3D 碎形樹

November 28, 2021

在〈3D 海龜繪圖〉曾經透過自行實作的 Matrix3D,進一步定義了 Turtle 類別,cqMore 的 cqmore.matrix 就定義了 Matrix3D,可以用來實現 Turtle 類別。

cqmore.matrix

cqmore.matrix 提供了 Matrix3D,也提供了 rotationtranslation 等函式,可用來建立對應的轉換矩陣,若拿來實作 Turtle 類別的話,可以如下:

from cqmore.matrix import rotation, translation

class Turtle:
    def __init__(self, pos = (0, 0, 0)):
        self.coordinateVt = pos
        self.xAxis = (1, 0, 0)
        self.yAxis = (0, 1, 0)
        self.zAxis = (0, 0, 1)


    def forward(self, leng):
        m = translation(tuple(elem * leng for elem in self.xAxis)) # type: ignore
        self.coordinateVt = m.transform(self.coordinateVt)
        return self


    def roll(self, angle):
        xr = rotation(self.xAxis, angle)
        self.yAxis = xr.transform(self.yAxis)
        self.zAxis = xr.transform(self.zAxis)
        return self


    def pitch(self, angle):
        yr = rotation(self.yAxis, -angle)
        self.xAxis = yr.transform(self.xAxis)
        self.zAxis = yr.transform(self.zAxis)
        return self


    def turn(self, angle):
        zr = rotation(self.zAxis, angle)
        self.xAxis = zr.transform(self.xAxis)
        self.yAxis = zr.transform(self.yAxis)
        return self


    def pos(self):
        return self.coordinateVt
    

    def copy(self):
        t = Turtle()
        t.coordinateVt = self.coordinateVt
        t.xAxis = self.xAxis
        t.yAxis = self.yAxis
        t.zAxis = self.zAxis
        return t

若使用海龜繪圖,海龜的狀態就是座標、方向等資訊,為了便於建立海龜複製品,這邊多定義了一個 copy 方法,這是因為海龜繪圖常使用遞迴來繪製碎形,而遞迴時最好一次只做一件事,而且當次遞迴時的狀態,最好與先前或後續遞迴無關,否則你就還得留意狀態重置問題。

若是在純函數式語言,沒有變數的概念下,這不成問題,然而若是以命令式的典範來實作,想要避免狀態重置的問題,最好的方式就是,複製當次遞迴時需要的狀態,當次遞迴若需改變狀態,只改變複製品。

碎形樹

基於 BREP 的 CadQuery,老實說並不適合用來進行碎形繪製的任務,這個任務反而是基於 CSG 的 OpenSCAD 比較合適,不過若碎形階數不高,還可以勉強為之,只不會效率不佳就是了。

這邊以 OpenSCAD 文件中曾經談過的〈碎形之二(3D 樹木曲線)〉為例,來看看 CadQuery 要怎麼做,以下先列出全部程式碼:

from cqmore.matrix import rotation, translation
from cqmore import Workplane
from cqmore.polyhedron import tetrahedron

class Turtle:
    def __init__(self, pos = (0, 0, 0)):
        self.coordinateVt = pos
        self.xAxis = (1, 0, 0)
        self.yAxis = (0, 1, 0)
        self.zAxis = (0, 0, 1)


    def forward(self, leng):
        m = translation(tuple(elem * leng for elem in self.xAxis)) # type: ignore
        self.coordinateVt = m.transform(self.coordinateVt)
        return self


    def roll(self, angle):
        xr = rotation(self.xAxis, angle)
        self.yAxis = xr.transform(self.yAxis)
        self.zAxis = xr.transform(self.zAxis)
        return self


    def pitch(self, angle):
        yr = rotation(self.yAxis, -angle)
        self.xAxis = yr.transform(self.xAxis)
        self.zAxis = yr.transform(self.zAxis)
        return self


    def turn(self, angle):
        zr = rotation(self.zAxis, angle)
        self.xAxis = zr.transform(self.xAxis)
        self.yAxis = zr.transform(self.yAxis)
        return self


    def pos(self):
        return self.coordinateVt
    

    def copy(self):
        t = Turtle()
        t.coordinateVt = self.coordinateVt
        t.xAxis = self.xAxis
        t.yAxis = self.yAxis
        t.zAxis = self.zAxis
        return t

def turtle_tree(leng, leng_scale1, leng_scale2, limit, turnAngle, rollAngle, line_diameter):
    # 收集線段用的 Workplane
    _LINE_WORKPLANE = Workplane()
    _LINE_JOIN = _LINE_WORKPLANE.polyhedron(*tetrahedron(line_diameter / 2))
    def line(p1, p2):
        # 透過 polylineJoin 來建立線段
        return _LINE_WORKPLANE.polylineJoin([p1, p2], _LINE_JOIN)

    def _turtle_tree(workplane, turtle, leng, leng_scale1, leng_scale2, limit, turnAngle, rollAngle):
        if leng > limit:
            workplane = workplane.add(
                # 繪製線段
                line(turtle.pos(), turtle.forward(leng).pos())
            ) 

            workplane = _turtle_tree(
                workplane, 
                turtle.copy().turn(turnAngle), 
                leng * leng_scale1, 
                leng_scale1, 
                leng_scale2, 
                limit, 
                turnAngle, 
                rollAngle
            )

            return _turtle_tree(
                workplane, 
                turtle.copy().roll(rollAngle), 
                leng * leng_scale2, 
                leng_scale1, 
                leng_scale2, 
                limit, 
                turnAngle, 
                rollAngle
            )

        return workplane

    # 最後必須將收集的線段 combine 
    return _turtle_tree(
        Workplane(), 
        Turtle(), 
        leng, 
        leng_scale1, 
        leng_scale2, 
        limit, 
        turnAngle, 
        rollAngle
    ).combine()


leng = 20
limit = 1
leng_scale1 = 0.4
leng_scale2 = 0.9
turnAngle = 60
rollAngle = 135
line_diameter = 4


tree = turtle_tree(
    leng, 
    leng_scale1, 
    leng_scale2, 
    limit, 
    turnAngle, 
    rollAngle,
    line_diameter
)

在海龜前進時必須繪製線段,在上面 turtle_tree 的程式註解裡可以看到,這是透過 cqmore.WorkplanepolylineJoin 來實現。

OpenSCAD 繪製出來的圖形,在 render 模型時會自動聯集,然而在 CadQuery 不會這麼做,全部線段必須收集起來,最後收集的線段得 combine 在一起。

來看看繪製後的成果: