First large project I've attempted using CadQuery.
Goal was to make a modular dollhouse for my kids.
It's a tudor style house with inspiration drawn from google image searches.
The central idea is that it would be printed in roughly nine sections. With the floors and roof divided into three sections each.
I opted for the main rooms to be 175mm x 175mm x 175mm or roughly 7" x 7" x 7".
Which is about the limits of my print bed.
- CadQuery - Python Cad Libary
- CQ-Editor - GUI to see model changes
- cadqueryhelper - Shape primitives libraries, used for object repetition. (I wrote this)
- cqterrain - Primary library used to build the house (I authored this as well)
I opted for python code to make the cad model because I like making 3d models with code.
I've used OpenSCAD in the past, but it has limitations for large projects.
- Python has a large ecosystem
- Parts of the model can be broken up into smaller modules and tested in solation.
- Version control - I can review changes to the model before committing.
- Github - code is kept in the cloud in a private (or public) repo.
- Re-using code from one project to the next is pretty straight forward.
- Tedious, the project itself was 500+ lines of code just for the dollhouse itself.
- The re-usable api was expanded out as the model was being created, which slowed down development.
- If there are bugs in your logic tracking them down can be arduous, the generated models do not lend themselves well to unit tests.
The initial outline of the project.
The front is too boring
Shows the breakdown of the nine sections.
I ended up simplifying from the initial sketch.
I used Microsoft 3d Viewer to generate the lighting on the model
- Arches
- Stones
- Casement Windows
- Lattice Windows
- Roof
The arch cutouts are straight forward.
def make_arch_door(wall, length, width, height, floor_height):
# find the bottom of the wall to align to.
bottom = wall.faces("-Z").val()
#create the initial shape
cutout = (cq.Workplane(bottom.Center())
.box(length, width, height)
.translate((0,0,(height/2)+floor_height))
)
# round off the top
cutout = cutout.faces("Z").edges("Y").fillet((length/2)-.5)
#remove the arch from the wall
w = wall.cut(cutout)
return w
The arch is aligned to the bottom of the object it's being cut out of.
I opted, to write a pseudo-random stone pattern generator
Code for a stone section.
def add_stones(wall, length, height, wall_width, rotate=0, seed="test4"):
# static boxes to act as stone
tile = cq.Workplane("XY").box(10,10,2)
tile2 = cq.Workplane("XY").box(8,8,2)
tile3 = cq.Workplane("XY").box(6,12,2)
# create an array of the stones and chamfer / fillet to add interest to the shapes.
stone_list = [tile.chamfer(0.8), tile2.fillet(.5), tile3.chamfer(0.5)]
# This is the pattern generator
stones = stone.make_stones(stone_list, [12,12,2], columns = 14, rows = 3, seed=seed).rotate((0,1,0),(0,0,0), 90).rotate((0,0,1),(0,0,0), 90)
# Align the pattern and surround with a frame
stones = stones.translate((0,-2,-1*(height/2)+(24))).rotate((0,0,1),(0,0,0), rotate)
frame = window.frame(length, 2, 48).translate((0,-1*((1)+(wall_width/2)),-1*(height/2)+(24))).rotate((0,0,1),(0,0,0), rotate)
# Add the detailing to the room wall
return wall.add(stones).add(frame)
- The generated output of the stone pattern is defined by the seed.
- different seed means different stone placement.
Let's looks at cqterrain stone.make_stones code.
import cadquery as cq
import random
import math
def make_stones(parts, dim=[5,5,2], rows=2, columns=5, seed="test4"):
grid = cq.Assembly()
random.seed(seed)
# loop the rows
for row_i in range(rows):
row_offset = (dim[0] * row_i)
# loop the columns per row
for col_i in range(columns):
col_offset = (dim[1] * col_i)
col_push_x = 0
col_push_y = 0
if col_i % 2 == 1:
col_push_x = 0
col_push_y = 0
z_push=0
# move the part in a random direction along the x and y axis.
x_rand = random.randrange(-1*(math.floor(dim[0]/2)),(math.floor(dim[0]/2)))
y_rand = random.randrange(-1*(math.floor(dim[1]/2)),(math.floor(dim[1]/2)))
# choose a random part from the parts list
part_index = random.randrange(0,len(parts))
# add the part to the assembly
grid.add(parts[part_index], loc=cq.Location(cq.Vector(row_offset + col_push_x + x_rand, col_offset + col_push_y + y_rand, z_push)))
length = dim[1] * columns
width = dim[0] * rows
# dump the assembly out as a single compound
comp = grid.toCompound()
work = cq.Workplane("XZ").center(0, 0).workplane()
work.add(comp)
# zero out the grid
work = work.translate(((dim[0]/2),(dim[1]/2)))
work = work.translate((-1*(width/2),-1*(length/2)))
return work
- That's all of of it, the double for loop isn't ideal but I kept it for readability.
- I basically yoinked my code for laying a tile onto a grid, and modified it to support pseudo-random selection and placement.
- It's not perfect but it's good enough to give the impression of a stone pattern.
- The code isn't resource intensive since it's made up of very simple primitives.
Code for making the windows.
def casement_windows(wall, length, width, height, count, padding):
# Create the cut out the holes where the windows will be placed.
window_cutout = cq.Workplane().box(length, width, height)
window_cut_series = series(window_cutout, count, length_offset = padding)
# create the window frame, and grill.
i_window = window.frame(length, width+3, height)
grill = window.grill(length=length, width=4, height=height, rows=4, columns=2, grill_width=2, grill_height=3)
i_window.add(grill)
# Create the window set
window_series = series(i_window, count, length_offset = padding)
# Remove the cutout and add the windows
w = wall.cut(window_cut_series)
w = w.add(window_series)
return w
Making the grill.
def grill(length=20, width=4, height=40, columns=4, rows=2, grill_width=1, grill_height=1):
# Make a flat plane
pane = cq.Workplane("XY").box(length, grill_height, height)
t_width = length / columns
t_height = height / rows
# Make the window cutout
tile = cq.Workplane("XY").box(t_width, grill_height, t_height).rotate((1,0,0),(0,0,0),90)
# Repeat the cutout
tiles = grid.make_grid(tile, [t_width+grill_width, t_height+grill_width], rows=columns, columns=rows).rotate((1,0,0),(0,0,0),-90)
# Remove the window cutouts leaving the frame
combine = pane.cut(tiles)
return combine
The tudor framing on the outside of the house are just these casement window grills.
The only difference between the casement and the lattice is the grill pattern.
def lattice(length=20, width=4, height=40, tile_size=4, lattice_width=1, lattice_height=1, lattice_angle=45):
# Determine longest distance between points
hyp = math.hypot(length, height)
columns= math.floor(hyp / (tile_size+lattice_width))
rows= math.floor(hyp / (tile_size+lattice_width))
# Make a flat plane
pane = cq.Workplane("XY").box(length, lattice_height, height)
#make the cutout tile
tile = cq.Workplane("XY").box(tile_size, lattice_height, tile_size).rotate((1,0,0),(0,0,0),90)
tiles = grid.make_grid(tile, [tile_size+lattice_width, tile_size+lattice_width], rows=columns, columns=rows).rotate((1,0,0),(0,0,0),-90).rotate((0,1,0),(0,0,0),lattice_angle)
combine = pane.cut(tiles)
return combine
The roof tiles were a struggle but it was a good opportunity re-learn some trigonometry.
Code to make a roof.
def make_roof(roof_width=185, x_offset=0):
# Make the wedge shape
gable_roof_raw = roof.dollhouse_gable(length=roof_width, width=185, height=100)
# Shell the roof to cut out the inside
gable_roof = roof.shell(gable_roof_raw,face="Y", width=-4)
# Determine the arccosine angle of the roof
angle = roof.angle(185, 100)
face_x = gable_roof_raw.faces("<X")
# Feature to enable/disable rendering roof tiles
if render_roof_tiles:
# Individual roof tile
tile = cq.Workplane("XY").box(15,12,2).rotate((0,1,0),(0,0,0),8)
# Grid of tiles
tiles = roof.tiles(tile, face_x, 185, 100, 15, 12, angle, rows=28, odd_col_push=[3,0], intersect=False).rotate((0,0,1),(0,0,0),90).translate((3,45,0))
tiles = tiles.translate((x_offset,0,0))
# Cut away box to remove excess tiles
inter_tiles = cq.Workplane("XY").box(roof_width,185, 100)
inter_tiles = tiles.intersect(inter_tiles)
return gable_roof.add(inter_tiles)
else:
# Quick roof no tiles
return gable_roof
Making the tiles is resource intensive, so a feature flag was added for quick rendering.
Making the wedge
def dollhouse_gable(length= 40, width=40, height=40):
roof = cq.Workplane("XY" ).wedge(length,height,width,0,0,length,0).rotate((1,0,0), (0,0,0), -90)
return roof
Shell the roof
def shell(part, face="-Z", width=-1):
result = part.faces(face).shell(width)
return result
Determine angle
def angle(length, height):
'''
Presumed length and height are part of a right triangle
'''
hyp = math.hypot(length, height)
angle = length/hyp
angle_radians = math.acos((angle))
angle_deg = math.degrees(angle_radians)
return angle_deg
- Roof Dormers
- Ladder
- Stairs
- Floor Tiles
- Clips
Implementation of the dormer
def make_dormer_roof(roof_part, width=185):
# Wedge Used for cutout
gable_roof_raw = roof.dollhouse_gable(length=width, width=185, height=100).translate((0,0,-4.5))
length=185
height = 100
inner_height = 60
# Sub roof of the dormer
roof_half_one = roof.dollhouse_gable(length=140, width=40, height=30).translate((0,0,29)).rotate((0,0,1),(0,0,0),90).translate((-20,15,0))
roof_half_two = roof.dollhouse_gable(length=140, width=40, height=30).translate((0,0,29)).rotate((0,0,1),(0,0,0),-90).translate((20,15,0))
# Render the tiles of the dormer
if render_roof_tiles:
angle = roof.angle(40, 30)
face_x = roof_half_one.faces(">X")
tile = cq.Workplane("XY").box(15,12,2).rotate((0,1,0),(0,0,0),8)
tiles = roof.tiles(tile, face_x, 140, 30, 15, 12, angle, rows=4, odd_col_push=[3,0], intersect=False).translate((-14.5,23,29))
tiles2 = roof.tiles(tile, face_x, 140, 30, 15, 12, angle, rows=4, odd_col_push=[3,0], intersect=False).translate((-14.5,23,29)).rotate((0,0,1),(0,0,0),180).translate((0,46,0))
# make the body / walls of the cut-away dormer aligned to the parent roof. combine the body of the dormer with the dormer roof
# this one is solid
inner = roof_part.faces("<Z").box(80,110,inner_height, combine=False).translate((0,0,inner_height/2+4))
inner = inner.union(roof_half_one).union(roof_half_two)
# make the body / walls of the actual dormer aligned to the parent roof. combine the body of the dormer with the dormer roof
# this one is shelled
inner_shell = roof_part.faces("<Z").box(80,140,inner_height, combine=False).translate((0,15,inner_height/2+4))
inner_shell = inner_shell.union(roof_half_one).union(roof_half_two)
inner_shell = inner_shell.faces(">Y").shell(-4)
# cut away excess tiles
if render_roof_tiles:
tile_cut = cq.Workplane("XY").box(40,140,50).translate((20,15,25))
tile_cut2 = cq.Workplane("XY").box(40,140,50).translate((-20,15,25))
tiles = tiles.intersect(tile_cut)
tiles2 = tiles2.intersect(tile_cut2)
inner_shell = inner_shell.add(tiles).add(tiles2)
# shell the dormer roof
inner_shell = inner_shell.cut(gable_roof_raw)
# Place the dormer onto the roof part
combine = roof_part.cut(inner).add(inner_shell)
# Add the window to the dormer
window_slug = inner.faces("<Y").cylinder(8,20,combine=False).rotateAboutCenter((1,0,0),90).translate((0,2.5,10))
window_inner = inner.faces("<Y").cylinder(8,17,combine=False).rotateAboutCenter((1,0,0),90).translate((0,2.5,10))
win_frame = window_slug.cut(window_inner)
grill = window.grill(40, 5, 40, 2, 2, 3, 3 ).translate((0,-52,5))
combine = combine.cut(window_slug).add(win_frame).add(grill)
return combine
Overall the dormer was complicated to make, and the code needs to be refactored and broken up.
# Create a latter instance
ladder_bp = Ladder(length=30, height=175, width=8)
# Set sub-part parameters
ladder_bp.rung_padding = 12
ladder_bp.rung_height = 3
ladder_bp.rung_width = 3
# make the sub parts
ladder_bp.make()
# Combine the parts into one solid.
ladder = ladder_bp.build().rotate((0,0,1),(0,0,0),90).translate((55,-60,175))
cqterrain class - Ladder code.
Ladders are a totally different pattern.
they are class objects with two lifecycles:
- make creates the sub-parts.
- build assembles the parts into a solid.
stair_lower = stairs(
length = 148,
width = 32,
height = 175,
run = 8,
stair_length_offset = 5.35,
stair_height = 3,
stair_height_offset = -.8,
rail_width = 3,
rail_height = 14,
step_overlap = None
)
Stairs are an older pattern in cqterrain, you call the constructor with parameters and it returns the solid.
The code is planned to be replaced.
The project used two variants of Floor tiles.
Tile code
def octagon_with_dots(tile_size=5, chamfer_size = 1.2, mid_tile_size =1.6, spacing = .5 ):
tile = (cq.Workplane("XY")
.rect(tile_size,tile_size)
.extrude(1)
.edges("|Z")
.chamfer(chamfer_size) # SET PERCENTAGE
)
rotated_tile = tile.rotate((0,0,1),(0,0,0), 45)
mid_tile = (cq.Workplane("XY")
.rect(mid_tile_size, mid_tile_size)
.extrude(1)
.rotate((0,0,1),(0,0,0), 45)
)
tiles = grid.make_grid(tile, [tile_size + spacing,tile_size + spacing], rows=3, columns=3)
center_tiles = grid.make_grid(mid_tile, [tile_size + spacing, tile_size + spacing], rows=4, columns=4)
combined = tiles.add(center_tiles).translate((0,0,-1*(1/2)))
return combined
Two sets of tiles overlaid ontop of each other.
When a tile is applied to a room; the code is built to know what to do with that.
bp.floors[0].floor_tile = tile.octagon_with_dots(10, 2.4, 3.2, 1)
bp.floors[0].floor_tile_padding = 1
bp.floors[0].make()
def basketweave(length = 4, width = 2, height = 1, padding = .5):
length_padding = length + padding
width_padding = width + padding
rect = (
cq.Workplane("XY")
.box(width, length_padding, height)
.center(width_padding, 0)
.box(width, length_padding, height)
.translate((-1*(width_padding/2), 0, 0))
)
rect2 = (
cq.Workplane("XY")
.box(width, length_padding, height)
.center(width_padding, 0)
.box(width, length_padding, height)
.translate((-1*(width_padding/2), 0, 0))
.rotate((0,0,1), (0,0,0), 90)
.translate((width_padding*2, 0, 0))
)
combine = (cq.Workplane("XY").union(rect).union(rect2).translate((-1*(width_padding),0,0)))
combine2 = (cq.Workplane("XY")
.union(combine)
.rotate((0,0,1),(0,0,0), 180)
.translate((0,width_padding*2,0))
)
tile_combine = cq.Workplane("XY").union(combine).union(combine2).translate((0,-1*(width_padding),0))
return tile_combine
I ended up making a couple clip variants to hold the parts together.
Clip code
import cadquery as cq
def clip():
part1 = cq.Workplane("XY").box(12.5, 24, 8)
inner = cq.Workplane("XY").box(8.5,22,8).translate((0,-1,0))
combined = part1.cut(inner)
combined = combined.fillet(.3)
return combined
part_clip = clip()
show_object(part_clip)
The clips work so-so.
- On average each part takes about 2 days to print.
- I spent 3 weeks printings parts.
- No Support materials were needed for any of the parts.
- The part modularity was a plus, and allows the kids to re-organize the house into different shapes or multiple smaller houses.
- If I were to do this again I would add a more robust mechanism for connecting parts together.