From d671ea43a2fe6f112b03f42cae21edaa2120ba9f Mon Sep 17 00:00:00 2001 From: Randy Frank Date: Mon, 21 Oct 2024 17:56:45 -0400 Subject: [PATCH] Add support for DSG lines in OV export If lines are found in the DSG protobuffers, these are mapped to USD curves. No texture map support at the moment. CLI option and env var can be used to set the line thickness. --- src/ansys/pyensight/core/utils/dsg_server.py | 163 +++++++++++++----- .../pyensight/core/utils/omniverse_cli.py | 34 +++- .../core/utils/omniverse_dsg_server.py | 145 +++++++++++++++- 3 files changed, 297 insertions(+), 45 deletions(-) diff --git a/src/ansys/pyensight/core/utils/dsg_server.py b/src/ansys/pyensight/core/utils/dsg_server.py index 81110b00177..a181992a924 100644 --- a/src/ansys/pyensight/core/utils/dsg_server.py +++ b/src/ansys/pyensight/core/utils/dsg_server.py @@ -142,7 +142,7 @@ def nodal_surface_rep(self): self.session.log(f"Note: part '{self.cmd.name}' contains no triangles.") return None, None, None, None, None, None verts = self.coords - self.normalize_verts(verts) + _ = self._normalize_verts(verts) conn = self.conn_tris normals = self.normals @@ -151,7 +151,7 @@ def nodal_surface_rep(self): tcoords = self.tcoords if self.tcoords_elem or self.normals_elem: verts_per_prim = 3 - num_prims = int(conn.size / verts_per_prim) + num_prims = conn.size // verts_per_prim # "flatten" the triangles to move values from elements to nodes new_verts = numpy.ndarray((num_prims * verts_per_prim * 3,), dtype="float32") new_conn = numpy.ndarray((num_prims * verts_per_prim,), dtype="int32") @@ -205,47 +205,16 @@ def nodal_surface_rep(self): var_cmd = None # texture coords need transformation from variable value to [ST] if tcoords is not None: - var_dsg_id = self.cmd.color_variableid - var_cmd = self.session.variables[var_dsg_id] - v_min = None - v_max = None - for lvl in var_cmd.levels: - if (v_min is None) or (v_min > lvl.value): - v_min = lvl.value - if (v_max is None) or (v_max < lvl.value): - v_max = lvl.value - var_minmax = [v_min, v_max] - # build a power of two x 1 texture - num_texels = int(len(var_cmd.texture) / 4) - half_texel = 1 / (num_texels * 2.0) - num_verts = int(verts.size / 3) - tmp = numpy.ndarray((num_verts * 2,), dtype="float32") - tmp.fill(0.5) # fill in the T coordinate... - tex_width = half_texel * 2 * (num_texels - 1) # center to center of num_texels - # if the range is 0, adjust the min by -1. The result is that the texture - # coords will get mapped to S=1.0 which is what EnSight does in this situation - if (var_minmax[1] - var_minmax[0]) == 0.0: - var_minmax[0] = var_minmax[0] - 1.0 - var_width = var_minmax[1] - var_minmax[0] - for idx in range(num_verts): - # normalized S coord value (clamp) - s = (tcoords[idx] - var_minmax[0]) / var_width - if s < 0.0: - s = 0.0 - if s > 1.0: - s = 1.0 - # map to the texture range and set the S value - tmp[idx * 2] = s * tex_width + half_texel - tcoords = tmp + tcoords, var_cmd = self._build_st_coords(tcoords, verts.size // 3) self.session.log( - f"Part '{self.cmd.name}' defined: {self.coords.size/3} verts, {self.conn_tris.size/3} tris." + f"Part '{self.cmd.name}' defined: {self.coords.size // 3} verts, {self.conn_tris.size // 3} tris." ) command = self.cmd return command, verts, conn, normals, tcoords, var_cmd - def normalize_verts(self, verts: numpy.ndarray): + def _normalize_verts(self, verts: numpy.ndarray): """ This function scales and translates vertices, so the longest axis in the scene is of length 1.0, and data is centered at the origin @@ -254,7 +223,7 @@ def normalize_verts(self, verts: numpy.ndarray): """ s = 1.0 if self.session.normalize_geometry and self.session.scene_bounds is not None: - num_verts = int(verts.size / 3) + num_verts = verts.size // 3 midx = (self.session.scene_bounds[3] + self.session.scene_bounds[0]) * 0.5 midy = (self.session.scene_bounds[4] + self.session.scene_bounds[1]) * 0.5 midz = (self.session.scene_bounds[5] + self.session.scene_bounds[2]) * 0.5 @@ -275,6 +244,118 @@ def normalize_verts(self, verts: numpy.ndarray): verts[j + 2] = (verts[j + 2] - midz) / s return 1.0 / s + def _build_st_coords(self, tcoords: numpy.ndarray, num_verts: int): + """ + The Omniverse interface uses 2D texturing (s,t) to reference the texture map. + This method converts DSG texture coordinates (1D and in "variable" units) into + 2D OpenGL style [0.,1.] normalized coordinate space. the "t" coordinate will + always be 0.5. + + Parameters + ---------- + tcoords: numpy.ndarray + The DSG 1D texture coordinates, which are actually variable values. + + num_verts: int + The number of vertices in the mesh. + + Returns + ------- + numpy.ndarray, Any + The ST OpenGL GL texture coordinate array and the variable definition DSG command. + """ + var_dsg_id = self.cmd.color_variableid # type: ignore + var_cmd = self.session.variables[var_dsg_id] + v_min = None + v_max = None + for lvl in var_cmd.levels: + if (v_min is None) or (v_min > lvl.value): + v_min = lvl.value + if (v_max is None) or (v_max < lvl.value): + v_max = lvl.value + var_minmax: List[float] = [v_min, v_max] # type: ignore + # build a power of two x 1 texture + num_texels = len(var_cmd.texture) // 4 + half_texel = 1 / (num_texels * 2.0) + tmp = numpy.ndarray((num_verts * 2,), dtype="float32") + tmp.fill(0.5) # fill in the T coordinate... + tex_width = half_texel * 2 * (num_texels - 1) # center to center of num_texels + # if the range is 0, adjust the min by -1. The result is that the texture + # coords will get mapped to S=1.0 which is what EnSight does in this situation + if (var_minmax[1] - var_minmax[0]) == 0.0: + var_minmax[0] = var_minmax[0] - 1.0 + var_width = var_minmax[1] - var_minmax[0] + for idx in range(num_verts): + # normalized S coord value (clamp) + s = (tcoords[idx] - var_minmax[0]) / var_width + if s < 0.0: + s = 0.0 + if s > 1.0: + s = 1.0 + # map to the texture range and set the S value + tmp[idx * 2] = s * tex_width + half_texel + return tmp, var_cmd + + def line_rep(self): + """ + This function processes the geometry arrays and returns values to represent line data. + The vertex array embeds the connectivity, so every two points represent a line segment. + The tcoords similarly follow the vertex array notion. + + Returns + ------- + On failure, the method returns None for the first return value. The returned tuple is: + + (part_command, vertices, connectivity, tex_coords, var_command) + + part_command: UPDATE_PART command object + vertices: numpy array of per-node coordinates (two per line segment) + tcoords: numpy array of per vertex texture coordinates (optional) + var_command: UPDATE_VARIABLE command object for the variable the colors correspond to, if any + """ + if self.cmd is None: + return None, None, None, None + if self.cmd.render != self.cmd.CONNECTIVITY: + # Early out. Rendering type for this object is a surface rep, not a point rep + return None, None, None, None + + num_lines = self.conn_lines.size // 2 + if num_lines == 0: + return None, None, None, None + verts = numpy.ndarray((num_lines * 2 * 3,), dtype="float32") + tcoords = None + if self.tcoords.size: + tcoords = numpy.ndarray((num_lines * 2,), dtype="float32") + # TODO: handle elemental line values (self.tcoords_elem) by converting to nodal... + # if self.tcoords_elem: + for i in range(num_lines): + i0 = self.conn_lines[i * 2] + i1 = self.conn_lines[i * 2 + 1] + offset = i * 6 + verts[offset + 0] = self.coords[i0 * 3 + 0] + verts[offset + 1] = self.coords[i0 * 3 + 1] + verts[offset + 2] = self.coords[i0 * 3 + 2] + verts[offset + 3] = self.coords[i1 * 3 + 0] + verts[offset + 4] = self.coords[i1 * 3 + 1] + verts[offset + 5] = self.coords[i1 * 3 + 2] + if tcoords is not None: + # tcoords are 1D at this point + offset = i * 2 + tcoords[offset + 0] = self.tcoords[i0] + tcoords[offset + 1] = self.tcoords[i1] + + _ = self._normalize_verts(verts) + + var_cmd = None + # texture coords need transformation from variable value to [ST] + if tcoords is not None: + tcoords, var_cmd = self._build_st_coords(tcoords, verts.size // 3) + + self.session.log(f"Part '{self.cmd.name}' defined: {num_lines} lines.") + command = self.cmd + + return command, verts, tcoords, var_cmd + def point_rep(self): """ This function processes the geometry arrays and returns values to represent point data @@ -297,8 +378,8 @@ def point_rep(self): # Early out. Rendering type for this object is a surface rep, not a point rep return None, None, None, None, None verts = self.coords - num_verts = int(verts.size / 3) - norm_scale = self.normalize_verts(verts) + num_verts = verts.size // 3 + norm_scale = self._normalize_verts(verts) # Convert var values in self.tcoords to RGB colors # For now, look up RGB colors. Planned USD enhancements should allow tex coords instead. @@ -359,7 +440,7 @@ def point_rep(self): colors[idx * 3 + ii] = ( col0[ii] * pal_sub + col1[ii] * (1.0 - pal_sub) ) / 255.0 - self.session.log(f"Part '{self.cmd.name}' defined: {self.coords.size/3} points.") + self.session.log(f"Part '{self.cmd.name}' defined: {self.coords.size // 3} points.") node_sizes = None if self.node_sizes.size and self.node_sizes.size == num_verts: @@ -375,7 +456,7 @@ def point_rep(self): for ii in range(0, num_verts): node_sizes[ii] = node_size_default - self.session.log(f"Part '{self.cmd.name}' defined: {self.coords.size/3} points.") + self.session.log(f"Part '{self.cmd.name}' defined: {self.coords.size // 3} points.") command = self.cmd return command, verts, node_sizes, colors, var_cmd diff --git a/src/ansys/pyensight/core/utils/omniverse_cli.py b/src/ansys/pyensight/core/utils/omniverse_cli.py index 1e8056e0658..9d7f3a8481e 100644 --- a/src/ansys/pyensight/core/utils/omniverse_cli.py +++ b/src/ansys/pyensight/core/utils/omniverse_cli.py @@ -78,6 +78,8 @@ def __init__( normalize_geometry: bool = False, dsg_uri: str = "", monitor_directory: str = "", + line_width: float = -0.0001, + use_lines: bool = False, ) -> None: self._dsg_uri = dsg_uri self._destination = destination @@ -93,6 +95,8 @@ def __init__( self._server_process = None self._status_filename: str = "" self._monitor_directory: str = monitor_directory + self._line_width = line_width + self._use_lines = use_lines @property def monitor_directory(self) -> Optional[str]: @@ -184,7 +188,9 @@ def run_server(self, one_shot: bool = False) -> None: """ # Build the Omniverse connection - omni_link = ov_dsg_server.OmniverseWrapper(destination=self._destination) + omni_link = ov_dsg_server.OmniverseWrapper( + destination=self._destination, line_width=self._line_width, use_lines=self._use_lines + ) logging.info("Omniverse connection established.") # parse the DSG USI @@ -269,7 +275,9 @@ def run_monitor(self): return # Build the Omniverse connection - omni_link = ov_dsg_server.OmniverseWrapper(destination=self._destination) + omni_link = ov_dsg_server.OmniverseWrapper( + destination=self._destination, line_width=self._line_width, use_lines=self._use_lines + ) logging.info("Omniverse connection established.") # use an OmniverseUpdateHandler @@ -449,6 +457,20 @@ def run_monitor(self): type=str2bool_type, help="Convert a single geometry into USD and exit. Default: false", ) + line_default: Any = os.environ.get("ANSYS_OV_LINE_WIDTH", None) + if line_default is not None: + try: + line_default = float(line_default) + except ValueError: + line_default = None + # Potential future default: -0.0001 + parser.add_argument( + "--line_width", + metavar="line_width", + default=line_default, + type=float, + help=f"Width of lines: >0=absolute size. <0=fraction of diagonal. 0=wireframe. Default: {line_default}", + ) # parse the command line args = parser.parse_args() @@ -469,6 +491,12 @@ def run_monitor(self): logging.root.removeHandler(logging.root.handlers[0]) logging.basicConfig(**log_args) # type: ignore + # size of lines in data units or fraction of bounding box diagonal + use_lines = args.line_width is not None + line_width = -0.0001 + if args.line_width is not None: + line_width = args.line_width + # Build the server object server = OmniverseGeometryServer( destination=args.destination, @@ -479,6 +507,8 @@ def run_monitor(self): normalize_geometry=args.normalize_geometry, vrmode=not args.include_camera, temporal=args.temporal, + line_width=line_width, + use_lines=use_lines, ) # run the server diff --git a/src/ansys/pyensight/core/utils/omniverse_dsg_server.py b/src/ansys/pyensight/core/utils/omniverse_dsg_server.py index 5786d8e662c..fb4605f6661 100644 --- a/src/ansys/pyensight/core/utils/omniverse_dsg_server.py +++ b/src/ansys/pyensight/core/utils/omniverse_dsg_server.py @@ -32,12 +32,19 @@ from typing import Any, Dict, List, Optional from ansys.pyensight.core.utils.dsg_server import Part, UpdateHandler +import numpy import png from pxr import Gf, Sdf, Usd, UsdGeom, UsdLux, UsdShade class OmniverseWrapper(object): - def __init__(self, live_edit: bool = False, destination: str = "") -> None: + def __init__( + self, + live_edit: bool = False, + destination: str = "", + line_width: float = -0.0001, + use_lines: bool = False, + ) -> None: self._cleaned_index = 0 self._cleaned_names: dict = {} self._connectionStatusSubscription = None @@ -54,6 +61,9 @@ def __init__(self, live_edit: bool = False, destination: str = "") -> None: if destination: self.destination = destination + self._line_width = line_width + self._use_lines = use_lines + @property def destination(self) -> str: """The current output directory.""" @@ -65,6 +75,18 @@ def destination(self, directory: str) -> None: if not self.is_valid_destination(directory): logging.warning(f"Invalid destination path: {directory}") + @property + def line_width(self) -> float: + return self._line_width + + @line_width.setter + def line_width(self, line_width: float) -> None: + self._line_width = line_width + + @property + def use_lines(self) -> bool: + return self._use_lines + def shutdown(self) -> None: """ Shutdown the connection to Omniverse cleanly. @@ -274,7 +296,7 @@ def create_dsg_mesh_block( mesh.CreateDoubleSidedAttr().Set(True) mesh.CreatePointsAttr(verts) mesh.CreateNormalsAttr(normals) - mesh.CreateFaceVertexCountsAttr([3] * int(conn.size / 3)) + mesh.CreateFaceVertexCountsAttr([3] * (conn.size // 3)) mesh.CreateFaceVertexIndicesAttr(conn) if (tcoords is not None) and variable: # USD 22.08 changed the primvar API @@ -334,6 +356,100 @@ def add_timestep_group( visibility_attr.Set("invisible", timeline[1] * self._time_codes_per_second) return timestep_prim + def create_dsg_lines( + self, + name, + id, + part_hash, + parent_prim, + verts, + tcoords, + matrix=[1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0], + diffuse=[1.0, 1.0, 1.0, 1.0], + variable=None, + timeline=[0.0, 0.0], + first_timestep=False, + ): + # 1D texture map for variables https://graphics.pixar.com/usd/release/tut_simple_shading.html + # create the part usd object + partname = self.clean_name(name + part_hash.hexdigest()) + stage_name = "/Parts/" + partname + ".usd" + part_stage_url = self.stage_url(os.path.join("Parts", partname + ".usd")) + part_stage = None + + # TODO: GLB extension maps to DSG PART attribute map + width = self.line_width + wireframe = width == 0.0 + if width < 0.0: + tmp = verts.reshape(-1, 3) + mins = numpy.min(tmp, axis=0) + maxs = numpy.max(tmp, axis=0) + dx = maxs[0] - mins[0] + dy = maxs[1] - mins[1] + dz = maxs[2] - mins[2] + diagonal = math.sqrt(dx * dx + dy * dy + dz * dz) + width = diagonal * math.fabs(width) + self.line_width = width + + # For the present, only line colors are supported, no texturing + # var_cmd = variable + var_cmd = None + + if not os.path.exists(part_stage_url): + part_stage = Usd.Stage.CreateNew(part_stage_url) + self._old_stages.append(part_stage_url) + xform = UsdGeom.Xform.Define(part_stage, "/" + partname) + lines = UsdGeom.BasisCurves.Define(part_stage, "/" + partname + "/Lines") + lines.CreateDoubleSidedAttr().Set(True) + lines.CreatePointsAttr(verts) + lines.CreateCurveVertexCountsAttr([2] * (verts.size // 6)) + lines.CreatePurposeAttr().Set("render") + lines.CreateTypeAttr().Set("linear") + lines.CreateWidthsAttr([width]) + lines.SetWidthsInterpolation("constant") + prim = lines.GetPrim() + prim.CreateAttribute( + "omni:scene:visualization:drawWireframe", Sdf.ValueTypeNames.Bool + ).Set(wireframe) + if (tcoords is not None) and var_cmd: + # USD 22.08 changed the primvar API + if hasattr(lines, "CreatePrimvar"): + texCoords = lines.CreatePrimvar( + "st", Sdf.ValueTypeNames.TexCoord2fArray, UsdGeom.Tokens.varying + ) + else: + primvarsAPI = UsdGeom.PrimvarsAPI(lines) + texCoords = primvarsAPI.CreatePrimvar( + "st", Sdf.ValueTypeNames.TexCoord2fArray, UsdGeom.Tokens.varying + ) + texCoords.Set(tcoords) + texCoords.SetInterpolation("vertex") + part_prim = part_stage.GetPrimAtPath("/" + partname) + part_stage.SetDefaultPrim(part_prim) + + # Currently, this will never happen, but it is a setup for rigid body transforms + # At present, the group transforms have been cooked into the vertices so this is not needed + matrixOp = xform.AddXformOp( + UsdGeom.XformOp.TypeTransform, UsdGeom.XformOp.PrecisionDouble + ) + matrixOp.Set(Gf.Matrix4d(*matrix).GetTranspose()) + + self.create_dsg_material( + part_stage, lines, "/" + partname, diffuse=diffuse, variable=var_cmd + ) + + timestep_prim = self.add_timestep_group(parent_prim, timeline, first_timestep) + + # glue it into our stage + path = timestep_prim.GetPath().AppendChild("part_ref_" + partname) + part_ref = self._stage.OverridePrim(path) + part_ref.GetReferences().AddReference("." + stage_name) + + if part_stage is not None: + part_stage.GetRootLayer().Save() + + return part_stage_url + def create_dsg_points( self, name, @@ -645,6 +761,31 @@ def finalize_part(self, part: Part) -> None: timeline=self.session.cur_timeline, first_timestep=(self.session.cur_timeline[0] == self.session.time_limits[0]), ) + if self._omni.use_lines: + command, verts, tcoords, var_cmd = part.line_rep() + if command is not None: + line_color = [ + part.cmd.line_color[0] * part.cmd.diffuse, + part.cmd.line_color[1] * part.cmd.diffuse, + part.cmd.line_color[2] * part.cmd.diffuse, + part.cmd.line_color[3], + ] + # Generate the lines + _ = self._omni.create_dsg_lines( + name, + obj_id, + part.hash, + parent_prim, + verts, + tcoords, + matrix=matrix, + diffuse=line_color, + variable=var_cmd, + timeline=self.session.cur_timeline, + first_timestep=( + self.session.cur_timeline[0] == self.session.time_limits[0] + ), + ) elif part.cmd.render == part.cmd.NODES: command, verts, sizes, colors, var_cmd = part.point_rep()