Skip to content

Commit f3952ac

Browse files
authored
feat: add support for splines (#11)
* feat(wip): add support for splines * fix: properly load the splines & add color * feat: update visualization example * tests: add tests for splines * fix: weird merge conflicts * fix: consistent naming * docs: update readme
1 parent b6ae386 commit f3952ac

9 files changed

Lines changed: 456 additions & 34 deletions

File tree

README.md

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -19,17 +19,10 @@ HPS is a compressed 3D mesh format commonly used in dental scanning applications
1919
- Read mesh geometry (vertices and faces) from HPS files.
2020
- Supports CA, CC, and CE schemas, including encrypted files.
2121
- Extract mesh colors, texture coordinates (UVs) and texture images.
22+
- Extract splines and curves in the scan data.
2223
- Export the mesh to OBJ, PLY and STL.
2324
- Command-line tool for exporting meshes.
2425

25-
<details>
26-
<summary>Planned Features</summary>
27-
28-
- Extract splines and curves in the scan data.
29-
30-
</details>
31-
32-
3326
## Getting Started
3427

3528
### Installation

examples/view_hps.py

Lines changed: 88 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,13 @@
33
import pathlib
44

55
import numpy as np
6+
import numpy.typing as npt
67
import trimesh
78
import trimesh.visual
89
from PIL import Image
910

1011
from hpsdecode import load_hps
12+
from hpsdecode.mesh import Spline
1113

1214

1315
def convert_bgr_to_rgb(image_data: bytes) -> Image.Image:
@@ -24,6 +26,75 @@ def convert_bgr_to_rgb(image_data: bytes) -> Image.Image:
2426
return Image.merge("RGB", (b, g, r))
2527

2628

29+
def create_cylinder_mesh(
30+
start_point: npt.NDArray[np.floating],
31+
end_point: npt.NDArray[np.floating],
32+
radius: float,
33+
slices: int = 32,
34+
) -> trimesh.Trimesh:
35+
"""Create a cylinder mesh between two points.
36+
37+
:param start_point: The starting point of the cylinder.
38+
:param end_point: The ending point of the cylinder.
39+
:param radius: The radius of the cylinder.
40+
:param slices: The number of slices around the cylinder circumference.
41+
:return: A Trimesh representing the cylinder.
42+
"""
43+
direction = end_point - start_point
44+
height = np.linalg.norm(direction)
45+
if height < 1e-6:
46+
return trimesh.Trimesh()
47+
48+
cylinder = trimesh.creation.cylinder(radius=radius, height=height, sections=slices)
49+
50+
z_axis = np.array([0.0, 0.0, 1.0])
51+
direction_normalized = direction / height
52+
53+
rotation_matrix = trimesh.geometry.align_vectors(z_axis, direction_normalized)
54+
cylinder.apply_transform(rotation_matrix)
55+
56+
center = (start_point + end_point) / 2.0
57+
cylinder.apply_translation(center)
58+
59+
return cylinder
60+
61+
62+
def create_spline_mesh(spline: Spline, slices: int = 32) -> trimesh.Trimesh:
63+
"""Create a mesh representation of a spline as connected cylinders.
64+
65+
:param spline: The spline to convert to a mesh.
66+
:param slices: The number of slices around each cylinder circumference.
67+
:return: A trimesh representing the spline.
68+
"""
69+
if spline.num_control_points < 2:
70+
return trimesh.Trimesh()
71+
72+
meshes = []
73+
control_points = spline.control_points
74+
75+
for i in range(len(control_points) - 1):
76+
cylinder = create_cylinder_mesh(control_points[i], control_points[i + 1], spline.radius, slices)
77+
if cylinder.vertices.size > 0:
78+
meshes.append(cylinder)
79+
80+
if spline.is_cyclic and len(control_points) >= 2:
81+
cylinder = create_cylinder_mesh(control_points[-1], control_points[0], spline.radius, slices)
82+
if cylinder.vertices.size > 0:
83+
meshes.append(cylinder)
84+
85+
if not meshes:
86+
return trimesh.Trimesh()
87+
88+
r = (spline.color >> 16) & 0xFF
89+
g = (spline.color >> 8) & 0xFF
90+
b = spline.color & 0xFF
91+
92+
combined = trimesh.util.concatenate(meshes)
93+
combined.visual.vertex_colors = np.array([r, g, b, 255], dtype=np.uint8)
94+
95+
return combined
96+
97+
2798
def create_texture_visual(
2899
vertices: np.ndarray,
29100
faces: np.ndarray,
@@ -90,6 +161,11 @@ def main() -> None:
90161
action="store_true",
91162
help="Disable texture rendering even if available.",
92163
)
164+
parser.add_argument(
165+
"--show-splines",
166+
action="store_true",
167+
help="Show splines associated with the scan, if available.",
168+
)
93169

94170
args = parser.parse_args()
95171
if not args.input.exists():
@@ -125,7 +201,18 @@ def main() -> None:
125201
process=False,
126202
)
127203

128-
t_mesh.show(caption=args.input.name, smooth=False)
204+
scene = trimesh.Scene()
205+
scene.add_geometry(t_mesh, node_name="mesh")
206+
207+
if args.show_splines and mesh.has_splines:
208+
print(f"Creating spline visualization ({len(mesh.splines)} splines)...")
209+
210+
for idx, spline in enumerate(mesh.splines):
211+
spline_mesh = create_spline_mesh(spline)
212+
if spline_mesh.vertices.size > 0:
213+
scene.add_geometry(spline_mesh, node_name=f"spline_{idx}")
214+
215+
scene.show(caption=args.input.name, smooth=False)
129216

130217

131218
if __name__ == "__main__":

src/hpsdecode/export/obj.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@
44

55
__all__ = ["MaterialConfig", "OBJExporter"]
66

7+
import dataclasses
78
import io
89
import typing as t
9-
from dataclasses import dataclass
1010
from pathlib import Path
1111

1212
import numpy as np
@@ -23,7 +23,7 @@
2323
from hpsdecode.mesh import HPSMesh
2424

2525

26-
@dataclass
26+
@dataclasses.dataclass
2727
class MaterialConfig:
2828
"""Material properties for OBJ MTL files."""
2929

src/hpsdecode/loader.py

Lines changed: 146 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,17 @@
88
import typing as t
99
import xml.etree.ElementTree as ET
1010

11+
import numpy as np
12+
1113
from hpsdecode.exceptions import HPSParseError, HPSSchemaError
12-
from hpsdecode.mesh import HPSMesh, HPSPackedScan, SchemaType
14+
from hpsdecode.mesh import HPSMesh, HPSPackedScan, SchemaType, Spline
1315
from hpsdecode.schemas import SUPPORTED_SCHEMAS, EncryptedData, ParseContext, get_parser
1416

1517
if t.TYPE_CHECKING:
1618
import os
1719

20+
import numpy.typing as npt
21+
1822
from hpsdecode.encryption import EncryptionKeyProvider
1923

2024
#: List of XML paths to search for texture images that may be encrypted.
@@ -126,6 +130,143 @@ def get_required_text(element: ET.Element) -> str:
126130
return text
127131

128132

133+
def get_property_value(element: ET.Element, property_name: str) -> str:
134+
"""Get the value attribute from a 'Property' element.
135+
136+
:param element: The XML element.
137+
:param property_name: The name of the property to find.
138+
:return: The property value.
139+
:raises HPSParseError: If the property or its value is missing.
140+
"""
141+
property_element = element.find(f".//Property[@name='{property_name}']")
142+
if property_element is None:
143+
raise HPSParseError(f"Missing 'Property' element with name='{property_name}'")
144+
145+
value = property_element.get("value")
146+
if value is None:
147+
raise HPSParseError(f"Missing 'value' attribute on Property[@name='{property_name}']")
148+
149+
return value
150+
151+
152+
def extract_control_points_packed(data: bytes) -> npt.NDArray[np.floating]:
153+
"""Extract 3D control points from packed binary data (base64-encoded).
154+
155+
:param data: The binary data containing packed float coordinates.
156+
:return: A numpy array of shape (N, 3) containing the control points.
157+
:raises HPSParseError: If the data length is not divisible by 4 (size of float) or if no valid points are found.
158+
"""
159+
if len(data) % 4 != 0:
160+
raise HPSParseError(f"Packed control points data length {len(data)} is not divisible by 4 (sizeof float)")
161+
162+
floats = np.frombuffer(data, dtype=np.float32)
163+
num_points = len(floats) // 3
164+
if num_points == 0:
165+
raise HPSParseError("No complete control points found in packed data")
166+
167+
return floats[: num_points * 3].reshape(num_points, 3)
168+
169+
170+
def extract_control_points_xml(element: ET.Element) -> npt.NDArray[np.floating]:
171+
"""Extract 3D control points from XML elements.
172+
173+
:param element: The XML element containing the control point objects.
174+
:return: A numpy array of shape (N, 3) containing the control points.
175+
:raises HPSParseError: If any control point is missing required attributes or if no valid points are found.
176+
"""
177+
points: list[tuple[float, float, float]] = []
178+
179+
for obj in element.findall("Object"):
180+
vector = obj.find("Vector[@name='p']")
181+
if vector is None:
182+
raise HPSParseError("Object in ControlPoints is missing Vector[@name='p'] element")
183+
184+
x_str = vector.get("x")
185+
y_str = vector.get("y")
186+
z_str = vector.get("z")
187+
if x_str is None or y_str is None or z_str is None:
188+
raise HPSParseError("Vector element is missing x, y, or z attribute")
189+
190+
try:
191+
x = float(x_str)
192+
y = float(y_str)
193+
z = float(z_str)
194+
except ValueError as e:
195+
raise HPSParseError(f"Failed to parse vector coordinates: {e}") from e
196+
197+
points.append((x, y, z))
198+
199+
if not points:
200+
raise HPSParseError("ControlPoints element contains no valid control points")
201+
202+
return np.array(points, dtype=np.float32)
203+
204+
205+
def parse_spline(element: ET.Element) -> Spline:
206+
"""Parse a single spline from an XML Object element.
207+
208+
:param element: The XML element representing the spline object.
209+
:return: A Spline object containing the parsed data.
210+
:raises HPSParseError: If required elements or attributes are missing.
211+
"""
212+
name = get_property_value(element, "Name")
213+
radius_str = get_property_value(element, "Radius")
214+
closed_str = get_property_value(element, "Closed")
215+
color_str = get_property_value(element, "Color")
216+
misc_str = get_property_value(element, "iMisc1")
217+
218+
try:
219+
radius = float(radius_str)
220+
is_cyclic = closed_str.lower() == "true"
221+
color = int(color_str)
222+
misc = int(misc_str)
223+
except ValueError as e:
224+
raise HPSParseError(f"Failed to parse spline property values: {e}") from e
225+
226+
control_points_packed_element = element.find(".//ControlPointsPacked")
227+
control_points_xml_element = element.find(".//ControlPoints")
228+
229+
if control_points_packed_element is not None:
230+
control_points_text = control_points_packed_element.text
231+
if control_points_text is None or control_points_text.strip() == "":
232+
raise HPSParseError("ControlPointsPacked element has no content")
233+
234+
control_points_data = base64.b64decode(control_points_text.strip())
235+
control_points = extract_control_points_packed(control_points_data)
236+
elif control_points_xml_element is not None:
237+
control_points = extract_control_points_xml(control_points_xml_element)
238+
else:
239+
raise HPSParseError("Spline object is missing control points")
240+
241+
return Spline(
242+
name=name,
243+
control_points=control_points,
244+
radius=radius,
245+
is_cyclic=is_cyclic,
246+
color=color,
247+
misc=misc,
248+
)
249+
250+
251+
def parse_splines(root: ET.Element) -> list[Spline]:
252+
"""Parse all splines from the XML root element.
253+
254+
:param root: The root XML element of the HPS file.
255+
:return: A list of parsed Spline objects.
256+
"""
257+
splines: list[Spline] = []
258+
259+
splines_container = root.find(".//Splines")
260+
if splines_container is None:
261+
return splines
262+
263+
for obj in splines_container.findall(".//Object[@name='Spline']"):
264+
spline = parse_spline(obj)
265+
splines.append(spline)
266+
267+
return splines
268+
269+
129270
def parse_xml(file: str | os.PathLike[str] | t.IO[bytes] | bytes) -> ET.ElementTree:
130271
"""Parse an HPS XML file.
131272
@@ -217,6 +358,8 @@ def load_hps(
217358
is_encrypted=is_encrypted and encryptable,
218359
)
219360

361+
splines = parse_splines(root)
362+
220363
properties: dict[str, t.Any] = {}
221364
properties_element = root.find("Properties")
222365
if properties_element is not None:
@@ -237,6 +380,7 @@ def load_hps(
237380
vertex_colors_data=vertex_colors_data,
238381
texture_coords_data=texture_coords_data,
239382
texture_images=list(texture_images.values()),
383+
splines=splines,
240384
check_value=int(check_value) if check_value else None,
241385
properties=properties,
242386
)
@@ -261,6 +405,7 @@ def load_hps(
261405
vertex_colors_data=context.vertex_colors_data,
262406
texture_coords_data=context.texture_coords_data,
263407
texture_images=context.texture_images,
408+
splines=context.splines,
264409
vertex_commands=result.vertex_commands,
265410
face_commands=result.face_commands,
266411
check_value=context.check_value,

0 commit comments

Comments
 (0)