88import typing as t
99import xml .etree .ElementTree as ET
1010
11+ import numpy as np
12+
1113from hpsdecode .exceptions import HPSParseError , HPSSchemaError
12- from hpsdecode .mesh import HPSMesh , HPSPackedScan , SchemaType
14+ from hpsdecode .mesh import HPSMesh , HPSPackedScan , SchemaType , Spline
1315from hpsdecode .schemas import SUPPORTED_SCHEMAS , EncryptedData , ParseContext , get_parser
1416
1517if 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+
129270def 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