Skip to content

Return CAD physical-analysis length results in one explicit unit #240

Description

@JordanNoone

Return CAD physical-analysis length results in one explicit unit

Summary

zoo_calculate_cad_physical_properties can return center-of-mass coordinates
and bounding-box coordinates in different length units without identifying that
the units differ.

The caller's unit_length is passed to the remote center-of-mass endpoint, but
the 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_length to both center of mass and bounding box in one
PhysicalPropertiesRequest.

Current behavior

At KittyCAD/mcp@4e5648bcef5d9e102be5b2845d1f7bcaf5953f31:

  • unit_length is passed to create_file_center_of_mass;
  • STL bounds are calculated from trimesh coordinates without conversion;
  • non-STL bounds are calculated from the coordinates of a converted STL;
  • the result has center_of_mass and bounding_box fields but no units.

Source:

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]),
},
}

This is especially ambiguous for STL because STL does not encode an
authoritative length unit. A caller requesting unit_length="cm" can receive a
center of mass converted by the API to centimeters beside raw mesh-coordinate
bounding-box values whose unit is unstated.

Minimal reproduction

  1. Create a closed STL cube whose vertices span 0..10 on each axis, following
    the application's normal STL-unit convention.
  2. Call zoo_calculate_cad_physical_properties with unit_length="cm" and
    otherwise valid mass, density, area, and volume units.
  3. Inspect center_of_mass and bounding_box in 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..10 mesh coordinates. The response
does not say whether those bounding-box values are millimeters, centimeters, or
another convention.

As a passing control, run zoo_calculate_kcl_physical_properties for an
equivalent KCL cube with unit_length="cm"; both center of mass and bounding box
are requested in centimeters.

Expected behavior

Physical-analysis results must not contain unlabeled mixed-unit length fields.
Preferably, unit_length should control both center of mass and bounding box for
CAD 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_mass and bounding_box use the same requested length unit, or each
    field carries explicit, machine-readable unit metadata.
  • STL analysis has a documented and testable source-unit policy; it does not
    silently imply that STL contains embedded units.
  • STEP/STP and SLDPRT conversion tests verify how source units survive CAD-to-STL
    conversion.
  • CAD and KCL physical-analysis responses follow the same unit contract.
  • Regression tests cover at least mm and cm, including one input whose source
    unit differs from unit_length.
  • Existing numeric response consumers receive a documented migration path if the
    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:

  • KittyCAD/mcp#128 introduced the
    bounding-box tools but did not define a cross-field unit contract.
  • KittyCAD/mcp#120 moved KCL physical
    analysis to execute_and_measure; it does not address CAD mesh-coordinate
    units.

Suggested labels

bug, python

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingpythonPull requests that update python code

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions