Skip to content
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ VOLCO uses two configuration files: simulation settings and printer settings. Be
| `solver_tolerance` | Tolerance for volume conservation in the bisection method. | 0.0001 |
| `consider_acceleration` | Whether to consider acceleration in volume distribution. | false |
| `stl_ascii` | Whether to export STL in ASCII format (true) or binary (false). | false |
| `preview_mode` | If true, runs a fast, lightweight preview that traces the G-code path and fills extrusions as capsules. No physics or overlap checks. Intended for quick feedback before running the full simulation. | false |

### Printer Configuration

Expand All @@ -156,6 +157,12 @@ VOLCO uses two configuration files: simulation settings and printer settings. Be

- **consider_acceleration**: When true, the simulation accounts for acceleration and deceleration, which can provide more accurate results but increases computation time.

## Preview Mode

Preview mode is designed for fast, lightweight visualization of the print path. It traces the G-code path and fills extrusions as capsules (cylinders with rounded ends), matching the nozzle diameter. This mode skips all physics and overlap checks, making it ideal for quick feedback and rapid iteration before running the full, volume-conserving simulation.

If you want to run VOLCO in maximum speed mode (no volume conservation, no overlap checks), set `max_speed_mode: true` in your simulation config. This will trace the G-code path and fill voxels along it, suitable for fast preview, parallel, or GPU-optimized runs.

## Finite Element Analysis (FEA)

VOLCO includes a Finite Element Analysis (FEA) module that enables structural analysis of the simulated 3D printed parts. With just one line of code, you can analyze the structural behavior of your VOLCO simulation results:
Expand Down
1 change: 1 addition & 0 deletions app/configs/arguments.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,6 @@ def get_options():
parser.add_argument("--gcode", type=str)
parser.add_argument("--sim", type=str)
parser.add_argument("--printer", type=str)
parser.add_argument("--preview", action="store_true", help="Enable preview mode (fast, lightweight visualization)")

return parser.parse_args()
3 changes: 3 additions & 0 deletions app/configs/simulation.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,3 +53,6 @@ def _load_config_from_dict(self, config):
# Default acceleration and STL settings
self.consider_acceleration = config.get("consider_acceleration", False)
self.stl_ascii = config.get("stl_ascii", False)

# Preview mode (fast, lightweight visualization)
self.preview_mode = config.get("preview_mode", False)
15 changes: 15 additions & 0 deletions app/geometry/geometry_math.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,21 @@ def find_coordinates(indexes, voxel_size):

@staticmethod
def calculate_filled_volume(voxel_space, voxel_size):
# Accept either a raw ndarray or a VoxelSpace-like object with a
# `_filled_voxels_count` running counter. Prefer the running counter
# when available to avoid expensive full-array scans.
if hasattr(voxel_space, "_filled_voxels_count"):
return int(voxel_space._filled_voxels_count) * voxel_size ** 3

# If an object with `.space` is provided, try to use its counter first.
if hasattr(voxel_space, "space") and hasattr(voxel_space.space, "shape"):
# If the container itself tracks a counter, use it.
if hasattr(voxel_space, "_filled_voxels_count"):
return int(voxel_space._filled_voxels_count) * voxel_size ** 3
# Fallback to scanning the ndarray
return np.count_nonzero(voxel_space.space) * voxel_size ** 3

# Otherwise assume voxel_space is a numpy array
return np.count_nonzero(voxel_space) * voxel_size**3

def find_index(coordinate, voxel_size):
Expand Down
119 changes: 80 additions & 39 deletions app/geometry/sphere.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,10 @@ def deposit_sphere(
):
initial_radius = self.estimate_initial_radius(sphere_volume)

_, voxel_space = BisectionMethod().execute(
# `voxel_space` is expected to be a VoxelSpace object here. The
# bisection solver and inner functions will mutate and ultimately
# return that object so callers can read `.space` and counters.
_, voxel_space_out = BisectionMethod().execute(
self._deposit_sphere,
initial_point=initial_radius,
tolerance=solver_tolerance,
Expand All @@ -34,24 +37,56 @@ def deposit_sphere(
),
)

return voxel_space
return voxel_space_out

def fill_voxels(self, voxel_space_obj, radius, lower_indexes, upper_indexes):
# Accept either a VoxelSpace-like object (has `.space`) or a raw ndarray.
is_voxel_space = hasattr(voxel_space_obj, "space")
space = voxel_space_obj.space if is_voxel_space else voxel_space_obj

def fill_voxels(self, voxel_space, radius, lower_indexes, upper_indexes):
empty_voxels = GeometryMath.find_empty_voxels_in_space(
voxel_space, lower_indexes, upper_indexes
space, lower_indexes, upper_indexes
)

for voxel in empty_voxels:
voxel_coordinate = GeometryMath.find_coordinates(voxel, self.voxel_size)

distance_to_centre = GeometryMath.distance(
voxel_coordinate, self.centre_coordinates
)

if distance_to_centre <= radius + self.voxel_size * 1e-8:
voxel_space[tuple(voxel)] = 1

return voxel_space
if not empty_voxels:
return voxel_space_obj

# Convert to numpy array for vectorized operations
empty_voxels_np = np.array(empty_voxels)
# Calculate coordinates for all voxels at once
voxel_coords = self.voxel_size * (2 * (empty_voxels_np + 1) - 1) * 0.5
# Calculate distances to centre for all voxels
centre = np.array(self.centre_coordinates)
dists = np.linalg.norm(voxel_coords - centre, axis=1)
# Mask for voxels within radius
mask = dists <= radius + self.voxel_size * 1e-8

if not np.any(mask):
return voxel_space_obj

# Filter indices that should be filled
fill_indices = empty_voxels_np[mask]

# Advanced index assignment (vectorized)
xi = fill_indices[:, 0].astype(int)
yj = fill_indices[:, 1].astype(int)
zk = fill_indices[:, 2].astype(int)

# Before setting, count how many of these are actually zero (defensive)
current_vals = space[xi, yj, zk]
new_mask = current_vals == 0
n_new = int(np.count_nonzero(new_mask))
if n_new > 0:
# Assign into the ndarray `space` (in-place)
space[xi[new_mask], yj[new_mask], zk[new_mask]] = 1
# Update running counter only when we were given a VoxelSpace object
if is_voxel_space and hasattr(voxel_space_obj, "_filled_voxels_count"):
voxel_space_obj._filled_voxels_count += n_new

# Return the same type we were given
if is_voxel_space:
return voxel_space_obj
return space

def estimate_initial_radius(self, volume):
return (3.0 * volume / (4.0 * math.pi)) ** (1.0 / 3.0)
Expand Down Expand Up @@ -85,56 +120,62 @@ def find_sphere_limits(self, radius, nozzle_height):
"""

def deform_voxel_space_for_big_spheres(self, voxel_space, radius):
# Accept either a VoxelSpace object or a raw ndarray and return the same type.
max_indexes = [
self._find_index(coord + radius) for coord in self.centre_coordinates
]

vs = voxel_space
for axis_number in range(0, 3):
voxel_space = self._maybe_expand_voxel_space(
voxel_space, max_indexes[axis_number], axis_number
)
vs = self._maybe_expand_voxel_space(vs, max_indexes[axis_number], axis_number)

return voxel_space
return vs

def _maybe_expand_voxel_space(self, voxel_space, max_index, axis_number):
size = voxel_space.shape
def _maybe_expand_voxel_space(self, voxel_space_obj, max_index, axis_number):
# Accept either a VoxelSpace-like object (has `.space`) or a raw ndarray.
is_voxel_space = hasattr(voxel_space_obj, "space")
space = voxel_space_obj.space if is_voxel_space else voxel_space_obj

size = space.shape
index_size = size[axis_number]

if max_index < index_size:
return voxel_space
return voxel_space_obj

number_to_be_added = max_index - index_size + 1

new_size = list(size)
new_size[axis_number] = number_to_be_added
# Add a buffer to reduce the number of reallocations. Buffer is 20%
# of current size (rounded), but at least the minimum required.
buffer_layers = max(int(index_size * 0.2), 1)
layers_to_add = max(number_to_be_added, buffer_layers)

mat_add = np.zeros(new_size, dtype=np.int8)
# Create only the additional block to concatenate
mat_add_size = list(size)
mat_add_size[axis_number] = layers_to_add
mat_add = np.zeros(mat_add_size, dtype=np.int8)

voxel_space = np.concatenate((voxel_space, mat_add), axis=axis_number)
new_space = np.concatenate((space, mat_add), axis=axis_number)

return voxel_space
if is_voxel_space:
voxel_space_obj.space = new_space
return voxel_space_obj

def _deposit_sphere(self, radius, voxel_space, nozzle_height, target_volume):
copy_voxel_space = voxel_space.copy()
return new_space

copy_voxel_space = self.deform_voxel_space_for_big_spheres(
copy_voxel_space, radius
)
def _deposit_sphere(self, radius, voxel_space, nozzle_height, target_volume):
# Accept and return either a VoxelSpace object or a raw ndarray.
vs = self.deform_voxel_space_for_big_spheres(voxel_space, radius)

lower_indexes, upper_indexes = self.find_sphere_limits(radius, nozzle_height)

copy_voxel_space = self.fill_voxels(
copy_voxel_space, radius, lower_indexes, upper_indexes
)
vs = self.fill_voxels(vs, radius, lower_indexes, upper_indexes)

current_volume = GeometryMath.calculate_filled_volume(
copy_voxel_space, self.voxel_size
)
current_volume = GeometryMath.calculate_filled_volume(vs, self.voxel_size)

volume_overshoot = current_volume / target_volume - 1.0

return volume_overshoot, copy_voxel_space
# Return the computed overshoot and the (possibly mutated) voxel container
return volume_overshoot, vs

def _increase_solver_tolerance(self, radius_a, radius_b):
return radius_b - radius_a < self.voxel_size * 0.5
Expand Down
Loading