From eb8c04f870ba8fbb32e560cb7b6a7ea4ae010e2a Mon Sep 17 00:00:00 2001 From: "Salvador E. Tropea" Date: Mon, 16 Jan 2023 23:42:52 -0300 Subject: [PATCH] [Blender Export] Added Blender render - Impressive quality thanks to the pcb2blender plug-in --- CHANGELOG.md | 2 + README.md | 97 +++++ docs/README.in | 3 + docs/samples/generic_plot.kibot.yaml | 109 ++++++ kibot/blender_scripts/blender_export.py | 217 ++++++++++- kibot/dep_downloader.py | 4 + kibot/kiplot.py | 41 +- kibot/out_base_3d.py | 27 +- kibot/out_blender_export.py | 361 ++++++++++++++++++ kibot/out_compress.py | 13 +- kibot/out_vrml.py | 9 +- src/kibot-check | 37 ++ .../yaml_samples/blender_export_1.kibot.yaml | 30 ++ 13 files changed, 917 insertions(+), 33 deletions(-) create mode 100644 kibot/out_blender_export.py create mode 100644 tests/yaml_samples/blender_export_1.kibot.yaml diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b7ffc67..6af009cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `vrml` export the 3D model in Virtual Reality Modeling Language (#349) - `ps_sch_print`, `dxf_sch_print` and `hpgl_sch_print` variants of `pdf_sch_print` + - `blender_export` exports the PCB to Blender and other 3D formats, + renders the PCB with impressive quality (experimental) - New internal filters: - `_only_smd` used to get only SMD parts - `_only_tht` used to get only THT parts diff --git a/README.md b/README.md index 10de2517..0a975556 100644 --- a/README.md +++ b/README.md @@ -167,6 +167,9 @@ Notes: - Mandatory for `kicost` - Optional to find components costs and specs for `bom` +[**Blender**](https://www.blender.org/) v3.4.0 [![Tool](https://raw.githubusercontent.com/INTI-CMNB/KiBot/master/docs/images/llave-inglesa-22x22.png)](https://www.blender.org/) [![Debian](https://raw.githubusercontent.com/INTI-CMNB/KiBot/master/docs/images/debian-openlogo-22x22.png)](https://packages.debian.org/bullseye/blender) +- Mandatory for `blender_export` + [**Interactive HTML BoM**](https://github.com/INTI-CMNB/InteractiveHtmlBom) v2.4.1.4 [![Tool](https://raw.githubusercontent.com/INTI-CMNB/KiBot/master/docs/images/llave-inglesa-22x22.png)](https://github.com/INTI-CMNB/InteractiveHtmlBom) ![Auto-download](https://raw.githubusercontent.com/INTI-CMNB/KiBot/master/docs/images/auto_download-22x22.png) - Mandatory for `ibom` @@ -1391,6 +1394,9 @@ The available values for *type* are: - `step` *Standard for the Exchange of Product Data* for the PCB - `vrml` *Virtual Reality Modeling Language* for the PCB - `render_3d` PCB render, from the KiCad's 3D Viewer + - `blender_export` PCB export to Blender and high quality 3D render. + Including export to: `fbx` (Kaydara's Filmbox), 'obj' (Wavefront), 'x3d' (ISO/IEC standard), + `gltf` (GL format), `stl` (3D printing) and 'ply' (Stanford). - Web pages: - `populate` To create step-by-step assembly instructions. - `kikit_present` To create a project presentation web page. @@ -1540,6 +1546,97 @@ Notes: 1. Most relevant options are listed first and in **bold**. Which ones are more relevant is quite arbitrary, comments are welcome. 2. Aliases are listed in *italics*. +* Blender Export **Experimental** + * Type: `blender_export` + * Description: Exports the PCB in various 3D file formats. + Also renders the PCB in high-quality. + This output is complex to setup and needs very big dependencies. + Please be patient when using it. + You need Blender with the pcb2blender plug-in installed. + Visit: [pcb2blender](https://github.com/30350n/pcb2blender) + * Valid keys: + - **`comment`**: [string=''] A comment for documentation purposes. It helps to identify the output. + - **`dir`**: [string='./'] Output directory for the generated files. + If it starts with `+` the rest is concatenated to the default dir. + - **`name`**: [string=''] Used to identify this particular output definition. + Avoid using `_` as first character. These names are reserved for KiBot. + - **`options`**: [dict] Options for the `blender_export` output. + * Valid keys: + - **`download`**: [boolean=true] Downloads missing 3D models from KiCad git. Only applies to models in KISYS3DMOD. + - **`no_virtual`**: [boolean=false] Used to exclude 3D models for components with 'virtual' attribute. + - **`pcb3d`**: [string=''] Name of the output that generated the PCB3D file to import in Belnder. + See the `PCB2Blender_2_1` and `PCB2Blender_2_1_haschtl` templates. + - **`render_options`**: [dict] How the render is done for the `render` output type. + * Valid keys: + - **`samples`**: [number=10] How many samples we create. Each sample is a raytracing render. + Use 1 for a raw preview, 10 for a draft and 100 or more for the final render. + - **`transparent_background`**: [boolean=false] Make the background transparent. + - `background1`: [string='#66667F'] First color for the background gradient. + - `background2`: [string='#CCCCE5'] Second color for the background gradient. + - `resolution_x`: [number=1280] Width of the image. + - `resolution_y`: [number=720] Height of the image. + - **`view`**: [string='top'] [top,bottom,front,rear,right,left,z,Z,y,Y,x,X] Point of view. + Compatible with `render_3d`. + - `add_default_light`: [boolean=true] Add a default light when none specified. + The default light is located at (-size*3.33, size*3.33, size*5) where size is max(width, height) of the PCB. + - `camera`: [dict] Options for the camera. + If none specified KiBot will create a suitable camera. + * Valid keys: + - `name`: [string=''] Name for the light. + - `pos_x`: [number|string] X position [m]. You can use `width`, `height` and `size` for PCB dimensions. + - `pos_y`: [number|string] Y position [m]. You can use `width`, `height` and `size` for PCB dimensions. + - `pos_z`: [number|string] Z position [m]. You can use `width`, `height` and `size` for PCB dimensions. + - `dnf_filter`: [string|list(string)='_none'] Name of the filter to mark components as not fitted. + A short-cut to use for simple cases where a variant is an overkill. + - `kicad_3d_url`: [string='https://gitlab.com/kicad/libraries/kicad-packages3D/-/raw/master/'] Base URL for the KiCad 3D models. + - `light`: [dict|list(dict)] Options for the light/s. + * Valid keys: + - `name`: [string=''] Name for the light. + - `pos_x`: [number|string] X position [m]. You can use `width`, `height` and `size` for PCB dimensions. + - `pos_y`: [number|string] Y position [m]. You can use `width`, `height` and `size` for PCB dimensions. + - `pos_z`: [number|string] Z position [m]. You can use `width`, `height` and `size` for PCB dimensions. + - `outputs`: [dict|list(dict)] Outputs to generate in the same run. + * Valid keys: + - **`type`**: [string='render'] [fbx,obj,x3d,gltf,stl,ply,blender,render] The format for the output. + The `render` type will generate a PNG image of the render result. + `fbx` is Kaydara's Filmbox, 'obj' is the Wavefront, 'x3d' is the new ISO/IEC standard + that replaced VRML, `gltf` is the standardized GL format, `stl` is the 3D printing + format, 'ply' is Polygon File Format (Stanford). + Note that some formats includes the light and camera and others are just the 3D model + (i.e. STL and PLY). + - `output`: [string='%f-%i%I%v.%x'] Name for the generated file (%i='blender' %x=VARIABLE). + The extension is selected from the type. Affected by global options. + - `pcb_import`: Options to configure how Blender imports the PCB. + The default values are good for most cases. + * Valid keys: + - `center`: [boolean=true] Center the PCB at the coordinates origin. + - `components`: [boolean=true] Import the components. + - `cut_boards`: [boolean=true] Separate the sub-PCBs in separated 3D models. + - `enhance_materials`: [boolean=true] Create good looking materials. + - `merge_materials`: [boolean=true] Reuse materials. + - `solder_joints`: [string='SMART'] [NONE,SMART,ALL] The plug-in can add nice looking solder joints. + This option controls if we add it for none, all or only for THT/SMD pads with solder paste. + - `stack_boards`: [boolean=true] Move the sub-PCBs to their relative position. + - `texture_dpi`: [number=1016.0] [508-2032] Texture density in dots per inch. + - `pre_transform`: [string|list(string)='_none'] Name of the filter to transform fields before applying other filters. + A short-cut to use for simple cases where a variant is an overkill. + - `rotate_x`: [number=0] Angle to rotate the board in the X axis, positive is clockwise [degrees]. + - `rotate_y`: [number=0] Angle to rotate the board in the Y axis, positive is clockwise [degrees]. + - `rotate_z`: [number=0] Angle to rotate the board in the Z axis, positive is clockwise [degrees]. + - `variant`: [string=''] Board variant to apply. + - `category`: [string|list(string)=''] The category for this output. If not specified an internally defined category is used. + Categories looks like file system paths, i.e. **PCB/fabrication/gerber**. + The categories are currently used for `navigate_results`. + - `disable_run_by_default`: [string|boolean] Use it to disable the `run_by_default` status of other output. + Useful when this output extends another and you don't want to generate the original. + Use the boolean true value to disable the output you are extending. + - `extends`: [string=''] Copy the `options` section from the indicated output. + Used to inherit options from another output of the same type. + - `output_id`: [string=''] Text to use for the %I expansion content. To differentiate variations of this output. + - `priority`: [number=50] [0,100] Priority for this output. High priority outputs are created first. + Internally we use 10 for low priority, 90 for high priority and 50 for most outputs. + - `run_by_default`: [boolean=true] When enabled this output will be created when no specific outputs are requested. + * BoardView * Type: `boardview` * Description: Exports the PCB in board view format. diff --git a/docs/README.in b/docs/README.in index ae0d66c6..ca4c7e77 100644 --- a/docs/README.in +++ b/docs/README.in @@ -752,6 +752,9 @@ The available values for *type* are: - `step` *Standard for the Exchange of Product Data* for the PCB - `vrml` *Virtual Reality Modeling Language* for the PCB - `render_3d` PCB render, from the KiCad's 3D Viewer + - `blender_export` PCB export to Blender and high quality 3D render. + Including export to: `fbx` (Kaydara's Filmbox), 'obj' (Wavefront), 'x3d' (ISO/IEC standard), + `gltf` (GL format), `stl` (3D printing) and 'ply' (Stanford). - Web pages: - `populate` To create step-by-step assembly instructions. - `kikit_present` To create a project presentation web page. diff --git a/docs/samples/generic_plot.kibot.yaml b/docs/samples/generic_plot.kibot.yaml index 543e8a33..b02eac55 100644 --- a/docs/samples/generic_plot.kibot.yaml +++ b/docs/samples/generic_plot.kibot.yaml @@ -89,6 +89,115 @@ preflight: update_xml: true outputs: + # Blender Export **Experimental**: + # Also renders the PCB in high-quality. + # This output is complex to setup and needs very big dependencies. + # Please be patient when using it. + # You need Blender with the pcb2blender plug-in installed. + # Visit: [pcb2blender](https://github.com/30350n/pcb2blender) + - name: 'blender_export_example' + comment: 'Exports the PCB in various 3D file formats.' + type: 'blender_export' + dir: 'Example/blender_export_dir' + options: + # [boolean=true] Add a default light when none specified. + # The default light is located at (-size*3.33, size*3.33, size*5) where size is max(width, height) of the PCB + add_default_light: true + # [dict] Options for the camera. + # If none specified KiBot will create a suitable camera + camera: + # [string=''] Name for the light + name: '' + # [number|string] X position [m]. You can use `width`, `height` and `size` for PCB dimensions + pos_x: 0 + # [number|string] Y position [m]. You can use `width`, `height` and `size` for PCB dimensions + pos_y: 0 + # [number|string] Z position [m]. You can use `width`, `height` and `size` for PCB dimensions + pos_z: 0 + # [string|list(string)='_none'] Name of the filter to mark components as not fitted. + # A short-cut to use for simple cases where a variant is an overkill + dnf_filter: '_none' + # [boolean=true] Downloads missing 3D models from KiCad git. Only applies to models in KISYS3DMOD + download: true + # [string='https://gitlab.com/kicad/libraries/kicad-packages3D/-/raw/master/'] Base URL for the KiCad 3D models + kicad_3d_url: 'https://gitlab.com/kicad/libraries/kicad-packages3D/-/raw/master/' + # [dict|list(dict)] Options for the light/s + light: + # [string=''] Name for the light + - name: '' + # [number|string] X position [m]. You can use `width`, `height` and `size` for PCB dimensions + pos_x: 0 + # [number|string] Y position [m]. You can use `width`, `height` and `size` for PCB dimensions + pos_y: 0 + # [number|string] Z position [m]. You can use `width`, `height` and `size` for PCB dimensions + pos_z: 0 + # [boolean=false] Used to exclude 3D models for components with 'virtual' attribute + no_virtual: false + # [dict|list(dict)] Outputs to generate in the same run + outputs: + # [string='%f-%i%I%v.%x'] Name for the generated file (%i='blender' %x=VARIABLE). + # The extension is selected from the type. Affected by global options + - output: '%f-%i%I%v.%x' + # [string='render'] [fbx,obj,x3d,gltf,stl,ply,blender,render] The format for the output. + # The `render` type will generate a PNG image of the render result. + # `fbx` is Kaydara's Filmbox, 'obj' is the Wavefront, 'x3d' is the new ISO/IEC standard + # that replaced VRML, `gltf` is the standardized GL format, `stl` is the 3D printing + # format, 'ply' is Polygon File Format (Stanford). + # Note that some formats includes the light and camera and others are just the 3D model + # (i.e. STL and PLY) + type: 'render' + # [string=''] Name of the output that generated the PCB3D file to import in Belnder. + # See the `PCB2Blender_2_1` and `PCB2Blender_2_1_haschtl` templates + pcb3d: '' + # Options to configure how Blender imports the PCB. + # The default values are good for most cases + pcb_import: + # [boolean=true] Center the PCB at the coordinates origin + center: true + # [boolean=true] Import the components + components: true + # [boolean=true] Separate the sub-PCBs in separated 3D models + cut_boards: true + # [boolean=true] Create good looking materials + enhance_materials: true + # [boolean=true] Reuse materials + merge_materials: true + # [string='SMART'] [NONE,SMART,ALL] The plug-in can add nice looking solder joints. + # This option controls if we add it for none, all or only for THT/SMD pads with solder paste + solder_joints: 'SMART' + # [boolean=true] Move the sub-PCBs to their relative position + stack_boards: true + # [number=1016.0] [508-2032] Texture density in dots per inch + texture_dpi: 1016.0 + # [string|list(string)='_none'] Name of the filter to transform fields before applying other filters. + # A short-cut to use for simple cases where a variant is an overkill + pre_transform: '_none' + # [dict] How the render is done for the `render` output type + render_options: + # [string='#66667F'] First color for the background gradient + background1: '#66667F' + # [string='#CCCCE5'] Second color for the background gradient + background2: '#CCCCE5' + # [number=1280] Width of the image + resolution_x: 1280 + # [number=720] Height of the image + resolution_y: 720 + # [number=10] How many samples we create. Each sample is a raytracing render. + # Use 1 for a raw preview, 10 for a draft and 100 or more for the final render + samples: 10 + # [boolean=false] Make the background transparent + transparent_background: false + # [number=0] Angle to rotate the board in the X axis, positive is clockwise [degrees] + rotate_x: 0 + # [number=0] Angle to rotate the board in the Y axis, positive is clockwise [degrees] + rotate_y: 0 + # [number=0] Angle to rotate the board in the Z axis, positive is clockwise [degrees] + rotate_z: 0 + # [string=''] Board variant to apply + variant: '' + # [string='top'] [top,bottom,front,rear,right,left,z,Z,y,Y,x,X] Point of view. + # Compatible with `render_3d` + view: 'top' # BoardView: # This format allows simple pads and connections navigation, mainly for circuit debug. # The output can be loaded using Open Board View (https://openboardview.org/) diff --git a/kibot/blender_scripts/blender_export.py b/kibot/blender_scripts/blender_export.py index 61866c5c..d092e6d8 100644 --- a/kibot/blender_scripts/blender_export.py +++ b/kibot/blender_scripts/blender_export.py @@ -11,17 +11,26 @@ # # Should be invoked using: # blender -b --factory-startup -P blender_export.py -- OPTIONS +import argparse # to parse options for us and print a nice help message +import json +import math +import os +import sys # to get command line args +# Blender modules import addon_utils import bpy - +X_AXIS = 0 +Y_AXIS = 1 +Z_AXIS = 2 VALID_FORMATS = {'fbx': 'Filmbox, proprietary format developed by Kaydara (owned by Autodesk)', 'obj': 'geometry definition format developed by Wavefront Technologies. Currently open', 'x3d': 'VRML successor. A royalty-free ISO/IEC standard for declaratively representing 3D graphics.', 'blender': 'Blender native format', 'gltf': 'standard file format for three-dimensional scenes and models.', 'stl': '3D printing from stereolithography CAD software created by 3D SystemsSTL (only mesh)', - 'ply': 'Polygon File Format or the Stanford Triangle Format (only mesh)'} + 'ply': 'Polygon File Format or the Stanford Triangle Format (only mesh)', + 'render': 'do render'} def fbx_export(name): @@ -53,19 +62,212 @@ def gltf_export(name): export_draco_mesh_compression_level=6, export_colors=False, export_yup=True) +def render_export(render_path): + print('- Render') + render = bpy.context.scene.render + render.use_file_extension = True + render.filepath = render_path + bpy.ops.render.render(write_still=True) + + +def rot(axis, deg): + bpy.context.active_object.rotation_euler[axis] = math.radians(deg) + + +def do_rotate(scene, name, id): + rotate = scene.get(name) + if rotate: + rot(id, rotate) + + +def do_point_of_view(scene, name): + view = scene.get(name) + if view is None or view == 'z': + return + if view == 'Z': + rot(Y_AXIS, 180) + return + if view == 'y': + rot(X_AXIS, -90) + return + if view == 'Y': + rot(X_AXIS, 90) + rot(Z_AXIS, 180) + return + if view == 'x': + rot(Y_AXIS, 90) + rot(Z_AXIS, 90) + return + if view == 'X': + rot(Y_AXIS, -90) + rot(Z_AXIS, -90) + return + + +def srgb_to_linearrgb(c): + """ Apply the gamma correction """ + if c < 0: + return 0 + elif c < 0.04045: + return c/12.92 + else: + return ((c+0.055)/1.055)**2.4 + + +def hex_to_rgba(hex_value): + hex_color = hex_value[1:] + r = int(hex_color[:2], base=16) + sr = r/255.0 + lr = srgb_to_linearrgb(sr) + g = int(hex_color[2:4], base=16) + sg = g/255.0 + lg = srgb_to_linearrgb(sg) + b = int(hex_color[4:6], base=16) + sb = b/255.0 + lb = srgb_to_linearrgb(sb) + return (lr, lg, lb, 1.0) + + +def create_background_gradient(color1, color0): + """ This creates a background gradient relative to the camera. + I'm not a Blender guru and I took the idea from: + https://www.youtube.com/watch?v=9NdZ8leYDcM (in Urdu/Hindi!) + If you know a simpler mechanism please let me know """ + world_name = "World" + # Create a "World" + scn = bpy.context.scene + scn.world = bpy.data.worlds.new(world_name) + # Enable the use of nodes + scn.world.use_nodes = True + # Get the default nodes + nt = scn.world.node_tree + nodes = nt.nodes + # Create a texture coordinate + tc = nodes.new(type="ShaderNodeTexCoord") + tc.location = (-580, 435) + # Create a mapping + mp = nodes.new(type="ShaderNodeMapping") + mp.location = (-400, 400) + mp.inputs["Rotation"].default_value = (0, 0, math.radians(90)) + mp.inputs["Location"].default_value = (0.7, 0, 0) + nt.links.new(tc.outputs["Camera"], mp.inputs["Vector"]) + # Create a gradient texture + gt = nodes.new(type="ShaderNodeTexGradient") + gt.location = (-180, 280) + gt.gradient_type = "EASING" + nt.links.new(mp.outputs["Vector"], gt.inputs["Vector"]) + # Create a color ramp + cr = nodes.new(type="ShaderNodeValToRGB") + cr.location = (10, 325) + cr.color_ramp.interpolation = "EASE" + cr.color_ramp.elements[0].color = hex_to_rgba(color0) + cr.color_ramp.elements[1].color = hex_to_rgba(color1) + nt.links.new(gt.outputs["Color"], cr.inputs["Fac"]) + # Move the Background + bk1 = nodes["Background"] + bk1.location = (355, 100) + nt.links.new(cr.outputs["Color"], bk1.inputs["Color"]) + # Create another background + bk2 = nodes.new(type="ShaderNodeBackground") + bk2.location = (355, 240) + nt.links.new(cr.outputs["Color"], bk2.inputs["Color"]) + # Create a light path + lp = nodes.new(type="ShaderNodeLightPath") + lp.location = (355, 580) + # Create a mix shader + mx = nodes.new(type="ShaderNodeMixShader") + mx.location = (560, 220) + nt.links.new(lp.outputs["Is Camera Ray"], mx.inputs["Fac"]) + nt.links.new(bk2.outputs["Background"], mx.inputs[1]) + nt.links.new(bk1.outputs["Background"], mx.inputs[2]) + # Move the World Output + wo = nodes["World Output"] + wo.location = (760, 245) + wo_is = wo.inputs["Surface"] + nt.links.remove(wo_is.links[0]) + nt.links.new(mx.outputs["Shader"], wo_is) + + +def apply_scene(file): + # Loads scene + if not file: + return + with open(file, 'rt') as f: + text = f.read() + print(text) + jscene = json.loads(text) + scene = bpy.context.scene + + # Select the board + print('- Select all') + bpy.ops.object.select_all(action='SELECT') + # Apply point of view + do_point_of_view(jscene, 'view') + # Apply extra rotations + do_rotate(jscene, 'rotate_x', 0) + do_rotate(jscene, 'rotate_y', 1) + do_rotate(jscene, 'rotate_z', 2) + + # Add a camera + auto_camera = False + camera = jscene.get('camera') + if not camera: + auto_camera = True + camera = {'name': 'kibot_camera', 'position': (0.0, 0.0, 10.0)} + name = camera.get('name', 'unknown') + pos = camera.get('position', (0, 0, 0)) + print(f"- Creating camera {name} at {pos}") + cam_data = bpy.data.cameras.new(name) + cam_ob = bpy.data.objects.new(name=name, object_data=cam_data) + scene.collection.objects.link(cam_ob) # instance the camera object in the scene + scene.camera = cam_ob # set the active camera + cam_ob.location = pos + if auto_camera: + print('- Changing camera to focus the board') + bpy.ops.view3d.camera_to_view_selected() + + # Add lights + lights = jscene.get('lights') + if lights: + for light in lights: + name = light.get('name', 'unknown') + pos = light.get('position', (0.0, 0.0, 0.0)) + print(f"- Creating light {name} at {pos}") + light_data = bpy.data.lights.new(name, 'POINT') + light_ob = bpy.data.objects.new(name=name, object_data=light_data) + scene.collection.objects.link(light_ob) + light_ob.location = pos + + bpy.context.view_layer.update() + + # Setup render options + render = jscene.get('render') + if render: + scene.cycles.samples = render.get('samples', 10) + r = scene.render + r.engine = 'CYCLES' + r.resolution_x = render.get('resolution_x', 1920) + r.resolution_y = render.get('resolution_y', 1080) + r.resolution_percentage = 100 + r.use_border = False + if render.get('transparent_background'): + r.film_transparent = True + r.image_settings.color_mode = 'RGBA' + else: + create_background_gradient(render.get('background1', '#66667F'), render.get('background2', '#CCCCE5')) + + EXPORTERS = {'fbx': fbx_export, 'obj': obj_export, 'x3d': x3d_export, 'stl': stl_export, 'ply': ply_export, 'blender': blender_export, - 'gltf': gltf_export} + 'gltf': gltf_export, + 'render': render_export} def main(): - import argparse # to parse options for us and print a nice help message - import os - import sys # to get command line args # get the args passed to blender after "--", all of which are ignored by # blender so scripts may receive their own arguments argv = sys.argv @@ -95,6 +297,7 @@ def main(): help="Rasterized (Cycles) or 3D (deprecated) [RASTERIZED]") parser.add_argument("-M", "--dont_merge_materials", action="store_false", help="do not merge materials") parser.add_argument("-o", "--output", type=str, required=True, nargs='+', help="output file name, can be repeated") + parser.add_argument("-r", "--scene", type=str, help="JSON file containing camera, light and render options") parser.add_argument("-s", "--solder_joints", type=str, choices=["NONE", "SMART", "ALL"], default="SMART", help="Add none, all or only for THT/SMD with solder paste [SMART]") parser.add_argument("-S", "--dont_stack_boards", action="store_false", help="do not stack sub-PCBs") @@ -123,6 +326,8 @@ def main(): cut_boards=args.dont_cut_boards, stack_boards=args.dont_stack_boards, texture_dpi=args.texture_dpi) + # Apply the scene + apply_scene(args.scene) # Do all the exports for f, o in zip(args.format, args.output): print(f"Exporting {o} in {f} format") diff --git a/kibot/dep_downloader.py b/kibot/dep_downloader.py index b7ee55ca..2f560941 100644 --- a/kibot/dep_downloader.py +++ b/kibot/dep_downloader.py @@ -90,6 +90,10 @@ Dependencies: url: https://www.gnu.org/software/bash/ debian: bash arch: bash + - name: Blender + url: https://www.blender.org/ + debian: blender + arch: blender """ import importlib import os diff --git a/kibot/kiplot.py b/kibot/kiplot.py index 833190e4..e82d6627 100644 --- a/kibot/kiplot.py +++ b/kibot/kiplot.py @@ -41,6 +41,7 @@ logger = log.get_logger() # Cache to avoid running external many times to check their versions script_versions = {} actions_loaded = False +needed_imports = set() try: import yaml @@ -387,6 +388,17 @@ def config_output(out, dry=False, dont_stop=False): return ok +def get_output_targets(output, parent): + out = RegOutput.get_output(output) + if out is None: + logger.error('Unknown output `{}` selected in {}'.format(output, parent)) + exit(WRONG_ARGUMENTS) + config_output(out) + out_dir = get_output_dir(out.dir, out, dry=True) + files_list = out.get_targets(out_dir) + return files_list, out_dir, out + + def run_output(out, dont_stop=False): if out._done: return @@ -820,6 +832,12 @@ def yaml_dump(f, tree): f.write(yaml.dump(tree, sort_keys=False)) +def register_xmp_import(name): + """ Register an import we need for an example """ + global needed_imports + needed_imports.add(name) + + def generate_one_example(dest_dir, types): """ Generate a example config for dest_dir """ fname = discover_files(dest_dir) @@ -853,15 +871,8 @@ def generate_one_example(dest_dir, types): yaml_dump(f, {'global': glb}) f.write('\n') # A helper for the internal templates - needed_imports = [] - if GS.pcb_file: - needed_imports.extend(['Elecrow', 'FusionPCB', 'JLCPCB', 'PCBWay']) - if GS.sch_file and GS.pcb_file: - needed_imports.append('MacroFab_XYRS') - if needed_imports: - imports = [{'file': man} for man in needed_imports] - yaml_dump(f, {'import': imports}) - f.write('\n') + global needed_imports + needed_imports = set() # All the outputs outputs = [] for n, cls in OrderedDict(sorted(outs.items())).items(): @@ -878,7 +889,13 @@ def generate_one_example(dest_dir, types): if tpls: # Load the templates tpl_names = tpls - tpls = [yaml.safe_load(open(t))['outputs'] for t in tpls] + tpls = [] + for t in tpl_names: + tree = yaml.safe_load(open(t)) + tpls.append(tree['outputs']) + imps = tree.get('import') + if imps: + needed_imports.update([imp['file'] for imp in imps]) tree = cls.get_conf_examples(n, layers, tpls) if tree: logger.debug('- {}, generated'.format(n)) @@ -887,6 +904,10 @@ def generate_one_example(dest_dir, types): outputs.extend(tree) else: logger.debug('- {}, nothing to do'.format(n)) + if needed_imports: + imports = [{'file': man} for man in sorted(needed_imports)] + yaml_dump(f, {'import': imports}) + f.write('\n') if outputs: yaml_dump(f, {'outputs': outputs}) else: diff --git a/kibot/out_base_3d.py b/kibot/out_base_3d.py index e0b933c8..00eb84ab 100644 --- a/kibot/out_base_3d.py +++ b/kibot/out_base_3d.py @@ -91,7 +91,20 @@ class Base3DOptions(VariantOptions): f.write(r.content) return dest - def download_models(self, rename_filter=None, rename_function=None, rename_data=None): + def wrl_name(self, name, force_wrl): + """ Try to use the WRL version """ + if not force_wrl: + return name + nm, ext = os.path.splitext(name) + if ext.lower() == '.wrl': + return name + nm += '.wrl' + if os.path.isfile(nm): + logger.debug('- Forcing WRL '+nm) + return nm + return name + + def download_models(self, rename_filter=None, rename_function=None, rename_data=None, force_wrl=False): """ Check we have the 3D models. Inform missing models. Try to download the missing models @@ -152,7 +165,8 @@ class Base3DOptions(VariantOptions): if replace: source_models.add(replace) old_name = m3d.m_Filename - new_name = replace if not is_copy_mode else rename_function(rename_data, replace) + new_name = (self.wrl_name(replace, force_wrl) if not is_copy_mode else + rename_function(rename_data, replace)) self.undo_3d_models[new_name] = old_name m3d.m_Filename = new_name models_replaced = True @@ -164,7 +178,8 @@ class Base3DOptions(VariantOptions): # This is completely valid for KiCad, but kicad2step doesn't support it source_models.add(full_name) old_name = m3d.m_Filename - new_name = full_name if not is_copy_mode else rename_function(rename_data, full_name) + new_name = (self.wrl_name(full_name, force_wrl) if not is_copy_mode else + rename_function(rename_data, full_name)) self.undo_3d_models[new_name] = old_name m3d.m_Filename = new_name if not models_replaced and extra_debug: @@ -191,10 +206,10 @@ class Base3DOptions(VariantOptions): models.add(full_name) return list(models) - def filter_components(self, highlight=None): + def filter_components(self, highlight=None, force_wrl=False): if not self._comps: # No variant/filter to apply - if self.download_models(): + if self.download_models(force_wrl=force_wrl): # Some missing components found and we downloaded them # Save the fixed board ret = self.save_tmp_board() @@ -203,7 +218,7 @@ class Base3DOptions(VariantOptions): return ret return GS.pcb_file self.filter_pcb_components(do_3D=True, do_2D=True, highlight=highlight) - self.download_models() + self.download_models(force_wrl=force_wrl) fname = self.save_tmp_board() self.unfilter_pcb_components(do_3D=True, do_2D=True) return fname diff --git a/kibot/out_blender_export.py b/kibot/out_blender_export.py new file mode 100644 index 00000000..eb794c4a --- /dev/null +++ b/kibot/out_blender_export.py @@ -0,0 +1,361 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2023 Salvador E. Tropea +# Copyright (c) 2023 Instituto Nacional de TecnologĂ­a Industrial +# License: GPL-3.0 +# Project: KiBot (formerly KiPlot) +""" +Dependencies: + - from: Blender + role: mandatory + version: 3.4.0 +""" +from copy import copy +import json +import os +from tempfile import NamedTemporaryFile +from .error import KiPlotConfigurationError +from .kiplot import get_output_targets, run_output, run_command, register_xmp_import +from .gs import GS +from .optionable import Optionable +from .out_base_3d import Base3DOptions, Base3D +from .macros import macros, document, output_class # noqa: F401 +from . import log + +logger = log.get_logger() +bb = None + + +def get_board_size(): + global bb + if bb is None: + bb = GS.board.ComputeBoundingBox(True) + width = GS.to_mm(bb.GetWidth())/1000.0 + height = GS.to_mm(bb.GetHeight())/1000.0 + size = max(width, height) + return width, height, size + + +class PCB2BlenderOptions(Optionable): + """ How the PCB3D is imported """ + def __init__(self, field=None): + super().__init__() + with document: + self.components = True + """ Import the components """ + self.cut_boards = True + """ Separate the sub-PCBs in separated 3D models """ + self.texture_dpi = 1016.0 + """ [508-2032] Texture density in dots per inch """ + self.center = True + """ Center the PCB at the coordinates origin """ + self.enhance_materials = True + """ Create good looking materials """ + self.merge_materials = True + """ Reuse materials """ + self.solder_joints = "SMART" + """ [NONE,SMART,ALL] The plug-in can add nice looking solder joints. + This option controls if we add it for none, all or only for THT/SMD pads with solder paste """ + self.stack_boards = True + """ Move the sub-PCBs to their relative position """ + self._unkown_is_error = True + + +class BlenderOutputOptions(Optionable): + """ What is generated """ + def __init__(self, field=None): + super().__init__() + with document: + self.type = 'render' + """ *[fbx,obj,x3d,gltf,stl,ply,blender,render] The format for the output. + The `render` type will generate a PNG image of the render result. + `fbx` is Kaydara's Filmbox, 'obj' is the Wavefront, 'x3d' is the new ISO/IEC standard + that replaced VRML, `gltf` is the standardized GL format, `stl` is the 3D printing + format, 'ply' is Polygon File Format (Stanford). + Note that some formats includes the light and camera and others are just the 3D model + (i.e. STL and PLY) """ + self.output = GS.def_global_output + """ Name for the generated file (%i='blender' %x=VARIABLE). + The extension is selected from the type """ + self._unkown_is_error = True + + +class BlenderLightOptions(Optionable): + """ A light in the scene. Currently also for the camera """ + def __init__(self, field=None): + super().__init__() + with document: + self.name = "" + """ Name for the light """ + self.pos_x = 0 + """ [number|string] X position [m]. You can use `width`, `height` and `size` for PCB dimensions """ + self.pos_y = 0 + """ [number|string] Y position [m]. You can use `width`, `height` and `size` for PCB dimensions """ + self.pos_z = 0 + """ [number|string] Z position [m]. You can use `width`, `height` and `size` for PCB dimensions """ + self._unkown_is_error = True + + def solve(self, member): + val = getattr(self, member) + if not isinstance(val, str): + return float(val) + try: + res = eval(val, {}, {'width': self._width, 'height': self._width, 'size': self._size}) + except Exception as e: + raise KiPlotConfigurationError('wrong `{}`: `{}`\nPython says: `{}`'.format(member, val, str(e))) + return res + + def config(self, parent): + super().config(parent) + self._width, self._height, self._size = get_board_size() + self.pos_x = self.solve('pos_x') + self.pos_y = self.solve('pos_y') + self.pos_z = self.solve('pos_z') + + +class BlenderRenderOptions(Optionable): + """ Render parameters """ + def __init__(self, field=None): + super().__init__() + with document: + self.samples = 10 + """ *How many samples we create. Each sample is a raytracing render. + Use 1 for a raw preview, 10 for a draft and 100 or more for the final render """ + self.resolution_x = 1280 + """ Width of the image """ + self.resolution_y = 720 + """ Height of the image """ + self.transparent_background = False + """ *Make the background transparent """ + self.background1 = "#66667F" + """ First color for the background gradient """ + self.background2 = "#CCCCE5" + """ Second color for the background gradient """ + self._unkown_is_error = True + + +class Blender_ExportOptions(Base3DOptions): + _views = {'top': 'z', 'bottom': 'Z', 'front': 'y', 'rear': 'Y', 'right': 'x', 'left': 'X'} + + def __init__(self): + with document: + self.pcb3d = "" + """ *Name of the output that generated the PCB3D file to import in Belnder. + See the `PCB2Blender_2_1` and `PCB2Blender_2_1_haschtl` templates """ + self.pcb_import = PCB2BlenderOptions + """ Options to configure how Blender imports the PCB. + The default values are good for most cases """ + self.outputs = BlenderOutputOptions + """ [dict|list(dict)] Outputs to generate in the same run """ + self.light = BlenderLightOptions + """ [dict|list(dict)] Options for the light/s """ + self.add_default_light = True + """ Add a default light when none specified. + The default light is located at (-size*3.33, size*3.33, size*5) where size is max(width, height) of the PCB """ + self.camera = BlenderLightOptions + """ [dict] Options for the camera. + If none specified KiBot will create a suitable camera """ + self.render_options = BlenderRenderOptions + """ *[dict] How the render is done for the `render` output type """ + self.rotate_x = 0 + """ Angle to rotate the board in the X axis, positive is clockwise [degrees] """ + self.rotate_y = 0 + """ Angle to rotate the board in the Y axis, positive is clockwise [degrees] """ + self.rotate_z = 0 + """ Angle to rotate the board in the Z axis, positive is clockwise [degrees] """ + self.view = 'top' + """ *[top,bottom,front,rear,right,left,z,Z,y,Y,x,X] Point of view. + Compatible with `render_3d` """ + super().__init__() + self._expand_id += '_blender' + self._unkown_is_error = True + + def config(self, parent): + super().config(parent) + # Check we at least have a name for the source output + if not self.pcb3d: + raise KiPlotConfigurationError('You must specify the name of the output that generates the PCB3D file') + # Do we have outputs? + if isinstance(self.outputs, type): + raise KiPlotConfigurationError('You must specify at least one output') + elif isinstance(self.outputs, BlenderOutputOptions): + # One, make a list + self.outputs = [self.outputs] + # Ensure we have import options + if isinstance(self.pcb_import, type): + self.pcb_import = PCB2BlenderOptions() + # Ensure we have a light + if isinstance(self.light, type): + # None + if self.add_default_light: + # Create one + light = BlenderLightOptions() + light.name = 'kibot_light' + _, _, size = get_board_size() + light.pos_x = -size*3.33 + light.pos_y = size*3.33 + light.pos_z = size*5.0 + self.light = [light] + else: + # The dark ... + self.light = [] + elif isinstance(self.light, BlenderLightOptions): + # Ensure a list + self.light = [self.light] + # Check light names + light_names = set() + for li in self.light: + name = li.name if li.name else 'kibot_light' + if name in light_names: + id = 2 + while name+'_'+str(id) in light_names: + id += 1 + name = name+'_'+str(id) + li.name = name + # If no camera let the script create one + if isinstance(self.camera, type): + self.camera = None + elif not self.camera.name: + self.camera.name = 'kibot_camera' + # Ensure we have some render options + if isinstance(self.render_options, type): + self.render_options = BlenderRenderOptions() + # View point + view = self._views.get(self.view, None) + if view is not None: + self.view = view + + def run(self, output): + super().run(output) + command = self.ensure_tool('Blender') + pcb3d_targets, pcb3d_out_dir, pcb3d_out = get_output_targets(self.pcb3d, self._parent) + pcb3d_file = pcb3d_targets[0] + logger.debug('- From file '+pcb3d_file) + if not pcb3d_out._done: + logger.debug('- Running '+self.pcb3d) + run_output(pcb3d_out) + if not os.path.isfile(pcb3d_file): + raise KiPlotConfigurationError('Missing '+pcb3d_file) + # Create a JSON with the scene information + with NamedTemporaryFile(mode='w', suffix='.json') as f: + scene = {} + if self.light: + lights = [{'name': light.name, 'position': (light.pos_x, light.pos_y, light.pos_z)} for light in self.light] + scene['lights'] = lights + if self.camera: + scene['camera'] = {'name': self.camera.name, + 'position': (self.camera.pos_x, self.camera.pos_y, self.camera.pos_z)} + ro = self.render_options + scene['render'] = {'samples': ro.samples, + 'resolution_x': ro.resolution_x, + 'resolution_y': ro.resolution_y, + 'transparent_background': ro.transparent_background, + 'background1': ro.background1, + 'background2': ro.background2} + if self.rotate_x: + scene['rotate_x'] = -self.rotate_x + if self.rotate_y: + scene['rotate_y'] = -self.rotate_y + if self.rotate_z: + scene['rotate_z'] = -self.rotate_z + if self.view: + scene['view'] = self.view + text = json.dumps(scene, sort_keys=True, indent=2) + logger.debug('Scene:\n'+text) + f.write(text) + f.flush() + # Create the command line + script = os.path.join(os.path.dirname(__file__), 'blender_scripts', 'blender_export.py') + cmd = [command, '-b', '--factory-startup', '-P', script, '--'] + pi = self.pcb_import + if not pi.components: + cmd.append('--no_components') + if not pi.cut_boards: + cmd.append('--dont_cut_boards') + if pi.texture_dpi != 1016.0: + cmd.extend(['--texture_dpi', str(pi.texture_dpi)]) + if not pi.center: + cmd.append('--dont_center') + if not pi.enhance_materials: + cmd.append('--dont_enhance_materials') + if not pi.merge_materials: + cmd.append('--dont_merge_materials') + if pi.solder_joints != "SMART": + cmd.extend(['--solder_joints', pi.solder_joints]) + if not pi.stack_boards: + cmd.append('--dont_stack_boards') + cmd.append('--format') + cmd.extend([o.type for o in self.outputs]) + cmd.append('--output') + for o in self.outputs: + if o.type == 'render': + self._expand_ext = 'png' + elif o.type == 'blender': + self._expand_ext = 'blend' + else: + self._expand_ext = o.type + cmd.append(self._parent.expand_filename(self._parent.output_dir, o.output)) + cmd.extend(['--scene', f.name]) + cmd.append(pcb3d_file) + # Execute the command + run_command(cmd) + + +@output_class +class Blender_Export(Base3D): + """ Blender Export **Experimental** + Exports the PCB in various 3D file formats. + Also renders the PCB in high-quality. + This output is complex to setup and needs very big dependencies. + Please be patient when using it. + You need Blender with the pcb2blender plug-in installed. + Visit: [pcb2blender](https://github.com/30350n/pcb2blender) """ + def __init__(self): + super().__init__() + with document: + self.options = Blender_ExportOptions + """ *[dict] Options for the `blender_export` output """ + self._category = 'PCB/3D' + + @staticmethod + def get_conf_examples(name, layers, templates): + if not GS.check_tool(name, 'Blender'): + return None + outs = [] + has_top = False + has_bottom = False + for la in layers: + if la.is_top() or la.layer.startswith('F.'): + has_top = True + elif la.is_bottom() or la.layer.startswith('B.'): + has_bottom = True + if not has_top and not has_bottom: + return None + register_xmp_import('PCB2Blender_2_1') + out_ops = {'pcb3d': '_PCB2Blender_2_1', 'outputs': [{'type': 'render'}]} + if has_top: + gb = {} + gb['name'] = 'basic_{}_top'.format(name) + gb['comment'] = '3D view from top (Blender)' + gb['type'] = name + gb['dir'] = '3D' + gb['options'] = copy(out_ops) + outs.append(gb) + gb = {} + gb['name'] = 'basic_{}_30deg'.format(name) + gb['comment'] = '3D view from 30 degrees (Blender)' + gb['type'] = name + gb['dir'] = '3D' + gb['output_id'] = '30deg' + gb['options'] = copy(out_ops) + gb['options'].update({'rotate_x': 30, 'rotate_z': -20}) + outs.append(gb) + if has_bottom: + gb = {} + gb['name'] = 'basic_{}_bottom'.format(name) + gb['comment'] = '3D view from bottom (Blender)' + gb['type'] = name + gb['dir'] = '3D' + gb['options'] = copy(out_ops) + gb['options'].update({'view': 'bottom'}) + outs.append(gb) + return outs diff --git a/kibot/out_compress.py b/kibot/out_compress.py index f65b6d88..f268d471 100644 --- a/kibot/out_compress.py +++ b/kibot/out_compress.py @@ -24,8 +24,8 @@ from zipfile import ZipFile, ZIP_STORED, ZIP_DEFLATED, ZIP_BZIP2, ZIP_LZMA from tarfile import open as tar_open from collections import OrderedDict from .gs import GS -from .kiplot import config_output, get_output_dir, run_output -from .misc import WRONG_INSTALL, W_EMPTYZIP, WRONG_ARGUMENTS, INTERNAL_ERROR +from .kiplot import config_output, run_output, get_output_targets +from .misc import WRONG_INSTALL, W_EMPTYZIP, INTERNAL_ERROR from .optionable import Optionable, BaseOptions from .registrable import RegOutput from .macros import macros, document, output_class # noqa: F401 @@ -153,13 +153,8 @@ class CompressOptions(BaseOptions): output_out_dir = None if f.from_output: logger.debugl(2, '- From output `{}`'.format(f.from_output)) - out = RegOutput.get_output(f.from_output) - if out is None: - logger.error('Unknown output `{}` selected in {}'.format(f.from_output, self._parent)) - exit(WRONG_ARGUMENTS) - config_output(out) - output_out_dir = out_dir = get_output_dir(out.dir, out, dry=True) - files_list = out.get_targets(out_dir) + files_list, out_dir, out = get_output_targets(f.from_output, self._parent) + output_out_dir = out_dir logger.debugl(2, '- List of files: {}'.format(files_list)) if out_dir not in dirs_list: dirs_list.append(out_dir) diff --git a/kibot/out_vrml.py b/kibot/out_vrml.py index 4147fec6..88ec9a43 100644 --- a/kibot/out_vrml.py +++ b/kibot/out_vrml.py @@ -19,6 +19,11 @@ from . import log logger = log.get_logger() +def replace_ext(file, ext): + file, ext = os.path.splitext(file) + return file+'.wrl' + + class VRMLOptions(Base3DOptions): def __init__(self): with document: @@ -48,7 +53,7 @@ class VRMLOptions(Base3DOptions): if self.dir_models: # We will also generate the models dir = os.path.join(out_dir, self.dir_models) - filtered = {os.path.join(dir, os.path.basename(m)) for m in self.list_models() if m.endswith('.wrl')} + filtered = {os.path.join(dir, os.path.basename(replace_ext(m, 'wrl'))) for m in self.list_models()} targets.extend(list(filtered)) return targets @@ -59,7 +64,7 @@ class VRMLOptions(Base3DOptions): def run(self, name): command = self.ensure_tool('KiAuto') super().run(name) - board_name = self.filter_components() + board_name = self.filter_components(force_wrl=True) cmd = [command, 'export_vrml', '--output_name', os.path.basename(name), '-U', self.model_units] if self.dir_models: cmd.extend(['--dir_models', self.dir_models]) diff --git a/src/kibot-check b/src/kibot-check index 41371699..e40eb77f 100755 --- a/src/kibot-check +++ b/src/kibot-check @@ -66,6 +66,43 @@ deps = '{\ "url": "https://www.gnu.org/software/bash/",\ "url_down": null\ },\ + "Blender": {\ + "arch": "blender",\ + "command": "blender",\ + "comments": [],\ + "deb_package": "blender",\ + "downloader": null,\ + "downloader_str": null,\ + "extra_arch": null,\ + "extra_deb": null,\ + "help_option": "--version",\ + "importance": 10000,\ + "in_debian": true,\ + "is_kicad_plugin": false,\ + "is_python": false,\ + "name": "Blender",\ + "no_cmd_line_version": false,\ + "no_cmd_line_version_old": false,\ + "output": "blender_export",\ + "plugin_dirs": null,\ + "pypi_name": "Blender",\ + "roles": [\ + {\ + "desc": null,\ + "mandatory": true,\ + "max_version": null,\ + "output": "blender_export",\ + "version": [\ + 3,\ + 4,\ + 0\ + ]\ + }\ + ],\ + "tests": [],\ + "url": "https://www.blender.org/",\ + "url_down": null\ + },\ "Colorama": {\ "arch": "python-colorama",\ "command": "colorama",\ diff --git a/tests/yaml_samples/blender_export_1.kibot.yaml b/tests/yaml_samples/blender_export_1.kibot.yaml new file mode 100644 index 00000000..6c32cf4d --- /dev/null +++ b/tests/yaml_samples/blender_export_1.kibot.yaml @@ -0,0 +1,30 @@ +# KiBot Blender export test 1 +kibot: + version: 1 + +import: + - file: PCB2Blender_2_1 + +outputs: + - name: '3d_export' + comment: "Exports the PCB in blender format" + type: blender_export + disable_run_by_default: _PCB2Blender_2_1 + options: + pcb3d: _PCB2Blender_2_1 + rotate_x: 30 + rotate_z: -20 + # view: bottom +# camera: +# name: MyCamera +# pos_x: 0.3 +# pos_y: width*2 +# pos_z: size*3 + render_options: + transparent_background: true + samples: 10 + #resolution_x: 1920 + #resolution_y: 1080 + outputs: + - type: blender + - type: render