[Blender Export] Added Blender render

- Impressive quality thanks to the pcb2blender plug-in
This commit is contained in:
Salvador E. Tropea 2023-01-16 23:42:52 -03:00
parent 25fd32b84a
commit eb8c04f870
13 changed files with 917 additions and 33 deletions

View File

@ -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

View File

@ -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.

View File

@ -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.

View File

@ -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/)

View File

@ -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")

View File

@ -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

View File

@ -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:

View File

@ -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

361
kibot/out_blender_export.py Normal file
View File

@ -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

View File

@ -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)

View File

@ -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])

View File

@ -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",\

View File

@ -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