├── README.md └── celtic-knot.py /README.md: -------------------------------------------------------------------------------- 1 | Celtic Knot 2 | ===================== 3 | 4 | This Blender plugin generates bezier curves, pipes and ribbons in elaborate weavings, 5 | based off of a framework mesh. Tested with Blender 4.0. 6 | 7 | [![](https://raw.githubusercontent.com/wiki/BorisTheBrave/celtic-knot/images/platonic.png)](https://github.com/BorisTheBrave/celtic-knot/wiki/Gallery) 8 | 9 | Installation 10 | ------------ 11 | Go to `Edit > Preferences > Add-ons` and click `Install...`. Select `celtic-knot-py`, and click the checkbox to enable it. 12 | 13 | [1]: http://wiki.blender.org/index.php/Doc:2.6/Manual/Extensions/Python/Add-Ons 14 | 15 | Usage 16 | ----- 17 | 18 | Select any mesh, then run the plugin from the `Add > Curves` menu. Then tweak the generation parameters to suit your particular usage. 19 | 20 | Further [explanation](https://github.com/BorisTheBrave/celtic-knot/wiki/Tutorial) and [examples](https://github.com/BorisTheBrave/celtic-knot/wiki/Gallery) can be found in the [wiki]( 25 | 26 | * 27 | 28 | 29 | License 30 | ------- 31 | This code is licensed under the MIT License copyright Adam Newgas 2013. 32 | 33 | -------------------------------------------------------------------------------- /celtic-knot.py: -------------------------------------------------------------------------------- 1 | # Blender plugin for generating celtic knot curves from 3d meshes 2 | # See README for more information 3 | # 4 | # The MIT License (MIT) 5 | # 6 | # Copyright (c) 2013 Adam Newgas 7 | # 8 | # Permission is hereby granted, free of charge, to any person obtaining a copy 9 | # of this software and associated documentation files (the "Software"), to deal 10 | # in the Software without restriction, including without limitation the rights 11 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 12 | # copies of the Software, and to permit persons to whom the Software is 13 | # furnished to do so, subject to the following conditions: 14 | # 15 | # The above copyright notice and this permission notice shall be included in 16 | # all copies or substantial portions of the Software. 17 | # 18 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 19 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 20 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 21 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 22 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 23 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 24 | # THE SOFTWARE. 25 | 26 | bl_info = { 27 | "name": "Celtic Knot", 28 | "description": "Generates bezier curves, pipes and ribbons in elaborate weavings, based off of a framework mesh", 29 | "author": "Adam Newgas", 30 | "version": (2, 1, 0), 31 | "blender": (4, 0, 2), 32 | "location": "View3D > Add > Curve", 33 | "warning": "", 34 | "wiki_url": "https://github.com/BorisTheBrave/celtic-knot/wiki", 35 | "category": "Add Curve"} 36 | 37 | import bpy 38 | import bmesh 39 | from bpy_extras import object_utils 40 | from collections import defaultdict 41 | from mathutils import Color 42 | from math import pi, sin, cos 43 | from random import random, seed, choice, randrange 44 | 45 | HANDLE_TYPE_MAP = {"AUTO": "AUTOMATIC", "ALIGNED": "ALIGNED"} 46 | 47 | # Twist types 48 | TWIST_CW = "TWIST_CW" 49 | STRAIGHT = "STRAIGHT" 50 | TWIST_CCW = "TWIST_CCW" 51 | IGNORE = "IGNORE" 52 | 53 | # output types 54 | BEZIER = "BEZIER" 55 | PIPE = "PIPE" 56 | RIBBON = "RIBBON" 57 | 58 | ## General math utilites 59 | 60 | def is_boundary(loop): 61 | """Is a given loop on the boundary of a manifold (only connected to one face)""" 62 | return len(loop.link_loops) == 0 63 | 64 | 65 | def lerp(v1, v2, t): 66 | return v1 * (1 - t) + v2 * t 67 | 68 | def cyclic_zip(l): 69 | i = iter(l) 70 | first = prev = next(i) 71 | for item in i: 72 | yield prev, item 73 | prev = item 74 | yield prev, first 75 | 76 | 77 | def edge_midpoint(edge): 78 | v1 = edge.verts[0] 79 | v2 = edge.verts[1] 80 | return (v1.co + v2.co) / 2.0 81 | 82 | 83 | def bmesh_from_pydata(vertices, faces): 84 | bm = bmesh.new() 85 | for v in vertices: 86 | bm.verts.new(v) 87 | bm.verts.index_update() 88 | bm.verts.ensure_lookup_table() 89 | for f in faces: 90 | bm.faces.new([bm.verts[v] for v in f]) 91 | bm.edges.index_update() 92 | bm.edges.ensure_lookup_table() 93 | i = 0 94 | for edge in bm.edges: 95 | for loop in edge.link_loops: 96 | loop.index = i 97 | i += 1 98 | return bm 99 | 100 | 101 | ## Remeshing operations (replacing one bmesh with another) 102 | 103 | def remesh_midedge_subdivision(bm): 104 | edge_index_to_new_index = {} 105 | vert_index_to_new_index = {} 106 | new_vert_count = 0 107 | new_verts = [] 108 | new_faces = [] 109 | for vert in bm.verts: 110 | vert_index_to_new_index[vert.index] = new_vert_count 111 | new_verts.append(vert.co) 112 | new_vert_count += 1 113 | for edge in bm.edges: 114 | edge_index_to_new_index[edge.index] = new_vert_count 115 | new_verts.append(edge_midpoint(edge)) 116 | new_vert_count += 1 117 | # Add a face per face in the original mesh, with twice as many vertices 118 | for face in bm.faces: 119 | new_face = [] 120 | for loop in face.loops: 121 | new_face.append(vert_index_to_new_index[loop.vert.index]) 122 | new_face.append(edge_index_to_new_index[loop.edge.index]) 123 | new_faces.append(new_face) 124 | return bmesh_from_pydata(new_verts, new_faces) 125 | 126 | 127 | def remesh_medial(bm): 128 | edge_index_to_new_index = {} 129 | vert_index_to_new_index = {} 130 | new_vert_count = 0 131 | new_verts = [] 132 | new_faces = [] 133 | for vert in bm.verts: 134 | vert_index_to_new_index[vert.index] = new_vert_count 135 | new_verts.append(vert.co) 136 | new_vert_count += 1 137 | for edge in bm.edges: 138 | edge_index_to_new_index[edge.index] = new_vert_count 139 | new_verts.append(edge_midpoint(edge)) 140 | new_vert_count += 1 141 | # Add a face for each face in the original mesh 142 | for face in bm.faces: 143 | new_face = [] 144 | for loop in face.loops: 145 | new_face.append(edge_index_to_new_index[loop.edge.index]) 146 | new_faces.append(new_face) 147 | # Add a triangle for each vert of each face 148 | for face in bm.faces: 149 | for loop1, loop2 in cyclic_zip(face.loops): 150 | v0 = vert_index_to_new_index[loop2.vert.index] 151 | v1 = edge_index_to_new_index[loop1.edge.index] 152 | v2 = edge_index_to_new_index[loop2.edge.index] 153 | new_faces.append([v0, v2, v1]) 154 | 155 | return bmesh_from_pydata(new_verts, new_faces) 156 | 157 | 158 | REMESH_TYPES = [("NONE", "None", ""), 159 | ("EDGE_SUBDIVIDE", "Edge Subdivide", "Subdivide every edge"), 160 | ("MEDIAL", "Medial", "Replace every vertex with a fan of faces")] 161 | 162 | 163 | def remesh(bm, remesh_type): 164 | if remesh_type is None or remesh_type == "NONE": 165 | return bm 166 | if remesh_type == "EDGE_SUBDIVIDE": 167 | return remesh_midedge_subdivision(bm) 168 | if remesh_type == "MEDIAL": 169 | return remesh_medial(bm) 170 | 171 | 172 | class DirectedLoop: 173 | """Stores an edge loop and a particular facing along it.""" 174 | def __init__(self, loop, forward): 175 | self.loop = loop 176 | self.forward = forward 177 | 178 | 179 | @property 180 | def reversed(self): 181 | return DirectedLoop(self.loop, not self.forward) 182 | 183 | @property 184 | def next_face_loop(self): 185 | loop = self.loop 186 | forward = self.forward 187 | # Follow the face around, ignoring boundary edges 188 | while True: 189 | if forward: 190 | loop = loop.link_loop_next 191 | else: 192 | loop = loop.link_loop_prev 193 | if not is_boundary(loop): 194 | break 195 | return DirectedLoop(loop, forward) 196 | 197 | @property 198 | def next_edge_loop(self): 199 | loop = self.loop 200 | forward = self.forward 201 | if forward: 202 | v = loop.vert.index 203 | loop = loop.link_loops[0] 204 | forward = (loop.vert.index == v) == forward 205 | return DirectedLoop(loop, forward) 206 | else: 207 | v = loop.vert.index 208 | loop = loop.link_loops[-1] 209 | forward = (loop.vert.index == v) == forward 210 | return DirectedLoop(loop, forward) 211 | 212 | 213 | def get_celtic_twists(bm, twist_prob): 214 | """Gets a twist per edge for celtic knot style patterns. 215 | These are also called "plain weavings".""" 216 | seed(0) 217 | twists = [] 218 | for edge in bm.edges: 219 | if len(edge.link_loops) == 0: 220 | twists.append(IGNORE) 221 | else: 222 | if random() < twist_prob: 223 | twists.append(TWIST_CW) 224 | else: 225 | twists.append(STRAIGHT) 226 | return twists 227 | 228 | 229 | def strand_part(prev_loop, loop, forward): 230 | """A strand part uniquely identifies one point on a strand 231 | crossing a particular edge.""" 232 | return forward, frozenset((prev_loop.index, loop.index)) 233 | 234 | 235 | class StrandAnalysisBuilder: 236 | """Computes information about which strand parts belong to which strands.""" 237 | def __init__(self): 238 | self.crossings = defaultdict(list) 239 | self.current_strand_index = 0 240 | self.strand_indices = {} 241 | self.strand_size = defaultdict(int) 242 | 243 | # Builder methods 244 | def start_strand(self): 245 | pass 246 | 247 | def add_loop(self, prev_loop, loop, twist, forward): 248 | if twist != STRAIGHT: 249 | self.crossings[loop.edge.index].append(self.current_strand_index) 250 | self.strand_indices[strand_part(prev_loop, loop, forward)] = self.current_strand_index 251 | self.strand_size[self.current_strand_index] += 1 252 | 253 | def end_strand(self): 254 | self.current_strand_index += 1 255 | 256 | def all_crossings(self): 257 | return set(frozenset([x, y]) for l in self.crossings.values() for x in l for y in l if x != y) 258 | 259 | def get_strands(self): 260 | """Returns a dict of strand parts to integers""" 261 | return self.strand_indices 262 | 263 | def get_strand_sizes(self): 264 | return self.strand_size 265 | 266 | def get_braids(self): 267 | """Partitions the strands so any two crossing strands are in separate partitions. 268 | Each partition is called a braid. 269 | Returns a dict of strand parts to integers""" 270 | crossings = self.all_crossings() 271 | braids = defaultdict(list) 272 | braid_count = 0 273 | for s in range(self.current_strand_index): 274 | crossed_braids = set(braids[t] for p in crossings if s in p for t in p if t in braids) 275 | for b in range(braid_count): 276 | if b not in crossed_braids: 277 | break 278 | else: 279 | b = braid_count 280 | braid_count += 1 281 | braids[s] = b 282 | return {k: braids[v] for (k, v) in self.strand_indices.items()} 283 | 284 | 285 | def get_medial_twill_twists(bm, orig_face_len): 286 | """Gets twists per edge assuming bm has been transformed by remesh_medial.""" 287 | twists = [TWIST_CW] * len(bm.edges) 288 | for face in bm.faces[0:orig_face_len]: 289 | for edge in face.edges: 290 | twists[edge.index] = TWIST_CCW 291 | return twists 292 | 293 | 294 | def get_twill_twists(bm): 295 | """Gets twists per edge that describe a pattern where each strand goes over 2 then under 2, 296 | and adjacent strands have the pattern offset by one. 297 | This is heuristic, it's not always possible for some meshes. 298 | Largely based off "Cyclic Twill-Woven Objects", Akleman, Chen, Chen, Xing, Gross (2011) 299 | """ 300 | seed(0) 301 | bm.verts.ensure_lookup_table() 302 | bm.edges.ensure_lookup_table() 303 | 304 | def move(d): 305 | return d.next_face_loop.next_edge_loop 306 | 307 | def swap(d): 308 | return d.next_edge_loop 309 | 310 | class Votes: 311 | def __init__(self, cw=0, ccw=0): 312 | self.cw = cw 313 | self.ccw = ccw 314 | 315 | def __add__(self, other): 316 | return Votes(self.cw + other.cw, self.ccw + other.ccw) 317 | 318 | def edge_cond_vote(dloop): 319 | next = move(dloop) 320 | next2 = move(next) 321 | twist1 = coloring[next.loop.edge.index] 322 | twist2 = coloring[next2.loop.edge.index] 323 | if twist1 is None or twist2 is None: 324 | return Votes() 325 | if twist1 is TWIST_CW and twist2 is TWIST_CW: 326 | return Votes(0, 1) 327 | if twist1 is TWIST_CCW and twist2 is TWIST_CCW: 328 | return Votes(1, 0) 329 | if twist1 is TWIST_CW: 330 | return Votes(1, 0) 331 | else: 332 | return Votes(0, 1) 333 | 334 | def face_cond_vote(dloop): 335 | s = move(dloop) 336 | p = move(swap(dloop.reversed)) 337 | f = move(swap(s)) 338 | twist_s = coloring[s.loop.edge.index] 339 | twist_p = coloring[p.loop.edge.index] 340 | twist_f = coloring[f.loop.edge.index] 341 | if twist_s is None or twist_p is None or twist_f is None: 342 | return Votes() 343 | if twist_p != twist_f: 344 | if twist_s is TWIST_CW: 345 | return Votes(1, 0) 346 | else: 347 | return Votes(0, 1) 348 | return Votes(1, 1) 349 | 350 | def vert_cond_vote(dloop): 351 | s = move(dloop) 352 | p = move(swap(dloop)) 353 | f = move(swap(s.reversed)) 354 | twist_s = coloring[s.loop.edge.index] 355 | twist_p = coloring[p.loop.edge.index] 356 | twist_f = coloring[f.loop.edge.index] 357 | if twist_s is None or twist_p is None or twist_f is None: 358 | return Votes() 359 | if twist_p != twist_f: 360 | if twist_s is TWIST_CW: 361 | return Votes(1, 0) 362 | else: 363 | return Votes(0, 1) 364 | return Votes(1, 1) 365 | 366 | def count_votes(edge_index): 367 | edge = bm.edges[edge_index] 368 | votes = Votes() 369 | for loop in edge.link_loops: 370 | # Edge condition votes 371 | votes += edge_cond_vote(DirectedLoop(loop, True)) 372 | votes += edge_cond_vote(DirectedLoop(loop, False)) 373 | # Face condition votes 374 | votes += face_cond_vote(DirectedLoop(loop, True)) 375 | votes += face_cond_vote(DirectedLoop(loop, False)) 376 | # Vert condition votes 377 | votes += vert_cond_vote(DirectedLoop(loop, True)) 378 | votes += vert_cond_vote(DirectedLoop(loop, False)) 379 | 380 | return votes 381 | 382 | # Initialize 383 | frontier = set() 384 | coloring = [None] * len(bm.edges) 385 | cached_votes = {} 386 | 387 | def color_edge(edge, twist): 388 | if edge.index in frontier: 389 | frontier.remove(edge.index) 390 | coloring[edge.index] = twist 391 | for v in edge.verts: 392 | for other in v.link_edges: 393 | if coloring[other.index] is None: 394 | frontier.add(other.index) 395 | # Clear cached votes 396 | cached_votes.pop(edge.index, None) 397 | for v1 in edge.verts: 398 | for e2 in v1.link_edges: 399 | for v2 in e2.verts: 400 | if v1.index == v2.index: continue 401 | for e3 in v2.link_edges: 402 | cached_votes.pop(e3.index, None) 403 | 404 | def get_cached_vote(edge_index): 405 | if edge_index in cached_votes: 406 | return cached_votes[edge_index] 407 | else: 408 | return cached_votes.setdefault(edge_index, count_votes(edge_index)) 409 | 410 | # For each disconnected island of edges 411 | while True: 412 | uncolored = [i for i, color in enumerate(coloring) if color is None] 413 | if not uncolored: 414 | break 415 | 416 | # Pick a random point 417 | v0 = choice(bm.edges[choice(uncolored)].verts) 418 | 419 | # Set initial coloring 420 | for e in v0.link_edges: 421 | color_edge(e, TWIST_CW) 422 | break 423 | 424 | # Explore from frontier 425 | while frontier: 426 | # First clear out any boundaries from the frontier 427 | while True: 428 | found_boundaries = False 429 | for e in list(frontier): 430 | edge = bm.edges[e] 431 | if is_boundary(edge.link_loops[0]): 432 | color_edge(edge, IGNORE) 433 | found_boundaries = True 434 | if not found_boundaries: 435 | break 436 | # Color the best choice of edge 437 | votes = {e: get_cached_vote(e) for e in frontier} 438 | m = max(max(v.cw, v.ccw) for v in votes.values()) 439 | best_edge, best_votes = choice([(k, v) for (k, v) in votes.items() if v.cw == m or v.ccw == m]) 440 | set_twist = TWIST_CW if best_votes.cw > best_votes.ccw else TWIST_CCW 441 | color_edge(bm.edges[best_edge], set_twist) 442 | 443 | assert all(coloring), "Failed to assign some twists when computing twill" 444 | 445 | return coloring 446 | 447 | 448 | def get_offset(weave_up, weave_down, twist, forward): 449 | if twist is TWIST_CW: 450 | return weave_down if forward else weave_up 451 | elif twist is TWIST_CCW: 452 | return weave_up if forward else weave_down 453 | elif twist is STRAIGHT: 454 | return (weave_down + weave_up) / 2.0 455 | else: 456 | assert False, "Unexpected twist type " + twist 457 | 458 | 459 | class RibbonBuilder: 460 | """Builds a mesh containing a polygonal ribbon for each strand.""" 461 | def __init__(self, weave_up, weave_down, length, breadth, 462 | strand_analysis=None, 463 | materials=None): 464 | self.weave_up = weave_up 465 | self.weave_down = weave_down 466 | self.vertices = [] 467 | self.faces = [] 468 | self.prev_out_verts = None 469 | self.prev_out_uvs = None 470 | self.first_in_verts = None 471 | self.first_in_uvs = None 472 | self.prev_material = None 473 | self.c = length 474 | self.w = breadth 475 | self.strand_analysis = strand_analysis 476 | self.uvs = [] 477 | self.materials = materials or defaultdict(int) 478 | self.material_values = [] 479 | self.count = 0 480 | 481 | def get_sub_face(self, v1, v2, v3, v4): 482 | hc = self.c / 2.0 483 | hw = self.w / 2.0 484 | return ( 485 | lerp(lerp(v1, v4, 0.5 - hc), lerp(v2, v3, 0.5 - hc), 0.5 - hw), 486 | lerp(lerp(v1, v4, 0.5 - hc), lerp(v2, v3, 0.5 - hc), 0.5 + hw), 487 | lerp(lerp(v1, v4, 0.5 + hc), lerp(v2, v3, 0.5 + hc), 0.5 + hw), 488 | lerp(lerp(v1, v4, 0.5 + hc), lerp(v2, v3, 0.5 + hc), 0.5 - hw), 489 | ) 490 | 491 | def start_strand(self): 492 | self.first_in_verts = None 493 | self.first_in_uvs = None 494 | self.prev_out_verts = None 495 | self.prev_out_uvs = None 496 | self.prev_material = None 497 | self.count = 0 498 | 499 | def add_vertex(self, vert_co): 500 | self.vertices.append(vert_co) 501 | 502 | def add_face(self, vertices, uvs, material): 503 | self.faces.append(vertices) 504 | self.uvs.extend(uvs) 505 | self.material_values.append(material) 506 | 507 | def add_loop(self, prev_loop, loop, twist, forward): 508 | normal = loop.calc_normal() + prev_loop.calc_normal() 509 | normal.normalize() 510 | offset = -get_offset(self.weave_up, self.weave_down, twist, forward) * normal 511 | 512 | center1 = prev_loop.face.calc_center_median() 513 | center2 = loop.face.calc_center_median() 514 | v1 = loop.vert.co 515 | v2 = loop.link_loop_next.vert.co 516 | 517 | if twist is STRAIGHT: 518 | if forward: 519 | v1, center1, v2, center2 = center1, v1, v2, center1 520 | else: 521 | v1, center1, v2, center2 = v2, center1, center1, v1 522 | else: 523 | if not forward: 524 | v1, center1, v2, center2 = center1, v2, center2, v1 525 | 526 | v1, center1, v2, center2 = self.get_sub_face(v1, center1, v2, center2) 527 | 528 | sp = strand_part(prev_loop, loop, forward) 529 | self.prev_material = material = self.materials[sp] 530 | 531 | if self.strand_analysis: 532 | strand_index = self.strand_analysis.get_strands()[sp] 533 | strand_size = self.strand_analysis.get_strand_sizes()[strand_index] 534 | u1 = (self.count + 0) / strand_size 535 | u2 = (self.count + self.c) / strand_size 536 | else: 537 | u1 = None 538 | u2 = None 539 | 540 | i = len(self.vertices) 541 | self.add_vertex(v1 + offset) 542 | self.add_vertex(center1 + offset) 543 | self.add_vertex(v2 + offset) 544 | self.add_vertex(center2 + offset) 545 | self.add_face([i, i + 1, i + 2], [u1, 0, u1, 1, u2, 1], material) 546 | self.add_face([i, i + 2, i + 3], [u1, 0, u2, 1, u2, 0], material) 547 | in_verts = [i + 1, i + 0] 548 | in_uvs = [u1, 1, u1, 0] 549 | out_verts = [i + 3, i + 2] 550 | out_uvs = [u2, 0, u2, 1] 551 | 552 | if self.first_in_verts is None: 553 | self.first_in_verts = in_verts 554 | self.first_in_uvs = [u1 + 1, 1, u1 + 1, 0] 555 | if self.prev_out_verts is not None: 556 | self.add_face(self.prev_out_verts + in_verts, 557 | self.prev_out_uvs + in_uvs, 558 | material) 559 | self.prev_out_verts = out_verts 560 | self.prev_out_uvs = out_uvs 561 | self.count += 1 562 | 563 | def end_strand(self): 564 | self.add_face(self.prev_out_verts + self.first_in_verts, 565 | self.prev_out_uvs + self.first_in_uvs, 566 | self.prev_material) 567 | 568 | def make_mesh(self): 569 | me = bpy.data.meshes.new("") 570 | # Create mesh 571 | me.from_pydata(self.vertices, [], self.faces) 572 | # Set materials 573 | me.polygons.foreach_set("material_index", self.material_values) 574 | me.uv_layers.new(name = "") 575 | uv_layer = me.uv_layers[0] 576 | uv_layer.data.foreach_set("uv", self.uvs) 577 | # Recompute basic values 578 | me.update(calc_edges=True) 579 | return me 580 | 581 | 582 | class BezierBuilder: 583 | """Builds a bezier object containing a curve for each strand.""" 584 | def __init__(self, bm, crossing_angle, crossing_strength, handle_type, weave_up, weave_down, materials=None): 585 | # Cache some values 586 | self.s = sin(crossing_angle) * crossing_strength 587 | self.c = cos(crossing_angle) * crossing_strength 588 | self.handle_type = handle_type 589 | self.weave_up = weave_up 590 | self.weave_down = weave_down 591 | # Create the new object 592 | self.curve = bpy.data.curves.new("Celtic", "CURVE") 593 | self.curve.dimensions = "3D" 594 | self.curve.twist_mode = "MINIMUM" 595 | setup_materials(self.curve.materials, materials) 596 | # Compute all the midpoints of each edge 597 | self.midpoints = [] 598 | for e in bm.edges: 599 | self.midpoints.append(edge_midpoint(e)) 600 | # Per strand stuff 601 | self.current_spline = None 602 | self.cos = None 603 | self.handle_lefts = None 604 | self.handle_rights = None 605 | self.first = True 606 | self.materials = materials or defaultdict(int) 607 | self.current_material = None 608 | 609 | def start_strand(self): 610 | self.current_spline = self.curve.splines.new("BEZIER") 611 | self.current_spline.use_cyclic_u = True 612 | # Data for the strand 613 | # It's faster to store in an array and load into blender 614 | # at once 615 | self.cos = [] 616 | self.handle_lefts = [] 617 | self.handle_rights = [] 618 | self.current_material = None 619 | self.first = True 620 | 621 | def add_loop(self, prev_loop, loop, twist, forward): 622 | if not self.first: 623 | self.current_spline.bezier_points.add(1) 624 | self.first = False 625 | midpoint = self.midpoints[loop.edge.index] 626 | normal = loop.calc_normal() + prev_loop.calc_normal() 627 | normal.normalize() 628 | offset = -get_offset(self.weave_up, self.weave_down, twist, forward) * normal 629 | midpoint = midpoint + offset 630 | self.cos.extend(midpoint) 631 | 632 | self.current_material = self.materials[strand_part(prev_loop, loop, forward)] 633 | 634 | if self.handle_type != "AUTO": 635 | tangent = loop.link_loop_next.vert.co - loop.vert.co 636 | tangent.normalize() 637 | binormal = normal.cross(tangent).normalized() 638 | if not forward: tangent *= -1 639 | s_binormal = self.s * binormal 640 | c_tangent = self.c * tangent 641 | handle_left = midpoint - s_binormal - c_tangent 642 | handle_right = midpoint + s_binormal + c_tangent 643 | self.handle_lefts.extend(handle_left) 644 | self.handle_rights.extend(handle_right) 645 | 646 | def end_strand(self): 647 | points = self.current_spline.bezier_points 648 | points.foreach_set("co", self.cos) 649 | self.current_spline.material_index = self.current_material 650 | if self.handle_type != "AUTO": 651 | points.foreach_set("handle_left", self.handle_lefts) 652 | points.foreach_set("handle_right", self.handle_rights) 653 | 654 | 655 | def visit_strands(bm, twists, builder): 656 | """Walks over a mesh strand by strand turning at each edge by the specified twists, 657 | calling visitor methods on the given builder for each edge crossed.""" 658 | # Stores which loops the curve has already passed through 659 | loops_entered = defaultdict(lambda: False) 660 | loops_exited = defaultdict(lambda: False) 661 | 662 | # Starting at directed loop, build a curve one vertex at a time 663 | # until we start where we came from 664 | # Forward means that for any two edges the loop crosses 665 | # sharing a face, it is passing through in clockwise order 666 | # else anticlockwise 667 | def make_loop(d): 668 | builder.start_strand() 669 | while True: 670 | if d.forward: 671 | if loops_exited[d.loop]: break 672 | loops_exited[d.loop] = True 673 | d = d.next_face_loop 674 | assert loops_entered[d.loop] == False 675 | loops_entered[d.loop] = True 676 | prev_loop = d.loop 677 | # Find next radial loop 678 | twist = twists[d.loop.edge.index] 679 | if twist in (TWIST_CCW, TWIST_CW): 680 | d = d.next_edge_loop 681 | else: 682 | if loops_entered[d.loop]: break 683 | loops_entered[d.loop] = True 684 | d = d.next_face_loop 685 | assert loops_exited[d.loop] == False 686 | loops_exited[d.loop] = True 687 | prev_loop = d.loop 688 | # Find next radial loop 689 | twist = twists[d.loop.edge.index] 690 | if twist in (TWIST_CCW, TWIST_CW): 691 | d = d.next_edge_loop 692 | builder.add_loop(prev_loop, d.loop, twist, d.forward) 693 | builder.end_strand() 694 | 695 | # Attempt to start a loop at each untouched loop in the entire mesh 696 | for face in bm.faces: 697 | for loop in face.loops: 698 | if is_boundary(loop): continue 699 | if not loops_exited[loop]: make_loop(DirectedLoop(loop, True)) 700 | if not loops_entered[loop]: make_loop(DirectedLoop(loop, False)) 701 | 702 | 703 | def make_material(name, diffuse): 704 | mat = bpy.data.materials.new(name) 705 | mat.diffuse_color = (*diffuse ,1.0) 706 | mat.specular_intensity = 0.5 707 | 708 | return mat 709 | 710 | 711 | def setup_materials(materials_array, materials): 712 | if materials is not None: 713 | material_count = len(set(materials.values())) 714 | c = Color() 715 | for i in range(material_count): 716 | c.hsv = (i / float(material_count), 0.7, 0.25) 717 | materials_array.append(make_material("CelticKnot", c)) 718 | 719 | 720 | def create_bezier(context, bm, twists, 721 | crossing_angle, crossing_strength, handle_type, weave_up, weave_down, materials): 722 | builder = BezierBuilder(bm, crossing_angle, crossing_strength, handle_type, weave_up, weave_down, materials) 723 | visit_strands(bm, twists, builder) 724 | curve = builder.curve 725 | 726 | orig_obj = context.active_object 727 | # Create an object from the curve 728 | object_utils.object_data_add(context, curve, operator=None) 729 | # Set the handle type (this is faster than setting it pointwise) 730 | bpy.ops.object.editmode_toggle() 731 | bpy.ops.curve.select_all(action="SELECT") 732 | bpy.ops.curve.handle_type_set(type=HANDLE_TYPE_MAP[handle_type]) 733 | # Some blender versions lack the default 734 | bpy.ops.curve.radius_set(radius=1.0) 735 | bpy.ops.object.editmode_toggle() 736 | # Restore active selection 737 | curve_obj = context.active_object 738 | context.view_layer.objects.active = orig_obj 739 | 740 | 741 | return curve_obj 742 | 743 | 744 | def create_ribbon(context, bm, twists, weave_up, weave_down, length, breadth, 745 | strand_analysis, materials): 746 | builder = RibbonBuilder(weave_up, weave_down, length, breadth, strand_analysis, materials) 747 | visit_strands(bm, twists, builder) 748 | mesh = builder.make_mesh() 749 | orig_obj = context.active_object 750 | object_utils.object_data_add(context, mesh, operator=None) 751 | mesh_obj = context.active_object 752 | context.view_layer.objects.active = orig_obj 753 | 754 | setup_materials(mesh.materials, materials) 755 | 756 | return mesh_obj 757 | 758 | 759 | def create_pipe_from_bezier(context, curve_obj, thickness): 760 | curve_obj.select_set(True) 761 | curve_obj.data.bevel_mode = 'ROUND' 762 | curve_obj.data.bevel_depth = thickness 763 | context.view_layer.objects.active = curve_obj 764 | # For some reason only works with keep_original=True 765 | bpy.ops.object.convert(target="MESH", keep_original=True) 766 | new_obj = context.view_layer.objects.active 767 | new_obj.select_set(False) 768 | curve_obj.select_set(True) 769 | bpy.ops.object.delete() 770 | new_obj.select_set(True) 771 | context.view_layer.objects.active = new_obj 772 | 773 | 774 | class CelticKnotOperator(bpy.types.Operator): 775 | bl_idname = "object.celtic_knot_operator" 776 | bl_label = "Celtic Knot" 777 | bl_options = {'REGISTER', 'UNDO', 'PRESET'} 778 | 779 | remesh_type: bpy.props.EnumProperty(items=REMESH_TYPES, 780 | name="Remesh Type", 781 | description="Pre-process the mesh before weaving", 782 | default="NONE") 783 | 784 | weave_types = [("CELTIC","Celtic","All crossings use same orientation"), 785 | ("TWILL","Twill","Over two then under two")] 786 | weave_type: bpy.props.EnumProperty(items=weave_types, 787 | name="Weave Type", 788 | description="Determines which crossings are over or under", 789 | default="CELTIC") 790 | 791 | weave_up: bpy.props.FloatProperty(name="Weave Up", 792 | description="Distance to shift curve upwards over knots", 793 | subtype="DISTANCE", 794 | unit="LENGTH") 795 | weave_down: bpy.props.FloatProperty(name="Weave Down", 796 | description="Distance to shift curve downward under knots", 797 | subtype="DISTANCE", 798 | unit="LENGTH") 799 | twist_proportion: bpy.props.FloatProperty(name="Twist Proportion", 800 | description="Percent of edges that twist.", 801 | subtype="PERCENTAGE", 802 | unit="NONE", 803 | default=100.0, 804 | min=0.0, 805 | max=100.0) 806 | output_types = [(BEZIER, "Bezier", "Bezier curve"), 807 | (PIPE, "Pipe", "Rounded solid mesh"), 808 | (RIBBON, "Ribbon", "Flat plane mesh")] 809 | output_type: bpy.props.EnumProperty(items=output_types, 810 | name="Output Type", 811 | description="Controls what type of curve/mesh is generated", 812 | default=BEZIER) 813 | 814 | handle_types = [("ALIGNED","Aligned","Points at a fixed crossing angle"), 815 | ("AUTO","Auto","Automatic control points")] 816 | handle_type: bpy.props.EnumProperty(items=handle_types, 817 | name="Handle Type", 818 | description="Controls what type the bezier control points use", 819 | default="AUTO") 820 | crossing_angle: bpy.props.FloatProperty(name="Crossing Angle", 821 | description="Aligned only: the angle between curves in a knot", 822 | default=pi/4, 823 | min=0,max=pi/2, 824 | subtype="ANGLE", 825 | unit="ROTATION") 826 | crossing_strength: bpy.props.FloatProperty(name="Crossing Strength", 827 | description="Aligned only: strenth of bezier control points", 828 | soft_min=0, 829 | subtype="DISTANCE", 830 | unit="LENGTH") 831 | thickness: bpy.props.FloatProperty(name="Thickness", 832 | description="Radius of tube around curve (zero disables)", 833 | soft_min=0, 834 | subtype="DISTANCE", 835 | unit="LENGTH") 836 | length: bpy.props.FloatProperty(name="Length", 837 | description="Percent along faces that the ribbon runs parallel", 838 | subtype="PERCENTAGE", 839 | unit="NONE", 840 | default=90, 841 | soft_min=0.0, 842 | soft_max=100.0) 843 | breadth: bpy.props.FloatProperty(name="Breadth", 844 | description="Ribbon width as a percentage across faces.", 845 | subtype="PERCENTAGE", 846 | unit="NONE", 847 | default=50, 848 | soft_min=0.0, 849 | soft_max=100.0) 850 | coloring_types = [("NONE", "None", "No colors"), 851 | ("STRAND", "Per strand", "Assign a unique material to every strand."), 852 | ("BRAID", "Per braid", "Use as few materials as possible while preserving crossings.")] 853 | coloring_type: bpy.props.EnumProperty(items=coloring_types, 854 | name="Coloring", 855 | description="Controls what materials are assigned to the created object", 856 | default="NONE") 857 | 858 | def draw(self, context): 859 | layout = self.layout 860 | layout.prop(self, "remesh_type") 861 | layout.prop(self, "weave_type") 862 | if self.weave_type == "CELTIC": 863 | layout.prop(self, "twist_proportion") 864 | layout.prop(self, "output_type") 865 | layout.prop(self, "weave_up") 866 | layout.prop(self, "weave_down") 867 | if self.output_type in (BEZIER, PIPE): 868 | layout.prop(self, "handle_type") 869 | if self.handle_type != "AUTO": 870 | layout.prop(self, "crossing_angle") 871 | layout.prop(self, "crossing_strength") 872 | elif self.output_type == RIBBON: 873 | layout.prop(self, "length") 874 | layout.prop(self, "breadth") 875 | if self.output_type == PIPE: 876 | layout.prop(self, "thickness") 877 | layout.prop(self, "coloring_type") 878 | 879 | @classmethod 880 | def poll(cls, context): 881 | ob = context.active_object 882 | return ((ob is not None) and 883 | (ob.mode == "OBJECT") and 884 | (ob.type == "MESH") and 885 | (context.mode == "OBJECT")) 886 | 887 | def execute(self, context): 888 | obj = context.active_object 889 | orig_bm = bm = bmesh.new() 890 | bm.from_mesh(obj.data) 891 | 892 | # Apply remesh if desired 893 | bm = remesh(bm, self.remesh_type) 894 | 895 | # Compute twists 896 | if self.weave_type == "CELTIC": 897 | twists = get_celtic_twists(bm, self.twist_proportion / 100) 898 | else: 899 | if self.remesh_type == "MEDIAL": 900 | twists = get_medial_twill_twists(bm, len(orig_bm.faces)) 901 | else: 902 | twists = get_twill_twists(bm) 903 | 904 | # Assign materials to strand parts 905 | strand_analysis = StrandAnalysisBuilder() 906 | has_analysis = False 907 | 908 | def get_analysis(): 909 | nonlocal has_analysis 910 | if not has_analysis: 911 | visit_strands(bm, twists, strand_analysis) 912 | has_analysis = True 913 | return strand_analysis 914 | 915 | if self.coloring_type == "NONE": 916 | materials = None 917 | else: 918 | if self.coloring_type == "STRAND": 919 | materials = get_analysis().get_strands() 920 | else: 921 | materials = get_analysis().get_braids() 922 | 923 | # Build a mesh (or curve) object from the above 924 | if self.output_type in (BEZIER, PIPE): 925 | curve_obj = create_bezier(context, bm, twists, 926 | self.crossing_angle, 927 | self.crossing_strength, 928 | self.handle_type, 929 | self.weave_up, 930 | self.weave_down, 931 | materials) 932 | 933 | # If thick, then give it a bevel_object and convert to mesh 934 | if self.output_type == PIPE and self.thickness > 0: 935 | create_pipe_from_bezier(context, curve_obj, self.thickness) 936 | else: 937 | create_ribbon(context, bm, twists, self.weave_up, self.weave_down, self.length / 100, self.breadth / 100, 938 | get_analysis(), materials) 939 | return {'FINISHED'} 940 | 941 | 942 | class GeometricRemeshOperator(bpy.types.Operator): 943 | bl_idname = "object.geometric_remesh_operator" 944 | bl_label = "Geometric Remesh" 945 | bl_options = {'REGISTER', 'UNDO'} 946 | 947 | remesh_type: bpy.props.EnumProperty(items=[t for t in REMESH_TYPES if t[0] != "NONE"], 948 | name="Remesh Type", 949 | description="Pre-process the mesh before weaving", 950 | default="EDGE_SUBDIVIDE") 951 | 952 | @classmethod 953 | def poll(cls, context): 954 | ob = context.active_object 955 | return ((ob is not None) and 956 | (ob.mode == "OBJECT") and 957 | (ob.type == "MESH") and 958 | (context.mode == "OBJECT")) 959 | 960 | def execute(self, context): 961 | obj = context.active_object 962 | bm = bmesh.new() 963 | bm.from_mesh(obj.data) 964 | bm = remesh(bm, self.remesh_type) 965 | bm.to_mesh(obj.data) 966 | return {'FINISHED'} 967 | 968 | def menu_func(self, context): 969 | self.layout.operator(CelticKnotOperator.bl_idname, 970 | text="Celtic Knot From Mesh", 971 | icon='PLUGIN') 972 | 973 | 974 | def register(): 975 | bpy.utils.register_class(CelticKnotOperator) 976 | bpy.utils.register_class(GeometricRemeshOperator) 977 | bpy.types.VIEW3D_MT_curve_add.append(menu_func) 978 | 979 | 980 | def unregister(): 981 | bpy.types.VIEW3D_MT_curve_add.remove(menu_func) 982 | bpy.utils.unregister_class(GeometricRemeshOperator) 983 | bpy.utils.unregister_class(CelticKnotOperator) 984 | 985 | 986 | 987 | if __name__ == "__main__": 988 | register() 989 | --------------------------------------------------------------------------------