Skip to content

Commit fdf8f9b

Browse files
collindutterclaude
andcommitted
feat: add Rodin Bang! 3D edit node
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent c410bb6 commit fdf8f9b

3 files changed

Lines changed: 505 additions & 0 deletions

File tree

griptape_nodes_library.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2617,6 +2617,17 @@
26172617
"group": "create"
26182618
}
26192619
},
2620+
{
2621+
"class_name": "RodinBang3DEdit",
2622+
"file_path": "griptape_nodes_library/three_d/rodin_bang_3d_edit.py",
2623+
"metadata": {
2624+
"category": "3D",
2625+
"description": "Split 3D models into parts using Rodin Bang! via Griptape model proxy",
2626+
"display_name": "Rodin Bang! 3D Edit",
2627+
"icon": "box",
2628+
"group": "edit"
2629+
}
2630+
},
26202631
{
26212632
"class_name": "ForLoopStartNode",
26222633
"file_path": "griptape_nodes_library/execution/for_loop_start.py",
Lines changed: 308 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,308 @@
1+
from __future__ import annotations
2+
3+
import logging
4+
from contextlib import suppress
5+
from typing import Any
6+
7+
from griptape_nodes.exe_types.core_types import Parameter, ParameterMode
8+
from griptape_nodes.exe_types.param_components.project_file_parameter import ProjectFileParameter
9+
from griptape_nodes.exe_types.param_types.parameter_dict import ParameterDict
10+
from griptape_nodes.exe_types.param_types.parameter_int import ParameterInt
11+
from griptape_nodes.exe_types.param_types.parameter_string import ParameterString
12+
from griptape_nodes.exe_types.param_types.parameter_three_d import Parameter3D
13+
from griptape_nodes.traits.options import Options
14+
from griptape_nodes.traits.slider import Slider
15+
16+
from griptape_nodes_library.griptape_proxy_node import GriptapeProxyNode
17+
from griptape_nodes_library.three_d.three_d_artifact import ThreeDUrlArtifact
18+
19+
logger = logging.getLogger("griptape_nodes")
20+
21+
__all__ = ["RodinBang3DEdit"]
22+
23+
# Output format options
24+
GEOMETRY_FORMAT_OPTIONS = ["glb", "usdz", "fbx", "obj", "stl"]
25+
DEFAULT_GEOMETRY_FORMAT = "glb"
26+
27+
# Material options
28+
MATERIAL_OPTIONS = ["PBR", "Shaded", "None", "All"]
29+
DEFAULT_MATERIAL = "PBR"
30+
31+
# Resolution options
32+
RESOLUTION_OPTIONS = ["Basic", "High"]
33+
DEFAULT_RESOLUTION = "Basic"
34+
35+
# Strength range
36+
DEFAULT_STRENGTH = 5
37+
MIN_STRENGTH = 2
38+
MAX_STRENGTH = 12
39+
40+
41+
class RodinBang3DEdit(GriptapeProxyNode):
42+
"""Split 3D models into parts using Rodin Bang! via Griptape model proxy.
43+
44+
Takes a previously generated Rodin Gen-2 asset and splits it into
45+
individual parts. Higher strength values produce more pieces.
46+
47+
Inputs:
48+
- asset_id (str): UUID of a previous Rodin Gen-2 generation task
49+
- strength (int): Splitting intensity (2-12). Higher values produce more pieces.
50+
- geometry_file_format (str): Output 3D file format (glb, usdz, fbx, obj, stl)
51+
- material (str): Material type (PBR, Shaded, None, All)
52+
- resolution (str): Texture resolution (Basic=2K, High=4K)
53+
54+
Outputs:
55+
- generation_id (str): Generation ID from the API
56+
- provider_response (dict): Verbatim provider response from the model proxy
57+
- model_url (ThreeDUrlArtifact): Primary generated 3D model part
58+
- all_files (list): URLs of all generated part files
59+
"""
60+
61+
def __init__(self, **kwargs: Any) -> None:
62+
super().__init__(**kwargs)
63+
self.category = "API Nodes"
64+
self.description = "Split 3D models into parts using Rodin Bang! via Griptape model proxy"
65+
66+
# --- INPUT PARAMETERS ---
67+
self.add_parameter(
68+
ParameterString(
69+
name="asset_id",
70+
tooltip="UUID of a previous Rodin Gen-2 generation task to split into parts",
71+
placeholder_text="Enter a Rodin Gen-2 asset UUID...",
72+
allow_output=False,
73+
ui_options={"display_name": "Asset ID"},
74+
)
75+
)
76+
77+
self.add_parameter(
78+
ParameterInt(
79+
name="strength",
80+
default_value=DEFAULT_STRENGTH,
81+
tooltip="Controls splitting intensity. Higher values produce more pieces.",
82+
allow_output=False,
83+
min_val=MIN_STRENGTH,
84+
max_val=MAX_STRENGTH,
85+
traits={Slider(min_val=MIN_STRENGTH, max_val=MAX_STRENGTH)},
86+
)
87+
)
88+
89+
self.add_parameter(
90+
ParameterString(
91+
name="geometry_file_format",
92+
default_value=DEFAULT_GEOMETRY_FORMAT,
93+
tooltip="Output 3D file format",
94+
allow_output=False,
95+
traits={Options(choices=GEOMETRY_FORMAT_OPTIONS)},
96+
ui_options={"display_name": "File Format"},
97+
)
98+
)
99+
100+
self.add_parameter(
101+
ParameterString(
102+
name="material",
103+
default_value=DEFAULT_MATERIAL,
104+
tooltip="Material type: PBR (physically based), Shaded (baked lighting), None, or All",
105+
allow_output=False,
106+
traits={Options(choices=MATERIAL_OPTIONS)},
107+
)
108+
)
109+
110+
self.add_parameter(
111+
ParameterString(
112+
name="resolution",
113+
default_value=DEFAULT_RESOLUTION,
114+
tooltip="Texture resolution: Basic (2K) or High (4K)",
115+
allow_output=False,
116+
traits={Options(choices=RESOLUTION_OPTIONS)},
117+
)
118+
)
119+
120+
# --- OUTPUT PARAMETERS ---
121+
self.add_parameter(
122+
ParameterString(
123+
name="generation_id",
124+
tooltip="Generation ID from the API",
125+
allow_input=False,
126+
allow_property=False,
127+
hide=True,
128+
)
129+
)
130+
131+
self.add_parameter(
132+
ParameterDict(
133+
name="provider_response",
134+
tooltip="Verbatim response from Griptape model proxy",
135+
allowed_modes={ParameterMode.OUTPUT},
136+
hide_property=True,
137+
hide=True,
138+
)
139+
)
140+
141+
self.add_parameter(
142+
Parameter3D(
143+
name="model_url",
144+
tooltip="Primary generated 3D model part",
145+
allowed_modes={ParameterMode.OUTPUT, ParameterMode.PROPERTY},
146+
settable=False,
147+
ui_options={"pulse_on_run": True, "display_name": "3D Model"},
148+
)
149+
)
150+
151+
self.add_parameter(
152+
Parameter(
153+
name="all_files",
154+
output_type="list",
155+
type="list",
156+
tooltip="URLs of all generated part files",
157+
allowed_modes={ParameterMode.OUTPUT},
158+
ui_options={"display_name": "All Files"},
159+
)
160+
)
161+
162+
self._output_file = ProjectFileParameter(
163+
node=self,
164+
name="output_file",
165+
default_filename="model.glb",
166+
)
167+
self._output_file.add_parameter()
168+
169+
# Status parameters MUST be last
170+
self._create_status_parameters(
171+
result_details_tooltip="Details about the 3D edit result or any errors",
172+
result_details_placeholder="Generation status and details will appear here.",
173+
parameter_group_initially_collapsed=True,
174+
)
175+
176+
def _log(self, message: str) -> None:
177+
with suppress(Exception):
178+
logger.info(message)
179+
180+
def _get_api_model_id(self) -> str:
181+
return "rodin-bang"
182+
183+
async def _build_payload(self) -> dict[str, Any]:
184+
asset_id = self.get_parameter_value("asset_id") or ""
185+
if not asset_id.strip():
186+
msg = "An asset_id is required. Provide the UUID of a previous Rodin Gen-2 generation."
187+
raise ValueError(msg)
188+
189+
strength = self.get_parameter_value("strength") or DEFAULT_STRENGTH
190+
geometry_file_format = self.get_parameter_value("geometry_file_format") or DEFAULT_GEOMETRY_FORMAT
191+
material = self.get_parameter_value("material") or DEFAULT_MATERIAL
192+
resolution = self.get_parameter_value("resolution") or DEFAULT_RESOLUTION
193+
194+
payload: dict[str, Any] = {
195+
"asset_id": asset_id.strip(),
196+
"strength": strength,
197+
"geometry_file_format": geometry_file_format,
198+
"material": material,
199+
"resolution": resolution,
200+
}
201+
202+
return payload
203+
204+
async def _parse_result(self, result_json: dict[str, Any], _generation_id: str) -> None:
205+
self.parameter_output_values["provider_response"] = result_json
206+
207+
# Get download URLs - the proxy returns 'downloads' list at top level
208+
files = result_json.get("downloads", [])
209+
if not files:
210+
# Try nested in result object
211+
result = result_json.get("result", {})
212+
if isinstance(result, dict):
213+
files = result.get("downloads", [])
214+
215+
if not files:
216+
self._set_safe_defaults()
217+
self._set_status_results(
218+
was_successful=False,
219+
result_details="Generation completed but no files were found in the response.",
220+
)
221+
return
222+
223+
await self._save_model_files(files)
224+
225+
async def _save_model_files(self, files: list[dict[str, Any]]) -> None:
226+
"""Download and save the generated 3D model part files."""
227+
requested_format = self.get_parameter_value("geometry_file_format") or DEFAULT_GEOMETRY_FORMAT
228+
229+
all_file_urls: list[str] = []
230+
primary_url: str | None = None
231+
primary_filename: str | None = None
232+
233+
for idx, file_info in enumerate(files):
234+
file_url = file_info.get("url")
235+
file_name = file_info.get("name", f"part_{idx}.{requested_format}")
236+
237+
if not file_url:
238+
continue
239+
240+
try:
241+
self._log(f"Downloading file: {file_name}")
242+
file_bytes = await self._download_bytes_from_url(file_url)
243+
244+
if file_bytes:
245+
dest = self._output_file.build_file()
246+
saved = await dest.awrite_bytes(file_bytes)
247+
all_file_urls.append(saved.location)
248+
self._log(f"Saved file: {saved.name}")
249+
250+
# Track primary model file
251+
if file_name.lower().endswith(f".{requested_format}") and primary_url is None:
252+
primary_url = saved.location
253+
primary_filename = saved.name
254+
elif primary_url is None:
255+
primary_url = saved.location
256+
primary_filename = saved.name
257+
258+
except Exception as e:
259+
self._log(f"Failed to save file {file_name}: {e}")
260+
261+
# Set outputs
262+
self.parameter_output_values["all_files"] = all_file_urls
263+
264+
if primary_url:
265+
self.parameter_output_values["model_url"] = ThreeDUrlArtifact(
266+
value=primary_url,
267+
meta={"filename": primary_filename, "format": requested_format},
268+
)
269+
self._set_status_results(
270+
was_successful=True,
271+
result_details=f"3D model split successfully. Saved {len(all_file_urls)} file(s).",
272+
)
273+
else:
274+
self._set_safe_defaults()
275+
self._set_status_results(
276+
was_successful=False,
277+
result_details="Generation completed but failed to save model files.",
278+
)
279+
280+
def _extract_error_message(self, response_json: dict[str, Any]) -> str:
281+
"""Extract error message, handling Rodin's application-level error format."""
282+
if not response_json:
283+
return f"{self.name} generation failed with no error details provided by API."
284+
285+
# Rodin returns errors with "error" code and "message" description
286+
error_code = response_json.get("error")
287+
message = response_json.get("message")
288+
if error_code and message:
289+
if isinstance(message, list):
290+
message = "; ".join(str(m) for m in message)
291+
return f"{self.name}: {error_code}: {message}"
292+
293+
return super()._extract_error_message(response_json)
294+
295+
def _set_safe_defaults(self) -> None:
296+
self.parameter_output_values["generation_id"] = ""
297+
self.parameter_output_values["provider_response"] = None
298+
self.parameter_output_values["model_url"] = None
299+
self.parameter_output_values["all_files"] = []
300+
301+
def _handle_payload_build_error(self, e: Exception) -> None:
302+
if isinstance(e, ValueError):
303+
self._set_safe_defaults()
304+
self._set_status_results(was_successful=False, result_details=str(e))
305+
self._handle_failure_exception(e)
306+
return
307+
308+
super()._handle_payload_build_error(e)

0 commit comments

Comments
 (0)