diff --git a/src/Mod/VibeCAD/VibeCADGui.py b/src/Mod/VibeCAD/VibeCADGui.py index b51ddb29..9072aca8 100644 --- a/src/Mod/VibeCAD/VibeCADGui.py +++ b/src/Mod/VibeCAD/VibeCADGui.py @@ -2330,6 +2330,28 @@ def _format_progress_event(event: dict[str, Any]) -> str: phase = str(event.get("phase") or "work").replace("_", " ") elapsed = float(event.get("elapsed_seconds", 0.0) or 0.0) return f"VibeScript {phase} completed in {elapsed:.2f}s." + if name == "vibescript_domain_worker_progress": + phase = str(event.get("phase") or "work") + item = event.get("item_progress") + if ( + phase == "simulation_collision" + and isinstance(item, dict) + and item.get("kind") == "collision_frame" + ): + completed = max(0, int(item.get("completed") or 0)) + total = max(0, int(item.get("total") or 0)) + percent = round(100.0 * completed / total) if total else 0 + message = ( + f"Checking motion collisions: frame {completed} of {total} " + f"({percent}%)" + ) + remaining = item.get("estimated_remaining_seconds") + if isinstance(remaining, (int, float)) and remaining >= 0: + minutes = max(1, round(float(remaining) / 60.0)) + unit = "minute" if minutes == 1 else "minutes" + message += f" - about {minutes} {unit} remaining" + return message + return f"VibeScript {phase.replace('_', ' ')}..." if name == "vibescript_domain_deferred_recompute_completed": count = int(event.get("target_count", 0) or 0) elapsed = float(event.get("elapsed_seconds", 0.0) or 0.0) @@ -2383,6 +2405,7 @@ def _format_progress_event(event: dict[str, Any]) -> str: "vibescript_domain_deferred_recompute_completed", "vibescript_domain_phase_completed", "vibescript_domain_phase_started", + "vibescript_domain_worker_progress", } diff --git a/src/Mod/VibeCAD/VibeCADMechanismEngine.py b/src/Mod/VibeCAD/VibeCADMechanismEngine.py index 00019ad7..0e3e2919 100644 --- a/src/Mod/VibeCAD/VibeCADMechanismEngine.py +++ b/src/Mod/VibeCAD/VibeCADMechanismEngine.py @@ -831,6 +831,7 @@ def normalize_mechanism_scenario(value: Any) -> dict[str, Any]: "frames_per_second", } ), + optional=frozenset({"collision_mode"}), ) raw_motion_ids = simulation_raw["motion_ids"] if ( @@ -886,6 +887,12 @@ def normalize_mechanism_scenario(value: Any) -> dict[str, Any]: "scenario.simulation", "contains invalid time or tolerance bounds", ) + collision_mode = str(simulation_raw.get("collision_mode") or "full") + if collision_mode not in {"full", "off"}: + raise _error( + "scenario.simulation.collision_mode", + "must be full or off", + ) estimated_frames = math.ceil((end - start) / step) + 2 if ( estimated_frames > 10_000 @@ -910,6 +917,7 @@ def normalize_mechanism_scenario(value: Any) -> dict[str, Any]: "time_step_s": step, "error_tolerance": tolerance, "frames_per_second": frames_per_second, + "collision_mode": collision_mode, } elif motions: raise _error( diff --git a/src/Mod/VibeCAD/VibeCADMechanismGeometry.py b/src/Mod/VibeCAD/VibeCADMechanismGeometry.py index bac3c781..067c6fa5 100644 --- a/src/Mod/VibeCAD/VibeCADMechanismGeometry.py +++ b/src/Mod/VibeCAD/VibeCADMechanismGeometry.py @@ -5,10 +5,12 @@ from __future__ import annotations from collections.abc import Mapping, Sequence -from concurrent.futures import ThreadPoolExecutor +from concurrent.futures import ThreadPoolExecutor, as_completed import math import os +from queue import SimpleQueue import re +from threading import local from typing import Any, Callable STATIC_PAIR_EVIDENCE_SCHEMA = "vibecad-mechanism-static-pair-evidence-v1" @@ -25,6 +27,7 @@ _MAX_CONTACT_WITNESSES = 4096 _COLLISION_MESH_LINEAR_DEFLECTION_MM = 0.05 _COLLISION_MESH_ANGULAR_DEFLECTION_RADIANS = 0.5 +_COLLISION_PROXIMITY_TOLERANCE_MM = 1.0e-7 class MechanismGeometryError(ValueError): @@ -271,6 +274,33 @@ def collision_mesh_statistics(self) -> dict[str, int | float]: ), } + def fork(self) -> "DynamicCollisionEvaluator": + """Return an evaluator with independently owned OCCT topology and mesh. + + Frame workers change top-level placements while proximity reads the + triangulation attached to each shape. A deep geometry-and-mesh copy is + therefore required: sharing a ``TopoDS_TShape`` across workers would + reintroduce the same unsafe concurrent access avoided by disjoint pair + batching inside one evaluator. + """ + + duplicate = object.__new__(DynamicCollisionEvaluator) + duplicate._shapes = { + name: shape.copy(True, True) for name, shape in self._shapes.items() + } + duplicate._local_bounds = { + name: { + axis: list(values) + for axis, values in bounds.items() + } + for name, bounds in self._local_bounds.items() + } + duplicate._unique_mesh_definition_count = self._unique_mesh_definition_count + duplicate._unique_mesh_triangle_count = self._unique_mesh_triangle_count + duplicate._component_names = list(self._component_names) + duplicate._pairs = list(self._pairs) + return duplicate + def precompute_strict_containment( self, frames: Sequence[Mapping[str, Any]], @@ -448,6 +478,7 @@ def evaluate_with_known_pairs( [str, str, str, int, int], None ] | None = None, + surface_worker_limit: int | None = None, ) -> dict[str, Any]: """Evaluate one pose with explicit reuse and exclusion semantics.""" @@ -554,7 +585,11 @@ def evaluate_surface_job( ) surface_results = [] - maximum_workers = max(1, min(4, os.cpu_count() or 1)) + maximum_workers = ( + max(1, min(4, os.cpu_count() or 1)) + if surface_worker_limit is None + else max(1, int(surface_worker_limit)) + ) for batch in _disjoint_surface_job_batches(surface_jobs): worker_count = min(len(batch), maximum_workers) if worker_count > 1: @@ -613,6 +648,29 @@ def evaluate_surface_job( } +def recommended_collision_frame_workers( + frame_count: int, + *, + cpu_count: int | None = None, +) -> int: + """Balance independent-frame concurrency with evaluator-copy setup cost. + + Each worker owns a deep copy of every collision BREP and triangulation. + The square-root bound keeps that one-time setup proportional to the useful + frame work while the CPU bound leaves a quarter of logical CPUs available + to VibeCAD and the rest of the system. + """ + + if isinstance(frame_count, bool) or not isinstance(frame_count, int) or frame_count < 1: + raise ValueError("frame_count must be a positive integer") + available = os.cpu_count() if cpu_count is None else cpu_count + if isinstance(available, bool) or not isinstance(available, int) or available < 1: + available = 1 + cpu_target = max(1, math.floor(available * 0.75)) + setup_target = max(1, math.ceil(math.sqrt(frame_count))) + return min(frame_count, cpu_target, setup_target) + + def evaluate_dynamic_collisions( components: Mapping[str, Any], frames: Sequence[Mapping[str, Any]], @@ -625,6 +683,8 @@ def evaluate_dynamic_collisions( [str, int, int, str, str, int, int], None ] | None = None, + frame_workers: int | None = None, + aggregate_progress_callback: Callable[[int, int], None] | None = None, ) -> dict[str, Any]: """Evaluate and compact deterministic collisions over a motion trace. @@ -696,17 +756,35 @@ def evaluate_dynamic_collisions( for frame_index, pair in containment_witnesses: containment_pairs_by_frame.setdefault(frame_index, set()).add(pair) - frame_results: list[dict[str, Any]] = [] - broad_phase_candidates = 0 - exact_common_evaluations = 0 - surface_proximity_evaluations = 0 - containment_collisions = 0 - for expected_index, frame in enumerate(solver_frames, start=1): + total_frames = len(solver_frames) + if frame_workers is None: + worker_count = recommended_collision_frame_workers(total_frames) + elif ( + isinstance(frame_workers, bool) + or not isinstance(frame_workers, int) + or frame_workers < 1 + ): + raise ValueError("frame_workers must be a positive integer or None") + else: + worker_count = min(total_frames, frame_workers) + + if aggregate_progress_callback is not None: + aggregate_progress_callback(0, total_frames) + + def evaluate_frame( + frame_evaluator: DynamicCollisionEvaluator, + expected_index: int, + frame: Mapping[str, Any], + *, + known_pairs: Mapping[tuple[str, str], Mapping[str, Any] | None], + parallel: bool, + ) -> tuple[int, dict[str, Any]]: if progress_callback is not None: - progress_callback("started", expected_index, len(frames) - 1) - evaluated = evaluator.evaluate_with_known_pairs( + if not parallel: + progress_callback("started", expected_index, total_frames) + evaluated = frame_evaluator.evaluate_with_known_pairs( frame.get("component_placements"), - known_pair_results=known_rigid_results, + known_pair_results=known_pairs, excluded_pairs=normalized_excluded_pairs, containment_pairs=containment_pairs_by_frame.get( expected_index, @@ -714,12 +792,12 @@ def evaluate_dynamic_collisions( ), pair_progress_callback=( None - if pair_progress_callback is None + if parallel or pair_progress_callback is None else lambda event, first, second, pair_index, pair_total: ( pair_progress_callback( event, expected_index, - len(frames) - 1, + total_frames, first, second, pair_index, @@ -727,35 +805,133 @@ def evaluate_dynamic_collisions( ) ) ), + surface_worker_limit=1 if parallel else None, ) - broad_phase_candidates += int(evaluated["broad_phase_candidate_count"]) - exact_common_evaluations += int(evaluated["exact_common_count"]) - surface_proximity_evaluations += int( - evaluated["surface_proximity_count"] - ) - containment_collisions += int(evaluated["containment_collision_count"]) - collisions = list(evaluated["collisions"]) - if expected_index == 1: - collisions_by_pair = { - ( - str(item["first_component"]), - str(item["second_component"]), - ): item - for item in collisions - } - known_rigid_results = { - pair: collisions_by_pair.get(pair) - for pair in normalized_rigid_pairs - } - frame_results.append( + return ( + expected_index, { "frame_index": expected_index, "nominal_time_s": nominal_times[expected_index], - "collisions": collisions, - } + "collisions": list(evaluated["collisions"]), + "evaluation": evaluated, + }, ) - if progress_callback is not None: - progress_callback("completed", expected_index, len(frames) - 1) + + completed = 0 + indexed_results: dict[int, dict[str, Any]] = {} + pending_frames = list(enumerate(solver_frames, start=1)) + + # A rigid pair's first-frame verdict is reused at every subsequent frame. + # Establish that verdict before independent frame workers begin. + if normalized_rigid_pairs: + first_index, first_frame = pending_frames.pop(0) + _index, first_result = evaluate_frame( + evaluator, + first_index, + first_frame, + known_pairs={}, + parallel=worker_count > 1, + ) + indexed_results[first_index] = first_result + collisions_by_pair = { + ( + str(item["first_component"]), + str(item["second_component"]), + ): item + for item in first_result["collisions"] + } + known_rigid_results = { + pair: collisions_by_pair.get(pair) + for pair in normalized_rigid_pairs + } + completed = 1 + if aggregate_progress_callback is not None: + aggregate_progress_callback(completed, total_frames) + + if worker_count == 1: + for expected_index, frame in pending_frames: + _index, frame_result = evaluate_frame( + evaluator, + expected_index, + frame, + known_pairs=known_rigid_results, + parallel=False, + ) + indexed_results[expected_index] = frame_result + completed += 1 + if progress_callback is not None: + progress_callback("completed", expected_index, total_frames) + if aggregate_progress_callback is not None: + aggregate_progress_callback(completed, total_frames) + elif pending_frames: + active_workers = min(worker_count, len(pending_frames)) + evaluators = [evaluator] + evaluators.extend(evaluator.fork() for _index in range(1, active_workers)) + available_evaluators: SimpleQueue[DynamicCollisionEvaluator] = SimpleQueue() + for frame_evaluator in evaluators: + available_evaluators.put(frame_evaluator) + worker_state = local() + + def initialize_frame_worker() -> None: + worker_state.evaluator = available_evaluators.get() + + def evaluate_owned_frame( + expected_index: int, + frame: Mapping[str, Any], + ) -> tuple[int, dict[str, Any]]: + return evaluate_frame( + worker_state.evaluator, + expected_index, + frame, + known_pairs=known_rigid_results, + parallel=True, + ) + + futures = {} + with ThreadPoolExecutor( + max_workers=active_workers, + thread_name_prefix="vibecad-collision-frame", + initializer=initialize_frame_worker, + ) as executor: + for expected_index, frame in pending_frames: + future = executor.submit( + evaluate_owned_frame, + expected_index, + frame, + ) + futures[future] = expected_index + for future in as_completed(futures): + expected_index, frame_result = future.result() + indexed_results[expected_index] = frame_result + completed += 1 + if aggregate_progress_callback is not None: + aggregate_progress_callback(completed, total_frames) + + ordered_results = [indexed_results[index] for index in range(1, total_frames + 1)] + frame_results = [ + { + "frame_index": item["frame_index"], + "nominal_time_s": item["nominal_time_s"], + "collisions": item["collisions"], + } + for item in ordered_results + ] + broad_phase_candidates = sum( + int(item["evaluation"]["broad_phase_candidate_count"]) + for item in ordered_results + ) + exact_common_evaluations = sum( + int(item["evaluation"]["exact_common_count"]) + for item in ordered_results + ) + surface_proximity_evaluations = sum( + int(item["evaluation"]["surface_proximity_count"]) + for item in ordered_results + ) + containment_collisions = sum( + int(item["evaluation"]["containment_collision_count"]) + for item in ordered_results + ) return { "summary": summarize_dynamic_collision_frames( @@ -1122,6 +1298,7 @@ def summarize_dynamic_collision_frames( ) summary = { "schema": DYNAMIC_COLLISION_TRACE_SCHEMA, + "evaluation_mode": "full", "status": "complete" if not warnings else "incomplete", "analysis_complete": not warnings, "geometry_authority": ( @@ -1138,6 +1315,7 @@ def summarize_dynamic_collision_frames( ), "component_count": len(names), "possible_pair_count": (len(names) * (len(names) - 1)) // 2, + "requested_frame_count": len(frames), "evaluated_frame_count": len(frames), # True means the entire requested trace was evaluated and no collision # was found. An interrupted geometry check is deliberately not reported @@ -1155,6 +1333,51 @@ def summarize_dynamic_collision_frames( return summary +def skipped_dynamic_collision_summary( + component_names: Sequence[str], + *, + requested_frame_count: int, + warning: Mapping[str, Any], +) -> dict[str, Any]: + """Describe an explicit collision opt-out without inventing safe evidence.""" + + if ( + isinstance(requested_frame_count, bool) + or not isinstance(requested_frame_count, int) + or requested_frame_count < 1 + ): + raise _error("requested_frame_count", "must be a positive integer") + empty_frames = [ + { + "frame_index": index, + "nominal_time_s": None, + "collisions": [], + } + for index in range(1, requested_frame_count + 1) + ] + summary = summarize_dynamic_collision_frames(component_names, empty_frames) + normalized_warnings = summarize_dynamic_collision_frames( + component_names, + empty_frames, + evaluation_warnings=[warning], + )["warnings"] + summary.update( + { + "evaluation_mode": "off", + "status": "not_checked", + "analysis_complete": False, + "geometry_authority": "not_evaluated", + "collision_definition": "not_evaluated", + "evaluated_frame_count": 0, + "collision_free": False, + "interference_volume_complete": False, + "warning_count": 1, + "warnings": normalized_warnings, + } + ) + return summary + + def _error(path: str, message: str) -> MechanismGeometryError: return MechanismGeometryError(f"{path}: {message}") @@ -1524,7 +1747,13 @@ def _surface_collision_evidence( """ try: - result = first_shape.proximity(second_shape, 0.0) + # The FreeCAD wrapper only calls OCCT's SetTolerance when this value is + # positive. Passing zero leaves a backend-dependent default that can + # return empty overlap maps even for intersecting closed solids. + result = first_shape.proximity( + second_shape, + _COLLISION_PROXIMITY_TOLERANCE_MM, + ) if ( not isinstance(result, tuple) or len(result) != 2 diff --git a/src/Mod/VibeCAD/VibeCADVibeScriptDomainRuntime.py b/src/Mod/VibeCAD/VibeCADVibeScriptDomainRuntime.py index 6e83eb14..97199fc3 100644 --- a/src/Mod/VibeCAD/VibeCADVibeScriptDomainRuntime.py +++ b/src/Mod/VibeCAD/VibeCADVibeScriptDomainRuntime.py @@ -50,6 +50,7 @@ ) from VibeCADMechanismGeometry import ( measure_static_mechanism_pairs, + skipped_dynamic_collision_summary, summarize_dynamic_collision_frames, ) from VibeCADTools import tool_failure @@ -9616,7 +9617,11 @@ def _validate_assembly_execution( "frames_per_second", "estimated_frame_limit", } - optional_simulation_properties = {"label", "motion_names"} + optional_simulation_properties = { + "label", + "motion_names", + "collision_mode", + } if ( not isinstance(simulation_properties, dict) or not required_simulation_properties <= set(simulation_properties) @@ -9667,6 +9672,9 @@ def _validate_assembly_execution( ) frames_per_second = simulation_properties["frames_per_second"] estimated_frame_limit = simulation_properties["estimated_frame_limit"] + collision_mode = str( + simulation_properties.get("collision_mode") or "full" + ) if ( type(frames_per_second) is not int or not 1 <= frames_per_second <= 240 @@ -9675,6 +9683,7 @@ def _validate_assembly_execution( != math.ceil((end_time - start_time) / time_step) + 2 or not 2 <= estimated_frame_limit <= 10_000 or estimated_frame_limit * len(components) > 100_000 + or collision_mode not in {"full", "off"} ): raise ValueError( f"Simulation output {simulation_name!r} changed its bounded frame " @@ -9874,18 +9883,35 @@ def _validate_assembly_execution( # operations away from the GUI thread. The host authenticates its trace # artifact and component inputs, validates every pair record below, and # independently derives the complete summary from those frame records. - expected_collision_summary = summarize_dynamic_collision_frames( - component_names, - [ - { - "frame_index": int(frame["frame_index"]), - "nominal_time_s": frame["nominal_time_s"], - "collisions": list(frame["collisions"]), - } - for frame in frames[1:] - ], - evaluation_warnings=trace.get("collision_warnings"), - ) + collision_evidence_frames = [ + { + "frame_index": int(frame["frame_index"]), + "nominal_time_s": frame["nominal_time_s"], + "collisions": list(frame["collisions"]), + } + for frame in frames[1:] + ] + if collision_mode == "off": + raw_warnings = trace.get("collision_warnings") + if not isinstance(raw_warnings, list) or len(raw_warnings) != 1: + raise ValueError( + f"{context} must identify its skipped collision analysis." + ) + expected_collision_summary = skipped_dynamic_collision_summary( + component_names, + requested_frame_count=len(collision_evidence_frames), + warning=raw_warnings[0], + ) + if any(frame["collisions"] for frame in collision_evidence_frames): + raise ValueError( + f"{context} invented collision evidence while analysis was off." + ) + else: + expected_collision_summary = summarize_dynamic_collision_frames( + component_names, + collision_evidence_frames, + evaluation_warnings=trace.get("collision_warnings"), + ) if ( trace.get("collision_summary") != expected_collision_summary or frames[0]["collisions"] != [] diff --git a/src/Mod/VibeCAD/vibecad_tests/assembly_vibescript_api_integration.py b/src/Mod/VibeCAD/vibecad_tests/assembly_vibescript_api_integration.py index ba4209f3..0b252fc0 100644 --- a/src/Mod/VibeCAD/vibecad_tests/assembly_vibescript_api_integration.py +++ b/src/Mod/VibeCAD/vibecad_tests/assembly_vibescript_api_integration.py @@ -29,6 +29,7 @@ from VibeCADCore import VibeCADService # noqa: E402 from VibeCADMechanismGeometry import ( # noqa: E402 DYNAMIC_COLLISION_TRACE_SCHEMA, + DynamicCollisionEvaluator, MechanismGeometryError, STATIC_MECHANISM_EVIDENCE_SCHEMA, STATIC_PAIR_EVIDENCE_SCHEMA, @@ -1186,7 +1187,11 @@ def _source_text( ) -def _simulation_source(formula: str = "initialValue + pi/2*time") -> str: +def _simulation_source( + formula: str = "initialValue + pi/2*time", + *, + collision_mode: str = "full", +) -> str: return ( "base = api.component(inputs['base'], grounded=True, label='Base')\n" "arm = api.component(inputs['arm'], label='Arm')\n" @@ -1197,7 +1202,8 @@ def _simulation_source(formula: str = "initialValue + pi/2*time") -> str: f"drive = api.motion(hinge, {formula!r}, label='Hinge Drive')\n" "simulation = api.simulation(model, [drive], start_time_s=0, " "end_time_s=0.1, time_step_s=0.02, error_tolerance=1e-6, " - "frames_per_second=30, label='Kinematic Trace')\n" + f"frames_per_second=30, collision_mode={collision_mode!r}, " + "label='Kinematic Trace')\n" "result = {'Model':model, 'Base':base, 'Arm':arm, 'Hinge':hinge, " "'Drive':drive, 'Simulation':simulation, 'Diagnostics':diagnostics}\n" ) @@ -1253,6 +1259,12 @@ def _exercise_dynamic_collision_geometry() -> dict: first = Part.makeBox(10, 10, 10) second = Part.makeBox(10, 10, 10) + evaluator = DynamicCollisionEvaluator({"First": first, "Second": second}) + forked_evaluator = evaluator.fork() + assert all( + not evaluator._shapes[name].isPartner(forked_evaluator._shapes[name]) + for name in evaluator.component_names + ) clear = [0.0, 0.0, 0.0], [30.0, 0.0, 0.0] overlap = [0.0, 0.0, 0.0], [5.0, 0.0, 0.0] result = evaluate_dynamic_collisions( @@ -1549,6 +1561,33 @@ def _exercise_simulation_lifecycle(root: Path, pack) -> dict: assert observation["motion_type"] == "angular" assert observation["maximum_relative_rotation_degrees"] > 8.9 + playback_capture = _candidate_capture( + base_capture, + operation="create_program", + tool_name="vibescript.assembly.create_program", + arguments={ + "program_name": "Native Kinematic Playback Only", + "source": _simulation_source(collision_mode="off"), + "input_schema": input_schema, + "inputs": references, + "expected_outputs": expected_outputs, + }, + ) + _playback_prepared, playback_execution = _prepare_and_execute( + playback_capture, + service, + ) + assert playback_execution.get("ok") is True, playback_execution + playback_collision = playback_execution["assembly_validation"]["simulation"][ + "collision_summary" + ] + assert playback_collision["evaluation_mode"] == "off" + assert playback_collision["status"] == "not_checked" + assert playback_collision["analysis_complete"] is False + assert playback_collision["collision_free"] is False + assert playback_collision["evaluated_frame_count"] == 0 + assert playback_collision["requested_frame_count"] == 6 + changed_motion = copy.deepcopy(execution) changed_motion_item = next( item for item in changed_motion["outputs"] if item["name"] == "Drive" diff --git a/src/Mod/VibeCAD/vibecad_tests/test_engine_contracts.py b/src/Mod/VibeCAD/vibecad_tests/test_engine_contracts.py index 7bd8b1ab..adde7153 100644 --- a/src/Mod/VibeCAD/vibecad_tests/test_engine_contracts.py +++ b/src/Mod/VibeCAD/vibecad_tests/test_engine_contracts.py @@ -254,6 +254,29 @@ def test_analyze_progress_reaches_the_application_status_bar( ] +class TestVibeScriptWorkerStatusRendering: + def test_collision_progress_reports_frames_percent_and_eta(self) -> None: + import VibeCADGui as gui + + event = { + "event": "vibescript_domain_worker_progress", + "domain": "assembly", + "phase": "simulation_collision", + "item_progress": { + "kind": "collision_frame", + "completed": 60, + "total": 141, + "estimated_remaining_seconds": 1086.0, + }, + } + + assert gui._format_progress_event(event) == ( + "Checking motion collisions: frame 60 of 141 (43%) - " + "about 18 minutes remaining" + ) + assert gui._progress_event_should_update_status(event) is True + + def test_private_vibescript_carriers_are_not_provider_document_objects() -> None: from VibeCADCore import VibeCADService diff --git a/src/Mod/VibeCAD/vibecad_tests/test_mechanism_engine.py b/src/Mod/VibeCAD/vibecad_tests/test_mechanism_engine.py index dfe2fa81..88485be2 100644 --- a/src/Mod/VibeCAD/vibecad_tests/test_mechanism_engine.py +++ b/src/Mod/VibeCAD/vibecad_tests/test_mechanism_engine.py @@ -132,6 +132,42 @@ def _scenario() -> dict: } +def test_simulation_collision_mode_is_additive_and_defaults_to_full() -> None: + scenario = _scenario() + scenario["motions"] = [ + { + "id": "Drive", + "label": "", + "joint_id": "Joint2", + "motion_type": "angular", + "formula": "time", + } + ] + scenario["simulation"] = { + "id": "Simulation", + "label": "", + "motion_ids": ["Drive"], + "start_time_s": 0.0, + "end_time_s": 1.0, + "time_step_s": 0.1, + "error_tolerance": 1.0e-6, + "frames_per_second": 30, + } + + assert normalize_mechanism_scenario(scenario)["simulation"][ + "collision_mode" + ] == "full" + + scenario["simulation"]["collision_mode"] = "off" + assert normalize_mechanism_scenario(scenario)["simulation"][ + "collision_mode" + ] == "off" + + scenario["simulation"]["collision_mode"] = "sometimes" + with pytest.raises(MechanismContractError, match="collision_mode"): + normalize_mechanism_scenario(scenario) + + def _solved_placement(x: float = 0.0) -> dict: return { "position_mm": [x, 0.0, 0.0], diff --git a/src/Mod/VibeCAD/vibecad_tests/test_mechanism_geometry_parallelism.py b/src/Mod/VibeCAD/vibecad_tests/test_mechanism_geometry_parallelism.py new file mode 100644 index 00000000..b1f834ec --- /dev/null +++ b/src/Mod/VibeCAD/vibecad_tests/test_mechanism_geometry_parallelism.py @@ -0,0 +1,165 @@ +# SPDX-License-Identifier: LGPL-2.1-or-later + +"""Deterministic frame-parallel mechanism collision contracts.""" + +from __future__ import annotations + +import threading +import time + +import VibeCADMechanismGeometry as geometry + + +def _frames(count: int) -> list[dict]: + return [ + { + "frame_index": index, + "frame_kind": "input" if index == 0 else "solver_output", + "nominal_time_s": None if index == 0 else float(index - 1), + "component_placements": { + "First": { + "position_mm": [float(index), 0.0, 0.0], + "rotation_xyzw": [0.0, 0.0, 0.0, 1.0], + }, + "Second": { + "position_mm": [float(index + 1), 0.0, 0.0], + "rotation_xyzw": [0.0, 0.0, 0.0, 1.0], + }, + }, + } + for index in range(count + 1) + ] + + +def test_dynamic_collision_frames_use_isolated_parallel_evaluators( + monkeypatch, +) -> None: + lock = threading.Lock() + instances = [] + active = 0 + maximum_active = 0 + + class FakeEvaluator: + component_names = ["First", "Second"] + collision_mesh_statistics = { + "unique_collision_mesh_count": 2, + "unique_collision_mesh_triangle_count": 24, + "collision_mesh_angular_deflection_radians": 0.5, + } + + def __init__(self, _components, *, definition_keys=None) -> None: + del definition_keys + self.active = 0 + self.maximum_active = 0 + self.surface_worker_limits = [] + instances.append(self) + + def fork(self): + return FakeEvaluator({}) + + def precompute_strict_containment( + self, + _frames, + *, + excluded_pairs, + progress_callback=None, + ): + del excluded_pairs, progress_callback + return set() + + def evaluate_with_known_pairs( + self, + placements, + *, + known_pair_results, + excluded_pairs, + containment_pairs, + pair_progress_callback=None, + surface_worker_limit=None, + ): + nonlocal active, maximum_active + del ( + known_pair_results, + excluded_pairs, + containment_pairs, + pair_progress_callback, + ) + self.surface_worker_limits.append(surface_worker_limit) + with lock: + self.active += 1 + self.maximum_active = max(self.maximum_active, self.active) + active += 1 + maximum_active = max(maximum_active, active) + try: + frame_index = int(placements["First"]["position_mm"][0]) + # Deliberately finish in a different order from the trace. + time.sleep(0.005 * (9 - frame_index)) + return { + "broad_phase_candidate_count": 1, + "exact_common_count": 0, + "surface_proximity_count": 1, + "containment_collision_count": 0, + "collisions": [], + } + finally: + with lock: + self.active -= 1 + active -= 1 + + monkeypatch.setattr(geometry, "DynamicCollisionEvaluator", FakeEvaluator) + progress = [] + + result = geometry.evaluate_dynamic_collisions( + {"First": object(), "Second": object()}, + _frames(8), + frame_workers=3, + aggregate_progress_callback=lambda completed, total: progress.append( + (completed, total) + ), + ) + + assert maximum_active >= 2 + assert len(instances) == 3 + assert all(instance.maximum_active == 1 for instance in instances) + assert all( + limit == 1 + for instance in instances + for limit in instance.surface_worker_limits + ) + assert [frame["frame_index"] for frame in result["frames"]] == list( + range(1, 9) + ) + assert progress[0] == (0, 8) + assert progress[-1] == (8, 8) + assert [completed for completed, _total in progress] == list(range(9)) + + +def test_recommended_frame_workers_balance_cpu_use_and_evaluator_setup() -> None: + assert geometry.recommended_collision_frame_workers(100, cpu_count=1) == 1 + assert geometry.recommended_collision_frame_workers(100, cpu_count=8) == 6 + assert geometry.recommended_collision_frame_workers(4, cpu_count=56) == 2 + assert geometry.recommended_collision_frame_workers(100, cpu_count=56) == 10 + assert geometry.recommended_collision_frame_workers(141, cpu_count=56) == 12 + + +def test_skipped_collision_summary_cannot_claim_collision_free() -> None: + warning = { + "code": "COLLISION_ANALYSIS_SKIPPED", + "stage": "simulation_collision", + "message": "Collision analysis was explicitly disabled for this simulation.", + } + + summary = geometry.skipped_dynamic_collision_summary( + ["First", "Second"], + requested_frame_count=8, + warning=warning, + ) + + assert summary["evaluation_mode"] == "off" + assert summary["status"] == "not_checked" + assert summary["analysis_complete"] is False + assert summary["collision_free"] is False + assert summary["requested_frame_count"] == 8 + assert summary["evaluated_frame_count"] == 0 + assert summary["warning_count"] == 1 + assert summary["warnings"] == [warning] diff --git a/src/Mod/VibeCAD/vibecad_tests/test_modeling_surface_architecture.py b/src/Mod/VibeCAD/vibecad_tests/test_modeling_surface_architecture.py index c9ab2082..730c2a7e 100644 --- a/src/Mod/VibeCAD/vibecad_tests/test_modeling_surface_architecture.py +++ b/src/Mod/VibeCAD/vibecad_tests/test_modeling_surface_architecture.py @@ -4785,6 +4785,13 @@ def reference(name: str) -> dict[str, str]: end_time_s=2, time_step_s=0.1, ) + playback_only = api.simulation( + model, + [drive], + end_time_s=2, + time_step_s=0.1, + collision_mode="off", + ) exploded = api.exploded_view( model, [ @@ -4844,6 +4851,8 @@ def reference(name: str) -> dict[str, str]: assert simulation.arguments == (model,) assert simulation.properties["motions"] == (drive,) assert simulation.properties["estimated_frame_limit"] == 22 + assert simulation.properties["collision_mode"] == "full" + assert playback_only.properties["collision_mode"] == "off" assert exploded.arguments == (model,) assert exploded.properties["moves"][0]["kind"] == "normal" assert exploded.properties["moves"][0]["components"] == (arm,) diff --git a/src/Mod/VibeCAD/vibecad_tests/test_vibescript_file_io.py b/src/Mod/VibeCAD/vibecad_tests/test_vibescript_file_io.py index 12b0449b..d9ccfa57 100644 --- a/src/Mod/VibeCAD/vibecad_tests/test_vibescript_file_io.py +++ b/src/Mod/VibeCAD/vibecad_tests/test_vibescript_file_io.py @@ -87,6 +87,32 @@ def permanently_locked(_source: Path, target: Path) -> None: assert not list(tmp_path.glob("*.tmp")) +def test_worker_item_progress_retains_rate_and_eta(tmp_path: Path) -> None: + import vibescript_worker_progress as progress + + destination = tmp_path / "progress.json" + progress.configure(destination, "assembly") + progress.set_phase("simulation_collision", output="Simulation") + progress.set_item_progress( + "collision_frame", + completed=60, + total=141, + current="60", + rate_per_second=0.075, + estimated_remaining_seconds=1080.0, + ) + + payload = json.loads(destination.read_text(encoding="utf-8")) + assert payload["item_progress"] == { + "kind": "collision_frame", + "completed": 60, + "total": 141, + "current": "60", + "rate_per_second": 0.075, + "estimated_remaining_seconds": 1080.0, + } + + @pytest.mark.skipif(os.name != "nt", reason="Windows sharing flags are NT-specific") def test_windows_reader_allows_atomic_replacement_while_open(tmp_path: Path) -> None: import VibeCADVibeScriptFileIO as file_io diff --git a/src/Mod/VibeCAD/vibescript_assembly_api.py b/src/Mod/VibeCAD/vibescript_assembly_api.py index 80f8b67e..f9185cbf 100644 --- a/src/Mod/VibeCAD/vibescript_assembly_api.py +++ b/src/Mod/VibeCAD/vibescript_assembly_api.py @@ -149,6 +149,7 @@ def explicit_connector_compatibility( _CONTACT_POLICIES = frozenset( {"prohibited", "clearance", "allowed", "required", "ignored"} ) +_COLLISION_MODES = frozenset({"full", "off"}) _MOTION_FUNCTIONS = frozenset({"abs", "asin", "arcsin", "arctan", "cos", "sin"}) _MOTION_NAMES = frozenset({"time", "initialValue", "pi"}) _OCCURRENCE_PATH = re.compile( @@ -1685,6 +1686,7 @@ def simulation( time_step_s: float = 0.01, error_tolerance: float = 1.0e-6, frames_per_second: int = 30, + collision_mode: str = "full", label: str = "", ) -> DomainValue: """Run native Assembly kinematics in the worker and retain its trace. @@ -1696,6 +1698,8 @@ def simulation( rejects simulations exceeding 100000 component-pose samples. ``time_step_s`` controls trace density; ``frames_per_second`` is retained only as the live playback rate and does not add solver samples. + ``collision_mode='off'`` skips dynamic collision analysis for playback; + its result is explicitly reported as not checked, never collision-free. """ operation = "simulation" @@ -1762,6 +1766,14 @@ def simulation( "must be from 1 through 240", frames_per_second, ) + clean_collision_mode = str(collision_mode or "").strip().lower() + if clean_collision_mode not in _COLLISION_MODES: + raise _error( + operation, + "collision_mode", + f"must be one of {sorted(_COLLISION_MODES)}", + collision_mode, + ) # OndselSolver retains the input state in addition to the requested # output-time states. The extra slot also covers a non-integral final # interval without relying on a hidden solver rounding rule. @@ -1788,6 +1800,7 @@ def simulation( time_step_s=step, error_tolerance=tolerance, frames_per_second=frames_per_second, + collision_mode=clean_collision_mode, estimated_frame_limit=estimated_frames, label=label, ) diff --git a/src/Mod/VibeCAD/vibescript_assembly_worker.py b/src/Mod/VibeCAD/vibescript_assembly_worker.py index c9f99dc2..18aa5955 100644 --- a/src/Mod/VibeCAD/vibescript_assembly_worker.py +++ b/src/Mod/VibeCAD/vibescript_assembly_worker.py @@ -10,6 +10,7 @@ import math from pathlib import Path import re +import time from types import MappingProxyType from typing import Any @@ -32,6 +33,7 @@ from VibeCADMechanismGeometry import ( evaluate_dynamic_collisions, measure_static_mechanism_pairs, + skipped_dynamic_collision_summary, summarize_dynamic_collision_frames, ) import vibescript_worker_progress as worker_progress @@ -2561,6 +2563,9 @@ def _mechanism_scenario_contract( "frames_per_second": simulation_properties.get( "frames_per_second" ), + "collision_mode": str( + simulation_properties.get("collision_mode") or "full" + ), } return normalize_mechanism_scenario( @@ -3477,94 +3482,21 @@ def _execute_native_simulation( }, ) - worker_progress.set_phase( - "simulation_collision", - output=simulation_output, - ) + collision_mode = str(properties.get("collision_mode") or "full") collision_warnings: list[dict[str, str]] = [] - try: - collision_shapes = {} - collision_definition_keys = {} - for name, component in components.items(): - source = getattr(component, "LinkedObject", None) - shape = getattr(source, "Shape", None) - if shape is None: - raise RuntimeError( - f"Simulation component {name!r} has no source shape for " - "collision evaluation" - ) - collision_shapes[name] = shape - metadata = component_source_metadata.get(name) - definition_key = _collision_definition_key( - metadata, - component_data.get(name), - ) - if len(definition_key) != 64: - raise RuntimeError( - f"Simulation component {name!r} has no authenticated shape " - "identity for collision evaluation" - ) - collision_definition_keys[name] = definition_key - # Components in the same exact fixed-joint closure are one rigid unit - # for motion analysis. Their intentional mating geometry cannot develop - # a new dynamic interference, so do not repeat that pair at every frame. - # Every pair across distinct rigid units remains eligible, including - # imported geometry and modeled fasteners. - fixed_group_pairs = _fixed_collision_pairs(components, joint_data) - - def report_collision_progress(event: str, frame_index: int, total: int) -> None: - graph_id = f"frame-{frame_index}-of-{total}" - if event == "started": - worker_progress.graph_started("collision", graph_id) - else: - worker_progress.graph_completed("collision", graph_id) - - def report_collision_pair_progress( - event: str, - frame_index: int, - frame_total: int, - first: str, - second: str, - pair_index: int, - pair_total: int, - ) -> None: - if frame_index == 0: - graph_type = "collision_containment" - graph_id = ( - f"all-{frame_total}-frames:" - f"pair-{pair_index}-of-{pair_total}:" - f"{first}--{second}" - ) - else: - graph_type = "collision_surface" - graph_id = ( - f"frame-{frame_index}-of-{frame_total}:" - f"pair-{pair_index}-of-{pair_total}:" - f"{first}--{second}" - ) - if event == "started": - worker_progress.graph_started(graph_type, graph_id) - else: - worker_progress.graph_completed(graph_type, graph_id) - - collision_trace = evaluate_dynamic_collisions( - collision_shapes, - frames, - definition_keys=collision_definition_keys, - excluded_pairs=fixed_group_pairs, - progress_callback=report_collision_progress, - pair_progress_callback=report_collision_pair_progress, - ) - except Exception as exc: - collision_warnings.append( - { - "code": "COLLISION_ANALYSIS_INCOMPLETE", - "stage": "simulation_collision", - "message": ( - f"{type(exc).__name__}: {exc}" - )[:2048], - } + if collision_mode == "off": + worker_progress.set_phase( + "simulation_collision_skipped", + output=simulation_output, ) + skipped_warning = { + "code": "COLLISION_ANALYSIS_SKIPPED", + "stage": "simulation_collision", + "message": ( + "Collision analysis was explicitly disabled for this simulation." + ), + } + collision_warnings.append(skipped_warning) unevaluated_frames = [ { "frame_index": frame_index, @@ -3574,17 +3506,148 @@ def report_collision_pair_progress( for frame_index, frame in enumerate(frames[1:], start=1) ] collision_trace = { - "summary": summarize_dynamic_collision_frames( + "summary": skipped_dynamic_collision_summary( list(components), - unevaluated_frames, - evaluation_warnings=collision_warnings, + requested_frame_count=len(unevaluated_frames), + warning=skipped_warning, ), "frames": unevaluated_frames, "evaluation": { "analysis_complete": False, - "warning_count": len(collision_warnings), + "evaluation_mode": "off", + "warning_count": 1, }, } + else: + worker_progress.set_phase( + "simulation_collision", + output=simulation_output, + ) + try: + collision_shapes = {} + collision_definition_keys = {} + for name, component in components.items(): + source = getattr(component, "LinkedObject", None) + shape = getattr(source, "Shape", None) + if shape is None: + raise RuntimeError( + f"Simulation component {name!r} has no source shape for " + "collision evaluation" + ) + collision_shapes[name] = shape + metadata = component_source_metadata.get(name) + definition_key = _collision_definition_key( + metadata, + component_data.get(name), + ) + if len(definition_key) != 64: + raise RuntimeError( + f"Simulation component {name!r} has no authenticated shape " + "identity for collision evaluation" + ) + collision_definition_keys[name] = definition_key + # Components in the same exact fixed-joint closure are one rigid + # unit. Their intentional mating geometry cannot develop a new + # dynamic interference, so do not repeat those pairs every frame. + fixed_group_pairs = _fixed_collision_pairs(components, joint_data) + + def report_collision_progress( + event: str, frame_index: int, total: int + ) -> None: + graph_id = f"frame-{frame_index}-of-{total}" + if event == "started": + worker_progress.graph_started("collision", graph_id) + else: + worker_progress.graph_completed("collision", graph_id) + + def report_collision_pair_progress( + event: str, + frame_index: int, + frame_total: int, + first: str, + second: str, + pair_index: int, + pair_total: int, + ) -> None: + if frame_index == 0: + graph_type = "collision_containment" + graph_id = ( + f"all-{frame_total}-frames:" + f"pair-{pair_index}-of-{pair_total}:" + f"{first}--{second}" + ) + else: + graph_type = "collision_surface" + graph_id = ( + f"frame-{frame_index}-of-{frame_total}:" + f"pair-{pair_index}-of-{pair_total}:" + f"{first}--{second}" + ) + if event == "started": + worker_progress.graph_started(graph_type, graph_id) + else: + worker_progress.graph_completed(graph_type, graph_id) + + collision_started = time.monotonic() + + def report_collision_aggregate(completed: int, total: int) -> None: + elapsed = max(0.0, time.monotonic() - collision_started) + rate = ( + float(completed) / elapsed + if completed > 0 and elapsed > 0 + else None + ) + remaining = ( + float(total - completed) / rate + if rate is not None and rate > 0.0 + else None + ) + worker_progress.set_item_progress( + "collision_frame", + completed=completed, + total=total, + current=str(completed), + rate_per_second=rate, + estimated_remaining_seconds=remaining, + ) + + collision_trace = evaluate_dynamic_collisions( + collision_shapes, + frames, + definition_keys=collision_definition_keys, + excluded_pairs=fixed_group_pairs, + progress_callback=report_collision_progress, + pair_progress_callback=report_collision_pair_progress, + aggregate_progress_callback=report_collision_aggregate, + ) + except Exception as exc: + collision_warnings.append( + { + "code": "COLLISION_ANALYSIS_INCOMPLETE", + "stage": "simulation_collision", + "message": (f"{type(exc).__name__}: {exc}")[:2048], + } + ) + unevaluated_frames = [ + { + "frame_index": frame_index, + "nominal_time_s": frame.get("nominal_time_s"), + "collisions": [], + } + for frame_index, frame in enumerate(frames[1:], start=1) + ] + collision_trace = { + "summary": summarize_dynamic_collision_frames( + list(components), + unevaluated_frames, + evaluation_warnings=collision_warnings, + ), + "frames": unevaluated_frames, + "evaluation": { + "analysis_complete": False, + "warning_count": len(collision_warnings), + }, + } worker_progress.set_phase("simulation_serialization", output=simulation_output) collision_frames = { int(item["frame_index"]): list(item["collisions"]) diff --git a/src/Mod/VibeCAD/vibescript_worker_progress.py b/src/Mod/VibeCAD/vibescript_worker_progress.py index a5d743b0..76b7f3f4 100644 --- a/src/Mod/VibeCAD/vibescript_worker_progress.py +++ b/src/Mod/VibeCAD/vibescript_worker_progress.py @@ -5,6 +5,7 @@ from __future__ import annotations import json +import math from pathlib import Path import time from typing import Any @@ -98,6 +99,8 @@ def set_item_progress( completed: int, total: int, current: str = "", + rate_per_second: float | None = None, + estimated_remaining_seconds: float | None = None, ) -> None: """Publish bounded counters for the current native worker subphase.""" @@ -105,11 +108,30 @@ def set_item_progress( clean_total = max(0, int(total)) if clean_completed > clean_total: clean_completed = clean_total + metrics: dict[str, float] = {} + if ( + isinstance(rate_per_second, (int, float)) + and not isinstance(rate_per_second, bool) + and math.isfinite(float(rate_per_second)) + and float(rate_per_second) >= 0.0 + ): + metrics["rate_per_second"] = round(float(rate_per_second), 6) + if ( + isinstance(estimated_remaining_seconds, (int, float)) + and not isinstance(estimated_remaining_seconds, bool) + and math.isfinite(float(estimated_remaining_seconds)) + and float(estimated_remaining_seconds) >= 0.0 + ): + metrics["estimated_remaining_seconds"] = round( + float(estimated_remaining_seconds), + 3, + ) _state["item_progress"] = { "kind": str(item_kind), "completed": clean_completed, "total": clean_total, **({"current": str(current)} if str(current) else {}), + **metrics, } _write()