diff --git a/docs/research/geonodes-translation-corpus.md b/docs/research/geonodes-translation-corpus.md index e8b4694..2e6d946 100644 --- a/docs/research/geonodes-translation-corpus.md +++ b/docs/research/geonodes-translation-corpus.md @@ -114,6 +114,13 @@ Completed Cartesian function graph entry: - Toolchain note: `external/geometry-script` is patched so nested `@tree` calls create `GeometryNodeGroup` nodes under Blender 5.1. +Completed pie chart entry: + +- `docs/research/translations/shriinivas-piechart.md` +- Source groups: `NodeGroup`, `Pie Chart` +- Verification: translated segment and three-slice chart match evaluated source + geometry with sorted max vertex delta `0.0` in Blender 5.1.1. + ## Extraction Workflow 1. Download one source `.blend` into an ignored experiment folder. diff --git a/docs/research/procedural-doctrine.md b/docs/research/procedural-doctrine.md index f0226d1..5003636 100644 --- a/docs/research/procedural-doctrine.md +++ b/docs/research/procedural-doctrine.md @@ -96,6 +96,24 @@ This is a good control pattern. A graph can expose a broad global control like Use this when a graph should work plainly by default but become more expressive when upstream geometry brings richer attributes. +## Groups Can Pass Batons + +Reusable node groups do not have to output geometry alone. They can output +state: an angle, count, bound, accumulated offset, or any other value the next +group needs. + +The pie chart segment returns: + +```text +Curve: visible wedge geometry +End: the angle where the next wedge should begin +``` + +That makes the chart group a relay. Each segment runs its stretch, then passes +the end angle forward. This is the pattern for cumulative structures: charts, +stairs, chains, segmented shells, stacked floors, ribs, vertebrae, and anything +where part N+1 depends on where part N stopped. + ## Stack Abstractions Carefully There are two abstraction layers available: diff --git a/docs/research/translations/shriinivas-piechart.md b/docs/research/translations/shriinivas-piechart.md new file mode 100644 index 0000000..e10ddd3 --- /dev/null +++ b/docs/research/translations/shriinivas-piechart.md @@ -0,0 +1,127 @@ +# Translation: Shriinivas Pie Chart + +## Source + +- Repository: https://github.com/Shriinivas/geometrynodes +- Source file: `piechart.blend` +- Source groups: + - `NodeGroup` + - `Pie Chart` +- Geometry Script recreation: + `examples/geometry_script/shriinivas_piechart.py` + +## Graph Family + +The small pie chart is a nested state-passing graph. Each segment receives: + +```text +start angle +value +total +shift distance +materials +``` + +It outputs: + +```text +segment geometry +end angle +``` + +The chart group chains three segment calls: + +```text +A starts at 0 +B starts at A.end +C starts at B.end +``` + +This is the new abstraction lesson: a node group can return geometry and a +control value. The geometry is the visible slice; the control value is the baton +passed to the next runner. + +## Segment Map + +Source group: `NodeGroup` + +Geometry Script group: `VG Pie Segment` + +Mechanism: + +- Compute `fraction = Value / Total`. +- Compute `sweep = fraction * tau`. +- Compute `End = Start + sweep`. +- Build a connected-center `Arc` from `Start` through `sweep`. +- Fill the arc into a mesh face. +- Extrude the face downward for pie thickness. +- Convert `fraction * 100` to a string and append `%`. +- Convert that string to curves, fill it, and extrude it lightly into text. +- Use `Attribute Statistic` over the slice face's `Position` to find a center + offset for the text. +- Compute a shift vector at the segment midpoint: + +```text +mid = Start + sweep / 2 +offset = (cos(mid) * Shift, sin(mid) * Shift, 0) +``` + +- Join text, extruded slice, and cap face. +- Offset the joined geometry by the shift vector. +- Return geometry plus `End`. + +Metaphor: the segment group is a pastry cutter with a receipt printer attached. +It cuts one wedge, stamps the percentage label near its middle, nudges the whole +wedge outward if requested, and hands the next cutter the angle where it stopped. + +## Chart Map + +Source group: `Pie Chart` + +Geometry Script group: `VG Pie Chart` + +Mechanism: + +- Compute `Total = A + B + C`. +- Call `VG Pie Segment` for A with `Start = 0`. +- Call `VG Pie Segment` for B with `Start = A.End`. +- Call `VG Pie Segment` for C with `Start = B.End`. +- Join the three segment geometries. + +Metaphor: the chart group is not a shape maker. It is an accountant-conductor: +it totals the values, cues each segment in order, and makes sure every slice +starts where the previous one ended. + +## Verification + +Run: + +```powershell +& 'C:\Program Files (x86)\Steam\steamapps\common\Blender\blender.exe' --background '.\experiments\source-blends\shriinivas-piechart.blend' --python '.\tools\verify_piechart_translations.py' +``` + +Results from Blender 5.1.1: + +| Case | Vertices | Sorted max delta | Status | +| --- | ---: | ---: | --- | +| Segment | 319 | 0.0 | accepted | +| Chart | 957 | 0.0 | accepted | + +Generated groups saved and reinspected: + +- `VG Pie Segment`: 30 nodes, 48 links. +- `VG Pie Chart`: 8 nodes, 25 links. + +## Lessons + +- A reusable node group can output both visible geometry and invisible state. + That state can drive later graph calls. +- `Arc` with `connect_center=True` is a wedge skeleton. `Fill Curve` gives it a + face; `Extrude Mesh` gives it thickness. +- `Attribute Statistic` can summarize geometry to produce placement handles. + Here, the mean position of the wedge face becomes a label anchor. +- Text is geometry too: string operations feed `String to Curves`, then the same + fill/extrude/material pipeline applies. +- Charts and other sequential structures often need cumulative scalar flow. + In Geometry Script, nested `@tree` calls make that flow readable as ordinary + Python variable passing. diff --git a/examples/geometry_script/shriinivas_piechart.py b/examples/geometry_script/shriinivas_piechart.py new file mode 100644 index 0000000..302eecb --- /dev/null +++ b/examples/geometry_script/shriinivas_piechart.py @@ -0,0 +1,145 @@ +"""Geometry Script recreation of the small pie chart from Shriinivas/piechart.blend.""" + +try: + from tools.geometry_script_loader import load_repo_geometry_script +except ModuleNotFoundError: + import sys + from pathlib import Path + + sys.path.insert(0, str(Path(__file__).resolve().parents[2])) + from tools.geometry_script_loader import load_repo_geometry_script + +load_repo_geometry_script() + +from geometry_script import * # noqa: F403 - Geometry Script exposes node functions as DSL globals. + +TAU = 6.2831854820251465 + + +@tree("VG Pie Segment") +def vg_pie_segment( + start: Float = TAU, + value: Float = 0.5, + total: Float = 0.5, + shift: Float = 0.5, + segment_material: Material = None, + text_material: Material = None, +): + fraction = math(operation=Math.Operation.DIVIDE, value=(value, total)) + sweep = math(operation=Math.Operation.MULTIPLY, value=(fraction, TAU)) + end = math(operation=Math.Operation.ADD, value=(start, sweep)) + + percentage = math(operation=Math.Operation.MULTIPLY, value=(fraction, 100.0)) + percentage_string = value_to_string(data_type=ValueToString.DataType.FLOAT, value=percentage, decimals=2) + label = join_strings(delimiter="", strings=[percentage_string, string(string="%")]) + + arc_curve = arc( + mode=Arc.Mode.RADIUS, + resolution=64, + radius=1.0, + start_angle=start, + sweep_angle=sweep, + connect_center=True, + ) + face = arc_curve.fill_curve() + extruded_face = face.extrude_mesh(mode=ExtrudeMesh.Mode.FACES, offset_scale=-0.1) + + text_curve = string_to_curves(string=label, size=0.2, align_x="Center", align_y="Middle").curve_instances + text_face = text_curve.fill_curve() + text_mesh = text_face.extrude_mesh(mode=ExtrudeMesh.Mode.FACES, offset_scale=0.025).mesh + face_center = attribute_statistic( + data_type=AttributeStatistic.DataType.FLOAT_VECTOR, + domain=AttributeStatistic.Domain.FACE, + geometry=face, + attribute=position(), + ).mean + positioned_text = text_mesh.set_position(offset=face_center).set_material(material=text_material) + + mid_angle = math(operation=Math.Operation.ADD, value=(start, math(operation=Math.Operation.DIVIDE, value=(sweep, 2.0)))) + shift_vector = combine_xyz( + x=math(operation=Math.Operation.MULTIPLY, value=(math(operation=Math.Operation.COSINE, value=mid_angle), shift)), + y=math(operation=Math.Operation.MULTIPLY, value=(math(operation=Math.Operation.SINE, value=mid_angle), shift)), + z=0.0, + ) + + segment = extruded_face.mesh.set_material(material=segment_material) + cap = face.set_material(material=segment_material) + geometry = join_geometry(geometry=[positioned_text, segment, cap]).set_position(offset=shift_vector) + return {"Curve": geometry, "End": end} + + +@tree("VG Pie Chart") +def vg_pie_chart( + geometry: Geometry, + a: Float = 0.5, + a_shift: Float = 0.5, + a_material: Material = None, + b: Float = 0.5, + b_shift: Float = 0.0, + b_material: Material = None, + c: Float = 0.5, + c_shift: Float = 0.0, + c_material: Material = None, + text_material: Material = None, +): + del geometry + + total = math(operation=Math.Operation.ADD, value=(math(operation=Math.Operation.ADD, value=(a, b)), c)) + a_geometry, a_end = vg_pie_segment( + start=0.0, + value=a, + total=total, + shift=a_shift, + segment_material=a_material, + text_material=text_material, + ) + b_geometry, b_end = vg_pie_segment( + start=a_end, + value=b, + total=total, + shift=b_shift, + segment_material=b_material, + text_material=text_material, + ) + c_geometry, _c_end = vg_pie_segment( + start=b_end, + value=c, + total=total, + shift=c_shift, + segment_material=c_material, + text_material=text_material, + ) + return join_geometry(geometry=[a_geometry, b_geometry, c_geometry]) + + +def _finalize_groups(): + import bpy + + groups = [] + for name in ("VG Pie Segment", "VG Pie Chart"): + group = bpy.data.node_groups.get(name) + if not group: + continue + group.use_fake_user = True + if hasattr(group, "interface"): + for item in group.interface.items_tree: + if item.item_type == "SOCKET" and item.in_out == "OUTPUT" and item.name == "Result": + item.name = "Geometry" + if item.item_type == "SOCKET" and item.in_out == "INPUT" and item.name in {"A", "B", "C"}: + item.name = item.name.lower() + groups.append(group) + return groups + + +_finalize_groups() + + +if __name__ == "__main__": + for group in _finalize_groups(): + print( + "VG_PIE_GROUP", + group.name, + len(group.nodes), + len(group.links), + sorted(set(node.bl_idname for node in group.nodes)), + ) diff --git a/notes/fresh-workspace-handoff.md b/notes/fresh-workspace-handoff.md index 3fc9a95..16b045d 100644 --- a/notes/fresh-workspace-handoff.md +++ b/notes/fresh-workspace-handoff.md @@ -35,6 +35,10 @@ The completed Cartesian function translation is `Geometry Nodes Line`, `Geometry Nodes Parabola`, and `Geometry Nodes Circle` from `cartesian.blend`. +The completed pie chart translation is +`docs/research/translations/shriinivas-piechart.md`. It covers `NodeGroup` and +`Pie Chart` from `piechart.blend`. + `docs/research/procedural-doctrine.md` now stores the current intuitive model for mapping geometric ideas to Geometry Script: curves as rails, fields as weather, `Set Position` as the hinge, profiles as body, attributes as optional @@ -46,8 +50,8 @@ Run `.\tools\setup_geometry_script_clone.ps1` if `external/geometry-script` is missing in a fresh workspace. The current next action is to select the next Tier 1 source graph from -`Shriinivas/geometrynodes`, preferably `piechart.blend` or `fieldvalue.blend`, -inspect its node groups, and add the next doctrine-backed translation entry. +`Shriinivas/geometrynodes`, preferably `fieldvalue.blend`, inspect its node +groups, and add the next doctrine-backed translation entry. ## Important Invariants diff --git a/state/evidence.jsonl b/state/evidence.jsonl index c3d675f..95e39e0 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -6,3 +6,4 @@ {"ts": "2026-05-04T12:24:11+00:00", "type": "verification", "status": "accepted", "note": "Verified translated Shriinivas cartesian helper behavior against source NodeGroup.001 by wrapping both helpers as Curve+Vector -> Set Position -> Curve to Mesh and comparing evaluated mesh vertices. Test used Count=25, Start=(-0.45,0,0), End=(0.45,0,0), r=0.6, Value=1.25; source and translation both produced 200 vertices with max delta 0.0."} {"ts": "2026-05-04T12:42:20+00:00", "type": "implementation", "status": "accepted", "note": "Translated and verified all four inspected Shriinivas/cartesian.blend graph groups: helper, line, parabola, and circle. Verification compares evaluated meshes in Blender 5.1.1; helper/line/parabola match direct vertex order with max delta 0.0, circle matches sorted vertex set with max sorted delta 0.0."} {"ts": "2026-05-04T12:42:20+00:00", "type": "tooling", "status": "accepted", "note": "Reverted direct AppData Geometry Script surgery. Cloned upstream Geometry Script into ignored external/geometry-script, committed local branch vibegeometry/blender-5-nested-tree-groups with nested @tree group fix, stored patch under patches/geometry-script, and added setup_geometry_script_clone.ps1 plus loader so VibeGeometry uses the repo-local patched clone."} +{"ts": "2026-05-04T12:48:18+00:00", "type": "implementation", "status": "accepted", "note": "Translated and verified Shriinivas/piechart.blend small graph family: NodeGroup -> VG Pie Segment and Pie Chart -> VG Pie Chart. Segment and chart evaluated mesh comparisons both passed in Blender 5.1.1 with sorted max vertex delta 0.0. Doctrine added: reusable groups can pass invisible scalar state such as End angle alongside visible geometry."} diff --git a/state/map.yaml b/state/map.yaml index 085109a..9ee1716 100644 --- a/state/map.yaml +++ b/state/map.yaml @@ -82,5 +82,5 @@ data_flow: - If state grows faster than understanding, cut it back. current_status: - summary: VibeGeometry has an initial persistence scaffold plus verified Geometry Script translations for all four inspected Shriinivas/cartesian.blend groups: helper, line, parabola, and circle. - next_action: Select the next Tier 1 source graph from Shriinivas/geometrynodes, preferably `piechart.blend` or `fieldvalue.blend`, inspect its node groups, and add the next doctrine-backed translation entry. + summary: VibeGeometry has an initial persistence scaffold plus verified Geometry Script translations for the inspected Shriinivas cartesian and pie chart graph families. + next_action: Select the next Tier 1 source graph from Shriinivas/geometrynodes, preferably `fieldvalue.blend`, inspect its node groups, and add the next doctrine-backed translation entry. diff --git a/tools/verify_piechart_translations.py b/tools/verify_piechart_translations.py new file mode 100644 index 0000000..270774f --- /dev/null +++ b/tools/verify_piechart_translations.py @@ -0,0 +1,175 @@ +"""Compare Shriinivas pie chart source graphs against Geometry Script translations.""" + +from __future__ import annotations + +import importlib.util +import json +import math +import sys +from pathlib import Path +from typing import Any + +import bpy + + +ROOT = Path(__file__).resolve().parents[1] +TRANSLATION = ROOT / "examples" / "geometry_script" / "shriinivas_piechart.py" + + +def _load_translation() -> None: + spec = importlib.util.spec_from_file_location("vg_piechart_translation", TRANSLATION) + if spec is None or spec.loader is None: + raise RuntimeError(f"Could not load translation script: {TRANSLATION}") + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + + +def _socket_by_name(sockets: Any, name: str): + for socket in sockets: + if socket.name == name: + return socket + raise KeyError(name) + + +def _new_geometry_group(name: str) -> bpy.types.GeometryNodeTree: + if name in bpy.data.node_groups: + bpy.data.node_groups.remove(bpy.data.node_groups[name]) + group = bpy.data.node_groups.new(name, "GeometryNodeTree") + group.is_modifier = True + group.interface.new_socket(name="Geometry", in_out="INPUT", socket_type="NodeSocketGeometry") + group.interface.new_socket(name="Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry") + return group + + +def _set_inputs(group_node: bpy.types.GeometryNode, values: dict[str, object]) -> None: + lower_values = {name.lower(): value for name, value in values.items()} + for socket in group_node.inputs: + if socket.name in values: + socket.default_value = values[socket.name] + elif socket.name.lower() in lower_values: + socket.default_value = lower_values[socket.name.lower()] + + +def _build_segment_wrapper(name: str, group: bpy.types.GeometryNodeTree, inputs: dict[str, object]): + wrapper = _new_geometry_group(name) + nodes = wrapper.nodes + links = wrapper.links + group_node = nodes.new("GeometryNodeGroup") + group_node.node_tree = group + _set_inputs(group_node, inputs) + group_output = nodes.new("NodeGroupOutput") + links.new(_socket_by_name(group_node.outputs, "Curve"), _socket_by_name(group_output.inputs, "Geometry")) + return wrapper + + +def _build_chart_wrapper(name: str, group: bpy.types.GeometryNodeTree, inputs: dict[str, object]): + wrapper = _new_geometry_group(name) + nodes = wrapper.nodes + links = wrapper.links + group_input = nodes.new("NodeGroupInput") + group_node = nodes.new("GeometryNodeGroup") + group_node.node_tree = group + _set_inputs(group_node, inputs) + group_output = nodes.new("NodeGroupOutput") + if any(socket.name == "Geometry" for socket in group_node.inputs): + links.new(_socket_by_name(group_input.outputs, "Geometry"), _socket_by_name(group_node.inputs, "Geometry")) + links.new(_socket_by_name(group_node.outputs, "Geometry"), _socket_by_name(group_output.inputs, "Geometry")) + return wrapper + + +def _evaluated_vertices(group: bpy.types.GeometryNodeTree) -> list[tuple[float, float, float]]: + mesh = bpy.data.meshes.new(group.name + " Input Mesh") + obj = bpy.data.objects.new(group.name + " Object", mesh) + bpy.context.collection.objects.link(obj) + modifier = obj.modifiers.new(group.name + " Modifier", "NODES") + modifier.node_group = group + depsgraph = bpy.context.evaluated_depsgraph_get() + evaluated = obj.evaluated_get(depsgraph) + evaluated_mesh = evaluated.to_mesh() + try: + return [tuple(vertex.co) for vertex in evaluated_mesh.vertices] + finally: + evaluated.to_mesh_clear() + + +def _bounds(vertices: list[tuple[float, float, float]]) -> dict[str, list[float]]: + return { + "min": [min(vertex[i] for vertex in vertices) for i in range(3)], + "max": [max(vertex[i] for vertex in vertices) for i in range(3)], + } + + +def _compare(case: str, source_vertices, translated_vertices) -> dict[str, object]: + if len(source_vertices) != len(translated_vertices): + return { + "case": case, + "ok": False, + "reason": "vertex_count_mismatch", + "source_vertex_count": len(source_vertices), + "translated_vertex_count": len(translated_vertices), + "source_bounds": _bounds(source_vertices) if source_vertices else None, + "translated_bounds": _bounds(translated_vertices) if translated_vertices else None, + } + max_delta = 0.0 + for source, translated in zip(sorted(source_vertices), sorted(translated_vertices)): + max_delta = max(max_delta, math.dist(source, translated)) + return { + "case": case, + "ok": max_delta <= 1e-6, + "source_vertex_count": len(source_vertices), + "translated_vertex_count": len(translated_vertices), + "max_sorted_vertex_delta": max_delta, + "source_bounds": _bounds(source_vertices), + "translated_bounds": _bounds(translated_vertices), + } + + +def main() -> int: + _load_translation() + text_material = bpy.data.materials.get("Text Material") or bpy.data.materials.new("Text Material") + mat1 = bpy.data.materials.get("Material 1") or bpy.data.materials.new("Material 1") + mat2 = bpy.data.materials.get("Material 2") or bpy.data.materials.new("Material 2") + mat3 = bpy.data.materials.get("Material 3") or bpy.data.materials.new("Material 3") + + segment_inputs = { + "Start": 0.35, + "Value": 0.8, + "Total": 2.4, + "Shift": 0.2, + "Segment Material": mat1, + "Text Material": text_material, + } + chart_inputs = { + "A": 0.8, + "A Shift": 0.2, + "A Material": mat1, + "B": 1.1, + "B Shift": 0.05, + "B Material": mat2, + "C": 0.5, + "C Shift": 0.0, + "C Material": mat3, + "Text Material": text_material, + } + + cases = [ + ( + "segment", + _build_segment_wrapper("VG Verify Source Pie Segment", bpy.data.node_groups["NodeGroup"], segment_inputs), + _build_segment_wrapper("VG Verify Translated Pie Segment", bpy.data.node_groups["VG Pie Segment"], segment_inputs), + ), + ( + "chart", + _build_chart_wrapper("VG Verify Source Pie Chart", bpy.data.node_groups["Pie Chart"], chart_inputs), + _build_chart_wrapper("VG Verify Translated Pie Chart", bpy.data.node_groups["VG Pie Chart"], chart_inputs), + ), + ] + results = [_compare(case, _evaluated_vertices(source), _evaluated_vertices(translated)) for case, source, translated in cases] + ok = all(result["ok"] for result in results) + print("VG_PIECHART_TRANSLATION_BEHAVIOR " + json.dumps({"ok": ok, "results": results}, sort_keys=True)) + return 0 if ok else 1 + + +if __name__ == "__main__": + raise SystemExit(main())