Skip to content

Commit 02ebe7b

Browse files
Allow Custom Voxel Dimension
1 parent 6d29866 commit 02ebe7b

File tree

5 files changed

+68
-45
lines changed

5 files changed

+68
-45
lines changed

README.md

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
## Features
99

1010
- [x] **Voxel-based Modeling:** Define 3D shapes by adding individual voxels (cubes).
11-
- [x] **Non-Uniform Voxel Dimensions:** Specify custom width, height, and depth for voxels.
11+
- [x] **Non-Uniform Voxel Dimensions:** Specify default non-uniform dimensions for a model, and override dimensions on a per-voxel basis.
1212
- [x] **Flexible Anchoring:** Position voxels using different anchor points ([`cubeforge.CubeAnchor`](cubeforge/constants.py)) like corners or centers.
1313
- [x] **STL Export:** Save the generated mesh to both ASCII and Binary STL file formats.
1414
- [x] **Simple API:** Easy-to-use interface with the core [`cubeforge.VoxelModel`](cubeforge/model.py) class.
@@ -23,7 +23,7 @@ pip install cubeforge
2323
```
2424

2525
**Install from source:**
26-
You can also clone the repository and install the package using pip:
26+
You can also clone the repository and install the package using `pip`:
2727

2828
```bash
2929
pip install .
@@ -48,10 +48,12 @@ model.add_voxel(1, 1, 0)
4848
# --- Or add multiple voxels at once ---
4949
# model.add_voxels([(0, 0, 0), (1, 0, 0), (1, 1, 0)])
5050

51-
# --- Example with non-uniform dimensions and different anchor ---
52-
model_non_uniform = cubeforge.VoxelModel(voxel_dimensions=(2.0, 1.0, 3.0))
53-
# Add a voxel centered at (0, 0, 0)
54-
model_non_uniform.add_voxel(0, 0, 0, anchor=cubeforge.CubeAnchor.CENTER)
51+
# --- Example with custom dimensions per voxel ---
52+
tower_model = cubeforge.VoxelModel(voxel_dimensions=(1.0, 1.0, 1.0))
53+
# Add a 1x1x1 base cube centered at (0,0,0)
54+
tower_model.add_voxel(0, 0, 0, anchor=cubeforge.CubeAnchor.CENTER)
55+
# Stack a wide, flat 3x0.5x3 cube on top of it
56+
tower_model.add_voxel(0, 0.5, 0, anchor=cubeforge.CubeAnchor.BOTTOM_CENTER, dimensions=(3.0, 0.5, 3.0))
5557

5658
# Define output path
5759
output_dir = "output"
@@ -68,7 +70,8 @@ print(f"Saved mesh to {output_filename}")
6870

6971
The [`examples/create_shapes.py`](examples/create_shapes.py ) script demonstrates various features, including:
7072
* Creating simple and complex shapes.
71-
* Using different voxel dimensions.
73+
* Using different default voxel dimensions.
74+
* Overriding dimensions for individual voxels.
7275
* Utilizing various [`CubeAnchor`](cubeforge/constants.py ) options.
7376
* Saving in both ASCII and Binary STL formats.
7477
* Generating a surface with random heights.
@@ -85,8 +88,8 @@ The output STL files will be saved in the [`examples`](examples ) directory.
8588

8689
* **[`cubeforge.VoxelModel`](cubeforge/model.py ):** The main class for creating and managing the voxel model.
8790
* [`__init__(self, voxel_dimensions=(1.0, 1.0, 1.0))`](cubeforge/model.py ): Initializes the model, optionally setting default voxel dimensions.
88-
* [`add_voxel(self, x, y, z, anchor=CubeAnchor.CORNER_NEG)`](cubeforge/model.py ): Adds a single voxel.
89-
* [`add_voxels(self, coordinates, anchor=CubeAnchor.CORNER_NEG)`](cubeforge/model.py ): Adds multiple voxels.
91+
* [`add_voxel(self, x, y, z, anchor=CubeAnchor.CORNER_NEG, dimensions=None)`](cubeforge/model.py ): Adds a single voxel, optionally with custom dimensions.
92+
* [`add_voxels(self, coordinates, anchor=CubeAnchor.CORNER_NEG, dimensions=None)`](cubeforge/model.py ): Adds multiple voxels, optionally with custom dimensions.
9093
* [`remove_voxel(self, x, y, z, anchor=CubeAnchor.CORNER_NEG)`](cubeforge/model.py ): Removes a voxel.
9194
* [`clear(self)`](cubeforge/model.py ): Removes all voxels.
9295
* [`generate_mesh(self)`](cubeforge/model.py ): Generates the triangle mesh data.

cubeforge/model.py

Lines changed: 47 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010

1111
class VoxelModel:
1212
"""
13-
Represents a 3D model composed of voxels aligned to a grid.
13+
Represents a 3D model composed of voxels.
1414
Each voxel can have independent dimensions (width, height, depth).
1515
1616
Allows adding voxels based on coordinates and anchor points, and exporting
@@ -24,20 +24,20 @@ def __init__(self, voxel_dimensions=(1.0, 1.0, 1.0)):
2424
Args:
2525
voxel_dimensions (tuple): A tuple of three positive numbers
2626
(width, height, depth) representing the
27-
size of each voxel in the X, Y, and Z
28-
dimensions, respectively. Defaults to (1.0, 1.0, 1.0).
27+
default size of each voxel.
2928
"""
3029
if not (isinstance(voxel_dimensions, (tuple, list)) and
3130
len(voxel_dimensions) == 3 and
3231
all(isinstance(dim, (int, float)) and dim > 0 for dim in voxel_dimensions)):
3332
raise ValueError("voxel_dimensions must be a tuple or list of three positive numbers (width, height, depth).")
3433
self.voxel_dimensions = tuple(float(dim) for dim in voxel_dimensions)
35-
# Stores the integer grid coordinates (ix, iy, iz) of each voxel.
36-
# The actual position depends on the grid coordinates and voxel_dimensions.
37-
self._voxels = set()
38-
logger.info(f"VoxelModel initialized with voxel_dimensions={self.voxel_dimensions}")
34+
# Stores voxel data as a dictionary:
35+
# key: integer grid coordinate (ix, iy, iz)
36+
# value: tuple of dimensions (width, height, depth) for that voxel
37+
self._voxels = {}
38+
logger.info(f"VoxelModel initialized with default voxel_dimensions={self.voxel_dimensions}")
3939

40-
def _calculate_min_corner(self, x, y, z, anchor):
40+
def _calculate_min_corner(self, x, y, z, anchor, dimensions):
4141
"""
4242
Calculates the minimum corner coordinates based on anchor point and voxel dimensions.
4343
@@ -48,14 +48,15 @@ def _calculate_min_corner(self, x, y, z, anchor):
4848
y (float): Y-coordinate of the anchor point.
4949
z (float): Z-coordinate of the anchor point.
5050
anchor (CubeAnchor): The anchor type.
51+
dimensions (tuple): The (width, height, depth) of the voxel.
5152
5253
Returns:
5354
tuple: (min_x, min_y, min_z) coordinates of the voxel's minimum corner.
5455
5556
Raises:
5657
ValueError: If an invalid anchor point is provided.
5758
"""
58-
size_x, size_y, size_z = self.voxel_dimensions
59+
size_x, size_y, size_z = dimensions
5960
half_x, half_y, half_z = size_x / 2.0, size_y / 2.0, size_z / 2.0
6061

6162
if anchor == CubeAnchor.CORNER_NEG:
@@ -73,7 +74,7 @@ def _calculate_min_corner(self, x, y, z, anchor):
7374

7475
return min_x, min_y, min_z
7576

76-
def add_voxel(self, x, y, z, anchor=CubeAnchor.CORNER_NEG):
77+
def add_voxel(self, x, y, z, anchor=CubeAnchor.CORNER_NEG, dimensions=None):
7778
"""
7879
Adds a voxel to the model. Replaces add_cube.
7980
@@ -84,33 +85,44 @@ def add_voxel(self, x, y, z, anchor=CubeAnchor.CORNER_NEG):
8485
anchor (CubeAnchor): The reference point within the voxel that
8586
(x, y, z) corresponds to. Defaults to
8687
CubeAnchor.CORNER_NEG.
88+
dimensions (tuple, optional): The (width, height, depth) for this
89+
specific voxel. If None, the model's
90+
default dimensions are used.
8791
"""
88-
min_x, min_y, min_z = self._calculate_min_corner(x, y, z, anchor)
92+
voxel_dims = self.voxel_dimensions if dimensions is None else tuple(float(d) for d in dimensions)
93+
if dimensions is not None and not (isinstance(voxel_dims, (tuple, list)) and
94+
len(voxel_dims) == 3 and
95+
all(isinstance(d, (int, float)) and d > 0 for d in voxel_dims)):
96+
raise ValueError("Custom dimensions must be a tuple or list of three positive numbers.")
8997

90-
# Calculate grid coordinates based on minimum corner and dimensions
91-
# Using round to snap to the nearest grid point.
98+
min_x, min_y, min_z = self._calculate_min_corner(x, y, z, anchor, voxel_dims)
99+
100+
# Calculate grid coordinates based on minimum corner and *default* dimensions
101+
# This ensures voxels snap to a consistent grid.
92102
grid_x = round(min_x / self.voxel_dimensions[0])
93103
grid_y = round(min_y / self.voxel_dimensions[1])
94104
grid_z = round(min_z / self.voxel_dimensions[2])
95105

96106
grid_coord = (grid_x, grid_y, grid_z)
97-
self._voxels.add(grid_coord)
107+
self._voxels[grid_coord] = voxel_dims
98108
# logger.debug(f"Added voxel at grid {grid_coord} (from anchor {anchor} at ({x},{y},{z}))")
99109

100110
# Alias add_cube to add_voxel for backward compatibility (optional, but can be helpful)
101111
add_cube = add_voxel
102112

103-
def add_voxels(self, coordinates, anchor=CubeAnchor.CORNER_NEG):
113+
def add_voxels(self, coordinates, anchor=CubeAnchor.CORNER_NEG, dimensions=None):
104114
"""
105115
Adds multiple voxels from an iterable. Replaces add_cubes.
106116
107117
Args:
108118
coordinates (iterable): An iterable of (x, y, z) tuples or lists.
109119
anchor (CubeAnchor): The anchor point to use for all voxels added
110120
in this call.
121+
dimensions (tuple, optional): The dimensions to apply to all voxels
122+
in this call. If None, defaults are used.
111123
"""
112124
for x_coord, y_coord, z_coord in coordinates:
113-
self.add_voxel(x_coord, y_coord, z_coord, anchor)
125+
self.add_voxel(x_coord, y_coord, z_coord, anchor, dimensions)
114126

115127
# Alias add_cubes to add_voxels
116128
add_cubes = add_voxels
@@ -126,14 +138,17 @@ def remove_voxel(self, x, y, z, anchor=CubeAnchor.CORNER_NEG):
126138
anchor (CubeAnchor): The reference point within the voxel that
127139
(x, y, z) corresponds to.
128140
"""
129-
min_x, min_y, min_z = self._calculate_min_corner(x, y, z, anchor)
141+
# Note: Removal does not need custom dimensions, as it identifies the
142+
# voxel by its position on the grid, which is calculated using default dimensions.
143+
min_x, min_y, min_z = self._calculate_min_corner(x, y, z, anchor, self.voxel_dimensions)
130144

131145
grid_x = round(min_x / self.voxel_dimensions[0])
132146
grid_y = round(min_y / self.voxel_dimensions[1])
133147
grid_z = round(min_z / self.voxel_dimensions[2])
134148

135149
grid_coord = (grid_x, grid_y, grid_z)
136-
self._voxels.discard(grid_coord)
150+
if grid_coord in self._voxels:
151+
del self._voxels[grid_coord]
137152
# logger.debug(f"Attempted removal at grid {grid_coord}")
138153

139154
# Alias remove_cube to remove_voxel
@@ -162,7 +177,7 @@ def generate_mesh(self):
162177

163178
logger.info(f"Generating mesh for {len(self._voxels)} voxels...")
164179
triangles = []
165-
size_x, size_y, size_z = self.voxel_dimensions # Use specific dimensions
180+
# Voxel dimensions are now fetched per-voxel, so size_x, etc. are defined inside the loop.
166181

167182
# Define faces by normal, neighbor offset, and vertex indices (0-7)
168183
# Vertex indices correspond to relative positions scaled by dimensions:
@@ -180,22 +195,27 @@ def generate_mesh(self):
180195
]
181196

182197
processed_faces = 0
183-
for gx, gy, gz in self._voxels:
184-
# Calculate the minimum corner based on grid coordinates and dimensions
185-
min_cx = gx * size_x
186-
min_cy = gy * size_y
187-
min_cz = gz * size_z
198+
for (gx, gy, gz), (size_x, size_y, size_z) in self._voxels.items():
199+
# Calculate the minimum corner based on grid coordinates and *default* dimensions
200+
min_cx = gx * self.voxel_dimensions[0]
201+
min_cy = gy * self.voxel_dimensions[1]
202+
min_cz = gz * self.voxel_dimensions[2]
188203

189-
# Calculate the 8 absolute vertex coordinates for this voxel
204+
# Calculate the 8 absolute vertex coordinates for this voxel using its specific dimensions
190205
verts = [
191206
(min_cx + (i % 2) * size_x, min_cy + ((i // 2) % 2) * size_y, min_cz + (i // 4) * size_z)
192207
for i in range(8)
193208
]
194209

195210
for normal, offset, indices in faces_data:
196211
neighbor_coord = (gx + offset[0], gy + offset[1], gz + offset[2])
212+
neighbor_dims = self._voxels.get(neighbor_coord)
197213

198-
if neighbor_coord not in self._voxels: # Exposed face
214+
# A face is exposed if there is no neighbor, OR if the neighbor
215+
# has different dimensions, which would create a complex partial
216+
# surface. For simplicity and correctness of the outer shell,
217+
# we will generate the face in the latter case.
218+
if not neighbor_dims or neighbor_dims != (size_x, size_y, size_z): # Exposed face
199219
processed_faces += 1
200220
# Get the four vertices for this face using the indices
201221
v0 = verts[indices[0]]
@@ -215,7 +235,7 @@ def save_mesh(self, filename, format='stl_binary', **kwargs):
215235
216236
Args:
217237
filename (str): The path to the output file.
218-
format (str): The desired output format identifier (e.g.,
238+
format (str): The desired output format identifier (e.g/,
219239
'stl_binary', 'stl_ascii'). Case-insensitive.
220240
Defaults to 'stl_binary'.
221241
**kwargs: Additional arguments passed directly to the specific

cubeforge/writers.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,5 +137,5 @@ def get_writer(format_id):
137137
if writer_class:
138138
return writer_class()
139139
else:
140-
supported_formats = ", ".join(_writer_map.keys())
140+
supported_formats = ", ".join(sorted(_writer_map.keys()))
141141
raise ValueError(f"Unsupported format: '{format_id}'. Supported formats are: {supported_formats}")

examples/create_shapes.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -106,13 +106,13 @@
106106

107107
for x in range(grid_size_x):
108108
for z in range(grid_size_z):
109-
total_height = min_height + random.randint(0, max_additional_height)
110-
for y in range(total_height):
111-
# Add voxel using corner anchor. Coordinates are direct due to 1x1x1 dimension.
112-
model6.add_voxel(x * voxel_dim[0],
113-
y * voxel_dim[1],
114-
z * voxel_dim[2],
115-
anchor=cubeforge.CubeAnchor.CORNER_NEG)
109+
total_height = min_height + random.random() * max_additional_height
110+
# Add voxel using corner anchor. Coordinates are direct due to 1x1x1 dimension.
111+
model6.add_voxel(x * voxel_dim[0],
112+
0,
113+
z * voxel_dim[2],
114+
dimensions=(voxel_dim[0], total_height, voxel_dim[2]),
115+
anchor=cubeforge.CubeAnchor.CORNER_NEG)
116116

117117
output_filename6 = os.path.join(output_dir, "random_height_surface.stl") # Use output_dir
118118
model6.save_mesh(output_filename6, format='stl_binary', solid_name="RandomHeightSurface")

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "cubeforge"
7-
version = "0.1.0"
7+
version = "0.2.0"
88
authors = [
99
{ name="Teddy van Jerry", email="me@teddy-van-jerry.org" },
1010
]

0 commit comments

Comments
 (0)