From 43518d022b0cfb0662233103939aa8a657c5b6e0 Mon Sep 17 00:00:00 2001 From: ethan Date: Fri, 3 Jan 2025 16:13:18 -0500 Subject: [PATCH] 2.0 initial refactor --- README.md | 6 + __init__.py | 2 +- baker.py | 951 +++++++++++++++++++++++++++++++ blender_manifest.toml | 2 +- constants.py | 180 ++---- operators/core.py | 701 +++++++++++++++++++++++ operators/marmoset.py | 95 ++-- operators/material.py | 81 +-- operators/operators.py | 770 ------------------------- preferences.py | 552 +++++++----------- ui.py | 469 +++++++--------- utils/baker.py | 1205 ++++++---------------------------------- utils/generic.py | 180 +----- utils/io.py | 48 ++ utils/marmoset.py | 4 +- utils/node.py | 908 +++++++----------------------- utils/pack.py | 80 +++ utils/render.py | 55 +- utils/scene.py | 441 +++++++-------- 19 files changed, 2909 insertions(+), 3821 deletions(-) create mode 100644 baker.py create mode 100644 operators/core.py delete mode 100644 operators/operators.py create mode 100644 utils/io.py create mode 100644 utils/pack.py diff --git a/README.md b/README.md index 22507e6..53e1822 100644 --- a/README.md +++ b/README.md @@ -9,3 +9,9 @@ After installation, run the one-click scene setup and then start modeling! GrabD # Features Head to the [Gumroad](https://gumroad.com/l/grabdoc) page for a full feature list! + +# Support + +GrabDoc is a FOSS (Free and Open Source Software) project, which means I see very little return on my time spent developing and maintaining it. + +If you would like to support me, consider buying this add-on on [Gumroad](https://gumroad.com/l/grabdoc), or if you've already done that, send me a tip on [Ko-fi](https://ko-fi.com/razed)! diff --git a/__init__.py b/__init__.py index 78fc73a..1b244f7 100644 --- a/__init__.py +++ b/__init__.py @@ -2,7 +2,7 @@ module_names = ( - "operators.operators", + "operators.core", "operators.material", "operators.marmoset", "preferences", diff --git a/baker.py b/baker.py new file mode 100644 index 0000000..d422781 --- /dev/null +++ b/baker.py @@ -0,0 +1,951 @@ +import bpy +from bpy.types import PropertyGroup, UILayout, Context, NodeTree +from bpy.props import ( + BoolProperty, StringProperty, EnumProperty, + IntProperty, FloatProperty, PointerProperty, + CollectionProperty +) + +from .constants import Global +from .utils.scene import scene_setup +from .utils.node import ( + generate_shader_interface, get_group_inputs, get_material_output_sockets +) +from .utils.render import ( + set_guide_height, get_rendered_objects, set_color_management +) + + +class Baker(PropertyGroup): + """A Blender shader and render settings automation system with + efficient setup and clean up of desired render targets non-destructively. + + This system is designed for rapid development of new + bake map types with minimal unique implementation. + + The most minimal examples of subclasses require the following: + - Basic shader properties outlined (e.g. ID, Supported Engines, etc) + - BPY code for re-creating the desired shader / node group""" + ID = '' + NAME = ID.capitalize() + VIEW_TRANSFORM = 'Standard' + MARMOSET_COMPATIBLE = True + SUPPORTED_ENGINES = (('blender_eevee_next', "EEVEE", ""), + ('cycles', "Cycles", ""), + ('blender_workbench', "Workbench", "")) + + def __init__(self): + """Called when `bpy.ops.grab_doc.scene_setup` operator is ran. + + This method is NOT called when created via `CollectionProperty`.""" + self.index = self.get_unique_index(getattr(bpy.context.scene.gd, self.ID)) + + # NOTE: For properties using constants as defaults + self.__class__.suffix = StringProperty( + description="The suffix of the exported bake map", + name="Suffix", default=self.ID + ) + self.__class__.engine = EnumProperty( + name='Render Engine', + items=self.SUPPORTED_ENGINES, + update=self.__class__.apply_render_settings + ) + self.__class__.suffix = StringProperty( + description="The suffix of the exported bake map", + name="Suffix", default=self.ID + ) + self.__class__.enabled = BoolProperty( + name="Export Enabled", default=self.MARMOSET_COMPATIBLE + ) + + @staticmethod + def get_unique_index(collection: CollectionProperty) -> int: + """Get a unique index value based on a given `CollectionProperty`.""" + indices = [baker.index for baker in collection] + index = 0 + while True: + if index not in indices: + break + index += 1 + return index + + @staticmethod + def get_node_name(name: str, idx: int=0): + """Set node name based on given base `name` and optional `idx`.""" + node_name = Global.PREFIX + name.replace(" ", "") + if idx: + node_name += f"_{idx}" + return node_name + + def setup(self): + """General operations to run before bake export.""" + self.apply_render_settings(requires_preview=False) + + def node_setup(self): + """Shader logic to generate a node group.""" + if not self.node_name: + self.node_name = self.get_node_name(self.NAME) + self.node_tree = bpy.data.node_groups.get(self.node_name) + if self.node_tree is None: + self.node_tree = bpy.data.node_groups.new(self.node_name, 'ShaderNodeTree') + # NOTE: Optional alpha socket for pre-built bake map types + alpha = self.node_tree.interface.new_socket( + name='Alpha', socket_type='NodeSocketFloat' + ) + alpha.default_value = 1 + self.node_tree.use_fake_user = True + generate_shader_interface(self.node_tree, get_material_output_sockets()) + + def reimport_setup(self, material, image): + """Shader logic to link an imported image texture a generic BSDF.""" + links = material.node_tree.links + bsdf = material.node_tree.nodes['Principled BSDF'] + try: + links.new(bsdf.inputs[self.ID.capitalize()], image.outputs["Color"]) + except KeyError: + pass + + def cleanup(self): + """Operations to revert unique scene modifications after bake export.""" + + def apply_render_settings(self, requires_preview: bool=True) -> None: + """Apply global baker render and color management settings.""" + if requires_preview and not bpy.context.scene.gd.preview_state: + return + + scene = bpy.context.scene + render = scene.render + cycles = scene.cycles + if scene.gd.use_filtering and not self.disable_filtering: + render.filter_size = cycles.filter_width = scene.gd.filter_width + else: + render.filter_size = cycles.filter_width = .01 + + eevee = scene.eevee + display = scene.display + render.engine = str(self.engine).upper() + if render.engine == "blender_eevee_next".upper(): + eevee.taa_render_samples = eevee.taa_samples = self.samples + elif render.engine == 'CYCLES': + cycles.samples = cycles.preview_samples = self.samples_cycles + elif render.engine == 'BLENDER_WORKBENCH': + display.render_aa = display.viewport_aa = self.samples_workbench + + set_color_management(self.VIEW_TRANSFORM, + self.contrast.replace('_', ' ')) + + def draw_properties(self, context: Context, layout: UILayout): + """Dedicated layout for specific bake map properties.""" + + def draw(self, context: Context, layout: UILayout): + """Dropdown layout for bake map properties and operators.""" + layout.use_property_split = True + layout.use_property_decorate = False + col = layout.column() + + box = col.box() + box.label(text="Properties", icon="PROPERTIES") + row = box.row(align=True) + if len(self.SUPPORTED_ENGINES) < 2: + row.enabled = False + row.prop(self, 'engine') + + self.draw_properties(context, box) + + # TODO: Generated node group inputs + #box = col.box() + #box.label(text="Inputs", icon="MATSHADERBALL") + + box = col.box() + box.label(text="Settings", icon="SETTINGS") + col_set = box.column() + gd = context.scene.gd + if gd.engine == 'grabdoc': + col_set.prop(self, 'reimport') + col_set.prop(self, 'disable_filtering') + prop = 'samples' + if self.engine == 'blender_workbench': + prop = 'samples_workbench' + elif self.engine == 'cycles': + prop = 'samples_cycles' + col_set.prop(self, prop, text='Samples') + col_set.prop(self, 'contrast') + col_set.prop(self, 'suffix') + + socket_req = "Supports" + icon = 'INFO' + if not self.MARMOSET_COMPATIBLE: + info_box = col.box() + col2 = info_box.column(align=True) + col2.label(text="\u2022 Marmoset not supported", icon=icon) + socket_req = "Requires" + icon = 'BLANK1' + if self.node_tree: + inputs = get_group_inputs(self.node_tree) + if self.node_tree and inputs: + if 'info_box' not in locals(): + info_box = col.box() + col2 = info_box.column(align=True) + col2.label(text=f"\u2022 {socket_req} sockets:", icon=icon) + row = col2.row(align=True) + inputs_fmt = ", ".join([socket.name for socket in inputs]) + row.label(text=f" {inputs_fmt}", icon='BLANK1') + + col.separator(factor=.5) + row = col.row(align=True) + if not gd.preview_state: + row.operator("grab_doc.baker_add", text=f"Add {self.NAME} Map", + icon='ADD').map_type = self.ID + if self == getattr(gd, self.ID)[0]: + return + remove = row.operator("grab_doc.baker_remove", text="", icon='TRASH') + remove.map_type = self.ID + remove.baker_index = self.index + + # NOTE: Internal properties + index: IntProperty(default=-1) + node_name: StringProperty() + node_tree: PointerProperty(type=NodeTree) + + # NOTE: Default properties + reimport: BoolProperty( + description="Reimport bake map texture into a Blender material", + name="Re-import" + ) + visibility: BoolProperty( + description="Toggle UI visibility of this bake map", default=True + ) + disable_filtering: BoolProperty( + description="Override global filtering setting and set filter to .01px", + name="Override Filtering", default=False, update=apply_render_settings + ) + samples: IntProperty(name="EEVEE Samples", update=apply_render_settings, + default=32, min=1, soft_max=256) + samples_cycles: IntProperty(name="Cycles Samples", + update=apply_render_settings, + default=16, min=1, soft_max=256) + samples_workbench: EnumProperty( + items=(('OFF', "No Anti-Aliasing", ""), + ('FXAA', "1 Sample", ""), + ('5', "5 Samples", ""), + ('8', "8 Samples", ""), + ('11', "11 Samples", ""), + ('16', "16 Samples", ""), + ('32', "32 Samples", "")), + name="Workbench Samples", default="8", update=apply_render_settings + ) + contrast: EnumProperty( + items=(('None', "Default", ""), + ('Very_High_Contrast', "Very High", ""), + ('High_Contrast', "High", ""), + ('Medium_High_Contrast', "Medium High", ""), + ('Medium_Low_Contrast', "Medium Low", ""), + ('Low_Contrast', "Low", ""), + ('Very_Low_Contrast', "Very Low", "")), + name="Contrast", update=apply_render_settings + ) + + +class Normals(Baker): + ID = 'normals' + NAME = ID.capitalize() + VIEW_TRANSFORM = "Raw" + MARMOSET_COMPATIBLE = True + SUPPORTED_ENGINES = Baker.SUPPORTED_ENGINES[:-1] + + def node_setup(self): + super().node_setup() + + self.node_tree.interface.new_socket( + name='Normal', socket_type='NodeSocketVector' + ) + + group_output = self.node_tree.nodes.new('NodeGroupOutput') + group_input = self.node_tree.nodes.new('NodeGroupInput') + group_input.location = (-1200, 0) + + bevel = self.node_tree.nodes.new('ShaderNodeBevel') + bevel.inputs[0].default_value = 0 + bevel.location = (-1000, 0) + + vec_transform = self.node_tree.nodes.new('ShaderNodeVectorTransform') + vec_transform.vector_type = 'NORMAL' + vec_transform.convert_to = 'CAMERA' + vec_transform.location = (-800, 0) + + vec_mult = self.node_tree.nodes.new('ShaderNodeVectorMath') + vec_mult.operation = 'MULTIPLY' + vec_mult.inputs[1].default_value[0] = .5 + vec_mult.inputs[1].default_value[1] = -.5 if self.flip_y else .5 + vec_mult.inputs[1].default_value[2] = -.5 + vec_mult.location = (-600, 0) + + vec_add = self.node_tree.nodes.new('ShaderNodeVectorMath') + vec_add.inputs[1].default_value[0] = \ + vec_add.inputs[1].default_value[1] = \ + vec_add.inputs[1].default_value[2] = 0.5 + vec_add.location = (-400, 0) + + invert = self.node_tree.nodes.new('ShaderNodeInvert') + invert.location = (-1000, -300) + + subtract = self.node_tree.nodes.new('ShaderNodeMixRGB') + subtract.blend_type = 'SUBTRACT' + subtract.inputs[0].default_value = 1 + subtract.inputs[1].default_value = (1, 1, 1, 1) + subtract.location = (-800, -300) + + transp_shader = self.node_tree.nodes.new('ShaderNodeBsdfTransparent') + transp_shader.location = (-400, -400) + + mix_shader = self.node_tree.nodes.new('ShaderNodeMixShader') + mix_shader.location = (-200, -300) + + links = self.node_tree.links + links.new(bevel.inputs["Normal"], group_input.outputs["Normal"]) + links.new(vec_transform.inputs["Vector"], bevel.outputs["Normal"]) + links.new(vec_mult.inputs["Vector"], vec_transform.outputs["Vector"]) + links.new(vec_add.inputs["Vector"], vec_mult.outputs["Vector"]) + + links.new(invert.inputs['Color'], group_input.outputs['Alpha']) + links.new(subtract.inputs['Color2'], invert.outputs['Color']) + links.new(mix_shader.inputs['Fac'], subtract.outputs['Color']) + links.new(mix_shader.inputs[1], transp_shader.outputs['BSDF']) + links.new(mix_shader.inputs[2], vec_add.outputs['Vector']) + + # NOTE: Update if use_texture default changes + links.new(group_output.inputs["Shader"], mix_shader.outputs["Shader"]) + + def reimport_setup(self, material, image): + links = material.node_tree.links + bsdf = material.node_tree.nodes['Principled BSDF'] + normal = material.node_tree.nodes['Normal Map'] + if normal is None: + normal = material.node_tree.nodes.new('ShaderNodeNormalMap') + normal.hide = True + normal.location = (image.location[0] + 100, image.location[1]) + image.location = (image.location[0] - 200, image.location[1]) + links.new(normal.inputs["Color"], image.outputs["Color"]) + links.new(bsdf.inputs["Normal"], normal.outputs["Normal"]) + + def draw_properties(self, context: Context, layout: UILayout): + col = layout.column() + col.prop(self, 'flip_y') + if context.scene.gd.engine == 'grabdoc': + col.prop(self, 'use_texture') + if self.engine == 'cycles': + col.prop(self, 'bevel_weight') + + def update_flip_y(self, _context: Context): + vec_multiply = self.node_tree.nodes['Vector Math'] + vec_multiply.inputs[1].default_value[1] = -.5 if self.flip_y else .5 + + def update_use_texture(self, context: Context) -> None: + if not context.scene.gd.preview_state: + return + group_output = self.node_tree.nodes['Group Output'] + + links = self.node_tree.links + if self.use_texture: + links.new(group_output.inputs["Shader"], + self.node_tree.nodes['Mix Shader'].outputs["Shader"]) + return + links.new(group_output.inputs["Shader"], + self.node_tree.nodes['Vector Math.001'].outputs["Vector"]) + + def update_bevel_weight(self, _context: Context): + bevel = self.node_tree.nodes['Bevel'] + bevel.inputs[0].default_value = self.bevel_weight + + flip_y: BoolProperty( + description="Flip the normal map Y direction (DirectX format)", + name="Invert (-Y)", options={'SKIP_SAVE'}, update=update_flip_y + ) + use_texture: BoolProperty( + description="Use texture normals linked to the Principled BSDF", + name="Mix Textures", options={'SKIP_SAVE'}, + default=True, update=update_use_texture + ) + bevel_weight: FloatProperty( + description="Bevel shader weight (May need to increase samples)", + name="Bevel", options={'SKIP_SAVE'}, update=update_bevel_weight, + default=0, step=1, min=0, max=10, soft_max=1 + ) + + +class Curvature(Baker): + ID = 'curvature' + NAME = ID.capitalize() + VIEW_TRANSFORM = "Standard" + MARMOSET_COMPATIBLE = True + SUPPORTED_ENGINES = Baker.SUPPORTED_ENGINES[1:] + + def __init__(self): + super().__init__() + self.engine = self.SUPPORTED_ENGINES[-1][0] + + def setup(self) -> None: + super().setup() + scene = bpy.context.scene + scene_shading = scene.display.shading + scene_shading.light = 'FLAT' + scene_shading.color_type = 'SINGLE' + scene_shading.show_cavity = True + scene_shading.cavity_type = 'BOTH' + scene_shading.cavity_ridge_factor = \ + scene_shading.curvature_ridge_factor = self.ridge + scene_shading.curvature_valley_factor = self.valley + scene_shading.cavity_valley_factor = 0 + scene_shading.single_color = (.214041, .214041, .214041) + scene.display.matcap_ssao_distance = .075 + self.update_range(bpy.context) + + def node_setup(self): + super().node_setup() + + group_output = self.node_tree.nodes.new('NodeGroupOutput') + + geometry = self.node_tree.nodes.new('ShaderNodeNewGeometry') + geometry.location = (-800, 0) + + color_ramp = self.node_tree.nodes.new('ShaderNodeValToRGB') + color_ramp.color_ramp.elements.new(.5) + color_ramp.color_ramp.elements[0].position = 0.49 + color_ramp.color_ramp.elements[2].position = 0.51 + color_ramp.location = (-600, 0) + + emission = self.node_tree.nodes.new('ShaderNodeEmission') + emission.location = (-200, 0) + + links = self.node_tree.links + links.new(color_ramp.inputs["Fac"], geometry.outputs["Pointiness"]) + links.new(emission.inputs["Color"], color_ramp.outputs["Color"]) + links.new(group_output.inputs["Shader"], emission.outputs["Emission"]) + + def apply_render_settings(self, requires_preview: bool=True): + super().apply_render_settings(requires_preview) + scene = bpy.context.scene + view_transform = self.VIEW_TRANSFORM + if scene.render.engine != 'BLENDER_WORKBENCH': + view_transform = "Raw" + set_color_management(view_transform) + + def draw_properties(self, context: Context, layout: UILayout): + if context.scene.gd.engine != 'grabdoc': + return + col = layout.column() + if context.scene.render.engine == 'BLENDER_WORKBENCH': + col.prop(self, 'ridge', text="Ridge") + col.prop(self, 'valley', text="Valley") + elif context.scene.render.engine == 'CYCLES': + col.prop(self, 'range', text="Range") + + def cleanup(self) -> None: + bpy.data.objects[Global.BG_PLANE_NAME].color[3] = 1 + + def update_curvature(self, context: Context): + if not context.scene.gd.preview_state: + return + scene_shading = context.scene.display.shading + scene_shading.cavity_ridge_factor = \ + scene_shading.curvature_ridge_factor = self.ridge + scene_shading.curvature_valley_factor = self.valley + + def update_range(self, _context: Context): + color_ramp = self.node_tree.nodes['Color Ramp'] + color_ramp.color_ramp.elements[0].position = 0.49 - (self.range/2+.01) + color_ramp.color_ramp.elements[2].position = 0.51 + (self.range/2-.01) + + ridge: FloatProperty(name="", update=update_curvature, + default=2, min=0, max=2, precision=3, + step=.1, subtype='FACTOR') + valley: FloatProperty(name="", update=update_curvature, + default=1.5, min=0, max=2, precision=3, + step=.1, subtype='FACTOR') + range: FloatProperty(name="", update=update_range, + default=.05, min=0, max=1, step=.1, subtype='FACTOR') + + +class Occlusion(Baker): + ID = 'occlusion' + NAME = ID.capitalize() + VIEW_TRANSFORM = "Raw" + MARMOSET_COMPATIBLE = True + SUPPORTED_ENGINES = Baker.SUPPORTED_ENGINES[:-1] + + def setup(self) -> None: + super().setup() + scene = bpy.context.scene + + eevee = scene.eevee + if scene.render.engine == "blender_eevee_next".upper(): + eevee.use_gtao = True + # NOTE: Overscan helps with screenspace effects + eevee.use_overscan = True + eevee.overscan_size = 25 + + def node_setup(self): + super().node_setup() + + normal = self.node_tree.interface.new_socket( + name='Normal', socket_type='NodeSocketVector' + ) + normal.default_value = (0.5, 0.5, 1) + + group_input = self.node_tree.nodes.new('NodeGroupInput') + group_input.location = (-1000, 0) + + group_output = self.node_tree.nodes.new('NodeGroupOutput') + + ao = self.node_tree.nodes.new('ShaderNodeAmbientOcclusion') + ao.samples = 32 + ao.location = (-600, 0) + + gamma = self.node_tree.nodes.new('ShaderNodeGamma') + gamma.inputs[1].default_value = 1 + gamma.location = (-400, 0) + + emission = self.node_tree.nodes.new('ShaderNodeEmission') + emission.location = (-200, 0) + + links = self.node_tree.links + links.new(ao.inputs["Normal"], group_input.outputs["Normal"]) + links.new(gamma.inputs["Color"], ao.outputs["Color"]) + links.new(emission.inputs["Color"], gamma.outputs["Color"]) + links.new(group_output.inputs["Shader"], emission.outputs["Emission"]) + + def draw_properties(self, context: Context, layout: UILayout): + gd = context.scene.gd + col = layout.column() + if gd.engine == 'marmoset': + col.prop(gd, "mt_occlusion_samples", text="Ray Count") + return + col.prop(self, 'gamma', text="Intensity") + col.prop(self, 'distance', text="Distance") + + def update_gamma(self, _context: Context): + gamma = self.node_tree.nodes['Gamma'] + gamma.inputs[1].default_value = self.gamma + + def update_distance(self, _context: Context): + ao = self.node_tree.nodes['Ambient Occlusion'] + ao.inputs[1].default_value = self.distance + + gamma: FloatProperty( + description="Intensity of AO (calculated with gamma)", + default=1, min=.001, soft_max=10, step=.17, + name="", update=update_gamma + ) + distance: FloatProperty( + description="The distance AO rays travel", + default=1, min=0, soft_max=100, step=.03, subtype='DISTANCE', + name="", update=update_distance + ) + + +class Height(Baker): + ID = 'height' + NAME = ID.capitalize() + VIEW_TRANSFORM = "Raw" + MARMOSET_COMPATIBLE = True + SUPPORTED_ENGINES = Baker.SUPPORTED_ENGINES[:-1] + + def setup(self) -> None: + super().setup() + if self.method == 'AUTO': + rendered_obs = get_rendered_objects() + set_guide_height(rendered_obs) + + def node_setup(self): + super().node_setup() + + group_output = self.node_tree.nodes.new('NodeGroupOutput') + + camera = self.node_tree.nodes.new('ShaderNodeCameraData') + camera.location = (-800, 0) + + # NOTE: Map Range updates handled on map preview + map_range = self.node_tree.nodes.new('ShaderNodeMapRange') + map_range.location = (-600, 0) + + ramp = self.node_tree.nodes.new('ShaderNodeValToRGB') + ramp.color_ramp.elements[0].color = (1, 1, 1, 1) + ramp.color_ramp.elements[1].color = (0, 0, 0, 1) + ramp.location = (-400, 0) + + links = self.node_tree.links + links.new(map_range.inputs["Value"], camera.outputs["View Z Depth"]) + links.new(ramp.inputs["Fac"], map_range.outputs["Result"]) + links.new(group_output.inputs["Shader"], ramp.outputs["Color"]) + + def draw_properties(self, context: Context, layout: UILayout): + col = layout.column() + if context.scene.gd.engine == 'grabdoc': + col.prop(self, 'invert', text="Invert") + row = col.row() + row.prop(self, 'method', text="Method", expand=True) + if self.method == 'MANUAL': + col.prop(self, 'distance', text="0-1 Range") + + def update_method(self, context: Context): + scene_setup(self, context) + if not context.scene.gd.preview_state: + return + if self.method == 'AUTO': + rendered_obs = get_rendered_objects() + set_guide_height(rendered_obs) + + def update_guide(self, context: Context): + map_range = self.node_tree.nodes['Map Range'] + camera_object_z = Global.CAMERA_DISTANCE * bpy.context.scene.gd.scale + map_range.inputs[1].default_value = camera_object_z - self.distance + map_range.inputs[2].default_value = camera_object_z + + ramp = self.node_tree.nodes['Color Ramp'] + ramp.color_ramp.elements[0].color = \ + (0, 0, 0, 1) if self.invert else (1, 1, 1, 1) + ramp.color_ramp.elements[1].color = \ + (1, 1, 1, 1) if self.invert else (0, 0, 0, 1) + ramp.location = (-400, 0) + + if self.method == 'MANUAL': + scene_setup(self, context) + + invert: BoolProperty( + description="Invert height mask, useful for sculpting negatively", + update=update_guide + ) + distance: FloatProperty( + name="", update=update_guide, + default=1, min=.01, soft_max=100, step=.03, subtype='DISTANCE' + ) + method: EnumProperty( + description="Height method, use manual if auto produces range errors", + name="Method", update=update_method, + items=(('AUTO', "Auto", ""), + ('MANUAL', "Manual", "")) + ) + + +class Id(Baker): + ID = 'id' + NAME = "Material ID" + VIEW_TRANSFORM = "Standard" + MARMOSET_COMPATIBLE = True + SUPPORTED_ENGINES = (Baker.SUPPORTED_ENGINES[-1],) + + def setup(self) -> None: + super().setup() + if bpy.context.scene.render.engine == 'BLENDER_WORKBENCH': + self.update_method(bpy.context) + + def node_setup(self): + pass + + def draw_properties(self, context: Context, layout: UILayout): + gd = context.scene.gd + row = layout.row() + if gd.engine != 'marmoset': + row.prop(self, 'method') + if self.method != 'MATERIAL': + return + + col = layout.column(align=True) + col.separator(factor=.5) + col.scale_y = 1.1 + col.operator("grab_doc.quick_id_setup") + + row = col.row(align=True) + row.scale_y = .9 + row.label(text=" Remove:") + row.operator( + "grab_doc.remove_mats_by_name", + text='All' + ).name = Global.RANDOM_ID_PREFIX + + col = layout.column(align=True) + col.separator(factor=.5) + col.scale_y = 1.1 + col.operator("grab_doc.quick_id_selected") + + row = col.row(align=True) + row.scale_y = .9 + row.label(text=" Remove:") + row.operator("grab_doc.remove_mats_by_name", + text='All').name = Global.ID_PREFIX + row.operator("grab_doc.quick_remove_selected_mats", + text='Selected') + + def update_method(self, context: Context): + shading = context.scene.display.shading + shading.show_cavity = False + shading.light = 'FLAT' + shading.color_type = self.method + + method: EnumProperty( + items=(('MATERIAL', 'Material', ''), + ('SINGLE', 'Single', ''), + ('OBJECT', 'Object', ''), + ('RANDOM', 'Random', ''), + ('VERTEX', 'Vertex', ''), + ('TEXTURE', 'Texture', '')), + name="Method", update=update_method, default='RANDOM' + ) + +class Alpha(Baker): + ID = 'alpha' + NAME = ID.capitalize() + VIEW_TRANSFORM = "Raw" + MARMOSET_COMPATIBLE = True + SUPPORTED_ENGINES = Baker.SUPPORTED_ENGINES[:-1] + + def node_setup(self): + super().node_setup() + + group_output = self.node_tree.nodes.new('NodeGroupOutput') + group_input = self.node_tree.nodes.new('NodeGroupInput') + group_input.location = (-1000, 200) + + camera = self.node_tree.nodes.new('ShaderNodeCameraData') + camera.location = (-1000, 0) + + map_range = self.node_tree.nodes.new('ShaderNodeMapRange') + map_range.location = (-800, 0) + camera_object_z = Global.CAMERA_DISTANCE * bpy.context.scene.gd.scale + map_range.inputs[1].default_value = camera_object_z - .00001 + map_range.inputs[2].default_value = camera_object_z + + invert_mask = self.node_tree.nodes.new('ShaderNodeInvert') + invert_mask.name = "Invert Mask" + invert_mask.location = (-600, 200) + + invert_depth = self.node_tree.nodes.new('ShaderNodeInvert') + invert_depth.name = "Invert Depth" + invert_depth.location = (-600, 0) + + mix = self.node_tree.nodes.new('ShaderNodeMix') + mix.name = "Invert Mask" + mix.data_type = "RGBA" + mix.inputs["B"].default_value = (0, 0, 0, 1) + mix.location = (-400, 0) + + emission = self.node_tree.nodes.new('ShaderNodeEmission') + emission.location = (-200, 0) + + links = self.node_tree.links + links.new(invert_mask.inputs["Color"], group_input.outputs["Alpha"]) + links.new(mix.inputs["Factor"], invert_mask.outputs["Color"]) + + links.new(map_range.inputs["Value"], camera.outputs["View Z Depth"]) + links.new(invert_depth.inputs["Color"], map_range.outputs["Result"]) + links.new(mix.inputs["A"], invert_depth.outputs["Color"]) + + links.new(emission.inputs["Color"], mix.outputs["Result"]) + links.new(group_output.inputs["Shader"], emission.outputs["Emission"]) + + def draw_properties(self, context: Context, layout: UILayout): + if context.scene.gd.engine != 'grabdoc': + return + col = layout.column() + col.prop(self, 'invert_depth', text="Invert Depth") + col.prop(self, 'invert_mask', text="Invert Mask") + + def update_map_range(self, _context: Context): + map_range = self.node_tree.nodes['Map Range'] + camera_object_z = Global.CAMERA_DISTANCE * bpy.context.scene.gd.scale + map_range.inputs[1].default_value = camera_object_z - .00001 + map_range.inputs[2].default_value = camera_object_z + invert_depth = self.node_tree.nodes['Invert Depth'] + invert_depth.inputs[0].default_value = 0 if self.invert_depth else 1 + invert_mask = self.node_tree.nodes['Invert Mask'] + invert_mask.inputs[0].default_value = 0 if self.invert_mask else 1 + + invert_depth: BoolProperty( + description="Invert the global depth mask", update=update_map_range + ) + invert_mask: BoolProperty( + description="Invert the alpha mask", update=update_map_range + ) + + +class Roughness(Baker): + ID = 'roughness' + NAME = ID.capitalize() + VIEW_TRANSFORM = "Raw" + MARMOSET_COMPATIBLE = False + SUPPORTED_ENGINES = Baker.SUPPORTED_ENGINES[:-1] + + def node_setup(self): + super().node_setup() + + self.node_tree.interface.new_socket( + name=self.NAME, socket_type='NodeSocketFloat' + ) + + group_output = self.node_tree.nodes.new('NodeGroupOutput') + group_input = self.node_tree.nodes.new('NodeGroupInput') + group_input.location = (-600, 0) + + invert = self.node_tree.nodes.new('ShaderNodeInvert') + invert.location = (-400, 0) + invert.inputs[0].default_value = 0 + + emission = self.node_tree.nodes.new('ShaderNodeEmission') + emission.location = (-200, 0) + + links = self.node_tree.links + links.new(invert.inputs["Color"], group_input.outputs["Roughness"]) + links.new(emission.inputs["Color"], invert.outputs["Color"]) + links.new(group_output.inputs["Shader"], emission.outputs["Emission"]) + + def draw_properties(self, context: Context, layout: UILayout): + col = layout.column() + if context.scene.gd.engine == 'grabdoc': + col.prop(self, 'invert', text="Invert") + + def update_roughness(self, _context: Context): + invert = self.node_tree.nodes['Invert Color'] + invert.inputs[0].default_value = 1 if self.invert else 0 + + invert: BoolProperty(description="Invert the Roughness (AKA Glossiness)", + update=update_roughness) + + +class Color(Baker): + ID = 'color' + NAME = "Base Color" + VIEW_TRANSFORM = "Standard" + MARMOSET_COMPATIBLE = False + SUPPORTED_ENGINES = Baker.SUPPORTED_ENGINES[:-1] + + def node_setup(self): + super().node_setup() + + self.node_tree.interface.new_socket( + name=self.NAME, socket_type='NodeSocketColor' + ) + + group_output = self.node_tree.nodes.new('NodeGroupOutput') + group_input = self.node_tree.nodes.new('NodeGroupInput') + group_input.location = (-400, 0) + + emission = self.node_tree.nodes.new('ShaderNodeEmission') + emission.location = (-200, 0) + + links = self.node_tree.links + links.new(emission.inputs["Color"], group_input.outputs["Base Color"]) + links.new(group_output.inputs["Shader"], emission.outputs["Emission"]) + + def reimport_setup(self, material, image): + image.image.colorspace_settings.name = 'Non-Color' + bsdf = material.node_tree.nodes['Principled BSDF'] + links = material.node_tree.links + links.new(bsdf.inputs["Base Color"], image.outputs["Color"]) + + +class Emissive(Baker): + ID = 'emissive' + NAME = ID.capitalize() + VIEW_TRANSFORM = "Standard" + MARMOSET_COMPATIBLE = False + SUPPORTED_ENGINES = Baker.SUPPORTED_ENGINES[:-1] + + def node_setup(self): + super().node_setup() + + emit_color = self.node_tree.interface.new_socket( + name="Emission Color", socket_type='NodeSocketColor' + ) + emit_color.default_value = (0, 0, 0, 1) + emit_strength = self.node_tree.interface.new_socket( + name="Emission Strength", socket_type='NodeSocketFloat' + ) + emit_strength.default_value = 1 + + group_output = self.node_tree.nodes.new('NodeGroupOutput') + group_input = self.node_tree.nodes.new('NodeGroupInput') + group_input.location = (-600, 0) + + emission = self.node_tree.nodes.new('ShaderNodeEmission') + emission.location = (-200, 0) + + links = self.node_tree.links + links.new(emission.inputs["Color"], group_input.outputs["Emission Color"]) + links.new(group_output.inputs["Shader"], emission.outputs["Emission"]) + + def reimport_setup(self, material, image): + image.image.colorspace_settings.name = 'Non-Color' + bsdf = material.node_tree.nodes['Principled BSDF'] + links = material.node_tree.links + links.new(bsdf.inputs["Emission Color"], image.outputs["Color"]) + + +class Metallic(Baker): + ID = 'metallic' + NAME = ID.capitalize() + VIEW_TRANSFORM = "Raw" + MARMOSET_COMPATIBLE = False + SUPPORTED_ENGINES = Baker.SUPPORTED_ENGINES[:-1] + + def node_setup(self): + super().node_setup() + + self.node_tree.interface.new_socket( + name=self.NAME, socket_type='NodeSocketFloat' + ) + + group_output = self.node_tree.nodes.new('NodeGroupOutput') + group_input = self.node_tree.nodes.new('NodeGroupInput') + group_input.location = (-400, 0) + + emission = self.node_tree.nodes.new('ShaderNodeEmission') + emission.location = (-200, 0) + + links = self.node_tree.links + links.new(emission.inputs["Color"], group_input.outputs["Metallic"]) + links.new(group_output.inputs["Shader"], emission.outputs["Emission"]) + + def reimport_setup(self, material, image): + bsdf = material.node_tree.nodes['Principled BSDF'] + links = material.node_tree.links + links.new(bsdf.inputs["Metallic"], image.outputs["Color"]) + + +class Custom(Baker): + ID = 'custom' + NAME = ID.capitalize() + VIEW_TRANSFORM = "Raw" + MARMOSET_COMPATIBLE = False + SUPPORTED_ENGINES = Baker.SUPPORTED_ENGINES[:-1] + + def update_view_transform(self, _context: Context): + self.VIEW_TRANSFORM = self.view_transform.capitalize() + self.apply_render_settings() + + def draw_properties(self, context: Context, layout: UILayout): + col = layout.column() + row = col.row() + if context.scene.gd.preview_state: + row.enabled = False + if not isinstance(self.node_tree, NodeTree): + row.alert = True + row.prop(self, 'node_tree') + col.prop(self, 'view_transform') + + def node_setup(self, _context: Context=bpy.context): + if not isinstance(self.node_tree, NodeTree): + self.node_name = "" + return + self.node_name = self.node_tree.name + generate_shader_interface(self.node_tree, get_material_output_sockets()) + + # NOTE: Subclassed property - implement as user-facing + node_tree: PointerProperty( + description="Your baking shader, MUST have shader output", + name='Shader', type=NodeTree, update=node_setup + ) + view_transform: EnumProperty(items=(('raw', "Raw", ""), + ('standard', "Standard", "")), + name="View", + default=VIEW_TRANSFORM.lower(), + update=update_view_transform) diff --git a/blender_manifest.toml b/blender_manifest.toml index bc3a335..97d3441 100644 --- a/blender_manifest.toml +++ b/blender_manifest.toml @@ -1,7 +1,7 @@ schema_version = "1.0.0" id = "GrabDoc" -version = "1.4.7" +version = "2.0.0" name = "GrabDoc" tagline = "A trim & tileable baker for Blender" maintainer = "Ethan Simon-Law " diff --git a/constants.py b/constants.py index 9c25e25..b66c1a6 100644 --- a/constants.py +++ b/constants.py @@ -1,11 +1,12 @@ - class Global: """A collection of constants used for global variable standardization""" - PREFIX = "GD_" - FLAG_PREFIX = "[GrabDoc] " - LOW_SUFFIX = "_low_gd" - HIGH_SUFFIX = "_high_gd" + PREFIX = "GD_" + FLAG_PREFIX = "[GrabDoc] " + LOW_SUFFIX = "_low_gd" + HIGH_SUFFIX = "_high_gd" + NODE_GROUP_WARN_NAME = "_grabdoc_ng_warning" + REFERENCE_NAME = FLAG_PREFIX + "Reference" TRIM_CAMERA_NAME = FLAG_PREFIX + "Trim Camera" @@ -13,149 +14,46 @@ class Global: HEIGHT_GUIDE_NAME = FLAG_PREFIX + "Height Guide" ORIENT_GUIDE_NAME = FLAG_PREFIX + "Orient Guide" GD_MATERIAL_NAME = FLAG_PREFIX + "Material" - COLL_NAME = "GrabDoc Core" - COLL_OB_NAME = "GrabDoc Bake Group" - - ID_PREFIX = FLAG_PREFIX + "ID" - RANDOM_ID_PREFIX = FLAG_PREFIX + "RANDOM_ID" - + ID_PREFIX = FLAG_PREFIX + "ID" + RANDOM_ID_PREFIX = FLAG_PREFIX + "RANDOM_ID" REIMPORT_MAT_NAME = FLAG_PREFIX + "Bake Result" + COLL_CORE_NAME = FLAG_PREFIX + "Core" + COLL_GROUP_NAME = FLAG_PREFIX + "Bake Group" - NORMAL_ID = "normals" - CURVATURE_ID = "curvature" - OCCLUSION_ID = "occlusion" - HEIGHT_ID = "height" - MATERIAL_ID = "id" - ALPHA_ID = "alpha" - COLOR_ID = "color" - EMISSIVE_ID = "emissive" - ROUGHNESS_ID = "roughness" - METALLIC_ID = "metallic" - - NORMAL_NAME = NORMAL_ID.capitalize() - CURVATURE_NAME = CURVATURE_ID.capitalize() - OCCLUSION_NAME = "Ambient Occlusion" - HEIGHT_NAME = HEIGHT_ID.capitalize() - MATERIAL_NAME = "Material ID" - ALPHA_NAME = ALPHA_ID.capitalize() - COLOR_NAME = "Base Color" - EMISSIVE_NAME = EMISSIVE_ID.capitalize() - ROUGHNESS_NAME = ROUGHNESS_ID.capitalize() - METALLIC_NAME = METALLIC_ID.capitalize() - - NORMAL_NODE = PREFIX + NORMAL_NAME - CURVATURE_NODE = PREFIX + CURVATURE_NAME - OCCLUSION_NODE = PREFIX + OCCLUSION_NAME - HEIGHT_NODE = PREFIX + HEIGHT_NAME - ALPHA_NODE = PREFIX + ALPHA_NAME - COLOR_NODE = PREFIX + COLOR_NAME - EMISSIVE_NODE = PREFIX + EMISSIVE_NAME - ROUGHNESS_NODE = PREFIX + ROUGHNESS_NAME - METALLIC_NODE = PREFIX + METALLIC_NAME + CAMERA_DISTANCE = 15 - ALL_MAP_IDS = ( - NORMAL_ID, - CURVATURE_ID, - OCCLUSION_ID, - HEIGHT_ID, - MATERIAL_ID, - ALPHA_ID, - COLOR_ID, - EMISSIVE_ID, - ROUGHNESS_ID, - METALLIC_ID - ) + INVALID_BAKE_TYPES = ('EMPTY', + 'VOLUME', + 'ARMATURE', + 'LATTICE', + 'LIGHT', + 'LIGHT_PROBE', + 'CAMERA') - ALL_MAP_NAMES = ( - NORMAL_NAME, - CURVATURE_NAME, - OCCLUSION_NAME, - HEIGHT_NAME, - MATERIAL_NAME, - ALPHA_NAME, - COLOR_NAME, - EMISSIVE_NAME, - ROUGHNESS_NAME, - METALLIC_NAME - ) + IMAGE_FORMATS = {'TIFF': 'tif', + 'TARGA': 'tga', + 'OPEN_EXR': 'exr', + 'PNG': 'png'} - SHADER_MAP_NAMES = ( - NORMAL_NODE, - CURVATURE_NODE, - ALPHA_NODE, - COLOR_NODE, - EMISSIVE_NODE, - ROUGHNESS_NODE, - METALLIC_NODE, - OCCLUSION_NODE - ) + NODE_GROUP_WARN = \ +""" +This node is generated by GrabDoc! Once exiting Map Preview, +every node link will be returned to their original sockets. - INVALID_BAKE_TYPES = ( - 'EMPTY', - 'VOLUME', - 'ARMATURE', - 'LATTICE', - 'LIGHT', - 'LIGHT_PROBE', - 'CAMERA' - ) - - IMAGE_FORMATS = { - 'TIFF': 'tif', - 'TARGA': 'tga', - 'OPEN_EXR': 'exr', - 'PNG': 'png' - } - - NG_NODE_WARNING = \ -"""This is a passthrough node from GrabDoc, once you -Exit Map Preview every node link will be returned -to original positions. It's best not to touch the -contents of the node group (or material) but if you -do anyways it shouldn't be overwritten by GrabDoc -until the node group is removed from file, which -only happens when you use the `Remove Setup` operator.""" - - # TODO: Currently unused - PACK_MAPS_WARNING = \ -"""Map Packing is a feature for optimizing textures being exported -(usually directly to engine) by cramming grayscale baked maps into -each RGBA channel of a single texture reducing the amount of texture -samples used and by extension the memory footprint. This is meant to -be a simple alternative to pit-stopping over to compositing software -to finish the job but its usability is limited. - -Here's a few things to keep note of: -\u2022 Only grayscale bake maps can be packed -\u2022 Map packing isn't supported for Marmoset bakes -\u2022 The default packed channels don't represent the default bake -maps. Without intervention, the G, B, and A channels will be empty.""" - - PREVIEW_WARNING = \ -"""Material Preview allows you to visualize your bake maps in real-time! -\u2022 This feature is intended for previewing your materials before baking, NOT -\u2022 for working while inside a preview. Once finished, please exit previews -\u2022 to avoid scene altering changes. -\u2022 Pressing OK will dismiss this warning permanently for the project.""" +Avoid editing the contents of this node group. If you do make +changes, using the Remove Setup operator will overwrite any changes!""" class Error: """A collection of constants used for error code/message standardization""" - - NO_OBJECTS_SELECTED = "There are no objects selected" - TRIM_CAM_NOT_FOUND = \ - "GrabDoc Camera not found, please run the Refresh Scene operator" - NO_OBJECTS_BAKE_GROUPS = \ - "No objects found in bake collections" - NO_VALID_PATH_SET = \ - "There is no export path set" - MAT_SLOTS_WITHOUT_LINKS = \ - "Material slots were found without links, using default values" - MARMOSET_EXPORT_COMPLETE = \ - "Export completed! Opening Marmoset Toolbag..." - MARMOSET_REFRESH_COMPLETE = \ - "Models re-exported! Check Marmoset Toolbag" - OFFLINE_RENDER_COMPLETE = \ - "Offline render completed!" - EXPORT_COMPLETE = \ - "Export completed!" + NO_OBJECTS_SELECTED = "There are no objects selected" + BAKE_GROUPS_EMPTY = "No objects found in bake collections" + NO_VALID_PATH_SET = "There is no export path set" + ALL_MAPS_DISABLED = "No bake maps are enabled" + MARMOSET_EXPORT_COMPLETE = "Export completed! Opening Marmoset Toolbag..." + MARMOSET_REFRESH_COMPLETE = "Models re-exported! Switch to Marmoset Toolbag" + EXPORT_COMPLETE = "Export completed!" + CAMERA_NOT_FOUND = \ + "GrabDoc camera not found, please run the Refresh Scene operator" + MISSING_SLOT_LINKS = \ + "Material slots missing necessary node links, using default values" diff --git a/operators/core.py b/operators/core.py new file mode 100644 index 0000000..473d096 --- /dev/null +++ b/operators/core.py @@ -0,0 +1,701 @@ +import os +import time + +import bpy +import blf +from bpy.types import SpaceView3D, Event, Context, Operator, UILayout +from bpy.props import StringProperty, IntProperty + +from ..constants import Global, Error +from ..ui import register_baker_panels +from ..utils.render import get_rendered_objects +from ..utils.generic import get_user_preferences +from ..utils.node import link_group_to_object, node_cleanup +from ..utils.io import export_plane, get_format, get_temp_path +from ..utils.scene import ( + camera_in_3d_view, is_scene_valid, scene_setup, scene_cleanup, validate_scene +) +from ..utils.baker import ( + get_baker_collections, reimport_baker_textures, baker_setup, + baker_cleanup, get_bakers, get_baker_by_index +) +from ..utils.pack import ( + get_channel_path, pack_image_channels, is_pack_maps_enabled +) + + +class GRABDOC_OT_load_reference(Operator): + """Import a reference onto the background plane""" + bl_idname = "grab_doc.load_reference" + bl_label = "Load Reference" + bl_options = {'REGISTER', 'UNDO'} + + filepath: StringProperty(subtype="FILE_PATH") + + def execute(self, context: Context): + bpy.data.images.load(self.filepath, check_existing=True) + path = os.path.basename(os.path.normpath(self.filepath)) + context.scene.gd.reference = bpy.data.images[path] + return {'FINISHED'} + + def invoke(self, context: Context, _event: Event): + context.window_manager.fileselect_add(self) + return {'RUNNING_MODAL'} + + +class GRABDOC_OT_open_folder(Operator): + """Opens up the File Explorer to the designated folder location""" + bl_idname = "grab_doc.open_folder" + bl_label = "Open Export Folder" + bl_options = {'REGISTER', 'UNDO'} + + def execute(self, context: Context): + try: + bpy.ops.wm.path_open(filepath=context.scene.gd.filepath) + except RuntimeError: + self.report({'ERROR'}, Error.NO_VALID_PATH_SET) + return {'CANCELLED'} + return {'FINISHED'} + + +class GRABDOC_OT_toggle_camera_view(Operator): + """View or leave the GrabDoc camera view""" + bl_idname = "grab_doc.toggle_camera_view" + bl_label = "Toggle Camera View" + + def execute(self, context: Context): + context.scene.camera = bpy.data.objects[Global.TRIM_CAMERA_NAME] + bpy.ops.view3d.view_camera() + return {'FINISHED'} + + +class GRABDOC_OT_scene_setup(Operator): + """Setup or rebuild GrabDoc in your current scene. + +Useful for rare cases where GrabDoc isn't compatible with an existing setup. + +Can also potentially fix console spam from UI elements""" + bl_idname = "grab_doc.scene_setup" + bl_label = "Setup GrabDoc Scene" + bl_options = {'REGISTER', 'INTERNAL'} + + @classmethod + def poll(cls, context: Context) -> bool: + return GRABDOC_OT_baker_export_single.poll(context) + + def execute(self, context: Context): + for baker_prop in get_baker_collections(): + baker_prop.clear() + baker = baker_prop.add() + baker.__init__() # pylint: disable=C2801 + + register_baker_panels() + scene_setup(self, context) + + for baker in get_bakers(): + baker.node_setup() + return {'FINISHED'} + + +class GRABDOC_OT_scene_cleanup(Operator): + """Remove all GrabDoc objects from the scene; keeps reimported textures""" + bl_idname = "grab_doc.scene_cleanup" + bl_label = "Remove GrabDoc Scene" + bl_options = {'REGISTER', 'INTERNAL'} + + @classmethod + def poll(cls, context: Context) -> bool: + return GRABDOC_OT_baker_export_single.poll(context) + + def execute(self, context: Context): + scene_cleanup(context) + return {'FINISHED'} + + +class GRABDOC_OT_baker_add(Operator): + """Add a new baker of this type to the current scene""" + bl_idname = "grab_doc.baker_add" + bl_label = "Add Bake Map" + bl_options = {'REGISTER', 'UNDO', 'INTERNAL'} + + map_type: StringProperty() + + def execute(self, context: Context): + baker_prop = getattr(context.scene.gd, self.map_type) + baker = baker_prop.add() + baker.__init__() # pylint: disable=C2801 + register_baker_panels() + baker.node_setup() + return {'FINISHED'} + + +class GRABDOC_OT_baker_remove(Operator): + """Remove the current baker from the current scene""" + bl_idname = "grab_doc.baker_remove" + bl_label = "Remove Baker" + bl_options = {'REGISTER', 'UNDO', 'INTERNAL'} + + map_type: StringProperty() + baker_index: IntProperty() + + def execute(self, context: Context): + baker_prop = getattr(context.scene.gd, self.map_type) + baker = get_baker_by_index(baker_prop, self.baker_index) + if baker.node_tree: + bpy.data.node_groups.remove(baker.node_tree) + baker_prop.remove(self.baker_index) + register_baker_panels() + return {'FINISHED'} + + +class GRABDOC_OT_baker_export(Operator, UILayout): + """Bake and export all enabled bake maps""" + bl_idname = "grab_doc.baker_export" + bl_label = "Export Maps" + bl_options = {'REGISTER', 'INTERNAL'} + + progress_factor = 0.0 + + @classmethod + def poll(cls, context: Context) -> bool: + gd = context.scene.gd + if gd.filepath == "//" and not bpy.data.filepath: + cls.poll_message_set("Relative export path but file not saved") + return False + if gd.preview_state: + cls.poll_message_set("Cannot run while in Map Preview") + return False + return True + + @staticmethod + def export(context: Context, suffix: str, path: str = None) -> str: + gd = context.scene.gd + render = context.scene.render + saved_path = render.filepath + + name = f"{gd.filename}_{suffix}" + if path is None: + path = gd.filepath + path = os.path.join(path, name + get_format()) + render.filepath = path + + context.scene.camera = bpy.data.objects[Global.TRIM_CAMERA_NAME] + + bpy.ops.render.render(write_still=True) + render.filepath = saved_path + return path + + def execute(self, context: Context): + gd = context.scene.gd + report_value, report_string = validate_scene(context) + if report_value: + self.report({'ERROR'}, report_string) + return {'CANCELLED'} + bakers = get_bakers(filter_enabled=True) + if not bakers: + self.report({'ERROR'}, Error.ALL_MAPS_DISABLED) + return {'CANCELLED'} + if gd.use_pack_maps is True and not is_pack_maps_enabled(): + self.report( + {'ERROR'}, + "Map packing enabled but incorrect export maps enabled" + ) + return {'CANCELLED'} + + self.map_type = 'export' + + start = time.time() + context.window_manager.progress_begin(0, 9999) + completion_step = 100 / (1 + len(bakers)) + completion_percent = 0 + + saved_properties = baker_setup(context) + + active_selected = False + if context.object: + activeCallback = context.object.name + modeCallback = context.object.mode + if bpy.ops.object.mode_set.poll(): + bpy.ops.object.mode_set(mode='OBJECT') + active_selected = True + + # Scale up BG Plane (helps overscan & border pixels) + plane_ob = bpy.data.objects[Global.BG_PLANE_NAME] + plane_ob.scale[0] = plane_ob.scale[1] = 3 + + rendered_objects = get_rendered_objects() + for baker in bakers: + baker.setup() + if not baker.node_tree: + continue + for ob in rendered_objects: + unlinked = link_group_to_object(ob, baker.node_tree) + if unlinked: + self.report( + {'WARNING'}, + f"{ob.name}: {Error.MISSING_SLOT_LINKS}" + ) + + self.export(context, baker.suffix) + baker.cleanup() + if baker.node_tree: + node_cleanup(baker.node_tree) + + completion_percent += completion_step + context.window_manager.progress_update(completion_percent) + + # Reimport textures to render result material + bakers_to_reimport = [baker for baker in bakers if baker.reimport] + reimport_baker_textures(bakers_to_reimport) + + # Refresh all original settings + baker_cleanup(context, saved_properties) + + plane_ob = bpy.data.objects[Global.BG_PLANE_NAME] + plane_ob.scale[0] = plane_ob.scale[1] = 1 + + if gd.export_plane: + export_plane(context) + + if active_selected: + context.view_layer.objects.active = bpy.data.objects[activeCallback] + if bpy.ops.object.mode_set.poll(): + bpy.ops.object.mode_set(mode=modeCallback) + + elapsed = round(time.time() - start, 2) + self.report( + {'INFO'}, f"{Error.EXPORT_COMPLETE} (execution time: {elapsed}s)" + ) + context.window_manager.progress_end() + + if gd.use_pack_maps is True: + bpy.ops.grab_doc.baker_pack() + return {'FINISHED'} + + +class GRABDOC_OT_baker_export_single(Operator): + """Render the selected bake map and preview it within Blender. + +Rendering a second time will overwrite the internal image""" + bl_idname = "grab_doc.baker_export_single" + bl_label = "" + bl_options = {'REGISTER', 'INTERNAL'} + + map_type: StringProperty() + baker_index: IntProperty() + + @classmethod + def poll(cls, context: Context) -> bool: + if context.scene.gd.preview_state: + cls.poll_message_set("Cannot do this while in Map Preview") + return False + return True + + def open_render_image(self, filepath: str): + bpy.ops.screen.userpref_show("INVOKE_DEFAULT") + area = bpy.context.window_manager.windows[-1].screen.areas[0] + area.type = "IMAGE_EDITOR" + area.spaces.active.image = bpy.data.images.load( + filepath, check_existing=True + ) + + def execute(self, context: Context): + report_value, report_string = validate_scene(context, False) + if report_value: + self.report({'ERROR'}, report_string) + return {'CANCELLED'} + + start = time.time() + + activeCallback = None + if context.object: + activeCallback = context.object.name + modeCallback = context.object.mode + if bpy.ops.object.mode_set.poll(): + bpy.ops.object.mode_set(mode='OBJECT') + + plane_ob = bpy.data.objects[Global.BG_PLANE_NAME] + plane_ob.scale[0] = plane_ob.scale[1] = 3 + + saved_properties = baker_setup(context) + + gd = context.scene.gd + self.baker = getattr(gd, self.map_type)[self.baker_index] + self.baker.setup() + if self.baker.node_tree: + for ob in get_rendered_objects(): + unlinked = link_group_to_object(ob, self.baker.node_tree) + if not unlinked: + continue + self.report( + {'WARNING'}, f"{ob.name}: {Error.MISSING_SLOT_LINKS}" + ) + path = GRABDOC_OT_baker_export.export( + context, self.baker.suffix, path=get_temp_path() + ) + self.open_render_image(path) + self.baker.cleanup() + if self.baker.node_tree: + node_cleanup(self.baker.node_tree) + + # Reimport textures to render result material + if self.baker.reimport: + reimport_baker_textures([self.baker]) + + baker_cleanup(context, saved_properties) + + plane_ob = bpy.data.objects[Global.BG_PLANE_NAME] + plane_ob.scale[0] = plane_ob.scale[1] = 1 + + if activeCallback: + context.view_layer.objects.active = bpy.data.objects[activeCallback] + # NOTE: Also helps refresh viewport + if bpy.ops.object.mode_set.poll(): + bpy.ops.object.mode_set(mode=modeCallback) + + elapsed = round(time.time() - start, 2) + self.report( + {'INFO'}, f"{Error.EXPORT_COMPLETE} (execute time: {elapsed}s)" + ) + return {'FINISHED'} + + +class GRABDOC_OT_baker_preview_exit(Operator): + """Exit the current Map Preview""" + bl_idname = "grab_doc.baker_preview_exit" + bl_label = "Exit Map Preview" + bl_options = {'REGISTER', 'INTERNAL'} + + def execute(self, context: Context): + context.scene.gd.preview_state = False + return {'FINISHED'} + + +# NOTE: This needs to be outside of the class due +# to how `draw_handler_add` handles args +def draw_callback_px(self, context: Context) -> None: + font_id = 0 + font_size = 25 + font_opacity = .8 + font_x_pos = 15 + font_y_pos = 80 + font_pos_offset = 50 + + # NOTE: Handle small viewports + for area in context.screen.areas: + if area.type != 'VIEW_3D': + continue + for region in area.regions: + if region.type != 'WINDOW': + continue + if region.width <= 700 or region.height <= 400: + font_size *= .5 + font_pos_offset *= .5 + break + + blf.enable(font_id, 4) + blf.shadow(font_id, 0, *(0, 0, 0, font_opacity)) + blf.position(font_id, font_x_pos, (font_y_pos + font_pos_offset), 0) + blf.size(font_id, font_size) + blf.color(font_id, *(1, 1, 1, font_opacity)) + render_text = f"{self.preview_name.capitalize()} Preview" + if self.disable_binds: + render_text += " | [ESC] to exit" + blf.draw(font_id, render_text) + blf.position(font_id, font_x_pos, font_y_pos, 0) + blf.size(font_id, font_size+1) + blf.color(font_id, *(1, 1, 0, font_opacity)) + blf.draw(font_id, "You are in Map Preview mode!") + + +class GRABDOC_OT_baker_preview(Operator): + """Preview the selected bake map type""" + bl_idname = "grab_doc.baker_preview" + bl_label = "" + bl_options = {'REGISTER', 'INTERNAL'} + + baker_index: IntProperty() + map_type: StringProperty() + last_ob_amount: int = 0 + disable_binds: bool = False + + def modal(self, context: Context, event: Event): + scene = context.scene + gd = scene.gd + + # Format + # NOTE: Set alpha channel if background plane not visible in render + image_settings = scene.render.image_settings + if not gd.coll_rendered: + scene.render.film_transparent = True + image_settings.color_mode = 'RGBA' + else: + scene.render.film_transparent = False + image_settings.color_mode = 'RGB' + + image_settings.file_format = gd.format + if gd.format == 'OPEN_EXR': + image_settings.color_depth = gd.exr_depth + elif gd.format != 'TARGA': + image_settings.color_depth = gd.depth + + # Handle newly added object materials + ob_amount = len(bpy.data.objects) + if ob_amount > self.last_ob_amount and self.baker.node_tree: + link_group_to_object(context.object, self.baker.node_tree) + self.last_ob_amount = ob_amount + + # Exit check + if not gd.preview_state \ + or (event.type in {'ESC'} and self.disable_binds) \ + or not is_scene_valid(): + self.cleanup(context) + return {'CANCELLED'} + return {'PASS_THROUGH'} + + def cleanup(self, context: Context) -> None: + gd = context.scene.gd + gd.preview_state = False + + SpaceView3D.draw_handler_remove(self._handle, 'WINDOW') + + self.baker.cleanup() + if self.baker.node_tree: + node_cleanup(self.baker.node_tree) + baker_cleanup(context, self.saved_properties) + + for screens in self.original_workspace.screens: + for area in screens.areas: + if area.type != 'VIEW_3D': + continue + for space in area.spaces: + space.shading.type = self.saved_render_view + break + + if self.user_preferences.exit_camera_preview and is_scene_valid(): + context.scene.camera = bpy.data.objects[Global.TRIM_CAMERA_NAME] + bpy.ops.view3d.view_camera() + + def execute(self, context: Context): + report_value, report_string = validate_scene(context, False) + if report_value: + self.report({'ERROR'}, report_string) + return {'CANCELLED'} + + self.user_preferences = get_user_preferences() + + gd = context.scene.gd + self.saved_properties = baker_setup(context) + # TODO: Necessary to save? + self.saved_properties['bpy.context.scene.gd.engine'] = gd.engine + + gd.preview_state = True + gd.preview_map_type = self.map_type + gd.engine = 'grabdoc' + + self.last_ob_amount = len(bpy.data.objects) + self.disable_binds = not self.user_preferences.disable_preview_binds + + if bpy.ops.object.mode_set.poll(): + bpy.ops.object.mode_set(mode='OBJECT') + + self.original_workspace = context.workspace + for area in context.screen.areas: + if area.type == 'VIEW_3D': + for space in area.spaces: + self.saved_render_view = space.shading.type + space.shading.type = 'RENDERED' + break + + context.scene.camera = bpy.data.objects[Global.TRIM_CAMERA_NAME] + if not camera_in_3d_view(): + bpy.ops.view3d.view_camera() + + gd.preview_index = self.baker_index + baker_prop = getattr(gd, self.map_type) + self.baker = get_baker_by_index(baker_prop, self.baker_index) + self.baker.setup() + if self.baker.node_tree: + rendered_objects = get_rendered_objects() + for ob in rendered_objects: + unlinked = link_group_to_object(ob, self.baker.node_tree) + if not unlinked: + continue + self.report( + {'WARNING'}, f"{ob.name}: {Error.MISSING_SLOT_LINKS}" + ) + + self.preview_name = self.map_type + if self.baker.ID == 'custom': + self.preview_name = self.baker.suffix.capitalize() + self._handle = SpaceView3D.draw_handler_add( # pylint: disable=E1120 + draw_callback_px, (self, context), 'WINDOW', 'POST_PIXEL' + ) + context.window_manager.modal_handler_add(self) + return {'RUNNING_MODAL'} + + +class GRABDOC_OT_baker_preview_export(Operator): + """Export the currently previewed material""" + bl_idname = "grab_doc.baker_export_preview" + bl_label = "Export Preview" + bl_options = {'REGISTER'} + + baker_index: IntProperty() + + @classmethod + def poll(cls, context: Context) -> bool: + return not GRABDOC_OT_baker_export_single.poll(context) + + def execute(self, context: Context): + report_value, report_string = validate_scene(context) + if report_value: + self.report({'ERROR'}, report_string) + return {'CANCELLED'} + + start = time.time() + + # NOTE: Manual plane scale to account for overscan + plane_ob = bpy.data.objects[Global.BG_PLANE_NAME] + scale_plane = False + if plane_ob.scale[0] == 1: + scale_plane = True + plane_ob.scale[0] = plane_ob.scale[1] = 3 + + gd = context.scene.gd + baker = getattr(gd, gd.preview_map_type)[self.baker_index] + + GRABDOC_OT_baker_export.export(context, baker.suffix) + if baker.reimport: + reimport_baker_textures([baker]) + + if scale_plane: + plane_ob.scale[0] = plane_ob.scale[1] = 1 + + if gd.export_plane: + export_plane(context) + + elapsed = round(time.time() - start, 2) + self.report( + {'INFO'}, f"{Error.EXPORT_COMPLETE} (execution time: {elapsed}s)" + ) + return {'FINISHED'} + + +class GRABDOC_OT_baker_visibility(Operator): + """Configure bake map UI visibility, will also disable baking""" + bl_idname = "grab_doc.baker_visibility" + bl_label = "Configure Baker Visibility" + bl_options = {'REGISTER'} + + @classmethod + def poll(cls, context: Context) -> bool: + return GRABDOC_OT_baker_export_single.poll(context) + + def execute(self, _context: Context): + return {'FINISHED'} + + def invoke(self, context: Context, _event: Event): + return context.window_manager.invoke_props_dialog(self, width=200) + + def draw(self, _context: Context): + col = self.layout.column(align=True) + for baker in get_bakers(): + icon = "BLENDER" if not baker.MARMOSET_COMPATIBLE else "WORLD" + col.prop(baker, 'visibility', text=baker.NAME, icon=icon) + + +class GRABDOC_OT_baker_pack(Operator): + """Merge previously exported bake maps into single packed texture""" + bl_idname = "grab_doc.baker_pack" + bl_label = "Run Pack" + bl_options = {'REGISTER'} + + @classmethod + def poll(cls, context: Context) -> bool: + gd = context.scene.gd + r = get_channel_path(gd.channel_r) + g = get_channel_path(gd.channel_g) + b = get_channel_path(gd.channel_b) + a = get_channel_path(gd.channel_a) + if not all((r, g, b)): + cls.poll_message_set("No bake maps exported yet") + return False + if gd.channel_a != 'none' and a is None: + cls.poll_message_set("The A channel set but texture not exported") + return False + return True + + def execute(self, context: Context): + gd = context.scene.gd + pack_name = gd.filename + "_" + gd.pack_name + path = gd.filepath + + # Loads all images into blender to avoid using a + # separate python module to convert to np array + image_r = bpy.data.images.load(get_channel_path(gd.channel_r)) + image_g = bpy.data.images.load(get_channel_path(gd.channel_g)) + image_b = bpy.data.images.load(get_channel_path(gd.channel_b)) + pack_order = [(image_r, (0, 0)), + (image_g, (0, 1)), + (image_b, (0, 2))] + if gd.channel_a != 'none': + image_a = bpy.data.images.load(get_channel_path(gd.channel_a)) + pack_order.append((image_a, (0, 3))) + + dst_image = pack_image_channels(pack_order, pack_name) + dst_image.filepath_raw = path+"//"+pack_name+get_format() + dst_image.file_format = gd.format + dst_image.save() + + # Remove images from blend file to keep it clean + bpy.data.images.remove(image_r) + bpy.data.images.remove(image_g) + bpy.data.images.remove(image_b) + if gd.channel_a != 'none': + bpy.data.images.remove(image_a) + bpy.data.images.remove(dst_image) + + # Option to delete the extra maps through the operator panel + if gd.remove_original_maps is True: + if os.path.exists(get_channel_path(gd.channel_r)): + os.remove(get_channel_path(gd.channel_r)) + if os.path.exists(get_channel_path(gd.channel_g)): + os.remove(get_channel_path(gd.channel_g)) + if os.path.exists(get_channel_path(gd.channel_b)): + os.remove(get_channel_path(gd.channel_b)) + if gd.channel_a != 'none' and \ + os.path.exists(get_channel_path(gd.channel_a)): + os.remove(get_channel_path(gd.channel_a)) + return {'FINISHED'} + + +################################################ +# REGISTRATION +################################################ + + +classes = ( + GRABDOC_OT_load_reference, + GRABDOC_OT_open_folder, + GRABDOC_OT_toggle_camera_view, + GRABDOC_OT_scene_setup, + GRABDOC_OT_scene_cleanup, + GRABDOC_OT_baker_add, + GRABDOC_OT_baker_remove, + GRABDOC_OT_baker_export, + GRABDOC_OT_baker_export_single, + GRABDOC_OT_baker_preview, + GRABDOC_OT_baker_preview_exit, + GRABDOC_OT_baker_preview_export, + GRABDOC_OT_baker_visibility, + GRABDOC_OT_baker_pack +) + +def register(): + for cls in classes: + bpy.utils.register_class(cls) + +def unregister(): + for cls in classes: + bpy.utils.unregister_class(cls) diff --git a/operators/marmoset.py b/operators/marmoset.py index b5c5605..2bec427 100644 --- a/operators/marmoset.py +++ b/operators/marmoset.py @@ -6,82 +6,69 @@ import bpy from bpy.types import Context, Operator, Object +from ..utils.io import export_plane, get_temp_path from ..constants import Global, Error -from ..utils.generic import ( - OpInfo, - bad_setup_check, - export_plane, - get_temp_path -) +from ..utils.generic import get_user_preferences +from ..utils.baker import get_bakers +from ..utils.scene import validate_scene from ..utils.render import set_guide_height, get_rendered_objects -################################################ -# MARMOSET EXPORTER -################################################ - - -class GrabDoc_OT_send_to_marmo(OpInfo, Operator): - """Export your models, open & bake the enabled maps in Marmoset Toolbag""" - bl_idname = "grab_doc.bake_marmoset" - bl_label = "Open / Refresh in Marmoset" +class GrabDoc_OT_send_to_marmo(Operator): + """Export your models, open and bake the enabled maps in Marmoset Toolbag""" + bl_idname = "grab_doc.bake_marmoset" + bl_label = "Open / Refresh in Marmoset" + bl_options = {'REGISTER', 'INTERNAL'} - send_type: bpy.props.EnumProperty( - items=( - ('open',"Open",""), - ('refresh', "Refresh", "") - ), - options={'HIDDEN'} - ) + send_type: bpy.props.EnumProperty(items=(('open', "Open", ""), + ('refresh', "Refresh", "")), + options={'HIDDEN'}) @classmethod - def poll(cls, context: Context) -> bool: - package = __package__.rsplit(".", maxsplit=1)[0] - return os.path.exists( - context.preferences.addons[package].preferences.marmo_executable - ) + def poll(cls, _context: Context) -> bool: + return os.path.exists(get_user_preferences().mt_executable) def open_marmoset(self, context: Context, temp_path, addon_path): - package = __package__.rsplit(".", maxsplit=1)[0] - preferences = context.preferences.addons[package].preferences - executable = preferences.marmo_executable + executable = get_user_preferences().mt_executable # Create a dictionary of variables to transfer into Marmoset gd = context.scene.gd properties = { - 'file_path': f'{bpy.path.abspath(gd.export_path)}{gd.export_name}.{gd.marmo_format.lower()}', - 'format': gd.marmo_format.lower(), - 'export_path': bpy.path.abspath(gd.export_path), - 'hdri_path': f'{os.path.dirname(executable)}\\data\\sky\\Evening Clouds.tbsky', - - 'resolution_x': gd.resolution_x, - 'resolution_y': gd.resolution_y, + 'file_path': \ + f'{gd.filepath}{gd.filename}.{gd.mt_format.lower()}', + 'format': gd.mt_format.lower(), + 'filepath': bpy.path.abspath(gd.filepath), + 'hdri_path': \ + f'{os.path.dirname(executable)}\\data\\sky\\Evening Clouds.tbsky', + + 'resolution_x': gd.resolution_x, + 'resolution_y': gd.resolution_y, 'bits_per_channel': int(gd.depth), - 'samples': int(gd.marmo_samples), + 'samples': int(gd.mt_samples), - 'auto_bake': gd.marmo_auto_bake, - 'close_after_bake': gd.marmo_auto_close, + 'auto_bake': gd.mt_auto_bake, + 'close_after_bake': gd.mt_auto_close, - 'export_normal': gd.normals[0].enabled & gd.normals[0].visibility, - 'flipy_normal': gd.normals[0].flip_y, + 'export_normal': gd.normals[0].enabled and gd.normals[0].visibility, + 'flipy_normal': gd.normals[0].flip_y, 'suffix_normal': gd.normals[0].suffix, - 'export_curvature': gd.curvature[0].enabled & gd.curvature[0].visibility, + 'export_curvature': gd.curvature[0].enabled and gd.curvature[0].visibility, 'suffix_curvature': gd.curvature[0].suffix, - 'export_occlusion': gd.occlusion[0].enabled & gd.occlusion[0].visibility, - 'ray_count_occlusion': gd.marmo_occlusion_ray_count, + 'export_occlusion': gd.occlusion[0].enabled and gd.occlusion[0].visibility, + 'ray_count_occlusion': gd.mt_occlusion_samples, 'suffix_occlusion': gd.occlusion[0].suffix, - 'export_height': gd.height[0].enabled & gd.height[0].visibility, + 'export_height': gd.height[0].enabled and gd.height[0].visibility, 'cage_height': gd.height[0].distance * 100 * 2, 'suffix_height': gd.height[0].suffix, - 'export_alpha': gd.alpha[0].enabled & gd.alpha[0].visibility, + 'export_alpha': gd.alpha[0].enabled and gd.alpha[0].visibility, 'suffix_alpha': gd.alpha[0].suffix, - 'export_matid': gd.id[0].enabled & gd.id[0].visibility, + 'export_matid': gd.id[0].enabled and gd.id[0].visibility, 'suffix_id': gd.id[0].suffix } @@ -93,7 +80,7 @@ def open_marmoset(self, context: Context, temp_path, addon_path): break json_properties = json.dumps(properties, indent=4) - json_path = os.path.join(temp_path, "marmo_vars.json") + json_path = os.path.join(temp_path, "mt_vars.json") with open(json_path, "w", encoding='utf-8') as file: file.write(json_properties) @@ -121,11 +108,13 @@ def open_marmoset(self, context: Context, temp_path, addon_path): return {'FINISHED'} def execute(self, context: Context): - report_value, report_string = \ - bad_setup_check(context, active_export=True) + report_value, report_string = validate_scene(context) if report_value: self.report({'ERROR'}, report_string) return {'CANCELLED'} + if not get_bakers(filter_enabled=True): + self.report({'ERROR'}, Error.ALL_MAPS_DISABLED) + return {'CANCELLED'} saved_selected = context.view_layer.objects.selected.keys() @@ -151,7 +140,7 @@ def execute(self, context: Context): # Get background plane low and high poly plane_low: Object = bpy.data.objects.get(Global.BG_PLANE_NAME) plane_low.name = Global.BG_PLANE_NAME + Global.LOW_SUFFIX - bpy.data.collections[Global.COLL_NAME].hide_select = \ + bpy.data.collections[Global.COLL_CORE_NAME].hide_select = \ plane_low.hide_select = False plane_low.select_set(True) plane_high: Object = plane_low.copy() @@ -183,7 +172,7 @@ def execute(self, context: Context): bpy.data.objects.remove(plane_high) if not gd.coll_selectable: - bpy.data.collections[Global.COLL_NAME].hide_select = True + bpy.data.collections[Global.COLL_CORE_NAME].hide_select = True for ob_name in saved_selected: ob = context.scene.objects.get(ob_name) diff --git a/operators/material.py b/operators/material.py index 3582815..4ffeadc 100644 --- a/operators/material.py +++ b/operators/material.py @@ -4,37 +4,37 @@ from bpy.types import Context, Operator from ..constants import Global -from ..utils.generic import UseSelectedOnly, OpInfo +from ..utils.generic import UseSelectedOnly from ..utils.render import get_rendered_objects -def generate_random_name( - prefix: str, - minimum: int=1000, - maximum: int=100000 - ) -> str: - """Generates a random id map name based on a given prefix""" - while True: - name = prefix+str(randint(minimum, maximum)) - if name not in bpy.data.materials: - break - return name - -def quick_material_cleanup() -> None: - for mat in bpy.data.materials: - if mat.name.startswith(Global.ID_PREFIX) \ - and not mat.users \ - or mat.name.startswith(Global.RANDOM_ID_PREFIX) \ - and not mat.users: - bpy.data.materials.remove(mat) - - -class GRABDOC_OT_quick_id_setup(OpInfo, Operator): +class GRABDOC_OT_quick_id_setup(Operator): """Sets up materials on all objects within the cameras view frustrum""" - bl_idname = "grab_doc.quick_id_setup" - bl_label = "Auto ID Full Scene" + bl_idname = "grab_doc.quick_id_setup" + bl_label = "Auto ID Full Scene" + bl_options = {'REGISTER', 'UNDO'} + + @staticmethod + def generate_random_name(prefix: str, + minimum: int=1000, + maximum: int=100000) -> str: + """Generates a random id map name based on a given prefix""" + while True: + name = prefix+str(randint(minimum, maximum)) + if name not in bpy.data.materials: + break + return name - def execute(self, context: Context): + @staticmethod + def quick_material_cleanup() -> None: + for mat in bpy.data.materials: + if mat.name.startswith(Global.ID_PREFIX) \ + and not mat.users \ + or mat.name.startswith(Global.RANDOM_ID_PREFIX) \ + and not mat.users: + bpy.data.materials.remove(mat) + + def execute(self, _context: Context): for mat in bpy.data.materials: if mat.name.startswith(Global.RANDOM_ID_PREFIX): bpy.data.materials.remove(mat) @@ -52,7 +52,7 @@ def execute(self, context: Context): continue mat = bpy.data.materials.new( - generate_random_name(Global.RANDOM_ID_PREFIX) + self.generate_random_name(Global.RANDOM_ID_PREFIX) ) mat.use_nodes = True # NOTE: Viewport color @@ -63,18 +63,19 @@ def execute(self, context: Context): ob.active_material_index = 0 ob.active_material = mat - quick_material_cleanup() + self.quick_material_cleanup() return {'FINISHED'} -class GRABDOC_OT_quick_id_selected(OpInfo, UseSelectedOnly, Operator): +class GRABDOC_OT_quick_id_selected(UseSelectedOnly, Operator): """Adds a new single material with a random color to the selected objects""" - bl_idname = "grab_doc.quick_id_selected" - bl_label = "Add ID to Selected" + bl_idname = "grab_doc.quick_id_selected" + bl_label = "Add ID to Selected" + bl_options = {'REGISTER', 'UNDO'} def execute(self, context: Context): mat = bpy.data.materials.new( - generate_random_name(Global.ID_PREFIX) + GRABDOC_OT_quick_id_setup.generate_random_name(Global.ID_PREFIX) ) mat.use_nodes = True mat.diffuse_color = (random(), random(), random(), 1) @@ -87,14 +88,15 @@ def execute(self, context: Context): continue ob.active_material_index = 0 ob.active_material = mat - quick_material_cleanup() + GRABDOC_OT_quick_id_setup.quick_material_cleanup() return {'FINISHED'} -class GRABDOC_OT_remove_mats_by_name(OpInfo, Operator): +class GRABDOC_OT_remove_mats_by_name(Operator): """Remove materials based on an internal prefixed name""" - bl_idname = "grab_doc.remove_mats_by_name" - bl_label = "Remove Mats by Name" + bl_idname = "grab_doc.remove_mats_by_name" + bl_label = "Remove Mats by Name" + bl_options = {'REGISTER', 'UNDO'} name: bpy.props.StringProperty(options={'HIDDEN'}) @@ -105,10 +107,11 @@ def execute(self, _context: Context): return {'FINISHED'} -class GRABDOC_OT_quick_remove_selected_mats(OpInfo, UseSelectedOnly, Operator): +class GRABDOC_OT_quick_remove_selected_mats(UseSelectedOnly, Operator): """Remove all GrabDoc ID materials based on the selected objects from the scene""" - bl_idname = "grab_doc.quick_remove_selected_mats" - bl_label = "Remove Selected Materials" + bl_idname = "grab_doc.quick_remove_selected_mats" + bl_label = "Remove Selected Materials" + bl_options = {'REGISTER', 'UNDO'} def execute(self, context: Context): for ob in context.selected_objects: diff --git a/operators/operators.py b/operators/operators.py deleted file mode 100644 index e790293..0000000 --- a/operators/operators.py +++ /dev/null @@ -1,770 +0,0 @@ -import os -import time -import numpy # type: ignore - -import bpy -import blf -from bpy.types import SpaceView3D, Event, Context, Operator, UILayout -from bpy.props import StringProperty - -from ..constants import Global, Error -from ..utils.render import get_rendered_objects -from ..utils.node import apply_node_to_objects, node_cleanup -from ..utils.scene import scene_setup, remove_setup -from ..utils.generic import ( - OpInfo, - get_format, - proper_scene_setup, - bad_setup_check, - export_plane, - camera_in_3d_view, - get_temp_path -) -from ..utils.baker import ( - baker_init, - get_bake_maps, - baker_cleanup, - get_bakers, - reimport_as_material -) - - -################################################ -# MISC -################################################ - - -class GRABDOC_OT_load_reference(OpInfo, Operator): - """Import a reference onto the background plane""" - bl_idname = "grab_doc.load_reference" - bl_label = "Load Reference" - - filepath: StringProperty(subtype="FILE_PATH") - - def execute(self, context: Context): - bpy.data.images.load(self.filepath, check_existing=True) - context.scene.gd.reference = \ - bpy.data.images[ - os.path.basename(os.path.normpath(self.filepath)) - ] - return {'FINISHED'} - - def invoke(self, context: Context, _event: Event): - context.window_manager.fileselect_add(self) - return {'RUNNING_MODAL'} - - -class GRABDOC_OT_open_folder(OpInfo, Operator): - """Opens up the File Explorer to the designated folder location""" - bl_idname = "grab_doc.open_folder" - bl_label = "Open Folder" - - def execute(self, context: Context): - try: - bpy.ops.wm.path_open( - filepath=bpy.path.abspath(context.scene.gd.export_path) - ) - except RuntimeError: - self.report({'ERROR'}, Error.NO_VALID_PATH_SET) - return {'CANCELLED'} - return {'FINISHED'} - - -class GRABDOC_OT_view_cam(OpInfo, Operator): - """View or leave the GrabDoc camera""" - bl_idname = "grab_doc.view_cam" - - def execute(self, context: Context): - context.scene.camera = bpy.data.objects[Global.TRIM_CAMERA_NAME] - bpy.ops.view3d.view_camera() - return {'FINISHED'} - - -################################################ -# SCENE SETUP & CLEANUP -################################################ - - -class GRABDOC_OT_setup_scene(OpInfo, Operator): - """Setup / Refresh your current scene. Useful if you messed -up something within the GrabDoc collections that you don't -know how to properly revert""" - bl_idname = "grab_doc.setup_scene" - bl_label = "Setup / Refresh GrabDoc Scene" - - @classmethod - def poll(cls, context: Context) -> bool: - return GRABDOC_OT_export_maps.poll(context) - - def execute(self, context: Context): - scene_setup(self, context) - return {'FINISHED'} - - -class GRABDOC_OT_remove_setup(OpInfo, Operator): - """Completely removes every element of GrabDoc from the -scene, not including images reimported after bakes""" - bl_idname = "grab_doc.remove_setup" - bl_label = "Remove Setup" - - @classmethod - def poll(cls, context: Context) -> bool: - return GRABDOC_OT_export_maps.poll(context) - - def execute(self, context: Context): - remove_setup(context) - return {'FINISHED'} - - -################################################ -# MAP EXPORT -################################################ - - -class GRABDOC_OT_export_maps(OpInfo, Operator, UILayout): - """Export all enabled bake maps""" - bl_idname = "grab_doc.export_maps" - bl_label = "Export Maps" - bl_options = {'INTERNAL'} - - progress_factor = 0.0 - - @classmethod - def poll(cls, context: Context) -> bool: - if context.scene.gd.preview_state: - cls.poll_message_set( - "Cannot perform this operation while in Preview Mode" - ) - return False - return True - - @staticmethod - def export(context: Context, suffix: str, path: str = None) -> str: - gd = context.scene.gd - render = context.scene.render - saved_path = render.filepath - - name = f"{gd.export_name}_{suffix}" - if path is None: - path = bpy.path.abspath(gd.export_path) - path = os.path.join(path, name + get_format()) - render.filepath = path - - context.scene.camera = bpy.data.objects[Global.TRIM_CAMERA_NAME] - - bpy.ops.render.render(write_still=True) - render.filepath = saved_path - return path - - def execute(self, context: Context): - gd = context.scene.gd - report_value, report_string = \ - bad_setup_check(context, active_export=True) - if report_value: - self.report({'ERROR'}, report_string) - return {'CANCELLED'} - if gd.use_pack_maps is True and not is_pack_maps_enabled(): - self.report( - {'ERROR'}, - "Map packing enabled but incorrect export maps enabled" - ) - return {'CANCELLED'} - - self.map_name = 'export' - - bake_maps = get_bake_maps() - - start = time.time() - context.window_manager.progress_begin(0, 9999) - completion_step = 100 / (1 + len(bake_maps)) - completion_percent = 0 - - baker_init(self, context) - - active_selected = False - if context.object: - activeCallback = context.object.name - modeCallback = context.object.mode - if bpy.ops.object.mode_set.poll(): - bpy.ops.object.mode_set(mode='OBJECT') - active_selected = True - - # Scale up BG Plane (helps overscan & border pixels) - plane_ob = bpy.data.objects[Global.BG_PLANE_NAME] - plane_ob.scale[0] = plane_ob.scale[1] = 3 - - rendered_objects = get_rendered_objects() - for bake_map in bake_maps: - bake_map.setup() - if bake_map.NODE: - result = apply_node_to_objects(bake_map.NODE, rendered_objects) - if result is False: - self.report({'INFO'}, Error.MAT_SLOTS_WITHOUT_LINKS) - - self.export(context, bake_map.suffix) - bake_map.cleanup() - if bake_map.NODE: - node_cleanup(bake_map.NODE) - - completion_percent += completion_step - context.window_manager.progress_update(completion_percent) - - # Reimport textures to render result material - map_names = [bake.ID for bake in bake_maps if bake.reimport] - reimport_as_material(map_names) - - # Refresh all original settings - baker_cleanup(self, context) - - plane_ob = bpy.data.objects[Global.BG_PLANE_NAME] - plane_ob.scale[0] = plane_ob.scale[1] = 1 - - if gd.export_plane: - export_plane(context) - - if active_selected: - context.view_layer.objects.active = bpy.data.objects[activeCallback] - if bpy.ops.object.mode_set.poll(): - bpy.ops.object.mode_set(mode=modeCallback) - - end = time.time() - exc_time = round(end - start, 2) - self.report( - {'INFO'}, f"{Error.EXPORT_COMPLETE} (execution time: {exc_time}s)" - ) - context.window_manager.progress_end() - - if gd.use_pack_maps is True: - bpy.ops.grab_doc.pack_maps() - return {'FINISHED'} - - -class GRABDOC_OT_single_render(OpInfo, Operator): - """Renders the selected material and previews it inside Blender""" - bl_idname = "grab_doc.single_render" - bl_options = {'INTERNAL'} - - map_name: StringProperty() - - # TODO: - # - Support Reimport as Materials - # - Support correct default color spaces - - @classmethod - def poll(cls, context: Context) -> bool: - return GRABDOC_OT_export_maps.poll(context) - - def open_render_image(self, filepath: str): - new_image = bpy.data.images.load(filepath, check_existing=True) - bpy.ops.screen.userpref_show("INVOKE_DEFAULT") - area = bpy.context.window_manager.windows[-1].screen.areas[0] - area.type = "IMAGE_EDITOR" - area.spaces.active.image = new_image - - def execute(self, context: Context): - report_value, report_string = \ - bad_setup_check(context, active_export=False) - if report_value: - self.report({'ERROR'}, report_string) - return {'CANCELLED'} - - start = time.time() - - active_selected = False - if context.object: - activeCallback = context.object.name - modeCallback = context.object.mode - if bpy.ops.object.mode_set.poll(): - bpy.ops.object.mode_set(mode='OBJECT') - active_selected = True - - plane_ob = bpy.data.objects[Global.BG_PLANE_NAME] - plane_ob.scale[0] = plane_ob.scale[1] = 3 - - baker_init(self, context) - - gd = context.scene.gd - self.baker = getattr(gd, self.map_name)[0] - self.baker.setup() - if self.baker.NODE: - result = apply_node_to_objects( - self.baker.NODE, get_rendered_objects() - ) - if result is False: - self.report({'INFO'}, Error.MAT_SLOTS_WITHOUT_LINKS) - path = GRABDOC_OT_export_maps.export( - context, self.baker.suffix, path=get_temp_path() - ) - self.open_render_image(path) - self.baker.cleanup() - if self.baker.NODE: - node_cleanup(self.baker.NODE) - - # Reimport textures to render result material - if self.baker.reimport: - reimport_as_material([self.baker.ID]) - - baker_cleanup(self, context) - - plane_ob = bpy.data.objects[Global.BG_PLANE_NAME] - plane_ob.scale[0] = plane_ob.scale[1] = 1 - - if active_selected: - context.view_layer.objects.active = bpy.data.objects[activeCallback] - # NOTE: Also helps refresh viewport - if bpy.ops.object.mode_set.poll(): - bpy.ops.object.mode_set(mode=modeCallback) - - end = time.time() - exc_time = round(end - start, 2) - - self.report( - {'INFO'}, - f"{Error.OFFLINE_RENDER_COMPLETE} (execution time: {exc_time}s)" - ) - return {'FINISHED'} - - -################################################ -# MAP PREVIEW -################################################ - - -class GRABDOC_OT_leave_map_preview(Operator): - """Exit the current Map Preview""" - bl_idname = "grab_doc.leave_modal" - bl_label = "Exit Map Preview" - bl_options = {'INTERNAL', 'REGISTER'} - - def execute(self, context: Context): - context.scene.gd.preview_state = False - return {'FINISHED'} - - -class GRABDOC_OT_map_preview_warning(OpInfo, Operator): - """Preview the selected material""" - bl_idname = "grab_doc.preview_warning" - bl_label = "MATERIAL PREVIEW WARNING" - bl_options = {'INTERNAL'} - - map_name: StringProperty() - - def invoke(self, context: Context, _event: Event): - return context.window_manager.invoke_props_dialog(self, width=525) - - def draw(self, _context: Context): - self.layout.label(text=Global.PREVIEW_WARNING) - #col = self.layout.column(align=True) - #for line in Global.PREVIEW_WARNING.split('\n')[1:][:-1]: - # col.label(text=line) - # col.separator() - - def execute(self, context: Context): - context.scene.gd.preview_first_time = False - bpy.ops.grab_doc.preview_map(map_name=self.map_name) - return {'FINISHED'} - - -def draw_callback_px(self, context: Context) -> None: - """This needs to be outside of the class - because of how draw_handler_add handles args""" - font_id = 0 - font_size = 25 - font_opacity = .8 - font_x_pos = 15 - font_y_pos = 80 - font_pos_offset = 50 - - # NOTE: Handle small viewports - for area in context.screen.areas: - if area.type != 'VIEW_3D': - continue - for region in area.regions: - if region.type != 'WINDOW': - continue - if region.width <= 700 or region.height <= 400: - font_size *= .5 - font_pos_offset *= .5 - break - - blf.enable(font_id, 4) - blf.shadow(font_id, 0, *(0, 0, 0, font_opacity)) - blf.position(font_id, font_x_pos, (font_y_pos + font_pos_offset), 0) - blf.size(font_id, font_size) - blf.color(font_id, *(1, 1, 1, font_opacity)) - render_text = self.map_name.capitalize() - blf.draw(font_id, f"{render_text} Preview | [ESC] to exit") - blf.position(font_id, font_x_pos, font_y_pos, 0) - blf.size(font_id, font_size+1) - blf.color(font_id, *(1, 1, 0, font_opacity)) - blf.draw(font_id, "You are in Map Preview mode!") - - -class GRABDOC_OT_map_preview(OpInfo, Operator): - """Preview the selected material""" - bl_idname = "grab_doc.preview_map" - bl_options = {'INTERNAL'} - - map_name: StringProperty() - - def modal(self, context: Context, event: Event): - scene = context.scene - gd = scene.gd - - scene.camera = bpy.data.objects[Global.TRIM_CAMERA_NAME] - - # Exporter settings - # NOTE: Use alpha channel if background plane not visible in render - image_settings = scene.render.image_settings - if not gd.coll_rendered: - scene.render.film_transparent = True - image_settings.color_mode = 'RGBA' - else: - scene.render.film_transparent = False - image_settings.color_mode = 'RGB' - - # Get correct file format and color depth - image_settings.file_format = gd.format - if gd.format == 'OPEN_EXR': - image_settings.color_depth = gd.exr_depth - elif gd.format != 'TARGA': - image_settings.color_depth = gd.depth - - # Exit check - if not gd.preview_state \ - or event.type in {'ESC'} \ - or not proper_scene_setup(): - self.modal_cleanup(context) - return {'CANCELLED'} - return {'PASS_THROUGH'} - - def modal_cleanup(self, context: Context) -> None: - gd = context.scene.gd - gd.preview_state = False - - SpaceView3D.draw_handler_remove( # pylint: disable=E1120 - self._handle, 'WINDOW' - ) - - self.baker.cleanup() - node_cleanup(self.baker.NODE) - baker_cleanup(self, context) - - # Current workspace shading type - for area in context.screen.areas: - if area.type != 'VIEW_3D': - continue - for space in area.spaces: - space.shading.type = self.saved_render_view - break - - # Current workspace shading type - for screens in bpy.data.workspaces[self.savedWorkspace].screens: - for area in screens.areas: - if area.type != 'VIEW_3D': - continue - for space in area.spaces: - space.shading.type = self.saved_render_view - break - - gd.baker_type = self.savedBakerType - context.scene.render.engine = self.savedEngine - - # Check for auto exit camera option, keep this - # at the end of the stack to avoid pop in - if not proper_scene_setup(): - bpy.ops.grab_doc.view_cam() - - def execute(self, context: Context): - report_value, report_string = \ - bad_setup_check(context, active_export=False) - if report_value: - self.report({'ERROR'}, report_string) - return {'CANCELLED'} - - gd = context.scene.gd - self.savedBakerType = gd.baker_type - self.savedWorkspace = context.workspace.name - self.savedEngine = context.scene.render.engine - gd.preview_state = True - gd.preview_type = self.map_name - gd.baker_type = 'blender' - - if bpy.ops.object.mode_set.poll(): - bpy.ops.object.mode_set(mode='OBJECT') - for area in context.screen.areas: - if area.type == 'VIEW_3D': - for space in area.spaces: - self.saved_render_view = space.shading.type - space.shading.type = 'RENDERED' - break - if not camera_in_3d_view(): - bpy.ops.view3d.view_camera() - - baker_init(self, context) - - self.baker = getattr(gd, self.map_name)[0] - self.baker.setup() - if self.baker.NODE: - rendered_objects = get_rendered_objects() - result = apply_node_to_objects(self.baker.NODE, rendered_objects) - if result is False: - self.report({'INFO'}, Error.MAT_SLOTS_WITHOUT_LINKS) - self._handle = SpaceView3D.draw_handler_add( # pylint: disable=E1120 - draw_callback_px, (self, context), 'WINDOW', 'POST_PIXEL' - ) - context.window_manager.modal_handler_add(self) - return {'RUNNING_MODAL'} - - -class GRABDOC_OT_export_current_preview(OpInfo, Operator): - """Export the currently previewed material""" - bl_idname = "grab_doc.export_preview" - bl_label = "Export Previewed Map" - - @classmethod - def poll(cls, context: Context) -> bool: - return not GRABDOC_OT_export_maps.poll(context) - - def execute(self, context: Context): - gd = context.scene.gd - - report_value, report_string = \ - bad_setup_check(context, active_export=True) - if report_value: - self.report({'ERROR'}, report_string) - return {'CANCELLED'} - - start = time.time() - - # NOTE: Manual plane scale to account for overscan - plane_ob = bpy.data.objects[Global.BG_PLANE_NAME] - scale_plane = False - if plane_ob.scale[0] == 1: - scale_plane = True - plane_ob.scale[0] = plane_ob.scale[1] = 3 - - baker = getattr(gd, gd.preview_type)[0] - - GRABDOC_OT_export_maps.export(context, baker.suffix) - if baker.reimport: - reimport_as_material([baker.ID]) - - if scale_plane: - plane_ob.scale[0] = plane_ob.scale[1] = 1 - - if gd.export_plane: - export_plane(context) - - end = time.time() - exc_time = round(end - start, 2) - - self.report( - {'INFO'}, f"{Error.EXPORT_COMPLETE} (execution time: {exc_time}s)" - ) - return {'FINISHED'} - - -class GRABDOC_OT_config_maps(Operator): - """Configure bake map UI visibility, will also disable baking""" - bl_idname = "grab_doc.config_maps" - bl_label = "Configure Map Visibility" - bl_options = {'REGISTER'} - - @classmethod - def poll(cls, context: Context) -> bool: - return GRABDOC_OT_export_maps.poll(context) - - def execute(self, _context): - return {'FINISHED'} - - def invoke(self, context: Context, _event: Event): - return context.window_manager.invoke_props_dialog(self, width=200) - - def draw(self, _context: Context): - layout = self.layout - col = layout.column(align=True) - - bakers = get_bakers() - for baker in bakers: - icon = \ - "BLENDER" if not baker[0].MARMOSET_COMPATIBLE else "WORLD" - col.prop( - baker[0], 'visibility', - text=baker[0].NAME, icon=icon - ) - - -################################################ -# CHANNEL PACKING -################################################ - - -def pack_image_channels(pack_order, PackName): - """ - NOTE: Original code sourced from: - https://blender.stackexchange.com/questions/274712/how-to-channel-pack-texture-in-python - """ - dst_array = None - has_alpha = False - - # Build the packed pixel array - for pack_item in pack_order: - image = pack_item[0] - # Initialize arrays on the first iteration - if dst_array is None: - w, h = image.size - src_array = numpy.empty(w * h * 4, dtype=numpy.float32) - dst_array = numpy.ones(w * h * 4, dtype=numpy.float32) - assert image.size[:] == (w, h), "Images must be same size" - - # Fetch pixels from the source image and copy channels - image.pixels.foreach_get(src_array) - for src_chan, dst_chan in pack_item[1:]: - if dst_chan == 3: - has_alpha = True - dst_array[dst_chan::4] = src_array[src_chan::4] - - # Create image from the packed pixels - dst_image = bpy.data.images.new(PackName, w, h, alpha=has_alpha) - dst_image.pixels.foreach_set(dst_array) - - return dst_image - - -def get_channel_path(channel: str) -> str | None: - """Get the channel path of the given channel name. - - If the channel path is not found returns `None`.""" - if channel == "none": - return None - gd = bpy.context.scene.gd - suffix = getattr(gd, channel)[0].suffix - if suffix is None: - return None - filename = gd.export_name + '_' + suffix + get_format() - filepath = os.path.join(gd.export_path, filename) - if not os.path.exists(filepath): - return None - return filepath - - -def is_pack_maps_enabled() -> bool: - """Checks if the chosen pack channels - match the enabled maps to export. - - This function also returns True if a required - bake map is not enabled but the texture exists.""" - bake_map_names = ['none'] - for bake_map in get_bake_maps(): - bake_map_names.append(bake_map.ID) - - gd = bpy.context.scene.gd - if gd.channel_r not in bake_map_names \ - and get_channel_path(gd.channel_r) is None: - return False - if gd.channel_g not in bake_map_names \ - and get_channel_path(gd.channel_g) is None: - return False - if gd.channel_b not in bake_map_names \ - and get_channel_path(gd.channel_b) is None: - return False - if gd.channel_a not in bake_map_names \ - and get_channel_path(gd.channel_a) is None: - return False - return True - - -class GRABDOC_OT_pack_maps(OpInfo, Operator): - """Merge previously exported bake maps into single packed texture""" - bl_idname = "grab_doc.pack_maps" - bl_label = "Run Pack" - - @classmethod - def poll(cls, context: Context) -> bool: - gd = context.scene.gd - r = get_channel_path(gd.channel_r) - g = get_channel_path(gd.channel_g) - b = get_channel_path(gd.channel_b) - a = get_channel_path(gd.channel_a) - if not all((r, g, b)): - return False - if gd.channel_a != 'none' and a is None: - return False - return True - - def execute(self, context): - gd = context.scene.gd - - pack_name = gd.export_name + "_" + gd.pack_name - path = gd.export_path - - # Loads all images into blender to avoid using a - # separate python module to convert to np array - image_r = bpy.data.images.load(get_channel_path(gd.channel_r)) - image_g = bpy.data.images.load(get_channel_path(gd.channel_g)) - image_b = bpy.data.images.load(get_channel_path(gd.channel_b)) - pack_order = [ - (image_r, (0, 0)), - (image_g, (0, 1)), - (image_b, (0, 2)) - ] - if gd.channel_a != 'none': - image_a = bpy.data.images.load(get_channel_path(gd.channel_a)) - pack_order.append((image_a, (0, 3))) - - dst_image = pack_image_channels(pack_order, pack_name) - dst_image.filepath_raw = path+"//"+pack_name+get_format() - dst_image.file_format = gd.format - dst_image.save() - - # Remove images from blend file to keep it clean - bpy.data.images.remove(image_r) - bpy.data.images.remove(image_g) - bpy.data.images.remove(image_b) - if gd.channel_a != 'none': - bpy.data.images.remove(image_a) - bpy.data.images.remove(dst_image) - - # Option to delete the extra maps through the operator panel - if gd.remove_original_maps is True: - if os.path.exists(get_channel_path(gd.channel_r)): - os.remove(get_channel_path(gd.channel_r)) - if os.path.exists(get_channel_path(gd.channel_g)): - os.remove(get_channel_path(gd.channel_g)) - if os.path.exists(get_channel_path(gd.channel_b)): - os.remove(get_channel_path(gd.channel_b)) - if gd.channel_a != 'none' and \ - os.path.exists(get_channel_path(gd.channel_a)): - os.remove(get_channel_path(gd.channel_a)) - return {'FINISHED'} - - -################################################ -# REGISTRATION -################################################ - - -classes = ( - GRABDOC_OT_load_reference, - GRABDOC_OT_open_folder, - GRABDOC_OT_view_cam, - GRABDOC_OT_setup_scene, - GRABDOC_OT_remove_setup, - GRABDOC_OT_single_render, - GRABDOC_OT_export_maps, - GRABDOC_OT_map_preview_warning, - GRABDOC_OT_map_preview, - GRABDOC_OT_leave_map_preview, - GRABDOC_OT_export_current_preview, - GRABDOC_OT_config_maps, - GRABDOC_OT_pack_maps -) - -def register(): - for cls in classes: - bpy.utils.register_class(cls) - -def unregister(): - for cls in classes: - bpy.utils.unregister_class(cls) diff --git a/preferences.py b/preferences.py index 50663ed..6ac45c6 100644 --- a/preferences.py +++ b/preferences.py @@ -5,310 +5,170 @@ from bl_operators.presets import AddPresetBase from bl_ui.utils import PresetPanel from bpy.types import ( - Menu, - Panel, - Operator, - AddonPreferences, - Context, - PropertyGroup, - Image, - Scene, - Collection, - Object + Menu, Panel, Operator, AddonPreferences, + Context, PropertyGroup, Image, Scene, + Collection, Object, Node ) from bpy.props import ( - BoolProperty, - PointerProperty, - CollectionProperty, - StringProperty, - EnumProperty, - IntProperty, - FloatProperty + BoolProperty, PointerProperty, CollectionProperty, + StringProperty, EnumProperty, IntProperty, FloatProperty ) -from .constants import Global +from .baker import Baker from .utils.scene import scene_setup -#from .utils.render import get_rendered_objects, set_guide_height -from .utils.baker import ( - Alpha, - Color, - Curvature, - Height, - Id, - Metallic, - Normals, - Occlusion, - Emissive, - Roughness -) - - -################################################ -# PRESETS -################################################ - - -class GRABDOC_MT_presets(Menu): - bl_label = "" - preset_subdir = "gd" - preset_operator = "script.execute_preset" - draw = Menu.draw_preset - - -class GRABDOC_PT_presets(PresetPanel, Panel): - bl_label = 'Bake Presets' - preset_subdir = 'grab_doc' - preset_operator = 'script.execute_preset' - preset_add_operator = 'grab_doc.preset_add' - - -class GRABDOC_OT_add_preset(AddPresetBase, Operator): - bl_idname = "grab_doc.preset_add" - bl_label = "Add a new preset" - preset_menu = "GRABDOC_MT_presets" - - # Variable used for all preset values - preset_defines = [ - "gd = bpy.context.scene.gd" - ] - - # Properties to store - # TODO: Create a function to generate this list - preset_values = [ - "gd.coll_selectable", - "gd.coll_visible", - "gd.coll_rendered", - "gd.use_grid", - "gd.grid_subdivs", - "gd.filter", - "gd.filter_width", - "gd.scale", - - "gd.baker_type", - "gd.export_path", - "gd.export_name", - "gd.resolution_x", - "gd.resolution_y", - "gd.lock_res", - "gd.format", - "gd.depth", - "gd.tga_depth", - "gd.png_compression", - - "gd.use_bake_collections", - "gd.export_plane", - - "gd.marmo_auto_bake", - "gd.marmo_auto_close", - "gd.marmo_samples", - "gd.marmo_occlusion_ray_count", - "gd.marmo_format", - - "gd.normals", - "gd.curvature", - "gd.occlusion", - "gd.height", - "gd.alpha", - "gd.id", - "gd.color", - "gd.emissive", - "gd.roughness", - "gd.metallic", - - "gd.use_pack_maps", - "gd.pack_name", - "gd.channel_r", - "gd.channel_g", - "gd.channel_b", - "gd.channel_a" - ] - - # Where to store the preset - preset_subdir = "grab_doc" - - -############################################################ -# PROPERTY GROUP -############################################################ class GRABDOC_AP_preferences(AddonPreferences): bl_idname = __package__ - marmo_executable: StringProperty( - description="Path to Marmoset Toolbag 3 or 4 executable", + mt_executable: StringProperty( + description="Path to Marmoset Toolbag 3 / 4 executable", name="Marmoset EXE Path", default="", subtype="FILE_PATH" ) - render_within_frustrum: BoolProperty( - name="Render Within Frustrum", - description="""Only render objects within the camera viewing frustrum. + description=\ +"""Only render objects within the camera's viewing frustrum. -Improves render speed but it may apply materials incorrectly (void objects).""", - default=False +Improves render speed but it may apply materials incorrectly (void objects)""", + name="Render Within Frustrum", default=False + ) + exit_camera_preview: BoolProperty( + description="Exit the camera when leaving Map Preview", + name="Auto-exit Preview Camera", default=False ) + disable_preview_binds: BoolProperty( + description=\ +"""By default, pressing escape while in Map Preview automatically exits preview. + +This can get in the way of other modal operators, causing some friction""", + name="Disable Keybinds in Preview", default=False + ) + + def draw(self, _context: Context): + for prop in self.__annotations__.keys(): + self.layout.prop(self, prop) + + +class GRABDOC_PG_properties(PropertyGroup): + def update_filename(self, _context: Context): + if not self.filename: + self.filename = "untitled" - def draw(self, _context): - layout = self.layout - col = layout.column() - col.prop(self, 'marmo_executable') - col.prop(self, 'render_within_frustrum') - - -class GRABDOC_property_group(PropertyGroup): - MAP_TYPES = (('none', "None", ""), - ('normals', "Normals", ""), - ('curvature', "Curvature", ""), - ('occlusion', "Ambient Occlusion", ""), - ('height', "Height", ""), - ('id', "Material ID", ""), - ('alpha', "Alpha", ""), - ('color', "Base Color", ""), - ('emissive', "Emissive", ""), - ('roughness', "Roughness", ""), - ('metallic', "Metallic", "")) - - def update_export_name(self, _context: Context): - if not self.export_name: - self.export_name = "untitled" - - def update_export_path(self, _context: Context): - export_path_exists = \ - os.path.exists(bpy.path.abspath(self.export_path)) - if self.export_path != '' and not export_path_exists: - self.export_path = '' + def update_filepath(self, _context: Context): + if self.filepath == '//': + return + if not os.path.exists(self.filepath): + self.filepath = '//' def update_res_x(self, context: Context): - if self.lock_res and self.resolution_x != self.resolution_y: + if self.resolution_lock and self.resolution_x != self.resolution_y: self.resolution_y = self.resolution_x scene_setup(self, context) def update_res_y(self, context: Context): - if self.lock_res and self.resolution_y != self.resolution_x: + if self.resolution_lock and self.resolution_y != self.resolution_x: self.resolution_x = self.resolution_y scene_setup(self, context) def update_scale(self, context: Context): scene_setup(self, context) - gd_camera_ob_z = bpy.data.objects.get( - Global.TRIM_CAMERA_NAME - ).location[2] - height_ng = bpy.data.node_groups.get(Global.HEIGHT_NODE) - - map_range = height_ng.nodes.get('Map Range') - map_range.inputs[1].default_value = \ - -self.height[0].distance + gd_camera_ob_z - map_range.inputs[2].default_value = gd_camera_ob_z - - map_range_alpha = \ - bpy.data.node_groups[Global.ALPHA_NODE].nodes.get('Map Range') - map_range_alpha.inputs[1].default_value = gd_camera_ob_z - .00001 - map_range_alpha.inputs[2].default_value = gd_camera_ob_z - - # Project Setup + # TODO: Validate necessity + #height = bpy.data.node_groups.get(Height.node_name) + #map_range = height.nodes.get('Map Range') + #camera_object_z = Global.CAMERA_DISTANCE * bpy.context.scene.gd.scale + #map_range.inputs[1].default_value = \ + # camera_object_z - self.height[0].distance + #map_range.inputs[2].default_value = camera_object_z + #alpha = bpy.data.node_groups.get(Alpha.node_name) + #map_range_alpha = alpha.nodes.get('Map Range') + #map_range_alpha.inputs[1].default_value = camera_object_z - .00001 + #map_range_alpha.inputs[2].default_value = camera_object_z + + # Scene coll_selectable: BoolProperty( - update=scene_setup, - description="Sets the background plane selection capability" + description="Sets the background plane selection capability", + update=scene_setup ) coll_visible: BoolProperty(default=True, update=scene_setup, description="Sets the visibility in the viewport") coll_rendered: BoolProperty( - default=True, - update=scene_setup, description=\ - "Sets the visibility in exports, this will also enable transparency and alpha channel exports if visibility is turned off" +"""Sets visibility of background plane in exports. + +Enables transparency and alpha channel if disabled""", + default=True, update=scene_setup ) scale: FloatProperty( - name="Scale", - default=2, - min=.1, - soft_max=100, - precision=3, - subtype='DISTANCE', - description=\ - "Background plane & camera scale, also applies to exported plane", - update=update_scale + description="Background plane and camera; applied to exported plane", + name="Scale", update=scene_setup, + default=2, min=.1, soft_max=100, precision=3, subtype='DISTANCE' ) - filter: BoolProperty( - name='Use Filtering', - default=True, + use_filtering: BoolProperty( description=\ - "Pixel filtering, useful for avoiding aliased edges on bake maps", - update=scene_setup +"""Blurs sharp edge shapes to reduce harsh, aliased edges. + +When disabled, pixel filtering is reduced to .01px""", + name='', default=True, update=scene_setup ) filter_width: FloatProperty( - name="Filter Amount", - default=1.2, - min=0, - soft_max=10, - subtype='PIXEL', description="The width in pixels used for filtering", - update=scene_setup - ) - - reference: PointerProperty( - name='Reference Selection', - type=Image, - description="Select an image reference to use on the background plane", - update=scene_setup + name="Filter Amount", update=scene_setup, + default=1.2, min=0, soft_max=10, subtype='PIXEL' ) use_grid: BoolProperty( - name='Use Grid', - default=True, - description=\ - "Wireframe grid on plane for better snapping usability", - update=scene_setup + description="Wireframe grid on plane for better snapping usability", + name='Use Grid', default=False, update=scene_setup ) grid_subdivs: IntProperty( - name="Grid Subdivisions", - default=0, - min=0, - soft_max=64, - description="Subdivision count for grid", - update=scene_setup - ) - - # Baker - baker_type: EnumProperty( - items=( - ('blender', "Blender", "Set Baker: Blender"), - ('marmoset', "Toolbag", "Set Baker: Marmoset Toolbag") - ), - name="Baker" - ) - export_path: StringProperty( - name="Export Filepath", - default=" ", - description="This is the path all files will be exported to", - subtype='DIR_PATH', - update=update_export_path + description="Subdivision count for the background plane's grid", + name="Grid Subdivisions", update=scene_setup, + default=2, min=0, soft_max=64 ) - resolution_x: IntProperty( - name="Res X", - default=2048, - min=4, soft_max=8192, - update=update_res_x + reference: PointerProperty( + description="Select an image reference to use on the background plane", + name='Reference Selection', type=Image, update=scene_setup ) - resolution_y: IntProperty( - name="Res Y", - default=2048, - min=4, soft_max=8192, - update=update_res_y + + # Output + engine: EnumProperty( + description="The baking engine you would like to use", + name="Engine", + items=(('grabdoc', "GrabDoc", "Set Baker: GrabDoc (Blender)"), + ('marmoset', "Toolbag", "Set Baker: Marmoset Toolbag")) ) - lock_res: BoolProperty( - name='Sync Resolution', - default=True, - update=update_res_x + filepath: StringProperty( + description="The path all textures will be exported to", + name="Export Filepath", default="//", subtype='DIR_PATH', + update=update_filepath ) - export_name: StringProperty( - name="", + filename: StringProperty( description="Prefix name used for exported maps", - default="untitled", - update=update_export_name + name="", default="untitled", update=update_filename ) - use_bake_collections: BoolProperty( + resolution_x: IntProperty(name="X Resolution", update=update_res_x, + default=2048, min=4, soft_max=8192) + resolution_y: IntProperty(name="Y Resolution", update=update_res_y, + default=2048, min=4, soft_max=8192) + resolution_lock: BoolProperty(name='Lock Resolution', + default=True, update=update_res_x) + format: EnumProperty(name="Format", + items=(('PNG', "PNG", ""), + ('TIFF', "TIFF", ""), + ('TARGA', "TGA", ""), + ('OPEN_EXR', "EXR", ""))) + depth: EnumProperty(items=(('16', "16", ""), + ('8', "8", ""))) + exr_depth: EnumProperty(items=(('16', "16", ""), + ('32', "32", ""))) + tga_depth: EnumProperty(items=(('8', "8", ""), + ('16', "16", ""))) + png_compression: IntProperty( + description="Lossless compression; lower file size, longer bake times", + name="", default=50, min=0, max=100, subtype='PERCENTAGE' + ) + use_bake_collection: BoolProperty( description="Add a collection to the scene for use as bake groups", update=scene_setup ) @@ -316,104 +176,48 @@ def update_scale(self, context: Context): description="Export the background plane as an unwrapped FBX" ) - # Image Formats - format: EnumProperty( - items=( - ('PNG', "PNG", ""), - ('TIFF', "TIFF", ""), - ('TARGA', "TGA", ""), - ('OPEN_EXR', "EXR", "") - ), - name="Format" - ) - depth: EnumProperty( - items=( - ('16', "16", ""), - ('8', "8", "") - ) - ) - # NOTE: Wrapper property so we can assign a new default - png_compression: IntProperty( - name="", - default=50, - min=0, - max=100, - description=\ - "Lossless compression for lower file size but longer bake times", - subtype='PERCENTAGE' - ) - exr_depth: EnumProperty( - items=( - ('16', "16", ""), - ('32', "32", "") - ) - ) - tga_depth: EnumProperty( - items=( - ('16', "16", ""), - ('8', "8", "") - ), - default='8' - ) + # Bake maps + MAP_TYPES = [('none', "None", "")] + baker_props = {} + for baker in Baker.__subclasses__(): + baker_props[baker.ID] = CollectionProperty(type=baker, name=baker.NAME) + MAP_TYPES.append((baker.ID, baker.NAME, "")) + __annotations__.update(baker_props) # pylint: disable=E0602 # Map preview - preview_first_time: BoolProperty(default=True) - # NOTE: The modal system relies on this - # particular switch to control UI States - preview_state: BoolProperty() - preview_type: EnumProperty(items=MAP_TYPES) - - # Baking - normals: CollectionProperty(type=Normals) - curvature: CollectionProperty(type=Curvature) - occlusion: CollectionProperty(type=Occlusion) - height: CollectionProperty(type=Height) - id: CollectionProperty(type=Id) - alpha: CollectionProperty(type=Alpha) - color: CollectionProperty(type=Color) - emissive: CollectionProperty(type=Emissive) - roughness: CollectionProperty(type=Roughness) - metallic: CollectionProperty(type=Metallic) - - # Marmoset baking - marmo_auto_bake: BoolProperty(name="Auto bake", default=True) - marmo_auto_close: BoolProperty(name="Close after baking") - marmo_samples: EnumProperty( - items=( - ('1', "1x", ""), - ('4', "4x", ""), - ('16', "16x", ""), - ('64', "64x", "") - ), - default="16", - name="Marmoset Samples", - description=\ - "Samples rendered per pixel. 64 samples is not supported in Marmoset 3 (defaults to 16 samples)" + preview_map_type: EnumProperty(items=MAP_TYPES) + preview_index: IntProperty() + preview_state: BoolProperty( + description="Flags if the user is currently in Map Preview" ) - marmo_occlusion_ray_count: IntProperty( - default=512, - min=32, - soft_max=4096 + + # Marmoset + mt_auto_bake: BoolProperty(name="Auto bake", default=True) + mt_auto_close: BoolProperty(name="Close after baking") + mt_samples: EnumProperty( + description=\ + "Samples rendered per pixel. 64x not supported in MT3 (defaults to 16)", + items=(('1', "1x", ""), + ('4', "4x", ""), + ('16', "16x", ""), + ('64', "64x", "")), + default="16", name="Marmoset Samples" ) - marmo_format: EnumProperty( - items=( - ('PNG', "PNG", ""), - ('PSD', "PSD", "") - ), + mt_occlusion_samples: IntProperty(default=512, min=32, soft_max=4096) + mt_format: EnumProperty( + items=(('PNG', "PNG", ""), + ('PSD', "PSD", "")), name="Format" ) - # Channel packing + # Pack maps use_pack_maps: BoolProperty( - name="Pack on Export", - description=\ - "After exporting, pack bake maps using the selected packing channels", - default=False + description="Pack textures using the selected channels after exporting", + name="Pack on Export", default=False ) remove_original_maps: BoolProperty( - name="Remove Original Maps", description="Remove the original unpacked maps after exporting", - default=False + name="Remove Original Maps", default=False ) pack_name: StringProperty(name="Packed Map Name", default="AORM") channel_r: EnumProperty(items=MAP_TYPES[1:], default="occlusion", name='R') @@ -422,36 +226,70 @@ def update_scale(self, context: Context): channel_a: EnumProperty(items=MAP_TYPES, default="none", name='A') +################################################ +# PRESETS +################################################ + + +class GRABDOC_MT_presets(Menu): + bl_label = "" + preset_subdir = "gd" + preset_operator = "script.execute_preset" + draw = Menu.draw_preset + + +class GRABDOC_PT_presets(PresetPanel, Panel): + bl_label = 'Bake Presets' + preset_subdir = 'grab_doc' + preset_operator = 'script.execute_preset' + preset_add_operator = 'grab_doc.preset_add' + + +class GRABDOC_OT_add_preset(AddPresetBase, Operator): + bl_idname = "grab_doc.preset_add" + bl_label = "Add a new preset" + preset_menu = "GRABDOC_MT_presets" + + preset_subdir = "grab_doc" + preset_defines = ["gd=bpy.context.scene.gd"] + preset_values = [] + bakers = [baker.ID for baker in Baker.__subclasses__()] + for name in GRABDOC_PG_properties.__annotations__.keys(): + if name.startswith("preview_"): + continue + if name in bakers: + preset_values.append(f"gd.{name}[0]") + continue + preset_values.append(f"gd.{name}") + + # TODO: Figure out a way to run register_baker_panels + # in order to support multi-baker presets + #def execute(self, context: Context): + # super().execute(context) + + ################################## # REGISTRATION ################################## -classes = ( +classes = [ + GRABDOC_AP_preferences, GRABDOC_MT_presets, GRABDOC_PT_presets, - GRABDOC_OT_add_preset, - Normals, - Curvature, - Occlusion, - Height, - Id, - Alpha, - Color, - Emissive, - Roughness, - Metallic, - GRABDOC_property_group, - GRABDOC_AP_preferences -) + GRABDOC_OT_add_preset +] +# NOTE: Register properties last for collection generation +classes.extend([*Baker.__subclasses__(), GRABDOC_PG_properties]) def register(): for cls in classes: bpy.utils.register_class(cls) - Scene.gd = PointerProperty(type=GRABDOC_property_group) - Collection.gd_bake_collection = BoolProperty(default=False) - Object.gd_object = BoolProperty(default=False) + Scene.gd = PointerProperty(type=GRABDOC_PG_properties) + Collection.gd_collection = BoolProperty() + Object.gd_object = BoolProperty() + Node.gd_spawn = BoolProperty() def unregister(): for cls in classes: diff --git a/ui.py b/ui.py index b05e73b..5f3d1cd 100644 --- a/ui.py +++ b/ui.py @@ -1,112 +1,83 @@ import os import bpy -from bpy.types import Context, Panel, UILayout +from bpy.types import Context, Panel, UILayout, NodeTree -from .constants import Global -from .operators.operators import GRABDOC_OT_export_maps from .preferences import GRABDOC_PT_presets -from .utils.generic import ( - PanelInfo, - proper_scene_setup, - camera_in_3d_view, - get_version -) +from .utils.baker import get_baker_collections +from .utils.generic import get_version, get_user_preferences +from .utils.scene import camera_in_3d_view, is_scene_valid -################################################ -# UI -################################################ +class GDPanel(Panel): + bl_category = 'GrabDoc' + bl_space_type = 'VIEW_3D' + bl_region_type = 'UI' + bl_label = "" -class GRABDOC_PT_grabdoc(Panel, PanelInfo): - bl_label = "GrabDoc " + get_version() +class GRABDOC_PT_grabdoc(GDPanel): + bl_label = "GrabDoc " + get_version()[:-2] + documentation = "https://github.com/oRazeD/GrabDoc/wiki" def draw_header_preset(self, _context: Context): - if proper_scene_setup(): - # NOTE: This method already has - # self but the IDE trips on it - # pylint: disable=no-value-for-parameter - GRABDOC_PT_presets.draw_panel_header(self.layout) + row = self.layout.row(align=True) + if is_scene_valid(): + GRABDOC_PT_presets.draw_menu(row, text="Presets") + row.operator("wm.url_open", text="", icon='HELP').url = \ + self.documentation + row.separator() - def marmo_header_layout(self, layout: UILayout): - preferences = bpy.context.preferences.addons[__package__].preferences - marmo_executable = preferences.marmo_executable + def draw(self, _context: Context): + row = self.layout.row(align=True) + row.scale_x = row.scale_y = 1.25 + scene_setup = is_scene_valid() + row.operator("grab_doc.scene_setup", + icon="FILE_REFRESH" if scene_setup else "TOOL_SETTINGS", + text="Rebuild Scene" if scene_setup else "Setup Scene") + if scene_setup: + row.operator("grab_doc.scene_cleanup", text="", icon="CANCEL") - col = layout.column(align=True) - row = col.row() - if not os.path.exists(marmo_executable): - row.alignment = 'CENTER' - row.label(text="Marmoset Toolbag Executable Required", icon='INFO') - row = col.row() - row.prop(preferences, 'marmo_executable', text="Executable Path") - return - row.prop(preferences, 'marmo_executable', text="Executable Path") - row = col.row(align=True) - row.scale_y = 1.25 - row.operator( - "grab_doc.bake_marmoset", - text="Bake in Marmoset", icon="EXPORT" - ).send_type = 'open' - row.operator( - "grab_doc.bake_marmoset", - text="", icon='FILE_REFRESH' - ).send_type = 'refresh' + +class GRABDOC_PT_scene(GDPanel): + bl_label = 'Scene' + bl_parent_id = "GRABDOC_PT_grabdoc" + + @classmethod + def poll(cls, _context: Context) -> bool: + return is_scene_valid() + + def draw_header(self, _context: Context): + self.layout.label(icon='SCENE_DATA') + + def draw_header_preset(self, context: Context): + gd = context.scene.gd + row = self.layout.row(align=True) + row.scale_x = .95 + row.prop(gd, "coll_selectable", text="", emboss=False, + icon='RESTRICT_SELECT_OFF' if gd.coll_selectable else 'RESTRICT_SELECT_ON') + row.prop(gd, "coll_visible", text="", emboss=False, + icon='RESTRICT_VIEW_OFF' if gd.coll_visible else 'RESTRICT_VIEW_ON') + row.prop(gd, "coll_rendered", text="", emboss=False, + icon='RESTRICT_RENDER_OFF' if gd.coll_rendered else 'RESTRICT_RENDER_ON') + row2 = row.row() + row2.scale_x = 1.15 + row2.operator("grab_doc.toggle_camera_view", + text="Leave" if camera_in_3d_view() else "View", + icon="OUTLINER_OB_CAMERA") def draw(self, context: Context): gd = context.scene.gd layout = self.layout - # Scene - box = layout.box() - scene_setup = proper_scene_setup() - split = box.split(factor=.5 if scene_setup else .9) - split.label(text="Scene", icon="SCENE_DATA") - if scene_setup: - col = split.column(align=True) - in_trim_cam = camera_in_3d_view() - col.operator( - "grab_doc.view_cam", - text="Leave" if in_trim_cam else "View", - icon="OUTLINER_OB_CAMERA" - ) - - col = box.column(align=True) - row = col.row(align=True) - row.scale_x = row.scale_y = 1.25 - row.operator( - "grab_doc.setup_scene", - text="Rebuild Scene" if scene_setup else "Setup Scene", - icon="FILE_REFRESH" - ) - if not scene_setup: - return - row.operator("grab_doc.remove_setup", text="", icon="CANCEL") - - col.label(text="Camera Restrictions") - row = col.row(align=True) - row.prop( - gd, "coll_selectable", text="Select", - icon='RESTRICT_SELECT_OFF' if gd.coll_selectable else 'RESTRICT_SELECT_ON' - ) - row.prop( - gd, "coll_visible", text="Visible", - icon='RESTRICT_VIEW_OFF' if gd.coll_visible else 'RESTRICT_VIEW_ON' - ) - row.prop( - gd, "coll_rendered", text="Render", - icon='RESTRICT_RENDER_OFF' if gd.coll_rendered else 'RESTRICT_RENDER_ON' - ) - - box.use_property_split = True - box.use_property_decorate = False - - col = box.column() + col = layout.column(align=True) + col.use_property_split = True + col.use_property_decorate = False col.prop(gd, "scale", text='Scaling', expand=True) row = col.row() row.prop(gd, "filter_width", text="Filtering") row.separator() # NOTE: Odd spacing without these - row.prop(gd, "filter", text="") + row.prop(gd, "use_filtering", text="") row = col.row() row.prop(gd, "grid_subdivs", text="Grid") row.separator() @@ -116,62 +87,75 @@ def draw(self, context: Context): row.prop(gd, "reference", text='Reference') row.operator("grab_doc.load_reference", text="", icon='FILE_FOLDER') - # Output - self.export_path_exists = \ - os.path.exists(bpy.path.abspath(gd.export_path)) - layout = self.layout - layout.activate_init = True - layout.use_property_split = True - layout.use_property_decorate = False +class GRABDOC_PT_output(GDPanel): + bl_label = 'Output' + bl_parent_id = "GRABDOC_PT_grabdoc" + + @classmethod + def poll(cls, _context: Context) -> bool: + return is_scene_valid() + + def draw_header(self, _context: Context): + self.layout.label(icon='OUTPUT') - box = layout.box() + def draw_header_preset(self, context: Context): + mt_executable = get_user_preferences().mt_executable + if context.scene.gd.engine == 'marmoset' \ + and not os.path.exists(mt_executable): + self.layout.enabled = False + self.layout.scale_x = 1 + self.layout.operator("grab_doc.baker_export", + text="Export", icon="EXPORT") - split = box.split(factor=.5) - split.label(text="Output", icon="OUTPUT") + def mt_header_layout(self, layout: UILayout): + preferences = get_user_preferences() + mt_executable = preferences.mt_executable + + col = layout.column(align=True) + row = col.row() + if not os.path.exists(mt_executable): + row.alignment = 'CENTER' + row.label(text="Marmoset Toolbag Executable Required", icon='INFO') + row = col.row() + row.prop(preferences, 'mt_executable', text="Executable Path") + return + row.prop(preferences, 'mt_executable', text="Executable Path") + row = col.row(align=True) + row.scale_y = 1.25 + row.operator("grab_doc.bake_marmoset", text="Bake in Marmoset", + icon="EXPORT").send_type = 'open' + row.operator("grab_doc.bake_marmoset", + text="", icon='FILE_REFRESH').send_type = 'refresh' + def draw(self, context: Context): gd = context.scene.gd - preferences = bpy.context.preferences.addons[__package__].preferences - marmo_executable = preferences.marmo_executable - row = split.row(align=True) - if gd.baker_type == 'marmoset' \ - and not os.path.exists(marmo_executable): - row.enabled = False - row.operator("grab_doc.export_maps", - text="Export", icon="EXPORT") + layout = self.layout + layout.activate_init = True + layout.use_property_split = True + layout.use_property_decorate = False - if gd.baker_type == 'marmoset': - self.marmo_header_layout(box) + if gd.engine == 'marmoset': + self.mt_header_layout(layout) - col2 = box.column() + col2 = layout.column() row = col2.row() - row.enabled = not gd.preview_state - row.prop(gd, 'baker_type', text="Baker") - col2.separator(factor=.5) - + row.prop(gd, 'engine') row = col2.row() - row.alert = not self.export_path_exists - row.prop(gd, 'export_path', text="Path") - row.alert = False - row.operator( - "grab_doc.open_folder", - text="", - icon="FOLDER_REDIRECT" - ) - col2.prop(gd, "export_name", text="Name") + row.prop(gd, 'filepath', text="Path") + row.operator("grab_doc.open_folder", + text="", icon="FOLDER_REDIRECT") + col2.prop(gd, "filename", text="Name") row = col2.row() row.prop(gd, "resolution_x", text='Resolution') row.prop(gd, "resolution_y", text='') - row.prop( - gd, 'lock_res', - icon_only=True, - icon="LOCKED" if gd.lock_res else "UNLOCKED" - ) + row.prop(gd, 'resolution_lock', icon_only=True, + icon="LOCKED" if gd.resolution_lock else "UNLOCKED") row = col2.row() - if gd.baker_type == "marmoset": - image_format = "marmo_format" + if gd.engine == "marmoset": + image_format = "mt_format" else: image_format = "format" row.prop(gd, image_format) @@ -179,7 +163,7 @@ def draw(self, context: Context): row2 = row.row() if gd.format == "OPEN_EXR": row2.prop(gd, "exr_depth", expand=True) - elif gd.format != "TARGA" or gd.baker_type == 'marmoset': + elif gd.format != "TARGA" or gd.engine == 'marmoset': row2.prop(gd, "depth", expand=True) else: row2.enabled = False @@ -194,92 +178,84 @@ def draw(self, context: Context): else: # TIFF row.prop(image_settings, "tiff_codec", text="Codec") - if gd.baker_type == "marmoset": + if gd.engine == "marmoset": row = col2.row(align=True) - row.prop(gd, "marmo_samples", text="Samples", expand=True) - - col = box.column(align=True) - col.prop( - gd, "use_bake_collections", - text="Bake Groups" - ) - col.prop( - gd, "export_plane", - text='Export Plane' - ) + row.prop(gd, "mt_samples", text="Samples", expand=True) + + col = layout.column(align=True) + col.prop(gd, "use_bake_collection", text="Bake Groups") + col.prop(gd, "export_plane", text='Export Plane') col.prop(gd, 'use_pack_maps') if gd.use_pack_maps: col.prop(gd, 'remove_original_maps') - if gd.baker_type == "marmoset": - col.prop( - gd, 'marmo_auto_bake', - text='Bake on Import' - ) + if gd.engine == "marmoset": + col.prop(gd, 'mt_auto_bake', text='Bake on Import') row = col.row() - row.enabled = gd.marmo_auto_bake - row.prop( - gd, 'marmo_auto_close', - text='Close after Baking' - ) + row.enabled = gd.mt_auto_bake + row.prop(gd, 'mt_auto_close', text='Close after Baking') -class GRABDOC_PT_view_edit_maps(PanelInfo, Panel): - bl_label = 'Bake Maps' +class GRABDOC_PT_bake_maps(GDPanel): + bl_label = 'Bake Maps' bl_parent_id = "GRABDOC_PT_grabdoc" @classmethod def poll(cls, _context: Context) -> bool: - return proper_scene_setup() + return is_scene_valid() - def draw_header_preset(self, _context: Context): - layout = self.layout + def draw_header(self, _context: Context): + self.layout.label(icon='SHADING_RENDERED') - row = layout.row(align=True) - row.operator("grab_doc.config_maps", - emboss=False, text="", icon="SETTINGS") + def draw_header_preset(self, _context: Context): + self.layout.operator("grab_doc.baker_visibility", + emboss=False, text="", icon="SETTINGS") def draw(self, context: Context): - gd = context.scene.gd + if not context.scene.gd.preview_state: + return layout = self.layout col = layout.column(align=True) - if not gd.preview_state: - return - row = col.row(align=True) + row.alert = True row.scale_y = 1.5 - row.operator("grab_doc.leave_modal", icon="CANCEL") + row.operator("grab_doc.baker_preview_exit", icon="CANCEL") row = col.row(align=True) row.scale_y = 1.1 - baker = getattr(gd, gd.preview_type)[0] - row.operator( - "grab_doc.export_preview", - text=f"Export {baker.NAME}", - icon="EXPORT" - ) + + gd = context.scene.gd + baker = getattr(gd, gd.preview_map_type)[gd.preview_index] + row.operator("grab_doc.baker_export_preview", + text=f"Export {baker.NAME}", icon="EXPORT") baker.draw(context, layout) -class GRABDOC_PT_pack_maps(PanelInfo, Panel): - bl_label = 'Pack Maps' +class GRABDOC_PT_pack_maps(GDPanel): + bl_label = 'Pack Maps' bl_parent_id = "GRABDOC_PT_grabdoc" + bl_options = {'DEFAULT_CLOSED'} @classmethod def poll(cls, context: Context) -> bool: - return proper_scene_setup() and GRABDOC_OT_export_maps.poll(context) + if context.scene.gd.preview_state: + return False + return is_scene_valid() + + def draw_header(self, _context: Context): + self.layout.label(icon='RENDERLAYERS') def draw_header_preset(self, _context: Context): - self.layout.operator("grab_doc.pack_maps", icon='IMAGE_DATA') + self.layout.scale_x = .9 + self.layout.operator("grab_doc.baker_pack") def draw(self, context: Context): - gd = context.scene.gd - layout = self.layout layout.use_property_split = True layout.use_property_decorate = False + gd = context.scene.gd col = layout.column(align=True) col.prop(gd, 'channel_r') col.prop(gd, 'channel_g') @@ -288,110 +264,85 @@ def draw(self, context: Context): col.prop(gd, 'pack_name', text="Suffix") -################################################ -# BAKER UI -################################################ - - -class BakerPanel(): - ID = "" - NAME = "" +class BakerPanel(GDPanel): + bl_parent_id = "GRABDOC_PT_bake_maps" + bl_options = {'HEADER_LAYOUT_EXPAND', 'DEFAULT_CLOSED'} - bl_parent_id = "GRABDOC_PT_view_edit_maps" - bl_options = {'HEADER_LAYOUT_EXPAND', 'DEFAULT_CLOSED'} + baker = None @classmethod def poll(cls, context: Context) -> bool: - baker = getattr(context.scene.gd, cls.ID)[0] - return not context.scene.gd.preview_state and baker.visibility - - def __init__(self): - self.baker = getattr(bpy.context.scene.gd, self.ID) - if self.baker is None: - # TODO: Handle this in the future; usually - # only happens in manually "broken" blend files - return - self.baker = self.baker[0] + if cls.baker is None: + return False + return not context.scene.gd.preview_state and cls.baker.visibility def draw_header(self, _context: Context): row = self.layout.row(align=True) + + baker_name = self.baker.NAME + if self.baker.ID == 'custom': + baker_name = self.baker.suffix.capitalize() + if not isinstance(self.baker.node_tree, NodeTree): + row.enabled = False + + index = self.baker.index + if index > 0 and not self.baker.ID == 'custom': + baker_name = f"{self.baker.NAME} {index+1}" + text = f"{baker_name} Preview".replace("_", " ") + row.separator(factor=.5) row.prop(self.baker, 'enabled', text="") - row.operator( - "grab_doc.preview_map", - text=f"{self.NAME} Preview" - ).map_name = self.ID - row.operator( - "grab_doc.single_render", - text="", - icon="RENDER_STILL" - ).map_name = self.ID + preview = row.operator("grab_doc.baker_preview", text=text) + preview.map_type = self.baker.ID + preview.baker_index = index + + row.operator("grab_doc.baker_export_single", text="", + icon='RENDER_STILL').map_type = self.baker.ID row.separator(factor=1.3) def draw(self, context: Context): self.baker.draw(context, self.layout) -class GRABDOC_PT_normals(BakerPanel, PanelInfo, Panel): - ID = Global.NORMAL_ID - NAME = Global.NORMAL_NAME - -class GRABDOC_PT_height(BakerPanel, PanelInfo, Panel): - ID = Global.HEIGHT_ID - NAME = Global.HEIGHT_NAME - -class GRABDOC_PT_alpha(BakerPanel, PanelInfo, Panel): - ID = Global.ALPHA_ID - NAME = Global.ALPHA_NAME - -class GRABDOC_PT_occlusion(BakerPanel, PanelInfo, Panel): - ID = Global.OCCLUSION_ID - NAME = Global.OCCLUSION_NAME - -class GRABDOC_PT_curvature(BakerPanel, PanelInfo, Panel): - ID = Global.CURVATURE_ID - NAME = Global.CURVATURE_NAME - -class GRABDOC_PT_emissive(BakerPanel, PanelInfo, Panel): - ID = Global.EMISSIVE_ID - NAME = Global.EMISSIVE_NAME - -class GRABDOC_PT_id(BakerPanel, PanelInfo, Panel): - ID = Global.MATERIAL_ID - NAME = Global.MATERIAL_NAME - -class GRABDOC_PT_color(BakerPanel, PanelInfo, Panel): - ID = Global.COLOR_ID - NAME = Global.COLOR_NAME - -class GRABDOC_PT_roughness(BakerPanel, PanelInfo, Panel): - ID = Global.ROUGHNESS_ID - NAME = Global.ROUGHNESS_NAME - -class GRABDOC_PT_metallic(BakerPanel, PanelInfo, Panel): - ID = Global.METALLIC_ID - NAME = Global.METALLIC_NAME - ################################################ # REGISTRATION ################################################ -classes = ( +def create_baker_panels(): + """Creates panels for every item in the baker + `CollectionProperty`s utilizing dynamic subclassing.""" + baker_classes = [] + for baker_prop in get_baker_collections(): + for baker in baker_prop: + class_name = f"GRABDOC_PT_{baker.ID}_{baker.index}" + panel_cls = type(class_name, (BakerPanel,), {}) + panel_cls.baker = baker + if baker.index > 0: + baker.node_name = baker.get_node_name(baker.NAME, baker.index+1) + if not baker.suffix[-1].isdigit(): + baker.suffix = f"{baker.suffix}_{baker.index+1}" + baker_classes.append(panel_cls) + return baker_classes + + +def register_baker_panels(): + for cls in BakerPanel.__subclasses__(): + try: + bpy.utils.unregister_class(cls) + except RuntimeError: + continue + for cls in create_baker_panels(): + bpy.utils.register_class(cls) + + +classes = [ GRABDOC_PT_grabdoc, - GRABDOC_PT_view_edit_maps, - GRABDOC_PT_pack_maps, - GRABDOC_PT_normals, - GRABDOC_PT_height, - GRABDOC_PT_alpha, - GRABDOC_PT_occlusion, - GRABDOC_PT_curvature, - GRABDOC_PT_emissive, - GRABDOC_PT_id, - GRABDOC_PT_color, - GRABDOC_PT_roughness, - GRABDOC_PT_metallic -) + GRABDOC_PT_scene, + GRABDOC_PT_output, + GRABDOC_PT_bake_maps, + GRABDOC_PT_pack_maps +] def register(): for cls in classes: diff --git a/utils/baker.py b/utils/baker.py index 6cf73ac..1e7697f 100644 --- a/utils/baker.py +++ b/utils/baker.py @@ -1,983 +1,67 @@ import os import bpy -from bpy.types import Context, PropertyGroup, UILayout -from bpy.props import ( - BoolProperty, - StringProperty, - EnumProperty, - IntProperty, - FloatProperty -) +from bpy.types import Context +from bpy.props import CollectionProperty +from ..baker import Baker from ..constants import Global -from .generic import get_format -from .render import set_guide_height, get_rendered_objects -from .scene import scene_setup - - -def get_render_engine() -> str: - if bpy.app.version >= (4, 2, 0): - return "blender_eevee_next" - return "blender_eevee" - - -################################################ -# BAKERS -################################################ - - -class Baker(): - # NOTE: Variables and their - # option types for sub-classes - ID = "" - NAME = ID.capitalize() - NODE = None - COLOR_SPACE = "sRGB" - VIEW_TRANSFORM = 'Standard' - MARMOSET_COMPATIBLE = True - SUPPORTED_ENGINES = ( - (get_render_engine(), "EEVEE", ""), - ('cycles', "Cycles", ""), - ('blender_workbench', "Workbench", "") - ) - - # NOTE: Default functions - def setup(self): - """Operations to run before bake map export.""" - self.apply_render_settings(requires_preview=False) - - def apply_render_settings(self, requires_preview: bool=True) -> None: - if requires_preview and not bpy.context.scene.gd.preview_state: - return - - scene = bpy.context.scene - scene.render.engine = str(self.engine).upper() - - # NOTE: Allow use of custom engines but leave default - if scene.render.engine == get_render_engine().capitalize(): - scene.eevee.taa_render_samples = \ - scene.eevee.taa_samples = self.samples - elif scene.render.engine == 'CYCLES': - scene.cycles.samples = \ - scene.cycles.preview_samples = self.samples_cycles - elif scene.render.engine == 'BLENDER_WORKBENCH': - scene.display.render_aa = \ - scene.display.viewport_aa = self.samples_workbench - - set_color_management( - self.COLOR_SPACE, - self.VIEW_TRANSFORM, - self.contrast.replace('_', ' ') - ) - - def cleanup(self): - """Operations to run after bake map export conclusion.""" - - def draw_properties(self, context: Context, layout: UILayout): - pass - - def draw(self, context: Context, layout: UILayout): - """Draw layout for contextual bake map properties and operators.""" - gd = context.scene.gd - - layout.use_property_split = True - layout.use_property_decorate = False - - col = layout.column() - - if not self.MARMOSET_COMPATIBLE: - box = col.box() - col2 = box.column(align=True) - col2.label(text="\u2022 Requires Shader Manipulation", icon='INFO') - col2.label(text="\u2022 No Marmoset Support", icon='BLANK1') - - box = col.box() - box.label(text="Properties", icon="PROPERTIES") - if len(self.SUPPORTED_ENGINES) > 1: - box.prop(self, 'engine', text="Engine") - self.draw_properties(context, box) - - box = col.box() - box.label(text="Settings", icon="SETTINGS") - col = box.column() - if gd.baker_type == 'blender': - col.prop(self, 'reimport', text="Re-import") - if self.engine == get_render_engine(): - prop = 'samples' - elif self.engine == 'blender_workbench': - prop = 'samples_workbench' - else: # Cycles - prop = 'samples_cycles' - col.prop( - self, - prop, - text='Samples' - ) - col.prop(self, 'contrast', text="Contrast") - col.prop(self, 'suffix', text="Suffix") - - # NOTE: Default properties - enabled: BoolProperty( - name="Export Enabled", - default=True # TODO: Could set this based on marmoset compat - ) - reimport: BoolProperty( - name="Reimport Texture", - description="Reimport bake map texture into a Blender material" - ) - suffix: StringProperty( - name="Suffix", - description="The suffix of the exported bake map", - # NOTE: `default` not captured in sub-classes - # so you must set after item creation for now - default=ID - ) - visibility: BoolProperty(default=True) - samples: IntProperty( - name="EEVEE Samples", default=128, min=1, soft_max=512, - update=apply_render_settings - ) - samples_cycles: IntProperty( - name="Cycles Samples", default=16, min=1, soft_max=1024, - update=apply_render_settings - ) - samples_workbench: EnumProperty( - items=( - ('OFF', "No Anti-Aliasing", ""), - ('FXAA', "1 Sample", ""), - ('5', "5 Samples", ""), - ('8', "8 Samples", ""), - ('11', "11 Samples", ""), - ('16', "16 Samples", ""), - ('32', "32 Samples", "") - ), - default="16", - name="Workbench Samples", - update=apply_render_settings - ) - contrast: EnumProperty( - items=( - ('None', "None (Medium)", ""), - ('Very_High_Contrast', "Very High", ""), - ('High_Contrast', "High", ""), - ('Medium_High_Contrast', "Medium High", ""), - ('Medium_Low_Contrast', "Medium Low", ""), - ('Low_Contrast', "Low", ""), - ('Very_Low_Contrast', "Very Low", "") - ), - name="Contrast", - update=apply_render_settings - ) - # NOTE: You must add the following redundant - # properties to all sub-classes for now... - engine: EnumProperty( - items=SUPPORTED_ENGINES, - name='Render Engine', - update=apply_render_settings - ) - - -class Normals(Baker, PropertyGroup): - ID = Global.NORMAL_ID - NAME = Global.NORMAL_NAME - NODE = Global.NORMAL_NODE - COLOR_SPACE = "sRGB" - VIEW_TRANSFORM = "Raw" - MARMOSET_COMPATIBLE = True - SUPPORTED_ENGINES = ( - (get_render_engine(), "EEVEE", ""), - ('cycles', "Cycles", "") - ) - - def setup(self) -> None: - super().setup() - - ng_normal = bpy.data.node_groups[self.NODE] - vec_transform = ng_normal.nodes.get('Vector Transform') - group_output = ng_normal.nodes.get('Group Output') - - links = ng_normal.links - if self.use_texture: - links.new( - vec_transform.inputs["Vector"], - ng_normal.nodes.get('Bevel').outputs["Normal"] - ) - links.new( - group_output.inputs["Shader"], - ng_normal.nodes.get('Mix Shader').outputs["Shader"] - ) - else: - links.new( - vec_transform.inputs["Vector"], - ng_normal.nodes.get('Bevel.001').outputs["Normal"] - ) - links.new( - group_output.inputs["Shader"], - ng_normal.nodes.get('Vector Math.001').outputs["Vector"] - ) - - def draw_properties(self, context: Context, layout: UILayout): - col = layout.column() - col.prop(self, 'flip_y', text="Flip Y (-Y)") - if context.scene.gd.baker_type == 'blender': - col.prop(self, 'use_texture', text="Texture Normals") - - def update_flip_y(self, _context: Context): - vec_multiply = \ - bpy.data.node_groups[self.NODE].nodes.get( - 'Vector Math' - ) - vec_multiply.inputs[1].default_value[1] = -.5 if self.flip_y else .5 - - def update_use_texture(self, context: Context) -> None: - if not context.scene.gd.preview_state: - return - tree = bpy.data.node_groups[self.NODE] - vec_transform = tree.nodes.get('Vector Transform') - group_output = tree.nodes.get('Group Output') - - links = tree.links - if self.use_texture: - links.new( - vec_transform.inputs["Vector"], - tree.nodes.get('Bevel').outputs["Normal"] - ) - links.new( - group_output.inputs["Shader"], - tree.nodes.get('Mix Shader').outputs["Shader"] - ) - else: - links.new( - vec_transform.inputs["Vector"], - tree.nodes.get('Bevel.001').outputs["Normal"] - ) - links.new( - group_output.inputs["Shader"], - tree.nodes.get('Vector Math.001').outputs["Vector"] - ) - - flip_y: BoolProperty( - name="Flip Y (-Y)", - description="Flip the normal map Y direction (DirectX format)", - options={'SKIP_SAVE'}, - update=update_flip_y - ) - use_texture: BoolProperty( - name="Use Texture Normals", - description="Use texture normals linked to the Principled BSDF", - options={'SKIP_SAVE'}, - default=True, - update=update_use_texture - ) - engine: EnumProperty( - items=SUPPORTED_ENGINES, - name='Render Engine', - update=Baker.apply_render_settings - ) - - -class Curvature(Baker, PropertyGroup): - ID = Global.CURVATURE_ID - NAME = Global.CURVATURE_NAME - NODE = Global.CURVATURE_NODE - COLOR_SPACE = "sRGB" - VIEW_TRANSFORM = "Standard" - MARMOSET_COMPATIBLE = True - SUPPORTED_ENGINES = ( - ('blender_workbench', "Workbench", ""), - ('cycles', "Cycles", "") - ) - - def setup(self) -> None: - super().setup() - scene = bpy.context.scene - scene_shading = scene.display.shading - self.savedCavityType = scene_shading.cavity_type - self.savedCavityRidgeFactor = scene_shading.cavity_ridge_factor - self.savedCurveRidgeFactor = scene_shading.curvature_ridge_factor - self.savedCavityValleyFactor = scene_shading.cavity_valley_factor - self.savedCurveValleyFactor = scene_shading.curvature_valley_factor - self.savedRidgeDistance = scene.display.matcap_ssao_distance - self.savedSingleColor = [*scene_shading.single_color] - scene_shading.light = 'FLAT' - scene_shading.color_type = 'SINGLE' - scene_shading.show_cavity = True - scene_shading.cavity_type = 'BOTH' - scene_shading.cavity_ridge_factor = \ - scene_shading.curvature_ridge_factor = self.ridge - scene_shading.curvature_valley_factor = self.valley - scene_shading.cavity_valley_factor = 0 - scene_shading.single_color = (.214041, .214041, .214041) - scene.display.matcap_ssao_distance = .075 - self.update_range(bpy.context) - - def apply_render_settings(self, requires_preview: bool=True): - super().apply_render_settings(requires_preview) - scene = bpy.context.scene - if scene.render.engine == 'CYCLES': - set_color_management(self.COLOR_SPACE, "Raw") - else: - set_color_management(self.COLOR_SPACE, self.VIEW_TRANSFORM) - - - def draw_properties(self, context: Context, layout: UILayout): - if context.scene.gd.baker_type != 'blender': - return - col = layout.column() - if context.scene.render.engine == 'BLENDER_WORKBENCH': - col.prop(self, 'ridge', text="Ridge") - col.prop(self, 'valley', text="Valley") - elif context.scene.render.engine == 'CYCLES': - col.prop(self, 'range', text="Range") - - def cleanup(self) -> None: - display = \ - bpy.data.scenes[str(bpy.context.scene.name)].display - display.shading.cavity_ridge_factor = self.savedCavityRidgeFactor - display.shading.curvature_ridge_factor = self.savedCurveRidgeFactor - display.shading.cavity_valley_factor = self.savedCavityValleyFactor - display.shading.curvature_valley_factor = self.savedCurveValleyFactor - display.shading.single_color = self.savedSingleColor - display.shading.cavity_type = self.savedCavityType - display.matcap_ssao_distance = self.savedRidgeDistance - - bpy.data.objects[Global.BG_PLANE_NAME].color[3] = 1 - - def update_curvature(self, context: Context): - if not context.scene.gd.preview_state: - return - scene_shading = context.scene.display.shading - scene_shading.cavity_ridge_factor = \ - scene_shading.curvature_ridge_factor = self.ridge - scene_shading.curvature_valley_factor = self.valley - - def update_range(self, _context: Context): - color_ramp = \ - bpy.data.node_groups[self.NODE].nodes.get("Color Ramp") - color_ramp.color_ramp.elements[0].position = \ - 0.49 - (self.range/2+.01) - color_ramp.color_ramp.elements[2].position = \ - 0.51 + (self.range/2-.01) - - ridge: FloatProperty( - name="", - default=2, - min=0, - max=2, - precision=3, - step=.1, - update=update_curvature, - subtype='FACTOR' - ) - valley: FloatProperty( - name="", - default=1.5, - min=0, - max=2, - precision=3, - step=.1, - update=update_curvature, - subtype='FACTOR' - ) - range: FloatProperty( - name="", - default=.05, - min=0, - max=1, - step=.1, - update=update_range, - subtype='FACTOR' - ) - engine: EnumProperty( - items=SUPPORTED_ENGINES, - name='Render Engine', - update=apply_render_settings - ) - - -class Occlusion(Baker, PropertyGroup): - ID = Global.OCCLUSION_ID - NAME = Global.OCCLUSION_NAME - NODE = Global.OCCLUSION_NODE - COLOR_SPACE = "sRGB" - VIEW_TRANSFORM = "Raw" - MARMOSET_COMPATIBLE = True - SUPPORTED_ENGINES = ( - (get_render_engine(), "EEVEE", ""), - ('cycles', "Cycles", "") - ) - - def setup(self) -> None: - super().setup() - scene = bpy.context.scene - - eevee = scene.eevee - self.savedUseOverscan = eevee.use_overscan - self.savedOverscanSize = eevee.overscan_size - if scene.render.engine == get_render_engine().capitalize(): - eevee.use_gtao = True - # NOTE: Overscan helps with screenspace effects - eevee.use_overscan = True - eevee.overscan_size = 25 - - def cleanup(self) -> None: - eevee = bpy.context.scene.eevee - eevee.use_overscan = self.savedUseOverscan - eevee.overscan_size = self.savedOverscanSize - eevee.use_gtao = False - - def draw_properties(self, context: Context, layout: UILayout): - gd = context.scene.gd - col = layout.column() - if gd.baker_type == 'marmoset': - col.prop(gd, "marmo_occlusion_ray_count", text="Ray Count") - return - col.prop(self, 'gamma', text="Intensity") - col.prop(self, 'distance', text="Distance") - - def update_gamma(self, _context: Context): - gamma = bpy.data.node_groups[self.NODE].nodes.get('Gamma') - gamma.inputs[1].default_value = self.gamma - - def update_distance(self, _context: Context): - ao = bpy.data.node_groups[self.NODE].nodes.get( - 'Ambient Occlusion' - ) - ao.inputs[1].default_value = self.distance - - gamma: FloatProperty( - default=1, - min=.001, - soft_max=10, - step=.17, - name="", - description="Intensity of AO (calculated with gamma)", - update=update_gamma - ) - distance: FloatProperty( - default=1, - min=0, - soft_max=100, - step=.03, - subtype='DISTANCE', - name="", - description="The distance AO rays travel", - update=update_distance - ) - engine: EnumProperty( - items=SUPPORTED_ENGINES, - name='Render Engine', - update=Baker.apply_render_settings - ) - - -class Height(Baker, PropertyGroup): - ID = Global.HEIGHT_ID - NAME = Global.HEIGHT_NAME - NODE = Global.HEIGHT_NODE - COLOR_SPACE = "sRGB" - VIEW_TRANSFORM = "Raw" - MARMOSET_COMPATIBLE = True - SUPPORTED_ENGINES = ( - (get_render_engine(), "EEVEE", ""), - ('cycles', "Cycles", "") - ) - - def setup(self) -> None: - super().setup() - - if self.method == 'AUTO': - rendered_obs = get_rendered_objects() - set_guide_height(rendered_obs) - - def draw_properties(self, context: Context, layout: UILayout): - col = layout.column() - if context.scene.gd.baker_type == 'blender': - col.prop(self, 'invert', text="Invert") - row = col.row() - row.prop(self, 'method', text="Height Mode", expand=True) - if self.method == 'MANUAL': - col.prop(self, 'distance', text="0-1 Range") - - def update_method(self, context: Context): - scene_setup(self, context) - if not context.scene.gd.preview_state: - return - if self.method == 'AUTO': - rendered_obs = get_rendered_objects() - set_guide_height(rendered_obs) - - def update_guide(self, context: Context): - gd_camera_ob_z = \ - bpy.data.objects.get(Global.TRIM_CAMERA_NAME).location[2] - - map_range = \ - bpy.data.node_groups[self.NODE].nodes.get('Map Range') - map_range.inputs[1].default_value = \ - gd_camera_ob_z + -self.distance - map_range.inputs[2].default_value = \ - gd_camera_ob_z - - ramp = bpy.data.node_groups[self.NODE].nodes.get( - 'ColorRamp') - ramp.color_ramp.elements[0].color = \ - (0, 0, 0, 1) if self.invert else (1, 1, 1, 1) - ramp.color_ramp.elements[1].color = \ - (1, 1, 1, 1) if self.invert else (0, 0, 0, 1) - ramp.location = \ - (-400, 0) - - if self.method == 'MANUAL': - scene_setup(self, context) - - invert: BoolProperty( - description="Invert height mask, useful for sculpting negatively", - update=update_guide - ) - distance: FloatProperty( - name="", - default=1, - min=.01, - soft_max=100, - step=.03, - subtype='DISTANCE', - update=update_guide - ) - method: EnumProperty( - items=( - ('AUTO', "Auto", ""), - ('MANUAL', "Manual", "") - ), - update=update_method, - description="Height method, use manual if auto produces range errors" - ) - engine: EnumProperty( - items=SUPPORTED_ENGINES, - name='Render Engine', - update=Baker.apply_render_settings - ) - - -class Id(Baker, PropertyGroup): - ID = Global.MATERIAL_ID - NAME = Global.MATERIAL_NAME - NODE = None - MARMOSET_COMPATIBLE = True - COLOR_SPACE = "sRGB" - VIEW_TRANSFORM = "Standard" - SUPPORTED_ENGINES = ( - ('blender_workbench', "Workbench", ""), - ) - - def setup(self) -> None: - super().setup() - scene = bpy.context.scene - if scene.render.engine == 'BLENDER_WORKBENCH': - self.update_method(bpy.context) - - def draw_properties(self, context: Context, layout: UILayout): - gd = context.scene.gd - row = layout.row() - if gd.baker_type != 'marmoset': - row.prop(self, 'method', text="ID Method") - if self.method != 'MATERIAL': - return - - col = layout.column(align=True) - col.separator(factor=.5) - col.scale_y = 1.1 - col.operator("grab_doc.quick_id_setup") - - row = col.row(align=True) - row.scale_y = .9 - row.label(text=" Remove:") - row.operator( - "grab_doc.remove_mats_by_name", - text='All' - ).name = Global.RANDOM_ID_PREFIX - - col = layout.column(align=True) - col.separator(factor=.5) - col.scale_y = 1.1 - col.operator("grab_doc.quick_id_selected") - - row = col.row(align=True) - row.scale_y = .9 - row.label(text=" Remove:") - row.operator( - "grab_doc.remove_mats_by_name", - text='All' - ).name = Global.ID_PREFIX - row.operator("grab_doc.quick_remove_selected_mats", - text='Selected') - - def update_method(self, context: Context): - shading = context.scene.display.shading - shading.show_cavity = False - shading.light = 'FLAT' - shading.color_type = self.method - - method_list = ( - ('RANDOM', "Random", ""), - ('MATERIAL', "Material", ""), - ('VERTEX', "Object / Vertex", "") - ) - method: EnumProperty( - items=method_list, - name=f"{NAME} Method", - update=update_method - ) - engine: EnumProperty( - items=SUPPORTED_ENGINES, - name='Render Engine', - update=Baker.apply_render_settings - ) - - -class Alpha(Baker, PropertyGroup): - ID = Global.ALPHA_ID - NAME = Global.ALPHA_NAME - NODE = Global.ALPHA_NODE - COLOR_SPACE = "sRGB" - VIEW_TRANSFORM = "Raw" - VIEW_TRANSFORM = "Standard" - MARMOSET_COMPATIBLE = True - SUPPORTED_ENGINES = ( - (get_render_engine(), "EEVEE", ""), - ('cycles', "Cycles", "") - ) - - def draw_properties(self, context: Context, layout: UILayout): - col = layout.column() - if context.scene.gd.baker_type == 'blender': - col.prop(self, 'invert_depth', text="Invert Depth") - col.prop(self, 'invert_mask', text="Invert Mask") - - def update_alpha(self, _context: Context): - gd_camera_ob_z = \ - bpy.data.objects.get(Global.TRIM_CAMERA_NAME).location[2] - map_range = bpy.data.node_groups[self.NODE].nodes.get('Map Range') - map_range.inputs[1].default_value = gd_camera_ob_z - .00001 - map_range.inputs[2].default_value = gd_camera_ob_z - invert_depth = bpy.data.node_groups[self.NODE].nodes.get('Invert Depth') - invert_depth.inputs[0].default_value = 0 if self.invert_depth else 1 - invert_mask = bpy.data.node_groups[self.NODE].nodes.get('Invert Mask') - invert_mask.inputs[0].default_value = 0 if self.invert_mask else 1 - - invert_depth: BoolProperty( - description="Invert the global depth mask", - update=update_alpha - ) - invert_mask: BoolProperty( - description="Invert the alpha mask", - update=update_alpha - ) - engine: EnumProperty( - items=SUPPORTED_ENGINES, - name='Render Engine', - update=Baker.apply_render_settings - ) - - -class Color(Baker, PropertyGroup): - ID = Global.COLOR_ID - NAME = Global.COLOR_NAME - NODE = Global.COLOR_NODE - COLOR_SPACE = "sRGB" - VIEW_TRANSFORM = "Standard" - MARMOSET_COMPATIBLE = False - SUPPORTED_ENGINES = ( - (get_render_engine(), "EEVEE", ""), - ('cycles', "Cycles", "") - ) - - engine: EnumProperty( - items=SUPPORTED_ENGINES, - name='Render Engine', - update=Baker.apply_render_settings - ) - - -class Emissive(Baker, PropertyGroup): - ID = Global.EMISSIVE_ID - NAME = Global.EMISSIVE_NAME - NODE = Global.EMISSIVE_NODE - COLOR_SPACE = "sRGB" - VIEW_TRANSFORM = "Standard" - MARMOSET_COMPATIBLE = False - SUPPORTED_ENGINES = ( - (get_render_engine(), "EEVEE", ""), - ('cycles', "Cycles", "") - ) - - engine: EnumProperty( - items=SUPPORTED_ENGINES, - name='Render Engine', - update=Baker.apply_render_settings - ) - - -class Roughness(Baker, PropertyGroup): - ID = Global.ROUGHNESS_ID - NAME = Global.ROUGHNESS_NAME - NODE = Global.ROUGHNESS_NODE - COLOR_SPACE = "sRGB" - VIEW_TRANSFORM = "Raw" - MARMOSET_COMPATIBLE = False - SUPPORTED_ENGINES = ( - (get_render_engine(), "EEVEE", ""), - ('cycles', "Cycles", "") - ) - - def draw_properties(self, context: Context, layout: UILayout): - col = layout.column() - if context.scene.gd.baker_type == 'blender': - col.prop(self, 'invert', text="Invert") - - def update_roughness(self, _context: Context): - invert = bpy.data.node_groups[self.NODE].nodes.get('Invert') - invert.inputs[0].default_value = 1 if self.invert else 0 - - invert: BoolProperty( - description="Invert the Roughness (AKA Glossiness)", - update=update_roughness - ) - engine: EnumProperty( - items=SUPPORTED_ENGINES, - name='Render Engine', - update=Baker.apply_render_settings - ) - - -class Metallic(Baker, PropertyGroup): - ID = Global.METALLIC_ID - NAME = Global.METALLIC_NAME - NODE = Global.METALLIC_NODE - COLOR_SPACE = "sRGB" - VIEW_TRANSFORM = "Raw" - MARMOSET_COMPATIBLE = False - SUPPORTED_ENGINES = ( - (get_render_engine(), "EEVEE", ""), - ('cycles', "Cycles", "") - ) - - engine: EnumProperty( - items=SUPPORTED_ENGINES, - name='Render Engine', - update=Baker.apply_render_settings - ) - - -################################################ -# UTILITIES -################################################ - - -def set_color_management( - display_device: str='None', - view_transform: str='Standard', - look: str='None' - ) -> None: - """Helper function for supporting custom color management - profiles. Ignores anything that isn't compatible""" - display_settings = bpy.context.scene.display_settings - view_settings = bpy.context.scene.view_settings - display_settings.display_device = display_device - view_settings.view_transform = view_transform - view_settings.look = look - view_settings.exposure = 0 - view_settings.gamma = 1 - view_settings.use_curve_mapping = False - view_settings.use_hdr_view = False - - -def get_bakers() -> list[Baker]: - gd = bpy.context.scene.gd - bakers = [] - for name in Global.ALL_MAP_IDS: - try: - baker = getattr(gd, name) - bakers.append(baker) - except AttributeError: - print(f"Could not find baker `{name}`.") - return bakers - - -def get_bake_maps(enabled_only: bool = True) -> list[Baker]: - bakers = get_bakers() - bake_maps = [] - for baker in bakers: - for bake_map in baker: - if enabled_only and not (bake_map.enabled and bake_map.visibility): - continue - bake_maps.append(bake_map) - return bake_maps - - -def reimport_as_material(map_names: list[str]) -> None: - """Reimport baked textures as a material for use inside of Blender""" - gd = bpy.context.scene.gd - if not map_names: - return - - # Create material - mat = bpy.data.materials.get(Global.REIMPORT_MAT_NAME) - if mat is None: - mat = bpy.data.materials.new(Global.REIMPORT_MAT_NAME) - mat.use_nodes = True - links = mat.node_tree.links - - bsdf = mat.node_tree.nodes['Principled BSDF'] - bsdf.inputs["Emission Color"].default_value = (0,0,0,1) - bsdf.inputs["Emission Strength"].default_value = 1 - - y_offset = 256 - for name in map_names: - # Import images - image = mat.node_tree.nodes.get(name) - if image is None: - image = mat.node_tree.nodes.new('ShaderNodeTexImage') - image.hide = True - image.name = image.label = name - image.location = (-300, y_offset) - y_offset -= 32 - - export_name = f'{gd.export_name}_{name}' - export_path = os.path.join( - bpy.path.abspath(gd.export_path), export_name + get_format() - ) - if not os.path.exists(export_path): - continue - image.image = bpy.data.images.load(export_path, check_existing=True) - - # NOTE: Unique bake map exceptions - if name not in (Global.COLOR_ID, Global.EMISSIVE_ID): - image.image.colorspace_settings.name = 'Non-Color' - if name == Global.NORMAL_ID: - normal = mat.node_tree.nodes.get("Normal Map") - if normal is None: - normal = mat.node_tree.nodes.new('ShaderNodeNormalMap') - normal.hide = True - normal.location = (image.location[0] + 100, image.location[1]) - image.location = (image.location[0] - 200, image.location[1]) - links.new(normal.inputs["Color"], image.outputs["Color"]) - links.new(bsdf.inputs["Normal"], normal.outputs["Normal"]) - elif name == Global.COLOR_ID: - links.new(bsdf.inputs["Base Color"], image.outputs["Color"]) - elif name == Global.EMISSIVE_ID: - links.new(bsdf.inputs["Emission Color"], image.outputs["Color"]) - elif name == Global.METALLIC_ID: - links.new(bsdf.inputs["Metallic"], image.outputs["Color"]) - elif name == Global.CURVATURE_ID: - continue - elif name == Global.OCCLUSION_ID: - # TODO: Could mix AO with Base Color in the future - continue - elif name == Global.HEIGHT_ID: - continue - elif name == Global.MATERIAL_ID: - continue - elif name == Global.ALPHA_ID: - continue - else: - try: - links.new( - bsdf.inputs[name.capitalize()], image.outputs["Color"] - ) - except KeyError: - pass - - -################################################ -# INITIALIZATION & CLEANUP -################################################ - - -# TODO: Preserve use_local_camera & original camera -def baker_init(self, context: Context): - scene = context.scene - gd = scene.gd - render = scene.render +from .io import get_format + + +def baker_setup(context: Context) -> dict: + """Baker scene bootstrapper.""" + scene = context.scene + gd = scene.gd + render = scene.render + eevee = scene.eevee + cycles = scene.cycles + view_layer = context.view_layer + display = scene.display + shading = scene.display.shading + view_settings = scene.view_settings + display_settings = scene.display_settings + image_settings = render.image_settings + + properties = (view_layer, render, eevee, cycles, + shading, display, view_settings, + display_settings, image_settings) + saved_properties = save_properties(properties) + saved_properties['bpy.context.scene.camera'] = scene.camera + saved_properties['bpy.context.scene.gd.reference'] = gd.reference # Active Camera for area in context.screen.areas: - if area.type == 'VIEW_3D': - for space in area.spaces: - space.use_local_camera = False - break + if area.type != 'VIEW_3D': + continue + for space in area.spaces: + space.use_local_camera = False + break scene.camera = bpy.data.objects.get(Global.TRIM_CAMERA_NAME) - - # View layer - self.savedViewLayerUse = context.view_layer.use - self.savedUseSingleLayer = render.use_single_layer - - context.view_layer.use = True - render.use_single_layer = True - if scene.world: scene.world.use_nodes = False - # Render Engine (Set per bake map) - eevee = scene.eevee - self.savedRenderer = render.engine - - # Sampling (Set per bake map) - self.savedWorkbenchSampling = scene.display.render_aa - self.savedWorkbenchVPSampling = scene.display.viewport_aa - self.savedEeveeRenderSampling = eevee.taa_render_samples - self.savedEeveeSampling = eevee.taa_samples - self.savedCyclesSampling = context.scene.cycles.preview_samples - self.savedCyclesRenderSampling = context.scene.cycles.samples - - # Ambient Occlusion - self.savedUseAO = eevee.use_gtao - self.savedAODistance = eevee.gtao_distance - self.savedAOQuality = eevee.gtao_quality - eevee.use_gtao = False # Disable unless needed for AO bakes - eevee.gtao_distance = .2 - eevee.gtao_quality = .5 + view_layer.use = render.use_single_layer = True - # Color Management - view_settings = scene.view_settings - self.savedDisplayDevice = scene.display_settings.display_device - self.savedViewTransform = view_settings.view_transform - self.savedLook = view_settings.look - self.savedExposure = view_settings.exposure - self.savedGamma = view_settings.gamma - self.savedTransparency = render.film_transparent - self.savedCurveMapping = view_settings.use_curve_mapping - self.savedHdrView = view_settings.use_hdr_view + eevee.use_gtao = False + eevee.use_taa_reprojection = False + eevee.gtao_distance = .2 + eevee.gtao_quality = .5 - # Performance - if bpy.app.version >= (2, 83, 0): - self.savedHQNormals = render.use_high_quality_normals - render.use_high_quality_normals = True + cycles.pixel_filter_type = 'BLACKMAN_HARRIS' - # Film - self.savedFilterSize = render.filter_size - self.savedFilterSizeCycles = context.scene.cycles.filter_width - self.savedFilterSizeTypeCycles = context.scene.cycles.pixel_filter_type - render.filter_size = context.scene.cycles.filter_width = gd.filter_width - context.scene.cycles.pixel_filter_type = 'BLACKMAN_HARRIS' - - # Dimensions (NOTE: don't bother saving these) - render.resolution_x = gd.resolution_x - render.resolution_y = gd.resolution_y + render.resolution_x = gd.resolution_x + render.resolution_y = gd.resolution_y render.resolution_percentage = 100 + render.use_sequencer = render.use_compositing = False + render.dither_intensity = 0 # Output - image_settings = render.image_settings - self.savedColorMode = image_settings.color_mode - self.savedFileFormat = image_settings.file_format - self.savedColorDepth = image_settings.color_depth - - # If background plane not visible in render, create alpha channel + # NOTE: If background plane not visible in render, create alpha channel + image_settings.color_mode = 'RGB' if not gd.coll_rendered: - render.film_transparent = True image_settings.color_mode = 'RGBA' - else: - image_settings.color_mode = 'RGB' + render.film_transparent = True # Format image_settings.file_format = gd.format @@ -985,122 +69,143 @@ def baker_init(self, context: Context): image_settings.color_depth = gd.exr_depth elif gd.format != 'TARGA': image_settings.color_depth = gd.depth - if gd.format == "PNG": image_settings.compression = gd.png_compression - # Post Processing - self.savedUseSequencer = render.use_sequencer - self.savedUseCompositor = render.use_compositing - self.savedDitherIntensity = render.dither_intensity - render.use_sequencer = render.use_compositing = False - render.dither_intensity = 0 - # Viewport shading - scene_shading = scene.display.shading - self.savedLight = scene_shading.light - self.savedColorType = scene_shading.color_type - self.savedBackface = scene_shading.show_backface_culling - self.savedXray = scene_shading.show_xray - self.savedShadows = scene_shading.show_shadows - self.savedCavity = scene_shading.show_cavity - self.savedDOF = scene_shading.use_dof - self.savedOutline = scene_shading.show_object_outline - self.savedShowSpec = scene_shading.show_specular_highlight - scene_shading.show_backface_culling = \ - scene_shading.show_xray = \ - scene_shading.show_shadows = \ - scene_shading.show_cavity = \ - scene_shading.use_dof = \ - scene_shading.show_object_outline = \ - scene_shading.show_specular_highlight = False - - # Reference - self.savedRefSelection = gd.reference.name if gd.reference else None + shading.show_backface_culling = \ + shading.show_xray = \ + shading.show_shadows = \ + shading.show_cavity = \ + shading.use_dof = \ + shading.show_object_outline = \ + shading.show_specular_highlight = False # Background plane visibility bg_plane = bpy.data.objects.get(Global.BG_PLANE_NAME) bg_plane.hide_viewport = not gd.coll_visible - bg_plane.hide_render = not gd.coll_rendered + bg_plane.hide_render = not gd.coll_rendered bg_plane.hide_set(False) + return saved_properties -def baker_cleanup(self, context: Context) -> None: - scene = context.scene - gd = scene.gd - render = scene.render - # View layer - context.view_layer.use = self.savedViewLayerUse - scene.render.use_single_layer = self.savedUseSingleLayer +def baker_cleanup(context: Context, properties: dict) -> None: + """Baker core cleanup, reverses any values changed by `baker_setup`.""" + if context.scene.world: + context.scene.world.use_nodes = True + load_properties(properties) - if scene.world: - scene.world.use_nodes = True - - # Render Engine - render.engine = self.savedRenderer - # Sampling - scene.display.render_aa = self.savedWorkbenchSampling - scene.display.viewport_aa = self.savedWorkbenchVPSampling - scene.eevee.taa_render_samples = self.savedEeveeRenderSampling - scene.eevee.taa_samples = self.savedEeveeSampling +def get_baker_by_index( + collection: CollectionProperty, index: int + ) -> Baker | None: + """Get a specific baker based on a given collection + property and custom index property value.""" + for baker in collection: + if baker.index == index: + return baker + return None - self.savedCyclesSampling = context.scene.cycles.preview_samples - self.savedCyclesRenderSampling = context.scene.cycles.samples - # Ambient Occlusion - scene.eevee.use_gtao = self.savedUseAO - scene.eevee.gtao_distance = self.savedAODistance - scene.eevee.gtao_quality = self.savedAOQuality +def get_bakers(filter_enabled: bool = False) -> list[Baker]: + """Get all bakers in the current scene.""" + all_bakers = [] + for baker_id in [baker.ID for baker in Baker.__subclasses__()]: + baker = getattr(bpy.context.scene.gd, baker_id) + # NOTE: Flatten collections into single list + for bake_map in baker: + if filter_enabled \ + and (not bake_map.enabled or not bake_map.visibility): + continue + all_bakers.append(bake_map) + return all_bakers - # Color Management - view_settings = scene.view_settings - scene.display_settings.display_device = self.savedDisplayDevice - view_settings.view_transform = self.savedViewTransform - view_settings.look = self.savedLook - view_settings.exposure = self.savedExposure - view_settings.gamma = self.savedGamma +def get_baker_collections() -> list[CollectionProperty]: + """Get all baker collection properties in the current scene.""" + bakers = [] + for baker_id in [baker.ID for baker in Baker.__subclasses__()]: + baker = getattr(bpy.context.scene.gd, baker_id) + bakers.append(baker) + return bakers - view_settings.use_curve_mapping = self.savedCurveMapping - view_settings.use_hdr_view = self.savedHdrView - scene.render.film_transparent = self.savedTransparency +def save_properties(properties: list) -> dict: + """Store all given iterable properties.""" + saved_properties = {} + for data in properties: + for attr in dir(data): + if data not in saved_properties: + saved_properties[data] = {} + saved_properties[data][attr] = getattr(data, attr) + return saved_properties - # Performance - if bpy.app.version >= (2, 83, 0): - render.use_high_quality_normals = self.savedHQNormals - # Film - render.filter_size = self.savedFilterSize +def load_properties(properties: dict) -> None: + """Set all given properties to their assigned value.""" + custom_properties = {} + for key, values in properties.items(): + if not isinstance(values, dict): + custom_properties[key] = values + continue + for name, value in values.items(): + try: + setattr(key, name, value) + except (AttributeError, TypeError): # Read only attribute + pass + # NOTE: Extra entries added after running `save_properties` + for key, value in custom_properties.items(): + name = key.rsplit('.', maxsplit=1)[-1] + components = key.split('.')[:-1] + root = globals()[components[0]] + components = components[1:] + # Reconstruct attribute chain + obj = root + for part in components: + next_attr = getattr(obj, part) + if next_attr is None: + break + obj = next_attr + if obj == root: + continue + try: + setattr(obj, name, value) + except ReferenceError: + pass - context.scene.cycles.filter_width = self.savedFilterSizeCycles - context.scene.cycles.pixel_filter_type = self.savedFilterSizeTypeCycles - # Output - render.image_settings.color_depth = self.savedColorDepth - render.image_settings.color_mode = self.savedColorMode - render.image_settings.file_format = self.savedFileFormat +def reimport_baker_textures(bakers: list) -> None: + """Reimport baked textures as a material for use inside of Blender""" + gd = bpy.context.scene.gd + if not bakers: + return - # Post Processing - render.use_sequencer = self.savedUseSequencer - render.use_compositing = self.savedUseCompositor + # Create material + mat = bpy.data.materials.get(Global.REIMPORT_MAT_NAME) + if mat is None: + mat = bpy.data.materials.new(Global.REIMPORT_MAT_NAME) + mat.use_nodes = True - render.dither_intensity = self.savedDitherIntensity + bsdf = mat.node_tree.nodes['Principled BSDF'] + bsdf.inputs["Emission Color"].default_value = (0,0,0,1) + bsdf.inputs["Emission Strength"].default_value = 1 - scene_shading = scene.display.shading + # Import and link image textures + y_offset = 256 + for baker in bakers: + image = mat.node_tree.nodes.get(baker.ID) + if image is None: + image = mat.node_tree.nodes.new('ShaderNodeTexImage') + image.hide = True + image.name = image.label = baker.ID + image.location = (-300, y_offset) + y_offset -= 32 - # Refresh - scene_shading.show_cavity = self.savedCavity - scene_shading.color_type = self.savedColorType - scene_shading.show_backface_culling = self.savedBackface - scene_shading.show_xray = self.savedXray - scene_shading.show_shadows = self.savedShadows - scene_shading.use_dof = self.savedDOF - scene_shading.show_object_outline = self.savedOutline - scene_shading.show_specular_highlight = self.savedShowSpec - scene_shading.light = self.savedLight + filename = f'{gd.filename}_{baker.ID}' + filepath = os.path.join(gd.filepath, filename + get_format()) + if not os.path.exists(filepath): + continue + image.image = bpy.data.images.load(filepath, check_existing=True) - if self.savedRefSelection: - gd.reference = bpy.data.images[self.savedRefSelection] + baker.reimport_setup(mat, image) diff --git a/utils/generic.py b/utils/generic.py index 841ccb3..2016459 100644 --- a/utils/generic.py +++ b/utils/generic.py @@ -2,84 +2,18 @@ import re import tomllib from pathlib import Path -from inspect import getframeinfo, stack import bpy -from bpy.types import Context, Operator +from bpy.types import Context -from ..constants import Global, Error - - -class OpInfo: - bl_options = {'REGISTER', 'UNDO'} - bl_label = "" - - -class PanelInfo: - bl_category = 'GrabDoc' - bl_space_type = 'VIEW_3D' - bl_region_type = 'UI' - bl_label = "" +from ..constants import Error class UseSelectedOnly(): @classmethod def poll(cls, context: Context) -> bool: - return True if len(context.selected_objects) else poll_message_error( - cls, Error.NO_OBJECTS_SELECTED - ) - - -def export_plane(context: Context) -> None: - """Export the grabdoc background plane for external use""" - gd = context.scene.gd - - # Save original selection - savedSelection = context.selected_objects - - # Deselect all objects - bpy.ops.object.select_all(action='DESELECT') - - # Select bg plane, export and deselect bg plane - bpy.data.collections[Global.COLL_NAME].hide_select = False - bpy.data.objects[Global.BG_PLANE_NAME].hide_select = False - bpy.data.objects[Global.BG_PLANE_NAME].select_set(True) - - bpy.ops.export_scene.fbx( - filepath=os.path.join( - bpy.path.abspath(gd.export_path), - gd.export_name + '_plane.fbx' - ), - use_selection=True - ) - - bpy.data.objects[Global.BG_PLANE_NAME].select_set(False) - - # Refresh original selection - for ob in savedSelection: - ob.select_set(True) - - if not gd.coll_selectable: - bpy.data.collections[Global.COLL_NAME].hide_select = False - - -def proper_scene_setup() -> bool: - """Look for grabdoc objects to decide - if the scene is setup correctly""" - object_checks = ( - Global.COLL_NAME in bpy.data.collections, - Global.BG_PLANE_NAME in bpy.context.scene.objects, - Global.TRIM_CAMERA_NAME in bpy.context.scene.objects - ) - return True in object_checks - - -def camera_in_3d_view() -> bool: - """Check if we are actively viewing - through the camera in the 3D View""" - return [ - area.spaces.active.region_3d.view_perspective for area in bpy.context.screen.areas if area.type == 'VIEW_3D' - ] == ['CAMERA'] + return True if len(context.selected_objects) \ + else cls.poll_message_set(Error.NO_OBJECTS_SELECTED) def get_version(version: tuple[int, int, int] | None = None) -> str | None: @@ -95,105 +29,15 @@ def get_version(version: tuple[int, int, int] | None = None) -> str | None: return '.'.join(match.groups()) if match else None -def get_temp_path() -> str: - """Gets or creates a temporary directory based on the extensions system.""" - return bpy.utils.extension_path_user( - __package__.rsplit(".", maxsplit=1)[0], path="temp", create=True - ) - - -def get_debug_line_no() -> str: - """Simple method of getting the - line number of a particular error""" - caller = getframeinfo(stack()[2][0]) - file_name = caller.filename.split("\\", -1)[-1] - line_num = caller.lineno - return f"{file_name}:{line_num}" - - -def poll_message_error( - cls: Operator, - error_message: str, - print_err_line: bool=True - ) -> bool: - """Calls the poll_message_set function, for use in operator polls. - This ALWAYS returns a False boolean value if called. - - Parameters - ---------- - error_message : str - Error message to print to the user - print_err_line : bool, optional - Whether the error line will be printed or not, by default True - """ - cls.poll_message_set( - f"{error_message}. ({get_debug_line_no()})'" \ - if print_err_line else f"{error_message}." - ) - return False - - -def get_format() -> str: - """Get the correct file extension based on `format` attribute""" - return f".{Global.IMAGE_FORMATS[bpy.context.scene.gd.format]}" - - -def bad_setup_check( - context: Context, - active_export: bool, - report_value=False, - report_string="" - ) -> tuple[bool, str]: - """Determine if specific parts of the scene - are set up incorrectly and return a detailed - explanation of things for the user to fix""" - gd = context.scene.gd - - if not Global.TRIM_CAMERA_NAME in context.view_layer.objects \ - and not report_value: - report_value = True - report_string = Error.TRIM_CAM_NOT_FOUND - - if gd.use_bake_collections and not report_value: - if not len(bpy.data.collections[Global.COLL_OB_NAME].objects): - report_value = True - report_string = Error.NO_OBJECTS_BAKE_GROUPS +def get_user_preferences(): + package = __package__.rsplit(".", maxsplit=1)[0] + return bpy.context.preferences.addons[package].preferences - if not active_export: - return report_value, report_string - if not os.path.exists(bpy.path.abspath(gd.export_path)) \ - and not report_value: - report_value = True - report_string = Error.NO_VALID_PATH_SET +def enum_members_from_type(rna_type, prop_str): + prop = rna_type.bl_rna.properties[prop_str] + return [e.identifier for e in prop.enum_items] - # Check if all bake maps are disabled - bake_maps = ( - gd.normals[0].enabled, - gd.curvature[0].enabled, - gd.occlusion[0].enabled, - gd.height[0].enabled, - gd.id[0].enabled, - gd.alpha[0].enabled, - gd.color[0].enabled, - gd.emissive[0].enabled, - gd.roughness[0].enabled, - gd.metallic[0].enabled - ) - bake_map_vis = ( - gd.normals[0].visibility, - gd.curvature[0].visibility, - gd.occlusion[0].visibility, - gd.height[0].visibility, - gd.id[0].visibility, - gd.alpha[0].visibility, - gd.color[0].visibility, - gd.emissive[0].visibility, - gd.roughness[0].visibility, - gd.metallic[0].visibility - ) - if True not in bake_maps or True not in bake_map_vis: - report_value = True - report_string = "No bake maps are turned on." - return report_value, report_string +def enum_members_from_instance(rna_item, prop_str): + return enum_members_from_type(type(rna_item), prop_str) diff --git a/utils/io.py b/utils/io.py new file mode 100644 index 0000000..078ffe5 --- /dev/null +++ b/utils/io.py @@ -0,0 +1,48 @@ +import os + +import bpy +from bpy.types import Context + +from ..constants import Global + + +def export_plane(context: Context) -> None: + """Export the grabdoc background plane for external use""" + gd = context.scene.gd + + # Save original selection + savedSelection = context.selected_objects + + # Deselect all objects + bpy.ops.object.select_all(action='DESELECT') + + # Select bg plane, export and deselect bg plane + bpy.data.collections[Global.COLL_CORE_NAME].hide_select = False + bpy.data.objects[Global.BG_PLANE_NAME].hide_select = False + bpy.data.objects[Global.BG_PLANE_NAME].select_set(True) + + bpy.ops.export_scene.fbx( + filepath=os.path.join(gd.filepath, gd.filename + '_plane.fbx'), + use_selection=True + ) + + bpy.data.objects[Global.BG_PLANE_NAME].select_set(False) + + # Refresh original selection + for ob in savedSelection: + ob.select_set(True) + + if not gd.coll_selectable: + bpy.data.collections[Global.COLL_CORE_NAME].hide_select = False + + +def get_temp_path() -> str: + """Gets or creates a temporary directory based on the extensions system.""" + return bpy.utils.extension_path_user( + __package__.rsplit(".", maxsplit=1)[0], path="temp", create=True + ) + + +def get_format() -> str: + """Get the correct file extension based on `format` attribute""" + return f".{Global.IMAGE_FORMATS[bpy.context.scene.gd.format]}" diff --git a/utils/marmoset.py b/utils/marmoset.py index 79a5472..2967a06 100644 --- a/utils/marmoset.py +++ b/utils/marmoset.py @@ -11,7 +11,7 @@ def run_auto_baker(baker, properties: dict) -> None: baker.bake() - os.startfile(properties['export_path']) + os.startfile(properties['filepath']) # TODO: Implement alpha mask # NOTE: There is no alpha support in Marmoset so we use @@ -113,7 +113,7 @@ def shader_setup(properties: dict) -> None: def main(): plugin_path = Path(mset.getPluginPath()).parents[1] temp_path = os.path.join(plugin_path, "temp") - properties_path = os.path.join(temp_path, "marmo_vars.json") + properties_path = os.path.join(temp_path, "mt_vars.json") # Check if file location has been repopulated if not os.path.exists(properties_path): diff --git a/utils/node.py b/utils/node.py index 494f511..e5133c1 100644 --- a/utils/node.py +++ b/utils/node.py @@ -1,748 +1,216 @@ -from typing import Iterable - import bpy -from bpy.types import ( - Object, - ShaderNodeGroup, - NodeSocket, - NodeTree, - Material -) +from bpy.types import Object, NodeTree, NodeTreeInterfaceItem from ..constants import Global -def generate_shader_interface(tree: NodeTree, inputs: dict) -> None: - tree.interface.new_socket( - name="Shader", - socket_type="NodeSocketShader", - in_out='OUTPUT' - ) - saved_links = tree.interface.new_panel( - name="Saved Links", - description="Stored links to restore original socket links later", - default_closed=True - ) - for name, socket_type in inputs.items(): - tree.interface.new_socket( - name=name, - parent=saved_links, - socket_type=socket_type, - in_out='INPUT' - ) +def node_cleanup(node_tree: NodeTree) -> None: + """Remove node group and return original links if they exist""" + inputs = get_material_output_sockets() + for mat in bpy.data.materials: + mat.use_nodes = True + if mat.name == Global.GD_MATERIAL_NAME: + bpy.data.materials.remove(mat) + continue + nodes = mat.node_tree.nodes + if node_tree.name not in nodes: + continue + # Get node group in material + gd_nodes = [node for node in nodes if node.gd_spawn is True] + for gd_node in gd_nodes: + if gd_node.type != 'FRAME': + node = gd_node + break + output_node = None + for output in node.outputs: + for link in output.links: + if link.to_node.type != 'OUTPUT_MATERIAL': + continue + output_node = link.to_node + break + if output_node is None: + for gd_node in gd_nodes: + nodes.remove(gd_node) + continue -def get_material_output_inputs() -> dict: - """Create a dummy node group and capture - the default material output inputs.""" - tree = bpy.data.node_groups.new( - 'Material Output', - 'ShaderNodeTree' - ) - output = tree.nodes.new( - 'ShaderNodeOutputMaterial' - ) - material_output_inputs = {} + # Return original connections + for node_input in node.inputs: + for link in node_input.links: + if node_input.name.split(' ')[-1] not in inputs: + continue + original_node_connection = nodes.get(link.from_node.name) + original_node_socket = link.from_socket.name + for connection_name in inputs: + if node_input.name != connection_name: + continue + mat.node_tree.links.new( + output_node.inputs[connection_name], + original_node_connection.outputs[original_node_socket] + ) + + warning_text = bpy.data.texts.get(Global.NODE_GROUP_WARN_NAME) + if warning_text is not None: + bpy.data.texts.remove(warning_text) + for gd_node in gd_nodes: + nodes.remove(gd_node) + + +def get_material_output_sockets() -> dict: + """Create a dummy node group if none is supplied and + capture the default material output sockets/`inputs`.""" + tree = bpy.data.node_groups.new('Material Output', 'ShaderNodeTree') + output = tree.nodes.new('ShaderNodeOutputMaterial') + material_output_sockets = {} for node_input in output.inputs: - # NOTE: No clue what this input is for - if node_input.name == 'Thickness': - continue - material_output_inputs[node_input.name] = \ - f'NodeSocket{node_input.type.capitalize()}' + node_type = node_input.type.capitalize() + if node_input.type == "VALUE": + node_type = "Float" + bpy_node_type = f'NodeSocket{node_type}' + material_output_sockets[node_input.name] = bpy_node_type bpy.data.node_groups.remove(tree) - return material_output_inputs - - -def node_init() -> None: - """Initialize all node groups used within GrabDoc""" - gd = bpy.context.scene.gd - - inputs = get_material_output_inputs() - - if not Global.NORMAL_NODE in bpy.data.node_groups: - tree = bpy.data.node_groups.new( - Global.NORMAL_NODE, 'ShaderNodeTree' - ) - tree.use_fake_user = True - - # Create interface - generate_shader_interface(tree, inputs) - alpha = tree.interface.new_socket( - name='Alpha', - socket_type='NodeSocketFloat' - ) - alpha.default_value = 1 - tree.interface.new_socket( - name='Normal', - socket_type='NodeSocketVector', - in_out='INPUT' - ) - - # Create nodes - group_output = tree.nodes.new('NodeGroupOutput') - group_output.name = "Group Output" - group_input = tree.nodes.new('NodeGroupInput') - group_input.name = "Group Input" - group_input.location = (-1400,100) - - bevel = tree.nodes.new('ShaderNodeBevel') - bevel.name = "Bevel" - bevel.inputs[0].default_value = 0 - bevel.location = (-1000,0) - - bevel_2 = tree.nodes.new('ShaderNodeBevel') - bevel_2.name = "Bevel.001" - bevel_2.location = (-1000,-200) - bevel_2.inputs[0].default_value = 0 - - vec_transform = tree.nodes.new('ShaderNodeVectorTransform') - vec_transform.name = "Vector Transform" - vec_transform.vector_type = 'NORMAL' - vec_transform.convert_to = 'CAMERA' - vec_transform.location = (-800,0) - - vec_mult = tree.nodes.new('ShaderNodeVectorMath') - vec_mult.name = "Vector Math" - vec_mult.operation = 'MULTIPLY' - vec_mult.inputs[1].default_value[0] = .5 - vec_mult.inputs[1].default_value[1] = \ - -.5 if gd.normals[0].flip_y else .5 - vec_mult.inputs[1].default_value[2] = -.5 - vec_mult.location = (-600,0) - - vec_add = tree.nodes.new('ShaderNodeVectorMath') - vec_add.name = "Vector Math.001" - vec_add.inputs[1].default_value[0] = \ - vec_add.inputs[1].default_value[1] = \ - vec_add.inputs[1].default_value[2] = 0.5 - vec_add.location = (-400,0) - - invert = tree.nodes.new('ShaderNodeInvert') - invert.name = "Invert" - invert.location = (-1000,200) - - subtract = tree.nodes.new('ShaderNodeMixRGB') - subtract.blend_type = 'SUBTRACT' - subtract.name = "Subtract" - subtract.inputs[0].default_value = 1 - subtract.inputs[1].default_value = (1, 1, 1, 1) - subtract.location = (-800,300) - - transp_shader = tree.nodes.new('ShaderNodeBsdfTransparent') - transp_shader.name = "Transparent BSDF" - transp_shader.location = (-400,200) - - mix_shader = tree.nodes.new('ShaderNodeMixShader') - mix_shader.name = "Mix Shader" - mix_shader.location = (-200,300) - - # Link nodes - links = tree.links - - links.new(bevel.inputs["Normal"], group_input.outputs["Normal"]) - links.new(vec_transform.inputs["Vector"], bevel_2.outputs["Normal"]) - links.new(vec_mult.inputs["Vector"], vec_transform.outputs["Vector"]) - links.new(vec_add.inputs["Vector"], vec_mult.outputs["Vector"]) - links.new(group_output.inputs["Shader"], vec_add.outputs["Vector"]) - - links.new(invert.inputs['Color'], group_input.outputs['Alpha']) - links.new(subtract.inputs['Color2'], invert.outputs['Color']) - links.new(mix_shader.inputs['Fac'], subtract.outputs['Color']) - links.new(mix_shader.inputs[1], transp_shader.outputs['BSDF']) - links.new(mix_shader.inputs[2], vec_add.outputs['Vector']) - - if not Global.CURVATURE_NODE in bpy.data.node_groups: - tree = bpy.data.node_groups.new(Global.CURVATURE_NODE, 'ShaderNodeTree') - tree.use_fake_user = True - - # Create sockets - generate_shader_interface(tree, inputs) - - # Create nodes - group_output = tree.nodes.new('NodeGroupOutput') - group_output.name = "Group Output" - - geometry = tree.nodes.new('ShaderNodeNewGeometry') - geometry.location = (-800, 0) - - color_ramp = tree.nodes.new('ShaderNodeValToRGB') - color_ramp.color_ramp.elements.new(.5) - color_ramp.color_ramp.elements[0].position = 0.49 - color_ramp.color_ramp.elements[2].position = 0.51 - color_ramp.location = (-600, 0) - - emission = tree.nodes.new('ShaderNodeEmission') - emission.name = "Emission" - emission.location = (-200, 0) - - # Link nodes - links = tree.links - links.new(color_ramp.inputs["Fac"], geometry.outputs["Pointiness"]) - links.new(emission.inputs["Color"], color_ramp.outputs["Color"]) - links.new(group_output.inputs["Shader"], emission.outputs["Emission"]) - - if not Global.OCCLUSION_NODE in bpy.data.node_groups: - tree = bpy.data.node_groups.new( - Global.OCCLUSION_NODE, 'ShaderNodeTree' - ) - tree.use_fake_user = True - - # Create sockets - generate_shader_interface(tree, inputs) - alpha = tree.interface.new_socket( - name='Alpha', - socket_type='NodeSocketFloat' - ) - alpha.default_value = 1 - normal=tree.interface.new_socket( - name='Normal', - socket_type='NodeSocketVector', - in_out='INPUT' - ) - normal.default_value= (0.5, 0.5, 1) - - # Create nodes - group_input = tree.nodes.new('NodeGroupInput') - group_input.name = "Group Input" - group_input.location = (-1000,0) - - group_output = tree.nodes.new('NodeGroupOutput') - group_output.name = "Group Output" - - ao = tree.nodes.new('ShaderNodeAmbientOcclusion') - ao.name = "Ambient Occlusion" - ao.samples = 32 - ao.location = (-600,0) - - gamma = tree.nodes.new('ShaderNodeGamma') - gamma.name = "Gamma" - gamma.inputs[1].default_value = gd.occlusion[0].gamma - gamma.location = (-400,0) - - emission = tree.nodes.new('ShaderNodeEmission') - emission.name = "Emission" - emission.location = (-200,0) - - # Link nodes - links = tree.links - links.new(ao.inputs["Normal"],group_input.outputs["Normal"]) - - links.new(gamma.inputs["Color"], ao.outputs["Color"]) - links.new(emission.inputs["Color"], gamma.outputs["Color"]) - links.new(group_output.inputs["Shader"], emission.outputs["Emission"]) - - if not Global.HEIGHT_NODE in bpy.data.node_groups: - tree = bpy.data.node_groups.new( - Global.HEIGHT_NODE, 'ShaderNodeTree' - ) - tree.use_fake_user = True - - # Create sockets - generate_shader_interface(tree, inputs) - - # Create nodes - group_output = tree.nodes.new('NodeGroupOutput') - group_output.name = "Group Output" - - camera = tree.nodes.new('ShaderNodeCameraData') - camera.name = "Camera Data" - camera.location = (-800,0) - - # NOTE: Map Range updates handled on map preview - map_range = tree.nodes.new('ShaderNodeMapRange') - map_range.name = "Map Range" - map_range.location = (-600,0) - - ramp = tree.nodes.new('ShaderNodeValToRGB') - ramp.name = "ColorRamp" - ramp.color_ramp.elements[0].color = (1, 1, 1, 1) - ramp.color_ramp.elements[1].color = (0, 0, 0, 1) - ramp.location = (-400,0) - - # Link nodes - links = tree.links - links.new(map_range.inputs["Value"], camera.outputs["View Z Depth"]) - links.new(ramp.inputs["Fac"], map_range.outputs["Result"]) - links.new(group_output.inputs["Shader"], ramp.outputs["Color"]) - - if not Global.ALPHA_NODE in bpy.data.node_groups: - tree = bpy.data.node_groups.new( - Global.ALPHA_NODE, 'ShaderNodeTree' - ) - tree.use_fake_user = True - - # Create sockets - generate_shader_interface(tree, inputs) - alpha = tree.interface.new_socket( - name=Global.ALPHA_NAME, - socket_type='NodeSocketFloat' - ) - alpha.default_value = 1 - - # Create nodes - group_output = tree.nodes.new('NodeGroupOutput') - group_output.name = "Group Output" - group_input = tree.nodes.new('NodeGroupInput') - group_input.name = "Group Input" - group_input.location = (-1000, 200) - - camera = tree.nodes.new('ShaderNodeCameraData') - camera.name = "Camera Data" - camera.location = (-1000, 0) - - camera_object_z = \ - bpy.data.objects.get(Global.TRIM_CAMERA_NAME).location[2] - - map_range = tree.nodes.new('ShaderNodeMapRange') - map_range.name = "Map Range" - map_range.location = (-800, 0) - map_range.inputs[1].default_value = camera_object_z - .00001 - map_range.inputs[2].default_value = camera_object_z - - invert_mask = tree.nodes.new('ShaderNodeInvert') - invert_mask.name = "Invert Mask" - invert_mask.location = (-600, 200) + return material_output_sockets - invert_depth = tree.nodes.new('ShaderNodeInvert') - invert_depth.name = "Invert Depth" - invert_depth.location = (-600, 0) - mix = tree.nodes.new('ShaderNodeMix') - mix.name = "Invert Mask" - mix.data_type = "RGBA" - mix.inputs["B"].default_value = (0, 0, 0, 1) - mix.location = (-400, 0) - - emission = tree.nodes.new('ShaderNodeEmission') - emission.name = "Emission" - emission.location = (-200,0) - - # Link nodes - links = tree.links - links.new(invert_mask.inputs["Color"], group_input.outputs["Alpha"]) - links.new(mix.inputs["Factor"], invert_mask.outputs["Color"]) - - links.new(map_range.inputs["Value"], camera.outputs["View Z Depth"]) - links.new(invert_depth.inputs["Color"], map_range.outputs["Result"]) - links.new(mix.inputs["A"], invert_depth.outputs["Color"]) - - links.new(emission.inputs["Color"], mix.outputs["Result"]) - links.new(group_output.inputs["Shader"], emission.outputs["Emission"]) - - if not Global.COLOR_NODE in bpy.data.node_groups: - tree = bpy.data.node_groups.new( - Global.COLOR_NODE, 'ShaderNodeTree' - ) - tree.use_fake_user = True - - # Create sockets - generate_shader_interface(tree, inputs) - tree.interface.new_socket( - name=Global.COLOR_NAME, - socket_type='NodeSocketColor' - ) - - # Create nodes - group_output = tree.nodes.new('NodeGroupOutput') - group_output.name = "Group Output" - group_input = tree.nodes.new('NodeGroupInput') - group_input.name = "Group Input" - group_input.location = (-400,0) - - emission = tree.nodes.new('ShaderNodeEmission') - emission.name = "Emission" - emission.location = (-200,0) - - # Link nodes - links = tree.links - links.new(emission.inputs["Color"], group_input.outputs["Base Color"]) - links.new(group_output.inputs["Shader"], emission.outputs["Emission"]) - - if not Global.EMISSIVE_NODE in bpy.data.node_groups: - tree = bpy.data.node_groups.new( - Global.EMISSIVE_NODE, 'ShaderNodeTree' - ) - tree.use_fake_user = True - - # Create sockets - generate_shader_interface(tree, inputs) - emit_color = tree.interface.new_socket( - name="Emission Color", - socket_type='NodeSocketColor', - in_out='INPUT' - ) - emit_color.default_value = (0, 0, 0, 1) - emit_strength = tree.interface.new_socket( - name="Emission Strength", - socket_type='NodeSocketFloat', - in_out='INPUT' - ) - emit_strength.default_value = 1 - - # Create nodes - group_output = tree.nodes.new('NodeGroupOutput') - group_output.name = "Group Output" - group_input = tree.nodes.new('NodeGroupInput') - group_input.name = "Group Input" - group_input.location = (-600, 0) - - emission = tree.nodes.new('ShaderNodeEmission') - emission.name = "Emission" - emission.location = (-200, 0) - - # Link nodes - links = tree.links - links.new( - emission.inputs["Color"], - group_input.outputs["Emission Color"] - ) - links.new( - group_output.inputs["Shader"], - emission.outputs["Emission"] - ) - - if not Global.ROUGHNESS_NODE in bpy.data.node_groups: - tree = bpy.data.node_groups.new( - Global.ROUGHNESS_NODE, 'ShaderNodeTree' - ) - tree.use_fake_user = True - - # Create sockets - generate_shader_interface(tree, inputs) - tree.interface.new_socket( - name='Roughness', - socket_type='NodeSocketFloat', - in_out='INPUT' - ) - - # Create nodes - group_output = tree.nodes.new('NodeGroupOutput') - group_output.name = "Group Output" - group_input = tree.nodes.new('NodeGroupInput') - group_input.name = "Group Input" - group_input.location = (-600,0) - - invert = tree.nodes.new('ShaderNodeInvert') - invert.location = (-400,0) - invert.inputs[0].default_value = 0 - - emission = tree.nodes.new('ShaderNodeEmission') - emission.name = "Emission" - emission.location = (-200,0) - - # Link nodes - links = tree.links - links.new( - invert.inputs["Color"], - group_input.outputs["Roughness"] - ) - links.new( - emission.inputs["Color"], - invert.outputs["Color"] - ) - links.new( - group_output.inputs["Shader"], - emission.outputs["Emission"] - ) - - if not Global.METALLIC_NODE in bpy.data.node_groups: - tree = bpy.data.node_groups.new( - Global.METALLIC_NODE, 'ShaderNodeTree' - ) - tree.use_fake_user = True - - # Create sockets - generate_shader_interface(tree, inputs) - tree.interface.new_socket( - name='Metallic', - socket_type='NodeSocketFloat', - in_out='INPUT' - ) - - # Create nodes - group_output = tree.nodes.new('NodeGroupOutput') - group_output.name = "Group Output" - group_input = tree.nodes.new('NodeGroupInput') - group_input.name = "Group Input" - group_input.location = (-400,0) - - emission = tree.nodes.new('ShaderNodeEmission') - emission.name = "Emission" - emission.location = (-200,0) - - # Link nodes - links = tree.links - links.new( - emission.inputs["Color"], - group_input.outputs["Metallic"] - ) - links.new( - group_output.inputs["Shader"], - emission.outputs["Emission"] - ) - - -def create_node_links( - input_name: str, - node_group: ShaderNodeGroup, - original_input: NodeSocket, - material: Material - ) -> bool: - """Add node group to given material slots and save original links""" - node_found = False - for link in original_input.links: - node_found = True - material.node_tree.links.new( - node_group.inputs[input_name], - link.from_node.outputs[link.from_socket.name] - ) - break - else: - try: - node_group.inputs[input_name].default_value = \ - original_input.default_value - except TypeError: - if isinstance(original_input.default_value, float): - node_group.inputs[input_name].default_value = \ - int(original_input.default_value) - else: - node_group.inputs[input_name].default_value = \ - float(original_input.default_value) - return node_found +def get_group_inputs( + node_tree: NodeTree, remove_cache: bool=True + ) -> list[NodeTreeInterfaceItem] | None: + """Get the interface inputs of a given `NodeTree`.""" + inputs = [] + for item in node_tree.interface.items_tree: + if not hasattr(item, 'in_out') \ + or item.in_out != 'INPUT': + continue + inputs.append(item) + if remove_cache: + inputs = inputs[:-4] + return inputs -def apply_node_to_objects(name: str, objects: Iterable[Object]) -> bool: - """Add node group to given object material slots""" - gd = bpy.context.scene.gd - operation_success = True - for ob in objects: - # If no material slots found or empty mat - # slots found, assign a material to it - if not ob.material_slots or "" in ob.material_slots: - if Global.GD_MATERIAL_NAME in bpy.data.materials: - mat = bpy.data.materials[Global.GD_MATERIAL_NAME] - else: - mat = bpy.data.materials.new(name=Global.GD_MATERIAL_NAME) - mat.use_nodes = True +def link_group_to_object(ob: Object, node_tree: NodeTree) -> dict[str, list]: + """Add given `NodeTree` to the objects' material slots. - # NOTE: Set default emission color - bsdf = mat.node_tree.nodes['Principled BSDF'] - bsdf.inputs["Emission Color"].default_value = (0,0,0,1) + Handles cases with empty or no material slots. - # NOTE: We want to avoid removing empty material slots - # as they can be used for geometry masking - for slot in ob.material_slots: - if slot.name == '': - ob.material_slots[slot.name].material = mat - if not ob.active_material or ob.active_material.name == '': - ob.active_material = mat + Returns count of material with potentially poor render results.""" + if not ob.material_slots or "" in ob.material_slots: + if Global.GD_MATERIAL_NAME in bpy.data.materials: + mat = bpy.data.materials[Global.GD_MATERIAL_NAME] + else: + mat = bpy.data.materials.new(name=Global.GD_MATERIAL_NAME) + mat.use_nodes = True + bsdf = mat.node_tree.nodes['Principled BSDF'] + bsdf.inputs["Emission Color"].default_value = (0,0,0,1) + # NOTE: Do not remove empty slots as they are used in geometry masking for slot in ob.material_slots: - material = bpy.data.materials.get(slot.name) - material.use_nodes = True - - nodes = material.node_tree.nodes - if name in nodes: - continue - - # Get output material node(s) - output_nodes = { - mat for mat in nodes if mat.type == 'OUTPUT_MATERIAL' - } - if not output_nodes: - output_nodes.append( - nodes.new('ShaderNodeOutputMaterial') - ) - - node_group = bpy.data.node_groups.get(name) - for output in output_nodes: - passthrough = nodes.new('ShaderNodeGroup') - passthrough.node_tree = node_group - passthrough.location = ( - output.location[0], - output.location[1] - 160 - ) - passthrough.name = node_group.name - passthrough.hide = True - - # Add note next to node group explaining basic functionality - GD_text = bpy.data.texts.get('_grabdoc_ng_warning') - if GD_text is None: - GD_text = bpy.data.texts.new(name='_grabdoc_ng_warning') - - GD_text.clear() - GD_text.write(Global.NG_NODE_WARNING) + if slot.name == '': + ob.material_slots[slot.name].material = mat + if not ob.active_material or ob.active_material.name == '': + ob.active_material = mat + + inputs = get_group_inputs(node_tree) + input_names = [g_input.name for g_input in inputs] + unlinked_inputs: dict[str, list] = {} + + for slot in ob.material_slots: + material = bpy.data.materials.get(slot.name) + material.use_nodes = True + nodes = material.node_tree.nodes + if node_tree.name in nodes: + continue - GD_frame = nodes.new('NodeFrame') - GD_frame.location = ( - output.location[0], - output.location[1] - 195 + output_nodes = {mat for mat in nodes if mat.type == 'OUTPUT_MATERIAL'} + if not output_nodes: + output_nodes.append(nodes.new('ShaderNodeOutputMaterial')) + for output in output_nodes: + passthrough = nodes.new('ShaderNodeGroup') + passthrough.node_tree = node_tree + passthrough.name = node_tree.name + passthrough.hide = True + passthrough.location = (output.location[0], output.location[1]-160) + passthrough.gd_spawn = True + + if Global.GD_MATERIAL_NAME not in material.name: + unlinked_inputs[material.name] = inputs + + frame = nodes.new('NodeFrame') + frame.name = node_tree.name + warning_text = bpy.data.texts.get(Global.NODE_GROUP_WARN_NAME) + if warning_text is None: + warning_text = bpy.data.texts.new(Global.NODE_GROUP_WARN_NAME) + warning_text.clear() + warning_text.write(Global.NODE_GROUP_WARN) + frame.text = warning_text + frame.width = 750 + frame.height = 200 + frame.location = (output.location[0], output.location[1]-200) + frame.gd_spawn = True + + # Link identical sockets from output connected node + from_output_node = output.inputs[0].links[0].from_node + for node_input in from_output_node.inputs: + if node_input.name not in input_names \ + or not node_input.links: + continue + link = node_input.links[0] + material.node_tree.links.new( + passthrough.inputs[node_input.name], + link.from_node.outputs[link.from_socket.name] ) - GD_frame.name = node_group.name - GD_frame.text = GD_text - GD_frame.width = 1000 - GD_frame.height = 150 - # Link nodes - # TODO: This section needs to be seriously reconsidered - # inputs = get_material_output_inputs() - # if node_input.name in inputs: - # for connection_name in inputs: - # if node_input.name != connection_name: - # continue - # mat_slot.node_tree.links.new( - # passthrough_ng.inputs[connection_name], - # source_node.outputs[link.from_socket.name] - # ) - for output_material_input in output.inputs: - for link in output_material_input.links: - source_node = nodes.get(link.from_node.name) + unlinked = unlinked_inputs.get(material.name) + if unlinked is None: + continue + try: + unlinked.remove(node_input.name) + except ValueError: + continue - # Store original output material connections - try: - material.node_tree.links.new( - passthrough.inputs[output_material_input.name], - source_node.outputs[link.from_socket.name] - ) - except KeyError: - pass + # Link original output material connections + for node_input in output.inputs: + for link in node_input.links: + material.node_tree.links.new( + passthrough.inputs[node_input.name], + link.from_node.outputs[link.from_socket.name] + ) + material.node_tree.links.remove(link) + material.node_tree.links.new(output.inputs["Surface"], + passthrough.outputs["Shader"]) - #Link dependencies from any BSDF node - if name not in Global.SHADER_MAP_NAMES \ - or "BSDF" not in source_node.type: - continue + optional_sockets = ('Alpha',) + for unlinked in unlinked_inputs.values(): + for socket in optional_sockets: + try: + unlinked.remove(socket) + except ValueError: + continue + # NOTE: Rebuild list without empty entries + unlinked_inputs = {k: v for k, v in unlinked_inputs.items() if v} + return unlinked_inputs - node_found = False - for original_input in source_node.inputs: - if name == Global.COLOR_NODE \ - and original_input.name == Global.COLOR_NAME: - node_found = create_node_links( - input_name=original_input.name, - node_group=passthrough, - original_input=original_input, - material=material - ) - elif name == Global.EMISSIVE_NODE \ - and original_input.name == "Emission Color": - node_found = create_node_links( - input_name=original_input.name, - node_group=passthrough, - original_input=original_input, - material=material - ) - elif name == Global.ROUGHNESS_NODE \ - and original_input.name == Global.ROUGHNESS_NAME: - node_found = create_node_links( - input_name=original_input.name, - node_group=passthrough, - original_input=original_input, - material=material - ) - elif name == Global.METALLIC_NODE \ - and original_input.name == Global.METALLIC_NAME: - node_found = create_node_links( - input_name=original_input.name, - node_group=passthrough, - original_input=original_input, - material=material - ) - elif name == Global.ALPHA_NODE \ - and original_input.name == Global.ALPHA_NAME: - node_found = create_node_links( - input_name=original_input.name, - node_group=passthrough, - original_input=original_input, - material=material - ) - if original_input.name == 'Alpha' \ - and material.blend_method == 'OPAQUE' \ - and len(original_input.links): - material.blend_method = 'CLIP' - elif name == Global.NORMAL_NODE \ - and original_input.name == "Normal": - node_found = create_node_links( - input_name=original_input.name, - node_group=passthrough, - original_input=original_input, - material=material - ) - if original_input.name == 'Alpha' \ - and gd.normals[0].use_texture \ - and material.blend_method == 'OPAQUE' \ - and len(original_input.links): - material.blend_method = 'CLIP' - elif name == Global.OCCLUSION_NODE \ - and original_input.name == "Normal": - node_found = create_node_links( - input_name=original_input.name, - node_group=passthrough, - original_input=original_input, - material=material - ) - elif node_found: - break - if not node_found \ - and name != Global.NORMAL_NODE \ - and material.name != Global.GD_MATERIAL_NAME: - operation_success = False +def generate_shader_interface( + tree: NodeTree, inputs: dict[str, str], + name: str = "Output Cache", hidden: bool=True +) -> None: + """Add sockets to a new panel in a given `NodeTree` + and expected input of sockets. Defaults to `Shader` output. - # NOTE: Remove all material output links and - # create new connection with main input - for link in output.inputs['Volume'].links: - material.node_tree.links.remove(link) - for link in output.inputs['Displacement'].links: - material.node_tree.links.remove(link) - material.node_tree.links.new( - output.inputs["Surface"], passthrough.outputs["Shader"] - ) - return operation_success + Generally used alongside other automation + for creating reusable node input schemes. - -def node_cleanup(setup_type: str) -> None: - """Remove node group & return original links if they exist""" - if setup_type is None: + Dict formatting examples: + - {'Displacement': 'NodeSocketVector'} + - {SocketName: SocketType}""" + if name in tree.interface.items_tree: return - inputs = get_material_output_inputs() - for mat in bpy.data.materials: - mat.use_nodes = True - nodes = mat.node_tree.nodes - if mat.name == Global.GD_MATERIAL_NAME: - bpy.data.materials.remove(mat) - continue - if setup_type not in nodes: - continue - - grabdoc_nodes = [ - mat for mat in nodes if mat.name.startswith(setup_type) - ] - for node in grabdoc_nodes: - output_node = None - for output in node.outputs: - for link in output.links: - if link.to_node.type == 'OUTPUT_MATERIAL': - output_node = link.to_node - break - if output_node is not None: - break - if output_node is None: - nodes.remove(node) - continue - - for node_input in node.inputs: - for link in node_input.links: - if node_input.name.split(' ')[-1] not in inputs: - continue - original_node_connection = nodes.get(link.from_node.name) - original_node_socket = link.from_socket.name - for connection_name in inputs: - if node_input.name != connection_name: - continue - mat.node_tree.links.new( - output_node.inputs[connection_name], - original_node_connection.outputs[ - original_node_socket - ] - ) - nodes.remove(node) + saved_links = tree.interface.new_panel(name, default_closed=hidden) + for socket_name, socket_type in inputs.items(): + tree.interface.new_socket(name=socket_name, parent=saved_links, + socket_type=socket_type) + if "Shader" in tree.interface.items_tree: + return + tree.interface.new_socket(name="Shader", socket_type="NodeSocketShader", + in_out='OUTPUT') diff --git a/utils/pack.py b/utils/pack.py new file mode 100644 index 0000000..574db8d --- /dev/null +++ b/utils/pack.py @@ -0,0 +1,80 @@ +import os +import numpy # pylint: disable=E0401 + +import bpy + +from .io import get_format +from .baker import get_bakers + + +def pack_image_channels(pack_order, PackName): + """NOTE: Original code sourced from: + https://blender.stackexchange.com/questions/274712/how-to-channel-pack-texture-in-python + """ + dst_array = None + has_alpha = False + + # Build the packed pixel array + for pack_item in pack_order: + image = pack_item[0] + # Initialize arrays on the first iteration + if dst_array is None: + w, h = image.size + src_array = numpy.empty(w * h * 4, dtype=numpy.float32) + dst_array = numpy.ones(w * h * 4, dtype=numpy.float32) + assert image.size[:] == (w, h), "Images must be same size" + + # Fetch pixels from the source image and copy channels + image.pixels.foreach_get(src_array) + for src_chan, dst_chan in pack_item[1:]: + if dst_chan == 3: + has_alpha = True + dst_array[dst_chan::4] = src_array[src_chan::4] + + # Create image from the packed pixels + dst_image = bpy.data.images.new(PackName, w, h, alpha=has_alpha) + dst_image.pixels.foreach_set(dst_array) + return dst_image + + +def get_channel_path(channel: str) -> str | None: + """Get the channel path of the given channel name. + + If the channel path is not found returns `None`.""" + if channel == "none": + return None + gd = bpy.context.scene.gd + # TODO: Multi-baker support + suffix = getattr(gd, channel)[0].suffix + if suffix is None: + return None + filename = gd.filename + '_' + suffix + get_format() + filepath = os.path.join(gd.filepath, filename) + if not os.path.exists(filepath): + return None + return filepath + + +def is_pack_maps_enabled() -> bool: + """Checks if the chosen pack channels + match the enabled maps to export. + + This function also returns True if a required + bake map is not enabled but the texture exists.""" + baker_ids = ['none'] + baker_ids += [baker.ID for baker in get_bakers(filter_enabled=True)] + + gd = bpy.context.scene.gd + if gd.channel_r not in baker_ids \ + and get_channel_path(gd.channel_r) is None: + return False + if gd.channel_g not in baker_ids \ + and get_channel_path(gd.channel_g) is None: + return False + if gd.channel_b not in baker_ids \ + and get_channel_path(gd.channel_b) is None: + return False + if gd.channel_a not in baker_ids \ + and get_channel_path(gd.channel_a) is None: + return False + return True diff --git a/utils/render.py b/utils/render.py index db98668..32f5af8 100644 --- a/utils/render.py +++ b/utils/render.py @@ -1,10 +1,9 @@ - -from mathutils import Vector - import bpy +from mathutils import Vector from bpy.types import Object from ..constants import Global +from .generic import get_user_preferences def is_object_gd_valid( @@ -33,8 +32,7 @@ def is_object_gd_valid( def in_viewing_frustrum(vector: Vector) -> bool: - """Decide whether a given object is - within the cameras viewing frustrum""" + """Decide whether a given object is within the cameras viewing frustrum.""" bg_plane = bpy.data.objects[Global.BG_PLANE_NAME] viewing_frustrum = ( Vector((bg_plane.dimensions.x * -1.25 + bg_plane.location[0], @@ -57,9 +55,9 @@ def get_rendered_objects() -> set | None: """Generate a list of all objects that will be rendered based on its origin position in world space""" objects = set() - if bpy.context.scene.gd.use_bake_collections: + if bpy.context.scene.gd.use_bake_collection: for coll in bpy.data.collections: - if coll.gd_bake_collection is False: + if coll.gd_collection is False: continue objects.update( [ob for ob in coll.all_objects if is_object_gd_valid(ob)] @@ -70,22 +68,22 @@ def get_rendered_objects() -> set | None: # rendered_obs.add(ob.name) return objects - package = __package__.rsplit(".", maxsplit=1)[0] - preferences = bpy.context.preferences.addons[package].preferences - for ob in bpy.context.view_layer.objects: - if not is_object_gd_valid(ob): - continue - if not preferences.render_within_frustrum: - objects.add(ob) - continue - # Distance based filter; preference locked + objects = bpy.context.view_layer.objects + objects = [ob for ob in objects if is_object_gd_valid(ob)] + + if not get_user_preferences().render_within_frustrum: + return objects + + # Distance based filter + filtered_objects = set() + for ob in objects: local_bbox_center = .125 * sum( (Vector(ob) for ob in ob.bound_box), Vector() ) global_bbox_center = ob.matrix_world @ local_bbox_center if in_viewing_frustrum(global_bbox_center): - objects.add(ob) - return objects + filtered_objects.add(ob) + return filtered_objects def set_guide_height(objects: list[Object]=None) -> None: @@ -93,8 +91,7 @@ def set_guide_height(objects: list[Object]=None) -> None: based on a given list of objects""" tallest_vert = find_tallest_object(objects) bg_plane = bpy.data.objects.get(Global.BG_PLANE_NAME) - bpy.context.scene.gd.height[0].distance = \ - tallest_vert - bg_plane.location[2] + bpy.context.scene.gd.height[0].distance = tallest_vert-bg_plane.location[2] def find_tallest_object(objects: list[Object]=None) -> float: @@ -131,3 +128,21 @@ def find_tallest_object(objects: list[Object]=None) -> float: # NOTE: Fallback to manual height value return bpy.context.scene.gd.height[0].distance return max(tallest_verts) + + +def set_color_management( + view_transform: str = 'Standard', + look: str = 'None', + display_device: str = 'sRGB' + ) -> None: + """Helper function for supporting custom color management + profiles. Ignores anything that isn't compatible""" + display_settings = bpy.context.scene.display_settings + display_settings.display_device = display_device + view_settings = bpy.context.scene.view_settings + view_settings.view_transform = view_transform + view_settings.look = look + view_settings.exposure = 0 + view_settings.gamma = 1 + view_settings.use_curve_mapping = False + view_settings.use_hdr_view = False diff --git a/utils/scene.py b/utils/scene.py index 2d52956..0ea84cb 100644 --- a/utils/scene.py +++ b/utils/scene.py @@ -1,125 +1,25 @@ +import os + import bpy import bmesh from bpy.types import Context, Object -from ..constants import Global -from ..utils.generic import camera_in_3d_view -from ..utils.node import node_init - - -def remove_setup(context: Context, hard_reset: bool=True) -> None | list: - """Completely removes every element of GrabDoc from - the scene, not including images reimported after bakes - - hard_reset: When refreshing a scene we may want to keep - certain data-blocks that the user can manipulates""" - # COLLECTIONS - - # Move objects contained inside the bake group collection - # to the root collection level and delete the collection - saved_bake_group_obs = [] - bake_group_coll = bpy.data.collections.get(Global.COLL_OB_NAME) - if bake_group_coll is not None: - for ob in bake_group_coll.all_objects: - # Move object to the master collection - if hard_reset or not context.scene.gd.use_bake_collections: - context.scene.collection.objects.link(ob) - else: - saved_bake_group_obs.append(ob) - - # Remove the objects from the grabdoc collection - ob.users_collection[0].objects.unlink(ob) - - bpy.data.collections.remove(bake_group_coll) - - # Move objects accidentally placed in the GD collection to - # the master collection and then remove the collection - gd_coll = bpy.data.collections.get(Global.COLL_NAME) - if gd_coll is not None: - for ob in gd_coll.all_objects: - if ob.name not in ( - Global.BG_PLANE_NAME, - Global.ORIENT_GUIDE_NAME, - Global.HEIGHT_GUIDE_NAME, - Global.TRIM_CAMERA_NAME - ): - # Move object to the master collection - context.scene.collection.objects.link(ob) - - # Remove object from the grabdoc collection - ob.gd_coll.objects.unlink(ob) - - bpy.data.collections.remove(gd_coll) - - # HARD RESET - a simpler method for clearing all gd related object - - if hard_reset: - for ob_name in ( - Global.BG_PLANE_NAME, - Global.ORIENT_GUIDE_NAME, - Global.HEIGHT_GUIDE_NAME - ): - if ob_name in bpy.data.objects: - bpy.data.meshes.remove(bpy.data.meshes[ob_name]) - - if Global.TRIM_CAMERA_NAME in bpy.data.objects: - bpy.data.cameras.remove(bpy.data.cameras[Global.TRIM_CAMERA_NAME]) - - if Global.REFERENCE_NAME in bpy.data.materials: - bpy.data.materials.remove(bpy.data.materials[Global.REFERENCE_NAME]) - - for group in bpy.data.node_groups: - if group.name.startswith(Global.PREFIX): - bpy.data.node_groups.remove(group) - - return None - - # SOFT RESET - - # Bg plane - saved_mat = None - saved_plane_loc = saved_plane_rot = (0, 0, 0) - if Global.BG_PLANE_NAME in bpy.data.objects: - plane_ob = bpy.data.objects[Global.BG_PLANE_NAME] - - # Save material/reference & transforms - saved_mat = plane_ob.active_material - saved_plane_loc = plane_ob.location.copy() - saved_plane_rot = plane_ob.rotation_euler.copy() - - bpy.data.meshes.remove(plane_ob.data) - - # Camera - # Forcibly exit the camera before deleting it so - # the original users camera position is retained - update_camera = False - if camera_in_3d_view(): - update_camera = True - bpy.ops.view3d.view_camera() - - if Global.TRIM_CAMERA_NAME in bpy.data.cameras: - bpy.data.cameras.remove(bpy.data.cameras[Global.TRIM_CAMERA_NAME]) - if Global.HEIGHT_GUIDE_NAME in bpy.data.meshes: - bpy.data.meshes.remove(bpy.data.meshes[Global.HEIGHT_GUIDE_NAME]) - if Global.ORIENT_GUIDE_NAME in bpy.data.meshes: - bpy.data.meshes.remove(bpy.data.meshes[Global.ORIENT_GUIDE_NAME]) - - return saved_plane_loc, saved_plane_rot, saved_mat, update_camera, saved_bake_group_obs +from ..constants import Global, Error # NOTE: Needs self for property update functions to register def scene_setup(_self, context: Context) -> None: - """Generate/setup all relevant GrabDoc object, collections, node groups and scene settings""" + """Setup all relevant objects, collections, node groups, and properties.""" gd = context.scene.gd - view_layer = context.view_layer - - # PRELIMINARY + context.scene.render.resolution_x = gd.resolution_x + context.scene.render.resolution_y = gd.resolution_y + view_layer = context.view_layer saved_active_collection = view_layer.active_layer_collection - saved_selected_obs = view_layer.objects.selected.keys() - - gd_coll = bpy.data.collections.get(Global.COLL_NAME) + saved_selected_obs = view_layer.objects.selected.keys() + activeCallback = None + gd_coll = bpy.data.collections.get(Global.COLL_CORE_NAME) if context.object: active_ob = context.object activeCallback = active_ob.name @@ -133,35 +33,17 @@ def scene_setup(_self, context: Context) -> None: if bpy.ops.object.mode_set.poll(): bpy.ops.object.mode_set(mode='OBJECT') - # Deselect all objects for ob in context.selected_objects: ob.select_set(False) - # Remove all related GrabDoc data blocks but store necessary values saved_plane_loc, saved_plane_rot, \ - saved_mat, update_camera, saved_bake_group_obs = \ - remove_setup(context, hard_reset=False) + saved_mat, was_viewing_camera, saved_bake_group_obs = \ + scene_cleanup(context, hard_reset=False) - # Set scene resolution - context.scene.render.resolution_x = gd.resolution_x - context.scene.render.resolution_y = gd.resolution_y - - # PROPERTIES - for map_name in gd.MAP_TYPES: - try: - prop = getattr(gd, map_name[0]) - if prop: - continue - item = prop.add() - item.suffix = map_name[0] - except AttributeError: - continue - - # COLLECTIONS - # Create a bake group collection if requested - if gd.use_bake_collections: - bake_group_coll = bpy.data.collections.new(Global.COLL_OB_NAME) - bake_group_coll.gd_bake_collection = True + # Collections + if gd.use_bake_collection: + bake_group_coll = bpy.data.collections.new(Global.COLL_GROUP_NAME) + bake_group_coll.gd_collection = True context.scene.collection.children.link(bake_group_coll) view_layer.active_layer_collection = \ @@ -171,31 +53,28 @@ def scene_setup(_self, context: Context) -> None: for ob in saved_bake_group_obs: bake_group_coll.objects.link(ob) - # Create core GrabDoc collection - gd_coll = bpy.data.collections.new(name=Global.COLL_NAME) - gd_coll.gd_bake_collection = True + gd_coll = bpy.data.collections.new(name=Global.COLL_CORE_NAME) + gd_coll.gd_collection = True context.scene.collection.children.link(gd_coll) view_layer.active_layer_collection = \ view_layer.layer_collection.children[-1] - - # Set active collection view_layer.active_layer_collection = \ view_layer.layer_collection.children[gd_coll.name] - # BG PLANE - bpy.ops.mesh.primitive_plane_add( - size=gd.scale, - calc_uvs=True, - align='WORLD', - location=saved_plane_loc, - rotation=saved_plane_rot - ) - - # Rename newly made BG Plane & set a reference to it + # Background plane + bpy.ops.mesh.primitive_plane_add(size=gd.scale, + calc_uvs=True, + align='WORLD', + location=saved_plane_loc, + rotation=saved_plane_rot) context.object.name = context.object.data.name = Global.BG_PLANE_NAME plane_ob = bpy.data.objects[Global.BG_PLANE_NAME] + plane_ob.select_set(False) + plane_ob.show_wire = True + plane_ob.lock_scale[0] = \ + plane_ob.lock_scale[1] = \ + plane_ob.lock_scale[2] = True - # Plane scaling if gd.resolution_x != gd.resolution_y: if gd.resolution_x > gd.resolution_y: div_factor = gd.resolution_x / gd.resolution_y @@ -205,17 +84,7 @@ def scene_setup(_self, context: Context) -> None: plane_ob.scale[0] /= div_factor bpy.ops.object.transform_apply(location=False, rotation=False) - plane_ob.select_set(False) - # NOTE: Skip enabling gd_object - # as we always want it to render - #plane_ob.gd_object = True - plane_ob.show_wire = True - plane_ob.lock_scale[0] = \ - plane_ob.lock_scale[1] = \ - plane_ob.lock_scale[2] = True - - # Add reference to the plane if one has been added - # else find and remove any existing reference materials + # Reference if gd.reference and not gd.preview_state: for mat in bpy.data.materials: if mat.name == Global.REFERENCE_NAME: @@ -237,10 +106,8 @@ def scene_setup(_self, context: Context) -> None: image.image = gd.reference image.location = (-300,0) - mat.node_tree.links.new( - output.inputs["Surface"], - image.outputs["Color"] - ) + mat.node_tree.links.new(output.inputs["Surface"], + image.outputs["Color"]) plane_ob.active_material = bpy.data.materials[Global.REFERENCE_NAME] @@ -255,128 +122,222 @@ def scene_setup(_self, context: Context) -> None: plane_ob.active_material = saved_mat # TODO: Removal operation inside of a setup function if Global.REFERENCE_NAME in bpy.data.materials: - bpy.data.materials.remove( - bpy.data.materials[Global.REFERENCE_NAME] - ) + bpy.data.materials.remove(bpy.data.materials[Global.REFERENCE_NAME]) - # Grid for better snapping and measurements + # Grid if gd.use_grid and gd.grid_subdivs: bm = bmesh.new() bm.from_mesh(plane_ob.data) - bmesh.ops.subdivide_edges( - bm, - edges=bm.edges, - cuts=gd.grid_subdivs, - use_grid_fill=True - ) + bmesh.ops.subdivide_edges(bm, bm.edges, + cuts=gd.grid_subdivs, + use_grid_fill=True) bm.to_mesh(plane_ob.data) - # CAMERA + # Camera camera_data = bpy.data.cameras.new(Global.TRIM_CAMERA_NAME) - camera_data.type = 'ORTHO' - camera_data.display_size = .01 + camera_data.type = 'ORTHO' + camera_data.display_size = .01 + camera_data.ortho_scale = gd.scale + camera_data.clip_start = 0.1 + camera_data.clip_end = 1000 * (gd.scale / 25) camera_data.passepartout_alpha = 1 - camera_data.ortho_scale = gd.scale - camera_data.clip_start = 0.1 - camera_data.clip_end = 1000 * (gd.scale / 25) camera_ob = bpy.data.objects.new(Global.TRIM_CAMERA_NAME, camera_data) - camera_ob.location = (0, 0, 15 * gd.scale) - camera_ob.parent = plane_ob - camera_ob.gd_object = True - # TODO: This causes visual errors when in camera view? - #camera_ob.hide_viewport = camera_ob.hide_select = True + camera_ob.parent = plane_ob + camera_object_z = Global.CAMERA_DISTANCE * gd.scale + camera_ob.location = (0, 0, camera_object_z) + camera_ob.hide_select = camera_ob.gd_object = True gd_coll.objects.link(camera_ob) context.scene.camera = camera_ob - # NOTE: Run if camera existed prior to setup refresh - if update_camera: + if was_viewing_camera: bpy.ops.view3d.view_camera() - # POINT CLOUD GENERATION - # TODO: Switch approach to if any bake map uses manual height + # Point cloud if gd.height[0].enabled and gd.height[0].method == 'MANUAL': generate_height_guide(Global.HEIGHT_GUIDE_NAME, plane_ob) generate_orientation_guide(Global.ORIENT_GUIDE_NAME, plane_ob) - # NODE GROUPS - node_init() - - # CLEANUP + # Cleanup for ob_name in saved_selected_obs: ob = context.scene.objects.get(ob_name) ob.select_set(True) - try: - view_layer.active_layer_collection = saved_active_collection + view_layer.active_layer_collection = saved_active_collection + if activeCallback: view_layer.objects.active = bpy.data.objects[activeCallback] - if activeCallback: - if bpy.ops.object.mode_set.poll(): - bpy.ops.object.mode_set(mode = modeCallback) - except UnboundLocalError: - pass - - # NOTE: Reset collection visibility, run this after everything else - gd_coll.hide_select = not gd.coll_selectable + if bpy.ops.object.mode_set.poll(): + bpy.ops.object.mode_set(mode=modeCallback) + + gd_coll.hide_select = not gd.coll_selectable gd_coll.hide_viewport = not gd.coll_visible - gd_coll.hide_render = not gd.coll_rendered + gd_coll.hide_render = not gd.coll_rendered + + +def scene_cleanup(context: Context, hard_reset: bool=True) -> None | list: + """Completely removes every element of GrabDoc from + the scene, not including images reimported after bakes + + hard_reset: When refreshing a scene we may want to keep + certain data-blocks that the user can manipulates""" + # NOTE: Move objects contained inside the bake group collection + # to the root collection level and delete the collection + saved_bake_group_obs = [] + bake_group_coll = bpy.data.collections.get(Global.COLL_GROUP_NAME) + if bake_group_coll is not None: + for ob in bake_group_coll.all_objects: + if hard_reset or not context.scene.gd.use_bake_collection: + context.scene.collection.objects.link(ob) + else: + saved_bake_group_obs.append(ob) + ob.users_collection[0].objects.unlink(ob) + bpy.data.collections.remove(bake_group_coll) + + # NOTE: Compensate for objects accidentally placed in the core collection + gd_coll = bpy.data.collections.get(Global.COLL_CORE_NAME) + if gd_coll is not None: + for ob in gd_coll.all_objects: + if ob.name not in (Global.BG_PLANE_NAME, + Global.ORIENT_GUIDE_NAME, + Global.HEIGHT_GUIDE_NAME, + Global.TRIM_CAMERA_NAME): + context.scene.collection.objects.link(ob) + ob.gd_coll.objects.unlink(ob) + bpy.data.collections.remove(gd_coll) + + if hard_reset: + for ob_name in (Global.BG_PLANE_NAME, + Global.ORIENT_GUIDE_NAME, + Global.HEIGHT_GUIDE_NAME): + if ob_name in bpy.data.objects: + bpy.data.meshes.remove(bpy.data.meshes[ob_name]) + if Global.TRIM_CAMERA_NAME in bpy.data.objects: + bpy.data.cameras.remove(bpy.data.cameras[Global.TRIM_CAMERA_NAME]) + if Global.REFERENCE_NAME in bpy.data.materials: + bpy.data.materials.remove(bpy.data.materials[Global.REFERENCE_NAME]) + for group in bpy.data.node_groups: + if group.name.startswith(Global.PREFIX): + bpy.data.node_groups.remove(group) + return None + + saved_mat = None + saved_plane_loc = saved_plane_rot = (0, 0, 0) + if Global.BG_PLANE_NAME in bpy.data.objects: + plane_ob = bpy.data.objects[Global.BG_PLANE_NAME] + saved_mat = plane_ob.active_material + saved_plane_loc = plane_ob.location.copy() + saved_plane_rot = plane_ob.rotation_euler.copy() + bpy.data.meshes.remove(plane_ob.data) + + # NOTE: Forcibly exit the camera before deleting it + # so the original users camera position is retained + was_viewing_camera = False + if camera_in_3d_view(): + was_viewing_camera = True + bpy.ops.view3d.view_camera() + + if Global.TRIM_CAMERA_NAME in bpy.data.cameras: + bpy.data.cameras.remove(bpy.data.cameras[Global.TRIM_CAMERA_NAME]) + if Global.HEIGHT_GUIDE_NAME in bpy.data.meshes: + bpy.data.meshes.remove(bpy.data.meshes[Global.HEIGHT_GUIDE_NAME]) + if Global.ORIENT_GUIDE_NAME in bpy.data.meshes: + bpy.data.meshes.remove(bpy.data.meshes[Global.ORIENT_GUIDE_NAME]) + + return saved_plane_loc, saved_plane_rot, saved_mat, \ + was_viewing_camera, saved_bake_group_obs + + +def validate_scene( + context: Context, + is_exporting: bool=True, + report_value=False, + report_string="" + ) -> tuple[bool, str]: + """Determine if specific parts of the scene + are set up incorrectly and return a detailed + explanation of things for the user to fix.""" + gd = context.scene.gd + + if not Global.TRIM_CAMERA_NAME in context.view_layer.objects \ + and not report_value: + report_value = True + report_string = Error.CAMERA_NOT_FOUND + + if gd.use_bake_collection and not report_value: + if not len(bpy.data.collections[Global.COLL_GROUP_NAME].objects): + report_value = True + report_string = Error.BAKE_GROUPS_EMPTY + + if is_exporting is False: + return report_value, report_string + + if not report_value \ + and not gd.filepath == "//" \ + and not os.path.exists(gd.filepath): + report_value = True + report_string = Error.NO_VALID_PATH_SET + return report_value, report_string + + +def is_scene_valid() -> bool: + """Validate all required objects to determine correct scene setup.""" + object_checks = (Global.COLL_CORE_NAME in bpy.data.collections, + Global.BG_PLANE_NAME in bpy.context.scene.objects, + Global.TRIM_CAMERA_NAME in bpy.context.scene.objects) + return True in object_checks + + +def camera_in_3d_view() -> bool: + """Check if the first found 3D View is currently viewing the camera.""" + for area in bpy.context.screen.areas: + if area.type != 'VIEW_3D': + continue + return area.spaces.active.region_3d.view_perspective == 'CAMERA' def generate_height_guide(name: str, plane_ob: Object) -> None: - """Generate a mesh object that gauges the height map range. - This is for the "Manual" height map mode and can better - inform a correct 0-1 range""" + """Generate a mesh that represents the height map range. + + Generally used for `Manual` Height method to visualize the 0-1 range.""" scene = bpy.context.scene - camera_view_frame = \ - bpy.data.objects[Global.TRIM_CAMERA_NAME].data.view_frame( - scene=scene - ) + trim_camera = bpy.data.objects.get(Global.TRIM_CAMERA_NAME) + camera_view_frame = trim_camera.data.view_frame(scene=scene) stems_vecs = [ (v[0], v[1], scene.gd.height[0].distance) for v in camera_view_frame ] - ring_vecs = [ - (v[0], v[1], v[2]+1) for v in camera_view_frame - ] + ring_vecs = [(v[0], v[1], v[2]+1) for v in camera_view_frame] ring_vecs += stems_vecs mesh = bpy.data.meshes.new(name) - ob = bpy.data.objects.new(name, mesh) - - # Create mesh from list of vertices / edges / faces - mesh.from_pydata( - vertices=ring_vecs, - edges=[(0,4), (1,5), (2,6), (3,7), (4,5), (5,6), (6,7), (7,4)], - faces=[] - ) + mesh.from_pydata(vertices=ring_vecs, + edges=[(0,4), (1,5), (2,6), + (3,7), (4,5), (5,6), + (6,7), (7,4)], + faces=[]) mesh.update() + ob = bpy.data.objects.new(name, mesh) bpy.context.collection.objects.link(ob) - ob.gd_object = True - ob.parent = plane_ob # NOTE: BG Plane + ob.gd_object = True + ob.parent = plane_ob ob.hide_select = True -def generate_orientation_guide( - name: str, plane_ob: Object - ) -> None: +def generate_orientation_guide(name: str, plane_ob: Object) -> None: """Generate a mesh object that sits beside the background plane to guide the user to the correct "up" orientation""" mesh = bpy.data.meshes.new(name) - ob = bpy.data.objects.new(name, mesh) - - # Create mesh from list of vertices / edges / faces plane_y = plane_ob.dimensions.y / 2 - mesh.from_pydata( - vertices=[ - (-.3, plane_y+.1, 0), (.3, plane_y+.1, 0), (0, plane_y+.35, 0) - ], - edges=[(0,2), (0,1), (1,2)], - faces=[] - ) + mesh.from_pydata(vertices=[(-.3, plane_y+.1, 0), + (.3, plane_y+.1, 0), + (0, plane_y+.35, 0)], + edges=[(0,2), (0,1), (1,2)], + faces=[]) mesh.update() + ob = bpy.data.objects.new(name, mesh) bpy.context.collection.objects.link(ob) - ob.parent = plane_ob # NOTE: BG Plane + ob.parent = plane_ob ob.hide_select = True - ob.gd_object = True + ob.gd_object = True