"""Shared Blender helpers for the G2 strategic buildings of Champions of Luneraria. Every G2 generator (Ancient, towers, boss grotto, Schlundkrone) builds its geometry from these primitives, so the pieces share one design language: bevelled hard-surface stone and metal, faceted crystals, rune bands with UVs for the glyph shader and rock masses from noise-displaced spheres. After building, `finalize` sets split normals by angle and bakes an ambient-occlusion term into the vertex colour `Col` (exported as COLOR_0); the Godot shaders multiply it into stone and metal and use it for wear. Material names (LN_*) are the contract with scripts/luneraria/view/lune_materials.gd; the Blender colours only serve the .blend preview. Godot coordinates are written through G(x, y, z) (x right, y up, z towards the viewer); Blender is Z-up. """ from pathlib import Path import bpy, bmesh, json, hashlib, struct from math import sin, cos, pi, sqrt, atan2 from mathutils import Vector, Matrix, noise from mathutils.bvhtree import BVHTree P = Path(__file__).resolve().parents[3] SRC = Path(__file__).resolve().parent OUT = P / 'assets/luneraria/g2' V = Vector def reset(): _materials.clear() bpy.ops.wm.read_factory_settings(use_empty=True) bpy.context.preferences.filepaths.save_version = 0 bpy.context.scene.render.fps = 30 OUT.mkdir(parents=True, exist_ok=True) def G(x, y, z): return V((x, -z, y)) def to_godot(v): return (v.x, v.z, -v.y) def lin(c): return tuple(x ** 2.2 for x in c) # Display colour (sRGB), roughness, metallic, emission strength. PALETTE = { 'LN_Stone_Pale': ((.80, .76, .68), .88, 0.0, 0.0), 'LN_Stone_Dark': ((.26, .26, .29), .9, 0.0, 0.0), 'LN_Stone_Grotto': ((.46, .43, .47), .92, 0.0, 0.0), 'LN_Rock': ((.40, .39, .37), .95, 0.0, 0.0), 'LN_Metal_Team': ((.74, .76, .80), .34, .9, 0.0), 'LN_Metal_Iron': ((.22, .22, .24), .55, .8, 0.0), 'LN_Metal_Bronze': ((.62, .44, .22), .42, .9, 0.0), 'LN_Crystal_Team': ((.55, .80, 1.0), .08, 0.0, 3.0), 'LN_Crystal_Grotto': ((.70, .50, 1.0), .08, 0.0, 3.0), 'LN_Rune_Team': ((.55, .80, 1.0), .5, 0.0, 2.0), 'LN_Rune_Grotto': ((.70, .50, 1.0), .5, 0.0, 2.0), 'LN_Cloth_Team': ((.20, .40, .80), .85, 0.0, 0.0), 'LN_Wood': ((.36, .25, .16), .8, 0.0, 0.0), 'LN_Cave': ((.03, .02, .05), 1.0, 0.0, 0.0), 'LN_Water_Glow': ((.45, .35, .80), .1, 0.0, 1.2), 'LN_Boss_Hide': ((.24, .25, .29), .84, 0.0, 0.0), 'LN_Boss_Bone': ((.80, .77, .69), .62, 0.0, 0.0), 'LN_Boss_Bronze': ((.60, .44, .24), .42, .85, 0.0), 'LN_Crystal_Boss': ((.70, .50, 1.0), .08, 0.0, 3.0), } _materials = {} def material(name): if name in _materials: return _materials[name] color, rough, metal, emission = PALETTE[name] m = bpy.data.materials.new(name) c = lin(color) m.diffuse_color = (*c, 1) m.use_nodes = True nodes, links = m.node_tree.nodes, m.node_tree.links bsdf = nodes.get('Principled BSDF') bsdf.inputs['Roughness'].default_value = rough bsdf.inputs['Metallic'].default_value = metal # Baked occlusion (vertex colour Col) darkens the preview like it does in Godot. attribute = nodes.new('ShaderNodeVertexColor') attribute.layer_name = 'Col' mix = nodes.new('ShaderNodeMix') mix.data_type = 'RGBA' mix.blend_type = 'MULTIPLY' mix.inputs['Factor'].default_value = 1.0 mix.inputs['A'].default_value = (*c, 1) links.new(attribute.outputs['Color'], mix.inputs['B']) links.new(mix.outputs['Result'], bsdf.inputs['Base Color']) if emission: bsdf.inputs['Emission Color'].default_value = (*c, 1) bsdf.inputs['Emission Strength'].default_value = emission _materials[name] = m return m # ---------------------------------------------------------------- objects def make(name, verts, faces, mat, uvs=None, smooth=False): """Object from Godot-space vertices (tuples or Vectors) and faces; uvs per vertex.""" me = bpy.data.meshes.new(name) me.from_pydata([tuple(G(*v)) for v in verts], [], faces) me.update() me.materials.append(material(mat)) if uvs is not None: layer = me.uv_layers.new(name='UVMap') for poly in me.polygons: for li in poly.loop_indices: layer.data[li].uv = uvs[me.loops[li].vertex_index] for poly in me.polygons: poly.use_smooth = smooth ob = bpy.data.objects.new(name, me) bpy.context.collection.objects.link(ob) return ob def bevel(ob, offset=.03, segments=2, angle=35.0, profile=.5): """Bevels every edge sharper than `angle` degrees (hard-surface highlight edges).""" bm = bmesh.new() bm.from_mesh(ob.data) bmesh.ops.remove_doubles(bm, verts=list(bm.verts), dist=1e-6) bm.normal_update() limit = angle * pi / 180.0 edges = [e for e in bm.edges if len(e.link_faces) == 2 and e.calc_face_angle(0.0) > limit] if edges: bmesh.ops.bevel(bm, geom=edges, offset=offset, offset_type='OFFSET', segments=segments, profile=profile, affect='EDGES', clamp_overlap=True) bm.to_mesh(ob.data) bm.free() ob.data.update() return ob def join(objects, name, origin=(0, 0, 0)): """Joins objects into one named object whose origin sits at the Godot point `origin`.""" objects = [o for o in objects if o is not None] bpy.ops.object.select_all(action='DESELECT') for o in objects: o.select_set(True) bpy.context.view_layer.objects.active = objects[0] if len(objects) > 1: bpy.ops.object.join() ob = bpy.context.view_layer.objects.active ob.name = name ob.data.name = name pivot = G(*origin) ob.data.transform(Matrix.Translation(-pivot)) ob.location = pivot return ob # ---------------------------------------------------------------- primitives def lathe(name, profile, sides, mat, center=(0, 0, 0), rotation=0.0, cap_top=True, cap_bottom=True, jitter=0.0, seed=0.0, smooth=False, uv_band=None): """Revolves a (radius, height) profile. uv_band=(cell_length) gives UVs u along the circumference in cells and v along the profile (0..1) for rune bands.""" cx, cy, cz = center verts, faces, uvs = [], [], [] n = len(profile) total = 0.0 lengths = [0.0] for j in range(1, n): total += ((profile[j][0] - profile[j - 1][0]) ** 2 + (profile[j][1] - profile[j - 1][1]) ** 2) ** .5 lengths.append(total) for j, (r, y) in enumerate(profile): for k in range(sides + (1 if uv_band else 0)): a = 2 * pi * (k % sides) / sides + rotation rr = r * (1 + jitter * noise.noise(V((cos(a) * 2.3, y * 1.7, sin(a) * 2.3 + seed)))) if jitter else r verts.append((cx + cos(a) * rr, cy + y, cz + sin(a) * rr)) if uv_band: circumference = 2 * pi * max(r, .01) cells = max(1, round(circumference / uv_band)) uvs.append((cells * k / sides, lengths[j] / max(total, 1e-6))) ring = sides + (1 if uv_band else 0) for j in range(n - 1): for k in range(sides): a0, b0 = j * ring + k, j * ring + (k + 1) % ring if not uv_band else j * ring + k + 1 faces.append((a0, b0, b0 + ring, a0 + ring)) if cap_bottom: faces.append(tuple(reversed(range(sides)))) if cap_top: faces.append(tuple(range((n - 1) * ring, (n - 1) * ring + sides))) ob = make(name, verts, faces, mat, uvs if uv_band else None, smooth) # Faces must point outwards whatever the profile direction was. bm = bmesh.new() bm.from_mesh(ob.data) bmesh.ops.remove_doubles(bm, verts=list(bm.verts), dist=1e-6) bmesh.ops.recalc_face_normals(bm, faces=list(bm.faces)) bm.to_mesh(ob.data) bm.free() return ob def box(name, center, size, mat, yaw=0.0, pitch=0.0, roll=0.0, bev=.025, segments=1): hx, hy, hz = size[0] / 2, size[1] / 2, size[2] / 2 rot = Matrix.Rotation(yaw, 3, 'Y') @ Matrix.Rotation(pitch, 3, 'X') @ Matrix.Rotation(roll, 3, 'Z') c = V(center) verts = [] for dy in (-1, 1): for dz in (-1, 1): for dx in (-1, 1): verts.append(tuple(c + rot @ V((dx * hx, dy * hy, dz * hz)))) faces = [(0, 1, 3, 2), (4, 6, 7, 5), (0, 4, 5, 1), (2, 3, 7, 6), (0, 2, 6, 4), (1, 5, 7, 3)] ob = make(name, verts, faces, mat) _outward(ob) if bev > 0: bevel(ob, bev, segments, 30.0) return ob def _outward(ob): bm = bmesh.new() bm.from_mesh(ob.data) bmesh.ops.recalc_face_normals(bm, faces=list(bm.faces)) bm.to_mesh(ob.data) bm.free() def prism(name, outline, y0, y1, mat, bev=.02, segments=1, top_scale=1.0): """Extrudes a closed Godot-xz outline from y0 to y1 (top optionally scaled around its centre).""" n = len(outline) cx = sum(p[0] for p in outline) / n cz = sum(p[1] for p in outline) / n verts = [(x, y0, z) for x, z in outline] verts += [(cx + (x - cx) * top_scale, y1, cz + (z - cz) * top_scale) for x, z in outline] faces = [(i, (i + 1) % n, n + (i + 1) % n, n + i) for i in range(n)] faces += [tuple(reversed(range(n))), tuple(range(n, 2 * n))] ob = make(name, verts, faces, mat) _outward(ob) if bev > 0: bevel(ob, bev, segments, 30.0) return ob def sweep(name, points, widths, thicknesses, mat, up_hint=(0, 1, 0), steps=24, lens=0.0, closed_ends=True, uv_cells=None, smooth=True, twist=0.0): """Sweeps a rectangle (optionally lens-shaped) along a Catmull-Rom path through Godot points. widths/thicknesses are sampled along the path. uv_cells gives rune UVs (u along, v across).""" pts = [V(p) for p in points] def cat(t): seg = min(len(pts) - 2, int(t * (len(pts) - 1))) u = t * (len(pts) - 1) - seg a, b, c, d = [pts[max(0, min(len(pts) - 1, q))] for q in (seg - 1, seg, seg + 1, seg + 2)] return .5 * ((2 * b) + (-a + c) * u + (2 * a - 5 * b + 4 * c - d) * u * u + (-a + 3 * b - 3 * c + d) * u * u * u) def sample(values, t): f = t * (len(values) - 1) i = min(len(values) - 2, int(f)) return values[i] * (1 - (f - i)) + values[i + 1] * (f - i) # Rectangle corners with optional lens bulge: 8 points around the section. section = [(-1, -1), (0, -1 - lens), (1, -1), (1, 0), (1, 1), (0, 1 + lens), (-1, 1), (-1, 0)] verts, faces, uvs = [], [], [] along = 0.0 previous = None for j in range(steps + 1): t = j / steps p = cat(t) tangent = (cat(min(1, t + .01)) - cat(max(0, t - .01))).normalized() hint = V(up_hint) side = tangent.cross(hint) if side.length < 1e-4: side = tangent.cross(V((1, 0, 0))) side.normalize() normal = side.cross(tangent).normalized() if twist: ang = twist * t side, normal = side * cos(ang) + normal * sin(ang), normal * cos(ang) - side * sin(ang) if previous is not None: along += (p - previous).length previous = p w, th = sample(widths, t) * .5, sample(thicknesses, t) * .5 for k, (sx, sy) in enumerate(section): verts.append(tuple(p + side * sx * w + normal * sy * th)) uvs.append(((along / uv_cells) if uv_cells else t, k / 8.0)) if j: base = (j - 1) * 8 for k in range(8): a0, b0 = base + k, base + (k + 1) % 8 faces.append((a0, b0, b0 + 8, a0 + 8)) if closed_ends: faces.append(tuple(reversed(range(8)))) faces.append(tuple(range(steps * 8, steps * 8 + 8))) ob = make(name, verts, faces, mat, uvs, smooth) _outward(ob) return ob def crystal(name, center, radius, top, bottom, mat, sides=6, tilt=(0.0, 0.0), twist=0.0, seed=0, shoulder=.62): """Faceted crystal: a prism with a pointed crown and a shorter pointed foot. Facet radii vary slightly per side so the highlights break up.""" c = V(center) rot = Matrix.Rotation(tilt[0], 3, 'X') @ Matrix.Rotation(tilt[1], 3, 'Z') verts = [tuple(c + rot @ V((0, top, 0)))] rings = [(shoulder * top, 1.0), (-shoulder * bottom * .5, 1.0)] for y, scale in rings: for k in range(sides): a = 2 * pi * k / sides + twist rr = radius * scale * (1 + .12 * noise.noise(V((k * 1.7, y, seed)))) verts.append(tuple(c + rot @ V((cos(a) * rr, y, sin(a) * rr)))) verts.append(tuple(c + rot @ V((0, -bottom, 0)))) faces = [(0, 1 + (k + 1) % sides, 1 + k) for k in range(sides)] for k in range(sides): faces.append((1 + k, 1 + (k + 1) % sides, 1 + sides + (k + 1) % sides, 1 + sides + k)) last = 1 + 2 * sides faces += [(last, 1 + sides + k, 1 + sides + (k + 1) % sides) for k in range(sides)] ob = make(name, verts, faces, mat) _outward(ob) return ob def blob(name, center, radii, mat, seed=0.0, subdiv=3, amp=.25, flatten_below=None, yaw=0.0, crag=0.0): """Rock mass: icosphere displaced by fractal noise (and optional terraced crags).""" bm = bmesh.new() bmesh.ops.create_icosphere(bm, subdivisions=subdiv, radius=1.0) c = V(center) rot = Matrix.Rotation(yaw, 3, 'Y') for v in bm.verts: d = v.co.normalized() n = noise.fractal(d * 1.6 + V((seed, seed * .7, seed * .3)), 1.0, 2.0, 4) r = 1.0 + amp * n if crag: r += crag * abs(noise.noise(d * 3.1 + V((seed * 1.3, 0, 0)))) p = V((d.x * radii[0] * r, d.y * radii[1] * r, d.z * radii[2] * r)) if flatten_below is not None and p.y < flatten_below: p.y = flatten_below + (p.y - flatten_below) * .15 v.co = G(*(c + rot @ p)) me = bpy.data.meshes.new(name) bm.to_mesh(me) bm.free() me.materials.append(material(mat)) ob = bpy.data.objects.new(name, me) bpy.context.collection.objects.link(ob) return ob def chunk(name, center, size, mat, seed=0.0, yaw=0.0, tilt=0.0, sink=.25): """Broken masonry fragment: a low-poly displaced icosphere with a flat bed, irregular and angular (rubble, debris, ruins). `sink` buries the bottom into the ground.""" bm = bmesh.new() bmesh.ops.create_icosphere(bm, subdivisions=1, radius=1.0) c = V(center) rot = Matrix.Rotation(yaw, 3, 'Y') @ Matrix.Rotation(tilt, 3, 'X') sx, sy, sz = size for v in bm.verts: d = v.co.normalized() r = 1.0 + .28 * noise.noise(d * 2.1 + V((seed, seed * 1.9, seed * .4))) p = V((d.x * sx * r, d.y * sy * r, d.z * sz * r)) # Flatten faces into cut planes so fragments look split, not rounded. p.x = max(-sx * .82, min(sx * .82, p.x)) p.z = max(-sz * .82, min(sz * .82, p.z)) p.y = max(-sy * (1 - sink), min(sy * .9, p.y)) v.co = G(*(c + rot @ p)) me = bpy.data.meshes.new(name) bm.to_mesh(me) bm.free() me.materials.append(material(mat)) ob = bpy.data.objects.new(name, me) bpy.context.collection.objects.link(ob) return ob def cliff(name, center, radii, mat, seed=0.0, cuts=7, subdiv=2, amp=.16, yaw=0.0, lean=(0.0, 0.0), flat_bottom=None): """Faceted cliff rock in the low-poly language of the map rocks: a displaced icosphere cut by a few random planes (split faces), leaning optionally. Flat shading comes from finalize()'s angle split. flat_bottom (Godot y, absolute) flattens everything below it.""" bm = bmesh.new() bmesh.ops.create_icosphere(bm, subdivisions=subdiv, radius=1.0) rng = seed * 7.31 planes = [] for k in range(cuts): # Cut normals biased sideways and up, so faces read as cleft rock, not as a gem. a = 2 * pi * ((k * .618 + seed * .17) % 1.0) tilt = .25 + .9 * ((k * .377 + seed * .29) % 1.0) n = V((cos(a) * sin(tilt * 1.2), cos(tilt * 1.2) * .6, sin(a) * sin(tilt * 1.2))).normalized() planes.append((n, .6 + .26 * ((k * .731 + rng) % 1.0))) c = V(center) rot = Matrix.Rotation(yaw, 3, 'Y') @ Matrix.Rotation(lean[0], 3, 'X') @ Matrix.Rotation(lean[1], 3, 'Z') for v in bm.verts: d = v.co.normalized() r = 1.0 + amp * noise.fractal(d * 1.9 + V((seed, seed * .7, seed * .3)), 1.0, 2.0, 3) q = d * r for n, off in planes: over = q.dot(n) - off if over > 0: q -= n * over p = V((q.x * radii[0], q.y * radii[1], q.z * radii[2])) w = c + rot @ p if flat_bottom is not None and w.y < flat_bottom: w.y = flat_bottom v.co = G(*w) me = bpy.data.meshes.new(name) bm.to_mesh(me) bm.free() me.materials.append(material(mat)) ob = bpy.data.objects.new(name, me) bpy.context.collection.objects.link(ob) return ob def ring(name, center, radius, width, thickness, mat, sides=48, axis_tilt=(0.0, 0.0), uv_cell=None, bev=.02): """Flat ring (rectangular section) around the Godot y axis, tilted by (x, z) angles. The outer band carries rune UVs when uv_cell is given.""" c = V(center) rot = Matrix.Rotation(axis_tilt[0], 3, 'X') @ Matrix.Rotation(axis_tilt[1], 3, 'Z') section = [(radius - width / 2, -thickness / 2), (radius + width / 2, -thickness / 2), (radius + width / 2, thickness / 2), (radius - width / 2, thickness / 2)] verts, faces, uvs = [], [], [] cells = max(1, round(2 * pi * radius / uv_cell)) if uv_cell else 1 for k in range(sides + 1): a = 2 * pi * k / sides for s, (r, y) in enumerate(section): verts.append(tuple(c + rot @ V((cos(a) * r, y, sin(a) * r)))) uvs.append((cells * k / sides, 0.0 if s in (0, 1) else 1.0)) for k in range(sides): for s in range(4): a0, b0 = k * 4 + s, k * 4 + (s + 1) % 4 faces.append((a0, b0, b0 + 4, a0 + 4)) ob = make(name, verts, faces, mat, uvs) _outward(ob) if bev > 0: bevel(ob, bev, 1, 30.0) return ob def band(name, center, radius, y0, y1, mat, sides=48, uv_cell=.35, inset=0.0): """Vertical cylindrical band (rune frieze) with UVs: u in glyph cells, v across.""" c = V(center) verts, faces, uvs = [], [], [] cells = max(1, round(2 * pi * radius / uv_cell)) for k in range(sides + 1): a = 2 * pi * k / sides for s, y in enumerate((y0, y1)): verts.append((c.x + cos(a) * (radius + inset), c.y + y, c.z + sin(a) * (radius + inset))) uvs.append((cells * k / sides, float(s))) for k in range(sides): faces.append((k * 2, k * 2 + 2, k * 2 + 3, k * 2 + 1)) ob = make(name, verts, faces, mat, uvs) _outward(ob) return ob def panel(name, center, width, height, normal_yaw, mat, depth=.02, uv_cells=(1, 1)): """Thin rectangular inlay facing the Godot direction normal_yaw (for rune panels).""" c = V(center) right = V((cos(normal_yaw), 0, -sin(normal_yaw))) out = V((sin(normal_yaw), 0, cos(normal_yaw))) up = V((0, 1, 0)) verts, uvs = [], [] for sy in (-1, 1): for sx in (-1, 1): verts.append(tuple(c + right * sx * width / 2 + up * sy * height / 2 + out * depth)) uvs.append(((sx + 1) / 2 * uv_cells[0], (sy + 1) / 2 * uv_cells[1])) faces = [(0, 1, 3, 2)] ob = make(name, verts, faces, mat, uvs) return ob def disc_grid(name, center, radius, mat, rings=6, sides=48, y=0.0, uv_scale=None): """Flat disc (Godot y up) with polar UVs: u = angle cells, v = radius (for floor runes).""" c = V(center) verts, faces, uvs = [(c.x, c.y + y, c.z)], [], [(0.0, 0.0)] for j in range(1, rings + 1): r = radius * j / rings for k in range(sides + 1): a = 2 * pi * k / sides verts.append((c.x + cos(a) * r, c.y + y, c.z + sin(a) * r)) uvs.append((k / sides * (uv_scale or 1.0), j / rings)) for k in range(sides): faces.append((0, 1 + k + 1, 1 + k)) ring_n = sides + 1 for j in range(rings - 1): for k in range(sides): a0 = 1 + j * ring_n + k faces.append((a0, a0 + 1, a0 + 1 + ring_n, a0 + ring_n)) ob = make(name, verts, faces, mat, uvs) for poly in ob.data.polygons: poly.use_smooth = True # Faces up. bm = bmesh.new() bm.from_mesh(ob.data) for f in bm.faces: f.normal_update() if f.normal.z < 0: f.normal_flip() bm.to_mesh(ob.data) bm.free() return ob def banner(name, width, height, mat, shape='swallowtail', segments=(6, 12), top=(0, 0, 0), yaw=0.0): """Hanging cloth below the Godot point `top`: UV v runs 0 at the pole to 1 at the hem, so the cloth shader weights the wind by v. shape: 'swallowtail' (Team A, cool) or 'flame' (Team B, warm: three flame tongues).""" nx, ny = segments t = V(top) rot = Matrix.Rotation(yaw, 3, 'Y') verts, uvs, faces = [], [], [] for j in range(ny + 1): v = j / ny for i in range(nx + 1): u = i / nx x = (u - .5) * width y = -v * height if j == ny: if shape == 'swallowtail': y += height * .28 * (1 - abs(u - .5) * 2) else: y -= height * .12 * max(0.0, cos((u - .5) * 3 * 2 * pi)) ** 2 verts.append(tuple(t + rot @ V((x, y, 0)))) uvs.append((u, v)) for j in range(ny): for i in range(nx): a = j * (nx + 1) + i faces.append((a, a + 1, a + nx + 2, a + nx + 1)) ob = make(name, verts, faces, mat, uvs, smooth=True) return ob # ---------------------------------------------------------------- finishing def finalize(objects, sharp_angle=40.0, ao=True, rays=24, reach=2.2, ground=0.0, occluders=None, seed=7): """Split normals by angle, then bake ambient occlusion into the vertex colour Col. Occlusion is traced against all `occluders` (default: the objects themselves) plus the ground plane at Godot height `ground`, so feet of walls and inner corners darken.""" for ob in objects: me = ob.data bm = bmesh.new() bm.from_mesh(me) bmesh.ops.remove_doubles(bm, verts=list(bm.verts), dist=1e-6) bm.to_mesh(me) bm.free() me.shade_smooth() me.set_sharp_from_angle(angle=sharp_angle * pi / 180.0) me.update() if not ao: for ob in objects: _fill_color(ob, lambda i: 1.0) return sources = occluders if occluders is not None else objects verts, polys = [], [] for ob in sources: mw = ob.matrix_world base = len(verts) verts += [mw @ v.co for v in ob.data.vertices] polys += [tuple(base + i for i in p.vertices) for p in ob.data.polygons] tree = BVHTree.FromPolygons(verts, polys, epsilon=0.0) # Fixed, well spread hemisphere directions (golden spiral), rotated per vertex. dirs = [] for i in range(rays): z = 1 - (i + .5) / rays r = sqrt(max(0.0, 1 - z * z)) a = i * 2.39996323 dirs.append(V((cos(a) * r, sin(a) * r, z))) ground_z = ground # Blender z == Godot y for ob in objects: mw = ob.matrix_world nm = mw.to_3x3().inverted().transposed() values = [] for v in ob.data.vertices: p = mw @ v.co n = (nm @ v.normal).normalized() t = n.orthogonal().normalized() b = n.cross(t) hit = 0.0 origin = p + n * .012 for d in dirs: w = t * d.x + b * d.y + n * d.z blocked = False if w.z < -1e-4 and origin.z >= ground_z: dist = (origin.z - ground_z) / -w.z blocked = dist < reach if not blocked: loc, _, _, dist = tree.ray_cast(origin, w, reach) blocked = loc is not None if blocked: hit += d.z weight = sum(d.z for d in dirs) values.append(max(0.0, 1.0 - hit / weight)) _fill_color(ob, lambda i, values=values: .28 + .72 * values[i] ** .8) def _fill_color(ob, fn): me = ob.data attr = me.color_attributes.get('Col') or me.color_attributes.new(name='Col', type='FLOAT_COLOR', domain='POINT') for i, item in enumerate(attr.data): a = fn(i) item.color = (a, a, a, 1.0) me.color_attributes.active_color = attr me.color_attributes.render_color_index = me.color_attributes.find('Col') def triangulate(ob): bm = bmesh.new() bm.from_mesh(ob.data) bmesh.ops.triangulate(bm, faces=list(bm.faces)) bm.to_mesh(ob.data) bm.free() def triangles(objects): total = 0 for ob in objects: total += sum(len(p.vertices) - 2 for p in ob.data.polygons) return total def export(objects, glb_name, blend_name, extra_objects=()): """Saves the .blend and exports the given objects (plus armatures) as GLB. Returns the parsed glTF JSON and the raw bytes for the manifest.""" bpy.ops.wm.save_as_mainfile(filepath=str(SRC / blend_name)) bpy.ops.object.select_all(action='DESELECT') for ob in list(objects) + list(extra_objects): ob.select_set(True) bpy.context.view_layer.objects.active = objects[0] path = OUT / glb_name bpy.ops.export_scene.gltf(filepath=str(path), export_format='GLB', use_selection=True, export_materials='EXPORT', export_yup=True, export_animations=False, export_vertex_color='ACTIVE', export_normals=True, export_apply=False) raw = path.read_bytes() doc = json.loads(raw[20:20 + struct.unpack_from('