# Builds assets/DragonModel.obj (+ .mtl): a solid, three-dimensional dragon whose side view is exactly # the tangram picture. The tiles of Dragon.jpeg are grouped into body parts; each part gets its own # thickness, with the tiles as chamfered slabs on both faces and a bulging faceted core between them. # Legs and horns are mirrored to both sides, and the wings are swept out from the shoulders. # # --print makes the printable variant: every tile grows out to meet its neighbours (no gaps), wings, # horns and crest become thick plates with cores of their own, and the cores are a fourth material # ("frame") that shows as grout in the V-grooves between tiles and on every side wall. --stl writes # one binary STL per material for a multi-colour printer, scaled so the dragon is --width mm long; # --check voxelises the result and reports how many separate pieces it forms. # # --relief (with --print) keeps only the top half, lying flat: every part's core runs down to Z = 0 # so the dragon can sit on a plate (see util/plaqueModel.py). # # Usage: python3 util/dragonModel.py pieces.json assets/DragonModel.obj [--scale 0.01] # python3 util/dragonModel.py pieces.json assets/DragonPrint.obj --print --stl assets --width 150 --check # pieces.json comes from: python3 util/traceDragon.py assets/Dragon.jpeg assets/Dragon.svg --pieces pieces.json import argparse import json import os import numpy as np from PIL import Image, ImageDraw from scipy import ndimage from objWriter import ObjWriterT, writeMtl, pieceCount, reportPieces, DRAGON_MATERIALS from outline import traceBoundary, simplifyClean, signedArea, offsetRing, earClip SLAB = 4.0 # thickness of a tile slab, image pixels CHAMFER = 3.0 # chamfer on the visible tile edges SEAM = 4 # how far a part outline grows beyond its tiles, image pixels CLOSE = 14 # closing radius that bridges the gaps between a part's tiles BULGE = 0.15 # how far the core swells outward at its mid-plane, as a fraction of depth OUTLINE_TOLERANCE = 2.5 # polygon simplification for part outlines, image pixels CELL_TOLERANCE = 1.0 # polygon simplification for grown tiles in the printable variant CELL_GAP = 0.6 # inset of each grown tile so neighbouring colour bodies never overlap PLATE_DEPTH = 7.0 # half thickness of printable wings, horns and crest BODY_DEPTH = 55.0 MATERIAL_ORDER = ('dark', 'champagne', 'glass', 'frame', 'plate') def identity(p): return p class DragonT: # Everything needed to build the dragon from a pieces.json: tile rasters, part outlines, and the # mesh routines. build() emits the parts into a writer; footprint() is the outline of the parts. def __init__(self, data, scale=0.01, printable=False, relief=False): self.pieces = data['pieces'] self.x0, self.y0, self.x1, self.y1 = data['bounds'] self.width = self.x1 - self.x0 self.imageW = data['width'] self.imageH = data['height'] self.scale = scale self.printable = printable self.relief = relief tileImage = Image.new('I', (self.imageW, self.imageH), -1) draw = ImageDraw.Draw(tileImage) for index, piece in enumerate(self.pieces): draw.polygon([tuple(p) for p in piece['points']], fill=index) self.tileIndex = np.asarray(tileImage).astype(int) self.nearestY, self.nearestX = ndimage.distance_transform_edt(self.tileIndex < 0, return_indices=True)[1] self.centroids = np.array([np.mean(piece['points'], axis=0) for piece in self.pieces]) self.tileDistance = {} self.masks = {} self.writer = None # ---- Tiles and outlines ---- def tilesNear(self, points): # Pick tiles by the nearest centroid to each anchor, so the selection survives re-tracing. found = [] for p in points: index = int(np.argmin(np.hypot(*(self.centroids - np.array(p, dtype=float)).T))) if index not in found: found.append(index) return found def materialNear(self, x, y): xi = min(self.imageW - 1, max(0, int(round(x)))) yi = min(self.imageH - 1, max(0, int(round(y)))) return self.pieces[self.tileIndex[self.nearestY[yi, xi], self.nearestX[yi, xi]]]['material'] def toModel(self, x, y, z): return np.array([(x - (self.x0 + self.width / 2)) * self.scale, (self.y1 - y) * self.scale, z * self.scale]) def modelCcw(self, ring): # Orders a ring counter-clockwise as seen from +Z in model space (image y points down, so this # is clockwise on the picture). xy = np.array([self.toModel(x, y, 0.0)[:2] for x, y in ring]) return ring[::-1] if signedArea(xy) < 0 else ring def partMask(self, tiles): # Close the gaps between the tiles (which can be wider than the seams where corners meet) # without growing the outline by more than SEAM, and make sure the part is one island. key = tuple(tiles) if key in self.masks: return self.masks[key] mask = np.isin(self.tileIndex, tiles) radius = CLOSE while True: closed = ndimage.binary_dilation(mask, iterations=radius) closed = ndimage.binary_erosion(closed, iterations=radius - SEAM) closed = ndimage.binary_fill_holes(closed) if ndimage.label(closed)[1] == 1 or radius > 60: self.masks[key] = closed return closed radius += 8 def tileCells(self, tiles, mask): # Grow every tile of a part to the bisectors between it and its neighbours, filling the whole # part outline, then pull each cell in a touch so the colour bodies stay disjoint. for index in tiles: if index not in self.tileDistance: self.tileDistance[index] = ndimage.distance_transform_edt(self.tileIndex != index) owner = np.argmin(np.stack([self.tileDistance[index] for index in tiles]), axis=0) cells = [] for k, index in enumerate(tiles): cell = mask & (owner == k) labels, count = ndimage.label(cell) if count > 1: sizes = ndimage.sum(cell, labels, range(1, count + 1)) cell = labels == (1 + int(np.argmax(sizes))) ring = simplifyClean(traceBoundary(cell), CELL_TOLERANCE) cells.append((index, offsetRing(self.modelCcw(ring), -CELL_GAP))) return cells def insetRing(self, ring, chamfer): # Shrink the chamfer until the inset outline keeps every edge pointing the way it did. n = len(ring) while chamfer > 0.25: inner = offsetRing(ring, -chamfer) good = np.sign(signedArea(inner)) == np.sign(signedArea(ring)) for i in range(n): if not good: break good = (ring[(i + 1) % n] - ring[i]) @ (inner[(i + 1) % n] - inner[i]) > 0 if good: return inner, chamfer chamfer = chamfer * 0.5 return ring.copy(), 0.0 # ---- Mesh pieces ---- def emitFaces(self, name, faces): # faces: list of (material, indices); grouped by material under one object. self.writer.object(name) byMaterial = {} for material, indices in faces: byMaterial.setdefault(material, []).append(indices) for material, items in byMaterial.items(): self.writer.material(material) for indices in items: self.writer.face(indices) def rings(self, levels, transform): return [([self.writer.vertex(transform(self.toModel(x, y, z))) for x, y in poly], poly) for poly, z in levels] @staticmethod def walls(rings, material=None, materialAt=None): # Quads between successive rings of equal length, ordered by increasing Z; outward for CCW rings. faces = [] n = len(rings[0][0]) for level in range(len(rings) - 1): a, ringA = rings[level] b, ringB = rings[level + 1] for i in range(n): j = (i + 1) % n m = material if materialAt is None else materialAt((ringA[i] + ringA[j]) / 2) faces.append((m, [a[i], a[j], b[j], b[i]])) return faces @staticmethod def caps(ring, backIndices, frontIndices, materialAt): # Triangulated end faces: the back one faces -Z, the front one +Z. earClip returns every # triangle counter-clockwise in image space; match the ring's own turn. faces = [] ringCcw = signedArea(ring) > 0 for a, b, c in earClip(ring): tri = [a, b, c] if ringCcw else [c, b, a] material = materialAt(ring[[a, b, c]].mean(axis=0)) faces.append((material, [backIndices[i] for i in tri[::-1]])) faces.append((material, [frontIndices[i] for i in tri])) return faces def slab(self, outline, material, zFront, zBack, chamferFront, chamferBack, transform=identity): # A tile as a slab between two Z planes, chamfered on whichever faces are visible. ring = self.modelCcw(np.array(outline, dtype=float)) levels = [] if chamferBack > 0: inner, chamferBack = self.insetRing(ring, chamferBack) levels.append((inner, zBack)) levels.append((ring, zBack + chamferBack)) else: levels.append((ring, zBack)) if chamferFront > 0: inner, chamferFront = self.insetRing(ring, chamferFront) levels.append((ring, zFront - chamferFront)) levels.append((inner, zFront)) else: levels.append((ring, zFront)) rings = self.rings(levels, transform) return self.caps(ring, rings[0][0], rings[-1][0], lambda p: material) + self.walls(rings, material) def core(self, ring, zCentre, depth, transform=identity): # The volume between the two tile faces of a part: the part outline, widest at its mid-plane. # In relief the core is a plinth from Z = 0 up to the top tiles instead. inner = depth - SLAB if self.relief: levels = [(ring, 0.0), (ring, zCentre + inner)] else: levels = [(ring, zCentre - inner), (offsetRing(ring, depth * BULGE), zCentre), (ring, zCentre + inner)] rings = self.rings(levels, transform) materialAt = (lambda p: 'frame') if self.printable else (lambda p: self.materialNear(p[0], p[1])) return self.walls(rings, materialAt=materialAt) + self.caps(ring, rings[0][0], rings[-1][0], materialAt) def part(self, name, tiles, zCentre, depth, transform=identity): mask = self.partMask(tiles) ring = self.modelCcw(simplifyClean(traceBoundary(mask), OUTLINE_TOLERANCE)) faces = self.core(ring, zCentre, depth, transform) outlines = self.tileCells(tiles, mask) if self.printable else [(index, self.pieces[index]['points']) for index in tiles] for index, outline in outlines: material = self.pieces[index]['material'] faces += self.slab(outline, material, zCentre + depth, zCentre + depth - SLAB, CHAMFER, 0.0, transform) if not self.relief: faces += self.slab(outline, material, zCentre - depth + SLAB, zCentre - depth, 0.0, CHAMFER, transform) self.emitFaces(name, faces) def plate(self, name, tiles, zCentre, thickness, transform=identity): # A free-standing tile group visible from both sides (wings, horns, crest): thin chamfered # slabs in the display model, a proper cored part in the printable one. if self.printable: self.part(name, tiles, zCentre, PLATE_DEPTH, transform) return faces = [] chamfer = min(CHAMFER, thickness / 3.0) for index in tiles: piece = self.pieces[index] faces += self.slab(piece['points'], piece['material'], zCentre + thickness / 2, zCentre - thickness / 2, chamfer, chamfer, transform) self.emitFaces(name, faces) # ---- The dragon: tile groups by anchor point, half-depths in image pixels ---- def parts(self): # (name, tiles, zCentre, depth, plate?) for every part; relief keeps only the +Z side. sides = ((1.0, 'R'),) if self.relief else ((-1.0, 'L'), (1.0, 'R')) found = [('head', self.tilesNear([(800, 130), (874, 171), (968, 182), (856, 157), (838, 243), (917, 247)]), 0.0, 42.0, False), ('neck', self.tilesNear([(689, 232), (767, 233), (768, 313), (749, 383), (848, 408)]), 0.0, 36.0, False), ('body', self.tilesNear([(766, 509), (566, 492), (664, 549), (905, 511), (859, 542), (786, 638), (881, 618)]), 0.0, BODY_DEPTH, False), ('tail', self.tilesNear([(577, 730), (494, 705), (365, 713), (260, 647), (225, 558), (291, 477)]), 0.0, 26.0, False)] for side, label in sides: found.append(('hindLeg' + label, self.tilesNear([(934, 702), (885, 722), (1001, 727)]), side * (BODY_DEPTH - 26.0), 26.0, False)) found.append(('foreLeg' + label, self.tilesNear([(406, 589), (479, 578)]), side * 27.0, 22.0, False)) found.append(('horn' + label, self.tilesNear([(657, 117)]), side * 26.0, 10.0, True)) found.append(('crest', self.tilesNear([(711, 80)]), 0.0, 8.0, True)) for side, label in sides: found.append(('wing' + label, self.tilesNear([(450, 246), (396, 301), (574, 398)]), side * 46.0, 6.0, True)) return found def build(self, writer, transform=identity): self.writer = writer wingRoot = self.toModel(690, 462, 0.0) wingAngle = np.radians(25.0) for name, tiles, zCentre, depth, isPlate in self.parts(): shape = transform if name.startswith('wing') and not self.relief: # Hinged at the shoulder and swept outward so the two wings do not coincide. side = -1.0 if name.endswith('L') else 1.0 cosA = np.cos(wingAngle * side) sinA = np.sin(wingAngle * side) def shape(p, cosA=cosA, sinA=sinA): q = p - wingRoot return transform(np.array([q[0] * cosA + q[2] * sinA, q[1], -q[0] * sinA + q[2] * cosA]) + wingRoot) if isPlate: self.plate(name, tiles, zCentre, depth, shape) else: self.part(name, tiles, zCentre, depth, shape) def footprint(self): # Union of the part outlines, in image pixels. mask = np.zeros((self.imageH, self.imageW), dtype=bool) for name, tiles, zCentre, depth, isPlate in self.parts(): mask |= self.partMask(tiles) return mask def writeOutputs(writer, out, header, stlDir=None, widthMm=None, modelWidth=None, check=False, voxel=0.01): # OBJ + MTL, optional per-material STLs (scaled so the model spans widthMm), optional piece count. mtlName = os.path.splitext(os.path.basename(out))[0] + '.mtl' writer.write(out, mtlName, header) used = [m for m in MATERIAL_ORDER if any(f[1] == m for f in writer.faces)] writeMtl(os.path.join(os.path.dirname(out), mtlName), {m: DRAGON_MATERIALS[m] for m in used}, 'Materials for %s' % os.path.basename(out)) print('%s: %d vertices, %d faces, materials %s' % (out, len(writer.vertices), writer.faceCount, ', '.join(used))) if stlDir: stem = os.path.splitext(os.path.basename(out))[0] unitScale = widthMm / modelWidth for material in used: path = os.path.join(stlDir, '%s-%s.stl' % (stem, material)) print('%s: %d triangles' % (path, writer.writeStl(path, {material}, unitScale, '%s %s' % (stem, material)))) if check: reportPieces(*pieceCount(writer, voxel)) if __name__ == '__main__': parser = argparse.ArgumentParser() parser.add_argument('pieces') parser.add_argument('out') parser.add_argument('--scale', type=float, default=0.01, help='model units per image pixel') parser.add_argument('--print', dest='printable', action='store_true', help='gapless, four-material printable variant') parser.add_argument('--relief', action='store_true', help='with --print: top half only, flat underneath') parser.add_argument('--stl', default=None, help='directory to write one STL per material into') parser.add_argument('--width', type=float, default=150.0, help='length of the dragon in the STL files, millimetres') parser.add_argument('--check', action='store_true', help='voxelise the model and count separate pieces') args = parser.parse_args() data = json.load(open(args.pieces)) dragon = DragonT(data, args.scale, args.printable, args.relief and args.printable) writer = ObjWriterT() dragon.build(writer) variant = ('printable relief ' if dragon.relief else 'printable ') if args.printable else '' header = ['Singe dragon, %ssolid model built by util/dragonModel.py from the tiles of Dragon.jpeg' % variant, 'Units: %g per image pixel; Y up, faces right (+X), resting on Y = 0, symmetric about Z = 0' % args.scale] writeOutputs(writer, args.out, header, args.stl, args.width, dragon.width * args.scale, args.check, args.scale)