Add reusable procedural helper library

This commit is contained in:
Meta Vens 2026-05-05 02:26:46 +01:00
commit aa4d49da1a
18 changed files with 378 additions and 242 deletions

View file

@ -49,13 +49,14 @@ Currently present:
- persistence surfaces for re-entry, evidence, planning, and compaction hygiene
- starter architecture notes for the Blender scene-generation pipeline
- a small `vibegeometry/` helper library for coordinate frames, deterministic
fields, batching, Blender object/material/camera helpers, and verifier checks
- a repo structure for schemas, prompts, examples, experiments, scripts, tools,
notes, and durable state
- small state inspection and pre-compaction helpers
Not present yet:
- Blender Python graph builder library
- scene brief schema
- node graph manifest schema
- render review schema
@ -73,6 +74,7 @@ Not present yet:
- `notes/fresh-workspace-handoff.md`: compact re-entry packet
- `notes/vibegeometry-current-system-map.md`: current repo machinery
- `notes/vibegeometry-implementation-plan.md`: implementation sequence
- `vibegeometry/`: reusable procedural-scene helper library
- `notes/architecture-rationale.md`: why the persistence surfaces exist
- `docs/architecture/`: pipeline contracts and design notes
- `docs/research/`: Blender geometry-node research notes

View file

@ -91,6 +91,9 @@ This is intentionally hybrid:
- Python structures the lore-derived coordinate system, layer tables, material
meanings, mapped civic regions, cameras, labels, and render paths.
- `vibegeometry/` supplies the reusable Python primitives promoted from this
build: coordinate frames, deterministic fields, batched mesh construction,
Blender object/material/camera helpers, and verifier checks.
- Geometry Script emits `VG Bloom Light Spine`, an inspectable 21-node
geometry-node group used by a Nodes modifier in the scene.
- bpy builds the full shell surfaces, despun core, hub cap, docking port, spun

View file

@ -22,6 +22,21 @@ except ModuleNotFoundError:
load_repo_geometry_script()
from geometry_script import * # noqa: F403 - Geometry Script exposes node functions as DSL globals.
from vibegeometry import (
CylinderFrame,
add_box,
add_curve_polyline,
add_cylinder_between,
add_material,
add_mesh_parts,
add_multi_polyline_curve,
aim_camera,
append_box_parts,
append_cylindrical_ring_band,
clear_scene,
fbm_2d,
smoothstep,
)
TAU = pymath.tau
@ -31,6 +46,12 @@ CUT_END = pymath.radians(132.0)
FULL_START = -pymath.pi
FULL_END = pymath.pi
INNER_RADIUS = 5.0
FRAME = CylinderFrame(INNER_RADIUS)
radial = FRAME.radial
tangent = FRAME.tangent
cyl_point = FRAME.cyl_point
surface_point = FRAME.surface_point
mat = add_material
@tree("VG Bloom Light Spine")
@ -55,42 +76,6 @@ def vg_bloom_light_spine(
return {"Geometry": curve_to_mesh(curve=positioned, profile_curve=profile, fill_caps=True)}
def mat(name, color, emission=False, strength=0.0, alpha=1.0):
import bpy
material = bpy.data.materials.new(name)
material.diffuse_color = (color[0], color[1], color[2], alpha)
material.use_nodes = True
bsdf = material.node_tree.nodes.get("Principled BSDF")
if bsdf:
if emission:
bsdf.inputs["Emission Color"].default_value = (color[0], color[1], color[2], 1.0)
bsdf.inputs["Emission Strength"].default_value = strength
bsdf.inputs["Base Color"].default_value = (color[0], color[1], color[2], alpha)
bsdf.inputs["Roughness"].default_value = 0.62
bsdf.inputs["Alpha"].default_value = alpha
if alpha < 1.0:
material.blend_method = "BLEND"
material.use_screen_refraction = True
material.show_transparent_back = True
return material
def clear_scene():
import bpy
bpy.ops.object.select_all(action="SELECT")
bpy.ops.object.delete()
def radial(angle: float):
return (0.0, pymath.cos(angle), pymath.sin(angle))
def tangent(angle: float):
return (0.0, -pymath.sin(angle), pymath.cos(angle))
def add_shell_segment(name, radius, half_length, thickness, material, angle_steps=144, x_steps=8, start_angle=FULL_START, end_angle=FULL_END):
import bpy
@ -100,7 +85,7 @@ def add_shell_segment(name, radius, half_length, thickness, material, angle_step
x = -half_length + (2.0 * half_length * ix / x_steps)
for ia in range(angle_steps + 1):
a = start_angle + (end_angle - start_angle) * ia / angle_steps
verts.append((x, radius * pymath.cos(a), radius * pymath.sin(a)))
verts.append(cyl_point(x, a, radius))
for ix in range(x_steps):
for ia in range(angle_steps):
row = angle_steps + 1
@ -118,11 +103,6 @@ def add_shell_segment(name, radius, half_length, thickness, material, angle_step
return obj
def surface_point(x, angle, radius=INNER_RADIUS, lift=-0.04):
r = radius + lift
return (x, r * pymath.cos(angle), r * pymath.sin(angle))
def add_surface_patch(name, x0, x1, a0, a1, radius, material, lift=-0.045, angle_steps=8, x_steps=3):
import bpy
@ -177,68 +157,6 @@ def add_surface_block_field(name, x_values, angles, material, size, radial_lift=
)
def append_box_parts(verts, faces, loc, axes, size):
from mathutils import Vector
origin = Vector(loc)
ax = [Vector(axis).normalized() for axis in axes]
sx, sy, sz = (value * 0.5 for value in size)
base = len(verts)
for dx, dy, dz in [
(-sx, -sy, -sz),
(sx, -sy, -sz),
(sx, sy, -sz),
(-sx, sy, -sz),
(-sx, -sy, sz),
(sx, -sy, sz),
(sx, sy, sz),
(-sx, sy, sz),
]:
v = origin + ax[0] * dx + ax[1] * dy + ax[2] * dz
verts.append((v.x, v.y, v.z))
faces.extend(
[
(base + 0, base + 1, base + 2, base + 3),
(base + 4, base + 7, base + 6, base + 5),
(base + 0, base + 4, base + 5, base + 1),
(base + 1, base + 5, base + 6, base + 2),
(base + 2, base + 6, base + 7, base + 3),
(base + 3, base + 7, base + 4, base + 0),
]
)
def add_mesh_parts(name, verts, faces, material):
import bpy
mesh = bpy.data.meshes.new(name + "Mesh")
mesh.from_pydata(verts, [], faces)
mesh.update()
obj = bpy.data.objects.new(name, mesh)
bpy.context.collection.objects.link(obj)
obj.data.materials.append(material)
return obj
def add_multi_polyline_curve(name, lines, bevel, material, resolution=2):
import bpy
curve = bpy.data.curves.new(name + "Curve", "CURVE")
curve.dimensions = "3D"
curve.resolution_u = resolution
curve.bevel_depth = bevel
curve.bevel_resolution = 2
for line in lines:
spline = curve.splines.new("POLY")
spline.points.add(len(line) - 1)
for point, co in zip(spline.points, line):
point.co = (co[0], co[1], co[2], 1.0)
obj = bpy.data.objects.new(name, curve)
bpy.context.collection.objects.link(obj)
obj.data.materials.append(material)
return obj
def add_surface_stilt(name, x, angle, inner_lift, outer_lift, material, radius=0.009):
start = surface_point(x, angle, INNER_RADIUS, lift=inner_lift)
end = surface_point(x, angle, INNER_RADIUS, lift=outer_lift)
@ -317,17 +235,6 @@ def add_favela_fractal_cluster(prefix, x_values, angles, mats):
add_multi_polyline_curve(prefix + "_brace_and_catwalks", brace_lines, 0.005, mats["brace"], resolution=2)
def append_endcap_ring_band(verts, faces, x, inner_radius, outer_radius, segments=96):
base = len(verts)
for i in range(segments):
a = FULL_START + TAU * i / segments
verts.append(cyl_point(x, a, inner_radius))
verts.append(cyl_point(x, a, outer_radius))
for i in range(segments):
j = (i + 1) % segments
faces.append((base + i * 2, base + j * 2, base + j * 2 + 1, base + i * 2 + 1))
def add_hubward_endcap_terraced_slums(prefix, x, mats):
# Bloom lore frame: on an endcap, "up" is inward toward the axial Spire.
# These terraces are not polar graph paper. Fractal noise makes the shelf
@ -345,7 +252,7 @@ def add_hubward_endcap_terraced_slums(prefix, x, mats):
outer_r = rings[tier + 1]
tier_t = tier / (len(rings) - 2)
face_x = x + 0.035 + tier * 0.018
append_endcap_ring_band(shelf_verts, shelf_faces, face_x, inner_r, outer_r, segments=160)
append_cylindrical_ring_band(shelf_verts, shelf_faces, face_x, inner_r, outer_r, segments=160)
count = int(30 + tier * 8 + 18 * fbm_2d(tier * 0.71, 4.0, seed=12))
angular_step = TAU / count
run = 0
@ -414,7 +321,7 @@ def add_hubward_endcap_terraced_slums(prefix, x, mats):
def add_hubward_office_complex(prefix, x, mats):
office_verts, office_faces = [], []
for i, radius in enumerate((0.42, 0.68, 0.92)):
append_endcap_ring_band(office_verts, office_faces, x + 0.18 + i * 0.06, radius, radius + 0.16, segments=96)
append_cylindrical_ring_band(office_verts, office_faces, x + 0.18 + i * 0.06, radius, radius + 0.16, segments=96)
add_mesh_parts(prefix + "_ring_office_mesh", office_verts, office_faces, mats["hub"])
for i, angle in enumerate([pymath.radians(a) for a in range(-150, 181, 30)]):
add_cylinder_between(
@ -511,48 +418,6 @@ def add_frame_transfer_artery(name, x, angle, start_radius, end_radius, material
return add_curve_polyline(name, samples, bevel, material)
def cyl_point(x, angle, radius):
return (x, radius * pymath.cos(angle), radius * pymath.sin(angle))
def smoothstep(t):
return t * t * (3.0 - 2.0 * t)
def hash01(ix, iy=0, seed=0):
n = ix * 374761393 + iy * 668265263 + seed * 1442695041
n = (n ^ (n >> 13)) * 1274126177
n = n ^ (n >> 16)
return (n & 0xFFFFFFFF) / 0xFFFFFFFF
def value_noise_2d(x, y, seed=0):
ix = pymath.floor(x)
iy = pymath.floor(y)
fx = smoothstep(x - ix)
fy = smoothstep(y - iy)
a = hash01(ix, iy, seed)
b = hash01(ix + 1, iy, seed)
c = hash01(ix, iy + 1, seed)
d = hash01(ix + 1, iy + 1, seed)
ab = a + (b - a) * fx
cd = c + (d - c) * fx
return ab + (cd - ab) * fy
def fbm_2d(x, y, seed=0, octaves=4):
value = 0.0
amplitude = 0.5
frequency = 1.0
total = 0.0
for octave in range(octaves):
value += value_noise_2d(x * frequency, y * frequency, seed + octave * 19) * amplitude
total += amplitude
amplitude *= 0.5
frequency *= 2.03
return value / total if total else 0.0
def add_spiral_spoke_loop(name, x, angle, start_radius, end_radius, material, bevel=0.04, cargo=False):
"""Shared cylinder frame: x is axial, angle is shell azimuth, radius is spin radius."""
samples = []
@ -615,60 +480,6 @@ def add_endcap_terrace_blocks(prefix, x, radii, angles, material, height=0.18):
)
def add_cylinder_between(name, start, end, radius, material, vertices=16):
import bpy
from mathutils import Vector
start_v = Vector(start)
end_v = Vector(end)
mid = (start_v + end_v) / 2.0
direction = end_v - start_v
length = direction.length
bpy.ops.mesh.primitive_cylinder_add(vertices=vertices, radius=radius, depth=length, location=mid)
obj = bpy.context.object
obj.name = name
obj.data.name = name + "Mesh"
obj.rotation_euler = direction.to_track_quat("Z", "Y").to_euler()
obj.data.materials.append(material)
return obj
def add_curve_polyline(name, points, bevel, material, resolution=2):
import bpy
curve = bpy.data.curves.new(name + "Curve", "CURVE")
curve.dimensions = "3D"
curve.resolution_u = resolution
curve.bevel_depth = bevel
curve.bevel_resolution = 4
spline = curve.splines.new("POLY")
spline.points.add(len(points) - 1)
for point, co in zip(spline.points, points):
point.co = (co[0], co[1], co[2], 1.0)
obj = bpy.data.objects.new(name, curve)
bpy.context.collection.objects.link(obj)
obj.data.materials.append(material)
return obj
def add_box(name, loc, axes, size, material):
import bpy
from mathutils import Matrix, Vector
bpy.ops.mesh.primitive_cube_add(size=1.0, location=loc)
obj = bpy.context.object
obj.name = name
obj.dimensions = size
matrix = Matrix.Identity(4)
matrix.col[0].xyz = Vector(axes[0]).normalized()
matrix.col[1].xyz = Vector(axes[1]).normalized()
matrix.col[2].xyz = Vector(axes[2]).normalized()
obj.rotation_euler = matrix.to_euler()
obj.data.materials.append(material)
bpy.ops.object.transform_apply(location=False, rotation=False, scale=True)
return obj
def add_label(name, text, loc, size, material):
import bpy
@ -696,13 +507,6 @@ def add_gn_light_spine(material):
return obj
def aim_camera(obj, target):
from mathutils import Vector
direction = Vector(target) - obj.location
obj.rotation_euler = direction.to_track_quat("-Z", "Y").to_euler()
def build_scene():
import bpy

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2 MiB

After

Width:  |  Height:  |  Size: 2 MiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 MiB

After

Width:  |  Height:  |  Size: 1.8 MiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

After

Width:  |  Height:  |  Size: 1.7 MiB

Before After
Before After

View file

@ -63,6 +63,11 @@ tables, loops, variants, cleanup, validation, docs, and scene/render
orchestration; Geometry Script emits inspectable Geometry Nodes groups; `bpy`
handles Blender scene machinery and evaluated evidence.
`vibegeometry/` is the first reusable helper library. It currently holds
coordinate-frame helpers, deterministic field/noise helpers, mesh batching,
thin Blender object/material/camera helpers, and verifier helpers. The Bloom
script imports these primitives directly, so the library is not just a shrine.
The repo-local Geometry Script clone has Blender 5.1 work for nested `@tree`
group reuse, a ported `nodes_to_script` converter, and menu-socket default
tolerance. See `docs/research/geometry-script-fork-notes.md`. Run
@ -77,9 +82,9 @@ The GameCult fork is intentionally pruned to `main` plus VibeGeometry-owned
tooling branches. Do not copy upstream branch soup forward unless a future patch
actually needs it.
The current next action is to extract the Bloom coordinate/noise/detail lessons
into a small reusable builder seam, starting with either endcap terrace slums or
spiral transfer arteries, then bind that seam to scene-brief, manifest,
The current next action is to grow `vibegeometry/` from helper primitives into a
small reusable builder seam, starting with either endcap terrace slums or spiral
transfer arteries, then bind that seam to scene-brief, manifest,
inspection-render, and verifier contract drafts.
## Important Invariants

View file

@ -44,8 +44,8 @@ not carry live next-action clutter.
## Near-Term Sequence
1. Promote the Bloom coordinate/noise lessons into reusable builder seams.
- Extract shared cylindrical/endcap coordinate helpers into a clear module or
Geometry Script-oriented builder layer.
- Build on the new `vibegeometry/` helper library rather than adding more
one-off example functions.
- Preserve the split between coordinate contract, density field, batched
realization, domain camera, and verifier checks.
- Keep the first extraction small: endcap terrace slums or spiral transfer

View file

@ -41,3 +41,4 @@
{"ts": "2026-05-05T00:33:45+00:00", "type": "correction", "status": "accepted", "note": "Corrected Bloom terrace-slum orientation against lore: on hubward endcaps, up is inward toward the Spire, so slums are annular shelves around the docking hub office complex growing outward to the surface; removed the misleading cylinder-wall favela framing and verifier now checks hubward_endcap_rice_paddy_slums objects."}
{"ts": "2026-05-05T00:55:36+00:00", "type": "correction", "status": "accepted", "note": "Bloom endcap terrace slums now use fractal value-noise occupancy rather than neat concentric box rings; verified dedicated hubward-endcap render and mesh-density checks for poor shacks, midrise housing, and outer urban crown."}
{"ts": "2026-05-05T01:12:46+00:00", "type": "maintenance", "status": "accepted", "note": "Polished persistent state after Bloom corrections: map, handoff, plan, and doctrine now prioritize source coordinate contracts, deterministic noise for organic occupancy, batched dense detail, domain-facing inspection renders, and verifier checks as reusable machine behavior rather than one-off scene scars."}
{"ts": "2026-05-05T01:25:43+00:00", "type": "implementation", "status": "accepted", "note": "Created the first reusable vibegeometry Python helper library for coordinate frames, deterministic fields, dense mesh batching, Blender scene primitives, and verifier helpers; refactored the Bloom build and verifier to use it, then rebuilt and verified the scene in Blender with 1028 geometry/text objects."}

View file

@ -41,6 +41,7 @@ live_architecture:
- Review renders against the visual target and record accepted, rejected, or revised hypotheses.
authoring_stack:
- Python structures intent, repetition, data tables, variants, cleanup, validation, documentation, and scene/render orchestration.
- `vibegeometry/` provides reusable Python helpers for coordinate contracts, deterministic fields, batched geometry, Blender object/material/camera setup, and verifier checks.
- Geometry Script emits inspectable reusable Geometry Nodes groups.
- bpy handles Blender objects, modifiers, materials, cameras, lights, files, renders, and evaluated-geometry checks.
- External Python tools inspect source blends, generate converter drafts, compare outputs, and maintain corpus evidence.
@ -73,6 +74,7 @@ current_artifacts:
- docs/research/translations/ircss-french-houses-node-atlas.md
- docs/research/translations/ircss-french-houses-node-mechanisms.md
- docs/research/builds/aetheria-bloom-habitat.md
- vibegeometry/
tools:
- tools/vibegeometry_state.py
- tools/vibegeometry_prepare_compaction.py
@ -97,8 +99,8 @@ data_flow:
- If state grows faster than understanding, cut it back.
current_status:
summary: VibeGeometry has verified Geometry Script translations for local samples, official Blender demos, and IRCSS French Houses footholds, plus a from-scratch Aetheria Bloom whole-habitat build. Durable lessons now driving the pipeline: Python orchestrates intent and batching; Geometry Script emits inspectable node groups; source-bound scenes need explicit coordinate contracts before ornament; organic occupancy needs deterministic noise fields; dense detail must be batched; and visual acceptance needs domain-facing inspection renders plus verifier checks, not only overview renders. Bloom currently demonstrates shared cylindrical/endcap helpers, golden-angle transfer loops, layered shell/endcaps, noise-driven endcap terrace slums, social-gradient geography, roads/rivers/clouds, and service markers. Known graph-tooling blockers remain repeat-zone feedback, repeat Top outputs, foreach zones, and gizmo nodes.
next_action: Extract the Bloom coordinate/noise/detail lessons into a small reusable builder seam, starting with either endcap terrace slums or spiral transfer arteries, then bind that seam to scene-brief, manifest, inspection-render, and verifier contract drafts.
summary: VibeGeometry has verified Geometry Script translations for local samples, official Blender demos, and IRCSS French Houses footholds, plus a from-scratch Aetheria Bloom whole-habitat build. Durable lessons now driving the pipeline: Python orchestrates intent and batching; Geometry Script emits inspectable node groups; source-bound scenes need explicit coordinate contracts before ornament; organic occupancy needs deterministic noise fields; dense detail must be batched; and visual acceptance needs domain-facing inspection renders plus verifier checks, not only overview renders. The first reusable `vibegeometry/` helper library now carries coordinate frames, fields, batching, Blender scene primitives, and verifier helpers used by the Bloom build. Known graph-tooling blockers remain repeat-zone feedback, repeat Top outputs, foreach zones, and gizmo nodes.
next_action: Grow `vibegeometry/` from helper primitives into builder seams by extracting one higher-level pattern next: either endcap terrace slums or spiral transfer arteries, then bind that seam to scene-brief, manifest, inspection-render, and verifier contract drafts.
remotes:
vibegeometry: https://github.com/GameCult/VibeGeometry
geometry_script_fork: https://github.com/GameCult/geometry-script

View file

@ -3,11 +3,16 @@
from __future__ import annotations
from pathlib import Path
import sys
import bpy
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from vibegeometry import assert_objects_exist, assert_render_artifacts, assert_scene_density
ARTIFACT_DIR = ROOT / "experiments" / "generated" / "aetheria_bloom"
@ -83,8 +88,7 @@ def assert_true(condition: bool, message: str):
def verify_scene():
missing = sorted(name for name in REQUIRED_OBJECTS if name not in bpy.data.objects)
assert_true(not missing, f"Missing required objects: {missing}")
assert_objects_exist(bpy, REQUIRED_OBJECTS)
group = bpy.data.node_groups.get("VG Bloom Light Spine")
assert_true(group is not None, "Missing Geometry Script node group VG Bloom Light Spine")
@ -94,18 +98,17 @@ def verify_scene():
carrier = bpy.data.objects["GN_Light_Spine_Modifier_Carrier"]
assert_true(any(mod.type == "NODES" and mod.node_group == group for mod in carrier.modifiers), "Light-spine carrier lacks expected geometry-nodes modifier")
for filename in (
"aetheria_bloom_habitat.png",
"aetheria_bloom_interior_world.png",
"aetheria_bloom_hubward_endcap_terraces.png",
"aetheria_bloom_habitat.blend",
):
path = ARTIFACT_DIR / filename
assert_true(path.exists(), f"Missing generated artifact {path}")
assert_true(path.stat().st_size > 10_000, f"Generated artifact is suspiciously small: {path}")
assert_render_artifacts(
ARTIFACT_DIR / filename
for filename in (
"aetheria_bloom_habitat.png",
"aetheria_bloom_interior_world.png",
"aetheria_bloom_hubward_endcap_terraces.png",
"aetheria_bloom_habitat.blend",
)
)
mesh_objects = [obj for obj in bpy.data.objects if obj.type in {"MESH", "CURVE", "FONT"}]
assert_true(len(mesh_objects) >= 930, f"Expected a populated whole-Bloom study scene, found {len(mesh_objects)} geometry/text objects")
mesh_objects = assert_scene_density(bpy, 930)
slum_mesh = bpy.data.objects["hubward_endcap_rice_paddy_slums_rickety_shack_mesh"].data
mid_mesh = bpy.data.objects["hubward_endcap_rice_paddy_slums_midrise_housing_mesh"].data

37
vibegeometry/__init__.py Normal file
View file

@ -0,0 +1,37 @@
"""Reusable procedural-scene helpers for VibeGeometry examples."""
from .batching import append_box_parts, append_cylindrical_ring_band
from .blender import (
add_box,
add_curve_polyline,
add_cylinder_between,
add_material,
add_mesh_parts,
add_multi_polyline_curve,
aim_camera,
clear_scene,
)
from .coordinates import CylinderFrame
from .fields import fbm_2d, hash01, smoothstep, value_noise_2d
from .verify import assert_objects_exist, assert_render_artifacts, assert_scene_density
__all__ = [
"CylinderFrame",
"add_box",
"add_curve_polyline",
"add_cylinder_between",
"add_material",
"add_mesh_parts",
"add_multi_polyline_curve",
"aim_camera",
"append_box_parts",
"append_cylindrical_ring_band",
"assert_objects_exist",
"assert_render_artifacts",
"assert_scene_density",
"clear_scene",
"fbm_2d",
"hash01",
"smoothstep",
"value_noise_2d",
]

51
vibegeometry/batching.py Normal file
View file

@ -0,0 +1,51 @@
"""Mesh batching helpers for dense procedural detail."""
from __future__ import annotations
import math
def append_box_parts(verts, faces, loc, axes, size):
"""Append an oriented box to shared mesh arrays."""
from mathutils import Vector
origin = Vector(loc)
ax = [Vector(axis).normalized() for axis in axes]
sx, sy, sz = (value * 0.5 for value in size)
base = len(verts)
for dx, dy, dz in [
(-sx, -sy, -sz),
(sx, -sy, -sz),
(sx, sy, -sz),
(-sx, sy, -sz),
(-sx, -sy, sz),
(sx, -sy, sz),
(sx, sy, sz),
(-sx, sy, sz),
]:
v = origin + ax[0] * dx + ax[1] * dy + ax[2] * dz
verts.append((v.x, v.y, v.z))
faces.extend(
[
(base + 0, base + 1, base + 2, base + 3),
(base + 4, base + 7, base + 6, base + 5),
(base + 0, base + 4, base + 5, base + 1),
(base + 1, base + 5, base + 6, base + 2),
(base + 2, base + 6, base + 7, base + 3),
(base + 3, base + 7, base + 4, base + 0),
]
)
def append_cylindrical_ring_band(verts, faces, x, inner_radius, outer_radius, segments=96, start_angle=-math.pi, end_angle=math.pi):
"""Append an annular band in the Y/Z plane at axial coordinate x."""
span = end_angle - start_angle
base = len(verts)
for i in range(segments):
a = start_angle + span * i / segments
verts.append((x, inner_radius * math.cos(a), inner_radius * math.sin(a)))
verts.append((x, outer_radius * math.cos(a), outer_radius * math.sin(a)))
for i in range(segments):
j = (i + 1) % segments
faces.append((base + i * 2, base + j * 2, base + j * 2 + 1, base + i * 2 + 1))

124
vibegeometry/blender.py Normal file
View file

@ -0,0 +1,124 @@
"""Thin bpy helpers for scene construction and inspection renders."""
from __future__ import annotations
def add_material(name, color, emission=False, strength=0.0, alpha=1.0):
import bpy
material = bpy.data.materials.new(name)
material.diffuse_color = (color[0], color[1], color[2], alpha)
material.use_nodes = True
bsdf = material.node_tree.nodes.get("Principled BSDF")
if bsdf:
if emission:
bsdf.inputs["Emission Color"].default_value = (color[0], color[1], color[2], 1.0)
bsdf.inputs["Emission Strength"].default_value = strength
bsdf.inputs["Base Color"].default_value = (color[0], color[1], color[2], alpha)
bsdf.inputs["Roughness"].default_value = 0.62
bsdf.inputs["Alpha"].default_value = alpha
if alpha < 1.0:
material.blend_method = "BLEND"
material.use_screen_refraction = True
material.show_transparent_back = True
return material
def clear_scene():
import bpy
bpy.ops.object.select_all(action="SELECT")
bpy.ops.object.delete()
def add_mesh_parts(name, verts, faces, material):
import bpy
mesh = bpy.data.meshes.new(name + "Mesh")
mesh.from_pydata(verts, [], faces)
mesh.update()
obj = bpy.data.objects.new(name, mesh)
bpy.context.collection.objects.link(obj)
obj.data.materials.append(material)
return obj
def add_multi_polyline_curve(name, lines, bevel, material, resolution=2):
import bpy
curve = bpy.data.curves.new(name + "Curve", "CURVE")
curve.dimensions = "3D"
curve.resolution_u = resolution
curve.bevel_depth = bevel
curve.bevel_resolution = 2
for line in lines:
spline = curve.splines.new("POLY")
spline.points.add(len(line) - 1)
for point, co in zip(spline.points, line):
point.co = (co[0], co[1], co[2], 1.0)
obj = bpy.data.objects.new(name, curve)
bpy.context.collection.objects.link(obj)
obj.data.materials.append(material)
return obj
def add_cylinder_between(name, start, end, radius, material, vertices=16):
import bpy
from mathutils import Vector
start_v = Vector(start)
end_v = Vector(end)
mid = (start_v + end_v) / 2.0
direction = end_v - start_v
length = direction.length
bpy.ops.mesh.primitive_cylinder_add(vertices=vertices, radius=radius, depth=length, location=mid)
obj = bpy.context.object
obj.name = name
obj.data.name = name + "Mesh"
obj.rotation_euler = direction.to_track_quat("Z", "Y").to_euler()
obj.data.materials.append(material)
return obj
def add_curve_polyline(name, points, bevel, material, resolution=2):
import bpy
curve = bpy.data.curves.new(name + "Curve", "CURVE")
curve.dimensions = "3D"
curve.resolution_u = resolution
curve.bevel_depth = bevel
curve.bevel_resolution = 4
spline = curve.splines.new("POLY")
spline.points.add(len(points) - 1)
for point, co in zip(spline.points, points):
point.co = (co[0], co[1], co[2], 1.0)
obj = bpy.data.objects.new(name, curve)
bpy.context.collection.objects.link(obj)
obj.data.materials.append(material)
return obj
def add_box(name, loc, axes, size, material):
import bpy
from mathutils import Matrix, Vector
bpy.ops.mesh.primitive_cube_add(size=1.0, location=loc)
obj = bpy.context.object
obj.name = name
obj.dimensions = size
matrix = Matrix.Identity(4)
matrix.col[0].xyz = Vector(axes[0]).normalized()
matrix.col[1].xyz = Vector(axes[1]).normalized()
matrix.col[2].xyz = Vector(axes[2]).normalized()
obj.rotation_euler = matrix.to_euler()
obj.data.materials.append(material)
bpy.ops.object.transform_apply(location=False, rotation=False, scale=True)
return obj
def aim_camera(obj, target):
from mathutils import Vector
direction = Vector(target) - obj.location
obj.rotation_euler = direction.to_track_quat("-Z", "Y").to_euler()

View file

@ -0,0 +1,33 @@
"""Coordinate-frame helpers for procedural Blender scenes."""
from __future__ import annotations
import math
from dataclasses import dataclass
TAU = math.tau
@dataclass(frozen=True)
class CylinderFrame:
"""Shared cylindrical frame: x is axial, angle is azimuth, radius is spin radius."""
inner_radius: float
@staticmethod
def radial(angle: float) -> tuple[float, float, float]:
return (0.0, math.cos(angle), math.sin(angle))
@staticmethod
def tangent(angle: float) -> tuple[float, float, float]:
return (0.0, -math.sin(angle), math.cos(angle))
@staticmethod
def cyl_point(x: float, angle: float, radius: float) -> tuple[float, float, float]:
return (x, radius * math.cos(angle), radius * math.sin(angle))
def surface_point(self, x: float, angle: float, radius: float | None = None, lift: float = -0.04) -> tuple[float, float, float]:
r = (self.inner_radius if radius is None else radius) + lift
return self.cyl_point(x, angle, r)

44
vibegeometry/fields.py Normal file
View file

@ -0,0 +1,44 @@
"""Deterministic scalar fields for procedural distribution."""
from __future__ import annotations
import math
def smoothstep(t: float) -> float:
return t * t * (3.0 - 2.0 * t)
def hash01(ix: int, iy: int = 0, seed: int = 0) -> float:
n = ix * 374761393 + iy * 668265263 + seed * 1442695041
n = (n ^ (n >> 13)) * 1274126177
n = n ^ (n >> 16)
return (n & 0xFFFFFFFF) / 0xFFFFFFFF
def value_noise_2d(x: float, y: float, seed: int = 0) -> float:
ix = math.floor(x)
iy = math.floor(y)
fx = smoothstep(x - ix)
fy = smoothstep(y - iy)
a = hash01(ix, iy, seed)
b = hash01(ix + 1, iy, seed)
c = hash01(ix, iy + 1, seed)
d = hash01(ix + 1, iy + 1, seed)
ab = a + (b - a) * fx
cd = c + (d - c) * fx
return ab + (cd - ab) * fy
def fbm_2d(x: float, y: float, seed: int = 0, octaves: int = 4) -> float:
value = 0.0
amplitude = 0.5
frequency = 1.0
total = 0.0
for octave in range(octaves):
value += value_noise_2d(x * frequency, y * frequency, seed + octave * 19) * amplitude
total += amplitude
amplitude *= 0.5
frequency *= 2.03
return value / total if total else 0.0

27
vibegeometry/verify.py Normal file
View file

@ -0,0 +1,27 @@
"""Verification helpers for generated Blender scenes."""
from __future__ import annotations
from pathlib import Path
def assert_objects_exist(bpy_module, names):
missing = sorted(name for name in names if name not in bpy_module.data.objects)
if missing:
raise AssertionError(f"Missing required objects: {missing}")
def assert_render_artifacts(paths, min_size=10_000):
for path in paths:
artifact = Path(path)
if not artifact.exists():
raise AssertionError(f"Missing generated artifact {artifact}")
if artifact.stat().st_size <= min_size:
raise AssertionError(f"Generated artifact is suspiciously small: {artifact}")
def assert_scene_density(bpy_module, min_geometry_objects):
mesh_objects = [obj for obj in bpy_module.data.objects if obj.type in {"MESH", "CURVE", "FONT"}]
if len(mesh_objects) < min_geometry_objects:
raise AssertionError(f"Expected a populated scene, found {len(mesh_objects)} geometry/text objects")
return mesh_objects