|
async def zoo_calculate_cad_physical_properties( |
|
file_path: Path | str, |
|
unit_length: str, |
|
unit_mass: str, |
|
unit_density: str, |
|
density: float, |
|
unit_area: str, |
|
unit_vol: str, |
|
) -> dict: |
|
"""Calculate physical properties (volume, mass, surface area, center of mass, bounding box) of a CAD file. |
|
|
|
NOTE: The bounding box will be returned in the same unit length as the original CAD file. |
|
|
|
Args: |
|
file_path (Path | str): The path to the file. The file should be one of the supported formats: .fbx, .gltf, .obj, .ply, .sldprt, .step, .stp, .stl (case-insensitive) |
|
unit_length (str): The unit of length for center of mass. One of 'cm', 'ft', 'in', 'm', 'mm', 'yd'. |
|
unit_mass (str): The unit of mass for the mass result. One of 'g', 'kg', 'lb'. |
|
unit_density (str): The unit of density for the material. One of 'lb:ft3', 'kg:m3'. |
|
density (float): The density of the material. |
|
unit_area (str): The unit of area for surface area. One of 'cm2', 'dm2', 'ft2', 'in2', 'km2', 'm2', 'mm2', 'yd2'. |
|
unit_vol (str): The unit of volume. One of 'cm3', 'ft3', 'in3', 'm3', 'mm3', 'yd3', 'usfloz', 'usgal', 'l', 'ml'. |
|
|
|
Returns: |
|
dict: A dictionary with keys 'volume', 'mass', 'surface_area', 'center_of_mass', and 'bounding_box'. |
|
""" |
|
file_path = Path(file_path) |
|
|
|
logger.info("Calculating physical properties for %s", str(file_path.resolve())) |
|
|
|
async with aiofiles.open(file_path, "rb") as inp: |
|
data = await inp.read() |
|
|
|
normalized_ext = _normalize_ext(file_path.suffix.split(".")[1]) |
|
src_format = FileImportFormat(normalized_ext) |
|
|
|
volume_result = kittycad_client.file.create_file_volume( |
|
output_unit=UnitVolume(unit_vol), |
|
src_format=src_format, |
|
body=data, |
|
) |
|
if not isinstance(volume_result, FileVolume) or volume_result.volume is None: |
|
raise ZooMCPException("Failed to calculate volume") |
|
|
|
mass_result = kittycad_client.file.create_file_mass( |
|
output_unit=UnitMass(unit_mass), |
|
src_format=src_format, |
|
body=data, |
|
material_density_unit=UnitDensity(unit_density), |
|
material_density=density, |
|
) |
|
if not isinstance(mass_result, FileMass) or mass_result.mass is None: |
|
raise ZooMCPException("Failed to calculate mass") |
|
|
|
sa_result = kittycad_client.file.create_file_surface_area( |
|
output_unit=UnitArea(unit_area), |
|
src_format=src_format, |
|
body=data, |
|
) |
|
if not isinstance(sa_result, FileSurfaceArea) or sa_result.surface_area is None: |
|
raise ZooMCPException("Failed to calculate surface area") |
|
|
|
com_result = kittycad_client.file.create_file_center_of_mass( |
|
src_format=src_format, |
|
body=data, |
|
output_unit=UnitLength(unit_length), |
|
) |
|
if ( |
|
not isinstance(com_result, FileCenterOfMass) |
|
or com_result.center_of_mass is None |
|
): |
|
raise ZooMCPException("Failed to calculate center of mass") |
|
|
|
# Compute bounding box from mesh data |
|
if normalized_ext == "stl": |
|
bbox = _compute_stl_bounding_box(data) |
|
else: |
|
stl_result = kittycad_client.file.create_file_conversion( |
|
src_format=src_format, |
|
output_format=FileExportFormat.STL, |
|
body=data, |
|
) |
|
if not isinstance(stl_result, FileConversion): |
|
raise ZooMCPException("Failed to convert file for bounding box calculation") |
|
if stl_result.outputs is None or len(stl_result.outputs) == 0: |
|
raise ZooMCPException( |
|
"Failed to convert file for bounding box calculation, no output" |
|
) |
|
bbox = _compute_stl_bounding_box(next(iter(stl_result.outputs.values()))) |
|
|
|
physical_properties = { |
|
"volume": volume_result.volume, |
|
"mass": mass_result.mass, |
|
"surface_area": sa_result.surface_area, |
|
"center_of_mass": com_result.center_of_mass.to_dict(), |
|
"bounding_box": bbox, |
|
} |
|
|
|
return physical_properties |
|
|
|
|
|
async def zoo_calculate_kcl_physical_properties( |
|
kcl_code: str | None, |
|
kcl_path: Path | str | None, |
|
unit_length: str, |
|
unit_mass: str, |
|
unit_density: str, |
|
density: float, |
|
unit_area: str, |
|
unit_vol: str, |
|
) -> dict: |
|
"""Calculate physical properties (volume, mass, surface area, center of mass, bounding box) of a KCL model. |
|
|
|
Either kcl_code or kcl_path must be provided. If kcl_path is provided, it should point |
|
to a .kcl file or a directory containing a main.kcl file. |
|
|
|
Args: |
|
kcl_code (str | None): KCL code to evaluate. |
|
kcl_path (Path | str | None): Path to a .kcl file or a directory containing a main.kcl file. |
|
unit_length (str): The unit of length for center of mass and bounding box. One of 'cm', 'ft', 'in', 'm', 'mm', 'yd'. |
|
unit_mass (str): The unit of mass for the mass result. One of 'g', 'kg', 'lb'. |
|
unit_density (str): The unit of density for the material. One of 'lb:ft3', 'kg:m3'. |
|
density (float): The density of the material. |
|
unit_area (str): The unit of area for surface area. One of 'cm2', 'dm2', 'ft2', 'in2', 'km2', 'm2', 'mm2', 'yd2'. |
|
unit_vol (str): The unit of volume. One of 'cm3', 'ft3', 'in3', 'm3', 'mm3', 'yd3', 'usfloz', 'usgal', 'l', 'ml'. |
|
|
|
Returns: |
|
dict: A dictionary with keys 'volume', 'mass', 'surface_area', 'center_of_mass', and 'bounding_box'. |
|
""" |
|
logger.info("Calculating physical properties of KCL") |
|
|
|
_check_kcl_code_or_path(kcl_code, kcl_path) |
|
|
|
request = kcl.PhysicalPropertiesRequest() |
|
request.set_surface_area(_parse_unit(unit_area, UNIT_AREA_MAP, "unit_area")) |
|
request.set_volume(_parse_unit(unit_vol, UNIT_VOLUME_MAP, "unit_volume")) |
|
request.set_center_of_mass(_parse_unit(unit_length, UNIT_LENGTH_MAP, "unit_length")) |
|
request.set_bounding_box(_parse_unit(unit_length, UNIT_LENGTH_MAP, "unit_length")) |
|
request.set_mass( |
|
output_unit=_parse_unit(unit_mass, UNIT_MASS_MAP, "unit_mass"), |
|
material_density=density, |
|
material_density_unit=_parse_unit( |
|
unit_density, UNIT_DENSITY_MAP, "unit_density" |
|
), |
|
) |
|
|
|
if kcl_code: |
|
response = await _execute_with_retries( |
|
kcl.execute_code_and_measure, kcl_code, request |
|
) |
|
else: |
|
response = await _execute_with_retries( |
|
kcl.execute_and_measure, str(kcl_path), request |
|
) |
|
|
|
volume = response.get_volume() |
|
com = response.get_center_of_mass() |
|
sa = response.get_surface_area() |
|
mass = response.get_mass() |
|
bbox = response.get_bounding_box() |
|
bbox_center = bbox.get_center() |
|
bbox_dims = bbox.get_dimensions() |
|
|
|
physical_properties = { |
|
"volume": volume, |
|
"mass": mass, |
|
"surface_area": sa, |
|
"center_of_mass": {"x": com.x, "y": com.y, "z": com.z}, |
|
"bounding_box": { |
|
"center": {"x": bbox_center.x, "y": bbox_center.y, "z": bbox_center.z}, |
|
"dimensions": {"x": bbox_dims.x, "y": bbox_dims.y, "z": bbox_dims.z}, |
|
}, |
|
} |
|
|
|
return physical_properties |
|
|
|
|
|
def _compute_stl_bounding_box(stl_data: bytes) -> dict: |
|
"""Load an STL file with trimesh and compute the bounding box. |
|
|
|
Args: |
|
stl_data: Raw bytes of an STL file (binary or ASCII). |
|
|
|
Returns: |
|
dict with 'center' (dict with x,y,z) and 'dimensions' (dict with x,y,z). |
|
""" |
|
if len(stl_data) == 0: |
|
raise ZooMCPException("STL data is empty") |
|
|
|
mesh = trimesh.load(io.BytesIO(stl_data), file_type="stl") |
|
|
|
if not hasattr(mesh, "bounds") or mesh.bounds is None: |
|
raise ZooMCPException("Failed to compute bounding box from STL data") |
|
|
|
bounds = mesh.bounds # [[min_x, min_y, min_z], [max_x, max_y, max_z]] |
|
center = (bounds[0] + bounds[1]) / 2 |
|
dimensions = bounds[1] - bounds[0] |
|
|
|
return { |
|
"center": {"x": float(center[0]), "y": float(center[1]), "z": float(center[2])}, |
|
"dimensions": { |
|
"x": float(dimensions[0]), |
|
"y": float(dimensions[1]), |
|
"z": float(dimensions[2]), |
|
}, |
|
} |
Return CAD physical-analysis length results in one explicit unit
Summary
zoo_calculate_cad_physical_propertiescan return center-of-mass coordinatesand bounding-box coordinates in different length units without identifying that
the units differ.
The caller's
unit_lengthis passed to the remote center-of-mass endpoint, butthe bounding box is computed locally from the source STL coordinates. For
non-STL inputs, the source file is converted to STL and the returned mesh
coordinates are used directly. The function documentation says the bounding box
uses the original file's length unit, while the returned dictionary contains no
unit metadata.
The equivalent KCL physical-analysis path is a useful control: it applies
unit_lengthto both center of mass and bounding box in onePhysicalPropertiesRequest.Current behavior
At
KittyCAD/mcp@4e5648bcef5d9e102be5b2845d1f7bcaf5953f31:unit_lengthis passed tocreate_file_center_of_mass;trimeshcoordinates without conversion;center_of_massandbounding_boxfields but no units.Source:
mcp/src/zoo_mcp/zoo_tools.py
Lines 777 to 981 in 4e5648b
This is especially ambiguous for STL because STL does not encode an
authoritative length unit. A caller requesting
unit_length="cm"can receive acenter of mass converted by the API to centimeters beside raw mesh-coordinate
bounding-box values whose unit is unstated.
Minimal reproduction
0..10on each axis, followingthe application's normal STL-unit convention.
zoo_calculate_cad_physical_propertieswithunit_length="cm"andotherwise valid mass, density, area, and volume units.
center_of_massandbounding_boxin the returned dictionary.The center of mass is returned according to the requested API output unit. The
bounding box is computed from the raw
0..10mesh coordinates. The responsedoes not say whether those bounding-box values are millimeters, centimeters, or
another convention.
As a passing control, run
zoo_calculate_kcl_physical_propertiesfor anequivalent KCL cube with
unit_length="cm"; both center of mass and bounding boxare requested in centimeters.
Expected behavior
Physical-analysis results must not contain unlabeled mixed-unit length fields.
Preferably,
unit_lengthshould control both center of mass and bounding box forCAD and KCL inputs.
If preserving source-coordinate bounds is necessary, the response must instead
report explicit units for every length-valued field and require or document the
source-unit assumption for formats such as STL.
Acceptance criteria
center_of_massandbounding_boxuse the same requested length unit, or eachfield carries explicit, machine-readable unit metadata.
silently imply that STL contains embedded units.
conversion.
mmandcm, including one input whose sourceunit differs from
unit_length.response schema changes.
Duplicate search
No exact open or closed duplicate was found on 2026-08-26 using searches for
physical-analysis units, bounding-box units, and center-of-mass/bounding-box unit
consistency in
KittyCAD/mcp.Related but distinct:
bounding-box tools but did not define a cross-field unit contract.
analysis to
execute_and_measure; it does not address CAD mesh-coordinateunits.
Suggested labels
bug,python