Add center of mass / centroid computation#33
Open
BriceCroix wants to merge 7 commits into
Open
Conversation
Owner
|
I've adapted your change. Please send a new pull request to acknowledge your contribution to the project. I added method calculate_volume_and_centroid() for the centroid accumulation loop inside the existing volume pass and returning both values as a tuple; and added method: triangle_centroid(). Here is the full code with your part in it: #!/usr/bin/env python3
'''
VOLUME CALCULATION STL MODELS
Author: Mar Canet (mar.canet@gmail.com) - August 2012-2025
Description: Calculate volume and mass of STL models (binary and ASCII), NIfTI, and DICOM files.
'''
from typing import Dict, Generator, List, Optional, Tuple
import struct
import sys
import re
import argparse
import json
import os
# Type alias for a 3D coordinate triple
Point3D = Tuple[float, float, float]
try:
from tqdm import tqdm
except ImportError:
print("tqdm is not installed. Please install it: pip install tqdm")
sys.exit(1)
try:
from rich.console import Console
from rich.table import Table
import rich.box
except ImportError:
print("Rich is not installed. Please install it: pip install rich")
sys.exit(1)
console = Console()
class materialsFor3DPrinting:
def __init__(self):
# Materials are ordered from more to less common.
# Densities in g/cm³.
self.materials_dict = {
1: {'name': 'PLA', 'mass': 1.25},
2: {'name': 'PETG', 'mass': 1.27},
3: {'name': 'ABS', 'mass': 1.02},
4: {'name': 'Resin', 'mass': 1.20},
5: {'name': 'TPU (Rubber-like)', 'mass': 1.20},
6: {'name': 'Polyamide_SLS', 'mass': 0.95},
7: {'name': 'Polyamide_MJF', 'mass': 1.01},
8: {'name': 'Plexiglass', 'mass': 1.18},
9: {'name': 'Alumide', 'mass': 1.36},
10: {'name': 'Carbon Steel', 'mass': 7.80},
11: {'name': 'Steel', 'mass': 7.86},
12: {'name': 'Aluminum', 'mass': 2.698},
13: {'name': 'Titanium', 'mass': 4.41},
14: {'name': 'Brass', 'mass': 8.60},
15: {'name': 'Bronze', 'mass': 9.00},
16: {'name': 'Copper', 'mass': 9.00},
17: {'name': 'Silver', 'mass': 10.26},
18: {'name': 'Gold_14K', 'mass': 13.60},
19: {'name': 'Gold_18K', 'mass': 15.60},
20: {'name': '3k CFRP', 'mass': 1.79},
21: {'name': 'Red Oak', 'mass': 0.70},
}
def get_material_info(self, material_id):
return self.materials_dict.get(material_id)
def list_materials(self, output_format='table'):
if output_format == 'json':
print(json.dumps(self.materials_dict, indent=4))
else:
table = Table(title="Available 3D Printing Materials",
show_header=True, header_style="bold magenta")
table.add_column("ID", style="dim", width=6)
table.add_column("Name")
table.add_column("Density (g/cm³)", justify="right")
for key, value in self.materials_dict.items():
table.add_row(str(key), value['name'], f"{value['mass']:.3f}")
console.print(table)
class STLUtils:
def __init__(self) -> None:
self.f = None
self.is_binary_file: Optional[bool] = None
self.triangles: List[Tuple[Point3D, Point3D, Point3D]] = []
self.triangle_count: int = 0
self.file_size: int = 0
self.bounding_box_cm: Optional[Dict[str, float]] = None
self._bbox_min: Point3D = (0.0, 0.0, 0.0)
self.is_watertight: Optional[bool] = None # populated after loadSTL
# ------------------------------------------------------------------
# Reliable binary vs ASCII detection
# ------------------------------------------------------------------
def is_binary(self, filepath):
"""
Detect binary vs ASCII STL reliably.
Checking only for a 'solid' header prefix is not enough — many binary
STL exporters write a header beginning with 'solid'. We additionally
verify the expected file size against the triangle count stored in the
binary header:
expected = 80 (header) + 4 (count) + count * 50 (triangles)
If the sizes match the file is binary regardless of the prefix.
"""
with open(filepath, 'rb') as f:
header = f.read(80).decode(errors='replace')
if not header.lstrip().startswith('solid'):
return True # clearly binary
raw = f.read(4)
if len(raw) < 4:
return False # too small to be a valid binary STL
triangle_count = struct.unpack('<I', raw)[0]
expected_size = 80 + 4 + triangle_count * 50
return os.path.getsize(filepath) == expected_size
# ------------------------------------------------------------------
# Triangle readers
# ------------------------------------------------------------------
def _parse_vertices(self, line: str) -> List[float]:
"""Extract up to 3 floats from a vertex/normal line using a robust regex."""
number_re = re.compile(r"[-+]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?")
return list(map(float, number_re.findall(line)))[:3]
def read_ascii_triangle(
self, lines: List[str], index: int
) -> Optional[Tuple[Point3D, Point3D, Point3D]]:
"""
Parse one ASCII STL facet starting at *index* (the 'facet normal' line).
Uses a robust float regex (FIX #4) that handles scientific notation and
variable whitespace. Returns ``None`` on any parse failure so the caller
can skip bad facets rather than crash.
Parameters
----------
lines : list[str]
All lines of the ASCII STL file.
index : int
Line index of the ``facet normal`` line for this triangle.
Returns
-------
tuple[Point3D, Point3D, Point3D] or None
The three vertices of the triangle, or ``None`` if parsing failed.
"""
try:
# 'facet normal' at index+0, 'outer loop' at index+1,
# three 'vertex' lines at index+2,3,4, 'endloop' at index+5, 'endfacet' at index+6
p1 = self._parse_vertices(lines[index + 2])
p2 = self._parse_vertices(lines[index + 3])
p3 = self._parse_vertices(lines[index + 4])
if len(p1) < 3 or len(p2) < 3 or len(p3) < 3:
return None
return (p1, p2, p3)
except (IndexError, ValueError):
return None
def unpack(self, sig: str, length: int) -> tuple:
return struct.unpack(sig, self.f.read(length))
def read_triangle_binary(self) -> Tuple[Point3D, Point3D, Point3D]:
"""Read one triangle record from an open binary STL file."""
self.unpack("<3f", 12) # normal (discarded)
p1 = list(self.unpack("<3f", 12))
p2 = list(self.unpack("<3f", 12))
p3 = list(self.unpack("<3f", 12))
self.unpack("<h", 2) # attribute byte count
return (p1, p2, p3)
# ------------------------------------------------------------------
# File loading
# ------------------------------------------------------------------
def loadSTL(self, infilename):
self.file_size = os.path.getsize(infilename)
self.is_binary_file = self.is_binary(infilename)
self.triangles = []
try:
if self.is_binary_file:
with open(infilename, "rb") as self.f:
self.f.seek(80)
self.triangle_count = struct.unpack("@i", self.f.read(4))[0]
for _ in tqdm(range(self.triangle_count), desc="Reading triangles"):
self.triangles.append(self.read_triangle_binary())
else:
with open(infilename, 'r') as f:
lines = f.readlines()
ascii_triangles = []
i = 0
with tqdm(total=len(lines), desc="Reading triangles") as pbar:
while i < len(lines):
if lines[i].strip().lower().startswith('facet'):
tri = self.read_ascii_triangle(lines, i)
if tri is not None:
ascii_triangles.append(tri)
pbar.update(7)
i += 7
else:
pbar.update(1)
i += 1
self.triangles = ascii_triangles
self.triangle_count = len(self.triangles)
self._calculate_bounding_box()
# Watertight check — must run after _calculate_bounding_box
self.is_watertight = self._check_watertight()
if not self.is_watertight:
console.print(
"[bold yellow]⚠ Warning:[/bold yellow] This mesh does not appear to be "
"watertight (not every edge is shared by exactly 2 triangles). "
"Volume calculations may be inaccurate for open or malformed meshes."
)
except Exception as e:
print(f"Error loading STL file: {e}")
sys.exit(1)
# ------------------------------------------------------------------
# Bounding box
# ------------------------------------------------------------------
def _calculate_bounding_box(self):
if not self.triangles:
self.bounding_box_cm = {'width': 0, 'depth': 0, 'height': 0}
self._bbox_min = (0.0, 0.0, 0.0)
return
fv = self.triangles[0][0]
min_x = max_x = fv[0]
min_y = max_y = fv[1]
min_z = max_z = fv[2]
for tri in self.triangles:
for v in tri:
if v[0] < min_x: min_x = v[0]
if v[0] > max_x: max_x = v[0]
if v[1] < min_y: min_y = v[1]
if v[1] > max_y: max_y = v[1]
if v[2] < min_z: min_z = v[2]
if v[2] > max_z: max_z = v[2]
self.bounding_box_cm = {
'width': (max_x - min_x) / 10.0,
'depth': (max_y - min_y) / 10.0,
'height': (max_z - min_z) / 10.0,
}
self._bbox_min = (min_x, min_y, min_z)
# ------------------------------------------------------------------
# Watertight / closed-mesh validation
# ------------------------------------------------------------------
def _check_watertight(self):
"""
Verify the mesh is a closed manifold by checking that every undirected
edge is shared by exactly 2 triangles. An open mesh (surface scan,
incomplete export, etc.) would have boundary edges that appear only once,
and would produce a nonsense volume result from the divergence theorem.
Vertex coordinates are rounded to 6 decimal places before comparison to
absorb floating-point noise from the file format.
"""
from collections import defaultdict
edge_count = defaultdict(int)
for p1, p2, p3 in self.triangles:
v1 = tuple(round(c, 6) for c in p1)
v2 = tuple(round(c, 6) for c in p2)
v3 = tuple(round(c, 6) for c in p3)
for a, b in ((v1, v2), (v2, v3), (v3, v1)):
key = (min(a, b), max(a, b))
edge_count[key] += 1
return all(count == 2 for count in edge_count.values())
# ------------------------------------------------------------------
# Volume + Memory: translate to origin — as a generator
# ------------------------------------------------------------------
def _translated_triangles(self):
"""
Yield triangles translated so the mesh bounding-box minimum sits at the
origin (0, 0, 0).
WHY: The divergence-theorem signed-volume formula accumulates tetrahedron
volumes relative to the world origin. When the mesh is far from the
origin (e.g. placed at large positive coordinates in the slicer), the
individual terms become very large and largely cancel each other,
causing catastrophic floating-point precision loss that can produce a
negative — or otherwise wrong — result. Translating to the origin first
keeps all coordinates small and positive, giving a numerically stable
answer regardless of where the model was placed in world space.
This is implemented as a generator so it never materialises a
second full copy of all triangles in RAM — critical for large meshes.
"""
ox, oy, oz = self._bbox_min
for p1, p2, p3 in self.triangles:
yield (
(p1[0] - ox, p1[1] - oy, p1[2] - oz),
(p2[0] - ox, p2[1] - oy, p2[2] - oz),
(p3[0] - ox, p3[1] - oy, p3[2] - oz),
)
# ------------------------------------------------------------------
# Core maths
# ------------------------------------------------------------------
@staticmethod
def _signed_volume_of_triangle(p1: Point3D, p2: Point3D, p3: Point3D) -> float:
"""
Compute the signed volume of the tetrahedron formed by the three vertices
and the origin (0, 0, 0).
Geometrically this is one-sixth of the scalar triple product
(determinant) of the three position vectors::
V_signed = (1/6) * det([p1, p2, p3])
= (1/6) * (p1 · (p2 × p3))
The *sign* encodes the orientation of the triangle relative to the
origin (right- vs left-handed winding). Summing over all triangles of a
closed mesh gives the net volume via the divergence theorem.
Parameters
----------
p1, p2, p3 : Point3D
3D coordinates of the triangle's vertices.
Returns
-------
float
Signed volume of the tetrahedron (origin, p1, p2, p3).
Examples
--------
>>> STLUtils._signed_volume_of_triangle((1,0,0), (0,1,0), (0,0,1))
0.16666666666666666
>>> STLUtils._signed_volume_of_triangle((1,0,0), (0,0,1), (0,1,0))
-0.16666666666666666
"""
v321 = p3[0] * p2[1] * p1[2]
v231 = p2[0] * p3[1] * p1[2]
v312 = p3[0] * p1[1] * p2[2]
v132 = p1[0] * p3[1] * p2[2]
v213 = p2[0] * p1[1] * p3[2]
v123 = p1[0] * p2[1] * p3[2]
return (1.0 / 6.0) * (-v321 + v231 + v312 - v132 - v213 + v123)
@staticmethod
def _triangle_centroid(p1: Point3D, p2: Point3D, p3: Point3D) -> Point3D:
"""
Compute the centroid of the tetrahedron formed by the three vertices
and the origin (0, 0, 0).
The centroid is simply the average of all four vertices (the three
triangle corners plus the origin), which simplifies to
``(p1 + p2 + p3) / 4`` since the origin contributes zero.
Parameters
----------
p1, p2, p3 : Point3D
3D coordinates of the triangle's vertices.
Returns
-------
Point3D
Centroid coordinates of the tetrahedron.
"""
return (
(p1[0] + p2[0] + p3[0]) / 4.0,
(p1[1] + p2[1] + p3[1]) / 4.0,
(p1[2] + p2[2] + p3[2]) / 4.0,
)
# ------------------------------------------------------------------
# Public calculations
# ------------------------------------------------------------------
def calculate_volume_and_centroid(self) -> Tuple[float, Dict[str, float]]:
"""
Compute the volume and center of mass of the loaded mesh in a single pass.
Both quantities share the per-triangle signed-volume term required by the
divergence theorem, so computing them together costs no extra iteration.
The centroid of a closed polyhedron is::
C = (1 / (2V)) * Σ v_i * centroid_i
where ``v_i`` is the signed volume of the i-th tetrahedron (origin →
triangle) and ``centroid_i`` is its centroid. After accumulating in
origin-translated coordinates the bbox_min offset is added back
so the returned centroid is in the original world-space coordinate system.
Returns
-------
tuple[float, dict[str, float]]
``(volume_cm3, centroid_cm)`` where *volume_cm3* is the mesh volume
in cm³ and *centroid_cm* is ``{'x': …, 'y': …, 'z': …}`` in cm.
Warns
-----
Prints a Rich warning if the raw signed volume is negative, which
indicates reversed (inside-out) face normals.
"""
total_volume = 0.0
cx = cy = cz = 0.0
for p1, p2, p3 in tqdm(self._translated_triangles(),
desc="Calculating volume",
total=self.triangle_count):
sv = self._signed_volume_of_triangle(p1, p2, p3)
total_volume += sv
tc = self._triangle_centroid(p1, p2, p3)
cx += sv * tc[0]
cy += sv * tc[1]
cz += sv * tc[2]
if total_volume < 0:
console.print(
"[bold yellow]⚠ Warning:[/bold yellow] Raw signed volume is negative, "
"indicating the mesh has reversed (inside-out) face normals. "
"Returning the absolute value — please check your model's face orientation."
)
volume_mm3 = abs(total_volume)
if volume_mm3 == 0:
centroid_cm = {'x': 0.0, 'y': 0.0, 'z': 0.0}
else:
ox, oy, oz = self._bbox_min
centroid_cm = {
'x': round((cx / (4.0 * total_volume) + ox) / 10.0, 4),
'y': round((cy / (4.0 * total_volume) + oy) / 10.0, 4),
'z': round((cz / (4.0 * total_volume) + oz) / 10.0, 4),
}
return volume_mm3 / 1000.0, centroid_cm
def calculate_volume(self) -> float:
"""
Convenience wrapper — returns only volume in cm³.
See :meth:`calculate_volume_and_centroid` for the full calculation.
Returns
-------
float
Mesh volume in cm³.
"""
volume, _ = self.calculate_volume_and_centroid()
return volume
def calculate_mass(self, volume_cm3: float, density_g_cm3: float) -> float:
"""
Compute mass from volume and material density.
Parameters
----------
volume_cm3 : float
Volume of the part in cm³.
density_g_cm3 : float
Material density in g/cm³.
Returns
-------
float
Mass in grams.
"""
return volume_cm3 * density_g_cm3
def calculate_surface_area(self) -> float:
"""
Compute the total surface area of the loaded mesh.
Uses the cross-product magnitude of each triangle's edge vectors.
Edge vectors are translation-invariant (differences between vertices),
so no origin shift is needed here — and iterating ``self.triangles``
directly avoids materialising an extra copy in RAM (FIX #5).
Returns
-------
float
Surface area in cm².
"""
area = 0.0
for p1, p2, p3 in tqdm(self.triangles, desc="Calculating area "):
ax = p2[0] - p1[0]; ay = p2[1] - p1[1]; az = p2[2] - p1[2]
bx = p3[0] - p1[0]; by = p3[1] - p1[1]; bz = p3[2] - p1[2]
cx = ay * bz - az * by
cy = az * bx - ax * bz
cz = ax * by - ay * bx
area += 0.5 * (cx * cx + cy * cy + cz * cz) ** 0.5
return area / 100.0 # mm² → cm²
@staticmethod
def cm3_to_inch3(v: float) -> float:
"""Convert cubic centimetres to cubic inches."""
return v * 0.0610237441
# ----------------------------------------------------------------------
# CLI
# ----------------------------------------------------------------------
def main():
parser = argparse.ArgumentParser(
description='Calculate properties of 3D models. By default, calculates all properties for all materials.',
formatter_class=argparse.RawTextHelpFormatter
)
parser.add_argument('filename', nargs='?', default=None,
help='Path to the input STL file.')
parser.add_argument('--calculation', choices=['volume', 'area'], default=None,
help='Run only one calculation instead of full analysis.')
parser.add_argument('--unit', choices=['cm', 'inch'], default='cm',
help='Unit for volume display (default: cm).')
parser.add_argument('--material', type=int, choices=range(1, 22), default=1,
help='Material ID for mass calculation (default: 1 = PLA).')
parser.add_argument('--infill', type=float, default=20.0,
help='Infill %% for mass calculation (default: 20.0).')
parser.add_argument('--filetype', choices=['stl', 'nii', 'dcm'], default='stl',
help='Input file type (default: stl).')
parser.add_argument('--output-format', choices=['table', 'json'], default='table',
help='Output format (default: table).')
parser.add_argument('--list-materials', action='store_true',
help='List all available materials and exit.')
args = parser.parse_args()
materials = materialsFor3DPrinting()
if not 0.0 <= args.infill <= 100.0:
parser.error("Infill percentage must be between 0 and 100.")
if args.list_materials:
materials.list_materials(args.output_format)
sys.exit(0)
if not args.filename:
parser.error("A filename is required unless --list-materials is used.")
is_full_analysis = args.calculation is None
if args.filetype == 'stl':
stl = STLUtils()
stl.loadSTL(args.filename)
bbox = stl.bounding_box_cm
results = {}
if is_full_analysis:
volume_cm3, centroid_cm = stl.calculate_volume_and_centroid()
area_cm2 = stl.calculate_surface_area()
adjusted = volume_cm3 * (args.infill / 100.0)
results = {
"file_information": {
"filename": os.path.basename(args.filename),
"file_size_kb": f"{stl.file_size / 1024:.2f}",
"is_watertight": stl.is_watertight,
},
"model_properties": {
"triangle_count": stl.triangle_count,
"bounding_box_cm": {
"width": f"{bbox['width']:.2f}",
"depth": f"{bbox['depth']:.2f}",
"height": f"{bbox['height']:.2f}",
},
"centroid_cm": {
"x": centroid_cm['x'],
"y": centroid_cm['y'],
"z": centroid_cm['z'],
},
"surface_area_cm2": f"{area_cm2:.4f}",
"volume_cm3": f"{volume_cm3:.4f}",
"volume_inch3": f"{stl.cm3_to_inch3(volume_cm3):.4f}",
},
"mass_estimates": [],
}
for mat_id, mat_info in materials.materials_dict.items():
results["mass_estimates"].append({
"id": mat_id,
"name": mat_info['name'],
"density_g_cm3": mat_info['mass'],
"mass_at_infill": {
"infill_percent": args.infill,
"mass_g": f"{stl.calculate_mass(adjusted, mat_info['mass']):.3f}",
},
"mass_at_100_infill": {
"infill_percent": 100.0,
"mass_g": f"{stl.calculate_mass(volume_cm3, mat_info['mass']):.3f}",
},
})
else:
results = {
"file": args.filename,
"calculation": args.calculation,
"bounding_box_cm": bbox,
"is_watertight": stl.is_watertight,
}
if args.calculation == 'volume':
volume_cm3 = stl.calculate_volume()
adjusted = volume_cm3 * (args.infill / 100.0)
material_info = materials.get_material_info(args.material)
results.update({
"volume_cm3": f"{volume_cm3:.4f}",
"volume_inch3": f"{stl.cm3_to_inch3(volume_cm3):.4f}",
"material_name": material_info['name'],
"mass_at_infill": {
"infill_percent": args.infill,
"mass_g": f"{stl.calculate_mass(adjusted, material_info['mass']):.3f}",
},
"mass_at_100_infill": {
"infill_percent": 100.0,
"mass_g": f"{stl.calculate_mass(volume_cm3, material_info['mass']):.3f}",
},
})
elif args.calculation == 'area':
area_cm2 = stl.calculate_surface_area()
results["surface_area_cm2"] = f"{area_cm2:.4f}"
# --- Output ---
if args.output_format == 'json':
print(json.dumps(results, indent=4))
else:
watertight_str = lambda w: "✔ Yes" if w else "✘ No (open mesh — volume may be inaccurate)"
if is_full_analysis:
props = results['model_properties']
info = results['file_information']
info_table = Table(
title=f"Model Analysis: {info['filename']}",
show_header=False, box=rich.box.ROUNDED
)
info_table.add_column("Property", style="dim")
info_table.add_column("Value")
info_table.add_row("File Size", f"{info['file_size_kb']} KB")
info_table.add_row("Watertight", watertight_str(info['is_watertight']))
info_table.add_row("Triangles", f"{props['triangle_count']:,}")
info_table.add_row("Bounding Box (cm)",
f"W: {props['bounding_box_cm']['width']}, "
f"D: {props['bounding_box_cm']['depth']}, "
f"H: {props['bounding_box_cm']['height']}")
c = props['centroid_cm']
info_table.add_row("Center of Mass (cm)",
f"X: {c['x']}, Y: {c['y']}, Z: {c['z']}")
info_table.add_row("Surface Area", f"{props['surface_area_cm2']} cm²")
vol_display = (f"{props['volume_inch3']} inch³"
if args.unit == 'inch' else f"{props['volume_cm3']} cm³")
info_table.add_row("Volume (solid)", vol_display)
console.print(info_table)
mass_table = Table(
title=f"Mass Estimates — all materials, {args.infill:.1f}% and 100% infill",
show_header=True, header_style="bold magenta"
)
mass_table.add_column("ID", style="dim", width=4)
mass_table.add_column("Material Name")
mass_table.add_column("Density", justify="right")
mass_table.add_column(f"Mass @ {args.infill:.1f}% (g)", justify="right")
mass_table.add_column("Mass @ 100% (g)", justify="right")
for item in results['mass_estimates']:
mass_table.add_row(
str(item['id']),
item['name'],
f"{item['density_g_cm3']:.3f}",
item['mass_at_infill']['mass_g'],
item['mass_at_100_infill']['mass_g'],
)
console.print(mass_table)
else:
if args.calculation == 'volume':
table = Table(title="Volume & Mass Calculation",
show_header=False, box=rich.box.ROUNDED)
table.add_column("Property", style="dim")
table.add_column("Value")
table.add_row("Watertight", watertight_str(results['is_watertight']))
table.add_row("Bounding Box (cm)",
f"W: {bbox['width']:.2f}, D: {bbox['depth']:.2f}, H: {bbox['height']:.2f}")
vol_display = (f"{results['volume_inch3']} inch³"
if args.unit == 'inch' else f"{results['volume_cm3']} cm³")
table.add_row("Volume (solid)", vol_display)
table.add_row("Material", f"{results['material_name']} (ID: {args.material})")
table.add_row(f"Mass ({args.infill:.1f}% Infill)",
f"{results['mass_at_infill']['mass_g']} g")
table.add_row("Mass (100% Infill)",
f"{results['mass_at_100_infill']['mass_g']} g")
console.print(table)
elif args.calculation == 'area':
table = Table(title="Surface Area Calculation",
show_header=False, box=rich.box.ROUNDED)
table.add_column("Property", style="dim")
table.add_column("Value")
table.add_row("Watertight", watertight_str(results['is_watertight']))
table.add_row("Bounding Box (cm)",
f"W: {bbox['width']:.2f}, D: {bbox['depth']:.2f}, H: {bbox['height']:.2f}")
table.add_row("Surface Area", f"{results['surface_area_cm2']} cm²")
console.print(table)
elif args.filetype in ['nii', 'dcm']:
console.print("[yellow]Warning: NIfTI and DICOM support is limited.[/yellow]")
if __name__ == '__main__':
main() |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
First of all thank you for this project which is quite handy to work with some projects ! 😁
Here are my additions to it in order to fit my needs, please do not hesitate to ask me for changes if you disagree with some of my choices !
What
Added computation of centroid coordinates, which is also the center of mass for materials with uniform density.
Why
Because for personal projects I quite often need to know how my model will balance in the real-life.
How
Modified method
calculate_volumeto becomecalculate_volume_and_centroid. This is done this way because both calculations need the individual signed volume of each triangle.What else
requirements.txtandsetup.py.README.mdto show how to setup and use a virtual environement during development.Example output
python3 volume_calculator.py ~/my_file.stl Reading triangles: 100%|████████████████████████| 1516/1516 [00:00<00:00, 298174.20it/s] Calculating volume: 100%|████████████████████████| 1516/1516 [00:00<00:00, 365392.76it/s] Calculating area : 100%|████████████████████████| 1516/1516 [00:00<00:00, 400640.47it/s] Model Analysis: my_file.stl ╭─────────────────────┬───────────────────────────╮ │ File Size │ 74.11 KB │ │ Triangles │ 1,516 │ │ Bounding Box (cm) │ W: 3.00, D: 2.80, H: 9.98 │ │ Center of mass (cm) │ X: 1.44, Y: 1.40, Z: 3.24 │ │ Surface Area │ 132.6962 cm² │ │ Volume (solid) │ 23.3373 cm³ │ ╰─────────────────────┴───────────────────────────╯