import FreeCAD as App import FreeCADGui as Gui from datetime import datetime def print_verbose(msg): verbose = False if verbose: now = datetime.now() current_time = now.strftime("%H:%M:%S") print(current_time, msg) 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 self._should_render(obj) or self._should_render_as_is(obj) or obj.TypeId in ['PartDesign::CoordinateSystem', 'PartDesign::Line']: 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', 'Part::MultiFuse', 'Part::Loft', 'Part::Torus', 'Part::Cylinder'] def _should_render_as_is(self, obj): return obj.TypeId in ['App::FeaturePython'] 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_verbose('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) if 'Assembly_handbook_RasterSavedView' in view.PropertiesList and view.Assembly_handbook_RasterSavedView is not None: tmp_doc = view.Assembly_handbook_RasterSavedView.Document close_tmp_doc = False else: tmp_doc = App.newDocument('tmp_raster', hidden=False, temp=False) close_tmp_doc = True transparent_background = True if 'Assembly_handbook_TransparentBackground' in view.PropertiesList: transparent_background = view.Assembly_handbook_TransparentBackground objects_to_reset = {} duplicated_parts = {} try: print_verbose("Preparing scene...") # Clean existing scene (if any) sceneGroup = tmp_doc.getObject('Scene') if sceneGroup is not None: sceneGroup.removeObjectsFromDocument() tmp_doc.removeObject(sceneGroup.Name) # construct new scene with links to the parts we want sceneGroup = tmp_doc.addObject('App::DocumentObjectGroup', 'Scene') prev_parts = [] new_parts = [] all_parts = view.XSource + view.Source objects_to_hide = [] if 'Assembly_handbook_HideParts' in view.PropertiesList: objects_to_hide = self._flatten_objects_tree(view.Assembly_handbook_HideParts) for part in all_parts: 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 if part.TypeId in ['Part::Part2DObjectPython']: link.Placement = part.Placement is_new_part = workbench.techDrawExtensions.isNewPartInView(view, part) if not fast_render: # check if another part with different render settings will conflict with ours # a conflict occurs when two parts link to the same object (directly or indirectly), because render settings (such as color) are set at the object level 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 self._should_render(obj) and 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 part_copy.ViewObject.Visibility = False sceneGroup.addObject(link) 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) and not obj in objects_to_hide: 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 ) if not obj.ViewObject.Visibility: obj.ViewObject.ShapeMaterial.AmbientColor = (0, 0, 0) obj.ViewObject.ShapeMaterial.DiffuseColor = (0, 0, 0) obj.ViewObject.ShapeMaterial.SpecularColor = (0, 0, 0) obj.ViewObject.ShapeMaterial.EmissiveColor = (0, 0, 0) 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) targetViewVolume = None try: targetViewVolume = view.Assembly_handbook_ViewVolume except: pass if targetViewVolume is None: tmp_doc_view.fitAll() else: sceneGroup.ViewObject.Visibility = False viewVolumeLink = tmp_doc.addObject('App::Link', 'ViewVolume') viewVolumeLink.LinkedObject = targetViewVolume viewVolumeLink.Placement = targetViewVolume.Placement tmp_doc_view.fitAll() tmp_doc.removeObject(viewVolumeLink.Name) sceneGroup.ViewObject.Visibility = True print_verbose("Near=" + str(cam.nearDistance.getValue()) + ", far="+str(cam.farDistance.getValue())) cam.nearDistance.setValue(cam.nearDistance.getValue() - 1000) cam.farDistance.setValue(cam.farDistance.getValue() + 1000) 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 #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: print_verbose("Fast rasterization...") 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 print_verbose("Rendering old parts (gray)...") 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) print_verbose("Rendering new parts (black)...") 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 print_verbose("Compositing images...") composite_img = prev_parts_img.copy() composite_img.paste(new_parts_img, None, new_parts_img) # Optimize the image to reduce storage size if not fast_render: print_verbose("Optimizing PNG size...") num_colors = 32 # All-or-nothing alpha: we use a white background and only make pixels fully transparent where alpha is zero, to not loose antialiasing bg_img = Image.new(composite_img.mode, composite_img.size, color = '#ffffff') bg_img.paste(composite_img.convert('RGB'), composite_img) final_alpha = composite_img.split()[3].point(lambda p: 0 if p <= int(255/num_colors+0.5) else 255) if not transparent_background: final_alpha = final_alpha.point(lambda p: 255) composite_img = bg_img composite_img.putalpha(final_alpha) # Convert to indexed colors composite_img = composite_img.quantize(colors=num_colors, dither=Image.Dither.NONE) finally: print_verbose("Cleaning scene...") #raise Exception("test") # 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) and not obj in objects_to_hide: 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] obj.ViewObject.PointMaterial.Transparency = 0 # remove the temporary document if close_tmp_doc: App.closeDocument(tmp_doc.Name) print_verbose("Finalizing view...") # 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()[-1] 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 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() print_verbose("Done") 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 blue, background in red, fill shapes in green # the green band contains the lines image, the red band contains the inverted alpha layer # if there is a clipping plane set with "Fill clip plane", the blue band contains the intersection with the clip plane configured = [] print_verbose('Preparing objects for line rendering...') 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 obj in configured: continue configured.append(obj) if self._should_render(obj) and not fast_render: obj.ViewObject.DisplayMode = 'Flat Lines' obj.ViewObject.PointMaterial.Transparency = 1.0 # hide points obj.ViewObject.LineColor = (0.0, 0.0, 1.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, 1.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 print_verbose('Rendering lines...') temp_file_name = tempfile.gettempdir() + "/ahb_temp_image.png" #temp_file_name = "/home/youen/tmp/ahb_temp_image.png" doc_view.saveImage(temp_file_name, resolution[0]+2, resolution[1]+2, "#ff00ff") # 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) clip_img = lines_bands[2] generate_outlines = not fast_render #generate_outlines = False 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 print_verbose('Preparing objects for outline rendering...') step = 8 r = step g = step b = step configured = [] for link in doc.findObjects(): if link in parts or link in masking_parts: for obj in self._flatten_objects_tree([link]): if obj in configured: continue configured.append(obj) if self._should_render(obj) and obj.TypeId != 'Part::Part2DObjectPython': configured.append(obj) 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 print_verbose('Rendering shapes...') 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) print_verbose('Extracting outlines...') 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)) print_verbose('Combining lines and outlines...') 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 alpha_img = alpha_img.point(lambda p: 0 if p == 0 else 255) # colorize final image print_verbose('Colorizing 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 ]) # set clip color if not fast_render: clip_color = (0.5, 0.5, 0.5) colorized_clip_img = Image.merge("RGB", [ clip_img.point(lambda p: int(clip_color[0] * (255.0 - p))), clip_img.point(lambda p: int(clip_color[1] * (255.0 - p))), clip_img.point(lambda p: int(clip_color[2] * (255.0 - p))) ]) result.paste(colorized_clip_img, clip_img.point(lambda p: 255 - p)) # 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()