[Blender Export][Added] Options useful to generate simple animations

- The resulting PNGs can be converted to MP4 using ffmpeg
This commit is contained in:
Salvador E. Tropea 2023-08-16 13:36:19 -03:00
parent 4202f01c01
commit 948a40fb91
6 changed files with 246 additions and 113 deletions

View File

@ -17,6 +17,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
(#470)
- Blender export:
- Support for pcb2blender v2.6 (Blender 3.5.1)
- `auto_camera_z_axis_factor`: used to control the default camera distance
- Options to create simple animations:
- PoV `steps`: to create rotation angle increments
- `default_file_id`: can be used to create numbered PNGs
- `fixed_auto_camera`: to avoid adjusting the automatic camera on each frame
- Populate:
- Basic support for regular list items (#480)
@ -26,6 +31,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Problems when trying to aggregate the datasheet field (#472)
- kibot-check:
- Show 7.x as supported (#469)
- Blender export:
- Rotations are now applied to the current view, not just the top view
## [1.6.3] - 2023-06-26

View File

@ -1736,11 +1736,15 @@ Notes:
* Valid keys:
- **`view`**: [string='top'] [top,bottom,front,rear,right,left,z,Z,y,Y,x,X] Point of view.
Compatible with `render_3d`.
- `file_id`: [string=''] String to diferentiate the name of this view.
When empty we use the `view`.
- `file_id`: [string=''] String to diferentiate the name of this point of view.
When empty we use the `default_file_id` or the `view`.
- `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].
- `steps`: [number=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.
- **`render_options`**: [dict] Controls 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.
@ -1756,6 +1760,8 @@ Notes:
- *width*: Alias for resolution_x.
- `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.
- `auto_camera_z_axis_factor`: [number=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.
- `camera`: [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.
@ -1765,6 +1771,10 @@ Notes:
- `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.
- `type`: [string='perspective'] [perspective,orthographic,panoramic] Type of camera.
- `default_file_id`: [string=''] Default value for the `file_id` in the `point_of_view` options.
Use something like '_%03d' for animations.
- `fixed_auto_camera`: [boolean=false] When using the automatically generated camera and multiple points of view this option computes the camera
position just once. Suitable for videos.
- `light`: [dict|list(dict)] Options for the light/s.
* Valid keys:
- `energy`: [number=0] How powerful is the light. Using 0 for POINT and SUN KiBot will try to use something useful.
@ -1959,8 +1969,8 @@ Notes:
- `logo_scale`: [number=2] Scaling factor for the logo. Note that this value isn't honored by all spreadsheet software.
- `max_col_width`: [number=60] [20,999] Maximum column width (characters).
- `mouser_link`: [string|list(string)=''] Column/s containing Mouser part numbers, will be linked to web page.
- `specs_columns`: [list(dict)|list(string)] Which columns are included in the Specs worksheet. Use `References` for the references,
'Row' for the order and 'Sep' to separate groups at the same level. By default all are included.
- `specs_columns`: [list(dict)|list(string)] Which columns are included in the Specs worksheet. Use `References` for the
references, 'Row' for the order and 'Sep' to separate groups at the same level. By default all are included.
Column names are distributor specific, the following aren't: '_desc', '_value', '_tolerance', '_footprint',
'_power', '_current', '_voltage', '_frequency', '_temp_coeff', '_manf', '_size'.
* Valid keys:
@ -3370,8 +3380,8 @@ Notes:
Plugin: Uses an external python function, only `code` and `arg` are relevant.
- `arg`: [string=''] Argument to pass to the plugin. Used for *plugin*.
- `code`: [string=''] Plugin specification (PACKAGE.FUNCTION or PYTHON_FILE.FUNCTION). Used for *plugin*.
- `cutout`: [number|string] When your design features open pockets on the side, this parameter specifies extra cutout depth in order to
ensure that a sharp corner of the pocket can be milled. Used for *full*.
- `cutout`: [number|string] When your design features open pockets on the side, this parameter specifies extra cutout
depth in order to ensure that a sharp corner of the pocket can be milled. Used for *full*.
- `hcount`: [number=1] Number of tabs in the horizontal direction. Used for *fixed*.
- `hwidth`: [number|string] The width of tabs in the horizontal direction. Used for *fixed* and *spacing*.
- *min_distance*: Alias for mindistance.

View File

@ -110,6 +110,9 @@ outputs:
# [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
# [number=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
auto_camera_z_axis_factor: 1.1
# [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
@ -124,6 +127,12 @@ outputs:
pos_z: 0
# [string='perspective'] [perspective,orthographic,panoramic] Type of camera
type: 'perspective'
# [string=''] Default value for the `file_id` in the `point_of_view` options.
# Use something like '_%03d' for animations
default_file_id: ''
# [boolean=false] When using the automatically generated camera and multiple points of view this option computes the camera
# position just once. Suitable for videos
fixed_auto_camera: false
# [dict|list(dict)] Options for the light/s
light:
# [number=0] How powerful is the light. Using 0 for POINT and SUN KiBot will try to use something useful
@ -214,8 +223,8 @@ outputs:
texture_dpi: 1016.0
# [dict|list(dict)] How the object is viewed by the camera
point_of_view:
# [string=''] String to diferentiate the name of this view.
# When empty we use the `view`
# [string=''] String to diferentiate the name of this point of view.
# When empty we use the `default_file_id` or the `view`
- file_id: ''
# [number=0] Angle to rotate the board in the X axis, positive is clockwise [degrees]
rotate_x: 0
@ -223,6 +232,11 @@ outputs:
rotate_y: 0
# [number=0] Angle to rotate the board in the Z axis, positive is clockwise [degrees]
rotate_z: 0
# [number=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
steps: 1
# [string='top'] [top,bottom,front,rear,right,left,z,Z,y,Y,x,X] Point of view.
# Compatible with `render_3d`
view: 'top'
@ -556,8 +570,8 @@ outputs:
# [boolean=false] Enable Specs worksheet creation. Contains specifications for the components.
# Works with only some KiCost APIs
specs: false
# [list(dict)|list(string)] Which columns are included in the Specs worksheet. Use `References` for the references,
# 'Row' for the order and 'Sep' to separate groups at the same level. By default all are included.
# [list(dict)|list(string)] Which columns are included in the Specs worksheet. Use `References` for the
# references, 'Row' for the order and 'Sep' to separate groups at the same level. By default all are included.
# Column names are distributor specific, the following aren't: '_desc', '_value', '_tolerance', '_footprint',
# '_power', '_current', '_voltage', '_frequency', '_temp_coeff', '_manf', '_size'
specs_columns:
@ -1882,8 +1896,8 @@ outputs:
arg: ''
# [string=''] Plugin specification (PACKAGE.FUNCTION or PYTHON_FILE.FUNCTION). Used for *plugin*
code: ''
# [number|string] When your design features open pockets on the side, this parameter specifies extra cutout depth in order to
# ensure that a sharp corner of the pocket can be milled. Used for *full*
# [number|string] When your design features open pockets on the side, this parameter specifies extra cutout
# depth in order to ensure that a sharp corner of the pocket can be milled. Used for *full*
cutout: 1
# [number=1] Number of tabs in the horizontal direction. Used for *fixed*
hcount: 1

View File

@ -20,9 +20,12 @@ import sys # to get command line args
import addon_utils
import bpy
X_AXIS = 0
Y_AXIS = 1
Z_AXIS = 2
# X_AXIS = 0
# Y_AXIS = 1
# Z_AXIS = 2
X_AXIS = 'X'
Y_AXIS = 'Y'
Z_AXIS = 'Z'
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.',
@ -70,38 +73,26 @@ def render_export(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_rotate(rots):
bpy.ops.transform.rotate(value=math.radians(-rots[0]), orient_axis='X', center_override=(0, 0, 0))
bpy.ops.transform.rotate(value=math.radians(-rots[1]), orient_axis='Y', center_override=(0, 0, 0))
bpy.ops.transform.rotate(value=math.radians(-rots[2]), orient_axis='Z', center_override=(0, 0, 0))
def do_point_of_view(scene, name):
view = scene.get(name)
if view is None or view == 'z':
return
return (0, 0, 0)
if view == 'Z':
rot(Y_AXIS, 180)
return
return (0, 180, 0)
if view == 'y':
rot(X_AXIS, -90)
return
return (-90, 0, 0)
if view == 'Y':
rot(X_AXIS, 90)
rot(Z_AXIS, 180)
return
return (90, 0, 180)
if view == 'x':
rot(Y_AXIS, 90)
rot(Z_AXIS, 90)
return
return (0, 90, 90)
if view == 'X':
rot(Y_AXIS, -90)
rot(Z_AXIS, -90)
return
return (0, -90, -90)
def srgb_to_linearrgb(c):
@ -191,18 +182,19 @@ def create_background_gradient(color1, color0):
jscene = None
auto_camera = False
cam_ob = None
cur_rot = None
location = None
def apply_scene(file, n_view=0):
def apply_start_scene(file):
# Loads scene
if not file:
return 1
global jscene
if jscene is None:
with open(file, 'rt') as f:
text = f.read()
print(text)
jscene = json.loads(text)
with open(file, 'rt') as f:
text = f.read()
print(text)
jscene = json.loads(text)
scene = bpy.context.scene
# Select the board
@ -210,72 +202,77 @@ def apply_scene(file, n_view=0):
bpy.ops.object.select_all(action='SELECT')
# Make sure we start rotations from 0
bpy.context.active_object.rotation_euler = (0, 0, 0)
global location
location = bpy.context.active_object.location.copy()
povs = jscene.get('point_of_view')
if povs:
pov = povs[n_view]
global cur_rot
pov = povs[0]
# Apply point of view
do_point_of_view(pov, 'view')
cur_rot = do_point_of_view(pov, 'view')
# Apply extra rotations
do_rotate(pov, 'rotate_x', 0)
do_rotate(pov, 'rotate_y', 1)
do_rotate(pov, 'rotate_z', 2)
cur_rot = (cur_rot[0] + pov.get('rotate_x', 0),
cur_rot[1] + pov.get('rotate_y', 0),
cur_rot[2] + pov.get('rotate_z', 0))
print(f'- Initial rotations: {cur_rot}')
do_rotate(cur_rot)
# Add a camera
global auto_camera
if not n_view:
# First time: create the camera
camera = jscene.get('camera')
if not camera:
# First time: create the camera
camera = jscene.get('camera')
if not camera:
auto_camera = True
name = 'kibot_camera'
pos = (0.0, 0.0, 10.0)
type = 'PERSP'
else:
name = camera.get('name', 'unknown')
pos = camera.get('position', None)
type = camera.get('type', 'PERSP')
if pos is None:
auto_camera = True
name = 'kibot_camera'
pos = (0.0, 0.0, 10.0)
type = 'PERSP'
pos = (0, 0, 0)
else:
name = camera.get('name', 'unknown')
pos = camera.get('position', None)
type = camera.get('type', 'PERSP')
if pos is None:
auto_camera = True
pos = (0, 0, 0)
else:
auto_camera = False
print(f"- Creating camera {name} at {pos}")
cam_data = bpy.data.cameras.new(name)
global cam_ob
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
cam_ob.data.type = type
auto_camera = False
print(f"- Creating camera {name} at {pos}")
cam_data = bpy.data.cameras.new(name)
global cam_ob
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
cam_ob.data.type = type
if auto_camera:
print('- Changing camera to focus the board')
bpy.ops.view3d.camera_to_view_selected()
cam_ob.location = (cam_ob.location[0], cam_ob.location[1], cam_ob.location[2]*1.1)
cam_ob.location = (cam_ob.location[0], cam_ob.location[1],
cam_ob.location[2]*jscene.get('auto_camera_z_axis_factor', 1.1))
# Add lights
if not n_view:
# First time: create the 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))
typ = light.get('type', 'SUN')
energy = light.get('energy', 0.0)
print(f"- Creating light {name} at {pos}, type: {typ} energy: {energy}")
light_data = bpy.data.lights.new(name, typ)
print(f"- Default energy: {light_data.energy}")
if energy:
light_data.energy = energy
light_ob = bpy.data.objects.new(name=name, object_data=light_data)
scene.collection.objects.link(light_ob)
light_ob.location = pos
# First time: create the 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))
typ = light.get('type', 'SUN')
energy = light.get('energy', 0.0)
print(f"- Creating light {name} at {pos}, type: {typ} energy: {energy}")
light_data = bpy.data.lights.new(name, typ)
print(f"- Default energy: {light_data.energy}")
if energy:
light_data.energy = energy
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 and not n_view:
if render:
scene.cycles.samples = render.get('samples', 10)
r = scene.render
r.engine = 'CYCLES'
@ -291,6 +288,43 @@ def apply_scene(file, n_view=0):
return len(povs)
def apply_scene(n_view):
global jscene
if jscene is None:
return
# Make sure we start rotations from 0
povs = jscene.get('point_of_view')
if povs:
global cur_rot
pov = povs[n_view]
# Apply point of view
new_rot = do_point_of_view(pov, 'view')
# Apply extra rotations
new_rot = (new_rot[0] + pov.get('rotate_x', 0),
new_rot[1] + pov.get('rotate_y', 0),
new_rot[2] + pov.get('rotate_z', 0))
if new_rot != cur_rot:
print(f'- Rotations: {new_rot}')
# Reset the position
bpy.context.active_object.location = location.copy()
# Reset the rotation
bpy.context.active_object.rotation_euler = (0, 0, 0)
# Apply the new rotation
do_rotate(new_rot)
cur_rot = new_rot
# Move the camera
global auto_camera
if auto_camera and not jscene.get('fixed_auto_camera', False):
print('- Changing camera to focus the board')
bpy.ops.view3d.camera_to_view_selected()
cam_ob.location = (cam_ob.location[0], cam_ob.location[1],
cam_ob.location[2]*jscene.get('auto_camera_z_axis_factor', 1.1))
bpy.context.view_layer.update()
EXPORTERS = {'fbx': fbx_export,
'obj': obj_export,
'x3d': x3d_export,
@ -369,7 +403,7 @@ def main():
ops["center_boards"] = args.dont_center
bpy.ops.pcb2blender.import_pcb3d(**ops)
# Apply the scene first scene
c_views = apply_scene(args.scene)
c_views = apply_start_scene(args.scene)
c_formats = len(args.format)
if c_formats % c_views:
print("The number of outputs must be a multiple of the views (views: {} outputs: {})".format(c_views, c_formats))
@ -378,7 +412,7 @@ def main():
for n in range(c_views):
if n:
# Apply scene N
apply_scene(args.scene, n)
apply_scene(n)
# Get the current slice
formats = args.format[n*per_pass:(n+1)*per_pass]
outputs = args.output[n*per_pass:(n+1)*per_pass]

View File

@ -13,6 +13,7 @@ Dependencies:
"""
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
@ -26,6 +27,7 @@ from . import log
logger = log.get_logger()
bb = None
RE_FILE_ID = re.compile(r"\%\d*d")
def get_board_size():
@ -207,10 +209,14 @@ class BlenderPointOfViewOptions(Optionable):
""" *[top,bottom,front,rear,right,left,z,Z,y,Y,x,X] Point of view.
Compatible with `render_3d` """
self.file_id = ''
""" String to diferentiate the name of this view.
When empty we use the `view` """
""" String to diferentiate 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
self._file_id = ''
def config(self, parent):
super().config(parent)
@ -218,9 +224,14 @@ class BlenderPointOfViewOptions(Optionable):
view = self._views.get(self.view, None)
if view is not None:
self.view = view
self._file_id = self.file_id
if not self._file_id:
self._file_id = '_'+self._rviews.get(self.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):
@ -304,6 +315,15 @@ class Blender_ExportOptions(BaseOptions):
""" [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
@ -369,7 +389,7 @@ class Blender_ExportOptions(BaseOptions):
elif isinstance(self.point_of_view, BlenderPointOfViewOptions):
self.point_of_view = [self.point_of_view]
def get_output_filename(self, o, output_dir, pov):
def get_output_filename(self, o, output_dir, pov, order):
if o.type == 'render':
self._expand_ext = 'png'
elif o.type == 'blender':
@ -377,7 +397,15 @@ class Blender_ExportOptions(BaseOptions):
else:
self._expand_ext = o.type
cur_id = self._expand_id
self._expand_id += pov._file_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(output_dir, o.output)
self._expand_id = cur_id
return name
@ -530,6 +558,8 @@ class Blender_ExportOptions(BaseOptions):
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)
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,
@ -538,11 +568,21 @@ class Blender_ExportOptions(BaseOptions):
'background1': ro.background1,
'background2': ro.background2}
povs = []
last_pov = BlenderPointOfViewOptions()
for pov in self.point_of_view:
povs.append({'rotate_x': -pov.rotate_x,
'rotate_y': -pov.rotate_y,
'rotate_z': -pov.rotate_z,
'view': pov.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)
@ -569,18 +609,22 @@ class Blender_ExportOptions(BaseOptions):
if not pi.stack_boards:
cmd.append('--dont_stack_boards')
cmd.append('--format')
for _ in self.point_of_view:
for o in self.outputs:
cmd.append(o.type)
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 o in self.outputs:
name = self.get_output_filename(o, self._parent.output_dir, pov)
if name in names:
raise KiPlotConfigurationError('Repeated name (use `file_id`): '+name)
cmd.append(name)
names.add(name)
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)
order += 1
cmd.extend(['--scene', f.name])
cmd.append(pcb3d_file)
# Execute the command

View File

@ -0,0 +1,24 @@
# KiBot Blender export test multiple view points
# Multiple points of view
kibot:
version: 1
outputs:
- name: '3d_video_export'
comment: "Generates the frames for a video of a rotating PCB"
type: blender_export
options:
render_options:
transparent_background: true
samples: 10
fixed_auto_camera: true
auto_camera_z_axis_factor: 1.5
default_file_id: '_%03d'
point_of_view:
- rotate_x: 30
rotate_z: -20
- rotate_x: 4
rotate_z: 4
steps: 90
outputs:
- type: render