Skip to content

Commit 9ec365a

Browse files
dmitratclaude
andcommitted
feat(addon): wire local bake into Render + enable LOCAL strategy (3b.4)
Completes the "On this computer" path end to end: - Render launch: when LOCAL + an unbaked sim, it runs the local bake first (OUTWIT_OT_bridge_bake_local with chain_to_render=True), which on completion re-invokes the launch. A path+mtime "already baked this .blend" guard (_local_bake_is_current) breaks the re-bake loop — needed because Geometry-Nodes sims always read as unbaked from the scan. - Post-upload gate now routes by plan: should_delegate → BakeAndRender* (farm bakes); should_local → PLAIN Render* with the collected fluid caches (already baked here), skipping preflight (it can false-flag an already-baked GN zone). - Pack-copy strips non-DOMAIN fluid modifiers ONLY when a domain is already baked (local path) — an unbaked scene (delegated path) keeps them for the farm bake; runs on the upload copy so the artist's live scene is untouched. - Flip LOCAL_BAKE_AVAILABLE = True; panel notes "Render will bake on this computer first (saves the .blend)". Bump addon 0.13.1 -> 0.14.0. Tests: operator/panel "LOCAL unavailable→block" cases converted to the now-shipped LOCAL-available behavior (unavailable-gate logic stays covered by the bridge_status + planner tests, which set the flag explicitly); operator harness loads bridge_simulation with the dataclass-slots shim. 170 addon tests green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 60f4af5 commit 9ec365a

8 files changed

Lines changed: 158 additions & 42 deletions

File tree

OutWit.Render.BlenderAddon/Tests/bridge_operator_policy_tests.py

Lines changed: 51 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,29 @@ class SceneEngineRoutingError(Exception):
136136
sys.modules[f"{PACKAGE_NAME}.bridge_dependency_policy"] = dependency_policy_module
137137
dependency_policy_spec.loader.exec_module(dependency_policy_module)
138138

139+
# bridge_simulation uses dataclass(slots=True) (Blender's 3.11+ Python); shim the slots kwarg away on
140+
# an older test interpreter. The launch flow reaches it via _scan_scene_simulations.
141+
simulation_spec = importlib.util.spec_from_file_location(
142+
f"{PACKAGE_NAME}.bridge_simulation", ADDON_DIR / "bridge_simulation.py")
143+
simulation_module = importlib.util.module_from_spec(simulation_spec)
144+
sys.modules[f"{PACKAGE_NAME}.bridge_simulation"] = simulation_module
145+
if sys.version_info >= (3, 10):
146+
simulation_spec.loader.exec_module(simulation_module)
147+
else:
148+
import dataclasses as _dataclasses
149+
150+
_real_dataclass = _dataclasses.dataclass
151+
152+
def _dataclass_without_slots(*args, **kwargs):
153+
kwargs.pop("slots", None)
154+
return _real_dataclass(*args, **kwargs)
155+
156+
_dataclasses.dataclass = _dataclass_without_slots
157+
try:
158+
simulation_spec.loader.exec_module(simulation_module)
159+
finally:
160+
_dataclasses.dataclass = _real_dataclass
161+
139162
bridge_scene_packaging_module = types.ModuleType(f"{PACKAGE_NAME}.bridge_scene_packaging")
140163

141164
class ScenePackagingError(Exception):
@@ -997,7 +1020,8 @@ def test_validate_operator_reports_delegated_bake_for_simulation(self) -> None:
9971020
self.assertIn("baked on the render farm", state.status_message)
9981021
self.assertIn(({"INFO"}, state.status_message), operator.report_calls)
9991022

1000-
def test_validate_operator_blocks_simulation_when_local_bake_unavailable(self) -> None:
1023+
def test_validate_operator_reports_local_bake_for_simulation(self) -> None:
1024+
# LOCAL strategy + unbaked sim → not an error; it will be baked on this computer before rendering.
10011025
state = _create_state()
10021026
state.bake_strategy = "LOCAL"
10031027
context = _create_context(state)
@@ -1012,8 +1036,8 @@ def test_validate_operator_blocks_simulation_when_local_bake_unavailable(self) -
10121036
result = operator.execute(context)
10131037

10141038
self.assertEqual({"FINISHED"}, result)
1015-
self.assertIn("render farm", state.status_message)
1016-
self.assertIn(({"ERROR"}, state.status_message), operator.report_calls)
1039+
self.assertIn("this computer", state.status_message)
1040+
self.assertIn(({"INFO"}, state.status_message), operator.report_calls)
10171041

10181042
def test_preflight_operator_skips_when_simulation_is_delegated_bake(self) -> None:
10191043
# A simulation covered by a delegated bake is not preflighted on the unbaked scene; the
@@ -1036,7 +1060,9 @@ def test_preflight_operator_skips_when_simulation_is_delegated_bake(self) -> Non
10361060
self.assertFalse(state.preflight_still_ready)
10371061
self.assertIn(({"INFO"}, state.status_message), operator.report_calls)
10381062

1039-
def test_preflight_operator_blocks_simulation_when_local_bake_unavailable(self) -> None:
1063+
def test_preflight_operator_skips_when_simulation_is_local_bake(self) -> None:
1064+
# A simulation covered by a local bake is not preflighted on the unbaked scene; the operator
1065+
# reports informationally ("on this computer") and leaves the per-mode verdicts unset.
10401066
state = _create_state()
10411067
state.bake_strategy = "LOCAL"
10421068
context = _create_context(state)
@@ -1051,10 +1077,10 @@ def test_preflight_operator_blocks_simulation_when_local_bake_unavailable(self)
10511077
operator = bridge_operators.OUTWIT_OT_bridge_run_preflight()
10521078
result = operator.execute(context)
10531079

1054-
self.assertEqual({"CANCELLED"}, result)
1055-
self.assertFalse(state.preflight_can_render_all)
1056-
self.assertIn("render farm", state.preflight_issue_summary)
1057-
self.assertIn(({"ERROR"}, state.preflight_issue_summary), operator.report_calls)
1080+
self.assertEqual({"FINISHED"}, result)
1081+
self.assertIn("this computer", state.status_message)
1082+
self.assertFalse(state.preflight_still_ready)
1083+
self.assertIn(({"INFO"}, state.status_message), operator.report_calls)
10581084

10591085
def test_launch_operator_routes_unbaked_simulation_to_delegated_bake(self) -> None:
10601086
# The crux: an unbaked simulation with DELEGATED must NOT block and must NOT reach a plain
@@ -1089,26 +1115,36 @@ def run_selected_launch(_context, *, bake=False):
10891115
self.assertTrue(captured.get("bake"), "launch must route to the BakeAndRender* path")
10901116
self.assertIn(({"INFO"}, "BakeAndRenderStill launched successfully."), operator.report_calls)
10911117

1092-
def test_launch_operator_blocks_simulation_when_local_bake_unavailable(self) -> None:
1093-
# LOCAL baking is not available yet, so the launch must refuse rather than silently delegate
1094-
# or render unbaked — the gate the user required ("no render without a bake plan").
1118+
def test_launch_operator_local_bake_renders_plain_not_delegated(self) -> None:
1119+
# LOCAL strategy: the scene is baked locally before upload, so the launch must take the PLAIN
1120+
# Render* path (bake=False) — never BakeAndRender* (which would re-bake on the farm). (The
1121+
# stub scene has no live sim objects, so the pre-upload bake step is skipped here; this exercises
1122+
# the post-upload routing for a LOCAL-baked scene the validator still flags.)
10951123
state = _create_state()
10961124
state.bake_strategy = "LOCAL"
10971125
context = _create_context(state)
10981126
response = _create_simulation_issue_validate_response()
1127+
launch_response = SimpleNamespace(job_id="job-7", status="Pending", message="RenderStill launched successfully.")
1128+
captured = {}
10991129

11001130
bridge_operators._ensure_current_scene_uploaded = lambda _context: None
11011131
bridge_operators._get_current_blend_path = lambda: "C:/Workspace/test.blend"
11021132
bridge_operators._scene_requires_upload = lambda *_args: False
11031133
bridge_operators._run_validate_blend = lambda _context: (bridge_operators._apply_validate_response(state, response) or response)
1104-
bridge_operators._run_selected_launch = lambda _context, *, bake=False: (_ for _ in ()).throw(AssertionError("must not submit when blocked"))
1134+
1135+
def run_selected_launch(_context, *, bake=False):
1136+
captured["bake"] = bake
1137+
return launch_response
1138+
1139+
bridge_operators._run_selected_launch = run_selected_launch
1140+
bridge_operators._sticky_render_settings_after_submit = lambda _context: None
11051141

11061142
operator = bridge_operators.OUTWIT_OT_bridge_launch_render()
11071143
result = _run_launch_operator(operator, context)
11081144

1109-
self.assertEqual({"CANCELLED"}, result)
1110-
self.assertIn("render farm", state.status_message)
1111-
self.assertIn(({"ERROR"}, state.status_message), operator.report_calls)
1145+
self.assertEqual({"FINISHED"}, result)
1146+
self.assertIn("bake", captured)
1147+
self.assertFalse(captured["bake"], "local bake → plain Render*, not BakeAndRender*")
11121148

11131149

11141150
if __name__ == "__main__":

OutWit.Render.BlenderAddon/Tests/bridge_panel_policy_tests.py

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -171,14 +171,16 @@ def test_primary_finding_omits_simulation_when_delegated_bake_covers_it(self) ->
171171
self.assertNotEqual("Blocked", bridge_panel._primary_finding_policy(state))
172172
self.assertIn("baked on the render farm", bridge_panel._simulation_bake_plan_message(state))
173173

174-
def test_primary_finding_surfaces_simulation_block_when_local_bake_unavailable(self) -> None:
174+
def test_primary_finding_omits_simulation_when_local_bake_covers_it(self) -> None:
175+
# LOCAL strategy (now available): the simulation is baked on this computer → not a block.
175176
state = _create_state()
176177
state.bake_strategy = "LOCAL"
177178
state.validate_warning_summary = ""
178179
state.validate_issue_summary = "Cloth simulation 'Pillow' is not yet portable to remote rendering in the current v1 flow."
179180

180-
self.assertIn("render farm", bridge_panel._primary_finding(state))
181-
self.assertEqual("Blocked", bridge_panel._primary_finding_policy(state))
181+
self.assertEqual("", bridge_panel._primary_finding(state))
182+
self.assertNotEqual("Blocked", bridge_panel._primary_finding_policy(state))
183+
self.assertIn("this computer", bridge_panel._simulation_bake_plan_message(state))
182184

183185
def test_dependency_plan_block_message_returns_primary_finding_only_when_blocked(self) -> None:
184186
state = _create_state()
@@ -197,13 +199,14 @@ def test_dependency_plan_block_message_returns_empty_when_scene_is_not_blocked(s
197199

198200
self.assertEqual("", result)
199201

200-
def test_dependency_plan_block_message_blocks_simulation_when_local_bake_unavailable(self) -> None:
202+
def test_dependency_plan_block_message_empty_when_simulation_is_local_bake(self) -> None:
203+
# LOCAL strategy (now available) covers the simulation → no diagnostic block.
201204
state = _create_state()
202205
state.bake_strategy = "LOCAL"
203206
state.validate_warning_summary = ""
204207
state.validate_issue_summary = "Cloth simulation 'Pillow' is not yet portable to remote rendering in the current v1 flow."
205208

206-
self.assertIn("render farm", bridge_panel._dependency_plan_block_message(state))
209+
self.assertEqual("", bridge_panel._dependency_plan_block_message(state))
207210

208211
def test_dependency_plan_block_message_empty_when_simulation_is_delegated_bake(self) -> None:
209212
state = _create_state()

OutWit.Render.BlenderAddon/outwit_render_bridge/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
bl_info = {
22
"name": "OmnibusCloud Render Bridge",
33
"author": "OutWit",
4-
"version": (0, 13, 1),
4+
"version": (0, 14, 0),
55
"blender": (4, 0, 0),
66
"location": "View3D > Sidebar > OmnibusCloud",
77
"description": "Thin Blender addon for the local OmnibusCloud render bridge",

OutWit.Render.BlenderAddon/outwit_render_bridge/blender_manifest.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
schema_version = "1.0.0"
22
id = "omnibuscloud_render_bridge"
3-
version = "0.13.1"
3+
version = "0.14.0"
44
name = "OmnibusCloud Render Bridge"
55
tagline = "Thin Blender addon for the local OmnibusCloud render bridge"
66
maintainer = "OutWit"

OutWit.Render.BlenderAddon/outwit_render_bridge/bridge_dependency_policy.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,7 @@ class BakePlan(NamedTuple):
131131
# unbaked simulation rather than silently delegating or — worse — rendering it unbaked. Flip to
132132
# ``True`` once the local driver ships. Lives here (not in bridge_simulation) so the bpy-free gate
133133
# in operators/status/panel can read it without importing the bpy-adjacent scan module.
134-
LOCAL_BAKE_AVAILABLE = False
134+
LOCAL_BAKE_AVAILABLE = True
135135

136136

137137
def resolve_bake_plan(

OutWit.Render.BlenderAddon/outwit_render_bridge/bridge_operators.py

Lines changed: 65 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import sys
77

88
import bpy
9-
from bpy.props import StringProperty
9+
from bpy.props import BoolProperty, StringProperty
1010
from bpy.types import Operator
1111

1212
from .bridge_async import AsyncCall, JobMonitor, TERMINAL_STATUSES
@@ -285,18 +285,24 @@ def _get_uploaded_attachment_manifest(state) -> list[dict[str, object]]:
285285
return value if isinstance(value, list) else []
286286

287287

288+
def _local_bake_is_current(state) -> bool:
289+
"""True when a local bake's result still belongs to the CURRENT .blend (same path + mtime). The bake
290+
saves the file and records its path/mtime; this stays true until the file is edited+saved again. Used
291+
both to merge the fluid manifest and to avoid re-baking an already-locally-baked scene (Geometry-Nodes
292+
sims always read as 'unbaked' from the scan, so presence alone cannot tell us a bake already ran)."""
293+
current_path = bpy.data.filepath or ""
294+
return (
295+
bool(getattr(state, "local_bake_source_path", ""))
296+
and getattr(state, "local_bake_source_path", "") == current_path
297+
and getattr(state, "local_bake_source_mtime", "") == _blend_file_mtime(current_path)
298+
)
299+
300+
288301
def _local_bake_fluid_attachments(state) -> list[dict[str, object]]:
289-
"""Fluid-cache attachments collected by a local bake — but only when they still belong to the current
290-
.blend (same path + mtime). After a save/edit they are discarded, so a stale manifest never attaches to
291-
a different or modified scene."""
302+
"""Fluid-cache attachments collected by a local bake — only when they still belong to the current
303+
.blend (see _local_bake_is_current), so a stale manifest never attaches to a different/edited scene."""
292304
raw = getattr(state, "local_bake_fluid_manifest_json", "") or ""
293-
if not raw:
294-
return []
295-
296-
current_path = bpy.data.filepath or ""
297-
if getattr(state, "local_bake_source_path", "") != current_path:
298-
return []
299-
if getattr(state, "local_bake_source_mtime", "") != _blend_file_mtime(current_path):
305+
if not raw or not _local_bake_is_current(state):
300306
return []
301307

302308
try:
@@ -314,6 +320,24 @@ def _planned_upload_attachments(state) -> list[dict[str, object]]:
314320
return list(collect_scene_attachment_metadata()) + _local_bake_fluid_attachments(state)
315321

316322

323+
def _should_bake_locally_first(context) -> bool:
324+
"""True when a Render launch must run a LOCAL bake first: the LOCAL strategy is selected, the local
325+
driver is available, the scene has an unbaked simulation, and we have not already locally baked THIS
326+
exact .blend (the path+mtime guard that breaks the re-bake loop — Geometry-Nodes sims always read as
327+
'unbaked' from the scan, so presence alone can't tell us a bake already ran)."""
328+
from .bridge_dependency_policy import LOCAL_BAKE_AVAILABLE
329+
330+
state = _get_runtime_state(context)
331+
if not LOCAL_BAKE_AVAILABLE:
332+
return False
333+
if (getattr(state, "bake_strategy", "DELEGATED") or "DELEGATED").upper() != "LOCAL":
334+
return False
335+
if _local_bake_is_current(state):
336+
return False
337+
338+
return bool(_scan_scene_simulations(context.scene).unbaked_kinds)
339+
340+
317341
def _apply_dependency_plan(state, attachments: list[dict[str, object]]) -> None:
318342
summary = summarize_scene_attachment_metadata(attachments)
319343
state.dependency_plan_total_count = int(summary.get("TotalCount") or 0)
@@ -1690,6 +1714,10 @@ class OUTWIT_OT_bridge_bake_local(Operator):
16901714
bl_description = ("Bake this scene's simulations in Blender and save the .blend, so the baked scene "
16911715
"renders distributed without a farm bake")
16921716

1717+
# Set by the Render launch when it delegates baking to this operator: on completion, continue to the
1718+
# render. False for a standalone bake (the artist just wanted to bake).
1719+
chain_to_render: BoolProperty(default=False, options={"SKIP_SAVE", "HIDDEN"})
1720+
16931721
_timer = None
16941722
_steps: list = []
16951723
_index = 0
@@ -1782,6 +1810,12 @@ def _finish(self, context):
17821810
if fluid_attachments else "Local bake complete.")
17831811
self.report({"INFO"}, state.status_message)
17841812
_tag_job_areas_redraw()
1813+
1814+
# When the Render launch delegated this bake, continue to the render now that the scene is baked
1815+
# (it reads as baked → the launch takes the plain Render* path with the collected fluid caches).
1816+
if self.chain_to_render:
1817+
bpy.ops.outwit.bridge_launch_render("INVOKE_DEFAULT")
1818+
17851819
return {"FINISHED"}
17861820

17871821
def _cancel(self, context, message: str, level: str = "WARNING"):
@@ -2198,6 +2232,13 @@ def execute(self, context):
21982232
self.report({"WARNING"}, "A launch is already in progress.")
21992233
return {"CANCELLED"}
22002234

2235+
# LOCAL strategy with an unbaked simulation: bake it in this Blender FIRST, then the bake operator
2236+
# re-invokes this launch (the scene now reads as baked → plain Render* with the collected fluid
2237+
# caches). The path+mtime guard in _should_bake_locally_first stops this from looping.
2238+
if _should_bake_locally_first(context):
2239+
bpy.ops.outwit.bridge_bake_local("INVOKE_DEFAULT", chain_to_render=True)
2240+
return {"FINISHED"}
2241+
22012242
# Main-thread: validate the scene is saved and gather all bpy-derived data up front, so the
22022243
# worker thread only does network/subprocess work (Blender's API is not thread-safe).
22032244
try:
@@ -2327,13 +2368,13 @@ def _finish_launch(self, context):
23272368
if bake_plan.block:
23282369
raise BridgeClientError(bake_plan.block)
23292370

2330-
bake = bake_plan.should_delegate or bake_plan.should_local
2371+
covered_by_bake = bake_plan.should_delegate or bake_plan.should_local
23312372

23322373
if not validation_response.is_valid:
2333-
# The ONLY validation failure a bake may bypass is the simulation-cache block, and only
2334-
# when a bake plan covers it. Any other hard issue still blocks.
2374+
# The ONLY validation failure a bake plan may bypass is the simulation-cache block, and
2375+
# only when a plan covers it. Any other hard issue still blocks.
23352376
other_issue = get_non_simulation_validation_issue(state.validate_issue_summary)
2336-
if other_issue or not bake:
2377+
if other_issue or not covered_by_bake:
23372378
raise BridgeClientError(
23382379
other_issue
23392380
or validation_response.message
@@ -2342,11 +2383,16 @@ def _finish_launch(self, context):
23422383
or "Blend validation reported blocking issues."
23432384
)
23442385

2345-
if bake:
2346-
# Bake path: the BakeAndRender* script bakes the simulation on the delegated node, then
2347-
# validates and renders the BAKED scene. The plain-scene preflight is skipped by design
2348-
# it would re-flag the very simulation we are about to bake.
2386+
if bake_plan.should_delegate:
2387+
# Delegated bake: the BakeAndRender* script bakes the simulation on the fastest node, then
2388+
# validates and renders the BAKED scene. The plain-scene preflight is skipped by design
2389+
# it would re-flag the very simulation we are about to bake.
23492390
response = _run_selected_launch(context, bake=True)
2391+
elif bake_plan.should_local:
2392+
# Local bake already ran in this Blender before upload — the uploaded scene IS baked, so it
2393+
# renders through the PLAIN Render* path with the collected fluid caches. Preflight is
2394+
# skipped: it can false-flag an already-baked Geometry-Nodes zone (no bake-state flag).
2395+
response = _run_selected_launch(context, bake=False)
23502396
else:
23512397
_run_preflight(context)
23522398
if not _selected_mode_is_ready(state):

OutWit.Render.BlenderAddon/outwit_render_bridge/bridge_panel.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -505,6 +505,12 @@ def _draw_simulation_bake(layout, state) -> None:
505505
box.label(text="Bake before rendering:")
506506
box.prop(state, "bake_strategy", expand=True)
507507

508+
# LOCAL needs the artist to know Render bakes here first (and saves the file). The unbaked check keeps
509+
# the note from showing once everything is already baked.
510+
if (getattr(state, "bake_strategy", "DELEGATED") or "DELEGATED").upper() == "LOCAL" \
511+
and getattr(state, "scene_unbaked_simulation_summary", ""):
512+
box.label(text="Render will bake on this computer first (saves the .blend).", icon="INFO")
513+
508514

509515
def _draw_render_setup(layout, context, state, view) -> None:
510516
_draw_target(layout, state)

0 commit comments

Comments
 (0)