diff --git a/README.md b/README.md index b75813c..7237549 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/research/builds/aetheria-bloom-habitat.md b/docs/research/builds/aetheria-bloom-habitat.md index 6c957bf..8f2e9d0 100644 --- a/docs/research/builds/aetheria-bloom-habitat.md +++ b/docs/research/builds/aetheria-bloom-habitat.md @@ -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 diff --git a/examples/geometry_script/aetheria_bloom_habitat.py b/examples/geometry_script/aetheria_bloom_habitat.py index 07da694..61901d5 100644 --- a/examples/geometry_script/aetheria_bloom_habitat.py +++ b/examples/geometry_script/aetheria_bloom_habitat.py @@ -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 diff --git a/experiments/generated/aetheria_bloom/aetheria_bloom_habitat.blend b/experiments/generated/aetheria_bloom/aetheria_bloom_habitat.blend index f7ecf25..ad07071 100644 Binary files a/experiments/generated/aetheria_bloom/aetheria_bloom_habitat.blend and b/experiments/generated/aetheria_bloom/aetheria_bloom_habitat.blend differ diff --git a/experiments/generated/aetheria_bloom/aetheria_bloom_habitat.png b/experiments/generated/aetheria_bloom/aetheria_bloom_habitat.png index a456a4a..2a9cfc7 100644 Binary files a/experiments/generated/aetheria_bloom/aetheria_bloom_habitat.png and b/experiments/generated/aetheria_bloom/aetheria_bloom_habitat.png differ diff --git a/experiments/generated/aetheria_bloom/aetheria_bloom_hubward_endcap_terraces.png b/experiments/generated/aetheria_bloom/aetheria_bloom_hubward_endcap_terraces.png index ff7b162..3196198 100644 Binary files a/experiments/generated/aetheria_bloom/aetheria_bloom_hubward_endcap_terraces.png and b/experiments/generated/aetheria_bloom/aetheria_bloom_hubward_endcap_terraces.png differ diff --git a/experiments/generated/aetheria_bloom/aetheria_bloom_interior_world.png b/experiments/generated/aetheria_bloom/aetheria_bloom_interior_world.png index f88cf37..3ff8b31 100644 Binary files a/experiments/generated/aetheria_bloom/aetheria_bloom_interior_world.png and b/experiments/generated/aetheria_bloom/aetheria_bloom_interior_world.png differ diff --git a/notes/fresh-workspace-handoff.md b/notes/fresh-workspace-handoff.md index c994342..38b2b45 100644 --- a/notes/fresh-workspace-handoff.md +++ b/notes/fresh-workspace-handoff.md @@ -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 diff --git a/notes/vibegeometry-implementation-plan.md b/notes/vibegeometry-implementation-plan.md index ebeb05e..1cc8910 100644 --- a/notes/vibegeometry-implementation-plan.md +++ b/notes/vibegeometry-implementation-plan.md @@ -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 diff --git a/state/evidence.jsonl b/state/evidence.jsonl index be40e11..456e365 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -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."} diff --git a/state/map.yaml b/state/map.yaml index a82242d..8afd570 100644 --- a/state/map.yaml +++ b/state/map.yaml @@ -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 diff --git a/tools/verify_aetheria_bloom_habitat.py b/tools/verify_aetheria_bloom_habitat.py index 08c654e..27dfc38 100644 --- a/tools/verify_aetheria_bloom_habitat.py +++ b/tools/verify_aetheria_bloom_habitat.py @@ -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 diff --git a/vibegeometry/__init__.py b/vibegeometry/__init__.py new file mode 100644 index 0000000..1f568f9 --- /dev/null +++ b/vibegeometry/__init__.py @@ -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", +] diff --git a/vibegeometry/batching.py b/vibegeometry/batching.py new file mode 100644 index 0000000..29be7b9 --- /dev/null +++ b/vibegeometry/batching.py @@ -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)) + diff --git a/vibegeometry/blender.py b/vibegeometry/blender.py new file mode 100644 index 0000000..d97b2c8 --- /dev/null +++ b/vibegeometry/blender.py @@ -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() + diff --git a/vibegeometry/coordinates.py b/vibegeometry/coordinates.py new file mode 100644 index 0000000..ac115c4 --- /dev/null +++ b/vibegeometry/coordinates.py @@ -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) + diff --git a/vibegeometry/fields.py b/vibegeometry/fields.py new file mode 100644 index 0000000..e7c86a4 --- /dev/null +++ b/vibegeometry/fields.py @@ -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 + diff --git a/vibegeometry/verify.py b/vibegeometry/verify.py new file mode 100644 index 0000000..dc29fd7 --- /dev/null +++ b/vibegeometry/verify.py @@ -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