FreeCAD workbench to create assembly handbooks
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

413 lines
18 KiB

import FreeCAD as App
import FreeCADGui as Gui
class RasterView:
def __init__(self, view):
self.source_view = view
doc = view.Document
self.image_file_name = doc.FileName.replace('.FCStd', '') + '_raster/' + view.Name + '.png'
def init_image_projection(self):
doc = self.source_view.Document
image_name = self.source_view.Label + "_raster"
image = doc.getObject(image_name)
if image is None:
return False
self.image_view = image
if image.Assembly_handbook_ViewVolumeWidth > 0:
self._precompute_image_projection()
return True
return False
def init_image(self):
workbench = Gui.getWorkbench("AssemblyHandbookWorkbench") #: :type workbench: AssemblyHandbookWorkbench
doc = self.source_view.Document
page = workbench.techDrawExtensions.getViewPage(self.source_view)
image_name = self.source_view.Label + "_raster"
image = doc.getObject(image_name)
if image is None:
image = doc.addObject('TechDraw::DrawViewImage', image_name)
image.addProperty("App::PropertyFloat", "Assembly_handbook_ViewVolumeWidth", "Assembly_handbook")
image.addProperty("App::PropertyFloat", "Assembly_handbook_ViewVolumeHeight", "Assembly_handbook")
image.addProperty("App::PropertyFloat", "Assembly_handbook_ViewVolumeDepth", "Assembly_handbook")
image.addProperty("App::PropertyVector", "Assembly_handbook_ViewVolumeOffset", "Assembly_handbook")
if not image in page.Views:
page.addView(image)
new_views_list = page.Views
new_views_list.remove(image)
view_idx = new_views_list.index(self.source_view)
new_views_list.insert(view_idx, image)
page.Views = new_views_list
self.image_view = image
if image.Assembly_handbook_ViewVolumeWidth > 0:
self._precompute_image_projection()
def _precompute_image_projection(self):
YDirection = self.source_view.Direction.cross(self.source_view.XDirection)
self.image_x_dir = self.source_view.XDirection / self.image_view.Assembly_handbook_ViewVolumeWidth
self.image_y_dir = YDirection / self.image_view.Assembly_handbook_ViewVolumeHeight
self.image_z_dir = self.source_view.Direction / self.image_view.Assembly_handbook_ViewVolumeDepth
self.image_x_dir_inv = self.source_view.XDirection * self.image_view.Assembly_handbook_ViewVolumeWidth
self.image_y_dir_inv = YDirection * self.image_view.Assembly_handbook_ViewVolumeHeight
self.image_z_dir_inv = self.source_view.Direction * self.image_view.Assembly_handbook_ViewVolumeDepth
def project3DPointToImageView(self, point3d):
offset = self.image_view.Assembly_handbook_ViewVolumeOffset
return App.Vector(self.image_x_dir.dot(point3d) + offset.x, self.image_y_dir.dot(point3d) + offset.y, self.image_z_dir.dot(point3d) + offset.z)
def project3DPointToSourceView(self, point3d):
offset = self.image_view.Assembly_handbook_ViewVolumeOffset
offset = App.Vector((offset.x-0.5) * self.image_view.Assembly_handbook_ViewVolumeWidth, (offset.y-0.5) * self.image_view.Assembly_handbook_ViewVolumeHeight, (offset.z-0.5) * self.image_view.Assembly_handbook_ViewVolumeDepth)
#image_view_point = App.Vector(self.image_x_dir.dot(point3d), self.image_y_dir.dot(point3d), self.image_z_dir.dot(point3d))
#return App.Vector(image_view_point.x * self.image_view.Assembly_handbook_ViewVolumeWidth, image_view_point.y * self.image_view.Assembly_handbook_ViewVolumeHeight, 0)
YDirection = self.source_view.Direction.cross(self.source_view.XDirection)
return App.Vector(self.source_view.XDirection.dot(point3d) + offset.x, YDirection.dot(point3d) + offset.y, self.image_z_dir.dot(point3d) + offset.z)
def projectImageViewPointTo3D(self, point2d):
offset = self.image_view.Assembly_handbook_ViewVolumeOffset
p = point2d - offset
return self.image_x_dir_inv * p.x + self.image_y_dir_inv * p.y + self.image_z_dir_inv * p.z
def _flatten_objects_tree(self, obj_list):
result = []
for obj in obj_list:
if obj.TypeId == 'Part::FeaturePython' and hasattr(obj, 'LinkedObject'): # variant link
result.extend(self._flatten_objects_tree(obj.Group))
elif obj.TypeId in ['App::Link']:
result.extend(self._flatten_objects_tree([obj.LinkedObject]))
elif obj.TypeId in ['App::Part', 'App::DocumentObjectGroup']:
result.extend(self._flatten_objects_tree(obj.Group))
elif obj.TypeId in ['Part::Feature', 'Part::FeaturePython', 'PartDesign::Body', 'PartDesign::CoordinateSystem', 'PartDesign::Line', 'Part::Mirroring', 'Part::Cut', 'Part::Part2DObjectPython']:
result.append(obj)
if hasattr(obj, 'Group'):
result.extend(self._flatten_objects_tree(obj.Group))
return result
def _should_render(self, obj):
return obj.TypeId in ['Part::Feature', 'Part::FeaturePython', 'PartDesign::Body', 'Part::Mirroring', 'Part::Cut', 'Part::Part2DObjectPython']
def render(self, fast_render = True):
from pivy import coin
import os
from PIL import Image, ImageDraw, ImageChops
import Part
Image.MAX_IMAGE_PIXELS = 9999999999 # allow very high resolution images
workbench = Gui.getWorkbench("AssemblyHandbookWorkbench") #: :type workbench: AssemblyHandbookWorkbench
view = self.source_view
self.init_image()
print('Rasterizing ' + view.Label + " to " + self.image_file_name + "...")
dir = os.path.dirname(self.image_file_name)
if not os.path.exists(dir):
os.makedirs(dir)
tmp_doc = App.newDocument('tmp_raster', hidden=False, temp=False)
objects_to_reset = {}
duplicated_parts = {}
try:
# construct new scene with links to the parts we want
prev_parts = []
new_parts = []
for part in view.XSource:
link = tmp_doc.addObject('App::Link', part.Name)
link.Label = part.Label
if part.TypeId == 'App::Link':
link.LinkedObject = part.LinkedObject
link.Placement = part.Placement
elif part.TypeId == 'Part::FeaturePython' and hasattr(part, 'LinkedObject'): # variant link
link.LinkedObject = part.LinkedObject
link.Placement = part.Placement
else:
link.LinkedObject = part
is_new_part = workbench.techDrawExtensions.isNewPartInView(view, part)
if not fast_render:
is_conflicting = False
if link.LinkedObject in duplicated_parts.keys():
link.LinkedObject = duplicated_parts[link.LinkedObject]
else:
other_parts = prev_parts if is_new_part else new_parts
for other_part in other_parts:
other_objects = self._flatten_objects_tree([other_part])
for obj in self._flatten_objects_tree([link]):
if obj in other_objects:
is_conflicting = True
if is_conflicting:
# We must copy the part because otherwise we can't control the emissive color (link material override does not work for emissive color)
#print("conflict: " + link.LinkedObject.Document.Name + '#' + link.LinkedObject.Label)
shape_copy = Part.getShape(link.LinkedObject,'',needSubElement=False,refine=False)
part_copy = tmp_doc.addObject('Part::Feature','ShapeCopy')
part_copy.Shape = shape_copy
part_copy.Label = part.Label
duplicated_parts[link.LinkedObject] = part_copy
link.LinkedObject = part_copy
if is_new_part:
new_parts.append(link)
else:
prev_parts.append(link)
# hide objects that we don't want to display ; also make a backup of properties we want to reset after we're done
for obj in self._flatten_objects_tree([link]):
if obj in objects_to_reset.keys():
continue
if self._should_render(obj):
if not fast_render:
objects_to_reset[obj] = (
obj.ViewObject.Visibility,
obj.ViewObject.LineColor,
obj.ViewObject.ShapeMaterial.AmbientColor,
obj.ViewObject.ShapeMaterial.DiffuseColor,
obj.ViewObject.ShapeMaterial.SpecularColor,
obj.ViewObject.ShapeMaterial.EmissiveColor,
obj.ViewObject.LineWidth,
obj.ViewObject.DisplayMode
)
else:
objects_to_reset[obj] = (
obj.ViewObject.Visibility,
)
obj.ViewObject.Visibility = False
tmp_doc_view = Gui.getDocument(tmp_doc.Name).mdiViewsOfType('Gui::View3DInventor')[0]
cam = tmp_doc_view.getCameraNode()
rot = coin.SbRotation(coin.SbVec3f(1,0,0), coin.SbVec3f(view.XDirection.x,view.XDirection.y,view.XDirection.z))
rot *= coin.SbRotation(coin.SbVec3f(0,0,1), coin.SbVec3f(view.Direction.x,view.Direction.y,view.Direction.z))
cam.orientation.setValue(rot)
tmp_doc_view.fitAll()
viewVolume = cam.getViewVolume(0.0)
self.image_view.Assembly_handbook_ViewVolumeWidth = viewVolume.getWidth()
self.image_view.Assembly_handbook_ViewVolumeHeight = viewVolume.getHeight()
self.image_view.Assembly_handbook_ViewVolumeDepth = viewVolume.getDepth()
max_res = 3200 # todo: keep aspect ratio when we limit max image dimensions
#max_res = 1500
resolution = [
int(viewVolume.getWidth() * view.Scale * 10),
int(viewVolume.getHeight() * view.Scale * 10)
]
if resolution[0] > max_res:
resolution[1] = int(resolution[1] * max_res / resolution[0])
resolution[0] = int(max_res)
if resolution[1] > max_res:
resolution[0] = int(resolution[0] * max_res / resolution[1])
resolution[1] = int(max_res)
if fast_render:
composite_img = self._render_lines(tmp_doc, resolution, prev_parts + new_parts, (0.0, 0.0, 0.0), [])
else:
# render old parts in gray lines
prev_parts_img = self._render_lines(tmp_doc, resolution, prev_parts, (0.6, 0.6, 0.6), [], fast_render)
# render new parts in black lines (old parts can mask them)
new_parts_img = self._render_lines(tmp_doc, resolution, new_parts, (0.0, 0.0, 0.0), prev_parts, fast_render)
# create the composite image
composite_img = prev_parts_img.copy()
composite_img.paste(new_parts_img, None, new_parts_img)
finally:
# restore properties on objects we have modified
for obj, props in objects_to_reset.items():
obj.ViewObject.Visibility = props[0]
if self._should_render(obj):
obj.ViewObject.LineColor = props[1]
obj.ViewObject.ShapeMaterial.AmbientColor = props[2]
obj.ViewObject.ShapeMaterial.DiffuseColor = props[3]
obj.ViewObject.ShapeMaterial.SpecularColor = props[4]
obj.ViewObject.ShapeMaterial.EmissiveColor = props[5]
obj.ViewObject.LineWidth = props[6]
obj.ViewObject.DisplayMode = props[7]
# remove the temporary document
App.closeDocument(tmp_doc.Name)
# Crop the image, which is also used to deduce the center of the source view
original_size = composite_img.size
diff_source_img = composite_img.split()[3]
bg = Image.new(diff_source_img.mode, diff_source_img.size, '#000000') # fills an image with the background color
diff = ImageChops.difference(diff_source_img, bg) # diff between the actual image and the background color
bbox = diff.getbbox() # finds border size (non-black portion of the image)
composite_img = composite_img.crop(bbox)
'''draw = ImageDraw.Draw(composite_img)
def debugPoint(p3d):
p2d = self.project3DPointToImageView(p3d)
pp = App.Vector(p2d.x * original_size[0] - bbox[0], (1.0-p2d.y) * original_size[1] - bbox[1])
#print('pp', pp)
len = 100
draw.line([(pp.x, pp.y-len), (pp.x, pp.y+len)], fill=128, width = 7)
draw.line([(pp.x-len, pp.y), (pp.x+len, pp.y)], fill=128, width = 7)
debugPoint(App.Vector(-12.5, 37.5, 25.0))
debugPoint(App.Vector(-12.5, -1387.5, 25.0))
debugPoint(App.Vector(131.23702882966705, -655.0000021095163, 145.21130178331268))'''
composite_img.save(self.image_file_name)
sb_offset = viewVolume.projectToScreen(coin.SbVec3f(0,0,0))
crop_offset = App.Vector(((bbox[0] + bbox[2])/2 - original_size[0]/2)/original_size[0], ((bbox[1] + bbox[3])/2 - original_size[1]/2)/original_size[1], 0)
self.image_view.Assembly_handbook_ViewVolumeOffset = App.Vector(sb_offset[0] - crop_offset.x, sb_offset[1] + crop_offset.y, sb_offset[2])
self._precompute_image_projection()
p2dA = self.project3DPointToImageView(App.Vector(0,0,0))
p2dB = self.project3DPointToImageView(view.XDirection)
image_scale = view.Scale / (p2dB.x - p2dA.x) / original_size[0] * 10
# display the image in the view
image = self.image_view
image.ImageFile = ""
image.Scale = image_scale
image.X = view.X
image.Y = view.Y
image.ImageFile = self.image_file_name # TODO: see if it's possible to set a relative path
image.ViewObject.Crop = True
image.Width = composite_img.size[0] * image_scale / 10.0 * 1.01
image.Height = composite_img.size[1] * image_scale / 10.0 * 1.01
image.recompute()
def _render_lines(self, doc, resolution, parts, line_color, masking_parts, fast_render = True):
import tempfile
from PIL import Image, ImageDraw, ImageFilter
doc_view = Gui.getDocument(doc.Name).mdiViewsOfType('Gui::View3DInventor')[0]
# render lines in black, background in red, fill shapes in green
# the green band contains the lines images, the red band contains the inverted alpha layer
for link in doc.findObjects():
if link in parts or link in masking_parts:
link.ViewObject.Visibility = True
# in current version of freecad, link override material does not allow to override all material properties, for example emissive color, so we have to change material of the linked object
for obj in self._flatten_objects_tree([link]):
if self._should_render(obj) and not fast_render:
obj.ViewObject.LineColor = (0.0, 0.0, 0.0, 0.0) if link in parts else (1.0, 0.0, 1.0)
obj.ViewObject.ShapeMaterial.AmbientColor = (0.0, 0.0, 0.0, 0.0)
obj.ViewObject.ShapeMaterial.DiffuseColor = (0.0, 0.0, 0.0, 0.0)
obj.ViewObject.ShapeMaterial.SpecularColor = (0.0, 0.0, 0.0, 0.0)
obj.ViewObject.ShapeMaterial.EmissiveColor = (0.0, 1.0, 0.0, 0.0) if link in parts else (1.0, 0.0, 1.0)
# We need to set two different values otherwise freecad does not always update LineWidth of sub-elements
obj.ViewObject.LineWidth = 1.0
obj.ViewObject.LineWidth = 2.0
else:
link.ViewObject.Visibility = False
temp_file_name = tempfile.gettempdir() + "/ahb_temp_image.png"
doc_view.saveImage(temp_file_name, resolution[0]+2, resolution[1]+2, "#ff0000") # we add 1 pixel border that we will need to crop later
lines_bands_img = self._read_image(temp_file_name)
lines_bands = lines_bands_img.split()
lines_img = lines_bands[1]
alpha_img = lines_bands[0].point(lambda p: 255 - p)
generate_outlines = not fast_render
if generate_outlines:
# Render all shapes with different colors, in order to extract outlines (where color changes)
# This is needed because FreeCAD does not render lines on the boundary of curve shapes, such as spheres or cylinders
# The technique could be improved by using the depth buffer instead, in order to detect boundaries within the same object
step = 8
r = step
g = step
b = step
for link in doc.findObjects():
if link in parts or link in masking_parts:
for obj in self._flatten_objects_tree([link]):
if self._should_render(obj) and obj.TypeId != 'Part::Part2DObjectPython':
obj.ViewObject.DisplayMode = 'Shaded'
obj.ViewObject.ShapeMaterial.AmbientColor = (0.0, 0.0, 0.0, 0.0)
obj.ViewObject.ShapeMaterial.DiffuseColor = (0.0, 0.0, 0.0, 0.0)
obj.ViewObject.ShapeMaterial.SpecularColor = (0.0, 0.0, 0.0, 0.0)
obj.ViewObject.ShapeMaterial.EmissiveColor = (r/255.0, g/255.0, b/255.0, 0.0) if link in parts else (1.0, 1.0, 1.0, 0.0)
r = r + step
if r >= 256 - step:
r = step
g = g + step
if g >= 256 - step:
g = step
b = b + step
if b >= 256 - step:
b = step
else:
obj.ViewObject.Visibility = False
doc_view.saveImage(temp_file_name, (resolution[0]+2)*2, (resolution[1]+2)*2, "#ffffff") # shapes are rendered at twice the resolution for antialiasing
shapes_img = self._read_image(temp_file_name)
outlines_img = None
for x in range(0, 3):
for y in range(0, 3):
if x == 1 and y == 1: continue
kernel = [0, 0, 0, 0, 1, 0, 0, 0, 0]
kernel[y * 3 + x] = -1
partial_outlines = shapes_img.filter(ImageFilter.Kernel((3, 3), kernel, 1, 127))
partial_outlines = partial_outlines.point(lambda p: 255 if p == 127 else 0)
partial_outlines = partial_outlines.convert("L")
partial_outlines = partial_outlines.point(lambda p: 255 if p == 255 else 0)
if outlines_img is None:
outlines_img = partial_outlines
else:
outlines_img.paste(partial_outlines, None, partial_outlines.point(lambda p: 0 if p == 255 else 255))
lines_fullres = lines_img.resize(outlines_img.size, Image.NEAREST)
lines_fullres.paste(outlines_img, None, outlines_img.point(lambda p: 255 if p == 0 else 0))
#lines_fullres.paste(255, alpha_fullres.point(lambda p: 255 if p == 0 else 0))
all_lines = lines_fullres.resize(lines_img.size, Image.BILINEAR)
#all_lines = lines_img.copy()
alpha_fullres = alpha_img.resize(outlines_img.size, Image.NEAREST)
alpha_fullres.paste(outlines_img.point(lambda p: 255), None, outlines_img.point(lambda p: 255 if p == 0 else 0))
alpha_img = alpha_fullres.resize(all_lines.size, Image.BILINEAR)
else:
all_lines = lines_img
# colorize final image
fill_color = (1.0, 1.0, 1.0)
result = Image.merge("RGBA", [
all_lines.point(lambda p: int(fill_color[0] * p + line_color[0] * (255.0 - p))),
all_lines.point(lambda p: int(fill_color[1] * p + line_color[1] * (255.0 - p))),
all_lines.point(lambda p: int(fill_color[2] * p + line_color[2] * (255.0 - p))),
alpha_img.point(lambda p: 0 if p == 0 else 255)
])
# crop 1px borders
result = result.crop((1, 1, result.size[0] - 1, result.size[1] - 1))
return result
def _read_image(self, file_name):
from PIL import Image
with Image.open(file_name) as image:
return image.copy()