Module geometry
=============================================================================== SFPPy Module: Geometry =============================================================================== Provides a framework for defining 3D packaging geometries and their surface/volume properties. Supports standalone shapes and composite structures for packaging simulations.
Main Components:
- Shape3D (Base class for 3D shapes)
- Subclasses: Cylinder
, Cone
, RectangularPrism
, Sphere
, SquarePyramid
, Hemisphere
- Implements _compute_volume()
, _compute_surface_area()
, _compute_connectors()
- CompositeShape (Combines multiple shapes while adjusting for overlapping volumes and shared faces)
- Packaging3D (High-level interface for working with packaging geometries)
- Connector (Defines interfaces between shapes for composite construction)
- SHAPE_REGISTRY (Maps packaging terms like "can" or "bottle" to their geometric models)
Integration with SFPPy Modules:
- Uses check_units()
from layer.py
to convert dimensions into SI units.
- Supports migration.py
by providing accurate volume and surface area computations for mass transfer models.
Example:
from patankar.geometry import Packaging3D
pkg = Packaging3D('bottle', body_radius=(5, 'cm'), body_height=(20, 'cm'))
vol, area = pkg.get_volume_and_area()
=============================================================================== Details ===============================================================================
Purpose
This module provides a framework for defining and combining various three-dimensional packaging shapes—both simple (e.g., cylinders, cones, rectangular prisms) and composite (e.g., 'bottle' = large cylinder + narrow cylinder). It also calculates each shape’s internal volume (in m³) and internal surface area (in m²).
Overview
- Shape3D and Subclasses:
- Each subclass (Cylinder, Cone, RectangularPrism, Sphere, SquarePyramid,
Hemisphere) implements:
_compute_volume()
and_compute_surface_area()
._compute_connectors()
, which returns a list ofConnector
objects representing the flat faces that can potentially connect to other shapes. Connectors have a face area and an axis (normal vector).
-
Connectors allow shapes to “snap” together in a
CompositeShape
. -
CompositeShape:
- Manages multiple shapes added together, summing volumes and surface areas, minus overlaps along shared connector faces.
-
Uses
add_shape(…)
to join a new shape to any existing sub-shape via matching connector orientations. Overlapping face area is removed from the total surface area calculation. -
Synonyms and Shape Registry:
- A dictionary
SHAPE_REGISTRY
maps real-world packaging names (like "can", "box", "glass") to their corresponding Shape3D classes. -
Some "synonyms" map to more complex, composite constructs. For example, the name "bottle" creates a
CompositeShape
of two cylinders (body + neck). -
Units:
-
Dimensions can be given either as floats in meters or as
(value, "unit")
pairs. The helper_to_m(…)
usescheck_units()
(imported fromlayer
) to convert numeric values to SI units (meters). -
Packaging3D:
- A high-level interface for creating either a single shape or a
composite shape by name and keyword arguments. It returns volume
(in m³) and surface area (in m²) via
.get_volume_and_area()
.
Usage Example: from patankar.packaging import Packaging3D
# Create a 'bottle' (two stacked cylinders) by specifying body and neck dims
pkg = Packaging3D(
'bottle',
body_radius=(5, 'cm'),
body_height=(20, 'cm'),
neck_radius=(2, 'cm'),
neck_height=(5, 'cm')
)
vol, area = pkg.get_volume_and_area()
print("Volume (m^3):", vol)
print("Surface Area (m^2):", area)
# Create a single shape (e.g., 'can' which is a cylinder) with radius
# and height specified in centimeters
pkg2 = Packaging3D('can', radius=(4, 'cm'), height=(12, 'cm'))
vol2, area2 = pkg2.get_volume_and_area()
About units: All lengths can be given: - without units: all lengths are assumed to be in meters (i.e., their SI unit) - with units by using a tupple (value,"unit"), where unit can be m,dm,cm,mm,um,nm… Input units can be heterogenerous, the result is always SI: - [m2] for surface areas - [m3] for volumes
Get help and syntax: help_geometry()
Notes
- This code is primarily illustrative. In a production system, you may expand the geometry classes, refine orientation logic for connectors, handle partial overlaps, or integrate with a 3D transform library for more sophisticated shape placement.
- The overlap deduction for composite shapes is simplified. It subtracts
2 * (minimum overlapping face area)
from the total surface area, which assumes a perfect “face-to-face” join.
Dependencies
- Python 3.x
check_units()
function from thelayer
module
@version: 1.0 @project: SFPPy - SafeFoodPackaging Portal in Python initiative @author: INRAE\olivier.vitrac@agroparistech.fr @licence: MIT @Date: 2024-10-28, rev. 2025-02-22
===============================================================================
Expand source code
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
===============================================================================
SFPPy Module: Geometry
===============================================================================
Provides a framework for defining 3D packaging geometries and their surface/volume properties.
Supports standalone shapes and composite structures for packaging simulations.
**Main Components:**
- **Shape3D** (Base class for 3D shapes)
- Subclasses: `Cylinder`, `Cone`, `RectangularPrism`, `Sphere`, `SquarePyramid`, `Hemisphere`
- Implements `_compute_volume()`, `_compute_surface_area()`, `_compute_connectors()`
- **CompositeShape** (Combines multiple shapes while adjusting for overlapping volumes and shared faces)
- **Packaging3D** (High-level interface for working with packaging geometries)
- **Connector** (Defines interfaces between shapes for composite construction)
- **SHAPE_REGISTRY** (Maps packaging terms like "can" or "bottle" to their geometric models)
**Integration with SFPPy Modules:**
- Uses `check_units()` from `layer.py` to convert dimensions into SI units.
- Supports `migration.py` by providing accurate volume and surface area computations for mass transfer models.
Example:
```python
from patankar.geometry import Packaging3D
pkg = Packaging3D('bottle', body_radius=(5, 'cm'), body_height=(20, 'cm'))
vol, area = pkg.get_volume_and_area()
```
===============================================================================
Details
===============================================================================
Purpose:
This module provides a framework for defining and combining various
three-dimensional packaging shapes—both simple (e.g., cylinders, cones,
rectangular prisms) and composite (e.g., 'bottle' = large cylinder + narrow
cylinder). It also calculates each shape’s internal volume (in m³) and
internal surface area (in m²).
Overview:
1. **Shape3D and Subclasses**:
- Each subclass (Cylinder, Cone, RectangularPrism, Sphere, SquarePyramid,
Hemisphere) implements:
* `_compute_volume()` and `_compute_surface_area()`.
* `_compute_connectors()`, which returns a list of `Connector` objects
representing the flat faces that can potentially connect to other
shapes. Connectors have a face area and an axis (normal vector).
- Connectors allow shapes to “snap” together in a `CompositeShape`.
2. **CompositeShape**:
- Manages multiple shapes added together, summing volumes and surface
areas, minus overlaps along shared connector faces.
- Uses `add_shape(...)` to join a new shape to any existing sub-shape
via matching connector orientations. Overlapping face area is removed
from the total surface area calculation.
3. **Synonyms and Shape Registry**:
- A dictionary `SHAPE_REGISTRY` maps real-world packaging names (like
"can", "box", "glass") to their corresponding Shape3D classes.
- Some "synonyms" map to more complex, composite constructs. For example,
the name "bottle" creates a `CompositeShape` of two cylinders (body +
neck).
4. **Units**:
- Dimensions can be given either as floats in meters or as `(value, "unit")`
pairs. The helper `_to_m(...)` uses `check_units` (imported from
`layer`) to convert numeric values to SI units (meters).
5. **Packaging3D**:
- A high-level interface for creating either a single shape or a
composite shape by name and keyword arguments. It returns volume
(in m³) and surface area (in m²) via `.get_volume_and_area()`.
Usage Example:
from patankar.packaging import Packaging3D
# Create a 'bottle' (two stacked cylinders) by specifying body and neck dims
pkg = Packaging3D(
'bottle',
body_radius=(5, 'cm'),
body_height=(20, 'cm'),
neck_radius=(2, 'cm'),
neck_height=(5, 'cm')
)
vol, area = pkg.get_volume_and_area()
print("Volume (m^3):", vol)
print("Surface Area (m^2):", area)
# Create a single shape (e.g., 'can' which is a cylinder) with radius
# and height specified in centimeters
pkg2 = Packaging3D('can', radius=(4, 'cm'), height=(12, 'cm'))
vol2, area2 = pkg2.get_volume_and_area()
About units:
All lengths can be given:
- without units: all lengths are assumed to be in meters (i.e., their SI unit)
- with units by using a tupple (value,"unit"), where unit can be m,dm,cm,mm,um,nm...
Input units can be heterogenerous, the result is always SI:
- [m**2] for surface areas
- [m**3] for volumes
Get help and syntax:
help_geometry()
Notes:
- This code is primarily illustrative. In a production system, you may expand
the geometry classes, refine orientation logic for connectors, handle
partial overlaps, or integrate with a 3D transform library for more
sophisticated shape placement.
- The overlap deduction for composite shapes is simplified. It subtracts
`2 * (minimum overlapping face area)` from the total surface area, which
assumes a perfect “face-to-face” join.
Dependencies:
- Python 3.x
- `check_units` function from the `layer` module
@version: 1.0
@project: SFPPy - SafeFoodPackaging Portal in Python initiative
@author: INRAE\\olivier.vitrac@agroparistech.fr
@licence: MIT
@Date: 2024-10-28, rev. 2025-02-22
===============================================================================
"""
# %% Dependencies
import math
import numpy as np
from collections import defaultdict
from patankar.layer import check_units
__all__ = ['CompositeShape', 'Cone', 'Connector', 'Cylinder', 'Hemisphere', 'OpenCone', 'OpenCylinder1', 'OpenCylinder2', 'OpenPrism1', 'OpenPrism2', 'OpenSquare1', 'OpenSquare2', 'Packaging3D', 'RectangularPrism', 'Shape3D', 'Sphere', 'SquarePyramid', 'check_units', 'create_shape_by_name', 'get_all_shapes_info', 'get_geometries_and_synonyms', 'help_geometry']
__project__ = "SFPPy"
__author__ = "Olivier Vitrac"
__copyright__ = "Copyright 2022"
__credits__ = ["Olivier Vitrac"]
__license__ = "MIT"
__maintainer__ = "Olivier Vitrac"
__email__ = "olivier.vitrac@agroparistech.fr"
__version__ = "1.2"
# %% Helper functions
# Convert lengths to SI units [m]
def _to_m(value):
"""
Convert a dimension value to meters using check_units if it's a tuple.
Otherwise assume the value is already in meters.
"""
if isinstance(value, tuple):
val_in_m, _ = check_units(value) # check_units returns (value_in_SI, "m")
return val_in_m
else:
return value
# %% Private Classes
class Connector:
"""
Represents a 'connection face' on a shape:
- area: the connectable area (m^2)
- axis: a unit vector (tuple) indicating the orientation of the connector
- name: optionally label the connector (e.g. 'top', 'bottom', etc.)
"""
def __init__(self, area, axis=(0, 0, 1), name=""):
self.area = area
# Normalize axis for safety
mag = math.sqrt(axis[0]**2 + axis[1]**2 + axis[2]**2)
if mag > 0:
self.axis = (axis[0]/mag, axis[1]/mag, axis[2]/mag)
else:
self.axis = axis
self.name = name
def __repr__(self):
"""String representation of the Connector object."""
axis_str = f"({self.axis[0]:.2f}, {self.axis[1]:.2f}, {self.axis[2]:.2f})"
name_str = f"'{self.name}'" if self.name else "(unnamed)"
print(f"Connector(name={name_str}, area={self.area.item():.4g} m², axis={axis_str})")
return str(self)
def __str__(self):
"""Formatted representation of the connector"""
return f"<{self.__class__.__name__}: {self.name}>"
class Shape3D:
"""
Base class for a 3D shape. Subclasses must implement:
- _compute_volume()
- _compute_surface_area()
- _compute_connectors() -> list of Connector objects
"""
def __init__(self, **dimensions):
# Convert every dimension to meters
self.dimensions = {k: _to_m(v) for k, v in dimensions.items()}
def volume(self):
return self._compute_volume()
def surface_area(self):
return self._compute_surface_area()
def connectors(self):
return self._compute_connectors()
def _compute_volume(self):
raise NotImplementedError
def _compute_surface_area(self):
raise NotImplementedError
def _compute_connectors(self):
"""
Return a list of Connector objects that represent the shape’s
possible connections. By default, shapes with no flat faces return [].
"""
return []
def __repr__(self):
"""String representation of the Shape3D object."""
class_name = self.__class__.__name__
# Convert numpy arrays to scalars before formatting
dimensions_str = ", ".join(f"{k}={v.item():.4g} m" if isinstance(v, np.ndarray) else f"{k}={v:.4f} m"
for k, v in self.dimensions.items())
vol = self.volume()
surf = self.surface_area()
connectors = self.connectors()
connector_str = (
"\n - ".join(repr(c) for c in connectors) if connectors else "None"
)
print(
f"{class_name}(\n"
f" Dimensions: {dimensions_str}\n"
f" Volume: {vol.item():.4g} m³\n"
f" Surface Area: {surf.item():.4g} m²\n"
f" Connectors:\n - {connector_str}\n"
f")"
)
return str(self)
def __str__(self):
"""Formatted string representing the 3D shape"""
n = len(self.connectors())
return f"<{self.__class__.__name__} with {n} connector{'s' if n>1 else ''}>"
# ----------------------------------------------------------------------------
# Basic shapes
# ----------------------------------------------------------------------------
class Cylinder(Shape3D):
"""
A cylinder with radius=r and height=h.
Has two connectors (top and bottom).
"""
def _compute_volume(self):
r = self.dimensions['radius']
h = self.dimensions['height']
return math.pi * r**2 * h
def _compute_surface_area(self):
r = self.dimensions['radius']
h = self.dimensions['height']
# Full cylinder: side + 2 ends
return 2.0 * math.pi * r * h + 2.0 * math.pi * r**2
def _compute_connectors(self):
"""
Two circular faces: top (normal +z), bottom (normal -z).
"""
r = self.dimensions['radius']
area_face = math.pi * r**2
c_top = Connector(area=area_face, axis=(0,0,1), name="cylinder_top")
c_bottom = Connector(area=area_face, axis=(0,0,-1), name="cylinder_bottom")
return [c_top, c_bottom]
class Cone(Shape3D):
"""
A cone with radius=r, height=h.
Typically only 1 connectable face: the circular base (normal -z).
"""
def _compute_volume(self):
r = self.dimensions['radius']
h = self.dimensions['height']
return (1.0/3.0) * math.pi * r**2 * h
def _compute_surface_area(self):
r = self.dimensions['radius']
h = self.dimensions['height']
slant = math.sqrt(r**2 + h**2)
base_area = math.pi * r**2
lateral_area = math.pi * r * slant
return base_area + lateral_area
def _compute_connectors(self):
r = self.dimensions['radius']
area_face = math.pi * r**2
# We'll define the base as normal -z
return [Connector(area=area_face, axis=(0,0,-1), name="cone_base")]
class RectangularPrism(Shape3D):
"""
A rectangular prism with length=l, width=w, height=h.
Has 6 connectors for each face.
"""
def _compute_volume(self):
l = self.dimensions['length']
w = self.dimensions['width']
h = self.dimensions['height']
return l * w * h
def _compute_surface_area(self):
l = self.dimensions['length']
w = self.dimensions['width']
h = self.dimensions['height']
return 2.0 * (l*w + w*h + h*l)
def _compute_connectors(self):
l = self.dimensions['length']
w = self.dimensions['width']
h = self.dimensions['height']
# areas
area_lw = l * w
area_wh = w * h
area_hl = h * l
# Each face axis.
# We'll define +z, -z, +y, -y, +x, -x as possible "connectors".
return [
Connector(area=area_lw, axis=(0,0, 1), name="top_face"),
Connector(area=area_lw, axis=(0,0,-1), name="bottom_face"),
Connector(area=area_wh, axis=(0, 1,0), name="front_face"),
Connector(area=area_wh, axis=(0,-1,0), name="back_face"),
Connector(area=area_hl, axis=( 1,0,0), name="right_face"),
Connector(area=area_hl, axis=(-1,0,0), name="left_face")
]
class Sphere(Shape3D):
"""
A sphere with radius=r.
In a strict sense, no perfectly flat 'connector' faces exist.
So we typically return [] for connectors.
"""
def _compute_volume(self):
r = self.dimensions['radius']
return (4.0/3.0)*math.pi*(r**3)
def _compute_surface_area(self):
r = self.dimensions['radius']
return 4.0*math.pi*(r**2)
def _compute_connectors(self):
# Spheres have no truly flat face to connect.
return []
class SquarePyramid(Shape3D):
"""
Square-based pyramid with side=a and height=h.
Has 1 connectable face (square base) with normal -z (assuming apex up).
"""
def _compute_volume(self):
a = self.dimensions['side']
h = self.dimensions['height']
return (a**2 * h) / 3.0
def _compute_surface_area(self):
a = self.dimensions['side']
h = self.dimensions['height']
base_area = a**2
# Slant height
slant = math.sqrt((a/2.0)**2 + h**2)
# Four triangular faces
lateral_area = a * slant * 2.0 # Because each triangle is (a*slant)/2, times 4 => 2*a*slant
return base_area + lateral_area
def _compute_connectors(self):
a = self.dimensions['side']
# The base area is a^2
return [Connector(area=a**2, axis=(0,0,-1), name="pyramid_base")]
class Hemisphere(Shape3D):
"""
Hemisphere with radius=r.
One connector (the flat circular base).
"""
def _compute_volume(self):
r = self.dimensions['radius']
return (2.0/3.0)*math.pi*(r**3)
def _compute_surface_area(self):
r = self.dimensions['radius']
# Curved surface area = 2πr^2
# The flat cross-section area (open) = πr^2
# If it's closed, we might add that, but typically "hemisphere" is open.
# So total "internal" area might be 3πr^2 if we consider the open face.
return 3.0*math.pi*(r**2)
def _compute_connectors(self):
r = self.dimensions['radius']
return [Connector(area=math.pi*r**2, axis=(0,0,-1), name="hemisphere_flat")]
class OpenCylinder1(Shape3D):
"""
An open cylinder with exactly one open end (like a glass, pot, or jar).
Volume:
π * r^2 * h
Surface area:
Lateral area (2πrh) + base area (πr^2) => 2πrh + πr^2
Connectors:
Only one at the bottom (circular face).
"""
def _compute_volume(self):
r = self.dimensions['radius']
h = self.dimensions['height']
return math.pi * r**2 * h
def _compute_surface_area(self):
r = self.dimensions['radius']
h = self.dimensions['height']
lateral_area = 2.0 * math.pi * r * h
bottom_area = math.pi * r**2
return lateral_area + bottom_area
def _compute_connectors(self):
r = self.dimensions['radius']
bottom_area = math.pi * r**2
return [Connector(area=bottom_area, axis=(0, 0, -1), name="open_cylinder1_bottom")]
class OpenCylinder2(Shape3D):
"""
An open cylinder with two open ends (like a straw or tube).
Volume:
π * r^2 * h
Surface area:
Only lateral area => 2πrh
(No top or bottom disk, since both ends are open.)
Connectors:
Two (top and bottom), each with area πr^2.
"""
def _compute_volume(self):
r = self.dimensions['radius']
h = self.dimensions['height']
return math.pi * r**2 * h
def _compute_surface_area(self):
r = self.dimensions['radius']
h = self.dimensions['height']
# No bases since both ends are open
return 2.0 * math.pi * r * h
def _compute_connectors(self):
r = self.dimensions['radius']
area_face = math.pi * r**2
# top face (normal +z) and bottom face (normal -z)
c_top = Connector(area=area_face, axis=(0,0, 1), name="open_cylinder2_top")
c_bottom = Connector(area=area_face, axis=(0,0,-1), name="open_cylinder2_bottom")
return [c_top, c_bottom]
class OpenSquare1(Shape3D):
"""
A square-based box with ONE open face (like an open-top box).
Required dimensions:
side (the length of each side of the square base)
height
Volume:
side^2 * height
Surface area:
4 * side * height + (bottom face area)
= (4 * side * height) + (side^2)
Connectors:
One connector at the open face (the top).
- The bottom is closed, so no connector there.
"""
def _compute_volume(self):
s = self.dimensions['side']
h = self.dimensions['height']
return s * s * h
def _compute_surface_area(self):
s = self.dimensions['side']
h = self.dimensions['height']
# Side walls: 4 * s * h
# Bottom: s^2
return (4.0 * s * h) + (s**2)
def _compute_connectors(self):
"""
The open face is the top: area = side^2, normal +z
"""
s = self.dimensions['side']
top_area = s**2
return [Connector(area=top_area, axis=(0,0,1), name="open_square1_top")]
class OpenSquare2(Shape3D):
"""
A square-based box with TWO open faces (no top, no bottom).
Required dimensions:
side
height
Volume:
side^2 * height
Surface area:
Only the 4 vertical walls => 4 * side * height
Connectors:
Two connectors: top (+z) and bottom (-z).
"""
def _compute_volume(self):
s = self.dimensions['side']
h = self.dimensions['height']
return s * s * h
def _compute_surface_area(self):
s = self.dimensions['side']
h = self.dimensions['height']
# No top or bottom => 4 side walls
return 4.0 * s * h
def _compute_connectors(self):
s = self.dimensions['side']
area_face = s**2
top_connector = Connector(area=area_face, axis=(0,0,1), name="open_square2_top")
bot_connector = Connector(area=area_face, axis=(0,0,-1), name="open_square2_bottom")
return [top_connector, bot_connector]
class OpenPrism1(Shape3D):
"""
A rectangular prism with ONE open face.
Required dimensions:
length, width, height
Volume:
length * width * height
Surface area:
(Sum of all faces) - area of the open face
i.e. 2*(lw + lh + wh) - lw (assuming top is open).
So total = lw + 2*(lh + wh).
Connectors:
One connector at the open face.
By convention, let's treat the "top" (normal +z) as open.
That means the bottom is length x width, and sides are intact.
"""
def _compute_volume(self):
l = self.dimensions['length']
w = self.dimensions['width']
h = self.dimensions['height']
return l * w * h
def _compute_surface_area(self):
l = self.dimensions['length']
w = self.dimensions['width']
h = self.dimensions['height']
total_closed = 2.0*(l*w + w*h + h*l)
# Open face is the top (area = l*w).
return total_closed - (l*w)
def _compute_connectors(self):
"""
The open face is the top: area = l*w, normal +z.
"""
l = self.dimensions['length']
w = self.dimensions['width']
top_area = l * w
return [Connector(area=top_area, axis=(0,0,1), name="open_prism1_top")]
class OpenPrism2(Shape3D):
"""
A rectangular prism with TWO open faces (no top, no bottom).
Required dimensions:
length, width, height
Volume:
length * width * height
Surface area:
(Sum of all faces) - 2*(area of top + bottom)
i.e. 2*(l*w + w*h + h*l) - 2*(l*w)
= 2*(w*h + h*l)
Connectors:
Two connectors: top (+z), bottom (-z).
"""
def _compute_volume(self):
l = self.dimensions['length']
w = self.dimensions['width']
h = self.dimensions['height']
return l * w * h
def _compute_surface_area(self):
l = self.dimensions['length']
w = self.dimensions['width']
h = self.dimensions['height']
# Full closed prism area: 2*(lw + lh + wh)
# Remove top (lw) and bottom (lw), total of 2*lw
return 2.0*(l*h + w*h) # Just the 4 side faces
def _compute_connectors(self):
l = self.dimensions['length']
w = self.dimensions['width']
face_area = l * w
top_connector = Connector(area=face_area, axis=(0,0,1), name="open_prism2_top")
bot_connector = Connector(area=face_area, axis=(0,0,-1), name="open_prism2_bottom")
return [top_connector, bot_connector]
class OpenCone(Shape3D):
"""
A cone with the base removed, leaving a single open circular face.
Required dimensions:
radius, height
Volume:
Same as a full cone => (1/3)*π*r^2*h
Surface area:
Only the lateral surface => π*r*sqrt(r^2 + h^2)
(No base area since it's open.)
Connectors:
One connector at the base (the open circle).
"""
def _compute_volume(self):
r = self.dimensions['radius']
h = self.dimensions['height']
return (1.0/3.0) * math.pi * (r**2) * h
def _compute_surface_area(self):
r = self.dimensions['radius']
h = self.dimensions['height']
slant = math.sqrt(r**2 + h**2)
# Lateral area only
return math.pi * r * slant
def _compute_connectors(self):
r = self.dimensions['radius']
area_face = math.pi * r**2
# The open face is the base, normal -z
return [Connector(area=area_face, axis=(0,0,-1), name="open_cone_base")]
# %% Main code
# ----------------------------------------------------------------------------
# Shape Registry (synonyms):
# ----------------------------------------------------------------------------
SHAPE_REGISTRY = {
# Existing geometry classes
"cylinder": Cylinder,
"cone": Cone,
"rectangular_prism": RectangularPrism,
"sphere": Sphere,
"square_pyramid": SquarePyramid,
"hemisphere": Hemisphere,
"cube": RectangularPrism, # special case => length=width=height
"box": RectangularPrism,
"prism": RectangularPrism,
"can": Cylinder,
"bowl": Hemisphere,
# Open geometries
"open_cylinder_1": OpenCylinder1,
"open_cylinder_2": OpenCylinder2,
"open_square1": OpenSquare1,
"open_square2": OpenSquare2,
"open_prism1": OpenPrism1,
"open_prism2": OpenPrism2,
"open_cone": OpenCone,
# Synonyms for open containers
"box_container": OpenPrism1,
# Synonyms for an open cylinder with one open end:
"glass": OpenCylinder1,
"pot": OpenCylinder1,
"jar": OpenCylinder1,
# Synonym for an open cylinder with two open ends:
"straw": OpenCylinder2,
}
# ----------------------------------------------------------------------------
# Shape Metadata:
# ----------------------------------------------------------------------------
SHAPE_PARAMETER_SPEC = {
"Cylinder": {
"required": ["radius", "height"],
"doc": (
"A standard cylinder with top and bottom faces.\n"
"Volume = π r² h. Surface area includes top and bottom disks."
),
},
"OpenCylinder1": {
"required": ["radius", "height"],
"doc": (
"A cylinder with exactly one open end (like a glass).\n"
"Volume = π r² h. Surface area = 2πrh + πr²."
),
},
"OpenCylinder2": {
"required": ["radius", "height"],
"doc": (
"A cylinder with two open ends (like a straw).\n"
"Volume = π r² h. Surface area = 2πrh (no top or bottom)."
),
},
"Cone": {
"required": ["radius", "height"],
"doc": (
"A full cone with closed circular base.\n"
"Volume = (1/3) π r² h. Surface area = base + lateral area."
),
},
"OpenCone": {
"required": ["radius", "height"],
"doc": (
"A cone with its base removed, leaving a single open circular face.\n"
"Volume = (1/3) π r² h. Surface area = π r * slant (no base)."
),
},
"RectangularPrism": {
"required": ["length", "width", "height"],
"doc": (
"A rectangular prism with all faces closed.\n"
"Volume = l * w * h. Surface area = 2(lw + lh + wh)."
),
},
"SquarePyramid": {
"required": ["side", "height"],
"doc": (
"A square-based pyramid.\n"
"Volume = (side² * height) / 3. Surface area = base + 4 triangles."
),
},
"Hemisphere": {
"required": ["radius"],
"doc": (
"A hemisphere (half a sphere) typically open at the flat side.\n"
"Volume = (2/3) π r³. Surface area = 3π r² (2πr² curved + πr² open)."
),
},
"Sphere": {
"required": ["radius"],
"doc": (
"A full sphere.\n"
"Volume = (4/3) π r³. Surface area = 4π r²."
),
},
"OpenSquare1": {
"required": ["side", "height"],
"doc": (
"A square-based box with ONE open face (like an open-top box).\n"
"Volume = side² * height.\n"
"Surface area = bottom + 4 walls = side² + 4 side * height."
),
},
"OpenSquare2": {
"required": ["side", "height"],
"doc": (
"A square-based box with TWO open faces (no top, no bottom).\n"
"Volume = side² * height.\n"
"Surface area = 4 side * height."
),
},
"OpenPrism1": {
"required": ["length", "width", "height"],
"doc": (
"A rectangular prism with ONE open face (e.g. open top).\n"
"Volume = l * w * h.\n"
"Surface area = 2(lw + lh + wh) - lw (remove top)."
),
},
"OpenPrism2": {
"required": ["length", "width", "height"],
"doc": (
"A rectangular prism with TWO open faces (no top, no bottom).\n"
"Volume = l * w * h.\n"
"Surface area = 2(lw + lh + wh) - 2(lw)."
),
},
}
# ----------------------------------------------------------------------------
# autodoc function:
# ----------------------------------------------------------------------------
def get_geometries_and_synonyms():
"""
Returns a dictionary mapping each shape class name
to a sorted list of all registry keys (synonyms) that point to it.
Example return:
{
"Cylinder": ["can", "cylinder"],
"OpenCylinder1": ["glass", "jar", "open_cylinder_1", "pot"],
...
}
"""
class_to_names = defaultdict(list)
for shape_name, shape_cls in SHAPE_REGISTRY.items():
# e.g. shape_cls.__name__ => 'Cylinder'
class_to_names[shape_cls.__name__].append(shape_name)
# Sort synonyms for a consistent presentation
result = {}
for cls_name, synonyms in class_to_names.items():
result[cls_name] = sorted(synonyms)
return result
def get_all_shapes_info():
"""
Returns a dictionary that combines synonyms, required parameters,
and doc strings for each shape class.
Example structure:
{
'Cylinder': {
'synonyms': ['can', 'cylinder'],
'required_params': ['radius', 'height'],
'doc': '...'
},
'OpenCylinder1': {
'synonyms': ['glass', 'jar', 'open_cylinder_1', 'pot'],
'required_params': ['radius', 'height'],
'doc': '...'
},
...
}
"""
shape_synonyms_map = get_geometries_and_synonyms() # {class_name -> [synonyms]}
all_info = {}
for cls_name, synonyms in shape_synonyms_map.items():
param_spec = SHAPE_PARAMETER_SPEC.get(cls_name, {})
required = param_spec.get("required", [])
doc_str = param_spec.get("doc", "No documentation available.")
all_info[cls_name] = {
"synonyms": synonyms,
"required_params": required,
"doc": doc_str,
}
return all_info
def help_geometry():
"""
Returns a pretty-formatted string showing all shape classes,
their synonyms, required parameters, and documentation.
Example usage:
help_geometry()
"""
info = get_all_shapes_info()
lines = []
lines.append("=== List of Implemented Geometries & Synonyms ===\n")
# Sort by class name for consistency
for cls_name in sorted(info.keys()):
synonyms = info[cls_name]["synonyms"]
required_params = info[cls_name]["required_params"]
doc_text = info[cls_name]["doc"]
lines.append(f"Shape Class: {cls_name}")
lines.append(f" Synonyms : {', '.join(synonyms)}")
lines.append(f" Required Params: {', '.join(required_params) if required_params else 'None'}")
# Optionally indent doc lines
doc_lines = doc_text.split("\n")
for dl in doc_lines:
lines.append(f" {dl}")
lines.append("-" * 60)
prettytxt = "\n".join(lines)
print(prettytxt)
# ----------------------------------------------------------------------------
# Main Factory Function:
# ----------------------------------------------------------------------------
def create_shape_by_name(name, **dimensions):
"""
Factory function to create either a single shape or a known composite shape.
For a direct shape, we find it in SHAPE_REGISTRY.
For a composite shape (like 'bottle'), we build it from simpler shapes.
"""
lower_name = name.lower()
# Example of a special composite shape: 'bottle'
# A "bottle" can be modeled as: a large cylinder (body) + smaller cylinder (neck)
# joined along their circular faces.
if lower_name == "bottle":
# We expect: body_radius, body_height, neck_radius, neck_height
body_radius = _to_m(dimensions["body_radius"])
body_height = _to_m(dimensions["body_height"])
neck_radius = _to_m(dimensions["neck_radius"])
neck_height = _to_m(dimensions["neck_height"])
# Create the big cylinder
body = Cylinder(radius=body_radius, height=body_height)
# Create the smaller cylinder for neck
neck = Cylinder(radius=neck_radius, height=neck_height)
# Combine them
bottle_composite = CompositeShape()
bottle_composite.add_shape(body)
bottle_composite.add_shape(neck, connect_axis=(0,0,1))
return bottle_composite
else:
# If it's a direct geometry name or a synonym:
shape_class = SHAPE_REGISTRY.get(lower_name, None)
if shape_class is None:
raise ValueError(f"Unknown shape or composite name '{name}'.")
# Special case for "cube": user might supply side=...
# or length=... In normal usage for a "cube" we do side=...
# We'll unify as rectangular prism with l=w=h=side
if lower_name == "cube":
# We expect 'side' => l=w=h
side = _to_m(dimensions['side'])
return RectangularPrism(length=side, width=side, height=side)
# Otherwise just create the shape with the given dimensions
return shape_class(**dimensions)
# ----------------------------------------------------------------------------
# Composite Geometry Class (valid approximation for 3D-->1D simulation)
# ----------------------------------------------------------------------------
class CompositeShape(Shape3D):
"""
Represents a shape made by combining multiple sub-shapes.
The total volume is the sum of sub-shapes' volumes.
For surface area, we use the naive sum minus the overlapped face
(twice the minimum connectable area).
In a real system you might track the exact arrangement in 3D space,
but here we keep it conceptual for demonstration:
- add_shape(shape, connect_axis): we try to connect the new shape along
a matching connector from an existing shape if axes align.
"""
def __init__(self):
super().__init__() # empty base
self.shapes = []
self.connections = [] # List of (shapeA, shapeB, overlap_area)
def add_shape(self, new_shape, connect_axis=None):
"""
Add a new shape to this composite. If connect_axis is provided,
we attempt to find a connector on 'new_shape' that matches
a connector on an existing shape in self.shapes.
For demonstration, we connect to the first available match.
Overlap area is min( area1, area2 ).
"""
if not self.shapes:
# If this is the first shape in the composite, just add it.
self.shapes.append(new_shape)
return
if connect_axis is None:
# No connector logic needed, just add it unconnected
self.shapes.append(new_shape)
return
# We'll search for a matching connector (by orientation) in the new_shape,
# and see if we can pair it with an existing shape's connector.
# If found, record the overlap.
# Step 1: gather new_shape connectors that match the connect_axis
new_connectors = [
c for c in new_shape.connectors()
if _axes_almost_equal(c.axis, connect_axis)
]
if not new_connectors:
# If we found no matching connectors, we just add shape
self.shapes.append(new_shape)
return
# Step 2: gather existing shapes' connectors that match the opposite axis
# i.e. connect_axis is (0,0,1), we might need existing axis to be (0,0,-1)
# so that they can connect “face-to-face”.
opposite_axis = tuple([-a for a in connect_axis])
for existing in self.shapes:
existing_connectors = [
c for c in existing.connectors()
if _axes_almost_equal(c.axis, opposite_axis)
]
if not existing_connectors:
continue # no suitable connector on that shape
# We’ll just connect the first pair of connectors we find
# (In a real system, you’d define a better approach or prompt the user.)
overlap_area = _compute_min_overlap(new_connectors[0], existing_connectors[0])
self.connections.append((new_shape, existing, overlap_area))
# Add the new shape to the composite
self.shapes.append(new_shape)
return
# If we get here, no suitable pairing was found. Just add shape unconnected
self.shapes.append(new_shape)
def _compute_volume(self):
return sum(s.volume() for s in self.shapes)
def _compute_surface_area(self):
"""
Sum of the sub-shapes’ surface areas minus
2 * sum of each overlapping face area.
"""
total_area = sum(s.surface_area() for s in self.shapes)
overlap = 0.0
for (shapeA, shapeB, overlap_area) in self.connections:
overlap += overlap_area
# We remove twice the overlap area for each connection
# (once from shape A, once from shape B).
return total_area - 2.0 * overlap
def _compute_connectors(self):
"""
As a composite, its external connectors might be complicated.
Here we return an empty list, or you could gather connectors
that are not overlapped.
"""
return []
# ----------------------------------------------------------------------------
# Helper functions
# ----------------------------------------------------------------------------
def _axes_almost_equal(axis1, axis2, tol=1e-5):
"""
Check if two unit vectors are nearly the same (or exactly opposite).
Because connectors are face normals, we consider "matching" to be
an axis that is within tolerance of the negative direction or the same,
depending on your design rules.
In the code above, for matching we do EXACT direction or EXACT opposite.
Adjust to your preference.
"""
# We'll check if either they're almost the same or almost exact opposites
dot = axis1[0]*axis2[0] + axis1[1]*axis2[1] + axis1[2]*axis2[2]
# If dot ~ 1.0 => same direction, if dot ~ -1.0 => opposite direction
return abs(abs(dot) - 1.0) < tol
def _compute_min_overlap(connector1, connector2):
"""
The overlap area is the minimum of the two connectable faces,
since you can't overlap more than the smaller face area.
"""
return min(connector1.area, connector2.area)
# %% Packaging3D
# ----------------------------------------------------------------------------
# High Level "Packaging3D" class
# ----------------------------------------------------------------------------
class Packaging3D:
"""
High-level interface that creates a shape/composite shape by name
and provides volume & surface area in SI units.
usage:
pkg = Packaging3D('bottle',
body_radius=(5, 'cm'),
body_height=(20, 'cm'),
neck_radius=(1.5, 'cm'),
neck_height=(5, 'cm'))
vol, area = pkg.get_volume_and_area()
"""
def __init__(self, geometry_name, **dimensions):
self.geometry_name = geometry_name
self.shape = create_shape_by_name(geometry_name, **dimensions)
def get_volume_and_area(self):
"""
Returns: (volume_in_m3, surface_area_in_m2)
"""
return (self.shape.volume(), self.shape.surface_area())
def __repr__(self):
"""String representation of Packaging3D, including the nested shape."""
print(f"Packaging3D(geometry_name='{self.geometry_name}', shape=\n{repr(self.shape)})")
return str(self)
def __str__(self):
"""Formatted string representation of the Packaging 3D"""
return f"<{self.__class__.__name__}: {self.geometry_name}>"
# --------------------------------------------------------------------
# For convenience, several operators have been overloaded
# packaging >> medium # sets the volume and the surfacearea
# --------------------------------------------------------------------
# method: medium._to(material) and its associated operator >>
def _to(self,other=None):
"""Propagates volume and area to a food instance"""
from patankar.food import foodphysics
if not isinstance(other,foodphysics):
raise TypeError(f"other must be a foodphysics instance not a {type(other).__name__}")
other.volume,other.surfacearea = self.get_volume_and_area()
# we record in other the properties inherited and then transferable
other.acknowledge(what={"volume","surfacearea"},category="geometry")
return other
def __rshift__(self, other):
"""Overloads >> to propagate to other."""
self._to(other)
return other
# %% Test
# ----------------------------------------------------------------------------
# USAGE EXAMPLES
# ----------------------------------------------------------------------------
if __name__ == "__main__":
# 1) A "bottle" composed of two cylinders
bottle_pkg = Packaging3D(
"bottle",
body_radius=(50, "mm"), # 0.05 m
body_height=(0.2, "m"), # 0.20 m
neck_radius=(2, "cm"), # 0.02 m
neck_height=0.05 # 0.05 m
)
b_vol, b_area = bottle_pkg.get_volume_and_area()
print("Bottle Volume (m**3):", b_vol)
print("Bottle Surface (m**2):", b_area)
# 2) A single shape: "can" is just a cylinder
can_pkg = Packaging3D("can", radius=(4,"cm"), height=(12,"cm"))
c_vol, c_area = can_pkg.get_volume_and_area()
print("Can Volume (m**3):", c_vol)
print("Can Surface (m**2):", c_area)
# 3) A "cube" with side=10 cm
cube_pkg = Packaging3D("cube", side=(10,"cm"))
cu_vol, cu_area = cube_pkg.get_volume_and_area()
print("Cube Volume (m**3):", cu_vol)
print("Cube Surface (m**2):", cu_area)
Functions
def check_units(value, ProvidedUnits=None, ExpectedUnits=None, defaulttempUnits='degC')
-
check numeric inputs and convert them to SI units
Expand source code
def check_units(value,ProvidedUnits=None,ExpectedUnits=None,defaulttempUnits="degC"): """ check numeric inputs and convert them to SI units """ # by convention, NumPy arrays and None are return unchanged (prevent nesting) if isinstance(value,np.ndarray) or value is None: return value,UnknownUnits if isinstance(value,tuple): if len(value) != 2: raise ValueError('value should be a tuple: (value,"unit"') ProvidedUnits = value[1] value = value[0] if isinstance(value,list): # the function is vectorized value = np.array(value) if {"degC", "K"} & {ProvidedUnits, ExpectedUnits}: # the value is a temperature ExpectedUnits = defaulttempUnits if ExpectedUnits is None else ExpectedUnits ProvidedUnits = ExpectedUnits if ProvidedUnits is None else ProvidedUnits if ProvidedUnits=="degC" and ExpectedUnits=="K": value += constants["T0K"] elif ProvidedUnits=="K" and ExpectedUnits=="degC": value -= constants["T0K"] return np.array([value]),ExpectedUnits else: # the value is not a temperature ExpectedUnits = NoUnits if ExpectedUnits is None else ExpectedUnits if (ProvidedUnits==ExpectedUnits) or (ProvidedUnits==NoUnits) or (ExpectedUnits==None): conversion =1 # no conversion needed units = ExpectedUnits if ExpectedUnits is not None else NoUnits else: q0,conversion,units = toSI(qSI(1,ProvidedUnits)) return np.array([value*conversion]),units
def create_shape_by_name(name, **dimensions)
-
Factory function to create either a single shape or a known composite shape.
For a direct shape, we find it in SHAPE_REGISTRY. For a composite shape (like 'bottle'), we build it from simpler shapes.
Expand source code
def create_shape_by_name(name, **dimensions): """ Factory function to create either a single shape or a known composite shape. For a direct shape, we find it in SHAPE_REGISTRY. For a composite shape (like 'bottle'), we build it from simpler shapes. """ lower_name = name.lower() # Example of a special composite shape: 'bottle' # A "bottle" can be modeled as: a large cylinder (body) + smaller cylinder (neck) # joined along their circular faces. if lower_name == "bottle": # We expect: body_radius, body_height, neck_radius, neck_height body_radius = _to_m(dimensions["body_radius"]) body_height = _to_m(dimensions["body_height"]) neck_radius = _to_m(dimensions["neck_radius"]) neck_height = _to_m(dimensions["neck_height"]) # Create the big cylinder body = Cylinder(radius=body_radius, height=body_height) # Create the smaller cylinder for neck neck = Cylinder(radius=neck_radius, height=neck_height) # Combine them bottle_composite = CompositeShape() bottle_composite.add_shape(body) bottle_composite.add_shape(neck, connect_axis=(0,0,1)) return bottle_composite else: # If it's a direct geometry name or a synonym: shape_class = SHAPE_REGISTRY.get(lower_name, None) if shape_class is None: raise ValueError(f"Unknown shape or composite name '{name}'.") # Special case for "cube": user might supply side=... # or length=... In normal usage for a "cube" we do side=... # We'll unify as rectangular prism with l=w=h=side if lower_name == "cube": # We expect 'side' => l=w=h side = _to_m(dimensions['side']) return RectangularPrism(length=side, width=side, height=side) # Otherwise just create the shape with the given dimensions return shape_class(**dimensions)
def get_all_shapes_info()
-
Returns a dictionary that combines synonyms, required parameters, and doc strings for each shape class.
Example structure: { 'Cylinder': { 'synonyms': ['can', 'cylinder'], 'required_params': ['radius', 'height'], 'doc': '…' }, 'OpenCylinder1': { 'synonyms': ['glass', 'jar', 'open_cylinder_1', 'pot'], 'required_params': ['radius', 'height'], 'doc': '…' }, … }
Expand source code
def get_all_shapes_info(): """ Returns a dictionary that combines synonyms, required parameters, and doc strings for each shape class. Example structure: { 'Cylinder': { 'synonyms': ['can', 'cylinder'], 'required_params': ['radius', 'height'], 'doc': '...' }, 'OpenCylinder1': { 'synonyms': ['glass', 'jar', 'open_cylinder_1', 'pot'], 'required_params': ['radius', 'height'], 'doc': '...' }, ... } """ shape_synonyms_map = get_geometries_and_synonyms() # {class_name -> [synonyms]} all_info = {} for cls_name, synonyms in shape_synonyms_map.items(): param_spec = SHAPE_PARAMETER_SPEC.get(cls_name, {}) required = param_spec.get("required", []) doc_str = param_spec.get("doc", "No documentation available.") all_info[cls_name] = { "synonyms": synonyms, "required_params": required, "doc": doc_str, } return all_info
def get_geometries_and_synonyms()
-
Returns a dictionary mapping each shape class name to a sorted list of all registry keys (synonyms) that point to it.
Example return: { "Cylinder": ["can", "cylinder"], "OpenCylinder1": ["glass", "jar", "open_cylinder_1", "pot"], … }
Expand source code
def get_geometries_and_synonyms(): """ Returns a dictionary mapping each shape class name to a sorted list of all registry keys (synonyms) that point to it. Example return: { "Cylinder": ["can", "cylinder"], "OpenCylinder1": ["glass", "jar", "open_cylinder_1", "pot"], ... } """ class_to_names = defaultdict(list) for shape_name, shape_cls in SHAPE_REGISTRY.items(): # e.g. shape_cls.__name__ => 'Cylinder' class_to_names[shape_cls.__name__].append(shape_name) # Sort synonyms for a consistent presentation result = {} for cls_name, synonyms in class_to_names.items(): result[cls_name] = sorted(synonyms) return result
def help_geometry()
-
Returns a pretty-formatted string showing all shape classes, their synonyms, required parameters, and documentation.
Example usage: help_geometry()
Expand source code
def help_geometry(): """ Returns a pretty-formatted string showing all shape classes, their synonyms, required parameters, and documentation. Example usage: help_geometry() """ info = get_all_shapes_info() lines = [] lines.append("=== List of Implemented Geometries & Synonyms ===\n") # Sort by class name for consistency for cls_name in sorted(info.keys()): synonyms = info[cls_name]["synonyms"] required_params = info[cls_name]["required_params"] doc_text = info[cls_name]["doc"] lines.append(f"Shape Class: {cls_name}") lines.append(f" Synonyms : {', '.join(synonyms)}") lines.append(f" Required Params: {', '.join(required_params) if required_params else 'None'}") # Optionally indent doc lines doc_lines = doc_text.split("\n") for dl in doc_lines: lines.append(f" {dl}") lines.append("-" * 60) prettytxt = "\n".join(lines) print(prettytxt)
Classes
class CompositeShape
-
Represents a shape made by combining multiple sub-shapes. The total volume is the sum of sub-shapes' volumes.
For surface area, we use the naive sum minus the overlapped face (twice the minimum connectable area).
In a real system you might track the exact arrangement in 3D space, but here we keep it conceptual for demonstration: - add_shape(shape, connect_axis): we try to connect the new shape along a matching connector from an existing shape if axes align.
Expand source code
class CompositeShape(Shape3D): """ Represents a shape made by combining multiple sub-shapes. The total volume is the sum of sub-shapes' volumes. For surface area, we use the naive sum minus the overlapped face (twice the minimum connectable area). In a real system you might track the exact arrangement in 3D space, but here we keep it conceptual for demonstration: - add_shape(shape, connect_axis): we try to connect the new shape along a matching connector from an existing shape if axes align. """ def __init__(self): super().__init__() # empty base self.shapes = [] self.connections = [] # List of (shapeA, shapeB, overlap_area) def add_shape(self, new_shape, connect_axis=None): """ Add a new shape to this composite. If connect_axis is provided, we attempt to find a connector on 'new_shape' that matches a connector on an existing shape in self.shapes. For demonstration, we connect to the first available match. Overlap area is min( area1, area2 ). """ if not self.shapes: # If this is the first shape in the composite, just add it. self.shapes.append(new_shape) return if connect_axis is None: # No connector logic needed, just add it unconnected self.shapes.append(new_shape) return # We'll search for a matching connector (by orientation) in the new_shape, # and see if we can pair it with an existing shape's connector. # If found, record the overlap. # Step 1: gather new_shape connectors that match the connect_axis new_connectors = [ c for c in new_shape.connectors() if _axes_almost_equal(c.axis, connect_axis) ] if not new_connectors: # If we found no matching connectors, we just add shape self.shapes.append(new_shape) return # Step 2: gather existing shapes' connectors that match the opposite axis # i.e. connect_axis is (0,0,1), we might need existing axis to be (0,0,-1) # so that they can connect “face-to-face”. opposite_axis = tuple([-a for a in connect_axis]) for existing in self.shapes: existing_connectors = [ c for c in existing.connectors() if _axes_almost_equal(c.axis, opposite_axis) ] if not existing_connectors: continue # no suitable connector on that shape # We’ll just connect the first pair of connectors we find # (In a real system, you’d define a better approach or prompt the user.) overlap_area = _compute_min_overlap(new_connectors[0], existing_connectors[0]) self.connections.append((new_shape, existing, overlap_area)) # Add the new shape to the composite self.shapes.append(new_shape) return # If we get here, no suitable pairing was found. Just add shape unconnected self.shapes.append(new_shape) def _compute_volume(self): return sum(s.volume() for s in self.shapes) def _compute_surface_area(self): """ Sum of the sub-shapes’ surface areas minus 2 * sum of each overlapping face area. """ total_area = sum(s.surface_area() for s in self.shapes) overlap = 0.0 for (shapeA, shapeB, overlap_area) in self.connections: overlap += overlap_area # We remove twice the overlap area for each connection # (once from shape A, once from shape B). return total_area - 2.0 * overlap def _compute_connectors(self): """ As a composite, its external connectors might be complicated. Here we return an empty list, or you could gather connectors that are not overlapped. """ return []
Ancestors
Methods
def add_shape(self, new_shape, connect_axis=None)
-
Add a new shape to this composite. If connect_axis is provided, we attempt to find a connector on 'new_shape' that matches a connector on an existing shape in self.shapes.
For demonstration, we connect to the first available match. Overlap area is min( area1, area2 ).
Expand source code
def add_shape(self, new_shape, connect_axis=None): """ Add a new shape to this composite. If connect_axis is provided, we attempt to find a connector on 'new_shape' that matches a connector on an existing shape in self.shapes. For demonstration, we connect to the first available match. Overlap area is min( area1, area2 ). """ if not self.shapes: # If this is the first shape in the composite, just add it. self.shapes.append(new_shape) return if connect_axis is None: # No connector logic needed, just add it unconnected self.shapes.append(new_shape) return # We'll search for a matching connector (by orientation) in the new_shape, # and see if we can pair it with an existing shape's connector. # If found, record the overlap. # Step 1: gather new_shape connectors that match the connect_axis new_connectors = [ c for c in new_shape.connectors() if _axes_almost_equal(c.axis, connect_axis) ] if not new_connectors: # If we found no matching connectors, we just add shape self.shapes.append(new_shape) return # Step 2: gather existing shapes' connectors that match the opposite axis # i.e. connect_axis is (0,0,1), we might need existing axis to be (0,0,-1) # so that they can connect “face-to-face”. opposite_axis = tuple([-a for a in connect_axis]) for existing in self.shapes: existing_connectors = [ c for c in existing.connectors() if _axes_almost_equal(c.axis, opposite_axis) ] if not existing_connectors: continue # no suitable connector on that shape # We’ll just connect the first pair of connectors we find # (In a real system, you’d define a better approach or prompt the user.) overlap_area = _compute_min_overlap(new_connectors[0], existing_connectors[0]) self.connections.append((new_shape, existing, overlap_area)) # Add the new shape to the composite self.shapes.append(new_shape) return # If we get here, no suitable pairing was found. Just add shape unconnected self.shapes.append(new_shape)
class Cone (**dimensions)
-
A cone with radius=r, height=h. Typically only 1 connectable face: the circular base (normal -z).
Expand source code
class Cone(Shape3D): """ A cone with radius=r, height=h. Typically only 1 connectable face: the circular base (normal -z). """ def _compute_volume(self): r = self.dimensions['radius'] h = self.dimensions['height'] return (1.0/3.0) * math.pi * r**2 * h def _compute_surface_area(self): r = self.dimensions['radius'] h = self.dimensions['height'] slant = math.sqrt(r**2 + h**2) base_area = math.pi * r**2 lateral_area = math.pi * r * slant return base_area + lateral_area def _compute_connectors(self): r = self.dimensions['radius'] area_face = math.pi * r**2 # We'll define the base as normal -z return [Connector(area=area_face, axis=(0,0,-1), name="cone_base")]
Ancestors
class Connector (area, axis=(0, 0, 1), name='')
-
Represents a 'connection face' on a shape: - area: the connectable area (m^2) - axis: a unit vector (tuple) indicating the orientation of the connector - name: optionally label the connector (e.g. 'top', 'bottom', etc.)
Expand source code
class Connector: """ Represents a 'connection face' on a shape: - area: the connectable area (m^2) - axis: a unit vector (tuple) indicating the orientation of the connector - name: optionally label the connector (e.g. 'top', 'bottom', etc.) """ def __init__(self, area, axis=(0, 0, 1), name=""): self.area = area # Normalize axis for safety mag = math.sqrt(axis[0]**2 + axis[1]**2 + axis[2]**2) if mag > 0: self.axis = (axis[0]/mag, axis[1]/mag, axis[2]/mag) else: self.axis = axis self.name = name def __repr__(self): """String representation of the Connector object.""" axis_str = f"({self.axis[0]:.2f}, {self.axis[1]:.2f}, {self.axis[2]:.2f})" name_str = f"'{self.name}'" if self.name else "(unnamed)" print(f"Connector(name={name_str}, area={self.area.item():.4g} m², axis={axis_str})") return str(self) def __str__(self): """Formatted representation of the connector""" return f"<{self.__class__.__name__}: {self.name}>"
class Cylinder (**dimensions)
-
A cylinder with radius=r and height=h. Has two connectors (top and bottom).
Expand source code
class Cylinder(Shape3D): """ A cylinder with radius=r and height=h. Has two connectors (top and bottom). """ def _compute_volume(self): r = self.dimensions['radius'] h = self.dimensions['height'] return math.pi * r**2 * h def _compute_surface_area(self): r = self.dimensions['radius'] h = self.dimensions['height'] # Full cylinder: side + 2 ends return 2.0 * math.pi * r * h + 2.0 * math.pi * r**2 def _compute_connectors(self): """ Two circular faces: top (normal +z), bottom (normal -z). """ r = self.dimensions['radius'] area_face = math.pi * r**2 c_top = Connector(area=area_face, axis=(0,0,1), name="cylinder_top") c_bottom = Connector(area=area_face, axis=(0,0,-1), name="cylinder_bottom") return [c_top, c_bottom]
Ancestors
class Hemisphere (**dimensions)
-
Hemisphere with radius=r. One connector (the flat circular base).
Expand source code
class Hemisphere(Shape3D): """ Hemisphere with radius=r. One connector (the flat circular base). """ def _compute_volume(self): r = self.dimensions['radius'] return (2.0/3.0)*math.pi*(r**3) def _compute_surface_area(self): r = self.dimensions['radius'] # Curved surface area = 2πr^2 # The flat cross-section area (open) = πr^2 # If it's closed, we might add that, but typically "hemisphere" is open. # So total "internal" area might be 3πr^2 if we consider the open face. return 3.0*math.pi*(r**2) def _compute_connectors(self): r = self.dimensions['radius'] return [Connector(area=math.pi*r**2, axis=(0,0,-1), name="hemisphere_flat")]
Ancestors
class OpenCone (**dimensions)
-
A cone with the base removed, leaving a single open circular face.
Required dimensions: radius, height
Volume
Same as a full cone => (1/3)πr^2h Surface area: Only the lateral surface => πr*sqrt(r^2 + h^2) (No base area since it's open.)
Connectors
One connector at the base (the open circle).
Expand source code
class OpenCone(Shape3D): """ A cone with the base removed, leaving a single open circular face. Required dimensions: radius, height Volume: Same as a full cone => (1/3)*π*r^2*h Surface area: Only the lateral surface => π*r*sqrt(r^2 + h^2) (No base area since it's open.) Connectors: One connector at the base (the open circle). """ def _compute_volume(self): r = self.dimensions['radius'] h = self.dimensions['height'] return (1.0/3.0) * math.pi * (r**2) * h def _compute_surface_area(self): r = self.dimensions['radius'] h = self.dimensions['height'] slant = math.sqrt(r**2 + h**2) # Lateral area only return math.pi * r * slant def _compute_connectors(self): r = self.dimensions['radius'] area_face = math.pi * r**2 # The open face is the base, normal -z return [Connector(area=area_face, axis=(0,0,-1), name="open_cone_base")]
Ancestors
class OpenCylinder1 (**dimensions)
-
An open cylinder with exactly one open end (like a glass, pot, or jar).
Volume
π * r^2 * h Surface area: Lateral area (2πrh) + base area (πr^2) => 2πrh + πr^2
Connectors
Only one at the bottom (circular face).
Expand source code
class OpenCylinder1(Shape3D): """ An open cylinder with exactly one open end (like a glass, pot, or jar). Volume: π * r^2 * h Surface area: Lateral area (2πrh) + base area (πr^2) => 2πrh + πr^2 Connectors: Only one at the bottom (circular face). """ def _compute_volume(self): r = self.dimensions['radius'] h = self.dimensions['height'] return math.pi * r**2 * h def _compute_surface_area(self): r = self.dimensions['radius'] h = self.dimensions['height'] lateral_area = 2.0 * math.pi * r * h bottom_area = math.pi * r**2 return lateral_area + bottom_area def _compute_connectors(self): r = self.dimensions['radius'] bottom_area = math.pi * r**2 return [Connector(area=bottom_area, axis=(0, 0, -1), name="open_cylinder1_bottom")]
Ancestors
class OpenCylinder2 (**dimensions)
-
An open cylinder with two open ends (like a straw or tube).
Volume
π * r^2 * h Surface area: Only lateral area => 2πrh (No top or bottom disk, since both ends are open.)
Connectors
Two (top and bottom), each with area πr^2.
Expand source code
class OpenCylinder2(Shape3D): """ An open cylinder with two open ends (like a straw or tube). Volume: π * r^2 * h Surface area: Only lateral area => 2πrh (No top or bottom disk, since both ends are open.) Connectors: Two (top and bottom), each with area πr^2. """ def _compute_volume(self): r = self.dimensions['radius'] h = self.dimensions['height'] return math.pi * r**2 * h def _compute_surface_area(self): r = self.dimensions['radius'] h = self.dimensions['height'] # No bases since both ends are open return 2.0 * math.pi * r * h def _compute_connectors(self): r = self.dimensions['radius'] area_face = math.pi * r**2 # top face (normal +z) and bottom face (normal -z) c_top = Connector(area=area_face, axis=(0,0, 1), name="open_cylinder2_top") c_bottom = Connector(area=area_face, axis=(0,0,-1), name="open_cylinder2_bottom") return [c_top, c_bottom]
Ancestors
class OpenPrism1 (**dimensions)
-
A rectangular prism with ONE open face.
Required dimensions: length, width, height
Volume
length * width * height Surface area: (Sum of all faces) - area of the open face i.e. 2(lw + lh + wh) - lw (assuming top is open). So total = lw + 2(lh + wh).
Connectors
One connector at the open face.
By convention, let's treat the "top" (normal +z) as open. That means the bottom is length x width, and sides are intact.
Expand source code
class OpenPrism1(Shape3D): """ A rectangular prism with ONE open face. Required dimensions: length, width, height Volume: length * width * height Surface area: (Sum of all faces) - area of the open face i.e. 2*(lw + lh + wh) - lw (assuming top is open). So total = lw + 2*(lh + wh). Connectors: One connector at the open face. By convention, let's treat the "top" (normal +z) as open. That means the bottom is length x width, and sides are intact. """ def _compute_volume(self): l = self.dimensions['length'] w = self.dimensions['width'] h = self.dimensions['height'] return l * w * h def _compute_surface_area(self): l = self.dimensions['length'] w = self.dimensions['width'] h = self.dimensions['height'] total_closed = 2.0*(l*w + w*h + h*l) # Open face is the top (area = l*w). return total_closed - (l*w) def _compute_connectors(self): """ The open face is the top: area = l*w, normal +z. """ l = self.dimensions['length'] w = self.dimensions['width'] top_area = l * w return [Connector(area=top_area, axis=(0,0,1), name="open_prism1_top")]
Ancestors
class OpenPrism2 (**dimensions)
-
A rectangular prism with TWO open faces (no top, no bottom).
Required dimensions: length, width, height
Volume
length * width * height Surface area: (Sum of all faces) - 2(area of top + bottom) i.e. 2(lw + wh + hl) - 2(lw) = 2(wh + hl)
Connectors
Two connectors: top (+z), bottom (-z).
Expand source code
class OpenPrism2(Shape3D): """ A rectangular prism with TWO open faces (no top, no bottom). Required dimensions: length, width, height Volume: length * width * height Surface area: (Sum of all faces) - 2*(area of top + bottom) i.e. 2*(l*w + w*h + h*l) - 2*(l*w) = 2*(w*h + h*l) Connectors: Two connectors: top (+z), bottom (-z). """ def _compute_volume(self): l = self.dimensions['length'] w = self.dimensions['width'] h = self.dimensions['height'] return l * w * h def _compute_surface_area(self): l = self.dimensions['length'] w = self.dimensions['width'] h = self.dimensions['height'] # Full closed prism area: 2*(lw + lh + wh) # Remove top (lw) and bottom (lw), total of 2*lw return 2.0*(l*h + w*h) # Just the 4 side faces def _compute_connectors(self): l = self.dimensions['length'] w = self.dimensions['width'] face_area = l * w top_connector = Connector(area=face_area, axis=(0,0,1), name="open_prism2_top") bot_connector = Connector(area=face_area, axis=(0,0,-1), name="open_prism2_bottom") return [top_connector, bot_connector]
Ancestors
class OpenSquare1 (**dimensions)
-
A square-based box with ONE open face (like an open-top box).
Required dimensions: side (the length of each side of the square base) height
Volume
side^2 * height Surface area: 4 * side * height + (bottom face area) = (4 * side * height) + (side^2)
Connectors
One connector at the open face (the top). - The bottom is closed, so no connector there.
Expand source code
class OpenSquare1(Shape3D): """ A square-based box with ONE open face (like an open-top box). Required dimensions: side (the length of each side of the square base) height Volume: side^2 * height Surface area: 4 * side * height + (bottom face area) = (4 * side * height) + (side^2) Connectors: One connector at the open face (the top). - The bottom is closed, so no connector there. """ def _compute_volume(self): s = self.dimensions['side'] h = self.dimensions['height'] return s * s * h def _compute_surface_area(self): s = self.dimensions['side'] h = self.dimensions['height'] # Side walls: 4 * s * h # Bottom: s^2 return (4.0 * s * h) + (s**2) def _compute_connectors(self): """ The open face is the top: area = side^2, normal +z """ s = self.dimensions['side'] top_area = s**2 return [Connector(area=top_area, axis=(0,0,1), name="open_square1_top")]
Ancestors
class OpenSquare2 (**dimensions)
-
A square-based box with TWO open faces (no top, no bottom).
Required dimensions: side height
Volume
side^2 * height Surface area: Only the 4 vertical walls => 4 * side * height
Connectors
Two connectors: top (+z) and bottom (-z).
Expand source code
class OpenSquare2(Shape3D): """ A square-based box with TWO open faces (no top, no bottom). Required dimensions: side height Volume: side^2 * height Surface area: Only the 4 vertical walls => 4 * side * height Connectors: Two connectors: top (+z) and bottom (-z). """ def _compute_volume(self): s = self.dimensions['side'] h = self.dimensions['height'] return s * s * h def _compute_surface_area(self): s = self.dimensions['side'] h = self.dimensions['height'] # No top or bottom => 4 side walls return 4.0 * s * h def _compute_connectors(self): s = self.dimensions['side'] area_face = s**2 top_connector = Connector(area=area_face, axis=(0,0,1), name="open_square2_top") bot_connector = Connector(area=area_face, axis=(0,0,-1), name="open_square2_bottom") return [top_connector, bot_connector]
Ancestors
class Packaging3D (geometry_name, **dimensions)
-
High-level interface that creates a shape/composite shape by name and provides volume & surface area in SI units.
usage: pkg = Packaging3D('bottle', body_radius=(5, 'cm'), body_height=(20, 'cm'), neck_radius=(1.5, 'cm'), neck_height=(5, 'cm')) vol, area = pkg.get_volume_and_area()
Expand source code
class Packaging3D: """ High-level interface that creates a shape/composite shape by name and provides volume & surface area in SI units. usage: pkg = Packaging3D('bottle', body_radius=(5, 'cm'), body_height=(20, 'cm'), neck_radius=(1.5, 'cm'), neck_height=(5, 'cm')) vol, area = pkg.get_volume_and_area() """ def __init__(self, geometry_name, **dimensions): self.geometry_name = geometry_name self.shape = create_shape_by_name(geometry_name, **dimensions) def get_volume_and_area(self): """ Returns: (volume_in_m3, surface_area_in_m2) """ return (self.shape.volume(), self.shape.surface_area()) def __repr__(self): """String representation of Packaging3D, including the nested shape.""" print(f"Packaging3D(geometry_name='{self.geometry_name}', shape=\n{repr(self.shape)})") return str(self) def __str__(self): """Formatted string representation of the Packaging 3D""" return f"<{self.__class__.__name__}: {self.geometry_name}>" # -------------------------------------------------------------------- # For convenience, several operators have been overloaded # packaging >> medium # sets the volume and the surfacearea # -------------------------------------------------------------------- # method: medium._to(material) and its associated operator >> def _to(self,other=None): """Propagates volume and area to a food instance""" from patankar.food import foodphysics if not isinstance(other,foodphysics): raise TypeError(f"other must be a foodphysics instance not a {type(other).__name__}") other.volume,other.surfacearea = self.get_volume_and_area() # we record in other the properties inherited and then transferable other.acknowledge(what={"volume","surfacearea"},category="geometry") return other def __rshift__(self, other): """Overloads >> to propagate to other.""" self._to(other) return other
Methods
def get_volume_and_area(self)
-
Returns: (volume_in_m3, surface_area_in_m2)
Expand source code
def get_volume_and_area(self): """ Returns: (volume_in_m3, surface_area_in_m2) """ return (self.shape.volume(), self.shape.surface_area())
class RectangularPrism (**dimensions)
-
A rectangular prism with length=l, width=w, height=h. Has 6 connectors for each face.
Expand source code
class RectangularPrism(Shape3D): """ A rectangular prism with length=l, width=w, height=h. Has 6 connectors for each face. """ def _compute_volume(self): l = self.dimensions['length'] w = self.dimensions['width'] h = self.dimensions['height'] return l * w * h def _compute_surface_area(self): l = self.dimensions['length'] w = self.dimensions['width'] h = self.dimensions['height'] return 2.0 * (l*w + w*h + h*l) def _compute_connectors(self): l = self.dimensions['length'] w = self.dimensions['width'] h = self.dimensions['height'] # areas area_lw = l * w area_wh = w * h area_hl = h * l # Each face axis. # We'll define +z, -z, +y, -y, +x, -x as possible "connectors". return [ Connector(area=area_lw, axis=(0,0, 1), name="top_face"), Connector(area=area_lw, axis=(0,0,-1), name="bottom_face"), Connector(area=area_wh, axis=(0, 1,0), name="front_face"), Connector(area=area_wh, axis=(0,-1,0), name="back_face"), Connector(area=area_hl, axis=( 1,0,0), name="right_face"), Connector(area=area_hl, axis=(-1,0,0), name="left_face") ]
Ancestors
class Shape3D (**dimensions)
-
Base class for a 3D shape. Subclasses must implement: - _compute_volume() - _compute_surface_area() - _compute_connectors() -> list of Connector objects
Expand source code
class Shape3D: """ Base class for a 3D shape. Subclasses must implement: - _compute_volume() - _compute_surface_area() - _compute_connectors() -> list of Connector objects """ def __init__(self, **dimensions): # Convert every dimension to meters self.dimensions = {k: _to_m(v) for k, v in dimensions.items()} def volume(self): return self._compute_volume() def surface_area(self): return self._compute_surface_area() def connectors(self): return self._compute_connectors() def _compute_volume(self): raise NotImplementedError def _compute_surface_area(self): raise NotImplementedError def _compute_connectors(self): """ Return a list of Connector objects that represent the shape’s possible connections. By default, shapes with no flat faces return []. """ return [] def __repr__(self): """String representation of the Shape3D object.""" class_name = self.__class__.__name__ # Convert numpy arrays to scalars before formatting dimensions_str = ", ".join(f"{k}={v.item():.4g} m" if isinstance(v, np.ndarray) else f"{k}={v:.4f} m" for k, v in self.dimensions.items()) vol = self.volume() surf = self.surface_area() connectors = self.connectors() connector_str = ( "\n - ".join(repr(c) for c in connectors) if connectors else "None" ) print( f"{class_name}(\n" f" Dimensions: {dimensions_str}\n" f" Volume: {vol.item():.4g} m³\n" f" Surface Area: {surf.item():.4g} m²\n" f" Connectors:\n - {connector_str}\n" f")" ) return str(self) def __str__(self): """Formatted string representing the 3D shape""" n = len(self.connectors()) return f"<{self.__class__.__name__} with {n} connector{'s' if n>1 else ''}>"
Subclasses
- CompositeShape
- Cone
- Cylinder
- Hemisphere
- OpenCone
- OpenCylinder1
- OpenCylinder2
- OpenPrism1
- OpenPrism2
- OpenSquare1
- OpenSquare2
- RectangularPrism
- Sphere
- SquarePyramid
Methods
def connectors(self)
-
Expand source code
def connectors(self): return self._compute_connectors()
def surface_area(self)
-
Expand source code
def surface_area(self): return self._compute_surface_area()
def volume(self)
-
Expand source code
def volume(self): return self._compute_volume()
class Sphere (**dimensions)
-
A sphere with radius=r. In a strict sense, no perfectly flat 'connector' faces exist. So we typically return [] for connectors.
Expand source code
class Sphere(Shape3D): """ A sphere with radius=r. In a strict sense, no perfectly flat 'connector' faces exist. So we typically return [] for connectors. """ def _compute_volume(self): r = self.dimensions['radius'] return (4.0/3.0)*math.pi*(r**3) def _compute_surface_area(self): r = self.dimensions['radius'] return 4.0*math.pi*(r**2) def _compute_connectors(self): # Spheres have no truly flat face to connect. return []
Ancestors
class SquarePyramid (**dimensions)
-
Square-based pyramid with side=a and height=h. Has 1 connectable face (square base) with normal -z (assuming apex up).
Expand source code
class SquarePyramid(Shape3D): """ Square-based pyramid with side=a and height=h. Has 1 connectable face (square base) with normal -z (assuming apex up). """ def _compute_volume(self): a = self.dimensions['side'] h = self.dimensions['height'] return (a**2 * h) / 3.0 def _compute_surface_area(self): a = self.dimensions['side'] h = self.dimensions['height'] base_area = a**2 # Slant height slant = math.sqrt((a/2.0)**2 + h**2) # Four triangular faces lateral_area = a * slant * 2.0 # Because each triangle is (a*slant)/2, times 4 => 2*a*slant return base_area + lateral_area def _compute_connectors(self): a = self.dimensions['side'] # The base area is a^2 return [Connector(area=a**2, axis=(0,0,-1), name="pyramid_base")]
Ancestors