Commit 9210b2e5 authored by Andrey Filippov's avatar Andrey Filippov

more testing, added dialog to setup paths

parent 0b0cddbf
......@@ -2,10 +2,9 @@ from __future__ import division
from __future__ import print_function
'''
# Copyright (C) 2015, Elphel.inc.
# File: x3d_step_assy.py
# File: x3d_step_assy_color_match.py
# Generate x3d model from STEP parts models and STEP assembly
# by matching each solid in the assembly to the parts.
# Work in progress, not yet handles parts with symmetries
#
# Uses code from https://gist.github.com/hyOzd/2b38adff6a04e1613622
#
......@@ -28,6 +27,7 @@ from __future__ import print_function
@contact: andrey@elphel.coml
@deffield updated: Updated
'''
from email import Errors
__author__ = "Andrey Filippov"
__copyright__ = "Copyright 2015, Elphel, Inc."
__license__ = "GPL"
......@@ -37,34 +37,69 @@ __email__ = "andrey@elphel.com"
__status__ = "Development"
import FreeCAD
import FreeCADGui # just to update console output (change to threads later?) - does not seem to work
import Part
import os
import time
import pickle
import math
import xml.etree.ElementTree as et
from xml.dom import minidom
from FreeCAD import Base
from PySide import QtCore, QtGui
import sys
import traceback
ROOT_DIR = '~/parts/0393/export'
DIR_LIST = ["parts","subassy_flat"]
ROOT_DIR = '~/parts/0393/export1'
STEP_PARTS='~/parts/0393/export1/step_parts'
#DIR_LIST = ["parts","subassy_flat"]
ASSEMBLY_PATH = None
INFO_DIR = "info"
X3D_DIR = "x3d"
X3D_EXT = ".x3d"
INFO_EXT = ".pickle"
PRECISION = 0.0001
PRECISION_AREA = 0.001
PRECISION_VOLUME = 0.001
PRECISION_GYRATION = 0.001
PRECISION_INSIDE = 0.03
COLOR_PER_VERTEX = True
COMPONENTS = None # to hold data structure that is long to build so it will survive if the macro crashes
if ROOT_DIR[0] == "~":
ROOT_DIR = os.path.join(os.path.expanduser('~'),ROOT_DIR[2:])
if STEP_PARTS[0] == "~":
STEP_PARTS = os.path.join(os.path.expanduser('~'),STEP_PARTS[2:])
def get_step_list(dir_listdirs):
"""
@param dir_listdirs - a single directory path or a list of directories to scan for parts definitions as STEP files
@return a list of full paths of the STEP parts models
"""
if not isinstance(dir_listdirs,(list,tuple)):
dir_listdirs=[dir_listdirs]
return [os.path.join(root,f)
for dir_path in dir_listdirs if os.path.isdir(dir_path)
for root, _, files in os.walk(dir_path, topdown=True, onerror=None, followlinks = True)
for f in files if f.endswith((".step",".stp",".STP",".STEP"))]
"""
def get_step_list(dir_list):
step_files = []
for rpath in dir_list:
apath = os.path.join(ROOT_DIR,rpath)
step_files += [os.path.join(rpath, f) for f in os.listdir(apath) if os.path.isfile(os.path.join(apath, f)) and f.endswith((".step",".stp"))]
try:
step_files += [os.path.join(rpath, f) for f in os.listdir(apath) if os.path.isfile(os.path.join(apath, f)) and f.endswith((".step",".stp"))]
except:
print ("Failed to add files from %s"%apath)
return step_files
"""
def vector_to_tuple(v):
return((v.x,v.y,v.z))
......@@ -104,60 +139,149 @@ def verticesToCheck(solid):
lv.append((v.X,v.Y,v.Z))
return lv
def create_file_info(shape, fname=""):
objects = []
#repairing open shells
solids = shape.Solids
if len(solids) != len(shape.Shells):
print ("Repairing open shells that are not solids for %s"%(fname))
solids = repair_solids_from_shells(shape)
fromShell=True
else:
fromShell=False
for s in (solids):
pp=s.PrincipalProperties
objects.append({
"rpath": fname,
"shell": fromShell,
"volume": s.Volume,
"area": s.Area,
"center": vector_to_tuple(s.CenterOfMass),
"principal": {'RadiusOfGyration': pp['RadiusOfGyration'],
'FirstAxisOfInertia': vector_to_tuple(pp['FirstAxisOfInertia']),
'SecondAxisOfInertia': vector_to_tuple(pp['SecondAxisOfInertia']),
'ThirdAxisOfInertia': vector_to_tuple(pp['ThirdAxisOfInertia']),
'Moments': pp['Moments'],
'SymmetryPoint': pp['SymmetryPoint'],
'SymmetryAxis': pp['SymmetryAxis']},
"vertices": verticesToCheck(s)
})
return objects
def create_file_info_nogui(shape, fname=""):
objects = []
#repairing open shells
solids = shape.Solids
if len(solids) != len(shape.Shells):
print ("Repairing open shells that are not solids for %s"%(fname))
solids = repair_solids_from_shells(shape)
fromShell=True
else:
fromShell=False
for s in (solids):
pp=s.PrincipalProperties
objects.append({
"rpath": fname,
"shell": fromShell,
"volume": s.Volume,
"area": s.Area,
"center": vector_to_tuple(s.CenterOfMass),
"principal": {'RadiusOfGyration': pp['RadiusOfGyration'],
'FirstAxisOfInertia': vector_to_tuple(pp['FirstAxisOfInertia']),
'SecondAxisOfInertia': vector_to_tuple(pp['SecondAxisOfInertia']),
'ThirdAxisOfInertia': vector_to_tuple(pp['ThirdAxisOfInertia']),
'Moments': pp['Moments'],
'SymmetryPoint': pp['SymmetryPoint'],
'SymmetryAxis': pp['SymmetryAxis']},
"vertices": verticesToCheck(s)
})
# return objects
return (objects,solids)
def create_file_info(freecadObjects, fname=""):
if not "Gui" in dir(FreeCAD):
return create_file_info_nogui(freecadObjects, fname)
# Count all shells in all objects
numShells = 0
for o in freecadObjects:
if hasattr(o, "Shape"):
numShells += len(o.Shape.Shells)
txt=""
if fname:
txt += " in "+fname
progress_bar = Base.ProgressIndicator()
progress_bar.start("Generating objects%s to export to X3D ..."%(txt), len(freecadObjects))
objects = []
allSolids=[]
for o in freecadObjects:
if hasattr(o, "Shape"):
shape=o.Shape
#repairing open shells
solids = shape.Solids
if len(solids) != len(shape.Shells):
print ("Repairing open shells that are not solids for %s"%(fname))
solids = repair_solids_from_shells(shape)
fromShell=True
else:
fromShell=False
# get all colors for faces in this object (normally just one Shell/Solid
color_set=set()
if o.ViewObject:
for clr in o.ViewObject.DiffuseColor: # colors are one per face
color_set.add(clr)
col_list = list(color_set)
col_dict={} # index for each color (reverse to list)
for i, clr in enumerate(col_list):
col_dict[clr] = i
#Calculate per-color centers for each object (normally each object has just one Solid/Shell
dc=o.ViewObject.DiffuseColor
if (len(dc) == 1) and (len(o.Shape.Faces)>1):
dc= dc * len(o.Shape.Faces)
colorCenters=[[0.0,0.0,0.0,0.0] for c in col_list] # SX,SY,SZ,S0
# for clr,face in zip(o.ViewObject.DiffuseColor, o.Shape.Faces):
for clr,face in zip(dc, o.Shape.Faces):
clr_index = col_dict[clr]
m = face.Area
c = face.CenterOfMass # Vector
colorCenters[clr_index][0]+= c.x * m
colorCenters[clr_index][1]+= c.y * m
colorCenters[clr_index][2]+= c.z * m
colorCenters[clr_index][3]+= m
# print ("%s: cx=%f, cy=%f, cz=%f, m=%f"%(fname, c.x,c.y,c.z,m))
color_center_area={}
for clr in col_dict:
clr_index = col_dict[clr]
color_center_area[clr]={"center":(colorCenters[clr_index][0]/colorCenters[clr_index][3],
colorCenters[clr_index][1]/colorCenters[clr_index][3],
colorCenters[clr_index][2]/colorCenters[clr_index][3]),
"area": colorCenters[clr_index][3]}
# print ("color_center_area[%s] = %s"%(str(clr), str(color_center_area[clr])))
for i, s in enumerate(solids):
pp=s.PrincipalProperties
object={
"rpath": fname,
"shell": fromShell,
"volume": s.Volume,
"area": s.Area,
"center": vector_to_tuple(s.CenterOfMass),
"principal": {'RadiusOfGyration': pp['RadiusOfGyration'],
'FirstAxisOfInertia': vector_to_tuple(pp['FirstAxisOfInertia']),
'SecondAxisOfInertia': vector_to_tuple(pp['SecondAxisOfInertia']),
'ThirdAxisOfInertia': vector_to_tuple(pp['ThirdAxisOfInertia']),
'Moments': pp['Moments'],
'SymmetryPoint': pp['SymmetryPoint'],
'SymmetryAxis': pp['SymmetryAxis']},
"vertices": verticesToCheck(s)
}
if i == 0:
object["colorCenters"] = color_center_area
objects.append(object)
allSolids.append(s)
progress_bar.next() # True) # True - enable ESC to abort
progress_bar.stop()
return (objects,allSolids)
def get_info_files(dir_list = DIR_LIST):
def get_info_files_nogui(dir_list = None):
if dir_list is None:
dir_list = [STEP_PARTS]
start_time=time.time()
sl = get_step_list(dir_list = dir_list)
# print ("Step files:")
# for i,f in enumerate (sl):
# print("%d: %s"%(i,f))
if not INFO_DIR in os.listdir(ROOT_DIR):
os.mkdir(os.path.join(ROOT_DIR,INFO_DIR))
todo_list = []
for f in sl:
for f in sl: # now f is a full absolute path
fname,_ = os.path.splitext(os.path.basename(f))
# print("%s -> %s"%(f,fname))
if not os.path.isfile(os.path.join(ROOT_DIR,INFO_DIR,fname+INFO_EXT)):
info_path = os.path.join(ROOT_DIR,INFO_DIR,fname+INFO_EXT)
step_file = f # os.path.join(ROOT_DIR, f)
if (not os.path.isfile(info_path)) or (os.path.getmtime(step_file) > os.path.getmtime(info_path)): # no info or step is newer
todo_list.append(f)
# for i,f in enumerate (todo_list):
# print("%d: %s"%(i,f))
for i, f in enumerate(todo_list):
apath=os.path.join(ROOT_DIR,f)
rslt_path = os.path.join(ROOT_DIR,INFO_DIR, os.path.splitext(os.path.basename(f))[0] + INFO_EXT)
for i, apath in enumerate(todo_list):
# apath = os.path.join(ROOT_DIR,f)
rslt_path = os.path.join(ROOT_DIR,INFO_DIR, os.path.splitext(os.path.basename(apath))[0] + INFO_EXT)
print("%d: Reading %s @%f"%(i,apath, time.time()-start_time), end="...")
shape = Part.read(apath)
print(" got %d solids @%f"%(len(shape.Solids), time.time()-start_time))
objects = create_file_info(shape,f)
objects,_ = create_file_info_nogui(shape, apath)
print (objects)
pickle.dump(objects, open(rslt_path, "wb" ))
......@@ -179,19 +303,99 @@ def get_info_files(dir_list = DIR_LIST):
o.insert(0,o.pop(mi))
return info_dict
def findPartsTransformations(solids, objects, candidates, info_dict, precision=0.01):
def get_info_files(dir_list = None):
if dir_list is None:
dir_list = [STEP_PARTS]
if not "Gui" in dir(FreeCAD):
return get_info_files_nogui(dir_list)
start_time=time.time()
sl = get_step_list(dir_listdirs = dir_list)
if not INFO_DIR in os.listdir(ROOT_DIR):
os.mkdir(os.path.join(ROOT_DIR,INFO_DIR))
todo_list = []
for f in sl: # now f is a full absolute path
print (f)
fname,_ = os.path.splitext(os.path.basename(f))
info_path = os.path.join(ROOT_DIR,INFO_DIR,fname+INFO_EXT)
step_file = f # os.path.join(ROOT_DIR, f)
if (not os.path.isfile(info_path)) or (os.path.getmtime(step_file) > os.path.getmtime(info_path)): # no info or step is newer
todo_list.append(f)
for i, apath in enumerate(todo_list):
# apath=os.path.join(ROOT_DIR,f)
rslt_path = os.path.join(ROOT_DIR,INFO_DIR, os.path.splitext(os.path.basename(apath))[0] + INFO_EXT)
print("%d: Reading %s @%f"%(i,apath, time.time()-start_time), end="...")
# Prepare data
FreeCAD.loadFile(apath)
doc = FreeCAD.activeDocument()
doc.Label = fname
print(" got %d objects @%f"%(len(doc.Objects), time.time()-start_time))
objects,_ = create_file_info(doc.Objects, apath)
FreeCAD.closeDocument(doc.Name)
FreeCADGui.updateGui()
# print (objects)
pickle.dump(objects, open(rslt_path, "wb" ))
# Now read all pickled data:
info_dict = {}
progress_bar = Base.ProgressIndicator()
progress_bar.start("Reading %d part info files ..."%(len(sl)), len(sl))
for f in sl:
name = os.path.splitext(os.path.basename(f))[0]
info_path = os.path.join(ROOT_DIR,INFO_DIR, name + INFO_EXT)
info_dict[name] = pickle.load(open(info_path, "rb"))
progress_bar.next()
progress_bar.stop()
FreeCAD.Console.PrintMessage("get_info_files() - loaded");
#Put largest element of multi-solid parts as the [0] index.
for k in info_dict:
if len(info_dict[k]) >1:
o = info_dict[k]
print (k,len(o),o)
vols = [s["volume"] for s in o]
# vols = [s['principal']['RadiusOfGyration'][0] for s in o] # RadiusOfGyration[2] better characterizes the outer(larger) object?
# Maybe it is just outer thread?
mi = vols.index(max(vols))
print ("Largest solid is number %d"%(mi))
if mi>0:
o.insert(0,o.pop(mi))
FreeCAD.Console.PrintMessage("get_info_files() - largest made first");
return info_dict
def findPartsTransformations(solids, objects, candidates, info_dict, insidePrecision = PRECISION_INSIDE, precision = PRECISION):
"""
@param candidates - list (per assembly element) of dictionaries indexed by part name (usually just one) one,
containing list of colors (tuples) for which there is an area match between assembly element and a part.
Build element part frame from colors first, then (if not enough) use inertial directions
"""
progress_bar = Base.ProgressIndicator()
progress_bar.start("Finding transformations for library parts to match assembly elements ...", len(objects))
transformations=[]
for i,s in enumerate(solids):
tolerance = precision * s.BoundBox.DiagonalLength # Or should it be fraction of the translation distance?
tolerance = insidePrecision * s.BoundBox.DiagonalLength # Or should it be fraction of the translation distance?
trans=[]
print ("%d findPartsTransformations:"%(i))
for cand_name in candidates[i]:
co = info_dict[cand_name][0]
matrix_part = ppToMatrix(co['principal'],co['center'])
co = info_dict[cand_name][0] # First solid in the candidate part file
try:
colorCenters = co['colorCenters']
except:
colorCenters = {}
matrix_part = ppToMatrix(co['principal'],co['center'], colorCenters, candidates[i][cand_name], 0, precision)
# Now try 4 orientations (until the first match).
# TODO - process parts with rotational axis (that allows certain, but not any rotation)
matrix_part_inverse = matrix_part.inverse()
# get color properties of a solid
try:
colorCenters = objects[i]['colorCenters']
except:
colorCenters = {}
for orient in range(4):
matrix_assy = ppToMatrix(s.PrincipalProperties,s.CenterOfMass,orient)
matrix_assy = ppToMatrix(s.PrincipalProperties,s.CenterOfMass,colorCenters, candidates[i][cand_name], orient, precision)
matrix_part_assy = matrix_assy.multiply(matrix_part_inverse)
for j, v in enumerate (co['vertices']):
if not s.isInside(matrix_part_assy.multiply(FreeCAD.Vector(v)),tolerance,True):
......@@ -215,7 +419,7 @@ def findPartsTransformations(solids, objects, candidates, info_dict, precision=0
( 1, 0,-1), ( 1, 0, 0), ( 1, 0, 1),
( 1, 1,-1), ( 1, 1, 0), ( 1, 1, 1))
for orient in range(4):
matrix_assy = ppToMatrix(s.PrincipalProperties,s.CenterOfMass,orient)
matrix_assy = ppToMatrix(s.PrincipalProperties,s.CenterOfMass,colorCenters, candidates[i][cand_name], orient, precision)
matrix_part_assy = matrix_assy.multiply(matrix_part_inverse)
for j, v in enumerate (co['vertices']):
if not s.isInside(matrix_part_assy.multiply(FreeCAD.Vector(v)),tolerance,True):
......@@ -232,44 +436,165 @@ def findPartsTransformations(solids, objects, candidates, info_dict, precision=0
else:
trans.append(None) # so Transformations have same structure as candidates
print("*** Could not find match for part %s"%(cand_name))
transformations.append(trans)
return transformations
transformations.append(trans)
progress_bar.next() # True) # True - enable ESC to abort
progress_bar.stop()
return transformations
#components=scan_step_parts.findComponents("/home/andrey/parts/0393/export/nc393_05_flat_noassy.stp")
def findComponents(assembly_path, precision_inside = PRECISION_INSIDE, precision = PRECISION):
def colorMatchCandidate(assy_object, candidates, info_dict, precision = PRECISION_AREA):
"""
@return dictionary partName -> list of colors (as 3-tuples)
"""
colored_candidates={}
if candidates:
assy_color_center_area = assy_object["colorCenters"]
cand_matches=[]
for candidate in candidates:
matched_colors=[]
info_cand= info_dict[candidate][0] # only first solid in a part
# print ("info_cand=",info_cand)
for color in assy_color_center_area:
assy_area= assy_color_center_area[color]["area"]
# print ("color: %s, assy_area = %f"%(str(color), assy_area))
try:
part_area = info_cand ["colorCenters"][color]["area"]
# print ("color: %s, part_area = %f"%(str(color), part_area))
if abs(part_area - assy_area) < precision * assy_area:
matched_colors.append(color)
except:
pass
cand_matches.append(matched_colors)
max_match = max([len(a) for a in cand_matches])
for candidate, colors in zip(candidates,cand_matches):
if len(colors) == max_match:
colored_candidates[candidate]=colors
return colored_candidates
"""
PRECISION_AREA = 0.02
PRECISION_VOLUME = 0.03
PRECISION_INSIDE = 0.03
"""
#components=scan_step_parts.findComponents("/home/andrey/parts/0393/export/nc393_07_flat_noassy.stp")
def findComponents(assembly,
precision_area = PRECISION_AREA,
precision_volume = PRECISION_VOLUME,
precision_gyration = PRECISION_GYRATION,
precision_inside = PRECISION_INSIDE,
precision = PRECISION,
show_best = True):
"""
@param assembly - may be file path (different treatment for Gui/no-Gui, Shape or doc.Objects or None - will use ActiveDocument().Objects
@param precision_area = PRECISION_AREA - relative precision in surface area calculations
@param precision_volume = PRECISION_VOLUME - relative precision in volume calculations
@param precision_gyration = PRECISION_GYRATION - relative precision in radius of gyration calculations
@param precision_inside = PRECISION_INSIDE - relative precision in calculations of point inside/outside of a solid
@param precision = PRECISION - precision in vector calculation
@param show_best - calculate and show the best relative match for each parameter
"""
FreeCAD.Console.PrintMessage("findComponents(): Getting parts database");
global COMPONENTS
start_time=time.time()
print("Getting parts database")
info_dict = get_info_files()
print("Reading assembly file %s @%f"%(assembly_path, time.time()-start_time), end="...")
shape = Part.read(assembly_path)
print(" got %d solids @%f"%(len(shape.Solids), time.time()-start_time))
objects = create_file_info(shape,assembly_path)
print (objects)
FreeCAD.Console.PrintMessage("findComponents(): Got parts database");
aname = ""
if not assembly:
assembly = FreeCAD.activeDocument().Objects
FreeCAD.Console.PrintMessage("Using %d solids in the active document @%f"%(len(assembly), time.time()-start_time));
if isinstance (assembly, (str,unicode)):
assembly_path = assembly
aname,_ = os.path.splitext(os.path.basename(assembly_path))
if not "Gui" in dir(FreeCAD):
print("Reading assembly file %s @%f"%(assembly_path, time.time()-start_time), end="...")
assembly = Part.read(assembly_path)
print(" got %d solids @%f"%(len(assembly.Solids), time.time()-start_time))
else:
FreeCAD.Console.PrintMessage("Using STEP file assembly %s @%f"%(assembly_path, time.time()-start_time));
FreeCAD.loadFile(assembly_path)
doc = FreeCAD.activeDocument()
doc.Label = aname
print(" got %d objects @%f"%(len(doc.Objects), time.time()-start_time))
assembly = doc.Objects
FreeCAD.Console.PrintMessage(" got %d solids @%f"%(len(assembly), time.time()-start_time));
# assuming assembly is doc.Objects
if isinstance(assembly,Part.Shape):
FreeCAD.Console.PrintMessage("Using provided objects @%f"%(len(assembly.Solids), time.time()-start_time));
objects,solids = create_file_info_nogui(shape, aname)
# shape = assembly
else:
objects,solids = create_file_info(assembly, aname)
# print (objects)
progress_bar = Base.ProgressIndicator()
progress_bar.start("Looking for matching parts for each of the assembly element ...", len(objects))
candidates=[]
for i,o in enumerate(objects):
# try:
# FreeCADGui.updateGui()
# except:
# pass
print (i,o)
this_candidates = []
list_errors=[]
rg=o['principal']['RadiusOfGyration']
rgp = precision*math.sqrt(rg[0]**2 + rg[1]**2 + rg[2]**2)
vp = o['volume']*precision
ap = o['area']*precision
rg_av = math.sqrt(rg[0]**2 + rg[1]**2 + rg[2]**2)
rgp = precision_gyration * rg_av
vp = o['volume']*precision_volume
ap = o['area']*precision_area
for n in info_dict:
co = info_dict[n][0]
if ((abs(o['volume'] - co['volume']) < vp) and
(abs(o['area'] - co['area']) < ap) and
(abs(rg[0] - co['principal']['RadiusOfGyration'][0]) < ap) and
(abs(rg[1] - co['principal']['RadiusOfGyration'][1]) < ap) and
(abs(rg[2] - co['principal']['RadiusOfGyration'][2]) < ap)):
errors = (abs(o['volume'] - co['volume']),
abs(o['area'] - co['area']),
abs(rg[0] - co['principal']['RadiusOfGyration'][0]),
abs(rg[1] - co['principal']['RadiusOfGyration'][1]),
abs(rg[2] - co['principal']['RadiusOfGyration'][2]),
)
if show_best:
list_errors.append(errors)
# if ((abs(o['volume'] - co['volume']) < vp) and
# (abs(o['area'] - co['area']) < ap) and
# (abs(rg[0] - co['principal']['RadiusOfGyration'][0]) < rgp) and
# (abs(rg[1] - co['principal']['RadiusOfGyration'][1]) < rgp) and
# (abs(rg[2] - co['principal']['RadiusOfGyration'][2]) < rgp)):
if ((errors[0] < vp) and
(errors[1] < ap) and
(errors[2] < rgp) and
(errors[3] < rgp) and
(errors[4] < rgp)):
this_candidates.append(n)
candidates.append(this_candidates)
solids = shape.Solids
if len(solids) != len(shape.Shells):
print ("Repairing open shells that are not solids for %s"%(assembly_path))
solids = repair_solids_from_shells(shape)
transformations = findPartsTransformations(solids, objects, candidates, info_dict, precision_inside)
if show_best:
weighted_errors = [errors[0]/vp + errors[1]/ap + (errors[2] + errors[3] + errors[4])/rgp for errors in list_errors]
best_index = weighted_errors.index(min(weighted_errors))
errors = list_errors[best_index]
print ("Best match with %s, relative errors: dV=%f, dS=%f, dRG1=%f, dRG2=%f, dRG3=%f"%(
info_dict.keys()[best_index],
errors[0]/o['volume'],
errors[1]/o['area'],
errors[2]/rg_av,
errors[3]/rg_av,
errors[4]/rg_av))
# Filter candidates by number of color areas matched
colored_candidates=colorMatchCandidate(o, this_candidates, info_dict, precision_area)
try:
num_ass_obj_colors = len(o["colorCenters"])
except:
num_ass_obj_colors = 0
print ("%d :colors: %d candidates: %s, colored_candidates: %s"%(i,num_ass_obj_colors, str(this_candidates), str(colored_candidates)))
candidates.append(colored_candidates)
progress_bar.next() # True) # True - enable ESC to abort
progress_bar.stop()
transformations = findPartsTransformations(solids, objects, candidates, info_dict, precision_inside, precision)
#Each part can be in two orientations - check overlap after loading actual parts
return {"shape":shape,"objects":objects,"candidates":candidates,"transformations":transformations}
COMPONENTS = {"solids":solids,"objects":objects,"candidates":candidates,"transformations":transformations}
return COMPONENTS
# return {"solids":solids,"objects":objects,"candidates":candidates,"transformations":transformations}
def getComponents():
return COMPONENTS
def ortho3(v0,v1):
v0.normalize()
dv = FreeCAD.Vector(v0).multiply(v0.dot(v1))
......@@ -281,30 +606,106 @@ def ortho3(v0,v1):
def ppToMatrix(pp, center=(0,0,0), orient=0): #Both Vectors and lists/tuples are OK here
v0 = FreeCAD.Vector(pp["FirstAxisOfInertia"])
v1 = FreeCAD.Vector(pp["SecondAxisOfInertia"])
v2 = FreeCAD.Vector(pp["ThirdAxisOfInertia"])
def ppToMatrix(pp,
center = (0,0,0),
colorCenters = {}, # should have all the colors in a colors list "by design"
colors = [],
orient = 0,
precision = PRECISION):
"""
@param pp - PrincipalProperties
@param center - Center of mass
@param colorCenters - dictionary indexed by colors, having center of color and area of each color (not used here)
@param colors - list of matched colors (tuples)
@param orient - 2-bit modifier for first and second axis of inertia (bit 0 - sign of the first axis, bit 1 - sign of the second)
orient will be overridden if there are some color vectors that define orientation
@param precision - multiplier for the radius of gyration to compare with color vectors
@return 4-matrix
"""
rg= pp['RadiusOfGyration']
eps=math.sqrt(rg[0]**2 + rg[1]**2 + rg[2]**2) * precision
color_vectors = []
t = FreeCAD.Vector(center)
if (orient & 1 ) :
v0.multiply(-1.0)
if (orient & 2 ) :
v1.multiply(-1.0)
#v0,v1,v2 = ortho3(v0,v1)
if v2.dot(v0.cross(v1)) < 0 :
v2.multiply(-1.0)
return FreeCAD.Matrix(v0.x, v1.x, v2.x, t.x,
v0.y, v1.y, v2.y, t.y,
v0.z, v1.z, v2.z, t.z,
0.0, 0.0, 0.0, 1.0)
vectors=[]
for color in colors:
## print ("colorCenters=", colorCenters[color]['center']," area=",colorCenters[color]['area'])
color_vectors.append(FreeCAD.Vector(colorCenters[color]['center']) - t)
print ("color_vectors=",color_vectors)
## print ("color_vectors=",color_vectors, "t=",t)
if color_vectors: # find the longest one
lengths = [v.Length for v in color_vectors]
l = max(lengths)
v = color_vectors.pop(lengths.index(l))
if l > eps:
vectors.append(v.normalize())
def traslateToMatrix(center=(0,0,0)): #Both Vectors and lists/tuples are OK here
t = FreeCAD.Vector(center)
return FreeCAD.Matrix( 1.0, 0.0, 0.0, t.x,
0.0, 1.0, 0.0, t.y,
0.0, 0.0, 1.0, t.z,
0.0, 0.0, 0.0, 1.0)
if vectors and color_vectors: # now find the vector having maximal orthogonal component to v[0]
lengths = [v.cross(vectors[0]).Length for v in color_vectors]
l = max(lengths)
v = color_vectors.pop(lengths.index(l))
if l > eps:
vectors.append(v.normalize())
# print ("vectors=",vectors)
#use gyro axis (or two of them)
if len(vectors) < 3: #insufficient color vectors
vgyro=[FreeCAD.Vector(pp["FirstAxisOfInertia"]),
FreeCAD.Vector(pp["SecondAxisOfInertia"]),
FreeCAD.Vector(pp["ThirdAxisOfInertia"])]
if (orient & 1 ) :
vgyro[0].multiply(-1.0)
if (orient & 2 ) :
vgyro[1].multiply(-1.0)
#v0,v1,v2 = ortho3(v0,v1)
if vgyro[2].dot(vgyro[0].cross(vgyro[1])) < 0 :
vgyro[2].multiply(-1.0)
## print ("vgyro=", vgyro)
if not vectors:
vectors = [vgyro[0], vgyro[1], vgyro[2]]
else: # at least one vector is defined from colors, need one more
new_directions = [False,False,False]
new_length = len(vectors)
if len(vectors) < 2: # == 1, need one more
## print ("vgyro=",vgyro)
for i in range(3): # filter parallel to existing
for v in vectors:
if v.cross(vgyro[i]).Length < eps:
break
else:
new_directions[i] = True
new_length += 1
## print ("new_directions=",new_directions," new_length=",new_length)
if new_length > 2: # extras, filter more (perpendicular to axis of symmetry)
if (new_directions[0] or new_directions[1]) and ((rg[0] - rg[1]) < eps):
if new_directions[1]:
new_directions[1] = False
new_length -= 1
if new_directions[0] and (new_length > 2):
new_directions[0] = False
new_length -= 1
if (new_length > 2) and (new_directions[1] or new_directions[2]) and ((rg[1] - rg[2]) < eps):
if new_directions[1]:
new_directions[1] = False
new_length -= 1
if new_directions[2] and (new_length > 3):
new_directions[2] = False
new_length -= 1
## print ("new_directions=",new_directions," new_length=",new_length)
# All good, add 1,2,3-rd and make ortho-normal
if len(vectors) < 2:
i = new_directions.index(True)
vectors.append((vgyro[i] - vectors[0] * vectors[0].dot(vgyro[i])).normalize())
# here we have 2 vectors, make a third
vectors=[vectors[0],vectors[1], vectors[0].cross(vectors[1]).normalize()]
if vectors[2].dot(vectors[0].cross(vectors[1])) < 0 :
vectors[2].multiply(-1.0)
# print ("Final vectors=",vectors)
return FreeCAD.Matrix(vectors[0].x, vectors[1].x, vectors[2].x, t.x,
vectors[0].y, vectors[1].y, vectors[2].y, t.y,
vectors[0].z, vectors[1].z, vectors[2].z, t.z,
0.0, 0.0, 0.0, 1.0)
def list_parts_offsets():
info_files = get_info_files()
......@@ -312,11 +713,16 @@ def list_parts_offsets():
for j,o in enumerate(info_files[name]):
d = math.sqrt(o["center"][0]**2 + o["center"][1]**2 + o["center"][2]**2)
if j == 0:
print("%3i:"%(i), end="")
print("%4d:"%(i), end="")
else:
print(" ", end="")
print(" ", end="")
print("%s offset = %6.1f"%(name, d))
def list_parts():
info_files = get_info_files()
for i, name in enumerate(info_files):
print ("%4d '%s': %d solids:%s"%(i,name,len(info_files[name]),str(info_files[name])))
# X3D Export
def getShapeNode(vertices, faces, diffuseColor = None, main_color_index = 0, colorPerVertex = True):
......@@ -349,7 +755,7 @@ def getShapeNode(vertices, faces, diffuseColor = None, main_color_index = 0, col
materialNode.set('diffuseColor', "%f %f %f" % tuple(diffuseColor[main_color_index * 3: main_color_index * 3 + 3]))
return shapeNode
def exportX3D(objects, filepath, colorPerVertex):
def exportX3D(objects, filepath, id="part", colorPerVertex=False):
"""Export given list of objects to a X3D file.
Each object is a dictionary in this form:
......@@ -359,17 +765,19 @@ def exportX3D(objects, filepath, colorPerVertex):
color : [R, G, B,...] # number range is 0-1.0, exactly 3 elements for a single color, 3*N for per-vertex colors
}
"""
progress_bar = Base.ProgressIndicator()
progress_bar.start("Saving objects to X3D file %s ..."%(filepath), len(objects))
x3dNode = et.Element('x3d')
x3dNode.set('profile', 'Interchange')
x3dNode.set('version', '3.3')
sceneNode = et.SubElement(x3dNode, 'Scene')
progress_bar = Base.ProgressIndicator()
progress_bar.start("Saving objects to X3D file %s ..."%(filepath), len(objects))
groupNode = et.SubElement(sceneNode, 'Group')
groupNode.set('id', id)
for o in objects:
shapeNode = getShapeNode(o["points"], o["faces"], o["color"], o["main_color_index"], colorPerVertex)
sceneNode.append(shapeNode)
groupNode.append(shapeNode)
progress_bar.next() # True) # True - enable ESC to abort
oneliner= et.tostring(x3dNode)
......@@ -410,7 +818,7 @@ def prepareX3dExport(freecadObjects, fname=""):
mesh = f.tessellate(1)
if (not mesh[0]) or (not mesh[1]):
continue # some objects (such as Part:Circle)
color_index = col_dict[o.ViewObject.DiffuseColor[i]]
color_index = col_dict[o.ViewObject.DiffuseColor[i]] #sometimes len(o.ViewObject.DiffuseColor[i]) ==1, but it will not get here
color_areas [color_index] += f.Area
delta = len(points)
new_indices=[]
......@@ -445,23 +853,27 @@ def prepareX3dExport(freecadObjects, fname=""):
progress_bar.stop()
return objects
def generatePartsX3d(dir_list = DIR_LIST, colorPerVertex = COLOR_PER_VERTEX):
def generatePartsX3d(dir_list = [STEP_PARTS], colorPerVertex = COLOR_PER_VERTEX):
start_time=time.time()
info_dict= get_info_files(dir_list) # Will (re-) build info files if missing
step_list = get_step_list(dir_list) #relative to ROOT_DIR
step_list = get_step_list(dir_list) # now absolute, not relative to ROOT_DIR
if not X3D_DIR in os.listdir(ROOT_DIR):
os.mkdir(os.path.join(ROOT_DIR,X3D_DIR))
numExported=0
for step_file in step_list:
partName,_ = os.path.splitext(os.path.basename(step_file))
x3dFile = os.path.join(ROOT_DIR,X3D_DIR,partName + X3D_EXT)
if not os.path.isfile(x3dFile):
if (not os.path.isfile(x3dFile)) or (os.path.getmtime(step_file) > os.path.getmtime(x3dFile)):
# Prepare data
FreeCAD.loadFile(os.path.join(ROOT_DIR,step_file))
FreeCAD.loadFile(step_file) # os.path.join(ROOT_DIR,step_file))
doc = FreeCAD.activeDocument()
doc.Label = partName
x3d_objects = prepareX3dExport(doc.Objects, step_file) # step_file needed just for progress bar
exportX3D(x3d_objects, x3dFile, colorPerVertex)
exportX3D(x3d_objects, x3dFile, id="part_"+partName, colorPerVertex=colorPerVertex)
FreeCAD.closeDocument(doc.Name)
FreeCADGui.updateGui()
numExported += 1
print("Exported %d files as X3D in @%f seconds, "%(numExported, time.time()-start_time))
def matrix4ToX3D(m, eps=0.000001): #assuming 3x3 matrix is pure rotational
axis=FreeCAD.Vector(m.A32-m.A23, m.A13-m.A31, m.A21-m.A12)
r = axis.Length # math.sqrt(axis.X**2 + axis.Y**2 + axis.Z**2)
......@@ -506,12 +918,35 @@ def matrix4ToX3D(m, eps=0.000001): #assuming 3x3 matrix is pure rotational
def generateAssemblyX3d(assembly_path, components = None, dir_list = DIR_LIST, colorPerVertex = COLOR_PER_VERTEX):
def generateAssemblyX3d(assembly_path,
components = None,
dir_list = [STEP_PARTS],
colorPerVertex = COLOR_PER_VERTEX,
precision_area = PRECISION_AREA,
precision_volume = PRECISION_VOLUME,
precision_gyration = PRECISION_GYRATION,
precision_inside = PRECISION_INSIDE,
precision = PRECISION
):
start_time=time.time()
info_dict = get_info_files(dir_list) # Will (re-) build info files if missing
generatePartsX3d(dir_list = DIR_LIST, colorPerVertex = COLOR_PER_VERTEX) # Will only run if files are not there yet
FreeCAD.Console.PrintMessage("generateAssemblyX3d()");
generatePartsX3d(dir_list = [STEP_PARTS], colorPerVertex = COLOR_PER_VERTEX) # Will only run if files are not there yet
FreeCAD.Console.PrintMessage("generatePartsX3d() Done");
if not components:
components = findComponents(assembly_path, precision_inside = PRECISION_INSIDE, precision = PRECISION)
assName,_ = os.path.splitext(os.path.basename(assembly_path))
components = COMPONENTS # try to use global ones
if not components: # COMPONETS do not exist either - rebuild them
components = findComponents(assembly_path, # None is OK here
precision_area = precision_area,
precision_volume = precision_volume,
precision_gyration = precision_gyration,
precision_inside = precision_inside,
precision = precision,
show_best = False)
if assembly_path:
assName,_ = os.path.splitext(os.path.basename(assembly_path))
else:
assName= FreeCAD.activeDocument().Objects[0].Label
x3dFile = os.path.join(ROOT_DIR,X3D_DIR,assName + X3D_EXT) # currently in the same directory as parts
x3dNode = et.Element('x3d')
x3dNode.set('profile', 'Interchange')
......@@ -538,7 +973,8 @@ def generateAssemblyX3d(assembly_path, components = None, dir_list = DIR_LIST, c
else:
print("Component %d does not have any matches, ignoring. Candidates: %s"%(i,str(parts)))
continue
bbox=components['shape'].Shells[i].BoundBox
# bbox=components['shape'].Shells[i].BoundBox
bbox=components['solids'][i].BoundBox
bboxCenter=((bbox.XMax + bbox.XMin)/2,(bbox.YMax + bbox.YMin)/2,(bbox.ZMax + bbox.ZMin)/2)
bboxSize= ( bbox.XMax - bbox.XMin, bbox.YMax - bbox.YMin, bbox.ZMax - bbox.ZMin)
......@@ -580,12 +1016,241 @@ def generateAssemblyX3d(assembly_path, components = None, dir_list = DIR_LIST, c
print ("Writing assembly to %s"%(x3dFile))
with open(x3dFile, "wr") as f:
f.write(reparsed.toprettyxml(indent=" "))
print("Assembly %s exported as X3D file in @%f seconds, "%(x3dFile, time.time()-start_time))
FreeCAD.Console.PrintMessage("Assembly %s exported as X3D file in @%f seconds, "%(x3dFile, time.time()-start_time));
return components
def showFailedComponents(components = COMPONENTS):
if components is None:
components = COMPONENTS
FreeCADGui.SendMsgToActiveView("ViewFit")
doc=FreeCAD.activeDocument()
for o in doc.Objects:
o.ViewObject.Visibility = False # turn off normal objects
print (components)
print (COMPONENTS)
for i, s in enumerate(components['solids']):
if not components['transformations'][i]:
doc.addObject("Part::Feature","missing_%d"%i).Shape = s
def run():
# form = X3dStepAssyDialog() # FreeCADGui.getMainWindow())
# form.show()
get_info_files()
#X3dStepAssyDialog
########################################################################
class X3dStepAssyDialog(QtGui.QWidget):
""""""
assembly_path = None
x3d_root_path = None
step_parts_path = None
log_file = None
#----------------------------------------------------------------------
def get_path_text(self, path, mode = None):
if path:
return path
if mode == "assy":
return "Active document"
elif mode == "log":
return "stdout"
else:
return "not set"
def __init__(self, assembly_path=None, x3d_root_path=None, step_parts_path = None):
self.assembly_path = assembly_path
self.x3d_root_path = x3d_root_path
self.step_parts_path = step_parts_path
"""Constructor"""
QtGui.QWidget.__init__(self) # ,parent=parent)
self.label = QtGui.QLabel("Python rules!")
# create the buttons
label_log_file = QtGui.QLabel("Log file")
self.log_file_btn = QtGui.QPushButton(self.get_path_text(self.log_file, "log"))
self.log_file_btn.setToolTip("Select log file for 'print' operators. Will be reset when starting macro execution")
label_assembly = QtGui.QLabel("Assembly to process")
self.assembly_btn = QtGui.QPushButton(self.get_path_text(self.assembly_path, "assy"))
self.assembly_btn.setToolTip("Select assembly STEP model, if none is selected the active FreeCAD document will be used")
label_x3d_root_btn = QtGui.QLabel("Working directory")
self.x3d_root_btn = QtGui.QPushButton(self.get_path_text(self.x3d_root_path))
self.x3d_root_btn.setToolTip("'info' and 'x3d' directories will be created/updated under the selected directory")
label_step_parts = QtGui.QLabel("Step parts directory")
self.step_parts_btn = QtGui.QPushButton(self.get_path_text(self.step_parts_path))
self.step_parts_btn.setToolTip("Select directory containing all the parts STEP models. Will scan sub-directories")
self.help_btn = QtGui.QPushButton("?")
self.execute_btn = QtGui.QPushButton("Execute macro (may take hours!)")
self.log_file_btn.clicked.connect(self.selectLogFile)
self.assembly_btn.clicked.connect(self.selectAssembly)
self.x3d_root_btn.clicked.connect(self.selectX3dRoot)
self.step_parts_btn.clicked.connect(self.selectStepParts)
self.help_btn.clicked.connect(self.showHelp)
self.execute_btn.clicked.connect(self.executeMacro)
# layout widgets
layout = QtGui.QGridLayout() # parent=parent)
layout.setColumnStretch(1,1)
layout.addWidget(label_assembly, 0, 0)
layout.addWidget(self.assembly_btn, 0, 1)
layout.addWidget(label_x3d_root_btn, 1, 0)
layout.addWidget(self.x3d_root_btn, 1, 1)
layout.addWidget(label_step_parts, 2, 0)
layout.addWidget(self.step_parts_btn, 2, 1)
layout.addWidget(label_log_file, 3, 0)
layout.addWidget(self.log_file_btn, 3, 1)
layout.addWidget(self.help_btn, 4, 0)
layout.addWidget(self.execute_btn, 4, 1)
self.setLayout(layout)
# set the position and size of the window
self.setGeometry(100, 100, 300, 100)
self.setWindowTitle("STEP assembly to X3D converter")
#----------------------------------------------------------------------
def selectLogFile(self):
prompt_file = self.log_file
if not prompt_file:
prompt_file = self.x3d_root_path
self.log_file,_ = QtGui.QFileDialog.getSaveFileName (self,
"Select log file (or cancel to use stdout)",
prompt_file)
self.log_file_btn.setText(self.get_path_text(self.log_file, "log"))
def selectAssembly(self):
self.assembly_path, _ = QtGui.QFileDialog.getOpenFileName(self,
"Select assembly file (cancel to use loaded to FreeCAD)",
self.assembly_path,
"STEP files (*.step *.stp *.STEP *.STP)")
self.assembly_btn.setText(self.get_path_text(self.assembly_path, True))
def selectX3dRoot(self):
self.x3d_root_path = QtGui.QFileDialog.getExistingDirectory(self,
"Select working directory for STEP->x3d conversion",
self.x3d_root_path)
self.x3d_root_btn.setText(self.get_path_text(self.x3d_root_path))
def selectStepParts(self):
self.step_parts_path = QtGui.QFileDialog.getExistingDirectory(self,
"Select working directory for STEP->x3d conversion",
self.step_parts_path)
self.step_parts_btn.setText(self.get_path_text(self.step_parts_path))
def showHelp(self):
msg = ("This macro converts assembly CAD model to X3D. It tries to recognize "
"individual parts (provided as STEP files) in the assembly model, converts "
"each part to X3D and then generates assembly X3D file that includes inline "
"references to the recognized part files, applying appropriate transformations "
"(rotations and translations).\n\n"
"First thing the program does is it scans all the STEP models under the "
"specified directory and collects general properties of each file, including "
"volume, surface area, center of mass, gyration radii and axes, as well as "
"per-color centers. Normally each part should contain just one solid, but if "
"there are more than one only the largest (by volume) will be used for "
"identification in the assembly (in that case assembly may show multiple not "
"matched solids that will still be correctly rendered in the final model with "
"each part).\n\n"
"This information will be saved in 'info' directory under the specified working "
"directory, same file name as the original STEP model but with extension "
"'.pickle' (and yes, they are just Python pickle files). These files are "
"saved in one directory, so each original part file have to have unique name, "
"even when stored in different directories. This file basename (last segment "
"of the OS path without the extension) will be used as a part name and used "
"in 'id' and 'class' properties of the result x3d files. The program only "
"processes the part files if the corresponding info file does not exist or "
"has the modification timestamp earlier than the STEP model.\n\n"
"During the next step the assembly object is analyzed and the same properties "
"are extracted for each solid, then the each is compared to the library part "
"and the parts with the same values (to the specified precision) are selected "
"as potential candidates. Parts material is not used, so distinguish between "
"similar screws that have the same geometry the color may be used.\n\n"
"This allows to find the position of the center of volume of the part in the "
"assembly, but getting the correct orientation is trickier. For the asymmetrical "
"(having all 3 different radii of gyration) it is rather easy (only 4 variants "
"to check as the gyration axes can have opposite direction), it also works for "
"the parts with full cylindrical or spherical symmetry where the axes match is not "
"required, but it is more difficult to deal with the discrete rotational symmetry. "
"When resolving such cases the program relies on colored faces of the parts. "
"Coloring just a single hole (not on the axis of the symmetry) in the part "
"(and then using it in the assembly) breaks ambiguity. Parts that do not have "
"faces that can be easily colored can be modified with boolean operations that "
"preserves the shape but add color asymmetry\n\n"
"When the solids are matched, the program generates missing/old (by timestamp) "
"x3d files of the individual parts and assembly in the 'x3d' subdirectory of the "
"working directory. It also generates and shows the parts that are not recognized "
"(they might be 'other' solids of the part files and so will be available in the "
" generated model).\n\n"
"This method can work with most modern CAD systems, and does not require special "
"export - the colored STEP files are still good for production. In some systems "
"the assembly model should be flattened (removed assembly status) before STEP "
"export, it is also advised to import individual parts that are provided to you "
"as STEP models to the CAD that is used for the assembly and re-exporting to STEP "
"so both part and assembly STEP files will be generated by the same software.\n\n"
)
msgBox = QtGui.QMessageBox(QtGui.QMessageBox.Question, "About STEP->X3D Assembly converter", msg)
msgBox.exec_()
# msgBox = QtGui.QMessageBox.about(self,"About STEP->X3D Assembly converter",msg )
# msgBox.setText("The document has been modified.")
# msgBox.exec_()
def executeMacro(self):
global ROOT_DIR, ASSEMBLY_PATH, STEP_PARTS, COMPONENTS
COMPONENTS = None # Start with new ones
# print ("ROOT_DIR=%s"%(ROOT_DIR))
# msgBox = QtGui.QMessageBox(QtGui.QMessageBox.Question, "About STEP->X3D Assembly converter", "ROOT_DIR=%s"%(ROOT_DIR))
# msgBox.exec_()
FreeCAD.Console.PrintMessage("Starting execution...");
ASSEMBLY_PATH = self.assembly_path
ROOT_DIR = self.x3d_root_path
STEP_PARTS = self.step_parts_path
if self.log_file:
sys.stdout = open(self.log_file,"w")
else:
sys.stdout = sys.__stdout__
try: # does not work
components=generateAssemblyX3d(self.assembly_path) # If None - will use ActiveDocument().Objects
except:
self.errorDialog(traceback.format_exc())
showFailedComponents(components)
sys.stdout.close()
sys.stdout = sys.__stdout__
def errorDialog(msg):
# Create a simple dialog QMessageBox
# The first argument indicates the icon used: one of QtGui.QMessageBox.{NoIcon, Information, Warning, Critical, Question}
diag = QtGui.QMessageBox(QtGui.QMessageBox.Error, 'Error in macro', msg)
diag.setWindowModality(QtCore.Qt.ApplicationModal)
diag.exec_()
#----------------------------------------------------------------------
if __name__ == "__main__":
run()
form = X3dStepAssyDialog(assembly_path= ASSEMBLY_PATH, x3d_root_path = ROOT_DIR, step_parts_path = STEP_PARTS) # FreeCADGui.getMainWindow())
form.show()
# run()
# def __init__(self, assembly_path=None, x3d_root_path=None, step_parts_path = None):
"""
reload (x3d_step_assy)
form = x3d_step_assy.X3dStepAssyDialog(assembly_path= x3d_step_assy.ASSEMBLY_PATH, x3d_root_path = x3d_step_assy.ROOT_DIR, step_parts_path = x3d_step_assy.STEP_PARTS)
form.show()
components= x3d_step_assy.generateAssemblyX3d(x3d_step_assy.ASSEMBLY_PATH)
>>> Gui.getDocument("Unnamed").getObject("Part__Feature").Visibility=False
"""
\ No newline at end of file
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment