748 lines
32 KiB
Python
748 lines
32 KiB
Python
# -*- 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: ImageMagick
|
|
role: Automatically crop images
|
|
"""
|
|
import json
|
|
import os
|
|
import re
|
|
from tempfile import NamedTemporaryFile, TemporaryDirectory
|
|
from .error import KiPlotConfigurationError
|
|
from .kiplot import get_output_targets, run_output, run_command, register_xmp_import, config_output, configure_and_run
|
|
from .gs import GS
|
|
from .misc import MISSING_TOOL, BLENDER_ERROR
|
|
from .optionable import Optionable, BaseOptions
|
|
from .out_base_3d import Base3D, Base3DOptionsWithHL
|
|
from .registrable import RegOutput
|
|
from .macros import macros, document, output_class # noqa: F401
|
|
from . import log
|
|
|
|
logger = log.get_logger()
|
|
bb = None
|
|
RE_FILE_ID = re.compile(r"\%\d*d")
|
|
|
|
|
|
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):
|
|
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._unknown_is_error = True
|
|
|
|
|
|
class BlenderOutputOptions(Optionable):
|
|
""" What is generated """
|
|
def __init__(self):
|
|
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='3D_blender_$VIEW' %x=VARIABLE).
|
|
The extension is selected from the type """
|
|
self.dir = ''
|
|
""" Subdirectory for this output """
|
|
self._unknown_is_error = True
|
|
|
|
|
|
class BlenderObjOptions(Optionable):
|
|
""" A light/camera in the scene. """
|
|
def __init__(self):
|
|
super().__init__()
|
|
with document:
|
|
self.name = ""
|
|
""" Name for the """
|
|
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._unknown_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 BlenderLightOptions(BlenderObjOptions):
|
|
""" A light in the scene. """
|
|
def __init__(self):
|
|
super().__init__()
|
|
with document:
|
|
self.type = "POINT"
|
|
""" [POINT,SUN,SPOT,HEMI,AREA] Type of light. SUN lights will illuminate more evenly """
|
|
self.energy = 0
|
|
""" How powerful is the light. Using 0 for POINT and SUN KiBot will try to use something useful """
|
|
self.add_to_doc('name', ' light', with_nl=False)
|
|
|
|
def adjust(self):
|
|
self._width, self._height, self._size = get_board_size()
|
|
if not self.get_user_defined('pos_x') and not self.get_user_defined('pos_y') and not self.get_user_defined('pos_z'):
|
|
self.pos_x = -self._size*3.33
|
|
self.pos_y = self._size*3.33
|
|
self.pos_z = self._size*5.0
|
|
if self.energy == 0:
|
|
if self.type == "POINT":
|
|
# 10 W is the default, works for 5 cm board, we make it grow cudratically
|
|
self.energy = 10.0*((self._size/0.05)**2)
|
|
elif self.type == "SUN":
|
|
# This is power by area
|
|
self.energy = 2
|
|
|
|
def config(self, parent):
|
|
super().config(parent)
|
|
self.adjust()
|
|
|
|
|
|
class BlenderCameraOptions(BlenderObjOptions):
|
|
""" A camera in the scene. """
|
|
CAM_TYPE = {'perspective': 'PERSP', 'orthographic': 'ORTHO', 'panoramic': 'PANO'}
|
|
|
|
def __init__(self):
|
|
super().__init__()
|
|
with document:
|
|
self.type = "perspective"
|
|
""" [perspective,orthographic,panoramic] Type of camera """
|
|
self.clip_start = -1
|
|
""" Minimum distance for objects to the camera. Any object closer than this distance won't be visible.
|
|
Only positive values have effect. A negative value has a special meaning.
|
|
For a camera with defined position, a negative value means to use Blender defaults (i.e. 0.1 m).
|
|
For cameras without position KiBot will ask Blender to compute its position and the use a clip
|
|
distance that is 1/10th of the Z distance """
|
|
self.add_to_doc('name', ' camera', with_nl=False)
|
|
|
|
def config(self, parent):
|
|
super().config(parent)
|
|
self._type = self.CAM_TYPE[self.type]
|
|
|
|
|
|
class BlenderRenderOptions(Optionable):
|
|
""" Render parameters """
|
|
def __init__(self):
|
|
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.width = None
|
|
""" {resolution_x} """
|
|
self.resolution_y = 720
|
|
""" Height of the image """
|
|
self.height = None
|
|
""" {resolution_y} """
|
|
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.auto_crop = False
|
|
""" When enabled the image will be post-processed to remove the empty space around the image.
|
|
In this mode the `background2` is changed to be the same as `background1` """
|
|
self.no_denoiser = False
|
|
""" Used to disable the render denoiser on old hardware, or when the functionality isn't compiled.
|
|
Note that the impact in quality is huge, you should increase the amount of samples 10 times """
|
|
self._unknown_is_error = True
|
|
|
|
|
|
class BlenderPointOfViewOptions(Optionable):
|
|
""" Point of View parameters """
|
|
_views = {'top': 'z', 'bottom': 'Z', 'front': 'y', 'rear': 'Y', 'right': 'x', 'left': 'X'}
|
|
_rviews = {v: k for k, v in _views.items()}
|
|
|
|
def __init__(self):
|
|
super().__init__()
|
|
with document:
|
|
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` """
|
|
self.file_id = ''
|
|
""" String to differentiate the name of this point of view.
|
|
When empty we use the `default_file_id` or the `view` """
|
|
self.steps = 1
|
|
""" [1-1000] Generate this amount of steps using the rotation angles as increments.
|
|
Use a value of 1 (default) to interpret the angles as absolute.
|
|
Used for animations. You should define the `default_file_id` to something like
|
|
'_%03d' to get the animation frames """
|
|
self._unknown_is_error = True
|
|
|
|
def config(self, parent):
|
|
super().config(parent)
|
|
# View point
|
|
view = self._views.get(self.view, None)
|
|
if view is not None:
|
|
self.view = view
|
|
|
|
def get_view(self):
|
|
return self._rviews.get(self.view, 'no_view')
|
|
|
|
def increment(self, inc):
|
|
self.rotate_x += inc.rotate_x
|
|
self.rotate_y += inc.rotate_y
|
|
self.rotate_z += inc.rotate_z
|
|
|
|
|
|
class PCB3DExportOptions(Base3DOptionsWithHL):
|
|
""" Options to generate the PCB3D file """
|
|
def __init__(self):
|
|
super().__init__()
|
|
with document:
|
|
self.output = GS.def_global_output
|
|
""" Name for the generated PCB3D file (%i='blender_export' %x='pcb3d') """
|
|
self.version = '2.7'
|
|
""" [2.1,2.1_haschtl,2.7] Variant of the format used """
|
|
self.solder_paste_for_populated = True
|
|
""" Add solder paste only for the populated components.
|
|
Populated components are the ones listed in `show_components` """
|
|
self._expand_id = 'blender_export'
|
|
self._expand_ext = 'pcb3d'
|
|
self._unknown_is_error = True
|
|
|
|
def get_output_name(self, out_dir):
|
|
p = self._parent
|
|
cur_id = p._expand_id
|
|
cur_ext = p._expand_ext
|
|
p._expand_id = self._expand_id
|
|
p._expand_ext = self._expand_ext
|
|
out_name = p._parent.expand_filename(out_dir, self.output)
|
|
p._expand_id = cur_id
|
|
p._expand_ext = cur_ext
|
|
return out_name
|
|
|
|
def setup_renderer(self, components, active_components, bottom, name):
|
|
super().setup_renderer(components, active_components)
|
|
self._pov.view = 'Z' if bottom else 'z'
|
|
# Expand the name using .PNG
|
|
cur_ext = self._expand_ext
|
|
self._expand_ext = 'png'
|
|
o_name = self.expand_filename_both(name, is_sch=False)
|
|
self._expand_ext = cur_ext
|
|
self._out.output = o_name
|
|
return o_name
|
|
|
|
def save_renderer_options(self):
|
|
""" Save the current renderer settings """
|
|
p = self._parent
|
|
# We are an option inside another option
|
|
self._parent = self._parent._parent
|
|
super().save_renderer_options()
|
|
self._parent = p
|
|
self.old_show_all_components = self._show_all_components
|
|
self.old_view = self._pov.view
|
|
self.old_output = self._out.output
|
|
|
|
def restore_renderer_options(self):
|
|
""" Restore the renderer settings """
|
|
p = self._parent
|
|
self._parent = self._parent._parent
|
|
super().restore_renderer_options()
|
|
self._parent = p
|
|
self._show_all_components = self.old_show_all_components
|
|
self._pov.view = self.old_view
|
|
self._out.output = self.old_output
|
|
|
|
|
|
class Blender_ExportOptions(BaseOptions):
|
|
def __init__(self):
|
|
with document:
|
|
self.pcb3d = PCB3DExportOptions
|
|
""" *[string|dict] Options to export the PCB to Blender.
|
|
You can also specify the name of the output that generates the PCB3D file.
|
|
See the `PCB2Blender_2_1`, `PCB2Blender_2_7` 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 = BlenderCameraOptions
|
|
""" [dict] Options for the camera.
|
|
If none specified KiBot will create a suitable camera.
|
|
If no position is specified for the camera KiBot will look for a suitable position """
|
|
self.fixed_auto_camera = False
|
|
""" When using the automatically generated camera and multiple points of view this option computes the camera
|
|
position just once. Suitable for videos """
|
|
self.auto_camera_z_axis_factor = 1.1
|
|
""" Value to multiply the Z axis coordinate after computing the automatically generated camera.
|
|
Used to avoid collision of the camera and the object """
|
|
self.default_file_id = ''
|
|
""" Default value for the `file_id` in the `point_of_view` options.
|
|
Use something like '_%03d' for animations """
|
|
self.render_options = BlenderRenderOptions
|
|
""" *[dict] Controls how the render is done for the `render` output type """
|
|
self.point_of_view = BlenderPointOfViewOptions
|
|
""" *[dict|list(dict)] How the object is viewed by the camera """
|
|
super().__init__()
|
|
self._expand_id = '3D_blender'
|
|
self._unknown_is_error = True
|
|
|
|
def config(self, parent):
|
|
super().config(parent)
|
|
# Check we at least have a name for the source output
|
|
if isinstance(self.pcb3d, str) and not self.pcb3d:
|
|
raise KiPlotConfigurationError('You must specify the name of the output that'
|
|
' generates the PCB3D file or its options')
|
|
if isinstance(self.pcb3d, type):
|
|
self.pcb3d = PCB3DExportOptions()
|
|
self.pcb3d.config(self)
|
|
# Do we have outputs?
|
|
if isinstance(self.outputs, type):
|
|
self.outputs = []
|
|
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'
|
|
light.adjust()
|
|
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()
|
|
# Point of View, make sure we have a list and at least 1 element
|
|
if isinstance(self.point_of_view, type):
|
|
pov = BlenderPointOfViewOptions()
|
|
# Manually translate top -> z
|
|
pov.view = 'z'
|
|
self.point_of_view = [pov]
|
|
elif isinstance(self.point_of_view, BlenderPointOfViewOptions):
|
|
self.point_of_view = [self.point_of_view]
|
|
|
|
def get_output_filename(self, o, output_dir, pov, order):
|
|
if o.type == 'render':
|
|
self._expand_ext = 'png'
|
|
elif o.type == 'blender':
|
|
self._expand_ext = 'blend'
|
|
else:
|
|
self._expand_ext = o.type
|
|
cur_id = self._expand_id
|
|
file_id = pov.file_id
|
|
if not file_id:
|
|
file_id = self.default_file_id or ('_'+pov.get_view())
|
|
m = RE_FILE_ID.search(file_id)
|
|
if m:
|
|
res = m.group(0)
|
|
val = res % order
|
|
file_id = file_id.replace(res, val)
|
|
self._expand_id += file_id
|
|
name = self._parent.expand_filename(os.path.join(output_dir, o.dir), o.output)
|
|
self._expand_id = cur_id
|
|
return name
|
|
|
|
def get_targets(self, out_dir):
|
|
files = []
|
|
if isinstance(self.pcb3d, PCB3DExportOptions):
|
|
files.append(self.pcb3d.get_output_name(out_dir))
|
|
order = 1
|
|
for pov in self.point_of_view:
|
|
for _ in range(pov.steps):
|
|
for o in self.outputs:
|
|
files.append(self.get_output_filename(o, out_dir, pov, order))
|
|
return files
|
|
|
|
def create_vrml(self, dest_dir):
|
|
tree = {'name': '_temporal_vrml_for_pcb3d',
|
|
'type': 'vrml',
|
|
'comment': 'Internally created for the PCB3D',
|
|
'dir': dest_dir,
|
|
'options': {'output': 'pcb.wrl',
|
|
'dir_models': 'components',
|
|
'use_pcb_center_as_ref': False,
|
|
'model_units': 'meters'}}
|
|
out = RegOutput.get_class_for('vrml')()
|
|
out.set_tree(tree)
|
|
config_output(out)
|
|
out.options.copy_options(self.pcb3d)
|
|
logger.debug(' - Creating VRML ...')
|
|
out.options.run(os.path.join(dest_dir, 'pcb.wrl'))
|
|
|
|
def create_layers(self, dest_dir):
|
|
out_dir = os.path.join(dest_dir, 'layers')
|
|
tree = {'name': '_temporal_svgs_layers',
|
|
'type': 'svg',
|
|
'comment': 'Internally created for the PCB3D',
|
|
'dir': out_dir,
|
|
'options': {'output': '%i.%x',
|
|
'margin': 1,
|
|
'limit_viewbox': True,
|
|
'svg_precision': 6,
|
|
'drill_marks': 'none'},
|
|
'layers': ['F.Cu', 'B.Cu', 'F.Paste', 'B.Paste', 'F.Mask', 'B.Mask',
|
|
{'layer': 'F.SilkS', 'suffix': 'F_SilkS'},
|
|
{'layer': 'B.SilkS', 'suffix': 'B_SilkS'}]}
|
|
configure_and_run(tree, out_dir, ' - Creating SVG for layers ...')
|
|
|
|
def create_pads(self, dest_dir):
|
|
options = {'stackup_create': False}
|
|
if self.pcb3d.version == '2.1_haschtl':
|
|
options['stackup_create'] = True
|
|
elif self.pcb3d.version == '2.7':
|
|
options['stackup_create'] = True
|
|
options['stackup_file'] = 'stackup'
|
|
options['stackup_dir'] = 'layers'
|
|
options['stackup_format'] = 'BIN'
|
|
tree = {'name': '_temporal_pcb3d_tools',
|
|
'type': 'pcb2blender_tools',
|
|
'comment': 'Internally created for the PCB3D',
|
|
'dir': dest_dir,
|
|
'options': options}
|
|
if self.pcb3d.solder_paste_for_populated:
|
|
sc = 'all'
|
|
if not self.pcb3d._show_all_components:
|
|
sc = 'none' if not self.pcb3d.show_components else self.pcb3d._show_components_raw
|
|
tree['options']['show_components'] = sc
|
|
configure_and_run(tree, dest_dir, ' - Creating Pads and boundary ...')
|
|
|
|
def create_pcb3d(self, data_dir):
|
|
out_dir = self._parent.output_dir
|
|
# Compute the name for the PCB3D
|
|
out_name = self.pcb3d.get_output_name(out_dir)
|
|
tree = {'name': '_temporal_compress_pcb3d',
|
|
'type': 'compress',
|
|
'comment': 'Internally created for the PCB3D',
|
|
'dir': out_dir,
|
|
'options': {'output': out_name,
|
|
'format': 'ZIP',
|
|
'files': [{'source': os.path.join(data_dir, 'boards'),
|
|
'dest': '/'},
|
|
{'source': os.path.join(data_dir, 'boards/*'),
|
|
'dest': 'boards'},
|
|
{'source': os.path.join(data_dir, 'components'),
|
|
'dest': '/'},
|
|
{'source': os.path.join(data_dir, 'components/*'),
|
|
'dest': 'components'},
|
|
{'source': os.path.join(data_dir, 'layers'),
|
|
'dest': '/'},
|
|
{'source': os.path.join(data_dir, 'layers/*'),
|
|
'dest': 'layers'},
|
|
{'source': os.path.join(data_dir, 'pads'),
|
|
'dest': '/'},
|
|
{'source': os.path.join(data_dir, 'pads/*'),
|
|
'dest': 'pads'},
|
|
{'source': os.path.join(data_dir, 'pcb.wrl'),
|
|
'dest': '/'},
|
|
]}}
|
|
configure_and_run(tree, out_dir, ' - Creating the PCB3D ...')
|
|
return out_name
|
|
|
|
def solve_pcb3d(self):
|
|
if isinstance(self.pcb3d, str):
|
|
# An output creates it
|
|
pcb3d_targets, _, 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)
|
|
self._pcb3d = PCB3DExportOptions()
|
|
self._pcb3d.output = pcb3d_file
|
|
# Needed by ensure tool
|
|
self._pcb3d._parent = self._parent
|
|
else:
|
|
# We create it
|
|
with TemporaryDirectory() as tmp_dir:
|
|
# VRML
|
|
self.create_vrml(tmp_dir)
|
|
# SVG layers
|
|
self.create_layers(tmp_dir)
|
|
# Pads and bounds
|
|
self.create_pads(tmp_dir)
|
|
# Compress the files
|
|
pcb3d_file = self.create_pcb3d(tmp_dir)
|
|
self._pcb3d = self.pcb3d
|
|
# Needed by ensure tool
|
|
self.type = self._parent.type
|
|
if not os.path.isfile(pcb3d_file):
|
|
raise KiPlotConfigurationError('Missing '+pcb3d_file)
|
|
return pcb3d_file
|
|
|
|
def analyze_errors(self, msg):
|
|
if 'Traceback ' in msg:
|
|
GS.exit_with_error('Error from Blender run:\n'+msg[msg.index('Traceback '):], BLENDER_ERROR)
|
|
|
|
def run(self, output):
|
|
if GS.ki5:
|
|
GS.exit_with_error("`blender_export` needs KiCad 6+", MISSING_TOOL)
|
|
pcb3d_file = self.solve_pcb3d()
|
|
# If no outputs specified just finish
|
|
# Can be used to export the PCB to Blender
|
|
if not self.outputs:
|
|
return
|
|
# Make sure Blender is available
|
|
command = self._pcb3d.ensure_tool('Blender')
|
|
if self.render_options.auto_crop:
|
|
# Avoid a gradient
|
|
self.render_options.background2 = self.render_options.background1
|
|
convert_command = self.ensure_tool('ImageMagick')
|
|
# 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),
|
|
'type': light.type,
|
|
'energy': light.energy} for light in self.light]
|
|
scene['lights'] = lights
|
|
if self.camera:
|
|
ca = self.camera
|
|
scene['camera'] = {'name': ca.name,
|
|
'type': ca._type}
|
|
if (hasattr(ca, '_pos_x_user_defined') or hasattr(ca, '_pos_y_user_defined') or
|
|
hasattr(ca, '_pos_z_user_defined')):
|
|
scene['camera']['position'] = (ca.pos_x, ca.pos_y, ca.pos_z)
|
|
if ca.clip_start >= 0:
|
|
scene['camera']['clip_start'] = ca.clip_start
|
|
scene['fixed_auto_camera'] = self.fixed_auto_camera
|
|
scene['auto_camera_z_axis_factor'] = self.auto_camera_z_axis_factor
|
|
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}
|
|
povs = []
|
|
last_pov = BlenderPointOfViewOptions()
|
|
for pov in self.point_of_view:
|
|
if pov.steps > 1:
|
|
for _ in range(pov.steps):
|
|
last_pov.increment(pov)
|
|
povs.append({'rotate_x': -last_pov.rotate_x,
|
|
'rotate_y': -last_pov.rotate_y,
|
|
'rotate_z': -last_pov.rotate_z,
|
|
'view': pov.view})
|
|
else:
|
|
povs.append({'rotate_x': -pov.rotate_x,
|
|
'rotate_y': -pov.rotate_y,
|
|
'rotate_z': -pov.rotate_z,
|
|
'view': pov.view})
|
|
last_pov = pov
|
|
scene['point_of_view'] = povs
|
|
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')
|
|
if self.render_options.no_denoiser:
|
|
cmd.append('--no_denoiser')
|
|
cmd.append('--format')
|
|
for pov in self.point_of_view:
|
|
for _ in range(pov.steps):
|
|
for o in self.outputs:
|
|
cmd.append(o.type)
|
|
cmd.append('--output')
|
|
names = set()
|
|
order = 1
|
|
for pov in self.point_of_view:
|
|
for _ in range(pov.steps):
|
|
for o in self.outputs:
|
|
name = self.get_output_filename(o, self._parent.output_dir, pov, order)
|
|
if name in names:
|
|
raise KiPlotConfigurationError('Repeated name (use `file_id`): '+name)
|
|
cmd.append(name)
|
|
names.add(name)
|
|
os.makedirs(os.path.dirname(name), exist_ok=True)
|
|
order += 1
|
|
cmd.extend(['--scene', f.name])
|
|
cmd.append(pcb3d_file)
|
|
# Execute the command
|
|
self.analyze_errors(run_command(cmd))
|
|
if self.render_options.auto_crop:
|
|
for pov in self.point_of_view:
|
|
for o in self.outputs:
|
|
if o.type != 'render':
|
|
continue
|
|
name = self.get_output_filename(o, self._parent.output_dir, pov)
|
|
run_command([convert_command, name, '-trim', '+repage', '-trim', '+repage', name])
|
|
|
|
|
|
@output_class
|
|
class Blender_Export(Base3D):
|
|
""" Blender Export
|
|
Exports the PCB in various 3D file formats.
|
|
Also renders the PCB with high-quality.
|
|
Needs KiCad 6 or newer.
|
|
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).
|
|
You can just generate the exported PCB if no output is specified.
|
|
You can also export the PCB and render it at the same time """
|
|
def __init__(self):
|
|
super().__init__()
|
|
with document:
|
|
self.options = Blender_ExportOptions
|
|
""" *[dict] Options for the `blender_export` output """
|
|
self._category = 'PCB/3D'
|
|
|
|
def get_dependencies(self):
|
|
files = BaseOutput.get_dependencies(self) # noqa: F821
|
|
if isinstance(self.options.pcb3d, str):
|
|
files.append(self.options.pcb3d)
|
|
else:
|
|
files.extend(self.options.pcb3d.list_models())
|
|
return files
|
|
|
|
def get_renderer_options(self):
|
|
""" Where are the options for this output when used as a 'renderer' """
|
|
ops = self.options
|
|
out = next(filter(lambda x: x.type == 'render', ops.outputs), None)
|
|
res = None
|
|
if out is not None:
|
|
if isinstance(ops.pcb3d, str):
|
|
# We can't configure it
|
|
out = None
|
|
else:
|
|
res = ops.pcb3d
|
|
res._pov = ops.point_of_view[0]
|
|
res._out = out
|
|
return res if out is not None else None
|
|
|
|
def get_extension(self):
|
|
# Used when we are a renderer
|
|
return 'png'
|
|
|
|
@staticmethod
|
|
def get_conf_examples(name, layers):
|
|
if not GS.check_tool(name, 'Blender') or GS.ki5:
|
|
return None
|
|
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')
|
|
povs = []
|
|
if has_top:
|
|
povs.append({'view': 'top'})
|
|
povs.append({'rotate_x': 30, 'rotate_z': -20, 'file_id': '_30deg'})
|
|
if has_bottom:
|
|
povs.append({'view': 'bottom'})
|
|
gb = {}
|
|
gb['name'] = 'basic_{}'.format(name)
|
|
gb['comment'] = '3D view from top/30 deg/bottom (Blender)'
|
|
gb['type'] = name
|
|
gb['dir'] = '3D'
|
|
gb['options'] = {'pcb3d': '_PCB2Blender_2_1',
|
|
'outputs': [{'type': 'render'}, {'type': 'blender'}],
|
|
'point_of_view': povs}
|
|
return [gb]
|