From c3a0f269b7d4813db0bc65bd5068a78bbff37480 Mon Sep 17 00:00:00 2001 From: Jerry <99706807@qq.com> Date: Mon, 3 Aug 2026 11:17:05 +0800 Subject: [PATCH] feat: integrate SimpleCADAPI 2.0.2 CAD workflows --- README.md | 23 +- SIMPLECADAPI_2.0.2_INTEGRATION.md | 186 + SIMPLECADAPI_2.0.2_UPDATE.md | 549 + SimpleCADAPI/.gitignore | 6 + SimpleCADAPI/AGENT.md | 31 + SimpleCADAPI/MANIFEST.in | 1 + SimpleCADAPI/README.md | 119 +- SimpleCADAPI/README.zh-CN.md | 54 +- .../design-docs/cadql-brep-query-language.md | 2658 +++ .../cadql-tagging-and-auto-semantics.md | 964 + ...evaluated-scene-and-viewer-architecture.md | 2293 ++ ...ep-brep-recursive-geometric-explanation.md | 1911 ++ SimpleCADAPI/docs/api/Dimension.md | 17 + SimpleCADAPI/docs/api/DimensionTolerance.md | 20 + .../docs/api/FreeCADScriptTranslator.md | 1 + SimpleCADAPI/docs/api/GraphSession.md | 23 +- SimpleCADAPI/docs/api/ModelResult.md | 64 + SimpleCADAPI/docs/api/README.md | 54 + SimpleCADAPI/docs/api/Sketch.md | 9 + SimpleCADAPI/docs/api/ToleranceAnalysis.md | 17 + .../docs/api/ToleranceAnalysisError.md | 17 + SimpleCADAPI/docs/api/ToleranceCheck.md | 17 + .../docs/api/ToleranceContribution.md | 17 + SimpleCADAPI/docs/api/ToleranceGraph.md | 17 + SimpleCADAPI/docs/api/ToleranceReport.md | 17 + SimpleCADAPI/docs/api/ToleranceRequirement.md | 17 + .../docs/api/ToleranceValidationError.md | 17 + SimpleCADAPI/docs/api/Unit.md | 20 + SimpleCADAPI/docs/api/UnitValidationError.md | 17 + SimpleCADAPI/docs/api/Var.md | 8 +- SimpleCADAPI/docs/api/add_line_rsketch.md | 4 +- SimpleCADAPI/docs/api/analyze_tolerance.md | 27 + SimpleCADAPI/docs/api/apply_tag.md | 13 +- SimpleCADAPI/docs/api/apply_tag_rselection.md | 36 + .../docs/api/canonical_unit_for_dimension.md | 19 + SimpleCADAPI/docs/api/capture_result.md | 34 + SimpleCADAPI/docs/api/chamfer_rsolid.md | 17 +- SimpleCADAPI/docs/api/check_tolerance.md | 22 + SimpleCADAPI/docs/api/convert_value.md | 17 + SimpleCADAPI/docs/api/cut_rsolid.md | 10 +- SimpleCADAPI/docs/api/explain_tag.md | 27 + SimpleCADAPI/docs/api/export_model_json.md | 12 +- .../docs/api/expression_uses_units.md | 17 + SimpleCADAPI/docs/api/extrude_rsolid.md | 37 +- SimpleCADAPI/docs/api/fillet_rsolid.md | 17 +- SimpleCADAPI/docs/api/get_unit.md | 17 + SimpleCADAPI/docs/api/infer_dimension.md | 21 + SimpleCADAPI/docs/api/list_tags.md | 15 +- SimpleCADAPI/docs/api/loft_rsolid.md | 23 +- SimpleCADAPI/docs/api/make_box_rsolid.md | 33 +- SimpleCADAPI/docs/api/make_circle_redge.md | 11 +- SimpleCADAPI/docs/api/make_circle_rface.md | 15 +- SimpleCADAPI/docs/api/make_circle_rwire.md | 15 +- SimpleCADAPI/docs/api/make_cone_rsolid.md | 34 +- SimpleCADAPI/docs/api/make_cylinder_rsolid.md | 35 +- .../docs/api/make_face_from_sketch_rface.md | 10 +- .../docs/api/make_face_from_wire_rface.md | 11 +- SimpleCADAPI/docs/api/make_line_redge.md | 12 +- SimpleCADAPI/docs/api/make_rectangle_rface.md | 17 +- SimpleCADAPI/docs/api/make_rectangle_rwire.md | 17 +- SimpleCADAPI/docs/api/make_sketch_rsketch.md | 3 +- .../docs/api/make_wire_from_edges_rwire.md | 6 +- .../docs/api/make_wire_from_sketch_rwire.md | 9 +- SimpleCADAPI/docs/api/model.md | 50 + SimpleCADAPI/docs/api/output_role.md | 23 + .../docs/api/render_screenshot_rpath.md | 6 +- SimpleCADAPI/docs/api/requires_session.md | 28 + SimpleCADAPI/docs/api/revolve_rsolid.md | 23 +- SimpleCADAPI/docs/api/select.md | 12 + SimpleCADAPI/docs/api/select_edges_by_tag.md | 10 +- SimpleCADAPI/docs/api/select_faces_by_tag.md | 10 +- SimpleCADAPI/docs/api/shell_rsolid.md | 26 +- SimpleCADAPI/docs/api/solids.md | 19 + SimpleCADAPI/docs/api/source_binding.md | 19 + SimpleCADAPI/docs/api/source_topology.md | 19 + SimpleCADAPI/docs/api/sweep_rsolid.md | 20 +- SimpleCADAPI/docs/api/twisted_sweep_rsolid.md | 46 + SimpleCADAPI/docs/api/union_rsolid.md | 12 +- SimpleCADAPI/docs/api/var_function.md | 13 +- .../architecture/sdk-architecture-review.md | 8 +- .../translator-backend-contract.md | 101 + SimpleCADAPI/docs/core/README.md | 24 +- .../docs/core/dimension-tolerance-chains.md | 256 + .../docs/core/operation_graph_json_spec.md | 120 +- SimpleCADAPI/docs/core/physical-units.md | 202 + .../docs/core/serialization/README.md | 42 +- .../serialization/expressions-and-replay.md | 23 +- .../features-booleans-transforms.md | 34 + SimpleCADAPI/docs/core/tagged_mixin.md | 46 +- SimpleCADAPI/docs/guides/README.md | 11 + .../guides/step-brep-reverse-engineering.md | 49 + SimpleCADAPI/docs/stdlib/README.md | 17 +- SimpleCADAPI/docs/stdlib/make_bolt_rsolid.md | 41 + .../docs/stdlib/make_helical_gear_rsolid.md | 7 +- .../stdlib/make_herringbone_gear_rsolid.md | 6 +- SimpleCADAPI/docs/stdlib/make_nut_rsolid.md | 35 + .../make_roller_chain_sprocket_rsolid.md | 19 + .../stdlib/make_straight_bevel_gear_rsolid.md | 30 + SimpleCADAPI/examples/.gitignore | 5 + SimpleCADAPI/examples/01_basic_modeling.py | 31 - SimpleCADAPI/examples/02_graph_replay.py | 39 - SimpleCADAPI/examples/03_expressions.py | 33 - .../examples/04_dimension_tolerance_chain.py | 108 + .../examples/05_loft_sweep_revolve.py | 35 - .../examples/06_parametric_gear_model.py | 192 - .../07_serialization_operation_tree.py | 312 - .../examples/08_constrained_sketch.py | 852 +- .../examples/09_naca0016_blade_freecad.py | 50 +- SimpleCADAPI/examples/10_part_assembly.py | 417 +- SimpleCADAPI/examples/11_stdlib_gears.py | 119 - .../12_herringbone_planetary_gears.py | 636 - SimpleCADAPI/examples/13_cycloidal_reducer.py | 847 - SimpleCADAPI/examples/14_ball_bearing.py | 168 - .../examples/15_cached_mesh_obj_export.py | 101 - .../assembly.py | 100 +- .../bearings.py | 8 + .../carriers.py | 17 + .../collision_probe.py | 9 +- .../common.py | 19 +- .../flanges.py | 42 +- .../gears.py | 31 +- .../housing.py | 51 +- .../main.py | 62 +- .../materials.py | 1 + .../shafts.py | 3 + .../examples/17_static_collision_verifier.py | 75 - .../18_leg_wheel_robot_dog_leg/actuator.py | 142 - .../18_leg_wheel_robot_dog_leg/brackets.py | 194 - .../collision_probe.py | 151 - .../18_leg_wheel_robot_dog_leg/hardware.py | 164 - .../leg_assembly.py | 254 - .../18_leg_wheel_robot_dog_leg/leg_common.py | 343 - .../leg_dimensions.py | 173 - .../leg_materials.py | 61 - .../18_leg_wheel_robot_dog_leg/links.py | 592 - .../18_leg_wheel_robot_dog_leg/main.py | 90 - .../assembly.py | 163 - .../common.py | 78 - .../dimensions.py | 54 - .../19_four_planet_planetary_reducer/main.py | 71 - .../materials.py | 35 - .../19_four_planet_planetary_reducer/parts.py | 206 - .../assembly.py | 7 + .../bearings.py | 14 + .../common.py | 24 +- .../electronics.py | 24 +- .../gears.py | 39 + .../housing.py | 50 +- .../20_integrated_bldc_joint_actuator/main.py | 76 +- .../materials.py | 1 + .../motor.py | 29 +- SimpleCADAPI/examples/README.md | 32 +- SimpleCADAPI/pyproject.toml | 10 +- SimpleCADAPI/scene-contract/.gitignore | 2 + SimpleCADAPI/scene-contract/package-lock.json | 304 + SimpleCADAPI/scene-contract/package.json | 24 + SimpleCADAPI/scene-contract/src/canonical.ts | 266 + SimpleCADAPI/scene-contract/src/documents.ts | 158 + .../src/generated/connector_binding.ts | 94 + .../scene-contract/src/generated/entities.ts | 379 + .../scene-contract/src/generated/index.ts | 7 + .../src/generated/normalized_product.ts | 128 + .../src/generated/presentation.ts | 120 + .../scene-contract/src/generated/scene.ts | 545 + SimpleCADAPI/scene-contract/src/glb.ts | 348 + SimpleCADAPI/scene-contract/src/index.ts | 7 + SimpleCADAPI/scene-contract/src/report.ts | 154 + SimpleCADAPI/scene-contract/src/resources.ts | 76 + SimpleCADAPI/scene-contract/src/schema.ts | 103 + SimpleCADAPI/scene-contract/src/transforms.ts | 35 + SimpleCADAPI/scene-contract/src/validation.ts | 1746 ++ SimpleCADAPI/scene-contract/src/zip.ts | 278 + .../scene-contract/test/canonical.test.ts | 87 + .../scene-contract/test/corpus-support.ts | 118 + .../scene-contract/test/corpus.test.ts | 1097 + .../scene-contract/test/documents.test.ts | 165 + .../test/package-budget.test.ts | 262 + .../scene-contract/test/rules.test.ts | 81 + .../scene-contract/test/zip-resource.test.ts | 91 + SimpleCADAPI/scene-contract/tsconfig.json | 16 + SimpleCADAPI/skills/simplecadapi/SKILL.md | 58 +- .../references/MODELING_WORKFLOWS.md | 165 +- .../simplecadapi/references/SDK_OVERVIEW.md | 10 +- .../references/SDK_PACKAGE_SUMMARY.md | 4 +- .../simplecadapi/references/SDK_SURFACES.md | 108 +- .../references/docs/api/Dimension.md | 17 + .../references/docs/api/DimensionTolerance.md | 20 + .../docs/api/FreeCADScriptTranslator.md | 1 + .../references/docs/api/GraphSession.md | 22 +- .../references/docs/api/ModelResult.md | 39 + .../references/docs/api/README.md | 54 + .../references/docs/api/Sketch.md | 7 + .../references/docs/api/ToleranceAnalysis.md | 17 + .../docs/api/ToleranceAnalysisError.md | 17 + .../references/docs/api/ToleranceCheck.md | 17 + .../docs/api/ToleranceContribution.md | 17 + .../references/docs/api/ToleranceGraph.md | 17 + .../references/docs/api/ToleranceReport.md | 17 + .../docs/api/ToleranceRequirement.md | 17 + .../docs/api/ToleranceValidationError.md | 17 + .../simplecadapi/references/docs/api/Unit.md | 20 + .../docs/api/UnitValidationError.md | 17 + .../simplecadapi/references/docs/api/Var.md | 8 +- .../references/docs/api/add_line_rsketch.md | 4 +- .../references/docs/api/analyze_tolerance.md | 27 + .../references/docs/api/apply_tag.md | 13 +- .../docs/api/apply_tag_rselection.md | 36 + .../docs/api/canonical_unit_for_dimension.md | 19 + .../references/docs/api/capture_result.md | 30 + .../references/docs/api/chamfer_rsolid.md | 17 +- .../references/docs/api/check_tolerance.md | 22 + .../references/docs/api/convert_value.md | 17 + .../references/docs/api/cut_rsolid.md | 10 +- .../references/docs/api/explain_tag.md | 27 + .../references/docs/api/export_model_json.md | 12 +- .../docs/api/expression_uses_units.md | 17 + .../references/docs/api/extrude_rsolid.md | 36 +- .../references/docs/api/fillet_rsolid.md | 17 +- .../references/docs/api/get_unit.md | 17 + .../references/docs/api/infer_dimension.md | 21 + .../references/docs/api/list_tags.md | 15 +- .../references/docs/api/loft_rsolid.md | 23 +- .../references/docs/api/make_box_rsolid.md | 26 +- .../references/docs/api/make_circle_redge.md | 11 +- .../references/docs/api/make_circle_rface.md | 15 +- .../references/docs/api/make_circle_rwire.md | 15 +- .../references/docs/api/make_cone_rsolid.md | 27 +- .../docs/api/make_cylinder_rsolid.md | 23 +- .../docs/api/make_face_from_sketch_rface.md | 10 +- .../docs/api/make_face_from_wire_rface.md | 11 +- .../references/docs/api/make_line_redge.md | 12 +- .../docs/api/make_rectangle_rface.md | 17 +- .../docs/api/make_rectangle_rwire.md | 17 +- .../docs/api/make_sketch_rsketch.md | 3 +- .../docs/api/make_wire_from_edges_rwire.md | 6 +- .../docs/api/make_wire_from_sketch_rwire.md | 8 +- .../simplecadapi/references/docs/api/model.md | 44 + .../references/docs/api/output_role.md | 23 + .../docs/api/render_screenshot_rpath.md | 6 +- .../references/docs/api/requires_session.md | 22 + .../references/docs/api/revolve_rsolid.md | 21 +- .../references/docs/api/select.md | 12 + .../docs/api/select_edges_by_tag.md | 10 +- .../docs/api/select_faces_by_tag.md | 10 +- .../references/docs/api/shell_rsolid.md | 26 +- .../references/docs/api/solids.md | 19 + .../references/docs/api/source_binding.md | 19 + .../references/docs/api/source_topology.md | 19 + .../references/docs/api/sweep_rsolid.md | 20 +- .../docs/api/twisted_sweep_rsolid.md | 46 + .../references/docs/api/union_rsolid.md | 12 +- .../references/docs/api/var_function.md | 13 +- .../architecture/sdk-architecture-review.md | 8 +- .../references/docs/core/README.md | 24 +- .../docs/core/dimension-tolerance-chains.md | 56 + .../docs/core/operation_graph_json_spec.md | 22 +- .../references/docs/core/physical-units.md | 45 + .../docs/core/serialization/README.md | 39 +- .../features-booleans-transforms.md | 34 + .../references/docs/core/tagged_mixin.md | 46 +- .../references/docs/stdlib/README.md | 17 +- .../docs/stdlib/make_bolt_rsolid.md | 41 + .../docs/stdlib/make_helical_gear_rsolid.md | 7 +- .../stdlib/make_herringbone_gear_rsolid.md | 6 +- .../references/docs/stdlib/make_nut_rsolid.md | 35 + .../make_roller_chain_sprocket_rsolid.md | 19 + .../stdlib/make_straight_bevel_gear_rsolid.md | 30 + .../brep-reverse-engineering.md | 761 + SimpleCADAPI/src/simplecadapi/__init__.py | 191 +- SimpleCADAPI/src/simplecadapi/_mesh.py | 69 +- .../simplecadapi/auto_tools/auto_docs_gen.py | 64 +- .../src/simplecadapi/auto_tools/skill_pack.py | 77 +- SimpleCADAPI/src/simplecadapi/autotag.py | 717 +- SimpleCADAPI/src/simplecadapi/core.py | 543 +- SimpleCADAPI/src/simplecadapi/expr.py | 482 +- SimpleCADAPI/src/simplecadapi/graph.py | 672 +- .../simplecadapi/inverse_engineer/__init__.py | 5 + .../inverse_engineer/brep/__init__.py | 46 + .../inverse_engineer/brep/__main__.py | 3 + .../simplecadapi/inverse_engineer/brep/cli.py | 118 + .../inverse_engineer/brep/compare.py | 456 + .../inverse_engineer/brep/inspect.py | 284 + .../simplecadapi/inverse_engineer/brep/io.py | 64 + .../inverse_engineer/brep/render.py | 126 + .../inverse_engineer/brep/slices.py | 247 + .../src/simplecadapi/kernel/ocp_booleans.py | 100 +- .../src/simplecadapi/kernel/ocp_builders.py | 109 +- .../src/simplecadapi/kernel/ocp_mesh.py | 85 +- SimpleCADAPI/src/simplecadapi/operations.py | 2699 ++- SimpleCADAPI/src/simplecadapi/ql.py | 486 +- .../src/simplecadapi/scene/__init__.py | 158 + .../src/simplecadapi/scene/archive.py | 437 + .../src/simplecadapi/scene/canonical.py | 120 + .../src/simplecadapi/scene/compiler.py | 1058 + .../ocp-evaluated-properties-1.profile.json | 248 + .../profiles/scene-1.0-ocp-glb-2.profile.json | 222 + .../pseudocode/ocp-evaluated-properties-1.md | 308 + .../pseudocode/scene-1.0-ocp-glb-2.md | 255 + .../contracts/rules/scene-1.0-rules.json | 34 + .../schemas/connector-binding-1.0.schema.json | 44 + .../schemas/entities-1.0.schema.json | 124 + ...evaluated-properties-profile-1.schema.json | 253 + .../schemas/normalized-product-1.schema.json | 81 + .../schemas/presentation-1.0.schema.json | 51 + .../contracts/schemas/profile-1.schema.json | 9 + .../schemas/render-profile-2.schema.json | 227 + .../contracts/schemas/rules-1.schema.json | 23 + .../contracts/schemas/scene-1.0.schema.json | 398 + .../src/simplecadapi/scene/documents.py | 186 + .../simplecadapi/scene/generated/__init__.py | 15 + .../scene/generated/connector_binding.py | 67 + .../simplecadapi/scene/generated/entities.py | 275 + .../scene/generated/normalized_product.py | 145 + .../scene/generated/presentation.py | 92 + .../src/simplecadapi/scene/generated/scene.py | 377 + SimpleCADAPI/src/simplecadapi/scene/glb.py | 397 + .../src/simplecadapi/scene/glb_writer.py | 135 + .../src/simplecadapi/scene/render_mesh.py | 364 + .../src/simplecadapi/scene/resources.py | 120 + .../src/simplecadapi/scene/transforms.py | 56 + .../src/simplecadapi/scene/validation.py | 2071 ++ SimpleCADAPI/src/simplecadapi/serializer.py | 506 +- SimpleCADAPI/src/simplecadapi/sketch.py | 2 +- .../src/simplecadapi/source_mapping.py | 367 + SimpleCADAPI/src/simplecadapi/std/__init__.py | 9 +- SimpleCADAPI/src/simplecadapi/std/bearing.py | 2 + SimpleCADAPI/src/simplecadapi/std/chain.py | 112 + SimpleCADAPI/src/simplecadapi/std/fastener.py | 919 + SimpleCADAPI/src/simplecadapi/std/gear.py | 677 +- SimpleCADAPI/src/simplecadapi/tagging.py | 919 +- SimpleCADAPI/src/simplecadapi/tolerance.py | 1729 ++ SimpleCADAPI/src/simplecadapi/topology.py | 98 +- SimpleCADAPI/src/simplecadapi/tracking.py | 1339 +- .../src/simplecadapi/translator/__init__.py | 13 + .../src/simplecadapi/translator/base.py | 46 + .../src/simplecadapi/translator/errors.py | 53 + .../translator/freecad_translator/__init__.py | 12 +- .../translator/freecad_translator/analysis.py | 181 + .../translator/freecad_translator/api.py | 80 +- .../freecad_translator/capabilities.py | 189 + .../translator/freecad_translator/codegen.py | 332 + .../translator/freecad_translator/context.py | 23 + .../freecad_translator/emitters/__init__.py | 24 + .../freecad_translator/emitters/booleans.py | 174 + .../freecad_translator/emitters/features.py | 175 + .../freecad_translator/emitters/geometry.py | 440 + .../freecad_translator/emitters/primitives.py | 99 + .../freecad_translator/emitters/products.py | 247 + .../freecad_translator/emitters/registry.py | 116 + .../freecad_translator/emitters/selections.py | 81 + .../freecad_translator/emitters/sketches.py | 87 + .../freecad_translator/emitters/transforms.py | 124 + .../translator/freecad_translator/exporter.py | 73 + .../freecad_translator/runtime/__init__.py | 28 + .../freecad_translator/runtime/assemblies.py | 245 + .../freecad_translator/runtime/curves.py | 241 + .../freecad_translator/runtime/expressions.py | 505 + .../freecad_translator/runtime/geometry.py | 583 + .../freecad_translator/runtime/metadata.py | 440 + .../freecad_translator/runtime/persistence.py | 264 + .../freecad_translator/runtime/products.py | 276 + .../freecad_translator/runtime/selections.py | 731 + .../freecad_translator/runtime/sketches.py | 635 + .../freecad_translator/script_translator.py | 5081 +--- .../freecad_translator/translator.py | 674 + .../fusion360_translator/__init__.py | 13 + .../translator/fusion360_translator/api.py | 28 + .../fusion360_translator/capabilities.py | 117 + .../fusion360_translator/compiler.py | 4185 ++++ .../fusion360_translator/script_translator.py | 5 + .../fusion360_translator/translator.py | 147 + .../solidworks_translator/__init__.py | 19 + .../translator/solidworks_translator/api.py | 56 + .../solidworks_translator/capabilities.py | 130 + .../solidworks_translator/compiler.py | 10868 +++++++++ .../script_translator.py | 5 + .../solidworks_translator/translator.py | 144 + .../src/simplecadapi/translator/types.py | 77 + SimpleCADAPI/src/simplecadapi/units.py | 462 + .../test/fixtures/scene-contract/corpus.json | 19872 ++++++++++++++++ .../test/scene_contract_corpus_support.py | 65 + SimpleCADAPI/test/test_all_features.py | 13 +- SimpleCADAPI/test/test_auto_docs_gen.py | 42 +- .../test/test_example_model_contract.py | 108 + SimpleCADAPI/test/test_freecad_translator.py | 727 +- .../test/test_freecad_translator_gsm.py | 241 + .../test/test_fusion360_translator.py | 264 + SimpleCADAPI/test/test_graph_model_api.py | 289 + SimpleCADAPI/test/test_graph_ql.py | 59 +- .../test/test_inverse_engineer_brep.py | 150 + SimpleCADAPI/test/test_model_json.py | 33 + .../test/test_operation_output_roles.py | 869 + .../test/test_original_api_integration.py | 17 +- SimpleCADAPI/test/test_product_assembly.py | 146 + SimpleCADAPI/test/test_public_api_surface.py | 38 + SimpleCADAPI/test/test_ql.py | 82 + .../test/test_rearchitecture_2_0_contract.py | 7 +- .../test/test_rearchitecture_2_0_ops.py | 226 + SimpleCADAPI/test/test_scene_compiler.py | 534 + SimpleCADAPI/test/test_scene_contract.py | 1859 ++ .../test_scene_phase_a_characterization.py | 345 + .../test/test_scene_phase_a_examples.py | 238 + SimpleCADAPI/test/test_serialization.py | 294 + SimpleCADAPI/test/test_sketch_api.py | 103 + SimpleCADAPI/test/test_skill_pack.py | 3 + .../test/test_solidworks_translator.py | 97 + SimpleCADAPI/test/test_source_mapping.py | 116 + SimpleCADAPI/test/test_stdlib_bearing.py | 6 + SimpleCADAPI/test/test_stdlib_chain.py | 106 + SimpleCADAPI/test/test_stdlib_fastener.py | 359 + SimpleCADAPI/test/test_stdlib_gear.py | 420 +- SimpleCADAPI/test/test_tagging_refactor.py | 281 +- SimpleCADAPI/test/test_tolerance.py | 722 + SimpleCADAPI/test/test_tracking_boolean.py | 202 +- .../test/test_tracking_data_models.py | 51 + SimpleCADAPI/test/test_tracking_features.py | 12 +- SimpleCADAPI/test/test_tracking_tags.py | 149 +- .../test/test_translator_backend_contract.py | 110 + SimpleCADAPI/test/test_units.py | 534 + SimpleCADAPI/tests/test_topology_identity.py | 93 + .../tools/generate_scene_contract_fixtures.py | 480 + .../tools/generate_scene_contract_types.py | 267 + .../tools/run_example_scene_exports.py | 383 + .../tools/scene_contract_fixtures/__init__.py | 1 + .../scene_contract_fixtures/authored_cases.py | 167 + .../scene_contract_fixtures/base_scene.py | 471 + .../scene_contract_fixtures/binary_cases.py | 703 + .../tools/scene_contract_fixtures/common.py | 145 + .../scene_contract_fixtures/entity_cases.py | 103 + .../scene_contract_fixtures/field_matrix.py | 215 + .../scene_contract_fixtures/numeric_cases.py | 128 + .../scene_contract_fixtures/package_cases.py | 263 + .../scene_contract_fixtures/resource_cases.py | 710 + .../scene_contract_fixtures/scene_cases.py | 218 + .../scene_contract_fixtures/scene_corpus.py | 140 + .../schema_field_matrix.py | 330 + SimpleCADAPI/uv.lock | 247 +- SimpleCADAPI/viewer/README.md | 42 + SimpleCADAPI/viewer/index.html | 13 + SimpleCADAPI/viewer/package-lock.json | 1240 + SimpleCADAPI/viewer/package.json | 22 + .../viewer/src/components/click-selection.ts | 68 + .../viewer/src/components/panel-resizer.ts | 110 + SimpleCADAPI/viewer/src/main.ts | 1136 + SimpleCADAPI/viewer/src/style.css | 101 + SimpleCADAPI/viewer/tsconfig.json | 14 + SimpleCADAPI/viewer/vite.config.ts | 3 + .../tasks/[taskId]/exports/[format]/route.ts | 41 + .../src/components/agent-studio.tsx | 304 +- cad-agent-studio/src/lib/cad-generator.ts | 164 +- cad-agent-studio/src/lib/chat.ts | 108 + cad-agent-studio/src/lib/preview-artifacts.ts | 4 + cad-agent-studio/src/lib/robot-export.ts | 231 + .../assembly-designir-1.0.schema.json | 457 + .../contracts/designir-3.0.schema.json | 39 +- designir-pipeline/scripts/designir_codec.py | 121 + .../scripts/designir_pipeline.py | 59 +- .../scripts/robot_description_export.py | 282 + .../scripts/surfaceir_pipeline.py | 817 +- .../scripts/step_to_case.py | 128 +- .../tests/test_assembly_designir.py | 87 + .../tests/test_designir_pipeline.py | 8 +- .../tests/test_experience_library.py | 58 + .../tests/test_robot_description_export.py | 78 + .../tests/test_surfaceir_pipeline.py | 70 +- .../lcd25-reducer/semantic-assembly/README.md | 79 + ...5-with-output-shaft.assembly-designir.json | 967 + ...25-harmonic-reducer.assembly-designir.json | 739 + .../validate_assembly_design.py | 253 + .../validation/add-output-shaft.json | 11 + .../semantic-assembly/validation/base.json | 11 + .../variants/add-output-shaft.patch.json | 138 + text-to-cad/skills/cad-router/SKILL.md | 28 +- .../cad-router/references/backend-contract.md | 9 + .../cad-router/references/capabilities.md | 47 +- .../cad-router/references/designir-3.0.md | 43 + .../cad-router/scripts/capabilities.json | 50 +- .../cad-router/scripts/reconstruct_step.py | 359 +- .../skills/cad-router/scripts/route.py | 268 +- .../cad-router/test_reconstruct_step.py | 25 + .../python/skills/cad-router/test_route.py | 76 + 481 files changed, 110229 insertions(+), 12826 deletions(-) create mode 100644 SIMPLECADAPI_2.0.2_INTEGRATION.md create mode 100644 SIMPLECADAPI_2.0.2_UPDATE.md create mode 100644 SimpleCADAPI/AGENT.md create mode 100644 SimpleCADAPI/design-docs/cadql-brep-query-language.md create mode 100644 SimpleCADAPI/design-docs/cadql-tagging-and-auto-semantics.md create mode 100644 SimpleCADAPI/design-docs/evaluated-scene-and-viewer-architecture.md create mode 100644 SimpleCADAPI/design-docs/step-brep-recursive-geometric-explanation.md create mode 100644 SimpleCADAPI/docs/api/Dimension.md create mode 100644 SimpleCADAPI/docs/api/DimensionTolerance.md create mode 100644 SimpleCADAPI/docs/api/ModelResult.md create mode 100644 SimpleCADAPI/docs/api/ToleranceAnalysis.md create mode 100644 SimpleCADAPI/docs/api/ToleranceAnalysisError.md create mode 100644 SimpleCADAPI/docs/api/ToleranceCheck.md create mode 100644 SimpleCADAPI/docs/api/ToleranceContribution.md create mode 100644 SimpleCADAPI/docs/api/ToleranceGraph.md create mode 100644 SimpleCADAPI/docs/api/ToleranceReport.md create mode 100644 SimpleCADAPI/docs/api/ToleranceRequirement.md create mode 100644 SimpleCADAPI/docs/api/ToleranceValidationError.md create mode 100644 SimpleCADAPI/docs/api/Unit.md create mode 100644 SimpleCADAPI/docs/api/UnitValidationError.md create mode 100644 SimpleCADAPI/docs/api/analyze_tolerance.md create mode 100644 SimpleCADAPI/docs/api/apply_tag_rselection.md create mode 100644 SimpleCADAPI/docs/api/canonical_unit_for_dimension.md create mode 100644 SimpleCADAPI/docs/api/capture_result.md create mode 100644 SimpleCADAPI/docs/api/check_tolerance.md create mode 100644 SimpleCADAPI/docs/api/convert_value.md create mode 100644 SimpleCADAPI/docs/api/explain_tag.md create mode 100644 SimpleCADAPI/docs/api/expression_uses_units.md create mode 100644 SimpleCADAPI/docs/api/get_unit.md create mode 100644 SimpleCADAPI/docs/api/infer_dimension.md create mode 100644 SimpleCADAPI/docs/api/model.md create mode 100644 SimpleCADAPI/docs/api/output_role.md create mode 100644 SimpleCADAPI/docs/api/requires_session.md create mode 100644 SimpleCADAPI/docs/api/solids.md create mode 100644 SimpleCADAPI/docs/api/source_binding.md create mode 100644 SimpleCADAPI/docs/api/source_topology.md create mode 100644 SimpleCADAPI/docs/api/twisted_sweep_rsolid.md create mode 100644 SimpleCADAPI/docs/architecture/translator-backend-contract.md create mode 100644 SimpleCADAPI/docs/core/dimension-tolerance-chains.md create mode 100644 SimpleCADAPI/docs/core/physical-units.md create mode 100644 SimpleCADAPI/docs/guides/README.md create mode 100644 SimpleCADAPI/docs/guides/step-brep-reverse-engineering.md create mode 100644 SimpleCADAPI/docs/stdlib/make_bolt_rsolid.md create mode 100644 SimpleCADAPI/docs/stdlib/make_nut_rsolid.md create mode 100644 SimpleCADAPI/docs/stdlib/make_roller_chain_sprocket_rsolid.md create mode 100644 SimpleCADAPI/docs/stdlib/make_straight_bevel_gear_rsolid.md delete mode 100644 SimpleCADAPI/examples/01_basic_modeling.py delete mode 100644 SimpleCADAPI/examples/02_graph_replay.py delete mode 100644 SimpleCADAPI/examples/03_expressions.py create mode 100644 SimpleCADAPI/examples/04_dimension_tolerance_chain.py delete mode 100644 SimpleCADAPI/examples/05_loft_sweep_revolve.py delete mode 100644 SimpleCADAPI/examples/06_parametric_gear_model.py delete mode 100644 SimpleCADAPI/examples/07_serialization_operation_tree.py delete mode 100644 SimpleCADAPI/examples/11_stdlib_gears.py delete mode 100644 SimpleCADAPI/examples/12_herringbone_planetary_gears.py delete mode 100644 SimpleCADAPI/examples/13_cycloidal_reducer.py delete mode 100644 SimpleCADAPI/examples/14_ball_bearing.py delete mode 100644 SimpleCADAPI/examples/15_cached_mesh_obj_export.py delete mode 100644 SimpleCADAPI/examples/17_static_collision_verifier.py delete mode 100644 SimpleCADAPI/examples/18_leg_wheel_robot_dog_leg/actuator.py delete mode 100644 SimpleCADAPI/examples/18_leg_wheel_robot_dog_leg/brackets.py delete mode 100644 SimpleCADAPI/examples/18_leg_wheel_robot_dog_leg/collision_probe.py delete mode 100644 SimpleCADAPI/examples/18_leg_wheel_robot_dog_leg/hardware.py delete mode 100644 SimpleCADAPI/examples/18_leg_wheel_robot_dog_leg/leg_assembly.py delete mode 100644 SimpleCADAPI/examples/18_leg_wheel_robot_dog_leg/leg_common.py delete mode 100644 SimpleCADAPI/examples/18_leg_wheel_robot_dog_leg/leg_dimensions.py delete mode 100644 SimpleCADAPI/examples/18_leg_wheel_robot_dog_leg/leg_materials.py delete mode 100644 SimpleCADAPI/examples/18_leg_wheel_robot_dog_leg/links.py delete mode 100644 SimpleCADAPI/examples/18_leg_wheel_robot_dog_leg/main.py delete mode 100644 SimpleCADAPI/examples/19_four_planet_planetary_reducer/assembly.py delete mode 100644 SimpleCADAPI/examples/19_four_planet_planetary_reducer/common.py delete mode 100644 SimpleCADAPI/examples/19_four_planet_planetary_reducer/dimensions.py delete mode 100644 SimpleCADAPI/examples/19_four_planet_planetary_reducer/main.py delete mode 100644 SimpleCADAPI/examples/19_four_planet_planetary_reducer/materials.py delete mode 100644 SimpleCADAPI/examples/19_four_planet_planetary_reducer/parts.py create mode 100644 SimpleCADAPI/scene-contract/.gitignore create mode 100644 SimpleCADAPI/scene-contract/package-lock.json create mode 100644 SimpleCADAPI/scene-contract/package.json create mode 100644 SimpleCADAPI/scene-contract/src/canonical.ts create mode 100644 SimpleCADAPI/scene-contract/src/documents.ts create mode 100644 SimpleCADAPI/scene-contract/src/generated/connector_binding.ts create mode 100644 SimpleCADAPI/scene-contract/src/generated/entities.ts create mode 100644 SimpleCADAPI/scene-contract/src/generated/index.ts create mode 100644 SimpleCADAPI/scene-contract/src/generated/normalized_product.ts create mode 100644 SimpleCADAPI/scene-contract/src/generated/presentation.ts create mode 100644 SimpleCADAPI/scene-contract/src/generated/scene.ts create mode 100644 SimpleCADAPI/scene-contract/src/glb.ts create mode 100644 SimpleCADAPI/scene-contract/src/index.ts create mode 100644 SimpleCADAPI/scene-contract/src/report.ts create mode 100644 SimpleCADAPI/scene-contract/src/resources.ts create mode 100644 SimpleCADAPI/scene-contract/src/schema.ts create mode 100644 SimpleCADAPI/scene-contract/src/transforms.ts create mode 100644 SimpleCADAPI/scene-contract/src/validation.ts create mode 100644 SimpleCADAPI/scene-contract/src/zip.ts create mode 100644 SimpleCADAPI/scene-contract/test/canonical.test.ts create mode 100644 SimpleCADAPI/scene-contract/test/corpus-support.ts create mode 100644 SimpleCADAPI/scene-contract/test/corpus.test.ts create mode 100644 SimpleCADAPI/scene-contract/test/documents.test.ts create mode 100644 SimpleCADAPI/scene-contract/test/package-budget.test.ts create mode 100644 SimpleCADAPI/scene-contract/test/rules.test.ts create mode 100644 SimpleCADAPI/scene-contract/test/zip-resource.test.ts create mode 100644 SimpleCADAPI/scene-contract/tsconfig.json create mode 100644 SimpleCADAPI/skills/simplecadapi/references/docs/api/Dimension.md create mode 100644 SimpleCADAPI/skills/simplecadapi/references/docs/api/DimensionTolerance.md create mode 100644 SimpleCADAPI/skills/simplecadapi/references/docs/api/ModelResult.md create mode 100644 SimpleCADAPI/skills/simplecadapi/references/docs/api/ToleranceAnalysis.md create mode 100644 SimpleCADAPI/skills/simplecadapi/references/docs/api/ToleranceAnalysisError.md create mode 100644 SimpleCADAPI/skills/simplecadapi/references/docs/api/ToleranceCheck.md create mode 100644 SimpleCADAPI/skills/simplecadapi/references/docs/api/ToleranceContribution.md create mode 100644 SimpleCADAPI/skills/simplecadapi/references/docs/api/ToleranceGraph.md create mode 100644 SimpleCADAPI/skills/simplecadapi/references/docs/api/ToleranceReport.md create mode 100644 SimpleCADAPI/skills/simplecadapi/references/docs/api/ToleranceRequirement.md create mode 100644 SimpleCADAPI/skills/simplecadapi/references/docs/api/ToleranceValidationError.md create mode 100644 SimpleCADAPI/skills/simplecadapi/references/docs/api/Unit.md create mode 100644 SimpleCADAPI/skills/simplecadapi/references/docs/api/UnitValidationError.md create mode 100644 SimpleCADAPI/skills/simplecadapi/references/docs/api/analyze_tolerance.md create mode 100644 SimpleCADAPI/skills/simplecadapi/references/docs/api/apply_tag_rselection.md create mode 100644 SimpleCADAPI/skills/simplecadapi/references/docs/api/canonical_unit_for_dimension.md create mode 100644 SimpleCADAPI/skills/simplecadapi/references/docs/api/capture_result.md create mode 100644 SimpleCADAPI/skills/simplecadapi/references/docs/api/check_tolerance.md create mode 100644 SimpleCADAPI/skills/simplecadapi/references/docs/api/convert_value.md create mode 100644 SimpleCADAPI/skills/simplecadapi/references/docs/api/explain_tag.md create mode 100644 SimpleCADAPI/skills/simplecadapi/references/docs/api/expression_uses_units.md create mode 100644 SimpleCADAPI/skills/simplecadapi/references/docs/api/get_unit.md create mode 100644 SimpleCADAPI/skills/simplecadapi/references/docs/api/infer_dimension.md create mode 100644 SimpleCADAPI/skills/simplecadapi/references/docs/api/model.md create mode 100644 SimpleCADAPI/skills/simplecadapi/references/docs/api/output_role.md create mode 100644 SimpleCADAPI/skills/simplecadapi/references/docs/api/requires_session.md create mode 100644 SimpleCADAPI/skills/simplecadapi/references/docs/api/solids.md create mode 100644 SimpleCADAPI/skills/simplecadapi/references/docs/api/source_binding.md create mode 100644 SimpleCADAPI/skills/simplecadapi/references/docs/api/source_topology.md create mode 100644 SimpleCADAPI/skills/simplecadapi/references/docs/api/twisted_sweep_rsolid.md create mode 100644 SimpleCADAPI/skills/simplecadapi/references/docs/core/dimension-tolerance-chains.md create mode 100644 SimpleCADAPI/skills/simplecadapi/references/docs/core/physical-units.md create mode 100644 SimpleCADAPI/skills/simplecadapi/references/docs/stdlib/make_bolt_rsolid.md create mode 100644 SimpleCADAPI/skills/simplecadapi/references/docs/stdlib/make_nut_rsolid.md create mode 100644 SimpleCADAPI/skills/simplecadapi/references/docs/stdlib/make_roller_chain_sprocket_rsolid.md create mode 100644 SimpleCADAPI/skills/simplecadapi/references/docs/stdlib/make_straight_bevel_gear_rsolid.md create mode 100644 SimpleCADAPI/skills/simplecadapi/references/inverse_engineer/brep-reverse-engineering.md create mode 100644 SimpleCADAPI/src/simplecadapi/inverse_engineer/__init__.py create mode 100644 SimpleCADAPI/src/simplecadapi/inverse_engineer/brep/__init__.py create mode 100644 SimpleCADAPI/src/simplecadapi/inverse_engineer/brep/__main__.py create mode 100644 SimpleCADAPI/src/simplecadapi/inverse_engineer/brep/cli.py create mode 100644 SimpleCADAPI/src/simplecadapi/inverse_engineer/brep/compare.py create mode 100644 SimpleCADAPI/src/simplecadapi/inverse_engineer/brep/inspect.py create mode 100644 SimpleCADAPI/src/simplecadapi/inverse_engineer/brep/io.py create mode 100644 SimpleCADAPI/src/simplecadapi/inverse_engineer/brep/render.py create mode 100644 SimpleCADAPI/src/simplecadapi/inverse_engineer/brep/slices.py create mode 100644 SimpleCADAPI/src/simplecadapi/scene/__init__.py create mode 100644 SimpleCADAPI/src/simplecadapi/scene/archive.py create mode 100644 SimpleCADAPI/src/simplecadapi/scene/canonical.py create mode 100644 SimpleCADAPI/src/simplecadapi/scene/compiler.py create mode 100644 SimpleCADAPI/src/simplecadapi/scene/contracts/profiles/ocp-evaluated-properties-1.profile.json create mode 100644 SimpleCADAPI/src/simplecadapi/scene/contracts/profiles/scene-1.0-ocp-glb-2.profile.json create mode 100644 SimpleCADAPI/src/simplecadapi/scene/contracts/pseudocode/ocp-evaluated-properties-1.md create mode 100644 SimpleCADAPI/src/simplecadapi/scene/contracts/pseudocode/scene-1.0-ocp-glb-2.md create mode 100644 SimpleCADAPI/src/simplecadapi/scene/contracts/rules/scene-1.0-rules.json create mode 100644 SimpleCADAPI/src/simplecadapi/scene/contracts/schemas/connector-binding-1.0.schema.json create mode 100644 SimpleCADAPI/src/simplecadapi/scene/contracts/schemas/entities-1.0.schema.json create mode 100644 SimpleCADAPI/src/simplecadapi/scene/contracts/schemas/evaluated-properties-profile-1.schema.json create mode 100644 SimpleCADAPI/src/simplecadapi/scene/contracts/schemas/normalized-product-1.schema.json create mode 100644 SimpleCADAPI/src/simplecadapi/scene/contracts/schemas/presentation-1.0.schema.json create mode 100644 SimpleCADAPI/src/simplecadapi/scene/contracts/schemas/profile-1.schema.json create mode 100644 SimpleCADAPI/src/simplecadapi/scene/contracts/schemas/render-profile-2.schema.json create mode 100644 SimpleCADAPI/src/simplecadapi/scene/contracts/schemas/rules-1.schema.json create mode 100644 SimpleCADAPI/src/simplecadapi/scene/contracts/schemas/scene-1.0.schema.json create mode 100644 SimpleCADAPI/src/simplecadapi/scene/documents.py create mode 100644 SimpleCADAPI/src/simplecadapi/scene/generated/__init__.py create mode 100644 SimpleCADAPI/src/simplecadapi/scene/generated/connector_binding.py create mode 100644 SimpleCADAPI/src/simplecadapi/scene/generated/entities.py create mode 100644 SimpleCADAPI/src/simplecadapi/scene/generated/normalized_product.py create mode 100644 SimpleCADAPI/src/simplecadapi/scene/generated/presentation.py create mode 100644 SimpleCADAPI/src/simplecadapi/scene/generated/scene.py create mode 100644 SimpleCADAPI/src/simplecadapi/scene/glb.py create mode 100644 SimpleCADAPI/src/simplecadapi/scene/glb_writer.py create mode 100644 SimpleCADAPI/src/simplecadapi/scene/render_mesh.py create mode 100644 SimpleCADAPI/src/simplecadapi/scene/resources.py create mode 100644 SimpleCADAPI/src/simplecadapi/scene/transforms.py create mode 100644 SimpleCADAPI/src/simplecadapi/scene/validation.py create mode 100644 SimpleCADAPI/src/simplecadapi/source_mapping.py create mode 100644 SimpleCADAPI/src/simplecadapi/std/chain.py create mode 100644 SimpleCADAPI/src/simplecadapi/std/fastener.py create mode 100644 SimpleCADAPI/src/simplecadapi/tolerance.py create mode 100644 SimpleCADAPI/src/simplecadapi/translator/base.py create mode 100644 SimpleCADAPI/src/simplecadapi/translator/errors.py create mode 100644 SimpleCADAPI/src/simplecadapi/translator/freecad_translator/analysis.py create mode 100644 SimpleCADAPI/src/simplecadapi/translator/freecad_translator/capabilities.py create mode 100644 SimpleCADAPI/src/simplecadapi/translator/freecad_translator/codegen.py create mode 100644 SimpleCADAPI/src/simplecadapi/translator/freecad_translator/context.py create mode 100644 SimpleCADAPI/src/simplecadapi/translator/freecad_translator/emitters/__init__.py create mode 100644 SimpleCADAPI/src/simplecadapi/translator/freecad_translator/emitters/booleans.py create mode 100644 SimpleCADAPI/src/simplecadapi/translator/freecad_translator/emitters/features.py create mode 100644 SimpleCADAPI/src/simplecadapi/translator/freecad_translator/emitters/geometry.py create mode 100644 SimpleCADAPI/src/simplecadapi/translator/freecad_translator/emitters/primitives.py create mode 100644 SimpleCADAPI/src/simplecadapi/translator/freecad_translator/emitters/products.py create mode 100644 SimpleCADAPI/src/simplecadapi/translator/freecad_translator/emitters/registry.py create mode 100644 SimpleCADAPI/src/simplecadapi/translator/freecad_translator/emitters/selections.py create mode 100644 SimpleCADAPI/src/simplecadapi/translator/freecad_translator/emitters/sketches.py create mode 100644 SimpleCADAPI/src/simplecadapi/translator/freecad_translator/emitters/transforms.py create mode 100644 SimpleCADAPI/src/simplecadapi/translator/freecad_translator/exporter.py create mode 100644 SimpleCADAPI/src/simplecadapi/translator/freecad_translator/runtime/__init__.py create mode 100644 SimpleCADAPI/src/simplecadapi/translator/freecad_translator/runtime/assemblies.py create mode 100644 SimpleCADAPI/src/simplecadapi/translator/freecad_translator/runtime/curves.py create mode 100644 SimpleCADAPI/src/simplecadapi/translator/freecad_translator/runtime/expressions.py create mode 100644 SimpleCADAPI/src/simplecadapi/translator/freecad_translator/runtime/geometry.py create mode 100644 SimpleCADAPI/src/simplecadapi/translator/freecad_translator/runtime/metadata.py create mode 100644 SimpleCADAPI/src/simplecadapi/translator/freecad_translator/runtime/persistence.py create mode 100644 SimpleCADAPI/src/simplecadapi/translator/freecad_translator/runtime/products.py create mode 100644 SimpleCADAPI/src/simplecadapi/translator/freecad_translator/runtime/selections.py create mode 100644 SimpleCADAPI/src/simplecadapi/translator/freecad_translator/runtime/sketches.py create mode 100644 SimpleCADAPI/src/simplecadapi/translator/freecad_translator/translator.py create mode 100644 SimpleCADAPI/src/simplecadapi/translator/fusion360_translator/__init__.py create mode 100644 SimpleCADAPI/src/simplecadapi/translator/fusion360_translator/api.py create mode 100644 SimpleCADAPI/src/simplecadapi/translator/fusion360_translator/capabilities.py create mode 100644 SimpleCADAPI/src/simplecadapi/translator/fusion360_translator/compiler.py create mode 100644 SimpleCADAPI/src/simplecadapi/translator/fusion360_translator/script_translator.py create mode 100644 SimpleCADAPI/src/simplecadapi/translator/fusion360_translator/translator.py create mode 100644 SimpleCADAPI/src/simplecadapi/translator/solidworks_translator/__init__.py create mode 100644 SimpleCADAPI/src/simplecadapi/translator/solidworks_translator/api.py create mode 100644 SimpleCADAPI/src/simplecadapi/translator/solidworks_translator/capabilities.py create mode 100644 SimpleCADAPI/src/simplecadapi/translator/solidworks_translator/compiler.py create mode 100644 SimpleCADAPI/src/simplecadapi/translator/solidworks_translator/script_translator.py create mode 100644 SimpleCADAPI/src/simplecadapi/translator/solidworks_translator/translator.py create mode 100644 SimpleCADAPI/src/simplecadapi/translator/types.py create mode 100644 SimpleCADAPI/src/simplecadapi/units.py create mode 100644 SimpleCADAPI/test/fixtures/scene-contract/corpus.json create mode 100644 SimpleCADAPI/test/scene_contract_corpus_support.py create mode 100644 SimpleCADAPI/test/test_example_model_contract.py create mode 100644 SimpleCADAPI/test/test_freecad_translator_gsm.py create mode 100644 SimpleCADAPI/test/test_fusion360_translator.py create mode 100644 SimpleCADAPI/test/test_graph_model_api.py create mode 100644 SimpleCADAPI/test/test_inverse_engineer_brep.py create mode 100644 SimpleCADAPI/test/test_operation_output_roles.py create mode 100644 SimpleCADAPI/test/test_scene_compiler.py create mode 100644 SimpleCADAPI/test/test_scene_contract.py create mode 100644 SimpleCADAPI/test/test_scene_phase_a_characterization.py create mode 100644 SimpleCADAPI/test/test_scene_phase_a_examples.py create mode 100644 SimpleCADAPI/test/test_solidworks_translator.py create mode 100644 SimpleCADAPI/test/test_source_mapping.py create mode 100644 SimpleCADAPI/test/test_stdlib_chain.py create mode 100644 SimpleCADAPI/test/test_stdlib_fastener.py create mode 100644 SimpleCADAPI/test/test_tolerance.py create mode 100644 SimpleCADAPI/test/test_translator_backend_contract.py create mode 100644 SimpleCADAPI/test/test_units.py create mode 100644 SimpleCADAPI/tools/generate_scene_contract_fixtures.py create mode 100644 SimpleCADAPI/tools/generate_scene_contract_types.py create mode 100644 SimpleCADAPI/tools/run_example_scene_exports.py create mode 100644 SimpleCADAPI/tools/scene_contract_fixtures/__init__.py create mode 100644 SimpleCADAPI/tools/scene_contract_fixtures/authored_cases.py create mode 100644 SimpleCADAPI/tools/scene_contract_fixtures/base_scene.py create mode 100644 SimpleCADAPI/tools/scene_contract_fixtures/binary_cases.py create mode 100644 SimpleCADAPI/tools/scene_contract_fixtures/common.py create mode 100644 SimpleCADAPI/tools/scene_contract_fixtures/entity_cases.py create mode 100644 SimpleCADAPI/tools/scene_contract_fixtures/field_matrix.py create mode 100644 SimpleCADAPI/tools/scene_contract_fixtures/numeric_cases.py create mode 100644 SimpleCADAPI/tools/scene_contract_fixtures/package_cases.py create mode 100644 SimpleCADAPI/tools/scene_contract_fixtures/resource_cases.py create mode 100644 SimpleCADAPI/tools/scene_contract_fixtures/scene_cases.py create mode 100644 SimpleCADAPI/tools/scene_contract_fixtures/scene_corpus.py create mode 100644 SimpleCADAPI/tools/scene_contract_fixtures/schema_field_matrix.py create mode 100644 SimpleCADAPI/viewer/README.md create mode 100644 SimpleCADAPI/viewer/index.html create mode 100644 SimpleCADAPI/viewer/package-lock.json create mode 100644 SimpleCADAPI/viewer/package.json create mode 100644 SimpleCADAPI/viewer/src/components/click-selection.ts create mode 100644 SimpleCADAPI/viewer/src/components/panel-resizer.ts create mode 100644 SimpleCADAPI/viewer/src/main.ts create mode 100644 SimpleCADAPI/viewer/src/style.css create mode 100644 SimpleCADAPI/viewer/tsconfig.json create mode 100644 SimpleCADAPI/viewer/vite.config.ts create mode 100644 cad-agent-studio/src/app/api/tasks/[taskId]/exports/[format]/route.ts create mode 100644 cad-agent-studio/src/lib/robot-export.ts create mode 100644 designir-pipeline/contracts/assembly-designir-1.0.schema.json create mode 100644 designir-pipeline/scripts/designir_codec.py create mode 100644 designir-pipeline/scripts/robot_description_export.py create mode 100644 designir-pipeline/tests/test_assembly_designir.py create mode 100644 designir-pipeline/tests/test_robot_description_export.py create mode 100644 models/lcd25-reducer/semantic-assembly/README.md create mode 100644 models/lcd25-reducer/semantic-assembly/generated/lcd25-with-output-shaft.assembly-designir.json create mode 100644 models/lcd25-reducer/semantic-assembly/lcd25-harmonic-reducer.assembly-designir.json create mode 100644 models/lcd25-reducer/semantic-assembly/validate_assembly_design.py create mode 100644 models/lcd25-reducer/semantic-assembly/validation/add-output-shaft.json create mode 100644 models/lcd25-reducer/semantic-assembly/validation/base.json create mode 100644 models/lcd25-reducer/semantic-assembly/variants/add-output-shaft.patch.json diff --git a/README.md b/README.md index 0ae9bed..36b846b 100644 --- a/README.md +++ b/README.md @@ -12,10 +12,16 @@ CadSet 是一个面向机械零件的 STEP-first 参数化 CAD 系统。它覆 2. 根据文字和参考图片生成或修改可编辑 CAD。 3. 将上传的 STEP 重建为 DesignIR,独立生成新的 STEP,并继续参数化修改。 -CAD Router 会在两个执行后端之间选择: +CAD Router 不再设置永久默认后端,而是根据已有源、标准件覆盖、语义 +重放需求、几何词汇、装配关系和验收要求在两个平权建模后端之间选择: -- **text-to-cad / Build123d**:通用机械零件、特征建模、几何验证和 STEP 输出。 -- **SimpleCADAPI**:可重放操作图、语义化机械结构和专用机械零件能力。 +- **Build123d**:定制机械零件、复杂轮廓与曲面、源级装配和 STEP 输出。 +- **SimpleCADAPI 2.0.2**:标准机械工厂、ModelResult 可重放图、语义拓扑、 + 单位/公差、场景包、CAD 翻译和严格 B-Rep 对比。 + +`text-to-cad` 是 DesignIR、路由、修复、验收和交付编排层,不再因为它 +承载 Build123d Skill 而给 Build123d 额外默认分。上传 STEP 由 SurfaceIR +独立重建,不伪装成任一文字生成后端。 ## 系统架构 @@ -25,12 +31,12 @@ CAD Router 会在两个执行后端之间选择: └─────────┬──────────┘ │ ┌─────────▼──────────┐ - │ CAD Router │ + │ CAD Router/DesignIR │ └──────┬───────┬─────┘ │ │ - ┌───────────▼─┐ ┌─▼──────────────┐ - │ Build123d │ │ SimpleCADAPI │ - └───────────┬─┘ └─┬──────────────┘ + ┌───────────▼─┐ ┌─▼────────────────┐ + │ Build123d │ │ SimpleCADAPI 2.0.2│ + └───────────┬─┘ └─┬────────────────┘ └────┬────┘ │ DesignIR 3.0 + STEP @@ -50,6 +56,7 @@ Teacher STEP -> isolated compiler without Teacher STEP access -> rebuilt STEP -> independent Acceptance Agent + -> SimpleCADAPI strict B-Rep comparison -> deterministic experience promotion ``` @@ -60,7 +67,7 @@ Teacher STEP | [`cad-agent-studio/`](cad-agent-studio/README.md) | 独立 Next.js 前端,负责对话、图片与 STEP 上传、任务管理和模型预览。 | | [`designir-pipeline/`](designir-pipeline/README.md) | DesignIR Schema、双 Agent、隔离编译、验收和经验晋升。 | | [`text-to-cad/`](text-to-cad/README.md) | CAD Router、Build123d CAD Skill、CAD Viewer 和共享 CAD 运行时。 | -| [`SimpleCADAPI/`](SimpleCADAPI/README.md) | 第二建模后端,提供可重放操作图和机械建模 API。 | +| [`SimpleCADAPI/`](SimpleCADAPI/README.md) | `2.0.2` 平权建模与验收后端,提供标准件、可重放图、语义拓扑、单位/公差、场景和 B-Rep 工具。 | | [`llm.config.yaml`](llm.config.yaml) | 前端共享的模型供应商、模型和密钥配置。 | 依赖目录、虚拟环境、构建缓存和批量运行数据不会进入 Git。锁文件、源码、Schema、Agent 定义和确定性测试会进入版本控制。 diff --git a/SIMPLECADAPI_2.0.2_INTEGRATION.md b/SIMPLECADAPI_2.0.2_INTEGRATION.md new file mode 100644 index 0000000..d554af8 --- /dev/null +++ b/SIMPLECADAPI_2.0.2_INTEGRATION.md @@ -0,0 +1,186 @@ +# SimpleCADAPI 2.0.2 融合说明 + +## 融合结果 + +CadSet 已将内置 SimpleCADAPI 从 `2.0.1b1` 对齐到 `2.0.2`,并完成运行 +环境、DesignIR 编译器、CAD Router、上传 STEP 验收、Skill、测试和文档 +的同步接入。 + +这次不是简单复制依赖目录。新增能力已经进入实际执行链: + +| 新能力 | CadSet 使用位置 | 当前状态 | +|---|---|---| +| `@model` / `ModelResult` | DesignIR SimpleCAD 编译器 | 已使用 | +| 语义标签与拓扑来源 | 每个 DesignIR 特征和布尔工具 | 已使用 | +| STEP B-Rep 严格比较 | 上传 STEP 独立验收区 | 已使用 | +| 螺栓、螺母、链轮、锥齿轮工厂 | cad-router 标准件识别 | 已纳入路由 | +| 单位与公差链 | cad-router 需求识别 | 已纳入路由,待扩展 DesignIR 映射 | +| Scene Schema / `.scene.zip` | SimpleCADAPI 产物能力 | 源码已具备,尚未替换现有 Viewer | +| Fusion 360 / SolidWorks 翻译 | SimpleCADAPI 派生导出能力 | 源码已具备,尚未开放前端按钮 | +| B-Rep 多视图与截面 XOR | 诊断工具 | 源码已具备,严格 compare 已接入 | + +CAD Agent Studio 已同步读取新的路由结果,不再在路由字段缺失时静默 +假定 Build123d。上传 STEP 的任务清单会保存 `routing_policy` 和 +`strict_brep_acceptance` 报告,并在聊天结果中区分 `brep_exact` 与仅几何 +通过的情况。 + +## 新的角色边界 + +### text-to-cad + +继续是系统核心,但核心含义变为“编排层”,不是“默认建模后端”: + +- CAD Router +- DesignIR 3.0 所有权 +- SurfaceIR 上传 STEP 重建 +- 需求细化和视觉修复 +- 验收、产物和 Viewer 交付 + +### Build123d + +作为平权建模后端,适合: + +- 定制支架、壳体、轴、法兰和夹具 +- 复杂草图、自由曲面、放样和扫掠 +- 已有 build123d 源码的连续修改 +- 源级装配与基准关系 + +### SimpleCADAPI 2.0.2 + +提升为平权建模与验收后端,适合: + +- 齿轮、齿条、内齿圈、锥齿轮和摆线件 +- 轴承、螺栓、螺母和滚子链轮 +- 减速器、执行器和带运动约束的机构 +- 需要 Model JSON 重放、语义标签和拓扑来源的模型 +- 需要单位、公差链或 FreeCAD/Fusion 360/SolidWorks 翻译的模型 +- 上传 STEP 的严格 B-Rep 几何与拓扑验收 + +## CAD Router 2.0 + +路由取消永久默认后端。两个后端从相同基础分开始,按以下证据评分: + +1. 已有可编辑源属于哪个后端。 +2. 是否存在直接覆盖的标准机械工厂。 +3. 是否要求模型图重放、语义拓扑、单位、公差或 CAD 翻译。 +4. 是否需要复杂自定义轮廓或自由曲面。 +5. 是否需要装配和运动约束。 +6. 是否需要特定验收能力。 + +主要决策示例: + +| 请求 | 结果 | 原因 | +|---|---|---| +| 模数 2 直齿轮 | SimpleCADAPI | 有确定性标准工厂 | +| M8 螺栓 | SimpleCADAPI | 有紧固件工厂 | +| 滚子链轮 | SimpleCADAPI | 有链轮工厂 | +| 带语义标签和公差链的法兰 | SimpleCADAPI | 语义、单位和公差是主要要求 | +| 普通定制法兰 | Build123d,低置信度 | 两者都能表达,Build123d 的通用特征略优 | +| NURBS 自由曲面壳体 | Build123d | 定制曲面词汇更适合通用后端 | +| 修改已有 SimpleCAD Model JSON | SimpleCADAPI | 保持原生编辑源 | +| 上传 STEP 重建 | SurfaceIR | 重建引擎固定;后端评分只用于后续语义修改 | + +路由输出新增: + +- `routing_policy.version` +- `routing_policy.requirements` +- `routing_policy.native_source_backend` +- `routing_policy.score_margin` +- `routing_policy.effective_execution_engine` +- `routing_policy.validation_engines` + +上传 STEP 时,`selected_backend` 表示后续语义修改的优选后端, +`effective_execution_engine` 明确为 `surfaceir`,避免把 SurfaceIR 重建 +错误描述为 Build123d 或 SimpleCADAPI 重建。 + +## DesignIR → SimpleCADAPI + +SimpleCAD 编译器现在使用: + +```text +DesignIR feature + -> SimpleCAD primitive/feature + -> feature. topology tag + -> boolean lineage + -> feature..result semantic tag + -> capture_result + -> ModelResult.model_json + -> STEP +``` + +每个基础体、孔工具和最终特征结果都会进入模型图。这样 Model JSON 不再 +只是低层操作列表,还可以根据特征 ID 定位语义对象,为后续参数修改和 +非目标区域保持检查提供锚点。 + +## 上传 STEP 验收 + +上传 STEP 仍严格执行教师隔离: + +```text +Teacher STEP + -> 私有证据提取 + -> DesignIR 3.0 / SurfaceIR + -> 无教师文件的独立重建 + -> SurfaceIR 几何验收 + -> SimpleCADAPI B-Rep 硬验收 +``` + +SimpleCADAPI 比较器在验收区同时读取教师 STEP 和候选 STEP,检查: + +- `target - candidate` 差集体积 +- `candidate - target` 差集体积 +- 几何点集是否一致 +- 带几何标签的面/边/点邻接图是否同构 + +新增质量等级: + +| 等级 | 含义 | +|---|---| +| `brep_exact` | 几何集合一致且拓扑邻接图同构 | +| `geometry_equal_topology_differs` | 几何通过,但拓扑结构不同,需要复查 | +| `geometry_accepted_brep_unavailable` | 原验收通过,但补充比较器不可用 | +| `review_required` | 只达到可用重建或存在其他验收问题 | + +严格比较器不可用时会记录原因,不会伪造成通过;拓扑不一致时模型仍可供 +查看,但任务状态为 `review_required`。 + +## 依赖更新 + +两个本地 Python 环境均已安装 SimpleCADAPI `2.0.2` 的新增运行依赖: + +- `jsonschema` +- `rfc8785` +- `typing-extensions` + +仓库的 `SimpleCADAPI/uv.lock` 已随上游版本同步。虚拟环境仍由 `.gitignore` +排除,不会提交。 + +## 已完成验证 + +本次融合已通过: + +- cad-router:24 项测试 +- DesignIR 编译器:11 项测试 +- 上传 STEP 完整重建与 B-Rep 硬验收:1 项测试 +- SimpleCADAPI 完整回归:934 项通过,3 项按可选环境条件跳过 +- CAD Agent Studio:8 项测试,生产构建通过 + +完整回归包含: + +- `@model` / `ModelResult` +- 单位 +- 公差链 +- B-Rep 逆向检查 +- 螺栓和螺母 +- 滚子链轮 + +## 后续扩展边界 + +本次没有直接把所有新能力暴露到网页: + +- `.scene.zip` 与现有 CAD Viewer 存在功能重叠,需要先确定统一场景合同。 +- Fusion 360 和 SolidWorks 翻译必须在对应软件或命令环境中做真实执行验收。 +- 单位和公差已进入路由,但 DesignIR schema 仍需增加完整公差图映射。 +- B-Rep 截面 XOR 适合失败诊断,不应默认对每个大模型高密度运行。 + +这些能力已经保留在源码和 Skill 中,后续接入不需要再次升级依赖。 diff --git a/SIMPLECADAPI_2.0.2_UPDATE.md b/SIMPLECADAPI_2.0.2_UPDATE.md new file mode 100644 index 0000000..d8391d4 --- /dev/null +++ b/SIMPLECADAPI_2.0.2_UPDATE.md @@ -0,0 +1,549 @@ +# SimpleCADAPI 2.0.2 更新对比与 CadSet 集成建议 + +> 对比日期:2026-07-28 +> 当前集成版本:`/Users/jerry/linkhand/CadSet/SimpleCADAPI`,`2.0.1b1` +> 待评估版本:`/Users/jerry/Downloads/SimpleCADAPI-master`,`2.0.2` + +## 1. 结论摘要 + +SimpleCADAPI `2.0.2` 相比 CadSet 当前集成的 `2.0.1b1` 是一次明显的增量升级,不是接口重写。 + +本次静态对比得到: + +| 指标 | 结果 | +|---|---:| +| 当前版本 | `2.0.1b1` | +| 新版本 | `2.0.2` | +| 当前文件数 | 555 | +| 新版文件数 | 767 | +| 内容相同 | 351 | +| 新增文件 | 240 | +| 删除文件 | 28 | +| 修改文件 | 176 | +| 当前顶层公开导出 | 214 | +| 新版顶层公开导出 | 303 | +| 新增顶层公开导出 | 89 | +| 删除顶层公开导出 | 0 | +| Python 测试文件 | 34 → 51 | +| Model JSON schema | 仍为 `2.0` | + +文件统计排除了 `.git`、虚拟环境、缓存、构建输出和 `node_modules` 等非源码内容。由于下载目录没有上游 Git 历史或正式 CHANGELOG,本报告基于两个目录的文件内容、公开导出、函数签名、测试、文档和依赖配置进行对比。 + +对 CadSet 的总体判断: + +- 可以升级,现有调用大概率不会因公开 API 删除而直接失效。 +- 不建议只覆盖 `SimpleCADAPI/` 后结束;这样只能获得兼容性更新,无法充分利用新增能力。 +- 对我们最有价值的是新版的语义标签、拓扑跟踪、单位/公差、模型入口以及 STEP B-Rep 比较工具。 +- 新版 B-Rep 工具适合增强 DesignIR 重建验收,但它不是 STEP 参数化历史恢复器,也不能单独替代 SurfaceIR。 +- 建议先在升级分支完成回归测试,再将新增能力分阶段接入 `designir-pipeline`、`cad-router` 和前端查看器。 + +## 2. 新增内容 + +### 2.1 统一的可重放模型入口 + +新版增加: + +- `@model` +- `ModelResult` +- `capture_result(...)` +- `requires_session` +- `get_active_session` + +新版推荐使用一个 `@model` 入口管理 `GraphSession`,模型函数执行后返回 `ModelResult`。结果对象可直接提供: + +- 模型图 JSON +- 重放结果 +- 场景包路径 +- 会话和结果节点 + +这比我们目前在 `designir_pipeline.py` 中手动创建 `GraphSession`、手动导出 JSON 更完整,也更容易建立统一的生成合同。 + +对 CadSet 的价值: + +- DesignIR 编译结果可统一包装为一个可重放模型。 +- 生成、重放、导出和验收可以绑定同一个模型标识。 +- 以后更容易追踪“哪个参数、哪个操作生成了哪个结果”。 + +### 2.2 物理单位和公差链 + +新版新增完整的单位系统: + +- 长度:毫米、厘米、米、英寸、英尺 +- 面积和体积单位 +- 角度:度、弧度 +- 百分比和无量纲单位 +- 单位换算、维度推导和表达式单位检查 + +主要公开 API 包括: + +- `Unit`、`Dimension`、`UnitLike` +- `get_unit(...)` +- `convert_value(...)` +- `infer_dimension(...)` +- `canonical_unit_for_dimension(...)` +- `expression_uses_units(...)` + +同时新增公差分析: + +- `DimensionTolerance` +- `ToleranceGraph` +- `ToleranceRequirement` +- `ToleranceAnalysis` +- `ToleranceReport` +- `analyze_tolerance(...)` +- `check_tolerance(...)` + +对 CadSet 的价值: + +- DesignIR 参数可以显式区分长度、角度、数量和无量纲值。 +- 上传 STEP 推断出的毫米尺寸与文本中出现的英寸尺寸可以在进入后端前统一换算。 +- 参数修改可以提前阻止长度与角度相加等无效表达式。 +- 可以把制造公差和参数允许范围纳入修改验收。 + +需要注意:新版默认以毫米和度计算,但保留变量声明时的单位。旧式无单位变量仍被支持,不过不能与带单位变量任意混合。 + +### 2.3 语义标签、来源映射和拓扑跟踪增强 + +新增或扩展的能力包括: + +- `apply_tag_rselection(...)` +- `explain_tag(...)` +- 带 `scope` 的 `list_tags(...)` +- `TrackingPolicy` +- `LineagePolicy` +- `TopologyPropagation` +- `TagBinding`、`TagTarget`、`TagEvidence` +- `TagProducer`、`TagPropagation`、`TagLineageWitness` +- `source_binding`、`source_topology`、`output_role`、`solids` 等 QL 查询 + +基础体和特征操作现在可以直接声明角色标签。例如盒体可分别标记顶面、底面、前后左右面;圆柱可标记起始面、结束面、侧面、圆边和接缝边。拉伸、扫掠、圆角、倒角、布尔运算也增加了结果角色和跟踪策略。 + +对 CadSet 的价值非常直接: + +- DesignIR 中的 `feature_id` 可以映射为 SimpleCADAPI 的语义 tag。 +- 参数的 `affected_face_ids` 不必只依赖庞大的原始面 ID 列表,可逐步转成“特征角色 + 稳定选择条件”。 +- 修改孔径、板厚或法兰间距后,可以通过标签验证目标特征是否变化、非目标特征是否保持。 +- 对布尔运算后的拓扑命名问题,新版提供了更明确的来源证据和传播策略。 + +这部分是最值得优先接入 DesignIR 3.0 的更新。 + +### 2.4 Scene Schema 1.0 与 `.scene.zip` + +新版新增完整的场景编译与归档子系统: + +- `SceneRoot` +- `SceneSource` +- `SceneCompileOptions` +- `CompiledScenePackage` +- `compile_scene(...)` +- `export_scene(...)` +- GLB 三角面和边线生成 +- 场景清单、规范化 JSON、哈希和资源校验 +- 浏览器端 Scene Viewer + +使用 `@model(export_dir=...)` 时可以生成一个自包含的 `.scene.zip`。包内包含: + +- `scene.json` +- `model/model.json` +- 操作来源映射涉及的 Python 文件 +- Viewer 所需的 GLB、实体和选择资源 + +新版同时加入: + +- `scene-contract/`:独立 TypeScript 场景合同、规范化、校验和 ZIP 工具 +- `viewer/`:Vite/TypeScript 浏览器查看器 + +对 CadSet 的价值: + +- 可把“可视化几何 + 模型图 + 源码映射 + 选择信息”打成一个可验证产物。 +- CAD Agent Studio 目前分别管理 STEP、GLB 和 DesignIR;后续可以评估把 `.scene.zip` 作为 SimpleCADAPI 模型的附加交付物。 +- 场景包适合预览、审计和语义选择,但不能替代 STEP、URDF 或 MJCF。 + +### 2.5 STEP B-Rep 逆向检查和比较 + +新版新增 `simplecadapi.inverse_engineer.brep` 及命令: + +```text +simplecad-brep inspect +simplecad-brep compare +simplecad-brep render +simplecad-brep slices +``` + +主要能力: + +- 检查 STEP 的实体、壳、面、边、曲面和曲线类型。 +- 输出结构化 B-Rep 报告。 +- 对目标 STEP 与候选 STEP 做双向几何和拓扑检查。 +- 渲染一致的正交视图和等轴测视图。 +- 对指定物理截面采样并生成 XOR 差异图。 + +其中 `render` 和截面图片依赖可选的 `matplotlib`。 + +对 CadSet 的价值: + +- 可作为上传 STEP 重建前的几何证据提取补充。 +- 可作为验收 Agent 的第二套独立检查器。 +- 截面 XOR 对内部孔、腔体和薄壁差异尤其有帮助。 +- 可为失败零件生成更易分析的结构化报告。 + +边界说明: + +- 该模块明确是检查、比较和诊断工具。 +- 它不会从 STEP 恢复原始草图、约束和真实建模历史。 +- 它不能证明两个文件拥有相同的建模意图。 +- 是否达到我们的“1:1”标准,仍需结合体积、质心、包围盒、双向表面距离、对称差体积、关键截面和参数修改测试。 + +### 2.6 新增 CAD 翻译后端 + +原有 FreeCAD 翻译器被重构为公共的翻译器后端合同,并新增: + +- Fusion 360 translator +- SolidWorks translator +- 共享的 translator base、types 和 errors +- 各后端 capability 描述 + +对 CadSet 的建议: + +- 初期将其视为可选派生产物,不要把它们作为 DesignIR 的权威重建后端。 +- 先用一组参数化零件验证生成脚本在目标软件中的可执行性、拓扑一致性和参数可编辑性。 +- FreeCAD 原有流程也需要回归,因为内部实现已经拆分重构。 + +### 2.7 标准件和机械零件库扩展 + +新增标准库模块: + +- `std/fastener.py` +- `std/chain.py` + +新增或正式公开的工厂能力: + +- `make_bolt_rsolid` +- `make_nut_rsolid` +- `make_roller_chain_sprocket_rsolid` +- `make_straight_bevel_gear_rsolid` + +现有轴承和齿轮能力仍然保留。 + +对 CadSet 的价值: + +- `cad-router` 可将螺栓、螺母、滚子链轮和直齿锥齿轮优先路由到 SimpleCADAPI。 +- DesignIR 可用语义化标准件特征代替大量低层几何操作。 +- 对标准件修改,参数命名和约束可以更稳定。 + +### 2.8 新增几何操作 + +新增公开操作: + +- `twisted_sweep_rsolid` + +适用于带扭转角的扫掠实体。它拓展了现有拉伸、旋转、放样和普通扫掠的表达范围,但仍需在我们的后端能力表中明确其限制并增加测试。 + +## 3. 修改内容 + +### 3.1 现有函数以增量参数为主 + +抽查 CadSet 当前使用的核心函数,新版没有删除原有必需参数,主要增加可选的关键字参数: + +| 函数 | 主要变化 | +|---|---| +| `make_box_rsolid` | 新增结果和六个方向面的标签参数 | +| `make_cylinder_rsolid` | 新增结果、端面、侧面、圆边和接缝标签参数 | +| `union_rsolid` | 新增 `tracking_policy` | +| `cut_rsolid` | 新增 `tracking_policy` | +| `export_model_json` | 新增 `result_node_ids` | +| `list_tags` | 新增 `scope` | +| 拉伸/扫掠/圆角/倒角 | 新增输出角色、结果标签或跟踪相关参数 | + +因此现有代码不是立即失效。普通命名参数建议改为关键字调用,避免参数顺序变化;`union_rsolid` 和 `cut_rsolid` 的实体输入仍是 `*solids` 可变位置参数,不能写成不存在的 `solids=`、`solid=` 或 `cutters=` 关键字。 + +CadSet 当前存在的典型调用: + +```python +scad.union_rsolid([shape, tool], clean=True) +scad.cut_rsolid(shape, cutters, skip_non_intersecting=False) +``` + +建议将布尔实体作为明确的位置参数传入,并将控制项保留为关键字参数: + +```python +scad.union_rsolid(shape, tool, clean=True) +scad.cut_rsolid( + shape, + *cutters, + skip_non_intersecting=False, +) +``` + +新版也继续兼容嵌套序列,因此当前的 `scad.union_rsolid([shape, tool], clean=True)` 和 `scad.cut_rsolid(shape, cutters, ...)` 仍符合源码合同。新代码采用展平的位置参数只是为了表达更清晰。 + +### 3.2 Skill 推荐工作流发生变化 + +当前 Skill 主要指导 Agent 手动管理 `GraphSession` 和导出函数;新版 Skill 将以下方式设为标准: + +1. 一个 `@model` 模型入口。 +2. 子构建器使用 `@requires_session`。 +3. 使用 `capture_result(...)` 明确最终结果。 +4. 通过 `ModelResult` 获取模型 JSON、重放结果和场景产物。 +5. 使用标签解释和来源映射,而不是只依赖面/边遍历顺序。 + +如果升级源码但不更新 Skill,Agent 仍会继续按旧范式生成代码,新增能力无法被稳定利用。 + +### 3.3 FreeCAD 翻译器内部重构 + +FreeCAD 翻译器从较集中的实现拆分为: + +- API +- capability 分析 +- code generation +- context +- exporter +- script translator +- backend translator + +公开用途仍然是从 Model JSON 生成 FreeCAD Python 或 `.FCStd`,但内部重构意味着必须重新跑翻译器测试,不能只依据顶层 API 未删除就判断行为完全一致。 + +### 3.4 示例与文档重组 + +新版增加了单位公差链示例,同时删除或移出了多个旧的独立示例和复杂项目示例。保留并修改了部分大型减速器和 BLDC 执行器示例。 + +这是仓库内容整理,不代表对应的公开建模 API 被删除。升级时不应以覆盖目录的方式误删我们可能引用的旧示例;需要先确认这些示例是否被 CadSet 文档或测试使用。 + +## 4. 依赖和工具链变化 + +运行时新增: + +```toml +jsonschema = ">=4.25.1,<5.0.0" +rfc8785 = ">=0.1.4,<0.2.0" +typing-extensions = ">=4.12,<5" +``` + +可选依赖新增: + +```toml +inverse-engineer = ["matplotlib>=3.7.0"] +``` + +开发依赖新增: + +```toml +datamodel-code-generator = "==0.32.0" +``` + +CLI 新增: + +```text +simplecad-brep +``` + +这些依赖分别服务于场景 schema 校验、RFC 8785 规范化 JSON、类型兼容、B-Rep 可视化和 schema 模型生成。 + +升级后必须同步锁文件和部署环境。否则源码替换后可能在导入场景模块时出现缺包,而不是在安装阶段明确失败。 + +## 5. CadSet 当前实际使用情况 + +CadSet 目前对 SimpleCADAPI 的核心集成位于: + +```text +designir-pipeline/scripts/designir_pipeline.py +``` + +当前适配器主要使用: + +- `GraphSession` +- `make_box_rsolid` +- `make_cylinder_rsolid` +- `union_rsolid` +- `cut_rsolid` +- `export_model_json` +- `export_step` + +路由和前端相关位置包括: + +```text +text-to-cad/skills/cad-router/ +text-to-cad/viewer/ +cad-agent-studio/ +``` + +当前尚未接入的 `2.0.2` 新能力: + +- `@model` 和 `ModelResult` +- 单位与公差链 +- 丰富的角色标签和来源解释 +- Scene Schema 1.0 与 `.scene.zip` +- STEP B-Rep 检查、比较和截面 XOR +- Fusion 360、SolidWorks 翻译器 +- 新的紧固件、链轮和锥齿轮工厂 + +因此,当前项目不会因为把下载目录放在旁边就自动获得这些能力。 + +## 6. 对 DesignIR 3.0 和上传 STEP 重建的意义 + +### 6.1 可以直接帮助的部分 + +1. **重建前证据提取** + - 用 `simplecad-brep inspect` 输出面、边、曲面、曲线和拓扑摘要。 + - 将摘要作为 DesignIR 特征推断的证据之一。 + +2. **独立验收** + - 用 `compare` 检查候选与源 STEP 的双向几何和拓扑。 + - 用 `slices` 比较关键截面并输出 XOR。 + - 与现有 SurfaceIR 验收指标交叉验证。 + +3. **稳定参数修改** + - 把 DesignIR feature ID 映射为语义标签。 + - 用来源映射和拓扑跟踪验证修改影响范围。 + - 用单位和公差阻止不合法参数修改。 + +4. **可审计产物** + - 对 SimpleCADAPI 生成结果附带 Model JSON 或 `.scene.zip`。 + - 让查看器、验收器和后续 Agent 使用同一份模型语义。 + +### 6.2 不能直接解决的部分 + +- STEP 本身没有原始草图和建模历史,新版也不会凭空恢复真实历史。 +- B-Rep inspection 只能提供几何与拓扑证据,特征树仍然是推断结果。 +- `.scene.zip` 是 SimpleCADAPI 自有模型的场景包,不是把任意 STEP 自动转成参数化模型的万能格式。 +- 语义标签只能在我们生成或映射标签后生效,不能自动让所有历史 STEP 拥有可靠参数名。 +- 自由曲面、复杂过渡面和多实体装配的独立参数化重建仍需 DesignIR/SurfaceIR 扩展。 + +## 7. 兼容性与风险 + +| 风险项 | 级别 | 说明 | +|---|---|---| +| 顶层 API 删除 | 低 | 静态对比未发现删除的顶层公开导出 | +| 函数行为变化 | 中 | 特征、跟踪、标签和翻译器内部有明显修改 | +| 依赖缺失 | 中 | 新增三个运行时依赖和一个可选可视化依赖 | +| Model JSON 兼容 | 低到中 | schema 仍为 2.0,但节点负载和语义证据可能增加 | +| FreeCAD 翻译回归 | 中 | 后端内部大幅拆分,并新增统一 translator contract | +| Viewer 重复建设 | 中 | 新 Scene Viewer 与 CadSet 现有 Viewer 功能有重叠 | +| 上传 STEP 能力被高估 | 高 | B-Rep 工具不是自动参数化历史恢复工具 | +| 直接目录覆盖 | 高 | 可能覆盖本地改动、旧示例、Skill 和未提交文件 | + +## 8. 推荐升级方案 + +### 阶段 A:安全升级源码 + +1. 新建升级分支,不直接覆盖 `main`。 +2. 记录 CadSet 当前 `SimpleCADAPI/` 是否存在本地改动。 +3. 用 `2.0.2` 更新源码、文档、Skill 和依赖配置。 +4. 保留 CadSet 自己的适配层,不把项目逻辑写入上游目录。 +5. 同步 Python 锁文件和部署安装脚本。 +6. 运行 SimpleCADAPI 上游测试和 CadSet 集成测试。 + +### 阶段 B:保持功能等价 + +首先只做兼容迁移: + +- 将调用统一改为关键字参数。 +- 验证盒体、圆柱、布尔、导出 STEP 和 Model JSON。 +- 验证现有 DesignIR 示例在升级前后: + - 实体数一致 + - 包围盒一致 + - 体积误差在容差内 + - STEP 导出成功 + - Model JSON 可重放 + +这一阶段不改变路由决策,也不宣称重建精度提高。 + +### 阶段 C:接入语义能力 + +建立 DesignIR → SimpleCADAPI 映射: + +```text +DesignIR feature.id + → SimpleCAD tag + → operation output role + → topology/source evidence + → edit validation contract +``` + +优先覆盖: + +- base/body +- bore/hole +- boss +- pocket/slot +- fillet/chamfer +- linear/polar pattern +- mounting face +- datum axis/plane + +修改后验证目标 tag 的几何变化,并检查非目标 tag 是否保持。 + +### 阶段 D:接入上传 STEP 验收 + +建议将新工具接入验收 Agent,而不是直接让重建 Agent把报告当成答案: + +```text +源 STEP + ├─ SurfaceIR/DesignIR 现有证据 + └─ SimpleCAD B-Rep inspection + ↓ + 重建候选 STEP + ↓ + ┌──────────┴──────────┐ + │ 现有几何验收 │ + │ SimpleCAD compare │ + │ 关键截面 XOR │ + └──────────┬──────────┘ + ↓ + 统一验收报告 +``` + +两个检查器得出冲突结论时不自动晋升经验,应进入人工审查或失败聚类。 + +### 阶段 E:扩展路由和交付物 + +回归稳定后再做: + +- 路由新增 bolt、nut、sprocket、roller chain、bevel gear 关键词。 +- 对 SimpleCADAPI 模型可选生成 `.scene.zip`。 +- 评估 CAD Agent Studio 是否复用 Scene Contract,而不是立刻替换现有 Viewer。 +- 将 Fusion 360、SolidWorks 脚本作为可选下载项。 + +## 9. 建议的验收矩阵 + +### 9.1 基础回归 + +| 类型 | 用例 | +|---|---| +| 基础体 | box、cylinder | +| 特征 | extrude、revolve、loft、sweep、twisted sweep | +| 布尔 | union、cut、intersect | +| 修饰 | fillet、chamfer、shell | +| 阵列 | linear、polar | +| 导出 | STEP、STL、Model JSON | +| 重放 | Model JSON replay | + +### 9.2 CadSet 集成回归 + +- `designir-pipeline` 的 SimpleCADAPI 后端测试全部通过。 +- `cad-router` 的后端选择测试全部通过。 +- 文本生成、图文生成和 DesignIR 参数修改各跑一组真实用例。 +- 升级前后同一 DesignIR 输出做几何差异比较。 +- 前端仍可加载 STEP/GLB,下载链路不受影响。 + +### 9.3 新能力验收 + +- 单位混合与非法维度表达式测试。 +- 公差链通过与失败测试。 +- 标签在布尔、圆角和倒角后的追踪测试。 +- `.scene.zip` schema、哈希、资源集合和 Viewer 加载测试。 +- `simplecad-brep inspect/compare/render/slices` CLI 测试。 +- FreeCAD、Fusion 360、SolidWorks 翻译能力边界测试。 +- 新标准件尺寸、轴线、体积和参数修改测试。 + +## 10. 最终建议 + +建议升级到 `2.0.2`,但采用“先兼容、再利用”的方式: + +1. **先完成源码和依赖升级,证明现有 CadSet 功能没有回退。** +2. **优先接入标签、来源映射和拓扑跟踪,增强参数修改的可解释性。** +3. **把 B-Rep inspection、compare 和 slices 接入上传 STEP 的独立验收。** +4. **再接入单位、公差、新标准件和场景包。** +5. **Fusion 360、SolidWorks 和新 Viewer 放在后续可选阶段。** + +对我们的核心目标“上传 STEP → 自有 DesignIR → 独立重建 → 参数修改 → 自动验收”而言,`2.0.2` 最重要的贡献不是让 STEP 自动恢复建模历史,而是提供了更好的语义记录、拓扑来源、模型重放和独立几何诊断基础。正确接入后,它能提升重建与修改闭环的可验证性;是否真正提高 1:1 重建率,仍必须通过同一批真实 STEP 的升级前后基准测试来证明。 diff --git a/SimpleCADAPI/.gitignore b/SimpleCADAPI/.gitignore index c692092..bbb2afa 100644 --- a/SimpleCADAPI/.gitignore +++ b/SimpleCADAPI/.gitignore @@ -15,6 +15,7 @@ output/ # Sandbox artifacts sandbox/ +design-doc/ # Generated skills archives skills/*.tar.gz @@ -25,3 +26,8 @@ skills/*.tar.gz *.step *.stl examples.py + +# Browser viewer dependencies and build output +viewer/node_modules/ +viewer/dist/ +viewer/pnpm-lock.yaml diff --git a/SimpleCADAPI/AGENT.md b/SimpleCADAPI/AGENT.md new file mode 100644 index 0000000..c707112 --- /dev/null +++ b/SimpleCADAPI/AGENT.md @@ -0,0 +1,31 @@ +# First of all, you should follow the phylosophy below whatever you are going to do: + +## Completeness Principle — Boil the Lake +AI-assisted coding makes the macrginal cost of completeness near-zero. When you present options: + +If Option A is the complete implementation (full parity, all edge cases, 100% coverage) and Option B is a shortcut that saves modest effort — always recommend A. The delta between 80 lines and 150 lines is meaningless with Codex. "Good enough" is the wrong instinct when "complete" costs minutes more. +Lake vs. ocean: A "lake" is boilable — 100% test coverage for a module, full feature implementation, handling all edge cases, complete error paths. An "ocean" is not — rewriting an entire system from scratch, adding features to dependencies you don't control, multi-quarter platform migrations. Recommend boiling lakes. Flag oceans as out of scope. +When estimating effort, always show both scales: human team time and Codex time. The compression ratio varies by task type — use this reference: +Task type Human team Codex Compression +Boilerplate / scaffolding 2 days 15 min ~100x +Test writing 1 day 15 min ~50x +Feature implementation 1 week 30 min ~30x +Bug fix + regression test 4 hours 15 min ~20x +Architecture / design 2 days 4 hours ~5x +Research / exploration 1 day 3 hours ~3x +This principle applies to test coverage, error handling, documentation, edge cases, and feature completeness. Don't skip the last 10% to "save time" — with AI, that 10% costs seconds. +Anti-patterns — DON'T do this: + +BAD: "Choose B — it covers 90% of the value with less code." (If A is only 70 lines more, choose A.) +BAD: "We can skip edge case handling to save time." (Edge case handling costs minutes with Codex.) +BAD: "Let's defer test coverage to a follow-up PR." (Tests are the cheapest lake to boil.) +BAD: Quoting only human-team effort: "This would take 2 weeks." (Say: "2 weeks human / ~1 hour Codex.") + + +# Second: Only do necessary action, rather than do much is better. + +# Third: Never use complex English Terms in chinese, you should say normal chinese. + +# Before editing or adding code, plan for module splitting rather than putting code in one file which is not human readable. + +# If you have any experience during working, you can write it down here, with detailed reasons. diff --git a/SimpleCADAPI/MANIFEST.in b/SimpleCADAPI/MANIFEST.in index 17489f7..9e054f2 100644 --- a/SimpleCADAPI/MANIFEST.in +++ b/SimpleCADAPI/MANIFEST.in @@ -8,6 +8,7 @@ recursive-include docs *.md # 包含源码 recursive-include src *.py +recursive-include src/simplecadapi/scene/contracts *.json *.md include src/simplecadapi/py.typed recursive-include src/auto_tools *.py diff --git a/SimpleCADAPI/README.md b/SimpleCADAPI/README.md index 573ba6d..bf1333e 100644 --- a/SimpleCADAPI/README.md +++ b/SimpleCADAPI/README.md @@ -25,20 +25,22 @@ in a compact public API for creating solids, applying features, tagging semantic intent, querying topology, exporting manufacturing files, and translating recorded models into FreeCAD workflows. -Current beta: `simplecadapi==2.0.1b1`. +Current release: `simplecadapi==2.0.2`. ## What It Provides - OCP-native shape types: `Vertex`, `Edge`, `Wire`, `Face`, and `Solid`. - Functional modeling operations for primitives, profiles, extrude, revolve, loft, sweep, booleans, transforms, patterns, fillets, chamfers, and shells. -- Replayable modeling with `GraphSession`, `export_model_json(...)`, +- Replayable modeling with `@model`, `ModelResult`, `capture_result(...)`, `import_model_json(...)`, and `replay_model_json(...)`. - Expression parameters with `var(...)`, arithmetic expressions, and serialized expression graphs. +- Physical units with automatic dimension inference, canonical CAD conversion, + and manufacturing tolerance-chain validation. - QL selectors for geometry grounding, topology queries, and stable feature selections. -- Semantic tags through `apply_tag(shape, tag)` and `list_tags(shape)`. +- Semantic tags through `apply_tag(shape=..., tag=...)` and `list_tags(shape=...)`. - STEP/STL export and FreeCAD translation helpers for script or `.FCStd` output. ## Install @@ -69,53 +71,113 @@ import simplecadapi as scad out = Path("out") out.mkdir(exist_ok=True) -base = scad.make_box_rsolid(60.0, 36.0, 8.0, bottom_face_center=(0.0, 0.0, 0.0)) -hole = scad.make_cylinder_rsolid(5.0, 14.0, bottom_face_center=(0.0, 0.0, -3.0)) -slot = scad.make_box_rsolid(18.0, 8.0, 14.0, bottom_face_center=(14.0, 0.0, -3.0)) +base = scad.make_box_rsolid( + width=60.0, height=36.0, depth=8.0, bottom_face_center=(0.0, 0.0, 0.0) +) +hole = scad.make_cylinder_rsolid( + radius=5.0, height=14.0, bottom_face_center=(0.0, 0.0, -3.0) +) +slot = scad.make_box_rsolid( + width=18.0, height=8.0, depth=14.0, bottom_face_center=(14.0, 0.0, -3.0) +) part = scad.cut_rsolid(base, hole, slot) -boss = scad.make_cylinder_rsolid(8.0, 7.0, bottom_face_center=(-18.0, 0.0, 8.0)) +boss = scad.make_cylinder_rsolid( + radius=8.0, height=7.0, bottom_face_center=(-18.0, 0.0, 8.0) +) part = scad.union_rsolid(part, boss) -part = scad.apply_tag(part, "role.demo.bracket") +part = scad.apply_tag(shape=part, tag="role.demo.bracket") print("volume", round(part.get_volume(), 3)) print("faces", len(part.get_faces())) -print("tags", scad.list_tags(part)) +print("tags", scad.list_tags(shape=part)) -scad.export_step(part, str(out / "bracket.step")) -scad.export_stl(part, str(out / "bracket.stl")) +scad.export_step(shapes=part, filename=str(out / "bracket.step")) +scad.export_stl(shapes=part, filename=str(out / "bracket.stl")) ``` ## Replayable Modeling -Use `GraphSession` when a model should be inspectable, serializable, replayable, -or translated into another CAD environment. +Use one `@scad.model` entry point when a model should be inspectable, +serializable, replayable, or translated into another CAD environment. The +decorated function owns its `GraphSession` and returns a `ModelResult`. ```python import simplecadapi as scad from simplecadapi import ql as Q -with scad.GraphSession() as session: - body = scad.make_box_rsolid(40.0, 24.0, 10.0, bottom_face_center=(0.0, 0.0, 0.0)) - cutter = scad.make_cylinder_rsolid(4.0, 16.0, bottom_face_center=(0.0, 0.0, -3.0)) +@scad.model(graph_id="chamfered_block") +def build_model(): + body = scad.make_box_rsolid( + width=40.0, height=24.0, depth=10.0, + bottom_face_center=(0.0, 0.0, 0.0), + ) + cutter = scad.make_cylinder_rsolid( + radius=4.0, height=16.0, bottom_face_center=(0.0, 0.0, -3.0) + ) drilled = scad.cut_rsolid(body, cutter) bottom_circle = ( Q.edges() - .where(Q.curve_type("circle")) - .order_by(Q.center_axis("z")) + .where(Q.curve_type(kind="circle")) + .order_by(Q.center_axis(axis="z")) .take(1) .exactly(1) ) - final = scad.chamfer_rsolid(drilled, bottom_circle, 0.6) + final = scad.chamfer_rsolid(solid=drilled, edges=bottom_circle, distance=0.6) + scad.capture_result(value=final) + return final -model_json = scad.export_model_json(session) -rebuilt = scad.replay_model_json(model_json) +result = build_model() +model_json = result.model_json +rebuilt = result.replay() -print("recorded_nodes", session.graph.node_count) +print("recorded_nodes", result.session.graph.node_count) print("replayed_outputs", len(rebuilt)) ``` +Pass `export_dir=...` to `@scad.model` when the invocation should also write +one self-contained `.scene.zip`. The package contains `scene.json`, +`model/model.json`, the complete project-relative Python files referenced by +operation source mappings under `sources/`, and the GLB/entity assets required +by the Viewer. Automatic export does not write adjacent model/session JSON, +STEP, STL, or FCStd files; those explicit export APIs remain available. The +package path is `result.artifact_paths["scene"]`. Without `export_dir`, model +execution remains in memory. + +## Physical Units And Tolerances + +Declare nominal and manufacturing-tolerance units at the variable boundary. +SimpleCAD evaluates lengths in millimeters and angles in degrees while preserving +the declaration units in model JSON: + +```python +import simplecadapi as scad + +width = scad.var( + "width", + 1.0, + unit="in", + tolerance=0.1, + tolerance_unit="mm", +) +height = scad.var("height", 40.0, unit="mm", tolerance=0.2) +diagonal = scad.sqrt(width**2 + height**2) + +analysis = scad.analyze_tolerance(diagonal) +check = scad.check_tolerance(diagonal, 0.3, tolerance_unit="mm") + +print(analysis.dimension.name, analysis.unit.symbol) +print(analysis.nominal, analysis.lower_bound, analysis.upper_bound) +print("passes", check.passed) +``` + +Addition and subtraction require matching dimensions. Multiplication, division, +integer powers, and square root derive dimensions. Trigonometric functions require +angle or dimensionless inputs as appropriate. Legacy variables without `unit` +remain supported, but cannot be mixed with unit-declared variables in one +expression. + ## Modeling Mental Model - Start from design intent: reference axes, critical profiles, and the features @@ -162,15 +224,12 @@ Explicit compound projections remain available for geometry-only STEP export. Run examples from the source checkout: ```bash -uv run python examples/01_basic_modeling.py -uv run python examples/02_graph_replay.py -uv run python examples/03_expressions.py -uv run python examples/05_loft_sweep_revolve.py -uv run python examples/06_parametric_gear_model.py -uv run python examples/07_serialization_operation_tree.py +uv run python examples/04_dimension_tolerance_chain.py uv run python examples/08_constrained_sketch.py uv run python examples/09_naca0016_blade_freecad.py uv run python examples/10_part_assembly.py +uv run python examples/16_compact_two_stage_planetary_reducer/main.py +uv run python examples/20_integrated_bldc_joint_actuator/main.py ``` ## Documentation @@ -179,6 +238,10 @@ uv run python examples/10_part_assembly.py - Core type and modeling notes: [`docs/core/`](docs/core/) - Serialization and replay details: [`docs/core/serialization/README.md`](docs/core/serialization/README.md) +- Dimension tolerance chains: + [`docs/core/dimension-tolerance-chains.md`](docs/core/dimension-tolerance-chains.md) +- Physical units and dimension inference: + [`docs/core/physical-units.md`](docs/core/physical-units.md) - Operation graph JSON spec: [`docs/core/operation_graph_json_spec.md`](docs/core/operation_graph_json_spec.md) diff --git a/SimpleCADAPI/README.zh-CN.md b/SimpleCADAPI/README.zh-CN.md index 7e7d752..0fcd2a4 100644 --- a/SimpleCADAPI/README.zh-CN.md +++ b/SimpleCADAPI/README.zh-CN.md @@ -21,16 +21,16 @@ SimpleCADAPI 是一个基于 OCP 的 Python CAD SDK,提供清晰的函数式建模操作和可重放的模型图。它在 OpenCascade 几何内核之上提供精简的公共 API,可用于创建实体、应用特征、添加语义标签、查询拓扑、导出制造文件,以及将记录的模型转换为 FreeCAD 工作流。 -当前测试版本:`simplecadapi==2.0.1b1`。 +当前正式版本:`simplecadapi==2.0.2`。 ## 核心能力 - 基于 OCP 的 `Vertex`、`Edge`、`Wire`、`Face` 和 `Solid` 类型。 - 支持基本体、轮廓、拉伸、旋转、放样、扫掠、布尔运算、变换、阵列、圆角、倒角和抽壳等函数式建模操作。 -- 通过 `GraphSession`、`export_model_json(...)`、`import_model_json(...)` 和 `replay_model_json(...)` 记录并重放建模过程。 +- 通过 `@model`、`ModelResult`、`capture_result(...)`、`import_model_json(...)` 和 `replay_model_json(...)` 记录并重放建模过程。 - 通过 `var(...)`、算术表达式和可序列化表达式图定义参数。 - 使用 QL 选择器定位几何、查询拓扑并稳定选择特征。 -- 通过 `apply_tag(shape, tag)` 和 `list_tags(shape)` 管理语义标签。 +- 通过 `apply_tag(shape=..., tag=...)` 和 `list_tags(shape=...)` 管理语义标签。 - 支持 STEP/STL 导出,以及 FreeCAD 脚本和 `.FCStd` 转换。 ## 安装 @@ -63,37 +63,59 @@ import simplecadapi as scad out = Path("out") out.mkdir(exist_ok=True) -base = scad.make_box_rsolid(60.0, 36.0, 8.0, bottom_face_center=(0.0, 0.0, 0.0)) -hole = scad.make_cylinder_rsolid(5.0, 14.0, bottom_face_center=(0.0, 0.0, -3.0)) +base = scad.make_box_rsolid( + width=60.0, height=36.0, depth=8.0, bottom_face_center=(0.0, 0.0, 0.0) +) +hole = scad.make_cylinder_rsolid( + radius=5.0, height=14.0, bottom_face_center=(0.0, 0.0, -3.0) +) part = scad.cut_rsolid(base, hole) -part = scad.apply_tag(part, "role.demo.bracket") +part = scad.apply_tag(shape=part, tag="role.demo.bracket") print("volume", round(part.get_volume(), 3)) -print("tags", scad.list_tags(part)) +print("tags", scad.list_tags(shape=part)) -scad.export_step(part, str(out / "bracket.step")) -scad.export_stl(part, str(out / "bracket.stl")) +scad.export_step(shapes=part, filename=str(out / "bracket.step")) +scad.export_stl(shapes=part, filename=str(out / "bracket.stl")) ``` ## 可重放建模 -当模型需要检查、序列化、重放或转换到其他 CAD 环境时,请使用 `GraphSession`: +当模型需要检查、序列化、重放或转换到其他 CAD 环境时,请使用唯一的 +`@scad.model` 顶层入口。该入口拥有自己的 `GraphSession` 并返回 `ModelResult`: ```python import simplecadapi as scad -with scad.GraphSession() as session: - body = scad.make_box_rsolid(40.0, 24.0, 10.0, bottom_face_center=(0.0, 0.0, 0.0)) - cutter = scad.make_cylinder_rsolid(4.0, 16.0, bottom_face_center=(0.0, 0.0, -3.0)) +@scad.model(graph_id="drilled_block") +def build_model(): + body = scad.make_box_rsolid( + width=40.0, height=24.0, depth=10.0, + bottom_face_center=(0.0, 0.0, 0.0), + ) + cutter = scad.make_cylinder_rsolid( + radius=4.0, height=16.0, bottom_face_center=(0.0, 0.0, -3.0) + ) drilled = scad.cut_rsolid(body, cutter) + scad.capture_result(value=drilled) + return drilled -model_json = scad.export_model_json(session) -rebuilt = scad.replay_model_json(model_json) +result = build_model() +model_json = result.model_json +rebuilt = result.replay() -print("recorded_nodes", session.graph.node_count) +print("recorded_nodes", result.session.graph.node_count) print("replayed_outputs", len(rebuilt)) ``` +如果模型调用还需要写出最终文件,请向 `@scad.model` 传入 +`export_dir=...`。显式 `capture_result(...)` 的结果会生成一个自包含的 +`.scene.zip`,其中包含 `scene.json`、`model/model.json`、operation +source mapping 引用的完整项目相对 Python 源文件(位于 `sources/`)以及 +Viewer 所需的 GLB/entity 资源。自动导出不会在旁边生成 model/session JSON、 +STEP、STL 或 FCStd;这些格式仍可通过显式导出 API 生成。文件路径为 +`result.artifact_paths["scene"]`。省略 `export_dir` 时不会写文件。 + ## FreeCAD 转换 可以把记录的模型 JSON 转换为 FreeCAD Python 脚本: diff --git a/SimpleCADAPI/design-docs/cadql-brep-query-language.md b/SimpleCADAPI/design-docs/cadql-brep-query-language.md new file mode 100644 index 0000000..c34564c --- /dev/null +++ b/SimpleCADAPI/design-docs/cadql-brep-query-language.md @@ -0,0 +1,2658 @@ +# CADQL: 基于 BRep Graph 的结构化查询语言设计 + +## 文档状态 + +- 状态:Proposed; transitional scoped semantics implemented +- 目标版本:CADQL Query IR 1.0 +- 目标项目:SimpleCADAPI 2.x +- 主要影响模块:`ql.py`、`core.py`、`topology.py`、`graph.py`、`serializer.py`、各 translator backend +- 非目标:在第一阶段引入 GraphQL Server、HTTP API 或第三方 GraphQL runtime + +### 当前实现边界 + +当前代码尚未实现本文定义的完整 CADQL Query IR。已落地的过渡 subset 仅包括现有 `ShapeSelector` 上的 scoped tag predicate、typed tracking predicates、source-preserving `TagBinding` assignment,以及 graph/model schema `2.0` 中的 semantic replay。该 subset 不等于 typed graph-pattern evaluator、完整 Topology Evolution Graph、named-port schema `3.0` 或跨 backend provider parity。 + +因此,下文 Phase 1-7 仍是 proposed roadmap;已实现的 tagging/tracking subset 应按 `design-docs/cadql-tagging-and-auto-semantics.md` 的 bounded transition 状态理解。 + +## 1. 摘要 + +SimpleCADAPI 需要一套以 BRep topology graph 为基础、同时支持几何属性和 semantic tags 的结构化查询语言。该查询语言必须满足: + +1. 查询意图能够进入 canonical operation graph,并可序列化、重放和翻译。 +2. 查询结果不依赖未定义的 BRep 枚举顺序。 +3. 几何条件具有明确的单位、容差和方向语义。 +4. semantic tag 查询能够区分 local、inherited、effective 和 lineage scope。 +5. 查询的 cardinality 和歧义策略是正式 contract,而不是调用端的临时检查。 +6. Python replay、FreeCAD translator 和未来 translator 使用相同查询语义。 +7. 查询意图与一次执行产生的几何 fingerprint/evidence 分离。 + +本方案借鉴 GraphQL 的 schema、类型验证、variables、fragments、selection set 和结构化错误,但不直接使用 GraphQL 作为核心执行模型。Canonical contract 是一个强类型、JSON 可序列化的 CAD Query IR。GraphQL-like 文本、Python builder、GUI 和 LLM 都只是 Query IR 的前端。 + +## 2. 背景与问题 + +当前 selection 体系由多套机制叠加形成: + +- `get_edges(index)`、`get_faces(index)` 等 indexed getter。 +- `ShapeSelector` 和 QL predicate/order/cardinality。 +- `select_edges_by_tag`、`select_faces_by_tag`。 +- `make_select_rvertex/redge/rwire/rface/rsolid` graph node。 +- `geo_selector` 几何 fingerprint。 +- `TopoRef`、legacy enumeration index 和 selector hint fallback。 +- feature params 中的 `selected_edge_node_ids`、`selected_face_node_ids`、indices 和 refs。 + +这些机制目前没有统一的一等数据模型,造成以下问题: + +- QL query 经常在 record time 被 resolve,然后降级为 geometry-only selection node,原始 semantic intent 丢失。 +- `TopoRef.topo_id` 是 implementation-defined,不适合作为 durable topology naming。 +- Python replay 和 FreeCAD translator 使用不同的 geo scoring、阈值和 normal 方向规则。 +- 对称或重复几何中的同分候选不会可靠地报告歧义。 +- selection node、query、projection、cardinality 和 fallback strategy 混在同一组 params 中。 +- backend 很难声明自己支持哪些 query predicate 和 traversal relation。 + +因此,下一代查询系统不能继续在当前 `ql.py` 上零散增加 helper。需要先定义稳定的 Query IR、类型系统和执行语义。 + +## 3. 设计决策 + +### 3.1 决策一:Canonical contract 是 Query IR,不是文本语法 + +Canonical operation graph 中保存 JSON Query IR,不保存 Python callable,也不把 GraphQL 字符串作为唯一事实来源。 + +原因: + +- JSON IR 更容易做 schema migration 和 strict validation。 +- translator 不需要嵌入 GraphQL parser。 +- Python builder、GraphQL-like text、GUI 和 LLM 可共享同一个执行器。 +- Query IR 可以精确表示 typed graph pattern、单位、容差和 cardinality。 + +### 3.2 决策二:GraphQL-like syntax 是推荐文本前端 + +借鉴 GraphQL: + +- 类型化 schema。 +- 字段和 input object。 +- variables。 +- fragments。 +- validation before execution。 +- result projection。 +- structured diagnostics。 + +不照搬 GraphQL: + +- 不使用 GraphQL resolver 自由定义字段语义。 +- 不使用 GraphQL null propagation 表示选择失败。 +- 不依赖 GraphQL list 顺序。 +- 不把 selection set 与 CAD entity match 混为一谈。 +- 不把 mutation/subscription 引入 CADQL 1.0。 + +### 3.3 决策三:查询对象是 BRep Topology Graph + +必须区分三种图: + +| 图 | 职责 | +| --- | --- | +| Operation Graph | 描述模型如何构建,并提供 query scope 和 provenance。 | +| BRep Topology Graph | 描述当前 output 中 topology entities 及其 contains/boundary/incident/adjacent 关系。 | +| Topology Evolution Graph | 通过 EntityVersion/TopoUseVersion/Change 描述 preserved/modified/generated/deleted、split、merge 和 generation。 | + +CADQL 在 Current BRep Graph 与 Topology Evolution Graph 的联合视图上执行,可以通过 provenance relation 连接 Operation Graph。 + +### 3.4 决策四:Query intent 是主身份,Evidence 是辅助信息 + +Query intent 描述用户为什么选择实体,例如: + +```text +选择带 role.mounting_surface tag、surface type 为 plane、法向朝 +Z 的唯一 face +``` + +Evidence 描述某次执行如何得到结果,例如: + +```text +匹配了 face_7;area=245;normal=[0,0,1];唯一候选;resolver=ocp +``` + +Replay 必须先重新执行 Query intent。Evidence 用于 drift diagnosis 和显式 fallback,不是 primary selector。 + +### 3.5 决策五:违反唯一性默认失败 + +当 feature query 的 cardinality 要求唯一实体,而匹配结果不唯一时必须失败,不允许静默选择 BRep 列表中的第一个实体。多实体选择通过 `EXACTLY`、`AT_LEAST` 等 cardinality 正式声明。 + +### 3.6 决策六:查询定义与 graph binding 分离 + +Query IR 描述 traversal、predicate、ordering、cardinality 和 projection,不保存具体 operation node ID。查询作用域通过执行时 binding 提供: + +- Python 中由 `cadql.select(cadql.Face, in_=body)` 的 `in_` 参数建立 runtime binding。 +- Operation Graph 中由 query node 的 `scope` typed input port 建立 binding。 +- 文本查询中由 variable 建立 binding。 + +因此,相同 Query IR 可以绑定到参数变化后重建的 operation output,`query_hash` 也不会因为 node ID 改变。持久化对象由 `query` 和 `bindings` 两部分组成。 + +### 3.7 决策七:Canonical query 是 typed graph pattern + +很多实际选择不是“某条 edge 的长度或中心是多少”,而是“当前拓扑实体在空间结构和建模历史中扮演什么角色”。Boolean、fillet、chamfer、shell、cut 和 pattern 都会产生 split、merge、generated、modified 和 preserved entities,不能为每种 operation 发明一个 selection helper。 + +Canonical CADQL 因此采用统一的 typed graph pattern: + +- typed variable 表示当前或历史 topology entity、oriented topology use、change 或 operation。 +- topology relation 表示 contains、boundary、incident、adjacent 等当前空间结构。 +- lineage relation 表示一个实体经过任意 operation 后如何 split、merge、modify、generate 或 preserve。 +- provenance relation 表示 operation、input role 和 source binding。 +- semantic predicate 表示用户或系统提供的稳定意图锚点。 +- geometry predicate 只作为可选约束,不是统一 identity 模型的基础。 + +查询最终返回某个 typed variable 的去重集合。现有 `faces().where(...).order_by(...)` 属于 legacy `ql.py`,不是 CADQL 1.0 public syntax,也不是 canonical IR。 + +这一形式统一表达: + +- box/cylinder union 后,位于两个来源 surface 交界上的 current edges。 +- fillet/chamfer 后,由原 edge 分裂或生成、并与特定来源 faces 相邻的 current edges。 +- 多次 feature 后,仍是某个 semantic source query 的 descendants 的 current faces。 +- 不知道具体 curve/surface class 时,仅依赖 topology role 和 lineage 的选择。 + +CADQL 不能在完全没有 semantic tag、source query、spatial relation、lineage 或 geometry distinction 时区分数学上对称且可交换的实体。此时正确结果是集合或 `AmbiguousSelectionError`,而不是伪造稳定身份。 + +### 3.8 决策八:Public API 只有一种 query 形态 + +CADQL 不提供 `from_shape`、`anchor`、`on`、`SurfaceContext` 等相互重叠的 query 入口。所有选择都从同一个构造器开始: + +```python +cadql.select(ResultType, in_=scope) +``` + +其中 public `scope` 可以是 Shape、operation output 或另一个 `SelectionQuery[T]`。已经 resolved 的 `SelectionSet[T]` 不作为 public `in_` 参数;Operation Graph 内部会把 referenced query 的 output 作为 typed binding 连接到下游 query node。所有 query 使用相同的操作并返回相同的结果形态: + +```text +select -> where -> order_by/take -> expect -> SelectionQuery[T] +SelectionQuery[T].resolve() -> SelectionSet[T] +``` + +已有 query 不会被“提升”为另一种 Anchor 对象。它本身就是可持久化、可作为后续 relation source binding 的选择意图: + +```python +mounting_face = ( + cadql.select(cadql.Face, in_=before_boolean) + .where(cadql.tag("role.mounting_surface", scope="effective")) + .expect_one() +) + +current_faces = ( + cadql.select(cadql.Face, in_=after_boolean) + .where( + cadql.related( + cadql.descends_from, + from_=cadql.this, + to=mounting_face, + derivations=("continuation", "fragment"), + depth=(1, 64), + ) + ) + .expect_at_least(1) +) +``` + +局部 surface/UV 选择也不是另一套 context API。先选 current faces,再把该 query 作为普通 relation binding: + +```python +outer_coedges = ( + cadql.select(cadql.Coedge, in_=after_boolean) + .where( + cadql.related(cadql.boundary_uses, from_=current_faces, to=cadql.this) + & cadql.topology.loop_role.eq("outer") + ) + .expect_at_least(1) +) +``` + +`FaceFamily` 只是 `SelectionSet[Face]`,`WorkPlaneContext` 只是绑定到单一 planar chart 的 query validation 状态,二者都不是 public object kind。一个历史 face resolve 为多个 current fragments 时,结果自然是多个 Face;初始 `.expect_one()` 与后续 `.expect_at_least(1)` 是两个独立 query 的 cardinality contract。 + +### 3.9 决策九:先定义不可约的选择 case + +表面上不同的 CAD 选择应先归约到最少的正交能力。CADQL 只有六种不可互相替代的 match case: + +| Case | 唯一新增能力 | 代表问题 | +| --- | --- | --- | +| 类型全集 | 只给出 result type 和 scope | 选择 body 中所有 Face。 | +| 属性约束 | 对 candidate property 求 predicate | 选择带 tag、面积大于阈值或法向朝上的 Face。 | +| Current topology relation/path | 匹配 snapshot 中的 typed relation 或其有界/传递闭包 | 选择 face boundary Edge、两个 faces 的共享 Edge 或相切 edge chain。 | +| Oriented occurrence | 返回 TopoUse,而不是 underlying entity | 选择 face outer loop 的正向 Coedge 或 seam 的某次 use。 | +| Evolution relation | 沿 Change graph 匹配历史关系 | 选择原 edge split 后的 current fragments。 | +| Multi-source Change pattern | 同时约束一个 Change 的多个 input/output | 选择 box/cylinder 两个 source surfaces 共同生成的交界 edges。 | + +以下不是新的 match case: + +- Semantic、geometry、local UV 和相对空间条件都是属性约束,只是 property namespace 不同。 +- Face、Edge、Vertex 只是 result type 不同。 +- Boolean、fillet、chamfer 只是 Change producer 不同。 +- Union、intersection、difference 是集合代数。 +- Ordering、take、group 和 cardinality 是结果代数。 +- 保存、命名和 graph binding 是 query 生命周期,不改变 match 语义。 + +任何新增 public helper 都必须证明自己能表达一个现有六类无法表达的 case;否则只能是 lowering 到这些原语的非 canonical convenience syntax,并且不进入 1.0 public contract。 + +### 3.10 典型选择 case corpus + +下面每行具有不同的最小表达式或数据需求;同一行中的 operation 名称和 topology type 变化不产生新 case。 + +| # | 典型需求 | 唯一归约 | 需要的数据 | +| --- | --- | --- | --- | +| 1 | 选择 scope 中全部 Face | 类型全集 | Current BRep containment。 | +| 2 | 选择带 mounting tag 的 Face | 属性约束:semantic | Semantic binding。 | +| 3 | 选择半径约等于 5 mm 的圆 Edge | 属性约束:intrinsic geometry | Geometry properties、unit、tolerance。 | +| 4 | 选择平行于 datum plane 的 Face | 属性约束:relative geometry | Reference binding、frame。 | +| 5 | 选择已选 Face 包含的 Edge | Query scope containment | Current BRep containment。 | +| 6 | 选择两个 Face 的共享 Edge | Current relation | `incident_faces`。 | +| 7 | 选择与 seed Face 相邻的 Face | Current relation | `adjacent_faces`。 | +| 8 | 选择与 seed 连通且保持相切的 Edge chain | Current relation path | `adjacent_edges`、step predicate、depth bound。 | +| 9 | 选择 Face outer loop 中的 Edge occurrences | Oriented occurrence + property | `boundary_uses`、`loop_role`。 | +| 10 | 区分 periodic Face 上同一 seam Edge 的两次使用 | Oriented occurrence | TopoUse occurrence identity。 | +| 11 | 选择 planar Face 局部坐标最右侧的 Coedge | Oriented occurrence + local property | SurfaceChart、stable frame。 | +| 12 | 选择 source Edge split 后的 current fragments | Evolution relation | `descends_from`、`FRAGMENT` evidence。 | +| 13 | 选择由 source Face 生成的新 boundary Edge | Evolution relation | `generated_from`、`BOUNDARY` evidence。 | +| 14 | 选择与某 transition Face 同一 Change 的 Edge | Evolution relation | `co_result_of` witness。 | +| 15 | 选择同时由 box/cylinder sources 生成的 interface Edge | Explicit Change pattern | 多 input role、`INTERSECTION` output。 | +| 16 | Source Face 已消失时选择消费它的 Change outputs | Explicit Change pattern | Historical input 和 generated outputs。 | +| 17 | 选择满足 A 或 B、同时排除 C 的 Edge | Set algebra | Union/difference 和 entity identity。 | +| 18 | 从多个候选中选择最高且唯一的 Face | Result ordering | Business order、tie error、take、cardinality。 | +| 19 | 每个 hole loop 选择最长且唯一的 Edge | Correlated partition | Loop identity、per-partition order/cardinality。 | +| 20 | 数学上完全对称且无区分依据的实体 | Cardinality/ambiguity | 返回集合,或唯一性要求下失败。 | + +覆盖规则: + +- Case 2、3、4 的求值数据不同,但语法和结果行为完全相同,都是 `.where(property_predicate)`。 +- Case 6、7、8 的 relation 不同,但语法和结果行为完全相同,都是 `.where(related(...))`。 +- Case 12、13、14 的 evolution relation 不同,但仍使用同一个 `related(...)` constructor。 +- Case 15、16 都需要显式匹配一个 n-ary Change;input 数量不是新的语言机制。 +- Case 17、18、19、20 不增加 candidate identity 机制,只处理已匹配集合。 +- Face/Edge/Vertex/Wire/Solid 的替换不产生新 case;它只触发 relation/property type validation。 + +## 4. 术语 + +| 术语 | 定义 | +| --- | --- | +| Scope | 查询开始的 operation output 或已有 topology entity set;scope binding 不属于 Query IR identity。 | +| Match | 根据 predicate 选择 topology entities。 | +| Traversal | 沿强类型 topology/lineage relation 移动。 | +| Predicate | 对 geometry、topology、semantic 或 provenance property 的条件。 | +| Projection | 查询结果需要返回的字段,不决定选中哪些实体。 | +| Cardinality | 对匹配结果数量的正式约束。 | +| Ambiguity | 多个候选都满足 identity 或 fingerprint,无法唯一判定。 | +| Evidence | 某次执行的匹配属性、候选数、path witness 和 selected refs。 | +| SelectionQuery | 未执行的 typed Query IR 及其 runtime bindings;可以作为另一个 query 的 source。 | +| SelectionSet | 某个 Query IR 的强类型结果集合。 | + +## 5. 类型系统 + +### 5.1 Topology 类型 + +目标类型层次: + +```text +TopoEntity +├── Compound +├── CompSolid +├── Solid +├── Shell +├── Face +├── Wire +├── Edge +└── Vertex + +QueryContext +├── SurfaceChart +└── FrameRef + +TopoUse[T] +├── FaceUse +├── WireUse +├── Coedge oriented use of Edge in a face boundary +└── VertexUse +``` + +CADQL 1.0 的内部 BRep graph 必须完整表示: + +```text +Compound +CompSolid +Solid +Shell +Face +Wire +Edge +Vertex +TopoUse +SurfaceChart +``` + +Public query return type 1.0 至少支持 Solid/Face/Wire/Edge/Vertex 和 Coedge。Coedge 是 `TopoUse[Edge]` 的公开类型名,不是新的 BRep entity。Shell/Compound/CompSolid 及其他 TopoUse 可以参与 pattern,并在 provider capability 声明后作为返回类型。SDK 当前 `TopoKind` 已有 Compound,但缺少 Shell/CompSolid;canonical selection operations 也缺少 Compound/Shell/CompSolid。Phase 1 必须补齐 IR type enum,Phase 4 再补齐 public graph outputs。 + +### 5.2 Property namespace + +属性必须按 namespace 分类,禁止把所有属性放入未定义语义的 dict。 + +#### Topology properties + +```text +topology.kind +topology.closed +topology.edge_count +topology.wire_count +topology.inner_wire_count +topology.incident_face_count +topology.adjacent_face_count +topology.loop_role +topology.loop_identity +``` + +#### Geometry properties + +```text +geometry.length +geometry.area +geometry.volume +geometry.center +geometry.center.x +geometry.center.y +geometry.center.z +geometry.bbox +geometry.normal +geometry.tangent +geometry.curvature +geometry.radius +geometry.curve_type +geometry.surface_type +``` + +#### Semantic properties + +```text +semantic.tags +semantic.metadata.. +semantic.role +semantic.group +semantic.material +``` + +#### Provenance properties + +```text +provenance.produced_by +provenance.operation_type +provenance.origin_role +provenance.source_node +``` + +#### Spatial/reference properties + +```text +geometry.axis.origin +geometry.axis.direction +geometry.location +geometry.support_curve_type +geometry.support_surface_type +geometry.parameter_span +geometry.sweep_angle +``` + +这些 property 仅对适用的 entity kind 可用。例如 `geometry.axis` 可用于 cylinder/cone surface 和 circle/ellipse curve;`geometry.sweep_angle` 只适用于具有规范角参数的 trimmed conic。Property registry 必须声明适用类型,不能在不适用时返回任意默认值。 + +#### Local/chart properties + +```text +local.u +local.v +local.u_range.min +local.u_range.max +local.v_range.min +local.v_range.max +local.winding +local.orientation +``` + +`local.u/v` 具有 length 或 dimensionless 参数单位,由 SurfaceChart schema 声明;不能在 query 中混合比较不同单位。`local.winding/orientation` 只适用于 Coedge/WireUse,其他 local property 的适用类型由 registry 静态验证。任何 predicate、ordering、partition 或 projection 对 `local.*` property 的引用都必须携带明确的 chart binding。Chart identity 通过 `surface_chart/use_chart` relation 表达,不再提供会自引用 chart binding 的 `local.chart_id` property。Loop role 和 identity 是纯拓扑事实,使用不依赖 chart 的 `topology.loop_role/loop_identity`。 + +### 5.3 Property capability + +每个 backend/evaluator 必须声明 property capability: + +```json +{ + "geometry.area": "supported", + "geometry.curvature": "unsupported", + "topology.loop_role": "supported" +} +``` + +Query validation 在执行前检查所有 property 和 relation。Unsupported property 必须产生 `UnsupportedQueryCapabilityError`,不能被当作 false predicate。 + +## 6. Typed Traversal Relations + +CADQL relation 是有限枚举,并具有 source/target 类型约束。 + +### 6.1 Topology relations + +| Relation | Source | Target | 语义 | +| --- | --- | --- | --- | +| `contains_direct` | Compound/CompSolid/Solid/Shell/Face/Wire | next lower entity | 只表示直接 BRep containment。 | +| `contains` | TopoEntity | lower-dimensional entity | `contains_direct` 的有界传递 path,必须指定 target type。 | +| `incident_faces` | Edge | Face | 共享该 edge 的 faces。 | +| `adjacent_faces` | Face | Face | 通过 edge adjacency 相邻。 | +| `adjacent_edges` | Edge | Edge | 共享 vertex 的 edges;可用 step predicate 进一步要求 tangent continuity。 | +| `boundary_uses` | Face/Wire | Coedge | Face 或 wire boundary 中有方向的 Edge occurrences。 | +| `use_entity` | TopoUse[T] | T | Occurrence 引用的同类型 underlying entity,例如 Coedge -> Edge。 | +| `use_parent` | TopoUse | TopoEntity | Occurrence 所属 parent entity。 | +| `use_chart` | TopoUse | SurfaceChart | Occurrence 所属 face chart。 | +| `surface_chart` | Face | SurfaceChart | Face query 绑定的 surface chart。 | +| `next_coedge` | Coedge | Coedge | 同一 WireUse 中按 orientation 的下一个 occurrence。 | +| `previous_coedge` | Coedge | Coedge | 同一 WireUse 中按 orientation 的上一个 occurrence。 | +| `mate_coedge` | Coedge | Coedge | 邻接 FaceUse 上引用同一 underlying Edge 的 occurrence。 | +| `starts_at` | Coedge | VertexUse | Coedge 在 parent orientation 下的起始 VertexUse。 | +| `ends_at` | Coedge | VertexUse | Coedge 在 parent orientation 下的结束 VertexUse。 | +| `belongs_to_loop` | Coedge | WireUse | Coedge 所属的 boundary loop occurrence。 | + +`boundary_uses(Face/Wire, Coedge)` 是 `FaceUse -> WireUse -> Coedge` occurrence chain 的规范投影。它保留每个 parent occurrence,不按 underlying Edge 去重;因此 seam Edge 在同一 Face 中仍返回两个 Coedges。需要显式遍历中间 occurrence 时使用 `use_parent`/`belongs_to_loop`,不增加另一套 `coedges` relation。 + +### 6.2 Lineage relations + +Lineage 不能只是一组从 child 指向 parent 的模糊 ref。Canonical Topology Evolution Graph 使用四类 node: + +```text +EntityVersion 某个 operation output snapshot 中的 topology entity +TopoUseVersion 某个 snapshot 中带 parent/orientation/occurrence 的 topology use +Change 一次 operation 内的一项 topology evolution event +Operation Operation Graph 中的建模 operation +``` + +基本关系: + +```text +EntityVersion -[INPUT_TO {input_port, role}]-> Change +Change -[OUTPUT {event, derivation}]-> EntityVersion +TopoUseVersion -[USE_INPUT_TO {input_port, role}]-> Change +Change -[USE_OUTPUT {event, derivation, order_interval}]-> TopoUseVersion +TopoUseVersion -[USE_ENTITY]-> EntityVersion +TopoUseVersion -[USE_PARENT]-> EntityVersion +Change -[PERFORMED_BY]-> Operation +EntityVersion -[MEMBER_OF]-> Snapshot +``` + +`event` 沿用宏观结果分类: + +```text +PRESERVED +MODIFIED +GENERATED +DELETED +``` + +`derivation` 使用与具体 feature 名称无关的演化分类: + +| Derivation | 含义 | +| --- | --- | +| `CONTINUATION` | 同一拓扑角色继续存在,可能参数或位置改变。 | +| `FRAGMENT` | 一个 parent 被 split/trim 后产生同维度片段。 | +| `MERGE` | 多个同维度 parents 合并为一个 output。 | +| `INTERSECTION` | 由两个或多个 support entities 的相交生成。 | +| `BOUNDARY` | 由高一维结果或修改区域产生的新 boundary。 | +| `REPLACEMENT` | 旧实体被新的同角色实体替换,但不是可证明的 fragment。 | +| `UNKNOWN` | Backend 只知道 parent/child,无法给出更强分类。 | + +例如原 edge 经 chamfer/fillet 后被分裂,新的同维 edge 使用 `FRAGMENT`;feature 新建的过渡面 boundary edge 使用 `BOUNDARY`;boolean 两个 surface 的交线使用 `INTERSECTION`。这些分类描述 BRep 演化事实,不编码 `fillet`、`chamfer` 或 `union` 名称。 + +一个 Change 可以有多个 input 和 output,因此能自然表示 split 和 merge,不需要伪造一对一 durable topology ID。`order_interval` 是可选的 source-use 参数区间;只有 backend 能证明 occurrence ancestry 和 orientation 时才记录。Lineage relation 只能在 model graph 提供相应 change evidence 时使用;缺少 evidence 不能退化成几何猜测。 + +### 6.3 Relation quantifiers + +Predicate 可以对子关系做 `exists`、`all` 和 `count`: + +```json +{ + "relation": "incident_faces", + "from": "edge", + "to": "source_face", + "relation_quantifier": {"kind": "at_least", "value": 1} +} +``` + +`source_face` 必须是已声明 variable;其 `descends_from` source binding 由同一个 `and` pattern 中的独立 path constraint 表达。 + +Count 形式: + +```json +{ + "relation": "incident_faces", + "relation_quantifier": "count", + "where": {"property": "geometry.surface_type", "eq": "plane"}, + "cardinality": {"kind": "exactly", "value": 1} +} +``` + +Quantifier 的 `where` 在 relation target type 上静态验证。`all` 对空 relation 返回 false,避免 vacuous truth 导致意外匹配。 + +Python `related(...)` 的 `from_`/`to` endpoint 可以绑定单个 variable 或 `SelectionQuery[T]`。Python 可以省略默认 endpoint quantifier;normalized IR 必须显式携带它: + +```text +ANY_MEMBER 至少与 binding 中一个 member 满足 relation;默认值。 +ALL_MEMBERS 与 binding 中每个 member 满足 relation;空 binding 返回 false。 +EXACTLY(n) 恰好与 n 个 members 满足 relation。 +AT_LEAST(n) 至少与 n 个 members 满足 relation。 +AT_MOST(n) 至多与 n 个 members 满足 relation。 +``` + +默认值永远是 `ANY_MEMBER`,不随 relation 名称、方向或 endpoint cardinality 改变。Python 省略默认值与显式写 `from_quantifier="any_member"`/`to_quantifier="any_member"` 完全等价;Canonical IR 总是显式保存 normalized quantifier。 + +### 6.4 Relation validation + +以下是合法 traversal: + +```text +Solid -> contains_direct -> Shell +Face -> contains_direct -> Wire +Wire -> contains_direct -> Edge +Edge -> contains_direct -> Vertex +Face -> adjacent_faces -> Face +``` + +以下必须在 validation 阶段失败: + +```text +Vertex -> contains_direct -> Face +Edge -> boundary_uses +Solid -> incident_faces +``` + +### 6.5 BRep graph snapshot construction + +Provider 对每个 scope output 构建一个只读 BRep graph snapshot: + +1. Scope root 本身是 graph node,不只索引它的 children。 +2. 每个拓扑实体以 `(kind, entity_token)` 在该 snapshot 内去重。 +3. `contains_direct` 保存 direct containment edge;`contains` 是它的 typed path projection,不重复存边。 +4. `boundary_uses` 连接 parent 和有方向 occurrence,`use_entity` 再连接 underlying entity;`incident` 和 `adjacent` 从共享 underlying boundary entity 推导。 +5. Face adjacency 默认是共享 Edge;仅共享 Vertex 不算 face adjacency。 +6. Seam edge 在同一 face 中可能出现两次,但 entity graph node 只有一个;需要 occurrence 信息的查询使用 `TopoUse/Coedge`,不假装 Edge entity 能表达方向不同的两次使用。 +7. Snapshot identity 只在一次 scope evaluation 内有效。跨 replay identity 由 Query intent 和 Evidence 处理,不持久化 `entity_token`。 + +`related(...)` traversal 对 entity set 逐个展开、合并并按 entity identity 去重。需要保留多条 path multiplicity 的 path query 不属于 CADQL 1.0。 + +### 6.6 Generic feature-evolution semantics + +CADQL 不定义 boolean、fillet 或 chamfer 专用 relation。Provider 将每次 operation 的 topology history 规范化为 Change graph: + +```text +input EntityVersion(s) -> Change -> output EntityVersion(s) +``` + +Input edge 必须带 role: + +```text +subject 被延续、切分或替换的实体 +target 用户明确选中并驱动 feature 的实体 +support 决定新实体位置或边界的支撑实体 +tool boolean/tool operand 中的实体 +context 参与 change,但没有更强可证明角色的实体 +``` + +Output edge 必须带 `event` 和 `derivation`。由此可统一表达: + +- Boolean 交界 edge:同一个 Change 有来自两个 operands 的 support/tool inputs,output derivation 为 `INTERSECTION`。 +- 原 edge 被 split:原 edge 是 subject/target input,多个 current edges 是 `FRAGMENT` outputs。 +- Fillet/chamfer 新 boundary edges:原 target edge 和 adjacent support faces 是 inputs,新 edges 是 `BOUNDARY` outputs。 +- 原 face 被 trim:原 face 是 subject input,current face 是 `FRAGMENT` 或 `CONTINUATION` output。 +- 多次 feature 后追踪:沿 Change graph 对 `descends_from` 或更宽的 `depends_on` 做传递 path match。 + +规范 evolution relation 只有以下五个;没有 `fragments_of`、`generated_by`、`intersects_sources` 等 aliases: + +| Relation | Source | Target | Depth | 含义 | +| --- | --- | --- | --- | --- | +| `descends_from` | current Entity/TopoUse | bound historical same-kind Entity/TopoUse | 1..n | Source 是 target 的 continuation/fragment/merge/replacement descendant。 | +| `generated_from` | current Entity/TopoUse | bound historical Entity/TopoUse | 1..n | Source 由 target 参与 intersection/boundary generation。 | +| `depends_on` | current Entity/TopoUse | bound historical Entity/TopoUse | 1..n | Source 的 causal Change path 中包含 target,不保证同维度或材料连续性。 | +| `co_result_of` | current Entity/TopoUse | current Entity/TopoUse | 1 | 两者是同一个 Change 的 outputs。 | +| `changed_with` | current Entity/TopoUse | bound historical Entity/TopoUse | 1 | Source 的 Change 同时消费 target。 | + +CADQL 1.0 不提供 `interface_between(a, b)`、`descendants_of(a)` 等同义 helper。它们分别使用 `change(...)` 和 `related(...)` 表达,避免同一 graph constraint 出现第二种 public 写法。 + +如果 backend 没有完整 Change input/output evidence,相关 pattern 产生 `UnsupportedQueryCapabilityError`,不能退化为“找最接近的 curve”。 + +对 box/cylinder union 场景,不应先假定交界 edge 是 circle。Cylinder 与 oblique box plane 的支撑交线通常为 ellipse/conic,trim 后只是其中一段。CADQL 应先按两个来源 bindings 共同参与的 `INTERSECTION` Change 选择 current edges;curve type、sweep angle 或 length 只是可选的二次约束。 + +### 6.7 Entity 和 oriented use + +BRep 中同一个底层 entity 可以在不同 parent 中以不同 orientation 出现。Canonical graph 区分: + +```text +TopoEntityVersion snapshot 内未定向的拓扑实体 +TopoUse entity 在某个 parent boundary 中的一次有方向 occurrence +``` + +`contains`、`incident_faces` 等 entity-level relation 用于大多数 selection;`boundary_uses`、`next_coedge`、`starts_at` 和 `ends_at` 用于依赖方向或 seam occurrence 的 query。`TopoEntityVersion` 可按 kernel `IsSame` 语义去重,`TopoUse` identity 则包含 parent、orientation 和 occurrence ordinal。 + +Normal、tangent、edge endpoint order 等 oriented property 必须在 `TopoUse` 上求值,或显式指定相对哪个 parent 求 oriented view。不能在去除 orientation 的 entity 上声称存在唯一方向。 + +### 6.8 Query composition 和 relation direction + +`select(T, in_=scope)` 始终只做两件事:建立 candidate universe,并声明 result type。它不隐式进入 history、不自动加入邻面,也不根据调用链切换语义。 + +`in_` 只绑定 candidate domain,不 lower 为 graph relation: + +- Shape 或 operation output:candidate domain 是该 current snapshot。 +- `SelectionQuery[P]`:candidate domain 是其 resolved `SelectionSet[P]` 覆盖的 current subgraphs。 +- 请求 `TopoEntity` 类型时,subgraph 包含 scope root 本身和沿 `contains_direct` 可达的 descendants。 +- 请求 `Coedge` 时,subgraph 包含 scope 中 Face/Wire 的 `boundary_uses` targets。 +- 所有 candidate 合并后按 entity/use identity 去重。 +- 若目标不是 candidate-domain membership,而是 incident、adjacent、use/entity 或 history 关系,必须写入 `.where(...)`。 + +Candidate-domain membership 是 evaluator seed 规则,不属于 `related(...)` registry,也不进入 query identity。Query identity 保存 result type;binding identity 保存具体 scope。这样 `select(Solid, in_=solid)` 可以选择 root,`select(Coedge, in_=body)` 也不需要伪造 `contains(body, coedge)`。 + +所有 graph relation 使用同一个 predicate constructor: + +```python +cadql.related(relation, from_=source, to=cadql.this) +cadql.related(relation, from_=cadql.this, to=target) +``` + +`cadql.this` 表示当前被测试的 candidate。Relation schema 静态检查 source/target type,因此 relation direction 不靠方法名猜测。一个 `related(...)` predicate 最多允许一个 endpoint 是 `SelectionQuery[T]`,另一个必须是 `cadql.this` 或局部 scalar variable。Set endpoint 默认量词始终是 `ANY_MEMBER`;需要其他行为时按 endpoint 方向使用 `from_quantifier=` 或 `to_quantifier=`。Relation target 自身的 exists/count 语义使用独立的 `relation_quantifier` IR field,不复用 endpoint quantifier 名称。 + +示例: + +```python +# Face -> boundary_uses -> Coedge +coedges = ( + cadql.select(cadql.Coedge, in_=body) + .where(cadql.related(cadql.boundary_uses, from_=faces, to=cadql.this)) +) + +# Coedge -> use_entity -> Edge +edges = ( + cadql.select(cadql.Edge, in_=body) + .where(cadql.related(cadql.use_entity, from_=coedges, to=cadql.this)) +) + +# Edge -> incident_faces -> Face +shared_edges = ( + cadql.select(cadql.Edge, in_=body) + .where( + cadql.related(cadql.incident_faces, from_=cadql.this, to=face_a) + & cadql.related(cadql.incident_faces, from_=cadql.this, to=face_b) + ) +) +``` + +Python `cadql.related(...)` 可以省略 `depth`,唯一默认值是 `depth=1`;normalized IR 必须显式保存该值。有界或传递 traversal 使用同一个 constructor,并提供 `depth=(min, max)`。Normalize 阶段把默认/显式 `depth=1` lower 为相同 canonical `relation` constraint,把范围 lower 为 canonical `path` constraint。 + +Path 的 `step_where` 是可选的 pairwise predicate,显式绑定 `cadql.step.from_`、`cadql.step.to` 和 relation witness。例如 `adjacent_edges` witness 是共享 Vertex: + +```python +cadql.related( + cadql.adjacent_edges, + from_=seed_edges, + to=cadql.this, + depth=(0, 64), + step_where=cadql.step.tangent_continuous( + angle_tolerance=cadql.deg(0.1), + ), +) +``` + +普通 `where=` 只允许 target-local predicate,不能表达 pairwise tangency。Provider 不支持 relation witness 或 oriented tangent 时必须报告 capability error。 + +Canonical path IR 保存同一信息: + +```json +{ + "path": { + "from": "seed_edge", + "relation": "adjacent_edges", + "to": "result", + "min_depth": 0, + "max_depth": 64, + "from_quantifier": "any_member", + "step_where": { + "predicate": "tangent_continuous", + "relative_to": "relation_witness", + "angle_tolerance": {"value": 0.1, "unit": "deg"} + } + } +} +``` + +`tangent_continuous` 只适用于 `adjacent_edges` path。`relative_to: relation_witness` 表示在共享 Vertex occurrence 处比较进入/离开 tangent,因此满足 oriented-property 的显式 context 要求。 + +### 6.9 Local 2D topology naming + +局部 surface 选择的稳定性主要来自 query-selected Face 与其局部拓扑结构,而不是 UV 数值。每个 face chart 暴露: + +```text +FaceUse + -> outer/inner WireUse + -> ordered cyclic Coedge sequence + -> start/end VertexUse + -> mate Coedge on neighboring FaceUse +``` + +Outer/inner 是 `topology.loop_role` property,不是第二套 relation。局部 traversal 只使用 6.1 已注册的 `next_coedge`、`previous_coedge`、`mate_coedge`、`starts_at`、`ends_at` 和 `belongs_to_loop`。 + +当原 boundary edge 被 split 时,Topology Evolution Graph 必须把 current coedge fragments 映射到 source coedge query,并保留在 source orientation 下的有序覆盖: + +```text +source CoedgeUse + -> fragment 0 + -> fragment 1 + -> fragment 2 +``` + +该顺序来自 `TopoUseVersion USE_OUTPUT.order_interval`,不是 current backend edge enumeration。若 provider 只能证明 underlying edge fragments 属于同一 source query、无法证明 use ancestry/orientation/order,则仍可返回无序 fragment set,但任何 `next_coedge` 或 ordered-range query 必须产生 `UnsupportedQueryCapabilityError`。 + +常见无具体几何 query: + +```text +selected face 的 outer loop edges +selected face 某个有稳定区分依据的 inner loop current descendants +selected coedge 被 split 后的全部 current fragments +与 selected face 相邻、且由同一个 Change 新生成的 faces +selected face boundary 上由 tool source 参与生成的 edges +``` + +“第 2 个 inner loop”只有在 loop 本身有 semantic tag 或稳定 ordering key 时才可持久化。Raw current loop enumeration 仍不是 durable identity。 + +### 6.10 Surface chart 只是显式 predicate binding + +CADQL 不创建 SurfaceContext/WorkPlaneContext 对象。Local property 必须通过 `in_chart=` 显式绑定到一个 Face query: + +```python +right_side = ( + cadql.select(cadql.Coedge, in_=body) + .where( + cadql.related(cadql.boundary_uses, from_=mounting_face, to=cadql.this) + & cadql.local.u.approximately( + cadql.local.u_range.max, + in_chart=mounting_face, + ) + ) + .expect_one() +) +``` + +规则: + +- `mounting_face` 必须静态为 `SelectionQuery[Face]`。 +- 若 local predicate 需要单一 chart,binding query 必须具有 `ONE` cardinality。 +- 多个 faces 可按 chart 分组后分别执行 local predicate,但不同 chart 的 UV 不得直接比较或全局排序。 +- Planar frame 来自 source Face query 的 operation/frame binding,不从 current trimmed bbox 重新猜测。 +- Chart 只沿可证明的 `CONTINUATION`/`FRAGMENT` use history 继承。`MERGE`/`REPLACEMENT` 默认不继承,必须用新的 Face query 和显式 frame 重新定义 local predicate。 + +邻接和 Change neighborhood 不再是 context expansion mode。它们分别写成 `related(incident_faces, ...)`、`related(evolution_relation, ...)` 或 multi-source `change(...)` constraints。因此候选范围不会因一个隐式 expansion 改变。 + +## 7. Predicate Algebra + +### 7.1 Boolean predicates + +```text +{"and": [, ]} +{"or": [, ]} +{"not": } +``` + +空 `and`、空 `or` 在 strict mode 下非法,避免产生不直观的全选或空选。 + +### 7.2 Scalar comparison + +```text +eq +ne +lt +lte +gt +gte +between +approximately +``` + +浮点 property 禁止默认 exact equality。对 length/area/volume/radius 使用 `approximately` 或范围谓词。 + +### 7.3 Vector and direction predicates + +```text +parallel_to +same_direction_as +opposite_direction_to +perpendicular_to +angle_to +``` + +语义定义: + +- `parallel_to` 允许同向和反向。 +- `same_direction_as` 只允许同向。 +- `opposite_direction_to` 只允许反向。 +- 所有方向 predicate 必须指定或继承 angle tolerance。 +- curved face 的 normal 查询必须指定 sampling policy;CADQL 1.0 默认仅允许 planar face 的 stable normal predicate。 + +### 7.4 String和enum predicates + +```text +eq +ne +in +exists +``` + +`geometry.surface_type`、`geometry.curve_type` 使用 enum,不使用 backend-specific Python class name。 + +### 7.5 Tag predicates + +Tag 查询格式: + +```json +{ + "property": "semantic.tags", + "tag": { + "scope": "effective", + "op": "eq", + "value": "role.mounting_surface" + } +} +``` + +Tag scope: + +| Scope | 含义 | +| --- | --- | +| `local` | 直接挂在 entity 上。 | +| `inherited` | 从 topology parent 或 model entity 传播。 | +| `effective` | local 与 inherited 的并集。 | +| `lineage` | 从 topology lineage ancestor 传播。 | + +Python `cadql.tag(value, scope="effective")` 的默认 scope 是 `effective`,builder normalization 始终把默认值显式写入 IR。其他 scope 必须显式传参。 + +当前 SDK 仅保存扁平化 `_tags: set[str]`,传播后无法判断一个 tag 是 local 还是 inherited。因此迁移前只有 `effective` 可被可靠执行;请求 `local`、`inherited` 或 `lineage` 必须得到 `UnsupportedQueryCapabilityError`,不能把 effective tags 冒充为其他 scope。 + +Phase 4 必须增加保留来源的 canonical tag binding: + +```json +{ + "tag": "role.mounting_surface", + "assignment_node": "node_tag_mounting_surface", + "scope_binding": "body", + "target": { + "kind": "selection_query", + "query_hash": "sha256:...", + "binding_hash": "sha256:..." + }, + "attachment": "local", + "propagation": "downward" +} +``` + +`effective` 是 local bindings 与适用 inherited bindings 的计算结果,不再通过复制字符串丢失来源。Lineage tags 根据 tag bindings 和 Topology Evolution Graph 在 query-time 计算。 + +匹配模式: + +```text +eq +in +prefix +pattern +exists +``` + +`pattern` 只支持 dot-token wildcard,例如 `role.*` 和 `anchor.datum.*`。CADQL 1.0 不支持任意 regex。 + +### 7.6 Metadata predicates + +Metadata 查询必须指定 namespace: + +```json +{ + "property": "semantic.metadata.manufacturing.finish", + "eq": "ground" +} +``` + +未指定 namespace 的自由 key 查询在 strict mode 下非法。 + +## 8. Units 和 Tolerance + +### 8.1 Quantity + +所有有量纲 value 使用: + +```json +{ + "value": 100.0, + "unit": "mm2" +} +``` + +CADQL 1.0 基础单位: + +```text +length: mm, cm, m, in +area: mm2, cm2, m2, in2 +volume: mm3, cm3, m3, in3 +angle: deg, rad +``` + +Query IR 规范化阶段转换为 canonical units: + +```text +length -> mm +area -> mm2 +volume -> mm3 +angle -> rad +``` + +### 8.2 Tolerance + +QueryOptions 提供默认 tolerance profile: + +```json +{ + "linear_abs": {"value": 1e-6, "unit": "mm"}, + "linear_rel": 1e-9, + "angular_abs": {"value": 1e-6, "unit": "rad"}, + "area_abs": {"value": 1e-9, "unit": "mm2"}, + "area_rel": 1e-8, + "volume_abs": {"value": 1e-12, "unit": "mm3"}, + "volume_rel": 1e-8 +} +``` + +Predicate 可以覆盖默认 tolerance。Scalar approximate equality 统一使用: + +```text +abs(a - b) <= max(abs_tolerance, rel_tolerance * max(abs(a), abs(b))) +``` + +Ordering key 的业务同分也使用对应量纲的同一 tolerance,而不是 exact float equality。NaN、infinity 或 property unavailable 在 strict query 中产生 `PropertyUnavailableError`,不能参与排序。Backend 不能自行改变 tolerance 语义,只能声明数值能力低于请求精度并拒绝执行。 + +### 8.3 Coordinate frame + +`geometry.center`、`geometry.bbox`、`geometry.normal`、`geometry.tangent` 以及 query 中的 point/vector 都必须在同一显式 frame 中解释。QueryOptions 默认使用 world frame: + +```json +{ + "frame": {"kind": "world"} +} +``` + +可选 frame: + +```text +world +operation_context +frame_ref +``` + +`operation_context` 使用 scope producer 记录的 coordinate-system snapshot;`frame_ref` 引用 model 的 Frame Graph。Provider 必须先把属性转换到 query frame 再求值。Face entity 本身没有唯一 oriented normal;normal/tangent predicate 必须通过 `relative_to=` 绑定 parent Solid/Shell shape、query 或明确的 FaceUse。Provider 在该 parent occurrence 中计算 oriented normal,再转换到 query frame。 + +## 9. Cardinality 和 Ordering + +### 9.1 Cardinality + +正式类型: + +```text +ANY +ONE +OPTIONAL_ONE +EXACTLY(n) +AT_LEAST(n) +AT_MOST(n) +BETWEEN(min, max) +``` + +Feature selection 不允许使用隐式 `ANY`。例如 fillet 的 edge query 必须明确至少一条 edge,remove-face shell query 必须明确预期数量或范围。 + +Cardinality 只验证完整 pattern、order 和 take 求值后的结果,不执行排序、截断或“选最佳候选”。`.expect_one()` 对两个匹配实体必须失败,即使 query 存在 `order_by`。 + +Validation outcome: + +| 条件 | Error | +| --- | --- | +| `actual == 0` 且 minimum 大于 0 | `NoMatchError` | +| `0 < actual < minimum` | `SelectionCardinalityError` | +| `actual > maximum` | `AmbiguousSelectionError` | +| top-N boundary 出现业务排序同分 | `AmbiguousSelectionError` | + +### 9.2 Ordering + +`take` 必须在 `order_by` 后使用。CADQL 1.0 不提供 `first`、`last`、`offset` 或 `selection_item(index)`;这些写法要么重复 `order_by(...).take(...)`,要么重新引入不稳定 index。 + +合法示例: + +```json +{ + "order_by": [ + {"property": "geometry.center.z", "direction": "desc"}, + {"property": "geometry.center.x", "direction": "asc"}, + {"property": "geometry.area", "direction": "desc"} + ] +} +``` + +禁止使用 backend topology enumeration index 作为默认稳定排序。内部可用 deterministic final tie-breaker,但如果业务排序键全部相同且 query 要求唯一项,必须报告 ambiguity。 + +`take` 只能在显式 `order_by` 后使用。它默认采用 `boundary_ties: error`:如果第 N 项与第 N+1 项的全部业务排序键相同,则产生 `AmbiguousSelectionError`。内部 entity token 只能保证 deterministic materialization,不能用来打破业务并列。 + +### 9.3 Set semantics 和 ambiguity + +Query 的自然结果始终是去重集合。Cardinality 决定该集合是否可接受: + +- `EXACTLY(4)` 返回四个实体是成功,不是 ambiguity。 +- `ONE` 返回两个实体时是 `AmbiguousSelectionError`。 +- `AT_LEAST(1)` 返回多个实体是成功。 +- `take(1)` 在业务排序边界同分时是 `AmbiguousSelectionError`。 + +因此 CADQL 1.0 不增加独立的 `ALLOW_SET`/`ERROR` policy。`PICK_FIRST` 也不进入 canonical contract。Python 临时探索 API 可以提供显式 unsafe helper,但不得写入 canonical model graph。 + +### 9.4 Result algebra + +Match 结束后,所有 query 共用同一结果代数: + +```text +distinct +union / intersection / difference +order_by +take +expect +partition_by +order_each_by +take_each +expect_each_one / expect_each_exactly +``` + +规则: + +- 集合运算两侧必须是兼容的 `SelectionQuery[T]`,结果仍是 `SelectionQuery[T]`。 +- 去重依据是 current entity identity 或 TopoUse identity,不是 geometry fingerprint。 +- `order_by` 只建立业务顺序;`take` 才缩小集合。 +- `expect` 只验证最终集合,不选择候选。 +- 每个 operation 执行后类型都不改变,resolve 后统一返回 `SelectionSet[T]`。 + +`group_by()` 不进入 CADQL 1.0 public API,因为它会引入第二种 public result shape。需要“每个 group 选择一个”的 case 使用 CADQL 1.0 correlated partition stage: + +```python +longest_edge_per_hole = ( + cadql.select(cadql.Coedge, in_=mounting_face) + .where(cadql.topology.loop_role.eq("inner")) + .partition_by(cadql.topology.loop_identity) + .order_each_by(cadql.length.desc()) + .take_each(1, boundary_ties="error") + .expect_each_one() +) +``` + +该 query 的 resolved result 仍是扁平 `SelectionSet[Coedge]`;partition key 和每组 cardinality 进入 evidence。若 feature 消费 underlying Edge,再用一个普通 `select(Edge).where(related(use_entity, ...))` query。Partition stages 只改变内部求值方式,不产生 `SelectionGroup` public result。 + +## 10. Query IR 1.0 + +### 10.1 顶层结构 + +```json +{ + "schema_version": "1.0", + "variables": { + "result": {"type": "face", "domain": "current"} + }, + "match": { + "node": "result", + "where": { + "property": "semantic.tags", + "tag": { + "scope": "effective", + "op": "eq", + "value": "role.mounting_surface" + } + } + }, + "return": "result", + "cardinality": {"kind": "one"}, + "projection": ["ref"], + "options": { + "frame": {"kind": "world"} + } +} +``` + +### 10.2 Variables 和 binding + +Variable domain: + +```text +current 当前 scope snapshot 中的 entity/use +history scope 可达的历史 EntityVersion +change Topology Evolution Graph 中的 Change +operation Operation Graph node +bound_source 外部 binding 提供的 entity set 或 operation output +``` + +`current` domain 已经表示由 `scope` binding 建立的 candidate domain;IR 不再额外生成 `contains(scope, result)` constraint。`bound_source` variable 必须通过 `from_binding` 关联 named binding,relation/path endpoint 只引用已声明 variable,不直接引用 binding name。 + +Query IR 外部的 runtime binding: + +```json +{ + "bindings": { + "scope": { + "kind": "operation_output", + "node_id": "node_result", + "output_slot": 0 + }, + "box_source": { + "kind": "operation_output", + "node_id": "node_box", + "output_slot": 0 + }, + "cylinder_source": { + "kind": "operation_output", + "node_id": "node_cylinder", + "output_slot": 0 + } + } +} +``` + +Graph node 内使用 named input bindings,避免 query params 重复持有 node ID: + +```json +{ + "bindings": { + "scope": {"kind": "input", "port": "scope"}, + "box_source": {"kind": "input", "port": "box_source"}, + "cylinder_source": {"kind": "input", "port": "cylinder_source"} + } +} +``` + +Binding source 可以是 Shape output、SelectionSet output 或 operation reference。后者用于 source operand、feature target 和 semantic source query。Binding 是 query execution identity 的一部分,但不进入 normalized pattern hash。 + +后续 binding 可增加: + +```text +selection_output +assembly_component +product_part +explicit_entity_refs +semantic_query +``` + +Explicit refs 只能作为 legacy/evidence binding,不能被描述为重建出的 semantic intent。 + +### 10.3 Pattern constraints + +Canonical constraints: + +```text +node variable 的 property/semantic predicate +relation 两个 variable 之间的一条 typed relation +path 两个 variable 之间有界或传递的 typed relation path +change EntityVersion/Change/Operation 的 evolution pattern +and/or/not 组合 constraints +exists/count 局部 variable quantifier +``` + +Box/cylinder union 后选择交界 edges 的完整例子: + +```json +{ + "schema_version": "1.0", + "variables": { + "edge": {"type": "edge", "domain": "current"}, + "change": {"type": "change", "domain": "change"}, + "box_face": {"type": "face", "domain": "bound_source", "from_binding": "box_source"}, + "cylinder_face": {"type": "face", "domain": "bound_source", "from_binding": "cylinder_source"}, + "box_current_face": {"type": "face", "domain": "current"}, + "cylinder_current_face": {"type": "face", "domain": "current"} + }, + "match": { + "and": [ + { + "change": { + "variable": "change", + "inputs": [ + { + "variable": "box_face", + "input_port": "subjects", + "role": "support", + "endpoint_quantifier": "any_member" + }, + { + "variable": "cylinder_face", + "input_port": "tools", + "role": "tool", + "endpoint_quantifier": "any_member" + } + ], + "outputs": [ + { + "variable": "edge", + "derivation": ["intersection"] + } + ] + } + }, + { + "relation": "incident_faces", + "from": "edge", + "to": "box_current_face", + "relation_quantifier": {"kind": "at_least", "value": 1} + }, + { + "path": { + "from": "box_current_face", + "relation": "descends_from", + "to": "box_face", + "min_depth": 1, + "max_depth": 64, + "to_quantifier": "any_member" + } + }, + { + "relation": "incident_faces", + "from": "edge", + "to": "cylinder_current_face", + "relation_quantifier": {"kind": "at_least", "value": 1} + }, + { + "path": { + "from": "cylinder_current_face", + "relation": "descends_from", + "to": "cylinder_face", + "min_depth": 1, + "max_depth": 64, + "to_quantifier": "any_member" + } + } + ] + }, + "return": "edge", + "cardinality": {"kind": "exactly", "value": 3}, + "projection": ["ref", "lineage"] +} +``` + +该 query 不引用 circle、ellipse、length、center 或 backend topology index。它选择的是三条曲线在联合 topology/evolution graph 中的角色。 + +原 edge 经任意后续 feature 分裂后,选择其 current fragments: + +```json +{ + "schema_version": "1.0", + "variables": { + "edge": {"type": "edge", "domain": "current"}, + "source_edge": {"type": "edge", "domain": "bound_source", "from_binding": "source_edge"} + }, + "match": { + "path": { + "from": "edge", + "relation": "descends_from", + "to": "source_edge", + "derivation": ["continuation", "fragment"], + "min_depth": 1, + "max_depth": 64, + "to_quantifier": "any_member" + } + }, + "return": "edge", + "cardinality": {"kind": "at_least", "value": 1}, + "projection": ["ref", "lineage"] +} +``` + +同样的 pattern 适用于 boolean split、fillet、chamfer、shell 或后续 trim;query 不检查 operation name。 + +### 10.4 Public builder lowering + +Public builder 只有一个入口。`select/where/order_by/take/expect` normalize 后 lower 到 canonical variables + constraints: + +```text +select(Face, in_=body).where(P) + +=> bind body as scope + seed candidate domain Face(current), including an applicable root + node predicate P(result) +``` + +另一个 query 作为 `in_` 时建立其 current subgraph candidate domain: + +```text +select(Edge, in_=face_query) + +=> bind face_query SelectionSet[Face] as scope + seed Edge(current) from each selected Face subgraph +``` + +非 containment 关系不通过改变入口表达: + +```text +select(Face, in_=body).where( + related(adjacent_faces, from_=seed_faces, to=this) +) + +=> bind seed_faces + relation adjacent_faces(seed, result) +``` + +Cardinality 是顶层 postcondition,不是可插入中间的 operation。多个各自需要 cardinality contract 的 query 通过内部 `SelectionSet` graph binding 组合;public Python composition 仍传入 `SelectionQuery[T]`,不要求用户提前 `resolve()`。 + +### 10.5 Projection + +Projection 只影响 query result/evidence,不影响 match: + +```json +[ + "ref", + "semantic.tags", + "geometry.area", + "geometry.center", + { + "property": "geometry.normal", + "relative_to_binding": "body" + } +] +``` + +Oriented property projection 必须使用结构化 item,并携带 parent-relative binding;裸字符串 `geometry.normal`/`geometry.tangent` 不合法。 + +Canonical feature graph 至少要求 projection 包含 `ref`。 + +### 10.6 Query-to-query binding lowering + +Query composition 不引入 Anchor 或 Context payload。每个被引用的 query 都是一个独立 Query IR object;引用方通过 named input port 绑定其 `SelectionSet` output。 + +```python +source_face = ( + cadql.select(cadql.Face, in_=before_boolean) + .where(cadql.tag("role.mounting_surface", scope="effective")) + .expect_one() +) + +current_faces = ( + cadql.select(cadql.Face, in_=after_boolean) + .where( + cadql.related( + cadql.descends_from, + from_=cadql.this, + to=source_face, + derivations=("continuation", "fragment"), + depth=(1, 64), + ) + ) + .expect_at_least(1) +) +``` + +Lowering: + +```text +query source_face -> SelectionSet[Face] +query current_faces input source_face: SelectionSet[Face] + +F: Face(current) +S: Face(bound_source) +F path(descends_from, derivation in {CONTINUATION, FRAGMENT}) S +return distinct F +``` + +局部 boundary query 同样只增加普通 bindings 和 relations: + +```python +coedges = ( + cadql.select(cadql.Coedge, in_=after_boolean) + .where(cadql.related(cadql.boundary_uses, from_=current_faces, to=cadql.this)) +) +``` + +若 query 使用 `local.*` property,IR 额外声明 `SurfaceChart` variable:Face query 通过 `surface_chart` 绑定 chart,Coedge 通过 `use_chart` 绑定同一个 chart。Chart/frame evidence 属于该 query binding,不属于新的持久化 object kind。`MERGE`/`REPLACEMENT` 默认不传播 chart;validation 要求调用方提供新的 chart/frame binding。 + +## 11. GraphQL-like Text Syntax + +推荐文本前端示例: + +```graphql +query MountingFace($body: NodeRef!) { + brep(node: $body) { + matches: match( + type: FACE + where: { + and: [ + { + semantic: { + tag: { + scope: EFFECTIVE + matches: "role.mounting_surface" + } + } + } + { + geometry: { + surfaceType: { eq: PLANE } + normal: { + sameDirectionAs: [0, 0, 1] + angleTolerance: { value: 0.1, unit: DEG } + relativeTo: $body + } + } + } + ] + } + expect: ONE + ) { + ref + semantic { tags } + geometry { area center } + } + } +} +``` + +语法约束: + +- `match(type: FACE)` 负责 entity matching;文档保持为合法 GraphQL document subset,而不是发明 `match Face` 语法。 +- `{ ref geometry semantic }` 是 projection selection set。 +- `where` 编译为 predicate AST。 +- `expect` 编译为 cardinality。 +- `orderBy` 编译为 deterministic order stage。 +- GraphQL fragments 只复用 projection;predicate 复用使用 typed input variables,因为标准 GraphQL fragment 不能展开 input object。 +- directives 仅用于 debug/evidence 等非选择语义,不能改变 entity identity。 + +CADQL 1.0 parser 可后置实现。第一阶段以 Python builder 和 JSON IR 为主。 + +## 12. Python Builder API + +目标 API: + +```python +from simplecadapi import cadql + +mounting_face = ( + cadql.select(cadql.Face, in_=body) + .where( + cadql.tag("role.mounting_surface", scope="effective") + & cadql.surface_type.eq("plane") + & cadql.normal.same_direction_as( + (0.0, 0.0, 1.0), + angle_tolerance=cadql.deg(0.1), + relative_to=body, + ) + ) + .expect_one() +) +``` + +执行: + +```python +result = mounting_face.resolve() +face = result.one() +``` + +直接用于 feature: + +```python +result = shell_rsolid( + body, + faces_to_remove=mounting_face, + thickness=2.0, +) +``` + +Active `GraphSession` 中,feature 必须记录 Query IR,不允许只记录 resolved concrete face。 + +### 12.1 所有 query 返回同一种结果 + +`resolve()` 返回: + +```python +SelectionSet[Face] +``` + +它包含: + +```text +entities +refs +evidence +diagnostics +query_hash +``` + +`SelectionSet[T]` 是所有 query 的唯一 resolved result。它携带 entities、refs 和 execution evidence;不存在针对 Anchor、Context、history query 或 property query 的其他 result class。禁止通过普通 list slicing 隐式改变 canonical query semantics。 + +### 12.2 六种 match case 的统一表达 + +以下片段假定 `face_a`、`face_b`、`seed_edges`、`source_edge`、`box_faces` 和 `cylinder_faces` 是前面通过同一个 `cadql.select(...)` API 定义的 typed `SelectionQuery[T]` bindings。 + +类型全集: + +```python +faces = cadql.select(cadql.Face, in_=body).expect_at_least(1) +``` + +属性约束,无论 property 来自 semantic、geometry、spatial 还是 local namespace,都使用 `.where(...)`: + +```python +mounting_faces = ( + cadql.select(cadql.Face, in_=body) + .where( + cadql.tag("role.mounting_surface", scope="effective") + & cadql.surface_type.eq("plane") + ) + .expect_at_least(1) +) +``` + +Current topology relation,单步形式: + +```python +shared_edges = ( + cadql.select(cadql.Edge, in_=body) + .where( + cadql.related(cadql.incident_faces, from_=cadql.this, to=face_a) + & cadql.related(cadql.incident_faces, from_=cadql.this, to=face_b) + ) + .expect_at_least(1) +) +``` + +同一个 current topology relation case 的 path 形式: + +```python +tangent_chain = ( + cadql.select(cadql.Edge, in_=body) + .where( + cadql.related( + cadql.adjacent_edges, + from_=seed_edges, + to=cadql.this, + step_where=cadql.step.tangent_continuous( + angle_tolerance=cadql.deg(0.1), + ), + depth=(0, 64), + ) + ) + .expect_at_least(1) +) +``` + +Oriented occurrence: + +```python +outer_coedges = ( + cadql.select(cadql.Coedge, in_=body) + .where( + cadql.related(cadql.boundary_uses, from_=mounting_faces, to=cadql.this) + & cadql.topology.loop_role.eq("outer") + ) + .expect_at_least(1) +) +``` + +Evolution relation: + +```python +current_fragments = ( + cadql.select(cadql.Edge, in_=result) + .where( + cadql.related( + cadql.descends_from, + from_=cadql.this, + to=source_edge, + derivations=("continuation", "fragment"), + depth=(1, 64), + ) + ) + .expect_at_least(1) +) +``` + +Multi-source Change pattern: + +Box/cylinder union 后,不依赖 curve 类型选择交界 edges: + +```python +interface_edges = ( + cadql.select(cadql.Edge, in_=result) + .where( + cadql.change( + inputs=( + cadql.change_input( + box_faces, + input_port="subjects", + role="support", + endpoint_quantifier="any_member", + ), + cadql.change_input( + cylinder_faces, + input_port="tools", + role="tool", + endpoint_quantifier="any_member", + ), + ), + output=cadql.this, + derivation="intersection", + ) + ) + .expect_exactly(3) +) +``` + +`cadql.change(...)` 直接对应 canonical Change constraint。每个 `change_input(...)` 必须声明 input port、role 和 `endpoint_quantifier`;`any_member` 表示 source query 中至少一个 Face 是该 Change input,不表示整个 Face set 被当作一个 graph node。它不检查 producer operation 是否名为 union,也不检查 edge 是 circle 或 ellipse。 + +选择由 source edge 参与生成、但不一定与 source 同维度连续的 current boundaries,仍是普通 evolution relation: + +```python +generated_boundaries = ( + cadql.select(cadql.Edge, in_=result) + .where( + cadql.related( + cadql.generated_from, + from_=cadql.this, + to=source_edge, + derivations=("boundary", "intersection"), + depth=(1, 64), + ) + ) + .expect_at_least(1) +) +``` + +`descends_from` 是较强的材料/拓扑延续关系;`generated_from` 和 `depends_on` 是更宽的因果关系。调用者必须选择符合意图的关系,不能把所有 parent refs 都称作 descendants。 + +### 12.3 Query composition,不创建 Anchor 或 Context + +先定义 source face query: + +```python +mounting_surface = ( + cadql.select(cadql.Face, in_=body) + .where(cadql.tag("role.mounting_surface", scope="effective")) + .expect_one() +) +``` + +在 topology-changing operation 后,显式定义 current descendants query: + +```python +current_mounting_faces = ( + cadql.select(cadql.Face, in_=result) + .where( + cadql.related( + cadql.descends_from, + from_=cadql.this, + to=mounting_surface, + derivations=("continuation", "fragment"), + depth=(1, 64), + ) + ) + .expect_at_least(1) +) +``` + +再通过普通 current relations 选择 boundary occurrences 和 underlying entities: + +```python +outer_coedges = ( + cadql.select(cadql.Coedge, in_=result) + .where( + cadql.related( + cadql.boundary_uses, + from_=current_mounting_faces, + to=cadql.this, + ) + & cadql.topology.loop_role.eq("outer") + ) + .expect_at_least(1) +) + +outer_edges = ( + cadql.select(cadql.Edge, in_=result) + .where(cadql.related(cadql.use_entity, from_=outer_coedges, to=cadql.this)) + .expect_at_least(1) +) +``` + +这里 lower 为: + +```text +current face descends_from mounting_surface query result +AND current face boundary_uses coedge +AND coedge references returned edge +``` + +在 topology change 前的唯一 planar Face chart 中使用稳定局部坐标,仍然是相同 query 形态: + +```python +right_side = ( + cadql.select(cadql.Coedge, in_=body) + .where( + cadql.related( + cadql.boundary_uses, + from_=mounting_surface, + to=cadql.this, + ) + & cadql.local.u.approximately( + cadql.local.u_range.max, + in_chart=mounting_surface, + ) + ) + .expect_one() +) +``` + +如果 source face 被 split,`current_mounting_faces.resolve()` 自然返回 `SelectionSet[Face]`。如果业务要求恰好三个 fragments,就在这个 query 上写 `.expect_exactly(3)`;不需要 `FaceFamily` result type 或 `expect_faces_exactly` 专用方法。跨多个 fragments 使用 local UV 时,Coedge query 必须先 `.partition_by(cadql.use_chart)`,并在每个 partition 内绑定对应的 `ONE` Face chart;不能把 `AT_LEAST(1)` Face query 当作单一 chart。 + +Box/cylinder 交界的三个 box faces 不共面,也仍是普通 Face query: + +```python +box_faces = ( + cadql.select(cadql.Face, in_=box) + .expect_at_least(1) +) + +cylinder_faces = ( + cadql.select(cadql.Face, in_=cylinder) + .expect_at_least(1) +) + +interface_edges = ( + cadql.select(cadql.Edge, in_=result) + .where( + cadql.change( + inputs=( + cadql.change_input( + box_faces, + input_port="subjects", + role="support", + endpoint_quantifier="any_member", + ), + cadql.change_input( + cylinder_faces, + input_port="tools", + role="tool", + endpoint_quantifier="any_member", + ), + ), + output=cadql.this, + derivation="intersection", + ) + ) + .expect_exactly(3) +) +``` + +每个 face 有自己的 SurfaceChart。上例没有跨 chart 比较 UV,只使用 Change source bindings,因此仍是 geometry-independent pattern。 + +### 12.4 Indexed getter 的未来行为 + +`get_faces(0)` 等旧 API 暂时保留,但标记为 convenience/legacy selection。它不能被诚实地转换为 CADQL 1.0 semantic pattern,GraphSession 在迁移期继续记录 legacy materialized selection: + +```text +legacy_selection( + basis=backend_enumeration, + index=0, + evidence=fingerprint +) +``` + +该结构是 model schema 的 legacy extension,不属于 Query IR 1.0。文档和 examples 逐步迁移到 semantic/topology/lineage CADQL。 + +## 13. Operation Graph 集成 + +### 13.1 新 canonical operation + +建议增加: + +```text +query_brep_rselection +``` + +只定义这一个 canonical query operation。Result entity type 是 Query IR 和 typed output 的参数,不再按 Face/Edge/Vertex 拆分 `query_brep_rfaces`、`query_brep_redges` 等平行 operations。 + +Node 结构: + +```json +{ + "op": "query_brep_rselection", + "inputs": [ + {"port": "scope", "node_id": "node_result", "output_slot": 0}, + {"port": "box_source", "node_id": "node_box", "output_slot": 0}, + {"port": "cylinder_source", "node_id": "node_cylinder", "output_slot": 0} + ], + "params": { + "query_object": "query_box_cylinder_interface", + "query_hash": "sha256:..." + }, + "outputs": [ + {"port": "result", "type": "selection_set", "entity_type": "edge"} + ] +} +``` + +`query_box_cylinder_interface` 是 model 顶层 `query_objects` 中的完整 normalized Query IR,即 10.3 的完整 pattern,包括两个 current incident-face variables 和 source lineage constraints。Graph node 不保存语义缩水的第二份 query 副本。 + +该 node 输出 `SelectionSet`,不是复制出来的 shape object。 + +这里有一个 typed SelectionSet output,而不是按匹配实体数量生成多个 graph outputs。实际 cardinality 位于 Query IR。 + +### 13.2 Operation Graph 类型桥接 + +当前 `OperationNode` 只有无名称的 `inputs` 和 `output_count`,不能表达 SelectionSet 与 Shape 的类型区别。引入 CADQL 时,model schema 必须增加: + +```json +{ + "inputs": [ + {"port": "body", "node_id": "node_box", "output_slot": 0}, + {"port": "edges", "node_id": "node_query", "output_slot": 0} + ], + "outputs": [ + {"port": "result", "type": "solid"} + ] +} +``` + +Operation registry 定义静态 signature,例如: + +```text +query_brep_rselection(scope: Shape, sources: BindingMap) -> SelectionSet +query_brep_rselection(scope: SelectionSet, sources: BindingMap) -> SelectionSet +make_fillet_rsolid(body: Solid, edges: SelectionSet) -> Solid +make_shell_rsolid(body: Solid, faces_to_remove: SelectionSet) -> Solid +apply_tag_rselection(scope: Shape, targets: SelectionSet, tag: Tag) -> Shape +``` + +Importer 只能在 output slot 可恢复时把 2.0 graph 的 positional inputs 按 operation signature 升级为 named ports;无法恢复 slot 的多输出引用必须保留为 legacy binding 并报告 migration diagnostic,不能猜测。Exporter 在新 model schema 中不再用 params 中的 `selected_*_node_ids` 表达数据流。 + +该变更需要将 model/graph schema 从 `2.0` 升级为 `3.0`;Query IR 仍独立使用 `1.0`。Package 版本与 schema 版本不绑定。 + +### 13.3 Feature 消费 SelectionSet + +例如 shell graph: + +```text +body -----------------------> shell + \ / + -> query_brep_rselection -- +``` + +Feature params 不再新增并行的: + +```text +selected_faces +selected_face_node_ids +selected_face_indices +selector_hint +selection_query +``` + +新 graph 只通过 typed input 消费 selection set。Legacy importer 可以将旧字段升级成 Query IR + evidence/fallback。 + +### 13.4 Semantic tag persistence + +Semantic query 要可 replay,tag assignment 必须进入 Operation Graph,而不能只修改 Python wrapper 的 `_tags`。新增 canonical alias operation: + +```text +apply_tag_rselection( + scope: Shape, + targets: SelectionSet, + tag: Tag, + propagation: LOCAL | DOWNWARD, +) -> Shape +``` + +该 operation 不改变 geometry,返回具有新 semantic state 的 shape view。Root shape tagging 被 lower 为选择 scope root 的 SelectionSet。自动 tagging 也生成相同的 canonical `TagBinding`,但可作为 producer operation 的 semantic output metadata 存储,避免创建大量可见 node。 + +TagBinding 保存 assignment node、target query/ref、attachment 和 propagation policy。执行 CADQL 时,provider 以当前 scope producer 为版本边界计算 local/inherited/effective tags。下游 geometry operation 必须通过 topology delta 传播 tag binding;没有 lineage evidence 时不得猜测 lineage tag。 + +Legacy 2.0 payload 中只有扁平 tags,导入时统一标为 `effective_legacy`。它们只参与 `scope: effective` 查询并产生 migration warning,不能伪造 local/inherited 来源。 + +### 13.5 是否保留 `make_select_*` + +迁移期保留: + +```text +make_select_rvertex +make_select_redge +make_select_rwire +make_select_rface +make_select_rsolid +``` + +它们被定义为 legacy materialized selection node。新代码不再为 query 的每个 match 自动生成一个 `make_select_*` node。 + +迁移稳定后只保留 explicit one-entity materialization 作为 legacy graph adapter;CADQL 不增加 `selection_item_rentity`,避免通过另一名称恢复 index selection。 + +## 14. Query Execution + +### 14.1 执行阶段 + +统一执行器流程: + +```text +parse/build +-> normalize +-> type validate +-> capability validate +-> compile execution plan +-> resolve named bindings +-> build current BRep snapshot +-> load reachable Topology Evolution Graph +-> seed candidates for typed variables +-> join topology/evolution/provenance constraints +-> evaluate semantic and optional geometry predicates +-> project and distinct the return variable +-> order +-> take +-> cardinality validation +-> build refs/evidence +-> return SelectionSet[T] +``` + +### 14.2 Property provider + +执行器不直接散落调用 OCP/FreeCAD API。定义 backend-neutral provider protocol: + +```python +class BRepQueryProvider(Protocol): + def resolve_binding(self, binding: QueryBinding) -> object: ... + def snapshot(self, scope: object) -> BRepSnapshot: ... + def evolution_graph(self, scope: object) -> TopologyEvolutionGraph: ... + def related(self, entity: object, relation: Relation) -> Sequence[object]: ... + def property(self, entity: object, property_id: str) -> object: ... + def tags(self, entity: object, scope: TagScope) -> Sequence[str]: ... + def evidence_ref(self, entity: object) -> TopoRef: ... + def entity_token(self, entity: object) -> object: ... + def use_token(self, use: object) -> object: ... +``` + +`entity_token` 只在一次 provider execution 内用于未定向 entity identity、`distinct` 和 deterministic materialization。OCP provider 应使用 topology identity (`IsSame` 等价语义),不能使用 Python wrapper identity。`use_token` 还包含 parent、orientation 和 occurrence。两种 token 都不持久化,也不能作为业务 tie-breaker;`TopoRef` 只进入 evidence/fallback,不被命名为 durable stable identity。 + +实现: + +```text +OCPQueryProvider +FreeCADQueryProvider +``` + +两者运行同一 Query IR conformance tests。 + +### 14.3 Determinism + +执行器必须保证: + +- `distinct` 的 equality semantics 明确。 +- order keys 逐项稳定比较。 +- 缺失 property 的排序行为明确,默认 validation error。 +- query hash 由 normalized IR 生成,与 dict insertion order 无关。 +- 同一 provider、相同 BRep 和相同 Query IR 返回相同 ordered refs。 + +### 14.4 History capability boundary + +Provider 对每个 scope 声明 history level: + +```text +NONE 只有当前 BRep snapshot,例如无 sidecar 的 STEP 导入。 +PARTIAL 有部分 operation/topology parent evidence。 +COMPLETE 查询可达范围内的 Change graph 满足 canonical completeness checks。 +``` + +Current topology/semantic/geometry pattern 在 `NONE` 下仍可执行。任何引用 history/change domain 或 evolution path 的 query 必须在 validation 阶段计算所需 evidence coverage: + +- `NONE`:current topology/semantic/geometry query 和同一 snapshot 的 query-to-query relation 可以执行;只有跨 snapshot evolution path 产生 `UnsupportedQueryCapabilityError`。 +- `PARTIAL`:只有 query 所需 operation/path 均有完整 evidence 时才能执行,否则失败。 +- `COMPLETE`:按正常 graph matcher 执行。 + +缺失 history 不是 predicate false,也不能自动切换到 geometry fingerprint。用户可以显式提供 semantic tag sidecar 或选择 current-space query,但系统不能声称恢复了不存在的建模历史。 + +## 15. Evidence 和 Drift Detection + +### 15.1 Evidence schema + +```json +{ + "query_hash": "sha256:...", + "binding_hash": "sha256:...", + "execution_hash": "sha256:...", + "provider": "ocp", + "provider_version": "7.9.3", + "selected": [ + { + "ref": { + "graph_id": "graph_1", + "node_id": "node_box", + "output_slot": 0, + "kind": "FACE", + "topo_id": "face_7" + }, + "properties": { + "geometry.surface_type": "plane", + "geometry.area": {"value": 245.0, "unit": "mm2"}, + "geometry.normal": { + "value": [0, 0, 1], + "relative_to_binding": "body" + }, + "semantic.tags": ["role.mounting_surface"] + }, + "fingerprint": { + "bbox": {}, + "center": [0, 0, 10] + } + } + ], + "candidate_count": 1, + "runner_up": null, + "diagnostics": [] +} +``` + +Hash 定义: + +- `query_hash`:normalized typed graph pattern、cardinality、ordering、projection 和 options,不含具体 node IDs。 +- `binding_hash`:所有 named bindings 的 graph/node/output/selection identity 的 canonical hash。 +- `execution_hash`:`query_hash + binding_hash + provider semantic version + scope content fingerprint`。 + +Evidence 中的 `TopoRef.topo_id` 和 geometry fingerprint 只描述一次 execution 的结果,不是 durable query identity。 + +### 15.2 Replay behavior + +Replay: + +1. 重新执行 Query IR。 +2. 验证 cardinality 和 ordering boundary ties。 +3. 比较 query result 与旧 evidence。 +4. `drift_mode=diagnose` 时,如果 execution identity 改变但 pattern 仍满足,记录 `selection_drift` diagnostic。 +5. `drift_mode=strict` 时,同一情况产生 `SelectionDriftError`。 +6. 如果 query 不再满足,不自动按旧 fingerprint 选择,除非 legacy query options 显式允许 fallback。 + +### 15.3 Fallback policy + +```text +NONE +EVIDENCE_FINGERPRINT +LEGACY_INDEX +``` + +Canonical feature query 默认 `NONE`。Legacy model migration 可使用 `EVIDENCE_FINGERPRINT` 或 `LEGACY_INDEX`,并生成 warning。 + +## 16. Error Model + +统一错误: + +| Error | 条件 | +| --- | --- | +| `QuerySyntaxError` | 文本或 JSON IR 语法不合法。 | +| `QueryValidationError` | 类型、relation、property 或 unit 不合法。 | +| `UnsupportedQueryCapabilityError` | provider/backend 不支持所需能力。 | +| `QueryScopeError` | source node/output 不存在或类型错误。 | +| `SelectionCardinalityError` | 非零结果仍低于 cardinality minimum。 | +| `NoMatchError` | `SelectionCardinalityError` 的特例:结果为零且 minimum 大于零。 | +| `AmbiguousSelectionError` | 结果超过 cardinality maximum,或 top-N boundary 无法唯一确定。 | +| `PropertyUnavailableError` | 实体上无法稳定计算请求属性。 | +| `SelectionDriftError` | strict replay 中结果相对 evidence 发生不允许的漂移。 | +| `QueryComplexityError` | Pattern variable、constraint、path 或 candidate 数量超过限制。 | +| `LegacySelectionError` | Legacy index/fingerprint selector 缺少 basis 或无法解析。 | + +所有错误必须包含的公共 payload: + +```text +query_location +message +error_code +repair_suggestions +``` + +可用时附加 `query_hash`、`binding_hash`、scope、expected/actual cardinality、candidate summaries、unsupported capability 和 source location。Syntax error 不要求提供尚不能计算的 hash 或 candidates。 + +## 17. Translator Contract + +### 17.1 Backend capability + +Translator `capabilities.py` 增加: + +```json +{ + "query_ir_versions": ["1.0"], + "query_entity_kinds": [ + "compound", "compsolid", "solid", "shell", "face", "wire", "edge", "vertex", "topo_use", "coedge" + ], + "query_domains": ["current", "history", "change", "operation", "bound_source"], + "query_relations": [ + "contains_direct", "contains", "incident_faces", "adjacent_faces", "adjacent_edges", + "boundary_uses", "use_entity", "use_parent", "use_chart", "surface_chart", + "next_coedge", "previous_coedge", "mate_coedge", "starts_at", "ends_at", "belongs_to_loop", + "descends_from", "generated_from", "depends_on", "co_result_of", "changed_with" + ], + "history_level": "complete", + "use_history": "ordered", + "evolution_derivations": [ + "continuation", "fragment", "merge", "intersection", "boundary", "replacement" + ], + "query_properties": [ + "geometry.length", + "geometry.area", + "geometry.volume", + "geometry.center", + "geometry.normal", + "geometry.curve_type", + "geometry.surface_type", + "topology.loop_role", + "topology.loop_identity", + "local.u_range.min", + "local.u_range.max", + "semantic.tags" + ] +} +``` + +### 17.2 FreeCAD translator + +FreeCAD 不再维护独立 selector scoring contract,而是实现 `FreeCADQueryProvider`。 + +`query_brep_rselection` runtime: + +1. 从 `GRAPH_NODES[source_node_id]` 取得 shape。 +2. 使用统一 Query IR evaluator。 +3. 返回 `GRAPH_SELECTIONS[node_id]` 中的 typed selection result。 +4. 默认不为每个 selection 创建 visible `Part::Feature`。 +5. 如果需要 materialized helper object,放入 construction group 并隐藏。 +6. fillet/chamfer/shell 直接将 SelectionSet refs 转换为 FreeCAD `EdgeN/FaceN`。 + +### 17.3 Backend parity + +同一 fixture 必须在 OCP 和 FreeCAD provider 上满足: + +- result cardinality 一致。 +- semantic tags 匹配一致。 +- geometry predicate tolerance 一致。 +- normal orientation semantics 一致。 +- ambiguity outcome 一致。 +- Change input roles 和 output derivations 一致。 +- `descends_from/generated_from/depends_on` path result 一致。 + +不要求 backend 的 raw topology index 一致。 + +## 18. Security 和复杂度限制 + +CADQL 不是任意 Python 执行环境。 + +禁止: + +- Python lambda/callable predicate 进入 canonical IR。 +- 任意 module/class introspection。 +- 任意 regex。 +- unbounded recursive traversal。 +- backend-specific property path 注入。 + +QueryOptions 限制: + +```text +max_pattern_variables +max_pattern_constraints +max_path_changes +max_candidate_count +max_projection_fields +max_fragment_expansion +execution_timeout +``` + +超限产生 `QueryComplexityError`。 + +## 19. Versioning + +Query IR 独立版本: + +```text +query.schema_version = 1.0 +``` + +它不与 model schema version 强绑定。Model schema 声明支持的 Query IR version 范围。 + +Version policy: + +- 新增 optional property/predicate:minor-compatible。 +- 改变 predicate、normal、tag scope、cardinality 语义:major change。 +- Provider capability 可以小于完整 schema,但必须显式声明。 + +## 20. 迁移方案 + +### Phase 0:Characterization + +目标:锁住当前 behavior 和问题。 + +工作: + +- 收集当前 QL、indexed getter、tag selection、`make_select_*` fixtures。 +- 增加对称 geometry、nested selection、Compound、pattern、fillet/chamfer/shell tests。 +- 增加 box/cylinder boolean interface、edge split、face trim、split/merge lineage characterization fixtures。 +- 审计每个 topology-changing operation 当前能提供的 input/output parent mapping;区分 complete、partial 和 absent evidence。 +- 增加 OCP replay 与 FreeCAD translator parity tests。 +- 不修改 public API。 + +退出标准: + +- 当前每种 selection path 都有 model JSON fixture。 +- 已知 ambiguity 和 backend mismatch 有明确 failing test。 + +### Phase 1:Typed Graph Pattern IR + +新增模块建议: + +```text +src/simplecadapi/cadql/ +├── __init__.py +├── types.py +├── predicates.py +├── relations.py +├── ir.py +├── validation.py +├── normalize.py +├── errors.py +└── provider.py +``` + +实现: + +- Query IR dataclasses 和 JSON schema。 +- Variable、topology relation、evolution path 和 pattern constraint registry。 +- Property/relation applicability registry。 +- Unit/tolerance normalization。 +- Cardinality 和 set semantics。 +- Query hash。 +- 不实现 GraphQL parser。 + +退出标准: + +- JSON IR round-trip 稳定。 +- invalid variable/relation/path/property/unit 在执行前失败。 +- normalized IR hash 稳定。 + +### Phase 2:Topology Evolution Evidence + +目标:为 geometry-independent history query 建立可靠事实源。 + +实现: + +- 将当前 `TopoDelta` 扩展为显式 Change inputs/outputs。 +- 增加 `TopoUseVersion`、`USE_ENTITY/USE_PARENT` 和可选 ordered use-fragment evidence。 +- 每个 input 记录 entity ref、input port 和 role。 +- 每个 output 记录 entity ref、event 和 derivation。 +- 为 boolean、fillet、chamfer、shell、cut、transform 和 pattern 建立 topology history adapter。 +- 区分 `TopoEntityVersion` 与 oriented `TopoUse`。 +- 序列化完整 parent refs,不只保存宏观 generated/modified lists。 +- Capability 按 operation 和 derivation 分类声明 complete/partial/unsupported。 + +退出标准: + +- split、merge、intersection、boundary 和 continuation fixtures 可重建 Change graph。 +- Seam/coedge fixture 能区分 underlying entity ancestry 与 occurrence ancestry;不支持 use ordering 时 capability 明确为 partial。 +- 同一 fixture 的 OCP record-time evidence 可 JSON round-trip。 +- 证据不完整时 lineage query 在执行前失败,不做 geometry fallback。 + +### Phase 3:OCP Pattern Evaluator 和 Python Builder + +新增: + +```text +cadql/builder.py +cadql/evaluator.py +cadql/providers/ocp.py +``` + +实现: + +- Solid/Face/Wire/Edge/Vertex topology pattern。 +- Current/history/change domains 和 typed joins。 +- `descends_from`、`generated_from`、`depends_on` 和 Change constraints。 +- 基础 geometry properties。 +- semantic tags。 +- pattern/order/cardinality/evidence。 +- Python fluent builder。 + +Feature API 开始接受 `SelectionQuery[T]`/`SelectionSet[T]`,但旧 list/ShapeSelector 继续兼容。 + +退出标准: + +- 新 builder 能表达 boolean interface 和任意 feature 后的 edge fragments,且不需要 geometry predicate。 +- 新 builder 可替代 fillet/chamfer/shell 的主要 QL examples。 + +### Phase 4:Operation Graph 和 Semantic Integration + +实现: + +- 新增 `query_brep_rselection` canonical op。 +- Graph/model schema 3.0 增加 named input ports 和 typed outputs。 +- Feature graph 通过 typed selection input 消费 query result。 +- Serializer/replay 支持 Query IR 和 Evidence。 +- `apply_tag_rselection` 和 source-preserving TagBinding。 +- Legacy selected params 导入升级。 + +退出标准: + +- 新 graph 不再依赖 `selected_*_indices` 作为 primary contract。 +- QL semantic intent 在 model JSON 中可见。 +- active GraphSession 能记录完整 Query IR、bindings 和 TagBinding。 + +### Phase 5:FreeCAD Provider + +实现: + +- `FreeCADQueryProvider`。 +- Generated runtime graph-pattern evaluator。 +- FreeCAD topology history 到 canonical Change graph 的 adapter。 +- detail feature SelectionSet 转换。 +- selection helper 默认 internal/hidden。 +- OCP/FreeCAD conformance suite。 + +退出标准: + +- 所有共享 query fixture 在两个 provider 中 outcome 一致。 +- Boolean interface 和 feature fragment lineage outcome 一致。 +- 不再维护独立的 ad hoc FreeCAD geo selector scorer。 + +### Phase 6:GraphQL-like Parser + +在 IR 和 evaluator 稳定后实现: + +- parser。 +- variables。 +- fragments。 +- schema introspection。 +- formatter。 +- query explain/debug tools。 + +GraphQL parser 不是前五阶段的 blocker。 + +### Phase 7:Deprecation + +逐步 deprecate: + +- 当前 `ShapeSelector` 作为 canonical graph representation。 +- `selection_query`、`selected_*_node_ids`、indices 多轨并存。 +- indexed getter 作为推荐 replayable selection。 +- backend-specific geo selector scoring。 + +旧 model JSON 继续通过 migration adapter 导入。 + +## 21. 测试方案 + +### 21.1 IR tests + +- JSON round-trip。 +- schema version。 +- normalized hash。 +- property/relation type validation。 +- unit conversion。 +- tolerance override。 +- cardinality validation。 + +### 21.2 Geometry tests + +- box 最大/最小平面。 +- cylinder planar/cylindrical faces。 +- circular edge radius。 +- face normal same/opposite/parallel semantics。 +- inner/outer wire。 +- nested face-to-edge traversal。 +- planar chart frame 在 face split 后保持一致。 +- periodic surface chart 的 seam coedges 保留 occurrence。 + +### 21.3 Topology evolution/composition tests + +- box/cylinder union 交界 edge 只通过两个 source bindings 和 `INTERSECTION` 选择。 +- 原 edge 经 boolean/fillet/chamfer 后的 current fragments。 +- 原 face trim/split 后的 query 返回多个 current Face。 +- `select(T, in_=query)` 只做 containment,不隐式加入 incident 或 history candidates。 +- 显式 `incident_faces` relation 能选到 fillet/chamfer transition faces。 +- 显式 Change constraint 能选到同一 Change 的 boundaries。 +- coedge fragment ordering 有 evidence 时可用,无 evidence 时明确拒绝 ordered query。 +- 多个不共面 faces 各自维护 chart,禁止直接跨 chart 比较 UV。 +- 无 history 的导入 shape 可执行同一 snapshot 的 query composition。 +- 无 history 的 source query 跨 topology-changing snapshot evolution 明确失败。 +- Source face 完全被 feature 消耗时,Change constraint 仍可从历史 input 到达 generated outputs。 +- `MERGE/REPLACEMENT` 默认不继承 source chart;重新定义 Face query 和 frame 后才允许 local query。 + +### 21.4 Semantic tests + +- local tag。 +- inherited/effective tag。 +- lineage tag。 +- wildcard token pattern。 +- metadata namespace。 + +### 21.5 Ambiguity tests + +- 对称 box 的相同 edge。 +- duplicated holes。 +- radial pattern 中相同 faces。 +- 同 area/normal/center 的候选。 +- order keys 同分。 + +预期:需要唯一实体时产生 `AmbiguousSelectionError`。 + +### 21.6 Replay tests + +- 参数变化后 query 仍匹配同一语义实体。 +- topology id 改变但 semantic query 仍成立。 +- query result drift evidence。 +- strict/no fallback。 +- legacy fingerprint fallback warning。 + +### 21.7 Translator conformance + +- OCP 和 FreeCAD cardinality 一致。 +- OCP 和 FreeCAD property normalization 一致。 +- normal orientation 一致。 +- tag scope 一致。 +- ambiguous query 均失败。 +- fillet/chamfer/shell subelement mapping 正确。 + +## 22. API 示例 + +### 22.1 最大平面 + +```python +top_face = ( + cadql.select(cadql.Face, in_=body) + .where(cadql.surface_type.eq("plane")) + .order_by(cadql.center.z.desc(), cadql.area.desc()) + .take(1, boundary_ties="error") + .expect_one() +) +``` + +如果多个面 center.z 和 area 均相同,查询失败,而不是取第一个。 + +### 22.2 Semantic mounting faces + +```python +mounting_faces = ( + cadql.select(cadql.Face, in_=body) + .where( + cadql.tag("role.mounting_surface", scope="effective") + & cadql.surface_type.eq("plane") + ) + .expect_at_least(1) +) +``` + +### 22.3 Face boundary 中的 circular edges + +```python +mounting_surface = ( + cadql.select(cadql.Face, in_=body) + .where(cadql.tag("role.mounting_surface", scope="effective")) + .expect_one() +) + +holes = ( + cadql.select(cadql.Edge, in_=mounting_surface) + .where( + cadql.curve_type.eq("circle") + & cadql.radius.approximately(cadql.mm(2.5), rel=1e-6) + ) + .expect_exactly(4) +) +``` + +### 22.4 Feature consumption + +```python +rounded = fillet_rsolid( + body, + edges=( + cadql.select(cadql.Edge, in_=body) + .where(cadql.tag("edge.outer.vertical")) + .expect_exactly(4) + ), + radius=2.0, +) +``` + +### 22.5 Corner case: source face 被 split 成多个 current faces + +假设 boolean 或后续 trim 将原来的一个 mounting face 切成三个不连续的 face fragments。Source query 要求唯一;current query 独立要求三个结果: + +```python +mounting_surface = ( + cadql.select(cadql.Face, in_=before_boolean) + .where(cadql.tag("role.mounting_surface", scope="effective")) + .expect_one() +) + +current_mounting_faces = ( + cadql.select(cadql.Face, in_=after_boolean) + .where( + cadql.related( + cadql.descends_from, + from_=cadql.this, + to=mounting_surface, + derivations=("continuation", "fragment"), + depth=(1, 64), + ) + ) + .expect_exactly(3) +) + +# Query all current boundary occurrences across the three fragments. +outer_coedges = ( + cadql.select(cadql.Coedge, in_=after_boolean) + .where( + cadql.related( + cadql.boundary_uses, + from_=current_mounting_faces, + to=cadql.this, + ) + & cadql.topology.loop_role.eq("outer") + ) + .expect_at_least(3) +) + +# Most detail features consume underlying 3D edges, not oriented coedges. +fragment_edges = ( + cadql.select(cadql.Edge, in_=after_boolean) + .where(cadql.related(cadql.use_entity, from_=outer_coedges, to=cadql.this)) + .expect_at_least(3) +) +``` + +Lowering 的关键部分: + +```text +F: Face(current) +U: TopoUse(current) +E: Edge(current) + +F descends_from mounting_surface query result + through derivation in {CONTINUATION, FRAGMENT} +F boundary_uses U +U use_entity E +return distinct E +``` + +这里不能沿用 source query 的 `.expect_one()` 检查 current results。两个 query 有两个独立 cardinality contract。若参数变化后只产生两个 fragments,current query 的 `.expect_exactly(3)` 必须产生 `SelectionCardinalityError`,而不是按旧 topology index 补选第三个 face。 + +### 22.6 Corner case: source face 被 fillet/chamfer 完全消耗 + +假设一个很窄的 face 被 fillet 或 chamfer 完全消耗,最终结果中不存在任何 `CONTINUATION/FRAGMENT/REPLACEMENT` face。Descendant query 应返回空集;generated-output query 直接从消费 source face 的 Change 开始,不需要 context expansion mode: + +```python +narrow_surface = ( + cadql.select(cadql.Face, in_=before_fillet) + .where(cadql.tag("role.narrow_transition", scope="effective")) + .expect_one() +) + +transition_faces = ( + cadql.select(cadql.Face, in_=after_fillet) + .where( + cadql.related( + cadql.generated_from, + from_=cadql.this, + to=narrow_surface, + derivations=("boundary",), + depth=1, + ) + ) + .expect_at_least(1) +) + +transition_edges = ( + cadql.select(cadql.Edge, in_=after_fillet) + .where( + cadql.related( + cadql.depends_on, + from_=cadql.this, + to=narrow_surface, + depth=1, + ) + & cadql.related( + cadql.co_result_of, + from_=cadql.this, + to=transition_faces, + ) + ) + .expect_at_least(1) +) +``` + +对应语义: + +```text +source current descendants may be empty +Change consumes historical narrow_surface query result +Change outputs transition_faces with BOUNDARY derivation +transition_edges are outputs of the same Change and depend on the source +``` + +该查询不检查 operation 名称是否为 fillet/chamfer,也不假定 transition edge 是 line、circle 或 spline。若 backend 只有 current BRep、没有消费 source face 的 Change evidence,必须产生 `UnsupportedQueryCapabilityError`;不能退化为按距离或面积猜测 transition faces。 + +## 23. Open Questions + +以下问题在 Phase 1 前需要做 ADR 决策: + +1. SelectionSet 是否允许作为 public Python sequence,还是只暴露显式 `.all()`、`.one()`。 +2. Curved face normal/curvature sampling policy 在哪个版本加入。 +3. Evidence 是否默认进入 model JSON,还是只对 feature-consumed query 持久化。 +4. Legacy index fallback 的废弃周期。 +5. GraphQL-like parser 是自行实现最小 grammar,还是复用 GraphQL parser 后转换 AST。 +6. Periodic surface chart 的 canonical seam 和 UV unwrap policy。 +7. Source Face query 的 frame 在非刚性/拓扑替换 operation 后允许 continuation,还是必须显式提供新 frame。 + +## 24. 推荐结论 + +采用以下方向: + +1. 新建 `simplecadapi.cadql`,不继续把当前 `ql.py` 扩展为长期 canonical contract。 +2. Query IR 1.0 是核心规范;GraphQL-like text 是后置前端。 +3. Canonical query 是 Current BRep Graph 与 Topology Evolution Graph 上的 typed graph pattern,不是 operation-specific selector。 +4. Public API 只有 `cadql.select(ResultType, in_=scope)` 一个 query 入口;Shape、operation output 和已有 query 都通过同一个 `in_` binding 进入。 +5. 所有选择归约为六类 match capability;所有 query 使用相同的 `where/order_by/take/expect` 行为并产生 `SelectionQuery[T]`,`.resolve()` 统一返回 `SelectionSet[T]`。 +6. Geometry predicate 是可选约束;topology role、source binding、semantic tag 和 evolution path 可以独立完成选择。 +7. 完整 Change input/output/role/derivation evidence 是 history query 的前置条件,缺失时明确报 capability error。 +8. Unit、tolerance、oriented TopoUse、ordering 和 cardinality 必须进入 contract。 +9. Query intent 与 Evidence/fingerprint 分离;`TopoRef` 只作 evidence/fallback。 +10. 新 graph 使用 typed SelectionSet node,不再把每个 query match 自动展开为 geometry-only `make_select_*`。 +11. OCP replay 和所有 translator 运行同一 Query IR、Change Graph、TopoUse 和 surface-chart conformance suite。 +12. GraphQL-like parser 只有在 Query IR 和 evaluator 稳定后才实现。 + +该方案的核心不是设计更漂亮的 filter API,而是建立一个可以跨参数变化、跨 replay、跨 CAD backend 保留用户选择意图的 topology query contract。 diff --git a/SimpleCADAPI/design-docs/cadql-tagging-and-auto-semantics.md b/SimpleCADAPI/design-docs/cadql-tagging-and-auto-semantics.md new file mode 100644 index 0000000..9e1d47c --- /dev/null +++ b/SimpleCADAPI/design-docs/cadql-tagging-and-auto-semantics.md @@ -0,0 +1,964 @@ +# CADQL Tagging 与 Auto Semantics 重构设计 + +## 文档状态 + +- 状态:Partially implemented (bounded transition) +- 目标版本:Semantic Binding Schema 1.0 +- 依赖:`design-docs/cadql-brep-query-language.md` +- 目标项目:SimpleCADAPI 2.x +- 主要影响模块:`tagging.py`、`autotag.py`、`core.py`、`topology.py`、`tracking.py`、`graph.py`、`serializer.py`、`cadql/`、各 translator backend +- 非目标:继续扩展当前 `ql.py`、为每种 feature 增加专用 selector、用 tag 替代完整 topology history + +### 当前实现边界 + +当前 2.0 graph/model schema 已实现 Semantic Binding Schema 1.0 的 bounded subset:source-preserving `TagBinding`、`local`/`inherited`/`effective`/`lineage` scope、显式 downward policy、evidence-gated tracking projection、semantic-only graph node、model-level `semantic_bindings` registry 与 strict replay。当前 `ql.py` 仅作为过渡层增加 scoped tag predicate、typed operation/origin/output-role predicate,以及 projected source-binding/source-topology predicate,不是完整 CADQL Query IR。 + +尚未实现完整 typed CADQL Query IR、完整 Topology Evolution Graph、GraphQL-like parser、所有 topology/use 类型以及全部 translator backend parity。当前实现继续使用 graph/model schema `2.0`;本文中依赖 schema `3.0` 或完整 provider contract 的部分仍为 proposed。 + +### 已实现的 feature output role 子集 + +当前 `TopoDelta.roles: Tuple[TopoRoleEntry, ...]` 将 output role 与 +`TopoEntry.event` 分开保存。每个 role entry 使用 geometry-node-owned durable +`TopoRef`,并要求 operation-owned OCC witness、`coverage=complete`、 +`status=proven` 与 exact result membership。以下是当前 public contract: + +| Operation | Role | Kind | Requested cardinality | +| --- | --- | --- | --- | +| Extrude | `extrusion.start` | Face | exactly one | +| Extrude | `extrusion.end` | Face | exactly one | +| Extrude | `extrusion.side` | Face | at least one, bind all | +| Revolve | `revolution.start` | Face | exactly one | +| Revolve | `revolution.end` | Face | exactly one | +| Revolve | `revolution.side` | Face | at least one, bind all | +| Fillet | `fillet.patch` | Face | at least one, bind all | +| Chamfer | `chamfer.patch` | Face | at least one, bind all | +| Shell | `shell.body_face` | Face | at least one, bind all | +| Shell | `shell.offset_face` | Face | at least one, bind all | +| Shell | `shell.closing_descendant` | Face | at least one, bind all | +| Shell | `shell.wall` | Edge | at least one, bind all | +| Loft | `loft.start` | Face | exactly one | +| Loft | `loft.end` | Face | exactly one | +| Loft | `loft.side` | Face | at least one, bind all | +| Sweep | `sweep.start` | Face | exactly one | +| Sweep | `sweep.end` | Face | exactly one | +| Sweep | `sweep.side` | Face | at least one, bind all | + +这些 role 由 `FirstShape`、`LastShape`、`Generated`、`Modified`、Fillet/Chamfer +contour API 和 exact result membership 产生;不使用 center/normal、enumeration + index 或 unmatched-to-generated fallback。Public feature 的 named tag arguments + lower 为独立 `apply_tag_rselection` node,用户 +binding 是 `USER_OPERATION + ASSERTED`,自动 role binding 是 +`AUTO_RULE + PROVEN + RECOMPUTE`。Geometry node params 不保存这些 tag。 + +Strict replay 会重建 feature 后比较 serialized/recomputed role entries,并验证 +role allowlist、feature node ownership、output slot、coverage/status、topology +kind、binding ancestry、cardinality 和 exact selected refs。Full revolve 不具有 +独立 start/end cap;OCC 未证明的 shell role 也保持 unavailable。调用方请求这些 +role 时整个 operation 失败,不以 geometry guess 补齐。 + +当前 bounded QL 增加: + +```python +ql.output_role(role_name="extrusion.end") +ql.source_binding(binding_id="tag_binding_...") +ql.source_topology(topo_id="edge_...") +``` + +后两个 predicate 只查询 canonical local `TagBinding.evidence`。Extrude 的 +source Edge 到 generated side Face 投影只复制完整 witness 下的 local +`USER_OPERATION` binding,并保存 source binding/topology、target topology、 +operation、role 和 evidence method。Replay-safe authoring 必须把 +`apply_tag_rselection(...)` 返回的 semantic view 作为 feature input;detached +semantic branch 不会引入隐藏 graph coupling。Predicate payload +deserialization 对 unknown kind、unknown/missing fields 与非法 child cardinality +fail closed。 + +## 1. 摘要 + +下一代 tagging 系统不再把所有语义压入 topology wrapper 上的 `_tags: set[str]`。Canonical truth 是可序列化、保留来源的 `TagBinding`;CADQL 将 tag 作为 `semantic.tags` 属性查询,并在执行时计算 `local`、`inherited`、`effective` 和 `lineage` 四种 scope。 + +Auto tagging 也不再表示“给所有结果实体补一些字符串”。它是一组 evidence-gated semantic rules:规则只能在所需事实可证明时产生 `TagBinding`,证据不足时产生 `UNKNOWN`/diagnostic 或不产生 assertion,不能把 unmatched entity 推断为 generated。 + +本设计遵循以下边界: + +1. 用户或产品域赋予的 unary categorical meaning 使用 tag。 +2. Current BRep 结构事实使用 topology property/relation。 +3. 数值、枚举、几何分类和诊断使用 typed property/metadata。 +4. Operation、source role、split/merge/generated/preserved 和多来源因果使用 Topology Evolution Graph。 +5. Tag 可以作为稳定意图锚点,但不能成为 relation、history 或 evidence 的损失性副本。 + +## 2. 当前问题 + +当前实现由四套机制叠加而成: + +| 机制 | 例子 | 当前问题 | +| --- | --- | --- | +| 用户显式 tag | `role.mounting_surface` | 与系统 tag 共用一个 set,无法识别作者和 assignment。 | +| Wrapper 结构 tag | `wire.outer`、`edge.boundary` | 把可直接从 BRep graph 得到的事实 materialize 为字符串。 | +| 几何分类 tag | `face.top`、`face.side` | frame、算法和置信度不明确;未知几何退化为 enumeration index。 | +| Tracking tag | `op.cut.generated`、`origin.tool` | 把 Change event 和 source role 降级为 unary string,且依赖 Face-only matcher。 | + +具体缺陷包括: + +- `_tags: set[str]` 不保存 assignment、producer、target query、attachment、propagation、evidence 和 lifecycle。 +- Downward propagation 直接复制字符串,之后无法区分 local 与 inherited。 +- `_carry_source_tags()` 直接复制 source Face 字符串,无法恢复 lineage witness。 +- `apply_tracking_tags()` 只遍历 Face,不能消费 Edge/Wire/TopoUse history。 +- Unmatched Face 默认被标记 `generated`,把 history 缺失伪装成确定事件。 +- Boolean `section_edges` 是 Edge evidence,却用 Face ID 匹配,不能正确工作。 +- Loft/sweep 提供 Edge history,但 Face-only auto tagger仍会给结果 Face生成看似完整的 operation tag。 +- Transform 的 pure-preserve fallback 没有逐实体 correspondence,却对所有结果 Face声称 preserved。 +- `face.face_0`、pattern index 和 sketch edge zip-order 依赖 backend enumeration,不是 durable semantic identity。 +- Operation runtime 的 rich `delta_entries` 与 canonical `TopoDelta.entries` 分离,replay/translator 无法保证同一 semantic output。 + +当前测试中的“每个结果 Face 都有 operation tag”不是正确性证明,因为该覆盖率可以完全由 unmatched-to-generated fallback 产生。 + +## 3. 与 CADQL 的关系 + +CADQL 在三种图的联合视图上执行: + +| 数据层 | 职责 | Tagging 是否拥有该事实 | +| --- | --- | --- | +| Operation Graph | 模型构建、assignment producer、query binding | 否;TagBinding 引用它。 | +| Current BRep Graph | contains、boundary、incident、adjacent、TopoUse | 否;tag 只作为 entity 的 semantic property。 | +| Topology Evolution Graph | Change input/output、role、event、derivation | 否;lineage scope 依赖它计算。 | +| Semantic Binding Store | 用户/系统声明的稳定 unary meaning | 是。 | + +CADQL 只有一种 tag predicate: + +```python +cadql.tag("role.mounting_surface", scope="effective") +``` + +它是对 `semantic.tags` 的 property predicate,不创建新的 selector、anchor 或 context 类型。Tag assignment 的 target 本身使用同一套 `SelectionQuery[T]`;不再引入另一套 tag selector DSL。 + +## 4. 设计原则 + +### 4.1 一项事实只有一个 canonical owner + +以下映射是强制的: + +| 事实 | Canonical owner | 可否派生为 tag | +| --- | --- | --- | +| Face 的 outer/inner loop | `topology.loop_role` | 默认否。 | +| Edge 是 Face boundary | `boundary_uses`/`use_entity` | 否。 | +| Surface 是 plane/cylinder | `geometry.surface_type` | 默认否。 | +| Face 当前在 world +Z 方向 | oriented geometry predicate + frame | 否。 | +| Entity 由某 operation 产生 | `provenance.produced_by` | 否。 | +| Entity 是 preserved/modified/generated | Change output event | 否。 | +| Entity 来自 body/tool/profile/path | Change input role/path | 否。 | +| 用户声明 mounting surface | TagBinding | 是,且这是 canonical truth。 | +| 业务规则证明某面是 sealing surface | TagBinding + rule evidence | 是。 | +| 长度、半径、pattern index | typed property/metadata | 否。 | + +Tag materialized cache 可以为性能复制 relation/property 的结果,但必须标记为 derived cache、可丢弃且不能成为 replay truth。Semantic Binding Schema 1.0 不定义此优化。 + +CADQL 中的 `semantic.role` 和 `semantic.group` 是 registry-backed TagBinding projection,例如从 `role.*` 和 `group.*` bindings 得到结构化视图,不建立第二份 writable storage。`semantic.material` 等具有独立 schema、互斥或结构化值的属性使用 typed semantic property,不同时编码为 tag。查询 provider必须从同一个 canonical owner求值这些属性。 + +### 4.2 Tag 只表达 unary categorical semantics + +适合 tag 的内容: + +```text +role.mounting_surface +role.sealing_surface +anchor.datum.primary +group.fasteners +process.inspect +``` + +不适合 tag 的内容: + +```text +op.make_cut_rsolid.generated +origin.tool +face.area.245 +edge.index.3 +face.normal.+z +wire.outer +``` + +判断标准:如果完整含义需要 source、target、operation、frame、单位、置信度或多个参与者,它就不是单个 tag。 + +### 4.3 Evidence 不足时保持未知 + +Auto semantic rule 的三值结果为: + +```text +PROVEN 所需 evidence 完整,允许发出 binding。 +NOT_APPLICABLE 规则已完整求值,candidate 不满足。 +UNKNOWN evidence 或 capability 不足,禁止发出 binding。 +``` + +`UNKNOWN` 不能被实现为 false、generated、preserved 或最佳猜测。Strict replay 中,如果 feature 依赖该 auto semantic,`UNKNOWN` 必须产生结构化错误;非依赖型装饰 semantic 可以只生成 diagnostic。 + +### 4.4 Query intent 与 execution evidence 分离 + +Target query 描述要标记谁;assignment evidence 描述某次执行为何解析到这些 entities。Replay 先重新执行 query,再比较 evidence。`TopoRef.topo_id` 和 geometry fingerprint 只属于 evidence,不是 TagBinding identity。 + +### 4.5 Semantic state 是版本化 shape view + +`apply_tag_rselection` 不修改 geometry,但产生新的 semantic state version。相同 BRep entity 在 assignment 前后可以有不同 effective tags。CADQL provider 必须相对当前 scope producer/version 读取 bindings,不能从全局 mutable wrapper set 读取历史无关的并集。 + +## 5. Canonical 数据模型 + +### 5.1 TagDefinition + +Tag token 继续使用 normalized dot-token 格式: + +```text +[a-z][a-z0-9_-]*(.[a-z][a-z0-9_-]*)* +``` + +可选 registry 为已知 namespace 声明行为: + +```json +{ + "tag": "role.mounting_surface", + "namespace": "role", + "value_kind": "categorical", + "allowed_target_types": ["face"], + "default_topology_propagation": "local", + "default_lineage_policy": "continuation_fragment" +} +``` + +Registry 用于 validation 和文档,不拥有 assignment。未注册但格式合法的 custom tag 可以被允许,但必须显式提供 propagation 和 lineage policy,不能从字符串前缀静默猜测。 + +### 5.2 TagBinding + +Canonical assignment: + +```json +{ + "schema_version": "1.0", + "binding_id": "tag_binding_mounting_surface", + "tag": "role.mounting_surface", + "producer": { + "kind": "user_operation", + "node_id": "node_tag_mounting_surface", + "rule_id": null, + "rule_version": null + }, + "scope": { + "node_id": "node_body", + "output_slot": 0 + }, + "target": { + "kind": "selection_query", + "query_hash": "sha256:...", + "binding_hash": "sha256:..." + }, + "attachment": "local", + "propagation": { + "topology": "local", + "lineage": "continuation_fragment" + }, + "evidence": { + "kind": "query_execution", + "execution_hash": "sha256:...", + "selected_refs": [] + }, + "certainty": "asserted", + "lifecycle": "assertion" +} +``` + +字段语义: + +| 字段 | Contract | +| --- | --- | +| `binding_id` | Assignment identity,不由 selected topology ID 生成。 | +| `tag` | Unary semantic token。 | +| `producer` | 用户 operation 或 versioned auto rule。 | +| `scope` | Target query 的 current snapshot/version boundary。 | +| `target` | Canonical Query IR binding 或仅用于 legacy import 的 explicit refs。 | +| `attachment` | 新 assignment 只允许 `local`;`inherited` 是计算结果。`effective_legacy` 仅用于迁移。 | +| `propagation.topology` | `local` 或显式 `downward`;`local` 表示不沿 topology传播。 | +| `propagation.lineage` | 允许沿哪些 derivation 计算 lineage visibility。 | +| `evidence` | Assignment execution witness,不参与 query intent hash。 | +| `certainty` | `asserted` 或 `proven`;猜测值不能进入 canonical bindings。 | +| `lifecycle` | replay 时重新解析、重新计算或保持 legacy snapshot。 | + +### 5.3 TargetBinding + +新 assignment 的 target 必须是: + +```text +selection_query canonical Query IR + runtime bindings +scope_root lower 为选择当前 scope root 的 canonical query +``` + +以下 target 只用于迁移: + +```text +explicit_refs execution-local refs,无 semantic intent +legacy_effective 只有旧 flat tag snapshot +``` + +Auto rule 可以直接在 producer operation 的已知 Change outputs 上产生 target,但 serialization 时仍 lower 为 canonical source-preserving target: + +- 引用 producer output variable 的 query;或 +- 引用 versioned Change output set 的 internal binding。 + +不能只保存“当时第 3 个 Face”。 + +### 5.4 Producer + +Producer kind: + +```text +user_operation +auto_rule +imported_sidecar +legacy_import +``` + +Auto rule 必须保存稳定 `rule_id` 和 `rule_version`。修改 rule 语义必须提升 version;replay 可据此检测 semantic drift。 + +### 5.5 Evidence + +Evidence 是 tagged union: + +| Kind | 必需内容 | 适用场景 | +| --- | --- | --- | +| `user_assertion` | assignment node、authoring source | 用户明确声明。 | +| `query_execution` | query/binding/execution hash、selected refs | Query target assignment。 | +| `topology_change` | Change IDs、input/output witnesses、coverage | 基于 lineage 的 auto rule。 | +| `geometry_classification` | algorithm/version、frame、tolerance、measured properties | 几何分类规则。 | +| `imported_sidecar` | source URI/hash/schema | 外部 semantic data。 | +| `legacy_snapshot` | imported tags、migration diagnostic | 旧模型兼容。 | + +Evidence 可以证明 assignment,但不会把几何 fingerprint 升级为 durable target identity。 + +## 6. Tag Scope 的正式语义 + +### 6.1 Local + +`local` 返回当前 semantic state version 中,target query 直接匹配该 entity/use 的 bindings。 + +```text +local(e, v) = {b.tag | binding b is visible in semantic version v + AND target(b, v) contains e} +``` + +它不包含 topology parent、model entity 或 lineage ancestor 的 tag。 + +### 6.2 Inherited + +`inherited` 只来自显式 topology propagation policy: + +```text +inherited(e, v) = bindings attached to ancestor a + where topology propagation is downward + and contains-path(a, e) is valid in snapshot v +``` + +规则: + +- 不再根据 `role.*`、`anchor.*`、`group.*` 前缀自动决定传播。 +- Propagation 在 assignment 时显式冻结并序列化。 +- Downward 传播计算于 Current BRep containment,不复制字符串。 +- 默认是 `local`,即不传播。 +- 给 Solid 标记 `role.mounting_plate` 不应默认让每条 Edge 都成为 mounting plate。 +- 若确实表达 assembly/model membership,优先使用 model relation 或显式 group binding;不要依靠 topology descendants 模拟产品结构。 + +### 6.3 Effective + +```text +effective(e, v) = local(e, v) union inherited(e, v) +``` + +`effective` 不自动包含 lineage。这样 current snapshot 内的 attachment semantics 与跨 operation ancestry 保持正交。 + +### 6.4 Lineage + +`lineage` 通过 TagBinding 与 Topology Evolution Graph 联合计算: + +```text +lineage(e, v) = source bindings whose tagged entities are reachable + through allowed Change output derivations +``` + +默认 policy: + +| Policy | 允许 derivation | 说明 | +| --- | --- | --- | +| `none` | 无 | 不跨 operation 可见。 | +| `continuation` | `CONTINUATION` | 只接受同角色延续。 | +| `continuation_fragment` | `CONTINUATION`、`FRAGMENT` | 允许 trim/split fragments。 | +| `explicit` | assignment 指定集合 | 允许 `MERGE`/`REPLACEMENT` 等业务定义。 | + +`INTERSECTION` 和 `BOUNDARY` 默认不继承 source tag,因为新 entity 是由 source 参与生成,不等于 source 语义角色的延续。需要选择这类结果时使用 `generated_from`、`depends_on` 或 `cadql.change(...)`。`MERGE`/`REPLACEMENT` 也不默认继承,除非 assignment 显式声明。 + +`lineage` scope 只返回 lineage-derived tags;调用方若需要本地与 lineage 并集,应显式组合 predicates,不把 `effective` 的含义扩大。 + +History capability 为 `NONE` 或所需 path evidence 不完整时,请求 `lineage` 必须产生 `UnsupportedQueryCapabilityError`,不能返回空集冒充“没有 tag”。 + +### 6.5 TopoUse + +TagBinding 可以 target entity 或 oriented use: + +- Entity tag 对同一 underlying entity 的所有 occurrences 可见。 +- TopoUse tag 只对该 parent/orientation/occurrence 可见。 +- TopoUse 的 inherited 与 lineage 依赖 use-level containment/evolution evidence。 +- 缺少 use ancestry 时不能把 entity lineage tag 冒充 occurrence lineage tag。 + +## 7. Public API 与 Operation Graph + +### 7.1 用户赋值 + +保留简单入口: + +```python +apply_tag(body, "role.mounting_plate") +``` + +新 schema 中该 convenience API 的固定默认值是: + +```text +topology_propagation = LOCAL +lineage_policy = CONTINUATION_FRAGMENT +``` + +因此它不会把 Solid 的 role 复制给所有 Face/Edge,但在 backend 提供完整 `CONTINUATION`/`FRAGMENT` evidence 时,该 assertion 可以通过 `scope="lineage"` 从后续版本查询。需要 topology downward inheritance 时必须调用带显式 policy 的 `apply_tag_rselection`。迁移期执行旧 model时可以保留旧 effective结果,但新 assignment不能继续按token prefix选择policy。 + +它 lower 为 scope-root query 和 canonical operation: + +```text +apply_tag_rselection( + scope: Shape, + targets: SelectionSet, + tag: Tag, + topology_propagation: LOCAL | DOWNWARD, + lineage_policy: NONE | CONTINUATION | CONTINUATION_FRAGMENT | EXPLICIT, +) -> Shape +``` + +推荐的精确赋值: + +```python +mounting_face = ( + cadql.select(cadql.Face, in_=body) + .where( + cadql.surface_type.eq("plane") + & cadql.normal.same_direction_as( + (0, 0, 1), + relative_to=body, + angle_tolerance=cadql.deg(0.1), + ) + ) + .expect_one() +) + +tagged_body = apply_tag_rselection( + body, + targets=mounting_face, + tag="role.mounting_surface", + topology_propagation="local", + lineage_policy="continuation_fragment", +) +``` + +Active `GraphSession` 必须记录 target Query IR 和 binding,不能只记录 resolved Face refs。 + +### 7.2 读取 + +CADQL 是 canonical query API: + +```python +cadql.select(cadql.Face, in_=body).where( + cadql.tag("role.mounting_surface", scope="effective") +) +``` + +`list_tags(shape)` 可以保留为 current wrapper convenience,但默认返回当前 version 的 `effective` tags,并应允许显式 scope: + +```python +list_tags(shape, scope="local") +list_tags(shape, scope="effective") +list_tags(shape, scope="lineage") +``` + +它不得绕过 provider 从 `_tags` 直接读取。迁移期旧签名等价于 `scope="effective"`。 + +### 7.3 删除和替换 + +Tag 删除不是从 entity set 中 `discard()` 字符串,而是产生新的 semantic operation,撤销或 supersede 指定 binding: + +```text +remove_tag_rselection(scope, targets, tag, matching_producer?) -> Shape +``` + +默认不能静默删除其他 producer 的同名 binding。例如移除 auto-rule binding 不应删除用户 assertion。 + +### 7.4 Conflict semantics + +同一个 entity 可同时存在多个 producer 的同名 tag,查询结果按 token 去重,但 evidence/projection 保留所有 bindings。 + +Tag 1.0 不定义基于命名约定的互斥,例如 `state.open` 与 `state.closed`。真正互斥的数据应使用 typed enum property。若产品域 registry 声明 tag group 互斥,assignment validation 必须报告 conflict,不能按“最后写入者获胜”处理。 + +## 8. Auto Semantics Rule Contract + +### 8.1 Rule schema + +每条规则必须声明: + +```json +{ + "rule_id": "simplecad.primitive.box.face_roles", + "rule_version": "1.0", + "trigger": "make_box_rsolid", + "candidate_type": "face", + "required_capabilities": [ + "operation_output_roles", + "oriented_face_geometry" + ], + "required_evidence": { + "history": "operation_native", + "frame": "operation_context", + "coverage": "complete" + }, + "output": { + "kind": "tag_binding", + "allowed_tags": ["role.cap.start", "role.cap.end", "role.side"] + }, + "unknown_policy": "diagnostic", + "lifecycle": "recompute" +} +``` + +Rule evaluator 返回: + +```text +bindings +status: PROVEN | NOT_APPLICABLE | UNKNOWN +evidence +diagnostics +coverage +``` + +### 8.2 权威等级 + +| Level | Source | 可产生的结果 | +| --- | --- | --- | +| 1 | 用户显式 assertion/imported authoritative sidecar | `certainty=asserted` binding。 | +| 2 | Kernel/operation-native topology history | Change graph;满足业务规则时可产生 `certainty=proven` binding。 | +| 3 | Exact current topology fact | topology property/relation;通常不 materialize 为 tag。 | +| 4 | Deterministic geometry classifier | typed geometry property;只有注册业务映射后才能产生 tag。 | +| 5 | Heuristic/fingerprint | diagnostic/evidence only。 | + +低等级 evidence 不能覆盖或伪装为高等级事实。尤其 geometry proximity 不能产生 preserved/generated lineage。 + +### 8.3 Coverage + +每个 rule evaluation 必须声明: + +```text +COMPLETE 候选 universe 与所需 evidence 完整。 +PARTIAL 只有部分 candidates/relations 有 evidence。 +NONE 无法求值。 +``` + +只有 `COMPLETE` 且 rule 对具体 candidate 得到 `PROVEN` 时才发出需要全局排他性的 tag,例如“唯一 start cap”。`PARTIAL` 不能通过给其余 candidates 标记 opposite role 来补齐结果。 + +### 8.4 禁止行为 + +以下规则在新系统中非法: + +- unmatched result entity => `generated`。 +- pure-preserve delta => 所有 result entities preserved,而无逐实体 mapping。 +- backend enumeration index => semantic tag。 +- geometry look-alike => lineage continuation。 +- 将 operation 名、event 和 origin role拼接为 tag作为唯一事实。 +- 给所有 descendants 无条件复制 `role.*`。 +- 多来源 Change => 只保留一个 `origin.*` 字符串。 +- rule 失败后静默回退到 fingerprint 并仍标记 `proven`。 + +## 9. Operation-by-Operation 策略 + +### 9.1 Primitive + +Primitive operation 自己知道构造参数和 output roles,优先使用 operation-native evidence,而不是事后猜测几何。 + +| 当前输出 | 新 canonical 表达 | +| --- | --- | +| `geom.primitive.box` | `provenance.operation_type == make_box_rsolid` 或 typed semantic metadata,不需要 tag。 | +| 裸 `box` | 删除。 | +| `face.top/bottom/front/...` | 不自动作为 world-direction tag。使用 geometry predicate + explicit frame。 | +| `face.surface` | `geometry.surface_type`。 | + +如果产品 API 需要稳定 feature role,可定义 frame-aware operation role: + +```text +role.cap.start +role.cap.end +role.side +``` + +这些角色必须绑定 primitive 的参数方向和 operation context,并保存 rule evidence。对于 box 的四个侧面,如果没有额外业务区别,不自动命名 front/back/left/right;数学对称性应保留为集合。 + +Cone 必须与 cylinder 使用同一 role contract,不再因为当前实现遗漏 `auto_tag_faces()` 而表现不同。 + +### 9.2 Extrude 和 Revolve + +Feature provenance 和 topology evolution进入 Change graph: + +- Profile Face/Coedge 是 `subject` 或 `support` input。 +- Start/end continuation、side/boundary outputs使用 event + derivation表示。 +- `face.extrusion.start/side/end` 若保留为用户友好 semantic,必须由 operation-native role mapping产生 TagBinding,不使用 center exact equality或 normal exact equality。 +- `solid.extrusion` 改为 `provenance.operation_type`,不作为 tag。 +- Revolve 使用相同 Change contract;full/partial revolve 的 caps必须按实际 operation evidence处理。 + +推荐 query: + +```python +side_faces = ( + cadql.select(cadql.Face, in_=extruded) + .where( + cadql.related( + cadql.generated_from, + from_=cadql.this, + to=profile_boundary, + derivations=("boundary",), + depth=1, + ) + ) +) +``` + +只有业务代码确实需要可命名的 feature role 时才额外发出 `role.extrusion.side`。 + +### 9.3 Boolean + +Boolean 不再产生 canonical `op.*` 或 `origin.*` tags。 + +必须记录: + +- 每个 operand 的 named input port。 +- 每个 source entity 的 role:`subject`、`tool`、`support` 或 `context`。 +- 每个 output entity 的 event 和 derivation。 +- Intersection Edge 的多 source Change witness。 + +交界 edge 使用: + +```python +cadql.change( + inputs=( + cadql.change_input(subject_faces, input_port="subjects", role="support"), + cadql.change_input(tool_faces, input_port="tools", role="tool"), + ), + output=cadql.this, + derivation="intersection", +) +``` + +不能用 `role.section_face` 替代 Edge-level `INTERSECTION` Change。若产品域确实定义“section interface”业务角色,可在上述 query 上显式应用 tag;该 tag 的 target intent仍是 Change pattern。 + +多步 cut/intersect 必须保留每一步 Change,不能简单拼接 event buckets 后覆盖相同 `topo_id` 的 `delta_entries`。 + +### 9.4 Fillet、Chamfer 和 Shell + +这些 feature 的核心是 target selection 与 Change graph: + +- 用户选择的 Edge/Face 是 `target` input。 +- 邻接 support Face 是 `support` input。 +- Trimmed source entities 是 `CONTINUATION`/`FRAGMENT` output。 +- Transition Face 和新 boundary Edge 是 `BOUNDARY` output。 +- 完全消耗的 source entity保留为 historical input与 `DELETED` output event。 + +默认不产生 `op.fillet.generated` 等 tag。查询 transition geometry 应使用 `generated_from`、`depends_on` 和 `co_result_of`。 + +用户已有 `role.mounting_surface` 的 source Face,只沿 `CONTINUATION`/`FRAGMENT` 进入 lineage scope;fillet transition Face 不自动继承该角色。 + +### 9.5 Transform 和 Mirror + +Rigid transform 不改变 topology role,但必须提供逐 entity/use `CONTINUATION` mapping。只有 mapping complete 时 lineage tag 才可查询。 + +- `solid.transform.mirrored` 改为 provenance/operation property。 +- 不复制 source wrapper `_tags`。 +- Mirror 的 orientation-sensitive TopoUse/normal 必须在新 parent occurrence和 frame中重新求值。 +- 没有逐实体 mapping 时 history capability 为 `PARTIAL`,不能把所有 Face声明 preserved。 + +### 9.6 Loft 和 Sweep + +Loft/sweep 必须首先修复 evidence kind mismatch: + +- Profile/path Edge history进入 Change graph。 +- Result Face/Edge 与 profile/path source的关系必须由 operation-native adapter提供。 +- Auto semantic evaluator按 candidate type消费对应 Change outputs,不允许 Face-only fallback。 +- Profile 与 path 是不同 input ports/roles,不能把二者 tags union到 result Solid后丢失来源。 + +如果 backend只能提供 Edge-level partial history,Face lineage query与依赖它的 auto rule必须报告 unsupported/unknown;不能给全部 Faces标记 generated。 + +### 9.7 Pattern + +Pattern 应有显式 Pattern operation 和 instance output relation: + +```text +source EntityVersion -> Change(instance identity) -> instance EntityVersion +``` + +每个 instance 保存稳定 operation-local `instance_key`,index只作为 typed property: + +```text +provenance.pattern_instance_key +provenance.pattern_ordinal +``` + +`solid.pattern.linear/radial` 改为 operation provenance,不是 semantic tag。Source tags通过 `CONTINUATION` lineage policy可见;不要复制到每个 instance wrapper。 + +对完全对称的 instances,若没有用户 tag或业务 key,CADQL返回集合或 ambiguity,不用 ordinal伪造 semantic identity。 + +### 9.8 Sketch Promotion + +Sketch entity到 BRep Edge的映射是 source binding,不是 tag naming问题: + +- Sketch entity/profile使用稳定 semantic refs。 +- Promotion adapter记录每个 output Edge对应的 source entity ref和 evidence。 +- `sketch.*`、`sketch_profile.*`、`sketch_entity.*` 不再作为唯一 mapping。 +- 禁止仅通过 `zip(edges, entity_ids)` 声称 correspondence。 +- 用户附着在 sketch entity上的 semantic tag可通过 proven promotion Change在 `lineage` scope查询。 + +## 10. Topology、Geometry、Provenance 与 Tag 的迁移表 + +| 现有 tag/数据 | 新位置 | 迁移行为 | +| --- | --- | --- | +| `wire.outer`/`wire.inner` | `topology.loop_role` on WireUse/Coedge | 不导入为 semantic tag。 | +| `edge.boundary` | `boundary_uses` + `use_entity` | 不导入为 semantic tag。 | +| `face.top/bottom/...` | Legacy effective tag;新模型使用 frame-aware geometry query或 explicit role binding | 导入并警告方向语义未声明。 | +| `face.face_N` | Legacy enumeration annotation | 不生成新 binding;仅保留 migration evidence。 | +| `geom.primitive.*` | `provenance.operation_type`/typed metadata | 可保留 legacy effective,不再新发出。 | +| `op..` | Change output event + performed_by Operation | 不生成新 binding。 | +| `origin.` | Change input role/path | 不生成新 binding。 | +| `solid.boolean.*` | Operation provenance | 不生成新 binding。 | +| `solid.pattern.*` | Pattern provenance/property | 不生成新 binding。 | +| `role.*` 用户 tag | TagBinding | 尽可能恢复 assignment;否则 `effective_legacy`。 | +| `anchor.*` 用户 tag | TagBinding | 同上。 | +| `group.*` 用户 tag | TagBinding 或 model membership relation | 按 domain schema迁移。 | +| Numeric/index tags | typed metadata/property | 不作为 semantic binding。 | + +## 11. Legacy 兼容 + +旧模型只有 flat tags时,导入为: + +```json +{ + "producer": {"kind": "legacy_import"}, + "target": {"kind": "legacy_effective", "refs": []}, + "attachment": "effective_legacy", + "propagation": {"topology": "local", "lineage": "none"}, + "certainty": "asserted", + "lifecycle": "snapshot", + "evidence": {"kind": "legacy_snapshot"} +} +``` + +Provider 只把 `effective_legacy` 暴露给 `scope="effective"`。它不能回答 local、inherited 或 lineage,并产生 migration diagnostic;该 attachment不是正常assignment可写入的值。 + +兼容规则: + +- 旧 public `list_tags()` 继续看到原 effective tokens。 +- 旧 QL tag predicate可由 adapter lower 为 CADQL `effective` predicate。 +- 不从 token prefix反推旧 propagation。 +- 不从 `op.*`/`origin.*` 反向构造可信 Change graph。 +- 如果 graph中同时有真实 TopoDelta evidence,可以独立迁移为 Change;仍不能仅依据旧 tag补齐缺失 parent mapping。 + +## 12. Serialization 与 Provider Contract + +Model schema 3.0 增加: + +```text +semantic_bindings: TagBinding[] +semantic_rules: AutoSemanticRuleRef[] +semantic_diagnostics: SemanticDiagnostic[] +``` + +Operation node可以引用其产生的 binding IDs。TagBinding target query复用 model顶层 `query_objects`,不嵌入语义缩水的第二份 query。 + +Provider 增加: + +```python +class SemanticProvider(Protocol): + def bindings(self, scope_version, entity_or_use) -> Sequence[TagBinding]: ... + def tags(self, entity_or_use, scope: TagScope) -> Sequence[str]: ... + def explain_tag(self, entity_or_use, tag: str, scope: TagScope) -> Sequence[TagWitness]: ... +``` + +`explain_tag()` 至少返回: + +```text +binding_id +producer +attachment path +topology inheritance path, if any +lineage Change path, if any +evidence status +diagnostics +``` + +OCP 和 FreeCAD provider必须运行同一 scope和rule conformance suite。Backend不支持某 rule所需 capability时报告 unknown/unsupported,不自行更换算法。 + +## 13. Invalidation 与 Replay + +TagBinding lifecycle: + +| Lifecycle | Replay behavior | +| --- | --- | +| `recompute` | 重跑 target query或auto rule,生成新 evidence并比较 drift。 | +| `assertion` | 重跑 target query;用户 semantic保留,但 query不再满足时失败。 | +| `snapshot` | 仅 legacy/import sidecar;不能声称动态 lineage。 | + +参数变化后: + +1. 重建 geometry和 Change graph。 +2. 重新解析 assignment target query。 +3. 重新运行 versioned auto rules。 +4. 撤销本次 replay中不再 proven的 derived bindings。 +5. 比较旧/新 evidence并生成 drift diagnostic。 +6. Feature依赖的 semantic target违反 cardinality时按 CADQL错误模型失败。 + +Derived auto binding不能因为曾经写入 wrapper set而永久残留。Semantic state由当前有效 bindings重新计算,不执行增量字符串清理猜测。 + +## 14. 错误与诊断 + +新增结构化错误: + +| Error | 条件 | +| --- | --- | +| `TagValidationError` | token、target type、policy或registry不合法。 | +| `TagTargetResolutionError` | Assignment target query无法满足 cardinality。 | +| `SemanticConflictError` | Registry定义的互斥 semantic同时成立。 | +| `AutoSemanticEvidenceError` | Strict dependency所需rule evidence为UNKNOWN/PARTIAL。 | +| `SemanticCapabilityError` | Provider不能计算请求的tag scope或rule。 | +| `SemanticDriftError` | Strict replay中assignment/rule结果发生不允许的漂移。 | + +Diagnostic 至少包含: + +```text +binding_id/rule_id +scope version +required and available capability +coverage +candidate count +missing witnesses +repair suggestions +``` + +## 15. 测试策略 + +### 15.1 Binding tests + +- TagBinding JSON round-trip和stable hash。 +- 同名tag、不同producer不会互相覆盖。 +- Target query intent与execution refs分离。 +- Remove/supersede只影响指定binding。 + +### 15.2 Scope tests + +- local不包含parent和lineage tags。 +- inherited只沿显式downward policy计算。 +- effective严格等于local union inherited。 +- lineage只沿允许的derivations计算。 +- `BOUNDARY`/`INTERSECTION` 默认不继承source role tag。 +- history缺失时lineage产生capability error而不是空结果。 +- entity tag和TopoUse tag保持不同visibility。 + +### 15.3 Auto rule precision/recall + +- 每个emitted binding有required evidence witness。 +- 可证明的semantic不会遗漏。 +- Unmatched candidate不会得到generated/preserved tag。 +- Partial coverage不会产生排他性role。 +- Rule version变化产生semantic drift diagnostic。 + +### 15.4 Operation fixtures + +- Primitive roles使用operation frame,不依赖world axis或Face枚举。 +- Extrude/revolve caps和sides使用operation-native mapping。 +- Boolean interface保留多input Change,Edge evidence不被当作Face。 +- Fillet/chamfer source fragments、transition Faces和boundaries可通过evolution relation选择。 +- Transform/mirror有逐entity continuation mapping。 +- Loft/sweep在只有Edge evidence时拒绝Face lineage assertion。 +- Pattern instances保留source lineage和instance key,不以ordinal作为semantic identity。 +- Sketch promotion不依赖zip/enumeration order。 + +### 15.5 Replay与translator + +- OCP和FreeCAD的tag scopes一致。 +- Model JSON round-trip后producer/evidence/policies不丢失。 +- 参数变化后过期auto bindings消失。 +- Legacy effective tags仍可查询,但local/inherited/lineage明确unsupported。 + +## 16. 实施阶段 + +### Phase A:Characterization 与止损 + +- 锁定当前public tag行为和serialized fixtures。 +- 给unmatched-to-generated、section Edge/Face mismatch、loft/sweep mismatch增加failing characterization tests。 +- 停止新增`op.*`、`origin.*`和enumeration-derived tags。 +- `apply_tracking_tags()`增加diagnostic mode,逐步取消unsound fallback。 + +退出标准:所有已知误报都有测试,不再扩大flat-tag contract。 + +### Phase B:Semantic Binding Schema + +- 实现TagBinding、producer、target、policy、evidence和serialization。 +- 实现semantic state version与binding store。 +- `apply_tag_rselection`记录target Query IR。 +- Flat `_tags` 暂时作为effective cache,不再是canonical truth。 + +退出标准:用户assignment可JSON round-trip并解释来源。 + +### Phase C:CADQL Scope Provider + +- 实现local/inherited/effective。 +- 移除prefix-driven runtime copying;迁移为binding-time explicit policy。 +- 实现`explain_tag()`。 +- Legacy tags仅通过effective adapter暴露。 + +退出标准:三个current-snapshot scopes通过OCP conformance tests。 + +### Phase D:Topology Evolution Graph + +- 按CADQL设计建立完整Change inputs/outputs。 +- 将`delta_entries`合并进canonical序列化schema。 +- 修复Face-only consumer、section Edge、transform correspondence和多步Change。 +- 实现lineage scope与history capability validation。 + +退出标准:continuation/fragment/boundary/intersection fixtures可查询且缺证据时失败。 + +### Phase E:Auto Semantic Rules + +- 引入versioned rule registry和三值求值。 +- 先迁移primitive与extrude roles,再迁移其他feature。 +- 删除unmatched-generated和operation/origin tags的canonical生成。 +- Geometry heuristic仅作为typed property或diagnostic。 + +退出标准:每个auto binding都有rule、evidence、coverage和replay lifecycle。 + +### Phase F:Translator 与清理 + +- FreeCAD provider实现相同semantic binding/scope contract。 +- 更新docs、skills和examples到CADQL tag query。 +- Deprecate `Solid.auto_tag_faces()`和内部直接`_tags`复制。 +- 删除flat tag作为truth的代码路径。 + +退出标准:OCP/FreeCAD semantic conformance一致,新model不依赖flat tags。 + +## 17. 推荐决策 + +1. 采用`TagBinding`作为唯一canonical tag assignment;`_tags`只能是迁移期effective cache。 +2. Tag仅表达用户/业务域的unary categorical semantics。 +3. `wire.outer`、`edge.boundary`迁移到Current BRep properties/relations。 +4. `op.*`、`origin.*`、preserved/modified/generated迁移到Operation/Topology Evolution Graph。 +5. 几何类型、方向、数值和pattern ordinal迁移到typed properties,不再自动materialize为tag。 +6. Downward propagation默认关闭并在每个binding中显式保存;不再按prefix复制字符串。 +7. `effective = local union inherited`;lineage保持独立scope并依赖完整Change evidence。 +8. Lineage默认只允许`CONTINUATION`/`FRAGMENT`;`BOUNDARY`、`INTERSECTION`、`MERGE`和`REPLACEMENT`不自动继承semantic role。 +9. Auto semantics使用versioned、evidence-gated、三值规则;`UNKNOWN`是一等结果。 +10. 立即禁止unmatched => generated、enumeration => semantic role、geometry guess => proven history。 +11. 所有tag target复用`cadql.select(...)`和Query IR,不建设第二套selector或anchor API。 +12. Replay和translator必须保存并解释assignment intent、producer、scope、evidence和drift,不能只搬运最终字符串集合。 + +这套策略的核心不是让auto tagging覆盖更多实体,而是让每个可查询semantic都有明确作者、目标意图、适用scope、证明依据和失效规则。CADQL负责查询这些事实;Current BRep Graph和Topology Evolution Graph负责证明结构与历史;TagBinding只负责承载真正的semantic assertion。 diff --git a/SimpleCADAPI/design-docs/evaluated-scene-and-viewer-architecture.md b/SimpleCADAPI/design-docs/evaluated-scene-and-viewer-architecture.md new file mode 100644 index 0000000..7908d79 --- /dev/null +++ b/SimpleCADAPI/design-docs/evaluated-scene-and-viewer-architecture.md @@ -0,0 +1,2293 @@ +# SimpleCAD Evaluated Scene 与 Viewer 架构设计 + +## 文档状态 + +- 状态:Frozen(Scene Schema 1.0 Phase A contract) +- 冻结证据日期:2026-07-22 +- Shared corpus:`test/fixtures/scene-contract/corpus.json`,SHA-256 `4f04224333e6aa70b12fc33efaee7b8d27782894d692fe402a6ef1d394a780ca` +- 目标版本:Scene Schema 1.0 +- 目标项目:SimpleCADAPI 2.x +- 依赖:现有 Model Schema 2.0、Product/Assembly 语义、内部三角化能力、glTF 2.0、RFC 8785 JSON Canonicalization Scheme (JCS) +- 主要影响模块:`src/simplecadapi/scene/` 中的 compiler、render mesh/asset、serializer、validator 和 contract artifacts,`source_mapping.py`,以及仓库根目录下独立的 `viewer/` TypeScript/Three.js 工程;现有 `graph.py`、`serializer.py`、`product.py`、`_mesh.py` 提供输入 +- 非目标:用 scene 文件替代 operation graph、在浏览器中重放全部 CAD operations、第一阶段提供协同编辑或通用游戏引擎、把 viewer 临时状态写回 canonical model + +### 当前实现边界 + +当前仓库已经具有 replayable operation graph、`ModelResult`、显式 result capture、`Part`/`Assembly`/`Component` 产品层级、刚体 placement、基础 material RGB、Phase A 冻结的 Scene Schema、immutable contract documents、strict validators、resource profiles、exact GLB/ZIP vectors、Python/TypeScript parity harness,以及可工作的 Scene Compiler、triangle/edge GLB writer、entity sidecar、canonical `.scene.zip` exporter和browser Viewer。Viewer当前是直接DOM状态管理的TypeScript/Three.js/Vite应用,支持package loading、product tree、Features/source inspection和五种selection intent;尚未实现presentation/imported compilation、scene patch protocol、持久化workspace、独立model/scene pair-state系统或browser CAD replay。 + +本文将现有 `model.json` 定义为 parametric design/replay IR,将新增的 scene 文件定义为 evaluated presentation artifact。Scene 可以由 model 和可选 `ScenePresentationSpec` 重建,但不能反向替代二者,也不能成为表达设计或 presentation authoring intent 的第二份事实来源。 + +## 1. 摘要 + +SimpleCADAPI 接下来需要一层独立于 Operation Graph 的 evaluated scene,用于回答以下问题: + +1. 当前应该显示哪些对象。 +2. 对象之间是什么产品或实例层级。 +3. 每个实例的局部变换是什么。 +4. 每个实例引用哪个可渲染 geometry asset 和 appearance。 +5. 用户点击一个 triangle、face 或 component 时,如何追溯到 scene node、product component、graph output 和 semantic evidence。 +6. Viewer 在不运行 OpenCascade replay 的情况下,如何直接加载并显示模型。 +7. 同一 part 被多次实例化时,如何只保存一份 mesh。 +8. 几何变化、placement 变化和纯显示变化如何采用不同的缓存与更新策略。 + +推荐的数据流为: + +```text +Python modeling source + | + v +Operation Graph / model.json + | + | replay or direct runtime evaluation + v +Solid / Part / Assembly values + | + | Scene Compiler + v +SceneSnapshot / scene.json + | + +---- content-addressed GLB assets + +---- evaluated entity snapshots and picking maps + +---- generic package: optional embedded model.json + +---- self-contained model package: + | model/model.json + | sources/.py + | + v +Browser Viewer / Desktop Viewer +``` + +核心决策是: + +> `model.json` 描述模型如何构建,`scene.json` 描述模型求值后如何被显示和交互,render assets 描述 GPU 实际绘制的数据,workspace 描述某个用户当前的临时界面状态。 + +## 2. 为什么不能直接让 Viewer 消费 Operation Graph + +Operation Graph 的边表达 operation data dependency,而不是 scene parent/child hierarchy。一个 operation node 可以是 profile、selection、boolean、constraint、material assignment 或 diagnostic semantic operation;它不一定对应一个可见对象。 + +如果 Viewer 直接消费 operation graph,就必须承担以下职责: + +- 理解并实现全部 canonical CAD operations。 +- 在浏览器中运行 OpenCascade 或等价 CAD kernel。 +- 解析 operation output、semantic output 和 product output 的不同类型。 +- 根据 graph leaves 推测 visibility。 +- 根据 product operations 重建 definition/instance hierarchy。 +- 自行生成三角网格、法线、边线和 picking 映射。 +- 跟随每次 schema 或 operation set 变化升级执行器。 + +这会把 Viewer 变成第二个 CAD replay backend,显著增加前后端一致性成本,也使一个只想查看模型的客户端必须信任并执行完整建模程序。 + +Scene Snapshot 应当是已经求值的、可验证的、有限能力的显示数据。Viewer 只需要理解 scene/render asset schema;为只读Features和源码inspection,它可以解析embedded model graph的有限记录,但不会实现或执行fillet、loft、constraint solve或CADQL。读取operation records用于展示不等于把Viewer变成CAD replay backend。 + +## 3. 六种 Artifact 的边界 + +### 3.1 Design Model + +当前 `model.json` 属于 Design Model,canonical owner 包括: + +- Operation DAG 和 operation params。 +- Expression intent 和 numeric snapshot。 +- Tolerance requirements。 +- Feature selection intent。 +- Semantic bindings。 +- Topology evolution evidence。 +- Product construction operations。 +- Replay result node IDs。 + +Design Model 的主要用途是 replay、编辑、参数修改、translator 和设计审计。 + +### 3.2 Evaluated Scene + +新增 `scene.json` 属于 Evaluated Scene,canonical owner 只包括某次编译的 evaluated snapshot: + +- 某个 scene revision 的 node hierarchy。 +- Definition/instance 引用关系。 +- Parent-relative rigid transforms。 +- Geometry asset 和 appearance 绑定。 +- 编译后的 Scene visibility、selectability 和 display organization。 +- Scene-level source provenance。 +- 由 `ScenePresentationSpec` 求值后的 named views 和 appearance overrides;annotations 在后续 extension 中引入。 +- Definition-local evaluated topology/entity snapshot 引用。 + +Scene 是 source model 与 optional presentation spec 某次求值的快照。任一 source 更新后 Scene 都可以失效并重新编译。Generated scene 本身不是 authoring source。 + +### 3.3 Render Assets + +Render Assets 负责保存: + +- Vertex positions。 +- Vertex或corner normals。 +- Triangle indices。 +- CAD edge polylines。 +- Local bounds。 +- Face/edge picking ranges。 +- Tessellation settings。 + +第一阶段推荐两个 appearance-neutral GLB profile:triangle geometry asset 和 CAD edge line asset。独立 `EntitySnapshotAsset` 保存 definition/source-specific evaluated topology、geometry properties、SDK tags/metadata、operation provenance 和 picking map。Asset 使用内容哈希寻址,支持不同 scene revision 复用;同一 geometry bytes 可以对应多个 provenance/metadata 不同的 entity snapshot assets。 + +### 3.4 Viewer Workspace + +Viewer Workspace 负责某个用户或某次查看会话的临时状态: + +- 当前 camera pose。 +- Hover 和 selection。 +- 用户临时隐藏的节点。 +- Tree 展开状态。 +- Clipping planes。 +- Exploded view amount。 +- 当前 display mode。 +- Panel layout 和 measurement history。 + +Workspace 默认不属于 canonical scene,也不能修改 model semantics。若需要分享视图,用户必须把明确选择的 workspace state 发布为独立 `ScenePresentationSpec`,然后重新编译 scene。 + +### 3.5 Scene Presentation Spec + +`ScenePresentationSpec` 是可选 authoring artifact,负责不可从 model 确定性重建的发布意图: + +- Named cameras/views。 +- Authored visibility 和 appearance overrides。 +- Labels、callouts 和 read-only dimensions。 +- Authored section presets。 + +它拥有独立 schema、logical ID 和 artifact hash,并作为 `compile_scene(..., presentation=...)` 的显式输入。没有 presentation spec 时,compiler 只生成 deterministic defaults,不从之前生成的 scene 反向读取 authoring state。 + +### 3.6 Connector Binding Spec + +`ConnectorBindingSpec` 是 Viewer/Editor 产生的 revision-bound authoring command,不是 evaluated scene 的一部分。它保存用户选择的 owner、target entity、connector ID、方向选项和 source model/scene preconditions。Python SDK 或受控 backend 验证这些 preconditions,通过trusted deterministic recompilation把revision-local entity解析回唯一live geometry selection、调用canonical connector operation,并生成新的`model.json`和`.scene.zip`。 + +Connector binding 的 canonical owner 最终仍是 Design Model 中的 connector operations/Product semantics。Binding spec 可以作为审计或重试 command 保存,但不能只修改 scene snapshot 后宣称 model 已被编辑。 + +## 4. Canonical Owner 原则 + +每项事实只能有一个 canonical owner。Scene 可以保存从 model 求值得到的缓存,但必须标记来源和失效条件。 + +| 事实 | Canonical owner | Scene 中的表示 | +| --- | --- | --- | +| Box、fillet、boolean 如何构建 | Operation Graph | 不复制 operation semantics | +| 参数表达式和尺寸链 | Expression/Tolerance Graph | 可复制只读摘要,不可编辑为第二份参数源 | +| Assembly definition 和 constraints | Product semantics in model | Scene 保存求值后的 hierarchy、transform 和可选 kinematic projection | +| 当前 component placement | Evaluated product state | Scene node local transform | +| Part 的实体几何 | Replayed BRep value | Content-addressed render asset | +| Material 的物理密度 | Product `Material` | Scene appearance 可引用 source material ID,但不拥有密度 | +| Base color、roughness 等显示属性 | Product-to-appearance deterministic rule 或 `ScenePresentationSpec` | Scene 保存 evaluated Appearance | +| Face 的 semantic tag | Semantic Binding Store | Entity snapshot 中保存 evaluated tag cache 和 binding IDs | +| SDK shape metadata | Runtime/replayed shape metadata | Entity snapshot 保存 JSON-safe snapshot | +| Engine geometry facts | Evaluated OCP BRep | Entity snapshot 保存 typed geometry property snapshot | +| Triangle 属于哪个 face | Definition-specific Entity Snapshot Asset | Scene definition 引用 picking range | +| 当前 hover/selection | Viewer Workspace | 不进入 scene snapshot | +| 作者保存的默认视角 | `ScenePresentationSpec` | Scene 保存 evaluated named camera/view | +| 用户临时相机 | Viewer Workspace | 不进入 canonical scene | +| Connector 定义和 geometry anchor intent | Design Model/Product semantics | Scene 保存 evaluated connector snapshot | +| Viewer 发起的 connector 绑定命令 | `ConnectorBindingSpec`,成功后归并到 Design Model | 不直接修改已有 scene revision | + +## 5. 与 `capture_result` 的关系 + +`capture_result` 继续只定义 Design Model 的 replay outputs。它回答: + +> 哪些 graph-backed values 是 canonical model results。 + +它不回答: + +- 哪些 intermediate objects 在 Viewer 中可见。 +- 哪个 assembly 应该作为 scene root。 +- Camera、lights 或 background 是什么。 +- Component tree 如何展开。 +- Geometry 使用哪个 tessellation quality。 +- Viewer 当前选中了什么。 + +第一阶段不扩展 `capture_result` 的职责,也不增加隐式 display metadata。Low-level Scene compilation 使用显式 root descriptor: + +```python +result = build_gearbox() +assembly, preview = result.value + +package = scad.compile_scene( + scene_id="gearbox-demo", + roots=( + scad.SceneRoot(root_id="main", value=assembly), + ), + source=result, + options=scad.SceneCompileOptions(embed_source=True), +) +scad.export_scene(package=package, path="gearbox.scene.zip") +``` + +Base 1.0 `roots`只接受一个或多个显式`SceneRoot(root_id=..., value=..., transform=..., source_element_id=...)`,不接受裸runtime values,因为裸value没有stable root namespace。Model/manual source中`source_element_id`必须省略,`value`允许`Solid`、含至少一个valid solid的`Compound`、`Part`或`Assembly`。Imported source中`source_element_id` required,必须是importer/caller从exact source artifact提供的stable non-empty element identity,`value`只允许`Solid`或上述`Compound`;Base 1.0不为imported `Part`/`Assembly`发明product provenance。`source_element_id`不能从root sequence、runtime traversal或temporary file path生成,UTF-8长度必须不超过structural ID budget。`transform`省略时规范化为第7.4节exact identity transform;显式transform必须valid rigid transform。Tuple输入在compile开始时按`root_id` unsigned UTF-8 byte order规范化,因此caller sequence不影响manifest;duplicate root ID在sort前失败。 + +如果只有 `model.json`,调用端先 replay captured results,再显式包装为 `SceneRoot`。若 captured result 只包含 flattened compound,scene compiler 只能生成 flat geometry scene,不能凭空恢复已经丢失的 product hierarchy。`root_id` 在一个 scene 内唯一,为 multi-root definition 和 occurrence identity 提供 namespace;compiler 不依赖 sequence position 生成 ID。 + +因此,产品模型若需要保留 hierarchy,至少应满足以下一项: + +1. `capture_result(value=assembly)` 将 assembly 作为 model result。 +2. Runtime CLI 直接把 `ModelResult.value` 中的 assembly 传给 `compile_scene`。 +3. 后续 schema 显式增加 product result role,但不通过猜测 downstream compound 反推 assembly。 + +当前还提供显式opt-in的publishing shortcut:`@model(graph_id=..., export_dir=...)`在model调用结束后自动执行`ModelResult.export_artifacts()`;也可以先取得`ModelResult`,再手动调用`result.export_artifacts(output_dir=...)`。如果作者没有调用`capture_result()`,`@model`先把返回值作为result capture。自动路径从captured values导出`Part`/`Assembly`,没有product value时才导出`Solid`/`Compound`,为roots分配`capture-`,使用`scene_id=graph_id`,强制嵌入model和可解析Python source,并且在output directory外部只写一个`.scene.zip`。它不旁置写出model/session JSON、STEP、STL或FCStd。 + +## 6. 当前能力与缺口 + +### 6.1 已有能力 + +| 能力 | 当前来源 | Scene Compiler 可直接复用 | +| --- | --- | --- | +| Graph provenance | `OperationNode`、`TopoRef`、graph attachment | 是 | +| Explicit model results | `GraphSession.result_node_ids` | 是,但只作为 fallback root evidence | +| Product hierarchy | `Assembly.components` | 是 | +| Repeated part instance | `Component.item` definition reference | 是 | +| Rigid placement | `Placement` | 是 | +| Basic material color | `Material.color` | 是 | +| Triangle/edge render mesh | `RenderMesh`、`RenderEdgeMesh` | 已接入Scene Compiler | +| Face/edge picking range | Entity sidecar groups | 已接入Viewer selection | +| Local bounds | `TriMesh.bounds` | 是 | +| Static collision traversal | Recursive component path + placement composition | 是,可复用 traversal semantics | +| Static screenshot | Existing tessellation and shading code | 只复用经验,不复用 Matplotlib renderer contract | +| Canonical Scene package | `CompiledScenePackage`、`.scene.zip` exporter | 已实现 | +| Source mapping | `OperationNode.source`、`source_files` | 已接入Features/source pane | +| Browser frontend | TypeScript/Three.js/Vite Viewer | 已支持package、tree、Features和五种selection | + +### 6.2 仍缺少的核心能力 + +| 缺口 | 对 Viewer 的影响 | +| --- | --- | +| Presentation compilation 尚未实现 | 不能从authored spec生成named views/overrides | +| Imported source compilation 尚未实现 | 第一阶段compiler只接受manual/model runtime values | +| Browser尚无独立model/scene pairing | 当前只从一个validated package发现embedded model | +| Browser尚无完整operation/entity双向cross-link | Features和geometry inspector当前是独立selection flows | +| Viewer缺少自动化E2E harness | Source滚动/高亮主要依靠build和人工验证 | +| 没有 patch protocol | 每次变化只能全量刷新 | +| 没有 exact measurement boundary | Mesh Viewer 只能近似测量任意 geometry | + +## 7. Scene Schema 1.0 总体结构 + +Scene JSON 顶层推荐结构如下: + +```json +{ + "schema_version": "1.0", + "extensions_used": [], + "extensions_required": [], + "extensions": {}, + "scene_id": "gearbox-demo", + "revision": "sha256:...", + "generator": { + "name": "simplecadapi", + "simplecadapi_version": "2.0.1b1", + "ocp_version": "7.8.1", + "ocp_bindings_version": "7.8.1", + "python_abi": "cp312", + "platform_tag": "macosx_14_0_arm64", + "toolchain_hash": "sha256:...", + "profile": "scene-1.0-ocp-glb-2" + }, + "source": { + "kind": "model", + "graph_id": "gearbox", + "model_schema_version": "2.0", + "artifact_hash": "sha256:...", + "embedded_artifact_uri": "model/model.json", + "embedded_artifact_byte_length": 45678, + "source_files": [ + { + "path": "models/gearbox.py", + "uri": "sources/models/gearbox.py", + "media_type": "text/x-python; charset=utf-8", + "byte_length": 12345, + "content_hash": "sha256:..." + } + ] + }, + "presentation_source": { + "presentation_id": "gearbox-published-view", + "schema_version": "1.0", + "artifact_hash": "sha256:...", + "embedded_artifact_uri": "presentation/presentation.json", + "embedded_artifact_byte_length": 1234 + }, + "coordinate_system": { + "length_unit": "mm", + "handedness": "right", + "up_axis": "+Z" + }, + "compile_options": { + "linear_tolerance": 0.35, + "angular_tolerance": 0.22, + "embed_source": true, + "embed_presentation": true + }, + "definitions": [], + "nodes": [], + "geometry_assets": [], + "edge_assets": [], + "appearances": [], + "entity_assets": [], + "connectors": [], + "cameras": [], + "lights": [], + "annotations": [], + "diagnostics": [] +} +``` + +### 7.1 必填字段 + +第一阶段必填: + +- `schema_version` +- `extensions_used` +- `extensions_required` +- `extensions` +- `scene_id` +- `revision` +- `generator` +- `source` +- `coordinate_system` +- `compile_options` +- `definitions` +- `nodes` +- `geometry_assets` +- `edge_assets` +- `appearances` +- `entity_assets` +- `connectors` +- `cameras` +- `lights` +- `annotations` +- `diagnostics` + +这些 collection 在没有内容时仍以空 array/object 输出,不通过 missing/empty 两种状态表达额外语义。`presentation_source` 是 optional,且只有存在 presentation spec 时允许出现。 + +Base 1.0固定`extensions_used=[]`、`extensions_required=[]`、`extensions={}`和`diagnostics=[]`。Compiler validation/compilation failure通过`SceneValidationReport`返回,不生成带warning/error diagnostic的canonical scene。以后如需发布diagnostics或extensions,必须使用新base schema或完整注册的profile;Base 1.0 loader拒绝任一non-empty value。Workspace runtime error也不写回scene。 + +`SceneValidationReport`的非manifest diagnostic record结构固定为: + +```json +{ + "severity": "warning", + "code": "topology_provenance_unavailable", + "path": "/definitions/0", + "message": "Entity ranges are available without model topology provenance." +} +``` + +`severity`只允许`info`、`warning`、`error`;`code`使用normative semantic rule ID,`path`使用RFC 6901 JSON Pointer。Message是human-readable且不参与artifact identity。Runtime loader/network/GPU diagnostics属于workspace,不修改scene revision。 + +`source` 对所有 scene 必填,并且是 discriminated union: + +- `kind=model`:必须包含 graph/model schema 和 exact input artifact bytes 的 SHA-256 `artifact_hash`;self-contained model package还包含embedded model和`source_files`。 +- `kind=imported`:必须包含 imported artifact format 和 exact bytes hash。 +- `kind=manual`:必须包含调用方提供的 stable `source_id`。 + +这里的 `artifact_hash` 不是尚未定义的 canonical model digest。对于当前 `ModelResult`,它只是 `result.model_json.encode("utf-8")` 的 exact-byte SHA-256。若 package 内嵌 source artifact,URI、byte length 和 hash 必须在加载、下载或 replay 前一致;只改变 model JSON formatting 也会改变 source artifact hash 和 scene revision,这是 Scene 1.0 可接受的保守失效策略。每个Python file独立声明其exact bytes的`content_hash`和`byte_length`,这些records作为manifest内容参与scene revision,但不改变`artifact_hash`的含义。 + +`presentation_source` 只在使用 `ScenePresentationSpec` 时存在。Artifact bytes 被定义为 normalized presentation record 的 JCS bytes,`artifact_hash` 是这些 bytes 的 SHA-256,因此 `compile_scene()` 接受 parsed spec 时也能确定性重建 hash input。Presentation spec 可以嵌入 package,也可以只保留 hash;嵌入时 `embedded_artifact_uri`、`embedded_artifact_byte_length` 必填,且文件内容必须恰好是上述 JCS bytes。生成的 cameras、visibility 和 appearance overrides 都必须能够追溯到这个 source。 + +Scene 1.0 loader只接受exact`schema_version="1.0"`;未来base schema变化必须经过显式migrator。Scene 1.0对未知base fields默认fail closed。Base validator要求三个extension containers为空;不假定所有`1.x`自动兼容,也不接受尚未注册schema/profile的optional payload。 + +### 7.2 Base Record 字段策略 + +Scene 1.0 的所有 base records 和本文定义的 entity sidecar records 都采用同一规则:表格或 discriminated union 未列出的字段一律拒绝;required 字段必须存在;optional 字段没有值时必须省略,禁止用 `null` 代替。只有下表明确标记 nullable 的字段允许 `null`。所有 map key 必须是 Unicode string,所有 number 必须是 JCS 可表示的 finite IEEE-754 binary64 value。 + +| Record | Required fields | Optional fields | Nullable fields | +| --- | --- | --- | --- | +| `generator` | `name`, `simplecadapi_version`, `ocp_version`, `ocp_bindings_version`, `python_abi`, `platform_tag`, `toolchain_hash`, `profile` | 无 | 无 | +| `coordinate_system` | `length_unit`, `handedness`, `up_axis` | 无 | 无 | +| `compile_options` | `linear_tolerance`, `angular_tolerance`, `embed_source`, `embed_presentation` | 无 | 无 | +| `source(kind=model)` | `kind`, `graph_id`, `model_schema_version`, `artifact_hash` | `embedded_artifact_uri`, `embedded_artifact_byte_length`, `source_files` | 无 | +| `SourceFile` | `path`, `uri`, `media_type`, `byte_length`, `content_hash` | 无 | 无 | +| `source(kind=imported)` | `kind`, `format`, `artifact_hash` | `embedded_artifact_uri`, `embedded_artifact_byte_length` | 无 | +| `source(kind=manual)` | `kind`, `source_id` | 无 | 无 | +| `presentation_source` | `presentation_id`, `schema_version`, `artifact_hash` | `embedded_artifact_uri`, `embedded_artifact_byte_length` | 无 | +| `SceneDefinition` | `definition_id`, `kind`, `name`, `source`, `sdk_metadata` | `geometry_asset_id`, `edge_asset_id`, `entity_asset_id`, `appearance_id` | `name` | +| `SceneNode` | `node_id`, `parent_node_id`, `order`, `definition_id`, `name`, `transform`, `visible`, `selectable`, `appearance_override_id`, `source`, `sdk_metadata` | 无 | `parent_node_id`, `name`, `appearance_override_id` | +| `SceneGeometryAsset` | `asset_id`, `uri`, `media_type`, `byte_length`, `content_hash`, `scene_local_bounds`, `asset_to_scene`, `tessellation` | 无 | 无 | +| `SceneEdgeAsset` | `asset_id`, `uri`, `media_type`, `byte_length`, `content_hash`, `scene_local_bounds`, `asset_to_scene`, `tessellation` | 无 | 无 | +| `SceneEntityAsset` | `entity_asset_id`, `uri`, `media_type`, `byte_length`, `content_hash` | 无 | 无 | +| `SceneAppearance` | `appearance_id`, `name`, `source`, `base_color`, `metallic`, `roughness`, `alpha_mode`, `double_sided`, `edge_color`, `sdk_metadata` | 无 | `name`, `source` | +| `SceneConnector` | `connector_snapshot_id`, `owner_definition_id`, `connector_id`, `name`, `anchor_kind`, `local_transform`, `source`, `sdk_metadata` | `target`, `forwarded_from` | `name`, `source` | +| `SceneCamera` | `camera_id`, `name`, `projection`, `parent_node_id`, `transform`, `near`, `far` and projection-specific field | 无 | `parent_node_id` | + +`embedded_artifact_uri` 和 `embedded_artifact_byte_length` 必须成对出现。`SourceFile`是closed record。Schema-level generic model scene可以省略`source_files`;compiler-produced self-contained model package在嵌入model时总是输出该array,即使没有可解析的project-relative operation source而为空。`SceneDefinition.source` 是 `model_output`、`product_model`、`product_manual`、`imported`、`manual` union;`SceneNode.source` 是 `product_occurrence`、`shape_root` union。各 variant 的 exact fields 在第 8 节定义。`sdk_metadata` 必须通过第 10 节的 JSON-safe policy 规范化,不能包含 runtime object。 + +`generator.name`固定为`simplecadapi`。三个version fields、`python_abi`和`platform_tag`使用ASCII token grammar`[A-Za-z0-9][A-Za-z0-9._+-]{0,127}`,不得使用`2.x`、path或缺失fallback。`toolchain_hash`按第10.4节toolchain descriptor求SHA-256;它覆盖所有output-affectingPython modules、native OCP/kernel binaries和build identity。Backend只有在拥有同hash的registered toolchain时才能声称可重现scene。`generator.profile`是deterministic compiler profile ID,并参与revision。`media_type`固定为对应profile定义的exact value;Base 1.0不接受等价alias。`byte_length`是非负integer。所有content-hash IDs使用第16节的exact grammar。 + +Nested common records也采用exact fields:rigid transform只允许`origin`、`x_axis`、`y_axis`、`z_axis`;bounds只允许`min`、`max`;`compile_options`和geometry`tessellation`只允许positive`linear_tolerance`、positive`angular_tolerance`;edge`tessellation`只允许positive`linear_tolerance`。每个geometry asset的两个tessellation values和每个edge asset的linear value必须exact等于top-level resolved`compile_options`。`asset_to_scene`必须恰好是第7.3节固定的16-number matrix,不接受exporter-specific alternative。 + +Embedding conditions是exact contract:`source.kind=model|imported`且`embed_source=true`时source URI/length必须存在且bytes/hash匹配;`embed_source=false`时两者必须省略。Embedded model的URI固定为`model/model.json`。其`source_files`按`path`的unsigned UTF-8 bytes排序且唯一,每个URI必须exact等于`sources/`;path必须匹配archive-safe ASCII `.py` grammar `^[A-Za-z0-9][A-Za-z0-9._/-]*\.py$`,不得有empty、`.`或`..` segment,也不得产生case-insensitive collision。每个source member必须是strict UTF-8并通过自己的length/hash验证。`presentation_source`存在且`embed_presentation=true`时其URI/length必须存在;false时省略。没有presentation时`embed_presentation`必须为false。Manual source不能embedded,要求`embed_source=false`。这两个booleans参与revision并使相同input tuple的package policy确定;其他canonical URI固定为`source/source.bin`和`presentation/presentation.json`。 + +Embedded model中每个operation node的source mapping是source mapping schema 1.0 closed record,exact fields为`schema_version`、`path`、`path_kind`、`line`、`column`、`end_line`、`end_column`、`call_text`、`callsite_id`和`assignment_targets`。`path_kind=project_relative`时`path`必须解析到一个manifest-declared source file,声明的source span必须与immutable file bytes中的`call_text` exact相等;`path_kind=unresolved`时`path=null`。`callsite_id`必须能从path、四个coordinates和`call_text`的canonical material重新计算。Source mapping只用于只读provenance和Viewer定位,不参与operation replay。 + +以下 nested records 同样是 exact contract: + +- `SelectionRef(kind=component)` required且只允许 `kind`、`scene_node_id`;`SelectionRef(kind=entity)` required且只允许 `kind`、`scene_node_id`、`entity_asset_id`、`entity_id`。 +- Face/edge group fields和 integer constraints由第 10.2 节定义。 +- `ConnectorBindingSpec.target(kind=topology_entity)` required且只允许 `kind`、`entity_asset_id`、`entity_id`、`expected_source`、`flip`;`expected_source` 必须是 entity中exact `model_output` 或 `model_topology` source record。Vertex target强制 `flip=false`。 +- `ConnectorBindingSpec.source_model` 只允许 `graph_id`、`model_schema_version`、`artifact_hash`;`source_scene` 只允许 `scene_id`、`revision`。Top-level只允许第 14.4 节示例中的九个 fields,全部 required,其中 `name` required-nullable。 +- `ScenePresentationSpec` 的六个 top-level fields全部 required:`schema_version`、`presentation_id`、`source_scene_id`、`appearances`、`node_overrides`、`cameras`。其 nested exact records由第 12.4 节定义。 +- `SceneNode.order` 是 non-negative integer;每个 root和每组 siblings分别从 0连续编号。Boolean fields必须是 JSON boolean,不能接受 0/1 coercion。 + +所有 JSON integer,包括 byte length、output slot、order、count、ordinal、range和GLB integer metadata,都必须位于 `[0, 9007199254740991]`;具体resource budget通常更小。Python与TypeScript validator不得接受会在JavaScript `Number`中丢失精度的integer。Float fields遵循finite binary64/JCS规则。 + +Phase A必须提交五个structural JSON Schema 2020-12 files:`scene-1.0.schema.json`、`entities-1.0.schema.json`、`presentation-1.0.schema.json`、`connector-binding-1.0.schema.json`和`normalized-product-1.schema.json`。JSON Schema不能表达JCS bytes、duplicate keys、hashes、ordering、cross-file references、cycles、ZIP或GLB rules,因此它不是完整validity authority。Phase A还必须提交normative`scene-1.0-rules.json`:每条semantic/package rule有stable lower-snake-case ID、applicable artifact、evaluation phase、JSON Pointer policy和precedence;Python/TypeScript validators必须按phase及rule ID的unsigned UTF-8 byte order报告第一个error。五个schemas、rule registry、两个profile specs及其hash-linked pseudocode、golden boundary/malformed corpus和exact JCS/GLB/ZIP vectors共同构成freeze source of truth。本文示例不得替代它们;全部cross-language tests通过后才能把状态从`Proposed`改为`Frozen`。 + +### 7.3 坐标与单位 + +Scene Schema 1.0 固定: + +- 右手坐标系。 +- `+Z` 为 up axis。 +- 默认长度单位为 `mm`。 +- Node transform 必须是 rigid transform,第一阶段不支持 scale、shear 或 reflection。 + +Scene hierarchy 和 bounds 使用上述 CAD scene coordinates。GLB asset 必须保持标准 glTF 2.0 坐标约定:右手、`+Y` up、线性单位为 meter。Compiler 使用固定转换将 CAD local points 写入 GLB: + +```text +gltf_m = (cad_x_mm, cad_z_mm, -cad_y_mm) / 1000 +``` + +每个 geometry/edge asset manifest 显式保存同一固定 `asset_to_scene` affine transform,将 GLB meter coordinates 转回 CAD scene millimeters: + +```text +cad_mm = (1000 * gltf_x_m, -1000 * gltf_z_m, 1000 * gltf_y_m) +``` + +`asset_to_scene` 允许 fixed unit scale,因此不属于 Scene Node 的 rigid transform。Manifest 中的 16 个数字按 row-major、column-vector convention 解释。Renderer 的顺序必须是 `node_world_transform * asset_to_scene * asset_position`。不能把非标准 mm/`+Z` buffer 标记为 `model/gltf-binary`,也不能同时对 mesh 和 scene root 重复做 axis conversion。 + +### 7.4 Transform 表示 + +为避免 matrix、quaternion 和 axes 三套表示互相不一致,Scene 1.0 只使用一种 canonical rigid transform: + +```json +{ + "origin": [0.0, 0.0, 0.0], + "x_axis": [1.0, 0.0, 0.0], + "y_axis": [0.0, 1.0, 0.0], + "z_axis": [0.0, 0.0, 1.0] +} +``` + +Validator 必须检查: + +- 所有数字 finite。 +- 每个 axis 为单位向量。 +- axes 两两正交。 +- `z_axis` 与 `cross(x_axis, y_axis)` 一致。 +- transform 是 parent-relative,不是重复保存的 world transform。 + +Base numeric profile固定:axis norm error、pairwise dot absolute value和cross-product component error都必须`<= 1e-12`。所有scene-coordinate point/origin/bounds components必须位于`[-1e12, 1e12]` mm;所有GLB float32 values必须finite。Bounds containment和derived frame composition使用`epsilon_mm = max(1e-9, 1e-12 * max(1, max_abs_coordinate))`;方向/axis比较仍使用`1e-12`。Non-negative length/area/volume不能通过epsilon接受负数。Validator不得使用平台默认`isclose`或随模型大小任意变化的hidden tolerance。 + +Renderer 可以在加载时派生 4x4 matrix。World transform 永远由 hierarchy composition 派生,不进入 canonical scene JSON。 + +## 8. Definition 与 Instance 模型 + +### 8.1 Definition + +Definition 表示可复用的 product 或 geometry definition: + +```json +{ + "definition_id": "definition/main/part/input-shaft", + "kind": "part", + "name": "Input Shaft", + "geometry_asset_id": "sha256:...", + "edge_asset_id": "sha256:...", + "entity_asset_id": "sha256:...", + "appearance_id": "appearance/evaluated/0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + "source": { + "kind": "product_model", + "root_id": "main", + "semantic_type": "Part", + "semantic_id": "input-shaft", + "graph_id": "gearbox", + "node_id": "node_...", + "output_slot": 0 + }, + "sdk_metadata": {} +} +``` + +支持的 `kind` 初始包括: + +- `part` +- `assembly` +- `shape` + +Assembly definition 通常没有 `geometry_asset_id`,其可见内容来自 descendant part instances。Standalone captured `Solid` 或 `Compound` 使用 `shape` definition。 + +Definition asset conditions 是:`part` 和 renderable `shape` 必须同时包含 `geometry_asset_id`、`edge_asset_id`、`entity_asset_id`、`appearance_id`。`assembly` 必须省略全部四个 asset/appearance refs,其可见内容只来自 descendants。每个 geometry/entity/edge ref 必须存在并形成一致的 definition-local triple;manifest 中不允许 unreferenced asset records。Base 1.0 不表达 generic group、intentionally non-renderable Part/Shape 或 shaded-only profile;这些需求以后通过 explicit capability 引入。 + +Definition `source` variants 的 exact fields 为: + +- `kind=product_model`:required `kind`、`root_id`、`semantic_type`、`semantic_id`、`graph_id`、`node_id`、`output_slot`。`semantic_type` 只允许 `Part` 或 `Assembly`,并分别要求 definition `kind=part` 或 `kind=assembly`。 +- `kind=product_manual`:required且只允许 `kind`、`root_id`、`semantic_type`、`semantic_id`;只允许 scene source `kind=manual`,不参与 model/DAG provenance。 +- `kind=model_output`:required `kind`、`root_id`、`graph_id`、`node_id`、`output_slot`。 +- `kind=imported`:required `kind`、`root_id`、`source_element_id`。 +- `kind=manual`:required `kind`、`root_id`、`source_id`。 + +Top-level/source compatibility固定: + +| Scene `source.kind` | Definition source | Entity source | Connector source | Node source | +| --- | --- | --- | --- | --- | +| `model` | `product_model` or `model_output` | `model_output` or proven `model_topology` | `model_operation` | `product_occurrence` or `shape_root` | +| `imported` | `imported` | `imported_primitive` or `unbound` | `null` | `shape_root` | +| `manual` | `product_manual` or `manual` | `unbound` | `manual` | `product_occurrence` or `shape_root` | + +Base 1.0不允许一份scene混合不同行的provenance variants。Imported/manual entities可用于selection/inspection但`connector_binding_status`不能是`supported`。 + +Definition/node/source compatibility还必须满足以下exact matrix: + +- `product_model`只用于`kind=part|assembly`,`semantic_type`必须分别为`Part|Assembly`;top-level source必须是`model`,nested `graph_id`必须等于top-level `source.graph_id`。 +- `product_manual`只用于`kind=part|assembly`且`semantic_type`同样匹配;top-level source必须是`manual`。 +- `model_output`只用于`kind=shape`且top-level source必须是`model`,nested `graph_id`必须等于top-level `source.graph_id`。 +- `imported`只用于`kind=shape`且top-level source必须是`imported`;`source_element_id`必须等于对应`SceneRoot.source_element_id`。Imported scene固定`connectors=[]`。 +- `manual`只用于`kind=shape`且top-level source必须是`manual`;nested `source_id`必须exact等于top-level `SceneSource.source_id`。 +- `product_occurrence` node只能引用同`root_id`的`part|assembly` definition,其source必须是对应top-level row的`product_model|product_manual`。`shape_root` node只能引用同`root_id`的`shape` definition,其source必须是`model_output|imported|manual`。 +- 每个node source、referenced definition source和derived structural IDs中的`root_id`必须相同。Model definition/entity/connector records中的每个nested `graph_id`必须等于top-level model graph ID;manual definition/connector nested `source_id`必须等于top-level manual source ID。 + +`sdk_metadata`保存definition owner的JSON-safe evaluated metadata。Part/Assembly owner metadata按第10.2节projection policy处理。Shape-level metadata通常不放在definition record,而放在entity snapshot的owning solid/entity record,避免definition与topology sidecar两份cache分歧。唯一例外是flattened Compound root:Compound自身不是Base entity kind,因此其projected Compound-level tags和metadata存入Shape definition`sdk_metadata`的exact keys`compound_tags`和`compound_metadata`;child Solid metadata仍留在entity records。两个keys在non-Compound definition中禁止。 + +### 8.2 Scene Node + +Scene node 表示 hierarchy 中的一次 occurrence: + +```json +{ + "node_id": "instance/main/stage1/input-shaft", + "parent_node_id": "instance/main/stage1", + "order": 3, + "definition_id": "definition/main/part/input-shaft", + "name": "Input Shaft Instance", + "transform": { + "origin": [0.0, 0.0, 18.0], + "x_axis": [1.0, 0.0, 0.0], + "y_axis": [0.0, 1.0, 0.0], + "z_axis": [0.0, 0.0, 1.0] + }, + "visible": true, + "selectable": true, + "appearance_override_id": null, + "source": { + "kind": "product_occurrence", + "root_id": "main", + "component_path": ["stage1", "input-shaft"] + }, + "sdk_metadata": {} +} +``` + +Scene 只保存 `parent_node_id`,不同时保存 mutable `children` 数组,避免 parent/children 两份结构产生分歧。Sibling 显示顺序通过 `order` 显式保存。Root node 的 `parent_node_id` 为 `null`。 + +Node hierarchy 必须: + +- 无环。 +- 每个 non-root parent 存在。 +- `node_id` 全局唯一。 +- 每个 referenced definition 存在。 +- 同一个 parent 下 `order` 不重复。 + +Scene 1.0 node 是 product/shape occurrence,必须引用 definition。Camera 和 light 不伪装成 product node,使用各自 record 中的 parent-relative transform。 + +Canonical `node.visible` 和 `node.selectable` 向 descendants传播:`effective_visible = node.visible && all_ancestor_visible`,`effective_selectable = node.selectable && all_ancestor_selectable && effective_visible`。Workspace hide/isolate在 canonical effective visibility之后叠加且不写回 scene。`appearance_override_id` 不继承,只影响该 node自己引用的 renderable definition;assembly node的 override必须为 `null`。 + +Node `source` variants 的 exact fields 为: + +- `kind=product_occurrence`:required且只允许`kind`、`root_id`、`component_path`。Root occurrence的`component_path=[]`。 +- `kind=shape_root`:required `kind`、`root_id`。 + +Product occurrence identity必须完全派生:`node_id`等于`instance/`追加每个encoded component path segment;parent的component path必须恰为child path去掉最后一段,且`parent_node_id`等于其derived ID。Root occurrence的`parent_node_id=null`、`order`等于roots按`root_id` unsigned UTF-8 byte order排序后的ordinal。每组child `order`来自owner Assembly declaration order并从0连续编号。`visible=true`、`selectable=true`、`appearance_override_id=null`是没有presentation override时的exact defaults。Definition `name`等于owner Part/Assembly nullable name,Shape definition name固定`null`。Root Product occurrence `name`等于root Part/Assembly name;descendant occurrence先使用nullable`Component.name`,为null时使用referenced Part/Assembly name,仍缺失则为null;Shape root occurrence name固定`null`。Compiler不合成display label。 + +Root occurrence `instance/.transform`必须exact等于resolved `SceneRoot.transform`;它是root-local frame到scene frame的唯一serialized placement。每个descendant occurrence的`transform`只保存其owner Assembly中parent-relative Component placement,不预乘root或ancestor transforms。World transform按第7.4节从root到leaf依次composition。Standalone Shape root使用相同root transform rule。 + +`sdk_metadata` 是 occurrence-level evaluated metadata。当前 `Component` API 没有 metadata hook,因此 product occurrences 输出 `{}`;保留该 required field 是为了让 component inspector 与未来 occurrence metadata extension 有稳定位置,不能把 definition metadata复制到这里。 + +### 8.3 Repeated Instances + +同一个 `Part` 被多个 `Component` 引用时: + +- 只生成一个 definition。 +- 只生成一个 geometry asset。 +- 为每个 component path 生成独立 scene node。 +- 每个 node 保存自己的 transform、visibility 和 selection identity。 + +Nested repeated assembly 可以展开为多组 occurrence nodes,但 descendant part nodes 继续引用相同 part definition 和 geometry asset。 + +## 9. Geometry Asset Contract + +### 9.1 Render Mesh 不等于 Collision Mesh + +当前 private `TriMesh` 可以作为 Scene Compiler 的算法起点,但不应直接公开。需要新增独立的 triangle `RenderMesh` 和 line `RenderEdgeMesh` contract,原因包括: + +- Collision mesh 只需要 positions 和 triangles。 +- Triangle render mesh 需要 normals 和 face groups;edge overlay 需要独立 line buffer。 +- Picking 需要 source entity ranges。 +- Render asset 需要稳定 binary encoding 和 content hash。 +- 后续 LOD、compression 和 quantization 不应影响 collision verifier。 + +推荐的内部不可变类型: + +```python +@dataclass(frozen=True) +class RenderMesh: + positions: np.ndarray + normals: np.ndarray + indices: np.ndarray + face_groups: tuple[RenderFaceGroup, ...] + bounds: Bounds3 + linear_tolerance: float + angular_tolerance: float + +@dataclass(frozen=True) +class RenderEdgeMesh: + positions: np.ndarray + indices: np.ndarray + edge_groups: tuple[RenderEdgeGroup, ...] + bounds: Bounds3 + linear_tolerance: float +``` + +### 9.2 Geometry Asset Manifest + +```json +{ + "asset_id": "sha256:...", + "uri": "geometry/sha256-....glb", + "media_type": "model/gltf-binary", + "byte_length": 123456, + "content_hash": "sha256:...", + "scene_local_bounds": { + "min": [-10.0, -10.0, 0.0], + "max": [10.0, 10.0, 50.0] + }, + "asset_to_scene": [ + 1000.0, 0.0, 0.0, 0.0, + 0.0, 0.0, -1000.0, 0.0, + 0.0, 1000.0, 0.0, 0.0, + 0.0, 0.0, 0.0, 1.0 + ], + "tessellation": { + "linear_tolerance": 0.35, + "angular_tolerance": 0.22 + } +} +``` + +`asset_id` 与 `content_hash` 第一阶段相同,均为 deterministic GLB bytes 的 SHA-256。保留两个字段是为了将来支持 logical asset identity,但第一阶段禁止二者不一致。Geometry asset 不引用 entity asset,也不保存 graph/topology provenance;同一 geometry bytes 可以被不同 model/source definitions 复用。Geometry URI 必须是 `geometry/sha256-.glb`,其中 hex 与 ID 相同。 + +`scene_local_bounds`不是pre-quantization kernel bounds;它必须从GLB POSITION accessor中的canonical float32 values直接应用`asset_to_scene`映射到scene coordinates后逐分量计算,即`cad_mm=(1000*x,-1000*z,1000*y)`。Package validator从validated immutable GLB bytes重复该计算并要求JCS numeric value exact相等。Edge asset采用相同规则。Entity properties中的kernel-evaluated bounds是另一份精度更高的CAD evidence,不要求与render bounds bitwise相等。 + +### 9.3 GLB 使用范围 + +第一阶段 triangle GLB 只承担 appearance-neutral binary render geometry: + +- positions +- normals +- indices + +Scene hierarchy、product provenance、CAD topology identity 和 viewer annotations 不交给标准 GLB scene graph 管理。这些内容由 `scene.json` 和 entity snapshot sidecar 管理。 + +原因是: + +- Scene hierarchy 需要 product/component path identity。 +- 同一 geometry asset 需要被多个 scene node 实例化。 +- Face-level picking 需要 graph/topology provenance。 +- CAD-specific metadata 不应被塞入大量不受约束的 glTF `extras`。 + +Scene 1.0 triangle GLB profile 必须满足: + +- 使用标准 glTF 2.0 meter、右手、`+Y` up coordinates。 +- 恰好一个 scene、一个 identity node、一个 mesh 和一个 `TRIANGLES` primitive。 +- Primitive 使用一个 `POSITION`、一个 `NORMAL` 和一个 index accessor。 +- Index accessor 使用 unsigned 16-bit 或 unsigned 32-bit scalar。 +- 不包含 glTF materials;definition/instance appearance 由 scene manifest 绑定。 +- 不包含 images、textures、animations、skins、morph targets、cameras、lights、sparse accessors 或 nested URI。 +- 只允许一个 GLB BIN-backed buffer,`buffer.uri` 必须 absent。 +- 不允许 `extras`、unknown extensions、Draco 或 Meshopt。 +- Face range 使用 primitive-local index ordinal;Scene 1.0 固定 `mesh_index=0`、`primitive_index=0`。 + +一个 primitive 的限制使 face ranges 存在唯一 global index-ordinal space,也保证 appearance-only change 不改变 GLB bytes。未来多 material slot 必须在新 capability 中定义 external primitive-slot mapping,不能偷偷向同一 1.0 asset 增加 primitives。 + +Triangle GLB 的 JSON object 必须等价于以下 exact closed skeleton;除替换 `<...>` values外不得增加/省略 fields。引号中的angle-bracket tokens只是本文metavariable,实际`count`、`byteLength`、`byteOffset`、`componentType`和bounds components必须是JSON integer/number,绝不能序列化为string: + +```json +{ + "accessors": [ + {"bufferView": 0, "componentType": 5126, "count": "", "max": ["", "", ""], "min": ["", "", ""], "type": "VEC3"}, + {"bufferView": 1, "componentType": 5126, "count": "", "type": "VEC3"}, + {"bufferView": 2, "componentType": "<5123-or-5125>", "count": "", "type": "SCALAR"} + ], + "asset": {"generator": "SimpleCAD Scene GLB Profile 1", "version": "2.0"}, + "bufferViews": [ + {"buffer": 0, "byteLength": "<12*vertex_count>", "byteOffset": 0, "target": 34962}, + {"buffer": 0, "byteLength": "<12*vertex_count>", "byteOffset": "", "target": 34962}, + {"buffer": 0, "byteLength": "", "byteOffset": "", "target": 34963} + ], + "buffers": [{"byteLength": ""}], + "meshes": [{"primitives": [{"attributes": {"NORMAL": 1, "POSITION": 0}, "indices": 2, "mode": 4}]}], + "nodes": [{"mesh": 0}], + "scene": 0, + "scenes": [{"nodes": [0]}] +} +``` + +Line GLB 使用同一 exact fields,但只有两个 accessors/bufferViews:accessor 0 是 POSITION float32 VEC3并包含 min/max,accessor 1 是 index SCALAR;primitive恰为 `{"attributes":{"POSITION":0},"indices":1,"mode":1}`。`bufferViews[0].target=34962`、`bufferViews[1].target=34963`。不存在 NORMAL accessor。 + +BIN layout 对 triangle 固定为 tightly packed position float32 triples、normal float32 triples、indices;line固定为 positions、indices。每段 start 4-byte aligned,段间和最终 BIN padding为 zero。`byteOffset` 即上述 prefix加alignment;即使为 0也必须按 skeleton输出。`buffer.byteLength` 是最后一个有效byte后的 unpadded length;GLB BIN chunk length向上4-byte对齐。Accessor不输出 `byteOffset`、`normalized`、`name`、`sparse`或其他 optional fields;bufferView不输出 `byteStride`或`name`。POSITION min/max从写入buffer的 canonical glTF float32 values逐分量计算并以JCS number输出;normal/index accessors省略min/max。 + +Index component type在`vertex_count <= 65536`时固定5123,否则5125;所有vertices必须至少被一个index引用,所以该条件等价于`max_index <= 65535`。空geometry禁止,因此不用5121。JSON chunk是上述object的RFC 8785 JCS UTF-8 bytes,尾部只用ASCII space补到4-byte;BIN只用zero补齐。GLB header和chunk headers按glTF 2.0 little-endian exact constants写出,total length必须精确等于两chunks。任何timestamp、path、toolchain version或random value都禁止进入GLB;toolchain只在scene manifest`generator`中。 + +第10.4节冻结vertex/entity/group ordering和normal policy。如果OCP tessellation只保证集合等价,writer必须先执行这些规范化规则;不能把偶然的Python/OCP iteration order作为contract。Phase A必须发布triangle与line各至少一个normative exact-byte golden GLB fixture及其SHA-256,Python和TypeScript preflight都必须接受;Phase B writer必须逐byte重现这些fixtures。 + +### 9.4 Normals + +当前 tessellator 没有公开 normals。Scene Compiler 必须定义明确策略: + +1. 优先读取 kernel triangulation normals。 +2. 无可用 normals 时,根据 oriented triangle 计算 face normals。 +3. 跨 CAD face 默认不平滑,避免圆角边界或 sharp edge 被错误平滑。 +4. 同一 CAD face 内也不做跨 triangle averaging;使用第 10.4 节规定的 kernel corner normal 或 oriented triangle fallback。 +5. 所有 normals 必须 finite 且 normalized。 + +Position转换为canonical glTF float32后,任意两个corner bitwise相同或profile binary32 cross product恰为zero的triangle作为collapsed tessellation丢弃。Edge endpoint转换后bitwise相同的segment同样丢弃。丢弃发生在canonical sort和group range构造前;remaining index stream不得包含未引用vertex。若因此使某个face为空,Base 1.0 compilation失败;edge为空时按第10.2节标记`render_status=degenerate`并省略其group。Compiler不得输出NaN normal、zero normal、degenerate primitive或zero-count accessor来隐式降级。 + +### 9.5 CAD Edge Overlay + +CAD Viewer 不能用 triangle boundary 代替 CAD edge。Triangle boundary 会显示 tessellation 内部对角线,也无法保持 analytic edge 语义。 + +需要单独对 BRep edges 做曲线离散化,保存: + +- Edge polyline positions。 +- Source edge reference。 +- Boundary/seam 可选分类。 +- Local bounds。 + +Edge asset 也使用 content-addressed GLB,但采用独立 Scene 1.0 line profile:恰好一个 `LINES` primitive,positions 使用 standard glTF coordinates,indices 将每条 curve 离散为 segment pairs。每个 edge group 引用同一 primitive 中连续、偶数长度的 index range。Viewer 使用 line pass 绘制 edge overlay。Silhouette 可以由 GPU 派生,但不能替代完整 CAD edge asset。 + +Edge asset manifest 与 geometry asset manifest 使用相同的 `asset_id`、media type、byte length、content hash、scene-local bounds 和 `asset_to_scene` 字段;其 URI 固定为 `edges/sha256-.glb`,`tessellation` 只有 `linear_tolerance`。Base 1.0 每个 renderable definition 都必须引用 edge asset;普通 shaded-only scene 需要后续单独 profile,不能通过省略 Base required asset 隐式降级。 + +## 10. Evaluated Entity Snapshot、Picking 与 Provenance + +### 10.1 Selection Intent 与解析 + +Viewer MVP 支持五种显式 selection intent: + +1. `component`:选择一个 scene occurrence,identity 是 `scene_node_id`。 +2. `solid`:从命中的 face 解析其 owning solid entity。 +3. `face`:从 triangle primitive index 解析 face range。 +4. `edge`:从 CAD edge line segment 解析 edge range。 +5. `vertex`:对 entity snapshot 中的 vertex point 建立 runtime point-picking buffer,并使用 screen-space threshold 命中。 + +Viewer 不从 triangle adjacency 猜测 CAD face/edge/vertex。一次 viewport hit 按以下顺序解析:GPU draw/instance -> scene occurrence -> definition -> entity asset -> render range -> entity record -> source graph/topology provenance。Solid selection 使用 face record 的 `parent_entity_ids` 找到唯一 owning solid;vertex selection 的 point buffer只用于 picking/highlight,不作为默认可见模型内容。 + +`component` 与 `solid` 不是同义词。一个 component occurrence 可以引用 assembly definition而没有 solid,也可以在后续 multi-body capability 中拥有多个 solids。Repeated instances 共享同一 entity asset,但通过不同 `scene_node_id` 保持 occurrence identity。 + +### 10.2 Entity Snapshot Asset + +Scene manifest 中的 entity asset record 只保存 transport metadata: + +```json +{ + "entity_asset_id": "sha256:...", + "uri": "entities/sha256-....json", + "media_type": "application/vnd.simplecad.entities+json", + "byte_length": 34567, + "content_hash": "sha256:..." +} +``` + +`entity_asset_id` 与 `content_hash` 在 Scene 1.0 中相同。Payload 本身不包含 `entity_asset_id`,避免 self-hash cycle;其 JCS bytes 定义 content hash。一个 geometry asset 可以对应多个 source/provenance/metadata 不同的 entity assets。 + +Entity URI 必须是 `entities/sha256-.json`,其中 hex 与 ID 相同。Definition 引用 geometry 时 entity asset mandatory,不能用 missing sidecar 表示“只看 mesh”;不需要 metadata/picking 的客户端可以选择不下载,但 package 仍必须完整包含并验证它。 + +```json +{ + "schema_version": "1.0", + "definition_id": "definition/main/part/input-shaft", + "geometry_asset_id": "sha256:...", + "edge_asset_id": "sha256:...", + "geometry_engine": { + "name": "OpenCascade", + "version": "7.8.1", + "profile": "ocp-evaluated-properties-1" + }, + "entities": [ + { + "entity_id": "entity/solid/0", + "kind": "solid", + "parent_entity_ids": [], + "child_entity_ids": ["entity/face/0"], + "source": { + "kind": "model_output", + "graph_id": "gearbox", + "node_id": "node_...", + "output_slot": 0 + }, + "geometry": {"type": "brep_solid"}, + "properties": { + "quality": "kernel_evaluated", + "bounds": {"min": [-10.0, -10.0, 0.0], "max": [10.0, 10.0, 50.0]}, + "volume": 12500.0, + "surface_area": 4100.0, + "centroid": [0.0, 0.0, 25.0] + }, + "sdk_connector_frame": null, + "render_status": "rendered", + "connector_binding_status": "not_applicable", + "semantic_binding_ids": [], + "evaluated_tags": ["solid.body"], + "sdk_metadata": {} + }, + { + "entity_id": "entity/face/0", + "kind": "face", + "parent_entity_ids": ["entity/solid/0"], + "child_entity_ids": ["entity/edge/0"], + "source": { + "kind": "model_output", + "graph_id": "gearbox", + "node_id": "node_...", + "output_slot": 0 + }, + "geometry": { + "type": "plane", + "origin": [0.0, 0.0, 50.0], + "normal": [0.0, 0.0, 1.0], + "x_direction": [1.0, 0.0, 0.0] + }, + "properties": { + "quality": "kernel_evaluated", + "bounds": {"min": [-10.0, -10.0, 50.0], "max": [10.0, 10.0, 50.0]}, + "area": 400.0, + "centroid": [0.0, 0.0, 50.0], + "orientation": "forward" + }, + "sdk_connector_frame": { + "origin": [0.0, 0.0, 50.0], + "x_axis": [1.0, 0.0, 0.0], + "y_axis": [0.0, 1.0, 0.0], + "z_axis": [0.0, 0.0, 1.0] + }, + "render_status": "rendered", + "connector_binding_status": "supported", + "semantic_binding_ids": ["binding-..."], + "evaluated_tags": ["role.mounting_surface"], + "sdk_metadata": {"manufacturing": {"finish": "ground"}} + }, + { + "entity_id": "entity/edge/0", + "kind": "edge", + "parent_entity_ids": ["entity/face/0"], + "child_entity_ids": ["entity/vertex/0", "entity/vertex/1"], + "source": {"kind": "model_output", "graph_id": "gearbox", "node_id": "node_...", "output_slot": 0}, + "geometry": {"type": "line", "origin": [-10.0, 0.0, 50.0], "direction": [1.0, 0.0, 0.0]}, + "properties": { + "quality": "kernel_evaluated", + "bounds": {"min": [-10.0, 0.0, 50.0], "max": [10.0, 0.0, 50.0]}, + "length": 20.0, + "centroid": [0.0, 0.0, 50.0] + }, + "sdk_connector_frame": { + "origin": [0.0, 0.0, 50.0], + "x_axis": [0.0, 1.0, 0.0], + "y_axis": [0.0, 0.0, 1.0], + "z_axis": [1.0, 0.0, 0.0] + }, + "render_status": "rendered", + "connector_binding_status": "supported", + "semantic_binding_ids": [], + "evaluated_tags": [], + "sdk_metadata": {} + }, + { + "entity_id": "entity/vertex/0", + "kind": "vertex", + "parent_entity_ids": ["entity/edge/0"], + "child_entity_ids": [], + "source": {"kind": "model_output", "graph_id": "gearbox", "node_id": "node_...", "output_slot": 0}, + "geometry": {"type": "point", "position": [-10.0, 0.0, 50.0]}, + "properties": { + "quality": "kernel_evaluated", + "bounds": {"min": [-10.0, 0.0, 50.0], "max": [-10.0, 0.0, 50.0]}, + "position": [-10.0, 0.0, 50.0] + }, + "sdk_connector_frame": { + "origin": [-10.0, 0.0, 50.0], + "x_axis": [1.0, 0.0, 0.0], + "y_axis": [0.0, 1.0, 0.0], + "z_axis": [0.0, 0.0, 1.0] + }, + "render_status": "rendered", + "connector_binding_status": "supported", + "semantic_binding_ids": [], + "evaluated_tags": [], + "sdk_metadata": {} + }, + { + "entity_id": "entity/vertex/1", + "kind": "vertex", + "parent_entity_ids": ["entity/edge/0"], + "child_entity_ids": [], + "source": {"kind": "model_output", "graph_id": "gearbox", "node_id": "node_...", "output_slot": 0}, + "geometry": {"type": "point", "position": [10.0, 0.0, 50.0]}, + "properties": { + "quality": "kernel_evaluated", + "bounds": {"min": [10.0, 0.0, 50.0], "max": [10.0, 0.0, 50.0]}, + "position": [10.0, 0.0, 50.0] + }, + "sdk_connector_frame": { + "origin": [10.0, 0.0, 50.0], + "x_axis": [1.0, 0.0, 0.0], + "y_axis": [0.0, 1.0, 0.0], + "z_axis": [0.0, 0.0, 1.0] + }, + "render_status": "rendered", + "connector_binding_status": "supported", + "semantic_binding_ids": [], + "evaluated_tags": [], + "sdk_metadata": {} + } + ], + "face_groups": [ + { + "group_id": 0, + "entity_id": "entity/face/0", + "mesh_index": 0, + "primitive_index": 0, + "first_index": 0, + "index_count": 180 + } + ], + "edge_groups": [ + { + "group_id": 0, + "entity_id": "entity/edge/0", + "mesh_index": 0, + "primitive_index": 0, + "first_index": 0, + "index_count": 12 + } + ] +} +``` + +Payload required fields 是 `schema_version`、`definition_id`、`geometry_asset_id`、`edge_asset_id`、`geometry_engine`、`entities`、`face_groups`、`edge_groups`。`geometry_engine` exact fields 是 `name`、`version`、`profile`;`name`固定为`OpenCascade`,`version`必须是exact kernel version且与manifest`generator.ocp_version`相同,不接受`7.x`。 + +每个entity required fields是`entity_id`、`kind`、`parent_entity_ids`、`child_entity_ids`、`source`、`geometry`、`properties`、`sdk_connector_frame`、`render_status`、`connector_binding_status`、`semantic_binding_ids`、`evaluated_tags`、`sdk_metadata`,不允许其他字段。`kind`只允许`solid`、`face`、`edge`、`vertex`。`entity_id`使用asset-local`entity//`,ordinal grammar是`0|[1-9][0-9]*`;它只在`entity_asset_id` scope内唯一。Compiler必须按第10.4节deterministic profile生成ordinal,不得宣称该ordinal跨不同model revision durable。 + +Entity `source` 是 exact discriminated union: + +- `kind=model_output`:required `kind`、`graph_id`、`node_id`、`output_slot`。它证明entity来自该evaluated operation output,但不声称model artifact包含该subshape的durable topology identity。 +- `kind=model_topology`:required `kind`、`graph_id`、`node_id`、`output_slot`、`topology_kind`、`topo_id`。 +- `kind=imported_primitive`:required `kind`、`source_element_id`。 +- `kind=unbound`:只允许 `kind`。 + +Graph-backed evaluated BRep 的每个entity至少使用 `model_output`。该tuple是拥有整个definition BRep的shape output,通常是Part body source或standalone shape root;它不是随便选取的“最近operation”。只有exact source `model.json`自身包含能唯一关联该entity的topology record,且trusted compiler能把该record解析到当前evaluated subshape时,compiler才可把该entity升级为`model_topology`。`topology_kind`必须与entity kind对应为`SOLID`、`FACE`、`EDGE`或`VERTEX`,且`(graph_id,node_id,output_slot,topology_kind,topo_id)`必须能在artifact中exact查到。由OCP traversal counter、process-local map、shape hash或scene compiler临时分配的ID不得写成`topo_id`。同一definition asset可以混合两种model source variants,但不得把缺失evidence从相邻entity推断出来。`imported_primitive`和`unbound`不参与DAG cross-link或connector binding。 + +For imported roots,definition `source_element_id`来自`SceneRoot.source_element_id`。只有trusted importer还能提供exact source artifact内stable primitive mapping时,entity才使用对应`imported_primitive.source_element_id`;该ID不要求等于root element ID。没有这种evidence时entity必须使用`unbound`,不能从OCP traversal、canonical entity ordinal或geometry hash伪造imported primitive identity。 + +`geometry` 是 typed analytic-classification snapshot,不是可 replay BRep:solid 使用 `brep_solid`;vertex 使用 `point` + `position`;edge 使用 `line`、`circle`、`ellipse`、`bspline_curve` 或 `other_curve`;face 使用 `plane`、`cylinder`、`cone`、`sphere`、`torus`、`bspline_surface` 或 `other_surface`。每个 variant 只允许其数学定义所需字段;unsupported kernel detail 使用 `other_curve`/`other_surface` 的 required `engine_type`,不能把 opaque OCP object dump 进 JSON。Axis/direction vectors normalized,radii/lengths non-negative,所有坐标使用 definition-local scene coordinates 和 scene length unit。 + +Geometry variant exact fields: + +| `type` | Required fields beyond `type` | Optional/nullable fields | +| --- | --- | --- | +| `brep_solid` | 无 | 无 | +| `point` | `position` | 无 | +| `line` | `origin`, `direction` | 无 | +| `circle` | `center`, `normal`, `x_direction`, `radius` | 无 | +| `ellipse` | `center`, `normal`, `x_direction`, `major_radius`, `minor_radius` | 无 | +| `bspline_curve` | `degree`, `rational`, `periodic`, `poles_count`, `knots_count` | 无 | +| `other_curve` | `engine_type` | 无 | +| `plane` | `origin`, `normal`, `x_direction` | 无 | +| `cylinder` | `origin`, `axis`, `x_direction`, `radius` | 无 | +| `cone` | `origin`, `axis`, `x_direction`, `reference_radius`, `semi_angle_degrees` | 无 | +| `sphere` | `center`, `axis`, `x_direction`, `radius` | 无 | +| `torus` | `center`, `axis`, `x_direction`, `major_radius`, `minor_radius` | 无 | +| `bspline_surface` | `u_degree`, `v_degree`, `u_rational`, `v_rational`, `u_periodic`, `v_periodic`, `u_poles_count`, `v_poles_count`, `u_knots_count`, `v_knots_count` | 无 | +| `other_surface` | `engine_type` | 无 | + +Vector/point fields 是恰好三个 finite numbers。`degree` 和所有 `*_count` 是 non-negative integers;实际 B-spline validity 还必须满足 engine/profile 定义的 degree/pole/knot constraints。`major_radius >= minor_radius > 0`,其他 radius positive;cone semi-angle 必须落在 engine profile 允许的 open interval。Geometry record 不保存 trimmed parameter ranges、control points 或 full BRep,因此它是可查询分类/属性快照而不是 interchange geometry。 + +`properties`按`kind`固定:solid required`quality`、`bounds`、`volume`、`surface_area`、`centroid`;face required`quality`、`bounds`、`area`、`centroid`、`orientation`;edge required`quality`、`bounds`、`length`、`centroid`;vertex required`quality`、`bounds`、`position`。`quality`在Base 1.0固定为`kernel_evaluated`;face`orientation`只允许`forward`、`reversed`、`internal`、`external`。Edge orientation是face-use/coedge属性,不是deduplicated edge intrinsic property,Base 1.0不序列化它。这些值是指定engine/profile对evaluated BRep的快照,不保证仅凭mesh可重算。 + +`bounds` exact fields 是 `min`、`max`,二者都是 vec3 且逐轴 `min <= max`。Length、area、volume 分别使用 scene unit、scene unit squared、scene unit cubed;它们必须 non-negative。Centroid/position 必须落在 bounds 内,允许 validator 的 declared numeric tolerance。Base 1.0 不接受 record-specific arbitrary property bags;新增 engine properties必须升级 profile/schema 或使用 namespaced extension。 + +`sdk_connector_frame`是使用当前SDK selector/frame derivation对该entity预计算的unflipped definition-local rigid transform。`solid`必须为`null`;face和vertex必须是valid transform;edge在current SDK能取得non-zero start-to-end direction时是valid transform,否则为`null`。Closed edge的coincident endpoints不能使用arbitrary fallback伪造direction,因此其frame为`null`。Static Viewer只预览non-null frame。Vertex target的binding`flip`必须为`false`,UI不显示flip control。未来若新增deterministic tangent-based closed-edge frame,必须更换`geometry_engine.profile`。 + +`render_status`只允许`rendered`和`degenerate`。Solid、face和vertex固定`rendered`。Edge在canonical float32 filtering后至少有一个non-zero segment时为`rendered`,否则为`degenerate`;degenerate edge保留properties/adjacency/picking inspector identity,但没有edge group、line hit target或visible overlay。 + +`connector_binding_status`只允许`not_applicable`、`owner_not_part`、`source_not_model`、`frame_undefined`、`selector_ambiguous`、`selector_unstable`、`supported`。Solid固定`not_applicable`。Face/edge/vertex只有在owner是model-backed Part、source是`model_output`或`model_topology`、frame non-null、当前SDK生成的`geo_exact` selector在owner body全部同kind candidates中恰有一个score`<= 1e-4`且所有其他scores`> 1e-4`、并且clean replay后重新编译仍映射回同一canonical entity时才可标记`supported`。其余状态按上述优先顺序选择第一个适用reason。当前public resolver尚未拒绝ties,因此backend在advertise connector apply capability前必须增加同一unique-match validation;scene compiler不能仅因best candidate通过threshold就写`supported`。 + +拓扑adjacency必须闭合且满足exact cardinality:solid的parents必须为空、children必须是至少一个face;face必须恰好一个solid parent、children全部是至少一个edge;edge必须有一个或多个face parents、children是一个或两个vertices;vertex必须有一个或多个edge parents且children为空。每一条parent relation必须有reciprocal child relation。所有adjacency arrays按unsigned UTF-8 byte ordersort且无重复。Solid intent命中face后使用其唯一solid parent。Standalone wire/shell和shared-face compsolid不属于Base Scene 1.0 render root;遇到无法投影为上述manifold solid ownership的root必须显式失败,不能由unregistered extension绕过。 + +`semantic_binding_ids`和`evaluated_tags`是Semantic Binding Store对当前evaluated entity派生的只读cache。Compiler必须验证每个binding存在于exact source graph且runtime target确为该entity。Browser对`model_topology` entity还能把binding topology target与entity source做exact cross-check;对只有`model_output`的entity,browser只能验证binding ID、producer graph和schema存在,不能把cache误标为artifact-proven topology evidence。 + +`sdk_metadata`只投影evaluated metadata中不属于compiler/runtime internals的top-level keys。Base exclusion set固定为`graph`、`topo_ref`、`track`、`source_sketch`、`sketch_solve`、`sketch_promotion`以及所有以`_`开头的keys;这些值可能包含process-local topology IDs或runtime objects,必须省略而不是hash入entity identity。`geo`、`std.*`和其他non-reserved user keys保留。投影值必须是JSON object,递归允许null、boolean、string、finite number、array和string-keyed object;tuple规范化为array,未知Python/OCP value、bytes、set、non-string key和cycle必须使compilation失败并报告metadata path,不能静默`repr()`。Profile改变exclusion set必须更换profile ID。Object keys按JCS处理,metadata size计入package budgets。 + +Model-backed compilation只接受exact model artifact clean replay后的projected metadata。Compiler必须先replay exact model bytes,按root source tuple取得replayed values,再逐个比较caller runtime Part/Assembly/Material/Compound/Solid及其face/edge/vertex的projected tags/metadata与clean replay;任一不同以`model_runtime_metadata_unreplayable`失败。Connector metadata不在Scene 1.0 projection内。Canonical scene始终使用replayed projection,不使用无法由model artifact重建的runtime mutation。Manual/imported scenes可以投影其input metadata,因为其source identity不是replay promise。直到下述canonical operation实现并迁移stdlib,作者若需保存model metadata不能在build返回后调用unrecorded`set_metadata()`改变scene revision。 + +Replayable metadata operation contract固定为public keyword-only `set_model_metadata_rvalue(*, owner, key, value)`和graph op `set_model_metadata`。`owner`只允许`Solid|Compound|Part|Assembly|Material`;Face/Edge/Vertex和Connector metadata不在Base contract。`key`必须是non-empty Unicode string,不得以`_`开头或属于Base exclusion set,UTF-8长度不超过structural ID budget。`value`必须通过第10.2节JSON-safe normalization,normalized value连同`key`完整写入node params。Operation functional-copy owner及其metadata map,以normalized value replace同key的旧值并返回与input相同semantic owner type;不删除key、不原地修改input,output count固定1。Shape owner使用semantic clone保留BRep/tag/topology evidence,Product/Material owner使用immutable reconstruction保留all fields。Replay执行同一normalization/replacement且output lineage指向该node slot 0;Model Schema 2.0 exporter/importer/replay allowlist和translator capability registry必须注册exact op,未识别它时strict replay失败。Phase B迁移所有stdlib post-operation metadata writes到该API;old artifacts不会被悄悄升级,仍按runtime/replay mismatch fail closed。 + +Face/edge group record exact fields都是`group_id`、`entity_id`、`mesh_index`、`primitive_index`、`first_index`、`index_count`,不允许null或额外字段;除`entity_id`外的五个fields都必须是non-negative integers。`mesh_index=0`且`primitive_index=0`。Face groups与face entities一一对应,每个face恰有一个positive group。Edge groups只与`render_status=rendered`的edges一一对应,每个rendered edge恰有一个positive group;degenerate edge恰有zero groups。`group_id`在各自array中从0连续递增。Face groups必须按`first_index`排序、互不重叠,并精确partition triangle primitive的全部indices;每组`index_count`是3的倍数。Edge groups必须精确partition line primitive的全部indices;每组`index_count`是2的倍数。`first_index`和`index_count`是primitive index accessor的element ordinal,不是byte offset。Vertex没有serialized render range。任何face无法产生至少一个non-degenerate triangle时Base 1.0 compilation失败;degenerate edge不使整个solid失败。 + +### 10.3 Selection Identity + +Entity selection 的完整 external identity 是: + +```text +(scene_id, revision, scene_node_id, entity_asset_id, entity_id) +``` + +Component selection identity 是 `(scene_id, revision, scene_node_id)`。内部 scene/presentation references 不附加 scene/revision,使用 discriminated `SelectionRef`: + +```json +{"kind": "component", "scene_node_id": "instance/main/stage1/input-shaft"} +``` + +```json +{ + "kind": "entity", + "scene_node_id": "instance/main/stage1/input-shaft", + "entity_asset_id": "sha256:...", + "entity_id": "entity/face/0" +} +``` + +`scene_id` 和 `revision` 只在 workspace、API 或 command envelope 中附加,避免 canonical scene manifest self-reference。不能把 OCC hash、Python object ID、当前 process enumeration index 或 `topo_id` 单独作为 durable identity。跨 model regeneration 优先依赖 semantic binding/query intent;无法证明同一 target 时必须 invalidated,不能用 geometry-nearest 自动替换 critical selection。 + +当前Viewer显示的`UNIQUE QL SELECTOR`只是从sidecar中evaluated tags、analytic type、exact measure/center facts和source-output hints生成的便利draft,并且只在这些facts能隔离一个同kind entity时提供。它不属于上述selection identity,不是canonical feature-selection intent,也不能未经重新验证跨revision持久化或自动应用。 + +### 10.4 Deterministic Profile 1 + +Base 1.0 固定 `generator.profile="scene-1.0-ocp-glb-2"` 和 entity `geometry_engine.profile="ocp-evaluated-properties-1"`。这两个 profile 的 normative rules 是: + +1. Phase A提交normative`scene-1.0-ocp-glb-2.profile.json`和`ocp-evaluated-properties-1.profile.json`。前者固定OCP meshing/discretization API、all flags、parallelism、normal/writer implementation和下面的numeric algorithm;后者固定analytic classification OCP APIs、location/orientation handling、axis conventions、B-spline constraints、engine type strings、property algorithms、metadata projection、selector scoring和connector frame tie-breaker。Prose中的“current SDK rule”不能替代这两个machine-readable profile和linked pseudocode。 +2. `toolchain_hash` descriptor是closed JCS object,包含两个profile bytes hashes,以及SimpleCAD scene/compiler/product/selector modules、Python executable、OCP binding shared libraries和transitive native kernel libraries的SHA-256 records;records按logical name排序,绝对path、mtime和host name不进入descriptor。Hash是descriptor JCS bytes的SHA-256。Exact version fields只用于展示,不代替hash。 +3. 所有subshape先消除location到definition-local coordinates。Edge wrapper在properties、discretization、selector和frame derivation前强制使用OCP `FORWARD` orientation;face orientation保留,因为每个face只有一个solid owner。Base 1.0拒绝被多个solids共享的face/compsolid topology,不靠traversal选择owner。Edge-face incidence可以many-to-many,但不保存coedge orientation。 +4. Numeric primitive`f32(x)`固定为IEEE-754 binary32 round-to-nearest-ties-to-even,再把`0x80000000`改为`0x00000000`;overflow/non-finite失败。Binary32 boundary fixture使用exact input binary64 bit patterns。Cross product在binary64中按`p1=f64(a1*b1)`、`p2=f64(a2*b2)`、`c=f64(p1-p2)`逐项执行,禁止FMA;norm使用binary64 sum-of-squares fixed x/y/z order和correctly-rounded square root。Normalize后每component经`f32`一次,若result norm不在`[1-1e-6,1+1e-6]`则失败。Collapsed test使用converted positions的binary32 values提升到binary64后执行上述cross;cross三components bitwise为positive/negative zero即collapsed。Profile vectors覆盖subnormal、halfway tie、cancellation、overflow和negative zero。 +5. 每个face先生成geometry-only canonical triangle block:不含entity ID/source/tags/metadata,包含sorted canonical `(position bits,normal bits)` vertices和oriented triangles。每个rendered edge生成geometry-only canonical segment block。`render_key=SHA-256(kind byte || block bytes)`。Triangle/line GLB按`(render_key, block bytes)`排列blocks;equal blocks顺序可交换且产生相同GLB bytes。Metadata、provenance、binding或tag-only change因此不能改变geometry/edge asset hash。 +6. Triangle block中kernel corner normal可用时按profile读取并normalize;否则使用oriented triangle normal。禁止跨triangle/CAD face averaging。每个oriented triangle只允许cyclic rotation,不允许reversal;选择index triple lexicographic最小的cyclic rotation,再sort。Edge block中endpoint indices取`(min,max)`,segment pairs sort并deduplicate;different CAD edges即使geometry相同仍拥有different sidecar groups,但重复的block bytes不改变GLB block stream。 +7. Entity canonical labeling先以`kind + geometry/properties/source/frame/status/bindings/tags/projected metadata + render_key`的JCS bytes hash建立partition,再使用sorted parent/child partition-label multisets反复refine到stable。仍有non-singleton cells时只在每个cell内执行lexicographic individualization/backtracking;全sidecar最多探索1,000,000 states,超出则compilation以`entity_canonicalization_budget_exceeded`失败。该有界algorithm和tie comparator在profile pseudocode中逐步固定,不允许实现选择另一graph canonicalizer。按canonical permutation和kind order`solid < face < edge < vertex`分配IDs。 +8. Equal render blocks的range slots按canonical entity ID分配;因此entity sidecar mapping确定,但GLB bytes不依赖entity canonical order。Face/edge groups按`first_index`输出,entities按entity ID输出;all set-like entity arrays按unsigned UTF-8 byte ordersort。GLB array/buffer order遵循第9.3节。 +9. Default appearance exact values是:`name=null`、`source=null`、`base_color=[0.72,0.75,0.78,1.0]`、`metallic=0.0`、`roughness=0.55`、`alpha_mode="opaque"`、`double_sided=false`、`edge_color=[0.08,0.09,0.1,1.0]`、`sdk_metadata={}`。 +10. Phase A提交closed`normalized-product-1.schema.json`。Part record包含body source identity、name、material完整JSON-safe record、declaration-order connector records和projected Part metadata;Assembly record包含name、declaration-order component records、connectors、constraints、grounded IDs和projected metadata。每个component record exact包含`component_id`、nullable`name`、definition ref和local placement,declaration order保留。Connector records不含runtime `_metadata`。Compiler比较其JCS bytes;任一field不同即typed semantic ID collision。禁止用mesh bytes或“至少相同”heuristic判定equivalence。 + +Profile任何normative rule变化都必须使用新profile ID并造成cache miss。Characterization tests必须覆盖symmetric entity、bounded canonicalization failure、reversed kernel traversal、negative zero、normal fallback、closed/degenerate edge、shared-face rejection和duplicate geometry with different metadata/provenance。 + +## 11. Appearance + +Product `Material` 当前同时包含物理属性和基础 RGB。Scene 不应直接把物理 material schema 扩展成 renderer shader schema,而应新增 `Appearance`: + +```json +{ + "appearance_id": "appearance/evaluated/012345...", + "name": "Steel", + "source": { + "kind": "product_material", + "root_id": "main", + "material_id": "steel" + }, + "base_color": [0.55, 0.58, 0.62, 1.0], + "metallic": 0.0, + "roughness": 0.55, + "alpha_mode": "opaque", + "double_sided": false, + "edge_color": [0.08, 0.09, 0.1, 1.0], + "sdk_metadata": {} +} +``` + +`appearance_id` 最后一段是去掉 `appearance_id` 字段后,对完整 JCS Appearance record 求 SHA-256 的 lowercase hex。由于 source scope 也参与 hash,不同 roots 中同名但不同来源/内容的 materials 不会碰撞。完全相同的 source-scoped appearance record 才能复用一个 ID。 + +Appearance `source` nullable;non-null variants 是:`kind=product_material` required `kind`、`root_id`、`material_id`,`kind=presentation` required `kind`、`presentation_id`、`appearance_name`。`product_material`只允许top-level `model|manual` scene,`root_id`必须匹配至少一个使用该Material的Part definition root;imported scene不能包含它。`presentation`只在`presentation_source`存在时允许,`presentation_id`必须与其相等且`appearance_name`必须解析到exact authoring record。Compiler neutral default使用`source=null`,其complete record仍通过content hash获得稳定identity。`name`可以为null。`sdk_metadata`保存source Product `Material`的JSON-safe metadata;presentation/default appearance使用`{}`,不把Part metadata复制到material inspector。 + +`base_color`和`edge_color`恰好四个`[0,1]`values,按sRGB color + linear alpha编码;renderer在lighting calculation前转换RGB到linear。`metallic`、`roughness`在`[0,1]`。Base 1.0`alpha_mode`固定为`opaque`且两个alpha都必须1.0;不冻结cross-renderer blend sorting,也不支持alpha mask、texture或renderer-specific shader parameters。 + +Scene Compiler 的默认映射: + +- Product material RGB -> `base_color.rgb`。 +- 未指定 RGB -> 第 10.4 节固定的 neutral CAD color `[0.72, 0.75, 0.78]`。 +- Alpha 固定为 1.0。 +- Metallic/roughness始终使用`0.0`/`0.55` deterministic defaults,因为当前Product Material没有explicit PBR fields;不根据material name猜测。 +- Appearance `source` 保留 root-scoped physical material provenance。 + +后续可以增加明确的 product-to-appearance assignment API,但不能根据 `steel`、`aluminum` 等名称启发式猜测 PBR 参数并宣称为 canonical material fact。 + +Scene 1.0 的 node-level required-nullable field 统一命名为 `appearance_override_id`,用于实例显示 override。没有 override 时值为 `null`;非 null 时必须引用 manifest `appearances` 中存在的 record,只影响 presentation,不修改 `Part.material`。 + +## 12. Camera、Light 与 Presentation + +### 12.1 Camera + +Scene 可以保存作者发布的 named camera: + +```json +{ + "camera_id": "camera/gearbox-published-view/isometric", + "name": "Isometric", + "projection": "perspective", + "parent_node_id": null, + "transform": { + "origin": [120.0, -160.0, 110.0], + "x_axis": [1.0, 0.0, 0.0], + "y_axis": [0.0, 0.0, 1.0], + "z_axis": [0.0, -1.0, 0.0] + }, + "vertical_fov_degrees": 35.0, + "near": 0.1, + "far": 100000.0 +} +``` + +Camera 自己保存 parent-relative rigid transform,`parent_node_id` 可以引用 scene occurrence,也可以为 `null`。Camera local `-Z` 是 forward,local `+Y` 是 up。Perspective camera 必须满足 `0 < vertical_fov_degrees < 180`、`near > 0`、`far > near`;Orthographic camera 使用正数 `vertical_span`,不能同时保存 FOV。Near、far 和 vertical span 使用 scene length unit。 + +如果 scene 没有 camera,Viewer 应根据 world bounds 执行 deterministic fit-to-view,而不是要求 exporter 写入一个假的 camera。 + +### 12.2 Light(Base 1.0 Reserved) + +第一阶段 Viewer 使用实现定义的 studio lighting。Base Scene 1.0 要求 `lights=[]`,不冻结半完整的 light record。Author-controlled directional、point 和 environment light 以后通过 required extension 引入,extension 必须定义 transform、color space、intensity unit 和 deterministic ordering。 + +### 12.3 Background 与 Environment + +默认 background/theme 属于 Viewer preference。只有作者明确发布 presentation 时才进入 scene。Viewer preference 不应因为加载另一个 model 而永久被 scene 覆盖。 + +### 12.4 ScenePresentationSpec + +Presentation spec 是独立、版本化、JCS-serializable 的 authoring input。最小结构: + +```json +{ + "schema_version": "1.0", + "presentation_id": "gearbox-published-view", + "source_scene_id": "gearbox-demo", + "appearances": [ + { + "name": "Highlight", + "base_color": [1.0, 0.55, 0.05, 1.0], + "metallic": 0.0, + "roughness": 0.45, + "alpha_mode": "opaque", + "double_sided": false, + "edge_color": [0.12, 0.06, 0.0, 1.0] + } + ], + "node_overrides": [ + { + "node_id": "instance/main/stage1/input-shaft", + "visible": true, + "appearance_name": "Highlight" + } + ], + "cameras": [] +} +``` + +Base`ScenePresentationSpec`只允许上述`schema_version`、`presentation_id`、`source_scene_id`、`appearances`、`node_overrides`和`cameras`字段;未知字段fail closed。`source_scene_id`必须exact等于compile target`scene_id`。Appearance names、camera names和node override`node_id`分别唯一。Compiler将每个authoring appearance与presentation source scope组合、生成evaluated Appearance及content-derived`appearance_id`,再把`appearance_name`resolve为scene node的`appearance_override_id`。Appearance override只能target renderable Part/Shape,Assembly target失败;visibility override可target任意node。Presentation references stable scene node IDs或`SelectionRef`,不直接引用triangle ordinal、OCC hash或workspace-only GPU pick ID。Compiler必须验证所有refs;找不到target直接失败。Migration tool可在canonical compilation之外报告问题,但compiler不产生warning scene,也不从上一个evaluated manifest猜测presentation intent。 + +Presentation appearance exact fields是`name`、`base_color`、`metallic`、`roughness`、`alpha_mode`、`double_sided`、`edge_color`,其中`alpha_mode="opaque"`且alpha=1。Presentation camera exact fields是`name`、`projection`、`parent_node_id`、`transform`、`near`、`far`以及projection-specific`vertical_fov_degrees`或`vertical_span`;它不包含`camera_id`。Compiler按第16.1节统一segment encoder将authoring name编码为`camera//`。Node override只允许`node_id`、optional`visible`和optional`appearance_name`,且后二者至少存在一项。所有nested records使用第7.2节unknown-field/null policy。Base 1.0只拥有named views,不定义active/default camera;workspace选择的当前camera不进入canonical scene。 + +## 13. Annotation、Dimension 与 Measurement + +### 13.1 Annotation(Base 1.0 Reserved) + +Scene annotation 是 presentation object,不是 CAD feature,潜在类型包括: + +- Label。 +- Callout。 +- Section marker。 +- Read-only dimension display。 +- Warning/diagnostic marker。 + +但 Base Scene 1.0 要求 `annotations=[]`,`ScenePresentationSpec` base schema 也不接受 annotations。Annotation extension 必须定义 annotation ID、kind-specific payload、units、style、`SelectionRef` target union、source kind 和 deterministic ordering。Source kind 至少包括: + +- `authored` +- `derived` +- `diagnostic` + +### 13.2 Measurement 精度边界 + +Mesh-only Viewer 可以可靠提供: + +- Triangle/vertex coordinate。 +- Approximate point distance。 +- Approximate angle。 +- Mesh bounds。 +- Section visualization。 + +Mesh-only Viewer 不能宣称提供任意 analytic BRep exact measurement。圆柱半径、曲面距离、精确 edge length 等能力需要以下至少一项: + +1. 后端 CAD query/measurement service。 +2. Optional BRep asset 和 browser CAD kernel。 +3. Exporter 预计算的 typed exact properties。 + +任何提供 measurement 的 UI 都必须区分 `exact`、`derived` 和 `mesh_approximate`,不能把 mesh 测量显示为无误差的 CAD measurement。Measurement 在 Phase D,不是 Phase C Viewer MVP 的退出条件。 + +## 14. Assembly 与 Kinematics + +### 14.1 MVP + +MVP Scene 保存 solved/evaluated component transforms。Viewer 不重新运行当前 Python assembly solver。 + +这支持: + +- Product tree。 +- Part isolate/hide/show。 +- Repeated instance selection。 +- Exploded view。 +- Current pose display。 + +### 14.2 后续 Kinematic Projection + +后续 scene capability 可以从 assembly constraints 派生只读 kinematic data: + +- Revolute joint axis、current angle、limits。 +- Prismatic axis、current distance、limits。 +- Coupling relation。 +- Connector frames。 + +该 projection 用于 Viewer motion preview,不成为 assembly constraint canonical owner。复杂闭环或 solver-dependent motion仍应由后端求解并发送 transform updates。 + +### 14.3 Evaluated Connector Snapshot + +Scene Base 1.0 的 `connectors` 保存 Product `Part`/`Assembly` connectors 的 evaluated、只读 projection: + +```json +{ + "connector_snapshot_id": "connector/main/part/input-shaft/mount-face", + "owner_definition_id": "definition/main/part/input-shaft", + "connector_id": "mount-face", + "name": "Mount Face", + "anchor_kind": "geometry", + "local_transform": { + "origin": [0.0, 0.0, 50.0], + "x_axis": [1.0, 0.0, 0.0], + "y_axis": [0.0, 1.0, 0.0], + "z_axis": [0.0, 0.0, 1.0] + }, + "target": { + "entity_asset_id": "sha256:...", + "entity_id": "entity/face/0" + }, + "source": { + "kind": "model_operation", + "graph_id": "gearbox", + "node_id": "node_connector", + "output_slot": 0 + }, + "sdk_metadata": {} +} +``` + +`local_transform` 是 owner definition-local evaluated connector frame,不是 occurrence/world transform。Viewer 在某个 component occurrence 中显示 connector 时组合 `node_world_transform * local_transform`。一个 definition connector 自动出现在其所有 occurrences;Editor 必须明确提示“编辑 definition 将影响 N 个 instances”,不能误导为只修改当前 occurrence。 + +字段条件: + +- `anchor_kind=geometry`:owner definition必须是`part`;`target` required,其exact fields是`entity_asset_id`、`entity_id`;asset必须由owner definition引用,entity kind必须是`face`、`edge`或`vertex`;`forwarded_from`必须省略。Base 1.0只接受能证明target属于owner Part body的geometry connector。Foreign-body target、standalone Shape connector或Assembly geometry connector compilation失败,不把不明坐标系的frame标记为owner-local。 +- `anchor_kind=placement`:owner definition必须是`part`或`assembly`;`target`和`forwarded_from`都必须省略。 +- `anchor_kind=forwarded`:owner definition必须是`assembly`;`forwarded_from` required,`target`必须省略。`forwarded_from` exact fields是`source_component_id`、`source_definition_id`、`source_connector_id`、`source_connector_snapshot_id`和nullable`offset`;`offset`使用canonical rigid transform。前两个Product-local IDs保留authoring audit,后两个scene IDs使package validator能验证target connector。 +- `source` 为 nullable;非 null 时 exact variant `kind=model_operation` 包含 `kind`、`graph_id`、`node_id`、`output_slot`,`kind=manual` 只包含 `kind`、`source_id`。 +- `sdk_metadata`在Base 1.0固定为`{}`。当前Connector `_metadata`既未进入`Connector.to_dict()`也未被model replay保存,因此compiler不得投影它;future connector metadata需要recorded connector operation和profile/schema revision。 + +Compiler 必须确认 geometry connector 的 resolved target 与 entity source provenance 一致,并确认 `local_transform` 按 SDK 现有规则求值:face origin/normal、edge midpoint/direction、vertex point/identity axes,再应用 `flip` 已产生的方向。Scene 不复制 `flip` authoring intent;该 intent 保留在 model connector operation 中。 + +Existing Product connector不是可以静默省略的optional snapshot。若geometry selector无法unique resolve、target不属于owner、`sdk_connector_frame=null`(例如current SDK closed edge)或placement/forwarded frame求值失败,整个compilation以stable`connector_frame_undefined`或更specific rule失败。`SceneConnector.local_transform`保持required non-null。Phase A/Compiler tests必须包含一个serialized/replayed closed-edge connector并证明它明确失败;未来profile定义closed-edge tangent frame后才可导出该connector。 + +Forwarded snapshot必须满足以下可独立验证的resolution contract: + +1. `source_connector_snapshot_id`必须引用恰好一个connector,其`owner_definition_id=source_definition_id`且`connector_id=source_connector_id`。 +2. 对owner assembly definition的每个occurrence node,都必须存在恰好一个direct child node,child的`source.component_path`等于owner occurrence path追加`source_component_id`,并引用`source_definition_id`。所有这些child nodes的local transform必须相同;否则definition equivalence已经被破坏,package无效。 +3. Forwarded`local_transform`必须等于`source child local transform * source connector local_transform * offset`;`offset=null`按identity处理。Composition按第7.4节row-major/column-vector rigid-transform顺序,在binary64中以profile固定multiply/add order且禁止FMA;origin逐component用`epsilon_mm`比较,axis逐componentabsolute error`<=1e-12`。 +4. 以`connector_snapshot_id -> source_connector_snapshot_id`建立directed graph,必须无self-edge、无cycle且最大chain depth不超过resource profile。Source connector可以是geometry、placement或另一个valid forwarded snapshot。 +5. `source_component_id`必须是owner assembly direct component,而不是descendant path或任意matching occurrence。缺失、多个source child、definition mismatch、dangling snapshot或frame mismatch都使package失败。 + +Model-backed scene中,connector `source`必须是`model_operation`并能在exact graph中找到valid output slot;manual scene中必须是`manual`。Imported scene可以使用`source=null`,但不能伪装为model operation。每个owner definition内`connector_id`唯一;`connector_snapshot_id`必须等于第16.1节owner kind/semantic ID/connector ID推导的structural ID,而不是任意label。 + +`model_operation`的canonical producer不是独立Connector creation node,而是将该connector首次引入其owner Product definition的unique graph output:attached Part使用`make_add_connector_rpart`,attached Assembly使用`make_add_connector_rassembly`,forwarded connector使用`make_forward_connector_rassembly`,三者`output_slot`在Model Schema 2.0均固定为0。Compiler必须证明producer input owner尚无该`connector_id`、producer output owner恰有equal connector record,且从producer到selected owner definition output的replay lineage没有删除或替换该record;缺失或多个candidate以`connector_source_unproven`失败。Geometry/placement connector creation operation只提供anchor intent,不写入`SceneConnector.source`。Manual scene的connector `source.source_id`必须exact等于top-level manual `SceneSource.source_id`;connector自身identity由`connector_snapshot_id`提供,不另生成path、UUID或sequence-derived source ID。 + +### 14.4 Connector Binding Authoring + +Viewer/Editor 通过独立 `ConnectorBindingSpec` 请求创建 connector。Base command 使用 JCS,exact top-level fields 为: + +```json +{ + "schema_version": "1.0", + "binding_id": "bind-input-shaft-mount", + "source_model": { + "graph_id": "gearbox", + "model_schema_version": "2.0", + "artifact_hash": "sha256:..." + }, + "source_scene": { + "scene_id": "gearbox-demo", + "revision": "sha256:..." + }, + "owner_definition_id": "definition/main/part/input-shaft", + "selected_occurrence_node_id": "instance/main/stage1/input-shaft", + "connector_id": "mount-face", + "name": "Mount Face", + "target": { + "kind": "topology_entity", + "entity_asset_id": "sha256:...", + "entity_id": "entity/face/0", + "expected_source": { + "kind": "model_output", + "graph_id": "gearbox", + "node_id": "node_...", + "output_slot": 0 + }, + "flip": false + } +} +``` + +所有 top-level fields required;只有 `name` nullable。`source_model`、`source_scene` 和 topology `expected_source` 不允许额外字段。`binding_id` 使用 caller-authored Scene ID grammar。`connector_id` 使用现有 Product identifier grammar `[A-Za-z][A-Za-z0-9_.:-]*`,嵌入 structural ID 时按第 16.1 节 percent encode。Base `target.kind=topology_entity` 只接受 face、edge、vertex entities,因为当前 SDK connector operations只对这些 topology kinds 定义 frame derivation。 + +后续 `feature_output` target variant 可以引用 `graph_id`、`node_id`、`output_slot` 和一个版本化 frame derivation rule,例如 operation context frame;它要求新增 canonical model operation,不能在 Viewer 中把一次 evaluated transform 偷换成 durable feature binding。Solid/feature node 可用于 DAG 定位,但在该 operation 落地前不能保存为 geometry connector。 + +Backend apply 流程必须原子执行: + +1. 从受控registry取得或随request接收完整source `CompiledScenePackage`,执行full package validation,并验证当前model exact-byte hash、graph ID、scene ID和revision全部匹配command preconditions。 +2. 验证 selected occurrence 引用 owner definition,entity asset 和 entity record 仍存在。 +3. 比较 entity `source` 与 `expected_source` 的全部字段,拒绝 stale 或 unbound/imported target。 +4. Replay exact model,并使用source scene中冻结的roots、compile options和toolchain profile重新编译pre-mutation scene;其revision和全部referenced asset hashes必须与command source scene相等,否则返回conflict。 +5. 从该次trusted compilation产生的ephemeral resolution index中,用 `(owner_definition_id, entity_asset_id, entity_id)` 取得唯一 live face/edge/vertex。Index不是artifact,也不能通过geometry-nearest search替代;zero或multiple matches都失败。 +6. 从resolved live entity建立当前SDK canonical `GeometryRef`,调用对应face/edge/vertex connector operation,并把connector加到owner Part definition;重复connector ID失败。 +7. 导出新的model artifact,再以同一stable `scene_id`、root descriptors和compile options重编译scene。 +8. 返回新model hash、新scene revision、connector snapshot ID和`SceneValidationReport`;report不进入新scene revision。 + +`expected_source` 是 provenance/staleness precondition,不是单独的subshape selector。Target entity还必须有`connector_binding_status="supported"`。`model_topology`可以提供更强artifact evidence,但Base 1.0不要求它;`model_output` target依靠exact source model + exact source scene + deterministic recompilation index完成revision-local resolution。Backend不得直接用`topo_id`、OCP hash、runtime traversal ordinal或non-unique selector score猜目标。若当前toolchain无法重现source revision,command必须失效并要求重新选择。 + +Recompilation recipe来自validated source manifest:root occurrence及其definition source恢复ordered `SceneRoot` descriptors,其中`root_id`来自root occurrence source、`value`来自exact definition/model output、`transform`来自root occurrence的exact transform;top-level `compile_options`提供resolved options,`source`提供exact model precondition。标记为self-contained model package时,source model和所有manifest-declared project-relative Python sources必须已经在package中;registry fallback只适用于明确发布为non-self-contained的generic package或optional presentation。若`presentation_source`存在,backend还必须取得hash匹配的exact presentation JCS bytes。缺少任一root output、source model、presentation artifact或matching complete generator/toolchain时,backend不能advertise `cad_verified`,apply返回unsupported/conflict而不是省略输入后尝试“近似重编译”。Binding command本身不复制这些可能很大的artifacts。 + +普通静态 Viewer 可以只导出 `.json` command;有 backend 的 Editor 可以直接提交。无论 transport 如何,成功前不得本地篡改 canonical scene/model;可以用 workspace-only optimistic gizmo 显示 pending connector。Revision/hash conflict 必须提示用户 reload/reselect,不能自动将 command 迁移到 geometry-nearest entity。 + +当前 SDK 能对一个 live `Part` value执行 geometry connector + `add_connector_rpart`,但 public serializer不能从 standalone uploaded `model.json`恢复一个可继续append operations、替换captured results并原子rollback的 editable `GraphSession`。它也不能自动把 nested immutable Part的新版本重接到所有parent assemblies。因此 Base binding command可以先稳定生成/验证,但hosted apply在新增 `import_model_session`/model transformation transaction前一律unsupported。该transaction必须adopt并strictly validate imported graph、append canonical operations、替换result IDs、重建affected Product operations、export新model,并在任一步失败时保持原artifact不变。 + +同进程保留原始 live `ModelResult.session` 的实验性apply也必须使用显式transaction API后才能advertise capability,不能直接mutate session并假装atomic。Nested Part apply还必须确定性重建affected parents并保持unrelated graph intent。Viewer根据backend capability response禁用unsupported apply,不能把scene occurrence局部添加connector当作成功。 + +Assembly connector authoring 使用 placement connector 或 forwarded connector 的独立 command variant,不从 assembly descendant face 隐式创建 geometry connector;该 variant 不属于 Base `topology_entity` binding。 + +## 15. Scene Package + +推荐发布格式扩展名为 `.scene.zip`,本质是 ZIP package: + +```text +gearbox.scene.zip +├── scene.json +├── geometry/ +│ ├── sha256-abc.glb +│ ├── sha256-def.glb +│ └── sha256-ghi.glb +├── edges/ +│ ├── sha256-jkl.glb +│ └── sha256-mno.glb +├── entities/ +│ ├── sha256-abc.json +│ ├── sha256-def.json +│ └── sha256-ghi.json +├── model/ +│ └── model.json # self-contained model package required +├── sources/ +│ └── models/ +│ └── gearbox.py # preserved project-relative path +└── presentation/ + └── presentation.json # optional +``` + +开发模式同时支持 unpacked directory,便于 diff、调试和静态 HTTP hosting。Packed 与 unpacked 形式必须拥有相同 `scene.json` 和 asset bytes。 + +### 15.1 Render-self-contained 与 Self-contained Model Package + +Viewer 必须能够只依赖 `scene.json` 和 referenced geometry/edge/entity assets 完成显示与五层 selection;这是render-self-contained scene。Generic low-level package可以不嵌入model。Self-contained model package还必须包含exact `model/model.json`和`source_files`声明的Python files,用于: + +- Download original model。 +- Backend replay。 +- Features history/source panel。 +- Exact CAD service。 + +缺少 embedded model 或 presentation source 不得阻止普通 Viewer 加载render scene,但Features/source capability必须disabled。Compiler-produced `@model(export_dir=...)`和`ModelResult.export_artifacts()` package属于self-contained model package,总是嵌入model并输出`source_files`,后者没有可解析source时可以是空array。若embedded artifacts存在,loader必须按manifest中的URI、byte length和artifact hash验证,不得因为文件位于约定目录就默认信任。 + +### 15.2 URI 规则 + +Scene 1.0 URI是opaque ASCII ZIP member name,不是RFC URL:不执行percent decode、query/fragment split、Unicode normalize、base URI resolution或filesystem path semantics。Grammar固定为`[A-Za-z0-9][A-Za-z0-9._/-]{0,1023}`,使用`/`separator;每个segment必须non-empty且不能是`.`或`..`。`%`、`?`、`#`、backslash、colon、NUL、leading/trailing slash、absolute path和drive prefix全部拒绝。HTTP、`file://`和external transport不属于Base 1.0。 + +Loader必须在读取任何payload前拒绝duplicate/case-insensitive-colliding member names、encrypted/non-regular entries和unsupported compression。ASCII restriction使Unicode database/version不参与package identity。Package member set必须exact等于`scene.json`加manifest中所有geometry/edge/entity/model/source-file/presentation URI的set;每个non-scene member至少被一个record引用,相同URI的所有records必须声明相同hash/length/media role。`source_files[].path`是不含`sources/`的project-relative path,`uri`必须是exact `sources/` transformation。其他member一律拒绝。 + +Packed loader优先从archive stream读取,不extract到filesystem。必须extract时,每个目标的resolved realpath必须位于新建package root内,并以no-follow方式创建;unpacked directory loader同样拒绝symlink并检查每个resolved path containment。Unpacked loader必须先以no-follow enumeration/stat收集所有regular-file ASCII member names和uint64 sizes,在读取任何payload前验证name/collision/member-count、per-file limits、checked total uncompressed bytes、exact canonical stored size及non-ZIP64 representability;之后只先读取budgeted `scene.json`来验证exact member set,再读取referenced immutable blobs。Enumeration与open之间任何type/size/file-identity变化都以TOCTOU error失败。Archive不得有leading prefix或EOCD后的trailing bytes。Central/local header的name、size、CRC-32、flags和method必须一致;data descriptor、ZIP64、multi-disk、archive/member comment和extra field全部拒绝。Import只允许creator OS Unix、version needed/made-by 2.0、flags exact`0x0800`、method stored(0)或deflate(8)、disk fields 0、internal attributes 0,且external attributes high 16 bits必须是Unix regular file mode`0100644`。 + +Canonical`.scene.zip` exporter无directory entries,members按ASCII path bytes unsigned lexicographic order,method stored。每个local header固定:signature`0x04034b50`、version needed`20`、flags`0x0800`、method`0`、DOS time`0`、DOS date`0x0021`、actual CRC-32、equal compressed/uncompressed uint32 sizes、actual filename length、extra length`0`。每个central header固定:signature`0x02014b50`、version made by`0x0314`、version needed/flags/method/time/date/CRC/sizes与local一致、extra/comment/disk/internal attributes均`0`、external attributes`0x81A40000`、actual local offset。EOCD固定single-disk zero fields、matching uint16 entry counts、actual central size/offset和zero comment。Archive总长度和all offsets用checked arithmetic且不得需要ZIP64。Importer可以接受上述allowlist内deflate package,但必须在接受前按payload lengths和member name bytes计算canonical stored envelope size并满足第22.2节budget;重新export时只canonicalize envelope,payload bytes不变,不保留原compression bytes。Phase A exact ZIP vector是这些fields的normative byte oracle。 + +所有JSON在object construction前检测duplicate member names;发现任何层级duplicate key立即拒绝,不能依赖parser的first/last-wins行为。JSON输入必须是strict UTF-8,不接受BOM、replacement decoding、comments或trailing tokens。`scene.json`、entity sidecars和embedded presentation必须逐byte等于parsed value的RFC 8785 JCS encoding;noncanonical whitespace、number或member order直接拒绝。Embedded model/imported source bytes保持其source-defined raw encoding。Python source blobs是strict UTF-8 inert text,不是JCS documents;package validator按immutable bytes验证length/hash并将operation source spans与文本cross-check。Hash、byte length和validation必须消费同一份immutable bytes;validator验证的member/JSON/GLB/source bytes就是后续renderer或inspector读取的bytes,禁止按路径二次打开造成TOCTOU/parser confusion。 + +## 16. Identity、Revision 与 Content Hash + +必须区分四种 identity: + +| Identity | 用途 | 生成策略 | +| --- | --- | --- | +| `scene_id` | 一个长期 scene 的逻辑身份 | 调用方必须显式提供稳定 ID | +| `revision` | Scene manifest 的不可变版本 | Canonical manifest content hash | +| `node_id` | Revision 间尽量稳定的 occurrence identity | Product component path 优先 | +| content asset ID | Geometry、edge 或 entity snapshot bytes identity | SHA-256 content hash | + +### 16.1 ID Grammar 与 Segment Encoding + +Scene 1.0 的 caller-authored `scene_id`、`root_id` 和 `presentation_id` 使用: + +```text +[A-Za-z][A-Za-z0-9_.-]{0,127} +``` + +禁止 `/`、`:`、`%` 和 whitespace。Content IDs 使用 exact lowercase form: + +```text +sha256:[0-9a-f]{64} +``` + +Product IDs 当前允许 `:`,因此不能直接拼接到 structural ID。每个 product/component/graph/node semantic segment 必须先按 UTF-8 编码,再使用 RFC 3986 percent encoding;只保留 ASCII unreserved `A-Z a-z 0-9 - . _ ~`,其他 bytes 包括 `/` 都必须编码,percent hex 必须 uppercase。Decoder 必须拒绝对 unreserved byte 的多余编码、lowercase percent hex、invalid UTF-8 和 decode/re-encode 不一致。 + +Structural IDs 使用固定 literal segments 和 `/` 分隔: + +```text +definition/// +instance///... +definition//shape/model/// +definition//shape/imported/ +definition//shape/manual/ +appearance// +connector//// +``` + +Base 1.0 的 `` 固定 literal `evaluated`,因此所有 evaluated appearance ID恰为 `appearance/evaluated/`;source/presentation scope已包含在被hash record中,不再编码第二种ID grammar。 + +由于 data segments 不允许 raw slash,tuple-to-string mapping 无歧义。Validator 必须在构造 registry 前拒绝 duplicate canonical IDs;不能先由 mapping 覆盖再检查。 + +### 16.2 Product-backed IDs + +Product hierarchy 中: + +- Definition ID 使用 root namespace 和 typed semantic ID,例如 `definition//part/`、`definition//assembly/`。 +- Scene node ID 来自 root identity 和完整 component path。 +- 同一个 root namespace 内,同一 semantic type/ID 必须表示同一 definition;compiler 发现不同 producer/content 时失败。 +- 不同 roots 可以使用相同 product ID 并生成不同 definitions;geometry bytes 仍通过 content hash 跨 definitions 去重。 +- 同一 root 中 repeated Part/Assembly 复用同一 definition。 + +现有 Product API 没有 scene-wide global ID contract,因此 Scene 1.0 不宣称裸 `part_id` 在多 root scene 中全局唯一。`SceneRoot.root_id` 是 identity contract 的必要部分。 + +同一 root 内的 definition equivalence 使用第 10.4 节 exact normalized Product records,不能仅比较显示名称或 mesh bytes,因为不同 product definitions 可以具有相同 geometry。 + +每个 root 自身也有 root occurrence node `instance/`;descendant node 是 `instance///...`。因此两个 roots 包含相同 assembly/component path 时仍拥有不同的全局 node IDs。 + +### 16.3 Shape-backed IDs + +Standalone graph shape 没有 product ID 时,definition identity 使用: + +```text +definition//shape/model/// +``` + +Root occurrence 仍统一使用 `instance/` 并引用上述 definition;不再使用第二套 `shape/...` node identity。Definition identity 只保证在同一个 exported model artifact 内稳定。由于当前 operation node IDs 默认是随机短 ID,独立重建的等价 model 不保证产生相同 definition ID。发生这种情况时,patch 系统应发送 full replacement 或要求作者提供稳定 semantic ID,不能用 geometry proximity 猜测对象 identity。Imported/manual shape 使用其对应 source variant;调用方没有 stable source identity 时 compilation 失败。 + +### 16.4 Canonical JSON + +Scene manifest、entity snapshot sidecar、optional presentation spec 和 connector binding spec 使用 RFC 8785 JCS。为了得到稳定 revision,必须满足: + +- UTF-8 和 JCS string/number/object-key encoding,包括 `-0`、Unicode 和 IEEE-754 finite number 的 JCS 处理。 +- 禁止 NaN 和 infinity。 +- Hash input 完全省略顶层 `revision` 字段,不使用空字符串或 placeholder。 +- Canonical package中的JSON member必须就是compact JCS bytes;pretty rendering只允许UI显示,不能作为valid package member。 + +JCS保留array order,因此compiler在JCS前必须规范化所有set-like arrays。Validator不silently normalize wire input;任何array不是canonical order就拒绝,再对unchanged parsed value重算revision。JSON object member names严格使用RFC 8785要求的UTF-16 code-unit ordering,由JCS encoder负责。除此之外,本文显式要求排序的schema arrays、IDs、logical names和ASCII package paths固定使用unsigned UTF-8 byte lexicographic order;不使用Python code-point或JavaScript默认comparator: + +- `extensions_used=[]`、`extensions_required=[]`和`extensions={}`,non-empty直接拒绝。 +- `definitions` 按 `definition_id` sort,`nodes` 按 `node_id` sort。 +- `geometry_assets` 和 `edge_assets` 按 `asset_id` sort,`entity_assets` 按 `entity_asset_id` sort。 +- `appearances` 按 `appearance_id` sort,`connectors` 按 `connector_snapshot_id` sort,`cameras` 按 `camera_id` sort。 +- Base 1.0的`lights`、`annotations`和`diagnostics`必须为空。 +- semantic binding IDs 和 evaluated tags lexicographic sort。 + +`component_path`、matrix/vector、face/edge range和其他语义上有顺序的arrays保留schema-defined order。Entity payload中的`entities`、`face_groups`和`edge_groups`也必须already满足第10.2/10.4节order。Scene validator只验证order并按原value JCS重算revision,不将多个wire manifests折叠到同一revision。 + +## 17. Scene Compiler + +### 17.1 编译输入 + +推荐 API: + +```python +package = scad.compile_scene( + scene_id="gearbox-demo", + roots=(scad.SceneRoot(root_id="main", value=assembly),), + source=result, + presentation=None, + options=scad.SceneCompileOptions( + linear_tolerance=0.35, + angular_tolerance=0.22, + ), +) +``` + +`scene_id` 是 required keyword-only input;compiler 不生成 UUID、timestamp 或 path-derived fallback。`source` 可以是 `ModelResult` 或显式 `SceneSource` descriptor。它不选择 `roots`,但不是可随意填写的 label:`source.kind=model` 时 compiler 必须拥有 source `GraphSession`/artifact ownership evidence,并验证每个 graph-backed root、Part body、definition source、entity source、connector source和 semantic binding都属于同一 exact source graph/model artifact。调用方只提供 hash/graph ID而没有 ownership evidence时,不允许生成 `kind=model` scene;应使用 imported/manual source或先通过 trusted replay service建立 evidence。`presentation` 可以是 `ScenePresentationSpec`;省略时只生成 deterministic default appearance/visibility,不保存作者自定义视图。Entity snapshots 和 CAD edge assets 是 Base Scene 1.0 renderable definition 的 mandatory output,不提供 disable option。 + +`SceneCompileOptions`在Base 1.0中是closed immutable record,只含`linear_tolerance`、`angular_tolerance`、`embed_source`和`embed_presentation`。两种tolerance省略时分别固定为`0.35` mm和`0.22` radians;embedding booleans都默认false。显式tolerance必须finite,`0 < linear_tolerance <= 1000000.0`且`0 < angular_tolerance <= 3.141592653589793`。同一linear tolerance同时用于triangle deflection和CAD edge discretization;不另设hidden edge default。Compiler必须把四个resolved values写入manifest并纳入cache/revision input。Meshing parallelism、thread count、OCP environment或process cache不得改变canonical output;当前profile无法满足时必须按profile单线程规范化或失败。 + +`roots`必须是non-empty tuple,`root_id`唯一;每个root必须产生一个root occurrence和definition。RenderablePart/Shape必须包含至少一个valid solid、face、edge和vertex,并生成non-empty triangle/line assets;empty shape、wire、shell、vertex-only、edge-only、face-only、zero-solid Compound和fully-collapsed tessellation在Base 1.0中失败。纯Assembly可以没有自身geometry,但其recursive descendants必须至少包含一个renderable definition;完全空scene只能由低层schema fixture构造,不能由`compile_scene()`产生。 + +Ownership validation至少执行 `GraphSession.validate_graph_ownership()` 等价检查,并验证所有 `model_output`/`model_topology` 的 `(node_id, output_slot)` 在exact model graph中存在、slot小于`output_count`;只有`model_topology`要求其topology tuple能在artifact records中找到。所有semantic binding IDs必须存在且target/source一致。任何imported/unbound entity不能伪装为model provenance。Package validator对embedded model重复执行纯结构cross-check;trusted compiler负责证明runtime geometry确由这些roots求值,并为connector apply产生不序列化的entity-to-live-shape index。 + +`compile_scene()` 返回 `CompiledScenePackage`,而不是裸 `SceneSnapshot`。Package 是 immutable aggregate,至少包含: + +- `manifest: SceneSnapshot` +- `blobs: Mapping[str, bytes]`,key 是 manifest 中 package-relative URI +- Generic package中按options选择的embedded source/presentation blobs +- Compiler-produced self-contained model package中的mandatory `model/model.json`和manifest-declared `sources/**/*.py` + +这样 asset bytes 的所有权和生命周期是显式的,compiler 不依赖临时目录、隐藏 mutable state 或进程内 cache 才能完成 export。`CompiledScenePackage`不保存输入ZIP envelope或compressed sizes。当前public slice提供`preflight_zip_bytes()`和package validator组合加载archive bytes;后续`import_scene()`可以在此边界上返回同一package type。Accepted package可以payload-byte-preserving地重新export为canonical stored envelope,但不会保留原compression bytes或header布局。 + +不建议在 `ModelResult` 上自动增加 eager `scene_json` 属性。Scene compilation 包含昂贵 tessellation 和 binary asset generation,不能成为每次`@model`调用的无条件成本。当前支持两个显式opt-in入口:decorator的`export_dir`和调用后的`ModelResult.export_artifacts(output_dir=...)`;省略二者时保持in-memory-only行为。 + +### 17.2 编译过程 + +Scene Compiler 按以下顺序工作: + +1. 验证root descriptors、root value类型、root IDs和graph ownership;model source先clean replay exact artifact并验证projected runtime metadata equality。 +2. 解析replayed model或manual root为standalone shape或product definition/instance tree;imported root只解析为standalone shape。 +3. 建立全局definition registry,包括flattened Compound owner metadata exception。 +4. 递归遍历 assembly,组合并验证 component path,但只保存 local placement。 +5. 对每个 unique geometry definition 生成或复用 RenderMesh。 +6. 生成 normals、CAD edge polylines 和 local bounds。 +7. 建立 solid/face/edge/vertex entity records、face/edge picking ranges 和 graph/topology provenance。 +8. 将 Product Material 映射为 Appearance,并求值 definition connectors。 +9. 当嵌入model时,从live graph nodes收集所有`path_kind=project_relative` source;验证file仍位于其`pyproject.toml` project root、path仍一致、member path archive-safe、文件存在且是strict UTF-8 `.py`,拒绝case-fold collisions,并按path排序/deduplicate。 +10. 写出 deterministic triangle GLB、edge GLB、entity snapshot sidecar、exact model bytes和`sources/` bytes。 +11. 计算所有 blob hashes,建立不含 `revision` 的 draft manifest。 +12. 执行 structural draft validation。 +13. 使用 normalized JCS draft manifest 计算 scene revision,并构造 final manifest。 +14. 构造 `CompiledScenePackage`。 +15. 执行包含 URI、byte length、blob hash、GLB profile、entity adjacency/range、embedded model graph references、source span/callsite和revision 的 full package validation。 + +### 17.3 Compound 策略 + +`Compound` 没有 product hierarchy 时,第一阶段将其视为一个 shape definition 和一个 render object。不能根据 disconnected solids 的空间位置猜测 component identity。 + +后续可增加 explicit shape group input,但不自动把所有 compound children 提升为可持久化 product nodes。 + +### 17.4 Cache Key + +Render mesh cache key 至少包括: + +- Geometry source content/evaluation identity。 +- Linear tolerance。 +- Angular tolerance。 +- Normal generation policy version。 +- Edge tessellation policy version。 +- Render asset schema version。 +- OCP/kernel 和 tessellator build identity。 +- 全部 meshing flags。 +- Normal/edge generation implementation version。 +- GLB writer/profile version。 +- Numeric/float normalization profile。 + +Placement、visibility、camera 或 appearance-only changes 不得使 geometry asset cache 失效。 + +Deterministic asset hash的保证范围是:相同exact source artifact、相同geometry value、相同compile options、相同profile ID/profile bytes和相同registered `toolchain_hash`。只匹配profile ID但toolchain hash不同不能声称byte-for-byte reproducible。Scene revision还要求相同`scene_id`、resolved roots、presentation和完整exact `generator` record;任一display version、ABI/platform token、toolchain hash或profile变化都会按manifest内容产生新revision。跨OCP/tessellator/writer toolchain的byte-for-byte equality不是Scene 1.0保证;toolchain/profile变化必须cache miss并进入generator metadata。 + +## 18. Public API 草案 + +第一阶段推荐最小 API: + +```python +package = scad.compile_scene( + scene_id="model-preview", + roots=(scad.SceneRoot(root_id="main", value=value),), + source=model_result, + presentation=presentation, + options=options, +) + +scad.validate_scene_manifest(scene=package.manifest) +scad.validate_scene_package(package=package) +scad.export_scene(package=package, path="model.scene.zip") + +@scad.model(graph_id="model", export_dir="out") +def build_model(): + return build_part() + +result = build_model() +assert result.artifact_paths["scene"].name == "model.scene.zip" +``` + +也可以不在decorator传`export_dir`,随后调用`result.export_artifacts(output_dir="out")`。这两个automatic publishing入口只在output directory中写一个Scene ZIP;model/session JSON和Python source位于ZIP内部,不自动生成旁置STEP、STL或FCStd。Archive import convenience尚未作为public API发布;当前读取边界是`preflight_zip_bytes()`、`parse_canonical_json()`和`validate_scene_package()`。 + +核心 public types: + +- `SceneSnapshot` +- `CompiledScenePackage` +- `SceneRoot` +- `SceneSource` +- `ScenePresentationSpec` +- `SceneDefinition` +- `SceneNode` +- `SceneAppearance` +- `SceneGeometryAsset` +- `SceneEdgeAsset` +- `SceneEntityAsset` +- `SceneConnector` +- `ConnectorBindingSpec` +- `SceneCamera` +- `SceneCompileOptions` +- `SceneValidationReport` + +`validate_scene_manifest()`只验证schema、IDs、hierarchy、transforms、references和revision。`validate_scene_package()`还验证所有referenced bytes、GLB allowlist/profile、entity adjacency/ranges、embedded model/source artifact和所有artifact-derived resource budgets;它不验证不存在于`CompiledScenePackage`中的input archive length、compressed sizes或compression ratios。Packed envelope或unpacked stat table由对应preflight先验证canonical stored archive size;`export_scene()`在写入前重复计算该size。Exporter还必须调用package validator;manifest-only validation不能声称package安全或完整。 + +不建议第一阶段提供大量 mutable `scene.add_node()` 风格 API。先以 immutable dataclass、compiler output 和 strict serializer 为主,避免形成第二套随意构造且无法验证的 scene graph。 + +## 19. Viewer Runtime 架构 + +### 19.1 独立 TypeScript/Three.js 工程 + +Viewer 位于仓库根目录 `viewer/`,拥有独立 `package.json`、TypeScript config、Vite build pipeline 和发布产物。它可以依赖发布后的schema fixtures/types,但不能import Python package internals,也不能要求SimpleCAD virtualenv、OCP或scene compiler才能启动。当前实现由`viewer/src/main.ts`直接管理DOM/UI和Three.js runtime,并未采用React。 + +当前主要目录: + +```text +viewer/ +├── package.json +├── src/ +│ ├── main.ts # package loader, UI state, Features, inspector +│ ├── style.css # layout, source range and selection styling +│ └── components/ +│ └── click-selection.ts +└── public/cases/ # optional static fixtures +``` + +TypeScript负责package/UI逻辑,Three.js `WebGLRenderer`负责imperative rendering。GLB只能从已经经过package member length/hash validation的in-memory bytes构造;package source不允许触发nested network/file resource resolution。Three.js是renderer adapter的实现细节,不改变Scene contract。 + +Camera pose、hover、GPU resources、pointer movement和renderer dirty flags保存在imperative runtime中;每帧camera update不重建UI。若未来迁移到React,仍应维持这一边界,但React store/hooks不是当前实现contract。 + +### 19.2 Package Loader 与未来配对 + +负责: + +- 通过file picker或drag/drop加载一个local `.scene.zip`。 +- 验证archive member policy、manifest references、byte length和SHA-256。 +- 从package中发现并解析embedded `model/model.json`。 +- 按`source.source_files`加载strict UTF-8 Python source。 +- 验证通过后并行加载 GLB 和 entity assets。 +- 建立 node/definition/asset registries。 +- 根据 parent relation 计算 world transforms。 + +当前没有独立external model slot、unpacked-directory loader或完整pair-state UI。Validated package中存在embedded model时启用Features/source;否则保持scene-only模式,Preview、product tree和geometry selection仍可用。以下independent slots和配对状态是后续architecture,不是当前Viewer行为: + +| 状态 | 条件 | UI/能力 | +| --- | --- | --- | +| `artifact_matched` | scene `source.kind=model`,且 exact model bytes SHA-256、graph ID、model schema version 全部相等 | Preview和DAG并列可用;在结构验证前不cross-link或author | +| `provenance_matched` | `artifact_matched` 且browser完成下述全部结构cross-check | 启用read-only DAG/entity双向定位和binding command draft/export;不代表CAD replay已验证 | +| `scene_only` | 只有 scene,且无 verified embedded model | Preview、tree、entity inspector 可用;DAG 和 authoring disabled | +| `model_only` | 只有 model | DAG/params 可用;viewport 显示 no evaluated scene,不执行 browser replay | +| `mismatched` | 两者都存在,且任一pairing field不同,或三个fields相等但任一required provenance cross-check失败 | 两侧仍可独立查看,显示stale/mismatch或invalid-provenance banner;禁止自动cross-link和authoring | +| `non_model_scene` | scene source 是 imported/manual | Preview 可用;model pairing 不适用 | + +Hash 必须对用户选择的原始 `ArrayBuffer` 计算,不能 parse 后 reserialize 再计算。Embedded model也必须按manifest URI、byte length和hash验证后才能用于Features。未来若加入external model slot,external与embedded model不同时必须进入mismatch,不能静默优先任意一份。 + +Browser 对 operation JSON 只做 budgeted display/index validation,不 replay。DAG index 读取 `graph.graph_id`、`nodes[].node_id/op/params/param_exprs/inputs/output_count/display`、`edges`、`leaf_ids` 和 expression graph;必须检测 duplicate IDs、dangling inputs、cycles、`edges`/`inputs` disagreement 和超预算 payload。它不是 Python model importer 的安全替代品,connector apply backend 仍须执行 canonical model validation/replay。 + +Package validators已经验证每个model-backed definition/entity/connector `(graph_id,node_id,output_slot)`存在于embedded graph且slot有效,并验证operation source span/callsite。未来进入`provenance_matched`前,browser还应验证第14.3节完整connector producer contract、topology records、semantic bindings以及`edges`/`inputs`一致性。Browser静态检查不能声称已证明replayed input/output equality或geometry correspondence;后者仍由trusted compiler/backend验证。 + +未来的`provenance_matched`仍只是artifact/record consistency,不证明无OCP replay的browser重新建立了geometry correspondence。Connector command提交必须由backend返回`cad_verified` capability:backend重新验证exact model/scene preconditions、trusted compiler ownership evidence和target resolution后才执行mutation。 + +### 19.3 Renderer + +负责: + +- 按 geometry + appearance 对 repeated occurrences 做 instanced mesh rendering,并保留 `instanceId -> scene_node_id` map。 +- PBR 或稳定 CAD studio shading。 +- CAD edge overlay。 +- Mesh ID pass、CAD line hit testing 和 runtime vertex point picking。 +- Clipping planes。 +- Grid、axes 和 background。 +- Fit-to-view。 + +Renderer 对外只暴露typed commands/events,例如load package、set node visibility、set selection intent、fit bounds和resolved raw hit。DOM event handlers不应绕过renderer state随意mutate unrelated Three.js objects。Material创建从`SceneAppearance`确定性映射,definition appearance和node override按manifest precedence解析;material-aware preview不从mesh name猜测颜色。 + +### 19.4 Product Tree 与 Features + +Tree 直接使用 scene node hierarchy,不解析 operation graph。Tree node 与 viewport occurrence 共享 `scene_node_id`。 + +当前Features tree读取embedded model:从`leaf_ids`开始递归跟随`inputs`,每个graph node只显示一次,再补上未从leaf访问的nodes;它显示operation dependency而不伪装成product hierarchy。选择feature时清除occurrence selection并显示: + +- `op`、category、`node_id`、input node IDs、output count、tags和result/leaf status。 +- 完整 JSON-safe `params`,不只显示 summary。 +- Display summary和`assignment_targets`。 +- Source path/call range;project-relative mapping通过`source_files[path]`显示完整Python file,高亮`line..end_line`并滚动到首行。 +- Source unresolved或member缺失时显示`call_text` fallback。 + +当前不实现viewport/entity到feature的双向定位,也不在选择feature时高亮所有对应geometry;`param_exprs`、semantic/topology delta详情也仍是planned capability。未来增加cross-link时只能使用明确且结构验证通过的provenance,不能根据名称或geometry proximity猜测对应关系。一个operation node可以没有可见entity,也可以对应多个definition/occurrence/entity,这是正常状态。 + +### 19.5 Selection Service + +Selection service通过toolbar显式选择`component|solid|face|edge|vertex` intent,并将triangle face groups、CAD edge groups和runtime vertex points的hit解析为: + +- Scene occurrence。 +- Part/assembly definition。 +- Component path。 +- Solid/face/edge/vertex entity。 +- Source graph output。 +- Engine geometry classification/properties。 +- Evaluated semantic bindings/tags 和 JSON-safe SDK metadata。 + +Selection toolbar 必须始终显示当前 intent `component|solid|face|edge|vertex`,不能用一次 click 同时产生五个隐式 selection。Inspector展示当前实现可用的occurrence/definition/entity identity、properties、source、tags和metadata;选中的occurrence/entity在viewport高亮。World-space point/axis/bounds是runtime derived view,并标记derived;sidecar中的definition-local values保持不变。Viewer可以生成`UNIQUE QL SELECTOR` convenience text,但它不是canonical selection identity或自动持久化command。 + +### 19.6 Connector Editor + +Connector panel 列出 selected definition 的 evaluated connector snapshots,并可在`provenance_matched` pair上进入create mode。Create mode只允许`connector_binding_status="supported"`的face、edge或vertex target;其他entities仍可选择和inspect,但panel显示exact status reason。预览使用entity`sdk_connector_frame`显示local/world axes,收集stable connector ID、nullable name和face/edge flip,然后生成第14.4节的`ConnectorBindingSpec`;vertex固定`flip=false`。 + +Static build 提供 download command;hosted mode 提交给明确配置的 backend endpoint。Panel 必须显示 target definition 及 occurrence count、pending/accepted/conflict 状态、新 model hash 和 scene revision。`scene_only`、`mismatched`、unbound/imported entity、assembly without supported owner operation 都是 disabled state,并给出具体原因。 + +### 19.7 Workspace Store + +Workspace 与 loaded scene revision 分开保存。Scene revision 改变后: + +- 仍存在的 stable node IDs 保留 visibility/selection preference。 +- 不存在的 IDs 被 invalidated 并产生 diagnostic。 +- Entity selection 只有在 entity asset/content identity 和 entity ID 仍匹配时保留。 +- 禁止 geometry-nearest 自动替换 critical semantic selection。 + +## 20. Viewer MVP 功能边界 + +### 20.1 必须完成 + +- 加载validated `.scene.zip` package;没有embedded model时保持scene-only fallback。 +- Orbit、pan、zoom、fit-to-view。 +- Assembly/product tree。 +- Embedded model Features tree、完整node params、assignment targets和operation summary。 +- Manifest-declared完整Python source display、originating line range highlight/scroll和`call_text` fallback。 +- `component`、`solid`、`face`、`edge`、`vertex` 五种 selection intent。 +- Hide/show、isolate 和 reset visibility。 +- Basic material color 和 stable shading。 +- CAD edge overlay。 +- Entity inspector:engine geometry properties、graph/topology provenance、tags、SDK metadata。 +- Bounds、axes 和 grid。 +- Scene/asset validation error UI。 +- Display scene/source/revision information。 + +独立external model/unpacked loading、完整pair-state UI、parameter expressions和viewport/entity/Features双向定位属于后续扩展,不是当前MVP完成条件。 + +### 20.2 推荐进入 MVP 后半段 + +- Section/clipping planes。 +- Exploded view。 +- Approximate measurement,并明确精度等级。 +- Named views。 +- Screenshot/export image。 +- Semantic tag/filter panel。 +- Connector binding command authoring;hosted apply 可以在后续 milestone 接入。 + +### 20.3 非 MVP + +- Browser-side CAD replay。 +- Browser-side arbitrary feature/parameter editing。 +- Constraint editing。 +- Multi-user collaboration。 +- Physics simulation。 +- Arbitrary animation timeline。 +- Texture authoring。 +- General-purpose game scene scripting。 +- Exact BRep measurement without backend/kernel。 + +## 21. Incremental Scene Protocol + +第一阶段先完成 immutable full snapshot。Patch protocol 只能建立在 stable scene IDs、asset hashes 和 strict revision validation 已经完成之后。 + +建议后续 message envelope: + +```json +{ + "protocol_version": "1.0", + "message_type": "snapshot", + "scene_id": "gearbox-demo", + "revision": "sha256:...", + "base_revision": null, + "sequence": 1, + "message_id": "uuid", + "payload": {} +} +``` + +Patch 至少支持: + +- `upsert_definition` +- `remove_definition` +- `upsert_node` +- `remove_node` +- `set_transform` +- `set_visibility` +- `set_appearance` +- `add_asset` +- `remove_asset_reference` + +Patch contract 必须定义: + +- `base_revision` optimistic concurrency。 +- Atomic transaction boundary。 +- Idempotency。 +- Duplicate/out-of-order message handling。 +- Missing asset behavior。 +- Hash mismatch behavior。 +- Reconnect 后 full snapshot resync。 + +不建议直接使用无约束 JSON Patch。Typed scene operations 更容易做 referential integrity validation,也能区分 transform-only update 与 geometry replacement。 + +## 22. Validation 与安全 + +Scene 文件可能由网络或用户上传,必须比当前内部 model replay 更严格。 + +### 22.1 Schema Validation + +必须验证: + +- Required/unknown fields policy。 +- Semantic version 格式和 capability compatibility。 +- ID 唯一性和合法字符。 +- Parent、definition、asset、appearance、entity、connector 和 `SelectionRef` 全部存在。 +- Top-level/definition/node/entity/connector/appearance source matrix、root IDs、graph IDs和manual source IDs满足第8.1/11/14.3节。 +- Model connector source node具有正确add/forward op、slot、owner/connector params和direct inputs;trusted compiler另验证replayed equality和lineage。 +- Node hierarchy 无环。 +- Rigid transforms 有效。 +- Bounds finite 且 min <= max。 +- Asset byte length 和 hash 一致。 +- `source_files`按path排序且唯一,project-relative `.py` path archive-safe且无case-insensitive collision,URI exact等于`sources/`。 +- Python source member是strict UTF-8,length/hash匹配;project-relative operation mapping解析到embedded file,source span等于`call_text`,`callsite_id`可重算,unresolved source要求`path=null`。 +- Entity face/edge ranges 不越过对应 `(mesh_index, primitive_index)` 的 index accessor。 +- Triangle indices 不越过 vertex buffer。 +- Triangle/line GLB 的 scene、node、mesh、primitive、buffer 和 accessor 数量与 allowlist profile 一致。 +- GLB accessor type、component type、primitive mode、coordinate conversion 与 scene contract 一致。 +- Entity adjacency、source-kind agreement、face/edge range partition、alignment 和 deterministic ordering 满足 contract。 +- Revision 可重新计算。 + +### 22.2 Resource Budgets + +Loader必须在分配payload-sized memory、inflate或GPU upload前应用resource profile。Base默认untrusted-input profile固定为: + +| Resource | Default maximum | +| --- | ---: | +| ZIP members | 50,000 | +| Input archive bytes | 256 MiB | +| Canonical stored archive bytes | 256 MiB | +| Total declared uncompressed bytes | 1 GiB | +| One member / one GLB | 256 MiB | +| `scene.json` | 32 MiB | +| One entity sidecar | 64 MiB | +| Embedded `model.json` | 64 MiB | +| Presentation JSON | 8 MiB | +| Decompression ratio per member and aggregate | 100:1 | +| JSON nesting depth | 64 | +| One JSON string UTF-8 bytes | 1 MiB | +| One URI UTF-8 bytes | 1,024 | +| One structural ID UTF-8 bytes | 4,096 | +| Definitions / nodes | 25,000 / 100,000 | +| Geometry / edge / entity assets | 25,000 each | +| Embedded Python source files | 25,000 | +| Appearances / connectors / cameras | 25,000 / 100,000 / 1,000 | +| Scene hierarchy depth | 256 | +| Forwarded connector chain depth | 64 | +| Entities per sidecar / total | 500,000 / 2,000,000 | +| Triangle vertices per asset / total | 2,000,000 / 10,000,000 | +| Triangles per asset / total | 2,000,000 / 10,000,000 | +| Line vertices per asset / total | 2,000,000 / 10,000,000 | +| Line segments per asset / total | 2,000,000 / 10,000,000 | +| Static decoded-buffer cost | 512 MiB | + +MiB是`1024 * 1024`bytes。`input archive bytes`是transported complete ZIP byte length,不是member sizes之和,只由packed importer preflight验证;unpacked input没有该指标。Compression ratio定义为sum uncompressed member sizes / complete input archive length,且per-member ratio是uncompressed size / max(1, compressed size)。Archive preflight必须使用central/local header中一致的declared sizes累加并checked-arithmetic;实际inflate bytes必须恰好匹配declared size且仍受streaming counter限制。 + +Canonical exporter和packed importer都必须在materialize canonical output前精确计算`canonical_stored_archive_bytes = 22 + sum(76 + 2 * ascii_name_byte_length + payload_byte_length)`;该式适用于第15.2节zero-extra/zero-comment local header、central header和EOCD profile。值必须`<=256 MiB`且所有entry counts、sizes、offsets和central directory fields必须适配non-ZIP64 widths。这样canonical export总能被同一Base profile重新import;一个accepted deflated input即使original archive小于256 MiB,也只有在其canonical stored representation同样不超过256 MiB时才被接受。GLB header/chunk lengths还必须适配unsigned 32-bit GLB fields。 + +`static decoded-buffer cost`是artifact-derived validity formula,不依赖implementation residency:对每个unique triangle GLB加`position.byteLength + normal.byteLength + index.byteLength`的CPU copy和同值GPU copy;对每个unique line GLB加`position.byteLength + index.byteLength`的CPU/GPU copies;对每个vertex entity另加runtime point-picking`12` position bytes + `4` entity-index bytes的CPU/GPU copies;再加所有immutable JSON/text member uncompressed byte lengths一次。Python source使用general one-member/package limits,不使用64 MiB embedded model专用上限,并计入member count、total uncompressed、canonical archive和static immutable cost。Total使用checked uint64 arithmetic且必须`<=512 MiB`。Scene occurrences不重复计asset buffers;implementation临时parse/staging copies不影响artifact validity,但仍受process sandbox memory limit。Renderer可以lazy load降低实际residency,不能用lazy behavior接受超过static formula的package。 + +Browser model/DAG display另固定默认:model artifact最多64 MiB、100,000 operation nodes、500,000 dependency edges、100,000 expression nodes、graph depth 10,000。Base annotations固定为0;extension必须定义额外count budget。Implementations可以由trusted deployment显式提高limits,但不得降低schema/identity/hash检查,且UI必须显示active profile。Compiler/exporter和Python/TypeScript importers的默认tests必须使用上述同一profile。 + +Load/decode deadline是operational policy而不是artifact validity,因为wall-clock结果不deterministic。Viewer默认每个worker task 30 seconds并支持AbortSignal/cancellation;timeout产生workspace error,不写入manifest diagnostic。Streaming progress必须在compressed read、inflate、hash、JSON/GLB validation和GPU upload阶段报告。 + +### 22.3 Package Security + +- 拒绝 ZIP path traversal。 +- 拒绝 absolute path。 +- 默认拒绝 external URI。 +- 不执行 package 内脚本。 +- Embedded `.py`只作为inert UTF-8 text解码和escaped source display;不得`eval`、import、注入raw HTML或发送给Python runtime。 +- 使用 budgeted parser 在交给通用 renderer loader 前 preflight GLB。 +- 拒绝 GLB 中的 nested/external/data URI、images、textures、animations、skins、morph targets、sparse accessors、`extras` 和未声明 extension。 +- Renderer loader 的 network/file URI resolver 必须禁用,即使 preflight 有遗漏也不能发起子资源请求。 +- Hash verification 必须在 GPU upload 前完成。 + +### 22.4 Model Replay Service + +若未来服务端接受 `model.json` 并生成 scene,还必须增加 model payload budgets、kernel replay timeout、worker isolation 和 cancellation。Scene Viewer 本身不应执行 model operations。 + +## 23. 现有 Model Serializer 的前置加固 + +本地 runtime 直接编译 scene 不必等待所有 model serializer 问题修完,但以下问题会阻塞“上传 model -> 服务端生成 scene”的受信边界: + +1. Model import 对 required fields 和 nested graph schema 的验证不完整。 +2. Graph `edges` 和 node `inputs` 是两份 dependency representation,当前没有一致性检查。 +3. `output_count`、`leaf_ids` 和部分 cross-graph refs 缺少完整 import-time validation。 +4. Derived registries/logs 没有全部从 graph 重建或交叉验证。 +5. 当前随机短 graph/node IDs 不适合跨独立 build 的长期 scene identity。 +6. 缺少 canonical model digest;Scene 1.0 暂时只能使用 exact source artifact bytes hash。 +7. 缺少 payload、node count、depth、kernel time 和 memory budgets。 +8. 缺少正式 migration registry 和 historical fixtures。 + +这些工作与 Scene Schema 并行,但在开放不可信 model upload 前必须完成。 + +## 24. 测试策略 + +### 24.1 Scene Schema Tests + +- Minimal valid standalone shape scene。 +- Valid nested assembly scene。 +- The same valid/malformed corpus is accepted/rejected by Python and TypeScript with matching JSON Pointer/code。 +- Duplicate/missing IDs。 +- Parent cycles 和 dangling parent。 +- Missing definition/asset/appearance ref。 +- Invalid/non-finite transforms。 +- Wrong revision/hash。 +- Unknown field policy。 +- Required/optional/nullable matrix for every base record and discriminated union variant。 +- Stable caller-provided `scene_id`; missing `scene_id` fails without fallback。 +- Version compatibility matrix。 +- RFC 8785 vectors cover Unicode keys/strings、IEEE-754 rendering、negative zero and two-pass revision omission。 +- Default and boundary `compile_options`、safe-integer limits、resource profile limits。 + +### 24.2 Compiler Tests + +- One `Solid` -> one root-aware shape definition、one root occurrence、one geometry asset、one edge asset 和 one entity asset。 +- One `Part` -> product definition、material mapping、provenance。 +- Nested `Assembly` -> correct local hierarchy。 +- Same Part repeated N times -> one mesh asset、N instance nodes。 +- Same subassembly repeated -> stable expanded occurrence paths。 +- Flattened Compound -> no invented product hierarchy。 +- Placement-only change -> same geometry asset hash。 +- Material-only change -> same geometry asset hash。 +- Geometry/tessellation change -> new asset hash。 +- Part connectors -> evaluated definition-local connector snapshots。 +- Existing closed-edge/unresolved connector causes explicit compilation failure rather than omission。 +- Model runtime-only metadata mismatch with clean replay fails;replayed metadata remains deterministic。 +- Flattened Compound owner tags/metadata have one definition-level projection and are not copied to child solids。 +- Exact source artifact repeated with reversed/shuffled kernel traversal -> same entity、GLB and scene hashes。 +- Symmetric/coincident entities -> canonical bytes remain stable and ambiguous connector selectors are not marked supported。 +- Empty root、empty assembly、wire/shell/face-only root、任一collapsed face和completely empty line asset fail explicitly;individual collapsed edges remain degenerate inspector-only entities。 +- Unsupported root value fails explicitly。 +- Imported Part/Assembly或缺失stable `source_element_id`的imported root fails explicitly;缺少primitive mapping的imported entities remain `unbound`。 +- `embed_source=true`写入exact `model/model.json`和sorted `source_files`,每个`sources/`的bytes/hash/length保持一致。 +- 同graph ID但不同graph object的root、越界output slot和创建后被mutate的stale `ModelResult` snapshot全部fail closed。 +- `@model(export_dir=...)`和`ModelResult.export_artifacts()`只写一个`.scene.zip`,不旁置写model/session JSON、STEP、STL或FCStd。 + +### 24.3 Mesh Tests + +- Positions/normals finite。 +- Indices valid。 +- Triangle orientation consistent。 +- Bounds match vertices。 +- Face groups exactly partition expected index ranges。 +- Every face and rendered edge has exactly one positive range;degenerate edges have no range and expose an inspector-only entity。 +- CAD edge polylines are not triangle boundaries。 +- Deterministic GLB bytes across repeated exports。 +- Triangle and line writers reproduce normative exact-byte GLB vectors and hashes。 +- Round-trip GLB decode preserves counts and bounds。 + +### 24.4 Picking Tests + +- Component pick resolves scene node。 +- Repeated instances share asset but return distinct occurrence IDs。 +- Solid intent resolves owning solid from a face hit。 +- Face group resolves graph/node/output source and optional proven topology evidence without conflating them。 +- Edge group resolves source edge。 +- Vertex point pick resolves definition-local entity and occurrence-specific world point。 +- Entity inspector returns engine geometry/properties, tags and JSON-safe SDK metadata。 +- Trusted compiler binding/tag caches match runtime source; browser only claims artifact-proven target evidence for `model_topology`。 +- Revision change invalidates stale selection safely。 + +### 24.5 Embedded Model Provenance 与未来 Connector Tests + +- Embedded model在用于Features前完成byte-length/hash、schema version、graph ID、node shape、inputs、leaf IDs和output references cross-check。 +- Reformatting otherwise equivalent model JSON会改变Scene 1.0 exact-byte artifact hash。 +- Scene-only package保持Preview/tree/entity selection,Features/source disabled。 +- Future external model slot必须覆盖`artifact_matched`、`provenance_matched`、`model_only`和`mismatched` states,且非matched状态不能启用provenance cross-link或connector command drafting。 +- Binding spec stale model hash、scene revision、entity asset、entity source or duplicate connector ID fails atomically。 +- `model_output` binding requires exact source scene recompilation and unique ephemeral entity resolution; no nearest-geometry fallback。 +- Selector tie/unstable replay produces non-supported `connector_binding_status` and cannot draft a command。 +- Forwarded snapshot validates direct source component、source definition/snapshot ownership、offset composition and acyclic depth。 +- Missing source model/presentation/root output/toolchain profile prevents `cad_verified` capability。 +- Backend without editable model transaction rejects apply even for a direct-root Part。 +- Backend with future editable transaction still rejects nested immutable Part apply until nested Product rewrite capability is advertised。 + +### 24.6 Package Tests + +- Packed and unpacked scene equivalence。 +- Missing asset。 +- Corrupt GLB。 +- Hash mismatch。 +- ZIP traversal。 +- Duplicate/colliding ZIP member、backslash/NUL name、encrypted/non-regular entry 和 central/local header mismatch。 +- ASCII case-insensitive collision、data descriptor、ZIP64、multi-disk、extra/comment and unsupported method rejection。 +- Duplicate JSON object key、BOM、invalid UTF-8 和 trailing token。 +- Missing/wrong `source_files` member、unsafe path、wrong URI、case-fold collision、source hash/length mismatch和invalid UTF-8。 +- Embedded operation source unknown/missing field、unmapped project-relative path、span mismatch和wrong `callsite_id`。 +- Packed extraction/unpacked directory symlink escape。 +- Zip bomb/resource budget。 +- External URI rejected by default。 +- Canonical exporter reproduces exact archive bytes, order, timestamps, modes and CRC metadata。 + +### 24.7 Viewer End-to-End Fixtures + +至少保留以下 immutable fixtures: + +- Single colored bracket。 +- Repeated bearing balls 或 repeated fasteners。 +- Nested reducer assembly。 +- Large integrated actuator。 +- Symmetric part with ambiguous topology identities。 +- Scene with named view 和 appearance override。 + +每个 fixture 保留: + +- Exact source model artifact hash。 +- Scene manifest golden snapshot。 +- Canonical `scene.json` bytes、revision vector、asset bytes/hashes and canonical archive hash。 +- Expected node/definition/triangle counts。 +- Entity/connector snapshot counts、provenance variants、binding status、DAG linkage 和 five-intent picking assertions。 +- Features tree selection、assignment-target display、完整source file、active line range、scroll和unresolved-source fallback assertions。 +- Reference screenshot tolerance test。 + +## 25. 实施阶段 + +### Phase A:Scene Contract 与 Characterization + +状态:已冻结并由Python/TypeScript shared corpus、generated types、exact JCS/GLB/ZIP vectors和strict validators持续验证。 + +工作项: + +- 定义 immutable scene dataclasses。 +- 提交五个structural JSON Schema 2020-12 files、normative semantic rule registry和generated Python/TypeScript types。 +- 提交两个normative profile JSON、hash-linked numeric/canonicalization pseudocode和profile schemas/validators。 +- 定义strict manifest/package/sidecar/spec validators和canonical JSON encoder。 +- 定义 ID、revision、coordinate system 和 URI policy。 +- 定义 Entity Snapshot、evaluated connector 和 `ConnectorBindingSpec` exact records。 +- 定义 `ScenePresentationSpec` schema 和 deterministic no-presentation defaults。 +- 定义exact triangle/line GLB skeleton、default resource profile和canonical ZIP envelope。 +- 实现只接受`Mapping[str, bytes]`的low-level canonical ZIP envelope encoder/decoder;它不理解scene manifest,只负责Phase A exact headers、ordering、CRC、budgets和vectors。 +- 为现有 examples 记录 expected product hierarchy 和 mesh counts。 +- 明确 private `TriMesh` 与新 `RenderMesh` 的边界。 +- 增加shared valid/malformed corpus、JCS/revision vectors、exact-byte GLB vectors和ZIP metadata vectors。 +- 增加current Model Schema 2.0 topology coverage、reversed traversal、symmetric entity和selector ambiguity characterization tests。 + +退出标准: + +- `scene-1.0.schema.json`、`entities-1.0.schema.json`、`presentation-1.0.schema.json`、`connector-binding-1.0.schema.json`和`normalized-product-1.schema.json`全部closed并通过schema metaschema validation。 +- `scene-1.0-rules.json`、`scene-1.0-ocp-glb-2.profile.json`、`ocp-evaluated-properties-1.profile.json`及其linked pseudocode全部通过applicable schema、content-hash linkage和unknown-field validation。 +- Minimal/nested/repeated-instance scene能在Python和TypeScript构造或parse、验证和round trip。 +- 两端对全部shared valid/malformed fixtures给出相同accept/reject结果及稳定JSON Pointer/error code。 +- 相同scene重复序列化得到相同canonical bytes/digest;golden JCS和two-pass revision vectors逐byte通过。 +- Python/TypeScript GLB preflight接受triangle/line exact-byte vectors并拒绝每个forbidden-member mutation。 +- Python/TypeScript low-level archive preflight接受exact canonical ZIP vector、逐field mutations和allowlisted deflate fixture;low-level envelope encoder逐byte重现ZIP vector。 +- Default/resource/numeric边界、entity adjacency/range、provenance union、forwarded connector和binding status matrices有golden coverage。 +- 只有以上条件全部满足,文档和schemas才能从`Proposed`改为`Frozen`;实现PR不能自行放宽contract。 + +### Phase B:Scene Compiler 与 Render Assets + +状态:当前slice已能从runtime values生成canonical `.scene.zip`;model publishing profile嵌入`model/model.json`和mapped Python source。 + +工作项: + +- 实现 `compile_scene`。 +- 实现`SceneRoot`内Solid/Compound/Part/Assembly traversal。 +- 实现 definition/instance deduplication。 +- 实现第10.2节`set_model_metadata_rvalue` exact operation并纳入Model Schema 2.0 export/import/replay和translator capability registry;将stdlib和Phase B fixtures中的post-operation `set_metadata()`迁移到该operation,使clean replay投影与runtime exact相等。 +- 实现 `RenderMesh`、`RenderEdgeMesh`、normals 和 bounds。 +- 实现 deterministic triangle/line GLB writer 与 standard glTF coordinate conversion。 +- 实现 definition-specific entity snapshot、engine properties、SDK tags/metadata 和 face/edge picking ranges。 +- 实现 evaluated connector snapshots。 +- 将Phase A low-level envelope codec集成为scene directory/ZIP exporter/importer;增加manifest/member/hash/GLB/entity validation,不另写ZIP header implementation。 +- 实现operation source mapping、`source_files` collection和embedded model/source cross-validation。 +- 实现`@model(export_dir=...)`与`ModelResult.export_artifacts()`的single-package publishing path。 + +退出标准: + +- Example 10和当前model exporters可以生成validated self-contained package;其余保留examples按支持范围持续characterize。 +- Repeated part 只产生一个 geometry asset。 +- Placement-only changes 不改变 asset hash。 +- CAD edge asset 不是 triangle boundary derivation,且能够通过 line pass 加载。 +- Triangle/line writer逐byte重现Phase A vectors;canonical ZIP exporter逐byte重现archive vector。 +- Same exact inputs在fresh processes和reversed traversal characterization harness中产生相同scene、entity、GLB和archive hashes。 +- Full package validator拒绝hash/range/adjacency/profile/budget/forwarded-connector mutations before renderer consumption。 + +### Phase C:Viewer MVP + +状态:独立`viewer/` TypeScript/Three.js/Vite slice已在不运行CAD replay的情况下加载`.scene.zip`,显示evaluated scene、embedded Features和Python source。 + +工作项: + +- Validated package loader和content-hash checks。 +- Orbit/pan/zoom/fit。 +- Product tree。 +- Features tree、node params、assignment targets和source range display。 +- Component/solid/face/edge/vertex selection intent。 +- Visibility/isolate。 +- Basic appearance。 +- GPU edge overlay。 +- Entity/geometry/tag/metadata inspector。 +- Error/diagnostic UI。 +- 后续:external model/unpacked loaders、完整pair states、expressions和provenance-backed双向cross-link。 + +退出标准: + +- 大型 Example 20 可以加载、导航、选择和 isolate components。 +- Embedded model package的Features/source pane与scene viewport同时可用;scene-only package保持geometry capability。 +- Feature/source browser-level automation补齐line highlight和scroll regression。 +- Missing/corrupt assets 显示结构化错误,而不是白屏。 + +### Phase D:CAD Interaction + +目标:从通用 mesh viewer 进化到可用 CAD viewer。 + +工作项: + +- Section/clipping。 +- Exploded view。 +- Semantic tag/filter inspection。 +- Approximate measurement 与 precision labels。 +- Named views 和 screenshots。 +- Selection provenance panel。 +- Connector binding command UI;backend apply 等待 editable model transaction capability。 + +退出标准: + +- 用户可以从 component -> solid/face/edge/vertex -> source graph/semantic evidence 完成追溯。 +- 用户可以选择 supported topology entity、预览 connector frame 并生成 validated binding command。 +- Viewer 明确区分 exact 和 mesh-approximate measurement。 + +### Phase E:Publishing 与 Incremental Updates + +目标:支持 hosted scene、cache 和 transform/material 增量更新。 + +工作项: + +- Scene snapshot protocol。 +- Typed patch operations。 +- Revision conflict/resync。 +- Content-addressed asset service。 +- Browser persistent asset cache。 +- Worker-side model replay isolation。 + +退出标准: + +- Transform-only patch 不重新下载 geometry。 +- Duplicate/out-of-order patch 可检测并恢复。 +- Reconnect 能通过 full snapshot 正确 resync。 + +### Phase F:Exact CAD Services 与 Kinematics + +目标:在保持 thin Viewer 的前提下增加高级 CAD 能力。 + +可选工作项: + +- Backend exact measurement/query API。 +- Optional BRep sidecar。 +- Assembly joint projection 和 motion preview。 +- LOD、streaming 和 mesh compression。 +- Feature history linkage。 +- Annotation publishing。 +- Nested Product graph rewrite for connector apply。 + +这些能力不是 Viewer MVP 的前置条件。 + +## 26. 里程碑与优先级 + +| 优先级 | 里程碑 | 直接产物 | +| --- | --- | --- | +| P0 | Scene Schema + validator | 稳定 contract、canonical digest、malformed tests | +| P0 | Runtime Scene Compiler | `SceneRoot[Solid|Compound|Part|Assembly]` -> `CompiledScenePackage` | +| P0 | RenderMesh + triangle GLB | Browser-ready geometry、normals、bounds | +| P0 | CAD edge asset | Line GLB、正确 edge overlay、基础 edge provenance | +| P0 | Entity snapshot + five-intent picking | Viewer 与 CAD provenance/metadata 的桥梁 | +| P0 | TypeScript/Three.js package viewer | Camera、render、tree、Features/source、selection、inspector、asset reuse | +| P1 | External model pairing + E2E harness | Pair states、bidirectional provenance、source highlight browser regressions | +| P1 | Section/measure/explode | 基础 CAD inspection UX | +| P1 | Connector binding command | Topology selection 到 revision-bound authoring command | +| P1 | Persistent browser cache | 跨会话 asset reuse | +| P2 | Scene patch protocol | 增量更新和 hosted viewer | +| P2 | Exact backend services | Analytic measurement/query | +| P2 | Kinematics projection | Motion preview | + +## 27. Viewer MVP 总体验收标准 + +以下条件全部满足,才可称为首个可用 Viewer,而不是 mesh demo: + +1. Viewer 加载 scene 时不需要 SimpleCAD Python runtime 或 OpenCascade。 +2. Scene 保留 Assembly/Component/Part hierarchy,不只是一块 flattened mesh。 +3. Repeated part instances 共享 geometry asset。 +4. Component、solid、face、edge、vertex selection 可以追溯到 scene occurrence、entity snapshot 和 source provenance。 +5. Viewer 支持product tree、Features/params、selection、hide/show、isolate和fit-to-view。 +6. CAD edge overlay 不显示 triangle 内部边。 +7. Scene、GLB 和 entity snapshot sidecar 都有 strict validation 和 content hash。 +8. Large assembly 的 asset loading 有明确 budgets、progress 和 error state。 +9. Scene IDs、revision 和 asset IDs 的含义不同且被测试固定。 +10. Workspace state 不污染 canonical model 或 scene。 +11. Viewer 若提供 mesh measurement,必须明确标记为 approximate;Phase C MVP 可以尚未提供 measurement。 +12. 同一exact source artifact、resolved roots、presentation、compile options、profile bytes、registered `toolchain_hash`和完整`generator` record重复导出得到deterministic scene revision和asset hashes。 +13. Scene-only package保持geometry capability;embedded model package增加Features/source capability。Future external pair states需要另行满足`artifact_matched`、`provenance_matched`、model-only和mismatched测试。 +14. Entity inspector 显示 engine properties、topology provenance、tags、metadata 和 connectors,不依赖 browser OCP replay。 +15. 选择mapped feature时显示assignment targets和完整manifest-declared Python file,高亮originating range且不执行source code。 + +## 28. 风险与取舍 + +### 28.1 Scene 与 Model 信息重复 + +Scene 必然复制一部分 evaluated state,例如 transforms、material color 和 semantic tag cache。解决方式不是消除所有重复,而是明确 canonical owner、source artifact hash 和 invalidation。Scene 是可丢弃 build artifact;不可重建的 authored presentation 由 `ScenePresentationSpec` 拥有。 + +### 28.2 Topology Identity 不够稳定 + +当前 `topo_id` 和随机 operation node IDs 不能保证跨独立 build 稳定。Scene 1.0 只能保证同一 model revision 内 picking 可追溯。跨 revision selection persistence 优先依赖 product path 和 semantic bindings;无法证明时应失效。 + +### 28.3 GLB 与 CAD 坐标习惯不同 + +Scene 固定 `+Z` up 和 mm,GLB 固定遵守 glTF 2.0 meter/`+Y` up,`asset_to_scene` 保存唯一转换。不能让不同 exporter 自行选择 axis/unit,否则 transform、measurement 和 picking 会分裂。 + +### 28.4 Viewer 过早依赖具体前端框架 + +Scene contract、GLB 和 entity snapshot sidecar 应独立于 Three.js、Babylon.js 或其他 renderer。Frontend 可以替换,scene schema 不能由某个 framework object serialization 决定。 + +### 28.5 一开始设计完整游戏引擎 Scene + +SimpleCAD 需要的是 CAD evaluated scene,不是脚本、physics、audio、particle、arbitrary ECS。Schema 应保留 extension/capability 机制,但第一阶段只实现 CAD Viewer 必需的 hierarchy、renderable、appearance、camera 和 annotation。 + +### 28.6 Scene 自动附加到每个 ModelResult + +自动编译会显著增加每次model build的tessellation、内存和序列化成本,也隐藏质量参数,因此不能成为`@model`的无条件副作用。当前实现采用显式opt-in:`@model(export_dir=...)`在调用结束后自动发布,省略`export_dir`时保持in-memory-only;`ModelResult.export_artifacts(output_dir=...)`提供调用后的显式替代。两个入口都只写一个self-contained `.scene.zip`。 + +## 29. 推荐决策 + +1. 新增独立 Scene Schema 1.0,不扩展 Operation Graph 为 render scene。 +2. `model.json` 继续是 design/replay canonical artifact;scene 是 evaluated、可重建的显示 artifact。 +3. `capture_result`不承担scene visibility或hierarchy;low-level `compile_scene(scene_id=..., roots=...)`显式提供logical scene identity和roots,automatic publishing wrapper只从explicit captured outputs派生roots。 +4. 使用 Definition/Instance 分离,保留 assembly hierarchy 和 repeated mesh reuse。 +5. 使用`.scene.zip` package。Generic render package包含`scene.json`、content-addressed triangle/line GLB和definition-specific entity sidecar;self-contained model package还包含`model/model.json`和`sources/`。 +6. Scene 使用右手 `+Z` up、mm 和 parent-relative rigid transforms;GLB 保持标准 glTF meter/`+Y` up。 +7. Render mesh 与 collision mesh 分离;新增 normals、CAD edges、typed entity snapshots 和 picking ranges。 +8. Product material 与 render appearance 分离,RGB 只做确定性默认映射。 +9. Product path 是 scene occurrence identity 的首选,asset bytes 使用 SHA-256 identity。 +10. Viewer workspace、`ScenePresentationSpec` 和 evaluated scene 分离。 +11. 首先完成 immutable full snapshot,再设计 patch protocol。 +12. Browser Viewer 不执行 model replay;不可信 model replay 放在有 budget 和 isolation 的后端 worker。 +13. Mesh Viewer 的 measurement 默认为 approximate,exact CAD measurement 通过后端或 optional kernel 提供。 +14. Scene schema、compiler、asset writer和entity/picking contract先于高级frontend能力;独立`viewer/`在validated package上逐步扩展pairing、provenance和panels。 +15. Connector authoring 使用 revision-bound command,由 SDK/backend 更新 canonical model并重编 scene;不能直接修改 evaluated snapshot。 +16. Operation source mapping是只读Viewer evidence,不执行代码,也不成为feature selection的第二canonical owner。 + +## 30. 建议的下一步 + +Phase A、compiler/exporter、source embedding和Viewer基础slice已经落地,下一步集中处理剩余边界: + +1. 为Viewer加入不依赖手工操作的browser E2E fixture,验证package upload、Features selection、assignment targets、完整source display、line range highlight和scroll。 +2. 明确当前package-only loader与未来external model/unpacked loader的产品边界,再实现pair-state UI和provenance-backed双向cross-link。 +3. 完成presentation/imported source compilation或将相关public types继续标记为contract-only。 +4. 继续以shared corpus保持Python/TypeScript validator parity,新增每个source mapping/path/member trust-boundary mutation。 +5. Connector command UI可以围绕schema实现;backend apply仍必须等待editable model transaction,nested Product rewrite不做隐式兼容。 diff --git a/SimpleCADAPI/design-docs/step-brep-recursive-geometric-explanation.md b/SimpleCADAPI/design-docs/step-brep-recursive-geometric-explanation.md new file mode 100644 index 0000000..4c886ac --- /dev/null +++ b/SimpleCADAPI/design-docs/step-brep-recursive-geometric-explanation.md @@ -0,0 +1,1911 @@ +# STEP B-Rep 基础几何解释与残差递归算法规范 + +## 文档状态 + +- 状态:Proposed +- 规范版本:0.2 +- 目标项目:SimpleCADAPI 2.x +- 输入:STEP 导入后得到的 B-Rep snapshot +- 输出:可重放的几何程序、有限解释证据、评分分量和最终验证指标 +- v0.2 实体基础单元:Box、Cylinder、Cone、Sphere、Torus、RawBRep +- v0.2 组合操作:Union、Subtract +- v0.2 NURBS 能力:解析曲面约化、NURBS 面证据匹配、RawBRep 兜底;不从开放 NURBS 面猜测实体 +- 非目标:证明原始 CAD feature history、识别 NACA 等领域参数族、保证全局最优、从 mesh 恢复解析 B-Rep + +## 1. 摘要 + +STEP B-Rep 提供有限数量的 Face、Edge、Vertex、支撑几何及 topology,但通常不提供原始 primitive、Boolean 和 feature tree。本规范把逆向定义为以下有限搜索: + +1. 将输入 B-Rep 转换为有限的面、边和邻接证据原子。 +2. 只从输入中出现的有限几何事件枚举 Box、Cylinder、Cone、Sphere 和 Torus 候选。 +3. 用显式谓词计算每个候选解释的证据原子 bitset。 +4. 用版本化数值代价表达基础单元常见性和参数复杂度。 +5. 对候选计算 `R+ = T - H` 和 `R- = H - T`,再递归解释每个 residual solid。 +6. 用有硬预算的 Beam Search 枚举有限个完整程序。 +7. 从头重放完整程序,并用 symmetric-difference、双向边界距离和法向误差判定几何等价。 +8. 在验证通过且所有目标 SurfaceAtom 都有归属的程序中选择总代价最低者。 + +本文的规范性术语均映射到有限集合、公式、确定排序或返回 `OK | FAILED | TIMEOUT | BUDGET_EXHAUSTED` 的 kernel 调用。说明性文字不创建额外算法分支。 + +## 2. 输入、程序和总函数 + +### 2.1 输入 + +输入实体记为: + +\[ +T=(V,E,F,A) +\] + +其中 `V`、`E`、`F` 和 topology relation 集合 `A` 均为有限集合。 + +### 2.2 输出程序 + +```text +Program := Box(parameters) + | Cylinder(parameters) + | Cone(parameters) + | Sphere(parameters) + | Torus(parameters) + | RawBRep(snapshot, byte_length, raw_stats) + | Union(Program, Program) + | Subtract(Program, Program) +``` + +内部搜索额外使用占位节点: + +```text +Template := Program | Slot(slot_id) +``` + +完成程序不允许包含 `Slot`。 + +`RawBRep` 的参数不是 process-local kernel object。它是以下总函数的结果: + +```text +SnapshotOf(shape, context) -> KernelResult[BRepSnapshot] + +BRepSnapshot + profile = "simplecad-brep-snapshot-0.2" + bytes + sha256 = SHA256(bytes) +``` + +该 profile 的 encoder 必须保存重放 shape 所需的 Vertex、Edge curve、Face surface、trim、orientation、tolerance 和 topology relation,并对相同 evaluated B-Rep 和 profile 产生相同 bytes。若 backend 不提供该 profile,`SnapshotOf` 返回 `FAILED(UNSUPPORTED_SNAPSHOT_PROFILE)`。`RawBRepKey = snapshot.sha256`,`byte_length = len(snapshot.bytes)`;反序列化时必须重新计算 SHA-256 和 byte length 并相等。 + +`raw_stats` 是从 snapshot 解码结果重新枚举得到的 closed record: + +```text +RawStats + analytic_face_count + nurbs_face_records # 每项含 NurbsCost 所需全部整数 + edge_count + vertex_count +``` + +创建节点时从 `ResidualAnalysis` 复制;反序列化时从 snapshot 重算并要求相等。 + +### 2.3 Kernel 调用总类型 + +所有可能失败的 kernel 操作统一返回: + +```text +KernelResult[T] := + OK(value: T) + | FAILED(code: FailureCode) + | TIMEOUT + | BUDGET_EXHAUSTED +``` + +每次调用前执行: + +```text +KernelContext.try_reserve(kind) := + if monotonic_clock() >= deadline: TIMEOUT + elif total_calls + 1 > max_total_calls: BUDGET_EXHAUSTED + elif kind == BOOLEAN and boolean_calls + 1 > max_boolean_calls: BUDGET_EXHAUSTED + else increment counters and OK +``` + +所有 kernel API 都接收 `deadline`。分析阶段调用失败时,整个输入降级为 `RawBRep`;候选阶段调用失败时丢弃该候选;最终验证阶段调用失败时,该程序验证失败。 + +本规范的调用计数单位是本节列出的逻辑 kernel API 调用,不是 CAD kernel 内部函数调用次数。调用包装器必须在进入 backend 前执行一次 `try_reserve`。纯 CPU 循环每处理 1024 个元素检查一次 deadline;到期后返回 `TIMEOUT`。 + +特殊失败动作只有以下四类,后文不得隐式覆盖: + +```text +initial/residual analysis failure -> 当前 task 只允许 RawExpansion +candidate construction/measurement failure -> 丢弃当前 candidate +residual generation failure -> 丢弃当前 candidate +validation failure -> 当前 program 的 ValidProgram = false +``` + +初始输入 analysis 失败时,只有 `SnapshotOf(target)` 成功才能返回 RawBRep;否则返回 `ANALYSIS_FAILED` 且 `program = null`。 + +### 2.4 空 shape + +Boolean 结果类型为: + +```text +BooleanShape := EMPTY | NONEMPTY(shape) +``` + +定义: + +```text +volume(EMPTY) = 0 +surface_area(EMPTY) = 0 +components(EMPTY) = [] +ValidSolid(EMPTY) = false +``` + +`EMPTY` 没有 bbox、Face、Edge 或 Vertex,不能成为 Program 叶节点。它只允许出现在 residual 和程序简化过程中。 + +### 2.5 几何程序执行 + +```text +Render(Box/Cylinder/Cone/Sphere/Torus) := kernel_construct_primitive(parameters) +Render(RawBRep(snapshot,byte_length,raw_stats)) := kernel_copy(snapshot) +Render(Union(A,B)) := kernel_regularized_union(Render(A), Render(B)) +Render(Subtract(A,B)) := kernel_regularized_cut(Render(A), Render(B)) +``` + +任一子调用不是 `OK` 时,父调用返回相同失败状态。 + +内部验证允许程序简化常量 `EMPTY`: + +```text +Union(EMPTY,X) = X +Union(X,EMPTY) = X +Subtract(EMPTY,X) = EMPTY +Subtract(X,EMPTY) = X +``` + +完成输出程序执行常量折叠后不得含 `EMPTY`。 + +## 3. 完整配置 + +### 3.1 配置结构 + +```text +ReverseConfig + tolerance: + eps_linear + eps_angular + eps_radius + eps_surface + eps_normal + eps_volume + eps_area + eps_length + discretization: + face_deflection + face_angular_deflection + edge_step + nurbs_u_samples + nurbs_v_samples + max_mesh_area_relative_error + analysis: + max_analysis_entities + max_analysis_total_kernel_calls + max_event_records + analysis_timeout_seconds + candidates: + max_direction_clusters + max_extent_pairs_per_axis + max_box_frames + max_box_candidates_per_frame + max_candidates_per_type + boolean_prefilter_top_m + min_trigger_entities + min_support_coverage + max_candidate_attempts_per_task + max_candidate_kernel_calls_per_task + score: + reward_support + reward_boundary + reward_edge + reward_adjacency + penalty_leaf_cost + unmatched_support_penalty + unmatched_boundary_penalty + unmatched_edge_penalty + expansion_margin + cost: + type_cost_box + type_cost_cylinder + type_cost_cone + type_cost_sphere + type_cost_torus + type_cost_nurbs_face + type_cost_raw_brep + operation_cost_union + operation_cost_subtract + scalar_parameter_cost + search: + beam_width + max_task_depth + max_expanded_states + max_search_total_kernel_calls + max_search_boolean_calls + search_timeout_seconds + max_completed_programs_to_validate + max_pending_tasks_per_state + validation: + max_validation_total_kernel_calls + max_validation_boolean_calls + validation_timeout_seconds +``` + +### 3.2 默认容差 + +令输入 bbox 对角线为 `D`,STEP entity tolerance 最大值为 `t_step`: + +\[ +\epsilon_{linear}=\max(t_{step},10^{-9}D) +\] + +\[ +\epsilon_{surface}=2\epsilon_{linear} +\] + +\[ +\epsilon_{radius}=2\epsilon_{linear} +\] + +\[ +\epsilon_{angular}=10^{-7} +\] + +\[ +\epsilon_{normal}=10^{-6} +\] + +\[ +\epsilon_{volume}=\max(10^{-12}D^3,10\epsilon_{linear}^3) +\] + +\[ +\epsilon_{area}=\max(10^{-12}D^2,10\epsilon_{linear}^2) +\] + +\[ +\epsilon_{length}=\epsilon_{linear} +\] + +角度单位为 radian,长度单位采用导入后统一的模型单位。 + +### 3.3 默认离散化与预算 + +```text +face_deflection = max(eps_surface / 2, 1e-6 * D) +face_angular_deflection = eps_normal / 2 +edge_step = max(eps_length, 1e-4 * D) +nurbs_u_samples = 9 +nurbs_v_samples = 9 +max_mesh_area_relative_error = 1e-3 + +max_analysis_entities = 1000000 +max_analysis_total_kernel_calls = 1000000 +max_event_records = 1000000 +analysis_timeout_seconds = 120 + +max_direction_clusters = 12 +max_extent_pairs_per_axis = 8 +max_box_frames = 64 +max_box_candidates_per_frame = 16 +max_candidates_per_type = 24 +boolean_prefilter_top_m = 16 +min_trigger_entities = 1 +min_support_coverage = 0.01 +max_candidate_attempts_per_task = 10000 +max_candidate_kernel_calls_per_task = 100000 + +reward_support = 10.0 +reward_boundary = 5.0 +reward_edge = 2.0 +reward_adjacency = 1.0 +penalty_leaf_cost = 1.0 +unmatched_support_penalty = 5.0 +unmatched_boundary_penalty = 2.0 +unmatched_edge_penalty = 1.0 +expansion_margin = 2.0 + +type_cost_box = 1.0 +type_cost_cylinder = 1.0 +type_cost_cone = 1.5 +type_cost_sphere = 1.5 +type_cost_torus = 2.0 +type_cost_nurbs_face = 4.0 +type_cost_raw_brep = 8.0 +operation_cost_union = 0.5 +operation_cost_subtract = 0.5 +scalar_parameter_cost = 0.05 + +beam_width = 8 +max_task_depth = 10 +max_expanded_states = 1000 +max_search_total_kernel_calls = 1000000 +max_search_boolean_calls = 500 +search_timeout_seconds = 120 +max_completed_programs_to_validate = 32 +max_pending_tasks_per_state = 256 + +max_validation_total_kernel_calls = 1000000 +max_validation_boolean_calls = 500 +validation_timeout_seconds = 120 +``` + +### 3.4 配置合法性 + +`ConfigValid(config)` 当且仅当: + +- 所有浮点配置均为 finite。 +- 所有 `eps_*`、`face_deflection`、`face_angular_deflection`、`edge_step` 和 timeout 严格大于 `0`。 +- `0 < eps_angular < pi/2` 且 `0 < eps_normal < pi/2`。 +- `nurbs_u_samples >= 3` 且 `nurbs_v_samples >= 3`。 +- `0 <= max_mesh_area_relative_error < 1`。 +- 所有 `max_*`、`beam_width` 和 `boolean_prefilter_top_m` 是有限正整数。 +- `min_trigger_entities` 是有限非负整数。 +- `0 <= min_support_coverage <= 1`。 +- 所有 reward、penalty 和 cost 是 finite 且大于等于 `0`。 + +输入数值合法性: + +```text +FiniteBRep(T) := + every bbox coordinate, Vertex coordinate, curve parameter, + surface parameter, knot, weight and tolerance used by this algorithm is finite + and every radius used by an analytic record is > 0 + and every direction consumed by normalize has norm > eps_linear +``` + +`t_step` 为所有 finite entity tolerance 的最大值;没有 entity tolerance 时取 `0`。`FiniteBRep == false` 时返回 `INVALID_INPUT`。 + +`ConfigValid == false` 时返回 `INVALID_CONFIG`,不调用 kernel。 + +## 4. 量化与确定顺序 + +### 4.1 量化 + +定义半数向正无穷舍入: + +\[ +Q(x,\epsilon)=\lfloor x/\epsilon+1/2\rfloor +\] + +\[ +Q_3(p,\epsilon)=(Q(p_x,\epsilon),Q(p_y,\epsilon),Q(p_z,\epsilon)) +\] + +单位方向 `d` 的规范符号:找到绝对值最大的分量;多个分量同大时按 `x,y,z` 选择第一个;若该分量小于 `0`,令 `d := -d`。 + +\[ +Q_d(d)=Q_3(d,\sin(\epsilon_{angular})) +\] + +### 4.2 基本方向谓词 + +定义有向夹角: + +\[ +angle(a,b)=acos(clamp(dot(a/\|a\|,b/\|b\|),-1,1)) +\] + +只有 `norm(a) > eps_linear` 且 `norm(b) > eps_linear` 时定义;否则调用该谓词的 match 结果为 `false`。 + +\[ +parallel(a,b):=angleAbs(a,b)\leq\epsilon_{angular} +\] + +其中: + +\[ +angleAbs(a,b)=acos(clamp(|dot(a,b)|,-1,1)) +\] + +\[ +perpendicular(a,b):=|dot(a,b)|\leq\sin(\epsilon_{angular}) +\] + +两轴线 `(q1,d1)`、`(q2,d2)` 共轴: + +```text +coaxial := parallel(d1,d2) + and norm((q2-q1) - d1*dot(d1,q2-q1)) <= eps_linear +``` + +### 4.3 Bbox 相交 + +闭区间 bbox `A`、`B` 相交当且仅当每个轴 `k` 满足: + +\[ +A_{min,k}\leq B_{max,k}\land B_{min,k}\leq A_{max,k} +\] + +`expand(B, e)` 将每个最小坐标减 `e`,每个最大坐标加 `e`。 + +### 4.4 Canonical serialization + +所有 canonical record 使用 UTF-8 JSON,object key 按 UTF-8 byte order 排序,禁止 NaN 和 Infinity,浮点参数先替换为本文定义的量化整数。 + +```text +CandidateKey = canonical_json({type, quantized_parameters}) +RawBRepKey = snapshot.sha256 +ProgramKey = canonical_json(program tree with CandidateKey or RawBRepKey leaves) +``` + +程序树不做交换律重排;`FoldUnion` 的输入排序在第 15 节定义。 + +## 5. 输入分析 + +### 5.1 初始化顺序 + +1. 从导入 shape 计算 bbox;失败返回 `ANALYSIS_FAILED` 和 `RawBRep`。 +2. 计算 `D`;若 `D <= 0` 返回 `INVALID_INPUT`。 +3. 若 bbox 坐标或 `D` 非 finite,返回 `INVALID_INPUT`。 +4. 计算默认配置或读取覆盖配置。 +5. 若 `ConfigValid == false` 返回 `INVALID_CONFIG`。 +6. 创建独立 analysis context,其 deadline 和调用上限来自 `analysis.*`。 +7. 若实体总数超过 `max_analysis_entities`,返回 `ANALYSIS_FAILED`。 +8. 检查 `FiniteBRep`。 +9. 执行本节其余步骤。 + +### 5.2 Solid 有效性 + +```text +ValidSolid(T) := + kernel_is_valid(T) == OK(true) + and solid_count(T) == 1 + and open_shell_count(T) == 0 + and closed_shell_count(T) >= 1 + and non_manifold_edge_count(T) == 0 + and abs(volume(T)) > eps_volume +``` + +任一测量调用失败,`ValidSolid` 为 `false`。若为 `false`,返回 `RawBRep(T)` 和 `INVALID_SOLID`。v0.2 不隐式 repair。 + +## 6. 有限证据原子 + +### 6.1 SurfaceAtom + +对每张 Face 调用固定参数三角化。调用失败时分析失败。对每个面积严格大于 `0` 的三角形 `(a,b,c)` 生成: + +```text +SurfaceAtom + atom_id + source_face_id + point = (a+b+c)/3 + oriented_normal + area = norm(cross(b-a,c-a))/2 +``` + +`oriented_normal` 按 Face orientation 修正。`atom_id` 按 `(source_face_key, Q3(point), Q(area, eps_area), local_triangle_key)` 排序后编号。`local_triangle_key` 是三角形三个量化顶点排序后的 tuple。 + +记集合为 `SA(T)`: + +\[ +W_S(T)=\sum_{a\in SA(T)}a.area +\] + +调用 kernel 计算真实表面积 `A_kernel`。只有满足: + +\[ +W_S(T)>0 +\] + +且: + +\[ +|W_S(T)-A_{kernel}|\leq\max(\epsilon_{area}, +maxMeshAreaRelativeError\cdot A_{kernel}) +\] + +分析才继续,否则返回 `MESH_COVERAGE_FAILED` 和 `RawBRep`。 + +### 6.2 EdgeAtom + +跳过 kernel 标记为 degenerated 或 seam 的 Edge。对其余 Edge 计算弧长 `L`。失败时跳过该 Edge 并记录 diagnostic。若 `L <= eps_length`,跳过。 + +\[ +n=\max(1,\lceil L/edgeStep\rceil) +\] + +按等弧长区间取中点和单位切向,生成: + +```text +EdgeAtom + atom_id + source_edge_id + point + unit_tangent + length_weight = L/n +``` + +任一中点求值失败时跳过整条 Edge。记集合为 `EA(T)`: + +\[ +W_E(T)=\sum_{e\in EA(T)}e.lengthWeight +\] + +### 6.3 AdjacencyAtom + +对每条非 seam、非 degenerated Edge,取得 incident oriented Face uses。若恰有两个不同 Face,生成: + +```text +AdjacencyAtom + atom_id + source_edge_id + face_a_id = min(face keys) + face_b_id = max(face keys) + weight = edge_length +``` + +Edge 长度失败或小于等于 `eps_length` 时不生成。集合记为 `AA(T)`: + +\[ +W_A(T)=\sum_{r\in AA(T)}r.weight +\] + +## 7. 支撑几何 key + +### 7.1 Plane + +Plane 用单位法向 `n` 和 `h = dot(n,origin)` 表示。按第 4.1 节规范 `n` 符号;若翻转 `n`,同时翻转 `h`。 + +```text +PlaneKey = (Q_d(n), Q(h, eps_linear)) +``` + +### 7.2 Axis + +轴线 `(p,d)` 转换为距原点最近点: + +\[ +q=p-d\cdot dot(d,p) +\] + +规范 `d` 符号后: + +```text +AxisKey = (Q_d(d), Q3(q, eps_linear)) +``` + +### 7.3 解析 support + +```text +CylinderKey = (AxisKey, Q(radius, eps_radius)) +ConeKey = (AxisKey, Q3(apex, eps_linear), Q(semi_angle, eps_angular)) +SphereKey = (Q3(center, eps_linear), Q(radius, eps_radius)) +TorusKey = (AxisKey, Q(major_radius, eps_radius), Q(minor_radius, eps_radius)) +LineKey = (AxisKey) +CircleKey = (AxisKey, Q3(center, eps_linear), Q(radius, eps_radius)) +``` + +### 7.4 NURBS exact key + +v0.2 的 exact key 不试图消除参数反转或重参数化。使用 STEP/kernel 提供的参数化顺序,并将控制点转换到 world coordinates: + +```text +NurbsExactKey = SHA256( + degree_u, + degree_v, + periodic_u, + periodic_v, + Q(knots_u, 1e-12), + Q(knots_v, 1e-12), + multiplicities_u, + multiplicities_v, + Q(weights, 1e-12), + Q3(world_control_points, eps_linear) +) +``` + +### 7.5 NURBS 解析约化 + +```text +RecognizeAnalytic(surface, eps_surface) := + OK(CertifiedAnalytic(PlaneParameters, max_error)) + | OK(CertifiedAnalytic(CylinderParameters, max_error)) + | OK(CertifiedAnalytic(ConeParameters, max_error)) + | OK(CertifiedAnalytic(SphereParameters, max_error)) + | OK(CertifiedAnalytic(TorusParameters, max_error)) + | OK(NONE) + | FAILED + | TIMEOUT + | BUDGET_EXHAUSTED +``` + +只有 backend 能认证整个输入 support surface 在其完整参数域内与返回解析曲面的最大距离不超过 `eps_surface`,并返回 `max_error <= eps_surface` 时才允许 `CertifiedAnalytic`。参数 record 必须包含第 7.1 到 7.3 节 key 所需的全部数值。固定采样只能产生第 12.5 节的近似 evidence,不能把 `NONE` 升级为解析类型。没有认证接口的 backend 必须返回 `OK(NONE)`。 + +## 8. EvidenceGroup 与有限事件 + +### 8.1 Group 代表值 + +每个原始几何 record 为: + +```text +EvidenceRecord + canonical_bucket_key + exact_parameters + weight + source_entity_key + provenance +``` + +`provenance` 是有限的 source entity key 集合。按 `canonical_bucket_key` 分组。对同一 source entity 只保留排序键最小的 record;组权重是剩余 record weight 之和;组 provenance 是 record provenance 的并集。代表 record 按以下键取最小: + +```text +(-weight, source_entity_key, canonical_json(exact_parameters)) +``` + +候选实际参数只取代表 record 的 `exact_parameters`;bucket 中其他 record 只增加 trigger provenance 和 group weight。 + +Face record 的 `source_entity_key` 和单元素 provenance 为 Face key,weight 为 Face area;Edge 对应 Edge key 和 Edge length;Vertex 对应 Vertex key 和 `eps_area`;bbox corner 对应 `bbox.corner.<0..7>` 和 `0`;world axis 对应 `world.x/y/z` 和 `0`。事件的 `exact_parameters` 包含实际 `value` 或 direction。事件 group 输出显式字段: + +```text +EventGroup + bucket_key + representative_value_or_direction + group_weight + provenance +``` + +### 8.2 DirectionEvent + +来源: + +- Plane Face normal,权重为 Face area。 +- Cylinder/Cone/Torus axis,权重为 Face area。 +- LINE Edge direction,权重为 Edge length。 +- world axes,权重为 `0`,source key 固定为 `world.x/y/z`。 + +按 `Q_d` 分组。若组总正权重 `W > 0`: + +\[ +d=normalize(\sum_i w_i d_i) +\] + +所有 `d_i` 先规范符号。若 `W = 0`,代表方向取 source key 最小 record 的 exact direction。按 `(-group_weight, Q_d(d))` 排序,保留前 `max_direction_clusters` 个。 + +### 8.3 PositionEvent + +给定单位方向 `d`,产生 record: + +- 每个 Vertex:`value = dot(d,p)`。 +- 每个 normal 与 `d` 满足 `parallel` 的 Plane:`value = dot(d,origin)`。 +- bbox 八个 corner:`value = dot(d,corner)`。 + +每个来源按第 8.1 节生成完整 EvidenceRecord;`canonical_bucket_key = Q(value,eps_linear)`。按第 8.1 节分组并选择 exact representative value。 + +### 8.4 AxialEvent + +给定轴 `(q,d)`,产生 record: + +- 每个 Vertex:`value = dot(d,p-q)`。 +- 每个与该轴满足 `coaxial` 的 CIRCLE Edge:`value = dot(d,center-q)`,weight 为 Edge length。 +- 每个 normal 与 `d` 满足 `parallel` 的 Plane:`value = dot(d,origin-q)`。 +- bbox corner:`value = dot(d,corner-q)`。 + +每个来源按第 8.1 节生成完整 EvidenceRecord;`canonical_bucket_key = Q(value,eps_linear)`。按第 8.1 节分组。 + +DirectionEvent、PositionEvent、AxialEvent 生成 record 时,每增加一个 record 都递增当前 analysis/task 的 `event_record_count`。达到 `max_event_records` 时停止生成更多 record,并返回 `BUDGET_EXHAUSTED`;初始 analysis 因此降级 RawBRep,residual task 因此只允许 RawExpansion。 + +### 8.5 ExtentPair + +对排序后的不同 event `i < j` 生成: + +```text +ExtentPair + low = min(event_i.value, event_j.value) + high = max(event_i.value, event_j.value) + weight = event_i.group_weight + event_j.group_weight + provenance = union(event_i.provenance, event_j.provenance) +``` + +只保留 `high-low > eps_linear`。按: + +```text +(-weight, -(high-low), Q(low,eps_linear), Q(high,eps_linear)) +``` + +排序,保留前 `max_extent_pairs_per_axis` 个。 + +## 9. 有限候选枚举 + +### 9.0 Primitive 参数 record + +所有方向均为 world-coordinate unit vector,所有 frame 均为右手正交 frame,所有 extent 满足 `high-low > eps_linear`: + +```text +BoxParameters + frame_origin + x_axis + y_axis + z_axis = cross(x_axis,y_axis) + x_low, x_high + y_low, y_high + z_low, z_high + +CylinderParameters + axis_point # 距 world origin 最近点 q + axis_direction + radius > eps_radius + axial_low, axial_high + +ConeParameters + axis_point # 距 world origin 最近点 q + axis_direction + apex_axial_coordinate + semi_angle in (eps_angular, pi/2-eps_angular) + axial_low, axial_high + +SphereParameters + center + radius > eps_radius + +TorusParameters + axis_point + axis_direction + major_radius + minor_radius +``` + +Box 的 world point 为: + +\[ +frameOrigin+x\cdot xAxis+y\cdot yAxis+z\cdot zAxis +\] + +Cylinder/Cone 的 world axial point 为 `axis_point + z*axis_direction`。Cone 在轴坐标 `z` 的半径为: + +\[ +r(z)=|z-apexAxialCoordinate|\tan(semiAngle) +\] + +候选只允许 `r(axial_low)` 或 `r(axial_high)` 至少一个大于 `eps_radius`。这些 record 的字段顺序就是 CandidateKey 的字段顺序。`kernel_construct_primitive` 只接受这些 record。 + +### 9.1 CandidateValid + +```text +CandidateValid(H,T) := + construction returned OK + and kernel_is_valid(H) == OK(true) + and solid_count(H) == 1 + and volume(H) > eps_volume + and BBox(H) intersects expand(BBox(T), eps_linear) + and TriggerEntityCount(H) >= min_trigger_entities +``` + +其中每个 kernel 测量按第 2.3 节执行;任一不是 `OK` 时结果为 `false`。 + +`TriggerEntityCount` 是候选使用的 extent、direction、axis、radius 等 event provenance 中不同 Face/Edge/Vertex key 的数量;`world.*` 和 bbox provenance 不计数。 + +### 9.2 Box + +1. 从 DirectionEvent 枚举无序三元组,三元组内部按 `Q_d` 排序。 +2. 只保留三对方向均满足 `perpendicular` 的三元组。 +3. 令第一个方向为 `x0`,从第二个方向减去在 `x0` 上的投影后规范化为 `y0`,令 `z0 = normalize(cross(x0,y0))`。 +4. 若 `angleAbs(z0, third_direction) > eps_angular`,丢弃。 +5. frame 固定为右手系 `(x0,y0,z0)`。 +6. frame 按三个 direction group weight 总和降序、frame key 升序排序,只保留前 `max_box_frames` 个。 +7. 对每个 frame axis 生成 PositionEvent 和 ExtentPair。 +8. `frame_origin = (0,0,0)`,三个 extent 均为 world origin 在对应 frame axis 上的投影坐标;枚举三个 ExtentPair 集合的笛卡尔积并构造 oriented Box。 +9. 每个 frame 内按 `(-trigger_weight,-volume,CandidateKey)` 排序,保留前 `max_box_candidates_per_frame` 个。 + +`trigger_weight` 是所有 trigger provenance 对应 record weight 的去重和。 + +### 9.3 Cylinder + +候选轴和半径来源: + +- 每个 CylinderKey Face group 的代表参数。 +- 每个 CircleKey group;同一 AxisKey 和 radius bucket 下至少存在两个不同 Circle center axial bucket。 + +对每个 `(axis,radius)` 生成 AxialEvent 和 ExtentPair。Cylinder Face group 只提供 axis/radius,有限 axial extent 一律来自 ExtentPair。每个 pair 构造 `CylinderParameters` 并按 `CandidateValid` 过滤。 + +### 9.4 Cone + +来源一:每个 ConeKey Face group 的代表参数。 + +来源二:同一 AxisKey 的两个 Circle group `(z1,r1)`、`(z2,r2)`,要求: + +\[ +|z_1-z_2|>\epsilon_{linear} +\] + +且: + +\[ +|r_1-r_2|>\epsilon_{radius} +\] + +计算: + +\[ +k=(r_2-r_1)/(z_2-z_1) +\] + +\[ +z_{apex}=z_1-r_1/k +\] + +\[ +semiAngle=atan(|k|) +\] + +Cone Face group 只提供 axis/apex/semi-angle,有限 extent 一律来自 AxialEvent 的 ExtentPair。来源二的 `apex_axial_coordinate = z_apex`。截面半径小于 `eps_radius` 时置为 `0`。构造 `ConeParameters` 后按 `CandidateValid` 过滤。 + +### 9.5 Sphere + +每个 SphereKey Face group 的代表参数生成一个完整 Sphere。v0.2 不从任意圆环拟合 Sphere。 + +### 9.6 Torus + +每个 TorusKey Face group 的代表参数生成一个完整 Torus,且要求: + +```text +major_radius > minor_radius > eps_radius +``` + +v0.2 不生成 horn torus 或 spindle torus,不从曲率样本拟合 Torus。 + +### 9.7 NURBS 证据记录,不是实体候选 + +NURBS 不进入本节的实体候选列表,不参与 `PrimitiveCost`、signed residual 或 Beam Search。分析阶段只生成: + +```text +NurbsEvidenceRecord + face_ids sharing the same NurbsExactKey + NurbsCost +``` + +v0.2 只按 `NurbsExactKey` 分组,不计算一般 NURBS 的 pairwise 等价关系。 + +### 9.8 去重和预算 + +候选只按 `CandidateKey` 去重。相同 key 合并 trigger provenance。v0.2 不调用 Boolean 做候选几何去重。 + +每种类型按第 13.3 节排序后保留前 `max_candidates_per_type` 个。 + +每个 task 在尝试构造候选前递增 `candidate_attempt_count`;在候选专属 kernel 调用前递增 `candidate_kernel_call_count`。达到 `max_candidate_attempts_per_task` 或 `max_candidate_kernel_calls_per_task` 后,停止该 task 的候选枚举,并继续保留其无条件 RawExpansion。该规则同时限制 Circle pair、Cone pair 和 Box extent 笛卡尔积的实际尝试数。 + +## 10. 先验与描述代价 + +### 10.1 PrimitiveCost + +```text +ParameterCodeUnits(Box) = 9 +ParameterCodeUnits(Cylinder) = 8 +ParameterCodeUnits(Cone) = 9 +ParameterCodeUnits(Sphere) = 4 +ParameterCodeUnits(Torus) = 9 +``` + +```text +PrimitiveCost(H) = + configured_type_cost(H.type) + + scalar_parameter_cost * ParameterCodeUnits(H.type) +``` + +`ParameterCodeUnits` 是 v0.2 先验表中的版本化整数,不声称等于参数 record 字段数或最小几何自由度。 + +### 10.2 NurbsCost + +```text +NurbsCost(face) = + type_cost_nurbs_face + + 0.02 * control_point_count + + 0.01 * (knot_u_count + knot_v_count) + + 0.01 * non_unit_weight_count + + 0.02 * degree_u * degree_v + + 0.05 * trim_edge_count +``` + +同一 `NurbsExactKey` group 的代价为一张 support 的 `NurbsCost` 加 `0.1 * (face_count-1)`。 + +### 10.3 RawBRepCost + +```text +RawBRepCost(analysis_or_raw_stats) = + type_cost_raw_brep + + sum(NurbsCost(record) for nurbs_face_records) + + 0.20 * analytic_face_count + + 0.05 * edge_count + + 0.01 * vertex_count +``` + +### 10.4 LeafCost + +```text +LeafCost(primitive) = PrimitiveCost(primitive) +LeafCost(RawBRep(...,raw_stats)) = RawBRepCost(raw_stats) +``` + +这些代价是“常见性”的唯一规范表示。数值越低,先验偏好越高。 + +## 11. 点、法向和 Edge 投影谓词 + +### 11.1 ProjectFace + +```text +ProjectFace(point, face) := kernel_nearest_point_on_trimmed_face(point, face) +``` + +成功值包含: + +```text +distance +projected_point +all_outward_normals_at_projection +``` + +投影位于 smooth interior 时 normal 集合大小为 `1`;位于 Edge/Vertex 时包含所有 incident Face outward normals;无法取得 normal 时调用失败。 + +### 11.2 BoundaryExplainsOnFace + +```text +BoundaryExplainsOnFace(atom, face) := + ProjectFace(atom.point, face) == OK(p) + and p.distance <= eps_surface + and exists n in p.all_outward_normals_at_projection: + angle(atom.oriented_normal, n) <= eps_normal +``` + +### 11.3 ProjectEdge + +```text +EdgeExplainsOnEdge(atom, edge) := + kernel_nearest_point_on_edge(atom.point, edge) == OK(p) + and p.distance <= eps_surface + and angleAbs(atom.unit_tangent, p.unit_tangent) <= eps_normal +``` + +## 12. 支撑面匹配 + +### 12.1 无向 support 匹配 + +`SupportExplains` 故意忽略材料方向,因为同一个 Cylinder support 可以是凸台外壁,也可以在 `Subtract` 后成为孔壁。方向只在候选实际边界和最终 leaf ownership 中检查。 + +Plane: + +\[ +|dot(n,a.point)-h|\leq\epsilon_{surface} +\] + +且 `angleAbs(n,a.oriented_normal) <= eps_normal`。 + +Cylinder:令: + +\[ +v=a.point-q-d\cdot dot(d,a.point-q) +\] + +要求: + +\[ +|\|v\|-r|\leq\epsilon_{surface} +\] + +且 `norm(v) > eps_linear`,并满足 `angleAbs(v/norm(v),a.oriented_normal) <= eps_normal`。 + +Cone、Sphere、Torus:调用无限支撑面的 kernel projection,要求 distance 不超过 `eps_surface`,且 projected normal 与 atom normal 的 `angleAbs` 不超过 `eps_normal`。 + +```text +SupportExplains(H,a) := + exists support s of primitive H: support_match(s,a) +``` + +### 12.2 实际边界匹配 + +```text +BoundaryExplains(H,a) := + exists boundary Face f of H: BoundaryExplainsOnFace(a,f) +``` + +### 12.3 Edge 匹配 + +`NaturalEdges(H)` 是 primitive 构造结果中排除 seam 和 degenerated Edge 后的有限 Edge 集合。 + +```text +EdgeExplains(H,e) := + exists g in NaturalEdges(H): EdgeExplainsOnEdge(e,g) +``` + +### 12.4 FaceMap + +对目标 Face `f` 和候选边界 Face `b`: + +\[ +M(f,b)=\sum_{a\in SA(T),a.sourceFace=f} +a.area\cdot I[BoundaryExplainsOnFace(a,b)] +\] + +`FaceMap(f,H)` 取 `M(f,b)` 最大的候选 Face;最大值小于 `eps_area` 时为 `NONE`。同分时按: + +```text +BoundaryFaceKey = (support_key, + Q3(face_centroid, eps_linear), + Q(face_area, eps_area), + construction_local_face_id) +``` + +取 key 最小者。`construction_local_face_id` 由 primitive builder 按固定面角色分配,不使用 kernel 枚举序号。 + +## 13. 候选解释与局部分数 + +### 13.1 Bitset + +```text +CandidateExplanation + support_surface_atom_bits[a] = SupportExplains(H,a) + boundary_surface_atom_bits[a] = BoundaryExplains(H,a) + edge_atom_bits[e] = EdgeExplains(H,e) + adjacency_atom_bits[r] = AdjacencyExplains(H,r) +``` + +`AdjacencyExplains(H,r)` 当且仅当: + +- `FaceMap(r.face_a,H)` 和 `FaceMap(r.face_b,H)` 均不是 `NONE`。 +- 两个映射结果不同。 +- 两个候选 Face 在候选 B-Rep 中共享至少一条非 degenerated Edge。 + +### 13.2 覆盖率 + +\[ +C_S=\frac{\sum_a a.area\cdot I[supportBits[a]]}{W_S(T)} +\] + +\[ +C_B=\frac{\sum_a a.area\cdot I[boundaryBits[a]]}{W_S(T)} +\] + +若 `W_E(T)>0`: + +\[ +C_E=\frac{\sum_e e.lengthWeight\cdot I[edgeBits[e]]}{W_E(T)} +\] + +否则 `C_E=0` 且局部分数中的 Edge reward 置 `0`。 + +若 `W_A(T)>0`: + +\[ +C_A=\frac{\sum_r r.weight\cdot I[adjacencyBits[r]]}{W_A(T)} +\] + +否则 `C_A=0` 且局部分数中的 adjacency reward 置 `0`。 + +### 13.3 LocalScore + +\[ +LocalScore(H,T)= +rewardSupport\cdot C_S+ +rewardBoundary\cdot C_B+ +rewardEdge'\cdot C_E+ +rewardAdjacency'\cdot C_A- +penaltyLeafCost\cdot PrimitiveCost(H) +\] + +候选只在 `C_S >= min_support_coverage` 时保留。排序键: + +```text +(-LocalScore, -C_S, -C_B, PrimitiveCost, CandidateKey) +``` + +每种类型保留前 `max_candidates_per_type` 个,合并后保留前 `boolean_prefilter_top_m` 个。 + +## 14. Signed residual + +对当前任务 solid `S` 和候选 `H`,连续执行两个 kernel Boolean: + +```text +RplusResult: BooleanShape = regularized_cut(S,H) +RminusResult: BooleanShape = regularized_cut(H,S) +``` + +调用前必须为两个 Boolean 分别成功 reserve budget。任一结果失败时淘汰候选。 + +对 `EMPTY` 使用空 component list;对 `NONEMPTY(shape)` 调用 `components(shape)`。调用失败时淘汰候选。每个非空 component 必须满足 `ValidSolid`。体积小于等于 `eps_volume` 的 component 不进入列表,删除体积累加为 `discardedVolume`。若: + +\[ +discardedVolume>\epsilon_{volume} +\] + +则淘汰候选。 + +剩余 component 分别记为: + +```text +P = sorted components of Rplus +M = sorted components of Rminus +``` + +每个剩余 component 立即执行第 15.1 节 `AnalyzeResidual`。任一分析失败时淘汰候选。排序键为 analysis 中的 `snapshot.sha256`。 + +## 15. 程序模板状态转移 + +### 15.1 ResidualAnalysis + +```text +ResidualAnalysis + shape # 只在当前进程计算期间使用 + snapshot: BRepSnapshot # 可序列化、可重放 + surface_atoms + edge_atoms + adjacency_atoms + support_groups + direction_events + face_count + edge_count + vertex_count + analytic_face_count + nurbs_face_count + raw_stats + volume + surface_area +``` + +```text +AnalyzeResidual(S,config,context) := + ValidSolid(S) + + SnapshotOf(S) + + 第 6 至 8 节全部分析 +``` + +结果是 `KernelResult[ResidualAnalysis]`。所有测量只在这里执行并缓存;候选排序、cost、proxy、task 选择和 StateKey 不允许再次隐式调用 kernel。初始 target 也产生相同结构的 `ResidualAnalysis`。 + +### 15.2 FoldUnion + +统一排序函数: + +```text +UnionOrderKey(primitive leaf) = (0, CandidateKey) +UnionOrderKey(RawBRep leaf) = (1, RawBRepKey) +UnionOrderKey(Slot) = (2, slot_id) +UnionOrderKey(composite Program) = (3, ProgramKey) +``` + +输入按 `UnionOrderKey` 排序。 + +```text +FoldUnion([]) = EMPTY +FoldUnion([x]) = x +FoldUnion([x1,...,xn]) = Union(...Union(Union(x1,x2),x3)...,xn) +``` + +`EMPTY` 只用于构造规则,不是 `Program` 节点。 + +### 15.3 CandidateExpansion + +设 `P` 有 `p` 个 components,`M` 有 `m` 个 components。为每个 component 创建唯一 Slot,ID 为: + +```text +SHA256(parent_slot_id, sign, component_analysis.snapshot.sha256, + same_fingerprint_ordinal) +``` + +其中 `sign` 只用于 ID,取 `PLUS` 或 `MINUS`;ordinal 是排序后同 fingerprint 的从零计数。 + +```text +base = FoldUnion([H] + plus_slots) +replacement = base if m == 0 +replacement = Subtract(base, + FoldUnion(minus_slots)) if m > 0 +``` + +将当前 `Slot` 替换为 `replacement`。每个新 Slot 对应: + +```text +ResidualTask + slot_id + analysis: ResidualAnalysis + depth = parent.depth + 1 +``` + +若新 task 数使状态的 `pending_task_count > max_pending_tasks_per_state`,丢弃该 CandidateExpansion,只保留父 task 的 RawExpansion。 + +新插入 Boolean 节点数: + +```text +union_count = p + max(m-1, 0) +subtract_count = 1 if m > 0 else 0 +``` + +立即增加的 committed cost: + +```text +PrimitiveCost(H) ++ union_count * operation_cost_union ++ subtract_count * operation_cost_subtract +``` + +### 15.4 RawExpansion + +`task.analysis` 已含 `SnapshotOf(task.analysis.shape)` 的结果。将当前 `Slot` 替换为 `RawBRep(task.analysis.snapshot, len(snapshot.bytes), task.analysis.raw_stats)`,删除该 task,立即增加: + +```text +RawBRepCost(task.analysis) +``` + +### 15.5 成本不变量 + +对任一搜索状态: + +```text +committed_cost = + sum(cost of every committed leaf) + + sum(cost of every committed Boolean node) +``` + +当没有 Slot 时: + +```text +committed_cost == ProgramCost(completed_program) +``` + +## 16. Residual 代理代价和展开门槛 + +### 16.1 ResidualProxyCost + +```text +ResidualProxyCost(analysis) = + 1.00 * solid_count + + 0.20 * analytic_face_count + + 0.50 * nurbs_face_count + + 0.02 * edge_count + + 0.01 * vertex_count +``` + +component list 的 proxy 是每个 component proxy 之和。 + +### 16.2 ExpansionEstimate + +\[ +ExpansionEstimate= +PrimitiveCost(H)+ +unionCount\cdot operationCostUnion+ +subtractCount\cdot operationCostSubtract+ +Proxy(P)+Proxy(M)+ +unmatchedSupportPenalty\cdot(1-C_S)+ +unmatchedBoundaryPenalty\cdot(1-C_B)+ +unmatchedEdgePenalty'\cdot(1-C_E) +\] + +若 `W_E=0`,最后一项为 `0`。 + +只有满足: + +\[ +ExpansionEstimate\leq RawBRepCost(currentTask.analysis)+expansionMargin +\] + +的候选才执行第 15.3 节状态转移。 + +## 17. 有界 Beam Search + +### 17.1 SearchState + +```text +SearchState + template + tasks_by_slot_id + committed_cost + estimated_remaining_cost + state_key +``` + +```text +estimated_remaining_cost = + sum(MinLeafCost for each pending task) +``` + +```text +MinLeafCost = min( + type_cost_box + 9*scalar_parameter_cost, + type_cost_cylinder + 8*scalar_parameter_cost, + type_cost_cone + 9*scalar_parameter_cost, + type_cost_sphere + 4*scalar_parameter_cost, + type_cost_torus + 9*scalar_parameter_cost, + type_cost_raw_brep +) +``` + +该值只用于排序;不声称是包含 Boolean 操作的严格数学下界。 + +### 17.2 StateKey + +```text +state_key = SHA256( + canonical serialization of template, + sorted tuples(slot_id, task.depth, task.analysis.snapshot.sha256) +) +``` + +同一 `state_key` 只保留 `committed_cost` 最小者;同分保留 template serialization 字典序最小者。 + +### 17.3 任务选择 + +从 pending task 中按以下键取最小: + +```text +(-RawBRepCost(task.analysis), + -task.analysis.volume, + task.analysis.snapshot.sha256, + slot_id) +``` + +### 17.4 单状态展开 + +```text +if task.depth >= max_task_depth: + children = [RawExpansion(state, task)] +else: + children = every accepted CandidateExpansion + plus one unconditional RawExpansion +``` + +展开一个状态前检查 `expanded_states + 1 <= max_expanded_states`。每个 kernel 调用由 `KernelContext.try_reserve` 单独检查,因此 Boolean 和总调用硬上限不会超出。 + +### 17.5 Frontier + +算法每次从 frontier 取排序键最小的一个状态展开,再将 children 放回 frontier。排序键: + +```text +( + committed_cost + estimated_remaining_cost, + sum(RawBRepCost(task.analysis) for pending tasks), + pending_task_count, + state_key +) +``` + +插入 children、去重、排序后只保留前 `beam_width` 个状态。 + +### 17.6 搜索终止 + +搜索上下文保存唯一 `termination_reason`: + +```text +FRONTIER_EMPTY +STATE_LIMIT +BOOLEAN_LIMIT +KERNEL_CALL_LIMIT +SEARCH_TIMEOUT +ENOUGH_COMPLETED_PROGRAMS +``` + +循环开始及每次 kernel reserve 前更新该值。达到任何预算后不再生成候选。若一个状态已从 frontier 弹出但尚未完成展开,将该未修改状态加入 `fallbackStates`;已经完整生成的 children 保留在 frontier。把 `frontier union fallbackStates` 中每个状态的全部 pending Slot 按 slot ID 升序执行 RawExpansion,得到完整程序。若 frontier 正常变空且 termination reason 尚未设置,则设置为 `FRONTIER_EMPTY`。 + +完成程序按 `(ProgramCost,ProgramKey)` 去重,只保留前 `max_completed_programs_to_validate` 个。 + +### 17.7 显式调用上界 + +若实际展开状态数为 `S <= max_expanded_states`,每状态经过预筛选的候选数为 `P <= boolean_prefilter_top_m`,signed residual 最多调用: + +\[ +2SP +\] + +次 Boolean。实际数量同时受 `max_search_boolean_calls` 限制。候选构造、投影和测量计入 `max_search_total_kernel_calls`。最终程序重放和验证使用独立 validation context,不消费搜索预算。 + +## 18. 完整程序代价 + +```text +ProgramCost(primitive) = PrimitiveCost(primitive) +ProgramCost(RawBRep(...,raw_stats)) = RawBRepCost(raw_stats) +ProgramCost(Union(A,B)) = ProgramCost(A)+ProgramCost(B)+operation_cost_union +ProgramCost(Subtract(A,B)) = ProgramCost(A)+ProgramCost(B)+operation_cost_subtract +``` + +## 19. 完整程序验证 + +### 19.1 重放 + +整个 validation 阶段创建一个共享 `validationContext`;所有完成程序和最终 RawBRep fallback 共用它的 deadline 和调用计数。依次执行按 `(ProgramCost,ProgramKey)` 排序的程序;context 到期或预算耗尽后不再验证后续程序。 + +对一个程序只执行一次 `Render(P)` 并保存 `ReplayResult = R`。失败时: + +```text +ValidProgram(P,T) = false +``` + +### 19.2 Symmetric difference + +成功重放结果为 `R`: + +```text +D1: BooleanShape = regularized_cut(T,R) +D2: BooleanShape = regularized_cut(R,T) +symdiff_volume = volume(D1)+volume(D2) # volume(EMPTY)=0 +``` + +任一调用失败则验证失败。 + +```text +VolumeEquivalent := symdiff_volume <= eps_volume +``` + +### 19.3 双向边界距离和法向 + +用第 6.1 节相同配置生成 `SA(R)`,并对 mesh area 执行相同覆盖检查。对 `SA(T)` 中每个 atom 投影到 `boundary(R)`,对 `SA(R)` 中每个 atom 投影到 `boundary(T)`。 + +每次 projection 必须成功。距离集合 `Dists` 和最小有向法向误差集合 `Angles`: + +```text +distance = nearest boundary distance +angle = min(angle(atom.oriented_normal,n) + for n in all outward normals at projection) +``` + +两个 atom 集合都非空,因此: + +\[ +maxBoundaryDistance=\max(Dists) +\] + +\[ +rmsBoundaryDistance=\sqrt{ +\frac{\sum_i area_i\cdot distance_i^2}{\sum_i area_i}} +\] + +\[ +maxNormalError=\max(Angles) +\] + +```text +BoundaryEquivalent := maxBoundaryDistance <= eps_surface +NormalEquivalent := maxNormalError <= eps_normal +``` + +`rmsBoundaryDistance` 只作为输出指标,不参与 v0.2 acceptance。 + +### 19.4 可计算 topology signature + +v0.2 不用原始 `V-E+F` 计算 Euler characteristic。定义: + +```text +TopologySignature(S) = ( + solid_count(S), + closed_shell_count(S), + open_shell_count(S), + non_manifold_edge_count(S) +) +``` + +任一计数调用失败则验证失败。 + +```text +TopologySignatureMatches := TopologySignature(T) == TopologySignature(R) +``` + +### 19.5 ValidProgram + +```text +ValidProgram(P,T) := + ReplayResult R exists + and kernel_is_valid(R) == OK(true) + and VolumeEquivalent + and BoundaryEquivalent + and NormalEquivalent + and TopologySignatureMatches +``` + +`ValidProgram` 是 acceptance profile 名称,含义仅为本节 finite tests 在给定配置下通过,不表示符号等价或完整 Hausdorff/topology 证明。 + +## 20. SurfaceAtom 归属与有效解释 + +### 20.1 Leaf polarity + +从程序根向叶递归: + +```text +polarity(root) = +1 +polarity(Union.left/right) = polarity(parent) +polarity(Subtract.left) = polarity(parent) +polarity(Subtract.right) = -polarity(parent) +``` + +### 20.2 LeafSupportEvidence + +对 primitive 或 RawBRep 叶节点 `L`,将其支撑面投影法向乘 `polarity(L)`: + +```text +LeafSupportEvidence(L,a) := + exists support face f of L: + projection of a.point to support(f) succeeds + and distance <= eps_surface + and exists normal n at projection: + angle(a.oriented_normal, polarity(L)*n) <= eps_normal +``` + +支撑面使用未裁剪 support,因此 Boolean 改变 trim 后仍可产生解释证据。这个 predicate 不声称该叶节点是原始历史来源,也不证明该 support 在 Boolean 中对最终边界有唯一贡献。 + +### 20.3 唯一 owner + +对每个目标 SurfaceAtom 枚举有限叶节点集合。满足 `LeafSupportEvidence` 的叶节点按: + +```text +( + 0 if primitive else 1, + LeafCost, + -leaf_depth, + leaf_program_key +) +``` + +取最小者。输出字段名为 `support_evidence_owner`;没有匹配者时为 `NONE`。 + +### 20.4 ValidExplanation + +```text +ValidExplanation(P,T) := + ValidProgram(P,T) + and every a in SA(T) has support_evidence_owner != NONE +``` + +本文中的“100% 解释”严格指:第 6.1 节有限 SurfaceAtom 集合全部有 support evidence,并且第 19 节 acceptance profile 通过;不表示连续曲面上的数学全称命题。第 6.1 节同时限制离散 surface area 与 kernel surface area 的差值。 + +## 21. Edge 和 adjacency 节点归属 + +Edge 和 adjacency 可能由 Boolean 生成,因此不强制归属到叶节点。 + +### 21.1 节点重放缓存 + +完整程序验证时缓存每个语法树节点的 `Render(node)`。节点深度从根为 `0` 向下递增。 + +### 21.2 EdgeOwner + +```text +NodeExplainsEdge(node,e) := + exists non-seam, non-degenerated Edge g in Render(node): + EdgeExplainsOnEdge(e,g) +``` + +候选 node 按 `(-node_depth,node_program_key)` 取最小。最终 root 若仍不能解释,则 owner 为 `UNATTRIBUTED`。 + +### 21.3 AdjacencyOwner + +对每个 node 的 rendered shape,用第 12.4 节方式把目标两个 Face 映射到 node result Face: + +```text +NodeExplainsAdjacency(node,r) := + both FaceMap values exist and differ + and the mapped result Faces share a non-degenerated Edge +``` + +按 `(-node_depth,node_program_key)` 选择 owner。失败时为 `UNATTRIBUTED`。 + +Edge 或 adjacency 的 `UNATTRIBUTED` 数量进入输出,但 v0.2 不把它们加入 `ValidExplanation`;几何正确性由第 19 节约束。 + +## 22. 最终选择 + +先对最多 `max_completed_programs_to_validate` 个完成程序执行第 19、20 节。令: + +```text +VP = finite set of programs with ValidExplanation == true +``` + +若 `VP` 非空,排序键为: + +```text +( + ProgramCost, + RawBRep_leaf_count, + total_NURBS_face_count_inside_RawBRep, + Boolean_node_count, + ProgramKey +) +``` + +返回最小者,status 为 `VALID_EXPLANATION`。 + +若 `VP` 为空,验证 `RawBRep(T)`。若其 `ValidExplanation` 为 true,返回它,status 为 `RAW_BREP_ONLY`;否则返回 `FINAL_VALIDATION_FAILED`。 + +## 23. 主算法伪代码 + +```python +def reverse_step(target, overrides=None): + bbox = kernel_bbox(target) + if not bbox.ok: + return snapshot_or_failure(target, "ANALYSIS_FAILED") + if not bbox.finite or bbox.diagonal <= 0: + return snapshot_or_failure(target, "INVALID_INPUT") + + config = resolve_config(target, bbox, overrides) + if not config_valid(config): + return failure("INVALID_CONFIG") + + analysis_ctx = KernelContext.for_analysis(config) + analysis = analyze_residual(target, config, analysis_ctx) + if not analysis.ok: + return snapshot_or_failure(target, analysis.status) + + search_ctx = KernelContext.for_search(config) + initial_slot = Slot(id=sha256("root")) + initial_task = ResidualTask( + slot_id=initial_slot.id, + analysis=analysis.value, + depth=0, + ) + frontier = [SearchState( + template=initial_slot, + tasks={initial_slot.id: initial_task}, + committed_cost=0, + )] + completed = [] + fallback_states = [] + expanded_states = 0 + + while frontier and not search_ctx.stopped: + state = pop_min(frontier, key=frontier_sort_key) + + if not state.tasks: + completed.append(state.template) + if len(completed) >= config.search.max_completed_programs_to_validate: + search_ctx.stop("ENOUGH_COMPLETED_PROGRAMS") + continue + + if expanded_states >= config.search.max_expanded_states: + search_ctx.stop("STATE_LIMIT") + fallback_states.append(state) + break + + task = select_task(state.tasks) + expanded_states += 1 + children = [] + + if task.depth < config.search.max_task_depth: + candidates = enumerate_candidates(task.analysis, config, search_ctx) + explanations = compute_explanations(candidates, task.analysis, config, search_ctx) + ranked = rank_and_limit(explanations, config) + + for candidate, explanation in ranked: + residuals = signed_residuals(task.analysis, candidate, config, search_ctx) + if not residuals.ok: + if search_ctx.stopped: + break + continue + if expansion_estimate(candidate, explanation, residuals, config) \ + > raw_brep_cost(task.analysis, config) + config.score.expansion_margin: + continue + children.append(candidate_expansion( + state, task, candidate, residuals, config + )) + + if search_ctx.stopped: + fallback_states.append(state) + break + + children.append(raw_expansion(state, task, config)) + frontier = deduplicate(frontier + children) + frontier.sort(key=frontier_sort_key) + frontier = frontier[:config.search.beam_width] + + if not search_ctx.termination_reason: + search_ctx.stop("FRONTIER_EMPTY") + + completed.extend(materialize_all_slots_as_raw( + deduplicate(frontier + fallback_states), config + )) + completed = deduplicate_and_sort_programs(completed, config) + completed = completed[:config.search.max_completed_programs_to_validate] + + valid = [] + validation_ctx = KernelContext.for_validation(config) + for program in completed: + if validation_ctx.stopped: + break + metrics = validate_program_and_assign_atoms( + program, target, analysis, config, validation_ctx + ) + if metrics.valid_explanation: + valid.append((final_program_sort_key(program, metrics), program, metrics)) + + if valid: + valid.sort(key=lambda x: x[0]) + return result(valid[0], search_ctx.termination_reason) + + return validate_and_return_raw_brep( + target, analysis, config, validation_ctx + ) +``` + +`snapshot_or_failure` 创建一个独立、只允许一次 `SnapshotOf` 的 emergency context;成功时返回 `RawBRep`,失败时 `program = null`。这条 emergency 调用及其 deadline 单独写入结果,不能复用已经耗尽的阶段 context。 + +## 24. 状态与诊断枚举 + +```text +INVALID_CONFIG +INVALID_INPUT +ANALYSIS_FAILED +INVALID_SOLID +MESH_COVERAGE_FAILED +CANDIDATE_CONSTRUCTION_FAILED +BOOLEAN_FAILED +BOOLEAN_TIMEOUT +NUMERICALLY_UNSTABLE +STATE_LIMIT +BOOLEAN_LIMIT +KERNEL_CALL_LIMIT +SEARCH_TIMEOUT +FRONTIER_EMPTY +ENOUGH_COMPLETED_PROGRAMS +RAW_BREP_ONLY +FINAL_VALIDATION_FAILED +VALID_EXPLANATION +``` + +自由文本 diagnostic 不参与控制流。 + +## 25. 输出结构 + +```text +ExplanationResult + status + search_termination_reason + input_fingerprint + config + program + program_key + program_cost + candidate_records + surface_atom_owners + edge_atom_owners + adjacency_atom_owners + validation_metrics + kernel_call_counts + diagnostics +``` + +`validation_metrics` 至少包含: + +```text +symdiff_volume +max_boundary_distance +rms_boundary_distance +max_normal_error +topology_signature_input +topology_signature_result +surface_atom_count +owned_surface_atom_count +unattributed_edge_atom_count +unattributed_adjacency_atom_count +valid_program +valid_explanation +``` + +## 26. MVP 模块 + +实现模块和唯一输入输出如下: + +| 模块 | 输入 | 输出 | +| --- | --- | --- | +| `BRepAnalyzer` | B-Rep、Config、KernelContext | descriptors、SA、EA、AA、support groups | +| `CandidateEnumerator` | analysis、Config | finite primitive candidates | +| `CandidateScorer` | candidate、current residual analysis | bitsets、coverage、LocalScore | +| `ResidualEngine` | residual、candidate、KernelContext | sorted Rplus/Rminus components | +| `ProgramSearch` | root residual、Config | finite completed programs | +| `ProgramValidator` | program、target atoms、Config | validation metrics、atom owners | + +## 27. 测试要求 + +### 27.1 确定性 + +对相同 STEP bytes、backend version 和 config 重复运行两次,以下 bytes 必须相同: + +- atom records canonical JSON。 +- event records canonical JSON。 +- CandidateKey 序列。 +- ProgramKey。 +- 非 timeout 情况下的最终 ExplanationResult canonical JSON;运行时间字段除外。 + +### 27.2 单 primitive + +对 Box、Cylinder、Cone、Sphere 和 ring Torus fixture: + +```text +ValidExplanation(expected_primitive_program, fixture) == true +ProgramCost(expected_primitive_program) < RawBRepCost(fixture) +``` + +### 27.3 Boolean + +对 Box 减 Cylinder、Box 加 Cylinder、同轴 Cylinder 相减 fixture: + +- 返回 `VALID_EXPLANATION`。 +- `symdiff_volume <= eps_volume`。 +- `max_boundary_distance <= eps_surface`。 +- `max_normal_error <= eps_normal`。 +- `owned_surface_atom_count == surface_atom_count`。 + +### 27.4 Face split + +对几何相同但 Face split 不同的两个 STEP: + +- 最优程序的 `ProgramKey` 相同。 +- 最优程序的 `ProgramCost` 相同。 +- 两者各自 `ValidExplanation == true`。 + +不比较 atom 数量,因为三角化可能跟随输入 Face split 变化。 + +### 27.5 NURBS + +- 可约化 NURBS Cylinder:`RecognizeAnalytic` 返回完整 Cylinder 参数且通过采样验证。 +- exact-key 相同的 NURBS Face:产生同一个 exact group。 +- pairwise sampled match 不取传递闭包。 +- 全自由曲面 closed solid:在预算内返回 `RawBRep` 或包含 RawBRep residual 的有效程序。 + +### 27.6 硬预算 + +每次测试断言: + +```text +search_boolean_calls <= max_search_boolean_calls +search_total_kernel_calls <= max_search_total_kernel_calls +expanded_states <= max_expanded_states +each_task_depth <= max_task_depth +validation_boolean_calls <= max_validation_boolean_calls +validation_total_kernel_calls <= max_validation_total_kernel_calls +``` + +## 28. 算法总流程 + +```text +STEP B-Rep + | + v +解析并验证 Config + | + v +ValidSolid + 固定参数三角化/边离散 + | + v +构造有限 SA / EA / AA 和 support groups + | + v +从有限 Direction/Position/Axial/Extent 事件枚举 primitive candidates + | + v +计算 explanation bitsets、coverage 和 LocalScore + | + v +Top-M 候选计算 R+ 与 R- + | + v +ExpansionEstimate 门槛 + | + v +固定模板替换规则 + 有界 Beam Search + | \ + | +--> 每个 Slot 无条件可替换为 RawBRep + v +有限个完成 Program + | + v +Render + symmetric difference + 双向边界/法向 + topology signature + | + v +SurfaceAtom polarity owner + | + v +在 ValidExplanation 集合中最小化 ProgramCost +``` + +## 29. 结论 + +v0.2 将原始设想翻译为以下有限优化问题。给定输入 `T`、固定配置 `C` 和搜索实际枚举到的有限程序集合 `Programs(T,C)`: + +\[ +P^*=argmin_{P\in Programs(T,C)} ProgramCost(P) +\] + +约束: + +\[ +ValidExplanation(P,T)=true +\] + +基础单元常见性由 `PrimitiveCost`、`NurbsCost` 和 `RawBRepCost` 表示;解释多少边和面由 `CandidateExplanation` bitset 及加权覆盖率表示;“基础几何元素 + 剩余部分”由两个 regularized cut 和第 15.3 节固定程序模板表示;递归由有限事件、候选上限、Beam Width、任务深度、状态数、kernel 调用数、Boolean 调用数和 deadline 限制。 + +因此该规范不需要枚举任意空间中的全部几何体,也不需要对一般 NURBS 求不可控的全局拟合;在所有搜索路径失败或预算耗尽时,`RawBRep` 分支给出有限、可重放的兜底程序。 diff --git a/SimpleCADAPI/docs/api/Dimension.md b/SimpleCADAPI/docs/api/Dimension.md new file mode 100644 index 0000000..cce883d --- /dev/null +++ b/SimpleCADAPI/docs/api/Dimension.md @@ -0,0 +1,17 @@ +# Dimension + +## Class Definition + +```python +class Dimension(length: int = 0, angle: int = 0) +``` + +*Source: units.py* + +## Import Surface + +- top-level: `from simplecadapi import Dimension` + +## Description + +Physical dimension represented by integer length and angle exponents. diff --git a/SimpleCADAPI/docs/api/DimensionTolerance.md b/SimpleCADAPI/docs/api/DimensionTolerance.md new file mode 100644 index 0000000..aac851f --- /dev/null +++ b/SimpleCADAPI/docs/api/DimensionTolerance.md @@ -0,0 +1,20 @@ +# DimensionTolerance + +## Class Definition + +```python +class DimensionTolerance(lower_deviation: float, upper_deviation: float) +``` + +*Source: expr.py* + +## Import Surface + +- top-level: `from simplecadapi import DimensionTolerance` + +## Description + +Permitted lower and upper deviations from a nominal dimension. + +Deviations are signed: the lower deviation must be less than or equal to +zero and the upper deviation must be greater than or equal to zero. diff --git a/SimpleCADAPI/docs/api/FreeCADScriptTranslator.md b/SimpleCADAPI/docs/api/FreeCADScriptTranslator.md index c74dc25..b0db7ce 100644 --- a/SimpleCADAPI/docs/api/FreeCADScriptTranslator.md +++ b/SimpleCADAPI/docs/api/FreeCADScriptTranslator.md @@ -21,6 +21,7 @@ Current design goals: - Translate only from the canonical low-level `graph` IR - Preserve node metadata and graph lineage as FreeCAD custom properties - Preserve `expression_graph` as explicit translator metadata +- Preserve dimension tolerances and tolerance-chain requirements as metadata - Preserve exported assembly constraints as document metadata objects - Keep assembly metadata from the full model payload alongside the IR-driven geometry translation diff --git a/SimpleCADAPI/docs/api/GraphSession.md b/SimpleCADAPI/docs/api/GraphSession.md index c79981c..ec15c12 100644 --- a/SimpleCADAPI/docs/api/GraphSession.md +++ b/SimpleCADAPI/docs/api/GraphSession.md @@ -16,16 +16,15 @@ class GraphSession(graph_id: Optional[str] = None) Context manager that records CAD operations into a DAG. -with GraphSession() as session: -n1 = record_operation( -"make_line_redge", {"start": (0, 0, 0), "end": (1, 0, 0)} -) -n2 = record_operation( -"make_line_redge", {"start": (1, 0, 0), "end": (1, 1, 0)} -) -record_operation( -"make_wire_from_edges_rwire", {"edge_count": 2}, inputs=[n1, n2] -) +For new replayable model entry points, prefer `@scad.model`, which owns the +session and returns a `ModelResult`. Use `GraphSession` directly when composing +or testing lower-level graph workflows. `result_node_ids` reports the graph +nodes selected by `capture_result`: -# Access the graph after the session -print(session.graph.topological_order()) +```python +with GraphSession(graph_id="demo") as session: + body = make_box_rsolid(width=10.0, height=6.0, depth=2.0) + session.capture_result(value=body) + +print(session.result_node_ids) +``` diff --git a/SimpleCADAPI/docs/api/ModelResult.md b/SimpleCADAPI/docs/api/ModelResult.md new file mode 100644 index 0000000..116607e --- /dev/null +++ b/SimpleCADAPI/docs/api/ModelResult.md @@ -0,0 +1,64 @@ +# ModelResult + +## Class Definition + +```python +@dataclass(frozen=True) +class ModelResult: + value: Any + session: GraphSession + result_node_ids: Tuple[str, ...] + model_json: str + session_json: str + artifact_paths: Mapping[str, Path] = field(default_factory=dict) +``` + +*Source: graph.py* + +## Import Surface + +- top-level: `from simplecadapi import ModelResult` + +## Description + +Immutable result returned by a function decorated with `@scad.model`. +It keeps the ordinary Python return value together with the owned session and +the durable graph artifacts produced for that model invocation. + +## Attributes + +- `value`: The value returned by the model function. It may be a shape, a + product assembly, or a tuple containing application-level reports and a + captured preview. +- `session`: The completed `GraphSession` that recorded the model. +- `result_node_ids`: Explicitly captured graph node ids used as model outputs. +- `model_json`: Canonical low-level operation graph JSON containing the captured + result leaves. +- `session_json`: Session JSON containing the complete session state. +- `artifact_paths`: Files written by automatic export. It contains the `scene` + key when captured geometry or product values produced a Scene ZIP. + +## Artifact Export + +```python +result = build_model() +exported = result.export_artifacts(output_dir="examples/out/bracket") +print(exported.artifact_paths["scene"]) +``` + +The same export runs automatically when `@scad.model(export_dir=...)` is used. +Only values explicitly passed to `capture_result(...)` are considered final +geometry/product outputs. Automatic export writes one self-contained +`.scene.zip` containing the model JSON, mapped Python sources, and +render/selection assets; it does not write adjacent model/session JSON, STEP, +STL, or FCStd files. Without an export directory, no files are written. + +## Replay + +```python +result = build_model() +rebuilt = result.replay() +``` + +`replay()` is equivalent to replaying `result.model_json`. Pass +`strict=False` only when intentionally relaxing replay validation. diff --git a/SimpleCADAPI/docs/api/README.md b/SimpleCADAPI/docs/api/README.md index 42dbe26..1980ea1 100644 --- a/SimpleCADAPI/docs/api/README.md +++ b/SimpleCADAPI/docs/api/README.md @@ -65,14 +65,38 @@ This index includes generated docs for the public SimpleCAD API surface, includi - [loft_rsolid](loft_rsolid.md) *(from operations.py)* `top-level` - [revolve_rsolid](revolve_rsolid.md) *(from operations.py)* `top-level` - [sweep_rsolid](sweep_rsolid.md) *(from operations.py)* `top-level` +- [twisted_sweep_rsolid](twisted_sweep_rsolid.md) *(from operations.py)* `top-level` ## Tagging and Selection - [apply_tag](apply_tag.md) *(from operations.py)* `top-level` +- [apply_tag_rselection](apply_tag_rselection.md) *(from operations.py)* `top-level` +- [explain_tag](explain_tag.md) *(from operations.py)* `top-level` - [list_tags](list_tags.md) *(from operations.py)* `top-level` - [select_edges_by_tag](select_edges_by_tag.md) *(from operations.py)* `top-level` - [select_faces_by_tag](select_faces_by_tag.md) *(from operations.py)* `top-level` +Creation-time topology-identity tags are supported by profile constructors and +native feature primitives through `tag_prefix`. QL selectors also support `shared_boundary(...)`, +`intersection(...)`, `incident_to(...)`, `incident_face_count(...)`, and +`solids()` for relation-aware Edge selection. + +## Unified Tag Contract + +Topology identity and user semantics share one public tag model. Every binding +is inspected with `list_tags(...)` and `explain_tag(...)` and queried with +`ql.tag(...)`; one topology object may carry several tags for different uses. + +`tag_prefix="housing"` creates topology-identity tags such as +`housing.face.top` and `housing.solid`. These bindings carry `topology_name` +evidence and project only when kernel history proves exact correspondence. + +Role parameters such as `top_face_tag`, `side_faces_tag`, and +`generated_faces_tag`, plus `result_tag`, create tags whose evidence identifies +the kernel-proven role or result. Their tag text alone does not establish +topology identity. There is one public tag parameter per target role, not a +generic role-to-tag mapping. + ## Boolean Operations - [cut_rsolid](cut_rsolid.md) *(from operations.py)* `top-level` @@ -98,6 +122,10 @@ This index includes generated docs for the public SimpleCAD API surface, includi ## Modeling Graph and Replay - [GraphSession](GraphSession.md) *(from graph.py)* `top-level` +- [ModelResult](ModelResult.md) *(from graph.py)* `top-level` +- [capture_result](capture_result.md) *(from graph.py)* `top-level` +- [model](model.md) *(from graph.py)* `top-level` +- [requires_session](requires_session.md) *(from graph.py)* `top-level` - [export_graph_json](export_graph_json.md) *(from serializer.py)* `top-level` - [export_model_json](export_model_json.md) *(from serializer.py)* `top-level` - [export_session_json](export_session_json.md) *(from serializer.py)* `top-level` @@ -111,12 +139,34 @@ This index includes generated docs for the public SimpleCAD API surface, includi ## Expressions and Parameters - [Const](Const.md) *(from expr.py)* `top-level` +- [DimensionTolerance](DimensionTolerance.md) *(from expr.py)* `top-level` - [Expr](Expr.md) *(from expr.py)* `top-level` - [ExpressionGraph](ExpressionGraph.md) *(from expr.py)* `top-level` +- [ToleranceAnalysis](ToleranceAnalysis.md) *(from tolerance.py)* `top-level` +- [ToleranceAnalysisError](ToleranceAnalysisError.md) *(from tolerance.py)* `top-level` +- [ToleranceCheck](ToleranceCheck.md) *(from tolerance.py)* `top-level` +- [ToleranceContribution](ToleranceContribution.md) *(from tolerance.py)* `top-level` +- [ToleranceGraph](ToleranceGraph.md) *(from tolerance.py)* `top-level` +- [ToleranceReport](ToleranceReport.md) *(from tolerance.py)* `top-level` +- [ToleranceRequirement](ToleranceRequirement.md) *(from tolerance.py)* `top-level` +- [ToleranceValidationError](ToleranceValidationError.md) *(from tolerance.py)* `top-level` - [Var](Var.md) *(from expr.py)* `top-level` +- [analyze_tolerance](analyze_tolerance.md) *(from tolerance.py)* `top-level` +- [check_tolerance](check_tolerance.md) *(from tolerance.py)* `top-level` - [const](const_function.md) *(from expr.py)* `top-level` - [var](var_function.md) *(from expr.py)* `top-level` +## Physical Units + +- [Dimension](Dimension.md) *(from units.py)* `top-level` +- [Unit](Unit.md) *(from units.py)* `top-level` +- [UnitValidationError](UnitValidationError.md) *(from units.py)* `top-level` +- [canonical_unit_for_dimension](canonical_unit_for_dimension.md) *(from units.py)* `top-level` +- [convert_value](convert_value.md) *(from units.py)* `top-level` +- [expression_uses_units](expression_uses_units.md) *(from units.py)* `top-level` +- [get_unit](get_unit.md) *(from units.py)* `top-level` +- [infer_dimension](infer_dimension.md) *(from units.py)* `top-level` + ## Types and Errors - [SimpleCADError](SimpleCADError.md) *(from errors.py)* `top-level` @@ -207,11 +257,15 @@ This index includes generated docs for the public SimpleCAD API surface, includi - [meta](meta.md) *(from ql.py)* `submodule:ql` - [not_](not_.md) *(from ql.py)* `submodule:ql` - [or_](or_.md) *(from ql.py)* `submodule:ql` +- [output_role](output_role.md) *(from ql.py)* `submodule:ql` - [place_component_rassembly](place_component_rassembly.md) *(from operations.py)* `top-level` - [radial_pattern_rsolidlist](radial_pattern_rsolidlist.md) *(from operations.py)* `top-level` - [render_screenshot_rpath](render_screenshot_rpath.md) *(from operations.py)* `top-level` - [select](select.md) *(from ql.py)* `submodule:ql` +- [solids](solids.md) *(from ql.py)* `submodule:ql` - [solve_assembly_constraints_rassembly](solve_assembly_constraints_rassembly.md) *(from operations.py)* `top-level` +- [source_binding](source_binding.md) *(from ql.py)* `submodule:ql` +- [source_topology](source_topology.md) *(from ql.py)* `submodule:ql` - [tag](tag.md) *(from ql.py)* `submodule:ql` - [unground_component_rassembly](unground_component_rassembly.md) *(from operations.py)* `top-level` - [value](value.md) *(from ql.py)* `submodule:ql` diff --git a/SimpleCADAPI/docs/api/Sketch.md b/SimpleCADAPI/docs/api/Sketch.md index e7b4d37..c7c022e 100644 --- a/SimpleCADAPI/docs/api/Sketch.md +++ b/SimpleCADAPI/docs/api/Sketch.md @@ -22,3 +22,12 @@ Use `make_sketch_rsketch(...)`, `add_point_rsketch(...)`, profiles. Public sketch construction APIs are functional and return an updated `Sketch` document. The legacy `curves` constructor remains only for reading already-built wire/edge containers. + +Entity IDs are creation-time local identifiers, not geometry guesses. During +`make_wire_from_sketch_rwire(...)` or `make_face_from_sketch_rface(...)`, the +promotion map binds each ordered profile entity to exactly one generated Edge. +The canonical topology-identity tags are +`sketch..entity.` and +`sketch..profile.`, with `topology_name` evidence. +Downstream features can project those tags only when their kernel history +proves one-source/one-target correspondence. diff --git a/SimpleCADAPI/docs/api/ToleranceAnalysis.md b/SimpleCADAPI/docs/api/ToleranceAnalysis.md new file mode 100644 index 0000000..3483c91 --- /dev/null +++ b/SimpleCADAPI/docs/api/ToleranceAnalysis.md @@ -0,0 +1,17 @@ +# ToleranceAnalysis + +## Class Definition + +```python +class ToleranceAnalysis(target_expr_id: str, method: ToleranceMethod, nominal: float, lower_bound: float, upper_bound: float, lower_deviation: float, upper_deviation: float, dimension: Dimension | None = None, unit: Unit | None = None, contributions: Tuple[ToleranceContribution, ...] = ()) +``` + +*Source: tolerance.py* + +## Import Surface + +- top-level: `from simplecadapi import ToleranceAnalysis` + +## Description + +Nominal value and propagated limits for an expression. diff --git a/SimpleCADAPI/docs/api/ToleranceAnalysisError.md b/SimpleCADAPI/docs/api/ToleranceAnalysisError.md new file mode 100644 index 0000000..edbdb0b --- /dev/null +++ b/SimpleCADAPI/docs/api/ToleranceAnalysisError.md @@ -0,0 +1,17 @@ +# ToleranceAnalysisError + +## Class Definition + +```python +class ToleranceAnalysisError +``` + +*Source: tolerance.py* + +## Import Surface + +- top-level: `from simplecadapi import ToleranceAnalysisError` + +## Description + +Raised when a tolerance chain cannot be propagated safely. diff --git a/SimpleCADAPI/docs/api/ToleranceCheck.md b/SimpleCADAPI/docs/api/ToleranceCheck.md new file mode 100644 index 0000000..088507c --- /dev/null +++ b/SimpleCADAPI/docs/api/ToleranceCheck.md @@ -0,0 +1,17 @@ +# ToleranceCheck + +## Class Definition + +```python +class ToleranceCheck(requirement: ToleranceRequirement, analysis: ToleranceAnalysis, passed: bool, lower_margin: float, upper_margin: float) +``` + +*Source: tolerance.py* + +## Import Surface + +- top-level: `from simplecadapi import ToleranceCheck` + +## Description + +Validation result for one tolerance requirement. diff --git a/SimpleCADAPI/docs/api/ToleranceContribution.md b/SimpleCADAPI/docs/api/ToleranceContribution.md new file mode 100644 index 0000000..142a333 --- /dev/null +++ b/SimpleCADAPI/docs/api/ToleranceContribution.md @@ -0,0 +1,17 @@ +# ToleranceContribution + +## Class Definition + +```python +class ToleranceContribution(variable_expr_id: str, variable_name: str, nominal: float, source_tolerance: DimensionTolerance, sensitivity: float | None, lower_deviation: float, upper_deviation: float, source_unit: Unit | None = None) +``` + +*Source: tolerance.py* + +## Import Surface + +- top-level: `from simplecadapi import ToleranceContribution` + +## Description + +One source dimension's propagated contribution to a result. diff --git a/SimpleCADAPI/docs/api/ToleranceGraph.md b/SimpleCADAPI/docs/api/ToleranceGraph.md new file mode 100644 index 0000000..47033d4 --- /dev/null +++ b/SimpleCADAPI/docs/api/ToleranceGraph.md @@ -0,0 +1,17 @@ +# ToleranceGraph + +## Class Definition + +```python +class ToleranceGraph(expression_graph: ExpressionGraph) +``` + +*Source: tolerance.py* + +## Import Surface + +- top-level: `from simplecadapi import ToleranceGraph` + +## Description + +Tolerance requirements attached to one expression graph. diff --git a/SimpleCADAPI/docs/api/ToleranceReport.md b/SimpleCADAPI/docs/api/ToleranceReport.md new file mode 100644 index 0000000..c57407e --- /dev/null +++ b/SimpleCADAPI/docs/api/ToleranceReport.md @@ -0,0 +1,17 @@ +# ToleranceReport + +## Class Definition + +```python +class ToleranceReport(checks: Tuple[ToleranceCheck, ...] = ()) +``` + +*Source: tolerance.py* + +## Import Surface + +- top-level: `from simplecadapi import ToleranceReport` + +## Description + +Validation report for every requirement in a tolerance graph. diff --git a/SimpleCADAPI/docs/api/ToleranceRequirement.md b/SimpleCADAPI/docs/api/ToleranceRequirement.md new file mode 100644 index 0000000..094441f --- /dev/null +++ b/SimpleCADAPI/docs/api/ToleranceRequirement.md @@ -0,0 +1,17 @@ +# ToleranceRequirement + +## Class Definition + +```python +class ToleranceRequirement(requirement_id: str, target_expr_id: str, tolerance: DimensionTolerance, method: ToleranceMethod = 'worst_case', name: str = '', tolerance_unit: Unit | None = None, target_dimension: Dimension | None = None) +``` + +*Source: tolerance.py* + +## Import Surface + +- top-level: `from simplecadapi import ToleranceRequirement` + +## Description + +Permitted result deviations for one derived dimension. diff --git a/SimpleCADAPI/docs/api/ToleranceValidationError.md b/SimpleCADAPI/docs/api/ToleranceValidationError.md new file mode 100644 index 0000000..092283b --- /dev/null +++ b/SimpleCADAPI/docs/api/ToleranceValidationError.md @@ -0,0 +1,17 @@ +# ToleranceValidationError + +## Class Definition + +```python +class ToleranceValidationError(report: 'ToleranceReport') +``` + +*Source: tolerance.py* + +## Import Surface + +- top-level: `from simplecadapi import ToleranceValidationError` + +## Description + +Raised when one or more declared tolerance requirements fail. diff --git a/SimpleCADAPI/docs/api/Unit.md b/SimpleCADAPI/docs/api/Unit.md new file mode 100644 index 0000000..6516627 --- /dev/null +++ b/SimpleCADAPI/docs/api/Unit.md @@ -0,0 +1,20 @@ +# Unit + +## Class Definition + +```python +class Unit(symbol: str, dimension: Dimension, scale_to_canonical: float) +``` + +*Source: units.py* + +## Import Surface + +- top-level: `from simplecadapi import Unit` + +## Description + +Named unit with a scale to SimpleCAD's canonical numeric units. + +Custom units are supported and serialize their symbol, dimension, and scale. +Registered built-in units serialize as compact symbols. diff --git a/SimpleCADAPI/docs/api/UnitValidationError.md b/SimpleCADAPI/docs/api/UnitValidationError.md new file mode 100644 index 0000000..6f2e25a --- /dev/null +++ b/SimpleCADAPI/docs/api/UnitValidationError.md @@ -0,0 +1,17 @@ +# UnitValidationError + +## Class Definition + +```python +class UnitValidationError +``` + +*Source: units.py* + +## Import Surface + +- top-level: `from simplecadapi import UnitValidationError` + +## Description + +Raised when units or expression dimensions are physically inconsistent. diff --git a/SimpleCADAPI/docs/api/Var.md b/SimpleCADAPI/docs/api/Var.md index 114d7a8..35221e2 100644 --- a/SimpleCADAPI/docs/api/Var.md +++ b/SimpleCADAPI/docs/api/Var.md @@ -3,7 +3,7 @@ ## Class Definition ```python -class Var(name: str, default: float, comment: str | None = None, expr_id: str = field(default_factory=lambda : _make_expr_id('var'))) +class Var(name: str, default: float, comment: str | None = None, expr_id: str = field(default_factory=lambda : _make_expr_id('var')), tolerance: DimensionTolerance | None = None, unit: Unit | None = None, tolerance_unit: Unit | None = None) ``` *Source: expr.py* @@ -14,4 +14,8 @@ class Var(name: str, default: float, comment: str | None = None, expr_id: str = ## Description -Named scalar parameter with a default fallback value. +Named scalar parameter with optional physical-unit and tolerance intent. + +``default`` and ``tolerance`` remain in their declared units. Evaluation, +geometry parameters, and tolerance propagation convert them to SimpleCAD's +canonical CAD units: millimeters for length and degrees for angle. diff --git a/SimpleCADAPI/docs/api/add_line_rsketch.md b/SimpleCADAPI/docs/api/add_line_rsketch.md index d65ddcd..c94254c 100644 --- a/SimpleCADAPI/docs/api/add_line_rsketch.md +++ b/SimpleCADAPI/docs/api/add_line_rsketch.md @@ -14,4 +14,6 @@ def add_line_rsketch(sketch: Sketch, entity_id: str, start: Union[SketchRef, str ## Description -Add a named line entity and return an updated sketch document. +Add an identified line entity and return an updated sketch document. During +profile promotion, `entity_id` becomes the local segment of the canonical +topology-identity tag for the corresponding profile Edge. diff --git a/SimpleCADAPI/docs/api/analyze_tolerance.md b/SimpleCADAPI/docs/api/analyze_tolerance.md new file mode 100644 index 0000000..863d25a --- /dev/null +++ b/SimpleCADAPI/docs/api/analyze_tolerance.md @@ -0,0 +1,27 @@ +# analyze_tolerance + +## API Definition + +```python +def analyze_tolerance(value: ScalarLike, *, method: ToleranceMethod = 'worst_case') -> ToleranceAnalysis +``` + +*Source: tolerance.py* + +## Import Surface + +- top-level: `from simplecadapi import analyze_tolerance` + +## Description + +Propagate source manufacturing tolerances through a scalar expression. + +``worst_case`` returns guaranteed interval bounds. Affine chains are +dependency-aware, so repeated variables such as ``x - x`` cancel exactly; +nonlinear chains use conservative interval arithmetic. ``rss`` performs a +first-order root-sum-square calculation using analytic sensitivities. + +Unit-aware variables are converted to canonical CAD units before +propagation. The returned analysis reports the inferred physical dimension +and canonical result unit. Every variable in the expression must declare a +source tolerance. diff --git a/SimpleCADAPI/docs/api/apply_tag.md b/SimpleCADAPI/docs/api/apply_tag.md index 9929ea4..084c45b 100644 --- a/SimpleCADAPI/docs/api/apply_tag.md +++ b/SimpleCADAPI/docs/api/apply_tag.md @@ -14,10 +14,13 @@ def apply_tag(shape: AnyShape, tag: str) -> AnyShape ## Description -Attach a normalized tag to a shape using the standard propagation policy. +Attach a normalized local user tag to a shape. Tags must already be normalized lowercase tokens such as -``role.mounting_surface`` or ``group.fasteners``. Propagation is intentionally -not configurable from the public API; the default tag policy propagates -semantic role/anchor/group tags downward and keeps topology-specific tags -local. +`role.mounting_surface` or `group.fasteners`. The default topology policy is +`local` for every tag; token prefixes do not imply downward propagation. +Lineage visibility is limited to proven continuation and fragment witnesses. + +`apply_tag(...)` preserves its historical in-place wrapper behavior. Use +`apply_tag_rselection(...)` when you need an independent semantic shape view, +explicit topology propagation, or a replayable multi-entity assignment. diff --git a/SimpleCADAPI/docs/api/apply_tag_rselection.md b/SimpleCADAPI/docs/api/apply_tag_rselection.md new file mode 100644 index 0000000..b015ef2 --- /dev/null +++ b/SimpleCADAPI/docs/api/apply_tag_rselection.md @@ -0,0 +1,36 @@ +# apply_tag_rselection + +## API Definition + +```python +def apply_tag_rselection( + scope: AnyShape, + targets: Union[ShapeSelector, Sequence[AnyShape]], + tag: str, + topology_propagation: str | TopologyPropagation = TopologyPropagation.LOCAL, + lineage_policy: str | LineagePolicy = LineagePolicy.CONTINUATION_FRAGMENT, +) -> AnyShape +``` + +*Source: operations.py* + +## Import Surface + +- top-level: `from simplecadapi import apply_tag_rselection` + +## Description + +Return an independent semantic view over the same geometry with one canonical +`TagBinding` attached to the selected entities. `targets` may be a serializable +QL `ShapeSelector` or a non-empty sequence of topology objects belonging to +`scope`. + +Topology propagation defaults to `local`. Set `topology_propagation="downward"` +only when descendants should inherit the binding. Lineage defaults to proven +continuation and fragment derivations; it never makes lineage part of the +`effective` scope. + +Inside `GraphSession`, the operation records the complete binding, target intent, +and selected-reference evidence. Replay re-resolves the target and checks that +the evidence has not drifted. The semantic node does not replace geometry-owned +topology references. diff --git a/SimpleCADAPI/docs/api/canonical_unit_for_dimension.md b/SimpleCADAPI/docs/api/canonical_unit_for_dimension.md new file mode 100644 index 0000000..af5f505 --- /dev/null +++ b/SimpleCADAPI/docs/api/canonical_unit_for_dimension.md @@ -0,0 +1,19 @@ +# canonical_unit_for_dimension + +## API Definition + +```python +def canonical_unit_for_dimension(dimension: Dimension) -> Unit +``` + +*Source: units.py* + +## Import Surface + +- top-level: `from simplecadapi import canonical_unit_for_dimension` + +## Description + +Return the canonical unit used by CAD and tolerance calculations. + +Length, area, volume, and angle use ``mm``, ``mm^2``, ``mm^3``, and ``deg``. diff --git a/SimpleCADAPI/docs/api/capture_result.md b/SimpleCADAPI/docs/api/capture_result.md new file mode 100644 index 0000000..fdbf486 --- /dev/null +++ b/SimpleCADAPI/docs/api/capture_result.md @@ -0,0 +1,34 @@ +# capture_result + +## API Definition + +```python +def capture_result(*, value: Any) -> Any +``` + +*Source: graph.py* + +## Import Surface + +- top-level: `from simplecadapi import capture_result` + +## Description + +Mark the graph nodes represented by `value` as the explicit final outputs of the +active model session. The value is returned unchanged, so it can be used inline +or assigned back to a local variable. + +```python +@scad.model(graph_id="multi_output_demo") +def build_model(): + primary = scad.make_box_rsolid(width=10.0, height=4.0, depth=2.0) + secondary = scad.make_cylinder_rsolid(radius=2.0, height=5.0) + return scad.capture_result(value=(primary, secondary)) +``` + +Explicit capture prevents unrelated intermediate graph leaves from becoming +model outputs. When the enclosing `@model` uses `export_dir=...`, captured +geometry and product values become roots in the single self-contained Scene ZIP. +The package embeds model JSON and mapped Python sources; automatic export does +not create adjacent model/session JSON, STEP, STL, or FCStd files. It must be +called inside `@model` or another active `GraphSession`. diff --git a/SimpleCADAPI/docs/api/chamfer_rsolid.md b/SimpleCADAPI/docs/api/chamfer_rsolid.md index 99c2f65..d2aecbe 100644 --- a/SimpleCADAPI/docs/api/chamfer_rsolid.md +++ b/SimpleCADAPI/docs/api/chamfer_rsolid.md @@ -3,7 +3,14 @@ ## API Definition ```python -def chamfer_rsolid(solid: Solid, edges: Union[Sequence[Edge], ShapeSelector], distance: ScalarLike) -> Solid +def chamfer_rsolid( + solid: Solid, + edges: Union[Sequence[Edge], ShapeSelector], + distance: ScalarLike, + *, + result_tag: Optional[str] = None, + generated_faces_tag: Optional[str] = None, +) -> Solid ``` *Source: operations.py* @@ -14,4 +21,10 @@ def chamfer_rsolid(solid: Solid, edges: Union[Sequence[Edge], ShapeSelector], di ## Description -Apply chamfers to selected solid edges. +Apply chamfers to selected solid edges. `generated_faces_tag` targets every face +with the kernel-proven `chamfer.patch` role. OCC contour expansion is included +rather than treating only the seed edge as the feature boundary. + +The operation fails if a requested patch role has no proven result. `result_tag` +tags the resulting solid, and graph recording lowers assignments to replayable +semantic nodes. diff --git a/SimpleCADAPI/docs/api/check_tolerance.md b/SimpleCADAPI/docs/api/check_tolerance.md new file mode 100644 index 0000000..9f6a8cf --- /dev/null +++ b/SimpleCADAPI/docs/api/check_tolerance.md @@ -0,0 +1,22 @@ +# check_tolerance + +## API Definition + +```python +def check_tolerance(value: ScalarLike, tolerance: ToleranceLike, *, method: ToleranceMethod = 'worst_case', name: str | None = None, tolerance_unit: UnitLike | None = None) -> ToleranceCheck +``` + +*Source: tolerance.py* + +## Import Surface + +- top-level: `from simplecadapi import check_tolerance` + +## Description + +Propagate and verify one Length or Angle requirement. + +``tolerance_unit`` defaults to the target dimension's canonical unit. When +provided, it must be dimensionally compatible and is converted before the +comparison. Legacy unitless requirements remain supported when no unit is +supplied. diff --git a/SimpleCADAPI/docs/api/convert_value.md b/SimpleCADAPI/docs/api/convert_value.md new file mode 100644 index 0000000..6ddf243 --- /dev/null +++ b/SimpleCADAPI/docs/api/convert_value.md @@ -0,0 +1,17 @@ +# convert_value + +## API Definition + +```python +def convert_value(value: int | float, from_unit: UnitLike, to_unit: UnitLike) -> float +``` + +*Source: units.py* + +## Import Surface + +- top-level: `from simplecadapi import convert_value` + +## Description + +Convert a finite numeric value between dimensionally compatible units. diff --git a/SimpleCADAPI/docs/api/cut_rsolid.md b/SimpleCADAPI/docs/api/cut_rsolid.md index 82eb8ad..3c7ba02 100644 --- a/SimpleCADAPI/docs/api/cut_rsolid.md +++ b/SimpleCADAPI/docs/api/cut_rsolid.md @@ -3,7 +3,11 @@ ## API Definition ```python -def cut_rsolid(*solids: Union[Solid, Sequence[Solid]], skip_non_intersecting: bool = True) -> Solid +def cut_rsolid( + *solids: Union[Solid, Sequence[Solid]], + skip_non_intersecting: bool = True, + tracking_policy: TrackingPolicy | str = TrackingPolicy.FULL, +) -> Solid ``` *Source: operations.py* @@ -29,6 +33,10 @@ sequences, and returns a single `Solid`. - **Description**: When True, tools with no meaningful intersection are ignored for interactive convenience. Graph replay records this flag and should use False for strict diagnostic workflows. +### tracking_policy + +- **Description**: `TrackingPolicy.FULL` computes topology history and lineage. `TrackingPolicy.GRAPH` preserves the canonical cut node, parameters, inputs, result topology references, and replay while omitting `TopoDelta` and history-derived topology lineage. Intersection validation and `skip_non_intersecting` behavior are unchanged. + ## Returns Solid: The cut result solid. diff --git a/SimpleCADAPI/docs/api/explain_tag.md b/SimpleCADAPI/docs/api/explain_tag.md new file mode 100644 index 0000000..4681317 --- /dev/null +++ b/SimpleCADAPI/docs/api/explain_tag.md @@ -0,0 +1,27 @@ +# explain_tag + +## API Definition + +```python +def explain_tag( + shape: AnyShape, + tag: str, + scope: str | TagScope = TagScope.EFFECTIVE, +) -> List[Dict[str, Any]] +``` + +*Source: operations.py* + +## Import Surface + +- top-level: `from simplecadapi import explain_tag` + +## Description + +Return every visible canonical binding that produces `tag` in the requested +scope. Explanations preserve binding identity, producer, attachment, evidence, +and policy-allowed lineage witnesses, so equal tag tokens from different +producers remain distinguishable. + +As with `list_tags(...)`, `effective` excludes lineage. A lineage explanation +requires complete topology-history coverage. diff --git a/SimpleCADAPI/docs/api/export_model_json.md b/SimpleCADAPI/docs/api/export_model_json.md index 5009670..04056fe 100644 --- a/SimpleCADAPI/docs/api/export_model_json.md +++ b/SimpleCADAPI/docs/api/export_model_json.md @@ -3,7 +3,12 @@ ## API Definition ```python -def export_model_json(session: 'GraphSession', indent: int = 2) -> str +def export_model_json( + session: 'GraphSession', + indent: int = 2, + *, + result_node_ids: Optional[Sequence[str]] = None, +) -> str ``` *Source: serializer.py* @@ -20,3 +25,8 @@ Current Phase 1 scope uses the active session as the container of: - operation graph - expression graph - capabilities/schema metadata + +When `result_node_ids` is omitted, explicitly captured session results are used +when available; otherwise export falls back to graph leaves. New top-level model +code normally reads `ModelResult.model_json` instead of calling this function +directly. diff --git a/SimpleCADAPI/docs/api/expression_uses_units.md b/SimpleCADAPI/docs/api/expression_uses_units.md new file mode 100644 index 0000000..2548e6b --- /dev/null +++ b/SimpleCADAPI/docs/api/expression_uses_units.md @@ -0,0 +1,17 @@ +# expression_uses_units + +## API Definition + +```python +def expression_uses_units(value: 'ScalarLike') -> bool +``` + +*Source: units.py* + +## Import Surface + +- top-level: `from simplecadapi import expression_uses_units` + +## Description + +Return whether an expression contains an explicit unit declaration. diff --git a/SimpleCADAPI/docs/api/extrude_rsolid.md b/SimpleCADAPI/docs/api/extrude_rsolid.md index 0f6d234..c3b48bd 100644 --- a/SimpleCADAPI/docs/api/extrude_rsolid.md +++ b/SimpleCADAPI/docs/api/extrude_rsolid.md @@ -3,7 +3,17 @@ ## API Definition ```python -def extrude_rsolid(profile: Union[Wire, Face], direction: Tuple[float, float, float], distance: ScalarLike) -> Solid +def extrude_rsolid( + profile: Union[Wire, Face], + direction: Tuple[float, float, float], + distance: ScalarLike, + *, + tag_prefix: Optional[str] = None, + result_tag: Optional[str] = None, + start_face_tag: Optional[str] = None, + end_face_tag: Optional[str] = None, + side_faces_tag: Optional[str] = None, +) -> Solid ``` *Source: operations.py* @@ -14,4 +24,27 @@ def extrude_rsolid(profile: Union[Wire, Face], direction: Tuple[float, float, fl ## Description -Create a solid by extruding a profile. +Create a solid by extruding a profile. Kernel history assigns the output roles +`extrusion.start`, `extrusion.end`, and `extrusion.side`. Use the semantic role +tag arguments to attach user tags to those exact role sets. + +When `tag_prefix` is supplied, profile Edge tags are recommended at creation +time through the profile API (`edge_tags` and `tag_prefix`). The feature +produces `.face.start`, `.face.end`, and one +`.face.side.` for each kernel-proven profile Edge. +The cap Face tags are inherited by their boundary Edges, so an exact Edge +can be selected from two tagged neighboring Faces with QL `incident_to` or +`shared_boundary` rather than an enumeration index. + +Start and end roles require exactly one proven face. The side role requires one +or more proven faces and tags all of them. Missing, ambiguous, or unsupported +roles fail the whole operation instead of returning an untagged result. + +`result_tag` attaches a local tag to the resulting solid. In a `GraphSession`, +all requested tags lower to replayable `apply_tag_rselection` semantic nodes; +they are not stored as geometry parameters. + +Topology tags produced by `tag_prefix` remain directly queryable after +booleans when OCC provides a complete preserved or modified Face history. The +result binding retains the original semantic binding ID and source topology ID; +new boundary faces do not inherit the topology tag. diff --git a/SimpleCADAPI/docs/api/fillet_rsolid.md b/SimpleCADAPI/docs/api/fillet_rsolid.md index 61d2a67..2dc839c 100644 --- a/SimpleCADAPI/docs/api/fillet_rsolid.md +++ b/SimpleCADAPI/docs/api/fillet_rsolid.md @@ -3,7 +3,14 @@ ## API Definition ```python -def fillet_rsolid(solid: Solid, edges: Union[Sequence[Edge], ShapeSelector], radius: ScalarLike) -> Solid +def fillet_rsolid( + solid: Solid, + edges: Union[Sequence[Edge], ShapeSelector], + radius: ScalarLike, + *, + result_tag: Optional[str] = None, + generated_faces_tag: Optional[str] = None, +) -> Solid ``` *Source: operations.py* @@ -14,4 +21,10 @@ def fillet_rsolid(solid: Solid, edges: Union[Sequence[Edge], ShapeSelector], rad ## Description -Apply fillets to selected solid edges. +Apply fillets to selected solid edges. `generated_faces_tag` targets every face +with the kernel-proven `fillet.patch` role. OCC contour expansion is included, +so the role is not limited to the original seed edge. + +The operation fails if a requested patch role has no proven result. `result_tag` +tags the resulting solid. In a `GraphSession`, assignments are separate replayable +semantic nodes with asserted user provenance. diff --git a/SimpleCADAPI/docs/api/get_unit.md b/SimpleCADAPI/docs/api/get_unit.md new file mode 100644 index 0000000..ca5818b --- /dev/null +++ b/SimpleCADAPI/docs/api/get_unit.md @@ -0,0 +1,17 @@ +# get_unit + +## API Definition + +```python +def get_unit(value: UnitLike) -> Unit +``` + +*Source: units.py* + +## Import Surface + +- top-level: `from simplecadapi import get_unit` + +## Description + +Resolve a built-in unit name/alias or return an existing ``Unit``. diff --git a/SimpleCADAPI/docs/api/infer_dimension.md b/SimpleCADAPI/docs/api/infer_dimension.md new file mode 100644 index 0000000..d740467 --- /dev/null +++ b/SimpleCADAPI/docs/api/infer_dimension.md @@ -0,0 +1,21 @@ +# infer_dimension + +## API Definition + +```python +def infer_dimension(value: 'ScalarLike') -> Dimension | None +``` + +*Source: units.py* + +## Import Surface + +- top-level: `from simplecadapi import infer_dimension` + +## Description + +Infer and validate an expression's result dimension. + +``None`` means the expression uses only legacy variables without unit +declarations. Expressions that contain explicit units are validated +strictly and cannot mix in legacy variables. diff --git a/SimpleCADAPI/docs/api/list_tags.md b/SimpleCADAPI/docs/api/list_tags.md index 69d2e13..971e9e5 100644 --- a/SimpleCADAPI/docs/api/list_tags.md +++ b/SimpleCADAPI/docs/api/list_tags.md @@ -3,7 +3,10 @@ ## API Definition ```python -def list_tags(shape: AnyShape) -> List[str] +def list_tags( + shape: AnyShape, + scope: str | TagScope = TagScope.EFFECTIVE, +) -> List[str] ``` *Source: operations.py* @@ -14,4 +17,12 @@ def list_tags(shape: AnyShape) -> List[str] ## Description -Return shape tags in deterministic sorted order. +Return shape tags in deterministic sorted order for one semantic scope. + +- `local`: bindings attached directly to the entity. +- `inherited`: bindings visible through explicit downward topology propagation. +- `effective`: local plus inherited bindings. Lineage is not included. +- `lineage`: bindings visible through complete, policy-allowed topology history. + +Lineage queries fail with a semantic capability error when complete topology +history is unavailable; they do not guess from geometry or enumeration order. diff --git a/SimpleCADAPI/docs/api/loft_rsolid.md b/SimpleCADAPI/docs/api/loft_rsolid.md index 8e7645f..0ea7ac1 100644 --- a/SimpleCADAPI/docs/api/loft_rsolid.md +++ b/SimpleCADAPI/docs/api/loft_rsolid.md @@ -3,7 +3,17 @@ ## API Definition ```python -def loft_rsolid(profiles: List[Wire], ruled: bool = False) -> Solid +def loft_rsolid( + profiles: List[Wire], + ruled: bool = False, + *, + tracking_policy: TrackingPolicy | str = TrackingPolicy.FULL, + tag_prefix: Optional[str] = None, + result_tag: Optional[str] = None, + start_face_tag: Optional[str] = None, + end_face_tag: Optional[str] = None, + side_faces_tag: Optional[str] = None, +) -> Solid ``` *Source: operations.py* @@ -14,4 +24,13 @@ def loft_rsolid(profiles: List[Wire], ruled: bool = False) -> Solid ## Description -Create a solid by lofting multiple profiles. +Create a solid by lofting multiple profiles. Kernel history assigns +`loft.start`, `loft.end`, and `loft.side` roles. Start and end tags require one +proven face each; side tags apply to all proven side faces. `result_tag` targets +the solid. Recorded assignments are replayable semantic nodes. + +`TrackingPolicy.FULL` is the default and preserves complete kernel topology +history. `TrackingPolicy.GRAPH` skips topology-history queries while still +recording and replaying the `make_loft_rsolid` graph node. In `GRAPH` mode, +`result_tag` remains available, but face-role tags and `tag_prefix` require +`FULL` tracking. diff --git a/SimpleCADAPI/docs/api/make_box_rsolid.md b/SimpleCADAPI/docs/api/make_box_rsolid.md index f38b665..2bc59c0 100644 --- a/SimpleCADAPI/docs/api/make_box_rsolid.md +++ b/SimpleCADAPI/docs/api/make_box_rsolid.md @@ -3,7 +3,21 @@ ## API Definition ```python -def make_box_rsolid(width: ScalarLike, height: ScalarLike, depth: ScalarLike, bottom_face_center: Tuple[float, float, float] = (0, 0, 0)) -> Solid +def make_box_rsolid( + width: ScalarLike, + height: ScalarLike, + depth: ScalarLike, + bottom_face_center: Tuple[float, float, float] = (0, 0, 0), + *, + tag_prefix: Optional[str] = None, + result_tag: Optional[str] = None, + bottom_face_tag: Optional[str] = None, + top_face_tag: Optional[str] = None, + front_face_tag: Optional[str] = None, + back_face_tag: Optional[str] = None, + left_face_tag: Optional[str] = None, + right_face_tag: Optional[str] = None, +) -> Solid ``` *Source: operations.py* @@ -14,4 +28,19 @@ def make_box_rsolid(width: ScalarLike, height: ScalarLike, depth: ScalarLike, bo ## Description -Create a box solid. +Create a native box solid. The exact kernel-backed Face roles are `box.bottom`, +`box.top`, `box.front`, `box.back`, `box.left`, and `box.right`. Each role has +exactly one Face. + +The face tag arguments attach tags to those roles. `result_tag` targets the +result Solid. `tag_prefix="housing"` creates the topology tags +`housing.solid`, `housing.face.bottom`, +`housing.face.top`, `housing.face.front`, `housing.face.back`, +`housing.face.left`, and `housing.face.right`. + +The roles and topology tags come directly from OCC Box Face witnesses, not Face +enumeration or geometric classification. The current OCP Box builder does not +expose equivalent direct Edge witnesses, so Box Edge output roles are +unsupported. Select an exact Edge from two tagged incident Faces with +`Q.edges().incident_to(face_a, face_b, distinct=True)` or +`face_a.shared_boundary(face_b)`. diff --git a/SimpleCADAPI/docs/api/make_circle_redge.md b/SimpleCADAPI/docs/api/make_circle_redge.md index be9a29b..a48f920 100644 --- a/SimpleCADAPI/docs/api/make_circle_redge.md +++ b/SimpleCADAPI/docs/api/make_circle_redge.md @@ -3,7 +3,13 @@ ## API Definition ```python -def make_circle_redge(center: Tuple[float, float, float], radius: ScalarLike, normal: Tuple[float, float, float] = (0, 0, 1)) -> Edge +def make_circle_redge( + center: Tuple[float, float, float], + radius: ScalarLike, + normal: Tuple[float, float, float] = (0, 0, 1), + *, + tag_prefix: Optional[str] = None, +) -> Edge ``` *Source: operations.py* @@ -14,4 +20,5 @@ def make_circle_redge(center: Tuple[float, float, float], radius: ScalarLike, no ## Description -Create a circular edge. +Create a circular edge. `tag_prefix` optionally creates the topology tag +`.edge`. diff --git a/SimpleCADAPI/docs/api/make_circle_rface.md b/SimpleCADAPI/docs/api/make_circle_rface.md index 2e4c9f7..e35deac 100644 --- a/SimpleCADAPI/docs/api/make_circle_rface.md +++ b/SimpleCADAPI/docs/api/make_circle_rface.md @@ -3,7 +3,14 @@ ## API Definition ```python -def make_circle_rface(center: Tuple[float, float, float], radius: ScalarLike, normal: Tuple[float, float, float] = (0, 0, 1)) -> Face +def make_circle_rface( + center: Tuple[float, float, float], + radius: ScalarLike, + normal: Tuple[float, float, float] = (0, 0, 1), + *, + tag_prefix: Optional[str] = None, + edge_tag: Optional[str] = None, +) -> Face ``` *Source: operations.py* @@ -14,4 +21,8 @@ def make_circle_rface(center: Tuple[float, float, float], radius: ScalarLike, no ## Description -Create a circular face. +Create a circular face. `tag_prefix` creates `.face`, while +`edge_tag` supplies the final segment of `.edge.` for its +boundary Edge, or the complete Edge tag when `tag_prefix` is omitted. The Face +topology tag is visible to effective boundary-Edge QL queries without copying +arbitrary local Face tags to every Edge. diff --git a/SimpleCADAPI/docs/api/make_circle_rwire.md b/SimpleCADAPI/docs/api/make_circle_rwire.md index b1ffd49..da58c4e 100644 --- a/SimpleCADAPI/docs/api/make_circle_rwire.md +++ b/SimpleCADAPI/docs/api/make_circle_rwire.md @@ -3,7 +3,14 @@ ## API Definition ```python -def make_circle_rwire(center: Tuple[float, float, float], radius: ScalarLike, normal: Tuple[float, float, float] = (0, 0, 1)) -> Wire +def make_circle_rwire( + center: Tuple[float, float, float], + radius: ScalarLike, + normal: Tuple[float, float, float] = (0, 0, 1), + *, + tag_prefix: Optional[str] = None, + edge_tag: Optional[str] = None, +) -> Wire ``` *Source: operations.py* @@ -14,4 +21,8 @@ def make_circle_rwire(center: Tuple[float, float, float], radius: ScalarLike, no ## Description -Create a circular wire. +Create a circular wire. `tag_prefix` creates `.wire`, while +`edge_tag` supplies the final segment of `.edge.` for its +single circular Edge, or the complete Edge tag when `tag_prefix` is omitted. +These topology tags are preserved only where a downstream operation has +complete, kernel-proven correspondence. diff --git a/SimpleCADAPI/docs/api/make_cone_rsolid.md b/SimpleCADAPI/docs/api/make_cone_rsolid.md index 95b0348..358f86e 100644 --- a/SimpleCADAPI/docs/api/make_cone_rsolid.md +++ b/SimpleCADAPI/docs/api/make_cone_rsolid.md @@ -3,7 +3,22 @@ ## API Definition ```python -def make_cone_rsolid(bottom_radius: ScalarLike, height: ScalarLike, top_radius: ScalarLike = 0.0, bottom_face_center: Tuple[float, float, float] = (0, 0, 0), axis: Tuple[float, float, float] = (0, 0, 1)) -> Solid +def make_cone_rsolid( + bottom_radius: ScalarLike, + height: ScalarLike, + top_radius: ScalarLike = 0.0, + bottom_face_center: Tuple[float, float, float] = (0, 0, 0), + axis: Tuple[float, float, float] = (0, 0, 1), + *, + tag_prefix: Optional[str] = None, + result_tag: Optional[str] = None, + start_face_tag: Optional[str] = None, + end_face_tag: Optional[str] = None, + side_face_tag: Optional[str] = None, + start_edge_tag: Optional[str] = None, + end_edge_tag: Optional[str] = None, + seam_edge_tag: Optional[str] = None, +) -> Solid ``` *Source: operations.py* @@ -14,4 +29,19 @@ def make_cone_rsolid(bottom_radius: ScalarLike, height: ScalarLike, top_radius: ## Description -Create a cone or truncated cone solid. +Create a native cone or frustum. The kernel-backed Face roles are `cone.start`, +`cone.end`, and `cone.side`. The Edge roles are `cone.start_boundary`, +`cone.end_boundary`, and `cone.seam`. + +For a pointed cone (`top_radius=0`), no top cap exists, so `cone.end` is absent +and requesting `end_face_tag` fails. `cone.end_boundary` remains available as +the kernel's degenerate apex Edge. A frustum (`top_radius>0`) has all six roles. + +The role tag arguments attach tags to exact roles. `result_tag` targets the +Solid. `tag_prefix="adapter"` creates `adapter.solid`, the +Face tags `adapter.face.start`, `adapter.face.end` when present, and +`adapter.face.side`, plus `adapter.edge.start`, `adapter.edge.end`, and +`adapter.edge.seam`. + +All roles and topology tags use direct OCC Cone witnesses. They are not inferred from +topology enumeration, size, normal, or position. diff --git a/SimpleCADAPI/docs/api/make_cylinder_rsolid.md b/SimpleCADAPI/docs/api/make_cylinder_rsolid.md index cd72c8e..574befc 100644 --- a/SimpleCADAPI/docs/api/make_cylinder_rsolid.md +++ b/SimpleCADAPI/docs/api/make_cylinder_rsolid.md @@ -3,7 +3,21 @@ ## API Definition ```python -def make_cylinder_rsolid(radius: ScalarLike, height: ScalarLike, bottom_face_center: Tuple[float, float, float] = (0, 0, 0), axis: Tuple[float, float, float] = (0, 0, 1)) -> Solid +def make_cylinder_rsolid( + radius: ScalarLike, + height: ScalarLike, + bottom_face_center: Tuple[float, float, float] = (0, 0, 0), + axis: Tuple[float, float, float] = (0, 0, 1), + *, + tag_prefix: Optional[str] = None, + result_tag: Optional[str] = None, + start_face_tag: Optional[str] = None, + end_face_tag: Optional[str] = None, + side_face_tag: Optional[str] = None, + start_edge_tag: Optional[str] = None, + end_edge_tag: Optional[str] = None, + seam_edge_tag: Optional[str] = None, +) -> Solid ``` *Source: operations.py* @@ -14,4 +28,21 @@ def make_cylinder_rsolid(radius: ScalarLike, height: ScalarLike, bottom_face_cen ## Description -Create a cylinder solid. +Create a native cylinder solid. The kernel-backed output roles are: + +- `cylinder.start`, `cylinder.end`, and `cylinder.side` for Faces. +- `cylinder.start_boundary`, `cylinder.end_boundary`, and `cylinder.seam` for Edges. + +The role tag arguments attach tags to those exact roles. +`tag_prefix="shaft"` creates the topology tag prefix `shaft`: the cap and +lateral Faces receive `shaft.face.start`, `shaft.face.end`, and +`shaft.face.side`; their boundary Edges inherit the corresponding Face tags. +The three native Edge roles additionally receive `shaft.edge.start`, +`shaft.edge.end`, and `shaft.edge.seam`. + +Topology tag prefixes must be supplied while creating the profile or feature. The implementation +uses OCC primitive witnesses and exact incident topology, not face or edge +enumeration, area, normal, or position heuristics. Use QL relation/set queries +to disambiguate an Edge shared by two tagged Faces, for example +`Q.edges().incident_to(face_a, face_b, distinct=True)` or +`face_a.shared_boundary(face_b)`. diff --git a/SimpleCADAPI/docs/api/make_face_from_sketch_rface.md b/SimpleCADAPI/docs/api/make_face_from_sketch_rface.md index a254ca0..3582a03 100644 --- a/SimpleCADAPI/docs/api/make_face_from_sketch_rface.md +++ b/SimpleCADAPI/docs/api/make_face_from_sketch_rface.md @@ -14,4 +14,12 @@ def make_face_from_sketch_rface(sketch: Sketch, profile: int | str = 0, *, requi ## Description -Promote a sketch profile to a concrete face, solving internally. +Promote a sketch profile to a concrete face, solving internally. The promoted +Face receives the canonical profile topology-identity tag and each boundary +Edge receives the exact Sketch entity tag from the promotion map. For example, +a Sketch with `name="rect"` and profile `bottom` produces `sketch.rect.profile.bottom` and +`sketch.rect.entity.bottom`. + +These are creation-time tags with `topology_name` evidence backed by the solved Sketch +promotion map. They replay from the Sketch payload and promotion parameters; +ordinary compatibility tags such as `sketch_entity.bottom` remain separate. diff --git a/SimpleCADAPI/docs/api/make_face_from_wire_rface.md b/SimpleCADAPI/docs/api/make_face_from_wire_rface.md index 1546650..0152432 100644 --- a/SimpleCADAPI/docs/api/make_face_from_wire_rface.md +++ b/SimpleCADAPI/docs/api/make_face_from_wire_rface.md @@ -3,7 +3,12 @@ ## API Definition ```python -def make_face_from_wire_rface(wire: Wire, normal: Tuple[float, float, float] = (0, 0, 1)) -> Face +def make_face_from_wire_rface( + wire: Wire, + normal: Tuple[float, float, float] = (0, 0, 1), + *, + tag_prefix: Optional[str] = None, +) -> Face ``` *Source: operations.py* @@ -14,4 +19,6 @@ def make_face_from_wire_rface(wire: Wire, normal: Tuple[float, float, float] = ( ## Description -Create a face from a closed wire. +Create a face from a closed wire. Existing proven Edge topology tags on the +wire are copied to corresponding Face boundary Edges. `tag_prefix` optionally +adds the Face tag `.face`. diff --git a/SimpleCADAPI/docs/api/make_line_redge.md b/SimpleCADAPI/docs/api/make_line_redge.md index 9d9a6aa..c4ae06c 100644 --- a/SimpleCADAPI/docs/api/make_line_redge.md +++ b/SimpleCADAPI/docs/api/make_line_redge.md @@ -3,7 +3,12 @@ ## API Definition ```python -def make_line_redge(start: Tuple[ScalarLike, ScalarLike, ScalarLike], end: Tuple[ScalarLike, ScalarLike, ScalarLike]) -> Edge +def make_line_redge( + start: Tuple[ScalarLike, ScalarLike, ScalarLike], + end: Tuple[ScalarLike, ScalarLike, ScalarLike], + *, + tag_prefix: Optional[str] = None, +) -> Edge ``` *Source: operations.py* @@ -14,4 +19,7 @@ def make_line_redge(start: Tuple[ScalarLike, ScalarLike, ScalarLike], end: Tuple ## Description -Create a straight edge between two points. +Create a straight edge between two points. When `tag_prefix` is provided, the +edge receives the topology tag `.edge`. Downstream profile and +feature operations may preserve that tag when kernel history proves the +correspondence. diff --git a/SimpleCADAPI/docs/api/make_rectangle_rface.md b/SimpleCADAPI/docs/api/make_rectangle_rface.md index 96730b0..3f86ac7 100644 --- a/SimpleCADAPI/docs/api/make_rectangle_rface.md +++ b/SimpleCADAPI/docs/api/make_rectangle_rface.md @@ -3,7 +3,15 @@ ## API Definition ```python -def make_rectangle_rface(width: ScalarLike, height: ScalarLike, center: Tuple[ScalarLike, ScalarLike, ScalarLike] = (0, 0, 0), normal: Tuple[ScalarLike, ScalarLike, ScalarLike] = (0, 0, 1)) -> Face +def make_rectangle_rface( + width: ScalarLike, + height: ScalarLike, + center: Tuple[ScalarLike, ScalarLike, ScalarLike] = (0, 0, 0), + normal: Tuple[ScalarLike, ScalarLike, ScalarLike] = (0, 0, 1), + *, + tag_prefix: Optional[str] = None, + edge_tags: Optional[Sequence[str]] = None, +) -> Face ``` *Source: operations.py* @@ -14,4 +22,9 @@ def make_rectangle_rface(width: ScalarLike, height: ScalarLike, center: Tuple[Sc ## Description -Create a rectangular face. +Create a rectangular face. `tag_prefix` creates `.face`, and +`edge_tags` supplies one tag for each of its four boundary Edges. With +`tag_prefix`, each is a local segment under `.edge`; without it, +each is a complete Edge tag. These topology tags can be projected to proven +feature Faces and queried with the same `list_tags(...)` and `ql.tag(...)` +surfaces as other tags. diff --git a/SimpleCADAPI/docs/api/make_rectangle_rwire.md b/SimpleCADAPI/docs/api/make_rectangle_rwire.md index 926900c..d06cc42 100644 --- a/SimpleCADAPI/docs/api/make_rectangle_rwire.md +++ b/SimpleCADAPI/docs/api/make_rectangle_rwire.md @@ -3,7 +3,15 @@ ## API Definition ```python -def make_rectangle_rwire(width: ScalarLike, height: ScalarLike, center: Tuple[ScalarLike, ScalarLike, ScalarLike] = (0, 0, 0), normal: Tuple[ScalarLike, ScalarLike, ScalarLike] = (0, 0, 1)) -> Wire +def make_rectangle_rwire( + width: ScalarLike, + height: ScalarLike, + center: Tuple[ScalarLike, ScalarLike, ScalarLike] = (0, 0, 0), + normal: Tuple[ScalarLike, ScalarLike, ScalarLike] = (0, 0, 1), + *, + tag_prefix: Optional[str] = None, + edge_tags: Optional[Sequence[str]] = None, +) -> Wire ``` *Source: operations.py* @@ -14,4 +22,9 @@ def make_rectangle_rwire(width: ScalarLike, height: ScalarLike, center: Tuple[Sc ## Description -Create a rectangular wire. +Create a rectangular wire. `tag_prefix` creates `.wire`. +`edge_tags` must contain one tag for each generated profile Edge, in kernel +construction order. With `tag_prefix`, each value is the local segment of +`.edge.`; without it, each value is the complete Edge tag. +These topology tags are stable anchors for operations such as `extrude_rsolid` +when correspondence is proven. diff --git a/SimpleCADAPI/docs/api/make_sketch_rsketch.md b/SimpleCADAPI/docs/api/make_sketch_rsketch.md index cfa467f..c091355 100644 --- a/SimpleCADAPI/docs/api/make_sketch_rsketch.md +++ b/SimpleCADAPI/docs/api/make_sketch_rsketch.md @@ -17,4 +17,5 @@ def make_sketch_rsketch(name: Optional[str] = None, *, plane: Any = 'XY', sketch Create an empty declarative sketch document. Use this API, not concrete edge/wire constructors, when the intent is to -build a sketch profile with constraints. +build a sketch profile with constraints. The sketch name and explicit entity +IDs are stable local identifiers used by constrained profile promotion. diff --git a/SimpleCADAPI/docs/api/make_wire_from_edges_rwire.md b/SimpleCADAPI/docs/api/make_wire_from_edges_rwire.md index 3f92edc..69bb8b6 100644 --- a/SimpleCADAPI/docs/api/make_wire_from_edges_rwire.md +++ b/SimpleCADAPI/docs/api/make_wire_from_edges_rwire.md @@ -3,7 +3,7 @@ ## API Definition ```python -def make_wire_from_edges_rwire(edges: List[Edge]) -> Wire +def make_wire_from_edges_rwire(edges: List[Edge], *, tag_prefix: Optional[str] = None) -> Wire ``` *Source: operations.py* @@ -14,4 +14,6 @@ def make_wire_from_edges_rwire(edges: List[Edge]) -> Wire ## Description -Create a wire from a list of connected edges. +Create a wire from a list of connected edges. Existing proven Edge topology +tags are preserved by exact topology identity; `tag_prefix` optionally adds +`.wire` to the resulting wire. diff --git a/SimpleCADAPI/docs/api/make_wire_from_sketch_rwire.md b/SimpleCADAPI/docs/api/make_wire_from_sketch_rwire.md index 9984092..9fa5464 100644 --- a/SimpleCADAPI/docs/api/make_wire_from_sketch_rwire.md +++ b/SimpleCADAPI/docs/api/make_wire_from_sketch_rwire.md @@ -14,4 +14,11 @@ def make_wire_from_sketch_rwire(sketch: Sketch, profile: int | str = 0, *, requi ## Description -Promote a sketch profile to a concrete wire, solving internally. +Promote a sketch profile to a concrete wire, solving internally. The promotion +map preserves the exact ordered `entity_id` to Edge correspondence. A Sketch +with `name="rect"` and profile `bottom` produces the tag +`sketch.rect.profile.bottom`; entity ID `right` produces +`sketch.rect.entity.right` with `topology_name` evidence. + +Promotion fails if the kernel returns a different Edge count, because the SDK +does not guess correspondence from Edge order, geometry, or measurements. diff --git a/SimpleCADAPI/docs/api/model.md b/SimpleCADAPI/docs/api/model.md new file mode 100644 index 0000000..76fbc83 --- /dev/null +++ b/SimpleCADAPI/docs/api/model.md @@ -0,0 +1,50 @@ +# model + +## API Definition + +```python +def model( + func=None, + *, + graph_id: Optional[str] = None, + export_dir: Optional[str | Path] = None, +) -> Callable +``` + +*Source: graph.py* + +## Import Surface + +- top-level: `from simplecadapi import model` + +## Description + +Decorate the single top-level entry point of a replayable model. Each invocation +creates and owns exactly one `GraphSession`, activates it while the function +runs, captures model/session JSON in memory, and returns a `ModelResult`. When +`export_dir` is provided, explicitly captured geometry/product values produce +one `.scene.zip` in that directory. The package embeds +`model/model.json`, mapped project-relative Python source files under +`sources/`, and the GLB/entity assets required for rendering and selection. It +does not create adjacent model/session JSON, STEP, STL, or FCStd files. + +```python +import simplecadapi as scad + +@scad.model(graph_id="bracket") +def build_bracket(): + body = scad.make_box_rsolid(width=20.0, height=10.0, depth=3.0) + scad.capture_result(value=body) + return body + +result = build_bracket() +print(result.result_node_ids) +``` + +Use `result.artifact_paths["scene"]` to locate the package, or call +`result.export_artifacts(output_dir=...)` after a model has run without an +export directory. The explicit `export_dir` opt-in avoids unexpected filesystem +writes for library callers and tests. + +Do not nest `@model` functions or create another `GraphSession` inside a model +function. Use `@requires_session` for child builders. diff --git a/SimpleCADAPI/docs/api/output_role.md b/SimpleCADAPI/docs/api/output_role.md new file mode 100644 index 0000000..97d49e1 --- /dev/null +++ b/SimpleCADAPI/docs/api/output_role.md @@ -0,0 +1,23 @@ +# output_role + +## API Definition + +```python +def output_role(role_name: str) -> SerializablePredicate +``` + +*Source: ql.py* + +## Import Surface + +- submodule: `from simplecadapi import ql` + +## Description + +Return a serializable predicate matching a kernel-proven operation output role +in `metadata["track"]`. Role matching never falls back to face order, geometry, +or flat tags. Use it with a typed selector, for example: + +```python +end = ql.faces().where(ql.output_role(role_name="extrusion.end")).exactly(1) +``` diff --git a/SimpleCADAPI/docs/api/render_screenshot_rpath.md b/SimpleCADAPI/docs/api/render_screenshot_rpath.md index b54d638..b55d683 100644 --- a/SimpleCADAPI/docs/api/render_screenshot_rpath.md +++ b/SimpleCADAPI/docs/api/render_screenshot_rpath.md @@ -3,7 +3,7 @@ ## API Definition ```python -def render_screenshot_rpath(shapes: Union[Solid, Sequence[Solid]], output_path: str, highlight_tags: Optional[Sequence[str]] = None, tag_labels: Optional[Dict[str, str]] = None, image_size: Tuple[int, int] = (1400, 900), view: Union[Tuple[float, float], str] = 'auto', show_axes: bool = True, show_legend: bool = True, zoom: float = 4.0) -> str +def render_screenshot_rpath(shapes: Union[Solid, Sequence[Solid]], output_path: str, highlight_tags: Optional[Sequence[str]] = None, tag_labels: Optional[Dict[str, str]] = None, image_size: Tuple[int, int] = (1400, 900), view: Union[Tuple[float, float], str] = 'auto', show_axes: bool = True, show_legend: bool = True, zoom: float = 4.0, show_callouts: bool = True) -> str ``` *Source: operations.py* @@ -14,4 +14,6 @@ def render_screenshot_rpath(shapes: Union[Solid, Sequence[Solid]], output_path: ## Description -Render a screenshot of shapes and save it to a file. +Render a screenshot of shapes and save it to a file. Set `show_callouts=False` +to retain highlighted material colors and the legend without placing tag labels +over the model. diff --git a/SimpleCADAPI/docs/api/requires_session.md b/SimpleCADAPI/docs/api/requires_session.md new file mode 100644 index 0000000..558b7b7 --- /dev/null +++ b/SimpleCADAPI/docs/api/requires_session.md @@ -0,0 +1,28 @@ +# requires_session + +## API Definition + +```python +def requires_session(func=None) -> Callable +``` + +*Source: graph.py* + +## Import Surface + +- top-level: `from simplecadapi import requires_session` + +## Description + +Decorate a reusable graph-producing builder that must run inside the caller's +active `GraphSession`. The decorator reuses the session owned by the enclosing +`@model` function and validates that returned graph values belong to it. + +```python +@scad.requires_session +def make_bracket_body(): + return scad.make_box_rsolid(width=20.0, height=10.0, depth=3.0) +``` + +Calling a `@requires_session` builder without an active session raises +`RuntimeError`. Builders must not create their own `GraphSession`. diff --git a/SimpleCADAPI/docs/api/revolve_rsolid.md b/SimpleCADAPI/docs/api/revolve_rsolid.md index ceb9dc9..f9b2782 100644 --- a/SimpleCADAPI/docs/api/revolve_rsolid.md +++ b/SimpleCADAPI/docs/api/revolve_rsolid.md @@ -3,7 +3,18 @@ ## API Definition ```python -def revolve_rsolid(profile: Union[Wire, Face], axis: Tuple[float, float, float] = (0, 0, 1), angle: ScalarLike = 360, origin: Tuple[float, float, float] = (0, 0, 0)) -> Solid +def revolve_rsolid( + profile: Union[Wire, Face], + axis: Tuple[float, float, float] = (0, 0, 1), + angle: ScalarLike = 360, + origin: Tuple[float, float, float] = (0, 0, 0), + *, + tag_prefix: Optional[str] = None, + result_tag: Optional[str] = None, + start_face_tag: Optional[str] = None, + end_face_tag: Optional[str] = None, + side_faces_tag: Optional[str] = None, +) -> Solid ``` *Source: operations.py* @@ -14,4 +25,12 @@ def revolve_rsolid(profile: Union[Wire, Face], axis: Tuple[float, float, float] ## Description -Create a solid by revolving a profile around an axis. +Create a solid by revolving a profile around an axis. Kernel history assigns +`revolution.start`, `revolution.end`, and `revolution.side` output roles. + +Start and end roles require exactly one proven face; side tags apply to every +proven side face. A full 360-degree revolve normally has no separate start or end +face, so requesting either cap tag raises a capability error rather than guessing. +The semantic role tag arguments attach tags to the proven cap or side Faces. +`result_tag` tags the resulting Solid. These assignments are replayable +semantic nodes. diff --git a/SimpleCADAPI/docs/api/select.md b/SimpleCADAPI/docs/api/select.md index 8ed3581..f117e07 100644 --- a/SimpleCADAPI/docs/api/select.md +++ b/SimpleCADAPI/docs/api/select.md @@ -15,3 +15,15 @@ def select(items: Iterable[Any]) -> Query ## Description Start a QL query over a shape collection or selector scope. + +For topology-aware selection, use `ql.faces()`, `ql.edges()`, `ql.wires()`, +`ql.vertices()`, or `ql.solids()`. `ShapeSelector.intersection(other)` forms a +serializable set intersection. `selector.shared_boundary(other, +to_kind="edge")` intersects the boundaries of two selectors. Edge selectors +also support `incident_to(face_selector, ..., distinct=True)` and +`incident_face_count(exactly=2)` to select edges by exact incident Face +witnesses and reject open or non-manifold edges. + +These selectors resolve by topology identity, not enumeration order, area, +normal, or position heuristics. Their `to_dict()` payloads can be restored with +`ql.selector_from_dict(...)`. diff --git a/SimpleCADAPI/docs/api/select_edges_by_tag.md b/SimpleCADAPI/docs/api/select_edges_by_tag.md index 9048890..f0748d9 100644 --- a/SimpleCADAPI/docs/api/select_edges_by_tag.md +++ b/SimpleCADAPI/docs/api/select_edges_by_tag.md @@ -3,7 +3,11 @@ ## API Definition ```python -def select_edges_by_tag(shape: Union[Face, Solid], tag: str) -> List[Edge] +def select_edges_by_tag( + shape: Union[Face, Solid], + tag: str, + scope: str | TagScope = TagScope.EFFECTIVE, +) -> List[Edge] ``` *Source: operations.py* @@ -14,4 +18,6 @@ def select_edges_by_tag(shape: Union[Face, Solid], tag: str) -> List[Edge] ## Description -Select edges by tag. +Select edges by an exact normalized tag in the requested semantic scope. +`effective` does not include lineage; request `scope="lineage"` explicitly when +selection depends on complete topology-history evidence. diff --git a/SimpleCADAPI/docs/api/select_faces_by_tag.md b/SimpleCADAPI/docs/api/select_faces_by_tag.md index 6d02151..03d1c80 100644 --- a/SimpleCADAPI/docs/api/select_faces_by_tag.md +++ b/SimpleCADAPI/docs/api/select_faces_by_tag.md @@ -3,7 +3,11 @@ ## API Definition ```python -def select_faces_by_tag(solid: Solid, tag: str) -> List[Face] +def select_faces_by_tag( + solid: Solid, + tag: str, + scope: str | TagScope = TagScope.EFFECTIVE, +) -> List[Face] ``` *Source: operations.py* @@ -14,4 +18,6 @@ def select_faces_by_tag(solid: Solid, tag: str) -> List[Face] ## Description -Select faces by tag. +Select faces by an exact normalized tag in the requested semantic scope. +`effective` does not include lineage; request `scope="lineage"` explicitly when +selection depends on complete topology-history evidence. diff --git a/SimpleCADAPI/docs/api/shell_rsolid.md b/SimpleCADAPI/docs/api/shell_rsolid.md index e3b3438..a6db125 100644 --- a/SimpleCADAPI/docs/api/shell_rsolid.md +++ b/SimpleCADAPI/docs/api/shell_rsolid.md @@ -3,7 +3,17 @@ ## API Definition ```python -def shell_rsolid(solid: Solid, faces_to_remove: Union[Sequence[Face], ShapeSelector], thickness: ScalarLike) -> Solid +def shell_rsolid( + solid: Solid, + faces_to_remove: Union[Sequence[Face], ShapeSelector], + thickness: ScalarLike, + *, + result_tag: Optional[str] = None, + body_faces_tag: Optional[str] = None, + offset_faces_tag: Optional[str] = None, + closing_faces_tag: Optional[str] = None, + wall_edges_tag: Optional[str] = None, +) -> Solid ``` *Source: operations.py* @@ -14,4 +24,16 @@ def shell_rsolid(solid: Solid, faces_to_remove: Union[Sequence[Face], ShapeSelec ## Description -Shell a solid to create a hollow part. +Shell a solid to create a hollow part. The operation can expose these exact +kernel roles: + +- `shell.body_face`: surviving or modified source body faces. +- `shell.offset_face`: generated offset faces. +- `shell.closing_descendant`: descendants of removed closing faces. +- `shell.wall`: generated closing-boundary edges. + +The named arguments map directly to those roles. A role is available only when +OCC provides a complete witness; +requesting an unavailable role fails instead of deriving one from enumeration or +geometry. `result_tag` tags the resulting solid. Recorded assignments replay as +semantic nodes and preserve face versus edge target kinds. diff --git a/SimpleCADAPI/docs/api/solids.md b/SimpleCADAPI/docs/api/solids.md new file mode 100644 index 0000000..a0d71b7 --- /dev/null +++ b/SimpleCADAPI/docs/api/solids.md @@ -0,0 +1,19 @@ +# solids + +## API Definition + +```python +def solids() -> ShapeSelector +``` + +*Source: ql.py* + +## Import Surface + +- submodule: `from simplecadapi import ql` + +## Description + +Create a serializable selector over Solid topology. Solid selectors can be +traversed to boundary Edges and combined with `intersection(...)` or +`shared_boundary(...)` to find topology common to two named Solid selectors. diff --git a/SimpleCADAPI/docs/api/source_binding.md b/SimpleCADAPI/docs/api/source_binding.md new file mode 100644 index 0000000..67b0236 --- /dev/null +++ b/SimpleCADAPI/docs/api/source_binding.md @@ -0,0 +1,19 @@ +# source_binding + +## API Definition + +```python +def source_binding(binding_id: str) -> SerializablePredicate +``` + +*Source: ql.py* + +## Import Surface + +- submodule: `from simplecadapi import ql` + +## Description + +Match a local projected `TagBinding` whose topology-change evidence preserves the +exact source `binding_id`. Objects without canonical local binding evidence raise +an unsupported-query capability error instead of consulting flat tags. diff --git a/SimpleCADAPI/docs/api/source_topology.md b/SimpleCADAPI/docs/api/source_topology.md new file mode 100644 index 0000000..28d32fb --- /dev/null +++ b/SimpleCADAPI/docs/api/source_topology.md @@ -0,0 +1,19 @@ +# source_topology + +## API Definition + +```python +def source_topology(topo_id: str) -> SerializablePredicate +``` + +*Source: ql.py* + +## Import Surface + +- submodule: `from simplecadapi import ql` + +## Description + +Match a local projected `TagBinding` by the exact source topology identity stored +in its kernel-history evidence. This predicate queries source-preserving evidence; +it does not infer ancestry from geometry. diff --git a/SimpleCADAPI/docs/api/sweep_rsolid.md b/SimpleCADAPI/docs/api/sweep_rsolid.md index 774d45d..c68b308 100644 --- a/SimpleCADAPI/docs/api/sweep_rsolid.md +++ b/SimpleCADAPI/docs/api/sweep_rsolid.md @@ -3,7 +3,17 @@ ## API Definition ```python -def sweep_rsolid(profile: Face, path: Wire, is_frenet: bool = False) -> Solid +def sweep_rsolid( + profile: Face, + path: Wire, + is_frenet: bool = False, + *, + tag_prefix: Optional[str] = None, + result_tag: Optional[str] = None, + start_face_tag: Optional[str] = None, + end_face_tag: Optional[str] = None, + side_faces_tag: Optional[str] = None, +) -> Solid ``` *Source: operations.py* @@ -14,4 +24,10 @@ def sweep_rsolid(profile: Face, path: Wire, is_frenet: bool = False) -> Solid ## Description -Create a solid by sweeping a profile along a path. +Create a solid by sweeping a profile along a path. Kernel history assigns +`sweep.start`, `sweep.end`, and `sweep.side` roles. Start and end tags require one +proven face each; side tags apply to all proven side faces. `result_tag` targets +the solid, and recorded assignments are replayable semantic nodes. + +Profiles with inner wires are rejected because the current PipeShell operation +receives only the outer wire; silently dropping profile holes is not allowed. diff --git a/SimpleCADAPI/docs/api/twisted_sweep_rsolid.md b/SimpleCADAPI/docs/api/twisted_sweep_rsolid.md new file mode 100644 index 0000000..2bb58d1 --- /dev/null +++ b/SimpleCADAPI/docs/api/twisted_sweep_rsolid.md @@ -0,0 +1,46 @@ +# twisted_sweep_rsolid + +## API Definition + +```python +def twisted_sweep_rsolid( + profile: Face, + distance: ScalarLike, + twist_angle: ScalarLike, + axis: Tuple[float, float, float] = (0.0, 0.0, 1.0), + origin: Tuple[float, float, float] = (0.0, 0.0, 0.0), + *, + guide_radius: ScalarLike = 1.0, + tag_prefix: Optional[str] = None, + result_tag: Optional[str] = None, + start_face_tag: Optional[str] = None, + end_face_tag: Optional[str] = None, + side_faces_tag: Optional[str] = None, +) -> Solid +``` + +*Source: operations.py* + +## Import Surface + +- top-level: `from simplecadapi import twisted_sweep_rsolid` + +## Description + +Sweep a planar profile along a straight axis while rotating it linearly by the +signed total `twist_angle` in degrees. The profile must lie at the sweep start, +be planar, and be normal to `axis`. + +The OCP implementation uses a one-edge straight spine and a one-edge +cylindrical auxiliary spine. This normally creates one continuous side face per +profile edge rather than splitting every side at intermediate loft sections. +Profiles with inner wires are rejected. + +Kernel history assigns `twisted_sweep.start`, `twisted_sweep.end`, and +`twisted_sweep.side` roles. The operation records one canonical +`make_twisted_sweep_rsolid` graph node containing `axis`, `origin`, `distance`, +`twist_angle`, and `guide_radius`; strict replay invokes the same public +operation with the recorded parameters. + +`guide_radius` controls only the auxiliary orientation guide and must be a +positive finite value. It does not set the swept profile radius. diff --git a/SimpleCADAPI/docs/api/union_rsolid.md b/SimpleCADAPI/docs/api/union_rsolid.md index 1b31c17..740c33a 100644 --- a/SimpleCADAPI/docs/api/union_rsolid.md +++ b/SimpleCADAPI/docs/api/union_rsolid.md @@ -3,7 +3,13 @@ ## API Definition ```python -def union_rsolid(*solids: Union[Solid, Sequence[Solid]], clean: bool = True, glue: bool = _DEFAULT_UNION_GLUE, tol: Optional[float] = None) -> Solid +def union_rsolid( + *solids: Union[Solid, Sequence[Solid]], + clean: bool = True, + glue: bool = _DEFAULT_UNION_GLUE, + tol: Optional[float] = None, + tracking_policy: TrackingPolicy | str = TrackingPolicy.FULL, +) -> Solid ``` *Source: operations.py* @@ -40,6 +46,10 @@ returning multiple pieces. - **Type**: `Optional fuzzy-boolean tolerance used by the OCC union kernel. When` - **Description**: omitted, SimpleCAD chooses a conservative scale-aware tolerance. +### tracking_policy + +- **Description**: `TrackingPolicy.FULL` computes topology history and lineage. `TrackingPolicy.GRAPH` preserves the canonical union node, parameters, inputs, result topology references, and replay while omitting `TopoDelta` and history-derived topology lineage. Geometry options `clean`, `glue`, and `tol` are unchanged. + ## Returns Solid: The merged union result. diff --git a/SimpleCADAPI/docs/api/var_function.md b/SimpleCADAPI/docs/api/var_function.md index 35d0196..d0e136c 100644 --- a/SimpleCADAPI/docs/api/var_function.md +++ b/SimpleCADAPI/docs/api/var_function.md @@ -3,7 +3,7 @@ ## API Definition ```python -def var(name: str, default: int | float, comment: str | None = None) -> Var +def var(name: str, default: int | float, comment: str | None = None, tolerance: ToleranceLike | None = None, *, unit: UnitLike | None = None, tolerance_unit: UnitLike | None = None) -> Var ``` *Source: expr.py* @@ -14,4 +14,13 @@ def var(name: str, default: int | float, comment: str | None = None) -> Var ## Description -Create a named variable node for v2 expression-driven parameters. +Create a physical or legacy scalar variable. + +``tolerance=0.1`` declares a symmetric ``+/-0.1`` tolerance. Use a +``(lower_deviation, upper_deviation)`` pair for an asymmetric tolerance. +``tolerance_unit`` defaults to ``unit`` when a nominal unit is declared. +Values are converted to canonical CAD units only when evaluated, so the +declaration and serialized expression node preserve the user's units. + +Variables without ``unit`` retain legacy unitless behavior. A unit-aware +expression cannot mix declared-unit variables with legacy variables. diff --git a/SimpleCADAPI/docs/architecture/sdk-architecture-review.md b/SimpleCADAPI/docs/architecture/sdk-architecture-review.md index 5b812ea..856be43 100644 --- a/SimpleCADAPI/docs/architecture/sdk-architecture-review.md +++ b/SimpleCADAPI/docs/architecture/sdk-architecture-review.md @@ -33,7 +33,7 @@ SimpleCADAPI 的顶层 API 选择是正确的:它没有把用户拖进传统 C - historical scalar-field/SDF implementation, now removed from the active public/support surface - `docs/core/serialization/` - `docs/core/operation_graph_json_spec.md` -- `examples/07_serialization_operation_tree.py` +- retained replayable examples under `examples/` - `test/` 与 `tests/` ## 顶层 API 评价 @@ -46,7 +46,7 @@ SimpleCADAPI 的顶层 API 选择是正确的:它没有把用户拖进传统 C - `__init__.py` 明确导出建模函数、核心类型、graph/session、serializer、expression 和 `ql` 子模块,公开面可见性好。参考:`src/simplecadapi/__init__.py`。 - 大多数核心创建函数遵守返回类型后缀,例如 `make_point_rvertex -> Vertex`、`make_line_redge -> Edge`、`make_circle_rwire -> Wire`、`make_box_rsolid -> Solid`。参考:`src/simplecadapi/operations.py:691`、`src/simplecadapi/operations.py:723`、`src/simplecadapi/operations.py:1465`。 -- composite convenience API 在 `GraphSession` 内降低为 canonical low-level graph,这个设计非常正确。用户调用 `make_box_rsolid`,图里记录 rectangle/profile/extrude,这是 CAD DSL 和 replay IR 分层的正确方式。参考:`src/simplecadapi/operations.py:1480`、`examples/07_serialization_operation_tree.py:52`、`examples/07_serialization_operation_tree.py:272`。 +- composite convenience API 在 `GraphSession` 内降低为 canonical low-level graph,这个设计非常正确。用户调用 `make_box_rsolid`,图里记录 rectangle/profile/extrude,这是 CAD DSL 和 replay IR 分层的正确方式。参考:`src/simplecadapi/operations.py` 与 `test/test_serialization.py`。 - `union_rsolid(...)` 明确返回单个 `Solid`,失败就报错,不默默返回多个实体。这对机械 CAD 是正确的默认语义。参考:`src/simplecadapi/operations.py:2856`、`src/simplecadapi/operations.py:2876`。 - `GraphSession` + `export_model_json` + `replay_model_json` 的主线是对的。它把脚本建模提升为可交换、可 replay 的模型记录。参考:`src/simplecadapi/graph.py:44`、`src/simplecadapi/serializer.py:416`、`src/simplecadapi/serializer.py:732`。 - `ql` 被放到子模块而非全部塞进顶层,是正确的边界意识。参考:`src/simplecadapi/__init__.py`、`docs/api/README.md:5`。 @@ -154,7 +154,7 @@ QL 是架构上最应该继续投资的部分。 - `OperationGraph` 是 DAG,节点保存 op、params、param_exprs、inputs、context、semantic_delta、topo_delta、tags。结构完整。参考:`src/simplecadapi/topology.py:318`、`src/simplecadapi/topology.py:476`。 - `export_model_json` 输出 graph、leaf_ids、expression_graph、frame_graph、registries、delta logs、canonical contract。方向很对。参考:`src/simplecadapi/serializer.py:627`。 - `_assert_graph_is_canonical` 在 export model 前拒绝非 canonical ops,这是保持 IR 干净的正确动作。参考:`src/simplecadapi/serializer.py:246`、`src/simplecadapi/serializer.py:624`。 -- `examples/07_serialization_operation_tree.py` 非常有价值。它把 source API 到 operation tree 的映射讲清楚,是 SDK 最该保留和扩展的示例。参考:`examples/07_serialization_operation_tree.py:1`、`examples/07_serialization_operation_tree.py:217`。 +- `test/test_serialization.py` 系统覆盖 source API 到 operation tree 的映射,是该契约的主要回归入口。 严重问题: @@ -164,7 +164,7 @@ QL 是架构上最应该继续投资的部分。 - graph schema version 是 `1.0`,model schema 是 `2.0-draft`,canonical contract 是 `2.0-final-state`。这三个版本信号混在一起,会让外部消费者不知道哪个才是稳定承诺。参考:`src/simplecadapi/topology.py:19`、`src/simplecadapi/serializer.py:221`、`src/simplecadapi/serializer.py:628`。 - node.context 和 frame_graph 被记录,但 replay 不恢复 frame/workplane;所有 ops 按当前 ambient world context 执行。参考:`src/simplecadapi/graph.py:132`、`src/simplecadapi/serializer.py:991`。 - `leaf_ids` 自动来自 graph leaves,而不是用户显式声明的 final outputs。debug/intermediate 独立节点会成为 replay 输出。参考:`src/simplecadapi/serializer.py:624`、`src/simplecadapi/topology.py:563`。 -- expression replay 是 numeric snapshot,不是 parametric replay。这个可以接受,但必须在 SDK 主文档里反复强调。参考:`examples/07_serialization_operation_tree.py:40`、`examples/07_serialization_operation_tree.py:287`。 +- expression replay 是 numeric snapshot,不是 parametric replay。这个可以接受,但必须在 SDK 主文档里反复强调。参考:`test/test_serialization.py`。 建议:replay 应该默认 strict。所有 missing input、missing param、unknown op、leaf missing output、selection cardinality mismatch 都应该 hard failure。需要宽松模式时显式 `strict=False`。 diff --git a/SimpleCADAPI/docs/architecture/translator-backend-contract.md b/SimpleCADAPI/docs/architecture/translator-backend-contract.md new file mode 100644 index 0000000..f887679 --- /dev/null +++ b/SimpleCADAPI/docs/architecture/translator-backend-contract.md @@ -0,0 +1,101 @@ +# Translator Backend Contract + +This contract defines the package structure and dependency boundaries for every +SimpleCAD translator backend. + +## Backend Naming + +Backend packages live under `simplecadapi.translator` and use the name +`_translator`, where `` is a stable lowercase backend ID. + +Examples: + +- `freecad_translator` +- `openscad_translator` +- `onshape_translator` + +Backends are exported explicitly from `simplecadapi.translator`. Importing a +backend package must not require the target CAD runtime to be installed. + +## Required Files + +Every backend package contains: + +| File | Responsibility | +| --- | --- | +| `__init__.py` | Defines the complete public backend surface through `__all__`. | +| `api.py` | Contains public convenience functions and user-facing error boundaries. | +| `translator.py` | Contains the `Translator` implementation. | +| `capabilities.py` | Declares backend targets and support for every canonical operation. | + +The translator class must inherit `BaseTranslator`. `capabilities.py` exports +`BACKEND_NAME`, `CAPABILITIES`, and `OP_SUPPORT`. The operation support map must +contain exactly the canonical operation set. Unsupported operations require a +non-empty reason. + +## Conditional Files + +Use these standard names when the corresponding responsibility exists: + +| File or directory | Responsibility | +| --- | --- | +| `exporter.py` | File output, external process or remote API execution, and output validation. | +| `context.py` | State owned by one translation invocation. | +| `analysis.py` | Backend-specific graph analysis and lowering decisions. | +| `codegen.py` | Pure target-code formatting and literal helpers. | +| `emitters/` | Canonical operation emitters and their single registry. | +| `runtime/` | Source fragments embedded into an artifact for execution in the target runtime. | + +An `emitters/registry.py` file is the only operation-to-emitter mapping. Runtime +fragments must remain valid Python source, must not execute target APIs when the +SimpleCAD package is imported, and must be assembled in one declared order. + +## Translation And Export + +Translation is an in-memory operation. It consumes canonical model JSON or an +already imported canonical payload and returns a `TranslationArtifact`. + +Export is an effectful operation. It may write files, invoke an executable, or +call a remote API. Those effects belong in `exporter.py`, not in the translator +or emitters. + +Public naming follows these forms: + +- `translate_model_json_to_` for in-memory conversion. +- `export_model_json_to_` for file or external-runtime output. + +Existing public names may remain as compatibility aliases. + +## Dependencies + +The allowed dependency direction is: + +```text +backend/__init__.py -> api.py, translator.py, capabilities.py +api.py -> translator.py, exporter.py +translator.py -> context.py, analysis.py, emitters/, runtime/ +emitters/ -> context and pure code-generation helpers +exporter.py -> shared types/errors and external execution +``` + +The following reverse dependencies are prohibited: + +- Translator or emitters importing `api.py`. +- Translator importing `exporter.py`. +- Emitters importing the public translator class. +- Runtime fragments importing the backend package. +- Capabilities importing translator implementation modules. + +## Compatibility + +The generated artifact and its persisted target metadata are treated as a +compatibility boundary. Pure module moves must not rename generated runtime +helpers, registries, target object properties, or public import paths. Behavior +fixes are made separately from structural migrations. + +## Verification + +The shared backend contract test verifies required files, public exports, +translator inheritance, backend naming, and canonical operation coverage. +Backend tests additionally verify generated artifact syntax and, where the +target runtime is available, real exported files. diff --git a/SimpleCADAPI/docs/core/README.md b/SimpleCADAPI/docs/core/README.md index ead6458..e2c7302 100644 --- a/SimpleCADAPI/docs/core/README.md +++ b/SimpleCADAPI/docs/core/README.md @@ -62,7 +62,7 @@ SimpleWorkplane ← local modeling context - **Shape-first API**: users work with `Vertex`, `Edge`, `Wire`, `Face`, and `Solid`, not graph nodes. - **Functional modeling style**: public operations return new geometry values, e.g. `make_box_rsolid(...)`, `cut_rsolid(...)`, `fillet_rsolid(...)`. - **OCP-native runtime**: geometry construction, topology traversal, properties, booleans, transforms, and export use OCP/OpenCascade helpers. -- **Replayable graph workflows**: `GraphSession` can record a canonical low-level operation graph and `export_model_json()` can serialize it for `replay_model_json()`. +- **Replayable graph workflows**: `@scad.model` owns one `GraphSession` and returns a `ModelResult`; `@scad.requires_session` composes child builders, and `scad.capture_result()` selects canonical output nodes for replay and export. - **Tags and metadata**: tags are useful for lightweight semantics; structured numeric facts should be stored in metadata such as `metadata["geo"]`. - **Indexed topology access**: use plural methods such as `get_edges()` and `get_faces()` for enumeration, and pass an index to the same getter, such as `get_edges(index)` or `get_faces(index)`, for intentional indexed picks that should become graph selection nodes. @@ -74,11 +74,14 @@ import simplecadapi as scad with scad.SimpleWorkplane(origin=(0, 0, 0)): box = scad.make_box_rsolid(width=5, height=3, depth=2) -scad.apply_tag(box, "role.bracket") +scad.apply_tag(shape=box, tag="role.bracket") box.set_metadata("material", "6061-T6") box.auto_tag_faces("box") -top_faces = [face for face in box.get_faces() if "face.top" in scad.list_tags(face)] +top_faces = [ + face for face in box.get_faces() + if "face.top" in scad.list_tags(shape=face) +] print(len(top_faces)) ``` @@ -87,13 +90,18 @@ print(len(top_faces)) ```python import simplecadapi as scad -with scad.GraphSession() as session: - body = scad.make_box_rsolid(10, 10, 4) - hole = scad.make_cylinder_rsolid(1.5, 8, bottom_face_center=(0, 0, -2)) +@scad.model(graph_id="drilled_block") +def build_model(): + body = scad.make_box_rsolid(width=10, height=10, depth=4) + hole = scad.make_cylinder_rsolid( + radius=1.5, height=8, bottom_face_center=(0, 0, -2) + ) part = scad.cut_rsolid(body, hole) + scad.capture_result(value=part) + return part -payload = scad.export_model_json(session) -rebuilt = scad.replay_model_json(payload) +result = build_model() +rebuilt = result.replay() print(len(rebuilt)) ``` diff --git a/SimpleCADAPI/docs/core/dimension-tolerance-chains.md b/SimpleCADAPI/docs/core/dimension-tolerance-chains.md new file mode 100644 index 0000000..81bff63 --- /dev/null +++ b/SimpleCADAPI/docs/core/dimension-tolerance-chains.md @@ -0,0 +1,256 @@ +# Dimension Tolerance Chains + +SimpleCADAPI can attach units and manufacturing tolerances to declared dimension +variables, infer dimensions through the expression DAG, propagate source +variation, and verify derived dimensions against design requirements. + +This feature is separate from sketch-solver residual tolerances, boolean fuzzy tolerances, mesh resolution, and geometric fitting tolerances. A dimension tolerance describes permitted manufacturing variation around a nominal design value; it never changes a CAD operation's numerical robustness settings. + +## Declare Source Dimensions + +Every variable that participates in a tolerance chain must declare a tolerance: + +```python +import simplecadapi as scad + +width = scad.var("width", 10.0, unit="mm", tolerance=0.1) +shaft = scad.var( + "shaft", + 0.315, + unit="in", + tolerance=(-0.05, 0.0), + tolerance_unit="mm", +) +``` + +A scalar tolerance is symmetric. `tolerance=0.1` means `-0.1/+0.1` +around the nominal value. A two-value sequence contains signed +`(lower_deviation, upper_deviation)` values. The lower deviation must be at most +zero, the upper deviation must be at least zero, and all values must be finite. + +`tolerance_unit` defaults to `unit`. It may differ from the nominal unit, but the +dimensions must match. Geometry and tolerance propagation use canonical +millimeters for length and degrees for angle. Declaration-space values remain on +the `Var` for display and serialization. + +The same values can be represented explicitly: + +```python +tolerance = scad.DimensionTolerance( + lower_deviation=-0.05, + upper_deviation=0.0, +) +shaft = scad.var("shaft", 8.0, unit="mm", tolerance=tolerance) +``` + +Tolerance identity follows `expr_id`, not the human-readable variable name. Two variables with the same name remain separate tolerance sources. + +## Propagate A Chain + +```python +housing = scad.var("housing", 100.0, unit="mm", tolerance=0.15) +bearing = scad.var( + "bearing", 2.0, unit="cm", tolerance=(-0.04, 0.05), tolerance_unit="mm" +) +spacer = scad.var("spacer", 79.4, unit="mm", tolerance=0.05) +clearance = housing - bearing - spacer + +result = scad.analyze_tolerance(clearance, method="worst_case") + +print(result.nominal) +print(result.lower_bound, result.upper_bound) +print(result.lower_deviation, result.upper_deviation) +for contribution in result.contributions: + print(contribution.variable_name, contribution.lower_deviation, contribution.upper_deviation) +``` + +`ToleranceAnalysis` contains absolute bounds, deviations from nominal, inferred +`dimension`, canonical `unit`, and one contribution record per source variable. +Each contribution reports its nominal and source tolerance in canonical units. + +The unit system validates the expression before propagation. This permits +physically meaningful nonlinear chains such as: + +```python +width = scad.var("width", 30.0, unit="mm", tolerance=0.1) +height = scad.var("height", 40.0, unit="mm", tolerance=0.2) +diagonal = scad.sqrt(width**2 + height**2) + +analysis = scad.analyze_tolerance(diagonal) +assert analysis.dimension == scad.LENGTH +assert analysis.unit == scad.MM +``` + +Area and volume expressions can be inferred and analyzed. Persisted manufacturing +requirements currently accept final Length and Angle results only. + +## Propagation Methods + +### Worst Case + +`method="worst_case"` is the default and the safety-oriented validation method. + +- Affine chains preserve variable identity and combine coefficients exactly. Repeated use is not treated as an independent source, so `x - x` has zero propagated tolerance. +- Nonlinear chains use conservative interval propagation. +- Multiplication and division consider all endpoint sign combinations. +- Integer, negative, fractional, and varying powers validate their mathematical domains. +- `sin` and `cos` include interior extrema; `tan` rejects intervals crossing a discontinuity. +- `sqrt`, `asin`, and `acos` validate the entire input interval. +- Division rejects denominator intervals containing zero. +- `atan2` rejects tolerance regions containing its undefined origin. + +Conservative interval propagation can intentionally overestimate a strongly correlated nonlinear expression. It must not underestimate a safety bound. + +### RSS + +`method="rss"` uses first-order analytic sensitivities and root-sum-square combination: + +```python +result = scad.analyze_tolerance(clearance, method="rss") +``` + +Distinct variables are assumed independent. Repeated occurrences of the same variable are merged before RSS, so `x - x` still has zero sensitivity and `x + x` has twice the sensitivity of `x`. + +RSS validates the full declared tolerance interval before calculating the first-order estimate. A nominal point cannot hide a division singularity, trigonometric discontinuity, or invalid function domain elsewhere in the source range. + +Covariance matrices, correlation groups, probability distributions, and Monte Carlo analysis are not represented by the current API. Use `worst_case` when the independence assumption is unavailable or when a guaranteed envelope is required. + +## Check One Requirement + +`check_tolerance()` returns a result without raising when the derived tolerance exceeds the permitted deviations: + +```python +check = scad.check_tolerance( + clearance, + tolerance=(-0.25, 0.24), + method="worst_case", + name="axial_clearance", + tolerance_unit="mm", +) + +print(check.passed) +print(check.lower_margin, check.upper_margin) +``` + +A non-negative lower and upper margin means the requirement passes, subject to a +small floating-point comparison epsilon. Requirement deviations are converted +from `tolerance_unit` to the target's canonical unit before comparison. + +## Session Requirements And Automatic Validation + +Use `GraphSession.require_tolerance()` for design requirements that must travel with the model: + +```python +with scad.GraphSession() as session: + body = scad.make_box_rsolid(housing, 10.0, 10.0) + session.require_tolerance( + clearance, + (-0.25, 0.24), + method="worst_case", + name="axial_clearance", + requirement_id="req.axial_clearance", + tolerance_unit="mm", + ) + +report = session.validate_tolerances(raise_on_failure=True) +model_json = scad.export_model_json(session) +``` + +Automatic validation occurs when: + +1. `validate_tolerances(raise_on_failure=True)` is called. +2. A session or model JSON payload is exported. +3. A model JSON payload is imported or replayed. +4. A model is translated to FreeCAD through the model importer. + +A failed requirement raises `ToleranceValidationError` at the tolerance layer. Model import, export, and replay expose it through the existing structured `SimpleCADError` harness where applicable. + +Declaring a requirement validates that the chain is complete and mathematically defined, but it still records a failing requirement so callers can inspect its margins. Export and replay are the enforcement boundaries. + +## Serialization + +Variable tolerances are stored on variable nodes in `expression_graph`: + +```json +{ + "expr_id": "var_width", + "kind": "var", + "name": "width", + "default": 1.0, + "unit": "in", + "tolerance": { + "lower_deviation": -0.1, + "upper_deviation": 0.1 + }, + "tolerance_unit": "mm" +} +``` + +Design requirements and their latest validation evidence are stored in the top-level `tolerance_graph`: + +```json +{ + "requirements": [ + { + "requirement_id": "req.axial_clearance", + "target_expr_id": "expr_clearance", + "tolerance": { + "lower_deviation": -0.25, + "upper_deviation": 0.24 + }, + "method": "worst_case", + "name": "axial_clearance", + "tolerance_unit": "mm", + "target_dimension": { + "length": 1, + "angle": 0 + } + } + ], + "validation": { + "passed": true, + "checks": [] + } +} +``` + +Validation evidence is recomputed during import; serialized evidence is not +trusted as an authority. The target expression dimension is inferred again and +must match `target_dimension`; `tolerance_unit` must have the same dimension. +Payloads created before units or `tolerance_graph` existed remain valid as legacy +unitless expressions and import with an empty tolerance graph when it is absent. + +Nominal geometry replay still uses the numeric snapshots in operation-node `params`. Tolerance validation does not sample or regenerate worst-case geometry. + +## FreeCAD Translation + +FreeCAD translation keeps the full tolerance graph as document metadata. The +`SimpleCADExpressions` spreadsheet stores lower/upper deviations in columns E/F, +nominal unit in G, tolerance unit in H, and inferred dimension in I. Spreadsheet +values and formulas use canonical CAD values so inch/radian declarations remain +consistent with operation-node snapshots. The translator preserves tolerance +intent but does not convert it into FreeCAD geometric-tolerance objects or +statistical solvers. + +## Failure Conditions + +Tolerance analysis fails explicitly for: + +- a source variable without a declared tolerance +- non-finite nominal values or deviations +- malformed signed lower/upper deviations +- unknown, malformed, non-finite, incompatible, overflowing, or underflowing units +- addition/subtraction or `atan2` with incompatible dimensions +- invalid powers, square roots, or trigonometric dimensions +- mixing unit-declared and legacy variables in one expression +- a requirement unit or persisted target dimension that disagrees with the target +- an Area, Volume, Dimensionless, or compound-dimension requirement target +- duplicate requirement IDs +- unknown or dangling expression references +- malformed, cyclic, duplicate-ID, or unsupported expression nodes +- an undefined expression anywhere in the declared tolerance interval +- an RSS derivative at a non-differentiable nominal point +- an unsupported propagation method + +See [Physical Units And Dimension Inference](physical-units.md) for the complete +unit registry, dimension algebra, custom-unit payload, and legacy behavior. diff --git a/SimpleCADAPI/docs/core/operation_graph_json_spec.md b/SimpleCADAPI/docs/core/operation_graph_json_spec.md index ddda745..044a3a5 100644 --- a/SimpleCADAPI/docs/core/operation_graph_json_spec.md +++ b/SimpleCADAPI/docs/core/operation_graph_json_spec.md @@ -117,7 +117,7 @@ source API 名字不等于 canonical graph op 名字。Composite source API 可 ```json { "schema_version": "2.0", - "producer_version": "2.0.1b1", + "producer_version": "2.0.2", "capabilities": { "selection_ref_strategies": true, "geo_select_nodes": true, @@ -128,7 +128,8 @@ source API 名字不等于 canonical graph op 名字。Composite source API 可 "topology_delta_summary": false, "assembly_graph": false, "scalar_field_graph": false, - "expression_graph": true + "expression_graph": true, + "dimension_tolerances": true }, "graph_id": "graph_xxxxxxxx", "nodes": [...], @@ -163,6 +164,7 @@ source API 名字不等于 canonical graph op 名字。Composite source API 可 | `assembly_graph` | `bool` | 当前 graph JSON 本身不承载 assembly graph | | `scalar_field_graph` | `bool` | 当前为 `false`;SDF / scalar field graph 暂时不在支持范围内 | | `expression_graph` | `bool` | session/model payload 支持 expression graph | +| `dimension_tolerances` | `bool` | session/model payload supports variable tolerances and a tolerance requirement graph | ## 5. Operation Node Schema @@ -334,7 +336,7 @@ source API 名字不等于 canonical graph op 名字。Composite source API 可 - `make_*_face` 与 `make_face_from_wire` -> `Sketch` - `make_*_edge` / `make_*_wire` -> `Profile` -- `make_extrude_rsolid` / `make_revolve_rsolid` / `make_loft_rsolid` / `make_sweep_rsolid` / `make_fillet_rsolid` / `make_chamfer_rsolid` / `make_shell_rsolid` / `make_cut_rsolid` / `make_union_rsolid` / `make_intersect_rsolid` -> `Feature` +- `make_extrude_rsolid` / `make_revolve_rsolid` / `make_loft_rsolid` / `make_sweep_rsolid` / `make_twisted_sweep_rsolid` / `make_fillet_rsolid` / `make_chamfer_rsolid` / `make_shell_rsolid` / `make_cut_rsolid` / `make_union_rsolid` / `make_intersect_rsolid` -> `Feature` ## 7. Topology Delta Schema @@ -667,7 +669,13 @@ outer_edges = Q.faces().where(Q.tag("face.top")).boundary("wire").where(Q.tag("w "expr_id": "var_119b16e4", "kind": "var", "name": "r", - "default": 2.0 + "default": 2.0, + "unit": "mm", + "tolerance": { + "lower_deviation": -0.1, + "upper_deviation": 0.2 + }, + "tolerance_unit": "mm" } ] } @@ -692,7 +700,13 @@ outer_edges = Q.faces().where(Q.tag("face.top")).boundary("wire").where(Q.tag("w "expr_id": "var_xxx", "kind": "var", "name": "radius", - "default": 2.0 + "default": 2.0, + "unit": "mm", + "tolerance": { + "lower_deviation": -0.05, + "upper_deviation": 0.1 + }, + "tolerance_unit": "mm" } ``` @@ -722,6 +736,82 @@ outer_edges = Q.faces().where(Q.tag("face.top")).boundary("wire").where(Q.tag("w - `cos` - `tan` - `sqrt` +- `acos` +- `asin` +- `atan` +- `atan2` + +### 10.3 Unit And Dimension Semantics + +Variable nodes may include: + +| Field | Type | Required | Meaning | +| --- | --- | --- | --- | +| `unit` | `string | unit object` | no | nominal declaration unit | +| `tolerance` | `object` | no | signed deviations in `tolerance_unit` | +| `tolerance_unit` | `string | unit object` | no | source tolerance unit; defaults to `unit` in Python declarations | + +Built-in units serialize as symbols such as `mm`, `in`, `deg`, or `rad`. Custom +units serialize in full: + +```json +{ + "symbol": "thou", + "dimension": {"length": 1, "angle": 0}, + "scale_to_canonical": 0.0254 +} +``` + +Dimensions contain required integer `length` and `angle` exponents. Canonical +numeric values used by operation-node `params` and tolerance analysis are `mm`, +`mm^2`, `mm^3`, `deg`, or `1` for the named dimensions. + +Import rebuilds the complete DAG and reruns dimension inference. Addition and +subtraction require matching dimensions; multiplication/division combine +exponents; dimensioned powers require supported constant exponents; square root +requires even exponents; and trigonometric operations enforce Angle/Dimensionless +inputs. A graph cannot mix unit-declared variables with legacy variables lacking +units. Unitless legacy graphs remain accepted. + +### 10.4 Dimension Tolerance Graph + +Variable `tolerance` values are signed deviations from `default`. A scalar source dimension must use `lower_deviation <= 0 <= upper_deviation`. + +Session/model payloads may include a sibling `tolerance_graph`: + +```json +{ + "requirements": [ + { + "requirement_id": "req.clearance", + "target_expr_id": "expr_clearance", + "tolerance": { + "lower_deviation": -0.2, + "upper_deviation": 0.3 + }, + "method": "worst_case", + "name": "clearance", + "tolerance_unit": "mm", + "target_dimension": { + "length": 1, + "angle": 0 + } + } + ], + "validation": { + "passed": true, + "checks": [] + } +} +``` + +Supported methods are `worst_case` and `rss`. Unit-aware requirements must target +Length or Angle. `tolerance_unit` must match the inferred target dimension and is +converted to the canonical unit before comparison. Importers recompute validation +from `expression_graph`, compare the inferred result to `target_dimension`, and do +not trust serialized `validation` evidence. Missing `tolerance_graph` is treated as +an empty graph for backward compatibility. Legacy requirements may omit both unit +fields when their target expression is unitless. ## 11. Frame Graph Schema @@ -765,6 +855,7 @@ outer_edges = Q.faces().where(Q.tag("face.top")).boundary("wire").where(Q.tag("w "canonical_contract": {...}, "graph": {...}, "expression_graph": {...}, + "tolerance_graph": {...}, "frame_graph": {...}, "geometry_registry": [...], "semantic_entity_registry": [...], @@ -784,6 +875,7 @@ outer_edges = Q.faces().where(Q.tag("face.top")).boundary("wire").where(Q.tag("w | `graph` | `graph object` | yes | canonical low-level graph and only source of truth | | `leaf_ids` | `array` | yes | explicit result set for multi-output graph replay/export | | `expression_graph` | `object` | yes | expression DAG | +| `tolerance_graph` | `object` | no | dimension-chain requirements and validation evidence; defaults to an empty graph | | `frame_graph` | `object` | yes | frame snapshots | | `geometry_registry` | `array` | yes | output geometry registry | | `semantic_entity_registry` | `array` | yes | semantic entity registry | @@ -936,6 +1028,7 @@ New canonical profile nodes use the `make_*_r*` names listed in `canonical_contr - `make_revolve_rsolid` - `make_loft_rsolid` - `make_sweep_rsolid` +- `make_twisted_sweep_rsolid` - `make_translate_rshape` - `make_rotate_rshape` - `make_mirror_rshape` @@ -1296,6 +1389,23 @@ Important: | --- | --- | --- | | `is_frenet` | `bool` | sweep orientation mode | +#### `make_twisted_sweep_rsolid` + +- Inputs: 1 profile `Face` +- Outputs: 1 `Solid` +- Params: + +| Key | Type | Meaning | +| --- | --- | --- | +| `axis` | 3-number array | sweep and rotation-axis direction in caller coordinates | +| `origin` | 3-number array | sweep start and a point on the rotation axis | +| `distance` | positive number | axial sweep distance | +| `twist_angle` | number | signed total rotation in degrees | +| `guide_radius` | positive number | internal auxiliary-spine radius | + +Replay rebuilds the auxiliary spine deterministically inside the operation. It +does not infer section counts or lower to transient loft nodes. + ### 14.6 Boolean Ops #### `make_union_rsolid` diff --git a/SimpleCADAPI/docs/core/physical-units.md b/SimpleCADAPI/docs/core/physical-units.md new file mode 100644 index 0000000..82ae205 --- /dev/null +++ b/SimpleCADAPI/docs/core/physical-units.md @@ -0,0 +1,202 @@ +# Physical Units And Dimension Inference + +SimpleCADAPI attaches physical meaning at `Var` declarations and infers the +dimension of every derived scalar expression. This catches invalid formulas before +they reach geometry, tolerance analysis, model export, replay, or FreeCAD +translation. + +## Canonical CAD Units + +Declaration values are preserved on each `Var`, but evaluation converts them to a +single CAD coordinate system: + +| Dimension | Canonical unit | +| --- | --- | +| Dimensionless | `1` | +| Length | `mm` | +| Area | `mm^2` | +| Volume | `mm^3` | +| Angle | `deg` | + +Degrees are canonical because existing SimpleCAD rotation and angular APIs use +degrees. Trigonometric evaluation converts to radians internally and converts +inverse-trigonometric results back to degrees. + +```python +import math +import simplecadapi as scad + +width = scad.var("width", 1.0, unit="in") +angle = scad.var("angle", math.pi / 2, unit="rad") + +assert width.default == 1.0 +assert width.evaluate() == 25.4 +assert angle.evaluate() == 90.0 +assert math.isclose(scad.sin(angle).evaluate(), 1.0) +``` + +Bindings use the variable's declaration unit. `width.evaluate({"width": 2.0})` +therefore returns `50.8` millimeters for an inch-declared variable. + +## Declaring Units And Tolerances + +```python +width = scad.var( + "width", + 1.0, + unit="in", + tolerance=(-0.1, 0.2), + tolerance_unit="mm", +) +``` + +- `default` is in `unit`. +- `tolerance` is in `tolerance_unit`. +- `tolerance_unit` defaults to `unit` when a tolerance is present. +- Nominal and tolerance units may differ, but their dimensions must match. +- A `tolerance_unit` requires both `unit` and `tolerance`. +- Values must be finite and representable after canonical conversion. + +`width.default` and `width.tolerance` preserve declaration-space values. +`width.canonical_default` and `width.canonical_tolerance` expose values used by +geometry and tolerance propagation. + +## Built-In Units + +| Dimension | Symbols | +| --- | --- | +| Dimensionless | `1`, `%` | +| Length | `mm`, `cm`, `m`, `in`, `ft` | +| Area | `mm^2`, `cm^2`, `m^2`, `in^2`, `ft^2` | +| Volume | `mm^3`, `cm^3`, `m^3`, `in^3`, `ft^3` | +| Angle | `deg`, `rad` | + +Common singular/plural names are accepted by `get_unit()`, including +`millimeters`, `inches`, `feet`, `degrees`, `radians`, `square feet`, and +`cubic inches`. `ml` aliases `cm^3`. + +Use constants such as `MM`, `INCH`, `DEGREE`, `RADIAN`, `LENGTH`, and `ANGLE`, or +resolve strings: + +```python +assert scad.get_unit("inch") == scad.INCH +assert scad.convert_value(1.0, "in", "mm") == 25.4 +assert math.isclose(scad.convert_value(180.0, "deg", "rad"), math.pi) +``` + +Incompatible conversion raises `UnitValidationError`. + +## Dimension Algebra + +`Dimension` stores integer exponents for length and angle. Named dimensions are: + +- `DIMENSIONLESS = Dimension()` +- `LENGTH = Dimension(length=1)` +- `AREA = Dimension(length=2)` +- `VOLUME = Dimension(length=3)` +- `ANGLE = Dimension(angle=1)` + +`infer_dimension(expression)` applies these rules: + +| Operation | Rule | +| --- | --- | +| `a + b`, `a - b` | dimensions must match | +| `a * b` | add dimension exponents | +| `a / b` | subtract dimension exponents | +| `a ** n` | multiply exponents by constant integer `n` | +| `sqrt(a)` or `a ** 0.5` | every exponent must be even | +| unary `-a`, `abs(a)` | preserve dimension | +| `sin`, `cos`, `tan` | input must be Angle; result is Dimensionless | +| `asin`, `acos`, `atan` | input must be Dimensionless; result is Angle | +| `atan2(y, x)` | inputs must have the same dimension; result is Angle | + +Arbitrary and varying powers are permitted for dimensionless bases. A dimensioned +base requires a constant integer exponent, except `0.5` is accepted when all base +exponents are even. + +```python +width = scad.var("width", 30.0, unit="mm") +height = scad.var("height", 40.0, unit="mm") + +area = width * height +diagonal = scad.sqrt(width**2 + height**2) + +assert scad.infer_dimension(area) == scad.AREA +assert scad.infer_dimension(diagonal) == scad.LENGTH +assert diagonal.evaluate() == 50.0 +``` + +## Numeric Constants + +Numeric literals are dimensionless coefficients in multiplication and division. +For addition and subtraction, a literal adopts the other operand's dimension as a +contextual offset: + +```python +length = scad.var("length", 10.0, unit="mm") +assert scad.infer_dimension(length * 2.0) == scad.LENGTH +assert scad.infer_dimension(length + 2.0) == scad.LENGTH +``` + +The literal is already expressed in the canonical result unit. `length + 2.0` +therefore means two millimeters, not two units of `length.unit`. Prefer explicit +variables when declaration-unit intent must be retained. + +## Legacy Unitless Expressions + +Variables without `unit` retain the previous behavior: + +- `infer_dimension()` returns `None`. +- Trigonometric inputs and results use radians. +- Existing arbitrary expression and tolerance behavior remains available. +- A legacy variable cannot be mixed with a unit-declared variable in one + expression because no safe physical meaning can be inferred. + +Pure numeric constant expressions infer `Dimensionless`. + +## Custom Units + +Custom linear-scale units use the same canonical system: + +```python +thou = scad.Unit("thou", scad.LENGTH, 0.0254) +width = scad.var("width", 1000.0, unit=thou) +assert width.evaluate() == 25.4 +``` + +Built-in units serialize as symbols. Custom units serialize a definition: + +```json +{ + "symbol": "thou", + "dimension": {"length": 1, "angle": 0}, + "scale_to_canonical": 0.0254 +} +``` + +Units are scale-only. Offset units such as Celsius/Fahrenheit are not represented. + +## Validation Boundaries + +Unit and dimension validation runs when: + +1. A `Var`, `Dimension`, or `Unit` is created. +2. An expression is directly evaluated. +3. An expression is registered or imported through `ExpressionGraph`. +4. A tolerance is analyzed or a requirement is declared. +5. Session/model JSON is imported, exported, replayed, or translated. + +Malformed units, cyclic graphs, duplicate expression IDs, mixed legacy/typed +variables, incompatible dimensions, invalid roots, and invalid trigonometric inputs +are rejected before graph mutation or geometry replay. + +## Manufacturing Requirement Scope + +Area, volume, inverse length, and other compound dimensions can be inferred and +analyzed. Manufacturing requirements created by `check_tolerance()` or +`GraphSession.require_tolerance()` currently accept final `Length` and `Angle` +results only. This prevents an area or volume variation from being presented as a +linear dimension requirement without explicit engineering semantics. + +See [Dimension Tolerance Chains](dimension-tolerance-chains.md) for propagation, +RSS assumptions, enforcement boundaries, and serialized requirement fields. diff --git a/SimpleCADAPI/docs/core/serialization/README.md b/SimpleCADAPI/docs/core/serialization/README.md index 4bf2c39..e3a2e56 100644 --- a/SimpleCADAPI/docs/core/serialization/README.md +++ b/SimpleCADAPI/docs/core/serialization/README.md @@ -10,14 +10,19 @@ The long-form schema reference remains [`../operation_graph_json_spec.md`](../op import json import simplecadapi as scad -with scad.GraphSession() as session: - body = scad.make_box_rsolid(10, 6, 2) - hole = scad.make_cylinder_rsolid(1, 4, bottom_face_center=(0, 0, -1)) +@scad.model(graph_id="drilled_block") +def build_model(): + body = scad.make_box_rsolid(width=10, height=6, depth=2) + hole = scad.make_cylinder_rsolid( + radius=1, height=4, bottom_face_center=(0, 0, -1) + ) result = scad.cut_rsolid(body, hole) + scad.capture_result(value=result) + return result -model_json = scad.export_model_json(session) -payload = json.loads(model_json) -rebuilt = scad.replay_model_json(model_json) +model = build_model() +payload = json.loads(model.model_json) +rebuilt = model.replay() ``` Inspect these fields: @@ -29,6 +34,17 @@ Inspect these fields: - `node["inputs"]`: upstream node ids used by replay. - `payload["leaf_ids"]`: explicit final result node ids. - `payload["expression_graph"]`: expression DAG used by expression-backed parameters. +- `payload["tolerance_graph"]`: dimension-chain requirements and validation evidence. + +For new top-level models, `ModelResult.model_json` is the preferred artifact +accessor. Use `@scad.requires_session` for reusable builders and +`scad.capture_result(...)` when the final output should not be inferred from +all graph leaves. If a model invocation also needs durable CAD/viewer files, +pass `export_dir=...` to `@scad.model`; its captured geometry/product values +then produce one self-contained `.scene.zip`. It embeds +`model/model.json`, mapped project-relative Python sources, and the evaluated +render/selection assets. It does not create adjacent model/session JSON, STEP, +STL, or FCStd files. No files are written when `export_dir` is omitted. ## Important rule: source API is not always graph API @@ -52,11 +68,13 @@ Many user-facing functions are convenience APIs. During an active `GraphSession` - [Primitive and profile operations](primitives-and-profiles.md) - [Features, booleans, transforms, patterns, and selectors](features-booleans-transforms.md) - [Expressions and replay behavior](expressions-and-replay.md) +- [Physical units and dimension inference](../physical-units.md) +- [Dimension tolerance chains](../dimension-tolerance-chains.md) -## Example +## Examples -See [`../../../examples/07_serialization_operation_tree.py`](../../../examples/07_serialization_operation_tree.py). It intentionally exercises every canonical core operation and writes: - -- `examples/out/serialization_operation_tree.model.json` -- `examples/out/serialization_operation_tree.summary.md` -- `examples/out/serialization_operation_tree.step` +The retained examples use the same model/session contract. See +[`../../../examples/08_constrained_sketch.py`](../../../examples/08_constrained_sketch.py) +for sketch promotion and replay, and +[`../../../examples/10_part_assembly.py`](../../../examples/10_part_assembly.py) +for product hierarchy and automatic artifact export. diff --git a/SimpleCADAPI/docs/core/serialization/expressions-and-replay.md b/SimpleCADAPI/docs/core/serialization/expressions-and-replay.md index 29b897d..fdd8be4 100644 --- a/SimpleCADAPI/docs/core/serialization/expressions-and-replay.md +++ b/SimpleCADAPI/docs/core/serialization/expressions-and-replay.md @@ -15,14 +15,15 @@ This lets consumers choose between: ```python import simplecadapi as scad -width = scad.var("width", 24.0, comment="plate width") -height = scad.var("height", 12.0, comment="plate height") -thickness = scad.var("thickness", 4.0, comment="plate thickness") +width = scad.var("width", 24.0, unit="mm", comment="plate width", tolerance=0.1) +height = scad.var("height", 12.0, unit="mm", comment="plate height", tolerance=0.1) +thickness = scad.var("thickness", 4.0, unit="mm", comment="plate thickness", tolerance=(-0.05, 0.1)) with scad.GraphSession() as session: plate = scad.make_box_rsolid(width, height, thickness) rib = scad.make_box_rsolid(width / 4.0, height, thickness * 2.0) part = scad.union_rsolid(plate, rib) + session.require_tolerance(width + height, 0.2, tolerance_unit="mm", name="plate_envelope") model_json = scad.export_model_json(session) ``` @@ -48,7 +49,9 @@ A node with expression-backed params may look like: } ``` -`params.distance` is the evaluated snapshot. `param_exprs.distance` says the value came from expression node `var_thickness`. +`params.distance` is the evaluated canonical snapshot. Unit-aware lengths are +stored in millimeters and angles in degrees. `param_exprs.distance` says the value +came from expression node `var_thickness` and preserves its declaration metadata. For tuple/list params, `param_exprs` mirrors the shape of the parameter and uses `null` where no expression is present: @@ -78,12 +81,24 @@ Consumers that want parameterization should: Consumers that only want geometry can ignore `param_exprs` and `expression_graph`. +Variable nodes may contain `unit`, `tolerance`, and `tolerance_unit`. Registered +units use string symbols; custom units use `{symbol, dimension, +scale_to_canonical}` objects. Import reconstructs the expression graph and reruns +dimension inference rather than trusting external dimension claims. + +Session/model payloads store derived-dimension requirements in `tolerance_graph`. +See [Physical Units](../physical-units.md) and [Dimension Tolerance +Chains](../dimension-tolerance-chains.md) for inference, propagation, and +validation semantics. + ## Replay policy in current implementation `replay_model_json(model_json)` currently uses the canonical low-level `graph` and the numeric values in `node.params`. That means replay is deterministic with respect to the exported snapshot. It does not currently re-solve expressions with changed variable values. +Replay does validate stored tolerance requirements before rebuilding the nominal geometry. A failing tolerance chain blocks replay, but passing bounds do not cause replay to sample or regenerate limit geometry. + In practical terms: ```python diff --git a/SimpleCADAPI/docs/core/serialization/features-booleans-transforms.md b/SimpleCADAPI/docs/core/serialization/features-booleans-transforms.md index 70ed9c4..61273c0 100644 --- a/SimpleCADAPI/docs/core/serialization/features-booleans-transforms.md +++ b/SimpleCADAPI/docs/core/serialization/features-booleans-transforms.md @@ -122,6 +122,40 @@ Replay effect: 2. Replay path wire from input 1. 3. Call `sweep_rsolid(profile, path, is_frenet=...)`. +## Twisted Sweep + +Source: + +```python +profile = scad.make_rectangle_rface(width=2.0, height=1.0) +solid = scad.twisted_sweep_rsolid( + profile=profile, + distance=8.0, + twist_angle=30.0, +) +``` + +Serialized node: + +```json +{ + "op": "make_twisted_sweep_rsolid", + "params": { + "axis": [0.0, 0.0, 1.0], + "origin": [0.0, 0.0, 0.0], + "distance": 8.0, + "twist_angle": 30.0, + "guide_radius": 1.0 + }, + "inputs": ["node_for_profile_face"], + "output_count": 1 +} +``` + +Replay reconstructs the continuous auxiliary-spine rotation law from the +recorded parameters and invokes `twisted_sweep_rsolid(...)`. No sampled loft +sections are stored or inferred. + ## Helical sweep macro lowering Source: diff --git a/SimpleCADAPI/docs/core/tagged_mixin.md b/SimpleCADAPI/docs/core/tagged_mixin.md index f669462..0ba9a04 100644 --- a/SimpleCADAPI/docs/core/tagged_mixin.md +++ b/SimpleCADAPI/docs/core/tagged_mixin.md @@ -2,21 +2,24 @@ ## Overview -`TaggedMixin` is the internal tag and metadata storage mixin used by `Vertex`, `Edge`, `Wire`, `Face`, and `Solid`. It owns the shared `_tags`, `_metadata`, and `_runtime` stores for topology wrappers. +`TaggedMixin` is the internal semantic binding and metadata mixin used by topology wrappers. Canonical tag ownership is source-preserving `TagBinding` data. `_tags` is only an effective-scope compatibility cache; user code must not treat it as writable truth. User code should not call member tag mutators. The public tag API is functional: - `apply_tag(shape, tag)` attaches one normalized tag. -- `list_tags(shape)` returns tags in deterministic sorted order. +- `apply_tag_rselection(scope, targets, tag, ...)` returns an independent semantic view and exposes explicit propagation policies. +- `list_tags(shape, scope=...)` returns tags in deterministic sorted order. +- `explain_tag(shape, tag, scope=...)` preserves binding and producer evidence. - `select_faces_by_tag(...)`, `select_edges_by_tag(...)`, and QL predicates such as `ql.tag("role.*")` provide selection/query helpers. ## Tagging Mental Model - Tags are normalized lowercase dot-separated semantic tokens. - Examples: `role.mounting_surface`, `anchor.datum.primary`, `group.fasteners`, `face.top`, `edge.boundary`, `wire.outer`, `solid.boolean.cut`. -- `apply_tag(shape, tag)` does not expose propagation controls. -- The standard policy propagates `role.*`, `anchor.*`, `group.*`, and a few legacy bare semantic tags downward. -- Topology-specific tags such as `face.*`, `edge.*`, `wire.*`, `vertex.*`, and `solid.*` stay local. +- New user assignments default to local topology propagation regardless of prefix. +- Downward inheritance is explicit and computed dynamically; bindings are not copied into every child. +- `effective` means local plus inherited and does not include lineage. +- `lineage` requires complete topology history and only follows derivations allowed by the binding policy. - Numeric dimensions, measurements, and rich descriptive payloads belong in metadata, not tags. - Geometry builders store structured geometry facts under `metadata["geo"]`. @@ -34,16 +37,29 @@ top_faces = [face for face in box.get_faces() if "face.top" in scad.list_tags(fa print(len(top_faces)) ``` -## Propagation Example +## Explicit Propagation Example ```python import simplecadapi as scad -body = scad.make_box_rsolid(10, 10, 2) -scad.apply_tag(body, "role.mounting_plate") +body = scad.make_box_rsolid(width=10, height=10, depth=2) +tagged = scad.apply_tag_rselection( + scope=body, + targets=[body], + tag="role.mounting_plate", + topology_propagation=scad.TopologyPropagation.DOWNWARD, +) -face_hits = [face for face in body.get_faces() if "role.mounting_plate" in scad.list_tags(face)] -edge_hits = [edge for edge in body.get_edges() if "role.mounting_plate" in scad.list_tags(edge)] +face_hits = scad.select_faces_by_tag( + solid=tagged, + tag="role.mounting_plate", + scope=scad.TagScope.INHERITED, +) +edge_hits = scad.select_edges_by_tag( + shape=tagged, + tag="role.mounting_plate", + scope=scad.TagScope.INHERITED, +) print(len(face_hits), len(edge_hits)) ``` @@ -55,7 +71,13 @@ Primitives and modeling operations may attach normalized tags automatically: - Primitive tags such as `geom.primitive.box`, `geom.primitive.cylinder`, and `geom.primitive.sphere`. - Face tags from `auto_tag_faces(...)`, such as `face.top`, `face.bottom`, `face.side`, and `face.surface`. - Wire tags such as `wire.outer` and `wire.inner`. -- Operation/tracking tags such as `solid.boolean.cut`, `op.cut.modified`, or `op.extrude.generated`. +- Operation-level categorical tags such as `solid.boolean.cut` may remain local annotations. + +Operation events and source roles are not tags. Proven `preserved`, `modified`, +or `generated` events and `body`/`tool` origins live in typed +`metadata["track"]`. Query them with `ql.operation_event(...)` and +`ql.origin_role(...)`. Missing correspondence remains `coverage="partial"` and +`status="unknown"`; it is never promoted to `generated` by default. ## Metadata Methods @@ -84,7 +106,7 @@ scad.apply_tag(body, "role.mounting_plate") body.auto_tag_faces("box") top_faces = Q.select(body.get_faces()).where(Q.tag("face.top")).all() -role_faces = Q.select(body.get_faces()).where(Q.tag("role.*")).all() +role_faces = Q.select(body.get_faces()).where(Q.tag("role.*", scope="effective")).all() print(len(top_faces), len(role_faces)) ``` diff --git a/SimpleCADAPI/docs/guides/README.md b/SimpleCADAPI/docs/guides/README.md new file mode 100644 index 0000000..51b7b9d --- /dev/null +++ b/SimpleCADAPI/docs/guides/README.md @@ -0,0 +1,11 @@ +# Engineering Guides + +- [STEP BREP 逆向工程方法](step-brep-reverse-engineering.md):从 STEP 检查、解析几何指纹、特征树推断、候选迭代,到几何点集、BREP 拓扑、参数历史和模型回放的完整工作流。 + +对应的可运行模块和案例: + +```text +src/simplecadapi/inverse_engineer/brep/ +examples/out/xzby_reverse/README.md +examples/out/xzby_reverse/reverse_model.py +``` diff --git a/SimpleCADAPI/docs/guides/step-brep-reverse-engineering.md b/SimpleCADAPI/docs/guides/step-brep-reverse-engineering.md new file mode 100644 index 0000000..11bbba9 --- /dev/null +++ b/SimpleCADAPI/docs/guides/step-brep-reverse-engineering.md @@ -0,0 +1,49 @@ +# STEP BREP 逆向工程方法 + +该方法论是 SimpleCAD skill 的任务专用参考,规范版本位于: + +```text +skills/simplecadapi/references/inverse_engineer/brep-reverse-engineering.md +``` + +正式检查工具位于: + +```text +src/simplecadapi/inverse_engineer/brep/ +``` + +Python API: + +```python +from simplecadapi.inverse_engineer import brep + +report = brep.inspect_step(path="part.step") +comparison = brep.compare_steps(target_path="target.step", candidate_path="candidate.step") +brep.render_step_views(step_path="candidate.step", output_path="candidate-views.png") +brep.compare_step_slices( + target_path="target.step", + candidate_path="candidate.step", + output_path="slice-overlay.png", +) +``` + +渲染和图片截面功能需要可选依赖: + +```bash +uv sync --extra inverse-engineer +``` + +CLI: + +```bash +uv run simplecad-brep inspect part.step -o part-report.json +uv run simplecad-brep compare target.step candidate.step -o comparison.json +uv run simplecad-brep render candidate.step candidate-views.png +uv run simplecad-brep slices target.step candidate.step slice-overlay.png +``` + +具体逆向案例见: + +```text +examples/out/xzby_reverse/README.md +``` diff --git a/SimpleCADAPI/docs/stdlib/README.md b/SimpleCADAPI/docs/stdlib/README.md index 6fe849f..bf605d2 100644 --- a/SimpleCADAPI/docs/stdlib/README.md +++ b/SimpleCADAPI/docs/stdlib/README.md @@ -4,12 +4,12 @@ This index includes generated docs for standard part factory functions. Use thes ## Import Surfaces -- Recommended package-level module export: `import simplecadapi as scad`, then call functions through submodules such as `scad.std.gear.(...)` and `scad.std.bearing.(...)`. +- Recommended package-level module export: `import simplecadapi as scad`, then call functions through submodules such as `scad.std.gear.(...)`, `scad.std.fastener.(...)`, and `scad.std.bearing.(...)`. - Direct submodule import is also supported, for example `from simplecadapi.std.gear import make_spur_gear_rsolid` or `from simplecadapi.std.bearing import make_ball_bearing_rassembly`. ## Usage Guidance -- Prefer standard-library factories for standard bearings, gears, ring gears, and racks before hand-modeling profiles with core geometry APIs. +- Prefer standard-library factories for standard bearings, fasteners, spur gears, straight bevel gears, ring gears, and racks before hand-modeling profiles with core geometry APIs. - Standard parts return normal SimpleCAD shapes or product assemblies, so they can be transformed, tagged, assembled, exported, or combined with core geometry operations. - Switch to core geometry APIs only when the requested standard part needs substantial custom geometry beyond the factory parameters. @@ -23,6 +23,19 @@ This index includes generated docs for standard part factory functions. Use thes - [make_herringbone_gear_rsolid](make_herringbone_gear_rsolid.md) *(from std/gear.py)* `stdlib` - [make_spur_gear_rsolid](make_spur_gear_rsolid.md) *(from std/gear.py)* `stdlib` +## Bevel Gears + +- [make_straight_bevel_gear_rsolid](make_straight_bevel_gear_rsolid.md) *(from std/gear.py)* `stdlib` + +## Roller Chain + +- [make_roller_chain_sprocket_rsolid](make_roller_chain_sprocket_rsolid.md) *(from std/chain.py)* `stdlib` + +## Fasteners + +- [make_bolt_rsolid](make_bolt_rsolid.md) *(from std/fastener.py)* `stdlib` +- [make_nut_rsolid](make_nut_rsolid.md) *(from std/fastener.py)* `stdlib` + ## Internal Ring Gears - [make_helical_ring_gear_rsolid](make_helical_ring_gear_rsolid.md) *(from std/gear.py)* `stdlib` diff --git a/SimpleCADAPI/docs/stdlib/make_bolt_rsolid.md b/SimpleCADAPI/docs/stdlib/make_bolt_rsolid.md new file mode 100644 index 0000000..268a82d --- /dev/null +++ b/SimpleCADAPI/docs/stdlib/make_bolt_rsolid.md @@ -0,0 +1,41 @@ +# make_bolt_rsolid + +## API Definition + +```python +def make_bolt_rsolid( + diameter: float, + length: float, + head_style: str = "hex", + thread_style: str = "auto", + thread_detail: str = "modeled", + thread_form: str = "v", + thread_pitch: Optional[float] = None, + thread_depth: Optional[float] = None, + thread_length: Optional[float] = None, + head_width: Optional[float] = None, + head_height: Optional[float] = None, + drive_style: str = "none", + drive_size: Optional[float] = None, + drive_depth: Optional[float] = None, + underhead_fillet_radius: Optional[float] = None, +) -> Solid +``` + +*Source: std/fastener.py* + +## Import Surface + +- standard library: `import simplecadapi as scad` then `scad.std.fastener.make_bolt_rsolid(...)`; direct submodule import: `from simplecadapi.std.fastener import make_bolt_rsolid` + +## Description + +Create a parameterized bolt along `+Z`, with the head underside on `Z=0`. Head styles are `hex`, `square`, `cylindrical`, `button`, and `countersunk`. Drive styles are `none`, `slot`, `cross`, and `hex_socket`. + +Thread styles are `auto`, `full`, `partial`, and `none`. `auto` selects full thread for `length <= 3 * diameter`; otherwise it uses the ISO-style piecewise partial-thread length. The default `thread_detail="modeled"` creates a visible replayable helical `v` or `trapezoidal` thread. Set `thread_detail="cosmetic"` explicitly for a smooth shank with thread intent only in metadata. For partial threads, `thread_length` is measured back from the tip at `Z=length`. + +For metric coarse-series diameters, the factory derives the default pitch from the standard series and records `d2 = d - 0.6495P` and `d1 = d - 1.0825P` in metadata. Hex-head defaults use catalog-like `S` and `k` values where available. The default underhead fillet is `0.06d`; `underhead_fillet_radius` can override it. The metadata also reports a minimum recommended mating-hole chamfer equal to the fillet radius. + +Modeled threads may rotate the helical seam to an equivalent kernel-stable phase. The selected phase is recorded as `thread_phase_degrees` in `std.fastener.bolt` metadata and does not change thread dimensions or handedness. + +Default dimensions are useful parametric proportions, not a claim of compliance with a specific ISO, DIN, ASME, or supplier catalog. Set catalog dimensions explicitly for released hardware. diff --git a/SimpleCADAPI/docs/stdlib/make_helical_gear_rsolid.md b/SimpleCADAPI/docs/stdlib/make_helical_gear_rsolid.md index 1950a88..7e56984 100644 --- a/SimpleCADAPI/docs/stdlib/make_helical_gear_rsolid.md +++ b/SimpleCADAPI/docs/stdlib/make_helical_gear_rsolid.md @@ -16,10 +16,9 @@ def make_helical_gear_rsolid(n_teeth: int, module: float, pressure_angle: float Create an involute helical gear. -Non-zero helix angles are modeled as small-step ruled lofts through rotated -copies of one profile. The small angular step keeps closed-wire section -correspondence stable while ruled faces avoid smooth loft bulging in STEP -exports. +Non-zero helix angles use a continuous twisted sweep along the gear axis. An +auxiliary-spine rotation law preserves the profile while generating one +continuous side face per profile edge instead of one face per loft interval. ## Parameters diff --git a/SimpleCADAPI/docs/stdlib/make_herringbone_gear_rsolid.md b/SimpleCADAPI/docs/stdlib/make_herringbone_gear_rsolid.md index a4ef950..9db0bac 100644 --- a/SimpleCADAPI/docs/stdlib/make_herringbone_gear_rsolid.md +++ b/SimpleCADAPI/docs/stdlib/make_herringbone_gear_rsolid.md @@ -16,10 +16,8 @@ def make_herringbone_gear_rsolid(n_teeth: int, module: float, pressure_angle: fl Create an involute herringbone (double-helical) gear. -Each half is modeled as a small-step ruled loft through rotated copies of -one profile, with a shared center section forming the herringbone ridge. -This keeps closed-wire section correspondence stable while avoiding smooth -loft bulging in STEP exports. +Each half uses a continuous twisted sweep with opposite handedness. The two +halves share the rotated center profile and are fused into one solid. ## Parameters diff --git a/SimpleCADAPI/docs/stdlib/make_nut_rsolid.md b/SimpleCADAPI/docs/stdlib/make_nut_rsolid.md new file mode 100644 index 0000000..53d7401 --- /dev/null +++ b/SimpleCADAPI/docs/stdlib/make_nut_rsolid.md @@ -0,0 +1,35 @@ +# make_nut_rsolid + +## API Definition + +```python +def make_nut_rsolid( + diameter: float, + width: float, + height: float, + nut_style: str = "hex", + hole_style: str = "through", + thread_detail: str = "modeled", + thread_form: str = "v", + thread_pitch: Optional[float] = None, + thread_depth: Optional[float] = None, + hole_depth: Optional[float] = None, + knurl_count: int = 24, +) -> Solid +``` + +*Source: std/fastener.py* + +## Import Surface + +- standard library: `import simplecadapi as scad` then `scad.std.fastener.make_nut_rsolid(...)`; direct submodule import: `from simplecadapi.std.fastener import make_nut_rsolid` + +## Description + +Create a parameterized nut along `+Z`, with its bottom face on `Z=0`. Nut styles are `hex`, `square`, `round`, and `knurled`. Hole styles are `through` and `blind`; a blind hole opens from the top face and uses `hole_depth` as its axial depth. + +The default `thread_detail="modeled"` creates replayable internal `v` or `trapezoidal` helical teeth. Set `thread_detail="cosmetic"` explicitly for a smooth major-diameter hole with thread intent only in metadata. For metric coarse-series diameters, the factory derives the default pitch and records `d2 = d - 0.6495P` and `d1 = d - 1.0825P` in metadata. `knurl_count` controls the lobe count of the printable knurled approximation. + +Modeled threads may rotate the helical seam to an equivalent kernel-stable phase. The selected phase is recorded as `thread_phase_degrees` in `std.fastener.nut` metadata and does not change thread dimensions or handedness. + +Default thread proportions are useful for parametric models, not a substitute for catalog tolerances, thread class, lead-in, prevailing-torque features, or manufacturing checks. diff --git a/SimpleCADAPI/docs/stdlib/make_roller_chain_sprocket_rsolid.md b/SimpleCADAPI/docs/stdlib/make_roller_chain_sprocket_rsolid.md new file mode 100644 index 0000000..7f08e4c --- /dev/null +++ b/SimpleCADAPI/docs/stdlib/make_roller_chain_sprocket_rsolid.md @@ -0,0 +1,19 @@ +# make_roller_chain_sprocket_rsolid + +## API Definition + +```python +def make_roller_chain_sprocket_rsolid(n_teeth: int, chain_pitch: float, roller_diameter: float, sprocket_thickness: float, *, bore_radius: float = 0.0, roller_clearance: float = 0.15) -> Solid +``` + +*Source: std/chain.py* + +## Import Surface + +- standard library: `import simplecadapi as scad` then `scad.std.chain.make_roller_chain_sprocket_rsolid(...)`; direct submodule import: `from simplecadapi.std.chain import make_roller_chain_sprocket_rsolid` + +## Description + +Create a roller-chain sprocket from tooth count, chain pitch, roller diameter, and tooth-plate thickness. The pitch radius follows the regular pitch polygon. Circular roller seats are cut at every pitch point and opened through the engineering outside-diameter envelope. + +The result preserves assembly-level engagement dimensions. Manufacturing release still requires the selected chain standard's permitted tooth-form range, tooth width, hub, material, heat treatment, runout, and supplier checks. diff --git a/SimpleCADAPI/docs/stdlib/make_straight_bevel_gear_rsolid.md b/SimpleCADAPI/docs/stdlib/make_straight_bevel_gear_rsolid.md new file mode 100644 index 0000000..923c761 --- /dev/null +++ b/SimpleCADAPI/docs/stdlib/make_straight_bevel_gear_rsolid.md @@ -0,0 +1,30 @@ +# make_straight_bevel_gear_rsolid + +## API Definition + +```python +def make_straight_bevel_gear_rsolid(n_teeth: int, module: float, pitch_angle: float = 45.0, pressure_angle: float = 20.0, face_width: float = 8.0, *, addendum_factor: float = 1.0, clearance_factor: float = 0.25, backlash: float = 0.0) -> Solid +``` + +*Source: std/gear.py* + +## Import Surface + +- standard library: `import simplecadapi as scad` then `scad.std.gear.make_straight_bevel_gear_rsolid(...)`; direct submodule import: `from simplecadapi.std.gear import make_straight_bevel_gear_rsolid` + +## Description + +Create a straight bevel gear with standard metric tooth proportions. The large-end transverse section uses an analytic involute profile. A similar small-end section is placed on the pitch cone and connected with ruled straight tooth surfaces. + +The returned solid contains nominal tooth geometry. Releasing a mating pair still requires mounting-distance, contact-pattern, backlash, material, heat-treatment, and strength checks. + +## Parameters + +- `n_teeth`: Number of teeth, at least 3. +- `module`: Large-end transverse module in millimetres. +- `pitch_angle`: Pitch-cone angle in degrees, greater than 0 and less than 90. +- `pressure_angle`: Transverse pressure angle in degrees. +- `face_width`: Tooth face width along the pitch-cone generator in millimetres; must be smaller than the outer pitch-cone distance. +- `addendum_factor`: Large-end tooth addendum divided by module. +- `clearance_factor`: Root clearance beyond the addendum divided by module. +- `backlash`: Large-end circumferential tooth-thickness reduction at the pitch circle in millimetres. diff --git a/SimpleCADAPI/examples/.gitignore b/SimpleCADAPI/examples/.gitignore index 611e307..903717e 100644 --- a/SimpleCADAPI/examples/.gitignore +++ b/SimpleCADAPI/examples/.gitignore @@ -4,3 +4,8 @@ out/ *.json *.FCStd *.FCBak +*.fcstd +*.fcbak +*.scene.zip +*.zip +*.log diff --git a/SimpleCADAPI/examples/01_basic_modeling.py b/SimpleCADAPI/examples/01_basic_modeling.py deleted file mode 100644 index 770013c..0000000 --- a/SimpleCADAPI/examples/01_basic_modeling.py +++ /dev/null @@ -1,31 +0,0 @@ -"""Basic shape-first modeling with the functional API. - -Run from the repository root with: - uv run python examples/01_basic_modeling.py -""" - -from pathlib import Path - -import simplecadapi as scad - - -OUT = Path("examples/out") -OUT.mkdir(parents=True, exist_ok=True) - - -base = scad.make_box_rsolid(60.0, 36.0, 8.0, bottom_face_center=(0.0, 0.0, 0.0)) -hole = scad.make_cylinder_rsolid(5.0, 14.0, bottom_face_center=(0.0, 0.0, -3.0)) -slot = scad.make_box_rsolid(18.0, 8.0, 14.0, bottom_face_center=(14.0, 0.0, -3.0)) -part = scad.cut_rsolid(base, hole, slot) - -boss = scad.make_cylinder_rsolid(8.0, 7.0, bottom_face_center=(-18.0, 0.0, 8.0)) -part = scad.union_rsolid(part, boss) -part.auto_tag_faces("box") - -print("volume", round(part.get_volume(), 3)) -print("faces", len(part.get_faces())) -print("edges", len(part.get_edges())) - -scad.export_step(part, str(OUT / "basic_modeling.step")) -scad.export_stl(part, str(OUT / "basic_modeling.stl")) -print("wrote", OUT / "basic_modeling.step") diff --git a/SimpleCADAPI/examples/02_graph_replay.py b/SimpleCADAPI/examples/02_graph_replay.py deleted file mode 100644 index b8d09cb..0000000 --- a/SimpleCADAPI/examples/02_graph_replay.py +++ /dev/null @@ -1,39 +0,0 @@ -"""Record a replayable model graph, export model JSON, then replay it. - -Run from the repository root with: - uv run python examples/02_graph_replay.py -""" - -from pathlib import Path - -import simplecadapi as scad -from simplecadapi import ql as Q - - -OUT = Path("examples/out") -OUT.mkdir(parents=True, exist_ok=True) - - -with scad.GraphSession() as session: - body = scad.make_box_rsolid(40.0, 24.0, 10.0, bottom_face_center=(0.0, 0.0, 0.0)) - cutter = scad.make_cylinder_rsolid(4.0, 16.0, bottom_face_center=(0.0, 0.0, -3.0)) - drilled = scad.cut_rsolid(body, cutter) - - # Use a serializable QL selector instead of relying on OCC edge iteration order. - bottom_circle = ( - Q.edges() - .where(Q.curve_type("circle")) - .order_by(Q.center_axis("z")) - .take(1) - .exactly(1) - ) - final = scad.chamfer_rsolid(drilled, bottom_circle, 0.6) - -model_json = scad.export_model_json(session) -(OUT / "graph_replay.model.json").write_text(model_json, encoding="utf-8") - -rebuilt = scad.replay_model_json(model_json) -print("recorded_nodes", session.graph.node_count) -print("replayed_outputs", len(rebuilt)) -print("replayed_type", type(rebuilt[0]).__name__ if rebuilt else "none") -print("wrote", OUT / "graph_replay.model.json") diff --git a/SimpleCADAPI/examples/03_expressions.py b/SimpleCADAPI/examples/03_expressions.py deleted file mode 100644 index 849d5ee..0000000 --- a/SimpleCADAPI/examples/03_expressions.py +++ /dev/null @@ -1,33 +0,0 @@ -"""Expression parameters inside a replayable graph/model workflow. - -Run from the repository root with: - uv run python examples/03_expressions.py -""" - -import json -from pathlib import Path - -import simplecadapi as scad - - -OUT = Path("examples/out") -OUT.mkdir(parents=True, exist_ok=True) - - -width = scad.var("width", 24.0, comment="plate width") -height = scad.var("height", 12.0, comment="plate height") -thickness = scad.var("thickness", 4.0, comment="plate thickness") - -with scad.GraphSession() as session: - plate = scad.make_box_rsolid(width, height, thickness) - rib = scad.make_box_rsolid(width / 4.0, height, thickness * 2.0) - rib = scad.translate_shape(rib, (0.0, 0.0, 4.0)) - part = scad.union_rsolid(plate, rib) - -model_json = scad.export_model_json(session) -payload = json.loads(model_json) -(OUT / "expressions.model.json").write_text(model_json, encoding="utf-8") - -print("expression_nodes", len(payload["expression_graph"]["nodes"])) -print("graph_nodes", len(payload["graph"]["nodes"])) -print("volume", round(part.get_volume(), 3)) diff --git a/SimpleCADAPI/examples/04_dimension_tolerance_chain.py b/SimpleCADAPI/examples/04_dimension_tolerance_chain.py new file mode 100644 index 0000000..dbb3b0f --- /dev/null +++ b/SimpleCADAPI/examples/04_dimension_tolerance_chain.py @@ -0,0 +1,108 @@ +"""Declare, propagate, validate, and serialize a dimension tolerance chain. + +Run from the repository root with: + uv run python examples/04_dimension_tolerance_chain.py +""" + +import json +from pathlib import Path + +import simplecadapi as scad + + +OUT = Path("examples/out") + + +@scad.model(graph_id="dimension_tolerance_chain") +def build_model(): + housing_span = scad.var( + name="housing_span", + default=100.0, + unit="mm", + tolerance=0.15, + comment="Internal housing span", + ) + bearing_width = scad.var( + name="bearing_width", + default=2.0, + unit="cm", + tolerance=(-0.04, 0.05), + tolerance_unit="mm", + comment="Bearing width", + ) + spacer_width = scad.var( + name="spacer_width", + default=79.4, + unit="mm", + tolerance=0.05, + comment="Spacer width", + ) + axial_clearance = housing_span - bearing_width - spacer_width + + worst_case = scad.analyze_tolerance( + value=axial_clearance, + method="worst_case", + ) + rss = scad.analyze_tolerance(value=axial_clearance, method="rss") + + housing = scad.make_box_rsolid( + width=housing_span, + height=10.0, + depth=10.0, + tag_prefix="tolerance_chain.housing", + result_tag="part.tolerance_chain.housing", + ) + session = scad.get_active_session() + if session is None: + raise RuntimeError("dimension tolerance model has no active session") + session.require_tolerance( + value=axial_clearance, + tolerance=(-0.25, 0.24), + tolerance_unit="mm", + method="worst_case", + name="axial_clearance", + ) + report = session.validate_tolerances(raise_on_failure=True) + scad.capture_result(value=housing) + return { + "housing": housing, + "worst_case": worst_case, + "rss": rss, + "report": report, + } + + +def main() -> None: + OUT.mkdir(parents=True, exist_ok=True) + result = build_model() + report_data = result.value + (OUT / "dimension_tolerance_chain.model.json").write_text( + result.model_json, + encoding="utf-8", + ) + + worst_case = report_data["worst_case"] + rss = report_data["rss"] + print("housing_volume", round(report_data["housing"].get_volume(), 3)) + print( + "worst_case", + round(worst_case.nominal, 3), + round(worst_case.lower_bound, 3), + round(worst_case.upper_bound, 3), + ) + print("result_unit", worst_case.dimension.name, worst_case.unit.symbol) + print( + "rss", + round(rss.nominal, 3), + round(rss.lower_bound, 3), + round(rss.upper_bound, 3), + ) + print("requirements_passed", report_data["report"].passed) + print( + "serialized_tolerance_graph", + "tolerance_graph" in json.loads(result.model_json), + ) + + +if __name__ == "__main__": + main() diff --git a/SimpleCADAPI/examples/05_loft_sweep_revolve.py b/SimpleCADAPI/examples/05_loft_sweep_revolve.py deleted file mode 100644 index 83f2be4..0000000 --- a/SimpleCADAPI/examples/05_loft_sweep_revolve.py +++ /dev/null @@ -1,35 +0,0 @@ -"""Profile operations: revolve, loft, and sweep. - -Run from the repository root with: - uv run python examples/05_loft_sweep_revolve.py -""" - -from pathlib import Path - -import simplecadapi as scad - - -OUT = Path("examples/out") -OUT.mkdir(parents=True, exist_ok=True) - -# Revolve a closed profile into a small knob. -profile = scad.make_polyline_rwire( - [(0.0, 0.0, 0.0), (4.0, 0.0, 0.0), (3.0, 0.0, 8.0), (1.0, 0.0, 8.0)], - closed=True, -) -knob = scad.revolve_rsolid(profile, axis=(0.0, 0.0, 1.0), angle=360.0) - -# Loft between rectangular sections. -a = scad.make_rectangle_rwire(8.0, 8.0, center=(16.0, 0.0, 0.0)) -b = scad.make_rectangle_rwire(4.0, 4.0, center=(16.0, 0.0, 8.0)) -loft = scad.loft_rsolid([a, b]) - -# Sweep a circular face along a polyline path. -profile_face = scad.make_circle_rface((30.0, 0.0, 0.0), 1.0, normal=(1.0, 0.0, 0.0)) -path = scad.make_polyline_rwire([(30.0, 0.0, 0.0), (34.0, 0.0, 3.0), (38.0, 3.0, 6.0)]) -swept = scad.sweep_rsolid(profile_face, path) - -scad.export_step([knob, loft, swept], str(OUT / "profile_operations.step")) -print("knob", round(knob.get_volume(), 3)) -print("loft", round(loft.get_volume(), 3)) -print("swept", round(swept.get_volume(), 3)) diff --git a/SimpleCADAPI/examples/06_parametric_gear_model.py b/SimpleCADAPI/examples/06_parametric_gear_model.py deleted file mode 100644 index ca40c23..0000000 --- a/SimpleCADAPI/examples/06_parametric_gear_model.py +++ /dev/null @@ -1,192 +0,0 @@ -"""Tidy parametric gear-like model JSON example. - -This example is intentionally lightweight enough for automated tests. It is not a -full involute gear generator; it demonstrates the same release-critical behavior: -expression parameters, derived numeric construction values, canonical graph -export, and exactly one explicit `leaf_ids` output. - -Run from the repository root with: - uv run python examples/06_parametric_gear_model.py -""" - -from __future__ import annotations - -import argparse -import json -from pathlib import Path - -import simplecadapi as scad - - -def _involute_spur_profile_points( - *, - tooth_count: int, - module: float, - pressure_angle_deg: float = 20.0, - backlash: float = 0.03, - profile_points: int = 10, - root_arc_points: int = 4, - tip_arc_points: int = 4, -) -> list[tuple[float, float, float]]: - """Sample a closed 2D involute spur gear outline in the XY plane.""" - - import math - - if tooth_count < 8: - raise ValueError("tooth_count must be >= 8") - if module <= 0: - raise ValueError("module must be > 0") - - pressure_angle = math.radians(pressure_angle_deg) - pitch_radius = 0.5 * module * tooth_count - tip_radius = pitch_radius + module - root_radius = pitch_radius - 1.25 * module - base_radius = pitch_radius * math.cos(pressure_angle) - if root_radius <= 0: - raise ValueError("invalid gear dimensions: root radius <= 0") - - half_tooth_angle = (math.pi / (2.0 * tooth_count)) - ( - backlash / (2.0 * pitch_radius) - ) - inv_pitch = math.tan(pressure_angle) - pressure_angle - r_start = max(root_radius, base_radius) - - def flank_angle_at_radius(radius: float) -> float: - ratio = min(1.0, max(0.0, base_radius / radius)) - phi = math.acos(ratio) - inv_r = math.tan(phi) - phi - return half_tooth_angle + inv_pitch - inv_r - - radii = [ - r_start + (tip_radius - r_start) * i / (profile_points - 1) - for i in range(profile_points) - ] - - flank_pos: list[tuple[float, float]] = [] - flank_neg: list[tuple[float, float]] = [] - for radius in radii: - beta = flank_angle_at_radius(radius) - x = radius * math.cos(beta) - y = radius * math.sin(beta) - flank_pos.append((x, y)) - flank_neg.append((x, -y)) - - start_angle = math.atan2(flank_pos[0][1], flank_pos[0][0]) - tip_angle_neg = math.atan2(flank_neg[-1][1], flank_neg[-1][0]) - tip_angle_pos = math.atan2(flank_pos[-1][1], flank_pos[-1][0]) - - tip_arc = [ - ( - tip_radius - * math.cos( - tip_angle_neg + (tip_angle_pos - tip_angle_neg) * i / tip_arc_points - ), - tip_radius - * math.sin( - tip_angle_neg + (tip_angle_pos - tip_angle_neg) * i / tip_arc_points - ), - ) - for i in range(1, tip_arc_points) - ] - - tooth_local: list[tuple[float, float]] = [] - tooth_local.append((root_radius * math.cos(-start_angle), root_radius * math.sin(-start_angle))) - tooth_local.extend(flank_neg) - tooth_local.extend(tip_arc) - tooth_local.extend(reversed(flank_pos)) - tooth_local.append((root_radius * math.cos(start_angle), root_radius * math.sin(start_angle))) - - def rotate_xy(point: tuple[float, float], angle: float) -> tuple[float, float]: - c = math.cos(angle) - s = math.sin(angle) - return (point[0] * c - point[1] * s, point[0] * s + point[1] * c) - - tooth_pitch_angle = 2.0 * math.pi / tooth_count - outline: list[tuple[float, float]] = [] - for k in range(tooth_count): - center_angle = k * tooth_pitch_angle - tooth_world = [rotate_xy(point, center_angle) for point in tooth_local] - outline.extend(tooth_world if not outline else tooth_world[1:]) - - a0 = center_angle + start_angle - a1 = center_angle + tooth_pitch_angle - start_angle - for i in range(1, root_arc_points): - angle = a0 + (a1 - a0) * i / root_arc_points - outline.append((root_radius * math.cos(angle), root_radius * math.sin(angle))) - - cleaned: list[tuple[float, float]] = [] - for point in outline: - if not cleaned: - cleaned.append(point) - continue - if math.hypot(point[0] - cleaned[-1][0], point[1] - cleaned[-1][1]) > 1e-7: - cleaned.append(point) - - return [(x, y, 0.0) for x, y in cleaned] - - -def build_model(output_dir: Path) -> dict: - """Build a small replayable involute spur gear and write model JSON. - - Args: - output_dir: Directory that receives `parametric_gear.model.json`. - - Returns: - The parsed exported model payload. - """ - - tooth_count_value = 14 - module_value = 1.4 - thickness = scad.var("thickness", 4.0) - bore_radius = scad.var("bore_radius", 2.2) - - # Keep derived construction facts as numerics; this example verifies they do - # not become top-level model variables such as `pitch_radius`. - profile_points = _involute_spur_profile_points( - tooth_count=tooth_count_value, - module=module_value, - ) - - with scad.GraphSession() as session: - profile = scad.make_polyline_rwire(profile_points, closed=True) - gear = scad.extrude_rsolid(profile, (0.0, 0.0, 1.0), thickness) - bore = scad.make_cylinder_rsolid( - bore_radius, - thickness + 2.0, - bottom_face_center=(0.0, 0.0, -1.0), - ) - gear = scad.cut_rsolid(gear, bore) - # Keep the final output as a single explicit leaf node. - gear = scad.translate_shape(gear, (0.0, 0.0, 0.0)) - - model_json = scad.export_model_json(session) - payload = json.loads(model_json) - output_dir.mkdir(parents=True, exist_ok=True) - (output_dir / "parametric_gear.model.json").write_text(model_json, encoding="utf-8") - return payload - - -def main() -> None: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument( - "--output-dir", - type=Path, - default=Path("examples/out/parametric_gear"), - ) - args = parser.parse_args() - - payload = build_model(args.output_dir) - var_names = [ - node.get("name") - for node in payload["expression_graph"]["nodes"] - if node.get("kind") == "var" - ] - - print("leaf_count", len(payload["leaf_ids"])) - print("graph_nodes", len(payload["graph"]["nodes"])) - print("vars", ",".join(str(name) for name in var_names)) - print("wrote", args.output_dir / "parametric_gear.model.json") - - -if __name__ == "__main__": - main() diff --git a/SimpleCADAPI/examples/07_serialization_operation_tree.py b/SimpleCADAPI/examples/07_serialization_operation_tree.py deleted file mode 100644 index e6a147c..0000000 --- a/SimpleCADAPI/examples/07_serialization_operation_tree.py +++ /dev/null @@ -1,312 +0,0 @@ -"""Show how source code maps to the serializable operation tree. - -Run from the repository root with: - uv run python examples/07_serialization_operation_tree.py - -This example intentionally keeps the geometry simple. Its main purpose is to -show that user-facing calls such as `make_box_rsolid()` and -`helical_sweep_rsolid()` are lowered into the canonical, replayable operation -nodes stored in `model.json`. - -Generated files: - examples/out/serialization_operation_tree.model.json - examples/out/serialization_operation_tree.summary.md - examples/out/serialization_operation_tree.step -""" - -from __future__ import annotations - -import json -from collections import Counter -from pathlib import Path -from textwrap import dedent - -import simplecadapi as scad -from simplecadapi import ql as Q - - -OUT = Path("examples/out") -OUT.mkdir(parents=True, exist_ok=True) -MODEL_JSON_PATH = OUT / "serialization_operation_tree.model.json" -SUMMARY_PATH = OUT / "serialization_operation_tree.summary.md" -STEP_PATH = OUT / "serialization_operation_tree.step" - - -def source_step(name: str): - """Print a readable marker while building the recorded model.""" - print(f"SOURCE STEP: {name}") - - -# --------------------------------------------------------------------------- -# Expression parameters: model JSON stores numeric snapshots in node.params and -# expression references in node.param_exprs / expression_graph. -# --------------------------------------------------------------------------- -plate_w = scad.var("plate_w", 36.0, comment="main plate width") -plate_h = scad.var("plate_h", 18.0, comment="main plate height") -plate_t = scad.var("plate_t", 3.0, comment="main plate thickness") -hole_r = scad.var("hole_r", 2.2, comment="through-hole radius") -rib_t = scad.var("rib_t", 1.6, comment="rib thickness") -fillet_r = scad.var("fillet_r", 0.45, comment="small edge fillet radius") - - -with scad.GraphSession() as session: - # Basic construction and primitive lowering: - # make_box_rsolid -> rectangle face -> four line edges -> wire -> face -> extrude - source_step("01 make_box_rsolid(expr dimensions) -> lowered profile + extrude") - plate = scad.make_box_rsolid(plate_w, plate_h, plate_t) - plate = scad.apply_tag(plate, "demo.main_plate") - - # make_cylinder_rsolid is also serializable via lowering: - # circle edge -> wire -> face -> extrude - source_step("02 make_cylinder_rsolid(expr radius) -> lowered circle face + extrude") - hole = scad.make_cylinder_rsolid( - hole_r, - plate_t + 2.0, - bottom_face_center=(0.0, 0.0, -1.0), - ) - drilled_plate = scad.cut_rsolid(plate, hole) - - # Core wire/profile API: point, line, circle, arc, spline, helix, wire construction, - # face construction. These are kept small and placed away from the plate so - # they are easy to inspect in the graph without making the shape complicated. - source_step("03 make_point_rvertex") - marker_point = scad.make_point_rvertex(-18.0, -9.0, 6.0) - - source_step("04 explicit edges + make_wire_from_edges_rwire + make_face_from_wire_rface") - e1 = scad.make_line_redge((-8.0, 0.0, plate_t), (-6.0, 0.0, plate_t)) - e2 = scad.make_three_point_arc_redge( - (-6.0, 0.0, plate_t), (-5.0, 1.0, plate_t), (-4.0, 0.0, plate_t) - ) - e3 = scad.make_angle_arc_redge( - (-3.0, 0.0, plate_t), 1.0, 3.14159, 0.0, normal=(0.0, 0.0, 1.0) - ) - spline_fit = scad.fit_cubic_bspline_control_points( - [(-2.0, 0.0, plate_t), (-1.0, 0.8, plate_t), (0.0, 0.0, plate_t)], - tolerance=0.01, - ) - e4 = scad.make_spline_redge( - control_points=spline_fit.control_points, - knots=spline_fit.unique_knots, - multiplicities=spline_fit.multiplicities, - ) - # The four edges above are intentionally separate leaf examples. A valid - # `make_wire_from_edges_rwire` example follows with a closed triangle. - - # A closed profile built explicitly from lines, then converted to a face. - tri_a = scad.make_line_redge((8.0, -2.0, plate_t), (11.0, -2.0, plate_t)) - tri_b = scad.make_line_redge((11.0, -2.0, plate_t), (9.5, 1.0, plate_t)) - tri_c = scad.make_line_redge((9.5, 1.0, plate_t), (8.0, -2.0, plate_t)) - triangle_wire = scad.make_wire_from_edges_rwire([tri_a, tri_b, tri_c]) - triangle_face = scad.make_face_from_wire_rface(triangle_wire) - triangle_boss = scad.extrude_rsolid(triangle_face, (0.0, 0.0, 1.0), rib_t) - - # Convenience wire/face builders are included too; inside GraphSession they - # lower to the same canonical low-level edge/wire/face operations. - source_step("05 convenience wires/faces -> lowered canonical edge/wire/face nodes") - rectangle_wire = scad.make_rectangle_rwire(4.0, 2.0, center=(-13.0, 0.0, plate_t)) - rectangle_face = scad.make_rectangle_rface(4.0, 2.0, center=(-13.0, 4.0, plate_t)) - circle_wire = scad.make_circle_rwire((13.0, 4.0, plate_t), 1.0) - circle_face = scad.make_circle_rface((13.0, 0.0, plate_t), 1.0) - segment_wire = scad.make_segment_rwire((-13.0, -4.0, plate_t), (-9.0, -4.0, plate_t)) - polyline_wire = scad.make_polyline_rwire( - [(-3.0, -5.0, plate_t), (-1.0, -4.0, plate_t), (1.0, -5.0, plate_t)] - ) - arc_wire = scad.make_three_point_arc_rwire( - (3.0, -5.0, plate_t), (4.0, -4.0, plate_t), (5.0, -5.0, plate_t) - ) - angle_arc_wire = scad.make_angle_arc_rwire((7.0, -5.0, plate_t), 1.0, 0.0, 1.57) - wire_spline_fit = scad.fit_cubic_bspline_control_points( - [(9.0, -5.0, plate_t), (10.0, -4.0, plate_t), (11.0, -5.0, plate_t)], - tolerance=0.01, - ) - spline_wire = scad.make_spline_rwire( - control_points=wire_spline_fit.control_points, - knots=wire_spline_fit.unique_knots, - multiplicities=wire_spline_fit.multiplicities, - ) - - # Basic solid constructors that lower to replayable core operations. - source_step("06 make_sphere_rsolid, make_cone_rsolid") - sphere = scad.make_sphere_rsolid(1.0, center=(-7.0, 6.0, plate_t + 1.0)) - cone = scad.make_cone_rsolid( - 1.2, - 2.0, - top_radius=0.4, - bottom_face_center=(-3.0, 6.0, plate_t), - ) - # Feature operations. - source_step("07 revolve_rsolid, loft_rsolid, sweep_rsolid") - revolve_profile = scad.make_polyline_rwire( - [(0.5, 0.0, 0.0), (1.2, 0.0, 0.0), (1.0, 0.0, 1.6), (0.5, 0.0, 1.6)], - closed=True, - ) - revolved_pin = scad.revolve_rsolid( - revolve_profile, - axis=(0.0, 0.0, 1.0), - angle=360.0, - origin=(0.0, 0.0, 0.0), - ) - revolved_pin = scad.translate_shape(revolved_pin, (4.0, 6.0, plate_t)) - - loft_a = scad.make_rectangle_rwire(1.8, 1.2, center=(8.0, 6.0, plate_t)) - loft_b = scad.make_rectangle_rwire(1.0, 0.8, center=(8.0, 6.0, plate_t + 2.0)) - lofted_post = scad.loft_rsolid([loft_a, loft_b], ruled=True) - - sweep_profile = scad.make_circle_rface((12.0, 6.0, plate_t), 0.35, normal=(1.0, 0.0, 0.0)) - sweep_path = scad.make_polyline_rwire( - [(12.0, 6.0, plate_t), (14.0, 6.0, plate_t + 1.0), (15.5, 7.0, plate_t + 1.5)] - ) - swept_pipe = scad.sweep_rsolid(sweep_profile, sweep_path, is_frenet=False) - - # Composite operation: helical_sweep_rsolid is serialized as helix + face + sweep, - # not as a dedicated `helical_sweep` graph node. - source_step("08 helical_sweep_rsolid macro -> make_helix_redge + wire + face + sweep") - thread_profile = scad.make_rectangle_rwire(0.25, 0.18, center=(0.0, 0.0, 0.0)) - helical_thread = scad.helical_sweep_rsolid( - thread_profile, - pitch=0.7, - height=2.2, - radius=0.9, - center=(13.0, -6.0, plate_t), - ) - - # Transforms and patterns. Pattern helpers serialize as explicit translate / - # rotate nodes instead of `linear_pattern` / `radial_pattern` macro nodes. - source_step("09 translate_shape, rotate_shape, mirror_shape") - rib = scad.make_box_rsolid(rib_t, plate_h * 0.55, plate_t * 1.4) - rib = scad.translate_shape(rib, (-plate_w / 4.0, 0.0, plate_t)) - rib = scad.rotate_shape(rib, 0.0) # zero-angle shortcut, intentionally not recorded - rib_copy = scad.mirror_shape(rib, plane_origin=(0.0, 0.0, 0.0), plane_normal=(1.0, 0.0, 0.0)) - - source_step("10 linear_pattern_rsolidlist and radial_pattern_rsolidlist macro lowering") - lug_seed = scad.make_box_rsolid(1.2, 1.2, 1.0, bottom_face_center=(-12.0, -7.0, plate_t)) - linear_lugs = scad.linear_pattern_rsolidlist(lug_seed, (1.0, 0.0, 0.0), count=3, spacing=3.0) - spoke_seed = scad.make_box_rsolid(0.8, 2.0, 0.8, bottom_face_center=(0.0, 5.2, plate_t)) - radial_spokes = scad.radial_pattern_rsolidlist( - spoke_seed, - center=(0.0, 0.0, plate_t), - axis=(0.0, 0.0, 1.0), - count=4, - total_rotation_angle=360.0, - ) - - # Boolean operations. Boolean union must produce one connected solid, so - # this tiny demo uses overlapping boxes instead of trying to merge every - # separate showcase solid above. - source_step("11 union_rsolid, intersect_rsolid, cut_rsolid") - union_a = scad.make_box_rsolid(3.0, 2.0, 1.0, bottom_face_center=(-4.0, -7.0, 0.0)) - union_b = scad.make_box_rsolid(3.0, 2.0, 1.0, bottom_face_center=(-2.5, -7.0, 0.0)) - union_demo = scad.union_rsolid(union_a, union_b) - - overlap_a = scad.make_box_rsolid(2.0, 2.0, 2.0, bottom_face_center=(12.0, -2.0, plate_t)) - overlap_b = scad.make_box_rsolid(2.0, 2.0, 2.0, bottom_face_center=(13.0, -2.0, plate_t)) - intersection_demo = scad.intersect_rsolid(overlap_a, overlap_b) - - # Detail operations use QL selectors so the graph contains stable, serializable - # selection hints rather than Python object identity from source code. - source_step("12 fillet_rsolid, chamfer_rsolid, shell_rsolid with serializable selectors") - vertical_edges = Q.edges().where(Q.curve_type("line")).take(4) - final = scad.fillet_rsolid(union_demo, vertical_edges, fillet_r) - - chamfer_box = scad.make_box_rsolid(3.0, 2.0, 1.0, bottom_face_center=(2.0, -7.0, 0.0)) - top_outer_edges = Q.edges().order_by(Q.center_axis("z"), desc=True).take(4) - chamfer_demo = scad.chamfer_rsolid(chamfer_box, top_outer_edges, 0.15) - - # Keep shell separate so the demo includes shell without making the main part - # fragile. It remains a replayable leaf in model.json. - shell_box = scad.make_box_rsolid(4.0, 3.0, 2.0, bottom_face_center=(18.0, -7.0, 0.0)) - top_face = Q.faces().order_by(Q.center_axis("z"), desc=True).take(1).exactly(1) - shell_demo = scad.shell_rsolid(shell_box, top_face, 0.25) - -# Export the canonical model JSON and inspect how the graph maps back to source. -model_json = scad.export_model_json(session) -payload = json.loads(model_json) -MODEL_JSON_PATH.write_text(model_json, encoding="utf-8") - -# Replay from model JSON to prove that the stored operation tree is sufficient. -rebuilt = scad.replay_model_json(model_json) -scad.export_step(rebuilt, str(STEP_PATH)) - -ops = [node["op"] for node in payload["graph"]["nodes"]] -op_counts = Counter(ops) -expr_nodes = payload["expression_graph"]["nodes"] -nodes_with_exprs = [ - node for node in payload["graph"]["nodes"] if node.get("param_exprs") -] - -# Build a compact source-to-graph explanation. This file is easier to read than -# the full JSON and is meant to be opened side by side with this Python source. -summary = dedent( - f""" - # Serialization Operation Tree Example - - Source file: `examples/07_serialization_operation_tree.py` - - Generated model JSON: `{MODEL_JSON_PATH}` - Generated STEP replay output: `{STEP_PATH}` - - ## What to compare - - 1. Read the `SOURCE STEP` comments / print output in the Python source. - 2. Open the JSON and inspect `graph.nodes[*].op`, `params`, `param_exprs`, and `inputs`. - 3. Notice that convenience API calls are lowered to canonical replayable operations. - - ## Basic counts - - - graph nodes: `{len(payload['graph']['nodes'])}` - - graph edges: `{len(payload['graph']['edges'])}` - - leaf ids: `{len(payload['leaf_ids'])}` -> `{payload['leaf_ids']}` - - expression graph nodes: `{len(expr_nodes)}` - - operation nodes with `param_exprs`: `{len(nodes_with_exprs)}` - - replayed outputs: `{len(rebuilt)}` - - ## Canonical operation set observed - - """ -).lstrip() - -for op, count in sorted(op_counts.items()): - summary += f"- `{op}`: {count}\n" - -summary += dedent( - """ - - ## Important source-code to graph mappings - - - `make_box_rsolid(...)` does **not** appear as `make_box` in model JSON. - It lowers to `make_line_redge` + `make_wire_from_edges_rwire` + - `make_face_from_wire_rface` + `make_extrude_rsolid`. - - `make_cylinder_rsolid(...)` lowers to a circle face plus `make_extrude_rsolid`. - - `make_sphere_rsolid(...)` and `make_cone_rsolid(...)` lower to revolve chains. - - `make_rectangle_rwire`, `make_circle_rwire`, `make_polyline_rwire`, and - single-arc/spline/helix wire helpers lower to edge + wire operations. - - `linear_pattern_rsolidlist(...)` lowers to explicit `make_translate_rshape` - nodes. - - `radial_pattern_rsolidlist(...)` lowers to explicit `make_rotate_rshape` - nodes. - - `helical_sweep_rsolid(...)` lowers to helix + face + `make_sweep_rsolid`; - there is no `helical_sweep` node. - - Expression values are snapshotted into `params`; the symbolic links live in - `param_exprs` and the top-level `expression_graph`. - - ## Nodes that reference expressions - - """ -) - -for node in nodes_with_exprs[:40]: - summary += f"- `{node['node_id']}` `{node['op']}` param_exprs={json.dumps(node['param_exprs'], sort_keys=True)}\n" - -if len(nodes_with_exprs) > 40: - summary += f"- ... {len(nodes_with_exprs) - 40} more expression-backed nodes\n" - -SUMMARY_PATH.write_text(summary, encoding="utf-8") - -print("wrote", MODEL_JSON_PATH) -print("wrote", SUMMARY_PATH) -print("wrote", STEP_PATH) -print("graph_nodes", len(payload["graph"]["nodes"])) -print("expression_nodes", len(expr_nodes)) -print("leaf_ids", payload["leaf_ids"]) -print("replayed_outputs", len(rebuilt)) -print("observed_ops", ", ".join(sorted(op_counts))) diff --git a/SimpleCADAPI/examples/08_constrained_sketch.py b/SimpleCADAPI/examples/08_constrained_sketch.py index b5609fa..074db04 100644 --- a/SimpleCADAPI/examples/08_constrained_sketch.py +++ b/SimpleCADAPI/examples/08_constrained_sketch.py @@ -21,7 +21,6 @@ import simplecadapi as scad OUT = Path("examples/out") -OUT.mkdir(parents=True, exist_ok=True) MODEL_JSON_PATH = OUT / "constrained_sketch.model.json" STEP_PATH = OUT / "constrained_sketch.step" FCSTD_PATH = OUT / "constrained_sketch.fcstd" @@ -30,7 +29,7 @@ FREECAD_CMD = Path("/Applications/FreeCAD.app/Contents/Resources/bin/freecadcmd" def _solve_and_report(name: str, sketch: scad.Sketch) -> None: result = scad.inspect_sketch_rsketchresult( - sketch, + sketch=sketch, require_fully_constrained=True, ) points = sorted( @@ -54,228 +53,513 @@ def _solve_and_report(name: str, sketch: scad.Sketch) -> None: ) +@scad.requires_session def _promote_face(name: str, sketch: scad.Sketch): - _solve_and_report(name, sketch) + _solve_and_report(name=name, sketch=sketch) return scad.make_face_from_sketch_rface( - sketch, + sketch=sketch, require_fully_constrained=True, ) +@scad.requires_session def make_rect_profile(name, x0, y0, width, height): - sketch = scad.make_sketch_rsketch(name, plane="XY") + sketch = scad.make_sketch_rsketch(name=name, plane="XY") - sketch = scad.add_point_rsketch(sketch, "p0", x0, y0) - sketch = scad.add_point_rsketch(sketch, "p1", x0 + width, y0) - sketch = scad.add_point_rsketch(sketch, "p2", x0 + width, y0 + height) - sketch = scad.add_point_rsketch(sketch, "p3", x0, y0 + height) + sketch = scad.add_point_rsketch(sketch=sketch, point_id="p0", x=x0, y=y0) + sketch = scad.add_point_rsketch( + sketch=sketch, + point_id="p1", + x=x0 + width, + y=y0, + ) + sketch = scad.add_point_rsketch( + sketch=sketch, + point_id="p2", + x=x0 + width, + y=y0 + height, + ) + sketch = scad.add_point_rsketch( + sketch=sketch, + point_id="p3", + x=x0, + y=y0 + height, + ) - sketch = scad.add_line_rsketch(sketch, "bottom", "p0", "p1") - sketch = scad.add_line_rsketch(sketch, "right", "p1", "p2") - sketch = scad.add_line_rsketch(sketch, "top", "p2", "p3") - sketch = scad.add_line_rsketch(sketch, "left", "p3", "p0") + sketch = scad.add_line_rsketch( + sketch=sketch, + entity_id="bottom", + start="p0", + end="p1", + ) + sketch = scad.add_line_rsketch( + sketch=sketch, + entity_id="right", + start="p1", + end="p2", + ) + sketch = scad.add_line_rsketch( + sketch=sketch, + entity_id="top", + start="p2", + end="p3", + ) + sketch = scad.add_line_rsketch( + sketch=sketch, + entity_id="left", + start="p3", + end="p0", + ) - sketch = scad.constrain_horizontal_rsketch(sketch, "bottom") - sketch = scad.constrain_vertical_rsketch(sketch, "right") - sketch = scad.constrain_parallel_rsketch(sketch, "bottom", "top") - sketch = scad.constrain_parallel_rsketch(sketch, "left", "right") - sketch = scad.constrain_perpendicular_rsketch(sketch, "bottom", "right") - sketch = scad.constrain_equal_length_rsketch(sketch, "bottom", "top") - sketch = scad.constrain_equal_length_rsketch(sketch, "left", "right") - sketch = scad.constrain_distance_rsketch(sketch, "p0", "p1", width) - sketch = scad.constrain_distance_rsketch(sketch, "p0", "p3", height) - sketch = scad.constrain_fix_rsketch(sketch, "p0") - return _promote_face(name, sketch) + sketch = scad.constrain_horizontal_rsketch(sketch=sketch, line="bottom") + sketch = scad.constrain_vertical_rsketch(sketch=sketch, line="right") + sketch = scad.constrain_parallel_rsketch( + sketch=sketch, + a="bottom", + b="top", + ) + sketch = scad.constrain_parallel_rsketch( + sketch=sketch, + a="left", + b="right", + ) + sketch = scad.constrain_perpendicular_rsketch( + sketch=sketch, + a="bottom", + b="right", + ) + sketch = scad.constrain_equal_length_rsketch( + sketch=sketch, + a="bottom", + b="top", + ) + sketch = scad.constrain_equal_length_rsketch( + sketch=sketch, + a="left", + b="right", + ) + sketch = scad.constrain_distance_rsketch( + sketch=sketch, + a="p0", + b="p1", + value=width, + ) + sketch = scad.constrain_distance_rsketch( + sketch=sketch, + a="p0", + b="p3", + value=height, + ) + sketch = scad.constrain_fix_rsketch(sketch=sketch, target="p0") + return _promote_face(name=name, sketch=sketch) +@scad.requires_session def make_circle_profile(name, center_x, center_y, radius, circle_id): - sketch = scad.make_sketch_rsketch(name, plane="XY") - sketch = scad.add_point_rsketch(sketch, "center", center_x, center_y) - sketch = scad.add_circle_rsketch(sketch, circle_id, "center", radius) - sketch = scad.constrain_fix_rsketch(sketch, "center") - sketch = scad.constrain_radius_rsketch(sketch, circle_id, radius) - return _promote_face(name, sketch) + sketch = scad.make_sketch_rsketch(name=name, plane="XY") + sketch = scad.add_point_rsketch( + sketch=sketch, + point_id="center", + x=center_x, + y=center_y, + ) + sketch = scad.add_circle_rsketch( + sketch=sketch, + entity_id=circle_id, + center="center", + radius=radius, + ) + sketch = scad.constrain_fix_rsketch(sketch=sketch, target="center") + sketch = scad.constrain_radius_rsketch( + sketch=sketch, + circle=circle_id, + value=radius, + ) + return _promote_face(name=name, sketch=sketch) +@scad.requires_session def make_guided_diamond_profile(name, center_x, center_y, width, height, guide_gap): half_w = width / 2.0 half_h = height / 2.0 - sketch = scad.make_sketch_rsketch(name, plane="XY") + sketch = scad.make_sketch_rsketch(name=name, plane="XY") - sketch = scad.add_point_rsketch(sketch, "center", center_x, center_y) - sketch = scad.add_point_rsketch(sketch, "left", center_x - half_w, center_y) - sketch = scad.add_point_rsketch(sketch, "top", center_x, center_y + half_h) - sketch = scad.add_point_rsketch(sketch, "right", center_x + half_w, center_y) - sketch = scad.add_point_rsketch(sketch, "bottom", center_x, center_y - half_h) + for point_id, x, y in ( + ("center", center_x, center_y), + ("left", center_x - half_w, center_y), + ("top", center_x, center_y + half_h), + ("right", center_x + half_w, center_y), + ("bottom", center_x, center_y - half_h), + ("guide_upper_start", center_x - half_w, center_y + guide_gap), + ("guide_upper_end", center_x, center_y + half_h + guide_gap), + ("guide_lower_start", center_x + half_w, center_y - guide_gap), + ("guide_lower_end", center_x, center_y - half_h - guide_gap), + ): + sketch = scad.add_point_rsketch( + sketch=sketch, + point_id=point_id, + x=x, + y=y, + ) - sketch = scad.add_point_rsketch(sketch, "guide_upper_start", center_x - half_w, center_y + guide_gap) - sketch = scad.add_point_rsketch(sketch, "guide_upper_end", center_x, center_y + half_h + guide_gap) - sketch = scad.add_point_rsketch(sketch, "guide_lower_start", center_x + half_w, center_y - guide_gap) - sketch = scad.add_point_rsketch(sketch, "guide_lower_end", center_x, center_y - half_h - guide_gap) + for entity_id, start, end, construction in ( + ("bottom_left", "left", "bottom", False), + ("right_bottom", "bottom", "right", False), + ("top_right", "right", "top", False), + ("left_top", "top", "left", False), + ("guide_upper", "guide_upper_start", "guide_upper_end", True), + ("guide_lower", "guide_lower_start", "guide_lower_end", True), + ): + sketch = scad.add_line_rsketch( + sketch=sketch, + entity_id=entity_id, + start=start, + end=end, + construction=construction, + ) - sketch = scad.add_line_rsketch(sketch, "bottom_left", "left", "bottom") - sketch = scad.add_line_rsketch(sketch, "right_bottom", "bottom", "right") - sketch = scad.add_line_rsketch(sketch, "top_right", "right", "top") - sketch = scad.add_line_rsketch(sketch, "left_top", "top", "left") - sketch = scad.add_line_rsketch(sketch, "guide_upper", "guide_upper_start", "guide_upper_end", construction=True) - sketch = scad.add_line_rsketch(sketch, "guide_lower", "guide_lower_start", "guide_lower_end", construction=True) + sketch = scad.constrain_fix_rsketch(sketch=sketch, target="center") + for function, a, b, value in ( + (scad.constrain_distance_x_rsketch, "left", "center", half_w), + (scad.constrain_distance_y_rsketch, "left", "center", 0.0), + (scad.constrain_distance_x_rsketch, "center", "right", half_w), + (scad.constrain_distance_y_rsketch, "center", "right", 0.0), + (scad.constrain_distance_x_rsketch, "center", "top", 0.0), + (scad.constrain_distance_y_rsketch, "center", "top", half_h), + (scad.constrain_distance_x_rsketch, "bottom", "center", 0.0), + (scad.constrain_distance_y_rsketch, "bottom", "center", half_h), + ): + sketch = function(sketch=sketch, a=a, b=b, value=value) - sketch = scad.constrain_fix_rsketch(sketch, "center") - sketch = scad.constrain_distance_x_rsketch(sketch, "left", "center", half_w) - sketch = scad.constrain_distance_y_rsketch(sketch, "left", "center", 0.0) - sketch = scad.constrain_distance_x_rsketch(sketch, "center", "right", half_w) - sketch = scad.constrain_distance_y_rsketch(sketch, "center", "right", 0.0) - sketch = scad.constrain_distance_x_rsketch(sketch, "center", "top", 0.0) - sketch = scad.constrain_distance_y_rsketch(sketch, "center", "top", half_h) - sketch = scad.constrain_distance_x_rsketch(sketch, "bottom", "center", 0.0) - sketch = scad.constrain_distance_y_rsketch(sketch, "bottom", "center", half_h) + for function, a, b in ( + (scad.constrain_parallel_rsketch, "left_top", "right_bottom"), + (scad.constrain_parallel_rsketch, "top_right", "bottom_left"), + (scad.constrain_equal_length_rsketch, "left_top", "top_right"), + (scad.constrain_equal_length_rsketch, "top_right", "right_bottom"), + (scad.constrain_equal_length_rsketch, "right_bottom", "bottom_left"), + ): + sketch = function(sketch=sketch, a=a, b=b) - sketch = scad.constrain_parallel_rsketch(sketch, "left_top", "right_bottom") - sketch = scad.constrain_parallel_rsketch(sketch, "top_right", "bottom_left") - sketch = scad.constrain_equal_length_rsketch(sketch, "left_top", "top_right") - sketch = scad.constrain_equal_length_rsketch(sketch, "top_right", "right_bottom") - sketch = scad.constrain_equal_length_rsketch(sketch, "right_bottom", "bottom_left") + for function, a, b, value in ( + (scad.constrain_distance_x_rsketch, "left", "guide_upper_start", 0.0), + (scad.constrain_distance_y_rsketch, "left", "guide_upper_start", guide_gap), + (scad.constrain_distance_x_rsketch, "top", "guide_upper_end", 0.0), + (scad.constrain_distance_y_rsketch, "top", "guide_upper_end", guide_gap), + (scad.constrain_distance_x_rsketch, "guide_lower_start", "right", 0.0), + (scad.constrain_distance_y_rsketch, "guide_lower_start", "right", guide_gap), + (scad.constrain_distance_x_rsketch, "guide_lower_end", "bottom", 0.0), + (scad.constrain_distance_y_rsketch, "guide_lower_end", "bottom", guide_gap), + ): + sketch = function(sketch=sketch, a=a, b=b, value=value) - sketch = scad.constrain_distance_x_rsketch(sketch, "left", "guide_upper_start", 0.0) - sketch = scad.constrain_distance_y_rsketch(sketch, "left", "guide_upper_start", guide_gap) - sketch = scad.constrain_distance_x_rsketch(sketch, "top", "guide_upper_end", 0.0) - sketch = scad.constrain_distance_y_rsketch(sketch, "top", "guide_upper_end", guide_gap) - sketch = scad.constrain_distance_x_rsketch(sketch, "guide_lower_start", "right", 0.0) - sketch = scad.constrain_distance_y_rsketch(sketch, "guide_lower_start", "right", guide_gap) - sketch = scad.constrain_distance_x_rsketch(sketch, "guide_lower_end", "bottom", 0.0) - sketch = scad.constrain_distance_y_rsketch(sketch, "guide_lower_end", "bottom", guide_gap) - - sketch = scad.constrain_parallel_rsketch(sketch, "guide_upper", "guide_lower") - sketch = scad.constrain_parallel_rsketch(sketch, "guide_upper", "right_bottom") - sketch = scad.constrain_parallel_rsketch(sketch, "guide_lower", "left_top") - sketch = scad.constrain_equal_length_rsketch(sketch, "guide_upper", "right_bottom") - sketch = scad.constrain_equal_length_rsketch(sketch, "guide_lower", "left_top") - return _promote_face(name, sketch) + for function, a, b in ( + (scad.constrain_parallel_rsketch, "guide_upper", "guide_lower"), + (scad.constrain_parallel_rsketch, "guide_upper", "right_bottom"), + (scad.constrain_parallel_rsketch, "guide_lower", "left_top"), + (scad.constrain_equal_length_rsketch, "guide_upper", "right_bottom"), + (scad.constrain_equal_length_rsketch, "guide_lower", "left_top"), + ): + sketch = function(sketch=sketch, a=a, b=b) + return _promote_face(name=name, sketch=sketch) +@scad.requires_session def make_curve_guided_relief_profile(name, center_x, center_y, radius, guide_span): - sketch = scad.make_sketch_rsketch(name, plane="XY") + sketch = scad.make_sketch_rsketch(name=name, plane="XY") - sketch = scad.add_point_rsketch(sketch, "center", center_x, center_y) - sketch = scad.add_point_rsketch(sketch, "rim", center_x + radius, center_y) - sketch = scad.add_point_rsketch(sketch, "clearance_center", center_x, center_y) - sketch = scad.add_point_rsketch(sketch, "upper_left", center_x - guide_span, center_y + radius) - sketch = scad.add_point_rsketch(sketch, "upper_right", center_x + guide_span, center_y + radius) - sketch = scad.add_point_rsketch(sketch, "lower_left", center_x - guide_span, center_y - radius) - sketch = scad.add_point_rsketch(sketch, "lower_right", center_x + guide_span, center_y - radius) + for point_id, x, y in ( + ("center", center_x, center_y), + ("rim", center_x + radius, center_y), + ("clearance_center", center_x, center_y), + ("upper_left", center_x - guide_span, center_y + radius), + ("upper_right", center_x + guide_span, center_y + radius), + ("lower_left", center_x - guide_span, center_y - radius), + ("lower_right", center_x + guide_span, center_y - radius), + ): + sketch = scad.add_point_rsketch( + sketch=sketch, + point_id=point_id, + x=x, + y=y, + ) - sketch = scad.add_circle_rsketch(sketch, "relief", "center", radius) - sketch = scad.add_circle_rsketch(sketch, "clearance", "clearance_center", radius, construction=True) - sketch = scad.add_line_rsketch(sketch, "radius_probe", "center", "rim", construction=True) - sketch = scad.add_line_rsketch(sketch, "upper_rail", "upper_left", "upper_right", construction=True) - sketch = scad.add_line_rsketch(sketch, "lower_rail", "lower_left", "lower_right", construction=True) + sketch = scad.add_circle_rsketch( + sketch=sketch, + entity_id="relief", + center="center", + radius=radius, + ) + sketch = scad.add_circle_rsketch( + sketch=sketch, + entity_id="clearance", + center="clearance_center", + radius=radius, + construction=True, + ) + for entity_id, start, end in ( + ("radius_probe", "center", "rim"), + ("upper_rail", "upper_left", "upper_right"), + ("lower_rail", "lower_left", "lower_right"), + ): + sketch = scad.add_line_rsketch( + sketch=sketch, + entity_id=entity_id, + start=start, + end=end, + construction=True, + ) - sketch = scad.constrain_fix_rsketch(sketch, "center") - sketch = scad.constrain_radius_rsketch(sketch, "relief", radius) - sketch = scad.constrain_point_on_rsketch(sketch, "rim", "relief") - sketch = scad.constrain_horizontal_rsketch(sketch, "radius_probe") - sketch = scad.constrain_length_rsketch(sketch, "radius_probe", radius) + sketch = scad.constrain_fix_rsketch(sketch=sketch, target="center") + sketch = scad.constrain_radius_rsketch( + sketch=sketch, + circle="relief", + value=radius, + ) + sketch = scad.constrain_point_on_rsketch( + sketch=sketch, + point="rim", + entity="relief", + ) + sketch = scad.constrain_horizontal_rsketch( + sketch=sketch, + line="radius_probe", + ) + sketch = scad.constrain_length_rsketch( + sketch=sketch, + line="radius_probe", + value=radius, + ) - sketch = scad.constrain_concentric_rsketch(sketch, "relief", "clearance") - sketch = scad.constrain_equal_radius_rsketch(sketch, "relief", "clearance") - sketch = scad.constrain_horizontal_rsketch(sketch, "upper_rail") - sketch = scad.constrain_horizontal_rsketch(sketch, "lower_rail") - sketch = scad.constrain_tangent_rsketch(sketch, "upper_rail", "relief") - sketch = scad.constrain_tangent_rsketch(sketch, "lower_rail", "relief") + sketch = scad.constrain_concentric_rsketch( + sketch=sketch, + a="relief", + b="clearance", + ) + sketch = scad.constrain_equal_radius_rsketch( + sketch=sketch, + a="relief", + b="clearance", + ) + sketch = scad.constrain_horizontal_rsketch( + sketch=sketch, + line="upper_rail", + ) + sketch = scad.constrain_horizontal_rsketch( + sketch=sketch, + line="lower_rail", + ) + sketch = scad.constrain_tangent_rsketch( + sketch=sketch, + a="upper_rail", + b="relief", + ) + sketch = scad.constrain_tangent_rsketch( + sketch=sketch, + a="lower_rail", + b="relief", + ) - sketch = scad.constrain_distance_x_rsketch(sketch, "center", "upper_left", -guide_span) - sketch = scad.constrain_distance_x_rsketch(sketch, "center", "upper_right", guide_span) - sketch = scad.constrain_distance_x_rsketch(sketch, "center", "lower_left", -guide_span) - sketch = scad.constrain_distance_x_rsketch(sketch, "center", "lower_right", guide_span) - return _promote_face(name, sketch) + for a, b, value in ( + ("center", "upper_left", -guide_span), + ("center", "upper_right", guide_span), + ("center", "lower_left", -guide_span), + ("center", "lower_right", guide_span), + ): + sketch = scad.constrain_distance_x_rsketch( + sketch=sketch, + a=a, + b=b, + value=value, + ) + return _promote_face(name=name, sketch=sketch) -plate_w = scad.var("plate_w", 96.0, comment="plate width") -plate_h = scad.var("plate_h", 54.0, comment="plate height") -plate_t = scad.var("plate_t", 6.0, comment="plate thickness") -boss_r = scad.var("boss_r", 14.0, comment="raised center boss radius") -boss_h = scad.var("boss_h", 5.0, comment="raised center boss height") -bore_r = scad.var("bore_r", 5.0, comment="through bore radius") -mount_r = scad.var("mount_r", 3.0, comment="mounting hole radius") -margin_x = scad.var("mount_margin_x", 12.0, comment="mounting hole x margin") -margin_y = scad.var("mount_margin_y", 9.0, comment="mounting hole y margin") -slot_w = scad.var("slot_w", 34.0, comment="service slot width") -slot_h = scad.var("slot_h", 8.0, comment="service slot height") -slot_y = scad.var("slot_center_y", 16.0, comment="service slot center y") -diamond_w = scad.var("guided_diamond_w", 14.0, comment="guided diamond pocket width") -diamond_h = scad.var("guided_diamond_h", 8.0, comment="guided diamond pocket height") -diamond_guide_gap = scad.var("guided_diamond_guide_gap", 5.0, comment="parallel guide rail offset") -relief_r = scad.var("curve_relief_r", 4.0, comment="curve-guided relief radius") -relief_guide_span = scad.var("curve_relief_guide_span", 9.0, comment="curve relief construction rail half span") +@scad.model(graph_id="constrained_sketch") +def build_model(): + plate_w = scad.var(name="plate_w", default=96.0, comment="plate width") + plate_h = scad.var(name="plate_h", default=54.0, comment="plate height") + plate_t = scad.var(name="plate_t", default=6.0, comment="plate thickness") + boss_r = scad.var( + name="boss_r", + default=14.0, + comment="raised center boss radius", + ) + boss_h = scad.var( + name="boss_h", + default=5.0, + comment="raised center boss height", + ) + bore_r = scad.var(name="bore_r", default=5.0, comment="through bore radius") + mount_r = scad.var( + name="mount_r", + default=3.0, + comment="mounting hole radius", + ) + margin_x = scad.var( + name="mount_margin_x", + default=12.0, + comment="mounting hole x margin", + ) + margin_y = scad.var( + name="mount_margin_y", + default=9.0, + comment="mounting hole y margin", + ) + slot_w = scad.var(name="slot_w", default=34.0, comment="service slot width") + slot_h = scad.var(name="slot_h", default=8.0, comment="service slot height") + slot_y = scad.var( + name="slot_center_y", + default=16.0, + comment="service slot center y", + ) + diamond_w = scad.var( + name="guided_diamond_w", + default=14.0, + comment="guided diamond pocket width", + ) + diamond_h = scad.var( + name="guided_diamond_h", + default=8.0, + comment="guided diamond pocket height", + ) + diamond_guide_gap = scad.var( + name="guided_diamond_guide_gap", + default=5.0, + comment="parallel guide rail offset", + ) + relief_r = scad.var( + name="curve_relief_r", + default=4.0, + comment="curve-guided relief radius", + ) + relief_guide_span = scad.var( + name="curve_relief_guide_span", + default=9.0, + comment="curve relief construction rail half span", + ) -center_x = plate_w / 2.0 -center_y = plate_h / 2.0 + center_x = plate_w / 2.0 + center_y = plate_h / 2.0 - -with scad.GraphSession() as session: - plate_profile = make_rect_profile("plate_outline", 0.0, 0.0, plate_w, plate_h) - plate_profile = scad.apply_tag(plate_profile, "demo.profile.plate") - plate = scad.extrude_rsolid(plate_profile, (0.0, 0.0, 1.0), plate_t) - plate = scad.apply_tag(plate, "demo.body.base_plate") + plate_profile = make_rect_profile( + name="plate_outline", + x0=0.0, + y0=0.0, + width=plate_w, + height=plate_h, + ) + plate_profile = scad.apply_tag( + shape=plate_profile, + tag="demo.profile.plate", + ) + plate = scad.extrude_rsolid( + profile=plate_profile, + direction=(0.0, 0.0, 1.0), + distance=plate_t, + tag_prefix="constrained_sketch.plate", + result_tag="part.constrained_sketch.base_plate", + ) + plate = scad.apply_tag(shape=plate, tag="demo.body.base_plate") boss_profile = make_circle_profile( - "center_boss", - center_x, - center_y, - boss_r, - "boss_outer", + name="center_boss", + center_x=center_x, + center_y=center_y, + radius=boss_r, + circle_id="boss_outer", ) boss_overlap = 1.0 - boss = scad.extrude_rsolid(boss_profile, (0.0, 0.0, 1.0), boss_h + boss_overlap) - boss = scad.translate_shape(boss, (0.0, 0.0, plate_t - boss_overlap)) - boss = scad.apply_tag(boss, "demo.body.raised_boss") + boss = scad.extrude_rsolid( + profile=boss_profile, + direction=(0.0, 0.0, 1.0), + distance=boss_h + boss_overlap, + tag_prefix="constrained_sketch.boss", + result_tag="part.constrained_sketch.raised_boss", + ) + boss = scad.translate_shape( + shape=boss, + vector=(0.0, 0.0, plate_t - boss_overlap), + ) + boss = scad.apply_tag(shape=boss, tag="demo.body.raised_boss") body = scad.union_rsolid(plate, boss, glue=False) bore_profile = make_circle_profile( - "center_bore", - center_x, - center_y, - bore_r, - "bore", + name="center_bore", + center_x=center_x, + center_y=center_y, + radius=bore_r, + circle_id="bore", ) bore_cutter = scad.extrude_rsolid( - bore_profile, - (0.0, 0.0, 1.0), - plate_t + boss_h + 2.0, + profile=bore_profile, + direction=(0.0, 0.0, 1.0), + distance=plate_t + boss_h + 2.0, + tag_prefix="constrained_sketch.bore.cutter", + result_tag="tool.constrained_sketch.center_bore", + ) + bore_cutter = scad.translate_shape( + shape=bore_cutter, + vector=(0.0, 0.0, -1.0), ) - bore_cutter = scad.translate_shape(bore_cutter, (0.0, 0.0, -1.0)) slot_profile = make_rect_profile( - "service_slot", - center_x - slot_w / 2.0, - slot_y - slot_h / 2.0, - slot_w, - slot_h, + name="service_slot", + x0=center_x - slot_w / 2.0, + y0=slot_y - slot_h / 2.0, + width=slot_w, + height=slot_h, + ) + slot_cutter = scad.extrude_rsolid( + profile=slot_profile, + direction=(0.0, 0.0, 1.0), + distance=plate_t + 2.0, + tag_prefix="constrained_sketch.slot.cutter", + result_tag="tool.constrained_sketch.service_slot", + ) + slot_cutter = scad.translate_shape( + shape=slot_cutter, + vector=(0.0, 0.0, -1.0), ) - slot_cutter = scad.extrude_rsolid(slot_profile, (0.0, 0.0, 1.0), plate_t + 2.0) - slot_cutter = scad.translate_shape(slot_cutter, (0.0, 0.0, -1.0)) diamond_profile = make_guided_diamond_profile( - "guided_diamond_pocket", - plate_w - 24.0, - plate_h - 18.0, - diamond_w, - diamond_h, - diamond_guide_gap, + name="guided_diamond_pocket", + center_x=plate_w - 24.0, + center_y=plate_h - 18.0, + width=diamond_w, + height=diamond_h, + guide_gap=diamond_guide_gap, + ) + diamond_cutter = scad.extrude_rsolid( + profile=diamond_profile, + direction=(0.0, 0.0, 1.0), + distance=plate_t + 2.0, + tag_prefix="constrained_sketch.diamond.cutter", + result_tag="tool.constrained_sketch.diamond_pocket", + ) + diamond_cutter = scad.translate_shape( + shape=diamond_cutter, + vector=(0.0, 0.0, -1.0), ) - diamond_cutter = scad.extrude_rsolid(diamond_profile, (0.0, 0.0, 1.0), plate_t + 2.0) - diamond_cutter = scad.translate_shape(diamond_cutter, (0.0, 0.0, -1.0)) curve_relief_profile = make_curve_guided_relief_profile( - "curve_guided_relief", - plate_w / 3.0, - plate_h - 12.0, - relief_r, - relief_guide_span, + name="curve_guided_relief", + center_x=plate_w / 3.0, + center_y=plate_h - 12.0, + radius=relief_r, + guide_span=relief_guide_span, + ) + curve_relief_cutter = scad.extrude_rsolid( + profile=curve_relief_profile, + direction=(0.0, 0.0, 1.0), + distance=plate_t + 2.0, + tag_prefix="constrained_sketch.curve_relief.cutter", + result_tag="tool.constrained_sketch.curve_relief", + ) + curve_relief_cutter = scad.translate_shape( + shape=curve_relief_cutter, + vector=(0.0, 0.0, -1.0), ) - curve_relief_cutter = scad.extrude_rsolid(curve_relief_profile, (0.0, 0.0, 1.0), plate_t + 2.0) - curve_relief_cutter = scad.translate_shape(curve_relief_cutter, (0.0, 0.0, -1.0)) mount_centers = [ ("mount_sw", margin_x, margin_y), @@ -285,13 +569,27 @@ with scad.GraphSession() as session: ] mount_cutters = [] for name, x_pos, y_pos in mount_centers: - mount_profile = make_circle_profile(name, x_pos, y_pos, mount_r, "mount_hole") - mount_cutter = scad.extrude_rsolid( - mount_profile, - (0.0, 0.0, 1.0), - plate_t + 2.0, + mount_tag = name.replace("_", ".") + mount_profile = make_circle_profile( + name=name, + center_x=x_pos, + center_y=y_pos, + radius=mount_r, + circle_id="mount_hole", + ) + mount_cutter = scad.extrude_rsolid( + profile=mount_profile, + direction=(0.0, 0.0, 1.0), + distance=plate_t + 2.0, + tag_prefix=f"constrained_sketch.{mount_tag}.cutter", + result_tag=f"tool.constrained_sketch.{mount_tag}", + ) + mount_cutters.append( + scad.translate_shape( + shape=mount_cutter, + vector=(0.0, 0.0, -1.0), + ) ) - mount_cutters.append(scad.translate_shape(mount_cutter, (0.0, 0.0, -1.0))) part = scad.cut_rsolid( body, @@ -302,96 +600,126 @@ with scad.GraphSession() as session: mount_cutters, skip_non_intersecting=False, ) - part = scad.apply_tag(part, "demo.constrained_sketch_bracket") + part = scad.apply_tag( + shape=part, + tag="demo.constrained_sketch_bracket", + ) + scad.capture_result(value=part) + return { + "part": part, + "plate_profile": plate_profile, + "diamond_profile": diamond_profile, + "curve_relief_profile": curve_relief_profile, + } -model_json = scad.export_model_json(session) -MODEL_JSON_PATH.write_text(model_json, encoding="utf-8") -rebuilt = scad.replay_model_json(model_json) -scad.export_step(rebuilt, str(STEP_PATH)) +def main() -> None: + OUT.mkdir(parents=True, exist_ok=True) + result = build_model() + data = result.value + MODEL_JSON_PATH.write_text(result.model_json, encoding="utf-8") -freecad_cmd = str(FREECAD_CMD) if FREECAD_CMD.exists() else None -scad.translator.freecad_translator.translate_model_json_to_fcstd( - model_json, - str(FCSTD_PATH), - document_name="SimpleCADConstrainedSketchDemo", - freecad_cmd=freecad_cmd, -) + rebuilt = result.replay() + scad.export_step(shapes=rebuilt, filename=str(STEP_PATH)) -payload = json.loads(model_json) -ops = [node["op"] for node in payload["graph"]["nodes"]] -promotion_nodes = [ - node - for node in payload["graph"]["nodes"] - if node["op"] in {"make_face_from_sketch_rface", "make_wire_from_sketch_rwire"} -] -diamond_promotion = next( - node - for node in promotion_nodes - if node["params"]["sketch"].get("name") == "guided_diamond_pocket" -) -diamond_constraints = diamond_promotion["params"]["sketch"].get("constraints", []) -curve_promotion = next( - node - for node in promotion_nodes - if node["params"]["sketch"].get("name") == "curve_guided_relief" -) -curve_constraints = curve_promotion["params"]["sketch"].get("constraints", []) -sketch_entity_tags = sorted( - tag - for edge in scad.ql.select(plate_profile.get_edges()).where( - scad.ql.tag("sketch_entity.*") - ).all() - for tag in scad.list_tags(edge) - if tag.startswith("sketch_entity.") -) -diamond_entity_tags = sorted( - tag - for edge in scad.ql.select(diamond_profile.get_edges()).where( - scad.ql.tag("sketch_entity.*") - ).all() - for tag in scad.list_tags(edge) - if tag.startswith("sketch_entity.") -) -curve_entity_tags = sorted( - tag - for edge in scad.ql.select(curve_relief_profile.get_edges()).where( - scad.ql.tag("sketch_entity.*") - ).all() - for tag in scad.list_tags(edge) - if tag.startswith("sketch_entity.") -) + freecad_cmd = str(FREECAD_CMD) if FREECAD_CMD.exists() else None + scad.translator.freecad_translator.translate_model_json_to_fcstd( + json_str=result.model_json, + output_path=str(FCSTD_PATH), + document_name="SimpleCADConstrainedSketchDemo", + freecad_cmd=freecad_cmd, + ) -print("graph_nodes", len(ops)) -print("sketch_ops", sum(1 for op in ops if "sketch" in op)) -print("promotion_nodes", len(promotion_nodes)) -print( - "promotion_solve_snapshots", - sum(1 for node in promotion_nodes if "solve_snapshot" in node.get("params", {})), -) -print("contains_public_solve_node", "make_solve_sketch_rsketchresult" in ops) -print("plate_sketch_entity_tags", sketch_entity_tags) -print("diamond_sketch_entity_tags", diamond_entity_tags) -print("diamond_constraint_count", len(diamond_constraints)) -print( - "diamond_parallel_equal_constraints", - sum( - 1 - for constraint in diamond_constraints - if constraint.get("kind") in {"parallel", "equal_length"} - ), -) -print("curve_sketch_entity_tags", curve_entity_tags) -print("curve_constraint_count", len(curve_constraints)) -print( - "curve_tangent_equal_radius_constraints", - sum( - 1 - for constraint in curve_constraints - if constraint.get("kind") in {"tangent", "equal_radius", "concentric", "point_on"} - ), -) -print("volume", round(part.get_volume(), 3)) -print("wrote", MODEL_JSON_PATH) -print("wrote", STEP_PATH) -print("wrote", FCSTD_PATH) + payload = json.loads(result.model_json) + ops = [node["op"] for node in payload["graph"]["nodes"]] + promotion_nodes = [ + node + for node in payload["graph"]["nodes"] + if node["op"] + in {"make_face_from_sketch_rface", "make_wire_from_sketch_rwire"} + ] + diamond_promotion = next( + node + for node in promotion_nodes + if node["params"]["sketch"].get("name") == "guided_diamond_pocket" + ) + diamond_constraints = diamond_promotion["params"]["sketch"].get( + "constraints", + [], + ) + curve_promotion = next( + node + for node in promotion_nodes + if node["params"]["sketch"].get("name") == "curve_guided_relief" + ) + curve_constraints = curve_promotion["params"]["sketch"].get( + "constraints", + [], + ) + sketch_entity_tags = sorted( + tag + for edge in scad.ql.select(items=data["plate_profile"].get_edges()) + .where(scad.ql.tag(pattern="sketch_entity.*")) + .all() + for tag in scad.list_tags(shape=edge) + if tag.startswith("sketch_entity.") + ) + diamond_entity_tags = sorted( + tag + for edge in scad.ql.select(items=data["diamond_profile"].get_edges()) + .where(scad.ql.tag(pattern="sketch_entity.*")) + .all() + for tag in scad.list_tags(shape=edge) + if tag.startswith("sketch_entity.") + ) + curve_entity_tags = sorted( + tag + for edge in scad.ql.select(items=data["curve_relief_profile"].get_edges()) + .where(scad.ql.tag(pattern="sketch_entity.*")) + .all() + for tag in scad.list_tags(shape=edge) + if tag.startswith("sketch_entity.") + ) + + print("graph_nodes", len(ops)) + print("sketch_ops", sum(1 for op in ops if "sketch" in op)) + print("promotion_nodes", len(promotion_nodes)) + print( + "promotion_solve_snapshots", + sum( + 1 + for node in promotion_nodes + if "solve_snapshot" in node.get("params", {}) + ), + ) + print("contains_public_solve_node", "make_solve_sketch_rsketchresult" in ops) + print("plate_sketch_entity_tags", sketch_entity_tags) + print("diamond_sketch_entity_tags", diamond_entity_tags) + print("diamond_constraint_count", len(diamond_constraints)) + print( + "diamond_parallel_equal_constraints", + sum( + 1 + for constraint in diamond_constraints + if constraint.get("kind") in {"parallel", "equal_length"} + ), + ) + print("curve_sketch_entity_tags", curve_entity_tags) + print("curve_constraint_count", len(curve_constraints)) + print( + "curve_tangent_equal_radius_constraints", + sum( + 1 + for constraint in curve_constraints + if constraint.get("kind") + in {"tangent", "equal_radius", "concentric", "point_on"} + ), + ) + print("volume", round(data["part"].get_volume(), 3)) + print("wrote", MODEL_JSON_PATH) + print("wrote", STEP_PATH) + print("wrote", FCSTD_PATH) + + +if __name__ == "__main__": + main() diff --git a/SimpleCADAPI/examples/09_naca0016_blade_freecad.py b/SimpleCADAPI/examples/09_naca0016_blade_freecad.py index c43bedc..4336b75 100644 --- a/SimpleCADAPI/examples/09_naca0016_blade_freecad.py +++ b/SimpleCADAPI/examples/09_naca0016_blade_freecad.py @@ -5,6 +5,7 @@ Run from the repository root with: Generated files: examples/out/naca0016_blade/naca0016_blade.model.json + examples/out/naca0016_blade/naca0016_blade.session.json examples/out/naca0016_blade/naca0016_blade.step examples/out/naca0016_blade/naca0016_blade.fcstd @@ -27,26 +28,38 @@ DEFAULT_OUTPUT_DIR = Path("examples/out/naca0016_blade") DEFAULT_FREECAD_CMD = Path("/Applications/FreeCAD.app/Contents/Resources/bin/freecadcmd") -def build_blade(output_dir: Path, *, freecad_cmd: Path | None = DEFAULT_FREECAD_CMD) -> dict: +@scad.model(graph_id="naca0016_blade") +def build_blade() -> scad.ModelResult: + blade = scad.make_naca_propeller_blade_rsolid( + blade_length=4.0, + root_chord=1.25, + tip_chord=0.35, + total_twist_angle=36.0, + num_sections=6, + ) + blade = scad.apply_tag(shape=blade, tag="role.naca0016.blade") + blade = scad.apply_tag(shape=blade, tag="part.naca0016.blade") + scad.capture_result(value=blade) + return blade + + +def write_blade_artifacts( + output_dir: Path, + *, + freecad_cmd: Path | None = DEFAULT_FREECAD_CMD, +) -> dict: output_dir.mkdir(parents=True, exist_ok=True) model_json_path = output_dir / "naca0016_blade.model.json" + session_json_path = output_dir / "naca0016_blade.session.json" step_path = output_dir / "naca0016_blade.step" fcstd_path = output_dir / "naca0016_blade.fcstd" - with scad.GraphSession() as session: - blade = scad.make_naca_propeller_blade_rsolid( - blade_length=4.0, - root_chord=1.25, - tip_chord=0.35, - total_twist_angle=36.0, - num_sections=6, - ) - blade = scad.apply_tag(blade, "role.naca0016.blade") - - model_json = scad.export_model_json(session) - payload = json.loads(model_json) - model_json_path.write_text(model_json, encoding="utf-8") - scad.export_step(blade, str(step_path)) + result = build_blade() + blade = result.value + payload = json.loads(result.model_json) + model_json_path.write_text(result.model_json, encoding="utf-8") + session_json_path.write_text(result.session_json, encoding="utf-8") + scad.export_step(shapes=blade, filename=str(step_path)) bspline_nodes = [ node for node in payload["graph"]["nodes"] if node.get("op") == "make_spline_redge" @@ -63,12 +76,13 @@ def build_blade(output_dir: Path, *, freecad_cmd: Path | None = DEFAULT_FREECAD_ print("bspline_knot_counts", knot_counts[:6]) print("volume", round(blade.get_volume(), 6)) print("wrote", model_json_path) + print("wrote", session_json_path) print("wrote", step_path) if freecad_cmd is not None and freecad_cmd.exists(): scad.translator.freecad_translator.translate_model_json_to_fcstd( - model_json, - str(fcstd_path), + json_str=result.model_json, + output_path=str(fcstd_path), document_name="NACA0016Blade", freecad_cmd=str(freecad_cmd), ) @@ -89,7 +103,7 @@ def main() -> None: help="FreeCADCmd path used to write .fcstd; skipped if the path does not exist.", ) args = parser.parse_args() - build_blade(args.output_dir, freecad_cmd=args.freecad_cmd) + write_blade_artifacts(args.output_dir, freecad_cmd=args.freecad_cmd) if __name__ == "__main__": diff --git a/SimpleCADAPI/examples/10_part_assembly.py b/SimpleCADAPI/examples/10_part_assembly.py index c04ee00..ad3f27d 100644 --- a/SimpleCADAPI/examples/10_part_assembly.py +++ b/SimpleCADAPI/examples/10_part_assembly.py @@ -9,53 +9,173 @@ import simplecadapi as scad from simplecadapi import ql -OUT_DIR = Path("examples/out/hydraulic_rod_assembly") +OUT_DIR = Path(__file__).resolve().parent / "out" / "hydraulic_rod_assembly" +@scad.model(graph_id="hydraulic_rod_assembly", export_dir=OUT_DIR) def build_hydraulic_rod_assembly(): flange_holes = [ - (0.0, 16.0), - (16.0, 0.0), - (0.0, -16.0), - (-16.0, 0.0), + ("zplus", 0.0, 16.0), + ("yplus", 16.0, 0.0), + ("zminus", 0.0, -16.0), + ("yminus", -16.0, 0.0), ] - with scad.GraphSession() as session: - barrel = scad.make_cylinder_rsolid( + @scad.requires_session + def _build_in_session(): + def _named_cylinder( + *, + radius: float, + height: float, + bottom_face_center: tuple[float, float, float], + axis: tuple[float, float, float], + start_face_tag: str, + end_face_tag: str, + side_faces_tag: str, + tag_prefix: str, + result_tag: str, + ): + return scad.make_cylinder_rsolid( + radius=radius, + height=height, + bottom_face_center=bottom_face_center, + axis=axis, + tag_prefix=tag_prefix, + result_tag=result_tag, + start_face_tag=start_face_tag, + end_face_tag=end_face_tag, + side_face_tag=side_faces_tag, + ) + + def _named_box( + *, + width: float, + height: float, + depth: float, + bottom_face_center: tuple[float, float, float], + bottom_face_tag: str, + top_face_tag: str, + side_faces_tag: str, + tag_prefix: str, + result_tag: str, + ): + profile = scad.make_rectangle_rface( + # Rectangle profile axes are Y/X for a +Z normal; swap the + # dimensions to preserve make_box_rsolid's global X/Y layout. + width=height, + height=width, + center=bottom_face_center, + tag_prefix=f"{tag_prefix}.profile", + edge_tags=("bottom", "right", "top", "left"), + ) + return scad.extrude_rsolid( + profile=profile, + direction=(0.0, 0.0, 1.0), + distance=depth, + tag_prefix=tag_prefix, + result_tag=result_tag, + start_face_tag=bottom_face_tag, + end_face_tag=top_face_tag, + side_faces_tag=side_faces_tag, + ) + + def _require_complete_face_naming(solid, prefix: str) -> None: + unnamed = [ + index + for index, face in enumerate(solid.get_faces()) + if not any( + tag.startswith(prefix) + for tag in scad.list_tags(face, scope="local") + ) + ] + if unnamed: + raise RuntimeError( + f"{prefix} final face naming is incomplete at indices {unnamed}" + ) + + def _require_shared_edge(solid, first_face_tag: str, second_face_tag: str): + first = ql.faces().where(ql.tag(first_face_tag)).exactly(1) + second = ql.faces().where(ql.tag(second_face_tag)).exactly(1) + shared = first.shared_boundary(second).incident_face_count(exactly=2) + incident = ( + ql.edges() + .incident_to(first, second, distinct=True) + .incident_face_count(exactly=2) + ) + shared_edge = shared.exactly(1).resolve(solid)[0] + incident_edge = incident.exactly(1).resolve(solid)[0] + if shared_edge.topo_id != incident_edge.topo_id: + raise RuntimeError( + f"Face pair {first_face_tag!r}, {second_face_tag!r} " + "did not resolve the same shared Edge" + ) + return shared_edge + + barrel = _named_cylinder( radius=16.0, height=120.0, bottom_face_center=(-60.0, 0.0, 0.0), axis=(1.0, 0.0, 0.0), + start_face_tag="sleeve.barrel.face.rear", + end_face_tag="sleeve.barrel.face.front", + side_faces_tag="sleeve.barrel.face.outer", + tag_prefix="hydraulic.sleeve.barrel", + result_tag="part.hydraulic.sleeve.barrel", ) - rod_gland_flange = scad.make_cylinder_rsolid( + rod_gland_flange = _named_cylinder( radius=22.0, height=12.0, bottom_face_center=(50.0, 0.0, 0.0), axis=(1.0, 0.0, 0.0), + start_face_tag="sleeve.gland.face.shoulder", + end_face_tag="sleeve.gland.face.mount", + side_faces_tag="sleeve.gland.face.outer", + tag_prefix="hydraulic.sleeve.gland.flange", + result_tag="part.hydraulic.sleeve.gland.flange", ) - rod_gland_nose = scad.make_cylinder_rsolid( + rod_gland_nose = _named_cylinder( radius=13.0, height=10.0, bottom_face_center=(58.0, 0.0, 0.0), axis=(1.0, 0.0, 0.0), + start_face_tag="sleeve.gland.nose.face.rear", + end_face_tag="sleeve.gland.nose.face.front", + side_faces_tag="sleeve.gland.nose.face.outer", + tag_prefix="hydraulic.sleeve.gland.nose", + result_tag="part.hydraulic.sleeve.gland.nose", ) - base_cap = scad.make_cylinder_rsolid( + base_cap = _named_cylinder( radius=18.0, height=12.0, bottom_face_center=(-66.0, 0.0, 0.0), axis=(1.0, 0.0, 0.0), + start_face_tag="sleeve.base.cap.face.rear", + end_face_tag="sleeve.base.cap.face.front", + side_faces_tag="sleeve.base.cap.face.outer", + tag_prefix="hydraulic.sleeve.base.cap", + result_tag="part.hydraulic.sleeve.base.cap", ) - rear_eye = scad.make_cylinder_rsolid( + rear_eye = _named_cylinder( radius=14.0, height=12.0, bottom_face_center=(-80.0, -6.0, 0.0), axis=(0.0, 1.0, 0.0), + start_face_tag="sleeve.rear.eye.face.ymin", + end_face_tag="sleeve.rear.eye.face.ymax", + side_faces_tag="sleeve.rear.eye.face.outer", + tag_prefix="hydraulic.sleeve.rear.eye", + result_tag="part.hydraulic.sleeve.rear.eye", ) - rear_eye_neck = scad.make_box_rsolid( - 18.0, - 14.0, - 16.0, + rear_eye_neck = _named_box( + width=18.0, + height=14.0, + depth=16.0, bottom_face_center=(-68.0, 0.0, -8.0), + bottom_face_tag="sleeve.rear.neck.face.bottom", + top_face_tag="sleeve.rear.neck.face.top", + side_faces_tag="sleeve.rear.neck.face.side", + tag_prefix="hydraulic.sleeve.rear.neck", + result_tag="part.hydraulic.sleeve.rear.neck", ) sleeve_raw = scad.union_rsolid( barrel, @@ -66,70 +186,112 @@ def build_hydraulic_rod_assembly(): rear_eye_neck, glue=False, ) - sleeve_solid = scad.cut_rsolid( - sleeve_raw, - scad.make_cylinder_rsolid( - radius=10.5, - height=136.0, - bottom_face_center=(-68.0, 0.0, 0.0), + barrel_bore = _named_cylinder( + radius=10.5, + height=136.0, + bottom_face_center=(-68.0, 0.0, 0.0), + axis=(1.0, 0.0, 0.0), + start_face_tag="sleeve.barrel.bore.face.rear", + end_face_tag="sleeve.barrel.bore.face.front", + side_faces_tag="sleeve.barrel.bore.face.wall", + tag_prefix="hydraulic.sleeve.barrel.bore", + result_tag="tool.hydraulic.sleeve.barrel.bore", + ) + sleeve_solid = scad.cut_rsolid(sleeve_raw, barrel_bore) + rear_eye_pin_bore = _named_cylinder( + radius=4.6, + height=26.0, + bottom_face_center=(-80.0, -13.0, 0.0), + axis=(0.0, 1.0, 0.0), + start_face_tag="sleeve.rear.eye.pin.face.ymin", + end_face_tag="sleeve.rear.eye.pin.face.ymax", + side_faces_tag="sleeve.rear.eye.pin.face.wall", + tag_prefix="hydraulic.sleeve.rear.eye.pin.bore", + result_tag="tool.hydraulic.sleeve.rear.eye.pin_bore", + ) + sleeve_solid = scad.cut_rsolid(sleeve_solid, rear_eye_pin_bore) + for hole_id, y, z in flange_holes: + bolt_hole = _named_cylinder( + radius=1.8, + height=16.0, + bottom_face_center=(48.0, y, z), axis=(1.0, 0.0, 0.0), - ), - ) - sleeve_solid = scad.cut_rsolid( - sleeve_solid, - scad.make_cylinder_rsolid( - radius=4.6, - height=26.0, - bottom_face_center=(-80.0, -13.0, 0.0), - axis=(0.0, 1.0, 0.0), - ), - ) - for y, z in flange_holes: + start_face_tag=f"sleeve.gland.bolt.{hole_id}.face.rear", + end_face_tag=f"sleeve.gland.bolt.{hole_id}.face.front", + side_faces_tag=f"sleeve.gland.bolt.{hole_id}.face.wall", + tag_prefix=f"hydraulic.sleeve.gland.bolt.{hole_id}", + result_tag=f"tool.hydraulic.sleeve.gland.bolt.{hole_id}", + ) sleeve_solid = scad.cut_rsolid( sleeve_solid, - scad.make_cylinder_rsolid( - radius=1.8, - height=16.0, - bottom_face_center=(48.0, y, z), - axis=(1.0, 0.0, 0.0), - ), + bolt_hole, ) - piston_land_left = scad.make_cylinder_rsolid( + piston_land_left = _named_cylinder( radius=10.0, height=3.2, bottom_face_center=(-6.0, 0.0, 0.0), axis=(1.0, 0.0, 0.0), + start_face_tag="rod.piston.land.left.face.rear", + end_face_tag="rod.piston.land.left.face.front", + side_faces_tag="rod.piston.land.left.face.outer", + tag_prefix="hydraulic.rod.piston.land.left", + result_tag="part.hydraulic.rod.piston.land.left", ) - piston_seal_groove = scad.make_cylinder_rsolid( + piston_seal_groove = _named_cylinder( radius=9.0, height=6.0, bottom_face_center=(-3.0, 0.0, 0.0), axis=(1.0, 0.0, 0.0), + start_face_tag="rod.piston.groove.face.rear", + end_face_tag="rod.piston.groove.face.front", + side_faces_tag="rod.piston.groove.face.outer", + tag_prefix="hydraulic.rod.piston.groove", + result_tag="part.hydraulic.rod.piston.groove", ) - piston_land_right = scad.make_cylinder_rsolid( + piston_land_right = _named_cylinder( radius=10.0, height=3.2, bottom_face_center=(2.6, 0.0, 0.0), axis=(1.0, 0.0, 0.0), + start_face_tag="rod.piston.land.right.face.rear", + end_face_tag="rod.piston.land.right.face.front", + side_faces_tag="rod.piston.land.right.face.outer", + tag_prefix="hydraulic.rod.piston.land.right", + result_tag="part.hydraulic.rod.piston.land.right", ) - chrome_rod = scad.make_cylinder_rsolid( + chrome_rod = _named_cylinder( radius=6.5, height=132.0, bottom_face_center=(3.0, 0.0, 0.0), axis=(1.0, 0.0, 0.0), + start_face_tag="rod.shaft.face.piston", + end_face_tag="rod.shaft.face.eye", + side_faces_tag="rod.shaft.face.outer", + tag_prefix="hydraulic.rod.shaft", + result_tag="part.hydraulic.rod.shaft", ) - rod_eye = scad.make_cylinder_rsolid( + rod_eye = _named_cylinder( radius=13.0, height=9.0, bottom_face_center=(143.0, -4.5, 0.0), axis=(0.0, 1.0, 0.0), + start_face_tag="rod.eye.face.ymin", + end_face_tag="rod.eye.face.ymax", + side_faces_tag="rod.eye.face.outer", + tag_prefix="hydraulic.rod.eye", + result_tag="part.hydraulic.rod.eye", ) - rod_eye_neck = scad.make_box_rsolid( - 20.0, - 8.0, - 13.0, + rod_eye_neck = _named_box( + width=20.0, + height=8.0, + depth=13.0, bottom_face_center=(130.0, 0.0, -6.5), + bottom_face_tag="rod.eye.neck.face.bottom", + top_face_tag="rod.eye.neck.face.top", + side_faces_tag="rod.eye.neck.face.side", + tag_prefix="hydraulic.rod.eye.neck", + result_tag="part.hydraulic.rod.eye.neck", ) piston_rod_raw = scad.union_rsolid( piston_land_left, @@ -140,23 +302,56 @@ def build_hydraulic_rod_assembly(): rod_eye_neck, glue=False, ) - rod_eye_pin_hole = scad.make_cylinder_rsolid( + rod_eye_pin_hole = _named_cylinder( radius=5.5, height=13.0, bottom_face_center=(143.0, -6.5, 0.0), axis=(0.0, 1.0, 0.0), + start_face_tag="rod.eye.pin.face.ymin", + end_face_tag="rod.eye.pin.face.ymax", + side_faces_tag="rod.eye.pin.face.wall", + tag_prefix="hydraulic.rod.eye.pin.bore", + result_tag="tool.hydraulic.rod.eye.pin_bore", ) piston_rod_solid = scad.cut_rsolid(piston_rod_raw, rod_eye_pin_hole) + sleeve_solid = scad.apply_tag( + shape=sleeve_solid, + tag="part.hydraulic.sleeve.finished", + ) + piston_rod_solid = scad.apply_tag( + shape=piston_rod_solid, + tag="part.hydraulic.rod.finished", + ) + + _require_complete_face_naming(sleeve_solid, "sleeve.") + _require_complete_face_naming(piston_rod_solid, "rod.") + _require_shared_edge( + sleeve_solid, + "sleeve.gland.face.mount", + "sleeve.gland.face.outer", + ) + _require_shared_edge( + sleeve_solid, + "sleeve.gland.nose.face.front", + "sleeve.barrel.bore.face.wall", + ) + for hole_id in ("zplus", "yplus"): + _require_shared_edge( + sleeve_solid, + "sleeve.gland.face.mount", + f"sleeve.gland.bolt.{hole_id}.face.wall", + ) + black_oxide_steel = scad.make_material_rmaterial( - "black_oxide_steel", + material_id="black_oxide_steel", name="Black oxide steel", density=7.85e-6, density_unit="kg/mm^3", color=(0.10, 0.11, 0.12), ) chrome_steel = scad.make_material_rmaterial( - "chrome_plated_steel", + material_id="chrome_plated_steel", name="Chrome plated steel", density=7.85e-6, density_unit="kg/mm^3", @@ -164,89 +359,117 @@ def build_hydraulic_rod_assembly(): ) sleeve_part = scad.make_part_rpart( - "outer_sleeve", sleeve_solid, name="Outer sleeve with clevis and gland" + part_id="outer_sleeve", + body=sleeve_solid, + name="Outer sleeve with clevis and gland", + ) + sleeve_part = scad.assign_material_rpart( + part=sleeve_part, material=black_oxide_steel + ) + sleeve_end_face = ( + ql.faces() + .where(ql.tag("sleeve.gland.face.mount")) + .exactly(1) + .resolve(sleeve_solid)[0] + ) + sleeve_connector = scad.make_face_connector_rconnector( + connector_id="slide_axis", face=sleeve_end_face + ) + sleeve_part = scad.add_connector_rpart( + part=sleeve_part, connector=sleeve_connector ) - sleeve_part = scad.assign_material_rpart(sleeve_part, black_oxide_steel) - sleeve_faces = ql.faces().resolve(sleeve_solid) - sleeve_end_face = None - for f in sleeve_faces: - n = f.get_normal_at() - if abs(abs(n.x) - 1.0) < 0.01 and f.get_area() < 1000.0: - sleeve_end_face = f - break - sleeve_connector = scad.make_face_connector_rconnector("slide_axis", sleeve_end_face) - sleeve_part = scad.add_connector_rpart(sleeve_part, sleeve_connector) piston_rod_part = scad.make_part_rpart( - "piston_rod", piston_rod_solid, name="Inner piston rod with eye end" + part_id="piston_rod", + body=piston_rod_solid, + name="Inner piston rod with eye end", + ) + piston_rod_part = scad.assign_material_rpart( + part=piston_rod_part, material=chrome_steel + ) + rod_end_face = ( + ql.faces() + .where(ql.tag("rod.piston.land.left.face.rear")) + .exactly(1) + .resolve(piston_rod_solid)[0] ) - piston_rod_part = scad.assign_material_rpart(piston_rod_part, chrome_steel) - rod_faces = ql.faces().resolve(piston_rod_solid) - rod_end_face = None - for f in rod_faces: - n = f.get_normal_at() - if abs(abs(n.x) - 1.0) < 0.01 and f.get_area() < 1000.0: - rod_end_face = f - break sleeve_normal = sleeve_end_face.get_normal_at() rod_normal = rod_end_face.get_normal_at() rod_flip = (sleeve_normal.x * rod_normal.x) < 0 - rod_connector = scad.make_face_connector_rconnector("slide_axis", rod_end_face, flip=rod_flip) - piston_rod_part = scad.add_connector_rpart(piston_rod_part, rod_connector) + rod_connector = scad.make_face_connector_rconnector( + connector_id="slide_axis", face=rod_end_face, flip=rod_flip + ) + piston_rod_part = scad.add_connector_rpart( + part=piston_rod_part, connector=rod_connector + ) hydraulic_assembly = scad.make_assembly_rassembly( - "hydraulic_rod_assembly", name="Hydraulic rod assembly" + assembly_id="hydraulic_rod_assembly", name="Hydraulic rod assembly" ) hydraulic_assembly = scad.add_component_rassembly( - hydraulic_assembly, - sleeve_part, + assembly=hydraulic_assembly, + item=sleeve_part, component_id="outer_sleeve", placement=scad.identity_placement_rplacement(), ) hydraulic_assembly = scad.add_component_rassembly( - hydraulic_assembly, - piston_rod_part, + assembly=hydraulic_assembly, + item=piston_rod_part, component_id="inner_piston_rod", placement=scad.identity_placement_rplacement(), ) hydraulic_assembly = scad.ground_component_rassembly( - hydraulic_assembly, "outer_sleeve" + assembly=hydraulic_assembly, component_id="outer_sleeve" ) hydraulic_assembly = scad.add_prismatic_constraint_rassembly( - hydraulic_assembly, - "rod_slide", - scad.make_connector_ref_rconnectorref("outer_sleeve", "slide_axis"), - scad.make_connector_ref_rconnectorref("inner_piston_rod", "slide_axis"), + assembly=hydraulic_assembly, + constraint_id="rod_slide", + connector_a=scad.make_connector_ref_rconnectorref( + component_id="outer_sleeve", connector_id="slide_axis" + ), + connector_b=scad.make_connector_ref_rconnectorref( + component_id="inner_piston_rod", connector_id="slide_axis" + ), drive_distance=0.0, - distance_limit=scad.make_scalar_limit_rscalarlimit(0.0, 100.0), + distance_limit=scad.make_scalar_limit_rscalarlimit( + lower_value=0.0, upper_value=100.0 + ), ) hydraulic_assembly = scad.solve_assembly_constraints_rassembly( - hydraulic_assembly + assembly=hydraulic_assembly ) - preview = scad.make_compound_from_assembly_rcompound(hydraulic_assembly) - model_json = scad.export_model_json(session) + preview = scad.make_compound_from_assembly_rcompound( + assembly=hydraulic_assembly + ) + preview = scad.apply_tag( + shape=preview, + tag="assembly.hydraulic.preview", + ) + return hydraulic_assembly, preview - return hydraulic_assembly, preview, model_json + hydraulic_assembly, preview = _build_in_session() + scad.capture_result(value=(hydraulic_assembly, preview)) + return hydraulic_assembly, preview def main() -> None: OUT_DIR.mkdir(parents=True, exist_ok=True) - assembly, preview, model_json = build_hydraulic_rod_assembly() + result = build_hydraulic_rod_assembly() + assembly, preview = result.value - model_path = OUT_DIR / "hydraulic_rod_assembly.model.json" - step_path = OUT_DIR / "hydraulic_rod_assembly.step" fcstd_path = OUT_DIR / "hydraulic_rod_assembly.FCStd" - model_path.write_text(model_json, encoding="utf-8") - scad.export_step(preview, str(step_path)) fcstd_status = "skipped" try: - scad.translator.freecad_translator.translate_model_json_to_fcstd(model_json, str(fcstd_path.resolve())) + scad.translator.freecad_translator.translate_model_json_to_fcstd( + json_str=result.model_json, + output_path=str(fcstd_path.resolve()), + ) fcstd_status = str(fcstd_path) except Exception as exc: # pragma: no cover - depends on local FreeCAD install fcstd_status = f"skipped ({exc.__class__.__name__})" - payload = json.loads(model_json) + payload = json.loads(result.model_json) face_count = len(ql.faces().resolve(preview)) print("assembly", assembly.assembly_id) print("components", assembly.component_ids()) @@ -254,8 +477,8 @@ def main() -> None: print("preview_faces", face_count) print("preview_volume", round(preview.get_volume(), 3)) print("graph_nodes", len(payload["graph"]["nodes"])) - print("wrote", model_path) - print("wrote", step_path) + for path in result.artifact_paths.values(): + print("wrote", path) print("fcstd", fcstd_status) diff --git a/SimpleCADAPI/examples/11_stdlib_gears.py b/SimpleCADAPI/examples/11_stdlib_gears.py deleted file mode 100644 index 03da347..0000000 --- a/SimpleCADAPI/examples/11_stdlib_gears.py +++ /dev/null @@ -1,119 +0,0 @@ -"""Example 11: standalone std.gear internal ring gears. - -This example intentionally avoids planetary assemblies. It exports three -separate ring-gear models so the internal tooth profile can be inspected -without overlapped sun/planet gears or assembly placement noise: - -- spur internal ring gear -- helical internal ring gear -- herringbone internal ring gear - -Each model is exported as model JSON, STEP, and FCStd. -""" - -import json -import sys -from pathlib import Path - -import simplecadapi as scad - - -# Ring gear sketches contain many profile entities and produce deep graphs. -sys.setrecursionlimit(10000) - - -MODULE = 1.5 -RING_TEETH = 66 -GEAR_HEIGHT = 8.0 -HELIX_ANGLE = 25.0 -RIM_THICKNESS = 4.0 -BACKLASH = 0.08 * MODULE -OUTPUT_DIR = Path("examples/out/ring_gears") - - -def _export_ring(name, description, build_ring): - OUTPUT_DIR.mkdir(parents=True, exist_ok=True) - - model_path = OUTPUT_DIR / f"{name}.model.json" - step_path = OUTPUT_DIR / f"{name}.step" - fcstd_path = OUTPUT_DIR / f"{name}.FCStd" - - with scad.GraphSession() as session: - ring = build_ring() - model_json = scad.export_model_json(session) - - model_path.write_text(model_json, encoding="utf-8") - scad.export_step(ring, str(step_path)) - - fcstd_status = str(fcstd_path) - try: - scad.translator.freecad_translator.translate_model_json_to_fcstd(model_json, str(fcstd_path.resolve())) - except Exception as exc: - fcstd_status = f"skipped ({exc.__class__.__name__})" - - payload = json.loads(model_json) - print(f"=== {description} ===") - print(f" volume: {ring.get_volume():.1f}") - print(f" graph nodes: {len(payload['graph']['nodes'])}") - print(f" model: {model_path}") - print(f" step: {step_path}") - print(f" fcstd: {fcstd_status}") - print() - - -def main(): - print( - "ring_z={ring_z} module={module} height={height} " - "rim={rim} helix={helix} backlash={backlash}".format( - ring_z=RING_TEETH, - module=MODULE, - height=GEAR_HEIGHT, - rim=RIM_THICKNESS, - helix=HELIX_ANGLE, - backlash=BACKLASH, - ) - ) - print(f"output_dir={OUTPUT_DIR}") - print() - - _export_ring( - "spur_ring_gear", - "Spur internal ring gear", - lambda: scad.std.gear.make_spur_ring_gear_rsolid( - n_teeth=RING_TEETH, - module=MODULE, - gear_height=GEAR_HEIGHT, - rim_thickness=RIM_THICKNESS, - backlash=BACKLASH, - ), - ) - - _export_ring( - "helical_ring_gear", - "Helical internal ring gear", - lambda: scad.std.gear.make_helical_ring_gear_rsolid( - n_teeth=RING_TEETH, - module=MODULE, - helix_angle=HELIX_ANGLE, - gear_height=GEAR_HEIGHT, - rim_thickness=RIM_THICKNESS, - backlash=BACKLASH, - ), - ) - - _export_ring( - "herringbone_ring_gear", - "Herringbone internal ring gear", - lambda: scad.std.gear.make_herringbone_ring_gear_rsolid( - n_teeth=RING_TEETH, - module=MODULE, - helix_angle=HELIX_ANGLE, - gear_height=GEAR_HEIGHT, - rim_thickness=RIM_THICKNESS, - backlash=BACKLASH, - ), - ) - - -if __name__ == "__main__": - main() diff --git a/SimpleCADAPI/examples/12_herringbone_planetary_gears.py b/SimpleCADAPI/examples/12_herringbone_planetary_gears.py deleted file mode 100644 index 7bde6ac..0000000 --- a/SimpleCADAPI/examples/12_herringbone_planetary_gears.py +++ /dev/null @@ -1,636 +0,0 @@ -"""Example 12: herringbone planetary reducer carrier assembly. - -This example builds a planetary reducer layout with a fixed internal ring gear: - -- one sun-drive carrier plate with a central shaft -- one herringbone sun gear fixed to that sun-drive plate -- one fixed herringbone internal ring gear -- one upper Y-shaped planet-carrier output plate with three pins -- one reusable herringbone planet gear Part instanced three times - -The ring gear is the grounded reference in this static CAD assembly. The sun -gear is fixed to the input shaft, while the planet carrier and each planet gear -use revolute joints so the product structure reflects the intended power path: -sun input -> planet gears against fixed ring -> slower planet-carrier output. -""" - -from __future__ import annotations - -import json -import math -import sys -from pathlib import Path - -import simplecadapi as scad -from simplecadapi import ql - - -# Gear sketches contain many profile entities and produce deep graphs. -sys.setrecursionlimit(20000) - - -MODULE = 1.5 -SUN_TEETH = 18 -PLANET_TEETH = 24 -RING_TEETH = SUN_TEETH + 2 * PLANET_TEETH -PLANET_COUNT = 3 -GEAR_HEIGHT = 8.0 -SUN_HELIX_ANGLE = 25.0 -PLANET_HELIX_ANGLE = -SUN_HELIX_ANGLE -RING_HELIX_ANGLE = PLANET_HELIX_ANGLE -RING_RIM_THICKNESS = 5.0 -RING_BACKLASH = 0.08 * MODULE -SUN_PITCH_RADIUS = MODULE * SUN_TEETH / 2.0 -PLANET_PITCH_RADIUS = MODULE * PLANET_TEETH / 2.0 -RING_PITCH_RADIUS = MODULE * RING_TEETH / 2.0 -SUN_BORE_RADIUS = 4.2 -PLANET_BORE_RADIUS = 3.5 -SUN_SHAFT_RADIUS = SUN_BORE_RADIUS - 0.2 -SUN_AXIS_SHOULDER_RADIUS = SUN_BORE_RADIUS - 0.05 -PLANET_PIN_RADIUS = PLANET_BORE_RADIUS - 0.7 -PLANET_PIN_BEARING_RADIUS = PLANET_BORE_RADIUS - 0.3 -SUN_DRIVE_PLATE_RADIUS = 14.0 -SUN_DRIVE_PLATE_THICKNESS = 4.0 -SUN_DRIVE_PLATE_BOTTOM_Z = -8.0 -PLANET_CARRIER_THICKNESS = 3.0 -PLANET_CARRIER_BOTTOM_Z = GEAR_HEIGHT + 1.0 -CARRIER_AXIS_CONNECTOR_Z = PLANET_CARRIER_BOTTOM_Z + PLANET_CARRIER_THICKNESS -SUN_AXIS_CONNECTOR_Z = GEAR_HEIGHT -PLANET_AXIS_CONNECTOR_Z = GEAR_HEIGHT -PLANET_PIN_BOTTOM_Z = -0.25 -PLANET_PIN_TOP_CLEARANCE = 0.5 -CARRIER_CENTER_CLEARANCE_RADIUS = SUN_BORE_RADIUS + 1.0 -CARRIER_HUB_RADIUS = CARRIER_CENTER_CLEARANCE_RADIUS + 4.5 -CARRIER_ARM_WIDTH = 8.0 -CARRIER_ARM_INNER_CLEARANCE = 0.6 -CARRIER_ARM_END_OVERHANG = 1.0 -PLANET_PAD_RADIUS = PLANET_BORE_RADIUS + 4.5 -SUN_SHAFT_TOP_Z = CARRIER_AXIS_CONNECTOR_Z -OUTPUT_DIR = Path("examples/out/herringbone_planetary_gears") - - -def _planet_spin_angle(carrier_angle_deg: float) -> float: - """Phase each planet so a tooth gap faces the sun contact line.""" - planet_half_pitch_deg = 180.0 / PLANET_TEETH - return carrier_angle_deg + 180.0 - planet_half_pitch_deg - - -def _z_rotation_placement(origin: tuple[float, float, float], angle_degrees: float): - angle_rad = math.radians(angle_degrees) - cos_a = math.cos(angle_rad) - sin_a = math.sin(angle_rad) - return scad.make_placement_rplacement( - origin=origin, - x_axis=(cos_a, sin_a, 0.0), - y_axis=(-sin_a, cos_a, 0.0), - ) - - -def _ground_solid(label: str, solid: scad.Solid) -> None: - faces = ql.select(solid.get_faces()).all() - tagged_role_faces = ql.select(faces).where(ql.tag("role.*")).all() - print( - f"{label}: faces={len(faces)} role_faces={len(tagged_role_faces)} " - f"volume={solid.get_volume():.1f} tags={','.join(scad.list_tags(solid))}" - ) - - -def _ground_compound(label: str, compound: scad.Compound) -> None: - solids = ql.select(compound.get_solids()).all() - face_count = sum(len(ql.select(solid.get_faces()).all()) for solid in solids) - volume = sum(solid.get_volume() for solid in solids) - print(f"{label}: solids={len(solids)} faces={face_count} volume={volume:.1f}") - - -def _axis_face( - label: str, - solid: scad.Solid, - center_xy: tuple[float, float], - target_z: float, - normal_z: float, -) -> scad.Face: - candidates = [] - for face in ql.select(solid.get_faces()).all(): - normal = face.get_normal_at() - if normal_z > 0.0 and normal.z < 0.7: - continue - if normal_z < 0.0 and normal.z > -0.7: - continue - center = face.get_center() - xy_error = math.hypot(center.x - center_xy[0], center.y - center_xy[1]) - z_error = abs(center.z - target_z) - candidates.append((z_error * 100.0 + xy_error, face, center, normal)) - - if not candidates: - raise ValueError(f"no connector face found for {label}") - - _score, face, center, normal = min(candidates, key=lambda item: item[0]) - print( - f"{label}_connector_face: center=({center.x:.3f},{center.y:.3f},{center.z:.3f}) " - f"normal=({normal.x:.3f},{normal.y:.3f},{normal.z:.3f}) area={face.get_area():.3f}" - ) - return face - - -def _cut_axial_bore(label: str, solid: scad.Solid, radius: float) -> scad.Solid: - cutter = scad.make_cylinder_rsolid( - radius=radius, - height=GEAR_HEIGHT + 2.0, - bottom_face_center=(0.0, 0.0, -1.0), - axis=(0.0, 0.0, 1.0), - ) - bored = scad.cut_rsolid(solid, cutter, skip_non_intersecting=False) - bored = scad.apply_tag(bored, f"solid.cut.{label}") - faces = ql.select(bored.get_faces()).all() - print( - f"{label}: bore_radius={radius:.2f} faces={len(faces)} " - f"volume={bored.get_volume():.1f} tags={','.join(scad.list_tags(bored))}" - ) - return bored - - -def _build_sun_drive_plate() -> scad.Solid: - plate = scad.make_cylinder_rsolid( - radius=SUN_DRIVE_PLATE_RADIUS, - height=SUN_DRIVE_PLATE_THICKNESS, - bottom_face_center=(0.0, 0.0, SUN_DRIVE_PLATE_BOTTOM_Z), - axis=(0.0, 0.0, 1.0), - ) - shaft = scad.make_cylinder_rsolid( - radius=SUN_SHAFT_RADIUS, - height=SUN_SHAFT_TOP_Z - SUN_DRIVE_PLATE_BOTTOM_Z, - bottom_face_center=(0.0, 0.0, SUN_DRIVE_PLATE_BOTTOM_Z), - axis=(0.0, 0.0, 1.0), - ) - sun_axis_shoulder = scad.make_cylinder_rsolid( - radius=SUN_AXIS_SHOULDER_RADIUS, - height=SUN_AXIS_CONNECTOR_Z - SUN_DRIVE_PLATE_BOTTOM_Z, - bottom_face_center=(0.0, 0.0, SUN_DRIVE_PLATE_BOTTOM_Z), - axis=(0.0, 0.0, 1.0), - ) - drive_plate = scad.union_rsolid(plate, sun_axis_shoulder, shaft, glue=False) - drive_plate = scad.apply_tag(drive_plate, "role.sun_drive_plate") - drive_plate = scad.apply_tag(drive_plate, "group.herringbone_planetary") - _ground_solid("sun_drive_plate", drive_plate) - return drive_plate - - -def _build_planet_carrier(planet_center_radius: float) -> scad.Solid: - arm_inner_x = CARRIER_CENTER_CLEARANCE_RADIUS + CARRIER_ARM_INNER_CLEARANCE - arm_outer_x = planet_center_radius + PLANET_PAD_RADIUS + CARRIER_ARM_END_OVERHANG - arm_length = arm_outer_x - arm_inner_x - arm_center_x = (arm_inner_x + arm_outer_x) / 2.0 - pin_height = ( - PLANET_CARRIER_BOTTOM_Z - + PLANET_CARRIER_THICKNESS - + PLANET_PIN_TOP_CLEARANCE - - PLANET_PIN_BOTTOM_Z - ) - hub = scad.make_cylinder_rsolid( - radius=CARRIER_HUB_RADIUS, - height=PLANET_CARRIER_THICKNESS, - bottom_face_center=(0.0, 0.0, PLANET_CARRIER_BOTTOM_Z), - axis=(0.0, 0.0, 1.0), - ) - - solids = [hub] - for index in range(PLANET_COUNT): - carrier_angle_deg = 360.0 * index / PLANET_COUNT - carrier_angle_rad = math.radians(carrier_angle_deg) - arm = scad.make_box_rsolid( - CARRIER_ARM_WIDTH, - arm_length, - PLANET_CARRIER_THICKNESS, - bottom_face_center=(arm_center_x, 0.0, PLANET_CARRIER_BOTTOM_Z), - ) - if carrier_angle_deg != 0.0: - arm = scad.rotate_shape( - arm, - carrier_angle_deg, - axis=(0.0, 0.0, 1.0), - origin=(0.0, 0.0, 0.0), - ) - solids.append(arm) - - center = ( - planet_center_radius * math.cos(carrier_angle_rad), - planet_center_radius * math.sin(carrier_angle_rad), - ) - solids.append( - scad.make_cylinder_rsolid( - radius=PLANET_PAD_RADIUS, - height=PLANET_CARRIER_THICKNESS, - bottom_face_center=(center[0], center[1], PLANET_CARRIER_BOTTOM_Z), - axis=(0.0, 0.0, 1.0), - ) - ) - solids.append( - scad.make_cylinder_rsolid( - radius=PLANET_PIN_RADIUS, - height=pin_height, - bottom_face_center=(center[0], center[1], PLANET_PIN_BOTTOM_Z), - axis=(0.0, 0.0, 1.0), - ) - ) - solids.append( - scad.make_cylinder_rsolid( - radius=PLANET_PIN_BEARING_RADIUS, - height=PLANET_AXIS_CONNECTOR_Z - PLANET_PIN_BOTTOM_Z, - bottom_face_center=(center[0], center[1], PLANET_PIN_BOTTOM_Z), - axis=(0.0, 0.0, 1.0), - ) - ) - - carrier = scad.union_rsolid(solids, glue=False) - carrier = scad.cut_rsolid( - carrier, - scad.make_cylinder_rsolid( - radius=CARRIER_CENTER_CLEARANCE_RADIUS, - height=PLANET_CARRIER_THICKNESS + 2.0, - bottom_face_center=(0.0, 0.0, PLANET_CARRIER_BOTTOM_Z - 1.0), - axis=(0.0, 0.0, 1.0), - ), - skip_non_intersecting=False, - ) - carrier = scad.apply_tag(carrier, "role.planet_carrier") - carrier = scad.apply_tag(carrier, "group.herringbone_planetary") - print( - f"planet_carrier_y_top: arms={PLANET_COUNT} arm_width={CARRIER_ARM_WIDTH:.2f} " - f"arm_length={arm_length:.2f} top_z={PLANET_CARRIER_BOTTOM_Z + PLANET_CARRIER_THICKNESS:.2f} " - f"hub_radius={CARRIER_HUB_RADIUS:.2f} pad_radius={PLANET_PAD_RADIUS:.2f}" - ) - _ground_solid("planet_carrier", carrier) - return carrier - - -def build_herringbone_planetary_gearset(): - """Build the open planetary carrier assembly and return preview/model JSON.""" - planet_center_radius = MODULE * (SUN_TEETH + PLANET_TEETH) / 2.0 - - with scad.GraphSession() as session: - sun_drive_plate = _build_sun_drive_plate() - planet_carrier = _build_planet_carrier(planet_center_radius) - - ring = scad.std.gear.make_herringbone_ring_gear_rsolid( - n_teeth=RING_TEETH, - module=MODULE, - helix_angle=RING_HELIX_ANGLE, - gear_height=GEAR_HEIGHT, - rim_thickness=RING_RIM_THICKNESS, - backlash=RING_BACKLASH, - ) - ring = scad.apply_tag(ring, "role.fixed_ring_gear") - ring = scad.apply_tag(ring, "group.herringbone_planetary") - _ground_solid("fixed_ring", ring) - - sun = scad.std.gear.make_herringbone_gear_rsolid( - n_teeth=SUN_TEETH, - module=MODULE, - helix_angle=SUN_HELIX_ANGLE, - gear_height=GEAR_HEIGHT, - ) - sun = _cut_axial_bore("sun_bore", sun, SUN_BORE_RADIUS) - sun = scad.apply_tag(sun, "role.sun_gear") - sun = scad.apply_tag(sun, "group.herringbone_planetary") - _ground_solid("sun", sun) - - planet_base = scad.std.gear.make_herringbone_gear_rsolid( - n_teeth=PLANET_TEETH, - module=MODULE, - helix_angle=PLANET_HELIX_ANGLE, - gear_height=GEAR_HEIGHT, - ) - planet_base = _cut_axial_bore("planet_bore", planet_base, PLANET_BORE_RADIUS) - planet_base = scad.apply_tag(planet_base, "role.planet_gear") - planet_base = scad.apply_tag(planet_base, "group.herringbone_planetary") - _ground_solid("planet_part", planet_base) - - carrier_material = scad.make_material_rmaterial( - "matte_anodized_aluminum", - name="Matte anodized aluminum", - density=2.7e-6, - density_unit="kg/mm^3", - color=(0.28, 0.30, 0.32), - ) - gear_material = scad.make_material_rmaterial( - "case_hardened_gear_steel", - name="Case hardened gear steel", - density=7.85e-6, - density_unit="kg/mm^3", - color=(0.68, 0.70, 0.72), - ) - print(f"materials: {carrier_material.material_id},{gear_material.material_id}") - - ring_part = scad.make_part_rpart( - "fixed_herringbone_ring", - ring, - name="Fixed herringbone internal ring gear", - ) - ring_part = scad.assign_material_rpart(ring_part, gear_material) - ring_part = scad.add_connector_rpart( - ring_part, - scad.make_face_connector_rconnector( - "axis", - _axis_face("ring_axis", ring, (0.0, 0.0), GEAR_HEIGHT, 1.0), - ), - ) - - sun_drive_part = scad.make_part_rpart( - "sun_drive_plate", - sun_drive_plate, - name="Grounded sun-drive plate with central shaft", - ) - sun_drive_part = scad.assign_material_rpart(sun_drive_part, carrier_material) - sun_drive_part = scad.add_connector_rpart( - sun_drive_part, - scad.make_face_connector_rconnector( - "carrier_axis", - _axis_face( - "sun_drive_carrier_axis", - sun_drive_plate, - (0.0, 0.0), - CARRIER_AXIS_CONNECTOR_Z, - 1.0, - ), - ), - ) - sun_drive_part = scad.add_connector_rpart( - sun_drive_part, - scad.make_face_connector_rconnector( - "sun_axis", - _axis_face( - "sun_drive_sun_axis", - sun_drive_plate, - (0.0, 0.0), - SUN_AXIS_CONNECTOR_Z, - 1.0, - ), - ), - ) - - carrier_part = scad.make_part_rpart( - "planet_carrier", - planet_carrier, - name="Planet carrier output plate with three pins", - ) - carrier_part = scad.assign_material_rpart(carrier_part, carrier_material) - carrier_part = scad.add_connector_rpart( - carrier_part, - scad.make_face_connector_rconnector( - "carrier_axis", - _axis_face( - "planet_carrier_axis", - planet_carrier, - (0.0, 0.0), - CARRIER_AXIS_CONNECTOR_Z, - 1.0, - ), - ), - ) - for index in range(PLANET_COUNT): - carrier_angle_deg = 360.0 * index / PLANET_COUNT - carrier_angle_rad = math.radians(carrier_angle_deg) - center_xy = ( - planet_center_radius * math.cos(carrier_angle_rad), - planet_center_radius * math.sin(carrier_angle_rad), - ) - carrier_part = scad.add_connector_rpart( - carrier_part, - scad.make_face_connector_rconnector( - f"planet_{index + 1}_axis", - _axis_face( - f"carrier_planet_{index + 1}_axis", - planet_carrier, - center_xy, - PLANET_AXIS_CONNECTOR_Z, - 1.0, - ), - ), - ) - - sun_part = scad.make_part_rpart( - "herringbone_sun", sun, name="Herringbone sun gear" - ) - sun_part = scad.assign_material_rpart(sun_part, gear_material) - sun_part = scad.add_connector_rpart( - sun_part, - scad.make_face_connector_rconnector( - "axis", - _axis_face("sun_axis", sun, (0.0, 0.0), GEAR_HEIGHT, 1.0), - ), - ) - - planet_part = scad.make_part_rpart( - "herringbone_planet", planet_base, name="Reusable herringbone planet gear" - ) - planet_part = scad.assign_material_rpart(planet_part, gear_material) - planet_part = scad.add_connector_rpart( - planet_part, - scad.make_face_connector_rconnector( - "axis", - _axis_face("planet_axis", planet_base, (0.0, 0.0), GEAR_HEIGHT, 1.0), - ), - ) - print( - "parts: " - f"{ring_part.part_id},{sun_drive_part.part_id},{carrier_part.part_id}," - f"{sun_part.part_id},{planet_part.part_id}" - ) - - gearset = scad.make_assembly_rassembly( - "herringbone_planetary_gearset", - name="Fixed-ring herringbone planetary reducer assembly", - ) - gearset = scad.add_component_rassembly( - gearset, - ring_part, - component_id="fixed_ring", - placement=_z_rotation_placement((0.0, 0.0, 0.0), 0.0), - name="Grounded fixed internal ring gear", - ) - gearset = scad.add_component_rassembly( - gearset, - sun_drive_part, - component_id="sun_drive_plate", - placement=_z_rotation_placement((0.0, 0.0, 0.0), 0.0), - name="Grounded sun-drive input plate", - ) - gearset = scad.add_component_rassembly( - gearset, - carrier_part, - component_id="planet_carrier", - placement=_z_rotation_placement((0.0, 0.0, 0.0), 0.0), - name="Planet-carrier output plate", - ) - gearset = scad.add_component_rassembly( - gearset, - sun_part, - component_id="sun", - placement=_z_rotation_placement((0.0, 0.0, 0.0), 0.0), - name="Sun gear fixed to input plate", - ) - - for index in range(PLANET_COUNT): - carrier_angle_deg = 360.0 * index / PLANET_COUNT - carrier_angle_rad = math.radians(carrier_angle_deg) - center = ( - planet_center_radius * math.cos(carrier_angle_rad), - planet_center_radius * math.sin(carrier_angle_rad), - 0.0, - ) - spin_angle = _planet_spin_angle(carrier_angle_deg) - gearset = scad.add_component_rassembly( - gearset, - planet_part, - component_id=f"planet_{index + 1}", - placement=_z_rotation_placement(center, spin_angle), - name=f"Planet gear {index + 1}", - ) - print( - f"planet_{index + 1}: carrier={carrier_angle_deg:.1f}deg " - f"center=({center[0]:.3f},{center[1]:.3f},{center[2]:.3f}) " - f"spin={spin_angle:.1f}deg" - ) - - gearset = scad.ground_component_rassembly(gearset, "fixed_ring") - gearset = scad.add_revolute_constraint_rassembly( - gearset, - "sun_input_revolute", - scad.make_connector_ref_rconnectorref("fixed_ring", "axis"), - scad.make_connector_ref_rconnectorref("sun_drive_plate", "sun_axis"), - name="Sun input shaft rotates inside the fixed ring gear", - ) - gearset = scad.add_revolute_constraint_rassembly( - gearset, - "carrier_output_revolute", - scad.make_connector_ref_rconnectorref("sun_drive_plate", "carrier_axis"), - scad.make_connector_ref_rconnectorref("planet_carrier", "carrier_axis"), - name="Planet carrier rotates around the sun-drive plate axis", - ) - gearset = scad.add_fixed_constraint_rassembly( - gearset, - "sun_fixed_to_drive_plate", - scad.make_connector_ref_rconnectorref("sun_drive_plate", "sun_axis"), - scad.make_connector_ref_rconnectorref("sun", "axis"), - name="Sun gear fixed to the input shaft", - ) - for index in range(PLANET_COUNT): - gearset = scad.add_revolute_constraint_rassembly( - gearset, - f"planet_{index + 1}_revolute", - scad.make_connector_ref_rconnectorref("planet_carrier", f"planet_{index + 1}_axis"), - scad.make_connector_ref_rconnectorref(f"planet_{index + 1}", "axis"), - name=f"Planet gear {index + 1} rotates on its carrier pin", - ) - for index in range(PLANET_COUNT): - planet_ref = scad.make_connector_ref_rconnectorref( - component_id=f"planet_{index + 1}", - connector_id="axis", - ) - gearset = scad.add_gear_constraint_rassembly( - assembly=gearset, - constraint_id=f"sun_planet_{index + 1}_external_mesh", - connector_a=scad.make_connector_ref_rconnectorref( - component_id="sun_drive_plate", - connector_id="sun_axis", - ), - connector_b=planet_ref, - pitch_radius_a=SUN_PITCH_RADIUS, - pitch_radius_b=PLANET_PITCH_RADIUS, - name=f"External sun to planet {index + 1} gear mesh", - ) - gearset = scad.add_belt_constraint_rassembly( - assembly=gearset, - constraint_id=f"ring_planet_{index + 1}_internal_mesh", - connector_a=scad.make_connector_ref_rconnectorref( - component_id="fixed_ring", - connector_id="axis", - ), - connector_b=planet_ref, - pulley_radius_a=RING_PITCH_RADIUS, - pulley_radius_b=PLANET_PITCH_RADIUS, - name=f"Internal fixed-ring to planet {index + 1} gear mesh", - ) - print( - "gear_constraints: " - f"sun_planet_external={PLANET_COUNT} ring_planet_internal={PLANET_COUNT} " - f"radii=({SUN_PITCH_RADIUS:.3f},{PLANET_PITCH_RADIUS:.3f},{RING_PITCH_RADIUS:.3f})" - ) - gearset = scad.solve_assembly_constraints_rassembly(gearset) - report = scad.inspect_assembly_constraints_rconstraintreport(gearset) - print( - "assembly: " - f"components={','.join(gearset.component_ids())} " - f"grounded={','.join(gearset.grounded_component_ids)} " - f"solved={report.solved} constraints={len(gearset.constraints)}" - ) - for residual in report.residuals: - print( - f"constraint_{residual.constraint_id}: " - f"translation={residual.translation_error:.6g} " - f"angle={residual.angular_error_degrees:.6g} " - f"ok={residual.within_tolerance}" - ) - - preview = scad.make_compound_from_assembly_rcompound(gearset) - _ground_compound("assembly_preview", preview) - model_json = scad.export_model_json(session) - - return gearset, preview, model_json - - -def main() -> None: - OUTPUT_DIR.mkdir(parents=True, exist_ok=True) - - model_path = OUTPUT_DIR / "herringbone_planetary_gearset.model.json" - step_path = OUTPUT_DIR / "herringbone_planetary_gearset.step" - fcstd_path = OUTPUT_DIR / "herringbone_planetary_gearset.FCStd" - if fcstd_path.exists(): - fcstd_path.unlink() - - assembly, preview, model_json = build_herringbone_planetary_gearset() - model_path.write_text(model_json, encoding="utf-8") - scad.export_step(preview, str(step_path)) - - fcstd_status = "not attempted" - try: - scad.translator.freecad_translator.translate_model_json_to_fcstd(model_json, str(fcstd_path.resolve())) - fcstd_status = f"{fcstd_path} ({fcstd_path.stat().st_size} bytes)" - except Exception as exc: # pragma: no cover - depends on local FreeCAD install - fcstd_status = f"failed ({exc.__class__.__name__}: {exc})" - - payload = json.loads(model_json) - replayed = scad.replay_model_json(model_json) - solids = ql.select(preview.get_solids()).all() - face_count = sum(len(ql.select(solid.get_faces()).all()) for solid in solids) - volumes = [solid.get_volume() for solid in solids] - - print( - "sun_z={sun} planet_z={planet} ring_z={ring} planets={count} " - "module={module} height={height} sun_helix={sun_helix} planet_helix={planet_helix}".format( - sun=SUN_TEETH, - planet=PLANET_TEETH, - ring=RING_TEETH, - count=PLANET_COUNT, - module=MODULE, - height=GEAR_HEIGHT, - sun_helix=SUN_HELIX_ANGLE, - planet_helix=PLANET_HELIX_ANGLE, - ) - ) - print(f"planet_center_radius={MODULE * (SUN_TEETH + PLANET_TEETH) / 2.0:.3f}") - print(f"assembly={assembly.assembly_id}") - print("components=" + ",".join(assembly.component_ids())) - print(f"preview_solids={len(solids)}") - print(f"preview_faces={face_count}") - print("volumes=" + ",".join(f"{volume:.1f}" for volume in volumes)) - print(f"replay_outputs={len(replayed)}") - print("replay_types=" + ",".join(type(item).__name__ for item in replayed)) - print(f"graph_nodes={len(payload['graph']['nodes'])}") - print(f"model={model_path}") - print(f"step={step_path}") - print(f"fcstd={fcstd_status}") - - -if __name__ == "__main__": - main() diff --git a/SimpleCADAPI/examples/13_cycloidal_reducer.py b/SimpleCADAPI/examples/13_cycloidal_reducer.py deleted file mode 100644 index c9ab9d9..0000000 --- a/SimpleCADAPI/examples/13_cycloidal_reducer.py +++ /dev/null @@ -1,847 +0,0 @@ -"""Example 13: compact 50 mm x 10 mm cycloidal reducer assembly. - -Design plan -=========== - -Package envelope: -- Maximum outside diameter: 50 mm. -- Maximum stack height: 10 mm. - -Reduction stage: -- Fixed pin ring: 11 pins. -- Twin cycloidal discs: 10 lobes each, stacked with 180 degree eccentric - carrier separation and a half-lobe tooth-index phase for load balance. -- Single-stage reduction: 11 - 1 = 10:1. -- The fixed pin ring contact is represented as a gear-like coupling between the - input eccentric carrier and each cycloidal disc's relative spin: each disc - spins -11/10 turn relative to its eccentric carrier for each input turn, - giving a global cycloidal/output phase of -1/10 input turn. - -Structure: -- fixed_housing: outer sleeve, top/bottom retainers, and 11 fixed ring pins. -- input_disk: bottom three-hole threaded mounting disk plus a double eccentric - cam shaft. The lower cam is at 0 degrees; the upper cam is at 180 degrees. -- lower_cycloidal_disc and upper_cycloidal_disc: 10-lobed discs with eccentric - bearing bores and three oversize output-pin relief holes. The upper disc is - tooth-indexed by half a lobe, i.e. 180 degrees divided by 10 lobes = 18 - geometric degrees. A full 180 degree rotation would be symmetry-equivalent - to the lower disc because the profile has 10 lobes. -- output_disk: top three-hole threaded mounting disk plus three output pins. - -Assembly relationships: -- fixed_housing is grounded. -- input_disk is revolute about the housing axis. -- output_disk is revolute about the housing axis. -- lower_cycloidal_disc is revolute on the lower input eccentric cam axis. -- upper_cycloidal_disc is revolute on the upper input eccentric cam axis. -- input_disk to each cycloidal disc has a gear-like pin-ring rolling coupling. - -The output pins are fixed to the output disk and pass through oversize circular -holes in both cycloidal discs. The two discs load those pins from opposite -eccentric directions, so the real mechanism keeps the output-pin side load more -balanced through a full rotation. This SDK does not yet have a native -pin-slot/contact primitive, so the example models that relation as clearance -geometry rather than a false coaxial ratio shortcut. - -Each cycloidal outline is fit as ten cubic B-spline segments, one segment per -lobe. This keeps the exported topology small and stable while preserving the -analytic pin-wheel profile within a controlled fit tolerance. -""" - -from __future__ import annotations - -import json -import math -import sys -from pathlib import Path - -import simplecadapi as scad -from simplecadapi import ql - - -sys.setrecursionlimit(20000) - - -PACKAGE_DIAMETER = 50.0 -PACKAGE_RADIUS = PACKAGE_DIAMETER / 2.0 -PACKAGE_HEIGHT = 10.0 -OUTPUT_DIR = Path("examples/out/cycloidal_reducer_50mm_10x") - -PIN_COUNT = 11 -CYCLOID_LOBES = PIN_COUNT - 1 -REDUCTION_RATIO = CYCLOID_LOBES -ECCENTRICITY = 0.8 -LOWER_ECCENTRIC_CENTER = (ECCENTRICITY, 0.0) -UPPER_ECCENTRIC_CENTER = (-ECCENTRICITY, 0.0) -UPPER_CYCLOID_BODY_PHASE_DEGREES = 180.0 / CYCLOID_LOBES - -RING_PIN_PITCH_RADIUS = 18.0 -RING_PIN_RADIUS = 0.65 -PROFILE_ROLLER_RADIUS = 1.6 -HOUSING_INNER_RADIUS = 22.3 -RETAINER_INNER_RADIUS = 13.5 - -BOTTOM_RETAINER_BOTTOM_Z = 1.25 -RETAINER_THICKNESS = 0.75 -TOP_RETAINER_BOTTOM_Z = 8.0 -PIN_BOTTOM_Z = BOTTOM_RETAINER_BOTTOM_Z -PIN_TOP_Z = TOP_RETAINER_BOTTOM_Z + RETAINER_THICKNESS - -INPUT_FLANGE_RADIUS = 11.8 -OUTPUT_FLANGE_RADIUS = 11.8 -FLANGE_THICKNESS = 1.1 -INPUT_FLANGE_BOTTOM_Z = 0.0 -OUTPUT_FLANGE_BOTTOM_Z = PACKAGE_HEIGHT - FLANGE_THICKNESS -MOUNT_HOLE_COUNT = 3 -MOUNT_HOLE_RADIUS = 1.03 -MOUNT_HOLE_ENTRY_RADIUS = 1.35 -MOUNT_HOLE_ENTRY_DEPTH = 0.28 -MOUNT_HOLE_PITCH_RADIUS = 8.8 - -ECCENTRIC_BOSS_RADIUS = 3.1 -INPUT_SHAFT_RADIUS = 0.55 -INPUT_CAM_DATUM_PAD_RADIUS = 0.22 -INPUT_CAM_DATUM_PAD_HEIGHT = 0.06 -INPUT_CAM_DATUM_PAD_OVERLAP = 0.02 -CYCLOID_BORE_RADIUS = 3.45 -LOWER_CYCLOID_BOTTOM_Z = 2.15 -CYCLOID_DISC_HEIGHT = 2.65 -CYCLOID_DISC_GAP = 0.20 -CYCLOID_BEARING_RACE_HEIGHT = 0.10 -LOWER_CYCLOID_TOP_Z = LOWER_CYCLOID_BOTTOM_Z + CYCLOID_DISC_HEIGHT -LOWER_CYCLOID_CONNECTOR_Z = LOWER_CYCLOID_TOP_Z + CYCLOID_BEARING_RACE_HEIGHT -UPPER_CYCLOID_BOTTOM_Z = LOWER_CYCLOID_CONNECTOR_Z + CYCLOID_DISC_GAP -UPPER_CYCLOID_TOP_Z = UPPER_CYCLOID_BOTTOM_Z + CYCLOID_DISC_HEIGHT -UPPER_CYCLOID_CONNECTOR_Z = UPPER_CYCLOID_TOP_Z + CYCLOID_BEARING_RACE_HEIGHT -CYCLOID_STACK_HEIGHT = UPPER_CYCLOID_CONNECTOR_Z - LOWER_CYCLOID_BOTTOM_Z -CYCLOID_LOBE_SAMPLE_COUNT = 33 -CYCLOID_SPLINE_TOLERANCE = 0.005 -CYCLOID_SPLINE_MAX_CONTROL_POINTS = 20 - -OUTPUT_PIN_COUNT = 3 -OUTPUT_PIN_RADIUS = 1.0 -OUTPUT_PIN_CLEARANCE_RADIUS = OUTPUT_PIN_RADIUS + ECCENTRICITY + 0.25 -OUTPUT_PIN_PITCH_RADIUS = 6.4 -OUTPUT_PIN_PHASE = 60.0 -OUTPUT_PIN_BOTTOM_Z = LOWER_CYCLOID_BOTTOM_Z -OUTPUT_PIN_TOP_Z = OUTPUT_FLANGE_BOTTOM_Z + 0.20 - - -def _polar(radius: float, angle_degrees: float) -> tuple[float, float]: - angle = math.radians(angle_degrees) - return radius * math.cos(angle), radius * math.sin(angle) - - -def _z_rotation_placement(origin: tuple[float, float, float], angle_degrees: float): - angle = math.radians(angle_degrees) - return scad.make_placement_rplacement( - origin=origin, - x_axis=(math.cos(angle), math.sin(angle), 0.0), - y_axis=(-math.sin(angle), math.cos(angle), 0.0), - ) - - -def _ground_solid(label: str, solid: scad.Solid) -> None: - faces = ql.select(solid.get_faces()).all() - role_faces = ql.select(faces).where(ql.tag("role.*")).all() - print( - f"{label}: faces={len(faces)} role_faces={len(role_faces)} " - f"volume={solid.get_volume():.1f} tags={','.join(scad.list_tags(solid))}" - ) - - -def _ground_compound(label: str, compound: scad.Compound) -> None: - solids = ql.select(compound.get_solids()).all() - face_count = sum(len(ql.select(solid.get_faces()).all()) for solid in solids) - volume = sum(solid.get_volume() for solid in solids) - print(f"{label}: solids={len(solids)} faces={face_count} volume={volume:.1f}") - - -def _axis_face( - label: str, - solid: scad.Solid, - center_xy: tuple[float, float], - target_z: float, - normal_z: float, -) -> scad.Face: - candidates = [] - for face in ql.select(solid.get_faces()).all(): - normal = face.get_normal_at() - if normal_z > 0.0 and normal.z < 0.7: - continue - if normal_z < 0.0 and normal.z > -0.7: - continue - center = face.get_center() - xy_error = math.hypot(center.x - center_xy[0], center.y - center_xy[1]) - z_error = abs(center.z - target_z) - candidates.append((z_error * 100.0 + xy_error, face, center, normal)) - - if not candidates: - raise ValueError(f"no connector face found for {label}") - - _score, face, center, normal = min(candidates, key=lambda item: item[0]) - print( - f"{label}_connector_face: center=({center.x:.3f},{center.y:.3f},{center.z:.3f}) " - f"normal=({normal.x:.3f},{normal.y:.3f},{normal.z:.3f}) area={face.get_area():.3f}" - ) - return face - - -def _make_annular_cylinder( - *, - outer_radius: float, - inner_radius: float, - bottom_z: float, - height: float, -) -> scad.Solid: - outer = scad.make_cylinder_rsolid( - radius=outer_radius, - height=height, - bottom_face_center=(0.0, 0.0, bottom_z), - axis=(0.0, 0.0, 1.0), - ) - inner = scad.make_cylinder_rsolid( - radius=inner_radius, - height=height + 2.0, - bottom_face_center=(0.0, 0.0, bottom_z - 1.0), - axis=(0.0, 0.0, 1.0), - ) - return scad.cut_rsolid(outer, inner, skip_non_intersecting=False) - - -def _cut_three_threaded_hole_envelopes( - solid: scad.Solid, - *, - bottom_z: float, - thickness: float, - entry_face: str, - phase_degrees: float = 0.0, -) -> scad.Solid: - cutters: list[scad.Solid] = [] - for index in range(MOUNT_HOLE_COUNT): - angle = phase_degrees + 360.0 * index / MOUNT_HOLE_COUNT - x, y = _polar(MOUNT_HOLE_PITCH_RADIUS, angle) - cutters.append( - scad.make_cylinder_rsolid( - radius=MOUNT_HOLE_RADIUS, - height=thickness + 0.4, - bottom_face_center=(x, y, bottom_z - 0.2), - axis=(0.0, 0.0, 1.0), - ) - ) - if entry_face == "bottom": - entry_bottom_z = bottom_z - 0.04 - elif entry_face == "top": - entry_bottom_z = bottom_z + thickness - MOUNT_HOLE_ENTRY_DEPTH - else: - raise ValueError("entry_face must be 'bottom' or 'top'") - cutters.append( - scad.make_cylinder_rsolid( - radius=MOUNT_HOLE_ENTRY_RADIUS, - height=MOUNT_HOLE_ENTRY_DEPTH + 0.08, - bottom_face_center=(x, y, entry_bottom_z), - axis=(0.0, 0.0, 1.0), - ) - ) - return scad.cut_rsolid(solid, cutters, skip_non_intersecting=False) - - -def _build_fixed_housing() -> scad.Solid: - sleeve = _make_annular_cylinder( - outer_radius=PACKAGE_RADIUS, - inner_radius=HOUSING_INNER_RADIUS, - bottom_z=0.0, - height=PACKAGE_HEIGHT, - ) - bottom_retainer = _make_annular_cylinder( - outer_radius=PACKAGE_RADIUS, - inner_radius=RETAINER_INNER_RADIUS, - bottom_z=BOTTOM_RETAINER_BOTTOM_Z, - height=RETAINER_THICKNESS, - ) - top_retainer = _make_annular_cylinder( - outer_radius=PACKAGE_RADIUS, - inner_radius=RETAINER_INNER_RADIUS, - bottom_z=TOP_RETAINER_BOTTOM_Z, - height=RETAINER_THICKNESS, - ) - pins: list[scad.Solid] = [] - for index in range(PIN_COUNT): - angle = 360.0 * index / PIN_COUNT - x, y = _polar(RING_PIN_PITCH_RADIUS, angle) - pins.append( - scad.make_cylinder_rsolid( - radius=RING_PIN_RADIUS, - height=PIN_TOP_Z - PIN_BOTTOM_Z, - bottom_face_center=(x, y, PIN_BOTTOM_Z), - axis=(0.0, 0.0, 1.0), - ) - ) - housing = scad.union_rsolid( - sleeve, - bottom_retainer, - top_retainer, - pins, - glue=False, - ) - housing = scad.apply_tag(housing, "role.fixed_pin_housing") - housing = scad.apply_tag(housing, "group.cycloidal_reducer") - _ground_solid("fixed_housing", housing) - return housing - - -def _build_input_disk() -> scad.Solid: - flange = scad.make_cylinder_rsolid( - radius=INPUT_FLANGE_RADIUS, - height=FLANGE_THICKNESS, - bottom_face_center=(0.0, 0.0, INPUT_FLANGE_BOTTOM_Z), - axis=(0.0, 0.0, 1.0), - ) - flange = _cut_three_threaded_hole_envelopes( - flange, - bottom_z=INPUT_FLANGE_BOTTOM_Z, - thickness=FLANGE_THICKNESS, - entry_face="bottom", - phase_degrees=0.0, - ) - cam_bottom_z = FLANGE_THICKNESS - 0.20 - input_shaft = scad.make_cylinder_rsolid( - radius=INPUT_SHAFT_RADIUS, - height=UPPER_CYCLOID_CONNECTOR_Z - cam_bottom_z, - bottom_face_center=(0.0, 0.0, cam_bottom_z), - axis=(0.0, 0.0, 1.0), - ) - lower_pad_bottom_z = LOWER_CYCLOID_CONNECTOR_Z - INPUT_CAM_DATUM_PAD_HEIGHT - lower_eccentric_boss = scad.make_cylinder_rsolid( - radius=ECCENTRIC_BOSS_RADIUS, - height=lower_pad_bottom_z + INPUT_CAM_DATUM_PAD_OVERLAP - cam_bottom_z, - bottom_face_center=(*LOWER_ECCENTRIC_CENTER, cam_bottom_z), - axis=(0.0, 0.0, 1.0), - ) - lower_datum_pad = scad.make_cylinder_rsolid( - radius=INPUT_CAM_DATUM_PAD_RADIUS, - height=INPUT_CAM_DATUM_PAD_HEIGHT, - bottom_face_center=(*LOWER_ECCENTRIC_CENTER, lower_pad_bottom_z), - axis=(0.0, 0.0, 1.0), - ) - upper_boss_bottom_z = UPPER_CYCLOID_BOTTOM_Z - 0.10 - upper_pad_bottom_z = UPPER_CYCLOID_CONNECTOR_Z - INPUT_CAM_DATUM_PAD_HEIGHT - upper_eccentric_boss = scad.make_cylinder_rsolid( - radius=ECCENTRIC_BOSS_RADIUS, - height=upper_pad_bottom_z + INPUT_CAM_DATUM_PAD_OVERLAP - upper_boss_bottom_z, - bottom_face_center=(*UPPER_ECCENTRIC_CENTER, upper_boss_bottom_z), - axis=(0.0, 0.0, 1.0), - ) - upper_datum_pad = scad.make_cylinder_rsolid( - radius=INPUT_CAM_DATUM_PAD_RADIUS, - height=INPUT_CAM_DATUM_PAD_HEIGHT, - bottom_face_center=(*UPPER_ECCENTRIC_CENTER, upper_pad_bottom_z), - axis=(0.0, 0.0, 1.0), - ) - input_disk = scad.union_rsolid( - flange, - input_shaft, - lower_eccentric_boss, - lower_datum_pad, - upper_eccentric_boss, - upper_datum_pad, - glue=False, - ) - input_disk = scad.apply_tag(input_disk, "role.input_three_thread_disk") - input_disk = scad.apply_tag(input_disk, "role.double_eccentric_camshaft") - input_disk = scad.apply_tag(input_disk, "group.cycloidal_reducer") - _ground_solid("input_disk", input_disk) - return input_disk - - -def _build_cycloidal_disc( - *, - label: str, - bottom_z: float, - output_pin_phase: float, - body_phase_degrees: float, - role_tag: str, -) -> scad.Solid: - disc = scad.std.gear.make_cycloidal_disc_rsolid( - n_lobes=CYCLOID_LOBES, - ring_pin_pitch_radius=RING_PIN_PITCH_RADIUS, - roller_radius=PROFILE_ROLLER_RADIUS, - eccentricity=ECCENTRICITY, - gear_height=CYCLOID_DISC_HEIGHT, - bore_radius=CYCLOID_BORE_RADIUS, - output_pin_count=OUTPUT_PIN_COUNT, - output_pin_pitch_radius=OUTPUT_PIN_PITCH_RADIUS, - output_pin_clearance_radius=OUTPUT_PIN_CLEARANCE_RADIUS, - output_pin_phase=output_pin_phase, - sample_count_per_lobe=CYCLOID_LOBE_SAMPLE_COUNT, - spline_tolerance=CYCLOID_SPLINE_TOLERANCE, - max_control_points=CYCLOID_SPLINE_MAX_CONTROL_POINTS, - ) - cycloid_meta = disc.get_metadata("std.gear.cycloidal_disc", {}) - top_z = bottom_z + CYCLOID_DISC_HEIGHT - connector_z = top_z + CYCLOID_BEARING_RACE_HEIGHT - disc = scad.translate_shape(disc, (0.0, 0.0, bottom_z)) - bearing_race = _make_annular_cylinder( - outer_radius=CYCLOID_BORE_RADIUS + 0.75, - inner_radius=CYCLOID_BORE_RADIUS, - bottom_z=top_z - 0.02, - height=CYCLOID_BEARING_RACE_HEIGHT + 0.02, - ) - disc = scad.union_rsolid(disc, bearing_race, glue=False) - if body_phase_degrees: - disc = scad.rotate_shape( - disc, - body_phase_degrees, - axis=(0.0, 0.0, 1.0), - origin=(0.0, 0.0, 0.0), - ) - disc = scad.apply_tag(disc, role_tag) - disc = scad.apply_tag(disc, "role.ten_lobe_cycloidal_disc") - disc = scad.apply_tag(disc, "group.cycloidal_reducer") - print( - f"{label}_profile: " - f"pins={PIN_COUNT} lobes={CYCLOID_LOBES} " - f"bottom_z={bottom_z:.2f} connector_z={connector_z:.2f} " - f"body_phase={body_phase_degrees:.1f} " - f"raw_output_pin_phase={output_pin_phase:.1f} " - f"segments={cycloid_meta.get('segment_count', CYCLOID_LOBES)} " - f"samples_per_lobe={CYCLOID_LOBE_SAMPLE_COUNT} " - f"control_points={min(cycloid_meta.get('control_point_counts', [0]))}.." - f"{max(cycloid_meta.get('control_point_counts', [0]))} " - f"fit_error_max={max(cycloid_meta.get('max_errors', [0.0])):.5f} " - f"radius_min={cycloid_meta.get('radius_min', 0.0):.3f} " - f"radius_max={cycloid_meta.get('radius_max', 0.0):.3f}" - ) - _ground_solid(label, disc) - return disc - - -def _build_output_disk() -> scad.Solid: - flange = scad.make_cylinder_rsolid( - radius=OUTPUT_FLANGE_RADIUS, - height=FLANGE_THICKNESS, - bottom_face_center=(0.0, 0.0, OUTPUT_FLANGE_BOTTOM_Z), - axis=(0.0, 0.0, 1.0), - ) - flange = _cut_three_threaded_hole_envelopes( - flange, - bottom_z=OUTPUT_FLANGE_BOTTOM_Z, - thickness=FLANGE_THICKNESS, - entry_face="top", - phase_degrees=0.0, - ) - pins: list[scad.Solid] = [] - for index in range(OUTPUT_PIN_COUNT): - angle = OUTPUT_PIN_PHASE + 360.0 * index / OUTPUT_PIN_COUNT - x, y = _polar(OUTPUT_PIN_PITCH_RADIUS, angle) - pins.append( - scad.make_cylinder_rsolid( - radius=OUTPUT_PIN_RADIUS, - height=OUTPUT_PIN_TOP_Z - OUTPUT_PIN_BOTTOM_Z, - bottom_face_center=(x, y, OUTPUT_PIN_BOTTOM_Z), - axis=(0.0, 0.0, 1.0), - ) - ) - output_disk = scad.union_rsolid(flange, pins, glue=False) - output_disk = scad.apply_tag(output_disk, "role.output_three_thread_disk") - output_disk = scad.apply_tag(output_disk, "group.cycloidal_reducer") - _ground_solid("output_disk", output_disk) - return output_disk - - -def build_cycloidal_reducer(): - with scad.GraphSession() as session: - housing = _build_fixed_housing() - input_disk = _build_input_disk() - lower_cycloidal_disc = _build_cycloidal_disc( - label="lower_cycloidal_disc", - bottom_z=LOWER_CYCLOID_BOTTOM_Z, - output_pin_phase=OUTPUT_PIN_PHASE, - body_phase_degrees=0.0, - role_tag="role.lower_cycloidal_disc", - ) - upper_cycloidal_disc = _build_cycloidal_disc( - label="upper_cycloidal_disc", - bottom_z=UPPER_CYCLOID_BOTTOM_Z, - output_pin_phase=OUTPUT_PIN_PHASE - UPPER_CYCLOID_BODY_PHASE_DEGREES, - body_phase_degrees=UPPER_CYCLOID_BODY_PHASE_DEGREES, - role_tag="role.upper_cycloidal_disc", - ) - output_disk = _build_output_disk() - - housing_material = scad.make_material_rmaterial( - material_id="black_anodized_aluminum", - name="Black anodized aluminum", - density=2.7e-6, - density_unit="kg/mm^3", - color=(0.08, 0.08, 0.09), - ) - steel_material = scad.make_material_rmaterial( - material_id="bearing_steel", - name="Bearing steel", - density=7.85e-6, - density_unit="kg/mm^3", - color=(0.62, 0.64, 0.66), - ) - bronze_material = scad.make_material_rmaterial( - material_id="phosphor_bronze", - name="Phosphor bronze", - density=8.8e-6, - density_unit="kg/mm^3", - color=(0.72, 0.48, 0.20), - ) - print( - "materials: " - f"{housing_material.material_id},{steel_material.material_id},{bronze_material.material_id}" - ) - - housing_part = scad.make_part_rpart( - part_id="fixed_pin_housing", - body=housing, - name="Fixed housing with eleven pin ring", - ) - housing_part = scad.assign_material_rpart(housing_part, housing_material) - housing_part = scad.add_connector_rpart( - housing_part, - scad.make_face_connector_rconnector( - "input_axis", - _axis_face( - "housing_input_axis", - housing, - (0.0, 0.0), - INPUT_FLANGE_BOTTOM_Z, - -1.0, - ), - flip=True, - ), - ) - housing_part = scad.add_connector_rpart( - housing_part, - scad.make_face_connector_rconnector( - "output_axis", - _axis_face( - "housing_output_axis", - housing, - (0.0, 0.0), - PACKAGE_HEIGHT, - 1.0, - ), - ), - ) - - input_part = scad.make_part_rpart( - part_id="input_three_thread_disk", - body=input_disk, - name="Input three threaded-hole disk with double eccentric camshaft", - ) - input_part = scad.assign_material_rpart(input_part, steel_material) - input_part = scad.add_connector_rpart( - input_part, - scad.make_face_connector_rconnector( - "axis", - _axis_face( - "input_axis", - input_disk, - (0.0, 0.0), - INPUT_FLANGE_BOTTOM_Z, - -1.0, - ), - flip=True, - ), - ) - input_part = scad.add_connector_rpart( - input_part, - scad.make_face_connector_rconnector( - "lower_eccentric_axis", - _axis_face( - "input_lower_eccentric_axis", - input_disk, - LOWER_ECCENTRIC_CENTER, - LOWER_CYCLOID_CONNECTOR_Z, - 1.0, - ), - ), - ) - input_part = scad.add_connector_rpart( - input_part, - scad.make_face_connector_rconnector( - "upper_eccentric_axis", - _axis_face( - "input_upper_eccentric_axis", - input_disk, - UPPER_ECCENTRIC_CENTER, - UPPER_CYCLOID_CONNECTOR_Z, - 1.0, - ), - ), - ) - - lower_cycloid_part = scad.make_part_rpart( - part_id="lower_ten_lobe_cycloidal_disc", - body=lower_cycloidal_disc, - name="Lower ten-lobe cycloidal disc", - ) - lower_cycloid_part = scad.assign_material_rpart( - lower_cycloid_part, bronze_material - ) - lower_cycloid_part = scad.add_connector_rpart( - lower_cycloid_part, - scad.make_face_connector_rconnector( - "eccentric_axis", - _axis_face( - "lower_cycloid_eccentric_axis", - lower_cycloidal_disc, - (0.0, 0.0), - LOWER_CYCLOID_CONNECTOR_Z, - 1.0, - ), - ), - ) - - upper_cycloid_part = scad.make_part_rpart( - part_id="upper_ten_lobe_cycloidal_disc", - body=upper_cycloidal_disc, - name="Upper ten-lobe cycloidal disc, 180 degree phased", - ) - upper_cycloid_part = scad.assign_material_rpart( - upper_cycloid_part, bronze_material - ) - upper_cycloid_part = scad.add_connector_rpart( - upper_cycloid_part, - scad.make_face_connector_rconnector( - "eccentric_axis", - _axis_face( - "upper_cycloid_eccentric_axis", - upper_cycloidal_disc, - (0.0, 0.0), - UPPER_CYCLOID_CONNECTOR_Z, - 1.0, - ), - ), - ) - - output_part = scad.make_part_rpart( - part_id="output_three_thread_disk", - body=output_disk, - name="Output three threaded-hole disk with drive pins", - ) - output_part = scad.assign_material_rpart(output_part, steel_material) - output_part = scad.add_connector_rpart( - output_part, - scad.make_face_connector_rconnector( - "axis", - _axis_face( - "output_axis", - output_disk, - (0.0, 0.0), - PACKAGE_HEIGHT, - 1.0, - ), - ), - ) - - reducer = scad.make_assembly_rassembly( - assembly_id="cycloidal_reducer_50mm_10x", - name="50 mm diameter 10:1 cycloidal reducer", - ) - reducer = scad.add_component_rassembly( - assembly=reducer, - item=housing_part, - component_id="fixed_housing", - placement=_z_rotation_placement((0.0, 0.0, 0.0), 0.0), - name="Grounded fixed pin-ring housing", - ) - reducer = scad.add_component_rassembly( - assembly=reducer, - item=input_part, - component_id="input_disk", - placement=_z_rotation_placement((0.0, 0.0, 0.0), 0.0), - name="Input three-thread-hole disk", - ) - reducer = scad.add_component_rassembly( - assembly=reducer, - item=lower_cycloid_part, - component_id="lower_cycloidal_disc", - placement=_z_rotation_placement((ECCENTRICITY, 0.0, 0.0), 0.0), - name="Lower cycloidal disc riding on 0 degree eccentric cam", - ) - reducer = scad.add_component_rassembly( - assembly=reducer, - item=upper_cycloid_part, - component_id="upper_cycloidal_disc", - placement=_z_rotation_placement((-ECCENTRICITY, 0.0, 0.0), 0.0), - name="Upper cycloidal disc riding on 180 degree eccentric cam", - ) - reducer = scad.add_component_rassembly( - assembly=reducer, - item=output_part, - component_id="output_disk", - placement=_z_rotation_placement((0.0, 0.0, 0.0), 0.0), - name="Output three-thread-hole disk", - ) - - reducer = scad.ground_component_rassembly(reducer, "fixed_housing") - reducer = scad.add_revolute_constraint_rassembly( - assembly=reducer, - constraint_id="input_revolute", - connector_a=scad.make_connector_ref_rconnectorref( - component_id="fixed_housing", connector_id="input_axis" - ), - connector_b=scad.make_connector_ref_rconnectorref( - component_id="input_disk", connector_id="axis" - ), - name="Input disk rotates in the fixed housing", - ) - reducer = scad.add_revolute_constraint_rassembly( - assembly=reducer, - constraint_id="output_revolute", - connector_a=scad.make_connector_ref_rconnectorref( - component_id="fixed_housing", connector_id="output_axis" - ), - connector_b=scad.make_connector_ref_rconnectorref( - component_id="output_disk", connector_id="axis" - ), - name="Output disk rotates coaxially in the fixed housing", - ) - reducer = scad.add_revolute_constraint_rassembly( - assembly=reducer, - constraint_id="lower_cycloid_on_eccentric_cam", - connector_a=scad.make_connector_ref_rconnectorref( - component_id="input_disk", connector_id="lower_eccentric_axis" - ), - connector_b=scad.make_connector_ref_rconnectorref( - component_id="lower_cycloidal_disc", connector_id="eccentric_axis" - ), - name="Lower cycloidal disc rotates on the 0 degree input eccentric cam", - ) - reducer = scad.add_revolute_constraint_rassembly( - assembly=reducer, - constraint_id="upper_cycloid_on_eccentric_cam", - connector_a=scad.make_connector_ref_rconnectorref( - component_id="input_disk", connector_id="upper_eccentric_axis" - ), - connector_b=scad.make_connector_ref_rconnectorref( - component_id="upper_cycloidal_disc", connector_id="eccentric_axis" - ), - name="Upper cycloidal disc rotates on the 180 degree input eccentric cam", - ) - reducer = scad.add_gear_constraint_rassembly( - assembly=reducer, - constraint_id="fixed_pin_ring_to_lower_cycloid_spin", - connector_a=scad.make_connector_ref_rconnectorref( - component_id="input_disk", connector_id="axis" - ), - connector_b=scad.make_connector_ref_rconnectorref( - component_id="lower_cycloidal_disc", connector_id="eccentric_axis" - ), - pitch_radius_a=float(PIN_COUNT), - pitch_radius_b=float(CYCLOID_LOBES), - name="Fixed pin ring drives the lower cycloidal disc relative spin", - ) - reducer = scad.add_gear_constraint_rassembly( - assembly=reducer, - constraint_id="fixed_pin_ring_to_upper_cycloid_spin", - connector_a=scad.make_connector_ref_rconnectorref( - component_id="input_disk", connector_id="axis" - ), - connector_b=scad.make_connector_ref_rconnectorref( - component_id="upper_cycloidal_disc", connector_id="eccentric_axis" - ), - pitch_radius_a=float(PIN_COUNT), - pitch_radius_b=float(CYCLOID_LOBES), - name="Fixed pin ring drives the upper cycloidal disc relative spin", - ) - print( - "assembly_plan: " - f"diameter={PACKAGE_DIAMETER:.1f} height={PACKAGE_HEIGHT:.1f} " - f"pins={PIN_COUNT} lobes={CYCLOID_LOBES} reduction={REDUCTION_RATIO}:1 " - f"eccentricity={ECCENTRICITY:.2f} cycloid_discs=2 " - f"eccentric_phase_degrees=0,180 " - f"tooth_index_phase_degrees=0,{UPPER_CYCLOID_BODY_PHASE_DEGREES:.1f} " - f"stack_height={CYCLOID_STACK_HEIGHT:.2f}" - ) - print( - "load_balance: " - "lower_eccentric=(+e,0) upper_eccentric=(-e,0) " - "output_pins_pass_through_both_discs contact_not_solved" - ) - print( - "kinematic_relation: " - f"each_cycloid_relative=-{PIN_COUNT}/{CYCLOID_LOBES}*input " - f"each_cycloid_global=output=-1/{REDUCTION_RATIO}*input via output pin holes" - ) - - reducer = scad.solve_assembly_constraints_rassembly(reducer) - report = scad.inspect_assembly_constraints_rconstraintreport(reducer) - print( - "assembly: " - f"components={','.join(reducer.component_ids())} " - f"grounded={','.join(reducer.grounded_component_ids)} " - f"solved={report.solved} constraints={len(reducer.constraints)}" - ) - for residual in report.residuals: - print( - f"constraint_{residual.constraint_id}: " - f"translation={residual.translation_error:.6g} " - f"angle={residual.angular_error_degrees:.6g} " - f"ok={residual.within_tolerance}" - ) - - preview = scad.make_compound_from_assembly_rcompound(reducer) - _ground_compound("assembly_preview", preview) - model_json = scad.export_model_json(session) - - return reducer, preview, model_json - - -def main() -> None: - OUTPUT_DIR.mkdir(parents=True, exist_ok=True) - - model_path = OUTPUT_DIR / "cycloidal_reducer_50mm_10x.model.json" - step_path = OUTPUT_DIR / "cycloidal_reducer_50mm_10x.step" - fcstd_path = OUTPUT_DIR / "cycloidal_reducer_50mm_10x.FCStd" - if fcstd_path.exists(): - fcstd_path.unlink() - - assembly, preview, model_json = build_cycloidal_reducer() - model_path.write_text(model_json, encoding="utf-8") - scad.export_step(preview, str(step_path)) - - fcstd_status = "not attempted" - try: - scad.translator.freecad_translator.translate_model_json_to_fcstd(model_json, str(fcstd_path.resolve())) - fcstd_status = f"{fcstd_path} ({fcstd_path.stat().st_size} bytes)" - except Exception as exc: # pragma: no cover - depends on local FreeCAD install - fcstd_status = f"failed ({exc.__class__.__name__}: {exc})" - - payload = json.loads(model_json) - replayed = scad.replay_model_json(model_json) - solids = ql.select(preview.get_solids()).all() - face_count = sum(len(ql.select(solid.get_faces()).all()) for solid in solids) - volumes = [solid.get_volume() for solid in solids] - - print( - "package: " - f"diameter={PACKAGE_DIAMETER:.1f} height={PACKAGE_HEIGHT:.1f} " - f"outer_radius={PACKAGE_RADIUS:.1f}" - ) - print( - "mounting: " - f"input=3xM2.5_envelope output=3xM2.5_envelope " - f"hole_pcd={2.0 * MOUNT_HOLE_PITCH_RADIUS:.1f}" - ) - print(f"assembly={assembly.assembly_id}") - print("components=" + ",".join(assembly.component_ids())) - print(f"preview_solids={len(solids)}") - print(f"preview_faces={face_count}") - print("volumes=" + ",".join(f"{volume:.1f}" for volume in volumes)) - print(f"replay_outputs={len(replayed)}") - print("replay_types=" + ",".join(type(item).__name__ for item in replayed)) - print(f"graph_nodes={len(payload['graph']['nodes'])}") - print(f"model={model_path}") - print(f"step={step_path}") - print(f"fcstd={fcstd_status}") - - -if __name__ == "__main__": - main() diff --git a/SimpleCADAPI/examples/14_ball_bearing.py b/SimpleCADAPI/examples/14_ball_bearing.py deleted file mode 100644 index af19c95..0000000 --- a/SimpleCADAPI/examples/14_ball_bearing.py +++ /dev/null @@ -1,168 +0,0 @@ -"""Example 14: parameterized ball bearing standard assembly. - -This example builds a small radial ball bearing through -``scad.std.bearing.make_ball_bearing_rassembly`` and then keeps working inside -that assembly by binding a demo shaft to the inner ring and a demo housing to -the outer ring. The important bearing semantics are product-level, not just -geometry: stable component ids expose the rings and balls, and the inner and -outer rings are connected by a revolute constraint. -""" - -from __future__ import annotations - -import json -from pathlib import Path - -import simplecadapi as scad -from simplecadapi import ql - - -OUT_DIR = Path("examples/out/ball_bearing_608_demo") - -BORE_DIAMETER = 8.0 -OUTER_DIAMETER = 22.0 -BEARING_WIDTH = 5.0 -BALL_DIAMETER = 3.0 -BALL_COUNT = 7 -RACEWAY_CLEARANCE = 0.05 -EDGE_CHAMFER = 0.08 -INNER_RING_ANGLE = 35.0 - - -def _axis_part(part_id: str, solid: scad.Solid, name: str) -> scad.Part: - part = scad.make_part_rpart(part_id, solid, name=name) - top_face = max( - solid.get_faces(), - key=lambda face: face.get_center().z if face.get_normal_at().z > 0.7 else -999.0, - ) - axis = scad.make_face_connector_rconnector("axis", top_face) - return scad.add_connector_rpart(part, axis) - - -def _make_demo_shaft() -> scad.Part: - shaft = scad.make_cylinder_rsolid( - radius=BORE_DIAMETER / 2.0 - 0.2, - height=BEARING_WIDTH + 4.0, - bottom_face_center=(0.0, 0.0, -BEARING_WIDTH / 2.0 - 4.0), - axis=(0.0, 0.0, 1.0), - ) - shaft = scad.apply_tag(shaft, "role.demo_shaft") - return _axis_part("demo_shaft", shaft, "Demo shaft bound to inner ring") - - -def _make_demo_housing() -> scad.Part: - housing_outer = scad.make_cylinder_rsolid( - radius=OUTER_DIAMETER / 2.0 + 3.0, - height=BEARING_WIDTH + 0.75, - bottom_face_center=(0.0, 0.0, -BEARING_WIDTH / 2.0 - 0.75), - axis=(0.0, 0.0, 1.0), - ) - bearing_pocket = scad.make_cylinder_rsolid( - radius=OUTER_DIAMETER / 2.0 + 0.25, - height=BEARING_WIDTH + 3.5, - bottom_face_center=(0.0, 0.0, -BEARING_WIDTH / 2.0 - 1.75), - axis=(0.0, 0.0, 1.0), - ) - housing = scad.cut_rsolid( - housing_outer, - bearing_pocket, - skip_non_intersecting=False, - ) - housing = scad.apply_tag(housing, "role.demo_housing") - return _axis_part("demo_housing", housing, "Demo housing bound to outer ring") - - -def build_ball_bearing_demo(): - with scad.GraphSession() as session: - bearing = scad.std.bearing.make_ball_bearing_rassembly( - BORE_DIAMETER, - OUTER_DIAMETER, - BEARING_WIDTH, - BALL_DIAMETER, - BALL_COUNT, - RACEWAY_CLEARANCE, - EDGE_CHAMFER, - "ball_bearing_608_demo", - INNER_RING_ANGLE, - ) - meta = bearing.get_metadata("std.bearing.ball_bearing") - - outer_ring = bearing.get_component("outer_ring").item.body - inner_ring = bearing.get_component("inner_ring").item.body - print( - "bearing_core", - f"components={len(bearing.component_ids())}", - f"balls={meta['ball_count']}", - f"constraint={meta['revolute_constraint_id']}", - ) - print( - "ring_geometry", - f"outer_faces={len(ql.faces().resolve(outer_ring))}", - f"inner_faces={len(ql.faces().resolve(inner_ring))}", - f"outer_volume={outer_ring.get_volume():.2f}", - f"inner_volume={inner_ring.get_volume():.2f}", - ) - - bearing = scad.add_component_rassembly( - bearing, - _make_demo_shaft(), - component_id="demo_shaft", - placement=scad.identity_placement_rplacement(), - ) - bearing = scad.add_component_rassembly( - bearing, - _make_demo_housing(), - component_id="demo_housing", - placement=scad.identity_placement_rplacement(), - ) - bearing = scad.add_fixed_constraint_rassembly( - bearing, - "shaft_to_inner_ring", - scad.make_connector_ref_rconnectorref("inner_ring", "axis"), - scad.make_connector_ref_rconnectorref("demo_shaft", "axis"), - ) - bearing = scad.add_fixed_constraint_rassembly( - bearing, - "housing_to_outer_ring", - scad.make_connector_ref_rconnectorref("outer_ring", "axis"), - scad.make_connector_ref_rconnectorref("demo_housing", "axis"), - ) - bearing = scad.solve_assembly_constraints_rassembly(bearing) - report = scad.inspect_assembly_constraints_rconstraintreport(bearing) - preview = scad.make_compound_from_assembly_rcompound(bearing) - model_json = scad.export_model_json(session) - - return bearing, report, preview, model_json - - -def main() -> None: - OUT_DIR.mkdir(parents=True, exist_ok=True) - assembly, report, preview, model_json = build_ball_bearing_demo() - - model_path = OUT_DIR / "ball_bearing_608_demo.model.json" - step_path = OUT_DIR / "ball_bearing_608_demo.step" - fcstd_path = OUT_DIR / "ball_bearing_608_demo.FCStd" - model_path.write_text(model_json, encoding="utf-8") - scad.export_step(preview, str(step_path)) - - fcstd_status = str(fcstd_path) - try: - scad.translator.freecad_translator.translate_model_json_to_fcstd(model_json, str(fcstd_path.resolve())) - except Exception as exc: # pragma: no cover - depends on local FreeCAD install - fcstd_status = f"skipped ({exc.__class__.__name__})" - - payload = json.loads(model_json) - print("assembly", assembly.assembly_id) - print("components", assembly.component_ids()) - print("constraints", assembly.constraint_ids()) - print("solved", report.solved) - print("preview_solids", len(preview.get_solids())) - print("preview_volume", round(preview.get_volume(), 2)) - print("graph_nodes", len(payload["graph"]["nodes"])) - print("wrote", model_path) - print("wrote", step_path) - print("fcstd", fcstd_status) - - -if __name__ == "__main__": - main() diff --git a/SimpleCADAPI/examples/15_cached_mesh_obj_export.py b/SimpleCADAPI/examples/15_cached_mesh_obj_export.py deleted file mode 100644 index 738510b..0000000 --- a/SimpleCADAPI/examples/15_cached_mesh_obj_export.py +++ /dev/null @@ -1,101 +0,0 @@ -"""Example 15: export an internal cached mesh as OBJ. - -Run from the repository root with: - uv run python examples/15_cached_mesh_obj_export.py - -This is a developer-facing example for the mesh-cache groundwork. It -intentionally does not call the public STL exporter. Instead it builds a normal -SimpleCAD solid, reads the framework's internal cached mesh, and writes a common -Wavefront OBJ mesh file from that pure triangle data. - -Application code should not depend on ``simplecadapi._mesh``. Future structural -checking APIs will consume the same internal mesh cache without exposing mesh -extraction to framework users. -""" - -from __future__ import annotations - -from pathlib import Path - -import simplecadapi as scad -import simplecadapi._mesh as internal_mesh - - -OUT_DIR = Path("examples/out/cached_mesh_obj_export") - - -def build_demo_solid() -> scad.Solid: - """Build a small bracket-like solid using only normal modeling APIs.""" - - base = scad.make_box_rsolid( - width=34.0, - height=20.0, - depth=6.0, - bottom_face_center=(0.0, 0.0, 0.0), - ) - through_hole = scad.make_cylinder_rsolid( - radius=4.0, - height=12.0, - bottom_face_center=(0.0, 0.0, -3.0), - axis=(0.0, 0.0, 1.0), - ) - mount_slot = scad.make_box_rsolid( - width=8.0, - height=24.0, - depth=10.0, - bottom_face_center=(10.0, 0.0, -2.0), - ) - boss = scad.make_cylinder_rsolid( - radius=7.0, - height=5.0, - bottom_face_center=(-10.0, 0.0, 6.0), - axis=(0.0, 0.0, 1.0), - ) - - bracket = scad.cut_rsolid( - base, - through_hole, - mount_slot, - skip_non_intersecting=False, - ) - bracket = scad.union_rsolid([bracket, boss]) - return scad.apply_tag(shape=bracket, tag="role.cached_mesh_obj_demo") - - -def write_cached_mesh_obj(solid: scad.Solid, path: Path) -> internal_mesh.TriMesh: - """Write a Solid's internal cached mesh to Wavefront OBJ.""" - - mesh = internal_mesh.cached_mesh(solid) - if mesh is None: - detail = internal_mesh.mesh_error(solid) or "no internal mesh cache" - raise RuntimeError(f"Solid has no cached mesh: {detail}") - - lines = [ - "# OBJ written from SimpleCAD internal cached mesh", - "# This example intentionally bypasses scad.export_stl(...).", - ] - for x, y, z in mesh.vertices: - lines.append(f"v {x:.9g} {y:.9g} {z:.9g}") - for a, b, c in mesh.triangles: - lines.append(f"f {int(a) + 1} {int(b) + 1} {int(c) + 1}") - - path.write_text("\n".join(lines) + "\n", encoding="utf-8") - return mesh - - -def main() -> None: - OUT_DIR.mkdir(parents=True, exist_ok=True) - solid = build_demo_solid() - obj_path = OUT_DIR / "cached_mesh_bracket.obj" - mesh = write_cached_mesh_obj(solid=solid, path=obj_path) - lower, upper = mesh.bounds - - print("volume", round(solid.get_volume(), 3)) - print("faces", len(solid.get_faces())) - print("mesh", f"vertices={mesh.vertex_count}", f"triangles={mesh.triangle_count}") - print("bounds", f"min={tuple(round(v, 3) for v in lower)}", f"max={tuple(round(v, 3) for v in upper)}") - print("wrote_obj", obj_path) - - -if __name__ == "__main__": - main() diff --git a/SimpleCADAPI/examples/16_compact_two_stage_planetary_reducer/assembly.py b/SimpleCADAPI/examples/16_compact_two_stage_planetary_reducer/assembly.py index 0a26a3c..7f052ad 100644 --- a/SimpleCADAPI/examples/16_compact_two_stage_planetary_reducer/assembly.py +++ b/SimpleCADAPI/examples/16_compact_two_stage_planetary_reducer/assembly.py @@ -4,37 +4,68 @@ from __future__ import annotations import simplecadapi as scad -from bearings import ( - make_coaxial_bearing_rplacement, - make_planet_bearing_rplacements, - make_radial_ball_bearing_rassembly, -) -from carriers import make_stage_carrier_rpart -from dimensions import ( - INPUT_BEARING_Z, - INTERMEDIATE_BEARING_Z, - OUTPUT_BEARING_Z, - PLANET_COUNT, - STAGE1_PLANET_BEARING, - STAGE2_PLANET_BEARING, - STAGE_1, - STAGE_2, - TOTAL_REDUCTION, - StageSpec, - UNIVERSAL_RADIAL_BEARING, -) -from flanges import make_input_flange_rpart, make_output_flange_rpart -from gears import ( - make_planet_component_rplacement, - make_stage_planet_gear_rpart, - make_stage_ring_gear_rpart, - make_stage_sun_gear_rpart, -) -from housing import make_reducer_housing_rpart -from materials import make_reducer_materials_rdict -from shafts import make_input_shaft_rpart - +if __package__: + from .bearings import ( + make_coaxial_bearing_rplacement, + make_planet_bearing_rplacements, + make_radial_ball_bearing_rassembly, + ) + from .carriers import make_stage_carrier_rpart + from .dimensions import ( + INPUT_BEARING_Z, + INTERMEDIATE_BEARING_Z, + OUTPUT_BEARING_Z, + PLANET_COUNT, + STAGE1_PLANET_BEARING, + STAGE2_PLANET_BEARING, + STAGE_1, + STAGE_2, + TOTAL_REDUCTION, + StageSpec, + UNIVERSAL_RADIAL_BEARING, + ) + from .flanges import make_input_flange_rpart, make_output_flange_rpart + from .gears import ( + make_planet_component_rplacement, + make_stage_planet_gear_rpart, + make_stage_ring_gear_rpart, + make_stage_sun_gear_rpart, + ) + from .housing import make_reducer_housing_rpart + from .materials import make_reducer_materials_rdict + from .shafts import make_input_shaft_rpart +else: + from bearings import ( + make_coaxial_bearing_rplacement, + make_planet_bearing_rplacements, + make_radial_ball_bearing_rassembly, + ) + from carriers import make_stage_carrier_rpart + from dimensions import ( + INPUT_BEARING_Z, + INTERMEDIATE_BEARING_Z, + OUTPUT_BEARING_Z, + PLANET_COUNT, + STAGE1_PLANET_BEARING, + STAGE2_PLANET_BEARING, + STAGE_1, + STAGE_2, + TOTAL_REDUCTION, + StageSpec, + UNIVERSAL_RADIAL_BEARING, + ) + from flanges import make_input_flange_rpart, make_output_flange_rpart + from gears import ( + make_planet_component_rplacement, + make_stage_planet_gear_rpart, + make_stage_ring_gear_rpart, + make_stage_sun_gear_rpart, + ) + from housing import make_reducer_housing_rpart + from materials import make_reducer_materials_rdict + from shafts import make_input_shaft_rpart +@scad.requires_session def make_two_stage_planetary_reducer_rassembly() -> scad.Assembly: """Build the full 20:1 compact reducer assembly and solve constraints.""" @@ -84,6 +115,7 @@ def make_two_stage_planetary_reducer_rassembly() -> scad.Assembly: bearing = make_radial_ball_bearing_rassembly( bearing_id="micro_radial_ball_bearing", spec=UNIVERSAL_RADIAL_BEARING, + tag_prefix="reducer.bearing.micro.radial", ) reducer = scad.make_assembly_rassembly( @@ -133,6 +165,7 @@ def make_two_stage_planetary_reducer_rassembly() -> scad.Assembly: return reducer +@scad.requires_session def _add_fixed_components_rassembly( *, assembly: scad.Assembly, @@ -150,6 +183,7 @@ def _add_fixed_components_rassembly( return assembly +@scad.requires_session def _add_bearing_components_rassembly( *, assembly: scad.Assembly, @@ -211,6 +245,7 @@ def _add_bearing_components_rassembly( return assembly +@scad.requires_session def _add_public_interface_connectors_rassembly(*, assembly: scad.Assembly) -> scad.Assembly: """Expose stable actuator module datums without leaking private component ids.""" @@ -231,6 +266,7 @@ def _add_public_interface_connectors_rassembly(*, assembly: scad.Assembly) -> sc return assembly +@scad.requires_session def _add_reducer_constraints_rassembly(*, assembly: scad.Assembly) -> scad.Assembly: assembly = scad.ground_component_rassembly(assembly=assembly, component_id="housing") assembly = scad.ground_component_rassembly(assembly=assembly, component_id="stage1_ring") @@ -290,6 +326,7 @@ def _add_reducer_constraints_rassembly(*, assembly: scad.Assembly) -> scad.Assem return assembly +@scad.requires_session def _add_bearing_interface_constraints_rassembly(*, assembly: scad.Assembly) -> scad.Assembly: coaxial_interfaces = ( ("input_bearing_outer_to_housing", "housing", "input_bearing_axis", "input_bearing", "outer_axis"), @@ -338,6 +375,7 @@ def _add_bearing_interface_constraints_rassembly(*, assembly: scad.Assembly) -> return assembly +@scad.requires_session def _add_stage_mesh_constraints_rassembly( *, assembly: scad.Assembly, @@ -388,6 +426,7 @@ def _add_stage_mesh_constraints_rassembly( return assembly +@scad.requires_session def _gear_stage_rplacement(*, stage: StageSpec) -> scad.Placement: return scad.make_placement_rplacement( origin=(0.0, 0.0, stage.bottom_z), @@ -396,6 +435,7 @@ def _gear_stage_rplacement(*, stage: StageSpec) -> scad.Placement: ) +@scad.requires_session def _ref(*, component_id: str, connector_id: str) -> scad.ConnectorRef: return scad.make_connector_ref_rconnectorref( component_id=component_id, diff --git a/SimpleCADAPI/examples/16_compact_two_stage_planetary_reducer/bearings.py b/SimpleCADAPI/examples/16_compact_two_stage_planetary_reducer/bearings.py index 033b278..456fe64 100644 --- a/SimpleCADAPI/examples/16_compact_two_stage_planetary_reducer/bearings.py +++ b/SimpleCADAPI/examples/16_compact_two_stage_planetary_reducer/bearings.py @@ -10,10 +10,12 @@ from common import make_z_rotation_rplacement from dimensions import BearingSpec, PLANET_COUNT, StageSpec +@scad.requires_session def make_radial_ball_bearing_rassembly( *, bearing_id: str, spec: BearingSpec, + tag_prefix: str, ) -> scad.Assembly: """Create a standard radial ball bearing assembly for reducer placement.""" @@ -30,7 +32,11 @@ def make_radial_ball_bearing_rassembly( ) meta = bearing.get_metadata("std.bearing.ball_bearing") outer_ring = bearing.get_component("outer_ring").item.body + outer_ring = scad.apply_tag(shape=outer_ring, tag=f"solid.{tag_prefix}.outer.ring") inner_ring = bearing.get_component("inner_ring").item.body + inner_ring = scad.apply_tag(shape=inner_ring, tag=f"solid.{tag_prefix}.inner.ring") + rolling_element = bearing.get_component(meta["ball_component_ids"][0]).item.body + scad.apply_tag(shape=rolling_element, tag=f"solid.{tag_prefix}.rolling.element") print( f"bearing_{bearing_id}: components={len(bearing.component_ids())} balls={meta['ball_count']} " f"od={spec.outer_diameter:.2f} bore={spec.bore_diameter:.2f} width={spec.width:.2f}" @@ -42,12 +48,14 @@ def make_radial_ball_bearing_rassembly( return bearing +@scad.requires_session def make_coaxial_bearing_rplacement(*, z: float) -> scad.Placement: """Return a coaxial bearing placement at the requested axial center.""" return make_z_rotation_rplacement(origin=(0.0, 0.0, z), angle_degrees=0.0) +@scad.requires_session def make_planet_bearing_rplacements(*, stage: StageSpec) -> list[scad.Placement]: """Return placed bearing placements centered in all planets of one stage.""" diff --git a/SimpleCADAPI/examples/16_compact_two_stage_planetary_reducer/carriers.py b/SimpleCADAPI/examples/16_compact_two_stage_planetary_reducer/carriers.py index ec78a71..856b1eb 100644 --- a/SimpleCADAPI/examples/16_compact_two_stage_planetary_reducer/carriers.py +++ b/SimpleCADAPI/examples/16_compact_two_stage_planetary_reducer/carriers.py @@ -35,6 +35,7 @@ from dimensions import ( ) +@scad.requires_session def make_stage_carrier_rpart( *, stage: StageSpec, @@ -55,6 +56,7 @@ def make_stage_carrier_rpart( pad_radius=STAGE1_PAD_RADIUS, central_shaft_radius=STAGE1_CARRIER_SHAFT_RADIUS, central_shaft_top_z=STAGE_2.top_z, + tag_prefix="reducer.stage1.carrier", ) connector_specs = [ { @@ -83,6 +85,7 @@ def make_stage_carrier_rpart( pad_radius=STAGE2_PAD_RADIUS, central_shaft_radius=OUTPUT_SHAFT_RADIUS, central_shaft_top_z=OUTPUT_FLANGE_TOP_Z, + tag_prefix="reducer.stage2.carrier", ) connector_specs = [ { @@ -142,6 +145,7 @@ def make_stage_carrier_rpart( return part +@scad.requires_session def _make_carrier_solid_rsolid( *, stage: StageSpec, @@ -155,12 +159,15 @@ def _make_carrier_solid_rsolid( pad_radius: float, central_shaft_radius: float, central_shaft_top_z: float, + tag_prefix: str, ) -> scad.Solid: hub = scad.make_cylinder_rsolid( radius=hub_radius, height=plate_thickness, bottom_face_center=(0.0, 0.0, plate_bottom_z), axis=(0.0, 0.0, 1.0), + tag_prefix=f"{tag_prefix}.hub", + result_tag=f"solid.{tag_prefix}.hub", ) solids = [hub] @@ -171,6 +178,8 @@ def _make_carrier_solid_rsolid( height=central_shaft_top_z - shaft_bottom_z, bottom_face_center=(0.0, 0.0, shaft_bottom_z), axis=(0.0, 0.0, 1.0), + tag_prefix=f"{tag_prefix}.shaft", + result_tag=f"solid.{tag_prefix}.shaft", ) ) @@ -195,6 +204,8 @@ def _make_carrier_solid_rsolid( height=arm_width, depth=plate_thickness, bottom_face_center=(arm_center_radius, 0.0, plate_bottom_z), + tag_prefix=f"{tag_prefix}.arm.i{index + 1}", + result_tag=f"solid.{tag_prefix}.arm.i{index + 1}", ) if abs(carrier_angle) > 1.0e-9: arm = scad.rotate_shape( @@ -210,6 +221,8 @@ def _make_carrier_solid_rsolid( height=plate_thickness, bottom_face_center=(center_xy[0], center_xy[1], plate_bottom_z), axis=(0.0, 0.0, 1.0), + tag_prefix=f"{tag_prefix}.pad.i{index + 1}", + result_tag=f"solid.{tag_prefix}.pad.i{index + 1}", ) ) solids.append( @@ -218,6 +231,8 @@ def _make_carrier_solid_rsolid( height=pin_height, bottom_face_center=(center_xy[0], center_xy[1], pin_bottom_z), axis=(0.0, 0.0, 1.0), + tag_prefix=f"{tag_prefix}.pin.i{index + 1}", + result_tag=f"solid.{tag_prefix}.pin.i{index + 1}", ) ) solids.append( @@ -226,6 +241,8 @@ def _make_carrier_solid_rsolid( height=pin_land_height, bottom_face_center=(center_xy[0], center_xy[1], pin_bottom_z), axis=(0.0, 0.0, 1.0), + tag_prefix=f"{tag_prefix}.pin.land.i{index + 1}", + result_tag=f"solid.{tag_prefix}.pin.land.i{index + 1}", ) ) diff --git a/SimpleCADAPI/examples/16_compact_two_stage_planetary_reducer/collision_probe.py b/SimpleCADAPI/examples/16_compact_two_stage_planetary_reducer/collision_probe.py index 338216f..33cf566 100644 --- a/SimpleCADAPI/examples/16_compact_two_stage_planetary_reducer/collision_probe.py +++ b/SimpleCADAPI/examples/16_compact_two_stage_planetary_reducer/collision_probe.py @@ -18,7 +18,10 @@ import time import simplecadapi as scad -from assembly import make_two_stage_planetary_reducer_rassembly +if __package__: + from .main import _build_compact_two_stage_planetary_reducer +else: + from main import _build_compact_two_stage_planetary_reducer sys.setrecursionlimit(30000) @@ -28,8 +31,8 @@ def _build_reducer_quietly() -> tuple[scad.Assembly, int, float]: log_buffer = io.StringIO() start = time.perf_counter() with contextlib.redirect_stdout(log_buffer): - with scad.GraphSession(graph_id="compact_reducer_collision_probe"): - assembly = make_two_stage_planetary_reducer_rassembly() + result = _build_compact_two_stage_planetary_reducer() + assembly, _preview = result.value elapsed = time.perf_counter() - start return assembly, len(log_buffer.getvalue().splitlines()), elapsed diff --git a/SimpleCADAPI/examples/16_compact_two_stage_planetary_reducer/common.py b/SimpleCADAPI/examples/16_compact_two_stage_planetary_reducer/common.py index 46772ec..3be8a13 100644 --- a/SimpleCADAPI/examples/16_compact_two_stage_planetary_reducer/common.py +++ b/SimpleCADAPI/examples/16_compact_two_stage_planetary_reducer/common.py @@ -9,6 +9,7 @@ import simplecadapi as scad from simplecadapi import ql +@scad.requires_session def make_z_rotation_rplacement( *, origin: tuple[float, float, float], @@ -26,12 +27,14 @@ def make_z_rotation_rplacement( ) +@scad.requires_session def make_annular_cylinder_rsolid( *, outer_radius: float, inner_radius: float, height: float, bottom_z: float, + tag_prefix: str, tag: str, ) -> scad.Solid: """Create a single hollow cylindrical solid with a through bore.""" @@ -43,12 +46,16 @@ def make_annular_cylinder_rsolid( height=height, bottom_face_center=(0.0, 0.0, bottom_z), axis=(0.0, 0.0, 1.0), + tag_prefix=f"{tag_prefix}.outer", + result_tag=f"solid.{tag_prefix}.outer", ) bore = scad.make_cylinder_rsolid( radius=inner_radius, height=height + 2.0, bottom_face_center=(0.0, 0.0, bottom_z - 1.0), axis=(0.0, 0.0, 1.0), + tag_prefix=f"{tag_prefix}.bore", + result_tag=f"solid.{tag_prefix}.bore.cutter", ) annular = scad.cut_rsolid(outer, bore, skip_non_intersecting=False) annular = scad.apply_tag(shape=annular, tag=tag) @@ -56,6 +63,7 @@ def make_annular_cylinder_rsolid( return annular +@scad.requires_session def make_axis_connector_rconnector( *, connector_id: str, @@ -83,6 +91,7 @@ def make_axis_connector_rconnector( ) +@scad.requires_session def make_axis_part_rpart( *, part_id: str, @@ -113,6 +122,7 @@ def make_axis_part_rpart( return part +@scad.requires_session def add_placement_axis_connector_rpart( *, part: scad.Part, @@ -130,6 +140,7 @@ def add_placement_axis_connector_rpart( return scad.add_connector_rpart(part=part, connector=connector) +@scad.requires_session def _apply_tags(shape: scad.Solid, tags: Iterable[str]) -> scad.Solid: """Apply normalized tags through the public SimpleCAD tag API.""" @@ -172,9 +183,13 @@ def _axis_face( def _ground_solid(*, label: str, solid: scad.Solid) -> None: faces = ql.select(items=solid.get_faces()).all() - role_faces = ql.select(items=faces).where(ql.tag(pattern="role.*")).all() + local_roles = [ + tag + for tag in scad.list_tags(shape=solid, scope="local") + if tag.startswith("role.") + ] print( - f"{label}: faces={len(faces)} role_faces={len(role_faces)} " + f"{label}: faces={len(faces)} local_roles={len(local_roles)} " f"volume={solid.get_volume():.3f} tags={','.join(scad.list_tags(shape=solid))}" ) diff --git a/SimpleCADAPI/examples/16_compact_two_stage_planetary_reducer/flanges.py b/SimpleCADAPI/examples/16_compact_two_stage_planetary_reducer/flanges.py index 475ed72..5b723d4 100644 --- a/SimpleCADAPI/examples/16_compact_two_stage_planetary_reducer/flanges.py +++ b/SimpleCADAPI/examples/16_compact_two_stage_planetary_reducer/flanges.py @@ -47,6 +47,7 @@ from dimensions import ( ) +@scad.requires_session def make_input_flange_rpart(*, material: scad.Material) -> scad.Part: """Create the reducer input flange part with six bolt holes.""" @@ -61,6 +62,7 @@ def make_input_flange_rpart(*, material: scad.Material) -> scad.Part: hole_count=INPUT_FLANGE_HOLE_COUNT, counterbore_diameter=INPUT_FLANGE_HOLE_COUNTERBORE_DIAMETER, counterbore_depth=INPUT_FLANGE_HOLE_COUNTERBORE_DEPTH, + tag_prefix="reducer.input.flange", ) flange = scad.translate_shape( shape=flange, @@ -90,10 +92,11 @@ def make_input_flange_rpart(*, material: scad.Material) -> scad.Part: ) +@scad.requires_session def make_output_flange_rpart(*, material: scad.Material) -> scad.Part: """Create the reducer output flange part with realistic mounting detail.""" - flange = _make_output_flange_solid_rsolid() + flange = _make_output_flange_solid_rsolid(tag_prefix="reducer.output.flange") flange = scad.translate_shape( shape=flange, vector=(0.0, 0.0, OUTPUT_FLANGE_BOTTOM_Z), @@ -123,7 +126,8 @@ def make_output_flange_rpart(*, material: scad.Material) -> scad.Part: ) -def _make_output_flange_solid_rsolid() -> scad.Solid: +@scad.requires_session +def _make_output_flange_solid_rsolid(*, tag_prefix: str) -> scad.Solid: """Build the sealed actuator-style output flange. The earlier example used a small six-hole disk. That was enough to prove @@ -139,12 +143,16 @@ def _make_output_flange_solid_rsolid() -> scad.Solid: height=OUTPUT_FLANGE_THICKNESS, bottom_face_center=(0.0, 0.0, 0.0), axis=(0.0, 0.0, 1.0), + tag_prefix=f"{tag_prefix}.base", + result_tag=f"solid.{tag_prefix}.base", ) boss = scad.make_cylinder_rsolid( radius=OUTPUT_FLANGE_BOSS_OUTER_DIAMETER / 2.0, height=OUTPUT_FLANGE_BOSS_HEIGHT + 0.05, bottom_face_center=(0.0, 0.0, OUTPUT_FLANGE_THICKNESS - 0.05), axis=(0.0, 0.0, 1.0), + tag_prefix=f"{tag_prefix}.boss", + result_tag=f"solid.{tag_prefix}.boss", ) register_outer = scad.make_cylinder_rsolid( @@ -152,12 +160,16 @@ def _make_output_flange_solid_rsolid() -> scad.Solid: height=OUTPUT_FLANGE_REGISTER_HEIGHT + 0.05, bottom_face_center=(0.0, 0.0, OUTPUT_FLANGE_THICKNESS - 0.05), axis=(0.0, 0.0, 1.0), + tag_prefix=f"{tag_prefix}.register.outer", + result_tag=f"solid.{tag_prefix}.register.outer", ) register_inner = scad.make_cylinder_rsolid( radius=OUTPUT_FLANGE_REGISTER_INNER_DIAMETER / 2.0, height=OUTPUT_FLANGE_REGISTER_HEIGHT + 0.55, bottom_face_center=(0.0, 0.0, OUTPUT_FLANGE_THICKNESS - 0.30), axis=(0.0, 0.0, 1.0), + tag_prefix=f"{tag_prefix}.register.inner", + result_tag=f"solid.{tag_prefix}.register.inner.cutter", ) register = scad.cut_rsolid(register_outer, register_inner, skip_non_intersecting=False) @@ -173,6 +185,8 @@ def _make_output_flange_solid_rsolid() -> scad.Solid: height=OUTPUT_FLANGE_THICKNESS + OUTPUT_FLANGE_BOSS_HEIGHT + 2.0, bottom_face_center=(0.0, 0.0, -1.0), axis=(0.0, 0.0, 1.0), + tag_prefix=f"{tag_prefix}.center.bore", + result_tag=f"solid.{tag_prefix}.center.bore.cutter", ) ] @@ -193,6 +207,8 @@ def _make_output_flange_solid_rsolid() -> scad.Solid: 0.0, OUTPUT_FLANGE_THICKNESS - 0.25, ), + tag_prefix=f"{tag_prefix}.register.gap.i{index + 1}", + result_tag=f"solid.{tag_prefix}.register.gap.i{index + 1}.cutter", ) cutters.append( scad.rotate_shape( @@ -211,7 +227,7 @@ def _make_output_flange_solid_rsolid() -> scad.Solid: side = -1.0 if hole_index == 0 else 1.0 output_hole_angles.append(pad_center_angle + side * OUTPUT_FLANGE_HOLE_OFFSET_DEGREES) - for angle_degrees in output_hole_angles: + for index, angle_degrees in enumerate(output_hole_angles): angle = math.radians(angle_degrees) x = output_bolt_radius * math.cos(angle) y = output_bolt_radius * math.sin(angle) @@ -225,6 +241,8 @@ def _make_output_flange_solid_rsolid() -> scad.Solid: height=OUTPUT_FLANGE_THICKNESS + OUTPUT_FLANGE_REGISTER_HEIGHT + 1.0, bottom_face_center=(x, y, -0.5), axis=(0.0, 0.0, 1.0), + tag_prefix=f"{tag_prefix}.link.hole.i{index + 1}", + result_tag=f"solid.{tag_prefix}.link.hole.i{index + 1}.cutter", ) ) cutters.append( @@ -239,6 +257,8 @@ def _make_output_flange_solid_rsolid() -> scad.Solid: - OUTPUT_FLANGE_HOLE_COUNTERBORE_DEPTH, ), axis=(0.0, 0.0, 1.0), + tag_prefix=f"{tag_prefix}.link.counterbore.i{index + 1}", + result_tag=f"solid.{tag_prefix}.link.counterbore.i{index + 1}.cutter", ) ) @@ -257,6 +277,8 @@ def _make_output_flange_solid_rsolid() -> scad.Solid: height=OUTPUT_FLANGE_THICKNESS + OUTPUT_FLANGE_BOSS_HEIGHT + 1.0, bottom_face_center=(x, y, -0.5), axis=(0.0, 0.0, 1.0), + tag_prefix=f"{tag_prefix}.cap.hole.i{index + 1}", + result_tag=f"solid.{tag_prefix}.cap.hole.i{index + 1}.cutter", ) ) cutters.append( @@ -271,6 +293,8 @@ def _make_output_flange_solid_rsolid() -> scad.Solid: - OUTPUT_FLANGE_CENTER_COUNTERBORE_DEPTH, ), axis=(0.0, 0.0, 1.0), + tag_prefix=f"{tag_prefix}.cap.counterbore.i{index + 1}", + result_tag=f"solid.{tag_prefix}.cap.counterbore.i{index + 1}.cutter", ) ) @@ -288,6 +312,7 @@ def _make_output_flange_solid_rsolid() -> scad.Solid: return flange +@scad.requires_session def _make_n_hole_flange_solid_rsolid( *, flange_outer_diameter: float, @@ -300,6 +325,7 @@ def _make_n_hole_flange_solid_rsolid( hole_count: int, counterbore_diameter: float | None = None, counterbore_depth: float = 0.0, + tag_prefix: str, ) -> scad.Solid: """Build a flange without edge-pick features so FreeCAD export is stable.""" @@ -308,12 +334,16 @@ def _make_n_hole_flange_solid_rsolid( height=flange_thickness, bottom_face_center=(0.0, 0.0, 0.0), axis=(0.0, 0.0, 1.0), + tag_prefix=f"{tag_prefix}.base", + result_tag=f"solid.{tag_prefix}.base", ) boss = scad.make_cylinder_rsolid( radius=boss_outer_diameter / 2.0, height=boss_height + 0.05, bottom_face_center=(0.0, 0.0, flange_thickness - 0.05), axis=(0.0, 0.0, 1.0), + tag_prefix=f"{tag_prefix}.boss", + result_tag=f"solid.{tag_prefix}.boss", ) flange = scad.union_rsolid([outer, boss], glue=False) @@ -323,6 +353,8 @@ def _make_n_hole_flange_solid_rsolid( height=flange_thickness + boss_height + 2.0, bottom_face_center=(0.0, 0.0, -1.0), axis=(0.0, 0.0, 1.0), + tag_prefix=f"{tag_prefix}.center.bore", + result_tag=f"solid.{tag_prefix}.center.bore.cutter", ) ] bolt_circle_radius = hole_circle_diameter / 2.0 @@ -338,6 +370,8 @@ def _make_n_hole_flange_solid_rsolid( -1.0, ), axis=(0.0, 0.0, 1.0), + tag_prefix=f"{tag_prefix}.mount.hole.i{index + 1}", + result_tag=f"solid.{tag_prefix}.mount.hole.i{index + 1}.cutter", ) ) if counterbore_diameter is not None and counterbore_depth > 0.0: @@ -354,6 +388,8 @@ def _make_n_hole_flange_solid_rsolid( flange_thickness - counterbore_depth, ), axis=(0.0, 0.0, 1.0), + tag_prefix=f"{tag_prefix}.mount.counterbore.i{index + 1}", + result_tag=f"solid.{tag_prefix}.mount.counterbore.i{index + 1}.cutter", ) ) flange = scad.cut_rsolid(flange, cutters, skip_non_intersecting=False) diff --git a/SimpleCADAPI/examples/16_compact_two_stage_planetary_reducer/gears.py b/SimpleCADAPI/examples/16_compact_two_stage_planetary_reducer/gears.py index 74a2deb..a2b52d3 100644 --- a/SimpleCADAPI/examples/16_compact_two_stage_planetary_reducer/gears.py +++ b/SimpleCADAPI/examples/16_compact_two_stage_planetary_reducer/gears.py @@ -28,6 +28,7 @@ from dimensions import ( ) +@scad.requires_session def make_stage_ring_gear_rpart( *, stage: StageSpec, @@ -46,25 +47,35 @@ def make_stage_ring_gear_rpart( addendum_factor=ADDENDUM_FACTOR, clearance_factor=CLEARANCE_FACTOR, ) + ring = scad.apply_tag(shape=ring, tag=f"solid.reducer.{stage.stage_id}.ring.gear") support = scad.make_cylinder_rsolid( radius=HOUSING_INNER_RADIUS, height=stage.gear_height, bottom_face_center=(0.0, 0.0, 0.0), axis=(0.0, 0.0, 1.0), + tag_prefix=f"reducer.{stage.stage_id}.ring.support.outer", + result_tag=f"solid.reducer.{stage.stage_id}.ring.support.outer", ) support_bore = scad.make_cylinder_rsolid( radius=stage.ring_outer_radius - FIXED_RING_HOUSING_SUPPORT_OVERLAP, height=stage.gear_height + 2.0, bottom_face_center=(0.0, 0.0, -1.0), axis=(0.0, 0.0, 1.0), + tag_prefix=f"reducer.{stage.stage_id}.ring.support.bore", + result_tag=f"solid.reducer.{stage.stage_id}.ring.support.bore.cutter", ) support = scad.cut_rsolid( support, support_bore, skip_non_intersecting=False, + tracking_policy=scad.TrackingPolicy.GRAPH, ) support = scad.apply_tag(shape=support, tag=f"role.{stage.stage_id}.fixed_ring_housing_support") - ring = scad.union_rsolid([ring, support], glue=False) + ring = scad.union_rsolid( + [ring, support], + glue=False, + tracking_policy=scad.TrackingPolicy.GRAPH, + ) ring = _apply_tags( ring, tags=(f"role.{stage.stage_id}.fixed_ring_gear", "group.two_stage_reducer"), @@ -91,6 +102,7 @@ def make_stage_ring_gear_rpart( ) +@scad.requires_session def make_stage_sun_gear_rpart( *, stage: StageSpec, @@ -109,10 +121,12 @@ def make_stage_sun_gear_rpart( clearance_factor=CLEARANCE_FACTOR, backlash=BACKLASH, ) + sun = scad.apply_tag(shape=sun, tag=f"solid.reducer.{stage.stage_id}.sun.gear") sun = _cut_bore_rsolid( label=f"{stage.stage_id}_sun_bore", solid=sun, bore_radius=bore_radius, + tag_prefix=f"reducer.{stage.stage_id}.sun.bore", ) sun = _apply_tags( sun, @@ -139,6 +153,7 @@ def make_stage_sun_gear_rpart( ) +@scad.requires_session def make_stage_planet_gear_rpart( *, stage: StageSpec, @@ -157,11 +172,13 @@ def make_stage_planet_gear_rpart( clearance_factor=CLEARANCE_FACTOR, backlash=BACKLASH, ) + planet = scad.apply_tag(shape=planet, tag=f"solid.reducer.{stage.stage_id}.planet.gear") bore_radius = bearing.outer_diameter / 2.0 + 0.06 planet = _cut_bore_rsolid( label=f"{stage.stage_id}_planet_bearing_seat", solid=planet, bore_radius=bore_radius, + tag_prefix=f"reducer.{stage.stage_id}.planet.bearing.seat", ) planet = _apply_tags( planet, @@ -194,6 +211,7 @@ def make_stage_planet_gear_rpart( ) +@scad.requires_session def make_planet_component_rplacement( *, stage: StageSpec, @@ -216,19 +234,28 @@ def make_planet_component_rplacement( return make_z_rotation_rplacement(origin=center, angle_degrees=planet_spin) +@scad.requires_session def _cut_bore_rsolid( *, label: str, solid: scad.Solid, bore_radius: float, + tag_prefix: str, ) -> scad.Solid: cutter = scad.make_cylinder_rsolid( radius=bore_radius, height=GEAR_HEIGHT + 2.0, bottom_face_center=(0.0, 0.0, -1.0), axis=(0.0, 0.0, 1.0), + tag_prefix=tag_prefix, + result_tag=f"solid.{tag_prefix}.cutter", + ) + bored = scad.cut_rsolid( + solid, + cutter, + skip_non_intersecting=False, + tracking_policy=scad.TrackingPolicy.GRAPH, ) - bored = scad.cut_rsolid(solid, cutter, skip_non_intersecting=False) bored = scad.apply_tag(shape=bored, tag=f"solid.cut.{label}") print(f"{label}: bore_radius={bore_radius:.3f} volume={bored.get_volume():.3f}") return bored diff --git a/SimpleCADAPI/examples/16_compact_two_stage_planetary_reducer/housing.py b/SimpleCADAPI/examples/16_compact_two_stage_planetary_reducer/housing.py index f0fd7c5..d993249 100644 --- a/SimpleCADAPI/examples/16_compact_two_stage_planetary_reducer/housing.py +++ b/SimpleCADAPI/examples/16_compact_two_stage_planetary_reducer/housing.py @@ -48,6 +48,7 @@ from dimensions import ( ) +@scad.requires_session def make_reducer_housing_rpart(*, material: scad.Material) -> scad.Part: """Create the through-bolted housing sleeve with internal datum collars.""" @@ -56,6 +57,7 @@ def make_reducer_housing_rpart(*, material: scad.Material) -> scad.Part: inner_radius=HOUSING_INNER_RADIUS, height=HOUSING_HEIGHT, bottom_z=HOUSING_BOTTOM_Z, + tag_prefix="reducer.housing.sleeve", tag="role.housing_sleeve", ) front_flange = _make_end_flange_rsolid( @@ -63,14 +65,16 @@ def make_reducer_housing_rpart(*, material: scad.Material) -> scad.Part: inner_radius=OUTPUT_SEAL_BORE_RADIUS, thickness=HOUSING_FRONT_FLANGE_THICKNESS, bottom_z=HOUSING_BOTTOM_Z + HOUSING_HEIGHT - HOUSING_FRONT_FLANGE_THICKNESS, + tag_prefix="reducer.housing.front.end.cap", ) rear_flange = _make_end_flange_rsolid( label="rear", inner_radius=INPUT_SEAL_BORE_RADIUS, thickness=HOUSING_REAR_FLANGE_THICKNESS, bottom_z=HOUSING_BOTTOM_Z, + tag_prefix="reducer.housing.rear.end.cap", ) - mount_pad = _make_mount_sector_pad_rsolid() + mount_pad = _make_mount_sector_pad_rsolid(tag_prefix="reducer.housing.mount.sector.pad") collars = [] datum_zs = ( INPUT_FLANGE_TOP_Z, @@ -91,6 +95,7 @@ def make_reducer_housing_rpart(*, material: scad.Material) -> scad.Part: inner_radius=HOUSING_DATUM_INNER_RADIUS, height=0.36, bottom_z=target_z - 0.36, + tag_prefix=f"reducer.housing.datum.collar.i{index + 1}", tag=f"role.housing_axis_datum_{index + 1}", ) collars.append(collar) @@ -102,8 +107,8 @@ def make_reducer_housing_rpart(*, material: scad.Material) -> scad.Part: housing = scad.cut_rsolid( housing, [ - _make_mount_gap_cutters_rsolids(), - _make_mount_hole_cutters_rsolids(), + _make_mount_gap_cutters_rsolids(tag_prefix="reducer.housing.mount.gap"), + _make_mount_hole_cutters_rsolids(tag_prefix="reducer.housing.mount.hole"), ], skip_non_intersecting=False, ) @@ -155,12 +160,14 @@ def make_reducer_housing_rpart(*, material: scad.Material) -> scad.Part: return part +@scad.requires_session def _make_end_flange_rsolid( *, label: str, inner_radius: float, thickness: float, bottom_z: float, + tag_prefix: str, ) -> scad.Solid: """Build one sealed housing end cap. @@ -174,6 +181,7 @@ def _make_end_flange_rsolid( inner_radius=inner_radius, height=thickness, bottom_z=bottom_z, + tag_prefix=tag_prefix, tag=f"role.housing_{label}_sealed_end_cap", ) clearance = OUTPUT_SEAL_RUNNING_CLEARANCE if label == "front" else INPUT_SEAL_RUNNING_CLEARANCE @@ -183,7 +191,8 @@ def _make_end_flange_rsolid( return flange -def _make_mount_sector_pad_rsolid() -> scad.Solid: +@scad.requires_session +def _make_mount_sector_pad_rsolid(*, tag_prefix: str) -> scad.Solid: """Build four graceful full-height sector pads before the global hole cut.""" outer = scad.make_cylinder_rsolid( @@ -191,12 +200,16 @@ def _make_mount_sector_pad_rsolid() -> scad.Solid: height=HOUSING_HEIGHT, bottom_face_center=(0.0, 0.0, HOUSING_BOTTOM_Z), axis=(0.0, 0.0, 1.0), + tag_prefix=f"{tag_prefix}.outer", + result_tag=f"solid.{tag_prefix}.outer", ) inner = scad.make_cylinder_rsolid( radius=HOUSING_MOUNT_PAD_INNER_RADIUS, height=HOUSING_HEIGHT + 2.0, bottom_face_center=(0.0, 0.0, HOUSING_BOTTOM_Z - 1.0), axis=(0.0, 0.0, 1.0), + tag_prefix=f"{tag_prefix}.inner", + result_tag=f"solid.{tag_prefix}.inner.cutter", ) pad = scad.cut_rsolid(outer, inner, skip_non_intersecting=False) pad = _apply_tags( @@ -211,7 +224,8 @@ def _make_mount_sector_pad_rsolid() -> scad.Solid: return pad -def _make_mount_gap_cutters_rsolids() -> list[scad.Solid]: +@scad.requires_session +def _make_mount_gap_cutters_rsolids(*, tag_prefix: str) -> list[scad.Solid]: """Build shallow radial gap cutters that divide the outer band into sectors.""" cutters = [] @@ -229,6 +243,8 @@ def _make_mount_gap_cutters_rsolids() -> list[scad.Solid]: height=HOUSING_MOUNT_SECTOR_GAP_WIDTH, depth=HOUSING_HEIGHT + 2.0, bottom_face_center=(gap_center_radius, 0.0, HOUSING_BOTTOM_Z - 1.0), + tag_prefix=f"{tag_prefix}.i{index + 1}", + result_tag=f"solid.{tag_prefix}.i{index + 1}.cutter", ) cutters.append( scad.rotate_shape( @@ -245,7 +261,8 @@ def _make_mount_gap_cutters_rsolids() -> list[scad.Solid]: return cutters -def _make_mount_hole_cutters_rsolids() -> list[scad.Solid]: +@scad.requires_session +def _make_mount_hole_cutters_rsolids(*, tag_prefix: str) -> list[scad.Solid]: """Build one shared cutter set for the boss and housing body holes.""" cutters = [] @@ -262,11 +279,23 @@ def _make_mount_hole_cutters_rsolids() -> list[scad.Solid]: for hole_index in range(HOUSING_MOUNT_HOLES_PER_SECTOR): angle_degrees = sector_angle_degrees + offsets[hole_index] angle = math.radians(angle_degrees) - cutters.extend(_make_single_mount_hole_cutters_rsolids(angle=angle)) + cutters.extend( + _make_single_mount_hole_cutters_rsolids( + angle=angle, + tag_prefix=( + f"{tag_prefix}.sector.i{sector_index + 1}.i{hole_index + 1}" + ), + ) + ) return cutters -def _make_single_mount_hole_cutters_rsolids(*, angle: float) -> list[scad.Solid]: +@scad.requires_session +def _make_single_mount_hole_cutters_rsolids( + *, + angle: float, + tag_prefix: str, +) -> list[scad.Solid]: """Build through and counterbore cutters for one housing screw.""" x = HOUSING_MOUNT_HOLE_CIRCLE_RADIUS * math.cos(angle) @@ -282,6 +311,8 @@ def _make_single_mount_hole_cutters_rsolids(*, angle: float) -> list[scad.Solid] height=HOUSING_HEIGHT + 2.0, bottom_face_center=(x, y, HOUSING_BOTTOM_Z - 1.0), axis=(0.0, 0.0, 1.0), + tag_prefix=f"{tag_prefix}.through", + result_tag=f"solid.{tag_prefix}.through.cutter", ) ) # Counterbores are added on both ends so the actuator can be mounted from @@ -297,6 +328,8 @@ def _make_single_mount_hole_cutters_rsolids(*, angle: float) -> list[scad.Solid] HOUSING_BOTTOM_Z + HOUSING_HEIGHT - HOUSING_MOUNT_COUNTERBORE_DEPTH, ), axis=(0.0, 0.0, 1.0), + tag_prefix=f"{tag_prefix}.front.counterbore", + result_tag=f"solid.{tag_prefix}.front.counterbore.cutter", ) ) cutters.append( @@ -305,6 +338,8 @@ def _make_single_mount_hole_cutters_rsolids(*, angle: float) -> list[scad.Solid] height=HOUSING_MOUNT_COUNTERBORE_DEPTH + 0.4, bottom_face_center=(x, y, HOUSING_BOTTOM_Z - 0.2), axis=(0.0, 0.0, 1.0), + tag_prefix=f"{tag_prefix}.rear.counterbore", + result_tag=f"solid.{tag_prefix}.rear.counterbore.cutter", ) ) return cutters diff --git a/SimpleCADAPI/examples/16_compact_two_stage_planetary_reducer/main.py b/SimpleCADAPI/examples/16_compact_two_stage_planetary_reducer/main.py index a291c2d..9dea0a3 100644 --- a/SimpleCADAPI/examples/16_compact_two_stage_planetary_reducer/main.py +++ b/SimpleCADAPI/examples/16_compact_two_stage_planetary_reducer/main.py @@ -8,9 +8,34 @@ from pathlib import Path import simplecadapi as scad -from assembly import make_two_stage_planetary_reducer_rassembly -from common import _ground_compound -from dimensions import HOUSING_HEIGHT, HOUSING_OUTER_RADIUS, TOTAL_REDUCTION +if __package__: + import importlib + + # The example also supports direct-file execution, so its child modules use + # sibling imports. Register those siblings before importing the package entry. + for _module_name in ( + "dimensions", + "common", + "materials", + "bearings", + "carriers", + "flanges", + "gears", + "housing", + "shafts", + "assembly", + ): + sys.modules.setdefault( + _module_name, + importlib.import_module(f"{__package__}.{_module_name}"), + ) + from .assembly import make_two_stage_planetary_reducer_rassembly + from .common import _ground_compound + from .dimensions import HOUSING_HEIGHT, HOUSING_OUTER_RADIUS, TOTAL_REDUCTION +else: + from assembly import make_two_stage_planetary_reducer_rassembly + from common import _ground_compound + from dimensions import HOUSING_HEIGHT, HOUSING_OUTER_RADIUS, TOTAL_REDUCTION # Herringbone gear profile graphs are intentionally deep. @@ -19,16 +44,16 @@ sys.setrecursionlimit(30000) OUT_DIR = Path("examples/out/compact_two_stage_planetary_reducer") +@scad.model(graph_id="compact_two_stage_planetary_reducer") def _build_compact_two_stage_planetary_reducer(): - """Build the reducer and return assembly, preview compound, and JSON exports.""" + """Build the reducer and return its assembly and preview compound.""" - with scad.GraphSession(graph_id="compact_two_stage_planetary_reducer") as session: - assembly = make_two_stage_planetary_reducer_rassembly() - preview = scad.make_compound_from_assembly_rcompound(assembly=assembly) - _ground_compound(label="reducer_preview", compound=preview) - session_json = scad.export_session_json(session=session) - model_json = scad.export_model_json(session=session) - return assembly, preview, model_json, session_json + assembly = make_two_stage_planetary_reducer_rassembly() + preview = scad.make_compound_from_assembly_rcompound(assembly=assembly) + preview = scad.apply_tag(shape=preview, tag="scene.reducer.preview") + _ground_compound(label="reducer_preview", compound=preview) + scad.capture_result(value=(assembly, preview)) + return assembly, preview def main() -> None: @@ -42,19 +67,20 @@ def main() -> None: if fcstd_path.exists(): fcstd_path.unlink() - assembly, preview, model_json, session_json = _build_compact_two_stage_planetary_reducer() - model_path.write_text(model_json, encoding="utf-8") - session_path.write_text(session_json, encoding="utf-8") + result = _build_compact_two_stage_planetary_reducer() + assembly, preview = result.value + model_path.write_text(result.model_json, encoding="utf-8") + session_path.write_text(result.session_json, encoding="utf-8") scad.export_step(shapes=preview, filename=str(step_path)) - imported = scad.import_model_json(json_str=model_json) - replayed = scad.replay_model_json(json_str=model_json) - payload = json.loads(model_json) + imported = scad.import_model_json(json_str=result.model_json) + replayed = scad.replay_model_json(json_str=result.model_json) + payload = json.loads(result.model_json) fcstd_status = "not attempted" try: scad.translator.freecad_translator.translate_model_json_to_fcstd( - json_str=model_json, + json_str=result.model_json, output_path=str(fcstd_path.resolve()), document_name="CompactTwoStagePlanetaryReducer", freecad_cmd=None, diff --git a/SimpleCADAPI/examples/16_compact_two_stage_planetary_reducer/materials.py b/SimpleCADAPI/examples/16_compact_two_stage_planetary_reducer/materials.py index d579571..ed2d984 100644 --- a/SimpleCADAPI/examples/16_compact_two_stage_planetary_reducer/materials.py +++ b/SimpleCADAPI/examples/16_compact_two_stage_planetary_reducer/materials.py @@ -5,6 +5,7 @@ from __future__ import annotations import simplecadapi as scad +@scad.requires_session def make_reducer_materials_rdict() -> dict[str, scad.Material]: """Create the small set of reusable material records for the assembly.""" diff --git a/SimpleCADAPI/examples/16_compact_two_stage_planetary_reducer/shafts.py b/SimpleCADAPI/examples/16_compact_two_stage_planetary_reducer/shafts.py index 8bd41b6..bbc37d3 100644 --- a/SimpleCADAPI/examples/16_compact_two_stage_planetary_reducer/shafts.py +++ b/SimpleCADAPI/examples/16_compact_two_stage_planetary_reducer/shafts.py @@ -8,6 +8,7 @@ from common import _apply_tags, add_placement_axis_connector_rpart, make_axis_pa from dimensions import INPUT_BEARING_Z, INPUT_FLANGE_TOP_Z, INPUT_SHAFT_RADIUS, STAGE_1 +@scad.requires_session def make_input_shaft_rpart(*, material: scad.Material) -> scad.Part: """Create the input shaft linking the input flange and stage 1 sun.""" @@ -17,6 +18,8 @@ def make_input_shaft_rpart(*, material: scad.Material) -> scad.Part: height=height, bottom_face_center=(0.0, 0.0, INPUT_FLANGE_TOP_Z), axis=(0.0, 0.0, 1.0), + tag_prefix="reducer.input.shaft", + result_tag="solid.reducer.input.shaft", ) shaft = _apply_tags( shaft, diff --git a/SimpleCADAPI/examples/17_static_collision_verifier.py b/SimpleCADAPI/examples/17_static_collision_verifier.py deleted file mode 100644 index 3183e51..0000000 --- a/SimpleCADAPI/examples/17_static_collision_verifier.py +++ /dev/null @@ -1,75 +0,0 @@ -"""Example 17: static current-pose collision verification. - -Run from the repository root with: - uv run python examples/17_static_collision_verifier.py - -This example checks the current placements of two box components. The verifier -uses internal cached meshes and python-fcl to report mesh contact penetration -deeper than the configured tolerance. It does not solve constraints or detect -complete containment cases. -""" - -from __future__ import annotations - -import simplecadapi as scad - - -def _box_part() -> scad.Part: - body = scad.make_box_rsolid(width=1.0, height=1.0, depth=1.0) - return scad.make_part_rpart(part_id="unit_box", body=body) - - -def _assembly_with_offset(offset: tuple[float, float, float]) -> scad.Assembly: - part = _box_part() - assembly = scad.make_assembly_rassembly(assembly_id="static_collision_demo") - assembly = scad.add_component_rassembly( - assembly=assembly, - item=part, - component_id="box_a", - placement=scad.identity_placement_rplacement(), - ) - assembly = scad.add_component_rassembly( - assembly=assembly, - item=part, - component_id="box_b", - placement=scad.make_placement_rplacement(origin=offset), - ) - return assembly - - -def _print_report(label: str, report: scad.verifier.CollisionReport) -> None: - print(label, f"completed={report.completed}", f"passed={report.passed}") - print(label, f"checked_pairs={report.checked_pair_count}", f"failures={report.failed_pair_count}") - for failure in report.failures: - print( - label, - "failure", - "/".join(failure.component_a), - "/".join(failure.component_b), - f"depth={failure.penetration_depth:.4f}", - f"allowed={failure.allowed_penetration:.4f}", - ) - for warning in report.warnings: - print(label, "warning", warning.code, warning.message) - - -def main() -> None: - config = scad.verifier.CollisionCheckConfig(max_allowed_penetration=0.01) - - separated = _assembly_with_offset(offset=(2.0, 0.0, 0.0)) - separated_report = scad.verifier.check_collision_rcollisionreport( - assembly=separated, - config=config, - ) - _print_report("separated", separated_report) - - overlapping = _assembly_with_offset(offset=(0.5, 0.0, 0.0)) - overlapping_report = scad.verifier.check_collision_rcollisionreport( - assembly=overlapping, - config=config, - ) - _print_report("overlapping", overlapping_report) - - -if __name__ == "__main__": - main() diff --git a/SimpleCADAPI/examples/18_leg_wheel_robot_dog_leg/actuator.py b/SimpleCADAPI/examples/18_leg_wheel_robot_dog_leg/actuator.py deleted file mode 100644 index 61f0576..0000000 --- a/SimpleCADAPI/examples/18_leg_wheel_robot_dog_leg/actuator.py +++ /dev/null @@ -1,142 +0,0 @@ -"""Safe reuse boundary for the integrated Example 20 joint actuator.""" - -from __future__ import annotations - -import importlib -import sys -from pathlib import Path - -import simplecadapi as scad - - -EXAMPLES_DIR = Path(__file__).resolve().parents[1] -if str(EXAMPLES_DIR) not in sys.path: - sys.path.insert(0, str(EXAMPLES_DIR)) - -_assembly = importlib.import_module("20_integrated_bldc_joint_actuator.assembly") -_dimensions = importlib.import_module("20_integrated_bldc_joint_actuator.dimensions") -_materials = importlib.import_module("20_integrated_bldc_joint_actuator.materials") - -ACTUATOR_CASE_CLAMP_Z = _dimensions.OUTPUT_CASE_CLAMP_CENTER_Z -ACTUATOR_OUTPUT_FACE_Z = _dimensions.OUTPUT_FLANGE_TOP_Z -ACTUATOR_PACKAGE_RADIUS = _dimensions.PACKAGE_RADIUS -ACTUATOR_PACKAGE_BOTTOM_Z = _dimensions.PACKAGE_STRUCTURAL_BOTTOM_Z -ACTUATOR_PACKAGE_TOP_Z = _dimensions.PACKAGE_TOP_Z -OUTPUT_BOLT_ANGLES_DEGREES = _dimensions.OUTPUT_LINK_BOLT_ANGLES_DEGREES -OUTPUT_BOLT_CIRCLE_RADIUS = _dimensions.OUTPUT_LINK_HOLE_PCD / 2.0 -OUTPUT_BOLT_COUNT = _dimensions.OUTPUT_LINK_BOLT_COUNT -OUTPUT_TAP_RADIUS = _dimensions.OUTPUT_LINK_TAP_RADIUS -OUTPUT_REGISTER_HEIGHT = _dimensions.OUTPUT_REGISTER_HEIGHT -OUTPUT_REGISTER_RADIUS = _dimensions.OUTPUT_REGISTER_RADIUS - - -def make_actuator_materials_rdict() -> dict[str, scad.Material]: - """Create the externally supplied material set used by Example 20.""" - - return _materials.make_actuator_materials_rdict() - - -def make_joint_actuator_rassembly( - *, materials: dict[str, scad.Material] -) -> scad.Assembly: - """Build the complete actuator as a two-body kinematic subassembly.""" - - _dimensions.validate_design_dimensions() - component_specs = _assembly.make_integrated_bldc_joint_actuator_components_rtuple( - materials=materials - ) - fixed_body = scad.make_assembly_rassembly( - assembly_id="integrated_50mm_bldc_joint_actuator_fixed_body", - name="Rigid actuator housing, motor, electronics, and reducer internals", - ) - output_carrier = next( - component for component in component_specs if component[0] == "output_carrier" - ) - for component_id, item, source_placement, name in component_specs: - if component_id == "output_carrier": - continue - fixed_body = scad.add_component_rassembly( - assembly=fixed_body, - item=item, - component_id=component_id, - placement=source_placement, - name=name, - ) - - for connector_id, source_component_id, source_connector_id, name in ( - ("case_clamp_axis", "reducer_housing", "case_clamp_axis", "External split-clamp datum"), - ("case_mount_axis", "output_bearing_cap", "case_mount_axis", "Fixed actuator case datum"), - ("output_support_axis", "reducer_housing", "stage2_carrier_axis", "Output carrier bearing axis"), - ("phase_terminal_access", "controller", "phase_access", "Rear phase-terminal service datum"), - ("power_can_terminal_access", "controller", "power_can_access", "Rear power/CAN service datum"), - ): - fixed_body = scad.forward_connector_rassembly( - assembly=fixed_body, - connector_id=connector_id, - source_component_id=source_component_id, - source_connector_id=source_connector_id, - name=name, - ) - - actuator = scad.make_assembly_rassembly( - assembly_id="leg_joint_actuator", - name="50 mm integrated BLDC actuator with one external output degree of freedom", - ) - actuator = scad.add_component_rassembly( - assembly=actuator, - item=fixed_body, - component_id="fixed_body", - placement=scad.identity_placement_rplacement(), - name="Rigid actuator body", - ) - actuator = scad.add_component_rassembly( - assembly=actuator, - item=output_carrier[1], - component_id="output_carrier", - placement=output_carrier[2], - name=output_carrier[3], - ) - actuator = scad.ground_component_rassembly( - assembly=actuator, - component_id="fixed_body", - ) - actuator = scad.add_revolute_constraint_rassembly( - assembly=actuator, - constraint_id="output_revolute", - connector_a=scad.make_connector_ref_rconnectorref( - component_id="fixed_body", - connector_id="output_support_axis", - ), - connector_b=scad.make_connector_ref_rconnectorref( - component_id="output_carrier", - connector_id="carrier_axis", - ), - name="Actuator output carrier rotation", - ) - actuator = scad.solve_assembly_constraints_rassembly( - assembly=actuator, - strict=True, - ) - for connector_id, source_component_id, source_connector_id, name in ( - ("case_clamp_axis", "fixed_body", "case_clamp_axis", "External split-clamp datum"), - ("case_mount_axis", "fixed_body", "case_mount_axis", "Fixed actuator case datum"), - ("output_link_axis", "output_carrier", "output_link_axis", "Rotating six-hole output flange"), - ("phase_terminal_access", "fixed_body", "phase_terminal_access", "Rear phase-terminal service datum"), - ("power_can_terminal_access", "fixed_body", "power_can_terminal_access", "Rear power/CAN service datum"), - ): - actuator = scad.forward_connector_rassembly( - assembly=actuator, - connector_id=connector_id, - source_component_id=source_component_id, - source_connector_id=source_connector_id, - name=name, - ) - print( - "leg_joint_actuator: " - f"diameter={ACTUATOR_PACKAGE_RADIUS * 2.0:.1f} " - f"length={ACTUATOR_PACKAGE_TOP_Z - ACTUATOR_PACKAGE_BOTTOM_Z:.1f} " - f"output_pcd={OUTPUT_BOLT_CIRCLE_RADIUS * 2.0:.1f} " - f"components={len(actuator.component_ids())} revolutes=1 " - f"connectors={','.join(actuator.connector_ids())}" - ) - return actuator diff --git a/SimpleCADAPI/examples/18_leg_wheel_robot_dog_leg/brackets.py b/SimpleCADAPI/examples/18_leg_wheel_robot_dog_leg/brackets.py deleted file mode 100644 index 049aff7..0000000 --- a/SimpleCADAPI/examples/18_leg_wheel_robot_dog_leg/brackets.py +++ /dev/null @@ -1,194 +0,0 @@ -"""Split-clamp actuator mounts for the leg-wheel example.""" - -from __future__ import annotations - -import simplecadapi as scad - -from leg_common import make_part_with_connectors_rpart -from leg_dimensions import ( - BODY_STANDOFF_THICKNESS, - BODY_STANDOFF_Z, - CASE_CLAMP_INNER_RADIUS, - CASE_CLAMP_OUTER_RADIUS, - CASE_CLAMP_PINCH_AXIS_RADIUS, - CASE_CLAMP_PINCH_HALF_SPAN, - CASE_CLAMP_PINCH_HOLE_RADIUS, - CASE_CLAMP_SLIT_WIDTH, - CASE_CLAMP_WIDTH, - KNEE_CASE_CLAMP_Z, - ROOT_AXIS, - THIGH_CASE_CLAMP_Z, -) - - -def make_split_case_clamp_rsolid( - *, center: tuple[float, float, float], z_center: float, tag: str -) -> scad.Solid: - """Create one machinable C-clamp with coaxial pinch-bolt ears.""" - - z_min = z_center - CASE_CLAMP_WIDTH / 2.0 - outer = scad.make_cylinder_rsolid( - radius=CASE_CLAMP_OUTER_RADIUS, - height=CASE_CLAMP_WIDTH, - bottom_face_center=(center[0], center[1], z_min), - axis=(0.0, 0.0, 1.0), - ) - bore = scad.make_cylinder_rsolid( - radius=CASE_CLAMP_INNER_RADIUS, - height=CASE_CLAMP_WIDTH + 2.0, - bottom_face_center=(center[0], center[1], z_min - 1.0), - axis=(0.0, 0.0, 1.0), - ) - clamp = scad.cut_rsolid(outer, bore, skip_non_intersecting=False) - - ear_center_x = center[0] + CASE_CLAMP_PINCH_AXIS_RADIUS - 2.5 - ear_center_y = CASE_CLAMP_SLIT_WIDTH / 2.0 + 2.5 - ears = [ - scad.make_box_rsolid( - width=8.0, - height=5.0, - depth=CASE_CLAMP_WIDTH, - bottom_face_center=(ear_center_x, center[1] + sign * ear_center_y, z_min), - ) - for sign in (-1.0, 1.0) - ] - clamp = scad.union_rsolid(clamp, ears, glue=False) - slit = scad.make_box_rsolid( - width=15.0, - height=CASE_CLAMP_SLIT_WIDTH, - depth=CASE_CLAMP_WIDTH + 2.0, - bottom_face_center=(center[0] + 31.0, center[1], z_min - 1.0), - ) - pinch_hole = scad.make_cylinder_rsolid( - radius=CASE_CLAMP_PINCH_HOLE_RADIUS, - height=CASE_CLAMP_PINCH_HALF_SPAN * 2.0, - bottom_face_center=( - center[0] + CASE_CLAMP_PINCH_AXIS_RADIUS, - center[1] - CASE_CLAMP_PINCH_HALF_SPAN, - z_center, - ), - axis=(0.0, 1.0, 0.0), - ) - clamp = scad.cut_rsolid( - clamp, - slit, - pinch_hole, - skip_non_intersecting=False, - ) - clamp = scad.apply_tag(shape=clamp, tag=tag) - print( - f"{tag}: bore_d={CASE_CLAMP_INNER_RADIUS * 2.0:.2f} " - f"pinch_hole_d={CASE_CLAMP_PINCH_HOLE_RADIUS * 2.0:.2f} " - f"faces={len(clamp.get_faces())} volume={clamp.get_volume():.3f}" - ) - return clamp - - -def make_body_mount_plate_rpart(*, material: scad.Material) -> scad.Part: - """Build the two-collar body mount for coaxial tandem root actuators.""" - - lower = make_split_case_clamp_rsolid( - center=ROOT_AXIS, - z_center=THIGH_CASE_CLAMP_Z, - tag="role.thigh_actuator_split_clamp", - ) - upper = make_split_case_clamp_rsolid( - center=ROOT_AXIS, - z_center=KNEE_CASE_CLAMP_Z, - tag="role.knee_drive_split_clamp", - ) - lower_z = THIGH_CASE_CLAMP_Z - CASE_CLAMP_WIDTH / 2.0 - upper_z = KNEE_CASE_CLAMP_Z + CASE_CLAMP_WIDTH / 2.0 - post_height = upper_z - lower_z - post_offset_x = 35.0 - posts = [ - scad.make_cylinder_rsolid( - radius=3.8, - height=post_height, - bottom_face_center=(ROOT_AXIS[0] + sign * post_offset_x, ROOT_AXIS[1], lower_z), - axis=(0.0, 0.0, 1.0), - ) - for sign in (-1.0, 1.0) - ] - collar_bridges = [ - scad.make_box_rsolid( - width=8.0, - height=7.6, - depth=CASE_CLAMP_WIDTH, - bottom_face_center=( - ROOT_AXIS[0] + sign * 33.0, - ROOT_AXIS[1], - clamp_z - CASE_CLAMP_WIDTH / 2.0, - ), - ) - for sign in (-1.0, 1.0) - for clamp_z in (THIGH_CASE_CLAMP_Z, KNEE_CASE_CLAMP_Z) - ] - lug_z_min = BODY_STANDOFF_Z - BODY_STANDOFF_THICKNESS / 2.0 - lugs = [ - scad.make_box_rsolid( - width=24.0, - height=18.0, - depth=BODY_STANDOFF_THICKNESS, - bottom_face_center=(ROOT_AXIS[0] + sign * 41.0, ROOT_AXIS[1], lug_z_min), - ) - for sign in (-1.0, 1.0) - ] - mount = scad.union_rsolid(lower, upper, posts, collar_bridges, lugs, glue=False) - torso_holes = [ - scad.make_cylinder_rsolid( - radius=2.3, - height=BODY_STANDOFF_THICKNESS + 2.0, - bottom_face_center=(ROOT_AXIS[0] + sign * 45.0, ROOT_AXIS[1], lug_z_min - 1.0), - axis=(0.0, 0.0, 1.0), - ) - for sign in (-1.0, 1.0) - ] - mount = scad.cut_rsolid(mount, torso_holes, skip_non_intersecting=False) - mount = scad.apply_tag(shape=mount, tag="role.body_mount_plate") - print( - "body_mount_plate: root_actuators=2 tandem=true " - f"clamp_z=({THIGH_CASE_CLAMP_Z:.1f},{KNEE_CASE_CLAMP_Z:.1f}) " - f"torso_holes=2 faces={len(mount.get_faces())} volume={mount.get_volume():.3f}" - ) - return make_part_with_connectors_rpart( - part_id="body_mount_plate", - body=mount, - name="Coaxial tandem root actuator split-clamp and body lug bracket", - material=material, - connectors=( - ("case_axis", (ROOT_AXIS[0], ROOT_AXIS[1], THIGH_CASE_CLAMP_Z), "z", "Thigh actuator clamp datum"), - ( - "knee_drive_case_axis", - (ROOT_AXIS[0], ROOT_AXIS[1], KNEE_CASE_CLAMP_Z), - "z", - "Knee-drive actuator clamp datum opposite the crank", - ), - ( - "thigh_clamp_bolt_seat", - ( - ROOT_AXIS[0] + CASE_CLAMP_PINCH_AXIS_RADIUS, - ROOT_AXIS[1] + CASE_CLAMP_PINCH_HALF_SPAN - 0.9, - THIGH_CASE_CLAMP_Z, - ), - "y", - "Thigh collar M4 bolt head seat", - ), - ( - "knee_clamp_bolt_seat", - ( - ROOT_AXIS[0] + CASE_CLAMP_PINCH_AXIS_RADIUS, - ROOT_AXIS[1] + CASE_CLAMP_PINCH_HALF_SPAN - 0.9, - KNEE_CASE_CLAMP_Z, - ), - "y", - "Knee-drive collar M4 bolt head seat", - ), - ( - "body_frame_axis", - (ROOT_AXIS[0], ROOT_AXIS[1], BODY_STANDOFF_Z), - "z", - "Body frame datum", - ), - ), - ) diff --git a/SimpleCADAPI/examples/18_leg_wheel_robot_dog_leg/collision_probe.py b/SimpleCADAPI/examples/18_leg_wheel_robot_dog_leg/collision_probe.py deleted file mode 100644 index 2d72db5..0000000 --- a/SimpleCADAPI/examples/18_leg_wheel_robot_dog_leg/collision_probe.py +++ /dev/null @@ -1,151 +0,0 @@ -"""Static external-envelope collision probe for the rebuilt Example 18.""" - -from __future__ import annotations - -import contextlib -import io -import sys -import time - -import simplecadapi as scad - -from actuator import make_actuator_materials_rdict -from leg_assembly import make_leg_wheel_robot_dog_leg_rassembly -from leg_materials import make_leg_materials_rdict - - -ACTUATOR_IDS = ( - "thigh_actuator", - "knee_drive_actuator", - "wheel_hub_actuator", -) -EXTERNAL_ACTUATOR_LEAVES = ( - ("reducer_housing",), - ("motor_shell",), - ("rear_electronics_cover",), - ("output_bearing_cap",), - ("output_carrier",), - ("controller", "three_phase_terminal"), - ("controller", "power_can_terminal"), -) -TOP_LEVEL_EXTERNALS = ( - "body_mount_plate", - "upper_link_plate", - "proximal_output_crank", - "knee_pushrod", - "shank_link", - "wheel_hub", - "wheel_tire", - "knee_bushing", - "knee_axle", - "thigh_clamp_bolt", - "knee_drive_clamp_bolt", - "wheel_clamp_bolt", - "proximal_linkage_pin", - "distal_linkage_pin", -) - - -def _leg_level_component_paths() -> tuple[tuple[str, ...], ...]: - paths: list[tuple[str, ...]] = [] - for actuator_id in ACTUATOR_IDS: - for leaf in EXTERNAL_ACTUATOR_LEAVES: - if leaf == ("output_carrier",): - paths.append((actuator_id, *leaf)) - else: - paths.append((actuator_id, "fixed_body", *leaf)) - paths.extend((component_id,) for component_id in TOP_LEVEL_EXTERNALS) - for interface in ("thigh", "knee_drive", "wheel"): - paths.extend((f"{interface}_output_screw_{index}",) for index in range(1, 7)) - return tuple(paths) - - -def _intentional_mating_pairs() -> tuple[scad.verifier.ComponentPair, ...]: - pairs = [ - scad.verifier.ComponentPair("wheel_hub", "wheel_tire"), - scad.verifier.ComponentPair("body_mount_plate", "thigh_clamp_bolt"), - scad.verifier.ComponentPair("body_mount_plate", "knee_drive_clamp_bolt"), - scad.verifier.ComponentPair("shank_link", "wheel_clamp_bolt"), - ] - for actuator_id in ACTUATOR_IDS: - pairs.append( - scad.verifier.ComponentPair( - (actuator_id, "fixed_body", "reducer_housing"), - (actuator_id, "fixed_body", "output_bearing_cap"), - ) - ) - for interface, actuator_id, driven_component_id in ( - ("thigh", "thigh_actuator", "upper_link_plate"), - ("knee_drive", "knee_drive_actuator", "proximal_output_crank"), - ("wheel", "wheel_hub_actuator", "wheel_hub"), - ): - pairs.append( - scad.verifier.ComponentPair( - (actuator_id, "output_carrier"), - driven_component_id, - ) - ) - for index in range(1, 7): - pairs.append( - scad.verifier.ComponentPair( - f"{interface}_output_screw_{index}", - (actuator_id, "output_carrier"), - ) - ) - return tuple(pairs) - - -def main() -> None: - sys.setrecursionlimit(40000) - build_log = io.StringIO() - start = time.perf_counter() - with contextlib.redirect_stdout(build_log): - assembly = make_leg_wheel_robot_dog_leg_rassembly( - actuator_materials=make_actuator_materials_rdict(), - leg_materials=make_leg_materials_rdict(), - ) - build_seconds = time.perf_counter() - start - - config = scad.verifier.CollisionCheckConfig( - max_allowed_penetration=0.08, - max_contacts_per_pair=32, - scope=scad.verifier.CollisionScope( - component_paths=_leg_level_component_paths(), - exclude_pairs=_intentional_mating_pairs(), - ), - ) - start = time.perf_counter() - report = scad.verifier.check_collision_rcollisionreport( - assembly=assembly, - config=config, - ) - check_seconds = time.perf_counter() - start - - print(f"assembly {assembly.assembly_id}") - print(f"build_log_lines {len(build_log.getvalue().splitlines())}") - print(f"build_seconds {build_seconds:.3f}") - print(f"check_seconds {check_seconds:.3f}") - print(f"completed {report.completed}") - print(f"passed {report.passed}") - print(f"checked_pair_count {report.checked_pair_count}") - print(f"failed_pair_count {report.failed_pair_count}") - print(f"warning_count {len(report.warnings)}") - for warning in report.warnings: - path = "/".join(warning.component_path or ()) - print(f"warning {path} {warning.code} {warning.message}") - for failure in sorted( - report.failures, - key=lambda item: item.penetration_depth, - reverse=True, - )[:25]: - print( - "failure", - "/".join(failure.component_a), - "/".join(failure.component_b), - f"depth={failure.penetration_depth:.3f}", - f"allowed={failure.allowed_penetration:.3f}", - ) - - -if __name__ == "__main__": - main() diff --git a/SimpleCADAPI/examples/18_leg_wheel_robot_dog_leg/hardware.py b/SimpleCADAPI/examples/18_leg_wheel_robot_dog_leg/hardware.py deleted file mode 100644 index 56d1e57..0000000 --- a/SimpleCADAPI/examples/18_leg_wheel_robot_dog_leg/hardware.py +++ /dev/null @@ -1,164 +0,0 @@ -"""Modeled bolts, threaded fasteners, and joint bushings for Example 18.""" - -from __future__ import annotations - -import simplecadapi as scad - -from leg_common import make_part_with_connectors_rpart -from leg_dimensions import KNEE_AXIS - - -def make_socket_head_screw_rpart( - *, - part_id: str, - shank_radius: float, - shank_length: float, - head_radius: float, - head_height: float, - material: scad.Material, -) -> scad.Part: - """Create a socket-head screw with its seat plane at local Z=0.""" - - shank = scad.make_cylinder_rsolid( - radius=shank_radius, - height=shank_length, - bottom_face_center=(0.0, 0.0, -head_height - shank_length), - axis=(0.0, 0.0, 1.0), - ) - head = scad.make_cylinder_rsolid( - radius=head_radius, - height=head_height, - bottom_face_center=(0.0, 0.0, -head_height), - axis=(0.0, 0.0, 1.0), - ) - screw = scad.union_rsolid(shank, head, glue=False) - screw = scad.apply_tag(shape=screw, tag="role.socket_head_screw") - return make_part_with_connectors_rpart( - part_id=part_id, - body=screw, - name=f"Socket-head screw {shank_radius * 2.0:.1f} x {shank_length:.1f} mm", - material=material, - connectors=(("head_top_axis", (0.0, 0.0, 0.0), "z", "Flush screw head top plane"),), - ) - - -def make_clamp_bolt_stack_rpart(*, material: scad.Material) -> scad.Part: - """Create an M4 bolt plus flange-nut stack for a split collar.""" - - shank_length = 12.2 - shank = scad.make_cylinder_rsolid( - radius=2.0, - height=shank_length, - bottom_face_center=(0.0, 0.0, -shank_length), - axis=(0.0, 0.0, 1.0), - ) - head = scad.make_cylinder_rsolid( - radius=3.6, - height=3.2, - bottom_face_center=(0.0, 0.0, -0.05), - axis=(0.0, 0.0, 1.0), - ) - nut = scad.make_cylinder_rsolid( - radius=3.8, - height=3.2, - bottom_face_center=(0.0, 0.0, -shank_length - 3.15), - axis=(0.0, 0.0, 1.0), - ) - stack = scad.union_rsolid(shank, head, nut, glue=False) - stack = scad.apply_tag(shape=stack, tag="role.clamp_bolt_and_nut") - return make_part_with_connectors_rpart( - part_id="m4_split_clamp_bolt_stack", - body=stack, - name="M4 split-clamp socket bolt and flange nut", - material=material, - connectors=(("seat_axis", (0.0, 0.0, 0.0), "z", "Clamp bolt head seat"),), - ) - - -def make_knee_bushing_rpart(*, material: scad.Material) -> scad.Part: - """Create a continuous bronze knee sleeve and axial spacer.""" - - outer = scad.make_cylinder_rsolid( - radius=5.95, - height=13.0, - bottom_face_center=(KNEE_AXIS[0], KNEE_AXIS[1], 0.0), - axis=(0.0, 0.0, 1.0), - ) - bore = scad.make_cylinder_rsolid( - radius=3.2, - height=15.0, - bottom_face_center=(KNEE_AXIS[0], KNEE_AXIS[1], -1.0), - axis=(0.0, 0.0, 1.0), - ) - sleeve = scad.cut_rsolid(outer, bore, skip_non_intersecting=False) - sleeve = scad.apply_tag(shape=sleeve, tag="role.knee_bearing_bushing") - return make_part_with_connectors_rpart( - part_id="knee_bronze_bushing", - body=sleeve, - name="12 mm OD bronze knee bushing and spacer", - material=material, - connectors=( - ("knee_axis", (KNEE_AXIS[0], KNEE_AXIS[1], 6.5), "z", "Knee revolute axis"), - ("bolt_head_top_axis", (KNEE_AXIS[0], KNEE_AXIS[1], 16.0), "z", "Shoulder bolt head top plane"), - ), - ) - - -def make_knee_shoulder_bolt_stack_rpart(*, material: scad.Material) -> scad.Part: - """Create the knee shoulder axle, socket head, and retained nut.""" - - shaft = scad.make_cylinder_rsolid( - radius=3.0, - height=13.0, - bottom_face_center=(KNEE_AXIS[0], KNEE_AXIS[1], 0.0), - axis=(0.0, 0.0, 1.0), - ) - head = scad.make_cylinder_rsolid( - radius=5.5, - height=3.0, - bottom_face_center=(KNEE_AXIS[0], KNEE_AXIS[1], 13.0), - axis=(0.0, 0.0, 1.0), - ) - nut = scad.make_cylinder_rsolid( - radius=5.5, - height=4.0, - bottom_face_center=(KNEE_AXIS[0], KNEE_AXIS[1], -4.0), - axis=(0.0, 0.0, 1.0), - ) - axle = scad.union_rsolid(shaft, head, nut, glue=False) - axle = scad.apply_tag(shape=axle, tag="role.knee_shoulder_axle") - return make_part_with_connectors_rpart( - part_id="knee_shoulder_bolt_stack", - body=axle, - name="M6 knee shoulder axle with socket head and retained nut", - material=material, - connectors=(("knee_axis", (KNEE_AXIS[0], KNEE_AXIS[1], 6.5), "z", "Knee axle axis"),), - ) -def make_linkage_pin_stack_rpart(*, material: scad.Material) -> scad.Part: - """Create a retained M4 shoulder pin spanning crank, gap, and pushrod.""" - - span = 9.3 - shaft = scad.make_cylinder_rsolid( - radius=2.0, - height=span, - bottom_face_center=(0.0, 0.0, -span / 2.0), - axis=(0.0, 0.0, 1.0), - ) - retainers = [ - scad.make_cylinder_rsolid( - radius=3.5, - height=1.2, - bottom_face_center=(0.0, 0.0, sign * span / 2.0 - (1.2 if sign < 0.0 else 0.0)), - axis=(0.0, 0.0, 1.0), - ) - for sign in (-1.0, 1.0) - ] - pin = scad.union_rsolid(shaft, retainers, glue=False) - pin = scad.apply_tag(shape=pin, tag="role.retained_linkage_pin") - return make_part_with_connectors_rpart( - part_id="m4_linkage_shoulder_pin_stack", - body=pin, - name="M4 retained linkage shoulder pin", - material=material, - connectors=(("pin_axis", (0.0, 0.0, 0.0), "z", "Linkage pin axis"),), - ) diff --git a/SimpleCADAPI/examples/18_leg_wheel_robot_dog_leg/leg_assembly.py b/SimpleCADAPI/examples/18_leg_wheel_robot_dog_leg/leg_assembly.py deleted file mode 100644 index 66ebd05..0000000 --- a/SimpleCADAPI/examples/18_leg_wheel_robot_dog_leg/leg_assembly.py +++ /dev/null @@ -1,254 +0,0 @@ -"""Top-level bolt-aligned leg-wheel robot dog leg assembly.""" - -from __future__ import annotations - -import simplecadapi as scad - -from actuator import make_joint_actuator_rassembly -from brackets import make_body_mount_plate_rpart -from hardware import ( - make_clamp_bolt_stack_rpart, - make_knee_bushing_rpart, - make_knee_shoulder_bolt_stack_rpart, - make_linkage_pin_stack_rpart, - make_socket_head_screw_rpart, -) -from leg_common import connector_ref, make_actuator_target_rplacement -from leg_dimensions import ( - ACTUATOR_OUTPUT_CONNECTOR_Z, - DISTAL_PUSHROD_PIN, - KNEE_DRIVE_AXIS, - PROXIMAL_PUSHROD_PIN, - OUTPUT_FLANGE_SCREW_SHANK_RADIUS, - ROD_PIN_AXIS_Z, - ROOT_AXIS, - WHEEL_AXIS, -) -from links import ( - make_proximal_crank_rpart, - make_pushrod_rpart, - make_shank_link_rpart, - make_upper_link_plate_rpart, - make_wheel_hub_rpart, - make_wheel_tire_rpart, -) - - -def make_leg_wheel_robot_dog_leg_rassembly( - *, - actuator_materials: dict[str, scad.Material], - leg_materials: dict[str, scad.Material], -) -> scad.Assembly: - """Build the posed planar leg-wheel assembly with explicit bolt interfaces.""" - - actuator = make_joint_actuator_rassembly(materials=actuator_materials) - body_mount = make_body_mount_plate_rpart(material=leg_materials["bracket"]) - upper_link = make_upper_link_plate_rpart(material=leg_materials["link"]) - proximal_crank = make_proximal_crank_rpart(material=leg_materials["linkage"]) - pushrod = make_pushrod_rpart(material=leg_materials["linkage"]) - shank_link = make_shank_link_rpart(material=leg_materials["link"]) - wheel_hub = make_wheel_hub_rpart(material=leg_materials["wheel_hub"]) - wheel_tire = make_wheel_tire_rpart(material=leg_materials["tire"]) - output_screw = make_socket_head_screw_rpart( - part_id="m3x5_output_socket_head_screw", - shank_radius=OUTPUT_FLANGE_SCREW_SHANK_RADIUS, - shank_length=5.0, - head_radius=2.85, - head_height=3.0, - material=leg_materials["fastener"], - ) - clamp_bolt = make_clamp_bolt_stack_rpart(material=leg_materials["fastener"]) - linkage_pin = make_linkage_pin_stack_rpart(material=leg_materials["fastener"]) - knee_bushing = make_knee_bushing_rpart(material=leg_materials["bushing"]) - knee_axle = make_knee_shoulder_bolt_stack_rpart(material=leg_materials["fastener"]) - - leg = scad.make_assembly_rassembly( - assembly_id="leg_wheel_robot_dog_leg", - name="Planar leg-wheel module with bolt-aligned actuator, knee, and wheel interfaces", - ) - for component_id, target, axis, name in ( - ("thigh_actuator", ROOT_AXIS, "z", "Body-fixed thigh reducer actuator"), - ("knee_drive_actuator", KNEE_DRIVE_AXIS, "z", "Body-fixed knee-drive actuator opposite the crank"), - ("wheel_hub_actuator", WHEEL_AXIS, "z", "Distal wheel hub reducer actuator"), - ): - leg = scad.add_component_rassembly( - assembly=leg, - item=actuator, - component_id=component_id, - placement=make_actuator_target_rplacement( - output_axis_origin=target, - output_axis_local_z=ACTUATOR_OUTPUT_CONNECTOR_Z, - axis=axis, - ), - name=name, - ) - - for component_id, item, name in ( - ("body_mount_plate", body_mount, "Body-fixed hip stack bracket for thigh and knee-drive cases"), - ("upper_link_plate", upper_link, "Output-bolted upper link plate"), - ("proximal_output_crank", proximal_crank, "Output-bolted knee-drive crank"), - ("knee_pushrod", pushrod, "Pinned pushrod between crank and shank"), - ("shank_link", shank_link, "Lower shank with integral pushrod ear and wheel hub case mount"), - ("wheel_hub", wheel_hub, "Rigid wheel hub bolted to actuator output"), - ("wheel_tire", wheel_tire, "Replaceable rubber tire fitted to rigid wheel hub"), - ("knee_bushing", knee_bushing, "Bronze knee pivot sleeve"), - ("knee_axle", knee_axle, "Retained knee shoulder axle"), - ): - leg = scad.add_component_rassembly( - assembly=leg, - item=item, - component_id=component_id, - placement=scad.identity_placement_rplacement(), - name=name, - ) - - for component_id, item, placement, name in ( - ( - "thigh_clamp_bolt", - clamp_bolt, - scad.identity_placement_rplacement(), - "M4 thigh actuator split-clamp bolt", - ), - ( - "knee_drive_clamp_bolt", - clamp_bolt, - scad.identity_placement_rplacement(), - "M4 knee-drive actuator split-clamp bolt", - ), - ( - "wheel_clamp_bolt", - clamp_bolt, - scad.identity_placement_rplacement(), - "M4 wheel actuator split-clamp bolt", - ), - ( - "proximal_linkage_pin", - linkage_pin, - scad.make_placement_rplacement( - origin=(PROXIMAL_PUSHROD_PIN[0], PROXIMAL_PUSHROD_PIN[1], ROD_PIN_AXIS_Z) - ), - "Retained proximal linkage shoulder pin", - ), - ( - "distal_linkage_pin", - linkage_pin, - scad.make_placement_rplacement( - origin=(DISTAL_PUSHROD_PIN[0], DISTAL_PUSHROD_PIN[1], ROD_PIN_AXIS_Z) - ), - "Retained distal linkage shoulder pin", - ), - ): - leg = scad.add_component_rassembly( - assembly=leg, - item=item, - component_id=component_id, - placement=placement, - name=name, - ) - - for interface, plate_component in ( - ("thigh", "upper_link_plate"), - ("knee_drive", "proximal_output_crank"), - ("wheel", "wheel_hub"), - ): - for index in range(1, 7): - leg = scad.add_component_rassembly( - assembly=leg, - item=output_screw, - component_id=f"{interface}_output_screw_{index}", - placement=scad.identity_placement_rplacement(), - name=f"{interface.replace('_', ' ')} output M3 screw {index}", - ) - - leg = _add_leg_constraints_rassembly(assembly=leg) - leg = scad.solve_assembly_constraints_rassembly(assembly=leg, strict=True) - _ground_constraint_report(assembly=leg) - print( - "leg_components: actuators=3 structural=7 hardware=25 components=" - f"{len(leg.component_ids())} constraints={len(leg.constraint_ids())}" - ) - return leg - - -def _add_leg_constraints_rassembly(*, assembly: scad.Assembly) -> scad.Assembly: - assembly = scad.ground_component_rassembly(assembly=assembly, component_id="body_mount_plate") - - fixed_pairs = ( - ("hip_stack_to_thigh_case", "body_mount_plate", "case_axis", "thigh_actuator", "case_clamp_axis"), - ("hip_stack_to_knee_drive_case", "body_mount_plate", "knee_drive_case_axis", "knee_drive_actuator", "case_clamp_axis"), - ("shank_clamped_to_wheel_case", "shank_link", "wheel_case_axis", "wheel_hub_actuator", "case_clamp_axis"), - ("thigh_output_bolted_to_link", "thigh_actuator", "output_link_axis", "upper_link_plate", "output_axis"), - ("knee_output_bolted_to_crank", "knee_drive_actuator", "output_link_axis", "proximal_output_crank", "output_axis"), - ("wheel_output_bolted_to_hub", "wheel_hub_actuator", "output_link_axis", "wheel_hub", "wheel_axis"), - ("wheel_tire_bonded_to_hub", "wheel_hub", "tire_axis", "wheel_tire", "hub_axis"), - ("knee_bushing_pressed_in_upper_link", "upper_link_plate", "knee_axis", "knee_bushing", "knee_axis"), - ("knee_axle_locked_to_bushing", "knee_bushing", "knee_axis", "knee_axle", "knee_axis"), - ("thigh_clamp_bolt_seated", "body_mount_plate", "thigh_clamp_bolt_seat", "thigh_clamp_bolt", "seat_axis"), - ("knee_clamp_bolt_seated", "body_mount_plate", "knee_clamp_bolt_seat", "knee_drive_clamp_bolt", "seat_axis"), - ("wheel_clamp_bolt_seated", "shank_link", "wheel_clamp_bolt_seat", "wheel_clamp_bolt", "seat_axis"), - ("proximal_pin_locked_to_crank", "proximal_output_crank", "rod_pin", "proximal_linkage_pin", "pin_axis"), - ("distal_pin_locked_to_shank", "shank_link", "rod_pin", "distal_linkage_pin", "pin_axis"), - ) - for constraint_id, a_component, a_connector, b_component, b_connector in fixed_pairs: - assembly = scad.add_fixed_constraint_rassembly( - assembly=assembly, - constraint_id=constraint_id, - connector_a=connector_ref(component_id=a_component, connector_id=a_connector), - connector_b=connector_ref(component_id=b_component, connector_id=b_connector), - name=constraint_id.replace("_", " "), - ) - - revolutes = ( - ("proximal_pushrod_on_shoulder_pin", "proximal_linkage_pin", "pin_axis", "knee_pushrod", "proximal_pin", None), - ("distal_pushrod_on_shoulder_pin", "distal_linkage_pin", "pin_axis", "knee_pushrod", "distal_pin", None), - ("shank_rotates_on_knee_bushing", "knee_bushing", "knee_axis", "shank_link", "knee_axis", None), - ) - for constraint_id, a_component, a_connector, b_component, b_connector, drive_angle in revolutes: - assembly = scad.add_revolute_constraint_rassembly( - assembly=assembly, - constraint_id=constraint_id, - connector_a=connector_ref(component_id=a_component, connector_id=a_connector), - connector_b=connector_ref(component_id=b_component, connector_id=b_connector), - drive_angle_degrees=drive_angle, - angle_limit=None, - name=constraint_id.replace("_", " "), - ) - - for interface, plate_component in ( - ("thigh", "upper_link_plate"), - ("knee_drive", "proximal_output_crank"), - ("wheel", "wheel_hub"), - ): - for index in range(1, 7): - assembly = scad.add_fixed_constraint_rassembly( - assembly=assembly, - constraint_id=f"{interface}_output_screw_{index}_seated", - connector_a=connector_ref( - component_id=plate_component, - connector_id=f"output_bolt_{index}_head_top", - ), - connector_b=connector_ref( - component_id=f"{interface}_output_screw_{index}", - connector_id="head_top_axis", - ), - name=f"{interface.replace('_', ' ')} output screw {index} coaxial", - ) - - print( - f"leg_constraints_added: fixed={len(fixed_pairs) + 18} " - f"revolute={len(revolutes)} actuator_output_mounts=fixed bolt_aligned=21" - ) - return assembly - - -def _ground_constraint_report(*, assembly: scad.Assembly) -> None: - report = scad.inspect_assembly_constraints_rconstraintreport(assembly=assembly) - print( - f"leg_constraints: solved={report.solved} grounded={len(report.grounded_component_ids)} " - f"solved_components={len(report.solved_component_ids)} unsolved={len(report.unsolved_component_ids)}" - ) - for residual in report.residuals: - print( - f"leg_constraint_{residual.constraint_id}: translation={residual.translation_error:.6g} " - f"angle={residual.angular_error_degrees:.6g} ok={residual.within_tolerance}" - ) diff --git a/SimpleCADAPI/examples/18_leg_wheel_robot_dog_leg/leg_common.py b/SimpleCADAPI/examples/18_leg_wheel_robot_dog_leg/leg_common.py deleted file mode 100644 index 7068512..0000000 --- a/SimpleCADAPI/examples/18_leg_wheel_robot_dog_leg/leg_common.py +++ /dev/null @@ -1,343 +0,0 @@ -"""Shared geometry helpers for Example 18.""" - -from __future__ import annotations - -import math -from collections.abc import Iterable - -import simplecadapi as scad -from simplecadapi import ql - - -Point3 = tuple[float, float, float] - - -def bolt_circle_points( - *, - center: Point3, - radius: float, - angles_degrees: Iterable[float], -) -> tuple[Point3, ...]: - """Return XY bolt-center points on a named bolt-circle datum.""" - - points = [] - for angle_degrees in angles_degrees: - angle = math.radians(angle_degrees) - points.append( - ( - center[0] + radius * math.cos(angle), - center[1] + radius * math.sin(angle), - center[2], - ) - ) - return tuple(points) - - -def make_bolt_circle_cutters_rsolidlist( - *, - center: Point3, - bolt_circle_radius: float, - angles_degrees: Iterable[float], - hole_radius: float, - z_min: float, - height: float, - counterbore_radius: float | None = None, - counterbore_depth: float = 0.0, - counterbore_from_top: bool = True, - counterbore_face_z: float | None = None, -) -> list[scad.Solid]: - """Build through-hole and optional counterbore cutters for a bolt circle.""" - - cutters: list[scad.Solid] = [] - for point in bolt_circle_points( - center=center, - radius=bolt_circle_radius, - angles_degrees=angles_degrees, - ): - cutters.append( - scad.make_cylinder_rsolid( - radius=hole_radius, - height=height, - bottom_face_center=(point[0], point[1], z_min), - axis=(0.0, 0.0, 1.0), - ) - ) - if counterbore_radius is not None and counterbore_depth > 0.0: - face_z = counterbore_face_z - if face_z is None: - face_z = z_min + height if counterbore_from_top else z_min - counterbore_z = face_z - counterbore_depth if counterbore_from_top else face_z - 0.2 - cutters.append( - scad.make_cylinder_rsolid( - radius=counterbore_radius, - height=counterbore_depth + 0.2, - bottom_face_center=(point[0], point[1], counterbore_z), - axis=(0.0, 0.0, 1.0), - ) - ) - return cutters - - -def make_rounded_slot_cutter_rsolid( - *, - center: Point3, - length: float, - width: float, - height: float, - angle_degrees: float, - tag: str, -) -> scad.Solid: - """Build a capsule-shaped cutter for a lightening pocket.""" - - if length <= width: - raise ValueError("rounded slot length must exceed width") - radius = width / 2.0 - straight = length - width - z_min = center[2] - height / 2.0 - bridge = scad.make_box_rsolid( - width=straight, - height=width, - depth=height, - bottom_face_center=(0.0, 0.0, z_min), - ) - left = scad.make_cylinder_rsolid( - radius=radius, - height=height, - bottom_face_center=(-straight / 2.0, 0.0, z_min), - axis=(0.0, 0.0, 1.0), - ) - right = scad.make_cylinder_rsolid( - radius=radius, - height=height, - bottom_face_center=(straight / 2.0, 0.0, z_min), - axis=(0.0, 0.0, 1.0), - ) - cutter = scad.union_rsolid([bridge, left, right], glue=False) - cutter = scad.rotate_shape( - shape=cutter, - angle=angle_degrees, - axis=(0.0, 0.0, 1.0), - origin=(0.0, 0.0, center[2]), - ) - cutter = scad.translate_shape(shape=cutter, vector=(center[0], center[1], 0.0)) - return scad.apply_tag(shape=cutter, tag=tag) - - -def make_axis_placement_rplacement( - *, - origin: Point3, - axis: str = "z", -) -> scad.Placement: - """Create a placement whose local Z axis is the requested world axis.""" - - if axis == "z": - return scad.make_placement_rplacement( - origin=origin, - x_axis=(1.0, 0.0, 0.0), - y_axis=(0.0, 1.0, 0.0), - ) - if axis == "y": - return scad.make_placement_rplacement( - origin=origin, - x_axis=(1.0, 0.0, 0.0), - y_axis=(0.0, 0.0, -1.0), - ) - if axis == "-z": - return scad.make_placement_rplacement( - origin=origin, - x_axis=(1.0, 0.0, 0.0), - y_axis=(0.0, -1.0, 0.0), - ) - if axis == "x": - return scad.make_placement_rplacement( - origin=origin, - x_axis=(0.0, 1.0, 0.0), - y_axis=(0.0, 0.0, 1.0), - ) - raise ValueError(f"unsupported axis {axis!r}") - - -def make_actuator_target_rplacement( - *, - output_axis_origin: Point3, - output_axis_local_z: float, - axis: str, -) -> scad.Placement: - """Place an actuator so its forwarded output/case axis lands on a world point.""" - - if axis == "z": - origin = ( - output_axis_origin[0], - output_axis_origin[1], - output_axis_origin[2] - output_axis_local_z, - ) - elif axis == "y": - origin = ( - output_axis_origin[0], - output_axis_origin[1] - output_axis_local_z, - output_axis_origin[2], - ) - elif axis == "-z": - origin = ( - output_axis_origin[0], - output_axis_origin[1], - output_axis_origin[2] + output_axis_local_z, - ) - else: - raise ValueError(f"unsupported axis {axis!r}") - return make_axis_placement_rplacement(origin=origin, axis=axis) - - -def add_datum_connector_rpart( - *, - part: scad.Part, - connector_id: str, - origin: Point3, - axis: str = "z", - name: str | None = None, -) -> scad.Part: - """Attach a topology-free connector datum to a part.""" - - connector = scad.make_placement_connector_rconnector( - connector_id=connector_id, - placement=make_axis_placement_rplacement(origin=origin, axis=axis), - name=name, - ) - return scad.add_connector_rpart(part=part, connector=connector) - - -def make_part_with_connectors_rpart( - *, - part_id: str, - body: scad.Solid, - name: str, - material: scad.Material, - connectors: Iterable[tuple[str, Point3, str, str | None]], -) -> scad.Part: - """Wrap a solid as a materialized Part and add placement connectors.""" - - part = scad.make_part_rpart(part_id=part_id, body=body, name=name) - part = scad.assign_material_rpart(part=part, material=material) - for connector_id, origin, axis, connector_name in connectors: - part = add_datum_connector_rpart( - part=part, - connector_id=connector_id, - origin=origin, - axis=axis, - name=connector_name, - ) - print(f"part_{part_id}: connectors={len(part.connectors)} volume={body.get_volume():.3f}") - return part - - -def make_rounded_bar_rsolid( - *, - start: Point3, - end: Point3, - width: float, - thickness: float, - end_hole_radius: float, - lightening_hole_radius: float | None = None, - lightening_count: int = 0, - tag: str, -) -> scad.Solid: - """Build a planar rounded-end plate between two XY points at constant Z.""" - - dx = end[0] - start[0] - dy = end[1] - start[1] - length = math.hypot(dx, dy) - if length <= width: - raise ValueError("rounded bar length must exceed width") - z_center = start[2] - z_min = z_center - thickness / 2.0 - radius = width / 2.0 - bridge = scad.make_box_rsolid( - width=length, - height=width, - depth=thickness, - bottom_face_center=(length / 2.0, 0.0, z_min), - ) - left = scad.make_cylinder_rsolid( - radius=radius, - height=thickness, - bottom_face_center=(0.0, 0.0, z_min), - axis=(0.0, 0.0, 1.0), - ) - right = scad.make_cylinder_rsolid( - radius=radius, - height=thickness, - bottom_face_center=(length, 0.0, z_min), - axis=(0.0, 0.0, 1.0), - ) - body = scad.union_rsolid([bridge, left, right], glue=False) - cutters = [ - scad.make_cylinder_rsolid( - radius=end_hole_radius, - height=thickness + 2.0, - bottom_face_center=(0.0, 0.0, z_min - 1.0), - axis=(0.0, 0.0, 1.0), - ), - scad.make_cylinder_rsolid( - radius=end_hole_radius, - height=thickness + 2.0, - bottom_face_center=(length, 0.0, z_min - 1.0), - axis=(0.0, 0.0, 1.0), - ), - ] - if lightening_hole_radius is not None and lightening_count > 0: - for index in range(lightening_count): - fraction = (index + 1.0) / (lightening_count + 1.0) - cutters.append( - scad.make_cylinder_rsolid( - radius=lightening_hole_radius, - height=thickness + 2.0, - bottom_face_center=(length * fraction, 0.0, z_min - 1.0), - axis=(0.0, 0.0, 1.0), - ) - ) - body = scad.cut_rsolid(body, cutters, skip_non_intersecting=False) - body = scad.rotate_shape( - shape=body, - angle=math.degrees(math.atan2(dy, dx)), - axis=(0.0, 0.0, 1.0), - origin=(0.0, 0.0, z_center), - ) - body = scad.translate_shape(shape=body, vector=(start[0], start[1], 0.0)) - body = scad.apply_tag(shape=body, tag=tag) - _ground_solid(label=tag, solid=body) - return body - - -def _ground_solid(*, label: str, solid: scad.Solid) -> None: - faces = ql.select(items=solid.get_faces()).all() - print(f"{label}: faces={len(faces)} volume={solid.get_volume():.3f}") - - -def _axis_vector(*, axis: str) -> Point3: - if axis == "z": - return (0.0, 0.0, 1.0) - if axis == "y": - return (0.0, 1.0, 0.0) - if axis == "x": - return (1.0, 0.0, 0.0) - if axis == "-z": - return (0.0, 0.0, -1.0) - raise ValueError(f"unsupported axis {axis!r}") - - -def ground_compound(*, label: str, compound: scad.Compound) -> None: - """Print compact grounding facts for an assembly preview.""" - - solids = ql.select(items=compound.get_solids()).all() - face_count = sum(len(ql.select(items=solid.get_faces()).all()) for solid in solids) - print( - f"{label}: solids={len(solids)} faces={face_count} " - f"volume={compound.get_volume():.3f}" - ) - - -def connector_ref(*, component_id: str, connector_id: str) -> scad.ConnectorRef: - return scad.make_connector_ref_rconnectorref( - component_id=component_id, - connector_id=connector_id, - ) diff --git a/SimpleCADAPI/examples/18_leg_wheel_robot_dog_leg/leg_dimensions.py b/SimpleCADAPI/examples/18_leg_wheel_robot_dog_leg/leg_dimensions.py deleted file mode 100644 index 710d3d1..0000000 --- a/SimpleCADAPI/examples/18_leg_wheel_robot_dog_leg/leg_dimensions.py +++ /dev/null @@ -1,173 +0,0 @@ -"""Design constants for the bolt-aligned planar leg-wheel example.""" - -from __future__ import annotations - -import math - -from actuator import ( - ACTUATOR_CASE_CLAMP_Z, - ACTUATOR_OUTPUT_FACE_Z, - ACTUATOR_PACKAGE_RADIUS, - OUTPUT_BOLT_ANGLES_DEGREES, - OUTPUT_BOLT_CIRCLE_RADIUS, - OUTPUT_BOLT_COUNT, - OUTPUT_TAP_RADIUS, - OUTPUT_REGISTER_HEIGHT, - OUTPUT_REGISTER_RADIUS, -) - - -# This example intentionally drops the hip-abduction package for now. The model -# is one planar leg module like the reference sketch: one body-fixed thigh -# actuator drives the upper link, a second body-fixed coaxial knee-drive actuator -# drives a crank/pushrod, and a third hub actuator drives the wheel. - -ROOT_AXIS = (0.0, 0.0, 0.0) -KNEE_AXIS = (42.0, -126.0, 0.0) -WHEEL_AXIS = (-78.0, -220.0, 0.0) -KNEE_PIVOT_Z = 6.5 -# The knee-drive actuator is mirrored about its unchanged split-clamp plane, so -# its output flange and red crank sit on the side opposite the actuator body. -KNEE_DRIVE_OUTPUT_Z = 85.6 -ROD_PIN_AXIS_Z = 85.95 - -THIGH_VECTOR_X = KNEE_AXIS[0] - ROOT_AXIS[0] -THIGH_VECTOR_Y = KNEE_AXIS[1] - ROOT_AXIS[1] -THIGH_LENGTH = math.hypot(THIGH_VECTOR_X, THIGH_VECTOR_Y) -THIGH_UNIT_X = THIGH_VECTOR_X / THIGH_LENGTH -THIGH_UNIT_Y = THIGH_VECTOR_Y / THIGH_LENGTH -THIGH_NORMAL_X = -THIGH_UNIT_Y -THIGH_NORMAL_Y = THIGH_UNIT_X - -SHANK_VECTOR_X = WHEEL_AXIS[0] - KNEE_AXIS[0] -SHANK_VECTOR_Y = WHEEL_AXIS[1] - KNEE_AXIS[1] -SHANK_LENGTH = math.hypot(SHANK_VECTOR_X, SHANK_VECTOR_Y) -SHANK_UNIT_X = SHANK_VECTOR_X / SHANK_LENGTH -SHANK_UNIT_Y = SHANK_VECTOR_Y / SHANK_LENGTH -SHANK_NORMAL_X = -SHANK_UNIT_Y -SHANK_NORMAL_Y = SHANK_UNIT_X - -REMOTE_CRANK_LENGTH = 46.0 -DISTAL_CRANK_LENGTH = REMOTE_CRANK_LENGTH -KNEE_DRIVE_AXIS = ( - ROOT_AXIS[0], - ROOT_AXIS[1], - KNEE_DRIVE_OUTPUT_Z, -) -PROXIMAL_PUSHROD_PIN = ( - ROOT_AXIS[0] + THIGH_NORMAL_X * REMOTE_CRANK_LENGTH, - ROOT_AXIS[1] + THIGH_NORMAL_Y * REMOTE_CRANK_LENGTH, - 0.0, -) -DISTAL_PUSHROD_PIN = ( - KNEE_AXIS[0] + THIGH_NORMAL_X * DISTAL_CRANK_LENGTH, - KNEE_AXIS[1] + THIGH_NORMAL_Y * DISTAL_CRANK_LENGTH, - 0.0, -) - -# Shared Example 20 external interfaces. The actuator uses M3 tapped output -# holes; the leg-side plates use ISO-style clearance holes and socket-head -# counterbores at exactly the same centers. -ACTUATOR_OUTPUT_CONNECTOR_Z = ACTUATOR_OUTPUT_FACE_Z -ACTUATOR_CASE_OUTER_RADIUS = ACTUATOR_PACKAGE_RADIUS -OUTPUT_FLANGE_OUTER_RADIUS = 22.4 -OUTPUT_FLANGE_REGISTER_INNER_RADIUS = OUTPUT_REGISTER_RADIUS -OUTPUT_FLANGE_BOLT_CIRCLE_RADIUS = OUTPUT_BOLT_CIRCLE_RADIUS -OUTPUT_FLANGE_BOLT_CLEARANCE_RADIUS = 1.65 -OUTPUT_FLANGE_BOLT_COUNTERBORE_RADIUS = 3.0 -OUTPUT_FLANGE_SCREW_SHANK_RADIUS = 1.5 -OUTPUT_FLANGE_BOLT_COUNT = OUTPUT_BOLT_COUNT -OUTPUT_FLANGE_BOLT_ANGLES_DEGREES = OUTPUT_BOLT_ANGLES_DEGREES - -CASE_CLAMP_INNER_RADIUS = ACTUATOR_CASE_OUTER_RADIUS + 0.15 -CASE_CLAMP_OUTER_RADIUS = 30.5 -CASE_CLAMP_WIDTH = 8.0 -CASE_CLAMP_SLIT_WIDTH = 2.2 -CASE_CLAMP_PINCH_HOLE_RADIUS = 2.15 -CASE_CLAMP_BOLT_SHANK_RADIUS = 2.0 -CASE_CLAMP_PINCH_AXIS_RADIUS = 32.0 -CASE_CLAMP_PINCH_HALF_SPAN = 7.0 -THIGH_CASE_CLAMP_Z = ROOT_AXIS[2] - (ACTUATOR_OUTPUT_FACE_Z - ACTUATOR_CASE_CLAMP_Z) -KNEE_CASE_CLAMP_Z = KNEE_DRIVE_OUTPUT_Z - ( - ACTUATOR_OUTPUT_FACE_Z - ACTUATOR_CASE_CLAMP_Z -) -WHEEL_CASE_CLAMP_Z = WHEEL_AXIS[2] - ( - ACTUATOR_OUTPUT_FACE_Z - ACTUATOR_CASE_CLAMP_Z -) - -PIN_CLEARANCE_RADIUS = 3.2 -ROD_PIN_CLEARANCE_RADIUS = 2.2 -LIGHTENING_CORNER_RADIUS = 4.0 - -UPPER_LINK_Z = 2.5 -UPPER_LINK_THICKNESS = 5.0 -UPPER_LINK_ROOT_RADIUS = 30.5 -UPPER_LINK_KNEE_RADIUS = 17.0 -UPPER_LINK_WEB_WIDTH = 28.0 -UPPER_LINK_WINDOW_LENGTH = 28.0 -UPPER_LINK_WINDOW_WIDTH = 14.0 - -KNEE_BEARING_OUTER_RADIUS = 14.0 -KNEE_BEARING_BORE_RADIUS = 6.0 -SHANK_LINK_Z = 10.5 -SHANK_LINK_THICKNESS = 5.0 -SHANK_KNEE_RADIUS = 18.0 -SHANK_WHEEL_RADIUS = 31.5 -SHANK_WEB_WIDTH = 24.0 -SHANK_WINDOW_LENGTH = 25.0 -SHANK_WINDOW_WIDTH = 12.0 - -REMOTE_CRANK_Z = 88.1 -REMOTE_CRANK_THICKNESS = 5.0 -REMOTE_CRANK_WIDTH = 12.0 -DISTAL_CRANK_Z = 88.1 -DISTAL_CRANK_THICKNESS = 5.0 -DISTAL_CRANK_WIDTH = 12.0 -PUSHROD_Z = 83.1 -PUSHROD_THICKNESS = 3.6 -PUSHROD_WIDTH = 9.0 - -WHEEL_TIRE_RADIUS = 48.0 -WHEEL_TIRE_WIDTH = 18.0 -WHEEL_TIRE_BORE_RADIUS = 34.0 -WHEEL_HUB_PLATE_RADIUS = 24.5 -WHEEL_HUB_PLATE_THICKNESS = 5.0 -WHEEL_SPOKE_WIDTH = 5.5 -WHEEL_SPOKE_COUNT = 8 - -BODY_STANDOFF_Z = 22.0 -BODY_STANDOFF_THICKNESS = 8.0 -KNEE_STACK_CLAMP_Z = KNEE_CASE_CLAMP_Z -KNEE_STACK_CLAMP_THICKNESS = CASE_CLAMP_WIDTH - -assert len(OUTPUT_FLANGE_BOLT_ANGLES_DEGREES) == OUTPUT_FLANGE_BOLT_COUNT -assert OUTPUT_FLANGE_BOLT_CIRCLE_RADIUS == 17.0 -assert OUTPUT_FLANGE_REGISTER_INNER_RADIUS == 7.98 -assert OUTPUT_REGISTER_HEIGHT == 1.5 -assert CASE_CLAMP_INNER_RADIUS > ACTUATOR_CASE_OUTER_RADIUS -assert OUTPUT_FLANGE_BOLT_CIRCLE_RADIUS + OUTPUT_FLANGE_BOLT_COUNTERBORE_RADIUS < 21.0 - - -def validate_leg_interface_dimensions() -> None: - """Validate shared actuator/leg hole, register, and clamp clearances.""" - - generated_angles = tuple( - OUTPUT_FLANGE_BOLT_ANGLES_DEGREES[0] - + 360.0 * index / OUTPUT_FLANGE_BOLT_COUNT - for index in range(OUTPUT_FLANGE_BOLT_COUNT) - ) - assert generated_angles == OUTPUT_FLANGE_BOLT_ANGLES_DEGREES - assert OUTPUT_TAP_RADIUS < OUTPUT_FLANGE_SCREW_SHANK_RADIUS - assert OUTPUT_FLANGE_SCREW_SHANK_RADIUS < OUTPUT_FLANGE_BOLT_CLEARANCE_RADIUS - assert OUTPUT_FLANGE_BOLT_COUNTERBORE_RADIUS > OUTPUT_FLANGE_SCREW_SHANK_RADIUS - assert CASE_CLAMP_BOLT_SHANK_RADIUS < CASE_CLAMP_PINCH_HOLE_RADIUS - assert abs(CASE_CLAMP_INNER_RADIUS - ACTUATOR_CASE_OUTER_RADIUS - 0.15) < 1.0e-9 - print( - "leg_interface_dimensions: " - f"output_holes={OUTPUT_FLANGE_BOLT_COUNT} " - f"pcd={OUTPUT_FLANGE_BOLT_CIRCLE_RADIUS * 2.0:.1f} " - f"tap_d={OUTPUT_TAP_RADIUS * 2.0:.2f} " - f"screw_d={OUTPUT_FLANGE_SCREW_SHANK_RADIUS * 2.0:.2f} " - f"clearance_d={OUTPUT_FLANGE_BOLT_CLEARANCE_RADIUS * 2.0:.2f} " - f"pilot_d={OUTPUT_FLANGE_REGISTER_INNER_RADIUS * 2.0:.2f}" - ) diff --git a/SimpleCADAPI/examples/18_leg_wheel_robot_dog_leg/leg_materials.py b/SimpleCADAPI/examples/18_leg_wheel_robot_dog_leg/leg_materials.py deleted file mode 100644 index aea9f5a..0000000 --- a/SimpleCADAPI/examples/18_leg_wheel_robot_dog_leg/leg_materials.py +++ /dev/null @@ -1,61 +0,0 @@ -"""Materials for the leg-wheel robot dog leg example.""" - -from __future__ import annotations - -import simplecadapi as scad - - -def make_leg_materials_rdict() -> dict[str, scad.Material]: - """Create reusable material definitions for the leg assembly.""" - - return { - "link": scad.make_material_rmaterial( - material_id="leg_link_turquoise_7075", - name="Turquoise anodized 7075 link plates", - density=2.81e-6, - density_unit="kg/mm^3", - color=(0.02, 0.67, 0.78), - ), - "linkage": scad.make_material_rmaterial( - material_id="leg_linkage_orange_ti", - name="Orange anodized titanium linkage hardware", - density=4.43e-6, - density_unit="kg/mm^3", - color=(0.95, 0.25, 0.06), - ), - "bracket": scad.make_material_rmaterial( - material_id="leg_bracket_violet_7075", - name="Violet anodized 7075 actuator clamps", - density=2.81e-6, - density_unit="kg/mm^3", - color=(0.48, 0.19, 0.76), - ), - "wheel_hub": scad.make_material_rmaterial( - material_id="leg_wheel_hub_gold_7075", - name="Gold anodized 7075 wheel hub", - density=2.81e-6, - density_unit="kg/mm^3", - color=(0.94, 0.62, 0.05), - ), - "fastener": scad.make_material_rmaterial( - material_id="leg_fastener_black_12_9_steel", - name="Black oxide class 12.9 fastener steel", - density=7.85e-6, - density_unit="kg/mm^3", - color=(0.08, 0.10, 0.13), - ), - "bushing": scad.make_material_rmaterial( - material_id="leg_bushing_bronze", - name="Oil-impregnated bearing bronze", - density=8.80e-6, - density_unit="kg/mm^3", - color=(0.63, 0.34, 0.12), - ), - "tire": scad.make_material_rmaterial( - material_id="leg_wheel_rubber", - name="Dark rubber tire", - density=1.15e-6, - density_unit="kg/mm^3", - color=(0.025, 0.03, 0.035), - ), - } diff --git a/SimpleCADAPI/examples/18_leg_wheel_robot_dog_leg/links.py b/SimpleCADAPI/examples/18_leg_wheel_robot_dog_leg/links.py deleted file mode 100644 index 041d64b..0000000 --- a/SimpleCADAPI/examples/18_leg_wheel_robot_dog_leg/links.py +++ /dev/null @@ -1,592 +0,0 @@ -"""Bolt-aligned link, crank, pushrod, shank, and wheel parts for Example 18.""" - -from __future__ import annotations - -import math - -import simplecadapi as scad - -from leg_common import ( - make_bolt_circle_cutters_rsolidlist, - make_part_with_connectors_rpart, - make_rounded_bar_rsolid, - make_rounded_slot_cutter_rsolid, -) -from brackets import make_split_case_clamp_rsolid -from leg_dimensions import ( - CASE_CLAMP_PINCH_AXIS_RADIUS, - CASE_CLAMP_PINCH_HALF_SPAN, - CASE_CLAMP_WIDTH, - DISTAL_CRANK_LENGTH, - DISTAL_CRANK_THICKNESS, - DISTAL_CRANK_WIDTH, - DISTAL_CRANK_Z, - DISTAL_PUSHROD_PIN, - KNEE_AXIS, - KNEE_BEARING_BORE_RADIUS, - KNEE_BEARING_OUTER_RADIUS, - KNEE_DRIVE_AXIS, - KNEE_PIVOT_Z, - OUTPUT_FLANGE_BOLT_ANGLES_DEGREES, - OUTPUT_FLANGE_BOLT_CIRCLE_RADIUS, - OUTPUT_FLANGE_BOLT_CLEARANCE_RADIUS, - OUTPUT_FLANGE_BOLT_COUNTERBORE_RADIUS, - OUTPUT_FLANGE_OUTER_RADIUS, - OUTPUT_FLANGE_REGISTER_INNER_RADIUS, - PIN_CLEARANCE_RADIUS, - PROXIMAL_PUSHROD_PIN, - PUSHROD_THICKNESS, - PUSHROD_WIDTH, - PUSHROD_Z, - REMOTE_CRANK_LENGTH, - REMOTE_CRANK_THICKNESS, - REMOTE_CRANK_WIDTH, - REMOTE_CRANK_Z, - ROD_PIN_AXIS_Z, - ROD_PIN_CLEARANCE_RADIUS, - ROOT_AXIS, - SHANK_KNEE_RADIUS, - SHANK_LENGTH, - SHANK_LINK_THICKNESS, - SHANK_LINK_Z, - SHANK_WEB_WIDTH, - SHANK_WHEEL_RADIUS, - SHANK_WINDOW_LENGTH, - SHANK_WINDOW_WIDTH, - UPPER_LINK_KNEE_RADIUS, - UPPER_LINK_ROOT_RADIUS, - UPPER_LINK_THICKNESS, - UPPER_LINK_WEB_WIDTH, - UPPER_LINK_WINDOW_LENGTH, - UPPER_LINK_WINDOW_WIDTH, - UPPER_LINK_Z, - WHEEL_AXIS, - WHEEL_HUB_PLATE_RADIUS, - WHEEL_HUB_PLATE_THICKNESS, - WHEEL_SPOKE_COUNT, - WHEEL_SPOKE_WIDTH, - WHEEL_TIRE_BORE_RADIUS, - WHEEL_TIRE_RADIUS, - WHEEL_TIRE_WIDTH, - WHEEL_CASE_CLAMP_Z, -) - - -def make_upper_link_plate_rpart(*, material: scad.Material) -> scad.Part: - """Build the output-bolted upper link plate with knee bearing holes.""" - - plate = _make_axis_plate_base_rsolid( - start=ROOT_AXIS, - end=KNEE_AXIS, - z_center=UPPER_LINK_Z, - thickness=UPPER_LINK_THICKNESS, - start_radius=UPPER_LINK_ROOT_RADIUS, - end_radius=UPPER_LINK_KNEE_RADIUS, - web_width=UPPER_LINK_WEB_WIDTH, - tag="role.upper_link_plate_base", - ) - z_min = UPPER_LINK_Z - UPPER_LINK_THICKNESS / 2.0 - cutters = [ - scad.make_cylinder_rsolid( - radius=OUTPUT_FLANGE_REGISTER_INNER_RADIUS + 0.05, - height=UPPER_LINK_THICKNESS + 2.0, - bottom_face_center=(ROOT_AXIS[0], ROOT_AXIS[1], z_min - 1.0), - axis=(0.0, 0.0, 1.0), - ), - scad.make_cylinder_rsolid( - radius=KNEE_BEARING_BORE_RADIUS, - height=UPPER_LINK_THICKNESS + 2.0, - bottom_face_center=(KNEE_AXIS[0], KNEE_AXIS[1], z_min - 1.0), - axis=(0.0, 0.0, 1.0), - ), - ] - cutters.extend( - make_bolt_circle_cutters_rsolidlist( - center=ROOT_AXIS, - bolt_circle_radius=OUTPUT_FLANGE_BOLT_CIRCLE_RADIUS, - angles_degrees=OUTPUT_FLANGE_BOLT_ANGLES_DEGREES, - hole_radius=OUTPUT_FLANGE_BOLT_CLEARANCE_RADIUS, - z_min=z_min - 1.0, - height=UPPER_LINK_THICKNESS + 2.0, - counterbore_radius=OUTPUT_FLANGE_BOLT_COUNTERBORE_RADIUS, - counterbore_depth=3.0, - counterbore_from_top=True, - counterbore_face_z=z_min + UPPER_LINK_THICKNESS, - ) - ) - cutters.extend( - _make_link_window_cutters( - start=ROOT_AXIS, - end=KNEE_AXIS, - z_center=UPPER_LINK_Z, - thickness=UPPER_LINK_THICKNESS, - fractions=(0.40, 0.67), - length=UPPER_LINK_WINDOW_LENGTH, - width=UPPER_LINK_WINDOW_WIDTH, - tag_prefix="upper_link_window", - ) - ) - plate = scad.cut_rsolid(plate, cutters, skip_non_intersecting=False) - plate = scad.apply_tag(shape=plate, tag="role.upper_link_plate") - print( - f"upper_link_plate: output_holes={len(OUTPUT_FLANGE_BOLT_ANGLES_DEGREES)} " - f"output_pcd={OUTPUT_FLANGE_BOLT_CIRCLE_RADIUS * 2.0:.1f} " - f"knee_bushing_bore={KNEE_BEARING_BORE_RADIUS * 2.0:.1f} faces={len(plate.get_faces())} " - f"volume={plate.get_volume():.3f}" - ) - return make_part_with_connectors_rpart( - part_id="upper_link_plate", - body=plate, - name="Upper link plate bolted to actuator output flange and knee bearing retainer", - material=material, - connectors=( - ("output_axis", ROOT_AXIS, "z", "Actuator output flange datum"), - ("knee_axis", (KNEE_AXIS[0], KNEE_AXIS[1], KNEE_PIVOT_Z), "z", "Knee bearing datum"), - *_output_bolt_connectors(center=ROOT_AXIS, face_z=z_min + UPPER_LINK_THICKNESS, axis="z"), - ), - ) - - -def make_proximal_crank_rpart(*, material: scad.Material) -> scad.Part: - """Create the short crank on the independent knee-drive actuator output.""" - - crank = _make_axis_plate_base_rsolid( - start=KNEE_DRIVE_AXIS, - end=PROXIMAL_PUSHROD_PIN, - z_center=REMOTE_CRANK_Z, - thickness=REMOTE_CRANK_THICKNESS, - start_radius=OUTPUT_FLANGE_OUTER_RADIUS, - end_radius=REMOTE_CRANK_WIDTH / 2.0 + 2.0, - web_width=REMOTE_CRANK_WIDTH, - tag="role.proximal_output_crank_base", - ) - z_min = REMOTE_CRANK_Z - REMOTE_CRANK_THICKNESS / 2.0 - cutters = [ - scad.make_cylinder_rsolid( - radius=OUTPUT_FLANGE_REGISTER_INNER_RADIUS + 0.05, - height=REMOTE_CRANK_THICKNESS + 2.0, - bottom_face_center=(KNEE_DRIVE_AXIS[0], KNEE_DRIVE_AXIS[1], z_min - 1.0), - axis=(0.0, 0.0, 1.0), - ), - scad.make_cylinder_rsolid( - radius=ROD_PIN_CLEARANCE_RADIUS, - height=REMOTE_CRANK_THICKNESS + 2.0, - bottom_face_center=(PROXIMAL_PUSHROD_PIN[0], PROXIMAL_PUSHROD_PIN[1], z_min - 1.0), - axis=(0.0, 0.0, 1.0), - ), - ] - cutters.extend( - make_bolt_circle_cutters_rsolidlist( - center=KNEE_DRIVE_AXIS, - bolt_circle_radius=OUTPUT_FLANGE_BOLT_CIRCLE_RADIUS, - angles_degrees=OUTPUT_FLANGE_BOLT_ANGLES_DEGREES, - hole_radius=OUTPUT_FLANGE_BOLT_CLEARANCE_RADIUS, - z_min=z_min - 1.0, - height=REMOTE_CRANK_THICKNESS + 2.0, - counterbore_radius=OUTPUT_FLANGE_BOLT_COUNTERBORE_RADIUS, - counterbore_depth=3.0, - counterbore_from_top=True, - counterbore_face_z=z_min + REMOTE_CRANK_THICKNESS, - ) - ) - crank = scad.cut_rsolid(crank, cutters, skip_non_intersecting=False) - crank = scad.apply_tag(shape=crank, tag="role.proximal_output_crank") - print( - f"knee_drive_output_crank: length={REMOTE_CRANK_LENGTH:.1f} " - f"output_flange_holes={len(OUTPUT_FLANGE_BOLT_ANGLES_DEGREES)} " - f"faces={len(crank.get_faces())} volume={crank.get_volume():.3f}" - ) - return make_part_with_connectors_rpart( - part_id="proximal_output_crank", - body=crank, - name="Knee-drive crank plate using the second actuator output flange holes", - material=material, - connectors=( - ("output_axis", KNEE_DRIVE_AXIS, "z", "Knee-drive actuator output flange datum"), - ("rod_pin", (PROXIMAL_PUSHROD_PIN[0], PROXIMAL_PUSHROD_PIN[1], ROD_PIN_AXIS_Z), "z", "Proximal pushrod pin datum"), - *_output_bolt_connectors( - center=KNEE_DRIVE_AXIS, - face_z=z_min + REMOTE_CRANK_THICKNESS, - axis="z", - ), - ), - ) - - -def make_pushrod_rpart(*, material: scad.Material) -> scad.Part: - """Create the flat pushrod with real pin-clearance holes.""" - - pushrod = make_rounded_bar_rsolid( - start=(PROXIMAL_PUSHROD_PIN[0], PROXIMAL_PUSHROD_PIN[1], PUSHROD_Z), - end=(DISTAL_PUSHROD_PIN[0], DISTAL_PUSHROD_PIN[1], PUSHROD_Z), - width=PUSHROD_WIDTH, - thickness=PUSHROD_THICKNESS, - end_hole_radius=ROD_PIN_CLEARANCE_RADIUS, - lightening_hole_radius=None, - lightening_count=0, - tag="role.knee_pushrod_plate", - ) - print( - f"knee_pushrod: pin_distance={_xy_distance(PROXIMAL_PUSHROD_PIN, DISTAL_PUSHROD_PIN):.1f} " - f"pin_hole_diameter={ROD_PIN_CLEARANCE_RADIUS * 2.0:.1f}" - ) - return make_part_with_connectors_rpart( - part_id="knee_pushrod", - body=pushrod, - name="Flat pushrod plate with matched clevis pin holes", - material=material, - connectors=( - ("proximal_pin", (PROXIMAL_PUSHROD_PIN[0], PROXIMAL_PUSHROD_PIN[1], ROD_PIN_AXIS_Z), "z", "Proximal crank pin datum"), - ("distal_pin", (DISTAL_PUSHROD_PIN[0], DISTAL_PUSHROD_PIN[1], ROD_PIN_AXIS_Z), "z", "Integral shank ear pin datum"), - ), - ) - - -def make_shank_link_rpart(*, material: scad.Material) -> scad.Part: - """Build the lower shank plate with an integral pushrod extension ear.""" - - shank = _make_axis_plate_base_rsolid( - start=KNEE_AXIS, - end=WHEEL_AXIS, - z_center=SHANK_LINK_Z, - thickness=SHANK_LINK_THICKNESS, - start_radius=SHANK_KNEE_RADIUS, - end_radius=SHANK_WHEEL_RADIUS, - web_width=SHANK_WEB_WIDTH, - tag="role.shank_link_base", - ) - z_min = SHANK_LINK_Z - SHANK_LINK_THICKNESS / 2.0 - distal_z_min = DISTAL_CRANK_Z - DISTAL_CRANK_THICKNESS / 2.0 - distal_z_top = distal_z_min + DISTAL_CRANK_THICKNESS - distal_drive_ear = _make_axis_plate_base_rsolid( - start=KNEE_AXIS, - end=DISTAL_PUSHROD_PIN, - z_center=DISTAL_CRANK_Z, - thickness=DISTAL_CRANK_THICKNESS, - start_radius=KNEE_BEARING_OUTER_RADIUS, - end_radius=DISTAL_CRANK_WIDTH / 2.0 + 2.0, - web_width=DISTAL_CRANK_WIDTH, - tag="role.shank_integral_pushrod_ear_base", - ) - standoff_outer = scad.make_cylinder_rsolid( - radius=KNEE_BEARING_OUTER_RADIUS, - height=distal_z_top - z_min, - bottom_face_center=(KNEE_AXIS[0], KNEE_AXIS[1], z_min), - axis=(0.0, 0.0, 1.0), - ) - standoff_inner = scad.make_cylinder_rsolid( - radius=KNEE_BEARING_BORE_RADIUS, - height=distal_z_top - z_min + 2.0, - bottom_face_center=(KNEE_AXIS[0], KNEE_AXIS[1], z_min - 1.0), - axis=(0.0, 0.0, 1.0), - ) - knee_standoff = scad.cut_rsolid(standoff_outer, standoff_inner, skip_non_intersecting=False) - shank = scad.union_rsolid([shank, distal_drive_ear, knee_standoff], glue=False) - wheel_clamp = make_split_case_clamp_rsolid( - center=WHEEL_AXIS, - z_center=WHEEL_CASE_CLAMP_Z, - tag="role.wheel_actuator_split_clamp", - ) - clamp_z_min = WHEEL_CASE_CLAMP_Z - CASE_CLAMP_WIDTH / 2.0 - clamp_post_height = z_min + SHANK_LINK_THICKNESS - clamp_z_min - clamp_posts = [ - scad.make_cylinder_rsolid( - radius=3.8, - height=clamp_post_height, - bottom_face_center=(WHEEL_AXIS[0], WHEEL_AXIS[1] + sign * 29.5, clamp_z_min), - axis=(0.0, 0.0, 1.0), - ) - for sign in (-1.0, 1.0) - ] - shank = scad.union_rsolid(shank, wheel_clamp, clamp_posts, glue=False) - integrated_knee_height = distal_z_top - z_min + 2.0 - cutters = [ - scad.make_cylinder_rsolid( - radius=PIN_CLEARANCE_RADIUS + 2.8, - height=integrated_knee_height, - bottom_face_center=(KNEE_AXIS[0], KNEE_AXIS[1], z_min - 1.0), - axis=(0.0, 0.0, 1.0), - ), - scad.make_cylinder_rsolid( - radius=ROD_PIN_CLEARANCE_RADIUS, - height=DISTAL_CRANK_THICKNESS + 2.0, - bottom_face_center=(DISTAL_PUSHROD_PIN[0], DISTAL_PUSHROD_PIN[1], distal_z_min - 1.0), - axis=(0.0, 0.0, 1.0), - ), - scad.make_cylinder_rsolid( - radius=19.5, - height=SHANK_LINK_THICKNESS + 2.0, - bottom_face_center=(WHEEL_AXIS[0], WHEEL_AXIS[1], z_min - 1.0), - axis=(0.0, 0.0, 1.0), - ), - ] - cutters.extend( - _make_link_window_cutters( - start=KNEE_AXIS, - end=WHEEL_AXIS, - z_center=SHANK_LINK_Z, - thickness=SHANK_LINK_THICKNESS, - fractions=(0.42, 0.66), - length=SHANK_WINDOW_LENGTH, - width=SHANK_WINDOW_WIDTH, - tag_prefix="shank_window", - ) - ) - shank = scad.cut_rsolid(shank, cutters, skip_non_intersecting=False) - shank = scad.apply_tag(shape=shank, tag="role.shank_wheel_plate") - print( - "shank_link: wheel_case_mount=split_clamp " - f"clamp_bore_d={50.3:.1f} " - f"integral_pushrod_ear={DISTAL_CRANK_LENGTH:.1f} " - f"length={SHANK_LENGTH:.1f} faces={len(shank.get_faces())} volume={shank.get_volume():.3f}" - ) - return make_part_with_connectors_rpart( - part_id="shank_link", - body=shank, - name="Lower shank plate with integral pushrod ear and wheel actuator split clamp", - material=material, - connectors=( - ("knee_axis", (KNEE_AXIS[0], KNEE_AXIS[1], KNEE_PIVOT_Z), "z", "Knee revolute datum"), - ("rod_pin", (DISTAL_PUSHROD_PIN[0], DISTAL_PUSHROD_PIN[1], ROD_PIN_AXIS_Z), "z", "Integral shank pushrod pin datum"), - ( - "wheel_case_axis", - (WHEEL_AXIS[0], WHEEL_AXIS[1], WHEEL_CASE_CLAMP_Z), - "z", - "Wheel hub actuator split-clamp datum", - ), - ( - "wheel_clamp_bolt_seat", - ( - WHEEL_AXIS[0] + CASE_CLAMP_PINCH_AXIS_RADIUS, - WHEEL_AXIS[1] + CASE_CLAMP_PINCH_HALF_SPAN - 0.9, - WHEEL_CASE_CLAMP_Z, - ), - "y", - "Wheel collar M4 bolt head seat", - ), - ), - ) - - -def make_wheel_tire_rpart(*, material: scad.Material) -> scad.Part: - """Create the rubber tire ring as a separate serviceable part.""" - - tire_center_z = -1.5 - tire_outer = scad.make_cylinder_rsolid( - radius=WHEEL_TIRE_RADIUS, - height=WHEEL_TIRE_WIDTH, - bottom_face_center=( - WHEEL_AXIS[0], - WHEEL_AXIS[1], - tire_center_z - WHEEL_TIRE_WIDTH / 2.0, - ), - axis=(0.0, 0.0, 1.0), - ) - tire_bore = scad.make_cylinder_rsolid( - radius=WHEEL_TIRE_BORE_RADIUS, - height=WHEEL_TIRE_WIDTH + 2.0, - bottom_face_center=( - WHEEL_AXIS[0], - WHEEL_AXIS[1], - tire_center_z - WHEEL_TIRE_WIDTH / 2.0 - 1.0, - ), - axis=(0.0, 0.0, 1.0), - ) - tire_ring = scad.cut_rsolid(tire_outer, tire_bore, skip_non_intersecting=False) - tire_ring = scad.apply_tag(shape=tire_ring, tag="role.replaceable_rubber_tire") - print( - f"wheel_tire: tire_radius={WHEEL_TIRE_RADIUS:.1f} width={WHEEL_TIRE_WIDTH:.1f} " - f"faces={len(tire_ring.get_faces())} volume={tire_ring.get_volume():.3f}" - ) - return make_part_with_connectors_rpart( - part_id="wheel_tire", - body=tire_ring, - name="Replaceable rubber wheel tire ring", - material=material, - connectors=(("hub_axis", WHEEL_AXIS, "z", "Wheel hub overmold datum"),), - ) - - -def make_wheel_hub_rpart(*, material: scad.Material) -> scad.Part: - """Create the rigid 7075 hub and spokes bolted to the actuator flange.""" - - hub_z = WHEEL_HUB_PLATE_THICKNESS / 2.0 - hub_z_min = hub_z - WHEEL_HUB_PLATE_THICKNESS / 2.0 - hub = scad.make_cylinder_rsolid( - radius=WHEEL_HUB_PLATE_RADIUS, - height=WHEEL_HUB_PLATE_THICKNESS, - bottom_face_center=(WHEEL_AXIS[0], WHEEL_AXIS[1], hub_z_min), - axis=(0.0, 0.0, 1.0), - ) - spokes = [ - _make_wheel_spoke_rsolid( - angle_degrees=22.5 + 360.0 * index / WHEEL_SPOKE_COUNT - ) - for index in range(WHEEL_SPOKE_COUNT) - ] - wheel = scad.union_rsolid([hub, spokes], glue=False) - cutters = [ - scad.make_cylinder_rsolid( - radius=OUTPUT_FLANGE_REGISTER_INNER_RADIUS + 0.05, - height=WHEEL_HUB_PLATE_THICKNESS + 2.0, - bottom_face_center=(WHEEL_AXIS[0], WHEEL_AXIS[1], hub_z_min - 1.0), - axis=(0.0, 0.0, 1.0), - ) - ] - cutters.extend( - make_bolt_circle_cutters_rsolidlist( - center=WHEEL_AXIS, - bolt_circle_radius=OUTPUT_FLANGE_BOLT_CIRCLE_RADIUS, - angles_degrees=OUTPUT_FLANGE_BOLT_ANGLES_DEGREES, - hole_radius=OUTPUT_FLANGE_BOLT_CLEARANCE_RADIUS, - z_min=hub_z_min - 1.0, - height=WHEEL_HUB_PLATE_THICKNESS + 2.0, - counterbore_radius=OUTPUT_FLANGE_BOLT_COUNTERBORE_RADIUS, - counterbore_depth=3.0, - counterbore_from_top=True, - counterbore_face_z=hub_z_min + WHEEL_HUB_PLATE_THICKNESS, - ) - ) - wheel = scad.cut_rsolid(wheel, cutters, skip_non_intersecting=False) - wheel = scad.apply_tag(shape=wheel, tag="role.rigid_spoked_wheel_hub") - print( - f"wheel_hub: output_holes={len(OUTPUT_FLANGE_BOLT_ANGLES_DEGREES)} " - f"output_pcd={OUTPUT_FLANGE_BOLT_CIRCLE_RADIUS * 2.0:.1f} spokes={WHEEL_SPOKE_COUNT} " - f"faces={len(wheel.get_faces())} volume={wheel.get_volume():.3f}" - ) - return make_part_with_connectors_rpart( - part_id="wheel_hub", - body=wheel, - name="7075 spoked wheel hub bolted to the actuator output flange", - material=material, - connectors=( - ("wheel_axis", WHEEL_AXIS, "z", "Wheel spin datum"), - ("tire_axis", WHEEL_AXIS, "z", "Replaceable tire datum"), - *_output_bolt_connectors( - center=WHEEL_AXIS, - face_z=hub_z_min + WHEEL_HUB_PLATE_THICKNESS, - axis="z", - ), - ), - ) - - -def _make_axis_plate_base_rsolid( - *, - start: tuple[float, float, float], - end: tuple[float, float, float], - z_center: float, - thickness: float, - start_radius: float, - end_radius: float, - web_width: float, - tag: str, -) -> scad.Solid: - length = _xy_distance(start, end) - if length <= max(start_radius, end_radius): - raise ValueError("axis plate endpoints are too close") - z_min = z_center - thickness / 2.0 - web = scad.make_box_rsolid( - width=length, - height=web_width, - depth=thickness, - bottom_face_center=(length / 2.0, 0.0, z_min), - ) - start_boss = scad.make_cylinder_rsolid( - radius=start_radius, - height=thickness, - bottom_face_center=(0.0, 0.0, z_min), - axis=(0.0, 0.0, 1.0), - ) - end_boss = scad.make_cylinder_rsolid( - radius=end_radius, - height=thickness, - bottom_face_center=(length, 0.0, z_min), - axis=(0.0, 0.0, 1.0), - ) - plate = scad.union_rsolid([web, start_boss, end_boss], glue=False) - angle_degrees = math.degrees(math.atan2(end[1] - start[1], end[0] - start[0])) - plate = scad.rotate_shape( - shape=plate, - angle=angle_degrees, - axis=(0.0, 0.0, 1.0), - origin=(0.0, 0.0, z_center), - ) - plate = scad.translate_shape(shape=plate, vector=(start[0], start[1], 0.0)) - return scad.apply_tag(shape=plate, tag=tag) - - -def _make_link_window_cutters( - *, - start: tuple[float, float, float], - end: tuple[float, float, float], - z_center: float, - thickness: float, - fractions: tuple[float, ...], - length: float, - width: float, - tag_prefix: str, -) -> list[scad.Solid]: - angle_degrees = math.degrees(math.atan2(end[1] - start[1], end[0] - start[0])) - cutters = [] - for index, fraction in enumerate(fractions, start=1): - cutters.append( - make_rounded_slot_cutter_rsolid( - center=( - start[0] + (end[0] - start[0]) * fraction, - start[1] + (end[1] - start[1]) * fraction, - z_center, - ), - length=length, - width=width, - height=thickness + 2.0, - angle_degrees=angle_degrees, - tag=f"role.{tag_prefix}_{index}", - ) - ) - return cutters - - -def _make_wheel_spoke_rsolid(*, angle_degrees: float) -> scad.Solid: - hub_overlap_radius = WHEEL_HUB_PLATE_RADIUS - 1.5 - rim_overlap_radius = WHEEL_TIRE_BORE_RADIUS + 1.5 - length = rim_overlap_radius - hub_overlap_radius - radial_center = (hub_overlap_radius + rim_overlap_radius) / 2.0 - z_center = WHEEL_HUB_PLATE_THICKNESS / 2.0 - spoke = scad.make_box_rsolid( - width=length, - height=WHEEL_SPOKE_WIDTH, - depth=WHEEL_HUB_PLATE_THICKNESS, - bottom_face_center=(WHEEL_AXIS[0] + radial_center, WHEEL_AXIS[1], z_center - WHEEL_HUB_PLATE_THICKNESS / 2.0), - ) - return scad.rotate_shape( - shape=spoke, - angle=angle_degrees, - axis=(0.0, 0.0, 1.0), - origin=WHEEL_AXIS, - ) - - -def _xy_distance(a: tuple[float, float, float], b: tuple[float, float, float]) -> float: - return math.hypot(b[0] - a[0], b[1] - a[1]) - - -def _output_bolt_connectors( - *, center: tuple[float, float, float], face_z: float, axis: str -) -> tuple[tuple[str, tuple[float, float, float], str, str], ...]: - connectors = [] - for index, angle_degrees in enumerate(OUTPUT_FLANGE_BOLT_ANGLES_DEGREES, start=1): - angle = math.radians(angle_degrees) - connectors.append( - ( - f"output_bolt_{index}_head_top", - ( - center[0] + OUTPUT_FLANGE_BOLT_CIRCLE_RADIUS * math.cos(angle), - center[1] + OUTPUT_FLANGE_BOLT_CIRCLE_RADIUS * math.sin(angle), - face_z, - ), - axis, - f"M3 output screw {index} head top", - ) - ) - return tuple(connectors) diff --git a/SimpleCADAPI/examples/18_leg_wheel_robot_dog_leg/main.py b/SimpleCADAPI/examples/18_leg_wheel_robot_dog_leg/main.py deleted file mode 100644 index abbf9e6..0000000 --- a/SimpleCADAPI/examples/18_leg_wheel_robot_dog_leg/main.py +++ /dev/null @@ -1,90 +0,0 @@ -"""Build, validate, and export the leg-wheel robot dog leg example.""" - -from __future__ import annotations - -import json -import sys -from pathlib import Path - -import simplecadapi as scad - -from leg_assembly import make_leg_wheel_robot_dog_leg_rassembly -from actuator import make_actuator_materials_rdict -from leg_common import ground_compound -from leg_materials import make_leg_materials_rdict -from leg_dimensions import validate_leg_interface_dimensions - - -# Example 16's reducer graph is intentionally deep because of herringbone gears. -sys.setrecursionlimit(40000) - -OUT_DIR = Path("examples/out/leg_wheel_robot_dog_leg") - - -def _build_leg_wheel_robot_dog_leg(): - validate_leg_interface_dimensions() - actuator_materials = make_actuator_materials_rdict() - leg_materials = make_leg_materials_rdict() - with scad.GraphSession(graph_id="leg_wheel_robot_dog_leg") as session: - assembly = make_leg_wheel_robot_dog_leg_rassembly( - actuator_materials=actuator_materials, - leg_materials=leg_materials, - ) - preview = scad.make_compound_from_assembly_rcompound(assembly=assembly) - ground_compound(label="leg_preview", compound=preview) - leaf_ops = [node.op for node in session.graph.leaf_nodes()] - print(f"leg_graph_results: leaves={len(leaf_ops)} ops={','.join(leaf_ops)}") - if leaf_ops != ["make_compound_from_assembly_rcompound"]: - raise RuntimeError("Leg graph contains detached source results") - session_json = scad.export_session_json(session=session) - model_json = scad.export_model_json(session=session) - return assembly, preview, model_json, session_json - - -def main() -> None: - OUT_DIR.mkdir(parents=True, exist_ok=True) - model_path = OUT_DIR / "leg_wheel_robot_dog_leg.model.json" - session_path = OUT_DIR / "leg_wheel_robot_dog_leg.session.json" - step_path = OUT_DIR / "leg_wheel_robot_dog_leg.step" - fcstd_path = OUT_DIR / "leg_wheel_robot_dog_leg.FCStd" - if fcstd_path.exists(): - fcstd_path.unlink() - - assembly, preview, model_json, session_json = _build_leg_wheel_robot_dog_leg() - model_path.write_text(model_json, encoding="utf-8") - session_path.write_text(session_json, encoding="utf-8") - scad.export_step(shapes=preview, filename=str(step_path)) - - imported = scad.import_model_json(json_str=model_json) - replayed = scad.replay_model_json(json_str=model_json) - payload = json.loads(model_json) - - fcstd_status = "not attempted" - try: - scad.translator.freecad_translator.translate_model_json_to_fcstd( - json_str=model_json, - output_path=str(fcstd_path.resolve()), - document_name="LegWheelRobotDogLeg", - freecad_cmd=None, - ) - fcstd_status = f"{fcstd_path} ({fcstd_path.stat().st_size} bytes)" - except Exception as exc: # pragma: no cover - depends on local FreeCAD install - fcstd_status = f"skipped ({exc.__class__.__name__}: {exc})" - - print(f"assembly={assembly.assembly_id}") - print("components=" + ",".join(assembly.component_ids())) - print("constraints=" + ",".join(assembly.constraint_ids())) - print(f"preview_solids={len(preview.get_solids())}") - print(f"preview_volume={preview.get_volume():.3f}") - print(f"imported_keys={','.join(sorted(imported.keys()))}") - print(f"replay_outputs={len(replayed)}") - print("replay_types=" + ",".join(type(item).__name__ for item in replayed)) - print(f"graph_nodes={len(payload['graph']['nodes'])}") - print(f"model={model_path}") - print(f"session={session_path}") - print(f"step={step_path}") - print(f"fcstd={fcstd_status}") - - -if __name__ == "__main__": - main() diff --git a/SimpleCADAPI/examples/19_four_planet_planetary_reducer/assembly.py b/SimpleCADAPI/examples/19_four_planet_planetary_reducer/assembly.py deleted file mode 100644 index 0760a8d..0000000 --- a/SimpleCADAPI/examples/19_four_planet_planetary_reducer/assembly.py +++ /dev/null @@ -1,163 +0,0 @@ -"""Assembly and kinematic constraints for a four-planet planetary reducer.""" - -from __future__ import annotations - -import simplecadapi as scad - -from dimensions import ( - FIXED_RING_REDUCTION, - PLANET_COUNT, - PLANET_PITCH_RADIUS, - RING_PITCH_RADIUS, - SUN_PITCH_RADIUS, -) -from materials import make_materials_rdict -from parts import ( - make_carrier_rpart, - make_planet_component_rplacement, - make_planet_gear_rpart, - make_ring_gear_rpart, - make_sun_gear_rpart, -) - - -def make_four_planet_planetary_reducer_rassembly() -> scad.Assembly: - """Build and solve the exposed four-planet fixed-ring reducer gearset.""" - - print( - f"ratio_plan: fixed_ring={FIXED_RING_REDUCTION:.3f}:1 " - f"planets={PLANET_COUNT} sun_r={SUN_PITCH_RADIUS:.3f} " - f"planet_r={PLANET_PITCH_RADIUS:.3f} ring_r={RING_PITCH_RADIUS:.3f}" - ) - materials = make_materials_rdict() - sun = make_sun_gear_rpart(material=materials["gear"]) - ring = make_ring_gear_rpart(material=materials["ring"]) - planet = make_planet_gear_rpart(material=materials["gear"]) - carrier = make_carrier_rpart(material=materials["carrier"]) - - reducer = scad.make_assembly_rassembly( - assembly_id="four_planet_planetary_reducer", - name="Exposed 3.5:1 four-planet fixed-ring planetary reducer gearset", - ) - for component_id, item, placement, name in ( - ("fixed_ring", ring, scad.identity_placement_rplacement(), "Fixed internal ring gear"), - ("sun_input", sun, scad.identity_placement_rplacement(), "Input sun gear"), - ("output_carrier", carrier, scad.identity_placement_rplacement(), "Four-pin output carrier"), - ): - reducer = scad.add_component_rassembly( - assembly=reducer, - item=item, - component_id=component_id, - placement=placement, - name=name, - ) - for index in range(PLANET_COUNT): - reducer = scad.add_component_rassembly( - assembly=reducer, - item=planet, - component_id=f"planet_{index + 1}", - placement=make_planet_component_rplacement(index=index), - name=f"Planet gear {index + 1}", - ) - - reducer = _add_public_connectors_rassembly(assembly=reducer) - reducer = _add_kinematic_constraints_rassembly(assembly=reducer) - reducer = scad.solve_assembly_constraints_rassembly(assembly=reducer, strict=True) - _ground_constraint_report(assembly=reducer) - print( - f"planetary_components: count={len(reducer.component_ids())} " - f"constraints={len(reducer.constraint_ids())}" - ) - return reducer - - -def _add_public_connectors_rassembly(*, assembly: scad.Assembly) -> scad.Assembly: - forwarded = ( - ("fixed_axis", "fixed_ring", "axis", "Fixed ring datum"), - ("input_axis", "sun_input", "axis", "Sun input datum"), - ("output_axis", "output_carrier", "output_axis", "Carrier output datum"), - ) - for connector_id, component_id, source_connector_id, name in forwarded: - assembly = scad.forward_connector_rassembly( - assembly=assembly, - connector_id=connector_id, - source_component_id=component_id, - source_connector_id=source_connector_id, - name=name, - ) - print("public_connectors: " + ",".join(connector_id for connector_id, *_ in forwarded)) - return assembly - - -def _add_kinematic_constraints_rassembly(*, assembly: scad.Assembly) -> scad.Assembly: - assembly = scad.ground_component_rassembly(assembly=assembly, component_id="fixed_ring") - revolutes = ( - ("sun_input_revolute", "fixed_ring", "axis", "sun_input", "axis", 0.0), - ("carrier_output_revolute", "fixed_ring", "axis", "output_carrier", "axis", 0.0), - ) - for constraint_id, a_component, a_connector, b_component, b_connector, drive_angle in revolutes: - assembly = scad.add_revolute_constraint_rassembly( - assembly=assembly, - constraint_id=constraint_id, - connector_a=_ref(component_id=a_component, connector_id=a_connector), - connector_b=_ref(component_id=b_component, connector_id=b_connector), - drive_angle_degrees=drive_angle, - angle_limit=None, - name=constraint_id.replace("_", " "), - ) - for index in range(PLANET_COUNT): - planet_id = f"planet_{index + 1}" - assembly = scad.add_revolute_constraint_rassembly( - assembly=assembly, - constraint_id=f"planet_{index + 1}_pin_revolute", - connector_a=_ref(component_id="output_carrier", connector_id=f"planet_{index + 1}_axis"), - connector_b=_ref(component_id=planet_id, connector_id="axis"), - drive_angle_degrees=None, - angle_limit=None, - name=f"Planet {index + 1} pin bearing revolute", - ) - assembly = scad.add_gear_constraint_rassembly( - assembly=assembly, - constraint_id=f"sun_to_planet_{index + 1}_external_mesh", - connector_a=_ref(component_id="sun_input", connector_id="axis"), - connector_b=_ref(component_id=planet_id, connector_id="axis"), - pitch_radius_a=SUN_PITCH_RADIUS, - pitch_radius_b=PLANET_PITCH_RADIUS, - phase_offset=None, - name=f"Sun external mesh to planet {index + 1}", - ) - assembly = scad.add_belt_constraint_rassembly( - assembly=assembly, - constraint_id=f"ring_to_planet_{index + 1}_internal_mesh", - connector_a=_ref(component_id="fixed_ring", connector_id="axis"), - connector_b=_ref(component_id=planet_id, connector_id="axis"), - pulley_radius_a=RING_PITCH_RADIUS, - pulley_radius_b=PLANET_PITCH_RADIUS, - phase_offset=None, - name=f"Fixed ring internal mesh to planet {index + 1}", - ) - print( - f"constraints_added: grounded=1 revolute={2 + PLANET_COUNT} " - f"external_mesh={PLANET_COUNT} internal_mesh={PLANET_COUNT}" - ) - return assembly - - -def _ref(*, component_id: str, connector_id: str) -> scad.ConnectorRef: - return scad.make_connector_ref_rconnectorref( - component_id=component_id, - connector_id=connector_id, - ) - - -def _ground_constraint_report(*, assembly: scad.Assembly) -> None: - report = scad.inspect_assembly_constraints_rconstraintreport(assembly=assembly) - print( - f"assembly_constraints: solved={report.solved} grounded={len(report.grounded_component_ids)} " - f"solved_components={len(report.solved_component_ids)} unsolved={len(report.unsolved_component_ids)}" - ) - for residual in report.residuals: - print( - f"constraint_{residual.constraint_id}: translation={residual.translation_error:.6g} " - f"angle={residual.angular_error_degrees:.6g} ok={residual.within_tolerance}" - ) diff --git a/SimpleCADAPI/examples/19_four_planet_planetary_reducer/common.py b/SimpleCADAPI/examples/19_four_planet_planetary_reducer/common.py deleted file mode 100644 index 844bb34..0000000 --- a/SimpleCADAPI/examples/19_four_planet_planetary_reducer/common.py +++ /dev/null @@ -1,78 +0,0 @@ -"""Shared helpers for Example 19.""" - -from __future__ import annotations - -import math - -import simplecadapi as scad -from simplecadapi import ql - - -def make_z_rotation_rplacement( - *, - origin: tuple[float, float, float], - angle_degrees: float, -) -> scad.Placement: - """Create a placement rotated about local Z and translated to origin.""" - - angle = math.radians(angle_degrees) - return scad.make_placement_rplacement( - origin=origin, - x_axis=(math.cos(angle), math.sin(angle), 0.0), - y_axis=(-math.sin(angle), math.cos(angle), 0.0), - ) - - -def add_axis_connector_rpart( - *, - part: scad.Part, - connector_id: str, - origin: tuple[float, float, float], - name: str, -) -> scad.Part: - """Attach a topology-free Z-axis datum connector to a part.""" - - connector = scad.make_placement_connector_rconnector( - connector_id=connector_id, - placement=scad.make_placement_rplacement( - origin=origin, - x_axis=(1.0, 0.0, 0.0), - y_axis=(0.0, 1.0, 0.0), - ), - name=name, - ) - return scad.add_connector_rpart(part=part, connector=connector) - - -def make_axis_part_rpart( - *, - part_id: str, - body: scad.Solid, - name: str, - material: scad.Material, - connector_specs: tuple[tuple[str, tuple[float, float, float], str], ...], -) -> scad.Part: - """Wrap one solid as a part and attach named axis connectors.""" - - part = scad.make_part_rpart(part_id=part_id, body=body, name=name) - part = scad.assign_material_rpart(part=part, material=material) - for connector_id, origin, connector_name in connector_specs: - part = add_axis_connector_rpart( - part=part, - connector_id=connector_id, - origin=origin, - name=connector_name, - ) - print(f"part_{part_id}: connectors={len(part.connectors)} volume={body.get_volume():.3f}") - return part - - -def ground_solid(*, label: str, solid: scad.Solid) -> None: - """Print a small QL-grounded summary for a generated solid.""" - - faces = ql.select(items=solid.get_faces()).all() - edges = ql.select(items=solid.get_edges()).all() - print( - f"{label}: faces={len(faces)} edges={len(edges)} " - f"volume={solid.get_volume():.3f} tags={','.join(scad.list_tags(shape=solid))}" - ) diff --git a/SimpleCADAPI/examples/19_four_planet_planetary_reducer/dimensions.py b/SimpleCADAPI/examples/19_four_planet_planetary_reducer/dimensions.py deleted file mode 100644 index e04ffa9..0000000 --- a/SimpleCADAPI/examples/19_four_planet_planetary_reducer/dimensions.py +++ /dev/null @@ -1,54 +0,0 @@ -"""Design constants for the four-planet single-stage planetary reducer.""" - -from __future__ import annotations - -import math - - -MODULE = 1.5 -PRESSURE_ANGLE = 20.0 -GEAR_HEIGHT = 8.0 -BACKLASH = 0.04 -ADDENDUM_FACTOR = 1.0 -CLEARANCE_FACTOR = 0.25 - -SUN_TEETH = 24 -PLANET_TEETH = 18 -PLANET_COUNT = 4 -RING_TEETH = SUN_TEETH + 2 * PLANET_TEETH - -SUN_PITCH_RADIUS = MODULE * SUN_TEETH / 2.0 -PLANET_PITCH_RADIUS = MODULE * PLANET_TEETH / 2.0 -RING_PITCH_RADIUS = MODULE * RING_TEETH / 2.0 -PLANET_CENTER_RADIUS = SUN_PITCH_RADIUS + PLANET_PITCH_RADIUS -FIXED_RING_REDUCTION = 1.0 + RING_TEETH / SUN_TEETH - -RING_RIM_THICKNESS = 4.0 -GEAR_AXIS_Z = GEAR_HEIGHT / 2.0 - -SUN_BORE_RADIUS = 3.0 -PLANET_PIN_RADIUS = 2.6 -PLANET_PIN_CLEARANCE_RADIUS = 3.2 - -CARRIER_BOTTOM_Z = -5.0 -CARRIER_THICKNESS = 4.0 -CARRIER_HUB_RADIUS = 10.0 -CARRIER_ARM_WIDTH = 6.0 -CARRIER_PIN_BOSS_RADIUS = 5.2 -CARRIER_PIN_HEIGHT = GEAR_HEIGHT + 6.0 - - -def planet_angle_degrees(*, index: int) -> float: - """Return the equally spaced carrier angle for one planet index.""" - - return 360.0 * index / PLANET_COUNT - - -def planet_center_xy(*, index: int) -> tuple[float, float]: - """Return the XY pitch-center location for one planet.""" - - angle = math.radians(planet_angle_degrees(index=index)) - return ( - PLANET_CENTER_RADIUS * math.cos(angle), - PLANET_CENTER_RADIUS * math.sin(angle), - ) diff --git a/SimpleCADAPI/examples/19_four_planet_planetary_reducer/main.py b/SimpleCADAPI/examples/19_four_planet_planetary_reducer/main.py deleted file mode 100644 index 22598f6..0000000 --- a/SimpleCADAPI/examples/19_four_planet_planetary_reducer/main.py +++ /dev/null @@ -1,71 +0,0 @@ -"""Build, solve, and export the four-planet planetary reducer gearset.""" - -from __future__ import annotations - -import json -from pathlib import Path - -import simplecadapi as scad - -from assembly import make_four_planet_planetary_reducer_rassembly - - -OUT_DIR = Path("examples/out/19_four_planet_planetary_reducer") - - -def _build_four_planet_reducer(): - with scad.GraphSession(graph_id="four_planet_planetary_reducer") as session: - assembly = make_four_planet_planetary_reducer_rassembly() - preview = scad.make_compound_from_assembly_rcompound(assembly=assembly) - session_json = scad.export_session_json(session=session) - model_json = scad.export_model_json(session=session) - return assembly, preview, model_json, session_json - - -def main() -> None: - OUT_DIR.mkdir(parents=True, exist_ok=True) - model_path = OUT_DIR / "four_planet_planetary_reducer.model.json" - session_path = OUT_DIR / "four_planet_planetary_reducer.session.json" - step_path = OUT_DIR / "four_planet_planetary_reducer.step" - fcstd_path = OUT_DIR / "four_planet_planetary_reducer.FCStd" - if fcstd_path.exists(): - fcstd_path.unlink() - - assembly, preview, model_json, session_json = _build_four_planet_reducer() - model_path.write_text(model_json, encoding="utf-8") - session_path.write_text(session_json, encoding="utf-8") - scad.export_step(shapes=preview, filename=str(step_path)) - - imported = scad.import_model_json(json_str=model_json) - replayed = scad.replay_model_json(json_str=model_json) - payload = json.loads(model_json) - - fcstd_status = "not attempted" - try: - scad.translator.freecad_translator.translate_model_json_to_fcstd( - json_str=model_json, - output_path=str(fcstd_path.resolve()), - document_name="FourPlanetPlanetaryReducer", - freecad_cmd=None, - ) - fcstd_status = f"{fcstd_path} ({fcstd_path.stat().st_size} bytes)" - except Exception as exc: # pragma: no cover - depends on local FreeCAD install - fcstd_status = f"skipped ({exc.__class__.__name__}: {exc})" - - print(f"assembly={assembly.assembly_id}") - print("components=" + ",".join(assembly.component_ids())) - print("constraints=" + ",".join(assembly.constraint_ids())) - print(f"preview_solids={len(preview.get_solids())}") - print(f"preview_volume={preview.get_volume():.3f}") - print(f"imported_keys={','.join(sorted(imported.keys()))}") - print(f"replay_outputs={len(replayed)}") - print("replay_types=" + ",".join(type(item).__name__ for item in replayed)) - print(f"graph_nodes={len(payload['graph']['nodes'])}") - print(f"model={model_path}") - print(f"session={session_path}") - print(f"step={step_path}") - print(f"fcstd={fcstd_status}") - - -if __name__ == "__main__": - main() diff --git a/SimpleCADAPI/examples/19_four_planet_planetary_reducer/materials.py b/SimpleCADAPI/examples/19_four_planet_planetary_reducer/materials.py deleted file mode 100644 index 4efdd8b..0000000 --- a/SimpleCADAPI/examples/19_four_planet_planetary_reducer/materials.py +++ /dev/null @@ -1,35 +0,0 @@ -"""Materials for the four-planet planetary reducer example.""" - -from __future__ import annotations - -import simplecadapi as scad - - -def make_materials_rdict() -> dict[str, scad.Material]: - """Create simple material definitions for the exposed gearset.""" - - materials = { - "gear": scad.make_material_rmaterial( - material_id="case_hardened_steel", - name="Case hardened gear steel", - density=7.85e-6, - density_unit="kg/mm^3", - color=(0.70, 0.70, 0.74), - ), - "ring": scad.make_material_rmaterial( - material_id="nitrided_internal_ring_steel", - name="Nitrided internal ring steel", - density=7.85e-6, - density_unit="kg/mm^3", - color=(0.44, 0.46, 0.50), - ), - "carrier": scad.make_material_rmaterial( - material_id="aluminum_7075_t6", - name="7075-T6 aluminum carrier", - density=2.81e-6, - density_unit="kg/mm^3", - color=(0.14, 0.48, 0.70), - ), - } - print("materials: " + ",".join(sorted(materials))) - return materials diff --git a/SimpleCADAPI/examples/19_four_planet_planetary_reducer/parts.py b/SimpleCADAPI/examples/19_four_planet_planetary_reducer/parts.py deleted file mode 100644 index 01b0925..0000000 --- a/SimpleCADAPI/examples/19_four_planet_planetary_reducer/parts.py +++ /dev/null @@ -1,206 +0,0 @@ -"""Gear and carrier parts for the four-planet planetary reducer.""" - -from __future__ import annotations - -import simplecadapi as scad - -from common import ground_solid, make_axis_part_rpart, make_z_rotation_rplacement -from dimensions import ( - ADDENDUM_FACTOR, - BACKLASH, - CARRIER_ARM_WIDTH, - CARRIER_BOTTOM_Z, - CARRIER_HUB_RADIUS, - CARRIER_PIN_BOSS_RADIUS, - CARRIER_PIN_HEIGHT, - CARRIER_THICKNESS, - CLEARANCE_FACTOR, - GEAR_AXIS_Z, - GEAR_HEIGHT, - MODULE, - PLANET_CENTER_RADIUS, - PLANET_COUNT, - PLANET_PIN_CLEARANCE_RADIUS, - PLANET_PIN_RADIUS, - PLANET_TEETH, - PRESSURE_ANGLE, - RING_RIM_THICKNESS, - RING_TEETH, - SUN_BORE_RADIUS, - SUN_TEETH, - planet_angle_degrees, - planet_center_xy, -) - - -def make_sun_gear_rpart(*, material: scad.Material) -> scad.Part: - """Create the input sun gear with a service bore and axis connector.""" - - sun = scad.std.gear.make_spur_gear_rsolid( - n_teeth=SUN_TEETH, - module=MODULE, - pressure_angle=PRESSURE_ANGLE, - gear_height=GEAR_HEIGHT, - addendum_factor=ADDENDUM_FACTOR, - clearance_factor=CLEARANCE_FACTOR, - backlash=BACKLASH, - ) - bore = scad.make_cylinder_rsolid( - radius=SUN_BORE_RADIUS, - height=GEAR_HEIGHT + 2.0, - bottom_face_center=(0.0, 0.0, -1.0), - axis=(0.0, 0.0, 1.0), - ) - sun = scad.cut_rsolid(sun, bore, skip_non_intersecting=False) - sun = scad.apply_tag(shape=sun, tag="role.sun_input_gear") - ground_solid(label="sun_gear", solid=sun) - return make_axis_part_rpart( - part_id="sun_input_gear", - body=sun, - name="Input sun gear, 24 teeth", - material=material, - connector_specs=(("axis", (0.0, 0.0, GEAR_AXIS_Z), "Sun input axis"),), - ) - - -def make_ring_gear_rpart(*, material: scad.Material) -> scad.Part: - """Create the fixed internal ring gear without an enclosing housing.""" - - ring = scad.std.gear.make_spur_ring_gear_rsolid( - n_teeth=RING_TEETH, - module=MODULE, - pressure_angle=PRESSURE_ANGLE, - gear_height=GEAR_HEIGHT, - rim_thickness=RING_RIM_THICKNESS, - backlash=BACKLASH, - addendum_factor=ADDENDUM_FACTOR, - clearance_factor=CLEARANCE_FACTOR, - ) - ring = scad.apply_tag(shape=ring, tag="role.fixed_internal_ring_gear") - ground_solid(label="ring_gear", solid=ring) - return make_axis_part_rpart( - part_id="fixed_ring_gear", - body=ring, - name="Fixed internal ring gear, 60 teeth", - material=material, - connector_specs=(("axis", (0.0, 0.0, GEAR_AXIS_Z), "Fixed ring axis"),), - ) - - -def make_planet_gear_rpart(*, material: scad.Material) -> scad.Part: - """Create one reusable planet gear with a carrier-pin bore.""" - - planet = scad.std.gear.make_spur_gear_rsolid( - n_teeth=PLANET_TEETH, - module=MODULE, - pressure_angle=PRESSURE_ANGLE, - gear_height=GEAR_HEIGHT, - addendum_factor=ADDENDUM_FACTOR, - clearance_factor=CLEARANCE_FACTOR, - backlash=BACKLASH, - ) - bore = scad.make_cylinder_rsolid( - radius=PLANET_PIN_CLEARANCE_RADIUS, - height=GEAR_HEIGHT + 2.0, - bottom_face_center=(0.0, 0.0, -1.0), - axis=(0.0, 0.0, 1.0), - ) - planet = scad.cut_rsolid(planet, bore, skip_non_intersecting=False) - planet = scad.apply_tag(shape=planet, tag="role.reusable_planet_gear") - ground_solid(label="planet_gear", solid=planet) - return make_axis_part_rpart( - part_id="planet_gear", - body=planet, - name="Reusable planet gear, 18 teeth", - material=material, - connector_specs=(("axis", (0.0, 0.0, GEAR_AXIS_Z), "Planet spin axis"),), - ) - - -def make_carrier_rpart(*, material: scad.Material) -> scad.Part: - """Create the four-pin carrier output spider.""" - - hub = scad.make_cylinder_rsolid( - radius=CARRIER_HUB_RADIUS, - height=CARRIER_THICKNESS, - bottom_face_center=(0.0, 0.0, CARRIER_BOTTOM_Z), - axis=(0.0, 0.0, 1.0), - ) - arms: list[scad.Solid] = [] - pin_bosses: list[scad.Solid] = [] - pins: list[scad.Solid] = [] - for index in range(PLANET_COUNT): - angle = planet_angle_degrees(index=index) - x, y = planet_center_xy(index=index) - arm = scad.make_box_rsolid( - width=PLANET_CENTER_RADIUS + CARRIER_PIN_BOSS_RADIUS, - height=CARRIER_ARM_WIDTH, - depth=CARRIER_THICKNESS, - bottom_face_center=(PLANET_CENTER_RADIUS / 2.0, 0.0, CARRIER_BOTTOM_Z), - ) - arms.append( - scad.rotate_shape( - shape=arm, - angle=angle, - axis=(0.0, 0.0, 1.0), - origin=(0.0, 0.0, 0.0), - ) - ) - pin_bosses.append( - scad.make_cylinder_rsolid( - radius=CARRIER_PIN_BOSS_RADIUS, - height=CARRIER_THICKNESS, - bottom_face_center=(x, y, CARRIER_BOTTOM_Z), - axis=(0.0, 0.0, 1.0), - ) - ) - pins.append( - scad.make_cylinder_rsolid( - radius=PLANET_PIN_RADIUS, - height=CARRIER_PIN_HEIGHT, - bottom_face_center=(x, y, CARRIER_BOTTOM_Z), - axis=(0.0, 0.0, 1.0), - ) - ) - carrier = scad.union_rsolid([hub, arms, pin_bosses, pins], glue=False) - center_bore = scad.make_cylinder_rsolid( - radius=SUN_BORE_RADIUS + 0.8, - height=CARRIER_THICKNESS + 2.0, - bottom_face_center=(0.0, 0.0, CARRIER_BOTTOM_Z - 1.0), - axis=(0.0, 0.0, 1.0), - ) - carrier = scad.cut_rsolid(carrier, center_bore, skip_non_intersecting=False) - carrier = scad.apply_tag(shape=carrier, tag="role.four_pin_output_carrier") - ground_solid(label="carrier", solid=carrier) - connector_specs = [ - ("axis", (0.0, 0.0, GEAR_AXIS_Z), "Carrier output axis"), - ("output_axis", (0.0, 0.0, GEAR_AXIS_Z), "Public output axis"), - ] - connector_specs.extend( - ( - f"planet_{index + 1}_axis", - (*planet_center_xy(index=index), GEAR_AXIS_Z), - f"Planet {index + 1} carrier pin axis", - ) - for index in range(PLANET_COUNT) - ) - return make_axis_part_rpart( - part_id="four_pin_output_carrier", - body=carrier, - name="Four-pin output carrier spider", - material=material, - connector_specs=tuple(connector_specs), - ) - - -def make_planet_component_rplacement(*, index: int) -> scad.Placement: - """Return the placement for one of the four equally spaced planets.""" - - angle = planet_angle_degrees(index=index) - x, y = planet_center_xy(index=index) - tooth_phase = angle + 180.0 - (180.0 / PLANET_TEETH) - print( - f"planet_{index + 1}: center=({x:.3f},{y:.3f},0.000) " - f"carrier_angle={angle:.1f} spin_phase={tooth_phase:.1f}" - ) - return make_z_rotation_rplacement(origin=(x, y, 0.0), angle_degrees=tooth_phase) diff --git a/SimpleCADAPI/examples/20_integrated_bldc_joint_actuator/assembly.py b/SimpleCADAPI/examples/20_integrated_bldc_joint_actuator/assembly.py index 95952ff..ebd6389 100644 --- a/SimpleCADAPI/examples/20_integrated_bldc_joint_actuator/assembly.py +++ b/SimpleCADAPI/examples/20_integrated_bldc_joint_actuator/assembly.py @@ -88,6 +88,7 @@ except ImportError: # Support direct execution from this example directory. from motor import make_bldc_rotor_rassembly, make_bldc_stator_rassembly +@scad.requires_session def make_integrated_bldc_joint_actuator_rassembly( *, materials: dict[str, scad.Material] ) -> scad.Assembly: @@ -116,6 +117,7 @@ def make_integrated_bldc_joint_actuator_rassembly( return actuator +@scad.requires_session def make_integrated_bldc_joint_actuator_components_rtuple( *, materials: dict[str, scad.Material] ) -> tuple[tuple[str, scad.Part | scad.Assembly, scad.Placement, str], ...]: @@ -258,6 +260,7 @@ def make_integrated_bldc_joint_actuator_components_rtuple( ) +@scad.requires_session def _add_public_connectors_rassembly(*, assembly: scad.Assembly) -> scad.Assembly: forwarded = ( ("case_clamp_axis", "reducer_housing", "case_clamp_axis", "External split-clamp datum"), @@ -279,6 +282,7 @@ def _add_public_connectors_rassembly(*, assembly: scad.Assembly) -> scad.Assembl return assembly +@scad.requires_session def _add_constraints_rassembly(*, assembly: scad.Assembly) -> scad.Assembly: assembly = scad.ground_component_rassembly(assembly=assembly, component_id="reducer_housing") assembly = scad.ground_component_rassembly(assembly=assembly, component_id="stage1_ring") @@ -339,6 +343,7 @@ def _add_constraints_rassembly(*, assembly: scad.Assembly) -> scad.Assembly: return assembly +@scad.requires_session def _add_stage_constraints_rassembly( *, assembly: scad.Assembly, @@ -386,6 +391,7 @@ def _add_stage_constraints_rassembly( return assembly +@scad.requires_session def _add_bearing_constraints_rassembly(*, assembly: scad.Assembly) -> scad.Assembly: interfaces = ( ("rear_bearing_outer_to_spider", "rear_bearing_spider", "bearing_axis", "rear_motor_bearing", "outer_axis"), @@ -437,5 +443,6 @@ def _add_bearing_constraints_rassembly(*, assembly: scad.Assembly) -> scad.Assem return assembly +@scad.requires_session def _stage_rplacement(*, stage: StageSpec) -> scad.Placement: return scad.make_placement_rplacement(origin=(0.0, 0.0, stage.bottom_z)) diff --git a/SimpleCADAPI/examples/20_integrated_bldc_joint_actuator/bearings.py b/SimpleCADAPI/examples/20_integrated_bldc_joint_actuator/bearings.py index 4c4e839..5f8ff2b 100644 --- a/SimpleCADAPI/examples/20_integrated_bldc_joint_actuator/bearings.py +++ b/SimpleCADAPI/examples/20_integrated_bldc_joint_actuator/bearings.py @@ -14,6 +14,7 @@ else: from gears import planet_center_xy +@scad.requires_session def make_standard_planet_bearing_rassembly( *, bearing_id: str, @@ -34,6 +35,7 @@ def make_standard_planet_bearing_rassembly( return bearing +@scad.requires_session def make_main_bearing_rassembly( *, bearing_id: str, @@ -48,11 +50,16 @@ def make_main_bearing_rassembly( pitch_radius = (bore_radius + outer_radius) / 2.0 inner_outer_radius = pitch_radius - ball_radius * 0.55 outer_inner_radius = pitch_radius + ball_radius * 0.55 + bearing_tag_prefix = ".".join( + f"size{token}" if token[0].isdigit() else token + for token in bearing_id.split("_") + ) inner_ring = make_annulus_rsolid( outer_radius=inner_outer_radius, inner_radius=bore_radius, bottom_z=-spec.width / 2.0, height=spec.width, + tag_prefix=f"bearing.{bearing_tag_prefix}.inner.ring", tags=("role.bearing_inner_ring",), ) outer_ring = make_annulus_rsolid( @@ -60,12 +67,17 @@ def make_main_bearing_rassembly( inner_radius=outer_inner_radius, bottom_z=-spec.width / 2.0, height=spec.width, + tag_prefix=f"bearing.{bearing_tag_prefix}.outer.ring", tags=("role.bearing_outer_ring",), ) ball = scad.make_sphere_rsolid( radius=ball_radius, center=(pitch_radius, 0.0, 0.0), ) + ball = scad.apply_tag( + shape=ball, + tag=f"solid.bearing.{bearing_tag_prefix}.rolling.element", + ) inner_part = make_axis_part_rpart( part_id=f"{bearing_id}_inner_ring", body=inner_ring, @@ -148,12 +160,14 @@ def make_main_bearing_rassembly( return bearing +@scad.requires_session def make_coaxial_bearing_rplacement(*, center_z: float) -> scad.Placement: """Place a standard bearing center plane on the actuator Z axis.""" return make_z_rotation_rplacement(origin=(0.0, 0.0, center_z), angle_degrees=0.0) +@scad.requires_session def make_planet_bearing_rplacement( *, stage: StageSpec, diff --git a/SimpleCADAPI/examples/20_integrated_bldc_joint_actuator/common.py b/SimpleCADAPI/examples/20_integrated_bldc_joint_actuator/common.py index c3c6ee7..9f86ec3 100644 --- a/SimpleCADAPI/examples/20_integrated_bldc_joint_actuator/common.py +++ b/SimpleCADAPI/examples/20_integrated_bldc_joint_actuator/common.py @@ -9,6 +9,7 @@ import simplecadapi as scad from simplecadapi import ql +@scad.requires_session def apply_tags(*, shape: scad.Solid, tags: Iterable[str]) -> scad.Solid: """Apply semantic tags through the public functional API.""" @@ -18,12 +19,14 @@ def apply_tags(*, shape: scad.Solid, tags: Iterable[str]) -> scad.Solid: return tagged +@scad.requires_session def make_annulus_rsolid( *, outer_radius: float, inner_radius: float, bottom_z: float, height: float, + tag_prefix: str, tags: Iterable[str], ) -> scad.Solid: """Create a strict single-solid annular cylinder.""" @@ -33,17 +36,22 @@ def make_annulus_rsolid( height=height, bottom_face_center=(0.0, 0.0, bottom_z), axis=(0.0, 0.0, 1.0), + tag_prefix=f"{tag_prefix}.outer", + result_tag=f"feature.{tag_prefix}.outer", ) bore = scad.make_cylinder_rsolid( radius=inner_radius, height=height + 2.0, bottom_face_center=(0.0, 0.0, bottom_z - 1.0), axis=(0.0, 0.0, 1.0), + tag_prefix=f"{tag_prefix}.bore", + result_tag=f"tool.{tag_prefix}.bore", ) annulus = scad.cut_rsolid(outer, bore, skip_non_intersecting=False) return apply_tags(shape=annulus, tags=tags) +@scad.requires_session def make_axis_part_rpart( *, part_id: str, @@ -70,6 +78,7 @@ def make_axis_part_rpart( return part +@scad.requires_session def make_z_rotation_rplacement( *, origin: tuple[float, float, float], @@ -94,6 +103,7 @@ def radial_centers(*, count: int, radius: float, angle_offset: float = 0.0): yield index, angle_degrees, (radius * math.cos(angle), radius * math.sin(angle)) +@scad.requires_session def make_axial_hole_cutters_rsolids( *, count: int, @@ -101,12 +111,13 @@ def make_axial_hole_cutters_rsolids( hole_radius: float, bottom_z: float, height: float, + tag_prefix: str, angle_offset: float = 0.0, ) -> list[scad.Solid]: """Create equally spaced axial hole cutters.""" cutters = [] - for _index, _angle, center in radial_centers( + for index, _angle, center in radial_centers( count=count, radius=pcd / 2.0, angle_offset=angle_offset, @@ -117,6 +128,8 @@ def make_axial_hole_cutters_rsolids( height=height, bottom_face_center=(center[0], center[1], bottom_z), axis=(0.0, 0.0, 1.0), + tag_prefix=f"{tag_prefix}.hole{index + 1}", + result_tag=f"tool.{tag_prefix}.hole{index + 1}", ) ) return cutters @@ -126,9 +139,13 @@ def ground_solid(*, label: str, solid: scad.Solid) -> None: """Print a concise QL-backed solid summary.""" faces = ql.select(items=solid.get_faces()).all() - role_faces = ql.select(items=faces).where(ql.tag(pattern="role.*")).all() + local_roles = [ + tag + for tag in scad.list_tags(shape=solid, scope="local") + if tag.startswith("role.") + ] print( - f"{label}: faces={len(faces)} role_faces={len(role_faces)} " + f"{label}: faces={len(faces)} local_roles={len(local_roles)} " f"volume={solid.get_volume():.3f} tags={','.join(scad.list_tags(shape=solid))}" ) @@ -142,6 +159,7 @@ def ground_compound(*, label: str, compound: scad.Compound) -> None: print(f"{label}: solids={len(solids)} faces={faces} volume={volume:.3f}") +@scad.requires_session def connector_ref(*, component_id: str, connector_id: str) -> scad.ConnectorRef: """Create a component-scoped connector reference.""" diff --git a/SimpleCADAPI/examples/20_integrated_bldc_joint_actuator/electronics.py b/SimpleCADAPI/examples/20_integrated_bldc_joint_actuator/electronics.py index b027b0c..0f0774c 100644 --- a/SimpleCADAPI/examples/20_integrated_bldc_joint_actuator/electronics.py +++ b/SimpleCADAPI/examples/20_integrated_bldc_joint_actuator/electronics.py @@ -49,6 +49,7 @@ POWER_CAN_TERMINAL_CENTER = (11.0, 0.0) MOSFET_ANGLES = (22.5, 67.5, 112.5, 202.5, 247.5, 292.5) +@scad.requires_session def make_integrated_controller_rassembly( *, pcb_material: scad.Material, @@ -139,12 +140,15 @@ def make_integrated_controller_rassembly( return controller +@scad.requires_session def _make_controller_pcb_rpart(*, material: scad.Material) -> scad.Part: board = scad.make_cylinder_rsolid( radius=PCB_RADIUS, height=PCB_THICKNESS, bottom_face_center=(0.0, 0.0, PCB_BOTTOM_Z), axis=(0.0, 0.0, 1.0), + tag_prefix="controller.pcb.board", + result_tag="feature.controller.pcb.board", ) cutters: list[scad.Solid] = [ scad.make_cylinder_rsolid( @@ -152,6 +156,8 @@ def _make_controller_pcb_rpart(*, material: scad.Material) -> scad.Part: height=PCB_THICKNESS + 2.0, bottom_face_center=(0.0, 0.0, PCB_BOTTOM_Z - 1.0), axis=(0.0, 0.0, 1.0), + tag_prefix="controller.pcb.center.bore", + result_tag="tool.controller.pcb.center.bore", ) ] cutters.extend( @@ -161,6 +167,7 @@ def _make_controller_pcb_rpart(*, material: scad.Material) -> scad.Part: hole_radius=PCB_MOUNT_HOLE_RADIUS, bottom_z=PCB_BOTTOM_Z - 1.0, height=PCB_THICKNESS + 2.0, + tag_prefix="controller.pcb.mount.hole", angle_offset=45.0, ) ) @@ -171,9 +178,13 @@ def _make_controller_pcb_rpart(*, material: scad.Material) -> scad.Part: hole_radius=3.45, bottom_z=PCB_BOTTOM_Z - 1.0, height=PCB_THICKNESS + 2.0, + tag_prefix="controller.pcb.rear.column.clearance", ) ) - for x, count in ((PHASE_TERMINAL_CENTER[0], 3), (POWER_CAN_TERMINAL_CENTER[0], 4)): + for terminal_id, x, count in ( + ("phase", PHASE_TERMINAL_CENTER[0], 3), + ("power.can", POWER_CAN_TERMINAL_CENTER[0], 4), + ): for pin in range(count): y = (pin - (count - 1) / 2.0) * 1.8 cutters.append( @@ -182,6 +193,8 @@ def _make_controller_pcb_rpart(*, material: scad.Material) -> scad.Part: height=PCB_THICKNESS + 2.0, bottom_face_center=(x, y, PCB_BOTTOM_Z - 1.0), axis=(0.0, 0.0, 1.0), + tag_prefix=f"controller.pcb.terminal.{terminal_id}.pin{pin + 1}", + result_tag=f"tool.controller.pcb.terminal.{terminal_id}.pin{pin + 1}", ) ) board = scad.cut_rsolid(board, cutters, skip_non_intersecting=False) @@ -216,12 +229,15 @@ def _make_controller_pcb_rpart(*, material: scad.Material) -> scad.Part: ) +@scad.requires_session def _make_mosfet_package_rpart(*, material: scad.Material) -> scad.Part: package = scad.make_box_rsolid( width=4.0, height=3.0, depth=1.4, bottom_face_center=(0.0, 0.0, 0.0), + tag_prefix="controller.mosfet.package", + result_tag="feature.controller.mosfet.package", ) package = apply_tags(shape=package, tags=("role.power_mosfet", "group.three_phase_bridge")) return make_axis_part_rpart( @@ -233,6 +249,7 @@ def _make_mosfet_package_rpart(*, material: scad.Material) -> scad.Part: ) +@scad.requires_session def _make_terminal_block_rpart( *, part_id: str, @@ -241,11 +258,14 @@ def _make_terminal_block_rpart( pin_count: int, material: scad.Material, ) -> scad.Part: + terminal_tag_prefix = part_id.replace("_", ".") body = scad.make_box_rsolid( width=width, height=6.0, depth=4.9, bottom_face_center=(0.0, 0.0, 0.0), + tag_prefix=f"controller.terminal.{terminal_tag_prefix}.body", + result_tag=f"feature.controller.terminal.{terminal_tag_prefix}.body", ) access_cutters = [] for pin in range(pin_count): @@ -256,6 +276,8 @@ def _make_terminal_block_rpart( height=width + 2.0, bottom_face_center=(-width / 2.0 - 1.0, y, 2.45), axis=(1.0, 0.0, 0.0), + tag_prefix=f"controller.terminal.{terminal_tag_prefix}.access{pin + 1}", + result_tag=f"tool.controller.terminal.{terminal_tag_prefix}.access{pin + 1}", ) ) body = scad.cut_rsolid(body, access_cutters, skip_non_intersecting=False) diff --git a/SimpleCADAPI/examples/20_integrated_bldc_joint_actuator/gears.py b/SimpleCADAPI/examples/20_integrated_bldc_joint_actuator/gears.py index 36136b7..b7c9aea 100644 --- a/SimpleCADAPI/examples/20_integrated_bldc_joint_actuator/gears.py +++ b/SimpleCADAPI/examples/20_integrated_bldc_joint_actuator/gears.py @@ -110,6 +110,7 @@ except ImportError: # Support direct execution from this example directory. ) +@scad.requires_session def make_stage_ring_gear_rpart( *, stage: StageSpec, @@ -128,17 +129,25 @@ def make_stage_ring_gear_rpart( addendum_factor=ADDENDUM_FACTOR, clearance_factor=CLEARANCE_FACTOR, ) + ring = scad.apply_tag( + shape=ring, + tag=f"solid.stdlib.{stage.stage_id}.fixed.herringbone.ring.gear", + ) support = scad.make_cylinder_rsolid( radius=RING_INSERT_OUTER_RADIUS, height=GEAR_HEIGHT, bottom_face_center=(0.0, 0.0, 0.0), axis=(0.0, 0.0, 1.0), + tag_prefix=f"reducer.{stage.stage_id}.ring.support", + result_tag=f"feature.reducer.{stage.stage_id}.ring.support", ) support_bore = scad.make_cylinder_rsolid( radius=stage.ring_outer_radius - RING_SUPPORT_OVERLAP, height=GEAR_HEIGHT + 2.0, bottom_face_center=(0.0, 0.0, -1.0), axis=(0.0, 0.0, 1.0), + tag_prefix=f"reducer.{stage.stage_id}.ring.support.bore", + result_tag=f"tool.reducer.{stage.stage_id}.ring.support.bore", ) support = scad.cut_rsolid(support, support_bore, skip_non_intersecting=False) ring = scad.union_rsolid(ring, support, glue=False) @@ -159,6 +168,7 @@ def make_stage_ring_gear_rpart( ) +@scad.requires_session def make_stage_planet_gear_rpart( *, stage: StageSpec, @@ -176,12 +186,18 @@ def make_stage_planet_gear_rpart( clearance_factor=CLEARANCE_FACTOR, backlash=BACKLASH, ) + planet = scad.apply_tag( + shape=planet, + tag=f"solid.stdlib.{stage.stage_id}.reusable.herringbone.planet.gear", + ) bearing_seat_radius = PLANET_BEARING.outer_diameter / 2.0 + 0.05 bearing_seat = scad.make_cylinder_rsolid( radius=bearing_seat_radius, height=GEAR_HEIGHT + 2.0, bottom_face_center=(0.0, 0.0, -1.0), axis=(0.0, 0.0, 1.0), + tag_prefix=f"reducer.{stage.stage_id}.planet.bearing.seat", + result_tag=f"tool.reducer.{stage.stage_id}.planet.bearing.seat", ) planet = scad.cut_rsolid(planet, bearing_seat, skip_non_intersecting=False) planet = apply_tags( @@ -205,6 +221,7 @@ def make_stage_planet_gear_rpart( ) +@scad.requires_session def make_stage1_carrier_sun_rpart(*, material: scad.Material) -> scad.Part: """Create the first carrier and integral second-stage sun/shaft.""" @@ -223,6 +240,8 @@ def make_stage1_carrier_sun_rpart(*, material: scad.Material) -> scad.Part: height=STAGE_2.top_z - STAGE1_CARRIER_BOTTOM_Z + 0.1, bottom_face_center=(0.0, 0.0, STAGE1_CARRIER_BOTTOM_Z - 0.05), axis=(0.0, 0.0, 1.0), + tag_prefix="reducer.stage1.carrier.interstage.shaft", + result_tag="feature.reducer.stage1.carrier.interstage.shaft", ) stage2_sun = scad.std.gear.make_herringbone_gear_rsolid( n_teeth=STAGE_2.sun_teeth, @@ -234,6 +253,10 @@ def make_stage1_carrier_sun_rpart(*, material: scad.Material) -> scad.Part: clearance_factor=CLEARANCE_FACTOR, backlash=BACKLASH, ) + stage2_sun = scad.apply_tag( + shape=stage2_sun, + tag="solid.stdlib.stage2.integral.herringbone.sun.gear", + ) stage2_sun = scad.translate_shape(shape=stage2_sun, vector=(0.0, 0.0, STAGE_2.bottom_z)) carrier = scad.union_rsolid(carrier, shaft, stage2_sun, glue=False) carrier = apply_tags( @@ -270,6 +293,7 @@ def make_stage1_carrier_sun_rpart(*, material: scad.Material) -> scad.Part: ) +@scad.requires_session def make_output_carrier_flange_rpart( *, stage: StageSpec, @@ -297,12 +321,16 @@ def make_output_carrier_flange_rpart( ), bottom_face_center=(0.0, 0.0, STAGE2_CARRIER_BOTTOM_Z - 0.05), axis=(0.0, 0.0, 1.0), + tag_prefix="reducer.stage2.output.shaft", + result_tag="feature.reducer.stage2.output.shaft", ) flange = scad.make_cylinder_rsolid( radius=OUTPUT_FLANGE_RADIUS, height=OUTPUT_FLANGE_TOP_Z - OUTPUT_FLANGE_BOTTOM_Z, bottom_face_center=(0.0, 0.0, OUTPUT_FLANGE_BOTTOM_Z), axis=(0.0, 0.0, 1.0), + tag_prefix="reducer.stage2.output.flange", + result_tag="feature.reducer.stage2.output.flange", ) output = scad.union_rsolid(carrier, shaft, flange, glue=False) output = scad.cut_rsolid( @@ -313,6 +341,7 @@ def make_output_carrier_flange_rpart( hole_radius=OUTPUT_LINK_TAP_RADIUS, bottom_z=OUTPUT_FLANGE_TOP_Z - OUTPUT_LINK_THREAD_DEPTH, height=OUTPUT_LINK_THREAD_DEPTH + 1.0, + tag_prefix="reducer.stage2.output.flange.thread", angle_offset=OUTPUT_LINK_BOLT_ANGLES_DEGREES[0], ), skip_non_intersecting=False, @@ -351,6 +380,7 @@ def make_output_carrier_flange_rpart( ) +@scad.requires_session def make_planet_rplacement(*, stage: StageSpec, index: int) -> scad.Placement: """Place and visually phase one planet at its pitch center.""" @@ -377,6 +407,7 @@ def planet_center_xy(*, stage: StageSpec, index: int) -> tuple[float, float]: ) +@scad.requires_session def _make_carrier_body_rsolid( *, stage: StageSpec, @@ -393,6 +424,8 @@ def _make_carrier_body_rsolid( height=plate_thickness, bottom_face_center=(0.0, 0.0, plate_bottom_z), axis=(0.0, 0.0, 1.0), + tag_prefix=f"reducer.{stage.stage_id}.carrier.hub", + result_tag=f"feature.reducer.{stage.stage_id}.carrier.hub", ) solids = [hub] pin_height = plate_bottom_z + plate_thickness - pin_bottom_z @@ -408,6 +441,8 @@ def _make_carrier_body_rsolid( height=arm_width, depth=plate_thickness, bottom_face_center=(arm_center_radius, 0.0, plate_bottom_z), + tag_prefix=f"reducer.{stage.stage_id}.carrier.arm{index + 1}", + result_tag=f"feature.reducer.{stage.stage_id}.carrier.arm{index + 1}", ) solids.append( scad.rotate_shape( @@ -423,6 +458,8 @@ def _make_carrier_body_rsolid( height=plate_thickness, bottom_face_center=(center[0], center[1], plate_bottom_z), axis=(0.0, 0.0, 1.0), + tag_prefix=f"reducer.{stage.stage_id}.carrier.pad{index + 1}", + result_tag=f"feature.reducer.{stage.stage_id}.carrier.pad{index + 1}", ) ) solids.append( @@ -431,6 +468,8 @@ def _make_carrier_body_rsolid( height=pin_height, bottom_face_center=(center[0], center[1], pin_bottom_z), axis=(0.0, 0.0, 1.0), + tag_prefix=f"reducer.{stage.stage_id}.carrier.pin{index + 1}", + result_tag=f"feature.reducer.{stage.stage_id}.carrier.pin{index + 1}", ) ) carrier = scad.union_rsolid(solids, glue=False) diff --git a/SimpleCADAPI/examples/20_integrated_bldc_joint_actuator/housing.py b/SimpleCADAPI/examples/20_integrated_bldc_joint_actuator/housing.py index df01f98..c00e17c 100644 --- a/SimpleCADAPI/examples/20_integrated_bldc_joint_actuator/housing.py +++ b/SimpleCADAPI/examples/20_integrated_bldc_joint_actuator/housing.py @@ -102,6 +102,7 @@ except ImportError: # Support direct execution from this example directory. ) +@scad.requires_session def make_motor_shell_rpart(*, material: scad.Material) -> scad.Part: """Create the stator sleeve, front attachment land, and rear columns.""" @@ -110,6 +111,7 @@ def make_motor_shell_rpart(*, material: scad.Material) -> scad.Part: inner_radius=MOTOR_SHELL_INNER_RADIUS, bottom_z=MOTOR_SHELL_BOTTOM_Z, height=MOTOR_SHELL_TOP_Z - MOTOR_SHELL_BOTTOM_Z, + tag_prefix="housing.motor.shell.sleeve", tags=("role.motor_shell", "role.stator_thermal_path"), ) front_land = make_annulus_rsolid( @@ -117,16 +119,19 @@ def make_motor_shell_rpart(*, material: scad.Material) -> scad.Part: inner_radius=HOUSING_INTERFACE_LAND_INNER_RADIUS, bottom_z=MOTOR_SHELL_TOP_Z - 1.4, height=1.4, + tag_prefix="housing.motor.shell.front.land", tags=("role.motor_reducer_mount",), ) columns = [] - for _index, _angle, center in radial_centers(count=4, radius=REAR_COLUMN_PCD / 2.0): + for index, _angle, center in radial_centers(count=4, radius=REAR_COLUMN_PCD / 2.0): columns.append( scad.make_cylinder_rsolid( radius=REAR_COLUMN_RADIUS, height=REAR_SPIDER_BOTTOM_Z - MOTOR_SHELL_BOTTOM_Z, bottom_face_center=(center[0], center[1], MOTOR_SHELL_BOTTOM_Z), axis=(0.0, 0.0, 1.0), + tag_prefix=f"housing.motor.shell.rear.column{index + 1}", + result_tag=f"feature.housing.motor.shell.rear.column{index + 1}", ) ) shell = scad.union_rsolid(sleeve, front_land, columns, glue=False) @@ -138,6 +143,7 @@ def make_motor_shell_rpart(*, material: scad.Material) -> scad.Part: hole_radius=M3_CLEARANCE_RADIUS, bottom_z=MOTOR_SHELL_TOP_Z - 2.8, height=3.6, + tag_prefix="housing.motor.shell.interface.clearance", angle_offset=30.0, ), make_axial_hole_cutters_rsolids( @@ -146,6 +152,7 @@ def make_motor_shell_rpart(*, material: scad.Material) -> scad.Part: hole_radius=REAR_FASTENER_HOLE_RADIUS, bottom_z=MOTOR_SHELL_BOTTOM_Z - 1.0, height=10.4, + tag_prefix="housing.motor.shell.rear.fastener.clearance", ), skip_non_intersecting=False, ) @@ -175,6 +182,7 @@ def make_motor_shell_rpart(*, material: scad.Material) -> scad.Part: ) +@scad.requires_session def make_reducer_housing_rpart(*, material: scad.Material) -> scad.Part: """Create the reducer sleeve and front motor-bearing bulkhead.""" @@ -183,6 +191,7 @@ def make_reducer_housing_rpart(*, material: scad.Material) -> scad.Part: inner_radius=REDUCER_HOUSING_INNER_RADIUS, bottom_z=REDUCER_HOUSING_BOTTOM_Z, height=REDUCER_HOUSING_FRONT_Z - REDUCER_HOUSING_BOTTOM_Z, + tag_prefix="housing.reducer.sleeve", tags=("role.reducer_housing_sleeve",), ) bulkhead = make_annulus_rsolid( @@ -190,6 +199,7 @@ def make_reducer_housing_rpart(*, material: scad.Material) -> scad.Part: inner_radius=FRONT_MOTOR_BEARING.outer_diameter / 2.0 + 0.05, bottom_z=REDUCER_HOUSING_BOTTOM_Z, height=STAGE_1.bottom_z - REDUCER_HOUSING_BOTTOM_Z, + tag_prefix="housing.reducer.front.bulkhead", tags=("role.motor_front_bearing_bulkhead",), ) interstage_divider = make_annulus_rsolid( @@ -197,6 +207,7 @@ def make_reducer_housing_rpart(*, material: scad.Material) -> scad.Part: inner_radius=INTERSTAGE_BEARING.outer_diameter / 2.0 + 0.05, bottom_z=INTERSTAGE_BEARING_CENTER_Z - INTERSTAGE_BEARING.width / 2.0, height=INTERSTAGE_BEARING.width, + tag_prefix="housing.reducer.interstage.divider", tags=("role.interstage_bearing_divider",), ) output_mount_land = make_annulus_rsolid( @@ -204,6 +215,7 @@ def make_reducer_housing_rpart(*, material: scad.Material) -> scad.Part: inner_radius=HOUSING_INTERFACE_LAND_INNER_RADIUS, bottom_z=REDUCER_HOUSING_FRONT_Z - 2.2, height=2.2, + tag_prefix="housing.reducer.output.mount.land", tags=("role.output_cap_mount_land",), ) housing = scad.union_rsolid( @@ -221,6 +233,7 @@ def make_reducer_housing_rpart(*, material: scad.Material) -> scad.Part: hole_radius=M3_CLEARANCE_RADIUS, bottom_z=REDUCER_HOUSING_BOTTOM_Z - 1.0, height=STAGE_1.bottom_z - REDUCER_HOUSING_BOTTOM_Z + 2.0, + tag_prefix="housing.reducer.motor.interface.clearance", angle_offset=30.0, ), make_axial_hole_cutters_rsolids( @@ -229,6 +242,7 @@ def make_reducer_housing_rpart(*, material: scad.Material) -> scad.Part: hole_radius=M3_CLEARANCE_RADIUS, bottom_z=REDUCER_HOUSING_FRONT_Z - 2.2, height=3.2, + tag_prefix="housing.reducer.output.interface.clearance", ), skip_non_intersecting=False, ) @@ -265,6 +279,7 @@ def make_reducer_housing_rpart(*, material: scad.Material) -> scad.Part: ) +@scad.requires_session def make_rear_bearing_spider_rpart(*, material: scad.Material) -> scad.Part: """Create a four-arm removable rear motor-bearing support.""" @@ -274,15 +289,18 @@ def make_rear_bearing_spider_rpart(*, material: scad.Material) -> scad.Part: inner_radius=8.05, bottom_z=bottom_z, height=5.0, + tag_prefix="housing.rear.spider.bearing.hub", tags=("role.rear_motor_bearing_seat",), ) solids = [hub] - for _index, angle, center in radial_centers(count=4, radius=REAR_COLUMN_PCD / 2.0): + for index, angle, center in radial_centers(count=4, radius=REAR_COLUMN_PCD / 2.0): arm = scad.make_box_rsolid( width=12.0, height=3.0, depth=5.0, bottom_face_center=(14.5, 0.0, bottom_z), + tag_prefix=f"housing.rear.spider.arm{index + 1}", + result_tag=f"feature.housing.rear.spider.arm{index + 1}", ) solids.append( scad.rotate_shape( @@ -298,6 +316,8 @@ def make_rear_bearing_spider_rpart(*, material: scad.Material) -> scad.Part: height=5.0, bottom_face_center=(center[0], center[1], bottom_z), axis=(0.0, 0.0, 1.0), + tag_prefix=f"housing.rear.spider.boss{index + 1}", + result_tag=f"feature.housing.rear.spider.boss{index + 1}", ) ) spider = scad.union_rsolid(solids, glue=False) @@ -309,6 +329,7 @@ def make_rear_bearing_spider_rpart(*, material: scad.Material) -> scad.Part: hole_radius=REAR_FASTENER_HOLE_RADIUS, bottom_z=bottom_z - 1.0, height=7.0, + tag_prefix="housing.rear.spider.fastener.clearance", ), skip_non_intersecting=False, ) @@ -329,6 +350,7 @@ def make_rear_bearing_spider_rpart(*, material: scad.Material) -> scad.Part: ) +@scad.requires_session def make_rear_electronics_cover_rpart(*, material: scad.Material) -> scad.Part: """Create the rear cover with PCB standoffs and terminal apertures.""" @@ -337,15 +359,23 @@ def make_rear_electronics_cover_rpart(*, material: scad.Material) -> scad.Part: height=REAR_COVER_THICKNESS, bottom_face_center=(0.0, 0.0, REAR_COVER_BOTTOM_Z), axis=(0.0, 0.0, 1.0), + tag_prefix="housing.rear.cover.plate", + result_tag="feature.housing.rear.cover.plate", ) standoffs = [] - for _index, _angle, center in radial_centers(count=4, radius=PCB_STANDOFF_PCD / 2.0, angle_offset=45.0): + for index, _angle, center in radial_centers( + count=4, + radius=PCB_STANDOFF_PCD / 2.0, + angle_offset=45.0, + ): standoffs.append( scad.make_cylinder_rsolid( radius=2.4, height=PCB_BOTTOM_Z - REAR_COVER_BOTTOM_Z - REAR_COVER_THICKNESS + 0.1, bottom_face_center=(center[0], center[1], REAR_COVER_BOTTOM_Z + REAR_COVER_THICKNESS - 0.1), axis=(0.0, 0.0, 1.0), + tag_prefix=f"housing.rear.cover.pcb.standoff{index + 1}", + result_tag=f"feature.housing.rear.cover.pcb.standoff{index + 1}", ) ) cover = scad.union_rsolid(cover, standoffs, glue=False) @@ -354,18 +384,24 @@ def make_rear_electronics_cover_rpart(*, material: scad.Material) -> scad.Part: height=7.2, depth=REAR_COVER_THICKNESS + 2.0, bottom_face_center=(-11.0, 0.0, REAR_COVER_BOTTOM_Z - 1.0), + tag_prefix="housing.rear.cover.phase.aperture", + result_tag="tool.housing.rear.cover.phase.aperture", ) power_aperture = scad.make_box_rsolid( width=9.2, height=7.2, depth=REAR_COVER_THICKNESS + 2.0, bottom_face_center=(11.0, 0.0, REAR_COVER_BOTTOM_Z - 1.0), + tag_prefix="housing.rear.cover.power.can.aperture", + result_tag="tool.housing.rear.cover.power.can.aperture", ) center_service = scad.make_cylinder_rsolid( radius=3.2, height=REAR_COVER_THICKNESS + 2.0, bottom_face_center=(0.0, 0.0, REAR_COVER_BOTTOM_Z - 1.0), axis=(0.0, 0.0, 1.0), + tag_prefix="housing.rear.cover.center.service", + result_tag="tool.housing.rear.cover.center.service", ) cover = scad.cut_rsolid( cover, @@ -378,6 +414,7 @@ def make_rear_electronics_cover_rpart(*, material: scad.Material) -> scad.Part: hole_radius=REAR_FASTENER_HOLE_RADIUS, bottom_z=REAR_COVER_BOTTOM_Z - 1.0, height=REAR_COVER_THICKNESS + 2.0, + tag_prefix="housing.rear.cover.column.fastener.clearance", ), make_axial_hole_cutters_rsolids( count=4, @@ -385,6 +422,7 @@ def make_rear_electronics_cover_rpart(*, material: scad.Material) -> scad.Part: hole_radius=1.1, bottom_z=REAR_COVER_BOTTOM_Z - 1.0, height=PCB_BOTTOM_Z - REAR_COVER_BOTTOM_Z + 2.0, + tag_prefix="housing.rear.cover.pcb.fastener.clearance", angle_offset=45.0, ), skip_non_intersecting=False, @@ -408,6 +446,7 @@ def make_rear_electronics_cover_rpart(*, material: scad.Material) -> scad.Part: ) +@scad.requires_session def make_output_bearing_cap_rpart(*, material: scad.Material) -> scad.Part: """Create the removable paired-bearing cartridge and front cap.""" @@ -417,6 +456,7 @@ def make_output_bearing_cap_rpart(*, material: scad.Material) -> scad.Part: inner_radius=bearing_clearance_radius, bottom_z=OUTPUT_CAP_BOTTOM_Z, height=3.0, + tag_prefix="housing.output.cap.rear.flange", tags=("role.output_cap_mount_flange",), ) cartridge = make_annulus_rsolid( @@ -424,6 +464,7 @@ def make_output_bearing_cap_rpart(*, material: scad.Material) -> scad.Part: inner_radius=bearing_clearance_radius, bottom_z=OUTPUT_CAP_BOTTOM_Z, height=OUTPUT_CAP_CARTRIDGE_TOP_Z - OUTPUT_CAP_BOTTOM_Z + 0.1, + tag_prefix="housing.output.cap.bearing.cartridge", tags=("role.paired_output_bearing_seat",), ) bearing_retainer = make_annulus_rsolid( @@ -431,6 +472,7 @@ def make_output_bearing_cap_rpart(*, material: scad.Material) -> scad.Part: inner_radius=8.10, bottom_z=OUTPUT_CAP_CARTRIDGE_TOP_Z - 0.1, height=0.5, + tag_prefix="housing.output.cap.bearing.retainer", tags=("role.output_axial_retainer",), ) outer_lip = make_annulus_rsolid( @@ -438,6 +480,7 @@ def make_output_bearing_cap_rpart(*, material: scad.Material) -> scad.Part: inner_radius=OUTPUT_FLANGE_RADIUS + 0.30, bottom_z=OUTPUT_CAP_CARTRIDGE_TOP_Z - 0.1, height=OUTPUT_CAP_TOP_Z - OUTPUT_CAP_CARTRIDGE_TOP_Z + 0.1, + tag_prefix="housing.output.cap.labyrinth.lip", tags=("role.output_labyrinth_lip",), ) cap = scad.union_rsolid(rear_flange, cartridge, bearing_retainer, outer_lip, glue=False) @@ -449,6 +492,7 @@ def make_output_bearing_cap_rpart(*, material: scad.Material) -> scad.Part: hole_radius=M3_CLEARANCE_RADIUS, bottom_z=OUTPUT_CAP_BOTTOM_Z - 1.0, height=OUTPUT_CAP_TOP_Z - OUTPUT_CAP_BOTTOM_Z + 2.0, + tag_prefix="housing.output.cap.interface.clearance", ), skip_non_intersecting=False, ) diff --git a/SimpleCADAPI/examples/20_integrated_bldc_joint_actuator/main.py b/SimpleCADAPI/examples/20_integrated_bldc_joint_actuator/main.py index 95821c2..975626a 100644 --- a/SimpleCADAPI/examples/20_integrated_bldc_joint_actuator/main.py +++ b/SimpleCADAPI/examples/20_integrated_bldc_joint_actuator/main.py @@ -1,4 +1,4 @@ -"""Build, validate, replay, and export the integrated BLDC joint actuator.""" +"""Build and export the integrated BLDC joint actuator.""" from __future__ import annotations @@ -8,19 +8,34 @@ from pathlib import Path import simplecadapi as scad -from assembly import make_integrated_bldc_joint_actuator_rassembly -from common import ground_compound -from dimensions import ( - MOTOR_AIR_GAP, - MOTOR_POLE_COUNT, - MOTOR_SLOT_COUNT, - PACKAGE_RADIUS, - PACKAGE_STRUCTURAL_BOTTOM_Z, - PACKAGE_TOP_Z, - TOTAL_REDUCTION, - validate_design_dimensions, -) -from materials import make_actuator_materials_rdict +try: + from .assembly import make_integrated_bldc_joint_actuator_rassembly + from .common import ground_compound + from .dimensions import ( + MOTOR_AIR_GAP, + MOTOR_POLE_COUNT, + MOTOR_SLOT_COUNT, + PACKAGE_RADIUS, + PACKAGE_STRUCTURAL_BOTTOM_Z, + PACKAGE_TOP_Z, + TOTAL_REDUCTION, + validate_design_dimensions, + ) + from .materials import make_actuator_materials_rdict +except ImportError: # Support direct execution from this example directory. + from assembly import make_integrated_bldc_joint_actuator_rassembly + from common import ground_compound + from dimensions import ( + MOTOR_AIR_GAP, + MOTOR_POLE_COUNT, + MOTOR_SLOT_COUNT, + PACKAGE_RADIUS, + PACKAGE_STRUCTURAL_BOTTOM_Z, + PACKAGE_TOP_Z, + TOTAL_REDUCTION, + validate_design_dimensions, + ) + from materials import make_actuator_materials_rdict sys.setrecursionlimit(30000) @@ -28,18 +43,21 @@ sys.setrecursionlimit(30000) OUT_DIR = Path("examples/out/integrated_bldc_joint_actuator") +@scad.model(graph_id="integrated_50mm_bldc_joint_actuator") def build_integrated_bldc_joint_actuator(): """Build the replayable actuator and return product and interchange outputs.""" validate_design_dimensions() materials = make_actuator_materials_rdict() - with scad.GraphSession(graph_id="integrated_50mm_bldc_joint_actuator") as session: - assembly = make_integrated_bldc_joint_actuator_rassembly(materials=materials) - preview = scad.make_compound_from_assembly_rcompound(assembly=assembly) - ground_compound(label="integrated_actuator_preview", compound=preview) - session_json = scad.export_session_json(session=session, indent=2) - model_json = scad.export_model_json(session=session, indent=2) - return assembly, preview, model_json, session_json + assembly = make_integrated_bldc_joint_actuator_rassembly(materials=materials) + preview = scad.make_compound_from_assembly_rcompound(assembly=assembly) + preview = scad.apply_tag( + shape=preview, + tag="scene.integrated.bldc.joint.actuator.preview", + ) + ground_compound(label="integrated_actuator_preview", compound=preview) + scad.capture_result(value=(assembly, preview)) + return assembly, preview def main() -> None: @@ -53,19 +71,18 @@ def main() -> None: if fcstd_path.exists(): fcstd_path.unlink() - assembly, preview, model_json, session_json = build_integrated_bldc_joint_actuator() - model_path.write_text(model_json, encoding="utf-8") - session_path.write_text(session_json, encoding="utf-8") + result = build_integrated_bldc_joint_actuator() + assembly, preview = result.value + model_path.write_text(result.model_json, encoding="utf-8") + session_path.write_text(result.session_json, encoding="utf-8") scad.export_step(shapes=preview, filename=str(step_path)) - imported = scad.import_model_json(json_str=model_json) - replayed = scad.replay_model_json(json_str=model_json, strict=True) - payload = json.loads(model_json) + payload = json.loads(result.model_json) fcstd_status = "not attempted" try: scad.translator.freecad_translator.translate_model_json_to_fcstd( - json_str=model_json, + json_str=result.model_json, output_path=str(fcstd_path.resolve()), document_name="Integrated50mmBLDCJointActuator", freecad_cmd=None, @@ -84,9 +101,6 @@ def main() -> None: print(f"constraints={len(assembly.constraint_ids())}") print(f"preview_solids={len(preview.get_solids())}") print(f"preview_volume={preview.get_volume():.3f}") - print(f"imported_keys={','.join(sorted(imported.keys()))}") - print(f"replay_outputs={len(replayed)}") - print("replay_types=" + ",".join(type(item).__name__ for item in replayed)) print(f"graph_nodes={len(payload['graph']['nodes'])}") print(f"model={model_path}") print(f"session={session_path}") diff --git a/SimpleCADAPI/examples/20_integrated_bldc_joint_actuator/materials.py b/SimpleCADAPI/examples/20_integrated_bldc_joint_actuator/materials.py index 4f63393..0bce961 100644 --- a/SimpleCADAPI/examples/20_integrated_bldc_joint_actuator/materials.py +++ b/SimpleCADAPI/examples/20_integrated_bldc_joint_actuator/materials.py @@ -5,6 +5,7 @@ from __future__ import annotations import simplecadapi as scad +@scad.requires_session def make_actuator_materials_rdict() -> dict[str, scad.Material]: """Create the materials used by Case 20.""" diff --git a/SimpleCADAPI/examples/20_integrated_bldc_joint_actuator/motor.py b/SimpleCADAPI/examples/20_integrated_bldc_joint_actuator/motor.py index 5c2ef39..6a73045 100644 --- a/SimpleCADAPI/examples/20_integrated_bldc_joint_actuator/motor.py +++ b/SimpleCADAPI/examples/20_integrated_bldc_joint_actuator/motor.py @@ -76,6 +76,7 @@ except ImportError: # Support direct execution from this example directory. ) +@scad.requires_session def make_bldc_stator_rassembly( *, steel_material: scad.Material, @@ -130,6 +131,7 @@ def make_bldc_stator_rassembly( return stator +@scad.requires_session def make_bldc_rotor_rassembly( *, steel_material: scad.Material, @@ -190,23 +192,27 @@ def make_bldc_rotor_rassembly( return rotor +@scad.requires_session def _make_stator_core_rpart(*, material: scad.Material) -> scad.Part: yoke = make_annulus_rsolid( outer_radius=MOTOR_STATOR_OUTER_RADIUS, inner_radius=MOTOR_STATOR_YOKE_INNER_RADIUS, bottom_z=MOTOR_STATOR_BOTTOM_Z, height=MOTOR_STATOR_TOP_Z - MOTOR_STATOR_BOTTOM_Z, + tag_prefix="motor.stator.back.iron", tags=("role.stator_back_iron",), ) tooth_length = MOTOR_STATOR_YOKE_INNER_RADIUS - MOTOR_STATOR_TOOTH_INNER_RADIUS + 0.40 tooth_center_radius = MOTOR_STATOR_TOOTH_INNER_RADIUS + tooth_length / 2.0 teeth = [] - for _index, angle, _center in radial_centers(count=MOTOR_SLOT_COUNT, radius=0.0): + for index, angle, _center in radial_centers(count=MOTOR_SLOT_COUNT, radius=0.0): tooth = scad.make_box_rsolid( width=tooth_length, height=MOTOR_STATOR_TOOTH_WIDTH, depth=MOTOR_STATOR_TOP_Z - MOTOR_STATOR_BOTTOM_Z, bottom_face_center=(tooth_center_radius, 0.0, MOTOR_STATOR_BOTTOM_Z), + tag_prefix=f"motor.stator.tooth{index + 1}", + result_tag=f"feature.motor.stator.tooth{index + 1}", ) teeth.append( scad.rotate_shape( @@ -255,6 +261,7 @@ def _make_stator_core_rpart(*, material: scad.Material) -> scad.Part: return part +@scad.requires_session def _make_winding_pack_rpart(*, material: scad.Material) -> scad.Part: side_depth = MOTOR_STATOR_TOP_Z - MOTOR_STATOR_BOTTOM_Z + 0.4 side_bottom_z = MOTOR_STATOR_BOTTOM_Z - 0.2 @@ -263,24 +270,32 @@ def _make_winding_pack_rpart(*, material: scad.Material) -> scad.Part: height=1.2, depth=side_depth, bottom_face_center=(17.55, 2.1, side_bottom_z), + tag_prefix="motor.winding.side.positive", + result_tag="feature.motor.winding.side.positive", ) side_negative = scad.make_box_rsolid( width=4.2, height=1.2, depth=side_depth, bottom_face_center=(17.55, -2.1, side_bottom_z), + tag_prefix="motor.winding.side.negative", + result_tag="feature.motor.winding.side.negative", ) rear_end_turn = scad.make_box_rsolid( width=4.2, height=5.4, depth=0.9, bottom_face_center=(17.55, 0.0, MOTOR_STATOR_BOTTOM_Z - 1.0), + tag_prefix="motor.winding.rear.end.turn", + result_tag="feature.motor.winding.rear.end.turn", ) front_end_turn = scad.make_box_rsolid( width=4.2, height=5.4, depth=0.9, bottom_face_center=(17.55, 0.0, MOTOR_STATOR_TOP_Z + 0.1), + tag_prefix="motor.winding.front.end.turn", + result_tag="feature.motor.winding.front.end.turn", ) winding = scad.union_rsolid( side_positive, @@ -302,6 +317,7 @@ def _make_winding_pack_rpart(*, material: scad.Material) -> scad.Part: ) +@scad.requires_session def _make_rotor_shaft_sun_rpart(*, material: scad.Material) -> scad.Part: shaft_bottom_z = REAR_BEARING_CENTER_Z - 3.0 shaft = scad.make_cylinder_rsolid( @@ -309,12 +325,16 @@ def _make_rotor_shaft_sun_rpart(*, material: scad.Material) -> scad.Part: height=STAGE_1.top_z - shaft_bottom_z, bottom_face_center=(0.0, 0.0, shaft_bottom_z), axis=(0.0, 0.0, 1.0), + tag_prefix="motor.rotor.drive.shaft", + result_tag="feature.motor.rotor.drive.shaft", ) back_iron = scad.make_cylinder_rsolid( radius=MOTOR_ROTOR_BACKIRON_RADIUS, height=MOTOR_ROTOR_TOP_Z - MOTOR_ROTOR_BOTTOM_Z, bottom_face_center=(0.0, 0.0, MOTOR_ROTOR_BOTTOM_Z), axis=(0.0, 0.0, 1.0), + tag_prefix="motor.rotor.back.iron", + result_tag="feature.motor.rotor.back.iron", ) sun = scad.std.gear.make_herringbone_gear_rsolid( n_teeth=STAGE_1.sun_teeth, @@ -326,6 +346,10 @@ def _make_rotor_shaft_sun_rpart(*, material: scad.Material) -> scad.Part: clearance_factor=CLEARANCE_FACTOR, backlash=BACKLASH, ) + sun = scad.apply_tag( + shape=sun, + tag="solid.stdlib.stage1.integral.herringbone.sun.gear", + ) sun = scad.translate_shape(shape=sun, vector=(0.0, 0.0, STAGE_1.bottom_z)) rotor = scad.union_rsolid(shaft, back_iron, sun, glue=False) rotor = apply_tags( @@ -354,6 +378,7 @@ def _make_rotor_shaft_sun_rpart(*, material: scad.Material) -> scad.Part: return part +@scad.requires_session def _make_rotor_magnet_rpart(*, material: scad.Material) -> scad.Part: half_width = MOTOR_MAGNET_TANGENTIAL_WIDTH / 2.0 outer_x = (MOTOR_MAGNET_OUTER_RADIUS**2 - half_width**2) ** 0.5 @@ -363,6 +388,8 @@ def _make_rotor_magnet_rpart(*, material: scad.Material) -> scad.Part: height=MOTOR_MAGNET_TANGENTIAL_WIDTH, depth=MOTOR_ROTOR_TOP_Z - MOTOR_ROTOR_BOTTOM_Z, bottom_face_center=(outer_x - radial_depth / 2.0, 0.0, MOTOR_ROTOR_BOTTOM_Z), + tag_prefix="motor.rotor.reusable.magnet", + result_tag="feature.motor.rotor.reusable.magnet", ) magnet = apply_tags(shape=magnet, tags=("role.rotor_magnet", "group.rotor_magnets")) print( diff --git a/SimpleCADAPI/examples/README.md b/SimpleCADAPI/examples/README.md index e524696..3457b5a 100644 --- a/SimpleCADAPI/examples/README.md +++ b/SimpleCADAPI/examples/README.md @@ -1,22 +1,22 @@ # SimpleCADAPI Examples Run examples from the repository root with `uv run python `. -Generated STEP/STL/JSON files are written to `examples/out/`, which is ignored by git. +Generated artifacts are written to `examples/out/`, which is ignored by git. + +Replayable examples use one top-level `@scad.model` entry. That entry owns the +single `GraphSession` and returns a `ModelResult`; reusable graph-producing +builders use `@scad.requires_session`. Final outputs are selected with +`scad.capture_result(...)`; `@scad.model(export_dir=...)` can automatically +write one self-contained Scene ZIP after execution. It embeds the replayable +model, mapped Python source files, render GLBs, and entity sidecars. Examples +that call explicit export or translator APIs may also write STEP, JSON, or +FreeCAD files. ## Examples -- `01_basic_modeling.py` — functional shape modeling, booleans, and STEP/STL export. -- `02_graph_replay.py` — `GraphSession`, canonical model JSON export, and replay. -- `03_expressions.py` — expression parameters captured in a replayable model graph. -- `05_loft_sweep_revolve.py` — profile operations: revolve, loft, and sweep. -- `06_parametric_gear_model.py` — lightweight involute spur gear model JSON example for replay/export tests. -- `07_serialization_operation_tree.py` — compact serialization demo showing how source calls map to canonical operation-tree nodes, including expressions, primitive lowering, features, booleans, transforms, patterns, and detail operations. -- `13_cycloidal_reducer.py` — compact 50 mm diameter, 10 mm tall, 10:1 cycloidal reducer assembly with twin segmented B-spline cycloidal discs, 180-degree opposed input eccentric cams, 18-degree half-lobe tooth-index phase, three-hole input/output disks, and assembly constraints. -- `14_ball_bearing.py` — parameterized ball bearing standard assembly with grooved inner/outer race rings, direct sphere rolling elements, stable ring component IDs, ring axis connectors, an inner-to-outer revolute constraint, and a demo shaft/housing bound through those connectors. -- `15_cached_mesh_obj_export.py` — developer-facing cached-mesh example that builds a normal Solid, bypasses the public STL exporter, reads the internal mesh cache, and writes a Wavefront OBJ file. -- `16_compact_two_stage_planetary_reducer/` — modular 58.8 mm diameter, 30 mm tall, 20:1 two-stage herringbone planetary reducer with through-bolted actuator housing bosses, sealed input/output end caps, realistic output register pads, reusable stdlib ball bearing placements, graph/model JSON replay, STEP export, solved gear constraints, and a `collision_probe.py` static verifier run. -- `17_static_collision_verifier.py` — static current-pose verifier example using internal cached meshes and python-fcl to report over-tolerance contact penetration. -- `18_leg_wheel_robot_dog_leg/` — planar leg-wheel module using three reused reducer actuator modules, a fixed motor-can part, a compact coaxial thigh/knee-drive actuator stack, a thigh output-flange-bolted upper link, a coaxial knee-drive output crank with 6-hole output flange pattern, a true parallelogram pushrod linkage whose knee-side `BB'` ear is integrated into the shank plate, knee bearing retainer holes, wheel-hub housing/output bolt circles, graph/model JSON replay, STEP/FCStd export, and a leg-level `collision_probe.py` packaging check. -- `19_four_planet_planetary_reducer/` — exposed single-stage 3.5:1 fixed-ring planetary gearset with one input sun gear, four equally spaced planet gears, an internal ring gear, a four-pin output carrier, solved revolute/external gear/internal belt-equivalent mesh constraints, graph/model JSON replay, STEP export, and FCStd export. -- `20_five_axis_desktop_robot_arm/` — five-revolute-axis desktop robot arm inspired by the reference image, using five reused Example 16 reducer actuator modules with an improved rear-service motor package, explicit part/interface validation before assembly, base yaw, shoulder/elbow/wrist pitch, tool roll, bolted housing/output flange interfaces, sensor face detail, graph/model JSON replay, STEP export, and FCStd export. -- `20_integrated_bldc_joint_actuator/` — compact 50 mm OD joint actuator with a real 12-slot/14-pole inner-rotor BLDC motor, integrated rotor-shaft/stage-1 sun, 20:1 two-stage herringbone planetary reducer, serviceable split housing, paired output bearings, circular ESC PCB, rear phase and power/CAN terminals, graph/model JSON replay, STEP export, and FCStd export. +- `04_dimension_tolerance_chain.py` — expression-driven dimension tolerance analysis and validation. +- `08_constrained_sketch.py` — fully constrained sketch profiles, feature promotion, replay, and FreeCAD export. +- `09_naca0016_blade_freecad.py` — NACA 0016 B-spline blade model JSON, STEP, and FreeCAD translation. +- `10_part_assembly.py` — hydraulic rod assembly with sleeve/piston parts, prismatic motion, and automatic self-contained Scene ZIP export. +- `16_compact_two_stage_planetary_reducer/` — modular 58.8 mm diameter, 30 mm tall, 20:1 two-stage herringbone planetary reducer with graph/model JSON replay, STEP export, solved constraints, and a static collision probe. +- `20_integrated_bldc_joint_actuator/` — compact 50 mm OD joint actuator with a 12-slot/14-pole inner-rotor BLDC motor, two-stage planetary reducer, split housing, output bearings, and controller electronics. diff --git a/SimpleCADAPI/pyproject.toml b/SimpleCADAPI/pyproject.toml index 2e57034..bdba9fe 100644 --- a/SimpleCADAPI/pyproject.toml +++ b/SimpleCADAPI/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "simplecadapi" -version = "2.0.1b1" +version = "2.0.2" description = "A simplified OCP-native CAD modeling Python API" readme = "README.md" license = { file = "LICENSE" } @@ -26,6 +26,9 @@ dependencies = [ "numpy>=1.21.0", "rich>=14.0.0", "cadquery-ocp>=7.9.3.1", + "jsonschema>=4.25.1,<5.0.0", + "rfc8785>=0.1.4,<0.2.0", + "typing-extensions>=4.12,<5", ] [project.optional-dependencies] @@ -40,6 +43,9 @@ editor = [] collision = [ "python-fcl>=0.7.0.11", ] +inverse-engineer = [ + "matplotlib>=3.7.0", +] [project.urls] Homepage = "https://github.com/NiJingzhe/SimpleCADAPI" @@ -83,6 +89,7 @@ dev = [ "matplotlib>=3.10.8", "numpy-stl>=3.2.0", "trimesh>=4.11.3", + "datamodel-code-generator==0.32.0", ] [project.scripts] @@ -90,3 +97,4 @@ evolve = "simplecadapi.auto_tools.evolution:main" make-export = "simplecadapi.auto_tools.make_export:main" auto-docs-gen = "simplecadapi.auto_tools.auto_docs_gen:main" skill-pack = "simplecadapi.auto_tools.skill_pack:main" +simplecad-brep = "simplecadapi.inverse_engineer.brep.cli:main" diff --git a/SimpleCADAPI/scene-contract/.gitignore b/SimpleCADAPI/scene-contract/.gitignore new file mode 100644 index 0000000..1eae0cf --- /dev/null +++ b/SimpleCADAPI/scene-contract/.gitignore @@ -0,0 +1,2 @@ +dist/ +node_modules/ diff --git a/SimpleCADAPI/scene-contract/package-lock.json b/SimpleCADAPI/scene-contract/package-lock.json new file mode 100644 index 0000000..69472aa --- /dev/null +++ b/SimpleCADAPI/scene-contract/package-lock.json @@ -0,0 +1,304 @@ +{ + "name": "simplecad-scene-contract-parity", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "simplecad-scene-contract-parity", + "version": "1.0.0", + "dependencies": { + "ajv": "8.20.0" + }, + "devDependencies": { + "@types/node": "22.10.2", + "json-schema-to-typescript": "15.0.4", + "typescript": "5.7.2" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@apidevtools/json-schema-ref-parser": { + "version": "11.9.3", + "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-11.9.3.tgz", + "integrity": "sha512-60vepv88RwcJtSHrD6MjIL6Ta3SOYbgfnkHb+ppAVK+o9mXprRtulx7VlRl3lN3bbvysAfCS7WMVfhUYemB0IQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jsdevtools/ono": "^7.1.3", + "@types/json-schema": "^7.0.15", + "js-yaml": "^4.1.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/philsturgeon" + } + }, + "node_modules/@jsdevtools/ono": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz", + "integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/lodash": { + "version": "4.17.24", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz", + "integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.10.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.2.tgz", + "integrity": "sha512-Xxr6BBRCAOQixvonOye19wnzyDiUtTeqldOOmj3CkeblonbccA12PFwlufvRdrpjXxqnmUaeiU5EOA+7s5diUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.20.0" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz", + "integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/js-yaml": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-schema-to-typescript": { + "version": "15.0.4", + "resolved": "https://registry.npmjs.org/json-schema-to-typescript/-/json-schema-to-typescript-15.0.4.tgz", + "integrity": "sha512-Su9oK8DR4xCmDsLlyvadkXzX6+GGXJpbhwoLtOGArAG61dvbW4YQmSEno2y66ahpIdmLMg6YUf/QHLgiwvkrHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@apidevtools/json-schema-ref-parser": "^11.5.5", + "@types/json-schema": "^7.0.15", + "@types/lodash": "^4.17.7", + "is-glob": "^4.0.3", + "js-yaml": "^4.1.0", + "lodash": "^4.17.21", + "minimist": "^1.2.8", + "prettier": "^3.2.5", + "tinyglobby": "^0.2.9" + }, + "bin": { + "json2ts": "dist/src/cli.js" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/prettier": { + "version": "3.9.5", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.5.tgz", + "integrity": "sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.2.tgz", + "integrity": "sha512-i5t66RHxDvVN40HfDd1PsEThGNnlMCMT3jMUuoh9/0TaqWevNontacunWyN02LA9/fIbEWlcHZcgTKb9QoaLfg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.20.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", + "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/SimpleCADAPI/scene-contract/package.json b/SimpleCADAPI/scene-contract/package.json new file mode 100644 index 0000000..46720c2 --- /dev/null +++ b/SimpleCADAPI/scene-contract/package.json @@ -0,0 +1,24 @@ +{ + "name": "simplecad-scene-contract-parity", + "version": "1.0.0", + "private": true, + "type": "module", + "engines": { + "node": ">=20" + }, + "scripts": { + "build": "tsc", + "generate:types": "uv run python ../tools/generate_scene_contract_types.py --language typescript", + "check:types": "uv run python ../tools/generate_scene_contract_types.py --check --language typescript", + "test": "npm run check:types && npm run build && node --test dist/test/*.test.js", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "ajv": "8.20.0" + }, + "devDependencies": { + "@types/node": "22.10.2", + "json-schema-to-typescript": "15.0.4", + "typescript": "5.7.2" + } +} diff --git a/SimpleCADAPI/scene-contract/src/canonical.ts b/SimpleCADAPI/scene-contract/src/canonical.ts new file mode 100644 index 0000000..de7596b --- /dev/null +++ b/SimpleCADAPI/scene-contract/src/canonical.ts @@ -0,0 +1,266 @@ +import { createHash } from "node:crypto"; + +export class DuplicateKeyError extends SyntaxError {} + +const decoder = new TextDecoder("utf-8", { fatal: true }); +const maxSafeInteger = 9_007_199_254_740_991; +const parserDepthCapacity = 256; + +function assertUnicodeScalarString(value: string): void { + for (let index = 0; index < value.length; index += 1) { + const unit = value.charCodeAt(index); + if (unit >= 0xd800 && unit <= 0xdbff) { + const next = value.charCodeAt(index + 1); + if (!Number.isInteger(next) || next < 0xdc00 || next > 0xdfff) { + throw new TypeError("string contains an unpaired surrogate"); + } + index += 1; + } else if (unit >= 0xdc00 && unit <= 0xdfff) { + throw new TypeError("string contains an unpaired surrogate"); + } + } +} + +class StrictJsonParser { + private offset = 0; + + public constructor(private readonly text: string) {} + + public parse(): unknown { + this.space(); + const parsed = this.value(0); + this.space(); + if (this.offset !== this.text.length) { + throw new SyntaxError("trailing data after JSON value"); + } + return parsed; + } + + private value(depth: number): unknown { + if (depth > parserDepthCapacity) { + throw new SyntaxError("JSON nesting exceeds parser capacity"); + } + this.space(); + const token = this.text[this.offset]; + if ( + this.text.startsWith("NaN", this.offset) || + this.text.startsWith("Infinity", this.offset) || + this.text.startsWith("-Infinity", this.offset) + ) { + throw new SyntaxError("non-finite JSON number is forbidden"); + } + if (token === "{") return this.object(depth + 1); + if (token === "[") return this.array(depth + 1); + if (token === '"') return this.string(); + if (token === "t" && this.consume("true")) return true; + if (token === "f" && this.consume("false")) return false; + if (token === "n" && this.consume("null")) return null; + if (token === "-" || (token !== undefined && token >= "0" && token <= "9")) { + return this.number(); + } + throw new SyntaxError(`invalid JSON token at offset ${this.offset}`); + } + + private object(depth: number): Record { + this.offset += 1; + this.space(); + const result: Record = Object.create(null) as Record; + const keys = new Set(); + if (this.text[this.offset] === "}") { + this.offset += 1; + return result; + } + while (true) { + if (this.text[this.offset] !== '"') { + throw new SyntaxError(`JSON object key expected at offset ${this.offset}`); + } + const key = this.string(); + if (keys.has(key)) { + throw new DuplicateKeyError(`duplicate JSON object member: ${JSON.stringify(key)}`); + } + keys.add(key); + this.space(); + if (this.text[this.offset] !== ":") { + throw new SyntaxError(`JSON object colon expected at offset ${this.offset}`); + } + this.offset += 1; + const child = this.value(depth); + result[key] = child; + this.space(); + const separator = this.text[this.offset]; + this.offset += 1; + if (separator === "}") return result; + if (separator !== ",") { + throw new SyntaxError(`JSON object separator expected at offset ${this.offset - 1}`); + } + this.space(); + } + } + + private array(depth: number): unknown[] { + this.offset += 1; + this.space(); + const result: unknown[] = []; + if (this.text[this.offset] === "]") { + this.offset += 1; + return result; + } + while (true) { + const child = this.value(depth); + result.push(child); + this.space(); + const separator = this.text[this.offset]; + this.offset += 1; + if (separator === "]") return result; + if (separator !== ",") { + throw new SyntaxError(`JSON array separator expected at offset ${this.offset - 1}`); + } + this.space(); + } + } + + private string(): string { + const start = this.offset; + this.offset += 1; + while (this.offset < this.text.length) { + const unit = this.text.charCodeAt(this.offset); + if (unit < 0x20) { + throw new SyntaxError(`unescaped control character at offset ${this.offset}`); + } + if (unit === 0x22) { + this.offset += 1; + const result = JSON.parse(this.text.slice(start, this.offset)) as string; + assertUnicodeScalarString(result); + return result; + } + if (unit === 0x5c) { + this.offset += 1; + const escape = this.text[this.offset]; + if (escape === "u") { + if (!/^[0-9a-fA-F]{4}$/.test(this.text.slice(this.offset + 1, this.offset + 5))) { + throw new SyntaxError(`invalid JSON Unicode escape at offset ${this.offset}`); + } + this.offset += 5; + continue; + } + if (escape === undefined || !'"\\/bfnrt'.includes(escape)) { + throw new SyntaxError(`invalid JSON escape at offset ${this.offset}`); + } + } + this.offset += 1; + } + throw new SyntaxError("unterminated JSON string"); + } + + private number(): number { + const match = /^-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?/.exec( + this.text.slice(this.offset), + ); + if (match === null) throw new SyntaxError(`invalid JSON number at offset ${this.offset}`); + const token = match[0]; + this.offset += token.length; + const result = Number(token); + if (!/[.eE]/.test(token) && Math.abs(result) > maxSafeInteger) { + throw new SyntaxError("JSON integer exceeds the JCS safe domain"); + } + if (!Number.isFinite(result)) throw new SyntaxError("non-finite JSON number is forbidden"); + return result; + } + + private consume(token: string): boolean { + if (!this.text.startsWith(token, this.offset)) return false; + this.offset += token.length; + return true; + } + + private space(): void { + while ( + this.text[this.offset] === " " || + this.text[this.offset] === "\n" || + this.text[this.offset] === "\r" || + this.text[this.offset] === "\t" + ) { + this.offset += 1; + } + } +} + +export function parseStrictJson(data: Uint8Array | string): unknown { + let text: string; + if (typeof data === "string") { + text = data; + } else { + if (data.length >= 3 && data[0] === 0xef && data[1] === 0xbb && data[2] === 0xbf) { + throw new SyntaxError("UTF-8 BOM is forbidden"); + } + text = decoder.decode(data); + } + if (text.startsWith("\ufeff")) throw new SyntaxError("UTF-8 BOM is forbidden"); + return new StrictJsonParser(text).parse(); +} + +function serialize(value: unknown, stack: Set): string { + if (value === null) return "null"; + if (typeof value === "boolean") return value ? "true" : "false"; + if (typeof value === "number") { + if (!Number.isFinite(value)) throw new TypeError("JSON number must be finite"); + return JSON.stringify(value); + } + if (typeof value === "string") { + assertUnicodeScalarString(value); + return JSON.stringify(value); + } + if (typeof value !== "object") throw new TypeError(`unsupported JSON value: ${typeof value}`); + if (stack.has(value)) throw new TypeError("cyclic value is not JSON"); + stack.add(value); + let result: string; + if (Array.isArray(value)) { + const items: string[] = []; + for (let index = 0; index < value.length; index += 1) { + if (!Object.hasOwn(value, index)) throw new TypeError("sparse arrays are outside the JSON domain"); + items.push(serialize(value[index], stack)); + } + result = `[${items.join(",")}]`; + } else { + const record = value as Record; + const keys = Object.keys(record).sort(); + for (const key of keys) assertUnicodeScalarString(key); + result = `{${keys + .map((key) => `${JSON.stringify(key)}:${serialize(record[key], stack)}`) + .join(",")}}`; + } + stack.delete(value); + return result; +} + +export function canonicalJsonBytes(value: unknown): Buffer { + return Buffer.from(serialize(value, new Set()), "utf8"); +} + +export function parseCanonicalJson(data: Uint8Array | string): unknown { + const raw = typeof data === "string" ? Buffer.from(data, "utf8") : Buffer.from(data); + const parsed = parseStrictJson(raw); + if (!raw.equals(canonicalJsonBytes(parsed))) { + throw new SyntaxError("JSON bytes are not RFC 8785 canonical"); + } + return parsed; +} + +export function sha256(data: Uint8Array): string { + return `sha256:${createHash("sha256").update(data).digest("hex")}`; +} + +export function canonicalJsonHash(value: unknown): string { + return sha256(canonicalJsonBytes(value)); +} + +export function computeSceneRevision(scene: Record): string { + const { revision: _revision, ...draft } = scene; + return canonicalJsonHash(draft); +} + +export function withSceneRevision(scene: Record): Record { + const result = structuredClone(scene); + result.revision = computeSceneRevision(result); + return result; +} diff --git a/SimpleCADAPI/scene-contract/src/documents.ts b/SimpleCADAPI/scene-contract/src/documents.ts new file mode 100644 index 0000000..e7aabe7 --- /dev/null +++ b/SimpleCADAPI/scene-contract/src/documents.ts @@ -0,0 +1,158 @@ +import type { + ConnectorBindingDocument as ConnectorBindingValue, + EntityDocument as EntityValue, + NormalizedProductDocument as NormalizedProductValue, + PresentationDocument as PresentationValue, + SceneDocument as SceneValue, +} from "./generated/index.js"; +import { canonicalJsonBytes, parseCanonicalJson, sha256 } from "./canonical.js"; +import type { ValidationReport } from "./report.js"; +import { + validateConnectorBinding, + validateEntityAsset, + validateNormalizedProduct, + validatePresentation, + validateSceneManifest, +} from "./validation.js"; + +export type SceneDocument = SceneValue; +export type EntityDocument = EntityValue; +export type PresentationDocument = PresentationValue; +export type ConnectorBindingDocument = ConnectorBindingValue; +export type NormalizedProductDocument = NormalizedProductValue; + +export type DeepReadonly = + T extends (...args: never[]) => unknown + ? T + : T extends readonly (infer Item)[] + ? readonly DeepReadonly[] + : T extends object + ? { readonly [Key in keyof T]: DeepReadonly } + : T; + +export interface ImmutableContractDocument { + readonly value: DeepReadonly; + readonly canonicalBytes: Buffer; + readonly canonicalHash: string; + toMutable(): Value; +} + +export interface ImmutableSceneDocument + extends ImmutableContractDocument { + readonly revision: string; +} + +export type ImmutableEntityDocument = ImmutableContractDocument; +export type ImmutablePresentationDocument = + ImmutableContractDocument; +export type ImmutableConnectorBindingDocument = + ImmutableContractDocument; +export type ImmutableNormalizedProductDocument = + ImmutableContractDocument; + +export interface ContractDocumentFactory { + fromValue(value: Value | DeepReadonly): Document; + parse(data: Uint8Array | string): Document; +} + +export class ContractDocumentValidationError extends TypeError { + public constructor(public readonly report: ValidationReport) { + const first = report.firstError; + super( + first === null + ? "Scene contract validation failed" + : `Scene contract validation failed: ${first.code} at ${first.path}: ${first.message}`, + ); + this.name = "ContractDocumentValidationError"; + } +} + +type Validator = (value: unknown) => ValidationReport; + +function deepFreeze(value: unknown): unknown { + if (Array.isArray(value)) { + for (const child of value) deepFreeze(child); + return Object.freeze(value); + } + if (value !== null && typeof value === "object") { + for (const child of Object.values(value)) deepFreeze(child); + return Object.freeze(value); + } + return value; +} + +class FrozenContractDocument + implements ImmutableContractDocument +{ + readonly #value: DeepReadonly; + readonly #canonicalBytes: Buffer; + readonly #canonicalHash: string; + + public constructor(value: Value | DeepReadonly, validate: Validator) { + const canonical = canonicalJsonBytes(value); + const detached = parseCanonicalJson(canonical); + const validation = validate(detached); + if (!validation.valid) throw new ContractDocumentValidationError(validation); + this.#value = deepFreeze(detached) as DeepReadonly; + this.#canonicalBytes = Buffer.from(canonical); + this.#canonicalHash = sha256(canonical); + Object.freeze(this); + } + + public get value(): DeepReadonly { + return this.#value; + } + + public get canonicalBytes(): Buffer { + return Buffer.from(this.#canonicalBytes); + } + + public get canonicalHash(): string { + return this.#canonicalHash; + } + + public toMutable(): Value { + return parseCanonicalJson(this.#canonicalBytes) as Value; + } +} + +class FrozenSceneDocument + extends FrozenContractDocument + implements ImmutableSceneDocument +{ + public get revision(): string { + return this.value.revision; + } +} + +function documentFactory( + create: (value: Value | DeepReadonly) => Document, +): ContractDocumentFactory { + return Object.freeze({ + fromValue: create, + parse: (data: Uint8Array | string) => create(parseCanonicalJson(data) as Value), + }); +} + +export const SceneDocument = documentFactory( + (value) => new FrozenSceneDocument(value, validateSceneManifest), +); + +export const EntityDocument = documentFactory( + (value) => new FrozenContractDocument(value, validateEntityAsset), +); + +export const PresentationDocument = documentFactory< + PresentationValue, + ImmutablePresentationDocument +>((value) => new FrozenContractDocument(value, validatePresentation)); + +export const ConnectorBindingDocument = documentFactory< + ConnectorBindingValue, + ImmutableConnectorBindingDocument +>((value) => new FrozenContractDocument(value, validateConnectorBinding)); + +export const NormalizedProductDocument = documentFactory< + NormalizedProductValue, + ImmutableNormalizedProductDocument +>((value) => new FrozenContractDocument(value, validateNormalizedProduct)); diff --git a/SimpleCADAPI/scene-contract/src/generated/connector_binding.ts b/SimpleCADAPI/scene-contract/src/generated/connector_binding.ts new file mode 100644 index 0000000..9372132 --- /dev/null +++ b/SimpleCADAPI/scene-contract/src/generated/connector_binding.ts @@ -0,0 +1,94 @@ +/* eslint-disable */ +/** + * This file was automatically generated by json-schema-to-typescript. + * DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file, + * and run json-schema-to-typescript to regenerate this file. + */ + +/** + * This interface was referenced by `ConnectorBindingDocument`'s JSON-Schema + * via the `definition` "logicalId". + */ +export type LogicalId = string; +/** + * This interface was referenced by `ConnectorBindingDocument`'s JSON-Schema + * via the `definition` "productId". + */ +export type ProductId = string; +/** + * This interface was referenced by `ConnectorBindingDocument`'s JSON-Schema + * via the `definition` "hash". + */ +export type Hash = string; +/** + * This interface was referenced by `ConnectorBindingDocument`'s JSON-Schema + * via the `definition` "structuralId". + */ +export type StructuralId = string; +/** + * This interface was referenced by `ConnectorBindingDocument`'s JSON-Schema + * via the `definition` "safeInteger". + */ +export type SafeInteger = number; + +export interface ConnectorBindingDocument { + schema_version: "1.0"; + binding_id: LogicalId; + source_model: SourceModel; + source_scene: SourceScene; + owner_definition_id: StructuralId; + selected_occurrence_node_id: StructuralId; + connector_id: ProductId; + name: string | null; + target: Target; +} +/** + * This interface was referenced by `ConnectorBindingDocument`'s JSON-Schema + * via the `definition` "sourceModel". + */ +export interface SourceModel { + graph_id: ProductId; + model_schema_version: "2.0"; + artifact_hash: Hash; +} +/** + * This interface was referenced by `ConnectorBindingDocument`'s JSON-Schema + * via the `definition` "sourceScene". + */ +export interface SourceScene { + scene_id: LogicalId; + revision: Hash; +} +/** + * This interface was referenced by `ConnectorBindingDocument`'s JSON-Schema + * via the `definition` "target". + */ +export interface Target { + kind: "topology_entity"; + entity_asset_id: Hash; + entity_id: string; + expected_source: ModelOutput | ModelTopology; + flip: boolean; +} +/** + * This interface was referenced by `ConnectorBindingDocument`'s JSON-Schema + * via the `definition` "modelOutput". + */ +export interface ModelOutput { + kind: "model_output"; + graph_id: ProductId; + node_id: ProductId; + output_slot: SafeInteger; +} +/** + * This interface was referenced by `ConnectorBindingDocument`'s JSON-Schema + * via the `definition` "modelTopology". + */ +export interface ModelTopology { + kind: "model_topology"; + graph_id: ProductId; + node_id: ProductId; + output_slot: SafeInteger; + topology_kind: "FACE" | "EDGE" | "VERTEX"; + topo_id: string; +} diff --git a/SimpleCADAPI/scene-contract/src/generated/entities.ts b/SimpleCADAPI/scene-contract/src/generated/entities.ts new file mode 100644 index 0000000..689c508 --- /dev/null +++ b/SimpleCADAPI/scene-contract/src/generated/entities.ts @@ -0,0 +1,379 @@ +/* eslint-disable */ +/** + * This file was automatically generated by json-schema-to-typescript. + * DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file, + * and run json-schema-to-typescript to regenerate this file. + */ + +/** + * This interface was referenced by `EntityDocument`'s JSON-Schema + * via the `definition` "structuralId". + */ +export type StructuralId = string; +/** + * This interface was referenced by `EntityDocument`'s JSON-Schema + * via the `definition` "hash". + */ +export type Hash = string; +/** + * This interface was referenced by `EntityDocument`'s JSON-Schema + * via the `definition` "entityId". + */ +export type EntityId = string; +/** + * This interface was referenced by `EntityDocument`'s JSON-Schema + * via the `definition` "source". + */ +export type Source = ModelOutput | ModelTopology | ImportedPrimitive | Unbound; +/** + * This interface was referenced by `EntityDocument`'s JSON-Schema + * via the `definition` "productId". + */ +export type ProductId = string; +/** + * This interface was referenced by `EntityDocument`'s JSON-Schema + * via the `definition` "safeInteger". + */ +export type SafeInteger = number; +/** + * This interface was referenced by `EntityDocument`'s JSON-Schema + * via the `definition` "geometry". + */ +export type Geometry = + | BrepSolid + | Point + | Line + | Circle + | Ellipse + | BsplineCurve + | OtherCurve + | Plane + | Cylinder + | Cone + | Sphere + | Torus + | BsplineSurface + | OtherSurface; +/** + * @minItems 3 + * @maxItems 3 + * + * This interface was referenced by `EntityDocument`'s JSON-Schema + * via the `definition` "vec3". + */ +export type Vec3 = [number, number, number]; +/** + * This interface was referenced by `EntityDocument`'s JSON-Schema + * via the `definition` "positiveInteger". + */ +export type PositiveInteger = number; + +export interface EntityDocument { + schema_version: "1.0"; + definition_id: StructuralId; + geometry_asset_id: Hash; + edge_asset_id: Hash; + geometry_engine: GeometryEngine; + /** + * @minItems 4 + */ + entities: [Entity, Entity, Entity, Entity, ...Entity[]]; + /** + * @minItems 1 + */ + face_groups: [Group, ...Group[]]; + edge_groups: Group[]; +} +/** + * This interface was referenced by `EntityDocument`'s JSON-Schema + * via the `definition` "geometryEngine". + */ +export interface GeometryEngine { + name: "OpenCascade"; + version: string; + profile: "ocp-evaluated-properties-1"; +} +/** + * This interface was referenced by `EntityDocument`'s JSON-Schema + * via the `definition` "entity". + */ +export interface Entity { + entity_id: EntityId; + kind: "solid" | "face" | "edge" | "vertex"; + parent_entity_ids: EntityId[]; + child_entity_ids: EntityId[]; + source: Source; + geometry: Geometry; + properties: SolidProperties | FaceProperties | EdgeProperties | VertexProperties; + sdk_connector_frame: null | Transform; + render_status: "rendered" | "degenerate"; + connector_binding_status: + | "not_applicable" + | "owner_not_part" + | "source_not_model" + | "frame_undefined" + | "selector_ambiguous" + | "selector_unstable" + | "supported"; + semantic_binding_ids: string[]; + evaluated_tags: string[]; + sdk_metadata: { + [k: string]: unknown; + }; +} +/** + * This interface was referenced by `EntityDocument`'s JSON-Schema + * via the `definition` "modelOutput". + */ +export interface ModelOutput { + kind: "model_output"; + graph_id: ProductId; + node_id: ProductId; + output_slot: SafeInteger; +} +/** + * This interface was referenced by `EntityDocument`'s JSON-Schema + * via the `definition` "modelTopology". + */ +export interface ModelTopology { + kind: "model_topology"; + graph_id: ProductId; + node_id: ProductId; + output_slot: SafeInteger; + topology_kind: "SOLID" | "FACE" | "EDGE" | "VERTEX"; + topo_id: string; +} +/** + * This interface was referenced by `EntityDocument`'s JSON-Schema + * via the `definition` "importedPrimitive". + */ +export interface ImportedPrimitive { + kind: "imported_primitive"; + source_element_id: string; +} +/** + * This interface was referenced by `EntityDocument`'s JSON-Schema + * via the `definition` "unbound". + */ +export interface Unbound { + kind: "unbound"; +} +/** + * This interface was referenced by `EntityDocument`'s JSON-Schema + * via the `definition` "brepSolid". + */ +export interface BrepSolid { + type: "brep_solid"; +} +/** + * This interface was referenced by `EntityDocument`'s JSON-Schema + * via the `definition` "point". + */ +export interface Point { + type: "point"; + position: Vec3; +} +/** + * This interface was referenced by `EntityDocument`'s JSON-Schema + * via the `definition` "line". + */ +export interface Line { + type: "line"; + origin: Vec3; + direction: Vec3; +} +/** + * This interface was referenced by `EntityDocument`'s JSON-Schema + * via the `definition` "circle". + */ +export interface Circle { + type: "circle"; + center: Vec3; + normal: Vec3; + x_direction: Vec3; + radius: number; +} +/** + * This interface was referenced by `EntityDocument`'s JSON-Schema + * via the `definition` "ellipse". + */ +export interface Ellipse { + type: "ellipse"; + center: Vec3; + normal: Vec3; + x_direction: Vec3; + major_radius: number; + minor_radius: number; +} +/** + * This interface was referenced by `EntityDocument`'s JSON-Schema + * via the `definition` "bsplineCurve". + */ +export interface BsplineCurve { + type: "bspline_curve"; + degree: SafeInteger; + rational: boolean; + periodic: boolean; + poles_count: SafeInteger; + knots_count: SafeInteger; +} +/** + * This interface was referenced by `EntityDocument`'s JSON-Schema + * via the `definition` "otherCurve". + */ +export interface OtherCurve { + type: "other_curve"; + engine_type: string; +} +/** + * This interface was referenced by `EntityDocument`'s JSON-Schema + * via the `definition` "plane". + */ +export interface Plane { + type: "plane"; + origin: Vec3; + normal: Vec3; + x_direction: Vec3; +} +/** + * This interface was referenced by `EntityDocument`'s JSON-Schema + * via the `definition` "cylinder". + */ +export interface Cylinder { + type: "cylinder"; + origin: Vec3; + axis: Vec3; + x_direction: Vec3; + radius: number; +} +/** + * This interface was referenced by `EntityDocument`'s JSON-Schema + * via the `definition` "cone". + */ +export interface Cone { + type: "cone"; + origin: Vec3; + axis: Vec3; + x_direction: Vec3; + reference_radius: number; + semi_angle_degrees: number; +} +/** + * This interface was referenced by `EntityDocument`'s JSON-Schema + * via the `definition` "sphere". + */ +export interface Sphere { + type: "sphere"; + center: Vec3; + axis: Vec3; + x_direction: Vec3; + radius: number; +} +/** + * This interface was referenced by `EntityDocument`'s JSON-Schema + * via the `definition` "torus". + */ +export interface Torus { + type: "torus"; + center: Vec3; + axis: Vec3; + x_direction: Vec3; + major_radius: number; + minor_radius: number; +} +/** + * This interface was referenced by `EntityDocument`'s JSON-Schema + * via the `definition` "bsplineSurface". + */ +export interface BsplineSurface { + type: "bspline_surface"; + u_degree: SafeInteger; + v_degree: SafeInteger; + u_rational: boolean; + v_rational: boolean; + u_periodic: boolean; + v_periodic: boolean; + u_poles_count: SafeInteger; + v_poles_count: SafeInteger; + u_knots_count: SafeInteger; + v_knots_count: SafeInteger; +} +/** + * This interface was referenced by `EntityDocument`'s JSON-Schema + * via the `definition` "otherSurface". + */ +export interface OtherSurface { + type: "other_surface"; + engine_type: string; +} +/** + * This interface was referenced by `EntityDocument`'s JSON-Schema + * via the `definition` "solidProperties". + */ +export interface SolidProperties { + quality: "kernel_evaluated"; + bounds: Bounds; + volume: number; + surface_area: number; + centroid: Vec3; +} +/** + * This interface was referenced by `EntityDocument`'s JSON-Schema + * via the `definition` "bounds". + */ +export interface Bounds { + min: Vec3; + max: Vec3; +} +/** + * This interface was referenced by `EntityDocument`'s JSON-Schema + * via the `definition` "faceProperties". + */ +export interface FaceProperties { + quality: "kernel_evaluated"; + bounds: Bounds; + area: number; + centroid: Vec3; + orientation: "forward" | "reversed" | "internal" | "external"; +} +/** + * This interface was referenced by `EntityDocument`'s JSON-Schema + * via the `definition` "edgeProperties". + */ +export interface EdgeProperties { + quality: "kernel_evaluated"; + bounds: Bounds; + length: number; + centroid: Vec3; +} +/** + * This interface was referenced by `EntityDocument`'s JSON-Schema + * via the `definition` "vertexProperties". + */ +export interface VertexProperties { + quality: "kernel_evaluated"; + bounds: Bounds; + position: Vec3; +} +/** + * This interface was referenced by `EntityDocument`'s JSON-Schema + * via the `definition` "transform". + */ +export interface Transform { + origin: Vec3; + x_axis: Vec3; + y_axis: Vec3; + z_axis: Vec3; +} +/** + * This interface was referenced by `EntityDocument`'s JSON-Schema + * via the `definition` "group". + */ +export interface Group { + group_id: SafeInteger; + entity_id: EntityId; + mesh_index: 0; + primitive_index: 0; + first_index: SafeInteger; + index_count: PositiveInteger; +} diff --git a/SimpleCADAPI/scene-contract/src/generated/index.ts b/SimpleCADAPI/scene-contract/src/generated/index.ts new file mode 100644 index 0000000..6a2407b --- /dev/null +++ b/SimpleCADAPI/scene-contract/src/generated/index.ts @@ -0,0 +1,7 @@ +// Generated by tools/generate_scene_contract_types.py. Do not edit. + +export type { SceneDocument } from "./scene.js"; +export type { EntityDocument } from "./entities.js"; +export type { PresentationDocument } from "./presentation.js"; +export type { ConnectorBindingDocument } from "./connector_binding.js"; +export type { NormalizedProductDocument } from "./normalized_product.js"; diff --git a/SimpleCADAPI/scene-contract/src/generated/normalized_product.ts b/SimpleCADAPI/scene-contract/src/generated/normalized_product.ts new file mode 100644 index 0000000..b64de3e --- /dev/null +++ b/SimpleCADAPI/scene-contract/src/generated/normalized_product.ts @@ -0,0 +1,128 @@ +/* eslint-disable */ +/** + * This file was automatically generated by json-schema-to-typescript. + * DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file, + * and run json-schema-to-typescript to regenerate this file. + */ + +export type NormalizedProductDocument = Part | Assembly; +export type ProductId = string; +export type BodySource = ModelBodySource | ManualBodySource; +export type SafeInteger = number; +export type Connector = { + [k: string]: unknown; +} & { + connector_id: ProductId; + name: string | null; + anchor: GeometryAnchor | PlacementAnchor | ForwardedAnchor; + geometry_ref?: null | GeometryRef; +}; +/** + * @minItems 3 + * @maxItems 3 + */ +export type Vec3 = [number, number, number]; +export type StructuralId = string; + +export interface Part { + kind: "part"; + part_id: ProductId; + body_source: BodySource; + name: string | null; + material: null | Material; + connectors: Connector[]; + metadata: { + [k: string]: unknown; + }; +} +export interface ModelBodySource { + kind: "model_output"; + graph_id: ProductId; + node_id: ProductId; + output_slot: SafeInteger; +} +export interface ManualBodySource { + kind: "manual"; + source_id: ProductId; +} +export interface Material { + material_id: ProductId; + name: string | null; + density: number | null; + density_unit: string | null; + color: null | [number, number, number]; + metadata: { + [k: string]: unknown; + }; +} +export interface GeometryAnchor { + anchor_kind: "geometry"; + geometry_ref: GeometryRef; +} +export interface GeometryRef { + kind: "face" | "edge" | "vertex"; + source_node_id: string | null; + geo_selector: { + [k: string]: unknown; + }; + flip: boolean; +} +export interface PlacementAnchor { + anchor_kind: "placement"; + placement: Transform; +} +export interface Transform { + origin: Vec3; + x_axis: Vec3; + y_axis: Vec3; + z_axis: Vec3; +} +export interface ForwardedAnchor { + anchor_kind: "forwarded"; + source_component_id: ProductId; + source_connector_id: ProductId; + offset: null | Transform; +} +export interface Assembly { + kind: "assembly"; + assembly_id: ProductId; + name: string | null; + components: Component[]; + connectors: Connector[]; + constraints: Constraint[]; + grounded_component_ids: ProductId[]; + metadata: { + [k: string]: unknown; + }; +} +export interface Component { + component_id: ProductId; + name: string | null; + definition_ref: StructuralId; + local_placement: Transform; +} +export interface Constraint { + constraint_id: ProductId; + constraint_kind: "fixed" | "revolute" | "prismatic" | "gear" | "belt" | "rack_pinion"; + connector_a: ConnectorRef; + connector_b: ConnectorRef; + drive_distance: number | null; + distance_limit: null | Limit; + drive_angle_degrees: number | null; + angle_limit: null | Limit; + pitch_radius_a: number | null; + pitch_radius_b: number | null; + pulley_radius_a: number | null; + pulley_radius_b: number | null; + pitch_radius: number | null; + phase_offset: number | null; + name: string | null; +} +export interface ConnectorRef { + component_id: ProductId; + connector_id: ProductId; +} +export interface Limit { + lower_value: number; + upper_value: number; +} diff --git a/SimpleCADAPI/scene-contract/src/generated/presentation.ts b/SimpleCADAPI/scene-contract/src/generated/presentation.ts new file mode 100644 index 0000000..976b320 --- /dev/null +++ b/SimpleCADAPI/scene-contract/src/generated/presentation.ts @@ -0,0 +1,120 @@ +/* eslint-disable */ +/** + * This file was automatically generated by json-schema-to-typescript. + * DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file, + * and run json-schema-to-typescript to regenerate this file. + */ + +/** + * This interface was referenced by `PresentationDocument`'s JSON-Schema + * via the `definition` "logicalId". + */ +export type LogicalId = string; +/** + * @minItems 4 + * @maxItems 4 + * + * This interface was referenced by `PresentationDocument`'s JSON-Schema + * via the `definition` "color". + */ +export type Color = [number, number, number, 1]; +/** + * This interface was referenced by `PresentationDocument`'s JSON-Schema + * via the `definition` "nodeOverride". + */ +export type NodeOverride = NodeOverride1 & { + node_id: StructuralId; + visible?: boolean; + appearance_name?: string; +}; +export type NodeOverride1 = { + [k: string]: unknown; +}; +/** + * This interface was referenced by `PresentationDocument`'s JSON-Schema + * via the `definition` "structuralId". + */ +export type StructuralId = string; +/** + * This interface was referenced by `PresentationDocument`'s JSON-Schema + * via the `definition` "camera". + */ +export type Camera = Perspective | Orthographic; +/** + * @minItems 3 + * @maxItems 3 + * + * This interface was referenced by `PresentationDocument`'s JSON-Schema + * via the `definition` "vec3". + */ +export type Vec3 = [number, number, number]; +/** + * This interface was referenced by `PresentationDocument`'s JSON-Schema + * via the `definition` "positive". + */ +export type Positive = number; + +export interface PresentationDocument { + schema_version: "1.0"; + presentation_id: LogicalId; + source_scene_id: LogicalId; + appearances: Appearance[]; + node_overrides: NodeOverride[]; + cameras: Camera[]; +} +/** + * This interface was referenced by `PresentationDocument`'s JSON-Schema + * via the `definition` "appearance". + */ +export interface Appearance { + name: string; + base_color: Color; + metallic: number; + roughness: number; + alpha_mode: "opaque"; + double_sided: boolean; + edge_color: Color; +} +/** + * This interface was referenced by `PresentationDocument`'s JSON-Schema + * via the `definition` "perspective". + */ +export interface Perspective { + name: string; + projection: "perspective"; + parent_node_id: null | StructuralId; + transform: Transform; + near: Positive; + far: Positive; + vertical_fov_degrees: number; +} +/** + * This interface was referenced by `PresentationDocument`'s JSON-Schema + * via the `definition` "transform". + */ +export interface Transform { + origin: Vec3; + x_axis: Vec3; + y_axis: Vec3; + z_axis: Vec3; +} +/** + * This interface was referenced by `PresentationDocument`'s JSON-Schema + * via the `definition` "orthographic". + */ +export interface Orthographic { + name: string; + projection: "orthographic"; + parent_node_id: null | StructuralId; + transform: Transform; + near: Positive; + far: Positive; + vertical_span: Positive; +} +/** + * This interface was referenced by `PresentationDocument`'s JSON-Schema + * via the `definition` "cameraBaseProperties". + */ +export interface CameraBaseProperties { + [k: string]: unknown; +} diff --git a/SimpleCADAPI/scene-contract/src/generated/scene.ts b/SimpleCADAPI/scene-contract/src/generated/scene.ts new file mode 100644 index 0000000..e9f2da2 --- /dev/null +++ b/SimpleCADAPI/scene-contract/src/generated/scene.ts @@ -0,0 +1,545 @@ +/* eslint-disable */ +/** + * This file was automatically generated by json-schema-to-typescript. + * DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file, + * and run json-schema-to-typescript to regenerate this file. + */ + +/** + * This interface was referenced by `SceneDocument`'s JSON-Schema + * via the `definition` "logicalId". + */ +export type LogicalId = string; +/** + * This interface was referenced by `SceneDocument`'s JSON-Schema + * via the `definition` "hash". + */ +export type Hash = string; +/** + * This interface was referenced by `SceneDocument`'s JSON-Schema + * via the `definition` "token". + */ +export type Token = string; +/** + * This interface was referenced by `SceneDocument`'s JSON-Schema + * via the `definition` "sceneSource". + */ +export type SceneSource = ModelSource | ImportedSource | ManualSource; +/** + * This interface was referenced by `SceneDocument`'s JSON-Schema + * via the `definition` "productId". + */ +export type ProductId = string; +/** + * This interface was referenced by `SceneDocument`'s JSON-Schema + * via the `definition` "safeInteger". + */ +export type SafeInteger = number; +/** + * This interface was referenced by `SceneDocument`'s JSON-Schema + * via the `definition` "uri". + */ +export type Uri = string; +/** + * This interface was referenced by `SceneDocument`'s JSON-Schema + * via the `definition` "definition". + */ +export type Definition = { + [k: string]: unknown; +} & { + definition_id: StructuralId; + kind: "part" | "assembly" | "shape"; + name: string | null; + source: DefinitionSource; + sdk_metadata: { + [k: string]: unknown; + }; + geometry_asset_id?: Hash; + edge_asset_id?: Hash; + entity_asset_id?: Hash; + appearance_id?: StructuralId; +}; +/** + * This interface was referenced by `SceneDocument`'s JSON-Schema + * via the `definition` "structuralId". + */ +export type StructuralId = string; +/** + * This interface was referenced by `SceneDocument`'s JSON-Schema + * via the `definition` "definitionSource". + */ +export type DefinitionSource = + ProductModelSource | ProductManualSource | ModelOutputSource | ImportedDefinitionSource | ManualDefinitionSource; +/** + * @minItems 3 + * @maxItems 3 + * + * This interface was referenced by `SceneDocument`'s JSON-Schema + * via the `definition` "vec3". + */ +export type Vec3 = [Number, Number, Number]; +/** + * This interface was referenced by `SceneDocument`'s JSON-Schema + * via the `definition` "number". + */ +export type Number = number; +/** + * This interface was referenced by `SceneDocument`'s JSON-Schema + * via the `definition` "positiveNumber". + */ +export type PositiveNumber = number; +/** + * @minItems 4 + * @maxItems 4 + * + * This interface was referenced by `SceneDocument`'s JSON-Schema + * via the `definition` "color". + */ +export type Color = [number, number, number, 1]; +/** + * This interface was referenced by `SceneDocument`'s JSON-Schema + * via the `definition` "connector". + */ +export type Connector = { + [k: string]: unknown; +} & { + connector_snapshot_id: StructuralId; + owner_definition_id: StructuralId; + connector_id: ProductId; + name: string | null; + anchor_kind: "geometry" | "placement" | "forwarded"; + local_transform: Transform; + target?: ConnectorTarget; + forwarded_from?: ForwardedFrom; + source: null | ModelOperationSource | ManualConnectorSource; + sdk_metadata: { + [k: string]: unknown; + }; +}; +/** + * This interface was referenced by `SceneDocument`'s JSON-Schema + * via the `definition` "camera". + */ +export type Camera = PerspectiveCamera | OrthographicCamera; + +export interface SceneDocument { + schema_version: "1.0"; + /** + * @maxItems 0 + */ + extensions_used: unknown[]; + /** + * @maxItems 0 + */ + extensions_required: unknown[]; + extensions: { + [k: string]: unknown; + }; + scene_id: LogicalId; + revision: Hash; + generator: Generator; + source: SceneSource; + presentation_source?: PresentationSource; + coordinate_system: CoordinateSystem; + compile_options: CompileOptions; + definitions: Definition[]; + nodes: Node[]; + geometry_assets: GeometryAsset[]; + edge_assets: EdgeAsset[]; + appearances: Appearance[]; + entity_assets: EntityAsset[]; + connectors: Connector[]; + cameras: Camera[]; + /** + * @maxItems 0 + */ + lights: unknown[]; + /** + * @maxItems 0 + */ + annotations: unknown[]; + /** + * @maxItems 0 + */ + diagnostics: unknown[]; +} +/** + * This interface was referenced by `SceneDocument`'s JSON-Schema + * via the `definition` "generator". + */ +export interface Generator { + name: "simplecadapi"; + simplecadapi_version: Token; + ocp_version: Token; + ocp_bindings_version: Token; + python_abi: Token; + platform_tag: Token; + toolchain_hash: Hash; + profile: "scene-1.0-ocp-glb-1" | "scene-1.0-ocp-glb-2"; +} +/** + * This interface was referenced by `SceneDocument`'s JSON-Schema + * via the `definition` "modelSource". + */ +export interface ModelSource { + kind: "model"; + graph_id: ProductId; + model_schema_version: "2.0"; + artifact_hash: Hash; + embedded_artifact_uri?: "model/model.json"; + embedded_artifact_byte_length?: SafeInteger; + /** + * @maxItems 25000 + */ + source_files?: SourceFile[]; +} +/** + * This interface was referenced by `SceneDocument`'s JSON-Schema + * via the `definition` "sourceFile". + */ +export interface SourceFile { + path: string; + uri: Uri; + media_type: "text/x-python; charset=utf-8"; + byte_length: SafeInteger; + content_hash: Hash; +} +/** + * This interface was referenced by `SceneDocument`'s JSON-Schema + * via the `definition` "importedSource". + */ +export interface ImportedSource { + kind: "imported"; + format: Token; + artifact_hash: Hash; + embedded_artifact_uri?: "source/source.bin"; + embedded_artifact_byte_length?: SafeInteger; +} +/** + * This interface was referenced by `SceneDocument`'s JSON-Schema + * via the `definition` "manualSource". + */ +export interface ManualSource { + kind: "manual"; + source_id: LogicalId; +} +/** + * This interface was referenced by `SceneDocument`'s JSON-Schema + * via the `definition` "presentationSource". + */ +export interface PresentationSource { + presentation_id: LogicalId; + schema_version: "1.0"; + artifact_hash: Hash; + embedded_artifact_uri?: "presentation/presentation.json"; + embedded_artifact_byte_length?: SafeInteger; +} +/** + * This interface was referenced by `SceneDocument`'s JSON-Schema + * via the `definition` "coordinateSystem". + */ +export interface CoordinateSystem { + length_unit: "mm"; + handedness: "right"; + up_axis: "+Z"; +} +/** + * This interface was referenced by `SceneDocument`'s JSON-Schema + * via the `definition` "compileOptions". + */ +export interface CompileOptions { + linear_tolerance: number; + angular_tolerance: number; + embed_source: boolean; + embed_presentation: boolean; +} +/** + * This interface was referenced by `SceneDocument`'s JSON-Schema + * via the `definition` "productModelSource". + */ +export interface ProductModelSource { + kind: "product_model"; + root_id: LogicalId; + semantic_type: "Part" | "Assembly"; + semantic_id: ProductId; + graph_id: ProductId; + node_id: ProductId; + output_slot: SafeInteger; +} +/** + * This interface was referenced by `SceneDocument`'s JSON-Schema + * via the `definition` "productManualSource". + */ +export interface ProductManualSource { + kind: "product_manual"; + root_id: LogicalId; + semantic_type: "Part" | "Assembly"; + semantic_id: ProductId; +} +/** + * This interface was referenced by `SceneDocument`'s JSON-Schema + * via the `definition` "modelOutputSource". + */ +export interface ModelOutputSource { + kind: "model_output"; + root_id: LogicalId; + graph_id: ProductId; + node_id: ProductId; + output_slot: SafeInteger; +} +/** + * This interface was referenced by `SceneDocument`'s JSON-Schema + * via the `definition` "importedDefinitionSource". + */ +export interface ImportedDefinitionSource { + kind: "imported"; + root_id: LogicalId; + source_element_id: string; +} +/** + * This interface was referenced by `SceneDocument`'s JSON-Schema + * via the `definition` "manualDefinitionSource". + */ +export interface ManualDefinitionSource { + kind: "manual"; + root_id: LogicalId; + source_id: LogicalId; +} +/** + * This interface was referenced by `SceneDocument`'s JSON-Schema + * via the `definition` "node". + */ +export interface Node { + node_id: StructuralId; + parent_node_id: StructuralId | null; + order: SafeInteger; + definition_id: StructuralId; + name: string | null; + transform: Transform; + visible: boolean; + selectable: boolean; + appearance_override_id: StructuralId | null; + source: ProductOccurrenceSource | ShapeRootSource; + sdk_metadata: { + [k: string]: unknown; + }; +} +/** + * This interface was referenced by `SceneDocument`'s JSON-Schema + * via the `definition` "transform". + */ +export interface Transform { + origin: Vec3; + x_axis: Vec3; + y_axis: Vec3; + z_axis: Vec3; +} +/** + * This interface was referenced by `SceneDocument`'s JSON-Schema + * via the `definition` "productOccurrenceSource". + */ +export interface ProductOccurrenceSource { + kind: "product_occurrence"; + root_id: LogicalId; + component_path: ProductId[]; +} +/** + * This interface was referenced by `SceneDocument`'s JSON-Schema + * via the `definition` "shapeRootSource". + */ +export interface ShapeRootSource { + kind: "shape_root"; + root_id: LogicalId; +} +/** + * This interface was referenced by `SceneDocument`'s JSON-Schema + * via the `definition` "geometryAsset". + */ +export interface GeometryAsset { + asset_id: Hash; + uri: Uri; + media_type: "model/gltf-binary"; + byte_length: SafeInteger; + content_hash: Hash; + scene_local_bounds: Bounds; + /** + * @minItems 16 + * @maxItems 16 + */ + asset_to_scene: [1000, 0, 0, 0, 0, 0, -1000, 0, 0, 1000, 0, 0, 0, 0, 0, 1]; + tessellation: { + linear_tolerance: PositiveNumber; + angular_tolerance: PositiveNumber; + }; +} +/** + * This interface was referenced by `SceneDocument`'s JSON-Schema + * via the `definition` "bounds". + */ +export interface Bounds { + min: Vec3; + max: Vec3; +} +/** + * This interface was referenced by `SceneDocument`'s JSON-Schema + * via the `definition` "edgeAsset". + */ +export interface EdgeAsset { + asset_id: Hash; + uri: Uri; + media_type: "model/gltf-binary"; + byte_length: SafeInteger; + content_hash: Hash; + scene_local_bounds: Bounds; + /** + * @minItems 16 + * @maxItems 16 + */ + asset_to_scene: [1000, 0, 0, 0, 0, 0, -1000, 0, 0, 1000, 0, 0, 0, 0, 0, 1]; + tessellation: { + linear_tolerance: PositiveNumber; + }; +} +/** + * This interface was referenced by `SceneDocument`'s JSON-Schema + * via the `definition` "appearance". + */ +export interface Appearance { + appearance_id: string; + name: string | null; + source: null | ProductMaterialSource | PresentationAppearanceSource; + base_color: Color; + metallic: number; + roughness: number; + alpha_mode: "opaque"; + double_sided: boolean; + edge_color: Color; + sdk_metadata: { + [k: string]: unknown; + }; +} +/** + * This interface was referenced by `SceneDocument`'s JSON-Schema + * via the `definition` "productMaterialSource". + */ +export interface ProductMaterialSource { + kind: "product_material"; + root_id: LogicalId; + material_id: ProductId; +} +/** + * This interface was referenced by `SceneDocument`'s JSON-Schema + * via the `definition` "presentationAppearanceSource". + */ +export interface PresentationAppearanceSource { + kind: "presentation"; + presentation_id: LogicalId; + appearance_name: string; +} +/** + * This interface was referenced by `SceneDocument`'s JSON-Schema + * via the `definition` "entityAsset". + */ +export interface EntityAsset { + entity_asset_id: Hash; + uri: Uri; + media_type: "application/vnd.simplecad.entities+json"; + byte_length: SafeInteger; + content_hash: Hash; +} +/** + * This interface was referenced by `SceneDocument`'s JSON-Schema + * via the `definition` "connectorTarget". + */ +export interface ConnectorTarget { + entity_asset_id: Hash; + entity_id: string; +} +/** + * This interface was referenced by `SceneDocument`'s JSON-Schema + * via the `definition` "forwardedFrom". + */ +export interface ForwardedFrom { + source_component_id: ProductId; + source_definition_id: StructuralId; + source_connector_id: ProductId; + source_connector_snapshot_id: StructuralId; + offset: null | Transform; +} +/** + * This interface was referenced by `SceneDocument`'s JSON-Schema + * via the `definition` "modelOperationSource". + */ +export interface ModelOperationSource { + kind: "model_operation"; + graph_id: ProductId; + node_id: ProductId; + output_slot: SafeInteger; +} +/** + * This interface was referenced by `SceneDocument`'s JSON-Schema + * via the `definition` "manualConnectorSource". + */ +export interface ManualConnectorSource { + kind: "manual"; + source_id: LogicalId; +} +/** + * This interface was referenced by `SceneDocument`'s JSON-Schema + * via the `definition` "perspectiveCamera". + */ +export interface PerspectiveCamera { + camera_id: StructuralId; + name: string; + projection: "perspective"; + parent_node_id: null | StructuralId; + transform: Transform; + near: PositiveNumber; + far: PositiveNumber; + vertical_fov_degrees: number; +} +/** + * This interface was referenced by `SceneDocument`'s JSON-Schema + * via the `definition` "orthographicCamera". + */ +export interface OrthographicCamera { + camera_id: StructuralId; + name: string; + projection: "orthographic"; + parent_node_id: null | StructuralId; + transform: Transform; + near: PositiveNumber; + far: PositiveNumber; + vertical_span: PositiveNumber; +} +/** + * This interface was referenced by `SceneDocument`'s JSON-Schema + * via the `definition` "assetBase". + */ +export interface AssetBase { + asset_id?: Hash; + uri?: Uri; + media_type?: "model/gltf-binary"; + byte_length?: SafeInteger; + content_hash?: Hash; + scene_local_bounds?: Bounds; + /** + * @minItems 16 + * @maxItems 16 + */ + asset_to_scene?: [1000, 0, 0, 0, 0, 0, -1000, 0, 0, 1000, 0, 0, 0, 0, 0, 1]; + [k: string]: unknown; +} +/** + * This interface was referenced by `SceneDocument`'s JSON-Schema + * via the `definition` "cameraBase". + */ +export interface CameraBase { + camera_id?: StructuralId; + name?: string; + parent_node_id?: null | StructuralId; + transform?: Transform; + near?: PositiveNumber; + far?: PositiveNumber; + [k: string]: unknown; +} diff --git a/SimpleCADAPI/scene-contract/src/glb.ts b/SimpleCADAPI/scene-contract/src/glb.ts new file mode 100644 index 0000000..b9a5677 --- /dev/null +++ b/SimpleCADAPI/scene-contract/src/glb.ts @@ -0,0 +1,348 @@ +import { canonicalJsonBytes, parseStrictJson } from "./canonical.js"; +import { BASE_LIMITS, type SceneResourceLimits } from "./resources.js"; + +export type GlbKind = "triangle" | "line"; + +export interface GlbInfo { + kind: GlbKind; + vertexCount: number; + indexCount: number; + primitiveCount: number; + decodedBufferBytes: number; + positionBounds: readonly [readonly [number, number, number], readonly [number, number, number]]; +} + +const maxSafeInteger = 9_007_199_254_740_991; + +function record(value: unknown, context: string): Record { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + throw new Error(`${context} must be an object`); + } + return value as Record; +} + +function requireKeys(value: unknown, expected: readonly string[], context: string): Record { + const result = record(value, context); + const keys = Object.keys(result).sort(); + if (keys.length !== expected.length || keys.some((key, index) => key !== [...expected].sort()[index])) { + throw new Error(`${context} fields do not match closed GLB profile`); + } + return result; +} + +function integer(value: unknown, context: string): number { + if (!Number.isSafeInteger(value) || typeof value !== "number" || value < 0 || value > maxSafeInteger) { + throw new Error(`${context} must be a non-negative safe integer`); + } + return value; +} + +function vec3(value: unknown, context: string): [number, number, number] { + if ( + !Array.isArray(value) || + value.length !== 3 || + value.some((component) => typeof component !== "number") + ) { + throw new Error(`${context} must be a three-number array`); + } + if (!value.every(Number.isFinite)) throw new Error(`${context} must contain finite values`); + return value as [number, number, number]; +} + +export function profileF32Bits(value: number): number { + if (typeof value !== "number" || !Number.isFinite(value)) throw new Error("f32 input must be a finite number"); + const converted = Math.fround(value); + if (!Number.isFinite(converted)) throw new Error("f32 conversion overflowed"); + const bytes = Buffer.allocUnsafe(4); + bytes.writeFloatLE(converted, 0); + const bits = bytes.readUInt32LE(); + return bits === 0x80000000 ? 0 : bits; +} + +export function profileF32(value: number): number { + const bytes = Buffer.allocUnsafe(4); + bytes.writeUInt32LE(profileF32Bits(value)); + return bytes.readFloatLE(); +} + +export function profileCross( + left: readonly [number, number, number], + right: readonly [number, number, number], +): [number, number, number] { + const a = vec3(left, "cross left"); + const b = vec3(right, "cross right"); + const p1x = a[1] * b[2]; + const p2x = a[2] * b[1]; + const p1y = a[2] * b[0]; + const p2y = a[0] * b[2]; + const p1z = a[0] * b[1]; + const p2z = a[1] * b[0]; + const result: [number, number, number] = [p1x - p2x, p1y - p2y, p1z - p2z]; + if (!result.every(Number.isFinite)) throw new Error("cross product produced a non-finite value"); + return result; +} + +export function profileNormalize(value: readonly [number, number, number]): [number, number, number] { + const vector = vec3(value, "normalize input"); + let squared = vector[0] * vector[0] + vector[1] * vector[1]; + squared += vector[2] * vector[2]; + if (!Number.isFinite(squared) || squared <= 0) throw new Error("normalize input has zero or non-finite length"); + const length = Math.sqrt(squared); + const result: [number, number, number] = vector.map((component) => profileF32(component / length)) as [number, number, number]; + let normSquared = result[0] * result[0] + result[1] * result[1]; + normSquared += result[2] * result[2]; + const norm = Math.sqrt(normSquared); + if (norm < 1 - 1e-6 || norm > 1 + 1e-6) throw new Error("normalized binary32 vector is outside the profile tolerance"); + return result; +} + +function array(value: unknown, context: string): unknown[] { + if (!Array.isArray(value)) throw new Error(`${context} must be an array`); + return value; +} + +function equalArray(left: readonly unknown[], right: readonly unknown[]): boolean { + return left.length === right.length && left.every((value, index) => value === right[index]); +} + +function tupleCompare(left: readonly number[], right: readonly number[]): number { + for (let index = 0; index < left.length; index += 1) { + if (left[index] !== right[index]) return left[index] - right[index]; + } + return 0; +} + +export function preflightGlbCounts( + kind: GlbKind, + vertexCountInput: number, + indexCountInput: number, + limits: SceneResourceLimits = BASE_LIMITS, +): number { + const vertexCount = integer(vertexCountInput, "POSITION count"); + const vertexLimit = kind === "triangle" + ? limits.triangle_vertices_per_asset + : limits.line_vertices_per_asset; + if (vertexCount === 0 || vertexCount > vertexLimit) { + throw new Error("GLB vertex count is empty or exceeds resource limit"); + } + const indexCount = integer(indexCountInput, "index count"); + const divisor = kind === "triangle" ? 3 : 2; + const primitiveCount = indexCount / divisor; + const primitiveLimit = kind === "triangle" + ? limits.triangles_per_asset + : limits.line_segments_per_asset; + if (indexCount === 0 || indexCount % divisor !== 0 || primitiveCount > primitiveLimit) { + throw new Error("GLB index count is invalid or exceeds resource limit"); + } + return primitiveCount; +} + +export function preflightGlb( + data: Uint8Array, + expectedKind?: GlbKind, + limits: SceneResourceLimits = BASE_LIMITS, +): GlbInfo { + if (data.byteLength > limits.one_member_bytes) throw new Error("GLB exceeds resource limit"); + const raw = Buffer.from(data); + if (raw.length < 28) throw new Error("truncated GLB"); + if (raw.readUInt32LE(0) !== 0x46546c67 || raw.readUInt32LE(4) !== 2 || raw.readUInt32LE(8) !== raw.length) { + throw new Error("invalid GLB header"); + } + const jsonLength = raw.readUInt32LE(12); + if (raw.readUInt32LE(16) !== 0x4e4f534a || jsonLength % 4 !== 0) { + throw new Error("invalid GLB JSON chunk header"); + } + const jsonEnd = 20 + jsonLength; + if (jsonEnd + 8 > raw.length) throw new Error("truncated GLB JSON chunk"); + const paddedJson = raw.subarray(20, jsonEnd); + let unpaddedLength = paddedJson.length; + while (unpaddedLength > 0 && paddedJson[unpaddedLength - 1] === 0x20) unpaddedLength -= 1; + const jsonBytes = paddedJson.subarray(0, unpaddedLength); + const expectedPadding = (4 - (jsonBytes.length % 4)) % 4; + if ( + paddedJson.length !== jsonBytes.length + expectedPadding || + paddedJson.subarray(jsonBytes.length).some((byte) => byte !== 0x20) + ) { + throw new Error("GLB JSON padding must use the minimal ASCII-space suffix"); + } + const document = parseStrictJson(jsonBytes); + if (document === null || typeof document !== "object" || Array.isArray(document) || !Buffer.from(jsonBytes).equals(canonicalJsonBytes(document))) { + throw new Error("GLB JSON chunk must be RFC 8785 canonical"); + } + const binLength = raw.readUInt32LE(jsonEnd); + if (raw.readUInt32LE(jsonEnd + 4) !== 0x004e4942 || binLength % 4 !== 0 || jsonEnd + 8 + binLength !== raw.length) { + throw new Error("invalid GLB BIN chunk header"); + } + const binData = raw.subarray(jsonEnd + 8); + const root = requireKeys( + document, + ["accessors", "asset", "bufferViews", "buffers", "meshes", "nodes", "scene", "scenes"], + "GLB root", + ); + const asset = root.asset; + if (JSON.stringify(asset) !== JSON.stringify({ generator: "SimpleCAD Scene GLB Profile 1", version: "2.0" })) { + throw new Error("GLB asset record does not match Scene profile"); + } + const nodes = array(root.nodes, "GLB nodes"); + const scenes = array(root.scenes, "GLB scenes"); + let skeletonValid = integer(root.scene, "GLB default scene") === 0 && nodes.length === 1 && scenes.length === 1; + if (skeletonValid) { + const node = requireKeys(nodes[0], ["mesh"], "GLB node"); + const scene = requireKeys(scenes[0], ["nodes"], "GLB scene"); + skeletonValid = integer(node.mesh, "GLB node mesh") === 0 && Array.isArray(scene.nodes) && equalArray(scene.nodes, [0]); + if (Array.isArray(scene.nodes) && scene.nodes.some((item) => !Number.isSafeInteger(item) || typeof item !== "number")) { + skeletonValid = false; + } + } + if (!skeletonValid) throw new Error("GLB scene/node skeleton does not match Scene profile"); + + const meshes = array(root.meshes, "GLB meshes"); + if (meshes.length !== 1) throw new Error("GLB must contain exactly one mesh"); + const mesh = requireKeys(meshes[0], ["primitives"], "GLB mesh"); + const primitives = array(mesh.primitives, "GLB primitives"); + if (primitives.length !== 1) throw new Error("GLB must contain exactly one primitive"); + const primitive = requireKeys(primitives[0], ["attributes", "indices", "mode"], "GLB primitive"); + const accessors = array(root.accessors, "GLB accessors"); + const views = array(root.bufferViews, "GLB bufferViews"); + const buffers = array(root.buffers, "GLB buffers"); + if (buffers.length !== 1) throw new Error("GLB accessors/bufferViews/buffer skeleton is invalid"); + const buffer = requireKeys(buffers[0], ["byteLength"], "GLB buffer"); + const bufferByteLength = integer(buffer.byteLength, "GLB buffer byteLength"); + const attributesRecord = record(primitive.attributes, "GLB primitive attributes"); + const attributes = Object.fromEntries( + Object.entries(attributesRecord).map(([name, value]) => [name, integer(value, `GLB ${name} attribute accessor`)]), + ); + const primitiveIndices = integer(primitive.indices, "GLB primitive index accessor"); + const primitiveMode = integer(primitive.mode, "GLB primitive mode"); + let kind: GlbKind; + let accessorCount: number; + let indexView: number; + if (JSON.stringify(attributes) === JSON.stringify({ NORMAL: 1, POSITION: 0 }) && primitiveIndices === 2 && primitiveMode === 4) { + kind = "triangle"; + accessorCount = 3; + indexView = 2; + } else if (JSON.stringify(attributes) === JSON.stringify({ POSITION: 0 }) && primitiveIndices === 1 && primitiveMode === 1) { + kind = "line"; + accessorCount = 2; + indexView = 1; + } else { + throw new Error("GLB primitive does not match triangle or line profile"); + } + if (expectedKind !== undefined && kind !== expectedKind) throw new Error(`expected ${expectedKind} GLB, got ${kind}`); + if (accessors.length !== accessorCount || views.length !== accessorCount) { + throw new Error("GLB accessor/bufferView count mismatch"); + } + + const position = requireKeys(accessors[0], ["bufferView", "componentType", "count", "max", "min", "type"], "POSITION accessor"); + const vertexCount = integer(position.count, "POSITION count"); + if (integer(position.bufferView, "POSITION bufferView") !== 0 || integer(position.componentType, "POSITION componentType") !== 5126 || position.type !== "VEC3") { + throw new Error("invalid POSITION accessor"); + } + const minimum = vec3(position.min, "POSITION min"); + const maximum = vec3(position.max, "POSITION max"); + if (minimum.some((value, index) => value > maximum[index])) throw new Error("POSITION min exceeds max"); + + if (kind === "triangle") { + const normal = requireKeys(accessors[1], ["bufferView", "componentType", "count", "type"], "NORMAL accessor"); + if (integer(normal.bufferView, "NORMAL bufferView") !== 1 || integer(normal.componentType, "NORMAL componentType") !== 5126 || integer(normal.count, "NORMAL count") !== vertexCount || normal.type !== "VEC3") { + throw new Error("invalid NORMAL accessor"); + } + } + const indexAccessor = requireKeys(accessors[indexView], ["bufferView", "componentType", "count", "type"], "index accessor"); + const indexCount = integer(indexAccessor.count, "index count"); + const primitiveCount = preflightGlbCounts(kind, vertexCount, indexCount, limits); + const componentType = vertexCount <= 65536 ? 5123 : 5125; + if (integer(indexAccessor.bufferView, "index bufferView") !== indexView || integer(indexAccessor.componentType, "index componentType") !== componentType || indexAccessor.type !== "SCALAR") { + throw new Error("invalid GLB index accessor"); + } + + let expectedOffset = 0; + const parsedViews: Record[] = []; + for (let viewIndex = 0; viewIndex < views.length; viewIndex += 1) { + const view = requireKeys(views[viewIndex], ["buffer", "byteLength", "byteOffset", "target"], `bufferView[${viewIndex}]`); + parsedViews.push(view); + const offset = integer(view.byteOffset, `bufferView[${viewIndex}].byteOffset`); + const length = integer(view.byteLength, `bufferView[${viewIndex}].byteLength`); + if (integer(view.buffer, `bufferView[${viewIndex}].buffer`) !== 0 || offset !== expectedOffset) throw new Error("GLB bufferView offset mismatch"); + if (integer(view.target, `bufferView[${viewIndex}].target`) !== (viewIndex < indexView ? 34962 : 34963)) throw new Error("GLB bufferView target mismatch"); + const expectedLength = viewIndex < indexView ? 12 * vertexCount : indexCount * (componentType === 5123 ? 2 : 4); + if (length !== expectedLength) throw new Error("GLB bufferView byteLength mismatch"); + expectedOffset = (expectedOffset + expectedLength + 3) & ~3; + } + const lastView = parsedViews.at(-1)!; + const unpaddedBinLength = integer(lastView.byteOffset, "last bufferView byteOffset") + integer(lastView.byteLength, "last bufferView byteLength"); + if (bufferByteLength !== unpaddedBinLength) throw new Error("GLB buffer byteLength mismatch"); + if (binLength !== ((unpaddedBinLength + 3) & ~3)) throw new Error("GLB BIN chunk length mismatch"); + if (binData.subarray(unpaddedBinLength).some((byte) => byte !== 0)) throw new Error("GLB BIN padding must be zero"); + + const positions: [number, number, number][] = []; + for (let index = 0; index < vertexCount; index += 1) { + const offset = index * 12; + const point: [number, number, number] = [binData.readFloatLE(offset), binData.readFloatLE(offset + 4), binData.readFloatLE(offset + 8)]; + if (!point.every(Number.isFinite)) throw new Error("GLB POSITION contains non-finite values"); + if ([0, 4, 8].some((delta) => binData.readUInt32LE(offset + delta) === 0x80000000)) throw new Error("GLB float buffers must encode zero with positive sign"); + positions.push(point); + } + const actualMin: [number, number, number] = [...positions[0]]; + const actualMax: [number, number, number] = [...positions[0]]; + for (const point of positions) { + for (let axis = 0; axis < 3; axis += 1) { + actualMin[axis] = Math.min(actualMin[axis], point[axis]); + actualMax[axis] = Math.max(actualMax[axis], point[axis]); + } + } + if (!equalArray(minimum, actualMin) || !equalArray(maximum, actualMax)) throw new Error("POSITION accessor bounds do not match buffer"); + + if (kind === "triangle") { + const normalOffset = integer(parsedViews[1].byteOffset, "NORMAL byteOffset"); + for (let index = 0; index < vertexCount; index += 1) { + const offset = normalOffset + index * 12; + const normal = [binData.readFloatLE(offset), binData.readFloatLE(offset + 4), binData.readFloatLE(offset + 8)]; + if ([0, 4, 8].some((delta) => binData.readUInt32LE(offset + delta) === 0x80000000)) throw new Error("GLB float buffers must encode zero with positive sign"); + const norm = Math.sqrt(normal.reduce((sum, component) => sum + component * component, 0)); + if (!normal.every(Number.isFinite) || norm < 1 - 1e-6 || norm > 1 + 1e-6) throw new Error("GLB NORMAL must be finite and normalized"); + } + } + const indices: number[] = []; + const indexOffset = integer(parsedViews[indexView].byteOffset, "index byteOffset"); + for (let index = 0; index < indexCount; index += 1) { + indices.push(componentType === 5123 ? binData.readUInt16LE(indexOffset + index * 2) : binData.readUInt32LE(indexOffset + index * 4)); + } + let maximumIndex = -1; + for (const index of indices) maximumIndex = Math.max(maximumIndex, index); + if (maximumIndex >= vertexCount || new Set(indices).size !== vertexCount) throw new Error("GLB indices must be in range and reference every vertex"); + if (kind === "triangle") { + const triangles: number[][] = []; + for (let index = 0; index < indexCount; index += 3) { + const triple = indices.slice(index, index + 3); + const rotations = [triple, [triple[1], triple[2], triple[0]], [triple[2], triple[0], triple[1]]].sort(tupleCompare); + if (!equalArray(triple, rotations[0])) throw new Error("GLB triangle indices are not in canonical cyclic rotation"); + const [a, b, c] = triple.map((item) => positions[item]); + const ab = b.map((value, axis) => value - a[axis]); + const ac = c.map((value, axis) => value - a[axis]); + const cross = [ab[1] * ac[2] - ab[2] * ac[1], ab[2] * ac[0] - ab[0] * ac[2], ab[0] * ac[1] - ab[1] * ac[0]]; + if (equalArray(a, b) || equalArray(b, c) || equalArray(a, c) || cross.every((value) => value === 0)) throw new Error("GLB contains a collapsed triangle"); + triangles.push(triple); + } + const sorted = [...triangles].sort(tupleCompare); + if (triangles.some((triple, index) => !equalArray(triple, sorted[index]))) throw new Error("GLB triangle records are not canonically sorted"); + } else { + const segments: number[][] = []; + for (let index = 0; index < indexCount; index += 2) { + const pair = indices.slice(index, index + 2); + if (pair[0] >= pair[1]) throw new Error("GLB line indices are not in canonical endpoint order"); + if (equalArray(positions[pair[0]], positions[pair[1]])) throw new Error("GLB contains a collapsed line segment"); + segments.push(pair); + } + const sortedUnique = [...new Map(segments.map((pair) => [pair.join(","), pair])).values()].sort(tupleCompare); + if (segments.length !== sortedUnique.length || segments.some((pair, index) => !equalArray(pair, sortedUnique[index]))) throw new Error("GLB line records are not sorted and unique"); + } + return { + kind, + vertexCount, + indexCount, + primitiveCount, + decodedBufferBytes: 2 * parsedViews.reduce((sum, view) => sum + integer(view.byteLength, "bufferView byteLength"), 0), + positionBounds: [minimum, maximum], + }; +} diff --git a/SimpleCADAPI/scene-contract/src/index.ts b/SimpleCADAPI/scene-contract/src/index.ts new file mode 100644 index 0000000..092bbcf --- /dev/null +++ b/SimpleCADAPI/scene-contract/src/index.ts @@ -0,0 +1,7 @@ +export * from "./canonical.js"; +export * from "./documents.js"; +export * from "./glb.js"; +export * from "./report.js"; +export * from "./resources.js"; +export * from "./validation.js"; +export * from "./zip.js"; diff --git a/SimpleCADAPI/scene-contract/src/report.ts b/SimpleCADAPI/scene-contract/src/report.ts new file mode 100644 index 0000000..ec7f5a6 --- /dev/null +++ b/SimpleCADAPI/scene-contract/src/report.ts @@ -0,0 +1,154 @@ +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { Ajv2020 } from "ajv/dist/2020.js"; +import { parseStrictJson } from "./canonical.js"; + +export type ValidationPhase = "parse" | "structure" | "semantic" | "package" | "budget"; +export type ValidationArtifact = "scene" | "entities" | "presentation" | "connector_binding" | "normalized_product" | "package" | "glb" | "zip"; + +export interface ValidationRule { + readonly id: string; + readonly phase: ValidationPhase; + readonly precedence: number; + readonly artifacts: readonly ValidationArtifact[]; + readonly pointer_policy: "exact" | "nearest_record" | "root"; + readonly summary: string; +} + +interface ValidationRuleRegistry { + readonly schema_version: "1.0"; + readonly registry_id: "scene-1.0-rules"; + readonly phases: readonly ValidationPhase[]; + readonly rules: readonly ValidationRule[]; +} + +export interface ValidationIssue { + severity: "error"; + code: string; + path: string; + message: string; + phase: ValidationPhase; +} + +export interface ValidationReport { + valid: boolean; + issues: readonly ValidationIssue[]; + firstError: ValidationIssue | null; +} + +const phaseOrder: Record = { + parse: 0, + structure: 1, + semantic: 2, + package: 3, + budget: 4, +}; + +function utf8Compare(left: string, right: string): number { + return Buffer.compare(Buffer.from(left, "utf8"), Buffer.from(right, "utf8")); +} + +const contractRoot = resolve( + dirname(fileURLToPath(import.meta.url)), + "../../../src/simplecadapi/scene/contracts", +); +const ruleSchema = parseStrictJson(readFileSync(resolve(contractRoot, "schemas/rules-1.schema.json"))); +const validateRuleSchema = new Ajv2020({ strict: true, allErrors: true }).compile(ruleSchema as object); + +export function validateRuleRegistry(value: unknown): ReadonlyMap { + if (!validateRuleSchema(value)) { + const errors = [...(validateRuleSchema.errors ?? [])].sort( + (left, right) => utf8Compare(left.instancePath, right.instancePath) || + utf8Compare(left.message ?? "", right.message ?? ""), + ); + const first = errors[0]; + throw new Error(`invalid scene rule registry at ${first?.instancePath ?? ""}: ${first?.message ?? "schema validation failed"}`); + } + + const registry = value as ValidationRuleRegistry; + const registryPhaseOrder = new Map(registry.phases.map((phase, index) => [phase, index])); + const byId = new Map(); + const phasePrecedence = new Set(); + for (const rule of registry.rules) { + if (byId.has(rule.id)) throw new Error(`invalid scene rule registry: duplicate rule ID ${JSON.stringify(rule.id)}`); + const precedenceKey = `${rule.phase}\0${rule.precedence}`; + if (phasePrecedence.has(precedenceKey)) { + throw new Error(`invalid scene rule registry: duplicate precedence ${rule.precedence} in phase ${JSON.stringify(rule.phase)}`); + } + byId.set(rule.id, rule); + phasePrecedence.add(precedenceKey); + } + const expected = [...registry.rules].sort( + (left, right) => + registryPhaseOrder.get(left.phase)! - registryPhaseOrder.get(right.phase)! || + left.precedence - right.precedence, + ); + if (registry.rules.some((rule, index) => rule !== expected[index])) { + throw new Error("invalid scene rule registry: rules must be ordered by phase and precedence"); + } + for (const phase of registry.phases) { + const phaseRules = registry.rules.filter((rule) => rule.phase === phase); + const ids = phaseRules.map((rule) => rule.id); + const expectedIds = [...ids].sort(utf8Compare); + if (ids.some((id, index) => id !== expectedIds[index])) { + throw new Error(`invalid scene rule registry: precedence in phase ${JSON.stringify(phase)} must match unsigned UTF-8 rule-ID order`); + } + } + return byId; +} + +const rules = validateRuleRegistry( + parseStrictJson(readFileSync(resolve(contractRoot, "rules/scene-1.0-rules.json"))), +); + +export function hasRootPointerPolicy(code: string): boolean { + return rules.get(code)?.pointer_policy === "root"; +} + +export function issue( + code: string, + path: string, + message: string, + phase: ValidationPhase = "semantic", +): ValidationIssue { + return { severity: "error", code, path, message, phase }; +} + +export function report(input: Iterable, artifact?: ValidationArtifact): ValidationReport { + const unique = new Map(); + for (const item of input) { + if (artifact !== undefined) { + const rule = rules.get(item.code); + if (rule === undefined) throw new Error(`unregistered scene validation issue code: ${JSON.stringify(item.code)}`); + if (item.phase !== rule.phase) { + throw new Error(`scene validation issue ${JSON.stringify(item.code)} has phase ${JSON.stringify(item.phase)}; registered phase is ${JSON.stringify(rule.phase)}`); + } + if (!rule.artifacts.includes(artifact)) { + throw new Error(`scene validation issue ${JSON.stringify(item.code)} does not apply to artifact ${JSON.stringify(artifact)}`); + } + if (rule.pointer_policy === "root" && item.path !== "") { + throw new Error(`scene validation issue ${JSON.stringify(item.code)} requires the root pointer`); + } + } + const key = artifact === undefined + ? `${item.phase}\0${item.code}\0${item.path}` + : `${item.phase}\0${item.code}\0${item.path}\0${item.message}`; + if (!unique.has(key)) unique.set(key, item); + } + const issues = [...unique.values()].sort( + (left, right) => + phaseOrder[left.phase] - phaseOrder[right.phase] || + (artifact === undefined + ? utf8Compare(left.code, right.code) + : rules.get(left.code)!.precedence - rules.get(right.code)!.precedence) || + utf8Compare(left.path, right.path) || + utf8Compare(left.message, right.message), + ); + return { valid: issues.length === 0, issues, firstError: issues[0] ?? null }; +} + +export function pointer(parts: readonly (string | number)[]): string { + if (parts.length === 0) return ""; + return `/${parts.map((part) => String(part).replaceAll("~", "~0").replaceAll("/", "~1")).join("/")}`; +} diff --git a/SimpleCADAPI/scene-contract/src/resources.ts b/SimpleCADAPI/scene-contract/src/resources.ts new file mode 100644 index 0000000..27d73fb --- /dev/null +++ b/SimpleCADAPI/scene-contract/src/resources.ts @@ -0,0 +1,76 @@ +export const BASE_LIMITS = { + zip_members: 50_000, + input_archive_bytes: 256 * 1024 * 1024, + canonical_archive_bytes: 256 * 1024 * 1024, + total_uncompressed_bytes: 1024 * 1024 * 1024, + one_member_bytes: 256 * 1024 * 1024, + scene_json_bytes: 32 * 1024 * 1024, + entity_json_bytes: 64 * 1024 * 1024, + model_json_bytes: 64 * 1024 * 1024, + presentation_json_bytes: 8 * 1024 * 1024, + compression_ratio: 100, + json_depth: 64, + json_string_bytes: 1024 * 1024, + uri_bytes: 1024, + structural_id_bytes: 4096, + definitions: 25_000, + nodes: 100_000, + assets_per_kind: 25_000, + appearances: 25_000, + connectors: 100_000, + cameras: 1000, + hierarchy_depth: 256, + forwarded_connector_depth: 64, + entities_per_sidecar: 500_000, + entities_total: 2_000_000, + triangle_vertices_per_asset: 2_000_000, + triangle_vertices_total: 10_000_000, + triangles_per_asset: 2_000_000, + triangles_total: 10_000_000, + line_vertices_per_asset: 2_000_000, + line_vertices_total: 10_000_000, + line_segments_per_asset: 2_000_000, + line_segments_total: 10_000_000, + static_decoded_buffer_bytes: 512 * 1024 * 1024, +} as const; + +export type SceneResourceLimits = { readonly [Name in keyof typeof BASE_LIMITS]: number }; +export type SceneResourceLimitName = keyof typeof BASE_LIMITS; + +export function preflightInputArchiveSize(size: number, limits: SceneResourceLimits = BASE_LIMITS): number { + if (!Number.isSafeInteger(size) || size < 0) throw new Error("invalid input archive size"); + if (size > limits.input_archive_bytes) throw new Error("input archive exceeds resource limit"); + return size; +} + +export function preflightResourceCount( + count: number, + limitName: SceneResourceLimitName, + limits: SceneResourceLimits = BASE_LIMITS, +): number { + if (!Number.isSafeInteger(count) || count < 0) throw new Error("invalid resource count"); + if (count > limits[limitName]) throw new Error(`${limitName} exceeds resource limit`); + return count; +} + +export function preflightAggregateCompressionRatio( + uncompressedSize: number, + archiveSize: number, + limits: SceneResourceLimits = BASE_LIMITS, +): void { + if (!Number.isSafeInteger(uncompressedSize) || uncompressedSize < 0 || !Number.isSafeInteger(archiveSize) || archiveSize <= 0) { + throw new Error("invalid compression ratio sizes"); + } + if (uncompressedSize > limits.compression_ratio * archiveSize) throw new Error("aggregate compression ratio exceeds limit"); +} + +export function preflightMemberCompressionRatio( + uncompressedSize: number, + compressedSize: number, + limits: SceneResourceLimits = BASE_LIMITS, +): void { + if (!Number.isSafeInteger(uncompressedSize) || uncompressedSize < 0 || !Number.isSafeInteger(compressedSize) || compressedSize < 0) { + throw new Error("invalid compression ratio sizes"); + } + if (uncompressedSize > limits.compression_ratio * Math.max(1, compressedSize)) throw new Error("member compression ratio exceeds limit"); +} diff --git a/SimpleCADAPI/scene-contract/src/schema.ts b/SimpleCADAPI/scene-contract/src/schema.ts new file mode 100644 index 0000000..80532fc --- /dev/null +++ b/SimpleCADAPI/scene-contract/src/schema.ts @@ -0,0 +1,103 @@ +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { Ajv2020, type ErrorObject, type ValidateFunction } from "ajv/dist/2020.js"; +import { parseStrictJson } from "./canonical.js"; + +const contractRoot = resolve( + dirname(fileURLToPath(import.meta.url)), + "../../../src/simplecadapi/scene/contracts/schemas", +); + +const ajv = new Ajv2020({ strict: true, allErrors: true }); + +function declareRequiredProperties(value: unknown): void { + if (value === null || typeof value !== "object") return; + const schema = value as Record; + if ( + schema.type === undefined && + (schema.properties !== undefined || schema.required !== undefined) + ) { + schema.type = "object"; + } + if (Array.isArray(schema.required) && schema.required.every((item) => typeof item === "string")) { + const properties = + schema.properties !== null && typeof schema.properties === "object" && !Array.isArray(schema.properties) + ? (schema.properties as Record) + : {}; + for (const name of schema.required) properties[name] ??= {}; + schema.properties = properties; + } + for (const key of ["properties", "$defs", "patternProperties", "dependentSchemas"]) { + const schemas = schema[key]; + if (schemas !== null && typeof schemas === "object" && !Array.isArray(schemas)) { + Object.values(schemas).forEach(declareRequiredProperties); + } + } + for (const key of ["allOf", "anyOf", "oneOf", "prefixItems"]) { + const schemas = schema[key]; + if (Array.isArray(schemas)) schemas.forEach(declareRequiredProperties); + } + for (const key of [ + "not", + "if", + "then", + "else", + "items", + "contains", + "additionalProperties", + "unevaluatedProperties", + "propertyNames", + ]) { + declareRequiredProperties(schema[key]); + } +} + +function load(name: string): ValidateFunction { + const schema = parseStrictJson(readFileSync(resolve(contractRoot, name))); + // Ajv strictRequired expects declarations in the same nested subschema. The + // contract uses valid required-only guards, so add behavior-neutral schemas. + declareRequiredProperties(schema); + return ajv.compile(schema as object); +} + +export type SchemaArtifact = "scene" | "entities" | "presentation" | "connector_binding" | "normalized_product"; + +const schemaFiles: Record = { + scene: "scene-1.0.schema.json", + entities: "entities-1.0.schema.json", + presentation: "presentation-1.0.schema.json", + connector_binding: "connector-binding-1.0.schema.json", + normalized_product: "normalized-product-1.schema.json", +}; +const schemaDocuments = Object.fromEntries( + Object.entries(schemaFiles).map(([artifact, name]) => [artifact, parseStrictJson(readFileSync(resolve(contractRoot, name)))]), +) as Record>; +const pointerValidators = new Map(); + +export function validateSchemaPointer(artifact: SchemaArtifact, schemaPointer: string, value: unknown): boolean { + const key = `${artifact}:${schemaPointer}`; + let validate = pointerValidators.get(key); + if (validate === undefined) { + const root = structuredClone(schemaDocuments[artifact]); + delete root.$id; + const wrapper = schemaPointer === "#" + ? root + : { $schema: root.$schema, $ref: schemaPointer, $defs: root.$defs }; + declareRequiredProperties(wrapper); + validate = ajv.compile(wrapper); + pointerValidators.set(key, validate); + } + return Boolean(validate(value)); +} + +export const validateSceneSchema = load("scene-1.0.schema.json"); +export const validateEntitiesSchema = load("entities-1.0.schema.json"); +export const validatePresentationSchema = load("presentation-1.0.schema.json"); +export const validateConnectorBindingSchema = load("connector-binding-1.0.schema.json"); +export const validateNormalizedProductSchema = load("normalized-product-1.schema.json"); + +export function schemaErrors(validate: ValidateFunction, value: unknown): ErrorObject[] { + validate(value); + return validate.errors === null || validate.errors === undefined ? [] : [...validate.errors]; +} diff --git a/SimpleCADAPI/scene-contract/src/transforms.ts b/SimpleCADAPI/scene-contract/src/transforms.ts new file mode 100644 index 0000000..975d6db --- /dev/null +++ b/SimpleCADAPI/scene-contract/src/transforms.ts @@ -0,0 +1,35 @@ +/** Deterministic rigid-transform primitives used by Scene validation. */ + +type JsonRecord = Record; + +export function composeRigidTransforms(left: JsonRecord, right: JsonRecord): JsonRecord { + const origin = left.origin as number[]; + const xAxis = left.x_axis as number[]; + const yAxis = left.y_axis as number[]; + const zAxis = left.z_axis as number[]; + const rotate = (vector: number[]): number[] => [0, 1, 2].map((index) => + (xAxis[index] * vector[0] + yAxis[index] * vector[1]) + zAxis[index] * vector[2] + ); + const rotatedOrigin = rotate(right.origin as number[]); + return { + origin: origin.map((component, index) => component + rotatedOrigin[index]), + x_axis: rotate(right.x_axis as number[]), + y_axis: rotate(right.y_axis as number[]), + z_axis: rotate(right.z_axis as number[]), + }; +} + +export function rigidTransformsEqual(actual: JsonRecord, expected: JsonRecord): boolean { + const actualOrigin = actual.origin as number[]; + const expectedOrigin = expected.origin as number[]; + const originEpsilon = Math.max( + 1e-9, + 1e-12 * Math.max(1, ...actualOrigin.map(Math.abs), ...expectedOrigin.map(Math.abs)), + ); + return actualOrigin.every((component, index) => Math.abs(component - expectedOrigin[index]) <= originEpsilon) && + ["x_axis", "y_axis", "z_axis"].every((key) => + (actual[key] as number[]).every((component, index) => + Math.abs(component - (expected[key] as number[])[index]) <= 1e-12 + ) + ); +} diff --git a/SimpleCADAPI/scene-contract/src/validation.ts b/SimpleCADAPI/scene-contract/src/validation.ts new file mode 100644 index 0000000..058f747 --- /dev/null +++ b/SimpleCADAPI/scene-contract/src/validation.ts @@ -0,0 +1,1746 @@ +import { + canonicalJsonBytes, + computeSceneRevision, + DuplicateKeyError, + parseCanonicalJson, + parseStrictJson, + sha256, +} from "./canonical.js"; +import { type GlbInfo, preflightGlb } from "./glb.js"; +import { hasRootPointerPolicy, issue, pointer, report, type ValidationArtifact, type ValidationIssue, type ValidationReport } from "./report.js"; +import { BASE_LIMITS, preflightResourceCount, type SceneResourceLimits } from "./resources.js"; +import { composeRigidTransforms, rigidTransformsEqual } from "./transforms.js"; +import type { ValidateFunction } from "ajv"; +import { + schemaErrors, + validateConnectorBindingSchema, + validateEntitiesSchema, + validateNormalizedProductSchema, + validatePresentationSchema, + validateSceneSchema, +} from "./schema.js"; + +type JsonRecord = Record; +type DocumentArtifact = Exclude; + +const collectionSortKeys: Record = { + definitions: "definition_id", + nodes: "node_id", + geometry_assets: "asset_id", + edge_assets: "asset_id", + entity_assets: "entity_asset_id", + appearances: "appearance_id", + connectors: "connector_snapshot_id", + cameras: "camera_id", + entities: "entity_id", +}; +const setArrayKeys = new Set(["semantic_binding_ids", "evaluated_tags", "parent_entity_ids", "child_entity_ids"]); +const identifierArrayKeys = new Set([ + "child_entity_ids", + "component_path", + "evaluated_tags", + "grounded_component_ids", + "parent_entity_ids", + "semantic_binding_ids", +]); +const sourcePathPattern = /^[A-Za-z0-9][A-Za-z0-9._/-]*\.py$/; +const operationSourceKeys = new Set([ + "assignment_targets", + "call_text", + "callsite_id", + "column", + "end_column", + "end_line", + "line", + "path", + "path_kind", + "schema_version", +]); + +function isRecord(value: unknown): value is JsonRecord { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function utf8Compare(left: string, right: string): number { + return Buffer.compare(Buffer.from(left, "utf8"), Buffer.from(right, "utf8")); +} + +function parseInput(value: unknown, canonical: boolean): { value?: unknown; issues: ValidationIssue[] } { + if (!(typeof value === "string" || value instanceof Uint8Array)) return { value, issues: [] }; + try { + return { value: canonical ? parseCanonicalJson(value) : parseStrictJson(value), issues: [] }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + let code: string; + if (error instanceof DuplicateKeyError) code = "duplicate_json_key"; + else if (message.includes("BOM")) code = "bom_forbidden"; + else if (error instanceof TypeError && (message.toLowerCase().includes("utf") || message.includes("surrogate"))) code = "invalid_utf8"; + else if (message.includes("nesting")) code = "resource_limit_exceeded"; + else if (message.includes("canonical") && canonical) code = "noncanonical_json"; + else if (message.includes("finite") || message.includes("NaN") || message.includes("Infinity")) code = "nonfinite_json_number"; + else code = "schema_invalid"; + const phase = code === "resource_limit_exceeded" ? "budget" : code === "schema_invalid" ? "structure" : "parse"; + return { issues: [issue(code, "", message, phase)] }; + } +} + +function structuralIssues(value: unknown, validator: ValidateFunction): ValidationIssue[] { + return schemaErrors(validator, value).map((error) => + issue( + "schema_invalid", + error.instancePath, + error.keyword === "additionalProperties" ? "unknown field is forbidden by the closed schema" : error.message ?? "schema validation failed", + "structure", + ), + ); +} + +function walk(value: unknown): [readonly (string | number)[], unknown][] { + const result: [readonly (string | number)[], unknown][] = []; + const stack: [readonly (string | number)[], unknown][] = [[[], value]]; + while (stack.length > 0) { + const current = stack.pop()!; + result.push(current); + const [path, child] = current; + if (Array.isArray(child)) { + for (let index = child.length - 1; index >= 0; index -= 1) stack.push([[...path, index], child[index]]); + } else if (isRecord(child)) { + const entries = Object.entries(child); + for (let index = entries.length - 1; index >= 0; index -= 1) stack.push([[...path, entries[index][0]], entries[index][1]]); + } + } + return result; +} + +function hasUnpairedSurrogate(value: string): boolean { + for (let index = 0; index < value.length; index += 1) { + const unit = value.charCodeAt(index); + if (unit >= 0xd800 && unit <= 0xdbff) { + const next = value.charCodeAt(index + 1); + if (next < 0xdc00 || next > 0xdfff) return true; + index += 1; + } else if (unit >= 0xdc00 && unit <= 0xdfff) { + return true; + } + } + return false; +} + +function isMetadataPath(path: readonly (string | number)[]): boolean { + return path.some((part) => part === "metadata" || part === "sdk_metadata"); +} + +function isIdentifierPath(path: readonly (string | number)[]): boolean { + if (path.length === 0) return false; + const field = path.at(-1); + if (typeof field === "string" && (field.endsWith("_id") || ["definition_ref", "source_element_id", "topo_id"].includes(field))) { + return true; + } + return path.length >= 2 && typeof field === "number" && typeof path.at(-2) === "string" && identifierArrayKeys.has(path.at(-2) as string); +} + +function isUriPath(path: readonly (string | number)[]): boolean { + const field = path.at(-1); + return typeof field === "string" && (field === "uri" || field.endsWith("_uri")); +} + +function serializedSize(value: unknown): number | null { + if (typeof value === "string") return Buffer.byteLength(value, "utf8"); + if (value instanceof Uint8Array) return value.byteLength; + try { + return canonicalJsonBytes(value).byteLength; + } catch { + return null; + } +} + +function serializedSizeIssues(value: unknown, artifact: ValidationArtifact, limits: SceneResourceLimits): ValidationIssue[] { + const size = serializedSize(value); + const limit = artifact === "scene" + ? limits.scene_json_bytes + : artifact === "entities" + ? limits.entity_json_bytes + : artifact === "presentation" + ? limits.presentation_json_bytes + : limits.one_member_bytes; + return size !== null && size > limit + ? [issue("resource_limit_exceeded", "", `${artifact} JSON bytes exceed resource limit`, "budget")] + : []; +} + +export function jsonResourceIssues(value: unknown, limits: SceneResourceLimits = BASE_LIMITS): ValidationIssue[] { + const issues: ValidationIssue[] = []; + for (const [path, child] of walk(value)) { + const jsonPointer = pointer(path); + if (path.length > limits.json_depth) { + issues.push(issue("resource_limit_exceeded", jsonPointer, "JSON nesting exceeds resource limit", "budget")); + } + if (isRecord(child)) { + for (const key of Object.keys(child)) { + const keyPointer = pointer([...path, key]); + if (hasUnpairedSurrogate(key)) { + issues.push(issue("invalid_utf8", keyPointer, "object key contains an unpaired surrogate", "parse")); + continue; + } + const keyBytes = Buffer.byteLength(key, "utf8"); + if (keyBytes > limits.json_string_bytes) { + issues.push(issue("resource_limit_exceeded", keyPointer, "JSON object key exceeds resource limit", "budget")); + } + if (isMetadataPath(path) && keyBytes > limits.structural_id_bytes) { + issues.push(issue("resource_limit_exceeded", keyPointer, "metadata key exceeds structural ID resource limit", "budget")); + } + } + } + if (typeof child === "number") { + if (!Number.isFinite(child)) { + issues.push(issue("nonfinite_json_number", jsonPointer, "JSON number must be finite", "parse")); + } + } else if (typeof child === "string") { + if (hasUnpairedSurrogate(child)) { + issues.push(issue("invalid_utf8", jsonPointer, "string contains an unpaired surrogate", "parse")); + } else { + const bytes = Buffer.byteLength(child, "utf8"); + if (bytes > limits.json_string_bytes) { + issues.push(issue("resource_limit_exceeded", jsonPointer, "JSON string exceeds resource limit", "budget")); + } + if (isUriPath(path) && bytes > limits.uri_bytes) { + issues.push(issue("resource_limit_exceeded", jsonPointer, "URI exceeds resource limit", "budget")); + } + if (isIdentifierPath(path) && bytes > limits.structural_id_bytes) { + issues.push(issue("resource_limit_exceeded", jsonPointer, "identifier exceeds structural ID resource limit", "budget")); + } + } + } + } + return issues; +} + +export function resourceCountIssues( + value: unknown, + artifact: DocumentArtifact, + limits: SceneResourceLimits = BASE_LIMITS, +): ValidationIssue[] { + if (!isRecord(value)) return []; + const issues: ValidationIssue[] = []; + let collections: readonly (readonly [string, keyof SceneResourceLimits])[] = []; + if (artifact === "scene") { + collections = [ + ["definitions", "definitions"], ["nodes", "nodes"], + ["geometry_assets", "assets_per_kind"], ["edge_assets", "assets_per_kind"], ["entity_assets", "assets_per_kind"], + ["appearances", "appearances"], ["connectors", "connectors"], ["cameras", "cameras"], + ]; + } else if (artifact === "entities") { + collections = [ + ["entities", "entities_per_sidecar"], + ["face_groups", "entities_per_sidecar"], + ["edge_groups", "entities_per_sidecar"], + ]; + } else if (artifact === "presentation") { + collections = [["node_overrides", "nodes"], ["appearances", "appearances"], ["cameras", "cameras"]]; + } + for (const [field, limitName] of collections) { + const records = value[field]; + if (Array.isArray(records)) { + try { + preflightResourceCount(records.length, limitName, limits); + } catch { + issues.push(issue("resource_limit_exceeded", `/${field}`, `${field} count exceeds resource limit`, "budget")); + } + } + } + if (artifact === "scene") { + const nodes = Array.isArray(value.nodes) ? value.nodes : []; + nodes.forEach((node, index) => { + const source = isRecord(node) && isRecord(node.source) ? node.source : null; + if (source !== null && Array.isArray(source.component_path) && source.component_path.length > limits.hierarchy_depth) { + issues.push(issue("resource_limit_exceeded", `/nodes/${index}/source/component_path`, "hierarchy depth exceeds resource limit", "budget")); + } + }); + const connectors = Array.isArray(value.connectors) ? value.connectors.filter(isRecord) : []; + const forwardEdges = new Map(); + for (const connector of connectors) { + if (connector.anchor_kind === "forwarded" && isRecord(connector.forwarded_from)) { + forwardEdges.set(connector.connector_snapshot_id, connector.forwarded_from.source_connector_snapshot_id); + } + } + for (const start of forwardEdges.keys()) { + const seen = new Set(); + let current: unknown = start; + let depth = 0; + while (forwardEdges.has(current) && !seen.has(current)) { + seen.add(current); + current = forwardEdges.get(current); + depth += 1; + if (depth > limits.forwarded_connector_depth) { + issues.push(issue("resource_limit_exceeded", "/connectors", "forwarded connector depth exceeds resource limit", "budget")); + break; + } + } + } + } + return issues; +} + +function arrayOrderIssues(value: JsonRecord): ValidationIssue[] { + const issues: ValidationIssue[] = []; + for (const [path, child] of walk(value)) { + const key = path.at(-1); + if (typeof key === "string" && setArrayKeys.has(key) && Array.isArray(child) && child.every((item) => typeof item === "string")) { + const expected = [...new Set(child)].sort(utf8Compare); + if (child.length !== expected.length || child.some((item, index) => item !== expected[index])) { + issues.push(issue("array_order_invalid", pointer(path), "set-like array is not sorted and unique")); + } + } + } + for (const [collection, idKey] of Object.entries(collectionSortKeys)) { + const records = value[collection]; + if (Array.isArray(records) && records.every((item) => isRecord(item) && typeof item[idKey] === "string")) { + const ids = records.map((item) => (item as JsonRecord)[idKey] as string); + const expected = [...new Set(ids)].sort(utf8Compare); + if (ids.length !== expected.length || ids.some((id, index) => id !== expected[index])) { + issues.push(issue("array_order_invalid", `/${collection}`, `${collection} is not sorted and unique by ${idKey}`)); + } + } + } + return issues; +} + +function vec3(value: unknown): value is [number, number, number] { + return Array.isArray(value) && value.length === 3 && value.every((item) => typeof item === "number" && Number.isFinite(item)); +} + +function transformIssue(value: unknown, path: string): ValidationIssue | null { + if (!isRecord(value) || !vec3(value.origin) || !vec3(value.x_axis) || !vec3(value.y_axis) || !vec3(value.z_axis)) return null; + const origin = value.origin; + const axes = [value.x_axis, value.y_axis, value.z_axis]; + if (origin.some((component) => Math.abs(component) > 1e12)) return issue("transform_invalid", path, "transform origin exceeds the scene coordinate limit"); + const dot = (left: readonly number[], right: readonly number[]) => left.reduce((sum, item, index) => sum + item * right[index], 0); + if (axes.some((axis) => Math.abs(Math.sqrt(dot(axis, axis)) - 1) > 1e-12)) return issue("transform_invalid", path, "transform axes must be unit vectors within 1e-12"); + if (Math.abs(dot(axes[0], axes[1])) > 1e-12 || Math.abs(dot(axes[0], axes[2])) > 1e-12 || Math.abs(dot(axes[1], axes[2])) > 1e-12) return issue("transform_invalid", path, "transform axes must be pairwise orthogonal within 1e-12"); + const cross = [axes[0][1] * axes[1][2] - axes[0][2] * axes[1][1], axes[0][2] * axes[1][0] - axes[0][0] * axes[1][2], axes[0][0] * axes[1][1] - axes[0][1] * axes[1][0]]; + if (cross.some((component, index) => Math.abs(component - axes[2][index]) > 1e-12)) return issue("transform_invalid", path, "z_axis must equal cross(x_axis, y_axis) within 1e-12"); + return null; +} + +function analyticIssues(geometry: unknown, path: string): ValidationIssue[] { + if (!isRecord(geometry)) return []; + const directionFields: Record = { + line: ["direction"], circle: ["normal", "x_direction"], ellipse: ["normal", "x_direction"], + plane: ["normal", "x_direction"], cylinder: ["axis", "x_direction"], cone: ["axis", "x_direction"], + sphere: ["axis", "x_direction"], torus: ["axis", "x_direction"], + }; + const coordinateFields: Record = { + point: ["position"], line: ["origin"], circle: ["center"], ellipse: ["center"], + plane: ["origin"], cylinder: ["origin"], cone: ["origin"], sphere: ["center"], + torus: ["center"], + }; + const type = typeof geometry.type === "string" ? geometry.type : ""; + const names = directionFields[type] ?? []; + const issues: ValidationIssue[] = []; + for (const name of coordinateFields[type] ?? []) { + const coordinate = geometry[name]; + if (vec3(coordinate) && coordinate.some((component) => Math.abs(component) > 1e12)) { + issues.push(issue("analytic_geometry_invalid", `${path}/${name}`, "analytic coordinate exceeds the scene coordinate limit")); + } + } + if (!names.every((name) => vec3(geometry[name]))) return issues; + const vectors = names.map((name) => geometry[name] as [number, number, number]); + vectors.forEach((vector, index) => { + const norm = Math.sqrt(vector.reduce((sum, component) => sum + component * component, 0)); + if (Math.abs(norm - 1) > 1e-12) issues.push(issue("analytic_geometry_invalid", `${path}/${names[index]}`, "analytic direction must be a unit vector within 1e-12")); + }); + if (vectors.length === 2 && Math.abs(vectors[0].reduce((sum, component, index) => sum + component * vectors[1][index], 0)) > 1e-12) { + issues.push(issue("analytic_geometry_invalid", path, "analytic axis and x_direction must be orthogonal within 1e-12")); + } + return issues; +} + +function boundsIssues(bounds: unknown, path: string, point?: unknown): ValidationIssue[] { + if (!isRecord(bounds) || !vec3(bounds.min) || !vec3(bounds.max)) return []; + const minimum = bounds.min; + const maximum = bounds.max; + const issues: ValidationIssue[] = []; + if (minimum.some((low, index) => low > maximum[index])) { + issues.push(issue("bounds_invalid", path, "bounds min exceeds max")); + } + if ([...minimum, ...maximum].some((component) => Math.abs(component) > 1e12)) { + issues.push(issue("bounds_invalid", path, "bounds exceed the scene coordinate limit")); + } + if (vec3(point)) { + const maxAbs = Math.max(1, ...minimum.map(Math.abs), ...maximum.map(Math.abs), ...point.map(Math.abs)); + const epsilon = Math.max(1e-9, 1e-12 * maxAbs); + if (point.some((value, index) => value < minimum[index] - epsilon || value > maximum[index] + epsilon)) { + issues.push(issue("bounds_invalid", path, "point or centroid is outside bounds")); + } + } + return issues; +} + +function encodeSegment(value: string): string { + let result = ""; + for (const byte of Buffer.from(value, "utf8")) { + const safe = + (byte >= 0x41 && byte <= 0x5a) || + (byte >= 0x61 && byte <= 0x7a) || + (byte >= 0x30 && byte <= 0x39) || + byte === 0x2d || byte === 0x2e || byte === 0x5f || byte === 0x7e; + result += safe ? String.fromCharCode(byte) : `%${byte.toString(16).toUpperCase().padStart(2, "0")}`; + } + return result; +} + +function rootIdFromDefinition(definition: JsonRecord): unknown { + return isRecord(definition.source) ? definition.source.root_id : undefined; +} + +function setsEqual(left: ReadonlySet, right: ReadonlySet): boolean { + return left.size === right.size && [...left].every((value) => right.has(value)); +} + +function sceneSemanticIssues(scene: JsonRecord): ValidationIssue[] { + const issues: ValidationIssue[] = []; + if (scene.revision !== computeSceneRevision(scene)) issues.push(issue("revision_mismatch", "/revision", "scene revision does not match canonical draft hash")); + const source = isRecord(scene.source) ? scene.source : {}; + const sourceKind = source.kind; + const graphId = source.graph_id; + const sourceId = source.source_id; + const options = isRecord(scene.compile_options) ? scene.compile_options : {}; + const presentationSource = scene.presentation_source; + const embedSource = options.embed_source; + const embedded = Object.hasOwn(source, "embedded_artifact_uri"); + if (sourceKind === "manual" && (embedSource === true || embedded)) { + issues.push(issue("source_matrix_invalid", "/source", "manual source cannot be embedded")); + } else if ((sourceKind === "model" || sourceKind === "imported") && embedSource !== embedded) { + issues.push(issue("source_matrix_invalid", "/source", "embed_source does not match source embedding fields")); + } + const sourceFiles = Array.isArray(source.source_files) ? source.source_files.filter(isRecord) : []; + const sourcePaths = sourceFiles.map((record) => String(record.path ?? "")); + const expectedSourcePaths = [...new Set(sourcePaths)].sort(utf8Compare); + if (sourcePaths.some((path, index) => path !== expectedSourcePaths[index]) || sourcePaths.length !== expectedSourcePaths.length) { + issues.push(issue("array_order_invalid", "/source/source_files", "source_files is not sorted and unique by path")); + } + const casefoldSourcePaths = new Map(); + sourceFiles.forEach((record, index) => { + if (typeof record.path === "string") { + const segments = record.path.split("/"); + if (!sourcePathPattern.test(record.path) || segments.some((segment) => segment === "" || segment === "." || segment === "..")) { + issues.push(issue("source_matrix_invalid", `/source/source_files/${index}/path`, "source file path is not archive-safe")); + } + const folded = record.path.toLowerCase(); + const previous = casefoldSourcePaths.get(folded); + if (previous !== undefined && previous !== record.path) { + issues.push(issue("source_matrix_invalid", `/source/source_files/${index}/path`, "source file paths collide case-insensitively")); + } else { + casefoldSourcePaths.set(folded, record.path); + } + if (record.uri !== `sources/${record.path}`) { + issues.push(issue("source_matrix_invalid", `/source/source_files/${index}/uri`, "source file URI does not preserve the project-relative path")); + } + } + }); + const embedPresentation = options.embed_presentation; + if ((presentationSource === null || presentationSource === undefined) && embedPresentation === true) { + issues.push(issue("source_matrix_invalid", "/compile_options/embed_presentation", "presentation embedding requires presentation_source")); + } + if ( + isRecord(presentationSource) && + embedPresentation !== Object.hasOwn(presentationSource, "embedded_artifact_uri") + ) { + issues.push(issue("source_matrix_invalid", "/presentation_source", "embed_presentation does not match presentation embedding fields")); + } + + const definitions = Array.isArray(scene.definitions) ? scene.definitions.filter(isRecord) : []; + const nodes = Array.isArray(scene.nodes) ? scene.nodes.filter(isRecord) : []; + const appearances = Array.isArray(scene.appearances) ? scene.appearances.filter(isRecord) : []; + const definitionMap = new Map(definitions.map((record) => [record.definition_id, record])); + const nodeMap = new Map(nodes.map((record) => [record.node_id, record])); + const appearanceMap = new Map(appearances.map((record) => [record.appearance_id, record])); + const geometryMap = new Map((Array.isArray(scene.geometry_assets) ? scene.geometry_assets : []).filter(isRecord).map((record) => [record.asset_id, record])); + const edgeMap = new Map((Array.isArray(scene.edge_assets) ? scene.edge_assets : []).filter(isRecord).map((record) => [record.asset_id, record])); + const entityMap = new Map((Array.isArray(scene.entity_assets) ? scene.entity_assets : []).filter(isRecord).map((record) => [record.entity_asset_id, record])); + const validSources: Record> = { + model: { part: "product_model", assembly: "product_model", shape: "model_output" }, + manual: { part: "product_manual", assembly: "product_manual", shape: "manual" }, + imported: { shape: "imported" }, + }; + definitions.forEach((definition, index) => { + const path = `/definitions/${index}`; + const kind = definition.kind; + const nested = isRecord(definition.source) ? definition.source : {}; + const nestedKind = nested.kind; + const rootId = nested.root_id; + const validSource = typeof sourceKind === "string" && typeof kind === "string" + ? validSources[sourceKind]?.[kind] + : undefined; + if (nestedKind !== validSource) { + issues.push(issue("source_matrix_invalid", `${path}/source`, "definition source is incompatible with scene and definition kinds")); + } + if ((nestedKind === "product_model" || nestedKind === "model_output") && nested.graph_id !== graphId) { + issues.push(issue("source_matrix_invalid", `${path}/source/graph_id`, "definition graph_id differs from scene graph_id")); + } + if (nestedKind === "manual" && nested.source_id !== sourceId) { + issues.push(issue("source_matrix_invalid", `${path}/source/source_id`, "definition manual source_id differs from scene source_id")); + } + let expectedId: string; + if (nestedKind === "product_model" || nestedKind === "product_manual") { + const expectedSemantic = kind === "part" ? "Part" : "Assembly"; + if (nested.semantic_type !== expectedSemantic) { + issues.push(issue("source_matrix_invalid", `${path}/source/semantic_type`, "semantic_type differs from definition kind")); + } + expectedId = `definition/${String(rootId)}/${String(kind)}/${encodeSegment(String(nested.semantic_id ?? ""))}`; + } else if (nestedKind === "model_output") { + expectedId = `definition/${String(rootId)}/shape/model/${encodeSegment(String(nested.graph_id ?? ""))}/${encodeSegment(String(nested.node_id ?? ""))}/${String(nested.output_slot)}`; + } else if (nestedKind === "imported") { + expectedId = `definition/${String(rootId)}/shape/imported/${encodeSegment(String(nested.source_element_id ?? ""))}`; + } else { + expectedId = `definition/${String(rootId)}/shape/manual/${encodeSegment(String(nested.source_id ?? ""))}`; + } + if (definition.definition_id !== expectedId) { + issues.push(issue("source_matrix_invalid", `${path}/definition_id`, "definition_id does not match its source-derived structural ID")); + } + for (const [field, registry] of [ + ["geometry_asset_id", geometryMap], + ["edge_asset_id", edgeMap], + ["entity_asset_id", entityMap], + ["appearance_id", appearanceMap], + ] as const) { + const reference = definition[field]; + if (reference !== null && reference !== undefined && !registry.has(reference)) { + issues.push(issue("reference_missing", `${path}/${field}`, `referenced ${field} does not exist`)); + } + } + }); + + const roots: JsonRecord[] = []; + const siblingOrders = new Map(); + const referencedDefinitions = new Set(); + nodes.forEach((node, index) => { + const path = `/nodes/${index}`; + const definition = definitionMap.get(node.definition_id); + if (definition === undefined) { + issues.push(issue("reference_missing", `${path}/definition_id`, "node definition does not exist")); + return; + } + referencedDefinitions.add(node.definition_id); + const nodeSource = isRecord(node.source) ? node.source : {}; + const nodeKind = nodeSource.kind; + const rootId = nodeSource.root_id; + const expectedNodeKind = definition.kind === "part" || definition.kind === "assembly" ? "product_occurrence" : "shape_root"; + if (nodeKind !== expectedNodeKind || rootId !== rootIdFromDefinition(definition)) { + issues.push(issue("source_matrix_invalid", `${path}/source`, "node source kind/root does not match definition")); + } + let expectedNodeId: string; + let expectedParent: string | null; + if (nodeKind === "product_occurrence") { + const componentPath = Array.isArray(nodeSource.component_path) ? nodeSource.component_path : []; + const encodedPath = componentPath.map((segment) => `/${encodeSegment(String(segment))}`).join(""); + const encodedParentPath = componentPath.slice(0, -1).map((segment) => `/${encodeSegment(String(segment))}`).join(""); + expectedNodeId = `instance/${String(rootId)}${encodedPath}`; + expectedParent = componentPath.length === 0 ? null : `instance/${String(rootId)}${encodedParentPath}`; + } else { + expectedNodeId = `instance/${String(rootId)}`; + expectedParent = null; + } + if (node.node_id !== expectedNodeId) { + issues.push(issue("hierarchy_invalid", `${path}/node_id`, "node_id does not match source-derived structural ID")); + } + if (node.parent_node_id !== expectedParent) { + issues.push(issue("hierarchy_invalid", `${path}/parent_node_id`, "parent_node_id does not match component path")); + } + if (node.parent_node_id === null) roots.push(node); + else if (!nodeMap.has(node.parent_node_id)) { + issues.push(issue("reference_missing", `${path}/parent_node_id`, "parent node does not exist")); + } + const orders = siblingOrders.get(node.parent_node_id) ?? []; + orders.push(node.order); + siblingOrders.set(node.parent_node_id, orders); + const override = node.appearance_override_id; + if (node.selectable !== true) { + issues.push(issue("source_matrix_invalid", `${path}/selectable`, "node selectability must use the deterministic true default")); + } + if (presentationSource === null || presentationSource === undefined) { + if (node.visible !== true) { + issues.push(issue("source_matrix_invalid", `${path}/visible`, "node visibility requires presentation_source")); + } + if (override !== null && override !== undefined) { + issues.push(issue("source_matrix_invalid", `${path}/appearance_override_id`, "appearance override requires presentation_source")); + } + } + if (override !== null && override !== undefined && !appearanceMap.has(override)) { + issues.push(issue("reference_missing", `${path}/appearance_override_id`, "appearance override does not exist")); + } + if (definition.kind === "assembly" && override !== null && override !== undefined) { + issues.push(issue("source_matrix_invalid", `${path}/appearance_override_id`, "assembly occurrence cannot carry appearance override")); + } + const transform = transformIssue(node.transform, `${path}/transform`); + if (transform !== null) issues.push(transform); + }); + for (const [parent, orders] of siblingOrders) { + if ( + orders.every((order) => typeof order === "number" && Number.isInteger(order)) && + [...orders].sort((left, right) => (left as number) - (right as number)).some((order, index) => order !== index) + ) { + issues.push(issue("hierarchy_invalid", "/nodes", `sibling order under ${String(parent)} is not continuous`)); + } + } + const rootIds = roots.map((root) => isRecord(root.source) ? root.source.root_id : undefined); + const expectedRoots = [...rootIds].sort((left, right) => utf8Compare(String(left), String(right))); + const actualRoots = [...roots] + .sort((left, right) => (left.order as number) - (right.order as number)) + .map((root) => isRecord(root.source) ? root.source.root_id : undefined); + if (actualRoots.some((rootId, index) => rootId !== expectedRoots[index])) { + issues.push(issue("hierarchy_invalid", "/nodes", "root order does not match unsigned UTF-8 root_id order")); + } + const definitionIds = new Set(definitionMap.keys()); + if (definitionIds.size > 0 || nodeMap.size > 0) { + if (!setsEqual(referencedDefinitions, definitionIds)) { + issues.push(issue("hierarchy_invalid", "/nodes", "every definition must be instantiated by at least one node")); + } + const definitionRootIds = new Set([...definitionMap.values()].map(rootIdFromDefinition)); + if ([...definitionRootIds].some((rootId) => roots.filter((root) => isRecord(root.source) && root.source.root_id === rootId).length !== 1)) { + issues.push(issue("hierarchy_invalid", "/nodes", "each definition root_id must have exactly one root occurrence")); + } + } + + const usedAssets = new Set(); + for (const definition of definitions) { + for (const field of ["geometry_asset_id", "edge_asset_id", "entity_asset_id"]) { + const reference = definition[field]; + if (reference !== null && reference !== undefined) usedAssets.add(reference); + } + } + const declaredAssets = new Set([...geometryMap.keys(), ...edgeMap.keys(), ...entityMap.keys()]); + if (!setsEqual(usedAssets, declaredAssets)) { + issues.push(issue("reference_missing", "/definitions", "asset records must be referenced exactly by definitions")); + } + for (const [collection, uriKind] of [["geometry_assets", "geometry"], ["edge_assets", "edges"]] as const) { + const assets = Array.isArray(scene[collection]) ? (scene[collection] as unknown[]).filter(isRecord) : []; + assets.forEach((asset, index) => { + const path = `/${collection}/${index}`; + if (asset.asset_id !== asset.content_hash) { + issues.push(issue("source_matrix_invalid", `${path}/asset_id`, "asset_id must equal content_hash")); + } + const assetId = String(asset.asset_id ?? ""); + const digest = assetId.startsWith("sha256:") ? assetId.slice("sha256:".length) : assetId; + if (asset.uri !== `${uriKind}/sha256-${digest}.glb`) { + issues.push(issue("source_matrix_invalid", `${path}/uri`, "asset URI does not match content hash")); + } + const tessellation = isRecord(asset.tessellation) ? asset.tessellation : {}; + if (tessellation.linear_tolerance !== options.linear_tolerance) { + issues.push(issue("source_matrix_invalid", `${path}/tessellation/linear_tolerance`, "asset tolerance differs from compile options")); + } + if (collection === "geometry_assets" && tessellation.angular_tolerance !== options.angular_tolerance) { + issues.push(issue("source_matrix_invalid", `${path}/tessellation/angular_tolerance`, "asset tolerance differs from compile options")); + } + issues.push(...boundsIssues(asset.scene_local_bounds, `${path}/scene_local_bounds`)); + }); + } + const entityAssets = Array.isArray(scene.entity_assets) ? scene.entity_assets.filter(isRecord) : []; + entityAssets.forEach((asset, index) => { + const path = `/entity_assets/${index}`; + if (asset.entity_asset_id !== asset.content_hash) { + issues.push(issue("source_matrix_invalid", `${path}/entity_asset_id`, "entity_asset_id must equal content_hash")); + } + const assetId = String(asset.entity_asset_id ?? ""); + const digest = assetId.startsWith("sha256:") ? assetId.slice("sha256:".length) : assetId; + if (asset.uri !== `entities/sha256-${digest}.json`) { + issues.push(issue("source_matrix_invalid", `${path}/uri`, "entity URI does not match content hash")); + } + }); + appearances.forEach((appearance, index) => { + const path = `/appearances/${index}`; + const draft = { ...appearance }; + delete draft.appearance_id; + const expected = `appearance/evaluated/${sha256(canonicalJsonBytes(draft)).slice("sha256:".length)}`; + if (appearance.appearance_id !== expected) { + issues.push(issue("source_matrix_invalid", `${path}/appearance_id`, "appearance_id does not match content-derived identity")); + } + const appearanceSource = appearance.source; + if (isRecord(appearanceSource)) { + if (appearanceSource.kind === "product_material" && sourceKind !== "model" && sourceKind !== "manual") { + issues.push(issue("source_matrix_invalid", `${path}/source`, "product material source is incompatible with scene source")); + } + if ( + appearanceSource.kind === "product_material" && + !definitions.some((definition) => + definition.kind === "part" && + definition.appearance_id === appearance.appearance_id && + rootIdFromDefinition(definition) === appearanceSource.root_id + ) + ) { + issues.push(issue("source_matrix_invalid", `${path}/source/root_id`, "product material appearance provenance does not match a Part definition root")); + } + if ( + appearanceSource.kind === "presentation" && + (!isRecord(presentationSource) || appearanceSource.presentation_id !== presentationSource.presentation_id) + ) { + issues.push(issue("source_matrix_invalid", `${path}/source`, "presentation appearance source does not match presentation_source")); + } + } + }); + + const connectors = Array.isArray(scene.connectors) ? scene.connectors.filter(isRecord) : []; + if (sourceKind === "imported" && connectors.length > 0) { + issues.push(issue("source_matrix_invalid", "/connectors", "imported scene connectors must be empty")); + } + const connectorMap = new Map(connectors.map((record) => [record.connector_snapshot_id, record])); + const connectorIdsByOwner = new Map>(); + const forwardEdges = new Map(); + connectors.forEach((connector, index) => { + const path = `/connectors/${index}`; + const owner = definitionMap.get(connector.owner_definition_id); + if (owner === undefined) { + issues.push(issue("reference_missing", `${path}/owner_definition_id`, "connector owner definition does not exist")); + return; + } + const ownerIds = connectorIdsByOwner.get(connector.owner_definition_id) ?? new Set(); + if (ownerIds.has(connector.connector_id)) { + issues.push(issue("id_duplicate", `${path}/connector_id`, "connector_id is duplicated within owner definition")); + } + ownerIds.add(connector.connector_id); + connectorIdsByOwner.set(connector.owner_definition_id, ownerIds); + const ownerSource = isRecord(owner.source) ? owner.source : {}; + const ownerSemantic = ownerSource.semantic_id === undefined || ownerSource.semantic_id === null + ? "None" + : String(ownerSource.semantic_id); + const expectedSnapshot = `connector/${String(ownerSource.root_id)}/${String(owner.kind)}/${encodeSegment(ownerSemantic)}/${encodeSegment(String(connector.connector_id ?? ""))}`; + if (connector.connector_snapshot_id !== expectedSnapshot) { + issues.push(issue("connector_invalid", `${path}/connector_snapshot_id`, "connector snapshot ID does not match owner and connector IDs")); + } + const anchorKind = connector.anchor_kind; + if (anchorKind === "geometry" && owner.kind !== "part") { + issues.push(issue("connector_invalid", `${path}/owner_definition_id`, "geometry connector owner must be a Part")); + } + if (anchorKind === "placement" && owner.kind !== "part" && owner.kind !== "assembly") { + issues.push(issue("connector_invalid", `${path}/owner_definition_id`, "connector owner must be a Product definition")); + } + if (anchorKind === "forwarded" && owner.kind !== "assembly") { + issues.push(issue("connector_invalid", `${path}/owner_definition_id`, "forwarded connector owner must be an Assembly")); + } + const connectorSource = connector.source; + if (sourceKind === "model") { + if (!isRecord(connectorSource) || connectorSource.kind !== "model_operation" || connectorSource.graph_id !== graphId) { + issues.push(issue("connector_invalid", `${path}/source`, "model connector must use the scene graph model_operation source")); + } else if (connectorSource.output_slot !== 0) { + issues.push(issue("connector_invalid", `${path}/source/output_slot`, "connector producer output_slot must be 0")); + } + } else if ( + sourceKind === "manual" && + (!isRecord(connectorSource) || + Object.keys(connectorSource).length !== 2 || + connectorSource.kind !== "manual" || + connectorSource.source_id !== sourceId) + ) { + issues.push(issue("connector_invalid", `${path}/source`, "manual connector source must equal the top-level source_id")); + } + const transform = transformIssue(connector.local_transform, `${path}/local_transform`); + if (transform !== null) issues.push(transform); + if (anchorKind === "forwarded") { + const forwarded = isRecord(connector.forwarded_from) ? connector.forwarded_from : {}; + const sourceSnapshot = forwarded.source_connector_snapshot_id; + const sourceConnector = connectorMap.get(sourceSnapshot); + if ( + sourceConnector === undefined || + sourceConnector.owner_definition_id !== forwarded.source_definition_id || + sourceConnector.connector_id !== forwarded.source_connector_id + ) { + issues.push(issue("connector_invalid", `${path}/forwarded_from`, "forwarded connector source snapshot ownership is invalid")); + } + forwardEdges.set(connector.connector_snapshot_id, sourceSnapshot); + if (forwarded.offset !== null && forwarded.offset !== undefined) { + const offset = transformIssue(forwarded.offset, `${path}/forwarded_from/offset`); + if (offset !== null) issues.push(offset); + } + if (owner.kind === "assembly") { + const ownerNodes = nodes.filter((node) => + node.definition_id === connector.owner_definition_id && + isRecord(node.source) && + node.source.kind === "product_occurrence" + ); + const sourceChildren: JsonRecord[] = []; + for (const ownerNode of ownerNodes) { + const ownerNodeSource = ownerNode.source as JsonRecord; + const expectedPath = [ + ...(Array.isArray(ownerNodeSource.component_path) ? ownerNodeSource.component_path : []), + forwarded.source_component_id, + ]; + const matches = nodes.filter((node) => + isRecord(node.source) && + node.source.kind === "product_occurrence" && + node.source.root_id === ownerNodeSource.root_id && + Array.isArray(node.source.component_path) && + node.source.component_path.length === expectedPath.length && + node.source.component_path.every((component, componentIndex) => component === expectedPath[componentIndex]) && + node.parent_node_id === ownerNode.node_id + ); + if (matches.length !== 1 || matches[0].definition_id !== forwarded.source_definition_id) { + issues.push(issue("connector_invalid", `${path}/forwarded_from`, "forwarded connector source component is not one exact direct child")); + } else { + sourceChildren.push(matches[0]); + } + } + if (sourceChildren.length > 1) { + const expected = canonicalJsonBytes(sourceChildren[0].transform); + if (sourceChildren.slice(1).some((child) => !canonicalJsonBytes(child.transform).equals(expected))) { + issues.push(issue("connector_invalid", `${path}/forwarded_from`, "forwarded connector source child transforms differ between owner occurrences")); + } + } + if (sourceChildren.length > 0 && sourceConnector !== undefined) { + const childTransform = sourceChildren[0].transform; + const sourceTransform = sourceConnector.local_transform; + const offset = forwarded.offset ?? { + origin: [0, 0, 0], + x_axis: [1, 0, 0], + y_axis: [0, 1, 0], + z_axis: [0, 0, 1], + }; + if ( + isRecord(childTransform) && transformIssue(childTransform, "") === null && + isRecord(sourceTransform) && transformIssue(sourceTransform, "") === null && + isRecord(offset) && transformIssue(offset, "") === null && + isRecord(connector.local_transform) && transformIssue(connector.local_transform, "") === null + ) { + const expected = composeRigidTransforms(composeRigidTransforms(childTransform, sourceTransform), offset); + if (!rigidTransformsEqual(connector.local_transform, expected)) { + issues.push(issue("connector_invalid", `${path}/local_transform`, "forwarded connector transform does not match child, source, and offset composition")); + } + } + } + } + } + }); + for (const start of forwardEdges.keys()) { + const seen = new Set(); + let current: unknown = start; + while (forwardEdges.has(current)) { + if (seen.has(current)) { + issues.push(issue("connector_invalid", "/connectors", "forwarded connector graph contains a cycle")); + break; + } + seen.add(current); + current = forwardEdges.get(current); + } + } + const cameras = Array.isArray(scene.cameras) ? scene.cameras.filter(isRecord) : []; + cameras.forEach((camera, index) => { + const path = `/cameras/${index}`; + if (presentationSource === null || presentationSource === undefined) { + issues.push(issue("source_matrix_invalid", path, "evaluated scene camera requires presentation_source")); + } + if (camera.parent_node_id !== null && camera.parent_node_id !== undefined && !nodeMap.has(camera.parent_node_id)) { + issues.push(issue("reference_missing", `${path}/parent_node_id`, "camera parent node does not exist")); + } + if (typeof camera.near === "number" && typeof camera.far === "number" && camera.far <= camera.near) { + issues.push(issue("bounds_invalid", `${path}/far`, "camera far must exceed near")); + } + const transform = transformIssue(camera.transform, `${path}/transform`); + if (transform !== null) issues.push(transform); + }); + return issues; +} + +function entitySemanticIssues(asset: JsonRecord): ValidationIssue[] { + const issues: ValidationIssue[] = []; + const entityValues = Array.isArray(asset.entities) ? asset.entities : []; + const entities = entityValues.filter(isRecord); + const byId = new Map(entities.map((entity) => [entity.entity_id, entity])); + if (byId.size !== entityValues.length) { + issues.push(issue("id_duplicate", "/entities", "entity IDs must be unique")); + } + for (const kind of ["solid", "face", "edge", "vertex"]) { + const actual = new Set( + [...byId.entries()] + .filter(([entityId, entity]) => entity.kind === kind && typeof entityId === "string") + .map(([entityId]) => entityId), + ); + const expected = new Set([...actual].map((_id, index) => `entity/${kind}/${index}`)); + if (!setsEqual(actual, expected)) { + issues.push(issue("entity_topology_invalid", "/entities", `${kind} entity IDs must use dense zero-based ordinals`)); + } + } + const kindGeometry: Record> = { + solid: new Set(["brep_solid"]), + face: new Set(["plane", "cylinder", "cone", "sphere", "torus", "bspline_surface", "other_surface"]), + edge: new Set(["line", "circle", "ellipse", "bspline_curve", "other_curve"]), + vertex: new Set(["point"]), + }; + const expectedPropertyKeys: Record> = { + solid: new Set(["quality", "bounds", "volume", "surface_area", "centroid"]), + face: new Set(["quality", "bounds", "area", "centroid", "orientation"]), + edge: new Set(["quality", "bounds", "length", "centroid"]), + vertex: new Set(["quality", "bounds", "position"]), + }; + entities.forEach((entity, index) => { + const path = `/entities/${index}`; + const entityId = entity.entity_id; + const kind = entity.kind; + if (typeof entityId === "string" && !entityId.startsWith(`entity/${String(kind)}/`)) { + issues.push(issue("entity_topology_invalid", `${path}/entity_id`, "entity ID kind differs from entity kind")); + } + const geometry = isRecord(entity.geometry) ? entity.geometry : {}; + if (typeof kind === "string" && !kindGeometry[kind]?.has(geometry.type)) { + issues.push(issue("entity_topology_invalid", `${path}/geometry/type`, "geometry variant is incompatible with entity kind")); + } + issues.push(...analyticIssues(geometry, `${path}/geometry`)); + const properties = isRecord(entity.properties) ? entity.properties : {}; + if (typeof kind === "string" && !setsEqual(new Set(Object.keys(properties)), expectedPropertyKeys[kind] ?? new Set())) { + issues.push(issue("entity_topology_invalid", `${path}/properties`, "property record is incompatible with entity kind")); + } + const parents = Array.isArray(entity.parent_entity_ids) ? entity.parent_entity_ids : []; + const children = Array.isArray(entity.child_entity_ids) ? entity.child_entity_ids : []; + if ( + kind === "solid" && + (parents.length > 0 || children.length === 0 || children.some((child) => byId.get(child)?.kind !== "face")) + ) { + issues.push(issue("entity_topology_invalid", path, "solid must own one or more faces and have no parent")); + } + if ( + kind === "face" && + (parents.length !== 1 || byId.get(parents[0])?.kind !== "solid" || children.length === 0 || children.some((child) => byId.get(child)?.kind !== "edge")) + ) { + issues.push(issue("entity_topology_invalid", path, "face must have one solid parent and one or more edge children")); + } + if ( + kind === "edge" && + (parents.length === 0 || parents.some((parent) => byId.get(parent)?.kind !== "face") || + (children.length !== 1 && children.length !== 2) || children.some((child) => byId.get(child)?.kind !== "vertex")) + ) { + issues.push(issue("entity_topology_invalid", path, "edge must have face parents and one or two vertex children")); + } + if ( + kind === "vertex" && + (parents.length === 0 || parents.some((parent) => byId.get(parent)?.kind !== "edge") || children.length > 0) + ) { + issues.push(issue("entity_topology_invalid", path, "vertex must have edge parents and no children")); + } + for (const parent of parents) { + const target = byId.get(parent); + if (target === undefined) issues.push(issue("reference_missing", `${path}/parent_entity_ids`, "parent entity does not exist")); + else if (!Array.isArray(target.child_entity_ids) || !target.child_entity_ids.includes(entityId)) issues.push(issue("entity_topology_invalid", `${path}/parent_entity_ids`, "parent relation is not reciprocal")); + } + for (const child of children) { + const target = byId.get(child); + if (target === undefined) issues.push(issue("reference_missing", `${path}/child_entity_ids`, "child entity does not exist")); + else if (!Array.isArray(target.parent_entity_ids) || !target.parent_entity_ids.includes(entityId)) issues.push(issue("entity_topology_invalid", `${path}/child_entity_ids`, "child relation is not reciprocal")); + } + const source = entity.source; + if (isRecord(source) && source.kind === "model_topology") { + const expectedTopologyKind: Record = { solid: "SOLID", face: "FACE", edge: "EDGE", vertex: "VERTEX" }; + if (typeof kind === "string" && source.topology_kind !== expectedTopologyKind[kind]) { + issues.push(issue("source_matrix_invalid", `${path}/source/topology_kind`, "topology source kind differs from entity kind")); + } + } + const frame = entity.sdk_connector_frame; + if (kind === "solid" && frame !== null) { + issues.push(issue("connector_invalid", `${path}/sdk_connector_frame`, "solid connector frame must be null")); + } + if ((kind === "face" || kind === "vertex") && frame === null) { + issues.push(issue("connector_invalid", `${path}/sdk_connector_frame`, "face and vertex connector frames must be present")); + } + if (frame !== null && frame !== undefined) { + const frameIssue = transformIssue(frame, `${path}/sdk_connector_frame`); + if (frameIssue !== null) issues.push(frameIssue); + } + const status = entity.render_status; + if (kind !== "edge" && status !== "rendered") { + issues.push(issue("entity_topology_invalid", `${path}/render_status`, "only edges may be degenerate")); + } + if (kind === "solid" && entity.connector_binding_status !== "not_applicable") { + issues.push(issue("connector_invalid", `${path}/connector_binding_status`, "solid binding status must be not_applicable")); + } + if ( + kind === "edge" && + frame === null && + !new Set(["frame_undefined", "owner_not_part", "source_not_model"]).has(entity.connector_binding_status as string) + ) { + issues.push(issue("connector_invalid", `${path}/connector_binding_status`, "edge without frame cannot be supported")); + } + if (kind === "vertex") { + const point = geometry.position; + if (!equalJson(properties.position, point)) { + issues.push(issue("bounds_invalid", `${path}/properties/position`, "vertex property position differs from geometry point")); + } + issues.push(...boundsIssues(properties.bounds, `${path}/properties/bounds`, point)); + } else { + issues.push(...boundsIssues(properties.bounds, `${path}/properties/bounds`, properties.centroid)); + } + }); + const faceIds = new Set([...byId.entries()].filter(([, entity]) => entity.kind === "face").map(([entityId]) => entityId)); + const renderedEdgeIds = new Set([...byId.entries()].filter(([, entity]) => entity.kind === "edge" && entity.render_status === "rendered").map(([entityId]) => entityId)); + const degenerateEdgeIds = new Set([...byId.entries()].filter(([, entity]) => entity.kind === "edge" && entity.render_status === "degenerate").map(([entityId]) => entityId)); + for (const [groupsKey, expectedIds, divisor] of [["face_groups", faceIds, 3], ["edge_groups", renderedEdgeIds, 2]] as const) { + const groups = Array.isArray(asset[groupsKey]) ? (asset[groupsKey] as unknown[]).filter(isRecord) : []; + const groupIds = groups.map((group) => group.entity_id); + if (!setsEqual(new Set(groupIds), expectedIds) || new Set(groupIds).size !== groupIds.length) { + issues.push(issue("entity_range_invalid", `/${groupsKey}`, `${groupsKey} do not map exactly one group per rendered entity`)); + } + let first = 0; + groups.forEach((group, index) => { + const path = `/${groupsKey}/${index}`; + if (group.group_id !== index || group.first_index !== first) issues.push(issue("entity_range_invalid", path, "group IDs and ranges must be continuous")); + if (typeof group.index_count === "number") { + if (group.index_count <= 0 || group.index_count % divisor !== 0) issues.push(issue("entity_range_invalid", `${path}/index_count`, "group count has invalid primitive cardinality")); + first += group.index_count; + } + }); + } + const edgeGroups = Array.isArray(asset.edge_groups) ? asset.edge_groups.filter(isRecord) : []; + if (edgeGroups.some((group) => degenerateEdgeIds.has(group.entity_id))) { + issues.push(issue("entity_range_invalid", "/edge_groups", "degenerate edges must not have render groups")); + } + return issues; +} + +function presentationSemanticIssues(value: JsonRecord): ValidationIssue[] { + const issues: ValidationIssue[] = []; + const appearances = Array.isArray(value.appearances) ? value.appearances.filter(isRecord) : []; + const appearanceNames = appearances.map((appearance) => appearance.name); + if (new Set(appearanceNames).size !== appearanceNames.length) { + issues.push(issue("id_duplicate", "/appearances", "presentation appearance names must be unique")); + } + const appearanceNameSet = new Set(appearanceNames); + const overrides = Array.isArray(value.node_overrides) ? value.node_overrides.filter(isRecord) : []; + const nodeIds = overrides.map((override) => override.node_id); + if (new Set(nodeIds).size !== nodeIds.length) { + issues.push(issue("id_duplicate", "/node_overrides", "node overrides must target unique node IDs")); + } + overrides.forEach((override, index) => { + if (override.appearance_name !== undefined && !appearanceNameSet.has(override.appearance_name)) { + issues.push(issue("presentation_reference_invalid", `/node_overrides/${index}/appearance_name`, "appearance_name does not exist")); + } + }); + const cameras = Array.isArray(value.cameras) ? value.cameras.filter(isRecord) : []; + const cameraNames = cameras.map((camera) => camera.name); + if (new Set(cameraNames).size !== cameraNames.length) { + issues.push(issue("id_duplicate", "/cameras", "presentation camera names must be unique")); + } + cameras.forEach((camera, index) => { + if (typeof camera.near === "number" && typeof camera.far === "number" && camera.far <= camera.near) { + issues.push(issue("bounds_invalid", `/cameras/${index}/far`, "camera far must exceed near")); + } + const transform = transformIssue(camera.transform, `/cameras/${index}/transform`); + if (transform !== null) issues.push(transform); + }); + return issues; +} + +function connectorBindingSemanticIssues(value: JsonRecord): ValidationIssue[] { + const issues: ValidationIssue[] = []; + const sourceModel = isRecord(value.source_model) ? value.source_model : {}; + const target = isRecord(value.target) ? value.target : {}; + const expectedSource = isRecord(target.expected_source) ? target.expected_source : {}; + if (sourceModel.graph_id !== expectedSource.graph_id) { + issues.push(issue("source_matrix_invalid", "/target/expected_source/graph_id", "target graph differs from source model graph")); + } + if (typeof target.entity_id === "string" && target.entity_id.startsWith("entity/vertex/") && target.flip !== false) { + issues.push(issue("connector_invalid", "/target/flip", "vertex connector target requires flip=false")); + } + return issues; +} + +function normalizedProductSemanticIssues(value: JsonRecord): ValidationIssue[] { + const issues: ValidationIssue[] = []; + const connectors = Array.isArray(value.connectors) ? value.connectors.filter(isRecord) : []; + const connectorIds = connectors.map((connector) => connector.connector_id); + if (new Set(connectorIds).size !== connectorIds.length) { + issues.push(issue("id_duplicate", "/connectors", "connector IDs must be unique")); + } + if (value.kind !== "assembly") return issues; + + const components = Array.isArray(value.components) ? value.components.filter(isRecord) : []; + const componentIds = components.map((component) => component.component_id); + if (new Set(componentIds).size !== componentIds.length) { + issues.push(issue("id_duplicate", "/components", "component IDs must be unique")); + } + const componentSet = new Set(componentIds); + const grounded = Array.isArray(value.grounded_component_ids) ? value.grounded_component_ids : []; + if (grounded.some((componentId) => !componentSet.has(componentId))) { + issues.push(issue("reference_missing", "/grounded_component_ids", "grounded component does not exist")); + } + const constraints = Array.isArray(value.constraints) ? value.constraints.filter(isRecord) : []; + const constraintIds = constraints.map((constraint) => constraint.constraint_id); + if (new Set(constraintIds).size !== constraintIds.length) { + issues.push(issue("id_duplicate", "/constraints", "constraint IDs must be unique")); + } + constraints.forEach((constraint, index) => { + for (const referenceKey of ["connector_a", "connector_b"]) { + const reference = constraint[referenceKey]; + if (isRecord(reference) && !componentSet.has(reference.component_id)) { + issues.push(issue("reference_missing", `/constraints/${index}/${referenceKey}/component_id`, "constraint component does not exist")); + } + } + for (const limitKey of ["distance_limit", "angle_limit"]) { + const limit = constraint[limitKey]; + if ( + isRecord(limit) && + typeof limit.lower_value === "number" && + typeof limit.upper_value === "number" && + limit.lower_value > limit.upper_value + ) { + issues.push(issue("bounds_invalid", `/constraints/${index}/${limitKey}`, "constraint lower limit exceeds upper limit")); + } + } + }); + return issues; +} + +function validateDocument( + value: unknown, + artifact: DocumentArtifact, + validator: ValidateFunction, + semanticIssues: (parsed: JsonRecord) => ValidationIssue[], + checkArrayOrder = false, + limits: SceneResourceLimits = BASE_LIMITS, +): ValidationReport { + const serializedInput = typeof value === "string" || value instanceof Uint8Array; + const parsed = parseInput(value, serializedInput); + if (parsed.value === undefined && parsed.issues.length > 0) return report(parsed.issues, artifact); + const issues = [ + ...serializedSizeIssues(value, artifact, limits), + ...parsed.issues, + ...jsonResourceIssues(parsed.value, limits), + ...resourceCountIssues(parsed.value, artifact, limits), + ]; + if (issues.some((item) => item.code === "resource_limit_exceeded" && item.message.includes("nesting"))) { + return report(issues, artifact); + } + issues.push(...structuralIssues(parsed.value, validator)); + if (isRecord(parsed.value) && !issues.some((item) => item.phase === "parse" || item.phase === "structure")) { + if (checkArrayOrder) issues.push(...arrayOrderIssues(parsed.value)); + issues.push(...semanticIssues(parsed.value)); + } + return report(issues, artifact); +} + +export function validateSceneManifest(value: unknown, limits: SceneResourceLimits = BASE_LIMITS): ValidationReport { + return validateDocument(value, "scene", validateSceneSchema, sceneSemanticIssues, true, limits); +} + +export function validateEntityAsset(value: unknown, limits: SceneResourceLimits = BASE_LIMITS): ValidationReport { + return validateDocument(value, "entities", validateEntitiesSchema, entitySemanticIssues, true, limits); +} + +export function validatePresentation(value: unknown, limits: SceneResourceLimits = BASE_LIMITS): ValidationReport { + return validateDocument(value, "presentation", validatePresentationSchema, presentationSemanticIssues, false, limits); +} + +export function validateConnectorBinding(value: unknown, limits: SceneResourceLimits = BASE_LIMITS): ValidationReport { + return validateDocument(value, "connector_binding", validateConnectorBindingSchema, connectorBindingSemanticIssues, false, limits); +} + +export function validateNormalizedProduct(value: unknown, limits: SceneResourceLimits = BASE_LIMITS): ValidationReport { + return validateDocument(value, "normalized_product", validateNormalizedProductSchema, normalizedProductSemanticIssues, false, limits); +} + +type PackageMediaRole = "geometry" | "edge" | "entity" | "model_source" | "imported_source" | "python_source" | "presentation"; + +interface PackageRecord extends JsonRecord { + byte_length: unknown; + content_hash: unknown; + media_role: PackageMediaRole; +} + +function packageRecords(scene: JsonRecord): { records: Map; issues: ValidationIssue[] } { + const result = new Map(); + const issues: ValidationIssue[] = []; + const add = (uri: unknown, byteLength: unknown, contentHash: unknown, mediaRole: PackageMediaRole): void => { + if (typeof uri !== "string") return; + const record: PackageRecord = { byte_length: byteLength, content_hash: contentHash, media_role: mediaRole }; + const previous = result.get(uri); + if ( + previous !== undefined && + (previous.byte_length !== record.byte_length || + previous.content_hash !== record.content_hash || + previous.media_role !== record.media_role) + ) { + issues.push(issue("package_member_set_invalid", "", "manifest records for one URI disagree on hash, length, or media role", "package")); + return; + } + result.set(uri, record); + }; + for (const [key, mediaRole] of [["geometry_assets", "geometry"], ["edge_assets", "edge"], ["entity_assets", "entity"]] as const) { + const records = Array.isArray(scene[key]) ? scene[key] : []; + for (const value of records) if (isRecord(value)) add(value.uri, value.byte_length, value.content_hash, mediaRole); + } + if (isRecord(scene.source) && typeof scene.source.embedded_artifact_uri === "string") { + add( + scene.source.embedded_artifact_uri, + scene.source.embedded_artifact_byte_length, + scene.source.artifact_hash, + scene.source.kind === "model" ? "model_source" : "imported_source", + ); + const sourceFiles = Array.isArray(scene.source.source_files) ? scene.source.source_files : []; + for (const sourceFile of sourceFiles) { + if (isRecord(sourceFile)) add(sourceFile.uri, sourceFile.byte_length, sourceFile.content_hash, "python_source"); + } + } + if (isRecord(scene.presentation_source) && typeof scene.presentation_source.embedded_artifact_uri === "string") { + add( + scene.presentation_source.embedded_artifact_uri, + scene.presentation_source.embedded_artifact_byte_length, + scene.presentation_source.artifact_hash, + "presentation", + ); + } + return { records: result, issues }; +} + +export interface PackageBudgetContributions { + sceneJsonBytes: number; + glbDecodedBufferBytes: number; + entityJsonBytes: number; + otherImmutableJsonBytes: number; + entityCount: number; + entityVertexCount: number; + triangleVertexCount: number; + triangleCount: number; + lineVertexCount: number; + lineSegmentCount: number; +} + +export interface PackageBudgetTotals { + staticDecodedBufferBytes: number; + entityCount: number; + triangleVertexTotal: number; + triangleTotal: number; + lineVertexTotal: number; + lineSegmentTotal: number; +} + +export interface PackageBudgetLimits { + static_decoded_buffer_bytes: number; + entities_total: number; + triangle_vertices_total: number; + triangles_total: number; + line_vertices_total: number; + line_segments_total: number; +} + +export function computePackageBudgetTotals(input: PackageBudgetContributions): PackageBudgetTotals { + return { + staticDecodedBufferBytes: + input.sceneJsonBytes + + input.glbDecodedBufferBytes + + input.entityJsonBytes + + input.otherImmutableJsonBytes + + 2 * input.entityVertexCount * 16, + entityCount: input.entityCount, + triangleVertexTotal: input.triangleVertexCount, + triangleTotal: input.triangleCount, + lineVertexTotal: input.lineVertexCount, + lineSegmentTotal: input.lineSegmentCount, + }; +} + +export function packageBudgetIssues( + totals: PackageBudgetTotals, + limits: PackageBudgetLimits = BASE_LIMITS, +): ValidationIssue[] { + const issues: ValidationIssue[] = []; + if (totals.staticDecodedBufferBytes > limits.static_decoded_buffer_bytes) { + issues.push(issue("static_buffer_limit_exceeded", "", "static decoded buffer formula exceeds resource limit", "budget")); + } + if (totals.entityCount > limits.entities_total) { + issues.push(issue("resource_limit_exceeded", "/entity_assets", "total entity count exceeds resource limit", "budget")); + } + for (const [total, limit, message] of [ + [totals.triangleVertexTotal, limits.triangle_vertices_total, "total triangle GLB vertex count exceeds resource limit"], + [totals.triangleTotal, limits.triangles_total, "total triangle count exceeds resource limit"], + [totals.lineVertexTotal, limits.line_vertices_total, "total line GLB vertex count exceeds resource limit"], + [totals.lineSegmentTotal, limits.line_segments_total, "total line segment count exceeds resource limit"], + ] as const) { + if (total > limit) issues.push(issue("resource_limit_exceeded", "", message, "budget")); + } + return issues; +} + + +const sourceDecoder = new TextDecoder("utf-8", { fatal: true }); + +function sourceSegment( + content: string, + line: number, + column: number, + endLine: number, + endColumn: number, +): string | null { + const normalized = content.replace(/\r\n?/g, "\n"); + const lines = normalized.split("\n"); + if (normalized.endsWith("\n")) lines.pop(); + if (lines.length === 0 || line < 1 || endLine < line || endLine > lines.length) return null; + const startText = Array.from(lines[line - 1]); + const endText = Array.from(lines[endLine - 1]); + if (column < 0 || endColumn < 0 || column > startText.length || endColumn > endText.length || (line === endLine && endColumn < column)) return null; + if (line === endLine) return startText.slice(column, endColumn).join(""); + const parts = [startText.slice(column).join("")]; + for (let index = line; index < endLine - 1; index += 1) parts.push(lines[index]); + parts.push(endText.slice(0, endColumn).join("")); + return parts.join("\n"); +} + +function expectedCallsiteId(source: JsonRecord): string { + const material = [ + source.path ?? "", + source.line, + source.column, + source.end_line, + source.end_column, + source.call_text, + ].map(String).join("\x1f"); + return `callsite_${sha256(Buffer.from(material, "utf8")).slice("sha256:".length, "sha256:".length + 16)}`; +} + +function operationSourceIssues( + source: unknown, + path: string, + sourceFiles: ReadonlyMap, +): ValidationIssue[] { + if (!isRecord(source)) return [issue("source_matrix_invalid", path, "operation source must be an object")]; + const issues: ValidationIssue[] = []; + const keys = Object.keys(source); + if (keys.length !== operationSourceKeys.size || keys.some((key) => !operationSourceKeys.has(key))) { + issues.push(issue("source_matrix_invalid", path, "operation source fields do not match source mapping schema 1.0")); + } + const line = source.line; + const column = source.column; + const endLine = source.end_line; + const endColumn = source.end_column; + const assignmentTargets = source.assignment_targets; + const valid = + source.schema_version === "1.0" && + (source.path_kind === "project_relative" || source.path_kind === "unresolved") && + Number.isSafeInteger(line) && (line as number) >= 1 && + Number.isSafeInteger(column) && (column as number) >= 0 && + Number.isSafeInteger(endLine) && (endLine as number) >= (line as number) && + Number.isSafeInteger(endColumn) && (endColumn as number) >= 0 && + typeof source.call_text === "string" && + typeof source.callsite_id === "string" && /^callsite_[0-9a-f]{16}$/.test(source.callsite_id) && + Array.isArray(assignmentTargets) && assignmentTargets.every((value) => typeof value === "string" && value.length > 0); + if (!valid) { + issues.push(issue("source_matrix_invalid", path, "operation source values do not match source mapping schema 1.0")); + return issues; + } + if (source.path_kind === "unresolved") { + if (source.path !== null) issues.push(issue("source_matrix_invalid", `${path}/path`, "unresolved operation source path must be null")); + } else if (typeof source.path !== "string" || !sourceFiles.has(source.path)) { + issues.push(issue("source_matrix_invalid", `${path}/path`, "operation source path does not resolve to an embedded source file")); + } else { + const segment = sourceSegment(sourceFiles.get(source.path)!, line as number, column as number, endLine as number, endColumn as number); + if (segment === null || segment !== source.call_text) { + issues.push(issue("source_matrix_invalid", path, "operation source span does not match embedded source text")); + } + } + if (source.callsite_id !== expectedCallsiteId(source)) { + issues.push(issue("source_matrix_invalid", `${path}/callsite_id`, "operation callsite_id does not match its canonical source span")); + } + return issues; +} + +function embeddedModelIssues( + scene: JsonRecord, + payload: Buffer, + sourceFiles: ReadonlyMap, + entityPayloads: ReadonlyMap, +): ValidationIssue[] { + const prefix = "/model/model.json"; + let modelValue: unknown; + try { + modelValue = parseStrictJson(payload); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (error instanceof DuplicateKeyError) return [issue("duplicate_json_key", "", message, "parse")]; + if (error instanceof TypeError && message.toLowerCase().includes("utf")) return [issue("invalid_utf8", prefix, message, "parse")]; + return [issue("schema_invalid", prefix, message, "structure")]; + } + if (!isRecord(modelValue)) return [issue("schema_invalid", prefix, "embedded model must be a JSON object", "structure")]; + const model = modelValue; + if (model.schema_version !== "2.0" || !isRecord(model.graph)) { + return [issue("schema_invalid", prefix, "embedded model does not contain a schema 2.0 graph", "structure")]; + } + const graph = model.graph; + const sceneSource = isRecord(scene.source) ? scene.source : {}; + if (graph.graph_id !== sceneSource.graph_id) { + return [issue("source_matrix_invalid", `${prefix}/graph/graph_id`, "embedded model graph_id differs from scene source")]; + } + if (!Array.isArray(graph.nodes)) { + return [issue("schema_invalid", `${prefix}/graph/nodes`, "embedded model graph nodes must be an array", "structure")]; + } + + const issues: ValidationIssue[] = []; + const nodeMap = new Map(); + const nodeIndexes = new Map(); + graph.nodes.forEach((nodeValue, index) => { + const nodePath = `${prefix}/graph/nodes/${index}`; + if (!isRecord(nodeValue)) { + issues.push(issue("schema_invalid", nodePath, "model graph node must be an object", "structure")); + return; + } + const node = nodeValue; + const validNode = + typeof node.node_id === "string" && node.node_id.length > 0 && + typeof node.op === "string" && + isRecord(node.params) && + Array.isArray(node.inputs) && node.inputs.every((value) => typeof value === "string") && + Number.isSafeInteger(node.output_count) && (node.output_count as number) >= 1; + if (!validNode) { + issues.push(issue("schema_invalid", nodePath, "model graph node has an invalid Viewer operation shape", "structure")); + return; + } + const nodeId = node.node_id as string; + if (nodeMap.has(nodeId)) { + issues.push(issue("id_duplicate", `${nodePath}/node_id`, "model graph node_id is duplicated")); + return; + } + nodeMap.set(nodeId, node); + nodeIndexes.set(nodeId, index); + if (Object.hasOwn(node, "source")) issues.push(...operationSourceIssues(node.source, `${nodePath}/source`, sourceFiles)); + }); + for (const [nodeId, node] of nodeMap) { + for (const inputId of node.inputs as string[]) { + if (!nodeMap.has(inputId)) issues.push(issue("reference_missing", `${prefix}/graph/nodes/${nodeIndexes.get(nodeId)!}/inputs`, "model graph input references a missing node")); + } + } + if (!Array.isArray(model.leaf_ids) || !model.leaf_ids.every((value) => typeof value === "string" && nodeMap.has(value))) { + issues.push(issue("reference_missing", `${prefix}/leaf_ids`, "model leaf_ids must resolve to graph nodes")); + } + + const validateReference = (value: unknown, path: string): void => { + if (!isRecord(value)) return; + const node = typeof value.node_id === "string" ? nodeMap.get(value.node_id) : undefined; + if (node === undefined || !Number.isSafeInteger(value.output_slot) || (value.output_slot as number) < 0 || (value.output_slot as number) >= (node.output_count as number)) { + issues.push(issue("reference_missing", path, "model source does not resolve to an embedded graph output")); + } + }; + (Array.isArray(scene.definitions) ? scene.definitions : []).forEach((definitionValue, index) => { + if (!isRecord(definitionValue) || !isRecord(definitionValue.source)) return; + if (definitionValue.source.kind === "product_model" || definitionValue.source.kind === "model_output") validateReference(definitionValue.source, `/definitions/${index}/source`); + }); + (Array.isArray(scene.connectors) ? scene.connectors : []).forEach((connectorValue, index) => { + if (isRecord(connectorValue) && isRecord(connectorValue.source) && connectorValue.source.kind === "model_operation") validateReference(connectorValue.source, `/connectors/${index}/source`); + }); + for (const [uri, entityPayload] of entityPayloads) { + (Array.isArray(entityPayload.entities) ? entityPayload.entities : []).forEach((entityValue, index) => { + if (!isRecord(entityValue) || !isRecord(entityValue.source)) return; + if (entityValue.source.kind === "model_output" || entityValue.source.kind === "model_topology") validateReference(entityValue.source, `/${uri}/entities/${index}/source`); + }); + } + return issues; +} + +function embeddedPresentationIssues( + scene: JsonRecord, + payload: Buffer, + uri: string, + limits: SceneResourceLimits, +): ValidationIssue[] { + const prefix = `/${uri}`; + const presentationReport = validatePresentation(payload, limits); + const issues = presentationReport.issues.map((item) => { + const code = item.code === "presentation_reference_invalid" ? "source_matrix_invalid" : item.code; + const path = hasRootPointerPolicy(code) ? "" : `${prefix}${item.path}`; + return issue(code, path, item.message, item.phase); + }); + if (presentationReport.issues.some((item) => item.phase === "parse" || item.phase === "structure")) return issues; + let parsed: unknown; + try { parsed = parseCanonicalJson(payload); } catch { return issues; } + if (!isRecord(parsed) || !isRecord(scene.presentation_source)) return issues; + const presentation = parsed; + const presentationSource = scene.presentation_source; + if (presentation.presentation_id !== presentationSource.presentation_id) { + issues.push(issue("source_matrix_invalid", `${prefix}/presentation_id`, "presentation_id differs from manifest presentation_source")); + } + if (presentation.source_scene_id !== scene.scene_id) { + issues.push(issue("source_matrix_invalid", `${prefix}/source_scene_id`, "presentation source_scene_id differs from manifest scene_id")); + } + + const sceneAppearances = (Array.isArray(scene.appearances) ? scene.appearances : []).filter(isRecord); + const expectedAppearanceIds = new Map(); + const authoredAppearances = (Array.isArray(presentation.appearances) ? presentation.appearances : []).filter(isRecord); + authoredAppearances.forEach((authored, index) => { + const evaluated: JsonRecord = { + alpha_mode: authored.alpha_mode, + base_color: authored.base_color, + double_sided: authored.double_sided, + edge_color: authored.edge_color, + metallic: authored.metallic, + name: authored.name, + roughness: authored.roughness, + sdk_metadata: {}, + source: { + appearance_name: authored.name, + kind: "presentation", + presentation_id: presentation.presentation_id, + }, + }; + const appearanceId = `appearance/evaluated/${sha256(canonicalJsonBytes(evaluated)).slice("sha256:".length)}`; + expectedAppearanceIds.set(authored.name, appearanceId); + const expected = { appearance_id: appearanceId, ...evaluated }; + if (sceneAppearances.filter((appearance) => equalJson(appearance, expected)).length !== 1) { + issues.push(issue("source_matrix_invalid", `${prefix}/appearances/${index}`, "presentation appearance does not resolve to exactly one evaluated scene appearance")); + } + }); + const expectedAppearanceIdSet = new Set(expectedAppearanceIds.values()); + sceneAppearances.forEach((appearance, index) => { + if ( + isRecord(appearance.source) && + appearance.source.kind === "presentation" && + !expectedAppearanceIdSet.has(appearance.appearance_id as string) + ) { + issues.push(issue("source_matrix_invalid", `/appearances/${index}/source/appearance_name`, "evaluated presentation appearance does not resolve to an authored appearance")); + } + }); + + const sceneNodes = (Array.isArray(scene.nodes) ? scene.nodes : []).filter(isRecord); + const nodeMap = new Map(sceneNodes.map((node) => [node.node_id, node])); + const definitionMap = new Map( + (Array.isArray(scene.definitions) ? scene.definitions : []).filter(isRecord).map((definition) => [definition.definition_id, definition]), + ); + const overrides = (Array.isArray(presentation.node_overrides) ? presentation.node_overrides : []).filter(isRecord); + const overrideMap = new Map(overrides.map((override) => [override.node_id, override])); + overrides.forEach((override, index) => { + const node = nodeMap.get(override.node_id); + if (node === undefined) { + issues.push(issue("source_matrix_invalid", `${prefix}/node_overrides/${index}/node_id`, "node override target does not exist in the scene")); + return; + } + if (override.appearance_name !== undefined) { + const definition = definitionMap.get(node.definition_id); + if (definition === undefined || (definition.kind !== "part" && definition.kind !== "shape")) { + issues.push(issue("source_matrix_invalid", `${prefix}/node_overrides/${index}/appearance_name`, "appearance override target is not a renderable Part or Shape")); + } + } + }); + sceneNodes.forEach((node, index) => { + const override = overrideMap.get(node.node_id); + const expectedVisible = override?.visible ?? true; + const appearanceName = override?.appearance_name; + const expectedAppearanceId = appearanceName === undefined ? undefined : expectedAppearanceIds.get(appearanceName); + if (node.visible !== expectedVisible) { + issues.push(issue("source_matrix_invalid", `/nodes/${index}/visible`, "node visibility does not match the embedded presentation")); + } + if (expectedAppearanceId !== undefined && node.appearance_override_id !== expectedAppearanceId) { + issues.push(issue("source_matrix_invalid", `/nodes/${index}/appearance_override_id`, "node appearance override does not resolve the embedded presentation name")); + } + if (appearanceName === undefined && node.appearance_override_id !== null) { + issues.push(issue("source_matrix_invalid", `/nodes/${index}/appearance_override_id`, "node appearance override is not authored by the embedded presentation")); + } + }); + + const sceneCameras = (Array.isArray(scene.cameras) ? scene.cameras : []).filter(isRecord); + const expectedCameraIds = new Set(); + const authoredCameras = (Array.isArray(presentation.cameras) ? presentation.cameras : []).filter(isRecord); + authoredCameras.forEach((authored, index) => { + if (authored.parent_node_id !== null && !nodeMap.has(authored.parent_node_id)) { + issues.push(issue("source_matrix_invalid", `${prefix}/cameras/${index}/parent_node_id`, "presentation camera parent does not exist in the scene")); + } + const cameraId = `camera/${String(presentation.presentation_id)}/${encodeSegment(String(authored.name ?? ""))}`; + expectedCameraIds.add(cameraId); + const expected = { camera_id: cameraId, ...authored }; + if (sceneCameras.filter((camera) => equalJson(camera, expected)).length !== 1) { + issues.push(issue("source_matrix_invalid", `${prefix}/cameras/${index}`, "presentation camera does not resolve to exactly one evaluated scene camera")); + } + }); + sceneCameras.forEach((camera, index) => { + if (!expectedCameraIds.has(camera.camera_id as string)) { + issues.push(issue("source_matrix_invalid", `/cameras/${index}`, "evaluated scene camera is not authored by the embedded presentation")); + } + }); + return issues; +} + +function transformedBounds(info: GlbInfo, matrix: unknown): JsonRecord | null { + if (!Array.isArray(matrix) || matrix.length !== 16 || !matrix.every((item) => typeof item === "number")) return null; + const [minimum, maximum] = info.positionBounds; + const points: number[][] = []; + for (const x of [minimum[0], maximum[0]]) for (const y of [minimum[1], maximum[1]]) for (const z of [minimum[2], maximum[2]]) { + points.push([matrix[0] * x + matrix[1] * y + matrix[2] * z + matrix[3], matrix[4] * x + matrix[5] * y + matrix[6] * z + matrix[7], matrix[8] * x + matrix[9] * y + matrix[10] * z + matrix[11]]); + } + return { min: [0, 1, 2].map((axis) => Math.min(...points.map((point) => point[axis]))), max: [0, 1, 2].map((axis) => Math.max(...points.map((point) => point[axis]))) }; +} + +function equalJson(left: unknown, right: unknown): boolean { + return Buffer.compare(canonicalJsonBytes(left), canonicalJsonBytes(right)) === 0; +} + +export function validateScenePackage( + sceneInput: unknown, + blobsInput: ReadonlyMap | Record, + limits: SceneResourceLimits = BASE_LIMITS, +): ValidationReport { + const manifest = validateSceneManifest(sceneInput, limits); + const issues = [...manifest.issues]; + const parsed = parseInput(sceneInput, typeof sceneInput === "string" || sceneInput instanceof Uint8Array); + if (!isRecord(parsed.value) || issues.some((item) => item.phase === "parse" || item.phase === "structure")) return report(issues, "package"); + const scene = parsed.value; + const blobs = blobsInput instanceof Map ? new Map(blobsInput) : new Map(Object.entries(blobsInput)); + const packageRecordResult = packageRecords(scene); + const records = packageRecordResult.records; + issues.push(...packageRecordResult.issues); + if (blobs.size !== records.size || [...blobs.keys()].some((name) => !records.has(name))) issues.push(issue("package_member_set_invalid", "", "blob keys do not exactly equal manifest URI references", "package")); + const glbs = new Map(); + const entities = new Map(); + let embeddedModel: Buffer | null = null; + const sourceFiles = new Map(); + const sourcePathByUri = new Map(); + if (isRecord(scene.source) && Array.isArray(scene.source.source_files)) { + for (const sourceFile of scene.source.source_files) { + if (isRecord(sourceFile) && typeof sourceFile.uri === "string" && typeof sourceFile.path === "string") { + sourcePathByUri.set(sourceFile.uri, sourceFile.path); + } + } + } + const budget: PackageBudgetContributions = { + sceneJsonBytes: canonicalJsonBytes(scene).length, + glbDecodedBufferBytes: 0, + entityJsonBytes: 0, + otherImmutableJsonBytes: 0, + entityCount: 0, + entityVertexCount: 0, + triangleVertexCount: 0, + triangleCount: 0, + lineVertexCount: 0, + lineSegmentCount: 0, + }; + for (const [uri, asset] of records) { + const value = blobs.get(uri); + if (value === undefined) continue; + const roleLimit = asset.media_role === "entity" + ? limits.entity_json_bytes + : asset.media_role === "model_source" + ? limits.model_json_bytes + : asset.media_role === "presentation" + ? limits.presentation_json_bytes + : limits.one_member_bytes; + if (value.byteLength > Math.min(limits.one_member_bytes, roleLimit)) { + issues.push(issue("resource_limit_exceeded", `/${uri}`, "package member bytes exceed resource limit", "budget")); + continue; + } + const payload = Buffer.from(value); + if (payload.length !== asset.byte_length) issues.push(issue("blob_length_mismatch", `/${uri}`, "blob length differs from manifest", "package")); + if (sha256(payload) !== asset.content_hash) issues.push(issue("blob_hash_mismatch", `/${uri}`, "blob hash differs from manifest", "package")); + if (asset.media_role === "geometry" || asset.media_role === "edge") { + try { + const info = preflightGlb(payload, asset.media_role === "geometry" ? "triangle" : "line", limits); + glbs.set(uri, info); + budget.glbDecodedBufferBytes += info.decodedBufferBytes; + if (info.kind === "triangle") { + budget.triangleVertexCount += info.vertexCount; + budget.triangleCount += info.primitiveCount; + } else { + budget.lineVertexCount += info.vertexCount; + budget.lineSegmentCount += info.primitiveCount; + } + } + catch (error) { issues.push(issue("glb_profile_invalid", `/${uri}`, error instanceof Error ? error.message : String(error), "package")); } + } else if (asset.media_role === "entity") { + const entityReport = validateEntityAsset(payload, limits); + for (const item of entityReport.issues) { + issues.push(issue( + item.code, + hasRootPointerPolicy(item.code) ? "" : `/${uri}${item.path}`, + item.message, + item.phase, + )); + } + try { + const value = parseCanonicalJson(payload); + if (isRecord(value)) { + entities.set(uri, value); + budget.entityJsonBytes += payload.length; + const sidecarEntities = Array.isArray(value.entities) ? value.entities : []; + budget.entityCount += sidecarEntities.length; + budget.entityVertexCount += sidecarEntities.filter((entity) => isRecord(entity) && entity.kind === "vertex").length; + } + } catch { /* Reported above. */ } + } else if (asset.media_role === "presentation") { + issues.push(...embeddedPresentationIssues(scene, payload, uri, limits)); + budget.otherImmutableJsonBytes += payload.length; + } else if (asset.media_role === "model_source") { + embeddedModel = payload; + budget.otherImmutableJsonBytes += payload.length; + } else if (asset.media_role === "python_source") { + try { + const sourcePath = sourcePathByUri.get(uri); + if (sourcePath !== undefined) sourceFiles.set(sourcePath, sourceDecoder.decode(payload)); + } catch (error) { + issues.push(issue("invalid_utf8", `/${uri}`, error instanceof Error ? error.message : String(error), "parse")); + } + budget.otherImmutableJsonBytes += payload.length; + } + } + if (embeddedModel !== null) issues.push(...embeddedModelIssues(scene, embeddedModel, sourceFiles, entities)); + issues.push(...packageBudgetIssues(computePackageBudgetTotals(budget), limits)); + const geometryById = new Map((Array.isArray(scene.geometry_assets) ? scene.geometry_assets : []).filter(isRecord).map((item) => [item.asset_id, item])); + const edgesById = new Map((Array.isArray(scene.edge_assets) ? scene.edge_assets : []).filter(isRecord).map((item) => [item.asset_id, item])); + const entityById = new Map((Array.isArray(scene.entity_assets) ? scene.entity_assets : []).filter(isRecord).map((item) => [item.entity_asset_id, item])); + const definitions = Array.isArray(scene.definitions) ? scene.definitions : []; + definitions.forEach((value, definitionIndex) => { + if (!isRecord(value) || value.kind === "assembly") return; + const entityRecord = entityById.get(value.entity_asset_id); + const geometryRecord = geometryById.get(value.geometry_asset_id); + const edgeRecord = edgesById.get(value.edge_asset_id); + for (const [record, expectedKind] of [[geometryRecord, "geometry"], [edgeRecord, "edge"]] as const) { + if (!isRecord(record) || typeof record.uri !== "string") continue; + const info = glbs.get(record.uri); + if (info !== undefined) { + const expected = transformedBounds(info, record.asset_to_scene); + if (expected === null || !equalJson(record.scene_local_bounds, expected)) issues.push(issue("bounds_invalid", `/definitions/${definitionIndex}`, `${expectedKind} asset scene_local_bounds differ from transformed GLB bounds`)); + } + } + if (!isRecord(entityRecord) || typeof entityRecord.uri !== "string") return; + const payload = entities.get(entityRecord.uri); + if (payload === undefined) return; + if (payload.definition_id !== value.definition_id || payload.geometry_asset_id !== value.geometry_asset_id || payload.edge_asset_id !== value.edge_asset_id) issues.push(issue("reference_missing", `/definitions/${definitionIndex}/entity_asset_id`, "entity sidecar ownership triple differs from definition")); + if ( + !isRecord(payload.geometry_engine) || + !isRecord(scene.generator) || + payload.geometry_engine.version !== scene.generator.ocp_version + ) { + issues.push(issue("source_matrix_invalid", `/definitions/${definitionIndex}/entity_asset_id`, "entity geometry engine version differs from manifest generator")); + } + const sceneSourceKind = isRecord(scene.source) ? scene.source.kind : undefined; + const allowedEntitySources: Record> = { + model: new Set(["model_output", "model_topology"]), + imported: new Set(["imported_primitive", "unbound"]), + manual: new Set(["unbound"]), + }; + const definitionSource = isRecord(value.source) ? value.source : {}; + const sidecarEntities = Array.isArray(payload.entities) ? payload.entities : []; + sidecarEntities.forEach((entityValue, entityIndex) => { + if (!isRecord(entityValue)) return; + const entityPath = `/${entityRecord.uri}/entities/${entityIndex}`; + const entitySource = isRecord(entityValue.source) ? entityValue.source : null; + if ( + entitySource === null || + typeof entitySource.kind !== "string" || + typeof sceneSourceKind !== "string" || + !allowedEntitySources[sceneSourceKind]?.has(entitySource.kind) + ) { + issues.push(issue("source_matrix_invalid", `${entityPath}/source`, "entity source is incompatible with scene source")); + } else if ( + sceneSourceKind === "model" && + ["graph_id", "node_id", "output_slot"].some( + (field) => entitySource[field] !== definitionSource[field], + ) + ) { + issues.push(issue("source_matrix_invalid", `${entityPath}/source`, "entity model source differs from owning definition output")); + } + + let expectedStatus: string | null = null; + if (entityValue.kind === "solid") expectedStatus = "not_applicable"; + else if (value.kind !== "part") expectedStatus = "owner_not_part"; + else if (sceneSourceKind !== "model") expectedStatus = "source_not_model"; + else if (entityValue.sdk_connector_frame === null) expectedStatus = "frame_undefined"; + if (expectedStatus !== null && entityValue.connector_binding_status !== expectedStatus) { + issues.push(issue("connector_invalid", `${entityPath}/connector_binding_status`, `connector binding status must be ${expectedStatus}`)); + } + }); + const faceTotal = (Array.isArray(payload.face_groups) ? payload.face_groups : []).filter(isRecord).reduce((sum, group) => sum + (typeof group.index_count === "number" ? group.index_count : 0), 0); + const edgeTotal = (Array.isArray(payload.edge_groups) ? payload.edge_groups : []).filter(isRecord).reduce((sum, group) => sum + (typeof group.index_count === "number" ? group.index_count : 0), 0); + const geometryInfo = isRecord(geometryRecord) && typeof geometryRecord.uri === "string" ? glbs.get(geometryRecord.uri) : undefined; + const edgeInfo = isRecord(edgeRecord) && typeof edgeRecord.uri === "string" ? glbs.get(edgeRecord.uri) : undefined; + if (geometryInfo !== undefined && faceTotal !== geometryInfo.indexCount) issues.push(issue("entity_range_invalid", `/definitions/${definitionIndex}/entity_asset_id`, "face groups do not partition triangle GLB indices")); + if (edgeInfo !== undefined && edgeTotal !== edgeInfo.indexCount) issues.push(issue("entity_range_invalid", `/definitions/${definitionIndex}/entity_asset_id`, "edge groups do not partition line GLB indices")); + }); + return report(issues, "package"); +} diff --git a/SimpleCADAPI/scene-contract/src/zip.ts b/SimpleCADAPI/scene-contract/src/zip.ts new file mode 100644 index 0000000..27b67ea --- /dev/null +++ b/SimpleCADAPI/scene-contract/src/zip.ts @@ -0,0 +1,278 @@ +import { inflateRawSync } from "node:zlib"; +import { + BASE_LIMITS, + preflightAggregateCompressionRatio, + preflightInputArchiveSize, + preflightMemberCompressionRatio, + type SceneResourceLimits, +} from "./resources.js"; + +export interface ArchiveInfo { + members: ReadonlyMap; + inputSize: number; + canonicalSize: number; + usedDeflate: boolean; +} + +interface Entry { + name: string; + method: number; + crc32: number; + compressedSize: number; + uncompressedSize: number; + localOffset: number; +} + +const memberPattern = /^[A-Za-z0-9][A-Za-z0-9._/-]{0,1023}$/; +function crc32Table(): Uint32Array { + const table = new Uint32Array(256); + for (let index = 0; index < 256; index += 1) { + let value = index; + for (let bit = 0; bit < 8; bit += 1) value = (value & 1) !== 0 ? 0xedb88320 ^ (value >>> 1) : value >>> 1; + table[index] = value >>> 0; + } + return table; +} + +const crcTable = crc32Table(); + +function crc32(data: Uint8Array): number { + let crc = 0xffffffff; + for (const byte of data) crc = crcTable[(crc ^ byte) & 0xff] ^ (crc >>> 8); + return (crc ^ 0xffffffff) >>> 0; +} + +function ascii(raw: Uint8Array, context: string): string { + if (raw.some((byte) => byte > 0x7f)) throw new Error("archive member names must be ASCII"); + return Buffer.from(raw).toString("ascii"); +} + +function validateName(name: string, seen: Set): Buffer { + const encoded = Buffer.from(name, "ascii"); + if (encoded.toString("ascii") !== name) throw new Error("archive member names must be ASCII"); + if (!memberPattern.test(name)) throw new Error(`invalid archive member name: ${JSON.stringify(name)}`); + if (name.split("/").some((segment) => segment === "" || segment === "." || segment === "..")) { + throw new Error(`invalid archive member path segment: ${JSON.stringify(name)}`); + } + const folded = name.toLowerCase(); + if (seen.has(folded)) throw new Error(`duplicate or case-colliding archive member: ${JSON.stringify(name)}`); + seen.add(folded); + return encoded; +} + +export function canonicalArchiveSize(sizes: ReadonlyMap): number { + let total = 22; + for (const [name, size] of sizes) total += 76 + 2 * Buffer.byteLength(name, "ascii") + size; + return total; +} + +export function preflightArchiveMemberSizes( + sizes: ReadonlyMap, + limits: SceneResourceLimits = BASE_LIMITS, +): number { + if (sizes.size === 0 || !sizes.has("scene.json")) throw new Error("scene package must contain scene.json"); + if (sizes.size > limits.zip_members) throw new Error("archive member count exceeds resource limit"); + const seen = new Set(); + let total = 0; + for (const [name, size] of sizes) { + validateName(name, seen); + if (!Number.isSafeInteger(size) || size < 0) throw new Error(`invalid member size for ${JSON.stringify(name)}`); + if (size > limits.one_member_bytes) throw new Error(`archive member exceeds resource limit: ${JSON.stringify(name)}`); + if (name === "scene.json" && size > limits.scene_json_bytes) throw new Error("scene.json exceeds resource limit"); + if (name.startsWith("entities/") && size > limits.entity_json_bytes) throw new Error(`entity sidecar exceeds resource limit: ${JSON.stringify(name)}`); + if (name === "model/model.json" && size > limits.model_json_bytes) throw new Error("embedded model.json exceeds resource limit"); + if (name === "presentation/presentation.json" && size > limits.presentation_json_bytes) throw new Error("presentation JSON exceeds resource limit"); + total += size; + if (total > limits.total_uncompressed_bytes) throw new Error("total uncompressed bytes exceed resource limit"); + } + const result = canonicalArchiveSize(sizes); + if (result > limits.canonical_archive_bytes) throw new Error("canonical stored archive exceeds resource limit"); + if (sizes.size > 0xffff || result > 0xffffffff) throw new Error("archive would require ZIP64"); + return result; +} + +export function canonicalZipBytes( + input: ReadonlyMap | Record, + limits: SceneResourceLimits = BASE_LIMITS, +): Buffer { + const members = input instanceof Map ? new Map(input) : new Map(Object.entries(input)); + const payloads = new Map([...members].map(([name, value]) => [name, Buffer.from(value)])); + const canonicalSize = preflightArchiveMemberSizes(new Map([...payloads].map(([name, value]) => [name, value.length])), limits); + const localParts: Buffer[] = []; + const centralParts: Buffer[] = []; + let localOffset = 0; + for (const name of [...payloads.keys()].sort((left, right) => Buffer.compare(Buffer.from(left, "ascii"), Buffer.from(right, "ascii")))) { + const nameBytes = Buffer.from(name, "ascii"); + const payload = payloads.get(name)!; + const crc = crc32(payload); + const local = Buffer.alloc(30); + local.writeUInt32LE(0x04034b50, 0); + local.writeUInt16LE(20, 4); + local.writeUInt16LE(0x0800, 6); + local.writeUInt16LE(0, 8); + local.writeUInt16LE(0, 10); + local.writeUInt16LE(0x0021, 12); + local.writeUInt32LE(crc, 14); + local.writeUInt32LE(payload.length, 18); + local.writeUInt32LE(payload.length, 22); + local.writeUInt16LE(nameBytes.length, 26); + local.writeUInt16LE(0, 28); + localParts.push(local, nameBytes, payload); + const central = Buffer.alloc(46); + central.writeUInt32LE(0x02014b50, 0); + central.writeUInt16LE(0x0314, 4); + central.writeUInt16LE(20, 6); + central.writeUInt16LE(0x0800, 8); + central.writeUInt16LE(0, 10); + central.writeUInt16LE(0, 12); + central.writeUInt16LE(0x0021, 14); + central.writeUInt32LE(crc, 16); + central.writeUInt32LE(payload.length, 20); + central.writeUInt32LE(payload.length, 24); + central.writeUInt16LE(nameBytes.length, 28); + central.writeUInt16LE(0, 30); + central.writeUInt16LE(0, 32); + central.writeUInt16LE(0, 34); + central.writeUInt16LE(0, 36); + central.writeUInt32LE(0x81a40000, 38); + central.writeUInt32LE(localOffset, 42); + centralParts.push(central, nameBytes); + localOffset += local.length + nameBytes.length + payload.length; + } + const centralBytes = Buffer.concat(centralParts); + const eocd = Buffer.alloc(22); + eocd.writeUInt32LE(0x06054b50, 0); + eocd.writeUInt16LE(0, 4); + eocd.writeUInt16LE(0, 6); + eocd.writeUInt16LE(payloads.size, 8); + eocd.writeUInt16LE(payloads.size, 10); + eocd.writeUInt32LE(centralBytes.length, 12); + eocd.writeUInt32LE(localOffset, 16); + eocd.writeUInt16LE(0, 20); + const result = Buffer.concat([...localParts, centralBytes, eocd]); + if (result.length !== canonicalSize) throw new Error("canonical ZIP size formula disagrees with encoder"); + return result; +} + +export function preflightZipBytes(data: Uint8Array, limits: SceneResourceLimits = BASE_LIMITS): ArchiveInfo { + preflightInputArchiveSize(data.byteLength, limits); + const raw = Buffer.isBuffer(data) ? data : Buffer.from(data); + if (raw.length < 22) throw new Error("truncated ZIP archive"); + const eocdOffset = raw.length - 22; + if (raw.readUInt32LE(eocdOffset) !== 0x06054b50 || raw.readUInt16LE(eocdOffset + 20) !== 0) { + throw new Error("ZIP EOCD must be final and have no comment"); + } + const disk = raw.readUInt16LE(eocdOffset + 4); + const centralDisk = raw.readUInt16LE(eocdOffset + 6); + const diskCount = raw.readUInt16LE(eocdOffset + 8); + const totalCount = raw.readUInt16LE(eocdOffset + 10); + const centralSize = raw.readUInt32LE(eocdOffset + 12); + const centralOffset = raw.readUInt32LE(eocdOffset + 16); + if (disk !== 0 || centralDisk !== 0 || diskCount !== totalCount) throw new Error("multi-disk ZIP archives are forbidden"); + if (totalCount > limits.zip_members) throw new Error("archive member count exceeds resource limit"); + if (centralOffset + centralSize !== eocdOffset) throw new Error("ZIP central directory offset/size mismatch"); + const entries: Entry[] = []; + const seen = new Set(); + let cursor = centralOffset; + for (let index = 0; index < totalCount; index += 1) { + if (cursor + 46 > eocdOffset) throw new Error("truncated ZIP central directory"); + if (raw.readUInt32LE(cursor) !== 0x02014b50) throw new Error("invalid ZIP central header signature"); + const madeBy = raw.readUInt16LE(cursor + 4); + const needed = raw.readUInt16LE(cursor + 6); + const flags = raw.readUInt16LE(cursor + 8); + const method = raw.readUInt16LE(cursor + 10); + const dosTime = raw.readUInt16LE(cursor + 12); + const dosDate = raw.readUInt16LE(cursor + 14); + const crc = raw.readUInt32LE(cursor + 16); + const compressedSize = raw.readUInt32LE(cursor + 20); + const uncompressedSize = raw.readUInt32LE(cursor + 24); + const nameLength = raw.readUInt16LE(cursor + 28); + const extraLength = raw.readUInt16LE(cursor + 30); + const commentLength = raw.readUInt16LE(cursor + 32); + const diskStart = raw.readUInt16LE(cursor + 34); + const internalAttributes = raw.readUInt16LE(cursor + 36); + const externalAttributes = raw.readUInt32LE(cursor + 38); + const localOffset = raw.readUInt32LE(cursor + 42); + if (madeBy !== 0x0314 || needed !== 20) throw new Error("ZIP creator/version profile mismatch"); + if (flags !== 0x0800 || (method !== 0 && method !== 8)) throw new Error("unsupported ZIP flags or compression method"); + if (dosTime !== 0 || dosDate !== 0x0021) throw new Error("ZIP timestamp profile mismatch"); + if (extraLength !== 0 || commentLength !== 0 || diskStart !== 0 || internalAttributes !== 0) throw new Error("ZIP extra/comment/disk/attributes are forbidden"); + if (externalAttributes !== 0x81a40000) throw new Error("ZIP member must be Unix regular mode 0100644"); + const nameStart = cursor + 46; + const recordEnd = nameStart + nameLength + extraLength + commentLength; + if (recordEnd > eocdOffset) throw new Error("truncated ZIP central member record"); + const name = ascii(raw.subarray(nameStart, nameStart + nameLength), "central name"); + validateName(name, seen); + entries.push({ name, method, crc32: crc, compressedSize, uncompressedSize, localOffset }); + cursor = recordEnd; + } + if (cursor !== eocdOffset) throw new Error("unexpected bytes in ZIP central directory"); + const canonicalSize = preflightArchiveMemberSizes(new Map(entries.map((entry) => [entry.name, entry.uncompressedSize])), limits); + const occupied: [number, number][] = []; + const payloadRanges: { entry: Entry; start: number; end: number }[] = []; + let totalUncompressed = 0; + for (const entry of entries) { + const offset = entry.localOffset; + if (offset + 30 > centralOffset) throw new Error("truncated ZIP local header"); + if (raw.readUInt32LE(offset) !== 0x04034b50) throw new Error("invalid ZIP local header signature"); + const needed = raw.readUInt16LE(offset + 4); + const flags = raw.readUInt16LE(offset + 6); + const method = raw.readUInt16LE(offset + 8); + const dosTime = raw.readUInt16LE(offset + 10); + const dosDate = raw.readUInt16LE(offset + 12); + const crc = raw.readUInt32LE(offset + 14); + const compressedSize = raw.readUInt32LE(offset + 18); + const uncompressedSize = raw.readUInt32LE(offset + 22); + const nameLength = raw.readUInt16LE(offset + 26); + const extraLength = raw.readUInt16LE(offset + 28); + if (needed !== 20 || flags !== 0x0800 || method !== entry.method || dosTime !== 0 || dosDate !== 0x0021 || crc !== entry.crc32 || compressedSize !== entry.compressedSize || uncompressedSize !== entry.uncompressedSize || extraLength !== 0) { + throw new Error("ZIP central/local header mismatch"); + } + const nameStart = offset + 30; + const nameEnd = nameStart + nameLength; + const payloadEnd = nameEnd + compressedSize; + if (payloadEnd > centralOffset) throw new Error("ZIP member payload crosses central directory"); + if (ascii(raw.subarray(nameStart, nameEnd), "local name") !== entry.name) throw new Error("ZIP central/local member name mismatch"); + occupied.push([offset, payloadEnd]); + payloadRanges.push({ entry, start: nameEnd, end: payloadEnd }); + totalUncompressed += uncompressedSize; + } + occupied.sort((left, right) => left[0] - right[0]); + if (occupied.length > 0) { + if (occupied[0][0] !== 0) throw new Error("ZIP archive has a leading prefix"); + for (let index = 1; index < occupied.length; index += 1) { + if (occupied[index - 1][1] !== occupied[index][0]) throw new Error("ZIP local records overlap or contain gaps"); + } + if (occupied.at(-1)![1] !== centralOffset) throw new Error("ZIP bytes exist between local records and central directory"); + } + preflightAggregateCompressionRatio(totalUncompressed, raw.length, limits); + + const members = new Map(); + for (const { entry, start, end } of payloadRanges) { + const compressed = raw.subarray(start, end); + try { + preflightMemberCompressionRatio(entry.uncompressedSize, entry.compressedSize, limits); + } catch { + throw new Error(`member compression ratio exceeds limit: ${JSON.stringify(entry.name)}`); + } + let payload: Buffer; + if (entry.method === 0) { + payload = Buffer.from(compressed); + } else { + try { + const inflated = inflateRawSync(compressed, { maxOutputLength: entry.uncompressedSize + 1, info: true }) as unknown as { + buffer: Buffer; + engine: { bytesWritten: number }; + }; + if (inflated.engine.bytesWritten !== compressed.length) throw new Error("trailing deflate data"); + payload = Buffer.from(inflated.buffer); + } catch { + throw new Error(`invalid deflate stream: ${JSON.stringify(entry.name)}`); + } + } + if (payload.length !== entry.uncompressedSize) throw new Error(`ZIP member decoded size mismatch: ${JSON.stringify(entry.name)}`); + if (crc32(payload) !== entry.crc32) throw new Error(`ZIP member CRC mismatch: ${JSON.stringify(entry.name)}`); + members.set(entry.name, payload); + } + return { members, inputSize: raw.length, canonicalSize, usedDeflate: entries.some((entry) => entry.method === 8) }; +} diff --git a/SimpleCADAPI/scene-contract/test/canonical.test.ts b/SimpleCADAPI/scene-contract/test/canonical.test.ts new file mode 100644 index 0000000..90bd936 --- /dev/null +++ b/SimpleCADAPI/scene-contract/test/canonical.test.ts @@ -0,0 +1,87 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + canonicalJsonBytes, + parseCanonicalJson, + parseStrictJson, + validateNormalizedProduct, + withSceneRevision, +} from "../src/index.js"; + +test("canonical JSON renders finite Numbers from their value", () => { + assert.equal(canonicalJsonBytes(1e30).toString("utf8"), "1e+30"); + assert.equal(canonicalJsonBytes(parseStrictJson("1e30")).toString("utf8"), "1e+30"); + + const nested = parseStrictJson('{"large":1e30,"small":[1E-7]}'); + assert.equal( + canonicalJsonBytes(nested).toString("utf8"), + '{"large":1e+30,"small":[1e-7]}', + ); + assert.deepEqual(parseCanonicalJson("1e+30"), 1e30); + assert.throws(() => parseCanonicalJson("1e30"), /canonical/); +}); + +test("canonical JSON normalizes negative zero at root and nested positions", () => { + const root = parseStrictJson("-0"); + assert.equal(Object.is(root, -0), true); + assert.equal(canonicalJsonBytes(root).toString("utf8"), "0"); + + const nested = parseStrictJson('{"values":[-0,0e0]}'); + assert.equal(canonicalJsonBytes(nested).toString("utf8"), '{"values":[0,0]}'); + assert.throws(() => parseCanonicalJson("-0"), /canonical/); +}); + +test("cloning parsed values does not change canonical bytes or scene revision", () => { + const scene = parseStrictJson('{"measurement":1e30,"nested":{"epsilon":1e-7}}') as Record< + string, + unknown + >; + const clone = structuredClone(scene); + + assert.deepEqual(canonicalJsonBytes(clone), canonicalJsonBytes(scene)); + assert.deepEqual(withSceneRevision(clone), withSceneRevision(scene)); +}); + +test("unsafe integer JSON is rejected lexically without affecting exponent Numbers", () => { + assert.throws(() => parseStrictJson("9007199254740992"), /safe domain/); + assert.throws(() => parseStrictJson('{"value":-9007199254740992}'), /safe domain/); + assert.equal( + canonicalJsonBytes(Number.MAX_SAFE_INTEGER + 1).toString("utf8"), + "9007199254740992", + ); + assert.equal(canonicalJsonBytes(parseStrictJson("1e30")).toString("utf8"), "1e+30"); +}); + +test("strict JSON rejects unpaired surrogates in values and object keys", () => { + assert.throws(() => parseStrictJson('{"value":"\\ud800"}'), /surrogate/); + assert.throws(() => parseStrictJson('{"\\ud800":0}'), /surrogate/); +}); + +test("serialized JSON over the depth limit reports a budget issue", () => { + let serialized = "0"; + for (let depth = 0; depth < 65; depth += 1) serialized = `{"x":${serialized}}`; + + const result = validateNormalizedProduct(serialized); + assert.equal(result.firstError?.code, "resource_limit_exceeded"); + assert.equal(result.firstError?.phase, "budget"); + assert.equal(result.firstError?.path, "/x".repeat(65)); +}); + +test("serialized non-finite tokens and overflow report nonfinite_json_number", () => { + for (const serialized of [ + "NaN", + "Infinity", + "-Infinity", + "1e999", + '[0,NaN]', + '{"value":1e999}', + ]) { + const result = validateNormalizedProduct(serialized); + assert.equal(result.firstError?.code, "nonfinite_json_number", serialized); + assert.equal(result.firstError?.phase, "parse", serialized); + } + + const programmatic = validateNormalizedProduct({ metadata: { value: Number.NaN } }); + assert.equal(programmatic.firstError?.code, "nonfinite_json_number"); + assert.equal(programmatic.firstError?.path, "/metadata/value"); +}); diff --git a/SimpleCADAPI/scene-contract/test/corpus-support.ts b/SimpleCADAPI/scene-contract/test/corpus-support.ts new file mode 100644 index 0000000..fc8e0a8 --- /dev/null +++ b/SimpleCADAPI/scene-contract/test/corpus-support.ts @@ -0,0 +1,118 @@ +import assert from "node:assert/strict"; +import { withSceneRevision } from "../src/index.js"; + +export interface ExpectedIssue { code: string; path: string } +export interface Mutation { + operation: "add" | "delete" | "set"; + path: (string | number)[]; + value?: unknown; +} +export interface SceneFieldCase { + name: string; + valid: boolean; + expected: ExpectedIssue | null; + mutations: Mutation[]; + recompute_revision: boolean; +} +export interface PackageCase { + name: string; + valid: boolean; + expected: ExpectedIssue | null; + manifest_base64: string; + blob_uris: string[]; + blob_mutations?: Record; +} +export interface SceneShapeCase extends PackageCase { + expected_shape: SceneShapeFacts; +} +export interface SceneShapeFacts { + definition_count: number; + definition_occurrence_counts: Record; + edge_asset_count: number; + entity_asset_count: number; + geometry_asset_count: number; + maximum_depth: number; + node_count: number; + root_node_ids: string[]; +} + +type JsonRecord = Record; + +function asRecord(value: unknown): JsonRecord { + assert.ok(value !== null && typeof value === "object" && !Array.isArray(value)); + return value as JsonRecord; +} + +function asRecords(value: unknown): JsonRecord[] { + assert.ok(Array.isArray(value)); + return value.map(asRecord); +} + +export function corpusBlobs( + pool: Record, + item: PackageCase, +): Map { + const blobs = new Map( + item.blob_uris.map((uri) => [uri, Buffer.from(pool[uri], "base64")]), + ); + for (const [uri, payload] of Object.entries(item.blob_mutations ?? {})) { + blobs.set(uri, Buffer.from(payload, "base64")); + } + return blobs; +} + +export function applySceneFieldCase( + scene: JsonRecord, + item: SceneFieldCase, +): JsonRecord { + const result = structuredClone(scene); + for (const mutation of item.mutations) { + let current: JsonRecord | unknown[] = result; + for (const part of mutation.path.slice(0, -1)) { + current = Array.isArray(current) + ? current[part as number] as JsonRecord | unknown[] + : current[part as string] as JsonRecord | unknown[]; + } + const final = mutation.path.at(-1)!; + if (mutation.operation === "delete") { + if (Array.isArray(current)) delete current[final as number]; + else delete current[final as string]; + } else if (Array.isArray(current)) { + current[final as number] = structuredClone(mutation.value); + } else { + current[final as string] = structuredClone(mutation.value); + } + } + return item.recompute_revision ? withSceneRevision(result) : result; +} + +export function sceneShapeFacts(scene: JsonRecord): SceneShapeFacts { + const nodes = asRecords(scene.nodes); + const definitionOccurrenceCounts: Record = {}; + for (const node of nodes) { + const definitionId = String(node.definition_id); + definitionOccurrenceCounts[definitionId] = + (definitionOccurrenceCounts[definitionId] ?? 0) + 1; + } + return { + definition_count: asRecords(scene.definitions).length, + definition_occurrence_counts: Object.fromEntries( + Object.entries(definitionOccurrenceCounts).sort(([left], [right]) => + Buffer.compare(Buffer.from(left), Buffer.from(right)) + ), + ), + edge_asset_count: asRecords(scene.edge_assets).length, + entity_asset_count: asRecords(scene.entity_assets).length, + geometry_asset_count: asRecords(scene.geometry_assets).length, + maximum_depth: Math.max( + ...nodes.map((node) => { + const source = asRecord(node.source); + return Array.isArray(source.component_path) ? source.component_path.length : 0; + }), + ), + node_count: nodes.length, + root_node_ids: nodes + .filter((node) => node.parent_node_id === null) + .map((node) => String(node.node_id)), + }; +} diff --git a/SimpleCADAPI/scene-contract/test/corpus.test.ts b/SimpleCADAPI/scene-contract/test/corpus.test.ts new file mode 100644 index 0000000..0728093 --- /dev/null +++ b/SimpleCADAPI/scene-contract/test/corpus.test.ts @@ -0,0 +1,1097 @@ +import { createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import assert from "node:assert/strict"; +import test, { type TestContext } from "node:test"; +import { + canonicalJsonBytes, + canonicalZipBytes, + BASE_LIMITS, + computeSceneRevision, + DuplicateKeyError, + parseCanonicalJson, + parseStrictJson, + preflightAggregateCompressionRatio, + preflightArchiveMemberSizes, + preflightGlb, + preflightGlbCounts, + preflightInputArchiveSize, + preflightMemberCompressionRatio, + preflightZipBytes, + profileCross, + profileF32Bits, + profileNormalize, + issue, + report, + sha256, + validateConnectorBinding, + validateEntityAsset, + validateNormalizedProduct, + validatePresentation, + validateSceneManifest, + validateScenePackage, + computePackageBudgetTotals, + jsonResourceIssues, + packageBudgetIssues, + resourceCountIssues, + withSceneRevision, + type SceneResourceLimits, +} from "../src/index.js"; +import { validateSceneSchema, validateSchemaPointer, type SchemaArtifact } from "../src/schema.js"; +import { + applySceneFieldCase, + corpusBlobs, + sceneShapeFacts, + type ExpectedIssue, + type PackageCase, + type SceneFieldCase, + type SceneShapeCase, +} from "./corpus-support.js"; + +interface ValidationCase { name: string; valid: boolean; expected: ExpectedIssue | null; payload_base64: string } +interface GlbCase { name: string; valid: boolean; error: string | null; expected_kind: "triangle" | "line"; kind: "triangle" | "line" | null; payload_base64: string } +interface ZipCase { name: string; valid: boolean; error: string | null; used_deflate: boolean | null; payload_base64: string } +interface NumericVector { name: string; operation: "f32" | "cross" | "normalize"; input_bits: string[] | string[][]; expected_bits?: string | string[]; error?: string; valid: boolean } +interface ResourceCase { name: string; operation: string; parameters: Record; valid: boolean; error?: string | null; expected?: ExpectedIssue | null } +interface Artifact { base64: string; sha256: string; uri?: string; revision?: string } +interface RevisionVector { name: string; canonical_base64: string; draft_base64: string; revision: string; sha256: string } +interface SchemaMutation { operation: "delete" | "set"; path: (string | number)[]; value?: unknown } +interface SchemaFieldMatrix { + artifact: SchemaArtifact; + base: Record; + cases: { mutations: SchemaMutation[]; name: string; valid: boolean }[]; + schema_pointer: string; + variant: string; +} +interface Corpus { + format_version: string; + artifacts: Record; + blobs: Record; + jcs_vectors: { name: string; input: unknown; canonical_base64: string; sha256: string }[]; + manifest_cases: ValidationCase[]; + entity_cases: ValidationCase[]; + presentation_cases: ValidationCase[]; + connector_binding_cases: ValidationCase[]; + normalized_product_cases: ValidationCase[]; + package_cases: PackageCase[]; + glb_cases: GlbCase[]; + numeric_vectors: NumericVector[]; + resource_cases: ResourceCase[]; + resource_limits: Record; + revision_vectors: RevisionVector[]; + scene_field_cases: SceneFieldCase[]; + scene_shape_cases: SceneShapeCase[]; + schema_field_matrices: SchemaFieldMatrix[]; + zip_cases: ZipCase[]; +} + +const here = dirname(fileURLToPath(import.meta.url)); +const corpusPath = resolve(here, "../../../test/fixtures/scene-contract/corpus.json"); +const corpus = parseStrictJson(readFileSync(corpusPath)) as Corpus; +const decode = (value: string) => Buffer.from(value, "base64"); + +test("shared corpus has every Scene 1.0 parity section", () => { + assert.equal(corpus.format_version, "1.0"); + for (const section of [ + corpus.jcs_vectors, + corpus.manifest_cases, + corpus.entity_cases, + corpus.presentation_cases, + corpus.connector_binding_cases, + corpus.normalized_product_cases, + corpus.package_cases, + corpus.glb_cases, + corpus.numeric_vectors, + corpus.resource_cases, + corpus.revision_vectors, + corpus.scene_field_cases, + corpus.scene_shape_cases, + corpus.schema_field_matrices, + corpus.zip_cases, + ]) assert.ok(section.length > 0); +}); + +function applySchemaMutations(base: Record, mutations: SchemaMutation[]): Record { + const result = structuredClone(base); + for (const mutation of mutations) { + let current: Record = result; + for (const part of mutation.path.slice(0, -1)) current = current[part] as Record; + const field = mutation.path.at(-1)!; + if (mutation.operation === "delete") delete current[field]; + else current[field] = structuredClone(mutation.value); + } + return result; +} + +test("all closed schema records and discriminator variants replay shared field matrices", async (context) => { + for (const matrix of corpus.schema_field_matrices) await context.test( + `${matrix.artifact}:${matrix.schema_pointer}:${matrix.variant}`, + async (matrixContext) => { + for (const item of matrix.cases) await matrixContext.test(item.name, () => { + const value = applySchemaMutations(matrix.base, item.mutations); + assert.equal(validateSchemaPointer(matrix.artifact, matrix.schema_pointer, value), item.valid); + }); + }, + ); +}); + +test("shared positive case names cannot drift to negative expectations", () => { + const positiveNames: string[] = []; + for (const [sectionName, value] of Object.entries(corpus)) { + if (!Array.isArray(value)) continue; + for (const item of value) { + if (item === null || typeof item !== "object" || Array.isArray(item)) continue; + const record = item as Record; + if ( + typeof record.name !== "string" + || (!record.name.startsWith("valid_") && record.name !== "nullable_fields_nonnull") + ) continue; + const label = `${sectionName}:${record.name}`; + positiveNames.push(label); + assert.equal(record.valid, true, label); + if (Object.hasOwn(record, "expected")) assert.equal(record.expected, null, label); + if (Object.hasOwn(record, "error")) assert.equal(record.error, null, label); + } + } + assert.equal(positiveNames.length, 30); +}); + +test("strict JSON rejects duplicate keys and protects ordinary object semantics", () => { + assert.throws(() => parseStrictJson(Buffer.from('{"a":1,"a":2}')), DuplicateKeyError); + const value = parseStrictJson(Buffer.from('{"__proto__":{"polluted":true}}')) as Record; + assert.equal(Object.hasOwn(value, "__proto__"), true); + assert.equal(({} as { polluted?: boolean }).polluted, undefined); +}); + +test("canonical JSON accepts finite numbers and rejects non-finite numbers", () => { + assert.equal(canonicalJsonBytes(1e30).toString("utf8"), "1e+30"); + for (const value of [Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY]) { + assert.throws(() => canonicalJsonBytes(value), /finite/); + } + assert.equal(canonicalJsonBytes(1.25).toString("utf8"), "1.25"); +}); + +test("strict parsing accepts valid noncanonical JSON while canonical parsing rejects it", () => { + const bytes = Buffer.from('{"b":1, "a":2}'); + assert.equal((parseStrictJson(bytes) as Record).a, 2); + assert.throws(() => parseCanonicalJson(bytes), /canonical/); +}); + +test("public validator resource overflows remain budget issues", () => { + const scene = validScene(); + const sceneBytes = canonicalJsonBytes(scene); + const collectionResult = validateSceneManifest(scene, { ...BASE_LIMITS, definitions: 0 }); + const serializedResult = validateSceneManifest(scene, { + ...BASE_LIMITS, + scene_json_bytes: sceneBytes.length - 1, + }); + + assert.deepEqual( + collectionResult.firstError === null + ? null + : [collectionResult.firstError.code, collectionResult.firstError.phase, collectionResult.firstError.path], + ["resource_limit_exceeded", "budget", "/definitions"], + ); + assert.deepEqual( + serializedResult.firstError === null + ? null + : [serializedResult.firstError.code, serializedResult.firstError.phase, serializedResult.firstError.path], + ["resource_limit_exceeded", "budget", ""], + ); +}); + +test("reports are deterministic by phase, UTF-8 rule ID, and UTF-8 pointer", () => { + const result = report([ + issue("z", "/b", "later path", "semantic"), + issue("a", "/z", "later code", "semantic"), + issue("a", "/a", "earlier pointer", "semantic"), + issue("z", "/z", "structure first", "structure"), + issue("a", "/a", "duplicate is ignored", "semantic"), + ]); + assert.deepEqual( + result.issues.map(({ phase, code, path }) => [phase, code, path]), + [ + ["structure", "z", "/z"], + ["semantic", "a", "/a"], + ["semantic", "a", "/z"], + ["semantic", "z", "/b"], + ], + ); +}); + +test("RFC 8785 vectors reproduce exact bytes and hashes", async (context) => { + for (const vector of corpus.jcs_vectors) await context.test(vector.name, () => { + const canonical = canonicalJsonBytes(vector.input); + assert.deepEqual(canonical, decode(vector.canonical_base64)); + assert.equal(sha256(canonical), vector.sha256); + }); +}); + +test("scene artifact revision and content hash are exact", () => { + const bytes = decode(corpus.artifacts.scene.base64); + const scene = parseStrictJson(bytes) as Record; + assert.equal(computeSceneRevision(scene), corpus.artifacts.scene.revision); + assert.equal(sha256(bytes), corpus.artifacts.scene.sha256); +}); + +test("all manifest cases match validity and expected first issue", async (context) => { + for (const item of corpus.manifest_cases) await context.test(item.name, () => { + const result = validateSceneManifest(decode(item.payload_base64)); + assert.equal(result.valid, item.valid); + if (item.expected !== null) { + assert.equal(result.firstError?.code, item.expected.code); + assert.equal(result.firstError?.path, item.expected.path); + } + }); +}); + +test("all entity cases match validity and expected first issue", async (context) => { + for (const item of corpus.entity_cases) await context.test(item.name, () => { + const result = validateEntityAsset(decode(item.payload_base64)); + assert.equal(result.valid, item.valid); + if (item.expected !== null) { + assert.equal(result.firstError?.code, item.expected.code); + assert.equal(result.firstError?.path, item.expected.path); + } + }); +}); + +async function assertValidationCases( + context: TestContext, + cases: ValidationCase[], + validate: (value: unknown) => ReturnType, +): Promise { + for (const item of cases) await context.test(item.name, () => { + const result = validate(decode(item.payload_base64)); + assert.equal(result.valid, item.valid); + if (item.expected !== null) { + assert.equal(result.firstError?.code, item.expected.code); + assert.equal(result.firstError?.path, item.expected.path); + } + }); +} + +test("all presentation cases match validity and expected first issue", async (context) => { + await assertValidationCases(context, corpus.presentation_cases, validatePresentation); +}); + +test("all connector binding cases match validity and expected first issue", async (context) => { + await assertValidationCases(context, corpus.connector_binding_cases, validateConnectorBinding); +}); + +test("all normalized product cases match validity and expected first issue", async (context) => { + await assertValidationCases(context, corpus.normalized_product_cases, validateNormalizedProduct); +}); + +test("presentation rejects a non-rigid camera transform", () => { + const valid = corpus.presentation_cases.find((item) => item.valid); + assert.ok(valid !== undefined); + const presentation = asRecord(parseStrictJson(decode(valid.payload_base64))); + const camera = asRecords(presentation.cameras)[0]; + asRecord(camera.transform).x_axis = [2, 0, 0]; + const result = validatePresentation(presentation); + assert.equal(result.firstError?.code, "transform_invalid"); + assert.equal(result.firstError?.path, "/cameras/0/transform"); +}); + +function artifactBlobs(): Map { + const result = new Map(); + for (const artifact of Object.values(corpus.artifacts)) { + if (artifact.uri !== undefined) result.set(artifact.uri, decode(artifact.base64)); + } + return result; +} + +type JsonRecord = Record; + +function asRecord(value: unknown): JsonRecord { + assert.ok(value !== null && typeof value === "object" && !Array.isArray(value)); + return value as JsonRecord; +} + +function asRecords(value: unknown): JsonRecord[] { + assert.ok(Array.isArray(value)); + return value.map(asRecord); +} + +function validScene(): JsonRecord { + return asRecord(parseStrictJson(decode(corpus.artifacts.scene.base64))); +} + +function validEntities(): JsonRecord { + return asRecord(parseStrictJson(decode(corpus.artifacts.entities.base64))); +} + +function operationSource(path: string, callText: string): JsonRecord { + const source: JsonRecord = { + schema_version: "1.0", + path, + path_kind: "project_relative", + line: 1, + column: 7, + end_line: 1, + end_column: 7 + Array.from(callText).length, + call_text: callText, + callsite_id: "", + assignment_targets: ["body"], + }; + const material = [ + source.path, + source.line, + source.column, + source.end_line, + source.end_column, + source.call_text, + ].map(String).join("\x1f"); + source.callsite_id = `callsite_${sha256(Buffer.from(material, "utf8")).slice("sha256:".length, "sha256:".length + 16)}`; + return source; +} + +function embeddedSourcePackage(): { + blobs: Map; + manifest: JsonRecord; + model: JsonRecord; + sourcePath: string; + sourceUri: string; +} { + const scene = validScene(); + const sidecar = validEntities(); + makeModelScene(scene, sidecar, "shape"); + + const definition = asRecords(scene.definitions)[0]; + const definitionId = "definition/root/shape/model/graph/node/0"; + definition.definition_id = definitionId; + asRecords(scene.nodes)[0].definition_id = definitionId; + sidecar.definition_id = definitionId; + + const callText = "scad.make_box_rsolid(width=1)"; + const sourcePath = "models/box.py"; + const sourceUri = `sources/${sourcePath}`; + const sourceBytes = Buffer.from(`body = ${callText}\n`, "utf8"); + const model: JsonRecord = { + schema_version: "2.0", + graph: { + graph_id: "graph", + nodes: [{ + node_id: "node", + op: "make_box_rsolid", + params: { width: 1 }, + inputs: [], + output_count: 1, + source: operationSource(sourcePath, callText), + }], + }, + leaf_ids: ["node"], + }; + const modelBytes = canonicalJsonBytes(model); + scene.source = { + kind: "model", + graph_id: "graph", + model_schema_version: "2.0", + artifact_hash: sha256(modelBytes), + embedded_artifact_uri: "model/model.json", + embedded_artifact_byte_length: modelBytes.length, + source_files: [{ + path: sourcePath, + uri: sourceUri, + media_type: "text/x-python; charset=utf-8", + byte_length: sourceBytes.length, + content_hash: sha256(sourceBytes), + }], + }; + asRecord(scene.compile_options).embed_source = true; + const replaced = replaceEntitySidecar(scene, sidecar); + replaced.blobs.set("model/model.json", modelBytes); + replaced.blobs.set(sourceUri, sourceBytes); + scene.revision = computeSceneRevision(scene); + return { blobs: replaced.blobs, manifest: scene, model, sourcePath, sourceUri }; +} + +function replaceEmbeddedModel( + fixture: ReturnType, +): void { + const payload = canonicalJsonBytes(fixture.model); + fixture.blobs.set("model/model.json", payload); + const source = asRecord(fixture.manifest.source); + source.artifact_hash = sha256(payload); + source.embedded_artifact_byte_length = payload.length; + fixture.manifest.revision = computeSceneRevision(fixture.manifest); +} + +function replaceEntitySidecar( + scene: JsonRecord, + sidecar: JsonRecord, +): { manifest: Buffer; blobs: Map; entityUri: string } { + const entityBytes = canonicalJsonBytes(sidecar); + const entityHash = sha256(entityBytes); + const entityUri = `entities/sha256-${entityHash.slice("sha256:".length)}.json`; + const entityRecord = asRecords(scene.entity_assets)[0]; + const oldUri = entityRecord.uri as string; + Object.assign(entityRecord, { + byte_length: entityBytes.length, + content_hash: entityHash, + entity_asset_id: entityHash, + uri: entityUri, + }); + asRecords(scene.definitions)[0].entity_asset_id = entityHash; + scene.revision = computeSceneRevision(scene); + const blobs = artifactBlobs(); + blobs.delete(oldUri); + blobs.set(entityUri, entityBytes); + return { manifest: canonicalJsonBytes(scene), blobs, entityUri }; +} + +function entityIndex(sidecar: JsonRecord, kind: string): number { + const index = asRecords(sidecar.entities).findIndex((entity) => entity.kind === kind); + assert.notEqual(index, -1); + return index; +} + +function assertFirstIssue( + result: ReturnType, + code: string, + path: string, +): void { + assert.equal(result.valid, false); + assert.equal(result.firstError?.code, code); + assert.equal(result.firstError?.path, path); +} + +test("scene semantic references and content identities reject independent mutations", async (context) => { + const missingHash = `sha256:${"f".repeat(64)}`; + const cases: [string, (scene: JsonRecord) => void, string, string][] = [ + ["missing definition appearance", (scene) => { + asRecords(scene.definitions)[0].appearance_id = `appearance/evaluated/${"f".repeat(64)}`; + }, "reference_missing", "/definitions/0/appearance_id"], + ["missing definition geometry asset", (scene) => { + asRecords(scene.definitions)[0].geometry_asset_id = missingHash; + }, "reference_missing", "/definitions"], + ["missing node appearance override", (scene) => { + asRecords(scene.nodes)[0].appearance_override_id = `appearance/evaluated/${"f".repeat(64)}`; + }, "reference_missing", "/nodes/0/appearance_override_id"], + ["geometry asset ID differs from content hash", (scene) => { + asRecords(scene.geometry_assets)[0].content_hash = missingHash; + }, "source_matrix_invalid", "/geometry_assets/0/asset_id"], + ["entity asset ID differs from content hash", (scene) => { + asRecords(scene.entity_assets)[0].content_hash = missingHash; + }, "source_matrix_invalid", "/entity_assets/0/entity_asset_id"], + ["entity asset URI is not content-derived", (scene) => { + asRecords(scene.entity_assets)[0].uri = "entities/noncanonical.json"; + }, "source_matrix_invalid", "/entity_assets/0/uri"], + ["geometry tessellation differs from compile options", (scene) => { + asRecord(asRecords(scene.geometry_assets)[0].tessellation).linear_tolerance = 0.02; + }, "source_matrix_invalid", "/geometry_assets/0/tessellation/linear_tolerance"], + ["appearance ID is not content-derived", (scene) => { + const appearanceId = `appearance/evaluated/${"f".repeat(64)}`; + asRecords(scene.appearances)[0].appearance_id = appearanceId; + asRecords(scene.definitions)[0].appearance_id = appearanceId; + }, "source_matrix_invalid", "/appearances/0/appearance_id"], + ["manual source cannot request embedding", (scene) => { + asRecord(scene.compile_options).embed_source = true; + }, "source_matrix_invalid", "/source"], + ["definition ID must be source-derived", (scene) => { + asRecord(scene.source).source_id = "other"; + asRecord(asRecords(scene.definitions)[0].source).source_id = "other"; + }, "source_matrix_invalid", "/definitions/0/definition_id"], + ]; + for (const [name, mutate, code, path] of cases) await context.test(name, () => { + const scene = validScene(); + mutate(scene); + scene.revision = computeSceneRevision(scene); + assertFirstIssue(validateSceneManifest(scene), code, path); + }); +}); + +function addSceneCamera(scene: JsonRecord): JsonRecord { + const camera = { + camera_id: "camera/test", + name: "Test camera", + projection: "perspective", + parent_node_id: null, + transform: structuredClone(asRecords(scene.nodes)[0].transform), + near: 1, + far: 10, + vertical_fov_degrees: 45, + }; + assert.ok(Array.isArray(scene.cameras)); + scene.cameras.push(camera); + return camera; +} + +test("scene cameras reject dangling parents and reversed clipping bounds independently", async (context) => { + await context.test("dangling parent", () => { + const scene = validScene(); + addSceneCamera(scene).parent_node_id = "instance/missing"; + scene.revision = computeSceneRevision(scene); + assertFirstIssue(validateSceneManifest(scene), "reference_missing", "/cameras/0/parent_node_id"); + }); + await context.test("far does not exceed near", () => { + const scene = validScene(); + addSceneCamera(scene).far = 1; + scene.revision = computeSceneRevision(scene); + assertFirstIssue(validateSceneManifest(scene), "bounds_invalid", "/cameras/0/far"); + }); +}); + +test("entity kind, frame, status, position, and bounds semantics reject independent mutations", async (context) => { + const cases: [string, string, (entity: JsonRecord) => void, string, (index: number) => string][] = [ + ["geometry kind", "solid", (entity) => { + entity.geometry = { type: "point", position: [0, 0, 0] }; + }, "entity_topology_invalid", (index) => `/entities/${index}/geometry/type`], + ["required connector frame", "face", (entity) => { + entity.sdk_connector_frame = null; + }, "connector_invalid", (index) => `/entities/${index}/sdk_connector_frame`], + ["render status", "vertex", (entity) => { + entity.render_status = "degenerate"; + }, "entity_topology_invalid", (index) => `/entities/${index}/render_status`], + ["vertex property position", "vertex", (entity) => { + const position = [...(asRecord(entity.properties).position as number[])]; + position[0] += 1; + asRecord(entity.properties).position = position; + }, "bounds_invalid", (index) => `/entities/${index}/properties/position`], + ["reversed bounds", "solid", (entity) => { + const bounds = asRecord(asRecord(entity.properties).bounds); + const maximum = bounds.max as number[]; + const minimum = [...(bounds.min as number[])]; + minimum[0] = maximum[0] + 1; + bounds.min = minimum; + }, "bounds_invalid", (index) => `/entities/${index}/properties/bounds`], + ]; + for (const [name, kind, mutate, code, path] of cases) await context.test(name, () => { + const sidecar = validEntities(); + const index = entityIndex(sidecar, kind); + mutate(asRecords(sidecar.entities)[index]); + assertFirstIssue(validateEntityAsset(sidecar), code, path(index)); + }); +}); + +test("analytic geometry coordinates enforce the scene coordinate limit", async (context) => { + const cases: [string, string, (geometry: JsonRecord) => void][] = [ + ["point.position", "vertex", (geometry) => { geometry.position = [1e12 + 1, 0, 0]; }], + ["line.origin", "edge", (geometry) => { geometry.origin = [0, -(1e12 + 1), 0]; }], + ["plane.origin", "face", (geometry) => { geometry.origin = [0, 0, 1e12 + 1]; }], + ["circle.center", "edge", (geometry) => { + for (const key of Object.keys(geometry)) delete geometry[key]; + Object.assign(geometry, { + type: "circle", + center: [1e12 + 1, 0, 0], + normal: [0, 0, 1], + x_direction: [1, 0, 0], + radius: 1, + }); + }], + ]; + for (const [name, kind, mutate] of cases) await context.test(name, () => { + const sidecar = validEntities(); + const index = entityIndex(sidecar, kind); + const geometry = asRecord(asRecords(sidecar.entities)[index].geometry); + mutate(geometry); + const result = validateEntityAsset(sidecar); + assert.equal(result.valid, false); + assert.ok(result.issues.some((item) => + item.code === "analytic_geometry_invalid" && + item.path === `/entities/${index}/geometry/${name.split(".")[1]}` + )); + }); +}); + +test("package checks entity geometry engine version against the manifest", () => { + const scene = validScene(); + const sidecar = validEntities(); + asRecord(sidecar.geometry_engine).version = "different"; + const { manifest, blobs } = replaceEntitySidecar(scene, sidecar); + const result = validateScenePackage(manifest, blobs); + assert.ok(result.issues.some((item) => + item.code === "source_matrix_invalid" && item.path === "/definitions/0/entity_asset_id" + )); +}); + +test("package rejects entity source variants incompatible with the scene source", () => { + const scene = validScene(); + const sidecar = validEntities(); + asRecords(sidecar.entities)[0].source = { + kind: "imported_primitive", + source_element_id: "element", + }; + const { manifest, blobs, entityUri } = replaceEntitySidecar(scene, sidecar); + const result = validateScenePackage(manifest, blobs); + assert.ok(result.issues.some((item) => + item.code === "source_matrix_invalid" && item.path === `/${entityUri}/entities/0/source` + )); +}); + +function makeModelScene(scene: JsonRecord, sidecar: JsonRecord, definitionKind: "shape" | "part"): void { + scene.source = { + kind: "model", + graph_id: "graph", + model_schema_version: "2.0", + artifact_hash: `sha256:${"1".repeat(64)}`, + }; + const definition = asRecords(scene.definitions)[0]; + definition.kind = definitionKind; + definition.source = definitionKind === "part" + ? { + kind: "product_model", + root_id: "root", + semantic_type: "Part", + semantic_id: "Part", + graph_id: "graph", + node_id: "node", + output_slot: 0, + } + : { + kind: "model_output", + root_id: "root", + graph_id: "graph", + node_id: "node", + output_slot: 0, + }; + for (const entity of asRecords(sidecar.entities)) { + entity.source = { + kind: "model_output", + graph_id: "graph", + node_id: "node", + output_slot: 0, + }; + } +} + +test("package checks model entity source ownership fields", () => { + const scene = validScene(); + const sidecar = validEntities(); + makeModelScene(scene, sidecar, "shape"); + asRecord(asRecords(sidecar.entities)[0].source).node_id = "other-node"; + const { manifest, blobs, entityUri } = replaceEntitySidecar(scene, sidecar); + const result = validateScenePackage(manifest, blobs); + assert.ok(result.issues.some((item) => + item.code === "source_matrix_invalid" && item.path === `/${entityUri}/entities/0/source` + )); +}); + +test("embedded model and Python source package validates", () => { + const fixture = embeddedSourcePackage(); + const result = validateScenePackage(fixture.manifest, fixture.blobs); + assert.equal(result.valid, true, JSON.stringify(result.issues)); +}); + +test("manifest rejects archive-unsafe embedded Python paths", () => { + const fixture = embeddedSourcePackage(); + const sourceFile = asRecords(asRecord(fixture.manifest.source).source_files)[0]; + sourceFile.path = "models/../box.py"; + sourceFile.uri = "sources/models/../box.py"; + fixture.manifest.revision = computeSceneRevision(fixture.manifest); + + const result = validateSceneManifest(fixture.manifest); + assert.ok(result.issues.some((item) => + item.code === "source_matrix_invalid" && item.path === "/source/source_files/0/path" + )); +}); + +test("package rejects malformed operation source mappings", () => { + const fixture = embeddedSourcePackage(); + const graph = asRecord(fixture.model.graph); + const source = asRecord(asRecords(graph.nodes)[0].source); + delete source.callsite_id; + replaceEmbeddedModel(fixture); + + const result = validateScenePackage(fixture.manifest, fixture.blobs); + assert.ok(result.issues.some((item) => + item.code === "source_matrix_invalid" && item.path === "/model/model.json/graph/nodes/0/source" + )); +}); + +test("package rejects invalid UTF-8 Python source bytes", () => { + const fixture = embeddedSourcePackage(); + const payload = Buffer.from([0xff]); + fixture.blobs.set(fixture.sourceUri, payload); + const sourceFile = asRecords(asRecord(fixture.manifest.source).source_files)[0]; + sourceFile.byte_length = payload.length; + sourceFile.content_hash = sha256(payload); + fixture.manifest.revision = computeSceneRevision(fixture.manifest); + + const result = validateScenePackage(fixture.manifest, fixture.blobs); + assert.ok(result.issues.some((item) => + item.code === "invalid_utf8" && item.path === `/${fixture.sourceUri}` + )); +}); + +test("package binding status rules follow Python precedence", async (context) => { + await context.test("solid is always not_applicable", () => { + const scene = validScene(); + const sidecar = validEntities(); + const index = entityIndex(sidecar, "solid"); + asRecords(sidecar.entities)[index].connector_binding_status = "supported"; + const { manifest, blobs, entityUri } = replaceEntitySidecar(scene, sidecar); + const result = validateScenePackage(manifest, blobs); + const found = result.issues.find((item) => item.code === "connector_invalid" && item.path === `/${entityUri}/entities/${index}/connector_binding_status`); + assert.match(found?.message ?? "", /not_applicable/); + }); + + await context.test("non-part owner precedes scene source", () => { + const scene = validScene(); + const sidecar = validEntities(); + const index = entityIndex(sidecar, "face"); + asRecords(sidecar.entities)[index].connector_binding_status = "source_not_model"; + const { manifest, blobs, entityUri } = replaceEntitySidecar(scene, sidecar); + const result = validateScenePackage(manifest, blobs); + const found = result.issues.find((item) => item.code === "connector_invalid" && item.path === `/${entityUri}/entities/${index}/connector_binding_status`); + assert.match(found?.message ?? "", /owner_not_part/); + }); + + await context.test("non-model part requires source_not_model", () => { + const scene = validScene(); + const sidecar = validEntities(); + const definition = asRecords(scene.definitions)[0]; + definition.kind = "part"; + definition.source = { + kind: "product_manual", + root_id: "root", + semantic_type: "Part", + semantic_id: "Part", + }; + const entities = asRecords(sidecar.entities); + for (const entity of entities) { + if (entity.kind !== "solid") entity.connector_binding_status = "source_not_model"; + } + const index = entityIndex(sidecar, "face"); + entities[index].connector_binding_status = "supported"; + const { manifest, blobs, entityUri } = replaceEntitySidecar(scene, sidecar); + const result = validateScenePackage(manifest, blobs); + const found = result.issues.find((item) => item.code === "connector_invalid" && item.path === `/${entityUri}/entities/${index}/connector_binding_status`); + assert.match(found?.message ?? "", /source_not_model/); + }); + + await context.test("model part null frame requires frame_undefined", () => { + const scene = validScene(); + const sidecar = validEntities(); + makeModelScene(scene, sidecar, "part"); + const entities = asRecords(sidecar.entities); + for (const entity of entities) { + if (entity.kind !== "solid") entity.connector_binding_status = "supported"; + } + const index = entityIndex(sidecar, "edge"); + entities[index].sdk_connector_frame = null; + entities[index].connector_binding_status = "owner_not_part"; + const { manifest, blobs, entityUri } = replaceEntitySidecar(scene, sidecar); + const result = validateScenePackage(manifest, blobs); + const found = result.issues.find((item) => item.code === "connector_invalid" && item.path === `/${entityUri}/entities/${index}/connector_binding_status`); + assert.match(found?.message ?? "", /frame_undefined/); + }); +}); + +test("all package cases match validity and expected first issue", async (context) => { + for (const item of corpus.package_cases) await context.test(item.name, () => { + const result = validateScenePackage( + decode(item.manifest_base64), + corpusBlobs(corpus.blobs, item), + ); + assert.equal(result.valid, item.valid); + if (item.expected !== null) { + assert.equal(result.firstError?.code, item.expected.code); + assert.equal(result.firstError?.path, item.expected.path); + } +}); + +test("accepted package fixtures cover every connector binding status", () => { + const statuses = new Set(); + for (const item of [...corpus.package_cases, ...corpus.scene_shape_cases]) { + if (!item.valid) continue; + for (const [uri, payload] of corpusBlobs(corpus.blobs, item)) { + if (!uri.startsWith("entities/")) continue; + const sidecar = asRecord(parseCanonicalJson(payload)); + for (const entity of asRecords(sidecar.entities)) { + statuses.add(String(entity.connector_binding_status)); + } + } + } + assert.deepEqual([...statuses].sort(), [ + "frame_undefined", + "not_applicable", + "owner_not_part", + "selector_ambiguous", + "selector_unstable", + "source_not_model", + "supported", + ]); +}); +}); + +test("scene field matrix replays independently", async (context) => { + const scene = validScene(); + for (const item of corpus.scene_field_cases) await context.test(item.name, () => { + const result = validateSceneManifest( + canonicalJsonBytes(applySceneFieldCase(scene, item)), + ); + assert.equal(result.valid, item.valid); + assert.equal(result.firstError?.code, item.expected?.code); + assert.equal(result.firstError?.path, item.expected?.path); + }); +}); + +test("scene shapes validate, round trip, and characterize asset reuse", async (context) => { + for (const item of corpus.scene_shape_cases) await context.test(item.name, () => { + const manifest = decode(item.manifest_base64); + const scene = asRecord(parseCanonicalJson(manifest)); + const result = validateScenePackage(manifest, corpusBlobs(corpus.blobs, item)); + assert.equal(result.valid, item.valid); + assert.equal(result.firstError?.code, item.expected?.code); + assert.equal(result.firstError?.path, item.expected?.path); + assert.deepEqual(canonicalJsonBytes(scene), manifest); + assert.deepEqual( + canonicalJsonBytes(sceneShapeFacts(scene)), + canonicalJsonBytes(item.expected_shape), + ); + + const definitions = new Map( + asRecords(scene.definitions).map((definition) => [ + String(definition.definition_id), + definition, + ]), + ); + for (const [definitionId, count] of Object.entries( + item.expected_shape.definition_occurrence_counts, + )) { + if (count <= 1) continue; + const occurrences = asRecords(scene.nodes).filter( + (node) => node.definition_id === definitionId, + ); + assert.equal(occurrences.length, count); + assert.equal(new Set(occurrences.map((node) => node.definition_id)).size, 1); + assert.notEqual(definitions.get(definitionId)?.geometry_asset_id, null); + } + }); +}); + +test("two-pass revision vectors reproduce exact drafts, revisions, and bytes", async (context) => { + for (const vector of corpus.revision_vectors) await context.test(vector.name, () => { + const draftBytes = decode(vector.draft_base64); + const draft = asRecord(parseCanonicalJson(draftBytes)); + const sceneBytes = decode(vector.canonical_base64); + const scene = withSceneRevision(draft); + assert.deepEqual(canonicalJsonBytes(draft), draftBytes); + assert.equal(scene.revision, vector.revision); + assert.deepEqual(canonicalJsonBytes(scene), sceneBytes); + assert.equal(sha256(sceneBytes), vector.sha256); + }); +}); + +test("all GLB cases match acceptance, kind, and exact rejection", async (context) => { + for (const item of corpus.glb_cases) await context.test(item.name, () => { + if (item.valid) { + const result = preflightGlb(decode(item.payload_base64), item.expected_kind); + assert.equal(result.kind, item.kind); + } else { + assert.throws(() => preflightGlb(decode(item.payload_base64), item.expected_kind), (error) => { + assert.equal((error as Error).message, item.error); + return true; + }); + } + }); +}); + +test("all ZIP cases match acceptance, deflate use, and exact rejection", async (context) => { + for (const item of corpus.zip_cases) await context.test(item.name, () => { + if (item.valid) { + const result = preflightZipBytes(decode(item.payload_base64)); + assert.equal(result.usedDeflate, item.used_deflate); + } else { + assert.throws(() => preflightZipBytes(decode(item.payload_base64)), (error) => { + assert.equal((error as Error).message, item.error); + return true; + }); + } + }); +}); + +function f64FromHex(value: string): number { + return Buffer.from(value, "hex").readDoubleBE(0); +} + +function f64Hex(value: number): string { + const bytes = Buffer.allocUnsafe(8); + bytes.writeDoubleBE(value, 0); + return bytes.toString("hex"); +} + +test("numeric profile vectors reproduce exact IEEE-754 results", async (context) => { + for (const vector of corpus.numeric_vectors) await context.test(vector.name, () => { + try { + let actual: string | string[]; + if (vector.operation === "f32") { + const input = vector.input_bits as string[]; + actual = profileF32Bits(f64FromHex(input[0])).toString(16).padStart(8, "0"); + } else if (vector.operation === "cross") { + const [left, right] = (vector.input_bits as string[][]).map( + (input) => input.map(f64FromHex) as [number, number, number], + ); + actual = profileCross(left, right).map(f64Hex); + } else { + const input = (vector.input_bits as string[][])[0].map(f64FromHex) as [number, number, number]; + actual = profileNormalize(input).map((component) => + profileF32Bits(component).toString(16).padStart(8, "0") + ); + } + assert.equal(vector.valid, true); + assert.deepEqual(actual, vector.expected_bits); + } catch (error) { + assert.equal(vector.valid, false); + assert.equal((error as Error).message, vector.error); + } + }); +}); + +test("resource profile and exact boundary cases match Python", async (context) => { + assert.deepEqual(BASE_LIMITS, Object.fromEntries(Object.entries(corpus.resource_limits))); + const scene = validScene(); + + for (const item of corpus.resource_cases) await context.test(item.name, () => { + let valid = false; + let error: string | null = null; + let first: ExpectedIssue | null = null; + try { + const parameters = item.parameters; + const limits = { ...BASE_LIMITS, ...(parameters.limits as Partial ?? {}) }; + if (item.operation === "input_archive_size") { + preflightInputArchiveSize(parameters.size as number, limits); + valid = true; + } else if (item.operation === "archive_member_sizes" || item.operation === "archive_member_count") { + let sizes: Map; + if (item.operation === "archive_member_sizes") { + sizes = new Map(Object.entries(parameters.sizes as Record)); + } else { + sizes = new Map([["scene.json", 0]]); + for (let index = 0; index < (parameters.count as number) - 1; index += 1) { + sizes.set(`x/${index.toString().padStart(5, "0")}`, 0); + } + } + preflightArchiveMemberSizes(sizes, limits); + valid = true; + } else if (item.operation === "aggregate_compression_ratio" || item.operation === "member_compression_ratio") { + const callback = item.operation === "aggregate_compression_ratio" + ? preflightAggregateCompressionRatio + : preflightMemberCompressionRatio; + callback(parameters.uncompressed_size as number, parameters.compressed_size as number, limits); + valid = true; + } else if (item.operation === "json_depth") { + const value: JsonRecord = {}; + let current = value; + for (let index = 0; index < (parameters.depth as number); index += 1) { + current.x = {}; + current = asRecord(current.x); + } + const result = report(jsonResourceIssues(value, limits)); + valid = result.valid; + first = result.firstError === null ? null : { code: result.firstError.code, path: result.firstError.path }; + } else if (item.operation === "json_domain") { + const text = parameters.text as string; + const kind = parameters.kind as string; + const field = parameters.field as string | null; + let value: JsonRecord; + if (kind === "value") value = { value: text }; + else if (kind === "object_key") value = { [text]: 0 }; + else if (kind === "metadata_key") value = { metadata: { [text]: 0 } }; + else if (kind === "sdk_metadata_key") value = { sdk_metadata: { [text]: 0 } }; + else if (kind === "identifier") value = { [field ?? "node_id"]: text }; + else if (kind === "identifier_array") value = { [field ?? "component_path"]: [text] }; + else if (kind === "uri") value = { uri: text }; + else throw new Error(`unknown JSON domain kind: ${kind}`); + const result = report(jsonResourceIssues(value, limits)); + valid = result.valid; + first = result.firstError === null ? null : { code: result.firstError.code, path: result.firstError.path }; + } else if (item.operation === "resource_count") { + const count = parameters.count as number; + const kind = parameters.kind as string; + const field = parameters.field as string | null; + let value: JsonRecord; + if (kind === "collection") value = { [field!]: Array(count).fill(null) }; + else if (kind === "hierarchy") value = { nodes: [{ source: { component_path: Array(count).fill("x") } }] }; + else if (kind === "forwarded") { + value = { + connectors: Array.from({ length: count }, (_unused, index) => ({ + anchor_kind: "forwarded", + connector_snapshot_id: `c${index}`, + forwarded_from: { source_connector_snapshot_id: `c${index + 1}` }, + })), + }; + } else throw new Error(`unknown resource count kind: ${kind}`); + const result = report(resourceCountIssues(value, parameters.artifact as "scene" | "entities" | "presentation", limits)); + valid = result.valid; + first = result.firstError === null ? null : { code: result.firstError.code, path: result.firstError.path }; + } else if (item.operation === "glb_counts") { + preflightGlbCounts( + parameters.kind as "triangle" | "line", + parameters.vertex_count as number, + parameters.index_count as number, + limits, + ); + valid = true; + } else if (item.operation === "package_budget") { + const input = parameters.contributions as { + scene_json_bytes: number; + glb_decoded_buffer_bytes: number; + entity_json_bytes: number; + other_immutable_json_bytes: number; + entity_count: number; + entity_vertex_count: number; + triangle_vertex_count: number; + triangle_count: number; + line_vertex_count: number; + line_segment_count: number; + }; + const totals = computePackageBudgetTotals({ + sceneJsonBytes: input.scene_json_bytes, + glbDecodedBufferBytes: input.glb_decoded_buffer_bytes, + entityJsonBytes: input.entity_json_bytes, + otherImmutableJsonBytes: input.other_immutable_json_bytes, + entityCount: input.entity_count, + entityVertexCount: input.entity_vertex_count, + triangleVertexCount: input.triangle_vertex_count, + triangleCount: input.triangle_count, + lineVertexCount: input.line_vertex_count, + lineSegmentCount: input.line_segment_count, + }); + const result = report(packageBudgetIssues(totals, limits), "package"); + valid = result.valid; + first = result.firstError === null ? null : { code: result.firstError.code, path: result.firstError.path }; + } else if (item.operation === "scene_geometry_byte_length") { + let value = structuredClone(scene); + const byteLength = Number(parameters.value); + asRecords(value.geometry_assets)[0].byte_length = byteLength; + if (byteLength <= Number.MAX_SAFE_INTEGER) value = withSceneRevision(value); + const result = validateSceneManifest(value); + valid = result.valid; + first = result.firstError === null ? null : { code: result.firstError.code, path: result.firstError.path }; + } else if (item.operation === "scene_compile_option") { + const value = structuredClone(scene); + const field = parameters.field as string; + const number = parameters.value as number; + asRecord(value.compile_options)[field] = number; + asRecord(asRecords(value.geometry_assets)[0].tessellation)[field] = number; + if (field === "linear_tolerance") asRecord(asRecords(value.edge_assets)[0].tessellation)[field] = number; + const result = validateSceneManifest(withSceneRevision(value)); + valid = result.valid; + first = result.firstError === null ? null : { code: result.firstError.code, path: result.firstError.path }; + } else { + throw new Error(`unknown resource operation: ${item.operation}`); + } + } catch (caught) { + valid = false; + error = (caught as Error).message; + } + assert.equal(valid, item.valid); + if (Object.hasOwn(item, "error")) assert.equal(error, item.error ?? null); + if (item.expected !== undefined && item.expected !== null) { + assert.equal(first?.code, item.expected.code); + assert.equal(first?.path, item.expected.path); + } + }); +}); + +test("canonical stored ZIP reproduces the exact shared vector", () => { + const expected = decode(corpus.artifacts.canonical_zip.base64); + const archive = preflightZipBytes(expected); + assert.equal(archive.inputSize, archive.canonicalSize); + assert.deepEqual(canonicalZipBytes(archive.members), expected); + assert.equal(`sha256:${createHash("sha256").update(expected).digest("hex")}`, corpus.artifacts.canonical_zip.sha256); +}); diff --git a/SimpleCADAPI/scene-contract/test/documents.test.ts b/SimpleCADAPI/scene-contract/test/documents.test.ts new file mode 100644 index 0000000..9ce6488 --- /dev/null +++ b/SimpleCADAPI/scene-contract/test/documents.test.ts @@ -0,0 +1,165 @@ +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import assert from "node:assert/strict"; +import test from "node:test"; +import { + ConnectorBindingDocument, + ContractDocumentValidationError, + EntityDocument, + type ImmutableContractDocument, + NormalizedProductDocument, + parseCanonicalJson, + parseStrictJson, + PresentationDocument, + SceneDocument, + sha256, +} from "../src/index.js"; + +interface ValidationCase { + valid: boolean; + payload_base64: string; +} + +interface Corpus { + artifacts: Record; + presentation_cases: ValidationCase[]; + connector_binding_cases: ValidationCase[]; + normalized_product_cases: ValidationCase[]; +} + +interface TestFactory { + fromValue(value: unknown): ImmutableContractDocument; + parse(data: Uint8Array | string): ImmutableContractDocument; +} + +const here = dirname(fileURLToPath(import.meta.url)); +const corpus = parseStrictJson( + readFileSync(resolve(here, "../../../test/fixtures/scene-contract/corpus.json")), +) as Corpus; + +const validCase = (cases: ValidationCase[]): Buffer => { + const item = cases.find((candidate) => candidate.valid); + assert.ok(item !== undefined); + return Buffer.from(item.payload_base64, "base64"); +}; + +const documents: readonly [string, TestFactory, Buffer][] = [ + [ + "scene", + SceneDocument as unknown as TestFactory, + Buffer.from(corpus.artifacts.scene.base64, "base64"), + ], + [ + "entities", + EntityDocument as unknown as TestFactory, + Buffer.from(corpus.artifacts.entities.base64, "base64"), + ], + [ + "presentation", + PresentationDocument as unknown as TestFactory, + validCase(corpus.presentation_cases), + ], + [ + "connector binding", + ConnectorBindingDocument as unknown as TestFactory, + validCase(corpus.connector_binding_cases), + ], + [ + "normalized product", + NormalizedProductDocument as unknown as TestFactory, + validCase(corpus.normalized_product_cases), + ], +]; + +function record(value: unknown): Record { + assert.ok(value !== null && typeof value === "object" && !Array.isArray(value)); + return value as Record; +} + +function assertDeepFrozen(value: unknown): void { + if (value === null || typeof value !== "object") return; + assert.equal(Object.isFrozen(value), true); + for (const child of Object.values(value)) assertDeepFrozen(child); +} + +function nestedContainer(value: Record): Record | unknown[] { + const result = Object.values(value).find( + (child) => child !== null && typeof child === "object", + ); + assert.ok(result !== undefined); + return result as Record | unknown[]; +} + +test("all contract document factories construct, parse, validate, and round trip", () => { + for (const [name, factory, payload] of documents) { + const input = record(parseCanonicalJson(payload)); + const constructed = factory.fromValue(input); + const parsed = factory.parse(payload); + + assert.deepEqual(constructed.canonicalBytes, payload, name); + assert.deepEqual(parsed.canonicalBytes, payload, name); + assert.equal(constructed.canonicalHash, sha256(payload), name); + assert.deepEqual(constructed.toMutable(), parsed.toMutable(), name); + assert.deepEqual(factory.fromValue(constructed.value).canonicalBytes, payload, name); + } +}); + +test("contract documents resist nested mutation and isolate mutable copies", () => { + for (const [name, factory, payload] of documents) { + const input = record(parseCanonicalJson(payload)); + const document = factory.fromValue(input); + const expectedHash = document.canonicalHash; + const expectedBytes = document.canonicalBytes; + + assert.equal(Object.isFrozen(document), true, name); + assertDeepFrozen(document.value); + assert.throws(() => { + record(document.value).mutation = true; + }, TypeError, name); + const frozenNested = nestedContainer(record(document.value)); + assert.throws(() => { + if (Array.isArray(frozenNested)) frozenNested.push(null); + else frozenNested.mutation = true; + }, TypeError, name); + + input.mutation = true; + const firstCopy = record(document.toMutable()); + const secondCopy = record(document.toMutable()); + const mutableNested = nestedContainer(firstCopy); + if (Array.isArray(mutableNested)) mutableNested.push(null); + else mutableNested.mutation = true; + firstCopy.mutation = true; + + assert.equal(Object.hasOwn(record(document.value), "mutation"), false, name); + assert.equal(Object.hasOwn(secondCopy, "mutation"), false, name); + assert.notDeepEqual(firstCopy, secondCopy, name); + + const exposedBytes = document.canonicalBytes; + exposedBytes[0] ^= 0xff; + assert.deepEqual(document.canonicalBytes, expectedBytes, name); + assert.equal(document.canonicalHash, expectedHash, name); + } +}); + +test("document constructors reject invalid values, noncanonical input, and stale revisions", () => { + for (const [name, factory] of documents) { + assert.throws( + () => factory.fromValue({}), + ContractDocumentValidationError, + name, + ); + } + assert.throws(() => SceneDocument.parse('{ "schema_version": "1.0" }'), /canonical/); + + const scene = record( + parseCanonicalJson(Buffer.from(corpus.artifacts.scene.base64, "base64")), + ); + scene.revision = `sha256:${"0".repeat(64)}`; + assert.throws( + () => SceneDocument.fromValue(scene as never), + (error: unknown) => + error instanceof ContractDocumentValidationError && + error.report.firstError?.code === "revision_mismatch", + ); +}); diff --git a/SimpleCADAPI/scene-contract/test/package-budget.test.ts b/SimpleCADAPI/scene-contract/test/package-budget.test.ts new file mode 100644 index 0000000..919d7a1 --- /dev/null +++ b/SimpleCADAPI/scene-contract/test/package-budget.test.ts @@ -0,0 +1,262 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; +import { + canonicalJsonBytes, + computeSceneRevision, + parseStrictJson, + sha256, +} from "../src/canonical.js"; +import { report } from "../src/report.js"; +import { + computePackageBudgetTotals, + packageBudgetIssues, + validateSceneManifest, + validateScenePackage, +} from "../src/validation.js"; + +type JsonRecord = Record; + +interface Artifact { + base64: string; + uri?: string; +} + +interface Corpus { + artifacts: Record; + presentation_cases: { valid: boolean; payload_base64: string }[]; +} + +const here = dirname(fileURLToPath(import.meta.url)); +const corpusPath = resolve(here, "../../../test/fixtures/scene-contract/corpus.json"); +const corpus = parseStrictJson(readFileSync(corpusPath)) as Corpus; +const decode = (value: string): Buffer => Buffer.from(value, "base64"); + +function asRecord(value: unknown): JsonRecord { + assert.ok(value !== null && typeof value === "object" && !Array.isArray(value)); + return value as JsonRecord; +} + +function asRecords(value: unknown): JsonRecord[] { + assert.ok(Array.isArray(value)); + return value.map(asRecord); +} + +function validScene(): JsonRecord { + return asRecord(parseStrictJson(decode(corpus.artifacts.scene.base64))); +} + +function artifactBlobs(): Map { + const result = new Map(); + for (const artifact of Object.values(corpus.artifacts)) { + if (artifact.uri !== undefined) result.set(artifact.uri, decode(artifact.base64)); + } + return result; +} + +function validPresentation(): JsonRecord { + const value = corpus.presentation_cases.find((item) => item.valid); + assert.ok(value !== undefined); + return asRecord(parseStrictJson(decode(value.payload_base64))); +} + +function utf8Compare(left: string, right: string): number { + return Buffer.compare(Buffer.from(left, "utf8"), Buffer.from(right, "utf8")); +} + +function attachPresentation(scene: JsonRecord, blobs: Map): { presentation: JsonRecord; blobs: Map } { + const presentation = validPresentation(); + const presentationId = presentation.presentation_id as string; + const expectedAppearanceIds = new Map(); + const sceneAppearances = asRecords(scene.appearances); + for (const authored of asRecords(presentation.appearances)) { + const evaluated: JsonRecord = { + alpha_mode: authored.alpha_mode, + base_color: authored.base_color, + double_sided: authored.double_sided, + edge_color: authored.edge_color, + metallic: authored.metallic, + name: authored.name, + roughness: authored.roughness, + sdk_metadata: {}, + source: { + appearance_name: authored.name, + kind: "presentation", + presentation_id: presentationId, + }, + }; + const appearanceId = `appearance/evaluated/${sha256(canonicalJsonBytes(evaluated)).slice("sha256:".length)}`; + expectedAppearanceIds.set(authored.name, appearanceId); + sceneAppearances.push({ appearance_id: appearanceId, ...evaluated }); + } + sceneAppearances.sort((left, right) => utf8Compare(left.appearance_id as string, right.appearance_id as string)); + scene.appearances = sceneAppearances; + + const nodeMap = new Map(asRecords(scene.nodes).map((node) => [node.node_id, node])); + for (const override of asRecords(presentation.node_overrides)) { + const node = nodeMap.get(override.node_id); + assert.ok(node !== undefined); + if (override.visible !== undefined) node.visible = override.visible; + if (override.appearance_name !== undefined) node.appearance_override_id = expectedAppearanceIds.get(override.appearance_name); + } + scene.cameras = asRecords(presentation.cameras).map((camera) => ({ + camera_id: `camera/${presentationId}/${String(camera.name)}`, + ...camera, + })); + + const presentationBytes = canonicalJsonBytes(presentation); + asRecord(scene.compile_options).embed_presentation = true; + scene.presentation_source = { + artifact_hash: sha256(presentationBytes), + embedded_artifact_byte_length: presentationBytes.length, + embedded_artifact_uri: "presentation/presentation.json", + presentation_id: presentationId, + schema_version: "1.0", + }; + scene.revision = computeSceneRevision(scene); + const resultBlobs = new Map(blobs); + resultBlobs.set("presentation/presentation.json", presentationBytes); + return { presentation, blobs: resultBlobs }; +} + +function replacePresentation(scene: JsonRecord, blobs: Map, payload: Buffer): Map { + const source = asRecord(scene.presentation_source); + source.artifact_hash = sha256(payload); + source.embedded_artifact_byte_length = payload.length; + scene.revision = computeSceneRevision(scene); + const result = new Map(blobs); + result.set("presentation/presentation.json", payload); + return result; +} + +test("package budget formula and first error match Python with declarative limits", () => { + const totals = computePackageBudgetTotals({ + sceneJsonBytes: 7, + glbDecodedBufferBytes: 11, + entityJsonBytes: 13, + otherImmutableJsonBytes: 17, + entityCount: 23, + entityVertexCount: 19, + triangleVertexCount: 29, + triangleCount: 31, + lineVertexCount: 37, + lineSegmentCount: 41, + }); + assert.deepEqual(totals, { + staticDecodedBufferBytes: 656, + entityCount: 23, + triangleVertexTotal: 29, + triangleTotal: 31, + lineVertexTotal: 37, + lineSegmentTotal: 41, + }); + assert.deepEqual(packageBudgetIssues(totals, { + static_decoded_buffer_bytes: 656, + entities_total: 23, + triangle_vertices_total: 29, + triangles_total: 31, + line_vertices_total: 37, + line_segments_total: 41, + }), []); + + const result = report(packageBudgetIssues(totals, { + static_decoded_buffer_bytes: 655, + entities_total: 22, + triangle_vertices_total: 28, + triangles_total: 30, + line_vertices_total: 36, + line_segments_total: 40, + }), "package"); + assert.deepEqual( + result.firstError === null + ? null + : [result.firstError.code, result.firstError.path, result.firstError.message], + ["resource_limit_exceeded", "", "total line GLB vertex count exceeds resource limit"], + ); +}); + +test("embedded presentation must be canonical and resolve exactly against the scene", () => { + const scene = validScene(); + const attached = attachPresentation(scene, artifactBlobs()); + const validResult = validateScenePackage(canonicalJsonBytes(scene), attached.blobs); + assert.equal(validResult.valid, true, JSON.stringify(validResult.issues)); + + const noncanonical = Buffer.from(JSON.stringify(attached.presentation, null, 2)); + const blobs = replacePresentation(scene, attached.blobs, noncanonical); + const result = validateScenePackage(canonicalJsonBytes(scene), blobs); + assert.deepEqual( + result.firstError === null ? null : [result.firstError.code, result.firstError.path], + ["noncanonical_json", ""], + ); +}); + +test("embedded presentation IDs and scene references are contextual", async (context) => { + const cases: [string, (presentation: JsonRecord) => void, string, string][] = [ + ["presentation ID", (presentation) => { presentation.presentation_id = "other"; }, "source_matrix_invalid", "/presentation/presentation.json/presentation_id"], + ["source scene ID", (presentation) => { presentation.source_scene_id = "other"; }, "source_matrix_invalid", "/presentation/presentation.json/source_scene_id"], + ["node override", (presentation) => { asRecords(presentation.node_overrides)[0].node_id = "instance/missing"; }, "source_matrix_invalid", "/presentation/presentation.json/node_overrides/0/node_id"], + ["camera parent", (presentation) => { asRecords(presentation.cameras)[0].parent_node_id = "instance/missing"; }, "source_matrix_invalid", "/presentation/presentation.json/cameras/0/parent_node_id"], + ]; + for (const [name, mutate, code, path] of cases) await context.test(name, () => { + const scene = validScene(); + const attached = attachPresentation(scene, artifactBlobs()); + mutate(attached.presentation); + const blobs = replacePresentation(scene, attached.blobs, canonicalJsonBytes(attached.presentation)); + const result = validateScenePackage(canonicalJsonBytes(scene), blobs); + assert.ok(result.issues.some((item) => item.code === code && item.path === path)); + }); +}); + +test("presentation appearance provenance and camera resolution are exact", () => { + const scene = validScene(); + const attached = attachPresentation(scene, artifactBlobs()); + const sceneAppearances = asRecords(scene.appearances); + const appearance = sceneAppearances.find((item) => item.source !== null && asRecord(item.source).kind === "presentation"); + assert.ok(appearance !== undefined); + asRecord(appearance.source).appearance_name = "Other"; + const draft = { ...appearance }; + delete draft.appearance_id; + const appearanceId = `appearance/evaluated/${sha256(canonicalJsonBytes(draft)).slice("sha256:".length)}`; + appearance.appearance_id = appearanceId; + asRecords(scene.nodes)[0].appearance_override_id = appearanceId; + sceneAppearances.sort((left, right) => utf8Compare(left.appearance_id as string, right.appearance_id as string)); + scene.appearances = sceneAppearances; + scene.cameras = []; + scene.revision = computeSceneRevision(scene); + + const result = validateScenePackage(canonicalJsonBytes(scene), attached.blobs); + const appearanceIndex = asRecords(scene.appearances).indexOf(appearance); + assert.ok(result.issues.some((item) => item.code === "source_matrix_invalid" && item.path === `/appearances/${appearanceIndex}/source/appearance_name`)); + assert.ok(result.issues.some((item) => item.code === "source_matrix_invalid" && item.path === "/presentation/presentation.json/cameras/0")); +}); + +test("product material provenance must match a Part definition root", () => { + const scene = validScene(); + const appearance = asRecords(scene.appearances)[0]; + appearance.source = { kind: "product_material", material_id: "steel", root_id: "other" }; + const draft = { ...appearance }; + delete draft.appearance_id; + const appearanceId = `appearance/evaluated/${sha256(canonicalJsonBytes(draft)).slice("sha256:".length)}`; + appearance.appearance_id = appearanceId; + asRecords(scene.definitions)[0].appearance_id = appearanceId; + scene.revision = computeSceneRevision(scene); + + const result = validateSceneManifest(scene); + assert.ok(result.issues.some((item) => item.code === "source_matrix_invalid" && item.path === "/appearances/0/source/root_id")); +}); + +test("one URI cannot declare contradictory hash, length, or media roles", () => { + const scene = validScene(); + const edge = asRecords(scene.edge_assets)[0]; + const oldUri = edge.uri as string; + edge.uri = asRecords(scene.geometry_assets)[0].uri; + scene.revision = computeSceneRevision(scene); + const blobs = artifactBlobs(); + blobs.delete(oldUri); + + const result = validateScenePackage(canonicalJsonBytes(scene), blobs); + assert.ok(result.issues.some((item) => item.code === "package_member_set_invalid")); + assert.ok(result.issues.some((item) => item.code === "source_matrix_invalid" && item.path === "/edge_assets/0/uri")); +}); diff --git a/SimpleCADAPI/scene-contract/test/rules.test.ts b/SimpleCADAPI/scene-contract/test/rules.test.ts new file mode 100644 index 0000000..052fddc --- /dev/null +++ b/SimpleCADAPI/scene-contract/test/rules.test.ts @@ -0,0 +1,81 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; +import { issue, parseStrictJson, report, validateRuleRegistry } from "../src/index.js"; + +const contractRoot = resolve( + dirname(fileURLToPath(import.meta.url)), + "../../../src/simplecadapi/scene/contracts", +); + +function registry(): Record { + return structuredClone( + parseStrictJson(readFileSync(resolve(contractRoot, "rules/scene-1.0-rules.json"))), + ) as Record; +} + +function rules(value: Record): Record[] { + assert.ok(Array.isArray(value.rules)); + return value.rules as Record[]; +} + +test("rule registry rejects malformed shape, duplicates, and wrong ordering", () => { + const valid = registry(); + assert.equal(validateRuleRegistry(valid).size, rules(valid).length); + + const missingPrecedence = registry(); + delete rules(missingPrecedence)[0].precedence; + assert.throws(() => validateRuleRegistry(missingPrecedence), /precedence/); + + const duplicateId = registry(); + rules(duplicateId)[1].id = rules(duplicateId)[0].id; + assert.throws(() => validateRuleRegistry(duplicateId), /duplicate rule ID/); + + const duplicatePrecedence = registry(); + rules(duplicatePrecedence)[1].precedence = 0; + assert.throws(() => validateRuleRegistry(duplicatePrecedence), /duplicate precedence/); + + const wrongOrder = registry(); + [rules(wrongOrder)[0], rules(wrongOrder)[1]] = [rules(wrongOrder)[1], rules(wrongOrder)[0]]; + assert.throws(() => validateRuleRegistry(wrongOrder), /ordered by phase and precedence/); + + const wrongIdOrder = registry(); + [rules(wrongIdOrder)[0].id, rules(wrongIdOrder)[1].id] = [rules(wrongIdOrder)[1].id, rules(wrongIdOrder)[0].id]; + assert.throws(() => validateRuleRegistry(wrongIdOrder), /unsigned UTF-8 rule-ID order/); +}); + +test("registered precedence orders artifact reports", () => { + const result = report([ + issue("transform_invalid", "/z", "later registered rule"), + issue("bounds_invalid", "/z", "z message"), + issue("bounds_invalid", "/z", "a message"), + issue("bounds_invalid", "/a", "earlier pointer"), + ], "scene"); + assert.deepEqual(result.issues.map(({ code, path, message }) => [code, path, message]), [ + ["bounds_invalid", "/a", "earlier pointer"], + ["bounds_invalid", "/z", "a message"], + ["bounds_invalid", "/z", "z message"], + ["transform_invalid", "/z", "later registered rule"], + ]); +}); + +test("artifact reports reject unregistered, wrong-phase, and wrong-artifact issues", () => { + assert.throws( + () => report([issue("not_registered", "", "no authority")], "scene"), + /unregistered/, + ); + assert.throws( + () => report([issue("bounds_invalid", "", "wrong phase", "package")], "scene"), + /registered phase/, + ); + assert.throws( + () => report([issue("presentation_reference_invalid", "", "wrong artifact")], "scene"), + /does not apply/, + ); + assert.throws( + () => report([issue("duplicate_json_key", "/value", "wrong pointer", "parse")], "scene"), + /root pointer/, + ); +}); diff --git a/SimpleCADAPI/scene-contract/test/zip-resource.test.ts b/SimpleCADAPI/scene-contract/test/zip-resource.test.ts new file mode 100644 index 0000000..22799b2 --- /dev/null +++ b/SimpleCADAPI/scene-contract/test/zip-resource.test.ts @@ -0,0 +1,91 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { BASE_LIMITS, preflightZipBytes } from "../src/index.js"; + +function declaredDeflateZip( + compressed: Uint8Array, + uncompressedSize: number, + localUncompressedSize = uncompressedSize, +): Buffer { + const name = Buffer.from("scene.json", "ascii"); + const local = Buffer.alloc(30); + local.writeUInt32LE(0x04034b50, 0); + local.writeUInt16LE(20, 4); + local.writeUInt16LE(0x0800, 6); + local.writeUInt16LE(8, 8); + local.writeUInt16LE(0, 10); + local.writeUInt16LE(0x0021, 12); + local.writeUInt32LE(0, 14); + local.writeUInt32LE(compressed.byteLength, 18); + local.writeUInt32LE(localUncompressedSize, 22); + local.writeUInt16LE(name.length, 26); + local.writeUInt16LE(0, 28); + + const centralOffset = local.length + name.length + compressed.byteLength; + const central = Buffer.alloc(46); + central.writeUInt32LE(0x02014b50, 0); + central.writeUInt16LE(0x0314, 4); + central.writeUInt16LE(20, 6); + central.writeUInt16LE(0x0800, 8); + central.writeUInt16LE(8, 10); + central.writeUInt16LE(0, 12); + central.writeUInt16LE(0x0021, 14); + central.writeUInt32LE(0, 16); + central.writeUInt32LE(compressed.byteLength, 20); + central.writeUInt32LE(uncompressedSize, 24); + central.writeUInt16LE(name.length, 28); + central.writeUInt16LE(0, 30); + central.writeUInt16LE(0, 32); + central.writeUInt16LE(0, 34); + central.writeUInt16LE(0, 36); + central.writeUInt32LE(0x81a40000, 38); + central.writeUInt32LE(0, 42); + + const eocd = Buffer.alloc(22); + eocd.writeUInt32LE(0x06054b50, 0); + eocd.writeUInt16LE(0, 4); + eocd.writeUInt16LE(0, 6); + eocd.writeUInt16LE(1, 8); + eocd.writeUInt16LE(1, 10); + eocd.writeUInt32LE(central.length + name.length, 12); + eocd.writeUInt32LE(centralOffset, 16); + eocd.writeUInt16LE(0, 20); + return Buffer.concat([local, name, compressed, central, name, eocd]); +} + +function assertError(archive: Uint8Array, expected: string): void { + assert.throws(() => preflightZipBytes(archive), (error) => { + assert.equal((error as Error).message, expected); + return true; + }); +} + +test("aggregate ratio uses cross-checked declarations before inflate", () => { + const compressed = Buffer.from([0]); + const template = declaredDeflateZip(compressed, 0); + const exactSize = template.length * BASE_LIMITS.compression_ratio; + + assertError( + declaredDeflateZip(compressed, exactSize), + 'member compression ratio exceeds limit: "scene.json"', + ); + assertError( + declaredDeflateZip(compressed, exactSize + 1), + "aggregate compression ratio exceeds limit", + ); + assertError( + declaredDeflateZip(compressed, exactSize + 1, exactSize), + "ZIP central/local header mismatch", + ); +}); + +test("zero compressed size uses one as the member ratio denominator", () => { + assertError( + declaredDeflateZip(Buffer.alloc(0), BASE_LIMITS.compression_ratio), + 'invalid deflate stream: "scene.json"', + ); + assertError( + declaredDeflateZip(Buffer.alloc(0), BASE_LIMITS.compression_ratio + 1), + 'member compression ratio exceeds limit: "scene.json"', + ); +}); diff --git a/SimpleCADAPI/scene-contract/tsconfig.json b/SimpleCADAPI/scene-contract/tsconfig.json new file mode 100644 index 0000000..a4405aa --- /dev/null +++ b/SimpleCADAPI/scene-contract/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "rootDir": ".", + "outDir": "dist", + "strict": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "declaration": true, + "sourceMap": true + }, + "include": ["src/**/*.ts", "test/**/*.ts"] +} diff --git a/SimpleCADAPI/skills/simplecadapi/SKILL.md b/SimpleCADAPI/skills/simplecadapi/SKILL.md index 7f8f3e7..b6c482b 100644 --- a/SimpleCADAPI/skills/simplecadapi/SKILL.md +++ b/SimpleCADAPI/skills/simplecadapi/SKILL.md @@ -5,9 +5,9 @@ license: AGPL-3.0 compatibility: Documentation/reference bundle for current SimpleCADAPI surfaces. metadata: project: simplecadapi - version: 2.0.1b1 + version: 2.0.2 package-name: simplecadapi - package-version: 2.0.1b1 + package-version: 2.0.2 --- # SimpleCAD SDK Skill @@ -29,6 +29,7 @@ metadata: - `/references/SDK_OVERVIEW.md` - `/references/SDK_SURFACES.md` - `/references/MODELING_WORKFLOWS.md` + - `/references/inverse_engineer/brep-reverse-engineering.md` ## MUST Requirements 1. Read `SKILL.md`, `references/docs/api/README.md`, and `references/docs/stdlib/README.md` before choosing APIs. @@ -37,9 +38,9 @@ metadata: 4. Prefer the standard parts library for standard parts before hand-modeling with core geometry APIs. 5. Follow the documented API signatures exactly. 6. When calling any SimpleCAD public API or standard-library function, use keyword arguments for every documented parameter; do not use positional arguments. -7. Use the graph/model JSON workflow for replayable tasks: `GraphSession`, `export_session_json`, `export_model_json`, `import_model_json`, and `replay_model_json`. +7. Use one `@model` entry point for replayable tasks, `@requires_session` for child builders, `capture_result(...)` for explicit outputs, and the returned `ModelResult` for model/session JSON and replay. 8. Use geometry APIs for integrated parts: profiles, features, booleans, transforms, tagging, QL inspection, serialization, and exports. -9. Use tags consistently through `apply_tag(shape=..., tag=...)` and `list_tags(shape=...)`; do not call shape member tag mutators. +9. Use tags through `apply_tag(shape=..., tag=...)`, `apply_tag_rselection(scope=..., targets=..., tag=...)`, `list_tags(shape=..., scope=...)`, and `explain_tag(shape=..., tag=..., scope=...)`; do not call shape member tag mutators. 10. Build and validate incrementally. Each step MUST include a small grounding `print`, and grounding MUST use QL where possible. 11. For inspection/debugging, query geometry with QL and print only the queried facts you need; do not print whole solids or full model objects. 12. Boolean operations return a single `Solid`. @@ -47,12 +48,21 @@ metadata: 14. For automated example/test harnesses, prefer the repo-local examples in `examples/` and avoid scratch scripts in `sandbox/`. 15. If union cannot produce exactly one merged solid, it fails explicitly; do not silently pick one piece. 16. If a single merged solid is required and union fails, slightly adjust part placement so intended bodies overlap/embed, then recompute. -17. If a task depends on model replay or interchange, prefer `export_model_json()` output over hand-written payloads. +17. If a task depends on model replay or interchange, prefer `ModelResult.model_json` or `export_model_json()` output over hand-written payloads. +18. For STEP/BREP reverse engineering, geometry reconstruction, topology matching, feature-history inference, or target/candidate CAD comparison, read `references/inverse_engineer/brep-reverse-engineering.md` completely before inspecting or modeling. +19. In BREP reverse engineering, geometry equality and topology equality are hard requirements. Use engineering prior only to choose among processes that satisfy both; do not accept visual, volume, area, or topology-count similarity as completion. + +## Task Router +- **Standard mechanical part**: read `references/docs/stdlib/README.md`, then the exact stdlib API page. +- **General integrated modeling**: read `references/MODELING_WORKFLOWS.md` and each exact API page used. +- **STEP/BREP reverse engineering or exact target comparison**: read `references/inverse_engineer/brep-reverse-engineering.md`; use `simplecadapi.inverse_engineer.brep` for inspection, strict comparison, views, and slice XOR diagnostics. +- **Sketch and constraints**: read the relevant sketch API pages and `references/docs/core/declarative_constraints.md` when needed. +- **Model JSON, graph replay, or translation**: read the graph/serialization API pages and the relevant translator documentation. ## Standard Parts Library - SimpleCAD includes a standard library for parameterized mechanical parts. - When the user needs a standard part and does not require complex custom geometry changes, use a standard-library function first. -- Current package-level standard-library surfaces include `scad.std.gear` for involute gears, internal ring gears, racks, and cycloidal discs, plus `scad.std.bearing` for ball bearing assemblies. +- Current package-level standard-library surfaces include `scad.std.gear` for gears and racks, `scad.std.bearing` for ball bearing assemblies, `scad.std.chain` for roller-chain sprockets, and `scad.std.fastener` for bolts and nuts. - Read `references/docs/stdlib/README.md` to discover standard-library functions. - Read `references/docs/stdlib/.md` before calling a standard-library function. - Standard-library functions return normal SimpleCAD shapes or product assemblies that can be transformed, tagged, assembled, exported, and used with graph/model JSON workflows. @@ -69,23 +79,32 @@ metadata: - Build from lower-dimensional geometry to higher-dimensional geometry: `Vertex` / `Edge` / `Wire` / `Face` profiles first, then `Solid` features such as extrude, revolve, loft, and sweep. - Keep modeling operations functional. Create new values from public functions such as `make_circle_rface(...)`, `extrude_rsolid(...)`, `cut_rsolid(...)`, and `fillet_rsolid(...)`. - Use keyword arguments for all SimpleCAD function calls, for example `make_box_rsolid(width=10.0, height=20.0, depth=3.0)` instead of positional arguments. -- Use `GraphSession` when the model should be replayable, inspectable, exported as model JSON, or translated to another CAD system. -- Treat model JSON as the interchange boundary. Prefer `export_model_json(session=...)` and `replay_model_json(json_str=...)` over hand-authored operation payloads. +- Use `@model` when the top-level model should be replayable, inspectable, exported as model JSON, or translated to another CAD system. It owns one `GraphSession`; reusable graph-producing builders use `@requires_session`. +- Treat model JSON as the interchange boundary. Prefer `ModelResult.model_json` and `ModelResult.replay()` for top-level models; use `export_model_json(session=...)` for lower-level direct sessions and `replay_model_json(json_str=...)` for standalone payloads. - Use QL for precise grounding. Query faces, edges, centers, normals, areas, lengths, curve types, and tags; print only the facts needed to validate the current step. - Use `get_edges(index)`, `get_faces(index)`, `get_wires(index)`, or `get_vertices(index)` when an indexed topology pick is intentional; these picks are preserved as geo select nodes in replayable graph workflows. -- Use tags for semantic intent and selection anchors, such as `role.mounting_surface`, `anchor.datum.primary`, `face.top`, or `group.fasteners`. +- Use tags for topology identity, semantic intent, and selection anchors, such as `housing.face.top`, `role.mounting_surface`, `anchor.datum.primary`, or `group.fasteners`. - Keep numeric and geometric facts in metadata or graph payloads, not in tags. - When a QL-selected face or edge is used by a later feature, expect the graph/model workflow to preserve that selection as a stable geo select node. - For FreeCAD translation, prefer canonical model JSON generated from a `GraphSession`; selected profiles and detail-feature selections should come from the graph rather than ad hoc object lookup. ## Tagging Mental Model - Public tag attachment is `apply_tag(shape=..., tag=...)`. -- Public tag inspection is `list_tags(shape=...)`, which returns a stable sorted list. +- Multi-entity or explicitly propagated attachment is `apply_tag_rselection(scope=..., targets=..., tag=..., topology_propagation=..., lineage_policy=...)`; it returns an independent semantic shape view. +- Public tag inspection is `list_tags(shape=..., scope=...)`, which returns a stable sorted list. Use `explain_tag(...)` when producer and evidence matter. - Tags are normalized lowercase dot-separated semantic tokens, for example `role.mounting_surface`, `anchor.datum.primary`, `group.fasteners`, `face.top`, or `solid.boolean.cut`. - Do not encode numeric dimensions or descriptive geometry payloads in tags; store them in metadata such as `shape.get_metadata("geo")` or `shape.set_metadata(...)`. -- `apply_tag(...)` does not expose propagation controls. The SDK propagates role/anchor/group-style semantic tags downward and keeps topology-specific tags such as `face.*`, `edge.*`, `wire.*`, `vertex.*`, and `solid.*` local. -- Primitives, face auto-tagging, features, booleans, transforms, and tracking may add normalized topology/operation tags automatically. -- Prefer QL tag predicates (`ql.tag("role.*")`, `ql.select(...).where(...)`) for inspection and grounding. +- Direct user assignments default to local topology propagation regardless of tag text. Constructor-generated topology-identity Face tags may use downward propagation so boundary Edges expose the Face tag; use `TopologyPropagation.DOWNWARD` explicitly for the same behavior in `apply_tag_rselection(...)`. +- `effective` includes local and inherited bindings but excludes lineage. Use `scope=TagScope.LINEAGE` only when complete topology-history evidence is available. +- Operation events, source roles, and feature output roles are typed `metadata["track"]`, not flat tags. Query them with `ql.operation_event(...)`, `ql.origin_role(...)`, and `ql.output_role(...)`; unknown correspondence remains partial/unknown. +- `extrude_rsolid`, `revolve_rsolid`, `fillet_rsolid`, `chamfer_rsolid`, `shell_rsolid`, `loft_rsolid`, `sweep_rsolid`, and `twisted_sweep_rsolid` accept strict role-specific tag arguments. Requested roles must have complete kernel evidence and satisfy their documented cardinality or the whole call fails. +- Role-specific tag arguments and `result_tag` lower to canonical `apply_tag_rselection` nodes in a `GraphSession`; they are semantic assignments, not geometry parameters. There is one public argument per target role; do not use a generic role-to-tag mapping. +- Topology identity and user semantics use the same `TagBinding`, `list_tags(...)`, `explain_tag(...)`, and `ql.tag(...)` surfaces. One topology object may carry multiple tags for different purposes. +- Geometry constructors and features use `tag_prefix` to create topology-identity tags such as `housing.face.top`; profile APIs use `edge_tag` or `edge_tags` for local Edge tag segments. These bindings carry `topology_name` evidence and project only across exact kernel-proven correspondence. +- Role-specific `*_tag` arguments and `result_tag` create tags with operation-role or result evidence. Tag text alone does not determine evidence or projection policy. +- Query proven source projection with `ql.source_binding(...)` or `ql.source_topology(...)`. These predicates inspect canonical local binding evidence, never tag text or geometric similarity. +- When a tagged profile entity must feed a later feature, pass the semantic view returned by `apply_tag_rselection(...)` into that feature. Tagging a detached branch and then using the original profile does not create hidden graph coupling. +- Prefer scoped QL tag predicates (`ql.tag("role.*", scope="effective")`, `ql.select(...).where(...)`) for inspection and grounding. ## SDK Focus - This skill is intended to describe the public CAD Python SDK surface. @@ -100,20 +119,22 @@ metadata: ```python import simplecadapi as scad -from simplecadapi import GraphSession, export_model_json, make_box_rsolid +from simplecadapi import ModelResult, capture_result, model, requires_session ``` Typical replayable usage in a Python script: ```python import simplecadapi as scad -from simplecadapi import GraphSession, export_model_json, replay_model_json -with GraphSession() as session: +@scad.model(graph_id="box") +def build_box(): shape = scad.make_box_rsolid(width=10.0, height=20.0, depth=30.0) + scad.capture_result(value=shape) + return shape -model_json = export_model_json(session=session) -rebuilt = replay_model_json(json_str=model_json) +result = build_box() +rebuilt = result.replay() print(len(rebuilt)) ``` @@ -127,3 +148,4 @@ Use the graph/model JSON workflow when the task needs reproducibility, interchan - `references/docs/api/` - `references/docs/stdlib/` - `references/docs/core/` +- `references/inverse_engineer/brep-reverse-engineering.md` diff --git a/SimpleCADAPI/skills/simplecadapi/references/MODELING_WORKFLOWS.md b/SimpleCADAPI/skills/simplecadapi/references/MODELING_WORKFLOWS.md index b2b3541..fa6a2b3 100644 --- a/SimpleCADAPI/skills/simplecadapi/references/MODELING_WORKFLOWS.md +++ b/SimpleCADAPI/skills/simplecadapi/references/MODELING_WORKFLOWS.md @@ -6,35 +6,40 @@ - Use the standard parts library first when a requested standard component is available and does not need complex custom geometry changes. - Start from profiles and reference geometry, then create solids with features such as extrude, revolve, loft, and sweep. - Use booleans and detail features after the base form is clear: cut openings, union intended merged bodies, then apply fillets, chamfers, or shell operations. -- Use `GraphSession` whenever the result should be replayable, inspectable, serialized, or translated. +- Use `@scad.model` for a top-level replayable entry point. It owns one `GraphSession` and returns a `ModelResult`; use `@scad.requires_session` for child builders. - Use QL for grounding and selection. Query the facts you need, such as face normals, centers, areas, edge lengths, curve types, and tags. - Use indexed child-geometry getters such as `get_edges(index)` and `get_faces(index)` when an indexed topology pick is intentional. - Use semantic tags for design intent and anchors. Keep numeric measurements and geometry facts in metadata or model JSON payloads. -- Treat `export_model_json()` as the interchange boundary for replay and CAD translation. +- Treat `ModelResult.model_json` as the interchange boundary for new model entry points. Use `export_model_json(session=...)` for lower-level direct sessions. +- Declare directly controlled dimensions with `var(..., unit=..., tolerance=..., tolerance_unit=...)`, and attach derived requirements with `GraphSession.require_tolerance(...)` when manufacturing variation matters. - Validate incrementally: after each major step, print small QL-derived facts such as selected face count, top face center, edge count, volume, or replay result count. ## 1) Capture a replayable modeling flow ```python -from simplecadapi import GraphSession, export_model_json +import simplecadapi as scad -with GraphSession() as session: - ... +@scad.model(graph_id="bracket") +def build_bracket(): + body = scad.make_box_rsolid(width=20.0, height=10.0, depth=3.0) + scad.capture_result(value=body) + return body -payload = export_model_json(session=session) +result = build_bracket() +payload = result.model_json +rebuilt = result.replay() ``` ## 2) Import and use in Python ```python import simplecadapi as scad -from simplecadapi import GraphSession, export_model_json ``` ## 3) Keep replay payloads as the interchange boundary - Prefer `export_model_json()` output instead of hand-written payloads. -- Use `replay_model_json()` when you need deterministic reconstruction. +- Use `ModelResult.replay()` for a model invocation, or `replay_model_json(json_str=...)` when consuming standalone model JSON. - Use `import_model_json()` when consuming previously exported payloads. ## 4) Use standard parts when they fit @@ -49,10 +54,10 @@ gear = scad.std.gear.make_spur_gear_rsolid( ) rack = scad.std.gear.make_spur_rack_rsolid(module=1.5, n_teeth=18) bearing = scad.std.bearing.make_ball_bearing_rassembly( - 8.0, - 22.0, - 7.0, - 3.5, + bore_diameter=8.0, + outer_diameter=22.0, + bearing_width=7.0, + ball_diameter=3.5, ) ``` @@ -66,25 +71,30 @@ bearing = scad.std.bearing.make_ball_bearing_rassembly( import simplecadapi as scad from simplecadapi import ql -with scad.GraphSession() as session: +@scad.model(graph_id="swept_profile") +def build_model(): profile = scad.make_circle_rface(center=(0, 0, 0), radius=1.0) body = scad.extrude_rsolid( profile=profile, direction=(0, 0, 1), distance=4.0, + end_face_tag="role.sweep_profile", + result_tag="part.body", ) end_face = ( ql.faces() - .where(ql.tag("face.extrusion.end")) + .where(ql.output_role(role_name="extrusion.end")) .exactly(1) .resolve(body)[0] ) print("end face center", end_face.get_center()) path = scad.make_segment_rwire(start=(0, 0, 4), end=(0, 0, 8)) swept = scad.sweep_rsolid(profile=end_face, path=path) + scad.capture_result(value=swept) + return swept -payload = scad.export_model_json(session=session) -rebuilt = scad.replay_model_json(json_str=payload) +result = build_model() +rebuilt = result.replay() print("rebuilt", len(rebuilt)) ``` @@ -92,14 +102,129 @@ print("rebuilt", len(rebuilt)) - Prefer QL selectors for semantic/geometric feature input selection. - Use `get_edges(index)`, `get_faces(index)`, `get_wires(index)`, or `get_vertices(index)` for intentional indexed picks in examples. -- Attach semantic tags with `apply_tag(shape=..., tag=...)` and inspect with `list_tags(shape=...)`. -- Use tags for intent, roles, anchors, groups, and topology names. -- Store dimensions, positions, measured geometry, and descriptive payloads in metadata or model JSON, not in tags. +- Attach local semantic tags with `apply_tag(shape=..., tag=...)`. Use `apply_tag_rselection(...)` for selector targets, explicit downward inheritance, or immutable semantic branches. +- Inspect with `list_tags(shape=..., scope=...)` and `explain_tag(...)`; `effective` excludes `lineage`. +- Use tags for topology identity, intent, roles, anchors, and groups. +- Store dimensions, positions, measured geometry, operation events, source roles, and descriptive payloads in metadata or model JSON, not in tags. - Keep QL result prints concise: selected count, centers, normals, areas, lengths, or tags. -## 7) Boolean and body discipline +## 7) Kernel-proven feature output roles + +- Feature output roles are typed tracking evidence, not user tag strings. Query them with `ql.output_role(role_name=...)`. +- The feature APIs expose one named tag argument per output role. Every requested role is strict: `one` roles require exactly one proven target, and `many` roles require at least one and tag the full proven set. +- There is no generic role-to-tag mapping. Unknown or unavailable roles and non-normalized tags fail before returning geometry. +- `result_tag` targets the one result Solid. Role tags target Faces except for `shell.wall`, which targets Edges. +- `tag_prefix` creates topology-identity tags such as `body.face.side.right`; role-specific tag parameters and `result_tag` create tags such as `role.mounting_surface` and `part.body`. They share one tag API, while their evidence and projection policies differ. Tag text alone never establishes topology identity. +- Box exposes the exact `box.bottom`, `box.top`, `box.front`, `box.back`, `box.left`, and `box.right` Face roles. It does not expose Edge roles; use tagged incident Faces with QL `incident_to(...)` or `shared_boundary(...)` for exact Box Edge selection. +- Cone exposes `cone.start`, optional `cone.end`, and `cone.side` Face roles plus `cone.start_boundary`, `cone.end_boundary`, and `cone.seam` Edge roles. A pointed cone has no end cap Face, but its end boundary is retained as the kernel's degenerate apex Edge. +- A 360-degree revolve has no distinct start/end caps. Requesting those tags fails rather than inventing cap roles. +- Shell role availability follows actual OCC evidence. For example, an operation may prove offset faces, closing descendants, and wall edges without proving a `shell.body_face` set. +- Sweep currently rejects a profile with inner wires rather than silently sweeping only its outer wire. + +```python +body = scad.extrude_rsolid( + profile=profile, + direction=(0, 0, 1), + distance=10.0, + start_face_tag="anchor.base", + end_face_tag="role.mounting_surface", + side_faces_tag="group.outer_walls", + result_tag="part.body", +) + +mounting_face = ( + ql.faces() + .where(ql.output_role(role_name="extrusion.end")) + .exactly(1) + .resolve(body)[0] +) +print(scad.list_tags(shape=mounting_face, scope="local")) +``` + +In a `GraphSession`, each requested assignment becomes a canonical +`apply_tag_rselection` semantic node. The feature node remains geometry-only, and +replay validates both the recomputed role set and the exact selected refs. + +## 8) Replay-safe source projection + +When a feature supports source projection, keep the returned semantic view in the +feature's input chain: + +```python +profile = scad.make_rectangle_rface(width=5.0, height=3.0) +source_edge = profile.get_edges(0) +profile = scad.apply_tag_rselection( + scope=profile, + targets=[source_edge], + tag="role.source_edge", +) +source_edge = scad.select_edges_by_tag( + shape=profile, + tag="role.source_edge", + scope="local", +)[0] +source_binding_id = scad.explain_tag( + shape=source_edge, + tag="role.source_edge", + scope="local", +)[0]["binding_id"] + +body = scad.extrude_rsolid( + profile=profile, + direction=(0, 0, 1), + distance=2.0, +) +projected_face = ( + ql.faces() + .where(ql.source_binding(binding_id=source_binding_id)) + .exactly(1) + .resolve(body)[0] +) +print(scad.list_tags(shape=projected_face, scope="local")) +``` + +The projected binding preserves the exact source binding ID, source topology ID, +target topology ID, operation, role, and evidence method. `ql.source_binding(...)` +and `ql.source_topology(...)` inspect this local evidence. They do not search tag +text or infer ancestry from geometry. Calling `apply_tag_rselection(...)` but then +passing the original profile to the feature creates a detached semantic branch and +does not authorize hidden coupling. + +## 9) Constrained Sketch topology tags + +- Give every profile entity a stable explicit `entity_id` when constructing a constrained Sketch. +- Promote through `make_wire_from_sketch_rwire(...)` or `make_face_from_sketch_rface(...)`; the promotion map is the exact entity-to-Edge correspondence. +- Inspect canonical topology-identity tags with `ql.tag("sketch.rect.entity.right")` or `explain_tag(...)`; the evidence contains `topology_name` and `SketchPromotionMap`. +- A promoted profile receives `sketch..profile.` and each boundary Edge receives `sketch..entity.`. +- If the generated Edge count differs from the promotion map, fail rather than guessing correspondence. +- A feature can project a Sketch entity topology tag only when kernel history proves one source Edge to one result Face. + +## 10) Boolean and body discipline - Use `union_rsolid(...)` when multiple solids should become one integrated body. - Ensure bodies that should union into one solid have real geometric overlap or embedding. - Use `cut_rsolid(...)` for subtractive features and `intersect_rsolid(...)` for common-volume workflows. - Validate body count and volume after major boolean operations. + +## 11) Dimension tolerance chains + +```python +width = scad.var("width", 10.0, unit="mm", tolerance=0.1) +gap = scad.var("gap", 0.5, unit="mm", tolerance=(-0.05, 0.1)) +overall = width + gap + +with scad.GraphSession() as session: + body = scad.make_box_rsolid(width, 2.0, 1.0) + session.require_tolerance(overall, (-0.15, 0.2), tolerance_unit="mm", name="overall") + +report = session.validate_tolerances(raise_on_failure=True) +``` + +- Use `worst_case` for a guaranteed conservative envelope. +- Use `rss` only when distinct source dimensions can be treated as independent. +- Every source variable in a chain must have a declared tolerance. +- Unit-aware expressions infer dimensions automatically and cannot mix legacy + variables without units. +- Length and angle use canonical `mm` and `deg` values in CAD operations. +- Read `references/docs/core/physical-units.md` for unit and inference rules. +- Read `references/docs/core/dimension-tolerance-chains.md` for complete rules. diff --git a/SimpleCADAPI/skills/simplecadapi/references/SDK_OVERVIEW.md b/SimpleCADAPI/skills/simplecadapi/references/SDK_OVERVIEW.md index 8ac6f7d..59943b6 100644 --- a/SimpleCADAPI/skills/simplecadapi/references/SDK_OVERVIEW.md +++ b/SimpleCADAPI/skills/simplecadapi/references/SDK_OVERVIEW.md @@ -1,8 +1,8 @@ # SDK Overview - Project: `simplecadapi` -- Version: `2.0.1b1` -- Package distribution: `simplecadapi==2.0.1b1` +- Version: `2.0.2` +- Package distribution: `simplecadapi==2.0.2` ## What this skill bundles @@ -20,11 +20,13 @@ ## Main SDK surfaces - Geometry and modeling operations in `docs/api/`. -- Standard parts library in `docs/stdlib/`, including `scad.std.gear` gear, ring gear, rack, and cycloidal disc factories plus `scad.std.bearing` bearing assembly factories. +- Standard parts library in `docs/stdlib/`, including `scad.std.gear`, `scad.std.bearing`, `scad.std.chain`, and `scad.std.fastener` factories. - Core shape/type semantics in `docs/core/`. - Graph/model serialization and replay APIs. - Expression, parameter, and semantic reference types. -- Functional tagging with `apply_tag(shape=..., tag=...)`, `list_tags(shape=...)`, and QL tag predicates. +- Source-preserving tagging with `apply_tag(...)`, `apply_tag_rselection(...)`, scoped `list_tags(...)`/`explain_tag(...)`, and scoped QL tag predicates. +- Kernel-proven primitive and feature output roles with strict role-based output tag assignment for box, cylinder, extrude, revolve, fillet, chamfer, shell, loft, sweep, and twisted sweep. +- Serializable QL predicates for `output_role`, projected `source_binding`, and projected `source_topology` evidence. ## Preferred replayable workflow diff --git a/SimpleCADAPI/skills/simplecadapi/references/SDK_PACKAGE_SUMMARY.md b/SimpleCADAPI/skills/simplecadapi/references/SDK_PACKAGE_SUMMARY.md index ad3aed7..62cd1d7 100644 --- a/SimpleCADAPI/skills/simplecadapi/references/SDK_PACKAGE_SUMMARY.md +++ b/SimpleCADAPI/skills/simplecadapi/references/SDK_PACKAGE_SUMMARY.md @@ -1,14 +1,14 @@ # SDK Package Summary - Project: `simplecadapi` -- Version: `2.0.1b1` +- Version: `2.0.2` - Summary: A simplified OCP-native CAD modeling Python API ## Scope - OCP-native public CAD Python SDK for geometry and replayable modeling. - Includes generated API, standard-library, and core type references under `references/docs/`. -- Includes a standard parts library for reusable mechanical parts such as bearings, gears, internal ring gears, racks, and cycloidal discs. +- Includes a standard parts library for reusable bearings, gears, roller-chain sprockets, bolts, nuts, racks, and cycloidal discs. - Emphasizes public surfaces rather than repository operations. ## Main reference entry points diff --git a/SimpleCADAPI/skills/simplecadapi/references/SDK_SURFACES.md b/SimpleCADAPI/skills/simplecadapi/references/SDK_SURFACES.md index 1e9f81b..bb16d58 100644 --- a/SimpleCADAPI/skills/simplecadapi/references/SDK_SURFACES.md +++ b/SimpleCADAPI/skills/simplecadapi/references/SDK_SURFACES.md @@ -50,7 +50,99 @@ top_faces = [face for face in body.get_faces() if "face.top" in scad.list_tags(s print(len(top_faces)) ``` -Use `apply_tag(shape=..., tag=...)` for user-authored semantic tags and `list_tags(shape=...)` for deterministic inspection. Keep numeric dimensions, measurements, and rich descriptive data in metadata rather than tags. +Use `apply_tag(shape=..., tag=...)` for a local user-authored tag. Use `apply_tag_rselection(...)` when a selector, explicit downward inheritance, or an independent semantic shape view is required. Inspect `local`, `inherited`, `effective`, or `lineage` with `list_tags(shape=..., scope=...)` and `explain_tag(...)`. `effective` excludes lineage. Keep numeric dimensions, measurements, operation events, source roles, and rich descriptive data in typed metadata rather than tags. + +## Feature Output Role Surface + +The following roles are operation-owned, kernel-proven sets. `one` means exactly +one result is required when the role is requested; `many` means at least one +result is required and all proven results are tagged. + +| Operation | Role | Kind | Cardinality | Named tag argument | +| --- | --- | --- | --- | --- | +| Box | `box.bottom` | Face | one | `bottom_face_tag` | +| Box | `box.top` | Face | one | `top_face_tag` | +| Box | `box.front` | Face | one | `front_face_tag` | +| Box | `box.back` | Face | one | `back_face_tag` | +| Box | `box.left` | Face | one | `left_face_tag` | +| Box | `box.right` | Face | one | `right_face_tag` | +| Cone | `cone.start` | Face | one | `start_face_tag` | +| Cone | `cone.end` | Face | one | `end_face_tag` | +| Cone | `cone.side` | Face | one | `side_face_tag` | +| Cone | `cone.start_boundary` | Edge | one | `start_edge_tag` | +| Cone | `cone.end_boundary` | Edge | one | `end_edge_tag` | +| Cone | `cone.seam` | Edge | one | `seam_edge_tag` | +| Extrude | `extrusion.start` | Face | one | `start_face_tag` | +| Extrude | `extrusion.end` | Face | one | `end_face_tag` | +| Extrude | `extrusion.side` | Face | many | `side_faces_tag` | +| Revolve | `revolution.start` | Face | one | `start_face_tag` | +| Revolve | `revolution.end` | Face | one | `end_face_tag` | +| Revolve | `revolution.side` | Face | many | `side_faces_tag` | +| Fillet | `fillet.patch` | Face | many | `generated_faces_tag` | +| Chamfer | `chamfer.patch` | Face | many | `generated_faces_tag` | +| Shell | `shell.body_face` | Face | many | `body_faces_tag` | +| Shell | `shell.offset_face` | Face | many | `offset_faces_tag` | +| Shell | `shell.closing_descendant` | Face | many | `closing_faces_tag` | +| Shell | `shell.wall` | Edge | many | `wall_edges_tag` | +| Loft | `loft.start` | Face | one | `start_face_tag` | +| Loft | `loft.end` | Face | one | `end_face_tag` | +| Loft | `loft.side` | Face | many | `side_faces_tag` | +| Sweep | `sweep.start` | Face | one | `start_face_tag` | +| Sweep | `sweep.end` | Face | one | `end_face_tag` | +| Sweep | `sweep.side` | Face | many | `side_faces_tag` | +| Twisted sweep | `twisted_sweep.start` | Face | one | `start_face_tag` | +| Twisted sweep | `twisted_sweep.end` | Face | one | `end_face_tag` | +| Twisted sweep | `twisted_sweep.side` | Face | many | `side_faces_tag` | + +Every feature also accepts `result_tag` for its one result Solid. Each output +role has one semantic tag argument named for the target, such as +`start_face_tag`, `side_faces_tag`, or `generated_faces_tag`; there is no generic +role-to-tag mapping. Malformed tags, unavailable roles, and cardinality +mismatches fail the operation. A full revolve has no +separate start/end cap roles. Shell roles vary with actual OCC history and are +not synthesized when unavailable. Box Edge roles are unsupported because the +kernel builder has no equivalent direct Edge witnesses. Sweep and twisted sweep +reject profiles with inner wires. A pointed cone has no `cone.end` cap Face, but its +`cone.end_boundary` remains the kernel-proven degenerate apex Edge. + +Use `ql.output_role(role_name=...)` to query operation role evidence. Use +`ql.source_binding(binding_id=...)` and `ql.source_topology(topo_id=...)` only for +projected local `TagBinding` evidence. + +## Unified Tag Contract + +Topology identity and user semantics use the same `TagBinding` and public query +surfaces. The authoring path and evidence determine projection behavior, not the +tag text: + +- `tag_prefix="housing"` creates topology-identity tags such as + `housing.face.top` and `housing.solid`. These bindings carry `topology_name` + evidence and identify kernel-proven result topology. +- `top_face_tag="role.cover"` attaches `role.cover` to the kernel-proven top + Face. Its binding carries operation output role evidence. +- `result_tag="part.housing"` attaches `part.housing` to the returned Solid. + Its binding carries operation result evidence. + +All three are discoverable with `list_tags(...)` and `explain_tag(...)` and +queryable with `ql.tag(...)`. A topology object may carry all three. Only a +topology-identity binding projects through the stricter exact-correspondence +path; a tag string does not opt into that behavior by resembling a topology tag. + +## Constrained Sketch Tag Surface + +Sketch entity IDs are creation-time local identifiers. When a constrained +profile is promoted, the exact ordered promotion map creates topology-identity +`TagBinding`s with `topology_name` evidence: + +- Profile `bottom` in sketch `rect`: `sketch.rect.profile.bottom` on the Face or Wire. +- Entity `right` in sketch `rect`: `sketch.rect.entity.right` on its promoted Edge. + +The promotion path validates that the generated Edge count equals the entity +map before attaching tags. It does not use geometric similarity, area, +normal, position, or enumeration order to repair a mismatch. Compatibility +tags such as `sketch_entity.right` remain ordinary compatibility tags. +Downstream features project topology-identity bindings only when kernel history +proves exact source-to-target correspondence. ## Recommended reading order @@ -64,12 +156,16 @@ Use `apply_tag(shape=..., tag=...)` for user-authored semantic tags and `list_ta ## Typical replayable surface ```python -from simplecadapi import GraphSession, export_model_json, replay_model_json +import simplecadapi as scad -with GraphSession() as session: - ... +@scad.model(graph_id="demo") +def build_model(): + result = ... + scad.capture_result(value=result) + return result -model_json = export_model_json(session=session) -rebuilt = replay_model_json(json_str=model_json) +model = build_model() +model_json = model.model_json +rebuilt = model.replay() print(len(rebuilt)) ``` diff --git a/SimpleCADAPI/skills/simplecadapi/references/docs/api/Dimension.md b/SimpleCADAPI/skills/simplecadapi/references/docs/api/Dimension.md new file mode 100644 index 0000000..cce883d --- /dev/null +++ b/SimpleCADAPI/skills/simplecadapi/references/docs/api/Dimension.md @@ -0,0 +1,17 @@ +# Dimension + +## Class Definition + +```python +class Dimension(length: int = 0, angle: int = 0) +``` + +*Source: units.py* + +## Import Surface + +- top-level: `from simplecadapi import Dimension` + +## Description + +Physical dimension represented by integer length and angle exponents. diff --git a/SimpleCADAPI/skills/simplecadapi/references/docs/api/DimensionTolerance.md b/SimpleCADAPI/skills/simplecadapi/references/docs/api/DimensionTolerance.md new file mode 100644 index 0000000..aac851f --- /dev/null +++ b/SimpleCADAPI/skills/simplecadapi/references/docs/api/DimensionTolerance.md @@ -0,0 +1,20 @@ +# DimensionTolerance + +## Class Definition + +```python +class DimensionTolerance(lower_deviation: float, upper_deviation: float) +``` + +*Source: expr.py* + +## Import Surface + +- top-level: `from simplecadapi import DimensionTolerance` + +## Description + +Permitted lower and upper deviations from a nominal dimension. + +Deviations are signed: the lower deviation must be less than or equal to +zero and the upper deviation must be greater than or equal to zero. diff --git a/SimpleCADAPI/skills/simplecadapi/references/docs/api/FreeCADScriptTranslator.md b/SimpleCADAPI/skills/simplecadapi/references/docs/api/FreeCADScriptTranslator.md index c74dc25..b0db7ce 100644 --- a/SimpleCADAPI/skills/simplecadapi/references/docs/api/FreeCADScriptTranslator.md +++ b/SimpleCADAPI/skills/simplecadapi/references/docs/api/FreeCADScriptTranslator.md @@ -21,6 +21,7 @@ Current design goals: - Translate only from the canonical low-level `graph` IR - Preserve node metadata and graph lineage as FreeCAD custom properties - Preserve `expression_graph` as explicit translator metadata +- Preserve dimension tolerances and tolerance-chain requirements as metadata - Preserve exported assembly constraints as document metadata objects - Keep assembly metadata from the full model payload alongside the IR-driven geometry translation diff --git a/SimpleCADAPI/skills/simplecadapi/references/docs/api/GraphSession.md b/SimpleCADAPI/skills/simplecadapi/references/docs/api/GraphSession.md index c79981c..36aed5c 100644 --- a/SimpleCADAPI/skills/simplecadapi/references/docs/api/GraphSession.md +++ b/SimpleCADAPI/skills/simplecadapi/references/docs/api/GraphSession.md @@ -16,16 +16,14 @@ class GraphSession(graph_id: Optional[str] = None) Context manager that records CAD operations into a DAG. -with GraphSession() as session: -n1 = record_operation( -"make_line_redge", {"start": (0, 0, 0), "end": (1, 0, 0)} -) -n2 = record_operation( -"make_line_redge", {"start": (1, 0, 0), "end": (1, 1, 0)} -) -record_operation( -"make_wire_from_edges_rwire", {"edge_count": 2}, inputs=[n1, n2] -) +For new replayable model entry points, prefer `@scad.model`, which owns the +session and returns a `ModelResult`. Use `GraphSession` directly for lower-level +graph workflows. `result_node_ids` reports nodes selected by `capture_result`: -# Access the graph after the session -print(session.graph.topological_order()) +```python +with GraphSession(graph_id="demo") as session: + body = make_box_rsolid(width=10.0, height=6.0, depth=2.0) + session.capture_result(value=body) + +print(session.result_node_ids) +``` diff --git a/SimpleCADAPI/skills/simplecadapi/references/docs/api/ModelResult.md b/SimpleCADAPI/skills/simplecadapi/references/docs/api/ModelResult.md new file mode 100644 index 0000000..b361af8 --- /dev/null +++ b/SimpleCADAPI/skills/simplecadapi/references/docs/api/ModelResult.md @@ -0,0 +1,39 @@ +# ModelResult + +## Class Definition + +```python +@dataclass(frozen=True) +class ModelResult: + value: Any + session: GraphSession + result_node_ids: Tuple[str, ...] + model_json: str + session_json: str + artifact_paths: Mapping[str, Path] = field(default_factory=dict) +``` + +*Source: graph.py* + +## Import Surface + +- top-level: `from simplecadapi import ModelResult` + +## Description + +Immutable result returned by a function decorated with `@scad.model`. +It contains the ordinary Python return value, the completed graph session, the +explicit result node ids, the model/session JSON artifacts, and any paths +written by automatic artifact export. + +```python +result = build_model() +rebuilt = result.replay() +``` + +`replay()` replays `result.model_json`. Use `result.value` for the application +return value and `result.result_node_ids` to inspect the captured graph outputs. +Use `result.export_artifacts(output_dir=...)` to write one self-contained +`.scene.zip` after the model runs. The package embeds model JSON, +mapped project-relative Python sources, and render/selection assets; automatic +export does not write adjacent model/session JSON, STEP, STL, or FCStd files. diff --git a/SimpleCADAPI/skills/simplecadapi/references/docs/api/README.md b/SimpleCADAPI/skills/simplecadapi/references/docs/api/README.md index 42dbe26..1980ea1 100644 --- a/SimpleCADAPI/skills/simplecadapi/references/docs/api/README.md +++ b/SimpleCADAPI/skills/simplecadapi/references/docs/api/README.md @@ -65,14 +65,38 @@ This index includes generated docs for the public SimpleCAD API surface, includi - [loft_rsolid](loft_rsolid.md) *(from operations.py)* `top-level` - [revolve_rsolid](revolve_rsolid.md) *(from operations.py)* `top-level` - [sweep_rsolid](sweep_rsolid.md) *(from operations.py)* `top-level` +- [twisted_sweep_rsolid](twisted_sweep_rsolid.md) *(from operations.py)* `top-level` ## Tagging and Selection - [apply_tag](apply_tag.md) *(from operations.py)* `top-level` +- [apply_tag_rselection](apply_tag_rselection.md) *(from operations.py)* `top-level` +- [explain_tag](explain_tag.md) *(from operations.py)* `top-level` - [list_tags](list_tags.md) *(from operations.py)* `top-level` - [select_edges_by_tag](select_edges_by_tag.md) *(from operations.py)* `top-level` - [select_faces_by_tag](select_faces_by_tag.md) *(from operations.py)* `top-level` +Creation-time topology-identity tags are supported by profile constructors and +native feature primitives through `tag_prefix`. QL selectors also support `shared_boundary(...)`, +`intersection(...)`, `incident_to(...)`, `incident_face_count(...)`, and +`solids()` for relation-aware Edge selection. + +## Unified Tag Contract + +Topology identity and user semantics share one public tag model. Every binding +is inspected with `list_tags(...)` and `explain_tag(...)` and queried with +`ql.tag(...)`; one topology object may carry several tags for different uses. + +`tag_prefix="housing"` creates topology-identity tags such as +`housing.face.top` and `housing.solid`. These bindings carry `topology_name` +evidence and project only when kernel history proves exact correspondence. + +Role parameters such as `top_face_tag`, `side_faces_tag`, and +`generated_faces_tag`, plus `result_tag`, create tags whose evidence identifies +the kernel-proven role or result. Their tag text alone does not establish +topology identity. There is one public tag parameter per target role, not a +generic role-to-tag mapping. + ## Boolean Operations - [cut_rsolid](cut_rsolid.md) *(from operations.py)* `top-level` @@ -98,6 +122,10 @@ This index includes generated docs for the public SimpleCAD API surface, includi ## Modeling Graph and Replay - [GraphSession](GraphSession.md) *(from graph.py)* `top-level` +- [ModelResult](ModelResult.md) *(from graph.py)* `top-level` +- [capture_result](capture_result.md) *(from graph.py)* `top-level` +- [model](model.md) *(from graph.py)* `top-level` +- [requires_session](requires_session.md) *(from graph.py)* `top-level` - [export_graph_json](export_graph_json.md) *(from serializer.py)* `top-level` - [export_model_json](export_model_json.md) *(from serializer.py)* `top-level` - [export_session_json](export_session_json.md) *(from serializer.py)* `top-level` @@ -111,12 +139,34 @@ This index includes generated docs for the public SimpleCAD API surface, includi ## Expressions and Parameters - [Const](Const.md) *(from expr.py)* `top-level` +- [DimensionTolerance](DimensionTolerance.md) *(from expr.py)* `top-level` - [Expr](Expr.md) *(from expr.py)* `top-level` - [ExpressionGraph](ExpressionGraph.md) *(from expr.py)* `top-level` +- [ToleranceAnalysis](ToleranceAnalysis.md) *(from tolerance.py)* `top-level` +- [ToleranceAnalysisError](ToleranceAnalysisError.md) *(from tolerance.py)* `top-level` +- [ToleranceCheck](ToleranceCheck.md) *(from tolerance.py)* `top-level` +- [ToleranceContribution](ToleranceContribution.md) *(from tolerance.py)* `top-level` +- [ToleranceGraph](ToleranceGraph.md) *(from tolerance.py)* `top-level` +- [ToleranceReport](ToleranceReport.md) *(from tolerance.py)* `top-level` +- [ToleranceRequirement](ToleranceRequirement.md) *(from tolerance.py)* `top-level` +- [ToleranceValidationError](ToleranceValidationError.md) *(from tolerance.py)* `top-level` - [Var](Var.md) *(from expr.py)* `top-level` +- [analyze_tolerance](analyze_tolerance.md) *(from tolerance.py)* `top-level` +- [check_tolerance](check_tolerance.md) *(from tolerance.py)* `top-level` - [const](const_function.md) *(from expr.py)* `top-level` - [var](var_function.md) *(from expr.py)* `top-level` +## Physical Units + +- [Dimension](Dimension.md) *(from units.py)* `top-level` +- [Unit](Unit.md) *(from units.py)* `top-level` +- [UnitValidationError](UnitValidationError.md) *(from units.py)* `top-level` +- [canonical_unit_for_dimension](canonical_unit_for_dimension.md) *(from units.py)* `top-level` +- [convert_value](convert_value.md) *(from units.py)* `top-level` +- [expression_uses_units](expression_uses_units.md) *(from units.py)* `top-level` +- [get_unit](get_unit.md) *(from units.py)* `top-level` +- [infer_dimension](infer_dimension.md) *(from units.py)* `top-level` + ## Types and Errors - [SimpleCADError](SimpleCADError.md) *(from errors.py)* `top-level` @@ -207,11 +257,15 @@ This index includes generated docs for the public SimpleCAD API surface, includi - [meta](meta.md) *(from ql.py)* `submodule:ql` - [not_](not_.md) *(from ql.py)* `submodule:ql` - [or_](or_.md) *(from ql.py)* `submodule:ql` +- [output_role](output_role.md) *(from ql.py)* `submodule:ql` - [place_component_rassembly](place_component_rassembly.md) *(from operations.py)* `top-level` - [radial_pattern_rsolidlist](radial_pattern_rsolidlist.md) *(from operations.py)* `top-level` - [render_screenshot_rpath](render_screenshot_rpath.md) *(from operations.py)* `top-level` - [select](select.md) *(from ql.py)* `submodule:ql` +- [solids](solids.md) *(from ql.py)* `submodule:ql` - [solve_assembly_constraints_rassembly](solve_assembly_constraints_rassembly.md) *(from operations.py)* `top-level` +- [source_binding](source_binding.md) *(from ql.py)* `submodule:ql` +- [source_topology](source_topology.md) *(from ql.py)* `submodule:ql` - [tag](tag.md) *(from ql.py)* `submodule:ql` - [unground_component_rassembly](unground_component_rassembly.md) *(from operations.py)* `top-level` - [value](value.md) *(from ql.py)* `submodule:ql` diff --git a/SimpleCADAPI/skills/simplecadapi/references/docs/api/Sketch.md b/SimpleCADAPI/skills/simplecadapi/references/docs/api/Sketch.md index e7b4d37..cbd4686 100644 --- a/SimpleCADAPI/skills/simplecadapi/references/docs/api/Sketch.md +++ b/SimpleCADAPI/skills/simplecadapi/references/docs/api/Sketch.md @@ -22,3 +22,10 @@ Use `make_sketch_rsketch(...)`, `add_point_rsketch(...)`, profiles. Public sketch construction APIs are functional and return an updated `Sketch` document. The legacy `curves` constructor remains only for reading already-built wire/edge containers. + +Entity IDs are creation-time local identifiers. Constrained profile promotion uses +the ordered promotion map to bind each entity to exactly one generated Edge, +creating `sketch..entity.` and +`sketch..profile.` tags with `topology_name` evidence. +Downstream features may project these tags only when their kernel history proves exact +correspondence. diff --git a/SimpleCADAPI/skills/simplecadapi/references/docs/api/ToleranceAnalysis.md b/SimpleCADAPI/skills/simplecadapi/references/docs/api/ToleranceAnalysis.md new file mode 100644 index 0000000..3483c91 --- /dev/null +++ b/SimpleCADAPI/skills/simplecadapi/references/docs/api/ToleranceAnalysis.md @@ -0,0 +1,17 @@ +# ToleranceAnalysis + +## Class Definition + +```python +class ToleranceAnalysis(target_expr_id: str, method: ToleranceMethod, nominal: float, lower_bound: float, upper_bound: float, lower_deviation: float, upper_deviation: float, dimension: Dimension | None = None, unit: Unit | None = None, contributions: Tuple[ToleranceContribution, ...] = ()) +``` + +*Source: tolerance.py* + +## Import Surface + +- top-level: `from simplecadapi import ToleranceAnalysis` + +## Description + +Nominal value and propagated limits for an expression. diff --git a/SimpleCADAPI/skills/simplecadapi/references/docs/api/ToleranceAnalysisError.md b/SimpleCADAPI/skills/simplecadapi/references/docs/api/ToleranceAnalysisError.md new file mode 100644 index 0000000..edbdb0b --- /dev/null +++ b/SimpleCADAPI/skills/simplecadapi/references/docs/api/ToleranceAnalysisError.md @@ -0,0 +1,17 @@ +# ToleranceAnalysisError + +## Class Definition + +```python +class ToleranceAnalysisError +``` + +*Source: tolerance.py* + +## Import Surface + +- top-level: `from simplecadapi import ToleranceAnalysisError` + +## Description + +Raised when a tolerance chain cannot be propagated safely. diff --git a/SimpleCADAPI/skills/simplecadapi/references/docs/api/ToleranceCheck.md b/SimpleCADAPI/skills/simplecadapi/references/docs/api/ToleranceCheck.md new file mode 100644 index 0000000..088507c --- /dev/null +++ b/SimpleCADAPI/skills/simplecadapi/references/docs/api/ToleranceCheck.md @@ -0,0 +1,17 @@ +# ToleranceCheck + +## Class Definition + +```python +class ToleranceCheck(requirement: ToleranceRequirement, analysis: ToleranceAnalysis, passed: bool, lower_margin: float, upper_margin: float) +``` + +*Source: tolerance.py* + +## Import Surface + +- top-level: `from simplecadapi import ToleranceCheck` + +## Description + +Validation result for one tolerance requirement. diff --git a/SimpleCADAPI/skills/simplecadapi/references/docs/api/ToleranceContribution.md b/SimpleCADAPI/skills/simplecadapi/references/docs/api/ToleranceContribution.md new file mode 100644 index 0000000..142a333 --- /dev/null +++ b/SimpleCADAPI/skills/simplecadapi/references/docs/api/ToleranceContribution.md @@ -0,0 +1,17 @@ +# ToleranceContribution + +## Class Definition + +```python +class ToleranceContribution(variable_expr_id: str, variable_name: str, nominal: float, source_tolerance: DimensionTolerance, sensitivity: float | None, lower_deviation: float, upper_deviation: float, source_unit: Unit | None = None) +``` + +*Source: tolerance.py* + +## Import Surface + +- top-level: `from simplecadapi import ToleranceContribution` + +## Description + +One source dimension's propagated contribution to a result. diff --git a/SimpleCADAPI/skills/simplecadapi/references/docs/api/ToleranceGraph.md b/SimpleCADAPI/skills/simplecadapi/references/docs/api/ToleranceGraph.md new file mode 100644 index 0000000..47033d4 --- /dev/null +++ b/SimpleCADAPI/skills/simplecadapi/references/docs/api/ToleranceGraph.md @@ -0,0 +1,17 @@ +# ToleranceGraph + +## Class Definition + +```python +class ToleranceGraph(expression_graph: ExpressionGraph) +``` + +*Source: tolerance.py* + +## Import Surface + +- top-level: `from simplecadapi import ToleranceGraph` + +## Description + +Tolerance requirements attached to one expression graph. diff --git a/SimpleCADAPI/skills/simplecadapi/references/docs/api/ToleranceReport.md b/SimpleCADAPI/skills/simplecadapi/references/docs/api/ToleranceReport.md new file mode 100644 index 0000000..c57407e --- /dev/null +++ b/SimpleCADAPI/skills/simplecadapi/references/docs/api/ToleranceReport.md @@ -0,0 +1,17 @@ +# ToleranceReport + +## Class Definition + +```python +class ToleranceReport(checks: Tuple[ToleranceCheck, ...] = ()) +``` + +*Source: tolerance.py* + +## Import Surface + +- top-level: `from simplecadapi import ToleranceReport` + +## Description + +Validation report for every requirement in a tolerance graph. diff --git a/SimpleCADAPI/skills/simplecadapi/references/docs/api/ToleranceRequirement.md b/SimpleCADAPI/skills/simplecadapi/references/docs/api/ToleranceRequirement.md new file mode 100644 index 0000000..094441f --- /dev/null +++ b/SimpleCADAPI/skills/simplecadapi/references/docs/api/ToleranceRequirement.md @@ -0,0 +1,17 @@ +# ToleranceRequirement + +## Class Definition + +```python +class ToleranceRequirement(requirement_id: str, target_expr_id: str, tolerance: DimensionTolerance, method: ToleranceMethod = 'worst_case', name: str = '', tolerance_unit: Unit | None = None, target_dimension: Dimension | None = None) +``` + +*Source: tolerance.py* + +## Import Surface + +- top-level: `from simplecadapi import ToleranceRequirement` + +## Description + +Permitted result deviations for one derived dimension. diff --git a/SimpleCADAPI/skills/simplecadapi/references/docs/api/ToleranceValidationError.md b/SimpleCADAPI/skills/simplecadapi/references/docs/api/ToleranceValidationError.md new file mode 100644 index 0000000..092283b --- /dev/null +++ b/SimpleCADAPI/skills/simplecadapi/references/docs/api/ToleranceValidationError.md @@ -0,0 +1,17 @@ +# ToleranceValidationError + +## Class Definition + +```python +class ToleranceValidationError(report: 'ToleranceReport') +``` + +*Source: tolerance.py* + +## Import Surface + +- top-level: `from simplecadapi import ToleranceValidationError` + +## Description + +Raised when one or more declared tolerance requirements fail. diff --git a/SimpleCADAPI/skills/simplecadapi/references/docs/api/Unit.md b/SimpleCADAPI/skills/simplecadapi/references/docs/api/Unit.md new file mode 100644 index 0000000..6516627 --- /dev/null +++ b/SimpleCADAPI/skills/simplecadapi/references/docs/api/Unit.md @@ -0,0 +1,20 @@ +# Unit + +## Class Definition + +```python +class Unit(symbol: str, dimension: Dimension, scale_to_canonical: float) +``` + +*Source: units.py* + +## Import Surface + +- top-level: `from simplecadapi import Unit` + +## Description + +Named unit with a scale to SimpleCAD's canonical numeric units. + +Custom units are supported and serialize their symbol, dimension, and scale. +Registered built-in units serialize as compact symbols. diff --git a/SimpleCADAPI/skills/simplecadapi/references/docs/api/UnitValidationError.md b/SimpleCADAPI/skills/simplecadapi/references/docs/api/UnitValidationError.md new file mode 100644 index 0000000..6f2e25a --- /dev/null +++ b/SimpleCADAPI/skills/simplecadapi/references/docs/api/UnitValidationError.md @@ -0,0 +1,17 @@ +# UnitValidationError + +## Class Definition + +```python +class UnitValidationError +``` + +*Source: units.py* + +## Import Surface + +- top-level: `from simplecadapi import UnitValidationError` + +## Description + +Raised when units or expression dimensions are physically inconsistent. diff --git a/SimpleCADAPI/skills/simplecadapi/references/docs/api/Var.md b/SimpleCADAPI/skills/simplecadapi/references/docs/api/Var.md index 114d7a8..35221e2 100644 --- a/SimpleCADAPI/skills/simplecadapi/references/docs/api/Var.md +++ b/SimpleCADAPI/skills/simplecadapi/references/docs/api/Var.md @@ -3,7 +3,7 @@ ## Class Definition ```python -class Var(name: str, default: float, comment: str | None = None, expr_id: str = field(default_factory=lambda : _make_expr_id('var'))) +class Var(name: str, default: float, comment: str | None = None, expr_id: str = field(default_factory=lambda : _make_expr_id('var')), tolerance: DimensionTolerance | None = None, unit: Unit | None = None, tolerance_unit: Unit | None = None) ``` *Source: expr.py* @@ -14,4 +14,8 @@ class Var(name: str, default: float, comment: str | None = None, expr_id: str = ## Description -Named scalar parameter with a default fallback value. +Named scalar parameter with optional physical-unit and tolerance intent. + +``default`` and ``tolerance`` remain in their declared units. Evaluation, +geometry parameters, and tolerance propagation convert them to SimpleCAD's +canonical CAD units: millimeters for length and degrees for angle. diff --git a/SimpleCADAPI/skills/simplecadapi/references/docs/api/add_line_rsketch.md b/SimpleCADAPI/skills/simplecadapi/references/docs/api/add_line_rsketch.md index d65ddcd..0250d72 100644 --- a/SimpleCADAPI/skills/simplecadapi/references/docs/api/add_line_rsketch.md +++ b/SimpleCADAPI/skills/simplecadapi/references/docs/api/add_line_rsketch.md @@ -14,4 +14,6 @@ def add_line_rsketch(sketch: Sketch, entity_id: str, start: Union[SketchRef, str ## Description -Add a named line entity and return an updated sketch document. +Add an identified line entity and return an updated sketch document. Its +`entity_id` becomes the local segment of the canonical topology-identity tag for +the corresponding promoted profile Edge. diff --git a/SimpleCADAPI/skills/simplecadapi/references/docs/api/analyze_tolerance.md b/SimpleCADAPI/skills/simplecadapi/references/docs/api/analyze_tolerance.md new file mode 100644 index 0000000..863d25a --- /dev/null +++ b/SimpleCADAPI/skills/simplecadapi/references/docs/api/analyze_tolerance.md @@ -0,0 +1,27 @@ +# analyze_tolerance + +## API Definition + +```python +def analyze_tolerance(value: ScalarLike, *, method: ToleranceMethod = 'worst_case') -> ToleranceAnalysis +``` + +*Source: tolerance.py* + +## Import Surface + +- top-level: `from simplecadapi import analyze_tolerance` + +## Description + +Propagate source manufacturing tolerances through a scalar expression. + +``worst_case`` returns guaranteed interval bounds. Affine chains are +dependency-aware, so repeated variables such as ``x - x`` cancel exactly; +nonlinear chains use conservative interval arithmetic. ``rss`` performs a +first-order root-sum-square calculation using analytic sensitivities. + +Unit-aware variables are converted to canonical CAD units before +propagation. The returned analysis reports the inferred physical dimension +and canonical result unit. Every variable in the expression must declare a +source tolerance. diff --git a/SimpleCADAPI/skills/simplecadapi/references/docs/api/apply_tag.md b/SimpleCADAPI/skills/simplecadapi/references/docs/api/apply_tag.md index 9929ea4..084c45b 100644 --- a/SimpleCADAPI/skills/simplecadapi/references/docs/api/apply_tag.md +++ b/SimpleCADAPI/skills/simplecadapi/references/docs/api/apply_tag.md @@ -14,10 +14,13 @@ def apply_tag(shape: AnyShape, tag: str) -> AnyShape ## Description -Attach a normalized tag to a shape using the standard propagation policy. +Attach a normalized local user tag to a shape. Tags must already be normalized lowercase tokens such as -``role.mounting_surface`` or ``group.fasteners``. Propagation is intentionally -not configurable from the public API; the default tag policy propagates -semantic role/anchor/group tags downward and keeps topology-specific tags -local. +`role.mounting_surface` or `group.fasteners`. The default topology policy is +`local` for every tag; token prefixes do not imply downward propagation. +Lineage visibility is limited to proven continuation and fragment witnesses. + +`apply_tag(...)` preserves its historical in-place wrapper behavior. Use +`apply_tag_rselection(...)` when you need an independent semantic shape view, +explicit topology propagation, or a replayable multi-entity assignment. diff --git a/SimpleCADAPI/skills/simplecadapi/references/docs/api/apply_tag_rselection.md b/SimpleCADAPI/skills/simplecadapi/references/docs/api/apply_tag_rselection.md new file mode 100644 index 0000000..b015ef2 --- /dev/null +++ b/SimpleCADAPI/skills/simplecadapi/references/docs/api/apply_tag_rselection.md @@ -0,0 +1,36 @@ +# apply_tag_rselection + +## API Definition + +```python +def apply_tag_rselection( + scope: AnyShape, + targets: Union[ShapeSelector, Sequence[AnyShape]], + tag: str, + topology_propagation: str | TopologyPropagation = TopologyPropagation.LOCAL, + lineage_policy: str | LineagePolicy = LineagePolicy.CONTINUATION_FRAGMENT, +) -> AnyShape +``` + +*Source: operations.py* + +## Import Surface + +- top-level: `from simplecadapi import apply_tag_rselection` + +## Description + +Return an independent semantic view over the same geometry with one canonical +`TagBinding` attached to the selected entities. `targets` may be a serializable +QL `ShapeSelector` or a non-empty sequence of topology objects belonging to +`scope`. + +Topology propagation defaults to `local`. Set `topology_propagation="downward"` +only when descendants should inherit the binding. Lineage defaults to proven +continuation and fragment derivations; it never makes lineage part of the +`effective` scope. + +Inside `GraphSession`, the operation records the complete binding, target intent, +and selected-reference evidence. Replay re-resolves the target and checks that +the evidence has not drifted. The semantic node does not replace geometry-owned +topology references. diff --git a/SimpleCADAPI/skills/simplecadapi/references/docs/api/canonical_unit_for_dimension.md b/SimpleCADAPI/skills/simplecadapi/references/docs/api/canonical_unit_for_dimension.md new file mode 100644 index 0000000..af5f505 --- /dev/null +++ b/SimpleCADAPI/skills/simplecadapi/references/docs/api/canonical_unit_for_dimension.md @@ -0,0 +1,19 @@ +# canonical_unit_for_dimension + +## API Definition + +```python +def canonical_unit_for_dimension(dimension: Dimension) -> Unit +``` + +*Source: units.py* + +## Import Surface + +- top-level: `from simplecadapi import canonical_unit_for_dimension` + +## Description + +Return the canonical unit used by CAD and tolerance calculations. + +Length, area, volume, and angle use ``mm``, ``mm^2``, ``mm^3``, and ``deg``. diff --git a/SimpleCADAPI/skills/simplecadapi/references/docs/api/capture_result.md b/SimpleCADAPI/skills/simplecadapi/references/docs/api/capture_result.md new file mode 100644 index 0000000..bab42ed --- /dev/null +++ b/SimpleCADAPI/skills/simplecadapi/references/docs/api/capture_result.md @@ -0,0 +1,30 @@ +# capture_result + +## API Definition + +```python +def capture_result(*, value: Any) -> Any +``` + +*Source: graph.py* + +## Import Surface + +- top-level: `from simplecadapi import capture_result` + +## Description + +Mark the graph nodes represented by `value` as explicit final outputs of the +active model session. The value is returned unchanged. Explicit capture keeps +intermediate/debug graph leaves out of `ModelResult.model_json`. Captured +geometry/product values are also the inputs to automatic artifact export when +the model decorator receives `export_dir=...`. + +```python +@scad.model(graph_id="demo") +def build_demo(): + body = scad.make_box_rsolid(width=10.0, height=4.0, depth=2.0) + return scad.capture_result(value=body) +``` + +Call it inside `@model` or another active `GraphSession`. diff --git a/SimpleCADAPI/skills/simplecadapi/references/docs/api/chamfer_rsolid.md b/SimpleCADAPI/skills/simplecadapi/references/docs/api/chamfer_rsolid.md index 99c2f65..d2aecbe 100644 --- a/SimpleCADAPI/skills/simplecadapi/references/docs/api/chamfer_rsolid.md +++ b/SimpleCADAPI/skills/simplecadapi/references/docs/api/chamfer_rsolid.md @@ -3,7 +3,14 @@ ## API Definition ```python -def chamfer_rsolid(solid: Solid, edges: Union[Sequence[Edge], ShapeSelector], distance: ScalarLike) -> Solid +def chamfer_rsolid( + solid: Solid, + edges: Union[Sequence[Edge], ShapeSelector], + distance: ScalarLike, + *, + result_tag: Optional[str] = None, + generated_faces_tag: Optional[str] = None, +) -> Solid ``` *Source: operations.py* @@ -14,4 +21,10 @@ def chamfer_rsolid(solid: Solid, edges: Union[Sequence[Edge], ShapeSelector], di ## Description -Apply chamfers to selected solid edges. +Apply chamfers to selected solid edges. `generated_faces_tag` targets every face +with the kernel-proven `chamfer.patch` role. OCC contour expansion is included +rather than treating only the seed edge as the feature boundary. + +The operation fails if a requested patch role has no proven result. `result_tag` +tags the resulting solid, and graph recording lowers assignments to replayable +semantic nodes. diff --git a/SimpleCADAPI/skills/simplecadapi/references/docs/api/check_tolerance.md b/SimpleCADAPI/skills/simplecadapi/references/docs/api/check_tolerance.md new file mode 100644 index 0000000..9f6a8cf --- /dev/null +++ b/SimpleCADAPI/skills/simplecadapi/references/docs/api/check_tolerance.md @@ -0,0 +1,22 @@ +# check_tolerance + +## API Definition + +```python +def check_tolerance(value: ScalarLike, tolerance: ToleranceLike, *, method: ToleranceMethod = 'worst_case', name: str | None = None, tolerance_unit: UnitLike | None = None) -> ToleranceCheck +``` + +*Source: tolerance.py* + +## Import Surface + +- top-level: `from simplecadapi import check_tolerance` + +## Description + +Propagate and verify one Length or Angle requirement. + +``tolerance_unit`` defaults to the target dimension's canonical unit. When +provided, it must be dimensionally compatible and is converted before the +comparison. Legacy unitless requirements remain supported when no unit is +supplied. diff --git a/SimpleCADAPI/skills/simplecadapi/references/docs/api/convert_value.md b/SimpleCADAPI/skills/simplecadapi/references/docs/api/convert_value.md new file mode 100644 index 0000000..6ddf243 --- /dev/null +++ b/SimpleCADAPI/skills/simplecadapi/references/docs/api/convert_value.md @@ -0,0 +1,17 @@ +# convert_value + +## API Definition + +```python +def convert_value(value: int | float, from_unit: UnitLike, to_unit: UnitLike) -> float +``` + +*Source: units.py* + +## Import Surface + +- top-level: `from simplecadapi import convert_value` + +## Description + +Convert a finite numeric value between dimensionally compatible units. diff --git a/SimpleCADAPI/skills/simplecadapi/references/docs/api/cut_rsolid.md b/SimpleCADAPI/skills/simplecadapi/references/docs/api/cut_rsolid.md index 82eb8ad..3c7ba02 100644 --- a/SimpleCADAPI/skills/simplecadapi/references/docs/api/cut_rsolid.md +++ b/SimpleCADAPI/skills/simplecadapi/references/docs/api/cut_rsolid.md @@ -3,7 +3,11 @@ ## API Definition ```python -def cut_rsolid(*solids: Union[Solid, Sequence[Solid]], skip_non_intersecting: bool = True) -> Solid +def cut_rsolid( + *solids: Union[Solid, Sequence[Solid]], + skip_non_intersecting: bool = True, + tracking_policy: TrackingPolicy | str = TrackingPolicy.FULL, +) -> Solid ``` *Source: operations.py* @@ -29,6 +33,10 @@ sequences, and returns a single `Solid`. - **Description**: When True, tools with no meaningful intersection are ignored for interactive convenience. Graph replay records this flag and should use False for strict diagnostic workflows. +### tracking_policy + +- **Description**: `TrackingPolicy.FULL` computes topology history and lineage. `TrackingPolicy.GRAPH` preserves the canonical cut node, parameters, inputs, result topology references, and replay while omitting `TopoDelta` and history-derived topology lineage. Intersection validation and `skip_non_intersecting` behavior are unchanged. + ## Returns Solid: The cut result solid. diff --git a/SimpleCADAPI/skills/simplecadapi/references/docs/api/explain_tag.md b/SimpleCADAPI/skills/simplecadapi/references/docs/api/explain_tag.md new file mode 100644 index 0000000..4681317 --- /dev/null +++ b/SimpleCADAPI/skills/simplecadapi/references/docs/api/explain_tag.md @@ -0,0 +1,27 @@ +# explain_tag + +## API Definition + +```python +def explain_tag( + shape: AnyShape, + tag: str, + scope: str | TagScope = TagScope.EFFECTIVE, +) -> List[Dict[str, Any]] +``` + +*Source: operations.py* + +## Import Surface + +- top-level: `from simplecadapi import explain_tag` + +## Description + +Return every visible canonical binding that produces `tag` in the requested +scope. Explanations preserve binding identity, producer, attachment, evidence, +and policy-allowed lineage witnesses, so equal tag tokens from different +producers remain distinguishable. + +As with `list_tags(...)`, `effective` excludes lineage. A lineage explanation +requires complete topology-history coverage. diff --git a/SimpleCADAPI/skills/simplecadapi/references/docs/api/export_model_json.md b/SimpleCADAPI/skills/simplecadapi/references/docs/api/export_model_json.md index 5009670..04056fe 100644 --- a/SimpleCADAPI/skills/simplecadapi/references/docs/api/export_model_json.md +++ b/SimpleCADAPI/skills/simplecadapi/references/docs/api/export_model_json.md @@ -3,7 +3,12 @@ ## API Definition ```python -def export_model_json(session: 'GraphSession', indent: int = 2) -> str +def export_model_json( + session: 'GraphSession', + indent: int = 2, + *, + result_node_ids: Optional[Sequence[str]] = None, +) -> str ``` *Source: serializer.py* @@ -20,3 +25,8 @@ Current Phase 1 scope uses the active session as the container of: - operation graph - expression graph - capabilities/schema metadata + +When `result_node_ids` is omitted, explicitly captured session results are used +when available; otherwise export falls back to graph leaves. New top-level model +code normally reads `ModelResult.model_json` instead of calling this function +directly. diff --git a/SimpleCADAPI/skills/simplecadapi/references/docs/api/expression_uses_units.md b/SimpleCADAPI/skills/simplecadapi/references/docs/api/expression_uses_units.md new file mode 100644 index 0000000..2548e6b --- /dev/null +++ b/SimpleCADAPI/skills/simplecadapi/references/docs/api/expression_uses_units.md @@ -0,0 +1,17 @@ +# expression_uses_units + +## API Definition + +```python +def expression_uses_units(value: 'ScalarLike') -> bool +``` + +*Source: units.py* + +## Import Surface + +- top-level: `from simplecadapi import expression_uses_units` + +## Description + +Return whether an expression contains an explicit unit declaration. diff --git a/SimpleCADAPI/skills/simplecadapi/references/docs/api/extrude_rsolid.md b/SimpleCADAPI/skills/simplecadapi/references/docs/api/extrude_rsolid.md index 0f6d234..b59f93f 100644 --- a/SimpleCADAPI/skills/simplecadapi/references/docs/api/extrude_rsolid.md +++ b/SimpleCADAPI/skills/simplecadapi/references/docs/api/extrude_rsolid.md @@ -3,7 +3,17 @@ ## API Definition ```python -def extrude_rsolid(profile: Union[Wire, Face], direction: Tuple[float, float, float], distance: ScalarLike) -> Solid +def extrude_rsolid( + profile: Union[Wire, Face], + direction: Tuple[float, float, float], + distance: ScalarLike, + *, + tag_prefix: Optional[str] = None, + result_tag: Optional[str] = None, + start_face_tag: Optional[str] = None, + end_face_tag: Optional[str] = None, + side_faces_tag: Optional[str] = None, +) -> Solid ``` *Source: operations.py* @@ -14,4 +24,26 @@ def extrude_rsolid(profile: Union[Wire, Face], direction: Tuple[float, float, fl ## Description -Create a solid by extruding a profile. +Create a solid by extruding a profile. Kernel history assigns the output roles +`extrusion.start`, `extrusion.end`, and `extrusion.side`. Use the role tag +arguments to attach tags to those exact role sets. + +Use creation-time profile topology tags (`tag_prefix` and `edge_tags`) when +durable feature Face/Edge identity is required. A tagged profile Edge yields a +corresponding `.face.side.` when OCC proves the generated +side Face. Tagged Faces expose their effective tags to boundary Edge queries; use QL +`incident_to(..., distinct=True)` or `shared_boundary(...)` to disambiguate +an Edge by its two neighboring Faces. + +Start and end roles require exactly one proven face. The side role requires one +or more proven faces and tags all of them. Missing, ambiguous, or unsupported +roles fail the whole operation instead of returning an untagged result. + +`result_tag` attaches a local tag to the resulting solid. In a `GraphSession`, +all requested tags lower to replayable `apply_tag_rselection` semantic nodes; +they are not stored as geometry parameters. + +Topology tags produced by `tag_prefix` remain directly queryable after +booleans when OCC provides a complete preserved or modified Face history. The +result binding retains the original semantic binding ID and source topology ID; +new boundary faces do not inherit the topology tag. diff --git a/SimpleCADAPI/skills/simplecadapi/references/docs/api/fillet_rsolid.md b/SimpleCADAPI/skills/simplecadapi/references/docs/api/fillet_rsolid.md index 61d2a67..2dc839c 100644 --- a/SimpleCADAPI/skills/simplecadapi/references/docs/api/fillet_rsolid.md +++ b/SimpleCADAPI/skills/simplecadapi/references/docs/api/fillet_rsolid.md @@ -3,7 +3,14 @@ ## API Definition ```python -def fillet_rsolid(solid: Solid, edges: Union[Sequence[Edge], ShapeSelector], radius: ScalarLike) -> Solid +def fillet_rsolid( + solid: Solid, + edges: Union[Sequence[Edge], ShapeSelector], + radius: ScalarLike, + *, + result_tag: Optional[str] = None, + generated_faces_tag: Optional[str] = None, +) -> Solid ``` *Source: operations.py* @@ -14,4 +21,10 @@ def fillet_rsolid(solid: Solid, edges: Union[Sequence[Edge], ShapeSelector], rad ## Description -Apply fillets to selected solid edges. +Apply fillets to selected solid edges. `generated_faces_tag` targets every face +with the kernel-proven `fillet.patch` role. OCC contour expansion is included, +so the role is not limited to the original seed edge. + +The operation fails if a requested patch role has no proven result. `result_tag` +tags the resulting solid. In a `GraphSession`, assignments are separate replayable +semantic nodes with asserted user provenance. diff --git a/SimpleCADAPI/skills/simplecadapi/references/docs/api/get_unit.md b/SimpleCADAPI/skills/simplecadapi/references/docs/api/get_unit.md new file mode 100644 index 0000000..ca5818b --- /dev/null +++ b/SimpleCADAPI/skills/simplecadapi/references/docs/api/get_unit.md @@ -0,0 +1,17 @@ +# get_unit + +## API Definition + +```python +def get_unit(value: UnitLike) -> Unit +``` + +*Source: units.py* + +## Import Surface + +- top-level: `from simplecadapi import get_unit` + +## Description + +Resolve a built-in unit name/alias or return an existing ``Unit``. diff --git a/SimpleCADAPI/skills/simplecadapi/references/docs/api/infer_dimension.md b/SimpleCADAPI/skills/simplecadapi/references/docs/api/infer_dimension.md new file mode 100644 index 0000000..d740467 --- /dev/null +++ b/SimpleCADAPI/skills/simplecadapi/references/docs/api/infer_dimension.md @@ -0,0 +1,21 @@ +# infer_dimension + +## API Definition + +```python +def infer_dimension(value: 'ScalarLike') -> Dimension | None +``` + +*Source: units.py* + +## Import Surface + +- top-level: `from simplecadapi import infer_dimension` + +## Description + +Infer and validate an expression's result dimension. + +``None`` means the expression uses only legacy variables without unit +declarations. Expressions that contain explicit units are validated +strictly and cannot mix in legacy variables. diff --git a/SimpleCADAPI/skills/simplecadapi/references/docs/api/list_tags.md b/SimpleCADAPI/skills/simplecadapi/references/docs/api/list_tags.md index 69d2e13..971e9e5 100644 --- a/SimpleCADAPI/skills/simplecadapi/references/docs/api/list_tags.md +++ b/SimpleCADAPI/skills/simplecadapi/references/docs/api/list_tags.md @@ -3,7 +3,10 @@ ## API Definition ```python -def list_tags(shape: AnyShape) -> List[str] +def list_tags( + shape: AnyShape, + scope: str | TagScope = TagScope.EFFECTIVE, +) -> List[str] ``` *Source: operations.py* @@ -14,4 +17,12 @@ def list_tags(shape: AnyShape) -> List[str] ## Description -Return shape tags in deterministic sorted order. +Return shape tags in deterministic sorted order for one semantic scope. + +- `local`: bindings attached directly to the entity. +- `inherited`: bindings visible through explicit downward topology propagation. +- `effective`: local plus inherited bindings. Lineage is not included. +- `lineage`: bindings visible through complete, policy-allowed topology history. + +Lineage queries fail with a semantic capability error when complete topology +history is unavailable; they do not guess from geometry or enumeration order. diff --git a/SimpleCADAPI/skills/simplecadapi/references/docs/api/loft_rsolid.md b/SimpleCADAPI/skills/simplecadapi/references/docs/api/loft_rsolid.md index 8e7645f..0ea7ac1 100644 --- a/SimpleCADAPI/skills/simplecadapi/references/docs/api/loft_rsolid.md +++ b/SimpleCADAPI/skills/simplecadapi/references/docs/api/loft_rsolid.md @@ -3,7 +3,17 @@ ## API Definition ```python -def loft_rsolid(profiles: List[Wire], ruled: bool = False) -> Solid +def loft_rsolid( + profiles: List[Wire], + ruled: bool = False, + *, + tracking_policy: TrackingPolicy | str = TrackingPolicy.FULL, + tag_prefix: Optional[str] = None, + result_tag: Optional[str] = None, + start_face_tag: Optional[str] = None, + end_face_tag: Optional[str] = None, + side_faces_tag: Optional[str] = None, +) -> Solid ``` *Source: operations.py* @@ -14,4 +24,13 @@ def loft_rsolid(profiles: List[Wire], ruled: bool = False) -> Solid ## Description -Create a solid by lofting multiple profiles. +Create a solid by lofting multiple profiles. Kernel history assigns +`loft.start`, `loft.end`, and `loft.side` roles. Start and end tags require one +proven face each; side tags apply to all proven side faces. `result_tag` targets +the solid. Recorded assignments are replayable semantic nodes. + +`TrackingPolicy.FULL` is the default and preserves complete kernel topology +history. `TrackingPolicy.GRAPH` skips topology-history queries while still +recording and replaying the `make_loft_rsolid` graph node. In `GRAPH` mode, +`result_tag` remains available, but face-role tags and `tag_prefix` require +`FULL` tracking. diff --git a/SimpleCADAPI/skills/simplecadapi/references/docs/api/make_box_rsolid.md b/SimpleCADAPI/skills/simplecadapi/references/docs/api/make_box_rsolid.md index f38b665..a9e7353 100644 --- a/SimpleCADAPI/skills/simplecadapi/references/docs/api/make_box_rsolid.md +++ b/SimpleCADAPI/skills/simplecadapi/references/docs/api/make_box_rsolid.md @@ -3,7 +3,21 @@ ## API Definition ```python -def make_box_rsolid(width: ScalarLike, height: ScalarLike, depth: ScalarLike, bottom_face_center: Tuple[float, float, float] = (0, 0, 0)) -> Solid +def make_box_rsolid( + width: ScalarLike, + height: ScalarLike, + depth: ScalarLike, + bottom_face_center: Tuple[float, float, float] = (0, 0, 0), + *, + tag_prefix: Optional[str] = None, + result_tag: Optional[str] = None, + bottom_face_tag: Optional[str] = None, + top_face_tag: Optional[str] = None, + front_face_tag: Optional[str] = None, + back_face_tag: Optional[str] = None, + left_face_tag: Optional[str] = None, + right_face_tag: Optional[str] = None, +) -> Solid ``` *Source: operations.py* @@ -14,4 +28,12 @@ def make_box_rsolid(width: ScalarLike, height: ScalarLike, depth: ScalarLike, bo ## Description -Create a box solid. +Create a native box with six exact kernel-backed Face roles: `box.bottom`, +`box.top`, `box.front`, `box.back`, `box.left`, and `box.right`. +`tag_prefix="housing"` creates `housing.solid` and corresponding +`housing.face.` topology-identity tags. Use the face tag arguments for +role tags and `result_tag` for the Solid. + +Box Edge roles are unsupported because the current OCP builder does not expose +equivalent direct Edge witnesses. Select an exact Edge from two tagged incident +Faces with QL `incident_to(..., distinct=True)` or `shared_boundary(...)`. diff --git a/SimpleCADAPI/skills/simplecadapi/references/docs/api/make_circle_redge.md b/SimpleCADAPI/skills/simplecadapi/references/docs/api/make_circle_redge.md index be9a29b..a48f920 100644 --- a/SimpleCADAPI/skills/simplecadapi/references/docs/api/make_circle_redge.md +++ b/SimpleCADAPI/skills/simplecadapi/references/docs/api/make_circle_redge.md @@ -3,7 +3,13 @@ ## API Definition ```python -def make_circle_redge(center: Tuple[float, float, float], radius: ScalarLike, normal: Tuple[float, float, float] = (0, 0, 1)) -> Edge +def make_circle_redge( + center: Tuple[float, float, float], + radius: ScalarLike, + normal: Tuple[float, float, float] = (0, 0, 1), + *, + tag_prefix: Optional[str] = None, +) -> Edge ``` *Source: operations.py* @@ -14,4 +20,5 @@ def make_circle_redge(center: Tuple[float, float, float], radius: ScalarLike, no ## Description -Create a circular edge. +Create a circular edge. `tag_prefix` optionally creates the topology tag +`.edge`. diff --git a/SimpleCADAPI/skills/simplecadapi/references/docs/api/make_circle_rface.md b/SimpleCADAPI/skills/simplecadapi/references/docs/api/make_circle_rface.md index 2e4c9f7..e35deac 100644 --- a/SimpleCADAPI/skills/simplecadapi/references/docs/api/make_circle_rface.md +++ b/SimpleCADAPI/skills/simplecadapi/references/docs/api/make_circle_rface.md @@ -3,7 +3,14 @@ ## API Definition ```python -def make_circle_rface(center: Tuple[float, float, float], radius: ScalarLike, normal: Tuple[float, float, float] = (0, 0, 1)) -> Face +def make_circle_rface( + center: Tuple[float, float, float], + radius: ScalarLike, + normal: Tuple[float, float, float] = (0, 0, 1), + *, + tag_prefix: Optional[str] = None, + edge_tag: Optional[str] = None, +) -> Face ``` *Source: operations.py* @@ -14,4 +21,8 @@ def make_circle_rface(center: Tuple[float, float, float], radius: ScalarLike, no ## Description -Create a circular face. +Create a circular face. `tag_prefix` creates `.face`, while +`edge_tag` supplies the final segment of `.edge.` for its +boundary Edge, or the complete Edge tag when `tag_prefix` is omitted. The Face +topology tag is visible to effective boundary-Edge QL queries without copying +arbitrary local Face tags to every Edge. diff --git a/SimpleCADAPI/skills/simplecadapi/references/docs/api/make_circle_rwire.md b/SimpleCADAPI/skills/simplecadapi/references/docs/api/make_circle_rwire.md index b1ffd49..da58c4e 100644 --- a/SimpleCADAPI/skills/simplecadapi/references/docs/api/make_circle_rwire.md +++ b/SimpleCADAPI/skills/simplecadapi/references/docs/api/make_circle_rwire.md @@ -3,7 +3,14 @@ ## API Definition ```python -def make_circle_rwire(center: Tuple[float, float, float], radius: ScalarLike, normal: Tuple[float, float, float] = (0, 0, 1)) -> Wire +def make_circle_rwire( + center: Tuple[float, float, float], + radius: ScalarLike, + normal: Tuple[float, float, float] = (0, 0, 1), + *, + tag_prefix: Optional[str] = None, + edge_tag: Optional[str] = None, +) -> Wire ``` *Source: operations.py* @@ -14,4 +21,8 @@ def make_circle_rwire(center: Tuple[float, float, float], radius: ScalarLike, no ## Description -Create a circular wire. +Create a circular wire. `tag_prefix` creates `.wire`, while +`edge_tag` supplies the final segment of `.edge.` for its +single circular Edge, or the complete Edge tag when `tag_prefix` is omitted. +These topology tags are preserved only where a downstream operation has +complete, kernel-proven correspondence. diff --git a/SimpleCADAPI/skills/simplecadapi/references/docs/api/make_cone_rsolid.md b/SimpleCADAPI/skills/simplecadapi/references/docs/api/make_cone_rsolid.md index 95b0348..bdc50fb 100644 --- a/SimpleCADAPI/skills/simplecadapi/references/docs/api/make_cone_rsolid.md +++ b/SimpleCADAPI/skills/simplecadapi/references/docs/api/make_cone_rsolid.md @@ -3,7 +3,22 @@ ## API Definition ```python -def make_cone_rsolid(bottom_radius: ScalarLike, height: ScalarLike, top_radius: ScalarLike = 0.0, bottom_face_center: Tuple[float, float, float] = (0, 0, 0), axis: Tuple[float, float, float] = (0, 0, 1)) -> Solid +def make_cone_rsolid( + bottom_radius: ScalarLike, + height: ScalarLike, + top_radius: ScalarLike = 0.0, + bottom_face_center: Tuple[float, float, float] = (0, 0, 0), + axis: Tuple[float, float, float] = (0, 0, 1), + *, + tag_prefix: Optional[str] = None, + result_tag: Optional[str] = None, + start_face_tag: Optional[str] = None, + end_face_tag: Optional[str] = None, + side_face_tag: Optional[str] = None, + start_edge_tag: Optional[str] = None, + end_edge_tag: Optional[str] = None, + seam_edge_tag: Optional[str] = None, +) -> Solid ``` *Source: operations.py* @@ -14,4 +29,12 @@ def make_cone_rsolid(bottom_radius: ScalarLike, height: ScalarLike, top_radius: ## Description -Create a cone or truncated cone solid. +Create a native cone or frustum with direct kernel-backed `cone.start`, +`cone.end`, and `cone.side` Face roles and `cone.start_boundary`, +`cone.end_boundary`, and `cone.seam` Edge roles. + +A pointed cone (`top_radius=0`) has no `cone.end` Face, so requesting that role +fails. Its `cone.end_boundary` is the kernel's degenerate apex Edge. A frustum +has all six roles. `tag_prefix="adapter"` creates corresponding +`adapter.face.*` and `adapter.edge.*` topology-identity tags; use the role tag +arguments for role assignments. diff --git a/SimpleCADAPI/skills/simplecadapi/references/docs/api/make_cylinder_rsolid.md b/SimpleCADAPI/skills/simplecadapi/references/docs/api/make_cylinder_rsolid.md index cd72c8e..7f44778 100644 --- a/SimpleCADAPI/skills/simplecadapi/references/docs/api/make_cylinder_rsolid.md +++ b/SimpleCADAPI/skills/simplecadapi/references/docs/api/make_cylinder_rsolid.md @@ -3,7 +3,21 @@ ## API Definition ```python -def make_cylinder_rsolid(radius: ScalarLike, height: ScalarLike, bottom_face_center: Tuple[float, float, float] = (0, 0, 0), axis: Tuple[float, float, float] = (0, 0, 1)) -> Solid +def make_cylinder_rsolid( + radius: ScalarLike, + height: ScalarLike, + bottom_face_center: Tuple[float, float, float] = (0, 0, 0), + axis: Tuple[float, float, float] = (0, 0, 1), + *, + tag_prefix: Optional[str] = None, + result_tag: Optional[str] = None, + start_face_tag: Optional[str] = None, + end_face_tag: Optional[str] = None, + side_face_tag: Optional[str] = None, + start_edge_tag: Optional[str] = None, + end_edge_tag: Optional[str] = None, + seam_edge_tag: Optional[str] = None, +) -> Solid ``` *Source: operations.py* @@ -14,4 +28,9 @@ def make_cylinder_rsolid(radius: ScalarLike, height: ScalarLike, bottom_face_cen ## Description -Create a cylinder solid. +Create a native cylinder with kernel-backed topology tags. `tag_prefix="shaft"` produces +`shaft.face.start`, `shaft.face.end`, `shaft.face.side`, and corresponding +`shaft.edge.start`, `shaft.edge.end`, and `shaft.edge.seam` tags. Face tags +are inherited by their boundary Edges. Role tags use the native +`cylinder.*` Face/Edge roles. Use QL `incident_to(..., distinct=True)` or +`shared_boundary(...)` to select an Edge from its two neighboring tagged Faces. diff --git a/SimpleCADAPI/skills/simplecadapi/references/docs/api/make_face_from_sketch_rface.md b/SimpleCADAPI/skills/simplecadapi/references/docs/api/make_face_from_sketch_rface.md index a254ca0..c6c8b92 100644 --- a/SimpleCADAPI/skills/simplecadapi/references/docs/api/make_face_from_sketch_rface.md +++ b/SimpleCADAPI/skills/simplecadapi/references/docs/api/make_face_from_sketch_rface.md @@ -14,4 +14,12 @@ def make_face_from_sketch_rface(sketch: Sketch, profile: int | str = 0, *, requi ## Description -Promote a sketch profile to a concrete face, solving internally. +Promote a sketch profile to a concrete face, solving internally. The Face and +each boundary Edge receive canonical tags with `topology_name` evidence from the +exact Sketch promotion map. For a Sketch with `name="rect"`, profile `bottom`, +and entity ID `right`, the tags are `sketch.rect.profile.bottom` and +`sketch.rect.entity.right`. + +The bindings replay from the Sketch payload and promotion parameters. Legacy +compatibility tags such as `sketch_entity.right` remain separate from the +canonical topology-identity tags. diff --git a/SimpleCADAPI/skills/simplecadapi/references/docs/api/make_face_from_wire_rface.md b/SimpleCADAPI/skills/simplecadapi/references/docs/api/make_face_from_wire_rface.md index 1546650..0152432 100644 --- a/SimpleCADAPI/skills/simplecadapi/references/docs/api/make_face_from_wire_rface.md +++ b/SimpleCADAPI/skills/simplecadapi/references/docs/api/make_face_from_wire_rface.md @@ -3,7 +3,12 @@ ## API Definition ```python -def make_face_from_wire_rface(wire: Wire, normal: Tuple[float, float, float] = (0, 0, 1)) -> Face +def make_face_from_wire_rface( + wire: Wire, + normal: Tuple[float, float, float] = (0, 0, 1), + *, + tag_prefix: Optional[str] = None, +) -> Face ``` *Source: operations.py* @@ -14,4 +19,6 @@ def make_face_from_wire_rface(wire: Wire, normal: Tuple[float, float, float] = ( ## Description -Create a face from a closed wire. +Create a face from a closed wire. Existing proven Edge topology tags on the +wire are copied to corresponding Face boundary Edges. `tag_prefix` optionally +adds the Face tag `.face`. diff --git a/SimpleCADAPI/skills/simplecadapi/references/docs/api/make_line_redge.md b/SimpleCADAPI/skills/simplecadapi/references/docs/api/make_line_redge.md index 9d9a6aa..c4ae06c 100644 --- a/SimpleCADAPI/skills/simplecadapi/references/docs/api/make_line_redge.md +++ b/SimpleCADAPI/skills/simplecadapi/references/docs/api/make_line_redge.md @@ -3,7 +3,12 @@ ## API Definition ```python -def make_line_redge(start: Tuple[ScalarLike, ScalarLike, ScalarLike], end: Tuple[ScalarLike, ScalarLike, ScalarLike]) -> Edge +def make_line_redge( + start: Tuple[ScalarLike, ScalarLike, ScalarLike], + end: Tuple[ScalarLike, ScalarLike, ScalarLike], + *, + tag_prefix: Optional[str] = None, +) -> Edge ``` *Source: operations.py* @@ -14,4 +19,7 @@ def make_line_redge(start: Tuple[ScalarLike, ScalarLike, ScalarLike], end: Tuple ## Description -Create a straight edge between two points. +Create a straight edge between two points. When `tag_prefix` is provided, the +edge receives the topology tag `.edge`. Downstream profile and +feature operations may preserve that tag when kernel history proves the +correspondence. diff --git a/SimpleCADAPI/skills/simplecadapi/references/docs/api/make_rectangle_rface.md b/SimpleCADAPI/skills/simplecadapi/references/docs/api/make_rectangle_rface.md index 96730b0..3f86ac7 100644 --- a/SimpleCADAPI/skills/simplecadapi/references/docs/api/make_rectangle_rface.md +++ b/SimpleCADAPI/skills/simplecadapi/references/docs/api/make_rectangle_rface.md @@ -3,7 +3,15 @@ ## API Definition ```python -def make_rectangle_rface(width: ScalarLike, height: ScalarLike, center: Tuple[ScalarLike, ScalarLike, ScalarLike] = (0, 0, 0), normal: Tuple[ScalarLike, ScalarLike, ScalarLike] = (0, 0, 1)) -> Face +def make_rectangle_rface( + width: ScalarLike, + height: ScalarLike, + center: Tuple[ScalarLike, ScalarLike, ScalarLike] = (0, 0, 0), + normal: Tuple[ScalarLike, ScalarLike, ScalarLike] = (0, 0, 1), + *, + tag_prefix: Optional[str] = None, + edge_tags: Optional[Sequence[str]] = None, +) -> Face ``` *Source: operations.py* @@ -14,4 +22,9 @@ def make_rectangle_rface(width: ScalarLike, height: ScalarLike, center: Tuple[Sc ## Description -Create a rectangular face. +Create a rectangular face. `tag_prefix` creates `.face`, and +`edge_tags` supplies one tag for each of its four boundary Edges. With +`tag_prefix`, each is a local segment under `.edge`; without it, +each is a complete Edge tag. These topology tags can be projected to proven +feature Faces and queried with the same `list_tags(...)` and `ql.tag(...)` +surfaces as other tags. diff --git a/SimpleCADAPI/skills/simplecadapi/references/docs/api/make_rectangle_rwire.md b/SimpleCADAPI/skills/simplecadapi/references/docs/api/make_rectangle_rwire.md index 926900c..d06cc42 100644 --- a/SimpleCADAPI/skills/simplecadapi/references/docs/api/make_rectangle_rwire.md +++ b/SimpleCADAPI/skills/simplecadapi/references/docs/api/make_rectangle_rwire.md @@ -3,7 +3,15 @@ ## API Definition ```python -def make_rectangle_rwire(width: ScalarLike, height: ScalarLike, center: Tuple[ScalarLike, ScalarLike, ScalarLike] = (0, 0, 0), normal: Tuple[ScalarLike, ScalarLike, ScalarLike] = (0, 0, 1)) -> Wire +def make_rectangle_rwire( + width: ScalarLike, + height: ScalarLike, + center: Tuple[ScalarLike, ScalarLike, ScalarLike] = (0, 0, 0), + normal: Tuple[ScalarLike, ScalarLike, ScalarLike] = (0, 0, 1), + *, + tag_prefix: Optional[str] = None, + edge_tags: Optional[Sequence[str]] = None, +) -> Wire ``` *Source: operations.py* @@ -14,4 +22,9 @@ def make_rectangle_rwire(width: ScalarLike, height: ScalarLike, center: Tuple[Sc ## Description -Create a rectangular wire. +Create a rectangular wire. `tag_prefix` creates `.wire`. +`edge_tags` must contain one tag for each generated profile Edge, in kernel +construction order. With `tag_prefix`, each value is the local segment of +`.edge.`; without it, each value is the complete Edge tag. +These topology tags are stable anchors for operations such as `extrude_rsolid` +when correspondence is proven. diff --git a/SimpleCADAPI/skills/simplecadapi/references/docs/api/make_sketch_rsketch.md b/SimpleCADAPI/skills/simplecadapi/references/docs/api/make_sketch_rsketch.md index cfa467f..c091355 100644 --- a/SimpleCADAPI/skills/simplecadapi/references/docs/api/make_sketch_rsketch.md +++ b/SimpleCADAPI/skills/simplecadapi/references/docs/api/make_sketch_rsketch.md @@ -17,4 +17,5 @@ def make_sketch_rsketch(name: Optional[str] = None, *, plane: Any = 'XY', sketch Create an empty declarative sketch document. Use this API, not concrete edge/wire constructors, when the intent is to -build a sketch profile with constraints. +build a sketch profile with constraints. The sketch name and explicit entity +IDs are stable local identifiers used by constrained profile promotion. diff --git a/SimpleCADAPI/skills/simplecadapi/references/docs/api/make_wire_from_edges_rwire.md b/SimpleCADAPI/skills/simplecadapi/references/docs/api/make_wire_from_edges_rwire.md index 3f92edc..69bb8b6 100644 --- a/SimpleCADAPI/skills/simplecadapi/references/docs/api/make_wire_from_edges_rwire.md +++ b/SimpleCADAPI/skills/simplecadapi/references/docs/api/make_wire_from_edges_rwire.md @@ -3,7 +3,7 @@ ## API Definition ```python -def make_wire_from_edges_rwire(edges: List[Edge]) -> Wire +def make_wire_from_edges_rwire(edges: List[Edge], *, tag_prefix: Optional[str] = None) -> Wire ``` *Source: operations.py* @@ -14,4 +14,6 @@ def make_wire_from_edges_rwire(edges: List[Edge]) -> Wire ## Description -Create a wire from a list of connected edges. +Create a wire from a list of connected edges. Existing proven Edge topology +tags are preserved by exact topology identity; `tag_prefix` optionally adds +`.wire` to the resulting wire. diff --git a/SimpleCADAPI/skills/simplecadapi/references/docs/api/make_wire_from_sketch_rwire.md b/SimpleCADAPI/skills/simplecadapi/references/docs/api/make_wire_from_sketch_rwire.md index 9984092..79d3a22 100644 --- a/SimpleCADAPI/skills/simplecadapi/references/docs/api/make_wire_from_sketch_rwire.md +++ b/SimpleCADAPI/skills/simplecadapi/references/docs/api/make_wire_from_sketch_rwire.md @@ -14,4 +14,10 @@ def make_wire_from_sketch_rwire(sketch: Sketch, profile: int | str = 0, *, requi ## Description -Promote a sketch profile to a concrete wire, solving internally. +Promote a sketch profile to a concrete wire, solving internally. The promotion +map preserves exact ordered entity-to-Edge correspondence and creates tags with +`topology_name` evidence, such as `sketch.rect.profile.bottom` and +`sketch.rect.entity.right`. + +Promotion fails when the generated Edge count does not match the promotion map; +the SDK does not infer identity from geometry or enumeration heuristics. diff --git a/SimpleCADAPI/skills/simplecadapi/references/docs/api/model.md b/SimpleCADAPI/skills/simplecadapi/references/docs/api/model.md new file mode 100644 index 0000000..3ad45a8 --- /dev/null +++ b/SimpleCADAPI/skills/simplecadapi/references/docs/api/model.md @@ -0,0 +1,44 @@ +# model + +## API Definition + +```python +def model( + func=None, + *, + graph_id: Optional[str] = None, + export_dir: Optional[str | Path] = None, +) -> Callable +``` + +*Source: graph.py* + +## Import Surface + +- top-level: `from simplecadapi import model` + +## Description + +Decorate the single top-level entry point of a replayable model. Each invocation +creates and owns exactly one `GraphSession`, activates it while the function +runs, captures model/session JSON in memory, and returns a `ModelResult`. When +`export_dir` is provided, explicitly captured geometry/product values produce +one `.scene.zip` in that directory. The package embeds model JSON, +mapped project-relative Python source files, and render/selection assets; it +does not create adjacent model/session JSON, STEP, STL, or FCStd files. + +```python +@scad.model(graph_id="bracket") +def build_bracket(): + body = scad.make_box_rsolid(width=20.0, height=10.0, depth=3.0) + scad.capture_result(value=body) + return body +``` + +Use `result.artifact_paths["scene"]` to locate the package, or call +`result.export_artifacts(output_dir=...)` after a model has run without an +export directory. The explicit `export_dir` opt-in avoids unexpected filesystem +writes for library callers and tests. + +Do not nest `@model` functions or create another `GraphSession` inside a model +function. Use `@requires_session` for child builders. diff --git a/SimpleCADAPI/skills/simplecadapi/references/docs/api/output_role.md b/SimpleCADAPI/skills/simplecadapi/references/docs/api/output_role.md new file mode 100644 index 0000000..97d49e1 --- /dev/null +++ b/SimpleCADAPI/skills/simplecadapi/references/docs/api/output_role.md @@ -0,0 +1,23 @@ +# output_role + +## API Definition + +```python +def output_role(role_name: str) -> SerializablePredicate +``` + +*Source: ql.py* + +## Import Surface + +- submodule: `from simplecadapi import ql` + +## Description + +Return a serializable predicate matching a kernel-proven operation output role +in `metadata["track"]`. Role matching never falls back to face order, geometry, +or flat tags. Use it with a typed selector, for example: + +```python +end = ql.faces().where(ql.output_role(role_name="extrusion.end")).exactly(1) +``` diff --git a/SimpleCADAPI/skills/simplecadapi/references/docs/api/render_screenshot_rpath.md b/SimpleCADAPI/skills/simplecadapi/references/docs/api/render_screenshot_rpath.md index b54d638..b55d683 100644 --- a/SimpleCADAPI/skills/simplecadapi/references/docs/api/render_screenshot_rpath.md +++ b/SimpleCADAPI/skills/simplecadapi/references/docs/api/render_screenshot_rpath.md @@ -3,7 +3,7 @@ ## API Definition ```python -def render_screenshot_rpath(shapes: Union[Solid, Sequence[Solid]], output_path: str, highlight_tags: Optional[Sequence[str]] = None, tag_labels: Optional[Dict[str, str]] = None, image_size: Tuple[int, int] = (1400, 900), view: Union[Tuple[float, float], str] = 'auto', show_axes: bool = True, show_legend: bool = True, zoom: float = 4.0) -> str +def render_screenshot_rpath(shapes: Union[Solid, Sequence[Solid]], output_path: str, highlight_tags: Optional[Sequence[str]] = None, tag_labels: Optional[Dict[str, str]] = None, image_size: Tuple[int, int] = (1400, 900), view: Union[Tuple[float, float], str] = 'auto', show_axes: bool = True, show_legend: bool = True, zoom: float = 4.0, show_callouts: bool = True) -> str ``` *Source: operations.py* @@ -14,4 +14,6 @@ def render_screenshot_rpath(shapes: Union[Solid, Sequence[Solid]], output_path: ## Description -Render a screenshot of shapes and save it to a file. +Render a screenshot of shapes and save it to a file. Set `show_callouts=False` +to retain highlighted material colors and the legend without placing tag labels +over the model. diff --git a/SimpleCADAPI/skills/simplecadapi/references/docs/api/requires_session.md b/SimpleCADAPI/skills/simplecadapi/references/docs/api/requires_session.md new file mode 100644 index 0000000..e41912d --- /dev/null +++ b/SimpleCADAPI/skills/simplecadapi/references/docs/api/requires_session.md @@ -0,0 +1,22 @@ +# requires_session + +## API Definition + +```python +def requires_session(func=None) -> Callable +``` + +*Source: graph.py* + +## Import Surface + +- top-level: `from simplecadapi import requires_session` + +## Description + +Decorate a reusable graph-producing builder that must run inside the caller's +active `GraphSession`. It reuses the session owned by the enclosing `@model` +function and validates returned graph values against that session. + +Calling a `@requires_session` builder without an active session raises +`RuntimeError`. Builders must not create their own `GraphSession`. diff --git a/SimpleCADAPI/skills/simplecadapi/references/docs/api/revolve_rsolid.md b/SimpleCADAPI/skills/simplecadapi/references/docs/api/revolve_rsolid.md index ceb9dc9..814425a 100644 --- a/SimpleCADAPI/skills/simplecadapi/references/docs/api/revolve_rsolid.md +++ b/SimpleCADAPI/skills/simplecadapi/references/docs/api/revolve_rsolid.md @@ -3,7 +3,18 @@ ## API Definition ```python -def revolve_rsolid(profile: Union[Wire, Face], axis: Tuple[float, float, float] = (0, 0, 1), angle: ScalarLike = 360, origin: Tuple[float, float, float] = (0, 0, 0)) -> Solid +def revolve_rsolid( + profile: Union[Wire, Face], + axis: Tuple[float, float, float] = (0, 0, 1), + angle: ScalarLike = 360, + origin: Tuple[float, float, float] = (0, 0, 0), + *, + tag_prefix: Optional[str] = None, + result_tag: Optional[str] = None, + start_face_tag: Optional[str] = None, + end_face_tag: Optional[str] = None, + side_faces_tag: Optional[str] = None, +) -> Solid ``` *Source: operations.py* @@ -14,4 +25,10 @@ def revolve_rsolid(profile: Union[Wire, Face], axis: Tuple[float, float, float] ## Description -Create a solid by revolving a profile around an axis. +Create a solid by revolving a profile around an axis. Kernel history assigns +`revolution.start`, `revolution.end`, and `revolution.side` output roles. + +Start and end roles require exactly one proven face; side tags apply to every +proven side face. A full 360-degree revolve normally has no separate start or end +face, so requesting either cap tag raises a capability error rather than guessing. +`result_tag` tags the resulting solid. Role tags are replayable semantic nodes. diff --git a/SimpleCADAPI/skills/simplecadapi/references/docs/api/select.md b/SimpleCADAPI/skills/simplecadapi/references/docs/api/select.md index 8ed3581..f117e07 100644 --- a/SimpleCADAPI/skills/simplecadapi/references/docs/api/select.md +++ b/SimpleCADAPI/skills/simplecadapi/references/docs/api/select.md @@ -15,3 +15,15 @@ def select(items: Iterable[Any]) -> Query ## Description Start a QL query over a shape collection or selector scope. + +For topology-aware selection, use `ql.faces()`, `ql.edges()`, `ql.wires()`, +`ql.vertices()`, or `ql.solids()`. `ShapeSelector.intersection(other)` forms a +serializable set intersection. `selector.shared_boundary(other, +to_kind="edge")` intersects the boundaries of two selectors. Edge selectors +also support `incident_to(face_selector, ..., distinct=True)` and +`incident_face_count(exactly=2)` to select edges by exact incident Face +witnesses and reject open or non-manifold edges. + +These selectors resolve by topology identity, not enumeration order, area, +normal, or position heuristics. Their `to_dict()` payloads can be restored with +`ql.selector_from_dict(...)`. diff --git a/SimpleCADAPI/skills/simplecadapi/references/docs/api/select_edges_by_tag.md b/SimpleCADAPI/skills/simplecadapi/references/docs/api/select_edges_by_tag.md index 9048890..f0748d9 100644 --- a/SimpleCADAPI/skills/simplecadapi/references/docs/api/select_edges_by_tag.md +++ b/SimpleCADAPI/skills/simplecadapi/references/docs/api/select_edges_by_tag.md @@ -3,7 +3,11 @@ ## API Definition ```python -def select_edges_by_tag(shape: Union[Face, Solid], tag: str) -> List[Edge] +def select_edges_by_tag( + shape: Union[Face, Solid], + tag: str, + scope: str | TagScope = TagScope.EFFECTIVE, +) -> List[Edge] ``` *Source: operations.py* @@ -14,4 +18,6 @@ def select_edges_by_tag(shape: Union[Face, Solid], tag: str) -> List[Edge] ## Description -Select edges by tag. +Select edges by an exact normalized tag in the requested semantic scope. +`effective` does not include lineage; request `scope="lineage"` explicitly when +selection depends on complete topology-history evidence. diff --git a/SimpleCADAPI/skills/simplecadapi/references/docs/api/select_faces_by_tag.md b/SimpleCADAPI/skills/simplecadapi/references/docs/api/select_faces_by_tag.md index 6d02151..03d1c80 100644 --- a/SimpleCADAPI/skills/simplecadapi/references/docs/api/select_faces_by_tag.md +++ b/SimpleCADAPI/skills/simplecadapi/references/docs/api/select_faces_by_tag.md @@ -3,7 +3,11 @@ ## API Definition ```python -def select_faces_by_tag(solid: Solid, tag: str) -> List[Face] +def select_faces_by_tag( + solid: Solid, + tag: str, + scope: str | TagScope = TagScope.EFFECTIVE, +) -> List[Face] ``` *Source: operations.py* @@ -14,4 +18,6 @@ def select_faces_by_tag(solid: Solid, tag: str) -> List[Face] ## Description -Select faces by tag. +Select faces by an exact normalized tag in the requested semantic scope. +`effective` does not include lineage; request `scope="lineage"` explicitly when +selection depends on complete topology-history evidence. diff --git a/SimpleCADAPI/skills/simplecadapi/references/docs/api/shell_rsolid.md b/SimpleCADAPI/skills/simplecadapi/references/docs/api/shell_rsolid.md index e3b3438..a6db125 100644 --- a/SimpleCADAPI/skills/simplecadapi/references/docs/api/shell_rsolid.md +++ b/SimpleCADAPI/skills/simplecadapi/references/docs/api/shell_rsolid.md @@ -3,7 +3,17 @@ ## API Definition ```python -def shell_rsolid(solid: Solid, faces_to_remove: Union[Sequence[Face], ShapeSelector], thickness: ScalarLike) -> Solid +def shell_rsolid( + solid: Solid, + faces_to_remove: Union[Sequence[Face], ShapeSelector], + thickness: ScalarLike, + *, + result_tag: Optional[str] = None, + body_faces_tag: Optional[str] = None, + offset_faces_tag: Optional[str] = None, + closing_faces_tag: Optional[str] = None, + wall_edges_tag: Optional[str] = None, +) -> Solid ``` *Source: operations.py* @@ -14,4 +24,16 @@ def shell_rsolid(solid: Solid, faces_to_remove: Union[Sequence[Face], ShapeSelec ## Description -Shell a solid to create a hollow part. +Shell a solid to create a hollow part. The operation can expose these exact +kernel roles: + +- `shell.body_face`: surviving or modified source body faces. +- `shell.offset_face`: generated offset faces. +- `shell.closing_descendant`: descendants of removed closing faces. +- `shell.wall`: generated closing-boundary edges. + +The named arguments map directly to those roles. A role is available only when +OCC provides a complete witness; +requesting an unavailable role fails instead of deriving one from enumeration or +geometry. `result_tag` tags the resulting solid. Recorded assignments replay as +semantic nodes and preserve face versus edge target kinds. diff --git a/SimpleCADAPI/skills/simplecadapi/references/docs/api/solids.md b/SimpleCADAPI/skills/simplecadapi/references/docs/api/solids.md new file mode 100644 index 0000000..a0d71b7 --- /dev/null +++ b/SimpleCADAPI/skills/simplecadapi/references/docs/api/solids.md @@ -0,0 +1,19 @@ +# solids + +## API Definition + +```python +def solids() -> ShapeSelector +``` + +*Source: ql.py* + +## Import Surface + +- submodule: `from simplecadapi import ql` + +## Description + +Create a serializable selector over Solid topology. Solid selectors can be +traversed to boundary Edges and combined with `intersection(...)` or +`shared_boundary(...)` to find topology common to two named Solid selectors. diff --git a/SimpleCADAPI/skills/simplecadapi/references/docs/api/source_binding.md b/SimpleCADAPI/skills/simplecadapi/references/docs/api/source_binding.md new file mode 100644 index 0000000..67b0236 --- /dev/null +++ b/SimpleCADAPI/skills/simplecadapi/references/docs/api/source_binding.md @@ -0,0 +1,19 @@ +# source_binding + +## API Definition + +```python +def source_binding(binding_id: str) -> SerializablePredicate +``` + +*Source: ql.py* + +## Import Surface + +- submodule: `from simplecadapi import ql` + +## Description + +Match a local projected `TagBinding` whose topology-change evidence preserves the +exact source `binding_id`. Objects without canonical local binding evidence raise +an unsupported-query capability error instead of consulting flat tags. diff --git a/SimpleCADAPI/skills/simplecadapi/references/docs/api/source_topology.md b/SimpleCADAPI/skills/simplecadapi/references/docs/api/source_topology.md new file mode 100644 index 0000000..28d32fb --- /dev/null +++ b/SimpleCADAPI/skills/simplecadapi/references/docs/api/source_topology.md @@ -0,0 +1,19 @@ +# source_topology + +## API Definition + +```python +def source_topology(topo_id: str) -> SerializablePredicate +``` + +*Source: ql.py* + +## Import Surface + +- submodule: `from simplecadapi import ql` + +## Description + +Match a local projected `TagBinding` by the exact source topology identity stored +in its kernel-history evidence. This predicate queries source-preserving evidence; +it does not infer ancestry from geometry. diff --git a/SimpleCADAPI/skills/simplecadapi/references/docs/api/sweep_rsolid.md b/SimpleCADAPI/skills/simplecadapi/references/docs/api/sweep_rsolid.md index 774d45d..c68b308 100644 --- a/SimpleCADAPI/skills/simplecadapi/references/docs/api/sweep_rsolid.md +++ b/SimpleCADAPI/skills/simplecadapi/references/docs/api/sweep_rsolid.md @@ -3,7 +3,17 @@ ## API Definition ```python -def sweep_rsolid(profile: Face, path: Wire, is_frenet: bool = False) -> Solid +def sweep_rsolid( + profile: Face, + path: Wire, + is_frenet: bool = False, + *, + tag_prefix: Optional[str] = None, + result_tag: Optional[str] = None, + start_face_tag: Optional[str] = None, + end_face_tag: Optional[str] = None, + side_faces_tag: Optional[str] = None, +) -> Solid ``` *Source: operations.py* @@ -14,4 +24,10 @@ def sweep_rsolid(profile: Face, path: Wire, is_frenet: bool = False) -> Solid ## Description -Create a solid by sweeping a profile along a path. +Create a solid by sweeping a profile along a path. Kernel history assigns +`sweep.start`, `sweep.end`, and `sweep.side` roles. Start and end tags require one +proven face each; side tags apply to all proven side faces. `result_tag` targets +the solid, and recorded assignments are replayable semantic nodes. + +Profiles with inner wires are rejected because the current PipeShell operation +receives only the outer wire; silently dropping profile holes is not allowed. diff --git a/SimpleCADAPI/skills/simplecadapi/references/docs/api/twisted_sweep_rsolid.md b/SimpleCADAPI/skills/simplecadapi/references/docs/api/twisted_sweep_rsolid.md new file mode 100644 index 0000000..2bb58d1 --- /dev/null +++ b/SimpleCADAPI/skills/simplecadapi/references/docs/api/twisted_sweep_rsolid.md @@ -0,0 +1,46 @@ +# twisted_sweep_rsolid + +## API Definition + +```python +def twisted_sweep_rsolid( + profile: Face, + distance: ScalarLike, + twist_angle: ScalarLike, + axis: Tuple[float, float, float] = (0.0, 0.0, 1.0), + origin: Tuple[float, float, float] = (0.0, 0.0, 0.0), + *, + guide_radius: ScalarLike = 1.0, + tag_prefix: Optional[str] = None, + result_tag: Optional[str] = None, + start_face_tag: Optional[str] = None, + end_face_tag: Optional[str] = None, + side_faces_tag: Optional[str] = None, +) -> Solid +``` + +*Source: operations.py* + +## Import Surface + +- top-level: `from simplecadapi import twisted_sweep_rsolid` + +## Description + +Sweep a planar profile along a straight axis while rotating it linearly by the +signed total `twist_angle` in degrees. The profile must lie at the sweep start, +be planar, and be normal to `axis`. + +The OCP implementation uses a one-edge straight spine and a one-edge +cylindrical auxiliary spine. This normally creates one continuous side face per +profile edge rather than splitting every side at intermediate loft sections. +Profiles with inner wires are rejected. + +Kernel history assigns `twisted_sweep.start`, `twisted_sweep.end`, and +`twisted_sweep.side` roles. The operation records one canonical +`make_twisted_sweep_rsolid` graph node containing `axis`, `origin`, `distance`, +`twist_angle`, and `guide_radius`; strict replay invokes the same public +operation with the recorded parameters. + +`guide_radius` controls only the auxiliary orientation guide and must be a +positive finite value. It does not set the swept profile radius. diff --git a/SimpleCADAPI/skills/simplecadapi/references/docs/api/union_rsolid.md b/SimpleCADAPI/skills/simplecadapi/references/docs/api/union_rsolid.md index 1b31c17..740c33a 100644 --- a/SimpleCADAPI/skills/simplecadapi/references/docs/api/union_rsolid.md +++ b/SimpleCADAPI/skills/simplecadapi/references/docs/api/union_rsolid.md @@ -3,7 +3,13 @@ ## API Definition ```python -def union_rsolid(*solids: Union[Solid, Sequence[Solid]], clean: bool = True, glue: bool = _DEFAULT_UNION_GLUE, tol: Optional[float] = None) -> Solid +def union_rsolid( + *solids: Union[Solid, Sequence[Solid]], + clean: bool = True, + glue: bool = _DEFAULT_UNION_GLUE, + tol: Optional[float] = None, + tracking_policy: TrackingPolicy | str = TrackingPolicy.FULL, +) -> Solid ``` *Source: operations.py* @@ -40,6 +46,10 @@ returning multiple pieces. - **Type**: `Optional fuzzy-boolean tolerance used by the OCC union kernel. When` - **Description**: omitted, SimpleCAD chooses a conservative scale-aware tolerance. +### tracking_policy + +- **Description**: `TrackingPolicy.FULL` computes topology history and lineage. `TrackingPolicy.GRAPH` preserves the canonical union node, parameters, inputs, result topology references, and replay while omitting `TopoDelta` and history-derived topology lineage. Geometry options `clean`, `glue`, and `tol` are unchanged. + ## Returns Solid: The merged union result. diff --git a/SimpleCADAPI/skills/simplecadapi/references/docs/api/var_function.md b/SimpleCADAPI/skills/simplecadapi/references/docs/api/var_function.md index 35d0196..d0e136c 100644 --- a/SimpleCADAPI/skills/simplecadapi/references/docs/api/var_function.md +++ b/SimpleCADAPI/skills/simplecadapi/references/docs/api/var_function.md @@ -3,7 +3,7 @@ ## API Definition ```python -def var(name: str, default: int | float, comment: str | None = None) -> Var +def var(name: str, default: int | float, comment: str | None = None, tolerance: ToleranceLike | None = None, *, unit: UnitLike | None = None, tolerance_unit: UnitLike | None = None) -> Var ``` *Source: expr.py* @@ -14,4 +14,13 @@ def var(name: str, default: int | float, comment: str | None = None) -> Var ## Description -Create a named variable node for v2 expression-driven parameters. +Create a physical or legacy scalar variable. + +``tolerance=0.1`` declares a symmetric ``+/-0.1`` tolerance. Use a +``(lower_deviation, upper_deviation)`` pair for an asymmetric tolerance. +``tolerance_unit`` defaults to ``unit`` when a nominal unit is declared. +Values are converted to canonical CAD units only when evaluated, so the +declaration and serialized expression node preserve the user's units. + +Variables without ``unit`` retain legacy unitless behavior. A unit-aware +expression cannot mix declared-unit variables with legacy variables. diff --git a/SimpleCADAPI/skills/simplecadapi/references/docs/architecture/sdk-architecture-review.md b/SimpleCADAPI/skills/simplecadapi/references/docs/architecture/sdk-architecture-review.md index 5b812ea..856be43 100644 --- a/SimpleCADAPI/skills/simplecadapi/references/docs/architecture/sdk-architecture-review.md +++ b/SimpleCADAPI/skills/simplecadapi/references/docs/architecture/sdk-architecture-review.md @@ -33,7 +33,7 @@ SimpleCADAPI 的顶层 API 选择是正确的:它没有把用户拖进传统 C - historical scalar-field/SDF implementation, now removed from the active public/support surface - `docs/core/serialization/` - `docs/core/operation_graph_json_spec.md` -- `examples/07_serialization_operation_tree.py` +- retained replayable examples under `examples/` - `test/` 与 `tests/` ## 顶层 API 评价 @@ -46,7 +46,7 @@ SimpleCADAPI 的顶层 API 选择是正确的:它没有把用户拖进传统 C - `__init__.py` 明确导出建模函数、核心类型、graph/session、serializer、expression 和 `ql` 子模块,公开面可见性好。参考:`src/simplecadapi/__init__.py`。 - 大多数核心创建函数遵守返回类型后缀,例如 `make_point_rvertex -> Vertex`、`make_line_redge -> Edge`、`make_circle_rwire -> Wire`、`make_box_rsolid -> Solid`。参考:`src/simplecadapi/operations.py:691`、`src/simplecadapi/operations.py:723`、`src/simplecadapi/operations.py:1465`。 -- composite convenience API 在 `GraphSession` 内降低为 canonical low-level graph,这个设计非常正确。用户调用 `make_box_rsolid`,图里记录 rectangle/profile/extrude,这是 CAD DSL 和 replay IR 分层的正确方式。参考:`src/simplecadapi/operations.py:1480`、`examples/07_serialization_operation_tree.py:52`、`examples/07_serialization_operation_tree.py:272`。 +- composite convenience API 在 `GraphSession` 内降低为 canonical low-level graph,这个设计非常正确。用户调用 `make_box_rsolid`,图里记录 rectangle/profile/extrude,这是 CAD DSL 和 replay IR 分层的正确方式。参考:`src/simplecadapi/operations.py` 与 `test/test_serialization.py`。 - `union_rsolid(...)` 明确返回单个 `Solid`,失败就报错,不默默返回多个实体。这对机械 CAD 是正确的默认语义。参考:`src/simplecadapi/operations.py:2856`、`src/simplecadapi/operations.py:2876`。 - `GraphSession` + `export_model_json` + `replay_model_json` 的主线是对的。它把脚本建模提升为可交换、可 replay 的模型记录。参考:`src/simplecadapi/graph.py:44`、`src/simplecadapi/serializer.py:416`、`src/simplecadapi/serializer.py:732`。 - `ql` 被放到子模块而非全部塞进顶层,是正确的边界意识。参考:`src/simplecadapi/__init__.py`、`docs/api/README.md:5`。 @@ -154,7 +154,7 @@ QL 是架构上最应该继续投资的部分。 - `OperationGraph` 是 DAG,节点保存 op、params、param_exprs、inputs、context、semantic_delta、topo_delta、tags。结构完整。参考:`src/simplecadapi/topology.py:318`、`src/simplecadapi/topology.py:476`。 - `export_model_json` 输出 graph、leaf_ids、expression_graph、frame_graph、registries、delta logs、canonical contract。方向很对。参考:`src/simplecadapi/serializer.py:627`。 - `_assert_graph_is_canonical` 在 export model 前拒绝非 canonical ops,这是保持 IR 干净的正确动作。参考:`src/simplecadapi/serializer.py:246`、`src/simplecadapi/serializer.py:624`。 -- `examples/07_serialization_operation_tree.py` 非常有价值。它把 source API 到 operation tree 的映射讲清楚,是 SDK 最该保留和扩展的示例。参考:`examples/07_serialization_operation_tree.py:1`、`examples/07_serialization_operation_tree.py:217`。 +- `test/test_serialization.py` 系统覆盖 source API 到 operation tree 的映射,是该契约的主要回归入口。 严重问题: @@ -164,7 +164,7 @@ QL 是架构上最应该继续投资的部分。 - graph schema version 是 `1.0`,model schema 是 `2.0-draft`,canonical contract 是 `2.0-final-state`。这三个版本信号混在一起,会让外部消费者不知道哪个才是稳定承诺。参考:`src/simplecadapi/topology.py:19`、`src/simplecadapi/serializer.py:221`、`src/simplecadapi/serializer.py:628`。 - node.context 和 frame_graph 被记录,但 replay 不恢复 frame/workplane;所有 ops 按当前 ambient world context 执行。参考:`src/simplecadapi/graph.py:132`、`src/simplecadapi/serializer.py:991`。 - `leaf_ids` 自动来自 graph leaves,而不是用户显式声明的 final outputs。debug/intermediate 独立节点会成为 replay 输出。参考:`src/simplecadapi/serializer.py:624`、`src/simplecadapi/topology.py:563`。 -- expression replay 是 numeric snapshot,不是 parametric replay。这个可以接受,但必须在 SDK 主文档里反复强调。参考:`examples/07_serialization_operation_tree.py:40`、`examples/07_serialization_operation_tree.py:287`。 +- expression replay 是 numeric snapshot,不是 parametric replay。这个可以接受,但必须在 SDK 主文档里反复强调。参考:`test/test_serialization.py`。 建议:replay 应该默认 strict。所有 missing input、missing param、unknown op、leaf missing output、selection cardinality mismatch 都应该 hard failure。需要宽松模式时显式 `strict=False`。 diff --git a/SimpleCADAPI/skills/simplecadapi/references/docs/core/README.md b/SimpleCADAPI/skills/simplecadapi/references/docs/core/README.md index ead6458..e2c7302 100644 --- a/SimpleCADAPI/skills/simplecadapi/references/docs/core/README.md +++ b/SimpleCADAPI/skills/simplecadapi/references/docs/core/README.md @@ -62,7 +62,7 @@ SimpleWorkplane ← local modeling context - **Shape-first API**: users work with `Vertex`, `Edge`, `Wire`, `Face`, and `Solid`, not graph nodes. - **Functional modeling style**: public operations return new geometry values, e.g. `make_box_rsolid(...)`, `cut_rsolid(...)`, `fillet_rsolid(...)`. - **OCP-native runtime**: geometry construction, topology traversal, properties, booleans, transforms, and export use OCP/OpenCascade helpers. -- **Replayable graph workflows**: `GraphSession` can record a canonical low-level operation graph and `export_model_json()` can serialize it for `replay_model_json()`. +- **Replayable graph workflows**: `@scad.model` owns one `GraphSession` and returns a `ModelResult`; `@scad.requires_session` composes child builders, and `scad.capture_result()` selects canonical output nodes for replay and export. - **Tags and metadata**: tags are useful for lightweight semantics; structured numeric facts should be stored in metadata such as `metadata["geo"]`. - **Indexed topology access**: use plural methods such as `get_edges()` and `get_faces()` for enumeration, and pass an index to the same getter, such as `get_edges(index)` or `get_faces(index)`, for intentional indexed picks that should become graph selection nodes. @@ -74,11 +74,14 @@ import simplecadapi as scad with scad.SimpleWorkplane(origin=(0, 0, 0)): box = scad.make_box_rsolid(width=5, height=3, depth=2) -scad.apply_tag(box, "role.bracket") +scad.apply_tag(shape=box, tag="role.bracket") box.set_metadata("material", "6061-T6") box.auto_tag_faces("box") -top_faces = [face for face in box.get_faces() if "face.top" in scad.list_tags(face)] +top_faces = [ + face for face in box.get_faces() + if "face.top" in scad.list_tags(shape=face) +] print(len(top_faces)) ``` @@ -87,13 +90,18 @@ print(len(top_faces)) ```python import simplecadapi as scad -with scad.GraphSession() as session: - body = scad.make_box_rsolid(10, 10, 4) - hole = scad.make_cylinder_rsolid(1.5, 8, bottom_face_center=(0, 0, -2)) +@scad.model(graph_id="drilled_block") +def build_model(): + body = scad.make_box_rsolid(width=10, height=10, depth=4) + hole = scad.make_cylinder_rsolid( + radius=1.5, height=8, bottom_face_center=(0, 0, -2) + ) part = scad.cut_rsolid(body, hole) + scad.capture_result(value=part) + return part -payload = scad.export_model_json(session) -rebuilt = scad.replay_model_json(payload) +result = build_model() +rebuilt = result.replay() print(len(rebuilt)) ``` diff --git a/SimpleCADAPI/skills/simplecadapi/references/docs/core/dimension-tolerance-chains.md b/SimpleCADAPI/skills/simplecadapi/references/docs/core/dimension-tolerance-chains.md new file mode 100644 index 0000000..6c64cd2 --- /dev/null +++ b/SimpleCADAPI/skills/simplecadapi/references/docs/core/dimension-tolerance-chains.md @@ -0,0 +1,56 @@ +# Dimension Tolerance Chains + +SimpleCADAPI can attach manufacturing tolerances to declared dimension variables, propagate them through the expression DAG, and verify derived dimensions against design requirements. + +This feature is separate from sketch-solver residual tolerances, boolean fuzzy tolerances, mesh resolution, and geometric fitting tolerances. + +## Core Workflow + +```python +import simplecadapi as scad + +housing = scad.var("housing", 100.0, unit="mm", tolerance=0.15) +bearing = scad.var("bearing", 2.0, unit="cm", tolerance=(-0.04, 0.05), tolerance_unit="mm") +spacer = scad.var("spacer", 79.4, unit="mm", tolerance=0.05) +clearance = housing - bearing - spacer + +worst_case = scad.analyze_tolerance(clearance, method="worst_case") +rss = scad.analyze_tolerance(clearance, method="rss") + +with scad.GraphSession() as session: + body = scad.make_box_rsolid(housing, 10.0, 10.0) + session.require_tolerance( + clearance, + (-0.25, 0.24), + method="worst_case", + name="axial_clearance", + tolerance_unit="mm", + ) + +report = session.validate_tolerances(raise_on_failure=True) +model_json = scad.export_model_json(session) +``` + +## Rules + +- A scalar tolerance is symmetric: `0.1` means `-0.1/+0.1`. +- A two-value sequence contains signed `(lower_deviation, upper_deviation)` values. +- Every variable in a tolerance chain must declare a finite tolerance. +- `tolerance_unit` defaults to `unit`; different nominal/tolerance units must + have the same dimension. +- Length and angle propagate in canonical `mm` and `deg` units. +- Expression dimensions are inferred and validated before propagation. +- Area and volume can be analyzed, while requirements currently accept final + Length and Angle results only. +- Tolerance sources are identified by `expr_id`, not by variable name. +- `worst_case` uses exact dependency-aware affine propagation and conservative nonlinear interval propagation. +- `rss` uses analytic first-order sensitivities, assumes distinct variables are independent, and combines repeated occurrences of the same variable before RSS. +- Both methods reject a chain that is undefined anywhere inside its declared source intervals. +- Export, import, replay, and FreeCAD translation validate stored requirements automatically. +- Legacy model JSON without `tolerance_graph` imports with an empty graph. +- Nominal replay continues to use numeric operation snapshots; tolerance analysis does not regenerate worst-case geometry. +- Import recomputes dimensions and rejects tampered unit or target-dimension data. + +Use `check_tolerance()` for a standalone non-raising check. Use `GraphSession.require_tolerance()` when the requirement must be serialized and enforced. + +See the source-checkout guide at `docs/core/dimension-tolerance-chains.md` for propagation details, failure conditions, JSON shapes, and FreeCAD behavior. diff --git a/SimpleCADAPI/skills/simplecadapi/references/docs/core/operation_graph_json_spec.md b/SimpleCADAPI/skills/simplecadapi/references/docs/core/operation_graph_json_spec.md index ddda745..e86793e 100644 --- a/SimpleCADAPI/skills/simplecadapi/references/docs/core/operation_graph_json_spec.md +++ b/SimpleCADAPI/skills/simplecadapi/references/docs/core/operation_graph_json_spec.md @@ -117,7 +117,7 @@ source API 名字不等于 canonical graph op 名字。Composite source API 可 ```json { "schema_version": "2.0", - "producer_version": "2.0.1b1", + "producer_version": "2.0.2", "capabilities": { "selection_ref_strategies": true, "geo_select_nodes": true, @@ -334,7 +334,7 @@ source API 名字不等于 canonical graph op 名字。Composite source API 可 - `make_*_face` 与 `make_face_from_wire` -> `Sketch` - `make_*_edge` / `make_*_wire` -> `Profile` -- `make_extrude_rsolid` / `make_revolve_rsolid` / `make_loft_rsolid` / `make_sweep_rsolid` / `make_fillet_rsolid` / `make_chamfer_rsolid` / `make_shell_rsolid` / `make_cut_rsolid` / `make_union_rsolid` / `make_intersect_rsolid` -> `Feature` +- `make_extrude_rsolid` / `make_revolve_rsolid` / `make_loft_rsolid` / `make_sweep_rsolid` / `make_twisted_sweep_rsolid` / `make_fillet_rsolid` / `make_chamfer_rsolid` / `make_shell_rsolid` / `make_cut_rsolid` / `make_union_rsolid` / `make_intersect_rsolid` -> `Feature` ## 7. Topology Delta Schema @@ -936,6 +936,7 @@ New canonical profile nodes use the `make_*_r*` names listed in `canonical_contr - `make_revolve_rsolid` - `make_loft_rsolid` - `make_sweep_rsolid` +- `make_twisted_sweep_rsolid` - `make_translate_rshape` - `make_rotate_rshape` - `make_mirror_rshape` @@ -1296,6 +1297,23 @@ Important: | --- | --- | --- | | `is_frenet` | `bool` | sweep orientation mode | +#### `make_twisted_sweep_rsolid` + +- Inputs: 1 profile `Face` +- Outputs: 1 `Solid` +- Params: + +| Key | Type | Meaning | +| --- | --- | --- | +| `axis` | 3-number array | sweep and rotation-axis direction in caller coordinates | +| `origin` | 3-number array | sweep start and a point on the rotation axis | +| `distance` | positive number | axial sweep distance | +| `twist_angle` | number | signed total rotation in degrees | +| `guide_radius` | positive number | internal auxiliary-spine radius | + +Replay rebuilds the auxiliary spine deterministically inside the operation. It +does not infer section counts or lower to transient loft nodes. + ### 14.6 Boolean Ops #### `make_union_rsolid` diff --git a/SimpleCADAPI/skills/simplecadapi/references/docs/core/physical-units.md b/SimpleCADAPI/skills/simplecadapi/references/docs/core/physical-units.md new file mode 100644 index 0000000..d699b75 --- /dev/null +++ b/SimpleCADAPI/skills/simplecadapi/references/docs/core/physical-units.md @@ -0,0 +1,45 @@ +# Physical Units And Dimension Inference + +Declare physical meaning on variables: + +```python +width = scad.var( + "width", + 1.0, + unit="in", + tolerance=0.1, + tolerance_unit="mm", +) +height = scad.var("height", 40.0, unit="mm", tolerance=0.2) +diagonal = scad.sqrt(width**2 + height**2) +``` + +## Canonical Rules + +- Length, area, volume, and angle evaluate in `mm`, `mm^2`, `mm^3`, and `deg`. +- Declaration values remain available on `Var.default` and `Var.tolerance`. +- `tolerance_unit` defaults to `unit` and must have the same dimension. +- Bindings use the variable's declaration unit. +- Numeric constants are dimensionless coefficients; in addition/subtraction they + are contextual offsets in the canonical result unit. + +## Inference Rules + +- Addition and subtraction require matching dimensions. +- Multiplication/division add/subtract dimension exponents. +- Constant integer powers multiply exponents. +- Square root requires every exponent to be even. +- `sin`, `cos`, and `tan` require Angle and return Dimensionless. +- `asin`, `acos`, and `atan` require Dimensionless and return Angle. +- `atan2` requires matching input dimensions and returns Angle. +- Unit-declared variables cannot mix with legacy variables without units. + +Built-in symbols are `1`, `%`, `mm`, `cm`, `m`, `in`, `ft`, their square/cubic +forms, `deg`, and `rad`. Use `get_unit()`, `convert_value()`, and +`infer_dimension()` for explicit inspection. + +Area and volume can be inferred and analyzed. Persisted manufacturing requirements +currently require a final Length or Angle result. + +Read the source-checkout `docs/core/physical-units.md` for custom-unit JSON, +complete operation tables, failure conditions, and legacy behavior. diff --git a/SimpleCADAPI/skills/simplecadapi/references/docs/core/serialization/README.md b/SimpleCADAPI/skills/simplecadapi/references/docs/core/serialization/README.md index 4bf2c39..084cb8b 100644 --- a/SimpleCADAPI/skills/simplecadapi/references/docs/core/serialization/README.md +++ b/SimpleCADAPI/skills/simplecadapi/references/docs/core/serialization/README.md @@ -10,14 +10,19 @@ The long-form schema reference remains [`../operation_graph_json_spec.md`](../op import json import simplecadapi as scad -with scad.GraphSession() as session: - body = scad.make_box_rsolid(10, 6, 2) - hole = scad.make_cylinder_rsolid(1, 4, bottom_face_center=(0, 0, -1)) +@scad.model(graph_id="drilled_block") +def build_model(): + body = scad.make_box_rsolid(width=10, height=6, depth=2) + hole = scad.make_cylinder_rsolid( + radius=1, height=4, bottom_face_center=(0, 0, -1) + ) result = scad.cut_rsolid(body, hole) + scad.capture_result(value=result) + return result -model_json = scad.export_model_json(session) -payload = json.loads(model_json) -rebuilt = scad.replay_model_json(model_json) +model = build_model() +payload = json.loads(model.model_json) +rebuilt = model.replay() ``` Inspect these fields: @@ -30,6 +35,16 @@ Inspect these fields: - `payload["leaf_ids"]`: explicit final result node ids. - `payload["expression_graph"]`: expression DAG used by expression-backed parameters. +For new top-level models, `ModelResult.model_json` is the preferred artifact +accessor. Use `@scad.requires_session` for reusable builders and +`scad.capture_result(...)` to keep intermediate graph leaves out of the model +outputs. If a model invocation also needs durable CAD/viewer files, pass +`export_dir=...` to `@scad.model`; its captured geometry/product values then +produce one self-contained `.scene.zip`. It embeds model JSON, mapped +project-relative Python sources, and evaluated render/selection assets; it does +not create adjacent model/session JSON, STEP, STL, or FCStd files. No files are +written when `export_dir` is omitted. + ## Important rule: source API is not always graph API Many user-facing functions are convenience APIs. During an active `GraphSession`, they lower to canonical low-level nodes: @@ -53,10 +68,10 @@ Many user-facing functions are convenience APIs. During an active `GraphSession` - [Features, booleans, transforms, patterns, and selectors](features-booleans-transforms.md) - [Expressions and replay behavior](expressions-and-replay.md) -## Example +## Examples -See [`../../../examples/07_serialization_operation_tree.py`](../../../examples/07_serialization_operation_tree.py). It intentionally exercises every canonical core operation and writes: - -- `examples/out/serialization_operation_tree.model.json` -- `examples/out/serialization_operation_tree.summary.md` -- `examples/out/serialization_operation_tree.step` +The retained examples use the same model/session contract. See +[`../../../examples/08_constrained_sketch.py`](../../../examples/08_constrained_sketch.py) +for sketch promotion and replay, and +[`../../../examples/10_part_assembly.py`](../../../examples/10_part_assembly.py) +for product hierarchy and automatic artifact export. diff --git a/SimpleCADAPI/skills/simplecadapi/references/docs/core/serialization/features-booleans-transforms.md b/SimpleCADAPI/skills/simplecadapi/references/docs/core/serialization/features-booleans-transforms.md index 70ed9c4..61273c0 100644 --- a/SimpleCADAPI/skills/simplecadapi/references/docs/core/serialization/features-booleans-transforms.md +++ b/SimpleCADAPI/skills/simplecadapi/references/docs/core/serialization/features-booleans-transforms.md @@ -122,6 +122,40 @@ Replay effect: 2. Replay path wire from input 1. 3. Call `sweep_rsolid(profile, path, is_frenet=...)`. +## Twisted Sweep + +Source: + +```python +profile = scad.make_rectangle_rface(width=2.0, height=1.0) +solid = scad.twisted_sweep_rsolid( + profile=profile, + distance=8.0, + twist_angle=30.0, +) +``` + +Serialized node: + +```json +{ + "op": "make_twisted_sweep_rsolid", + "params": { + "axis": [0.0, 0.0, 1.0], + "origin": [0.0, 0.0, 0.0], + "distance": 8.0, + "twist_angle": 30.0, + "guide_radius": 1.0 + }, + "inputs": ["node_for_profile_face"], + "output_count": 1 +} +``` + +Replay reconstructs the continuous auxiliary-spine rotation law from the +recorded parameters and invokes `twisted_sweep_rsolid(...)`. No sampled loft +sections are stored or inferred. + ## Helical sweep macro lowering Source: diff --git a/SimpleCADAPI/skills/simplecadapi/references/docs/core/tagged_mixin.md b/SimpleCADAPI/skills/simplecadapi/references/docs/core/tagged_mixin.md index f669462..0ba9a04 100644 --- a/SimpleCADAPI/skills/simplecadapi/references/docs/core/tagged_mixin.md +++ b/SimpleCADAPI/skills/simplecadapi/references/docs/core/tagged_mixin.md @@ -2,21 +2,24 @@ ## Overview -`TaggedMixin` is the internal tag and metadata storage mixin used by `Vertex`, `Edge`, `Wire`, `Face`, and `Solid`. It owns the shared `_tags`, `_metadata`, and `_runtime` stores for topology wrappers. +`TaggedMixin` is the internal semantic binding and metadata mixin used by topology wrappers. Canonical tag ownership is source-preserving `TagBinding` data. `_tags` is only an effective-scope compatibility cache; user code must not treat it as writable truth. User code should not call member tag mutators. The public tag API is functional: - `apply_tag(shape, tag)` attaches one normalized tag. -- `list_tags(shape)` returns tags in deterministic sorted order. +- `apply_tag_rselection(scope, targets, tag, ...)` returns an independent semantic view and exposes explicit propagation policies. +- `list_tags(shape, scope=...)` returns tags in deterministic sorted order. +- `explain_tag(shape, tag, scope=...)` preserves binding and producer evidence. - `select_faces_by_tag(...)`, `select_edges_by_tag(...)`, and QL predicates such as `ql.tag("role.*")` provide selection/query helpers. ## Tagging Mental Model - Tags are normalized lowercase dot-separated semantic tokens. - Examples: `role.mounting_surface`, `anchor.datum.primary`, `group.fasteners`, `face.top`, `edge.boundary`, `wire.outer`, `solid.boolean.cut`. -- `apply_tag(shape, tag)` does not expose propagation controls. -- The standard policy propagates `role.*`, `anchor.*`, `group.*`, and a few legacy bare semantic tags downward. -- Topology-specific tags such as `face.*`, `edge.*`, `wire.*`, `vertex.*`, and `solid.*` stay local. +- New user assignments default to local topology propagation regardless of prefix. +- Downward inheritance is explicit and computed dynamically; bindings are not copied into every child. +- `effective` means local plus inherited and does not include lineage. +- `lineage` requires complete topology history and only follows derivations allowed by the binding policy. - Numeric dimensions, measurements, and rich descriptive payloads belong in metadata, not tags. - Geometry builders store structured geometry facts under `metadata["geo"]`. @@ -34,16 +37,29 @@ top_faces = [face for face in box.get_faces() if "face.top" in scad.list_tags(fa print(len(top_faces)) ``` -## Propagation Example +## Explicit Propagation Example ```python import simplecadapi as scad -body = scad.make_box_rsolid(10, 10, 2) -scad.apply_tag(body, "role.mounting_plate") +body = scad.make_box_rsolid(width=10, height=10, depth=2) +tagged = scad.apply_tag_rselection( + scope=body, + targets=[body], + tag="role.mounting_plate", + topology_propagation=scad.TopologyPropagation.DOWNWARD, +) -face_hits = [face for face in body.get_faces() if "role.mounting_plate" in scad.list_tags(face)] -edge_hits = [edge for edge in body.get_edges() if "role.mounting_plate" in scad.list_tags(edge)] +face_hits = scad.select_faces_by_tag( + solid=tagged, + tag="role.mounting_plate", + scope=scad.TagScope.INHERITED, +) +edge_hits = scad.select_edges_by_tag( + shape=tagged, + tag="role.mounting_plate", + scope=scad.TagScope.INHERITED, +) print(len(face_hits), len(edge_hits)) ``` @@ -55,7 +71,13 @@ Primitives and modeling operations may attach normalized tags automatically: - Primitive tags such as `geom.primitive.box`, `geom.primitive.cylinder`, and `geom.primitive.sphere`. - Face tags from `auto_tag_faces(...)`, such as `face.top`, `face.bottom`, `face.side`, and `face.surface`. - Wire tags such as `wire.outer` and `wire.inner`. -- Operation/tracking tags such as `solid.boolean.cut`, `op.cut.modified`, or `op.extrude.generated`. +- Operation-level categorical tags such as `solid.boolean.cut` may remain local annotations. + +Operation events and source roles are not tags. Proven `preserved`, `modified`, +or `generated` events and `body`/`tool` origins live in typed +`metadata["track"]`. Query them with `ql.operation_event(...)` and +`ql.origin_role(...)`. Missing correspondence remains `coverage="partial"` and +`status="unknown"`; it is never promoted to `generated` by default. ## Metadata Methods @@ -84,7 +106,7 @@ scad.apply_tag(body, "role.mounting_plate") body.auto_tag_faces("box") top_faces = Q.select(body.get_faces()).where(Q.tag("face.top")).all() -role_faces = Q.select(body.get_faces()).where(Q.tag("role.*")).all() +role_faces = Q.select(body.get_faces()).where(Q.tag("role.*", scope="effective")).all() print(len(top_faces), len(role_faces)) ``` diff --git a/SimpleCADAPI/skills/simplecadapi/references/docs/stdlib/README.md b/SimpleCADAPI/skills/simplecadapi/references/docs/stdlib/README.md index 6fe849f..bf605d2 100644 --- a/SimpleCADAPI/skills/simplecadapi/references/docs/stdlib/README.md +++ b/SimpleCADAPI/skills/simplecadapi/references/docs/stdlib/README.md @@ -4,12 +4,12 @@ This index includes generated docs for standard part factory functions. Use thes ## Import Surfaces -- Recommended package-level module export: `import simplecadapi as scad`, then call functions through submodules such as `scad.std.gear.(...)` and `scad.std.bearing.(...)`. +- Recommended package-level module export: `import simplecadapi as scad`, then call functions through submodules such as `scad.std.gear.(...)`, `scad.std.fastener.(...)`, and `scad.std.bearing.(...)`. - Direct submodule import is also supported, for example `from simplecadapi.std.gear import make_spur_gear_rsolid` or `from simplecadapi.std.bearing import make_ball_bearing_rassembly`. ## Usage Guidance -- Prefer standard-library factories for standard bearings, gears, ring gears, and racks before hand-modeling profiles with core geometry APIs. +- Prefer standard-library factories for standard bearings, fasteners, spur gears, straight bevel gears, ring gears, and racks before hand-modeling profiles with core geometry APIs. - Standard parts return normal SimpleCAD shapes or product assemblies, so they can be transformed, tagged, assembled, exported, or combined with core geometry operations. - Switch to core geometry APIs only when the requested standard part needs substantial custom geometry beyond the factory parameters. @@ -23,6 +23,19 @@ This index includes generated docs for standard part factory functions. Use thes - [make_herringbone_gear_rsolid](make_herringbone_gear_rsolid.md) *(from std/gear.py)* `stdlib` - [make_spur_gear_rsolid](make_spur_gear_rsolid.md) *(from std/gear.py)* `stdlib` +## Bevel Gears + +- [make_straight_bevel_gear_rsolid](make_straight_bevel_gear_rsolid.md) *(from std/gear.py)* `stdlib` + +## Roller Chain + +- [make_roller_chain_sprocket_rsolid](make_roller_chain_sprocket_rsolid.md) *(from std/chain.py)* `stdlib` + +## Fasteners + +- [make_bolt_rsolid](make_bolt_rsolid.md) *(from std/fastener.py)* `stdlib` +- [make_nut_rsolid](make_nut_rsolid.md) *(from std/fastener.py)* `stdlib` + ## Internal Ring Gears - [make_helical_ring_gear_rsolid](make_helical_ring_gear_rsolid.md) *(from std/gear.py)* `stdlib` diff --git a/SimpleCADAPI/skills/simplecadapi/references/docs/stdlib/make_bolt_rsolid.md b/SimpleCADAPI/skills/simplecadapi/references/docs/stdlib/make_bolt_rsolid.md new file mode 100644 index 0000000..268a82d --- /dev/null +++ b/SimpleCADAPI/skills/simplecadapi/references/docs/stdlib/make_bolt_rsolid.md @@ -0,0 +1,41 @@ +# make_bolt_rsolid + +## API Definition + +```python +def make_bolt_rsolid( + diameter: float, + length: float, + head_style: str = "hex", + thread_style: str = "auto", + thread_detail: str = "modeled", + thread_form: str = "v", + thread_pitch: Optional[float] = None, + thread_depth: Optional[float] = None, + thread_length: Optional[float] = None, + head_width: Optional[float] = None, + head_height: Optional[float] = None, + drive_style: str = "none", + drive_size: Optional[float] = None, + drive_depth: Optional[float] = None, + underhead_fillet_radius: Optional[float] = None, +) -> Solid +``` + +*Source: std/fastener.py* + +## Import Surface + +- standard library: `import simplecadapi as scad` then `scad.std.fastener.make_bolt_rsolid(...)`; direct submodule import: `from simplecadapi.std.fastener import make_bolt_rsolid` + +## Description + +Create a parameterized bolt along `+Z`, with the head underside on `Z=0`. Head styles are `hex`, `square`, `cylindrical`, `button`, and `countersunk`. Drive styles are `none`, `slot`, `cross`, and `hex_socket`. + +Thread styles are `auto`, `full`, `partial`, and `none`. `auto` selects full thread for `length <= 3 * diameter`; otherwise it uses the ISO-style piecewise partial-thread length. The default `thread_detail="modeled"` creates a visible replayable helical `v` or `trapezoidal` thread. Set `thread_detail="cosmetic"` explicitly for a smooth shank with thread intent only in metadata. For partial threads, `thread_length` is measured back from the tip at `Z=length`. + +For metric coarse-series diameters, the factory derives the default pitch from the standard series and records `d2 = d - 0.6495P` and `d1 = d - 1.0825P` in metadata. Hex-head defaults use catalog-like `S` and `k` values where available. The default underhead fillet is `0.06d`; `underhead_fillet_radius` can override it. The metadata also reports a minimum recommended mating-hole chamfer equal to the fillet radius. + +Modeled threads may rotate the helical seam to an equivalent kernel-stable phase. The selected phase is recorded as `thread_phase_degrees` in `std.fastener.bolt` metadata and does not change thread dimensions or handedness. + +Default dimensions are useful parametric proportions, not a claim of compliance with a specific ISO, DIN, ASME, or supplier catalog. Set catalog dimensions explicitly for released hardware. diff --git a/SimpleCADAPI/skills/simplecadapi/references/docs/stdlib/make_helical_gear_rsolid.md b/SimpleCADAPI/skills/simplecadapi/references/docs/stdlib/make_helical_gear_rsolid.md index 1950a88..7e56984 100644 --- a/SimpleCADAPI/skills/simplecadapi/references/docs/stdlib/make_helical_gear_rsolid.md +++ b/SimpleCADAPI/skills/simplecadapi/references/docs/stdlib/make_helical_gear_rsolid.md @@ -16,10 +16,9 @@ def make_helical_gear_rsolid(n_teeth: int, module: float, pressure_angle: float Create an involute helical gear. -Non-zero helix angles are modeled as small-step ruled lofts through rotated -copies of one profile. The small angular step keeps closed-wire section -correspondence stable while ruled faces avoid smooth loft bulging in STEP -exports. +Non-zero helix angles use a continuous twisted sweep along the gear axis. An +auxiliary-spine rotation law preserves the profile while generating one +continuous side face per profile edge instead of one face per loft interval. ## Parameters diff --git a/SimpleCADAPI/skills/simplecadapi/references/docs/stdlib/make_herringbone_gear_rsolid.md b/SimpleCADAPI/skills/simplecadapi/references/docs/stdlib/make_herringbone_gear_rsolid.md index a4ef950..9db0bac 100644 --- a/SimpleCADAPI/skills/simplecadapi/references/docs/stdlib/make_herringbone_gear_rsolid.md +++ b/SimpleCADAPI/skills/simplecadapi/references/docs/stdlib/make_herringbone_gear_rsolid.md @@ -16,10 +16,8 @@ def make_herringbone_gear_rsolid(n_teeth: int, module: float, pressure_angle: fl Create an involute herringbone (double-helical) gear. -Each half is modeled as a small-step ruled loft through rotated copies of -one profile, with a shared center section forming the herringbone ridge. -This keeps closed-wire section correspondence stable while avoiding smooth -loft bulging in STEP exports. +Each half uses a continuous twisted sweep with opposite handedness. The two +halves share the rotated center profile and are fused into one solid. ## Parameters diff --git a/SimpleCADAPI/skills/simplecadapi/references/docs/stdlib/make_nut_rsolid.md b/SimpleCADAPI/skills/simplecadapi/references/docs/stdlib/make_nut_rsolid.md new file mode 100644 index 0000000..53d7401 --- /dev/null +++ b/SimpleCADAPI/skills/simplecadapi/references/docs/stdlib/make_nut_rsolid.md @@ -0,0 +1,35 @@ +# make_nut_rsolid + +## API Definition + +```python +def make_nut_rsolid( + diameter: float, + width: float, + height: float, + nut_style: str = "hex", + hole_style: str = "through", + thread_detail: str = "modeled", + thread_form: str = "v", + thread_pitch: Optional[float] = None, + thread_depth: Optional[float] = None, + hole_depth: Optional[float] = None, + knurl_count: int = 24, +) -> Solid +``` + +*Source: std/fastener.py* + +## Import Surface + +- standard library: `import simplecadapi as scad` then `scad.std.fastener.make_nut_rsolid(...)`; direct submodule import: `from simplecadapi.std.fastener import make_nut_rsolid` + +## Description + +Create a parameterized nut along `+Z`, with its bottom face on `Z=0`. Nut styles are `hex`, `square`, `round`, and `knurled`. Hole styles are `through` and `blind`; a blind hole opens from the top face and uses `hole_depth` as its axial depth. + +The default `thread_detail="modeled"` creates replayable internal `v` or `trapezoidal` helical teeth. Set `thread_detail="cosmetic"` explicitly for a smooth major-diameter hole with thread intent only in metadata. For metric coarse-series diameters, the factory derives the default pitch and records `d2 = d - 0.6495P` and `d1 = d - 1.0825P` in metadata. `knurl_count` controls the lobe count of the printable knurled approximation. + +Modeled threads may rotate the helical seam to an equivalent kernel-stable phase. The selected phase is recorded as `thread_phase_degrees` in `std.fastener.nut` metadata and does not change thread dimensions or handedness. + +Default thread proportions are useful for parametric models, not a substitute for catalog tolerances, thread class, lead-in, prevailing-torque features, or manufacturing checks. diff --git a/SimpleCADAPI/skills/simplecadapi/references/docs/stdlib/make_roller_chain_sprocket_rsolid.md b/SimpleCADAPI/skills/simplecadapi/references/docs/stdlib/make_roller_chain_sprocket_rsolid.md new file mode 100644 index 0000000..7f08e4c --- /dev/null +++ b/SimpleCADAPI/skills/simplecadapi/references/docs/stdlib/make_roller_chain_sprocket_rsolid.md @@ -0,0 +1,19 @@ +# make_roller_chain_sprocket_rsolid + +## API Definition + +```python +def make_roller_chain_sprocket_rsolid(n_teeth: int, chain_pitch: float, roller_diameter: float, sprocket_thickness: float, *, bore_radius: float = 0.0, roller_clearance: float = 0.15) -> Solid +``` + +*Source: std/chain.py* + +## Import Surface + +- standard library: `import simplecadapi as scad` then `scad.std.chain.make_roller_chain_sprocket_rsolid(...)`; direct submodule import: `from simplecadapi.std.chain import make_roller_chain_sprocket_rsolid` + +## Description + +Create a roller-chain sprocket from tooth count, chain pitch, roller diameter, and tooth-plate thickness. The pitch radius follows the regular pitch polygon. Circular roller seats are cut at every pitch point and opened through the engineering outside-diameter envelope. + +The result preserves assembly-level engagement dimensions. Manufacturing release still requires the selected chain standard's permitted tooth-form range, tooth width, hub, material, heat treatment, runout, and supplier checks. diff --git a/SimpleCADAPI/skills/simplecadapi/references/docs/stdlib/make_straight_bevel_gear_rsolid.md b/SimpleCADAPI/skills/simplecadapi/references/docs/stdlib/make_straight_bevel_gear_rsolid.md new file mode 100644 index 0000000..923c761 --- /dev/null +++ b/SimpleCADAPI/skills/simplecadapi/references/docs/stdlib/make_straight_bevel_gear_rsolid.md @@ -0,0 +1,30 @@ +# make_straight_bevel_gear_rsolid + +## API Definition + +```python +def make_straight_bevel_gear_rsolid(n_teeth: int, module: float, pitch_angle: float = 45.0, pressure_angle: float = 20.0, face_width: float = 8.0, *, addendum_factor: float = 1.0, clearance_factor: float = 0.25, backlash: float = 0.0) -> Solid +``` + +*Source: std/gear.py* + +## Import Surface + +- standard library: `import simplecadapi as scad` then `scad.std.gear.make_straight_bevel_gear_rsolid(...)`; direct submodule import: `from simplecadapi.std.gear import make_straight_bevel_gear_rsolid` + +## Description + +Create a straight bevel gear with standard metric tooth proportions. The large-end transverse section uses an analytic involute profile. A similar small-end section is placed on the pitch cone and connected with ruled straight tooth surfaces. + +The returned solid contains nominal tooth geometry. Releasing a mating pair still requires mounting-distance, contact-pattern, backlash, material, heat-treatment, and strength checks. + +## Parameters + +- `n_teeth`: Number of teeth, at least 3. +- `module`: Large-end transverse module in millimetres. +- `pitch_angle`: Pitch-cone angle in degrees, greater than 0 and less than 90. +- `pressure_angle`: Transverse pressure angle in degrees. +- `face_width`: Tooth face width along the pitch-cone generator in millimetres; must be smaller than the outer pitch-cone distance. +- `addendum_factor`: Large-end tooth addendum divided by module. +- `clearance_factor`: Root clearance beyond the addendum divided by module. +- `backlash`: Large-end circumferential tooth-thickness reduction at the pitch circle in millimetres. diff --git a/SimpleCADAPI/skills/simplecadapi/references/inverse_engineer/brep-reverse-engineering.md b/SimpleCADAPI/skills/simplecadapi/references/inverse_engineer/brep-reverse-engineering.md new file mode 100644 index 0000000..5f48cb4 --- /dev/null +++ b/SimpleCADAPI/skills/simplecadapi/references/inverse_engineer/brep-reverse-engineering.md @@ -0,0 +1,761 @@ +# STEP BREP 逆向工程方法 + +本文记录一种面向精确 BREP 的 STEP 逆向工作流。目标不是做出“看起来相似”的模型,而是恢复一个满足以下硬约束的可编辑参数模型: + +1. 几何点集一致; +2. BREP 拓扑一致; +3. 解析曲面的参数一致; +4. 边界曲线和关键参数范围一致; +5. 模型能够稳定重建和回放。 + +当多种特征树都满足这些硬约束时,再用工程师的建模习惯筛选:优先对称、整数或标准尺寸、少量草图、稳定基准、清晰特征依赖和容易修改的参数。 + +## 1. 先区分五种“一致” + +STEP 逆向中最容易犯的错误,是把不同层次的一致混为一谈。 + +### 1.1 外观一致 + +截图、网格或渲染结果相似。它只能用于快速发现大方向错误,不能用于验收。 + +截图看不出: + +- 两个相邻面是否已合并; +- 一个圆柱面是整圆还是两个半圆; +- 隐藏孔、沉孔深度和内部台阶; +- 半径的微小差异; +- BREP 邻接关系; +- 曲面的参数原点和方向。 + +### 1.2 数值摘要一致 + +包围盒、体积、表面积、质心一致。这比外观更强,但仍不充分。不同实体可能碰巧拥有相同的体积和包围盒。 + +用途: + +- 快速检查单位和坐标系; +- 定位哪个特征导致体积误差; +- 验证孔、槽、台阶的总体材料增减; +- 在迭代过程中排除明显错误。 + +### 1.3 几何点集一致 + +两个实体占据完全相同的空间。对实体可用双向布尔差集验证: + +```text +A - B 的体积 = 0 +B - A 的体积 = 0 +``` + +这是几何一致的硬检查,但它不保证拓扑一致。例如一个完整圆柱面和两个半圆柱面可以表示相同点集。 + +### 1.4 BREP 拓扑一致 + +面、边、顶点数量相同,并且面—边—顶点邻接图同构。 + +仅比较数量还不够。两个模型都可能有 20 个面,但连接方式不同。可靠做法是给图节点添加几何标签,再检查图同构: + +- 面标签:类型、面积、几何中心、半径、轴线、球心等; +- 边标签:类型、长度、几何中心、半径、轴线等; +- 顶点标签:三维坐标; +- 图边:Face—Edge 和 Edge—Vertex 关联。 + +### 1.5 参数化表示一致 + +同一个几何载体仍可能具有不同参数表示: + +- 圆柱轴方向相反; +- 平面原点不同; +- 圆弧起始角不同; +- UV 范围平移或反向; +- 一条整圆边与两条半圆边; +- 相同孔通过不同特征顺序产生不同的曲面参数原点。 + +有些参数差异只是非唯一表示,不应误判为几何差异;有些则是建模历史的有效证据。必须结合设计语义判断。 + +## 2. 检查手段速查表 + +| 检查手段 | 最适合回答的问题 | 能提供的直觉 | 不能单独证明什么 | +|---|---|---|---| +| STEP 实体类型计数 | 文件主要由什么几何组成 | 是机械解析体、自由曲面还是混合模型 | 导入后的唯一拓扑数量 | +| BREP 有效性 | 文件能否作为可靠实体继续运算 | 是否存在开壳、坏边、容差问题 | 几何是否符合设计意图 | +| 唯一 Face/Edge/Vertex 数量 | 拓扑复杂度和分段程度 | 圆是否被拆弧、面是否被切分 | 邻接关系是否一致 | +| 拓扑出现次数 | 面如何重复引用边和顶点 | 是否像闭合流形、是否有非流形连接 | 两模型是否同构 | +| 包围盒 | 单位、整体尺寸和主方向 | 哪个方向可能是厚度或长度 | 内部孔槽和局部形状 | +| 体积/面积/质心 | 材料增减是否正确 | 漏掉的是大特征还是表面细节 | 几何点集和拓扑一致 | +| 解析面类型统计 | 特征家族是否正确 | 孔、倒角、圆角、自由曲面来源 | 每个面的具体位置和连接 | +| 面参数与包围盒 | 单个特征的尺寸和位置 | 半径、轴线、台阶、角度、深度 | 建模历史唯一解 | +| 边类型与端点 | 草图分段和切点 | 整圆/半圆、45 度线、圆角切线 | 隐藏面的完整形状 | +| 面-边-顶点邻接图 | BREP 连接结构是否一致 | 哪个局部特征造成分段差异 | 几何点集必然一致 | +| 双向布尔差集 | 两实体是否占据相同空间 | 几何已经完成,可转向拓扑检查 | 面分段和参数化一致 | +| 多视图截图 | 大方向和明显特征是否正确 | 最快发现轴向、孔位、台阶错误 | 精确尺寸、内部结构和拓扑 | +| 物理截面 | 隐藏结构和不同深度轮廓 | 沉孔、槽、壁厚、局部层级 | 未采样位置必然一致 | +| 截面 XOR | 候选差异出现在哪个区域 | 直接定位下一步应修改的特征 | 连续空间上的严格零差异 | +| UV/曲线参数范围 | 参数历史和特征顺序 | 工具从哪个基准面开始、何时被后续特征裁剪 | 非唯一坐标架必然相同 | +| 模型图回放 | 建模过程能否稳定复现 | 是否依赖交互状态或临时对象 | 回放结果与目标自动一致 | + +推荐检查顺序是从便宜到昂贵: + +```text +有效性和摘要 +-> 解析几何清点 +-> 多视图和少量关键截面 +-> 面边参数与邻接 +-> 候选构建 +-> 双向差集 +-> 图同构 +-> 参数历史 +-> 回放 +``` + +不要一开始就对所有点做高密度分类,也不要一开始就逐个比较 STEP 文本实体。前者成本高且信息增量有限,后者会被导出器编号和非唯一参数化干扰。 + +## 3. 推荐的分阶段工作流 + +### 3.1 阶段 A:建立不可变基准 + +在写建模代码前,先从原始 STEP 导出一份机器可读报告。可以先用文本清点快速了解文件组成: + +```bash +rg -o '^[[:space:]]*#[0-9]+[[:space:]]*=[[:space:]]*[A-Z0-9_]+' model.step \ + | sed -E 's/.*=[[:space:]]*//' \ + | sort \ + | uniq -c \ + | sort -nr +``` + +这能快速发现 `ADVANCED_FACE`、`PLANE`、`CYLINDRICAL_SURFACE`、`B_SPLINE_SURFACE_WITH_KNOTS` 等实体,但它只是 STEP 表示层清点。`EDGE_CURVE` 数量不一定等于内核导入后的唯一 Edge 数量;退化边、缝边和表示方式都会造成差异。最终基准必须来自导入后的 BREP。 + +机器可读报告至少包括: + +- STEP 是否能读取; +- 转移根数量; +- BREP 是否有效; +- Solid、Shell、Face、Edge、Vertex 数量; +- 唯一拓扑数量和拓扑出现次数; +- 包围盒、体积、表面积、质心; +- 面类型统计; +- 边类型统计; +- 每个面的面积、UV 范围、解析曲面参数和邻接边; +- 每条边的类型、参数范围和邻接面。 + +唯一数量和出现次数必须分开: + +```text +unique_edges BREP 中不同的边对象数量 +edge_occurrences 遍历所有面时边被引用的总次数 +``` + +在闭合流形实体中,一条普通边通常被两个面引用,因此出现次数通常接近唯一边数的两倍。 + +正式实现位于: + +```text +src/simplecadapi/inverse_engineer/brep/inspect.py +``` + +Python API: + +```python +from simplecadapi.inverse_engineer import brep + +report = brep.inspect_step(path="model.step") +report.write_json("model-report.json") +``` + +CLI: + +```bash +uv run simplecad-brep inspect model.step -o model-report.json +``` + +### 基础属性代码片段 + +```python +from OCP.BRepCheck import BRepCheck_Analyzer +from OCP.BRepGProp import BRepGProp +from OCP.BRepBndLib import BRepBndLib +from OCP.Bnd import Bnd_Box +from OCP.GProp import GProp_GProps + +assert BRepCheck_Analyzer(shape).IsValid() + +bbox = Bnd_Box() +BRepBndLib.AddOptimal_s(shape, bbox) +print("bbox", bbox.Get()) + +volume_props = GProp_GProps() +BRepGProp.VolumeProperties_s(shape, volume_props) +print("volume", volume_props.Mass()) +print("center", volume_props.CentreOfMass().Coord()) + +area_props = GProp_GProps() +BRepGProp.SurfaceProperties_s(shape, area_props) +print("area", area_props.Mass()) +``` + +### 3.2 阶段 B:恢复坐标语义 + +不要根据 `X/Y/Z` 名称直接假设左右、前后和高度。 + +应综合使用: + +- 包围盒三个跨度; +- 主成分分析; +- 平面和圆柱轴线分布; +- 对称平面拟合; +- 正交视图和典型截面; +- 机械功能,例如孔轴通常对应装配或加工方向。 + +坐标语义判断错误会导致整个特征解释反转。比如一个方向上的 U 形截面可能是左右对称结构,也可能被错误解释为前后凹槽。 + +### 3.3 阶段 C:做解析几何清点 + +解析面类型是最有价值的“建模指纹”之一。 + +| 几何类型 | 常见来源 | 可恢复的信息 | +|---|---|---| +| Plane | 拉伸端面、切割平面、倒角 | 基准方向、台阶位置、厚度、角度 | +| Cylinder | 孔、轴、拉伸圆弧、直边圆角 | 半径、轴线、深度、圆弧分段 | +| Sphere | 三条圆角边交汇、球切割 | 圆角半径、特征交汇方式 | +| Torus | 圆弧轮廓的边圆角、环形过渡 | 主半径、次半径、圆角中心 | +| Cone | 锥孔、沉头、拔模 | 锥角、轴线、顶点 | +| BSpline | 放样、填充、自由曲面 | 次数、节点、控制点、连续性 | + +例如: + +```text +4 个 R1 Sphere +4 个 (R2, R1) Torus +14 个 R1 Cylinder +``` + +通常不是手工拼出的 22 个面,而是一次连续局部 R1 圆角的结果。解析面组合比截图更能揭示特征操作。 + +### 读取解析曲面参数 + +```python +from OCP.BRepAdaptor import BRepAdaptor_Surface + +surface = BRepAdaptor_Surface(face, True) +kind = surface.GetType() + +cylinder = surface.Cylinder() +print("radius", cylinder.Radius()) +print("axis", cylinder.Axis().Direction().Coord()) +print("origin", cylinder.Location().Coord()) + +sphere = surface.Sphere() +print("radius", sphere.Radius()) +print("center", sphere.Location().Coord()) + +torus = surface.Torus() +print("major", torus.MajorRadius()) +print("minor", torus.MinorRadius()) +``` + +### 3.4 阶段 D:从平面和边恢复尺寸关系 + +不要只读包围盒。每个解析面的平面方程、轴线和边界端点能够恢复: + +- 台阶高度; +- 壁厚; +- 孔深; +- 孔距; +- 对称位置; +- 45 度倒角; +- 圆角切点; +- 草图中的直线方程。 + +例如一组斜面端点满足: + +```text +x + |z| = 1.25 +``` + +这比“看起来大约 45 度”更强,说明草图很可能使用了对称 45 度约束。 + +### 读取边端点和类型 + +```python +from OCP.BRepAdaptor import BRepAdaptor_Curve + +curve = BRepAdaptor_Curve(edge) +first = curve.FirstParameter() +last = curve.LastParameter() +p0 = curve.Value(first) +p1 = curve.Value(last) + +print("type", curve.GetType()) +print("start", p0.Coord()) +print("end", p1.Coord()) + +if "Circle" in str(curve.GetType()): + circle = curve.Circle() + print("radius", circle.Radius()) + print("center", circle.Location().Coord()) +``` + +### 3.5 阶段 E:建立直观图像,但只让它承担合适的职责 + +推荐同时生成: + +1. 等轴视图; +2. 三个正交视图; +3. 关键高度或轴向位置的占用截面; +4. 目标/候选叠加图; +5. XOR 差异图。 + +多视图适合发现: + +- 坐标方向错误; +- 漏掉大特征; +- 孔位错误; +- 台阶方向错误; +- 圆角范围过大; +- 草图轮廓错误。 + +截面图适合发现: + +- 内孔层级; +- 沉孔深度; +- 不同高度处的轮廓变化; +- 隐藏槽; +- 两实体的局部差异。 + +正式实现: + +```text +src/simplecadapi/inverse_engineer/brep/render.py +src/simplecadapi/inverse_engineer/brep/slices.py +``` + +渲染图片需要可选依赖: + +```bash +uv sync --extra inverse-engineer +``` + +Python API: + +```python +from simplecadapi.inverse_engineer import brep + +brep.render_step_views(step_path="candidate.step", output_path="candidate-views.png") +comparison = brep.compare_step_slices( + target_path="target.step", + candidate_path="candidate.step", + output_path="slice-overlay.png", +) +``` + +CLI: + +```bash +uv run simplecad-brep render candidate.step candidate-views.png +uv run simplecad-brep slices target.step candidate.step slice-overlay.png +# 默认自动检查联合包围盒的 X/Y/Z 中心截面;特征边界应显式追加: +uv run simplecad-brep slices target.step candidate.step feature-slices.png \ + --slice xz:-1.6 --slice xz:4.0 --slice xy:0 +``` + +截面分类示例: + +```python +from OCP.BRepClass3d import BRepClass3d_SolidClassifier +from OCP.TopAbs import TopAbs_IN, TopAbs_ON +from OCP.gp import gp_Pnt + +classifier = BRepClass3d_SolidClassifier() +classifier.Load(shape) +classifier.Perform(gp_Pnt(x, y, z), 1e-8) +inside = classifier.State() in (TopAbs_IN, TopAbs_ON) +``` + +默认截面只用于快速建立直觉。逆向时应进一步选择穿过特征边界、台阶高度、沉孔肩部、对称面和疑似差异区域的物理截面。 + +注意:逐点 BREP 分类较慢。截面图是诊断工具,应控制分辨率;几何硬验收应使用布尔差集,而不是靠提高图片分辨率。 + +### 3.6 阶段 F:提出最小特征假设 + +先写出一个人类可读的候选特征树,不要马上编码大量细节。 + +好的候选通常包含: + +- 一个主草图和一次拉伸; +- 对称约束或镜像; +- 少量孔、槽、台阶; +- 局部圆角和倒角; +- 清晰的加工顺序。 + +每个参数都应能由原始 BREP 的证据解释。例如: + +```text +R1 来自 14 个 R1 圆柱、4 个 R1 球和 4 个次半径 R1 的圆环 +R3.25 来自沉孔圆柱 +R1.7 来自贯穿孔圆柱 +X=3.25 来自台阶平面 +Z=±6 来自对称孔轴 +``` + +避免在没有证据时使用: + +- 随机小偏移; +- 大量补偿布尔体; +- 任意样条; +- 网格拟合; +- 按最终面逐面拼壳。 + +### 3.7 阶段 G:按特征逐步落地 + +每一步都打印少量有意义的基准: + +```python +print( + "ground opening fillet", + body.get_volume(), + len(body.get_faces()), + len(body.get_edges()), +) +``` + +推荐顺序: + +1. 主轮廓; +2. 主拉伸; +3. 大型切除或并集; +4. 台阶; +5. 孔; +6. 圆角和倒角,或根据几何指纹调整到正确历史位置; +7. 输出 STEP; +8. 生成报告和截图; +9. 对比; +10. 只修改造成当前差异的特征。 + +不要一次添加所有特征。否则拓扑不一致时无法知道是哪一步造成的。 + +## 4. 从差异反推建模历史 + +### 4.1 面数少但几何相同:检查草图分段 + +典型情况: + +```text +目标:每个孔壁由两个半圆柱面组成 +候选:每个孔壁是一个完整圆柱面 +``` + +二者几何点集相同,但拓扑不同。原因通常不是布尔算法随机拆面,而是草图边界不同: + +```text +完整圆边拉伸 -> 一个圆柱侧面 +两条半圆弧闭合拉伸 -> 两个半圆柱侧面 +``` + +因此,拓扑差异常常能反推草图实体数量。 + +### 4.2 面数多且出现额外平面:检查错误的布尔拆分 + +把一个圆柱工具拆成左右两个半圆实体分别切除,会得到两个半圆柱面,但也会引入径向分隔平面。 + +这是一条重要经验: + +> 想得到分段侧面,应在草图边上分段,而不是把实体工具分成多个带内部平面的实体。 + +### 4.3 几何和拓扑相同但 UV 不同:检查特征顺序 + +典型沉孔: + +```text +方案 A:先沉孔,再从肩部开始切小孔 +方案 B:先切贯穿小孔,再切大沉孔 +``` + +两者最终几何可能相同,但小孔圆柱支撑面的参数原点不同。 + +如果目标小孔的圆柱原点位于入口平面,UV 还保留了被沉孔切掉的参数区间,这通常说明: + +```text +先贯穿孔 +再沉孔 +``` + +特征顺序不仅影响工程可读性,也会影响 STEP 中保留的曲面参数。 + +### 4.4 球面和圆环面组合:检查三维圆角交汇 + +二维草图圆角后拉伸,常产生圆柱面;对三维实体的多条相交边做圆角,会进一步产生球面和圆环面。 + +因此: + +- 只有圆柱圆角面:可能是二维圆角轮廓拉伸; +- 同时出现球面和圆环面:更像三维成组选边圆角; +- 球面半径和圆环次半径相同:通常直接给出圆角半径。 + +## 5. 严格验收方法 + +### 5.1 双向布尔差集 + +```python +from OCP.BRepAlgoAPI import BRepAlgoAPI_Cut +from OCP.BRepGProp import BRepGProp +from OCP.GProp import GProp_GProps + + +def cut_volume(a, b): + operation = BRepAlgoAPI_Cut(a, b) + operation.SetFuzzyValue(1e-9) + operation.Build() + props = GProp_GProps() + BRepGProp.VolumeProperties_s(operation.Shape(), props) + return props.Mass() + +assert cut_volume(target, candidate) < 1e-9 +assert cut_volume(candidate, target) < 1e-9 +``` + +这证明实体点集相同,但仍需拓扑检查。 + +### 5.2 几何标记的邻接图同构 + +正式实现位于: + +```text +src/simplecadapi/inverse_engineer/brep/compare.py +``` + +Python API: + +```python +from simplecadapi.inverse_engineer import brep + +comparison = brep.compare_steps(target_path="target.step", candidate_path="candidate.step") +assert comparison.hard_gate_passed +``` + +CLI: + +```bash +uv run simplecad-brep compare target.step candidate.step -o comparison.json +``` + +报告应同时满足: + +```text +same_geometric_point_set = true +geometry_labelled_incidence_graph_isomorphic = true +``` + +### 规范化非唯一参数 + +比较几何载体时,不要直接比较平面原点或轴向符号。 + +平面可规范为: + +```text +单位法向 n +有符号距离 d = n dot p +``` + +轴线可规范为: + +```text +单位方向 d,统一符号 +原点到世界原点的最近点 p - d(d dot p) +``` + +这样可以识别“同一个平面但原点不同”和“同一根轴但方向相反”。 + +### 5.3 参数化表示检查 + +完成几何和拓扑硬检查后,再检查: + +- UV 范围; +- 圆柱、圆锥、圆环坐标架; +- 圆弧参数范围; +- B 样条次数、节点、重数、控制点和权重; +- 边方向和面方向; +- 曲面原点是否反映预期特征顺序。 + +不要把所有参数差异都当成错误。先问: + +1. 这是非唯一坐标架选择吗? +2. 它是否改变几何点集? +3. 它是否改变 BREP 邻接? +4. 它是否揭示不同的草图分段或特征顺序? +5. 用户要求的是精确 BREP,还是 STEP 文本字节一致? + +STEP 实体编号和文本顺序通常不属于 BREP 本体,不应作为几何逆向的默认验收项。 + +### 5.4 回放验收 + +逆向模型不能只在当前 Python 进程中成功。应记录模型图,重新播放,再对回放结果执行相同检查。 + +```python +import simplecadapi as scad + +with scad.GraphSession(graph_id="reverse_part") as session: + body = build_body() + scad.capture_result(value=body) + +payload = scad.export_model_json(session) +rebuilt = scad.replay_model_json(payload)[0] +scad.export_step(rebuilt, "replayed.step") +``` + +需要分别比较: + +```text +原始 vs 直接构建 +原始 vs 回放构建 +直接构建 vs 回放构建 +``` + +## 6. 高效迭代策略 + +### 6.1 用差异类型决定下一步 + +| 当前差异 | 优先检查 | +|---|---| +| 包围盒错误 | 坐标系、单位、基准位置 | +| 体积差很大 | 漏掉主切除/并集、特征方向错误 | +| 体积接近但面积差大 | 圆角、倒角、孔壁或隐藏台阶 | +| 几何相同但面数少 | 草图边分段不足、同域面被合并 | +| 几何相同但面数多 | 多余切割平面、重复布尔、错误拆工具 | +| Sphere/Torus 数量错误 | 圆角选边范围或圆角时序 | +| Cylinder 数量错误 | 圆/圆弧草图分段、孔特征顺序 | +| 拓扑计数相同但图不同 | 面邻接、边界方向、错误局部连接 | +| UV 原点或范围错误 | 特征创建顺序、工具起始平面 | + +### 6.2 先匹配低维输入,再匹配高维结果 + +工程特征一般遵循: + +```text +点/尺寸 -> 草图边 -> 闭合轮廓 -> 面 -> 拉伸/旋转 -> 布尔 -> 圆角 +``` + +当最终圆柱面分段不对时,优先检查草图圆弧数量,而不是在最终实体上补切割面。低维输入通常才是拓扑来源。 + +### 6.3 用解析面统计做中间目标 + +完整拓扑还没对齐时,解析面统计是非常有效的阶段目标。例如: + +```text +目标:Plane 16, Cylinder 22, Sphere 4, Torus 4 +``` + +候选若为: + +```text +Plane 16, Cylinder 18, Sphere 4, Torus 4 +``` + +说明主体、台阶和圆角网络很可能已正确,剩余问题集中在圆柱类特征,不应重做整个主体。 + +### 6.4 保存候选,但不要让试验污染最终入口 + +建议保留: + +- 每轮 STEP; +- 每轮报告; +- 每轮截图; +- 差异日志; +- 被否定的假设及原因。 + +最终应收敛为一个独立、可读、可回放的入口,不能依赖多个临时试验脚本互相导入。 + +## 7. 本次实践中走过的弯路 + +### 7.1 只根据截图猜主特征 + +问题:截图遮挡内部结构,容易把缺口、孔和圆角顺序解释错。 + +改进:截图只用于提出假设;立即用解析曲面、面边界和截面验证。 + +### 7.2 对所有边做圆角 + +问题:`fillet(all_edges)` 产生了过多球面,且破坏目标的平面和圆环统计。 + +改进:根据设计语义分组选边。先识别“左侧开口轮廓”这一语义组,再验证它生成的解析面组合。 + +### 7.3 用两个半圆实体切出分段孔 + +问题:虽然圆柱面数量增加到目标值,却额外产生了两个径向平面。 + +改进:用两条半圆弧组成一个闭合草图,再做一次拉伸切除。 + +### 7.4 把体积和面积一致当作完成 + +问题:完整圆柱孔和两个半圆柱孔具有相同几何、体积和面积,但拓扑不同。 + +改进:加入唯一拓扑数量、解析面统计和邻接图同构。 + +### 7.5 直接比较平面原点和轴向 + +问题:同一个平面可选择任意平面内原点,同一根轴可反向,导致大量假差异。 + +改进:比较规范化平面方程和规范化轴线,不比较任意坐标架选择。 + +### 7.6 截面采样过密 + +问题:逐点 BREP 分类成本高,六张高分辨率截面会超时。 + +改进:截面图保持适中分辨率用于直观诊断;精确几何一致由双向布尔差集负责。 + +### 7.7 先做沉孔再从肩部做小孔 + +问题:最终几何和拓扑正确,但小孔圆柱参数原点与目标不同。 + +改进:先从入口平面做贯穿孔,再做沉孔。这个顺序既符合常见加工意图,也复现目标 UV 历史。 + +## 8. 推荐的自动化产物 + +每个逆向任务建议固定生成: + +```text +reference_report.json +candidate_report.json +candidate.step +candidate_replayed.step +candidate.model.json +candidate_views.png +candidate_slice_overlay.png +candidate_brep_compare.json +``` + +最终验收至少包括: + +```text +BREP valid +双向差集为零 +唯一 Face/Edge/Vertex 数量一致 +解析面和边类型统计一致 +几何标记邻接图同构 +关键解析参数一致 +模型图可重放 +回放结果再次通过全部检查 +``` + +## 9. 核心方法总结 + +最有效的逆向顺序不是“看图后直接建模”,而是: + +```text +建立精确基准 +-> 恢复坐标语义 +-> 清点解析几何 +-> 从平面、轴线和边界恢复尺寸关系 +-> 用多视图与截面建立直觉 +-> 提出最小工程特征树 +-> 逐特征构建和输出 +-> 用差异类型指导下一轮 +-> 双向差集验证几何 +-> 几何标记图验证拓扑 +-> UV/曲线参数验证特征历史 +-> 模型图回放并重复全部验收 +``` + +最重要的判断原则是: + +> 几何和拓扑是硬约束;工程师先验只用于在满足硬约束的多种过程之间做选择。解析几何告诉我们“是什么”,拓扑分段告诉我们“草图如何画”,参数范围和坐标架经常告诉我们“特征按什么顺序做”。 diff --git a/SimpleCADAPI/src/simplecadapi/__init__.py b/SimpleCADAPI/src/simplecadapi/__init__.py index 0ce4879..338c3c0 100644 --- a/SimpleCADAPI/src/simplecadapi/__init__.py +++ b/SimpleCADAPI/src/simplecadapi/__init__.py @@ -116,8 +116,11 @@ from .operations import ( loft_rsolid, revolve_rsolid, sweep_rsolid, + twisted_sweep_rsolid, # 标签和选择 apply_tag, + apply_tag_rselection, + explain_tag, list_tags, select_edges_by_tag, select_faces_by_tag, @@ -148,7 +151,15 @@ from .evolve import ( make_threaded_rod_rsolid, ) -from .graph import GraphSession, suspend_graph_recording +from .graph import ( + GraphSession, + ModelResult, + capture_result, + get_active_session, + model, + requires_session, + suspend_graph_recording, +) from .serializer import export_graph_json, import_graph_json, replay_graph from .serializer import export_session_json, import_session_json from .serializer import export_model_json, import_model_json, replay_model_json @@ -156,6 +167,7 @@ from .expr import ( Expr, Var, Const, + DimensionTolerance, ExpressionGraph, acos, asin, @@ -168,6 +180,54 @@ from .expr import ( tan, var, ) +from .tolerance import ( + ToleranceAnalysis, + ToleranceAnalysisError, + ToleranceCheck, + ToleranceContribution, + ToleranceGraph, + ToleranceMethod, + ToleranceReport, + ToleranceRequirement, + ToleranceValidationError, + analyze_tolerance, + check_tolerance, +) +from .units import ( + ANGLE, + AREA, + CUBIC_CM, + CUBIC_FOOT, + CUBIC_INCH, + CUBIC_M, + CUBIC_MM, + DEGREE, + DIMENSIONLESS, + FOOT, + INCH, + LENGTH, + M, + MM, + CM, + ONE, + PERCENT, + RADIAN, + SQUARE_CM, + SQUARE_FOOT, + SQUARE_INCH, + SQUARE_M, + SQUARE_MM, + VOLUME, + Dimension, + Unit, + UnitLike, + UnitValidationError, + canonical_unit_for_dimension, + convert_value, + expression_uses_units, + get_unit, + infer_dimension, +) from .math import BSplineFitResult, fit_cubic_bspline_control_points from .product import ( Assembly, @@ -186,8 +246,48 @@ from .product import ( ScalarLimit, ) from .sketch import Sketch, SketchConstraint, SketchConstraintDiagnostic, SketchRef, SketchSolveResult +from .tagging import ( + LineageDerivation, + LineagePolicy, + TagAttachment, + TagBinding, + TagBindingScope, + TagCertainty, + TagEvidence, + TagEvidenceKind, + TagLifecycle, + TagLineageWitness, + TagProducer, + TagProducerKind, + TagPropagation, + TagScope, + TagTarget, + TagTargetKind, + TopologyPropagation, +) +from .tracking import TrackingPolicy from .topology import SemanticDelta, SemanticRef from .errors import SimpleCADError +from .scene import ( + CanonicalEdgeBlock, + CanonicalTriangleBlock, + CompiledScenePackage, + RenderEdgeMesh, + RenderGroup, + RenderMesh, + SceneCompileOptions, + SceneRoot, + SceneSource, + build_edge_mesh, + build_render_mesh, + cad_direction_to_gltf, + cad_to_gltf, + compile_scene, + export_scene, + solid_asset_bounds, + write_line_glb, + write_triangle_glb, +) from . import ql from . import math @@ -364,8 +464,11 @@ __all__ = [ "loft_rsolid", "revolve_rsolid", "sweep_rsolid", + "twisted_sweep_rsolid", # 标签和选择 "apply_tag", + "apply_tag_rselection", + "explain_tag", "list_tags", "select_edges_by_tag", "select_faces_by_tag", @@ -398,6 +501,11 @@ __all__ = [ "verifier", # Graph/session + serialization APIs "GraphSession", + "ModelResult", + "capture_result", + "get_active_session", + "model", + "requires_session", "suspend_graph_recording", "export_graph_json", "import_graph_json", @@ -410,12 +518,57 @@ __all__ = [ "Var", "Const", "ExpressionGraph", + "DimensionTolerance", + "Dimension", + "Unit", + "UnitLike", + "UnitValidationError", + "DIMENSIONLESS", + "LENGTH", + "AREA", + "VOLUME", + "ANGLE", + "ONE", + "PERCENT", + "MM", + "CM", + "M", + "INCH", + "FOOT", + "DEGREE", + "RADIAN", + "SQUARE_MM", + "SQUARE_CM", + "SQUARE_M", + "SQUARE_INCH", + "SQUARE_FOOT", + "CUBIC_MM", + "CUBIC_CM", + "CUBIC_M", + "CUBIC_INCH", + "CUBIC_FOOT", + "ToleranceAnalysis", + "ToleranceAnalysisError", + "ToleranceCheck", + "ToleranceContribution", + "ToleranceGraph", + "ToleranceMethod", + "ToleranceReport", + "ToleranceRequirement", + "ToleranceValidationError", "BSplineFitResult", "acos", "asin", "atan", "atan2", "const", + "analyze_tolerance", + "check_tolerance", + "canonical_unit_for_dimension", + "convert_value", + "expression_uses_units", + "get_unit", + "infer_dimension", "cos", "sin", "sqrt", @@ -427,9 +580,45 @@ __all__ = [ "SketchConstraintDiagnostic", "SketchRef", "SketchSolveResult", + "LineageDerivation", + "LineagePolicy", + "TagAttachment", + "TagBinding", + "TagBindingScope", + "TagCertainty", + "TagEvidence", + "TagEvidenceKind", + "TagLifecycle", + "TagLineageWitness", + "TagProducer", + "TagProducerKind", + "TagPropagation", + "TagScope", + "TagTarget", + "TagTargetKind", + "TopologyPropagation", + "TrackingPolicy", "SemanticRef", "SemanticDelta", "SimpleCADError", + "CompiledScenePackage", + "CanonicalEdgeBlock", + "CanonicalTriangleBlock", + "RenderEdgeMesh", + "RenderGroup", + "RenderMesh", + "SceneCompileOptions", + "SceneRoot", + "SceneSource", + "compile_scene", + "export_scene", + "build_edge_mesh", + "build_render_mesh", + "cad_direction_to_gltf", + "cad_to_gltf", + "solid_asset_bounds", + "write_line_glb", + "write_triangle_glb", "Assembly", "Component", "Connector", diff --git a/SimpleCADAPI/src/simplecadapi/_mesh.py b/SimpleCADAPI/src/simplecadapi/_mesh.py index 798c1d0..443aa9d 100644 --- a/SimpleCADAPI/src/simplecadapi/_mesh.py +++ b/SimpleCADAPI/src/simplecadapi/_mesh.py @@ -12,7 +12,7 @@ from typing import Any, Tuple import numpy as np -from .kernel.ocp_mesh import tessellate_face +from .kernel.ocp_mesh import tessellate_face_with_normals DEFAULT_LINEAR_TOLERANCE = 0.35 @@ -40,6 +40,7 @@ class TriMesh: face_triangle_ranges: Tuple[FaceTriangleRange, ...] linear_tolerance: float = DEFAULT_LINEAR_TOLERANCE angular_tolerance: float = DEFAULT_ANGULAR_TOLERANCE + corner_normals: np.ndarray | None = None def __post_init__(self) -> None: vertices = np.asarray(self.vertices, dtype=float) @@ -56,8 +57,19 @@ class TriMesh: ): raise ValueError("TriMesh triangle indices are out of bounds") + corner_normals = self.corner_normals + if corner_normals is None: + corner_normals = _triangle_corner_normals(vertices, triangles) + else: + corner_normals = np.asarray(corner_normals, dtype=float) + if corner_normals.shape != (len(triangles), 3, 3): + raise ValueError("TriMesh corner_normals must have shape (m, 3, 3)") + if not np.all(np.isfinite(corner_normals)): + raise ValueError("TriMesh corner_normals must be finite") + object.__setattr__(self, "vertices", vertices) object.__setattr__(self, "triangles", triangles) + object.__setattr__(self, "corner_normals", corner_normals) @property def vertex_count(self) -> int: @@ -87,11 +99,12 @@ def build_solid_trimesh( all_vertices: list[tuple[float, float, float]] = [] all_triangles: list[tuple[int, int, int]] = [] + all_corner_normals: list[tuple[tuple[float, float, float], ...]] = [] face_ranges: list[FaceTriangleRange] = [] for face_index, face in enumerate(solid.get_faces()): start = len(all_triangles) - vertices, triangles = tessellate_face( + vertices, triangles, corner_normals = tessellate_face_with_normals( face.wrapped, tolerance=linear_tolerance, angular_tolerance=angular_tolerance, @@ -106,6 +119,12 @@ def build_solid_trimesh( ) for a, b, c in triangles ) + face_triangles = np.asarray(triangles, dtype=np.int64).reshape((-1, 3)) + face_vertices = np.asarray(vertices, dtype=float).reshape((-1, 3)) + all_corner_normals.extend( + tuple(tuple(float(value) for value in normal) for normal in triangle) + for triangle in corner_normals + ) face_ranges.append( FaceTriangleRange( face_index=face_index, @@ -121,6 +140,7 @@ def build_solid_trimesh( face_triangle_ranges=tuple(face_ranges), linear_tolerance=float(linear_tolerance), angular_tolerance=float(angular_tolerance), + corner_normals=np.asarray(all_corner_normals, dtype=float).reshape((-1, 3, 3)), ) @@ -137,10 +157,27 @@ def attach_default_mesh(solid: Any) -> None: _set_runtime(solid, MESH_ERROR_RUNTIME_KEY, None) -def cached_mesh(solid: Any) -> TriMesh | None: +def cached_mesh( + solid: Any, + *, + linear_tolerance: float = DEFAULT_LINEAR_TOLERANCE, + angular_tolerance: float = DEFAULT_ANGULAR_TOLERANCE, +) -> TriMesh | None: """Return the internal mesh cache for tests and future checkers.""" - mesh = _get_runtime(solid, MESH_RUNTIME_KEY, None) + key = _mesh_runtime_key(linear_tolerance, angular_tolerance) + mesh = _get_runtime(solid, key, None) + if mesh is None: + try: + mesh = build_solid_trimesh( + solid, + linear_tolerance=linear_tolerance, + angular_tolerance=angular_tolerance, + ) + _set_runtime(solid, key, mesh) + except Exception as exc: + _set_runtime(solid, MESH_ERROR_RUNTIME_KEY, str(exc)) + return None return mesh if isinstance(mesh, TriMesh) else None @@ -162,3 +199,27 @@ def _get_runtime(shape: Any, key: str, default: Any) -> Any: if callable(getter): return getter(key, default) return default + + +def _mesh_runtime_key(linear_tolerance: float, angular_tolerance: float) -> str: + if ( + float(linear_tolerance) == DEFAULT_LINEAR_TOLERANCE + and float(angular_tolerance) == DEFAULT_ANGULAR_TOLERANCE + ): + return MESH_RUNTIME_KEY + return f"mesh:{float(linear_tolerance).hex()}:{float(angular_tolerance).hex()}" + + +def _triangle_corner_normals( + vertices: np.ndarray, triangles: np.ndarray +) -> np.ndarray: + normals = np.zeros((len(triangles), 3, 3), dtype=float) + for index, (a, b, c) in enumerate(triangles): + left = vertices[int(b)] - vertices[int(a)] + right = vertices[int(c)] - vertices[int(a)] + cross = np.cross(left, right) + length = float(np.linalg.norm(cross)) + if length > 0.0 and np.isfinite(length): + normal = cross / length + normals[index, :, :] = normal + return normals diff --git a/SimpleCADAPI/src/simplecadapi/auto_tools/auto_docs_gen.py b/SimpleCADAPI/src/simplecadapi/auto_tools/auto_docs_gen.py index dad1c7b..e8d68f5 100644 --- a/SimpleCADAPI/src/simplecadapi/auto_tools/auto_docs_gen.py +++ b/SimpleCADAPI/src/simplecadapi/auto_tools/auto_docs_gen.py @@ -20,11 +20,11 @@ DEFAULT_SOURCE_FILENAMES: tuple[str, ...] = ( "evolve.py", "ql.py", "serializer.py", - "translator/freecad_translator/api.py", - "translator/freecad_translator/script_translator.py", "math.py", "product.py", "expr.py", + "tolerance.py", + "units.py", "graph.py", "sketch.py", "errors.py", @@ -47,18 +47,18 @@ FULL_PUBLIC_FUNCTION_MODULES = frozenset( ) EXPORTED_FUNCTION_MODULES = frozenset( - {"serializer.py", "graph.py", "expr.py", "errors.py"} + { + "serializer.py", + "graph.py", + "expr.py", + "tolerance.py", + "units.py", + "errors.py", + } ) EXPORTED_CALLABLE_MODULES = frozenset( - {"expr.py", "graph.py", "sketch.py", "errors.py", "topology.py", "math.py", "product.py"} -) - -TRANSLATOR_PUBLIC_MODULES = frozenset( - { - "translator/freecad_translator/api.py", - "translator/freecad_translator/script_translator.py", - } + {"expr.py", "tolerance.py", "units.py", "graph.py", "sketch.py", "errors.py", "topology.py", "math.py", "product.py"} ) MISSING = object() @@ -81,7 +81,25 @@ def _source_checkout_root(package_root: Path) -> Path | None: def _default_source_files(package_root: Path) -> List[Path]: - return [package_root / name for name in DEFAULT_SOURCE_FILENAMES] + source_files = [package_root / name for name in DEFAULT_SOURCE_FILENAMES] + translator_root = package_root / "translator" + for backend_dir in sorted(translator_root.glob("*_translator")): + source_files.extend( + [backend_dir / "api.py", backend_dir / "translator.py"] + ) + return source_files + + +def _translator_backend_name(module_name: str) -> str | None: + parts = module_name.split("/") + if ( + len(parts) == 3 + and parts[0] == "translator" + and parts[1].endswith("_translator") + and parts[2] in {"api.py", "translator.py"} + ): + return parts[1] + return None def _default_stdlib_source_files(package_root: Path) -> List[Path]: @@ -262,7 +280,7 @@ class APIDocumentGenerator: return False if module_name in FULL_PUBLIC_FUNCTION_MODULES: return True - if module_name in TRANSLATOR_PUBLIC_MODULES: + if _translator_backend_name(module_name) is not None: return True if module_name in EXPORTED_FUNCTION_MODULES: if not exported_names: @@ -278,7 +296,7 @@ class APIDocumentGenerator: ) -> bool: if name.startswith("_"): return False - if module_name in TRANSLATOR_PUBLIC_MODULES: + if _translator_backend_name(module_name) is not None: return True if module_name not in EXPORTED_CALLABLE_MODULES: return False @@ -314,10 +332,11 @@ class APIDocumentGenerator: if name in self.exported_names: return f"top-level: `from simplecadapi import {name}`" - if module_name.startswith("translator/freecad_translator/"): + translator_backend = _translator_backend_name(module_name) + if translator_backend is not None: return ( "translator backend: " - f"`from simplecadapi.translator.freecad_translator import {name}`" + f"`from simplecadapi.translator.{translator_backend} import {name}`" ) module_stem = module_name.removesuffix(".py") @@ -464,10 +483,11 @@ class APIDocumentGenerator: "Tagging and Selection": [], "Boolean Operations": [], "Export": [], - "FreeCAD Translation": [], + "Translator Backends": [], "Math Helpers": [], "Modeling Graph and Replay": [], "Expressions and Parameters": [], + "Physical Units": [], "Types and Errors": [], "Advanced Features": [], "Evolve": [], @@ -485,18 +505,22 @@ class APIDocumentGenerator: categories["Modeling Graph and Replay"].append(api) continue - if api.source_file.startswith("translator/freecad_translator/"): - categories["FreeCAD Translation"].append(api) + if _translator_backend_name(api.source_file) is not None: + categories["Translator Backends"].append(api) continue if api.source_file == "math.py": categories["Math Helpers"].append(api) continue - if api.source_file == "expr.py": + if api.source_file in {"expr.py", "tolerance.py"}: categories["Expressions and Parameters"].append(api) continue + if api.source_file == "units.py": + categories["Physical Units"].append(api) + continue + if api.source_file in {"sketch.py", "errors.py"}: categories["Types and Errors"].append(api) continue diff --git a/SimpleCADAPI/src/simplecadapi/auto_tools/skill_pack.py b/SimpleCADAPI/src/simplecadapi/auto_tools/skill_pack.py index b8370f8..8685274 100644 --- a/SimpleCADAPI/src/simplecadapi/auto_tools/skill_pack.py +++ b/SimpleCADAPI/src/simplecadapi/auto_tools/skill_pack.py @@ -481,7 +481,7 @@ class SkillPackager: 4. Prefer the standard parts library for standard parts before hand-modeling with core geometry APIs. 5. Follow the documented API signatures exactly. 6. When calling any SimpleCAD public API or standard-library function, use keyword arguments for every documented parameter; do not use positional arguments. - 7. Use the graph/model JSON workflow for replayable tasks: `GraphSession`, `export_session_json`, `export_model_json`, `import_model_json`, and `replay_model_json`. + 7. Use one `@model` entry point for replayable tasks, `@requires_session` for child builders, `capture_result(...)` for explicit outputs, and the returned `ModelResult` for model/session JSON and replay. 8. Use geometry APIs for integrated parts: profiles, features, booleans, transforms, tagging, QL inspection, serialization, and exports. 9. Use tags consistently through `apply_tag(shape=..., tag=...)` and `list_tags(shape=...)`; do not call shape member tag mutators. 10. Build and validate incrementally. Each step MUST include a small grounding `print`, and grounding MUST use QL where possible. @@ -491,7 +491,7 @@ class SkillPackager: 14. For automated example/test harnesses, prefer the repo-local examples in `examples/` and avoid scratch scripts in `sandbox/`. 15. If union cannot produce exactly one merged solid, it fails explicitly; do not silently pick one piece. 16. If a single merged solid is required and union fails, slightly adjust part placement so intended bodies overlap/embed, then recompute. - 17. If a task depends on model replay or interchange, prefer `export_model_json()` output over hand-written payloads. + 17. If a task depends on model replay or interchange, prefer `ModelResult.model_json` or `export_model_json()` output over hand-written payloads. ## Standard Parts Library - SimpleCAD includes a standard library for parameterized mechanical parts. @@ -513,8 +513,8 @@ class SkillPackager: - Build from lower-dimensional geometry to higher-dimensional geometry: `Vertex` / `Edge` / `Wire` / `Face` profiles first, then `Solid` features such as extrude, revolve, loft, and sweep. - Keep modeling operations functional. Create new values from public functions such as `make_circle_rface(...)`, `extrude_rsolid(...)`, `cut_rsolid(...)`, and `fillet_rsolid(...)`. - Use keyword arguments for all SimpleCAD function calls, for example `make_box_rsolid(width=10.0, height=20.0, depth=3.0)` instead of positional arguments. - - Use `GraphSession` when the model should be replayable, inspectable, exported as model JSON, or translated to another CAD system. - - Treat model JSON as the interchange boundary. Prefer `export_model_json(session=...)` and `replay_model_json(json_str=...)` over hand-authored operation payloads. + - Use `@model` when the top-level model should be replayable, inspectable, exported as model JSON, or translated to another CAD system. It owns one `GraphSession`; reusable graph-producing builders use `@requires_session`. + - Treat model JSON as the interchange boundary. Prefer `ModelResult.model_json` and `ModelResult.replay()` for top-level models; use `export_model_json(session=...)` for lower-level direct sessions and `replay_model_json(json_str=...)` for standalone payloads. - Use QL for precise grounding. Query faces, edges, centers, normals, areas, lengths, curve types, and tags; print only the facts needed to validate the current step. - Use `get_edges(index)`, `get_faces(index)`, `get_wires(index)`, or `get_vertices(index)` when an indexed topology pick is intentional; these picks are preserved as geo select nodes in replayable graph workflows. - Use tags for semantic intent and selection anchors, such as `role.mounting_surface`, `anchor.datum.primary`, `face.top`, or `group.fasteners`. @@ -544,20 +544,22 @@ class SkillPackager: ```python import simplecadapi as scad - from simplecadapi import GraphSession, export_model_json, make_box_rsolid + from simplecadapi import ModelResult, capture_result, model, requires_session ``` Typical replayable usage in a Python script: ```python import simplecadapi as scad - from simplecadapi import GraphSession, export_model_json, replay_model_json - with GraphSession() as session: + @scad.model(graph_id="box") + def build_box(): shape = scad.make_box_rsolid(width=10.0, height=20.0, depth=30.0) + scad.capture_result(value=shape) + return shape - model_json = export_model_json(session=session) - rebuilt = replay_model_json(json_str=model_json) + result = build_box() + rebuilt = result.replay() print(len(rebuilt)) ``` @@ -683,13 +685,17 @@ class SkillPackager: ## Typical replayable surface ```python - from simplecadapi import GraphSession, export_model_json, replay_model_json + import simplecadapi as scad - with GraphSession() as session: - ... + @scad.model(graph_id="demo") + def build_model(): + result = ... + scad.capture_result(value=result) + return result - model_json = export_model_json(session=session) - rebuilt = replay_model_json(json_str=model_json) + model = build_model() + model_json = model.model_json + rebuilt = model.replay() print(len(rebuilt)) ``` """ @@ -707,35 +713,39 @@ class SkillPackager: - Use the standard parts library first when a requested standard component is available and does not need complex custom geometry changes. - Start from profiles and reference geometry, then create solids with features such as extrude, revolve, loft, and sweep. - Use booleans and detail features after the base form is clear: cut openings, union intended merged bodies, then apply fillets, chamfers, or shell operations. - - Use `GraphSession` whenever the result should be replayable, inspectable, serialized, or translated. + - Use `@scad.model` for a top-level replayable entry point. It owns one `GraphSession` and returns a `ModelResult`; use `@scad.requires_session` for child builders. - Use QL for grounding and selection. Query the facts you need, such as face normals, centers, areas, edge lengths, curve types, and tags. - Use indexed child-geometry getters such as `get_edges(index)` and `get_faces(index)` when an indexed topology pick is intentional. - Use semantic tags for design intent and anchors. Keep numeric measurements and geometry facts in metadata or model JSON payloads. - - Treat `export_model_json()` as the interchange boundary for replay and CAD translation. + - Treat `ModelResult.model_json` as the interchange boundary for new model entry points. Use `export_model_json(session=...)` for lower-level direct sessions. - Validate incrementally: after each major step, print small QL-derived facts such as selected face count, top face center, edge count, volume, or replay result count. ## 1) Capture a replayable modeling flow ```python - from simplecadapi import GraphSession, export_model_json + import simplecadapi as scad - with GraphSession() as session: - ... + @scad.model(graph_id="bracket") + def build_bracket(): + body = scad.make_box_rsolid(width=20.0, height=10.0, depth=3.0) + scad.capture_result(value=body) + return body - payload = export_model_json(session=session) + result = build_bracket() + payload = result.model_json + rebuilt = result.replay() ``` ## 2) Import and use in Python ```python import simplecadapi as scad - from simplecadapi import GraphSession, export_model_json ``` ## 3) Keep replay payloads as the interchange boundary - Prefer `export_model_json()` output instead of hand-written payloads. - - Use `replay_model_json()` when you need deterministic reconstruction. + - Use `ModelResult.replay()` for a model invocation, or `replay_model_json(json_str=...)` when consuming standalone model JSON. - Use `import_model_json()` when consuming previously exported payloads. ## 4) Use standard parts when they fit @@ -750,10 +760,10 @@ class SkillPackager: ) rack = scad.std.gear.make_spur_rack_rsolid(module=1.5, n_teeth=18) bearing = scad.std.bearing.make_ball_bearing_rassembly( - 8.0, - 22.0, - 7.0, - 3.5, + bore_diameter=8.0, + outer_diameter=22.0, + bearing_width=7.0, + ball_diameter=3.5, ) ``` @@ -767,25 +777,30 @@ class SkillPackager: import simplecadapi as scad from simplecadapi import ql - with scad.GraphSession() as session: + @scad.model(graph_id="swept_profile") + def build_model(): profile = scad.make_circle_rface(center=(0, 0, 0), radius=1.0) body = scad.extrude_rsolid( profile=profile, direction=(0, 0, 1), distance=4.0, + end_face_tag="role.sweep_profile", + result_tag="part.body", ) end_face = ( ql.faces() - .where(ql.tag("face.extrusion.end")) + .where(ql.output_role(role_name="extrusion.end")) .exactly(1) .resolve(body)[0] ) print("end face center", end_face.get_center()) path = scad.make_segment_rwire(start=(0, 0, 4), end=(0, 0, 8)) swept = scad.sweep_rsolid(profile=end_face, path=path) + scad.capture_result(value=swept) + return swept - payload = scad.export_model_json(session=session) - rebuilt = scad.replay_model_json(json_str=payload) + result = build_model() + rebuilt = result.replay() print("rebuilt", len(rebuilt)) ``` @@ -794,7 +809,7 @@ class SkillPackager: - Prefer QL selectors for semantic/geometric feature input selection. - Use `get_edges(index)`, `get_faces(index)`, `get_wires(index)`, or `get_vertices(index)` for intentional indexed picks in examples. - Attach semantic tags with `apply_tag(shape=..., tag=...)` and inspect with `list_tags(shape=...)`. - - Use tags for intent, roles, anchors, groups, and topology names. + - Use tags for topology identity, intent, roles, anchors, and groups. - Store dimensions, positions, measured geometry, and descriptive payloads in metadata or model JSON, not in tags. - Keep QL result prints concise: selected count, centers, normals, areas, lengths, or tags. diff --git a/SimpleCADAPI/src/simplecadapi/autotag.py b/SimpleCADAPI/src/simplecadapi/autotag.py index 5392f2e..7f997cf 100644 --- a/SimpleCADAPI/src/simplecadapi/autotag.py +++ b/SimpleCADAPI/src/simplecadapi/autotag.py @@ -1,110 +1,599 @@ -"""Auto-tagging based on TopoDelta: applies operation semantic tags to result shapes. - -After a tracked operation (cut, union, fillet, etc.), this module can match -result faces to their delta entries and apply semantic tags like: -- ``op.cut.modified`` / ``op.cut.generated`` / ``op.cut.preserved`` -- ``origin.body`` / ``origin.tool`` -- ``role.section_face`` - -These tags can then be queried via the QL (``Q.tag("op.cut.generated")``). -""" +"""Evidence-gated semantic projections from topology tracking data.""" from __future__ import annotations -from typing import Any, Dict, Optional +from typing import Any, Dict, Optional, Sequence -from .core import Solid, Face -from .topology import TopoKind, TopoEvent, TopoDelta +from .core import AnyShape, Solid +from .topology import TopoDelta, TopoKind from .tracking import _topo_id +try: + from .tagging import ( + LineageDerivation, + TagEvidence, + TagProducerKind, + lineage_policy_allows, + operation_role_tag_binding, + projected_tag_binding, + ) +except ImportError: # The binding layer can be integrated independently. + TagEvidence = None # type: ignore[assignment,misc] + LineageDerivation = None # type: ignore[assignment,misc] + lineage_policy_allows = None # type: ignore[assignment] + operation_role_tag_binding = None # type: ignore[assignment] + projected_tag_binding = None # type: ignore[assignment] + + +def _event_name(value: Any) -> Optional[str]: + if value is None: + return None + token = getattr(value, "name", value) + token = str(token).strip().lower() + return token or None + + +def _operation_alias(op: str) -> str: + token = str(op).strip().lower() + if token.startswith("op."): + token = token[3:] + if token.startswith("make_"): + token = token[5:] + for suffix in ("_rsolid", "_rshape", "_rface", "_rwire", "_redge"): + if token.endswith(suffix): + return token[: -len(suffix)] + return token + + +def _entries_from_delta(delta: TopoDelta) -> Dict[str, Dict[str, Any]]: + """Build the compatibility lookup without dropping canonical witnesses.""" + + result: Dict[str, Dict[str, Any]] = {} + for entry in delta.entries: + parent_refs = [ + { + "graph_id": ref.graph_id, + "node_id": ref.node_id, + "output_slot": ref.output_slot, + "kind": ref.kind.name, + "topo_id": ref.topo_id, + } + for ref in entry.parent_refs + ] + witness = { + "topo_id": entry.ref.topo_id, + "kind": entry.ref.kind.name, + "event": entry.event.name.lower(), + "origin_role": entry.origin_role, + "input_topo_id": ( + entry.parent_refs[0].topo_id if entry.parent_refs else None + ), + "parent_refs": parent_refs, + "derivation": entry.metadata.get("derivation", "unknown"), + "coverage": entry.metadata.get("coverage", "complete"), + "status": entry.metadata.get("status", "proven"), + "evidence_kind": entry.metadata.get( + "evidence_kind", "kernel_history" + ), + "source_kind": entry.metadata.get("source_kind"), + } + aggregate = result.setdefault( + entry.ref.topo_id, + { + "topo_id": entry.ref.topo_id, + "kind": entry.ref.kind.name, + "event": witness["event"], + "origin_role": entry.origin_role, + "input_topo_id": witness["input_topo_id"], + "derivation": witness["derivation"], + "coverage": witness["coverage"], + "status": witness["status"], + "witnesses": [], + }, + ) + aggregate["witnesses"].append(witness) + return result + + +def _normalized_witnesses(entry: Dict[str, Any]) -> list[Dict[str, Any]]: + raw = entry.get("witnesses") + if isinstance(raw, (list, tuple)) and raw: + return [dict(item) for item in raw if isinstance(item, dict)] + return [dict(entry)] + + +def _matching_entry( + entries: Dict[str, Dict[str, Any]], topo_id: str, kind: TopoKind +) -> Optional[Dict[str, Any]]: + entry = entries.get(topo_id) + if not isinstance(entry, dict): + return None + + witnesses = [ + witness + for witness in _normalized_witnesses(entry) + if str(witness.get("kind", entry.get("kind", kind.name))).upper() + == kind.name + ] + if not witnesses: + return None + matched = dict(entry) + matched["kind"] = kind.name + matched["witnesses"] = witnesses + return matched + + +def _normalized_tracking_witness( + witness: Dict[str, Any], *, default_kind: TopoKind +) -> Dict[str, Any]: + result = { + "kind": "topology_change", + "topo_kind": str(witness.get("kind", default_kind.name)).upper(), + "source_kind": ( + str(witness["source_kind"]).upper() + if witness.get("source_kind") is not None + else None + ), + "event": _event_name(witness.get("event")), + "origin_role": witness.get("origin_role"), + "input_topo_id": witness.get("input_topo_id"), + "derivation": str(witness.get("derivation", "unknown")).lower(), + "coverage": str(witness.get("coverage", "complete")).lower(), + "status": str(witness.get("status", "proven")).lower(), + "evidence_kind": str( + witness.get("evidence_kind", "kernel_history") + ).lower(), + } + for key in ("result_role", "evidence_method", "project_source_tags"): + if witness.get(key) is not None: + result[key] = witness[key] + if witness.get("parent_refs") is not None: + result["parent_refs"] = list(witness["parent_refs"]) + if witness.get("section"): + result["section"] = True + return result + + +def _tracking_payload( + entry: Dict[str, Any], *, op: str, topo_id: str, kind: TopoKind +) -> Dict[str, Any]: + witnesses = [ + _normalized_tracking_witness(item, default_kind=kind) + for item in _normalized_witnesses(entry) + ] + events = sorted( + { + str(item["event"]) + for item in witnesses + if item.get("event") is not None + } + ) + origin_roles = sorted( + { + str(item["origin_role"]) + for item in witnesses + if item.get("origin_role") is not None + } + ) + result_roles = sorted( + { + str(item["result_role"]) + for item in witnesses + if item.get("result_role") is not None + } + ) + coverage = ( + "complete" + if witnesses and all(item["coverage"] == "complete" for item in witnesses) + else "partial" + ) + status = ( + "proven" + if witnesses and all(item["status"] == "proven" for item in witnesses) + else "unknown" + ) + payload: Dict[str, Any] = { + "schema_version": "1.0", + "kind": "topology_change", + "op": str(op), + "operation": _operation_alias(op), + "topo_id": topo_id, + "topo_kind": kind.name, + "coverage": coverage, + "status": status, + "events": events, + "origin_roles": origin_roles, + "result_roles": result_roles, + "witnesses": witnesses, + } + if len(events) == 1: + payload["event"] = events[0] + if len(origin_roles) == 1: + payload["origin_role"] = origin_roles[0] + if len(result_roles) == 1: + payload["result_role"] = result_roles[0] + return payload + + +def _unknown_tracking_payload( + *, op: str, topo_id: str, kind: TopoKind +) -> Dict[str, Any]: + return { + "schema_version": "1.0", + "kind": "topology_change", + "op": str(op), + "operation": _operation_alias(op), + "topo_id": topo_id, + "topo_kind": kind.name, + "coverage": "partial", + "status": "unknown", + "events": [], + "origin_roles": [], + "result_roles": [], + "witnesses": [], + } + + +def _carry_source_lineage( + target: AnyShape, + entry: Dict[str, Any], + source_entities: Dict[str, AnyShape], + *, + op: str, +) -> None: + """Attach source bindings only through policy-allowed kernel witnesses.""" + + add_lineage = getattr(target, "_add_tag_lineage", None) + if not callable(add_lineage): + return + + for witness in _normalized_witnesses(entry): + derivation = str(witness.get("derivation", "unknown")).lower() + if LineageDerivation is None: + continue + try: + LineageDerivation(derivation) + except ValueError: + # An operation role can be complete without proving tag lineage. + continue + input_id = witness.get("input_topo_id") + source = source_entities.get(str(input_id)) if input_id is not None else None + if source is None: + continue + local_bindings = getattr(source, "_local_tag_bindings", None) + if not callable(local_bindings) or lineage_policy_allows is None: + continue + evidence_data = { + "op": str(op), + "event": _event_name(witness.get("event")), + "origin_role": witness.get("origin_role"), + "coverage": str(witness.get("coverage", "complete")), + "derivation": derivation, + } + evidence = ( + TagEvidence("topology_change", evidence_data) + if TagEvidence is not None + else {"kind": "topology_change", **evidence_data} + ) + bindings = list(local_bindings()) + bindings.extend( + item.binding + for item in getattr(source, "_tag_lineage", ()) + if item.coverage == "complete" + and lineage_policy_allows( + item.binding.propagation, item.derivation + ) + ) + unique_bindings = {binding.binding_id: binding for binding in bindings} + for binding in unique_bindings.values(): + if not lineage_policy_allows(binding.propagation, derivation): + continue + add_lineage( + binding, + derivation=derivation, + source_topo_id=source.topo_id, + evidence=evidence, + coverage=str(witness.get("coverage", "complete")), + ) + + +def _apply_operation_role_bindings(target: AnyShape, track: Dict[str, Any], *, op: str) -> None: + if operation_role_tag_binding is None: + return + add_binding = getattr(target, "_add_tag_binding", None) + if not callable(add_binding): + return + for witness in track.get("witnesses", []): + role = witness.get("result_role") + if ( + role is None + or witness.get("status") != "proven" + or witness.get("coverage") != "complete" + ): + continue + role = str(role) + kind = target.__class__.__name__.lower() + tag = f"{kind}.{role}" + add_binding( + operation_role_tag_binding( + tag, + operation=op, + role=role, + target_topo_id=target.topo_id, + evidence_method=str(witness.get("evidence_method", "unknown")), + ) + ) + + +def _project_source_bindings( + target: AnyShape, + entry: Dict[str, Any], + source_entities: Dict[str, AnyShape], + *, + op: str, + require_lineage_policy: bool = False, + topology_source_targets: Optional[Dict[str, set[str]]] = None, +) -> None: + if projected_tag_binding is None: + return + add_binding = getattr(target, "_add_tag_binding", None) + if not callable(add_binding): + return + for witness in _normalized_witnesses(entry): + if not witness.get("project_source_tags"): + continue + if ( + str(witness.get("coverage", "partial")).lower() != "complete" + or str(witness.get("status", "unknown")).lower() != "proven" + ): + continue + input_id = witness.get("input_topo_id") + source = source_entities.get(str(input_id)) if input_id is not None else None + if source is None: + continue + local_bindings = getattr(source, "_local_tag_bindings", None) + if not callable(local_bindings): + continue + derivation = str(witness.get("derivation", "unknown")).lower() + event = _event_name(witness.get("event")) + role = str(witness.get("result_role") or event or derivation) + evidence_method = witness.get("evidence_method") + if evidence_method is None: + evidence_method = ( + { + "preserved": "Identity", + "modified": "Modified", + "generated": "Generated", + }.get(event, "KernelHistory") + if event is not None + else "KernelHistory" + ) + for binding in local_bindings(): + is_user_binding = binding.producer.kind == TagProducerKind.USER_OPERATION + is_prior_projection = ( + binding.producer.kind == TagProducerKind.AUTO_RULE + and binding.producer.rule_id + == "simplecad.feature_source_tag_projection" + ) + is_operation_output_naming = isinstance( + binding.evidence.data.get("operation_output_role"), dict + ) or isinstance( + binding.evidence.data.get("source_operation_output_role"), dict + ) + is_topology_naming = isinstance( + binding.evidence.data.get("topology_name"), dict + ) or isinstance( + binding.evidence.data.get("source_topology_name"), dict + ) + if is_topology_naming: + source_ids = { + str(item.get("input_topo_id")) + for item in _normalized_witnesses(entry) + if item.get("input_topo_id") is not None + } + exact_target_ids = ( + (topology_source_targets or {}).get(source.topo_id, set()) + ) + # Do not project a topology identity through a split or merge. + if len(source_ids) != 1 or exact_target_ids != {target.topo_id}: + continue + if require_lineage_policy: + if ( + (not is_operation_output_naming and not is_topology_naming) + or (not is_user_binding and not is_prior_projection) + ): + continue + elif not (is_user_binding or is_prior_projection): + continue + if require_lineage_policy and not lineage_policy_allows( + binding.propagation, derivation + ): + continue + add_binding( + projected_tag_binding( + binding, + operation=op, + role=role, + source_topo_id=source.topo_id, + target_topo_id=target.topo_id, + evidence_method=str(evidence_method), + topology=( + "downward" + if target.__class__.__name__ == "Face" and is_topology_naming + else "local" + ), + ) + ) + + +def _lineage_coverage( + entry: Dict[str, Any], source_entities: Dict[str, AnyShape] +) -> str: + for witness in _normalized_witnesses(entry): + if str(witness.get("coverage", "partial")).lower() != "complete": + return "partial" + input_id = witness.get("input_topo_id") + if input_id is not None and str(input_id) not in source_entities: + return "partial" + return "complete" + def apply_tracking_tags( solid: Solid, delta: TopoDelta, delta_entries: Optional[Dict[str, Dict[str, Any]]] = None, - op_prefix: str = "op", + op_prefix: str = "unknown", + *, + source_solid: Optional[Solid] = None, + source_solids: Optional[Sequence[Solid]] = None, + source_shapes: Optional[Sequence[AnyShape]] = None, ) -> Solid: - """Apply operation semantic tags to a result solid based on TopoDelta. + """Project proven tracking facts to typed metadata and lineage witnesses. - For each face in the result solid, checks whether it matches a face in the - delta's modified/generated/preserved/deleted lists by topo_id, and applies - corresponding tags. - - Faces in the result that don't match any delta entry are tagged as - ``{op_prefix}.generated`` (inferred new faces). - - Args: - solid: The result solid to tag. - delta: The TopoDelta from the tracked operation. - delta_entries: Per-entity metadata dict (optional, used for origin_role). - op_prefix: Tag prefix for the operation (default ``"op"``). - - Returns: - The same ``solid`` (mutated in place) for convenience. + Missing evidence remains unknown. Operation names, origin roles, and change + events are not materialized as semantic tags. """ - entries = delta_entries or {} - # Build a lookup: topo_id -> event - id_to_event: Dict[str, str] = {} - for ref in delta.modified: - id_to_event[ref.topo_id] = "modified" - for ref in delta.generated: - id_to_event[ref.topo_id] = "generated" - for ref in delta.preserved: - id_to_event[ref.topo_id] = "preserved" - for ref in delta.deleted: - id_to_event[ref.topo_id] = "deleted" + entries = dict(delta_entries) if delta_entries else _entries_from_delta(delta) + sources = list(source_solids or ()) + if source_solid is not None and all( + source_solid is not source for source in sources + ): + sources.insert(0, source_solid) + all_sources: list[AnyShape] = list(sources) + for source in source_shapes or (): + if all(source is not existing for existing in all_sources): + all_sources.append(source) + source_faces = { + _topo_id(face.wrapped): face + for source in all_sources + for face in ( + source.get_faces() + if hasattr(source, "get_faces") + else ([source] if source.__class__.__name__ == "Face" else []) + ) + } + source_edges = { + _topo_id(edge.wrapped): edge + for source in all_sources + for edge in ( + source.get_edges() + if hasattr(source, "get_edges") + else ([source] if source.__class__.__name__ == "Edge" else []) + ) + } - # Section edges - section_ids = {ref.topo_id for ref in delta.section_edges} - delta_is_pure_preserve = ( - len(delta.preserved) > 0 - and len(delta.modified) == 0 - and len(delta.generated) == 0 - and len(delta.deleted) == 0 - ) + topology_source_targets: Dict[str, set[str]] = {} + for target_id, entry in entries.items(): + if str(entry.get("kind", "")).upper() != TopoKind.FACE.name: + continue + for witness in _normalized_witnesses(entry): + if ( + witness.get("project_source_tags") + and str(witness.get("source_kind", "")).upper() + == TopoKind.EDGE.name + and witness.get("input_topo_id") is not None + ): + topology_source_targets.setdefault( + str(witness["input_topo_id"]), set() + ).add(str(target_id)) for face in solid.get_faces(): - fid = _topo_id(face.wrapped) - event = id_to_event.get(fid) + topo_id = _topo_id(face.wrapped) + entry = _matching_entry(entries, topo_id, TopoKind.FACE) + if entry is None: + face.set_metadata( + "track", + _unknown_tracking_payload( + op=op_prefix, topo_id=topo_id, kind=TopoKind.FACE + ), + ) + face._set_runtime("semantic.lineage.coverage", "partial") + continue - if event is None: - # Check delta_entries by input_topo_id - entry = entries.get(fid) - if entry: - event = entry.get("event", "") - - if event is None: - if delta_is_pure_preserve: - event = "preserved" - else: - # Face not found in delta -> it's a generated face - event = "generated" - - tag = f"{op_prefix}.{event}" - face._add_tag(tag) - face._apply_tag(f"face.{tag}", propagate=False) - face.set_metadata( - "track", - { - "event": event, - "topo_id": fid, - "op": op_prefix, - }, + track = _tracking_payload( + entry, + op=op_prefix, + topo_id=topo_id, + kind=TopoKind.FACE, ) + face.set_metadata("track", track) + _apply_operation_role_bindings(face, track, op=op_prefix) + face._set_runtime( + "semantic.lineage.coverage", + _lineage_coverage(entry, source_faces), + ) + if source_faces and track["status"] == "proven": + _carry_source_lineage(face, entry, source_faces, op=op_prefix) + _project_source_bindings( + face, + entry, + source_faces, + op=op_prefix, + require_lineage_policy=True, + topology_source_targets=topology_source_targets, + ) + if source_edges and track["status"] == "proven": + _project_source_bindings( + face, + entry, + source_edges, + op=op_prefix, + topology_source_targets=topology_source_targets, + ) - # Origin role tagging from entries - entry = entries.get(fid, {}) - origin_role = entry.get("origin_role") - if origin_role: - face._add_tag(f"origin.{origin_role}") - face._apply_tag(f"face.origin.{origin_role}", propagate=False) - face.get_metadata("track", {})["origin_role"] = origin_role + section_ids = { + ref.topo_id for ref in delta.section_edges if ref.kind == TopoKind.EDGE + } + for edge in solid.get_edges(): + topo_id = _topo_id(edge.wrapped) + entry = _matching_entry(entries, topo_id, TopoKind.EDGE) + if entry is None: + track = _unknown_tracking_payload( + op=op_prefix, topo_id=topo_id, kind=TopoKind.EDGE + ) + else: + track = _tracking_payload( + entry, + op=op_prefix, + topo_id=topo_id, + kind=TopoKind.EDGE, + ) - # Section face tagging (faces at boolean intersection) - if fid in section_ids: - face._apply_tag("role.section_face", propagate=False) - face._apply_tag("face.role.section", propagate=False) + if topo_id in section_ids: + track["section"] = True + track["derivation"] = "intersection" + if entry is None: + section_witness = { + "kind": "topology_change", + "topo_kind": TopoKind.EDGE.name, + "source_kind": None, + "event": "generated", + "origin_role": None, + "input_topo_id": None, + "derivation": "intersection", + "coverage": "complete", + "status": "proven", + "evidence_kind": "kernel_section_edges", + "section": True, + } + track.update( + { + "coverage": "complete", + "status": "proven", + "event": "generated", + "events": ["generated"], + "witnesses": [section_witness], + } + ) + edge.set_metadata("track", track) + _apply_operation_role_bindings(edge, track, op=op_prefix) + edge._set_runtime( + "semantic.lineage.coverage", + _lineage_coverage(entry, source_edges) if entry is not None else "partial", + ) + if entry is not None and source_edges and track["status"] == "proven": + _carry_source_lineage(edge, entry, source_edges, op=op_prefix) return solid @@ -115,51 +604,17 @@ def apply_tracking_tags_to_delta( delta_entries: Optional[Dict[str, Dict[str, Any]]] = None, op: str = "unknown", source_solid: Optional[Solid] = None, + source_solids: Optional[Sequence[Solid]] = None, + source_shapes: Optional[Sequence[AnyShape]] = None, ) -> Solid: - """Convenience wrapper that prefixes the operation name. + """Apply evidence-gated tracking projections for one operation result.""" - Args: - solid: The result solid. - delta: The TopoDelta. - delta_entries: Per-entity metadata. - op: Operation name (e.g. ``"cut"``, ``"union"``, ``"extrude"``). - source_solid: If provided, carry over tags from this solid's faces - to the result faces that originated from them. - - Returns: - The tagged solid. - """ - result = apply_tracking_tags(solid, delta, delta_entries, op_prefix=f"op.{op}") - - if source_solid is not None: - _carry_source_tags(result, delta_entries or {}, source_solid) - - return result - - -def _carry_source_tags( - result_solid: Solid, - delta_entries: Dict[str, Dict[str, Any]], - source_solid: Solid, -) -> None: - """Carry over tags from source solid faces to result faces. - - For each face in the result, looks up its delta entry to find the - ``input_topo_id`` of the source face, then copies propagatable tags. - """ - # Build source face lookup by topo_id - source_face_tags: Dict[str, set] = {} - for face in source_solid.get_faces(): - fid = _topo_id(face.wrapped) - source_face_tags[fid] = set(face._tags) - - # For each result face, find its source and copy tags - for face in result_solid.get_faces(): - fid = _topo_id(face.wrapped) - entry = delta_entries.get(fid, {}) - input_id = entry.get("input_topo_id") - if input_id and input_id in source_face_tags: - for tag in source_face_tags[input_id]: - # Don't overwrite operation tags - if not tag.startswith("op.") and not tag.startswith("origin."): - face._add_tag(tag) + return apply_tracking_tags( + solid, + delta, + delta_entries, + op_prefix=op, + source_solid=source_solid, + source_solids=source_solids, + source_shapes=source_shapes, + ) diff --git a/SimpleCADAPI/src/simplecadapi/core.py b/SimpleCADAPI/src/simplecadapi/core.py index 733879d..3a82c27 100644 --- a/SimpleCADAPI/src/simplecadapi/core.py +++ b/SimpleCADAPI/src/simplecadapi/core.py @@ -4,6 +4,7 @@ from __future__ import annotations from contextlib import contextmanager from contextvars import ContextVar, Token +from copy import deepcopy from dataclasses import dataclass, field from typing import Any, Dict, Iterable, List, Optional, Set, Tuple, Union, cast @@ -17,7 +18,24 @@ from .kernel.ocp_cast import as_compound, as_edge, as_face, as_solid, as_vertex, from .kernel.ocp_booleans import solids_of from .kernel.ocp_properties import Vec3, center_of_mass, face_normal_at, linear_length, surface_area, volume from .kernel.ocp_topology import edges_of, faces_of, inner_wires_of, is_wire_closed, outer_wire_of, vertex_point, vertices_of -from .tagging import DEFAULT_TAG_POLICY, normalize_tag +from .tagging import ( + LineagePolicy, + TagAttachment, + TagBinding, + TagEvidence, + TagLineageWitness, + TagProducer, + TagProducerKind, + TagScope, + TopologyPropagation, + UnsupportedQueryCapabilityError, + internal_tag_binding, + legacy_tag_binding, + lineage_policy_allows, + normalize_tag, + normalize_tag_scope, + user_tag_binding, +) suppress_vendor_deprecation_warnings() @@ -35,6 +53,8 @@ class _TopoEntity: topo_id: str representative: Any tags: Set[str] = field(default_factory=set) + tag_bindings: List[TagBinding] = field(default_factory=list) + tag_lineage: List[TagLineageWitness] = field(default_factory=list) metadata: Dict[str, Any] = field(default_factory=dict) runtime: Dict[str, Any] = field(default_factory=dict) wrappers: List[Any] = field(default_factory=list) @@ -75,6 +95,21 @@ class _TopologyEntityCache: return [entity for entity in self._entities_by_id.values() if entity.kind == kind] +def _copy_entity_state(source: _TopoEntity, target: _TopoEntity) -> None: + target.tags.clear() + target.tags.update(source.tags) + target.tag_bindings[:] = list(source.tag_bindings) + target.tag_lineage[:] = list(source.tag_lineage) + target.metadata.clear() + target.metadata.update(deepcopy(source.metadata)) + target.runtime.clear() + target.runtime.update(deepcopy(source.runtime)) + target.incident_face_ids.clear() + target.incident_face_ids.update(source.incident_face_ids) + target.incident_edge_ids.clear() + target.incident_edge_ids.update(source.incident_edge_ids) + + class CoordinateSystem: """Three-dimensional coordinate system. @@ -345,15 +380,111 @@ class TaggedMixin: def __init__(self, entity: Optional[_TopoEntity] = None): self._entity = entity if entity is None: - self._tags: Set[str] = set() - self._metadata: Dict[str, Any] = {} - self._runtime: Dict[str, Any] = {} + self._standalone_tag_cache: Set[str] = set() + self._standalone_tag_bindings: List[TagBinding] = [] + self._standalone_tag_lineage: List[TagLineageWitness] = [] + self._standalone_metadata: Dict[str, Any] = {} + self._standalone_runtime: Dict[str, Any] = {} else: - self._tags = entity.tags - self._metadata = entity.metadata - self._runtime = entity.runtime entity.wrappers.append(self) + @property + def _tag_cache(self) -> Set[str]: + entity = getattr(self, "_entity", None) + if entity is not None: + return entity.tags + return self._standalone_tag_cache + + @_tag_cache.setter + def _tag_cache(self, values: Iterable[str]) -> None: + replacement = set(values) + target = self._tag_cache + target.clear() + target.update(replacement) + + @property + def _tag_bindings(self) -> List[TagBinding]: + entity = getattr(self, "_entity", None) + if entity is not None: + return entity.tag_bindings + return self._standalone_tag_bindings + + @_tag_bindings.setter + def _tag_bindings(self, values: Iterable[TagBinding]) -> None: + replacement = list(values) + self._tag_bindings[:] = replacement + + @property + def _tag_lineage(self) -> List[TagLineageWitness]: + entity = getattr(self, "_entity", None) + if entity is not None: + return entity.tag_lineage + return self._standalone_tag_lineage + + @_tag_lineage.setter + def _tag_lineage(self, values: Iterable[TagLineageWitness]) -> None: + replacement = list(values) + self._tag_lineage[:] = replacement + + @property + def _metadata(self) -> Dict[str, Any]: + entity = getattr(self, "_entity", None) + if entity is not None: + return entity.metadata + return self._standalone_metadata + + @_metadata.setter + def _metadata(self, values: Dict[str, Any]) -> None: + replacement = dict(values) + target = self._metadata + target.clear() + target.update(replacement) + + @property + def _runtime(self) -> Dict[str, Any]: + entity = getattr(self, "_entity", None) + if entity is not None: + return entity.runtime + return self._standalone_runtime + + @_runtime.setter + def _runtime(self, values: Dict[str, Any]) -> None: + replacement = dict(values) + target = self._runtime + target.clear() + target.update(replacement) + + @property + def _tags(self) -> Set[str]: + """Compatibility cache; TagBinding objects are canonical truth.""" + + return self._tag_cache + + @_tags.setter + def _tags(self, values: Iterable[str]) -> None: + if not isinstance(values, Iterable) or isinstance(values, (str, bytes)): + raise TypeError("_tags compatibility cache must be assigned an iterable") + imported = [] + for value in values: + imported.append( + legacy_tag_binding( + normalize_tag(value, strict=True), + diagnostic="Imported from a direct legacy _tags assignment.", + ) + ) + if hasattr(self, "_tag_bindings"): + self._tag_bindings[:] = imported + else: + self._tag_bindings = imported + if hasattr(self, "_tag_lineage"): + self._tag_lineage[:] = [] + else: + self._tag_lineage = [] + cache = getattr(self, "_tag_cache", None) + if cache is None: + self._tag_cache = set() + self._refresh_tag_cache() + @property def topo_id(self) -> str: entity = getattr(self, "_entity", None) @@ -367,40 +498,338 @@ class TaggedMixin: def _add_tag(self, tag: str) -> None: if not isinstance(tag, str): raise TypeError("标签必须是字符串类型") - tag = normalize_tag(tag, strict=True) - self._tags.add(tag) + self._add_tag_binding(internal_tag_binding(normalize_tag(tag, strict=True))) + + def _add_tag_binding(self, binding: TagBinding) -> None: + if not isinstance(binding, TagBinding): + raise TypeError("binding must be a TagBinding") + if not any(item.binding_id == binding.binding_id for item in self._tag_bindings): + self._tag_bindings.append(binding) + self._refresh_tag_cache( + recursive=binding.propagation.topology == TopologyPropagation.DOWNWARD + ) + + def _add_tag_lineage( + self, + binding: TagBinding, + *, + derivation: str, + source_topo_id: str, + evidence: TagEvidence, + coverage: str = "complete", + ) -> None: + witness = TagLineageWitness( + binding=binding, + derivation=derivation, + source_topo_id=str(source_topo_id), + target_topo_id=self.topo_id, + evidence=evidence, + coverage=coverage, + ) + marker = ( + witness.binding.binding_id, + witness.derivation, + witness.source_topo_id, + witness.target_topo_id, + ) + if not any( + ( + item.binding.binding_id, + item.derivation, + item.source_topo_id, + item.target_topo_id, + ) + == marker + for item in self._tag_lineage + ): + self._tag_lineage.append(witness) + + def _copy_tag_state_from(self, source: "TaggedMixin") -> None: + if not isinstance(source, TaggedMixin): + raise TypeError("source must be a TaggedMixin") + self._tag_bindings[:] = list(source._tag_bindings) + self._tag_lineage[:] = list(source._tag_lineage) + self._refresh_tag_cache(recursive=True) + + def _replace_local_tag_bindings(self, bindings: Iterable[TagBinding]) -> None: + retained = [ + binding + for binding in self._tag_bindings + if binding.attachment != TagAttachment.LOCAL + ] + replacements = list(bindings) + if not all(isinstance(binding, TagBinding) for binding in replacements): + raise TypeError("bindings must contain only TagBinding objects") + self._tag_bindings[:] = [*retained, *replacements] + self._refresh_tag_cache(recursive=True) + + def _refresh_tag_cache( + self, *, recursive: bool = False, _visited: Optional[Set[int]] = None + ) -> None: + visited = _visited if _visited is not None else set() + marker = id(self) + if marker in visited: + return + visited.add(marker) + self._tag_cache.clear() + self._tag_cache.update(self._list_tags(TagScope.EFFECTIVE)) + if recursive and isinstance(self, TopoMixein): + for child in self.get_children(): + if isinstance(child, TaggedMixin): + child._refresh_tag_cache(recursive=True, _visited=visited) def _apply_tag( self, tag: str, *, normalize: bool = True, propagate: Optional[bool] = None ) -> None: if normalize: tag = normalize_tag(tag, strict=True) - self._tags.add(tag) if propagate is None: - propagate = DEFAULT_TAG_POLICY.should_propagate(tag) - if propagate: - self._propagate_tag_down(tag) + propagate = False + self._add_tag_binding( + internal_tag_binding( + tag, + topology=( + TopologyPropagation.DOWNWARD + if propagate + else TopologyPropagation.LOCAL + ), + ) + ) + + def _apply_user_tag( + self, + tag: str, + *, + topology_propagation: str | TopologyPropagation = TopologyPropagation.LOCAL, + lineage_policy: str | LineagePolicy = LineagePolicy.CONTINUATION_FRAGMENT, + producer_node_id: Optional[str] = None, + ) -> TagBinding: + binding = user_tag_binding( + normalize_tag(tag, strict=True), + node_id=producer_node_id, + topology=topology_propagation, + lineage=lineage_policy, + ) + self._add_tag_binding(binding) + return binding def _propagate_tag_down(self, tag: str) -> None: - if not isinstance(self, TopoMixein): - return - try: - children = self.get_children() - except Exception: - return - for child in children: - if isinstance(child, TaggedMixin): - child._tags.add(tag) - child._propagate_tag_down(tag) + # Inheritance is evaluated from ancestor bindings; no strings are copied. + return - def _remove_tag(self, tag: str) -> None: - self._tags.discard(tag) + def _remove_tag( + self, + tag: str, + *, + matching_producer: TagProducer | TagProducerKind | str = TagProducerKind.USER_OPERATION, + ) -> int: + normalized = normalize_tag(tag, strict=True) - def _has_tag(self, tag: str) -> bool: - return tag in self._tags + def matches(binding: TagBinding) -> bool: + if isinstance(matching_producer, TagProducer): + return binding.producer == matching_producer + try: + producer_kind = ( + matching_producer + if isinstance(matching_producer, TagProducerKind) + else TagProducerKind(matching_producer) + ) + except ValueError as exc: + raise ValueError( + f"unsupported matching producer '{matching_producer}'" + ) from exc + return binding.producer.kind == producer_kind - def _list_tags(self) -> list[str]: - return sorted(self._tags) + retained = [ + binding + for binding in self._tag_bindings + if not (binding.tag == normalized and matches(binding)) + ] + removed = len(self._tag_bindings) - len(retained) + self._tag_bindings[:] = retained + if removed: + self._refresh_tag_cache(recursive=True) + return removed + + def _remove_tag_binding(self, binding_id: str) -> bool: + if not isinstance(binding_id, str) or not binding_id: + raise ValueError("binding_id must be a non-empty string") + retained = [ + binding + for binding in self._tag_bindings + if binding.binding_id != binding_id + ] + removed = len(retained) != len(self._tag_bindings) + self._tag_bindings[:] = retained + if removed: + self._refresh_tag_cache(recursive=True) + return removed + + def _has_tag( + self, tag: str, scope: str | TagScope = TagScope.EFFECTIVE + ) -> bool: + return tag in self._list_tags(scope) + + def _local_tag_bindings(self) -> List[TagBinding]: + return [ + binding + for binding in self._tag_bindings + if binding.attachment == TagAttachment.LOCAL + ] + + def _legacy_effective_bindings(self) -> List[TagBinding]: + return [ + binding + for binding in self._tag_bindings + if binding.attachment == TagAttachment.EFFECTIVE_LEGACY + ] + + def _inherited_tag_bindings_with_paths( + self, + ) -> List[Tuple[TagBinding, Tuple[str, ...]]]: + result: List[Tuple[TagBinding, Tuple[str, ...]]] = [] + seen_bindings: Set[str] = set() + seen_objects: Set[int] = set() + wrappers = ( + self._entity.wrappers + if getattr(self, "_entity", None) is not None + else [self] + ) + queue: List[Tuple[Any, Tuple[str, ...]]] = [ + (parent, (self.topo_id, getattr(parent, "topo_id", str(id(parent))))) + for wrapper in wrappers + if isinstance(wrapper, TopoMixein) + for parent in wrapper.get_parents() + ] + while queue: + parent, path = queue.pop(0) + marker = id(parent) + if marker in seen_objects: + continue + seen_objects.add(marker) + if isinstance(parent, TaggedMixin): + for binding in parent._local_tag_bindings(): + if ( + binding.propagation.topology == TopologyPropagation.DOWNWARD + and binding.binding_id not in seen_bindings + ): + seen_bindings.add(binding.binding_id) + result.append((binding, tuple(reversed(path)))) + if isinstance(parent, TopoMixein): + for ancestor in parent.get_parents(): + queue.append( + ( + ancestor, + (*path, getattr(ancestor, "topo_id", str(id(ancestor)))), + ) + ) + return result + + def _inherited_tag_bindings(self) -> List[TagBinding]: + return [binding for binding, _path in self._inherited_tag_bindings_with_paths()] + + def _lineage_tag_bindings(self) -> List[TagBinding]: + coverage = self._runtime.get("semantic.lineage.coverage") + if coverage != "complete": + available = "none" if coverage is None else str(coverage) + raise UnsupportedQueryCapabilityError( + "lineage tag scope requires complete topology history; " + f"coverage={available}" + ) + if any(witness.coverage != "complete" for witness in self._tag_lineage): + raise UnsupportedQueryCapabilityError( + "lineage tag scope has incomplete lineage witnesses" + ) + result: List[TagBinding] = [] + seen: Set[str] = set() + for witness in self._tag_lineage: + binding = witness.binding + if ( + lineage_policy_allows(binding.propagation, witness.derivation) + and binding.binding_id not in seen + ): + seen.add(binding.binding_id) + result.append(binding) + return result + + def _list_tag_bindings( + self, scope: str | TagScope = TagScope.EFFECTIVE + ) -> List[TagBinding]: + resolved_scope = normalize_tag_scope(scope) + if ( + resolved_scope != TagScope.EFFECTIVE + and self._legacy_effective_bindings() + ): + raise UnsupportedQueryCapabilityError( + "legacy effective tag snapshots only support effective scope" + ) + if resolved_scope == TagScope.LOCAL: + return self._local_tag_bindings() + if resolved_scope == TagScope.INHERITED: + return self._inherited_tag_bindings() + if resolved_scope == TagScope.LINEAGE: + return self._lineage_tag_bindings() + + bindings = [ + *self._local_tag_bindings(), + *self._inherited_tag_bindings(), + *self._legacy_effective_bindings(), + ] + unique: Dict[str, TagBinding] = {} + for binding in bindings: + unique.setdefault(binding.binding_id, binding) + return list(unique.values()) + + def _list_tags( + self, scope: str | TagScope = TagScope.EFFECTIVE + ) -> list[str]: + return sorted({binding.tag for binding in self._list_tag_bindings(scope)}) + + def _explain_tag( + self, tag: str, scope: str | TagScope = TagScope.EFFECTIVE + ) -> List[Dict[str, Any]]: + normalized = normalize_tag(tag, strict=True) + resolved_scope = normalize_tag_scope(scope) + inherited_paths = { + binding.binding_id: path + for binding, path in self._inherited_tag_bindings_with_paths() + } + explanations: List[Dict[str, Any]] = [] + for binding in self._list_tag_bindings(resolved_scope): + if binding.tag != normalized: + continue + attachment = binding.attachment.value + explanation: Dict[str, Any] = { + "scope": resolved_scope.value, + "binding_id": binding.binding_id, + "producer": binding.producer.to_dict(), + "attachment": attachment, + "binding": binding.to_dict(), + } + if binding.binding_id in inherited_paths: + explanation["attachment"] = TagAttachment.INHERITED.value + explanation["topology_path"] = list( + inherited_paths[binding.binding_id] + ) + witnesses = [ + witness + for witness in self._tag_lineage + if witness.binding.binding_id == binding.binding_id + and lineage_policy_allows(binding.propagation, witness.derivation) + ] + if witnesses: + explanation["lineage"] = [ + { + "derivation": witness.derivation.value, + "source_topo_id": witness.source_topo_id, + "target_topo_id": witness.target_topo_id, + "coverage": witness.coverage, + "evidence": witness.evidence.to_dict(), + } + for witness in witnesses + ] + explanations.append(explanation) + return explanations def set_metadata(self, key: str, value: Any) -> None: self._metadata[key] = value @@ -417,8 +846,9 @@ class TaggedMixin: def _format_tags_and_metadata(self, indent: int = 0) -> str: spaces = " " * indent result = [] - if self._tags: - result.append(f"{spaces}tags: [{', '.join(sorted(self._tags))}]") + tags = self._list_tags() + if tags: + result.append(f"{spaces}tags: [{', '.join(tags)}]") if self._metadata: result.append(f"{spaces}metadata:") for key, value in sorted(self._metadata.items()): @@ -448,6 +878,8 @@ class TopoMixein: if child not in self.children: self.children.append(child) child.set_parent(self.self_shape_ref) + if isinstance(child, TaggedMixin): + child._refresh_tag_cache(recursive=True) def get_children(self) -> List["AnyShape"]: return self.children @@ -652,11 +1084,7 @@ class Wire(TaggedMixin, TopoMixein): raise ValueError(f"检查线闭合性失败: {e}") def _tag_edges(self) -> None: - policy = DEFAULT_TAG_POLICY for i, edge in enumerate(self.get_edges()): - for tag in self._list_tags(): - if policy.should_propagate(tag): - edge._add_tag(tag) edge._apply_tag("edge.boundary", propagate=False) geo = dict(edge.get_metadata("geo", {})) geo["edge_index"] = i @@ -719,17 +1147,10 @@ class Face(TaggedMixin, TopoMixein): raise ValueError(f"获取法向量失败: {e}") def _tag_wires(self) -> None: - policy = DEFAULT_TAG_POLICY outer_wire = self.get_outer_wire() - for tag in self._list_tags(): - if policy.should_propagate(tag): - outer_wire._add_tag(tag) outer_wire._apply_tag("wire.outer", propagate=False) outer_wire._tag_edges() for i, inner in enumerate(self.get_inner_wires()): - for tag in self._list_tags(): - if policy.should_propagate(tag): - inner._add_tag(tag) inner._apply_tag("wire.inner", propagate=False) geo = dict(inner.get_metadata("geo", {})) geo["inner_wire_index"] = i @@ -1053,3 +1474,41 @@ class Compound(TaggedMixin, TopoMixein): AnyShape = Union[Vertex, Edge, Wire, Face, Solid, Compound] + + +def clone_semantic_shape_view(shape: AnyShape) -> AnyShape: + """Create an independent semantic view over the same kernel geometry.""" + + if not isinstance(shape, (Vertex, Edge, Wire, Face, Solid, Compound)): + raise TypeError("shape must be a SimpleCAD topology object") + + clone = type(shape)(shape.wrapped) + source_entities = shape._topology_cache.entities() + matched_source_ids: Set[str] = set() + for target_entity in clone._topology_cache.entities(): + matches = [] + for source_entity in source_entities: + if source_entity.kind != target_entity.kind: + continue + try: + if source_entity.representative.IsSame(target_entity.representative): + matches.append(source_entity) + except Exception: + continue + if len(matches) != 1: + raise ValueError( + "semantic shape view topology does not map uniquely to its source" + ) + source_entity = matches[0] + if source_entity.topo_id in matched_source_ids: + raise ValueError("semantic shape view contains a duplicate topology entity") + matched_source_ids.add(source_entity.topo_id) + target_entity.topo_id = source_entity.topo_id + _copy_entity_state(source_entity, target_entity) + + clone._topology_cache._entities_by_id = { + entity.topo_id: entity for entity in clone._topology_cache.entities() + } + + clone._refresh_tag_cache(recursive=True) + return cast(AnyShape, clone) diff --git a/SimpleCADAPI/src/simplecadapi/expr.py b/SimpleCADAPI/src/simplecadapi/expr.py index 9f1db55..55e7d42 100644 --- a/SimpleCADAPI/src/simplecadapi/expr.py +++ b/SimpleCADAPI/src/simplecadapi/expr.py @@ -13,14 +13,133 @@ from __future__ import annotations from dataclasses import dataclass, field import math -from typing import Any, Dict, List, Mapping, Tuple, Union, cast +from numbers import Real +from typing import Any, Dict, List, Mapping, Sequence, Tuple, Union, cast import uuid +from .units import ( + Dimension, + Unit, + UnitLike, + get_unit, + unit_from_payload, + unit_to_payload, +) + def _make_expr_id(prefix: str = "expr") -> str: return f"{prefix}_{uuid.uuid4().hex[:8]}" +def _finite_scalar(value: Any, *, label: str) -> float: + if isinstance(value, bool) or not isinstance(value, Real): + raise TypeError(f"{label} must be a real number") + try: + result = float(value) + except OverflowError as exc: + raise ValueError(f"{label} must be finite") from exc + if not math.isfinite(result): + raise ValueError(f"{label} must be finite") + return result + + +@dataclass(frozen=True) +class DimensionTolerance: + """Permitted lower and upper deviations from a nominal dimension. + + Deviations are signed: the lower deviation must be less than or equal to + zero and the upper deviation must be greater than or equal to zero. + """ + + lower_deviation: float + upper_deviation: float + + def __post_init__(self) -> None: + lower = _finite_scalar(self.lower_deviation, label="lower_deviation") + upper = _finite_scalar(self.upper_deviation, label="upper_deviation") + if lower > 0.0: + raise ValueError("lower_deviation must be less than or equal to zero") + if upper < 0.0: + raise ValueError("upper_deviation must be greater than or equal to zero") + object.__setattr__(self, "lower_deviation", lower) + object.__setattr__(self, "upper_deviation", upper) + + @classmethod + def symmetric(cls, deviation: int | float) -> "DimensionTolerance": + """Create a symmetric ``+/- deviation`` tolerance.""" + + value = _finite_scalar(deviation, label="deviation") + if value < 0.0: + raise ValueError("deviation must be greater than or equal to zero") + return cls(lower_deviation=-value, upper_deviation=value) + + @property + def width(self) -> float: + return self.upper_deviation - self.lower_deviation + + def to_dict(self) -> Dict[str, float]: + return { + "lower_deviation": self.lower_deviation, + "upper_deviation": self.upper_deviation, + } + + @classmethod + def from_dict(cls, data: Mapping[str, Any]) -> "DimensionTolerance": + if not isinstance(data, Mapping): + raise TypeError("Dimension tolerance payload must be an object") + if "lower_deviation" not in data or "upper_deviation" not in data: + raise ValueError( + "Dimension tolerance payload requires lower_deviation and upper_deviation" + ) + return cls( + lower_deviation=data["lower_deviation"], + upper_deviation=data["upper_deviation"], + ) + + +ToleranceLike = Union[ + int, + float, + Sequence[int | float], + DimensionTolerance, +] + + +def coerce_dimension_tolerance(value: ToleranceLike) -> DimensionTolerance: + """Normalize a symmetric scalar or ``(lower, upper)`` deviation pair.""" + + if isinstance(value, DimensionTolerance): + return value + if isinstance(value, bool): + raise TypeError("Tolerance must be a real number or a two-value sequence") + if isinstance(value, Real): + return DimensionTolerance.symmetric(value) + if isinstance(value, Sequence) and not isinstance(value, (str, bytes)): + if len(value) != 2: + raise ValueError("Asymmetric tolerance must contain exactly two deviations") + return DimensionTolerance(value[0], value[1]) + raise TypeError("Tolerance must be a real number or a two-value sequence") + + +_EXPR_OP_ARITY = { + "add": 2, + "sub": 2, + "mul": 2, + "div": 2, + "pow": 2, + "neg": 1, + "abs": 1, + "sin": 1, + "cos": 1, + "tan": 1, + "sqrt": 1, + "acos": 1, + "asin": 1, + "atan": 1, + "atan2": 2, +} + + class ScalarExprBase: """Base class for scalar expression nodes.""" @@ -87,23 +206,94 @@ class Const(ScalarExprBase): value: float expr_id: str = field(default_factory=lambda: _make_expr_id("const")) + def __post_init__(self) -> None: + object.__setattr__(self, "value", _finite_scalar(self.value, label="value")) + if not isinstance(self.expr_id, str) or not self.expr_id: + raise ValueError("Expression id must be a non-empty string") + def evaluate(self, bindings: Mapping[str, float] | None = None) -> float: return float(self.value) @dataclass(frozen=True) class Var(ScalarExprBase): - """Named scalar parameter with a default fallback value.""" + """Named scalar parameter with optional physical-unit and tolerance intent. + + ``default`` and ``tolerance`` remain in their declared units. Evaluation, + geometry parameters, and tolerance propagation convert them to SimpleCAD's + canonical CAD units: millimeters for length and degrees for angle. + """ name: str default: float comment: str | None = None expr_id: str = field(default_factory=lambda: _make_expr_id("var")) + tolerance: DimensionTolerance | None = None + unit: Unit | None = None + tolerance_unit: Unit | None = None + + def __post_init__(self) -> None: + if not isinstance(self.name, str) or not self.name: + raise ValueError("Variable name must be a non-empty string") + object.__setattr__( + self, "default", _finite_scalar(self.default, label="Variable default") + ) + if self.comment is not None and not isinstance(self.comment, str): + raise ValueError("Variable comment must be a string when provided") + if self.tolerance is not None and not isinstance( + self.tolerance, DimensionTolerance + ): + raise TypeError("Variable tolerance must be a DimensionTolerance") + if self.unit is not None and not isinstance(self.unit, Unit): + object.__setattr__(self, "unit", get_unit(self.unit)) + if self.tolerance_unit is not None and not isinstance( + self.tolerance_unit, Unit + ): + object.__setattr__( + self, "tolerance_unit", get_unit(self.tolerance_unit) + ) + if self.unit is None and self.tolerance_unit is not None: + raise ValueError("A tolerance unit requires a nominal variable unit") + if self.tolerance is None and self.tolerance_unit is not None: + raise ValueError("A tolerance unit requires a declared tolerance") + if self.tolerance is not None and self.unit is not None: + if self.tolerance_unit is None: + object.__setattr__(self, "tolerance_unit", self.unit) + elif self.tolerance_unit.dimension != self.unit.dimension: + raise ValueError( + "Variable tolerance unit must have the same dimension as its nominal unit" + ) + if self.unit is not None: + self.unit.to_canonical(self.default) + if self.canonical_tolerance is not None: + self.canonical_tolerance.width + if not isinstance(self.expr_id, str) or not self.expr_id: + raise ValueError("Expression id must be a non-empty string") + + @property + def dimension(self) -> Dimension | None: + return None if self.unit is None else self.unit.dimension + + @property + def canonical_default(self) -> float: + return self.default if self.unit is None else self.unit.to_canonical(self.default) + + @property + def canonical_tolerance(self) -> DimensionTolerance | None: + if self.tolerance is None: + return None + if self.tolerance_unit is None: + return self.tolerance + return DimensionTolerance( + self.tolerance_unit.to_canonical(self.tolerance.lower_deviation), + self.tolerance_unit.to_canonical(self.tolerance.upper_deviation), + ) def evaluate(self, bindings: Mapping[str, float] | None = None) -> float: if bindings is not None and self.name in bindings: - return float(bindings[self.name]) - return float(self.default) + value = _finite_scalar(bindings[self.name], label=f"Binding '{self.name}'") + return value if self.unit is None else self.unit.to_canonical(value) + return self.canonical_default @dataclass(frozen=True) @@ -114,7 +304,27 @@ class Expr(ScalarExprBase): args: Tuple[ScalarExpr, ...] expr_id: str = field(default_factory=lambda: _make_expr_id("expr")) + def __post_init__(self) -> None: + if not isinstance(self.args, tuple): + object.__setattr__(self, "args", tuple(self.args)) + if not isinstance(self.op, str): + raise TypeError("Expression op must be a string") + if self.op not in _EXPR_OP_ARITY: + raise ValueError(f"Unsupported expression op '{self.op}'") + if len(self.args) != _EXPR_OP_ARITY[self.op]: + raise ValueError( + f"Expression op '{self.op}' expects {_EXPR_OP_ARITY[self.op]} arguments, " + f"got {len(self.args)}" + ) + if not all(isinstance(arg, (Const, Var, Expr)) for arg in self.args): + raise TypeError("Expression arguments must be scalar expression nodes") + if not isinstance(self.expr_id, str) or not self.expr_id: + raise ValueError("Expression id must be a non-empty string") + def evaluate(self, bindings: Mapping[str, float] | None = None) -> float: + from .units import infer_dimension + + infer_dimension(self) values = [arg.evaluate(bindings) for arg in self.args] if self.op == "add": return values[0] + values[1] @@ -131,21 +341,42 @@ class Expr(ScalarExprBase): if self.op == "abs": return abs(values[0]) if self.op == "sin": - return math.sin(values[0]) + from .units import expression_uses_units + + value = math.radians(values[0]) if expression_uses_units(self) else values[0] + return math.sin(value) if self.op == "cos": - return math.cos(values[0]) + from .units import expression_uses_units + + value = math.radians(values[0]) if expression_uses_units(self) else values[0] + return math.cos(value) if self.op == "tan": - return math.tan(values[0]) + from .units import expression_uses_units + + value = math.radians(values[0]) if expression_uses_units(self) else values[0] + return math.tan(value) if self.op == "sqrt": return math.sqrt(values[0]) if self.op == "acos": - return math.acos(values[0]) + from .units import expression_uses_units + + result = math.acos(values[0]) + return math.degrees(result) if expression_uses_units(self) else result if self.op == "asin": - return math.asin(values[0]) + from .units import expression_uses_units + + result = math.asin(values[0]) + return math.degrees(result) if expression_uses_units(self) else result if self.op == "atan": - return math.atan(values[0]) + from .units import expression_uses_units + + result = math.atan(values[0]) + return math.degrees(result) if expression_uses_units(self) else result if self.op == "atan2": - return math.atan2(values[0], values[1]) + from .units import expression_uses_units + + result = math.atan2(values[0], values[1]) + return math.degrees(result) if expression_uses_units(self) else result raise ValueError(f"Unsupported expression op '{self.op}'") @@ -156,17 +387,46 @@ ScalarLike = Union[int, float, ScalarExpr] def const(value: int | float) -> Const: """Create a constant scalar node for parameterized modeling.""" - return Const(float(value)) + return Const(_finite_scalar(value, label="Constant value")) -def var(name: str, default: int | float, comment: str | None = None) -> Var: - """Create a named variable node for v2 expression-driven parameters.""" +def var( + name: str, + default: int | float, + comment: str | None = None, + tolerance: ToleranceLike | None = None, + *, + unit: UnitLike | None = None, + tolerance_unit: UnitLike | None = None, +) -> Var: + """Create a physical or legacy scalar variable. + + ``tolerance=0.1`` declares a symmetric ``+/-0.1`` tolerance. Use a + ``(lower_deviation, upper_deviation)`` pair for an asymmetric tolerance. + ``tolerance_unit`` defaults to ``unit`` when a nominal unit is declared. + Values are converted to canonical CAD units only when evaluated, so the + declaration and serialized expression node preserve the user's units. + + Variables without ``unit`` retain legacy unitless behavior. A unit-aware + expression cannot mix declared-unit variables with legacy variables. + """ if not isinstance(name, str) or not name: raise ValueError("Variable name must be a non-empty string") if comment is not None and not isinstance(comment, str): raise ValueError("Variable comment must be a string when provided") - return Var(name=name, default=float(default), comment=comment) + return Var( + name=name, + default=_finite_scalar(default, label="Variable default"), + comment=comment, + tolerance=( + coerce_dimension_tolerance(tolerance) if tolerance is not None else None + ), + unit=get_unit(unit) if unit is not None else None, + tolerance_unit=( + get_unit(tolerance_unit) if tolerance_unit is not None else None + ), + ) def lift_scalar(value: ScalarLike) -> ScalarExpr: @@ -182,7 +442,11 @@ def lift_scalar(value: ScalarLike) -> ScalarExpr: def evaluate_scalar( value: ScalarLike, bindings: Mapping[str, float] | None = None ) -> float: - return float(lift_scalar(value).evaluate(bindings)) + expr = lift_scalar(value) + from .units import infer_dimension + + infer_dimension(expr) + return float(expr.evaluate(bindings)) def sin(value: ScalarLike) -> Expr: @@ -247,6 +511,12 @@ def _expr_to_node_payload(expr: ScalarExpr) -> Dict[str, Any]: } if expr.comment: payload["comment"] = expr.comment + if expr.tolerance is not None: + payload["tolerance"] = expr.tolerance.to_dict() + if expr.unit is not None: + payload["unit"] = unit_to_payload(expr.unit) + if expr.tolerance_unit is not None: + payload["tolerance_unit"] = unit_to_payload(expr.tolerance_unit) return payload return { "expr_id": expr.expr_id, @@ -256,6 +526,44 @@ def _expr_to_node_payload(expr: ScalarExpr) -> Dict[str, Any]: } +def _expr_signature(expr: ScalarExpr) -> Tuple[Any, ...]: + if isinstance(expr, Const): + return ("const", expr.value.hex()) + if isinstance(expr, Var): + tolerance = ( + None + if expr.tolerance is None + else ( + expr.tolerance.lower_deviation.hex(), + expr.tolerance.upper_deviation.hex(), + ) + ) + return ( + "var", + expr.name, + expr.default.hex(), + expr.comment, + tolerance, + None + if expr.unit is None + else ( + expr.unit.symbol, + expr.unit.dimension.length, + expr.unit.dimension.angle, + expr.unit.scale_to_canonical.hex(), + ), + None + if expr.tolerance_unit is None + else ( + expr.tolerance_unit.symbol, + expr.tolerance_unit.dimension.length, + expr.tolerance_unit.dimension.angle, + expr.tolerance_unit.scale_to_canonical.hex(), + ), + ) + return ("expr", expr.op, tuple(arg.expr_id for arg in expr.args)) + + class ExpressionGraph: """A lightweight registry of expression DAG nodes.""" @@ -271,27 +579,59 @@ class ExpressionGraph: def register(self, value: ScalarLike) -> ScalarExpr: expr = lift_scalar(value) - self._register_recursive(expr) - return expr + staged: Dict[str, ScalarExpr] = {} + visiting: set[str] = set() + validated_objects: set[int] = set() - def _register_recursive(self, expr: ScalarExpr) -> None: - if expr.expr_id in self._nodes: - return - if isinstance(expr, Expr): - for arg in expr.args: - self._register_recursive(arg) - self._nodes[expr.expr_id] = expr + def stage(node: ScalarExpr) -> None: + if node.expr_id in visiting: + raise ValueError( + f"Expression graph contains a cycle at '{node.expr_id}'" + ) + existing = staged.get(node.expr_id) + if existing is None: + existing = self._nodes.get(node.expr_id) + if existing is not None: + if _expr_signature(existing) != _expr_signature(node): + raise ValueError( + f"Expression id '{node.expr_id}' is already registered by a different node" + ) + else: + staged[node.expr_id] = node + object_id = id(node) + if object_id in validated_objects: + return + visiting.add(node.expr_id) + if isinstance(node, Expr): + for arg in node.args: + stage(arg) + visiting.remove(node.expr_id) + validated_objects.add(object_id) + + stage(expr) + from .units import infer_dimension + + infer_dimension(expr) + self._nodes.update(staged) + return expr def _topological_expr_ids(self) -> List[str]: ordered: List[str] = [] seen: set[str] = set() + visiting: set[str] = set() def visit(expr: ScalarExpr) -> None: if expr.expr_id in seen: return + if expr.expr_id in visiting: + raise ValueError( + f"Expression graph contains a cycle at '{expr.expr_id}'" + ) + visiting.add(expr.expr_id) if isinstance(expr, Expr): for arg in expr.args: visit(arg) + visiting.remove(expr.expr_id) seen.add(expr.expr_id) ordered.append(expr.expr_id) @@ -309,27 +649,102 @@ class ExpressionGraph: @classmethod def from_dict(cls, data: Dict[str, Any]) -> "ExpressionGraph": + if not isinstance(data, dict): + raise TypeError("Expression graph payload must be an object") + raw_nodes = data.get("nodes", []) + if not isinstance(raw_nodes, list): + raise TypeError("Expression graph 'nodes' must be an array") + graph = cls() + payload_by_id: Dict[str, Dict[str, Any]] = {} + for node in raw_nodes: + if not isinstance(node, dict): + raise TypeError("Expression graph nodes must be objects") + if "expr_id" not in node: + raise ValueError("Expression graph node is missing 'expr_id'") + expr_id_value = node["expr_id"] + if not isinstance(expr_id_value, str) or not expr_id_value: + raise ValueError("Expression graph node id must be non-empty") + expr_id = expr_id_value + if expr_id in payload_by_id: + raise ValueError(f"Duplicate expression node id '{expr_id}'") + payload_by_id[expr_id] = node + node_map: Dict[str, ScalarExpr] = {} - for node in data.get("nodes", []): + visiting: set[str] = set() + + def build(expr_id: str) -> ScalarExpr: + if expr_id in node_map: + return node_map[expr_id] + if expr_id in visiting: + raise ValueError(f"Expression graph contains a cycle at '{expr_id}'") + node = payload_by_id.get(expr_id) + if node is None: + raise ValueError(f"Unknown expression dependency '{expr_id}'") + visiting.add(expr_id) kind = node.get("kind") - expr_id = str(node["expr_id"]) if kind == "const": - expr = Const(value=float(node["value"]), expr_id=expr_id) + if "value" not in node: + raise ValueError( + f"Constant expression node '{expr_id}' is missing 'value'" + ) + expr = Const(value=node["value"], expr_id=expr_id) elif kind == "var": + if "name" not in node or "default" not in node: + raise ValueError( + f"Variable expression node '{expr_id}' requires 'name' and 'default'" + ) + tolerance_payload = node.get("tolerance") expr = Var( - name=str(node["name"]), - default=float(node["default"]), - comment=str(node["comment"]) if node.get("comment") else None, + name=node["name"], + default=node["default"], + comment=node.get("comment"), + tolerance=( + DimensionTolerance.from_dict(tolerance_payload) + if tolerance_payload is not None + else None + ), + unit=( + unit_from_payload(node["unit"]) + if "unit" in node + else None + ), + tolerance_unit=( + unit_from_payload(node["tolerance_unit"]) + if "tolerance_unit" in node + else None + ), expr_id=expr_id, ) elif kind == "expr": - args = tuple(node_map[arg_id] for arg_id in node.get("args", [])) - expr = Expr(op=str(node["op"]), args=args, expr_id=expr_id) + if "op" not in node: + raise ValueError( + f"Derived expression node '{expr_id}' is missing 'op'" + ) + raw_args = node.get("args", []) + if not isinstance(raw_args, list): + raise TypeError( + f"Expression node '{expr_id}' field 'args' must be an array" + ) + if not all(isinstance(arg_id, str) for arg_id in raw_args): + raise TypeError( + f"Expression node '{expr_id}' dependencies must be string ids" + ) + args = tuple(build(arg_id) for arg_id in raw_args) + expr = Expr(op=node["op"], args=args, expr_id=expr_id) else: raise ValueError(f"Unknown expression node kind: {kind!r}") + visiting.remove(expr_id) graph._nodes[expr_id] = expr node_map[expr_id] = expr + return expr + + for expr_id in payload_by_id: + build(expr_id) + from .units import infer_dimension + + for expr in node_map.values(): + infer_dimension(expr) return graph @@ -392,6 +807,7 @@ def _is_discrete_param_name(key: str) -> bool: "multiplicities", "output_count", "periodic", + "tag_binding", } diff --git a/SimpleCADAPI/src/simplecadapi/graph.py b/SimpleCADAPI/src/simplecadapi/graph.py index 0120434..d42fce1 100644 --- a/SimpleCADAPI/src/simplecadapi/graph.py +++ b/SimpleCADAPI/src/simplecadapi/graph.py @@ -24,14 +24,48 @@ from __future__ import annotations from contextlib import contextmanager from contextvars import ContextVar, Token -from typing import Any, Dict, Iterable, List, Optional, Set +from dataclasses import dataclass, field, fields, is_dataclass +from functools import wraps +import inspect +from typing import ( + Any, + Callable, + Dict, + Iterable, + List, + Mapping, + ParamSpec, + Optional, + Set, + Tuple, + TypeVar, + Union, +) +import uuid +from pathlib import Path -from .expr import ExpressionGraph, canonicalize_params +from .expr import ExpressionGraph, ScalarLike, ToleranceLike, canonicalize_params +from .units import UnitLike from .frame import FrameGraph -from .topology import OperationGraph, OperationNode, TopoDelta +from .tolerance import ( + ToleranceGraph, + ToleranceMethod, + ToleranceReport, + ToleranceRequirement, +) +from .topology import ( + OperationGraph, + OperationNode, + TopoDelta, + TopoEntry, + TopoEvent, + TopoRoleEntry, +) from .topology import SemanticDelta from .topology import TopoKind, TopoRef, topo_ref_to_dict from .core import Compound, Edge, Face, Solid, Vertex, Wire, get_current_cs +from .product import Assembly, Part +from .source_mapping import capture_source_provenance # --------------------------------------------------------------------------- @@ -45,6 +79,9 @@ _recording_suspend_depth_var: ContextVar[int] = ContextVar( "simplecadapi_recording_suspend_depth", default=0 ) +_P = ParamSpec("_P") +_R = TypeVar("_R") + class GraphSession: """Context manager that records CAD operations into a DAG. @@ -69,8 +106,12 @@ class GraphSession: def __init__(self, graph_id: Optional[str] = None) -> None: self.graph = OperationGraph(graph_id=graph_id) self.expression_graph = ExpressionGraph() + self.tolerance_graph = ToleranceGraph(self.expression_graph) self.frame_graph = FrameGraph() self._active_session_token: Optional[Token[Optional["GraphSession"]]] = None + self._result_node_ids: List[str] = [] + self._has_explicit_results = False + self._captured_values: List[Any] = [] def start(self) -> None: if self._active_session_token is not None: @@ -89,6 +130,152 @@ class GraphSession: def __exit__(self, exc_type, exc_val, exc_tb) -> None: self.stop() + def require_tolerance( + self, + value: ScalarLike, + tolerance: ToleranceLike, + *, + method: ToleranceMethod = "worst_case", + name: str | None = None, + requirement_id: str | None = None, + tolerance_unit: UnitLike | None = None, + ) -> ToleranceRequirement: + """Declare a persisted Length or Angle manufacturing requirement. + + ``tolerance_unit`` defaults to the target's canonical unit for + unit-aware expressions. The requirement is validated immediately and at + session/model export, import, replay, and translation boundaries. + """ + + return self.tolerance_graph.require( + value, + tolerance, + method=method, + name=name, + requirement_id=requirement_id, + tolerance_unit=tolerance_unit, + ) + + def validate_tolerances( + self, *, raise_on_failure: bool = False + ) -> ToleranceReport: + """Validate every declared dimension-chain requirement.""" + + return self.tolerance_graph.validate(raise_on_failure=raise_on_failure) + + @property + def result_node_ids(self) -> Tuple[str, ...]: + """Return the explicitly captured model result node ids.""" + + return tuple(self._result_node_ids) + + @property + def has_explicit_results(self) -> bool: + """Whether ``capture_result`` has been called for this session.""" + + return self._has_explicit_results + + @property + def captured_values(self) -> Tuple[Any, ...]: + """Return values explicitly marked for final artifact export.""" + + return tuple(self._captured_values) + + def capture_result(self, *, value: Any) -> Any: + """Capture graph nodes directly represented by *value* as results.""" + + self.validate_graph_ownership(value) + nodes = list(_graph_nodes_in_value(value, deep=True)) + if not nodes: + raise ValueError( + "capture_result() requires a value containing at least one " + "graph-backed shape or semantic value" + ) + captured_ids: List[str] = [] + for node in nodes: + if node.graph_id not in {None, self.graph.graph_id}: + raise ValueError( + f"result node '{node.node_id}' belongs to graph " + f"'{node.graph_id}', active graph is '{self.graph.graph_id}'" + ) + if self.graph.get_node(node.node_id) is not node: + raise ValueError( + f"result node '{node.node_id}' is not owned by graph " + f"'{self.graph.graph_id}'" + ) + if node.node_id not in captured_ids: + captured_ids.append(node.node_id) + self._has_explicit_results = True + for node_id in captured_ids: + if node_id not in self._result_node_ids: + self._result_node_ids.append(node_id) + self._captured_values.append(value) + return value + + def clear_results(self) -> None: + """Clear explicit result nodes and restore leaf fallback on export.""" + + self._result_node_ids.clear() + self._has_explicit_results = False + self._captured_values.clear() + + def validate_graph_ownership(self, value: Any) -> None: + """Reject values carrying lineage from a different graph.""" + + for item in _walk_values(value, set(), deep=True): + found = _graph_node_and_id(item) + if found is not None: + node, source_graph_id = found + if source_graph_id not in {None, self.graph.graph_id}: + raise ValueError( + f"value contains graph node '{node.node_id}' from graph " + f"'{source_graph_id}', active graph is '{self.graph.graph_id}'" + ) + if self.graph.get_node(node.node_id) is not node: + raise ValueError( + f"value contains graph node '{node.node_id}' not owned by " + f"active graph '{self.graph.graph_id}'" + ) + getter = getattr(item, "_get_runtime", None) + topo_ref = getter("topo.ref") if callable(getter) else None + if not isinstance(topo_ref, TopoRef): + continue + if topo_ref.graph_id != self.graph.graph_id: + raise ValueError( + f"value contains topology reference '{topo_ref.topo_id}' from " + f"graph '{topo_ref.graph_id}', active graph is " + f"'{self.graph.graph_id}'" + ) + if self.graph.get_node(topo_ref.node_id) is None: + raise ValueError( + f"value contains topology reference '{topo_ref.topo_id}' for " + f"unknown node '{topo_ref.node_id}'" + ) + + +@dataclass(frozen=True) +class ModelResult: + """The value and durable graph artifacts produced by ``@model``.""" + + value: Any + session: GraphSession + result_node_ids: Tuple[str, ...] + model_json: str + session_json: str + artifact_paths: Mapping[str, Path] = field(default_factory=dict) + + def replay(self, *, strict: bool = True) -> List[Any]: + """Replay the captured canonical model result.""" + + from .serializer import replay_model_json + + return replay_model_json(json_str=self.model_json, strict=strict) + + def export_artifacts(self, *, output_dir: str | Path) -> "ModelResult": + """Write one self-contained Scene ZIP for the captured model.""" + + return _export_model_artifacts(self, output_dir=output_dir) + def get_active_session() -> Optional[GraphSession]: """Return the currently active GraphSession, or None.""" @@ -106,6 +293,233 @@ def suspend_graph_recording(): _recording_suspend_depth_var.reset(token) +def _graph_node_and_id(value: Any) -> Optional[Tuple[OperationNode, Optional[str]]]: + getter = getattr(value, "_get_runtime", None) + if not callable(getter): + return None + node = getter("graph.node") + if not isinstance(node, OperationNode): + return None + graph_id = node.graph_id + get_metadata = getattr(value, "get_metadata", None) + graph_payload = get_metadata("graph", None) if callable(get_metadata) else None + if graph_id is None and isinstance(graph_payload, dict): + raw_graph_id = graph_payload.get("graph_id") + graph_id = str(raw_graph_id) if raw_graph_id else None + return node, graph_id + + +def _walk_values(value: Any, seen: Set[int], *, deep: bool) -> Iterable[Any]: + if value is None or isinstance(value, (str, bytes, int, float, bool)): + return + value_id = id(value) + if value_id in seen: + return + seen.add(value_id) + yield value + if _graph_node_and_id(value) is not None: + return + if isinstance(value, dict): + for key, item in value.items(): + yield from _walk_values(key, seen, deep=deep) + yield from _walk_values(item, seen, deep=deep) + elif isinstance(value, (list, tuple, set, frozenset)): + for item in value: + yield from _walk_values(item, seen, deep=deep) + elif deep and is_dataclass(value) and not isinstance(value, OperationNode): + for data_field in fields(value): + if not data_field.name.startswith("_"): + yield from _walk_values( + getattr(value, data_field.name), seen, deep=True + ) + + +def _graph_nodes_with_ids( + value: Any, *, deep: bool = False +) -> Iterable[Tuple[OperationNode, Optional[str]]]: + seen_nodes: Set[Tuple[Optional[str], str]] = set() + for item in _walk_values(value, set(), deep=deep): + found = _graph_node_and_id(item) + if found is None: + continue + node, graph_id = found + node_key = (graph_id, node.node_id) + if node_key in seen_nodes: + continue + seen_nodes.add(node_key) + yield node, graph_id + + +def _graph_nodes_in_value( + value: Any, *, deep: bool = False +) -> Iterable[OperationNode]: + for node, _graph_id in _graph_nodes_with_ids(value, deep=deep): + yield node + + +def capture_result(*, value: Any) -> Any: + """Capture *value* as an explicit result in the active model session.""" + + session = get_active_session() + if session is None: + raise RuntimeError( + "No active GraphSession. capture_result() must be called inside " + "@model or an active GraphSession." + ) + return session.capture_result(value=value) + + +def model( + func: Optional[Callable[_P, _R]] = None, + *, + graph_id: Optional[str] = None, + export_dir: str | Path | None = None, +) -> Union[ + Callable[[Callable[_P, _R]], Callable[_P, ModelResult]], + Callable[_P, ModelResult], +]: + """Decorate a top-level model function with one owned ``GraphSession``.""" + + def decorate(fn: Callable[_P, _R]) -> Callable[_P, ModelResult]: + if inspect.iscoroutinefunction(fn): + raise TypeError( + "@model does not support async functions; keep CAD model " + "construction synchronous" + ) + + @wraps(fn) + def wrapped(*args: _P.args, **kwargs: _P.kwargs) -> ModelResult: + active = get_active_session() + if active is not None: + raise RuntimeError( + "@model cannot be nested inside an active GraphSession; " + "use @requires_session for child builders." + ) + session = GraphSession(graph_id=graph_id) + with session: + value = fn(*args, **kwargs) + session.validate_graph_ownership(value) + if not session.has_explicit_results: + session.capture_result(value=value) + from .serializer import export_model_json, export_session_json + + result_node_ids = session.result_node_ids + model_json = export_model_json( + session=session, result_node_ids=result_node_ids + ) + session_json = export_session_json(session=session) + result = ModelResult( + value=value, + session=session, + result_node_ids=result_node_ids, + model_json=model_json, + session_json=session_json, + ) + return result.export_artifacts(output_dir=export_dir) if export_dir is not None else result + + return wrapped + + if func is None: + return decorate + return decorate(func) + + +def _export_model_artifacts(result: ModelResult, *, output_dir: str | Path) -> ModelResult: + from .scene import SceneCompileOptions, SceneRoot, compile_scene, export_scene + + destination = Path(output_dir) + destination.mkdir(parents=True, exist_ok=True) + stem = result.session.graph.graph_id + values = _captured_export_values(result.session.captured_values) + products = [value for value in values if isinstance(value, (Part, Assembly))] + shapes = [value for value in values if isinstance(value, (Solid, Compound))] + scene_values = products or shapes + paths: Dict[str, Path] = {} + if scene_values: + roots = tuple( + SceneRoot(root_id=f"capture-{index}", value=value) + for index, value in enumerate(scene_values) + ) + package = compile_scene( + scene_id=stem, + roots=roots, + source=result, + options=SceneCompileOptions(embed_source=True), + ) + scene_path = destination / f"{stem}.scene.zip" + export_scene(package=package, path=scene_path) + paths["scene"] = scene_path + return ModelResult( + value=result.value, + session=result.session, + result_node_ids=result.result_node_ids, + model_json=result.model_json, + session_json=result.session_json, + artifact_paths=paths, + ) + + +def _captured_export_values(values: Iterable[Any]) -> List[Any]: + result: List[Any] = [] + seen: Set[int] = set() + + def visit(value: Any) -> None: + if value is None or id(value) in seen: + return + if isinstance(value, (str, bytes, int, float, bool)): + return + seen.add(id(value)) + if isinstance(value, (Part, Assembly, Solid, Compound)): + result.append(value) + return + if isinstance(value, (list, tuple, set, frozenset)): + for item in value: + visit(item) + return + if is_dataclass(value): + for data_field in fields(value): + if not data_field.name.startswith("_"): + visit(getattr(value, data_field.name)) + + for value in values: + visit(value) + return result + + +def requires_session( + func: Optional[Callable[_P, _R]] = None, +) -> Union[ + Callable[[Callable[_P, _R]], Callable[_P, _R]], + Callable[_P, _R], +]: + """Decorate a builder that must reuse the caller's active GraphSession.""" + + def decorate(fn: Callable[_P, _R]) -> Callable[_P, _R]: + if inspect.iscoroutinefunction(fn): + raise TypeError( + "@requires_session does not support async functions; keep CAD " + "construction synchronous" + ) + + @wraps(fn) + def wrapped(*args: _P.args, **kwargs: _P.kwargs) -> _R: + session = get_active_session() + if session is None: + raise RuntimeError( + f"{fn.__name__} requires an active GraphSession; call it " + "from a @model function or inside `with GraphSession():`." + ) + value = fn(*args, **kwargs) + session.validate_graph_ownership(value) + return value + + return wrapped + + if func is None: + return decorate + return decorate(func) + + def _normalize_output_shapes(outputs: Any) -> List[Any]: if outputs is None: return [] @@ -121,20 +535,26 @@ def _extract_input_nodes(inputs: Optional[Iterable[Any]]) -> List[OperationNode] nodes: List[OperationNode] = [] seen: Set[str] = set() for obj in inputs: - if obj is None: - continue - node = getattr(obj, "_get_runtime", lambda *_args, **_kwargs: None)( - "graph.node" - ) - if node is None: - continue - if node.node_id in seen: - continue - seen.add(node.node_id) - nodes.append(node) + for node, _graph_id in _graph_nodes_with_ids(obj): + if node.node_id in seen: + continue + seen.add(node.node_id) + nodes.append(node) return nodes +def _validate_input_graph_ownership( + inputs: Optional[Iterable[Any]], session: GraphSession +) -> None: + for value in inputs or (): + session.validate_graph_ownership(value) + if isinstance(value, (Part, Assembly)) and _graph_node_and_id(value) is None: + raise ValueError( + f"unrecorded {type(value).__name__} cannot be used in active graph " + f"'{session.graph.graph_id}'; build it inside this GraphSession" + ) + + def _current_context_snapshot() -> Dict[str, Any]: cs = get_current_cs() return { @@ -196,6 +616,167 @@ def _shape_topo_id(shape: Any) -> str: return f"{prefix}_{hash(wrapped)}" +def _kernel_topo_id(shape: Any) -> str: + wrapped = _wrapped_shape(shape) + if wrapped is None: + return f"obj_{id(shape)}" + try: + return str(wrapped.HashCode(1000000)) + except AttributeError: + return str(hash(wrapped)) + + +def _topology_wrappers(shape: Any) -> List[Any]: + result: List[Any] = [] + seen_wrappers: Set[int] = set() + queue = [shape] + while queue: + current = queue.pop(0) + marker = id(current) + if marker in seen_wrappers: + continue + seen_wrappers.add(marker) + if _shape_kind(current) is not None: + result.append(current) + children = getattr(current, "get_children", None) + if callable(children): + queue.extend(children()) + return result + + +def _unique_ref_index( + shapes: Iterable[Any], + *, + ref_factory: Optional[Callable[[Any], TopoRef]] = None, +) -> Dict[tuple[TopoKind, str], TopoRef]: + candidates: Dict[tuple[TopoKind, str], List[TopoRef]] = {} + for shape in shapes: + for wrapper in _topology_wrappers(shape): + kind = _shape_kind(wrapper) + if kind is None: + continue + if ref_factory is None: + ref = getattr(wrapper, "_get_runtime", lambda *_args: None)("topo.ref") + if not isinstance(ref, TopoRef): + continue + else: + ref = ref_factory(wrapper) + key = (kind, _kernel_topo_id(wrapper)) + candidates.setdefault(key, []).append(ref) + + result: Dict[tuple[TopoKind, str], TopoRef] = {} + for key, refs in candidates.items(): + unique = set(refs) + if len(unique) > 1: + raise ValueError( + f"ambiguous topology identity for {key[0].name}:{key[1]}" + ) + result[key] = next(iter(unique)) + return result + + +def _canonicalize_recorded_topo_delta( + delta: Optional[TopoDelta], + *, + graph_id: str, + node_id: str, + outputs: List[Any], + inputs: Optional[Iterable[Any]], +) -> Optional[TopoDelta]: + if delta is None: + return None + + def output_ref_factory(slot: int): + return lambda wrapper: TopoRef( + graph_id=graph_id, + node_id=node_id, + output_slot=slot, + kind=_shape_kind(wrapper), + topo_id=_shape_topo_id(wrapper), + ) + + output_index: Dict[tuple[TopoKind, str], TopoRef] = {} + for slot, output in enumerate(outputs): + slot_index = _unique_ref_index( + [output], ref_factory=output_ref_factory(slot) + ) + for key, ref in slot_index.items(): + existing = output_index.get(key) + if existing is not None and existing != ref: + raise ValueError( + f"topology entity {key[0].name}:{key[1]} appears in multiple output slots" + ) + output_index[key] = ref + source_index = _unique_ref_index(inputs or ()) + + def resolve(ref: TopoRef, *, source: bool, required: bool = True) -> TopoRef: + index = source_index if source else output_index + resolved = index.get((ref.kind, ref.topo_id)) + if resolved is None: + if ref.graph_id not in {"", "pending"} and ref.node_id not in {"", "pending"}: + return ref + if required: + side = "source" if source else "result" + raise ValueError( + f"complete topology witness cannot resolve {side} {ref.kind.name}:{ref.topo_id}" + ) + return ref + return resolved + + entries = [] + for entry in delta.entries: + complete = ( + str(entry.metadata.get("coverage", "complete")) == "complete" + and str(entry.metadata.get("status", "proven")) == "proven" + ) + entries.append( + TopoEntry( + ref=resolve( + entry.ref, + source=entry.event == TopoEvent.DELETED, + required=complete, + ), + event=entry.event, + origin_role=entry.origin_role, + parent_refs=tuple( + resolve(ref, source=True, required=complete) + for ref in entry.parent_refs + ), + metadata=dict(entry.metadata), + ) + ) + + roles = [] + for role in delta.roles: + complete = ( + str(role.metadata.get("coverage", "complete")) == "complete" + and str(role.metadata.get("status", "proven")) == "proven" + ) + roles.append( + TopoRoleEntry( + ref=resolve(role.ref, source=False, required=complete), + role=role.role, + origin_role=role.origin_role, + parent_refs=tuple( + resolve(ref, source=True, required=complete) + for ref in role.parent_refs + ), + metadata=dict(role.metadata), + ) + ) + + return TopoDelta( + preserved=tuple(resolve(ref, source=False) for ref in delta.preserved), + modified=tuple(resolve(ref, source=False) for ref in delta.modified), + generated=tuple(resolve(ref, source=False) for ref in delta.generated), + deleted=tuple(resolve(ref, source=True) for ref in delta.deleted), + section_edges=tuple(resolve(ref, source=False) for ref in delta.section_edges), + entries=tuple(entries), + roles=tuple(roles), + raw_event=dict(delta.raw_event), + ) + + def _attach_topo_refs_recursive( shape: Any, *, @@ -286,6 +867,42 @@ def attach_graph_node( return output +def attach_semantic_graph_node( + output: Any, + node: OperationNode, + output_slot: int = 0, + graph_id: Optional[str] = None, +) -> Any: + """Attach semantic graph lineage without replacing geometry topology refs.""" + + if output is None: + return output + + setter = getattr(output, "_set_runtime", None) + if callable(setter): + setter("graph.node", node) + setter("graph.node_id", node.node_id) + setter("graph.output_slot", output_slot) + + effective_graph_id = graph_id + if effective_graph_id is None: + active = get_active_session() + effective_graph_id = active.graph.graph_id if active is not None else "" + + set_metadata = getattr(output, "set_metadata", None) + if callable(set_metadata): + set_metadata( + "graph", + { + "graph_id": effective_graph_id or None, + "node_id": node.node_id, + "op": node.op, + "output_slot": output_slot, + }, + ) + return output + + def record_operation_if_active( op: str, params: Optional[Dict[str, Any]] = None, @@ -295,6 +912,7 @@ def record_operation_if_active( topo_delta: Optional[TopoDelta] = None, context: Optional[Dict[str, Any]] = None, tags: Optional[Set[str]] = None, + source: Optional[Dict[str, Any]] = None, ) -> Optional[OperationNode]: """Record an operation only when a session is active. @@ -316,17 +934,33 @@ def record_operation_if_active( ) output_list = _normalize_output_shapes(outputs) - input_nodes = _extract_input_nodes(input_shapes) + input_list = list(input_shapes or ()) + _validate_input_graph_ownership(input_list, session) + input_nodes = _extract_input_nodes(input_list) + node_id = f"node_{uuid.uuid4().hex[:8]}" + canonical_topo_delta = _canonicalize_recorded_topo_delta( + topo_delta, + graph_id=session.graph.graph_id, + node_id=node_id, + outputs=output_list, + inputs=input_list, + ) node = session.graph.add_node( op=op, params=numeric_params, param_exprs=param_exprs or None, inputs=input_nodes or None, + node_id=node_id, output_count=max(len(output_list), 1), semantic_delta=semantic_delta, - topo_delta=topo_delta, + topo_delta=canonical_topo_delta, context=context or _current_context_snapshot(), tags=tags, + source=( + source + if source is not None + else capture_source_provenance() + ), ) _register_current_frame(session, node.node_id) @@ -349,6 +983,7 @@ def record_operation( topo_delta: Optional[TopoDelta] = None, context: Optional[Dict[str, Any]] = None, tags: Optional[Set[str]] = None, + source: Optional[Dict[str, Any]] = None, ) -> OperationNode: """Record an operation to the active graph session. @@ -392,6 +1027,11 @@ def record_operation( topo_delta=topo_delta, context=context, tags=tags, + source=( + source + if source is not None + else capture_source_provenance() + ), ) _register_current_frame(session, node.node_id) return node diff --git a/SimpleCADAPI/src/simplecadapi/inverse_engineer/__init__.py b/SimpleCADAPI/src/simplecadapi/inverse_engineer/__init__.py new file mode 100644 index 0000000..7272efb --- /dev/null +++ b/SimpleCADAPI/src/simplecadapi/inverse_engineer/__init__.py @@ -0,0 +1,5 @@ +"""Utilities for recovering editable CAD intent from reference geometry.""" + +from . import brep + +__all__ = ["brep"] diff --git a/SimpleCADAPI/src/simplecadapi/inverse_engineer/brep/__init__.py b/SimpleCADAPI/src/simplecadapi/inverse_engineer/brep/__init__.py new file mode 100644 index 0000000..4241ad9 --- /dev/null +++ b/SimpleCADAPI/src/simplecadapi/inverse_engineer/brep/__init__.py @@ -0,0 +1,46 @@ +"""STEP BREP inspection, comparison, rendering, and slice diagnostics. + +This subpackage is intentionally imported as a namespace instead of adding its +specialized OCP utilities to the top-level SimpleCAD modeling API. +""" + +from .compare import ( + BRepComparison, + InspectionSummaryComparison, + compare_inspections, + compare_shapes, + compare_steps, +) +from .inspect import BRepInspection, inspect_shape, inspect_step +from .io import load_step, shape_mass +from .render import DEFAULT_VIEWS, render_shape_views, render_step_views +from .slices import ( + SliceComparison, + SlicePanelResult, + SliceSpec, + center_slice_specs, + compare_shape_slices, + compare_step_slices, +) + +__all__ = [ + "BRepComparison", + "BRepInspection", + "DEFAULT_VIEWS", + "InspectionSummaryComparison", + "SliceComparison", + "SlicePanelResult", + "SliceSpec", + "center_slice_specs", + "compare_inspections", + "compare_shape_slices", + "compare_shapes", + "compare_step_slices", + "compare_steps", + "inspect_shape", + "inspect_step", + "load_step", + "render_shape_views", + "render_step_views", + "shape_mass", +] diff --git a/SimpleCADAPI/src/simplecadapi/inverse_engineer/brep/__main__.py b/SimpleCADAPI/src/simplecadapi/inverse_engineer/brep/__main__.py new file mode 100644 index 0000000..eb53e2f --- /dev/null +++ b/SimpleCADAPI/src/simplecadapi/inverse_engineer/brep/__main__.py @@ -0,0 +1,3 @@ +from .cli import main + +raise SystemExit(main()) diff --git a/SimpleCADAPI/src/simplecadapi/inverse_engineer/brep/cli.py b/SimpleCADAPI/src/simplecadapi/inverse_engineer/brep/cli.py new file mode 100644 index 0000000..1787e74 --- /dev/null +++ b/SimpleCADAPI/src/simplecadapi/inverse_engineer/brep/cli.py @@ -0,0 +1,118 @@ +"""Command-line entry point for BREP inverse-engineering diagnostics.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path +from typing import Sequence + +from .compare import compare_steps +from .inspect import inspect_step +from .render import render_step_views +from .slices import SliceSpec, compare_step_slices + + +def _write_or_print(payload: dict, output: str | None) -> None: + text = json.dumps(payload, indent=2) + if output: + path = Path(output) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + print(text) + + +def _slice_spec(value: str) -> SliceSpec: + try: + plane_text, coordinate_text = value.split(":", 1) + plane = plane_text.lower() + if plane not in {"xy", "xz", "yz"}: + raise ValueError + return SliceSpec(plane=plane, value=float(coordinate_text)) + except ValueError as error: + raise argparse.ArgumentTypeError( + "slice must be PLANE:VALUE, for example xz:-1.6" + ) from error + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="simplecad-brep", + description="Inspect and compare STEP BREP geometry and topology.", + ) + commands = parser.add_subparsers(dest="command", required=True) + + inspect_parser = commands.add_parser( + "inspect", help="Write a structured STEP BREP report" + ) + inspect_parser.add_argument("step") + inspect_parser.add_argument("--output", "-o") + + compare_parser = commands.add_parser( + "compare", help="Run bidirectional geometry and topology checks" + ) + compare_parser.add_argument("target") + compare_parser.add_argument("candidate") + compare_parser.add_argument("--output", "-o") + compare_parser.add_argument("--geometric-tolerance", type=float, default=1.0e-7) + compare_parser.add_argument("--volume-tolerance", type=float, default=1.0e-9) + + render_parser = commands.add_parser( + "render", help="Render consistent orthographic and isometric views" + ) + render_parser.add_argument("step") + render_parser.add_argument("output") + + slices_parser = commands.add_parser( + "slices", help="Compare sampled physical slices and render an XOR overlay" + ) + slices_parser.add_argument("target") + slices_parser.add_argument("candidate") + slices_parser.add_argument("output") + slices_parser.add_argument("--report") + slices_parser.add_argument( + "--slice", + dest="slices", + action="append", + type=_slice_spec, + help="Physical slice as PLANE:VALUE; repeat for multiple slices. Defaults to XYZ center slices.", + ) + slices_parser.add_argument("--horizontal-samples", type=int, default=91) + slices_parser.add_argument("--vertical-samples", type=int, default=121) + return parser + + +def main(argv: Sequence[str] | None = None) -> int: + args = build_parser().parse_args(argv) + if args.command == "inspect": + report = inspect_step(args.step) + _write_or_print(report.to_dict(), args.output) + return 0 + if args.command == "compare": + comparison = compare_steps( + args.target, + args.candidate, + geometric_tolerance=args.geometric_tolerance, + boolean_volume_tolerance=args.volume_tolerance, + ) + _write_or_print(comparison.to_dict(), args.output) + return 0 if comparison.hard_gate_passed else 1 + if args.command == "render": + output = render_step_views(args.step, args.output) + print(json.dumps({"output": str(output)}, indent=2)) + return 0 + if args.command == "slices": + comparison = compare_step_slices( + args.target, + args.candidate, + slices=args.slices, + samples=(args.horizontal_samples, args.vertical_samples), + output_path=args.output, + ) + _write_or_print(comparison.to_dict(), args.report) + return 0 if comparison.sampled_slices_identical else 1 + raise AssertionError(f"Unhandled command: {args.command}") + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/SimpleCADAPI/src/simplecadapi/inverse_engineer/brep/compare.py b/SimpleCADAPI/src/simplecadapi/inverse_engineer/brep/compare.py new file mode 100644 index 0000000..6a0c524 --- /dev/null +++ b/SimpleCADAPI/src/simplecadapi/inverse_engineer/brep/compare.py @@ -0,0 +1,456 @@ +"""Strict geometric-set and geometry-labelled topology comparison.""" + +from __future__ import annotations + +import json +from collections import Counter, defaultdict +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Any + +import numpy as np +from OCP.BRep import BRep_Tool +from OCP.BRepAdaptor import BRepAdaptor_Curve, BRepAdaptor_Surface +from OCP.BRepAlgoAPI import BRepAlgoAPI_Cut +from OCP.TopAbs import TopAbs_EDGE, TopAbs_FACE, TopAbs_VERTEX +from OCP.TopExp import TopExp, TopExp_Explorer +from OCP.TopTools import TopTools_IndexedMapOfShape +from OCP.TopoDS import TopoDS, TopoDS_Shape + +from .inspect import BRepInspection +from .io import load_step, shape_mass + + +@dataclass(frozen=True) +class BRepComparison: + """Hard-gate comparison facts for two solid BREPs.""" + + target: str | None + candidate: str | None + target_minus_candidate_volume: float + candidate_minus_target_volume: float + same_geometric_point_set: bool + geometry_labelled_incidence_graph_isomorphic: bool + target_graph_nodes_edges: tuple[int, int] + candidate_graph_nodes_edges: tuple[int, int] + geometric_tolerance: float + boolean_volume_tolerance: float + + @property + def hard_gate_passed(self) -> bool: + return ( + self.same_geometric_point_set + and self.geometry_labelled_incidence_graph_isomorphic + ) + + def to_dict(self) -> dict[str, Any]: + result = asdict(self) + result["hard_gate_passed"] = self.hard_gate_passed + return result + + def write_json(self, path: str | Path, *, indent: int = 2) -> Path: + output = Path(path) + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text(json.dumps(self.to_dict(), indent=indent), encoding="utf-8") + return output + + +@dataclass(frozen=True) +class InspectionSummaryComparison: + """Fast report-level diagnostics; not a geometric acceptance result.""" + + volume_delta: float + surface_area_delta: float + bounding_box_delta: tuple[float, ...] + counts_equal: bool + surface_type_counts_equal: bool + edge_type_counts_equal: bool + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + +def compare_inspections( + target: BRepInspection, candidate: BRepInspection +) -> InspectionSummaryComparison: + """Compare inexpensive inspection summaries without claiming BREP equality.""" + return InspectionSummaryComparison( + volume_delta=candidate.volume - target.volume, + surface_area_delta=candidate.surface_area - target.surface_area, + bounding_box_delta=tuple( + candidate_value - target_value + for target_value, candidate_value in zip( + target.bounding_box, candidate.bounding_box + ) + ), + counts_equal=target.counts == candidate.counts, + surface_type_counts_equal=target.surface_type_counts + == candidate.surface_type_counts, + edge_type_counts_equal=target.edge_type_counts == candidate.edge_type_counts, + ) + + +def _canonical_direction(values, tolerance: float) -> np.ndarray: + vector = np.asarray(values, dtype=float) + norm = np.linalg.norm(vector) + if norm <= tolerance: + raise ValueError("Cannot canonicalize a zero direction") + vector /= norm + for value in vector: + if abs(value) > tolerance: + if value < 0.0: + vector = -vector + break + return vector + + +def _quantize(values, tolerance: float) -> tuple[int, ...]: + return tuple( + int(round(value / tolerance)) + for value in np.atleast_1d(np.asarray(values, dtype=float)) + ) + + +def _canonical_axis( + location, direction, tolerance: float +) -> tuple[tuple[int, ...], tuple[int, ...]]: + axis = _canonical_direction(direction, tolerance) + point = np.asarray(location, dtype=float) + closest_to_origin = point - axis * np.dot(point, axis) + return _quantize(closest_to_origin, tolerance), _quantize(axis, tolerance) + + +def _surface_label(face, tolerance: float) -> tuple[Any, ...]: + adaptor = BRepAdaptor_Surface(face, True) + kind = str(adaptor.GetType()).split(".")[-1].removeprefix("GeomAbs_") + area, center = shape_mass(face, "area") + # Numerical integration of the same high-order surface can vary after a + # STEP round trip even when bidirectional Boolean differences are exactly + # zero. Area and centroid therefore help label analytic faces, but they + # must not become representation-sensitive identity for BSpline surfaces. + label: list[Any] = [kind] + if kind != "BSplineSurface": + label += [ + int(round(area / tolerance)), + _quantize(center, tolerance), + ] + if kind == "Plane": + plane = adaptor.Plane() + normal = _canonical_direction(plane.Axis().Direction().Coord(), tolerance) + distance = float( + np.dot(normal, np.asarray(plane.Location().Coord(), dtype=float)) + ) + if distance < -tolerance: + normal, distance = -normal, -distance + label += [_quantize(normal, tolerance), int(round(distance / tolerance))] + elif kind == "Cylinder": + cylinder = adaptor.Cylinder() + label += [ + int(round(cylinder.Radius() / tolerance)), + _canonical_axis( + cylinder.Location().Coord(), + cylinder.Axis().Direction().Coord(), + tolerance, + ), + ] + elif kind == "Cone": + cone = adaptor.Cone() + label += [ + int(round(cone.RefRadius() / tolerance)), + int(round(cone.SemiAngle() / tolerance)), + _canonical_axis( + cone.Location().Coord(), cone.Axis().Direction().Coord(), tolerance + ), + _quantize(cone.Apex().Coord(), tolerance), + ] + elif kind == "Sphere": + sphere = adaptor.Sphere() + label += [ + int(round(sphere.Radius() / tolerance)), + _quantize(sphere.Location().Coord(), tolerance), + ] + elif kind == "Torus": + torus = adaptor.Torus() + label += [ + int(round(torus.MajorRadius() / tolerance)), + int(round(torus.MinorRadius() / tolerance)), + _canonical_axis( + torus.Location().Coord(), torus.Axis().Direction().Coord(), tolerance + ), + ] + elif kind == "BSplineSurface": + surface = adaptor.BSpline() + label += [ + int(surface.UDegree()), + int(surface.VDegree()), + int(surface.NbUPoles()), + int(surface.NbVPoles()), + int(surface.NbUKnots()), + int(surface.NbVKnots()), + bool(surface.IsUPeriodic()), + bool(surface.IsVPeriodic()), + ] + return tuple(label) + + +def _edge_label(edge, tolerance: float) -> tuple[Any, ...]: + adaptor = BRepAdaptor_Curve(edge) + kind = str(adaptor.GetType()).split(".")[-1].removeprefix("GeomAbs_") + length, center = shape_mass(edge, "length") + # A topological degenerate edge has no geometric point-set extent. STEP + # readers may expose its null curve as Circle, OtherCurve, or no 3D curve + # at all; normalize those representations before graph comparison. + if abs(length) <= tolerance: + return ("DegenerateEdge",) + label: list[Any] = [ + kind, + int(round(length / tolerance)), + _quantize(center, tolerance), + ] + if kind == "Line": + line = adaptor.Line() + label += [ + _canonical_axis( + line.Location().Coord(), line.Direction().Coord(), tolerance + ) + ] + elif kind == "Circle": + circle = adaptor.Circle() + label += [ + int(round(circle.Radius() / tolerance)), + _quantize(circle.Location().Coord(), tolerance), + _quantize( + _canonical_direction(circle.Axis().Direction().Coord(), tolerance), + tolerance, + ), + ] + elif kind == "BSplineCurve": + curve = adaptor.BSpline() + label += [ + int(curve.Degree()), + int(curve.NbPoles()), + int(curve.NbKnots()), + bool(curve.IsPeriodic()), + bool(curve.IsRational()), + ] + return tuple(label) + + +def _vertex_label(vertex, tolerance: float) -> tuple[Any, ...]: + return "Vertex", _quantize(BRep_Tool.Pnt_s(vertex).Coord(), tolerance) + + +def _incidence_graph(shape: TopoDS_Shape, tolerance: float): + faces = TopTools_IndexedMapOfShape() + edges = TopTools_IndexedMapOfShape() + vertices = TopTools_IndexedMapOfShape() + TopExp.MapShapes_s(shape, TopAbs_FACE, faces) + TopExp.MapShapes_s(shape, TopAbs_EDGE, edges) + TopExp.MapShapes_s(shape, TopAbs_VERTEX, vertices) + + labels: dict[tuple[str, int], tuple[Any, ...]] = {} + adjacency: dict[tuple[str, int], set[tuple[str, int]]] = {} + + def add_node(node, label): + labels[node] = label + adjacency.setdefault(node, set()) + + def add_edge(first, second): + adjacency.setdefault(first, set()).add(second) + adjacency.setdefault(second, set()).add(first) + + for index in range(1, faces.Extent() + 1): + face = TopoDS.Face_s(faces.FindKey(index)) + node = ("f", index) + add_node(node, _surface_label(face, tolerance)) + explorer = TopExp_Explorer(face, TopAbs_EDGE) + while explorer.More(): + add_edge(node, ("e", edges.FindIndex(explorer.Current()))) + explorer.Next() + for index in range(1, edges.Extent() + 1): + edge = TopoDS.Edge_s(edges.FindKey(index)) + node = ("e", index) + add_node(node, _edge_label(edge, tolerance)) + explorer = TopExp_Explorer(edge, TopAbs_VERTEX) + while explorer.More(): + add_edge(node, ("v", vertices.FindIndex(explorer.Current()))) + explorer.Next() + for index in range(1, vertices.Extent() + 1): + vertex = TopoDS.Vertex_s(vertices.FindKey(index)) + add_node(("v", index), _vertex_label(vertex, tolerance)) + return labels, adjacency + + +def _same_partition(before: dict[Any, int], after: dict[Any, int]) -> bool: + nodes = list(before) + return all( + (before[first] == before[second]) == (after[first] == after[second]) + for first in nodes + for second in nodes + ) + + +def _labelled_graph_isomorphic(graph_a, graph_b) -> bool: + labels_a, adjacency_a = graph_a + labels_b, adjacency_b = graph_b + if len(labels_a) != len(labels_b): + return False + + all_labels = sorted(set(labels_a.values()) | set(labels_b.values()), key=repr) + label_colors = {label: index for index, label in enumerate(all_labels)} + colors_a = {node: label_colors[label] for node, label in labels_a.items()} + colors_b = {node: label_colors[label] for node, label in labels_b.items()} + + while True: + signatures_a = { + node: ( + colors_a[node], + tuple(sorted(colors_a[neighbor] for neighbor in adjacency_a[node])), + ) + for node in labels_a + } + signatures_b = { + node: ( + colors_b[node], + tuple(sorted(colors_b[neighbor] for neighbor in adjacency_b[node])), + ) + for node in labels_b + } + signatures = sorted( + set(signatures_a.values()) | set(signatures_b.values()), key=repr + ) + signature_colors = { + signature: index for index, signature in enumerate(signatures) + } + refined_a = { + node: signature_colors[signature] + for node, signature in signatures_a.items() + } + refined_b = { + node: signature_colors[signature] + for node, signature in signatures_b.items() + } + stable = _same_partition(colors_a, refined_a) and _same_partition( + colors_b, refined_b + ) + colors_a, colors_b = refined_a, refined_b + if stable: + break + + if Counter(colors_a.values()) != Counter(colors_b.values()): + return False + + candidates_b: dict[int, list[Any]] = defaultdict(list) + for node, color in colors_b.items(): + candidates_b[color].append(node) + order = sorted( + labels_a, + key=lambda node: (len(candidates_b[colors_a[node]]), -len(adjacency_a[node])), + ) + mapping: dict[Any, Any] = {} + used: set[Any] = set() + + def search(index: int) -> bool: + if index == len(order): + return True + node_a = order[index] + for node_b in candidates_b[colors_a[node_a]]: + if node_b in used or len(adjacency_a[node_a]) != len(adjacency_b[node_b]): + continue + if any( + (mapped_a in adjacency_a[node_a]) != (mapped_b in adjacency_b[node_b]) + for mapped_a, mapped_b in mapping.items() + ): + continue + mapping[node_a] = node_b + used.add(node_b) + if search(index + 1): + return True + used.remove(node_b) + del mapping[node_a] + return False + + return search(0) + + +def _cut_volume( + first: TopoDS_Shape, second: TopoDS_Shape, fuzzy_tolerance: float +) -> float: + """Return a robust geometric difference volume. + + STEP readers can produce geometrically identical closed solids whose + top-level and shell orientations differ. OCCT's cut classification is + orientation-sensitive in that case: ``A-B`` may incorrectly return all of + ``A`` even though the bounded point sets are equal. Trying both orientations + of both operands removes that representation artifact; reversing a closed + solid changes its boundary orientation, not its geometric point set. + """ + + volumes: list[float] = [] + for argument in (first, first.Reversed()): + for tool in (second, second.Reversed()): + operation = BRepAlgoAPI_Cut(argument, tool) + operation.SetFuzzyValue(fuzzy_tolerance) + operation.Build() + if operation.IsDone(): + volumes.append(abs(shape_mass(operation.Shape(), "volume")[0])) + if not volumes: + raise RuntimeError("BREP boolean difference failed") + return min(volumes) + + +def compare_shapes( + target: TopoDS_Shape, + candidate: TopoDS_Shape, + *, + target_name: str | None = None, + candidate_name: str | None = None, + geometric_tolerance: float = 1.0e-7, + boolean_volume_tolerance: float = 1.0e-9, + boolean_fuzzy_tolerance: float = 1.0e-9, +) -> BRepComparison: + """Compare geometric point sets and geometry-labelled incidence graphs.""" + target_graph = _incidence_graph(target, geometric_tolerance) + candidate_graph = _incidence_graph(candidate, geometric_tolerance) + target_minus_candidate = _cut_volume(target, candidate, boolean_fuzzy_tolerance) + candidate_minus_target = _cut_volume(candidate, target, boolean_fuzzy_tolerance) + return BRepComparison( + target=target_name, + candidate=candidate_name, + target_minus_candidate_volume=target_minus_candidate, + candidate_minus_target_volume=candidate_minus_target, + same_geometric_point_set=( + target_minus_candidate < boolean_volume_tolerance + and candidate_minus_target < boolean_volume_tolerance + ), + geometry_labelled_incidence_graph_isomorphic=_labelled_graph_isomorphic( + target_graph, candidate_graph + ), + target_graph_nodes_edges=( + len(target_graph[0]), + sum(len(neighbors) for neighbors in target_graph[1].values()) // 2, + ), + candidate_graph_nodes_edges=( + len(candidate_graph[0]), + sum(len(neighbors) for neighbors in candidate_graph[1].values()) // 2, + ), + geometric_tolerance=geometric_tolerance, + boolean_volume_tolerance=boolean_volume_tolerance, + ) + + +def compare_steps( + target_path: str | Path, + candidate_path: str | Path, + **kwargs, +) -> BRepComparison: + """Load two STEP files and run the strict BREP comparison.""" + target_source = Path(target_path) + candidate_source = Path(candidate_path) + return compare_shapes( + load_step(target_source), + load_step(candidate_source), + target_name=str(target_source), + candidate_name=str(candidate_source), + **kwargs, + ) diff --git a/SimpleCADAPI/src/simplecadapi/inverse_engineer/brep/inspect.py b/SimpleCADAPI/src/simplecadapi/inverse_engineer/brep/inspect.py new file mode 100644 index 0000000..9e3bfc9 --- /dev/null +++ b/SimpleCADAPI/src/simplecadapi/inverse_engineer/brep/inspect.py @@ -0,0 +1,284 @@ +"""Structured STEP BREP inspection.""" + +from __future__ import annotations + +import json +from collections import Counter, defaultdict +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Any + +from OCP.BRepAdaptor import BRepAdaptor_Curve, BRepAdaptor_Surface +from OCP.BRepBndLib import BRepBndLib +from OCP.BRepCheck import BRepCheck_Analyzer +from OCP.Bnd import Bnd_Box +from OCP.TopAbs import ( + TopAbs_EDGE, + TopAbs_FACE, + TopAbs_SHELL, + TopAbs_SOLID, + TopAbs_VERTEX, +) +from OCP.TopExp import TopExp, TopExp_Explorer +from OCP.TopTools import TopTools_IndexedMapOfShape +from OCP.TopoDS import TopoDS, TopoDS_Shape + +from .io import direction, load_step, shape_mass, xyz + + +@dataclass(frozen=True) +class BRepInspection: + """JSON-serializable geometry and topology facts for one BREP.""" + + source: str | None + valid: bool + counts: dict[str, int] + bounding_box: list[float] + volume: float + surface_area: float + center_of_mass: list[float] + surface_type_counts: dict[str, int] + edge_type_counts: dict[str, int] + faces: list[dict[str, Any]] + edges: list[dict[str, Any]] + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + def write_json(self, path: str | Path, *, indent: int = 2) -> Path: + output = Path(path) + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text(json.dumps(self.to_dict(), indent=indent), encoding="utf-8") + return output + + +def _count_occurrences(shape: TopoDS_Shape, kind) -> int: + explorer = TopExp_Explorer(shape, kind) + count = 0 + while explorer.More(): + count += 1 + explorer.Next() + return count + + +def _surface_parameters(adaptor: BRepAdaptor_Surface) -> dict[str, Any]: + kind = str(adaptor.GetType()).split(".")[-1].removeprefix("GeomAbs_") + result: dict[str, Any] = {"type": kind} + if kind == "Plane": + surface = adaptor.Plane() + result.update( + normal=direction(surface.Axis().Direction()), + location=xyz(surface.Location()), + ) + elif kind == "Cylinder": + surface = adaptor.Cylinder() + result.update( + radius=float(surface.Radius()), + axis=direction(surface.Axis().Direction()), + location=xyz(surface.Location()), + ) + elif kind == "Cone": + surface = adaptor.Cone() + result.update( + reference_radius=float(surface.RefRadius()), + semi_angle=float(surface.SemiAngle()), + axis=direction(surface.Axis().Direction()), + location=xyz(surface.Location()), + apex=xyz(surface.Apex()), + ) + elif kind == "Sphere": + surface = adaptor.Sphere() + result.update(radius=float(surface.Radius()), center=xyz(surface.Location())) + elif kind == "Torus": + surface = adaptor.Torus() + result.update( + major_radius=float(surface.MajorRadius()), + minor_radius=float(surface.MinorRadius()), + axis=direction(surface.Axis().Direction()), + location=xyz(surface.Location()), + ) + elif kind == "BSplineSurface": + surface = adaptor.BSpline() + rational = bool(surface.IsURational() or surface.IsVRational()) + u_poles = int(surface.NbUPoles()) + v_poles = int(surface.NbVPoles()) + result.update( + u_degree=int(surface.UDegree()), + v_degree=int(surface.VDegree()), + u_poles=u_poles, + v_poles=v_poles, + u_knots=int(surface.NbUKnots()), + v_knots=int(surface.NbVKnots()), + u_periodic=bool(surface.IsUPeriodic()), + v_periodic=bool(surface.IsVPeriodic()), + rational=rational, + u_knot_values=[ + float(surface.UKnot(index)) + for index in range(1, surface.NbUKnots() + 1) + ], + v_knot_values=[ + float(surface.VKnot(index)) + for index in range(1, surface.NbVKnots() + 1) + ], + u_multiplicities=[ + int(surface.UMultiplicity(index)) + for index in range(1, surface.NbUKnots() + 1) + ], + v_multiplicities=[ + int(surface.VMultiplicity(index)) + for index in range(1, surface.NbVKnots() + 1) + ], + control_points=[ + [ + xyz(surface.Pole(u_index, v_index)) + for v_index in range(1, v_poles + 1) + ] + for u_index in range(1, u_poles + 1) + ], + ) + if rational: + result["weights"] = [ + [ + float(surface.Weight(u_index, v_index)) + for v_index in range(1, v_poles + 1) + ] + for u_index in range(1, u_poles + 1) + ] + return result + + +def _edge_parameters(edge) -> dict[str, Any]: + adaptor = BRepAdaptor_Curve(edge) + kind = str(adaptor.GetType()).split(".")[-1].removeprefix("GeomAbs_") + first = float(adaptor.FirstParameter()) + last = float(adaptor.LastParameter()) + result: dict[str, Any] = {"type": kind, "first": first, "last": last} + if kind == "Circle": + circle = adaptor.Circle() + result.update( + radius=float(circle.Radius()), + center=xyz(circle.Location()), + axis=direction(circle.Axis().Direction()), + ) + elif kind == "Line": + result.update( + line_start=xyz(adaptor.Value(first)), line_end=xyz(adaptor.Value(last)) + ) + elif kind == "BSplineCurve": + curve = adaptor.BSpline() + rational = bool(curve.IsRational()) + result.update( + degree=int(curve.Degree()), + poles=int(curve.NbPoles()), + knots=int(curve.NbKnots()), + periodic=bool(curve.IsPeriodic()), + rational=rational, + knot_values=[ + float(curve.Knot(index)) for index in range(1, curve.NbKnots() + 1) + ], + multiplicities=[ + int(curve.Multiplicity(index)) + for index in range(1, curve.NbKnots() + 1) + ], + control_points=[ + xyz(curve.Pole(index)) for index in range(1, curve.NbPoles() + 1) + ], + ) + if rational: + result["weights"] = [ + float(curve.Weight(index)) for index in range(1, curve.NbPoles() + 1) + ] + return result + + +def inspect_shape( + shape: TopoDS_Shape, *, source: str | Path | None = None +) -> BRepInspection: + """Inspect one imported shape without relying on STEP entity numbering.""" + bounding_box = Bnd_Box() + BRepBndLib.AddOptimal_s(shape, bounding_box) + volume, center = shape_mass(shape, "volume") + surface_area, _ = shape_mass(shape, "area") + + face_map = TopTools_IndexedMapOfShape() + edge_map = TopTools_IndexedMapOfShape() + vertex_map = TopTools_IndexedMapOfShape() + TopExp.MapShapes_s(shape, TopAbs_FACE, face_map) + TopExp.MapShapes_s(shape, TopAbs_EDGE, edge_map) + TopExp.MapShapes_s(shape, TopAbs_VERTEX, vertex_map) + + face_edges: dict[int, list[int]] = {} + edge_faces: dict[int, list[int]] = defaultdict(list) + for face_index in range(1, face_map.Extent() + 1): + face = TopoDS.Face_s(face_map.FindKey(face_index)) + explorer = TopExp_Explorer(face, TopAbs_EDGE) + indices: list[int] = [] + while explorer.More(): + edge_index = edge_map.FindIndex(explorer.Current()) + indices.append(edge_index) + edge_faces[edge_index].append(face_index) + explorer.Next() + face_edges[face_index] = indices + + faces: list[dict[str, Any]] = [] + for face_index in range(1, face_map.Extent() + 1): + face = TopoDS.Face_s(face_map.FindKey(face_index)) + adaptor = BRepAdaptor_Surface(face, True) + area, _ = shape_mass(face, "area") + u0, u1 = adaptor.FirstUParameter(), adaptor.LastUParameter() + v0, v1 = adaptor.FirstVParameter(), adaptor.LastVParameter() + midpoint = adaptor.Value((u0 + u1) / 2.0, (v0 + v1) / 2.0) + faces.append( + { + "index": face_index, + "surface": _surface_parameters(adaptor), + "area": area, + "uv_bounds": [float(u0), float(u1), float(v0), float(v1)], + "midpoint": xyz(midpoint), + "edge_indices": face_edges[face_index], + } + ) + + edges: list[dict[str, Any]] = [] + for edge_index in range(1, edge_map.Extent() + 1): + edge = TopoDS.Edge_s(edge_map.FindKey(edge_index)) + length, midpoint = shape_mass(edge, "length") + edges.append( + { + "index": edge_index, + **_edge_parameters(edge), + "length": length, + "midpoint": midpoint.tolist(), + "face_indices": edge_faces[edge_index], + } + ) + + counts = { + "solid": _count_occurrences(shape, TopAbs_SOLID), + "shell": _count_occurrences(shape, TopAbs_SHELL), + "face_occurrences": _count_occurrences(shape, TopAbs_FACE), + "edge_occurrences": _count_occurrences(shape, TopAbs_EDGE), + "vertex_occurrences": _count_occurrences(shape, TopAbs_VERTEX), + "unique_faces": face_map.Extent(), + "unique_edges": edge_map.Extent(), + "unique_vertices": vertex_map.Extent(), + } + return BRepInspection( + source=str(source) if source is not None else None, + valid=bool(BRepCheck_Analyzer(shape).IsValid()), + counts=counts, + bounding_box=[float(value) for value in bounding_box.Get()], + volume=volume, + surface_area=surface_area, + center_of_mass=center.tolist(), + surface_type_counts=dict(Counter(face["surface"]["type"] for face in faces)), + edge_type_counts=dict(Counter(edge["type"] for edge in edges)), + faces=faces, + edges=edges, + ) + + +def inspect_step(path: str | Path) -> BRepInspection: + """Load and inspect one valid, single-root STEP file.""" + source = Path(path) + return inspect_shape(load_step(source), source=source) diff --git a/SimpleCADAPI/src/simplecadapi/inverse_engineer/brep/io.py b/SimpleCADAPI/src/simplecadapi/inverse_engineer/brep/io.py new file mode 100644 index 0000000..d1ecfef --- /dev/null +++ b/SimpleCADAPI/src/simplecadapi/inverse_engineer/brep/io.py @@ -0,0 +1,64 @@ +"""STEP loading and low-level BREP property helpers.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Literal + +import numpy as np +from OCP.BRepCheck import BRepCheck_Analyzer +from OCP.BRepGProp import BRepGProp +from OCP.GProp import GProp_GProps +from OCP.IFSelect import IFSelect_RetDone +from OCP.STEPControl import STEPControl_Reader +from OCP.TopoDS import TopoDS_Shape + +PropertyKind = Literal["volume", "area", "length"] + + +def load_step( + path: str | Path, *, require_single_root: bool = True, require_valid: bool = True +) -> TopoDS_Shape: + """Load one transferred STEP shape and optionally require a valid BREP.""" + source = Path(path) + reader = STEPControl_Reader() + status = reader.ReadFile(str(source)) + if status != IFSelect_RetDone: + raise ValueError(f"Could not read STEP file {source}: {status}") + transferred = reader.TransferRoots() + if transferred < 1 or reader.NbShapes() < 1: + raise ValueError(f"STEP file {source} transferred no shapes") + if require_single_root and (transferred != 1 or reader.NbShapes() != 1): + raise ValueError( + f"Expected one STEP root in {source}, got transferred={transferred}, shapes={reader.NbShapes()}" + ) + shape = reader.Shape(1) + if require_valid and not BRepCheck_Analyzer(shape).IsValid(): + raise ValueError(f"STEP BREP is invalid: {source}") + return shape + + +def shape_mass(shape: TopoDS_Shape, kind: PropertyKind) -> tuple[float, np.ndarray]: + """Return mass-like value and center for volume, area, or length.""" + properties = GProp_GProps() + if kind == "volume": + BRepGProp.VolumeProperties_s(shape, properties) + elif kind == "area": + BRepGProp.SurfaceProperties_s(shape, properties) + elif kind == "length": + BRepGProp.LinearProperties_s(shape, properties) + else: + raise ValueError(f"Unsupported property kind: {kind}") + return float(properties.Mass()), np.asarray( + properties.CentreOfMass().Coord(), dtype=float + ) + + +def xyz(point) -> list[float]: + """Convert an OCP point to a JSON-friendly XYZ list.""" + return [float(point.X()), float(point.Y()), float(point.Z())] + + +def direction(vector) -> list[float]: + """Convert an OCP direction to a JSON-friendly XYZ list.""" + return [float(vector.X()), float(vector.Y()), float(vector.Z())] diff --git a/SimpleCADAPI/src/simplecadapi/inverse_engineer/brep/render.py b/SimpleCADAPI/src/simplecadapi/inverse_engineer/brep/render.py new file mode 100644 index 0000000..70d5944 --- /dev/null +++ b/SimpleCADAPI/src/simplecadapi/inverse_engineer/brep/render.py @@ -0,0 +1,126 @@ +"""Consistent multi-view rendering for BREP inspection.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Sequence + +import numpy as np +from OCP.BRep import BRep_Tool +from OCP.BRepMesh import BRepMesh_IncrementalMesh +from OCP.TopAbs import TopAbs_FACE +from OCP.TopExp import TopExp_Explorer +from OCP.TopoDS import TopoDS, TopoDS_Shape + +from .inspect import inspect_shape +from .io import load_step + +DEFAULT_VIEWS: tuple[tuple[float, float, str], ...] = ( + (25.0, -55.0, "isometric"), + (0.0, -90.0, "top / X-Z"), + (90.0, -90.0, "front / X-Y"), + (0.0, 0.0, "side / Y-Z"), +) + + +def _triangles( + shape: TopoDS_Shape, linear_deflection: float, angular_deflection: float +): + BRepMesh_IncrementalMesh(shape, linear_deflection, False, angular_deflection, True) + polygons: list[list[tuple[float, float, float]]] = [] + explorer = TopExp_Explorer(shape, TopAbs_FACE) + while explorer.More(): + face = TopoDS.Face_s(explorer.Current()) + location = face.Location() + triangulation = BRep_Tool.Triangulation_s(face, location) + if triangulation is not None and triangulation.NbTriangles() > 0: + for index in range(1, triangulation.NbTriangles() + 1): + triangle = triangulation.Triangle(index) + points: list[tuple[float, float, float]] = [] + for node_index in ( + triangle.Value(1), + triangle.Value(2), + triangle.Value(3), + ): + point = triangulation.Node(node_index) + point.Transform(location.Transformation()) + points.append( + (float(point.X()), float(point.Y()), float(point.Z())) + ) + polygons.append(points) + explorer.Next() + return polygons + + +def render_shape_views( + shape: TopoDS_Shape, + output_path: str | Path, + *, + title: str = "BREP views", + views: Sequence[tuple[float, float, str]] = DEFAULT_VIEWS, + image_size: tuple[float, float] = (18.0, 12.0), + dpi: int = 180, + linear_deflection: float = 0.12, + angular_deflection: float = 0.18, +) -> Path: + """Render one BREP from consistent Matplotlib 3D camera angles. + + Matplotlib is imported lazily because visualization is an optional + diagnostic and is not needed for strict geometry/topology inspection. + """ + try: + import matplotlib + + matplotlib.use("Agg") + import matplotlib.pyplot as plt + from mpl_toolkits.mplot3d.art3d import Poly3DCollection + except ImportError as error: + raise ImportError("render_shape_views requires matplotlib") from error + + polygons = _triangles(shape, linear_deflection, angular_deflection) + report = inspect_shape(shape) + x0, y0, z0, x1, y1, z1 = report.bounding_box + spans = np.maximum(np.asarray([x1 - x0, y1 - y0, z1 - z0]), 1.0e-9) + margins = np.maximum(spans * 0.05, 1.0e-6) + + columns = 2 + rows = (len(views) + columns - 1) // columns + figure = plt.figure(figsize=image_size) + for index, (elevation, azimuth, view_title) in enumerate(views, 1): + axes = figure.add_subplot(rows, columns, index, projection="3d") + axes.add_collection3d( + Poly3DCollection( + polygons, + facecolor="#b9c7d6", + edgecolor="#435466", + linewidth=0.12, + alpha=1.0, + ) + ) + axes.view_init(elev=elevation, azim=azimuth) + axes.set_title(view_title) + axes.set_xlabel("X") + axes.set_ylabel("Y") + axes.set_zlabel("Z") + axes.set_box_aspect(tuple(float(value) for value in spans)) + axes.set_xlim(x0 - margins[0], x1 + margins[0]) + axes.set_ylim(y0 - margins[1], y1 + margins[1]) + axes.set_zlim(z0 - margins[2], z1 + margins[2]) + figure.suptitle(title) + figure.tight_layout() + output = Path(output_path) + output.parent.mkdir(parents=True, exist_ok=True) + figure.savefig(output, dpi=dpi) + plt.close(figure) + return output + + +def render_step_views( + step_path: str | Path, + output_path: str | Path, + **kwargs, +) -> Path: + """Load a STEP file and render consistent BREP views.""" + source = Path(step_path) + kwargs.setdefault("title", f"{source.name} - BREP views") + return render_shape_views(load_step(source), output_path, **kwargs) diff --git a/SimpleCADAPI/src/simplecadapi/inverse_engineer/brep/slices.py b/SimpleCADAPI/src/simplecadapi/inverse_engineer/brep/slices.py new file mode 100644 index 0000000..3d8b65e --- /dev/null +++ b/SimpleCADAPI/src/simplecadapi/inverse_engineer/brep/slices.py @@ -0,0 +1,247 @@ +"""Physical occupancy slices and target/candidate XOR diagnostics.""" + +from __future__ import annotations + +import json +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Any, Iterable, Literal, Sequence + +import numpy as np +from OCP.BRepClass3d import BRepClass3d_SolidClassifier +from OCP.TopAbs import TopAbs_IN, TopAbs_ON +from OCP.TopoDS import TopoDS_Shape +from OCP.gp import gp_Pnt + +from .inspect import inspect_shape +from .io import load_step + +SlicePlane = Literal["xy", "xz", "yz"] + + +@dataclass(frozen=True) +class SliceSpec: + """One constant-coordinate physical slice.""" + + plane: SlicePlane + value: float + label: str | None = None + + @property + def display_label(self) -> str: + if self.label: + return self.label + constant = {"xy": "Z", "xz": "Y", "yz": "X"}[self.plane] + return f"{constant}={self.value:g}" + + +@dataclass(frozen=True) +class SlicePanelResult: + slice: str + samples: int + xor_samples: int + + +@dataclass(frozen=True) +class SliceComparison: + target: str | None + candidate: str | None + total_samples: int + xor_samples: int + sampled_slices_identical: bool + panels: tuple[SlicePanelResult, ...] + image: str | None = None + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + def write_json(self, path: str | Path, *, indent: int = 2) -> Path: + output = Path(path) + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text(json.dumps(self.to_dict(), indent=indent), encoding="utf-8") + return output + + +def center_slice_specs( + minimum: np.ndarray, maximum: np.ndarray +) -> tuple[SliceSpec, ...]: + """Return one center slice normal to each global axis.""" + center = (np.asarray(minimum, dtype=float) + np.asarray(maximum, dtype=float)) / 2.0 + return ( + SliceSpec("yz", float(center[0]), f"X={center[0]:g}"), + SliceSpec("xz", float(center[1]), f"Y={center[1]:g}"), + SliceSpec("xy", float(center[2]), f"Z={center[2]:g}"), + ) + + +def _classifier(shape: TopoDS_Shape) -> BRepClass3d_SolidClassifier: + classifier = BRepClass3d_SolidClassifier() + classifier.Load(shape) + return classifier + + +def _occupied( + classifier: BRepClass3d_SolidClassifier, + x: float, + y: float, + z: float, + tolerance: float, +) -> bool: + classifier.Perform(gp_Pnt(float(x), float(y), float(z)), tolerance) + return classifier.State() in (TopAbs_IN, TopAbs_ON) + + +def _axis_ranges(target: TopoDS_Shape, candidate: TopoDS_Shape, margin_ratio: float): + target_box = inspect_shape(target).bounding_box + candidate_box = inspect_shape(candidate).bounding_box + minimum = np.minimum(np.asarray(target_box[:3]), np.asarray(candidate_box[:3])) + maximum = np.maximum(np.asarray(target_box[3:]), np.asarray(candidate_box[3:])) + span = np.maximum(maximum - minimum, 1.0e-9) + margin = span * margin_ratio + return minimum - margin, maximum + margin + + +def _plane_axes(spec: SliceSpec) -> tuple[int, int, int]: + # Return horizontal, vertical, constant XYZ indices. + return {"xy": (0, 1, 2), "xz": (0, 2, 1), "yz": (1, 2, 0)}[spec.plane] + + +def compare_shape_slices( + target: TopoDS_Shape, + candidate: TopoDS_Shape, + *, + slices: Sequence[SliceSpec] | None = None, + samples: tuple[int, int] = (91, 121), + classification_tolerance: float = 1.0e-8, + margin_ratio: float = 0.03, + output_path: str | Path | None = None, + target_name: str | None = None, + candidate_name: str | None = None, + dpi: int = 180, +) -> SliceComparison: + """Compare occupancy on physical slices and optionally render an XOR image. + + This is a visual diagnostic, not a replacement for bidirectional Boolean + comparison. Keep sample counts moderate because exact BREP classification + is intentionally used for every point. + """ + target_classifier = _classifier(target) + candidate_classifier = _classifier(candidate) + minimum, maximum = _axis_ranges(target, candidate, margin_ratio) + selected_slices = ( + tuple(slices) if slices is not None else center_slice_specs(minimum, maximum) + ) + if not selected_slices: + raise ValueError("At least one physical slice is required") + panel_data: list[tuple[SliceSpec, np.ndarray, np.ndarray, np.ndarray]] = [] + panel_results: list[SlicePanelResult] = [] + total_samples = 0 + xor_samples = 0 + + for spec in selected_slices: + horizontal_axis, vertical_axis, constant_axis = _plane_axes(spec) + horizontal = np.linspace( + minimum[horizontal_axis], maximum[horizontal_axis], samples[0] + ) + vertical = np.linspace( + minimum[vertical_axis], maximum[vertical_axis], samples[1] + ) + horizontal_grid, vertical_grid = np.meshgrid(horizontal, vertical) + code = np.zeros(horizontal_grid.shape, dtype=np.uint8) + for row in range(horizontal_grid.shape[0]): + for column in range(horizontal_grid.shape[1]): + point = [0.0, 0.0, 0.0] + point[horizontal_axis] = horizontal_grid[row, column] + point[vertical_axis] = vertical_grid[row, column] + point[constant_axis] = spec.value + target_inside = _occupied( + target_classifier, *point, classification_tolerance + ) + candidate_inside = _occupied( + candidate_classifier, *point, classification_tolerance + ) + code[row, column] = (1 if target_inside else 0) + ( + 2 if candidate_inside else 0 + ) + mismatches = int(np.count_nonzero((code == 1) | (code == 2))) + count = int(code.size) + total_samples += count + xor_samples += mismatches + panel_results.append(SlicePanelResult(spec.display_label, count, mismatches)) + panel_data.append((spec, horizontal_grid, vertical_grid, code)) + + rendered_path: str | None = None + if output_path is not None: + try: + import matplotlib + + matplotlib.use("Agg") + import matplotlib.pyplot as plt + from matplotlib.colors import ListedColormap + except ImportError as error: + raise ImportError("Slice overlay rendering requires matplotlib") from error + + columns = 3 + rows = (len(panel_data) + columns - 1) // columns + figure, axes = plt.subplots( + rows, columns, figsize=(18, 5 * rows), constrained_layout=True + ) + axes_array = np.atleast_1d(axes).ravel() + color_map = ListedColormap(["white", "#e63946", "#ff9f1c", "#457b9d"]) + axis_labels = {"xy": ("X", "Y"), "xz": ("X", "Z"), "yz": ("Y", "Z")} + for axes_item, (spec, horizontal_grid, vertical_grid, code), result in zip( + axes_array, panel_data, panel_results + ): + axes_item.pcolormesh( + horizontal_grid, + vertical_grid, + code, + cmap=color_map, + vmin=0, + vmax=3, + shading="nearest", + ) + axes_item.set_title( + f"{spec.display_label} - XOR samples {result.xor_samples}" + ) + horizontal_label, vertical_label = axis_labels[spec.plane] + axes_item.set_xlabel(horizontal_label) + axes_item.set_ylabel(vertical_label) + axes_item.set_aspect("equal") + for axes_item in axes_array[len(panel_data) :]: + axes_item.set_visible(False) + figure.suptitle( + "BREP slice overlay: blue=both, red=target only, orange=candidate only" + ) + output = Path(output_path) + output.parent.mkdir(parents=True, exist_ok=True) + figure.savefig(output, dpi=dpi) + plt.close(figure) + rendered_path = str(output) + + return SliceComparison( + target=target_name, + candidate=candidate_name, + total_samples=total_samples, + xor_samples=xor_samples, + sampled_slices_identical=xor_samples == 0, + panels=tuple(panel_results), + image=rendered_path, + ) + + +def compare_step_slices( + target_path: str | Path, + candidate_path: str | Path, + **kwargs, +) -> SliceComparison: + """Load two STEP solids and compare physical occupancy slices.""" + target_source = Path(target_path) + candidate_source = Path(candidate_path) + return compare_shape_slices( + load_step(target_source), + load_step(candidate_source), + target_name=str(target_source), + candidate_name=str(candidate_source), + **kwargs, + ) diff --git a/SimpleCADAPI/src/simplecadapi/kernel/ocp_booleans.py b/SimpleCADAPI/src/simplecadapi/kernel/ocp_booleans.py index d323e06..dc8b543 100644 --- a/SimpleCADAPI/src/simplecadapi/kernel/ocp_booleans.py +++ b/SimpleCADAPI/src/simplecadapi/kernel/ocp_booleans.py @@ -2,10 +2,12 @@ from __future__ import annotations -from typing import List, Optional, Sequence +from dataclasses import dataclass +from typing import List, Optional, Sequence, Tuple from OCP.BOPAlgo import BOPAlgo_GlueOff, BOPAlgo_GlueShift from OCP.BRepAlgoAPI import BRepAlgoAPI_Common, BRepAlgoAPI_Cut, BRepAlgoAPI_Fuse +from OCP.BRepTools import BRepTools_History from OCP.ShapeUpgrade import ShapeUpgrade_UnifySameDomain from OCP.TopAbs import TopAbs_SOLID from OCP.TopExp import TopExp_Explorer @@ -37,25 +39,109 @@ def clean_shape(shape: TopoDS_Shape) -> TopoDS_Shape: return unifier.Shape() -def fuse_shapes(shapes: Sequence[TopoDS_Shape], *, glue: bool = True, tol: Optional[float] = None, clean: bool = True) -> TopoDS_Shape: +@dataclass(frozen=True) +class FuseHistoryResult: + shape: TopoDS_Shape + history: BRepTools_History + section_edges: Tuple[TopoDS_Shape, ...] = () + + +def _is_result_member(result_shape: TopoDS_Shape, candidate: TopoDS_Shape) -> bool: + if result_shape.IsSame(candidate): + return True + explorer = TopExp_Explorer(result_shape, candidate.ShapeType()) + while explorer.More(): + if explorer.Current().IsSame(candidate): + return True + explorer.Next() + return False + + +def fuse_shapes_with_history( + shapes: Sequence[TopoDS_Shape], + *, + glue: bool = True, + tol: Optional[float] = None, + clean: bool = True, +) -> FuseHistoryResult: + """Fuse shapes once and retain history through optional same-domain cleanup.""" + if not shapes: raise ValueError("fuse_shapes requires at least one shape") if len(shapes) == 1: - return shapes[0] + return FuseHistoryResult(shape=shapes[0], history=BRepTools_History()) + builder = BRepAlgoAPI_Fuse() builder.SetRunParallel(True) builder.SetUseOBB(True) + builder.SetToFillHistory(True) builder.SetArguments(_list_of([shapes[0]])) builder.SetTools(_list_of(list(shapes[1:]))) if tol is not None: builder.SetFuzzyValue(float(tol)) - # Match CadQuery's Shape.fuse(glue=True) behavior: CadQuery maps glue=True - # to OCC's GlueShift, not GlueFull. GlueFull can leave overlapping solids - # separate in cases where CQ would return one fused solid. builder.SetGlue(BOPAlgo_GlueShift if glue else BOPAlgo_GlueOff) builder.Build() if not builder.IsDone(): raise ValueError("OCP fuse failed") + + result = builder.Shape() + history = BRepTools_History() + history.Merge(builder.History()) + section_edges = tuple(builder.SectionEdges()) + + if clean: + unifier = ShapeUpgrade_UnifySameDomain(result, True, True, True) + unifier.Build() + result = unifier.Shape() + clean_history = unifier.History() + history.Merge(clean_history) + + final_section_edges: List[TopoDS_Shape] = [] + for edge in section_edges: + candidates = [*clean_history.Modified(edge), *clean_history.Generated(edge)] + if not candidates and not clean_history.IsRemoved(edge): + candidates = [edge] + for candidate in candidates: + if _is_result_member(result, candidate) and not any( + existing.IsSame(candidate) for existing in final_section_edges + ): + final_section_edges.append(candidate) + section_edges = tuple(final_section_edges) + + return FuseHistoryResult( + shape=result, + history=history, + section_edges=section_edges, + ) + + +def fuse_shapes( + shapes: Sequence[TopoDS_Shape], + *, + glue: bool = True, + tol: Optional[float] = None, + clean: bool = True, +) -> TopoDS_Shape: + """Fuse shapes without collecting topology history.""" + + if not shapes: + raise ValueError("fuse_shapes requires at least one shape") + if len(shapes) == 1: + return shapes[0] + + builder = BRepAlgoAPI_Fuse() + builder.SetRunParallel(True) + builder.SetUseOBB(True) + builder.SetToFillHistory(False) + builder.SetArguments(_list_of([shapes[0]])) + builder.SetTools(_list_of(list(shapes[1:]))) + if tol is not None: + builder.SetFuzzyValue(float(tol)) + builder.SetGlue(BOPAlgo_GlueShift if glue else BOPAlgo_GlueOff) + builder.Build() + if not builder.IsDone(): + raise ValueError("OCP fuse failed") + result = builder.Shape() return clean_shape(result) if clean else result @@ -66,6 +152,7 @@ def cut_shapes(body: TopoDS_Shape, tools: Sequence[TopoDS_Shape]) -> TopoDS_Shap builder = BRepAlgoAPI_Cut() builder.SetRunParallel(True) builder.SetUseOBB(True) + builder.SetToFillHistory(False) builder.SetArguments(_list_of([body])) builder.SetTools(_list_of(tools)) builder.Build() @@ -84,6 +171,7 @@ def common_shapes(shapes: Sequence[TopoDS_Shape]) -> TopoDS_Shape: builder = BRepAlgoAPI_Common() builder.SetRunParallel(True) builder.SetUseOBB(True) + builder.SetToFillHistory(False) builder.SetArguments(_list_of([result])) builder.SetTools(_list_of([tool])) builder.Build() diff --git a/SimpleCADAPI/src/simplecadapi/kernel/ocp_builders.py b/SimpleCADAPI/src/simplecadapi/kernel/ocp_builders.py index e7fa677..5bb0802 100644 --- a/SimpleCADAPI/src/simplecadapi/kernel/ocp_builders.py +++ b/SimpleCADAPI/src/simplecadapi/kernel/ocp_builders.py @@ -2,6 +2,9 @@ from __future__ import annotations +from dataclasses import dataclass +from typing import Any, Tuple + from OCP.BRepPrimAPI import ( BRepPrimAPI_MakeBox, BRepPrimAPI_MakeCone, @@ -11,6 +14,19 @@ from OCP.BRepPrimAPI import ( from OCP.gp import gp_Ax2, gp_Dir, gp_Pnt +@dataclass(frozen=True) +class PrimitiveRoleWitness: + role: str + shape: Any + evidence_method: str + + +@dataclass(frozen=True) +class PrimitiveBuildResult: + solid: Any + roles: Tuple[PrimitiveRoleWitness, ...] + + def _point(value: tuple[float, float, float]) -> gp_Pnt: return gp_Pnt(float(value[0]), float(value[1]), float(value[2])) @@ -25,11 +41,27 @@ def _axis2( def make_box_solid(corner: tuple[float, float, float], dx: float, dy: float, dz: float): + return build_box_primitive(corner, dx, dy, dz).solid + + +def build_box_primitive( + corner: tuple[float, float, float], dx: float, dy: float, dz: float +) -> PrimitiveBuildResult: builder = BRepPrimAPI_MakeBox(_point(corner), float(dx), float(dy), float(dz)) builder.Build() if not builder.IsDone(): raise ValueError("OCP box builder failed") - return builder.Solid() + return PrimitiveBuildResult( + solid=builder.Solid(), + roles=( + PrimitiveRoleWitness("box.bottom", builder.BottomFace(), "Box.BottomFace"), + PrimitiveRoleWitness("box.top", builder.TopFace(), "Box.TopFace"), + PrimitiveRoleWitness("box.front", builder.FrontFace(), "Box.FrontFace"), + PrimitiveRoleWitness("box.back", builder.BackFace(), "Box.BackFace"), + PrimitiveRoleWitness("box.left", builder.LeftFace(), "Box.LeftFace"), + PrimitiveRoleWitness("box.right", builder.RightFace(), "Box.RightFace"), + ), + ) def make_cylinder_solid( @@ -38,13 +70,47 @@ def make_cylinder_solid( radius: float, height: float, ): + return build_cylinder_primitive(origin, axis, radius, height).solid + + +def build_cylinder_primitive( + origin: tuple[float, float, float], + axis: tuple[float, float, float], + radius: float, + height: float, +) -> PrimitiveBuildResult: builder = BRepPrimAPI_MakeCylinder( _axis2(origin, axis), float(radius), float(height) ) builder.Build() if not builder.IsDone(): raise ValueError("OCP cylinder builder failed") - return builder.Solid() + primitive = builder.Cylinder() + return PrimitiveBuildResult( + solid=builder.Solid(), + roles=( + PrimitiveRoleWitness( + "cylinder.start", primitive.BottomFace(), "Cylinder.BottomFace" + ), + PrimitiveRoleWitness( + "cylinder.end", primitive.TopFace(), "Cylinder.TopFace" + ), + PrimitiveRoleWitness( + "cylinder.side", primitive.LateralFace(), "Cylinder.LateralFace" + ), + PrimitiveRoleWitness( + "cylinder.start_boundary", + primitive.BottomEdge(), + "Cylinder.BottomEdge", + ), + PrimitiveRoleWitness( + "cylinder.end_boundary", primitive.TopEdge(), "Cylinder.TopEdge" + ), + PrimitiveRoleWitness( + "cylinder.seam", primitive.StartEdge(), "Cylinder.StartEdge" + ), + ), + ) def make_cone_solid( @@ -54,6 +120,18 @@ def make_cone_solid( top_radius: float, height: float, ): + return build_cone_primitive( + origin, axis, bottom_radius, top_radius, height + ).solid + + +def build_cone_primitive( + origin: tuple[float, float, float], + axis: tuple[float, float, float], + bottom_radius: float, + top_radius: float, + height: float, +) -> PrimitiveBuildResult: builder = BRepPrimAPI_MakeCone( _axis2(origin, axis), float(bottom_radius), @@ -63,7 +141,32 @@ def make_cone_solid( builder.Build() if not builder.IsDone(): raise ValueError("OCP cone builder failed") - return builder.Solid() + primitive = builder.Cone() + roles = [ + PrimitiveRoleWitness( + "cone.start", primitive.BottomFace(), "Cone.BottomFace" + ), + PrimitiveRoleWitness( + "cone.side", primitive.LateralFace(), "Cone.LateralFace" + ), + PrimitiveRoleWitness( + "cone.start_boundary", primitive.BottomEdge(), "Cone.BottomEdge" + ), + PrimitiveRoleWitness( + "cone.end_boundary", primitive.TopEdge(), "Cone.TopEdge" + ), + PrimitiveRoleWitness( + "cone.seam", primitive.StartEdge(), "Cone.StartEdge" + ), + ] + if float(top_radius) > 0.0: + roles.insert( + 1, + PrimitiveRoleWitness( + "cone.end", primitive.TopFace(), "Cone.TopFace" + ), + ) + return PrimitiveBuildResult(solid=builder.Solid(), roles=tuple(roles)) def make_sphere_solid(center: tuple[float, float, float], radius: float): diff --git a/SimpleCADAPI/src/simplecadapi/kernel/ocp_mesh.py b/SimpleCADAPI/src/simplecadapi/kernel/ocp_mesh.py index 3bfa2b3..5ff990c 100644 --- a/SimpleCADAPI/src/simplecadapi/kernel/ocp_mesh.py +++ b/SimpleCADAPI/src/simplecadapi/kernel/ocp_mesh.py @@ -5,8 +5,14 @@ from __future__ import annotations from typing import Iterable, List, Sequence, Tuple from OCP.BRep import BRep_Builder, BRep_Tool -from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeFace, BRepBuilderAPI_MakePolygon, BRepBuilderAPI_MakeSolid +from OCP.BRepBuilderAPI import ( + BRepBuilderAPI_MakeFace, + BRepBuilderAPI_MakePolygon, + BRepBuilderAPI_MakeSolid, + BRepBuilderAPI_Transform, +) from OCP.BRepMesh import BRepMesh_IncrementalMesh +from OCP.BRepTools import BRepTools from OCP.Poly import Poly_Triangulation from OCP.TopAbs import TopAbs_FORWARD, TopAbs_REVERSED from OCP.TopLoc import TopLoc_Location @@ -50,23 +56,88 @@ def solid_from_shell(shell): def tessellate_face(face: TopoDS_Face, tolerance: float = 0.35, angular_tolerance: float = 0.22): - mesh = BRepMesh_IncrementalMesh(face, float(tolerance), False, float(angular_tolerance), True) + vertices, triangles, _corner_normals = tessellate_face_with_normals( + face, + tolerance=tolerance, + angular_tolerance=angular_tolerance, + ) + return vertices, triangles + + +def tessellate_face_with_normals( + face: TopoDS_Face, + tolerance: float = 0.35, + angular_tolerance: float = 0.22, +): + """Return definition-local triangulation and per-corner CAD normals.""" + + prepared = _bake_face_location(face) + BRepTools.Clean_s(prepared, False) + mesh = BRepMesh_IncrementalMesh( + prepared, + float(tolerance), + False, + float(angular_tolerance), + False, + ) mesh.Perform() loc = TopLoc_Location() - tri = BRep_Tool.Triangulation_s(face, loc) + tri = BRep_Tool.Triangulation_s(prepared, loc, 0) if tri is None: - return [], [] + return [], [], [] trsf = loc.Transformation() vertices = [] for idx in range(1, tri.NbNodes() + 1): p = tri.Node(idx).Transformed(trsf) vertices.append((float(p.X()), float(p.Y()), float(p.Z()))) triangles = [] + corner_normals = [] reversed_face = face.Orientation() == TopAbs_REVERSED for idx in range(1, tri.NbTriangles() + 1): a, b, c = tri.Triangle(idx).Get() if reversed_face: - triangles.append((a - 1, c - 1, b - 1)) + node_indices = (a, c, b) else: - triangles.append((a - 1, b - 1, c - 1)) - return vertices, triangles + node_indices = (a, b, c) + triangles.append(tuple(index - 1 for index in node_indices)) + if tri.HasNormals(): + normals = [] + for node_index in node_indices: + direction = tri.Normal(node_index).Transformed(trsf) + if reversed_face: + direction = direction.Reversed() + normals.append((float(direction.X()), float(direction.Y()), float(direction.Z()))) + corner_normals.append(tuple(normals)) + else: + left = vertices[node_indices[1] - 1] + origin = vertices[node_indices[0] - 1] + right = vertices[node_indices[2] - 1] + cross = ( + (left[1] - origin[1]) * (right[2] - origin[2]) + - (left[2] - origin[2]) * (right[1] - origin[1]), + (left[2] - origin[2]) * (right[0] - origin[0]) + - (left[0] - origin[0]) * (right[2] - origin[2]), + (left[0] - origin[0]) * (right[1] - origin[1]) + - (left[1] - origin[1]) * (right[0] - origin[0]), + ) + length = sum(component * component for component in cross) ** 0.5 + normal = tuple(component / length for component in cross) if length > 0.0 else (0.0, 0.0, 0.0) + corner_normals.append((normal, normal, normal)) + return vertices, triangles, corner_normals + + +def _bake_face_location(face: TopoDS_Face) -> TopoDS_Face: + location = face.Location() + unlocated = face.Located(TopLoc_Location(), False) + if location.IsIdentity(): + return TopoDS.Face_s(unlocated) + builder = BRepBuilderAPI_Transform( + unlocated, + location.Transformation(), + True, + False, + ) + builder.Build() + if not builder.IsDone(): + raise ValueError("OCP face location bake failed") + return TopoDS.Face_s(builder.Shape()) diff --git a/SimpleCADAPI/src/simplecadapi/operations.py b/SimpleCADAPI/src/simplecadapi/operations.py index 361a736..3c8d69e 100644 --- a/SimpleCADAPI/src/simplecadapi/operations.py +++ b/SimpleCADAPI/src/simplecadapi/operations.py @@ -3,7 +3,8 @@ from __future__ import annotations import re -from typing import Any, Dict, List, Optional, Sequence, Set, Tuple, Union, cast +import uuid +from typing import Any, Dict, List, Mapping, Optional, Sequence, Set, Tuple, Union, cast import math import numpy as np @@ -23,18 +24,20 @@ from .core import ( Solid, Compound, AnyShape, + clone_semantic_shape_view, get_current_cs, ) from .autotag import apply_tracking_tags_to_delta from .expr import ScalarLike, evaluate_scalar, evaluate_value from .graph import ( attach_graph_node, + attach_semantic_graph_node, get_active_session, record_operation, record_operation_if_active, suspend_graph_recording, ) -from .ql import ShapeSelector +from .ql import ShapeSelector, output_role from .product import ( Assembly, Component, @@ -57,19 +60,42 @@ from .product import ( solve_assembly_constraints, ) from .sketch import Sketch, SketchRef, SketchSolveResult -from .tagging import normalize_tag +from .tagging import ( + LineagePolicy, + SemanticCapabilityError, + TagBinding, + TagBindingScope, + TagCertainty, + TagEvidence, + TagLifecycle, + TagProducer, + TagPropagation, + TagScope, + TagTarget, + TopologyPropagation, + lineage_policy_allows, + normalize_tag, + normalize_tag_scope, +) from .topology import ( SemanticDelta, SemanticRef, TopoDelta, TopoEntry, + TopoKind, TopoRef, topo_ref_to_dict, ) from .tracking import ( + TrackingPolicy, TrackedBooleanResult, TrackedResult, + _topo_id, + current_tracking_policy, + tracked_box, tracked_chamfer, + tracked_cone, + tracked_cylinder, tracked_cut, tracked_extrude, tracked_fillet, @@ -80,13 +106,11 @@ from .tracking import ( tracked_rotate, tracked_shell, tracked_sweep, + tracked_twisted_sweep, tracked_translate, - tracked_union, + track_union_history, ) from .kernel.ocp_builders import ( - make_box_solid, - make_cone_solid, - make_cylinder_solid, make_sphere_solid, ) from .kernel.ocp_curves import ( @@ -112,9 +136,20 @@ from .kernel.ocp_transforms import ( place_shape_ocp, translate_shape_ocp, ) -from .kernel.ocp_booleans import common_shapes, cut_shapes, fuse_shapes, solids_of +from .kernel.ocp_booleans import ( + common_shapes, + cut_shapes, + fuse_shapes, + fuse_shapes_with_history, + solids_of, +) from .kernel.ocp_topology import faces_of as faces_of_ocp -from .kernel.ocp_export import export_step_shapes, export_stl_shape, make_compound, make_compound_always +from .kernel.ocp_export import ( + export_step_shapes, + export_stl_shape, + make_compound, + make_compound_always, +) from .kernel.ocp_mesh import tessellate_face from .kernel.ocp_properties import bounding_box, distance as ocp_distance @@ -142,6 +177,7 @@ _OP_MAKE_EXTRUDE_RSOLID = "make_extrude_rsolid" _OP_MAKE_REVOLVE_RSOLID = "make_revolve_rsolid" _OP_MAKE_LOFT_RSOLID = "make_loft_rsolid" _OP_MAKE_SWEEP_RSOLID = "make_sweep_rsolid" +_OP_MAKE_TWISTED_SWEEP_RSOLID = "make_twisted_sweep_rsolid" _OP_MAKE_UNION_RSOLID = "make_union_rsolid" _OP_MAKE_CUT_RSOLID = "make_cut_rsolid" _OP_MAKE_INTERSECT_RSOLID = "make_intersect_rsolid" @@ -156,6 +192,7 @@ _OP_MAKE_SELECT_REDGE = "make_select_redge" _OP_MAKE_SELECT_RWIRE = "make_select_rwire" _OP_MAKE_SELECT_RFACE = "make_select_rface" _OP_MAKE_SELECT_RSOLID = "make_select_rsolid" +_OP_APPLY_TAG_RSELECTION = "apply_tag_rselection" _OP_MAKE_SKETCH_RSKETCH = "make_sketch_rsketch" _OP_MAKE_ADD_POINT_RSKETCH = "make_add_point_rsketch" _OP_MAKE_ADD_LINE_RSKETCH = "make_add_line_rsketch" @@ -188,7 +225,70 @@ _OP_MAKE_PRISMATIC_CONSTRAINT_RASSEMBLY = "make_prismatic_constraint_rassembly" _OP_MAKE_GEAR_CONSTRAINT_RASSEMBLY = "make_gear_constraint_rassembly" _OP_MAKE_BELT_CONSTRAINT_RASSEMBLY = "make_belt_constraint_rassembly" _OP_MAKE_RACK_PINION_CONSTRAINT_RASSEMBLY = "make_rack_pinion_constraint_rassembly" -_OP_MAKE_SOLVE_ASSEMBLY_CONSTRAINTS_RASSEMBLY = "make_solve_assembly_constraints_rassembly" +_OP_MAKE_SOLVE_ASSEMBLY_CONSTRAINTS_RASSEMBLY = ( + "make_solve_assembly_constraints_rassembly" +) + + +_OPERATION_OUTPUT_ROLE_CARDINALITY: Dict[str, Tuple[Tuple[str, str], ...]] = { + "make_box_rsolid": ( + ("box.bottom", "one"), + ("box.top", "one"), + ("box.front", "one"), + ("box.back", "one"), + ("box.left", "one"), + ("box.right", "one"), + ), + "make_cylinder_rsolid": ( + ("cylinder.start", "one"), + ("cylinder.end", "one"), + ("cylinder.side", "one"), + ("cylinder.start_boundary", "one"), + ("cylinder.end_boundary", "one"), + ("cylinder.seam", "one"), + ), + "make_cone_rsolid": ( + ("cone.start", "one"), + ("cone.end", "one"), + ("cone.side", "one"), + ("cone.start_boundary", "one"), + ("cone.end_boundary", "one"), + ("cone.seam", "one"), + ), + _OP_MAKE_EXTRUDE_RSOLID: ( + ("extrusion.start", "one"), + ("extrusion.end", "one"), + ("extrusion.side", "many"), + ), + _OP_MAKE_REVOLVE_RSOLID: ( + ("revolution.start", "one"), + ("revolution.end", "one"), + ("revolution.side", "many"), + ), + _OP_MAKE_FILLET_RSOLID: (("fillet.patch", "many"),), + _OP_MAKE_CHAMFER_RSOLID: (("chamfer.patch", "many"),), + _OP_MAKE_SHELL_RSOLID: ( + ("shell.body_face", "many"), + ("shell.offset_face", "many"), + ("shell.closing_descendant", "many"), + ("shell.wall", "many"), + ), + _OP_MAKE_LOFT_RSOLID: ( + ("loft.start", "one"), + ("loft.end", "one"), + ("loft.side", "many"), + ), + _OP_MAKE_SWEEP_RSOLID: ( + ("sweep.start", "one"), + ("sweep.end", "one"), + ("sweep.side", "many"), + ), + _OP_MAKE_TWISTED_SWEEP_RSOLID: ( + ("twisted_sweep.start", "one"), + ("twisted_sweep.end", "one"), + ("twisted_sweep.side", "many"), + ), +} _SKETCH_CONSTRAINT_OPS = { @@ -327,7 +427,9 @@ def _reserve_semantic_id(kind: str, value: str) -> None: registry.add(value) -def _semantic_created(entity_type: str, entity_id: str, metadata: Optional[Dict[str, Any]] = None) -> SemanticDelta: +def _semantic_created( + entity_type: str, entity_id: str, metadata: Optional[Dict[str, Any]] = None +) -> SemanticDelta: return SemanticDelta( created=( SemanticRef( @@ -341,7 +443,9 @@ def _semantic_created(entity_type: str, entity_id: str, metadata: Optional[Dict[ ) -def _semantic_modified(entity_type: str, entity_id: str, metadata: Optional[Dict[str, Any]] = None) -> SemanticDelta: +def _semantic_modified( + entity_type: str, entity_id: str, metadata: Optional[Dict[str, Any]] = None +) -> SemanticDelta: return SemanticDelta( modified=( SemanticRef( @@ -529,10 +633,52 @@ def _merge_topo_deltas(deltas: Sequence[TopoDelta]) -> Optional[TopoDelta]: def _copy_runtime_state(source: AnyShape, target: AnyShape) -> AnyShape: runtime = getattr(source, "_runtime", None) if isinstance(runtime, dict): - target._runtime = runtime.copy() + # A transformed BRep owns a new evaluated geometry. Copy semantic and + # graph provenance, but never carry the source's geometry-dependent + # mesh or mesh error into the new wrapper. + target._runtime = { + key: value + for key, value in runtime.items() + if not key == "mesh.error" and not key == "mesh.default" and not key.startswith("mesh:") + } return target +def _attach_lineage_from_source( + source: AnyShape, + target: AnyShape, + *, + derivation: str, + op: str, + coverage: str = "complete", +) -> None: + evidence = TagEvidence( + "topology_change", + { + "op": op, + "derivation": derivation, + "coverage": coverage, + }, + ) + bindings = list(source._local_tag_bindings()) + bindings.extend( + witness.binding + for witness in source._tag_lineage + if witness.coverage == "complete" + and lineage_policy_allows(witness.binding.propagation, witness.derivation) + ) + unique_bindings = {binding.binding_id: binding for binding in bindings} + for binding in unique_bindings.values(): + target._add_tag_lineage( + binding, + derivation=derivation, + source_topo_id=source.topo_id, + evidence=evidence, + coverage=coverage, + ) + target._set_runtime("semantic.lineage.coverage", coverage) + + def _current_context_metadata() -> Dict[str, Tuple[float, float, float]]: cs = get_current_cs() return { @@ -704,9 +850,7 @@ def _shape_geom_type(shape: AnyShape) -> Optional[str]: } return mapping.get( surface_type, - str(surface_type) - .replace("GeomAbs_SurfaceType.GeomAbs_", "") - .upper(), + str(surface_type).replace("GeomAbs_SurfaceType.GeomAbs_", "").upper(), ) except Exception: return None @@ -871,6 +1015,14 @@ def _record_geo_selection_nodes( if op is None: continue kind = _shape_kind_token(selected) + selected_node = _active_graph_node_for_shape(selected) + inputs = [source_node] + if ( + selected_node is not None + and getattr(selected_node, "op", None) == _OP_APPLY_TAG_RSELECTION + and selected_node is not source_node + ): + inputs.append(selected_node) node = record_operation( op=op, params={ @@ -880,7 +1032,7 @@ def _record_geo_selection_nodes( source_shape=source_shape, ), }, - inputs=[source_node], + inputs=inputs, output_count=1, semantic_delta=_semantic_delta_for_output(op, entity_type="Selection"), context=_current_context_metadata(), @@ -908,9 +1060,15 @@ def _is_geo_select_node(node: object) -> bool: return getattr(node, "op", None) in _GEO_SELECT_OPS -def _ensure_source_shape_has_own_selection_node(source_shape: AnyShape) -> Optional[object]: +def _ensure_source_shape_has_own_selection_node( + source_shape: AnyShape, +) -> Optional[object]: source_node = _active_graph_node_for_shape(source_shape) - if source_node is None or _is_geo_select_node(source_node): + if ( + source_node is None + or _is_geo_select_node(source_node) + or getattr(source_node, "op", None) == _OP_APPLY_TAG_RSELECTION + ): return source_node parent_source = _selection_source_for_shape(source_shape) @@ -1151,6 +1309,7 @@ def _semantic_delta_for_output( _OP_MAKE_REVOLVE_RSOLID, _OP_MAKE_LOFT_RSOLID, _OP_MAKE_SWEEP_RSOLID, + _OP_MAKE_TWISTED_SWEEP_RSOLID, _OP_MAKE_FILLET_RSOLID, _OP_MAKE_CHAMFER_RSOLID, _OP_MAKE_SHELL_RSOLID, @@ -1176,6 +1335,7 @@ def _semantic_delta_for_output( _OP_MAKE_REVOLVE_RSOLID, _OP_MAKE_LOFT_RSOLID, _OP_MAKE_SWEEP_RSOLID, + _OP_MAKE_TWISTED_SWEEP_RSOLID, _OP_MAKE_FILLET_RSOLID, _OP_MAKE_CHAMFER_RSOLID, _OP_MAKE_SHELL_RSOLID, @@ -1233,6 +1393,159 @@ def _finalize_primitive_solid( ) +def _topology_identity_payload(binding: TagBinding) -> Optional[Dict[str, Any]]: + for key in ("topology_name", "source_topology_name"): + value = binding.evidence.data.get(key) + if isinstance(value, dict): + return value + return None + + +def _copy_exact_topology_identity_tags( + target: AnyShape, + sources: Sequence[AnyShape], + *, + operation: str, +) -> AnyShape: + source_members = [ + member + for source in sources + for member in ( + source.get_edges() + if hasattr(source, "get_edges") + else [source] + ) + if isinstance(member, Edge) + ] + target_members = ( + target.get_edges() if hasattr(target, "get_edges") else [target] + ) + result = target + for target_member in target_members: + if not isinstance(target_member, Edge): + continue + matches = [ + source + for source in source_members + if source.wrapped.IsSame(target_member.wrapped) + ] + if len(matches) != 1: + continue + source = matches[0] + for binding in source._local_tag_bindings(): + topology_identity = _topology_identity_payload(binding) + if topology_identity is None: + continue + local_name = topology_identity.get("local_name") + if not isinstance(local_name, str) or not local_name: + continue + result = _apply_topology_identity_tag( + result, + [target_member], + binding.tag, + kind="edge", + local_name=local_name, + authoring_source=f"simplecadapi.{operation}.identity_tag", + ) + return result + + +def _apply_topology_identity_tag( + scope: AnyShape, + targets: Union[ShapeSelector, Sequence[AnyShape]], + tag: str, + *, + kind: str, + local_name: str, + authoring_source: str, +) -> AnyShape: + normalized = normalize_tag(tag, strict=True) + return _apply_tag_rselection( + scope, + targets, + normalized, + ( + TopologyPropagation.DOWNWARD + if kind == "face" + else TopologyPropagation.LOCAL + ), + LineagePolicy.CONTINUATION_FRAGMENT, + authoring_source=authoring_source, + extra_evidence={ + "topology_name": { + "kind": kind, + "name": normalized, + "local_name": normalize_tag(local_name, strict=True), + } + }, + ) + + +def _apply_shape_tag_prefix( + shape: AnyShape, + tag_prefix: Optional[str], + *, + kind: str, + authoring_source: str, +) -> AnyShape: + if tag_prefix is None: + return shape + prefix = normalize_tag(tag_prefix, strict=True) + tag = f"{prefix}.{kind}" + return _apply_topology_identity_tag( + shape, + ShapeSelector(kind).exactly(1), + tag, + kind=kind, + local_name=kind, + authoring_source=authoring_source, + ) + + +def _apply_profile_tag_prefix( + shape: Union[Wire, Face], + tag_prefix: Optional[str], + edge_tags: Optional[Sequence[str]], + *, + authoring_source: str, +) -> Union[Wire, Face]: + if tag_prefix is None and edge_tags is None: + return shape + prefix = normalize_tag(tag_prefix, strict=True) if tag_prefix is not None else None + result: AnyShape = ( + _apply_shape_tag_prefix( + shape, + prefix, + kind=shape.__class__.__name__.lower(), + authoring_source=authoring_source, + ) + if prefix is not None + else shape + ) + edges = list(shape.get_edges()) + local_names = ( + [str(value) for value in edge_tags] + if edge_tags is not None + else [f"line{index + 1}" for index in range(len(edges))] + ) + if len(local_names) != len(edges): + raise ValueError( + f"edge_tags must contain exactly {len(edges)} tags, got {len(local_names)}" + ) + for edge, raw_local_name in zip(edges, local_names): + local_name = normalize_tag(raw_local_name, strict=True) + edge_tag = f"{prefix}.edge.{local_name}" if prefix else local_name + result = _apply_topology_identity_tag( + result, + [edge], + edge_tag, + kind="edge", + local_name=local_name, + authoring_source=authoring_source, + ) + return cast(Union[Wire, Face], result) + + def _finalize_derived_shape( shape: AnyShape, *, @@ -1281,10 +1594,14 @@ def _finalize_tracked_solid( op: str, params: Dict[str, object], source_solid: Optional[Solid] = None, + source_solids: Optional[Sequence[Solid]] = None, delta: Optional[object] = None, delta_entries: Optional[Dict[str, Dict[str, object]]] = None, input_shapes: Optional[Sequence[AnyShape]] = None, ) -> Solid: + if current_tracking_policy() == TrackingPolicy.GRAPH: + delta = None + delta_entries = None if delta is not None: apply_tracking_tags_to_delta( solid, @@ -1292,6 +1609,8 @@ def _finalize_tracked_solid( cast(Optional[Dict[str, Dict[str, Any]]], delta_entries), op=op, source_solid=source_solid, + source_solids=source_solids, + source_shapes=input_shapes, ) _attach_track_summary( solid, @@ -1311,6 +1630,259 @@ def _finalize_tracked_solid( return solid +def _normalize_operation_role_tags( + op: str, + named_tags: Sequence[Tuple[str, Optional[str]]], +) -> List[Tuple[str, str, str]]: + role_specs = _OPERATION_OUTPUT_ROLE_CARDINALITY[op] + cardinality_by_role = dict(role_specs) + requested: Dict[str, str] = {} + + for role, raw_tag in named_tags: + if raw_tag is None: + continue + if role in requested: + raise ValueError(f"duplicate role tag assignment: {role}") + requested[role] = normalize_tag(raw_tag, strict=True) + + unknown = sorted(set(requested) - set(cardinality_by_role)) + if unknown: + raise ValueError( + f"unsupported output role(s) for {op}: {', '.join(unknown)}; " + f"expected one of {', '.join(cardinality_by_role)}" + ) + return [ + (role, cardinality, requested[role]) + for role, cardinality in role_specs + if role in requested + ] + + +def _validate_operation_output_roles( + delta: TopoDelta, + assignments: Sequence[Tuple[str, str, str]], +) -> Dict[str, str]: + target_kinds: Dict[str, str] = {} + for role, cardinality, _tag in assignments: + role_entries = { + (entry.ref, entry.ref.kind.name.lower()) + for entry in delta.roles + if entry.role == role + and str(entry.metadata.get("coverage", "complete")).lower() == "complete" + and str(entry.metadata.get("status", "proven")).lower() == "proven" + } + refs = {ref for ref, _kind in role_entries} + kinds = {kind for _ref, kind in role_entries} + if cardinality == "one" and len(refs) != 1: + raise SemanticCapabilityError( + f"operation output role '{role}' requires exactly one kernel-proven result, got {len(refs)}" + ) + if cardinality == "many" and not refs: + raise SemanticCapabilityError( + f"operation output role '{role}' requires at least one kernel-proven result" + ) + if len(kinds) != 1: + raise SemanticCapabilityError( + f"operation output role '{role}' does not resolve to one topology kind" + ) + target_kinds[role] = next(iter(kinds)) + return target_kinds + + +def _apply_operation_role_tags( + solid: Solid, + *, + op: str, + assignments: Sequence[Tuple[str, str, str]], + target_kinds: Mapping[str, str], + result_tag: Optional[str] = None, +) -> Solid: + if not assignments and result_tag is None: + return solid + + role_source_node = _active_graph_node_for_shape(solid) + role_source_slot = int(solid._get_runtime("graph.output_slot", 0)) + result: AnyShape = solid + for role, cardinality, tag in assignments: + selector = ShapeSelector(target_kinds[role]).where(output_role(role)) + selector = selector.exactly(1) if cardinality == "one" else selector.at_least(1) + result = _apply_tag_rselection( + result, + selector, + tag, + TopologyPropagation.LOCAL, + LineagePolicy.CONTINUATION_FRAGMENT, + authoring_source=f"simplecadapi.{op}.role_tag", + extra_evidence={ + "operation_output_role": { + "source_node_id": ( + role_source_node.node_id + if role_source_node is not None + else None + ), + "source_output_slot": role_source_slot, + "operation": op, + "role": role, + "cardinality": cardinality, + } + }, + ) + if result_tag is not None: + result = _apply_tag_rselection( + result, + ShapeSelector("solid").exactly(1), + result_tag, + TopologyPropagation.LOCAL, + LineagePolicy.CONTINUATION_FRAGMENT, + authoring_source=f"simplecadapi.{op}.result_tag", + extra_evidence={ + "operation_result": { + "source_node_id": ( + role_source_node.node_id + if role_source_node is not None + else None + ), + "source_output_slot": role_source_slot, + "operation": op, + } + }, + ) + return cast(Solid, result) + + +def _topology_identity_local_name(shape: AnyShape) -> Optional[str]: + for binding in shape._local_tag_bindings(): + payload = _topology_identity_payload(binding) + if payload is not None and payload.get("kind") == shape.__class__.__name__.lower(): + local_name = payload.get("local_name") + if isinstance(local_name, str) and local_name: + return local_name + return None + + +def _apply_feature_tag_prefix( + solid: Solid, + *, + tag_prefix: Optional[str], + op: str, + delta: TopoDelta, + start_role: Optional[str] = None, + end_role: Optional[str] = None, + side_role: Optional[str] = None, + source_shapes: Sequence[AnyShape] = (), + edge_roles: Sequence[Tuple[str, str]] = (), + face_roles: Sequence[Tuple[str, str]] = (), +) -> Solid: + if tag_prefix is None: + return solid + prefix = normalize_tag(tag_prefix, strict=True) + result: AnyShape = _apply_topology_identity_tag( + solid, + ShapeSelector("solid").exactly(1), + f"{prefix}.solid", + kind="solid", + local_name="solid", + authoring_source=f"simplecadapi.{op}.tag_prefix", + ) + + for role, local_name in ( + (start_role, "start"), + (end_role, "end"), + (side_role, "side"), + ): + if role is None: + continue + cardinality = "one" if local_name in {"start", "end"} else "many" + selector = ShapeSelector("face").where(output_role(role)) + selector = selector.exactly(1) if cardinality == "one" else selector.at_least(1) + result = _apply_topology_identity_tag( + cast(Solid, result), + selector, + f"{prefix}.face.{local_name}", + kind="face", + local_name=local_name, + authoring_source=f"simplecadapi.{op}.tag_prefix", + ) + + source_edges = { + _topo_id(edge.wrapped): edge + for source in source_shapes + for edge in ( + source.get_edges() + if hasattr(source, "get_edges") + else ([source] if isinstance(source, Edge) else []) + ) + } + result_faces = { + _topo_id(face.wrapped): face for face in cast(Solid, result).get_faces() + } + if side_role is not None: + # A side Face may have multiple source profile Edges (for example, a + # loft side joins the corresponding Edges from two sections). A + # A topology-identity tag is safe only when kernel history proves a bijection. + side_correspondence: List[Tuple[str, str]] = [] + for role_entry in delta.roles: + if role_entry.role != side_role: + continue + parent_edges = [ + parent + for parent in role_entry.parent_refs + if parent.kind == TopoKind.EDGE + ] + if len(parent_edges) != 1: + continue + side_correspondence.append( + (role_entry.ref.topo_id, parent_edges[0].topo_id) + ) + + target_sources: Dict[str, Set[str]] = {} + source_targets: Dict[str, Set[str]] = {} + for target_id, source_id in side_correspondence: + target_sources.setdefault(target_id, set()).add(source_id) + source_targets.setdefault(source_id, set()).add(target_id) + + for target_id, source_id in side_correspondence: + if len(target_sources[target_id]) != 1: + continue + if len(source_targets[source_id]) != 1: + continue + source_edge = source_edges.get(source_id) + target_face = result_faces.get(target_id) + if source_edge is None or target_face is None: + continue + edge_local_name = _topology_identity_local_name(source_edge) + if edge_local_name is None: + continue + result = _apply_topology_identity_tag( + cast(Solid, result), + [target_face], + f"{prefix}.face.side.{edge_local_name}", + kind="face", + local_name=f"side.{edge_local_name}", + authoring_source=f"simplecadapi.{op}.profile_edge_tag", + ) + + for role, local_name in edge_roles: + result = _apply_topology_identity_tag( + cast(Solid, result), + ShapeSelector("edge").where(output_role(role)).exactly(1), + f"{prefix}.edge.{local_name}", + kind="edge", + local_name=local_name, + authoring_source=f"simplecadapi.{op}.tag_prefix", + ) + for role, local_name in face_roles: + result = _apply_topology_identity_tag( + cast(Solid, result), + ShapeSelector("face").where(output_role(role)).exactly(1), + f"{prefix}.face.{local_name}", + kind="face", + local_name=local_name, + authoring_source=f"simplecadapi.{op}.tag_prefix", + ) + return cast(Solid, result) + + # ============================================================================= # 基础图形创建函数 # ============================================================================= @@ -1322,7 +1894,11 @@ def make_point_rvertex(x: ScalarLike, y: ScalarLike, z: ScalarLike) -> Vertex: cs = get_current_cs() point_value = cast(Tuple[float, float, float], evaluate_value((x, y, z))) global_point = cs.transform_point(np.array(point_value)) - vertex_shape = BRepBuilderAPI_MakeVertex(gp_Pnt(float(global_point[0]), float(global_point[1]), float(global_point[2]))).Vertex() + vertex_shape = BRepBuilderAPI_MakeVertex( + gp_Pnt( + float(global_point[0]), float(global_point[1]), float(global_point[2]) + ) + ).Vertex() return cast( Vertex, _finalize_primitive_shape( @@ -1425,6 +2001,24 @@ def _resolve_sketch_targets( return refs +def _begin_linear_sketch_edits(sketch: Sketch) -> Sketch: + """Enable in-place edits for one private, linear sketch construction chain.""" + sketch._set_runtime("sketch.linear_edit", True) + return sketch + + +def _end_linear_sketch_edits(sketch: Sketch) -> Sketch: + sketch._set_runtime("sketch.linear_edit", False) + return sketch + + +def _sketch_update_target(sketch: Sketch) -> Sketch: + if sketch._get_runtime("sketch.linear_edit", False): + sketch._last_solve_result = None + return sketch + return sketch.clone(include_solve=False) + + def add_point_rsketch( sketch: Sketch, point_id: str, @@ -1433,7 +2027,7 @@ def add_point_rsketch( ) -> Sketch: """Add a named point entity and return an updated sketch document.""" try: - updated = sketch.clone(include_solve=False) + updated = _sketch_update_target(sketch) updated.add_point(point_id, x, y) return cast( Sketch, @@ -1496,7 +2090,7 @@ def add_line_rsketch( try: start_ref = _resolve_sketch_target(sketch, start, expected="point") end_ref = _resolve_sketch_target(sketch, end, expected="point") - updated = sketch.clone(include_solve=False) + updated = _sketch_update_target(sketch) updated.add_line(entity_id, start_ref, end_ref, construction=construction) return cast( Sketch, @@ -1542,7 +2136,7 @@ def add_circle_rsketch( """Add a named circle entity and return an updated sketch document.""" try: center_ref = _resolve_sketch_target(sketch, center, expected="point") - updated = sketch.clone(include_solve=False) + updated = _sketch_update_target(sketch) updated.add_circle(entity_id, center_ref, radius, construction=construction) return cast( Sketch, @@ -1599,9 +2193,11 @@ def add_bspline_rsketch( try: start_ref = _resolve_sketch_target(sketch, start, expected="point") end_ref = _resolve_sketch_target(sketch, end, expected="point") - updated = sketch.clone(include_solve=False) + updated = _sketch_update_target(sketch) updated.add_bspline( - entity_id, start_ref, end_ref, + entity_id, + start_ref, + end_ref, control_points=control_points, degree=degree, knots=knots, @@ -1665,9 +2261,12 @@ def add_arc_rsketch( start_ref = _resolve_sketch_target(sketch, start, expected="point") end_ref = _resolve_sketch_target(sketch, end, expected="point") center_ref = _resolve_sketch_target(sketch, center, expected="point") - updated = sketch.clone(include_solve=False) + updated = _sketch_update_target(sketch) updated.add_arc( - entity_id, start_ref, end_ref, center_ref, + entity_id, + start_ref, + end_ref, + center_ref, construction=construction, ) return cast( @@ -1716,7 +2315,7 @@ def _constrain_rsketch( expected: Optional[Sequence[Optional[Union[str, Sequence[str]]]]] = None, ) -> Sketch: target_refs = _resolve_sketch_targets(sketch, targets, expected=expected) - updated = sketch.clone(include_solve=False) + updated = _sketch_update_target(sketch) updated.add_constraint( kind, target_refs, @@ -1746,112 +2345,379 @@ def _constrain_rsketch( ) -def constrain_coincident_rsketch(sketch: Sketch, a: Union[SketchRef, str], b: Union[SketchRef, str], *, constraint_id: Optional[str] = None) -> Sketch: +def constrain_coincident_rsketch( + sketch: Sketch, + a: Union[SketchRef, str], + b: Union[SketchRef, str], + *, + constraint_id: Optional[str] = None, +) -> Sketch: """Constrain two sketch points to be coincident.""" - return _constrain_rsketch(sketch, "coincident", [a, b], constraint_id=constraint_id, expected=["point", "point"]) + return _constrain_rsketch( + sketch, + "coincident", + [a, b], + constraint_id=constraint_id, + expected=["point", "point"], + ) -def constrain_connect_rsketch(sketch: Sketch, a: Union[SketchRef, str], b: Union[SketchRef, str], *, constraint_id: Optional[str] = None) -> Sketch: +def constrain_connect_rsketch( + sketch: Sketch, + a: Union[SketchRef, str], + b: Union[SketchRef, str], + *, + constraint_id: Optional[str] = None, +) -> Sketch: """Alias for `constrain_coincident_rsketch` using connection wording.""" - return _constrain_rsketch(sketch, "coincident", [a, b], constraint_id=constraint_id, expected=["point", "point"]) + return _constrain_rsketch( + sketch, + "coincident", + [a, b], + constraint_id=constraint_id, + expected=["point", "point"], + ) -def constrain_point_on_rsketch(sketch: Sketch, point: Union[SketchRef, str], entity: Union[SketchRef, str], *, constraint_id: Optional[str] = None) -> Sketch: +def constrain_point_on_rsketch( + sketch: Sketch, + point: Union[SketchRef, str], + entity: Union[SketchRef, str], + *, + constraint_id: Optional[str] = None, +) -> Sketch: """Constrain a sketch point to lie on a line or circle.""" - return _constrain_rsketch(sketch, "point_on", [point, entity], constraint_id=constraint_id, expected=["point", ("line", "circle")]) + return _constrain_rsketch( + sketch, + "point_on", + [point, entity], + constraint_id=constraint_id, + expected=["point", ("line", "circle")], + ) -def constrain_horizontal_rsketch(sketch: Sketch, line: Union[SketchRef, str], *, constraint_id: Optional[str] = None) -> Sketch: +def constrain_horizontal_rsketch( + sketch: Sketch, line: Union[SketchRef, str], *, constraint_id: Optional[str] = None +) -> Sketch: """Constrain a sketch line to be horizontal.""" - return _constrain_rsketch(sketch, "horizontal", [line], constraint_id=constraint_id, expected=["line"]) + return _constrain_rsketch( + sketch, "horizontal", [line], constraint_id=constraint_id, expected=["line"] + ) -def constrain_vertical_rsketch(sketch: Sketch, line: Union[SketchRef, str], *, constraint_id: Optional[str] = None) -> Sketch: +def constrain_vertical_rsketch( + sketch: Sketch, line: Union[SketchRef, str], *, constraint_id: Optional[str] = None +) -> Sketch: """Constrain a sketch line to be vertical.""" - return _constrain_rsketch(sketch, "vertical", [line], constraint_id=constraint_id, expected=["line"]) + return _constrain_rsketch( + sketch, "vertical", [line], constraint_id=constraint_id, expected=["line"] + ) -def constrain_parallel_rsketch(sketch: Sketch, a: Union[SketchRef, str], b: Union[SketchRef, str], *, constraint_id: Optional[str] = None) -> Sketch: +def constrain_parallel_rsketch( + sketch: Sketch, + a: Union[SketchRef, str], + b: Union[SketchRef, str], + *, + constraint_id: Optional[str] = None, +) -> Sketch: """Constrain two sketch lines to be parallel.""" - return _constrain_rsketch(sketch, "parallel", [a, b], constraint_id=constraint_id, expected=["line", "line"]) + return _constrain_rsketch( + sketch, + "parallel", + [a, b], + constraint_id=constraint_id, + expected=["line", "line"], + ) -def constrain_perpendicular_rsketch(sketch: Sketch, a: Union[SketchRef, str], b: Union[SketchRef, str], *, constraint_id: Optional[str] = None) -> Sketch: +def constrain_perpendicular_rsketch( + sketch: Sketch, + a: Union[SketchRef, str], + b: Union[SketchRef, str], + *, + constraint_id: Optional[str] = None, +) -> Sketch: """Constrain two sketch lines to be perpendicular.""" - return _constrain_rsketch(sketch, "perpendicular", [a, b], constraint_id=constraint_id, expected=["line", "line"]) + return _constrain_rsketch( + sketch, + "perpendicular", + [a, b], + constraint_id=constraint_id, + expected=["line", "line"], + ) -def constrain_collinear_rsketch(sketch: Sketch, a: Union[SketchRef, str], b: Union[SketchRef, str], *, constraint_id: Optional[str] = None) -> Sketch: +def constrain_collinear_rsketch( + sketch: Sketch, + a: Union[SketchRef, str], + b: Union[SketchRef, str], + *, + constraint_id: Optional[str] = None, +) -> Sketch: """Constrain two sketch lines to lie on the same infinite line.""" - return _constrain_rsketch(sketch, "collinear", [a, b], constraint_id=constraint_id, expected=["line", "line"]) + return _constrain_rsketch( + sketch, + "collinear", + [a, b], + constraint_id=constraint_id, + expected=["line", "line"], + ) -def constrain_tangent_rsketch(sketch: Sketch, a: Union[SketchRef, str], b: Union[SketchRef, str], *, constraint_id: Optional[str] = None) -> Sketch: +def constrain_tangent_rsketch( + sketch: Sketch, + a: Union[SketchRef, str], + b: Union[SketchRef, str], + *, + constraint_id: Optional[str] = None, +) -> Sketch: """Constrain supported sketch curves to be tangent.""" - return _constrain_rsketch(sketch, "tangent", [a, b], constraint_id=constraint_id, expected=[("line", "circle"), ("line", "circle")]) + return _constrain_rsketch( + sketch, + "tangent", + [a, b], + constraint_id=constraint_id, + expected=[("line", "circle"), ("line", "circle")], + ) -def constrain_concentric_rsketch(sketch: Sketch, a: Union[SketchRef, str], b: Union[SketchRef, str], *, constraint_id: Optional[str] = None) -> Sketch: +def constrain_concentric_rsketch( + sketch: Sketch, + a: Union[SketchRef, str], + b: Union[SketchRef, str], + *, + constraint_id: Optional[str] = None, +) -> Sketch: """Constrain two sketch circles to share a center.""" - return _constrain_rsketch(sketch, "concentric", [a, b], constraint_id=constraint_id, expected=["circle", "circle"]) + return _constrain_rsketch( + sketch, + "concentric", + [a, b], + constraint_id=constraint_id, + expected=["circle", "circle"], + ) -def constrain_midpoint_rsketch(sketch: Sketch, point: Union[SketchRef, str], line: Union[SketchRef, str], *, constraint_id: Optional[str] = None) -> Sketch: +def constrain_midpoint_rsketch( + sketch: Sketch, + point: Union[SketchRef, str], + line: Union[SketchRef, str], + *, + constraint_id: Optional[str] = None, +) -> Sketch: """Constrain a sketch point to the midpoint of a line.""" - return _constrain_rsketch(sketch, "midpoint", [point, line], constraint_id=constraint_id, expected=["point", "line"]) + return _constrain_rsketch( + sketch, + "midpoint", + [point, line], + constraint_id=constraint_id, + expected=["point", "line"], + ) -def constrain_symmetric_rsketch(sketch: Sketch, a: Union[SketchRef, str], b: Union[SketchRef, str], axis: Union[SketchRef, str], *, constraint_id: Optional[str] = None) -> Sketch: +def constrain_symmetric_rsketch( + sketch: Sketch, + a: Union[SketchRef, str], + b: Union[SketchRef, str], + axis: Union[SketchRef, str], + *, + constraint_id: Optional[str] = None, +) -> Sketch: """Constrain two sketch points to be symmetric about a line axis.""" - return _constrain_rsketch(sketch, "symmetric", [a, b, axis], constraint_id=constraint_id, expected=["point", "point", "line"]) + return _constrain_rsketch( + sketch, + "symmetric", + [a, b, axis], + constraint_id=constraint_id, + expected=["point", "point", "line"], + ) -def constrain_equal_length_rsketch(sketch: Sketch, a: Union[SketchRef, str], b: Union[SketchRef, str], *, constraint_id: Optional[str] = None) -> Sketch: +def constrain_equal_length_rsketch( + sketch: Sketch, + a: Union[SketchRef, str], + b: Union[SketchRef, str], + *, + constraint_id: Optional[str] = None, +) -> Sketch: """Constrain two sketch lines to have equal length.""" - return _constrain_rsketch(sketch, "equal_length", [a, b], constraint_id=constraint_id, expected=["line", "line"]) + return _constrain_rsketch( + sketch, + "equal_length", + [a, b], + constraint_id=constraint_id, + expected=["line", "line"], + ) -def constrain_equal_radius_rsketch(sketch: Sketch, a: Union[SketchRef, str], b: Union[SketchRef, str], *, constraint_id: Optional[str] = None) -> Sketch: +def constrain_equal_radius_rsketch( + sketch: Sketch, + a: Union[SketchRef, str], + b: Union[SketchRef, str], + *, + constraint_id: Optional[str] = None, +) -> Sketch: """Constrain two sketch circles to have equal radius.""" - return _constrain_rsketch(sketch, "equal_radius", [a, b], constraint_id=constraint_id, expected=["circle", "circle"]) + return _constrain_rsketch( + sketch, + "equal_radius", + [a, b], + constraint_id=constraint_id, + expected=["circle", "circle"], + ) -def constrain_distance_rsketch(sketch: Sketch, a: Union[SketchRef, str], b: Union[SketchRef, str], value: ScalarLike, *, constraint_id: Optional[str] = None, driving: bool = True) -> Sketch: +def constrain_distance_rsketch( + sketch: Sketch, + a: Union[SketchRef, str], + b: Union[SketchRef, str], + value: ScalarLike, + *, + constraint_id: Optional[str] = None, + driving: bool = True, +) -> Sketch: """Add a driving point-to-point distance constraint.""" - return _constrain_rsketch(sketch, "distance", [a, b], value=value, constraint_id=constraint_id, driving=driving, expected=["point", "point"]) + return _constrain_rsketch( + sketch, + "distance", + [a, b], + value=value, + constraint_id=constraint_id, + driving=driving, + expected=["point", "point"], + ) -def constrain_distance_x_rsketch(sketch: Sketch, a: Union[SketchRef, str], b: Union[SketchRef, str], value: ScalarLike, *, constraint_id: Optional[str] = None, driving: bool = True) -> Sketch: +def constrain_distance_x_rsketch( + sketch: Sketch, + a: Union[SketchRef, str], + b: Union[SketchRef, str], + value: ScalarLike, + *, + constraint_id: Optional[str] = None, + driving: bool = True, +) -> Sketch: """Add a driving horizontal distance constraint.""" - return _constrain_rsketch(sketch, "distance_x", [a, b], value=value, constraint_id=constraint_id, driving=driving, expected=["point", "point"]) + return _constrain_rsketch( + sketch, + "distance_x", + [a, b], + value=value, + constraint_id=constraint_id, + driving=driving, + expected=["point", "point"], + ) -def constrain_distance_y_rsketch(sketch: Sketch, a: Union[SketchRef, str], b: Union[SketchRef, str], value: ScalarLike, *, constraint_id: Optional[str] = None, driving: bool = True) -> Sketch: +def constrain_distance_y_rsketch( + sketch: Sketch, + a: Union[SketchRef, str], + b: Union[SketchRef, str], + value: ScalarLike, + *, + constraint_id: Optional[str] = None, + driving: bool = True, +) -> Sketch: """Add a driving vertical distance constraint.""" - return _constrain_rsketch(sketch, "distance_y", [a, b], value=value, constraint_id=constraint_id, driving=driving, expected=["point", "point"]) + return _constrain_rsketch( + sketch, + "distance_y", + [a, b], + value=value, + constraint_id=constraint_id, + driving=driving, + expected=["point", "point"], + ) -def constrain_length_rsketch(sketch: Sketch, line: Union[SketchRef, str], value: ScalarLike, *, constraint_id: Optional[str] = None, driving: bool = True) -> Sketch: +def constrain_length_rsketch( + sketch: Sketch, + line: Union[SketchRef, str], + value: ScalarLike, + *, + constraint_id: Optional[str] = None, + driving: bool = True, +) -> Sketch: """Add a driving line length constraint.""" - return _constrain_rsketch(sketch, "length", [line], value=value, constraint_id=constraint_id, driving=driving, expected=["line"]) + return _constrain_rsketch( + sketch, + "length", + [line], + value=value, + constraint_id=constraint_id, + driving=driving, + expected=["line"], + ) -def constrain_angle_rsketch(sketch: Sketch, a: Union[SketchRef, str], b: Union[SketchRef, str], value: ScalarLike, *, constraint_id: Optional[str] = None, driving: bool = True) -> Sketch: +def constrain_angle_rsketch( + sketch: Sketch, + a: Union[SketchRef, str], + b: Union[SketchRef, str], + value: ScalarLike, + *, + constraint_id: Optional[str] = None, + driving: bool = True, +) -> Sketch: """Add a driving angle constraint between two sketch lines.""" - return _constrain_rsketch(sketch, "angle", [a, b], value=value, constraint_id=constraint_id, driving=driving, expected=["line", "line"]) + return _constrain_rsketch( + sketch, + "angle", + [a, b], + value=value, + constraint_id=constraint_id, + driving=driving, + expected=["line", "line"], + ) -def constrain_radius_rsketch(sketch: Sketch, circle: Union[SketchRef, str], value: ScalarLike, *, constraint_id: Optional[str] = None, driving: bool = True) -> Sketch: +def constrain_radius_rsketch( + sketch: Sketch, + circle: Union[SketchRef, str], + value: ScalarLike, + *, + constraint_id: Optional[str] = None, + driving: bool = True, +) -> Sketch: """Add a driving circle radius constraint.""" - return _constrain_rsketch(sketch, "radius", [circle], value=value, constraint_id=constraint_id, driving=driving, expected=["circle"]) + return _constrain_rsketch( + sketch, + "radius", + [circle], + value=value, + constraint_id=constraint_id, + driving=driving, + expected=["circle"], + ) -def constrain_diameter_rsketch(sketch: Sketch, circle: Union[SketchRef, str], value: ScalarLike, *, constraint_id: Optional[str] = None, driving: bool = True) -> Sketch: +def constrain_diameter_rsketch( + sketch: Sketch, + circle: Union[SketchRef, str], + value: ScalarLike, + *, + constraint_id: Optional[str] = None, + driving: bool = True, +) -> Sketch: """Add a driving circle diameter constraint.""" - return _constrain_rsketch(sketch, "diameter", [circle], value=value, constraint_id=constraint_id, driving=driving, expected=["circle"]) + return _constrain_rsketch( + sketch, + "diameter", + [circle], + value=value, + constraint_id=constraint_id, + driving=driving, + expected=["circle"], + ) -def constrain_fix_rsketch(sketch: Sketch, target: Union[SketchRef, str], *, constraint_id: Optional[str] = None) -> Sketch: +def constrain_fix_rsketch( + sketch: Sketch, + target: Union[SketchRef, str], + *, + constraint_id: Optional[str] = None, +) -> Sketch: """Fix a sketch point or entity to its initial coordinates.""" return _constrain_rsketch(sketch, "fix", [target], constraint_id=constraint_id) @@ -1930,7 +2796,9 @@ def _sketch_promotion_tags( profile_payload: Dict[str, Any], ) -> Tuple[str, str]: sketch_tag = _safe_semantic_tag("sketch", sketch.name or sketch.sketch_id) - profile_tag = _safe_semantic_tag("sketch_profile", profile_payload.get("id", "profile")) + profile_tag = _safe_semantic_tag( + "sketch_profile", profile_payload.get("id", "profile") + ) return sketch_tag, profile_tag @@ -1938,18 +2806,32 @@ def _sketch_promotion_map( sketch: Sketch, profile: int | str, profile_payload: Dict[str, Any], + *, + target_kind: Optional[str] = None, ) -> Dict[str, Any]: sketch_tag, profile_tag = _sketch_promotion_tags(sketch, profile_payload) + profile_local_name = _sketch_topology_tag_local_name( + profile_payload.get("id", "profile") + ) + profile_identity_tag = f"{sketch_tag}.profile.{profile_local_name}" edges: List[Dict[str, Any]] = [] for entity_id in profile_payload.get("entity_ids", []): entity_tag = _safe_semantic_tag("sketch_entity", entity_id) + entity_local_name = _sketch_topology_tag_local_name(entity_id) edges.append( { "entity_id": str(entity_id), "target_kind": "edge", "tags": [sketch_tag, profile_tag, entity_tag], + "topology_name": { + "kind": "edge", + "name": f"{sketch_tag}.entity.{entity_local_name}", + "local_name": entity_local_name, + }, "metadata": { - "sketch_ref": _sketch_edge_metadata(sketch, str(entity_id), profile, profile_payload) + "sketch_ref": _sketch_edge_metadata( + sketch, str(entity_id), profile, profile_payload + ) }, } ) @@ -1958,6 +2840,11 @@ def _sketch_promotion_map( "profile_id": profile_payload.get("id"), "profile_kind": profile_payload.get("kind"), "tags": [sketch_tag, profile_tag], + "topology_name": { + "kind": target_kind or "profile", + "name": profile_identity_tag, + "local_name": profile_local_name, + }, "edges": edges, } @@ -1971,7 +2858,12 @@ def _apply_sketch_promotion_metadata( solve_snapshot: Dict[str, Any], ) -> None: source_sketch = _sketch_source_metadata(sketch, profile, profile_payload) - promotion_map = _sketch_promotion_map(sketch, profile, profile_payload) + promotion_map = _sketch_promotion_map( + sketch, + profile, + profile_payload, + target_kind="wire" if isinstance(shape, Wire) else "face", + ) sketch_tag, profile_tag = _sketch_promotion_tags(sketch, profile_payload) shape.set_metadata("source_sketch", source_sketch) @@ -2000,13 +2892,135 @@ def _apply_sketch_promotion_metadata( for edge, entity_id in zip(edges, profile_payload.get("entity_ids", [])): entity_tag = _safe_semantic_tag("sketch_entity", entity_id) - edge.set_metadata("sketch_ref", _sketch_edge_metadata(sketch, str(entity_id), profile, profile_payload)) + edge.set_metadata( + "sketch_ref", + _sketch_edge_metadata(sketch, str(entity_id), profile, profile_payload), + ) edge.set_metadata("source_sketch", source_sketch) edge._apply_tag(sketch_tag, propagate=False) edge._apply_tag(profile_tag, propagate=False) edge._apply_tag(entity_tag, propagate=False) +def _sketch_topology_tag_local_name(value: object) -> str: + """Return the normalized local segment for a sketch topology tag.""" + + return _safe_semantic_tag("name", value).split(".", 1)[1] + + +def _sketch_topology_identity_binding( + tag: str, + *, + kind: str, + local_name: str, + operation: str, + sketch: Sketch, + profile_id: str, + target_topo_id: str, + entity_id: Optional[str] = None, +) -> TagBinding: + rule_id = "simplecad.sketch_promotion_topology_name" + binding_key = "|".join( + ( + rule_id, + operation, + sketch.sketch_id, + profile_id, + entity_id or "", + target_topo_id, + tag, + ) + ) + return TagBinding( + tag=tag, + producer=TagProducer( + "auto_rule", + rule_id=rule_id, + rule_version="1.0", + ), + target=TagTarget("scope_root"), + propagation=TagPropagation( + topology=( + TopologyPropagation.DOWNWARD + if kind == "face" + else TopologyPropagation.LOCAL + ), + lineage=LineagePolicy.CONTINUATION_FRAGMENT, + ), + evidence=TagEvidence( + "topology_change", + { + "operation": operation, + "target_topo_id": target_topo_id, + "evidence_method": "SketchPromotionMap", + "sketch_promotion": { + "sketch_id": sketch.sketch_id, + "profile_id": profile_id, + **({"entity_id": entity_id} if entity_id is not None else {}), + }, + "topology_name": { + "kind": kind, + "name": tag, + "local_name": local_name, + }, + }, + ), + certainty=TagCertainty.PROVEN, + lifecycle=TagLifecycle.RECOMPUTE, + binding_id=f"tag_binding_{uuid.uuid5(uuid.NAMESPACE_URL, binding_key).hex}", + ) + + +def _apply_sketch_promotion_identity_tags( + shape: Union[Wire, Face], + *, + sketch: Sketch, + profile_payload: Dict[str, Any], + operation: str, +) -> Union[Wire, Face]: + """Attach topology-identity tags from an exact sketch promotion map.""" + + entity_ids = [str(value) for value in profile_payload.get("entity_ids", [])] + edges = list(shape.get_edges()) + if len(edges) != len(entity_ids): + raise ValueError( + "Sketch promotion cannot establish exact entity-to-edge correspondence: " + f"expected {len(entity_ids)} edges, got {len(edges)}" + ) + + sketch_prefix = _safe_semantic_tag("sketch", sketch.name or sketch.sketch_id) + profile_local_name = _sketch_topology_tag_local_name(profile_payload.get("id", "profile")) + profile_kind = "wire" if isinstance(shape, Wire) else "face" + profile_tag = f"{sketch_prefix}.profile.{profile_local_name}" + shape._add_tag_binding( + _sketch_topology_identity_binding( + profile_tag, + kind=profile_kind, + local_name=profile_local_name, + operation=operation, + sketch=sketch, + profile_id=str(profile_payload.get("id", "profile")), + target_topo_id=shape.topo_id, + ) + ) + + for edge, entity_id in zip(edges, entity_ids): + entity_local_name = _sketch_topology_tag_local_name(entity_id) + edge._add_tag_binding( + _sketch_topology_identity_binding( + f"{sketch_prefix}.entity.{entity_local_name}", + kind="edge", + local_name=entity_local_name, + operation=operation, + sketch=sketch, + profile_id=str(profile_payload.get("id", "profile")), + entity_id=entity_id, + target_topo_id=edge.topo_id, + ) + ) + return shape + + def _promote_sketch_profile( sketch: Sketch, profile: int | str, @@ -2033,7 +3047,9 @@ def _promote_sketch_profile( wire = sketch._wire_from_profile_payload(profile_payload) shape = make_face_from_wire_rface(wire, normal=sketch._plane_normal_tuple()) else: - raise ValueError(f"Unsupported sketch promotion target kind '{target_kind}'") + raise ValueError( + f"Unsupported sketch promotion target kind '{target_kind}'" + ) _apply_sketch_promotion_metadata( cast(Union[Wire, Face], shape), sketch=sketch, @@ -2050,7 +3066,9 @@ def _assert_sketch_solve_snapshot_matches( *, tolerance: float = 1e-7, ) -> None: - _assert_sketch_solve_snapshot_dict_matches(result.to_dict(), snapshot, tolerance=tolerance) + _assert_sketch_solve_snapshot_dict_matches( + result.to_dict(), snapshot, tolerance=tolerance + ) def _assert_sketch_solve_snapshot_dict_matches( @@ -2067,7 +3085,13 @@ def _assert_sketch_solve_snapshot_dict_matches( raise ValueError( f"Sketch solve DOF changed from {snapshot.get('dof')!r} to {actual.get('dof')!r}" ) - if abs(float(actual.get("residual_norm", 0.0)) - float(snapshot.get("residual_norm", 0.0))) > tolerance: + if ( + abs( + float(actual.get("residual_norm", 0.0)) + - float(snapshot.get("residual_norm", 0.0)) + ) + > tolerance + ): raise ValueError("Sketch solve residual changed beyond recorded tolerance") actual_points = cast(Dict[str, Any], actual.get("solved_points", {})) @@ -2076,8 +3100,16 @@ def _assert_sketch_solve_snapshot_dict_matches( raise ValueError("Sketch solve point set changed") for point_id, point in actual_points.items(): expected = expected_points[point_id] - if math.dist((float(point[0]), float(point[1])), (float(expected[0]), float(expected[1]))) > tolerance: - raise ValueError(f"Sketch solve point '{point_id}' changed beyond recorded tolerance") + if ( + math.dist( + (float(point[0]), float(point[1])), + (float(expected[0]), float(expected[1])), + ) + > tolerance + ): + raise ValueError( + f"Sketch solve point '{point_id}' changed beyond recorded tolerance" + ) actual_scalars = cast(Dict[str, Any], actual.get("solved_scalars", {})) expected_scalars = cast(Dict[str, Any], snapshot.get("solved_scalars", {})) @@ -2085,12 +3117,16 @@ def _assert_sketch_solve_snapshot_dict_matches( raise ValueError("Sketch solve scalar set changed") for scalar_id, value in actual_scalars.items(): if abs(float(value) - float(expected_scalars[scalar_id])) > tolerance: - raise ValueError(f"Sketch solve scalar '{scalar_id}' changed beyond recorded tolerance") + raise ValueError( + f"Sketch solve scalar '{scalar_id}' changed beyond recorded tolerance" + ) def make_line_redge( start: Tuple[ScalarLike, ScalarLike, ScalarLike], end: Tuple[ScalarLike, ScalarLike, ScalarLike], + *, + tag_prefix: Optional[str] = None, ) -> Edge: """Create a straight edge between two points.""" try: @@ -2100,9 +3136,8 @@ def make_line_redge( start_global = cs.transform_point(np.array(start_value)) end_global = cs.transform_point(np.array(end_value)) - edge_shape = make_line_edge(start_global, end_global) - return cast( + edge = cast( Edge, _finalize_primitive_shape( Edge(edge_shape), @@ -2111,6 +3146,15 @@ def make_line_redge( tags={"primitive", "edge"}, ), ) + return cast( + Edge, + _apply_shape_tag_prefix( + edge, + tag_prefix, + kind="edge", + authoring_source="simplecadapi.make_line_redge.tag_prefix", + ), + ) except Exception as e: _wrap_public_api_error( operation="make_line_redge", @@ -2175,6 +3219,8 @@ def make_circle_redge( center: Tuple[float, float, float], radius: ScalarLike, normal: Tuple[float, float, float] = (0, 0, 1), + *, + tag_prefix: Optional[str] = None, ) -> Edge: """Create a circular edge.""" try: @@ -2188,9 +3234,8 @@ def make_circle_redge( center_global = cs.transform_point(np.array(center_value)) normal_global = cs.transform_point(np.array(normal_value)) - cs.origin - edge_shape = make_circle_edge(center_global, radius_value, normal_global) - return cast( + edge = cast( Edge, _finalize_primitive_shape( Edge(edge_shape), @@ -2199,6 +3244,15 @@ def make_circle_redge( tags={"primitive", "edge"}, ), ) + return cast( + Edge, + _apply_shape_tag_prefix( + edge, + tag_prefix, + kind="edge", + authoring_source="simplecadapi.make_circle_redge.tag_prefix", + ), + ) except Exception as e: _wrap_public_api_error( operation="make_circle_redge", @@ -2221,17 +3275,32 @@ def make_circle_rwire( center: Tuple[float, float, float], radius: ScalarLike, normal: Tuple[float, float, float] = (0, 0, 1), + *, + tag_prefix: Optional[str] = None, + edge_tag: Optional[str] = None, ) -> Wire: """Create a circular wire.""" try: if get_active_session() is not None: edge = make_circle_redge(center, radius, normal) - return make_wire_from_edges_rwire([edge]) + wire = make_wire_from_edges_rwire([edge]) + local_edge_tag = edge_tag or "circle" + return cast( + Wire, + _apply_profile_tag_prefix( + wire, + tag_prefix, + [local_edge_tag] + if tag_prefix is not None or edge_tag is not None + else None, + authoring_source="simplecadapi.make_circle_rwire.tag_prefix", + ), + ) with suspend_graph_recording(): edge = make_circle_redge(center, radius, normal) wire_shape = make_wire_from_edges_ocp([edge.wrapped]) - return cast( + wire = cast( Wire, _finalize_primitive_shape( Wire(wire_shape), @@ -2240,6 +3309,18 @@ def make_circle_rwire( tags={"primitive", "wire"}, ), ) + local_edge_tag = edge_tag or "circle" + return cast( + Wire, + _apply_profile_tag_prefix( + wire, + tag_prefix, + [local_edge_tag] + if tag_prefix is not None or edge_tag is not None + else None, + authoring_source="simplecadapi.make_circle_rwire.tag_prefix", + ), + ) except Exception as e: _wrap_public_api_error( operation="make_circle_rwire", @@ -2260,20 +3341,36 @@ def make_circle_rface( center: Tuple[float, float, float], radius: ScalarLike, normal: Tuple[float, float, float] = (0, 0, 1), + *, + tag_prefix: Optional[str] = None, + edge_tag: Optional[str] = None, ) -> Face: """Create a circular face.""" try: if get_active_session() is not None: - wire = make_circle_rwire(center, radius, normal) - return make_face_from_wire_rface(wire, normal=normal) + wire = make_circle_rwire( + center, + radius, + normal, + tag_prefix=tag_prefix, + edge_tag=edge_tag, + ) + return make_face_from_wire_rface( + wire, normal=normal, tag_prefix=tag_prefix + ) with suspend_graph_recording(): - wire = make_circle_rwire(center, radius, normal) + wire = make_circle_rwire( + center, + radius, + normal, + tag_prefix=tag_prefix, + edge_tag=edge_tag, + ) face_shape = make_face_from_wire_ocp(wire.wrapped) face = Face(face_shape) - face._tags = wire._tags.copy() face._metadata = wire._metadata.copy() - return cast( + result = cast( Face, _finalize_primitive_shape( face, @@ -2282,6 +3379,21 @@ def make_circle_rface( tags={"primitive", "face"}, ), ) + result = cast( + Face, + _copy_exact_topology_identity_tags( + result, [wire], operation="make_circle_rface" + ), + ) + return cast( + Face, + _apply_shape_tag_prefix( + result, + tag_prefix, + kind="face", + authoring_source="simplecadapi.make_circle_rface.tag_prefix", + ), + ) except Exception as e: _wrap_public_api_error( operation="make_circle_rface", @@ -2303,6 +3415,9 @@ def make_rectangle_rwire( height: ScalarLike, center: Tuple[ScalarLike, ScalarLike, ScalarLike] = (0, 0, 0), normal: Tuple[ScalarLike, ScalarLike, ScalarLike] = (0, 0, 1), + *, + tag_prefix: Optional[str] = None, + edge_tags: Optional[Sequence[str]] = None, ) -> Wire: """Create a rectangular wire.""" try: @@ -2322,7 +3437,16 @@ def make_rectangle_rwire( _offset_point_expr(center, x_axis, y_axis, half_w, half_h), _offset_point_expr(center, x_axis, y_axis, -half_w, half_h), ] - return _make_closed_profile_rwire(corners) + wire = _make_closed_profile_rwire(corners) + return cast( + Wire, + _apply_profile_tag_prefix( + wire, + tag_prefix, + edge_tags, + authoring_source="simplecadapi.make_rectangle_rwire.tag_prefix", + ), + ) cs = get_current_cs() center_value = cast(Tuple[float, float, float], evaluate_value(center)) @@ -2365,9 +3489,12 @@ def make_rectangle_rwire( global_points.append(tuple(float(v) for v in point_3d)) # 创建边 - wire_points = [(float(point[0]), float(point[1]), float(point[2])) for point in global_points] + wire_points = [ + (float(point[0]), float(point[1]), float(point[2])) + for point in global_points + ] wire_shape = make_polyline_wire(wire_points, closed=True) - return cast( + wire = cast( Wire, _finalize_primitive_shape( Wire(wire_shape), @@ -2381,6 +3508,15 @@ def make_rectangle_rwire( tags={"primitive", "wire"}, ), ) + return cast( + Wire, + _apply_profile_tag_prefix( + wire, + tag_prefix, + edge_tags, + authoring_source="simplecadapi.make_rectangle_rwire.tag_prefix", + ), + ) except Exception as e: _wrap_public_api_error( operation="make_rectangle_rwire", @@ -2404,20 +3540,38 @@ def make_rectangle_rface( height: ScalarLike, center: Tuple[ScalarLike, ScalarLike, ScalarLike] = (0, 0, 0), normal: Tuple[ScalarLike, ScalarLike, ScalarLike] = (0, 0, 1), + *, + tag_prefix: Optional[str] = None, + edge_tags: Optional[Sequence[str]] = None, ) -> Face: """Create a rectangular face.""" try: if get_active_session() is not None: - wire = make_rectangle_rwire(width, height, center, normal) - return make_face_from_wire_rface(wire, normal=cast(Any, normal)) + wire = make_rectangle_rwire( + width, + height, + center, + normal, + tag_prefix=tag_prefix, + edge_tags=edge_tags, + ) + return make_face_from_wire_rface( + wire, normal=cast(Any, normal), tag_prefix=tag_prefix + ) with suspend_graph_recording(): - wire = make_rectangle_rwire(width, height, center, normal) + wire = make_rectangle_rwire( + width, + height, + center, + normal, + tag_prefix=tag_prefix, + edge_tags=edge_tags, + ) face_shape = make_face_from_wire_ocp(wire.wrapped) face = Face(face_shape) - face._tags = wire._tags.copy() face._metadata = wire._metadata.copy() - return cast( + result = cast( Face, _finalize_primitive_shape( face, @@ -2431,6 +3585,21 @@ def make_rectangle_rface( tags={"primitive", "face"}, ), ) + result = cast( + Face, + _copy_exact_topology_identity_tags( + result, [wire], operation="make_rectangle_rface" + ), + ) + return cast( + Face, + _apply_shape_tag_prefix( + result, + tag_prefix, + kind="face", + authoring_source="simplecadapi.make_rectangle_rface.tag_prefix", + ), + ) except Exception as e: _wrap_public_api_error( operation="make_rectangle_rface", @@ -2448,7 +3617,10 @@ def make_rectangle_rface( def make_face_from_wire_rface( - wire: Wire, normal: Tuple[float, float, float] = (0, 0, 1) + wire: Wire, + normal: Tuple[float, float, float] = (0, 0, 1), + *, + tag_prefix: Optional[str] = None, ) -> Face: """Create a face from a closed wire.""" try: @@ -2484,11 +3656,9 @@ def make_face_from_wire_rface( # 或者我们接受当前面的方向,添加一个警告 print(f"警告: 创建的面的法向量与期望方向相反 (点积: {dot_product:.3f})") - # 复制标签和元数据 - face._tags = wire._tags.copy() face._metadata = wire._metadata.copy() - return cast( + result = cast( Face, _finalize_derived_shape( face, @@ -2498,6 +3668,21 @@ def make_face_from_wire_rface( tags={"derived", "face"}, ), ) + result = cast( + Face, + _copy_exact_topology_identity_tags( + result, [wire], operation="make_face_from_wire_rface" + ), + ) + return cast( + Face, + _apply_shape_tag_prefix( + result, + tag_prefix, + kind="face", + authoring_source="simplecadapi.make_face_from_wire_rface.tag_prefix", + ), + ) except Exception as e: _wrap_public_api_error( operation="make_face_from_wire_rface", @@ -2554,7 +3739,6 @@ def make_face_from_wires_rface( if dot_product < 0: print(f"警告: 创建的面的法向量与期望方向相反 (点积: {dot_product:.3f})") - face._tags = outer_wire._tags.copy() face._metadata = outer_wire._metadata.copy() return cast( @@ -2586,14 +3770,16 @@ def make_face_from_wires_rface( ) -def make_wire_from_edges_rwire(edges: List[Edge]) -> Wire: +def make_wire_from_edges_rwire( + edges: List[Edge], *, tag_prefix: Optional[str] = None +) -> Wire: """Create a wire from a list of connected edges.""" try: if not edges: raise ValueError("边列表不能为空") wire_shape = make_wire_from_edges_ocp([edge.wrapped for edge in edges]) - return cast( + result = cast( Wire, _finalize_derived_shape( Wire(wire_shape), @@ -2603,6 +3789,21 @@ def make_wire_from_edges_rwire(edges: List[Edge]) -> Wire: tags={"derived", "wire"}, ), ) + result = cast( + Wire, + _copy_exact_topology_identity_tags( + result, edges, operation="make_wire_from_edges_rwire" + ), + ) + return cast( + Wire, + _apply_shape_tag_prefix( + result, + tag_prefix, + kind="wire", + authoring_source="simplecadapi.make_wire_from_edges_rwire.tag_prefix", + ), + ) except Exception as e: _wrap_public_api_error( operation="make_wire_from_edges_rwire", @@ -2644,7 +3845,7 @@ def make_wire_from_sketch_rwire( max_iterations=max_iterations, ) solve_snapshot = _sketch_solve_snapshot(solve_result) - return cast( + finalized = cast( Wire, _finalize_derived_shape( cast(Wire, wire), @@ -2657,12 +3858,26 @@ def make_wire_from_sketch_rwire( "tolerance": tolerance, "max_iterations": max_iterations, "solve_snapshot": solve_snapshot, - "promotion_map": _sketch_promotion_map(sketch, profile, profile_payload), + "promotion_map": _sketch_promotion_map( + sketch, + profile, + profile_payload, + target_kind="wire", + ), }, input_shapes=cast(Sequence[AnyShape], [sketch]), tags={"derived", "wire", "sketch"}, ), ) + return cast( + Wire, + _apply_sketch_promotion_identity_tags( + finalized, + sketch=sketch, + profile_payload=profile_payload, + operation=_OP_MAKE_WIRE_FROM_SKETCH_RWIRE, + ), + ) except Exception as e: _wrap_public_api_error( operation="make_wire_from_sketch_rwire", @@ -2703,7 +3918,7 @@ def make_face_from_sketch_rface( max_iterations=max_iterations, ) solve_snapshot = _sketch_solve_snapshot(solve_result) - return cast( + finalized = cast( Face, _finalize_derived_shape( cast(Face, face), @@ -2716,12 +3931,26 @@ def make_face_from_sketch_rface( "tolerance": tolerance, "max_iterations": max_iterations, "solve_snapshot": solve_snapshot, - "promotion_map": _sketch_promotion_map(sketch, profile, profile_payload), + "promotion_map": _sketch_promotion_map( + sketch, + profile, + profile_payload, + target_kind="face", + ), }, input_shapes=cast(Sequence[AnyShape], [sketch]), tags={"derived", "face", "sketch"}, ), ) + return cast( + Face, + _apply_sketch_promotion_identity_tags( + finalized, + sketch=sketch, + profile_payload=profile_payload, + operation=_OP_MAKE_FACE_FROM_SKETCH_RFACE, + ), + ) except Exception as e: _wrap_public_api_error( operation="make_face_from_sketch_rface", @@ -2744,9 +3973,32 @@ def make_box_rsolid( height: ScalarLike, depth: ScalarLike, bottom_face_center: Tuple[float, float, float] = (0, 0, 0), + *, + tag_prefix: Optional[str] = None, + result_tag: Optional[str] = None, + bottom_face_tag: Optional[str] = None, + top_face_tag: Optional[str] = None, + front_face_tag: Optional[str] = None, + back_face_tag: Optional[str] = None, + left_face_tag: Optional[str] = None, + right_face_tag: Optional[str] = None, ) -> Solid: - """Create a box solid.""" + """Create a box with native kernel-backed Face topology tags.""" try: + assignments = _normalize_operation_role_tags( + "make_box_rsolid", + ( + ("box.bottom", bottom_face_tag), + ("box.top", top_face_tag), + ("box.front", front_face_tag), + ("box.back", back_face_tag), + ("box.left", left_face_tag), + ("box.right", right_face_tag), + ), + ) + normalized_result_tag = ( + normalize_tag(result_tag, strict=True) if result_tag is not None else None + ) width_value = evaluate_scalar(width) height_value = evaluate_scalar(height) depth_value = evaluate_scalar(depth) @@ -2761,14 +4013,13 @@ def make_box_rsolid( center_global = cs.transform_point(np.array(center_value)) pnt = center_global - np.array([width_value / 2, height_value / 2, 0]) - solid = Solid( - make_box_solid( - (float(pnt[0]), float(pnt[1]), float(pnt[2])), - width_value, - height_value, - depth_value, - ) + tracked = tracked_box( + (float(pnt[0]), float(pnt[1]), float(pnt[2])), + width_value, + height_value, + depth_value, ) + solid = cast(Solid, tracked.shape) # 自动标记面 solid.auto_tag_faces("box") @@ -2783,7 +4034,8 @@ def make_box_rsolid( }, ) - return _finalize_primitive_solid( + target_kinds = _validate_operation_output_roles(tracked.delta, assignments) + finalized = _finalize_tracked_solid( solid, op="make_box_rsolid", params={ @@ -2792,7 +4044,29 @@ def make_box_rsolid( "depth": depth, "bottom_face_center": bottom_face_center, }, - tags={"primitive", "solid"}, + delta=tracked.delta, + delta_entries=cast(Dict[str, Dict[str, object]], tracked.delta_entries), + ) + tagged = _apply_operation_role_tags( + finalized, + op="make_box_rsolid", + assignments=assignments, + target_kinds=target_kinds, + result_tag=normalized_result_tag, + ) + return _apply_feature_tag_prefix( + tagged, + tag_prefix=tag_prefix, + op="make_box_rsolid", + delta=tracked.delta, + face_roles=( + ("box.bottom", "bottom"), + ("box.top", "top"), + ("box.front", "front"), + ("box.back", "back"), + ("box.left", "left"), + ("box.right", "right"), + ), ) except Exception as e: _wrap_public_api_error( @@ -2817,9 +4091,32 @@ def make_cylinder_rsolid( height: ScalarLike, bottom_face_center: Tuple[float, float, float] = (0, 0, 0), axis: Tuple[float, float, float] = (0, 0, 1), + *, + tag_prefix: Optional[str] = None, + result_tag: Optional[str] = None, + start_face_tag: Optional[str] = None, + end_face_tag: Optional[str] = None, + side_face_tag: Optional[str] = None, + start_edge_tag: Optional[str] = None, + end_edge_tag: Optional[str] = None, + seam_edge_tag: Optional[str] = None, ) -> Solid: - """Create a cylinder solid.""" + """Create a cylinder with native kernel-backed Face and Edge topology tags.""" try: + assignments = _normalize_operation_role_tags( + "make_cylinder_rsolid", + ( + ("cylinder.start", start_face_tag), + ("cylinder.end", end_face_tag), + ("cylinder.side", side_face_tag), + ("cylinder.start_boundary", start_edge_tag), + ("cylinder.end_boundary", end_edge_tag), + ("cylinder.seam", seam_edge_tag), + ), + ) + normalized_result_tag = ( + normalize_tag(result_tag, strict=True) if result_tag is not None else None + ) radius_value = evaluate_scalar(radius) height_value = evaluate_scalar(height) if radius_value <= 0 or height_value <= 0: @@ -2833,18 +4130,17 @@ def make_cylinder_rsolid( center_global = cs.transform_point(np.array(center_value)) axis_global = cs.transform_vector(np.array(axis_value)) - solid = Solid( - make_cylinder_solid( - ( - float(center_global[0]), - float(center_global[1]), - float(center_global[2]), - ), - (float(axis_global[0]), float(axis_global[1]), float(axis_global[2])), - radius_value, - height_value, - ) + tracked = tracked_cylinder( + ( + float(center_global[0]), + float(center_global[1]), + float(center_global[2]), + ), + (float(axis_global[0]), float(axis_global[1]), float(axis_global[2])), + radius_value, + height_value, ) + solid = cast(Solid, tracked.shape) # 自动标记面 solid.auto_tag_faces("cylinder") @@ -2861,7 +4157,8 @@ def make_cylinder_rsolid( }, ) - return _finalize_primitive_solid( + target_kinds = _validate_operation_output_roles(tracked.delta, assignments) + finalized = _finalize_tracked_solid( solid, op="make_cylinder_rsolid", params={ @@ -2870,7 +4167,29 @@ def make_cylinder_rsolid( "bottom_face_center": bottom_face_center, "axis": axis, }, - tags={"primitive", "solid"}, + delta=tracked.delta, + delta_entries=cast(Dict[str, Dict[str, object]], tracked.delta_entries), + ) + tagged = _apply_operation_role_tags( + finalized, + op="make_cylinder_rsolid", + assignments=assignments, + target_kinds=target_kinds, + result_tag=normalized_result_tag, + ) + return _apply_feature_tag_prefix( + tagged, + tag_prefix=tag_prefix, + op="make_cylinder_rsolid", + delta=tracked.delta, + start_role="cylinder.start", + end_role="cylinder.end", + side_role="cylinder.side", + edge_roles=( + ("cylinder.start_boundary", "start"), + ("cylinder.end_boundary", "end"), + ("cylinder.seam", "seam"), + ), ) except Exception as e: _wrap_public_api_error( @@ -2896,9 +4215,32 @@ def make_cone_rsolid( top_radius: ScalarLike = 0.0, bottom_face_center: Tuple[float, float, float] = (0, 0, 0), axis: Tuple[float, float, float] = (0, 0, 1), + *, + tag_prefix: Optional[str] = None, + result_tag: Optional[str] = None, + start_face_tag: Optional[str] = None, + end_face_tag: Optional[str] = None, + side_face_tag: Optional[str] = None, + start_edge_tag: Optional[str] = None, + end_edge_tag: Optional[str] = None, + seam_edge_tag: Optional[str] = None, ) -> Solid: - """Create a cone or truncated cone solid.""" + """Create a cone or frustum with native kernel-backed topology tags.""" try: + assignments = _normalize_operation_role_tags( + "make_cone_rsolid", + ( + ("cone.start", start_face_tag), + ("cone.end", end_face_tag), + ("cone.side", side_face_tag), + ("cone.start_boundary", start_edge_tag), + ("cone.end_boundary", end_edge_tag), + ("cone.seam", seam_edge_tag), + ), + ) + normalized_result_tag = ( + normalize_tag(result_tag, strict=True) if result_tag is not None else None + ) bottom_radius_value = evaluate_scalar(bottom_radius) height_value = evaluate_scalar(height) top_radius_value = evaluate_scalar(top_radius) @@ -2913,19 +4255,18 @@ def make_cone_rsolid( center_global = cs.transform_point(np.array(center_value)) axis_global = cs.transform_vector(np.array(axis_value)) - solid = Solid( - make_cone_solid( - ( - float(center_global[0]), - float(center_global[1]), - float(center_global[2]), - ), - (float(axis_global[0]), float(axis_global[1]), float(axis_global[2])), - bottom_radius_value, - top_radius_value, - height_value, - ) + tracked = tracked_cone( + ( + float(center_global[0]), + float(center_global[1]), + float(center_global[2]), + ), + (float(axis_global[0]), float(axis_global[1]), float(axis_global[2])), + bottom_radius_value, + top_radius_value, + height_value, ) + solid = cast(Solid, tracked.shape) # 自动标记面 solid._apply_tag("geom.primitive.cone", propagate=False) @@ -2942,7 +4283,8 @@ def make_cone_rsolid( }, ) - return _finalize_primitive_solid( + target_kinds = _validate_operation_output_roles(tracked.delta, assignments) + finalized = _finalize_tracked_solid( solid, op="make_cone_rsolid", params={ @@ -2952,7 +4294,29 @@ def make_cone_rsolid( "bottom_face_center": bottom_face_center, "axis": axis, }, - tags={"primitive", "solid"}, + delta=tracked.delta, + delta_entries=cast(Dict[str, Dict[str, object]], tracked.delta_entries), + ) + tagged = _apply_operation_role_tags( + finalized, + op="make_cone_rsolid", + assignments=assignments, + target_kinds=target_kinds, + result_tag=normalized_result_tag, + ) + return _apply_feature_tag_prefix( + tagged, + tag_prefix=tag_prefix, + op="make_cone_rsolid", + delta=tracked.delta, + start_role="cone.start", + end_role="cone.end" if top_radius_value > 0.0 else None, + side_role="cone.side", + edge_roles=( + ("cone.start_boundary", "start"), + ("cone.end_boundary", "end"), + ("cone.seam", "seam"), + ), ) except Exception as e: _wrap_public_api_error( @@ -3051,7 +4415,6 @@ def make_three_point_arc_redge( middle_global = cs.transform_point(np.array(middle_value)) end_global = cs.transform_point(np.array(end_value)) - edge_shape = make_arc_three_point_edge(start_global, middle_global, end_global) return cast( Edge, @@ -3294,7 +4657,9 @@ def _normalize_bspline_control_points( return tuple(normalized) -def _collapse_knot_vector(knots: Sequence[ScalarLike]) -> Tuple[Tuple[float, ...], Tuple[int, ...]]: +def _collapse_knot_vector( + knots: Sequence[ScalarLike], +) -> Tuple[Tuple[float, ...], Tuple[int, ...]]: values = [float(evaluate_scalar(knot)) for knot in knots] if not values: raise ValueError("knots must not be empty") @@ -3302,7 +4667,9 @@ def _collapse_knot_vector(knots: Sequence[ScalarLike]) -> Tuple[Tuple[float, ... raise ValueError("knots must contain only finite values") for previous, current in zip(values, values[1:]): if current < previous: - raise ValueError("knots must be non-decreasing when passed as a full knot vector") + raise ValueError( + "knots must be non-decreasing when passed as a full knot vector" + ) unique: List[float] = [] multiplicities: List[int] = [] for value in values: @@ -3424,7 +4791,9 @@ def make_spline_redge( knots=knots, multiplicities=multiplicities, ) - resolved_weights = _normalize_bspline_weights(weights, len(local_control_points)) + resolved_weights = _normalize_bspline_weights( + weights, len(local_control_points) + ) cs = get_current_cs() global_control_points = tuple( @@ -3448,7 +4817,9 @@ def make_spline_redge( "control_points": [list(point) for point in global_control_points], "knots": list(resolved_knots), "multiplicities": list(resolved_multiplicities), - "weights": list(resolved_weights) if resolved_weights is not None else None, + "weights": ( + list(resolved_weights) if resolved_weights is not None else None + ), "periodic": periodic_value, }, ) @@ -3568,7 +4939,11 @@ def make_polyline_rwire( global_points.append(tuple(float(v) for v in global_point)) wire_shape = make_polyline_wire( - [(float(point[0]), float(point[1]), float(point[2])) for point in global_points], closed=closed + [ + (float(point[0]), float(point[1]), float(point[2])) + for point in global_points + ], + closed=closed, ) return cast( Wire, @@ -3726,8 +5101,13 @@ def translate_shape(shape: AnyShape, vector: Tuple[float, float, float]) -> AnyS vector_value = cast(Tuple[float, float, float], evaluate_value(vector)) tracked = tracked_translate(shape, vector_value) translated = cast(Solid, tracked.shape) - translated._tags = shape._tags.copy() translated._metadata = shape._metadata.copy() + _attach_lineage_from_source( + shape, + translated, + derivation="continuation", + op=_OP_MAKE_TRANSLATE_RSHAPE, + ) return _finalize_tracked_solid( translated, op=_OP_MAKE_TRANSLATE_RSHAPE, @@ -3750,10 +5130,14 @@ def translate_shape(shape: AnyShape, vector: Tuple[float, float, float]) -> AnyS ), ) - # 复制标签和元数据 - new_shape._tags = shape._tags.copy() new_shape._metadata = shape._metadata.copy() _copy_runtime_state(shape, new_shape) + _attach_lineage_from_source( + shape, + new_shape, + derivation="continuation", + op=_OP_MAKE_TRANSLATE_RSHAPE, + ) record_operation_if_active( op=_OP_MAKE_TRANSLATE_RSHAPE, params={"vector": vector}, @@ -3800,8 +5184,13 @@ def rotate_shape( shape, angle_value, axis=axis_value, origin=origin_value ) rotated = cast(Solid, tracked.shape) - rotated._tags = shape._tags.copy() rotated._metadata = shape._metadata.copy() + _attach_lineage_from_source( + shape, + rotated, + derivation="continuation", + op=_OP_MAKE_ROTATE_RSHAPE, + ) return _finalize_tracked_solid( rotated, op=_OP_MAKE_ROTATE_RSHAPE, @@ -3830,10 +5219,14 @@ def rotate_shape( ), ) - # 复制标签和元数据 - new_shape._tags = shape._tags.copy() new_shape._metadata = shape._metadata.copy() _copy_runtime_state(shape, new_shape) + _attach_lineage_from_source( + shape, + new_shape, + derivation="continuation", + op=_OP_MAKE_ROTATE_RSHAPE, + ) record_operation_if_active( op=_OP_MAKE_ROTATE_RSHAPE, params={"angle": angle, "axis": axis, "origin": origin}, @@ -3870,9 +5263,26 @@ def extrude_rsolid( profile: Union[Wire, Face], direction: Tuple[float, float, float], distance: ScalarLike, + *, + tag_prefix: Optional[str] = None, + result_tag: Optional[str] = None, + start_face_tag: Optional[str] = None, + end_face_tag: Optional[str] = None, + side_faces_tag: Optional[str] = None, ) -> Solid: - """Create a solid by extruding a profile.""" + """Create a solid by extruding a profile, with optional role-based tags.""" try: + assignments = _normalize_operation_role_tags( + _OP_MAKE_EXTRUDE_RSOLID, + ( + ("extrusion.start", start_face_tag), + ("extrusion.end", end_face_tag), + ("extrusion.side", side_faces_tag), + ), + ) + normalized_result_tag = ( + normalize_tag(result_tag, strict=True) if result_tag is not None else None + ) distance_value = evaluate_scalar(distance) if distance_value <= 0: raise ValueError("拉伸距离必须大于0") @@ -3884,7 +5294,9 @@ def extrude_rsolid( direction_norm = float(np.linalg.norm(global_direction)) if direction_norm <= 1e-15: raise ValueError("拉伸方向不能是零向量") - direction_vec = tuple((global_direction / direction_norm * distance_value).tolist()) + direction_vec = tuple( + (global_direction / direction_norm * distance_value).tolist() + ) if isinstance(profile, Wire): # 如果是线,先转换为面 @@ -3910,42 +5322,11 @@ def extrude_rsolid( ) solid = cast(Solid, tracked.shape) - side_face_count = 0 - profile_face_normal = None - for face_after_extrusion in solid.get_faces(): - if face_after_extrusion.get_center() == face.get_center(): - face_after_extrusion._tags = profile._tags.copy() - face_after_extrusion._apply_tag("face.extrusion.start", propagate=False) - face_after_extrusion._metadata = profile._metadata.copy() - profile_face_normal = face_after_extrusion.get_normal_at() - - if profile_face_normal is None: - raise ValueError("没有找到和Profile一致的面对象") - - for face_after_extrusion in solid.get_faces(): - # 开始根据法向量判断顶面底面和侧面 - face_center = face_after_extrusion.get_center() - # 如果法向量和dir正交,认为是侧面 - face_normal = face_after_extrusion.get_normal_at() - if face_normal.dot(direction_vec) == 0: - face_after_extrusion._tags = profile._tags.copy() - face_after_extrusion._apply_tag("face.extrusion.side", propagate=False) - side_face_count += 1 - geo = dict(face_after_extrusion.get_metadata("geo", {})) - geo["side_face_index"] = side_face_count - face_after_extrusion.set_metadata("geo", geo) - - # 法向量夹角180度,是顶面 - if face_normal.getAngle(profile_face_normal) == math.pi: - face_after_extrusion._tags = profile._tags.copy() - face_after_extrusion._apply_tag("face.extrusion.end", propagate=False) - - # 复制标签和元数据 - solid._tags = profile._tags.copy() solid._apply_tag("solid.extrusion", propagate=False) solid._metadata = profile._metadata.copy() - return _finalize_tracked_solid( + target_kinds = _validate_operation_output_roles(tracked.delta, assignments) + finalized = _finalize_tracked_solid( solid, op=_OP_MAKE_EXTRUDE_RSOLID, params={ @@ -3956,6 +5337,23 @@ def extrude_rsolid( delta_entries=cast(Dict[str, Dict[str, object]], tracked.delta_entries), input_shapes=[profile], ) + tagged = _apply_operation_role_tags( + finalized, + op=_OP_MAKE_EXTRUDE_RSOLID, + assignments=assignments, + target_kinds=target_kinds, + result_tag=normalized_result_tag, + ) + return _apply_feature_tag_prefix( + tagged, + tag_prefix=tag_prefix, + op=_OP_MAKE_EXTRUDE_RSOLID, + delta=tracked.delta, + start_role="extrusion.start", + end_role="extrusion.end", + side_role="extrusion.side", + source_shapes=[profile], + ) except Exception as e: _wrap_public_api_error( operation="extrude_rsolid", @@ -3981,9 +5379,26 @@ def revolve_rsolid( axis: Tuple[float, float, float] = (0, 0, 1), angle: ScalarLike = 360, origin: Tuple[float, float, float] = (0, 0, 0), + *, + tag_prefix: Optional[str] = None, + result_tag: Optional[str] = None, + start_face_tag: Optional[str] = None, + end_face_tag: Optional[str] = None, + side_faces_tag: Optional[str] = None, ) -> Solid: - """Create a solid by revolving a profile around an axis.""" + """Create a revolved solid, with optional kernel-role-based tags.""" try: + assignments = _normalize_operation_role_tags( + _OP_MAKE_REVOLVE_RSOLID, + ( + ("revolution.start", start_face_tag), + ("revolution.end", end_face_tag), + ("revolution.side", side_faces_tag), + ), + ) + normalized_result_tag = ( + normalize_tag(result_tag, strict=True) if result_tag is not None else None + ) angle_value = evaluate_scalar(angle) if angle_value <= 0: raise ValueError("旋转角度必须大于0") @@ -4022,11 +5437,10 @@ def revolve_rsolid( ) solid = cast(Solid, tracked.shape) - # 复制标签和元数据 - solid._tags = profile._tags.copy() solid._metadata = profile._metadata.copy() - return _finalize_tracked_solid( + target_kinds = _validate_operation_output_roles(tracked.delta, assignments) + finalized = _finalize_tracked_solid( solid, op=_OP_MAKE_REVOLVE_RSOLID, params={ @@ -4038,6 +5452,23 @@ def revolve_rsolid( delta_entries=cast(Dict[str, Dict[str, object]], tracked.delta_entries), input_shapes=[profile], ) + tagged = _apply_operation_role_tags( + finalized, + op=_OP_MAKE_REVOLVE_RSOLID, + assignments=assignments, + target_kinds=target_kinds, + result_tag=normalized_result_tag, + ) + return _apply_feature_tag_prefix( + tagged, + tag_prefix=tag_prefix, + op=_OP_MAKE_REVOLVE_RSOLID, + delta=tracked.delta, + start_role="revolution.start" if angle_value < 360.0 else None, + end_role="revolution.end" if angle_value < 360.0 else None, + side_role="revolution.side", + source_shapes=[profile], + ) except Exception as e: _wrap_public_api_error( operation="revolve_rsolid", @@ -4063,18 +5494,195 @@ def revolve_rsolid( # ============================================================================= -def apply_tag(shape: AnyShape, tag: str) -> AnyShape: - """Attach a normalized tag to a shape using the standard propagation policy. +def _semantic_view_target(view: AnyShape, target: AnyShape) -> AnyShape: + candidates = [ + wrapper + for entity in view._topology_cache.entities() + if entity.kind == target._entity.kind + for wrapper in entity.wrappers + if isinstance(wrapper, type(target)) + ] + matches = [] + for candidate in candidates: + try: + if candidate.wrapped.IsSame(target.wrapped): + matches.append(candidate) + except Exception: + continue + unique = {candidate.topo_id: candidate for candidate in matches} + if not unique: + raise ValueError("tag target does not belong to the assignment scope") + if len(unique) != 1: + raise ValueError("tag target is ambiguous within the assignment scope") + return next(iter(unique.values())) + + +def apply_tag_rselection( + scope: AnyShape, + targets: Union[ShapeSelector, Sequence[AnyShape]], + tag: str, + topology_propagation: str | TopologyPropagation = TopologyPropagation.LOCAL, + lineage_policy: str | LineagePolicy = LineagePolicy.CONTINUATION_FRAGMENT, +) -> AnyShape: + """Return a semantic shape view with a canonical tag assignment.""" - Tags must already be normalized lowercase tokens such as - ``role.mounting_surface`` or ``group.fasteners``. Propagation is intentionally - not configurable from the public API; the default tag policy propagates - semantic role/anchor/group tags downward and keeps topology-specific tags - local. - """ try: - shape._apply_tag(tag) - return shape + return _apply_tag_rselection( + scope, + targets, + tag, + topology_propagation, + lineage_policy, + ) + except Exception as e: + _wrap_public_api_error( + operation="apply_tag_rselection", + what_happened="Failed to attach the tag to the selected shapes.", + possible_causes=[ + "The assignment scope or target selection is invalid.", + "The tag value is empty or malformed.", + "The selector resolved no targets or references foreign topology.", + ], + how_to_fix=[ + "Pass a valid scope and a non-empty ShapeSelector or shape sequence.", + "Use a normalized tag string such as 'role.mounting_surface' or 'group.fasteners'.", + "Ensure every explicit target belongs to the assignment scope.", + ], + error=e, + ) + + +def _apply_tag_rselection( + scope: AnyShape, + targets: Union[ShapeSelector, Sequence[AnyShape]], + tag: str, + topology_propagation: str | TopologyPropagation, + lineage_policy: str | LineagePolicy, + *, + authoring_source: str = "simplecadapi.apply_tag_rselection", + extra_evidence: Optional[Dict[str, Any]] = None, + clone_scope: bool = True, +) -> AnyShape: + normalized_tag = normalize_tag(tag, strict=True) + topology = TopologyPropagation(topology_propagation) + lineage = LineagePolicy(lineage_policy) + session = get_active_session() + source_node = _active_graph_node_for_shape(scope) + source_output_slot = int(scope._get_runtime("graph.output_slot", 0)) + if session is not None and source_node is None: + raise ValueError("assignment scope is not produced by the active GraphSession") + + view = clone_semantic_shape_view(scope) if clone_scope else scope + if isinstance(targets, ShapeSelector): + selector = targets + if source_node is not None: + if selector.source_node_id is None: + selector = selector.from_source(source_node.node_id, source_output_slot) + elif ( + selector.source_node_id != source_node.node_id + or int(selector.source_output_slot or 0) != source_output_slot + ): + raise ValueError( + "tag selector source does not match the assignment scope" + ) + selected = cast(List[AnyShape], selector.resolve(view)) + target = TagTarget("selection_query", selector=selector.to_dict()) + else: + if isinstance(targets, (str, bytes)): + raise TypeError("targets must be a ShapeSelector or shape sequence") + target_shapes = list(targets) + if not target_shapes: + raise ValueError("tag assignment targets cannot be empty") + if not all( + isinstance(item, (Vertex, Edge, Wire, Face, Solid, Compound)) + for item in target_shapes + ): + raise TypeError("tag assignment targets must contain only shapes") + selected = [ + _semantic_view_target(view, cast(AnyShape, item)) for item in target_shapes + ] + refs = tuple( + ref for ref in _serialize_shape_refs(target_shapes) if isinstance(ref, dict) + ) + if len(refs) != len(target_shapes): + refs = tuple( + {"kind": _shape_kind_token(item), "topo_id": item.topo_id} + for item in target_shapes + ) + target = TagTarget("explicit_refs", refs=refs) + + selected_by_topo_id = {item.topo_id: item for item in selected} + if len(selected_by_topo_id) != len(selected): + raise ValueError("tag assignment targets contain ambiguous duplicate entities") + selected = list(selected_by_topo_id.values()) + if not selected: + raise ValueError("tag assignment resolved no targets") + + selected_refs = _serialize_shape_refs(selected) + if len(selected_refs) != len(selected): + selected_refs = [ + {"kind": _shape_kind_token(item), "topo_id": item.topo_id} + for item in selected + ] + + assignment_node_id = ( + f"n_{uuid.uuid4().hex[:8]}" if source_node is not None else None + ) + evidence_data = { + "authoring_source": authoring_source, + "selected_count": len(selected), + "selected_refs": selected_refs, + **dict(extra_evidence or {}), + } + binding = TagBinding( + tag=normalized_tag, + producer=TagProducer("user_operation", node_id=assignment_node_id), + scope=TagBindingScope( + node_id=(source_node.node_id if source_node is not None else None), + output_slot=source_output_slot, + ), + target=target, + propagation=TagPropagation(topology=topology, lineage=lineage), + evidence=TagEvidence("query_execution", evidence_data), + certainty=TagCertainty.ASSERTED, + lifecycle=TagLifecycle.ASSERTION, + ) + + for selected_shape in selected: + selected_shape._add_tag_binding(binding) + + if source_node is not None and session is not None: + node = record_operation( + op=_OP_APPLY_TAG_RSELECTION, + params={"tag_binding": binding.to_dict()}, + inputs=[source_node], + node_id=assignment_node_id, + output_count=1, + context=_current_context_metadata(), + ) + attach_semantic_graph_node( + view, + node, + output_slot=0, + graph_id=session.graph.graph_id, + ) + return view + + +def apply_tag(shape: AnyShape, tag: str) -> AnyShape: + """Attach a local user tag with continuation/fragment lineage policy.""" + + try: + _ensure_source_shape_has_own_selection_node(shape) + selector = ShapeSelector(_shape_kind_token(shape)).exactly(1) + return _apply_tag_rselection( + shape, + selector, + tag, + TopologyPropagation.LOCAL, + LineagePolicy.CONTINUATION_FRAGMENT, + clone_scope=False, + ) except Exception as e: _wrap_public_api_error( operation="apply_tag", @@ -4091,10 +5699,13 @@ def apply_tag(shape: AnyShape, tag: str) -> AnyShape: ) -def list_tags(shape: AnyShape) -> List[str]: - """Return shape tags in deterministic sorted order.""" +def list_tags( + shape: AnyShape, + scope: str | TagScope = TagScope.EFFECTIVE, +) -> List[str]: + """Return shape tags in deterministic sorted order for one scope.""" try: - return shape._list_tags() + return shape._list_tags(normalize_tag_scope(scope)) except Exception as e: _wrap_public_api_error( operation="list_tags", @@ -4110,11 +5721,44 @@ def list_tags(shape: AnyShape) -> List[str]: ) -def select_faces_by_tag(solid: Solid, tag: str) -> List[Face]: +def explain_tag( + shape: AnyShape, + tag: str, + scope: str | TagScope = TagScope.EFFECTIVE, +) -> List[Dict[str, Any]]: + """Explain every visible binding that produces a tag token.""" + + try: + return shape._explain_tag( + normalize_tag(tag, strict=True), normalize_tag_scope(scope) + ) + except Exception as e: + _wrap_public_api_error( + operation="explain_tag", + what_happened="Failed to explain the tag on the shape.", + possible_causes=[ + "The shape or tag is invalid.", + "The requested scope requires unavailable semantic evidence.", + ], + how_to_fix=[ + "Pass a valid shape and normalized tag token.", + "Request a supported scope or provide complete topology history.", + ], + error=e, + ) + + +def select_faces_by_tag( + solid: Solid, + tag: str, + scope: str | TagScope = TagScope.EFFECTIVE, +) -> List[Face]: """Select faces by tag.""" try: + normalized = normalize_tag(tag, strict=True) + resolved_scope = normalize_tag_scope(scope) faces = solid.get_faces() - return [face for face in faces if face._has_tag(tag)] + return [face for face in faces if face._has_tag(normalized, resolved_scope)] except Exception as e: _wrap_public_api_error( operation="select_faces_by_tag", @@ -4131,17 +5775,23 @@ def select_faces_by_tag(solid: Solid, tag: str) -> List[Face]: ) -def select_edges_by_tag(shape: Union[Face, Solid], tag: str) -> List[Edge]: +def select_edges_by_tag( + shape: Union[Face, Solid], + tag: str, + scope: str | TagScope = TagScope.EFFECTIVE, +) -> List[Edge]: """Select edges by tag.""" try: + normalized = normalize_tag(tag, strict=True) + resolved_scope = normalize_tag_scope(scope) if isinstance(shape, Face): - edges = [Edge(edge) for edge in shape.wrapped.Edges()] + edges = shape.get_edges() elif isinstance(shape, Solid): edges = shape.get_edges() else: raise ValueError("只能从面或实体中选择边") - return [edge for edge in edges if edge._has_tag(tag)] + return [edge for edge in edges if edge._has_tag(normalized, resolved_scope)] except Exception as e: _wrap_public_api_error( operation="select_edges_by_tag", @@ -4170,6 +5820,7 @@ def union_rsolid( clean: bool = True, glue: bool = _DEFAULT_UNION_GLUE, tol: Optional[float] = None, + tracking_policy: TrackingPolicy | str = TrackingPolicy.FULL, ) -> Solid: """Compute the boolean union and return one solid. @@ -4181,6 +5832,8 @@ def union_rsolid( Defaults to True for SimpleCAD's standard union behavior. tol: Optional fuzzy-boolean tolerance used by the OCC union kernel. When omitted, SimpleCAD chooses a conservative scale-aware tolerance. + tracking_policy: FULL computes topology history and lineage. GRAPH keeps + the replayable operation node without computing a TopoDelta. Returns: Solid: The merged union result. @@ -4199,6 +5852,11 @@ def union_rsolid( """ try: + policy = ( + TrackingPolicy.GRAPH + if current_tracking_policy() == TrackingPolicy.GRAPH + else TrackingPolicy(tracking_policy) + ) remaining = _flatten_boolean_solids(solids, "union_rsolid") if not remaining: @@ -4212,8 +5870,25 @@ def union_rsolid( return remaining[0] effective_tol = _resolve_union_tol(remaining, tol) - fused_shape = fuse_shapes( - [solid.wrapped for solid in remaining], glue=glue, tol=effective_tol, clean=clean + fused = ( + fuse_shapes_with_history( + [solid.wrapped for solid in remaining], + glue=glue, + tol=effective_tol, + clean=clean, + ) + if policy == TrackingPolicy.FULL + else None + ) + fused_shape = ( + fused.shape + if fused is not None + else fuse_shapes( + [solid.wrapped for solid in remaining], + glue=glue, + tol=effective_tol, + clean=clean, + ) ) result_shapes = solids_of(fused_shape) @@ -4230,38 +5905,34 @@ def union_rsolid( failure_reason=failure_reason, ) - all_tags = set() all_metadata = {} for solid in remaining: - all_tags.update(solid._tags) all_metadata.update(solid._metadata) - fused_solid._tags = all_tags.copy() fused_solid._metadata = all_metadata.copy() tracked_union_result: Optional[TrackedBooleanResult] = None - if len(remaining) == 2: - try: - tracked_union_result = tracked_union( - remaining[0], - remaining[1], - glue=glue, - tol=float(effective_tol or 0.0), - ) - except Exception: - tracked_union_result = None + if fused is not None and len(remaining) >= 2: + tracked_union_result = track_union_history( + remaining, + fused_solid, + fused.history, + fused.section_edges, + ) + params: Dict[str, object] = { + "input_count": len(remaining), + "clean": clean, + "glue": glue, + "tol": effective_tol, + "tracking_policy": policy.value, + } if tracked_union_result is not None: fused_solid = _finalize_tracked_solid( fused_solid, op=_OP_MAKE_UNION_RSOLID, - params={ - "input_count": len(remaining), - "clean": clean, - "glue": glue, - "tol": effective_tol, - }, - source_solid=remaining[0], + params=params, + source_solids=remaining, delta=tracked_union_result.delta, delta_entries=cast( Dict[str, Dict[str, object]], @@ -4269,16 +5940,21 @@ def union_rsolid( ), input_shapes=remaining, ) + elif policy == TrackingPolicy.GRAPH: + fused_solid = cast( + Solid, + _finalize_derived_shape( + fused_solid, + op=_OP_MAKE_UNION_RSOLID, + params=params, + input_shapes=remaining, + ), + ) else: _attach_track_summary(fused_solid, op=_OP_MAKE_UNION_RSOLID) record_operation_if_active( op=_OP_MAKE_UNION_RSOLID, - params={ - "input_count": len(remaining), - "clean": clean, - "glue": glue, - "tol": effective_tol, - }, + params=params, outputs=fused_solid, input_shapes=remaining, context=_current_context_metadata(), @@ -4306,6 +5982,7 @@ def union_rsolid( def cut_rsolid( *solids: Union[Solid, Sequence[Solid]], skip_non_intersecting: bool = True, + tracking_policy: TrackingPolicy | str = TrackingPolicy.FULL, ) -> Solid: """Compute the boolean difference of solids. @@ -4316,6 +5993,8 @@ def cut_rsolid( skip_non_intersecting: When True, tools with no meaningful intersection are ignored for interactive convenience. Graph replay records this flag and should use False for strict diagnostic workflows. + tracking_policy: FULL computes topology history and lineage. GRAPH keeps + the replayable operation node without computing a TopoDelta. Returns: Solid: The cut result solid. @@ -4325,6 +6004,11 @@ def cut_rsolid( sequences, and returns a single `Solid`. """ try: + policy = ( + TrackingPolicy.GRAPH + if current_tracking_policy() == TrackingPolicy.GRAPH + else TrackingPolicy(tracking_policy) + ) remaining = _flatten_boolean_solids(solids, "cut_rsolid") if not remaining: @@ -4363,47 +6047,64 @@ def cut_rsolid( continue raise ValueError("差集工具实体与当前实体交集体积过小。") - tracked = tracked_cut(result_solid, candidate) - if tracked.solid is None: - raise ValueError("差集运算失败: OCC 未返回有效实体") - - new_result = tracked.solid - new_result._tags = result_solid._tags.copy() + tracked = ( + tracked_cut(result_solid, candidate) + if policy == TrackingPolicy.FULL + else None + ) + if tracked is not None: + if tracked.solid is None: + raise ValueError("差集运算失败: OCC 未返回有效实体") + new_result = tracked.solid + else: + new_result = _require_single_boolean_solid( + solids_of(cut_shapes(s1, [s2])), + operation="cut_rsolid", + failure_reason="差集运算失败: OCC 未返回有效实体", + ) new_result._metadata = result_solid._metadata.copy() result_solid = new_result - deltas.append(tracked.delta) - merged_delta_entries.update( - cast(Dict[str, Dict[str, object]], tracked.delta_entries) - ) + if tracked is not None: + deltas.append(tracked.delta) + merged_delta_entries.update( + cast(Dict[str, Dict[str, object]], tracked.delta_entries) + ) cut_performed = True - # 保留第一个实体的标签和元数据 - result_solid._tags = remaining[0]._tags.copy() result_solid._metadata = remaining[0]._metadata.copy() result_solid._apply_tag("solid.boolean.cut", propagate=False) merged_delta = _merge_topo_deltas(deltas) + params: Dict[str, object] = { + "tool_count": len(remaining) - 1, + "skip_non_intersecting": bool(skip_non_intersecting), + "tracking_policy": policy.value, + } if cut_performed and merged_delta is not None: result_solid = _finalize_tracked_solid( result_solid, op=_OP_MAKE_CUT_RSOLID, - params={ - "tool_count": len(remaining) - 1, - "skip_non_intersecting": bool(skip_non_intersecting), - }, - source_solid=remaining[0], + params=params, + source_solids=remaining, delta=merged_delta, delta_entries=merged_delta_entries or None, input_shapes=remaining, ) + elif policy == TrackingPolicy.GRAPH: + result_solid = cast( + Solid, + _finalize_derived_shape( + result_solid, + op=_OP_MAKE_CUT_RSOLID, + params=params, + input_shapes=remaining, + ), + ) else: _attach_track_summary(result_solid, op=_OP_MAKE_CUT_RSOLID) record_operation_if_active( op=_OP_MAKE_CUT_RSOLID, - params={ - "tool_count": len(remaining) - 1, - "skip_non_intersecting": bool(skip_non_intersecting), - }, + params=params, outputs=result_solid, input_shapes=remaining, context=_current_context_metadata(), @@ -4482,16 +6183,10 @@ def intersect_rsolid(*solids: Union[Solid, Sequence[Solid]]) -> Solid: if result_solid.get_volume() < 1e-12: raise ValueError("交集结果为空或体积过小") - # 合并所有输入实体的标签和元数据 - all_tags: set = set() all_metadata: dict = {} for solid in remaining: - all_tags = ( - all_tags.intersection(solid._tags) if all_tags else solid._tags.copy() - ) all_metadata.update(solid._metadata) - result_solid._tags = all_tags result_solid._metadata = all_metadata result_solid._apply_tag("solid.boolean.intersect", propagate=False) @@ -4501,7 +6196,7 @@ def intersect_rsolid(*solids: Union[Solid, Sequence[Solid]]) -> Solid: result_solid, op=_OP_MAKE_INTERSECT_RSOLID, params={"input_count": len(remaining)}, - source_solid=remaining[0], + source_solids=remaining, delta=merged_delta, delta_entries=merged_delta_entries or None, input_shapes=remaining, @@ -4576,7 +6271,6 @@ def make_2d_cut_rface(body: Face, tool: Face) -> Face: result_shape = cut_shapes(body.wrapped, [tool.wrapped]) result_face = _extract_single_face(result_shape, "make_2d_cut_rface") - result_face._tags = body._tags.copy() result_face._metadata = body._metadata.copy() return cast( @@ -4632,7 +6326,6 @@ def make_2d_union_rface(face_a: Face, face_b: Face) -> Face: result_shape = fuse_shapes([face_a.wrapped, face_b.wrapped], clean=True) result_face = _extract_single_face(result_shape, "make_2d_union_rface") - result_face._tags = face_a._tags | face_b._tags result_face._metadata = {**face_a._metadata, **face_b._metadata} return cast( @@ -4687,7 +6380,6 @@ def make_2d_intersect_rface(face_a: Face, face_b: Face) -> Face: result_shape = common_shapes([face_a.wrapped, face_b.wrapped]) result_face = _extract_single_face(result_shape, "make_2d_intersect_rface") - result_face._tags = face_a._tags & face_b._tags result_face._metadata = {**face_a._metadata, **face_b._metadata} return cast( @@ -5040,7 +6732,9 @@ def place_component_rassembly( outputs=result, input_shapes=[assembly, placement], semantic_delta=_semantic_modified( - "Component", f"{assembly.assembly_id}:{component_id}", {"placement": placement.to_dict()} + "Component", + f"{assembly.assembly_id}:{component_id}", + {"placement": placement.to_dict()}, ), context=_current_context_metadata(), ) @@ -5296,7 +6990,10 @@ def add_connector_rassembly(assembly: Assembly, connector: Connector) -> Assembl result = assembly.with_connector(connector) record_operation_if_active( _OP_MAKE_ADD_CONNECTOR_RASSEMBLY, - {"assembly_id": assembly.assembly_id, "connector_id": connector.connector_id}, + { + "assembly_id": assembly.assembly_id, + "connector_id": connector.connector_id, + }, outputs=result, input_shapes=[assembly, connector], semantic_delta=_semantic_modified( @@ -5452,7 +7149,9 @@ def ground_component_rassembly(assembly: Assembly, component_id: str) -> Assembl outputs=result, input_shapes=[assembly], semantic_delta=_semantic_modified( - "Assembly", assembly.assembly_id, {"grounded_component_id": component_id} + "Assembly", + assembly.assembly_id, + {"grounded_component_id": component_id}, ), context=_current_context_metadata(), ) @@ -5483,7 +7182,9 @@ def unground_component_rassembly(assembly: Assembly, component_id: str) -> Assem outputs=result, input_shapes=[assembly], semantic_delta=_semantic_modified( - "Assembly", assembly.assembly_id, {"ungrounded_component_id": component_id} + "Assembly", + assembly.assembly_id, + {"ungrounded_component_id": component_id}, ), context=_current_context_metadata(), ) @@ -5708,7 +7409,11 @@ def _add_constraint_rassembly( if not isinstance(assembly, Assembly): raise TypeError("assembly must be an Assembly") result = assembly.with_constraint(constraint) - inputs: List[object] = [assembly, constraint.connector_a, constraint.connector_b] + inputs: List[object] = [ + assembly, + constraint.connector_a, + constraint.connector_b, + ] if constraint.distance_limit is not None: inputs.append(constraint.distance_limit) if constraint.angle_limit is not None: @@ -5816,7 +7521,9 @@ def inspect_assembly_constraints_rconstraintreport( return inspect_assembly_constraints(assembly) -def _placed_solids_from_item(item: Union[Part, Assembly], placement: Placement) -> List[Solid]: +def _placed_solids_from_item( + item: Union[Part, Assembly], placement: Placement +) -> List[Solid]: if isinstance(item, Part): placed = place_shape_ocp( item.body, @@ -5998,8 +7705,12 @@ def export_stl(shapes: Union[AnyShape, Sequence[AnyShape]], filename: str) -> No for shape in shape_list: if not isinstance(shape, (Compound, Solid, Face)): - raise ValueError("export_stl函数只支持Compound、Solid和Face类型的几何体") - export_stl_shape(make_compound([shape.wrapped for shape in shape_list]), filename) + raise ValueError( + "export_stl函数只支持Compound、Solid和Face类型的几何体" + ) + export_stl_shape( + make_compound([shape.wrapped for shape in shape_list]), filename + ) except Exception as e: _wrap_public_api_error( operation="export_stl", @@ -6028,6 +7739,7 @@ def render_screenshot_rpath( show_axes: bool = True, show_legend: bool = True, zoom: float = 4.0, + show_callouts: bool = True, ) -> str: """Render a screenshot of shapes and save it to a file.""" try: @@ -6496,25 +8208,29 @@ def render_screenshot_rpath( ) y -= 0.035 - label_offset = 0.012 - for idx, (tag, point) in enumerate(label_points.items()): - label = labels.get(tag, tag) - xfig, yfig = _project_to_fig(point) - xfig = _clamp(xfig + label_offset, 0.02, 0.98) - yfig = _clamp(yfig + label_offset, 0.02, 0.98) - yfig = _clamp(yfig - idx * 0.02, 0.02, 0.98) - fig.text( - xfig, - yfig, - label, - color="#ffd27a", - fontsize=10, - ha="left", - va="center", - bbox=dict( - boxstyle="round,pad=0.2", fc="#111111", ec="#ffaa33", alpha=0.9 - ), - ) + if show_callouts: + label_offset = 0.012 + for idx, (tag, point) in enumerate(label_points.items()): + label = labels.get(tag, tag) + xfig, yfig = _project_to_fig(point) + xfig = _clamp(xfig + label_offset, 0.02, 0.98) + yfig = _clamp(yfig + label_offset, 0.02, 0.98) + yfig = _clamp(yfig - idx * 0.02, 0.02, 0.98) + fig.text( + xfig, + yfig, + label, + color="#ffd27a", + fontsize=10, + ha="left", + va="center", + bbox=dict( + boxstyle="round,pad=0.2", + fc="#111111", + ec="#ffaa33", + alpha=0.9, + ), + ) plt.savefig(output_path, facecolor=background) plt.close(fig) @@ -6543,10 +8259,22 @@ def render_screenshot_rpath( def fillet_rsolid( - solid: Solid, edges: Union[Sequence[Edge], ShapeSelector], radius: ScalarLike + solid: Solid, + edges: Union[Sequence[Edge], ShapeSelector], + radius: ScalarLike, + *, + result_tag: Optional[str] = None, + generated_faces_tag: Optional[str] = None, ) -> Solid: - """Apply fillets to selected solid edges.""" + """Apply fillets, with optional tagging of kernel-proven patch faces.""" try: + assignments = _normalize_operation_role_tags( + _OP_MAKE_FILLET_RSOLID, + (("fillet.patch", generated_faces_tag),), + ) + normalized_result_tag = ( + normalize_tag(result_tag, strict=True) if result_tag is not None else None + ) radius_value = evaluate_scalar(radius) if radius_value <= 0: raise ValueError("圆角半径必须大于0") @@ -6558,8 +8286,6 @@ def fillet_rsolid( tracked = tracked_fillet(solid, selected_edges, radius_value) result = cast(Solid, tracked.shape) - # 复制标签和元数据 - result._tags = solid._tags.copy() result._metadata = solid._metadata.copy() selected_edge_refs = _serialize_shape_refs(selected_edges) @@ -6577,7 +8303,8 @@ def fillet_rsolid( selected_edges, solid.get_edges() ) - return _finalize_tracked_solid( + target_kinds = _validate_operation_output_roles(tracked.delta, assignments) + finalized = _finalize_tracked_solid( result, op=_OP_MAKE_FILLET_RSOLID, params=selection_params, @@ -6586,6 +8313,13 @@ def fillet_rsolid( delta_entries=cast(Dict[str, Dict[str, object]], tracked.delta_entries), input_shapes=[solid, *selected_edges], ) + return _apply_operation_role_tags( + finalized, + op=_OP_MAKE_FILLET_RSOLID, + assignments=assignments, + target_kinds=target_kinds, + result_tag=normalized_result_tag, + ) except Exception as e: _wrap_public_api_error( operation="fillet_rsolid", @@ -6605,10 +8339,22 @@ def fillet_rsolid( def chamfer_rsolid( - solid: Solid, edges: Union[Sequence[Edge], ShapeSelector], distance: ScalarLike + solid: Solid, + edges: Union[Sequence[Edge], ShapeSelector], + distance: ScalarLike, + *, + result_tag: Optional[str] = None, + generated_faces_tag: Optional[str] = None, ) -> Solid: - """Apply chamfers to selected solid edges.""" + """Apply chamfers, with optional tagging of kernel-proven patch faces.""" try: + assignments = _normalize_operation_role_tags( + _OP_MAKE_CHAMFER_RSOLID, + (("chamfer.patch", generated_faces_tag),), + ) + normalized_result_tag = ( + normalize_tag(result_tag, strict=True) if result_tag is not None else None + ) distance_value = evaluate_scalar(distance) if distance_value <= 0: raise ValueError("倒角距离必须大于0") @@ -6620,8 +8366,6 @@ def chamfer_rsolid( tracked = tracked_chamfer(solid, selected_edges, distance_value) result = cast(Solid, tracked.shape) - # 复制标签和元数据 - result._tags = solid._tags.copy() result._metadata = solid._metadata.copy() selected_edge_refs = _serialize_shape_refs(selected_edges) @@ -6639,7 +8383,8 @@ def chamfer_rsolid( selected_edges, solid.get_edges() ) - return _finalize_tracked_solid( + target_kinds = _validate_operation_output_roles(tracked.delta, assignments) + finalized = _finalize_tracked_solid( result, op=_OP_MAKE_CHAMFER_RSOLID, params=selection_params, @@ -6648,6 +8393,13 @@ def chamfer_rsolid( delta_entries=cast(Dict[str, Dict[str, object]], tracked.delta_entries), input_shapes=[solid, *selected_edges], ) + return _apply_operation_role_tags( + finalized, + op=_OP_MAKE_CHAMFER_RSOLID, + assignments=assignments, + target_kinds=target_kinds, + result_tag=normalized_result_tag, + ) except Exception as e: _wrap_public_api_error( operation="chamfer_rsolid", @@ -6670,9 +8422,27 @@ def shell_rsolid( solid: Solid, faces_to_remove: Union[Sequence[Face], ShapeSelector], thickness: ScalarLike, + *, + result_tag: Optional[str] = None, + body_faces_tag: Optional[str] = None, + offset_faces_tag: Optional[str] = None, + closing_faces_tag: Optional[str] = None, + wall_edges_tag: Optional[str] = None, ) -> Solid: - """Shell a solid to create a hollow part.""" + """Shell a solid, with optional kernel-role-based face tags.""" try: + assignments = _normalize_operation_role_tags( + _OP_MAKE_SHELL_RSOLID, + ( + ("shell.body_face", body_faces_tag), + ("shell.offset_face", offset_faces_tag), + ("shell.closing_descendant", closing_faces_tag), + ("shell.wall", wall_edges_tag), + ), + ) + normalized_result_tag = ( + normalize_tag(result_tag, strict=True) if result_tag is not None else None + ) thickness_value = evaluate_scalar(thickness) if thickness_value <= 0: raise ValueError("壁厚必须大于0") @@ -6687,8 +8457,6 @@ def shell_rsolid( tracked = tracked_shell(solid, selected_faces, thickness_value) result = cast(Solid, tracked.shape) - # 复制标签和元数据 - result._tags = solid._tags.copy() result._metadata = solid._metadata.copy() selected_face_refs = _serialize_shape_refs(selected_faces) @@ -6706,7 +8474,8 @@ def shell_rsolid( selected_faces, solid.get_faces() ) - return _finalize_tracked_solid( + target_kinds = _validate_operation_output_roles(tracked.delta, assignments) + finalized = _finalize_tracked_solid( result, op=_OP_MAKE_SHELL_RSOLID, params=selection_params, @@ -6715,6 +8484,13 @@ def shell_rsolid( delta_entries=cast(Dict[str, Dict[str, object]], tracked.delta_entries), input_shapes=[solid, *selected_faces], ) + return _apply_operation_role_tags( + finalized, + op=_OP_MAKE_SHELL_RSOLID, + assignments=assignments, + target_kinds=target_kinds, + result_tag=normalized_result_tag, + ) except Exception as e: _wrap_public_api_error( operation="shell_rsolid", @@ -6733,32 +8509,106 @@ def shell_rsolid( ) -def loft_rsolid(profiles: List[Wire], ruled: bool = False) -> Solid: - """Create a solid by lofting multiple profiles.""" +def loft_rsolid( + profiles: List[Wire], + ruled: bool = False, + *, + tracking_policy: TrackingPolicy | str = TrackingPolicy.FULL, + tag_prefix: Optional[str] = None, + result_tag: Optional[str] = None, + start_face_tag: Optional[str] = None, + end_face_tag: Optional[str] = None, + side_faces_tag: Optional[str] = None, +) -> Solid: + """Create a lofted solid, with optional kernel-role-based tags.""" try: + policy = ( + TrackingPolicy.GRAPH + if current_tracking_policy() == TrackingPolicy.GRAPH + else TrackingPolicy(tracking_policy) + ) + assignments = _normalize_operation_role_tags( + _OP_MAKE_LOFT_RSOLID, + ( + ("loft.start", start_face_tag), + ("loft.end", end_face_tag), + ("loft.side", side_faces_tag), + ), + ) + normalized_result_tag = ( + normalize_tag(result_tag, strict=True) if result_tag is not None else None + ) if len(profiles) < 2: raise ValueError("放样至少需要2个轮廓") - tracked = tracked_loft(profiles, ruled=ruled) - result = cast(Solid, tracked.shape) + if policy == TrackingPolicy.GRAPH and (assignments or tag_prefix is not None): + raise ValueError( + "GRAPH tracking does not provide loft face-role evidence; " + "use FULL tracking for tag_prefix, start_face_tag, end_face_tag, " + "or side_faces_tag" + ) + + tracked = ( + tracked_loft(profiles, ruled=ruled) + if policy == TrackingPolicy.FULL + else None + ) + result = ( + cast(Solid, tracked.shape) + if tracked is not None + else Solid(make_loft_solid((profile.wrapped for profile in profiles), ruled=ruled)) + ) - # 合并所有轮廓的标签和元数据 - all_tags = set() all_metadata = {} for profile in profiles: - all_tags.update(profile._tags) all_metadata.update(profile._metadata) - result._tags = all_tags result._metadata = all_metadata - return _finalize_tracked_solid( - result, + params: Dict[str, object] = { + "profile_count": len(profiles), + "ruled": ruled, + "tracking_policy": policy.value, + } + if tracked is not None: + target_kinds = _validate_operation_output_roles(tracked.delta, assignments) + finalized = _finalize_tracked_solid( + result, + op=_OP_MAKE_LOFT_RSOLID, + params=params, + delta=tracked.delta, + delta_entries=cast(Dict[str, Dict[str, object]], tracked.delta_entries), + input_shapes=profiles, + ) + else: + target_kinds = {} + finalized = cast( + Solid, + _finalize_derived_shape( + result, + op=_OP_MAKE_LOFT_RSOLID, + params=params, + input_shapes=profiles, + ), + ) + tagged = _apply_operation_role_tags( + finalized, + op=_OP_MAKE_LOFT_RSOLID, + assignments=assignments, + target_kinds=target_kinds, + result_tag=normalized_result_tag, + ) + if tracked is None: + return tagged + return _apply_feature_tag_prefix( + tagged, + tag_prefix=tag_prefix, op=_OP_MAKE_LOFT_RSOLID, - params={"profile_count": len(profiles), "ruled": ruled}, delta=tracked.delta, - delta_entries=cast(Dict[str, Dict[str, object]], tracked.delta_entries), - input_shapes=profiles, + start_role="loft.start", + end_role="loft.end", + side_role="loft.side", + source_shapes=profiles, ) except Exception as e: _wrap_public_api_error( @@ -6778,18 +8628,38 @@ def loft_rsolid(profiles: List[Wire], ruled: bool = False) -> Solid: ) -def sweep_rsolid(profile: Face, path: Wire, is_frenet: bool = False) -> Solid: - """Create a solid by sweeping a profile along a path.""" +def sweep_rsolid( + profile: Face, + path: Wire, + is_frenet: bool = False, + *, + tag_prefix: Optional[str] = None, + result_tag: Optional[str] = None, + start_face_tag: Optional[str] = None, + end_face_tag: Optional[str] = None, + side_faces_tag: Optional[str] = None, +) -> Solid: + """Create a swept solid, with optional kernel-role-based tags.""" make_solid = True # 默认创建实体 try: + assignments = _normalize_operation_role_tags( + _OP_MAKE_SWEEP_RSOLID, + ( + ("sweep.start", start_face_tag), + ("sweep.end", end_face_tag), + ("sweep.side", side_faces_tag), + ), + ) + normalized_result_tag = ( + normalize_tag(result_tag, strict=True) if result_tag is not None else None + ) tracked = tracked_sweep(profile, path, is_frenet=is_frenet) result = cast(Solid, tracked.shape) - # 合并轮廓和路径的标签和元数据 - result._tags = profile._tags.union(path._tags) result._metadata = {**profile._metadata, **path._metadata} - return _finalize_tracked_solid( + target_kinds = _validate_operation_output_roles(tracked.delta, assignments) + finalized = _finalize_tracked_solid( result, op=_OP_MAKE_SWEEP_RSOLID, params={"is_frenet": bool(is_frenet)}, @@ -6797,6 +8667,23 @@ def sweep_rsolid(profile: Face, path: Wire, is_frenet: bool = False) -> Solid: delta_entries=cast(Dict[str, Dict[str, object]], tracked.delta_entries), input_shapes=[profile, path], ) + tagged = _apply_operation_role_tags( + finalized, + op=_OP_MAKE_SWEEP_RSOLID, + assignments=assignments, + target_kinds=target_kinds, + result_tag=normalized_result_tag, + ) + return _apply_feature_tag_prefix( + tagged, + tag_prefix=tag_prefix, + op=_OP_MAKE_SWEEP_RSOLID, + delta=tracked.delta, + start_role="sweep.start", + end_role="sweep.end", + side_role="sweep.side", + source_shapes=[profile], + ) except Exception as e: _wrap_public_api_error( operation="sweep_rsolid", @@ -6815,6 +8702,127 @@ def sweep_rsolid(profile: Face, path: Wire, is_frenet: bool = False) -> Solid: ) +def twisted_sweep_rsolid( + profile: Face, + distance: ScalarLike, + twist_angle: ScalarLike, + axis: Tuple[float, float, float] = (0.0, 0.0, 1.0), + origin: Tuple[float, float, float] = (0.0, 0.0, 0.0), + *, + guide_radius: ScalarLike = 1.0, + tag_prefix: Optional[str] = None, + result_tag: Optional[str] = None, + start_face_tag: Optional[str] = None, + end_face_tag: Optional[str] = None, + side_faces_tag: Optional[str] = None, +) -> Solid: + """Sweep a planar profile along a straight axis with linear rotation. + + ``twist_angle`` is the signed total rotation in degrees. The operation uses + a cylindrical auxiliary spine to define a continuous rotation law, yielding + one continuous side face per profile edge instead of a segmented ruled loft. + """ + try: + if not isinstance(profile, Face): + raise TypeError("profile must be a Face") + distance_value = float(evaluate_scalar(distance)) + twist_value = float(evaluate_scalar(twist_angle)) + guide_radius_value = float(evaluate_scalar(guide_radius)) + if not math.isfinite(distance_value) or distance_value <= 0.0: + raise ValueError("distance must be a finite positive number") + if not math.isfinite(twist_value): + raise ValueError("twist_angle must be finite") + if not math.isfinite(guide_radius_value) or guide_radius_value <= 0.0: + raise ValueError("guide_radius must be a finite positive number") + + axis_value = cast(Tuple[float, float, float], evaluate_value(axis)) + origin_value = cast(Tuple[float, float, float], evaluate_value(origin)) + if len(axis_value) != 3 or len(origin_value) != 3: + raise ValueError("axis and origin must each contain three values") + cs = get_current_cs() + global_axis = cs.transform_vector(np.asarray(axis_value, dtype=float)) + global_origin = cs.transform_point(np.asarray(origin_value, dtype=float)) + axis_norm = float(np.linalg.norm(global_axis)) + if not math.isfinite(axis_norm) or axis_norm <= 1.0e-15: + raise ValueError("axis must be a finite non-zero vector") + global_axis /= axis_norm + resolved_axis = tuple(float(value) for value in global_axis) + resolved_origin = tuple(float(value) for value in global_origin) + + assignments = _normalize_operation_role_tags( + _OP_MAKE_TWISTED_SWEEP_RSOLID, + ( + ("twisted_sweep.start", start_face_tag), + ("twisted_sweep.end", end_face_tag), + ("twisted_sweep.side", side_faces_tag), + ), + ) + normalized_result_tag = ( + normalize_tag(result_tag, strict=True) if result_tag is not None else None + ) + tracked = tracked_twisted_sweep( + profile, + axis=resolved_axis, + origin=resolved_origin, + distance=distance_value, + twist_angle=twist_value, + guide_radius=guide_radius_value, + ) + result = cast(Solid, tracked.shape) + kernel_metadata = result.get_metadata("twisted_sweep.kernel", {}) + result._metadata = profile._metadata.copy() + result.set_metadata("twisted_sweep.kernel", kernel_metadata) + + target_kinds = _validate_operation_output_roles(tracked.delta, assignments) + finalized = _finalize_tracked_solid( + result, + op=_OP_MAKE_TWISTED_SWEEP_RSOLID, + params={ + "axis": axis, + "origin": origin, + "distance": distance, + "twist_angle": twist_angle, + "guide_radius": guide_radius, + }, + delta=tracked.delta, + delta_entries=cast(Dict[str, Dict[str, object]], tracked.delta_entries), + input_shapes=[profile], + ) + tagged = _apply_operation_role_tags( + finalized, + op=_OP_MAKE_TWISTED_SWEEP_RSOLID, + assignments=assignments, + target_kinds=target_kinds, + result_tag=normalized_result_tag, + ) + return _apply_feature_tag_prefix( + tagged, + tag_prefix=tag_prefix, + op=_OP_MAKE_TWISTED_SWEEP_RSOLID, + delta=tracked.delta, + start_role="twisted_sweep.start", + end_role="twisted_sweep.end", + side_role="twisted_sweep.side", + source_shapes=[profile], + ) + except Exception as e: + _wrap_public_api_error( + operation="twisted_sweep_rsolid", + what_happened="Failed to sweep the profile with a linear axial rotation.", + possible_causes=[ + "The profile is invalid or contains inner wires.", + "The distance, twist angle, axis, or guide radius is invalid.", + "The requested twist causes self-intersection or kernel failure.", + ], + how_to_fix=[ + "Pass a valid planar Face without inner wires.", + "Use a positive distance and guide radius with a non-zero axis.", + "Reduce the twist angle or simplify the profile.", + ], + error=e, + ) + + def linear_pattern_rsolidlist( shape: AnyShape, direction: Tuple[float, float, float], count: int, spacing: float ) -> List[Solid]: @@ -7014,8 +9022,13 @@ def mirror_shape( ), ) new_shape = cast(Solid, tracked.shape) - new_shape._tags = shape._tags.copy() new_shape._metadata = shape._metadata.copy() + _attach_lineage_from_source( + shape, + new_shape, + derivation="continuation", + op=_OP_MAKE_MIRROR_RSHAPE, + ) new_shape._apply_tag("solid.transform.mirrored", propagate=False) return _finalize_tracked_solid( new_shape, @@ -7045,9 +9058,13 @@ def mirror_shape( ), ) - # 复制标签和元数据 - new_shape._tags = shape._tags.copy() new_shape._metadata = shape._metadata.copy() + _attach_lineage_from_source( + shape, + new_shape, + derivation="continuation", + op=_OP_MAKE_MIRROR_RSHAPE, + ) new_shape._apply_tag("solid.transform.mirrored", propagate=False) _attach_track_summary(new_shape, op=_OP_MAKE_MIRROR_RSHAPE) @@ -7118,8 +9135,6 @@ def helical_sweep_rsolid( ) result = Solid(result_shape) - # 复制轮廓的标签和元数据 - result._tags = profile._tags.copy() result._metadata = profile._metadata.copy() result._apply_tag("solid.feature.helical_sweep", propagate=False) diff --git a/SimpleCADAPI/src/simplecadapi/ql.py b/SimpleCADAPI/src/simplecadapi/ql.py index 562649f..9f2645d 100644 --- a/SimpleCADAPI/src/simplecadapi/ql.py +++ b/SimpleCADAPI/src/simplecadapi/ql.py @@ -3,6 +3,13 @@ from __future__ import annotations from dataclasses import dataclass, field from typing import Any, Callable, Dict, Iterable, List, Optional, Sequence, Tuple, cast +from .tagging import ( + SemanticCapabilityError, + TagScope, + UnsupportedQueryCapabilityError, + normalize_tag_scope, +) + Predicate = Callable[[Any], bool] KeyFn = Callable[[Any], Any] @@ -10,18 +17,43 @@ MISSING = object() _PROPERTY_RESOLVERS: Dict[str, Callable[[Any, str], Any]] = {} -def _get_tags(obj: Any) -> List[str]: +def _get_tags( + obj: Any, scope: str | TagScope = TagScope.EFFECTIVE +) -> List[str]: + resolved_scope = normalize_tag_scope(scope) if hasattr(obj, "_list_tags"): - try: - return list(obj._list_tags()) - except Exception: - return [] + return list(obj._list_tags(resolved_scope)) tags = getattr(obj, "_tags", None) if tags is None: return [] + if resolved_scope != TagScope.EFFECTIVE: + raise UnsupportedQueryCapabilityError( + f"{resolved_scope.value} tag scope is unavailable for legacy flat-tag objects" + ) return list(tags) +def _track_values(track: dict, plural: str, singular: str) -> List[str]: + values = track.get(plural) + if isinstance(values, (list, tuple, set, frozenset)): + return [str(value) for value in values] + value = track.get(singular) + return [str(value)] if value is not None else [] + + +def _operation_names(value: Any) -> set[str]: + token = str(value).strip() + token = token[len("op.") :] if token.startswith("op.") else token + names = {token} + alias = token[5:] if token.startswith("make_") else token + for suffix in ("_rsolid", "_rshape", "_rface", "_rwire", "_redge"): + if alias.endswith(suffix): + alias = alias[: -len(suffix)] + break + names.add(alias) + return names + + def _get_metadata_root(obj: Any) -> dict: root = getattr(obj, "_metadata", None) if isinstance(root, dict): @@ -87,6 +119,8 @@ def _lookup_property(obj: Any, path: str) -> Any: return "outer" if obj._has_tag("wire.inner"): return "inner" + except SemanticCapabilityError: + raise except Exception: return MISSING return MISSING @@ -266,10 +300,57 @@ class SerializablePredicate: def __call__(self, obj: Any) -> bool: if self.kind == "tag": pattern = str(self.data["pattern"]) + scope = normalize_tag_scope(str(self.data.get("scope", "effective"))) + tags = _get_tags(obj, scope) if pattern.endswith("*"): prefix = pattern[:-1] - return any(tag.startswith(prefix) for tag in _get_tags(obj)) - return pattern in _get_tags(obj) + return any(tag.startswith(prefix) for tag in tags) + return pattern in tags + + if self.kind == "operation_event": + track = _lookup_metadata(obj, "track") + if not isinstance(track, dict): + return False + actual_names: set[str] = set() + for key in ("op", "operation"): + if track.get(key) is not None: + actual_names.update(_operation_names(track[key])) + if not actual_names.intersection(_operation_names(self.data["op"])): + return False + expected = str(self.data.get("event", "*")) + events = _track_values(track, "events", "event") + return bool(events) if expected == "*" else expected in events + + if self.kind == "origin_role": + track = _lookup_metadata(obj, "track") + if not isinstance(track, dict): + return False + roles = _track_values(track, "origin_roles", "origin_role") + return str(self.data["role"]) in roles + + if self.kind == "output_role": + track = _lookup_metadata(obj, "track") + if not isinstance(track, dict): + return False + roles = _track_values(track, "result_roles", "result_role") + return str(self.data["role"]) in roles + + if self.kind in {"source_binding", "source_topology"}: + list_bindings = getattr(obj, "_local_tag_bindings", None) + if not callable(list_bindings): + raise UnsupportedQueryCapabilityError( + f"{self.kind} requires canonical local TagBinding evidence" + ) + field = ( + "source_binding_id" + if self.kind == "source_binding" + else "source_topo_id" + ) + expected = str(self.data[field]) + return any( + str(binding.evidence.data.get(field, "")) == expected + for binding in list_bindings() + ) if self.kind == "meta": actual = _lookup_metadata(obj, str(self.data["path"])) @@ -309,12 +390,60 @@ class SerializablePredicate: @classmethod def from_dict(cls, data: Dict[str, Any]) -> "SerializablePredicate": + if not isinstance(data, dict): + raise ValueError("predicate payload must be an object") + if set(data) - {"kind", "data", "children"}: + raise ValueError("predicate payload contains unknown fields") + kind = data.get("kind") + raw_data = data.get("data", {}) + raw_children = data.get("children", []) + if not isinstance(kind, str) or not kind: + raise ValueError("predicate kind must be a non-empty string") + if not isinstance(raw_data, dict): + raise ValueError("predicate data must be an object") + if not isinstance(raw_children, list): + raise ValueError("predicate children must be an array") + + schemas = { + "tag": ({"pattern"}, {"scope"}), + "operation_event": ({"op"}, {"event"}), + "origin_role": ({"role"}, set()), + "output_role": ({"role"}, set()), + "source_binding": ({"source_binding_id"}, set()), + "source_topology": ({"source_topo_id"}, set()), + "meta": ({"path", "op", "value"}, set()), + "property_compare": ({"path", "op", "value"}, set()), + "curve_type": ({"value"}, set()), + "surface_type": ({"value"}, set()), + "and": (set(), set()), + "or": (set(), set()), + "not": (set(), set()), + } + if kind not in schemas: + raise ValueError(f"unsupported predicate kind: {kind}") + required, optional = schemas[kind] + missing = required - set(raw_data) + unknown = set(raw_data) - required - optional + if missing: + raise ValueError( + f"predicate '{kind}' is missing required data: {', '.join(sorted(missing))}" + ) + if unknown: + raise ValueError( + f"predicate '{kind}' contains unknown data: {', '.join(sorted(unknown))}" + ) + if kind in {"and", "or"} and not raw_children: + raise ValueError(f"predicate '{kind}' requires at least one child") + if kind == "not" and len(raw_children) != 1: + raise ValueError("predicate 'not' requires exactly one child") + if kind not in {"and", "or", "not"} and raw_children: + raise ValueError(f"predicate '{kind}' cannot contain children") + return cls( - kind=str(data["kind"]), - data=dict(data.get("data", {})), + kind=kind, + data=dict(raw_data), children=tuple( - SerializablePredicate.from_dict(child) - for child in data.get("children", []) + SerializablePredicate.from_dict(child) for child in raw_children ), ) @@ -408,6 +537,11 @@ class ShapeSelector: cardinality: Dict[str, int] = field(default_factory=dict) source_node_id: Optional[str] = None source_output_slot: Optional[int] = None + set_operation: Optional[str] = None + operands: Tuple["ShapeSelector", ...] = () + incident_face_selectors: Tuple["ShapeSelector", ...] = () + incident_faces_distinct: bool = False + incident_face_cardinality: Dict[str, int] = field(default_factory=dict) def where(self, predicate: SerializablePredicate) -> "ShapeSelector": if not isinstance(predicate, SerializablePredicate): @@ -432,6 +566,11 @@ class ShapeSelector: cardinality=dict(self.cardinality), source_node_id=self.source_node_id, source_output_slot=self.source_output_slot, + set_operation=self.set_operation, + operands=tuple(self.operands), + incident_face_selectors=tuple(self.incident_face_selectors), + incident_faces_distinct=self.incident_faces_distinct, + incident_face_cardinality=dict(self.incident_face_cardinality), ) def order_by(self, key: SerializableKey, desc: bool = False) -> "ShapeSelector": @@ -450,6 +589,11 @@ class ShapeSelector: cardinality=dict(self.cardinality), source_node_id=self.source_node_id, source_output_slot=self.source_output_slot, + set_operation=self.set_operation, + operands=tuple(self.operands), + incident_face_selectors=tuple(self.incident_face_selectors), + incident_faces_distinct=self.incident_faces_distinct, + incident_face_cardinality=dict(self.incident_face_cardinality), ) def from_source( @@ -471,6 +615,11 @@ class ShapeSelector: cardinality=dict(self.cardinality), source_node_id=node_id, source_output_slot=int(output_slot), + set_operation=self.set_operation, + operands=tuple(self.operands), + incident_face_selectors=tuple(self.incident_face_selectors), + incident_faces_distinct=self.incident_faces_distinct, + incident_face_cardinality=dict(self.incident_face_cardinality), ) def take(self, count: int) -> "ShapeSelector": @@ -488,6 +637,11 @@ class ShapeSelector: cardinality=dict(self.cardinality), source_node_id=self.source_node_id, source_output_slot=self.source_output_slot, + set_operation=self.set_operation, + operands=tuple(self.operands), + incident_face_selectors=tuple(self.incident_face_selectors), + incident_faces_distinct=self.incident_faces_distinct, + incident_face_cardinality=dict(self.incident_face_cardinality), ) def exactly(self, count: int) -> "ShapeSelector": @@ -507,6 +661,11 @@ class ShapeSelector: cardinality=card, source_node_id=self.source_node_id, source_output_slot=self.source_output_slot, + set_operation=self.set_operation, + operands=tuple(self.operands), + incident_face_selectors=tuple(self.incident_face_selectors), + incident_faces_distinct=self.incident_faces_distinct, + incident_face_cardinality=dict(self.incident_face_cardinality), ) def at_least(self, count: int) -> "ShapeSelector": @@ -526,6 +685,11 @@ class ShapeSelector: cardinality=card, source_node_id=self.source_node_id, source_output_slot=self.source_output_slot, + set_operation=self.set_operation, + operands=tuple(self.operands), + incident_face_selectors=tuple(self.incident_face_selectors), + incident_faces_distinct=self.incident_faces_distinct, + incident_face_cardinality=dict(self.incident_face_cardinality), ) def at_most(self, count: int) -> "ShapeSelector": @@ -545,6 +709,11 @@ class ShapeSelector: cardinality=card, source_node_id=self.source_node_id, source_output_slot=self.source_output_slot, + set_operation=self.set_operation, + operands=tuple(self.operands), + incident_face_selectors=tuple(self.incident_face_selectors), + incident_faces_distinct=self.incident_faces_distinct, + incident_face_cardinality=dict(self.incident_face_cardinality), ) def traverse(self, relation: str, to_kind: str) -> "ShapeSelector": @@ -565,8 +734,98 @@ class ShapeSelector: def boundary(self, to_kind: str) -> "ShapeSelector": return self.traverse("boundary", to_kind) + def intersection(self, other: "ShapeSelector") -> "ShapeSelector": + """Return entities present in both selector result sets.""" + + if not isinstance(other, ShapeSelector): + raise TypeError("intersection requires another ShapeSelector") + if self.target_kind != other.target_kind: + raise ValueError("intersection operands must select the same topology kind") + return ShapeSelector( + target_kind=self.target_kind, + set_operation="intersection", + operands=(self, other), + ) + + def shared_boundary( + self, other: "ShapeSelector", to_kind: str = "edge" + ) -> "ShapeSelector": + """Select current-topology boundary entities shared by both operands.""" + + return self.boundary(to_kind).intersection(other.boundary(to_kind)) + + def incident_to( + self, + *face_selectors: "ShapeSelector", + distinct: bool = False, + ) -> "ShapeSelector": + """Restrict Edges by their distinct incident Face witnesses.""" + + if self.target_kind != "edge": + raise ValueError("incident_to is only valid on an Edge selector") + if not face_selectors or not all( + isinstance(selector, ShapeSelector) and selector.target_kind == "face" + for selector in face_selectors + ): + raise TypeError("incident_to requires one or more Face selectors") + return ShapeSelector( + target_kind="edge", + source_selector=self.source_selector, + traversal=self.traversal, + predicate=self.predicate, + order_key=self.order_key, + order_keys=tuple(self.order_keys), + order_desc=self.order_desc, + limit_count=self.limit_count, + cardinality=dict(self.cardinality), + source_node_id=self.source_node_id, + source_output_slot=self.source_output_slot, + set_operation=self.set_operation, + operands=tuple(self.operands), + incident_face_selectors=tuple(face_selectors), + incident_faces_distinct=bool(distinct), + incident_face_cardinality=dict(self.incident_face_cardinality), + ) + + def incident_face_count(self, *, exactly: int) -> "ShapeSelector": + if self.target_kind != "edge": + raise ValueError("incident_face_count is only valid on an Edge selector") + if exactly < 0: + raise ValueError("incident face count must be >= 0") + return ShapeSelector( + target_kind="edge", + source_selector=self.source_selector, + traversal=self.traversal, + predicate=self.predicate, + order_key=self.order_key, + order_keys=tuple(self.order_keys), + order_desc=self.order_desc, + limit_count=self.limit_count, + cardinality=dict(self.cardinality), + source_node_id=self.source_node_id, + source_output_slot=self.source_output_slot, + set_operation=self.set_operation, + operands=tuple(self.operands), + incident_face_selectors=tuple(self.incident_face_selectors), + incident_faces_distinct=self.incident_faces_distinct, + incident_face_cardinality={"exactly": int(exactly)}, + ) + def resolve(self, scope: Any) -> List[Any]: - if self.source_selector is None: + if self.set_operation is not None: + if self.set_operation != "intersection" or len(self.operands) < 2: + raise ValueError(f"unsupported selector set operation: {self.set_operation}") + operand_items = [operand.resolve(scope) for operand in self.operands] + shared = [ + {_shape_identity(item) for item in items} + for items in operand_items[1:] + ] + items = [ + item + for item in operand_items[0] + if all(_shape_identity(item) in markers for markers in shared) + ] + elif self.source_selector is None: items = _resolve_scope_items(scope, self.target_kind) else: if self.traversal is None: @@ -579,6 +838,42 @@ class ShapeSelector: if self.predicate is not None: items = [item for item in items if self.predicate(item)] + if self.incident_face_selectors: + face_sets = [ + { + _shape_identity(face) + for face in selector.resolve(scope) + } + for selector in self.incident_face_selectors + ] + + def has_incident_witnesses(edge: Any) -> bool: + incident = getattr(edge, "get_incident_faces", lambda: [])() + incident_ids = [_shape_identity(face) for face in incident] + if self.incident_faces_distinct: + return _has_distinct_witnesses(incident_ids, face_sets) + return all( + any(face_id in face_set for face_id in incident_ids) + for face_set in face_sets + ) + + items = [item for item in items if has_incident_witnesses(item)] + + if self.incident_face_cardinality: + exact_incident = self.incident_face_cardinality.get("exactly") + if exact_incident is not None: + items = [ + item + for item in items + if len( + { + _shape_identity(face) + for face in getattr(item, "get_incident_faces", lambda: [])() + } + ) + == exact_incident + ] + order_specs = self.order_keys if not order_specs and self.order_key is not None: order_specs = ((self.order_key, self.order_desc),) @@ -633,10 +928,69 @@ class ShapeSelector: if self.source_node_id is not None: payload["source_node_id"] = self.source_node_id payload["source_output_slot"] = int(self.source_output_slot or 0) + if self.set_operation is not None: + payload["set_operation"] = { + "op": self.set_operation, + "operands": [operand.to_dict() for operand in self.operands], + } + if self.incident_face_selectors: + payload["incident_faces"] = { + "selectors": [selector.to_dict() for selector in self.incident_face_selectors], + "distinct": self.incident_faces_distinct, + } + if self.incident_face_cardinality: + payload["incident_face_cardinality"] = dict(self.incident_face_cardinality) return payload @classmethod def from_dict(cls, data: Dict[str, Any]) -> "ShapeSelector": + set_operation = None + operands: Tuple[ShapeSelector, ...] = () + incident_face_selectors: Tuple[ShapeSelector, ...] = () + incident_faces_distinct = False + incident_face_cardinality: Dict[str, int] = {} + raw_set_operation = data.get("set_operation") + if raw_set_operation is not None: + if not isinstance(raw_set_operation, dict): + raise ValueError("selector set_operation must be an object") + if set(raw_set_operation) != {"op", "operands"}: + raise ValueError("selector set_operation has invalid fields") + set_operation = str(raw_set_operation["op"]).strip().lower() + if set_operation != "intersection": + raise ValueError(f"unsupported selector set operation: {set_operation}") + raw_operands = raw_set_operation["operands"] + if not isinstance(raw_operands, list) or len(raw_operands) < 2: + raise ValueError("selector intersection requires at least two operands") + operands = tuple(ShapeSelector.from_dict(item) for item in raw_operands) + target_kind = str(data["target_kind"]) + if any(operand.target_kind != target_kind for operand in operands): + raise ValueError("selector intersection operands must match target_kind") + raw_incident = data.get("incident_faces") + if raw_incident is not None: + if not isinstance(raw_incident, dict) or set(raw_incident) != { + "selectors", "distinct" + }: + raise ValueError("selector incident_faces has invalid fields") + if str(data.get("target_kind", "")).lower() != "edge": + raise ValueError("incident_faces is only valid for edge selectors") + raw_selectors = raw_incident["selectors"] + if not isinstance(raw_selectors, list) or not raw_selectors: + raise ValueError("incident_faces requires one or more selectors") + incident_face_selectors = tuple( + ShapeSelector.from_dict(item) for item in raw_selectors + ) + if any(selector.target_kind != "face" for selector in incident_face_selectors): + raise ValueError("incident_faces selectors must select faces") + incident_faces_distinct = bool(raw_incident["distinct"]) + raw_incident_cardinality = data.get("incident_face_cardinality") + if raw_incident_cardinality is not None: + if not isinstance(raw_incident_cardinality, dict) or set( + raw_incident_cardinality + ) != {"exactly"}: + raise ValueError("incident_face_cardinality only supports exactly") + incident_face_cardinality = { + "exactly": int(raw_incident_cardinality["exactly"]) + } source_selector = None if isinstance(data.get("source"), dict): source_selector = ShapeSelector.from_dict(data["source"]) @@ -683,10 +1037,40 @@ class ShapeSelector: if data.get("source_node_id") is not None else None ), + set_operation=set_operation, + operands=operands, + incident_face_selectors=incident_face_selectors, + incident_faces_distinct=incident_faces_distinct, + incident_face_cardinality=incident_face_cardinality, ) +def _has_distinct_witnesses( + incident_ids: Sequence[Any], candidate_sets: Sequence[set[Any]] +) -> bool: + """Return whether each relation has a distinct incident-face witness.""" + + used: set[Any] = set() + + def visit(index: int) -> bool: + if index == len(candidate_sets): + return True + for face_id in incident_ids: + if face_id in used or face_id not in candidate_sets[index]: + continue + used.add(face_id) + if visit(index + 1): + return True + used.remove(face_id) + return False + + return visit(0) + + def _shape_identity(obj: Any) -> Any: + entity = getattr(obj, "_entity", None) + if entity is not None: + return (obj.__class__.__name__, id(entity)) topo_id = getattr(obj, "topo_id", None) if topo_id is not None: return (obj.__class__.__name__, topo_id) @@ -801,6 +1185,8 @@ def _traverse_items( def _resolve_scope_items(scope: Any, target_kind: str) -> List[Any]: + if scope.__class__.__name__.lower() == target_kind: + return [scope] if target_kind == "edge": if hasattr(scope, "get_edges"): return list(scope.get_edges()) @@ -831,11 +1217,14 @@ def _resolve_scope_items(scope: Any, target_kind: str) -> List[Any]: raise TypeError(f"cannot resolve QL selector scope for target_kind={target_kind}") -def tag(pattern: str) -> SerializablePredicate: +def tag( + pattern: str, scope: str | TagScope = TagScope.EFFECTIVE +) -> SerializablePredicate: """Build a tag predicate for QL filtering. Args: pattern: Exact tag string or a trailing `*` prefix match. + scope: One of ``local``, ``inherited``, ``effective``, or ``lineage``. Returns: Serializable predicate that can be used in `Query.where(...)`. @@ -844,9 +1233,14 @@ def tag(pattern: str) -> SerializablePredicate: if not isinstance(pattern, str): raise TypeError("pattern must be a string") pattern = pattern.strip() + if not pattern: + raise ValueError("pattern must be non-empty") if "*" in pattern and not pattern.endswith("*"): raise ValueError("only trailing '*' wildcard is supported") - return SerializablePredicate("tag", {"pattern": pattern}) + resolved_scope = normalize_tag_scope(scope) + return SerializablePredicate( + "tag", {"pattern": pattern, "scope": resolved_scope.value} + ) def meta(path: str, op: str, value_: Any) -> SerializablePredicate: @@ -957,14 +1351,66 @@ def not_(predicate: Predicate) -> Predicate: return _predicate +def operation_event(op_name: str, event: str = "*") -> SerializablePredicate: + """Match typed operation and topology-event metadata under ``track``.""" + + if not isinstance(op_name, str) or not op_name.strip(): + raise ValueError("op_name must be a non-empty string") + if not isinstance(event, str) or not event.strip(): + raise ValueError("event must be a non-empty string") + return SerializablePredicate( + "operation_event", + { + "op": op_name.strip(), + "event": event.strip(), + }, + ) + + +def origin_role(role_name: str) -> SerializablePredicate: + """Match a typed source role under ``track`` metadata.""" + + if not isinstance(role_name, str) or not role_name.strip(): + raise ValueError("role_name must be a non-empty string") + return SerializablePredicate("origin_role", {"role": role_name.strip()}) + + +def output_role(role_name: str) -> SerializablePredicate: + """Match a kernel-proven operation output role under ``track`` metadata.""" + + if not isinstance(role_name, str) or not role_name.strip(): + raise ValueError("role_name must be a non-empty string") + return SerializablePredicate( + "output_role", {"role": role_name.strip().lower()} + ) + + +def source_binding(binding_id: str) -> SerializablePredicate: + """Match a projected local binding by its exact source binding identity.""" + + if not isinstance(binding_id, str) or not binding_id.strip(): + raise ValueError("binding_id must be a non-empty string") + return SerializablePredicate( + "source_binding", {"source_binding_id": binding_id.strip()} + ) + + +def source_topology(topo_id: str) -> SerializablePredicate: + """Match a projected local binding by its exact source topology identity.""" + + if not isinstance(topo_id, str) or not topo_id.strip(): + raise ValueError("topo_id must be a non-empty string") + return SerializablePredicate( + "source_topology", {"source_topo_id": topo_id.strip()} + ) + + def op(op_name: str, event: str = "*") -> SerializablePredicate: - if event == "*": - return tag(f"op.{op_name}.*") - return tag(f"op.{op_name}.{event}") + return operation_event(op_name, event) def origin(role_name: str) -> SerializablePredicate: - return tag(f"origin.{role_name}") + return origin_role(role_name) def role(role_name: str) -> SerializablePredicate: @@ -1019,5 +1465,9 @@ def vertices() -> ShapeSelector: return ShapeSelector(target_kind="vertex") +def solids() -> ShapeSelector: + return ShapeSelector(target_kind="solid") + + def selector_from_dict(data: Dict[str, Any]) -> ShapeSelector: return ShapeSelector.from_dict(data) diff --git a/SimpleCADAPI/src/simplecadapi/scene/__init__.py b/SimpleCADAPI/src/simplecadapi/scene/__init__.py new file mode 100644 index 0000000..4898c5f --- /dev/null +++ b/SimpleCADAPI/src/simplecadapi/scene/__init__.py @@ -0,0 +1,158 @@ +"""Validated Scene Schema 1.0 contract primitives. + +This package contains the validated Scene Schema boundary, deterministic +compiler/exporter, and the browser-facing evaluated mesh encoders. +""" + +from .archive import ( + ArchiveInfo, + canonical_zip_bytes, + preflight_archive_member_sizes, + preflight_unpacked_scene, + preflight_zip_bytes, +) +from .canonical import ( + canonical_json_bytes, + canonical_json_hash, + compute_scene_revision, + parse_canonical_json, + parse_strict_json, + with_scene_revision, +) +from .documents import ( + ConnectorBindingDocument, + EntityDocument, + NormalizedProductDocument, + PresentationDocument, + SceneDocument, +) +from .glb import ( + GlbInfo, + preflight_glb, + preflight_glb_counts, + profile_cross, + profile_f32, + profile_f32_bits, + profile_normalize, +) +from .resources import ( + BASE_LIMITS, + SceneResourceLimits, + canonical_archive_size, + preflight_aggregate_compression_ratio, + preflight_input_archive_size, + preflight_member_compression_ratio, + preflight_resource_count, +) +from .validation import ( + SCHEMA_FILES, + SceneContractError, + SceneValidationIssue, + SceneValidationReport, + assert_connector_binding, + assert_entity_asset, + assert_normalized_product, + assert_presentation, + assert_scene_manifest, + assert_scene_package, + load_contract_artifact, + validate_connector_binding, + validate_entity_asset, + validate_normalized_product, + validate_presentation, + validate_scene_manifest, + validate_scene_package, + json_resource_issues, + resource_count_issues, +) +from .compiler import ( + CompiledScenePackage, + SceneCompileOptions, + SceneRoot, + SceneSource, + compile_scene, + export_scene, +) +from .glb_writer import write_line_glb, write_triangle_glb +from .render_mesh import ( + CanonicalEdgeBlock, + CanonicalTriangleBlock, + RenderEdgeMesh, + RenderGroup, + RenderMesh, + build_edge_mesh, + build_render_mesh, + cad_direction_to_gltf, + cad_to_gltf, + solid_asset_bounds, +) + +__all__ = [ + "ArchiveInfo", + "BASE_LIMITS", + "ConnectorBindingDocument", + "CanonicalEdgeBlock", + "CanonicalTriangleBlock", + "CompiledScenePackage", + "EntityDocument", + "GlbInfo", + "NormalizedProductDocument", + "PresentationDocument", + "SCHEMA_FILES", + "SceneContractError", + "SceneDocument", + "SceneCompileOptions", + "SceneRoot", + "SceneSource", + "SceneResourceLimits", + "SceneValidationIssue", + "SceneValidationReport", + "assert_connector_binding", + "assert_entity_asset", + "assert_normalized_product", + "assert_presentation", + "assert_scene_manifest", + "assert_scene_package", + "canonical_json_bytes", + "canonical_json_hash", + "canonical_archive_size", + "canonical_zip_bytes", + "compute_scene_revision", + "load_contract_artifact", + "parse_canonical_json", + "parse_strict_json", + "preflight_glb", + "preflight_glb_counts", + "preflight_archive_member_sizes", + "preflight_input_archive_size", + "preflight_aggregate_compression_ratio", + "preflight_member_compression_ratio", + "preflight_resource_count", + "preflight_unpacked_scene", + "preflight_zip_bytes", + "profile_cross", + "profile_f32", + "profile_f32_bits", + "profile_normalize", + "validate_connector_binding", + "validate_entity_asset", + "validate_normalized_product", + "validate_presentation", + "validate_scene_manifest", + "validate_scene_package", + "RenderEdgeMesh", + "RenderGroup", + "RenderMesh", + "build_edge_mesh", + "build_render_mesh", + "cad_direction_to_gltf", + "cad_to_gltf", + "compile_scene", + "export_scene", + "json_resource_issues", + "resource_count_issues", + "with_scene_revision", + "solid_asset_bounds", + "write_line_glb", + "write_triangle_glb", +] diff --git a/SimpleCADAPI/src/simplecadapi/scene/archive.py b/SimpleCADAPI/src/simplecadapi/scene/archive.py new file mode 100644 index 0000000..87f8f4a --- /dev/null +++ b/SimpleCADAPI/src/simplecadapi/scene/archive.py @@ -0,0 +1,437 @@ +"""Canonical Scene ZIP encoding and hostile-input archive preflight.""" + +from __future__ import annotations + +import binascii +import json +import os +import re +import stat +import struct +import zlib +from dataclasses import dataclass +from pathlib import Path +from types import MappingProxyType +from typing import Mapping + +from .resources import ( + BASE_LIMITS, + SceneResourceLimits, + canonical_archive_size, + preflight_aggregate_compression_ratio, + preflight_input_archive_size, + preflight_member_compression_ratio, +) + + +_MEMBER_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._/-]{0,1023}$") +_LOCAL = struct.Struct(" str: + return json.dumps(value, ensure_ascii=True) + + +@dataclass(frozen=True) +class ArchiveInfo: + members: Mapping[str, bytes] + input_size: int | None + canonical_size: int + used_deflate: bool + + +@dataclass(frozen=True) +class _CentralEntry: + name: str + method: int + crc32: int + compressed_size: int + uncompressed_size: int + local_offset: int + + +def _validate_member_name(name: str, seen_casefold: set[str]) -> bytes: + try: + encoded = name.encode("ascii") + except UnicodeEncodeError as exc: + raise ValueError("archive member names must be ASCII") from exc + if not _MEMBER_RE.fullmatch(name): + raise ValueError(f"invalid archive member name: {_quote(name)}") + segments = name.split("/") + if any(segment in {"", ".", ".."} for segment in segments): + raise ValueError(f"invalid archive member path segment: {_quote(name)}") + folded = name.lower() + if folded in seen_casefold: + raise ValueError(f"duplicate or case-colliding archive member: {_quote(name)}") + seen_casefold.add(folded) + return encoded + + +def preflight_archive_member_sizes( + sizes: Mapping[str, int], *, limits: SceneResourceLimits = BASE_LIMITS +) -> int: + """Validate declared archive sizes before allocating or reading payloads.""" + + if not sizes or "scene.json" not in sizes: + raise ValueError("scene package must contain scene.json") + if len(sizes) > limits.zip_members: + raise ValueError("archive member count exceeds resource limit") + total = 0 + seen_casefold: set[str] = set() + for name, size in sizes.items(): + _validate_member_name(name, seen_casefold) + if isinstance(size, bool) or not isinstance(size, int) or size < 0: + raise ValueError(f"invalid member size for {_quote(name)}") + if size > limits.one_member_bytes: + raise ValueError(f"archive member exceeds resource limit: {_quote(name)}") + if name == "scene.json" and size > limits.scene_json_bytes: + raise ValueError("scene.json exceeds resource limit") + if name.startswith("entities/") and size > limits.entity_json_bytes: + raise ValueError(f"entity sidecar exceeds resource limit: {_quote(name)}") + if name == "model/model.json" and size > limits.model_json_bytes: + raise ValueError("embedded model.json exceeds resource limit") + if name == "presentation/presentation.json" and size > limits.presentation_json_bytes: + raise ValueError("presentation JSON exceeds resource limit") + total += size + if total > limits.total_uncompressed_bytes: + raise ValueError("total uncompressed bytes exceed resource limit") + canonical_size = canonical_archive_size(dict(sizes)) + if canonical_size > limits.canonical_archive_bytes: + raise ValueError("canonical stored archive exceeds resource limit") + if len(sizes) > 0xFFFF or canonical_size > 0xFFFFFFFF: + raise ValueError("archive would require ZIP64") + return canonical_size + + +def canonical_zip_bytes( + members: Mapping[str, bytes | bytearray | memoryview], + *, + limits: SceneResourceLimits = BASE_LIMITS, +) -> bytes: + """Encode exact canonical stored ZIP bytes for a validated member mapping.""" + + payloads = {name: bytes(value) for name, value in members.items()} + canonical_size = preflight_archive_member_sizes( + {name: len(value) for name, value in payloads.items()}, limits=limits + ) + local_parts: list[bytes] = [] + central_parts: list[bytes] = [] + local_offset = 0 + for name in sorted(payloads, key=lambda value: value.encode("ascii")): + name_bytes = name.encode("ascii") + payload = payloads[name] + crc = binascii.crc32(payload) & 0xFFFFFFFF + local_header = _LOCAL.pack( + 0x04034B50, + 20, + 0x0800, + 0, + 0, + 0x0021, + crc, + len(payload), + len(payload), + len(name_bytes), + 0, + ) + local_parts.extend((local_header, name_bytes, payload)) + central_parts.extend( + ( + _CENTRAL.pack( + 0x02014B50, + 0x0314, + 20, + 0x0800, + 0, + 0, + 0x0021, + crc, + len(payload), + len(payload), + len(name_bytes), + 0, + 0, + 0, + 0, + 0x81A40000, + local_offset, + ), + name_bytes, + ) + ) + local_offset += len(local_header) + len(name_bytes) + len(payload) + central = b"".join(central_parts) + eocd = _EOCD.pack( + 0x06054B50, + 0, + 0, + len(payloads), + len(payloads), + len(central), + local_offset, + 0, + ) + result = b"".join(local_parts) + central + eocd + if len(result) != canonical_size: + raise AssertionError("canonical ZIP size formula disagrees with encoder") + return result + + +def _decode_name(raw: bytes, seen_casefold: set[str]) -> str: + try: + name = raw.decode("ascii") + except UnicodeDecodeError as exc: + raise ValueError("archive member names must be ASCII") from exc + _validate_member_name(name, seen_casefold) + return name + + +def preflight_zip_bytes( + data: bytes | bytearray | memoryview, + *, + limits: SceneResourceLimits = BASE_LIMITS, +) -> ArchiveInfo: + """Validate and decode an allowlisted stored/deflate Scene ZIP archive.""" + + input_size = data.nbytes if isinstance(data, memoryview) else len(data) + preflight_input_archive_size(input_size, limits=limits) + raw = data if isinstance(data, bytes) else bytes(data) + if len(raw) < _EOCD.size: + raise ValueError("truncated ZIP archive") + eocd_offset = len(raw) - _EOCD.size + eocd = _EOCD.unpack_from(raw, eocd_offset) + if eocd[0] != 0x06054B50 or eocd[7] != 0: + raise ValueError("ZIP EOCD must be final and have no comment") + _, disk, central_disk, disk_count, total_count, central_size, central_offset, _ = eocd + if disk or central_disk or disk_count != total_count: + raise ValueError("multi-disk ZIP archives are forbidden") + if total_count > limits.zip_members: + raise ValueError("archive member count exceeds resource limit") + if central_offset + central_size != eocd_offset: + raise ValueError("ZIP central directory offset/size mismatch") + + entries: list[_CentralEntry] = [] + cursor = central_offset + seen_casefold: set[str] = set() + for _index in range(total_count): + if cursor + _CENTRAL.size > eocd_offset: + raise ValueError("truncated ZIP central directory") + fields = _CENTRAL.unpack_from(raw, cursor) + if fields[0] != 0x02014B50: + raise ValueError("invalid ZIP central header signature") + ( + _signature, + made_by, + needed, + flags, + method, + dos_time, + dos_date, + crc, + compressed_size, + uncompressed_size, + name_length, + extra_length, + comment_length, + disk_start, + internal_attributes, + external_attributes, + local_offset, + ) = fields + if made_by != 0x0314 or needed != 20: + raise ValueError("ZIP creator/version profile mismatch") + if flags != 0x0800 or method not in {0, 8}: + raise ValueError("unsupported ZIP flags or compression method") + if dos_time != 0 or dos_date != 0x0021: + raise ValueError("ZIP timestamp profile mismatch") + if extra_length or comment_length or disk_start or internal_attributes: + raise ValueError("ZIP extra/comment/disk/attributes are forbidden") + if external_attributes != 0x81A40000: + raise ValueError("ZIP member must be Unix regular mode 0100644") + name_start = cursor + _CENTRAL.size + name_end = name_start + name_length + record_end = name_end + extra_length + comment_length + if record_end > eocd_offset: + raise ValueError("truncated ZIP central member record") + name = _decode_name(raw[name_start:name_end], seen_casefold) + entries.append( + _CentralEntry( + name=name, + method=method, + crc32=crc, + compressed_size=compressed_size, + uncompressed_size=uncompressed_size, + local_offset=local_offset, + ) + ) + cursor = record_end + if cursor != eocd_offset: + raise ValueError("unexpected bytes in ZIP central directory") + + canonical_size = preflight_archive_member_sizes( + {entry.name: entry.uncompressed_size for entry in entries}, limits=limits + ) + occupied: list[tuple[int, int]] = [] + payload_ranges: list[tuple[_CentralEntry, int, int]] = [] + total_uncompressed = 0 + for entry in entries: + offset = entry.local_offset + if offset + _LOCAL.size > central_offset: + raise ValueError("truncated ZIP local header") + fields = _LOCAL.unpack_from(raw, offset) + if fields[0] != 0x04034B50: + raise ValueError("invalid ZIP local header signature") + ( + _signature, + needed, + flags, + method, + dos_time, + dos_date, + crc, + compressed_size, + uncompressed_size, + name_length, + extra_length, + ) = fields + if ( + needed != 20 + or flags != 0x0800 + or method != entry.method + or dos_time != 0 + or dos_date != 0x0021 + or crc != entry.crc32 + or compressed_size != entry.compressed_size + or uncompressed_size != entry.uncompressed_size + or extra_length != 0 + ): + raise ValueError("ZIP central/local header mismatch") + name_start = offset + _LOCAL.size + name_end = name_start + name_length + payload_end = name_end + compressed_size + if payload_end > central_offset: + raise ValueError("ZIP member payload crosses central directory") + try: + local_name = raw[name_start:name_end].decode("ascii") + except UnicodeDecodeError as exc: + raise ValueError("archive member names must be ASCII") from exc + if local_name != entry.name: + raise ValueError("ZIP central/local member name mismatch") + occupied.append((offset, payload_end)) + payload_ranges.append((entry, name_end, payload_end)) + total_uncompressed += uncompressed_size + occupied.sort() + if occupied: + if occupied[0][0] != 0: + raise ValueError("ZIP archive has a leading prefix") + for previous, current in zip(occupied, occupied[1:]): + if previous[1] != current[0]: + raise ValueError("ZIP local records overlap or contain gaps") + if occupied[-1][1] != central_offset: + raise ValueError("ZIP bytes exist between local records and central directory") + preflight_aggregate_compression_ratio( + total_uncompressed, len(raw), limits=limits + ) + + members: dict[str, bytes] = {} + for entry, payload_start, payload_end in payload_ranges: + compressed = raw[payload_start:payload_end] + try: + preflight_member_compression_ratio( + entry.uncompressed_size, entry.compressed_size, limits=limits + ) + except ValueError as exc: + raise ValueError(f"member compression ratio exceeds limit: {_quote(entry.name)}") + if entry.method == 0: + payload = compressed + else: + inflater = zlib.decompressobj(-zlib.MAX_WBITS) + payload = inflater.decompress(compressed, entry.uncompressed_size + 1) + if inflater.unused_data or inflater.unconsumed_tail: + raise ValueError(f"invalid deflate stream: {_quote(entry.name)}") + payload += inflater.flush(entry.uncompressed_size + 1 - len(payload)) + if not inflater.eof: + raise ValueError(f"invalid deflate stream: {_quote(entry.name)}") + if len(payload) != entry.uncompressed_size: + raise ValueError(f"ZIP member decoded size mismatch: {_quote(entry.name)}") + if binascii.crc32(payload) & 0xFFFFFFFF != entry.crc32: + raise ValueError(f"ZIP member CRC mismatch: {_quote(entry.name)}") + members[entry.name] = payload + return ArchiveInfo( + members=MappingProxyType(members), + input_size=len(raw), + canonical_size=canonical_size, + used_deflate=any(entry.method == 8 for entry in entries), + ) + + +def preflight_unpacked_scene( + path: str | os.PathLike[str], + *, + limits: SceneResourceLimits = BASE_LIMITS, +) -> ArchiveInfo: + """Preflight and immutably read an unpacked scene without following links.""" + + root = Path(path) + root_stat = root.stat(follow_symlinks=False) + if not stat.S_ISDIR(root_stat.st_mode): + raise ValueError("unpacked scene root must be a directory") + records: dict[str, tuple[Path, os.stat_result]] = {} + for current_root, dir_names, file_names in os.walk(root, followlinks=False): + current = Path(current_root) + for directory in dir_names: + directory_path = current / directory + mode = directory_path.stat(follow_symlinks=False).st_mode + if not stat.S_ISDIR(mode): + raise ValueError(f"unpacked scene contains a non-directory link: {directory_path}") + for filename in file_names: + file_path = current / filename + relative = file_path.relative_to(root).as_posix() + file_stat = file_path.stat(follow_symlinks=False) + if not stat.S_ISREG(file_stat.st_mode): + raise ValueError( + f"unpacked scene member is not a regular file: {_quote(relative)}" + ) + records[relative] = (file_path, file_stat) + canonical_size = preflight_archive_member_sizes( + {name: record.st_size for name, (_path, record) in records.items()}, limits=limits + ) + members: dict[str, bytes] = {} + nofollow = getattr(os, "O_NOFOLLOW", 0) + for name, (file_path, expected) in records.items(): + descriptor = os.open(file_path, os.O_RDONLY | nofollow) + try: + actual = os.fstat(descriptor) + if ( + actual.st_dev != expected.st_dev + or actual.st_ino != expected.st_ino + or actual.st_size != expected.st_size + or not stat.S_ISREG(actual.st_mode) + ): + raise ValueError( + f"unpacked scene member changed during preflight: {_quote(name)}" + ) + chunks: list[bytes] = [] + remaining = expected.st_size + while remaining: + chunk = os.read(descriptor, min(1024 * 1024, remaining)) + if not chunk: + raise ValueError( + f"unpacked scene member was truncated: {_quote(name)}" + ) + chunks.append(chunk) + remaining -= len(chunk) + payload = b"".join(chunks) + if os.read(descriptor, 1): + raise ValueError(f"unpacked scene member grew during read: {_quote(name)}") + members[name] = payload + finally: + os.close(descriptor) + return ArchiveInfo( + members=MappingProxyType(members), + input_size=None, + canonical_size=canonical_size, + used_deflate=False, + ) diff --git a/SimpleCADAPI/src/simplecadapi/scene/canonical.py b/SimpleCADAPI/src/simplecadapi/scene/canonical.py new file mode 100644 index 0000000..aedda01 --- /dev/null +++ b/SimpleCADAPI/src/simplecadapi/scene/canonical.py @@ -0,0 +1,120 @@ +"""Strict JSON parsing and RFC 8785 canonicalization for Scene 1.0.""" + +from __future__ import annotations + +import hashlib +import json +from copy import deepcopy +from typing import Any, Mapping + +import rfc8785 + + +MAX_SAFE_INTEGER = 9_007_199_254_740_991 + + +class DuplicateKeyError(ValueError): + """Raised before object construction when JSON contains a duplicate key.""" + + +def _reject_duplicate_pairs(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + result: dict[str, Any] = {} + for key, value in pairs: + if key in result: + raise DuplicateKeyError(f"duplicate JSON object member: {key!r}") + result[key] = value + return result + + +def _reject_nonfinite(token: str) -> None: + raise ValueError(f"non-finite JSON number is forbidden: {token}") + + +def _reject_unpaired_surrogates(value: Any) -> Any: + stack = [value] + while stack: + current = stack.pop() + if isinstance(current, str): + try: + current.encode("utf-8") + except UnicodeEncodeError as exc: + raise ValueError("string contains an unpaired surrogate") from exc + elif isinstance(current, dict): + stack.extend(current.keys()) + stack.extend(current.values()) + elif isinstance(current, list): + stack.extend(current) + return value + + +def parse_strict_json(data: bytes | bytearray | memoryview | str) -> Any: + """Parse one strict UTF-8 JSON value while rejecting duplicate keys. + + BOMs, invalid UTF-8, comments, non-finite numbers, and trailing tokens are + rejected. The returned value is not normalized. + """ + + if isinstance(data, str): + text = data + if text.startswith("\ufeff"): + raise ValueError("UTF-8 BOM is forbidden") + else: + raw = bytes(data) + if raw.startswith(b"\xef\xbb\xbf"): + raise ValueError("UTF-8 BOM is forbidden") + text = raw.decode("utf-8", errors="strict") + try: + return _reject_unpaired_surrogates( + json.loads( + text, + object_pairs_hook=_reject_duplicate_pairs, + parse_constant=_reject_nonfinite, + ) + ) + except RecursionError as exc: + raise ValueError("JSON nesting exceeds parser capacity") from exc + + +def canonical_json_bytes(value: Any) -> bytes: + """Return RFC 8785 JCS bytes, rejecting values outside the JCS domain.""" + + try: + return rfc8785.dumps(value) + except rfc8785.CanonicalizationError as exc: + raise ValueError(str(exc)) from exc + + +def parse_canonical_json(data: bytes | bytearray | memoryview | str) -> Any: + """Parse JSON and require the input bytes to already be exact JCS bytes.""" + + if isinstance(data, str): + raw = data.encode("utf-8") + else: + raw = bytes(data) + value = parse_strict_json(raw) + canonical = canonical_json_bytes(value) + if raw != canonical: + raise ValueError("JSON bytes are not RFC 8785 canonical") + return value + + +def canonical_json_hash(value: Any) -> str: + """Return a lowercase Scene content hash over canonical JSON bytes.""" + + return "sha256:" + hashlib.sha256(canonical_json_bytes(value)).hexdigest() + + +def compute_scene_revision(scene: Mapping[str, Any]) -> str: + """Compute the two-pass Scene revision with the top-level field omitted.""" + + draft = dict(scene) + draft.pop("revision", None) + return canonical_json_hash(draft) + + +def with_scene_revision(scene: Mapping[str, Any]) -> dict[str, Any]: + """Return a deep copy carrying its computed Scene revision.""" + + result = deepcopy(dict(scene)) + result["revision"] = compute_scene_revision(result) + return result diff --git a/SimpleCADAPI/src/simplecadapi/scene/compiler.py b/SimpleCADAPI/src/simplecadapi/scene/compiler.py new file mode 100644 index 0000000..76ccb01 --- /dev/null +++ b/SimpleCADAPI/src/simplecadapi/scene/compiler.py @@ -0,0 +1,1058 @@ +"""Small, strict Scene 1.0 compiler for manual and product values.""" + +from __future__ import annotations + +import hashlib +import importlib.metadata +import json +import os +import platform +import re +import sys +from dataclasses import dataclass, field +from pathlib import Path +from types import MappingProxyType +from typing import Any, Mapping, Sequence +from urllib.parse import quote + +from OCP.BRepAdaptor import BRepAdaptor_Curve, BRepAdaptor_Surface +from OCP.GeomAbs import ( + GeomAbs_BSplineCurve, + GeomAbs_BSplineSurface, + GeomAbs_Circle, + GeomAbs_Cone, + GeomAbs_Cylinder, + GeomAbs_Ellipse, + GeomAbs_Line, + GeomAbs_Plane, + GeomAbs_Sphere, + GeomAbs_Torus, +) +from OCP.TopAbs import ( + TopAbs_EXTERNAL, + TopAbs_FORWARD, + TopAbs_INTERNAL, + TopAbs_REVERSED, +) + +from ..core import Compound, Edge, Face, Solid, Vertex +from ..graph import ModelResult +from ..kernel.ocp_properties import center_of_mass +from ..product import ( + Assembly, + Connector, + Material, + Part, + Placement, + identity_placement, + resolve_connector_placement, +) +from ..serializer import _candidate_shapes_for_geo_selection, _geo_selector_score +from ..scene.archive import canonical_zip_bytes +from ..scene.canonical import canonical_json_bytes, canonical_json_hash, with_scene_revision +from ..scene.documents import EntityDocument, NormalizedProductDocument, SceneDocument +from ..scene.glb_writer import write_line_glb, write_triangle_glb +from ..scene.render_mesh import ASSET_TO_SCENE, RenderEdgeMesh, RenderMesh, build_edge_mesh, build_render_mesh, solid_asset_bounds +from ..scene.validation import validate_scene_package +from ..topology import OperationGraph, OperationNode + + +DEFAULT_LINEAR_TOLERANCE = 0.35 +DEFAULT_ANGULAR_TOLERANCE = 0.22 +_IDENTITY = { + "origin": [0.0, 0.0, 0.0], + "x_axis": [1.0, 0.0, 0.0], + "y_axis": [0.0, 1.0, 0.0], + "z_axis": [0.0, 0.0, 1.0], +} + + +@dataclass(frozen=True, slots=True) +class SceneRoot: + root_id: str + value: Solid | Compound | Part | Assembly + transform: Placement = field(default_factory=identity_placement) + source_element_id: str | None = None + + def __post_init__(self) -> None: + if not isinstance(self.root_id, str) or not self.root_id or not self.root_id[0].isalpha() or any( + char not in "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_.-" for char in self.root_id + ): + raise ValueError("root_id must be a Scene logical ID") + if not isinstance(self.value, (Solid, Compound, Part, Assembly)): + raise TypeError("SceneRoot.value must be Solid, Compound, Part, or Assembly") + if not isinstance(self.transform, Placement): + raise TypeError("SceneRoot.transform must be a Placement") + if self.source_element_id is not None and not str(self.source_element_id): + raise ValueError("source_element_id must be non-empty when provided") + + +@dataclass(frozen=True, slots=True) +class _EmbeddedSourceFile: + path: str + uri: str + content_hash: str + content: bytes = field(repr=False) + + +@dataclass(frozen=True, slots=True) +class SceneSource: + kind: str + source_id: str | None = None + graph_id: str | None = None + artifact_hash: str | None = None + format: str | None = None + artifact_bytes: bytes | None = None + _graph: OperationGraph | None = field(default=None, compare=False, repr=False) + _source_files: tuple["_EmbeddedSourceFile", ...] = field( + default=(), compare=False, repr=False + ) + + def __post_init__(self) -> None: + if self.kind not in {"manual", "model", "imported"}: + raise ValueError("SceneSource.kind must be manual, model, or imported") + if self.kind == "manual" and not self.source_id: + raise ValueError("manual SceneSource requires source_id") + if self.kind == "model" and (not self.graph_id or not self.artifact_hash): + raise ValueError("model SceneSource requires graph_id and artifact_hash") + if self.kind == "imported" and not self.format: + raise ValueError("imported SceneSource requires format") + if self._graph is not None and self._graph.graph_id != self.graph_id: + raise ValueError("SceneSource graph evidence must match graph_id") + + +@dataclass(frozen=True, slots=True) +class SceneCompileOptions: + linear_tolerance: float = DEFAULT_LINEAR_TOLERANCE + angular_tolerance: float = DEFAULT_ANGULAR_TOLERANCE + embed_source: bool = False + embed_presentation: bool = False + + def __post_init__(self) -> None: + if not 0.0 < float(self.linear_tolerance) <= 1_000_000.0: + raise ValueError("linear_tolerance must be in (0, 1000000]") + if not 0.0 < float(self.angular_tolerance) <= 3.141592653589793: + raise ValueError("angular_tolerance must be in (0, pi]") + if self.embed_presentation: + raise ValueError("presentation embedding is not implemented in the first compiler slice") + + +@dataclass(frozen=True, slots=True) +class CompiledScenePackage: + manifest: Mapping[str, Any] + blobs: Mapping[str, bytes] + + def __post_init__(self) -> None: + object.__setattr__(self, "manifest", MappingProxyType(_freeze_mapping(dict(self.manifest)))) + object.__setattr__(self, "blobs", MappingProxyType({str(key): bytes(value) for key, value in self.blobs.items()})) + + @property + def scene(self) -> Mapping[str, Any]: + return self.manifest + + +def compile_scene( + *, + scene_id: str, + roots: Sequence[SceneRoot], + source: SceneSource | ModelResult | None = None, + presentation: Any = None, + options: SceneCompileOptions | None = None, +) -> CompiledScenePackage: + if presentation is not None: + raise ValueError("presentation compilation is not implemented in the first compiler slice") + options = options or SceneCompileOptions() + root_values = tuple(roots) + if not root_values: + raise ValueError("compile_scene requires at least one root") + if len({root.root_id for root in root_values}) != len(root_values): + raise ValueError("SceneRoot.root_id values must be unique") + source_descriptor = _coerce_source( + source, + root_values, + embed_source=options.embed_source, + ) + if source_descriptor.kind == "imported": + raise ValueError("imported source compilation is not implemented in the first compiler slice") + + definitions: dict[str, dict[str, Any]] = {} + nodes: list[dict[str, Any]] = [] + products: dict[str, dict[str, Any]] = {} + renderables: dict[str, _Renderable] = {} + appearances: dict[str, dict[str, Any]] = {} + pending_connectors: list[_PendingConnector] = [] + for root in sorted(root_values, key=lambda item: item.root_id.encode("utf-8")): + _compile_root( + root, + source=source_descriptor, + options=options, + definitions=definitions, + nodes=nodes, + products=products, + renderables=renderables, + appearances=appearances, + pending_connectors=pending_connectors, + ) + if not renderables: + raise ValueError("scene contains no renderable Part or Shape definition") + + _reindex_node_orders(nodes) + geometry_assets: dict[str, dict[str, Any]] = {} + edge_assets: dict[str, dict[str, Any]] = {} + entity_assets: dict[str, dict[str, Any]] = {} + entity_asset_ids: dict[str, str] = {} + blobs: dict[str, bytes] = {} + for definition_id, renderable in sorted(renderables.items(), key=lambda item: item[0].encode("utf-8")): + triangle_glb = write_triangle_glb(renderable.render_mesh) + line_glb = write_line_glb(renderable.edge_mesh) + geometry_hash = _hash_bytes(triangle_glb) + edge_hash = _hash_bytes(line_glb) + definition_kind = definitions[definition_id]["kind"] + entity_payload = _entity_asset( + definition_id=definition_id, + definition_kind=definition_kind, + geometry_asset_id=geometry_hash, + edge_asset_id=edge_hash, + solid=renderable.solid, + render_mesh=renderable.render_mesh, + edge_mesh=renderable.edge_mesh, + source=source_descriptor, + definition_source=definitions[definition_id]["source"], + ocp_version=_ocp_version(), + ) + EntityDocument.from_value(entity_payload) + entity_bytes = canonical_json_bytes(entity_payload) + entity_hash = _hash_bytes(entity_bytes) + geometry_uri = f"geometry/sha256-{geometry_hash.removeprefix('sha256:')}.glb" + edge_uri = f"edges/sha256-{edge_hash.removeprefix('sha256:')}.glb" + entity_uri = f"entities/sha256-{entity_hash.removeprefix('sha256:')}.json" + blobs.update({geometry_uri: triangle_glb, edge_uri: line_glb, entity_uri: entity_bytes}) + bounds = _scene_bounds_from_glb(renderable.render_mesh.bounds) + edge_bounds = _scene_bounds_from_glb(renderable.edge_mesh.bounds) + geometry_assets.setdefault(geometry_hash, _asset(geometry_hash, geometry_uri, len(triangle_glb), bounds, {"linear_tolerance": options.linear_tolerance, "angular_tolerance": options.angular_tolerance})) + edge_assets.setdefault(edge_hash, _asset(edge_hash, edge_uri, len(line_glb), edge_bounds, {"linear_tolerance": options.linear_tolerance})) + entity_assets.setdefault(entity_hash, {"entity_asset_id": entity_hash, "uri": entity_uri, "media_type": "application/vnd.simplecad.entities+json", "byte_length": len(entity_bytes), "content_hash": entity_hash}) + entity_asset_ids[definition_id] = entity_hash + definition = definitions[definition_id] + definition.update({"geometry_asset_id": geometry_hash, "edge_asset_id": edge_hash, "entity_asset_id": entity_hash, "appearance_id": renderable.appearance_id}) + + if source_descriptor.artifact_bytes is not None: + blobs["model/model.json"] = bytes(source_descriptor.artifact_bytes) + for source_file in source_descriptor._source_files: + blobs[source_file.uri] = source_file.content + + for product in products.values(): + NormalizedProductDocument.from_value(product) + connectors = _finalize_connectors( + pending_connectors, + definitions=definitions, + entity_asset_ids=entity_asset_ids, + source=source_descriptor, + ) + + generator = _generator() + manifest = { + "schema_version": "1.0", + "extensions_used": [], + "extensions_required": [], + "extensions": {}, + "scene_id": scene_id, + "generator": generator, + "source": _scene_source_record(source_descriptor), + "coordinate_system": {"length_unit": "mm", "handedness": "right", "up_axis": "+Z"}, + "compile_options": {"linear_tolerance": options.linear_tolerance, "angular_tolerance": options.angular_tolerance, "embed_source": source_descriptor.artifact_bytes is not None, "embed_presentation": False}, + "definitions": sorted(definitions.values(), key=lambda item: item["definition_id"].encode("utf-8")), + "nodes": sorted(nodes, key=lambda item: item["node_id"].encode("utf-8")), + "geometry_assets": sorted(geometry_assets.values(), key=lambda item: item["asset_id"].encode("utf-8")), + "edge_assets": sorted(edge_assets.values(), key=lambda item: item["asset_id"].encode("utf-8")), + "appearances": sorted(appearances.values(), key=lambda item: item["appearance_id"].encode("utf-8")), + "entity_assets": sorted(entity_assets.values(), key=lambda item: item["entity_asset_id"].encode("utf-8")), + "connectors": sorted(connectors, key=lambda item: item["connector_snapshot_id"].encode("utf-8")), + "cameras": [], + "lights": [], + "annotations": [], + "diagnostics": [], + } + manifest = with_scene_revision(manifest) + SceneDocument.from_value(manifest) + report = validate_scene_package(manifest, blobs) + if not report.valid: + raise ValueError("compiled scene package failed validation: " + "; ".join(issue.message for issue in report.issues[:3])) + return CompiledScenePackage(manifest=manifest, blobs=blobs) + + +def export_scene(*, package: CompiledScenePackage, path: str | os.PathLike[str]) -> None: + if not isinstance(package, CompiledScenePackage): + raise TypeError("package must be a CompiledScenePackage") + manifest = _thaw_mapping(package.manifest) + blobs = dict(package.blobs) + report = validate_scene_package(manifest, blobs) + if not report.valid: + raise ValueError("cannot export invalid scene package") + members = {"scene.json": canonical_json_bytes(manifest), **blobs} + Path(path).write_bytes(canonical_zip_bytes(members)) + + +@dataclass(frozen=True, slots=True) +class _Renderable: + solid: Solid + render_mesh: RenderMesh + edge_mesh: RenderEdgeMesh + appearance_id: str + + +@dataclass(frozen=True, slots=True) +class _PendingConnector: + root_id: str + owner_kind: str + owner_definition_id: str + owner: Part | Assembly + connector: Connector + + +def _compile_root(root: SceneRoot, *, source: SceneSource, options: SceneCompileOptions, definitions: dict[str, dict[str, Any]], nodes: list[dict[str, Any]], products: dict[str, dict[str, Any]], renderables: dict[str, _Renderable], appearances: dict[str, dict[str, Any]], pending_connectors: list[_PendingConnector]) -> None: + if isinstance(root.value, (Solid, Compound)): + definition_source = _shape_definition_source(root.root_id, root.value, source) + definition_id = _definition_id_for_shape(root.root_id, definition_source) + solid = root.value if isinstance(root.value, Solid) else _compound_solid(root.value) + renderable = _make_renderable(definition_id, solid, options, appearances) + renderables[definition_id] = renderable + definitions[definition_id] = {"definition_id": definition_id, "kind": "shape", "name": None, "source": definition_source, "sdk_metadata": {}} + nodes.append(_node(f"instance/{root.root_id}", None, 0, definition_id, None, root.transform, {"kind": "shape_root", "root_id": root.root_id})) + return + if isinstance(root.value, Part): + _compile_part(root.root_id, root.value, root.transform, source, options, definitions, nodes, products, renderables, appearances, pending_connectors, parent_node_id=None, component_path=[]) + return + _compile_assembly(root.root_id, root.value, root.transform, source, options, definitions, nodes, products, renderables, appearances, pending_connectors, parent_node_id=None, component_path=[]) + + +def _compile_part(root_id: str, part: Part, transform: Placement, source: SceneSource, options: SceneCompileOptions, definitions: dict[str, dict[str, Any]], nodes: list[dict[str, Any]], products: dict[str, dict[str, Any]], renderables: dict[str, _Renderable], appearances: dict[str, dict[str, Any]], pending_connectors: list[_PendingConnector], *, parent_node_id: str | None, component_path: list[str]) -> str: + definition_id = f"definition/{root_id}/part/{_encode(part.part_id)}" + normalized = _normalized_part(part) + if definition_id not in definitions: + appearance_id = _appearance(part.material, root_id, appearances) + definitions[definition_id] = {"definition_id": definition_id, "kind": "part", "name": part.name, "source": _product_definition_source(root_id, part, source), "sdk_metadata": _metadata(part)} + renderables[definition_id] = _make_renderable(definition_id, part.body, options, appearances, appearance_id=appearance_id) + products[definition_id] = normalized + pending_connectors.extend(_pending_connectors(root_id, "part", part, definition_id)) + elif canonical_json_bytes(products[definition_id]) != canonical_json_bytes(normalized): + raise ValueError(f"conflicting Part definitions for {definition_id}") + else: + existing = renderables[definition_id] + candidate = _make_renderable( + definition_id, + part.body, + options, + appearances, + appearance_id=existing.appearance_id, + ) + if candidate.render_mesh != existing.render_mesh or candidate.edge_mesh != existing.edge_mesh: + raise ValueError(f"conflicting Part body geometry for {definition_id}") + node_id = f"instance/{root_id}" + "".join(f"/{_encode(item)}" for item in component_path) + nodes.append(_node(node_id, parent_node_id, len([n for n in nodes if n.get("parent_node_id") == parent_node_id]), definition_id, part.name, transform, {"kind": "product_occurrence", "root_id": root_id, "component_path": component_path})) + return definition_id + + +def _compile_assembly(root_id: str, assembly: Assembly, transform: Placement, source: SceneSource, options: SceneCompileOptions, definitions: dict[str, dict[str, Any]], nodes: list[dict[str, Any]], products: dict[str, dict[str, Any]], renderables: dict[str, _Renderable], appearances: dict[str, dict[str, Any]], pending_connectors: list[_PendingConnector], *, parent_node_id: str | None, component_path: list[str]) -> str: + definition_id = f"definition/{root_id}/assembly/{_encode(assembly.assembly_id)}" + normalized = _normalized_assembly(assembly, root_id) + if definition_id not in definitions: + definitions[definition_id] = {"definition_id": definition_id, "kind": "assembly", "name": assembly.name, "source": _product_definition_source(root_id, assembly, source), "sdk_metadata": _metadata(assembly)} + products[definition_id] = normalized + pending_connectors.extend(_pending_connectors(root_id, "assembly", assembly, definition_id)) + elif canonical_json_bytes(products[definition_id]) != canonical_json_bytes(normalized): + raise ValueError(f"conflicting Assembly definitions for {definition_id}") + node_id = f"instance/{root_id}" + "".join(f"/{_encode(item)}" for item in component_path) + nodes.append(_node(node_id, parent_node_id, len([n for n in nodes if n.get("parent_node_id") == parent_node_id]), definition_id, assembly.name, transform, {"kind": "product_occurrence", "root_id": root_id, "component_path": component_path})) + for component in assembly.components: + child_path = [*component_path, component.component_id] + if isinstance(component.item, Part): + _compile_part(root_id, component.item, component.placement, source, options, definitions, nodes, products, renderables, appearances, pending_connectors, parent_node_id=node_id, component_path=child_path) + else: + _compile_assembly(root_id, component.item, component.placement, source, options, definitions, nodes, products, renderables, appearances, pending_connectors, parent_node_id=node_id, component_path=child_path) + return definition_id + + +def _make_renderable(definition_id: str, solid: Solid, options: SceneCompileOptions, appearances: dict[str, dict[str, Any]], *, appearance_id: str | None = None) -> _Renderable: + face_ids = [f"entity/face/{index}" for index in range(len(solid.get_faces()))] + edge_ids = [f"entity/edge/{index}" for index in range(len(solid.get_edges()))] + render_mesh = build_render_mesh(solid, face_entity_ids=face_ids, linear_tolerance=options.linear_tolerance, angular_tolerance=options.angular_tolerance) + edge_mesh = build_edge_mesh( + solid, + edge_entity_ids=edge_ids, + linear_tolerance=options.linear_tolerance, + angular_tolerance=options.angular_tolerance, + ) + return _Renderable(solid, render_mesh, edge_mesh, appearance_id or _appearance(None, definition_id, appearances)) + + +def _entity_asset(*, definition_id: str, definition_kind: str, geometry_asset_id: str, edge_asset_id: str, solid: Solid, render_mesh: RenderMesh, edge_mesh: RenderEdgeMesh, source: SceneSource, definition_source: Mapping[str, Any], ocp_version: str) -> dict[str, Any]: + faces = solid.get_faces() + edges = solid.get_edges() + vertices = [] + seen_vertices: dict[str, Vertex] = {} + for edge in edges: + for vertex in edge.get_vertices(): + seen_vertices.setdefault(vertex.topo_id, vertex) + face_id = {face.topo_id: f"entity/face/{index}" for index, face in enumerate(faces)} + edge_id = {edge.topo_id: f"entity/edge/{index}" for index, edge in enumerate(edges)} + vertex_items = list(seen_vertices.values()) + vertex_id = {vertex.topo_id: f"entity/vertex/{index}" for index, vertex in enumerate(vertex_items)} + entities: list[dict[str, Any]] = [] + entity_source = _entity_source(source, definition_source) + solid_bounds = _bounds(solid_asset_bounds(solid)) + entities.append({"entity_id": "entity/solid/0", "kind": "solid", "parent_entity_ids": [], "child_entity_ids": sorted(face_id.values(), key=lambda item: item.encode("utf-8")), "source": entity_source, "geometry": {"type": "brep_solid"}, "properties": {"quality": "kernel_evaluated", "bounds": solid_bounds, "volume": solid.get_volume(), "surface_area": sum(face.get_area() for face in faces), "centroid": list(_vec(center_of_mass(solid.wrapped)))}, "sdk_connector_frame": None, "render_status": "rendered", "connector_binding_status": "not_applicable", "semantic_binding_ids": [], "evaluated_tags": [], "sdk_metadata": {}}) + for face in faces: + entity_id = face_id[face.topo_id] + normal = _vec(face.get_normal_at()) + center = _vec(face.get_center()) + geometry = _surface_geometry(face) + connector_frame = _frame(center, normal) + entities.append({"entity_id": entity_id, "kind": "face", "parent_entity_ids": ["entity/solid/0"], "child_entity_ids": sorted({edge_id[edge.topo_id] for edge in face.get_edges()}, key=lambda item: item.encode("utf-8")), "source": entity_source, "geometry": geometry, "properties": {"quality": "kernel_evaluated", "bounds": _bounds(_shape_bounds(face)), "area": face.get_area(), "centroid": list(center), "orientation": _orientation(face.wrapped.Orientation())}, "sdk_connector_frame": _frame(center, normal), "render_status": "rendered", "connector_binding_status": "owner_not_part" if definition_kind != "part" else "source_not_model", "semantic_binding_ids": [], "evaluated_tags": sorted(face._list_tags(), key=lambda item: item.encode("utf-8")), "sdk_metadata": _metadata(face)}) + entities[-1]["sdk_connector_frame"] = connector_frame + entities[-1]["connector_binding_status"] = _binding_status(source, definition_kind, connector_frame) + for edge in edges: + entity_id = edge_id[edge.topo_id] + child_vertices = sorted( + {vertex_id[vertex.topo_id] for vertex in edge.get_vertices() if vertex.topo_id in vertex_id}, + key=lambda item: item.encode("utf-8"), + ) + start, end = _edge_endpoints(edge) + non_degenerate = start is not None and end is not None and start != end + direction = _normalize(tuple(end[index] - start[index] for index in range(3))) if non_degenerate else (1.0, 0.0, 0.0) + frame = _frame(_vec(edge.get_center()), direction) if non_degenerate else None + entities.append({"entity_id": entity_id, "kind": "edge", "parent_entity_ids": sorted({face_id[face.topo_id] for face in edge.get_incident_faces() if face.topo_id in face_id}, key=lambda item: item.encode("utf-8")), "child_entity_ids": sorted(child_vertices, key=lambda item: item.encode("utf-8")), "source": entity_source, "geometry": _curve_geometry(edge), "properties": {"quality": "kernel_evaluated", "bounds": _bounds(_shape_bounds(edge)), "length": edge.get_length(), "centroid": list(_vec(edge.get_center()))}, "sdk_connector_frame": frame, "render_status": "degenerate" if entity_id in edge_mesh.degenerate_entity_ids else "rendered", "connector_binding_status": _binding_status(source, definition_kind, frame), "semantic_binding_ids": [], "evaluated_tags": sorted(edge._list_tags(), key=lambda item: item.encode("utf-8")), "sdk_metadata": _metadata(edge)}) + for vertex in vertex_items: + point = _vec(vertex.get_coordinates()) + vertex_frame = {**_IDENTITY, "origin": list(point)} + entities.append({"entity_id": vertex_id[vertex.topo_id], "kind": "vertex", "parent_entity_ids": sorted({edge_id[edge.topo_id] for edge in edges if any(child.topo_id == vertex.topo_id for child in edge.get_vertices())}, key=lambda item: item.encode("utf-8")), "child_entity_ids": [], "source": entity_source, "geometry": {"type": "point", "position": list(point)}, "properties": {"quality": "kernel_evaluated", "bounds": {"min": list(point), "max": list(point)}, "position": list(point)}, "sdk_connector_frame": vertex_frame, "render_status": "rendered", "connector_binding_status": _binding_status(source, definition_kind, vertex_frame), "semantic_binding_ids": [], "evaluated_tags": sorted(vertex._list_tags(), key=lambda item: item.encode("utf-8")), "sdk_metadata": _metadata(vertex)}) + for entity in entities: + entity["parent_entity_ids"] = sorted(entity["parent_entity_ids"], key=lambda item: item.encode("utf-8")) + entity["child_entity_ids"] = sorted(entity["child_entity_ids"], key=lambda item: item.encode("utf-8")) + return {"schema_version": "1.0", "definition_id": definition_id, "geometry_asset_id": geometry_asset_id, "edge_asset_id": edge_asset_id, "geometry_engine": {"name": "OpenCascade", "version": ocp_version, "profile": "ocp-evaluated-properties-1"}, "entities": sorted(entities, key=lambda item: item["entity_id"].encode("utf-8")), "face_groups": [{"group_id": index, "entity_id": group.entity_id, "mesh_index": 0, "primitive_index": 0, "first_index": group.first_index, "index_count": group.index_count} for index, group in enumerate(render_mesh.groups)], "edge_groups": [{"group_id": index, "entity_id": group.entity_id, "mesh_index": 0, "primitive_index": 0, "first_index": group.first_index, "index_count": group.index_count} for index, group in enumerate(edge_mesh.groups)]} + + +def _binding_status(source: SceneSource, definition_kind: str, frame: Mapping[str, Any] | None) -> str: + if definition_kind != "part": + return "owner_not_part" + if source.kind != "model": + return "source_not_model" + return "frame_undefined" if frame is None else "supported" + + +def _coerce_source( + source: SceneSource | ModelResult | None, + roots: Sequence[SceneRoot], + *, + embed_source: bool, +) -> SceneSource: + if isinstance(source, SceneSource): + if embed_source or source.artifact_bytes is None: + return source + return SceneSource( + kind=source.kind, + source_id=source.source_id, + graph_id=source.graph_id, + artifact_hash=source.artifact_hash, + format=source.format, + _graph=source._graph, + ) + if isinstance(source, ModelResult): + try: + model_payload = json.loads(source.model_json) + except (TypeError, ValueError) as exc: + raise ValueError("ModelResult.model_json is not valid JSON") from exc + live_graph_payload = json.loads(json.dumps(source.session.graph.to_dict())) + if model_payload.get("graph") != live_graph_payload: + raise ValueError( + "ModelResult session graph no longer matches its model JSON snapshot" + ) + return SceneSource( + kind="model", + graph_id=source.session.graph.graph_id, + artifact_hash=_model_artifact_hash(source.model_json), + artifact_bytes=( + source.model_json.encode("utf-8") if embed_source else None + ), + _graph=source.session.graph, + _source_files=( + _collect_source_files(source.session.graph) if embed_source else () + ), + ) + return SceneSource(kind="manual", source_id=roots[0].root_id) + + +def _scene_source_record(source: SceneSource) -> dict[str, Any]: + if source.kind == "manual": + return {"kind": "manual", "source_id": source.source_id} + if source.kind == "imported": + return {"kind": "imported", "format": source.format, "artifact_hash": source.artifact_hash} + record = {"kind": "model", "graph_id": source.graph_id, "model_schema_version": "2.0", "artifact_hash": source.artifact_hash} + if source.artifact_bytes is not None: + record.update({ + "embedded_artifact_uri": "model/model.json", + "embedded_artifact_byte_length": len(source.artifact_bytes), + "source_files": [ + { + "path": source_file.path, + "uri": source_file.uri, + "media_type": "text/x-python; charset=utf-8", + "byte_length": len(source_file.content), + "content_hash": source_file.content_hash, + } + for source_file in source._source_files + ], + }) + return record + + +def _model_artifact_hash(model_json: str) -> str: + return "sha256:" + hashlib.sha256(model_json.encode("utf-8")).hexdigest() + + +_SOURCE_PATH_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._/-]*\.py$") + + +def _collect_source_files(graph: OperationGraph) -> tuple[_EmbeddedSourceFile, ...]: + files: dict[str, _EmbeddedSourceFile] = {} + casefold_paths: dict[str, str] = {} + for node in graph.nodes: + source = node.source + if not isinstance(source, dict) or source.get("path_kind") != "project_relative": + continue + relative_path = source.get("path") + local_path = source.get("local_path") + if not isinstance(relative_path, str) or not isinstance(local_path, str): + raise ValueError("project-relative operation source lacks a local path") + if not _SOURCE_PATH_RE.fullmatch(relative_path): + raise ValueError(f"operation source path is not archive-safe: {relative_path!r}") + path = Path(local_path).resolve() + if not path.is_file() or path.suffix != ".py": + raise ValueError(f"operation source file is unavailable: {relative_path!r}") + project_root = _source_project_root(path) + if project_root is None or path.relative_to(project_root).as_posix() != relative_path: + raise ValueError(f"operation source path no longer matches its project: {relative_path!r}") + content = path.read_bytes() + try: + content.decode("utf-8") + except UnicodeDecodeError as exc: + raise ValueError(f"operation source is not UTF-8: {relative_path!r}") from exc + folded = relative_path.casefold() + previous_path = casefold_paths.get(folded) + if previous_path is not None and previous_path != relative_path: + raise ValueError("operation source paths collide case-insensitively") + casefold_paths[folded] = relative_path + content_hash = _hash_bytes(content) + record = _EmbeddedSourceFile( + path=relative_path, + uri=f"sources/{relative_path}", + content_hash=content_hash, + content=content, + ) + previous = files.get(relative_path) + if previous is not None and previous != record: + raise ValueError(f"operation source path has conflicting contents: {relative_path!r}") + files[relative_path] = record + return tuple(files[path] for path in sorted(files, key=lambda item: item.encode("utf-8"))) + + +def _source_project_root(path: Path) -> Path | None: + for parent in (path.parent, *path.parents): + if (parent / "pyproject.toml").is_file(): + return parent + return None + + +def _graph_output_ref(value: Any, *, source: SceneSource) -> tuple[str, int]: + getter = getattr(value, "_get_runtime", None) + node = getter("graph.node") if callable(getter) else None + if node is None or not getattr(node, "node_id", None): + raise ValueError("model scene root must retain graph node ownership") + graph = source._graph + if graph is None: + raise ValueError("model scene source requires ModelResult graph evidence") + graph_id = getattr(node, "graph_id", None) or source.graph_id + if graph_id != source.graph_id: + raise ValueError("model scene root belongs to a different graph") + if graph.get_node(node.node_id) is not node: + raise ValueError("model scene root is not owned by the source model graph") + output_slot = getter("graph.output_slot", 0) if callable(getter) else 0 + if ( + isinstance(output_slot, bool) + or not isinstance(output_slot, int) + or output_slot < 0 + or output_slot >= node.output_count + ): + raise ValueError("model scene root output slot is out of range") + return str(node.node_id), int(output_slot) + + +def _product_definition_source(root_id: str, value: Part | Assembly, source: SceneSource) -> dict[str, Any]: + semantic_type = "Part" if isinstance(value, Part) else "Assembly" + semantic_id = value.part_id if isinstance(value, Part) else value.assembly_id + if source.kind == "model": + node_id, output_slot = _graph_output_ref(value, source=source) + return {"kind": "product_model", "root_id": root_id, "semantic_type": semantic_type, "semantic_id": semantic_id, "graph_id": source.graph_id, "node_id": node_id, "output_slot": output_slot} + return {"kind": "product_manual", "root_id": root_id, "semantic_type": semantic_type, "semantic_id": semantic_id} + + +def _shape_definition_source(root_id: str, value: Solid | Compound, source: SceneSource) -> dict[str, Any]: + if source.kind == "model": + node_id, output_slot = _graph_output_ref(value, source=source) + return {"kind": "model_output", "root_id": root_id, "graph_id": source.graph_id, "node_id": node_id, "output_slot": output_slot} + return {"kind": "manual", "root_id": root_id, "source_id": source.source_id or root_id} + + +def _definition_id_for_shape(root_id: str, definition_source: Mapping[str, Any]) -> str: + if definition_source["kind"] == "model_output": + return f"definition/{root_id}/shape/model/{_encode(str(definition_source['graph_id']))}/{_encode(str(definition_source['node_id']))}/{definition_source['output_slot']}" + return _shape_definition_id(root_id, str(definition_source["source_id"])) + + +def _entity_source(source: SceneSource, definition_source: Mapping[str, Any]) -> dict[str, Any]: + if source.kind == "model": + return {"kind": "model_output", "graph_id": definition_source["graph_id"], "node_id": definition_source["node_id"], "output_slot": definition_source["output_slot"]} + return {"kind": "unbound"} + + +def _generator() -> dict[str, Any]: + version = importlib.metadata.version("simplecadapi") + ocp = _ocp_version() + identity = {"simplecadapi_version": version, "ocp_version": ocp, "platform": platform.platform()} + return {"name": "simplecadapi", "simplecadapi_version": version, "ocp_version": ocp, "ocp_bindings_version": ocp, "python_abi": f"cp{sys.version_info.major}{sys.version_info.minor}", "platform_tag": platform.system().lower() + "-" + platform.machine().lower(), "toolchain_hash": canonical_json_hash(identity), "profile": "scene-1.0-ocp-glb-2"} + + +def _ocp_version() -> str: + return importlib.metadata.version("cadquery-ocp") + + +def _appearance(material: Material | None, root_id: str, appearances: dict[str, dict[str, Any]]) -> str: + draft = {"alpha_mode": "opaque", "base_color": [*(material.color if material and material.color else (0.72, 0.75, 0.78)), 1], "double_sided": False, "edge_color": [0.08, 0.09, 0.1, 1], "metallic": 0, "name": material.name if material else None, "roughness": 0.55, "sdk_metadata": {}, "source": {"kind": "product_material", "root_id": root_id, "material_id": material.material_id} if material else None} + appearance_id = "appearance/evaluated/" + hashlib.sha256(canonical_json_bytes(draft)).hexdigest() + appearances.setdefault(appearance_id, {"appearance_id": appearance_id, **draft}) + return appearance_id + + +def _pending_connectors(root_id: str, owner_kind: str, owner: Part | Assembly, definition_id: str) -> list[_PendingConnector]: + return [ + _PendingConnector(root_id, owner_kind, definition_id, owner, connector) + for connector in owner.connectors + ] + + +def _finalize_connectors( + pending: Sequence[_PendingConnector], + *, + definitions: Mapping[str, Mapping[str, Any]], + entity_asset_ids: Mapping[str, str], + source: SceneSource, +) -> list[dict[str, Any]]: + pending_by_id: dict[str, _PendingConnector] = {} + for item in pending: + snapshot_id = _connector_snapshot_id(item) + if snapshot_id in pending_by_id: + raise ValueError(f"duplicate connector snapshot ID: {snapshot_id}") + pending_by_id[snapshot_id] = item + + snapshots: dict[str, dict[str, Any]] = {} + resolving: set[str] = set() + + def finalize(snapshot_id: str) -> dict[str, Any]: + if snapshot_id in snapshots: + return snapshots[snapshot_id] + if snapshot_id in resolving: + raise ValueError(f"cyclic forwarded connector source: {snapshot_id}") + item = pending_by_id.get(snapshot_id) + if item is None: + raise ValueError(f"forwarded connector source is not compiled: {snapshot_id}") + resolving.add(snapshot_id) + connector = item.connector + definition = definitions[item.owner_definition_id] + if item.owner_kind == "part" and connector.anchor_kind == "forwarded": + raise ValueError("forwarded connectors cannot be owned by Parts") + if item.owner_kind == "assembly" and connector.anchor_kind == "geometry": + raise ValueError("assembly geometry connectors are not supported") + if connector.anchor_kind == "geometry": + if not isinstance(item.owner, Part): + raise ValueError("geometry connectors require a Part owner") + target = _resolve_connector_target(item.owner.body, connector) + target_kind = type(target).__name__.lower() + if target_kind not in {"face", "edge", "vertex"}: + raise ValueError("geometry connector target must be a face, edge, or vertex") + entity_id = _entity_id_for_shape(item.owner.body, target) + entity_asset_id = entity_asset_ids.get(item.owner_definition_id) + if entity_asset_id is None: + raise ValueError("geometry connector owner has no entity asset") + payload = { + "connector_snapshot_id": snapshot_id, + "owner_definition_id": item.owner_definition_id, + "connector_id": connector.connector_id, + "name": connector.name, + "anchor_kind": "geometry", + "local_transform": resolve_connector_placement(connector).to_dict(), + "target": {"entity_asset_id": entity_asset_id, "entity_id": entity_id}, + "source": _connector_source(source, item.owner, connector), + "sdk_metadata": {}, + } + elif connector.anchor_kind == "placement": + payload = { + "connector_snapshot_id": snapshot_id, + "owner_definition_id": item.owner_definition_id, + "connector_id": connector.connector_id, + "name": connector.name, + "anchor_kind": "placement", + "local_transform": resolve_connector_placement(connector).to_dict(), + "source": _connector_source(source, item.owner, connector), + "sdk_metadata": {}, + } + else: + if not isinstance(item.owner, Assembly): + raise ValueError("forwarded connectors require an Assembly owner") + anchor = connector.anchor + assert anchor is not None + source_component_id = anchor.source_component_id + source_connector_id = anchor.source_connector_id + assert source_component_id is not None and source_connector_id is not None + component = item.owner.get_component(source_component_id) + source_item = component.item + source_kind = "assembly" if isinstance(source_item, Assembly) else "part" + source_semantic_id = source_item.assembly_id if isinstance(source_item, Assembly) else source_item.part_id + source_snapshot_id = ( + f"connector/{item.root_id}/{source_kind}/{_encode(source_semantic_id)}/" + f"{_encode(source_connector_id)}" + ) + finalize(source_snapshot_id) + payload = { + "connector_snapshot_id": snapshot_id, + "owner_definition_id": item.owner_definition_id, + "connector_id": connector.connector_id, + "name": connector.name, + "anchor_kind": "forwarded", + "local_transform": resolve_connector_placement(connector, owner_assembly=item.owner).to_dict(), + "forwarded_from": { + "source_component_id": source_component_id, + "source_definition_id": pending_by_id[source_snapshot_id].owner_definition_id, + "source_connector_id": source_connector_id, + "source_connector_snapshot_id": source_snapshot_id, + "offset": anchor.offset.to_dict() if anchor.offset is not None else None, + }, + "source": _connector_source(source, item.owner, connector), + "sdk_metadata": {}, + } + snapshots[snapshot_id] = payload + resolving.remove(snapshot_id) + return payload + + for snapshot_id in sorted(pending_by_id, key=lambda value: value.encode("utf-8")): + finalize(snapshot_id) + return sorted(snapshots.values(), key=lambda item: item["connector_snapshot_id"].encode("utf-8")) + + +def _connector_snapshot_id(item: _PendingConnector) -> str: + semantic_id = item.owner.assembly_id if isinstance(item.owner, Assembly) else item.owner.part_id + return ( + f"connector/{item.root_id}/{item.owner_kind}/" + f"{_encode(semantic_id)}/{_encode(item.connector.connector_id)}" + ) + + +def _connector_source( + source: SceneSource, + owner: Part | Assembly, + connector: Connector, +) -> dict[str, Any] | None: + if source.kind == "manual": + if not source.source_id: + raise ValueError("manual connector source requires a source_id") + return {"kind": "manual", "source_id": source.source_id} + if source.kind == "imported": + return None + graph = source._graph + if graph is None: + raise ValueError("model connector source requires ModelResult graph evidence") + owner_node_id, _output_slot = _graph_output_ref(owner, source=source) + producer = _connector_producer( + graph=graph, + owner_node_id=owner_node_id, + owner=owner, + connector=connector, + ) + return { + "kind": "model_operation", + "graph_id": source.graph_id, + "node_id": producer.node_id, + "output_slot": 0, + } + + +def _connector_producer( + *, + graph: OperationGraph, + owner_node_id: str, + owner: Part | Assembly, + connector: Connector, +) -> OperationNode: + if isinstance(owner, Part): + expected_op = "make_add_connector_rpart" + semantic_type = "Part" + semantic_id = owner.part_id + semantic_id_param = "part_id" + else: + expected_op = ( + "make_forward_connector_rassembly" + if connector.anchor_kind == "forwarded" + else "make_add_connector_rassembly" + ) + semantic_type = "Assembly" + semantic_id = owner.assembly_id + semantic_id_param = "assembly_id" + + matches: list[OperationNode] = [] + visited: set[str] = set() + node = graph.get_node(owner_node_id) + while node is not None and node.node_id not in visited: + visited.add(node.node_id) + delta = node.semantic_delta + connector_record = ( + delta.metadata.get("connector") if delta is not None else None + ) + modifies_owner = delta is not None and any( + ref.entity_type == semantic_type and ref.entity_id == semantic_id + for ref in delta.modified + ) + if ( + node.op == expected_op + and node.params.get(semantic_id_param) == semantic_id + and node.params.get("connector_id") == connector.connector_id + and modifies_owner + and connector_record == connector.to_dict() + ): + matches.append(node) + if not node.inputs: + break + node = graph.get_node(node.inputs[0].node_id) + + if len(matches) != 1: + raise ValueError( + "connector source is not uniquely proven by the owner product lineage: " + f"{semantic_type} {semantic_id!r} connector {connector.connector_id!r}" + ) + return matches[0] + + +def _resolve_connector_target(solid: Solid, connector: Connector) -> Face | Edge | Vertex: + geometry_ref = connector.geometry_ref + if geometry_ref is None: + raise ValueError("geometry connector is missing its GeometryRef") + candidates = _candidate_shapes_for_geo_selection(solid, geometry_ref.kind) + if not candidates: + raise ValueError(f"geometry connector found no {geometry_ref.kind} candidates") + ranked = sorted( + [ + ( + _geo_selector_score(candidate, geometry_ref.geo_selector), + candidate, + ) + for candidate in candidates + ], + key=lambda item: (item[0], item[1].topo_id.encode("utf-8")), + ) + if ranked[0][0] > 1e-4: + raise ValueError("geometry connector selector did not match a stable topology entity") + if len(ranked) > 1 and ranked[1][0] <= 1e-4: + raise ValueError("geometry connector selector is ambiguous") + return ranked[0][1] # type: ignore[return-value] + + +def _entity_id_for_shape(solid: Solid, shape: Face | Edge | Vertex) -> str: + kind = type(shape).__name__.lower() + if kind == "face": + values = solid.get_faces() + elif kind == "edge": + values = solid.get_edges() + else: + seen: dict[str, Vertex] = {} + for edge in solid.get_edges(): + for vertex in edge.get_vertices(): + seen.setdefault(vertex.topo_id, vertex) + values = list(seen.values()) + try: + ordinal = next(index for index, value in enumerate(values) if value.topo_id == shape.topo_id) + except StopIteration as exc: + raise ValueError(f"{kind} connector target is not part of the owner solid") from exc + return f"entity/{kind}/{ordinal}" + + +def _reindex_node_orders(nodes: list[dict[str, Any]]) -> None: + grouped: dict[str | None, list[dict[str, Any]]] = {} + for node in nodes: + grouped.setdefault(node.get("parent_node_id"), []).append(node) + for parent, siblings in grouped.items(): + if parent is None: + siblings.sort(key=lambda node: str(node["source"]["root_id"]).encode("utf-8")) + else: + siblings.sort(key=lambda node: tuple(str(part).encode("utf-8") for part in node["source"].get("component_path", []))) + for order, node in enumerate(siblings): + node["order"] = order + + +def _normalized_part(part: Part) -> dict[str, Any]: + material = part.material + material_value = None if material is None else {"material_id": material.material_id, "name": material.name, "density": material.density, "density_unit": material.density_unit, "color": list(material.color) if material.color is not None else None, "metadata": _metadata(material)} + return {"kind": "part", "part_id": part.part_id, "body_source": {"kind": "manual", "source_id": part.part_id}, "name": part.name, "material": material_value, "connectors": [connector.to_dict() for connector in part.connectors], "metadata": _metadata(part)} + + +def _normalized_assembly(assembly: Assembly, root_id: str) -> dict[str, Any]: + return {"kind": "assembly", "assembly_id": assembly.assembly_id, "name": assembly.name, "components": [{"component_id": component.component_id, "name": component.name, "definition_ref": f"definition/{root_id}/" + ("assembly/" if isinstance(component.item, Assembly) else "part/") + _encode(component.item.assembly_id if isinstance(component.item, Assembly) else component.item.part_id), "local_placement": component.placement.to_dict()} for component in assembly.components], "connectors": [connector.to_dict() for connector in assembly.connectors], "constraints": [constraint.to_dict() for constraint in assembly.constraints], "grounded_component_ids": sorted(assembly.grounded_component_ids, key=lambda item: item.encode("utf-8")), "metadata": _metadata(assembly)} + + +def _compound_solid(compound: Compound) -> Solid: + solids = compound.get_solids() + if len(solids) != 1: + raise ValueError("a standalone Compound must contain exactly one solid in the first compiler slice") + return solids[0] + + +def _shape_definition_id(root_id: str, source_id: str) -> str: + return f"definition/{root_id}/shape/manual/{_encode(source_id)}" + + +def _encode(value: str) -> str: + return quote(str(value), safe="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~") + + +def _node(node_id: str, parent_node_id: str | None, order: int, definition_id: str, name: str | None, transform: Placement, source: dict[str, Any]) -> dict[str, Any]: + return {"node_id": node_id, "parent_node_id": parent_node_id, "order": order, "definition_id": definition_id, "name": name, "transform": transform.to_dict(), "visible": True, "selectable": True, "appearance_override_id": None, "source": source, "sdk_metadata": {}} + + +def _hash_bytes(data: bytes) -> str: + return "sha256:" + hashlib.sha256(data).hexdigest() + + +def _asset(asset_id: str, uri: str, byte_length: int, bounds: dict[str, Any], tessellation: dict[str, Any]) -> dict[str, Any]: + return {"asset_id": asset_id, "uri": uri, "media_type": "model/gltf-binary", "byte_length": byte_length, "content_hash": asset_id, "scene_local_bounds": bounds, "asset_to_scene": ASSET_TO_SCENE, "tessellation": tessellation} + + +def _scene_bounds_from_glb(bounds: tuple[tuple[float, float, float], tuple[float, float, float]]) -> dict[str, list[float]]: + minimum, maximum = bounds + return {"min": [1000 * minimum[0], -1000 * maximum[2], 1000 * minimum[1]], "max": [1000 * maximum[0], -1000 * minimum[2], 1000 * maximum[1]]} + + +def _shape_bounds(shape: Any) -> tuple[tuple[float, float, float], tuple[float, float, float]]: + box = __import__("simplecadapi.kernel.ocp_properties", fromlist=["bounding_box"]).bounding_box(shape.wrapped) + return ((box.xmin, box.ymin, box.zmin), (box.xmax, box.ymax, box.zmax)) + + +def _bounds(value: tuple[tuple[float, float, float], tuple[float, float, float]]) -> dict[str, list[float]]: + return {"min": [float(item) for item in value[0]], "max": [float(item) for item in value[1]]} + + +def _vec(value: Any) -> tuple[float, float, float]: + if hasattr(value, "to_tuple"): + return tuple(float(item) for item in value.to_tuple()) + return tuple(float(item) for item in value) + + +def _normalize(value: tuple[float, float, float]) -> tuple[float, float, float]: + length = sum(item * item for item in value) ** 0.5 + if length == 0: + raise ValueError("cannot normalize zero vector") + return tuple(item / length for item in value) + + +def _frame(origin: tuple[float, float, float], z_axis: tuple[float, float, float]) -> dict[str, list[float]]: + z = _normalize(z_axis) + seeds = ((1.0, 0.0, 0.0), (0.0, 1.0, 0.0), (0.0, 0.0, 1.0)) + seed = min(seeds, key=lambda candidate: abs(sum(a * b for a, b in zip(z, candidate)))) + dot = sum(a * b for a, b in zip(z, seed)) + x = _normalize(tuple(seed[index] - z[index] * dot for index in range(3))) + y = _normalize((z[1] * x[2] - z[2] * x[1], z[2] * x[0] - z[0] * x[2], z[0] * x[1] - z[1] * x[0])) + return {"origin": list(origin), "x_axis": list(x), "y_axis": list(y), "z_axis": list(z)} + + +def _edge_endpoints(edge: Edge) -> tuple[tuple[float, float, float] | None, tuple[float, float, float] | None]: + try: + return _vec(edge.get_start_vertex().get_coordinates()), _vec(edge.get_end_vertex().get_coordinates()) + except Exception: + return None, None + + +def _orientation(value: Any) -> str: + return {TopAbs_FORWARD: "forward", TopAbs_REVERSED: "reversed", TopAbs_INTERNAL: "internal", TopAbs_EXTERNAL: "external"}.get(value, "forward") + + +def _surface_geometry(face: Face) -> dict[str, Any]: + adaptor = BRepAdaptor_Surface(face.wrapped, True) + kind = adaptor.GetType() + center = _vec(face.get_center()) + normal = _vec(face.get_normal_at()) + x_direction = _frame(center, normal)["x_axis"] + if kind == GeomAbs_Plane: + return {"type": "plane", "origin": list(center), "normal": list(normal), "x_direction": x_direction} + if kind == GeomAbs_Cylinder: + value = adaptor.Cylinder() + return {"type": "cylinder", "origin": [value.Location().X(), value.Location().Y(), value.Location().Z()], "axis": [value.Axis().Direction().X(), value.Axis().Direction().Y(), value.Axis().Direction().Z()], "x_direction": [value.XAxis().Direction().X(), value.XAxis().Direction().Y(), value.XAxis().Direction().Z()], "radius": value.Radius()} + if kind == GeomAbs_Sphere: + value = adaptor.Sphere() + return {"type": "sphere", "center": [value.Location().X(), value.Location().Y(), value.Location().Z()], "axis": [value.Position().Direction().X(), value.Position().Direction().Y(), value.Position().Direction().Z()], "x_direction": [value.XAxis().Direction().X(), value.XAxis().Direction().Y(), value.XAxis().Direction().Z()], "radius": value.Radius()} + if kind == GeomAbs_Torus: + value = adaptor.Torus() + return {"type": "torus", "center": [value.Location().X(), value.Location().Y(), value.Location().Z()], "axis": [value.Axis().Direction().X(), value.Axis().Direction().Y(), value.Axis().Direction().Z()], "x_direction": [value.XAxis().Direction().X(), value.XAxis().Direction().Y(), value.XAxis().Direction().Z()], "major_radius": max(value.MajorRadius(), value.MinorRadius()), "minor_radius": min(value.MajorRadius(), value.MinorRadius())} + return {"type": "other_surface", "engine_type": str(kind)} + + +def _curve_geometry(edge: Edge) -> dict[str, Any]: + adaptor = BRepAdaptor_Curve(edge.wrapped) + kind = adaptor.GetType() + start, _end = _edge_endpoints(edge) + start = start or _vec(edge.get_center()) + if kind == GeomAbs_Line: + value = adaptor.Line() + return {"type": "line", "origin": [value.Location().X(), value.Location().Y(), value.Location().Z()], "direction": [value.Direction().X(), value.Direction().Y(), value.Direction().Z()]} + if kind == GeomAbs_Circle: + value = adaptor.Circle() + return {"type": "circle", "center": [value.Location().X(), value.Location().Y(), value.Location().Z()], "normal": [value.Axis().Direction().X(), value.Axis().Direction().Y(), value.Axis().Direction().Z()], "x_direction": [value.XAxis().Direction().X(), value.XAxis().Direction().Y(), value.XAxis().Direction().Z()], "radius": value.Radius()} + if kind == GeomAbs_Ellipse: + value = adaptor.Ellipse() + return {"type": "ellipse", "center": [value.Location().X(), value.Location().Y(), value.Location().Z()], "normal": [value.Axis().Direction().X(), value.Axis().Direction().Y(), value.Axis().Direction().Z()], "x_direction": [value.XAxis().Direction().X(), value.XAxis().Direction().Y(), value.XAxis().Direction().Z()], "major_radius": value.MajorRadius(), "minor_radius": value.MinorRadius()} + if kind == GeomAbs_BSplineCurve: + value = adaptor.BSpline() + return {"type": "bspline_curve", "degree": value.Degree(), "rational": value.IsRational(), "periodic": value.IsPeriodic(), "poles_count": value.NbPoles(), "knots_count": value.NbKnots()} + return {"type": "other_curve", "engine_type": str(kind)} + + +def _metadata(value: Any) -> dict[str, Any]: + metadata = getattr(value, "_metadata", {}) + if not isinstance(metadata, dict): + return {} + excluded = {"graph", "topo_ref", "track", "source_sketch", "sketch_solve", "sketch_promotion"} + return {key: _json_value(item) for key, item in metadata.items() if key not in excluded and not key.startswith("_")} + + +def _json_value(value: Any) -> Any: + if value is None or isinstance(value, (bool, int, float, str)): + return value + if isinstance(value, dict): + return {str(key): _json_value(item) for key, item in value.items()} + if isinstance(value, (list, tuple)): + return [_json_value(item) for item in value] + raise ValueError(f"unsupported metadata value: {type(value).__name__}") + + +def _freeze_mapping(value: Any) -> Any: + if isinstance(value, dict): + return MappingProxyType({key: _freeze_mapping(item) for key, item in value.items()}) + if isinstance(value, list): + return tuple(_freeze_mapping(item) for item in value) + return value + + +def _thaw_mapping(value: Any) -> Any: + if isinstance(value, Mapping): + return {key: _thaw_mapping(item) for key, item in value.items()} + if isinstance(value, tuple): + return [_thaw_mapping(item) for item in value] + return value + + +__all__ = ["CompiledScenePackage", "SceneCompileOptions", "SceneRoot", "SceneSource", "compile_scene", "export_scene"] diff --git a/SimpleCADAPI/src/simplecadapi/scene/contracts/profiles/ocp-evaluated-properties-1.profile.json b/SimpleCADAPI/src/simplecadapi/scene/contracts/profiles/ocp-evaluated-properties-1.profile.json new file mode 100644 index 0000000..770f0af --- /dev/null +++ b/SimpleCADAPI/src/simplecadapi/scene/contracts/profiles/ocp-evaluated-properties-1.profile.json @@ -0,0 +1,248 @@ +{ + "profile_id": "ocp-evaluated-properties-1", + "profile_kind": "evaluated_properties", + "pseudocode_hash": "sha256:3d134fae3ea80d6d23772fccea761385e34e4b58dad5f2519375a6e52bf6d1ef", + "pseudocode_uri": "pseudocode/ocp-evaluated-properties-1.md", + "rules": { + "implementation_status": { + "phase": "phase_a_target_profile", + "scene_compiler": "phase_b_not_implemented", + "grounding_modules": ["simplecadapi.kernel.ocp_properties", "simplecadapi.operations", "simplecadapi.serializer", "simplecadapi.product"], + "required_ocp_binding": "cadquery-ocp>=7.9.3.1", + "target_deltas_from_current_helpers": ["serializer resolver accepts score ties; this target requires a unique passing score", "product edge frame falls back to +X when endpoints are missing; this target requires a null frame"] + }, + "shape_preparation": { + "location_read": "TopoDS_Shape.Location()", + "identity_location_constructor": "TopLoc_Location()", + "unlocated_shape_call": "shape.Located(TopLoc_Location(), False)", + "location_transform_call": "location.Transformation()", + "bake_constructor": "BRepBuilderAPI_Transform(unlocated_shape, location.Transformation(), True, False)", + "bake_build_call": "BRepBuilderAPI_Transform.Build()", + "bake_result_call": "BRepBuilderAPI_Transform.Shape()", + "edge_orientation_call": "edge.Oriented(TopAbs_FORWARD)", + "edge_orientation_order": "force_forward_before_location_bake", + "face_orientation": "retain_and_map_TopAbs_Orientation" + }, + "analytic_classification": { + "curve_adaptor_constructor": "BRepAdaptor_Curve(definition_local_forward_edge)", + "surface_adaptor_constructor": "BRepAdaptor_Surface(definition_local_face, True)", + "curve_type_call": "BRepAdaptor_Curve.GetType()", + "surface_type_call": "BRepAdaptor_Surface.GetType()", + "curve_type_map": { + "GeomAbs_Line": {"scene_type": "line", "extractor": "BRepAdaptor_Curve.Line()"}, + "GeomAbs_Circle": {"scene_type": "circle", "extractor": "BRepAdaptor_Curve.Circle()"}, + "GeomAbs_Ellipse": {"scene_type": "ellipse", "extractor": "BRepAdaptor_Curve.Ellipse()"}, + "GeomAbs_BSplineCurve": {"scene_type": "bspline_curve", "extractor": "BRepAdaptor_Curve.BSpline()"}, + "GeomAbs_Hyperbola": {"scene_type": "other_curve", "engine_type": "GeomAbs_Hyperbola"}, + "GeomAbs_Parabola": {"scene_type": "other_curve", "engine_type": "GeomAbs_Parabola"}, + "GeomAbs_BezierCurve": {"scene_type": "other_curve", "engine_type": "GeomAbs_BezierCurve"}, + "GeomAbs_OffsetCurve": {"scene_type": "other_curve", "engine_type": "GeomAbs_OffsetCurve"}, + "GeomAbs_OtherCurve": {"scene_type": "other_curve", "engine_type": "GeomAbs_OtherCurve"} + }, + "surface_type_map": { + "GeomAbs_Plane": {"scene_type": "plane", "extractor": "BRepAdaptor_Surface.Plane()"}, + "GeomAbs_Cylinder": {"scene_type": "cylinder", "extractor": "BRepAdaptor_Surface.Cylinder()"}, + "GeomAbs_Cone": {"scene_type": "cone", "extractor": "BRepAdaptor_Surface.Cone()"}, + "GeomAbs_Sphere": {"scene_type": "sphere", "extractor": "BRepAdaptor_Surface.Sphere()"}, + "GeomAbs_Torus": {"scene_type": "torus", "extractor": "BRepAdaptor_Surface.Torus()"}, + "GeomAbs_BSplineSurface": {"scene_type": "bspline_surface", "extractor": "BRepAdaptor_Surface.BSpline()"}, + "GeomAbs_BezierSurface": {"scene_type": "other_surface", "engine_type": "GeomAbs_BezierSurface"}, + "GeomAbs_SurfaceOfRevolution": {"scene_type": "other_surface", "engine_type": "GeomAbs_SurfaceOfRevolution"}, + "GeomAbs_SurfaceOfExtrusion": {"scene_type": "other_surface", "engine_type": "GeomAbs_SurfaceOfExtrusion"}, + "GeomAbs_OffsetSurface": {"scene_type": "other_surface", "engine_type": "GeomAbs_OffsetSurface"}, + "GeomAbs_OtherSurface": {"scene_type": "other_surface", "engine_type": "GeomAbs_OtherSurface"} + }, + "curve_value_calls": { + "line": ["gp_Lin.Location()", "gp_Lin.Direction()"], + "circle": ["gp_Circ.Location()", "gp_Circ.Axis().Direction()", "gp_Circ.XAxis().Direction()", "gp_Circ.Radius()"], + "ellipse": ["gp_Elips.Location()", "gp_Elips.Axis().Direction()", "gp_Elips.XAxis().Direction()", "gp_Elips.MajorRadius()", "gp_Elips.MinorRadius()"] + }, + "surface_value_calls": { + "plane": ["gp_Pln.Location()", "gp_Pln.Axis().Direction()", "gp_Pln.XAxis().Direction()"], + "cylinder": ["gp_Cylinder.Location()", "gp_Cylinder.Axis().Direction()", "gp_Cylinder.XAxis().Direction()", "gp_Cylinder.Radius()"], + "cone": ["gp_Cone.Location()", "gp_Cone.Axis().Direction()", "gp_Cone.XAxis().Direction()", "gp_Cone.RefRadius()", "gp_Cone.SemiAngle()"], + "sphere": ["gp_Sphere.Location()", "gp_Sphere.Position().Direction()", "gp_Sphere.XAxis().Direction()", "gp_Sphere.Radius()"], + "torus": ["gp_Torus.Location()", "gp_Torus.Axis().Direction()", "gp_Torus.XAxis().Direction()", "gp_Torus.MajorRadius()", "gp_Torus.MinorRadius()"] + }, + "angle_conversion": "semi_angle_degrees = math.degrees(gp_Cone.SemiAngle())", + "direction_policy": "normalize_binary64_reject_zero_or_non_finite", + "axis_convention": "normal_or_axis_is_local_positive_z_x_direction_is_local_positive_x_and_y_is_normalize(cross(z,x))", + "axis_orthogonality": "abs(dot(z,x)) <= 1e-12", + "radius_constraints": "circle_cylinder_sphere_radius_positive; ellipse_torus_major_radius_at_least_minor_radius_positive; cone_reference_radius_nonnegative", + "cone_angle_constraint": "0 < semi_angle_degrees < 90", + "face_orientation_map": {"TopAbs_FORWARD": "forward", "TopAbs_REVERSED": "reversed", "TopAbs_INTERNAL": "internal", "TopAbs_EXTERNAL": "external"} + }, + "bspline_validity": { + "curve_summary_calls": ["Geom_BSplineCurve.Degree()", "Geom_BSplineCurve.IsRational()", "Geom_BSplineCurve.IsPeriodic()", "Geom_BSplineCurve.NbPoles()", "Geom_BSplineCurve.NbKnots()"], + "curve_validation_calls": ["Geom_BSplineCurve.Knot(one_based_index)", "Geom_BSplineCurve.Multiplicity(one_based_index)"], + "surface_summary_calls": ["Geom_BSplineSurface.UDegree()", "Geom_BSplineSurface.VDegree()", "Geom_BSplineSurface.IsURational()", "Geom_BSplineSurface.IsVRational()", "Geom_BSplineSurface.IsUPeriodic()", "Geom_BSplineSurface.IsVPeriodic()", "Geom_BSplineSurface.NbUPoles()", "Geom_BSplineSurface.NbVPoles()", "Geom_BSplineSurface.NbUKnots()", "Geom_BSplineSurface.NbVKnots()"], + "surface_validation_calls": ["Geom_BSplineSurface.UKnot(one_based_index)", "Geom_BSplineSurface.UMultiplicity(one_based_index)", "Geom_BSplineSurface.VKnot(one_based_index)", "Geom_BSplineSurface.VMultiplicity(one_based_index)"], + "maximum_degree_call": "Geom_BSplineCurve.MaxDegree_s() and Geom_BSplineSurface.MaxDegree_s()", + "maximum_degree": 25, + "minimum_degree": 1, + "minimum_poles": 2, + "minimum_unique_knots": 2, + "knot_order": "strictly_increasing_finite", + "interior_multiplicity": "1_to_degree_inclusive", + "nonperiodic_endpoint_multiplicity": "1_to_degree_plus_one_inclusive", + "nonperiodic_count_equation": "sum(multiplicities) == poles_count + degree + 1", + "periodic_endpoint_rule": "first_multiplicity == last_multiplicity", + "periodic_count_equation": "sum(multiplicities) - first_multiplicity == poles_count", + "surface_direction_rule": "apply_curve_degree_pole_knot_rules_independently_to_u_and_v" + }, + "properties": { + "bounds_constructor": "Bnd_Box()", + "bounds_gap_call": "Bnd_Box.SetGap(0.0)", + "bounds_add_call": "BRepBndLib.AddOptimal_s(shape, box, False, False)", + "bounds_flags": {"useTriangulation": false, "useShapeTolerance": false}, + "bounds_read_call": "Bnd_Box.Get()", + "props_constructor": "GProp_GProps()", + "edge_call": "BRepGProp.LinearProperties_s(edge, props, False, False)", + "edge_flags": {"SkipShared": false, "UseTriangulation": false}, + "face_call": "BRepGProp.SurfaceProperties_s(face, props, False, False)", + "face_flags": {"SkipShared": false, "UseTriangulation": false}, + "solid_volume_call": "BRepGProp.VolumeProperties_s(solid, volume_props, False, False, False)", + "solid_volume_flags": {"OnlyClosed": false, "SkipShared": false, "UseTriangulation": false}, + "solid_surface_call": "BRepGProp.SurfaceProperties_s(solid, surface_props, False, False)", + "solid_surface_flags": {"SkipShared": false, "UseTriangulation": false}, + "mass_call": "GProp_GProps.Mass()", + "centroid_call": "GProp_GProps.CentreOfMass()", + "vertex_call": "BRep_Tool.Pnt_s(vertex)", + "quality": "kernel_evaluated", + "non_finite_policy": "reject", + "zero_mass_centroid_policy": "reject_entity" + }, + "metadata_projection": { + "input": "evaluated_entity_top_level_metadata_object", + "excluded_exact_keys": ["graph", "topo_ref", "track", "source_sketch", "sketch_solve", "sketch_promotion"], + "excluded_prefixes": ["_"], + "retained_examples": ["geo", "std.*"], + "accepted_scalars": ["null", "boolean", "string", "safe_integer", "finite_binary64"], + "safe_integer_range": [-9007199254740991, 9007199254740991], + "accepted_containers": ["list", "tuple_to_array", "string_keyed_object"], + "rejected": ["cycle", "bytes", "set", "non_string_key", "non_finite_number", "unknown_runtime_value"], + "coercion": "none_except_tuple_to_array", + "failure": "fail_compilation_with_metadata_path", + "object_key_order": "RFC8785_JCS" + }, + "geo_exact_selector": { + "constructor_symbol": "simplecadapi.operations._make_geo_selector", + "metadata_geo_normalizer_symbol": "simplecadapi.operations._jsonable_geo_value", + "resolver_score_symbol": "simplecadapi.serializer._geo_selector_score", + "mode": "geo_exact", + "selector_required_common_fields": ["mode", "kind", "metadata_geo"], + "selector_optional_common_fields": ["bbox", "geom_type"], + "selector_vertex_fields": ["coordinates"], + "selector_edge_fields": ["length", "center", "start", "end"], + "selector_face_fields": ["area", "center", "normal", "edge_count", "inner_wire_count"], + "selector_solid_fields": ["volume", "center"], + "bbox_api": "BRepBndLib.AddOptimal_s(shape, box, False, False) after Bnd_Box.SetGap(0.0)", + "edge_payload_calls": ["Edge.get_length()", "Edge.get_center()", "Edge.get_start_vertex().get_coordinates()", "Edge.get_end_vertex().get_coordinates()"], + "face_payload_calls": ["Face.get_area()", "Face.get_center()", "Face.get_normal_at(0.5, 0.5)", "Face.get_edges()", "Face.get_inner_wires()"], + "face_normal_calls": ["BRepAdaptor_Surface(face, True)", "BRepAdaptor_Surface.FirstUParameter()", "BRepAdaptor_Surface.LastUParameter()", "BRepAdaptor_Surface.FirstVParameter()", "BRepAdaptor_Surface.LastVParameter()", "BRepLProp_SLProps(adaptor, midpoint_u, midpoint_v, 1, 1e-7)", "BRepLProp_SLProps.IsNormalDefined()", "BRepLProp_SLProps.Normal()"], + "face_normal_orientation": "reverse_when_face.Orientation() == TopAbs_REVERSED", + "vertex_payload_call": "Vertex.get_coordinates()", + "solid_payload_calls": ["Solid.get_volume()", "Solid.get_center()"], + "source_shape_argument": "accepted_but_not_serialized_or_scored", + "metadata_geo_normalization": ["null_string_boolean_unchanged", "numpy_integer_to_int", "numpy_floating_to_float", "python_int_float_unchanged", "to_tuple_result_to_recursive_float_array", "xyz_attributes_to_float_vec3", "dict_keys_to_string_then_recursive_values", "list_tuple_to_recursive_array", "other_value_to_str"], + "metadata_geo_postcondition": "RFC8785_domain_or_fail_compilation", + "source_index_serialization": "forbidden", + "candidate_domain": "owner_part_body_all_candidates_of_exact_entity_kind", + "distance": "sqrt((ax-bx)^2 + (ay-by)^2 + (az-bz)^2)", + "bbox_score": "distance(actual_min, expected_min) + distance(actual_max, expected_max)", + "kind_mismatch_score": 1000000000000, + "absent_bbox_score": 0, + "invalid_or_failed_bbox_score": 1000000, + "missing_vector_distance": 1000000, + "geometry_type_mismatch_penalty": 1000000, + "base_score": "10 * bbox_score", + "vertex_formula": "base + 10*distance(coordinates, selector.coordinates)", + "edge_formula": "base + (10*abs(length-selector.length) if length key exists else 0) + 10*distance(center,selector.center) + (min(distance(start,selector.start)+distance(end,selector.end),distance(start,selector.end)+distance(end,selector.start)) if both selector endpoints and candidate endpoints are available else 0)", + "face_formula": "base + (abs(area-selector.area) if area key exists else 0) + 10*distance(center,selector.center) + 5*distance(normal,selector.normal) + (10*abs(edge_count-selector.edge_count) if edge_count key exists else 0) + (10*abs(inner_wire_count-selector.inner_wire_count) if inner_wire_count key exists else 0)", + "solid_formula": "base + (abs(volume-selector.volume) if volume key exists else 0)", + "metadata_geo_score_contribution": 0, + "candidate_index_score_contribution": 0, + "threshold": 0.0001, + "threshold_comparison": "score <= threshold", + "unique_match_rule": "exactly_one_candidate_at_or_below_threshold_and_every_other_candidate_above_threshold", + "zero_match_status": "selector_unstable", + "multiple_match_status": "selector_ambiguous", + "non_finite_score_policy": "reject" + }, + "connector_frame": { + "placement_symbol": "simplecadapi.product.Placement", + "normalization": "binary64_euclidean", + "face_origin": "selector.center", + "face_z_axis": "normalize(selector.normal)", + "edge_origin": "selector.center", + "edge_z_axis": "normalize(selector.end - selector.start)", + "edge_undefined": "missing_endpoint_or_coincident_endpoints", + "vertex_origin": "selector.coordinates", + "vertex_axes": {"x_axis": [1, 0, 0], "y_axis": [0, 1, 0], "z_axis": [0, 0, 1]}, + "solid_frame": null, + "axis_seeds": [[1, 0, 0], [0, 1, 0], [0, 0, 1]], + "axis_seed_score": "abs(dot(z_axis, seed))", + "axis_seed_selection": "strict_minimum_scan_in_list_order", + "axis_seed_tie_break": "first_seed_X_then_Y_then_Z", + "x_axis_formula": "normalize(seed - z_axis*dot(z_axis,seed))", + "y_axis_formula": "normalize(cross(z_axis,x_axis))", + "flip_storage": "store_unflipped", + "binding_reason_precedence": ["not_applicable", "owner_not_part", "source_not_model", "frame_undefined", "selector_ambiguous", "selector_unstable", "supported"] + }, + "topology": { + "accepted_root": "single_manifold_solid_or_compound_of_disjoint_manifold_solids", + "rejected_roots": ["standalone_vertex", "standalone_edge", "standalone_wire", "standalone_face", "standalone_shell", "compsolid", "shared_face_between_solids", "free_boundary", "nonmanifold_face_owner"], + "face_owner_count": 1, + "solid_parent_count": 0, + "solid_min_face_children": 1, + "face_min_edge_children": 1, + "edge_min_face_parents": 1, + "edge_vertex_children": [1, 2], + "vertex_min_edge_parents": 1, + "vertex_child_count": 0, + "allow_edge_many_faces": true, + "serialize_coedge_orientation": false, + "reciprocal_adjacency_required": true, + "adjacency_sort": "unsigned_utf8_entity_id", + "adjacency_duplicates": "forbidden" + }, + "canonical_labeling": { + "hash": "SHA-256", + "serialization": "RFC8785_JCS_UTF8", + "initial_record_fields": ["kind", "geometry", "properties", "source", "sdk_connector_frame", "render_status", "connector_binding_status", "semantic_binding_ids", "evaluated_tags", "sdk_metadata", "render_key"], + "initial_record_scope": "internal_canonicalization_record_not_serialized_entity_object", + "render_key_null_kinds": ["solid", "vertex", "degenerate_edge"], + "initial_partition": "group_by_sha256_of_initial_record", + "refinement_signature": "current_label_then_sorted_parent_label_multiset_then_sorted_child_label_multiset", + "initial_cell_order": "initial_label_bytes", + "refinement": "simultaneous_sha256_split_each_existing_cell_without_merging_until_no_cell_splits", + "refined_subcell_order": "next_label_bytes", + "individualized_cell": "first_non_singleton_cell_in_current_partition_order", + "individualized_label": "sha256(0x49_then_big_endian_uint64_depth_then_current_label_then_initial_record_bytes)", + "individualized_split_order": "candidate_singleton_then_remainder", + "candidate_order": "initial_record_bytes_then_refined_label_bytes", + "exact_candidate_ties": "arbitrary_order_but_evaluate_every_branch", + "search": "depth_first_individualize_then_refine_all_candidates", + "complete_graph_encoding": "JCS_ordered_initial_records_with_parent_and_child_canonical_indices", + "label_internal_encoding": "32_raw_SHA256_bytes", + "label_jcs_encoding": "lowercase_64_hex_without_prefix", + "search_initial_depth": 0, + "recursive_depth": "increment_by_one_after_each_individualization", + "complete_record_fields": ["child_indices", "initial_record", "parent_indices"], + "complete_adjacency_index_order": "ascending_integer", + "winner": "lexicographically_least_complete_graph_encoding_bytes", + "maximum_states": 1000000, + "budget_error": "entity_canonicalization_budget_exceeded", + "kind_order": ["solid", "face", "edge", "vertex"], + "id_assignment": "within_each_kind_filter_winning_permutation_then_assign_zero_based_ordinal", + "entity_id_template": "entity/{kind}/{ordinal}", + "entity_output_sort": "unsigned_utf8_entity_id", + "set_like_array_sort": "unsigned_utf8_bytes" + }, + "compatibility": { + "normative_change": "requires_new_profile_id", + "cache_effect": "mandatory_cache_miss" + } + }, + "schema_version": "1.0" +} diff --git a/SimpleCADAPI/src/simplecadapi/scene/contracts/profiles/scene-1.0-ocp-glb-2.profile.json b/SimpleCADAPI/src/simplecadapi/scene/contracts/profiles/scene-1.0-ocp-glb-2.profile.json new file mode 100644 index 0000000..9f6e732 --- /dev/null +++ b/SimpleCADAPI/src/simplecadapi/scene/contracts/profiles/scene-1.0-ocp-glb-2.profile.json @@ -0,0 +1,222 @@ +{ + "profile_id": "scene-1.0-ocp-glb-2", + "profile_kind": "render_asset", + "pseudocode_hash": "sha256:8d13fa787d3c705627e9da92d45ed46bd18afc6dbd6daaef6da5fe0e31d5d339", + "pseudocode_uri": "pseudocode/scene-1.0-ocp-glb-2.md", + "rules": { + "implementation_status": { + "phase": "phase_b_implemented", + "scene_compiler": "simplecadapi.scene.compiler", + "grounding_modules": ["simplecadapi.kernel.ocp_mesh", "simplecadapi.scene.glb", "simplecadapi.scene.render_mesh"], + "required_ocp_binding": "cadquery-ocp>=7.9.3.1", + "target_delta_from_current_helper": "scene compiler uses deterministic serial meshing and tangential edge deflection" + }, + "toolchain_descriptor": { + "serialization": "RFC8785_JCS_UTF8", + "descriptor_fields": ["build_identity", "native_libraries", "profile_hashes", "python_executable", "python_modules", "schema_version"], + "descriptor_field_shapes": {"build_identity": "closed_object", "native_libraries": "nonempty_record_array", "profile_hashes": "two_record_array", "python_executable": "single_record", "python_modules": "nonempty_record_array", "schema_version": "string_const_1.0"}, + "schema_version": "1.0", + "record_fields": ["content_hash", "logical_name"], + "record_arrays": ["native_libraries", "profile_hashes", "python_modules"], + "single_record_fields": ["python_executable"], + "content_hash": "sha256_lowercase_hex_over_exact_file_bytes", + "profile_logical_names": ["ocp-evaluated-properties-1.profile.json", "scene-1.0-ocp-glb-2.profile.json"], + "profile_hash_input": "exact_packaged_profile_file_bytes", + "python_module_roots": ["simplecadapi.kernel.ocp_mesh", "simplecadapi.kernel.ocp_properties", "simplecadapi.operations", "simplecadapi.product", "simplecadapi.scene.canonical", "simplecadapi.scene.compiler", "simplecadapi.scene.glb", "simplecadapi.scene.glb_writer", "simplecadapi.scene.render_mesh", "simplecadapi.serializer"], + "phase_b_target_module": "simplecadapi.scene.compiler", + "phase_b_target_module_status": "implemented", + "python_modules": "registered_complete_output_affecting_transitive_source_closure_of_roots", + "python_executable": "resolved_sys.executable_regular_file_bytes", + "native_libraries": "registered_complete_OCP_binding_and_transitive_native_kernel_library_closure", + "record_array_sort": "logical_name_unsigned_utf8", + "record_logical_name_uniqueness": "required_within_each_array", + "registration_input": "trusted_build_manifest_mapping_logical_names_to_resolved_files", + "record_file_policy": "resolve_symlinks_then_require_readable_regular_file_else_fail", + "closure_policy": "registration_must_enumerate_complete_output_affecting_closures_else_toolchain_is_not_registered", + "build_identity_fields": ["ocp_bindings_version", "ocp_version", "platform_tag", "python_abi", "simplecadapi_version"], + "build_identity_value_grammar": "ASCII_[A-Za-z0-9][A-Za-z0-9._+-]{0,127}", + "excluded_inputs": ["absolute_path", "host_name", "mtime"], + "toolchain_hash": "sha256_of_descriptor_jcs_bytes", + "reproducibility_claim": "requires_exact_registered_toolchain_hash_match", + "display_versions": "informational_not_hash_substitute" + }, + "coordinate_conversion": { + "cad_length_unit": "mm", + "gltf_length_unit": "m", + "cad_to_gltf": [0.001, 0, 0, 0, 0, 0, 0.001, 0, 0, -0.001, 0, 0, 0, 0, 0, 1], + "gltf_to_cad": [1000, 0, 0, 0, 0, 0, -1000, 0, 0, 1000, 0, 0, 0, 0, 0, 1], + "matrix_convention": "row_major_column_vector" + }, + "shape_preparation": { + "location_read": "TopoDS_Shape.Location()", + "identity_location_constructor": "TopLoc_Location()", + "unlocated_shape_call": "shape.Located(TopLoc_Location(), False)", + "location_transform_call": "location.Transformation()", + "bake_constructor": "BRepBuilderAPI_Transform(unlocated_shape, location.Transformation(), True, False)", + "bake_build_call": "BRepBuilderAPI_Transform.Build()", + "bake_result_call": "BRepBuilderAPI_Transform.Shape()", + "face_orientation": "retain_TopAbs_Orientation", + "edge_orientation_call": "edge.Oriented(TopAbs_FORWARD)", + "edge_orientation_order": "force_forward_before_location_bake" + }, + "meshing": { + "cache_clear_call": "BRepTools.Clean_s(definition_local_face, False)", + "constructor": "BRepMesh_IncrementalMesh(definition_local_face, float(compile_options.linear_tolerance), False, float(compile_options.angular_tolerance), False)", + "constructor_symbol": "OCP.BRepMesh.BRepMesh_IncrementalMesh", + "linear_deflection": "compile_options.linear_tolerance", + "is_relative": false, + "angular_deflection": "compile_options.angular_tolerance", + "is_in_parallel": false, + "perform_call": "BRepMesh_IncrementalMesh.Perform()", + "thread_count": 1, + "cached_triangulation_reuse": false + }, + "edge_discretization": { + "adaptor_constructor": "BRepAdaptor_Curve(definition_local_forward_edge)", + "adaptor_symbol": "OCP.BRepAdaptor.BRepAdaptor_Curve", + "algorithm_constructor": "GCPnts_TangentialDeflection(adaptor, adaptor.FirstParameter(), adaptor.LastParameter(), float(compile_options.angular_tolerance), float(compile_options.linear_tolerance), 2, 1e-9, 1e-7)", + "algorithm_symbol": "OCP.GCPnts.GCPnts_TangentialDeflection", + "angular_deflection": "compile_options.angular_tolerance", + "curvature_deflection": "compile_options.linear_tolerance", + "first_parameter_call": "BRepAdaptor_Curve.FirstParameter()", + "last_parameter_call": "BRepAdaptor_Curve.LastParameter()", + "minimum_of_points": 2, + "parameter_tolerance": 1e-9, + "minimum_curve_length": 1e-7, + "point_count_call": "GCPnts_TangentialDeflection.NbPoints()", + "point_call": "GCPnts_TangentialDeflection.Value(one_based_index)", + "failure_policy": "constructor_exception_fails_compilation", + "iteration": "one_based_inclusive" + }, + "triangulation": { + "location_constructor": "TopLoc_Location()", + "read_call": "BRep_Tool.Triangulation_s(definition_local_face, triangulation_location, 0)", + "mesh_purpose": 0, + "missing_policy": "fail_face", + "node_count_call": "Poly_Triangulation.NbNodes()", + "node_call": "Poly_Triangulation.Node(one_based_index)", + "node_location_call": "node.Transformed(triangulation_location.Transformation())", + "triangle_count_call": "Poly_Triangulation.NbTriangles()", + "triangle_call": "Poly_Triangulation.Triangle(one_based_index).Get()", + "index_base_conversion": "subtract_one", + "face_reversed_index_permutation": [0, 2, 1], + "face_other_index_permutation": [0, 1, 2] + }, + "normal": { + "availability_call": "Poly_Triangulation.HasNormals()", + "node_normal_call": "Poly_Triangulation.Normal(one_based_node_index)", + "normal_location_call": "normal.Transformed(triangulation_location.Transformation())", + "reversed_face_call": "gp_Dir.Reversed()", + "reversed_face_condition": "face.Orientation() == TopAbs_REVERSED", + "fallback": "oriented_triangle_cross_product_after_coordinate_conversion", + "smoothing": "none_across_triangles_or_cad_faces", + "minimum_norm": 0.999999, + "maximum_norm": 1.000001 + }, + "numeric": { + "binary32_rounding": "round_to_nearest_ties_to_even", + "cross_precision": "binary64_fixed_order_no_fma", + "norm_precision": "binary64_xyz_fixed_order_correctly_rounded_sqrt", + "negative_zero": "canonicalize_to_positive_zero", + "non_finite": "reject", + "collapsed_test": "binary32_positions_promoted_to_binary64_then_fixed_cross" + }, + "canonical_blocks": { + "triangle_kind_byte": 1, + "line_kind_byte": 2, + "vertex_key": "little_endian_position_f32_bits_then_normal_f32_bits", + "position_key": "little_endian_position_f32_bits", + "triangle_rotation": "lexicographically_least_cyclic_without_reversal", + "triangle_sort": "lexicographic_local_index_triple", + "segment_order": "min_local_index_then_max_local_index", + "segment_sort": "lexicographic_deduplicated", + "block_index_encoding": "little_endian_uint16_if_local_vertex_count_at_most_65536_else_uint32", + "triangle_block": "sorted_vertex_keys_then_conditionally_sized_local_triangle_indices", + "line_block": "sorted_position_keys_then_conditionally_sized_local_segment_indices", + "render_key": "sha256(kind_byte_then_block)", + "asset_block_sort": "render_key_bytes_then_block_bytes", + "equal_block_slot_order": "canonical_entity_id_unsigned_utf8", + "equal_block_asset_bytes": "identical_blocks_are_repeated_once_per_rendered_entity_and_equal_block_permutations_are_byte_identical", + "face_empty_policy": "fail_compilation", + "edge_empty_policy": "retain_degenerate_entity_without_render_block" + }, + "entity_ranges": { + "group_fields": ["entity_id", "first_index", "group_id", "index_count", "mesh_index", "primitive_index"], + "mesh_index": 0, + "primitive_index": 0, + "face_coverage": "one_positive_group_per_face_exactly_partitions_all_triangle_indices", + "face_index_count_multiple": 3, + "edge_coverage": "one_positive_group_per_rendered_edge_exactly_partitions_all_line_indices_and_zero_groups_per_degenerate_edge", + "edge_index_count_multiple": 2, + "range_unit": "primitive_index_accessor_element_ordinal", + "group_id_assignment": "zero_based_contiguous_in_array_order", + "array_order": "first_index_ascending", + "overlap": "forbidden" + }, + "default_appearance": { + "name": null, + "source": null, + "base_color": [0.72, 0.75, 0.78, 1], + "metallic": 0, + "roughness": 0.55, + "alpha_mode": "opaque", + "double_sided": false, + "edge_color": [0.08, 0.09, 0.1, 1], + "sdk_metadata": {} + }, + "normalized_product_equivalence": { + "schema_uri": "schemas/normalized-product-1.schema.json", + "serialization": "RFC8785_JCS_UTF8", + "part_fields": ["body_source", "connectors", "kind", "material", "metadata", "name", "part_id"], + "assembly_fields": ["assembly_id", "components", "connectors", "constraints", "grounded_component_ids", "kind", "metadata", "name"], + "component_fields": ["component_id", "definition_ref", "local_placement", "name"], + "declaration_order_arrays": ["assembly.components", "assembly.connectors", "assembly.constraints", "part.connectors"], + "set_like_arrays": {"assembly.grounded_component_ids": "unsigned_utf8"}, + "material": "complete_normalized_JSON_safe_record", + "metadata": "evaluated_profile_metadata_projection", + "connector_runtime_metadata": "forbidden", + "comparison": "exact_normalized_product_jcs_bytes", + "difference_result": "typed_semantic_id_collision", + "forbidden_equivalence": ["mesh_bytes", "subset_or_at_least_same_heuristic"] + }, + "compatibility": { + "normative_change": "requires_new_profile_id", + "cache_effect": "mandatory_cache_miss" + }, + "required_characterization_cases": ["bounded_canonicalization_failure", "closed_edge", "degenerate_edge", "duplicate_geometry_with_different_metadata", "duplicate_geometry_with_different_provenance", "negative_zero", "normal_fallback", "reversed_kernel_traversal", "shared_face_rejection", "symmetric_entity"], + "glb_writer": { + "writer_id": "simplecadapi.scene.glb.scene-1.0-ocp-glb-1", + "writer_implementation": "profile_pseudocode_exact_byte_writer", + "media_type": "model/gltf-binary", + "magic": 1179937895, + "version": 2, + "chunk_order": ["JSON", "BIN"], + "json_encoding": "RFC8785_JCS_UTF8", + "json_padding_byte": 32, + "bin_padding_byte": 0, + "integer_encoding": "little_endian", + "asset_record": {"generator": "SimpleCAD Scene GLB Profile 1", "version": "2.0"}, + "root_keys": ["accessors", "asset", "bufferViews", "buffers", "meshes", "nodes", "scene", "scenes"], + "scene_skeleton": {"default_scene": 0, "nodes": [{"mesh": 0}], "scenes": [{"nodes": [0]}], "mesh_count": 1, "primitive_count": 1}, + "triangle_primitive": {"attributes": {"NORMAL": 1, "POSITION": 0}, "indices": 2, "mode": 4}, + "line_primitive": {"attributes": {"POSITION": 0}, "indices": 1, "mode": 1}, + "triangle_bin_layout": ["POSITION_float32x3", "NORMAL_float32x3", "alignment_zero_padding", "INDICES"], + "line_bin_layout": ["POSITION_float32x3", "alignment_zero_padding", "INDICES"], + "array_concatenation": "asset_block_order_with_local_indices_offset_by_prior_vertex_counts", + "array_buffer_target": 34962, + "index_buffer_target": 34963, + "float_component_type": 5126, + "uint16_component_type": 5123, + "uint16_max_vertex_count": 65536, + "uint32_component_type": 5125, + "position_accessor": ["bufferView", "componentType", "count", "max", "min", "type"], + "normal_accessor": ["bufferView", "componentType", "count", "type"], + "index_accessor": ["bufferView", "componentType", "count", "type"], + "buffer_view_fields": ["buffer", "byteLength", "byteOffset", "target"], + "buffer_fields": ["byteLength"], + "buffer_byte_length": "unpadded_bin_length", + "forbidden": ["empty_accessor", "uri_buffer", "extras", "extensions", "additional_json_fields"] + } + }, + "schema_version": "1.0" +} diff --git a/SimpleCADAPI/src/simplecadapi/scene/contracts/pseudocode/ocp-evaluated-properties-1.md b/SimpleCADAPI/src/simplecadapi/scene/contracts/pseudocode/ocp-evaluated-properties-1.md new file mode 100644 index 0000000..77e8ad5 --- /dev/null +++ b/SimpleCADAPI/src/simplecadapi/scene/contracts/pseudocode/ocp-evaluated-properties-1.md @@ -0,0 +1,308 @@ +# OCP Evaluated Properties Profile 1 Pseudocode + +This normative Phase A target defines the evaluated sidecar that the Phase B +Scene compiler must produce. The repository does not yet contain that compiler. +The named OCP calls exist in cadquery-ocp 7.9.3.1; SDK-derived selector and frame +algorithms name their concrete Python symbols. +The existing serializer resolver accepts a score tie and the Product edge-frame +helper falls back to +X for missing endpoints. Those helpers are not the Scene +compiler: this target instead requires unique matching and a null edge frame. + +## Preparation and accepted topology + +```text +First build solid/face/edge/vertex incidence from the unmodified OCP topology. +Accept one manifold solid or a compound of disjoint manifold solids. Reject a +compsolid, shared face, shell, free boundary, standalone lower-dimensional root, +or any face that does not have exactly one solid owner. + +Required cardinalities: + solid: zero parents and at least one face child + face: exactly one solid parent and at least one edge child + edge: at least one face parent and one or two vertex children + vertex: at least one edge parent and zero children +Every relation is reciprocal. Deduplicate and unsigned-UTF-8-sort adjacency. + +bake_location(shape, force_forward): + oriented = shape.Oriented(TopAbs_FORWARD) if force_forward else shape + location = oriented.Location() + unlocated = oriented.Located(TopLoc_Location(), False) + builder = BRepBuilderAPI_Transform( + unlocated, location.Transformation(), True, False) + builder.Build() + fail unless builder.IsDone() + return builder.Shape() + +Evaluate every entity from its prepared copy. Faces retain TopAbs orientation. +Edges are forced FORWARD before location baking. Coedge orientation is not saved. +``` + +## Analytic classification + +```text +curve = BRepAdaptor_Curve(prepared_forward_edge) +curve_symbol = curve.GetType() + +GeomAbs_Line -> line, curve.Line() +GeomAbs_Circle -> circle, curve.Circle() +GeomAbs_Ellipse -> ellipse, curve.Ellipse() +GeomAbs_BSplineCurve -> bspline_curve, curve.BSpline() +GeomAbs_Hyperbola -> other_curve("GeomAbs_Hyperbola") +GeomAbs_Parabola -> other_curve("GeomAbs_Parabola") +GeomAbs_BezierCurve -> other_curve("GeomAbs_BezierCurve") +GeomAbs_OffsetCurve -> other_curve("GeomAbs_OffsetCurve") +GeomAbs_OtherCurve -> other_curve("GeomAbs_OtherCurve") + +surface = BRepAdaptor_Surface(prepared_face, True) +surface_symbol = surface.GetType() + +GeomAbs_Plane -> plane, surface.Plane() +GeomAbs_Cylinder -> cylinder, surface.Cylinder() +GeomAbs_Cone -> cone, surface.Cone() +GeomAbs_Sphere -> sphere, surface.Sphere() +GeomAbs_Torus -> torus, surface.Torus() +GeomAbs_BSplineSurface -> bspline_surface, surface.BSpline() +GeomAbs_BezierSurface -> other_surface("GeomAbs_BezierSurface") +GeomAbs_SurfaceOfRevolution -> other_surface("GeomAbs_SurfaceOfRevolution") +GeomAbs_SurfaceOfExtrusion -> other_surface("GeomAbs_SurfaceOfExtrusion") +GeomAbs_OffsetSurface -> other_surface("GeomAbs_OffsetSurface") +GeomAbs_OtherSurface -> other_surface("GeomAbs_OtherSurface") +``` + +For line/circle/ellipse call the exact `gp_Lin`, `gp_Circ`, and `gp_Elips` +methods listed in the profile. For plane/cylinder/cone/sphere/torus call the +listed `gp_*` methods. `gp_Cone.SemiAngle()` is radians and is converted with +`math.degrees`. Normalize all serialized directions and reject non-finite data. +Map face orientation exactly from the four `TopAbs_*` symbols in the profile. +Treat a serialized normal/axis as local +Z and `x_direction` as local +X; +require `abs(dot(z,x)) <= 1e-12`, and derive +Y as +`normalize(cross(z,x))`. Circle/cylinder/sphere radii are positive; +ellipse/torus satisfy `major_radius >= minor_radius > 0`; cone reference radius +is nonnegative and `0 < semi_angle_degrees < 90`. + +For a B-spline curve read `Degree`, `IsRational`, `IsPeriodic`, `NbPoles`, and +`NbKnots`, then read every one-based `Knot(i)` and `Multiplicity(i)`. For a +B-spline surface read the corresponding U and V summary calls and every +one-based `UKnot`, `UMultiplicity`, `VKnot`, and `VMultiplicity` listed in the +profile. Before emitting the summary require: + +```text +1 <= degree <= 25; poles >= 2; unique knots >= 2 +knots are finite and strictly increasing +interior multiplicity is in [1, degree] +non-periodic endpoint multiplicity is in [1, degree+1] +non-periodic: sum(multiplicities) == poles + degree + 1 +periodic: first multiplicity == last multiplicity +periodic: sum(multiplicities) - first multiplicity == poles +``` + +Apply those rules independently to U and V for a surface. The maximum 25 is +also checked against `Geom_BSplineCurve.MaxDegree_s()` and +`Geom_BSplineSurface.MaxDegree_s()`. + +## Kernel properties + +```text +bounds(shape): + box = Bnd_Box() + box.SetGap(0.0) + BRepBndLib.AddOptimal_s(shape, box, False, False) + return box.Get() + +edge properties: + props = GProp_GProps() + BRepGProp.LinearProperties_s(edge, props, False, False) + length = props.Mass(); centroid = props.CentreOfMass() + +face properties: + props = GProp_GProps() + BRepGProp.SurfaceProperties_s(face, props, False, False) + area = props.Mass(); centroid = props.CentreOfMass() + +solid properties: + volume_props = GProp_GProps() + BRepGProp.VolumeProperties_s(solid, volume_props, False, False, False) + volume = volume_props.Mass(); centroid = volume_props.CentreOfMass() + surface_props = GProp_GProps() + BRepGProp.SurfaceProperties_s(solid, surface_props, False, False) + surface_area = surface_props.Mass() + +vertex position = BRep_Tool.Pnt_s(vertex) +``` + +All property inputs are prepared definition-local shapes. Reject non-finite +results and a zero-mass entity for which the required centroid is undefined. +The serialized quality string is exactly `kernel_evaluated`. + +## Metadata projection + +```text +project(value, path, ancestors): + reject a cycle + accept null, boolean, and string without coercion + accept an integer only in [-9007199254740991, 9007199254740991] + accept a finite binary64 without coercion + map list recursively + map tuple recursively to a JSON array + map an object only when every key is a string; recurse on each value + reject bytes, set, non-string keys, non-finite numbers, and every other value + +project_entity_metadata(metadata): + require a top-level object + omit graph, topo_ref, track, source_sketch, sketch_solve, sketch_promotion + omit every key beginning with "_" + retain every other key, including geo and std.* keys, through project() + report the exact metadata path and fail compilation on rejection +``` + +No `repr`, stringification, integer-key conversion, or OCP-object coercion is +permitted. RFC 8785 determines object key order in canonical bytes. + +## `geo_exact` score and uniqueness + +The selector constructor is `simplecadapi.operations._make_geo_selector`; the +score is `simplecadapi.serializer._geo_selector_score`. `metadata_geo` has zero +score contribution. `distance` is binary64 Euclidean vec3 distance. + +Build the selector after location baking and FORWARD edge orientation. Common +fields are `mode`, `kind`, JSON-normalized `metadata_geo`, `bbox`, and optional +`geom_type`; `mode`, `kind`, and `metadata_geo` are required, while `bbox` and +`geom_type` are optional. The optional `source_shape` constructor argument is +not serialized or scored. Never serialize a source traversal index. Use +`BRepBndLib.AddOptimal_s(shape, box, False, False)` after `box.SetGap(0.0)` for +the bbox. Vertex adds `coordinates`; edge adds `length`, `center`, and, only +when both wrapper calls succeed, `start` and `end`; face adds `area`, `center`, +`normal`, `edge_count`, and `inner_wire_count`; solid adds `volume` and center. +The exact wrapper calls are listed in the profile. + +`metadata_geo` is normalized by +`simplecadapi.operations._jsonable_geo_value`: null/string/boolean and Python +int/float are retained; NumPy integers/floats convert to Python numbers; a +successful `to_tuple()` converts to a recursive float array; x/y/z attributes +convert to a float vec3; dict keys convert with `str` and values recurse; lists +and tuples recurse to arrays; every other value converts with `str`. The result +must be in the RFC 8785 domain or compilation fails. + +`Face.get_normal_at(0.5, 0.5)` constructs `BRepAdaptor_Surface(face, True)`, +maps 0.5 to the midpoint of each first/last parameter range, constructs +`BRepLProp_SLProps(adaptor, midpoint_u, midpoint_v, 1, 1e-7)`, requires +`IsNormalDefined()`, reads `Normal()`, and reverses it for `TopAbs_REVERSED`. + +```text +if candidate kind != selector kind: score = 1e12 +otherwise: + if selector.bbox is absent: bbox = 0 + else bbox = distance(actual_min, expected_min) + + distance(actual_max, expected_max) + use bbox = 1e6 when a present bbox is invalid or cannot be evaluated + score = 10*bbox + if expected geom_type exists and evaluated geom_type exists and differs: + score += 1e6 + + vertex: score += 10*distance(coordinates, selector.coordinates) + + edge: + if the length key exists: score += 10*abs(length-selector.length) + score += 10*distance(center, selector.center) + if both selector endpoints and candidate endpoints are available: + score += min(distance(start,selector.start)+distance(end,selector.end), + distance(start,selector.end)+distance(end,selector.start)) + + face: + if the area key exists: score += abs(area-selector.area) + score += 10*distance(center,selector.center) + score += 5*distance(normal,selector.normal) + if edge_count exists: score += 10*abs(edge_count-selector.edge_count) + if inner_wire_count exists: + score += 10*abs(inner_wire_count-selector.inner_wire_count) + + solid: if volume exists: score += abs(volume-selector.volume) +``` + +`distance(a,b)` returns `1e6` when either vec3 is absent and otherwise returns +`math.dist(a,b)`. + +Reject non-finite scores. Evaluate every same-kind candidate in the owner Part +body. A supported match requires exactly one score `<= 0.0001` and every other +score `> 0.0001`. More than one passing candidate is `selector_ambiguous`; zero +passing candidates or a clean replay that resolves to another canonical entity +is `selector_unstable`. Apply the profile's binding reason precedence. +The resolver's candidate index has zero score contribution. + +## Connector frame + +```text +orthogonal_x(z): + seeds = [(1,0,0), (0,1,0), (0,0,1)] + best = seeds[0] + for seed in seeds[1:]: + replace best only if abs(dot(z,seed)) < abs(dot(z,best)) + return normalize(best - z*dot(z,best)) + +frame(origin, z): + z = normalize(z) + x = orthogonal_x(z) + y = normalize(cross(z,x)) + return Placement(origin=origin, x_axis=x, y_axis=y) + +face: frame(selector.center, selector.normal) +edge: frame(selector.center, selector.end-selector.start) +vertex: identity axes at selector.coordinates +solid: null +``` + +The strict-less-than scan makes exact seed ties choose X before Y before Z. +An edge with missing or coincident endpoints has a null frame. Store the +unflipped frame; binding `flip` is not applied to the sidecar snapshot. + +## Canonical entity labeling + +```text +initial_record(e) = internal closed canonicalization-only object containing: + kind, geometry, properties, source, sdk_connector_frame, render_status, + connector_binding_status, semantic_binding_ids, evaluated_tags, + sdk_metadata, render_key +render_key is null for solid, vertex, and degenerate edge +initial_bytes(e) = JCS(initial_record(e)) +label(e) = SHA-256(initial_bytes(e)) +partition entities by label; order initial cells by label bytes + +Labels are held internally as 32 raw bytes. Whenever a label occurs in JCS, +encode it as 64 lowercase hexadecimal characters without a prefix. + +repeat simultaneously: + signature(e) = JCS([ + label(e), + unsigned-byte-sort(multiset(label(parent))), + unsigned-byte-sort(multiset(label(child)))]) + next_label(e) = SHA-256(signature(e)) + split each existing cell by next_label and order its subcells by label bytes + never merge cells that were already separate +until the partition no longer splits + +Call search on the root partition with depth 0. If every cell is singleton, +encode that permutation. Otherwise choose the first +non-singleton cell in current partition order. For every candidate ordered by +(initial_bytes, refined label bytes), replace the cell by a candidate singleton +followed by the remainder. At depth d assign the singleton label +`SHA-256(0x49 || big_endian_u64(d) || old_label || initial_bytes(candidate))`, +refine again, and recurse depth-first. Exact candidate-order ties may use any +order because every branch is evaluated. Recurse with depth d+1. Count the root and every recursive +individualized partition as one state. If the count would exceed 1,000,000, fail with +`entity_canonicalization_budget_exceeded`. + +For a complete permutation, encode JCS of an array in permutation order. Each +array item has exactly `child_indices`, `initial_record`, and `parent_indices`; +the index arrays contain the corresponding permutation indices sorted as +ascending integers. The winning +permutation has the lexicographically least complete encoding bytes. Compare all +candidates; traversal order never selects the winner by itself. + +For each kind in solid, face, edge, vertex order, filter the winning permutation +to that kind and assign entity/{kind}/0, entity/{kind}/1, ... . Output entities +by unsigned UTF-8 entity ID. Sort every set-like ID/tag/binding array by unsigned +UTF-8 bytes. +``` + +Any normative profile change requires a new profile ID and a mandatory cache +miss. diff --git a/SimpleCADAPI/src/simplecadapi/scene/contracts/pseudocode/scene-1.0-ocp-glb-2.md b/SimpleCADAPI/src/simplecadapi/scene/contracts/pseudocode/scene-1.0-ocp-glb-2.md new file mode 100644 index 0000000..5a261ea --- /dev/null +++ b/SimpleCADAPI/src/simplecadapi/scene/contracts/pseudocode/scene-1.0-ocp-glb-2.md @@ -0,0 +1,255 @@ +# Scene 1.0 OCP GLB Profile 2 Pseudocode + +This normative profile defines the bytes produced by the Scene compiler. Every +OCP symbol below exists in cadquery-ocp 7.9.3.1. The meshing, edge sampling, and +GLB surfaces are grounded in `simplecadapi.kernel.ocp_mesh`, +`simplecadapi.scene.render_mesh`, and `simplecadapi.scene.glb`. + +## Registered toolchain descriptor + +The `toolchain_hash` input is the SHA-256 of RFC 8785 JCS bytes for one closed +object with exactly `build_identity`, `native_libraries`, `profile_hashes`, +`python_executable`, `python_modules`, and `schema_version`. `schema_version` is +`1.0`. Each file record has exactly `content_hash` and `logical_name`, where the +hash is `sha256:` plus lowercase SHA-256 hex over the exact file bytes. +`build_identity` is a closed object; `profile_hashes`, `python_modules`, and +`native_libraries` are record arrays; `python_executable` is one record. +`profile_hashes` contains exactly the two packaged profile file records. Python module +records contain the registered complete output-affecting transitive source +closure rooted at the modules listed in the profile. Native records contain the +registered complete OCP binding and transitive native-kernel library closure. +`python_executable` hashes the regular file reached by resolving +`sys.executable`. Record arrays are unique and sorted by logical name in +unsigned UTF-8 order. + +Registration consumes a trusted build manifest mapping logical names to files. +Resolve symlinks and require every input to be a readable regular file. An +incomplete output-affecting source or native closure is not a registered +toolchain. `simplecadapi.scene.compiler` is an exact required Phase B target +root; it is explicitly absent during Phase A and therefore is not falsely +claimed as an existing implementation. + +`build_identity` is closed and contains exactly `ocp_bindings_version`, +`ocp_version`, `platform_tag`, `python_abi`, and `simplecadapi_version`; each +value matches `[A-Za-z0-9][A-Za-z0-9._+-]{0,127}`. Absolute paths, mtimes, and +host names are forbidden. Display versions do not replace the descriptor hash, +and reproducibility may be claimed only for an exact registered hash match. + +## Definition-local shapes + +```text +bake_location(shape, requested_orientation): + located = shape.Oriented(requested_orientation) when requested_orientation is not null + otherwise shape + location = located.Location() + unlocated = located.Located(TopLoc_Location(), False) + builder = BRepBuilderAPI_Transform( + unlocated, location.Transformation(), True, False) + builder.Build() + return builder.Shape(); fail unless builder.IsDone() + +For a face, requested_orientation is null and its TopAbs orientation is retained. +For an edge, requested_orientation is TopAbs_FORWARD before location is baked. +The prepared shape, not the located input, is used for meshing, discretization, +properties, classification, selectors, and frames. +``` + +## Numeric primitives + +```text +f64(x): + evaluate x as IEEE-754 binary64 round-to-nearest-ties-to-even + reject a non-finite result + return result + +f32(x): + result = IEEE-754 binary32 round-to-nearest-ties-to-even(x) + reject overflow or a non-finite result + if bits(result) == 0x80000000: return bits(0x00000000) + return result + +cross(a, b): + x = f64(f64(a.y * b.z) - f64(a.z * b.y)) + y = f64(f64(a.z * b.x) - f64(a.x * b.z)) + z = f64(f64(a.x * b.y) - f64(a.y * b.x)) + FMA is forbidden + return (x, y, z) + +normalize(v): + squared = f64(f64(v.x * v.x) + f64(v.y * v.y)) + squared = f64(squared + f64(v.z * v.z)) + length = correctly_rounded_binary64_sqrt(squared) + reject length == 0 or non-finite + result = (f32(v.x / length), f32(v.y / length), f32(v.z / length)) + reject binary64 norm(result) outside [0.999999, 1.000001] + return result +``` + +## Face triangulation + +```text +for each prepared definition-local face: + BRepTools.Clean_s(face, False) + mesher = BRepMesh_IncrementalMesh( + face, + float(compile_options.linear_tolerance), + False, + float(compile_options.angular_tolerance), + False) + mesher.Perform() + + tri_location = TopLoc_Location() + tri = BRep_Tool.Triangulation_s(face, tri_location, 0) + fail the face if tri is null + trsf = tri_location.Transformation() + + for node_index in 1..tri.NbNodes(): + cad_point = tri.Node(node_index).Transformed(trsf) + + for triangle_index in 1..tri.NbTriangles(): + (a, b, c) = tri.Triangle(triangle_index).Get() + oriented = (a, c, b) if face.Orientation() == TopAbs_REVERSED + else (a, b, c) + + positions = [f32_components(cad_to_gltf(cad_point[node_index])) + for node_index in oriented] + drop the triangle if two position bit triples are equal + collapsed_cross = cross(positions[1] - positions[0], + positions[2] - positions[0]) + drop when all three collapsed_cross components are signed zero + + for each corner node_index in oriented: + position = positions[corner ordinal] + if tri.HasNormals(): + direction = tri.Normal(node_index).Transformed(trsf) + if face.Orientation() == TopAbs_REVERSED: + direction = direction.Reversed() + normal = normalize(cad_to_gltf_direction(direction)) + else: + normal = normalize(collapsed_cross) + append vertex_key = little_endian(position f32 bits || normal f32 bits) + append the oriented triangle of its three vertex_keys + + reject the face if no triangle remains + remove unreferenced vertices and sort unique vertices by vertex_key bytes + remap each triangle to local indices + rotate each oriented triple cyclically to its lexicographically least value; + reversal is forbidden + sort triples lexicographically + encode each local index as little-endian u16 when this block has at most 65536 + vertices, otherwise as little-endian u32 + block = sorted vertex_key bytes followed by the encoded local triangle indices + render_key = SHA-256(0x01 || block) +``` + +Normals are per triangle corner. There is no averaging within a CAD face or +across CAD faces. + +## Edge discretization + +```text +for each edge prepared with TopAbs_FORWARD orientation: + adaptor = BRepAdaptor_Curve(edge) + sampler = GCPnts_TangentialDeflection( + adaptor, + adaptor.FirstParameter(), + adaptor.LastParameter(), + float(compile_options.angular_tolerance), + float(compile_options.linear_tolerance), + 2, + 1e-9, + 1e-7) + fail compilation if sampler construction raises + points = [sampler.Value(i) for i in 1..sampler.NbPoints()] + convert each point through cad_to_gltf and f32 + + for each adjacent pair: + drop it when endpoint bit triples are equal + sort unique position bit triples and remap endpoints + replace each pair with (min(local_a, local_b), max(local_a, local_b)) + sort and deduplicate pairs + if no pair remains, retain a degenerate entity and emit no render block + otherwise: + encode local indices as little-endian u16 when this block has at most 65536 + vertices, otherwise as little-endian u32 + block = sorted position bits followed by the encoded local segment indices + render_key = SHA-256(0x02 || block) +``` + +## Block and range order + +```text +Sort all emitted face blocks and all emitted edge blocks independently by +(render_key bytes, block bytes). Keep one block per rendered entity. Within an +equal-block cell, assign otherwise indistinguishable range slots by canonical +entity ID in unsigned UTF-8 order. Concatenate vertices in block order and add +the number of prior vertices to each block-local index. Metadata, source, +bindings, and tags never enter a block or render_key. + +Emit one positive face group per face and one positive edge group per rendered +edge; a degenerate edge has no group. Each array is ordered by `first_index`, +has contiguous zero-based `group_id` values, and exactly partitions its primitive +index accessor without overlap. Face counts are multiples of three and edge +counts are multiples of two. `mesh_index` and `primitive_index` are zero; +`first_index` and `index_count` count accessor elements, not bytes. +``` + +## Exact GLB writer + +```text +writer identity = simplecadapi.scene.glb.scene-1.0-ocp-glb-1 +asset = {"generator":"SimpleCAD Scene GLB Profile 1","version":"2.0"} +root fields = accessors, asset, bufferViews, buffers, meshes, nodes, scene, scenes +scene = 0; nodes = [{"mesh":0}]; scenes = [{"nodes":[0]}] +meshes = [{"primitives":[primitive]}] + +triangle primitive = { + "attributes":{"NORMAL":1,"POSITION":0}, "indices":2, "mode":4} +triangle BIN = POSITION float32x3, NORMAL float32x3, zero alignment, INDICES + +line primitive = { + "attributes":{"POSITION":0}, "indices":1, "mode":1} +line BIN = POSITION float32x3, zero alignment, INDICES + +POSITION accessor fields = bufferView, componentType=5126, count, max, min, + type="VEC3" +NORMAL accessor fields = bufferView, componentType=5126, count, type="VEC3" +index accessor fields = bufferView, componentType, count, type="SCALAR" +array bufferView target = 34962; index bufferView target = 34963 +bufferView fields = buffer=0, byteLength, byteOffset, target +buffer fields = byteLength, equal to the unpadded BIN length + +Use componentType 5123 when total vertex count <= 65536, otherwise 5125. +All integer and float buffer values are little-endian. Buffer views are +contiguous in the stated order and each next offset is aligned to four bytes +with zeroes. POSITION min/max are exact decoded binary32 extrema. + +JSON bytes are RFC 8785 JCS UTF-8 followed by minimal 0x20 padding to a multiple +of four. BIN bytes use minimal 0x00 final padding to a multiple of four. Emit a +12-byte little-endian GLB header (magic 0x46546c67, version 2, total length), +then JSON chunk type 0x4e4f534a, then BIN chunk type 0x004e4942. + +Empty accessors, URI buffers, extras, extensions, and every JSON field not named +above are forbidden. +``` + +## Default appearance and Product equivalence + +The exact default appearance is: + +```json +{"alpha_mode":"opaque","base_color":[0.72,0.75,0.78,1],"double_sided":false,"edge_color":[0.08,0.09,0.1,1],"metallic":0,"name":null,"roughness":0.55,"sdk_metadata":{},"source":null} +``` + +Definition equivalence validates against `normalized-product-1.schema.json` and +compares exact RFC 8785 JCS bytes. Part connectors and Assembly components, +connectors, and constraints retain declaration order. Grounded component IDs +are sorted by unsigned UTF-8 bytes. A component contains exactly +`component_id`, nullable `name`, `definition_ref`, and `local_placement`. +Material is the complete normalized JSON-safe record; Product metadata uses the +evaluated profile projection; runtime connector `_metadata` is forbidden. Any +record difference is a typed semantic-ID collision. Mesh equality and subset or +"at least same" heuristics are forbidden. + +Any normative profile change requires a new profile ID and a mandatory cache +miss. Characterization must cover every case in `required_characterization_cases`. diff --git a/SimpleCADAPI/src/simplecadapi/scene/contracts/rules/scene-1.0-rules.json b/SimpleCADAPI/src/simplecadapi/scene/contracts/rules/scene-1.0-rules.json new file mode 100644 index 0000000..6b66c18 --- /dev/null +++ b/SimpleCADAPI/src/simplecadapi/scene/contracts/rules/scene-1.0-rules.json @@ -0,0 +1,34 @@ +{ + "phases": ["parse", "structure", "semantic", "package", "budget"], + "registry_id": "scene-1.0-rules", + "rules": [ + {"artifacts": ["scene", "entities", "presentation", "connector_binding", "normalized_product", "package"], "id": "bom_forbidden", "phase": "parse", "pointer_policy": "root", "precedence": 0, "summary": "UTF-8 BOM is forbidden."}, + {"artifacts": ["scene", "entities", "presentation", "connector_binding", "normalized_product", "package"], "id": "duplicate_json_key", "phase": "parse", "pointer_policy": "root", "precedence": 1, "summary": "Duplicate JSON object members are forbidden."}, + {"artifacts": ["scene", "entities", "presentation", "connector_binding", "normalized_product", "package"], "id": "invalid_utf8", "phase": "parse", "pointer_policy": "exact", "precedence": 2, "summary": "JSON must use strict UTF-8."}, + {"artifacts": ["scene", "entities", "presentation", "connector_binding", "normalized_product", "package"], "id": "noncanonical_json", "phase": "parse", "pointer_policy": "root", "precedence": 3, "summary": "Canonical package JSON must be exact RFC 8785 JCS bytes."}, + {"artifacts": ["scene", "entities", "presentation", "connector_binding", "normalized_product", "package"], "id": "nonfinite_json_number", "phase": "parse", "pointer_policy": "exact", "precedence": 4, "summary": "NaN and infinities are forbidden."}, + {"artifacts": ["scene", "entities", "presentation", "connector_binding", "normalized_product", "package"], "id": "schema_invalid", "phase": "structure", "pointer_policy": "exact", "precedence": 0, "summary": "The artifact must satisfy its closed JSON Schema 2020-12 schema."}, + {"artifacts": ["entities", "package"], "id": "analytic_geometry_invalid", "phase": "semantic", "pointer_policy": "nearest_record", "precedence": 0, "summary": "Analytic axes and direction vectors must be finite, normalized, and mutually orthogonal where required."}, + {"artifacts": ["scene", "entities", "presentation", "connector_binding", "normalized_product", "package"], "id": "array_order_invalid", "phase": "semantic", "pointer_policy": "exact", "precedence": 1, "summary": "Set-like arrays must use the registered unsigned UTF-8 ordering."}, + {"artifacts": ["scene", "entities", "presentation", "normalized_product", "package"], "id": "bounds_invalid", "phase": "semantic", "pointer_policy": "nearest_record", "precedence": 2, "summary": "Bounds and contained points must satisfy the numeric profile."}, + {"artifacts": ["scene", "entities", "connector_binding", "package"], "id": "connector_invalid", "phase": "semantic", "pointer_policy": "nearest_record", "precedence": 3, "summary": "Connector owner, source, target, forwarding, and frame rules must hold."}, + {"artifacts": ["entities", "package"], "id": "entity_range_invalid", "phase": "semantic", "pointer_policy": "nearest_record", "precedence": 4, "summary": "Face and rendered-edge ranges must form exact positive partitions."}, + {"artifacts": ["entities", "package"], "id": "entity_topology_invalid", "phase": "semantic", "pointer_policy": "nearest_record", "precedence": 5, "summary": "Entity ownership, cardinality, and reciprocal adjacency must be valid."}, + {"artifacts": ["scene", "package"], "id": "hierarchy_invalid", "phase": "semantic", "pointer_policy": "nearest_record", "precedence": 6, "summary": "Scene hierarchy must be acyclic, rooted, and continuously ordered."}, + {"artifacts": ["scene", "entities", "presentation", "normalized_product", "package"], "id": "id_duplicate", "phase": "semantic", "pointer_policy": "exact", "precedence": 7, "summary": "Identifiers must be unique in their declared scope."}, + {"artifacts": ["presentation"], "id": "presentation_reference_invalid", "phase": "semantic", "pointer_policy": "exact", "precedence": 8, "summary": "Presentation names and references must be unique and resolvable."}, + {"artifacts": ["scene", "entities", "normalized_product", "package"], "id": "reference_missing", "phase": "semantic", "pointer_policy": "exact", "precedence": 9, "summary": "Every typed reference must resolve in its declared registry."}, + {"artifacts": ["scene", "package"], "id": "revision_mismatch", "phase": "semantic", "pointer_policy": "exact", "precedence": 10, "summary": "Scene revision must equal the two-pass JCS hash."}, + {"artifacts": ["scene", "entities", "connector_binding", "package"], "id": "source_matrix_invalid", "phase": "semantic", "pointer_policy": "nearest_record", "precedence": 11, "summary": "Top-level, definition, node, entity, connector, and appearance sources must agree."}, + {"artifacts": ["scene", "entities", "presentation", "package"], "id": "transform_invalid", "phase": "semantic", "pointer_policy": "nearest_record", "precedence": 12, "summary": "Rigid transforms must satisfy the exact right-handed numeric profile."}, + {"artifacts": ["package"], "id": "blob_hash_mismatch", "phase": "package", "pointer_policy": "exact", "precedence": 0, "summary": "Referenced immutable bytes must match the declared SHA-256."}, + {"artifacts": ["package"], "id": "blob_length_mismatch", "phase": "package", "pointer_policy": "exact", "precedence": 1, "summary": "Referenced immutable bytes must match the declared byte length."}, + {"artifacts": ["glb", "package"], "id": "glb_profile_invalid", "phase": "package", "pointer_policy": "exact", "precedence": 2, "summary": "GLB bytes must match the exact closed triangle or line profile."}, + {"artifacts": ["package"], "id": "package_member_set_invalid", "phase": "package", "pointer_policy": "root", "precedence": 3, "summary": "Package blob keys must exactly equal manifest URI references; archive validation separately requires scene.json."}, + {"artifacts": ["zip"], "id": "zip_profile_invalid", "phase": "package", "pointer_policy": "root", "precedence": 4, "summary": "ZIP headers, names, methods, CRCs, and envelope must match the allowlist."}, + {"artifacts": ["zip"], "id": "archive_limit_exceeded", "phase": "budget", "pointer_policy": "root", "precedence": 0, "summary": "Archive preflight limits must hold before payload allocation."}, + {"artifacts": ["scene", "entities", "presentation", "connector_binding", "normalized_product", "package", "glb"], "id": "resource_limit_exceeded", "phase": "budget", "pointer_policy": "nearest_record", "precedence": 1, "summary": "Artifact counts and sizes must remain within the Base profile."}, + {"artifacts": ["package"], "id": "static_buffer_limit_exceeded", "phase": "budget", "pointer_policy": "root", "precedence": 2, "summary": "The static decoded CPU/GPU buffer formula must not exceed 512 MiB."} + ], + "schema_version": "1.0" +} diff --git a/SimpleCADAPI/src/simplecadapi/scene/contracts/schemas/connector-binding-1.0.schema.json b/SimpleCADAPI/src/simplecadapi/scene/contracts/schemas/connector-binding-1.0.schema.json new file mode 100644 index 0000000..d192348 --- /dev/null +++ b/SimpleCADAPI/src/simplecadapi/scene/contracts/schemas/connector-binding-1.0.schema.json @@ -0,0 +1,44 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://simplecad.dev/schemas/connector-binding-1.0.schema.json", + "title": "SimpleCAD Connector Binding Command 1.0", + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "binding_id", "source_model", "source_scene", "owner_definition_id", "selected_occurrence_node_id", "connector_id", "name", "target"], + "properties": { + "schema_version": {"const": "1.0"}, + "binding_id": {"$ref": "#/$defs/logicalId"}, + "source_model": {"$ref": "#/$defs/sourceModel"}, + "source_scene": {"$ref": "#/$defs/sourceScene"}, + "owner_definition_id": {"$ref": "#/$defs/structuralId"}, + "selected_occurrence_node_id": {"$ref": "#/$defs/structuralId"}, + "connector_id": {"$ref": "#/$defs/productId"}, + "name": {"type": ["string", "null"]}, + "target": {"$ref": "#/$defs/target"} + }, + "$defs": { + "safeInteger": {"type": "integer", "minimum": 0, "maximum": 9007199254740991}, + "logicalId": {"type": "string", "pattern": "^[A-Za-z][A-Za-z0-9_.-]{0,127}$"}, + "productId": {"type": "string", "pattern": "^[A-Za-z][A-Za-z0-9_.:-]*$"}, + "structuralId": {"type": "string", "minLength": 1, "pattern": "^[A-Za-z0-9._~%:/-]+$"}, + "hash": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"}, + "sourceModel": {"type": "object", "additionalProperties": false, "required": ["graph_id", "model_schema_version", "artifact_hash"], "properties": { + "graph_id": {"$ref": "#/$defs/productId"}, "model_schema_version": {"const": "2.0"}, "artifact_hash": {"$ref": "#/$defs/hash"} + }}, + "sourceScene": {"type": "object", "additionalProperties": false, "required": ["scene_id", "revision"], "properties": { + "scene_id": {"$ref": "#/$defs/logicalId"}, "revision": {"$ref": "#/$defs/hash"} + }}, + "modelOutput": {"type": "object", "additionalProperties": false, "required": ["kind", "graph_id", "node_id", "output_slot"], "properties": { + "kind": {"const": "model_output"}, "graph_id": {"$ref": "#/$defs/productId"}, "node_id": {"$ref": "#/$defs/productId"}, "output_slot": {"$ref": "#/$defs/safeInteger"} + }}, + "modelTopology": {"type": "object", "additionalProperties": false, "required": ["kind", "graph_id", "node_id", "output_slot", "topology_kind", "topo_id"], "properties": { + "kind": {"const": "model_topology"}, "graph_id": {"$ref": "#/$defs/productId"}, "node_id": {"$ref": "#/$defs/productId"}, "output_slot": {"$ref": "#/$defs/safeInteger"}, + "topology_kind": {"enum": ["FACE", "EDGE", "VERTEX"]}, "topo_id": {"type": "string", "minLength": 1} + }}, + "target": {"type": "object", "additionalProperties": false, "required": ["kind", "entity_asset_id", "entity_id", "expected_source", "flip"], "properties": { + "kind": {"const": "topology_entity"}, "entity_asset_id": {"$ref": "#/$defs/hash"}, + "entity_id": {"type": "string", "pattern": "^entity/(face|edge|vertex)/(0|[1-9][0-9]*)$"}, + "expected_source": {"oneOf": [{"$ref": "#/$defs/modelOutput"}, {"$ref": "#/$defs/modelTopology"}]}, "flip": {"type": "boolean"} + }} + } +} diff --git a/SimpleCADAPI/src/simplecadapi/scene/contracts/schemas/entities-1.0.schema.json b/SimpleCADAPI/src/simplecadapi/scene/contracts/schemas/entities-1.0.schema.json new file mode 100644 index 0000000..a111f85 --- /dev/null +++ b/SimpleCADAPI/src/simplecadapi/scene/contracts/schemas/entities-1.0.schema.json @@ -0,0 +1,124 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://simplecad.dev/schemas/entities-1.0.schema.json", + "title": "SimpleCAD Entity Snapshot 1.0", + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "definition_id", "geometry_asset_id", "edge_asset_id", "geometry_engine", "entities", "face_groups", "edge_groups"], + "properties": { + "schema_version": {"const": "1.0"}, + "definition_id": {"$ref": "#/$defs/structuralId"}, + "geometry_asset_id": {"$ref": "#/$defs/hash"}, + "edge_asset_id": {"$ref": "#/$defs/hash"}, + "geometry_engine": {"$ref": "#/$defs/geometryEngine"}, + "entities": {"type": "array", "items": {"$ref": "#/$defs/entity"}, "minItems": 4}, + "face_groups": {"type": "array", "items": {"$ref": "#/$defs/group"}, "minItems": 1}, + "edge_groups": {"type": "array", "items": {"$ref": "#/$defs/group"}} + }, + "$defs": { + "safeInteger": {"type": "integer", "minimum": 0, "maximum": 9007199254740991}, + "positiveInteger": {"type": "integer", "minimum": 1, "maximum": 9007199254740991}, + "hash": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"}, + "productId": {"type": "string", "pattern": "^[A-Za-z][A-Za-z0-9_.:-]*$"}, + "structuralId": {"type": "string", "minLength": 1, "pattern": "^[A-Za-z0-9._~%:/-]+$"}, + "entityId": {"type": "string", "pattern": "^entity/(solid|face|edge|vertex)/(0|[1-9][0-9]*)$"}, + "vec3": {"type": "array", "prefixItems": [{"type": "number"}, {"type": "number"}, {"type": "number"}], "minItems": 3, "maxItems": 3}, + "transform": {"type": "object", "additionalProperties": false, "required": ["origin", "x_axis", "y_axis", "z_axis"], "properties": { + "origin": {"$ref": "#/$defs/vec3"}, "x_axis": {"$ref": "#/$defs/vec3"}, "y_axis": {"$ref": "#/$defs/vec3"}, "z_axis": {"$ref": "#/$defs/vec3"} + }}, + "bounds": {"type": "object", "additionalProperties": false, "required": ["min", "max"], "properties": { + "min": {"$ref": "#/$defs/vec3"}, "max": {"$ref": "#/$defs/vec3"} + }}, + "geometryEngine": {"type": "object", "additionalProperties": false, "required": ["name", "version", "profile"], "properties": { + "name": {"const": "OpenCascade"}, "version": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._+-]{0,127}$"}, "profile": {"const": "ocp-evaluated-properties-1"} + }}, + "modelOutput": {"type": "object", "additionalProperties": false, "required": ["kind", "graph_id", "node_id", "output_slot"], "properties": { + "kind": {"const": "model_output"}, "graph_id": {"$ref": "#/$defs/productId"}, "node_id": {"$ref": "#/$defs/productId"}, "output_slot": {"$ref": "#/$defs/safeInteger"} + }}, + "modelTopology": {"type": "object", "additionalProperties": false, "required": ["kind", "graph_id", "node_id", "output_slot", "topology_kind", "topo_id"], "properties": { + "kind": {"const": "model_topology"}, "graph_id": {"$ref": "#/$defs/productId"}, "node_id": {"$ref": "#/$defs/productId"}, "output_slot": {"$ref": "#/$defs/safeInteger"}, + "topology_kind": {"enum": ["SOLID", "FACE", "EDGE", "VERTEX"]}, "topo_id": {"type": "string", "minLength": 1} + }}, + "importedPrimitive": {"type": "object", "additionalProperties": false, "required": ["kind", "source_element_id"], "properties": { + "kind": {"const": "imported_primitive"}, "source_element_id": {"type": "string", "minLength": 1} + }}, + "unbound": {"type": "object", "additionalProperties": false, "required": ["kind"], "properties": {"kind": {"const": "unbound"}}}, + "source": {"oneOf": [{"$ref": "#/$defs/modelOutput"}, {"$ref": "#/$defs/modelTopology"}, {"$ref": "#/$defs/importedPrimitive"}, {"$ref": "#/$defs/unbound"}]}, + "brepSolid": {"type": "object", "additionalProperties": false, "required": ["type"], "properties": {"type": {"const": "brep_solid"}}}, + "point": {"type": "object", "additionalProperties": false, "required": ["type", "position"], "properties": {"type": {"const": "point"}, "position": {"$ref": "#/$defs/vec3"}}}, + "line": {"type": "object", "additionalProperties": false, "required": ["type", "origin", "direction"], "properties": {"type": {"const": "line"}, "origin": {"$ref": "#/$defs/vec3"}, "direction": {"$ref": "#/$defs/vec3"}}}, + "circle": {"type": "object", "additionalProperties": false, "required": ["type", "center", "normal", "x_direction", "radius"], "properties": { + "type": {"const": "circle"}, "center": {"$ref": "#/$defs/vec3"}, "normal": {"$ref": "#/$defs/vec3"}, "x_direction": {"$ref": "#/$defs/vec3"}, "radius": {"type": "number", "exclusiveMinimum": 0} + }}, + "ellipse": {"type": "object", "additionalProperties": false, "required": ["type", "center", "normal", "x_direction", "major_radius", "minor_radius"], "properties": { + "type": {"const": "ellipse"}, "center": {"$ref": "#/$defs/vec3"}, "normal": {"$ref": "#/$defs/vec3"}, "x_direction": {"$ref": "#/$defs/vec3"}, + "major_radius": {"type": "number", "exclusiveMinimum": 0}, "minor_radius": {"type": "number", "exclusiveMinimum": 0} + }}, + "bsplineCurve": {"type": "object", "additionalProperties": false, "required": ["type", "degree", "rational", "periodic", "poles_count", "knots_count"], "properties": { + "type": {"const": "bspline_curve"}, "degree": {"$ref": "#/$defs/safeInteger"}, "rational": {"type": "boolean"}, "periodic": {"type": "boolean"}, + "poles_count": {"$ref": "#/$defs/safeInteger"}, "knots_count": {"$ref": "#/$defs/safeInteger"} + }}, + "otherCurve": {"type": "object", "additionalProperties": false, "required": ["type", "engine_type"], "properties": {"type": {"const": "other_curve"}, "engine_type": {"type": "string", "minLength": 1}}}, + "plane": {"type": "object", "additionalProperties": false, "required": ["type", "origin", "normal", "x_direction"], "properties": { + "type": {"const": "plane"}, "origin": {"$ref": "#/$defs/vec3"}, "normal": {"$ref": "#/$defs/vec3"}, "x_direction": {"$ref": "#/$defs/vec3"} + }}, + "cylinder": {"type": "object", "additionalProperties": false, "required": ["type", "origin", "axis", "x_direction", "radius"], "properties": { + "type": {"const": "cylinder"}, "origin": {"$ref": "#/$defs/vec3"}, "axis": {"$ref": "#/$defs/vec3"}, "x_direction": {"$ref": "#/$defs/vec3"}, "radius": {"type": "number", "exclusiveMinimum": 0} + }}, + "cone": {"type": "object", "additionalProperties": false, "required": ["type", "origin", "axis", "x_direction", "reference_radius", "semi_angle_degrees"], "properties": { + "type": {"const": "cone"}, "origin": {"$ref": "#/$defs/vec3"}, "axis": {"$ref": "#/$defs/vec3"}, "x_direction": {"$ref": "#/$defs/vec3"}, + "reference_radius": {"type": "number", "minimum": 0}, "semi_angle_degrees": {"type": "number", "exclusiveMinimum": 0, "exclusiveMaximum": 90} + }}, + "sphere": {"type": "object", "additionalProperties": false, "required": ["type", "center", "axis", "x_direction", "radius"], "properties": { + "type": {"const": "sphere"}, "center": {"$ref": "#/$defs/vec3"}, "axis": {"$ref": "#/$defs/vec3"}, "x_direction": {"$ref": "#/$defs/vec3"}, "radius": {"type": "number", "exclusiveMinimum": 0} + }}, + "torus": {"type": "object", "additionalProperties": false, "required": ["type", "center", "axis", "x_direction", "major_radius", "minor_radius"], "properties": { + "type": {"const": "torus"}, "center": {"$ref": "#/$defs/vec3"}, "axis": {"$ref": "#/$defs/vec3"}, "x_direction": {"$ref": "#/$defs/vec3"}, + "major_radius": {"type": "number", "exclusiveMinimum": 0}, "minor_radius": {"type": "number", "exclusiveMinimum": 0} + }}, + "bsplineSurface": {"type": "object", "additionalProperties": false, "required": ["type", "u_degree", "v_degree", "u_rational", "v_rational", "u_periodic", "v_periodic", "u_poles_count", "v_poles_count", "u_knots_count", "v_knots_count"], "properties": { + "type": {"const": "bspline_surface"}, "u_degree": {"$ref": "#/$defs/safeInteger"}, "v_degree": {"$ref": "#/$defs/safeInteger"}, + "u_rational": {"type": "boolean"}, "v_rational": {"type": "boolean"}, "u_periodic": {"type": "boolean"}, "v_periodic": {"type": "boolean"}, + "u_poles_count": {"$ref": "#/$defs/safeInteger"}, "v_poles_count": {"$ref": "#/$defs/safeInteger"}, "u_knots_count": {"$ref": "#/$defs/safeInteger"}, "v_knots_count": {"$ref": "#/$defs/safeInteger"} + }}, + "otherSurface": {"type": "object", "additionalProperties": false, "required": ["type", "engine_type"], "properties": {"type": {"const": "other_surface"}, "engine_type": {"type": "string", "minLength": 1}}}, + "geometry": {"oneOf": [ + {"$ref": "#/$defs/brepSolid"}, {"$ref": "#/$defs/point"}, {"$ref": "#/$defs/line"}, {"$ref": "#/$defs/circle"}, {"$ref": "#/$defs/ellipse"}, + {"$ref": "#/$defs/bsplineCurve"}, {"$ref": "#/$defs/otherCurve"}, {"$ref": "#/$defs/plane"}, {"$ref": "#/$defs/cylinder"}, {"$ref": "#/$defs/cone"}, + {"$ref": "#/$defs/sphere"}, {"$ref": "#/$defs/torus"}, {"$ref": "#/$defs/bsplineSurface"}, {"$ref": "#/$defs/otherSurface"} + ]}, + "solidProperties": {"type": "object", "additionalProperties": false, "required": ["quality", "bounds", "volume", "surface_area", "centroid"], "properties": { + "quality": {"const": "kernel_evaluated"}, "bounds": {"$ref": "#/$defs/bounds"}, "volume": {"type": "number", "minimum": 0}, "surface_area": {"type": "number", "minimum": 0}, "centroid": {"$ref": "#/$defs/vec3"} + }}, + "faceProperties": {"type": "object", "additionalProperties": false, "required": ["quality", "bounds", "area", "centroid", "orientation"], "properties": { + "quality": {"const": "kernel_evaluated"}, "bounds": {"$ref": "#/$defs/bounds"}, "area": {"type": "number", "minimum": 0}, "centroid": {"$ref": "#/$defs/vec3"}, "orientation": {"enum": ["forward", "reversed", "internal", "external"]} + }}, + "edgeProperties": {"type": "object", "additionalProperties": false, "required": ["quality", "bounds", "length", "centroid"], "properties": { + "quality": {"const": "kernel_evaluated"}, "bounds": {"$ref": "#/$defs/bounds"}, "length": {"type": "number", "minimum": 0}, "centroid": {"$ref": "#/$defs/vec3"} + }}, + "vertexProperties": {"type": "object", "additionalProperties": false, "required": ["quality", "bounds", "position"], "properties": { + "quality": {"const": "kernel_evaluated"}, "bounds": {"$ref": "#/$defs/bounds"}, "position": {"$ref": "#/$defs/vec3"} + }}, + "entity": { + "type": "object", "additionalProperties": false, + "required": ["entity_id", "kind", "parent_entity_ids", "child_entity_ids", "source", "geometry", "properties", "sdk_connector_frame", "render_status", "connector_binding_status", "semantic_binding_ids", "evaluated_tags", "sdk_metadata"], + "properties": { + "entity_id": {"$ref": "#/$defs/entityId"}, "kind": {"enum": ["solid", "face", "edge", "vertex"]}, + "parent_entity_ids": {"type": "array", "items": {"$ref": "#/$defs/entityId"}, "uniqueItems": true}, + "child_entity_ids": {"type": "array", "items": {"$ref": "#/$defs/entityId"}, "uniqueItems": true}, + "source": {"$ref": "#/$defs/source"}, "geometry": {"$ref": "#/$defs/geometry"}, + "properties": {"oneOf": [{"$ref": "#/$defs/solidProperties"}, {"$ref": "#/$defs/faceProperties"}, {"$ref": "#/$defs/edgeProperties"}, {"$ref": "#/$defs/vertexProperties"}]}, + "sdk_connector_frame": {"oneOf": [{"type": "null"}, {"$ref": "#/$defs/transform"}]}, + "render_status": {"enum": ["rendered", "degenerate"]}, + "connector_binding_status": {"enum": ["not_applicable", "owner_not_part", "source_not_model", "frame_undefined", "selector_ambiguous", "selector_unstable", "supported"]}, + "semantic_binding_ids": {"type": "array", "items": {"type": "string", "minLength": 1}, "uniqueItems": true}, + "evaluated_tags": {"type": "array", "items": {"type": "string", "minLength": 1}, "uniqueItems": true}, + "sdk_metadata": {"type": "object"} + } + }, + "group": {"type": "object", "additionalProperties": false, "required": ["group_id", "entity_id", "mesh_index", "primitive_index", "first_index", "index_count"], "properties": { + "group_id": {"$ref": "#/$defs/safeInteger"}, "entity_id": {"$ref": "#/$defs/entityId"}, "mesh_index": {"const": 0}, "primitive_index": {"const": 0}, + "first_index": {"$ref": "#/$defs/safeInteger"}, "index_count": {"$ref": "#/$defs/positiveInteger"} + }} + } +} diff --git a/SimpleCADAPI/src/simplecadapi/scene/contracts/schemas/evaluated-properties-profile-1.schema.json b/SimpleCADAPI/src/simplecadapi/scene/contracts/schemas/evaluated-properties-profile-1.schema.json new file mode 100644 index 0000000..fc23111 --- /dev/null +++ b/SimpleCADAPI/src/simplecadapi/scene/contracts/schemas/evaluated-properties-profile-1.schema.json @@ -0,0 +1,253 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://simplecad.dev/schemas/evaluated-properties-profile-1.schema.json", + "title": "SimpleCAD OCP Evaluated Properties Profile 1", + "const": { + "profile_id": "ocp-evaluated-properties-1", + "profile_kind": "evaluated_properties", + "pseudocode_hash": "sha256:3d134fae3ea80d6d23772fccea761385e34e4b58dad5f2519375a6e52bf6d1ef", + "pseudocode_uri": "pseudocode/ocp-evaluated-properties-1.md", + "rules": { + "implementation_status": { + "phase": "phase_a_target_profile", + "scene_compiler": "phase_b_not_implemented", + "grounding_modules": ["simplecadapi.kernel.ocp_properties", "simplecadapi.operations", "simplecadapi.serializer", "simplecadapi.product"], + "required_ocp_binding": "cadquery-ocp>=7.9.3.1", + "target_deltas_from_current_helpers": ["serializer resolver accepts score ties; this target requires a unique passing score", "product edge frame falls back to +X when endpoints are missing; this target requires a null frame"] + }, + "shape_preparation": { + "location_read": "TopoDS_Shape.Location()", + "identity_location_constructor": "TopLoc_Location()", + "unlocated_shape_call": "shape.Located(TopLoc_Location(), False)", + "location_transform_call": "location.Transformation()", + "bake_constructor": "BRepBuilderAPI_Transform(unlocated_shape, location.Transformation(), True, False)", + "bake_build_call": "BRepBuilderAPI_Transform.Build()", + "bake_result_call": "BRepBuilderAPI_Transform.Shape()", + "edge_orientation_call": "edge.Oriented(TopAbs_FORWARD)", + "edge_orientation_order": "force_forward_before_location_bake", + "face_orientation": "retain_and_map_TopAbs_Orientation" + }, + "analytic_classification": { + "curve_adaptor_constructor": "BRepAdaptor_Curve(definition_local_forward_edge)", + "surface_adaptor_constructor": "BRepAdaptor_Surface(definition_local_face, True)", + "curve_type_call": "BRepAdaptor_Curve.GetType()", + "surface_type_call": "BRepAdaptor_Surface.GetType()", + "curve_type_map": { + "GeomAbs_Line": {"scene_type": "line", "extractor": "BRepAdaptor_Curve.Line()"}, + "GeomAbs_Circle": {"scene_type": "circle", "extractor": "BRepAdaptor_Curve.Circle()"}, + "GeomAbs_Ellipse": {"scene_type": "ellipse", "extractor": "BRepAdaptor_Curve.Ellipse()"}, + "GeomAbs_BSplineCurve": {"scene_type": "bspline_curve", "extractor": "BRepAdaptor_Curve.BSpline()"}, + "GeomAbs_Hyperbola": {"scene_type": "other_curve", "engine_type": "GeomAbs_Hyperbola"}, + "GeomAbs_Parabola": {"scene_type": "other_curve", "engine_type": "GeomAbs_Parabola"}, + "GeomAbs_BezierCurve": {"scene_type": "other_curve", "engine_type": "GeomAbs_BezierCurve"}, + "GeomAbs_OffsetCurve": {"scene_type": "other_curve", "engine_type": "GeomAbs_OffsetCurve"}, + "GeomAbs_OtherCurve": {"scene_type": "other_curve", "engine_type": "GeomAbs_OtherCurve"} + }, + "surface_type_map": { + "GeomAbs_Plane": {"scene_type": "plane", "extractor": "BRepAdaptor_Surface.Plane()"}, + "GeomAbs_Cylinder": {"scene_type": "cylinder", "extractor": "BRepAdaptor_Surface.Cylinder()"}, + "GeomAbs_Cone": {"scene_type": "cone", "extractor": "BRepAdaptor_Surface.Cone()"}, + "GeomAbs_Sphere": {"scene_type": "sphere", "extractor": "BRepAdaptor_Surface.Sphere()"}, + "GeomAbs_Torus": {"scene_type": "torus", "extractor": "BRepAdaptor_Surface.Torus()"}, + "GeomAbs_BSplineSurface": {"scene_type": "bspline_surface", "extractor": "BRepAdaptor_Surface.BSpline()"}, + "GeomAbs_BezierSurface": {"scene_type": "other_surface", "engine_type": "GeomAbs_BezierSurface"}, + "GeomAbs_SurfaceOfRevolution": {"scene_type": "other_surface", "engine_type": "GeomAbs_SurfaceOfRevolution"}, + "GeomAbs_SurfaceOfExtrusion": {"scene_type": "other_surface", "engine_type": "GeomAbs_SurfaceOfExtrusion"}, + "GeomAbs_OffsetSurface": {"scene_type": "other_surface", "engine_type": "GeomAbs_OffsetSurface"}, + "GeomAbs_OtherSurface": {"scene_type": "other_surface", "engine_type": "GeomAbs_OtherSurface"} + }, + "curve_value_calls": { + "line": ["gp_Lin.Location()", "gp_Lin.Direction()"], + "circle": ["gp_Circ.Location()", "gp_Circ.Axis().Direction()", "gp_Circ.XAxis().Direction()", "gp_Circ.Radius()"], + "ellipse": ["gp_Elips.Location()", "gp_Elips.Axis().Direction()", "gp_Elips.XAxis().Direction()", "gp_Elips.MajorRadius()", "gp_Elips.MinorRadius()"] + }, + "surface_value_calls": { + "plane": ["gp_Pln.Location()", "gp_Pln.Axis().Direction()", "gp_Pln.XAxis().Direction()"], + "cylinder": ["gp_Cylinder.Location()", "gp_Cylinder.Axis().Direction()", "gp_Cylinder.XAxis().Direction()", "gp_Cylinder.Radius()"], + "cone": ["gp_Cone.Location()", "gp_Cone.Axis().Direction()", "gp_Cone.XAxis().Direction()", "gp_Cone.RefRadius()", "gp_Cone.SemiAngle()"], + "sphere": ["gp_Sphere.Location()", "gp_Sphere.Position().Direction()", "gp_Sphere.XAxis().Direction()", "gp_Sphere.Radius()"], + "torus": ["gp_Torus.Location()", "gp_Torus.Axis().Direction()", "gp_Torus.XAxis().Direction()", "gp_Torus.MajorRadius()", "gp_Torus.MinorRadius()"] + }, + "angle_conversion": "semi_angle_degrees = math.degrees(gp_Cone.SemiAngle())", + "direction_policy": "normalize_binary64_reject_zero_or_non_finite", + "axis_convention": "normal_or_axis_is_local_positive_z_x_direction_is_local_positive_x_and_y_is_normalize(cross(z,x))", + "axis_orthogonality": "abs(dot(z,x)) <= 1e-12", + "radius_constraints": "circle_cylinder_sphere_radius_positive; ellipse_torus_major_radius_at_least_minor_radius_positive; cone_reference_radius_nonnegative", + "cone_angle_constraint": "0 < semi_angle_degrees < 90", + "face_orientation_map": {"TopAbs_FORWARD": "forward", "TopAbs_REVERSED": "reversed", "TopAbs_INTERNAL": "internal", "TopAbs_EXTERNAL": "external"} + }, + "bspline_validity": { + "curve_summary_calls": ["Geom_BSplineCurve.Degree()", "Geom_BSplineCurve.IsRational()", "Geom_BSplineCurve.IsPeriodic()", "Geom_BSplineCurve.NbPoles()", "Geom_BSplineCurve.NbKnots()"], + "curve_validation_calls": ["Geom_BSplineCurve.Knot(one_based_index)", "Geom_BSplineCurve.Multiplicity(one_based_index)"], + "surface_summary_calls": ["Geom_BSplineSurface.UDegree()", "Geom_BSplineSurface.VDegree()", "Geom_BSplineSurface.IsURational()", "Geom_BSplineSurface.IsVRational()", "Geom_BSplineSurface.IsUPeriodic()", "Geom_BSplineSurface.IsVPeriodic()", "Geom_BSplineSurface.NbUPoles()", "Geom_BSplineSurface.NbVPoles()", "Geom_BSplineSurface.NbUKnots()", "Geom_BSplineSurface.NbVKnots()"], + "surface_validation_calls": ["Geom_BSplineSurface.UKnot(one_based_index)", "Geom_BSplineSurface.UMultiplicity(one_based_index)", "Geom_BSplineSurface.VKnot(one_based_index)", "Geom_BSplineSurface.VMultiplicity(one_based_index)"], + "maximum_degree_call": "Geom_BSplineCurve.MaxDegree_s() and Geom_BSplineSurface.MaxDegree_s()", + "maximum_degree": 25, + "minimum_degree": 1, + "minimum_poles": 2, + "minimum_unique_knots": 2, + "knot_order": "strictly_increasing_finite", + "interior_multiplicity": "1_to_degree_inclusive", + "nonperiodic_endpoint_multiplicity": "1_to_degree_plus_one_inclusive", + "nonperiodic_count_equation": "sum(multiplicities) == poles_count + degree + 1", + "periodic_endpoint_rule": "first_multiplicity == last_multiplicity", + "periodic_count_equation": "sum(multiplicities) - first_multiplicity == poles_count", + "surface_direction_rule": "apply_curve_degree_pole_knot_rules_independently_to_u_and_v" + }, + "properties": { + "bounds_constructor": "Bnd_Box()", + "bounds_gap_call": "Bnd_Box.SetGap(0.0)", + "bounds_add_call": "BRepBndLib.AddOptimal_s(shape, box, False, False)", + "bounds_flags": {"useTriangulation": false, "useShapeTolerance": false}, + "bounds_read_call": "Bnd_Box.Get()", + "props_constructor": "GProp_GProps()", + "edge_call": "BRepGProp.LinearProperties_s(edge, props, False, False)", + "edge_flags": {"SkipShared": false, "UseTriangulation": false}, + "face_call": "BRepGProp.SurfaceProperties_s(face, props, False, False)", + "face_flags": {"SkipShared": false, "UseTriangulation": false}, + "solid_volume_call": "BRepGProp.VolumeProperties_s(solid, volume_props, False, False, False)", + "solid_volume_flags": {"OnlyClosed": false, "SkipShared": false, "UseTriangulation": false}, + "solid_surface_call": "BRepGProp.SurfaceProperties_s(solid, surface_props, False, False)", + "solid_surface_flags": {"SkipShared": false, "UseTriangulation": false}, + "mass_call": "GProp_GProps.Mass()", + "centroid_call": "GProp_GProps.CentreOfMass()", + "vertex_call": "BRep_Tool.Pnt_s(vertex)", + "quality": "kernel_evaluated", + "non_finite_policy": "reject", + "zero_mass_centroid_policy": "reject_entity" + }, + "metadata_projection": { + "input": "evaluated_entity_top_level_metadata_object", + "excluded_exact_keys": ["graph", "topo_ref", "track", "source_sketch", "sketch_solve", "sketch_promotion"], + "excluded_prefixes": ["_"], + "retained_examples": ["geo", "std.*"], + "accepted_scalars": ["null", "boolean", "string", "safe_integer", "finite_binary64"], + "safe_integer_range": [-9007199254740991, 9007199254740991], + "accepted_containers": ["list", "tuple_to_array", "string_keyed_object"], + "rejected": ["cycle", "bytes", "set", "non_string_key", "non_finite_number", "unknown_runtime_value"], + "coercion": "none_except_tuple_to_array", + "failure": "fail_compilation_with_metadata_path", + "object_key_order": "RFC8785_JCS" + }, + "geo_exact_selector": { + "constructor_symbol": "simplecadapi.operations._make_geo_selector", + "metadata_geo_normalizer_symbol": "simplecadapi.operations._jsonable_geo_value", + "resolver_score_symbol": "simplecadapi.serializer._geo_selector_score", + "mode": "geo_exact", + "selector_required_common_fields": ["mode", "kind", "metadata_geo"], + "selector_optional_common_fields": ["bbox", "geom_type"], + "selector_vertex_fields": ["coordinates"], + "selector_edge_fields": ["length", "center", "start", "end"], + "selector_face_fields": ["area", "center", "normal", "edge_count", "inner_wire_count"], + "selector_solid_fields": ["volume", "center"], + "bbox_api": "BRepBndLib.AddOptimal_s(shape, box, False, False) after Bnd_Box.SetGap(0.0)", + "edge_payload_calls": ["Edge.get_length()", "Edge.get_center()", "Edge.get_start_vertex().get_coordinates()", "Edge.get_end_vertex().get_coordinates()"], + "face_payload_calls": ["Face.get_area()", "Face.get_center()", "Face.get_normal_at(0.5, 0.5)", "Face.get_edges()", "Face.get_inner_wires()"], + "face_normal_calls": ["BRepAdaptor_Surface(face, True)", "BRepAdaptor_Surface.FirstUParameter()", "BRepAdaptor_Surface.LastUParameter()", "BRepAdaptor_Surface.FirstVParameter()", "BRepAdaptor_Surface.LastVParameter()", "BRepLProp_SLProps(adaptor, midpoint_u, midpoint_v, 1, 1e-7)", "BRepLProp_SLProps.IsNormalDefined()", "BRepLProp_SLProps.Normal()"], + "face_normal_orientation": "reverse_when_face.Orientation() == TopAbs_REVERSED", + "vertex_payload_call": "Vertex.get_coordinates()", + "solid_payload_calls": ["Solid.get_volume()", "Solid.get_center()"], + "source_shape_argument": "accepted_but_not_serialized_or_scored", + "metadata_geo_normalization": ["null_string_boolean_unchanged", "numpy_integer_to_int", "numpy_floating_to_float", "python_int_float_unchanged", "to_tuple_result_to_recursive_float_array", "xyz_attributes_to_float_vec3", "dict_keys_to_string_then_recursive_values", "list_tuple_to_recursive_array", "other_value_to_str"], + "metadata_geo_postcondition": "RFC8785_domain_or_fail_compilation", + "source_index_serialization": "forbidden", + "candidate_domain": "owner_part_body_all_candidates_of_exact_entity_kind", + "distance": "sqrt((ax-bx)^2 + (ay-by)^2 + (az-bz)^2)", + "bbox_score": "distance(actual_min, expected_min) + distance(actual_max, expected_max)", + "kind_mismatch_score": 1000000000000, + "absent_bbox_score": 0, + "invalid_or_failed_bbox_score": 1000000, + "missing_vector_distance": 1000000, + "geometry_type_mismatch_penalty": 1000000, + "base_score": "10 * bbox_score", + "vertex_formula": "base + 10*distance(coordinates, selector.coordinates)", + "edge_formula": "base + (10*abs(length-selector.length) if length key exists else 0) + 10*distance(center,selector.center) + (min(distance(start,selector.start)+distance(end,selector.end),distance(start,selector.end)+distance(end,selector.start)) if both selector endpoints and candidate endpoints are available else 0)", + "face_formula": "base + (abs(area-selector.area) if area key exists else 0) + 10*distance(center,selector.center) + 5*distance(normal,selector.normal) + (10*abs(edge_count-selector.edge_count) if edge_count key exists else 0) + (10*abs(inner_wire_count-selector.inner_wire_count) if inner_wire_count key exists else 0)", + "solid_formula": "base + (abs(volume-selector.volume) if volume key exists else 0)", + "metadata_geo_score_contribution": 0, + "candidate_index_score_contribution": 0, + "threshold": 0.0001, + "threshold_comparison": "score <= threshold", + "unique_match_rule": "exactly_one_candidate_at_or_below_threshold_and_every_other_candidate_above_threshold", + "zero_match_status": "selector_unstable", + "multiple_match_status": "selector_ambiguous", + "non_finite_score_policy": "reject" + }, + "connector_frame": { + "placement_symbol": "simplecadapi.product.Placement", + "normalization": "binary64_euclidean", + "face_origin": "selector.center", + "face_z_axis": "normalize(selector.normal)", + "edge_origin": "selector.center", + "edge_z_axis": "normalize(selector.end - selector.start)", + "edge_undefined": "missing_endpoint_or_coincident_endpoints", + "vertex_origin": "selector.coordinates", + "vertex_axes": {"x_axis": [1, 0, 0], "y_axis": [0, 1, 0], "z_axis": [0, 0, 1]}, + "solid_frame": null, + "axis_seeds": [[1, 0, 0], [0, 1, 0], [0, 0, 1]], + "axis_seed_score": "abs(dot(z_axis, seed))", + "axis_seed_selection": "strict_minimum_scan_in_list_order", + "axis_seed_tie_break": "first_seed_X_then_Y_then_Z", + "x_axis_formula": "normalize(seed - z_axis*dot(z_axis,seed))", + "y_axis_formula": "normalize(cross(z_axis,x_axis))", + "flip_storage": "store_unflipped", + "binding_reason_precedence": ["not_applicable", "owner_not_part", "source_not_model", "frame_undefined", "selector_ambiguous", "selector_unstable", "supported"] + }, + "topology": { + "accepted_root": "single_manifold_solid_or_compound_of_disjoint_manifold_solids", + "rejected_roots": ["standalone_vertex", "standalone_edge", "standalone_wire", "standalone_face", "standalone_shell", "compsolid", "shared_face_between_solids", "free_boundary", "nonmanifold_face_owner"], + "face_owner_count": 1, + "solid_parent_count": 0, + "solid_min_face_children": 1, + "face_min_edge_children": 1, + "edge_min_face_parents": 1, + "edge_vertex_children": [1, 2], + "vertex_min_edge_parents": 1, + "vertex_child_count": 0, + "allow_edge_many_faces": true, + "serialize_coedge_orientation": false, + "reciprocal_adjacency_required": true, + "adjacency_sort": "unsigned_utf8_entity_id", + "adjacency_duplicates": "forbidden" + }, + "canonical_labeling": { + "hash": "SHA-256", + "serialization": "RFC8785_JCS_UTF8", + "initial_record_fields": ["kind", "geometry", "properties", "source", "sdk_connector_frame", "render_status", "connector_binding_status", "semantic_binding_ids", "evaluated_tags", "sdk_metadata", "render_key"], + "initial_record_scope": "internal_canonicalization_record_not_serialized_entity_object", + "render_key_null_kinds": ["solid", "vertex", "degenerate_edge"], + "initial_partition": "group_by_sha256_of_initial_record", + "refinement_signature": "current_label_then_sorted_parent_label_multiset_then_sorted_child_label_multiset", + "initial_cell_order": "initial_label_bytes", + "refinement": "simultaneous_sha256_split_each_existing_cell_without_merging_until_no_cell_splits", + "refined_subcell_order": "next_label_bytes", + "individualized_cell": "first_non_singleton_cell_in_current_partition_order", + "individualized_label": "sha256(0x49_then_big_endian_uint64_depth_then_current_label_then_initial_record_bytes)", + "individualized_split_order": "candidate_singleton_then_remainder", + "candidate_order": "initial_record_bytes_then_refined_label_bytes", + "exact_candidate_ties": "arbitrary_order_but_evaluate_every_branch", + "search": "depth_first_individualize_then_refine_all_candidates", + "complete_graph_encoding": "JCS_ordered_initial_records_with_parent_and_child_canonical_indices", + "label_internal_encoding": "32_raw_SHA256_bytes", + "label_jcs_encoding": "lowercase_64_hex_without_prefix", + "search_initial_depth": 0, + "recursive_depth": "increment_by_one_after_each_individualization", + "complete_record_fields": ["child_indices", "initial_record", "parent_indices"], + "complete_adjacency_index_order": "ascending_integer", + "winner": "lexicographically_least_complete_graph_encoding_bytes", + "maximum_states": 1000000, + "budget_error": "entity_canonicalization_budget_exceeded", + "kind_order": ["solid", "face", "edge", "vertex"], + "id_assignment": "within_each_kind_filter_winning_permutation_then_assign_zero_based_ordinal", + "entity_id_template": "entity/{kind}/{ordinal}", + "entity_output_sort": "unsigned_utf8_entity_id", + "set_like_array_sort": "unsigned_utf8_bytes" + }, + "compatibility": { + "normative_change": "requires_new_profile_id", + "cache_effect": "mandatory_cache_miss" + } + }, + "schema_version": "1.0" + } +} diff --git a/SimpleCADAPI/src/simplecadapi/scene/contracts/schemas/normalized-product-1.schema.json b/SimpleCADAPI/src/simplecadapi/scene/contracts/schemas/normalized-product-1.schema.json new file mode 100644 index 0000000..d9fcce3 --- /dev/null +++ b/SimpleCADAPI/src/simplecadapi/scene/contracts/schemas/normalized-product-1.schema.json @@ -0,0 +1,81 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://simplecad.dev/schemas/normalized-product-1.schema.json", + "title": "SimpleCAD Normalized Product Definition 1", + "oneOf": [{"$ref": "#/$defs/part"}, {"$ref": "#/$defs/assembly"}], + "$defs": { + "safeInteger": {"type": "integer", "minimum": 0, "maximum": 9007199254740991}, + "productId": {"type": "string", "pattern": "^[A-Za-z][A-Za-z0-9_.:-]*$"}, + "structuralId": {"type": "string", "minLength": 1, "pattern": "^[A-Za-z0-9._~%:/-]+$"}, + "vec3": {"type": "array", "prefixItems": [{"type": "number"}, {"type": "number"}, {"type": "number"}], "minItems": 3, "maxItems": 3}, + "transform": {"type": "object", "additionalProperties": false, "required": ["origin", "x_axis", "y_axis", "z_axis"], "properties": { + "origin": {"$ref": "#/$defs/vec3"}, "x_axis": {"$ref": "#/$defs/vec3"}, "y_axis": {"$ref": "#/$defs/vec3"}, "z_axis": {"$ref": "#/$defs/vec3"} + }}, + "modelBodySource": {"type": "object", "additionalProperties": false, "required": ["kind", "graph_id", "node_id", "output_slot"], "properties": { + "kind": {"const": "model_output"}, "graph_id": {"$ref": "#/$defs/productId"}, "node_id": {"$ref": "#/$defs/productId"}, "output_slot": {"$ref": "#/$defs/safeInteger"} + }}, + "manualBodySource": {"type": "object", "additionalProperties": false, "required": ["kind", "source_id"], "properties": { + "kind": {"const": "manual"}, "source_id": {"$ref": "#/$defs/productId"} + }}, + "bodySource": {"oneOf": [{"$ref": "#/$defs/modelBodySource"}, {"$ref": "#/$defs/manualBodySource"}]}, + "material": {"type": "object", "additionalProperties": false, "required": ["material_id", "name", "density", "density_unit", "color", "metadata"], "properties": { + "material_id": {"$ref": "#/$defs/productId"}, "name": {"type": ["string", "null"]}, + "density": {"type": ["number", "null"]}, "density_unit": {"type": ["string", "null"]}, + "color": {"oneOf": [{"type": "null"}, {"type": "array", "prefixItems": [ + {"type": "number", "minimum": 0, "maximum": 1}, {"type": "number", "minimum": 0, "maximum": 1}, {"type": "number", "minimum": 0, "maximum": 1} + ], "minItems": 3, "maxItems": 3}]}, "metadata": {"type": "object"} + }}, + "geometryRef": {"type": "object", "additionalProperties": false, "required": ["kind", "source_node_id", "geo_selector", "flip"], "properties": { + "kind": {"enum": ["face", "edge", "vertex"]}, "source_node_id": {"type": ["string", "null"]}, "geo_selector": {"type": "object"}, "flip": {"type": "boolean"} + }}, + "geometryAnchor": {"type": "object", "additionalProperties": false, "required": ["anchor_kind", "geometry_ref"], "properties": { + "anchor_kind": {"const": "geometry"}, "geometry_ref": {"$ref": "#/$defs/geometryRef"} + }}, + "placementAnchor": {"type": "object", "additionalProperties": false, "required": ["anchor_kind", "placement"], "properties": { + "anchor_kind": {"const": "placement"}, "placement": {"$ref": "#/$defs/transform"} + }}, + "forwardedAnchor": {"type": "object", "additionalProperties": false, "required": ["anchor_kind", "source_component_id", "source_connector_id", "offset"], "properties": { + "anchor_kind": {"const": "forwarded"}, "source_component_id": {"$ref": "#/$defs/productId"}, "source_connector_id": {"$ref": "#/$defs/productId"}, + "offset": {"oneOf": [{"type": "null"}, {"$ref": "#/$defs/transform"}]} + }}, + "connector": {"type": "object", "additionalProperties": false, "required": ["connector_id", "name", "anchor"], "properties": { + "connector_id": {"$ref": "#/$defs/productId"}, "name": {"type": ["string", "null"]}, + "anchor": {"oneOf": [{"$ref": "#/$defs/geometryAnchor"}, {"$ref": "#/$defs/placementAnchor"}, {"$ref": "#/$defs/forwardedAnchor"}]}, + "geometry_ref": {"oneOf": [{"type": "null"}, {"$ref": "#/$defs/geometryRef"}]} + }, "allOf": [ + {"if": {"properties": {"anchor": {"properties": {"anchor_kind": {"const": "geometry"}}, "required": ["anchor_kind"]}}, "required": ["anchor"]}, "then": {"required": ["geometry_ref"]}}, + {"if": {"properties": {"anchor": {"properties": {"anchor_kind": {"enum": ["placement", "forwarded"]}}, "required": ["anchor_kind"]}}, "required": ["anchor"]}, "then": {"not": {"required": ["geometry_ref"]}}} + ]}, + "component": {"type": "object", "additionalProperties": false, "required": ["component_id", "name", "definition_ref", "local_placement"], "properties": { + "component_id": {"$ref": "#/$defs/productId"}, "name": {"type": ["string", "null"]}, "definition_ref": {"$ref": "#/$defs/structuralId"}, "local_placement": {"$ref": "#/$defs/transform"} + }}, + "connectorRef": {"type": "object", "additionalProperties": false, "required": ["component_id", "connector_id"], "properties": { + "component_id": {"$ref": "#/$defs/productId"}, "connector_id": {"$ref": "#/$defs/productId"} + }}, + "limit": {"type": "object", "additionalProperties": false, "required": ["lower_value", "upper_value"], "properties": { + "lower_value": {"type": "number"}, "upper_value": {"type": "number"} + }}, + "constraint": {"type": "object", "additionalProperties": false, "required": [ + "constraint_id", "constraint_kind", "connector_a", "connector_b", "drive_distance", "distance_limit", "drive_angle_degrees", "angle_limit", + "pitch_radius_a", "pitch_radius_b", "pulley_radius_a", "pulley_radius_b", "pitch_radius", "phase_offset", "name" + ], "properties": { + "constraint_id": {"$ref": "#/$defs/productId"}, "constraint_kind": {"enum": ["fixed", "revolute", "prismatic", "gear", "belt", "rack_pinion"]}, + "connector_a": {"$ref": "#/$defs/connectorRef"}, "connector_b": {"$ref": "#/$defs/connectorRef"}, + "drive_distance": {"type": ["number", "null"]}, "distance_limit": {"oneOf": [{"type": "null"}, {"$ref": "#/$defs/limit"}]}, + "drive_angle_degrees": {"type": ["number", "null"]}, "angle_limit": {"oneOf": [{"type": "null"}, {"$ref": "#/$defs/limit"}]}, + "pitch_radius_a": {"type": ["number", "null"]}, "pitch_radius_b": {"type": ["number", "null"]}, + "pulley_radius_a": {"type": ["number", "null"]}, "pulley_radius_b": {"type": ["number", "null"]}, + "pitch_radius": {"type": ["number", "null"]}, "phase_offset": {"type": ["number", "null"]}, "name": {"type": ["string", "null"]} + }}, + "part": {"type": "object", "additionalProperties": false, "required": ["kind", "part_id", "body_source", "name", "material", "connectors", "metadata"], "properties": { + "kind": {"const": "part"}, "part_id": {"$ref": "#/$defs/productId"}, "body_source": {"$ref": "#/$defs/bodySource"}, "name": {"type": ["string", "null"]}, + "material": {"oneOf": [{"type": "null"}, {"$ref": "#/$defs/material"}]}, "connectors": {"type": "array", "items": {"$ref": "#/$defs/connector"}}, "metadata": {"type": "object"} + }}, + "assembly": {"type": "object", "additionalProperties": false, "required": ["kind", "assembly_id", "name", "components", "connectors", "constraints", "grounded_component_ids", "metadata"], "properties": { + "kind": {"const": "assembly"}, "assembly_id": {"$ref": "#/$defs/productId"}, "name": {"type": ["string", "null"]}, + "components": {"type": "array", "items": {"$ref": "#/$defs/component"}}, "connectors": {"type": "array", "items": {"$ref": "#/$defs/connector"}}, + "constraints": {"type": "array", "items": {"$ref": "#/$defs/constraint"}}, "grounded_component_ids": {"type": "array", "items": {"$ref": "#/$defs/productId"}, "uniqueItems": true}, + "metadata": {"type": "object"} + }} + } +} diff --git a/SimpleCADAPI/src/simplecadapi/scene/contracts/schemas/presentation-1.0.schema.json b/SimpleCADAPI/src/simplecadapi/scene/contracts/schemas/presentation-1.0.schema.json new file mode 100644 index 0000000..5ceb4b4 --- /dev/null +++ b/SimpleCADAPI/src/simplecadapi/scene/contracts/schemas/presentation-1.0.schema.json @@ -0,0 +1,51 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://simplecad.dev/schemas/presentation-1.0.schema.json", + "title": "SimpleCAD Scene Presentation 1.0", + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "presentation_id", "source_scene_id", "appearances", "node_overrides", "cameras"], + "properties": { + "schema_version": {"const": "1.0"}, + "presentation_id": {"$ref": "#/$defs/logicalId"}, + "source_scene_id": {"$ref": "#/$defs/logicalId"}, + "appearances": {"type": "array", "items": {"$ref": "#/$defs/appearance"}}, + "node_overrides": {"type": "array", "items": {"$ref": "#/$defs/nodeOverride"}}, + "cameras": {"type": "array", "items": {"$ref": "#/$defs/camera"}} + }, + "$defs": { + "logicalId": {"type": "string", "pattern": "^[A-Za-z][A-Za-z0-9_.-]{0,127}$"}, + "structuralId": {"type": "string", "minLength": 1, "pattern": "^[A-Za-z0-9._~%:/-]+$"}, + "positive": {"type": "number", "exclusiveMinimum": 0}, + "vec3": {"type": "array", "prefixItems": [{"type": "number"}, {"type": "number"}, {"type": "number"}], "minItems": 3, "maxItems": 3}, + "color": {"type": "array", "prefixItems": [ + {"type": "number", "minimum": 0, "maximum": 1}, {"type": "number", "minimum": 0, "maximum": 1}, + {"type": "number", "minimum": 0, "maximum": 1}, {"const": 1} + ], "minItems": 4, "maxItems": 4}, + "transform": {"type": "object", "additionalProperties": false, "required": ["origin", "x_axis", "y_axis", "z_axis"], "properties": { + "origin": {"$ref": "#/$defs/vec3"}, "x_axis": {"$ref": "#/$defs/vec3"}, "y_axis": {"$ref": "#/$defs/vec3"}, "z_axis": {"$ref": "#/$defs/vec3"} + }}, + "appearance": {"type": "object", "additionalProperties": false, "required": ["name", "base_color", "metallic", "roughness", "alpha_mode", "double_sided", "edge_color"], "properties": { + "name": {"type": "string", "minLength": 1}, "base_color": {"$ref": "#/$defs/color"}, + "metallic": {"type": "number", "minimum": 0, "maximum": 1}, "roughness": {"type": "number", "minimum": 0, "maximum": 1}, + "alpha_mode": {"const": "opaque"}, "double_sided": {"type": "boolean"}, "edge_color": {"$ref": "#/$defs/color"} + }}, + "nodeOverride": {"type": "object", "additionalProperties": false, "required": ["node_id"], "properties": { + "node_id": {"$ref": "#/$defs/structuralId"}, "visible": {"type": "boolean"}, "appearance_name": {"type": "string", "minLength": 1} + }, "anyOf": [{"required": ["visible"]}, {"required": ["appearance_name"]}]}, + "cameraBaseProperties": { + "name": {"type": "string", "minLength": 1}, "parent_node_id": {"oneOf": [{"type": "null"}, {"$ref": "#/$defs/structuralId"}]}, + "transform": {"$ref": "#/$defs/transform"}, "near": {"$ref": "#/$defs/positive"}, "far": {"$ref": "#/$defs/positive"} + }, + "perspective": {"type": "object", "additionalProperties": false, "required": ["name", "projection", "parent_node_id", "transform", "near", "far", "vertical_fov_degrees"], "properties": { + "name": {"type": "string", "minLength": 1}, "projection": {"const": "perspective"}, "parent_node_id": {"oneOf": [{"type": "null"}, {"$ref": "#/$defs/structuralId"}]}, + "transform": {"$ref": "#/$defs/transform"}, "near": {"$ref": "#/$defs/positive"}, "far": {"$ref": "#/$defs/positive"}, + "vertical_fov_degrees": {"type": "number", "exclusiveMinimum": 0, "exclusiveMaximum": 180} + }}, + "orthographic": {"type": "object", "additionalProperties": false, "required": ["name", "projection", "parent_node_id", "transform", "near", "far", "vertical_span"], "properties": { + "name": {"type": "string", "minLength": 1}, "projection": {"const": "orthographic"}, "parent_node_id": {"oneOf": [{"type": "null"}, {"$ref": "#/$defs/structuralId"}]}, + "transform": {"$ref": "#/$defs/transform"}, "near": {"$ref": "#/$defs/positive"}, "far": {"$ref": "#/$defs/positive"}, "vertical_span": {"$ref": "#/$defs/positive"} + }}, + "camera": {"oneOf": [{"$ref": "#/$defs/perspective"}, {"$ref": "#/$defs/orthographic"}]} + } +} diff --git a/SimpleCADAPI/src/simplecadapi/scene/contracts/schemas/profile-1.schema.json b/SimpleCADAPI/src/simplecadapi/scene/contracts/schemas/profile-1.schema.json new file mode 100644 index 0000000..9c18dab --- /dev/null +++ b/SimpleCADAPI/src/simplecadapi/scene/contracts/schemas/profile-1.schema.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://simplecad.dev/schemas/profile-1.schema.json", + "title": "SimpleCAD Profile 1 Dispatcher", + "oneOf": [ + {"$ref": "render-profile-2.schema.json"}, + {"$ref": "evaluated-properties-profile-1.schema.json"} + ] +} diff --git a/SimpleCADAPI/src/simplecadapi/scene/contracts/schemas/render-profile-2.schema.json b/SimpleCADAPI/src/simplecadapi/scene/contracts/schemas/render-profile-2.schema.json new file mode 100644 index 0000000..d357460 --- /dev/null +++ b/SimpleCADAPI/src/simplecadapi/scene/contracts/schemas/render-profile-2.schema.json @@ -0,0 +1,227 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://simplecad.dev/schemas/render-profile-2.schema.json", + "title": "SimpleCAD Scene 1.0 OCP GLB Profile 2", + "const": { + "profile_id": "scene-1.0-ocp-glb-2", + "profile_kind": "render_asset", + "pseudocode_hash": "sha256:8d13fa787d3c705627e9da92d45ed46bd18afc6dbd6daaef6da5fe0e31d5d339", + "pseudocode_uri": "pseudocode/scene-1.0-ocp-glb-2.md", + "rules": { + "implementation_status": { + "phase": "phase_b_implemented", + "scene_compiler": "simplecadapi.scene.compiler", + "grounding_modules": ["simplecadapi.kernel.ocp_mesh", "simplecadapi.scene.glb", "simplecadapi.scene.render_mesh"], + "required_ocp_binding": "cadquery-ocp>=7.9.3.1", + "target_delta_from_current_helper": "scene compiler uses deterministic serial meshing and tangential edge deflection" + }, + "toolchain_descriptor": { + "serialization": "RFC8785_JCS_UTF8", + "descriptor_fields": ["build_identity", "native_libraries", "profile_hashes", "python_executable", "python_modules", "schema_version"], + "descriptor_field_shapes": {"build_identity": "closed_object", "native_libraries": "nonempty_record_array", "profile_hashes": "two_record_array", "python_executable": "single_record", "python_modules": "nonempty_record_array", "schema_version": "string_const_1.0"}, + "schema_version": "1.0", + "record_fields": ["content_hash", "logical_name"], + "record_arrays": ["native_libraries", "profile_hashes", "python_modules"], + "single_record_fields": ["python_executable"], + "content_hash": "sha256_lowercase_hex_over_exact_file_bytes", + "profile_logical_names": ["ocp-evaluated-properties-1.profile.json", "scene-1.0-ocp-glb-2.profile.json"], + "profile_hash_input": "exact_packaged_profile_file_bytes", + "python_module_roots": ["simplecadapi.kernel.ocp_mesh", "simplecadapi.kernel.ocp_properties", "simplecadapi.operations", "simplecadapi.product", "simplecadapi.scene.canonical", "simplecadapi.scene.compiler", "simplecadapi.scene.glb", "simplecadapi.scene.glb_writer", "simplecadapi.scene.render_mesh", "simplecadapi.serializer"], + "phase_b_target_module": "simplecadapi.scene.compiler", + "phase_b_target_module_status": "implemented", + "python_modules": "registered_complete_output_affecting_transitive_source_closure_of_roots", + "python_executable": "resolved_sys.executable_regular_file_bytes", + "native_libraries": "registered_complete_OCP_binding_and_transitive_native_kernel_library_closure", + "record_array_sort": "logical_name_unsigned_utf8", + "record_logical_name_uniqueness": "required_within_each_array", + "registration_input": "trusted_build_manifest_mapping_logical_names_to_resolved_files", + "record_file_policy": "resolve_symlinks_then_require_readable_regular_file_else_fail", + "closure_policy": "registration_must_enumerate_complete_output_affecting_closures_else_toolchain_is_not_registered", + "build_identity_fields": ["ocp_bindings_version", "ocp_version", "platform_tag", "python_abi", "simplecadapi_version"], + "build_identity_value_grammar": "ASCII_[A-Za-z0-9][A-Za-z0-9._+-]{0,127}", + "excluded_inputs": ["absolute_path", "host_name", "mtime"], + "toolchain_hash": "sha256_of_descriptor_jcs_bytes", + "reproducibility_claim": "requires_exact_registered_toolchain_hash_match", + "display_versions": "informational_not_hash_substitute" + }, + "coordinate_conversion": { + "cad_length_unit": "mm", + "gltf_length_unit": "m", + "cad_to_gltf": [0.001, 0, 0, 0, 0, 0, 0.001, 0, 0, -0.001, 0, 0, 0, 0, 0, 1], + "gltf_to_cad": [1000, 0, 0, 0, 0, 0, -1000, 0, 0, 1000, 0, 0, 0, 0, 0, 1], + "matrix_convention": "row_major_column_vector" + }, + "shape_preparation": { + "location_read": "TopoDS_Shape.Location()", + "identity_location_constructor": "TopLoc_Location()", + "unlocated_shape_call": "shape.Located(TopLoc_Location(), False)", + "location_transform_call": "location.Transformation()", + "bake_constructor": "BRepBuilderAPI_Transform(unlocated_shape, location.Transformation(), True, False)", + "bake_build_call": "BRepBuilderAPI_Transform.Build()", + "bake_result_call": "BRepBuilderAPI_Transform.Shape()", + "face_orientation": "retain_TopAbs_Orientation", + "edge_orientation_call": "edge.Oriented(TopAbs_FORWARD)", + "edge_orientation_order": "force_forward_before_location_bake" + }, + "meshing": { + "cache_clear_call": "BRepTools.Clean_s(definition_local_face, False)", + "constructor": "BRepMesh_IncrementalMesh(definition_local_face, float(compile_options.linear_tolerance), False, float(compile_options.angular_tolerance), False)", + "constructor_symbol": "OCP.BRepMesh.BRepMesh_IncrementalMesh", + "linear_deflection": "compile_options.linear_tolerance", + "is_relative": false, + "angular_deflection": "compile_options.angular_tolerance", + "is_in_parallel": false, + "perform_call": "BRepMesh_IncrementalMesh.Perform()", + "thread_count": 1, + "cached_triangulation_reuse": false + }, + "edge_discretization": { + "adaptor_constructor": "BRepAdaptor_Curve(definition_local_forward_edge)", + "adaptor_symbol": "OCP.BRepAdaptor.BRepAdaptor_Curve", + "algorithm_constructor": "GCPnts_TangentialDeflection(adaptor, adaptor.FirstParameter(), adaptor.LastParameter(), float(compile_options.angular_tolerance), float(compile_options.linear_tolerance), 2, 1e-9, 1e-7)", + "algorithm_symbol": "OCP.GCPnts.GCPnts_TangentialDeflection", + "angular_deflection": "compile_options.angular_tolerance", + "curvature_deflection": "compile_options.linear_tolerance", + "first_parameter_call": "BRepAdaptor_Curve.FirstParameter()", + "last_parameter_call": "BRepAdaptor_Curve.LastParameter()", + "minimum_of_points": 2, + "parameter_tolerance": 1e-9, + "minimum_curve_length": 1e-7, + "point_count_call": "GCPnts_TangentialDeflection.NbPoints()", + "point_call": "GCPnts_TangentialDeflection.Value(one_based_index)", + "failure_policy": "constructor_exception_fails_compilation", + "iteration": "one_based_inclusive" + }, + "triangulation": { + "location_constructor": "TopLoc_Location()", + "read_call": "BRep_Tool.Triangulation_s(definition_local_face, triangulation_location, 0)", + "mesh_purpose": 0, + "missing_policy": "fail_face", + "node_count_call": "Poly_Triangulation.NbNodes()", + "node_call": "Poly_Triangulation.Node(one_based_index)", + "node_location_call": "node.Transformed(triangulation_location.Transformation())", + "triangle_count_call": "Poly_Triangulation.NbTriangles()", + "triangle_call": "Poly_Triangulation.Triangle(one_based_index).Get()", + "index_base_conversion": "subtract_one", + "face_reversed_index_permutation": [0, 2, 1], + "face_other_index_permutation": [0, 1, 2] + }, + "normal": { + "availability_call": "Poly_Triangulation.HasNormals()", + "node_normal_call": "Poly_Triangulation.Normal(one_based_node_index)", + "normal_location_call": "normal.Transformed(triangulation_location.Transformation())", + "reversed_face_call": "gp_Dir.Reversed()", + "reversed_face_condition": "face.Orientation() == TopAbs_REVERSED", + "fallback": "oriented_triangle_cross_product_after_coordinate_conversion", + "smoothing": "none_across_triangles_or_cad_faces", + "minimum_norm": 0.999999, + "maximum_norm": 1.000001 + }, + "numeric": { + "binary32_rounding": "round_to_nearest_ties_to_even", + "cross_precision": "binary64_fixed_order_no_fma", + "norm_precision": "binary64_xyz_fixed_order_correctly_rounded_sqrt", + "negative_zero": "canonicalize_to_positive_zero", + "non_finite": "reject", + "collapsed_test": "binary32_positions_promoted_to_binary64_then_fixed_cross" + }, + "canonical_blocks": { + "triangle_kind_byte": 1, + "line_kind_byte": 2, + "vertex_key": "little_endian_position_f32_bits_then_normal_f32_bits", + "position_key": "little_endian_position_f32_bits", + "triangle_rotation": "lexicographically_least_cyclic_without_reversal", + "triangle_sort": "lexicographic_local_index_triple", + "segment_order": "min_local_index_then_max_local_index", + "segment_sort": "lexicographic_deduplicated", + "block_index_encoding": "little_endian_uint16_if_local_vertex_count_at_most_65536_else_uint32", + "triangle_block": "sorted_vertex_keys_then_conditionally_sized_local_triangle_indices", + "line_block": "sorted_position_keys_then_conditionally_sized_local_segment_indices", + "render_key": "sha256(kind_byte_then_block)", + "asset_block_sort": "render_key_bytes_then_block_bytes", + "equal_block_slot_order": "canonical_entity_id_unsigned_utf8", + "equal_block_asset_bytes": "identical_blocks_are_repeated_once_per_rendered_entity_and_equal_block_permutations_are_byte_identical", + "face_empty_policy": "fail_compilation", + "edge_empty_policy": "retain_degenerate_entity_without_render_block" + }, + "entity_ranges": { + "group_fields": ["entity_id", "first_index", "group_id", "index_count", "mesh_index", "primitive_index"], + "mesh_index": 0, + "primitive_index": 0, + "face_coverage": "one_positive_group_per_face_exactly_partitions_all_triangle_indices", + "face_index_count_multiple": 3, + "edge_coverage": "one_positive_group_per_rendered_edge_exactly_partitions_all_line_indices_and_zero_groups_per_degenerate_edge", + "edge_index_count_multiple": 2, + "range_unit": "primitive_index_accessor_element_ordinal", + "group_id_assignment": "zero_based_contiguous_in_array_order", + "array_order": "first_index_ascending", + "overlap": "forbidden" + }, + "default_appearance": { + "name": null, + "source": null, + "base_color": [0.72, 0.75, 0.78, 1], + "metallic": 0, + "roughness": 0.55, + "alpha_mode": "opaque", + "double_sided": false, + "edge_color": [0.08, 0.09, 0.1, 1], + "sdk_metadata": {} + }, + "normalized_product_equivalence": { + "schema_uri": "schemas/normalized-product-1.schema.json", + "serialization": "RFC8785_JCS_UTF8", + "part_fields": ["body_source", "connectors", "kind", "material", "metadata", "name", "part_id"], + "assembly_fields": ["assembly_id", "components", "connectors", "constraints", "grounded_component_ids", "kind", "metadata", "name"], + "component_fields": ["component_id", "definition_ref", "local_placement", "name"], + "declaration_order_arrays": ["assembly.components", "assembly.connectors", "assembly.constraints", "part.connectors"], + "set_like_arrays": {"assembly.grounded_component_ids": "unsigned_utf8"}, + "material": "complete_normalized_JSON_safe_record", + "metadata": "evaluated_profile_metadata_projection", + "connector_runtime_metadata": "forbidden", + "comparison": "exact_normalized_product_jcs_bytes", + "difference_result": "typed_semantic_id_collision", + "forbidden_equivalence": ["mesh_bytes", "subset_or_at_least_same_heuristic"] + }, + "compatibility": { + "normative_change": "requires_new_profile_id", + "cache_effect": "mandatory_cache_miss" + }, + "required_characterization_cases": ["bounded_canonicalization_failure", "closed_edge", "degenerate_edge", "duplicate_geometry_with_different_metadata", "duplicate_geometry_with_different_provenance", "negative_zero", "normal_fallback", "reversed_kernel_traversal", "shared_face_rejection", "symmetric_entity"], + "glb_writer": { + "writer_id": "simplecadapi.scene.glb.scene-1.0-ocp-glb-1", + "writer_implementation": "profile_pseudocode_exact_byte_writer", + "media_type": "model/gltf-binary", + "magic": 1179937895, + "version": 2, + "chunk_order": ["JSON", "BIN"], + "json_encoding": "RFC8785_JCS_UTF8", + "json_padding_byte": 32, + "bin_padding_byte": 0, + "integer_encoding": "little_endian", + "asset_record": {"generator": "SimpleCAD Scene GLB Profile 1", "version": "2.0"}, + "root_keys": ["accessors", "asset", "bufferViews", "buffers", "meshes", "nodes", "scene", "scenes"], + "scene_skeleton": {"default_scene": 0, "nodes": [{"mesh": 0}], "scenes": [{"nodes": [0]}], "mesh_count": 1, "primitive_count": 1}, + "triangle_primitive": {"attributes": {"NORMAL": 1, "POSITION": 0}, "indices": 2, "mode": 4}, + "line_primitive": {"attributes": {"POSITION": 0}, "indices": 1, "mode": 1}, + "triangle_bin_layout": ["POSITION_float32x3", "NORMAL_float32x3", "alignment_zero_padding", "INDICES"], + "line_bin_layout": ["POSITION_float32x3", "alignment_zero_padding", "INDICES"], + "array_concatenation": "asset_block_order_with_local_indices_offset_by_prior_vertex_counts", + "array_buffer_target": 34962, + "index_buffer_target": 34963, + "float_component_type": 5126, + "uint16_component_type": 5123, + "uint16_max_vertex_count": 65536, + "uint32_component_type": 5125, + "position_accessor": ["bufferView", "componentType", "count", "max", "min", "type"], + "normal_accessor": ["bufferView", "componentType", "count", "type"], + "index_accessor": ["bufferView", "componentType", "count", "type"], + "buffer_view_fields": ["buffer", "byteLength", "byteOffset", "target"], + "buffer_fields": ["byteLength"], + "buffer_byte_length": "unpadded_bin_length", + "forbidden": ["empty_accessor", "uri_buffer", "extras", "extensions", "additional_json_fields"] + } + }, + "schema_version": "1.0" + } +} diff --git a/SimpleCADAPI/src/simplecadapi/scene/contracts/schemas/rules-1.schema.json b/SimpleCADAPI/src/simplecadapi/scene/contracts/schemas/rules-1.schema.json new file mode 100644 index 0000000..f3f6d6f --- /dev/null +++ b/SimpleCADAPI/src/simplecadapi/scene/contracts/schemas/rules-1.schema.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://simplecad.dev/schemas/rules-1.schema.json", + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "registry_id", "phases", "rules"], + "properties": { + "schema_version": {"const": "1.0"}, + "registry_id": {"const": "scene-1.0-rules"}, + "phases": {"const": ["parse", "structure", "semantic", "package", "budget"]}, + "rules": {"type": "array", "minItems": 1, "items": {"$ref": "#/$defs/rule"}} + }, + "$defs": { + "rule": {"type": "object", "additionalProperties": false, "required": ["id", "phase", "precedence", "artifacts", "pointer_policy", "summary"], "properties": { + "id": {"type": "string", "pattern": "^[a-z][a-z0-9_]*$"}, + "phase": {"enum": ["parse", "structure", "semantic", "package", "budget"]}, + "precedence": {"type": "integer", "minimum": 0}, + "artifacts": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"enum": ["scene", "entities", "presentation", "connector_binding", "normalized_product", "package", "glb", "zip"]}}, + "pointer_policy": {"enum": ["exact", "nearest_record", "root"]}, + "summary": {"type": "string", "minLength": 1} + }} + } +} diff --git a/SimpleCADAPI/src/simplecadapi/scene/contracts/schemas/scene-1.0.schema.json b/SimpleCADAPI/src/simplecadapi/scene/contracts/schemas/scene-1.0.schema.json new file mode 100644 index 0000000..972d43a --- /dev/null +++ b/SimpleCADAPI/src/simplecadapi/scene/contracts/schemas/scene-1.0.schema.json @@ -0,0 +1,398 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://simplecad.dev/schemas/scene-1.0.schema.json", + "title": "SimpleCAD Evaluated Scene 1.0", + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "extensions_used", + "extensions_required", + "extensions", + "scene_id", + "revision", + "generator", + "source", + "coordinate_system", + "compile_options", + "definitions", + "nodes", + "geometry_assets", + "edge_assets", + "appearances", + "entity_assets", + "connectors", + "cameras", + "lights", + "annotations", + "diagnostics" + ], + "properties": { + "schema_version": {"const": "1.0"}, + "extensions_used": {"type": "array", "maxItems": 0}, + "extensions_required": {"type": "array", "maxItems": 0}, + "extensions": {"type": "object", "maxProperties": 0}, + "scene_id": {"$ref": "#/$defs/logicalId"}, + "revision": {"$ref": "#/$defs/hash"}, + "generator": {"$ref": "#/$defs/generator"}, + "source": {"$ref": "#/$defs/sceneSource"}, + "presentation_source": {"$ref": "#/$defs/presentationSource"}, + "coordinate_system": {"$ref": "#/$defs/coordinateSystem"}, + "compile_options": {"$ref": "#/$defs/compileOptions"}, + "definitions": {"type": "array", "items": {"$ref": "#/$defs/definition"}}, + "nodes": {"type": "array", "items": {"$ref": "#/$defs/node"}}, + "geometry_assets": {"type": "array", "items": {"$ref": "#/$defs/geometryAsset"}}, + "edge_assets": {"type": "array", "items": {"$ref": "#/$defs/edgeAsset"}}, + "appearances": {"type": "array", "items": {"$ref": "#/$defs/appearance"}}, + "entity_assets": {"type": "array", "items": {"$ref": "#/$defs/entityAsset"}}, + "connectors": {"type": "array", "items": {"$ref": "#/$defs/connector"}}, + "cameras": {"type": "array", "items": {"$ref": "#/$defs/camera"}}, + "lights": {"type": "array", "maxItems": 0}, + "annotations": {"type": "array", "maxItems": 0}, + "diagnostics": {"type": "array", "maxItems": 0} + }, + "$defs": { + "safeInteger": {"type": "integer", "minimum": 0, "maximum": 9007199254740991}, + "number": {"type": "number"}, + "positiveNumber": {"type": "number", "exclusiveMinimum": 0}, + "logicalId": {"type": "string", "pattern": "^[A-Za-z][A-Za-z0-9_.-]{0,127}$"}, + "productId": {"type": "string", "pattern": "^[A-Za-z][A-Za-z0-9_.:-]*$"}, + "structuralId": {"type": "string", "minLength": 1, "pattern": "^[A-Za-z0-9._~%:/-]+$"}, + "hash": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"}, + "uri": {"type": "string", "minLength": 1, "pattern": "^[A-Za-z0-9][A-Za-z0-9._/-]*$"}, + "token": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._+-]{0,127}$"}, + "vec3": { + "type": "array", + "prefixItems": [{"$ref": "#/$defs/number"}, {"$ref": "#/$defs/number"}, {"$ref": "#/$defs/number"}], + "minItems": 3, + "maxItems": 3 + }, + "color": { + "type": "array", + "prefixItems": [ + {"type": "number", "minimum": 0, "maximum": 1}, + {"type": "number", "minimum": 0, "maximum": 1}, + {"type": "number", "minimum": 0, "maximum": 1}, + {"const": 1} + ], + "minItems": 4, + "maxItems": 4 + }, + "transform": { + "type": "object", + "additionalProperties": false, + "required": ["origin", "x_axis", "y_axis", "z_axis"], + "properties": { + "origin": {"$ref": "#/$defs/vec3"}, + "x_axis": {"$ref": "#/$defs/vec3"}, + "y_axis": {"$ref": "#/$defs/vec3"}, + "z_axis": {"$ref": "#/$defs/vec3"} + } + }, + "bounds": { + "type": "object", + "additionalProperties": false, + "required": ["min", "max"], + "properties": {"min": {"$ref": "#/$defs/vec3"}, "max": {"$ref": "#/$defs/vec3"}} + }, + "generator": { + "type": "object", + "additionalProperties": false, + "required": ["name", "simplecadapi_version", "ocp_version", "ocp_bindings_version", "python_abi", "platform_tag", "toolchain_hash", "profile"], + "properties": { + "name": {"const": "simplecadapi"}, + "simplecadapi_version": {"$ref": "#/$defs/token"}, + "ocp_version": {"$ref": "#/$defs/token"}, + "ocp_bindings_version": {"$ref": "#/$defs/token"}, + "python_abi": {"$ref": "#/$defs/token"}, + "platform_tag": {"$ref": "#/$defs/token"}, + "toolchain_hash": {"$ref": "#/$defs/hash"}, + "profile": {"enum": ["scene-1.0-ocp-glb-1", "scene-1.0-ocp-glb-2"]} + } + }, + "modelSource": { + "type": "object", + "additionalProperties": false, + "required": ["kind", "graph_id", "model_schema_version", "artifact_hash"], + "properties": { + "kind": {"const": "model"}, + "graph_id": {"$ref": "#/$defs/productId"}, + "model_schema_version": {"const": "2.0"}, + "artifact_hash": {"$ref": "#/$defs/hash"}, + "embedded_artifact_uri": {"const": "model/model.json"}, + "embedded_artifact_byte_length": {"$ref": "#/$defs/safeInteger"}, + "source_files": { + "type": "array", + "items": {"$ref": "#/$defs/sourceFile"}, + "maxItems": 25000 + } + }, + "dependentRequired": { + "embedded_artifact_uri": ["embedded_artifact_byte_length"], + "embedded_artifact_byte_length": ["embedded_artifact_uri"], + "source_files": ["embedded_artifact_uri", "embedded_artifact_byte_length"] + } + }, + "sourceFile": { + "type": "object", + "additionalProperties": false, + "required": ["path", "uri", "media_type", "byte_length", "content_hash"], + "properties": { + "path": {"type": "string", "minLength": 1, "maxLength": 1000, "pattern": "^[A-Za-z0-9][A-Za-z0-9._/-]*\\.py$"}, + "uri": {"$ref": "#/$defs/uri"}, + "media_type": {"const": "text/x-python; charset=utf-8"}, + "byte_length": {"$ref": "#/$defs/safeInteger"}, + "content_hash": {"$ref": "#/$defs/hash"} + } + }, + "importedSource": { + "type": "object", + "additionalProperties": false, + "required": ["kind", "format", "artifact_hash"], + "properties": { + "kind": {"const": "imported"}, + "format": {"$ref": "#/$defs/token"}, + "artifact_hash": {"$ref": "#/$defs/hash"}, + "embedded_artifact_uri": {"const": "source/source.bin"}, + "embedded_artifact_byte_length": {"$ref": "#/$defs/safeInteger"} + }, + "dependentRequired": { + "embedded_artifact_uri": ["embedded_artifact_byte_length"], + "embedded_artifact_byte_length": ["embedded_artifact_uri"] + } + }, + "manualSource": { + "type": "object", + "additionalProperties": false, + "required": ["kind", "source_id"], + "properties": {"kind": {"const": "manual"}, "source_id": {"$ref": "#/$defs/logicalId"}} + }, + "sceneSource": {"oneOf": [{"$ref": "#/$defs/modelSource"}, {"$ref": "#/$defs/importedSource"}, {"$ref": "#/$defs/manualSource"}]}, + "presentationSource": { + "type": "object", + "additionalProperties": false, + "required": ["presentation_id", "schema_version", "artifact_hash"], + "properties": { + "presentation_id": {"$ref": "#/$defs/logicalId"}, + "schema_version": {"const": "1.0"}, + "artifact_hash": {"$ref": "#/$defs/hash"}, + "embedded_artifact_uri": {"const": "presentation/presentation.json"}, + "embedded_artifact_byte_length": {"$ref": "#/$defs/safeInteger"} + }, + "dependentRequired": { + "embedded_artifact_uri": ["embedded_artifact_byte_length"], + "embedded_artifact_byte_length": ["embedded_artifact_uri"] + } + }, + "coordinateSystem": { + "type": "object", + "additionalProperties": false, + "required": ["length_unit", "handedness", "up_axis"], + "properties": {"length_unit": {"const": "mm"}, "handedness": {"const": "right"}, "up_axis": {"const": "+Z"}} + }, + "compileOptions": { + "type": "object", + "additionalProperties": false, + "required": ["linear_tolerance", "angular_tolerance", "embed_source", "embed_presentation"], + "properties": { + "linear_tolerance": {"type": "number", "exclusiveMinimum": 0, "maximum": 1000000}, + "angular_tolerance": {"type": "number", "exclusiveMinimum": 0, "maximum": 3.141592653589793}, + "embed_source": {"type": "boolean"}, + "embed_presentation": {"type": "boolean"} + } + }, + "productModelSource": { + "type": "object", "additionalProperties": false, + "required": ["kind", "root_id", "semantic_type", "semantic_id", "graph_id", "node_id", "output_slot"], + "properties": { + "kind": {"const": "product_model"}, "root_id": {"$ref": "#/$defs/logicalId"}, + "semantic_type": {"enum": ["Part", "Assembly"]}, "semantic_id": {"$ref": "#/$defs/productId"}, + "graph_id": {"$ref": "#/$defs/productId"}, "node_id": {"$ref": "#/$defs/productId"}, "output_slot": {"$ref": "#/$defs/safeInteger"} + } + }, + "productManualSource": { + "type": "object", "additionalProperties": false, + "required": ["kind", "root_id", "semantic_type", "semantic_id"], + "properties": { + "kind": {"const": "product_manual"}, "root_id": {"$ref": "#/$defs/logicalId"}, + "semantic_type": {"enum": ["Part", "Assembly"]}, "semantic_id": {"$ref": "#/$defs/productId"} + } + }, + "modelOutputSource": { + "type": "object", "additionalProperties": false, + "required": ["kind", "root_id", "graph_id", "node_id", "output_slot"], + "properties": { + "kind": {"const": "model_output"}, "root_id": {"$ref": "#/$defs/logicalId"}, + "graph_id": {"$ref": "#/$defs/productId"}, "node_id": {"$ref": "#/$defs/productId"}, "output_slot": {"$ref": "#/$defs/safeInteger"} + } + }, + "importedDefinitionSource": { + "type": "object", "additionalProperties": false, + "required": ["kind", "root_id", "source_element_id"], + "properties": {"kind": {"const": "imported"}, "root_id": {"$ref": "#/$defs/logicalId"}, "source_element_id": {"type": "string", "minLength": 1}} + }, + "manualDefinitionSource": { + "type": "object", "additionalProperties": false, + "required": ["kind", "root_id", "source_id"], + "properties": {"kind": {"const": "manual"}, "root_id": {"$ref": "#/$defs/logicalId"}, "source_id": {"$ref": "#/$defs/logicalId"}} + }, + "definitionSource": {"oneOf": [ + {"$ref": "#/$defs/productModelSource"}, {"$ref": "#/$defs/productManualSource"}, + {"$ref": "#/$defs/modelOutputSource"}, {"$ref": "#/$defs/importedDefinitionSource"}, {"$ref": "#/$defs/manualDefinitionSource"} + ]}, + "definition": { + "type": "object", "additionalProperties": false, + "required": ["definition_id", "kind", "name", "source", "sdk_metadata"], + "properties": { + "definition_id": {"$ref": "#/$defs/structuralId"}, "kind": {"enum": ["part", "assembly", "shape"]}, + "name": {"type": ["string", "null"]}, "source": {"$ref": "#/$defs/definitionSource"}, "sdk_metadata": {"type": "object"}, + "geometry_asset_id": {"$ref": "#/$defs/hash"}, "edge_asset_id": {"$ref": "#/$defs/hash"}, + "entity_asset_id": {"$ref": "#/$defs/hash"}, "appearance_id": {"$ref": "#/$defs/structuralId"} + }, + "allOf": [ + {"if": {"properties": {"kind": {"const": "assembly"}}, "required": ["kind"]}, "then": {"not": {"anyOf": [ + {"required": ["geometry_asset_id"]}, {"required": ["edge_asset_id"]}, {"required": ["entity_asset_id"]}, {"required": ["appearance_id"]} + ]}}}, + {"if": {"properties": {"kind": {"enum": ["part", "shape"]}}, "required": ["kind"]}, "then": {"required": ["geometry_asset_id", "edge_asset_id", "entity_asset_id", "appearance_id"]}} + ] + }, + "productOccurrenceSource": { + "type": "object", "additionalProperties": false, "required": ["kind", "root_id", "component_path"], + "properties": {"kind": {"const": "product_occurrence"}, "root_id": {"$ref": "#/$defs/logicalId"}, "component_path": {"type": "array", "items": {"$ref": "#/$defs/productId"}}} + }, + "shapeRootSource": { + "type": "object", "additionalProperties": false, "required": ["kind", "root_id"], + "properties": {"kind": {"const": "shape_root"}, "root_id": {"$ref": "#/$defs/logicalId"}} + }, + "node": { + "type": "object", "additionalProperties": false, + "required": ["node_id", "parent_node_id", "order", "definition_id", "name", "transform", "visible", "selectable", "appearance_override_id", "source", "sdk_metadata"], + "properties": { + "node_id": {"$ref": "#/$defs/structuralId"}, "parent_node_id": {"anyOf": [{"$ref": "#/$defs/structuralId"}, {"type": "null"}]}, + "order": {"$ref": "#/$defs/safeInteger"}, "definition_id": {"$ref": "#/$defs/structuralId"}, "name": {"type": ["string", "null"]}, + "transform": {"$ref": "#/$defs/transform"}, "visible": {"type": "boolean"}, "selectable": {"type": "boolean"}, + "appearance_override_id": {"anyOf": [{"$ref": "#/$defs/structuralId"}, {"type": "null"}]}, + "source": {"oneOf": [{"$ref": "#/$defs/productOccurrenceSource"}, {"$ref": "#/$defs/shapeRootSource"}]}, "sdk_metadata": {"type": "object"} + } + }, + "assetBase": { + "type": "object", + "properties": { + "asset_id": {"$ref": "#/$defs/hash"}, "uri": {"$ref": "#/$defs/uri"}, "media_type": {"const": "model/gltf-binary"}, + "byte_length": {"$ref": "#/$defs/safeInteger"}, "content_hash": {"$ref": "#/$defs/hash"}, "scene_local_bounds": {"$ref": "#/$defs/bounds"}, + "asset_to_scene": {"const": [1000, 0, 0, 0, 0, 0, -1000, 0, 0, 1000, 0, 0, 0, 0, 0, 1]} + } + }, + "geometryAsset": { + "type": "object", "additionalProperties": false, + "required": ["asset_id", "uri", "media_type", "byte_length", "content_hash", "scene_local_bounds", "asset_to_scene", "tessellation"], + "properties": { + "asset_id": {"$ref": "#/$defs/hash"}, "uri": {"$ref": "#/$defs/uri"}, "media_type": {"const": "model/gltf-binary"}, + "byte_length": {"$ref": "#/$defs/safeInteger"}, "content_hash": {"$ref": "#/$defs/hash"}, "scene_local_bounds": {"$ref": "#/$defs/bounds"}, + "asset_to_scene": {"const": [1000, 0, 0, 0, 0, 0, -1000, 0, 0, 1000, 0, 0, 0, 0, 0, 1]}, + "tessellation": {"type": "object", "additionalProperties": false, "required": ["linear_tolerance", "angular_tolerance"], "properties": {"linear_tolerance": {"$ref": "#/$defs/positiveNumber"}, "angular_tolerance": {"$ref": "#/$defs/positiveNumber"}}} + } + }, + "edgeAsset": { + "type": "object", "additionalProperties": false, + "required": ["asset_id", "uri", "media_type", "byte_length", "content_hash", "scene_local_bounds", "asset_to_scene", "tessellation"], + "properties": { + "asset_id": {"$ref": "#/$defs/hash"}, "uri": {"$ref": "#/$defs/uri"}, "media_type": {"const": "model/gltf-binary"}, + "byte_length": {"$ref": "#/$defs/safeInteger"}, "content_hash": {"$ref": "#/$defs/hash"}, "scene_local_bounds": {"$ref": "#/$defs/bounds"}, + "asset_to_scene": {"const": [1000, 0, 0, 0, 0, 0, -1000, 0, 0, 1000, 0, 0, 0, 0, 0, 1]}, + "tessellation": {"type": "object", "additionalProperties": false, "required": ["linear_tolerance"], "properties": {"linear_tolerance": {"$ref": "#/$defs/positiveNumber"}}} + } + }, + "entityAsset": { + "type": "object", "additionalProperties": false, "required": ["entity_asset_id", "uri", "media_type", "byte_length", "content_hash"], + "properties": { + "entity_asset_id": {"$ref": "#/$defs/hash"}, "uri": {"$ref": "#/$defs/uri"}, + "media_type": {"const": "application/vnd.simplecad.entities+json"}, "byte_length": {"$ref": "#/$defs/safeInteger"}, "content_hash": {"$ref": "#/$defs/hash"} + } + }, + "productMaterialSource": { + "type": "object", "additionalProperties": false, "required": ["kind", "root_id", "material_id"], + "properties": {"kind": {"const": "product_material"}, "root_id": {"$ref": "#/$defs/logicalId"}, "material_id": {"$ref": "#/$defs/productId"}} + }, + "presentationAppearanceSource": { + "type": "object", "additionalProperties": false, "required": ["kind", "presentation_id", "appearance_name"], + "properties": {"kind": {"const": "presentation"}, "presentation_id": {"$ref": "#/$defs/logicalId"}, "appearance_name": {"type": "string", "minLength": 1}} + }, + "appearance": { + "type": "object", "additionalProperties": false, + "required": ["appearance_id", "name", "source", "base_color", "metallic", "roughness", "alpha_mode", "double_sided", "edge_color", "sdk_metadata"], + "properties": { + "appearance_id": {"type": "string", "pattern": "^appearance/evaluated/[0-9a-f]{64}$"}, "name": {"type": ["string", "null"]}, + "source": {"oneOf": [{"type": "null"}, {"$ref": "#/$defs/productMaterialSource"}, {"$ref": "#/$defs/presentationAppearanceSource"}]}, + "base_color": {"$ref": "#/$defs/color"}, "metallic": {"type": "number", "minimum": 0, "maximum": 1}, + "roughness": {"type": "number", "minimum": 0, "maximum": 1}, "alpha_mode": {"const": "opaque"}, "double_sided": {"type": "boolean"}, + "edge_color": {"$ref": "#/$defs/color"}, "sdk_metadata": {"type": "object"} + } + }, + "modelOperationSource": { + "type": "object", "additionalProperties": false, "required": ["kind", "graph_id", "node_id", "output_slot"], + "properties": {"kind": {"const": "model_operation"}, "graph_id": {"$ref": "#/$defs/productId"}, "node_id": {"$ref": "#/$defs/productId"}, "output_slot": {"$ref": "#/$defs/safeInteger"}} + }, + "manualConnectorSource": { + "type": "object", "additionalProperties": false, "required": ["kind", "source_id"], + "properties": {"kind": {"const": "manual"}, "source_id": {"$ref": "#/$defs/logicalId"}} + }, + "connectorTarget": { + "type": "object", "additionalProperties": false, "required": ["entity_asset_id", "entity_id"], + "properties": {"entity_asset_id": {"$ref": "#/$defs/hash"}, "entity_id": {"type": "string", "pattern": "^entity/(face|edge|vertex)/(0|[1-9][0-9]*)$"}} + }, + "forwardedFrom": { + "type": "object", "additionalProperties": false, + "required": ["source_component_id", "source_definition_id", "source_connector_id", "source_connector_snapshot_id", "offset"], + "properties": { + "source_component_id": {"$ref": "#/$defs/productId"}, "source_definition_id": {"$ref": "#/$defs/structuralId"}, + "source_connector_id": {"$ref": "#/$defs/productId"}, "source_connector_snapshot_id": {"$ref": "#/$defs/structuralId"}, + "offset": {"oneOf": [{"type": "null"}, {"$ref": "#/$defs/transform"}]} + } + }, + "connector": { + "type": "object", "additionalProperties": false, + "required": ["connector_snapshot_id", "owner_definition_id", "connector_id", "name", "anchor_kind", "local_transform", "source", "sdk_metadata"], + "properties": { + "connector_snapshot_id": {"$ref": "#/$defs/structuralId"}, "owner_definition_id": {"$ref": "#/$defs/structuralId"}, + "connector_id": {"$ref": "#/$defs/productId"}, "name": {"type": ["string", "null"]}, "anchor_kind": {"enum": ["geometry", "placement", "forwarded"]}, + "local_transform": {"$ref": "#/$defs/transform"}, "target": {"$ref": "#/$defs/connectorTarget"}, "forwarded_from": {"$ref": "#/$defs/forwardedFrom"}, + "source": {"oneOf": [{"type": "null"}, {"$ref": "#/$defs/modelOperationSource"}, {"$ref": "#/$defs/manualConnectorSource"}]}, + "sdk_metadata": {"type": "object", "maxProperties": 0} + }, + "allOf": [ + {"if": {"properties": {"anchor_kind": {"const": "geometry"}}, "required": ["anchor_kind"]}, "then": {"required": ["target"], "not": {"required": ["forwarded_from"]}}}, + {"if": {"properties": {"anchor_kind": {"const": "placement"}}, "required": ["anchor_kind"]}, "then": {"not": {"anyOf": [{"required": ["target"]}, {"required": ["forwarded_from"]}]}}}, + {"if": {"properties": {"anchor_kind": {"const": "forwarded"}}, "required": ["anchor_kind"]}, "then": {"required": ["forwarded_from"], "not": {"required": ["target"]}}} + ] + }, + "cameraBase": { + "type": "object", + "properties": { + "camera_id": {"$ref": "#/$defs/structuralId"}, "name": {"type": "string", "minLength": 1}, + "parent_node_id": {"oneOf": [{"type": "null"}, {"$ref": "#/$defs/structuralId"}]}, "transform": {"$ref": "#/$defs/transform"}, + "near": {"$ref": "#/$defs/positiveNumber"}, "far": {"$ref": "#/$defs/positiveNumber"} + } + }, + "perspectiveCamera": { + "type": "object", "additionalProperties": false, + "required": ["camera_id", "name", "projection", "parent_node_id", "transform", "near", "far", "vertical_fov_degrees"], + "properties": { + "camera_id": {"$ref": "#/$defs/structuralId"}, "name": {"type": "string", "minLength": 1}, "projection": {"const": "perspective"}, + "parent_node_id": {"oneOf": [{"type": "null"}, {"$ref": "#/$defs/structuralId"}]}, "transform": {"$ref": "#/$defs/transform"}, + "near": {"$ref": "#/$defs/positiveNumber"}, "far": {"$ref": "#/$defs/positiveNumber"}, + "vertical_fov_degrees": {"type": "number", "exclusiveMinimum": 0, "exclusiveMaximum": 180} + } + }, + "orthographicCamera": { + "type": "object", "additionalProperties": false, + "required": ["camera_id", "name", "projection", "parent_node_id", "transform", "near", "far", "vertical_span"], + "properties": { + "camera_id": {"$ref": "#/$defs/structuralId"}, "name": {"type": "string", "minLength": 1}, "projection": {"const": "orthographic"}, + "parent_node_id": {"oneOf": [{"type": "null"}, {"$ref": "#/$defs/structuralId"}]}, "transform": {"$ref": "#/$defs/transform"}, + "near": {"$ref": "#/$defs/positiveNumber"}, "far": {"$ref": "#/$defs/positiveNumber"}, "vertical_span": {"$ref": "#/$defs/positiveNumber"} + } + }, + "camera": {"oneOf": [{"$ref": "#/$defs/perspectiveCamera"}, {"$ref": "#/$defs/orthographicCamera"}]} + } +} diff --git a/SimpleCADAPI/src/simplecadapi/scene/documents.py b/SimpleCADAPI/src/simplecadapi/scene/documents.py new file mode 100644 index 0000000..1eb52d3 --- /dev/null +++ b/SimpleCADAPI/src/simplecadapi/scene/documents.py @@ -0,0 +1,186 @@ +"""Immutable aggregate boundaries for validated Scene contract documents.""" + +from __future__ import annotations + +import hashlib +from dataclasses import dataclass, field +from types import MappingProxyType +from typing import Any, Callable, ClassVar, Generic, Mapping, TypeVar, cast + +from typing_extensions import Self + +from .canonical import canonical_json_bytes, parse_canonical_json +from .generated.connector_binding import ( + ConnectorBindingDocument as ConnectorBindingValue, +) +from .generated.entities import EntityDocument as EntityValue +from .generated.normalized_product import ( + NormalizedProductDocument as NormalizedProductValue, +) +from .generated.presentation import PresentationDocument as PresentationValue +from .generated.scene import SceneDocument as SceneValue +from .validation import ( + SceneContractError, + SceneValidationReport, + validate_connector_binding, + validate_entity_asset, + validate_normalized_product, + validate_presentation, + validate_scene_manifest, +) + + +DocumentValue = TypeVar("DocumentValue") + + +def _copy_json_value(value: Any, stack: set[int] | None = None) -> Any: + """Detach JSON-like input while accepting frozen mappings and tuples.""" + + if value is None or isinstance(value, (bool, int, float, str)): + return value + if stack is None: + stack = set() + if isinstance(value, Mapping): + marker = id(value) + if marker in stack: + raise ValueError("cyclic value is not JSON") + stack.add(marker) + try: + result: dict[str, Any] = {} + for key, child in value.items(): + if not isinstance(key, str): + raise TypeError("JSON object keys must be strings") + result[key] = _copy_json_value(child, stack) + return result + finally: + stack.remove(marker) + if isinstance(value, (list, tuple)): + marker = id(value) + if marker in stack: + raise ValueError("cyclic value is not JSON") + stack.add(marker) + try: + return [_copy_json_value(child, stack) for child in value] + finally: + stack.remove(marker) + raise TypeError(f"unsupported JSON value: {type(value).__name__}") + + +def _freeze_json_value(value: Any) -> Any: + if isinstance(value, dict): + return MappingProxyType( + {key: _freeze_json_value(child) for key, child in value.items()} + ) + if isinstance(value, list): + return tuple(_freeze_json_value(child) for child in value) + return value + + +@dataclass(frozen=True, slots=True, init=False) +class _ContractDocument(Generic[DocumentValue]): + _value: Mapping[str, Any] = field(repr=False, compare=False) + _canonical_bytes: bytes = field(repr=False) + _canonical_hash: str + + _validator: ClassVar[Callable[[Any], SceneValidationReport]] + + def __init__(self, value: DocumentValue | Mapping[str, Any]) -> None: + copied = _copy_json_value(value) + canonical = canonical_json_bytes(copied) + parsed = parse_canonical_json(canonical) + report = type(self)._validator(parsed) + if not report.valid: + raise SceneContractError(report) + if not isinstance(parsed, dict): # The schemas require object roots. + raise TypeError("contract document root must be a JSON object") + object.__setattr__(self, "_value", _freeze_json_value(parsed)) + object.__setattr__(self, "_canonical_bytes", canonical) + object.__setattr__( + self, + "_canonical_hash", + "sha256:" + hashlib.sha256(canonical).hexdigest(), + ) + + @classmethod + def from_value(cls, value: DocumentValue | Mapping[str, Any]) -> Self: + """Validate and detach a structural contract value.""" + + return cls(value) + + @classmethod + def parse(cls, data: bytes | bytearray | memoryview | str) -> Self: + """Parse exact canonical JSON bytes, validate them, and freeze the value.""" + + return cls(parse_canonical_json(data)) + + @property + def value(self) -> Mapping[str, Any]: + """The deeply immutable document value.""" + + return self._value + + @property + def canonical_bytes(self) -> bytes: + return self._canonical_bytes + + @property + def canonical_hash(self) -> str: + return self._canonical_hash + + def to_mutable(self) -> DocumentValue: + """Return a fresh mutable dict/list tree with no aliases to this document.""" + + return cast(DocumentValue, parse_canonical_json(self._canonical_bytes)) + + def __bytes__(self) -> bytes: + return self._canonical_bytes + + +@dataclass(frozen=True, slots=True, init=False, eq=False, repr=False) +class SceneDocument(_ContractDocument[SceneValue]): + _validator: ClassVar[Callable[[Any], SceneValidationReport]] = ( + validate_scene_manifest + ) + + @property + def revision(self) -> str: + """The validated two-pass revision carried by this immutable scene.""" + + return cast(str, self._value["revision"]) + + +@dataclass(frozen=True, slots=True, init=False, eq=False, repr=False) +class EntityDocument(_ContractDocument[EntityValue]): + _validator: ClassVar[Callable[[Any], SceneValidationReport]] = ( + validate_entity_asset + ) + + +@dataclass(frozen=True, slots=True, init=False, eq=False, repr=False) +class PresentationDocument(_ContractDocument[PresentationValue]): + _validator: ClassVar[Callable[[Any], SceneValidationReport]] = ( + validate_presentation + ) + + +@dataclass(frozen=True, slots=True, init=False, eq=False, repr=False) +class ConnectorBindingDocument(_ContractDocument[ConnectorBindingValue]): + _validator: ClassVar[Callable[[Any], SceneValidationReport]] = ( + validate_connector_binding + ) + + +@dataclass(frozen=True, slots=True, init=False, eq=False, repr=False) +class NormalizedProductDocument(_ContractDocument[NormalizedProductValue]): + _validator: ClassVar[Callable[[Any], SceneValidationReport]] = ( + validate_normalized_product + ) + + +__all__ = [ + "ConnectorBindingDocument", + "EntityDocument", + "NormalizedProductDocument", + "PresentationDocument", + "SceneDocument", +] diff --git a/SimpleCADAPI/src/simplecadapi/scene/generated/__init__.py b/SimpleCADAPI/src/simplecadapi/scene/generated/__init__.py new file mode 100644 index 0000000..9fbc2f0 --- /dev/null +++ b/SimpleCADAPI/src/simplecadapi/scene/generated/__init__.py @@ -0,0 +1,15 @@ +"""Schema-generated Scene contract document types.""" + +from .scene import SceneDocument +from .entities import EntityDocument +from .presentation import PresentationDocument +from .connector_binding import ConnectorBindingDocument +from .normalized_product import NormalizedProductDocument + +__all__ = [ + "SceneDocument", + "EntityDocument", + "PresentationDocument", + "ConnectorBindingDocument", + "NormalizedProductDocument", +] diff --git a/SimpleCADAPI/src/simplecadapi/scene/generated/connector_binding.py b/SimpleCADAPI/src/simplecadapi/scene/generated/connector_binding.py new file mode 100644 index 0000000..1dd084f --- /dev/null +++ b/SimpleCADAPI/src/simplecadapi/scene/generated/connector_binding.py @@ -0,0 +1,67 @@ +# generated by datamodel-codegen: +# filename: connector-binding-1.0.schema.json + +from __future__ import annotations + +from typing import Literal, TypedDict + +SafeInteger = int + + +LogicalId = str + + +ProductId = str + + +StructuralId = str + + +Hash = str + + +class SourceModel(TypedDict): + graph_id: ProductId + model_schema_version: Literal["2.0"] + artifact_hash: Hash + + +class SourceScene(TypedDict): + scene_id: LogicalId + revision: Hash + + +class ModelOutput(TypedDict): + kind: Literal["model_output"] + graph_id: ProductId + node_id: ProductId + output_slot: SafeInteger + + +class ModelTopology(TypedDict): + kind: Literal["model_topology"] + graph_id: ProductId + node_id: ProductId + output_slot: SafeInteger + topology_kind: Literal["FACE", "EDGE", "VERTEX"] + topo_id: str + + +class Target(TypedDict): + kind: Literal["topology_entity"] + entity_asset_id: Hash + entity_id: str + expected_source: ModelOutput | ModelTopology + flip: bool + + +class ConnectorBindingDocument(TypedDict): + schema_version: Literal["1.0"] + binding_id: LogicalId + source_model: SourceModel + source_scene: SourceScene + owner_definition_id: StructuralId + selected_occurrence_node_id: StructuralId + connector_id: ProductId + name: str | None + target: Target diff --git a/SimpleCADAPI/src/simplecadapi/scene/generated/entities.py b/SimpleCADAPI/src/simplecadapi/scene/generated/entities.py new file mode 100644 index 0000000..46b07cf --- /dev/null +++ b/SimpleCADAPI/src/simplecadapi/scene/generated/entities.py @@ -0,0 +1,275 @@ +# generated by datamodel-codegen: +# filename: entities-1.0.schema.json + +from __future__ import annotations + +from typing import Any, Literal, TypedDict + +SafeInteger = int + + +PositiveInteger = int + + +Hash = str + + +ProductId = str + + +StructuralId = str + + +EntityId = str + + +Vec3 = list[float] + + +class Transform(TypedDict): + origin: Vec3 + x_axis: Vec3 + y_axis: Vec3 + z_axis: Vec3 + + +class Bounds(TypedDict): + min: Vec3 + max: Vec3 + + +class GeometryEngine(TypedDict): + name: Literal["OpenCascade"] + version: str + profile: Literal["ocp-evaluated-properties-1"] + + +class ModelOutput(TypedDict): + kind: Literal["model_output"] + graph_id: ProductId + node_id: ProductId + output_slot: SafeInteger + + +class ModelTopology(TypedDict): + kind: Literal["model_topology"] + graph_id: ProductId + node_id: ProductId + output_slot: SafeInteger + topology_kind: Literal["SOLID", "FACE", "EDGE", "VERTEX"] + topo_id: str + + +class ImportedPrimitive(TypedDict): + kind: Literal["imported_primitive"] + source_element_id: str + + +class Unbound(TypedDict): + kind: Literal["unbound"] + + +Source = ModelOutput | ModelTopology | ImportedPrimitive | Unbound + + +class BrepSolid(TypedDict): + type: Literal["brep_solid"] + + +class Point(TypedDict): + type: Literal["point"] + position: Vec3 + + +class Line(TypedDict): + type: Literal["line"] + origin: Vec3 + direction: Vec3 + + +class Circle(TypedDict): + type: Literal["circle"] + center: Vec3 + normal: Vec3 + x_direction: Vec3 + radius: float + + +class Ellipse(TypedDict): + type: Literal["ellipse"] + center: Vec3 + normal: Vec3 + x_direction: Vec3 + major_radius: float + minor_radius: float + + +class BsplineCurve(TypedDict): + type: Literal["bspline_curve"] + degree: SafeInteger + rational: bool + periodic: bool + poles_count: SafeInteger + knots_count: SafeInteger + + +class OtherCurve(TypedDict): + type: Literal["other_curve"] + engine_type: str + + +class Plane(TypedDict): + type: Literal["plane"] + origin: Vec3 + normal: Vec3 + x_direction: Vec3 + + +class Cylinder(TypedDict): + type: Literal["cylinder"] + origin: Vec3 + axis: Vec3 + x_direction: Vec3 + radius: float + + +class Cone(TypedDict): + type: Literal["cone"] + origin: Vec3 + axis: Vec3 + x_direction: Vec3 + reference_radius: float + semi_angle_degrees: float + + +class Sphere(TypedDict): + type: Literal["sphere"] + center: Vec3 + axis: Vec3 + x_direction: Vec3 + radius: float + + +class Torus(TypedDict): + type: Literal["torus"] + center: Vec3 + axis: Vec3 + x_direction: Vec3 + major_radius: float + minor_radius: float + + +class BsplineSurface(TypedDict): + type: Literal["bspline_surface"] + u_degree: SafeInteger + v_degree: SafeInteger + u_rational: bool + v_rational: bool + u_periodic: bool + v_periodic: bool + u_poles_count: SafeInteger + v_poles_count: SafeInteger + u_knots_count: SafeInteger + v_knots_count: SafeInteger + + +class OtherSurface(TypedDict): + type: Literal["other_surface"] + engine_type: str + + +Geometry = ( + BrepSolid + | Point + | Line + | Circle + | Ellipse + | BsplineCurve + | OtherCurve + | Plane + | Cylinder + | Cone + | Sphere + | Torus + | BsplineSurface + | OtherSurface +) + + +class SolidProperties(TypedDict): + quality: Literal["kernel_evaluated"] + bounds: Bounds + volume: float + surface_area: float + centroid: Vec3 + + +class FaceProperties(TypedDict): + quality: Literal["kernel_evaluated"] + bounds: Bounds + area: float + centroid: Vec3 + orientation: Literal["forward", "reversed", "internal", "external"] + + +class EdgeProperties(TypedDict): + quality: Literal["kernel_evaluated"] + bounds: Bounds + length: float + centroid: Vec3 + + +class VertexProperties(TypedDict): + quality: Literal["kernel_evaluated"] + bounds: Bounds + position: Vec3 + + +SemanticBindingId = str + + +EvaluatedTag = str + + +class Entity(TypedDict): + entity_id: EntityId + kind: Literal["solid", "face", "edge", "vertex"] + parent_entity_ids: list[EntityId] + child_entity_ids: list[EntityId] + source: Source + geometry: Geometry + properties: SolidProperties | FaceProperties | EdgeProperties | VertexProperties + sdk_connector_frame: Transform | None + render_status: Literal["rendered", "degenerate"] + connector_binding_status: Literal[ + "not_applicable", + "owner_not_part", + "source_not_model", + "frame_undefined", + "selector_ambiguous", + "selector_unstable", + "supported", + ] + semantic_binding_ids: list[SemanticBindingId] + evaluated_tags: list[EvaluatedTag] + sdk_metadata: dict[str, Any] + + +class Group(TypedDict): + group_id: SafeInteger + entity_id: EntityId + mesh_index: Literal[0] + primitive_index: Literal[0] + first_index: SafeInteger + index_count: PositiveInteger + + +class EntityDocument(TypedDict): + schema_version: Literal["1.0"] + definition_id: StructuralId + geometry_asset_id: Hash + edge_asset_id: Hash + geometry_engine: GeometryEngine + entities: list[Entity] + face_groups: list[Group] + edge_groups: list[Group] diff --git a/SimpleCADAPI/src/simplecadapi/scene/generated/normalized_product.py b/SimpleCADAPI/src/simplecadapi/scene/generated/normalized_product.py new file mode 100644 index 0000000..058cd80 --- /dev/null +++ b/SimpleCADAPI/src/simplecadapi/scene/generated/normalized_product.py @@ -0,0 +1,145 @@ +# generated by datamodel-codegen: +# filename: normalized-product-1.schema.json + +from __future__ import annotations + +from typing import Any, Literal, TypedDict + +from typing_extensions import NotRequired + +SafeInteger = int + + +ProductId = str + + +StructuralId = str + + +Vec3 = list[float] + + +class Transform(TypedDict): + origin: Vec3 + x_axis: Vec3 + y_axis: Vec3 + z_axis: Vec3 + + +class ModelBodySource(TypedDict): + kind: Literal["model_output"] + graph_id: ProductId + node_id: ProductId + output_slot: SafeInteger + + +class ManualBodySource(TypedDict): + kind: Literal["manual"] + source_id: ProductId + + +BodySource = ModelBodySource | ManualBodySource + + +ColorItem = float + + +class Material(TypedDict): + material_id: ProductId + name: str | None + density: float | None + density_unit: str | None + color: list[ColorItem] | None + metadata: dict[str, Any] + + +class GeometryRef(TypedDict): + kind: Literal["face", "edge", "vertex"] + source_node_id: str | None + geo_selector: dict[str, Any] + flip: bool + + +class GeometryAnchor(TypedDict): + anchor_kind: Literal["geometry"] + geometry_ref: GeometryRef + + +class PlacementAnchor(TypedDict): + anchor_kind: Literal["placement"] + placement: Transform + + +class ForwardedAnchor(TypedDict): + anchor_kind: Literal["forwarded"] + source_component_id: ProductId + source_connector_id: ProductId + offset: Transform | None + + +class Connector(TypedDict): + connector_id: ProductId + name: str | None + anchor: GeometryAnchor | PlacementAnchor | ForwardedAnchor + geometry_ref: NotRequired[GeometryRef | None] + + +class Component(TypedDict): + component_id: ProductId + name: str | None + definition_ref: StructuralId + local_placement: Transform + + +class ConnectorRef(TypedDict): + component_id: ProductId + connector_id: ProductId + + +class Limit(TypedDict): + lower_value: float + upper_value: float + + +class Constraint(TypedDict): + constraint_id: ProductId + constraint_kind: Literal[ + "fixed", "revolute", "prismatic", "gear", "belt", "rack_pinion" + ] + connector_a: ConnectorRef + connector_b: ConnectorRef + drive_distance: float | None + distance_limit: Limit | None + drive_angle_degrees: float | None + angle_limit: Limit | None + pitch_radius_a: float | None + pitch_radius_b: float | None + pulley_radius_a: float | None + pulley_radius_b: float | None + pitch_radius: float | None + phase_offset: float | None + name: str | None + + +class Part(TypedDict): + kind: Literal["part"] + part_id: ProductId + body_source: BodySource + name: str | None + material: Material | None + connectors: list[Connector] + metadata: dict[str, Any] + + +class Assembly(TypedDict): + kind: Literal["assembly"] + assembly_id: ProductId + name: str | None + components: list[Component] + connectors: list[Connector] + constraints: list[Constraint] + grounded_component_ids: list[ProductId] + metadata: dict[str, Any] + + +NormalizedProductDocument = Part | Assembly diff --git a/SimpleCADAPI/src/simplecadapi/scene/generated/presentation.py b/SimpleCADAPI/src/simplecadapi/scene/generated/presentation.py new file mode 100644 index 0000000..aacd9ba --- /dev/null +++ b/SimpleCADAPI/src/simplecadapi/scene/generated/presentation.py @@ -0,0 +1,92 @@ +# generated by datamodel-codegen: +# filename: presentation-1.0.schema.json + +from __future__ import annotations + +from typing import Any, Literal, TypedDict + +from typing_extensions import NotRequired + +LogicalId = str + + +StructuralId = str + + +Positive = float + + +Vec3 = list[float] + + +ColorItem = float + + +Color = list[ColorItem | Literal[1]] + + +class Transform(TypedDict): + origin: Vec3 + x_axis: Vec3 + y_axis: Vec3 + z_axis: Vec3 + + +class Appearance(TypedDict): + name: str + base_color: Color + metallic: float + roughness: float + alpha_mode: Literal["opaque"] + double_sided: bool + edge_color: Color + + +class NodeOverride1(TypedDict): + node_id: StructuralId + visible: bool + appearance_name: NotRequired[str] + + +class NodeOverride2(TypedDict): + node_id: StructuralId + visible: NotRequired[bool] + appearance_name: str + + +NodeOverride = NodeOverride1 | NodeOverride2 + + +CameraBaseProperties = Any + + +class Perspective(TypedDict): + name: str + projection: Literal["perspective"] + parent_node_id: StructuralId | None + transform: Transform + near: Positive + far: Positive + vertical_fov_degrees: float + + +class Orthographic(TypedDict): + name: str + projection: Literal["orthographic"] + parent_node_id: StructuralId | None + transform: Transform + near: Positive + far: Positive + vertical_span: Positive + + +Camera = Perspective | Orthographic + + +class PresentationDocument(TypedDict): + schema_version: Literal["1.0"] + presentation_id: LogicalId + source_scene_id: LogicalId + appearances: list[Appearance] + node_overrides: list[NodeOverride] + cameras: list[Camera] diff --git a/SimpleCADAPI/src/simplecadapi/scene/generated/scene.py b/SimpleCADAPI/src/simplecadapi/scene/generated/scene.py new file mode 100644 index 0000000..154d8f5 --- /dev/null +++ b/SimpleCADAPI/src/simplecadapi/scene/generated/scene.py @@ -0,0 +1,377 @@ +# generated by datamodel-codegen: +# filename: scene-1.0.schema.json + +from __future__ import annotations + +from typing import Any, Literal, TypedDict + +from typing_extensions import NotRequired + +SafeInteger = int + + +Number = float + + +PositiveNumber = float + + +LogicalId = str + + +ProductId = str + + +StructuralId = str + + +Hash = str + + +Uri = str + + +Token = str + + +Vec3 = list[Number] + + +ColorItem = float + + +Color = list[ColorItem | Literal[1]] + + +class Transform(TypedDict): + origin: Vec3 + x_axis: Vec3 + y_axis: Vec3 + z_axis: Vec3 + + +class Bounds(TypedDict): + min: Vec3 + max: Vec3 + + +class Generator(TypedDict): + name: Literal["simplecadapi"] + simplecadapi_version: Token + ocp_version: Token + ocp_bindings_version: Token + python_abi: Token + platform_tag: Token + toolchain_hash: Hash + profile: Literal["scene-1.0-ocp-glb-1", "scene-1.0-ocp-glb-2"] + + +class SourceFile(TypedDict): + path: str + uri: Uri + media_type: Literal["text/x-python; charset=utf-8"] + byte_length: SafeInteger + content_hash: Hash + + +class ImportedSource(TypedDict): + kind: Literal["imported"] + format: Token + artifact_hash: Hash + embedded_artifact_uri: NotRequired[Literal["source/source.bin"]] + embedded_artifact_byte_length: NotRequired[SafeInteger] + + +class ManualSource(TypedDict): + kind: Literal["manual"] + source_id: LogicalId + + +class PresentationSource(TypedDict): + presentation_id: LogicalId + schema_version: Literal["1.0"] + artifact_hash: Hash + embedded_artifact_uri: NotRequired[Literal["presentation/presentation.json"]] + embedded_artifact_byte_length: NotRequired[SafeInteger] + + +class CoordinateSystem(TypedDict): + length_unit: Literal["mm"] + handedness: Literal["right"] + up_axis: Literal["+Z"] + + +class CompileOptions(TypedDict): + linear_tolerance: float + angular_tolerance: float + embed_source: bool + embed_presentation: bool + + +class ProductModelSource(TypedDict): + kind: Literal["product_model"] + root_id: LogicalId + semantic_type: Literal["Part", "Assembly"] + semantic_id: ProductId + graph_id: ProductId + node_id: ProductId + output_slot: SafeInteger + + +class ProductManualSource(TypedDict): + kind: Literal["product_manual"] + root_id: LogicalId + semantic_type: Literal["Part", "Assembly"] + semantic_id: ProductId + + +class ModelOutputSource(TypedDict): + kind: Literal["model_output"] + root_id: LogicalId + graph_id: ProductId + node_id: ProductId + output_slot: SafeInteger + + +class ImportedDefinitionSource(TypedDict): + kind: Literal["imported"] + root_id: LogicalId + source_element_id: str + + +class ManualDefinitionSource(TypedDict): + kind: Literal["manual"] + root_id: LogicalId + source_id: LogicalId + + +DefinitionSource = ( + ProductModelSource + | ProductManualSource + | ModelOutputSource + | ImportedDefinitionSource + | ManualDefinitionSource +) + + +class Definition(TypedDict): + definition_id: StructuralId + kind: Literal["part", "assembly", "shape"] + name: str | None + source: DefinitionSource + sdk_metadata: dict[str, Any] + geometry_asset_id: NotRequired[Hash] + edge_asset_id: NotRequired[Hash] + entity_asset_id: NotRequired[Hash] + appearance_id: NotRequired[StructuralId] + + +class ProductOccurrenceSource(TypedDict): + kind: Literal["product_occurrence"] + root_id: LogicalId + component_path: list[ProductId] + + +class ShapeRootSource(TypedDict): + kind: Literal["shape_root"] + root_id: LogicalId + + +class Node(TypedDict): + node_id: StructuralId + parent_node_id: StructuralId | None + order: SafeInteger + definition_id: StructuralId + name: str | None + transform: Transform + visible: bool + selectable: bool + appearance_override_id: StructuralId | None + source: ProductOccurrenceSource | ShapeRootSource + sdk_metadata: dict[str, Any] + + +class AssetBase(TypedDict): + asset_id: NotRequired[Hash] + uri: NotRequired[Uri] + media_type: NotRequired[Literal["model/gltf-binary"]] + byte_length: NotRequired[SafeInteger] + content_hash: NotRequired[Hash] + scene_local_bounds: NotRequired[Bounds] + asset_to_scene: NotRequired[ + list[Literal[1000] | Literal[0] | Literal[-1000] | Literal[1]] + ] + + +class Tessellation(TypedDict): + linear_tolerance: PositiveNumber + angular_tolerance: PositiveNumber + + +class GeometryAsset(TypedDict): + asset_id: Hash + uri: Uri + media_type: Literal["model/gltf-binary"] + byte_length: SafeInteger + content_hash: Hash + scene_local_bounds: Bounds + asset_to_scene: list[Literal[1000] | Literal[0] | Literal[-1000] | Literal[1]] + tessellation: Tessellation + + +class Tessellation1(TypedDict): + linear_tolerance: PositiveNumber + + +class EdgeAsset(TypedDict): + asset_id: Hash + uri: Uri + media_type: Literal["model/gltf-binary"] + byte_length: SafeInteger + content_hash: Hash + scene_local_bounds: Bounds + asset_to_scene: list[Literal[1000] | Literal[0] | Literal[-1000] | Literal[1]] + tessellation: Tessellation1 + + +class EntityAsset(TypedDict): + entity_asset_id: Hash + uri: Uri + media_type: Literal["application/vnd.simplecad.entities+json"] + byte_length: SafeInteger + content_hash: Hash + + +class ProductMaterialSource(TypedDict): + kind: Literal["product_material"] + root_id: LogicalId + material_id: ProductId + + +class PresentationAppearanceSource(TypedDict): + kind: Literal["presentation"] + presentation_id: LogicalId + appearance_name: str + + +class Appearance(TypedDict): + appearance_id: str + name: str | None + source: ProductMaterialSource | PresentationAppearanceSource | None + base_color: Color + metallic: float + roughness: float + alpha_mode: Literal["opaque"] + double_sided: bool + edge_color: Color + sdk_metadata: dict[str, Any] + + +class ModelOperationSource(TypedDict): + kind: Literal["model_operation"] + graph_id: ProductId + node_id: ProductId + output_slot: SafeInteger + + +class ManualConnectorSource(TypedDict): + kind: Literal["manual"] + source_id: LogicalId + + +class ConnectorTarget(TypedDict): + entity_asset_id: Hash + entity_id: str + + +class ForwardedFrom(TypedDict): + source_component_id: ProductId + source_definition_id: StructuralId + source_connector_id: ProductId + source_connector_snapshot_id: StructuralId + offset: Transform | None + + +class Connector(TypedDict): + connector_snapshot_id: StructuralId + owner_definition_id: StructuralId + connector_id: ProductId + name: str | None + anchor_kind: Literal["geometry", "placement", "forwarded"] + local_transform: Transform + target: NotRequired[ConnectorTarget] + forwarded_from: NotRequired[ForwardedFrom] + source: ModelOperationSource | ManualConnectorSource | None + sdk_metadata: dict[str, Any] + + +class CameraBase(TypedDict): + camera_id: NotRequired[StructuralId] + name: NotRequired[str] + parent_node_id: NotRequired[StructuralId | None] + transform: NotRequired[Transform] + near: NotRequired[PositiveNumber] + far: NotRequired[PositiveNumber] + + +class PerspectiveCamera(TypedDict): + camera_id: StructuralId + name: str + projection: Literal["perspective"] + parent_node_id: StructuralId | None + transform: Transform + near: PositiveNumber + far: PositiveNumber + vertical_fov_degrees: float + + +class OrthographicCamera(TypedDict): + camera_id: StructuralId + name: str + projection: Literal["orthographic"] + parent_node_id: StructuralId | None + transform: Transform + near: PositiveNumber + far: PositiveNumber + vertical_span: PositiveNumber + + +Camera = PerspectiveCamera | OrthographicCamera + + +class ModelSource(TypedDict): + kind: Literal["model"] + graph_id: ProductId + model_schema_version: Literal["2.0"] + artifact_hash: Hash + embedded_artifact_uri: NotRequired[Literal["model/model.json"]] + embedded_artifact_byte_length: NotRequired[SafeInteger] + source_files: NotRequired[list[SourceFile]] + + +SceneSource = ModelSource | ImportedSource | ManualSource + + +class SceneDocument(TypedDict): + schema_version: Literal["1.0"] + extensions_used: list + extensions_required: list + extensions: dict[str, Any] + scene_id: LogicalId + revision: Hash + generator: Generator + source: SceneSource + presentation_source: NotRequired[PresentationSource] + coordinate_system: CoordinateSystem + compile_options: CompileOptions + definitions: list[Definition] + nodes: list[Node] + geometry_assets: list[GeometryAsset] + edge_assets: list[EdgeAsset] + appearances: list[Appearance] + entity_assets: list[EntityAsset] + connectors: list[Connector] + cameras: list[Camera] + lights: list + annotations: list + diagnostics: list diff --git a/SimpleCADAPI/src/simplecadapi/scene/glb.py b/SimpleCADAPI/src/simplecadapi/scene/glb.py new file mode 100644 index 0000000..a93d8a2 --- /dev/null +++ b/SimpleCADAPI/src/simplecadapi/scene/glb.py @@ -0,0 +1,397 @@ +"""Strict preflight for the two closed Scene 1.0 GLB profiles.""" + +from __future__ import annotations + +import math +import struct +from dataclasses import dataclass +from typing import Any, Literal + +from .canonical import canonical_json_bytes, parse_strict_json +from .resources import BASE_LIMITS, SceneResourceLimits + + +@dataclass(frozen=True) +class GlbInfo: + kind: Literal["triangle", "line"] + vertex_count: int + index_count: int + primitive_count: int + decoded_buffer_bytes: int + position_bounds: tuple[tuple[float, float, float], tuple[float, float, float]] + + +def _require_keys(value: Any, expected: set[str], context: str) -> None: + if not isinstance(value, dict) or set(value) != expected: + raise ValueError(f"{context} fields do not match closed GLB profile") + + +def _integer(value: Any, context: str) -> int: + if ( + isinstance(value, bool) + or not isinstance(value, int) + or value < 0 + or value > 9_007_199_254_740_991 + ): + raise ValueError(f"{context} must be a non-negative safe integer") + return value + + +def _vec3(value: Any, context: str) -> tuple[float, float, float]: + if ( + not isinstance(value, list) + or len(value) != 3 + or any( + isinstance(component, bool) or not isinstance(component, (int, float)) + for component in value + ) + ): + raise ValueError(f"{context} must be a three-number array") + result = tuple(float(component) for component in value) + if not all(math.isfinite(component) for component in result): + raise ValueError(f"{context} must contain finite values") + return result # type: ignore[return-value] + + +def profile_f32_bits(value: float) -> int: + """Convert one binary64 value to canonical profile binary32 bits.""" + + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise ValueError("f32 input must be a finite number") + source = float(value) + if not math.isfinite(source): + raise ValueError("f32 input must be a finite number") + try: + encoded = struct.pack(" float: + """Convert one value with the profile's binary32 and zero-sign rules.""" + + return struct.unpack(" tuple[float, float, float]: + """Evaluate the profile cross product in fixed binary64 operation order.""" + + a = _vec3(list(left), "cross left") + b = _vec3(list(right), "cross right") + p1x, p2x = a[1] * b[2], a[2] * b[1] + p1y, p2y = a[2] * b[0], a[0] * b[2] + p1z, p2z = a[0] * b[1], a[1] * b[0] + result = (p1x - p2x, p1y - p2y, p1z - p2z) + if not all(math.isfinite(component) for component in result): + raise ValueError("cross product produced a non-finite value") + return result + + +def profile_normalize( + value: tuple[float, float, float] | list[float], +) -> tuple[float, float, float]: + """Normalize a vector using the profile's fixed binary64/binary32 steps.""" + + vector = _vec3(list(value), "normalize input") + squared = vector[0] * vector[0] + vector[1] * vector[1] + squared = squared + vector[2] * vector[2] + if not math.isfinite(squared) or squared <= 0: + raise ValueError("normalize input has zero or non-finite length") + length = math.sqrt(squared) + result = tuple(profile_f32(component / length) for component in vector) + norm_squared = result[0] * result[0] + result[1] * result[1] + norm_squared = norm_squared + result[2] * result[2] + norm = math.sqrt(norm_squared) + if not 1.0 - 1e-6 <= norm <= 1.0 + 1e-6: + raise ValueError("normalized binary32 vector is outside the profile tolerance") + return result # type: ignore[return-value] + + +def preflight_glb_counts( + kind: Literal["triangle", "line"], + vertex_count: int, + index_count: int, + *, + limits: SceneResourceLimits = BASE_LIMITS, +) -> int: + """Validate kind-specific accessor counts before decoding GLB buffers.""" + + vertex_count = _integer(vertex_count, "POSITION count") + vertex_limit = ( + limits.triangle_vertices_per_asset + if kind == "triangle" + else limits.line_vertices_per_asset + ) + if vertex_count == 0 or vertex_count > vertex_limit: + raise ValueError("GLB vertex count is empty or exceeds resource limit") + index_count = _integer(index_count, "index count") + divisor = 3 if kind == "triangle" else 2 + primitive_count = index_count // divisor + primitive_limit = ( + limits.triangles_per_asset + if kind == "triangle" + else limits.line_segments_per_asset + ) + if index_count == 0 or index_count % divisor or primitive_count > primitive_limit: + raise ValueError("GLB index count is invalid or exceeds resource limit") + return primitive_count + + +def preflight_glb( + data: bytes | bytearray | memoryview, + *, + expected_kind: Literal["triangle", "line"] | None = None, + limits: SceneResourceLimits = BASE_LIMITS, +) -> GlbInfo: + """Validate exact Scene GLB JSON/BIN layout before a renderer sees bytes.""" + + view = memoryview(data) + if view.nbytes > limits.one_member_bytes: + raise ValueError("GLB exceeds resource limit") + raw = view.tobytes() + if len(raw) < 28: + raise ValueError("truncated GLB") + magic, version, total_length = struct.unpack_from(" len(raw): + raise ValueError("truncated GLB JSON chunk") + json_padded = raw[json_start:json_end] + json_bytes = json_padded.rstrip(b" ") + if json_padded != json_bytes + b" " * ((-len(json_bytes)) % 4): + raise ValueError("GLB JSON padding must use the minimal ASCII-space suffix") + document = parse_strict_json(json_bytes) + if not isinstance(document, dict) or canonical_json_bytes(document) != json_bytes: + raise ValueError("GLB JSON chunk must be RFC 8785 canonical") + bin_length, bin_type = struct.unpack_from(" high for low, high in zip(minimum, maximum)): + raise ValueError("POSITION min exceeds max") + + if kind == "triangle": + normal = accessors[1] + _require_keys(normal, {"bufferView", "componentType", "count", "type"}, "NORMAL accessor") + if ( + _integer(normal["bufferView"], "NORMAL bufferView") != 1 + or _integer(normal["componentType"], "NORMAL componentType") != 5126 + or _integer(normal["count"], "NORMAL count") != vertex_count + or normal["type"] != "VEC3" + ): + raise ValueError("invalid NORMAL accessor") + index = accessors[2] + index_view = 2 + else: + index = accessors[1] + index_view = 1 + _require_keys(index, {"bufferView", "componentType", "count", "type"}, "index accessor") + index_count = _integer(index["count"], "index count") + primitive_count = preflight_glb_counts( + kind, vertex_count, index_count, limits=limits + ) + component_type = 5123 if vertex_count <= 65536 else 5125 + if ( + _integer(index["bufferView"], "index bufferView") != index_view + or _integer(index["componentType"], "index componentType") != component_type + or index["type"] != "SCALAR" + ): + raise ValueError("invalid GLB index accessor") + + expected_offset = 0 + for index_number, view in enumerate(views): + _require_keys(view, {"buffer", "byteLength", "byteOffset", "target"}, f"bufferView[{index_number}]") + view_buffer = _integer(view["buffer"], f"bufferView[{index_number}].buffer") + view_offset = _integer(view["byteOffset"], f"bufferView[{index_number}].byteOffset") + view_length = _integer(view["byteLength"], f"bufferView[{index_number}].byteLength") + view_target = _integer(view["target"], f"bufferView[{index_number}].target") + if view_buffer != 0 or view_offset != expected_offset: + raise ValueError("GLB bufferView offset mismatch") + expected_target = 34962 if index_number < index_view else 34963 + if view_target != expected_target: + raise ValueError("GLB bufferView target mismatch") + if index_number < index_view: + expected_length = 12 * vertex_count + else: + expected_length = index_count * (2 if component_type == 5123 else 4) + if view_length != expected_length: + raise ValueError("GLB bufferView byteLength mismatch") + expected_offset += expected_length + expected_offset = (expected_offset + 3) & ~3 + unpadded_length = views[-1]["byteOffset"] + views[-1]["byteLength"] + if buffer_byte_length != unpadded_length: + raise ValueError("GLB buffer byteLength mismatch") + if bin_length != ((unpadded_length + 3) & ~3): + raise ValueError("GLB BIN chunk length mismatch") + if any(bin_data[unpadded_length:]): + raise ValueError("GLB BIN padding must be zero") + + position_bytes = bin_data[: 12 * vertex_count] + values = struct.unpack("<" + "f" * (3 * vertex_count), position_bytes) + positions = list(zip(values[0::3], values[1::3], values[2::3])) + if not all(math.isfinite(component) for point in positions for component in point): + raise ValueError("GLB POSITION contains non-finite values") + position_bits = [ + struct.unpack_from("= vertex_count or len(set(indices)) != vertex_count: + raise ValueError("GLB indices must be in range and reference every vertex") + if kind == "triangle": + triangles: list[tuple[int, int, int]] = [] + for start in range(0, index_count, 3): + triple = indices[start : start + 3] + rotations = (triple, triple[1:] + triple[:1], triple[2:] + triple[:2]) + if triple != min(rotations): + raise ValueError("GLB triangle indices are not in canonical cyclic rotation") + triangles.append(triple) + a, b, c = (positions[index] for index in triple) + ab = tuple(b[axis] - a[axis] for axis in range(3)) + ac = tuple(c[axis] - a[axis] for axis in range(3)) + cross = ( + ab[1] * ac[2] - ab[2] * ac[1], + ab[2] * ac[0] - ab[0] * ac[2], + ab[0] * ac[1] - ab[1] * ac[0], + ) + if a == b or b == c or a == c or cross == (0.0, 0.0, 0.0): + raise ValueError("GLB contains a collapsed triangle") + if triangles != sorted(triangles): + raise ValueError("GLB triangle records are not canonically sorted") + else: + segments: list[tuple[int, int]] = [] + for start in range(0, index_count, 2): + pair = indices[start : start + 2] + if pair[0] >= pair[1]: + raise ValueError("GLB line indices are not in canonical endpoint order") + segments.append(pair) + if positions[pair[0]] == positions[pair[1]]: + raise ValueError("GLB contains a collapsed line segment") + if segments != sorted(set(segments)): + raise ValueError("GLB line records are not sorted and unique") + return GlbInfo( + kind=kind, + vertex_count=vertex_count, + index_count=index_count, + primitive_count=primitive_count, + decoded_buffer_bytes=2 * sum(view["byteLength"] for view in views), + position_bounds=(minimum, maximum), + ) diff --git a/SimpleCADAPI/src/simplecadapi/scene/glb_writer.py b/SimpleCADAPI/src/simplecadapi/scene/glb_writer.py new file mode 100644 index 0000000..8a66aff --- /dev/null +++ b/SimpleCADAPI/src/simplecadapi/scene/glb_writer.py @@ -0,0 +1,135 @@ +"""Exact GLB writer for the Scene 1.0 OCP render profile.""" + +from __future__ import annotations + +import struct + +from .canonical import canonical_json_bytes +from .glb import preflight_glb +from .render_mesh import RenderEdgeMesh, RenderMesh + + +def write_triangle_glb(mesh: RenderMesh) -> bytes: + positions = b"".join(struct.pack(" bytes: + positions = b"".join(struct.pack(" bytes: + vertex_count = len(positions) // 12 + bin_data = bytearray(positions) + views = [ + {"buffer": 0, "byteLength": len(positions), "byteOffset": 0, "target": 34962} + ] + accessors = [ + { + "bufferView": 0, + "componentType": 5126, + "count": vertex_count, + "max": list(bounds[1]), + "min": list(bounds[0]), + "type": "VEC3", + } + ] + if kind == "triangle": + assert normals is not None + _pad4(bin_data) + normal_offset = len(bin_data) + bin_data.extend(normals) + views.append( + {"buffer": 0, "byteLength": len(normals), "byteOffset": normal_offset, "target": 34962} + ) + accessors.append( + {"bufferView": 1, "componentType": 5126, "count": vertex_count, "type": "VEC3"} + ) + _pad4(bin_data) + index_offset = len(bin_data) + bin_data.extend(indices) + views.append( + {"buffer": 0, "byteLength": len(indices), "byteOffset": index_offset, "target": 34963} + ) + accessors.append( + { + "bufferView": index_view, + "componentType": index_component_type, + "count": index_count, + "type": "SCALAR", + } + ) + primitive = ( + {"attributes": {"NORMAL": 1, "POSITION": 0}, "indices": 2, "mode": 4} + if kind == "triangle" + else {"attributes": {"POSITION": 0}, "indices": 1, "mode": 1} + ) + document = { + "accessors": accessors, + "asset": {"generator": "SimpleCAD Scene GLB Profile 1", "version": "2.0"}, + "bufferViews": views, + "buffers": [{"byteLength": len(bin_data)}], + "meshes": [{"primitives": [primitive]}], + "nodes": [{"mesh": 0}], + "scene": 0, + "scenes": [{"nodes": [0]}], + } + json_bytes = canonical_json_bytes(document) + json_chunk = json_bytes + b" " * ((-len(json_bytes)) % 4) + bin_chunk = bytes(bin_data) + b"\0" * ((-len(bin_data)) % 4) + result = b"".join( + ( + struct.pack(" tuple[int, str]: + return (5123, " None: + data.extend(b"\0" * ((-len(data)) % 4)) + + +__all__ = ["write_line_glb", "write_triangle_glb"] diff --git a/SimpleCADAPI/src/simplecadapi/scene/render_mesh.py b/SimpleCADAPI/src/simplecadapi/scene/render_mesh.py new file mode 100644 index 0000000..5db7d0a --- /dev/null +++ b/SimpleCADAPI/src/simplecadapi/scene/render_mesh.py @@ -0,0 +1,364 @@ +"""Deterministic Scene 1.0 render and CAD-edge mesh construction.""" + +from __future__ import annotations + +import hashlib +import struct +from dataclasses import dataclass +from typing import Iterable, Sequence + +from OCP.BRepAdaptor import BRepAdaptor_Curve +from OCP.GCPnts import GCPnts_TangentialDeflection +from OCP.TopAbs import TopAbs_FORWARD, TopAbs_REVERSED +from OCP.TopLoc import TopLoc_Location +from OCP.TopoDS import TopoDS + +from .._mesh import DEFAULT_ANGULAR_TOLERANCE, TriMesh, cached_mesh, mesh_error +from ..core import Solid +from ..kernel.ocp_properties import bounding_box +from ..scene.glb import profile_cross, profile_f32, profile_f32_bits, profile_normalize + + +ASSET_TO_SCENE = [1000, 0, 0, 0, 0, 0, -1000, 0, 0, 1000, 0, 0, 0, 0, 0, 1] + + +@dataclass(frozen=True, slots=True) +class CanonicalTriangleBlock: + entity_id: str + render_key: bytes + block_bytes: bytes + vertex_keys: tuple[bytes, ...] + positions: tuple[tuple[float, float, float], ...] + normals: tuple[tuple[float, float, float], ...] + triangles: tuple[tuple[int, int, int], ...] + + +@dataclass(frozen=True, slots=True) +class CanonicalEdgeBlock: + entity_id: str + render_key: bytes + block_bytes: bytes + position_keys: tuple[bytes, ...] + positions: tuple[tuple[float, float, float], ...] + segments: tuple[tuple[int, int], ...] + + +@dataclass(frozen=True, slots=True) +class RenderGroup: + entity_id: str + first_index: int + index_count: int + + +@dataclass(frozen=True, slots=True) +class RenderMesh: + positions: tuple[tuple[float, float, float], ...] + normals: tuple[tuple[float, float, float], ...] + indices: tuple[int, ...] + groups: tuple[RenderGroup, ...] + bounds: tuple[tuple[float, float, float], tuple[float, float, float]] + blocks: tuple[CanonicalTriangleBlock, ...] + + +@dataclass(frozen=True, slots=True) +class RenderEdgeMesh: + positions: tuple[tuple[float, float, float], ...] + indices: tuple[int, ...] + groups: tuple[RenderGroup, ...] + bounds: tuple[tuple[float, float, float], tuple[float, float, float]] + blocks: tuple[CanonicalEdgeBlock, ...] + degenerate_entity_ids: tuple[str, ...] + + +def cad_to_gltf(point: Sequence[float] | object) -> tuple[float, float, float]: + """Convert CAD mm/+Z coordinates to glTF m/+Y coordinates.""" + + if hasattr(point, "X") and hasattr(point, "Y") and hasattr(point, "Z"): + point = (float(point.X()), float(point.Y()), float(point.Z())) + return ( + profile_f32(float(point[0]) / 1000.0), # type: ignore[index] + profile_f32(float(point[2]) / 1000.0), # type: ignore[index] + profile_f32(-float(point[1]) / 1000.0), # type: ignore[index] + ) + + +def cad_direction_to_gltf(direction: Sequence[float]) -> tuple[float, float, float]: + return cad_to_gltf(direction) + + +def build_render_mesh( + solid: Solid, + *, + face_entity_ids: Sequence[str], + linear_tolerance: float, + angular_tolerance: float, +) -> RenderMesh: + faces = solid.get_faces() + if len(faces) != len(face_entity_ids): + raise ValueError("face entity count does not match the solid topology") + mesh = cached_mesh( + solid, + linear_tolerance=linear_tolerance, + angular_tolerance=angular_tolerance, + ) + if mesh is None: + detail = mesh_error(solid) or "unknown evaluated mesh error" + raise ValueError(f"solid evaluated mesh is unavailable: {detail}") + blocks = tuple( + _triangle_block( + mesh, + face_range=mesh.face_triangle_ranges[face_index], + entity_id=entity_id, + ) + for face_index, entity_id in enumerate(face_entity_ids) + ) + ordered = tuple(sorted(blocks, key=lambda item: (item.render_key, item.block_bytes, item.entity_id.encode("utf-8")))) + positions: list[tuple[float, float, float]] = [] + normals: list[tuple[float, float, float]] = [] + indices: list[int] = [] + groups: list[RenderGroup] = [] + for block in ordered: + offset = len(positions) + positions.extend(block.positions) + normals.extend(block.normals) + indices.extend(offset + index for triangle in block.triangles for index in triangle) + groups.append( + RenderGroup( + entity_id=block.entity_id, + first_index=len(indices) - len(block.triangles) * 3, + index_count=len(block.triangles) * 3, + ) + ) + return RenderMesh( + positions=tuple(positions), + normals=tuple(normals), + indices=tuple(indices), + groups=tuple(sorted(groups, key=lambda item: item.first_index)), + bounds=_bounds(positions), + blocks=ordered, + ) + + +def build_edge_mesh( + solid: Solid, + *, + edge_entity_ids: Sequence[str], + linear_tolerance: float, + angular_tolerance: float = DEFAULT_ANGULAR_TOLERANCE, +) -> RenderEdgeMesh: + edges = solid.get_edges() + if len(edges) != len(edge_entity_ids): + raise ValueError("edge entity count does not match the solid topology") + blocks = tuple( + _edge_block( + edge.wrapped, + entity_id=entity_id, + linear_tolerance=linear_tolerance, + angular_tolerance=angular_tolerance, + ) + for edge, entity_id in zip(edges, edge_entity_ids) + ) + rendered = tuple( + sorted( + (block for block in blocks if block.segments), + key=lambda item: (item.render_key, item.block_bytes, item.entity_id.encode("utf-8")), + ) + ) + positions: list[tuple[float, float, float]] = [] + indices: list[int] = [] + groups: list[RenderGroup] = [] + for block in rendered: + offset = len(positions) + positions.extend(block.positions) + indices.extend(offset + index for segment in block.segments for index in segment) + groups.append( + RenderGroup( + entity_id=block.entity_id, + first_index=len(indices) - len(block.segments) * 2, + index_count=len(block.segments) * 2, + ) + ) + return RenderEdgeMesh( + positions=tuple(positions), + indices=tuple(indices), + groups=tuple(sorted(groups, key=lambda item: item.first_index)), + bounds=_bounds(positions), + blocks=rendered, + degenerate_entity_ids=tuple( + sorted((block.entity_id for block in blocks if not block.segments), key=lambda value: value.encode("utf-8")) + ), + ) + + +def solid_asset_bounds(solid: Solid) -> tuple[tuple[float, float, float], tuple[float, float, float]]: + box = bounding_box(solid.wrapped) + return ( + (float(box.xmin), float(box.ymin), float(box.zmin)), + (float(box.xmax), float(box.ymax), float(box.zmax)), + ) + + +def _prepare_shape(shape, *, force_forward: bool): + oriented = shape.Oriented(TopAbs_FORWARD) if force_forward else shape + location = oriented.Location() + unlocated = oriented.Located(TopLoc_Location(), False) + if location.IsIdentity(): + return unlocated + from OCP.BRepBuilderAPI import BRepBuilderAPI_Transform + + builder = BRepBuilderAPI_Transform(unlocated, location.Transformation(), True, False) + builder.Build() + if not builder.IsDone(): + raise ValueError("OCP location bake failed") + return builder.Shape() + + +def _triangle_block( + mesh: TriMesh, + *, + face_range, + entity_id: str, +) -> CanonicalTriangleBlock: + vertex_records: dict[bytes, tuple[tuple[float, float, float], tuple[float, float, float]]] = {} + triangle_keys: list[tuple[bytes, bytes, bytes]] = [] + triangle_start = face_range.start + triangle_end = triangle_start + face_range.count + for triangle_index in range(triangle_start, triangle_end): + node_indices = tuple(int(index) for index in mesh.triangles[triangle_index]) + positions = tuple(cad_to_gltf(mesh.vertices[index]) for index in node_indices) + if len(set(_position_key(position) for position in positions)) != 3: + continue + cross = profile_cross( + tuple(positions[1][axis] - positions[0][axis] for axis in range(3)), + tuple(positions[2][axis] - positions[0][axis] for axis in range(3)), + ) + if all(component == 0.0 for component in cross): + continue + fallback_normal = profile_normalize(cross) + keys: list[bytes] = [] + for corner in range(3): + raw_normal = tuple( + float(value) for value in mesh.corner_normals[triangle_index, corner] + ) + normal = ( + fallback_normal + if all(component == 0.0 for component in raw_normal) + else profile_normalize(cad_direction_to_gltf(raw_normal)) + ) + position = positions[corner] + key = _vertex_key(position, normal) + vertex_records[key] = (position, normal) + keys.append(key) + triangle_keys.append(_least_cyclic_rotation(tuple(keys))) + if not triangle_keys: + raise ValueError(f"face {entity_id} has no non-degenerate triangle") + unique_keys = sorted(set(key for triangle in triangle_keys for key in triangle)) + key_to_index = {key: index for index, key in enumerate(unique_keys)} + triangles = tuple(sorted(tuple(key_to_index[key] for key in triangle) for triangle in triangle_keys)) + decoded = tuple(vertex_records[key] for key in unique_keys) + vertex_bytes = b"".join(unique_keys) + index_format = " CanonicalEdgeBlock: + prepared = TopoDS.Edge_s(_prepare_shape(edge, force_forward=True)) + adaptor = BRepAdaptor_Curve(prepared) + try: + sampler = GCPnts_TangentialDeflection( + adaptor, + adaptor.FirstParameter(), + adaptor.LastParameter(), + float(angular_tolerance), + float(linear_tolerance), + 2, + 1e-9, + 1e-7, + ) + except Exception as exc: + raise ValueError(f"edge {entity_id} discretization failed") from exc + positions = tuple( + cad_to_gltf(sampler.Value(index)) + for index in range(1, sampler.NbPoints() + 1) + ) + segments = [ + (_position_key(positions[index]), _position_key(positions[index + 1])) + for index in range(len(positions) - 1) + if _position_key(positions[index]) != _position_key(positions[index + 1]) + ] + position_keys = sorted(set(key for segment in segments for key in segment)) + key_to_index = {key: index for index, key in enumerate(position_keys)} + canonical_segments = tuple(sorted(set((min(key_to_index[a], key_to_index[b]), max(key_to_index[a], key_to_index[b])) for a, b in segments))) + decoded_positions = tuple(_decode_position(key) for key in position_keys) + index_format = " bytes: + return struct.pack(" bytes: + return _position_key(position) + struct.pack(" tuple[float, float, float]: + return tuple(struct.unpack(" tuple[tuple[float, float, float], tuple[float, float, float]]: + return _decode_position(key[:12]), _decode_position(key[12:]) + + +def _least_cyclic_rotation(values: tuple[bytes, bytes, bytes]) -> tuple[bytes, bytes, bytes]: + return min((values, values[1:] + values[:1], values[2:] + values[:2])) + + +def _bounds(points: Iterable[Sequence[float]]) -> tuple[tuple[float, float, float], tuple[float, float, float]]: + points = tuple(points) + if not points: + raise ValueError("render mesh cannot be empty") + return ( + tuple(min(float(point[axis]) for point in points) for axis in range(3)), + tuple(max(float(point[axis]) for point in points) for axis in range(3)), + ) # type: ignore[return-value] + + +__all__ = [ + "ASSET_TO_SCENE", + "CanonicalEdgeBlock", + "CanonicalTriangleBlock", + "RenderEdgeMesh", + "RenderGroup", + "RenderMesh", + "build_edge_mesh", + "build_render_mesh", + "cad_direction_to_gltf", + "cad_to_gltf", + "solid_asset_bounds", +] diff --git a/SimpleCADAPI/src/simplecadapi/scene/resources.py b/SimpleCADAPI/src/simplecadapi/scene/resources.py new file mode 100644 index 0000000..1d23275 --- /dev/null +++ b/SimpleCADAPI/src/simplecadapi/scene/resources.py @@ -0,0 +1,120 @@ +"""Scene 1.0 resource limits shared by validators and archive preflight.""" + +from __future__ import annotations + +from dataclasses import dataclass + + +MIB = 1024 * 1024 + + +@dataclass(frozen=True) +class SceneResourceLimits: + zip_members: int = 50_000 + input_archive_bytes: int = 256 * MIB + canonical_archive_bytes: int = 256 * MIB + total_uncompressed_bytes: int = 1024 * MIB + one_member_bytes: int = 256 * MIB + scene_json_bytes: int = 32 * MIB + entity_json_bytes: int = 64 * MIB + model_json_bytes: int = 64 * MIB + presentation_json_bytes: int = 8 * MIB + compression_ratio: int = 100 + json_depth: int = 64 + json_string_bytes: int = 1 * MIB + uri_bytes: int = 1024 + structural_id_bytes: int = 4096 + definitions: int = 25_000 + nodes: int = 100_000 + assets_per_kind: int = 25_000 + appearances: int = 25_000 + connectors: int = 100_000 + cameras: int = 1_000 + hierarchy_depth: int = 256 + forwarded_connector_depth: int = 64 + entities_per_sidecar: int = 500_000 + entities_total: int = 2_000_000 + triangle_vertices_per_asset: int = 2_000_000 + triangle_vertices_total: int = 10_000_000 + triangles_per_asset: int = 2_000_000 + triangles_total: int = 10_000_000 + line_vertices_per_asset: int = 2_000_000 + line_vertices_total: int = 10_000_000 + line_segments_per_asset: int = 2_000_000 + line_segments_total: int = 10_000_000 + static_decoded_buffer_bytes: int = 512 * MIB + + +BASE_LIMITS = SceneResourceLimits() + + +def preflight_input_archive_size( + size: int, *, limits: SceneResourceLimits = BASE_LIMITS +) -> int: + """Validate a transported archive length before reading that many bytes.""" + + if isinstance(size, bool) or not isinstance(size, int) or size < 0: + raise ValueError("invalid input archive size") + if size > limits.input_archive_bytes: + raise ValueError("input archive exceeds resource limit") + return size + + +def preflight_resource_count( + count: int, + limit_name: str, + *, + limits: SceneResourceLimits = BASE_LIMITS, +) -> int: + """Validate one non-negative resource count against the named profile limit.""" + + if isinstance(count, bool) or not isinstance(count, int) or count < 0: + raise ValueError("invalid resource count") + if limit_name not in SceneResourceLimits.__dataclass_fields__: + raise ValueError(f"unknown resource limit: {limit_name}") + if count > getattr(limits, limit_name): + raise ValueError(f"{limit_name} exceeds resource limit") + return count + + +def preflight_aggregate_compression_ratio( + uncompressed_size: int, + archive_size: int, + *, + limits: SceneResourceLimits = BASE_LIMITS, +) -> None: + """Validate the aggregate compression-ratio predicate without inflating data.""" + + if any( + isinstance(value, bool) or not isinstance(value, int) or value < 0 + for value in (uncompressed_size, archive_size) + ) or archive_size == 0: + raise ValueError("invalid compression ratio sizes") + if uncompressed_size > limits.compression_ratio * archive_size: + raise ValueError("aggregate compression ratio exceeds limit") + + +def preflight_member_compression_ratio( + uncompressed_size: int, + compressed_size: int, + *, + limits: SceneResourceLimits = BASE_LIMITS, +) -> None: + """Validate one member compression-ratio predicate before inflation.""" + + if any( + isinstance(value, bool) or not isinstance(value, int) or value < 0 + for value in (uncompressed_size, compressed_size) + ): + raise ValueError("invalid compression ratio sizes") + if uncompressed_size > limits.compression_ratio * max(1, compressed_size): + raise ValueError("member compression ratio exceeds limit") + + +def canonical_archive_size(member_sizes: dict[str, int]) -> int: + """Compute the exact stored ZIP envelope size from names and payload sizes.""" + + total = 22 + for name, size in member_sizes.items(): + total += 76 + 2 * len(name.encode("ascii")) + size + return total diff --git a/SimpleCADAPI/src/simplecadapi/scene/transforms.py b/SimpleCADAPI/src/simplecadapi/scene/transforms.py new file mode 100644 index 0000000..a9a6bf9 --- /dev/null +++ b/SimpleCADAPI/src/simplecadapi/scene/transforms.py @@ -0,0 +1,56 @@ +"""Deterministic rigid-transform primitives used by Scene validation.""" + +from __future__ import annotations + +from typing import Mapping, Sequence + + +RigidTransform = Mapping[str, Sequence[float]] + + +def compose_rigid_transforms( + left: RigidTransform, right: RigidTransform +) -> dict[str, list[float]]: + """Compose parent-relative transforms using fixed multiply/add order.""" + + def rotate(vector: Sequence[float]) -> list[float]: + return [ + (left["x_axis"][index] * vector[0] + left["y_axis"][index] * vector[1]) + + left["z_axis"][index] * vector[2] + for index in range(3) + ] + + rotated_origin = rotate(right["origin"]) + return { + "origin": [ + left["origin"][index] + rotated_origin[index] for index in range(3) + ], + "x_axis": rotate(right["x_axis"]), + "y_axis": rotate(right["y_axis"]), + "z_axis": rotate(right["z_axis"]), + } + + +def rigid_transforms_equal(actual: RigidTransform, expected: RigidTransform) -> bool: + """Compare derived transforms with the frozen Scene numeric profile.""" + + origin_epsilon = max( + 1e-9, + 1e-12 + * max( + 1.0, + *(abs(component) for component in actual["origin"]), + *(abs(component) for component in expected["origin"]), + ), + ) + return all( + abs(left - right) <= origin_epsilon + for left, right in zip(actual["origin"], expected["origin"]) + ) and all( + abs(left - right) <= 1e-12 + for key in ("x_axis", "y_axis", "z_axis") + for left, right in zip(actual[key], expected[key]) + ) + + +__all__ = ["compose_rigid_transforms", "rigid_transforms_equal"] diff --git a/SimpleCADAPI/src/simplecadapi/scene/validation.py b/SimpleCADAPI/src/simplecadapi/scene/validation.py new file mode 100644 index 0000000..ff90e50 --- /dev/null +++ b/SimpleCADAPI/src/simplecadapi/scene/validation.py @@ -0,0 +1,2071 @@ +"""Structural and semantic validators for the proposed Scene 1.0 contract.""" + +from __future__ import annotations + +import hashlib +import math +import re +from dataclasses import dataclass +from functools import lru_cache +from importlib.resources import files +from types import MappingProxyType +from typing import Any, Iterable, Mapping, Sequence +from urllib.parse import quote + +from jsonschema import Draft202012Validator + +from .canonical import ( + DuplicateKeyError, + canonical_json_bytes, + canonical_json_hash, + compute_scene_revision, + parse_canonical_json, + parse_strict_json, +) +from .glb import GlbInfo, preflight_glb +from .resources import BASE_LIMITS, SceneResourceLimits, preflight_resource_count +from .transforms import compose_rigid_transforms, rigid_transforms_equal + + +SCHEMA_FILES = { + "scene": "schemas/scene-1.0.schema.json", + "entities": "schemas/entities-1.0.schema.json", + "presentation": "schemas/presentation-1.0.schema.json", + "connector_binding": "schemas/connector-binding-1.0.schema.json", + "normalized_product": "schemas/normalized-product-1.schema.json", +} + +_PHASE_ORDER = {"parse": 0, "structure": 1, "semantic": 2, "package": 3, "budget": 4} +_SET_ARRAY_KEYS = {"semantic_binding_ids", "evaluated_tags", "parent_entity_ids", "child_entity_ids"} +_IDENTIFIER_ARRAY_KEYS = { + "child_entity_ids", + "component_path", + "evaluated_tags", + "grounded_component_ids", + "parent_entity_ids", + "semantic_binding_ids", +} +_COLLECTION_SORT_KEYS = { + "definitions": "definition_id", + "nodes": "node_id", + "geometry_assets": "asset_id", + "edge_assets": "asset_id", + "entity_assets": "entity_asset_id", + "appearances": "appearance_id", + "connectors": "connector_snapshot_id", + "cameras": "camera_id", + "entities": "entity_id", +} +_RULE_SCHEMA_PATH = "schemas/rules-1.schema.json" +_RULE_REGISTRY_PATH = "rules/scene-1.0-rules.json" +_SOURCE_PATH_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._/-]*\.py$") +_SOURCE_KEYS = { + "schema_version", + "path", + "path_kind", + "line", + "column", + "end_line", + "end_column", + "call_text", + "callsite_id", + "assignment_targets", +} + + +@dataclass(frozen=True) +class SceneValidationIssue: + severity: str + code: str + path: str + message: str + phase: str + + def to_dict(self) -> dict[str, str]: + return { + "severity": self.severity, + "code": self.code, + "path": self.path, + "message": self.message, + } + + +@dataclass(frozen=True) +class SceneValidationReport: + issues: tuple[SceneValidationIssue, ...] = () + + @property + def valid(self) -> bool: + return not self.issues + + @property + def first_error(self) -> SceneValidationIssue | None: + return self.issues[0] if self.issues else None + + def to_dict(self) -> dict[str, Any]: + return {"valid": self.valid, "issues": [issue.to_dict() for issue in self.issues]} + + +class SceneContractError(ValueError): + """Raised by assert helpers with the complete deterministic report.""" + + def __init__(self, report: SceneValidationReport) -> None: + self.report = report + first = report.first_error + message = "Scene contract validation failed" + if first is not None: + message += f": {first.code} at {first.path}: {first.message}" + super().__init__(message) + + +def _pointer(parts: Iterable[Any]) -> str: + encoded = [str(part).replace("~", "~0").replace("/", "~1") for part in parts] + return "/" + "/".join(encoded) if encoded else "" + + +def _join_pointer(prefix: str, *parts: Any) -> str: + suffix = _pointer(parts) + return prefix + suffix if prefix else suffix + + +def _utf8_key(value: str) -> bytes: + return value.encode("utf-8") + + +def _issue(code: str, path: str, message: str, phase: str = "semantic") -> SceneValidationIssue: + return SceneValidationIssue("error", code, path, message, phase) + + +def _validate_rule_registry( + registry: Any, schema: Any +) -> dict[str, Mapping[str, Any]]: + """Validate registry shape and the ordering invariants JSON Schema cannot express.""" + + Draft202012Validator.check_schema(schema) + errors = sorted( + Draft202012Validator(schema).iter_errors(registry), + key=lambda error: (_pointer(error.absolute_path).encode("utf-8"), error.message), + ) + if errors: + first = errors[0] + raise ValueError( + f"invalid scene rule registry at {_pointer(first.absolute_path)}: {first.message}" + ) + + phases = registry["phases"] + phase_order = {phase: index for index, phase in enumerate(phases)} + rules = registry["rules"] + by_id: dict[str, Mapping[str, Any]] = {} + phase_precedence: set[tuple[str, int]] = set() + for rule in rules: + rule_id = rule["id"] + if rule_id in by_id: + raise ValueError(f"invalid scene rule registry: duplicate rule ID {rule_id!r}") + key = (rule["phase"], rule["precedence"]) + if key in phase_precedence: + raise ValueError( + "invalid scene rule registry: duplicate precedence " + f"{rule['precedence']} in phase {rule['phase']!r}" + ) + by_id[rule_id] = rule + phase_precedence.add(key) + + expected = sorted( + rules, + key=lambda rule: (phase_order[rule["phase"]], rule["precedence"]), + ) + if rules != expected: + raise ValueError( + "invalid scene rule registry: rules must be ordered by phase and precedence" + ) + for phase in phases: + phase_rules = [rule for rule in rules if rule["phase"] == phase] + precedence_ids = [rule["id"] for rule in phase_rules] + utf8_ids = sorted(precedence_ids, key=_utf8_key) + if precedence_ids != utf8_ids: + raise ValueError( + "invalid scene rule registry: precedence in phase " + f"{phase!r} must match unsigned UTF-8 rule-ID order" + ) + return by_id + + +@lru_cache(maxsize=1) +def _rule_registry() -> dict[str, Mapping[str, Any]]: + schema = _load_json_artifact(_RULE_SCHEMA_PATH) + registry = _load_json_artifact(_RULE_REGISTRY_PATH) + return _validate_rule_registry(registry, schema) + + +def _has_root_pointer_policy(code: str) -> bool: + rule = _rule_registry().get(code) + return rule is not None and rule["pointer_policy"] == "root" + + +def _report( + issues: Iterable[SceneValidationIssue], *, artifact: str | None = None +) -> SceneValidationReport: + rules = _rule_registry() + unique: dict[tuple[str, ...], SceneValidationIssue] = {} + for issue in issues: + if artifact is not None: + rule = rules.get(issue.code) + if rule is None: + raise ValueError(f"unregistered scene validation issue code: {issue.code!r}") + if issue.phase != rule["phase"]: + raise ValueError( + f"scene validation issue {issue.code!r} has phase {issue.phase!r}; " + f"registered phase is {rule['phase']!r}" + ) + if artifact not in rule["artifacts"]: + raise ValueError( + f"scene validation issue {issue.code!r} does not apply to artifact {artifact!r}" + ) + if rule["pointer_policy"] == "root" and issue.path != "": + raise ValueError( + f"scene validation issue {issue.code!r} requires the root pointer" + ) + key = ( + (issue.phase, issue.code, issue.path) + if artifact is None + else (issue.phase, issue.code, issue.path, issue.message) + ) + unique.setdefault(key, issue) + ordered = sorted( + unique.values(), + key=lambda issue: ( + _PHASE_ORDER[issue.phase], + rules[issue.code]["precedence"] if artifact is not None else issue.code.encode("utf-8"), + issue.path.encode("utf-8"), + issue.message.encode("utf-8"), + ), + ) + return SceneValidationReport(tuple(ordered)) + + +def _has_blocking_issues(issues: Iterable[SceneValidationIssue]) -> bool: + return any(issue.phase in {"parse", "structure"} for issue in issues) + + +def _has_unsafe_depth(issues: Iterable[SceneValidationIssue]) -> bool: + return any( + issue.code == "resource_limit_exceeded" and "nesting" in issue.message + for issue in issues + ) + + +def _serialized_size(value: Any) -> int | None: + if isinstance(value, str): + try: + return len(value.encode("utf-8")) + except UnicodeEncodeError: + return None + if isinstance(value, memoryview): + return value.nbytes + if isinstance(value, (bytes, bytearray)): + return len(value) + try: + return len(canonical_json_bytes(value)) + except (RecursionError, TypeError, ValueError, UnicodeEncodeError): + return None + + +def _serialized_size_issues( + value: Any, + artifact: str, + *, + limits: SceneResourceLimits, +) -> list[SceneValidationIssue]: + size = _serialized_size(value) + limit = { + "scene": limits.scene_json_bytes, + "entities": limits.entity_json_bytes, + "presentation": limits.presentation_json_bytes, + }.get(artifact, limits.one_member_bytes) + if size is None or size <= limit: + return [] + return [ + _issue( + "resource_limit_exceeded", + "", + f"{artifact} JSON bytes exceed resource limit", + "budget", + ) + ] + + +def _contract_root(): + return files("simplecadapi.scene").joinpath("contracts") + + +def load_contract_artifact(relative_path: str) -> bytes: + """Load exact packaged contract bytes by a safe package-relative path.""" + + if relative_path.startswith("/") or ".." in relative_path.split("/"): + raise ValueError("contract artifact path must remain package-relative") + return _contract_root().joinpath(*relative_path.split("/")).read_bytes() + + +@lru_cache(maxsize=None) +def _load_json_artifact(relative_path: str) -> Any: + return parse_strict_json(load_contract_artifact(relative_path)) + + +@lru_cache(maxsize=None) +def _validator(artifact: str) -> Draft202012Validator: + schema = _load_json_artifact(SCHEMA_FILES[artifact]) + Draft202012Validator.check_schema(schema) + return Draft202012Validator(schema) + + +def _schema_issues(value: Any, artifact: str) -> list[SceneValidationIssue]: + issues: list[SceneValidationIssue] = [] + for error in _validator(artifact).iter_errors(value): + path = _pointer(error.absolute_path) + if error.validator == "additionalProperties": + # jsonschema points to the containing object; this remains stable + # across Python and TypeScript validators. + message = "unknown field is forbidden by the closed schema" + else: + message = error.message + issues.append(_issue("schema_invalid", path, message, "structure")) + return issues + + +def _parse_input(value: Any, *, require_canonical: bool) -> tuple[Any | None, list[SceneValidationIssue]]: + if isinstance(value, MappingProxyType): + return _thaw_frozen_json(value), [] + if not isinstance(value, (bytes, bytearray, memoryview, str)): + return value, [] + try: + parsed = parse_canonical_json(value) if require_canonical else parse_strict_json(value) + return parsed, [] + except DuplicateKeyError as exc: + return None, [_issue("duplicate_json_key", "", str(exc), "parse")] + except UnicodeDecodeError as exc: + return None, [_issue("invalid_utf8", "", str(exc), "parse")] + except ValueError as exc: + message = str(exc) + if "BOM" in message: + code = "bom_forbidden" + elif "surrogate" in message: + code = "invalid_utf8" + elif "finite" in message or "NaN" in message or "Infinity" in message: + code = "nonfinite_json_number" + elif require_canonical and "canonical" in message: + code = "noncanonical_json" + elif "nesting" in message: + code = "resource_limit_exceeded" + else: + code = "schema_invalid" + phase = "budget" if code == "resource_limit_exceeded" else "parse" if code != "schema_invalid" else "structure" + return None, [_issue(code, "", message, phase)] + + +def _thaw_frozen_json(value: Any) -> Any: + """Convert the immutable package view without recursing through user JSON.""" + + result: dict[int, Any] = {} + stack: list[tuple[Any, int | None, Any]] = [(value, None, None)] + root: Any = None + while stack: + current, parent_id, parent_key = stack.pop() + if isinstance(current, Mapping): + target: dict[str, Any] = {} + if parent_id is None: + root = target + else: + result[parent_id][parent_key] = target + result[id(current)] = target + for key, child in reversed(tuple(current.items())): + stack.append((child, id(current), key)) + elif isinstance(current, tuple): + target_list: list[Any] = [None] * len(current) + if parent_id is None: + root = target_list + else: + result[parent_id][parent_key] = target_list + result[id(current)] = target_list + for index in range(len(current) - 1, -1, -1): + stack.append((current[index], id(current), index)) + elif parent_id is None: + root = current + else: + result[parent_id][parent_key] = current + return root + + +def _walk_json(value: Any, path: tuple[Any, ...] = ()) -> Iterable[tuple[tuple[Any, ...], Any]]: + stack = [(path, value)] + while stack: + current_path, current = stack.pop() + yield current_path, current + if isinstance(current, dict): + stack.extend( + ((*current_path, key), child) + for key, child in reversed(tuple(current.items())) + ) + elif isinstance(current, list): + stack.extend( + ((*current_path, index), child) + for index, child in reversed(tuple(enumerate(current))) + ) + + +def _is_metadata_path(path: tuple[Any, ...]) -> bool: + return any(part in {"metadata", "sdk_metadata"} for part in path) + + +def _is_identifier_path(path: tuple[Any, ...]) -> bool: + if not path: + return False + field = path[-1] + if isinstance(field, str) and ( + field.endswith("_id") + or field in {"definition_ref", "source_element_id", "topo_id"} + ): + return True + return ( + len(path) >= 2 + and isinstance(path[-2], str) + and path[-2] in _IDENTIFIER_ARRAY_KEYS + and isinstance(field, int) + ) + + +def _is_uri_path(path: tuple[Any, ...]) -> bool: + return bool(path) and isinstance(path[-1], str) and ( + path[-1] == "uri" or path[-1].endswith("_uri") + ) + + +def _domain_issues( + value: Any, *, limits: SceneResourceLimits = BASE_LIMITS +) -> list[SceneValidationIssue]: + issues: list[SceneValidationIssue] = [] + for path, child in _walk_json(value): + pointer = _pointer(path) + if isinstance(child, dict): + for key in child: + if not isinstance(key, str): + issues.append(_issue("schema_invalid", pointer, "JSON object keys must be strings", "structure")) + continue + try: + encoded_key = key.encode("utf-8") + except UnicodeEncodeError: + issues.append(_issue("invalid_utf8", pointer, "object key contains an unpaired surrogate", "parse")) + continue + key_pointer = _join_pointer(pointer, key) + if len(encoded_key) > limits.json_string_bytes: + issues.append(_issue("resource_limit_exceeded", key_pointer, "JSON object key exceeds resource limit", "budget")) + if _is_metadata_path(path) and len(encoded_key) > limits.structural_id_bytes: + issues.append(_issue("resource_limit_exceeded", key_pointer, "metadata key exceeds structural ID resource limit", "budget")) + if isinstance(child, float) and not math.isfinite(child): + issues.append(_issue("nonfinite_json_number", pointer, "JSON number must be finite", "parse")) + if isinstance(child, int) and not isinstance(child, bool) and abs(child) > 9_007_199_254_740_991: + issues.append(_issue("schema_invalid", pointer, "integer exceeds the JCS safe domain", "structure")) + if isinstance(child, str): + try: + encoded = child.encode("utf-8") + except UnicodeEncodeError: + issues.append(_issue("invalid_utf8", pointer, "string contains an unpaired surrogate", "parse")) + continue + if len(encoded) > limits.json_string_bytes: + issues.append(_issue("resource_limit_exceeded", pointer, "JSON string exceeds resource limit", "budget")) + if _is_uri_path(path) and len(encoded) > limits.uri_bytes: + issues.append(_issue("resource_limit_exceeded", pointer, "URI exceeds resource limit", "budget")) + if _is_identifier_path(path) and len(encoded) > limits.structural_id_bytes: + issues.append(_issue("resource_limit_exceeded", pointer, "identifier exceeds structural ID resource limit", "budget")) + if len(path) > limits.json_depth: + issues.append(_issue("resource_limit_exceeded", pointer, "JSON nesting exceeds resource limit", "budget")) + return issues + + +def json_resource_issues( + value: Any, *, limits: SceneResourceLimits = BASE_LIMITS +) -> tuple[SceneValidationIssue, ...]: + """Return deterministic JSON-domain resource issues for boundary probes.""" + + return _report(_domain_issues(value, limits=limits)).issues + + +def _resource_count_issues( + value: Any, + artifact: str, + *, + limits: SceneResourceLimits = BASE_LIMITS, +) -> list[SceneValidationIssue]: + if not isinstance(value, dict): + return [] + issues: list[SceneValidationIssue] = [] + collections: tuple[tuple[str, str], ...] = () + if artifact == "scene": + collections = ( + ("definitions", "definitions"), + ("nodes", "nodes"), + ("geometry_assets", "assets_per_kind"), + ("edge_assets", "assets_per_kind"), + ("entity_assets", "assets_per_kind"), + ("appearances", "appearances"), + ("connectors", "connectors"), + ("cameras", "cameras"), + ) + elif artifact == "entities": + collections = ( + ("entities", "entities_per_sidecar"), + ("face_groups", "entities_per_sidecar"), + ("edge_groups", "entities_per_sidecar"), + ) + elif artifact == "presentation": + collections = ( + ("node_overrides", "nodes"), + ("appearances", "appearances"), + ("cameras", "cameras"), + ) + for field, limit_name in collections: + records = value.get(field) + if isinstance(records, list): + try: + preflight_resource_count(len(records), limit_name, limits=limits) + except ValueError: + issues.append( + _issue( + "resource_limit_exceeded", + f"/{field}", + f"{field} count exceeds resource limit", + "budget", + ) + ) + if artifact == "scene": + nodes = value.get("nodes") + for index, node in enumerate(nodes if isinstance(nodes, list) else []): + source = node.get("source") if isinstance(node, dict) else None + component_path = source.get("component_path") if isinstance(source, dict) else None + if isinstance(component_path, list) and len(component_path) > limits.hierarchy_depth: + issues.append( + _issue( + "resource_limit_exceeded", + f"/nodes/{index}/source/component_path", + "hierarchy depth exceeds resource limit", + "budget", + ) + ) + connectors = value.get("connectors") + connector_records = connectors if isinstance(connectors, list) else [] + forward_edges = { + connector.get("connector_snapshot_id"): connector.get( + "forwarded_from", {} + ).get("source_connector_snapshot_id") + for connector in connector_records + if isinstance(connector, dict) + and connector.get("anchor_kind") == "forwarded" + and isinstance(connector.get("forwarded_from"), dict) + } + for start in forward_edges: + current = start + seen: set[Any] = set() + depth = 0 + while current in forward_edges and current not in seen: + seen.add(current) + current = forward_edges[current] + depth += 1 + if depth > limits.forwarded_connector_depth: + issues.append( + _issue( + "resource_limit_exceeded", + "/connectors", + "forwarded connector depth exceeds resource limit", + "budget", + ) + ) + break + return issues + + +def resource_count_issues( + value: Any, + artifact: str, + *, + limits: SceneResourceLimits = BASE_LIMITS, +) -> tuple[SceneValidationIssue, ...]: + """Return early collection and graph-depth resource issues.""" + + if artifact not in SCHEMA_FILES: + raise ValueError(f"unknown scene artifact: {artifact}") + return _report(_resource_count_issues(value, artifact, limits=limits)).issues + + +def _sorted_unique(values: Sequence[str]) -> bool: + return list(values) == sorted(set(values), key=_utf8_key) + + +def _array_order_issues(value: Any) -> list[SceneValidationIssue]: + issues: list[SceneValidationIssue] = [] + if not isinstance(value, dict): + return issues + for key, child in value.items(): + if key in _SET_ARRAY_KEYS and isinstance(child, list) and all(isinstance(item, str) for item in child): + if not _sorted_unique(child): + issues.append(_issue("array_order_invalid", f"/{key}", "set-like array is not sorted and unique")) + for path, child in _walk_json(value): + if not path or not isinstance(child, list): + continue + key = path[-1] + if key in _SET_ARRAY_KEYS and all(isinstance(item, str) for item in child) and not _sorted_unique(child): + issues.append(_issue("array_order_invalid", _pointer(path), "set-like array is not sorted and unique")) + for collection, id_key in _COLLECTION_SORT_KEYS.items(): + records = value.get(collection) + if isinstance(records, list) and all(isinstance(record, dict) and isinstance(record.get(id_key), str) for record in records): + ids = [record[id_key] for record in records] + if ids != sorted(ids, key=_utf8_key) or len(ids) != len(set(ids)): + issues.append(_issue("array_order_invalid", f"/{collection}", f"{collection} is not sorted and unique by {id_key}")) + return issues + + +def _is_vec3(value: Any) -> bool: + return isinstance(value, list) and len(value) == 3 and all(isinstance(item, (int, float)) and not isinstance(item, bool) and math.isfinite(item) for item in value) + + +def _transform_issue(transform: Any, path: str) -> SceneValidationIssue | None: + if not isinstance(transform, dict) or not all(_is_vec3(transform.get(key)) for key in ("origin", "x_axis", "y_axis", "z_axis")): + return None # Structural schema owns this error. + origin = transform["origin"] + x_axis = transform["x_axis"] + y_axis = transform["y_axis"] + z_axis = transform["z_axis"] + if any(abs(component) > 1e12 for component in origin): + return _issue("transform_invalid", path, "transform origin exceeds the scene coordinate limit") + dot = lambda left, right: sum(a * b for a, b in zip(left, right)) + norm = lambda vector: math.sqrt(dot(vector, vector)) + if any(abs(norm(axis) - 1.0) > 1e-12 for axis in (x_axis, y_axis, z_axis)): + return _issue("transform_invalid", path, "transform axes must be unit vectors within 1e-12") + if any(abs(dot(left, right)) > 1e-12 for left, right in ((x_axis, y_axis), (x_axis, z_axis), (y_axis, z_axis))): + return _issue("transform_invalid", path, "transform axes must be pairwise orthogonal within 1e-12") + cross = [ + x_axis[1] * y_axis[2] - x_axis[2] * y_axis[1], + x_axis[2] * y_axis[0] - x_axis[0] * y_axis[2], + x_axis[0] * y_axis[1] - x_axis[1] * y_axis[0], + ] + if any(abs(actual - expected) > 1e-12 for actual, expected in zip(cross, z_axis)): + return _issue("transform_invalid", path, "z_axis must equal cross(x_axis, y_axis) within 1e-12") + return None + + +def _analytic_geometry_issues(geometry: Any, path: str) -> list[SceneValidationIssue]: + if not isinstance(geometry, dict): + return [] + geometry_type = geometry.get("type") + direction_fields = { + "line": ("direction",), + "circle": ("normal", "x_direction"), + "ellipse": ("normal", "x_direction"), + "plane": ("normal", "x_direction"), + "cylinder": ("axis", "x_direction"), + "cone": ("axis", "x_direction"), + "sphere": ("axis", "x_direction"), + "torus": ("axis", "x_direction"), + }.get(geometry_type, ()) + coordinate_fields = { + "point": ("position",), + "line": ("origin",), + "circle": ("center",), + "ellipse": ("center",), + "plane": ("origin",), + "cylinder": ("origin",), + "cone": ("origin",), + "sphere": ("center",), + "torus": ("center",), + }.get(geometry_type, ()) + issues: list[SceneValidationIssue] = [] + for field in coordinate_fields: + vector = geometry.get(field) + if _is_vec3(vector) and any(abs(component) > 1e12 for component in vector): + issues.append(_issue("analytic_geometry_invalid", path + "/" + field, "analytic coordinate exceeds the scene coordinate limit")) + if not direction_fields or not all(_is_vec3(geometry.get(field)) for field in direction_fields): + return issues + vectors = [geometry[field] for field in direction_fields] + for field, vector in zip(direction_fields, vectors): + norm = math.sqrt(sum(component * component for component in vector)) + if abs(norm - 1.0) > 1e-12: + issues.append(_issue("analytic_geometry_invalid", path + "/" + field, "analytic direction must be a unit vector within 1e-12")) + if len(vectors) == 2: + dot = sum(left * right for left, right in zip(*vectors)) + if abs(dot) > 1e-12: + issues.append(_issue("analytic_geometry_invalid", path, "analytic axis and x_direction must be orthogonal within 1e-12")) + return issues + + +def _bounds_issues(bounds: Any, path: str, point: Sequence[float] | None = None) -> list[SceneValidationIssue]: + if not isinstance(bounds, dict) or not _is_vec3(bounds.get("min")) or not _is_vec3(bounds.get("max")): + return [] + minimum, maximum = bounds["min"], bounds["max"] + issues: list[SceneValidationIssue] = [] + if any(low > high for low, high in zip(minimum, maximum)): + issues.append(_issue("bounds_invalid", path, "bounds min exceeds max")) + if any(abs(component) > 1e12 for component in (*minimum, *maximum)): + issues.append(_issue("bounds_invalid", path, "bounds exceed the scene coordinate limit")) + if point is not None and _is_vec3(list(point)): + max_abs = max(1.0, *(abs(component) for component in (*minimum, *maximum, *point))) + epsilon = max(1e-9, 1e-12 * max_abs) + if any(value < low - epsilon or value > high + epsilon for value, low, high in zip(point, minimum, maximum)): + issues.append(_issue("bounds_invalid", path, "point or centroid is outside bounds")) + return issues + + +def _encode_segment(value: str) -> str: + return quote(value, safe="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~") + + +def _root_id_from_definition(definition: Mapping[str, Any]) -> Any: + source = definition.get("source") + return source.get("root_id") if isinstance(source, dict) else None + + +def _scene_semantic_issues(scene: Mapping[str, Any]) -> list[SceneValidationIssue]: + issues: list[SceneValidationIssue] = [] + if scene.get("revision") != compute_scene_revision(scene): + issues.append(_issue("revision_mismatch", "/revision", "scene revision does not match canonical draft hash")) + source = scene.get("source") + source_kind = source.get("kind") if isinstance(source, dict) else None + graph_id = source.get("graph_id") if isinstance(source, dict) else None + source_id = source.get("source_id") if isinstance(source, dict) else None + options = scene.get("compile_options", {}) + presentation_source = scene.get("presentation_source") + if isinstance(options, dict) and isinstance(source, dict): + embed_source = options.get("embed_source") + embedded = "embedded_artifact_uri" in source + if source_kind == "manual" and (embed_source or embedded): + issues.append(_issue("source_matrix_invalid", "/source", "manual source cannot be embedded")) + elif source_kind in {"model", "imported"} and embed_source != embedded: + issues.append(_issue("source_matrix_invalid", "/source", "embed_source does not match source embedding fields")) + source_files = source.get("source_files", []) + if isinstance(source_files, list): + paths = [record.get("path") for record in source_files if isinstance(record, dict)] + if paths != sorted(paths, key=lambda value: str(value).encode("utf-8")) or len(paths) != len(set(paths)): + issues.append(_issue("array_order_invalid", "/source/source_files", "source_files is not sorted and unique by path")) + casefold_paths: dict[str, int] = {} + for index, record in enumerate(source_files): + if not isinstance(record, dict): + continue + path = record.get("path") + if isinstance(path, str): + segments = path.split("/") + if ( + not _SOURCE_PATH_RE.fullmatch(path) + or any(segment in {"", ".", ".."} for segment in segments) + ): + issues.append(_issue("source_matrix_invalid", f"/source/source_files/{index}/path", "source file path is not archive-safe")) + folded = path.lower() + if folded in casefold_paths and paths[casefold_paths[folded]] != path: + issues.append(_issue("source_matrix_invalid", f"/source/source_files/{index}/path", "source file paths collide case-insensitively")) + else: + casefold_paths[folded] = index + if record.get("uri") != f"sources/{path}": + issues.append(_issue("source_matrix_invalid", f"/source/source_files/{index}/uri", "source file URI does not preserve the project-relative path")) + embed_presentation = options.get("embed_presentation") + if presentation_source is None and embed_presentation: + issues.append(_issue("source_matrix_invalid", "/compile_options/embed_presentation", "presentation embedding requires presentation_source")) + if isinstance(presentation_source, dict) and embed_presentation != ("embedded_artifact_uri" in presentation_source): + issues.append(_issue("source_matrix_invalid", "/presentation_source", "embed_presentation does not match presentation embedding fields")) + + definitions = scene.get("definitions", []) + definition_map = {record.get("definition_id"): record for record in definitions if isinstance(record, dict)} + nodes = scene.get("nodes", []) + node_map = {record.get("node_id"): record for record in nodes if isinstance(record, dict)} + appearances = scene.get("appearances", []) + appearance_map = {record.get("appearance_id"): record for record in appearances if isinstance(record, dict)} + geometry_map = {record.get("asset_id"): record for record in scene.get("geometry_assets", []) if isinstance(record, dict)} + edge_map = {record.get("asset_id"): record for record in scene.get("edge_assets", []) if isinstance(record, dict)} + entity_map = {record.get("entity_asset_id"): record for record in scene.get("entity_assets", []) if isinstance(record, dict)} + + for index, definition in enumerate(definitions): + if not isinstance(definition, dict): + continue + path = f"/definitions/{index}" + kind = definition.get("kind") + nested = definition.get("source", {}) + nested_kind = nested.get("kind") if isinstance(nested, dict) else None + root_id = nested.get("root_id") if isinstance(nested, dict) else None + valid_source = { + "model": {"part": "product_model", "assembly": "product_model", "shape": "model_output"}, + "manual": {"part": "product_manual", "assembly": "product_manual", "shape": "manual"}, + "imported": {"shape": "imported"}, + }.get(source_kind, {}).get(kind) + if nested_kind != valid_source: + issues.append(_issue("source_matrix_invalid", path + "/source", "definition source is incompatible with scene and definition kinds")) + if nested_kind in {"product_model", "model_output"} and nested.get("graph_id") != graph_id: + issues.append(_issue("source_matrix_invalid", path + "/source/graph_id", "definition graph_id differs from scene graph_id")) + if nested_kind == "manual" and nested.get("source_id") != source_id: + issues.append(_issue("source_matrix_invalid", path + "/source/source_id", "definition manual source_id differs from scene source_id")) + if nested_kind in {"product_model", "product_manual"}: + expected_semantic = "Part" if kind == "part" else "Assembly" + if nested.get("semantic_type") != expected_semantic: + issues.append(_issue("source_matrix_invalid", path + "/source/semantic_type", "semantic_type differs from definition kind")) + expected_id = f"definition/{root_id}/{kind}/{_encode_segment(str(nested.get('semantic_id', '')))}" + elif nested_kind == "model_output": + expected_id = f"definition/{root_id}/shape/model/{_encode_segment(str(nested.get('graph_id', '')))}/{_encode_segment(str(nested.get('node_id', '')))}/{nested.get('output_slot')}" + elif nested_kind == "imported": + expected_id = f"definition/{root_id}/shape/imported/{_encode_segment(str(nested.get('source_element_id', '')))}" + else: + expected_id = f"definition/{root_id}/shape/manual/{_encode_segment(str(nested.get('source_id', '')))}" + if definition.get("definition_id") != expected_id: + issues.append(_issue("source_matrix_invalid", path + "/definition_id", "definition_id does not match its source-derived structural ID")) + for field, registry in (("geometry_asset_id", geometry_map), ("edge_asset_id", edge_map), ("entity_asset_id", entity_map), ("appearance_id", appearance_map)): + reference = definition.get(field) + if reference is not None and reference not in registry: + issues.append(_issue("reference_missing", path + "/" + field, f"referenced {field} does not exist")) + + roots: list[Mapping[str, Any]] = [] + sibling_orders: dict[Any, list[int]] = {} + referenced_definitions: set[Any] = set() + for index, node in enumerate(nodes): + if not isinstance(node, dict): + continue + path = f"/nodes/{index}" + definition = definition_map.get(node.get("definition_id")) + if definition is None: + issues.append(_issue("reference_missing", path + "/definition_id", "node definition does not exist")) + continue + referenced_definitions.add(node.get("definition_id")) + node_source = node.get("source", {}) + node_kind = node_source.get("kind") if isinstance(node_source, dict) else None + root_id = node_source.get("root_id") if isinstance(node_source, dict) else None + definition_root = _root_id_from_definition(definition) + expected_node_kind = "product_occurrence" if definition.get("kind") in {"part", "assembly"} else "shape_root" + if node_kind != expected_node_kind or root_id != definition_root: + issues.append(_issue("source_matrix_invalid", path + "/source", "node source kind/root does not match definition")) + if node_kind == "product_occurrence": + component_path = node_source.get("component_path", []) + expected_node_id = "instance/" + str(root_id) + "".join("/" + _encode_segment(str(segment)) for segment in component_path) + expected_parent = None if not component_path else "instance/" + str(root_id) + "".join("/" + _encode_segment(str(segment)) for segment in component_path[:-1]) + else: + expected_node_id = f"instance/{root_id}" + expected_parent = None + if node.get("node_id") != expected_node_id: + issues.append(_issue("hierarchy_invalid", path + "/node_id", "node_id does not match source-derived structural ID")) + if node.get("parent_node_id") != expected_parent: + issues.append(_issue("hierarchy_invalid", path + "/parent_node_id", "parent_node_id does not match component path")) + if node.get("parent_node_id") is None: + roots.append(node) + elif node.get("parent_node_id") not in node_map: + issues.append(_issue("reference_missing", path + "/parent_node_id", "parent node does not exist")) + sibling_orders.setdefault(node.get("parent_node_id"), []).append(node.get("order")) + override = node.get("appearance_override_id") + if node.get("selectable") is not True: + issues.append(_issue("source_matrix_invalid", path + "/selectable", "node selectability must use the deterministic true default")) + if presentation_source is None: + if node.get("visible") is not True: + issues.append(_issue("source_matrix_invalid", path + "/visible", "node visibility requires presentation_source")) + if override is not None: + issues.append(_issue("source_matrix_invalid", path + "/appearance_override_id", "appearance override requires presentation_source")) + if override is not None and override not in appearance_map: + issues.append(_issue("reference_missing", path + "/appearance_override_id", "appearance override does not exist")) + if definition.get("kind") == "assembly" and override is not None: + issues.append(_issue("source_matrix_invalid", path + "/appearance_override_id", "assembly occurrence cannot carry appearance override")) + transform_issue = _transform_issue(node.get("transform"), path + "/transform") + if transform_issue: + issues.append(transform_issue) + for parent, orders in sibling_orders.items(): + if all(isinstance(order, int) and not isinstance(order, bool) for order in orders) and sorted(orders) != list(range(len(orders))): + issues.append(_issue("hierarchy_invalid", "/nodes", f"sibling order under {parent!r} is not continuous")) + root_ids = [node.get("source", {}).get("root_id") for node in roots if isinstance(node.get("source"), dict)] + expected_roots = sorted(root_ids, key=lambda value: str(value).encode("utf-8")) + actual_roots = [root.get("source", {}).get("root_id") for root in sorted(roots, key=lambda item: item.get("order", -1))] + if actual_roots != expected_roots: + issues.append(_issue("hierarchy_invalid", "/nodes", "root order does not match unsigned UTF-8 root_id order")) + definition_ids = set(definition_map) + if definition_ids or node_map: + if referenced_definitions != definition_ids: + issues.append(_issue("hierarchy_invalid", "/nodes", "every definition must be instantiated by at least one node")) + definition_root_ids = {_root_id_from_definition(definition) for definition in definition_map.values()} + root_counts = { + root_id: sum( + 1 + for root in roots + if isinstance(root.get("source"), dict) + and root["source"].get("root_id") == root_id + ) + for root_id in definition_root_ids + } + if any(count != 1 for count in root_counts.values()): + issues.append(_issue("hierarchy_invalid", "/nodes", "each definition root_id must have exactly one root occurrence")) + + used_assets = {definition.get(field) for definition in definitions if isinstance(definition, dict) for field in ("geometry_asset_id", "edge_asset_id", "entity_asset_id") if definition.get(field) is not None} + declared_assets = set(geometry_map) | set(edge_map) | set(entity_map) + if used_assets != declared_assets: + issues.append(_issue("reference_missing", "/definitions", "asset records must be referenced exactly by definitions")) + for collection, kind in (("geometry_assets", "geometry"), ("edge_assets", "edges")): + for index, asset in enumerate(scene.get(collection, [])): + if not isinstance(asset, dict): + continue + path = f"/{collection}/{index}" + if asset.get("asset_id") != asset.get("content_hash"): + issues.append(_issue("source_matrix_invalid", path + "/asset_id", "asset_id must equal content_hash")) + digest = str(asset.get("asset_id", "")).removeprefix("sha256:") + if asset.get("uri") != f"{kind}/sha256-{digest}.glb": + issues.append(_issue("source_matrix_invalid", path + "/uri", "asset URI does not match content hash")) + tessellation = asset.get("tessellation", {}) + if isinstance(tessellation, dict) and isinstance(options, dict): + if tessellation.get("linear_tolerance") != options.get("linear_tolerance"): + issues.append(_issue("source_matrix_invalid", path + "/tessellation/linear_tolerance", "asset tolerance differs from compile options")) + if collection == "geometry_assets" and tessellation.get("angular_tolerance") != options.get("angular_tolerance"): + issues.append(_issue("source_matrix_invalid", path + "/tessellation/angular_tolerance", "asset tolerance differs from compile options")) + issues.extend(_bounds_issues(asset.get("scene_local_bounds"), path + "/scene_local_bounds")) + for index, asset in enumerate(scene.get("entity_assets", [])): + if not isinstance(asset, dict): + continue + path = f"/entity_assets/{index}" + if asset.get("entity_asset_id") != asset.get("content_hash"): + issues.append(_issue("source_matrix_invalid", path + "/entity_asset_id", "entity_asset_id must equal content_hash")) + digest = str(asset.get("entity_asset_id", "")).removeprefix("sha256:") + if asset.get("uri") != f"entities/sha256-{digest}.json": + issues.append(_issue("source_matrix_invalid", path + "/uri", "entity URI does not match content hash")) + for index, appearance in enumerate(appearances): + if not isinstance(appearance, dict): + continue + path = f"/appearances/{index}" + draft = dict(appearance) + draft.pop("appearance_id", None) + expected = "appearance/evaluated/" + hashlib.sha256(canonical_json_bytes(draft)).hexdigest() + if appearance.get("appearance_id") != expected: + issues.append(_issue("source_matrix_invalid", path + "/appearance_id", "appearance_id does not match content-derived identity")) + appearance_source = appearance.get("source") + if isinstance(appearance_source, dict): + if appearance_source.get("kind") == "product_material" and source_kind not in {"model", "manual"}: + issues.append(_issue("source_matrix_invalid", path + "/source", "product material source is incompatible with scene source")) + if appearance_source.get("kind") == "product_material": + matching_definitions = ( + definition + for definition in definitions + if isinstance(definition, dict) + and definition.get("kind") == "part" + and definition.get("appearance_id") == appearance.get("appearance_id") + ) + if not any( + _root_id_from_definition(definition) == appearance_source.get("root_id") + for definition in matching_definitions + ): + issues.append(_issue("source_matrix_invalid", path + "/source/root_id", "product material appearance provenance does not match a Part definition root")) + if appearance_source.get("kind") == "presentation": + if not isinstance(presentation_source, dict) or appearance_source.get("presentation_id") != presentation_source.get("presentation_id"): + issues.append(_issue("source_matrix_invalid", path + "/source", "presentation appearance source does not match presentation_source")) + + connectors = scene.get("connectors", []) + if source_kind == "imported" and connectors: + issues.append(_issue("source_matrix_invalid", "/connectors", "imported scene connectors must be empty")) + connector_map = {record.get("connector_snapshot_id"): record for record in connectors if isinstance(record, dict)} + connector_ids_by_owner: dict[Any, set[Any]] = {} + forward_edges: dict[Any, Any] = {} + for index, connector in enumerate(connectors): + if not isinstance(connector, dict): + continue + path = f"/connectors/{index}" + owner = definition_map.get(connector.get("owner_definition_id")) + if owner is None: + issues.append(_issue("reference_missing", path + "/owner_definition_id", "connector owner definition does not exist")) + continue + owner_ids = connector_ids_by_owner.setdefault(connector.get("owner_definition_id"), set()) + if connector.get("connector_id") in owner_ids: + issues.append(_issue("id_duplicate", path + "/connector_id", "connector_id is duplicated within owner definition")) + owner_ids.add(connector.get("connector_id")) + owner_source = owner.get("source", {}) + root_id = owner_source.get("root_id") if isinstance(owner_source, dict) else None + owner_semantic = owner_source.get("semantic_id") if isinstance(owner_source, dict) else None + expected_snapshot = f"connector/{root_id}/{owner.get('kind')}/{_encode_segment(str(owner_semantic))}/{_encode_segment(str(connector.get('connector_id', '')))}" + if connector.get("connector_snapshot_id") != expected_snapshot: + issues.append(_issue("connector_invalid", path + "/connector_snapshot_id", "connector snapshot ID does not match owner and connector IDs")) + anchor_kind = connector.get("anchor_kind") + if anchor_kind == "geometry" and owner.get("kind") != "part": + issues.append(_issue("connector_invalid", path + "/owner_definition_id", "geometry connector owner must be a Part")) + if anchor_kind == "placement" and owner.get("kind") not in {"part", "assembly"}: + issues.append(_issue("connector_invalid", path + "/owner_definition_id", "connector owner must be a Product definition")) + if anchor_kind == "forwarded" and owner.get("kind") != "assembly": + issues.append(_issue("connector_invalid", path + "/owner_definition_id", "forwarded connector owner must be an Assembly")) + connector_source = connector.get("source") + if source_kind == "model": + if not isinstance(connector_source, dict) or connector_source.get("kind") != "model_operation" or connector_source.get("graph_id") != graph_id: + issues.append(_issue("connector_invalid", path + "/source", "model connector must use the scene graph model_operation source")) + elif connector_source.get("output_slot") != 0: + issues.append(_issue("connector_invalid", path + "/source/output_slot", "connector producer output_slot must be 0")) + elif source_kind == "manual": + if not isinstance(connector_source, dict) or connector_source != {"kind": "manual", "source_id": source_id}: + issues.append(_issue("connector_invalid", path + "/source", "manual connector source must equal the top-level source_id")) + transform_issue = _transform_issue(connector.get("local_transform"), path + "/local_transform") + if transform_issue: + issues.append(transform_issue) + if anchor_kind == "forwarded": + forwarded = connector.get("forwarded_from", {}) + source_snapshot = forwarded.get("source_connector_snapshot_id") if isinstance(forwarded, dict) else None + source_connector = connector_map.get(source_snapshot) + if source_connector is None or source_connector.get("owner_definition_id") != forwarded.get("source_definition_id") or source_connector.get("connector_id") != forwarded.get("source_connector_id"): + issues.append(_issue("connector_invalid", path + "/forwarded_from", "forwarded connector source snapshot ownership is invalid")) + forward_edges[connector.get("connector_snapshot_id")] = source_snapshot + if isinstance(forwarded, dict) and forwarded.get("offset") is not None: + transform_issue = _transform_issue(forwarded.get("offset"), path + "/forwarded_from/offset") + if transform_issue: + issues.append(transform_issue) + if isinstance(forwarded, dict) and owner.get("kind") == "assembly": + owner_nodes = [ + node + for node in nodes + if isinstance(node, dict) + and node.get("definition_id") == connector.get("owner_definition_id") + and isinstance(node.get("source"), dict) + and node["source"].get("kind") == "product_occurrence" + ] + source_children: list[Mapping[str, Any]] = [] + for owner_node in owner_nodes: + owner_node_source = owner_node["source"] + expected_path = [ + *owner_node_source.get("component_path", []), + forwarded.get("source_component_id"), + ] + matches = [ + node + for node in nodes + if isinstance(node, dict) + and isinstance(node.get("source"), dict) + and node["source"].get("kind") == "product_occurrence" + and node["source"].get("root_id") + == owner_node_source.get("root_id") + and node["source"].get("component_path") == expected_path + and node.get("parent_node_id") == owner_node.get("node_id") + ] + if ( + len(matches) != 1 + or matches[0].get("definition_id") + != forwarded.get("source_definition_id") + ): + issues.append( + _issue( + "connector_invalid", + path + "/forwarded_from", + "forwarded connector source component is not one exact direct child", + ) + ) + else: + source_children.append(matches[0]) + if source_children and any( + child.get("transform") != source_children[0].get("transform") + for child in source_children[1:] + ): + issues.append( + _issue( + "connector_invalid", + path + "/forwarded_from", + "forwarded connector source child transforms differ between owner occurrences", + ) + ) + if source_children and isinstance(source_connector, dict): + child_transform = source_children[0].get("transform") + source_transform = source_connector.get("local_transform") + offset = forwarded.get("offset") or { + "origin": [0, 0, 0], + "x_axis": [1, 0, 0], + "y_axis": [0, 1, 0], + "z_axis": [0, 0, 1], + } + if ( + all( + isinstance(value, dict) + and _transform_issue(value, "") is None + for value in (child_transform, source_transform, offset) + ) + and isinstance(connector.get("local_transform"), dict) + and _transform_issue(connector["local_transform"], "") is None + ): + expected_transform = compose_rigid_transforms( + compose_rigid_transforms(child_transform, source_transform), + offset, + ) + if not rigid_transforms_equal( + connector["local_transform"], expected_transform + ): + issues.append( + _issue( + "connector_invalid", + path + "/local_transform", + "forwarded connector transform does not match child, source, and offset composition", + ) + ) + for start in forward_edges: + seen: set[Any] = set() + current = start + while current in forward_edges: + if current in seen: + issues.append(_issue("connector_invalid", "/connectors", "forwarded connector graph contains a cycle")) + break + seen.add(current) + current = forward_edges[current] + for index, camera in enumerate(scene.get("cameras", [])): + if not isinstance(camera, dict): + continue + path = f"/cameras/{index}" + if presentation_source is None: + issues.append(_issue("source_matrix_invalid", path, "evaluated scene camera requires presentation_source")) + if camera.get("parent_node_id") is not None and camera.get("parent_node_id") not in node_map: + issues.append(_issue("reference_missing", path + "/parent_node_id", "camera parent node does not exist")) + if isinstance(camera.get("near"), (int, float)) and isinstance(camera.get("far"), (int, float)) and camera["far"] <= camera["near"]: + issues.append(_issue("bounds_invalid", path + "/far", "camera far must exceed near")) + transform_issue = _transform_issue(camera.get("transform"), path + "/transform") + if transform_issue: + issues.append(transform_issue) + return issues + + +def validate_scene_manifest( + scene: Any, *, limits: SceneResourceLimits = BASE_LIMITS +) -> SceneValidationReport: + issues = _serialized_size_issues(scene, "scene", limits=limits) + value, parse_issues = _parse_input( + scene, + require_canonical=isinstance(scene, (bytes, bytearray, memoryview, str)), + ) + issues.extend(parse_issues) + if value is None and issues: + return _report(issues, artifact="scene") + issues.extend(_domain_issues(value, limits=limits)) + issues.extend(_resource_count_issues(value, "scene", limits=limits)) + if _has_unsafe_depth(issues): + return _report(issues, artifact="scene") + issues.extend(_schema_issues(value, "scene")) + if isinstance(value, dict) and not _has_blocking_issues(issues): + issues.extend(_array_order_issues(value)) + issues.extend(_scene_semantic_issues(value)) + return _report(issues, artifact="scene") + + +def _entity_semantic_issues(asset: Mapping[str, Any]) -> list[SceneValidationIssue]: + issues: list[SceneValidationIssue] = [] + entities = asset.get("entities", []) + entity_map = {entity.get("entity_id"): entity for entity in entities if isinstance(entity, dict)} + if len(entity_map) != len(entities): + issues.append(_issue("id_duplicate", "/entities", "entity IDs must be unique")) + for kind in ("solid", "face", "edge", "vertex"): + actual = { + entity_id + for entity_id, entity in entity_map.items() + if entity.get("kind") == kind and isinstance(entity_id, str) + } + expected = {f"entity/{kind}/{index}" for index in range(len(actual))} + if actual != expected: + issues.append(_issue("entity_topology_invalid", "/entities", f"{kind} entity IDs must use dense zero-based ordinals")) + kind_geometry = { + "solid": {"brep_solid"}, + "face": {"plane", "cylinder", "cone", "sphere", "torus", "bspline_surface", "other_surface"}, + "edge": {"line", "circle", "ellipse", "bspline_curve", "other_curve"}, + "vertex": {"point"}, + } + expected_property_keys = { + "solid": {"quality", "bounds", "volume", "surface_area", "centroid"}, + "face": {"quality", "bounds", "area", "centroid", "orientation"}, + "edge": {"quality", "bounds", "length", "centroid"}, + "vertex": {"quality", "bounds", "position"}, + } + for index, entity in enumerate(entities): + if not isinstance(entity, dict): + continue + path = f"/entities/{index}" + entity_id = entity.get("entity_id") + kind = entity.get("kind") + if isinstance(entity_id, str) and not entity_id.startswith(f"entity/{kind}/"): + issues.append(_issue("entity_topology_invalid", path + "/entity_id", "entity ID kind differs from entity kind")) + geometry = entity.get("geometry", {}) + if isinstance(geometry, dict) and geometry.get("type") not in kind_geometry.get(kind, set()): + issues.append(_issue("entity_topology_invalid", path + "/geometry/type", "geometry variant is incompatible with entity kind")) + issues.extend(_analytic_geometry_issues(geometry, path + "/geometry")) + properties = entity.get("properties", {}) + if isinstance(properties, dict) and set(properties) != expected_property_keys.get(kind): + issues.append(_issue("entity_topology_invalid", path + "/properties", "property record is incompatible with entity kind")) + parents = entity.get("parent_entity_ids", []) + children = entity.get("child_entity_ids", []) + if kind == "solid" and (parents or not children or any(entity_map.get(child, {}).get("kind") != "face" for child in children)): + issues.append(_issue("entity_topology_invalid", path, "solid must own one or more faces and have no parent")) + if kind == "face" and (len(parents) != 1 or entity_map.get(parents[0], {}).get("kind") != "solid" or not children or any(entity_map.get(child, {}).get("kind") != "edge" for child in children)): + issues.append(_issue("entity_topology_invalid", path, "face must have one solid parent and one or more edge children")) + if kind == "edge" and (not parents or any(entity_map.get(parent, {}).get("kind") != "face" for parent in parents) or len(children) not in {1, 2} or any(entity_map.get(child, {}).get("kind") != "vertex" for child in children)): + issues.append(_issue("entity_topology_invalid", path, "edge must have face parents and one or two vertex children")) + if kind == "vertex" and (not parents or any(entity_map.get(parent, {}).get("kind") != "edge" for parent in parents) or children): + issues.append(_issue("entity_topology_invalid", path, "vertex must have edge parents and no children")) + for parent in parents: + if parent not in entity_map: + issues.append(_issue("reference_missing", path + "/parent_entity_ids", "parent entity does not exist")) + elif entity_id not in entity_map[parent].get("child_entity_ids", []): + issues.append(_issue("entity_topology_invalid", path + "/parent_entity_ids", "parent relation is not reciprocal")) + for child in children: + if child not in entity_map: + issues.append(_issue("reference_missing", path + "/child_entity_ids", "child entity does not exist")) + elif entity_id not in entity_map[child].get("parent_entity_ids", []): + issues.append(_issue("entity_topology_invalid", path + "/child_entity_ids", "child relation is not reciprocal")) + source = entity.get("source", {}) + if isinstance(source, dict) and source.get("kind") == "model_topology": + expected = {"solid": "SOLID", "face": "FACE", "edge": "EDGE", "vertex": "VERTEX"}.get(kind) + if source.get("topology_kind") != expected: + issues.append(_issue("source_matrix_invalid", path + "/source/topology_kind", "topology source kind differs from entity kind")) + frame = entity.get("sdk_connector_frame") + if kind == "solid" and frame is not None: + issues.append(_issue("connector_invalid", path + "/sdk_connector_frame", "solid connector frame must be null")) + if kind in {"face", "vertex"} and frame is None: + issues.append(_issue("connector_invalid", path + "/sdk_connector_frame", "face and vertex connector frames must be present")) + if frame is not None: + transform_issue = _transform_issue(frame, path + "/sdk_connector_frame") + if transform_issue: + issues.append(transform_issue) + status = entity.get("render_status") + if kind != "edge" and status != "rendered": + issues.append(_issue("entity_topology_invalid", path + "/render_status", "only edges may be degenerate")) + if kind == "solid" and entity.get("connector_binding_status") != "not_applicable": + issues.append(_issue("connector_invalid", path + "/connector_binding_status", "solid binding status must be not_applicable")) + if kind == "edge" and frame is None and entity.get("connector_binding_status") not in {"frame_undefined", "owner_not_part", "source_not_model"}: + issues.append(_issue("connector_invalid", path + "/connector_binding_status", "edge without frame cannot be supported")) + if kind == "vertex" and isinstance(geometry, dict): + point = geometry.get("position") + if isinstance(properties, dict): + if properties.get("position") != point: + issues.append(_issue("bounds_invalid", path + "/properties/position", "vertex property position differs from geometry point")) + issues.extend(_bounds_issues(properties.get("bounds"), path + "/properties/bounds", point)) + elif isinstance(properties, dict): + issues.extend(_bounds_issues(properties.get("bounds"), path + "/properties/bounds", properties.get("centroid"))) + + face_ids = {entity_id for entity_id, entity in entity_map.items() if entity.get("kind") == "face"} + rendered_edge_ids = {entity_id for entity_id, entity in entity_map.items() if entity.get("kind") == "edge" and entity.get("render_status") == "rendered"} + degenerate_edge_ids = {entity_id for entity_id, entity in entity_map.items() if entity.get("kind") == "edge" and entity.get("render_status") == "degenerate"} + for groups_key, expected_ids, divisor in (("face_groups", face_ids, 3), ("edge_groups", rendered_edge_ids, 2)): + groups = asset.get(groups_key, []) + group_ids = [group.get("entity_id") for group in groups if isinstance(group, dict)] + if set(group_ids) != expected_ids or len(group_ids) != len(set(group_ids)): + issues.append(_issue("entity_range_invalid", f"/{groups_key}", f"{groups_key} do not map exactly one group per rendered entity")) + expected_first = 0 + for index, group in enumerate(groups): + if not isinstance(group, dict): + continue + path = f"/{groups_key}/{index}" + if group.get("group_id") != index or group.get("first_index") != expected_first: + issues.append(_issue("entity_range_invalid", path, "group IDs and ranges must be continuous")) + count = group.get("index_count") + if isinstance(count, int) and not isinstance(count, bool): + if count <= 0 or count % divisor: + issues.append(_issue("entity_range_invalid", path + "/index_count", "group count has invalid primitive cardinality")) + expected_first += count + if any(group.get("entity_id") in degenerate_edge_ids for group in asset.get("edge_groups", []) if isinstance(group, dict)): + issues.append(_issue("entity_range_invalid", "/edge_groups", "degenerate edges must not have render groups")) + return issues + + +def validate_entity_asset( + asset: Any, *, limits: SceneResourceLimits = BASE_LIMITS +) -> SceneValidationReport: + issues = _serialized_size_issues(asset, "entities", limits=limits) + value, parse_issues = _parse_input( + asset, + require_canonical=isinstance(asset, (bytes, bytearray, memoryview, str)), + ) + issues.extend(parse_issues) + if value is None and issues: + return _report(issues, artifact="entities") + issues.extend(_domain_issues(value, limits=limits)) + issues.extend(_resource_count_issues(value, "entities", limits=limits)) + if _has_unsafe_depth(issues): + return _report(issues, artifact="entities") + issues.extend(_schema_issues(value, "entities")) + if isinstance(value, dict) and not _has_blocking_issues(issues): + issues.extend(_array_order_issues(value)) + issues.extend(_entity_semantic_issues(value)) + return _report(issues, artifact="entities") + + +def _presentation_semantic_issues(value: Mapping[str, Any]) -> list[SceneValidationIssue]: + issues: list[SceneValidationIssue] = [] + appearances = value.get("appearances", []) + names = [record.get("name") for record in appearances if isinstance(record, dict)] + if len(names) != len(set(names)): + issues.append(_issue("id_duplicate", "/appearances", "presentation appearance names must be unique")) + name_set = set(names) + overrides = value.get("node_overrides", []) + node_ids = [record.get("node_id") for record in overrides if isinstance(record, dict)] + if len(node_ids) != len(set(node_ids)): + issues.append(_issue("id_duplicate", "/node_overrides", "node overrides must target unique node IDs")) + for index, override in enumerate(overrides): + if isinstance(override, dict) and override.get("appearance_name") is not None and override.get("appearance_name") not in name_set: + issues.append(_issue("presentation_reference_invalid", f"/node_overrides/{index}/appearance_name", "appearance_name does not exist")) + cameras = value.get("cameras", []) + camera_names = [record.get("name") for record in cameras if isinstance(record, dict)] + if len(camera_names) != len(set(camera_names)): + issues.append(_issue("id_duplicate", "/cameras", "presentation camera names must be unique")) + for index, camera in enumerate(cameras): + if not isinstance(camera, dict): + continue + if isinstance(camera.get("near"), (int, float)) and isinstance(camera.get("far"), (int, float)) and camera["far"] <= camera["near"]: + issues.append(_issue("bounds_invalid", f"/cameras/{index}/far", "camera far must exceed near")) + transform_issue = _transform_issue(camera.get("transform"), f"/cameras/{index}/transform") + if transform_issue: + issues.append(transform_issue) + return issues + + +def validate_presentation( + value: Any, *, limits: SceneResourceLimits = BASE_LIMITS +) -> SceneValidationReport: + issues = _serialized_size_issues(value, "presentation", limits=limits) + parsed, parse_issues = _parse_input( + value, + require_canonical=isinstance(value, (bytes, bytearray, memoryview, str)), + ) + issues.extend(parse_issues) + if parsed is None and issues: + return _report(issues, artifact="presentation") + issues.extend(_domain_issues(parsed, limits=limits)) + issues.extend(_resource_count_issues(parsed, "presentation", limits=limits)) + if _has_unsafe_depth(issues): + return _report(issues, artifact="presentation") + issues.extend(_schema_issues(parsed, "presentation")) + if isinstance(parsed, dict) and not _has_blocking_issues(issues): + issues.extend(_presentation_semantic_issues(parsed)) + return _report(issues, artifact="presentation") + + +def validate_connector_binding( + value: Any, *, limits: SceneResourceLimits = BASE_LIMITS +) -> SceneValidationReport: + issues = _serialized_size_issues(value, "connector_binding", limits=limits) + parsed, parse_issues = _parse_input( + value, + require_canonical=isinstance(value, (bytes, bytearray, memoryview, str)), + ) + issues.extend(parse_issues) + if parsed is None and issues: + return _report(issues, artifact="connector_binding") + issues.extend(_domain_issues(parsed, limits=limits)) + if _has_unsafe_depth(issues): + return _report(issues, artifact="connector_binding") + issues.extend(_schema_issues(parsed, "connector_binding")) + if isinstance(parsed, dict) and not _has_blocking_issues(issues): + source_model = parsed.get("source_model", {}) + expected = parsed.get("target", {}).get("expected_source", {}) if isinstance(parsed.get("target"), dict) else {} + if isinstance(source_model, dict) and isinstance(expected, dict) and source_model.get("graph_id") != expected.get("graph_id"): + issues.append(_issue("source_matrix_invalid", "/target/expected_source/graph_id", "target graph differs from source model graph")) + entity_id = parsed.get("target", {}).get("entity_id") if isinstance(parsed.get("target"), dict) else None + if isinstance(entity_id, str) and entity_id.startswith("entity/vertex/") and parsed.get("target", {}).get("flip") is not False: + issues.append(_issue("connector_invalid", "/target/flip", "vertex connector target requires flip=false")) + return _report(issues, artifact="connector_binding") + + +def _normalized_product_issues(value: Mapping[str, Any]) -> list[SceneValidationIssue]: + issues: list[SceneValidationIssue] = [] + connectors = value.get("connectors", []) + connector_ids = [connector.get("connector_id") for connector in connectors if isinstance(connector, dict)] + if len(connector_ids) != len(set(connector_ids)): + issues.append(_issue("id_duplicate", "/connectors", "connector IDs must be unique")) + if value.get("kind") == "assembly": + components = value.get("components", []) + component_ids = [component.get("component_id") for component in components if isinstance(component, dict)] + if len(component_ids) != len(set(component_ids)): + issues.append(_issue("id_duplicate", "/components", "component IDs must be unique")) + component_set = set(component_ids) + grounded = value.get("grounded_component_ids", []) + if any(component_id not in component_set for component_id in grounded): + issues.append(_issue("reference_missing", "/grounded_component_ids", "grounded component does not exist")) + constraints = value.get("constraints", []) + constraint_ids = [constraint.get("constraint_id") for constraint in constraints if isinstance(constraint, dict)] + if len(constraint_ids) != len(set(constraint_ids)): + issues.append(_issue("id_duplicate", "/constraints", "constraint IDs must be unique")) + for index, constraint in enumerate(constraints): + if not isinstance(constraint, dict): + continue + for ref_key in ("connector_a", "connector_b"): + ref = constraint.get(ref_key) + if isinstance(ref, dict) and ref.get("component_id") not in component_set: + issues.append(_issue("reference_missing", f"/constraints/{index}/{ref_key}/component_id", "constraint component does not exist")) + for limit_key in ("distance_limit", "angle_limit"): + limit = constraint.get(limit_key) + if isinstance(limit, dict) and limit.get("lower_value") > limit.get("upper_value"): + issues.append(_issue("bounds_invalid", f"/constraints/{index}/{limit_key}", "constraint lower limit exceeds upper limit")) + return issues + + +def validate_normalized_product( + value: Any, *, limits: SceneResourceLimits = BASE_LIMITS +) -> SceneValidationReport: + issues = _serialized_size_issues(value, "normalized_product", limits=limits) + parsed, parse_issues = _parse_input( + value, + require_canonical=isinstance(value, (bytes, bytearray, memoryview, str)), + ) + issues.extend(parse_issues) + if parsed is None and issues: + return _report(issues, artifact="normalized_product") + issues.extend(_domain_issues(parsed, limits=limits)) + if _has_unsafe_depth(issues): + return _report(issues, artifact="normalized_product") + issues.extend(_schema_issues(parsed, "normalized_product")) + if isinstance(parsed, dict) and not _has_blocking_issues(issues): + issues.extend(_normalized_product_issues(parsed)) + return _report(issues, artifact="normalized_product") + + +def _package_reference_records( + scene: Mapping[str, Any], +) -> tuple[dict[str, Mapping[str, Any]], list[SceneValidationIssue]]: + records: dict[str, Mapping[str, Any]] = {} + issues: list[SceneValidationIssue] = [] + + def add(uri: Any, byte_length: Any, content_hash: Any, media_role: str) -> None: + if not isinstance(uri, str): + return + record = { + "byte_length": byte_length, + "content_hash": content_hash, + "media_role": media_role, + } + previous = records.get(uri) + if previous is not None and previous != record: + issues.append(_issue("package_member_set_invalid", "", "manifest records for one URI disagree on hash, length, or media role", "package")) + return + records[uri] = record + + for collection, media_role in ( + ("geometry_assets", "geometry"), + ("edge_assets", "edge"), + ("entity_assets", "entity"), + ): + for record in scene.get(collection, []): + if isinstance(record, dict): + add(record.get("uri"), record.get("byte_length"), record.get("content_hash"), media_role) + source = scene.get("source") + if isinstance(source, dict) and isinstance(source.get("embedded_artifact_uri"), str): + add( + source["embedded_artifact_uri"], + source.get("embedded_artifact_byte_length"), + source.get("artifact_hash"), + "model_source" if source.get("kind") == "model" else "imported_source", + ) + for source_file in source.get("source_files", []): + if isinstance(source_file, dict): + add( + source_file.get("uri"), + source_file.get("byte_length"), + source_file.get("content_hash"), + "python_source", + ) + presentation = scene.get("presentation_source") + if isinstance(presentation, dict) and isinstance(presentation.get("embedded_artifact_uri"), str): + add( + presentation["embedded_artifact_uri"], + presentation.get("embedded_artifact_byte_length"), + presentation.get("artifact_hash"), + "presentation", + ) + return records, issues + + +def _source_segment( + content: str, + *, + line: int, + column: int, + end_line: int, + end_column: int, +) -> str | None: + normalized = content.replace("\r\n", "\n").replace("\r", "\n") + lines = normalized.splitlines(keepends=True) + if not lines and normalized == "": + return None + if line < 1 or end_line < line or end_line > len(lines): + return None + start_text = lines[line - 1].removesuffix("\n") + end_text = lines[end_line - 1].removesuffix("\n") + if ( + column < 0 + or end_column < 0 + or column > len(start_text) + or end_column > len(end_text) + or (line == end_line and end_column < column) + ): + return None + if line == end_line: + return start_text[column:end_column] + return "".join( + [lines[line - 1][column:], *lines[line:end_line - 1], end_text[:end_column]] + ) + + +def _expected_callsite_id(source: Mapping[str, Any]) -> str: + material = "\x1f".join( + [ + source.get("path") or "", + str(source.get("line")), + str(source.get("column")), + str(source.get("end_line")), + str(source.get("end_column")), + str(source.get("call_text")), + ] + ) + return "callsite_" + hashlib.sha256(material.encode("utf-8")).hexdigest()[:16] + + +def _operation_source_issues( + source: Any, + *, + path: str, + source_files: Mapping[str, str], +) -> list[SceneValidationIssue]: + if not isinstance(source, dict): + return [_issue("source_matrix_invalid", path, "operation source must be an object")] + issues: list[SceneValidationIssue] = [] + if set(source) != _SOURCE_KEYS: + issues.append(_issue("source_matrix_invalid", path, "operation source fields do not match source mapping schema 1.0")) + path_kind = source.get("path_kind") + source_path = source.get("path") + integers = { + key: source.get(key) + for key in ("line", "column", "end_line", "end_column") + } + valid_integers = all( + isinstance(value, int) and not isinstance(value, bool) + for value in integers.values() + ) + if ( + source.get("schema_version") != "1.0" + or path_kind not in {"project_relative", "unresolved"} + or not valid_integers + or integers["line"] < 1 + or integers["column"] < 0 + or integers["end_line"] < integers["line"] + or integers["end_column"] < 0 + or not isinstance(source.get("call_text"), str) + or not isinstance(source.get("callsite_id"), str) + or not re.fullmatch(r"callsite_[0-9a-f]{16}", source.get("callsite_id", "")) + or not isinstance(source.get("assignment_targets"), list) + or not all(isinstance(value, str) and value for value in source.get("assignment_targets", [])) + ): + issues.append(_issue("source_matrix_invalid", path, "operation source values do not match source mapping schema 1.0")) + return issues + if path_kind == "unresolved": + if source_path is not None: + issues.append(_issue("source_matrix_invalid", path + "/path", "unresolved operation source path must be null")) + elif not isinstance(source_path, str) or source_path not in source_files: + issues.append(_issue("source_matrix_invalid", path + "/path", "operation source path does not resolve to an embedded source file")) + else: + segment = _source_segment( + source_files[source_path], + line=integers["line"], + column=integers["column"], + end_line=integers["end_line"], + end_column=integers["end_column"], + ) + if segment is None or segment != source.get("call_text"): + issues.append(_issue("source_matrix_invalid", path, "operation source span does not match embedded source text")) + if source.get("callsite_id") != _expected_callsite_id(source): + issues.append(_issue("source_matrix_invalid", path + "/callsite_id", "operation callsite_id does not match its canonical source span")) + return issues + + +def _embedded_model_issues( + scene: Mapping[str, Any], + payload: bytes, + *, + source_files: Mapping[str, str], + entity_payloads: Mapping[str, Mapping[str, Any]], +) -> list[SceneValidationIssue]: + prefix = "/model/model.json" + try: + model = parse_strict_json(payload) + except DuplicateKeyError as exc: + return [_issue("duplicate_json_key", "", str(exc), "parse")] + except UnicodeDecodeError as exc: + return [_issue("invalid_utf8", prefix, str(exc), "parse")] + except ValueError as exc: + return [_issue("schema_invalid", prefix, str(exc), "structure")] + if not isinstance(model, dict): + return [_issue("schema_invalid", prefix, "embedded model must be a JSON object", "structure")] + graph = model.get("graph") + if model.get("schema_version") != "2.0" or not isinstance(graph, dict): + return [_issue("schema_invalid", prefix, "embedded model does not contain a schema 2.0 graph", "structure")] + source = scene.get("source", {}) + if graph.get("graph_id") != source.get("graph_id"): + return [_issue("source_matrix_invalid", prefix + "/graph/graph_id", "embedded model graph_id differs from scene source")] + raw_nodes = graph.get("nodes") + if not isinstance(raw_nodes, list): + return [_issue("schema_invalid", prefix + "/graph/nodes", "embedded model graph nodes must be an array", "structure")] + + issues: list[SceneValidationIssue] = [] + node_map: dict[str, Mapping[str, Any]] = {} + node_indexes: dict[str, int] = {} + for index, node in enumerate(raw_nodes): + node_path = f"{prefix}/graph/nodes/{index}" + if not isinstance(node, dict): + issues.append(_issue("schema_invalid", node_path, "model graph node must be an object", "structure")) + continue + node_id = node.get("node_id") + output_count = node.get("output_count") + inputs = node.get("inputs") + if ( + not isinstance(node_id, str) + or not node_id + or not isinstance(node.get("op"), str) + or not isinstance(node.get("params"), dict) + or not isinstance(inputs, list) + or not all(isinstance(value, str) for value in inputs) + or isinstance(output_count, bool) + or not isinstance(output_count, int) + or output_count < 1 + ): + issues.append(_issue("schema_invalid", node_path, "model graph node has an invalid Viewer operation shape", "structure")) + continue + if node_id in node_map: + issues.append(_issue("id_duplicate", node_path + "/node_id", "model graph node_id is duplicated")) + continue + node_map[node_id] = node + node_indexes[node_id] = index + if "source" in node: + issues.extend( + _operation_source_issues( + node["source"], + path=node_path + "/source", + source_files=source_files, + ) + ) + for node_id, node in node_map.items(): + for input_id in node.get("inputs", []): + if input_id not in node_map: + issues.append(_issue("reference_missing", f"{prefix}/graph/nodes/{node_indexes[node_id]}/inputs", "model graph input references a missing node")) + leaf_ids = model.get("leaf_ids") + if not isinstance(leaf_ids, list) or not all(isinstance(value, str) and value in node_map for value in leaf_ids): + issues.append(_issue("reference_missing", prefix + "/leaf_ids", "model leaf_ids must resolve to graph nodes")) + + def validate_reference(record: Any, path: str) -> None: + if not isinstance(record, dict): + return + node_id = record.get("node_id") + output_slot = record.get("output_slot") + node = node_map.get(node_id) if isinstance(node_id, str) else None + if ( + node is None + or isinstance(output_slot, bool) + or not isinstance(output_slot, int) + or output_slot < 0 + or output_slot >= node.get("output_count", 0) + ): + issues.append(_issue("reference_missing", path, "model source does not resolve to an embedded graph output")) + + for index, definition in enumerate(scene.get("definitions", [])): + nested = definition.get("source") if isinstance(definition, dict) else None + if isinstance(nested, dict) and nested.get("kind") in {"product_model", "model_output"}: + validate_reference(nested, f"/definitions/{index}/source") + for index, connector in enumerate(scene.get("connectors", [])): + nested = connector.get("source") if isinstance(connector, dict) else None + if isinstance(nested, dict) and nested.get("kind") == "model_operation": + validate_reference(nested, f"/connectors/{index}/source") + entity_records = { + record.get("uri"): record + for record in scene.get("entity_assets", []) + if isinstance(record, dict) + } + for uri, entity_payload in entity_payloads.items(): + if uri not in entity_records: + continue + for index, entity in enumerate(entity_payload.get("entities", [])): + nested = entity.get("source") if isinstance(entity, dict) else None + if isinstance(nested, dict) and nested.get("kind") in {"model_output", "model_topology"}: + validate_reference(nested, f"/{uri}/entities/{index}/source") + return issues + + +def _embedded_presentation_issues( + scene: Mapping[str, Any], + payload: bytes, + uri: str, + *, + limits: SceneResourceLimits, +) -> list[SceneValidationIssue]: + prefix = f"/{uri}" + presentation_report = validate_presentation(payload, limits=limits) + issues = [] + for issue in presentation_report.issues: + code = ( + "source_matrix_invalid" + if issue.code == "presentation_reference_invalid" + else issue.code + ) + path = "" if _has_root_pointer_policy(code) else prefix + issue.path + issues.append(_issue(code, path, issue.message, issue.phase)) + if _has_blocking_issues(presentation_report.issues): + return issues + try: + presentation = parse_canonical_json(payload) + except ValueError: + return issues + if not isinstance(presentation, dict): + return issues + + source = scene.get("presentation_source") + if not isinstance(source, dict): + return issues + if presentation.get("presentation_id") != source.get("presentation_id"): + issues.append(_issue("source_matrix_invalid", prefix + "/presentation_id", "presentation_id differs from manifest presentation_source")) + if presentation.get("source_scene_id") != scene.get("scene_id"): + issues.append(_issue("source_matrix_invalid", prefix + "/source_scene_id", "presentation source_scene_id differs from manifest scene_id")) + + scene_appearances = [ + appearance for appearance in scene.get("appearances", []) if isinstance(appearance, dict) + ] + expected_appearance_ids: dict[Any, str] = {} + for index, authored in enumerate(presentation.get("appearances", [])): + if not isinstance(authored, dict): + continue + name = authored.get("name") + evaluated = { + "alpha_mode": authored.get("alpha_mode"), + "base_color": authored.get("base_color"), + "double_sided": authored.get("double_sided"), + "edge_color": authored.get("edge_color"), + "metallic": authored.get("metallic"), + "name": name, + "roughness": authored.get("roughness"), + "sdk_metadata": {}, + "source": { + "appearance_name": name, + "kind": "presentation", + "presentation_id": presentation.get("presentation_id"), + }, + } + appearance_id = "appearance/evaluated/" + hashlib.sha256( + canonical_json_bytes(evaluated) + ).hexdigest() + expected_appearance_ids[name] = appearance_id + expected = {"appearance_id": appearance_id, **evaluated} + matches = [appearance for appearance in scene_appearances if appearance == expected] + if len(matches) != 1: + issues.append(_issue("source_matrix_invalid", prefix + f"/appearances/{index}", "presentation appearance does not resolve to exactly one evaluated scene appearance")) + + expected_id_set = set(expected_appearance_ids.values()) + for index, appearance in enumerate(scene_appearances): + appearance_source = appearance.get("source") + if isinstance(appearance_source, dict) and appearance_source.get("kind") == "presentation" and appearance.get("appearance_id") not in expected_id_set: + issues.append(_issue("source_matrix_invalid", f"/appearances/{index}/source/appearance_name", "evaluated presentation appearance does not resolve to an authored appearance")) + + scene_nodes = [node for node in scene.get("nodes", []) if isinstance(node, dict)] + node_map = {node.get("node_id"): node for node in scene_nodes} + definition_map = { + definition.get("definition_id"): definition + for definition in scene.get("definitions", []) + if isinstance(definition, dict) + } + overrides = [ + override for override in presentation.get("node_overrides", []) if isinstance(override, dict) + ] + override_map = {override.get("node_id"): override for override in overrides} + for index, override in enumerate(overrides): + node = node_map.get(override.get("node_id")) + if node is None: + issues.append(_issue("source_matrix_invalid", prefix + f"/node_overrides/{index}/node_id", "node override target does not exist in the scene")) + continue + appearance_name = override.get("appearance_name") + if appearance_name is not None: + definition = definition_map.get(node.get("definition_id")) + if not isinstance(definition, dict) or definition.get("kind") not in {"part", "shape"}: + issues.append(_issue("source_matrix_invalid", prefix + f"/node_overrides/{index}/appearance_name", "appearance override target is not a renderable Part or Shape")) + + for index, node in enumerate(scene_nodes): + override = override_map.get(node.get("node_id")) + expected_visible = override.get("visible", True) if isinstance(override, dict) else True + appearance_name = override.get("appearance_name") if isinstance(override, dict) else None + expected_appearance_id = expected_appearance_ids.get(appearance_name) if appearance_name is not None else None + if node.get("visible") != expected_visible: + issues.append(_issue("source_matrix_invalid", f"/nodes/{index}/visible", "node visibility does not match the embedded presentation")) + if appearance_name in expected_appearance_ids and node.get("appearance_override_id") != expected_appearance_id: + issues.append(_issue("source_matrix_invalid", f"/nodes/{index}/appearance_override_id", "node appearance override does not resolve the embedded presentation name")) + if appearance_name is None and node.get("appearance_override_id") is not None: + issues.append(_issue("source_matrix_invalid", f"/nodes/{index}/appearance_override_id", "node appearance override is not authored by the embedded presentation")) + + scene_cameras = [camera for camera in scene.get("cameras", []) if isinstance(camera, dict)] + expected_camera_ids: set[str] = set() + for index, authored in enumerate(presentation.get("cameras", [])): + if not isinstance(authored, dict): + continue + if authored.get("parent_node_id") is not None and authored.get("parent_node_id") not in node_map: + issues.append(_issue("source_matrix_invalid", prefix + f"/cameras/{index}/parent_node_id", "presentation camera parent does not exist in the scene")) + camera_id = f"camera/{presentation.get('presentation_id')}/{_encode_segment(str(authored.get('name', '')))}" + expected_camera_ids.add(camera_id) + expected = {"camera_id": camera_id, **authored} + matches = [camera for camera in scene_cameras if camera == expected] + if len(matches) != 1: + issues.append(_issue("source_matrix_invalid", prefix + f"/cameras/{index}", "presentation camera does not resolve to exactly one evaluated scene camera")) + for index, camera in enumerate(scene_cameras): + if camera.get("camera_id") not in expected_camera_ids: + issues.append(_issue("source_matrix_invalid", f"/cameras/{index}", "evaluated scene camera is not authored by the embedded presentation")) + return issues + + +def _compute_package_budget_totals( + *, + scene_json_bytes: int, + glb_decoded_buffer_bytes: int, + entity_json_bytes: int, + other_immutable_json_bytes: int, + entity_count: int, + entity_vertex_count: int, + triangle_vertex_count: int, + triangle_count: int, + line_vertex_count: int, + line_segment_count: int, +) -> dict[str, int]: + return { + "static_decoded_buffer_bytes": ( + scene_json_bytes + + glb_decoded_buffer_bytes + + entity_json_bytes + + other_immutable_json_bytes + + 2 * entity_vertex_count * 16 + ), + "entity_count": entity_count, + "triangle_vertex_total": triangle_vertex_count, + "triangle_total": triangle_count, + "line_vertex_total": line_vertex_count, + "line_segment_total": line_segment_count, + } + + +def _package_budget_issues( + totals: Mapping[str, int], *, limits: Any = BASE_LIMITS +) -> list[SceneValidationIssue]: + issues: list[SceneValidationIssue] = [] + if totals["static_decoded_buffer_bytes"] > limits.static_decoded_buffer_bytes: + issues.append(_issue("static_buffer_limit_exceeded", "", "static decoded buffer formula exceeds resource limit", "budget")) + if totals["entity_count"] > limits.entities_total: + issues.append(_issue("resource_limit_exceeded", "/entity_assets", "total entity count exceeds resource limit", "budget")) + for field, limit, message in ( + ("triangle_vertex_total", limits.triangle_vertices_total, "total triangle GLB vertex count exceeds resource limit"), + ("triangle_total", limits.triangles_total, "total triangle count exceeds resource limit"), + ("line_vertex_total", limits.line_vertices_total, "total line GLB vertex count exceeds resource limit"), + ("line_segment_total", limits.line_segments_total, "total line segment count exceeds resource limit"), + ): + if totals[field] > limit: + issues.append(_issue("resource_limit_exceeded", "", message, "budget")) + return issues + + +def validate_scene_package( + scene: Any, + blobs: Mapping[str, bytes | bytearray | memoryview], + *, + limits: SceneResourceLimits = BASE_LIMITS, +) -> SceneValidationReport: + manifest_report = validate_scene_manifest(scene, limits=limits) + issues = list(manifest_report.issues) + parsed, parse_issues = _parse_input(scene, require_canonical=isinstance(scene, (bytes, bytearray, memoryview, str))) + issues.extend(parse_issues) + if not isinstance(parsed, dict): + return _report(issues, artifact="package") + if _has_blocking_issues(issues): + return _report(issues, artifact="package") + records, record_issues = _package_reference_records(parsed) + issues.extend(record_issues) + if set(blobs) != set(records): + issues.append(_issue("package_member_set_invalid", "", "blob keys do not exactly equal manifest URI references", "package")) + glb_info: dict[str, GlbInfo] = {} + entity_payloads: dict[str, Mapping[str, Any]] = {} + embedded_model: bytes | None = None + source_files: dict[str, str] = {} + source_path_by_uri = { + record.get("uri"): record.get("path") + for record in parsed.get("source", {}).get("source_files", []) + if isinstance(record, dict) + } + budget = { + "scene_json_bytes": len(canonical_json_bytes(parsed)), + "glb_decoded_buffer_bytes": 0, + "entity_json_bytes": 0, + "other_immutable_json_bytes": 0, + "entity_count": 0, + "entity_vertex_count": 0, + "triangle_vertex_count": 0, + "triangle_count": 0, + "line_vertex_count": 0, + "line_segment_count": 0, + } + for uri, record in records.items(): + if uri not in blobs: + continue + blob = blobs[uri] + payload_size = blob.nbytes if isinstance(blob, memoryview) else len(blob) + role_limit = { + "entity": limits.entity_json_bytes, + "model_source": limits.model_json_bytes, + "presentation": limits.presentation_json_bytes, + }.get(record.get("media_role"), limits.one_member_bytes) + if payload_size > min(limits.one_member_bytes, role_limit): + issues.append(_issue("resource_limit_exceeded", f"/{uri}", "package member bytes exceed resource limit", "budget")) + continue + payload = bytes(blob) + if len(payload) != record.get("byte_length"): + issues.append(_issue("blob_length_mismatch", f"/{uri}", "blob length differs from manifest", "package")) + digest = "sha256:" + hashlib.sha256(payload).hexdigest() + if digest != record.get("content_hash"): + issues.append(_issue("blob_hash_mismatch", f"/{uri}", "blob hash differs from manifest", "package")) + media_role = record.get("media_role") + if media_role in {"geometry", "edge"}: + try: + info = preflight_glb(payload, expected_kind="triangle" if media_role == "geometry" else "line", limits=limits) + glb_info[uri] = info + budget["glb_decoded_buffer_bytes"] += info.decoded_buffer_bytes + if info.kind == "triangle": + budget["triangle_vertex_count"] += info.vertex_count + budget["triangle_count"] += info.primitive_count + else: + budget["line_vertex_count"] += info.vertex_count + budget["line_segment_count"] += info.primitive_count + except ValueError as exc: + issues.append(_issue("glb_profile_invalid", f"/{uri}", str(exc), "package")) + elif media_role == "entity": + entity_report = validate_entity_asset(payload, limits=limits) + issues.extend( + _issue( + issue.code, + "" if _has_root_pointer_policy(issue.code) else f"/{uri}" + issue.path, + issue.message, + issue.phase, + ) + for issue in entity_report.issues + ) + try: + entity_payload = parse_canonical_json(payload) + if isinstance(entity_payload, dict): + entity_payloads[uri] = entity_payload + budget["entity_json_bytes"] += len(payload) + entities = entity_payload.get("entities", []) + entities = entities if isinstance(entities, list) else [] + budget["entity_count"] += len(entities) + vertex_count = sum(1 for entity in entities if isinstance(entity, dict) and entity.get("kind") == "vertex") + budget["entity_vertex_count"] += vertex_count + except ValueError: + pass + elif media_role == "presentation": + issues.extend( + _embedded_presentation_issues( + parsed, payload, uri, limits=limits + ) + ) + budget["other_immutable_json_bytes"] += len(payload) + elif media_role == "model_source": + embedded_model = payload + budget["other_immutable_json_bytes"] += len(payload) + elif media_role == "python_source": + try: + source_text = payload.decode("utf-8", errors="strict") + except UnicodeDecodeError as exc: + issues.append(_issue("invalid_utf8", f"/{uri}", str(exc), "parse")) + else: + source_path = source_path_by_uri.get(uri) + if isinstance(source_path, str): + source_files[source_path] = source_text + budget["other_immutable_json_bytes"] += len(payload) + if embedded_model is not None: + issues.extend( + _embedded_model_issues( + parsed, + embedded_model, + source_files=source_files, + entity_payloads=entity_payloads, + ) + ) + issues.extend(_package_budget_issues(_compute_package_budget_totals(**budget), limits=limits)) + + geometry_records = {record.get("asset_id"): record for record in parsed.get("geometry_assets", []) if isinstance(record, dict)} + edge_records = {record.get("asset_id"): record for record in parsed.get("edge_assets", []) if isinstance(record, dict)} + for index, definition in enumerate(parsed.get("definitions", [])): + if not isinstance(definition, dict) or definition.get("kind") == "assembly": + continue + entity_record = next((record for record in parsed.get("entity_assets", []) if isinstance(record, dict) and record.get("entity_asset_id") == definition.get("entity_asset_id")), None) + if not isinstance(entity_record, dict): + continue + payload = entity_payloads.get(entity_record.get("uri")) + geometry_record = geometry_records.get(definition.get("geometry_asset_id")) + edge_record = edge_records.get(definition.get("edge_asset_id")) + geometry = glb_info.get(geometry_record.get("uri")) if isinstance(geometry_record, dict) else None + edge = glb_info.get(edge_record.get("uri")) if isinstance(edge_record, dict) else None + for record, info in ((geometry_record, geometry), (edge_record, edge)): + if isinstance(record, dict) and info is not None: + glb_minimum, glb_maximum = info.position_bounds + expected_bounds = { + "min": [1000 * glb_minimum[0], -1000 * glb_maximum[2], 1000 * glb_minimum[1]], + "max": [1000 * glb_maximum[0], -1000 * glb_minimum[2], 1000 * glb_maximum[1]], + } + if record.get("scene_local_bounds") != expected_bounds: + issues.append(_issue("bounds_invalid", f"/definitions/{index}", "asset scene_local_bounds differ from transformed GLB bounds")) + if isinstance(payload, dict): + if payload.get("definition_id") != definition.get("definition_id") or payload.get("geometry_asset_id") != definition.get("geometry_asset_id") or payload.get("edge_asset_id") != definition.get("edge_asset_id"): + issues.append(_issue("reference_missing", f"/definitions/{index}/entity_asset_id", "entity sidecar ownership triple differs from definition")) + if payload.get("geometry_engine", {}).get("version") != parsed.get("generator", {}).get("ocp_version"): + issues.append(_issue("source_matrix_invalid", f"/definitions/{index}/entity_asset_id", "entity geometry engine version differs from manifest generator")) + scene_source_kind = parsed.get("source", {}).get("kind") + allowed_entity_sources = { + "model": {"model_output", "model_topology"}, + "imported": {"imported_primitive", "unbound"}, + "manual": {"unbound"}, + }.get(scene_source_kind, set()) + for entity_index, entity in enumerate(payload.get("entities", [])): + if not isinstance(entity, dict): + continue + entity_path = f"/{entity_record.get('uri')}/entities/{entity_index}" + entity_source = entity.get("source", {}) + if not isinstance(entity_source, dict) or entity_source.get("kind") not in allowed_entity_sources: + issues.append(_issue("source_matrix_invalid", entity_path + "/source", "entity source is incompatible with scene source")) + elif scene_source_kind == "model": + definition_source = definition.get("source", {}) + if any( + entity_source.get(field) != definition_source.get(field) + for field in ("graph_id", "node_id", "output_slot") + ): + issues.append(_issue("source_matrix_invalid", entity_path + "/source", "entity model source differs from owning definition output")) + if entity.get("kind") == "solid": + expected_status = "not_applicable" + elif definition.get("kind") != "part": + expected_status = "owner_not_part" + elif scene_source_kind != "model": + expected_status = "source_not_model" + elif entity.get("sdk_connector_frame") is None: + expected_status = "frame_undefined" + else: + expected_status = None + if expected_status is not None and entity.get("connector_binding_status") != expected_status: + issues.append(_issue("connector_invalid", entity_path + "/connector_binding_status", f"connector binding status must be {expected_status}")) + face_total = sum(group.get("index_count", 0) for group in payload.get("face_groups", []) if isinstance(group, dict)) + edge_total = sum(group.get("index_count", 0) for group in payload.get("edge_groups", []) if isinstance(group, dict)) + if geometry is not None and face_total != geometry.index_count: + issues.append(_issue("entity_range_invalid", f"/definitions/{index}/entity_asset_id", "face groups do not partition triangle GLB indices")) + if edge is not None and edge_total != edge.index_count: + issues.append(_issue("entity_range_invalid", f"/definitions/{index}/entity_asset_id", "edge groups do not partition line GLB indices")) + return _report(issues, artifact="package") + + +def _assert(report: SceneValidationReport) -> None: + if not report.valid: + raise SceneContractError(report) + + +def assert_scene_manifest(scene: Any) -> None: + _assert(validate_scene_manifest(scene)) + + +def assert_entity_asset(asset: Any) -> None: + _assert(validate_entity_asset(asset)) + + +def assert_presentation(value: Any) -> None: + _assert(validate_presentation(value)) + + +def assert_connector_binding(value: Any) -> None: + _assert(validate_connector_binding(value)) + + +def assert_normalized_product(value: Any) -> None: + _assert(validate_normalized_product(value)) + + +def assert_scene_package(scene: Any, blobs: Mapping[str, bytes | bytearray | memoryview]) -> None: + _assert(validate_scene_package(scene, blobs)) + + +_rule_registry() diff --git a/SimpleCADAPI/src/simplecadapi/serializer.py b/SimpleCADAPI/src/simplecadapi/serializer.py index 437f2b6..d4626e0 100644 --- a/SimpleCADAPI/src/simplecadapi/serializer.py +++ b/SimpleCADAPI/src/simplecadapi/serializer.py @@ -27,17 +27,30 @@ from typing import Any, Dict, List, Optional, Sequence, Tuple, cast from .errors import raise_harness_error -from .core import AnyShape, Compound, Edge, Face, Solid, Vertex, Wire, use_coordinate_system -from .graph import attach_graph_node, suspend_graph_recording -from .ql import selector_from_dict +from .core import ( + AnyShape, + Compound, + Edge, + Face, + Solid, + Vertex, + Wire, + clone_semantic_shape_view, + use_coordinate_system, +) +from .graph import attach_graph_node, attach_semantic_graph_node, suspend_graph_recording +from .ql import output_role, selector_from_dict from .sketch import Sketch from .product import Assembly, Connector, ConnectorRef, GeometryRef, Material, Part, Placement, ScalarLimit from .topology import ( OperationGraph, + TopoRef, semantic_delta_to_dict, topo_delta_to_dict, + topo_ref_to_dict, topo_ref_from_dict, ) +from .tagging import TagBinding, TagTargetKind from . import operations as ops from .kernel.ocp_properties import bounding_box @@ -197,6 +210,10 @@ PUBLIC_API_COVERAGE: Dict[str, Dict[str, str]] = { "revolve_rsolid": {"status": "replayable", "op": "make_revolve_rsolid"}, "loft_rsolid": {"status": "replayable", "op": "make_loft_rsolid"}, "sweep_rsolid": {"status": "replayable", "op": "make_sweep_rsolid"}, + "twisted_sweep_rsolid": { + "status": "replayable", + "op": "make_twisted_sweep_rsolid", + }, "helical_sweep_rsolid": { "status": "expanded_macro", "op": "make_sweep_rsolid", @@ -216,6 +233,14 @@ PUBLIC_API_COVERAGE: Dict[str, Dict[str, str]] = { "make_select_rwire": {"status": "replayable", "op": "make_select_rwire"}, "make_select_rface": {"status": "replayable", "op": "make_select_rface"}, "make_select_rsolid": {"status": "replayable", "op": "make_select_rsolid"}, + "apply_tag": { + "status": "semantic_replayable", + "op": "apply_tag_rselection", + }, + "apply_tag_rselection": { + "status": "semantic_replayable", + "op": "apply_tag_rselection", + }, "linear_pattern_rsolidlist": { "status": "macro", "reason": "Pattern convenience API that should lower into repeated make_translate_rshape nodes.", @@ -315,6 +340,7 @@ CANONICAL_CORE_OP_SET: Tuple[str, ...] = ( "make_revolve_rsolid", "make_loft_rsolid", "make_sweep_rsolid", + "make_twisted_sweep_rsolid", "make_translate_rshape", "make_rotate_rshape", "make_mirror_rshape", @@ -334,6 +360,15 @@ CANONICAL_CORE_OP_SET: Tuple[str, ...] = ( "make_select_rsolid", ) +CANONICAL_SEMANTIC_OP_SET: Tuple[str, ...] = ( + "apply_tag_rselection", +) + +CANONICAL_OP_SET: Tuple[str, ...] = ( + *CANONICAL_CORE_OP_SET, + *CANONICAL_SEMANTIC_OP_SET, +) + SELECTION_REF_SCHEMA: Dict[str, Any] = { "edge_param": "selected_edges", "face_param": "selected_faces", @@ -370,6 +405,7 @@ def _canonical_contract_payload() -> Dict[str, Any]: "permissive_mode": "explicit_opt_in", }, "core_op_set": list(CANONICAL_CORE_OP_SET), + "semantic_op_set": list(CANONICAL_SEMANTIC_OP_SET), "selection_ref_schema": { "edge_param": SELECTION_REF_SCHEMA["edge_param"], "face_param": SELECTION_REF_SCHEMA["face_param"], @@ -388,7 +424,7 @@ def _canonical_contract_payload() -> Dict[str, Any]: def _assert_graph_is_canonical(graph: OperationGraph) -> None: invalid_ops = sorted( - {node.op for node in graph.nodes if node.op not in CANONICAL_CORE_OP_SET} + {node.op for node in graph.nodes if node.op not in CANONICAL_OP_SET} ) if invalid_ops: raise ValueError( @@ -426,11 +462,15 @@ def export_session_json(session: "GraphSession", indent: int = 2) -> str: import json + session.tolerance_graph.validate(raise_on_failure=True) return json.dumps( { "graph": session.graph.to_dict(), "expression_graph": session.expression_graph.to_dict(), + "tolerance_graph": session.tolerance_graph.to_dict(), "frame_graph": session.frame_graph.to_dict(), + "result_node_ids": list(session.result_node_ids), + "has_explicit_results": session.has_explicit_results, }, indent=indent, ) @@ -482,6 +522,7 @@ def import_session_json(json_str: str) -> Dict[str, Any]: from .expr import ExpressionGraph from .frame import FrameGraph + from .tolerance import ToleranceGraph try: payload = json.loads(json_str) @@ -497,6 +538,17 @@ def import_session_json(json_str: str) -> Dict[str, Any]: else: raise ValueError("Session payload 'expression_graph' must be an object") + tolerance_payload = payload.get("tolerance_graph") + if tolerance_payload is None: + tolerance_graph = ToleranceGraph(expr_graph) + elif isinstance(tolerance_payload, dict): + tolerance_graph = ToleranceGraph.from_dict( + tolerance_payload, expr_graph + ) + else: + raise ValueError("Session payload 'tolerance_graph' must be an object") + tolerance_graph.validate(raise_on_failure=True) + frame_payload = payload.get("frame_graph") if frame_payload is None: frame_graph = FrameGraph() @@ -511,7 +563,10 @@ def import_session_json(json_str: str) -> Dict[str, Any]: return { "graph": graph, "expression_graph": expr_graph, + "tolerance_graph": tolerance_graph, "frame_graph": frame_graph, + "result_node_ids": [str(v) for v in payload.get("result_node_ids", [])], + "has_explicit_results": bool(payload.get("has_explicit_results", False)), } except Exception as e: raise_harness_error( @@ -520,12 +575,12 @@ def import_session_json(json_str: str) -> Dict[str, Any]: possible_causes=[ "The input string is not valid JSON.", "The session payload is missing the required 'graph' object.", - "The expression_graph or frame_graph fields use the wrong JSON type.", + "The expression_graph, tolerance_graph, or frame_graph fields use the wrong JSON type.", ], how_to_fix=[ "Pass a valid JSON string produced by export_session_json().", "Make sure 'graph' is present and is a JSON object.", - "Use JSON objects for 'expression_graph' and 'frame_graph', not strings or arrays.", + "Use JSON objects for expression_graph, tolerance_graph, and frame_graph, not strings or arrays.", ], error=e, ) @@ -534,6 +589,8 @@ def import_session_json(json_str: str) -> Dict[str, Any]: def export_model_json( session: "GraphSession", indent: int = 2, + *, + result_node_ids: Optional[Sequence[str]] = None, ) -> str: """Export the canonical 2.0 model seed JSON. @@ -551,8 +608,21 @@ def export_model_json( sketch_profile_registry: List[Dict[str, Any]] = [] semantic_delta_log: List[Dict[str, Any]] = [] topology_delta_log: List[Dict[str, Any]] = [] + semantic_bindings: List[Dict[str, Any]] = [] for node in session.graph.topological_order(): + if node.op == "apply_tag_rselection": + binding_payload = node.params.get("tag_binding") + if not isinstance(binding_payload, dict): + raise ValueError( + f"semantic tag node '{node.node_id}' has no TagBinding payload" + ) + binding = TagBinding.from_dict(binding_payload) + if binding.producer.node_id != node.node_id: + raise ValueError( + f"semantic tag node '{node.node_id}' does not own binding '{binding.binding_id}'" + ) + semantic_bindings.append(binding.to_dict()) if node.semantic_delta is not None: semantic_delta_log.append( { @@ -627,7 +697,21 @@ def export_model_json( frame_graph_payload = session.frame_graph.to_dict() _assert_graph_is_canonical(session.graph) - leaf_ids = [node.node_id for node in session.graph.leaf_nodes()] + session.tolerance_graph.validate(raise_on_failure=True) + if result_node_ids is not None: + leaf_ids = [str(node_id) for node_id in result_node_ids] + elif session.has_explicit_results: + leaf_ids = list(session.result_node_ids) + else: + leaf_ids = [node.node_id for node in session.graph.leaf_nodes()] + unknown_result_ids = [ + node_id for node_id in leaf_ids if session.graph.get_node(node_id) is None + ] + if unknown_result_ids: + raise ValueError( + "model result node ids are not present in the session graph: " + + ", ".join(unknown_result_ids) + ) payload: Dict[str, Any] = { "schema_version": MODEL_SCHEMA_VERSION, @@ -635,12 +719,14 @@ def export_model_json( "graph": session.graph.to_dict(), "leaf_ids": leaf_ids, "expression_graph": session.expression_graph.to_dict(), + "tolerance_graph": session.tolerance_graph.to_dict(), "frame_graph": frame_graph_payload, "geometry_registry": geometry_registry, "semantic_entity_registry": semantic_entity_registry, "sketch_profile_registry": sketch_profile_registry, "semantic_delta_log": semantic_delta_log, "topology_delta_log": topology_delta_log, + "semantic_bindings": semantic_bindings, } return json.dumps(payload, indent=indent) @@ -678,6 +764,7 @@ def import_model_json(json_str: str) -> Dict[str, Any]: { "graph": payload.get("graph", {}), "expression_graph": payload.get("expression_graph", {}), + "tolerance_graph": payload.get("tolerance_graph", {}), "frame_graph": payload.get("frame_graph", {}), } ) @@ -705,6 +792,31 @@ def import_model_json(json_str: str) -> Dict[str, Any]: session_payload["topology_delta_log"] = list( payload.get("topology_delta_log", []) ) + semantic_bindings = list(payload.get("semantic_bindings", [])) + parsed_bindings = [TagBinding.from_dict(item) for item in semantic_bindings] + graph_binding_list = [ + TagBinding.from_dict(node.params["tag_binding"]) + for node in graph.nodes + if node.op == "apply_tag_rselection" + and isinstance(node.params.get("tag_binding"), dict) + ] + registry_bindings = { + binding.binding_id: binding.to_dict() for binding in parsed_bindings + } + graph_bindings = { + binding.binding_id: binding.to_dict() for binding in graph_binding_list + } + if ( + len(registry_bindings) != len(parsed_bindings) + or len(graph_bindings) != len(graph_binding_list) + or registry_bindings != graph_bindings + ): + raise ValueError( + "model semantic_bindings do not match apply_tag_rselection graph nodes" + ) + session_payload["semantic_bindings"] = [ + binding.to_dict() for binding in parsed_bindings + ] session_payload["leaf_ids"] = [str(v) for v in payload.get("leaf_ids", [])] return session_payload except Exception as e: @@ -734,6 +846,10 @@ def replay_model_json(json_str: str, *, strict: bool = True) -> List[Any]: if not isinstance(graph, OperationGraph): raise ValueError("Model payload does not contain a replayable graph") + tolerance_graph = payload.get("tolerance_graph") + if tolerance_graph is not None: + tolerance_graph.validate(raise_on_failure=True) + explicit_leaf_ids = payload.get("leaf_ids") return _execute_graph( graph, @@ -930,6 +1046,9 @@ def _replay_primitive_or_simple( def _shape_topo_ref_dict(shape: AnyShape) -> Dict[str, Any]: + runtime_ref = shape._get_runtime("topo.ref") + if isinstance(runtime_ref, TopoRef): + return topo_ref_to_dict(runtime_ref) topo_ref = shape.get_metadata("topo_ref") return topo_ref if isinstance(topo_ref, dict) else {} @@ -1505,6 +1624,270 @@ def _all_input_outputs( return result +def _resolve_tag_binding_targets(scope: AnyShape, binding: TagBinding) -> List[AnyShape]: + if binding.target.kind == TagTargetKind.SCOPE_ROOT: + return [scope] + if binding.target.kind == TagTargetKind.SELECTION_QUERY: + if binding.target.selector is None: + raise ValueError("selection_query tag target is missing its selector") + return cast( + List[AnyShape], + selector_from_dict(binding.target.selector).resolve(scope), + ) + if binding.target.kind != TagTargetKind.EXPLICIT_REFS: + raise ValueError( + f"tag target kind '{binding.target.kind.value}' is not replayable" + ) + + resolved: List[AnyShape] = [] + for ref in binding.target.refs: + topo_id = str(ref.get("topo_id", "")) + kind = str(ref.get("kind", "")).lower() + if not topo_id or not kind: + raise ValueError("explicit tag target refs require kind and topo_id") + if kind.startswith("topokind."): + kind = kind.split(".", 1)[1] + wrappers = [ + wrapper + for entity in scope._topology_cache.entities() + if entity.kind.lower() == kind + for wrapper in entity.wrappers + if isinstance(wrapper, (Vertex, Edge, Wire, Face, Solid, Compound)) + and ( + wrapper.topo_id == topo_id + or str(_shape_topo_ref_dict(wrapper).get("topo_id", "")) == topo_id + ) + ] + unique = {item.topo_id: item for item in wrappers} + if len(unique) != 1: + raise ValueError( + f"explicit tag target '{kind}:{topo_id}' does not resolve uniquely" + ) + resolved.append(cast(AnyShape, next(iter(unique.values())))) + return resolved + + +def _validate_tag_binding_evidence( + binding: TagBinding, + selected: Sequence[AnyShape], +) -> None: + selected_count = binding.evidence.data.get("selected_count") + if selected_count is not None and int(selected_count) != len(selected): + raise ValueError( + f"tag assignment expected {int(selected_count)} selected target(s), got {len(selected)}" + ) + expected_refs = binding.evidence.data.get("selected_refs") + if not isinstance(expected_refs, list): + return + expected = { + (str(ref.get("kind", "")).lower(), str(ref.get("topo_id", ""))) + for ref in expected_refs + if isinstance(ref, dict) + } + actual = set() + for item in selected: + topo_ref = _shape_topo_ref_dict(item) + actual.add( + ( + str(topo_ref.get("kind", item.__class__.__name__)).lower(), + str(topo_ref.get("topo_id", item.topo_id)), + ) + ) + if expected and expected != actual: + raise ValueError( + "tag assignment target evidence drifted during replay: " + f"expected={sorted(expected)}, actual={sorted(actual)}" + ) + + +def _is_upstream_node( + graph: OperationGraph, node_id: str, source_node_id: str +) -> bool: + pending = list(graph.upstream_nodes(node_id)) + seen = set() + while pending: + current = pending.pop() + if current == source_node_id: + return True + if current in seen: + continue + seen.add(current) + pending.extend(graph.upstream_nodes(current)) + return False + + +def _canonical_shape_ref(shape: AnyShape) -> Tuple[str, str, int, str, str]: + ref = _shape_topo_ref_dict(shape) + return ( + str(ref.get("graph_id", "")), + str(ref.get("node_id", "")), + int(ref.get("output_slot", 0)), + str(ref.get("kind", shape.__class__.__name__)).lower(), + str(ref.get("topo_id", shape.topo_id)), + ) + + +def _canonical_topo_ref(ref: TopoRef) -> Tuple[str, str, int, str, str]: + return ( + ref.graph_id, + ref.node_id, + ref.output_slot, + ref.kind.name.lower(), + ref.topo_id, + ) + + +def _validate_replayed_topology_roles( + graph: OperationGraph, + node: Any, + result_list: Sequence[Any], +) -> None: + if node.topo_delta is None or not node.topo_delta.roles: + return + + role_specs = dict(ops._OPERATION_OUTPUT_ROLE_CARDINALITY.get(node.op, ())) + serialized: Dict[str, set[Tuple[str, str, int, str, str]]] = {} + for entry in node.topo_delta.roles: + if entry.role not in role_specs: + raise ValueError( + f"Graph node '{node.node_id}' ({node.op}) contains unsupported output role '{entry.role}'" + ) + if entry.ref.graph_id != graph.graph_id or entry.ref.node_id != node.node_id: + raise ValueError( + f"Graph node '{node.node_id}' ({node.op}) output role '{entry.role}' has foreign result ownership" + ) + if entry.ref.output_slot >= len(result_list): + raise ValueError( + f"Graph node '{node.node_id}' ({node.op}) output role '{entry.role}' references missing output slot {entry.ref.output_slot}" + ) + if ( + str(entry.metadata.get("coverage", "complete")).lower() != "complete" + or str(entry.metadata.get("status", "proven")).lower() != "proven" + ): + continue + serialized.setdefault(entry.role, set()).add(_canonical_topo_ref(entry.ref)) + + runtime: Dict[str, set[Tuple[str, str, int, str, str]]] = {} + for output in result_list: + if not isinstance(output, (Solid, Compound, Face)): + continue + candidates: List[AnyShape] = [] + if hasattr(output, "get_faces"): + candidates.extend(output.get_faces()) + if hasattr(output, "get_edges"): + candidates.extend(output.get_edges()) + for candidate in candidates: + track = candidate.get_metadata("track") + if not isinstance(track, dict): + continue + for role in track.get("result_roles", ()): + role_name = str(role).strip().lower() + if role_name in role_specs: + runtime.setdefault(role_name, set()).add( + _canonical_shape_ref(candidate) + ) + + if serialized != runtime: + raise ValueError( + f"Graph node '{node.node_id}' ({node.op}) topology output-role evidence drifted during replay" + ) + + +def _validate_operation_output_evidence( + graph: OperationGraph, + node: Any, + binding: TagBinding, + selected: Sequence[AnyShape], + outputs: Dict[str, List[Any]], +) -> None: + raw = binding.evidence.data.get("operation_output_role") + if raw is None: + return + if not isinstance(raw, dict): + raise ValueError("operation_output_role evidence must be an object") + + source_node_id = str(raw.get("source_node_id", "")) + source_output_slot = int(raw.get("source_output_slot", 0)) + operation = str(raw.get("operation", "")) + role = str(raw.get("role", "")).strip().lower() + cardinality = str(raw.get("cardinality", "")) + if not source_node_id or not operation or not role: + raise ValueError("operation_output_role evidence is incomplete") + if not _is_upstream_node(graph, node.node_id, source_node_id): + raise ValueError( + f"operation output role source '{source_node_id}' is not upstream of '{node.node_id}'" + ) + + source_node = graph.get_node(source_node_id) + if source_node is None or source_node.op != operation: + raise ValueError( + f"operation output role source '{source_node_id}' does not match operation '{operation}'" + ) + role_specs = dict(ops._OPERATION_OUTPUT_ROLE_CARDINALITY.get(operation, ())) + if role not in role_specs or role_specs[role] != cardinality: + raise ValueError( + f"operation output role '{role}' with cardinality '{cardinality}' is unsupported for '{operation}'" + ) + if source_node.topo_delta is None: + raise ValueError( + f"operation output role source '{source_node_id}' has no topology-role evidence" + ) + + serialized_refs = { + _canonical_topo_ref(entry.ref) + for entry in source_node.topo_delta.roles + if entry.role == role + and str(entry.metadata.get("coverage", "complete")).lower() == "complete" + and str(entry.metadata.get("status", "proven")).lower() == "proven" + } + serialized_kinds = {ref[3] for ref in serialized_refs} + if len(serialized_kinds) != 1: + raise ValueError( + f"operation output role '{role}' does not resolve to one topology kind" + ) + source_outputs = outputs.get(source_node_id, []) + if source_output_slot >= len(source_outputs): + raise ValueError( + f"operation output role source '{source_node_id}:{source_output_slot}' has no replay output" + ) + source = cast(AnyShape, source_outputs[source_output_slot]) + target_kind = next(iter(serialized_kinds)) + if target_kind == "face": + runtime_items = source.get_faces() + elif target_kind == "edge": + runtime_items = source.get_edges() + elif target_kind == "wire": + runtime_items = source.get_wires() + elif target_kind == "vertex": + runtime_items = source.get_vertices() + else: + raise ValueError( + f"operation output role '{role}' uses unsupported topology kind '{target_kind}'" + ) + runtime_candidates = [ + item for item in runtime_items if output_role(role)(item) + ] + runtime_refs = {_canonical_shape_ref(face) for face in runtime_candidates} + selected_refs = {_canonical_shape_ref(item) for item in selected} + + if cardinality == "one" and len(runtime_refs) != 1: + raise ValueError( + f"operation output role '{role}' expected exactly one replay result, got {len(runtime_refs)}" + ) + if cardinality == "many" and not runtime_refs: + raise ValueError( + f"operation output role '{role}' resolved no replay results" + ) + if serialized_refs != runtime_refs: + raise ValueError( + f"operation output role '{role}' topology evidence drifted during replay" + ) + if selected_refs != runtime_refs: + raise ValueError( + f"operation output role '{role}' binding targets do not match its proven results" + ) + + def _execute_graph( graph: OperationGraph, leaf_node_ids: Optional[Sequence[str]] = None, @@ -1530,6 +1913,19 @@ def _execute_graph( output_slot=idx, graph_id=graph.graph_id, ) + if ctx.strict: + _validate_replayed_topology_roles(graph, node, result_list) + outputs[node.node_id] = result_list + + def _store_semantic_outputs(node, result: Any) -> None: + result_list = _normalize_output(result) + for idx, output in enumerate(result_list): + attach_semantic_graph_node( + output, + node, + output_slot=idx, + graph_id=graph.graph_id, + ) outputs[node.node_id] = result_list with suspend_graph_recording(): @@ -1544,6 +1940,60 @@ def _execute_graph( try: with context_manager: + if op_name == "apply_tag_rselection": + ctx.require_params( + node.node_id, op_name, params, ("tag_binding",) + ) + raw_binding = params["tag_binding"] + if not isinstance(raw_binding, dict): + ctx.fail( + f"Graph node '{node.node_id}' ({op_name}) tag_binding must be an object" + ) + binding = TagBinding.from_dict(raw_binding) + if ctx.strict and binding.producer.node_id != node.node_id: + ctx.fail( + f"Graph node '{node.node_id}' ({op_name}) does not match binding producer '{binding.producer.node_id}'" + ) + scope_node_id = binding.scope.node_id + if scope_node_id is None: + ctx.fail( + f"Graph node '{node.node_id}' ({op_name}) binding scope has no node_id" + ) + if ctx.strict and scope_node_id not in { + item.node_id for item in node.inputs + }: + ctx.fail( + f"Graph node '{node.node_id}' ({op_name}) binding scope '{scope_node_id}' is not an input" + ) + scope_outputs = outputs.get(str(scope_node_id), []) + output_slot = binding.scope.output_slot + if output_slot >= len(scope_outputs): + ctx.fail( + f"Graph node '{node.node_id}' ({op_name}) binding scope '{scope_node_id}:{output_slot}' has no replay output" + ) + scope = cast(AnyShape, scope_outputs[output_slot]) + result = clone_semantic_shape_view(scope) + selected = _resolve_tag_binding_targets(result, binding) + selected_by_id = {item.topo_id: item for item in selected} + if len(selected_by_id) != len(selected): + ctx.fail( + f"Graph node '{node.node_id}' ({op_name}) resolved duplicate tag targets" + ) + selected = list(selected_by_id.values()) + if not selected: + ctx.fail( + f"Graph node '{node.node_id}' ({op_name}) resolved no tag targets" + ) + if ctx.strict: + _validate_tag_binding_evidence(binding, selected) + _validate_operation_output_evidence( + graph, node, binding, selected, outputs + ) + for selected_shape in selected: + selected_shape._add_tag_binding(binding) + _store_semantic_outputs(node, result) + continue + if op_name == "make_add_point_rsketch": ctx.require_params(node.node_id, op_name, params, ("point_id", "x", "y")) sketch_outputs = _input_outputs(ctx, outputs, node, 0) @@ -2140,6 +2590,9 @@ def _execute_graph( False, ) ), + tracking_policy=str( + params.get("tracking_policy", "full") + ), ) _store_outputs(node, result) continue @@ -2162,6 +2615,9 @@ def _execute_graph( clean=bool(_param(ctx, node.node_id, op_name, params, "clean", True)), glue=bool(_param(ctx, node.node_id, op_name, params, "glue", True)), tol=cast(Optional[float], _param(ctx, node.node_id, op_name, params, "tol", None)), + tracking_policy=str( + params.get("tracking_policy", "full") + ), ) _store_outputs(node, result) elif all_solids and not ctx.strict: @@ -2339,7 +2795,11 @@ def _execute_graph( profile_outputs = _all_input_outputs(ctx, outputs, node) if profile_outputs: result = ops.loft_rsolid( - cast(Any, profile_outputs), ruled=bool(params["ruled"]) + cast(Any, profile_outputs), + ruled=bool(params["ruled"]), + tracking_policy=str( + params.get("tracking_policy", "full") + ), ) _store_outputs(node, result) elif ctx.strict: @@ -2361,6 +2821,36 @@ def _execute_graph( _store_outputs(node, result) continue + if op_name == "make_twisted_sweep_rsolid": + ctx.require_params( + node.node_id, + op_name, + params, + ( + "axis", + "origin", + "distance", + "twist_angle", + "guide_radius", + ), + ) + if ctx.strict and len(node.inputs) != 1: + ctx.fail( + f"Graph node '{node.node_id}' ({op_name}) requires exactly one profile input" + ) + profile_outputs = _input_outputs(ctx, outputs, node, 0) + if profile_outputs: + result = ops.twisted_sweep_rsolid( + cast(Any, profile_outputs[0]), + distance=params["distance"], + twist_angle=params["twist_angle"], + axis=cast(Any, tuple(params["axis"])), + origin=cast(Any, tuple(params["origin"])), + guide_radius=params["guide_radius"], + ) + _store_outputs(node, result) + continue + if op_name == "make_mirror_rshape": ctx.require_params( node.node_id, diff --git a/SimpleCADAPI/src/simplecadapi/sketch.py b/SimpleCADAPI/src/simplecadapi/sketch.py index 63de5e6..973c57f 100644 --- a/SimpleCADAPI/src/simplecadapi/sketch.py +++ b/SimpleCADAPI/src/simplecadapi/sketch.py @@ -232,7 +232,7 @@ class Sketch(TaggedMixin, TopoMixein): def clone(self, *, include_solve: bool = True) -> "Sketch": cloned = Sketch(name=self.name, plane=self.plane, sketch_id=self.sketch_id) - cloned._tags = self._tags.copy() + cloned._copy_tag_state_from(self) cloned._metadata = self._metadata.copy() cloned.entities = dict(self.entities) cloned.entity_order = list(self.entity_order) diff --git a/SimpleCADAPI/src/simplecadapi/source_mapping.py b/SimpleCADAPI/src/simplecadapi/source_mapping.py new file mode 100644 index 0000000..248451e --- /dev/null +++ b/SimpleCADAPI/src/simplecadapi/source_mapping.py @@ -0,0 +1,367 @@ +"""Best-effort source callsites for recorded operation nodes.""" + +from __future__ import annotations + +import ast +import dis +import hashlib +import inspect +from functools import lru_cache +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + + +_CALL_OPS = { + "CALL", + "CALL_FUNCTION", + "CALL_FUNCTION_EX", + "CALL_FUNCTION_KW", + "CALL_KW", + "CALL_METHOD", +} +_SOURCE_MODULE = Path(__file__).resolve() +_PACKAGE_ROOT = _SOURCE_MODULE.parent + + +def capture_source_provenance() -> Optional[Dict[str, Any]]: + """Capture provenance for the first non-SimpleCAD caller frame. + + Source discovery is intentionally failure-tolerant. Interactive shells, + generated code, frozen applications, and deleted source files can all + produce a valid CAD graph without a source mapping. + """ + + frame = inspect.currentframe() + try: + frame = frame.f_back if frame is not None else None + while frame is not None: + filename = frame.f_code.co_filename + if not _is_internal_filename(filename): + return _capture_frame(frame) + frame = frame.f_back + finally: + # Do not retain a reference cycle through frame locals. + del frame + return None + + +def canonical_source_payload(source: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]: + """Return the portable form stored in graph JSON. + + ``local_path`` is a runtime-only hint. A source file outside the detected + project cannot be assigned a truthful project-relative path, so its + absolute path is also removed at the interchange boundary. + """ + + if source is None: + return None + payload = dict(source) + payload.pop("local_path", None) + if payload.get("path_kind") == "absolute": + payload["path"] = None + payload["path_kind"] = "unresolved" + return payload + + +def _is_internal_filename(filename: str) -> bool: + if not filename or filename.startswith("<"): + return True + try: + path = Path(filename).resolve() + path.relative_to(_PACKAGE_ROOT) + return True + except (OSError, ValueError): + return False + + +def _capture_frame(frame: Any) -> Optional[Dict[str, Any]]: + try: + path = Path(frame.f_code.co_filename).resolve() + source, _tree = _read_source(path) + except (OSError, SyntaxError, UnicodeError): + return None + + calls, parents = _indexed_calls( + path, + frame.f_code.co_name, + int(getattr(frame.f_code, "co_firstlineno", frame.f_lineno)), + ) + if not calls: + return None + + selected = _select_call(calls, frame) + if selected is None: + return None + call = selected + + path_value, path_kind = _portable_path(path) + call_text = ast.get_source_segment(source, call) or "" + start_line = int(getattr(call, "lineno", frame.f_lineno)) + end_line = int(getattr(call, "end_lineno", start_line)) + start_column = _character_column(source, start_line, int(call.col_offset)) + end_column = _character_column( + source, + end_line, + int(getattr(call, "end_col_offset", call.col_offset)), + ) + return { + "schema_version": "1.0", + "path": path_value, + "path_kind": path_kind, + "local_path": str(path), + "line": start_line, + "column": start_column, + "end_line": end_line, + "end_column": end_column, + "call_text": call_text, + "callsite_id": _callsite_id( + path_value=path_value if path_kind == "project_relative" else None, + start_line=start_line, + start_column=start_column, + end_line=end_line, + end_column=end_column, + call_text=call_text, + ), + "assignment_targets": _assignment_targets(call, source, parents), + } + + +def _read_source(path: Path) -> Tuple[str, ast.AST]: + stat = path.stat() + return _read_source_version( + path, + int(stat.st_mtime_ns), + int(stat.st_size), + int(getattr(stat, "st_ino", 0)), + ) + + +@lru_cache(maxsize=128) +def _read_source_version( + path: Path, _mtime_ns: int, _size: int, _inode: int +) -> Tuple[str, ast.AST]: + source = path.read_text(encoding="utf-8") + return source, ast.parse(source, filename=str(path)) + + +def _portable_path(path: Path) -> Tuple[Optional[str], str]: + root = _project_root(path) + if root is None: + return str(path), "absolute" + return path.relative_to(root).as_posix(), "project_relative" + + +def _project_root(path: Path) -> Optional[Path]: + for parent in (path.parent, *path.parents): + if (parent / "pyproject.toml").is_file(): + return parent + return None + + +def _find_scope( + tree: ast.AST, code_name: str, first_line: int +) -> Tuple[ast.AST, Optional[str]]: + if code_name == "": + return tree, None + + candidates: List[ast.AST] = [] + for node in ast.walk(tree): + if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + continue + if node.name != code_name: + continue + decorator_lines = [ + int(decorator.lineno) for decorator in node.decorator_list + ] + start_line = min([int(node.lineno), *decorator_lines]) + end_line = int(getattr(node, "end_lineno", node.lineno)) + if start_line <= first_line <= end_line: + candidates.append(node) + if not candidates: + return tree, code_name + scope = min( + candidates, + key=lambda node: int(getattr(node, "end_lineno", node.lineno)) - node.lineno, + ) + return scope, code_name + + +def _indexed_calls( + path: Path, code_name: str, first_line: int +) -> Tuple[List[ast.Call], Dict[int, ast.AST]]: + stat = path.stat() + return _indexed_calls_version( + path, + code_name, + first_line, + int(stat.st_mtime_ns), + int(stat.st_size), + int(getattr(stat, "st_ino", 0)), + ) + + +@lru_cache(maxsize=256) +def _indexed_calls_version( + path: Path, + code_name: str, + first_line: int, + _mtime_ns: int, + _size: int, + _inode: int, +) -> Tuple[List[ast.Call], Dict[int, ast.AST]]: + _source, tree = _read_source(path) + scope, _function_name = _find_scope(tree, code_name, first_line) + return _collect_calls(scope, root_is_module=scope is tree) + + +class _CallCollector(ast.NodeVisitor): + def __init__(self) -> None: + self.calls: List[ast.Call] = [] + self.parents: Dict[int, ast.AST] = {} + + def visit(self, node: ast.AST) -> Any: + for child in ast.iter_child_nodes(node): + self.parents[id(child)] = node + return super().visit(node) + + def visit_Call(self, node: ast.Call) -> Any: + # Calls are appended after their children, matching Python's normal + # evaluation order for nested calls and disassembled CALL opcodes. + self.generic_visit(node) + self.calls.append(node) + + def visit_FunctionDef(self, node: ast.FunctionDef) -> Any: + return None + + def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> Any: + return None + + def visit_Lambda(self, node: ast.Lambda) -> Any: + return None + + def visit_ClassDef(self, node: ast.ClassDef) -> Any: + return None + + +def _collect_calls( + scope: ast.AST, *, root_is_module: bool +) -> Tuple[List[ast.Call], Dict[int, ast.AST]]: + collector = _CallCollector() + if root_is_module: + for node in getattr(scope, "body", ()): + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): + continue + collector.visit(node) + else: + for node in getattr(scope, "body", ()): + collector.visit(node) + # The visitor's post-order append is the execution order within a source + # statement, while statement traversal already preserves file order. + return collector.calls, collector.parents + + +def _select_call( + calls: List[ast.Call], frame: Any +) -> Optional[ast.Call]: + line = int(frame.f_lineno) + line_calls = [call for call in calls if int(call.lineno) == line] + if not line_calls: + nearest_distance = min(abs(int(call.lineno) - line) for call in calls) + line_calls = [ + call for call in calls if abs(int(call.lineno) - line) == nearest_distance + ] + if len(line_calls) == 1: + return line_calls[0] + + ordinal = _call_ordinal(frame, line) + if ordinal is None or ordinal < 1 or ordinal > len(line_calls): + return None + return line_calls[ordinal - 1] + + +def _call_ordinal(frame: Any, line: int) -> Optional[int]: + current_offset = int(frame.f_lasti) + call_offsets = _call_offsets_by_line(frame.f_code).get(line, ()) + if current_offset not in call_offsets: + return None + return call_offsets.index(current_offset) + 1 + + +@lru_cache(maxsize=128) +def _call_offsets_by_line(code: Any) -> Dict[int, Tuple[int, ...]]: + line_by_offset = dict(dis.findlinestarts(code)) + offsets_by_line: Dict[int, List[int]] = {} + for instruction in dis.get_instructions(code): + if instruction.opname not in _CALL_OPS: + continue + line = _instruction_line(instruction.offset, line_by_offset, 0) + offsets_by_line.setdefault(line, []).append(instruction.offset) + return {line: tuple(offsets) for line, offsets in offsets_by_line.items()} + + +def _instruction_line(offset: int, line_by_offset: Dict[int, int], fallback: int) -> int: + starts = [start for start in line_by_offset if start <= offset] + return line_by_offset[max(starts)] if starts else int(fallback) + + +def _assignment_targets( + call: ast.Call, source: str, parents: Dict[int, ast.AST] +) -> List[str]: + parent = parents.get(id(call)) + targets: List[ast.AST] = [] + if isinstance(parent, ast.Assign) and parent.value is call: + targets = list(parent.targets) + elif isinstance(parent, ast.AnnAssign) and parent.value is call: + targets = [parent.target] + elif isinstance(parent, ast.NamedExpr) and parent.value is call: + targets = [parent.target] + + return [ + expression + for target in targets + for expression in _target_expressions(target, source) + if expression + ] + + +def _target_expressions(target: ast.AST, source: str) -> List[str]: + if isinstance(target, (ast.Tuple, ast.List)): + return [ + expression + for item in target.elts + for expression in _target_expressions(item, source) + ] + if isinstance(target, ast.Starred): + return _target_expressions(target.value, source) + expression = ast.get_source_segment(source, target) + return [expression] if expression else [] + + +def _character_column(source: str, line: int, byte_column: int) -> int: + lines = source.splitlines() + if line < 1 or line > len(lines): + return byte_column + prefix = lines[line - 1].encode("utf-8")[:byte_column] + return len(prefix.decode("utf-8", errors="ignore")) + + +def _callsite_id( + *, + path_value: Optional[str], + start_line: int, + start_column: int, + end_line: int, + end_column: int, + call_text: str, +) -> str: + material = "\x1f".join( + [ + path_value or "", + str(start_line), + str(start_column), + str(end_line), + str(end_column), + call_text, + ] + ) + return "callsite_" + hashlib.sha256(material.encode("utf-8")).hexdigest()[:16] diff --git a/SimpleCADAPI/src/simplecadapi/std/__init__.py b/SimpleCADAPI/src/simplecadapi/std/__init__.py index 84be8cb..d920683 100644 --- a/SimpleCADAPI/src/simplecadapi/std/__init__.py +++ b/SimpleCADAPI/src/simplecadapi/std/__init__.py @@ -4,12 +4,13 @@ Each sub-module provides parameterised standard mechanical components built entirely from the public SimpleCAD modelling API surface. Modules: - gear — involute spur / helical / herringbone gears + gear — involute spur / straight-bevel / helical / herringbone gears bearing — ball bearing standard assemblies - screw — (future) threaded fasteners + chain — roller-chain sprockets + fastener — parameterized bolts and nuts pin — (future) dowel pins, flat keys, split pins """ -from . import bearing, gear +from . import bearing, chain, fastener, gear -__all__ = ["bearing", "gear"] +__all__ = ["bearing", "chain", "fastener", "gear"] diff --git a/SimpleCADAPI/src/simplecadapi/std/bearing.py b/SimpleCADAPI/src/simplecadapi/std/bearing.py index bc7995e..a505f05 100644 --- a/SimpleCADAPI/src/simplecadapi/std/bearing.py +++ b/SimpleCADAPI/src/simplecadapi/std/bearing.py @@ -34,6 +34,7 @@ from ..operations import ( revolve_rsolid, ) from ..core import Face, Solid +from ..tracking import graph_tracking_scope __all__ = ["make_ball_bearing_rassembly"] @@ -228,6 +229,7 @@ def _ball_placement(ball_pitch_radius: float, angle_degrees: float): ) +@graph_tracking_scope def make_ball_bearing_rassembly( bore_diameter: float, outer_diameter: float, diff --git a/SimpleCADAPI/src/simplecadapi/std/chain.py b/SimpleCADAPI/src/simplecadapi/std/chain.py new file mode 100644 index 0000000..49ce0c8 --- /dev/null +++ b/SimpleCADAPI/src/simplecadapi/std/chain.py @@ -0,0 +1,112 @@ +"""Roller-chain standard parts built from pitch and roller dimensions.""" + +from __future__ import annotations + +import math + +from ..core import Solid +from ..operations import cut_rsolid, make_cylinder_rsolid +from ..tracking import graph_tracking_scope + +__all__ = ["make_roller_chain_sprocket_rsolid"] + + +def _positive_finite(name: str, value: float) -> float: + resolved = float(value) + if not math.isfinite(resolved) or resolved <= 0.0: + raise ValueError(f"{name} must be positive and finite") + return resolved + + +@graph_tracking_scope +def make_roller_chain_sprocket_rsolid( + n_teeth: int, + chain_pitch: float, + roller_diameter: float, + sprocket_thickness: float, + *, + bore_radius: float = 0.0, + roller_clearance: float = 0.15, +) -> Solid: + """Create a roller-chain sprocket from the chain engagement dimensions. + + The pitch radius follows the regular pitch polygon. Circular roller-seat + cutters are placed at every pitch point and opened through the standard + engineering outside-diameter envelope ``p * (0.6 + cot(180 / z))``. The + result preserves the tooth count, pitch, roller seats, root diameter, and + outside diameter needed for assembly-level selection. + + A released sprocket still requires the selected chain standard's permitted + tooth-form range, tooth width, hub, material, heat treatment, runout, and + supplier checks. + """ + if not isinstance(n_teeth, int) or isinstance(n_teeth, bool) or n_teeth < 6: + raise ValueError("n_teeth must be an integer of at least 6") + pitch = _positive_finite("chain_pitch", chain_pitch) + roller = _positive_finite("roller_diameter", roller_diameter) + thickness = _positive_finite("sprocket_thickness", sprocket_thickness) + resolved_bore = float(bore_radius) + resolved_clearance = float(roller_clearance) + if not math.isfinite(resolved_bore) or resolved_bore < 0.0: + raise ValueError("bore_radius must be non-negative and finite") + if not math.isfinite(resolved_clearance) or resolved_clearance < 0.0: + raise ValueError("roller_clearance must be non-negative and finite") + if roller >= pitch: + raise ValueError("roller_diameter must be smaller than chain_pitch") + + half_tooth_angle = math.pi / n_teeth + pitch_radius = pitch / (2.0 * math.sin(half_tooth_angle)) + seat_radius = roller / 2.0 + resolved_clearance + root_radius = pitch_radius - seat_radius + outside_radius = pitch * (0.6 + 1.0 / math.tan(half_tooth_angle)) / 2.0 + if root_radius <= 0.0: + raise ValueError("chain dimensions produce a non-positive root radius") + if not root_radius < outside_radius < pitch_radius + seat_radius: + raise ValueError("chain dimensions do not produce open roller seats") + if resolved_bore >= root_radius: + raise ValueError("bore_radius must be smaller than the sprocket root radius") + + blank = make_cylinder_rsolid( + radius=outside_radius, + height=thickness, + bottom_face_center=(0.0, 0.0, 0.0), + axis=(0.0, 0.0, 1.0), + ) + cutters = [ + make_cylinder_rsolid( + radius=seat_radius, + height=thickness + 0.4, + bottom_face_center=( + pitch_radius * math.cos(2.0 * math.pi * index / n_teeth), + pitch_radius * math.sin(2.0 * math.pi * index / n_teeth), + -0.2, + ), + axis=(0.0, 0.0, 1.0), + ) + for index in range(n_teeth) + ] + if resolved_bore > 0.0: + cutters.append( + make_cylinder_rsolid( + radius=resolved_bore, + height=thickness + 0.4, + bottom_face_center=(0.0, 0.0, -0.2), + axis=(0.0, 0.0, 1.0), + ) + ) + sprocket = cut_rsolid(blank, cutters, skip_non_intersecting=False) + sprocket.set_metadata( + "std.chain.roller_sprocket", + { + "n_teeth": n_teeth, + "chain_pitch": pitch, + "roller_diameter": roller, + "sprocket_thickness": thickness, + "bore_radius": resolved_bore, + "roller_clearance": resolved_clearance, + "pitch_radius": pitch_radius, + "root_radius": root_radius, + "outside_radius": outside_radius, + }, + ) + return sprocket diff --git a/SimpleCADAPI/src/simplecadapi/std/fastener.py b/SimpleCADAPI/src/simplecadapi/std/fastener.py new file mode 100644 index 0000000..fcb2810 --- /dev/null +++ b/SimpleCADAPI/src/simplecadapi/std/fastener.py @@ -0,0 +1,919 @@ +"""Parameterized threaded fasteners built from replayable public APIs.""" + +from __future__ import annotations + +import math +from typing import Collection, List, Optional, Sequence, Tuple, cast + +from ..core import Edge, Solid +from ..expr import ScalarLike +from ..graph import suspend_graph_recording +from ..operations import ( + apply_tag, + cut_rsolid, + extrude_rsolid, + fillet_rsolid, + intersect_rsolid, + make_box_rsolid, + make_cone_rsolid, + make_cylinder_rsolid, + make_face_from_wire_rface, + make_helix_rwire, + make_polyline_rwire, + revolve_rsolid, + rotate_shape, + sweep_rsolid, + union_rsolid, +) +from ..tracking import graph_tracking_scope + +__all__ = ["make_bolt_rsolid", "make_nut_rsolid"] + + +_BOLT_HEAD_STYLES = {"hex", "square", "cylindrical", "button", "countersunk"} +_DRIVE_STYLES = {"none", "slot", "cross", "hex_socket"} +_THREAD_STYLES = {"auto", "none", "full", "partial"} +_THREAD_DETAILS = {"cosmetic", "modeled"} +_THREAD_FORMS = {"v", "trapezoidal"} +_NUT_STYLES = {"hex", "square", "round", "knurled"} +_HOLE_STYLES = {"through", "blind"} +_THREAD_PHASE_CANDIDATES = ( + 0.0, + 15.0, + 30.0, + 45.0, + 60.0, + 90.0, + 120.0, + 135.0, + 150.0, + 165.0, + 180.0, + 210.0, + 240.0, + 270.0, + 300.0, + 330.0, +) + +# ISO metric coarse-pitch series used when callers provide a catalog nominal +# diameter without an explicit pitch. +_METRIC_COARSE_PITCH = { + 1.0: 0.25, + 1.2: 0.25, + 1.4: 0.30, + 1.6: 0.35, + 1.8: 0.35, + 2.0: 0.40, + 2.5: 0.45, + 3.0: 0.50, + 3.5: 0.60, + 4.0: 0.70, + 5.0: 0.80, + 6.0: 1.00, + 7.0: 1.00, + 8.0: 1.25, + 10.0: 1.50, + 12.0: 1.75, + 14.0: 2.00, + 16.0: 2.00, + 18.0: 2.50, + 20.0: 2.50, + 22.0: 2.50, + 24.0: 3.00, + 27.0: 3.00, + 30.0: 3.50, + 33.0: 3.50, + 36.0: 4.00, + 39.0: 4.00, + 42.0: 4.50, + 45.0: 4.50, + 48.0: 5.00, + 52.0: 5.00, + 56.0: 5.50, + 60.0: 5.50, + 64.0: 6.00, +} + +# Common ISO 4014/4017 hex-head dimensions: nominal diameter -> (s, k). +_METRIC_HEX_HEAD_DIMENSIONS = { + 3.0: (5.5, 2.0), + 4.0: (7.0, 2.8), + 5.0: (8.0, 3.5), + 6.0: (10.0, 4.0), + 8.0: (13.0, 5.3), + 10.0: (16.0, 6.4), + 12.0: (18.0, 7.5), + 14.0: (21.0, 8.8), + 16.0: (24.0, 10.0), + 18.0: (27.0, 11.5), + 20.0: (30.0, 12.5), + 22.0: (34.0, 14.0), + 24.0: (36.0, 15.0), + 27.0: (41.0, 17.0), + 30.0: (46.0, 18.7), + 33.0: (50.0, 21.0), + 36.0: (55.0, 22.5), +} + + +def _positive_finite(name: str, value: float) -> float: + resolved = float(value) + if not math.isfinite(resolved) or resolved <= 0.0: + raise ValueError(f"{name} must be positive and finite") + return resolved + + +def _optional_positive(name: str, value: Optional[float], default: float) -> float: + return _positive_finite(name, default if value is None else value) + + +def _non_negative_finite(name: str, value: float) -> float: + resolved = float(value) + if not math.isfinite(resolved) or resolved < 0.0: + raise ValueError(f"{name} must be non-negative and finite") + return resolved + + +def _choice(name: str, value: str, choices: Collection[str]) -> str: + resolved = str(value).lower() + if resolved not in choices: + options = ", ".join(sorted(choices)) + raise ValueError(f"{name} must be one of: {options}") + return resolved + + +def _catalog_value( + table: Collection[float], + nominal_diameter: float, +) -> Optional[float]: + return next( + ( + candidate + for candidate in table + if math.isclose(candidate, nominal_diameter, rel_tol=0.0, abs_tol=1e-9) + ), + None, + ) + + +def _resolve_thread_pitch( + diameter: float, + thread_pitch: Optional[float], +) -> Tuple[float, str]: + if thread_pitch is not None: + return _positive_finite("thread_pitch", thread_pitch), "explicit" + nominal = _catalog_value(_METRIC_COARSE_PITCH.keys(), diameter) + if nominal is None: + raise ValueError( + "thread_pitch is required when diameter is outside the metric coarse series" + ) + return _METRIC_COARSE_PITCH[nominal], "metric_coarse" + + +def _metric_thread_dimensions(diameter: float, pitch: float) -> Tuple[float, float]: + pitch_diameter = diameter - 0.6495 * pitch + minor_diameter = diameter - 1.0825 * pitch + if minor_diameter <= 0.0: + raise ValueError( + "diameter and thread_pitch produce a non-positive minor diameter" + ) + return pitch_diameter, minor_diameter + + +def _standard_partial_thread_length(diameter: float, length: float) -> float: + if length <= 125.0: + return 2.0 * diameter + 6.0 + if length <= 200.0: + return 2.0 * diameter + 12.0 + return 2.0 * diameter + 25.0 + + +def _regular_polygon_points( + sides: int, + circumradius: float, + z: float, + phase: float, +) -> List[Tuple[ScalarLike, ScalarLike, ScalarLike]]: + return [ + ( + circumradius * math.cos(phase + 2.0 * math.pi * index / sides), + circumradius * math.sin(phase + 2.0 * math.pi * index / sides), + z, + ) + for index in range(sides) + ] + + +def _make_prism( + points: Sequence[Tuple[ScalarLike, ScalarLike, ScalarLike]], + height: float, +) -> Solid: + wire = make_polyline_rwire(points=list(points), closed=True) + face = make_face_from_wire_rface(wire=wire, normal=(0.0, 0.0, 1.0)) + return extrude_rsolid( + profile=face, + direction=(0.0, 0.0, 1.0), + distance=height, + ) + + +def _make_across_flats_prism( + sides: int, width: float, z: float, height: float +) -> Solid: + radius = width / (2.0 * math.cos(math.pi / sides)) + points = _regular_polygon_points( + sides=sides, + circumradius=radius, + z=z, + phase=math.pi / sides, + ) + return _make_prism(points=points, height=height) + + +def _thread_profile_points( + *, + inner_radius: float, + outer_radius: float, + pitch: float, + z: float, + thread_form: str, +) -> List[Tuple[ScalarLike, ScalarLike, ScalarLike]]: + if thread_form == "v": + flank_run = (outer_radius - inner_radius) / math.sqrt(3.0) + crest_flat = pitch / 8.0 + root_flat = pitch - crest_flat - 2.0 * flank_run + if root_flat <= 0.0: + raise ValueError( + "thread_depth is too large for a truncated 60-degree V profile" + ) + return [ + (outer_radius, 0.0, z + crest_flat / 2.0), + (inner_radius, 0.0, z + crest_flat / 2.0 + flank_run), + ( + inner_radius, + 0.0, + z + crest_flat / 2.0 + flank_run + root_flat, + ), + (outer_radius, 0.0, z + pitch - crest_flat / 2.0), + ] + return [ + (outer_radius, 0.0, z), + (inner_radius, 0.0, z + pitch * 0.35), + (inner_radius, 0.0, z + pitch * 0.65), + (outer_radius, 0.0, z + pitch), + ] + + +def _make_thread_cutter( + *, + inner_radius: float, + outer_radius: float, + pitch: float, + start_z: float, + length: float, + thread_form: str, +) -> Solid: + overrun_start = start_z - pitch + profile = make_polyline_rwire( + points=_thread_profile_points( + inner_radius=inner_radius, + outer_radius=outer_radius, + pitch=pitch, + z=overrun_start, + thread_form=thread_form, + ), + closed=True, + ) + profile_face = make_face_from_wire_rface( + wire=profile, + normal=(0.0, 1.0, 0.0), + ) + helix = make_helix_rwire( + pitch=pitch, + height=length + 2.0 * pitch, + radius=outer_radius, + center=(0.0, 0.0, overrun_start), + dir=(0.0, 0.0, 1.0), + ) + swept = sweep_rsolid(profile=profile_face, path=helix, is_frenet=True) + clip = make_cylinder_rsolid( + radius=outer_radius + max(1e-6, pitch * 0.01), + height=length, + bottom_face_center=(0.0, 0.0, start_z), + axis=(0.0, 0.0, 1.0), + ) + return intersect_rsolid(swept, clip) + + +def _rotate_thread_cutter(cutter: Solid, angle_degrees: float) -> Solid: + if angle_degrees == 0.0: + return cutter + return cast( + Solid, + rotate_shape( + shape=cutter, + angle=angle_degrees, + axis=(0.0, 0.0, 1.0), + origin=(0.0, 0.0, 0.0), + ), + ) + + +def _select_external_thread_phase( + *, + shank: Solid, + cutter: Solid, + minimum_volume: float, +) -> float: + maximum_volume = shank.get_volume() + volume_tolerance = max(1e-8, maximum_volume * 1e-8) + with suspend_graph_recording(): + for angle_degrees in _THREAD_PHASE_CANDIDATES: + candidate = _rotate_thread_cutter(cutter, angle_degrees) + try: + result = cut_rsolid( + shank, + candidate, + skip_non_intersecting=False, + ) + except Exception: + continue + volume = result.get_volume() + if minimum_volume * 0.98 < volume < maximum_volume - volume_tolerance: + return angle_degrees + raise ValueError("modeled external thread is unstable for these dimensions") + + +def _select_internal_thread_phase( + *, + opened_nut: Solid, + body: Solid, + ridge: Solid, +) -> float: + minimum_volume = opened_nut.get_volume() + maximum_volume = body.get_volume() + volume_tolerance = max(1e-8, maximum_volume * 1e-8) + with suspend_graph_recording(): + for angle_degrees in _THREAD_PHASE_CANDIDATES: + candidate = _rotate_thread_cutter(ridge, angle_degrees) + try: + result = union_rsolid(opened_nut, candidate, glue=False) + except Exception: + continue + volume = result.get_volume() + if ( + minimum_volume + volume_tolerance + < volume + < maximum_volume - volume_tolerance + ): + return angle_degrees + raise ValueError("modeled internal thread is unstable for these dimensions") + + +def _default_head_dimensions(diameter: float, head_style: str) -> Tuple[float, float]: + if head_style == "hex": + nominal = _catalog_value(_METRIC_HEX_HEAD_DIMENSIONS.keys(), diameter) + if nominal is not None: + return _METRIC_HEX_HEAD_DIMENSIONS[nominal] + factors = { + "hex": (1.6, 0.65), + "square": (1.5, 0.70), + "cylindrical": (1.5, 1.0), + "button": (1.8, 0.55), + "countersunk": (2.0, 0.60), + } + width_factor, height_factor = factors[head_style] + return diameter * width_factor, diameter * height_factor + + +def _underhead_edge(solid: Solid, shank_radius: float) -> Edge: + circumference = 2.0 * math.pi * shank_radius + edges = cast(List[Edge], solid.get_edges()) + candidates = [ + edge + for edge in edges + if abs(edge.get_center().z) <= max(1e-7, shank_radius * 1e-5) + and abs(edge.get_length() - circumference) <= circumference * 0.05 + ] + if len(candidates) != 1: + raise ValueError("could not identify one circular underhead transition edge") + return candidates[0] + + +def _make_bolt_head( + head_style: str, width: float, height: float, shank_radius: float +) -> Solid: + if head_style == "hex": + return _make_across_flats_prism( + sides=6, + width=width, + z=-height, + height=height, + ) + if head_style == "square": + return make_box_rsolid( + width=width, + height=width, + depth=height, + bottom_face_center=(0.0, 0.0, -height), + ) + if head_style == "cylindrical": + return make_cylinder_rsolid( + radius=width / 2.0, + height=height, + bottom_face_center=(0.0, 0.0, -height), + axis=(0.0, 0.0, 1.0), + ) + if head_style == "countersunk": + return make_cone_rsolid( + bottom_radius=width / 2.0, + top_radius=shank_radius, + height=height, + bottom_face_center=(0.0, 0.0, -height), + axis=(0.0, 0.0, 1.0), + ) + + radius = width / 2.0 + profile = make_polyline_rwire( + points=[ + (0.0, 0.0, -height), + (radius * 0.42, 0.0, -height), + (radius * 0.82, 0.0, -height * 0.62), + (radius, 0.0, -height * 0.22), + (radius, 0.0, 0.0), + (0.0, 0.0, 0.0), + ], + closed=True, + ) + face = make_face_from_wire_rface(wire=profile, normal=(0.0, -1.0, 0.0)) + return revolve_rsolid( + profile=face, + axis=(0.0, 0.0, 1.0), + angle=360.0, + origin=(0.0, 0.0, 0.0), + ) + + +def _drive_cutters( + *, + drive_style: str, + drive_size: float, + drive_depth: float, + head_height: float, +) -> List[Solid]: + if drive_style == "none": + return [] + epsilon = max(1e-5, head_height * 1e-4) + cutter_z = -head_height - epsilon + cutter_height = drive_depth + epsilon + slot_width = max(drive_size * 0.22, epsilon * 10.0) + if drive_style == "slot": + return [ + make_box_rsolid( + width=drive_size, + height=slot_width, + depth=cutter_height, + bottom_face_center=(0.0, 0.0, cutter_z), + ) + ] + if drive_style == "cross": + return [ + make_box_rsolid( + width=drive_size, + height=slot_width, + depth=cutter_height, + bottom_face_center=(0.0, 0.0, cutter_z), + ), + make_box_rsolid( + width=slot_width, + height=drive_size, + depth=cutter_height, + bottom_face_center=(0.0, 0.0, cutter_z), + ), + ] + points = _regular_polygon_points( + sides=6, + circumradius=drive_size / 2.0, + z=cutter_z, + phase=math.pi / 6.0, + ) + return [_make_prism(points=points, height=cutter_height)] + + +@graph_tracking_scope +def make_bolt_rsolid( + diameter: float, + length: float, + head_style: str = "hex", + thread_style: str = "auto", + thread_detail: str = "modeled", + thread_form: str = "v", + thread_pitch: Optional[float] = None, + thread_depth: Optional[float] = None, + thread_length: Optional[float] = None, + head_width: Optional[float] = None, + head_height: Optional[float] = None, + drive_style: str = "none", + drive_size: Optional[float] = None, + drive_depth: Optional[float] = None, + underhead_fillet_radius: Optional[float] = None, +) -> Solid: + """Create a bolt along +Z with its head underside on the Z=0 plane. + + ``head_style`` accepts ``hex``, ``square``, ``cylindrical``, ``button``, + and ``countersunk``. ``thread_style`` accepts ``auto``, ``full``, + ``partial``, and ``none``. Auto uses full thread through 3d length and the + standard piecewise partial-thread length above 3d. Cosmetic threads retain + a smooth major-diameter shank and record thread intent in metadata; modeled + threads cut a replayable helical groove. + """ + resolved_diameter = _positive_finite("diameter", diameter) + resolved_length = _positive_finite("length", length) + resolved_head_style = _choice("head_style", head_style, _BOLT_HEAD_STYLES) + requested_thread_style = _choice("thread_style", thread_style, _THREAD_STYLES) + resolved_thread_style = requested_thread_style + if requested_thread_style == "auto": + if thread_length is not None: + raise ValueError("thread_length requires thread_style='partial'") + resolved_thread_style = ( + "full" if resolved_length <= 3.0 * resolved_diameter else "partial" + ) + resolved_thread_detail = _choice("thread_detail", thread_detail, _THREAD_DETAILS) + resolved_thread_form = _choice("thread_form", thread_form, _THREAD_FORMS) + resolved_drive_style = _choice("drive_style", drive_style, _DRIVE_STYLES) + + default_width, default_height = _default_head_dimensions( + resolved_diameter, + resolved_head_style, + ) + resolved_head_width = _optional_positive("head_width", head_width, default_width) + resolved_head_height = _optional_positive( + "head_height", head_height, default_height + ) + if resolved_head_width <= resolved_diameter: + raise ValueError("head_width must be greater than diameter") + head_across_corners = ( + resolved_head_width / math.cos(math.pi / 6.0) + if resolved_head_style == "hex" + else None + ) + + if resolved_thread_style == "none" and thread_pitch is None: + pitch = 0.0 + pitch_source = "not_applicable" + pitch_diameter = resolved_diameter + basic_minor_diameter = resolved_diameter + else: + pitch, pitch_source = _resolve_thread_pitch(resolved_diameter, thread_pitch) + pitch_diameter, basic_minor_diameter = _metric_thread_dimensions( + resolved_diameter, + pitch, + ) + default_depth = ( + (resolved_diameter - basic_minor_diameter) / 2.0 + if resolved_thread_form == "v" and pitch > 0.0 + else pitch * 0.5 + ) + depth = ( + _positive_finite("thread_depth", thread_depth) + if thread_depth is not None + else default_depth + ) + major_radius = resolved_diameter / 2.0 + if resolved_thread_style != "none" and depth >= major_radius: + raise ValueError("thread_depth must be smaller than the bolt radius") + modeled_minor_diameter = resolved_diameter - 2.0 * depth + + if resolved_thread_style == "none": + resolved_thread_length = 0.0 + thread_start = resolved_length + elif resolved_thread_style == "full": + if thread_length is not None: + raise ValueError("thread_length is only valid when thread_style='partial'") + resolved_thread_length = resolved_length + thread_start = 0.0 + else: + if thread_length is None: + resolved_thread_length = _standard_partial_thread_length( + resolved_diameter, + resolved_length, + ) + if ( + requested_thread_style == "auto" + and resolved_thread_length >= resolved_length + ): + resolved_thread_style = "full" + resolved_thread_length = resolved_length + else: + resolved_thread_length = _positive_finite("thread_length", thread_length) + if resolved_thread_length <= 0.0: + raise ValueError("partial threads require positive unthreaded shank length") + if ( + resolved_thread_style == "partial" + and resolved_thread_length >= resolved_length + ): + raise ValueError("partial thread_length must be smaller than length") + thread_start = ( + 0.0 + if resolved_thread_style == "full" + else resolved_length - resolved_thread_length + ) + + if ( + resolved_thread_style != "none" + and resolved_thread_detail == "modeled" + and resolved_thread_length <= pitch + ): + raise ValueError("modeled thread length must be greater than thread_pitch") + + resolved_drive_size = 0.0 + resolved_drive_depth = 0.0 + if resolved_drive_style != "none": + resolved_drive_size = _optional_positive( + "drive_size", + drive_size, + resolved_head_width * 0.48, + ) + resolved_drive_depth = _optional_positive( + "drive_depth", + drive_depth, + resolved_head_height * 0.28, + ) + if resolved_drive_size >= resolved_head_width * 0.85: + raise ValueError("drive_size is too large for head_width") + if resolved_drive_depth >= resolved_head_height * 0.8: + raise ValueError("drive_depth is too large for head_height") + elif drive_size is not None or drive_depth is not None: + raise ValueError("drive_size and drive_depth require a non-'none' drive_style") + + default_fillet_radius = ( + 0.0 if resolved_head_style == "countersunk" else resolved_diameter * 0.06 + ) + resolved_fillet_radius = _non_negative_finite( + "underhead_fillet_radius", + ( + default_fillet_radius + if underhead_fillet_radius is None + else underhead_fillet_radius + ), + ) + maximum_fillet_radius = min(major_radius * 0.3, resolved_head_height * 0.25) + if resolved_fillet_radius > maximum_fillet_radius: + raise ValueError( + "underhead_fillet_radius is too large for the shank and head dimensions" + ) + + epsilon = max(1e-5, resolved_diameter * 1e-4) + thread_phase_degrees = 0.0 + shank = make_cylinder_rsolid( + radius=major_radius, + height=resolved_length + epsilon, + bottom_face_center=(0.0, 0.0, -epsilon), + axis=(0.0, 0.0, 1.0), + ) + + head = _make_bolt_head( + head_style=resolved_head_style, + width=resolved_head_width, + height=resolved_head_height, + shank_radius=major_radius, + ) + cutters = _drive_cutters( + drive_style=resolved_drive_style, + drive_size=resolved_drive_size, + drive_depth=resolved_drive_depth, + head_height=resolved_head_height, + ) + if cutters: + head = cut_rsolid(head, cutters, skip_non_intersecting=False) + bolt = union_rsolid(head, shank, glue=False) + if resolved_fillet_radius > 0.0: + bolt = fillet_rsolid( + solid=bolt, + edges=[_underhead_edge(bolt, major_radius)], + radius=resolved_fillet_radius, + ) + + modeled_thread_start = thread_start + modeled_thread_length = resolved_thread_length + if resolved_thread_style == "full": + modeled_thread_start = max(pitch, resolved_fillet_radius) + modeled_thread_length = resolved_length - modeled_thread_start + if resolved_thread_style != "none" and resolved_thread_detail == "modeled": + cutter = _make_thread_cutter( + inner_radius=major_radius - depth, + outer_radius=major_radius + depth * 0.23, + pitch=pitch, + start_z=modeled_thread_start, + length=modeled_thread_length, + thread_form=resolved_thread_form, + ) + maximum_removed_volume = ( + math.pi + * (major_radius * major_radius - (major_radius - depth) ** 2) + * modeled_thread_length + ) + thread_phase_degrees = _select_external_thread_phase( + shank=bolt, + cutter=cutter, + minimum_volume=bolt.get_volume() - maximum_removed_volume, + ) + cutter = _rotate_thread_cutter(cutter, thread_phase_degrees) + bolt = cut_rsolid(bolt, cutter, skip_non_intersecting=False) + bolt = cast(Solid, apply_tag(shape=bolt, tag="role.fastener.bolt")) + bolt = cast(Solid, apply_tag(shape=bolt, tag="group.fasteners")) + bolt.set_metadata( + "std.fastener.bolt", + { + "diameter": resolved_diameter, + "length": resolved_length, + "head_style": resolved_head_style, + "head_width": resolved_head_width, + "head_height": resolved_head_height, + "head_across_corners": head_across_corners, + "drive_style": resolved_drive_style, + "drive_size": resolved_drive_size, + "drive_depth": resolved_drive_depth, + "requested_thread_style": requested_thread_style, + "thread_style": resolved_thread_style, + "thread_detail": resolved_thread_detail, + "thread_form": resolved_thread_form, + "thread_pitch": pitch, + "thread_pitch_source": pitch_source, + "thread_depth": depth, + "pitch_diameter": pitch_diameter, + "basic_minor_diameter": basic_minor_diameter, + "modeled_minor_diameter": modeled_minor_diameter, + "thread_start": thread_start, + "thread_length": resolved_thread_length, + "modeled_thread_start": modeled_thread_start, + "modeled_thread_length": modeled_thread_length, + "thread_phase_degrees": thread_phase_degrees, + "underhead_fillet_radius": resolved_fillet_radius, + "recommended_mating_hole_chamfer_min": resolved_fillet_radius, + }, + ) + return bolt + + +def _make_nut_body(style: str, width: float, height: float, knurl_count: int) -> Solid: + if style == "hex": + return _make_across_flats_prism(sides=6, width=width, z=0.0, height=height) + if style == "square": + return make_box_rsolid( + width=width, + height=width, + depth=height, + bottom_face_center=(0.0, 0.0, 0.0), + ) + if style == "round": + return make_cylinder_rsolid( + radius=width / 2.0, + height=height, + bottom_face_center=(0.0, 0.0, 0.0), + axis=(0.0, 0.0, 1.0), + ) + + outer_radius = width / 2.0 + points = [] + for index in range(knurl_count * 2): + radius = outer_radius if index % 2 == 0 else outer_radius * 0.94 + angle = math.pi * index / knurl_count + points.append((radius * math.cos(angle), radius * math.sin(angle), 0.0)) + return _make_prism(points=points, height=height) + + +@graph_tracking_scope +def make_nut_rsolid( + diameter: float, + width: float, + height: float, + nut_style: str = "hex", + hole_style: str = "through", + thread_detail: str = "modeled", + thread_form: str = "v", + thread_pitch: Optional[float] = None, + thread_depth: Optional[float] = None, + hole_depth: Optional[float] = None, + knurl_count: int = 24, +) -> Solid: + """Create a nut along +Z with its bottom face on the Z=0 plane. + + ``nut_style`` accepts ``hex``, ``square``, ``round``, and ``knurled``. + Through and blind holes are supported. Modeled thread detail adds internal + V or trapezoidal helical teeth to a major-diameter hole. + """ + resolved_diameter = _positive_finite("diameter", diameter) + resolved_width = _positive_finite("width", width) + resolved_height = _positive_finite("height", height) + resolved_nut_style = _choice("nut_style", nut_style, _NUT_STYLES) + resolved_hole_style = _choice("hole_style", hole_style, _HOLE_STYLES) + resolved_thread_detail = _choice("thread_detail", thread_detail, _THREAD_DETAILS) + resolved_thread_form = _choice("thread_form", thread_form, _THREAD_FORMS) + pitch, pitch_source = _resolve_thread_pitch(resolved_diameter, thread_pitch) + pitch_diameter, basic_minor_diameter = _metric_thread_dimensions( + resolved_diameter, + pitch, + ) + default_depth = ( + (resolved_diameter - basic_minor_diameter) / 2.0 + if resolved_thread_form == "v" + else pitch * 0.5 + ) + depth = ( + _positive_finite("thread_depth", thread_depth) + if thread_depth is not None + else default_depth + ) + major_radius = resolved_diameter / 2.0 + if depth >= major_radius: + raise ValueError("thread_depth must be smaller than the nut hole radius") + if resolved_width / 2.0 <= major_radius + resolved_diameter * 0.05: + raise ValueError("width leaves insufficient wall around the nut hole") + + if ( + not isinstance(knurl_count, int) + or isinstance(knurl_count, bool) + or knurl_count < 8 + ): + raise ValueError("knurl_count must be an integer of at least 8") + if resolved_hole_style == "through": + if hole_depth is not None: + raise ValueError("hole_depth is only valid when hole_style='blind'") + resolved_hole_depth = resolved_height + hole_start = 0.0 + else: + resolved_hole_depth = _optional_positive( + "hole_depth", + hole_depth, + resolved_height * 0.75, + ) + if resolved_hole_depth >= resolved_height: + raise ValueError("blind hole_depth must be smaller than height") + hole_start = resolved_height - resolved_hole_depth + if resolved_thread_detail == "modeled" and resolved_hole_depth <= pitch: + raise ValueError("modeled thread depth must be greater than thread_pitch") + + body = _make_nut_body( + style=resolved_nut_style, + width=resolved_width, + height=resolved_height, + knurl_count=knurl_count, + ) + epsilon = max(1e-5, resolved_diameter * 1e-4) + cut_start = -epsilon if resolved_hole_style == "through" else hole_start + cut_height = ( + resolved_hole_depth + 2.0 * epsilon + if resolved_hole_style == "through" + else resolved_hole_depth + epsilon + ) + hole = make_cylinder_rsolid( + radius=major_radius, + height=cut_height, + bottom_face_center=(0.0, 0.0, cut_start), + axis=(0.0, 0.0, 1.0), + ) + nut = cut_rsolid(body, hole, skip_non_intersecting=False) + thread_phase_degrees = 0.0 + if resolved_thread_detail == "modeled": + ridge = _make_thread_cutter( + inner_radius=major_radius - depth, + outer_radius=major_radius + depth * 0.23, + pitch=pitch, + start_z=hole_start, + length=resolved_hole_depth, + thread_form=resolved_thread_form, + ) + thread_phase_degrees = _select_internal_thread_phase( + opened_nut=nut, + body=body, + ridge=ridge, + ) + ridge = _rotate_thread_cutter(ridge, thread_phase_degrees) + nut = union_rsolid(nut, ridge, glue=False) + nut = cast(Solid, apply_tag(shape=nut, tag="role.fastener.nut")) + nut = cast(Solid, apply_tag(shape=nut, tag="group.fasteners")) + nut.set_metadata( + "std.fastener.nut", + { + "diameter": resolved_diameter, + "width": resolved_width, + "height": resolved_height, + "nut_style": resolved_nut_style, + "hole_style": resolved_hole_style, + "hole_depth": resolved_hole_depth, + "hole_start": hole_start, + "thread_detail": resolved_thread_detail, + "thread_form": resolved_thread_form, + "thread_pitch": pitch, + "thread_pitch_source": pitch_source, + "thread_depth": depth, + "pitch_diameter": pitch_diameter, + "basic_minor_diameter": basic_minor_diameter, + "modeled_minor_diameter": resolved_diameter - 2.0 * depth, + "thread_phase_degrees": thread_phase_degrees, + "knurl_count": knurl_count, + }, + ) + return nut diff --git a/SimpleCADAPI/src/simplecadapi/std/gear.py b/SimpleCADAPI/src/simplecadapi/std/gear.py index 6014bd7..d57721c 100644 --- a/SimpleCADAPI/src/simplecadapi/std/gear.py +++ b/SimpleCADAPI/src/simplecadapi/std/gear.py @@ -9,8 +9,10 @@ Each gear profile is assembled in a constraint sketch using: concentricity constraints capturing the design intent The sketch is solved and promoted to a face via -``make_face_from_sketch_rface``. Spur gears are extruded; helical and -herringbone gears use ruled multi-section lofts with small incremental twist. +``make_face_from_sketch_rface``. Spur gears are extruded; straight bevel gears +loft similar involute sections along the pitch cone; helical gears use a +continuous twisted sweep, and herringbone gears fuse two opposite twisted +halves. Spur ring gears build a multi-loop face from an outer rim wire and an inward internal-tooth inner wire, then extrude it directly. Helical and herringbone ring gears loft the internal tooth void and subtract it from an extruded outer @@ -26,7 +28,10 @@ from typing import List, Optional, Tuple from ..core import Solid, Wire, Face from ..math import fit_cubic_bspline_control_points +from ..tracking import TrackingPolicy, graph_tracking_scope from ..operations import ( + _begin_linear_sketch_edits, + _end_linear_sketch_edits, add_arc_rsketch, add_bspline_rsketch, add_circle_rsketch, @@ -49,10 +54,13 @@ from ..operations import ( make_wire_from_sketch_rwire, rotate_shape, translate_shape, + twisted_sweep_rsolid, + union_rsolid, ) __all__ = [ "make_spur_gear_rsolid", + "make_straight_bevel_gear_rsolid", "make_helical_gear_rsolid", "make_herringbone_gear_rsolid", "make_spur_ring_gear_rsolid", @@ -69,6 +77,7 @@ __all__ = [ # Involute geometry helpers # --------------------------------------------------------------------------- + def _involute_point(base_radius: float, t: float) -> Tuple[float, float]: """Return a point on the involute of a circle at parameter *t*.""" return ( @@ -106,7 +115,9 @@ def _pressure_angle_to_radians(pressure_angle: float) -> float: if not math.isfinite(angle): raise ValueError("pressure_angle must be finite") if angle <= 0.0 or angle >= 90.0: - raise ValueError("pressure_angle must be greater than 0 and less than 90 degrees") + raise ValueError( + "pressure_angle must be greater than 0 and less than 90 degrees" + ) return math.radians(angle) @@ -116,6 +127,32 @@ def _rotate_xy(point: Tuple[float, float], angle: float) -> Tuple[float, float]: return (point[0] * ca - point[1] * sa, point[0] * sa + point[1] * ca) +def _transform_bspline_control_points( + control_points: List[List[float]], + angle: float, + *, + mirror: bool = False, + reverse: bool = False, +) -> List[List[float]]: + """Apply tooth symmetry to an already fitted canonical B-spline.""" + transformed = [] + for x, y in control_points: + point = (float(x), -float(y) if mirror else float(y)) + rx, ry = _rotate_xy(point, angle) + transformed.append([rx, ry]) + return list(reversed(transformed)) if reverse else transformed + + +def _reverse_bspline_parameters( + unique_knots: List[float], multiplicities: List[int] +) -> Tuple[List[float], List[int]]: + parameter_sum = float(unique_knots[0]) + float(unique_knots[-1]) + return ( + [parameter_sum - float(knot) for knot in reversed(unique_knots)], + list(reversed(multiplicities)), + ) + + def _unit_xy(angle: float) -> Tuple[float, float]: return (math.cos(angle), math.sin(angle)) @@ -197,15 +234,20 @@ def _compute_tooth_geometry( ) -> dict: """Compute geometric parameters for an involute tooth-space profile.""" addendum = _validate_positive_finite("addendum_factor", addendum_factor) * module - clearance = _validate_non_negative_finite("clearance_factor", clearance_factor) * module + clearance = ( + _validate_non_negative_finite("clearance_factor", clearance_factor) * module + ) backlash_value = _validate_non_negative_finite("backlash", backlash) pitch_radius = module * n_teeth / 2.0 base_radius = pitch_radius * math.cos(pressure_angle) - resolved_tip_radius = pitch_radius + addendum if tip_radius is None else float(tip_radius) + resolved_tip_radius = ( + pitch_radius + addendum if tip_radius is None else float(tip_radius) + ) resolved_root_radius = ( max(pitch_radius - addendum - clearance, base_radius * 0.5) - if root_radius is None else float(root_radius) + if root_radius is None + else float(root_radius) ) if resolved_tip_radius <= resolved_root_radius: raise ValueError("tip radius must be greater than root radius") @@ -248,6 +290,7 @@ def _compute_tooth_geometry( # Constraint-sketch-driven gear profile (B-spline flanks + arc root/tip) # --------------------------------------------------------------------------- + def _build_gear_profile_face( n_teeth: int, module: float, @@ -274,8 +317,11 @@ def _build_gear_profile_face( constraints. Profile continuity is expressed by shared point ids. """ geo = _compute_tooth_geometry( - n_teeth, module, pressure_angle, - root_radius=root_radius, tip_radius=tip_radius, + n_teeth, + module, + pressure_angle, + root_radius=root_radius, + tip_radius=tip_radius, addendum_factor=addendum_factor, clearance_factor=clearance_factor, backlash=backlash, @@ -298,16 +344,36 @@ def _build_gear_profile_face( root_right_angle = right_start if needs_root_connectors else right_flank_start_angle prev_right_start = root_right_angle - tooth_angle + canonical_cps, flank_degree, flank_knots, flank_mults = ( + _involute_bspline_control_points( + base_radius, + tip_radius, + flank_start_radius, + start_angle=0.0, + mirror=False, + ) + ) + reversed_flank_knots, reversed_flank_mults = _reverse_bspline_parameters( + flank_knots, flank_mults + ) + sketch = make_sketch_rsketch(name=f"gear_{n_teeth}t_m{module}", plane="XY") + sketch = _begin_linear_sketch_edits(sketch) # Add center point for construction circles sketch = add_point_rsketch(sketch, "center", 0.0, 0.0) sketch = constrain_fix_rsketch(sketch, "center") # Construction circles - sketch = add_circle_rsketch(sketch, "root_circle", "center", root_radius, construction=True) - sketch = add_circle_rsketch(sketch, "pitch_circle", "center", geo["pitch_radius"], construction=True) - sketch = add_circle_rsketch(sketch, "tip_circle", "center", tip_radius, construction=True) + sketch = add_circle_rsketch( + sketch, "root_circle", "center", root_radius, construction=True + ) + sketch = add_circle_rsketch( + sketch, "pitch_circle", "center", geo["pitch_radius"], construction=True + ) + sketch = add_circle_rsketch( + sketch, "tip_circle", "center", tip_radius, construction=True + ) sketch = constrain_radius_rsketch(sketch, "root_circle", root_radius) sketch = constrain_radius_rsketch(sketch, "pitch_circle", geo["pitch_radius"]) sketch = constrain_radius_rsketch(sketch, "tip_circle", tip_radius) @@ -325,20 +391,62 @@ def _build_gear_profile_face( a_tip_start = left_tip_angle + offset a_tip_end = right_tip_angle + offset - tooth_data.append({ - "rs": (root_radius * math.cos(a_root_start), root_radius * math.sin(a_root_start)), - "re": (root_radius * math.cos(a_root_end), root_radius * math.sin(a_root_end)), - "bs": (flank_start_radius * math.cos(a_base_start), flank_start_radius * math.sin(a_base_start)), - "be": (flank_start_radius * math.cos(a_base_end), flank_start_radius * math.sin(a_base_end)), - "ts": (tip_radius * math.cos(a_tip_start), tip_radius * math.sin(a_tip_start)), - "te": (tip_radius * math.cos(a_tip_end), tip_radius * math.sin(a_tip_end)), - }) + tooth_data.append( + { + "rs": ( + root_radius * math.cos(a_root_start), + root_radius * math.sin(a_root_start), + ), + "re": ( + root_radius * math.cos(a_root_end), + root_radius * math.sin(a_root_end), + ), + "bs": ( + flank_start_radius * math.cos(a_base_start), + flank_start_radius * math.sin(a_base_start), + ), + "be": ( + flank_start_radius * math.cos(a_base_end), + flank_start_radius * math.sin(a_base_end), + ), + "ts": ( + tip_radius * math.cos(a_tip_start), + tip_radius * math.sin(a_tip_start), + ), + "te": ( + tip_radius * math.cos(a_tip_end), + tip_radius * math.sin(a_tip_end), + ), + } + ) + + left_fillet_template = None + right_fillet_template = None + if needs_root_connectors: + left_fillet_template = _root_fillet_control_points( + tooth_data[0]["re"], + tooth_data[0]["bs"], + start_tangent_angle=root_left_angle + math.pi / 2.0, + end_tangent_angle=left_start, + ) + right_fillet_template = _root_fillet_control_points( + tooth_data[0]["be"], + tooth_data[1 % n_teeth]["rs"], + start_tangent_angle=right_start + math.pi, + end_tangent_angle=root_right_angle + math.pi / 2.0, + ) # Phase 1: Add resolved profile points. Edges share these ids to express # coincident endpoints without hundreds of redundant fix constraints. for i, td in enumerate(tooth_data): - for key, (px, py) in [("rs", td["rs"]), ("re", td["re"]), ("bs", td["bs"]), - ("be", td["be"]), ("ts", td["ts"]), ("te", td["te"])]: + for key, (px, py) in [ + ("rs", td["rs"]), + ("re", td["re"]), + ("bs", td["bs"]), + ("be", td["be"]), + ("ts", td["ts"]), + ("te", td["te"]), + ]: pid = f"t{i}_{key}" sketch = add_point_rsketch(sketch, pid, px, py) @@ -358,59 +466,84 @@ def _build_gear_profile_face( # Tangent root fillet: re -> bs (only when root lies inside base circle) if needs_root_connectors: - fillet_cps, fillet_deg, fillet_knots, fillet_mults = _root_fillet_control_points( - td["re"], td["bs"], - start_tangent_angle=root_left_angle + tooth_angle * i + math.pi / 2.0, - end_tangent_angle=left_start + tooth_angle * i, + assert left_fillet_template is not None + template_cps, fillet_deg, fillet_knots, fillet_mults = ( + left_fillet_template + ) + fillet_cps = _transform_bspline_control_points( + template_cps, + tooth_angle * i, ) sketch = add_bspline_rsketch( - sketch, f"fillet_left_{i}", re_id, f"t{i}_bs", - control_points=fillet_cps, degree=fillet_deg, - knots=fillet_knots, multiplicities=fillet_mults, + sketch, + f"fillet_left_{i}", + re_id, + f"t{i}_bs", + control_points=fillet_cps, + degree=fillet_deg, + knots=fillet_knots, + multiplicities=fillet_mults, ) # Left involute flank: bs → ts (B-spline) - left_cps, left_deg, left_knots, left_mults = _involute_bspline_control_points( - base_radius, tip_radius, flank_start_radius, - start_angle=left_start + tooth_angle * i, - mirror=False, + left_cps = _transform_bspline_control_points( + canonical_cps, + left_start + tooth_angle * i, ) sketch = add_bspline_rsketch( - sketch, f"bspline_left_{i}", bs_id, ts_id, - control_points=left_cps, degree=left_deg, - knots=left_knots, multiplicities=left_mults, + sketch, + f"bspline_left_{i}", + bs_id, + ts_id, + control_points=left_cps, + degree=flank_degree, + knots=flank_knots, + multiplicities=flank_mults, ) # Tip arc: ts → te sketch = add_arc_rsketch(sketch, f"arc_tip_{i}", ts_id, te_id, "center") # Right involute flank: te → be (B-spline) - right_cps, right_deg, right_knots, right_mults = _involute_bspline_control_points( - base_radius, tip_radius, flank_start_radius, - start_angle=right_start + tooth_angle * i, + right_cps = _transform_bspline_control_points( + canonical_cps, + right_start + tooth_angle * i, mirror=True, reverse=True, ) sketch = add_bspline_rsketch( - sketch, f"bspline_right_{i}", te_id, right_flank_end_id, - control_points=right_cps, degree=right_deg, - knots=right_knots, multiplicities=right_mults, + sketch, + f"bspline_right_{i}", + te_id, + right_flank_end_id, + control_points=right_cps, + degree=flank_degree, + knots=reversed_flank_knots, + multiplicities=reversed_flank_mults, ) # Tangent root fillet: be -> next rs (only when root lies inside base circle) if needs_root_connectors: - next_td = tooth_data[(i + 1) % n_teeth] - fillet_cps, fillet_deg, fillet_knots, fillet_mults = _root_fillet_control_points( - td["be"], next_td["rs"], - start_tangent_angle=right_start + tooth_angle * i + math.pi, - end_tangent_angle=root_right_angle + tooth_angle * i + math.pi / 2.0, + assert right_fillet_template is not None + template_cps, fillet_deg, fillet_knots, fillet_mults = ( + right_fillet_template + ) + fillet_cps = _transform_bspline_control_points( + template_cps, + tooth_angle * i, ) sketch = add_bspline_rsketch( - sketch, f"fillet_right_{i}", be_id, next_rs_id, - control_points=fillet_cps, degree=fillet_deg, - knots=fillet_knots, multiplicities=fillet_mults, + sketch, + f"fillet_right_{i}", + be_id, + next_rs_id, + control_points=fillet_cps, + degree=fillet_deg, + knots=fillet_knots, + multiplicities=fillet_mults, ) + sketch = _end_linear_sketch_edits(sketch) face = make_face_from_sketch_rface(sketch, profile=0) if build_face else None if return_sketch: return face, sketch @@ -420,7 +553,9 @@ def _build_gear_profile_face( def _rotate_profile_wire_3d( - wire: Wire, angle_deg: float, z: float, + wire: Wire, + angle_deg: float, + z: float, ) -> Wire: """Return a copy of *wire* rotated about Z and translated to height *z*.""" rotated = rotate_shape(wire, angle_deg, axis=(0, 0, 1), origin=(0, 0, 0)) @@ -438,6 +573,8 @@ def _profile_face_to_wire(face: Face) -> Wire: # Public API: External gears # --------------------------------------------------------------------------- + +@graph_tracking_scope def make_spur_gear_rsolid( n_teeth: int, module: float, @@ -487,6 +624,108 @@ def make_spur_gear_rsolid( return extrude_rsolid(face, direction=(0.0, 0.0, 1.0), distance=gear_height) +@graph_tracking_scope +def make_straight_bevel_gear_rsolid( + n_teeth: int, + module: float, + pitch_angle: float = 45.0, + pressure_angle: float = 20.0, + face_width: float = 8.0, + *, + addendum_factor: float = 1.0, + clearance_factor: float = 0.25, + backlash: float = 0.0, +) -> Solid: + """Create a straight bevel gear with standard metric tooth proportions. + + The large-end transverse section uses the same analytic involute profile as + :func:`make_spur_gear_rsolid`. A similar small-end section is located on the + requested pitch cone and joined with ruled straight tooth surfaces. + + ``face_width`` is measured along the pitch-cone generator. This factory + supplies nominal tooth geometry; a released pair still requires mating-gear, + mounting-distance, contact-pattern, backlash, material, heat-treatment, and + strength checks. + """ + if n_teeth < 3: + raise ValueError("n_teeth must be at least 3") + resolved_module = _validate_positive_finite("module", module) + resolved_face_width = _validate_positive_finite("face_width", face_width) + resolved_backlash = _validate_non_negative_finite("backlash", backlash) + resolved_addendum = _validate_positive_finite("addendum_factor", addendum_factor) + resolved_clearance = _validate_non_negative_finite( + "clearance_factor", clearance_factor + ) + pressure_angle_radians = _pressure_angle_to_radians(pressure_angle) + resolved_pitch_angle = float(pitch_angle) + if not math.isfinite(resolved_pitch_angle): + raise ValueError("pitch_angle must be finite") + if resolved_pitch_angle <= 0.0 or resolved_pitch_angle >= 90.0: + raise ValueError("pitch_angle must be greater than 0 and less than 90 degrees") + + pitch_angle_radians = math.radians(resolved_pitch_angle) + outer_pitch_radius = resolved_module * n_teeth / 2.0 + outer_cone_distance = outer_pitch_radius / math.sin(pitch_angle_radians) + if resolved_face_width >= outer_cone_distance: + raise ValueError("face_width must be smaller than the pitch-cone distance") + + inner_scale = (outer_cone_distance - resolved_face_width) / outer_cone_distance + inner_module = resolved_module * inner_scale + axial_width = resolved_face_width * math.cos(pitch_angle_radians) + inner_backlash = resolved_backlash * inner_scale + profile_kwargs = { + "addendum_factor": resolved_addendum, + "clearance_factor": resolved_clearance, + } + _, outer_sketch = _build_gear_profile_face( + n_teeth, + resolved_module, + pressure_angle_radians, + return_sketch=True, + build_face=False, + backlash=resolved_backlash, + **profile_kwargs, + ) + _, inner_sketch = _build_gear_profile_face( + n_teeth, + inner_module, + pressure_angle_radians, + return_sketch=True, + build_face=False, + backlash=inner_backlash, + **profile_kwargs, + ) + outer_wire = make_wire_from_sketch_rwire(outer_sketch) + inner_wire = make_wire_from_sketch_rwire(inner_sketch) + inner_wire = translate_shape( + inner_wire, + (0.0, 0.0, axial_width), + ) + gear = loft_rsolid( + [outer_wire, inner_wire], + ruled=True, + ) + gear.set_metadata( + "std.gear.straight_bevel", + { + "n_teeth": int(n_teeth), + "module": resolved_module, + "pitch_angle": resolved_pitch_angle, + "pressure_angle": float(pressure_angle), + "face_width": resolved_face_width, + "axial_width": axial_width, + "outer_pitch_radius": outer_pitch_radius, + "outer_cone_distance": outer_cone_distance, + "inner_scale": inner_scale, + "addendum_factor": resolved_addendum, + "clearance_factor": resolved_clearance, + "backlash": resolved_backlash, + }, + ) + return gear + + +@graph_tracking_scope def make_helical_gear_rsolid( n_teeth: int, module: float, @@ -500,10 +739,9 @@ def make_helical_gear_rsolid( ) -> Solid: """Create an involute helical gear. - Non-zero helix angles are modeled as small-step ruled lofts through rotated - copies of one profile. The small angular step keeps closed-wire section - correspondence stable while ruled faces avoid smooth loft bulging in STEP - exports. + Non-zero helix angles use a continuous twisted sweep along the gear axis. + An auxiliary-spine rotation law preserves the profile while generating one + continuous side face per profile edge instead of one face per loft interval. Parameters ---------- @@ -544,33 +782,29 @@ def make_helical_gear_rsolid( pa = _pressure_angle_to_radians(pressure_angle) pitch_diameter = module * n_teeth twist_total = math.degrees( - 2.0 * math.pi * gear_height * math.tan(math.radians(helix_angle)) + 2.0 + * math.pi + * gear_height + * math.tan(math.radians(helix_angle)) / (math.pi * pitch_diameter) ) - n_sections = max(6, int(abs(twist_total) / 5.0) + 2) - _, sketch = _build_gear_profile_face( + face = _build_gear_profile_face( n_teeth, module, pa, - return_sketch=True, addendum_factor=addendum_factor, clearance_factor=clearance_factor, backlash=backlash, - build_face=False, ) - base_wire = make_wire_from_sketch_rwire(sketch) - - sections = [] - for i in range(n_sections + 1): - frac = i / n_sections - z = gear_height * frac - twist = twist_total * frac - sections.append(_rotate_profile_wire_3d(base_wire, twist, z)) - - return loft_rsolid(sections, ruled=True) + return twisted_sweep_rsolid( + profile=face, + distance=gear_height, + twist_angle=twist_total, + ) +@graph_tracking_scope def make_herringbone_gear_rsolid( n_teeth: int, module: float, @@ -584,10 +818,8 @@ def make_herringbone_gear_rsolid( ) -> Solid: """Create an involute herringbone (double-helical) gear. - Each half is modeled as a small-step ruled loft through rotated copies of - one profile, with a shared center section forming the herringbone ridge. - This keeps closed-wire section correspondence stable while avoiding smooth - loft bulging in STEP exports. + Each half uses a continuous twisted sweep with opposite handedness. The two + halves share the rotated center profile and are fused into one solid. Parameters ---------- @@ -629,44 +861,51 @@ def make_herringbone_gear_rsolid( pitch_diameter = module * n_teeth half_height = gear_height / 2.0 half_twist = math.degrees( - 2.0 * math.pi * half_height * math.tan(math.radians(helix_angle)) + 2.0 + * math.pi + * half_height + * math.tan(math.radians(helix_angle)) / (math.pi * pitch_diameter) ) - n_sections_per_half = max(4, int(abs(half_twist) / 5.0) + 2) - _, sketch = _build_gear_profile_face( + face = _build_gear_profile_face( n_teeth, module, pa, - return_sketch=True, addendum_factor=addendum_factor, clearance_factor=clearance_factor, backlash=backlash, - build_face=False, ) - base_wire = make_wire_from_sketch_rwire(sketch) - - sections: List[Wire] = [] - - for i in range(n_sections_per_half + 1): - frac = i / n_sections_per_half - z = half_height * frac - twist = half_twist * frac - sections.append(_rotate_profile_wire_3d(base_wire, twist, z)) - - for i in range(1, n_sections_per_half + 1): - frac = i / n_sections_per_half - z = half_height + half_height * frac - twist = half_twist * (1.0 - frac) - sections.append(_rotate_profile_wire_3d(base_wire, twist, z)) - - return loft_rsolid(sections, ruled=True) + lower = twisted_sweep_rsolid( + profile=face, + distance=half_height, + twist_angle=half_twist, + ) + upper_profile = rotate_shape( + face, + half_twist, + axis=(0.0, 0.0, 1.0), + origin=(0.0, 0.0, 0.0), + ) + upper_profile = translate_shape(upper_profile, (0.0, 0.0, half_height)) + upper = twisted_sweep_rsolid( + profile=upper_profile, + distance=half_height, + twist_angle=-half_twist, + origin=(0.0, 0.0, half_height), + ) + return union_rsolid( + lower, + upper, + tracking_policy=TrackingPolicy.GRAPH, + ) # --------------------------------------------------------------------------- # Internal ring gears (multi-loop profile faces) # --------------------------------------------------------------------------- + def _internal_ring_radii( n_teeth: int, module: float, @@ -676,7 +915,9 @@ def _internal_ring_radii( ) -> Tuple[float, float, float, float]: """Return (pitch, internal tooth tip, internal root, outer rim) radii.""" addendum = _validate_positive_finite("addendum_factor", addendum_factor) * module - clearance = _validate_non_negative_finite("clearance_factor", clearance_factor) * module + clearance = ( + _validate_non_negative_finite("clearance_factor", clearance_factor) * module + ) pitch_radius = module * n_teeth / 2.0 internal_tip_radius = pitch_radius - addendum internal_root_radius = pitch_radius + addendum + clearance @@ -791,9 +1032,15 @@ def _build_internal_gear_profile_wire( sketch = add_point_rsketch(sketch, "center", 0.0, 0.0) sketch = constrain_fix_rsketch(sketch, "center") - sketch = add_circle_rsketch(sketch, "tip_circle", "center", tip_radius, construction=True) - sketch = add_circle_rsketch(sketch, "pitch_circle", "center", geo["pitch_radius"], construction=True) - sketch = add_circle_rsketch(sketch, "root_circle", "center", root_radius, construction=True) + sketch = add_circle_rsketch( + sketch, "tip_circle", "center", tip_radius, construction=True + ) + sketch = add_circle_rsketch( + sketch, "pitch_circle", "center", geo["pitch_radius"], construction=True + ) + sketch = add_circle_rsketch( + sketch, "root_circle", "center", root_radius, construction=True + ) sketch = constrain_radius_rsketch(sketch, "tip_circle", tip_radius) sketch = constrain_radius_rsketch(sketch, "pitch_circle", geo["pitch_radius"]) sketch = constrain_radius_rsketch(sketch, "root_circle", root_radius) @@ -810,19 +1057,42 @@ def _build_internal_gear_profile_wire( left_tip_angle = geo["left_tip_angle"] + offset right_tip_angle = geo["right_tip_angle"] + offset - tooth_data.append({ - "rs": (root_radius * math.cos(root_arc_start_angle), root_radius * math.sin(root_arc_start_angle)), - "re": (root_radius * math.cos(root_arc_end_angle), root_radius * math.sin(root_arc_end_angle)), - "lb": (flank_tip_radius * math.cos(left_tip_angle), flank_tip_radius * math.sin(left_tip_angle)), - "lt": (tip_radius * math.cos(left_tip_angle), tip_radius * math.sin(left_tip_angle)), - "rt": (tip_radius * math.cos(right_tip_angle), tip_radius * math.sin(right_tip_angle)), - "rb": (flank_tip_radius * math.cos(right_tip_angle), flank_tip_radius * math.sin(right_tip_angle)), - "rr": (root_radius * math.cos(right_root_angle), root_radius * math.sin(right_root_angle)), - "left_root_angle": left_root_angle, - "right_root_angle": right_root_angle, - "left_tip_angle": left_tip_angle, - "right_tip_angle": right_tip_angle, - }) + tooth_data.append( + { + "rs": ( + root_radius * math.cos(root_arc_start_angle), + root_radius * math.sin(root_arc_start_angle), + ), + "re": ( + root_radius * math.cos(root_arc_end_angle), + root_radius * math.sin(root_arc_end_angle), + ), + "lb": ( + flank_tip_radius * math.cos(left_tip_angle), + flank_tip_radius * math.sin(left_tip_angle), + ), + "lt": ( + tip_radius * math.cos(left_tip_angle), + tip_radius * math.sin(left_tip_angle), + ), + "rt": ( + tip_radius * math.cos(right_tip_angle), + tip_radius * math.sin(right_tip_angle), + ), + "rb": ( + flank_tip_radius * math.cos(right_tip_angle), + flank_tip_radius * math.sin(right_tip_angle), + ), + "rr": ( + root_radius * math.cos(right_root_angle), + root_radius * math.sin(right_root_angle), + ), + "left_root_angle": left_root_angle, + "right_root_angle": right_root_angle, + "left_tip_angle": left_tip_angle, + "right_tip_angle": right_tip_angle, + } + ) point_keys = ["rs", "re", "lb", "lt", "rt", "rb", "rr"] for i, td in enumerate(tooth_data): @@ -840,31 +1110,50 @@ def _build_internal_gear_profile_wire( rb_id = f"t{i}_rb" next_rs_id = f"t{(i + 1) % n_teeth}_rs" - sketch = add_arc_rsketch(sketch, f"arc_internal_root_{i}", rs_id, re_id, "center") + sketch = add_arc_rsketch( + sketch, f"arc_internal_root_{i}", rs_id, re_id, "center" + ) - left_cps, left_deg, left_knots, left_mults = _internal_involute_bspline_control_points( - base_radius, - start_radius=flank_tip_radius, - end_radius=root_radius, - start_angle=geo["left_base_angle"] + tooth_angle * i, - mirror=True, - reverse=True, + left_cps, left_deg, left_knots, left_mults = ( + _internal_involute_bspline_control_points( + base_radius, + start_radius=flank_tip_radius, + end_radius=root_radius, + start_angle=geo["left_base_angle"] + tooth_angle * i, + mirror=True, + reverse=True, + ) ) sketch = add_bspline_rsketch( - sketch, f"bspline_internal_left_{i}", re_id, lb_id, - control_points=left_cps, degree=left_deg, - knots=left_knots, multiplicities=left_mults, + sketch, + f"bspline_internal_left_{i}", + re_id, + lb_id, + control_points=left_cps, + degree=left_deg, + knots=left_knots, + multiplicities=left_mults, ) if needs_tip_connectors: - transition_angle = math.atan2(td["lt"][1] - td["lb"][1], td["lt"][0] - td["lb"][0]) + transition_angle = math.atan2( + td["lt"][1] - td["lb"][1], td["lt"][0] - td["lb"][0] + ) cps, deg, knots, mults = _root_fillet_control_points( - td["lb"], td["lt"], transition_angle, transition_angle, + td["lb"], + td["lt"], + transition_angle, + transition_angle, ) sketch = add_bspline_rsketch( - sketch, f"transition_internal_left_{i}", lb_id, lt_id, - control_points=cps, degree=deg, - knots=knots, multiplicities=mults, + sketch, + f"transition_internal_left_{i}", + lb_id, + lt_id, + control_points=cps, + degree=deg, + knots=knots, + multiplicities=mults, ) tip_left_id = lt_id tip_right_id = rt_id @@ -872,30 +1161,49 @@ def _build_internal_gear_profile_wire( tip_left_id = lb_id tip_right_id = rb_id - sketch = add_arc_rsketch(sketch, f"arc_internal_tip_{i}", tip_left_id, tip_right_id, "center") + sketch = add_arc_rsketch( + sketch, f"arc_internal_tip_{i}", tip_left_id, tip_right_id, "center" + ) if needs_tip_connectors: - transition_angle = math.atan2(td["rb"][1] - td["rt"][1], td["rb"][0] - td["rt"][0]) + transition_angle = math.atan2( + td["rb"][1] - td["rt"][1], td["rb"][0] - td["rt"][0] + ) cps, deg, knots, mults = _root_fillet_control_points( - td["rt"], td["rb"], transition_angle, transition_angle, + td["rt"], + td["rb"], + transition_angle, + transition_angle, ) sketch = add_bspline_rsketch( - sketch, f"transition_internal_right_{i}", rt_id, rb_id, - control_points=cps, degree=deg, - knots=knots, multiplicities=mults, + sketch, + f"transition_internal_right_{i}", + rt_id, + rb_id, + control_points=cps, + degree=deg, + knots=knots, + multiplicities=mults, ) - right_cps, right_deg, right_knots, right_mults = _internal_involute_bspline_control_points( - base_radius, - start_radius=flank_tip_radius, - end_radius=root_radius, - start_angle=geo["right_base_angle"] + tooth_angle * i, - mirror=False, + right_cps, right_deg, right_knots, right_mults = ( + _internal_involute_bspline_control_points( + base_radius, + start_radius=flank_tip_radius, + end_radius=root_radius, + start_angle=geo["right_base_angle"] + tooth_angle * i, + mirror=False, + ) ) sketch = add_bspline_rsketch( - sketch, f"bspline_internal_right_{i}", rb_id, next_rs_id, - control_points=right_cps, degree=right_deg, - knots=right_knots, multiplicities=right_mults, + sketch, + f"bspline_internal_right_{i}", + rb_id, + next_rs_id, + control_points=right_cps, + degree=right_deg, + knots=right_knots, + multiplicities=right_mults, ) wire = make_wire_from_sketch_rwire(sketch, profile=0) @@ -914,12 +1222,14 @@ def _build_ring_gear_face( clearance_factor: float = 0.25, ) -> Face: """Build the 2-D ring-gear face directly from outer and inner loops.""" - _pitch_radius, _internal_tip_radius, _internal_root_radius, outer_radius = _internal_ring_radii( - n_teeth, - module, - rim_thickness, - addendum_factor=addendum_factor, - clearance_factor=clearance_factor, + _pitch_radius, _internal_tip_radius, _internal_root_radius, outer_radius = ( + _internal_ring_radii( + n_teeth, + module, + rim_thickness, + addendum_factor=addendum_factor, + clearance_factor=clearance_factor, + ) ) outer_wire = make_circle_rwire(center=(0.0, 0.0, 0.0), radius=outer_radius) @@ -934,6 +1244,7 @@ def _build_ring_gear_face( return make_face_from_wires_rface(outer_wire, [inner_wire]) +@graph_tracking_scope def make_spur_ring_gear_rsolid( n_teeth: int, module: float, @@ -988,6 +1299,7 @@ def make_spur_ring_gear_rsolid( return extrude_rsolid(face, direction=(0.0, 0.0, 1.0), distance=gear_height) +@graph_tracking_scope def make_helical_ring_gear_rsolid( n_teeth: int, module: float, @@ -1043,17 +1355,22 @@ def make_helical_ring_gear_rsolid( pa = _pressure_angle_to_radians(pressure_angle) pitch_diameter = module * n_teeth twist_total = math.degrees( - 2.0 * math.pi * gear_height * math.tan(math.radians(helix_angle)) + 2.0 + * math.pi + * gear_height + * math.tan(math.radians(helix_angle)) / (math.pi * pitch_diameter) ) n_sections = max(6, int(abs(twist_total) / 5.0) + 2) - _pitch_radius, _internal_tip_radius, _internal_root_radius, outer_radius = _internal_ring_radii( - n_teeth, - module, - rim_thickness, - addendum_factor=addendum_factor, - clearance_factor=clearance_factor, + _pitch_radius, _internal_tip_radius, _internal_root_radius, outer_radius = ( + _internal_ring_radii( + n_teeth, + module, + rim_thickness, + addendum_factor=addendum_factor, + clearance_factor=clearance_factor, + ) ) outer_wire = make_circle_rwire(center=(0.0, 0.0, 0.0), radius=outer_radius) @@ -1082,6 +1399,7 @@ def make_helical_ring_gear_rsolid( return cut_rsolid(outer_solid, inner_loft) +@graph_tracking_scope def make_herringbone_ring_gear_rsolid( n_teeth: int, module: float, @@ -1138,17 +1456,22 @@ def make_herringbone_ring_gear_rsolid( pitch_diameter = module * n_teeth half_height = gear_height / 2.0 half_twist = math.degrees( - 2.0 * math.pi * half_height * math.tan(math.radians(helix_angle)) + 2.0 + * math.pi + * half_height + * math.tan(math.radians(helix_angle)) / (math.pi * pitch_diameter) ) n_sections_per_half = max(4, int(abs(half_twist) / 5.0) + 2) - _pitch_radius, _internal_tip_radius, _internal_root_radius, outer_radius = _internal_ring_radii( - n_teeth, - module, - rim_thickness, - addendum_factor=addendum_factor, - clearance_factor=clearance_factor, + _pitch_radius, _internal_tip_radius, _internal_root_radius, outer_radius = ( + _internal_ring_radii( + n_teeth, + module, + rim_thickness, + addendum_factor=addendum_factor, + clearance_factor=clearance_factor, + ) ) outer_wire = make_circle_rwire(center=(0.0, 0.0, 0.0), radius=outer_radius) @@ -1180,14 +1503,23 @@ def make_herringbone_ring_gear_rsolid( twist = half_twist * (1.0 - frac) inner_sections.append(_rotate_profile_wire_3d(inner_wire, twist, z)) - inner_loft = loft_rsolid(inner_sections, ruled=True) - return cut_rsolid(outer_solid, inner_loft) + inner_loft = loft_rsolid( + inner_sections, + ruled=True, + tracking_policy=TrackingPolicy.GRAPH, + ) + return cut_rsolid( + outer_solid, + inner_loft, + tracking_policy=TrackingPolicy.GRAPH, + ) # --------------------------------------------------------------------------- # Cycloidal reducer discs # --------------------------------------------------------------------------- + def _cycloidal_disc_point( theta: float, *, @@ -1280,6 +1612,7 @@ def _build_cycloidal_disc_profile_wire( return wire +@graph_tracking_scope def make_cycloidal_disc_rsolid( n_lobes: int, ring_pin_pitch_radius: float, @@ -1448,6 +1781,7 @@ def make_cycloidal_disc_rsolid( # Racks (straight / helical / herringbone) # --------------------------------------------------------------------------- + def _build_rack_profile_points( module: float, n_teeth: int, @@ -1504,6 +1838,7 @@ def _build_rack_profile_face( return make_face_from_sketch_rface(sketch, profile=0) +@graph_tracking_scope def make_spur_rack_rsolid( module: float, n_teeth: int = 10, @@ -1535,6 +1870,7 @@ def make_spur_rack_rsolid( return extrude_rsolid(face, direction=(0.0, 0.0, 1.0), distance=rack_height) +@graph_tracking_scope def make_helical_rack_rsolid( module: float, n_teeth: int = 10, @@ -1563,7 +1899,10 @@ def make_helical_rack_rsolid( pa = math.radians(pressure_angle) pitch = math.pi * module twist_total = math.degrees( - 2.0 * math.pi * rack_height * math.tan(math.radians(helix_angle)) + 2.0 + * math.pi + * rack_height + * math.tan(math.radians(helix_angle)) / (math.pi * pitch) ) @@ -1581,6 +1920,7 @@ def make_helical_rack_rsolid( return loft_rsolid(sections, ruled=False) +@graph_tracking_scope def make_herringbone_rack_rsolid( module: float, n_teeth: int = 10, @@ -1610,7 +1950,10 @@ def make_herringbone_rack_rsolid( pitch = math.pi * module half_height = rack_height / 2.0 half_twist = math.degrees( - 2.0 * math.pi * half_height * math.tan(math.radians(helix_angle)) + 2.0 + * math.pi + * half_height + * math.tan(math.radians(helix_angle)) / (math.pi * pitch) ) diff --git a/SimpleCADAPI/src/simplecadapi/tagging.py b/SimpleCADAPI/src/simplecadapi/tagging.py index c2a1659..b35799a 100644 --- a/SimpleCADAPI/src/simplecadapi/tagging.py +++ b/SimpleCADAPI/src/simplecadapi/tagging.py @@ -1,43 +1,880 @@ from __future__ import annotations -from dataclasses import dataclass +from copy import deepcopy +from dataclasses import dataclass, field +from enum import Enum import re -from typing import List +from typing import Any, Dict, List, Mapping, Optional, Tuple +import uuid +TAG_BINDING_SCHEMA_VERSION = "1.0" + _TAG_SEGMENT = r"[a-z][a-z0-9_-]*" _TAG_RE = re.compile(rf"^{_TAG_SEGMENT}(?:\.{_TAG_SEGMENT})*$") +class TagScope(str, Enum): + """Supported projections of the semantic binding store.""" + + LOCAL = "local" + INHERITED = "inherited" + EFFECTIVE = "effective" + LINEAGE = "lineage" + + +class TopologyPropagation(str, Enum): + LOCAL = "local" + DOWNWARD = "downward" + + +class LineagePolicy(str, Enum): + NONE = "none" + CONTINUATION = "continuation" + CONTINUATION_FRAGMENT = "continuation_fragment" + EXPLICIT = "explicit" + + +class LineageDerivation(str, Enum): + CONTINUATION = "continuation" + FRAGMENT = "fragment" + MERGE = "merge" + INTERSECTION = "intersection" + BOUNDARY = "boundary" + REPLACEMENT = "replacement" + + +class TagAttachment(str, Enum): + LOCAL = "local" + INHERITED = "inherited" + EFFECTIVE_LEGACY = "effective_legacy" + + +class TagProducerKind(str, Enum): + USER_OPERATION = "user_operation" + AUTO_RULE = "auto_rule" + IMPORTED_SIDECAR = "imported_sidecar" + LEGACY_IMPORT = "legacy_import" + + +class TagTargetKind(str, Enum): + SELECTION_QUERY = "selection_query" + SCOPE_ROOT = "scope_root" + EXPLICIT_REFS = "explicit_refs" + LEGACY_EFFECTIVE = "legacy_effective" + + +class TagEvidenceKind(str, Enum): + USER_ASSERTION = "user_assertion" + QUERY_EXECUTION = "query_execution" + TOPOLOGY_CHANGE = "topology_change" + GEOMETRY_CLASSIFICATION = "geometry_classification" + IMPORTED_SIDECAR = "imported_sidecar" + LEGACY_SNAPSHOT = "legacy_snapshot" + + +class TagCertainty(str, Enum): + ASSERTED = "asserted" + PROVEN = "proven" + + +class TagLifecycle(str, Enum): + ASSERTION = "assertion" + RECOMPUTE = "recompute" + SNAPSHOT = "snapshot" + + +VALID_TAG_SCOPES = frozenset(scope.value for scope in TagScope) + + +class TagValidationError(ValueError): + """Raised when a tag binding or policy is malformed.""" + + +class SemanticCapabilityError(ValueError): + """Raised when an object cannot evaluate a requested semantic scope.""" + + +class UnsupportedQueryCapabilityError(SemanticCapabilityError): + """Raised when required query evidence is unavailable or incomplete.""" + + +def _as_enum(value: Any, enum_type: type[Enum], field_name: str) -> Any: + if isinstance(value, enum_type): + return value + if not isinstance(value, str): + raise TagValidationError(f"{field_name} must be a string or {enum_type.__name__}") + try: + return enum_type(value) + except ValueError as exc: + allowed = ", ".join(sorted(item.value for item in enum_type)) + raise TagValidationError( + f"unsupported {field_name} '{value}'; expected one of: {allowed}" + ) from exc + + +def _strict_mapping( + name: str, + data: Mapping[str, Any], + *, + required: Tuple[str, ...], + optional: Tuple[str, ...] = (), +) -> None: + if not isinstance(data, Mapping): + raise TagValidationError(f"{name} must be an object") + keys = set(data) + missing = set(required) - keys + unknown = keys - set(required) - set(optional) + if missing: + raise TagValidationError( + f"{name} is missing required field(s): {', '.join(sorted(missing))}" + ) + if unknown: + raise TagValidationError( + f"{name} contains unknown field(s): {', '.join(sorted(unknown))}" + ) + + +def _optional_string(value: Any, field_name: str) -> Optional[str]: + if value is None: + return None + if not isinstance(value, str) or not value: + raise TagValidationError(f"{field_name} must be null or a non-empty string") + return value + + +def normalize_tag_scope(scope: str | TagScope) -> TagScope: + if isinstance(scope, TagScope): + return scope + if not isinstance(scope, str): + raise TypeError("tag scope must be a string or TagScope") + try: + return TagScope(scope.strip().lower()) + except ValueError as exc: + allowed = ", ".join(sorted(VALID_TAG_SCOPES)) + raise ValueError( + f"unsupported tag scope '{scope}'; expected one of: {allowed}" + ) from exc + + +@dataclass(frozen=True) +class TagBindingScope: + """Snapshot boundary in which an assignment target is evaluated.""" + + node_id: Optional[str] = None + output_slot: int = 0 + + def __post_init__(self) -> None: + object.__setattr__(self, "node_id", _optional_string(self.node_id, "scope.node_id")) + if isinstance(self.output_slot, bool) or not isinstance(self.output_slot, int): + raise TagValidationError("scope.output_slot must be an integer") + if self.output_slot < 0: + raise TagValidationError("scope.output_slot must be >= 0") + + def to_dict(self) -> Dict[str, Any]: + return {"node_id": self.node_id, "output_slot": self.output_slot} + + @classmethod + def from_dict(cls, data: Mapping[str, Any]) -> "TagBindingScope": + _strict_mapping( + "TagBinding.scope", data, required=("node_id", "output_slot") + ) + return cls(node_id=data["node_id"], output_slot=data["output_slot"]) + + +@dataclass(frozen=True) +class TagProducer: + """Identity of the operation, rule, or import that made an assertion.""" + + kind: TagProducerKind | str + node_id: Optional[str] = None + rule_id: Optional[str] = None + rule_version: Optional[str] = None + + def __post_init__(self) -> None: + kind = _as_enum(self.kind, TagProducerKind, "producer kind") + object.__setattr__(self, "kind", kind) + object.__setattr__( + self, "node_id", _optional_string(self.node_id, "producer.node_id") + ) + object.__setattr__( + self, "rule_id", _optional_string(self.rule_id, "producer.rule_id") + ) + object.__setattr__( + self, + "rule_version", + _optional_string(self.rule_version, "producer.rule_version"), + ) + if kind == TagProducerKind.AUTO_RULE and ( + self.rule_id is None or self.rule_version is None + ): + raise TagValidationError( + "auto_rule producers require rule_id and rule_version" + ) + if kind != TagProducerKind.AUTO_RULE and ( + self.rule_id is not None or self.rule_version is not None + ): + raise TagValidationError( + "rule_id and rule_version are only valid for auto_rule producers" + ) + + def to_dict(self) -> Dict[str, Any]: + return { + "kind": self.kind.value, + "node_id": self.node_id, + "rule_id": self.rule_id, + "rule_version": self.rule_version, + } + + @classmethod + def from_dict(cls, data: Mapping[str, Any]) -> "TagProducer": + _strict_mapping( + "TagBinding.producer", + data, + required=("kind", "node_id", "rule_id", "rule_version"), + ) + return cls( + kind=data["kind"], + node_id=data["node_id"], + rule_id=data["rule_id"], + rule_version=data["rule_version"], + ) + + +@dataclass(frozen=True) +class TagTarget: + """Serializable assignment intent, separate from execution evidence.""" + + kind: TagTargetKind | str = TagTargetKind.SCOPE_ROOT + query_hash: Optional[str] = None + binding_hash: Optional[str] = None + selector: Optional[Dict[str, Any]] = None + refs: Tuple[Dict[str, Any], ...] = () + + def __post_init__(self) -> None: + kind = _as_enum(self.kind, TagTargetKind, "target kind") + object.__setattr__(self, "kind", kind) + object.__setattr__( + self, + "query_hash", + _optional_string(self.query_hash, "target.query_hash"), + ) + object.__setattr__( + self, + "binding_hash", + _optional_string(self.binding_hash, "target.binding_hash"), + ) + if self.selector is not None and not isinstance(self.selector, dict): + raise TagValidationError("target.selector must be an object") + if not isinstance(self.refs, tuple) or not all( + isinstance(ref, dict) for ref in self.refs + ): + raise TagValidationError("target.refs must be a tuple of objects") + + if kind == TagTargetKind.SELECTION_QUERY: + if self.selector is None and self.query_hash is None: + raise TagValidationError( + "selection_query targets require selector or query_hash" + ) + if (self.query_hash is None) != (self.binding_hash is None): + raise TagValidationError( + "selection_query query_hash and binding_hash must be provided together" + ) + if self.refs: + raise TagValidationError( + "selection_query target refs belong in execution evidence" + ) + elif kind == TagTargetKind.EXPLICIT_REFS: + if not self.refs: + raise TagValidationError("explicit_refs targets require refs") + if any( + value is not None + for value in (self.query_hash, self.binding_hash, self.selector) + ): + raise TagValidationError( + "explicit_refs targets cannot contain query fields" + ) + elif kind == TagTargetKind.LEGACY_EFFECTIVE: + if any( + value is not None + for value in (self.query_hash, self.binding_hash, self.selector) + ): + raise TagValidationError( + "legacy_effective targets cannot contain query fields" + ) + elif any( + value is not None + for value in (self.query_hash, self.binding_hash, self.selector) + ) or self.refs: + raise TagValidationError("scope_root targets cannot contain query fields or refs") + + def to_dict(self) -> Dict[str, Any]: + payload: Dict[str, Any] = {"kind": self.kind.value} + if self.query_hash is not None: + payload["query_hash"] = self.query_hash + payload["binding_hash"] = self.binding_hash + if self.selector is not None: + payload["selector"] = deepcopy(self.selector) + if self.refs: + payload["refs"] = deepcopy(list(self.refs)) + return payload + + @classmethod + def from_dict(cls, data: Mapping[str, Any]) -> "TagTarget": + if not isinstance(data, Mapping): + raise TagValidationError("TagBinding.target must be an object") + _strict_mapping( + "TagBinding.target", + data, + required=("kind",), + optional=("query_hash", "binding_hash", "selector", "refs"), + ) + refs = data.get("refs", ()) + if not isinstance(refs, (list, tuple)) or not all( + isinstance(ref, Mapping) for ref in refs + ): + raise TagValidationError("target.refs must be an array of objects") + selector = data.get("selector") + return cls( + kind=data["kind"], + query_hash=data.get("query_hash"), + binding_hash=data.get("binding_hash"), + selector=deepcopy(dict(selector)) if isinstance(selector, Mapping) else selector, + refs=tuple(deepcopy(dict(ref)) for ref in refs), + ) + + +@dataclass(frozen=True) +class TagPropagation: + topology: TopologyPropagation | str = TopologyPropagation.LOCAL + lineage: LineagePolicy | str = LineagePolicy.CONTINUATION_FRAGMENT + explicit_derivations: Tuple[LineageDerivation | str, ...] = () + + def __post_init__(self) -> None: + topology = _as_enum( + self.topology, TopologyPropagation, "topology propagation" + ) + lineage = _as_enum(self.lineage, LineagePolicy, "lineage policy") + derivations = tuple( + _as_enum(item, LineageDerivation, "lineage derivation") + for item in self.explicit_derivations + ) + object.__setattr__(self, "topology", topology) + object.__setattr__(self, "lineage", lineage) + object.__setattr__(self, "explicit_derivations", derivations) + if lineage == LineagePolicy.EXPLICIT and not derivations: + raise TagValidationError( + "explicit lineage policy requires explicit_derivations" + ) + if lineage != LineagePolicy.EXPLICIT and derivations: + raise TagValidationError( + "explicit_derivations are only valid with explicit lineage policy" + ) + + def to_dict(self) -> Dict[str, Any]: + payload: Dict[str, Any] = { + "topology": self.topology.value, + "lineage": self.lineage.value, + } + if self.explicit_derivations: + payload["derivations"] = [ + derivation.value for derivation in self.explicit_derivations + ] + return payload + + @classmethod + def from_dict(cls, data: Mapping[str, Any]) -> "TagPropagation": + _strict_mapping( + "TagBinding.propagation", + data, + required=("topology", "lineage"), + optional=("derivations",), + ) + derivations = data.get("derivations", ()) + if not isinstance(derivations, (list, tuple)): + raise TagValidationError("propagation.derivations must be an array") + return cls( + topology=data["topology"], + lineage=data["lineage"], + explicit_derivations=tuple(derivations), + ) + + +@dataclass(frozen=True) +class TagEvidence: + """Typed evidence payload for a binding or a lineage witness.""" + + kind: TagEvidenceKind | str + data: Dict[str, Any] = field(default_factory=dict) + + def __post_init__(self) -> None: + object.__setattr__( + self, "kind", _as_enum(self.kind, TagEvidenceKind, "evidence kind") + ) + if not isinstance(self.data, dict): + raise TagValidationError("evidence data must be an object") + if "kind" in self.data: + raise TagValidationError("evidence data cannot redefine kind") + + def to_dict(self) -> Dict[str, Any]: + return {"kind": self.kind.value, **deepcopy(self.data)} + + @classmethod + def from_dict(cls, data: Mapping[str, Any]) -> "TagEvidence": + if not isinstance(data, Mapping): + raise TagValidationError("TagBinding.evidence must be an object") + if "kind" not in data: + raise TagValidationError("TagBinding.evidence is missing required field: kind") + return cls( + kind=data["kind"], + data=deepcopy({key: value for key, value in data.items() if key != "kind"}), + ) + + +@dataclass(frozen=True) +class TagBinding: + """Canonical, source-preserving Semantic Binding Schema 1.0 assignment.""" + + tag: str + producer: TagProducer + scope: TagBindingScope = field(default_factory=TagBindingScope) + target: TagTarget = field(default_factory=TagTarget) + propagation: TagPropagation = field(default_factory=TagPropagation) + evidence: TagEvidence = field( + default_factory=lambda: TagEvidence(TagEvidenceKind.USER_ASSERTION) + ) + attachment: TagAttachment | str = TagAttachment.LOCAL + certainty: TagCertainty | str = TagCertainty.ASSERTED + lifecycle: TagLifecycle | str = TagLifecycle.ASSERTION + binding_id: str = field( + default_factory=lambda: f"tag_binding_{uuid.uuid4().hex}" + ) + schema_version: str = TAG_BINDING_SCHEMA_VERSION + + def __post_init__(self) -> None: + try: + normalized = normalize_tag(self.tag, strict=True) + except (TypeError, ValueError) as exc: + raise TagValidationError(str(exc)) from exc + object.__setattr__(self, "tag", normalized) + object.__setattr__( + self, "attachment", _as_enum(self.attachment, TagAttachment, "attachment") + ) + object.__setattr__( + self, "certainty", _as_enum(self.certainty, TagCertainty, "certainty") + ) + object.__setattr__( + self, "lifecycle", _as_enum(self.lifecycle, TagLifecycle, "lifecycle") + ) + if not isinstance(self.producer, TagProducer): + raise TagValidationError("producer must be a TagProducer") + if not isinstance(self.scope, TagBindingScope): + raise TagValidationError("scope must be a TagBindingScope") + if not isinstance(self.target, TagTarget): + raise TagValidationError("target must be a TagTarget") + if not isinstance(self.propagation, TagPropagation): + raise TagValidationError("propagation must be a TagPropagation") + if not isinstance(self.evidence, TagEvidence): + raise TagValidationError("evidence must be TagEvidence") + if self.schema_version != TAG_BINDING_SCHEMA_VERSION: + raise TagValidationError( + f"unsupported TagBinding schema_version '{self.schema_version}'" + ) + if not isinstance(self.binding_id, str) or not self.binding_id: + raise TagValidationError("binding_id must be a non-empty string") + if self.attachment == TagAttachment.INHERITED: + raise TagValidationError( + "inherited is a computed attachment and cannot be stored in TagBinding" + ) + if self.attachment == TagAttachment.EFFECTIVE_LEGACY: + if self.producer.kind != TagProducerKind.LEGACY_IMPORT: + raise TagValidationError( + "effective_legacy bindings require a legacy_import producer" + ) + if self.target.kind != TagTargetKind.LEGACY_EFFECTIVE: + raise TagValidationError( + "effective_legacy bindings require a legacy_effective target" + ) + if self.propagation.lineage != LineagePolicy.NONE: + raise TagValidationError( + "legacy effective bindings cannot claim lineage" + ) + if self.lifecycle != TagLifecycle.SNAPSHOT: + raise TagValidationError( + "legacy effective bindings require snapshot lifecycle" + ) + if self.evidence.kind != TagEvidenceKind.LEGACY_SNAPSHOT: + raise TagValidationError( + "legacy effective bindings require legacy_snapshot evidence" + ) + elif self.target.kind == TagTargetKind.LEGACY_EFFECTIVE: + raise TagValidationError( + "legacy_effective targets require effective_legacy attachment" + ) + + def to_dict(self) -> Dict[str, Any]: + return { + "schema_version": self.schema_version, + "binding_id": self.binding_id, + "tag": self.tag, + "producer": self.producer.to_dict(), + "scope": self.scope.to_dict(), + "target": self.target.to_dict(), + "attachment": self.attachment.value, + "propagation": self.propagation.to_dict(), + "evidence": self.evidence.to_dict(), + "certainty": self.certainty.value, + "lifecycle": self.lifecycle.value, + } + + @classmethod + def from_dict(cls, data: Mapping[str, Any]) -> "TagBinding": + _strict_mapping( + "TagBinding", + data, + required=( + "schema_version", + "binding_id", + "tag", + "producer", + "scope", + "target", + "attachment", + "propagation", + "evidence", + "certainty", + "lifecycle", + ), + ) + try: + return cls( + schema_version=data["schema_version"], + binding_id=data["binding_id"], + tag=data["tag"], + producer=TagProducer.from_dict(data["producer"]), + scope=TagBindingScope.from_dict(data["scope"]), + target=TagTarget.from_dict(data["target"]), + attachment=data["attachment"], + propagation=TagPropagation.from_dict(data["propagation"]), + evidence=TagEvidence.from_dict(data["evidence"]), + certainty=data["certainty"], + lifecycle=data["lifecycle"], + ) + except TagValidationError: + raise + except (KeyError, TypeError, ValueError) as exc: + raise TagValidationError("invalid TagBinding payload") from exc + + +@dataclass(frozen=True) +class TagLineageWitness: + """Evidence that makes a source binding visible in lineage scope.""" + + binding: TagBinding + derivation: LineageDerivation | str + source_topo_id: str + target_topo_id: str + evidence: TagEvidence + coverage: str = "complete" + + def __post_init__(self) -> None: + if not isinstance(self.binding, TagBinding): + raise TagValidationError("lineage witness binding must be a TagBinding") + object.__setattr__( + self, + "derivation", + _as_enum(self.derivation, LineageDerivation, "lineage derivation"), + ) + for field_name in ("source_topo_id", "target_topo_id"): + value = getattr(self, field_name) + if not isinstance(value, str) or not value: + raise TagValidationError(f"{field_name} must be a non-empty string") + if not isinstance(self.evidence, TagEvidence): + raise TagValidationError("lineage witness evidence must be TagEvidence") + if self.coverage not in {"complete", "partial", "none"}: + raise TagValidationError( + "lineage witness coverage must be complete, partial, or none" + ) + + def to_dict(self) -> Dict[str, Any]: + return { + "binding": self.binding.to_dict(), + "derivation": self.derivation.value, + "source_topo_id": self.source_topo_id, + "target_topo_id": self.target_topo_id, + "evidence": self.evidence.to_dict(), + "coverage": self.coverage, + } + + @classmethod + def from_dict(cls, data: Mapping[str, Any]) -> "TagLineageWitness": + _strict_mapping( + "TagLineageWitness", + data, + required=( + "binding", + "derivation", + "source_topo_id", + "target_topo_id", + "evidence", + "coverage", + ), + ) + try: + return cls( + binding=TagBinding.from_dict(data["binding"]), + derivation=data["derivation"], + source_topo_id=data["source_topo_id"], + target_topo_id=data["target_topo_id"], + evidence=TagEvidence.from_dict(data["evidence"]), + coverage=data["coverage"], + ) + except TagValidationError: + raise + except (KeyError, TypeError, ValueError) as exc: + raise TagValidationError("invalid TagLineageWitness payload") from exc + + +def lineage_policy_allows( + policy: LineagePolicy | TagPropagation, + derivation: str | LineageDerivation, +) -> bool: + propagation = policy if isinstance(policy, TagPropagation) else None + lineage = propagation.lineage if propagation is not None else policy + lineage = _as_enum(lineage, LineagePolicy, "lineage policy") + derivation_value = _as_enum( + derivation, LineageDerivation, "lineage derivation" + ) + if lineage == LineagePolicy.NONE: + return False + if lineage == LineagePolicy.CONTINUATION: + return derivation_value == LineageDerivation.CONTINUATION + if lineage == LineagePolicy.CONTINUATION_FRAGMENT: + return derivation_value in { + LineageDerivation.CONTINUATION, + LineageDerivation.FRAGMENT, + } + if propagation is None: + return False + return derivation_value in propagation.explicit_derivations + + +def user_tag_binding( + tag: str, + *, + node_id: Optional[str] = None, + scope_node_id: Optional[str] = None, + output_slot: int = 0, + target: Optional[TagTarget] = None, + topology: TopologyPropagation | str = TopologyPropagation.LOCAL, + lineage: LineagePolicy | str = LineagePolicy.CONTINUATION_FRAGMENT, + explicit_derivations: Tuple[LineageDerivation | str, ...] = (), + evidence: Optional[TagEvidence] = None, +) -> TagBinding: + """Create a canonical user assertion with explicit frozen policies.""" + + return TagBinding( + tag=tag, + producer=TagProducer(TagProducerKind.USER_OPERATION, node_id=node_id), + scope=TagBindingScope( + node_id=node_id if scope_node_id is None else scope_node_id, + output_slot=output_slot, + ), + target=target or TagTarget(TagTargetKind.SCOPE_ROOT), + propagation=TagPropagation( + topology=topology, + lineage=lineage, + explicit_derivations=explicit_derivations, + ), + evidence=evidence + or TagEvidence( + TagEvidenceKind.USER_ASSERTION, + {"authoring_source": "simplecadapi.apply_tag"}, + ), + certainty=TagCertainty.ASSERTED, + lifecycle=TagLifecycle.ASSERTION, + ) + + +def internal_tag_binding( + tag: str, + *, + topology: TopologyPropagation | str = TopologyPropagation.LOCAL, + rule_id: str = "simplecad.compat.internal_annotation", + rule_version: str = "1.0", + evidence: Optional[Dict[str, Any]] = None, +) -> TagBinding: + """Create a source-marked binding for transitional internal annotations.""" + + return TagBinding( + tag=tag, + producer=TagProducer( + TagProducerKind.AUTO_RULE, + rule_id=rule_id, + rule_version=rule_version, + ), + propagation=TagPropagation( + topology=topology, + lineage=LineagePolicy.NONE, + ), + evidence=TagEvidence( + TagEvidenceKind.LEGACY_SNAPSHOT, + {"source": "internal_annotation", **dict(evidence or {})}, + ), + certainty=TagCertainty.PROVEN, + lifecycle=TagLifecycle.SNAPSHOT, + ) + + +def operation_role_tag_binding( + tag: str, + *, + operation: str, + role: str, + target_topo_id: str, + evidence_method: str, +) -> TagBinding: + """Create a deterministic binding for a kernel-proven output role.""" + + rule_id = "simplecad.operation_output_role" + binding_key = "|".join( + (rule_id, str(operation), str(role), str(target_topo_id), str(tag)) + ) + return TagBinding( + tag=tag, + producer=TagProducer( + TagProducerKind.AUTO_RULE, + rule_id=rule_id, + rule_version="1.0", + ), + target=TagTarget(TagTargetKind.SCOPE_ROOT), + propagation=TagPropagation( + topology=TopologyPropagation.LOCAL, + lineage=LineagePolicy.CONTINUATION_FRAGMENT, + ), + evidence=TagEvidence( + TagEvidenceKind.TOPOLOGY_CHANGE, + { + "operation": str(operation), + "result_role": str(role), + "target_topo_id": str(target_topo_id), + "evidence_method": str(evidence_method), + }, + ), + certainty=TagCertainty.PROVEN, + lifecycle=TagLifecycle.RECOMPUTE, + binding_id=f"tag_binding_{uuid.uuid5(uuid.NAMESPACE_URL, binding_key).hex}", + ) + + +def projected_tag_binding( + source: TagBinding, + *, + operation: str, + role: str, + source_topo_id: str, + target_topo_id: str, + evidence_method: str, + topology: TopologyPropagation | str = TopologyPropagation.LOCAL, +) -> TagBinding: + """Project one source binding through a proven feature-generation witness.""" + + if not isinstance(source, TagBinding): + raise TypeError("source must be a TagBinding") + rule_id = "simplecad.feature_source_tag_projection" + root_binding_id = str( + source.evidence.data.get("source_binding_id", source.binding_id) + ) + root_topo_id = str(source.evidence.data.get("source_topo_id", source_topo_id)) + source_operation_output_role = source.evidence.data.get( + "source_operation_output_role", + source.evidence.data.get("operation_output_role"), + ) + source_topology_name = source.evidence.data.get( + "source_topology_name", source.evidence.data.get("topology_name") + ) + binding_key = "|".join( + ( + rule_id, + root_binding_id, + str(operation), + str(role), + root_topo_id, + str(target_topo_id), + ) + ) + return TagBinding( + tag=source.tag, + producer=TagProducer( + TagProducerKind.AUTO_RULE, + rule_id=rule_id, + rule_version="1.0", + ), + target=TagTarget(TagTargetKind.SCOPE_ROOT), + propagation=TagPropagation( + topology=topology, + lineage=source.propagation.lineage, + explicit_derivations=source.propagation.explicit_derivations, + ), + evidence=TagEvidence( + TagEvidenceKind.TOPOLOGY_CHANGE, + { + "operation": str(operation), + "result_role": str(role), + "source_binding_id": root_binding_id, + "source_topo_id": root_topo_id, + "target_topo_id": str(target_topo_id), + "evidence_method": str(evidence_method), + **( + {"source_operation_output_role": deepcopy(source_operation_output_role)} + if isinstance(source_operation_output_role, dict) + else {} + ), + **( + {"source_topology_name": deepcopy(source_topology_name)} + if isinstance(source_topology_name, dict) + else {} + ), + }, + ), + certainty=TagCertainty.PROVEN, + lifecycle=TagLifecycle.RECOMPUTE, + binding_id=f"tag_binding_{uuid.uuid5(uuid.NAMESPACE_URL, binding_key).hex}", + ) + + +def legacy_tag_binding(tag: str, *, diagnostic: Optional[str] = None) -> TagBinding: + """Import one flat compatibility token without inventing assignment intent.""" + + evidence: Dict[str, Any] = {"imported_tags": [tag]} + if diagnostic is not None: + evidence["migration_diagnostic"] = str(diagnostic) + return TagBinding( + tag=tag, + producer=TagProducer(TagProducerKind.LEGACY_IMPORT), + target=TagTarget(TagTargetKind.LEGACY_EFFECTIVE), + attachment=TagAttachment.EFFECTIVE_LEGACY, + propagation=TagPropagation( + topology=TopologyPropagation.LOCAL, + lineage=LineagePolicy.NONE, + ), + evidence=TagEvidence(TagEvidenceKind.LEGACY_SNAPSHOT, evidence), + certainty=TagCertainty.ASSERTED, + lifecycle=TagLifecycle.SNAPSHOT, + ) + + def is_normalized_tag(tag: str) -> bool: - """Check whether a tag matches the normalized format. + """Check whether a tag matches the normalized dot-token format.""" - Args: - tag: Tag string. - - Returns: - bool: Whether the tag is valid. - """ if not isinstance(tag, str): return False return bool(_TAG_RE.fullmatch(tag)) def normalize_tag(tag: str, *, strict: bool = True) -> str: - """Normalize a tag. + """Normalize or strictly validate a tag.""" - Args: - tag: Raw tag. - strict: Whether to validate strictly. When True, only already-normalized - tags are accepted. - - Returns: - str: Normalized tag. - - Raises: - TypeError: If tag is not a string. - ValueError: If normalization fails. - """ if not isinstance(tag, str): raise TypeError("tag must be a string") cleaned = tag.strip() @@ -57,7 +894,7 @@ def normalize_tag(tag: str, *, strict: bool = True) -> str: @dataclass(frozen=True) class TagPolicy: - """Tag propagation policy.""" + """Legacy token registry hints; canonical assignments do not consult this.""" propagate_prefixes: tuple[str, ...] = ( "role.", @@ -87,37 +924,23 @@ class TagPolicy: block_exact: tuple[str, ...] = () def should_propagate(self, tag: str) -> bool: - """Check whether a tag should propagate downward. + """Return the old registry hint without changing assignment policy.""" - Args: - tag: Tag string. - - Returns: - bool: Whether the tag should propagate. - """ if tag in self.block_exact: return False if any(tag.startswith(prefix) for prefix in self.block_prefixes): return False if tag in self.propagate_exact: return True - if any(tag.startswith(prefix) for prefix in self.propagate_prefixes): - return True - return False + return any(tag.startswith(prefix) for prefix in self.propagate_prefixes) DEFAULT_TAG_POLICY = TagPolicy() def resolve_anchor_tag_candidates(tag: str) -> List[str]: - """Generate candidate tags for anchor lookup. + """Generate candidate tags for legacy anchor lookup.""" - Args: - tag: User-provided tag. - - Returns: - List[str]: Candidate tags ordered by priority. - """ token = tag.strip().lower() if not token: return [] @@ -153,3 +976,19 @@ def resolve_anchor_tag_candidates(tag: str) -> List[str]: f"legacy.{token}", token, ] + + +# Short aliases retained for callers that use the model names rather than the +# field-qualified names. +BindingScope = TagBindingScope +Propagation = TagPropagation +Lineage = LineagePolicy +Attachment = TagAttachment +Producer = TagProducer +ProducerKind = TagProducerKind +Target = TagTarget +TargetKind = TagTargetKind +Evidence = TagEvidence +EvidenceKind = TagEvidenceKind +Certainty = TagCertainty +Lifecycle = TagLifecycle diff --git a/SimpleCADAPI/src/simplecadapi/tolerance.py b/SimpleCADAPI/src/simplecadapi/tolerance.py new file mode 100644 index 0000000..47523ba --- /dev/null +++ b/SimpleCADAPI/src/simplecadapi/tolerance.py @@ -0,0 +1,1729 @@ +"""Manufacturing-tolerance propagation over scalar expression graphs.""" + +from __future__ import annotations + +from dataclasses import dataclass, replace +from decimal import Decimal, localcontext +from fractions import Fraction +import math +from typing import Any, Dict, List, Literal, Mapping, Tuple +import uuid + +from .expr import ( + Const, + DimensionTolerance, + Expr, + ExpressionGraph, + ScalarExpr, + ScalarLike, + ToleranceLike, + Var, + coerce_dimension_tolerance, + lift_scalar, +) +from .units import ( + ANGLE, + Dimension, + Unit, + UnitLike, + canonical_unit_for_dimension, + expression_uses_units, + get_unit, + infer_dimension, + unit_from_payload, + unit_to_payload, +) + + +ToleranceMethod = Literal["worst_case", "rss"] +_SUPPORTED_METHODS = {"worst_case", "rss"} +_AffineForm = Tuple[Fraction, Dict[str, Fraction]] + + +class ToleranceAnalysisError(ValueError): + """Raised when a tolerance chain cannot be propagated safely.""" + + +class ToleranceValidationError(ValueError): + """Raised when one or more declared tolerance requirements fail.""" + + def __init__(self, report: "ToleranceReport") -> None: + self.report = report + failed = [check.requirement.name for check in report.checks if not check.passed] + super().__init__("Tolerance requirements failed: " + ", ".join(failed)) + + +@dataclass(frozen=True) +class ToleranceContribution: + """One source dimension's propagated contribution to a result.""" + + variable_expr_id: str + variable_name: str + nominal: float + source_tolerance: DimensionTolerance + sensitivity: float | None + lower_deviation: float + upper_deviation: float + source_unit: Unit | None = None + + def to_dict(self) -> Dict[str, Any]: + return { + "variable_expr_id": self.variable_expr_id, + "variable_name": self.variable_name, + "nominal": self.nominal, + "source_tolerance": self.source_tolerance.to_dict(), + "sensitivity": self.sensitivity, + "lower_deviation": self.lower_deviation, + "upper_deviation": self.upper_deviation, + "source_unit": ( + None + if self.source_unit is None + else unit_to_payload(self.source_unit) + ), + } + + +@dataclass(frozen=True) +class ToleranceAnalysis: + """Nominal value and propagated limits for an expression.""" + + target_expr_id: str + method: ToleranceMethod + nominal: float + lower_bound: float + upper_bound: float + lower_deviation: float + upper_deviation: float + dimension: Dimension | None = None + unit: Unit | None = None + contributions: Tuple[ToleranceContribution, ...] = () + + def __post_init__(self) -> None: + values = ( + self.nominal, + self.lower_bound, + self.upper_bound, + self.lower_deviation, + self.upper_deviation, + ) + if not all(math.isfinite(value) for value in values): + raise ToleranceAnalysisError( + "Tolerance analysis values must all be finite" + ) + if self.lower_bound > self.upper_bound: + raise ToleranceAnalysisError("Tolerance analysis bounds are inverted") + if self.lower_bound > self.nominal or self.upper_bound < self.nominal: + raise ToleranceAnalysisError( + "Tolerance analysis bounds must enclose the nominal value" + ) + if self.lower_deviation > 0.0 or self.upper_deviation < 0.0: + raise ToleranceAnalysisError( + "Tolerance analysis deviations must enclose the nominal value" + ) + if self.unit is not None: + if self.dimension is None or self.unit.dimension != self.dimension: + raise ToleranceAnalysisError( + "Tolerance analysis unit must match its result dimension" + ) + elif self.dimension is not None: + raise ToleranceAnalysisError( + "A dimensioned tolerance analysis requires a canonical result unit" + ) + + @property + def tolerance(self) -> DimensionTolerance: + return DimensionTolerance(self.lower_deviation, self.upper_deviation) + + def to_dict(self) -> Dict[str, Any]: + return { + "target_expr_id": self.target_expr_id, + "method": self.method, + "nominal": self.nominal, + "lower_bound": self.lower_bound, + "upper_bound": self.upper_bound, + "lower_deviation": self.lower_deviation, + "upper_deviation": self.upper_deviation, + "dimension": ( + None if self.dimension is None else self.dimension.to_dict() + ), + "unit": None if self.unit is None else unit_to_payload(self.unit), + "contributions": [item.to_dict() for item in self.contributions], + } + + +@dataclass(frozen=True) +class ToleranceRequirement: + """Permitted result deviations for one derived dimension.""" + + requirement_id: str + target_expr_id: str + tolerance: DimensionTolerance + method: ToleranceMethod = "worst_case" + name: str = "" + tolerance_unit: Unit | None = None + target_dimension: Dimension | None = None + + def __post_init__(self) -> None: + if not isinstance(self.requirement_id, str) or not self.requirement_id: + raise ValueError("Tolerance requirement id must be a non-empty string") + if not isinstance(self.target_expr_id, str) or not self.target_expr_id: + raise ValueError("Tolerance target expression id must be a non-empty string") + _validate_method(self.method) + if not isinstance(self.tolerance, DimensionTolerance): + raise TypeError( + "Tolerance requirement tolerance must be a DimensionTolerance" + ) + if not isinstance(self.name, str) or not self.name: + raise ValueError("Tolerance requirement name must be a non-empty string") + if self.tolerance_unit is not None and not isinstance( + self.tolerance_unit, Unit + ): + raise TypeError("Tolerance requirement unit must be a Unit") + if self.target_dimension is not None and not isinstance( + self.target_dimension, Dimension + ): + raise TypeError("Tolerance target dimension must be a Dimension") + if ( + self.target_dimension is not None + and not self.target_dimension.is_design_dimension + ): + raise ValueError( + "Manufacturing tolerance requirements require a Length or Angle target" + ) + if self.tolerance_unit is not None: + if self.target_dimension is None: + raise ValueError( + "A tolerance unit requires a unit-aware target expression" + ) + if self.tolerance_unit.dimension != self.target_dimension: + raise ValueError( + "Tolerance requirement unit must match the target expression dimension" + ) + elif self.target_dimension is not None: + raise ValueError( + "A dimensioned tolerance requirement requires a tolerance unit" + ) + + @property + def canonical_tolerance(self) -> DimensionTolerance: + if self.tolerance_unit is None: + return self.tolerance + return DimensionTolerance( + self.tolerance_unit.to_canonical(self.tolerance.lower_deviation), + self.tolerance_unit.to_canonical(self.tolerance.upper_deviation), + ) + + def to_dict(self) -> Dict[str, Any]: + return { + "requirement_id": self.requirement_id, + "target_expr_id": self.target_expr_id, + "tolerance": self.tolerance.to_dict(), + "method": self.method, + "name": self.name, + "tolerance_unit": ( + None + if self.tolerance_unit is None + else unit_to_payload(self.tolerance_unit) + ), + "target_dimension": ( + None + if self.target_dimension is None + else self.target_dimension.to_dict() + ), + } + + +@dataclass(frozen=True) +class ToleranceCheck: + """Validation result for one tolerance requirement.""" + + requirement: ToleranceRequirement + analysis: ToleranceAnalysis + passed: bool + lower_margin: float + upper_margin: float + + def to_dict(self) -> Dict[str, Any]: + return { + "requirement": self.requirement.to_dict(), + "analysis": self.analysis.to_dict(), + "passed": self.passed, + "lower_margin": self.lower_margin, + "upper_margin": self.upper_margin, + } + + +@dataclass(frozen=True) +class ToleranceReport: + """Validation report for every requirement in a tolerance graph.""" + + checks: Tuple[ToleranceCheck, ...] = () + + @property + def passed(self) -> bool: + return all(check.passed for check in self.checks) + + def to_dict(self) -> Dict[str, Any]: + return { + "passed": self.passed, + "checks": [check.to_dict() for check in self.checks], + } + + +@dataclass(frozen=True) +class _Interval: + lower: float + upper: float + varies: bool = False + + def __post_init__(self) -> None: + if not math.isfinite(self.lower) or not math.isfinite(self.upper): + raise ToleranceAnalysisError("Tolerance propagation produced a non-finite interval") + if self.lower > self.upper: + raise ToleranceAnalysisError("Tolerance propagation produced an inverted interval") + + +def _validate_method(method: str) -> ToleranceMethod: + if not isinstance(method, str): + raise TypeError("Tolerance propagation method must be a string") + if method not in _SUPPORTED_METHODS: + raise ValueError( + f"Unsupported tolerance propagation method '{method}'; " + "expected 'worst_case' or 'rss'" + ) + return method # type: ignore[return-value] + + +def _validate_expression_identity(expr: ScalarExpr) -> None: + signatures: Dict[str, Tuple[Any, ...]] = {} + visiting: set[str] = set() + validated_objects: set[int] = set() + + def signature(node: ScalarExpr) -> Tuple[Any, ...]: + if isinstance(node, Const): + return ("const", node.value.hex()) + if isinstance(node, Var): + tolerance = ( + None + if node.tolerance is None + else ( + node.tolerance.lower_deviation.hex(), + node.tolerance.upper_deviation.hex(), + ) + ) + return ( + "var", + node.name, + node.default.hex(), + node.comment, + tolerance, + None if node.unit is None else node.unit.symbol, + None + if node.tolerance_unit is None + else node.tolerance_unit.symbol, + ) + return ("expr", node.op, tuple(arg.expr_id for arg in node.args)) + + def visit(node: ScalarExpr) -> None: + if node.expr_id in visiting: + raise ToleranceAnalysisError( + f"Expression graph contains a cycle at '{node.expr_id}'" + ) + node_signature = signature(node) + existing = signatures.get(node.expr_id) + if existing is not None and existing != node_signature: + raise ToleranceAnalysisError( + f"Expression id '{node.expr_id}' refers to multiple structural nodes" + ) + signatures[node.expr_id] = node_signature + object_id = id(node) + if object_id in validated_objects: + return + visiting.add(node.expr_id) + if isinstance(node, Expr): + for arg in node.args: + visit(arg) + visiting.remove(node.expr_id) + validated_objects.add(object_id) + + visit(expr) + + +def _variables(expr: ScalarExpr) -> Tuple[Var, ...]: + variables: Dict[str, Var] = {} + visiting: set[str] = set() + + def visit(node: ScalarExpr) -> None: + if node.expr_id in visiting: + raise ToleranceAnalysisError( + f"Expression graph contains a cycle at '{node.expr_id}'" + ) + if isinstance(node, Var): + existing = variables.get(node.expr_id) + if existing is not None and existing != node: + raise ToleranceAnalysisError( + f"Expression id '{node.expr_id}' refers to multiple variables" + ) + variables[node.expr_id] = node + return + if not isinstance(node, Expr): + return + visiting.add(node.expr_id) + for arg in node.args: + visit(arg) + visiting.remove(node.expr_id) + + visit(expr) + return tuple(sorted(variables.values(), key=lambda item: item.expr_id)) + + +def _require_source_tolerances(variables: Tuple[Var, ...]) -> None: + missing = [f"{var.name} ({var.expr_id})" for var in variables if var.tolerance is None] + if missing: + raise ToleranceAnalysisError( + "Every variable in a tolerance chain must declare a tolerance; missing: " + + ", ".join(missing) + ) + + +def _finite_fsum(values: List[float], *, label: str) -> float: + try: + result = math.fsum(values) + except OverflowError as exc: + raise ToleranceAnalysisError(f"{label} is non-finite") from exc + if not math.isfinite(result): + raise ToleranceAnalysisError(f"{label} is non-finite") + return result + + +def _fraction_to_decimal(value: Fraction) -> Decimal: + return Decimal(value.numerator) / Decimal(value.denominator) + + +def _decimal_to_float( + value: Decimal, + *, + label: str, + allow_unrepresentable: bool = False, + outward: bool = False, +) -> float | None: + if not value.is_finite(): + raise ToleranceAnalysisError(f"{label} is non-finite") + try: + result = float(value) + except (OverflowError, ValueError): + if allow_unrepresentable: + return None + raise ToleranceAnalysisError(f"{label} is outside the supported float range") + if not math.isfinite(result): + if allow_unrepresentable: + return None + raise ToleranceAnalysisError(f"{label} is outside the supported float range") + if result == 0.0 and value != 0: + return math.copysign(math.ulp(0.0), -1.0 if value < 0 else 1.0) + if outward and value != 0: + represented = Decimal.from_float(result) + if value < 0 and represented > value: + result = math.nextafter(result, -math.inf) + elif value > 0 and represented < value: + result = math.nextafter(result, math.inf) + return result + + +def _sensitivity_decimal(value: Decimal | Fraction | float) -> Decimal: + if isinstance(value, Decimal): + return value + if isinstance(value, Fraction): + return _fraction_to_decimal(value) + return Decimal.from_float(value) + + +def _mul_interval(left: _Interval, right: _Interval) -> _Interval: + products = ( + left.lower * right.lower, + left.lower * right.upper, + left.upper * right.lower, + left.upper * right.upper, + ) + return _outward_interval( + min(products), max(products), varies=left.varies or right.varies + ) + + +def _reciprocal_interval(value: _Interval) -> _Interval: + if value.lower <= 0.0 <= value.upper: + raise ToleranceAnalysisError( + "Cannot propagate division because the denominator tolerance interval contains zero" + ) + reciprocal = (1.0 / value.lower, 1.0 / value.upper) + return _outward_interval( + min(reciprocal), max(reciprocal), varies=value.varies + ) + + +def _integer_power_interval(base: _Interval, exponent: int) -> _Interval: + if exponent == 0: + return _Interval(1.0, 1.0) + if exponent < 0: + return _reciprocal_interval(_integer_power_interval(base, -exponent)) + values = (base.lower**exponent, base.upper**exponent) + if exponent % 2 == 0 and base.lower <= 0.0 <= base.upper: + return _bounded_outward_interval( + 0.0, + max(values), + minimum=0.0, + maximum=math.inf, + varies=base.varies, + ) + return _outward_interval( + min(values), max(values), varies=base.varies + ) + + +def _pow_interval(base: _Interval, exponent: _Interval) -> _Interval: + exponent_is_point = exponent.lower == exponent.upper + if exponent_is_point and float(exponent.lower).is_integer(): + return _integer_power_interval(base, int(exponent.lower)) + + if base.lower < 0.0: + raise ToleranceAnalysisError( + "Cannot propagate a non-integer or varying power over a negative base interval" + ) + if base.lower == 0.0 and exponent.lower <= 0.0: + raise ToleranceAnalysisError( + "Power tolerance interval includes an undefined or unbounded zero-base value" + ) + + candidates: List[float] = [] + if base.lower == 0.0: + candidates.append(0.0) + for base_value in {base.lower, base.upper}: + if base_value == 0.0: + continue + for exponent_value in {exponent.lower, exponent.upper}: + candidates.append(base_value**exponent_value) + if not candidates or not all(math.isfinite(value) for value in candidates): + raise ToleranceAnalysisError("Power tolerance propagation is non-finite") + return _outward_interval( + min(candidates), + max(candidates), + varies=base.varies or exponent.varies, + ) + + +def _outward_interval( + lower: float, upper: float, *, varies: bool = False +) -> _Interval: + if not math.isfinite(lower) or not math.isfinite(upper): + raise ToleranceAnalysisError( + "Tolerance propagation produced a non-finite interval" + ) + if lower == upper and not varies: + return _Interval(lower, upper) + return _Interval( + math.nextafter(lower, -math.inf), + math.nextafter(upper, math.inf), + varies, + ) + + +def _bounded_outward_interval( + lower: float, + upper: float, + *, + minimum: float, + maximum: float, + varies: bool = False, +) -> _Interval: + if lower == upper and not varies: + return _Interval(lower, upper) + outward_lower = ( + minimum if lower <= minimum else math.nextafter(lower, -math.inf) + ) + outward_upper = ( + maximum if upper >= maximum else math.nextafter(upper, math.inf) + ) + return _Interval(outward_lower, outward_upper, varies) + + +def _bound_from_deviation(nominal: float, deviation: float) -> float: + if deviation == 0.0: + return nominal + bound = nominal + deviation + if not math.isfinite(bound): + raise ToleranceAnalysisError( + "Tolerance bound is non-finite at the declared nominal value" + ) + direction = -math.inf if deviation < 0.0 else math.inf + if (deviation < 0.0 and bound >= nominal) or ( + deviation > 0.0 and bound <= nominal + ): + bound = nominal + return math.nextafter(bound, direction) + + +def _contains_periodic_point( + interval: _Interval, *, first: float, period: float +) -> bool: + if not interval.varies or interval.lower == interval.upper: + return False + if max(abs(interval.lower), abs(interval.upper)) >= 2.0**50 * period: + return True + first_index = math.ceil((interval.lower - first) / period) + point = first + first_index * period + return point <= interval.upper + + +def _angle_interval_to_radians(value: _Interval) -> _Interval: + return _outward_interval( + math.radians(value.lower), + math.radians(value.upper), + varies=value.varies, + ) + + +def _angle_interval_to_degrees(value: _Interval) -> _Interval: + return _outward_interval( + math.degrees(value.lower), + math.degrees(value.upper), + varies=value.varies, + ) + + +def _sin_interval(value: _Interval) -> _Interval: + if value.upper - value.lower >= 2.0 * math.pi: + return _Interval(-1.0, 1.0) + candidates = [math.sin(value.lower), math.sin(value.upper)] + if _contains_periodic_point(value, first=math.pi / 2.0, period=2.0 * math.pi): + candidates.append(1.0) + if _contains_periodic_point(value, first=-math.pi / 2.0, period=2.0 * math.pi): + candidates.append(-1.0) + return _bounded_outward_interval( + min(candidates), + max(candidates), + minimum=-1.0, + maximum=1.0, + varies=value.varies, + ) + + +def _cos_interval(value: _Interval) -> _Interval: + if value.upper - value.lower >= 2.0 * math.pi: + return _Interval(-1.0, 1.0) + candidates = [math.cos(value.lower), math.cos(value.upper)] + if _contains_periodic_point(value, first=0.0, period=2.0 * math.pi): + candidates.append(1.0) + if _contains_periodic_point(value, first=math.pi, period=2.0 * math.pi): + candidates.append(-1.0) + return _bounded_outward_interval( + min(candidates), + max(candidates), + minimum=-1.0, + maximum=1.0, + varies=value.varies, + ) + + +def _tan_interval(value: _Interval) -> _Interval: + if _contains_periodic_point(value, first=math.pi / 2.0, period=math.pi): + raise ToleranceAnalysisError( + "Cannot propagate tangent across a discontinuity in the tolerance interval" + ) + tangent = (math.tan(value.lower), math.tan(value.upper)) + return _outward_interval( + min(tangent), max(tangent), varies=value.varies + ) + + +def _interval_has_negative_side(value: _Interval) -> bool: + return value.lower < 0.0 or ( + value.lower == 0.0 and math.copysign(1.0, value.lower) < 0.0 + ) + + +def _interval_has_positive_side(value: _Interval) -> bool: + return value.upper > 0.0 or ( + value.upper == 0.0 and math.copysign(1.0, value.upper) > 0.0 + ) + + +def _atan2_interval( + y: _Interval, x: _Interval, *, reject_branch_cut: bool = False +) -> _Interval: + if x.lower <= 0.0 <= x.upper and y.lower <= 0.0 <= y.upper: + raise ToleranceAnalysisError( + "Cannot propagate atan2 because the tolerance region contains the undefined origin" + ) + crosses_branch_cut = ( + x.upper < 0.0 + and _interval_has_negative_side(y) + and _interval_has_positive_side(y) + ) + if crosses_branch_cut: + if reject_branch_cut: + raise ToleranceAnalysisError( + "RSS cannot propagate atan2 across its negative-x-axis branch cut" + ) + return _Interval(-math.pi, math.pi, True) + + points = [ + math.atan2(y_value, x_value) + for y_value in {y.lower, y.upper} + for x_value in {x.lower, x.upper} + ] + return _bounded_outward_interval( + min(points), + max(points), + minimum=-math.pi, + maximum=math.pi, + varies=y.varies or x.varies, + ) + + +def _affine_interval( + affine: _AffineForm, variables: Mapping[str, Var] +) -> _Interval: + nominal = affine[0] + lower_deviations: List[Fraction] = [] + upper_deviations: List[Fraction] = [] + for expr_id, coefficient in affine[1].items(): + variable = variables.get(expr_id) + if variable is None or variable.canonical_tolerance is None: + raise ToleranceAnalysisError( + f"Unknown or untoleranced affine variable '{expr_id}'" + ) + source_tolerance = variable.canonical_tolerance + nominal += coefficient * Fraction.from_float(variable.canonical_default) + deviations = ( + coefficient + * Fraction.from_float(source_tolerance.lower_deviation), + coefficient + * Fraction.from_float(source_tolerance.upper_deviation), + ) + lower_deviations.append(min(deviations)) + upper_deviations.append(max(deviations)) + with localcontext() as context: + context.prec = 100 + nominal_value = _decimal_to_float( + _fraction_to_decimal(nominal), label="Affine nominal value" + ) + lower_deviation = _decimal_to_float( + _fraction_to_decimal(sum(lower_deviations, Fraction(0))), + label="Affine lower deviation", + outward=True, + ) + upper_deviation = _decimal_to_float( + _fraction_to_decimal(sum(upper_deviations, Fraction(0))), + label="Affine upper deviation", + outward=True, + ) + if ( + nominal_value is None + or lower_deviation is None + or upper_deviation is None + ): + raise ToleranceAnalysisError("Affine tolerance interval is not representable") + return _Interval( + _bound_from_deviation(nominal_value, lower_deviation), + _bound_from_deviation(nominal_value, upper_deviation), + any(item != 0.0 for item in lower_deviations + upper_deviations), + ) + + +def _interval_value( + expr: ScalarExpr, + cache: Dict[str, _Interval], + variables: Mapping[str, Var] | None = None, + *, + reject_atan2_branch_cut: bool = False, +) -> _Interval: + cached = cache.get(expr.expr_id) + if cached is not None: + return cached + variable_map = variables + if variable_map is None: + variable_map = {item.expr_id: item for item in _variables(expr)} + if isinstance(expr, Const): + result = _Interval(expr.value, expr.value) + elif isinstance(expr, Var): + source_tolerance = expr.canonical_tolerance + if source_tolerance is None: + raise ToleranceAnalysisError( + f"Variable '{expr.name}' ({expr.expr_id}) has no declared tolerance" + ) + result = _Interval( + _bound_from_deviation( + expr.canonical_default, source_tolerance.lower_deviation + ), + _bound_from_deviation( + expr.canonical_default, source_tolerance.upper_deviation + ), + source_tolerance.width > 0.0, + ) + else: + affine = _affine_form(expr, {}, {}) + if affine is not None: + result = _affine_interval(affine, variable_map) + cache[expr.expr_id] = result + return result + args = [ + _interval_value( + arg, + cache, + variable_map, + reject_atan2_branch_cut=reject_atan2_branch_cut, + ) + for arg in expr.args + ] + if expr.op == "add": + result = _outward_interval( + args[0].lower + args[1].lower, + args[0].upper + args[1].upper, + varies=args[0].varies or args[1].varies, + ) + elif expr.op == "sub": + result = _outward_interval( + args[0].lower - args[1].upper, + args[0].upper - args[1].lower, + varies=args[0].varies or args[1].varies, + ) + elif expr.op == "mul": + if expr.args[0].expr_id == expr.args[1].expr_id: + result = _integer_power_interval(args[0], 2) + else: + result = _mul_interval(args[0], args[1]) + elif expr.op == "div": + reciprocal = _reciprocal_interval(args[1]) + if expr.args[0].expr_id == expr.args[1].expr_id: + result = _Interval(1.0, 1.0) + else: + result = _mul_interval(args[0], reciprocal) + elif expr.op == "pow": + result = _pow_interval(args[0], args[1]) + elif expr.op == "neg": + result = _outward_interval( + -args[0].upper, -args[0].lower, varies=args[0].varies + ) + elif expr.op == "abs": + if args[0].lower <= 0.0 <= args[0].upper: + upper = max(-args[0].lower, args[0].upper) + result = ( + _Interval(0.0, 0.0) + if not args[0].varies and upper == 0.0 + else _Interval( + 0.0, + math.nextafter(upper, math.inf), + args[0].varies, + ) + ) + else: + values = (abs(args[0].lower), abs(args[0].upper)) + result = _bounded_outward_interval( + min(values), + max(values), + minimum=0.0, + maximum=math.inf, + varies=args[0].varies, + ) + elif expr.op == "sin": + result = _sin_interval( + _angle_interval_to_radians(args[0]) + if expression_uses_units(expr) + else args[0] + ) + elif expr.op == "cos": + result = _cos_interval( + _angle_interval_to_radians(args[0]) + if expression_uses_units(expr) + else args[0] + ) + elif expr.op == "tan": + result = _tan_interval( + _angle_interval_to_radians(args[0]) + if expression_uses_units(expr) + else args[0] + ) + elif expr.op == "sqrt": + lower = args[0].lower + if lower == math.nextafter(0.0, -math.inf): + lower = 0.0 + if lower < 0.0: + raise ToleranceAnalysisError( + "Square-root tolerance interval extends below zero" + ) + result = _bounded_outward_interval( + math.sqrt(lower), + math.sqrt(args[0].upper), + minimum=0.0, + maximum=math.inf, + varies=args[0].varies, + ) + elif expr.op in {"asin", "acos"}: + lower = args[0].lower + upper = args[0].upper + if lower == math.nextafter(-1.0, -math.inf): + lower = -1.0 + if upper == math.nextafter(1.0, math.inf): + upper = 1.0 + if lower < -1.0 or upper > 1.0: + raise ToleranceAnalysisError( + f"{expr.op} tolerance interval extends outside [-1, 1]" + ) + values = ( + getattr(math, expr.op)(lower), + getattr(math, expr.op)(upper), + ) + if expr.op == "asin": + minimum, maximum = -math.pi / 2.0, math.pi / 2.0 + else: + minimum, maximum = 0.0, math.pi + result = _bounded_outward_interval( + min(values), max(values), minimum=minimum, maximum=maximum + , varies=args[0].varies + ) + if expression_uses_units(expr): + result = _angle_interval_to_degrees(result) + elif expr.op == "atan": + result = _outward_interval( + math.atan(args[0].lower), + math.atan(args[0].upper), + varies=args[0].varies, + ) + if expression_uses_units(expr): + result = _angle_interval_to_degrees(result) + elif expr.op == "atan2": + result = _atan2_interval( + args[0], args[1], reject_branch_cut=reject_atan2_branch_cut + ) + if expression_uses_units(expr): + result = _angle_interval_to_degrees(result) + else: + raise ToleranceAnalysisError( + f"Unsupported expression op '{expr.op}' in tolerance propagation" + ) + cache[expr.expr_id] = result + return result + + +def _constant_value(expr: ScalarExpr, cache: Dict[str, float | None]) -> float | None: + if expr.expr_id in cache: + return cache[expr.expr_id] + if isinstance(expr, Var): + result = None + elif isinstance(expr, Const): + result = expr.value + elif any(_constant_value(arg, cache) is None for arg in expr.args): + result = None + else: + result = expr.evaluate() + cache[expr.expr_id] = result + return result + + +def _affine_form( + expr: ScalarExpr, + cache: Dict[str, _AffineForm | None], + constant_cache: Dict[str, float | None], +) -> _AffineForm | None: + if expr.expr_id in cache: + return cache[expr.expr_id] + if isinstance(expr, Const): + result: _AffineForm | None = (Fraction.from_float(expr.value), {}) + elif isinstance(expr, Var): + result = (Fraction(0), {expr.expr_id: Fraction(1)}) + elif expr.op in {"add", "sub"}: + left = _affine_form(expr.args[0], cache, constant_cache) + right = _affine_form(expr.args[1], cache, constant_cache) + if left is None or right is None: + result = None + else: + sign = Fraction(1) if expr.op == "add" else Fraction(-1) + coefficients = dict(left[1]) + for expr_id, coefficient in right[1].items(): + coefficients[expr_id] = ( + coefficients.get(expr_id, Fraction(0)) + sign * coefficient + ) + result = (left[0] + sign * right[0], coefficients) + elif expr.op == "neg": + value = _affine_form(expr.args[0], cache, constant_cache) + result = ( + None + if value is None + else (-value[0], {key: -item for key, item in value[1].items()}) + ) + elif expr.op in {"mul", "div"}: + left_constant = _constant_value(expr.args[0], constant_cache) + right_constant = _constant_value(expr.args[1], constant_cache) + if expr.op == "mul" and left_constant is not None: + value = _affine_form(expr.args[1], cache, constant_cache) + factor = Fraction.from_float(left_constant) + elif right_constant is not None: + if expr.op == "div" and right_constant == 0.0: + raise ToleranceAnalysisError("Expression divides by zero") + value = _affine_form(expr.args[0], cache, constant_cache) + right_factor = Fraction.from_float(right_constant) + factor = ( + right_factor if expr.op == "mul" else Fraction(1) / right_factor + ) + else: + value = None + factor = Fraction(0) + result = ( + None + if value is None + else ( + value[0] * factor, + {key: item * factor for key, item in value[1].items()}, + ) + ) + elif expr.op == "pow": + exponent = _constant_value(expr.args[1], constant_cache) + if exponent == 1.0: + result = _affine_form(expr.args[0], cache, constant_cache) + elif exponent == 0.0: + result = (Fraction(1), {}) + else: + constant = _constant_value(expr, constant_cache) + result = ( + (Fraction.from_float(constant), {}) + if constant is not None + else None + ) + else: + constant = _constant_value(expr, constant_cache) + result = ( + (Fraction.from_float(constant), {}) if constant is not None else None + ) + cache[expr.expr_id] = result + return result + + +def _merge_gradients( + left: Mapping[str, Decimal], + right: Mapping[str, Decimal], + *, + left_scale: Decimal, + right_scale: Decimal, +) -> Dict[str, Decimal]: + result = { + key: value * left_scale + for key, value in left.items() + if value * left_scale != 0 + } + for key, value in right.items(): + result[key] = result.get(key, Decimal(0)) + value * right_scale + if result[key] == 0: + del result[key] + return result + + +def _gradient_value( + expr: ScalarExpr, cache: Dict[str, Tuple[float, Dict[str, Decimal]]] +) -> Tuple[float, Dict[str, Decimal]]: + cached = cache.get(expr.expr_id) + if cached is not None: + return cached + affine = _affine_form(expr, {}, {}) + if affine is not None: + try: + nominal = float(expr.evaluate()) + except Exception as exc: + raise ToleranceAnalysisError( + "Affine expression cannot be evaluated at nominal values" + ) from exc + result = ( + nominal, + { + expr_id: _fraction_to_decimal(coefficient) + for expr_id, coefficient in affine[1].items() + if coefficient != 0 + }, + ) + elif isinstance(expr, Const): + result = (expr.value, {}) + elif isinstance(expr, Var): + result = (expr.canonical_default, {expr.expr_id: Decimal(1)}) + else: + args = [_gradient_value(arg, cache) for arg in expr.args] + values = [item[0] for item in args] + gradients = [item[1] for item in args] + if expr.op == "add": + result = ( + values[0] + values[1], + _merge_gradients( + gradients[0], + gradients[1], + left_scale=Decimal(1), + right_scale=Decimal(1), + ), + ) + elif expr.op == "sub": + result = ( + values[0] - values[1], + _merge_gradients( + gradients[0], + gradients[1], + left_scale=Decimal(1), + right_scale=Decimal(-1), + ), + ) + elif expr.op == "mul": + result = ( + values[0] * values[1], + _merge_gradients( + gradients[0], + gradients[1], + left_scale=Decimal.from_float(values[1]), + right_scale=Decimal.from_float(values[0]), + ), + ) + elif expr.op == "div": + if values[1] == 0.0: + raise ToleranceAnalysisError("Expression divides by zero at nominal values") + result = ( + values[0] / values[1], + _merge_gradients( + gradients[0], + gradients[1], + left_scale=Decimal(1) / Decimal.from_float(values[1]), + right_scale=-Decimal.from_float(values[0]) + / Decimal.from_float(values[1]) + / Decimal.from_float(values[1]), + ), + ) + elif expr.op == "pow": + try: + value = values[0] ** values[1] + except (TypeError, ValueError, ZeroDivisionError, OverflowError) as exc: + raise ToleranceAnalysisError( + "Power expression is undefined at nominal values" + ) from exc + if isinstance(value, complex) or not math.isfinite(value): + raise ToleranceAnalysisError( + "Power expression is not finite and real at nominal values" + ) + if not gradients[1] and values[1] == 0.0: + result = (1.0, {}) + cache[expr.expr_id] = result + return result + if gradients[1]: + if values[0] <= 0.0: + raise ToleranceAnalysisError( + "RSS propagation through a varying exponent requires a positive base" + ) + exponent_scale = Decimal.from_float(float(value)) * Decimal.from_float( + math.log(values[0]) + ) + else: + exponent_scale = Decimal(0) + if gradients[0]: + if values[0] == 0.0: + if values[1] == 1.0: + base_scale = Decimal(1) + elif values[1] > 1.0: + base_scale = Decimal(0) + else: + raise ToleranceAnalysisError( + "Power derivative is undefined at nominal values" + ) + else: + base_scale = ( + Decimal.from_float(float(value)) + * Decimal.from_float(values[1]) + / Decimal.from_float(values[0]) + ) + else: + base_scale = Decimal(0) + result = ( + float(value), + _merge_gradients( + gradients[0], + gradients[1], + left_scale=base_scale, + right_scale=exponent_scale, + ), + ) + elif expr.op == "neg": + result = (-values[0], {key: -value for key, value in gradients[0].items()}) + elif expr.op == "abs": + if values[0] == 0.0 and gradients[0]: + raise ToleranceAnalysisError( + "RSS propagation is undefined where abs is non-differentiable" + ) + sign = Decimal(-1) if values[0] < 0.0 else Decimal(1) + result = ( + abs(values[0]), + {key: sign * value for key, value in gradients[0].items()}, + ) + elif expr.op in {"sin", "cos", "tan", "sqrt", "acos", "asin", "atan"}: + value = values[0] + unit_aware = expression_uses_units(expr) + function_value = ( + math.radians(value) + if unit_aware and expr.op in {"sin", "cos", "tan"} + else value + ) + if expr.op == "sin": + output = math.sin(function_value) + scale = Decimal.from_float(math.cos(function_value)) + if unit_aware: + scale *= Decimal.from_float(math.pi / 180.0) + elif expr.op == "cos": + output = math.cos(function_value) + scale = Decimal.from_float(-math.sin(function_value)) + if unit_aware: + scale *= Decimal.from_float(math.pi / 180.0) + elif expr.op == "tan": + cosine = math.cos(function_value) + if cosine == 0.0: + raise ToleranceAnalysisError("Tangent is undefined at the nominal value") + output = math.tan(function_value) + scale = Decimal(1) / (Decimal.from_float(cosine) ** 2) + if unit_aware: + scale *= Decimal.from_float(math.pi / 180.0) + elif expr.op == "sqrt": + if value < 0.0 or (value == 0.0 and gradients[0]): + raise ToleranceAnalysisError( + "Square-root derivative is undefined at the nominal value" + ) + output = math.sqrt(value) + scale = ( + Decimal(0) + if not gradients[0] + else Decimal("0.5") / Decimal.from_float(output) + ) + elif expr.op in {"acos", "asin"}: + if value < -1.0 or value > 1.0 or ( + abs(value) == 1.0 and gradients[0] + ): + raise ToleranceAnalysisError( + f"{expr.op} derivative is undefined at the nominal value" + ) + output = getattr(math, expr.op)(value) + if gradients[0]: + denominator = ( + Decimal(1) - Decimal.from_float(value) ** 2 + ).sqrt() + scale = Decimal(1) / denominator + if expr.op == "acos": + scale = -scale + else: + scale = Decimal(0) + if unit_aware: + output = math.degrees(output) + scale *= Decimal.from_float(180.0 / math.pi) + else: + output = math.atan(value) + decimal_value = Decimal.from_float(value) + if abs(value) <= 1.0: + scale = Decimal(1) / ( + Decimal(1) + decimal_value * decimal_value + ) + else: + reciprocal = Decimal(1) / decimal_value + scale = reciprocal * reciprocal / ( + Decimal(1) + reciprocal * reciprocal + ) + if unit_aware: + output = math.degrees(output) + scale *= Decimal.from_float(180.0 / math.pi) + result = ( + output, + {key: scale * item for key, item in gradients[0].items()}, + ) + elif expr.op == "atan2": + if values[0] == 0.0 and values[1] == 0.0: + raise ToleranceAnalysisError("atan2 is undefined at the nominal origin") + decimal_y = Decimal.from_float(values[0]) + decimal_x = Decimal.from_float(values[1]) + denominator = decimal_y * decimal_y + decimal_x * decimal_x + output = math.atan2(values[0], values[1]) + left_scale = decimal_x / denominator + right_scale = -decimal_y / denominator + if expression_uses_units(expr): + output = math.degrees(output) + angle_scale = Decimal.from_float(180.0 / math.pi) + left_scale *= angle_scale + right_scale *= angle_scale + result = ( + output, + _merge_gradients( + gradients[0], + gradients[1], + left_scale=left_scale, + right_scale=right_scale, + ), + ) + else: + raise ToleranceAnalysisError( + f"Unsupported expression op '{expr.op}' in RSS propagation" + ) + if not math.isfinite(result[0]) or not all( + item.is_finite() for item in result[1].values() + ): + raise ToleranceAnalysisError("RSS propagation produced a non-finite result") + cache[expr.expr_id] = result + return result + + +def _contribution( + variable: Var, sensitivity: Decimal | Fraction | float | None +) -> ToleranceContribution: + source_tolerance = variable.canonical_tolerance + if source_tolerance is None: + raise ToleranceAnalysisError( + f"Variable '{variable.name}' ({variable.expr_id}) has no declared tolerance" + ) + if sensitivity is None: + lower = source_tolerance.lower_deviation + upper = source_tolerance.upper_deviation + sensitivity_value: float | None = None + else: + with localcontext() as context: + context.prec = 80 + decimal_sensitivity = _sensitivity_decimal(sensitivity) + deviations = ( + decimal_sensitivity + * Decimal.from_float(source_tolerance.lower_deviation), + decimal_sensitivity + * Decimal.from_float(source_tolerance.upper_deviation), + ) + lower_decimal, upper_decimal = min(deviations), max(deviations) + lower_value = _decimal_to_float( + lower_decimal, + label="Tolerance contribution lower deviation", + outward=True, + ) + upper_value = _decimal_to_float( + upper_decimal, + label="Tolerance contribution upper deviation", + outward=True, + ) + if lower_value is None or upper_value is None: + raise ToleranceAnalysisError( + "Tolerance contribution is outside the supported float range" + ) + lower, upper = lower_value, upper_value + sensitivity_value = _decimal_to_float( + decimal_sensitivity, + label="Tolerance sensitivity", + allow_unrepresentable=True, + ) + return ToleranceContribution( + variable_expr_id=variable.expr_id, + variable_name=variable.name, + nominal=variable.canonical_default, + source_tolerance=source_tolerance, + sensitivity=sensitivity_value, + lower_deviation=lower, + upper_deviation=upper, + source_unit=( + None + if variable.dimension is None + else canonical_unit_for_dimension(variable.dimension) + ), + ) + + +def _nonlinear_contribution( + expr: ScalarExpr, + variable: Var, + variables: Tuple[Var, ...], + nominal: float, +) -> ToleranceContribution: + source_tolerance = variable.canonical_tolerance + if source_tolerance is None: + raise ToleranceAnalysisError( + f"Variable '{variable.name}' ({variable.expr_id}) has no declared tolerance" + ) + if source_tolerance.width == 0.0: + return _contribution(variable, None) + variable_map = { + item.expr_id: ( + item + if item.expr_id == variable.expr_id + else replace(item, tolerance=DimensionTolerance.symmetric(0.0)) + ) + for item in variables + } + interval = _interval_value(expr, {}, variable_map) + return ToleranceContribution( + variable_expr_id=variable.expr_id, + variable_name=variable.name, + nominal=variable.canonical_default, + source_tolerance=source_tolerance, + sensitivity=None, + lower_deviation=interval.lower - nominal, + upper_deviation=interval.upper - nominal, + source_unit=( + None + if variable.dimension is None + else canonical_unit_for_dimension(variable.dimension) + ), + ) + + +def analyze_tolerance( + value: ScalarLike, *, method: ToleranceMethod = "worst_case" +) -> ToleranceAnalysis: + """Propagate source manufacturing tolerances through a scalar expression. + + ``worst_case`` returns guaranteed interval bounds. Affine chains are + dependency-aware, so repeated variables such as ``x - x`` cancel exactly; + nonlinear chains use conservative interval arithmetic. ``rss`` performs a + first-order root-sum-square calculation using analytic sensitivities. + + Unit-aware variables are converted to canonical CAD units before + propagation. The returned analysis reports the inferred physical dimension + and canonical result unit. Every variable in the expression must declare a + source tolerance. + """ + + resolved_method = _validate_method(method) + expr = lift_scalar(value) + _validate_expression_identity(expr) + result_dimension = infer_dimension(expr) + result_unit = ( + None + if result_dimension is None + else canonical_unit_for_dimension(result_dimension) + ) + variables = _variables(expr) + _require_source_tolerances(variables) + + # Both methods must reject undefined values anywhere inside the declared + # source intervals. RSS still reports a first-order estimate, but it must + # not legitimize a chain that crosses a singularity or function domain. + variable_map = {item.expr_id: item for item in variables} + try: + interval = _interval_value( + expr, + {}, + variable_map, + reject_atan2_branch_cut=resolved_method == "rss", + ) + except ToleranceAnalysisError: + raise + except Exception as exc: + raise ToleranceAnalysisError( + "Expression is undefined inside its declared tolerance interval" + ) from exc + + try: + nominal = float(expr.evaluate()) + except Exception as exc: + raise ToleranceAnalysisError( + "Expression cannot be evaluated at its nominal values" + ) from exc + if not math.isfinite(nominal): + raise ToleranceAnalysisError("Expression nominal value must be finite") + interval = _Interval( + min(interval.lower, nominal), max(interval.upper, nominal) + ) + + if resolved_method == "worst_case": + affine = _affine_form(expr, {}, {}) + if affine is not None: + contributions: List[ToleranceContribution] = [] + variable_by_id = {item.expr_id: item for item in variables} + for expr_id, coefficient in sorted(affine[1].items()): + contribution = _contribution(variable_by_id[expr_id], coefficient) + contributions.append(contribution) + lower_deviation = _finite_fsum( + [item.lower_deviation for item in contributions], + label="Worst-case lower deviation", + ) + upper_deviation = _finite_fsum( + [item.upper_deviation for item in contributions], + label="Worst-case upper deviation", + ) + lower_bound = _bound_from_deviation(nominal, lower_deviation) + upper_bound = _bound_from_deviation(nominal, upper_deviation) + else: + lower_bound, upper_bound = interval.lower, interval.upper + lower_deviation = min(0.0, lower_bound - nominal) + upper_deviation = max(0.0, upper_bound - nominal) + contributions = [ + _nonlinear_contribution(expr, item, variables, nominal) + for item in variables + ] + else: + with localcontext() as context: + context.prec = 100 + gradient_nominal, gradients = _gradient_value(expr, {}) + if not math.isclose(gradient_nominal, nominal, rel_tol=1e-12, abs_tol=1e-12): + raise ToleranceAnalysisError( + "Nominal expression evaluation disagrees with RSS differentiation" + ) + contributions = [ + _contribution(variable, gradients.get(variable.expr_id, Decimal(0))) + for variable in variables + ] + lower_magnitude = math.hypot( + *(item.lower_deviation for item in contributions) + ) + upper_magnitude = math.hypot( + *(item.upper_deviation for item in contributions) + ) + if not math.isfinite(lower_magnitude) or not math.isfinite(upper_magnitude): + raise ToleranceAnalysisError("RSS tolerance magnitude is non-finite") + lower_deviation = -lower_magnitude + upper_deviation = upper_magnitude + lower_bound = _bound_from_deviation(nominal, lower_deviation) + upper_bound = _bound_from_deviation(nominal, upper_deviation) + + return ToleranceAnalysis( + target_expr_id=expr.expr_id, + method=resolved_method, + nominal=nominal, + lower_bound=lower_bound, + upper_bound=upper_bound, + lower_deviation=lower_deviation, + upper_deviation=upper_deviation, + dimension=result_dimension, + unit=result_unit, + contributions=tuple(contributions), + ) + + +def _requirement_unit( + expr: ScalarExpr, tolerance_unit: UnitLike | None +) -> Tuple[Dimension | None, Unit | None]: + target_dimension = infer_dimension(expr) + if target_dimension is None: + if tolerance_unit is not None: + raise ValueError( + "A tolerance unit requires a unit-aware target expression" + ) + return None, None + if not target_dimension.is_design_dimension: + raise ValueError( + "Manufacturing tolerance requirements currently require a Length or " + f"Angle result; got {target_dimension.name}" + ) + resolved_unit = ( + canonical_unit_for_dimension(target_dimension) + if tolerance_unit is None + else get_unit(tolerance_unit) + ) + if resolved_unit.dimension != target_dimension: + raise ValueError( + f"Tolerance unit '{resolved_unit.symbol}' is incompatible with " + f"target dimension {target_dimension.name}" + ) + return target_dimension, resolved_unit + + +def check_tolerance( + value: ScalarLike, + tolerance: ToleranceLike, + *, + method: ToleranceMethod = "worst_case", + name: str | None = None, + tolerance_unit: UnitLike | None = None, +) -> ToleranceCheck: + """Propagate and verify one Length or Angle requirement. + + ``tolerance_unit`` defaults to the target dimension's canonical unit. When + provided, it must be dimensionally compatible and is converted before the + comparison. Legacy unitless requirements remain supported when no unit is + supplied. + """ + + expr = lift_scalar(value) + allowed = coerce_dimension_tolerance(tolerance) + target_dimension, resolved_tolerance_unit = _requirement_unit( + expr, tolerance_unit + ) + requirement = ToleranceRequirement( + requirement_id=f"tolreq_{uuid.uuid4().hex[:8]}", + target_expr_id=expr.expr_id, + tolerance=allowed, + method=_validate_method(method), + name=expr.expr_id if name is None else name, + tolerance_unit=resolved_tolerance_unit, + target_dimension=target_dimension, + ) + return _check_requirement(expr, requirement) + + +def _check_requirement( + expr: ScalarExpr, requirement: ToleranceRequirement +) -> ToleranceCheck: + analysis = analyze_tolerance(expr, method=requirement.method) + allowed = requirement.canonical_tolerance + lower_margin = analysis.lower_deviation - allowed.lower_deviation + upper_margin = allowed.upper_deviation - analysis.upper_deviation + lower_epsilon = ( + 0.0 + if allowed.lower_deviation == 0.0 + else 8.0 * math.ulp(abs(allowed.lower_deviation)) + ) + upper_epsilon = ( + 0.0 + if allowed.upper_deviation == 0.0 + else 8.0 * math.ulp(abs(allowed.upper_deviation)) + ) + passed = lower_margin >= -lower_epsilon and upper_margin >= -upper_epsilon + return ToleranceCheck( + requirement=requirement, + analysis=analysis, + passed=passed, + lower_margin=lower_margin, + upper_margin=upper_margin, + ) + + +class ToleranceGraph: + """Tolerance requirements attached to one expression graph.""" + + def __init__(self, expression_graph: ExpressionGraph) -> None: + if not isinstance(expression_graph, ExpressionGraph): + raise TypeError("ToleranceGraph requires an ExpressionGraph") + self.expression_graph = expression_graph + self._requirements: Dict[str, ToleranceRequirement] = {} + + @property + def requirement_count(self) -> int: + return len(self._requirements) + + @property + def requirements(self) -> Tuple[ToleranceRequirement, ...]: + return tuple(self._requirements.values()) + + def require( + self, + value: ScalarLike, + tolerance: ToleranceLike, + *, + method: ToleranceMethod = "worst_case", + name: str | None = None, + requirement_id: str | None = None, + tolerance_unit: UnitLike | None = None, + ) -> ToleranceRequirement: + """Declare a target tolerance and register its expression dependencies.""" + + resolved_id = ( + f"tolreq_{uuid.uuid4().hex[:8]}" + if requirement_id is None + else requirement_id + ) + if not isinstance(resolved_id, str) or not resolved_id: + raise ValueError("Tolerance requirement id must be a non-empty string") + if resolved_id in self._requirements: + raise ValueError(f"Duplicate tolerance requirement id '{resolved_id}'") + expr = lift_scalar(value) + target_dimension, resolved_tolerance_unit = _requirement_unit( + expr, tolerance_unit + ) + requirement = ToleranceRequirement( + requirement_id=resolved_id, + target_expr_id=expr.expr_id, + tolerance=coerce_dimension_tolerance(tolerance), + method=_validate_method(method), + name=(expr.name if isinstance(expr, Var) else expr.expr_id) + if name is None + else name, + tolerance_unit=resolved_tolerance_unit, + target_dimension=target_dimension, + ) + # Reject incomplete or mathematically invalid chains when declared. + _check_requirement(expr, requirement) + self.expression_graph.register(expr) + self._requirements[resolved_id] = requirement + return requirement + + def validate(self, *, raise_on_failure: bool = False) -> ToleranceReport: + checks: List[ToleranceCheck] = [] + for requirement in self._requirements.values(): + expr = self.expression_graph.get(requirement.target_expr_id) + if expr is None: + raise ToleranceAnalysisError( + f"Unknown tolerance target expression '{requirement.target_expr_id}'" + ) + checks.append(_check_requirement(expr, requirement)) + report = ToleranceReport(tuple(checks)) + if raise_on_failure and not report.passed: + raise ToleranceValidationError(report) + return report + + def to_dict(self) -> Dict[str, Any]: + return { + "requirements": [item.to_dict() for item in self._requirements.values()], + "validation": self.validate().to_dict(), + } + + @classmethod + def from_dict( + cls, data: Mapping[str, Any], expression_graph: ExpressionGraph + ) -> "ToleranceGraph": + if not isinstance(data, Mapping): + raise TypeError("Tolerance graph payload must be an object") + raw_requirements = data.get("requirements", []) + if not isinstance(raw_requirements, list): + raise TypeError("Tolerance graph 'requirements' must be an array") + graph = cls(expression_graph) + for raw in raw_requirements: + if not isinstance(raw, Mapping): + raise TypeError("Tolerance requirements must be objects") + target_expr_id = raw.get("target_expr_id") + if not isinstance(target_expr_id, str) or not target_expr_id: + raise ValueError( + "Tolerance requirement 'target_expr_id' must be a non-empty string" + ) + expr = expression_graph.get(target_expr_id) + if expr is None: + raise ValueError( + f"Unknown tolerance target expression '{target_expr_id}'" + ) + tolerance_payload = raw.get("tolerance") + if not isinstance(tolerance_payload, Mapping): + raise TypeError("Tolerance requirement 'tolerance' must be an object") + method = raw.get("method", "worst_case") + name = raw.get("name", target_expr_id) + requirement_id = raw.get("requirement_id") + has_tolerance_unit = "tolerance_unit" in raw + tolerance_unit_payload = raw.get("tolerance_unit") + has_target_dimension = "target_dimension" in raw + target_dimension_payload = raw.get("target_dimension") + if not isinstance(method, str): + raise TypeError("Tolerance requirement 'method' must be a string") + if not isinstance(name, str) or not name: + raise ValueError( + "Tolerance requirement 'name' must be a non-empty string" + ) + if not isinstance(requirement_id, str) or not requirement_id: + raise ValueError( + "Tolerance requirement 'requirement_id' must be a non-empty string" + ) + tolerance_unit = ( + None + if tolerance_unit_payload is None + else unit_from_payload(tolerance_unit_payload) + ) + declared_target_dimension = ( + None + if target_dimension_payload is None + else Dimension.from_dict(target_dimension_payload) + ) + requirement = graph.require( + expr, + DimensionTolerance.from_dict(tolerance_payload), + method=method, # type: ignore[arg-type] + name=name, + requirement_id=requirement_id, + tolerance_unit=tolerance_unit, + ) + if has_target_dimension and ( + requirement.target_dimension != declared_target_dimension + ): + raise ValueError( + "Tolerance requirement target_dimension does not match the " + "inferred target expression dimension" + ) + if has_tolerance_unit and requirement.tolerance_unit != tolerance_unit: + raise ValueError( + "Tolerance requirement tolerance_unit does not match the " + "inferred target expression dimension" + ) + return graph diff --git a/SimpleCADAPI/src/simplecadapi/topology.py b/SimpleCADAPI/src/simplecadapi/topology.py index ca5334a..5d5625d 100644 --- a/SimpleCADAPI/src/simplecadapi/topology.py +++ b/SimpleCADAPI/src/simplecadapi/topology.py @@ -15,6 +15,8 @@ from enum import Enum, auto from importlib import metadata as importlib_metadata from typing import Any, Dict, FrozenSet, List, Optional, Set, Tuple +from .source_mapping import canonical_source_payload + GRAPH_SCHEMA_VERSION = "2.0" @@ -37,8 +39,15 @@ def graph_capabilities_payload() -> Dict[str, Any]: "product_semantics": True, "assembly_graph": True, "topology_delta_summary": False, + "topology_delta_entries": True, + "durable_topology_parent_refs": True, + "operation_output_roles": True, "scalar_field_graph": False, "expression_graph": True, + "semantic_tag_bindings": True, + "tag_binding_schema": "1.0", + "source_mapping": True, + "source_mapping_schema": "1.0", } @@ -179,6 +188,32 @@ def topo_entry_from_dict(data: Dict[str, Any]) -> "TopoEntry": ) +def topo_role_entry_to_dict(entry: "TopoRoleEntry") -> Dict[str, Any]: + return { + "ref": topo_ref_to_dict(entry.ref), + "role": entry.role, + "origin_role": entry.origin_role, + "parent_refs": [topo_ref_to_dict(ref) for ref in entry.parent_refs], + "metadata": dict(entry.metadata), + } + + +def topo_role_entry_from_dict(data: Dict[str, Any]) -> "TopoRoleEntry": + return TopoRoleEntry( + ref=topo_ref_from_dict(data["ref"]), + role=str(data["role"]), + origin_role=( + str(data["origin_role"]) + if data.get("origin_role") is not None + else None + ), + parent_refs=tuple( + topo_ref_from_dict(item) for item in data.get("parent_refs", []) + ), + metadata=dict(data.get("metadata", {})), + ) + + def topo_delta_to_dict(delta: "TopoDelta") -> Dict[str, Any]: return { "preserved": [topo_ref_to_dict(ref) for ref in delta.preserved], @@ -187,6 +222,7 @@ def topo_delta_to_dict(delta: "TopoDelta") -> Dict[str, Any]: "deleted": [topo_ref_to_dict(ref) for ref in delta.deleted], "section_edges": [topo_ref_to_dict(ref) for ref in delta.section_edges], "entries": [topo_entry_to_dict(entry) for entry in delta.entries], + "roles": [topo_role_entry_to_dict(entry) for entry in delta.roles], "raw_event": dict(delta.raw_event), } @@ -201,6 +237,9 @@ def topo_delta_from_dict(data: Dict[str, Any]) -> "TopoDelta": topo_ref_from_dict(item) for item in data.get("section_edges", []) ), entries=tuple(topo_entry_from_dict(item) for item in data.get("entries", [])), + roles=tuple( + topo_role_entry_from_dict(item) for item in data.get("roles", []) + ), raw_event=dict(data.get("raw_event", {})), ) @@ -225,6 +264,28 @@ class TopoEntry: metadata: Dict[str, Any] = field(default_factory=dict) +@dataclass(frozen=True) +class TopoRoleEntry: + """Kernel-backed operation role assigned to one result entity. + + Output roles are independent of topology change events. For example, an + extrusion start cap can be identified by ``FirstShape`` without claiming + that OCC classified it as generated or modified. + """ + + ref: TopoRef + role: str + origin_role: Optional[str] = None + parent_refs: Tuple[TopoRef, ...] = () + metadata: Dict[str, Any] = field(default_factory=dict) + + def __post_init__(self) -> None: + role = str(self.role).strip().lower() + if not role or any(not part for part in role.split(".")): + raise ValueError("topology output role must be a non-empty dot token") + object.__setattr__(self, "role", role) + + @dataclass(frozen=True) class TopoDelta: """Complete topological change set for a single operation. @@ -241,6 +302,7 @@ class TopoDelta: section_edges: Edges created by boolean intersection (convenience subset of ``generated``). entries: Optional richer per-entity records with lineage. + roles: Operation-native output-role evidence, independent of events. raw_event: Opaque dict for transport of OCC-specific detail. """ @@ -250,6 +312,7 @@ class TopoDelta: deleted: Tuple[TopoRef, ...] = () section_edges: Tuple[TopoRef, ...] = () entries: Tuple[TopoEntry, ...] = () + roles: Tuple[TopoRoleEntry, ...] = () raw_event: Dict[str, Any] = field(default_factory=dict) @@ -293,8 +356,8 @@ def bind_topo_delta( def bind_ref(ref: TopoRef) -> TopoRef: return TopoRef( - graph_id=graph_id, - node_id=node_id, + graph_id=graph_id if ref.graph_id in {"", "pending"} else ref.graph_id, + node_id=node_id if ref.node_id in {"", "pending"} else ref.node_id, output_slot=ref.output_slot, kind=ref.kind, topo_id=ref.topo_id, @@ -316,6 +379,16 @@ def bind_topo_delta( ) for entry in delta.entries ), + roles=tuple( + TopoRoleEntry( + ref=bind_ref(entry.ref), + role=entry.role, + origin_role=entry.origin_role, + parent_refs=tuple(bind_ref(ref) for ref in entry.parent_refs), + metadata=dict(entry.metadata), + ) + for entry in delta.roles + ), raw_event=dict(delta.raw_event), ) @@ -333,6 +406,7 @@ class OperationNode: output_count: Number of output shapes this node produces (usually 1). topo_delta: Topological change set (may be ``None`` for simple primitives). tags: Free-form labels for annotation. + source: Best-effort Python source provenance; ignored by replay. """ node_id: str @@ -345,6 +419,8 @@ class OperationNode: semantic_delta: Optional[SemanticDelta] = None topo_delta: Optional[TopoDelta] = None tags: FrozenSet[str] = frozenset() + graph_id: Optional[str] = None + source: Optional[Dict[str, Any]] = None def _make_id(prefix: str = "node") -> str: @@ -385,6 +461,7 @@ def _node_display_category(op: str) -> str: "make_revolve_rsolid", "make_loft_rsolid", "make_sweep_rsolid", + "make_twisted_sweep_rsolid", }: return "feature" if op in { @@ -495,6 +572,7 @@ class OperationGraph: topo_delta: Optional[TopoDelta] = None, context: Optional[Dict[str, Any]] = None, tags: Optional[Set[str]] = None, + source: Optional[Dict[str, Any]] = None, ) -> OperationNode: """Add an operation node and wire its input edges. @@ -506,10 +584,19 @@ class OperationGraph: input_nodes = tuple(inputs) if inputs else () for inp in input_nodes: + if inp.graph_id is not None and inp.graph_id != self.graph_id: + raise ValueError( + f"input node '{inp.node_id}' belongs to graph " + f"'{inp.graph_id}', active graph is '{self.graph_id}'" + ) if inp.node_id not in self._nodes: raise ValueError( f"input node '{inp.node_id}' is not part of this graph" ) + if self._nodes[inp.node_id] is not inp: + raise ValueError( + f"input node '{inp.node_id}' is not the node owned by this graph" + ) bound_semantic_delta = bind_semantic_delta(semantic_delta, self.graph_id, nid) bound_topo_delta = bind_topo_delta(topo_delta, self.graph_id, nid) @@ -525,6 +612,8 @@ class OperationGraph: semantic_delta=bound_semantic_delta, topo_delta=bound_topo_delta, tags=frozenset(tags) if tags else frozenset(), + graph_id=self.graph_id, + source=dict(source) if source else None, ) self._nodes[nid] = node @@ -639,6 +728,8 @@ class OperationGraph: "tags": sorted(node.tags), "display": _node_display_payload(node.op, node.params), } + if node.source is not None: + node_data["source"] = canonical_source_payload(node.source) if node.param_exprs: node_data["param_exprs"] = dict(node.param_exprs) if node.context: @@ -696,6 +787,7 @@ class OperationGraph: ), context=nd.get("context"), tags=tags_set if tags_set else None, + source=nd.get("source"), ) node_map[nd["node_id"]] = node @@ -741,6 +833,8 @@ class OperationGraph: semantic_delta=node.semantic_delta, topo_delta=node.topo_delta, tags=node.tags, + graph_id=graph.graph_id, + source=dict(node.source) if node.source else None, ) return graph diff --git a/SimpleCADAPI/src/simplecadapi/tracking.py b/SimpleCADAPI/src/simplecadapi/tracking.py index 28f69ef..5250b93 100644 --- a/SimpleCADAPI/src/simplecadapi/tracking.py +++ b/SimpleCADAPI/src/simplecadapi/tracking.py @@ -14,21 +14,29 @@ Supported operations: from __future__ import annotations +from contextlib import contextmanager +from contextvars import ContextVar from dataclasses import dataclass, field +from enum import Enum +from functools import wraps import math -from typing import Any, Dict, List, Optional, Tuple +from typing import Any, Callable, Dict, Iterable, Iterator, List, Optional, Tuple, TypeVar from OCP.BRepAlgoAPI import ( BRepAlgoAPI_Cut, - BRepAlgoAPI_Fuse, BRepAlgoAPI_Common, BRepAlgoAPI_BooleanOperation, ) -from OCP.BOPAlgo import BOPAlgo_GlueOff, BOPAlgo_GlueShift +from OCP.BRepAdaptor import BRepAdaptor_Surface from OCP.BRepBuilderAPI import ( + BRepBuilderAPI_MakeEdge, BRepBuilderAPI_Transform, BRepBuilderAPI_MakeShape, + BRepBuilderAPI_MakeWire, ) +from OCP.BRepCheck import BRepCheck_Analyzer +from OCP.BRepFill import BRepFill_TypeOfContact +from OCP.BRepLib import BRepLib from OCP.BRepPrimAPI import BRepPrimAPI_MakePrism from OCP.BRepPrimAPI import BRepPrimAPI_MakeRevol from OCP.BRepFilletAPI import ( @@ -41,23 +49,78 @@ from OCP.BRepOffsetAPI import ( BRepOffsetAPI_ThruSections, ) from OCP.BRepOffset import BRepOffset_Skin -from OCP.GeomAbs import GeomAbs_Arc -from OCP.gp import gp_Vec +from OCP.GeomAbs import GeomAbs_Arc, GeomAbs_Plane +from OCP.GCE2d import GCE2d_MakeSegment +from OCP.Geom import Geom_CylindricalSurface +from OCP.gp import gp_Ax3, gp_Dir, gp_Pnt, gp_Pnt2d, gp_Vec from OCP.TopTools import TopTools_ListOfShape -from OCP.TopExp import TopExp_Explorer -from OCP.TopAbs import TopAbs_FACE, TopAbs_EDGE, TopAbs_VERTEX +from OCP.TopExp import TopExp, TopExp_Explorer +from OCP.TopAbs import ( + TopAbs_COMPOUND, + TopAbs_EDGE, + TopAbs_FACE, + TopAbs_SOLID, + TopAbs_VERTEX, + TopAbs_WIRE, +) from OCP.TopoDS import TopoDS from .core import Solid, Face, Edge, Vertex +from .kernel.ocp_booleans import fuse_shapes_with_history, solids_of +from .kernel.ocp_builders import ( + build_box_primitive, + build_cone_primitive, + build_cylinder_primitive, +) from .topology import ( TopoKind, TopoEvent, TopoRef, TopoDelta, + TopoEntry, + TopoRoleEntry, _make_id, ) +class TrackingPolicy(str, Enum): + """Controls how much topology provenance an operation computes.""" + + FULL = "full" + GRAPH = "graph" + + +_tracking_policy: ContextVar[TrackingPolicy] = ContextVar( + "simplecad_tracking_policy", + default=TrackingPolicy.FULL, +) +_TrackedFactory = TypeVar("_TrackedFactory", bound=Callable[..., Any]) + + +def current_tracking_policy() -> TrackingPolicy: + return _tracking_policy.get() + + +@contextmanager +def tracking_policy_scope(policy: TrackingPolicy | str) -> Iterator[None]: + token = _tracking_policy.set(TrackingPolicy(policy)) + try: + yield + finally: + _tracking_policy.reset(token) + + +def graph_tracking_scope(factory: _TrackedFactory) -> _TrackedFactory: + """Run a standard-part factory with graph-only intermediate tracking.""" + + @wraps(factory) + def wrapped(*args: Any, **kwargs: Any) -> Any: + with tracking_policy_scope(TrackingPolicy.GRAPH): + return factory(*args, **kwargs) + + return wrapped # type: ignore[return-value] + + @dataclass class TrackedBooleanResult: """Result of a tracked boolean operation. @@ -97,6 +160,22 @@ def _iter_subshapes(shape, shape_type: int): explorer.Next() +def _shape_kind(shape, fallback: TopoKind) -> TopoKind: + """Return the actual topology kind of a kernel history output.""" + + try: + return { + TopAbs_VERTEX: TopoKind.VERTEX, + TopAbs_EDGE: TopoKind.EDGE, + TopAbs_WIRE: TopoKind.WIRE, + TopAbs_FACE: TopoKind.FACE, + TopAbs_SOLID: TopoKind.SOLID, + TopAbs_COMPOUND: TopoKind.COMPOUND, + }.get(shape.ShapeType(), fallback) + except Exception: + return fallback + + def _tolist_oftools(shapes) -> TopTools_ListOfShape: """Convert a Python list of TopoDS_Shape to TopTools_ListOfShape.""" tl = TopTools_ListOfShape() @@ -105,14 +184,54 @@ def _tolist_oftools(shapes) -> TopTools_ListOfShape: return tl -def _query_history( - builder: BRepAlgoAPI_BooleanOperation, - input_solid, +def _is_same_shape(left: Any, right: Any) -> bool: + try: + return bool(left.IsSame(right)) + except Exception: + return False + + +def _is_result_member(result_shape: Any, candidate: Any) -> bool: + """Return whether ``candidate`` is an exact subshape of ``result_shape``.""" + + try: + if candidate.IsNull(): + return False + except Exception: + return False + if _is_same_shape(result_shape, candidate): + return True + try: + shape_type = candidate.ShapeType() + except Exception: + return False + return any(_is_same_shape(item, candidate) for item in _iter_subshapes(result_shape, shape_type)) + + +def _dedupe_shapes(shapes: Iterable[Any]) -> List[Any]: + result: List[Any] = [] + for shape in shapes: + if not any(_is_same_shape(shape, existing) for existing in result): + result.append(shape) + return result + + +def _query_exact_history( + builder: Any, + input_shapes: Iterable[Any], graph_id: str, node_id: str, origin_role: str, kind: TopoKind, - shape_type: int, + *, + modified_derivation: str = "fragment", + generated_derivation: str = "boundary", + result_shape: Any = None, + result_role: Optional[str] = None, + preserved_result_role: Optional[str] = None, + modified_result_role: Optional[str] = None, + generated_result_role: Optional[str] = None, + project_source_tags: bool = False, ) -> Tuple[ List[TopoRef], List[TopoRef], List[TopoRef], List[TopoRef], List[Dict[str, Any]] ]: @@ -121,28 +240,38 @@ def _query_history( Returns five lists: ``preserved, modified, generated, deleted`` as ``TopoRef`` lists, plus a list of per-entity metadata dicts. """ + if current_tracking_policy() == TrackingPolicy.GRAPH: + return [], [], [], [], [] + preserved: List[TopoRef] = [] modified: List[TopoRef] = [] generated: List[TopoRef] = [] deleted: List[TopoRef] = [] entries: List[Dict[str, Any]] = [] - for sub in _iter_subshapes(input_solid, shape_type): + for sub in _dedupe_shapes(input_shapes): input_id = _topo_id(sub) - if builder.IsDeleted(sub): + if hasattr(builder, "IsDeleted"): + is_deleted = bool(builder.IsDeleted(sub)) + else: + is_deleted = bool(builder.IsRemoved(sub)) + if is_deleted: ref = TopoRef(graph_id, node_id, 0, kind, input_id) deleted.append(ref) entries.append( { "topo_id": input_id, "event": "deleted", + "kind": kind.name, + "source_kind": kind.name, "origin_role": origin_role, "input_topo_id": input_id, + "derivation": "replacement", + "coverage": "complete", + "status": "proven", } ) - continue - mod_list = builder.Modified(sub) gen_list = builder.Generated(sub) @@ -159,66 +288,464 @@ def _query_history( pass # Case 1: No modifications, no generations -> PRESERVED - if mod_size == 0 and gen_size == 0: + source_survives = result_shape is None or _is_result_member(result_shape, sub) + if not is_deleted and mod_size == 0 and gen_size == 0 and source_survives: ref = TopoRef(graph_id, node_id, 0, kind, input_id) preserved.append(ref) - entries.append( - { - "topo_id": input_id, - "event": "preserved", - "origin_role": origin_role, - "input_topo_id": input_id, - } - ) + entry = { + "topo_id": input_id, + "event": "preserved", + "kind": kind.name, + "source_kind": kind.name, + "origin_role": origin_role, + "input_topo_id": input_id, + "derivation": "continuation", + "coverage": "complete", + "status": "proven", + } + role = preserved_result_role or result_role + if role is not None: + entry["result_role"] = role + if project_source_tags: + entry["project_source_tags"] = True + entries.append(entry) continue # Case 2: Modified returns the same shape and no generations -> PRESERVED - if same_shape_in_mod and gen_size == 0: + if not is_deleted and same_shape_in_mod and gen_size == 0: ref = TopoRef(graph_id, node_id, 0, kind, input_id) preserved.append(ref) - entries.append( - { - "topo_id": input_id, - "event": "preserved", - "origin_role": origin_role, - "input_topo_id": input_id, - } - ) + entry = { + "topo_id": input_id, + "event": "preserved", + "kind": kind.name, + "source_kind": kind.name, + "origin_role": origin_role, + "input_topo_id": input_id, + "derivation": "continuation", + "coverage": "complete", + "status": "proven", + } + role = preserved_result_role or result_role + if role is not None: + entry["result_role"] = role + if project_source_tags: + entry["project_source_tags"] = True + entries.append(entry) continue # Case 3: Has modifications (different or multiple shapes) -> MODIFIED if mod_size > 0 and not same_shape_in_mod: for mod_shape in mod_list: + if result_shape is not None and not _is_result_member( + result_shape, mod_shape + ): + continue mod_id = _topo_id(mod_shape) - ref = TopoRef(graph_id, node_id, 0, kind, mod_id) + output_kind = _shape_kind(mod_shape, kind) + ref = TopoRef(graph_id, node_id, 0, output_kind, mod_id) modified.append(ref) - entries.append( - { + entry = { "topo_id": mod_id, "event": "modified", + "kind": output_kind.name, + "source_kind": kind.name, "origin_role": origin_role, "input_topo_id": input_id, + "derivation": modified_derivation, + "coverage": "complete", + "status": "proven", } - ) + role = modified_result_role or result_role + if role is not None: + entry["result_role"] = role + if project_source_tags: + entry["project_source_tags"] = True + entries.append(entry) # Case 4: Has generated new shapes -> GENERATED if gen_size > 0: for gen_shape in gen_list: + if result_shape is not None and not _is_result_member( + result_shape, gen_shape + ): + continue gen_id = _topo_id(gen_shape) - ref = TopoRef(graph_id, node_id, 0, kind, gen_id) + output_kind = _shape_kind(gen_shape, kind) + ref = TopoRef(graph_id, node_id, 0, output_kind, gen_id) generated.append(ref) - entries.append( - { + entry = { "topo_id": gen_id, "event": "generated", + "kind": output_kind.name, + "source_kind": kind.name, "origin_role": origin_role, "input_topo_id": input_id, + "derivation": generated_derivation, + "coverage": "complete", + "status": "proven", } - ) + role = generated_result_role or result_role + if role is not None: + entry["result_role"] = role + if project_source_tags: + entry["project_source_tags"] = True + entries.append(entry) return preserved, modified, generated, deleted, entries +def _query_history( + builder: Any, + input_solid, + graph_id: str, + node_id: str, + origin_role: str, + kind: TopoKind, + shape_type: int, + **kwargs: Any, +) -> Tuple[ + List[TopoRef], List[TopoRef], List[TopoRef], List[TopoRef], List[Dict[str, Any]] +]: + return _query_exact_history( + builder, + _iter_subshapes(input_solid, shape_type), + graph_id, + node_id, + origin_role, + kind, + **kwargs, + ) + + +def _query_shape_list_history( + builder: BRepBuilderAPI_MakeShape, + shapes: Iterable[Any], + graph_id: str, + node_id: str, + origin_role: str, + kind: TopoKind, + *, + result_shape: Any, + modified_derivation: str = "fragment", + generated_derivation: str = "boundary", + result_role: Optional[str] = None, + project_source_tags: bool = False, +) -> Tuple[ + List[TopoRef], List[TopoRef], List[TopoRef], List[TopoRef], List[Dict[str, Any]] +]: + """Query history for an exact shape list without topology traversal.""" + + return _query_exact_history( + builder, + shapes, + graph_id, + node_id, + origin_role, + kind, + modified_derivation=modified_derivation, + generated_derivation=generated_derivation, + result_shape=result_shape, + result_role=result_role, + project_source_tags=project_source_tags, + ) + + +def kind_to_topabs(kind: TopoKind) -> int: + return { + TopoKind.VERTEX: TopAbs_VERTEX, + TopoKind.EDGE: TopAbs_EDGE, + TopoKind.WIRE: TopAbs_WIRE, + TopoKind.FACE: TopAbs_FACE, + TopoKind.SOLID: TopAbs_SOLID, + TopoKind.COMPOUND: TopAbs_COMPOUND, + }[kind] + + +def _operation_role( + candidate: Any, + *, + result_shape: Any, + graph_id: str, + node_id: str, + role: str, + evidence_method: str, + origin_role: Optional[str] = None, + source_shape: Any = None, + source_kind: Optional[TopoKind] = None, +) -> Optional[Dict[str, Any]]: + if not _is_result_member(result_shape, candidate): + return None + output_kind = _shape_kind(candidate, TopoKind.FACE) + payload: Dict[str, Any] = { + "topo_id": _topo_id(candidate), + "kind": output_kind.name, + "role": role, + "origin_role": origin_role, + "coverage": "complete", + "status": "proven", + "evidence_kind": "kernel_operation_role", + "evidence_method": evidence_method, + } + if source_shape is not None: + payload["input_topo_id"] = _topo_id(source_shape) + payload["source_kind"] = (source_kind or _shape_kind(source_shape, output_kind)).name + return payload + + +def _canonical_topo_roles( + roles: List[Dict[str, Any]], *, graph_id: str, node_id: str +) -> Tuple[TopoRoleEntry, ...]: + result: List[TopoRoleEntry] = [] + seen = set() + for item in roles: + try: + output_kind = TopoKind[str(item["kind"]).upper()] + output_id = str(item["topo_id"]) + role = str(item["role"]) + except (KeyError, TypeError, ValueError): + continue + source_kind_name = item.get("source_kind") + source_id = item.get("input_topo_id") + parents: Tuple[TopoRef, ...] = () + if source_id is not None and source_kind_name is not None: + try: + source_kind = TopoKind[str(source_kind_name).upper()] + except KeyError: + continue + parents = ( + TopoRef(graph_id, node_id, 0, source_kind, str(source_id)), + ) + marker = (output_kind, output_id, role, parents) + if marker in seen: + continue + seen.add(marker) + result.append( + TopoRoleEntry( + ref=TopoRef(graph_id, node_id, 0, output_kind, output_id), + role=role, + origin_role=( + str(item["origin_role"]) + if item.get("origin_role") is not None + else None + ), + parent_refs=parents, + metadata={ + "coverage": str(item.get("coverage", "complete")), + "status": str(item.get("status", "proven")), + "evidence_kind": str( + item.get("evidence_kind", "kernel_operation_role") + ), + "evidence_method": str(item.get("evidence_method", "unknown")), + **( + {"source_kind": str(source_kind_name).upper()} + if source_kind_name is not None + else {} + ), + }, + ) + ) + return tuple(result) + + +def _roles_from_history( + entries: Iterable[Dict[str, Any]], *, evidence_method: str +) -> List[Dict[str, Any]]: + roles: List[Dict[str, Any]] = [] + for item in entries: + role = item.get("result_role") + if role is None: + continue + roles.append( + { + "topo_id": item["topo_id"], + "kind": item["kind"], + "role": role, + "origin_role": item.get("origin_role"), + "input_topo_id": item.get("input_topo_id"), + "source_kind": item.get("source_kind"), + "coverage": item.get("coverage", "complete"), + "status": item.get("status", "proven"), + "evidence_kind": "kernel_operation_role", + "evidence_method": evidence_method, + } + ) + return roles + + +def _actual_contour_edges(builder: Any, seeds: Iterable[Edge]) -> List[Any]: + """Expand fillet/chamfer seed edges to the contours OCC actually used.""" + + result: List[Any] = [] + seen_contours = set() + for seed in seeds: + contour = int(builder.Contour(seed.wrapped)) + if contour <= 0 or contour in seen_contours: + continue + seen_contours.add(contour) + for index in range(1, int(builder.NbEdges(contour)) + 1): + result.append(builder.Edge(contour, index)) + return _dedupe_shapes(result) + + +def _canonical_topo_entries( + entries: List[Dict[str, Any]], *, graph_id: str, node_id: str +) -> Tuple[TopoEntry, ...]: + """Convert kernel-history witnesses into the canonical typed delta model.""" + + event_map = { + "preserved": TopoEvent.PRESERVED, + "modified": TopoEvent.MODIFIED, + "generated": TopoEvent.GENERATED, + "deleted": TopoEvent.DELETED, + } + result: List[TopoEntry] = [] + for item in entries: + event = event_map.get(str(item.get("event", "")).lower()) + if event is None: + continue + try: + output_kind = TopoKind[str(item.get("kind", "FACE")).upper()] + source_kind = TopoKind[ + str(item.get("source_kind", output_kind.name)).upper() + ] + output_id = str(item["topo_id"]) + except (KeyError, TypeError, ValueError): + continue + + input_id = item.get("input_topo_id") + parents = ( + ( + TopoRef( + graph_id=graph_id, + node_id=node_id, + output_slot=0, + kind=source_kind, + topo_id=str(input_id), + ), + ) + if input_id is not None + else () + ) + result.append( + TopoEntry( + ref=TopoRef(graph_id, node_id, 0, output_kind, output_id), + event=event, + origin_role=( + str(item["origin_role"]) + if item.get("origin_role") is not None + else None + ), + parent_refs=parents, + metadata={ + "derivation": str(item.get("derivation", "unknown")), + "coverage": str(item.get("coverage", "complete")), + "status": str(item.get("status", "proven")), + "evidence_kind": "kernel_history", + "source_kind": source_kind.name, + **( + {"result_role": str(item["result_role"])} + if item.get("result_role") is not None + else {} + ), + **( + {"project_source_tags": True} + if item.get("project_source_tags") + else {} + ), + }, + ) + ) + return tuple(result) + + +def _aggregate_delta_entries( + entries: List[Dict[str, Any]], + roles: Optional[List[Dict[str, Any]]] = None, +) -> Dict[str, Dict[str, Any]]: + """Build the compatibility lookup without discarding multi-source witnesses.""" + + result: Dict[str, Dict[str, Any]] = {} + for item in entries: + topo_id = str(item["topo_id"]) + witness = dict(item) + aggregate = result.setdefault( + topo_id, + { + "topo_id": topo_id, + "kind": item.get("kind"), + "event": item.get("event"), + "origin_role": item.get("origin_role"), + "input_topo_id": item.get("input_topo_id"), + "derivation": item.get("derivation", "unknown"), + "coverage": item.get("coverage", "complete"), + "status": item.get("status", "proven"), + "witnesses": [], + }, + ) + aggregate["witnesses"].append(witness) + aggregate["events"] = sorted( + { + str(entry["event"]) + for entry in aggregate["witnesses"] + if entry.get("event") is not None + } + ) + aggregate["origin_roles"] = sorted( + { + str(entry["origin_role"]) + for entry in aggregate["witnesses"] + if entry.get("origin_role") is not None + } + ) + if any( + str(entry.get("coverage", "complete")) != "complete" + for entry in aggregate["witnesses"] + ): + aggregate["coverage"] = "partial" + if any( + str(entry.get("status", "proven")) != "proven" + for entry in aggregate["witnesses"] + ): + aggregate["status"] = "unknown" + for item in roles or []: + topo_id = str(item["topo_id"]) + role_witness = { + "kind": item.get("kind"), + "result_role": item.get("role"), + "origin_role": item.get("origin_role"), + "input_topo_id": item.get("input_topo_id"), + "source_kind": item.get("source_kind"), + "coverage": item.get("coverage", "complete"), + "status": item.get("status", "proven"), + "evidence_kind": item.get("evidence_kind", "kernel_operation_role"), + "evidence_method": item.get("evidence_method"), + } + aggregate = result.setdefault( + topo_id, + { + "topo_id": topo_id, + "kind": item.get("kind"), + "event": None, + "origin_role": item.get("origin_role"), + "input_topo_id": item.get("input_topo_id"), + "derivation": "unknown", + "coverage": item.get("coverage", "complete"), + "status": item.get("status", "proven"), + "witnesses": [], + }, + ) + aggregate["witnesses"].append(role_witness) + aggregate["result_roles"] = sorted( + { + str(witness["result_role"]) + for witness in aggregate["witnesses"] + if witness.get("result_role") is not None + } + ) + return result + + def _collect_section_edges( builder: BRepAlgoAPI_BooleanOperation, graph_id: str, @@ -266,6 +793,8 @@ def _build_boolean_result( "body", TopoKind.FACE, TopAbs_FACE, + result_shape=result_shape, + project_source_tags=True, ) # Query face-level history for tool t_pres, t_mod, t_gen, t_del, t_entries = _query_history( @@ -276,6 +805,8 @@ def _build_boolean_result( "tool", TopoKind.FACE, TopAbs_FACE, + result_shape=result_shape, + project_source_tags=True, ) # Section edges @@ -287,12 +818,12 @@ def _build_boolean_result( generated=tuple(b_gen + t_gen), deleted=tuple(b_del + t_del), section_edges=tuple(section_edges), + entries=_canonical_topo_entries( + b_entries + t_entries, graph_id=graph_id, node_id=node_id + ), ) - # Build per-entity metadata - all_entries: Dict[str, Dict[str, Any]] = {} - for e in b_entries + t_entries: - all_entries[e["topo_id"]] = e + all_entries = _aggregate_delta_entries(b_entries + t_entries) return TrackedBooleanResult( solid=result_solid, @@ -350,29 +881,84 @@ def tracked_union( Returns: :class:`TrackedBooleanResult` with the fused solid and topological delta. """ - fuse_op = BRepAlgoAPI_Fuse() - fuse_op.SetRunParallel(True) - fuse_op.SetUseOBB(True) - fuse_op.SetToFillHistory(True) - fuse_op.SetGlue(BOPAlgo_GlueShift if glue else BOPAlgo_GlueOff) - if tol is not None: - fuse_op.SetFuzzyValue(float(tol)) + fused = fuse_shapes_with_history( + [body.wrapped, tool.wrapped], + glue=glue, + tol=tol, + clean=False, + ) + result_solids = solids_of(fused.shape) + if len(result_solids) != 1: + raise ValueError("Boolean union did not produce exactly one solid") + return track_union_history( + [body, tool], + Solid(result_solids[0]), + fused.history, + fused.section_edges, + ) - args = TopTools_ListOfShape() - args.Append(body.wrapped) - tools = TopTools_ListOfShape() - tools.Append(tool.wrapped) - fuse_op.SetArguments(args) - fuse_op.SetTools(tools) - fuse_op.Build() +def track_union_history( + solids: Iterable[Solid], + result_solid: Solid, + history: Any, + section_edges: Iterable[Any] = (), +) -> TrackedBooleanResult: + """Build one face-level union delta from retained N-ary OCC history.""" - if not fuse_op.IsDone(): - raise ValueError("Boolean union failed: OCC build did not complete") + inputs = list(solids) + if len(inputs) < 2: + raise ValueError("Union history tracking requires at least two solids") - result = _build_boolean_result(fuse_op, body, tool, "union") - # Add section edges from the builder - return result + graph_id = _make_id("g") + node_id = _make_id("n") + preserved: List[TopoRef] = [] + modified: List[TopoRef] = [] + generated: List[TopoRef] = [] + deleted: List[TopoRef] = [] + history_entries: List[Dict[str, Any]] = [] + + for index, solid in enumerate(inputs): + origin_role = "body" if index == 0 else ("tool" if index == 1 else f"tool_{index}") + pres, mod, gen, del_, entries = _query_history( + history, + solid.wrapped, + graph_id, + node_id, + origin_role, + TopoKind.FACE, + TopAbs_FACE, + result_shape=result_solid.wrapped, + project_source_tags=True, + ) + preserved.extend(pres) + modified.extend(mod) + generated.extend(gen) + deleted.extend(del_) + history_entries.extend(entries) + + section_refs = tuple( + TopoRef(graph_id, node_id, 0, TopoKind.EDGE, _topo_id(edge)) + for edge in _dedupe_shapes(section_edges) + if _is_result_member(result_solid.wrapped, edge) + ) + delta = TopoDelta( + preserved=tuple(preserved), + modified=tuple(modified), + generated=tuple(generated), + deleted=tuple(deleted), + section_edges=section_refs, + entries=_canonical_topo_entries( + history_entries, + graph_id=graph_id, + node_id=node_id, + ), + ) + return TrackedBooleanResult( + solid=result_solid, + delta=delta, + delta_entries=_aggregate_delta_entries(history_entries), + ) def tracked_intersect(body: Solid, tool: Solid) -> TrackedBooleanResult: @@ -431,47 +1017,30 @@ def _query_single_shape_history( graph_id: str, node_id: str, op: str, - force_preserved: bool = False, ) -> Tuple[TopoDelta, Dict[str, Dict[str, Any]]]: - """Query history for a single-input operation (transforms, extrude, fillet…). - - Args: - force_preserved: If True, treat all input faces as PRESERVED regardless - of what OCC reports. Useful for pure transforms that create new - TShape copies but don't actually change topology. - """ - if force_preserved: - pres: List[TopoRef] = [] - entries: List[Dict[str, Any]] = [] - for sub in _iter_subshapes(input_solid, TopAbs_FACE): - input_id = _topo_id(sub) - ref = TopoRef(graph_id, node_id, 0, TopoKind.FACE, input_id) - pres.append(ref) - entries.append( - { - "topo_id": input_id, - "event": "preserved", - "origin_role": "body", - "input_topo_id": input_id, - } - ) - delta = TopoDelta(preserved=tuple(pres)) - all_entries: Dict[str, Dict[str, Any]] = {e["topo_id"]: e for e in entries} - return delta, all_entries - + """Query per-face kernel history for a single-input operation.""" + continuation = op in {"translate", "rotate", "mirror"} pres, mod, gen, del_, entries = _query_history( - builder, input_solid, graph_id, node_id, "body", TopoKind.FACE, TopAbs_FACE + builder, + input_solid, + graph_id, + node_id, + "body", + TopoKind.FACE, + TopAbs_FACE, + modified_derivation="continuation" if continuation else "fragment", + result_shape=builder.Shape(), ) delta = TopoDelta( preserved=tuple(pres), modified=tuple(mod), generated=tuple(gen), deleted=tuple(del_), + entries=_canonical_topo_entries( + entries, graph_id=graph_id, node_id=node_id + ), ) - all_entries: Dict[str, Dict[str, Any]] = {} - for e in entries: - all_entries[e["topo_id"]] = e - return delta, all_entries + return delta, _aggregate_delta_entries(entries) # --------------------------------------------------------------------------- @@ -516,7 +1085,6 @@ def tracked_translate( graph_id, node_id, "translate", - force_preserved=True, ) return TrackedResult(shape=result_solid, delta=delta, delta_entries=entries) @@ -566,7 +1134,6 @@ def tracked_rotate( graph_id, node_id, "rotate", - force_preserved=True, ) return TrackedResult(shape=result_solid, delta=delta, delta_entries=entries) @@ -606,7 +1173,6 @@ def tracked_mirror( graph_id, node_id, "mirror", - force_preserved=True, ) return TrackedResult(shape=result_solid, delta=delta, delta_entries=entries) @@ -617,6 +1183,114 @@ def tracked_mirror( # --------------------------------------------------------------------------- +def tracked_box( + corner: Tuple[float, float, float], + width: float, + height: float, + depth: float, +) -> TrackedResult: + """Build a box with operation-native Face role witnesses.""" + + graph_id = _make_id("g") + node_id = _make_id("n") + built = build_box_primitive(corner, width, height, depth) + result_solid = Solid(built.solid) + roles: List[Dict[str, Any]] = [] + for witness in built.roles: + role_entry = _operation_role( + witness.shape, + result_shape=result_solid.wrapped, + graph_id=graph_id, + node_id=node_id, + role=witness.role, + evidence_method=witness.evidence_method, + ) + if role_entry is not None: + roles.append(role_entry) + + delta = TopoDelta( + roles=_canonical_topo_roles(roles, graph_id=graph_id, node_id=node_id) + ) + return TrackedResult( + shape=result_solid, + delta=delta, + delta_entries=_aggregate_delta_entries([], roles), + ) + + +def tracked_cylinder( + origin: Tuple[float, float, float], + axis: Tuple[float, float, float], + radius: float, + height: float, +) -> TrackedResult: + """Build a cylinder with operation-native Face and Edge role witnesses.""" + + graph_id = _make_id("g") + node_id = _make_id("n") + built = build_cylinder_primitive(origin, axis, radius, height) + result_solid = Solid(built.solid) + roles: List[Dict[str, Any]] = [] + for witness in built.roles: + role_entry = _operation_role( + witness.shape, + result_shape=result_solid.wrapped, + graph_id=graph_id, + node_id=node_id, + role=witness.role, + evidence_method=witness.evidence_method, + ) + if role_entry is not None: + roles.append(role_entry) + + delta = TopoDelta( + roles=_canonical_topo_roles(roles, graph_id=graph_id, node_id=node_id) + ) + return TrackedResult( + shape=result_solid, + delta=delta, + delta_entries=_aggregate_delta_entries([], roles), + ) + + +def tracked_cone( + origin: Tuple[float, float, float], + axis: Tuple[float, float, float], + bottom_radius: float, + top_radius: float, + height: float, +) -> TrackedResult: + """Build a cone with operation-native Face and Edge role witnesses.""" + + graph_id = _make_id("g") + node_id = _make_id("n") + built = build_cone_primitive( + origin, axis, bottom_radius, top_radius, height + ) + result_solid = Solid(built.solid) + roles: List[Dict[str, Any]] = [] + for witness in built.roles: + role_entry = _operation_role( + witness.shape, + result_shape=result_solid.wrapped, + graph_id=graph_id, + node_id=node_id, + role=witness.role, + evidence_method=witness.evidence_method, + ) + if role_entry is not None: + roles.append(role_entry) + + delta = TopoDelta( + roles=_canonical_topo_roles(roles, graph_id=graph_id, node_id=node_id) + ) + return TrackedResult( + shape=result_solid, + delta=delta, + delta_entries=_aggregate_delta_entries([], roles), + ) + + def tracked_extrude( profile: Face, direction: Tuple[float, float, float], distance: float ) -> TrackedResult: @@ -648,11 +1322,51 @@ def tracked_extrude( result_solid = Solid(prism.Shape()) - delta, entries = _query_single_shape_history( - prism, profile.wrapped, graph_id, node_id, "extrude" + e_pres, e_mod, e_gen, e_del, edge_entries = _query_history( + prism, + profile.wrapped, + graph_id, + node_id, + "profile", + TopoKind.EDGE, + TopAbs_EDGE, + result_shape=result_solid.wrapped, + generated_result_role="extrusion.side", + project_source_tags=True, + ) + roles: List[Dict[str, Any]] = _roles_from_history( + edge_entries, evidence_method="Generated" + ) + for candidate, role, method in ( + (prism.FirstShape(profile.wrapped), "extrusion.start", "FirstShape"), + (prism.LastShape(profile.wrapped), "extrusion.end", "LastShape"), + ): + role_entry = _operation_role( + candidate, + result_shape=result_solid.wrapped, + graph_id=graph_id, + node_id=node_id, + role=role, + evidence_method=method, + origin_role="profile", + ) + if role_entry is not None: + roles.append(role_entry) + delta = TopoDelta( + preserved=tuple(e_pres), + modified=tuple(e_mod), + generated=tuple(e_gen), + deleted=tuple(e_del), + entries=_canonical_topo_entries( + edge_entries, graph_id=graph_id, node_id=node_id + ), + roles=_canonical_topo_roles(roles, graph_id=graph_id, node_id=node_id), + ) + return TrackedResult( + shape=result_solid, + delta=delta, + delta_entries=_aggregate_delta_entries(edge_entries, roles), ) - - return TrackedResult(shape=result_solid, delta=delta, delta_entries=entries) def tracked_revolve( @@ -682,11 +1396,49 @@ def tracked_revolve( result_solid = Solid(revolve_op.Shape()) - delta, entries = _query_single_shape_history( - revolve_op, profile.wrapped, graph_id, node_id, "revolve" + e_pres, e_mod, e_gen, e_del, edge_entries = _query_history( + revolve_op, + profile.wrapped, + graph_id, + node_id, + "profile", + TopoKind.EDGE, + TopAbs_EDGE, + result_shape=result_solid.wrapped, + generated_result_role="revolution.side", + project_source_tags=True, + ) + roles = _roles_from_history(edge_entries, evidence_method="Generated") + for candidate, role, method in ( + (revolve_op.FirstShape(profile.wrapped), "revolution.start", "FirstShape"), + (revolve_op.LastShape(profile.wrapped), "revolution.end", "LastShape"), + ): + role_entry = _operation_role( + candidate, + result_shape=result_solid.wrapped, + graph_id=graph_id, + node_id=node_id, + role=role, + evidence_method=method, + origin_role="profile", + ) + if role_entry is not None: + roles.append(role_entry) + delta = TopoDelta( + preserved=tuple(e_pres), + modified=tuple(e_mod), + generated=tuple(e_gen), + deleted=tuple(e_del), + entries=_canonical_topo_entries( + edge_entries, graph_id=graph_id, node_id=node_id + ), + roles=_canonical_topo_roles(roles, graph_id=graph_id, node_id=node_id), + ) + return TrackedResult( + shape=result_solid, + delta=delta, + delta_entries=_aggregate_delta_entries(edge_entries, roles), ) - - return TrackedResult(shape=result_solid, delta=delta, delta_entries=entries) def tracked_fillet(solid: Solid, edges: List[Edge], radius: float) -> TrackedResult: @@ -713,11 +1465,36 @@ def tracked_fillet(solid: Solid, edges: List[Edge], radius: float) -> TrackedRes result_solid = Solid(fillet_op.Shape()) - delta, entries = _query_single_shape_history( + delta, face_entries = _query_single_shape_history( fillet_op, solid.wrapped, graph_id, node_id, "fillet" ) - - return TrackedResult(shape=result_solid, delta=delta, delta_entries=entries) + contour_edges = _actual_contour_edges(fillet_op, edges) + e_pres, e_mod, e_gen, e_del, edge_entries = _query_exact_history( + fillet_op, + contour_edges, + graph_id, + node_id, + "contour_edge", + TopoKind.EDGE, + result_shape=result_solid.wrapped, + generated_result_role="fillet.patch", + project_source_tags=True, + ) + roles = _roles_from_history(edge_entries, evidence_method="Generated") + delta = TopoDelta( + preserved=tuple((*delta.preserved, *e_pres)), + modified=tuple((*delta.modified, *e_mod)), + generated=tuple((*delta.generated, *e_gen)), + deleted=tuple((*delta.deleted, *e_del)), + entries=tuple( + (*delta.entries, *_canonical_topo_entries(edge_entries, graph_id=graph_id, node_id=node_id)) + ), + roles=_canonical_topo_roles(roles, graph_id=graph_id, node_id=node_id), + ) + aggregate = _aggregate_delta_entries(edge_entries, roles) + for topo_id, entry in face_entries.items(): + aggregate.setdefault(topo_id, entry) + return TrackedResult(shape=result_solid, delta=delta, delta_entries=aggregate) def tracked_chamfer(solid: Solid, edges: List[Edge], distance: float) -> TrackedResult: @@ -744,11 +1521,36 @@ def tracked_chamfer(solid: Solid, edges: List[Edge], distance: float) -> Tracked result_solid = Solid(chamfer_op.Shape()) - delta, entries = _query_single_shape_history( + delta, face_entries = _query_single_shape_history( chamfer_op, solid.wrapped, graph_id, node_id, "chamfer" ) - - return TrackedResult(shape=result_solid, delta=delta, delta_entries=entries) + contour_edges = _actual_contour_edges(chamfer_op, edges) + e_pres, e_mod, e_gen, e_del, edge_entries = _query_exact_history( + chamfer_op, + contour_edges, + graph_id, + node_id, + "contour_edge", + TopoKind.EDGE, + result_shape=result_solid.wrapped, + generated_result_role="chamfer.patch", + project_source_tags=True, + ) + roles = _roles_from_history(edge_entries, evidence_method="Generated") + delta = TopoDelta( + preserved=tuple((*delta.preserved, *e_pres)), + modified=tuple((*delta.modified, *e_mod)), + generated=tuple((*delta.generated, *e_gen)), + deleted=tuple((*delta.deleted, *e_del)), + entries=tuple( + (*delta.entries, *_canonical_topo_entries(edge_entries, graph_id=graph_id, node_id=node_id)) + ), + roles=_canonical_topo_roles(roles, graph_id=graph_id, node_id=node_id), + ) + aggregate = _aggregate_delta_entries(edge_entries, roles) + for topo_id, entry in face_entries.items(): + aggregate.setdefault(topo_id, entry) + return TrackedResult(shape=result_solid, delta=delta, delta_entries=aggregate) def tracked_shell( @@ -782,11 +1584,71 @@ def tracked_shell( result_solid = Solid(shell_op.Shape()) - delta, entries = _query_single_shape_history( - shell_op, solid.wrapped, graph_id, node_id, "shell" + all_faces = list(_iter_subshapes(solid.wrapped, TopAbs_FACE)) + selected_faces = [face.wrapped for face in faces_to_remove] + remaining_faces = [ + face + for face in all_faces + if not any(_is_same_shape(face, selected) for selected in selected_faces) + ] + body_parts = _query_exact_history( + shell_op, + remaining_faces, + graph_id, + node_id, + "body", + TopoKind.FACE, + result_shape=result_solid.wrapped, + preserved_result_role="shell.body_face", + modified_result_role="shell.body_face", + generated_result_role="shell.offset_face", + project_source_tags=True, + ) + closing_parts = _query_exact_history( + shell_op, + selected_faces, + graph_id, + node_id, + "closing_face", + TopoKind.FACE, + result_shape=result_solid.wrapped, + result_role="shell.closing_descendant", + project_source_tags=True, + ) + boundary_edges = [ + edge + for face in selected_faces + for edge in _iter_subshapes(face, TopAbs_EDGE) + ] + boundary_parts = _query_exact_history( + shell_op, + boundary_edges, + graph_id, + node_id, + "closing_boundary", + TopoKind.EDGE, + result_shape=result_solid.wrapped, + generated_result_role="shell.wall", + project_source_tags=True, + ) + parts = (body_parts, closing_parts, boundary_parts) + history_entries = [item for part in parts for item in part[4]] + roles = _roles_from_history(history_entries, evidence_method="GeneratedOrModified") + delta = TopoDelta( + preserved=tuple(ref for part in parts for ref in part[0]), + modified=tuple(ref for part in parts for ref in part[1]), + generated=tuple(ref for part in parts for ref in part[2]), + deleted=tuple(ref for part in parts for ref in part[3]), + entries=_canonical_topo_entries( + history_entries, graph_id=graph_id, node_id=node_id + ), + roles=_canonical_topo_roles(roles, graph_id=graph_id, node_id=node_id), + ) + return TrackedResult( + shape=result_solid, + delta=delta, + delta_entries=_aggregate_delta_entries(history_entries, roles), ) - - return TrackedResult(shape=result_solid, delta=delta, delta_entries=entries) def tracked_loft(profiles: List[Wire], ruled: bool = False) -> TrackedResult: @@ -810,7 +1672,7 @@ def tracked_loft(profiles: List[Wire], ruled: bool = False) -> TrackedResult: modified: List[TopoRef] = [] generated: List[TopoRef] = [] deleted: List[TopoRef] = [] - entries: Dict[str, Dict[str, Any]] = {} + history_entries: List[Dict[str, Any]] = [] for idx, profile in enumerate(profiles): pres, mod, gen, del_, profile_entries = _query_history( @@ -821,21 +1683,49 @@ def tracked_loft(profiles: List[Wire], ruled: bool = False) -> TrackedResult: f"profile_{idx}", TopoKind.EDGE, TopAbs_EDGE, + result_shape=result_solid.wrapped, + generated_result_role="loft.side", + project_source_tags=True, ) preserved.extend(pres) modified.extend(mod) generated.extend(gen) deleted.extend(del_) - for item in profile_entries: - entries[item["topo_id"]] = item + history_entries.extend(profile_entries) + + roles = _roles_from_history(history_entries, evidence_method="Generated") + for candidate, role, method in ( + (loft_op.FirstShape(), "loft.start", "FirstShape"), + (loft_op.LastShape(), "loft.end", "LastShape"), + ): + role_entry = _operation_role( + candidate, + result_shape=result_solid.wrapped, + graph_id=graph_id, + node_id=node_id, + role=role, + evidence_method=method, + ) + if role_entry is not None: + roles.append(role_entry) delta = TopoDelta( preserved=tuple(preserved), modified=tuple(modified), generated=tuple(generated), deleted=tuple(deleted), + entries=_canonical_topo_entries( + history_entries, + graph_id=graph_id, + node_id=node_id, + ), + roles=_canonical_topo_roles(roles, graph_id=graph_id, node_id=node_id), + ) + return TrackedResult( + shape=result_solid, + delta=delta, + delta_entries=_aggregate_delta_entries(history_entries, roles), ) - return TrackedResult(shape=result_solid, delta=delta, delta_entries=entries) def tracked_sweep(profile: Face, path: Wire, is_frenet: bool = False) -> TrackedResult: @@ -844,6 +1734,11 @@ def tracked_sweep(profile: Face, path: Wire, is_frenet: bool = False) -> Tracked graph_id = _make_id("g") node_id = _make_id("n") + if profile.get_inner_wires(): + raise ValueError( + "Sweep profiles with inner wires are unsupported because PipeShell only receives the outer profile wire" + ) + sweep_op = BRepOffsetAPI_MakePipeShell(path.wrapped) sweep_op.SetMode(bool(is_frenet)) sweep_op.Add(profile.get_outer_wire().wrapped, False, False) @@ -863,24 +1758,192 @@ def tracked_sweep(profile: Face, path: Wire, is_frenet: bool = False) -> Tracked "profile", TopoKind.EDGE, TopAbs_EDGE, + result_shape=result_solid.wrapped, + generated_result_role="sweep.side", + project_source_tags=True, ) - path_pres, path_mod, path_gen, path_del, path_entries = _query_history( - sweep_op, - path.wrapped, - graph_id, - node_id, - "path", - TopoKind.EDGE, - TopAbs_EDGE, - ) + history_entries = p_entries + roles = _roles_from_history(history_entries, evidence_method="Generated") + for candidate, role, method in ( + (sweep_op.FirstShape(), "sweep.start", "FirstShape"), + (sweep_op.LastShape(), "sweep.end", "LastShape"), + ): + role_entry = _operation_role( + candidate, + result_shape=result_solid.wrapped, + graph_id=graph_id, + node_id=node_id, + role=role, + evidence_method=method, + ) + if role_entry is not None: + roles.append(role_entry) delta = TopoDelta( - preserved=tuple(p_pres + path_pres), - modified=tuple(p_mod + path_mod), - generated=tuple(p_gen + path_gen), - deleted=tuple(p_del + path_del), + preserved=tuple(p_pres), + modified=tuple(p_mod), + generated=tuple(p_gen), + deleted=tuple(p_del), + entries=_canonical_topo_entries( + history_entries, + graph_id=graph_id, + node_id=node_id, + ), + roles=_canonical_topo_roles(roles, graph_id=graph_id, node_id=node_id), + ) + return TrackedResult( + shape=result_solid, + delta=delta, + delta_entries=_aggregate_delta_entries(history_entries, roles), + ) + + +def tracked_twisted_sweep( + profile: Face, + *, + axis: Tuple[float, float, float], + origin: Tuple[float, float, float], + distance: float, + twist_angle: float, + guide_radius: float, +) -> TrackedResult: + """Sweep a profile along a line with a linear axial rotation law.""" + + graph_id = _make_id("g") + node_id = _make_id("n") + + if profile.get_inner_wires(): + raise ValueError("Twisted sweep profiles with inner wires are unsupported") + if BRepAdaptor_Surface(profile.wrapped, True).GetType() != GeomAbs_Plane: + raise ValueError("Twisted sweep requires a planar profile") + + normal = profile.get_normal_at() + normal_array = (float(normal.x), float(normal.y), float(normal.z)) + alignment = abs(sum(normal_array[index] * axis[index] for index in range(3))) + if alignment < 1.0 - 1.0e-8: + raise ValueError("Twisted sweep profile must be normal to the sweep axis") + center = profile.get_center() + axial_offset = sum( + (float(value) - origin[index]) * axis[index] + for index, value in enumerate((center.x, center.y, center.z)) + ) + if abs(axial_offset) > max(1.0e-7, distance * 1.0e-9): + raise ValueError("Twisted sweep profile must lie at the sweep origin") + + axis_dir = gp_Dir(*axis) + axis_array = tuple(float(value) for value in axis) + seed = (1.0, 0.0, 0.0) if abs(axis_array[0]) < 0.9 else (0.0, 1.0, 0.0) + projection = sum(seed[index] * axis_array[index] for index in range(3)) + reference = tuple( + seed[index] - projection * axis_array[index] for index in range(3) + ) + reference_dir = gp_Dir(*reference) + + start = gp_Pnt(*origin) + end = gp_Pnt( + origin[0] + axis[0] * distance, + origin[1] + axis[1] * distance, + origin[2] + axis[2] * distance, + ) + spine_edge_builder = BRepBuilderAPI_MakeEdge(start, end) + if not spine_edge_builder.IsDone(): + raise ValueError("Twisted sweep failed to build its straight spine") + spine_edge = spine_edge_builder.Edge() + spine_builder = BRepBuilderAPI_MakeWire(spine_edge) + if not spine_builder.IsDone(): + raise ValueError("Twisted sweep failed to build its spine wire") + spine = spine_builder.Wire() + + cylinder = Geom_CylindricalSurface( + gp_Ax3(start, axis_dir, reference_dir), float(guide_radius) + ) + guide_curve = GCE2d_MakeSegment( + gp_Pnt2d(0.0, 0.0), + gp_Pnt2d(math.radians(twist_angle), float(distance)), + ).Value() + guide_edge_builder = BRepBuilderAPI_MakeEdge(guide_curve, cylinder) + if not guide_edge_builder.IsDone(): + raise ValueError("Twisted sweep failed to build its rotation guide") + guide_builder = BRepBuilderAPI_MakeWire(guide_edge_builder.Edge()) + if not guide_builder.IsDone(): + raise ValueError("Twisted sweep failed to build its rotation guide wire") + guide = guide_builder.Wire() + BRepLib.BuildCurves3d_s(guide, 1.0e-7, MaxSegment=2000) + + sweep_op = BRepOffsetAPI_MakePipeShell(spine) + sweep_op.SetTolerance(1.0e-6, 1.0e-6, 1.0e-4) + sweep_op.SetMaxDegree(11) + sweep_op.SetMaxSegments(200) + sweep_op.SetMode( + guide, + True, + BRepFill_TypeOfContact.BRepFill_NoContact, + ) + sweep_op.Add( + profile.get_outer_wire().wrapped, + TopExp.FirstVertex_s(spine_edge, True), + False, + False, + ) + if not sweep_op.IsReady(): + raise ValueError("Twisted sweep is not ready after adding the profile") + sweep_op.Build() + if not sweep_op.IsDone(): + raise ValueError(f"Twisted sweep failed: {sweep_op.GetStatus().name}") + approximation_error = float(sweep_op.ErrorOnSurface()) + if not sweep_op.MakeSolid(): + raise ValueError("Twisted sweep failed to convert its shell into a solid") + + result_solid = Solid(sweep_op.Shape()) + if not BRepCheck_Analyzer(result_solid.wrapped, True).IsValid(): + raise ValueError("Twisted sweep produced an invalid solid") + result_solid.set_metadata( + "twisted_sweep.kernel", + {"surface_error": approximation_error}, + ) + + p_pres, p_mod, p_gen, p_del, history_entries = _query_history( + sweep_op, + profile.get_outer_wire().wrapped, + graph_id, + node_id, + "profile", + TopoKind.EDGE, + TopAbs_EDGE, + result_shape=result_solid.wrapped, + generated_result_role="twisted_sweep.side", + project_source_tags=True, + ) + roles = _roles_from_history(history_entries, evidence_method="Generated") + for candidate, role, method in ( + (sweep_op.FirstShape(), "twisted_sweep.start", "FirstShape"), + (sweep_op.LastShape(), "twisted_sweep.end", "LastShape"), + ): + role_entry = _operation_role( + candidate, + result_shape=result_solid.wrapped, + graph_id=graph_id, + node_id=node_id, + role=role, + evidence_method=method, + ) + if role_entry is not None: + roles.append(role_entry) + + delta = TopoDelta( + preserved=tuple(p_pres), + modified=tuple(p_mod), + generated=tuple(p_gen), + deleted=tuple(p_del), + entries=_canonical_topo_entries( + history_entries, + graph_id=graph_id, + node_id=node_id, + ), + roles=_canonical_topo_roles(roles, graph_id=graph_id, node_id=node_id), + ) + return TrackedResult( + shape=result_solid, + delta=delta, + delta_entries=_aggregate_delta_entries(history_entries, roles), ) - entries: Dict[str, Dict[str, Any]] = {} - for item in p_entries + path_entries: - entries[item["topo_id"]] = item - return TrackedResult(shape=result_solid, delta=delta, delta_entries=entries) diff --git a/SimpleCADAPI/src/simplecadapi/translator/__init__.py b/SimpleCADAPI/src/simplecadapi/translator/__init__.py index 583ec92..a4ff08b 100644 --- a/SimpleCADAPI/src/simplecadapi/translator/__init__.py +++ b/SimpleCADAPI/src/simplecadapi/translator/__init__.py @@ -1,5 +1,18 @@ """Translator backends for exporting SimpleCAD model JSON to external CAD systems.""" +from importlib import import_module +from importlib.util import find_spec + from . import freecad_translator __all__ = ["freecad_translator"] + +# Contributed backends are optional so a FreeCAD-only cherry-pick remains usable. +for _backend_name in ("fusion360_translator", "solidworks_translator"): + _qualified_name = f"{__name__}.{_backend_name}" + if find_spec(_qualified_name) is None: + continue + globals()[_backend_name] = import_module(_qualified_name) + __all__.append(_backend_name) + +del _backend_name, _qualified_name diff --git a/SimpleCADAPI/src/simplecadapi/translator/base.py b/SimpleCADAPI/src/simplecadapi/translator/base.py new file mode 100644 index 0000000..07f4665 --- /dev/null +++ b/SimpleCADAPI/src/simplecadapi/translator/base.py @@ -0,0 +1,46 @@ +"""Base contract for canonical model translators.""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import Any, Dict, Optional + +from ..serializer import import_model_json +from ..topology import OperationGraph +from .types import BackendCapabilities, TranslationArtifact + + +class BaseTranslator(ABC): + """Translate canonical model JSON into an in-memory backend artifact.""" + + @property + @abstractmethod + def capabilities(self) -> BackendCapabilities: + """Return the backend's static capability declaration.""" + + def translate_model_json(self, json_str: str) -> TranslationArtifact: + """Parse canonical model JSON and translate its payload.""" + + payload = import_model_json(json_str) + graph = payload.get("graph") + if not isinstance(graph, OperationGraph): + raise ValueError( + "Translation requires model JSON with a canonical low-level graph" + ) + if graph.node_count == 0: + raise ValueError( + "Translation requires model JSON with a non-empty canonical low-level graph" + ) + return self.translate_model_payload(payload, graph=graph) + + @abstractmethod + def translate_model_payload( + self, + payload: Dict[str, Any], + *, + graph: Optional[OperationGraph] = None, + ) -> TranslationArtifact: + """Translate an already-imported canonical model payload.""" + + +__all__ = ["BaseTranslator"] diff --git a/SimpleCADAPI/src/simplecadapi/translator/errors.py b/SimpleCADAPI/src/simplecadapi/translator/errors.py new file mode 100644 index 0000000..dce4476 --- /dev/null +++ b/SimpleCADAPI/src/simplecadapi/translator/errors.py @@ -0,0 +1,53 @@ +"""Shared translator backend exceptions.""" + +from __future__ import annotations + +from typing import Any, Dict + +from ..errors import ErrorGuidance, SimpleCADError + + +class TranslatorError(SimpleCADError): + """Base structured error raised by translator backends.""" + + code = "translator_error" + + def __init__( + self, + backend_id: str, + operation: str, + guidance: ErrorGuidance, + ) -> None: + self.backend_id = str(backend_id) + super().__init__(operation, guidance) + + def to_dict(self) -> Dict[str, Any]: + payload = super().to_dict() + payload["backend_id"] = self.backend_id + payload["code"] = self.code + return payload + + +class TranslationRequestError(TranslatorError): + code = "invalid_translation_request" + + +class UnsupportedTargetError(TranslatorError): + code = "unsupported_translation_target" + + +class BackendUnavailableError(TranslatorError): + code = "translator_backend_unavailable" + + +class BackendExecutionError(TranslatorError): + code = "translator_backend_execution_failed" + + +__all__ = [ + "BackendExecutionError", + "BackendUnavailableError", + "TranslationRequestError", + "TranslatorError", + "UnsupportedTargetError", +] diff --git a/SimpleCADAPI/src/simplecadapi/translator/freecad_translator/__init__.py b/SimpleCADAPI/src/simplecadapi/translator/freecad_translator/__init__.py index 677ab32..ec89fa3 100644 --- a/SimpleCADAPI/src/simplecadapi/translator/freecad_translator/__init__.py +++ b/SimpleCADAPI/src/simplecadapi/translator/freecad_translator/__init__.py @@ -1,10 +1,18 @@ """FreeCAD translator backend for SimpleCAD model JSON.""" -from .api import translate_model_json_to_fcstd, translate_model_json_to_freecad_script -from .script_translator import FreeCADScriptTranslator +from .api import ( + export_model_json_to_fcstd, + translate_model_json_to_fcstd, + translate_model_json_to_freecad_script, +) +from .capabilities import CAPABILITIES +from .translator import FreeCADScriptTranslator, FreeCADTranslator __all__ = [ + "CAPABILITIES", "FreeCADScriptTranslator", + "FreeCADTranslator", + "export_model_json_to_fcstd", "translate_model_json_to_fcstd", "translate_model_json_to_freecad_script", ] diff --git a/SimpleCADAPI/src/simplecadapi/translator/freecad_translator/analysis.py b/SimpleCADAPI/src/simplecadapi/translator/freecad_translator/analysis.py new file mode 100644 index 0000000..8c00ac6 --- /dev/null +++ b/SimpleCADAPI/src/simplecadapi/translator/freecad_translator/analysis.py @@ -0,0 +1,181 @@ +"""Pure graph analysis used by the FreeCAD compiler.""" + +from __future__ import annotations + +from typing import AbstractSet, Set +import math + +from ...topology import OperationGraph, OperationNode + + +def _contains_expr_refs(value: object) -> bool: + if isinstance(value, dict): + if isinstance(value.get("expr_id"), str) and value["expr_id"]: + return True + return any(_contains_expr_refs(child) for child in value.values()) + if isinstance(value, (list, tuple)): + return any(_contains_expr_refs(child) for child in value) + return False + + +def can_lower_circle_extrusion_to_cylinder( + circle_node: OperationNode, + extrusion_node: OperationNode, +) -> bool: + """Return whether a circle extrusion exactly matches a native cylinder.""" + + if _contains_expr_refs(circle_node.param_exprs) or _contains_expr_refs( + extrusion_node.param_exprs + ): + return False + try: + normal = tuple(float(value) for value in circle_node.params["normal"]) + direction = tuple(float(value) for value in extrusion_node.params["direction"]) + if len(normal) != 3 or len(direction) != 3: + return False + normal_length = math.sqrt(sum(value * value for value in normal)) + direction_length = math.sqrt(sum(value * value for value in direction)) + if normal_length <= 1e-12 or direction_length <= 1e-12: + return False + cosine = sum(normal[index] * direction[index] for index in range(3)) / ( + normal_length * direction_length + ) + return abs(abs(cosine) - 1.0) <= 1e-9 + except (KeyError, TypeError, ValueError): + return False + + +def find_cylinder_profile_nodes( + graph: OperationGraph, + result_node_ids: AbstractSet[str], +) -> Set[str]: + """Find single-use circle profiles represented by native cylinders.""" + + use_counts: dict[str, int] = {} + for graph_node in graph.topological_order(): + for input_ref in graph_node.inputs: + use_counts[input_ref.node_id] = use_counts.get(input_ref.node_id, 0) + 1 + + suppressed: Set[str] = set() + for graph_node in graph.topological_order(): + if graph_node.op != "make_extrude_rsolid" or len(graph_node.inputs) != 1: + continue + profile_node = graph.get_node(graph_node.inputs[0].node_id) + face_node = None + if ( + profile_node is not None + and profile_node.op == "make_face_from_wire_rface" + and len(profile_node.inputs) == 1 + ): + face_node = profile_node + profile_node = graph.get_node(profile_node.inputs[0].node_id) + if ( + profile_node is None + or profile_node.op != "make_wire_from_edges_rwire" + or len(profile_node.inputs) != 1 + ): + continue + edge_node = graph.get_node(profile_node.inputs[0].node_id) + if edge_node is None or edge_node.op != "make_circle_redge": + continue + if not can_lower_circle_extrusion_to_cylinder(edge_node, graph_node): + continue + profile_ids = [profile_node.node_id] + if face_node is not None: + profile_ids.append(face_node.node_id) + if any(node_id in result_node_ids for node_id in profile_ids): + continue + if any(use_counts.get(node_id, 0) != 1 for node_id in profile_ids): + continue + suppressed.update(profile_ids) + return suppressed + + +def can_fold_transform_into_input( + node: OperationNode, + graph: OperationGraph, + result_node_ids: AbstractSet[str], +) -> bool: + """Return whether a single-use transform can mutate its source placement.""" + + if ( + node.op not in {"make_translate_rshape", "make_rotate_rshape"} + or len(node.inputs) != 1 + ): + return False + source = node.inputs[0] + if source.op not in { + "make_box_rsolid", + "make_cone_rsolid", + "make_cylinder_rsolid", + "make_extrude_rsolid", + "make_sphere_rsolid", + "make_wire_from_edges_rwire", + "make_face_from_wire_rface", + "make_wire_from_sketch_rwire", + "make_face_from_sketch_rface", + "make_translate_rshape", + "make_rotate_rshape", + }: + return False + if source.node_id in result_node_ids: + return False + return graph.downstream_nodes(source.node_id) == [node.node_id] + + +def transform_feeds_only_loft( + graph: OperationGraph, + node_id: str, + seen: Set[str], +) -> bool: + """Return whether all paths from a transform terminate at loft nodes.""" + + if node_id in seen: + return False + seen.add(node_id) + downstream = graph.downstream_nodes(node_id) + if not downstream: + return False + for downstream_id in downstream: + downstream_node = graph.get_node(downstream_id) + if downstream_node is None: + return False + if downstream_node.op == "make_loft_rsolid": + continue + if downstream_node.op in {"make_translate_rshape", "make_rotate_rshape"}: + if transform_feeds_only_loft(graph, downstream_id, seen): + continue + return False + return True + + +def should_materialize_transform_for_loft_section( + node: OperationNode, + graph: OperationGraph, +) -> bool: + """Return whether a loft section transform requires a concrete object.""" + + if ( + node.op not in {"make_translate_rshape", "make_rotate_rshape"} + or len(node.inputs) != 1 + ): + return False + if not transform_feeds_only_loft(graph, node.node_id, set()): + return False + return node.inputs[0].op in { + "make_wire_from_edges_rwire", + "make_face_from_wire_rface", + "make_wire_from_sketch_rwire", + "make_face_from_sketch_rface", + "make_translate_rshape", + "make_rotate_rshape", + } + + +__all__ = [ + "can_fold_transform_into_input", + "can_lower_circle_extrusion_to_cylinder", + "find_cylinder_profile_nodes", + "should_materialize_transform_for_loft_section", + "transform_feeds_only_loft", +] diff --git a/SimpleCADAPI/src/simplecadapi/translator/freecad_translator/api.py b/SimpleCADAPI/src/simplecadapi/translator/freecad_translator/api.py index 5806184..98644b5 100644 --- a/SimpleCADAPI/src/simplecadapi/translator/freecad_translator/api.py +++ b/SimpleCADAPI/src/simplecadapi/translator/freecad_translator/api.py @@ -2,33 +2,11 @@ from __future__ import annotations -import json -import os -import shutil -import subprocess -import tempfile -from typing import Any, Optional +from typing import Optional from ...errors import raise_harness_error -from .script_translator import FreeCADScriptTranslator - - -def _json_ascii(value: Any) -> str: - return json.dumps(value, ensure_ascii=True, sort_keys=True) - - -def _discover_freecad_executable() -> Optional[str]: - candidates = [ - shutil.which("FreeCADCmd"), - shutil.which("freecadcmd"), - shutil.which("FreeCAD"), - "/Applications/FreeCAD.app/Contents/Resources/bin/freecadcmd", - "/Applications/FreeCAD.app/Contents/MacOS/FreeCAD", - ] - for candidate in candidates: - if candidate and os.path.exists(candidate): - return candidate - return None +from .exporter import discover_freecad_executable, export_freecad_script_to_fcstd +from .translator import FreeCADTranslator def translate_model_json_to_freecad_script( @@ -44,7 +22,7 @@ def translate_model_json_to_freecad_script( workbench module is available. """ - return FreeCADScriptTranslator( + return FreeCADTranslator( document_name=document_name ).translate_model_json_to_script(json_str) @@ -72,7 +50,7 @@ def translate_model_json_to_fcstd( tree. """ - freecad_exe = freecad_cmd or _discover_freecad_executable() + freecad_exe = freecad_cmd or discover_freecad_executable() if not freecad_exe: raise_harness_error( operation="translate_model_json_to_fcstd", @@ -91,35 +69,12 @@ def translate_model_json_to_fcstd( script = translate_model_json_to_freecad_script( json_str, document_name=document_name ) - resolved_output_path = os.path.abspath(output_path) - save_tail = ( - f"\nOUTPUT_PATH = {_json_ascii(resolved_output_path)}\n" - "_apply_result_visibility(RESULT_NODE_IDS)\n" - "_set_active_result_object(RESULT_NODE_IDS)\n" - "_save_fcstd_with_gui_visibility(OUTPUT_PATH)\n" - "print(OUTPUT_PATH)\n" - ) - - with tempfile.NamedTemporaryFile( - mode="w", suffix="_simplecad_freecad_export.py", delete=False - ) as handle: - temp_script_path = handle.name - handle.write(script) - handle.write(save_tail) - try: - completed = subprocess.run( - [freecad_exe, temp_script_path], - check=True, - text=True, - capture_output=True, + return export_freecad_script_to_fcstd( + script, + output_path, + freecad_executable=freecad_exe, ) - if not os.path.exists(resolved_output_path) or os.path.getsize(resolved_output_path) <= 0: - raise RuntimeError( - "FreeCAD export completed without creating a non-empty .FCStd file. " - f"stderr={completed.stderr.strip()!r}" - ) - return output_path except Exception as e: raise_harness_error( operation="translate_model_json_to_fcstd", @@ -136,3 +91,20 @@ def translate_model_json_to_fcstd( ], error=e, ) + + +def export_model_json_to_fcstd( + json_str: str, + output_path: str, + *, + document_name: str = "SimpleCADModel", + freecad_cmd: Optional[str] = None, +) -> str: + """Export canonical model JSON to `.FCStd` via FreeCADCmd/FreeCAD.""" + + return translate_model_json_to_fcstd( + json_str, + output_path, + document_name=document_name, + freecad_cmd=freecad_cmd, + ) diff --git a/SimpleCADAPI/src/simplecadapi/translator/freecad_translator/capabilities.py b/SimpleCADAPI/src/simplecadapi/translator/freecad_translator/capabilities.py new file mode 100644 index 0000000..7dcc007 --- /dev/null +++ b/SimpleCADAPI/src/simplecadapi/translator/freecad_translator/capabilities.py @@ -0,0 +1,189 @@ +"""Static capability declaration for the FreeCAD translator backend.""" + +from __future__ import annotations + +from typing import Dict + +from ..types import ( + BackendCapabilities, + OperationCapability, + SupportLevel, + TranslationOutputKind, + TranslationTarget, +) + +BACKEND_NAME = "freecad" + +_CANONICAL_OPS = ( + "make_point_rvertex", + "make_line_redge", + "make_circle_redge", + "make_three_point_arc_redge", + "make_angle_arc_redge", + "make_spline_redge", + "make_helix_redge", + "make_wire_from_edges_rwire", + "make_face_from_wire_rface", + "make_face_from_wires_rface", + "make_sketch_rsketch", + "make_add_point_rsketch", + "make_add_line_rsketch", + "make_add_circle_rsketch", + "make_add_arc_rsketch", + "make_add_bspline_rsketch", + "make_constrain_coincident_rsketch", + "make_constrain_point_on_rsketch", + "make_constrain_horizontal_rsketch", + "make_constrain_vertical_rsketch", + "make_constrain_parallel_rsketch", + "make_constrain_perpendicular_rsketch", + "make_constrain_collinear_rsketch", + "make_constrain_tangent_rsketch", + "make_constrain_concentric_rsketch", + "make_constrain_midpoint_rsketch", + "make_constrain_symmetric_rsketch", + "make_constrain_equal_length_rsketch", + "make_constrain_equal_radius_rsketch", + "make_constrain_distance_rsketch", + "make_constrain_distance_x_rsketch", + "make_constrain_distance_y_rsketch", + "make_constrain_length_rsketch", + "make_constrain_angle_rsketch", + "make_constrain_radius_rsketch", + "make_constrain_diameter_rsketch", + "make_constrain_fix_rsketch", + "make_wire_from_sketch_rwire", + "make_face_from_sketch_rface", + "make_box_rsolid", + "make_cylinder_rsolid", + "make_cone_rsolid", + "make_sphere_rsolid", + "make_material_rmaterial", + "make_placement_rplacement", + "make_identity_placement_rplacement", + "make_part_rpart", + "make_assign_material_rpart", + "make_assembly_rassembly", + "make_add_component_rassembly", + "make_place_component_rassembly", + "make_compound_from_assembly_rcompound", + "make_face_connector_rconnector", + "make_edge_connector_rconnector", + "make_vertex_connector_rconnector", + "make_placement_connector_rconnector", + "make_add_connector_rpart", + "make_add_connector_rassembly", + "make_forward_connector_rassembly", + "make_connector_ref_rconnectorref", + "make_scalar_limit_rscalarlimit", + "make_ground_component_rassembly", + "make_unground_component_rassembly", + "make_fixed_constraint_rassembly", + "make_revolute_constraint_rassembly", + "make_prismatic_constraint_rassembly", + "make_gear_constraint_rassembly", + "make_belt_constraint_rassembly", + "make_rack_pinion_constraint_rassembly", + "make_solve_assembly_constraints_rassembly", + "make_extrude_rsolid", + "make_revolve_rsolid", + "make_loft_rsolid", + "make_sweep_rsolid", + "make_twisted_sweep_rsolid", + "make_translate_rshape", + "make_rotate_rshape", + "make_mirror_rshape", + "make_cut_rsolid", + "make_union_rsolid", + "make_intersect_rsolid", + "make_2d_cut_rface", + "make_2d_union_rface", + "make_2d_intersect_rface", + "make_fillet_rsolid", + "make_chamfer_rsolid", + "make_shell_rsolid", + "make_select_rvertex", + "make_select_redge", + "make_select_rwire", + "make_select_rface", + "make_select_rsolid", + "apply_tag_rselection", +) + +OP_SUPPORT: Dict[str, OperationCapability] = { + op: OperationCapability(SupportLevel.NATIVE) for op in _CANONICAL_OPS +} +OP_SUPPORT["make_point_rvertex"] = OperationCapability( + SupportLevel.UNSUPPORTED, + reason="The FreeCAD point emitter has not been implemented yet.", +) +OP_SUPPORT["make_twisted_sweep_rsolid"] = OperationCapability( + SupportLevel.EMULATED, + reason=( + "FreeCAD has no equivalent auxiliary-spine feature; the backend emits " + "a smooth solid loft through rotated and translated profile sections." + ), +) +for _op in ( + "make_sketch_rsketch", + "make_add_point_rsketch", + "make_add_line_rsketch", + "make_add_circle_rsketch", + "make_add_arc_rsketch", + "make_add_bspline_rsketch", + "make_constrain_coincident_rsketch", + "make_constrain_point_on_rsketch", + "make_constrain_horizontal_rsketch", + "make_constrain_vertical_rsketch", + "make_constrain_parallel_rsketch", + "make_constrain_perpendicular_rsketch", + "make_constrain_collinear_rsketch", + "make_constrain_tangent_rsketch", + "make_constrain_concentric_rsketch", + "make_constrain_midpoint_rsketch", + "make_constrain_symmetric_rsketch", + "make_constrain_equal_length_rsketch", + "make_constrain_equal_radius_rsketch", + "make_constrain_distance_rsketch", + "make_constrain_distance_x_rsketch", + "make_constrain_distance_y_rsketch", + "make_constrain_length_rsketch", + "make_constrain_angle_rsketch", + "make_constrain_radius_rsketch", + "make_constrain_diameter_rsketch", + "make_constrain_fix_rsketch", +): + OP_SUPPORT[_op] = OperationCapability( + SupportLevel.METADATA_ONLY, + reason="The graph node is preserved until a sketch promotion materializes it.", + ) +OP_SUPPORT["apply_tag_rselection"] = OperationCapability( + SupportLevel.METADATA_ONLY, + reason="The semantic view is preserved as a linked object with its canonical TagBinding metadata.", +) + +CAPABILITIES = BackendCapabilities( + backend_id=BACKEND_NAME, + display_name="FreeCAD", + input_schema_versions=("2.0",), + targets=( + TranslationTarget( + target_id="freecad_script", + output_kind=TranslationOutputKind.TEXT, + media_type="text/x-python", + extensions=(".py",), + option_names=("document_name",), + ), + TranslationTarget( + target_id="fcstd", + output_kind=TranslationOutputKind.FILE, + media_type="application/octet-stream", + extensions=(".FCStd",), + requires_external_runtime=True, + option_names=("document_name", "freecad_cmd"), + ), + ), + operations=OP_SUPPORT, +) + +__all__ = ["BACKEND_NAME", "CAPABILITIES", "OP_SUPPORT"] diff --git a/SimpleCADAPI/src/simplecadapi/translator/freecad_translator/codegen.py b/SimpleCADAPI/src/simplecadapi/translator/freecad_translator/codegen.py new file mode 100644 index 0000000..3a00e01 --- /dev/null +++ b/SimpleCADAPI/src/simplecadapi/translator/freecad_translator/codegen.py @@ -0,0 +1,332 @@ +"""Pure code-generation helpers shared by the FreeCAD compiler and emitters.""" + +from __future__ import annotations + +import hashlib +import json +import pprint +from typing import Any, Dict, List, Optional, Sequence, Set, Tuple + +from ...expr import ExpressionGraph +from ...topology import OperationNode +from ...units import expression_uses_units, infer_dimension, unit_from_payload + + +def _json_ascii(value: Any) -> str: + return json.dumps(value, ensure_ascii=True, sort_keys=True) + + +def _py_literal(value: Any) -> str: + return pprint.pformat(value, compact=True, sort_dicts=True, width=120) + + +def _expression_physical_metadata( + nodes: Sequence[Any], +) -> Tuple[Dict[str, str], Dict[str, bool]]: + graph = ExpressionGraph.from_dict({"nodes": list(nodes)}) + dimensions: Dict[str, str] = {} + unit_aware: Dict[str, bool] = {} + for node in nodes: + if not isinstance(node, dict): + continue + expr_id = str(node.get("expr_id", "")) + expr = graph.get(expr_id) + if expr is None: + continue + dimension = infer_dimension(expr) + dimensions[expr_id] = "Legacy" if dimension is None else dimension.name + unit_aware[expr_id] = expression_uses_units(expr) + return dimensions, unit_aware + + +def _canonical_variable_default(node: Dict[str, Any]) -> float: + value = float(node.get("default", 0.0)) + unit_payload = node.get("unit") + if unit_payload is None: + return value + return unit_from_payload(unit_payload).to_canonical(value) + + +def _safe_name(raw: str, *, prefix: str = "obj") -> str: + token = "".join(ch if ch.isalnum() else "_" for ch in raw) + token = token.strip("_") + if not token: + token = prefix + if token[0].isdigit(): + token = f"{prefix}_{token}" + return token + + +_OP_EXPRESSION_BINDINGS: Dict[str, Tuple[Tuple[str, Tuple[Any, ...]], ...]] = { + "make_line_redge": (), + "make_circle_redge": (), + "make_angle_arc_redge": (), + "make_three_point_arc_redge": (), + "make_spline_redge": (), + "make_wire_from_edges_rwire": (), + "make_helix_redge": ( + ("Pitch", ("pitch",)), + ("Height", ("height",)), + ("Radius", ("radius",)), + ("Placement.Base.x", ("center", 0)), + ("Placement.Base.y", ("center", 1)), + ("Placement.Base.z", ("center", 2)), + ), + "make_face_from_wire_rface": (), + "make_face_from_wires_rface": (), + "make_box_rsolid": ( + ("Length", ("width",)), + ("Width", ("height",)), + ("Height", ("depth",)), + ), + "make_cylinder_rsolid": ( + ("Radius", ("radius",)), + ("Height", ("height",)), + ("Placement.Base.x", ("bottom_face_center", 0)), + ("Placement.Base.y", ("bottom_face_center", 1)), + ("Placement.Base.z", ("bottom_face_center", 2)), + ), + "make_cone_rsolid": ( + ("Radius1", ("bottom_radius",)), + ("Radius2", ("top_radius",)), + ("Height", ("height",)), + ("Placement.Base.x", ("bottom_face_center", 0)), + ("Placement.Base.y", ("bottom_face_center", 1)), + ("Placement.Base.z", ("bottom_face_center", 2)), + ), + "make_sphere_rsolid": ( + ("Radius", ("radius",)), + ("Placement.Base.x", ("center", 0)), + ("Placement.Base.y", ("center", 1)), + ("Placement.Base.z", ("center", 2)), + ), + "make_extrude_rsolid": ( + ("LengthFwd", ("distance",)), + ("Dir.x", ("direction", 0)), + ("Dir.y", ("direction", 1)), + ("Dir.z", ("direction", 2)), + ), + "make_revolve_rsolid": ( + ("Angle", ("angle",)), + ("Axis.x", ("axis", 0)), + ("Axis.y", ("axis", 1)), + ("Axis.z", ("axis", 2)), + ("Base.x", ("origin", 0)), + ("Base.y", ("origin", 1)), + ("Base.z", ("origin", 2)), + ), + "make_loft_rsolid": (("Ruled", ("ruled",)),), + "make_sweep_rsolid": (("Frenet", ("is_frenet",)),), + "make_cut_rsolid": (), + "make_union_rsolid": (), + "make_intersect_rsolid": (), + "make_fillet_rsolid": (), + "make_chamfer_rsolid": (), + "make_shell_rsolid": (("Value", ("thickness",)),), + "make_mirror_rshape": ( + ("Base.x", ("plane_origin", 0)), + ("Base.y", ("plane_origin", 1)), + ("Base.z", ("plane_origin", 2)), + ("Normal.x", ("plane_normal", 0)), + ("Normal.y", ("plane_normal", 1)), + ("Normal.z", ("plane_normal", 2)), + ), + "make_translate_rshape": ( + ("Placement.Base.x", ("vector", 0)), + ("Placement.Base.y", ("vector", 1)), + ("Placement.Base.z", ("vector", 2)), + ), + "make_rotate_rshape": ( + ("Placement.Base.x", ("origin", 0)), + ("Placement.Base.y", ("origin", 1)), + ("Placement.Base.z", ("origin", 2)), + ("Placement.Rotation.Axis.x", ("axis", 0)), + ("Placement.Rotation.Axis.y", ("axis", 1)), + ("Placement.Rotation.Axis.z", ("axis", 2)), + ("Placement.Rotation.Angle", ("angle",)), + ), +} + + +_OP_EXPRESSION_LIMITATIONS: Dict[str, str] = { + "make_spline_redge": ( + "Exact B-spline pole/weight expressions have no stable equivalent native " + "FreeCAD Sketcher BSpline parameter host. The translator exports exact " + "B-spline geometry, but does not map make_spline_redge param_exprs into " + "FreeCAD ExpressionEngine." + ), +} + + +def _contains_expr_refs(value: Any) -> bool: + if isinstance(value, dict): + if isinstance(value.get("expr_id"), str) and value["expr_id"]: + return True + return any(_contains_expr_refs(v) for v in value.values()) + if isinstance(value, (list, tuple)): + return any(_contains_expr_refs(v) for v in value) + return False + + +def _expression_limitation_payload( + op: str, param_exprs: Any +) -> Optional[Dict[str, str]]: + if not _contains_expr_refs(param_exprs or {}): + return None + reason = _OP_EXPRESSION_LIMITATIONS.get(str(op)) + if not reason: + return None + return {"op": str(op), "reason": str(reason)} + + +def _node_expression_limitation( + node: Optional[OperationNode], +) -> Optional[Dict[str, str]]: + if node is None: + return None + payload = _expression_limitation_payload(str(node.op), dict(node.param_exprs)) + if payload is None: + return None + return {"node_id": str(node.node_id), **payload} + + +def _sanitize_expr_alias(alias: str, *, prefix: str = "expr") -> str: + token = "".join( + ch if ch.isascii() and ch.isalnum() else "_" for ch in str(alias) + ).strip("_") + if not token: + token = prefix + if token[0].isdigit(): + token = f"{prefix}_{token}" + return token[:64] + + +def _expr_short_suffix(expr_id: str) -> str: + raw = str(expr_id).rsplit("_", 1)[-1] + token = "".join( + ch if ch.isascii() and ch.isalnum() else "_" for ch in raw + ).strip("_") + return token[:8] if token else "id" + + +def _const_value_alias_token(value: Any) -> str: + try: + number = float(value) + except Exception: + return "value" + text = f"{number:.6g}".replace("-", "neg_").replace(".", "_") + token = "".join( + ch if ch.isascii() and ch.isalnum() else "_" for ch in text + ).strip("_") + return token or "value" + + +def _spreadsheet_expr_alias(expr_node: Dict[str, Any], row: int) -> str: + expr_id = str(expr_node.get("expr_id", f"expr_{row}")) + kind = str(expr_node.get("kind", "expr")) + if kind == "var": + name = str(expr_node.get("name", "")).strip() + if name: + return _sanitize_expr_alias(f"var_{name}", prefix="var") + if kind == "const": + return _sanitize_expr_alias( + f"const_{_const_value_alias_token(expr_node.get('value'))}_{_expr_short_suffix(expr_id)}", + prefix="const", + ) + op = str(expr_node.get("op", "expr")).strip() or "expr" + return _sanitize_expr_alias( + f"expr_{op}_{_expr_short_suffix(expr_id)}", prefix="expr" + ) + + +def _spreadsheet_expr_aliases(nodes: Sequence[Any]) -> Dict[str, str]: + aliases: Dict[str, str] = {} + used: Set[str] = set() + row = 1 + for node in nodes: + if not isinstance(node, dict): + continue + expr_id = str(node.get("expr_id", f"expr_{row}")) + alias = _spreadsheet_expr_alias(node, row) + if alias in used: + suffix = hashlib.sha256(expr_id.encode("utf-8")).hexdigest()[:8] + prefix = alias[: 63 - len(suffix)] + alias = f"{prefix}_{suffix}" + collision = 2 + while alias in used: + collision_suffix = f"{suffix}_{collision}" + prefix = alias[: 63 - len(collision_suffix)] + alias = f"{prefix}_{collision_suffix}" + collision += 1 + aliases[expr_id] = alias + used.add(alias) + row += 1 + return aliases + + +def _coincident_constraint_pairs( + input_nodes: Sequence[Optional[OperationNode]], +) -> List[Tuple[int, int, int, int]]: + pairs: List[Tuple[int, int, int, int]] = [] + if len(input_nodes) < 2: + return pairs + for idx in range(len(input_nodes) - 1): + left = input_nodes[idx] + right = input_nodes[idx + 1] + if left is None or right is None: + continue + if left.op == "make_circle_redge" or right.op == "make_circle_redge": + continue + pairs.append((idx, 2, idx + 1, 1)) + first = input_nodes[0] + last = input_nodes[-1] + if ( + first is not None + and last is not None + and first.op != "make_circle_redge" + and last.op != "make_circle_redge" + ): + try: + first_start = first.params.get("start") + last_end = last.params.get("end") + if isinstance(first_start, (list, tuple)) and isinstance( + last_end, (list, tuple) + ): + if all( + abs(float(a) - float(b)) <= 1e-7 + for a, b in zip(first_start, last_end) + ): + pairs.append((len(input_nodes) - 1, 2, 0, 1)) + except Exception: + pass + return pairs + + +def _compile_time_nested_expr_ref(expr_meta: Any, *path: Any) -> Any: + value = expr_meta + for key in path: + if isinstance(value, dict) and key in value: + value = value[key] + elif isinstance(value, list) and isinstance(key, int) and 0 <= key < len(value): + value = value[key] + else: + return None + return value + + +__all__ = [ + "_OP_EXPRESSION_BINDINGS", + "_OP_EXPRESSION_LIMITATIONS", + "_coincident_constraint_pairs", + "_compile_time_nested_expr_ref", + "_contains_expr_refs", + "_canonical_variable_default", + "_expression_physical_metadata", + "_node_expression_limitation", + "_json_ascii", + "_py_literal", + "_safe_name", + "_sanitize_expr_alias", + "_spreadsheet_expr_alias", + "_spreadsheet_expr_aliases", +] diff --git a/SimpleCADAPI/src/simplecadapi/translator/freecad_translator/context.py b/SimpleCADAPI/src/simplecadapi/translator/freecad_translator/context.py new file mode 100644 index 0000000..19ba652 --- /dev/null +++ b/SimpleCADAPI/src/simplecadapi/translator/freecad_translator/context.py @@ -0,0 +1,23 @@ +"""Per-translation compiler state for the FreeCAD backend.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Dict, List, Optional, Set + +from ...topology import OperationGraph + + +@dataclass +class FreeCADCompileContext: + """Mutable state owned by one FreeCAD script compilation.""" + + document_name: str + source_graph: Optional[OperationGraph] = None + expression_aliases: Dict[str, str] = field(default_factory=dict) + result_node_ids: Set[str] = field(default_factory=set) + result_node_id_list: List[str] = field(default_factory=list) + suppressed_profile_node_ids: Set[str] = field(default_factory=set) + + +__all__ = ["FreeCADCompileContext"] diff --git a/SimpleCADAPI/src/simplecadapi/translator/freecad_translator/emitters/__init__.py b/SimpleCADAPI/src/simplecadapi/translator/freecad_translator/emitters/__init__.py new file mode 100644 index 0000000..74ea9d7 --- /dev/null +++ b/SimpleCADAPI/src/simplecadapi/translator/freecad_translator/emitters/__init__.py @@ -0,0 +1,24 @@ +"""Operation emitters for the FreeCAD backend.""" + +from .primitives import PrimitiveEmitterMixin +from .products import ProductEmitterMixin +from .selections import SelectionEmitterMixin +from .sketches import SketchEmitterMixin +from .geometry import GeometryEmitterMixin +from .features import FeatureEmitterMixin +from .booleans import BooleanEmitterMixin +from .transforms import TransformEmitterMixin +from .registry import EMITTER_METHOD_BY_OP, emit_native_node + +__all__ = [ + "PrimitiveEmitterMixin", + "ProductEmitterMixin", + "SelectionEmitterMixin", + "SketchEmitterMixin", + "GeometryEmitterMixin", + "FeatureEmitterMixin", + "BooleanEmitterMixin", + "TransformEmitterMixin", + "EMITTER_METHOD_BY_OP", + "emit_native_node", +] diff --git a/SimpleCADAPI/src/simplecadapi/translator/freecad_translator/emitters/booleans.py b/SimpleCADAPI/src/simplecadapi/translator/freecad_translator/emitters/booleans.py new file mode 100644 index 0000000..1bf4ecf --- /dev/null +++ b/SimpleCADAPI/src/simplecadapi/translator/freecad_translator/emitters/booleans.py @@ -0,0 +1,174 @@ +"""FreeCAD operation emitters for one canonical graph domain.""" + +from __future__ import annotations + +from typing import List, Optional + +from ....topology import OperationNode +from ..codegen import * + + +class BooleanEmitterMixin: + def _emit_booleans( + self, + node: OperationNode, + *, + var_name: str, + object_name: str, + tags_literal: str, + context_literal: str, + param_exprs_literal: str, + semantic_delta_literal: str, + topo_delta_literal: str, + ) -> Optional[List[str]]: + graph = self._source_graph + if graph is None: + return None + + rp = f"{var_name}_params" + re = f"{var_name}_param_exprs" + inputs = [inp.node_id for inp in node.inputs] + + def finish() -> List[str]: + return [ + f"_attach_simplecad_metadata({var_name}, node_id={_json_ascii(node.node_id)}, op={_json_ascii(node.op)}, params={rp}, inputs={var_name}_inputs, tags={tags_literal}, context={context_literal}, output_count={node.output_count}, param_exprs={param_exprs_literal}, semantic_delta={semantic_delta_literal}, topo_delta={topo_delta_literal})", + f"GRAPH_NODES[{_json_ascii(node.node_id)}] = {var_name}", + f"GRAPH_METADATA[{_json_ascii(node.node_id)}] = {{'op': {_json_ascii(node.op)}, 'params': {rp}, 'inputs': {var_name}_inputs, 'context': {context_literal}, 'tags': {tags_literal}}}", + f"GRAPH_OUTPUTS[{_json_ascii(node.node_id)}] = [{var_name}]", + ] + + def finish_ir() -> List[str]: + return [ + f"{var_name} = _register_ir_node({_json_ascii(object_name)}, node_id={_json_ascii(node.node_id)}, op={_json_ascii(node.op)}, params={rp}, inputs={var_name}_inputs, tags={tags_literal}, context={context_literal}, output_count={node.output_count}, param_exprs={param_exprs_literal}, semantic_delta={semantic_delta_literal}, topo_delta={topo_delta_literal})" + ] + + def finish_alias(source_node_id: str) -> List[str]: + return [ + f"{var_name} = _register_graph_alias(node_id={_json_ascii(node.node_id)}, source_node_id={_json_ascii(source_node_id)}, op={_json_ascii(node.op)}, params={rp}, inputs={var_name}_inputs, tags={tags_literal}, context={context_literal}, output_count={node.output_count}, param_exprs={param_exprs_literal}, semantic_delta={semantic_delta_literal}, topo_delta={topo_delta_literal})" + ] + + if node.op == "make_cut_rsolid" and len(inputs) >= 2: + if len(inputs) == 2: + base_name = _safe_name(f"{object_name}_base", prefix="operand") + tool_name = _safe_name(f"{object_name}_tool", prefix="operand") + lines = [ + "doc.recompute()", + f"{var_name} = doc.addObject('Part::Cut', {_json_ascii(object_name)})", + f"{var_name}.Base = _materialize_boolean_operand(GRAPH_NODES[{_json_ascii(inputs[0])}], {_json_ascii(base_name)})", + f"{var_name}.Tool = _materialize_boolean_operand(GRAPH_NODES[{_json_ascii(inputs[1])}], {_json_ascii(tool_name)})", + ] + else: + lines = ["doc.recompute()"] + base_name = _safe_name(f"{object_name}_base", prefix="operand") + previous_expr = f"_materialize_boolean_operand(GRAPH_NODES[{_json_ascii(inputs[0])}], {_json_ascii(base_name)})" + for index, tool_node_id in enumerate(inputs[1:], start=1): + is_last = index == len(inputs) - 1 + step_var = var_name if is_last else f"{var_name}_step_{index}" + step_name = object_name if is_last else _safe_name( + f"{object_name}_step_{index}", prefix="step" + ) + tool_name = _safe_name(f"{object_name}_tool_{index}", prefix="operand") + lines.extend( + [ + f"{step_var} = doc.addObject('Part::Cut', {_json_ascii(step_name)})", + f"{step_var}.Base = {previous_expr}", + f"{step_var}.Tool = _materialize_boolean_operand(GRAPH_NODES[{_json_ascii(tool_node_id)}], {_json_ascii(tool_name)})", + ] + ) + if not is_last: + lines.append(f"_set_visibility({step_var}, False)") + lines.append("doc.recompute()") + previous_expr = step_var + lines.extend(finish()) + return lines + if node.op == "make_union_rsolid" and len(inputs) >= 2: + if len(inputs) == 2: + base_name = _safe_name(f"{object_name}_base", prefix="operand") + tool_name = _safe_name(f"{object_name}_tool", prefix="operand") + lines = [ + "doc.recompute()", + f"{var_name} = doc.addObject('Part::Fuse', {_json_ascii(object_name)})", + f"{var_name}.Base = _materialize_boolean_operand(GRAPH_NODES[{_json_ascii(inputs[0])}], {_json_ascii(base_name)})", + f"{var_name}.Tool = _materialize_boolean_operand(GRAPH_NODES[{_json_ascii(inputs[1])}], {_json_ascii(tool_name)})", + ] + else: + lines = [ + "doc.recompute()", + f"{var_name} = doc.addObject('Part::MultiFuse', {_json_ascii(object_name)})", + f"{var_name}.Shapes = [_materialize_boolean_operand(GRAPH_NODES[node_id], {_json_ascii(object_name)} + '_operand_' + str(index)) for index, node_id in enumerate({var_name}_inputs)]", + ] + lines.extend(finish()) + return lines + if node.op == "make_intersect_rsolid" and len(inputs) >= 2: + if len(inputs) == 2: + base_name = _safe_name(f"{object_name}_base", prefix="operand") + tool_name = _safe_name(f"{object_name}_tool", prefix="operand") + lines = [ + f"{var_name} = doc.addObject('Part::Common', {_json_ascii(object_name)})", + f"{var_name}.Base = _materialize_boolean_operand(GRAPH_NODES[{_json_ascii(inputs[0])}], {_json_ascii(base_name)})", + f"{var_name}.Tool = _materialize_boolean_operand(GRAPH_NODES[{_json_ascii(inputs[1])}], {_json_ascii(tool_name)})", + ] + else: + lines = [ + f"{var_name} = doc.addObject('Part::MultiCommon', {_json_ascii(object_name)})", + f"{var_name}.Shapes = [_materialize_boolean_operand(GRAPH_NODES[node_id], {_json_ascii(object_name)} + '_operand_' + str(index)) for index, node_id in enumerate({var_name}_inputs)]", + ] + lines.extend(finish()) + return lines + if node.op in {"make_2d_cut_rface", "make_2d_union_rface", "make_2d_intersect_rface"} and len(inputs) >= 2: + lines = [ + "doc.recompute()", + f"{var_name} = _make_feature({_json_ascii(object_name)}, _face_boolean_shape({_json_ascii(node.op)}, GRAPH_NODES[{_json_ascii(inputs[0])}], GRAPH_NODES[{_json_ascii(inputs[1])}]), node_id={_json_ascii(node.node_id)}, op={_json_ascii(node.op)}, params={rp}, inputs={var_name}_inputs, tags={tags_literal}, context={context_literal}, output_count={node.output_count}, param_exprs={param_exprs_literal}, semantic_delta={semantic_delta_literal}, topo_delta={topo_delta_literal})", + ] + return lines + if node.op == "make_fillet_rsolid" and len(inputs) >= 1: + lines = [ + f"{var_name} = doc.addObject('Part::Fillet', {_json_ascii(object_name)})", + f"{var_name}.Base = GRAPH_NODES[{_json_ascii(inputs[0])}]", + f"{var_name}.Edges = [(int(idx) + 1, float(_resolve_param_value({rp}, {re}, 'radius')), float(_resolve_param_value({rp}, {re}, 'radius'))) for idx in _selected_indices_from_nodes({rp}.get('selected_edge_node_ids', []), {rp}.get('selected_edge_indices', []), _shape_from_graph_node({_json_ascii(inputs[0])}), 'edge')]", + ] + lines.append(f"_apply_detail_feature_bindings({var_name}, {re}, 'radius')") + lines.extend(finish()) + return lines + if node.op == "make_chamfer_rsolid" and len(inputs) >= 1: + lines = [ + f"{var_name} = doc.addObject('Part::Chamfer', {_json_ascii(object_name)})", + f"{var_name}.Base = GRAPH_NODES[{_json_ascii(inputs[0])}]", + f"{var_name}.Edges = [(int(idx) + 1, float(_resolve_param_value({rp}, {re}, 'distance')), float(_resolve_param_value({rp}, {re}, 'distance'))) for idx in _selected_indices_from_nodes({rp}.get('selected_edge_node_ids', []), {rp}.get('selected_edge_indices', []), _shape_from_graph_node({_json_ascii(inputs[0])}), 'edge')]", + ] + lines.append( + f"_apply_detail_feature_bindings({var_name}, {re}, 'distance')" + ) + lines.extend(finish()) + return lines + if node.op == "make_shell_rsolid" and len(inputs) >= 1: + lines = [ + f"{var_name} = doc.addObject('Part::Thickness', {_json_ascii(object_name)})", + f"{var_name}.Value = float(_resolve_param_value({rp}, {re}, 'thickness'))", + ] + lines.append( + f"_apply_op_expression_bindings({var_name}, {_json_ascii(node.op)}, {re})" + ) + if node.params.get("selected_face_indices") or node.params.get("selected_face_node_ids"): + face_name_expr = f"['Face' + str(int(i) + 1) for i in _selected_indices_from_nodes({rp}.get('selected_face_node_ids', []), {rp}.get('selected_face_indices', []), _shape_from_graph_node({_json_ascii(inputs[0])}), 'face')]" + lines.append( + f"{var_name}.Faces = (GRAPH_NODES[{_json_ascii(inputs[0])}], {face_name_expr})" + ) + lines.extend(finish()) + return lines + if node.op == "make_mirror_rshape" and len(inputs) == 1: + lines = [ + f"{var_name} = doc.addObject('Part::Mirroring', {_json_ascii(object_name)})", + f"{var_name}.Source = GRAPH_NODES[{_json_ascii(inputs[0])}]", + f"{var_name}.Base = _vec(_resolve_vec3_param({rp}, {re}, 'plane_origin') if 'plane_origin' in {rp} else (0.0, 0.0, 0.0))", + f"{var_name}.Normal = _vec(_resolve_vec3_param({rp}, {re}, 'plane_normal') if 'plane_normal' in {rp} else (0.0, 0.0, 1.0))", + ] + lines.append( + f"_apply_op_expression_bindings({var_name}, {_json_ascii(node.op)}, {re})" + ) + lines.extend(finish()) + return lines + return None + + +__all__ = ["BooleanEmitterMixin"] diff --git a/SimpleCADAPI/src/simplecadapi/translator/freecad_translator/emitters/features.py b/SimpleCADAPI/src/simplecadapi/translator/freecad_translator/emitters/features.py new file mode 100644 index 0000000..f1f587d --- /dev/null +++ b/SimpleCADAPI/src/simplecadapi/translator/freecad_translator/emitters/features.py @@ -0,0 +1,175 @@ +"""FreeCAD operation emitters for one canonical graph domain.""" + +from __future__ import annotations + +from typing import List, Optional + +from ....topology import OperationNode +from ..codegen import * + + +_TWISTED_SWEEP_EMULATION_LIMITATION = ( + "FreeCAD has no auxiliary-spine twisted-sweep feature. This object is a " + "smooth solid loft through rotated and translated profile sections; it " + "approximates the continuous SimpleCAD sweep, and expressions are evaluated " + "when the generated script runs rather than remaining dynamically bound." +) + + +class FeatureEmitterMixin: + def _emit_features( + self, + node: OperationNode, + *, + var_name: str, + object_name: str, + tags_literal: str, + context_literal: str, + param_exprs_literal: str, + semantic_delta_literal: str, + topo_delta_literal: str, + ) -> Optional[List[str]]: + graph = self._source_graph + if graph is None: + return None + + rp = f"{var_name}_params" + re = f"{var_name}_param_exprs" + inputs = [inp.node_id for inp in node.inputs] + + def finish() -> List[str]: + return [ + f"_attach_simplecad_metadata({var_name}, node_id={_json_ascii(node.node_id)}, op={_json_ascii(node.op)}, params={rp}, inputs={var_name}_inputs, tags={tags_literal}, context={context_literal}, output_count={node.output_count}, param_exprs={param_exprs_literal}, semantic_delta={semantic_delta_literal}, topo_delta={topo_delta_literal})", + f"GRAPH_NODES[{_json_ascii(node.node_id)}] = {var_name}", + f"GRAPH_METADATA[{_json_ascii(node.node_id)}] = {{'op': {_json_ascii(node.op)}, 'params': {rp}, 'inputs': {var_name}_inputs, 'context': {context_literal}, 'tags': {tags_literal}}}", + f"GRAPH_OUTPUTS[{_json_ascii(node.node_id)}] = [{var_name}]", + ] + + def finish_ir() -> List[str]: + return [ + f"{var_name} = _register_ir_node({_json_ascii(object_name)}, node_id={_json_ascii(node.node_id)}, op={_json_ascii(node.op)}, params={rp}, inputs={var_name}_inputs, tags={tags_literal}, context={context_literal}, output_count={node.output_count}, param_exprs={param_exprs_literal}, semantic_delta={semantic_delta_literal}, topo_delta={topo_delta_literal})" + ] + + def finish_alias(source_node_id: str) -> List[str]: + return [ + f"{var_name} = _register_graph_alias(node_id={_json_ascii(node.node_id)}, source_node_id={_json_ascii(source_node_id)}, op={_json_ascii(node.op)}, params={rp}, inputs={var_name}_inputs, tags={tags_literal}, context={context_literal}, output_count={node.output_count}, param_exprs={param_exprs_literal}, semantic_delta={semantic_delta_literal}, topo_delta={topo_delta_literal})" + ] + + if node.op == "make_extrude_rsolid" and len(inputs) == 1: + base_node = graph.get_node(inputs[0]) + profile_node = base_node + if ( + profile_node is not None + and profile_node.op == "make_face_from_wire_rface" + and profile_node.inputs + ): + profile_node = graph.get_node(profile_node.inputs[0].node_id) + circle_node = None + if ( + profile_node is not None + and profile_node.op == "make_wire_from_edges_rwire" + and len(profile_node.inputs) == 1 + ): + edge_node = graph.get_node(profile_node.inputs[0].node_id) + if edge_node is not None and edge_node.op == "make_circle_redge": + circle_node = edge_node + if circle_node is not None and self._can_lower_circle_extrusion_to_cylinder( + circle_node, node + ): + circle_var = _safe_name(circle_node.node_id) + lines = [ + f"{var_name} = doc.addObject('Part::Cylinder', {_json_ascii(object_name)})", + f"{var_name}.Radius = float(_resolve_param_value({circle_var}_params, {circle_var}_param_exprs, 'radius'))", + f"{var_name}.Height = float(_resolve_param_value({rp}, {re}, 'distance'))", + f"{var_name}.Placement = App.Placement(_vec(_resolve_vec3_param({circle_var}_params, {circle_var}_param_exprs, 'center')), _periodic_axis_rotation(_resolve_vec3_param({rp}, {re}, 'direction'), {circle_var}_params.get('_kernel_x_axis'), {circle_var}_params.get('_kernel_y_axis')))", + ] + lines.extend(finish()) + return lines + if base_node is not None and base_node.op in { + "make_face_from_wire_rface", + "make_face_from_wires_rface", + "make_wire_from_edges_rwire", + "make_face_from_sketch_rface", + "make_wire_from_sketch_rwire", + "make_2d_cut_rface", + "make_2d_union_rface", + "make_2d_intersect_rface", + }: + sketch_node_id = inputs[0] + if base_node.op == "make_face_from_wire_rface" and base_node.inputs: + sketch_node_id = base_node.inputs[0].node_id + base_expr = f"GRAPH_NODES[{_json_ascii(sketch_node_id)}]" + lines: List[str] = [] + lines.extend( + [ + f"{var_name} = doc.addObject('Part::Extrusion', {_json_ascii(object_name)})", + f"{var_name}.Base = {base_expr}", + f"{var_name}.DirMode = 'Custom'", + f"{var_name}.Dir = _vec(_resolve_vec3_param({rp}, {re}, 'direction'))", + f"{var_name}.LengthFwd = float(_resolve_param_value({rp}, {re}, 'distance'))", + f"{var_name}.LengthRev = 0.0", + f"{var_name}.Solid = True", + ] + ) + lines.append( + f"_apply_op_expression_bindings({var_name}, {_json_ascii(node.op)}, {re})" + ) + lines.extend(finish()) + return lines + if node.op == "make_revolve_rsolid" and len(inputs) == 1: + base_node = graph.get_node(inputs[0]) + if base_node is not None and base_node.op in { + "make_face_from_wire_rface", + "make_wire_from_edges_rwire", + "make_wire_from_sketch_rwire", + }: + source_expr = f"GRAPH_NODES[{_json_ascii(inputs[0])}]" + lines: List[str] = [] + lines.extend([ + f"{var_name} = doc.addObject('Part::Revolution', {_json_ascii(object_name)})", + f"{var_name}.Source = {source_expr}", + f"{var_name}.Axis = _vec(_resolve_vec3_param({rp}, {re}, 'axis') if 'axis' in {rp} else (0.0, 0.0, 1.0))", + f"{var_name}.Base = _vec(_resolve_vec3_param({rp}, {re}, 'origin') if 'origin' in {rp} else (0.0, 0.0, 0.0))", + f"{var_name}.Angle = float(_resolve_param_value({rp}, {re}, 'angle') if 'angle' in {rp} else 360.0)", + f"{var_name}.Solid = True", + ]) + lines.append( + f"_apply_op_expression_bindings({var_name}, {_json_ascii(node.op)}, {re})" + ) + lines.extend(finish()) + return lines + if node.op == "make_loft_rsolid" and len(inputs) >= 2: + lines = [ + f"{var_name} = doc.addObject('Part::Loft', {_json_ascii(object_name)})", + f"{var_name}.Sections = [GRAPH_NODES[node_id] for node_id in {var_name}_inputs]", + f"{var_name}.Solid = True", + f"{var_name}.Ruled = bool(_resolve_param_value({rp}, {re}, 'ruled') if 'ruled' in {rp} else False)", + ] + lines.append( + f"_apply_op_expression_bindings({var_name}, {_json_ascii(node.op)}, {re})" + ) + lines.extend(finish()) + return lines + if node.op == "make_sweep_rsolid" and len(inputs) == 2: + lines = [ + f"{var_name} = doc.addObject('Part::Sweep', {_json_ascii(object_name)})", + f"{var_name}.Sections = [GRAPH_NODES[{_json_ascii(inputs[0])}]]", + f"{var_name}.Spine = _spine_object({_json_ascii(inputs[1])})", + f"{var_name}.Solid = True", + f"{var_name}.Frenet = bool(_resolve_param_value({rp}, {re}, 'is_frenet') if 'is_frenet' in {rp} else False)", + ] + lines.append( + f"_apply_op_expression_bindings({var_name}, {_json_ascii(node.op)}, {re})" + ) + lines.extend(finish()) + return lines + if node.op == "make_twisted_sweep_rsolid" and len(inputs) == 1: + lines = [ + f"{var_name} = _make_feature({_json_ascii(object_name)}, _twisted_sweep_loft_shape(GRAPH_NODES[{_json_ascii(inputs[0])}], axis=_resolve_vec3_param({rp}, {re}, 'axis'), origin=_resolve_vec3_param({rp}, {re}, 'origin'), distance=float(_resolve_param_value({rp}, {re}, 'distance')), twist_angle=float(_resolve_param_value({rp}, {re}, 'twist_angle'))), node_id={_json_ascii(node.node_id)}, op={_json_ascii(node.op)}, params={rp}, inputs={var_name}_inputs, tags={tags_literal}, context={context_literal}, output_count={node.output_count}, param_exprs={param_exprs_literal}, semantic_delta={semantic_delta_literal}, topo_delta={topo_delta_literal})", + f"_mark_emulated_translation({var_name}, node_id={_json_ascii(node.node_id)}, op={_json_ascii(node.op)}, reason={_json_ascii(_TWISTED_SWEEP_EMULATION_LIMITATION)})", + ] + return lines + return None + + +__all__ = ["FeatureEmitterMixin"] diff --git a/SimpleCADAPI/src/simplecadapi/translator/freecad_translator/emitters/geometry.py b/SimpleCADAPI/src/simplecadapi/translator/freecad_translator/emitters/geometry.py new file mode 100644 index 0000000..daaef57 --- /dev/null +++ b/SimpleCADAPI/src/simplecadapi/translator/freecad_translator/emitters/geometry.py @@ -0,0 +1,440 @@ +"""FreeCAD operation emitters for one canonical graph domain.""" + +from __future__ import annotations + +from typing import List, Optional + +from ....topology import OperationNode +from ..codegen import * + + +_DYNAMIC_CURVE_NORMAL_LIMITATION = ( + "FreeCAD Sketcher cannot bind a sketch placement orientation to this vector " + "expression. The translated profile uses the expression's value when the " + "script runs, but later spreadsheet edits do not reorient the sketch." +) + + +class GeometryEmitterMixin: + def _emit_geometry( + self, + node: OperationNode, + *, + var_name: str, + object_name: str, + tags_literal: str, + context_literal: str, + param_exprs_literal: str, + semantic_delta_literal: str, + topo_delta_literal: str, + ) -> Optional[List[str]]: + graph = self._source_graph + if graph is None: + return None + + rp = f"{var_name}_params" + re = f"{var_name}_param_exprs" + inputs = [inp.node_id for inp in node.inputs] + + def finish() -> List[str]: + return [ + f"_attach_simplecad_metadata({var_name}, node_id={_json_ascii(node.node_id)}, op={_json_ascii(node.op)}, params={rp}, inputs={var_name}_inputs, tags={tags_literal}, context={context_literal}, output_count={node.output_count}, param_exprs={param_exprs_literal}, semantic_delta={semantic_delta_literal}, topo_delta={topo_delta_literal})", + f"GRAPH_NODES[{_json_ascii(node.node_id)}] = {var_name}", + f"GRAPH_METADATA[{_json_ascii(node.node_id)}] = {{'op': {_json_ascii(node.op)}, 'params': {rp}, 'inputs': {var_name}_inputs, 'context': {context_literal}, 'tags': {tags_literal}}}", + f"GRAPH_OUTPUTS[{_json_ascii(node.node_id)}] = [{var_name}]", + ] + + def finish_ir() -> List[str]: + return [ + f"{var_name} = _register_ir_node({_json_ascii(object_name)}, node_id={_json_ascii(node.node_id)}, op={_json_ascii(node.op)}, params={rp}, inputs={var_name}_inputs, tags={tags_literal}, context={context_literal}, output_count={node.output_count}, param_exprs={param_exprs_literal}, semantic_delta={semantic_delta_literal}, topo_delta={topo_delta_literal})" + ] + + def finish_alias(source_node_id: str) -> List[str]: + return [ + f"{var_name} = _register_graph_alias(node_id={_json_ascii(node.node_id)}, source_node_id={_json_ascii(source_node_id)}, op={_json_ascii(node.op)}, params={rp}, inputs={var_name}_inputs, tags={tags_literal}, context={context_literal}, output_count={node.output_count}, param_exprs={param_exprs_literal}, semantic_delta={semantic_delta_literal}, topo_delta={topo_delta_literal})" + ] + + if node.node_id in self._suppressed_profile_node_ids: + return finish_ir() + if node.op == "make_line_redge": + lines = [ + f"{var_name} = _register_graph_value(Part.makeLine(_vec(_resolve_vec3_param({rp}, {re}, 'start')), _vec(_resolve_vec3_param({rp}, {re}, 'end'))), node_id={_json_ascii(node.node_id)}, op={_json_ascii(node.op)}, params={rp}, inputs={var_name}_inputs, tags={tags_literal}, context={context_literal}, output_count={node.output_count}, param_exprs={param_exprs_literal}, semantic_delta={semantic_delta_literal}, topo_delta={topo_delta_literal})" + ] + return lines + if node.op == "make_circle_redge": + lines = [ + f"{var_name} = _register_graph_value(_kernel_circle_from_params({rp}, {re}).toShape(), node_id={_json_ascii(node.node_id)}, op={_json_ascii(node.op)}, params={rp}, inputs={var_name}_inputs, tags={tags_literal}, context={context_literal}, output_count={node.output_count}, param_exprs={param_exprs_literal}, semantic_delta={semantic_delta_literal}, topo_delta={topo_delta_literal})" + ] + return lines + if node.op == "make_angle_arc_redge": + lines = [ + f"{var_name} = _register_graph_value(Part.ArcOfCircle(_kernel_circle_from_params({rp}, {re}), float(_resolve_param_value({rp}, {re}, 'start_angle')), float(_resolve_param_value({rp}, {re}, 'end_angle'))).toShape(), node_id={_json_ascii(node.node_id)}, op={_json_ascii(node.op)}, params={rp}, inputs={var_name}_inputs, tags={tags_literal}, context={context_literal}, output_count={node.output_count}, param_exprs={param_exprs_literal}, semantic_delta={semantic_delta_literal}, topo_delta={topo_delta_literal})" + ] + return lines + if node.op == "make_three_point_arc_redge": + arc_expr = f"Part.Arc(_vec(_resolve_vec3_param({rp}, {re}, 'start')), _vec(_resolve_vec3_param({rp}, {re}, 'middle')), _vec(_resolve_vec3_param({rp}, {re}, 'end'))).toShape()" + lines = [ + f"{var_name} = _register_graph_value({arc_expr}, node_id={_json_ascii(node.node_id)}, op={_json_ascii(node.op)}, params={rp}, inputs={var_name}_inputs, tags={tags_literal}, context={context_literal}, output_count={node.output_count}, param_exprs={param_exprs_literal}, semantic_delta={semantic_delta_literal}, topo_delta={topo_delta_literal})" + ] + return lines + if node.op == "make_spline_redge": + lines = [ + f"{var_name} = _register_graph_value(_bspline_curve_from_params({rp}).toShape(), node_id={_json_ascii(node.node_id)}, op={_json_ascii(node.op)}, params={rp}, inputs={var_name}_inputs, tags={tags_literal}, context={context_literal}, output_count={node.output_count}, param_exprs={param_exprs_literal}, semantic_delta={semantic_delta_literal}, topo_delta={topo_delta_literal})" + ] + return lines + if node.op == "make_wire_from_edges_rwire": + input_nodes = [graph.get_node(node_id) for node_id in inputs] + if len(inputs) == 1: + single = input_nodes[0] + if single is not None and single.op == "make_helix_redge": + return finish_alias(inputs[0]) + if input_nodes and all( + inp is not None + and inp.op + in { + "make_line_redge", + "make_circle_redge", + "make_angle_arc_redge", + "make_three_point_arc_redge", + "make_spline_redge", + } + for inp in input_nodes + ): + if not any( + _contains_expr_refs(dict(inp.param_exprs)) + for inp in input_nodes + if inp is not None + ): + return [ + f"{var_name} = _make_feature({_json_ascii(object_name)}, _wire_shape_from_edge_objects({var_name}_inputs), node_id={_json_ascii(node.node_id)}, op={_json_ascii(node.op)}, params={rp}, inputs={var_name}_inputs, tags={tags_literal}, context={context_literal}, output_count={node.output_count}, param_exprs={param_exprs_literal}, semantic_delta={semantic_delta_literal}, topo_delta={topo_delta_literal})" + ] + lines = [ + f"{var_name} = doc.addObject('Sketcher::SketchObject', {_json_ascii(object_name)})", + f"{var_name}_sketch_bindings = []", + f"{var_name}_expr_limitations = []", + f"{var_name}_constraint_bindings = []", + ] + point_exprs: List[str] = [] + for geom_index, input_node in enumerate(input_nodes): + assert input_node is not None + edge_var = _safe_name(input_node.node_id) + edge_obj_expr = f"GRAPH_NODES[{_json_ascii(input_node.node_id)}]" + if input_node.op == "make_line_redge": + point_exprs.append(f"_edge_start_point({edge_obj_expr})") + point_exprs.append(f"_edge_end_point({edge_obj_expr})") + elif input_node.op == "make_three_point_arc_redge": + point_exprs.append(f"_edge_start_point({edge_obj_expr})") + point_exprs.append( + f"_resolve_vec3_param({edge_var}_params, {edge_var}_param_exprs, 'middle')" + ) + point_exprs.append(f"_edge_end_point({edge_obj_expr})") + elif input_node.op == "make_circle_redge": + point_exprs.append( + f"_resolve_vec3_param({edge_var}_params, {edge_var}_param_exprs, 'center')" + ) + elif input_node.op == "make_angle_arc_redge": + point_exprs.append(f"_edge_start_point({edge_obj_expr})") + point_exprs.append(f"_edge_mid_point({edge_obj_expr})") + point_exprs.append(f"_edge_end_point({edge_obj_expr})") + elif input_node.op == "make_spline_redge": + point_exprs.append(f"_edge_start_point({edge_obj_expr})") + point_exprs.append(f"_edge_mid_point({edge_obj_expr})") + point_exprs.append(f"_edge_end_point({edge_obj_expr})") + limitation_payload = _node_expression_limitation(input_node) + if ( + input_node.op + in {"make_circle_redge", "make_angle_arc_redge"} + and _contains_expr_refs(input_node.param_exprs.get("normal")) + ): + limitation_payload = { + "op": input_node.op, + "reason": _DYNAMIC_CURVE_NORMAL_LIMITATION, + } + if limitation_payload is not None: + lines.append( + f"{var_name}_expr_limitations.append({_py_literal(limitation_payload)})" + ) + if ( + len(input_nodes) == 1 + and input_nodes[0] is not None + and input_nodes[0].op == "make_line_redge" + ): + edge_var = _safe_name(input_nodes[0].node_id) + lines.append( + f"{var_name}_placement, {var_name}_length = _line_sketch_placement(_resolve_vec3_param({edge_var}_params, {edge_var}_param_exprs, 'start'), _resolve_vec3_param({edge_var}_params, {edge_var}_param_exprs, 'end'))" + ) + lines.append(f"{var_name}.Placement = {var_name}_placement") + else: + frame_points = "[" + ", ".join(point_exprs) + "]" + preferred_normal_expr = "None" + if all( + input_node is not None + and input_node.op == "make_circle_redge" + for input_node in input_nodes + ): + circle_var = _safe_name(input_nodes[0].node_id) + preferred_normal_expr = ( + f"_resolve_vec3_param({circle_var}_params, " + f"{circle_var}_param_exprs, 'normal') " + f"if 'normal' in {circle_var}_params else (0.0, 0.0, 1.0)" + ) + lines.append( + f"{var_name}_placement, {var_name}_origin, {var_name}_xaxis, {var_name}_yaxis = _frame_from_points({frame_points}, {context_literal}, {preferred_normal_expr})" + ) + lines.append(f"{var_name}.Placement = {var_name}_placement") + for geom_index, input_node in enumerate(input_nodes): + assert input_node is not None + edge_var = _safe_name(input_node.node_id) + edge_obj_expr = f"GRAPH_NODES[{_json_ascii(input_node.node_id)}]" + if input_node.op == "make_line_redge": + if len(input_nodes) == 1: + lines.append( + f"{var_name}_placement, {var_name}_length = _line_sketch_placement(_resolve_vec3_param({edge_var}_params, {edge_var}_param_exprs, 'start'), _resolve_vec3_param({edge_var}_params, {edge_var}_param_exprs, 'end'))" + ) + lines.append(f"{var_name}.Placement = {var_name}_placement") + lines.append( + f"{var_name}.addGeometry(Part.LineSegment(App.Vector(0.0, 0.0, 0.0), App.Vector({var_name}_length, 0.0, 0.0)), False)" + ) + lines.append( + f"{var_name}_length_constraint_{geom_index} = {var_name}.addConstraint(Sketcher.Constraint('Distance', {geom_index}, float({var_name}_length)))" + ) + lines.append( + f"{var_name}_dx_constraint_{geom_index} = {var_name}.addConstraint(Sketcher.Constraint('DistanceX', {geom_index}, 1, {geom_index}, 2, float(_resolve_nested_param_value({edge_var}_params, {edge_var}_param_exprs, 'end', 0)) - float(_resolve_nested_param_value({edge_var}_params, {edge_var}_param_exprs, 'start', 0))))" + ) + lines.append( + f"{var_name}_dy_constraint_{geom_index} = {var_name}.addConstraint(Sketcher.Constraint('DistanceY', {geom_index}, 1, {geom_index}, 2, float(_resolve_nested_param_value({edge_var}_params, {edge_var}_param_exprs, 'end', 1)) - float(_resolve_nested_param_value({edge_var}_params, {edge_var}_param_exprs, 'start', 1))))" + ) + lines.append( + f"{var_name}_sketch_bindings.append(('Placement.Base.x', _nested_expr_ref({edge_var}_param_exprs, 'start', 0)))" + ) + lines.append( + f"{var_name}_sketch_bindings.append(('Placement.Base.y', _nested_expr_ref({edge_var}_param_exprs, 'start', 1)))" + ) + lines.append( + f"{var_name}_sketch_bindings.append(('Placement.Base.z', _nested_expr_ref({edge_var}_param_exprs, 'start', 2)))" + ) + lines.append( + f"{var_name}_length_formula = _line_length_formula({edge_var}_params, {edge_var}_param_exprs)" + ) + lines.append( + f"{var_name}.setExpression('Geometry[{geom_index}].EndPoint.x', {var_name}_length_formula) if {var_name}_length_formula else None" + ) + lines.append( + f"{var_name}_constraint_bindings.append((f'Constraints[{{{var_name}_length_constraint_{geom_index}}}]', {var_name}_length_formula))" + ) + lines.append( + f"{var_name}_constraint_bindings.append((f'Constraints[{{{var_name}_dx_constraint_{geom_index}}}]', {_json_ascii(self._line_delta_formula(dict(input_node.param_exprs), 0)) if self._line_delta_formula(dict(input_node.param_exprs), 0) is not None else 'None'}))" + ) + lines.append( + f"{var_name}_constraint_bindings.append((f'Constraints[{{{var_name}_dy_constraint_{geom_index}}}]', {_json_ascii(self._line_delta_formula(dict(input_node.param_exprs), 1)) if self._line_delta_formula(dict(input_node.param_exprs), 1) is not None else 'None'}))" + ) + else: + lines.append( + f"{var_name}.addGeometry(_local_line_from_edge({edge_obj_expr}, {var_name}_origin, {var_name}_xaxis, {var_name}_yaxis), False)" + ) + lines.append( + f"{var_name}_length_formula_{geom_index} = _line_length_formula({edge_var}_params, {edge_var}_param_exprs)" + ) + lines.append( + f"{var_name}_length_value_{geom_index} = _resolve_param_value({edge_var}_params, {edge_var}_param_exprs, 'length') if 'length' in {edge_var}_params else {var_name}.Geometry[{geom_index}].length()" + ) + lines.append( + f"{var_name}_length_constraint_{geom_index} = {var_name}.addConstraint(Sketcher.Constraint('Distance', {geom_index}, float({var_name}_length_value_{geom_index})))" + ) + lines.append( + f"{var_name}_sketch_bindings.extend(_build_local_line_sketch_bindings({edge_var}_params, {edge_var}_param_exprs, geom_index={geom_index}, origin={var_name}_origin, x_axis={var_name}_xaxis, y_axis={var_name}_yaxis))" + ) + lines.append( + f"{var_name}_constraint_bindings.append((f'Constraints[{{{var_name}_length_constraint_{geom_index}}}]', {var_name}_length_formula_{geom_index}))" + ) + elif input_node.op == "make_circle_redge": + lines.append( + f"{var_name}.addGeometry(Part.Circle(_local_point_on_frame(_resolve_vec3_param({edge_var}_params, {edge_var}_param_exprs, 'center'), {var_name}_origin, {var_name}_xaxis, {var_name}_yaxis), App.Vector(0.0, 0.0, 1.0), float(_resolve_param_value({edge_var}_params, {edge_var}_param_exprs, 'radius'))), False)" + ) + lines.append( + f"{var_name}_diameter_constraint_{geom_index} = {var_name}.addConstraint(Sketcher.Constraint('Diameter', {geom_index}, 2.0 * float(_resolve_param_value({edge_var}_params, {edge_var}_param_exprs, 'radius'))))" + ) + if len(input_nodes) == 1: + lines.append( + f"{var_name}_sketch_bindings.append(('Placement.Base.x', _nested_expr_ref({edge_var}_param_exprs, 'center', 0)))" + ) + lines.append( + f"{var_name}_sketch_bindings.append(('Placement.Base.y', _nested_expr_ref({edge_var}_param_exprs, 'center', 1)))" + ) + lines.append( + f"{var_name}_sketch_bindings.append(('Placement.Base.z', _nested_expr_ref({edge_var}_param_exprs, 'center', 2)))" + ) + lines.append( + f"{var_name}_radius_expr_{geom_index} = _expr_formula_from_ref(_nested_expr_ref({edge_var}_param_exprs, 'radius'))" + ) + lines.append( + f"{var_name}_constraint_bindings.append((f'Constraints[{{{var_name}_diameter_constraint_{geom_index}}}]', f'2 * ({{{var_name}_radius_expr_{geom_index}}})' if {var_name}_radius_expr_{geom_index} else None))" + ) + else: + lines.append( + f"{var_name}_sketch_bindings.extend(_build_local_circle_sketch_bindings({edge_var}_params, {edge_var}_param_exprs, geom_index={geom_index}, origin={var_name}_origin, x_axis={var_name}_xaxis, y_axis={var_name}_yaxis))" + ) + lines.append( + f"{var_name}_radius_expr_{geom_index} = _expr_formula_from_ref(_nested_expr_ref({edge_var}_param_exprs, 'radius'))" + ) + lines.append( + f"{var_name}_constraint_bindings.append((f'Constraints[{{{var_name}_diameter_constraint_{geom_index}}}]', f'2 * ({{{var_name}_radius_expr_{geom_index}}})' if {var_name}_radius_expr_{geom_index} else None))" + ) + elif input_node.op == "make_angle_arc_redge": + arc_span_formula = self._angle_arc_span_formula( + dict(input_node.param_exprs) + ) + arc_radius_formula = self._compile_time_expr_formula( + _compile_time_nested_expr_ref( + dict(input_node.param_exprs), "radius" + ) + ) + lines.append( + f"{var_name}.addGeometry(_local_arc_from_edge({edge_obj_expr}, {var_name}_origin, {var_name}_xaxis, {var_name}_yaxis), False)" + ) + lines.append( + f"{var_name}_radius_constraint_{geom_index} = {var_name}.addConstraint(Sketcher.Constraint('Radius', {geom_index}, float(_resolve_param_value({edge_var}_params, {edge_var}_param_exprs, 'radius'))))" + ) + lines.append( + f"{var_name}_angle_constraint_{geom_index} = {var_name}.addConstraint(Sketcher.Constraint('Angle', {geom_index}, float(_resolve_param_value({edge_var}_params, {edge_var}_param_exprs, 'end_angle')) - float(_resolve_param_value({edge_var}_params, {edge_var}_param_exprs, 'start_angle'))))" + ) + lines.append( + f"{var_name}_sketch_bindings.extend(_build_local_angle_arc_sketch_bindings({edge_var}_params, {edge_var}_param_exprs, geom_index={geom_index}, origin={var_name}_origin, x_axis={var_name}_xaxis, y_axis={var_name}_yaxis))" + ) + lines.append( + f"{var_name}_constraint_bindings.append((f'Constraints[{{{var_name}_radius_constraint_{geom_index}}}]', {_json_ascii(arc_radius_formula) if arc_radius_formula is not None else 'None'}))" + ) + lines.append( + f"{var_name}_constraint_bindings.append((f'Constraints[{{{var_name}_angle_constraint_{geom_index}}}]', {_json_ascii(arc_span_formula) if arc_span_formula is not None else 'None'}))" + ) + elif input_node.op == "make_spline_redge": + lines.append( + f"{var_name}.addGeometry(_bspline_curve_from_params({edge_var}_params, transform_point=lambda point: _local_point_on_frame(point, {var_name}_origin, {var_name}_xaxis, {var_name}_yaxis)), False)" + ) + elif input_node.op == "make_three_point_arc_redge": + lines.append( + f"{var_name}.addGeometry(_local_arc_from_edge({edge_obj_expr}, {var_name}_origin, {var_name}_xaxis, {var_name}_yaxis), False)" + ) + lines.append( + f"{var_name}_sketch_bindings.extend(_build_local_three_point_arc_sketch_bindings({edge_var}_params, {edge_var}_param_exprs, geom_index={geom_index}, origin={var_name}_origin, x_axis={var_name}_xaxis, y_axis={var_name}_yaxis))" + ) + lines.append( + f"_apply_sketch_expression_bindings({var_name}, {var_name}_sketch_bindings)" + ) + for pair in _coincident_constraint_pairs(input_nodes): + lines.append( + f"{var_name}.addConstraint(Sketcher.Constraint('Coincident', {pair[0]}, {pair[1]}, {pair[2]}, {pair[3]}))" + ) + lines.append( + f"[_bind_expression({var_name}, prop, expr) for prop, expr in {var_name}_constraint_bindings if expr]" + ) + lines.extend(finish()) + lines.append( + f"{var_name}.SimpleCADExprSupport = 'limited' if {var_name}_expr_limitations else {var_name}.SimpleCADExprSupport" + ) + lines.append( + f"{var_name}.SimpleCADExprLimitation = json.dumps({var_name}_expr_limitations, ensure_ascii=True, sort_keys=True) if {var_name}_expr_limitations else {var_name}.SimpleCADExprLimitation" + ) + lines.append(f"if {var_name}_expr_limitations:") + lines.append( + f" GRAPH_LIMITATIONS[{_json_ascii(node.node_id)}] = {{'op': {_json_ascii(node.op)}, 'reason': json.dumps({var_name}_expr_limitations, ensure_ascii=True, sort_keys=True)}}" + ) + return lines + lines = [ + f"{var_name} = _make_feature({_json_ascii(object_name)}, _wire_shape_from_edge_objects({var_name}_inputs), node_id={_json_ascii(node.node_id)}, op={_json_ascii(node.op)}, params={rp}, inputs={var_name}_inputs, tags={tags_literal}, context={context_literal}, output_count={node.output_count}, param_exprs={param_exprs_literal}, semantic_delta={semantic_delta_literal}, topo_delta={topo_delta_literal})" + ] + return lines + if node.op == "make_helix_redge": + lines = [ + f"{var_name} = _make_native_object('Part::Helix', {_json_ascii(object_name)}, node_id={_json_ascii(node.node_id)}, op={_json_ascii(node.op)}, params={rp}, inputs={var_name}_inputs, tags={tags_literal}, context={context_literal}, output_count={node.output_count}, param_exprs={param_exprs_literal}, semantic_delta={semantic_delta_literal}, topo_delta={topo_delta_literal})", + f"{var_name}.Pitch = float(_resolve_param_value({rp}, {re}, 'pitch'))", + f"{var_name}.Height = float(_resolve_param_value({rp}, {re}, 'height'))", + f"{var_name}.Radius = float(_resolve_param_value({rp}, {re}, 'radius'))", + f"{var_name}.Placement = App.Placement(_vec(_resolve_vec3_param({rp}, {re}, 'center') if 'center' in {rp} else (0.0, 0.0, 0.0)), App.Rotation(App.Vector(0.0, 0.0, 1.0), _vec(_resolve_vec3_param({rp}, {re}, 'dir') if 'dir' in {rp} else (0.0, 0.0, 1.0))))", + ] + lines.append( + f"_apply_op_expression_bindings({var_name}, {_json_ascii(node.op)}, {re})" + ) + return lines + if node.op == "make_face_from_wire_rface": + if inputs: + return [ + f"{var_name} = _make_feature({_json_ascii(object_name)}, _face_shape_from_wire_shape(GRAPH_NODES[{_json_ascii(inputs[0])}], {_json_ascii(node.op)}), node_id={_json_ascii(node.node_id)}, op={_json_ascii(node.op)}, params={rp}, inputs={var_name}_inputs, tags={tags_literal}, context={context_literal}, output_count={node.output_count}, param_exprs={param_exprs_literal}, semantic_delta={semantic_delta_literal}, topo_delta={topo_delta_literal})" + ] + input_node = graph.get_node(inputs[0]) if inputs else None + if input_node is not None and input_node.op == "make_wire_from_edges_rwire": + edge_nodes = [graph.get_node(inp.node_id) for inp in input_node.inputs] + if edge_nodes and all( + ed is not None and ed.op == "make_line_redge" for ed in edge_nodes + ): + lines = [ + f"{var_name} = doc.addObject('Sketcher::SketchObject', {_json_ascii(object_name)})" + ] + for edge_node in edge_nodes: + assert edge_node is not None + edge_var = _safe_name(edge_node.node_id) + lines.append( + f"{var_name}.addGeometry(Part.LineSegment(_vec(_resolve_vec3_param({edge_var}_params, {edge_var}_param_exprs, 'start')), _vec(_resolve_vec3_param({edge_var}_params, {edge_var}_param_exprs, 'end'))), False)" + ) + lines.extend(finish()) + return lines + if edge_nodes and all( + ed is not None and ed.op == "make_circle_redge" for ed in edge_nodes + ): + circle_node = edge_nodes[0] + assert circle_node is not None + circle_var = _safe_name(circle_node.node_id) + lines = [ + f"{var_name} = doc.addObject('Sketcher::SketchObject', {_json_ascii(object_name)})", + f"{var_name}.addGeometry(Part.Circle(_vec(_resolve_vec3_param({circle_var}_params, {circle_var}_param_exprs, 'center')), _vec(_resolve_vec3_param({circle_var}_params, {circle_var}_param_exprs, 'normal') if 'normal' in {circle_var}_params else (0.0, 0.0, 1.0)), float(_resolve_param_value({circle_var}_params, {circle_var}_param_exprs, 'radius'))), False)", + f"_apply_sketch_expression_bindings({var_name}, _build_circle_sketch_bindings({circle_var}_param_exprs, geom_index=0, local=False))", + ] + lines.extend(finish()) + return lines + if edge_nodes and all( + ed is not None and ed.op == "make_angle_arc_redge" + for ed in edge_nodes + ): + arc_node = edge_nodes[0] + assert arc_node is not None + lines = [ + f"{var_name} = doc.addObject('Sketcher::SketchObject', {_json_ascii(object_name)})", + f"{var_name}.addGeometry(_arc_from_edge(GRAPH_NODES[{_json_ascii(arc_node.node_id)}]), False)", + f"_apply_sketch_expression_bindings({var_name}, _build_arc_sketch_bindings({_safe_name(arc_node.node_id)}_param_exprs, geom_index=0, prefer_local=False))", + ] + lines.extend(finish()) + return lines + if edge_nodes and all( + ed is not None and ed.op == "make_spline_redge" for ed in edge_nodes + ): + spline_node = edge_nodes[0] + assert spline_node is not None + spline_var = _safe_name(spline_node.node_id) + lines = [ + f"{var_name} = doc.addObject('Sketcher::SketchObject', {_json_ascii(object_name)})", + f"{var_name}.addGeometry(_bspline_curve_from_params({spline_var}_params), False)", + ] + lines.extend(finish()) + return lines + if edge_nodes and all( + ed is not None and ed.op == "make_three_point_arc_redge" + for ed in edge_nodes + ): + arc_node = edge_nodes[0] + assert arc_node is not None + arc_var = _safe_name(arc_node.node_id) + lines = [ + f"{var_name} = doc.addObject('Sketcher::SketchObject', {_json_ascii(object_name)})", + f"{var_name}.addGeometry(Part.ArcOfCircle(Part.Circle(Part.Arc(_vec(_resolve_vec3_param({arc_var}_params, {arc_var}_param_exprs, 'start')), _vec(_resolve_vec3_param({arc_var}_params, {arc_var}_param_exprs, 'middle')), _vec(_resolve_vec3_param({arc_var}_params, {arc_var}_param_exprs, 'end'))).toShape().Curve.Center, Part.Arc(_vec(_resolve_vec3_param({arc_var}_params, {arc_var}_param_exprs, 'start')), _vec(_resolve_vec3_param({arc_var}_params, {arc_var}_param_exprs, 'middle')), _vec(_resolve_vec3_param({arc_var}_params, {arc_var}_param_exprs, 'end'))).toShape().Curve.Axis, Part.Arc(_vec(_resolve_vec3_param({arc_var}_params, {arc_var}_param_exprs, 'start')), _vec(_resolve_vec3_param({arc_var}_params, {arc_var}_param_exprs, 'middle')), _vec(_resolve_vec3_param({arc_var}_params, {arc_var}_param_exprs, 'end'))).toShape().Curve.Radius), Part.Arc(_vec(_resolve_vec3_param({arc_var}_params, {arc_var}_param_exprs, 'start')), _vec(_resolve_vec3_param({arc_var}_params, {arc_var}_param_exprs, 'middle')), _vec(_resolve_vec3_param({arc_var}_params, {arc_var}_param_exprs, 'end'))).FirstParameter, Part.Arc(_vec(_resolve_vec3_param({arc_var}_params, {arc_var}_param_exprs, 'start')), _vec(_resolve_vec3_param({arc_var}_params, {arc_var}_param_exprs, 'middle')), _vec(_resolve_vec3_param({arc_var}_params, {arc_var}_param_exprs, 'end'))).LastParameter), False)", + ] + lines.extend(finish()) + return lines + if node.op == "make_face_from_wires_rface" and len(inputs) >= 1: + return [ + "doc.recompute()", + f"{var_name} = _make_feature({_json_ascii(object_name)}, _face_shape_from_wire_shapes(GRAPH_NODES[{_json_ascii(inputs[0])}], [GRAPH_NODES[node_id] for node_id in {var_name}_inputs[1:]], {_json_ascii(node.op)}), node_id={_json_ascii(node.node_id)}, op={_json_ascii(node.op)}, params={rp}, inputs={var_name}_inputs, tags={tags_literal}, context={context_literal}, output_count={node.output_count}, param_exprs={param_exprs_literal}, semantic_delta={semantic_delta_literal}, topo_delta={topo_delta_literal})", + ] + return None + + +__all__ = ["GeometryEmitterMixin"] diff --git a/SimpleCADAPI/src/simplecadapi/translator/freecad_translator/emitters/primitives.py b/SimpleCADAPI/src/simplecadapi/translator/freecad_translator/emitters/primitives.py new file mode 100644 index 0000000..133240b --- /dev/null +++ b/SimpleCADAPI/src/simplecadapi/translator/freecad_translator/emitters/primitives.py @@ -0,0 +1,99 @@ +"""FreeCAD operation emitters for one canonical graph domain.""" + +from __future__ import annotations + +from typing import List, Optional + +from ....topology import OperationNode +from ..codegen import * + + +class PrimitiveEmitterMixin: + def _emit_primitives( + self, + node: OperationNode, + *, + var_name: str, + object_name: str, + tags_literal: str, + context_literal: str, + param_exprs_literal: str, + semantic_delta_literal: str, + topo_delta_literal: str, + ) -> Optional[List[str]]: + graph = self._source_graph + if graph is None: + return None + + rp = f"{var_name}_params" + re = f"{var_name}_param_exprs" + inputs = [inp.node_id for inp in node.inputs] + + def finish() -> List[str]: + return [ + f"_attach_simplecad_metadata({var_name}, node_id={_json_ascii(node.node_id)}, op={_json_ascii(node.op)}, params={rp}, inputs={var_name}_inputs, tags={tags_literal}, context={context_literal}, output_count={node.output_count}, param_exprs={param_exprs_literal}, semantic_delta={semantic_delta_literal}, topo_delta={topo_delta_literal})", + f"GRAPH_NODES[{_json_ascii(node.node_id)}] = {var_name}", + f"GRAPH_METADATA[{_json_ascii(node.node_id)}] = {{'op': {_json_ascii(node.op)}, 'params': {rp}, 'inputs': {var_name}_inputs, 'context': {context_literal}, 'tags': {tags_literal}}}", + f"GRAPH_OUTPUTS[{_json_ascii(node.node_id)}] = [{var_name}]", + ] + + def finish_ir() -> List[str]: + return [ + f"{var_name} = _register_ir_node({_json_ascii(object_name)}, node_id={_json_ascii(node.node_id)}, op={_json_ascii(node.op)}, params={rp}, inputs={var_name}_inputs, tags={tags_literal}, context={context_literal}, output_count={node.output_count}, param_exprs={param_exprs_literal}, semantic_delta={semantic_delta_literal}, topo_delta={topo_delta_literal})" + ] + + def finish_alias(source_node_id: str) -> List[str]: + return [ + f"{var_name} = _register_graph_alias(node_id={_json_ascii(node.node_id)}, source_node_id={_json_ascii(source_node_id)}, op={_json_ascii(node.op)}, params={rp}, inputs={var_name}_inputs, tags={tags_literal}, context={context_literal}, output_count={node.output_count}, param_exprs={param_exprs_literal}, semantic_delta={semantic_delta_literal}, topo_delta={topo_delta_literal})" + ] + + if node.op == "make_box_rsolid": + lines = [ + f"{var_name}_width = float(_resolve_param_value({rp}, {re}, 'width'))", + f"{var_name}_height = float(_resolve_param_value({rp}, {re}, 'height'))", + f"{var_name}_depth = float(_resolve_param_value({rp}, {re}, 'depth'))", + f"{var_name}_bottom = _vec(_resolve_vec3_param({rp}, {re}, 'bottom_face_center'))", + f"{var_name}_corner = {var_name}_bottom - App.Vector({var_name}_width / 2.0, {var_name}_height / 2.0, 0.0)", + f"{var_name} = doc.addObject('Part::Box', {_json_ascii(object_name)})", + f"{var_name}.Length = {var_name}_width", + f"{var_name}.Width = {var_name}_height", + f"{var_name}.Height = {var_name}_depth", + f"{var_name}.Placement = App.Placement({var_name}_corner, App.Rotation())", + ] + lines.append(f"_apply_op_expression_bindings({var_name}, {_json_ascii(node.op)}, {re})") + lines.extend(finish()) + return lines + if node.op == "make_cylinder_rsolid": + lines = [ + f"{var_name} = doc.addObject('Part::Cylinder', {_json_ascii(object_name)})", + f"{var_name}.Radius = float(_resolve_param_value({rp}, {re}, 'radius'))", + f"{var_name}.Height = float(_resolve_param_value({rp}, {re}, 'height'))", + f"{var_name}.Placement = App.Placement(_vec(_resolve_vec3_param({rp}, {re}, 'bottom_face_center')), App.Rotation(App.Vector(0.0, 0.0, 1.0), _vec(_resolve_vec3_param({rp}, {re}, 'axis'))))", + ] + lines.append(f"_apply_op_expression_bindings({var_name}, {_json_ascii(node.op)}, {re})") + lines.extend(finish()) + return lines + if node.op == "make_cone_rsolid": + lines = [ + f"{var_name} = doc.addObject('Part::Cone', {_json_ascii(object_name)})", + f"{var_name}.Radius1 = float(_resolve_param_value({rp}, {re}, 'bottom_radius'))", + f"{var_name}.Radius2 = float(_resolve_param_value({rp}, {re}, 'top_radius'))", + f"{var_name}.Height = float(_resolve_param_value({rp}, {re}, 'height'))", + f"{var_name}.Placement = App.Placement(_vec(_resolve_vec3_param({rp}, {re}, 'bottom_face_center')), App.Rotation(App.Vector(0.0, 0.0, 1.0), _vec(_resolve_vec3_param({rp}, {re}, 'axis'))))", + ] + lines.append(f"_apply_op_expression_bindings({var_name}, {_json_ascii(node.op)}, {re})") + lines.extend(finish()) + return lines + if node.op == "make_sphere_rsolid": + lines = [ + f"{var_name} = doc.addObject('Part::Sphere', {_json_ascii(object_name)})", + f"{var_name}.Radius = float(_resolve_param_value({rp}, {re}, 'radius'))", + f"{var_name}.Placement = App.Placement(_vec(_resolve_vec3_param({rp}, {re}, 'center')), App.Rotation())", + ] + lines.append(f"_apply_op_expression_bindings({var_name}, {_json_ascii(node.op)}, {re})") + lines.extend(finish()) + return lines + return None + + +__all__ = ["PrimitiveEmitterMixin"] diff --git a/SimpleCADAPI/src/simplecadapi/translator/freecad_translator/emitters/products.py b/SimpleCADAPI/src/simplecadapi/translator/freecad_translator/emitters/products.py new file mode 100644 index 0000000..0f8603a --- /dev/null +++ b/SimpleCADAPI/src/simplecadapi/translator/freecad_translator/emitters/products.py @@ -0,0 +1,247 @@ +"""FreeCAD operation emitters for one canonical graph domain.""" + +from __future__ import annotations + +from typing import List, Optional + +from ....topology import OperationNode +from ..codegen import * + + +class ProductEmitterMixin: + def _emit_products( + self, + node: OperationNode, + *, + var_name: str, + object_name: str, + tags_literal: str, + context_literal: str, + param_exprs_literal: str, + semantic_delta_literal: str, + topo_delta_literal: str, + ) -> Optional[List[str]]: + graph = self._source_graph + if graph is None: + return None + + rp = f"{var_name}_params" + re = f"{var_name}_param_exprs" + inputs = [inp.node_id for inp in node.inputs] + + def finish() -> List[str]: + return [ + f"_attach_simplecad_metadata({var_name}, node_id={_json_ascii(node.node_id)}, op={_json_ascii(node.op)}, params={rp}, inputs={var_name}_inputs, tags={tags_literal}, context={context_literal}, output_count={node.output_count}, param_exprs={param_exprs_literal}, semantic_delta={semantic_delta_literal}, topo_delta={topo_delta_literal})", + f"GRAPH_NODES[{_json_ascii(node.node_id)}] = {var_name}", + f"GRAPH_METADATA[{_json_ascii(node.node_id)}] = {{'op': {_json_ascii(node.op)}, 'params': {rp}, 'inputs': {var_name}_inputs, 'context': {context_literal}, 'tags': {tags_literal}}}", + f"GRAPH_OUTPUTS[{_json_ascii(node.node_id)}] = [{var_name}]", + ] + + def finish_ir() -> List[str]: + return [ + f"{var_name} = _register_ir_node({_json_ascii(object_name)}, node_id={_json_ascii(node.node_id)}, op={_json_ascii(node.op)}, params={rp}, inputs={var_name}_inputs, tags={tags_literal}, context={context_literal}, output_count={node.output_count}, param_exprs={param_exprs_literal}, semantic_delta={semantic_delta_literal}, topo_delta={topo_delta_literal})" + ] + + def finish_alias(source_node_id: str) -> List[str]: + return [ + f"{var_name} = _register_graph_alias(node_id={_json_ascii(node.node_id)}, source_node_id={_json_ascii(source_node_id)}, op={_json_ascii(node.op)}, params={rp}, inputs={var_name}_inputs, tags={tags_literal}, context={context_literal}, output_count={node.output_count}, param_exprs={param_exprs_literal}, semantic_delta={semantic_delta_literal}, topo_delta={topo_delta_literal})" + ] + + if node.op == "make_material_rmaterial": + return [ + f"{var_name} = dict({rp})", + f"{var_name}_object = _ensure_material_object({var_name})", + f"PRODUCT_VALUES[{_json_ascii(node.node_id)}] = {{'kind': 'material', 'material': {var_name}, 'material_object': {var_name}_object}}", + f"{var_name} = _register_graph_value({var_name}, node_id={_json_ascii(node.node_id)}, op={_json_ascii(node.op)}, params={rp}, inputs={var_name}_inputs, tags={tags_literal}, context={context_literal}, output_count={node.output_count}, param_exprs={param_exprs_literal}, semantic_delta={semantic_delta_literal}, topo_delta={topo_delta_literal})", + ] + if node.op in {"make_placement_rplacement", "make_identity_placement_rplacement"}: + return [ + f"{var_name} = dict({rp})", + f"PRODUCT_VALUES[{_json_ascii(node.node_id)}] = {{'kind': 'placement', 'placement': {var_name}}}", + f"{var_name} = _register_graph_value({var_name}, node_id={_json_ascii(node.node_id)}, op={_json_ascii(node.op)}, params={rp}, inputs={var_name}_inputs, tags={tags_literal}, context={context_literal}, output_count={node.output_count}, param_exprs={param_exprs_literal}, semantic_delta={semantic_delta_literal}, topo_delta={topo_delta_literal})", + ] + if node.op == "make_part_rpart" and len(inputs) == 1: + lines = [ + f"{var_name} = doc.addObject('App::Part', {_json_ascii(object_name)})", + f"{var_name}.Label = str({rp}.get('name') or {rp}.get('part_id') or {_json_ascii(object_name)})", + f"{var_name}_source_body = GRAPH_NODES[{_json_ascii(inputs[0])}]", + f"{var_name}_body = _make_part_body_copy({var_name}, {var_name}_source_body, {_json_ascii(inputs[0])})", + f"{var_name}.addObject({var_name}_body)", + f"_ensure_string_property({var_name}, 'SimpleCADPartId')", + f"{var_name}.SimpleCADPartId = str({rp}.get('part_id', ''))", + f"_hide_origin_tree({var_name})", + ] + lines.extend(finish()) + lines.append( + f"PRODUCT_VALUES[{_json_ascii(node.node_id)}] = {{'kind': 'part', 'part_id': str({rp}.get('part_id', '')), 'body': {var_name}_body, 'container': {var_name}, 'material': None, 'connectors': []}}" + ) + return lines + if node.op == "make_assign_material_rpart" and len(inputs) >= 2: + lines = [ + f"{var_name}_part = PRODUCT_VALUES[{_json_ascii(inputs[0])}]", + f"{var_name}_material = PRODUCT_VALUES[{_json_ascii(inputs[1])}]['material']", + f"{var_name} = {var_name}_part['container']", + f"PRODUCT_VALUES[{_json_ascii(node.node_id)}] = dict({var_name}_part)", + f"_apply_material_to_product(PRODUCT_VALUES[{_json_ascii(node.node_id)}], {var_name}_material)", + f"{var_name} = _register_graph_folded_alias(node_id={_json_ascii(node.node_id)}, source_node_id={_json_ascii(inputs[0])}, op={_json_ascii(node.op)}, params={rp}, inputs={var_name}_inputs, tags={tags_literal}, context={context_literal}, output_count={node.output_count}, param_exprs={param_exprs_literal}, semantic_delta={semantic_delta_literal}, topo_delta={topo_delta_literal})", + ] + return lines + if node.op == "make_assign_material_rpart" and len(inputs) == 1: + lines = [ + f"{var_name}_part = PRODUCT_VALUES[{_json_ascii(inputs[0])}]", + f"{var_name}_material = _material_from_assignment_params({rp})", + f"{var_name} = {var_name}_part['container']", + f"PRODUCT_VALUES[{_json_ascii(node.node_id)}] = dict({var_name}_part)", + f"_apply_material_to_product(PRODUCT_VALUES[{_json_ascii(node.node_id)}], {var_name}_material)", + f"{var_name} = _register_graph_folded_alias(node_id={_json_ascii(node.node_id)}, source_node_id={_json_ascii(inputs[0])}, op={_json_ascii(node.op)}, params={rp}, inputs={var_name}_inputs, tags={tags_literal}, context={context_literal}, output_count={node.output_count}, param_exprs={param_exprs_literal}, semantic_delta={semantic_delta_literal}, topo_delta={topo_delta_literal})", + ] + return lines + if node.op == "make_assembly_rassembly": + lines = [ + f"{var_name} = _make_native_assembly({_json_ascii(object_name)}, node_id={_json_ascii(node.node_id)}, op={_json_ascii(node.op)}, params={rp}, inputs={var_name}_inputs, tags={tags_literal}, context={context_literal}, output_count={node.output_count}, param_exprs={param_exprs_literal}, semantic_delta={semantic_delta_literal}, topo_delta={topo_delta_literal})", + f"{var_name}.Label = str({rp}.get('name') or {rp}.get('assembly_id') or {_json_ascii(object_name)})", + f"_ensure_string_property({var_name}, 'SimpleCADAssemblyId')", + f"{var_name}.SimpleCADAssemblyId = str({rp}.get('assembly_id', ''))", + ] + lines.append( + f"PRODUCT_VALUES[{_json_ascii(node.node_id)}] = {{'kind': 'assembly', 'assembly_id': str({rp}.get('assembly_id', '')), 'container': {var_name}, 'components': [], 'connectors': [], 'constraints': [], 'grounded_component_ids': []}}" + ) + return lines + if node.op == "make_add_component_rassembly" and len(inputs) >= 3: + lines = [ + f"{var_name}_assembly = PRODUCT_VALUES[{_json_ascii(inputs[0])}]", + f"{var_name}_item = PRODUCT_VALUES[{_json_ascii(inputs[1])}]", + f"{var_name}_placement = PRODUCT_VALUES[{_json_ascii(inputs[2])}]['placement']", + f"{var_name} = {var_name}_assembly['container']", + f"{var_name}_link_label = str({rp}.get('name') or {rp}.get('component_id') or {_json_ascii(object_name)})", + f"{var_name}_link = _make_assembly_component_link({var_name}, {var_name}_item, {_json_ascii(object_name + '_component')}, {var_name}_link_label, {var_name}_placement)", + f"_ensure_string_property({var_name}_link, 'SimpleCADComponentId')", + f"{var_name}_link.SimpleCADComponentId = str({rp}.get('component_id', ''))", + f"PRODUCT_VALUES[{_json_ascii(node.node_id)}] = dict({var_name}_assembly)", + f"PRODUCT_VALUES[{_json_ascii(node.node_id)}]['components'] = list({var_name}_assembly.get('components', [])) + [{{'component_id': str({rp}.get('component_id', '')), 'link': {var_name}_link, 'placement': {var_name}_placement, 'item': {var_name}_item}}]", + f"{var_name} = _register_graph_folded_alias(node_id={_json_ascii(node.node_id)}, source_node_id={_json_ascii(inputs[0])}, op={_json_ascii(node.op)}, params={rp}, inputs={var_name}_inputs, tags={tags_literal}, context={context_literal}, output_count={node.output_count}, param_exprs={param_exprs_literal}, semantic_delta={semantic_delta_literal}, topo_delta={topo_delta_literal})", + ] + return lines + if node.op == "make_place_component_rassembly" and len(inputs) >= 2: + lines = [ + f"{var_name}_assembly = PRODUCT_VALUES[{_json_ascii(inputs[0])}]", + f"{var_name}_placement = PRODUCT_VALUES[{_json_ascii(inputs[1])}]['placement']", + f"{var_name} = {var_name}_assembly['container']", + f"{var_name}_components = []", + f"for _component in {var_name}_assembly.get('components', []):", + f" if str(_component.get('component_id')) == str({rp}.get('component_id')):", + f" _component = dict(_component)", + f" _component['placement'] = {var_name}_placement", + f" _set_component_link_placement(_component['link'], _component.get('item') or {{}}, {var_name}_placement)", + f" {var_name}_components.append(_component)", + f"PRODUCT_VALUES[{_json_ascii(node.node_id)}] = dict({var_name}_assembly)", + f"PRODUCT_VALUES[{_json_ascii(node.node_id)}]['components'] = {var_name}_components", + f"{var_name} = _register_graph_folded_alias(node_id={_json_ascii(node.node_id)}, source_node_id={_json_ascii(inputs[0])}, op={_json_ascii(node.op)}, params={rp}, inputs={var_name}_inputs, tags={tags_literal}, context={context_literal}, output_count={node.output_count}, param_exprs={param_exprs_literal}, semantic_delta={semantic_delta_literal}, topo_delta={topo_delta_literal})", + ] + return lines + if node.op in {"make_face_connector_rconnector", "make_edge_connector_rconnector", "make_vertex_connector_rconnector"}: + return [ + f"{var_name} = dict({rp})", + f"{var_name}.setdefault('anchor', {{'anchor_kind': 'geometry', 'geometry_ref': {var_name}.get('geometry_ref')}})", + f"{var_name}.setdefault('geometry_ref', ({var_name}.get('anchor') or {{}}).get('geometry_ref'))", + f"{var_name}.setdefault('name', {rp}.get('name'))", + f"PRODUCT_VALUES[{_json_ascii(node.node_id)}] = {{'kind': 'connector', 'connector': {var_name}}}", + f"{var_name} = _register_graph_value({var_name}, node_id={_json_ascii(node.node_id)}, op={_json_ascii(node.op)}, params={rp}, inputs={var_name}_inputs, tags={tags_literal}, context={context_literal}, output_count={node.output_count}, param_exprs={param_exprs_literal}, semantic_delta={semantic_delta_literal}, topo_delta={topo_delta_literal})", + ] + if node.op == "make_placement_connector_rconnector": + return [ + f"{var_name} = {{'connector_id': str({rp}.get('connector_id', '')), 'name': {rp}.get('name'), 'anchor': {{'anchor_kind': 'placement', 'placement': {rp}.get('placement') or {{}}}}}}", + f"PRODUCT_VALUES[{_json_ascii(node.node_id)}] = {{'kind': 'connector', 'connector': {var_name}}}", + f"{var_name} = _register_graph_value({var_name}, node_id={_json_ascii(node.node_id)}, op={_json_ascii(node.op)}, params={rp}, inputs={var_name}_inputs, tags={tags_literal}, context={context_literal}, output_count={node.output_count}, param_exprs={param_exprs_literal}, semantic_delta={semantic_delta_literal}, topo_delta={topo_delta_literal})", + ] + if node.op == "make_add_connector_rpart" and len(inputs) >= 2: + return [ + f"{var_name}_part = PRODUCT_VALUES[{_json_ascii(inputs[0])}]", + f"{var_name}_connector = PRODUCT_VALUES[{_json_ascii(inputs[1])}]['connector']", + f"PRODUCT_VALUES[{_json_ascii(node.node_id)}] = dict({var_name}_part)", + f"PRODUCT_VALUES[{_json_ascii(node.node_id)}]['connectors'] = list({var_name}_part.get('connectors', [])) + [{var_name}_connector]", + f"_materialize_product_connector_datums(PRODUCT_VALUES[{_json_ascii(node.node_id)}])", + f"{var_name} = _register_graph_folded_alias(node_id={_json_ascii(node.node_id)}, source_node_id={_json_ascii(inputs[0])}, op={_json_ascii(node.op)}, params={rp}, inputs={var_name}_inputs, tags={tags_literal}, context={context_literal}, output_count={node.output_count}, param_exprs={param_exprs_literal}, semantic_delta={semantic_delta_literal}, topo_delta={topo_delta_literal})", + ] + if node.op == "make_add_connector_rassembly" and len(inputs) >= 2: + return [ + f"{var_name}_assembly = PRODUCT_VALUES[{_json_ascii(inputs[0])}]", + f"{var_name}_connector = PRODUCT_VALUES[{_json_ascii(inputs[1])}]['connector']", + f"PRODUCT_VALUES[{_json_ascii(node.node_id)}] = dict({var_name}_assembly)", + f"PRODUCT_VALUES[{_json_ascii(node.node_id)}]['connectors'] = list({var_name}_assembly.get('connectors', [])) + [{var_name}_connector]", + f"_materialize_product_connector_datums(PRODUCT_VALUES[{_json_ascii(node.node_id)}])", + f"{var_name} = _register_graph_folded_alias(node_id={_json_ascii(node.node_id)}, source_node_id={_json_ascii(inputs[0])}, op={_json_ascii(node.op)}, params={rp}, inputs={var_name}_inputs, tags={tags_literal}, context={context_literal}, output_count={node.output_count}, param_exprs={param_exprs_literal}, semantic_delta={semantic_delta_literal}, topo_delta={topo_delta_literal})", + ] + if node.op == "make_forward_connector_rassembly" and len(inputs) >= 1: + return [ + f"{var_name}_assembly = PRODUCT_VALUES[{_json_ascii(inputs[0])}]", + f"{var_name}_connector = {{'connector_id': str({rp}.get('connector_id', '')), 'name': {rp}.get('name'), 'anchor': {{'anchor_kind': 'forwarded', 'source_component_id': str({rp}.get('source_component_id', '')), 'source_connector_id': str({rp}.get('source_connector_id', '')), 'offset': {rp}.get('offset')}}}}", + f"PRODUCT_VALUES[{_json_ascii(node.node_id)}] = dict({var_name}_assembly)", + f"PRODUCT_VALUES[{_json_ascii(node.node_id)}]['connectors'] = list({var_name}_assembly.get('connectors', [])) + [{var_name}_connector]", + f"_materialize_product_connector_datums(PRODUCT_VALUES[{_json_ascii(node.node_id)}])", + f"{var_name} = _register_graph_folded_alias(node_id={_json_ascii(node.node_id)}, source_node_id={_json_ascii(inputs[0])}, op={_json_ascii(node.op)}, params={rp}, inputs={var_name}_inputs, tags={tags_literal}, context={context_literal}, output_count={node.output_count}, param_exprs={param_exprs_literal}, semantic_delta={semantic_delta_literal}, topo_delta={topo_delta_literal})", + ] + if node.op == "make_connector_ref_rconnectorref": + return [ + f"{var_name} = dict({rp})", + f"PRODUCT_VALUES[{_json_ascii(node.node_id)}] = {{'kind': 'connector_ref', 'connector_ref': {var_name}}}", + f"{var_name} = _register_graph_value({var_name}, node_id={_json_ascii(node.node_id)}, op={_json_ascii(node.op)}, params={rp}, inputs={var_name}_inputs, tags={tags_literal}, context={context_literal}, output_count={node.output_count}, param_exprs={param_exprs_literal}, semantic_delta={semantic_delta_literal}, topo_delta={topo_delta_literal})", + ] + if node.op == "make_scalar_limit_rscalarlimit": + return [ + f"{var_name} = dict({rp})", + f"PRODUCT_VALUES[{_json_ascii(node.node_id)}] = {{'kind': 'scalar_limit', 'scalar_limit': {var_name}}}", + f"{var_name} = _register_graph_value({var_name}, node_id={_json_ascii(node.node_id)}, op={_json_ascii(node.op)}, params={rp}, inputs={var_name}_inputs, tags={tags_literal}, context={context_literal}, output_count={node.output_count}, param_exprs={param_exprs_literal}, semantic_delta={semantic_delta_literal}, topo_delta={topo_delta_literal})", + ] + if node.op in {"make_ground_component_rassembly", "make_unground_component_rassembly"} and len(inputs) >= 1: + action = "add" if node.op == "make_ground_component_rassembly" else "remove" + return [ + f"{var_name}_assembly = PRODUCT_VALUES[{_json_ascii(inputs[0])}]", + f"PRODUCT_VALUES[{_json_ascii(node.node_id)}] = dict({var_name}_assembly)", + f"{var_name}_grounded = list({var_name}_assembly.get('grounded_component_ids', []))", + f"{var_name}_component_id = str({rp}.get('component_id', ''))", + f"{var_name}_grounded = (list({var_name}_grounded) if {_json_ascii(action)} == 'add' else [component_id for component_id in {var_name}_grounded if component_id != {var_name}_component_id])", + f"if {_json_ascii(action)} == 'add' and {var_name}_component_id not in {var_name}_grounded: {var_name}_grounded.append({var_name}_component_id)", + f"PRODUCT_VALUES[{_json_ascii(node.node_id)}]['grounded_component_ids'] = {var_name}_grounded", + f"if {_json_ascii(action)} == 'add': _make_simplecad_grounded_joint({var_name}_assembly, {var_name}_component_id)", + f"{var_name} = _register_graph_folded_alias(node_id={_json_ascii(node.node_id)}, source_node_id={_json_ascii(inputs[0])}, op={_json_ascii(node.op)}, params={rp}, inputs={var_name}_inputs, tags={tags_literal}, context={context_literal}, output_count={node.output_count}, param_exprs={param_exprs_literal}, semantic_delta={semantic_delta_literal}, topo_delta={topo_delta_literal})", + ] + if node.op in {"make_fixed_constraint_rassembly", "make_revolute_constraint_rassembly", "make_prismatic_constraint_rassembly", "make_gear_constraint_rassembly", "make_belt_constraint_rassembly", "make_rack_pinion_constraint_rassembly"} and len(inputs) >= 3: + return [ + f"{var_name}_assembly = PRODUCT_VALUES[{_json_ascii(inputs[0])}]", + f"{var_name}_constraint = dict({rp})", + f"{var_name}_constraint['connector_a'] = PRODUCT_VALUES[{_json_ascii(inputs[1])}]['connector_ref']", + f"{var_name}_constraint['connector_b'] = PRODUCT_VALUES[{_json_ascii(inputs[2])}]['connector_ref']", + f"{var_name}_joint = _make_simplecad_joint({var_name}_assembly, {var_name}_constraint, {_json_ascii(object_name)}, str({rp}.get('name') or {rp}.get('constraint_id') or {_json_ascii(object_name)}))", + f"PRODUCT_VALUES[{_json_ascii(node.node_id)}] = dict({var_name}_assembly)", + f"PRODUCT_VALUES[{_json_ascii(node.node_id)}]['constraints'] = list({var_name}_assembly.get('constraints', [])) + [{var_name}_constraint]", + f"{var_name} = _register_graph_folded_alias(node_id={_json_ascii(node.node_id)}, source_node_id={_json_ascii(inputs[0])}, op={_json_ascii(node.op)}, params={rp}, inputs={var_name}_inputs, tags={tags_literal}, context={context_literal}, output_count={node.output_count}, param_exprs={param_exprs_literal}, semantic_delta={semantic_delta_literal}, topo_delta={topo_delta_literal})", + ] + if node.op == "make_solve_assembly_constraints_rassembly" and len(inputs) >= 1: + return [ + f"{var_name}_assembly = PRODUCT_VALUES[{_json_ascii(inputs[0])}]", + f"PRODUCT_VALUES[{_json_ascii(node.node_id)}] = dict({var_name}_assembly)", + f"{var_name}_placements = dict({rp}.get('component_placements') or {{}})", + f"{var_name}_components = []", + f"for _component in {var_name}_assembly.get('components', []):", + f" _component = dict(_component)", + f" _component_id = str(_component.get('component_id'))", + f" if _component_id in {var_name}_placements:", + f" _component['placement'] = {var_name}_placements[_component_id]", + f" _set_component_link_placement(_component['link'], _component.get('item') or {{}}, _component['placement'])", + f" {var_name}_components.append(_component)", + f"PRODUCT_VALUES[{_json_ascii(node.node_id)}]['components'] = {var_name}_components", + f"{var_name} = _register_graph_folded_alias(node_id={_json_ascii(node.node_id)}, source_node_id={_json_ascii(inputs[0])}, op={_json_ascii(node.op)}, params={rp}, inputs={var_name}_inputs, tags={tags_literal}, context={context_literal}, output_count={node.output_count}, param_exprs={param_exprs_literal}, semantic_delta={semantic_delta_literal}, topo_delta={topo_delta_literal})", + ] + if node.op == "make_compound_from_assembly_rcompound" and len(inputs) == 1: + return [ + f"{var_name}_assembly = PRODUCT_VALUES[{_json_ascii(inputs[0])}]", + f"ASSEMBLY_PROJECTION_INPUTS[{_json_ascii(node.node_id)}] = {_json_ascii(inputs[0])}", + "doc.recompute()", + f"{var_name}_shapes = _shapes_from_product_value({var_name}_assembly)", + f"{var_name} = _make_feature({_json_ascii(object_name)}, Part.makeCompound({var_name}_shapes), node_id={_json_ascii(node.node_id)}, op={_json_ascii(node.op)}, params={rp}, inputs={var_name}_inputs, tags={tags_literal}, context={context_literal}, output_count={node.output_count}, param_exprs={param_exprs_literal}, semantic_delta={semantic_delta_literal}, topo_delta={topo_delta_literal})", + ] + return None + + +__all__ = ["ProductEmitterMixin"] diff --git a/SimpleCADAPI/src/simplecadapi/translator/freecad_translator/emitters/registry.py b/SimpleCADAPI/src/simplecadapi/translator/freecad_translator/emitters/registry.py new file mode 100644 index 0000000..90d169d --- /dev/null +++ b/SimpleCADAPI/src/simplecadapi/translator/freecad_translator/emitters/registry.py @@ -0,0 +1,116 @@ +"""Single operation-to-emitter registry for the FreeCAD backend.""" + +from __future__ import annotations + +from typing import Any, Dict, List, Optional + +from ....topology import OperationNode + +EMITTER_METHOD_BY_OP: Dict[str, str] = { + "make_box_rsolid": "_emit_primitives", + "make_cylinder_rsolid": "_emit_primitives", + "make_cone_rsolid": "_emit_primitives", + "make_sphere_rsolid": "_emit_primitives", + "make_material_rmaterial": "_emit_products", + "make_placement_rplacement": "_emit_products", + "make_identity_placement_rplacement": "_emit_products", + "make_part_rpart": "_emit_products", + "make_assign_material_rpart": "_emit_products", + "make_assembly_rassembly": "_emit_products", + "make_add_component_rassembly": "_emit_products", + "make_place_component_rassembly": "_emit_products", + "make_face_connector_rconnector": "_emit_products", + "make_edge_connector_rconnector": "_emit_products", + "make_vertex_connector_rconnector": "_emit_products", + "make_placement_connector_rconnector": "_emit_products", + "make_add_connector_rpart": "_emit_products", + "make_add_connector_rassembly": "_emit_products", + "make_forward_connector_rassembly": "_emit_products", + "make_connector_ref_rconnectorref": "_emit_products", + "make_scalar_limit_rscalarlimit": "_emit_products", + "make_ground_component_rassembly": "_emit_products", + "make_unground_component_rassembly": "_emit_products", + "make_fixed_constraint_rassembly": "_emit_products", + "make_revolute_constraint_rassembly": "_emit_products", + "make_prismatic_constraint_rassembly": "_emit_products", + "make_gear_constraint_rassembly": "_emit_products", + "make_belt_constraint_rassembly": "_emit_products", + "make_rack_pinion_constraint_rassembly": "_emit_products", + "make_solve_assembly_constraints_rassembly": "_emit_products", + "make_compound_from_assembly_rcompound": "_emit_products", + "make_select_rvertex": "_emit_selections", + "make_select_redge": "_emit_selections", + "make_select_rwire": "_emit_selections", + "make_select_rface": "_emit_selections", + "make_select_rsolid": "_emit_selections", + "apply_tag_rselection": "_emit_selections", + "make_sketch_rsketch": "_emit_sketches", + "make_add_point_rsketch": "_emit_sketches", + "make_add_line_rsketch": "_emit_sketches", + "make_add_circle_rsketch": "_emit_sketches", + "make_add_arc_rsketch": "_emit_sketches", + "make_add_bspline_rsketch": "_emit_sketches", + "make_constrain_coincident_rsketch": "_emit_sketches", + "make_constrain_point_on_rsketch": "_emit_sketches", + "make_constrain_horizontal_rsketch": "_emit_sketches", + "make_constrain_vertical_rsketch": "_emit_sketches", + "make_constrain_parallel_rsketch": "_emit_sketches", + "make_constrain_perpendicular_rsketch": "_emit_sketches", + "make_constrain_collinear_rsketch": "_emit_sketches", + "make_constrain_tangent_rsketch": "_emit_sketches", + "make_constrain_concentric_rsketch": "_emit_sketches", + "make_constrain_midpoint_rsketch": "_emit_sketches", + "make_constrain_symmetric_rsketch": "_emit_sketches", + "make_constrain_equal_length_rsketch": "_emit_sketches", + "make_constrain_equal_radius_rsketch": "_emit_sketches", + "make_constrain_distance_rsketch": "_emit_sketches", + "make_constrain_distance_x_rsketch": "_emit_sketches", + "make_constrain_distance_y_rsketch": "_emit_sketches", + "make_constrain_length_rsketch": "_emit_sketches", + "make_constrain_angle_rsketch": "_emit_sketches", + "make_constrain_radius_rsketch": "_emit_sketches", + "make_constrain_diameter_rsketch": "_emit_sketches", + "make_constrain_fix_rsketch": "_emit_sketches", + "make_wire_from_sketch_rwire": "_emit_sketches", + "make_face_from_sketch_rface": "_emit_sketches", + "make_line_redge": "_emit_geometry", + "make_circle_redge": "_emit_geometry", + "make_angle_arc_redge": "_emit_geometry", + "make_three_point_arc_redge": "_emit_geometry", + "make_spline_redge": "_emit_geometry", + "make_wire_from_edges_rwire": "_emit_geometry", + "make_helix_redge": "_emit_geometry", + "make_face_from_wire_rface": "_emit_geometry", + "make_face_from_wires_rface": "_emit_geometry", + "make_extrude_rsolid": "_emit_features", + "make_revolve_rsolid": "_emit_features", + "make_loft_rsolid": "_emit_features", + "make_sweep_rsolid": "_emit_features", + "make_twisted_sweep_rsolid": "_emit_features", + "make_cut_rsolid": "_emit_booleans", + "make_union_rsolid": "_emit_booleans", + "make_intersect_rsolid": "_emit_booleans", + "make_2d_cut_rface": "_emit_booleans", + "make_2d_union_rface": "_emit_booleans", + "make_2d_intersect_rface": "_emit_booleans", + "make_fillet_rsolid": "_emit_booleans", + "make_chamfer_rsolid": "_emit_booleans", + "make_shell_rsolid": "_emit_booleans", + "make_mirror_rshape": "_emit_booleans", + "make_translate_rshape": "_emit_transforms", + "make_rotate_rshape": "_emit_transforms", +} + + +def emit_native_node( + translator: Any, + node: OperationNode, + **kwargs: str, +) -> Optional[List[str]]: + method_name = EMITTER_METHOD_BY_OP.get(node.op) + if method_name is None: + return None + return getattr(translator, method_name)(node, **kwargs) + + +__all__ = ["EMITTER_METHOD_BY_OP", "emit_native_node"] diff --git a/SimpleCADAPI/src/simplecadapi/translator/freecad_translator/emitters/selections.py b/SimpleCADAPI/src/simplecadapi/translator/freecad_translator/emitters/selections.py new file mode 100644 index 0000000..7ff5f60 --- /dev/null +++ b/SimpleCADAPI/src/simplecadapi/translator/freecad_translator/emitters/selections.py @@ -0,0 +1,81 @@ +"""FreeCAD operation emitters for one canonical graph domain.""" + +from __future__ import annotations + +from typing import List, Optional + +from ....topology import OperationNode +from ..codegen import * + + +class SelectionEmitterMixin: + def _emit_selections( + self, + node: OperationNode, + *, + var_name: str, + object_name: str, + tags_literal: str, + context_literal: str, + param_exprs_literal: str, + semantic_delta_literal: str, + topo_delta_literal: str, + ) -> Optional[List[str]]: + graph = self._source_graph + if graph is None: + return None + + rp = f"{var_name}_params" + re = f"{var_name}_param_exprs" + inputs = [inp.node_id for inp in node.inputs] + + def finish() -> List[str]: + return [ + f"_attach_simplecad_metadata({var_name}, node_id={_json_ascii(node.node_id)}, op={_json_ascii(node.op)}, params={rp}, inputs={var_name}_inputs, tags={tags_literal}, context={context_literal}, output_count={node.output_count}, param_exprs={param_exprs_literal}, semantic_delta={semantic_delta_literal}, topo_delta={topo_delta_literal})", + f"GRAPH_NODES[{_json_ascii(node.node_id)}] = {var_name}", + f"GRAPH_METADATA[{_json_ascii(node.node_id)}] = {{'op': {_json_ascii(node.op)}, 'params': {rp}, 'inputs': {var_name}_inputs, 'context': {context_literal}, 'tags': {tags_literal}}}", + f"GRAPH_OUTPUTS[{_json_ascii(node.node_id)}] = [{var_name}]", + ] + + def finish_ir() -> List[str]: + return [ + f"{var_name} = _register_ir_node({_json_ascii(object_name)}, node_id={_json_ascii(node.node_id)}, op={_json_ascii(node.op)}, params={rp}, inputs={var_name}_inputs, tags={tags_literal}, context={context_literal}, output_count={node.output_count}, param_exprs={param_exprs_literal}, semantic_delta={semantic_delta_literal}, topo_delta={topo_delta_literal})" + ] + + def finish_alias(source_node_id: str) -> List[str]: + return [ + f"{var_name} = _register_graph_alias(node_id={_json_ascii(node.node_id)}, source_node_id={_json_ascii(source_node_id)}, op={_json_ascii(node.op)}, params={rp}, inputs={var_name}_inputs, tags={tags_literal}, context={context_literal}, output_count={node.output_count}, param_exprs={param_exprs_literal}, semantic_delta={semantic_delta_literal}, topo_delta={topo_delta_literal})" + ] + + if node.op in { + "make_select_rvertex", + "make_select_redge", + "make_select_rwire", + "make_select_rface", + "make_select_rsolid", + } and len(inputs) == 1: + downstream_nodes = [ + graph.get_node(node_id) + for node_id in graph.downstream_nodes(node.node_id) + ] + allow_deferred = ( + node.op == "make_select_redge" + and bool(downstream_nodes) + and all( + downstream is not None + and downstream.op + in {"make_fillet_rsolid", "make_chamfer_rsolid"} + for downstream in downstream_nodes + ) + ) + return [ + f"{var_name} = _register_geo_selection_node(node_id={_json_ascii(node.node_id)}, op={_json_ascii(node.op)}, params={rp}, inputs={var_name}_inputs, tags={tags_literal}, context={context_literal}, output_count={node.output_count}, param_exprs={param_exprs_literal}, semantic_delta={semantic_delta_literal}, topo_delta={topo_delta_literal}, allow_deferred={allow_deferred!r})" + ] + if node.op == "apply_tag_rselection" and len(inputs) == 1: + return [ + f"{var_name} = _register_tag_selection_node({_json_ascii(object_name)}, node_id={_json_ascii(node.node_id)}, op={_json_ascii(node.op)}, params={rp}, inputs={var_name}_inputs, tags={tags_literal}, context={context_literal}, output_count={node.output_count}, param_exprs={param_exprs_literal}, semantic_delta={semantic_delta_literal}, topo_delta={topo_delta_literal})" + ] + return None + + +__all__ = ["SelectionEmitterMixin"] diff --git a/SimpleCADAPI/src/simplecadapi/translator/freecad_translator/emitters/sketches.py b/SimpleCADAPI/src/simplecadapi/translator/freecad_translator/emitters/sketches.py new file mode 100644 index 0000000..2cc7cfb --- /dev/null +++ b/SimpleCADAPI/src/simplecadapi/translator/freecad_translator/emitters/sketches.py @@ -0,0 +1,87 @@ +"""FreeCAD operation emitters for one canonical graph domain.""" + +from __future__ import annotations + +from typing import List, Optional + +from ....topology import OperationNode +from ..codegen import * + + +class SketchEmitterMixin: + def _emit_sketches( + self, + node: OperationNode, + *, + var_name: str, + object_name: str, + tags_literal: str, + context_literal: str, + param_exprs_literal: str, + semantic_delta_literal: str, + topo_delta_literal: str, + ) -> Optional[List[str]]: + graph = self._source_graph + if graph is None: + return None + + rp = f"{var_name}_params" + re = f"{var_name}_param_exprs" + inputs = [inp.node_id for inp in node.inputs] + + def finish() -> List[str]: + return [ + f"_attach_simplecad_metadata({var_name}, node_id={_json_ascii(node.node_id)}, op={_json_ascii(node.op)}, params={rp}, inputs={var_name}_inputs, tags={tags_literal}, context={context_literal}, output_count={node.output_count}, param_exprs={param_exprs_literal}, semantic_delta={semantic_delta_literal}, topo_delta={topo_delta_literal})", + f"GRAPH_NODES[{_json_ascii(node.node_id)}] = {var_name}", + f"GRAPH_METADATA[{_json_ascii(node.node_id)}] = {{'op': {_json_ascii(node.op)}, 'params': {rp}, 'inputs': {var_name}_inputs, 'context': {context_literal}, 'tags': {tags_literal}}}", + f"GRAPH_OUTPUTS[{_json_ascii(node.node_id)}] = [{var_name}]", + ] + + def finish_ir() -> List[str]: + return [ + f"{var_name} = _register_ir_node({_json_ascii(object_name)}, node_id={_json_ascii(node.node_id)}, op={_json_ascii(node.op)}, params={rp}, inputs={var_name}_inputs, tags={tags_literal}, context={context_literal}, output_count={node.output_count}, param_exprs={param_exprs_literal}, semantic_delta={semantic_delta_literal}, topo_delta={topo_delta_literal})" + ] + + def finish_alias(source_node_id: str) -> List[str]: + return [ + f"{var_name} = _register_graph_alias(node_id={_json_ascii(node.node_id)}, source_node_id={_json_ascii(source_node_id)}, op={_json_ascii(node.op)}, params={rp}, inputs={var_name}_inputs, tags={tags_literal}, context={context_literal}, output_count={node.output_count}, param_exprs={param_exprs_literal}, semantic_delta={semantic_delta_literal}, topo_delta={topo_delta_literal})" + ] + + if node.op in { + "make_sketch_rsketch", + "make_add_point_rsketch", + "make_add_line_rsketch", + "make_add_circle_rsketch", + "make_add_arc_rsketch", + "make_add_bspline_rsketch", + "make_constrain_coincident_rsketch", + "make_constrain_point_on_rsketch", + "make_constrain_horizontal_rsketch", + "make_constrain_vertical_rsketch", + "make_constrain_parallel_rsketch", + "make_constrain_perpendicular_rsketch", + "make_constrain_collinear_rsketch", + "make_constrain_tangent_rsketch", + "make_constrain_concentric_rsketch", + "make_constrain_midpoint_rsketch", + "make_constrain_symmetric_rsketch", + "make_constrain_equal_length_rsketch", + "make_constrain_equal_radius_rsketch", + "make_constrain_distance_rsketch", + "make_constrain_distance_x_rsketch", + "make_constrain_distance_y_rsketch", + "make_constrain_length_rsketch", + "make_constrain_angle_rsketch", + "make_constrain_radius_rsketch", + "make_constrain_diameter_rsketch", + "make_constrain_fix_rsketch", + }: + return finish_ir() + if node.op in {"make_wire_from_sketch_rwire", "make_face_from_sketch_rface"}: + return [ + f"{var_name} = _make_sketch_promotion_object({_json_ascii(object_name)}, node_id={_json_ascii(node.node_id)}, op={_json_ascii(node.op)}, params={rp}, inputs={var_name}_inputs, tags={tags_literal}, context={context_literal}, output_count={node.output_count}, param_exprs={param_exprs_literal}, semantic_delta={semantic_delta_literal}, topo_delta={topo_delta_literal})" + ] + return None + + +__all__ = ["SketchEmitterMixin"] diff --git a/SimpleCADAPI/src/simplecadapi/translator/freecad_translator/emitters/transforms.py b/SimpleCADAPI/src/simplecadapi/translator/freecad_translator/emitters/transforms.py new file mode 100644 index 0000000..922c903 --- /dev/null +++ b/SimpleCADAPI/src/simplecadapi/translator/freecad_translator/emitters/transforms.py @@ -0,0 +1,124 @@ +"""FreeCAD operation emitters for one canonical graph domain.""" + +from __future__ import annotations + +from typing import List, Optional + +from ....topology import OperationNode +from ..codegen import * + + +class TransformEmitterMixin: + def _emit_transforms( + self, + node: OperationNode, + *, + var_name: str, + object_name: str, + tags_literal: str, + context_literal: str, + param_exprs_literal: str, + semantic_delta_literal: str, + topo_delta_literal: str, + ) -> Optional[List[str]]: + graph = self._source_graph + if graph is None: + return None + + rp = f"{var_name}_params" + re = f"{var_name}_param_exprs" + inputs = [inp.node_id for inp in node.inputs] + + def finish() -> List[str]: + return [ + f"_attach_simplecad_metadata({var_name}, node_id={_json_ascii(node.node_id)}, op={_json_ascii(node.op)}, params={rp}, inputs={var_name}_inputs, tags={tags_literal}, context={context_literal}, output_count={node.output_count}, param_exprs={param_exprs_literal}, semantic_delta={semantic_delta_literal}, topo_delta={topo_delta_literal})", + f"GRAPH_NODES[{_json_ascii(node.node_id)}] = {var_name}", + f"GRAPH_METADATA[{_json_ascii(node.node_id)}] = {{'op': {_json_ascii(node.op)}, 'params': {rp}, 'inputs': {var_name}_inputs, 'context': {context_literal}, 'tags': {tags_literal}}}", + f"GRAPH_OUTPUTS[{_json_ascii(node.node_id)}] = [{var_name}]", + ] + + def finish_ir() -> List[str]: + return [ + f"{var_name} = _register_ir_node({_json_ascii(object_name)}, node_id={_json_ascii(node.node_id)}, op={_json_ascii(node.op)}, params={rp}, inputs={var_name}_inputs, tags={tags_literal}, context={context_literal}, output_count={node.output_count}, param_exprs={param_exprs_literal}, semantic_delta={semantic_delta_literal}, topo_delta={topo_delta_literal})" + ] + + def finish_alias(source_node_id: str) -> List[str]: + return [ + f"{var_name} = _register_graph_alias(node_id={_json_ascii(node.node_id)}, source_node_id={_json_ascii(source_node_id)}, op={_json_ascii(node.op)}, params={rp}, inputs={var_name}_inputs, tags={tags_literal}, context={context_literal}, output_count={node.output_count}, param_exprs={param_exprs_literal}, semantic_delta={semantic_delta_literal}, topo_delta={topo_delta_literal})" + ] + + if node.op == "make_translate_rshape" and len(inputs) == 1: + vector = node.params.get("vector") + if isinstance(vector, (list, tuple)) and len(vector) == 3: + try: + if all(abs(float(v)) <= 1e-12 for v in vector) and not _contains_expr_refs(dict(node.param_exprs)): + return finish_alias(inputs[0]) + except Exception: + pass + if self._can_fold_transform_into_input(node): + lines = [ + f"{var_name} = GRAPH_NODES[{_json_ascii(inputs[0])}]", + f"_fold_object_placement({var_name}, App.Placement(_vec(_resolve_vec3_param({rp}, {re}, 'vector')), App.Rotation()))", + ] + lines.append( + f"_apply_op_expression_bindings({var_name}, {_json_ascii(node.op)}, {re})" + ) + lines.append( + f"{var_name} = _register_graph_folded_alias(node_id={_json_ascii(node.node_id)}, source_node_id={_json_ascii(inputs[0])}, op={_json_ascii(node.op)}, params={rp}, inputs={var_name}_inputs, tags={tags_literal}, context={context_literal}, output_count={node.output_count}, param_exprs={param_exprs_literal}, semantic_delta={semantic_delta_literal}, topo_delta={topo_delta_literal})" + ) + return lines + if self._should_materialize_transform_for_loft_section(node): + lines = [ + f"{var_name}_shape = _shape_from_graph_node({_json_ascii(inputs[0])}).copy()", + f"{var_name}_placement = App.Placement(_vec(_resolve_vec3_param({rp}, {re}, 'vector')), App.Rotation())", + f"{var_name}_shape.Placement = {var_name}_placement.multiply({var_name}_shape.Placement)", + f"{var_name} = _make_feature({_json_ascii(object_name)}, {var_name}_shape, node_id={_json_ascii(node.node_id)}, op={_json_ascii(node.op)}, params={rp}, inputs={var_name}_inputs, tags={tags_literal}, context={context_literal}, output_count={node.output_count}, param_exprs={param_exprs_literal}, semantic_delta={semantic_delta_literal}, topo_delta={topo_delta_literal})", + ] + return lines + lines = [ + f"{var_name} = doc.addObject('App::Link', {_json_ascii(object_name)})", + f"{var_name}.LinkedObject = GRAPH_NODES[{_json_ascii(inputs[0])}]", + f"{var_name}.LinkTransform = True", + f"{var_name}.Placement = App.Placement(_vec(_resolve_vec3_param({rp}, {re}, 'vector')), App.Rotation())", + ] + lines.append( + f"_apply_op_expression_bindings({var_name}, {_json_ascii(node.op)}, {re})" + ) + lines.extend(finish()) + return lines + if node.op == "make_rotate_rshape" and len(inputs) == 1: + if self._can_fold_transform_into_input(node): + lines = [ + f"{var_name} = GRAPH_NODES[{_json_ascii(inputs[0])}]", + f"_fold_object_placement({var_name}, _placement_for_rotation(_resolve_vec3_param({rp}, {re}, 'origin') if 'origin' in {rp} else (0.0, 0.0, 0.0), _resolve_vec3_param({rp}, {re}, 'axis') if 'axis' in {rp} else (0.0, 0.0, 1.0), _resolve_param_value({rp}, {re}, 'angle')))", + ] + lines.append( + f"_apply_op_expression_bindings({var_name}, {_json_ascii(node.op)}, {re})" + ) + lines.append( + f"{var_name} = _register_graph_folded_alias(node_id={_json_ascii(node.node_id)}, source_node_id={_json_ascii(inputs[0])}, op={_json_ascii(node.op)}, params={rp}, inputs={var_name}_inputs, tags={tags_literal}, context={context_literal}, output_count={node.output_count}, param_exprs={param_exprs_literal}, semantic_delta={semantic_delta_literal}, topo_delta={topo_delta_literal})" + ) + return lines + if self._should_materialize_transform_for_loft_section(node): + lines = [ + f"{var_name}_shape = _shape_from_graph_node({_json_ascii(inputs[0])}).copy()", + f"{var_name}_placement = _placement_for_rotation(_resolve_vec3_param({rp}, {re}, 'origin') if 'origin' in {rp} else (0.0, 0.0, 0.0), _resolve_vec3_param({rp}, {re}, 'axis') if 'axis' in {rp} else (0.0, 0.0, 1.0), _resolve_param_value({rp}, {re}, 'angle'))", + f"{var_name}_shape.Placement = {var_name}_placement.multiply({var_name}_shape.Placement)", + f"{var_name} = _make_feature({_json_ascii(object_name)}, {var_name}_shape, node_id={_json_ascii(node.node_id)}, op={_json_ascii(node.op)}, params={rp}, inputs={var_name}_inputs, tags={tags_literal}, context={context_literal}, output_count={node.output_count}, param_exprs={param_exprs_literal}, semantic_delta={semantic_delta_literal}, topo_delta={topo_delta_literal})", + ] + return lines + lines = [ + f"{var_name} = doc.addObject('App::Link', {_json_ascii(object_name)})", + f"{var_name}.LinkedObject = GRAPH_NODES[{_json_ascii(inputs[0])}]", + f"{var_name}.LinkTransform = True", + f"{var_name}.Placement = _placement_for_rotation(_resolve_vec3_param({rp}, {re}, 'origin') if 'origin' in {rp} else (0.0, 0.0, 0.0), _resolve_vec3_param({rp}, {re}, 'axis') if 'axis' in {rp} else (0.0, 0.0, 1.0), _resolve_param_value({rp}, {re}, 'angle'))", + ] + lines.append( + f"_apply_op_expression_bindings({var_name}, {_json_ascii(node.op)}, {re})" + ) + lines.extend(finish()) + return lines + return None + + +__all__ = ["TransformEmitterMixin"] diff --git a/SimpleCADAPI/src/simplecadapi/translator/freecad_translator/exporter.py b/SimpleCADAPI/src/simplecadapi/translator/freecad_translator/exporter.py new file mode 100644 index 0000000..72d3bc4 --- /dev/null +++ b/SimpleCADAPI/src/simplecadapi/translator/freecad_translator/exporter.py @@ -0,0 +1,73 @@ +"""Execute generated FreeCAD scripts and validate exported files.""" + +from __future__ import annotations + +import json +import os +import shutil +import subprocess +import tempfile +from typing import Any, Optional + + +def _json_ascii(value: Any) -> str: + return json.dumps(value, ensure_ascii=True, sort_keys=True) + + +def discover_freecad_executable() -> Optional[str]: + """Return the first available FreeCAD command-line executable.""" + + candidates = [ + shutil.which("FreeCADCmd"), + shutil.which("freecadcmd"), + shutil.which("FreeCAD"), + "/Applications/FreeCAD.app/Contents/Resources/bin/freecadcmd", + "/Applications/FreeCAD.app/Contents/MacOS/FreeCAD", + ] + for candidate in candidates: + if candidate and os.path.exists(candidate): + return candidate + return None + + +def export_freecad_script_to_fcstd( + script: str, + output_path: str, + *, + freecad_executable: str, +) -> str: + """Execute a generated script and require a non-empty FCStd result.""" + + resolved_output_path = os.path.abspath(output_path) + save_tail = ( + f"\nOUTPUT_PATH = {_json_ascii(resolved_output_path)}\n" + "_apply_result_visibility(RESULT_NODE_IDS)\n" + "_set_active_result_object(RESULT_NODE_IDS)\n" + "_save_fcstd_with_gui_visibility(OUTPUT_PATH)\n" + "print(OUTPUT_PATH)\n" + ) + with tempfile.NamedTemporaryFile( + mode="w", suffix="_simplecad_freecad_export.py", delete=False + ) as handle: + temp_script_path = handle.name + handle.write(script) + handle.write(save_tail) + + completed = subprocess.run( + [freecad_executable, temp_script_path], + check=True, + text=True, + capture_output=True, + ) + if ( + not os.path.exists(resolved_output_path) + or os.path.getsize(resolved_output_path) <= 0 + ): + raise RuntimeError( + "FreeCAD export completed without creating a non-empty .FCStd file. " + f"stderr={completed.stderr.strip()!r}" + ) + return output_path + + +__all__ = ["discover_freecad_executable", "export_freecad_script_to_fcstd"] diff --git a/SimpleCADAPI/src/simplecadapi/translator/freecad_translator/runtime/__init__.py b/SimpleCADAPI/src/simplecadapi/translator/freecad_translator/runtime/__init__.py new file mode 100644 index 0000000..f5643aa --- /dev/null +++ b/SimpleCADAPI/src/simplecadapi/translator/freecad_translator/runtime/__init__.py @@ -0,0 +1,28 @@ +"""Runtime source embedded in generated FreeCAD scripts.""" + +from functools import lru_cache +from pathlib import Path + +_FRAGMENT_NAMES = ( + "metadata.py", + "assemblies.py", + "products.py", + "persistence.py", + "geometry.py", + "selections.py", + "curves.py", + "sketches.py", + "expressions.py", +) + + +@lru_cache(maxsize=1) +def assemble_runtime_source() -> str: + runtime_dir = Path(__file__).resolve().parent + return "".join( + (runtime_dir / name).read_text(encoding="utf-8") + for name in _FRAGMENT_NAMES + ) + + +__all__ = ["assemble_runtime_source"] diff --git a/SimpleCADAPI/src/simplecadapi/translator/freecad_translator/runtime/assemblies.py b/SimpleCADAPI/src/simplecadapi/translator/freecad_translator/runtime/assemblies.py new file mode 100644 index 0000000..f11f50c --- /dev/null +++ b/SimpleCADAPI/src/simplecadapi/translator/freecad_translator/runtime/assemblies.py @@ -0,0 +1,245 @@ +def _make_native_assembly(name, *, node_id, op, params, inputs, tags, context, output_count, param_exprs=None, semantic_delta=None, topo_delta=None): + if Assembly is None: + raise RuntimeError('FreeCAD Assembly workbench module is required for SimpleCAD Assembly translation') + obj = doc.addObject('Assembly::AssemblyObject', name) + obj.Type = 'Assembly' + try: + obj.newObject('Assembly::JointGroup', 'Joints') + except Exception: + pass + return _register_graph_object( + obj, + node_id=node_id, + op=op, + params=params, + inputs=inputs, + tags=tags, + context=context, + output_count=output_count, + param_exprs=param_exprs, + semantic_delta=semantic_delta, + topo_delta=topo_delta, + ) + + +def _joint_group_for(assembly_container): + for child in list(getattr(assembly_container, 'OutList', []) or []): + if getattr(child, 'TypeId', '') == 'Assembly::JointGroup': + return child + return assembly_container.newObject('Assembly::JointGroup', 'Joints') + + +def _find_component_entry(assembly_value, component_id): + for component in list(assembly_value.get('components', []) or []): + if str(component.get('component_id')) == str(component_id): + return component + raise RuntimeError(f'Missing assembly component {component_id!r}') + + +def _connector_payload_for(component_entry, connector_id): + item = component_entry.get('item') or {} + try: + return _connector_payload_for_item(item, connector_id) + except Exception as exc: + raise RuntimeError(f'Missing connector {connector_id!r} on component {component_entry.get("component_id")!r}') from exc + + +def _freecad_subname_for_kind(kind, index): + # Convert a 0-based index into a FreeCAD sub-element name string. + kind_lower = str(kind).lower() + prefix_map = {'vertex': 'Vertex', 'edge': 'Edge', 'wire': 'Wire', 'face': 'Face', 'solid': 'Solid'} + prefix = prefix_map.get(kind_lower, 'Face') + return f'{prefix}{index + 1}' + + +def _resolve_connector_subname(component_entry, connector_payload): + # Resolve the FreeCAD sub-element name (e.g. Face3) for a connector geometry_ref. + # Uses the geo_selector to match against the component link shape. + # Returns a tuple (subname1, subname2) suitable for FreeCAD Reference1/Reference2. + geometry_ref = connector_payload.get('geometry_ref') or {} + geo_selector = geometry_ref.get('geo_selector') or {} + kind = str(geometry_ref.get('kind') or geo_selector.get('kind') or '').lower() + if not kind: + return '', '' + link = component_entry.get('link') + if link is None: + return '', '' + linked_obj = getattr(link, 'LinkedObject', None) or link + shape = getattr(linked_obj, 'Shape', None) + if shape is None: + return '', '' + try: + index = _selection_index_for_selector(shape, geo_selector) + subname = _freecad_subname_for_kind(kind, index) + return subname, subname + except Exception: + return '', '' + + +def _connector_reference_for_component(assembly_value, component_entry, connector_payload): + link = component_entry.get('link') + if link is None: + raise RuntimeError(f'Missing component link for component {component_entry.get("component_id")!r}') + anchor = _connector_anchor_payload(connector_payload or {}) + if ( + getattr(link, 'TypeId', '') == 'Assembly::AssemblyLink' + and not bool(getattr(link, 'Rigid', True)) + and str(anchor.get('anchor_kind') or '').lower() == 'forwarded' + ): + source_component = _find_component_entry( + component_entry.get('item') or {}, anchor.get('source_component_id') + ) + source_link = source_component.get('link') + local_link = next( + child + for child in list(getattr(link, 'Group', []) or []) + if getattr(child, 'LinkedObject', None) is source_link + ) + source_item = source_component.get('item') or {} + source_connector = _connector_payload_for_item( + source_item, anchor.get('source_connector_id') + ) + placement = _connector_local_placement(source_item, source_connector) + if isinstance(anchor.get('offset'), dict): + placement = placement.multiply(_placement_from_axes_payload(anchor.get('offset'))) + return local_link, ['', ''], placement, True + datum = (connector_payload or {}).get('datum') + datum_name = str(getattr(datum, 'Name', '') or '') + if datum_name: + subname = datum_name + '.' + return link, [subname, subname], App.Placement(), False + anchor = _connector_anchor_payload(connector_payload or {}) + if str(anchor.get('anchor_kind') or '').lower() == 'geometry': + sub_a, sub_b = _resolve_connector_subname(component_entry, connector_payload) + if sub_a and sub_b: + return link, [sub_a, sub_b], App.Placement(), False + return link, ['', ''], _connector_local_placement(component_entry.get('item') or {}, connector_payload), True + + +def _make_simplecad_joint(assembly_value, constraint_payload, object_name, label): + assembly_container = assembly_value.get('container') + if assembly_container is None: + raise RuntimeError('Assembly value has no container for constraint translation') + connector_a = constraint_payload.get('connector_a') or {} + connector_b = constraint_payload.get('connector_b') or {} + component_a = _find_component_entry(assembly_value, connector_a.get('component_id')) + component_b = _find_component_entry(assembly_value, connector_b.get('component_id')) + connector_a_payload = _connector_payload_for(component_a, connector_a.get('connector_id')) + connector_b_payload = _connector_payload_for(component_b, connector_b.get('connector_id')) + joint_type = {'fixed': 'Fixed', 'revolute': 'Revolute', 'prismatic': 'Slider', 'gear': 'Gears', 'belt': 'Belt', 'rack_pinion': 'RackPinion'}.get(str(constraint_payload.get('constraint_kind'))) + if not joint_type: + raise RuntimeError(f"Unsupported SimpleCAD constraint kind {constraint_payload.get('constraint_kind')!r}") + joint_group = _joint_group_for(assembly_container) + joint = joint_group.newObject('App::FeaturePython', object_name) + joint.Label = str(label or constraint_payload.get('constraint_id') or object_name) + type_index = {'Fixed': 0, 'Revolute': 1, 'Slider': 3, 'RackPinion': 9, 'Gears': 11, 'Belt': 12}[joint_type] + native_status = 'metadata_only' + if JointObject is not None: + JointObject.Joint(joint, type_index) + native_status = 'native_equivalent' + if getattr(App, 'GuiUp', False) and hasattr(joint, 'ViewObject') and joint.ViewObject is not None: + try: + JointObject.ViewProviderJoint(joint.ViewObject) + except Exception: + pass + else: + _ensure_string_property(joint, 'JointType') + joint.JointType = joint_type + try: + ref_a_obj, ref_a_subs, ref_a_placement, ref_a_detached = _connector_reference_for_component(assembly_value, component_a, connector_a_payload) + ref_b_obj, ref_b_subs, ref_b_placement, ref_b_detached = _connector_reference_for_component(assembly_value, component_b, connector_b_payload) + if hasattr(joint, 'Reference1'): + if hasattr(joint, 'Detach1'): + joint.Detach1 = bool(ref_a_detached) + if hasattr(joint, 'Detach2'): + joint.Detach2 = bool(ref_b_detached) + joint.Reference1 = [ref_a_obj, list(ref_a_subs)] + joint.Reference2 = [ref_b_obj, list(ref_b_subs)] + if hasattr(joint, 'Placement1'): + joint.Placement1 = ref_a_placement + if hasattr(joint, 'Placement2'): + joint.Placement2 = ref_b_placement + except Exception: + native_status = 'native_partial' + if joint_type == 'Revolute' and constraint_payload.get('drive_angle_degrees') is not None: + try: + joint.Angle = float(constraint_payload.get('drive_angle_degrees')) + except Exception: + native_status = 'native_partial' + if joint_type == 'Slider' and constraint_payload.get('drive_distance') is not None: + try: + joint.Distance = float(constraint_payload.get('drive_distance')) + except Exception: + native_status = 'native_partial' + if joint_type == 'Gears': + try: + joint.Distance = float(constraint_payload.get('pitch_radius_a')) + joint.Distance2 = float(constraint_payload.get('pitch_radius_b')) + except Exception: + native_status = 'native_partial' + if joint_type == 'Belt': + try: + joint.Distance = float(constraint_payload.get('pulley_radius_a')) + joint.Distance2 = float(constraint_payload.get('pulley_radius_b')) + except Exception: + native_status = 'native_partial' + if joint_type == 'RackPinion': + try: + joint.Distance = float(constraint_payload.get('pitch_radius')) + except Exception: + native_status = 'native_partial' + angle_limit = constraint_payload.get('angle_limit') + if angle_limit: + try: + joint.EnableAngleMin = True + joint.EnableAngleMax = True + joint.AngleMin = float(angle_limit.get('lower_value')) + joint.AngleMax = float(angle_limit.get('upper_value')) + except Exception: + native_status = 'native_partial' + distance_limit = constraint_payload.get('distance_limit') + if distance_limit: + try: + joint.EnableLengthMin = True + joint.EnableLengthMax = True + joint.LengthMin = float(distance_limit.get('lower_value')) + joint.LengthMax = float(distance_limit.get('upper_value')) + except Exception: + native_status = 'native_partial' + _ensure_string_property(joint, 'SimpleCADConstraint') + _ensure_string_property(joint, 'SimpleCADConstraintTranslationStatus') + joint.SimpleCADConstraint = json.dumps(constraint_payload, ensure_ascii=True, sort_keys=True) + joint.SimpleCADConstraintTranslationStatus = native_status + _set_visibility(joint, False) + try: + SIMPLECAD_JOINT_OBJECTS[str(joint.Name)] = joint_type + except Exception: + pass + return joint + + +def _make_simplecad_grounded_joint(assembly_value, component_id): + assembly_container = assembly_value.get('container') + if assembly_container is None: + raise RuntimeError('Assembly value has no container for grounded joint translation') + component_entry = _find_component_entry(assembly_value, component_id) + link = component_entry.get('link') + if link is None: + raise RuntimeError(f'Missing component link for grounded component {component_id!r}') + joint_group = _joint_group_for(assembly_container) + ground = joint_group.newObject('App::FeaturePython', 'GroundedJoint_' + str(component_id)) + ground.Label = 'GroundedJoint_' + str(component_id) + native_status = 'metadata_only' + if JointObject is not None: + JointObject.GroundedJoint(ground, link) + native_status = 'native_equivalent' + else: + _ensure_string_property(ground, 'ObjectToGround') + _ensure_string_property(ground, 'SimpleCADGroundedComponent') + ground.SimpleCADGroundedComponent = str(component_id) + _set_visibility(ground, False) + try: + SIMPLECAD_JOINT_OBJECTS[str(ground.Name)] = 'Grounded' + except Exception: + pass + return ground diff --git a/SimpleCADAPI/src/simplecadapi/translator/freecad_translator/runtime/curves.py b/SimpleCADAPI/src/simplecadapi/translator/freecad_translator/runtime/curves.py new file mode 100644 index 0000000..4499dec --- /dev/null +++ b/SimpleCADAPI/src/simplecadapi/translator/freecad_translator/runtime/curves.py @@ -0,0 +1,241 @@ +def _local_line_from_edge(obj, origin, x_axis, y_axis): + start_3d = _edge_start_point(obj) + end_3d = _edge_end_point(obj) + start = _local_point_on_frame(start_3d, origin, x_axis, y_axis) + end = _local_point_on_frame(end_3d, origin, x_axis, y_axis) + projected_len = float((end - start).Length) + source_len = float((_vec(end_3d) - _vec(start_3d)).Length) + if source_len > 1e-9 and projected_len <= 1e-9: + raise RuntimeError('Projected non-zero edge collapsed to zero length; sketch frame is not coplanar with the source wire') + return Part.LineSegment( + start, + end, + ) + + +def _local_arc_from_edge(obj, origin, x_axis, y_axis): + return Part.Arc( + _local_point_on_frame(_edge_start_point(obj), origin, x_axis, y_axis), + _local_point_on_frame(_edge_mid_point(obj), origin, x_axis, y_axis), + _local_point_on_frame(_edge_end_point(obj), origin, x_axis, y_axis), + ) + + +def _angle_arc_axes(normal, kernel_x_axis=None, kernel_y_axis=None): + if kernel_x_axis is not None and kernel_y_axis is not None: + return _normalized_vec(kernel_x_axis), _normalized_vec(kernel_y_axis) + circle = Part.Circle( + App.Vector(0.0, 0.0, 0.0), + _normalized_vec(normal), + 1.0, + ) + return _normalized_vec(circle.XAxis), _normalized_vec(circle.YAxis) + + +def _periodic_axis_x(axis, kernel_x_axis=None, kernel_y_axis=None): + z_axis = _normalized_vec(axis) + if kernel_x_axis is not None: + x_axis = _normalized_vec(kernel_x_axis) + elif kernel_y_axis is not None: + x_axis = _normalized_vec(_normalized_vec(kernel_y_axis).cross(z_axis)) + else: + x_axis = _normalized_vec( + Part.Circle(App.Vector(0.0, 0.0, 0.0), z_axis, 1.0).XAxis + ) + projected = x_axis - z_axis * float(x_axis.dot(z_axis)) + if float(getattr(projected, 'Length', 0.0)) <= 1e-12: + projected = Part.Circle( + App.Vector(0.0, 0.0, 0.0), z_axis, 1.0 + ).XAxis + return _normalized_vec(projected) + + +def _periodic_axis_rotation(axis, kernel_x_axis=None, kernel_y_axis=None): + z_axis = _normalized_vec(axis) + x_axis = _periodic_axis_x(z_axis, kernel_x_axis, kernel_y_axis) + y_axis = _normalized_vec(z_axis.cross(x_axis)) + x_axis = _normalized_vec(y_axis.cross(z_axis)) + return App.Rotation(x_axis, y_axis, z_axis, 'ZXY') + + +def _kernel_circle_from_params(params, param_exprs): + normal = ( + _resolve_vec3_param(params, param_exprs, 'normal') + if 'normal' in params + else (0.0, 0.0, 1.0) + ) + circle = Part.Circle( + _vec(_resolve_vec3_param(params, param_exprs, 'center')), + _vec(normal), + float(_resolve_param_value(params, param_exprs, 'radius')), + ) + dynamic_normal = _contains_expr_refs( + param_exprs.get('normal') if isinstance(param_exprs, dict) else None + ) + circle.XAxis = _periodic_axis_x( + normal, + None if dynamic_normal else params.get('_kernel_x_axis'), + None if dynamic_normal else params.get('_kernel_y_axis'), + ) + return circle + + +def _angle_arc_world_point( + circle_center, + radius, + angle, + normal, + kernel_x_axis=None, + kernel_y_axis=None, +): + center = _vec(circle_center) + local_x, local_y = _angle_arc_axes(normal, kernel_x_axis, kernel_y_axis) + r = float(radius) + theta = float(angle) + return App.Vector( + center.x + r * math.cos(theta) * local_x.x + r * math.sin(theta) * local_y.x, + center.y + r * math.cos(theta) * local_x.y + r * math.sin(theta) * local_y.y, + center.z + r * math.cos(theta) * local_x.z + r * math.sin(theta) * local_y.z, + ) + + +def _angle_arc_curve( + circle_center, + radius, + start_angle, + end_angle, + normal, + kernel_x_axis=None, + kernel_y_axis=None, +): + sa = float(start_angle) + ea = float(end_angle) + mid_angle = 0.5 * (sa + ea) + start_world = _angle_arc_world_point( + circle_center, radius, sa, normal, kernel_x_axis, kernel_y_axis + ) + mid_world = _angle_arc_world_point( + circle_center, radius, mid_angle, normal, kernel_x_axis, kernel_y_axis + ) + end_world = _angle_arc_world_point( + circle_center, radius, ea, normal, kernel_x_axis, kernel_y_axis + ) + return Part.Arc(start_world, mid_world, end_world) + + +def _local_angle_arc( + circle_center, + radius, + start_angle, + end_angle, + normal, + origin, + x_axis, + y_axis, + kernel_x_axis=None, + kernel_y_axis=None, +): + sa = float(start_angle) + ea = float(end_angle) + mid_angle = 0.5 * (sa + ea) + start_local = _local_point_on_frame( + _vec_tuple(_angle_arc_world_point( + circle_center, radius, sa, normal, kernel_x_axis, kernel_y_axis + )), + origin, + x_axis, + y_axis, + ) + mid_local = _local_point_on_frame( + _vec_tuple(_angle_arc_world_point( + circle_center, radius, mid_angle, normal, kernel_x_axis, kernel_y_axis + )), + origin, + x_axis, + y_axis, + ) + end_local = _local_point_on_frame( + _vec_tuple(_angle_arc_world_point( + circle_center, radius, ea, normal, kernel_x_axis, kernel_y_axis + )), + origin, + x_axis, + y_axis, + ) + return Part.Arc(start_local, mid_local, end_local) + + +def _bspline_curve_from_params(params, transform_point=None): + poles = [] + for point in params.get('control_points') or []: + point3 = tuple(point) + (0.0,) if len(tuple(point)) == 2 else tuple(point) + pole = transform_point(point3) if transform_point is not None else _vec(point3) + poles.append(pole) + if not poles and params.get('points'): + for point in params.get('points') or []: + point3 = tuple(point) + (0.0,) if len(tuple(point)) == 2 else tuple(point) + pole = transform_point(point3) if transform_point is not None else _vec(point3) + poles.append(pole) + if len(poles) < 2: + raise RuntimeError('B-spline has fewer than two points') + curve = Part.BSplineCurve() + curve.interpolate(poles) + return curve + if not poles: + raise RuntimeError('B-spline has no control points') + mults = tuple(int(value) for value in (params.get('multiplicities') or [])) + knots = tuple(float(value) for value in (params.get('knots') or [])) + degree = int(params.get('degree', 3)) + periodic = bool(params.get('periodic', False)) + weights = params.get('weights') + curve = Part.BSplineCurve() + if weights is None: + curve.buildFromPolesMultsKnots(poles, mults, knots, periodic, degree) + else: + curve.buildFromPolesMultsKnots(poles, mults, knots, periodic, degree, tuple(float(value) for value in weights)) + return curve + + +def _wire_shape_from_edge_objects(node_ids): + shapes = [] + for node_id in node_ids: + shape = _shape_from_graph_node(node_id) + shapes.append(shape) + return Part.Wire(shapes) + + +def _shape_is_null(shape): + try: + return shape is None or shape.isNull() + except Exception: + return shape is None + + +def _spine_object(node_id): + node_id = str(node_id) + cached = GRAPH_SPINE_OBJECTS.get(node_id) + if cached is not None: + return cached + obj = GRAPH_NODES[node_id] + try: + shape = getattr(obj, 'Shape', None) + except Exception: + shape = None + if not _shape_is_null(shape): + return obj + meta = GRAPH_METADATA.get(node_id, {}) + if str(meta.get('op', '')) == 'make_wire_from_edges_rwire': + edge_ids = list(meta.get('inputs') or []) + if edge_ids: + fallback = doc.addObject('Part::Feature', f'make_spine_wire_{node_id}') + fallback.Shape = _wire_shape_from_edge_objects(edge_ids) + _set_visibility(fallback, False) + GRAPH_SPINE_OBJECTS[node_id] = fallback + return fallback + return obj + + +def _build_face_from_source(source_obj, name): + face_obj = doc.addObject('Part::Face', name) + face_obj.Sources = [source_obj] + return face_obj diff --git a/SimpleCADAPI/src/simplecadapi/translator/freecad_translator/runtime/expressions.py b/SimpleCADAPI/src/simplecadapi/translator/freecad_translator/runtime/expressions.py new file mode 100644 index 0000000..3396d9d --- /dev/null +++ b/SimpleCADAPI/src/simplecadapi/translator/freecad_translator/runtime/expressions.py @@ -0,0 +1,505 @@ +def _sanitize_expr_alias(expr_id, prefix='expr'): + alias = ''.join(ch if str(ch).isalnum() else '_' for ch in str(expr_id)).strip('_') + if not alias: + alias = prefix + if alias[0].isdigit(): + alias = prefix + '_' + alias + return alias[:64] + + +def _expr_alias(expr_id): + alias = EXPR_ALIAS_BY_ID.get(expr_id) + if alias: + return alias + return _sanitize_expr_alias(expr_id) + + +def _resolve_expr_ref(expr_ref): + if not isinstance(expr_ref, dict): + return None + expr_id = expr_ref.get('expr_id') + if not expr_id or 'expr_sheet' not in globals() or expr_sheet is None: + return None + alias = _expr_alias(expr_id) + try: + return float(expr_sheet.get(alias)) + except Exception: + cell = EXPR_CELL_BY_ID.get(expr_id) + if not cell: + return None + try: + return float(expr_sheet.get(cell)) + except Exception: + return None + + +def _expr_ref_to_freecad_expr(expr_ref): + if not isinstance(expr_ref, dict): + return None + expr_id = expr_ref.get('expr_id') + if not expr_id or 'expr_sheet' not in globals() or expr_sheet is None: + return None + if expr_id not in EXPR_CELL_BY_ID: + return None + return f"<>.{_expr_alias(expr_id)}" + + +def _nested_expr_ref(expr_meta, *path): + value = expr_meta + for key in path: + if isinstance(value, dict) and key in value: + value = value[key] + elif isinstance(value, list) and isinstance(key, int) and 0 <= key < len(value): + value = value[key] + else: + return None + return value + + +def _bind_expression(obj, prop_name, expr_ref): + if isinstance(expr_ref, str): + expr = expr_ref + else: + expr = _expr_ref_to_freecad_expr(expr_ref) + if not expr or not hasattr(obj, 'setExpression'): + return False + try: + obj.setExpression(prop_name, expr) + return True + except Exception: + return False + + +def _bind_expression_from_param(obj, prop_name, param_exprs, *path): + return _bind_expression(obj, prop_name, _nested_expr_ref(param_exprs, *path)) + + +def _apply_op_expression_bindings(obj, op_name, param_exprs): + for prop_name, path in OP_EXPRESSION_BINDINGS.get(str(op_name), ()): + _bind_expression_from_param(obj, prop_name, param_exprs, *path) + + +def _apply_sketch_expression_bindings(obj, bindings): + for prop_name, expr_ref in bindings or []: + _bind_expression(obj, prop_name, expr_ref) + + +def _expr_formula_from_ref(expr_ref): + expr = _expr_ref_to_freecad_expr(expr_ref) + return expr if expr else None + + +def _formula_nested_value(params, param_exprs, *path): + expr = _expr_formula_from_ref(_nested_expr_ref(param_exprs, *path)) + if expr is not None: + return expr + try: + value = params + for key in path: + value = value[key] + return repr(float(value)) + except Exception: + return None + + +def _formula_scale(expr, coeff): + coeff_value = float(coeff) + if abs(coeff_value) <= 1e-12: + return None + if abs(coeff_value - 1.0) <= 1e-12: + return expr + if abs(coeff_value + 1.0) <= 1e-12: + return f'-({expr})' + return f'({expr}) * ({repr(coeff_value)})' + + +def _formula_mul(left, right): + if left is None or right is None: + return None + return f'({left}) * ({right})' + + +def _formula_join_terms(*terms): + filtered = [term for term in terms if term is not None] + if not filtered: + return None + return ' + '.join(filtered) + + +def _formula_centered(expr, offset): + offset_value = float(offset) + if abs(offset_value) <= 1e-12: + return expr + return f'({expr}) - ({repr(offset_value)})' + + +def _formula_square(expr): + return f'pow(({expr}); 2)' + + +def _formula_cos_radians(expr): + return f'cos(({expr}) * 180 / pi)' + + +def _formula_sin_radians(expr): + return f'sin(({expr}) * 180 / pi)' + + +def _local_point_component_formula(params, param_exprs, point_path, origin, axis_vec): + path = tuple(point_path) if isinstance(point_path, (list, tuple)) else (point_path,) + offsets = (float(origin.x), float(origin.y), float(origin.z)) + axis = (float(axis_vec.x), float(axis_vec.y), float(axis_vec.z)) + terms = [] + for idx, (offset, coeff) in enumerate(zip(offsets, axis)): + value = _formula_nested_value(params, param_exprs, *(path + (idx,))) + if value is None: + return None + term = _formula_scale(_formula_centered(value, offset), coeff) + if term is not None: + terms.append(term) + if not terms: + return '0.0' + return ' + '.join(terms) + + +def _formula_value(params, param_exprs, key, index): + return _formula_nested_value(params, param_exprs, key, index) + + +def _line_length_formula(params, param_exprs): + sx = _formula_value(params, param_exprs, 'start', 0) + sy = _formula_value(params, param_exprs, 'start', 1) + sz = _formula_value(params, param_exprs, 'start', 2) + ex = _formula_value(params, param_exprs, 'end', 0) + ey = _formula_value(params, param_exprs, 'end', 1) + ez = _formula_value(params, param_exprs, 'end', 2) + terms = [] + for a, b in ((ex, sx), (ey, sy), (ez, sz)): + if a is None or b is None: + return None + terms.append(f"pow(({a}) - ({b}); 2)") + return f"sqrt({' + '.join(terms)})" + + +def _build_line_sketch_bindings(param_exprs, geom_index=0, use_local_line=False): + bindings = [] + for point_name, point_index in (("start", 1), ("end", 2)): + expr_ref = _nested_expr_ref(param_exprs, point_name) + if not isinstance(expr_ref, list): + continue + for axis_name, axis_index in (("x", 0), ("y", 1), ("z", 2)): + axis_expr = _nested_expr_ref(param_exprs, point_name, axis_index) + if axis_expr is None: + continue + prop = f"Geometry[{int(geom_index)}].{'StartPoint' if point_name == 'start' else 'EndPoint'}.{axis_name}" + if use_local_line and axis_name in {'x', 'y', 'z'}: + continue + bindings.append((prop, axis_expr)) + return bindings + + +def _build_circle_sketch_bindings(param_exprs, geom_index=0, local=False): + bindings = [] + for axis_name, axis_index in (("x", 0), ("y", 1), ("z", 2)): + axis_expr = _nested_expr_ref(param_exprs, 'center', axis_index) + if axis_expr is None: + continue + if local and axis_name == 'z': + continue + bindings.append((f"Geometry[{int(geom_index)}].Center.{axis_name}", axis_expr)) + radius_expr = _nested_expr_ref(param_exprs, 'radius') + if radius_expr is not None: + bindings.append((f"Geometry[{int(geom_index)}].Radius", radius_expr)) + return bindings + + +def _build_local_point_sketch_bindings(params, param_exprs, point_path, prop_prefix, geom_index=0, origin=None, x_axis=None, y_axis=None): + bindings = [] + if origin is None or x_axis is None or y_axis is None: + return bindings + x_expr = _local_point_component_formula(params, param_exprs, point_path, origin, x_axis) + if x_expr is not None: + bindings.append((f"Geometry[{int(geom_index)}].{prop_prefix}.x", x_expr)) + y_expr = _local_point_component_formula(params, param_exprs, point_path, origin, y_axis) + if y_expr is not None: + bindings.append((f"Geometry[{int(geom_index)}].{prop_prefix}.y", y_expr)) + return bindings + + +def _build_local_line_sketch_bindings(params, param_exprs, geom_index=0, origin=None, x_axis=None, y_axis=None): + bindings = [] + bindings.extend( + _build_local_point_sketch_bindings( + params, + param_exprs, + 'start', + 'StartPoint', + geom_index=geom_index, + origin=origin, + x_axis=x_axis, + y_axis=y_axis, + ) + ) + bindings.extend( + _build_local_point_sketch_bindings( + params, + param_exprs, + 'end', + 'EndPoint', + geom_index=geom_index, + origin=origin, + x_axis=x_axis, + y_axis=y_axis, + ) + ) + return bindings + + +def _build_local_circle_sketch_bindings(params, param_exprs, geom_index=0, origin=None, x_axis=None, y_axis=None): + bindings = [] + bindings.extend( + _build_local_point_sketch_bindings( + params, + param_exprs, + 'center', + 'Center', + geom_index=geom_index, + origin=origin, + x_axis=x_axis, + y_axis=y_axis, + ) + ) + radius_expr = _nested_expr_ref(param_exprs, 'radius') + if radius_expr is not None: + bindings.append((f"Geometry[{int(geom_index)}].Radius", radius_expr)) + return bindings + + +def _angle_arc_local_point_formula(params, param_exprs, angle_key, origin, sketch_axis): + if origin is None or sketch_axis is None: + return None + center_component = _local_point_component_formula(params, param_exprs, 'center', origin, sketch_axis) + radius_expr = _formula_nested_value(params, param_exprs, 'radius') + angle_expr = _formula_nested_value(params, param_exprs, angle_key) + if center_component is None or radius_expr is None or angle_expr is None: + return None + normal = params.get('normal', (0.0, 0.0, 1.0)) + dynamic_normal = _contains_expr_refs( + param_exprs.get('normal') if isinstance(param_exprs, dict) else None + ) + try: + arc_x, arc_y = _angle_arc_axes( + normal, + None if dynamic_normal else params.get('_kernel_x_axis'), + None if dynamic_normal else params.get('_kernel_y_axis'), + ) + except Exception: + return None + cos_term = _formula_scale( + _formula_mul(radius_expr, _formula_cos_radians(angle_expr)), + float(arc_x.x * sketch_axis.x + arc_x.y * sketch_axis.y + arc_x.z * sketch_axis.z), + ) + sin_term = _formula_scale( + _formula_mul(radius_expr, _formula_sin_radians(angle_expr)), + float(arc_y.x * sketch_axis.x + arc_y.y * sketch_axis.y + arc_y.z * sketch_axis.z), + ) + return _formula_join_terms(center_component, cos_term, sin_term) + + +def _build_local_angle_arc_sketch_bindings(params, param_exprs, geom_index=0, origin=None, x_axis=None, y_axis=None): + bindings = [] + bindings.extend( + _build_local_circle_sketch_bindings( + params, + param_exprs, + geom_index=geom_index, + origin=origin, + x_axis=x_axis, + y_axis=y_axis, + ) + ) + start_x = _angle_arc_local_point_formula(params, param_exprs, 'start_angle', origin, x_axis) + start_y = _angle_arc_local_point_formula(params, param_exprs, 'start_angle', origin, y_axis) + end_x = _angle_arc_local_point_formula(params, param_exprs, 'end_angle', origin, x_axis) + end_y = _angle_arc_local_point_formula(params, param_exprs, 'end_angle', origin, y_axis) + if start_x is not None: + bindings.append((f"Geometry[{int(geom_index)}].StartPoint.x", start_x)) + if start_y is not None: + bindings.append((f"Geometry[{int(geom_index)}].StartPoint.y", start_y)) + if end_x is not None: + bindings.append((f"Geometry[{int(geom_index)}].EndPoint.x", end_x)) + if end_y is not None: + bindings.append((f"Geometry[{int(geom_index)}].EndPoint.y", end_y)) + return bindings + + +def _three_point_arc_local_coordinate_formulas(params, param_exprs, origin, x_axis, y_axis): + return { + 'sx': _local_point_component_formula(params, param_exprs, 'start', origin, x_axis), + 'sy': _local_point_component_formula(params, param_exprs, 'start', origin, y_axis), + 'mx': _local_point_component_formula(params, param_exprs, 'middle', origin, x_axis), + 'my': _local_point_component_formula(params, param_exprs, 'middle', origin, y_axis), + 'ex': _local_point_component_formula(params, param_exprs, 'end', origin, x_axis), + 'ey': _local_point_component_formula(params, param_exprs, 'end', origin, y_axis), + } + + +def _three_point_arc_center_formula(params, param_exprs, origin, x_axis, y_axis, axis_name): + coords = _three_point_arc_local_coordinate_formulas(params, param_exprs, origin, x_axis, y_axis) + if any(value is None for value in coords.values()): + return None + sx = coords['sx'] + sy = coords['sy'] + mx = coords['mx'] + my = coords['my'] + ex = coords['ex'] + ey = coords['ey'] + denom = ( + f"2 * ((({sx}) * (({my}) - ({ey}))) + (({mx}) * (({ey}) - ({sy}))) + (({ex}) * (({sy}) - ({my}))))" + ) + start_sq = f"({_formula_square(sx)} + {_formula_square(sy)})" + mid_sq = f"({_formula_square(mx)} + {_formula_square(my)})" + end_sq = f"({_formula_square(ex)} + {_formula_square(ey)})" + if axis_name == 'x': + numer = ( + f"(({start_sq}) * (({my}) - ({ey}))) + (({mid_sq}) * (({ey}) - ({sy}))) + (({end_sq}) * (({sy}) - ({my})))" + ) + elif axis_name == 'y': + numer = ( + f"(({start_sq}) * (({ex}) - ({mx}))) + (({mid_sq}) * (({sx}) - ({ex}))) + (({end_sq}) * (({mx}) - ({sx})))" + ) + else: + return None + return f"(({numer})) / ({denom})" + + +def _three_point_arc_radius_formula(params, param_exprs, origin, x_axis, y_axis): + coords = _three_point_arc_local_coordinate_formulas(params, param_exprs, origin, x_axis, y_axis) + sx = coords.get('sx') + sy = coords.get('sy') + cx = _three_point_arc_center_formula(params, param_exprs, origin, x_axis, y_axis, 'x') + cy = _three_point_arc_center_formula(params, param_exprs, origin, x_axis, y_axis, 'y') + if sx is None or sy is None or cx is None or cy is None: + return None + return f"sqrt({_formula_square(f'({cx}) - ({sx})')} + {_formula_square(f'({cy}) - ({sy})')})" + + +def _build_local_three_point_arc_sketch_bindings(params, param_exprs, geom_index=0, origin=None, x_axis=None, y_axis=None): + bindings = [] + bindings.extend( + _build_local_point_sketch_bindings( + params, + param_exprs, + 'start', + 'StartPoint', + geom_index=geom_index, + origin=origin, + x_axis=x_axis, + y_axis=y_axis, + ) + ) + bindings.extend( + _build_local_point_sketch_bindings( + params, + param_exprs, + 'end', + 'EndPoint', + geom_index=geom_index, + origin=origin, + x_axis=x_axis, + y_axis=y_axis, + ) + ) + if origin is None or x_axis is None or y_axis is None: + return bindings + center_x = _three_point_arc_center_formula(params, param_exprs, origin, x_axis, y_axis, 'x') + center_y = _three_point_arc_center_formula(params, param_exprs, origin, x_axis, y_axis, 'y') + radius = _three_point_arc_radius_formula(params, param_exprs, origin, x_axis, y_axis) + if center_x is not None: + bindings.append((f"Geometry[{int(geom_index)}].Center.x", center_x)) + if center_y is not None: + bindings.append((f"Geometry[{int(geom_index)}].Center.y", center_y)) + if radius is not None: + bindings.append((f"Geometry[{int(geom_index)}].Radius", radius)) + return bindings + + +def _build_arc_sketch_bindings(param_exprs, geom_index=0, *, prefer_local=False): + bindings = [] + if prefer_local: + for axis_name, axis_index in (("x", 0), ("y", 1)): + start_expr = _nested_expr_ref(param_exprs, 'start', axis_index) + if start_expr is not None: + bindings.append((f"Geometry[{int(geom_index)}].StartPoint.{axis_name}", start_expr)) + end_expr = _nested_expr_ref(param_exprs, 'end', axis_index) + if end_expr is not None: + bindings.append((f"Geometry[{int(geom_index)}].EndPoint.{axis_name}", end_expr)) + return bindings + bindings.extend(_build_circle_sketch_bindings(param_exprs, geom_index=geom_index, local=False)) + start_angle_expr = _nested_expr_ref(param_exprs, 'start_angle') + if start_angle_expr is not None: + bindings.append((f"Geometry[{int(geom_index)}].FirstParameter", start_angle_expr)) + end_angle_expr = _nested_expr_ref(param_exprs, 'end_angle') + if end_angle_expr is not None: + bindings.append((f"Geometry[{int(geom_index)}].LastParameter", end_angle_expr)) + return bindings + + +def _detail_edge_binding_expr(param_exprs, key): + edge_indices = [] + radius_expr = None + if key == 'radius': + radius_expr = _nested_expr_ref(param_exprs, 'radius') + elif key == 'distance': + radius_expr = _nested_expr_ref(param_exprs, 'distance') + if radius_expr is None: + return None + return radius_expr + + +def _apply_detail_feature_bindings(obj, param_exprs, key): + expr_ref = _detail_edge_binding_expr(param_exprs, key) + if expr_ref is None: + return False + selected = [] + if key == 'radius': + selected = list(getattr(obj, 'Edges', []) or []) + else: + selected = list(getattr(obj, 'Edges', []) or []) + applied = False + for idx in range(len(selected)): + applied = _bind_expression(obj, f'Edges[{idx}]', expr_ref) or applied + return applied + + +def _resolve_param_value(params, param_exprs, key): + if isinstance(param_exprs, dict) and key in param_exprs: + value = _resolve_expr_ref(param_exprs[key]) + if value is not None: + return value + return params[key] + + +def _resolve_nested_param_value(params, param_exprs, *path): + value = params + expr_meta = param_exprs if isinstance(param_exprs, dict) else {} + for key in path: + value = value[key] + if isinstance(expr_meta, dict) and key in expr_meta: + expr_meta = expr_meta[key] + elif isinstance(expr_meta, list) and isinstance(key, int) and 0 <= key < len(expr_meta): + expr_meta = expr_meta[key] + else: + expr_meta = None + expr_value = _resolve_expr_ref(expr_meta) + if expr_value is not None: + return expr_value + return value + + +def _resolve_vec3_param(params, param_exprs, key): + return ( + float(_resolve_nested_param_value(params, param_exprs, key, 0)), + float(_resolve_nested_param_value(params, param_exprs, key, 1)), + float(_resolve_nested_param_value(params, param_exprs, key, 2)), + ) diff --git a/SimpleCADAPI/src/simplecadapi/translator/freecad_translator/runtime/geometry.py b/SimpleCADAPI/src/simplecadapi/translator/freecad_translator/runtime/geometry.py new file mode 100644 index 0000000..72e1511 --- /dev/null +++ b/SimpleCADAPI/src/simplecadapi/translator/freecad_translator/runtime/geometry.py @@ -0,0 +1,583 @@ +def _vec(v): + return App.Vector(float(v[0]), float(v[1]), float(v[2])) + + +def _placement_from_axes_payload(payload): + origin = payload.get('origin', (0.0, 0.0, 0.0)) + x_axis = payload.get('x_axis', (1.0, 0.0, 0.0)) + y_axis = payload.get('y_axis', (0.0, 1.0, 0.0)) + z_axis = payload.get('z_axis') + if z_axis is None: + x = _vec(x_axis) + y = _vec(y_axis) + z = x.cross(y) + length = float(getattr(z, 'Length', 0.0)) + if length == 0.0: + raise RuntimeError('Placement axes do not form a frame') + z_axis = (z.x / length, z.y / length, z.z / length) + matrix = App.Matrix() + matrix.A11 = float(x_axis[0]); matrix.A12 = float(y_axis[0]); matrix.A13 = float(z_axis[0]); matrix.A14 = float(origin[0]) + matrix.A21 = float(x_axis[1]); matrix.A22 = float(y_axis[1]); matrix.A23 = float(z_axis[1]); matrix.A24 = float(origin[1]) + matrix.A31 = float(x_axis[2]); matrix.A32 = float(y_axis[2]); matrix.A33 = float(z_axis[2]); matrix.A34 = float(origin[2]) + return App.Placement(matrix) + + +def _connector_tuple3(value, default=(0.0, 0.0, 0.0)): + if isinstance(value, (list, tuple)) and len(value) == 3: + try: + return (float(value[0]), float(value[1]), float(value[2])) + except Exception: + pass + return (float(default[0]), float(default[1]), float(default[2])) + + +def _dot3(a, b): + return a[0] * b[0] + a[1] * b[1] + a[2] * b[2] + + +def _cross3(a, b): + return ( + a[1] * b[2] - a[2] * b[1], + a[2] * b[0] - a[0] * b[2], + a[0] * b[1] - a[1] * b[0], + ) + + +def _unit3(value, default=(0.0, 0.0, 1.0)): + vec = _connector_tuple3(value, default) + length = math.sqrt(_dot3(vec, vec)) + if length <= 1.0e-12: + vec = _connector_tuple3(default, (0.0, 0.0, 1.0)) + length = math.sqrt(_dot3(vec, vec)) + return (vec[0] / length, vec[1] / length, vec[2] / length) + + +def _orthogonal_axis3(z_axis): + candidates = ((1.0, 0.0, 0.0), (0.0, 1.0, 0.0), (0.0, 0.0, 1.0)) + best = min(candidates, key=lambda candidate: abs(_dot3(z_axis, candidate))) + projected = ( + best[0] - z_axis[0] * _dot3(z_axis, best), + best[1] - z_axis[1] * _dot3(z_axis, best), + best[2] - z_axis[2] * _dot3(z_axis, best), + ) + return _unit3(projected, (1.0, 0.0, 0.0)) + + +def _placement_from_geometry_ref_payload(geometry_ref): + geometry_ref = geometry_ref or {} + selector = geometry_ref.get('geo_selector') or {} + kind = str(geometry_ref.get('kind') or selector.get('kind') or '').lower() + flip = bool(geometry_ref.get('flip', False)) + if kind == 'face': + origin = _connector_tuple3(selector.get('center'), (0.0, 0.0, 0.0)) + z_axis = _unit3(selector.get('normal'), (0.0, 0.0, 1.0)) + if flip: + z_axis = (-z_axis[0], -z_axis[1], -z_axis[2]) + x_axis = _orthogonal_axis3(z_axis) + y_axis = _unit3(_cross3(z_axis, x_axis), (0.0, 1.0, 0.0)) + return _placement_from_axes_payload({'origin': origin, 'x_axis': x_axis, 'y_axis': y_axis}) + if kind == 'edge': + origin = _connector_tuple3(selector.get('center'), (0.0, 0.0, 0.0)) + start = selector.get('start') + end = selector.get('end') + if isinstance(start, (list, tuple)) and isinstance(end, (list, tuple)): + s = _connector_tuple3(start) + e = _connector_tuple3(end) + direction = (e[0] - s[0], e[1] - s[1], e[2] - s[2]) + else: + direction = (1.0, 0.0, 0.0) + z_axis = _unit3(direction, (1.0, 0.0, 0.0)) + if flip: + z_axis = (-z_axis[0], -z_axis[1], -z_axis[2]) + x_axis = _orthogonal_axis3(z_axis) + y_axis = _unit3(_cross3(z_axis, x_axis), (0.0, 1.0, 0.0)) + return _placement_from_axes_payload({'origin': origin, 'x_axis': x_axis, 'y_axis': y_axis}) + if kind == 'vertex': + return _placement_from_axes_payload({'origin': _connector_tuple3(selector.get('coordinates'), (0.0, 0.0, 0.0))}) + return _placement_from_axes_payload({'origin': _connector_tuple3(selector.get('center'), (0.0, 0.0, 0.0))}) + + +def _connector_anchor_payload(connector_payload): + anchor = connector_payload.get('anchor') if isinstance(connector_payload, dict) else None + if isinstance(anchor, dict): + return anchor + geometry_ref = connector_payload.get('geometry_ref') if isinstance(connector_payload, dict) else None + if isinstance(geometry_ref, dict): + return {'anchor_kind': 'geometry', 'geometry_ref': geometry_ref} + return {'anchor_kind': 'placement', 'placement': {'origin': (0.0, 0.0, 0.0)}} + + +def _connector_payload_for_item(item, connector_id): + for connector in list((item or {}).get('connectors', []) or []): + if str(connector.get('connector_id')) == str(connector_id): + return connector + raise RuntimeError(f'Missing connector {connector_id!r} on product item') + + +def _connector_metadata_payload(connector_payload): + payload = dict(connector_payload or {}) + payload.pop('datum', None) + payload.pop('placement', None) + return payload + + +def _connector_local_placement(item, connector_payload, seen=None): + seen = set(seen or set()) + connector_id = str((connector_payload or {}).get('connector_id', '')) + key = (id(item), connector_id) + if key in seen: + raise RuntimeError(f'forwarded connector cycle detected at {connector_id!r}') + seen.add(key) + anchor = _connector_anchor_payload(connector_payload or {}) + kind = str(anchor.get('anchor_kind') or '').lower() + if kind == 'geometry': + return _placement_from_geometry_ref_payload(anchor.get('geometry_ref') or (connector_payload or {}).get('geometry_ref')) + if kind == 'placement': + return _placement_from_axes_payload(anchor.get('placement') or {}) + if kind == 'forwarded': + source_component = _find_component_entry(item or {}, anchor.get('source_component_id')) + source_item = source_component.get('item') or {} + source_connector = _connector_payload_for_item(source_item, anchor.get('source_connector_id')) + source_placement = _placement_from_axes_payload(source_component.get('placement') or {}) + result = source_placement.multiply(_connector_local_placement(source_item, source_connector, seen)) + if isinstance(anchor.get('offset'), dict): + result = result.multiply(_placement_from_axes_payload(anchor.get('offset'))) + return result + return App.Placement() + + +def _make_connector_datum(container, connector_payload, placement): + connector_id = str((connector_payload or {}).get('connector_id') or 'connector') + metadata_payload = _connector_metadata_payload(connector_payload) + object_name = 'connector_' + _simplecad_slug(connector_id, prefix='connector') + datum = None + try: + datum = container.newObject('PartDesign::CoordinateSystem', object_name) + except Exception: + datum = container.newObject('App::FeaturePython', object_name) + datum.Label = 'connector.' + connector_id + _ensure_placement_property(datum) + datum.Placement = placement + _ensure_string_property(datum, 'SimpleCADConnectorId') + _ensure_string_property(datum, 'SimpleCADConnector') + datum.SimpleCADConnectorId = connector_id + datum.SimpleCADConnector = json.dumps(metadata_payload, ensure_ascii=True, sort_keys=True) + _set_visibility(datum, True) + return datum + + +def _materialize_product_connector_datums(product_value): + container = (product_value or {}).get('container') + if container is None: + return product_value + resolved = [] + for connector in list((product_value or {}).get('connectors', []) or []): + connector = dict(connector) + try: + placement = _connector_local_placement(product_value, connector) + connector['datum'] = _make_connector_datum(container, connector, placement) + connector['placement'] = placement + except Exception: + pass + resolved.append(connector) + product_value['connectors'] = resolved + return product_value + + +def _component_connector_proxy(assembly_value, component_entry, connector_payload): + assembly_container = assembly_value.get('container') + if assembly_container is None: + raise RuntimeError('Assembly value has no container for connector proxy') + link = component_entry.get('link') + link_placement = getattr(link, 'Placement', App.Placement()) if link is not None else _placement_from_axes_payload(component_entry.get('placement') or {}) + local_placement = _connector_local_placement(component_entry.get('item') or {}, connector_payload) + proxy_payload = dict(connector_payload or {}) + proxy_payload['connector_id'] = str(component_entry.get('component_id')) + '.' + str(proxy_payload.get('connector_id', 'connector')) + return _make_connector_datum(assembly_container, proxy_payload, link_placement.multiply(local_placement)) + + +def _shape_from_component_link(link): + source = getattr(link, 'LinkedObject', None) + if source is None or not hasattr(source, 'Shape'): + raise RuntimeError('Component link has no shape-bearing linked object') + shape = source.Shape.copy() + try: + shape.Placement = link.Placement.multiply(shape.Placement) + except Exception: + pass + return shape +def _materialize_boolean_operand(obj, name): + if getattr(obj, 'TypeId', '') != 'App::Link': + return obj + shape = _shape_from_component_link(obj) + wrapper = doc.addObject('Part::Feature', name) + wrapper.Label = str(name) + wrapper.Shape = shape + _ensure_string_property(wrapper, 'SimpleCADMaterializedFromLink') + wrapper.SimpleCADMaterializedFromLink = str(getattr(obj, 'Name', '')) + _set_visibility(wrapper, False) + return wrapper + + +def _placed_shape_from_body(body, placement): + if body is None or not hasattr(body, 'Shape'): + raise RuntimeError('Part product value has no shape-bearing body') + shape = body.Shape.copy() + try: + shape.Placement = placement.multiply(shape.Placement) + except Exception: + pass + return shape + + +def _shapes_from_product_value(value, placement=None): + placement = placement or App.Placement() + if value.get('kind') == 'part': + return [_placed_shape_from_body(value.get('body'), placement)] + if value.get('kind') == 'assembly': + shapes = [] + for component in value.get('components', []): + component_placement = component.get('link').Placement if component.get('link') is not None else _placement_from_axes_payload(component.get('placement') or {}) + shapes.extend(_shapes_from_product_value(component.get('item'), placement.multiply(component_placement))) + return shapes + raise RuntimeError('Unsupported product value for shape projection') + + +def _normalized_vec(v): + vec = _vec(v) + length = float(getattr(vec, 'Length', 0.0)) + if length == 0.0: + raise RuntimeError('Expected a non-zero vector') + return App.Vector(vec.x / length, vec.y / length, vec.z / length) + + +def _scaled_direction(direction, distance): + unit = _normalized_vec(direction) + dist = float(distance) + return App.Vector(unit.x * dist, unit.y * dist, unit.z * dist) + + +def _twisted_sweep_loft_shape(profile_obj, *, axis, origin, distance, twist_angle): + operation = 'make_twisted_sweep_rsolid' + face = _face_shape_from_wire_shape(profile_obj, operation) + wires = list(getattr(face, 'Wires', []) or []) + if len(wires) != 1: + raise RuntimeError(f'{operation} emulation requires a face with one outer wire') + + axis_vec = _normalized_vec(axis) + origin_vec = _vec(origin) + distance_value = float(distance) + twist_value = float(twist_angle) + if not math.isfinite(distance_value) or distance_value <= 0.0: + raise RuntimeError(f'{operation} distance must be finite and positive') + if not math.isfinite(twist_value): + raise RuntimeError(f'{operation} twist angle must be finite') + + section_count = max(3, int(math.ceil(abs(twist_value) / 30.0)) + 1) + sections = [] + for index in range(section_count): + fraction = float(index) / float(section_count - 1) + section = wires[0].copy() + angle = twist_value * fraction + if abs(angle) > 1.0e-12: + section.rotate(origin_vec, axis_vec, angle) + offset = distance_value * fraction + if abs(offset) > 1.0e-12: + section.translate(App.Vector(axis_vec.x * offset, axis_vec.y * offset, axis_vec.z * offset)) + sections.append(section) + + result = Part.makeLoft(sections, True, False, False, 5) + if result is None or result.isNull() or not result.isValid(): + raise RuntimeError(f'{operation} loft emulation produced an invalid shape') + if len(list(getattr(result, 'Solids', []) or [])) != 1: + raise RuntimeError(f'{operation} loft emulation did not produce one solid') + return result + + +def _placement_from_context(context): + origin = context.get('origin') if isinstance(context, dict) else None + if isinstance(origin, (list, tuple)) and len(origin) == 3: + return App.Placement(_vec(origin), App.Rotation()) + return App.Placement() + + +def _rotation_from_context_axes(context): + if not isinstance(context, dict): + return App.Rotation() + x_axis = context.get('x_axis') + y_axis = context.get('y_axis') + z_axis = context.get('z_axis') + if not ( + isinstance(x_axis, (list, tuple)) and len(x_axis) == 3 and + isinstance(y_axis, (list, tuple)) and len(y_axis) == 3 and + isinstance(z_axis, (list, tuple)) and len(z_axis) == 3 + ): + return App.Rotation() + m = App.Matrix() + m.A11, m.A21, m.A31 = float(x_axis[0]), float(x_axis[1]), float(x_axis[2]) + m.A12, m.A22, m.A32 = float(y_axis[0]), float(y_axis[1]), float(y_axis[2]) + m.A13, m.A23, m.A33 = float(z_axis[0]), float(z_axis[1]), float(z_axis[2]) + return App.Rotation(m) + + +def _sketch_placement_from_context(context): + origin = context.get('origin') if isinstance(context, dict) else None + base = _vec(origin) if isinstance(origin, (list, tuple)) and len(origin) == 3 else App.Vector(0.0, 0.0, 0.0) + return App.Placement(base, _rotation_from_context_axes(context)) + + +def _line_sketch_placement(start, end): + start_v = _vec(start) + end_v = _vec(end) + delta = App.Vector(end_v.x - start_v.x, end_v.y - start_v.y, end_v.z - start_v.z) + x_axis = _normalized_vec((delta.x, delta.y, delta.z)) + ref = App.Vector(0.0, 0.0, 1.0) + dot = abs(float(x_axis.x * ref.x + x_axis.y * ref.y + x_axis.z * ref.z)) + if dot > 0.95: + ref = App.Vector(0.0, 1.0, 0.0) + z_axis = x_axis.cross(ref) + z_len = float(getattr(z_axis, 'Length', 0.0)) + if z_len == 0.0: + ref = App.Vector(1.0, 0.0, 0.0) + z_axis = x_axis.cross(ref) + z_len = float(getattr(z_axis, 'Length', 0.0)) + z_axis = App.Vector(z_axis.x / z_len, z_axis.y / z_len, z_axis.z / z_len) + y_axis = z_axis.cross(x_axis) + y_len = float(getattr(y_axis, 'Length', 0.0)) + y_axis = App.Vector(y_axis.x / y_len, y_axis.y / y_len, y_axis.z / y_len) + m = App.Matrix() + m.A11, m.A21, m.A31 = x_axis.x, x_axis.y, x_axis.z + m.A12, m.A22, m.A32 = y_axis.x, y_axis.y, y_axis.z + m.A13, m.A23, m.A33 = z_axis.x, z_axis.y, z_axis.z + rotation = App.Rotation(m) + return App.Placement(start_v, rotation), float(getattr(delta, 'Length', 0.0)) + + +def _pick_perpendicular_axis(vec): + ref = App.Vector(0.0, 0.0, 1.0) + dot = abs(float(vec.x * ref.x + vec.y * ref.y + vec.z * ref.z)) + if dot > 0.95: + ref = App.Vector(0.0, 1.0, 0.0) + perp = vec.cross(ref) + length = float(getattr(perp, 'Length', 0.0)) + if length == 0.0: + ref = App.Vector(1.0, 0.0, 0.0) + perp = vec.cross(ref) + length = float(getattr(perp, 'Length', 0.0)) + return App.Vector(perp.x / length, perp.y / length, perp.z / length) + + +def _frame_from_points(points, fallback_context=None, preferred_normal=None): + if not points: + raise RuntimeError('Expected at least one point for sketch frame') + origin = _vec(points[0]) + fallback_x = None + fallback_y = None + fallback_z = None + if isinstance(fallback_context, dict): + raw_x = fallback_context.get('x_axis') + raw_y = fallback_context.get('y_axis') + raw_z = fallback_context.get('z_axis') + if isinstance(raw_x, (list, tuple)) and len(raw_x) == 3: + try: + fallback_x = _normalized_vec(raw_x) + except Exception: + fallback_x = None + if isinstance(raw_y, (list, tuple)) and len(raw_y) == 3: + try: + fallback_y = _normalized_vec(raw_y) + except Exception: + fallback_y = None + if isinstance(raw_z, (list, tuple)) and len(raw_z) == 3: + try: + fallback_z = _normalized_vec(raw_z) + except Exception: + fallback_z = None + + preferred_z = None + if isinstance(preferred_normal, (list, tuple)) and len(preferred_normal) == 3: + try: + preferred_z = _normalized_vec(preferred_normal) + except Exception: + preferred_z = None + if preferred_z is not None: + on_preferred_plane = True + for point in points[1:]: + delta = App.Vector( + float(point[0]) - origin.x, + float(point[1]) - origin.y, + float(point[2]) - origin.z, + ) + offset = abs(float(delta.dot(preferred_z))) + if offset > max(1e-7, float(getattr(delta, 'Length', 0.0)) * 1e-7): + on_preferred_plane = False + break + if on_preferred_plane: + preferred_x = None + for candidate in (fallback_x, fallback_y): + if candidate is None: + continue + projected = candidate - preferred_z * float(candidate.dot(preferred_z)) + if float(getattr(projected, 'Length', 0.0)) > 1e-12: + preferred_x = _normalized_vec(projected) + break + if preferred_x is None: + preferred_x = _pick_perpendicular_axis(preferred_z) + preferred_y = _normalized_vec(preferred_z.cross(preferred_x)) + m = App.Matrix() + m.A11, m.A21, m.A31 = preferred_x.x, preferred_x.y, preferred_x.z + m.A12, m.A22, m.A32 = preferred_y.x, preferred_y.y, preferred_y.z + m.A13, m.A23, m.A33 = preferred_z.x, preferred_z.y, preferred_z.z + return ( + App.Placement(origin, App.Rotation(m)), + origin, + preferred_x, + preferred_y, + ) + + if fallback_x is not None and fallback_y is not None and fallback_z is not None: + scale = 1.0 + on_fallback_plane = True + for point in points[1:]: + delta = App.Vector(float(point[0]) - origin.x, float(point[1]) - origin.y, float(point[2]) - origin.z) + scale = max(scale, float(getattr(delta, 'Length', 0.0))) + offset = abs(float(delta.x * fallback_z.x + delta.y * fallback_z.y + delta.z * fallback_z.z)) + if offset > max(1e-7, scale * 1e-7): + on_fallback_plane = False + break + if on_fallback_plane: + m = App.Matrix() + m.A11, m.A21, m.A31 = fallback_x.x, fallback_x.y, fallback_x.z + m.A12, m.A22, m.A32 = fallback_y.x, fallback_y.y, fallback_y.z + m.A13, m.A23, m.A33 = fallback_z.x, fallback_z.y, fallback_z.z + placement = App.Placement(origin, App.Rotation(m)) + return placement, origin, fallback_x, fallback_y + + x_axis = None + for point in points[1:]: + delta = App.Vector(float(point[0]) - origin.x, float(point[1]) - origin.y, float(point[2]) - origin.z) + length = float(getattr(delta, 'Length', 0.0)) + if length > 1e-9: + x_axis = App.Vector(delta.x / length, delta.y / length, delta.z / length) + break + if x_axis is None: + x_axis = fallback_x if fallback_x is not None else App.Vector(1.0, 0.0, 0.0) + + z_axis = None + for point in points[1:]: + delta = App.Vector(float(point[0]) - origin.x, float(point[1]) - origin.y, float(point[2]) - origin.z) + candidate = x_axis.cross(delta) + length = float(getattr(candidate, 'Length', 0.0)) + if length > 1e-9: + z_axis = App.Vector(candidate.x / length, candidate.y / length, candidate.z / length) + break + + if z_axis is None and fallback_z is not None: + dot = abs(float(x_axis.x * fallback_z.x + x_axis.y * fallback_z.y + x_axis.z * fallback_z.z)) + if dot < 0.95: + z_axis = fallback_z + + if z_axis is None: + z_axis = _pick_perpendicular_axis(x_axis) + + y_axis = z_axis.cross(x_axis) + y_len = float(getattr(y_axis, 'Length', 0.0)) + if y_len == 0.0: + y_axis = _pick_perpendicular_axis(z_axis) + y_len = float(getattr(y_axis, 'Length', 0.0)) + y_axis = App.Vector(y_axis.x / y_len, y_axis.y / y_len, y_axis.z / y_len) + + m = App.Matrix() + m.A11, m.A21, m.A31 = x_axis.x, x_axis.y, x_axis.z + m.A12, m.A22, m.A32 = y_axis.x, y_axis.y, y_axis.z + m.A13, m.A23, m.A33 = z_axis.x, z_axis.y, z_axis.z + placement = App.Placement(origin, App.Rotation(m)) + return placement, origin, x_axis, y_axis + + +def _local_point_on_frame(point, origin, x_axis, y_axis): + p = _vec(point) + dx = p.x - origin.x + dy = p.y - origin.y + dz = p.z - origin.z + return App.Vector( + dx * x_axis.x + dy * x_axis.y + dz * x_axis.z, + dx * y_axis.x + dy * y_axis.y + dz * y_axis.z, + 0.0, + ) + + +def _vec_tuple(vec): + return (float(vec.x), float(vec.y), float(vec.z)) + + +def _first_edge(obj): + shape = getattr(obj, 'Shape', None) if hasattr(obj, 'Shape') else obj + if shape is None or shape.isNull(): + raise RuntimeError(f'Object {getattr(obj, "Name", "")} has no valid shape') + edges = list(getattr(shape, 'Edges', [])) + if not edges: + raise RuntimeError(f'Object {getattr(obj, "Name", "")} has no edges') + return edges[0] + + +def _edge_start_point(obj): + edge = _first_edge(obj) + return _vec_tuple(edge.Vertexes[0].Point) + + +def _edge_end_point(obj): + edge = _first_edge(obj) + return _vec_tuple(edge.Vertexes[-1].Point) + + +def _edge_mid_point(obj): + edge = _first_edge(obj) + point = edge.valueAt(0.5 * (float(edge.FirstParameter) + float(edge.LastParameter))) + return _vec_tuple(point) + + +def _arc_from_edge(obj): + return Part.Arc(_vec(_edge_start_point(obj)), _vec(_edge_mid_point(obj)), _vec(_edge_end_point(obj))) + + +def _shape_from_object_value(value, seen=None): + if value is None: + return None + seen = set() if seen is None else seen + marker = id(value) + if marker in seen: + raise RuntimeError('Cyclic FreeCAD link chain while resolving graph shape') + seen.add(marker) + linked_object = getattr(value, 'LinkedObject', None) + if linked_object is not None: + shape = _shape_from_object_value(linked_object, seen).copy() + placement = getattr(value, 'Placement', None) + if placement is not None: + shape.Placement = placement.multiply(shape.Placement) + return shape + if hasattr(value, 'Shape'): + return getattr(value, 'Shape', None) + return value + + +def _shape_from_graph_node(node_id): + value = GRAPH_NODES.get(node_id) + if value is None: + raise RuntimeError(f'Missing graph node {node_id!r}') + if isinstance(value, dict) and 'shape' in value: + return value['shape'] + shape = _shape_from_object_value(value) + try: + shape_invalid = shape is None or shape.isNull() + except Exception: + shape_invalid = shape is None + if shape_invalid: + try: + doc.recompute() + except Exception: + pass + shape = _shape_from_object_value(value) + if shape is None or shape.isNull(): + raise RuntimeError(f'Graph node {node_id!r} has no valid shape') + return shape diff --git a/SimpleCADAPI/src/simplecadapi/translator/freecad_translator/runtime/metadata.py b/SimpleCADAPI/src/simplecadapi/translator/freecad_translator/runtime/metadata.py new file mode 100644 index 0000000..f1bfb1b --- /dev/null +++ b/SimpleCADAPI/src/simplecadapi/translator/freecad_translator/runtime/metadata.py @@ -0,0 +1,440 @@ +class SimpleCADUnsupportedOpError(RuntimeError): + pass + + +def _ensure_string_property(obj, prop_name, group='SimpleCAD'): + if prop_name not in list(getattr(obj, 'PropertiesList', []) or []): + obj.addProperty('App::PropertyString', prop_name, group) + + +def _ensure_string_list_property(obj, prop_name, group='SimpleCAD'): + if prop_name not in list(getattr(obj, 'PropertiesList', []) or []): + obj.addProperty('App::PropertyStringList', prop_name, group) + + +def _ensure_string_map_property(obj, prop_name, group='SimpleCAD'): + if prop_name not in list(getattr(obj, 'PropertiesList', []) or []): + obj.addProperty('App::PropertyMap', prop_name, group) + + +def _ensure_float_property(obj, prop_name, group='SimpleCAD'): + if prop_name not in list(getattr(obj, 'PropertiesList', []) or []): + obj.addProperty('App::PropertyFloat', prop_name, group) + + +def _ensure_color_property(obj, prop_name, group='SimpleCAD'): + if prop_name not in list(getattr(obj, 'PropertiesList', []) or []): + obj.addProperty('App::PropertyColor', prop_name, group) + + +def _ensure_link_property(obj, prop_name, group='SimpleCAD'): + if prop_name not in list(getattr(obj, 'PropertiesList', []) or []): + obj.addProperty('App::PropertyLink', prop_name, group) + + +def _ensure_placement_property(obj, prop_name='Placement', group='SimpleCAD'): + if prop_name not in list(getattr(obj, 'PropertiesList', []) or []): + obj.addProperty('App::PropertyPlacement', prop_name, group) + + +def _contains_expr_refs(value): + if isinstance(value, dict): + if isinstance(value.get('expr_id'), str) and value['expr_id']: + return True + return any(_contains_expr_refs(v) for v in value.values()) + if isinstance(value, (list, tuple)): + return any(_contains_expr_refs(v) for v in value) + return False + + +def _expression_limitation_payload(op, param_exprs): + if not _contains_expr_refs(param_exprs or {}): + return None + reason = OP_EXPRESSION_LIMITATIONS.get(str(op)) + if not reason: + return None + return {'op': str(op), 'reason': str(reason)} + + +def _record_graph_limitation(node_id, op, param_exprs): + limitation = _expression_limitation_payload(op, param_exprs) + if limitation: + GRAPH_LIMITATIONS[str(node_id)] = limitation + return limitation + + +def _mark_emulated_translation(obj, *, node_id, op, reason): + _ensure_string_property(obj, 'SimpleCADTranslationSupport') + _ensure_string_property(obj, 'SimpleCADTranslationLimitation') + obj.SimpleCADTranslationSupport = 'emulated' + obj.SimpleCADTranslationLimitation = str(reason) + GRAPH_TRANSLATION_LIMITATIONS[str(node_id)] = { + 'op': str(op), + 'support': 'emulated', + 'reason': str(reason), + } + return obj + + +def _simplecad_slug(value, prefix='obj'): + token = ''.join(ch if str(ch).isalnum() else '_' for ch in str(value or '')) + token = token.strip('_') + if not token: + token = str(prefix) + if token[0].isdigit(): + token = str(prefix) + '_' + token + return token[:120] + + +def _semantic_label_token(tags): + normalized = sorted(str(tag) for tag in (tags or [])) + for prefix in ('role.', 'anchor.', 'group.'): + for tag in normalized: + if tag.startswith(prefix) and len(tag) > len(prefix): + return tag[len(prefix):].replace('.', '_') + for tag in normalized: + if tag not in {'primitive', 'derived', 'solid', 'face', 'wire', 'edge', 'vertex'}: + return tag.replace('.', '_') + return '' + + +def _op_label_token(op): + token = str(op or '') + if token.startswith('make_'): + token = token[len('make_'):] + for suffix in ('_rsolid', '_rface', '_rwire', '_redge', '_rvertex', '_rassembly', '_rpart'): + if token.endswith(suffix): + token = token[:-len(suffix)] + break + return token or str(op or 'node') + + +def _simplecad_display_name(*, node_id, op, params, tags, semantic_delta): + params = params or {} + for key in ('name', 'component_id', 'part_id', 'assembly_id', 'constraint_id', 'connector_id', 'material_id'): + value = params.get(key) + if value: + return f"{value} ({_op_label_token(op)})" + tag_token = _semantic_label_token(tags) + if tag_token: + return f"{tag_token} ({_op_label_token(op)})" + created = (semantic_delta or {}).get('created', []) if isinstance(semantic_delta, dict) else [] + if created: + first = created[0] + if isinstance(first, dict): + entity_type = first.get('entity_type') + entity_id = first.get('entity_id') + if entity_type and entity_id: + return f"{entity_type} {entity_id}" + return f"{_op_label_token(op)} {node_id}" + + +def _attach_simplecad_metadata(obj, *, node_id, op, params, inputs, tags, context, output_count, param_exprs=None, semantic_delta=None, topo_delta=None): + _ensure_string_property(obj, 'SimpleCADNodeId') + _ensure_string_property(obj, 'SimpleCADOp') + _ensure_string_property(obj, 'SimpleCADDisplayName') + _ensure_string_property(obj, 'SimpleCADParams') + _ensure_string_property(obj, 'SimpleCADInputs') + _ensure_string_property(obj, 'SimpleCADContext') + _ensure_string_property(obj, 'SimpleCADParamExprs') + _ensure_string_property(obj, 'SimpleCADSemanticDelta') + _ensure_string_property(obj, 'SimpleCADTopoDelta') + _ensure_string_property(obj, 'SimpleCADOutputCount') + _ensure_string_property(obj, 'SimpleCADExprSupport') + _ensure_string_property(obj, 'SimpleCADExprLimitation') + _ensure_string_list_property(obj, 'SimpleCADTags') + obj.SimpleCADNodeId = str(node_id) + obj.SimpleCADOp = str(op) + display_name = _simplecad_display_name(node_id=node_id, op=op, params=params, tags=tags, semantic_delta=semantic_delta) + obj.SimpleCADDisplayName = str(display_name) + try: + obj.Label = str(display_name) + except Exception: + pass + obj.SimpleCADParams = json.dumps(params, ensure_ascii=True, sort_keys=True) + obj.SimpleCADInputs = json.dumps(inputs, ensure_ascii=True, sort_keys=True) + obj.SimpleCADContext = json.dumps(context or {}, ensure_ascii=True, sort_keys=True) + obj.SimpleCADParamExprs = json.dumps(param_exprs or {}, ensure_ascii=True, sort_keys=True) + obj.SimpleCADSemanticDelta = json.dumps(semantic_delta or {}, ensure_ascii=True, sort_keys=True) + obj.SimpleCADTopoDelta = json.dumps(topo_delta or {}, ensure_ascii=True, sort_keys=True) + obj.SimpleCADOutputCount = str(int(output_count)) + limitation = _expression_limitation_payload(op, param_exprs) + obj.SimpleCADExprSupport = 'limited' if limitation else 'mapped_or_not_requested' + obj.SimpleCADExprLimitation = limitation['reason'] if limitation else '' + obj.SimpleCADTags = [str(tag) for tag in (tags or [])] + + +def _append_folded_op_metadata(obj, *, node_id, op, params, inputs, tags, context, output_count, param_exprs=None, semantic_delta=None, topo_delta=None): + _ensure_string_property(obj, 'SimpleCADFoldedOps') + try: + folded = json.loads(obj.SimpleCADFoldedOps) if obj.SimpleCADFoldedOps else [] + except Exception: + folded = [] + if not isinstance(folded, list): + folded = [] + folded.append({ + 'node_id': str(node_id), + 'op': str(op), + 'params': params or {}, + 'inputs': list(inputs or []), + 'tags': list(tags or []), + 'context': context or {}, + 'output_count': int(output_count), + 'param_exprs': param_exprs or {}, + 'semantic_delta': semantic_delta or {}, + 'topo_delta': topo_delta or {}, + }) + obj.SimpleCADFoldedOps = json.dumps(folded, ensure_ascii=True, sort_keys=True) + existing_tags = list(getattr(obj, 'SimpleCADTags', []) or []) + merged_tags = sorted({str(tag) for tag in existing_tags + list(tags or [])}) + try: + obj.SimpleCADTags = merged_tags + except Exception: + pass + + +def _record_graph_output(node_id, obj): + GRAPH_OUTPUTS.setdefault(node_id, []).append(obj) + + +def _register_graph_object(obj, *, node_id, op, params, inputs, tags, context, output_count, param_exprs=None, semantic_delta=None, topo_delta=None): + _attach_simplecad_metadata( + obj, + node_id=node_id, + op=op, + params=params, + inputs=inputs, + tags=tags, + context=context, + output_count=output_count, + param_exprs=param_exprs, + semantic_delta=semantic_delta, + topo_delta=topo_delta, + ) + GRAPH_NODES[node_id] = obj + GRAPH_METADATA[node_id] = { + 'op': op, + 'params': params, + 'inputs': list(inputs), + 'context': context or {}, + 'tags': list(tags or []), + } + _record_graph_limitation(node_id, op, param_exprs) + _record_graph_output(node_id, obj) + return obj + + +def _register_graph_metadata_only(*, node_id, op, params, inputs, tags, context, output_count, param_exprs=None, semantic_delta=None, topo_delta=None): + GRAPH_NODES[node_id] = { + 'node_id': node_id, + 'op': op, + 'params': params, + 'inputs': list(inputs), + 'context': context or {}, + 'tags': list(tags or []), + 'output_count': int(output_count), + 'param_exprs': param_exprs or {}, + 'semantic_delta': semantic_delta or {}, + 'topo_delta': topo_delta or {}, + } + GRAPH_METADATA[node_id] = { + 'op': op, + 'params': params, + 'inputs': list(inputs), + 'context': context or {}, + 'tags': list(tags or []), + } + _record_graph_limitation(node_id, op, param_exprs) + GRAPH_OUTPUTS.setdefault(node_id, []) + return GRAPH_NODES[node_id] + + +def _register_graph_alias(*, node_id, source_node_id, op, params, inputs, tags, context, output_count, param_exprs=None, semantic_delta=None, topo_delta=None): + source_obj = _node_object(source_node_id) + GRAPH_NODES[node_id] = source_obj + GRAPH_METADATA[node_id] = { + 'op': op, + 'params': params, + 'inputs': list(inputs), + 'context': context or {}, + 'tags': list(tags or []), + } + GRAPH_OUTPUTS[node_id] = list(GRAPH_OUTPUTS.get(source_node_id, [])) + return source_obj + + +def _register_graph_folded_alias(*, node_id, source_node_id, op, params, inputs, tags, context, output_count, param_exprs=None, semantic_delta=None, topo_delta=None): + source_obj = _node_object(source_node_id) + _append_folded_op_metadata( + source_obj, + node_id=node_id, + op=op, + params=params, + inputs=inputs, + tags=tags, + context=context, + output_count=output_count, + param_exprs=param_exprs, + semantic_delta=semantic_delta, + topo_delta=topo_delta, + ) + GRAPH_NODES[node_id] = source_obj + GRAPH_METADATA[node_id] = { + 'op': op, + 'params': params, + 'inputs': list(inputs), + 'context': context or {}, + 'tags': list(tags or []), + 'folded_into': str(source_node_id), + } + _record_graph_limitation(node_id, op, param_exprs) + GRAPH_OUTPUTS[node_id] = [source_obj] + return source_obj + + +def _register_graph_value(value, *, node_id, op, params, inputs, tags, context, output_count, param_exprs=None, semantic_delta=None, topo_delta=None): + GRAPH_NODES[node_id] = value + GRAPH_METADATA[node_id] = { + 'op': op, + 'params': params, + 'inputs': list(inputs), + 'context': context or {}, + 'tags': list(tags or []), + } + _record_graph_limitation(node_id, op, param_exprs) + GRAPH_OUTPUTS[node_id] = [] + return value + + +def _make_feature(name, shape, *, node_id, op, params, inputs, tags, context, output_count, param_exprs=None, semantic_delta=None, topo_delta=None): + obj = doc.addObject('Part::Feature', name) + obj.Shape = shape + return _register_graph_object( + obj, + node_id=node_id, + op=op, + params=params, + inputs=inputs, + tags=tags, + context=context, + output_count=output_count, + param_exprs=param_exprs, + semantic_delta=semantic_delta, + topo_delta=topo_delta, + ) +def _single_face_shape(shape, operation): + if hasattr(shape, 'Shape'): + shape = shape.Shape + if shape is None or shape.isNull(): + raise RuntimeError(f'{operation} produced no valid shape') + if getattr(shape, 'ShapeType', '') == 'Face': + return shape + faces = list(getattr(shape, 'Faces', []) or []) + if len(faces) == 1: + return faces[0] + raise RuntimeError(f'{operation} expected exactly one face, got {len(faces)}') + + +def _face_shape_from_wire_shape(shape, operation='make_face_from_wire_rface'): + source_obj = shape + if hasattr(shape, 'Shape'): + shape = shape.Shape + try: + shape_invalid = shape is None or shape.isNull() + except Exception: + shape_invalid = shape is None + if shape_invalid and hasattr(source_obj, 'Shape'): + try: + doc.recompute() + except Exception: + pass + shape = getattr(source_obj, 'Shape', None) + try: + shape_invalid = shape is None or shape.isNull() + except Exception: + shape_invalid = shape is None + if shape_invalid: + raise RuntimeError(f'{operation} source has no valid shape') + if getattr(shape, 'ShapeType', '') == 'Face': + return shape + if getattr(shape, 'ShapeType', '') == 'Wire': + return Part.Face(shape) + wires = list(getattr(shape, 'Wires', []) or []) + if len(wires) == 1: + return Part.Face(wires[0]) + return _single_face_shape(shape, operation) + + +def _wire_shape_from_object(obj, operation): + source_obj = obj + shape = getattr(obj, 'Shape', obj) + try: + shape_invalid = shape is None or shape.isNull() + except Exception: + shape_invalid = shape is None + if shape_invalid and hasattr(source_obj, 'Shape'): + try: + doc.recompute() + except Exception: + pass + shape = getattr(source_obj, 'Shape', None) + try: + shape_invalid = shape is None or shape.isNull() + except Exception: + shape_invalid = shape is None + if shape_invalid: + raise RuntimeError(f'{operation} source has no valid shape') + if getattr(shape, 'ShapeType', '') == 'Wire': + return shape + wires = list(getattr(shape, 'Wires', []) or []) + if len(wires) == 1: + return wires[0] + raise RuntimeError(f'{operation} expected exactly one wire, got {len(wires)}') + + +def _face_shape_from_wire_shapes(outer_obj, inner_objs, operation='make_face_from_wires_rface'): + outer_wire = _wire_shape_from_object(outer_obj, operation + ' outer') + inner_wires = [_wire_shape_from_object(inner_obj, operation + ' inner') for inner_obj in inner_objs] + attempts = [ + [outer_wire] + [wire.reversed() for wire in inner_wires], + [outer_wire] + inner_wires, + ] + for wires in attempts: + try: + face = Part.Face(wires) + except Exception: + continue + if face is not None and not face.isNull() and face.isValid(): + return face + raise RuntimeError(f'{operation} produced an invalid multi-loop face') + + +def _face_boolean_shape(operation, base_obj, tool_obj): + base_shape = _face_shape_from_wire_shape(base_obj, operation + ' base') + tool_shape = _face_shape_from_wire_shape(tool_obj, operation + ' tool') + if operation == 'make_2d_cut_rface': + result = base_shape.cut(tool_shape) + elif operation == 'make_2d_union_rface': + result = base_shape.fuse(tool_shape) + elif operation == 'make_2d_intersect_rface': + result = base_shape.common(tool_shape) + else: + raise RuntimeError(f'Unsupported 2D face boolean {operation!r}') + return _single_face_shape(result, operation) + + +def _make_native_object(type_id, name, *, node_id, op, params, inputs, tags, context, output_count, param_exprs=None, semantic_delta=None, topo_delta=None): + obj = doc.addObject(type_id, name) + return _register_graph_object( + obj, + node_id=node_id, + op=op, + params=params, + inputs=inputs, + tags=tags, + context=context, + output_count=output_count, + param_exprs=param_exprs, + semantic_delta=semantic_delta, + topo_delta=topo_delta, + ) diff --git a/SimpleCADAPI/src/simplecadapi/translator/freecad_translator/runtime/persistence.py b/SimpleCADAPI/src/simplecadapi/translator/freecad_translator/runtime/persistence.py new file mode 100644 index 0000000..3e26bd3 --- /dev/null +++ b/SimpleCADAPI/src/simplecadapi/translator/freecad_translator/runtime/persistence.py @@ -0,0 +1,264 @@ +def _node_object(node_id, index=0): + outputs = GRAPH_OUTPUTS.get(node_id, []) + if not outputs: + raise RuntimeError(f'Missing graph output object for node {node_id!r}') + idx = int(index) + if idx < 0 or idx >= len(outputs): + raise RuntimeError(f'Output object slot {idx} missing for node {node_id!r}') + return outputs[idx] + + +def _set_visibility(obj, visible): + if obj is not None: + try: + GUI_VISIBILITY_BY_NAME[str(obj.Name)] = bool(visible) + except Exception: + pass + try: + view = getattr(obj, 'ViewObject', None) + if view is not None and hasattr(view, 'Visibility'): + view.Visibility = bool(visible) + except Exception: + pass + try: + if hasattr(obj, 'Visibility'): + obj.Visibility = bool(visible) + except Exception: + pass + + +def _set_expanded(obj, expanded=True): + try: + GUI_EXPANDED_BY_NAME[str(obj.Name)] = bool(expanded) + except Exception: + pass + + +def _xml_attr(value): + return str(value).replace('&', '&').replace('"', '"').replace('<', '<').replace('>', '>') + + +def _visibility_for_gui(obj): + try: + name = str(obj.Name) + except Exception: + return False + if name in GUI_VISIBILITY_BY_NAME: + return bool(GUI_VISIBILITY_BY_NAME[name]) + try: + return bool(obj.Visibility) + except Exception: + return False + + +def _write_gui_document_xml(fcstd_path): + object_rows = [] + for obj in list(getattr(doc, 'Objects', []) or []): + try: + name = str(obj.Name) + except Exception: + continue + object_rows.append(( + name, + _visibility_for_gui(obj), + bool(GUI_EXPANDED_BY_NAME.get(name, False)), + GUI_SHAPE_COLOR_BY_NAME.get(name), + bool(GUI_MATERIAL_OVERRIDE_BY_NAME.get(name, False)), + )) + lines = [ + "", + '', + '', + f' ', + ] + for name, visible, expanded, packed_color, override_material in object_rows: + joint_type = SIMPLECAD_JOINT_OBJECTS.get(name) + properties = [ + ('Visibility', 'App::PropertyBool', f''), + ] + if packed_color is not None and override_material: + properties.extend([ + ('OverrideMaterial', 'App::PropertyBool', ''), + ( + 'ShapeMaterial', + 'App::PropertyMaterial', + '', + ), + ]) + elif packed_color is not None: + properties.append( + ('ShapeColor', 'App::PropertyColor', f'') + ) + if joint_type is not None: + vp_class = 'ViewProviderGroundedJoint' if joint_type == 'Grounded' else 'ViewProviderJoint' + properties.append( + ('Proxy', 'App::PropertyPythonObject', f'') + ) + lines.extend([ + f' ', + f' ', + ]) + for prop_name, prop_type, prop_value in properties: + status = ' status="67108864"' if prop_name == 'Proxy' else '' + lines.extend([ + f' ', + f' {prop_value}', + ' ', + ]) + lines.extend([ + ' ', + ' ', + ]) + lines.extend([ + ' ', + ' ', + '', + '', + ]) + gui_document = '\n'.join(lines).encode('utf-8') + tmp_path = str(fcstd_path) + '.simplecad_tmp' + with zipfile.ZipFile(fcstd_path, 'r') as source: + infos = source.infolist() + with zipfile.ZipFile(tmp_path, 'w', compression=zipfile.ZIP_DEFLATED) as target: + wrote_gui = False + for info in infos: + if info.filename == 'GuiDocument.xml': + target.writestr(info, gui_document) + wrote_gui = True + else: + target.writestr(info, source.read(info.filename)) + if not wrote_gui: + target.writestr('GuiDocument.xml', gui_document) + os.replace(tmp_path, fcstd_path) + + +def _save_fcstd_with_gui_visibility(output_path): + doc.recompute() + _hide_all_origin_trees() + doc.saveAs(output_path) + _hide_all_origin_trees() + _write_gui_document_xml(output_path) + + +def _placement_for_rotation(origin, axis, angle_degrees): + center = _vec(origin) + rotation = App.Rotation(_vec(axis), float(angle_degrees)) + to_center = App.Placement(center, rotation) + from_center = App.Placement(App.Vector(-center.x, -center.y, -center.z), App.Rotation()) + return to_center.multiply(from_center) + + +def _fold_object_placement(obj, placement): + obj.Placement = placement.multiply(obj.Placement) + return obj + + +def _set_part_body_visibility(product_value, visible): + container = product_value.get('container') if isinstance(product_value, dict) else None + if container is None: + return + body = product_value.get('body') + for child in list(getattr(container, 'Group', []) or []): + if _is_origin_object(child): + _set_visibility(child, False) + continue + if _is_connector_object(child): + _set_visibility(child, False) + continue + _set_visibility(child, bool(visible and child is body)) + if body is not None: + _set_visibility(body, visible) + + +def _set_product_tree_visibility(product_value, visible, *, show_source_container=True): + if not isinstance(product_value, dict): + return + kind = product_value.get('kind') + container = product_value.get('container') + if kind == 'part': + if container is not None: + _set_visibility(container, visible if show_source_container else False) + _set_expanded(container, bool(visible and show_source_container)) + _hide_origin_tree(container) + _set_part_body_visibility(product_value, visible) + return + if kind == 'assembly': + if container is not None: + _set_visibility(container, visible if show_source_container else False) + _set_expanded(container, bool(visible and show_source_container)) + _hide_origin_tree(container) + for child in list(getattr(container, 'Group', []) or []): + if _is_connector_object(child): + _set_visibility(child, False) + for component in product_value.get('components', []): + link = component.get('link') + if link is not None: + _set_visibility(link, visible) + _set_expanded(link, bool(visible)) + item = component.get('item') + _set_product_tree_visibility(item, visible, show_source_container=False) + + +def _apply_product_result_visibility(visible_ids): + product_ids = set() + product_ids.update(str(node_id) for node_id in visible_ids if str(node_id) in PRODUCT_VALUES) + product_ids.update( + str(source_id) + for node_id, source_id in ASSEMBLY_PROJECTION_INPUTS.items() + if str(node_id) in visible_ids + ) + for node_id in product_ids: + _set_product_tree_visibility(PRODUCT_VALUES.get(node_id), True) + if PRODUCT_LIBRARY_GROUP is not None: + _set_visibility(PRODUCT_LIBRARY_GROUP, False) + if CONSTRUCTION_GROUP is not None: + _set_visibility(CONSTRUCTION_GROUP, False) + + +def _result_product_container(result_node_ids): + for node_id in result_node_ids or []: + product_value = PRODUCT_VALUES.get(str(node_id)) + if isinstance(product_value, dict) and product_value.get('container') is not None: + return product_value.get('container') + source_id = ASSEMBLY_PROJECTION_INPUTS.get(str(node_id)) + product_value = PRODUCT_VALUES.get(str(source_id)) + if isinstance(product_value, dict) and product_value.get('container') is not None: + return product_value.get('container') + return None + + +def _set_active_result_object(result_node_ids): + obj = _result_product_container(result_node_ids) + if obj is None: + result_objects = [candidate for node_id in (result_node_ids or []) for candidate in GRAPH_OUTPUTS.get(str(node_id), [])] + obj = result_objects[0] if result_objects else None + if obj is None: + return + try: + doc.ActiveObject = obj + except Exception: + pass + try: + import FreeCADGui as Gui + if getattr(App, 'GuiUp', False) and Gui.ActiveDocument is not None: + Gui.ActiveDocument.ActiveView.setActiveObject('part', obj) + except Exception: + pass + + +def _apply_result_visibility(result_node_ids): + visible_ids = {str(node_id) for node_id in (result_node_ids or [])} + projection_visible_ids = {str(source_id) for node_id, source_id in ASSEMBLY_PROJECTION_INPUTS.items() if str(node_id) in visible_ids} + for node_id, outputs in GRAPH_OUTPUTS.items(): + is_visible = str(node_id) in visible_ids or str(node_id) in projection_visible_ids + if str(node_id) in ASSEMBLY_PROJECTION_INPUTS: + is_visible = False + for obj in outputs: + _set_visibility(obj, is_visible) + _apply_product_result_visibility(visible_ids) + _hide_all_origin_trees() diff --git a/SimpleCADAPI/src/simplecadapi/translator/freecad_translator/runtime/products.py b/SimpleCADAPI/src/simplecadapi/translator/freecad_translator/runtime/products.py new file mode 100644 index 0000000..13f76f6 --- /dev/null +++ b/SimpleCADAPI/src/simplecadapi/translator/freecad_translator/runtime/products.py @@ -0,0 +1,276 @@ +PRODUCT_LIBRARY_GROUP = None +CONSTRUCTION_GROUP = None +MATERIAL_LIBRARY_GROUP = None + + +def _named_document_group(name, label): + existing = doc.getObject(name) + if existing is not None: + return existing + group = doc.addObject('App::DocumentObjectGroup', name) + group.Label = label + _set_visibility(group, False) + return group + + +def _product_library_group(): + global PRODUCT_LIBRARY_GROUP + if PRODUCT_LIBRARY_GROUP is None: + PRODUCT_LIBRARY_GROUP = _named_document_group('SimpleCADProductLibrary', 'SimpleCAD Product Library') + return PRODUCT_LIBRARY_GROUP + + +def _construction_group(): + global CONSTRUCTION_GROUP + if CONSTRUCTION_GROUP is None: + CONSTRUCTION_GROUP = _named_document_group('SimpleCADConstruction', 'SimpleCAD Construction') + return CONSTRUCTION_GROUP + + +def _material_library_group(): + global MATERIAL_LIBRARY_GROUP + if MATERIAL_LIBRARY_GROUP is None: + MATERIAL_LIBRARY_GROUP = _named_document_group('SimpleCADMaterials', 'SimpleCAD Materials') + return MATERIAL_LIBRARY_GROUP + + +def _group_contains(group, obj): + return obj in list(getattr(group, 'Group', []) or []) + + +def _add_to_group(group, obj): + if obj is None or obj is group: + return + if not _group_contains(group, obj): + try: + group.addObject(obj) + except Exception: + pass + + +def _material_from_assignment_params(params): + params = dict(params or {}) + material = params.get('material') + if isinstance(material, dict): + return dict(material) + return { + 'material_id': str(params.get('material_id', '')), + 'name': params.get('name'), + 'density': params.get('density'), + 'density_unit': params.get('density_unit'), + 'color': params.get('color'), + } + + +def _material_color(material): + color = (material or {}).get('color') + if not isinstance(color, (list, tuple)) or len(color) != 3: + return None + try: + result = tuple(float(component) for component in color) + except Exception: + return None + if any(component < 0.0 or component > 1.0 for component in result): + return None + return result + + +def _packed_rgba(color): + channels = [max(0, min(255, int(float(component) * 255.0 + 0.5))) for component in color] + return (channels[0] << 24) | (channels[1] << 16) | (channels[2] << 8) | 255 + + +def _ensure_material_object(material): + material = dict(material or {}) + material_id = str(material.get('material_id') or '') + if not material_id: + raise RuntimeError('Material assignment is missing material_id') + existing = MATERIAL_OBJECTS_BY_ID.get(material_id) + if existing is not None: + return existing + obj = doc.addObject('App::MaterialObjectPython', 'Material_' + _simplecad_slug(material_id, prefix='material')) + obj.Label = str(material.get('name') or material_id) + _ensure_string_property(obj, 'SimpleCADMaterialId', 'Material') + _ensure_string_property(obj, 'SimpleCADMaterialName', 'Material') + _ensure_string_property(obj, 'SimpleCADMaterial', 'Material') + obj.SimpleCADMaterialId = material_id + obj.SimpleCADMaterialName = str(material.get('name') or '') + obj.SimpleCADMaterial = json.dumps(material, ensure_ascii=True, sort_keys=True) + if material.get('density') is not None: + _ensure_float_property(obj, 'SimpleCADDensity', 'Material') + _ensure_string_property(obj, 'SimpleCADDensityUnit', 'Material') + obj.SimpleCADDensity = float(material.get('density')) + obj.SimpleCADDensityUnit = str(material.get('density_unit') or '') + color = _material_color(material) + if color is not None: + _ensure_color_property(obj, 'SimpleCADColor', 'Material') + obj.SimpleCADColor = color + native_map = { + 'SimpleCAD.MaterialId': material_id, + 'General.Name': str(material.get('name') or material_id), + } + if material.get('density') is not None: + native_map['General.Density'] = str(material.get('density')) + ' ' + str(material.get('density_unit') or '') + if color is not None: + native_map['General.Color'] = ','.join(str(component) for component in color) + obj.Material = native_map + _add_to_group(_material_library_group(), obj) + _set_visibility(obj, False) + MATERIAL_OBJECTS_BY_ID[material_id] = obj + return obj + + +def _apply_material_to_object(obj, material, *, override=False, visual=True): + if obj is None: + return None + material = dict(material or {}) + material_obj = _ensure_material_object(material) + _ensure_string_property(obj, 'SimpleCADMaterial') + _ensure_link_property(obj, 'SimpleCADMaterialObject') + obj.SimpleCADMaterial = json.dumps(material, ensure_ascii=True, sort_keys=True) + obj.SimpleCADMaterialObject = material_obj + color = _material_color(material) + if color is None or not visual: + return material_obj + name = str(getattr(obj, 'Name', '')) + if name: + GUI_SHAPE_COLOR_BY_NAME[name] = _packed_rgba(color) + if override: + GUI_MATERIAL_OVERRIDE_BY_NAME[name] = True + try: + view = getattr(obj, 'ViewObject', None) + if view is not None: + if hasattr(view, 'ShapeColor'): + view.ShapeColor = color + if override and hasattr(view, 'OverrideMaterial'): + view.OverrideMaterial = True + except Exception: + pass + return material_obj + + +def _apply_material_to_product(product_value, material): + material_obj = _apply_material_to_object(product_value.get('container'), material, visual=False) + _apply_material_to_object(product_value.get('body'), material) + product_value['material'] = dict(material or {}) + product_value['material_object'] = material_obj + return product_value + + +def _is_origin_object(obj): + return getattr(obj, 'TypeId', '') == 'App::Origin' or str(getattr(obj, 'Name', '')).startswith('Origin') + + +def _is_connector_object(obj): + try: + if hasattr(obj, 'SimpleCADConnectorId'): + return True + except Exception: + pass + try: + return str(getattr(obj, 'Name', '')).startswith('connector_') + except Exception: + return False + + +def _hide_origin_tree(container): + for child in list(getattr(container, 'Group', []) or []): + if _is_origin_object(child): + _set_visibility(child, False) + for nested in list(getattr(child, 'OutListRecursive', []) or []): + _set_visibility(nested, False) + + +def _hide_all_origin_trees(): + for obj in list(getattr(doc, 'Objects', []) or []): + if not _is_origin_object(obj): + continue + _set_visibility(obj, False) + for nested in list(getattr(obj, 'OutListRecursive', []) or []): + _set_visibility(nested, False) + + +def _move_to_construction_group(obj): + group = _construction_group() + for candidate in [obj] + list(getattr(obj, 'OutListRecursive', []) or []): + if candidate is None: + continue + if getattr(candidate, 'TypeId', '') in {'App::Origin', 'App::Line', 'App::Plane', 'App::Point'}: + continue + _add_to_group(group, candidate) + _set_visibility(candidate, False) + _set_visibility(group, False) + + +def _move_product_source_to_library(product_item): + container = product_item.get('container') if isinstance(product_item, dict) else None + if container is None: + return + group = _product_library_group() + _add_to_group(group, container) + _set_visibility(container, False) + _hide_origin_tree(container) + _set_visibility(group, False) + + +def _make_part_body_copy(part_container, source_obj, source_node_id): + doc.recompute() + if source_obj is None or not hasattr(source_obj, 'Shape'): + raise RuntimeError('Part body source has no shape') + body = part_container.newObject('Part::Feature', 'Body') + body.Label = 'Body' + body.Shape = source_obj.Shape.copy() + _ensure_string_property(body, 'SimpleCADSourceBodyNodeId') + body.SimpleCADSourceBodyNodeId = str(source_node_id) + _set_visibility(body, True) + _move_to_construction_group(source_obj) + return body + + +def _make_assembly_component_link(assembly_container, product_item, name, label, placement): + link_type = 'Assembly::AssemblyLink' if product_item.get('kind') == 'assembly' else 'App::Link' + link = assembly_container.newObject(link_type, name) + link.Label = str(label) + _move_product_source_to_library(product_item) + link.LinkedObject = product_item.get('container') + if product_item.get('kind') == 'part': + link.LinkedObject = product_item.get('container') or product_item.get('body') + if link_type == 'Assembly::AssemblyLink': + try: + movable_kinds = {'revolute', 'prismatic'} + link.Rigid = not any( + str(constraint.get('constraint_kind')) in movable_kinds + for constraint in list(product_item.get('constraints', []) or []) + ) + except Exception: + pass + _set_component_link_placement(link, product_item, placement) + if product_item.get('kind') == 'part' and product_item.get('material'): + _apply_material_to_object(link, product_item.get('material'), override=True) + return link + + +def _set_component_link_placement(link, product_item, placement): + target_placement = _placement_from_axes_payload(placement) + if not ( + getattr(link, 'TypeId', '') == 'Assembly::AssemblyLink' + and not bool(getattr(link, 'Rigid', True)) + ): + link.Placement = target_placement + return + doc.recompute() + link.Placement = App.Placement() + for source_component in list(product_item.get('components', []) or []): + source_link = source_component.get('link') + local_link = next( + ( + child + for child in list(getattr(link, 'Group', []) or []) + if getattr(child, 'LinkedObject', None) is source_link + ), + None, + ) + if local_link is None: + continue + source_placement = _placement_from_axes_payload(source_component.get('placement') or {}) + local_link.Placement = target_placement.multiply(source_placement) diff --git a/SimpleCADAPI/src/simplecadapi/translator/freecad_translator/runtime/selections.py b/SimpleCADAPI/src/simplecadapi/translator/freecad_translator/runtime/selections.py new file mode 100644 index 0000000..7a812d8 --- /dev/null +++ b/SimpleCADAPI/src/simplecadapi/translator/freecad_translator/runtime/selections.py @@ -0,0 +1,731 @@ +def _subshape_candidates_for_kind(shape, kind): + kind = str(kind).lower() + if kind == 'solid': + return list(getattr(shape, 'Solids', []) or [shape]) + if kind == 'face': + return list(getattr(shape, 'Faces', []) or []) + if kind == 'edge': + return list(getattr(shape, 'Edges', []) or []) + if kind == 'wire': + return list(getattr(shape, 'Wires', []) or []) + if kind == 'vertex': + return list(getattr(shape, 'Vertexes', []) or []) + return [] + + +def _point_tuple(point): + return (float(point.x), float(point.y), float(point.z)) + + +def _candidate_center(candidate): + center = getattr(candidate, 'CenterOfMass', None) + if center is not None: + return _point_tuple(center) + bound_box = getattr(candidate, 'BoundBox', None) + if bound_box is not None: + return ( + (float(bound_box.XMin) + float(bound_box.XMax)) / 2.0, + (float(bound_box.YMin) + float(bound_box.YMax)) / 2.0, + (float(bound_box.ZMin) + float(bound_box.ZMax)) / 2.0, + ) + return None + + +def _tuple3(value): + if isinstance(value, (list, tuple)) and len(value) == 3: + return (float(value[0]), float(value[1]), float(value[2])) + return None + + +def _dist3(a, b): + if a is None or b is None: + return 1e6 + return math.dist(a, b) + + +def _relative_scalar_delta(actual, expected, floor=1.0): + try: + actual_f = float(actual) + expected_f = float(expected) + except Exception: + return 1e6 + return abs(actual_f - expected_f) / max(abs(actual_f), abs(expected_f), float(floor)) + + +def _unit_tuple(value): + if value is None: + return None + length = math.sqrt(sum(float(v) * float(v) for v in value)) + if length <= 1e-12: + return None + return tuple(float(v) / length for v in value) + + +def _selector_bbox_diagonal(selector): + bbox = selector.get('bbox') if isinstance(selector, dict) else None + if not isinstance(bbox, dict): + return 1.0 + expected_min = _tuple3(bbox.get('min')) + expected_max = _tuple3(bbox.get('max')) + if expected_min is None or expected_max is None: + return 1.0 + return max(_dist3(expected_min, expected_max), 1.0) + + +def _candidate_face_normal(candidate): + try: + u_min, u_max, v_min, v_max = candidate.ParameterRange + normal = candidate.normalAt(0.5 * (float(u_min) + float(u_max)), 0.5 * (float(v_min) + float(v_max))) + return _unit_tuple(_point_tuple(normal)) + except Exception: + try: + normal = candidate.normalAt(0.0, 0.0) + return _unit_tuple(_point_tuple(normal)) + except Exception: + return None + + +def _candidate_geom_type(candidate): + try: + surface = getattr(candidate, 'Surface', None) + if surface is not None: + type_name = type(surface).__name__.replace('Part.', '').upper() + mapping = { + 'PLANE': 'PLANE', + 'CYLINDER': 'CYLINDER', + 'CONE': 'CONE', + 'SPHERE': 'SPHERE', + 'TORUS': 'TORUS', + 'BSPLINESURFACE': 'BSPLINE', + 'BEZIERSURFACE': 'BEZIER', + } + return mapping.get(type_name, type_name) + except Exception: + pass + try: + curve = getattr(candidate, 'Curve', None) + if curve is not None: + type_name = type(curve).__name__.replace('Part.', '').upper() + mapping = { + 'LINE': 'LINE', + 'LINESEGMENT': 'LINE', + 'CIRCLE': 'CIRCLE', + 'ELLIPSE': 'ELLIPSE', + 'BSPLINECURVE': 'BSPLINE', + 'BEZIERCURVE': 'BEZIER', + } + result = mapping.get(type_name, type_name) + if result == 'BSPLINE' and _edge_is_geometrically_linear(candidate): + return 'LINE' + return result + except Exception: + pass + return None + + +def _canonical_geom_type(value): + text = str(value or '').upper().replace('_TYPE', '').replace('_', '') + aliases = ( + ('B-SPLINE', 'BSPLINE'), + ('BSPLINE', 'BSPLINE'), + ('NURBS', 'BSPLINE'), + ('BEZIER', 'BEZIER'), + ('ELLIPTICALARC', 'ELLIPSE'), + ('ELLIPSE', 'ELLIPSE'), + ('CYLINDER', 'CYLINDER'), + ('CIRCLE', 'CIRCLE'), + ('PLANE', 'PLANE'), + ('LINE', 'LINE'), + ('CONE', 'CONE'), + ('SPHERE', 'SPHERE'), + ('TORUS', 'TORUS'), + ) + for token, canonical in aliases: + if token in text: + return canonical + return text + + +def _edge_endpoints(candidate): + vertices = list(getattr(candidate, 'Vertexes', []) or []) + if len(vertices) >= 2: + return _point_tuple(vertices[0].Point), _point_tuple(vertices[-1].Point) + try: + return ( + _point_tuple(candidate.valueAt(float(candidate.FirstParameter))), + _point_tuple(candidate.valueAt(float(candidate.LastParameter))), + ) + except Exception: + return None + + +def _edge_is_geometrically_linear(candidate): + endpoints = _edge_endpoints(candidate) + if endpoints is None: + return False + start, end = endpoints + chord = App.Vector( + end[0] - start[0], end[1] - start[1], end[2] - start[2] + ) + chord_length = float(getattr(chord, 'Length', 0.0)) + edge_length = float(getattr(candidate, 'Length', 0.0)) + scale = max(1.0, chord_length, edge_length) + if chord_length <= scale * 1e-10: + return False + if abs(edge_length - chord_length) > scale * 1e-7: + return False + try: + first = float(candidate.FirstParameter) + last = float(candidate.LastParameter) + samples = [ + candidate.valueAt(first + (last - first) * index / 8.0) + for index in range(9) + ] + except Exception: + return False + origin = App.Vector(*start) + return all( + float(getattr((point - origin).cross(chord), 'Length', 0.0)) + / chord_length + <= scale * 1e-7 + for point in samples + ) + + +def _selector_geom_type(selector): + geom_type = _canonical_geom_type(selector.get('geom_type')) + if geom_type != 'BSPLINE' or str(selector.get('kind', '')).lower() != 'edge': + return geom_type + start = _tuple3(selector.get('start')) + end = _tuple3(selector.get('end')) + expected_length = selector.get('length') + if start is None or end is None or expected_length is None: + return geom_type + chord_length = _dist3(start, end) + scale = max(1.0, chord_length, abs(float(expected_length))) + if abs(float(expected_length) - chord_length) <= scale * 1e-7: + return 'LINE' + return geom_type + + +def _bbox_selector_score(candidate, selector): + bbox = selector.get('bbox') if isinstance(selector, dict) else None + bound_box = getattr(candidate, 'BoundBox', None) + if not isinstance(bbox, dict) or bound_box is None: + return 0.0 + expected_min = _tuple3(bbox.get('min')) + expected_max = _tuple3(bbox.get('max')) + if expected_min is None or expected_max is None: + return 1e6 + actual_min = (float(bound_box.XMin), float(bound_box.YMin), float(bound_box.ZMin)) + actual_max = (float(bound_box.XMax), float(bound_box.YMax), float(bound_box.ZMax)) + return (_dist3(actual_min, expected_min) + _dist3(actual_max, expected_max)) / _selector_bbox_diagonal(selector) + + +def _geo_selector_score(candidate, selector, candidate_index): + score = _bbox_selector_score(candidate, selector) * 10.0 + expected_geom_type = _selector_geom_type(selector) + actual_geom_type = _canonical_geom_type(_candidate_geom_type(candidate)) + if expected_geom_type and actual_geom_type and expected_geom_type != actual_geom_type: + score += 10.0 + kind = str(selector.get('kind', '')).lower() + if kind == 'edge': + if 'length' in selector and hasattr(candidate, 'Length'): + score += _relative_scalar_delta(candidate.Length, selector['length']) * 10.0 + score += (_dist3(_candidate_center(candidate), _tuple3(selector.get('center'))) / _selector_bbox_diagonal(selector)) * 10.0 + vertices = list(getattr(candidate, 'Vertexes', []) or []) + if len(vertices) >= 2: + start = _point_tuple(vertices[0].Point) + end = _point_tuple(vertices[-1].Point) + expected_start = _tuple3(selector.get('start')) + expected_end = _tuple3(selector.get('end')) + if expected_start is not None and expected_end is not None: + direct = _dist3(start, expected_start) + _dist3(end, expected_end) + reverse = _dist3(start, expected_end) + _dist3(end, expected_start) + score += min(direct, reverse) / max(float(candidate.Length), float(selector.get('length', 1.0)), 1.0) + elif kind == 'face': + if 'area' in selector and hasattr(candidate, 'Area'): + score += _relative_scalar_delta(candidate.Area, selector['area']) * 10.0 + score += (_dist3(_candidate_center(candidate), _tuple3(selector.get('center'))) / _selector_bbox_diagonal(selector)) * 10.0 + expected_normal = _unit_tuple(_tuple3(selector.get('normal'))) + actual_normal = _candidate_face_normal(candidate) + if expected_normal is not None and actual_normal is not None: + reversed_expected = tuple(-float(v) for v in expected_normal) + score += min(_dist3(actual_normal, expected_normal), _dist3(actual_normal, reversed_expected)) + if 'edge_count' in selector: + score += abs(len(list(getattr(candidate, 'Edges', []) or [])) - int(selector['edge_count'])) * 0.001 + if 'inner_wire_count' in selector: + score += abs(max(0, len(list(getattr(candidate, 'Wires', []) or [])) - 1) - int(selector['inner_wire_count'])) * 0.001 + elif kind == 'vertex': + point = getattr(candidate, 'Point', None) + if point is not None: + score += (_dist3(_point_tuple(point), _tuple3(selector.get('coordinates'))) / _selector_bbox_diagonal(selector)) * 10.0 + elif kind == 'wire': + edges = list(getattr(candidate, 'Edges', []) or []) + if 'edge_count' in selector: + score += abs(len(edges) - int(selector['edge_count'])) * 10.0 + elif kind == 'solid': + if 'volume' in selector and hasattr(candidate, 'Volume'): + score += _relative_scalar_delta(candidate.Volume, selector['volume']) * 10.0 + return score + + +def _selection_index_for_selector(source_shape, selector, context=None): + kind = str(selector.get('kind') or selector.get('target_kind') or '').lower() + candidates = _subshape_candidates_for_kind(source_shape, kind) + if not candidates: + raise RuntimeError(f'No {kind} candidates available for geo selection') + ranked = sorted( + enumerate(candidates), + key=lambda item: _geo_selector_score(item[1], selector, item[0]), + ) + best_index, best_candidate = ranked[0] + best_score = _geo_selector_score(best_candidate, selector, best_index) + second_score = ( + _geo_selector_score(ranked[1][1], selector, ranked[1][0]) + if len(ranked) > 1 + else float('inf') + ) + if best_score <= 1e-4 and second_score <= 1e-4: + raise RuntimeError( + f'Geo selector is ambiguous for {kind}; context={context!r}, ' + f'best score={best_score:.6g}, second score={second_score:.6g}' + ) + if best_score > 1e-2: + suffix = '' if second_score == float('inf') else f', second score={second_score:.6g}' + raise RuntimeError( + f'Geo selector did not match a stable {kind} candidate; ' + f'context={context!r}, best score={best_score:.6g}{suffix}' + ) + return int(best_index) + + +def _same_fragment_support(first, second, expected_type, scale): + first_type = _canonical_geom_type(_candidate_geom_type(first)) + second_type = _canonical_geom_type(_candidate_geom_type(second)) + if first_type != expected_type or second_type != expected_type: + return False + first_endpoints = _edge_endpoints(first) + second_endpoints = _edge_endpoints(second) + if first_endpoints is None or second_endpoints is None: + return False + tolerance = max(1e-7, scale * 1e-5) + if expected_type == 'LINE': + left = App.Vector( + *(first_endpoints[1][axis] - first_endpoints[0][axis] for axis in range(3)) + ) + right = App.Vector( + *(second_endpoints[1][axis] - second_endpoints[0][axis] for axis in range(3)) + ) + if float(left.Length) <= 1e-12 or float(right.Length) <= 1e-12: + return False + if abs(float(left.dot(right))) / (float(left.Length) * float(right.Length)) < 1.0 - 1e-6: + return False + origin = App.Vector(*first_endpoints[0]) + return all( + float(getattr((App.Vector(*point) - origin).cross(left), 'Length', 0.0)) + / float(left.Length) + <= tolerance + for point in second_endpoints + ) + if expected_type == 'CIRCLE': + first_curve = getattr(first, 'Curve', None) + second_curve = getattr(second, 'Curve', None) + if first_curve is None or second_curve is None: + return False + try: + centers_match = _dist3( + _point_tuple(first_curve.Center), _point_tuple(second_curve.Center) + ) <= tolerance + radii_match = abs(float(first_curve.Radius) - float(second_curve.Radius)) <= tolerance + axes_match = abs(float(first_curve.Axis.dot(second_curve.Axis))) >= 1.0 - 1e-6 + return centers_match and radii_match and axes_match + except Exception: + return False + return False + + +def _combined_edge_matches(candidates, selector): + if len(candidates) < 2: + return False + expected_length = selector.get('length') + expected_center = _tuple3(selector.get('center')) + expected_bbox = selector.get('bbox') + if expected_length is None or expected_center is None or not isinstance(expected_bbox, dict): + return False + total_length = sum(float(candidate.Length) for candidate in candidates) + scale = _selector_bbox_diagonal(selector) + if abs(total_length - float(expected_length)) / max(1.0, abs(float(expected_length))) > 1e-4: + return False + weighted_center = tuple( + sum(_candidate_center(candidate)[axis] * float(candidate.Length) for candidate in candidates) + / total_length + for axis in range(3) + ) + if _dist3(weighted_center, expected_center) / scale > 1e-4: + return False + expected_min = _tuple3(expected_bbox.get('min')) + expected_max = _tuple3(expected_bbox.get('max')) + actual_min = ( + min(float(candidate.BoundBox.XMin) for candidate in candidates), + min(float(candidate.BoundBox.YMin) for candidate in candidates), + min(float(candidate.BoundBox.ZMin) for candidate in candidates), + ) + actual_max = ( + max(float(candidate.BoundBox.XMax) for candidate in candidates), + max(float(candidate.BoundBox.YMax) for candidate in candidates), + max(float(candidate.BoundBox.ZMax) for candidate in candidates), + ) + return ( + expected_min is not None + and expected_max is not None + and (_dist3(actual_min, expected_min) + _dist3(actual_max, expected_max)) / scale <= 1e-4 + ) + + +def _fragmented_edge_indices(source_shape, selector, context=None): + if str(selector.get('kind', '')).lower() != 'edge': + return None + expected_type = _selector_geom_type(selector) + expected_start = _tuple3(selector.get('start')) + expected_end = _tuple3(selector.get('end')) + if expected_type not in {'LINE', 'CIRCLE'} or expected_start is None or expected_end is None: + return None + scale = _selector_bbox_diagonal(selector) + tolerance = max(1e-7, scale * 1e-5) + if ( + _dist3(expected_start, expected_end) <= tolerance + and expected_type != 'CIRCLE' + ): + return None + candidates = list(getattr(source_shape, 'Edges', []) or []) + eligible = [ + (index, candidate, _edge_endpoints(candidate)) + for index, candidate in enumerate(candidates) + if _canonical_geom_type(_candidate_geom_type(candidate)) == expected_type + and float(candidate.Length) < float(selector.get('length', candidate.Length)) * (1.0 - 1e-6) + ] + eligible = [item for item in eligible if item[2] is not None] + valid = set() + for index, candidate, endpoints in eligible: + next_points = [] + if _dist3(endpoints[0], expected_start) <= tolerance: + next_points.append(endpoints[1]) + if _dist3(endpoints[1], expected_start) <= tolerance: + next_points.append(endpoints[0]) + for point in next_points: + stack = [([index], [candidate], point)] + while stack: + indices, group, current = stack.pop() + if _dist3(current, expected_end) <= tolerance: + if _combined_edge_matches(group, selector): + valid.add(tuple(sorted(indices))) + continue + if len(indices) >= min(12, len(eligible)): + continue + for next_index, next_candidate, next_endpoints in eligible: + if next_index in indices or not _same_fragment_support( + group[-1], next_candidate, expected_type, scale + ): + continue + for endpoint_index in (0, 1): + if _dist3(next_endpoints[endpoint_index], current) <= tolerance: + stack.append(( + indices + [next_index], + group + [next_candidate], + next_endpoints[1 - endpoint_index], + )) + if len(valid) == 1: + return list(next(iter(valid))) + if len(valid) > 1: + raise RuntimeError( + f'Fragmented edge selector is ambiguous; context={context!r}, groups={sorted(valid)!r}' + ) + return None + + +def _selection_indices_for_selector(source_shape, selector, context=None): + try: + return [_selection_index_for_selector(source_shape, selector, context=context)] + except RuntimeError as single_error: + fragmented = _fragmented_edge_indices(source_shape, selector, context=context) + if fragmented is None: + raise single_error + return fragmented + + +def _merged_selector_group_candidate(source_shape, selectors): + if len(selectors) < 2: + return None + geom_type = _selector_geom_type(selectors[0]) + if any(_selector_geom_type(selector) != geom_type for selector in selectors[1:]): + return None + endpoints = [ + (_tuple3(selector.get('start')), _tuple3(selector.get('end'))) + for selector in selectors + ] + if any(None in pair for pair in endpoints): + return None + scale = max(_selector_bbox_diagonal(selector) for selector in selectors) + tolerance = max(1e-7, scale * 1e-5) + if geom_type == 'LINE': + origin = App.Vector(*endpoints[0][0]) + axis = App.Vector(*endpoints[0][1]) - origin + if float(axis.Length) <= 1e-12 or any( + float((App.Vector(*point) - origin).cross(axis).Length) + / float(axis.Length) + > tolerance + for pair in endpoints + for point in pair + ): + return None + elif geom_type == 'CIRCLE': + centers = [_tuple3(selector.get('center')) for selector in selectors] + if any(center is None for center in centers) or any( + _dist3(centers[0], center) > tolerance for center in centers[1:] + ): + return None + + outer_pairs = set() + for start_index, start_pair in enumerate(endpoints): + for start_endpoint_index in (0, 1): + stack = [( + (start_index,), + start_pair[start_endpoint_index], + start_pair[1 - start_endpoint_index], + )] + while stack: + used, start_point, current = stack.pop() + if len(used) == len(selectors): + outer_pairs.add(tuple(sorted((start_point, current)))) + continue + for next_index, next_pair in enumerate(endpoints): + if next_index in used: + continue + for endpoint_index in (0, 1): + if _dist3(next_pair[endpoint_index], current) <= tolerance: + stack.append(( + used + (next_index,), + start_point, + next_pair[1 - endpoint_index], + )) + if len(outer_pairs) != 1: + return None + outer_start, outer_end = next(iter(outer_pairs)) + lengths = [float(selector.get('length', 0.0)) for selector in selectors] + total_length = sum(lengths) + if total_length <= 0.0: + return None + boxes = [selector.get('bbox') or {} for selector in selectors] + mins = [_tuple3(box.get('min')) for box in boxes] + maxs = [_tuple3(box.get('max')) for box in boxes] + centers = [_tuple3(selector.get('center')) for selector in selectors] + if any(value is None for value in mins + maxs + centers): + return None + combined = { + 'kind': 'edge', + 'geom_type': geom_type, + 'start': outer_start, + 'end': outer_end, + 'length': total_length, + 'center': tuple( + sum(centers[index][axis] * lengths[index] for index in range(len(selectors))) + / total_length + for axis in range(3) + ), + 'bbox': { + 'min': tuple(min(value[axis] for value in mins) for axis in range(3)), + 'max': tuple(max(value[axis] for value in maxs) for axis in range(3)), + }, + } + try: + return _selection_index_for_selector(source_shape, combined, context='merged edge selectors') + except RuntimeError: + return None + + +def _selection_indices_for_selectors(source_shape, selectors, context=None): + resolved = [] + failures = {} + for selector_index, selector in enumerate(selectors): + try: + resolved.append(_selection_indices_for_selector( + source_shape, selector, context=f'{context} selector {selector_index}' + )) + except RuntimeError as error: + resolved.append(None) + failures[selector_index] = error + if not failures: + return list(dict.fromkeys(index for indices in resolved for index in indices)) + failure_indices = sorted(failures) + if len(failure_indices) > 12: + raise failures[failure_indices[0]] + groups = [] + + def visit_group(start_offset, indices): + if len(indices) >= 2: + candidate = _merged_selector_group_candidate( + source_shape, [selectors[index] for index in indices] + ) + if candidate is not None: + groups.append((tuple(indices), candidate)) + if len(indices) >= len(failure_indices): + return + for next_offset in range(start_offset, len(failure_indices)): + visit_group( + next_offset + 1, + indices + [failure_indices[next_offset]], + ) + + visit_group(0, []) + if not groups: + raise failures[min(failures)] + solutions = [] + + def solve(used_selectors, used_candidates, chosen): + remaining = sorted(set(failures) - used_selectors) + if not remaining: + solutions.append(tuple(chosen)) + return + first = remaining[0] + for indices, candidate in groups: + index_set = set(indices) + if ( + first not in index_set + or index_set & used_selectors + or candidate in used_candidates + ): + continue + solve( + used_selectors | index_set, + used_candidates | {candidate}, + chosen + [(indices, candidate)], + ) + + solve(set(), set(), []) + if len(solutions) != 1: + raise RuntimeError( + f'Merged edge selector groups are ambiguous; groups={groups!r}' + ) + chosen = solutions[0] + used_selectors = { + selector_index + for indices, _candidate in chosen + for selector_index in indices + } + selected = [candidate for _indices, candidate in chosen] + for selector_index, indices in enumerate(resolved): + if selector_index in used_selectors: + continue + if indices is None: + raise failures[selector_index] + selected.extend(indices) + return list(dict.fromkeys(selected)) + + +def _register_geo_selection_node(*, node_id, op, params, inputs, tags, context, output_count, param_exprs=None, semantic_delta=None, topo_delta=None, allow_deferred=False): + if not inputs: + raise RuntimeError(f'Selection node {node_id!r} is missing its source input') + selector = dict(params.get('geo_selector') or {}) + source_node_id = str(inputs[0]) + source_shape = _shape_from_graph_node(source_node_id) + resolution_error = None + try: + indices = _selection_indices_for_selector( + source_shape, selector, context=f'node {node_id}' + ) + except RuntimeError as error: + if not allow_deferred: + raise + indices = [] + resolution_error = str(error) + candidates = _subshape_candidates_for_kind(source_shape, selector.get('kind')) + selected_shapes = [candidates[index] for index in indices] + selected_shape = ( + selected_shapes[0] + if len(selected_shapes) == 1 + else Part.makeCompound(selected_shapes) if selected_shapes else Part.Shape() + ) + payload = { + 'node_id': node_id, + 'op': op, + 'params': params, + 'inputs': list(inputs), + 'context': context or {}, + 'tags': list(tags or []), + 'output_count': int(output_count), + 'selector': selector, + 'index': int(indices[0]) if indices else None, + 'indices': [int(index) for index in indices], + 'resolution_error': resolution_error, + 'kind': str(selector.get('kind', '')), + 'shape': selected_shape, + } + obj = doc.addObject('Part::Feature', f'{str(op)}_{str(node_id)}') + obj.Shape = selected_shape + registered = _register_graph_object( + obj, + node_id=node_id, + op=op, + params=params, + inputs=inputs, + tags=tags, + context=context, + output_count=output_count, + param_exprs=param_exprs, + semantic_delta=semantic_delta, + topo_delta=topo_delta, + ) + GRAPH_SELECTIONS[node_id] = payload + return registered + + +def _register_tag_selection_node(name, *, node_id, op, params, inputs, tags, context, output_count, param_exprs=None, semantic_delta=None, topo_delta=None): + if len(inputs) != 1: + raise RuntimeError(f'Tag selection node {node_id!r} requires exactly one source input') + binding = params.get('tag_binding') + if not isinstance(binding, dict): + raise RuntimeError(f'Tag selection node {node_id!r} is missing its TagBinding payload') + source_obj = _node_object(str(inputs[0])) + link_type = 'App::Link' + obj = doc.addObject(link_type, name) + obj.LinkedObject = source_obj + obj.LinkTransform = True + _ensure_string_property(obj, 'SimpleCADTagBinding') + obj.SimpleCADTagBinding = json.dumps(binding, ensure_ascii=True, sort_keys=True) + return _register_graph_object( + obj, + node_id=node_id, + op=op, + params=params, + inputs=inputs, + tags=tags, + context=context, + output_count=output_count, + param_exprs=param_exprs, + semantic_delta=semantic_delta, + topo_delta=topo_delta, + ) + + +def _selected_indices_from_nodes(node_ids, fallback_indices, base_shape=None, kind=None): + selectors = [] + legacy_indices = [] + for node_id in node_ids or []: + payload = GRAPH_SELECTIONS.get(str(node_id)) or GRAPH_NODES.get(str(node_id)) + if isinstance(payload, dict) and base_shape is not None: + selector = dict(payload.get('selector') or payload.get('params', {}).get('geo_selector') or {}) + if kind is not None: + selector['kind'] = str(kind) + if selector: + selectors.append(selector) + continue + if isinstance(payload, dict) and 'index' in payload: + legacy_indices.append(int(payload['index'])) + if selectors and base_shape is not None: + return _selection_indices_for_selectors( + base_shape, selectors, context=f'{kind or "shape"} detail selector set' + ) + if legacy_indices: + return legacy_indices + return [int(idx) for idx in (fallback_indices or [])] diff --git a/SimpleCADAPI/src/simplecadapi/translator/freecad_translator/runtime/sketches.py b/SimpleCADAPI/src/simplecadapi/translator/freecad_translator/runtime/sketches.py new file mode 100644 index 0000000..69ee981 --- /dev/null +++ b/SimpleCADAPI/src/simplecadapi/translator/freecad_translator/runtime/sketches.py @@ -0,0 +1,635 @@ +def _make_metadata_note(name, title, payload): + obj = doc.addObject('App::FeaturePython', name) + _ensure_string_property(obj, 'Title') + _ensure_string_property(obj, 'Payload') + obj.Title = title + obj.Payload = json.dumps(payload, ensure_ascii=True, sort_keys=True) + return obj + + +def _register_ir_node(name, *, node_id, op, params, inputs, tags, context, output_count, param_exprs=None, semantic_delta=None, topo_delta=None): + return _register_graph_metadata_only( + node_id=node_id, + op=op, + params=params, + inputs=inputs, + tags=tags, + context=context, + output_count=output_count, + param_exprs=param_exprs, + semantic_delta=semantic_delta, + topo_delta=topo_delta, + ) + + +def _placement_from_frame(origin, x_axis, y_axis, z_axis): + m = App.Matrix() + m.A11, m.A21, m.A31 = x_axis.x, x_axis.y, x_axis.z + m.A12, m.A22, m.A32 = y_axis.x, y_axis.y, y_axis.z + m.A13, m.A23, m.A33 = z_axis.x, z_axis.y, z_axis.z + return App.Placement(origin, App.Rotation(m)) + + +def _sketch_plane_frame(plane): + if isinstance(plane, str): + token = plane.upper() + if token == 'XY': + origin = App.Vector(0.0, 0.0, 0.0) + x_axis = App.Vector(1.0, 0.0, 0.0) + y_axis = App.Vector(0.0, 1.0, 0.0) + z_axis = App.Vector(0.0, 0.0, 1.0) + return origin, x_axis, y_axis, z_axis + if token == 'XZ': + origin = App.Vector(0.0, 0.0, 0.0) + x_axis = App.Vector(1.0, 0.0, 0.0) + y_axis = App.Vector(0.0, 0.0, 1.0) + z_axis = App.Vector(0.0, -1.0, 0.0) + return origin, x_axis, y_axis, z_axis + if token == 'YZ': + origin = App.Vector(0.0, 0.0, 0.0) + x_axis = App.Vector(0.0, 1.0, 0.0) + y_axis = App.Vector(0.0, 0.0, 1.0) + z_axis = App.Vector(1.0, 0.0, 0.0) + return origin, x_axis, y_axis, z_axis + if isinstance(plane, dict): + origin = _vec(plane.get('origin', (0.0, 0.0, 0.0))) + x_axis = _normalized_vec(plane.get('x_axis', (1.0, 0.0, 0.0))) + y_axis = _normalized_vec(plane.get('y_axis', (0.0, 1.0, 0.0))) + z_axis = x_axis.cross(y_axis) + z_len = float(getattr(z_axis, 'Length', 0.0)) + if z_len == 0.0: + raise RuntimeError('Sketch plane x_axis and y_axis must not be parallel') + z_axis = App.Vector(z_axis.x / z_len, z_axis.y / z_len, z_axis.z / z_len) + y_axis = z_axis.cross(x_axis) + y_len = float(getattr(y_axis, 'Length', 0.0)) + y_axis = App.Vector(y_axis.x / y_len, y_axis.y / y_len, y_axis.z / y_len) + return origin, x_axis, y_axis, z_axis + raise RuntimeError(f'Unsupported sketch plane payload: {plane!r}') + + +def _sketch_entity_maps(sketch_payload): + entities = list(sketch_payload.get('entities') or []) if isinstance(sketch_payload, dict) else [] + return entities, {str(entity.get('id')): entity for entity in entities if isinstance(entity, dict) and entity.get('id') is not None} + + +def _sketch_solved_point(point_id, sketch_payload, solve_snapshot): + solved = (solve_snapshot or {}).get('solved_points', {}) if isinstance(solve_snapshot, dict) else {} + if point_id in solved: + point = solved[point_id] + return (float(point[0]), float(point[1])) + _entities, by_id = _sketch_entity_maps(sketch_payload) + entity = by_id.get(str(point_id)) + if isinstance(entity, dict) and entity.get('kind') == 'point': + return (float(entity.get('x', 0.0)), float(entity.get('y', 0.0))) + raise RuntimeError(f'Missing solved sketch point {point_id!r}') + + +def _sketch_solved_radius(entity_id, entity, solve_snapshot): + key = f'circle:{entity_id}:radius' + scalars = (solve_snapshot or {}).get('solved_scalars', {}) if isinstance(solve_snapshot, dict) else {} + if key in scalars: + return float(scalars[key]) + return float(entity.get('radius', 0.0)) + + +def _sketch_profile_entity_ids(params, sketch_payload): + promotion_map = params.get('promotion_map') if isinstance(params, dict) else None + if isinstance(promotion_map, dict): + edges = promotion_map.get('edges') or [] + ids = [str(edge.get('entity_id')) for edge in edges if isinstance(edge, dict) and edge.get('entity_id') is not None] + if ids: + return ids + entities, _by_id = _sketch_entity_maps(sketch_payload) + return [ + str(entity.get('id')) + for entity in entities + if entity.get('kind') in {'line', 'circle', 'arc', 'bspline'} and not bool(entity.get('construction', False)) + ] + + +def _sketch_world_point(point_id, sketch_payload, solve_snapshot, origin, x_axis, y_axis): + x, y = _sketch_solved_point(point_id, sketch_payload, solve_snapshot) + return App.Vector( + origin.x + x * x_axis.x + y * y_axis.x, + origin.y + x * x_axis.y + y * y_axis.y, + origin.z + x * x_axis.z + y * y_axis.z, + ) + + +def _sketch_xy_to_world(x, y, origin, x_axis, y_axis): + # Convert raw 2-D sketch coordinates to a 3-D world App.Vector. + return App.Vector( + origin.x + x * x_axis.x + y * y_axis.x, + origin.y + x * x_axis.y + y * y_axis.y, + origin.z + x * x_axis.z + y * y_axis.z, + ) + + +def _sketch_local_point(point_id, sketch_payload, solve_snapshot): + x, y = _sketch_solved_point(point_id, sketch_payload, solve_snapshot) + return App.Vector(x, y, 0.0) + + +def _sketch_wire_shape_from_promotion(params): + sketch_payload = params.get('sketch') or {} + solve_snapshot = params.get('solve_snapshot') or {} + origin, x_axis, y_axis, z_axis = _sketch_plane_frame(sketch_payload.get('plane', 'XY')) + _entities, by_id = _sketch_entity_maps(sketch_payload) + edge_shapes = [] + for entity_id in _sketch_profile_entity_ids(params, sketch_payload): + entity = by_id.get(str(entity_id)) + if not isinstance(entity, dict): + continue + kind = str(entity.get('kind')) + if kind == 'line': + start = _sketch_world_point(str(entity.get('start')), sketch_payload, solve_snapshot, origin, x_axis, y_axis) + end = _sketch_world_point(str(entity.get('end')), sketch_payload, solve_snapshot, origin, x_axis, y_axis) + edge_shapes.append(Part.LineSegment(start, end).toShape()) + elif kind == 'circle': + center = _sketch_world_point(str(entity.get('center')), sketch_payload, solve_snapshot, origin, x_axis, y_axis) + edge_shapes.append(Part.Circle(center, z_axis, _sketch_solved_radius(str(entity_id), entity, solve_snapshot)).toShape()) + elif kind == 'arc': + start = _sketch_world_point(str(entity.get('start')), sketch_payload, solve_snapshot, origin, x_axis, y_axis) + end = _sketch_world_point(str(entity.get('end')), sketch_payload, solve_snapshot, origin, x_axis, y_axis) + center = _sketch_world_point(str(entity.get('center')), sketch_payload, solve_snapshot, origin, x_axis, y_axis) + import math as _math + radius = _math.hypot(start.x - center.x, start.y - center.y) + edge_shapes.append(Part.ArcOfCircle(Part.Circle(center, z_axis, radius), _math.atan2(start.y - center.y, start.x - center.x), _math.atan2(end.y - center.y, end.x - center.x)).toShape()) + elif kind == 'bspline': + cps_data = entity.get('control_points', []) + degree = int(entity.get('degree', 3)) + knots = entity.get('knots') + mults = entity.get('multiplicities') + weights = entity.get('weights') + periodic = bool(entity.get('periodic', False)) + cps = [_sketch_xy_to_world(float(p[0]), float(p[1]), origin, x_axis, y_axis) for p in cps_data] + curve = Part.BSplineCurve() + if weights: + curve.buildFromPolesMultsKnots(cps, mults, knots, periodic, degree, weights) + else: + curve.buildFromPolesMultsKnots(cps, mults, knots, periodic, degree) + edge_shapes.append(curve.toShape()) + if not edge_shapes: + raise RuntimeError('Sketch promotion has no profile geometry to materialize') + return Part.Wire(edge_shapes) + + +def _sketch_entity_expr(param_exprs, entity_index, *path): + expr_meta = _nested_expr_ref(param_exprs or {}, 'sketch', 'entities', int(entity_index)) + if expr_meta is None: + return None + return _nested_expr_ref(expr_meta, *path) + + +def _sketch_constraint_value_expr(param_exprs, constraint_index): + return _nested_expr_ref(param_exprs or {}, 'sketch', 'constraints', int(constraint_index), 'value') + + +def _sketch_constraint_status_append(status, source, mapped, **payload): + entry = {'id': source.get('id') if isinstance(source, dict) else None, 'kind': source.get('kind') if isinstance(source, dict) else None} + entry.update(payload) + status['mapped' if mapped else 'skipped'].append(entry) + + +def _sketch_constraint_priority(item): + _index, constraint = item + kind = str(constraint.get('kind')) if isinstance(constraint, dict) else '' + priorities = { + 'coincident': 0, + 'connect': 0, + 'point_on': 1, + 'fix': 2, + 'horizontal': 3, + 'vertical': 3, + 'distance': 4, + 'distance_x': 4, + 'distance_y': 4, + 'length': 4, + 'radius': 4, + 'diameter': 4, + 'angle': 4, + 'parallel': 5, + 'perpendicular': 5, + 'collinear': 5, + 'tangent': 5, + 'concentric': 5, + 'equal_length': 5, + 'equal_radius': 5, + 'midpoint': 6, + 'symmetric': 6, + } + return priorities.get(kind, 9), int(_index) + + +def _validate_sketch_constraint(sketch_obj, idx): + try: + result = sketch_obj.solve() + except Exception as exc: + return False, f'FreeCAD Sketcher solver raised {exc!r}' + try: + result_int = int(result) + except Exception: + return True, '' + if result_int < 0: + return False, f'FreeCAD Sketcher solver rejected constraint with result {result_int}' + return True, '' + + +def _remove_sketch_constraint(sketch_obj, idx): + try: + if hasattr(sketch_obj, 'setExpression'): + sketch_obj.setExpression(f'Constraints[{int(idx)}]', None) + except Exception: + pass + try: + sketch_obj.delConstraint(int(idx)) + except Exception: + pass + + +def _safe_add_sketch_constraint(sketch_obj, status, source, freecad_kind, *args, expr_ref=None, synthetic=False): + if Sketcher is None: + _sketch_constraint_status_append(status, source, False, freecad_kind=freecad_kind, reason='Sketcher module is unavailable', synthetic=bool(synthetic)) + return None + try: + idx = sketch_obj.addConstraint(Sketcher.Constraint(freecad_kind, *args)) + except Exception as exc: + _sketch_constraint_status_append(status, source, False, freecad_kind=freecad_kind, reason=str(exc), synthetic=bool(synthetic)) + return None + if expr_ref is not None: + _bind_expression(sketch_obj, f'Constraints[{int(idx)}]', expr_ref) + ok, reason = _validate_sketch_constraint(sketch_obj, idx) + if not ok: + _remove_sketch_constraint(sketch_obj, idx) + _sketch_constraint_status_append(status, source, False, freecad_kind=freecad_kind, reason=reason, synthetic=bool(synthetic)) + return None + serializable_args = [int(arg) if isinstance(arg, int) and not isinstance(arg, bool) else arg for arg in args] + _sketch_constraint_status_append(status, source, True, freecad_kind=freecad_kind, index=int(idx), args=serializable_args, synthetic=bool(synthetic)) + return int(idx) + + +def _target_point_id(target, by_id): + if not isinstance(target, dict): + return None + entity_id = str(target.get('entity_id')) + subentity = str(target.get('subentity', 'geometry')) + entity = by_id.get(entity_id) + if not isinstance(entity, dict): + return None + kind = str(entity.get('kind')) + if kind == 'point': + return entity_id + if kind == 'line' and subentity in {'start', 'end'}: + return str(entity.get(subentity)) + if kind == 'circle' and subentity == 'center': + return str(entity.get('center')) + return None + + +def _target_point_ref(target, by_id, point_refs): + point_id = _target_point_id(target, by_id) + if point_id is None: + return None + refs = point_refs.get(point_id) or [] + return refs[0] if refs else None + + +def _target_entity_ref(target, by_id, geom_by_entity): + if not isinstance(target, dict): + return None + entity_id = str(target.get('entity_id')) + entity = by_id.get(entity_id) + if not isinstance(entity, dict): + return None + geom_index = geom_by_entity.get(entity_id) + if geom_index is None: + return None + return int(geom_index), str(entity.get('kind')) + + +def _fix_point_constraint(sketch_obj, status, source, point_ref, x_value, y_value, x_expr=None, y_expr=None): + if point_ref is None: + _sketch_constraint_status_append(status, source, False, reason='Target point is not represented by safe Sketcher geometry') + return + geom_index, pos = point_ref + _safe_add_sketch_constraint(sketch_obj, status, source, 'DistanceX', int(geom_index), int(pos), float(x_value), expr_ref=x_expr) + _safe_add_sketch_constraint(sketch_obj, status, source, 'DistanceY', int(geom_index), int(pos), float(y_value), expr_ref=y_expr) + + +def _materialize_sketch_constraints(sketch_obj, sketch_payload, params, param_exprs, geom_by_entity, point_refs): + status = {'mapped': [], 'skipped': []} + entities, by_id = _sketch_entity_maps(sketch_payload) + entity_index_by_id = {str(entity.get('id')): idx for idx, entity in enumerate(entities)} + solve_snapshot = params.get('solve_snapshot') or {} + + for point_id, refs in sorted(point_refs.items()): + if len(refs) < 2: + continue + first_geom, first_pos = refs[0] + for geom_index, pos in refs[1:]: + _safe_add_sketch_constraint( + sketch_obj, + status, + {'id': f'point_identity:{point_id}', 'kind': 'coincident'}, + 'Coincident', + int(first_geom), + int(first_pos), + int(geom_index), + int(pos), + synthetic=True, + ) + + constraints = list(sketch_payload.get('constraints') or []) if isinstance(sketch_payload, dict) else [] + for constraint_index, constraint in sorted(enumerate(constraints), key=_sketch_constraint_priority): + if not isinstance(constraint, dict): + continue + kind = str(constraint.get('kind')) + targets = list(constraint.get('targets') or []) + value = constraint.get('value') + value_expr = _sketch_constraint_value_expr(param_exprs, constraint_index) + + if kind in {'coincident', 'connect'} and len(targets) == 2: + a = _target_point_ref(targets[0], by_id, point_refs) + b = _target_point_ref(targets[1], by_id, point_refs) + if a is None or b is None: + _sketch_constraint_status_append(status, constraint, False, reason='Coincident target is not represented by safe Sketcher geometry') + continue + _safe_add_sketch_constraint(sketch_obj, status, constraint, 'Coincident', int(a[0]), int(a[1]), int(b[0]), int(b[1])) + continue + + if kind == 'point_on' and len(targets) == 2: + point_ref = _target_point_ref(targets[0], by_id, point_refs) + entity_ref = _target_entity_ref(targets[1], by_id, geom_by_entity) + if point_ref is None or entity_ref is None: + _sketch_constraint_status_append(status, constraint, False, reason='Point-on target is not represented by safe Sketcher geometry') + continue + _safe_add_sketch_constraint(sketch_obj, status, constraint, 'PointOnObject', int(point_ref[0]), int(point_ref[1]), int(entity_ref[0])) + continue + + if kind in {'horizontal', 'vertical'} and len(targets) == 1: + entity_ref = _target_entity_ref(targets[0], by_id, geom_by_entity) + if entity_ref is None or entity_ref[1] != 'line': + _sketch_constraint_status_append(status, constraint, False, reason=f'{kind} requires a materialized line') + continue + _safe_add_sketch_constraint(sketch_obj, status, constraint, 'Horizontal' if kind == 'horizontal' else 'Vertical', int(entity_ref[0])) + continue + + if kind in {'parallel', 'perpendicular', 'equal_length', 'angle'} and len(targets) == 2: + a = _target_entity_ref(targets[0], by_id, geom_by_entity) + b = _target_entity_ref(targets[1], by_id, geom_by_entity) + if a is None or b is None or a[1] != 'line' or b[1] != 'line': + _sketch_constraint_status_append(status, constraint, False, reason=f'{kind} requires two materialized lines') + continue + if kind == 'parallel': + _safe_add_sketch_constraint(sketch_obj, status, constraint, 'Parallel', int(a[0]), int(b[0])) + elif kind == 'perpendicular': + _safe_add_sketch_constraint(sketch_obj, status, constraint, 'Perpendicular', int(a[0]), int(b[0])) + elif kind == 'equal_length': + _safe_add_sketch_constraint(sketch_obj, status, constraint, 'Equal', int(a[0]), int(b[0])) + else: + _safe_add_sketch_constraint(sketch_obj, status, constraint, 'Angle', int(a[0]), int(b[0]), float(value), expr_ref=value_expr) + continue + + if kind == 'collinear' and len(targets) == 2: + a = _target_entity_ref(targets[0], by_id, geom_by_entity) + b_target = targets[1] + b = _target_entity_ref(b_target, by_id, geom_by_entity) + if a is None or b is None or a[1] != 'line' or b[1] != 'line': + _sketch_constraint_status_append(status, constraint, False, reason='collinear requires two materialized lines') + continue + _safe_add_sketch_constraint(sketch_obj, status, constraint, 'Parallel', int(a[0]), int(b[0])) + b_entity = by_id.get(str(b_target.get('entity_id'))) + if isinstance(b_entity, dict): + for point_id in (str(b_entity.get('start')), str(b_entity.get('end'))): + refs = point_refs.get(point_id) or [] + if refs: + _safe_add_sketch_constraint(sketch_obj, status, constraint, 'PointOnObject', int(refs[0][0]), int(refs[0][1]), int(a[0])) + continue + + if kind in {'equal_radius', 'concentric'} and len(targets) == 2: + a = _target_entity_ref(targets[0], by_id, geom_by_entity) + b = _target_entity_ref(targets[1], by_id, geom_by_entity) + if a is None or b is None or a[1] != 'circle' or b[1] != 'circle': + _sketch_constraint_status_append(status, constraint, False, reason=f'{kind} requires two materialized circles') + continue + if kind == 'equal_radius': + _safe_add_sketch_constraint(sketch_obj, status, constraint, 'Equal', int(a[0]), int(b[0])) + else: + _safe_add_sketch_constraint(sketch_obj, status, constraint, 'Coincident', int(a[0]), 3, int(b[0]), 3) + continue + + if kind == 'tangent' and len(targets) == 2: + a = _target_entity_ref(targets[0], by_id, geom_by_entity) + b = _target_entity_ref(targets[1], by_id, geom_by_entity) + if a is None or b is None or a[1] not in {'line', 'circle'} or b[1] not in {'line', 'circle'}: + _sketch_constraint_status_append(status, constraint, False, reason='tangent requires two materialized line/circle entities') + continue + _safe_add_sketch_constraint(sketch_obj, status, constraint, 'Tangent', int(a[0]), int(b[0])) + continue + + if kind in {'distance', 'distance_x', 'distance_y'} and len(targets) == 2: + a = _target_point_ref(targets[0], by_id, point_refs) + b = _target_point_ref(targets[1], by_id, point_refs) + if a is None or b is None: + _sketch_constraint_status_append(status, constraint, False, reason=f'{kind} target is not represented by safe Sketcher geometry') + continue + fc_kind = {'distance': 'Distance', 'distance_x': 'DistanceX', 'distance_y': 'DistanceY'}[kind] + _safe_add_sketch_constraint(sketch_obj, status, constraint, fc_kind, int(a[0]), int(a[1]), int(b[0]), int(b[1]), float(value), expr_ref=value_expr) + continue + + if kind == 'length' and len(targets) == 1: + entity_ref = _target_entity_ref(targets[0], by_id, geom_by_entity) + if entity_ref is None or entity_ref[1] != 'line': + _sketch_constraint_status_append(status, constraint, False, reason='length requires a materialized line') + continue + _safe_add_sketch_constraint(sketch_obj, status, constraint, 'Distance', int(entity_ref[0]), float(value), expr_ref=value_expr) + continue + + if kind in {'radius', 'diameter'} and len(targets) == 1: + entity_ref = _target_entity_ref(targets[0], by_id, geom_by_entity) + if entity_ref is None or entity_ref[1] != 'circle': + _sketch_constraint_status_append(status, constraint, False, reason=f'{kind} requires a materialized circle') + continue + _safe_add_sketch_constraint(sketch_obj, status, constraint, 'Radius' if kind == 'radius' else 'Diameter', int(entity_ref[0]), float(value), expr_ref=value_expr) + continue + + if kind == 'fix' and len(targets) == 1: + target = targets[0] + entity_id = str(target.get('entity_id')) if isinstance(target, dict) else '' + entity = by_id.get(entity_id) + if not isinstance(entity, dict): + _sketch_constraint_status_append(status, constraint, False, reason='fix target entity is missing') + continue + entity_kind = str(entity.get('kind')) + if entity_kind == 'point': + point_id = entity_id + x_value, y_value = _sketch_solved_point(point_id, sketch_payload, solve_snapshot) + expr_meta = _sketch_entity_expr(param_exprs, entity_index_by_id[point_id]) + _fix_point_constraint(sketch_obj, status, constraint, _target_point_ref(target, by_id, point_refs), x_value, y_value, _nested_expr_ref(expr_meta, 'x'), _nested_expr_ref(expr_meta, 'y')) + elif entity_kind == 'line': + for point_key in ('start', 'end'): + point_id = str(entity.get(point_key)) + x_value, y_value = _sketch_solved_point(point_id, sketch_payload, solve_snapshot) + point_entity_index = entity_index_by_id.get(point_id) + expr_meta = _sketch_entity_expr(param_exprs, point_entity_index) if point_entity_index is not None else None + refs = point_refs.get(point_id) or [] + _fix_point_constraint(sketch_obj, status, constraint, refs[0] if refs else None, x_value, y_value, _nested_expr_ref(expr_meta, 'x'), _nested_expr_ref(expr_meta, 'y')) + elif entity_kind == 'circle': + center_id = str(entity.get('center')) + x_value, y_value = _sketch_solved_point(center_id, sketch_payload, solve_snapshot) + center_entity_index = entity_index_by_id.get(center_id) + expr_meta = _sketch_entity_expr(param_exprs, center_entity_index) if center_entity_index is not None else None + refs = point_refs.get(center_id) or [] + _fix_point_constraint(sketch_obj, status, constraint, refs[0] if refs else None, x_value, y_value, _nested_expr_ref(expr_meta, 'x'), _nested_expr_ref(expr_meta, 'y')) + entity_ref = _target_entity_ref(target, by_id, geom_by_entity) + if entity_ref is not None: + circle_index = entity_index_by_id.get(entity_id) + radius_expr = _nested_expr_ref(_sketch_entity_expr(param_exprs, circle_index) if circle_index is not None else None, 'radius') + _safe_add_sketch_constraint(sketch_obj, status, constraint, 'Radius', int(entity_ref[0]), _sketch_solved_radius(entity_id, entity, solve_snapshot), expr_ref=radius_expr) + else: + _sketch_constraint_status_append(status, constraint, False, reason=f'Cannot fix unsupported sketch entity kind {entity_kind!r}') + continue + + if kind in {'midpoint', 'symmetric'}: + _sketch_constraint_status_append(status, constraint, False, reason=f'{kind} has no crash-safe FreeCAD Sketcher mapping in this translator') + continue + + _sketch_constraint_status_append(status, constraint, False, reason=f'Unsupported sketch constraint kind {kind!r}') + return status + + +def _attach_sketch_promotion_metadata(obj, params, constraint_status): + _ensure_string_property(obj, 'SimpleCADSketch') + _ensure_string_property(obj, 'SimpleCADSketchSolve') + _ensure_string_property(obj, 'SimpleCADSketchPromotion') + _ensure_string_property(obj, 'SimpleCADSketchConstraints') + obj.SimpleCADSketch = json.dumps(params.get('sketch') or {}, ensure_ascii=True, sort_keys=True) + obj.SimpleCADSketchSolve = json.dumps(params.get('solve_snapshot') or {}, ensure_ascii=True, sort_keys=True) + obj.SimpleCADSketchPromotion = json.dumps(params.get('promotion_map') or {}, ensure_ascii=True, sort_keys=True) + obj.SimpleCADSketchConstraints = json.dumps(constraint_status or {}, ensure_ascii=True, sort_keys=True) + + +def _make_sketch_promotion_object(name, *, node_id, op, params, inputs, tags, context, output_count, param_exprs=None, semantic_delta=None, topo_delta=None): + sketch_payload = params.get('sketch') or {} + solve_snapshot = params.get('solve_snapshot') or {} + if Sketcher is None: + obj = doc.addObject('Part::Feature', name) + obj.Shape = _sketch_wire_shape_from_promotion(params) + constraint_status = {'mapped': [], 'skipped': [{'reason': 'Sketcher module is unavailable'}]} + _attach_sketch_promotion_metadata(obj, params, constraint_status) + return _register_graph_object( + obj, + node_id=node_id, + op=op, + params=params, + inputs=inputs, + tags=tags, + context=context, + output_count=output_count, + param_exprs=param_exprs, + semantic_delta=semantic_delta, + topo_delta=topo_delta, + ) + + obj = doc.addObject('Sketcher::SketchObject', name) + origin, x_axis, y_axis, z_axis = _sketch_plane_frame(sketch_payload.get('plane', 'XY')) + obj.Placement = _placement_from_frame(origin, x_axis, y_axis, z_axis) + profile_ids = set(_sketch_profile_entity_ids(params, sketch_payload)) + entities, _by_id = _sketch_entity_maps(sketch_payload) + geom_by_entity = {} + point_refs = {} + + for entity in entities: + entity_id = str(entity.get('id')) + kind = str(entity.get('kind')) + construction = bool(entity.get('construction', False)) or (kind in {'line', 'circle', 'arc', 'bspline'} and entity_id not in profile_ids) + if kind == 'line': + start_id = str(entity.get('start')) + end_id = str(entity.get('end')) + start = _sketch_local_point(start_id, sketch_payload, solve_snapshot) + end = _sketch_local_point(end_id, sketch_payload, solve_snapshot) + geom_index = int(obj.addGeometry(Part.LineSegment(start, end), construction)) + geom_by_entity[entity_id] = geom_index + point_refs.setdefault(start_id, []).append((geom_index, 1)) + point_refs.setdefault(end_id, []).append((geom_index, 2)) + elif kind == 'circle': + center_id = str(entity.get('center')) + center = _sketch_local_point(center_id, sketch_payload, solve_snapshot) + radius = _sketch_solved_radius(entity_id, entity, solve_snapshot) + geom_index = int(obj.addGeometry(Part.Circle(center, App.Vector(0.0, 0.0, 1.0), radius), construction)) + geom_by_entity[entity_id] = geom_index + point_refs.setdefault(center_id, []).append((geom_index, 3)) + elif kind == 'arc': + start_id = str(entity.get('start')) + end_id = str(entity.get('end')) + center_id = str(entity.get('center')) + start = _sketch_local_point(start_id, sketch_payload, solve_snapshot) + end = _sketch_local_point(end_id, sketch_payload, solve_snapshot) + center = _sketch_local_point(center_id, sketch_payload, solve_snapshot) + import math as _math + radius = _math.hypot(start.x - center.x, start.y - center.y) + arc = Part.ArcOfCircle(Part.Circle(center, App.Vector(0.0, 0.0, 1.0), radius), _math.atan2(start.y - center.y, start.x - center.x), _math.atan2(end.y - center.y, end.x - center.x)) + geom_index = int(obj.addGeometry(arc, construction)) + geom_by_entity[entity_id] = geom_index + point_refs.setdefault(start_id, []).append((geom_index, 1)) + point_refs.setdefault(end_id, []).append((geom_index, 2)) + elif kind == 'bspline': + cps_data = entity.get('control_points', []) + degree = int(entity.get('degree', 3)) + knots = entity.get('knots') + mults = entity.get('multiplicities') + weights = entity.get('weights') + periodic = bool(entity.get('periodic', False)) + cps = [App.Vector(float(p[0]), float(p[1]), 0.0) for p in cps_data] + curve = Part.BSplineCurve() + if weights: + curve.buildFromPolesMultsKnots(cps, mults, knots, periodic, degree, weights) + else: + curve.buildFromPolesMultsKnots(cps, mults, knots, periodic, degree) + geom_index = int(obj.addGeometry(curve, construction)) + geom_by_entity[entity_id] = geom_index + start_id = str(entity.get('start')) + end_id = str(entity.get('end')) + point_refs.setdefault(start_id, []).append((geom_index, 1)) + point_refs.setdefault(end_id, []).append((geom_index, 2)) + + if not geom_by_entity: + raise RuntimeError('Sketch promotion contains no materialized line or circle geometry') + constraint_status = _materialize_sketch_constraints(obj, sketch_payload, params, param_exprs or {}, geom_by_entity, point_refs) + try: + obj.solve() + except Exception: + pass + try: + doc.recompute() + except Exception: + pass + _attach_sketch_promotion_metadata(obj, params, constraint_status) + _attach_simplecad_metadata( + obj, + node_id=node_id, + op=op, + params=params, + inputs=inputs, + tags=tags, + context=context, + output_count=output_count, + param_exprs=param_exprs, + semantic_delta=semantic_delta, + topo_delta=topo_delta, + ) + SKETCH_REGISTRY.append({'node_id': node_id, 'op': op, 'object': obj.Name, 'constraint_status': constraint_status}) + registered = _register_graph_object( + obj, + node_id=node_id, + op=op, + params=params, + inputs=inputs, + tags=tags, + context=context, + output_count=output_count, + param_exprs=param_exprs, + semantic_delta=semantic_delta, + topo_delta=topo_delta, + ) + return registered diff --git a/SimpleCADAPI/src/simplecadapi/translator/freecad_translator/script_translator.py b/SimpleCADAPI/src/simplecadapi/translator/freecad_translator/script_translator.py index e27b78d..a6b5696 100644 --- a/SimpleCADAPI/src/simplecadapi/translator/freecad_translator/script_translator.py +++ b/SimpleCADAPI/src/simplecadapi/translator/freecad_translator/script_translator.py @@ -1,5080 +1,5 @@ -"""Translate SimpleCAD model/graph payloads into FreeCAD Python API scripts. - -This module intentionally targets FreeCAD's Python API, not raw `.FCStd` -internals. Generated scripts can be executed inside FreeCAD/FreeCADCmd and then -saved as `.FCStd` by FreeCAD itself. -""" - -from __future__ import annotations - -import json -import pprint -from typing import Any, Dict, List, Optional, Sequence, Set, Tuple +"""Compatibility imports for the historical FreeCAD script translator module.""" -from ...serializer import import_model_json -from ...topology import OperationGraph, OperationNode - - -def _json_ascii(value: Any) -> str: - return json.dumps(value, ensure_ascii=True, sort_keys=True) - - -def _py_literal(value: Any) -> str: - return pprint.pformat(value, compact=True, sort_dicts=True, width=120) - - -def _safe_name(raw: str, *, prefix: str = "obj") -> str: - token = "".join(ch if ch.isalnum() else "_" for ch in raw) - token = token.strip("_") - if not token: - token = prefix - if token[0].isdigit(): - token = f"{prefix}_{token}" - return token - - -_OP_EXPRESSION_BINDINGS: Dict[str, Tuple[Tuple[str, Tuple[Any, ...]], ...]] = { - "make_line_redge": (), - "make_circle_redge": (), - "make_angle_arc_redge": (), - "make_three_point_arc_redge": (), - "make_spline_redge": (), - "make_wire_from_edges_rwire": (), - "make_helix_redge": ( - ("Pitch", ("pitch",)), - ("Height", ("height",)), - ("Radius", ("radius",)), - ("Placement.Base.x", ("center", 0)), - ("Placement.Base.y", ("center", 1)), - ("Placement.Base.z", ("center", 2)), - ), - "make_face_from_wire_rface": (), - "make_face_from_wires_rface": (), - "make_box_rsolid": ( - ("Length", ("width",)), - ("Width", ("height",)), - ("Height", ("depth",)), - ), - "make_cylinder_rsolid": ( - ("Radius", ("radius",)), - ("Height", ("height",)), - ("Placement.Base.x", ("bottom_face_center", 0)), - ("Placement.Base.y", ("bottom_face_center", 1)), - ("Placement.Base.z", ("bottom_face_center", 2)), - ), - "make_cone_rsolid": ( - ("Radius1", ("bottom_radius",)), - ("Radius2", ("top_radius",)), - ("Height", ("height",)), - ("Placement.Base.x", ("bottom_face_center", 0)), - ("Placement.Base.y", ("bottom_face_center", 1)), - ("Placement.Base.z", ("bottom_face_center", 2)), - ), - "make_sphere_rsolid": ( - ("Radius", ("radius",)), - ("Placement.Base.x", ("center", 0)), - ("Placement.Base.y", ("center", 1)), - ("Placement.Base.z", ("center", 2)), - ), - "make_extrude_rsolid": ( - ("LengthFwd", ("distance",)), - ("Dir.x", ("direction", 0)), - ("Dir.y", ("direction", 1)), - ("Dir.z", ("direction", 2)), - ), - "make_revolve_rsolid": ( - ("Angle", ("angle",)), - ("Axis.x", ("axis", 0)), - ("Axis.y", ("axis", 1)), - ("Axis.z", ("axis", 2)), - ("Base.x", ("origin", 0)), - ("Base.y", ("origin", 1)), - ("Base.z", ("origin", 2)), - ), - "make_loft_rsolid": (("Ruled", ("ruled",)),), - "make_sweep_rsolid": (("Frenet", ("is_frenet",)),), - "make_cut_rsolid": (), - "make_union_rsolid": (), - "make_intersect_rsolid": (), - "make_fillet_rsolid": (), - "make_chamfer_rsolid": (), - "make_shell_rsolid": (("Value", ("thickness",)),), - "make_mirror_rshape": ( - ("Base.x", ("plane_origin", 0)), - ("Base.y", ("plane_origin", 1)), - ("Base.z", ("plane_origin", 2)), - ("Normal.x", ("plane_normal", 0)), - ("Normal.y", ("plane_normal", 1)), - ("Normal.z", ("plane_normal", 2)), - ), - "make_translate_rshape": ( - ("Placement.Base.x", ("vector", 0)), - ("Placement.Base.y", ("vector", 1)), - ("Placement.Base.z", ("vector", 2)), - ), - "make_rotate_rshape": ( - ("Placement.Base.x", ("origin", 0)), - ("Placement.Base.y", ("origin", 1)), - ("Placement.Base.z", ("origin", 2)), - ("Placement.Rotation.Axis.x", ("axis", 0)), - ("Placement.Rotation.Axis.y", ("axis", 1)), - ("Placement.Rotation.Axis.z", ("axis", 2)), - ("Placement.Rotation.Angle", ("angle",)), - ), -} - - -_OP_EXPRESSION_LIMITATIONS: Dict[str, str] = { - "make_spline_redge": ( - "Exact B-spline pole/weight expressions have no stable equivalent native " - "FreeCAD Sketcher BSpline parameter host. The translator exports exact " - "B-spline geometry, but does not map make_spline_redge param_exprs into " - "FreeCAD ExpressionEngine." - ), -} - - -def _contains_expr_refs(value: Any) -> bool: - if isinstance(value, dict): - if isinstance(value.get("expr_id"), str) and value["expr_id"]: - return True - return any(_contains_expr_refs(v) for v in value.values()) - if isinstance(value, (list, tuple)): - return any(_contains_expr_refs(v) for v in value) - return False - - -def _expression_limitation_payload( - op: str, param_exprs: Any -) -> Optional[Dict[str, str]]: - if not _contains_expr_refs(param_exprs or {}): - return None - reason = _OP_EXPRESSION_LIMITATIONS.get(str(op)) - if not reason: - return None - return {"op": str(op), "reason": str(reason)} - - -def _node_expression_limitation( - node: Optional[OperationNode], -) -> Optional[Dict[str, str]]: - if node is None: - return None - payload = _expression_limitation_payload(str(node.op), dict(node.param_exprs)) - if payload is None: - return None - return {"node_id": str(node.node_id), **payload} - - -def _sanitize_expr_alias(alias: str, *, prefix: str = "expr") -> str: - token = "".join(ch if str(ch).isalnum() else "_" for ch in str(alias)).strip("_") - if not token: - token = prefix - if token[0].isdigit(): - token = f"{prefix}_{token}" - return token[:64] - - -def _expr_short_suffix(expr_id: str) -> str: - raw = str(expr_id).rsplit("_", 1)[-1] - token = "".join(ch if ch.isalnum() else "_" for ch in raw).strip("_") - return token[:8] if token else "id" - - -def _const_value_alias_token(value: Any) -> str: - try: - number = float(value) - except Exception: - return "value" - text = f"{number:.6g}".replace("-", "neg_").replace(".", "_") - token = "".join(ch if ch.isalnum() else "_" for ch in text).strip("_") - return token or "value" - - -def _spreadsheet_expr_alias(expr_node: Dict[str, Any], row: int) -> str: - expr_id = str(expr_node.get("expr_id", f"expr_{row}")) - kind = str(expr_node.get("kind", "expr")) - if kind == "var": - name = str(expr_node.get("name", "")).strip() - if name: - return _sanitize_expr_alias(f"var_{name}", prefix="var") - if kind == "const": - return _sanitize_expr_alias( - f"const_{_const_value_alias_token(expr_node.get('value'))}_{_expr_short_suffix(expr_id)}", - prefix="const", - ) - op = str(expr_node.get("op", "expr")).strip() or "expr" - return _sanitize_expr_alias( - f"expr_{op}_{_expr_short_suffix(expr_id)}", prefix="expr" - ) - - -def _coincident_constraint_pairs( - input_nodes: Sequence[Optional[OperationNode]], -) -> List[Tuple[int, int, int, int]]: - pairs: List[Tuple[int, int, int, int]] = [] - if len(input_nodes) < 2: - return pairs - for idx in range(len(input_nodes) - 1): - left = input_nodes[idx] - right = input_nodes[idx + 1] - if left is None or right is None: - continue - if left.op == "make_circle_redge" or right.op == "make_circle_redge": - continue - pairs.append((idx, 2, idx + 1, 1)) - first = input_nodes[0] - last = input_nodes[-1] - if ( - first is not None - and last is not None - and first.op != "make_circle_redge" - and last.op != "make_circle_redge" - ): - try: - first_start = first.params.get("start") - last_end = last.params.get("end") - if isinstance(first_start, (list, tuple)) and isinstance( - last_end, (list, tuple) - ): - if all( - abs(float(a) - float(b)) <= 1e-7 - for a, b in zip(first_start, last_end) - ): - pairs.append((len(input_nodes) - 1, 2, 0, 1)) - except Exception: - pass - return pairs - - -def _compile_time_nested_expr_ref(expr_meta: Any, *path: Any) -> Any: - value = expr_meta - for key in path: - if isinstance(value, dict) and key in value: - value = value[key] - elif isinstance(value, list) and isinstance(key, int) and 0 <= key < len(value): - value = value[key] - else: - return None - return value - - -class FreeCADScriptTranslator: - """Compile a SimpleCAD model payload into a FreeCAD Python script. - - Current design goals: - - - Translate only from the canonical low-level `graph` IR - - Preserve node metadata and graph lineage as FreeCAD custom properties - - Preserve `expression_graph` as explicit translator metadata - - Preserve exported assembly constraints as document metadata objects - - Keep assembly metadata from the full model payload alongside the IR-driven - geometry translation - - The generated script focuses on `Part`-workbench-style objects and shape - construction, which is a better first target for the current canonical graph - than a full `Sketcher/PartDesign` mapping. - """ - - def __init__(self, document_name: str = "SimpleCADModel") -> None: - self.document_name = document_name - self._source_graph: Optional[OperationGraph] = None - self._expr_alias_by_id: Dict[str, str] = {} - self._result_node_ids: Set[str] = set() - self._result_node_id_list: List[str] = [] - self._suppressed_profile_node_ids: Set[str] = set() - - def _compile_time_expr_formula(self, expr_ref: Any) -> Optional[str]: - if not isinstance(expr_ref, dict): - return None - expr_id = str(expr_ref.get("expr_id") or "") - if not expr_id: - return None - alias = self._expr_alias_by_id.get(expr_id) - if not alias: - alias = _sanitize_expr_alias(expr_id, prefix="expr") - return f"<>.{alias}" - - def _angle_arc_span_formula(self, param_exprs: Dict[str, Any]) -> Optional[str]: - start_expr = self._compile_time_expr_formula( - _compile_time_nested_expr_ref(param_exprs, "start_angle") - ) - end_expr = self._compile_time_expr_formula( - _compile_time_nested_expr_ref(param_exprs, "end_angle") - ) - if start_expr is None and end_expr is None: - return None - if start_expr is None: - return end_expr - if end_expr is None: - return f"0 - ({start_expr})" - return f"({end_expr}) - ({start_expr})" - - def _line_delta_formula( - self, param_exprs: Dict[str, Any], axis: int - ) -> Optional[str]: - start_expr = self._compile_time_expr_formula( - _compile_time_nested_expr_ref(param_exprs, "start", axis) - ) - end_expr = self._compile_time_expr_formula( - _compile_time_nested_expr_ref(param_exprs, "end", axis) - ) - if start_expr is None and end_expr is None: - return None - if start_expr is None: - return end_expr - if end_expr is None: - return f"0 - ({start_expr})" - return f"({end_expr}) - ({start_expr})" - - def translate_model_json_to_script(self, json_str: str) -> str: - payload = import_model_json(json_str) - graph = payload.get("graph") - if not isinstance(graph, OperationGraph): - raise ValueError( - "FreeCAD translation requires model JSON with a canonical low-level graph" - ) - if graph.node_count == 0: - raise ValueError( - "FreeCAD translation requires model JSON with a non-empty canonical low-level graph" - ) - return self.translate_model_payload_to_script(payload, graph=graph) - - def translate_model_payload_to_script( - self, - payload: Dict[str, Any], - *, - graph: Optional[OperationGraph] = None, - ) -> str: - source_graph = graph or payload.get("graph") - if not isinstance(source_graph, OperationGraph): - raise ValueError( - "FreeCAD translation requires payload to contain a canonical low-level graph" - ) - if source_graph.node_count == 0: - raise ValueError( - "FreeCAD translation requires payload to contain a non-empty canonical low-level graph" - ) - self._source_graph = source_graph - leaf_ids = payload.get("leaf_ids") - if isinstance(leaf_ids, list) and leaf_ids: - self._result_node_id_list = [str(v) for v in leaf_ids] - else: - self._result_node_id_list = [leaf.node_id for leaf in source_graph.leaf_nodes()] - self._result_node_ids = set(self._result_node_id_list) - self._suppressed_profile_node_ids = self._find_cylinder_profile_nodes(source_graph) - - lines: List[str] = [] - emit = lines.append - - emit("import json") - emit("import math") - emit("import FreeCAD as App") - emit("import Part") - emit("try:") - emit(" import Sketcher") - emit("except Exception:") - emit(" Sketcher = None") - emit("try:") - emit(" import Assembly") - emit("except Exception:") - emit(" Assembly = None") - emit("try:") - emit(" import JointObject") - emit("except Exception:") - emit(" JointObject = None") - emit("try:") - emit(" import Spreadsheet") - emit("except Exception:") - emit(" Spreadsheet = None") - emit("import os") - emit("import zipfile") - emit("") - emit(f"DOC_NAME = {_json_ascii(self.document_name)}") - emit( - "doc = App.getDocument(DOC_NAME) if DOC_NAME in App.listDocuments() else App.newDocument(DOC_NAME)" - ) - emit("GRAPH_NODES = {}") - emit("GRAPH_OUTPUTS = {}") - emit("GRAPH_METADATA = {}") - emit("GRAPH_SELECTIONS = {}") - emit("GRAPH_SPINE_OBJECTS = {}") - emit("GRAPH_LIMITATIONS = {}") - emit("PRODUCT_VALUES = {}") - emit("ASSEMBLY_PROJECTION_INPUTS = {}") - emit("GUI_VISIBILITY_BY_NAME = {}") - emit("GUI_EXPANDED_BY_NAME = {}") - emit("GUI_SHAPE_COLOR_BY_NAME = {}") - emit("GUI_MATERIAL_OVERRIDE_BY_NAME = {}") - emit("MATERIAL_OBJECTS_BY_ID = {}") - emit("SIMPLECAD_JOINT_OBJECTS = {}") - emit("SKETCH_REGISTRY = []") - expression_graph_payload = payload.get("expression_graph", {}) - if hasattr(expression_graph_payload, "to_dict"): - expression_graph_payload = expression_graph_payload.to_dict() - self._expr_alias_by_id = {} - nodes = ( - expression_graph_payload.get("nodes", []) - if isinstance(expression_graph_payload, dict) - else [] - ) - if isinstance(nodes, list): - row = 1 - for node in nodes: - if isinstance(node, dict): - expr_id = str(node.get("expr_id", f"expr_{row}")) - self._expr_alias_by_id[expr_id] = _spreadsheet_expr_alias(node, row) - row += 1 - emit(f"EXPRESSION_GRAPH = {_py_literal(expression_graph_payload)}") - emit(f"OP_EXPRESSION_BINDINGS = {_py_literal(_OP_EXPRESSION_BINDINGS)}") - emit(f"OP_EXPRESSION_LIMITATIONS = {_py_literal(_OP_EXPRESSION_LIMITATIONS)}") - emit("") - emit(self._script_helpers()) - emit("") - - for line in self._emit_expression_graph(expression_graph_payload): - emit(line) - emit("") - - emit("EXPRESSION_GRAPH_META = EXPRESSION_GRAPH") - emit("") - - for node in source_graph.topological_order(): - emit(f"# Step {node.node_id}: {node.op}") - for line in self._emit_node(node): - emit(line) - emit("") - - emit("if GRAPH_LIMITATIONS:") - emit( - " _make_metadata_note('simplecad_expression_limitations', 'SimpleCAD Expression Limitations', GRAPH_LIMITATIONS)" - ) - emit("") - - emit("doc.recompute()") - emit("") - emit("# Leaf/result metadata") - emit(f"RESULT_NODE_IDS = {_py_literal(self._result_node_id_list)}") - emit( - "RESULT_OBJECTS = [obj for node_id in RESULT_NODE_IDS for obj in GRAPH_OUTPUTS.get(node_id, [])]" - ) - emit("_apply_result_visibility(RESULT_NODE_IDS)") - emit("_set_active_result_object(RESULT_NODE_IDS)") - emit("doc.TransientDir = getattr(doc, 'TransientDir', '')") - return "\n".join(lines).rstrip() + "\n" - - def _find_cylinder_profile_nodes(self, graph: OperationGraph) -> Set[str]: - use_counts: Dict[str, int] = {} - for graph_node in graph.topological_order(): - for input_ref in graph_node.inputs: - use_counts[input_ref.node_id] = use_counts.get(input_ref.node_id, 0) + 1 - - suppressed: Set[str] = set() - for graph_node in graph.topological_order(): - if graph_node.op != "make_extrude_rsolid" or len(graph_node.inputs) != 1: - continue - profile_node = graph.get_node(graph_node.inputs[0].node_id) - face_node = None - if ( - profile_node is not None - and profile_node.op == "make_face_from_wire_rface" - and len(profile_node.inputs) == 1 - ): - face_node = profile_node - profile_node = graph.get_node(profile_node.inputs[0].node_id) - if ( - profile_node is None - or profile_node.op != "make_wire_from_edges_rwire" - or len(profile_node.inputs) != 1 - ): - continue - edge_node = graph.get_node(profile_node.inputs[0].node_id) - if edge_node is None or edge_node.op != "make_circle_redge": - continue - profile_ids = [profile_node.node_id] - if face_node is not None: - profile_ids.append(face_node.node_id) - if any(node_id in self._result_node_ids for node_id in profile_ids): - continue - if any(use_counts.get(node_id, 0) != 1 for node_id in profile_ids): - continue - suppressed.update(profile_ids) - return suppressed - - def _emit_expression_graph(self, expression_graph_payload: Any) -> List[str]: - if not isinstance(expression_graph_payload, dict): - return [] - nodes = expression_graph_payload.get("nodes", []) - if not isinstance(nodes, list) or not nodes: - return [] - - lines: List[str] = ["# Expression graph -> Spreadsheet"] - lines.append("EXPR_CELL_BY_ID = {}") - lines.append("EXPR_ALIAS_BY_ID = {}") - lines.append("if Spreadsheet is not None:") - lines.append( - " expr_sheet = doc.addObject('Spreadsheet::Sheet', 'SimpleCADExpressions')" - ) - alias_by_id: Dict[str, str] = {} - row = 1 - for node in nodes: - if not isinstance(node, dict): - continue - expr_id = str(node.get("expr_id", f"expr_{row}")) - alias_by_id[expr_id] = _spreadsheet_expr_alias(node, row) - row += 1 - row = 1 - for node in nodes: - if not isinstance(node, dict): - continue - expr_id = str(node.get("expr_id", f"expr_{row}")) - alias = alias_by_id[expr_id] - cell = f"B{row}" - lines.append( - f" EXPR_CELL_BY_ID[{_json_ascii(expr_id)}] = {_json_ascii(cell)}" - ) - lines.append( - f" EXPR_ALIAS_BY_ID[{_json_ascii(expr_id)}] = {_json_ascii(alias)}" - ) - lines.append(f" expr_sheet.set('A{row}', {_json_ascii(alias)})") - lines.append(f" expr_sheet.set('C{row}', {_json_ascii(expr_id)})") - comment = ( - str(node.get("comment", "") or "") - if str(node.get("kind", "")) == "var" - else "" - ) - lines.append(f" expr_sheet.set('D{row}', {_json_ascii(comment)})") - formula = self._freecad_expr_formula(node, alias_by_id) - if formula is None: - lines.append( - f" expr_sheet.set({_json_ascii(cell)}, {_json_ascii('')})" - ) - else: - lines.append( - f" expr_sheet.set({_json_ascii(cell)}, {_json_ascii(formula)})" - ) - lines.append( - f" expr_sheet.setAlias({_json_ascii(cell)}, {_json_ascii(alias)})" - ) - row += 1 - lines.append("else:") - lines.append(" expr_sheet = None") - return lines - - def _freecad_expr_formula( - self, node: Dict[str, Any], alias_by_id: Dict[str, str] - ) -> Optional[str]: - kind = str(node.get("kind", "")) - if kind == "const": - return str(float(node.get("value", 0.0))) - if kind == "var": - return str(float(node.get("default", 0.0))) - if kind != "expr": - return None - - op = str(node.get("op", "")) - args: List[str] = [] - for arg in node.get("args", []): - alias = alias_by_id.get(str(arg)) - if not alias: - return None - args.append(f"<>.{alias}") - if op == "add" and len(args) == 2: - return f"={args[0]} + {args[1]}" - if op == "sub" and len(args) == 2: - return f"={args[0]} - {args[1]}" - if op == "mul" and len(args) == 2: - return f"={args[0]} * {args[1]}" - if op == "div" and len(args) == 2: - return f"={args[0]} / {args[1]}" - if op == "pow" and len(args) == 2: - return f"=pow({args[0]}, {args[1]})" - if op == "neg" and len(args) == 1: - return f"=-({args[0]})" - if op == "abs" and len(args) == 1: - return f"=abs({args[0]})" - if op == "sin" and len(args) == 1: - return f"=sin(({args[0]}) * 180 / pi)" - if op == "cos" and len(args) == 1: - return f"=cos(({args[0]}) * 180 / pi)" - if op == "tan" and len(args) == 1: - return f"=tan(({args[0]}) * 180 / pi)" - if op == "sqrt" and len(args) == 1: - return f"=sqrt({args[0]})" - if op == "acos" and len(args) == 1: - return f"=acos({args[0]}) * pi / 180" - if op == "asin" and len(args) == 1: - return f"=asin({args[0]}) * pi / 180" - if op == "atan" and len(args) == 1: - return f"=atan({args[0]}) * pi / 180" - if op == "atan2" and len(args) == 2: - return f"=atan2({args[0]}; {args[1]}) * pi / 180" - return None - - def _can_fold_transform_into_input(self, node: OperationNode) -> bool: - graph = self._source_graph - if graph is None or node.op not in {"make_translate_rshape", "make_rotate_rshape"} or len(node.inputs) != 1: - return False - source = node.inputs[0] - if source.op not in { - "make_box_rsolid", - "make_cone_rsolid", - "make_cylinder_rsolid", - "make_extrude_rsolid", - "make_sphere_rsolid", - "make_wire_from_edges_rwire", - "make_face_from_wire_rface", - "make_wire_from_sketch_rwire", - "make_face_from_sketch_rface", - "make_translate_rshape", - "make_rotate_rshape", - }: - return False - if source.node_id in self._result_node_ids: - return False - if graph.downstream_nodes(source.node_id) != [node.node_id]: - return False - return True - - def _should_materialize_transform_for_loft_section(self, node: OperationNode) -> bool: - graph = self._source_graph - if graph is None or node.op not in {"make_translate_rshape", "make_rotate_rshape"} or len(node.inputs) != 1: - return False - if not self._transform_feeds_only_loft(node.node_id, set()): - return False - source = node.inputs[0] - return source.op in { - "make_wire_from_edges_rwire", - "make_face_from_wire_rface", - "make_wire_from_sketch_rwire", - "make_face_from_sketch_rface", - "make_translate_rshape", - "make_rotate_rshape", - } - - def _transform_feeds_only_loft(self, node_id: str, seen: Set[str]) -> bool: - graph = self._source_graph - if graph is None or node_id in seen: - return False - seen.add(node_id) - downstream = graph.downstream_nodes(node_id) - if not downstream: - return False - for downstream_id in downstream: - downstream_node = graph.get_node(downstream_id) - if downstream_node is None: - return False - if downstream_node.op == "make_loft_rsolid": - continue - if downstream_node.op in {"make_translate_rshape", "make_rotate_rshape"}: - if self._transform_feeds_only_loft(downstream_id, seen): - continue - return False - return True - - def _script_helpers(self) -> str: - return """ -class SimpleCADUnsupportedOpError(RuntimeError): - pass - - -def _ensure_string_property(obj, prop_name, group='SimpleCAD'): - if prop_name not in list(getattr(obj, 'PropertiesList', []) or []): - obj.addProperty('App::PropertyString', prop_name, group) - - -def _ensure_string_list_property(obj, prop_name, group='SimpleCAD'): - if prop_name not in list(getattr(obj, 'PropertiesList', []) or []): - obj.addProperty('App::PropertyStringList', prop_name, group) - - -def _ensure_string_map_property(obj, prop_name, group='SimpleCAD'): - if prop_name not in list(getattr(obj, 'PropertiesList', []) or []): - obj.addProperty('App::PropertyMap', prop_name, group) +from .translator import FreeCADScriptTranslator, FreeCADTranslator - -def _ensure_float_property(obj, prop_name, group='SimpleCAD'): - if prop_name not in list(getattr(obj, 'PropertiesList', []) or []): - obj.addProperty('App::PropertyFloat', prop_name, group) - - -def _ensure_color_property(obj, prop_name, group='SimpleCAD'): - if prop_name not in list(getattr(obj, 'PropertiesList', []) or []): - obj.addProperty('App::PropertyColor', prop_name, group) - - -def _ensure_link_property(obj, prop_name, group='SimpleCAD'): - if prop_name not in list(getattr(obj, 'PropertiesList', []) or []): - obj.addProperty('App::PropertyLink', prop_name, group) - - -def _ensure_placement_property(obj, prop_name='Placement', group='SimpleCAD'): - if prop_name not in list(getattr(obj, 'PropertiesList', []) or []): - obj.addProperty('App::PropertyPlacement', prop_name, group) - - -def _contains_expr_refs(value): - if isinstance(value, dict): - if isinstance(value.get('expr_id'), str) and value['expr_id']: - return True - return any(_contains_expr_refs(v) for v in value.values()) - if isinstance(value, (list, tuple)): - return any(_contains_expr_refs(v) for v in value) - return False - - -def _expression_limitation_payload(op, param_exprs): - if not _contains_expr_refs(param_exprs or {}): - return None - reason = OP_EXPRESSION_LIMITATIONS.get(str(op)) - if not reason: - return None - return {'op': str(op), 'reason': str(reason)} - - -def _record_graph_limitation(node_id, op, param_exprs): - limitation = _expression_limitation_payload(op, param_exprs) - if limitation: - GRAPH_LIMITATIONS[str(node_id)] = limitation - return limitation - - -def _simplecad_slug(value, prefix='obj'): - token = ''.join(ch if str(ch).isalnum() else '_' for ch in str(value or '')) - token = token.strip('_') - if not token: - token = str(prefix) - if token[0].isdigit(): - token = str(prefix) + '_' + token - return token[:120] - - -def _semantic_label_token(tags): - normalized = sorted(str(tag) for tag in (tags or [])) - for prefix in ('role.', 'anchor.', 'group.'): - for tag in normalized: - if tag.startswith(prefix) and len(tag) > len(prefix): - return tag[len(prefix):].replace('.', '_') - for tag in normalized: - if tag not in {'primitive', 'derived', 'solid', 'face', 'wire', 'edge', 'vertex'}: - return tag.replace('.', '_') - return '' - - -def _op_label_token(op): - token = str(op or '') - if token.startswith('make_'): - token = token[len('make_'):] - for suffix in ('_rsolid', '_rface', '_rwire', '_redge', '_rvertex', '_rassembly', '_rpart'): - if token.endswith(suffix): - token = token[:-len(suffix)] - break - return token or str(op or 'node') - - -def _simplecad_display_name(*, node_id, op, params, tags, semantic_delta): - params = params or {} - for key in ('name', 'component_id', 'part_id', 'assembly_id', 'constraint_id', 'connector_id', 'material_id'): - value = params.get(key) - if value: - return f"{value} ({_op_label_token(op)})" - tag_token = _semantic_label_token(tags) - if tag_token: - return f"{tag_token} ({_op_label_token(op)})" - created = (semantic_delta or {}).get('created', []) if isinstance(semantic_delta, dict) else [] - if created: - first = created[0] - if isinstance(first, dict): - entity_type = first.get('entity_type') - entity_id = first.get('entity_id') - if entity_type and entity_id: - return f"{entity_type} {entity_id}" - return f"{_op_label_token(op)} {node_id}" - - -def _attach_simplecad_metadata(obj, *, node_id, op, params, inputs, tags, context, output_count, param_exprs=None, semantic_delta=None, topo_delta=None): - _ensure_string_property(obj, 'SimpleCADNodeId') - _ensure_string_property(obj, 'SimpleCADOp') - _ensure_string_property(obj, 'SimpleCADDisplayName') - _ensure_string_property(obj, 'SimpleCADParams') - _ensure_string_property(obj, 'SimpleCADInputs') - _ensure_string_property(obj, 'SimpleCADContext') - _ensure_string_property(obj, 'SimpleCADParamExprs') - _ensure_string_property(obj, 'SimpleCADSemanticDelta') - _ensure_string_property(obj, 'SimpleCADTopoDelta') - _ensure_string_property(obj, 'SimpleCADOutputCount') - _ensure_string_property(obj, 'SimpleCADExprSupport') - _ensure_string_property(obj, 'SimpleCADExprLimitation') - _ensure_string_list_property(obj, 'SimpleCADTags') - obj.SimpleCADNodeId = str(node_id) - obj.SimpleCADOp = str(op) - display_name = _simplecad_display_name(node_id=node_id, op=op, params=params, tags=tags, semantic_delta=semantic_delta) - obj.SimpleCADDisplayName = str(display_name) - try: - obj.Label = str(display_name) - except Exception: - pass - obj.SimpleCADParams = json.dumps(params, ensure_ascii=True, sort_keys=True) - obj.SimpleCADInputs = json.dumps(inputs, ensure_ascii=True, sort_keys=True) - obj.SimpleCADContext = json.dumps(context or {}, ensure_ascii=True, sort_keys=True) - obj.SimpleCADParamExprs = json.dumps(param_exprs or {}, ensure_ascii=True, sort_keys=True) - obj.SimpleCADSemanticDelta = json.dumps(semantic_delta or {}, ensure_ascii=True, sort_keys=True) - obj.SimpleCADTopoDelta = json.dumps(topo_delta or {}, ensure_ascii=True, sort_keys=True) - obj.SimpleCADOutputCount = str(int(output_count)) - limitation = _expression_limitation_payload(op, param_exprs) - obj.SimpleCADExprSupport = 'limited' if limitation else 'mapped_or_not_requested' - obj.SimpleCADExprLimitation = limitation['reason'] if limitation else '' - obj.SimpleCADTags = [str(tag) for tag in (tags or [])] - - -def _append_folded_op_metadata(obj, *, node_id, op, params, inputs, tags, context, output_count, param_exprs=None, semantic_delta=None, topo_delta=None): - _ensure_string_property(obj, 'SimpleCADFoldedOps') - try: - folded = json.loads(obj.SimpleCADFoldedOps) if obj.SimpleCADFoldedOps else [] - except Exception: - folded = [] - if not isinstance(folded, list): - folded = [] - folded.append({ - 'node_id': str(node_id), - 'op': str(op), - 'params': params or {}, - 'inputs': list(inputs or []), - 'tags': list(tags or []), - 'context': context or {}, - 'output_count': int(output_count), - 'param_exprs': param_exprs or {}, - 'semantic_delta': semantic_delta or {}, - 'topo_delta': topo_delta or {}, - }) - obj.SimpleCADFoldedOps = json.dumps(folded, ensure_ascii=True, sort_keys=True) - existing_tags = list(getattr(obj, 'SimpleCADTags', []) or []) - merged_tags = sorted({str(tag) for tag in existing_tags + list(tags or [])}) - try: - obj.SimpleCADTags = merged_tags - except Exception: - pass - - -def _record_graph_output(node_id, obj): - GRAPH_OUTPUTS.setdefault(node_id, []).append(obj) - - -def _register_graph_object(obj, *, node_id, op, params, inputs, tags, context, output_count, param_exprs=None, semantic_delta=None, topo_delta=None): - _attach_simplecad_metadata( - obj, - node_id=node_id, - op=op, - params=params, - inputs=inputs, - tags=tags, - context=context, - output_count=output_count, - param_exprs=param_exprs, - semantic_delta=semantic_delta, - topo_delta=topo_delta, - ) - GRAPH_NODES[node_id] = obj - GRAPH_METADATA[node_id] = { - 'op': op, - 'params': params, - 'inputs': list(inputs), - 'context': context or {}, - 'tags': list(tags or []), - } - _record_graph_limitation(node_id, op, param_exprs) - _record_graph_output(node_id, obj) - return obj - - -def _register_graph_metadata_only(*, node_id, op, params, inputs, tags, context, output_count, param_exprs=None, semantic_delta=None, topo_delta=None): - GRAPH_NODES[node_id] = { - 'node_id': node_id, - 'op': op, - 'params': params, - 'inputs': list(inputs), - 'context': context or {}, - 'tags': list(tags or []), - 'output_count': int(output_count), - 'param_exprs': param_exprs or {}, - 'semantic_delta': semantic_delta or {}, - 'topo_delta': topo_delta or {}, - } - GRAPH_METADATA[node_id] = { - 'op': op, - 'params': params, - 'inputs': list(inputs), - 'context': context or {}, - 'tags': list(tags or []), - } - _record_graph_limitation(node_id, op, param_exprs) - GRAPH_OUTPUTS.setdefault(node_id, []) - return GRAPH_NODES[node_id] - - -def _register_graph_alias(*, node_id, source_node_id, op, params, inputs, tags, context, output_count, param_exprs=None, semantic_delta=None, topo_delta=None): - source_obj = _node_object(source_node_id) - GRAPH_NODES[node_id] = source_obj - GRAPH_METADATA[node_id] = { - 'op': op, - 'params': params, - 'inputs': list(inputs), - 'context': context or {}, - 'tags': list(tags or []), - } - GRAPH_OUTPUTS[node_id] = list(GRAPH_OUTPUTS.get(source_node_id, [])) - return source_obj - - -def _register_graph_folded_alias(*, node_id, source_node_id, op, params, inputs, tags, context, output_count, param_exprs=None, semantic_delta=None, topo_delta=None): - source_obj = _node_object(source_node_id) - _append_folded_op_metadata( - source_obj, - node_id=node_id, - op=op, - params=params, - inputs=inputs, - tags=tags, - context=context, - output_count=output_count, - param_exprs=param_exprs, - semantic_delta=semantic_delta, - topo_delta=topo_delta, - ) - GRAPH_NODES[node_id] = source_obj - GRAPH_METADATA[node_id] = { - 'op': op, - 'params': params, - 'inputs': list(inputs), - 'context': context or {}, - 'tags': list(tags or []), - 'folded_into': str(source_node_id), - } - _record_graph_limitation(node_id, op, param_exprs) - GRAPH_OUTPUTS[node_id] = [source_obj] - return source_obj - - -def _register_graph_value(value, *, node_id, op, params, inputs, tags, context, output_count, param_exprs=None, semantic_delta=None, topo_delta=None): - GRAPH_NODES[node_id] = value - GRAPH_METADATA[node_id] = { - 'op': op, - 'params': params, - 'inputs': list(inputs), - 'context': context or {}, - 'tags': list(tags or []), - } - _record_graph_limitation(node_id, op, param_exprs) - GRAPH_OUTPUTS[node_id] = [] - return value - - -def _make_feature(name, shape, *, node_id, op, params, inputs, tags, context, output_count, param_exprs=None, semantic_delta=None, topo_delta=None): - obj = doc.addObject('Part::Feature', name) - obj.Shape = shape - return _register_graph_object( - obj, - node_id=node_id, - op=op, - params=params, - inputs=inputs, - tags=tags, - context=context, - output_count=output_count, - param_exprs=param_exprs, - semantic_delta=semantic_delta, - topo_delta=topo_delta, - ) - - -def _single_face_shape(shape, operation): - if hasattr(shape, 'Shape'): - shape = shape.Shape - if shape is None or shape.isNull(): - raise RuntimeError(f'{operation} produced no valid shape') - if getattr(shape, 'ShapeType', '') == 'Face': - return shape - faces = list(getattr(shape, 'Faces', []) or []) - if len(faces) == 1: - return faces[0] - raise RuntimeError(f'{operation} expected exactly one face, got {len(faces)}') - - -def _face_shape_from_wire_shape(shape, operation='make_face_from_wire_rface'): - source_obj = shape - if hasattr(shape, 'Shape'): - shape = shape.Shape - try: - shape_invalid = shape is None or shape.isNull() - except Exception: - shape_invalid = shape is None - if shape_invalid and hasattr(source_obj, 'Shape'): - try: - doc.recompute() - except Exception: - pass - shape = getattr(source_obj, 'Shape', None) - try: - shape_invalid = shape is None or shape.isNull() - except Exception: - shape_invalid = shape is None - if shape_invalid: - raise RuntimeError(f'{operation} source has no valid shape') - if getattr(shape, 'ShapeType', '') == 'Face': - return shape - if getattr(shape, 'ShapeType', '') == 'Wire': - return Part.Face(shape) - wires = list(getattr(shape, 'Wires', []) or []) - if len(wires) == 1: - return Part.Face(wires[0]) - return _single_face_shape(shape, operation) - - -def _wire_shape_from_object(obj, operation): - source_obj = obj - shape = getattr(obj, 'Shape', obj) - try: - shape_invalid = shape is None or shape.isNull() - except Exception: - shape_invalid = shape is None - if shape_invalid and hasattr(source_obj, 'Shape'): - try: - doc.recompute() - except Exception: - pass - shape = getattr(source_obj, 'Shape', None) - try: - shape_invalid = shape is None or shape.isNull() - except Exception: - shape_invalid = shape is None - if shape_invalid: - raise RuntimeError(f'{operation} source has no valid shape') - if getattr(shape, 'ShapeType', '') == 'Wire': - return shape - wires = list(getattr(shape, 'Wires', []) or []) - if len(wires) == 1: - return wires[0] - raise RuntimeError(f'{operation} expected exactly one wire, got {len(wires)}') - - -def _face_shape_from_wire_shapes(outer_obj, inner_objs, operation='make_face_from_wires_rface'): - outer_wire = _wire_shape_from_object(outer_obj, operation + ' outer') - inner_wires = [_wire_shape_from_object(inner_obj, operation + ' inner') for inner_obj in inner_objs] - attempts = [ - [outer_wire] + [wire.reversed() for wire in inner_wires], - [outer_wire] + inner_wires, - ] - for wires in attempts: - try: - face = Part.Face(wires) - except Exception: - continue - if face is not None and not face.isNull() and face.isValid(): - return face - raise RuntimeError(f'{operation} produced an invalid multi-loop face') - - -def _face_boolean_shape(operation, base_obj, tool_obj): - base_shape = _face_shape_from_wire_shape(base_obj, operation + ' base') - tool_shape = _face_shape_from_wire_shape(tool_obj, operation + ' tool') - if operation == 'make_2d_cut_rface': - result = base_shape.cut(tool_shape) - elif operation == 'make_2d_union_rface': - result = base_shape.fuse(tool_shape) - elif operation == 'make_2d_intersect_rface': - result = base_shape.common(tool_shape) - else: - raise RuntimeError(f'Unsupported 2D face boolean {operation!r}') - return _single_face_shape(result, operation) - - -def _make_native_object(type_id, name, *, node_id, op, params, inputs, tags, context, output_count, param_exprs=None, semantic_delta=None, topo_delta=None): - obj = doc.addObject(type_id, name) - return _register_graph_object( - obj, - node_id=node_id, - op=op, - params=params, - inputs=inputs, - tags=tags, - context=context, - output_count=output_count, - param_exprs=param_exprs, - semantic_delta=semantic_delta, - topo_delta=topo_delta, - ) - - -def _make_native_assembly(name, *, node_id, op, params, inputs, tags, context, output_count, param_exprs=None, semantic_delta=None, topo_delta=None): - if Assembly is None: - raise RuntimeError('FreeCAD Assembly workbench module is required for SimpleCAD Assembly translation') - obj = doc.addObject('Assembly::AssemblyObject', name) - obj.Type = 'Assembly' - try: - obj.newObject('Assembly::JointGroup', 'Joints') - except Exception: - pass - return _register_graph_object( - obj, - node_id=node_id, - op=op, - params=params, - inputs=inputs, - tags=tags, - context=context, - output_count=output_count, - param_exprs=param_exprs, - semantic_delta=semantic_delta, - topo_delta=topo_delta, - ) - - -def _joint_group_for(assembly_container): - for child in list(getattr(assembly_container, 'OutList', []) or []): - if getattr(child, 'TypeId', '') == 'Assembly::JointGroup': - return child - return assembly_container.newObject('Assembly::JointGroup', 'Joints') - - -def _find_component_entry(assembly_value, component_id): - for component in list(assembly_value.get('components', []) or []): - if str(component.get('component_id')) == str(component_id): - return component - raise RuntimeError(f'Missing assembly component {component_id!r}') - - -def _connector_payload_for(component_entry, connector_id): - item = component_entry.get('item') or {} - try: - return _connector_payload_for_item(item, connector_id) - except Exception as exc: - raise RuntimeError(f'Missing connector {connector_id!r} on component {component_entry.get("component_id")!r}') from exc - - -def _freecad_subname_for_kind(kind, index): - # Convert a 0-based index into a FreeCAD sub-element name string. - kind_lower = str(kind).lower() - prefix_map = {'vertex': 'Vertex', 'edge': 'Edge', 'wire': 'Wire', 'face': 'Face', 'solid': 'Solid'} - prefix = prefix_map.get(kind_lower, 'Face') - return f'{prefix}{index + 1}' - - -def _resolve_connector_subname(component_entry, connector_payload): - # Resolve the FreeCAD sub-element name (e.g. Face3) for a connector geometry_ref. - # Uses the geo_selector to match against the component link shape. - # Returns a tuple (subname1, subname2) suitable for FreeCAD Reference1/Reference2. - geometry_ref = connector_payload.get('geometry_ref') or {} - geo_selector = geometry_ref.get('geo_selector') or {} - kind = str(geometry_ref.get('kind') or geo_selector.get('kind') or '').lower() - if not kind: - return '', '' - link = component_entry.get('link') - if link is None: - return '', '' - linked_obj = getattr(link, 'LinkedObject', None) or link - shape = getattr(linked_obj, 'Shape', None) - if shape is None: - return '', '' - try: - index = _selection_index_for_selector(shape, geo_selector) - subname = _freecad_subname_for_kind(kind, index) - return subname, subname - except Exception: - return '', '' - - -def _connector_reference_for_component(assembly_value, component_entry, connector_payload): - link = component_entry.get('link') - if link is None: - raise RuntimeError(f'Missing component link for component {component_entry.get("component_id")!r}') - anchor = _connector_anchor_payload(connector_payload or {}) - if ( - getattr(link, 'TypeId', '') == 'Assembly::AssemblyLink' - and not bool(getattr(link, 'Rigid', True)) - and str(anchor.get('anchor_kind') or '').lower() == 'forwarded' - ): - source_component = _find_component_entry( - component_entry.get('item') or {}, anchor.get('source_component_id') - ) - source_link = source_component.get('link') - local_link = next( - child - for child in list(getattr(link, 'Group', []) or []) - if getattr(child, 'LinkedObject', None) is source_link - ) - source_item = source_component.get('item') or {} - source_connector = _connector_payload_for_item( - source_item, anchor.get('source_connector_id') - ) - placement = _connector_local_placement(source_item, source_connector) - if isinstance(anchor.get('offset'), dict): - placement = placement.multiply(_placement_from_axes_payload(anchor.get('offset'))) - return local_link, ['', ''], placement, True - datum = (connector_payload or {}).get('datum') - datum_name = str(getattr(datum, 'Name', '') or '') - if datum_name: - subname = datum_name + '.' - return link, [subname, subname], App.Placement(), False - anchor = _connector_anchor_payload(connector_payload or {}) - if str(anchor.get('anchor_kind') or '').lower() == 'geometry': - sub_a, sub_b = _resolve_connector_subname(component_entry, connector_payload) - if sub_a and sub_b: - return link, [sub_a, sub_b], App.Placement(), False - return link, ['', ''], _connector_local_placement(component_entry.get('item') or {}, connector_payload), True - - -def _make_simplecad_joint(assembly_value, constraint_payload, object_name, label): - assembly_container = assembly_value.get('container') - if assembly_container is None: - raise RuntimeError('Assembly value has no container for constraint translation') - connector_a = constraint_payload.get('connector_a') or {} - connector_b = constraint_payload.get('connector_b') or {} - component_a = _find_component_entry(assembly_value, connector_a.get('component_id')) - component_b = _find_component_entry(assembly_value, connector_b.get('component_id')) - connector_a_payload = _connector_payload_for(component_a, connector_a.get('connector_id')) - connector_b_payload = _connector_payload_for(component_b, connector_b.get('connector_id')) - joint_type = {'fixed': 'Fixed', 'revolute': 'Revolute', 'prismatic': 'Slider', 'gear': 'Gears', 'belt': 'Belt', 'rack_pinion': 'RackPinion'}.get(str(constraint_payload.get('constraint_kind'))) - if not joint_type: - raise RuntimeError(f"Unsupported SimpleCAD constraint kind {constraint_payload.get('constraint_kind')!r}") - joint_group = _joint_group_for(assembly_container) - joint = joint_group.newObject('App::FeaturePython', object_name) - joint.Label = str(label or constraint_payload.get('constraint_id') or object_name) - type_index = {'Fixed': 0, 'Revolute': 1, 'Slider': 3, 'RackPinion': 9, 'Gears': 11, 'Belt': 12}[joint_type] - native_status = 'metadata_only' - if JointObject is not None: - JointObject.Joint(joint, type_index) - native_status = 'native_equivalent' - if getattr(App, 'GuiUp', False) and hasattr(joint, 'ViewObject') and joint.ViewObject is not None: - try: - JointObject.ViewProviderJoint(joint.ViewObject) - except Exception: - pass - else: - _ensure_string_property(joint, 'JointType') - joint.JointType = joint_type - try: - ref_a_obj, ref_a_subs, ref_a_placement, ref_a_detached = _connector_reference_for_component(assembly_value, component_a, connector_a_payload) - ref_b_obj, ref_b_subs, ref_b_placement, ref_b_detached = _connector_reference_for_component(assembly_value, component_b, connector_b_payload) - if hasattr(joint, 'Reference1'): - if hasattr(joint, 'Detach1'): - joint.Detach1 = bool(ref_a_detached) - if hasattr(joint, 'Detach2'): - joint.Detach2 = bool(ref_b_detached) - joint.Reference1 = [ref_a_obj, list(ref_a_subs)] - joint.Reference2 = [ref_b_obj, list(ref_b_subs)] - if hasattr(joint, 'Placement1'): - joint.Placement1 = ref_a_placement - if hasattr(joint, 'Placement2'): - joint.Placement2 = ref_b_placement - except Exception: - native_status = 'native_partial' - if joint_type == 'Revolute' and constraint_payload.get('drive_angle_degrees') is not None: - try: - joint.Angle = float(constraint_payload.get('drive_angle_degrees')) - except Exception: - native_status = 'native_partial' - if joint_type == 'Slider' and constraint_payload.get('drive_distance') is not None: - try: - joint.Distance = float(constraint_payload.get('drive_distance')) - except Exception: - native_status = 'native_partial' - if joint_type == 'Gears': - try: - joint.Distance = float(constraint_payload.get('pitch_radius_a')) - joint.Distance2 = float(constraint_payload.get('pitch_radius_b')) - except Exception: - native_status = 'native_partial' - if joint_type == 'Belt': - try: - joint.Distance = float(constraint_payload.get('pulley_radius_a')) - joint.Distance2 = float(constraint_payload.get('pulley_radius_b')) - except Exception: - native_status = 'native_partial' - if joint_type == 'RackPinion': - try: - joint.Distance = float(constraint_payload.get('pitch_radius')) - except Exception: - native_status = 'native_partial' - angle_limit = constraint_payload.get('angle_limit') - if angle_limit: - try: - joint.EnableAngleMin = True - joint.EnableAngleMax = True - joint.AngleMin = float(angle_limit.get('lower_value')) - joint.AngleMax = float(angle_limit.get('upper_value')) - except Exception: - native_status = 'native_partial' - distance_limit = constraint_payload.get('distance_limit') - if distance_limit: - try: - joint.EnableLengthMin = True - joint.EnableLengthMax = True - joint.LengthMin = float(distance_limit.get('lower_value')) - joint.LengthMax = float(distance_limit.get('upper_value')) - except Exception: - native_status = 'native_partial' - _ensure_string_property(joint, 'SimpleCADConstraint') - _ensure_string_property(joint, 'SimpleCADConstraintTranslationStatus') - joint.SimpleCADConstraint = json.dumps(constraint_payload, ensure_ascii=True, sort_keys=True) - joint.SimpleCADConstraintTranslationStatus = native_status - _set_visibility(joint, False) - try: - SIMPLECAD_JOINT_OBJECTS[str(joint.Name)] = joint_type - except Exception: - pass - return joint - - -def _make_simplecad_grounded_joint(assembly_value, component_id): - assembly_container = assembly_value.get('container') - if assembly_container is None: - raise RuntimeError('Assembly value has no container for grounded joint translation') - component_entry = _find_component_entry(assembly_value, component_id) - link = component_entry.get('link') - if link is None: - raise RuntimeError(f'Missing component link for grounded component {component_id!r}') - joint_group = _joint_group_for(assembly_container) - ground = joint_group.newObject('App::FeaturePython', 'GroundedJoint_' + str(component_id)) - ground.Label = 'GroundedJoint_' + str(component_id) - native_status = 'metadata_only' - if JointObject is not None: - JointObject.GroundedJoint(ground, link) - native_status = 'native_equivalent' - else: - _ensure_string_property(ground, 'ObjectToGround') - _ensure_string_property(ground, 'SimpleCADGroundedComponent') - ground.SimpleCADGroundedComponent = str(component_id) - _set_visibility(ground, False) - try: - SIMPLECAD_JOINT_OBJECTS[str(ground.Name)] = 'Grounded' - except Exception: - pass - return ground - - -PRODUCT_LIBRARY_GROUP = None -CONSTRUCTION_GROUP = None -MATERIAL_LIBRARY_GROUP = None - - -def _named_document_group(name, label): - existing = doc.getObject(name) - if existing is not None: - return existing - group = doc.addObject('App::DocumentObjectGroup', name) - group.Label = label - _set_visibility(group, False) - return group - - -def _product_library_group(): - global PRODUCT_LIBRARY_GROUP - if PRODUCT_LIBRARY_GROUP is None: - PRODUCT_LIBRARY_GROUP = _named_document_group('SimpleCADProductLibrary', 'SimpleCAD Product Library') - return PRODUCT_LIBRARY_GROUP - - -def _construction_group(): - global CONSTRUCTION_GROUP - if CONSTRUCTION_GROUP is None: - CONSTRUCTION_GROUP = _named_document_group('SimpleCADConstruction', 'SimpleCAD Construction') - return CONSTRUCTION_GROUP - - -def _material_library_group(): - global MATERIAL_LIBRARY_GROUP - if MATERIAL_LIBRARY_GROUP is None: - MATERIAL_LIBRARY_GROUP = _named_document_group('SimpleCADMaterials', 'SimpleCAD Materials') - return MATERIAL_LIBRARY_GROUP - - -def _group_contains(group, obj): - return obj in list(getattr(group, 'Group', []) or []) - - -def _add_to_group(group, obj): - if obj is None or obj is group: - return - if not _group_contains(group, obj): - try: - group.addObject(obj) - except Exception: - pass - - -def _material_from_assignment_params(params): - params = dict(params or {}) - material = params.get('material') - if isinstance(material, dict): - return dict(material) - return { - 'material_id': str(params.get('material_id', '')), - 'name': params.get('name'), - 'density': params.get('density'), - 'density_unit': params.get('density_unit'), - 'color': params.get('color'), - } - - -def _material_color(material): - color = (material or {}).get('color') - if not isinstance(color, (list, tuple)) or len(color) != 3: - return None - try: - result = tuple(float(component) for component in color) - except Exception: - return None - if any(component < 0.0 or component > 1.0 for component in result): - return None - return result - - -def _packed_rgba(color): - channels = [max(0, min(255, int(float(component) * 255.0 + 0.5))) for component in color] - return (channels[0] << 24) | (channels[1] << 16) | (channels[2] << 8) | 255 - - -def _ensure_material_object(material): - material = dict(material or {}) - material_id = str(material.get('material_id') or '') - if not material_id: - raise RuntimeError('Material assignment is missing material_id') - existing = MATERIAL_OBJECTS_BY_ID.get(material_id) - if existing is not None: - return existing - obj = doc.addObject('App::MaterialObjectPython', 'Material_' + _simplecad_slug(material_id, prefix='material')) - obj.Label = str(material.get('name') or material_id) - _ensure_string_property(obj, 'SimpleCADMaterialId', 'Material') - _ensure_string_property(obj, 'SimpleCADMaterialName', 'Material') - _ensure_string_property(obj, 'SimpleCADMaterial', 'Material') - obj.SimpleCADMaterialId = material_id - obj.SimpleCADMaterialName = str(material.get('name') or '') - obj.SimpleCADMaterial = json.dumps(material, ensure_ascii=True, sort_keys=True) - if material.get('density') is not None: - _ensure_float_property(obj, 'SimpleCADDensity', 'Material') - _ensure_string_property(obj, 'SimpleCADDensityUnit', 'Material') - obj.SimpleCADDensity = float(material.get('density')) - obj.SimpleCADDensityUnit = str(material.get('density_unit') or '') - color = _material_color(material) - if color is not None: - _ensure_color_property(obj, 'SimpleCADColor', 'Material') - obj.SimpleCADColor = color - native_map = { - 'SimpleCAD.MaterialId': material_id, - 'General.Name': str(material.get('name') or material_id), - } - if material.get('density') is not None: - native_map['General.Density'] = str(material.get('density')) + ' ' + str(material.get('density_unit') or '') - if color is not None: - native_map['General.Color'] = ','.join(str(component) for component in color) - obj.Material = native_map - _add_to_group(_material_library_group(), obj) - _set_visibility(obj, False) - MATERIAL_OBJECTS_BY_ID[material_id] = obj - return obj - - -def _apply_material_to_object(obj, material, *, override=False, visual=True): - if obj is None: - return None - material = dict(material or {}) - material_obj = _ensure_material_object(material) - _ensure_string_property(obj, 'SimpleCADMaterial') - _ensure_link_property(obj, 'SimpleCADMaterialObject') - obj.SimpleCADMaterial = json.dumps(material, ensure_ascii=True, sort_keys=True) - obj.SimpleCADMaterialObject = material_obj - color = _material_color(material) - if color is None or not visual: - return material_obj - name = str(getattr(obj, 'Name', '')) - if name: - GUI_SHAPE_COLOR_BY_NAME[name] = _packed_rgba(color) - if override: - GUI_MATERIAL_OVERRIDE_BY_NAME[name] = True - try: - view = getattr(obj, 'ViewObject', None) - if view is not None: - if hasattr(view, 'ShapeColor'): - view.ShapeColor = color - if override and hasattr(view, 'OverrideMaterial'): - view.OverrideMaterial = True - except Exception: - pass - return material_obj - - -def _apply_material_to_product(product_value, material): - material_obj = _apply_material_to_object(product_value.get('container'), material, visual=False) - _apply_material_to_object(product_value.get('body'), material) - product_value['material'] = dict(material or {}) - product_value['material_object'] = material_obj - return product_value - - -def _is_origin_object(obj): - return getattr(obj, 'TypeId', '') == 'App::Origin' or str(getattr(obj, 'Name', '')).startswith('Origin') - - -def _is_connector_object(obj): - try: - if hasattr(obj, 'SimpleCADConnectorId'): - return True - except Exception: - pass - try: - return str(getattr(obj, 'Name', '')).startswith('connector_') - except Exception: - return False - - -def _hide_origin_tree(container): - for child in list(getattr(container, 'Group', []) or []): - if _is_origin_object(child): - _set_visibility(child, False) - for nested in list(getattr(child, 'OutListRecursive', []) or []): - _set_visibility(nested, False) - - -def _hide_all_origin_trees(): - for obj in list(getattr(doc, 'Objects', []) or []): - if not _is_origin_object(obj): - continue - _set_visibility(obj, False) - for nested in list(getattr(obj, 'OutListRecursive', []) or []): - _set_visibility(nested, False) - - -def _move_to_construction_group(obj): - group = _construction_group() - for candidate in [obj] + list(getattr(obj, 'OutListRecursive', []) or []): - if candidate is None: - continue - if getattr(candidate, 'TypeId', '') in {'App::Origin', 'App::Line', 'App::Plane', 'App::Point'}: - continue - _add_to_group(group, candidate) - _set_visibility(candidate, False) - _set_visibility(group, False) - - -def _move_product_source_to_library(product_item): - container = product_item.get('container') if isinstance(product_item, dict) else None - if container is None: - return - group = _product_library_group() - _add_to_group(group, container) - _set_visibility(container, False) - _hide_origin_tree(container) - _set_visibility(group, False) - - -def _make_part_body_copy(part_container, source_obj, source_node_id): - doc.recompute() - if source_obj is None or not hasattr(source_obj, 'Shape'): - raise RuntimeError('Part body source has no shape') - body = part_container.newObject('Part::Feature', 'Body') - body.Label = 'Body' - body.Shape = source_obj.Shape.copy() - _ensure_string_property(body, 'SimpleCADSourceBodyNodeId') - body.SimpleCADSourceBodyNodeId = str(source_node_id) - _set_visibility(body, True) - _move_to_construction_group(source_obj) - return body - - -def _make_assembly_component_link(assembly_container, product_item, name, label, placement): - link_type = 'Assembly::AssemblyLink' if product_item.get('kind') == 'assembly' else 'App::Link' - link = assembly_container.newObject(link_type, name) - link.Label = str(label) - _move_product_source_to_library(product_item) - link.LinkedObject = product_item.get('container') - if product_item.get('kind') == 'part': - link.LinkedObject = product_item.get('container') or product_item.get('body') - if link_type == 'Assembly::AssemblyLink': - try: - movable_kinds = {'revolute', 'prismatic'} - link.Rigid = not any( - str(constraint.get('constraint_kind')) in movable_kinds - for constraint in list(product_item.get('constraints', []) or []) - ) - except Exception: - pass - _set_component_link_placement(link, product_item, placement) - if product_item.get('kind') == 'part' and product_item.get('material'): - _apply_material_to_object(link, product_item.get('material'), override=True) - return link - - -def _set_component_link_placement(link, product_item, placement): - target_placement = _placement_from_axes_payload(placement) - if not ( - getattr(link, 'TypeId', '') == 'Assembly::AssemblyLink' - and not bool(getattr(link, 'Rigid', True)) - ): - link.Placement = target_placement - return - doc.recompute() - link.Placement = App.Placement() - for source_component in list(product_item.get('components', []) or []): - source_link = source_component.get('link') - local_link = next( - ( - child - for child in list(getattr(link, 'Group', []) or []) - if getattr(child, 'LinkedObject', None) is source_link - ), - None, - ) - if local_link is None: - continue - source_placement = _placement_from_axes_payload(source_component.get('placement') or {}) - local_link.Placement = target_placement.multiply(source_placement) - - -def _node_object(node_id, index=0): - outputs = GRAPH_OUTPUTS.get(node_id, []) - if not outputs: - raise RuntimeError(f'Missing graph output object for node {node_id!r}') - idx = int(index) - if idx < 0 or idx >= len(outputs): - raise RuntimeError(f'Output object slot {idx} missing for node {node_id!r}') - return outputs[idx] - - -def _set_visibility(obj, visible): - if obj is not None: - try: - GUI_VISIBILITY_BY_NAME[str(obj.Name)] = bool(visible) - except Exception: - pass - try: - view = getattr(obj, 'ViewObject', None) - if view is not None and hasattr(view, 'Visibility'): - view.Visibility = bool(visible) - except Exception: - pass - try: - if hasattr(obj, 'Visibility'): - obj.Visibility = bool(visible) - except Exception: - pass - - -def _set_expanded(obj, expanded=True): - try: - GUI_EXPANDED_BY_NAME[str(obj.Name)] = bool(expanded) - except Exception: - pass - - -def _xml_attr(value): - return str(value).replace('&', '&').replace('"', '"').replace('<', '<').replace('>', '>') - - -def _visibility_for_gui(obj): - try: - name = str(obj.Name) - except Exception: - return False - if name in GUI_VISIBILITY_BY_NAME: - return bool(GUI_VISIBILITY_BY_NAME[name]) - try: - return bool(obj.Visibility) - except Exception: - return False - - -def _write_gui_document_xml(fcstd_path): - object_rows = [] - for obj in list(getattr(doc, 'Objects', []) or []): - try: - name = str(obj.Name) - except Exception: - continue - object_rows.append(( - name, - _visibility_for_gui(obj), - bool(GUI_EXPANDED_BY_NAME.get(name, False)), - GUI_SHAPE_COLOR_BY_NAME.get(name), - bool(GUI_MATERIAL_OVERRIDE_BY_NAME.get(name, False)), - )) - lines = [ - "", - '', - '', - f' ', - ] - for name, visible, expanded, packed_color, override_material in object_rows: - joint_type = SIMPLECAD_JOINT_OBJECTS.get(name) - properties = [ - ('Visibility', 'App::PropertyBool', f''), - ] - if packed_color is not None and override_material: - properties.extend([ - ('OverrideMaterial', 'App::PropertyBool', ''), - ( - 'ShapeMaterial', - 'App::PropertyMaterial', - '', - ), - ]) - elif packed_color is not None: - properties.append( - ('ShapeColor', 'App::PropertyColor', f'') - ) - if joint_type is not None: - vp_class = 'ViewProviderGroundedJoint' if joint_type == 'Grounded' else 'ViewProviderJoint' - properties.append( - ('Proxy', 'App::PropertyPythonObject', f'') - ) - lines.extend([ - f' ', - f' ', - ]) - for prop_name, prop_type, prop_value in properties: - status = ' status="67108864"' if prop_name == 'Proxy' else '' - lines.extend([ - f' ', - f' {prop_value}', - ' ', - ]) - lines.extend([ - ' ', - ' ', - ]) - lines.extend([ - ' ', - ' ', - '', - '', - ]) - gui_document = '\\n'.join(lines).encode('utf-8') - tmp_path = str(fcstd_path) + '.simplecad_tmp' - with zipfile.ZipFile(fcstd_path, 'r') as source: - infos = source.infolist() - with zipfile.ZipFile(tmp_path, 'w', compression=zipfile.ZIP_DEFLATED) as target: - wrote_gui = False - for info in infos: - if info.filename == 'GuiDocument.xml': - target.writestr(info, gui_document) - wrote_gui = True - else: - target.writestr(info, source.read(info.filename)) - if not wrote_gui: - target.writestr('GuiDocument.xml', gui_document) - os.replace(tmp_path, fcstd_path) - - -def _save_fcstd_with_gui_visibility(output_path): - doc.recompute() - _hide_all_origin_trees() - doc.saveAs(output_path) - _hide_all_origin_trees() - _write_gui_document_xml(output_path) - - -def _placement_for_rotation(origin, axis, angle_degrees): - center = _vec(origin) - rotation = App.Rotation(_vec(axis), float(angle_degrees)) - to_center = App.Placement(center, rotation) - from_center = App.Placement(App.Vector(-center.x, -center.y, -center.z), App.Rotation()) - return to_center.multiply(from_center) - - -def _fold_object_placement(obj, placement): - obj.Placement = placement.multiply(obj.Placement) - return obj - - -def _set_part_body_visibility(product_value, visible): - container = product_value.get('container') if isinstance(product_value, dict) else None - if container is None: - return - body = product_value.get('body') - for child in list(getattr(container, 'Group', []) or []): - if _is_origin_object(child): - _set_visibility(child, False) - continue - if _is_connector_object(child): - _set_visibility(child, False) - continue - _set_visibility(child, bool(visible and child is body)) - if body is not None: - _set_visibility(body, visible) - - -def _set_product_tree_visibility(product_value, visible, *, show_source_container=True): - if not isinstance(product_value, dict): - return - kind = product_value.get('kind') - container = product_value.get('container') - if kind == 'part': - if container is not None: - _set_visibility(container, visible if show_source_container else False) - _set_expanded(container, bool(visible and show_source_container)) - _hide_origin_tree(container) - _set_part_body_visibility(product_value, visible) - return - if kind == 'assembly': - if container is not None: - _set_visibility(container, visible if show_source_container else False) - _set_expanded(container, bool(visible and show_source_container)) - _hide_origin_tree(container) - for child in list(getattr(container, 'Group', []) or []): - if _is_connector_object(child): - _set_visibility(child, False) - for component in product_value.get('components', []): - link = component.get('link') - if link is not None: - _set_visibility(link, visible) - _set_expanded(link, bool(visible)) - item = component.get('item') - _set_product_tree_visibility(item, visible, show_source_container=False) - - -def _apply_product_result_visibility(visible_ids): - product_ids = set() - product_ids.update(str(node_id) for node_id in visible_ids if str(node_id) in PRODUCT_VALUES) - product_ids.update( - str(source_id) - for node_id, source_id in ASSEMBLY_PROJECTION_INPUTS.items() - if str(node_id) in visible_ids - ) - for node_id in product_ids: - _set_product_tree_visibility(PRODUCT_VALUES.get(node_id), True) - if PRODUCT_LIBRARY_GROUP is not None: - _set_visibility(PRODUCT_LIBRARY_GROUP, False) - if CONSTRUCTION_GROUP is not None: - _set_visibility(CONSTRUCTION_GROUP, False) - - -def _result_product_container(result_node_ids): - for node_id in result_node_ids or []: - product_value = PRODUCT_VALUES.get(str(node_id)) - if isinstance(product_value, dict) and product_value.get('container') is not None: - return product_value.get('container') - source_id = ASSEMBLY_PROJECTION_INPUTS.get(str(node_id)) - product_value = PRODUCT_VALUES.get(str(source_id)) - if isinstance(product_value, dict) and product_value.get('container') is not None: - return product_value.get('container') - return None - - -def _set_active_result_object(result_node_ids): - obj = _result_product_container(result_node_ids) - if obj is None: - result_objects = [candidate for node_id in (result_node_ids or []) for candidate in GRAPH_OUTPUTS.get(str(node_id), [])] - obj = result_objects[0] if result_objects else None - if obj is None: - return - try: - doc.ActiveObject = obj - except Exception: - pass - try: - import FreeCADGui as Gui - if getattr(App, 'GuiUp', False) and Gui.ActiveDocument is not None: - Gui.ActiveDocument.ActiveView.setActiveObject('part', obj) - except Exception: - pass - - -def _apply_result_visibility(result_node_ids): - visible_ids = {str(node_id) for node_id in (result_node_ids or [])} - projection_visible_ids = {str(source_id) for node_id, source_id in ASSEMBLY_PROJECTION_INPUTS.items() if str(node_id) in visible_ids} - for node_id, outputs in GRAPH_OUTPUTS.items(): - is_visible = str(node_id) in visible_ids or str(node_id) in projection_visible_ids - if str(node_id) in ASSEMBLY_PROJECTION_INPUTS: - is_visible = False - for obj in outputs: - _set_visibility(obj, is_visible) - _apply_product_result_visibility(visible_ids) - _hide_all_origin_trees() - - -def _vec(v): - return App.Vector(float(v[0]), float(v[1]), float(v[2])) - - -def _placement_from_axes_payload(payload): - origin = payload.get('origin', (0.0, 0.0, 0.0)) - x_axis = payload.get('x_axis', (1.0, 0.0, 0.0)) - y_axis = payload.get('y_axis', (0.0, 1.0, 0.0)) - z_axis = payload.get('z_axis') - if z_axis is None: - x = _vec(x_axis) - y = _vec(y_axis) - z = x.cross(y) - length = float(getattr(z, 'Length', 0.0)) - if length == 0.0: - raise RuntimeError('Placement axes do not form a frame') - z_axis = (z.x / length, z.y / length, z.z / length) - matrix = App.Matrix() - matrix.A11 = float(x_axis[0]); matrix.A12 = float(y_axis[0]); matrix.A13 = float(z_axis[0]); matrix.A14 = float(origin[0]) - matrix.A21 = float(x_axis[1]); matrix.A22 = float(y_axis[1]); matrix.A23 = float(z_axis[1]); matrix.A24 = float(origin[1]) - matrix.A31 = float(x_axis[2]); matrix.A32 = float(y_axis[2]); matrix.A33 = float(z_axis[2]); matrix.A34 = float(origin[2]) - return App.Placement(matrix) - - -def _connector_tuple3(value, default=(0.0, 0.0, 0.0)): - if isinstance(value, (list, tuple)) and len(value) == 3: - try: - return (float(value[0]), float(value[1]), float(value[2])) - except Exception: - pass - return (float(default[0]), float(default[1]), float(default[2])) - - -def _dot3(a, b): - return a[0] * b[0] + a[1] * b[1] + a[2] * b[2] - - -def _cross3(a, b): - return ( - a[1] * b[2] - a[2] * b[1], - a[2] * b[0] - a[0] * b[2], - a[0] * b[1] - a[1] * b[0], - ) - - -def _unit3(value, default=(0.0, 0.0, 1.0)): - vec = _connector_tuple3(value, default) - length = math.sqrt(_dot3(vec, vec)) - if length <= 1.0e-12: - vec = _connector_tuple3(default, (0.0, 0.0, 1.0)) - length = math.sqrt(_dot3(vec, vec)) - return (vec[0] / length, vec[1] / length, vec[2] / length) - - -def _orthogonal_axis3(z_axis): - candidates = ((1.0, 0.0, 0.0), (0.0, 1.0, 0.0), (0.0, 0.0, 1.0)) - best = min(candidates, key=lambda candidate: abs(_dot3(z_axis, candidate))) - projected = ( - best[0] - z_axis[0] * _dot3(z_axis, best), - best[1] - z_axis[1] * _dot3(z_axis, best), - best[2] - z_axis[2] * _dot3(z_axis, best), - ) - return _unit3(projected, (1.0, 0.0, 0.0)) - - -def _placement_from_geometry_ref_payload(geometry_ref): - geometry_ref = geometry_ref or {} - selector = geometry_ref.get('geo_selector') or {} - kind = str(geometry_ref.get('kind') or selector.get('kind') or '').lower() - flip = bool(geometry_ref.get('flip', False)) - if kind == 'face': - origin = _connector_tuple3(selector.get('center'), (0.0, 0.0, 0.0)) - z_axis = _unit3(selector.get('normal'), (0.0, 0.0, 1.0)) - if flip: - z_axis = (-z_axis[0], -z_axis[1], -z_axis[2]) - x_axis = _orthogonal_axis3(z_axis) - y_axis = _unit3(_cross3(z_axis, x_axis), (0.0, 1.0, 0.0)) - return _placement_from_axes_payload({'origin': origin, 'x_axis': x_axis, 'y_axis': y_axis}) - if kind == 'edge': - origin = _connector_tuple3(selector.get('center'), (0.0, 0.0, 0.0)) - start = selector.get('start') - end = selector.get('end') - if isinstance(start, (list, tuple)) and isinstance(end, (list, tuple)): - s = _connector_tuple3(start) - e = _connector_tuple3(end) - direction = (e[0] - s[0], e[1] - s[1], e[2] - s[2]) - else: - direction = (1.0, 0.0, 0.0) - z_axis = _unit3(direction, (1.0, 0.0, 0.0)) - if flip: - z_axis = (-z_axis[0], -z_axis[1], -z_axis[2]) - x_axis = _orthogonal_axis3(z_axis) - y_axis = _unit3(_cross3(z_axis, x_axis), (0.0, 1.0, 0.0)) - return _placement_from_axes_payload({'origin': origin, 'x_axis': x_axis, 'y_axis': y_axis}) - if kind == 'vertex': - return _placement_from_axes_payload({'origin': _connector_tuple3(selector.get('coordinates'), (0.0, 0.0, 0.0))}) - return _placement_from_axes_payload({'origin': _connector_tuple3(selector.get('center'), (0.0, 0.0, 0.0))}) - - -def _connector_anchor_payload(connector_payload): - anchor = connector_payload.get('anchor') if isinstance(connector_payload, dict) else None - if isinstance(anchor, dict): - return anchor - geometry_ref = connector_payload.get('geometry_ref') if isinstance(connector_payload, dict) else None - if isinstance(geometry_ref, dict): - return {'anchor_kind': 'geometry', 'geometry_ref': geometry_ref} - return {'anchor_kind': 'placement', 'placement': {'origin': (0.0, 0.0, 0.0)}} - - -def _connector_payload_for_item(item, connector_id): - for connector in list((item or {}).get('connectors', []) or []): - if str(connector.get('connector_id')) == str(connector_id): - return connector - raise RuntimeError(f'Missing connector {connector_id!r} on product item') - - -def _connector_metadata_payload(connector_payload): - payload = dict(connector_payload or {}) - payload.pop('datum', None) - payload.pop('placement', None) - return payload - - -def _connector_local_placement(item, connector_payload, seen=None): - seen = set(seen or set()) - connector_id = str((connector_payload or {}).get('connector_id', '')) - key = (id(item), connector_id) - if key in seen: - raise RuntimeError(f'forwarded connector cycle detected at {connector_id!r}') - seen.add(key) - anchor = _connector_anchor_payload(connector_payload or {}) - kind = str(anchor.get('anchor_kind') or '').lower() - if kind == 'geometry': - return _placement_from_geometry_ref_payload(anchor.get('geometry_ref') or (connector_payload or {}).get('geometry_ref')) - if kind == 'placement': - return _placement_from_axes_payload(anchor.get('placement') or {}) - if kind == 'forwarded': - source_component = _find_component_entry(item or {}, anchor.get('source_component_id')) - source_item = source_component.get('item') or {} - source_connector = _connector_payload_for_item(source_item, anchor.get('source_connector_id')) - source_placement = _placement_from_axes_payload(source_component.get('placement') or {}) - result = source_placement.multiply(_connector_local_placement(source_item, source_connector, seen)) - if isinstance(anchor.get('offset'), dict): - result = result.multiply(_placement_from_axes_payload(anchor.get('offset'))) - return result - return App.Placement() - - -def _make_connector_datum(container, connector_payload, placement): - connector_id = str((connector_payload or {}).get('connector_id') or 'connector') - metadata_payload = _connector_metadata_payload(connector_payload) - object_name = 'connector_' + _simplecad_slug(connector_id, prefix='connector') - datum = None - try: - datum = container.newObject('PartDesign::CoordinateSystem', object_name) - except Exception: - datum = container.newObject('App::FeaturePython', object_name) - datum.Label = 'connector.' + connector_id - _ensure_placement_property(datum) - datum.Placement = placement - _ensure_string_property(datum, 'SimpleCADConnectorId') - _ensure_string_property(datum, 'SimpleCADConnector') - datum.SimpleCADConnectorId = connector_id - datum.SimpleCADConnector = json.dumps(metadata_payload, ensure_ascii=True, sort_keys=True) - _set_visibility(datum, True) - return datum - - -def _materialize_product_connector_datums(product_value): - container = (product_value or {}).get('container') - if container is None: - return product_value - resolved = [] - for connector in list((product_value or {}).get('connectors', []) or []): - connector = dict(connector) - try: - placement = _connector_local_placement(product_value, connector) - connector['datum'] = _make_connector_datum(container, connector, placement) - connector['placement'] = placement - except Exception: - pass - resolved.append(connector) - product_value['connectors'] = resolved - return product_value - - -def _component_connector_proxy(assembly_value, component_entry, connector_payload): - assembly_container = assembly_value.get('container') - if assembly_container is None: - raise RuntimeError('Assembly value has no container for connector proxy') - link = component_entry.get('link') - link_placement = getattr(link, 'Placement', App.Placement()) if link is not None else _placement_from_axes_payload(component_entry.get('placement') or {}) - local_placement = _connector_local_placement(component_entry.get('item') or {}, connector_payload) - proxy_payload = dict(connector_payload or {}) - proxy_payload['connector_id'] = str(component_entry.get('component_id')) + '.' + str(proxy_payload.get('connector_id', 'connector')) - return _make_connector_datum(assembly_container, proxy_payload, link_placement.multiply(local_placement)) - - -def _shape_from_component_link(link): - source = getattr(link, 'LinkedObject', None) - if source is None or not hasattr(source, 'Shape'): - raise RuntimeError('Component link has no shape-bearing linked object') - shape = source.Shape.copy() - try: - shape.Placement = link.Placement.multiply(shape.Placement) - except Exception: - pass - return shape - - -def _materialize_boolean_operand(obj, name): - if getattr(obj, 'TypeId', '') != 'App::Link': - return obj - shape = _shape_from_component_link(obj) - wrapper = doc.addObject('Part::Feature', name) - wrapper.Label = str(name) - wrapper.Shape = shape - _ensure_string_property(wrapper, 'SimpleCADMaterializedFromLink') - wrapper.SimpleCADMaterializedFromLink = str(getattr(obj, 'Name', '')) - _set_visibility(wrapper, False) - return wrapper - - -def _placed_shape_from_body(body, placement): - if body is None or not hasattr(body, 'Shape'): - raise RuntimeError('Part product value has no shape-bearing body') - shape = body.Shape.copy() - try: - shape.Placement = placement.multiply(shape.Placement) - except Exception: - pass - return shape - - -def _shapes_from_product_value(value, placement=None): - placement = placement or App.Placement() - if value.get('kind') == 'part': - return [_placed_shape_from_body(value.get('body'), placement)] - if value.get('kind') == 'assembly': - shapes = [] - for component in value.get('components', []): - component_placement = component.get('link').Placement if component.get('link') is not None else _placement_from_axes_payload(component.get('placement') or {}) - shapes.extend(_shapes_from_product_value(component.get('item'), placement.multiply(component_placement))) - return shapes - raise RuntimeError('Unsupported product value for shape projection') - - -def _normalized_vec(v): - vec = _vec(v) - length = float(getattr(vec, 'Length', 0.0)) - if length == 0.0: - raise RuntimeError('Expected a non-zero vector') - return App.Vector(vec.x / length, vec.y / length, vec.z / length) - - -def _scaled_direction(direction, distance): - unit = _normalized_vec(direction) - dist = float(distance) - return App.Vector(unit.x * dist, unit.y * dist, unit.z * dist) - - -def _placement_from_context(context): - origin = context.get('origin') if isinstance(context, dict) else None - if isinstance(origin, (list, tuple)) and len(origin) == 3: - return App.Placement(_vec(origin), App.Rotation()) - return App.Placement() - - -def _rotation_from_context_axes(context): - if not isinstance(context, dict): - return App.Rotation() - x_axis = context.get('x_axis') - y_axis = context.get('y_axis') - z_axis = context.get('z_axis') - if not ( - isinstance(x_axis, (list, tuple)) and len(x_axis) == 3 and - isinstance(y_axis, (list, tuple)) and len(y_axis) == 3 and - isinstance(z_axis, (list, tuple)) and len(z_axis) == 3 - ): - return App.Rotation() - m = App.Matrix() - m.A11, m.A21, m.A31 = float(x_axis[0]), float(x_axis[1]), float(x_axis[2]) - m.A12, m.A22, m.A32 = float(y_axis[0]), float(y_axis[1]), float(y_axis[2]) - m.A13, m.A23, m.A33 = float(z_axis[0]), float(z_axis[1]), float(z_axis[2]) - return App.Rotation(m) - - -def _sketch_placement_from_context(context): - origin = context.get('origin') if isinstance(context, dict) else None - base = _vec(origin) if isinstance(origin, (list, tuple)) and len(origin) == 3 else App.Vector(0.0, 0.0, 0.0) - return App.Placement(base, _rotation_from_context_axes(context)) - - -def _line_sketch_placement(start, end): - start_v = _vec(start) - end_v = _vec(end) - delta = App.Vector(end_v.x - start_v.x, end_v.y - start_v.y, end_v.z - start_v.z) - x_axis = _normalized_vec((delta.x, delta.y, delta.z)) - ref = App.Vector(0.0, 0.0, 1.0) - dot = abs(float(x_axis.x * ref.x + x_axis.y * ref.y + x_axis.z * ref.z)) - if dot > 0.95: - ref = App.Vector(0.0, 1.0, 0.0) - z_axis = x_axis.cross(ref) - z_len = float(getattr(z_axis, 'Length', 0.0)) - if z_len == 0.0: - ref = App.Vector(1.0, 0.0, 0.0) - z_axis = x_axis.cross(ref) - z_len = float(getattr(z_axis, 'Length', 0.0)) - z_axis = App.Vector(z_axis.x / z_len, z_axis.y / z_len, z_axis.z / z_len) - y_axis = z_axis.cross(x_axis) - y_len = float(getattr(y_axis, 'Length', 0.0)) - y_axis = App.Vector(y_axis.x / y_len, y_axis.y / y_len, y_axis.z / y_len) - m = App.Matrix() - m.A11, m.A21, m.A31 = x_axis.x, x_axis.y, x_axis.z - m.A12, m.A22, m.A32 = y_axis.x, y_axis.y, y_axis.z - m.A13, m.A23, m.A33 = z_axis.x, z_axis.y, z_axis.z - rotation = App.Rotation(m) - return App.Placement(start_v, rotation), float(getattr(delta, 'Length', 0.0)) - - -def _pick_perpendicular_axis(vec): - ref = App.Vector(0.0, 0.0, 1.0) - dot = abs(float(vec.x * ref.x + vec.y * ref.y + vec.z * ref.z)) - if dot > 0.95: - ref = App.Vector(0.0, 1.0, 0.0) - perp = vec.cross(ref) - length = float(getattr(perp, 'Length', 0.0)) - if length == 0.0: - ref = App.Vector(1.0, 0.0, 0.0) - perp = vec.cross(ref) - length = float(getattr(perp, 'Length', 0.0)) - return App.Vector(perp.x / length, perp.y / length, perp.z / length) - - -def _frame_from_points(points, fallback_context=None): - if not points: - raise RuntimeError('Expected at least one point for sketch frame') - origin = _vec(points[0]) - fallback_x = None - fallback_y = None - fallback_z = None - if isinstance(fallback_context, dict): - raw_x = fallback_context.get('x_axis') - raw_y = fallback_context.get('y_axis') - raw_z = fallback_context.get('z_axis') - if isinstance(raw_x, (list, tuple)) and len(raw_x) == 3: - try: - fallback_x = _normalized_vec(raw_x) - except Exception: - fallback_x = None - if isinstance(raw_y, (list, tuple)) and len(raw_y) == 3: - try: - fallback_y = _normalized_vec(raw_y) - except Exception: - fallback_y = None - if isinstance(raw_z, (list, tuple)) and len(raw_z) == 3: - try: - fallback_z = _normalized_vec(raw_z) - except Exception: - fallback_z = None - - if fallback_x is not None and fallback_y is not None and fallback_z is not None: - scale = 1.0 - on_fallback_plane = True - for point in points[1:]: - delta = App.Vector(float(point[0]) - origin.x, float(point[1]) - origin.y, float(point[2]) - origin.z) - scale = max(scale, float(getattr(delta, 'Length', 0.0))) - offset = abs(float(delta.x * fallback_z.x + delta.y * fallback_z.y + delta.z * fallback_z.z)) - if offset > max(1e-7, scale * 1e-7): - on_fallback_plane = False - break - if on_fallback_plane: - m = App.Matrix() - m.A11, m.A21, m.A31 = fallback_x.x, fallback_x.y, fallback_x.z - m.A12, m.A22, m.A32 = fallback_y.x, fallback_y.y, fallback_y.z - m.A13, m.A23, m.A33 = fallback_z.x, fallback_z.y, fallback_z.z - placement = App.Placement(origin, App.Rotation(m)) - return placement, origin, fallback_x, fallback_y - - x_axis = None - for point in points[1:]: - delta = App.Vector(float(point[0]) - origin.x, float(point[1]) - origin.y, float(point[2]) - origin.z) - length = float(getattr(delta, 'Length', 0.0)) - if length > 1e-9: - x_axis = App.Vector(delta.x / length, delta.y / length, delta.z / length) - break - if x_axis is None: - x_axis = fallback_x if fallback_x is not None else App.Vector(1.0, 0.0, 0.0) - - z_axis = None - for point in points[1:]: - delta = App.Vector(float(point[0]) - origin.x, float(point[1]) - origin.y, float(point[2]) - origin.z) - candidate = x_axis.cross(delta) - length = float(getattr(candidate, 'Length', 0.0)) - if length > 1e-9: - z_axis = App.Vector(candidate.x / length, candidate.y / length, candidate.z / length) - break - - if z_axis is None and fallback_z is not None: - dot = abs(float(x_axis.x * fallback_z.x + x_axis.y * fallback_z.y + x_axis.z * fallback_z.z)) - if dot < 0.95: - z_axis = fallback_z - - if z_axis is None: - z_axis = _pick_perpendicular_axis(x_axis) - - y_axis = z_axis.cross(x_axis) - y_len = float(getattr(y_axis, 'Length', 0.0)) - if y_len == 0.0: - y_axis = _pick_perpendicular_axis(z_axis) - y_len = float(getattr(y_axis, 'Length', 0.0)) - y_axis = App.Vector(y_axis.x / y_len, y_axis.y / y_len, y_axis.z / y_len) - - m = App.Matrix() - m.A11, m.A21, m.A31 = x_axis.x, x_axis.y, x_axis.z - m.A12, m.A22, m.A32 = y_axis.x, y_axis.y, y_axis.z - m.A13, m.A23, m.A33 = z_axis.x, z_axis.y, z_axis.z - placement = App.Placement(origin, App.Rotation(m)) - return placement, origin, x_axis, y_axis - - -def _local_point_on_frame(point, origin, x_axis, y_axis): - p = _vec(point) - dx = p.x - origin.x - dy = p.y - origin.y - dz = p.z - origin.z - return App.Vector( - dx * x_axis.x + dy * x_axis.y + dz * x_axis.z, - dx * y_axis.x + dy * y_axis.y + dz * y_axis.z, - 0.0, - ) - - -def _vec_tuple(vec): - return (float(vec.x), float(vec.y), float(vec.z)) - - -def _first_edge(obj): - shape = getattr(obj, 'Shape', None) if hasattr(obj, 'Shape') else obj - if shape is None or shape.isNull(): - raise RuntimeError(f'Object {getattr(obj, "Name", "")} has no valid shape') - edges = list(getattr(shape, 'Edges', [])) - if not edges: - raise RuntimeError(f'Object {getattr(obj, "Name", "")} has no edges') - return edges[0] - - -def _edge_start_point(obj): - edge = _first_edge(obj) - return _vec_tuple(edge.Vertexes[0].Point) - - -def _edge_end_point(obj): - edge = _first_edge(obj) - return _vec_tuple(edge.Vertexes[-1].Point) - - -def _edge_mid_point(obj): - edge = _first_edge(obj) - point = edge.valueAt(0.5 * (float(edge.FirstParameter) + float(edge.LastParameter))) - return _vec_tuple(point) - - -def _arc_from_edge(obj): - return Part.Arc(_vec(_edge_start_point(obj)), _vec(_edge_mid_point(obj)), _vec(_edge_end_point(obj))) - - -def _shape_from_graph_node(node_id): - value = GRAPH_NODES.get(node_id) - if value is None: - raise RuntimeError(f'Missing graph node {node_id!r}') - if isinstance(value, dict) and 'shape' in value: - return value['shape'] - if hasattr(value, 'Shape'): - shape = getattr(value, 'Shape', None) - else: - shape = value - try: - shape_invalid = shape is None or shape.isNull() - except Exception: - shape_invalid = shape is None - if shape_invalid: - try: - doc.recompute() - except Exception: - pass - if hasattr(value, 'Shape'): - shape = getattr(value, 'Shape', None) - if shape is None or shape.isNull(): - raise RuntimeError(f'Graph node {node_id!r} has no valid shape') - return shape - - -def _subshape_candidates_for_kind(shape, kind): - kind = str(kind).lower() - if kind == 'solid': - return list(getattr(shape, 'Solids', []) or [shape]) - if kind == 'face': - return list(getattr(shape, 'Faces', []) or []) - if kind == 'edge': - return list(getattr(shape, 'Edges', []) or []) - if kind == 'wire': - return list(getattr(shape, 'Wires', []) or []) - if kind == 'vertex': - return list(getattr(shape, 'Vertexes', []) or []) - return [] - - -def _point_tuple(point): - return (float(point.x), float(point.y), float(point.z)) - - -def _candidate_center(candidate): - center = getattr(candidate, 'CenterOfMass', None) - if center is not None: - return _point_tuple(center) - bound_box = getattr(candidate, 'BoundBox', None) - if bound_box is not None: - return ( - (float(bound_box.XMin) + float(bound_box.XMax)) / 2.0, - (float(bound_box.YMin) + float(bound_box.YMax)) / 2.0, - (float(bound_box.ZMin) + float(bound_box.ZMax)) / 2.0, - ) - return None - - -def _tuple3(value): - if isinstance(value, (list, tuple)) and len(value) == 3: - return (float(value[0]), float(value[1]), float(value[2])) - return None - - -def _dist3(a, b): - if a is None or b is None: - return 1e6 - return math.dist(a, b) - - -def _relative_scalar_delta(actual, expected, floor=1.0): - try: - actual_f = float(actual) - expected_f = float(expected) - except Exception: - return 1e6 - return abs(actual_f - expected_f) / max(abs(actual_f), abs(expected_f), float(floor)) - - -def _unit_tuple(value): - if value is None: - return None - length = math.sqrt(sum(float(v) * float(v) for v in value)) - if length <= 1e-12: - return None - return tuple(float(v) / length for v in value) - - -def _selector_bbox_diagonal(selector): - bbox = selector.get('bbox') if isinstance(selector, dict) else None - if not isinstance(bbox, dict): - return 1.0 - expected_min = _tuple3(bbox.get('min')) - expected_max = _tuple3(bbox.get('max')) - if expected_min is None or expected_max is None: - return 1.0 - return max(_dist3(expected_min, expected_max), 1.0) - - -def _candidate_face_normal(candidate): - try: - u_min, u_max, v_min, v_max = candidate.ParameterRange - normal = candidate.normalAt(0.5 * (float(u_min) + float(u_max)), 0.5 * (float(v_min) + float(v_max))) - return _unit_tuple(_point_tuple(normal)) - except Exception: - try: - normal = candidate.normalAt(0.0, 0.0) - return _unit_tuple(_point_tuple(normal)) - except Exception: - return None - - -def _candidate_geom_type(candidate): - try: - surface = getattr(candidate, 'Surface', None) - if surface is not None: - type_name = type(surface).__name__.replace('Part.', '').upper() - mapping = { - 'PLANE': 'PLANE', - 'CYLINDER': 'CYLINDER', - 'CONE': 'CONE', - 'SPHERE': 'SPHERE', - 'TORUS': 'TORUS', - 'BSPLINESURFACE': 'BSPLINE', - 'BEZIERSURFACE': 'BEZIER', - } - return mapping.get(type_name, type_name) - except Exception: - pass - try: - curve = getattr(candidate, 'Curve', None) - if curve is not None: - type_name = type(curve).__name__.replace('Part.', '').upper() - mapping = { - 'LINE': 'LINE', - 'LINESEGMENT': 'LINE', - 'CIRCLE': 'CIRCLE', - 'BSPLINECURVE': 'BSPLINE', - 'BEZIERCURVE': 'BEZIER', - } - return mapping.get(type_name, type_name) - except Exception: - pass - return None - - -def _bbox_selector_score(candidate, selector): - bbox = selector.get('bbox') if isinstance(selector, dict) else None - bound_box = getattr(candidate, 'BoundBox', None) - if not isinstance(bbox, dict) or bound_box is None: - return 0.0 - expected_min = _tuple3(bbox.get('min')) - expected_max = _tuple3(bbox.get('max')) - if expected_min is None or expected_max is None: - return 1e6 - actual_min = (float(bound_box.XMin), float(bound_box.YMin), float(bound_box.ZMin)) - actual_max = (float(bound_box.XMax), float(bound_box.YMax), float(bound_box.ZMax)) - return (_dist3(actual_min, expected_min) + _dist3(actual_max, expected_max)) / _selector_bbox_diagonal(selector) - - -def _geo_selector_score(candidate, selector, candidate_index): - score = _bbox_selector_score(candidate, selector) * 10.0 - expected_geom_type = str(selector.get('geom_type') or '').upper() - actual_geom_type = _candidate_geom_type(candidate) - if expected_geom_type and actual_geom_type and expected_geom_type != actual_geom_type: - score += 10.0 - kind = str(selector.get('kind', '')).lower() - if kind == 'edge': - if 'length' in selector and hasattr(candidate, 'Length'): - score += _relative_scalar_delta(candidate.Length, selector['length']) * 10.0 - score += (_dist3(_candidate_center(candidate), _tuple3(selector.get('center'))) / _selector_bbox_diagonal(selector)) * 10.0 - vertices = list(getattr(candidate, 'Vertexes', []) or []) - if len(vertices) >= 2: - start = _point_tuple(vertices[0].Point) - end = _point_tuple(vertices[-1].Point) - expected_start = _tuple3(selector.get('start')) - expected_end = _tuple3(selector.get('end')) - if expected_start is not None and expected_end is not None: - direct = _dist3(start, expected_start) + _dist3(end, expected_end) - reverse = _dist3(start, expected_end) + _dist3(end, expected_start) - score += min(direct, reverse) / max(float(candidate.Length), float(selector.get('length', 1.0)), 1.0) - elif kind == 'face': - if 'area' in selector and hasattr(candidate, 'Area'): - score += _relative_scalar_delta(candidate.Area, selector['area']) * 10.0 - score += (_dist3(_candidate_center(candidate), _tuple3(selector.get('center'))) / _selector_bbox_diagonal(selector)) * 10.0 - expected_normal = _unit_tuple(_tuple3(selector.get('normal'))) - actual_normal = _candidate_face_normal(candidate) - if expected_normal is not None and actual_normal is not None: - reversed_expected = tuple(-float(v) for v in expected_normal) - score += min(_dist3(actual_normal, expected_normal), _dist3(actual_normal, reversed_expected)) - if 'edge_count' in selector: - score += abs(len(list(getattr(candidate, 'Edges', []) or [])) - int(selector['edge_count'])) * 0.001 - if 'inner_wire_count' in selector: - score += abs(max(0, len(list(getattr(candidate, 'Wires', []) or [])) - 1) - int(selector['inner_wire_count'])) * 0.001 - elif kind == 'vertex': - point = getattr(candidate, 'Point', None) - if point is not None: - score += (_dist3(_point_tuple(point), _tuple3(selector.get('coordinates'))) / _selector_bbox_diagonal(selector)) * 10.0 - elif kind == 'wire': - edges = list(getattr(candidate, 'Edges', []) or []) - if 'edge_count' in selector: - score += abs(len(edges) - int(selector['edge_count'])) * 10.0 - elif kind == 'solid': - if 'volume' in selector and hasattr(candidate, 'Volume'): - score += _relative_scalar_delta(candidate.Volume, selector['volume']) * 10.0 - return score - - -def _selection_index_for_selector(source_shape, selector): - kind = str(selector.get('kind') or selector.get('target_kind') or '').lower() - candidates = _subshape_candidates_for_kind(source_shape, kind) - if not candidates: - raise RuntimeError(f'No {kind} candidates available for geo selection') - ranked = sorted( - enumerate(candidates), - key=lambda item: _geo_selector_score(item[1], selector, item[0]), - ) - best_index, best_candidate = ranked[0] - best_score = _geo_selector_score(best_candidate, selector, best_index) - if best_score > 1e-2: - second_score = None - if len(ranked) > 1: - second_index, second_candidate = ranked[1] - second_score = _geo_selector_score(second_candidate, selector, second_index) - suffix = '' if second_score is None else f', second score={second_score:.6g}' - raise RuntimeError(f'Geo selector did not match a stable {kind} candidate; best score={best_score:.6g}{suffix}') - return int(best_index) - - -def _register_geo_selection_node(*, node_id, op, params, inputs, tags, context, output_count, param_exprs=None, semantic_delta=None, topo_delta=None): - if not inputs: - raise RuntimeError(f'Selection node {node_id!r} is missing its source input') - selector = dict(params.get('geo_selector') or {}) - source_node_id = str(inputs[0]) - source_shape = _shape_from_graph_node(source_node_id) - index = _selection_index_for_selector(source_shape, selector) - candidates = _subshape_candidates_for_kind(source_shape, selector.get('kind')) - selected_shape = candidates[index] - payload = { - 'node_id': node_id, - 'op': op, - 'params': params, - 'inputs': list(inputs), - 'context': context or {}, - 'tags': list(tags or []), - 'output_count': int(output_count), - 'selector': selector, - 'index': int(index), - 'kind': str(selector.get('kind', '')), - 'shape': selected_shape, - } - obj = doc.addObject('Part::Feature', f'{str(op)}_{str(node_id)}') - obj.Shape = selected_shape - registered = _register_graph_object( - obj, - node_id=node_id, - op=op, - params=params, - inputs=inputs, - tags=tags, - context=context, - output_count=output_count, - param_exprs=param_exprs, - semantic_delta=semantic_delta, - topo_delta=topo_delta, - ) - GRAPH_SELECTIONS[node_id] = payload - return registered - - -def _selected_indices_from_nodes(node_ids, fallback_indices, base_shape=None, kind=None): - indices = [] - for node_id in node_ids or []: - payload = GRAPH_SELECTIONS.get(str(node_id)) or GRAPH_NODES.get(str(node_id)) - if isinstance(payload, dict) and base_shape is not None: - selector = dict(payload.get('selector') or payload.get('params', {}).get('geo_selector') or {}) - if kind is not None: - selector['kind'] = str(kind) - if selector: - indices.append(_selection_index_for_selector(base_shape, selector)) - continue - if isinstance(payload, dict) and 'index' in payload: - indices.append(int(payload['index'])) - if indices: - return indices - return [int(idx) for idx in (fallback_indices or [])] - - -def _local_line_from_edge(obj, origin, x_axis, y_axis): - start_3d = _edge_start_point(obj) - end_3d = _edge_end_point(obj) - start = _local_point_on_frame(start_3d, origin, x_axis, y_axis) - end = _local_point_on_frame(end_3d, origin, x_axis, y_axis) - projected_len = float((end - start).Length) - source_len = float((_vec(end_3d) - _vec(start_3d)).Length) - if source_len > 1e-9 and projected_len <= 1e-9: - raise RuntimeError('Projected non-zero edge collapsed to zero length; sketch frame is not coplanar with the source wire') - return Part.LineSegment( - start, - end, - ) - - -def _local_arc_from_edge(obj, origin, x_axis, y_axis): - return Part.Arc( - _local_point_on_frame(_edge_start_point(obj), origin, x_axis, y_axis), - _local_point_on_frame(_edge_mid_point(obj), origin, x_axis, y_axis), - _local_point_on_frame(_edge_end_point(obj), origin, x_axis, y_axis), - ) - - -def _angle_arc_axes(normal): - normal_vec = _normalized_vec(normal) - ref_vec = App.Vector(1.0, 0.0, 0.0) if abs(normal_vec.z) > 0.9 else App.Vector(0.0, 0.0, 1.0) - local_x = normal_vec.cross(ref_vec) - x_len = float(getattr(local_x, 'Length', 0.0)) - if x_len == 0.0: - ref_vec = App.Vector(0.0, 1.0, 0.0) - local_x = normal_vec.cross(ref_vec) - x_len = float(getattr(local_x, 'Length', 0.0)) - local_x = App.Vector(local_x.x / x_len, local_x.y / x_len, local_x.z / x_len) - local_y = normal_vec.cross(local_x) - y_len = float(getattr(local_y, 'Length', 0.0)) - local_y = App.Vector(local_y.x / y_len, local_y.y / y_len, local_y.z / y_len) - return local_x, local_y - - -def _angle_arc_world_point(circle_center, radius, angle, normal): - center = _vec(circle_center) - local_x, local_y = _angle_arc_axes(normal) - r = float(radius) - theta = float(angle) - return App.Vector( - center.x + r * math.cos(theta) * local_x.x + r * math.sin(theta) * local_y.x, - center.y + r * math.cos(theta) * local_x.y + r * math.sin(theta) * local_y.y, - center.z + r * math.cos(theta) * local_x.z + r * math.sin(theta) * local_y.z, - ) - - -def _angle_arc_curve(circle_center, radius, start_angle, end_angle, normal): - sa = float(start_angle) - ea = float(end_angle) - mid_angle = 0.5 * (sa + ea) - start_world = _angle_arc_world_point(circle_center, radius, sa, normal) - mid_world = _angle_arc_world_point(circle_center, radius, mid_angle, normal) - end_world = _angle_arc_world_point(circle_center, radius, ea, normal) - return Part.Arc(start_world, mid_world, end_world) - - -def _local_angle_arc(circle_center, radius, start_angle, end_angle, normal, origin, x_axis, y_axis): - sa = float(start_angle) - ea = float(end_angle) - mid_angle = 0.5 * (sa + ea) - start_local = _local_point_on_frame( - _vec_tuple(_angle_arc_world_point(circle_center, radius, sa, normal)), - origin, - x_axis, - y_axis, - ) - mid_local = _local_point_on_frame( - _vec_tuple(_angle_arc_world_point(circle_center, radius, mid_angle, normal)), - origin, - x_axis, - y_axis, - ) - end_local = _local_point_on_frame( - _vec_tuple(_angle_arc_world_point(circle_center, radius, ea, normal)), - origin, - x_axis, - y_axis, - ) - return Part.Arc(start_local, mid_local, end_local) - - -def _bspline_curve_from_params(params, transform_point=None): - poles = [] - for point in params.get('control_points') or []: - point3 = tuple(point) + (0.0,) if len(tuple(point)) == 2 else tuple(point) - pole = transform_point(point3) if transform_point is not None else _vec(point3) - poles.append(pole) - if not poles and params.get('points'): - for point in params.get('points') or []: - point3 = tuple(point) + (0.0,) if len(tuple(point)) == 2 else tuple(point) - pole = transform_point(point3) if transform_point is not None else _vec(point3) - poles.append(pole) - if len(poles) < 2: - raise RuntimeError('B-spline has fewer than two points') - curve = Part.BSplineCurve() - curve.interpolate(poles) - return curve - if not poles: - raise RuntimeError('B-spline has no control points') - mults = tuple(int(value) for value in (params.get('multiplicities') or [])) - knots = tuple(float(value) for value in (params.get('knots') or [])) - degree = int(params.get('degree', 3)) - periodic = bool(params.get('periodic', False)) - weights = params.get('weights') - curve = Part.BSplineCurve() - if weights is None: - curve.buildFromPolesMultsKnots(poles, mults, knots, periodic, degree) - else: - curve.buildFromPolesMultsKnots(poles, mults, knots, periodic, degree, tuple(float(value) for value in weights)) - return curve - - -def _wire_shape_from_edge_objects(node_ids): - shapes = [] - for node_id in node_ids: - shape = _shape_from_graph_node(node_id) - shapes.append(shape) - return Part.Wire(shapes) - - -def _shape_is_null(shape): - try: - return shape is None or shape.isNull() - except Exception: - return shape is None - - -def _spine_object(node_id): - node_id = str(node_id) - cached = GRAPH_SPINE_OBJECTS.get(node_id) - if cached is not None: - return cached - obj = GRAPH_NODES[node_id] - try: - shape = getattr(obj, 'Shape', None) - except Exception: - shape = None - if not _shape_is_null(shape): - return obj - meta = GRAPH_METADATA.get(node_id, {}) - if str(meta.get('op', '')) == 'make_wire_from_edges_rwire': - edge_ids = list(meta.get('inputs') or []) - if edge_ids: - fallback = doc.addObject('Part::Feature', f'make_spine_wire_{node_id}') - fallback.Shape = _wire_shape_from_edge_objects(edge_ids) - _set_visibility(fallback, False) - GRAPH_SPINE_OBJECTS[node_id] = fallback - return fallback - return obj - - -def _build_face_from_source(source_obj, name): - face_obj = doc.addObject('Part::Face', name) - face_obj.Sources = [source_obj] - return face_obj - - -def _make_metadata_note(name, title, payload): - obj = doc.addObject('App::FeaturePython', name) - _ensure_string_property(obj, 'Title') - _ensure_string_property(obj, 'Payload') - obj.Title = title - obj.Payload = json.dumps(payload, ensure_ascii=True, sort_keys=True) - return obj - - -def _register_ir_node(name, *, node_id, op, params, inputs, tags, context, output_count, param_exprs=None, semantic_delta=None, topo_delta=None): - return _register_graph_metadata_only( - node_id=node_id, - op=op, - params=params, - inputs=inputs, - tags=tags, - context=context, - output_count=output_count, - param_exprs=param_exprs, - semantic_delta=semantic_delta, - topo_delta=topo_delta, - ) - - -def _placement_from_frame(origin, x_axis, y_axis, z_axis): - m = App.Matrix() - m.A11, m.A21, m.A31 = x_axis.x, x_axis.y, x_axis.z - m.A12, m.A22, m.A32 = y_axis.x, y_axis.y, y_axis.z - m.A13, m.A23, m.A33 = z_axis.x, z_axis.y, z_axis.z - return App.Placement(origin, App.Rotation(m)) - - -def _sketch_plane_frame(plane): - if isinstance(plane, str): - token = plane.upper() - if token == 'XY': - origin = App.Vector(0.0, 0.0, 0.0) - x_axis = App.Vector(1.0, 0.0, 0.0) - y_axis = App.Vector(0.0, 1.0, 0.0) - z_axis = App.Vector(0.0, 0.0, 1.0) - return origin, x_axis, y_axis, z_axis - if token == 'XZ': - origin = App.Vector(0.0, 0.0, 0.0) - x_axis = App.Vector(1.0, 0.0, 0.0) - y_axis = App.Vector(0.0, 0.0, 1.0) - z_axis = App.Vector(0.0, -1.0, 0.0) - return origin, x_axis, y_axis, z_axis - if token == 'YZ': - origin = App.Vector(0.0, 0.0, 0.0) - x_axis = App.Vector(0.0, 1.0, 0.0) - y_axis = App.Vector(0.0, 0.0, 1.0) - z_axis = App.Vector(1.0, 0.0, 0.0) - return origin, x_axis, y_axis, z_axis - if isinstance(plane, dict): - origin = _vec(plane.get('origin', (0.0, 0.0, 0.0))) - x_axis = _normalized_vec(plane.get('x_axis', (1.0, 0.0, 0.0))) - y_axis = _normalized_vec(plane.get('y_axis', (0.0, 1.0, 0.0))) - z_axis = x_axis.cross(y_axis) - z_len = float(getattr(z_axis, 'Length', 0.0)) - if z_len == 0.0: - raise RuntimeError('Sketch plane x_axis and y_axis must not be parallel') - z_axis = App.Vector(z_axis.x / z_len, z_axis.y / z_len, z_axis.z / z_len) - y_axis = z_axis.cross(x_axis) - y_len = float(getattr(y_axis, 'Length', 0.0)) - y_axis = App.Vector(y_axis.x / y_len, y_axis.y / y_len, y_axis.z / y_len) - return origin, x_axis, y_axis, z_axis - raise RuntimeError(f'Unsupported sketch plane payload: {plane!r}') - - -def _sketch_entity_maps(sketch_payload): - entities = list(sketch_payload.get('entities') or []) if isinstance(sketch_payload, dict) else [] - return entities, {str(entity.get('id')): entity for entity in entities if isinstance(entity, dict) and entity.get('id') is not None} - - -def _sketch_solved_point(point_id, sketch_payload, solve_snapshot): - solved = (solve_snapshot or {}).get('solved_points', {}) if isinstance(solve_snapshot, dict) else {} - if point_id in solved: - point = solved[point_id] - return (float(point[0]), float(point[1])) - _entities, by_id = _sketch_entity_maps(sketch_payload) - entity = by_id.get(str(point_id)) - if isinstance(entity, dict) and entity.get('kind') == 'point': - return (float(entity.get('x', 0.0)), float(entity.get('y', 0.0))) - raise RuntimeError(f'Missing solved sketch point {point_id!r}') - - -def _sketch_solved_radius(entity_id, entity, solve_snapshot): - key = f'circle:{entity_id}:radius' - scalars = (solve_snapshot or {}).get('solved_scalars', {}) if isinstance(solve_snapshot, dict) else {} - if key in scalars: - return float(scalars[key]) - return float(entity.get('radius', 0.0)) - - -def _sketch_profile_entity_ids(params, sketch_payload): - promotion_map = params.get('promotion_map') if isinstance(params, dict) else None - if isinstance(promotion_map, dict): - edges = promotion_map.get('edges') or [] - ids = [str(edge.get('entity_id')) for edge in edges if isinstance(edge, dict) and edge.get('entity_id') is not None] - if ids: - return ids - entities, _by_id = _sketch_entity_maps(sketch_payload) - return [ - str(entity.get('id')) - for entity in entities - if entity.get('kind') in {'line', 'circle', 'arc', 'bspline'} and not bool(entity.get('construction', False)) - ] - - -def _sketch_world_point(point_id, sketch_payload, solve_snapshot, origin, x_axis, y_axis): - x, y = _sketch_solved_point(point_id, sketch_payload, solve_snapshot) - return App.Vector( - origin.x + x * x_axis.x + y * y_axis.x, - origin.y + x * x_axis.y + y * y_axis.y, - origin.z + x * x_axis.z + y * y_axis.z, - ) - - -def _sketch_xy_to_world(x, y, origin, x_axis, y_axis): - # Convert raw 2-D sketch coordinates to a 3-D world App.Vector. - return App.Vector( - origin.x + x * x_axis.x + y * y_axis.x, - origin.y + x * x_axis.y + y * y_axis.y, - origin.z + x * x_axis.z + y * y_axis.z, - ) - - -def _sketch_local_point(point_id, sketch_payload, solve_snapshot): - x, y = _sketch_solved_point(point_id, sketch_payload, solve_snapshot) - return App.Vector(x, y, 0.0) - - -def _sketch_wire_shape_from_promotion(params): - sketch_payload = params.get('sketch') or {} - solve_snapshot = params.get('solve_snapshot') or {} - origin, x_axis, y_axis, z_axis = _sketch_plane_frame(sketch_payload.get('plane', 'XY')) - _entities, by_id = _sketch_entity_maps(sketch_payload) - edge_shapes = [] - for entity_id in _sketch_profile_entity_ids(params, sketch_payload): - entity = by_id.get(str(entity_id)) - if not isinstance(entity, dict): - continue - kind = str(entity.get('kind')) - if kind == 'line': - start = _sketch_world_point(str(entity.get('start')), sketch_payload, solve_snapshot, origin, x_axis, y_axis) - end = _sketch_world_point(str(entity.get('end')), sketch_payload, solve_snapshot, origin, x_axis, y_axis) - edge_shapes.append(Part.LineSegment(start, end).toShape()) - elif kind == 'circle': - center = _sketch_world_point(str(entity.get('center')), sketch_payload, solve_snapshot, origin, x_axis, y_axis) - edge_shapes.append(Part.Circle(center, z_axis, _sketch_solved_radius(str(entity_id), entity, solve_snapshot)).toShape()) - elif kind == 'arc': - start = _sketch_world_point(str(entity.get('start')), sketch_payload, solve_snapshot, origin, x_axis, y_axis) - end = _sketch_world_point(str(entity.get('end')), sketch_payload, solve_snapshot, origin, x_axis, y_axis) - center = _sketch_world_point(str(entity.get('center')), sketch_payload, solve_snapshot, origin, x_axis, y_axis) - import math as _math - radius = _math.hypot(start.x - center.x, start.y - center.y) - edge_shapes.append(Part.ArcOfCircle(Part.Circle(center, z_axis, radius), _math.atan2(start.y - center.y, start.x - center.x), _math.atan2(end.y - center.y, end.x - center.x)).toShape()) - elif kind == 'bspline': - cps_data = entity.get('control_points', []) - degree = int(entity.get('degree', 3)) - knots = entity.get('knots') - mults = entity.get('multiplicities') - weights = entity.get('weights') - periodic = bool(entity.get('periodic', False)) - cps = [_sketch_xy_to_world(float(p[0]), float(p[1]), origin, x_axis, y_axis) for p in cps_data] - curve = Part.BSplineCurve() - if weights: - curve.buildFromPolesMultsKnots(cps, mults, knots, periodic, degree, weights) - else: - curve.buildFromPolesMultsKnots(cps, mults, knots, periodic, degree) - edge_shapes.append(curve.toShape()) - if not edge_shapes: - raise RuntimeError('Sketch promotion has no profile geometry to materialize') - return Part.Wire(edge_shapes) - - -def _sketch_entity_expr(param_exprs, entity_index, *path): - expr_meta = _nested_expr_ref(param_exprs or {}, 'sketch', 'entities', int(entity_index)) - if expr_meta is None: - return None - return _nested_expr_ref(expr_meta, *path) - - -def _sketch_constraint_value_expr(param_exprs, constraint_index): - return _nested_expr_ref(param_exprs or {}, 'sketch', 'constraints', int(constraint_index), 'value') - - -def _sketch_constraint_status_append(status, source, mapped, **payload): - entry = {'id': source.get('id') if isinstance(source, dict) else None, 'kind': source.get('kind') if isinstance(source, dict) else None} - entry.update(payload) - status['mapped' if mapped else 'skipped'].append(entry) - - -def _sketch_constraint_priority(item): - _index, constraint = item - kind = str(constraint.get('kind')) if isinstance(constraint, dict) else '' - priorities = { - 'coincident': 0, - 'connect': 0, - 'point_on': 1, - 'fix': 2, - 'horizontal': 3, - 'vertical': 3, - 'distance': 4, - 'distance_x': 4, - 'distance_y': 4, - 'length': 4, - 'radius': 4, - 'diameter': 4, - 'angle': 4, - 'parallel': 5, - 'perpendicular': 5, - 'collinear': 5, - 'tangent': 5, - 'concentric': 5, - 'equal_length': 5, - 'equal_radius': 5, - 'midpoint': 6, - 'symmetric': 6, - } - return priorities.get(kind, 9), int(_index) - - -def _validate_sketch_constraint(sketch_obj, idx): - try: - result = sketch_obj.solve() - except Exception as exc: - return False, f'FreeCAD Sketcher solver raised {exc!r}' - try: - result_int = int(result) - except Exception: - return True, '' - if result_int < 0: - return False, f'FreeCAD Sketcher solver rejected constraint with result {result_int}' - return True, '' - - -def _remove_sketch_constraint(sketch_obj, idx): - try: - if hasattr(sketch_obj, 'setExpression'): - sketch_obj.setExpression(f'Constraints[{int(idx)}]', None) - except Exception: - pass - try: - sketch_obj.delConstraint(int(idx)) - except Exception: - pass - - -def _safe_add_sketch_constraint(sketch_obj, status, source, freecad_kind, *args, expr_ref=None, synthetic=False): - if Sketcher is None: - _sketch_constraint_status_append(status, source, False, freecad_kind=freecad_kind, reason='Sketcher module is unavailable', synthetic=bool(synthetic)) - return None - try: - idx = sketch_obj.addConstraint(Sketcher.Constraint(freecad_kind, *args)) - except Exception as exc: - _sketch_constraint_status_append(status, source, False, freecad_kind=freecad_kind, reason=str(exc), synthetic=bool(synthetic)) - return None - if expr_ref is not None: - _bind_expression(sketch_obj, f'Constraints[{int(idx)}]', expr_ref) - ok, reason = _validate_sketch_constraint(sketch_obj, idx) - if not ok: - _remove_sketch_constraint(sketch_obj, idx) - _sketch_constraint_status_append(status, source, False, freecad_kind=freecad_kind, reason=reason, synthetic=bool(synthetic)) - return None - serializable_args = [int(arg) if isinstance(arg, int) and not isinstance(arg, bool) else arg for arg in args] - _sketch_constraint_status_append(status, source, True, freecad_kind=freecad_kind, index=int(idx), args=serializable_args, synthetic=bool(synthetic)) - return int(idx) - - -def _target_point_id(target, by_id): - if not isinstance(target, dict): - return None - entity_id = str(target.get('entity_id')) - subentity = str(target.get('subentity', 'geometry')) - entity = by_id.get(entity_id) - if not isinstance(entity, dict): - return None - kind = str(entity.get('kind')) - if kind == 'point': - return entity_id - if kind == 'line' and subentity in {'start', 'end'}: - return str(entity.get(subentity)) - if kind == 'circle' and subentity == 'center': - return str(entity.get('center')) - return None - - -def _target_point_ref(target, by_id, point_refs): - point_id = _target_point_id(target, by_id) - if point_id is None: - return None - refs = point_refs.get(point_id) or [] - return refs[0] if refs else None - - -def _target_entity_ref(target, by_id, geom_by_entity): - if not isinstance(target, dict): - return None - entity_id = str(target.get('entity_id')) - entity = by_id.get(entity_id) - if not isinstance(entity, dict): - return None - geom_index = geom_by_entity.get(entity_id) - if geom_index is None: - return None - return int(geom_index), str(entity.get('kind')) - - -def _fix_point_constraint(sketch_obj, status, source, point_ref, x_value, y_value, x_expr=None, y_expr=None): - if point_ref is None: - _sketch_constraint_status_append(status, source, False, reason='Target point is not represented by safe Sketcher geometry') - return - geom_index, pos = point_ref - _safe_add_sketch_constraint(sketch_obj, status, source, 'DistanceX', int(geom_index), int(pos), float(x_value), expr_ref=x_expr) - _safe_add_sketch_constraint(sketch_obj, status, source, 'DistanceY', int(geom_index), int(pos), float(y_value), expr_ref=y_expr) - - -def _materialize_sketch_constraints(sketch_obj, sketch_payload, params, param_exprs, geom_by_entity, point_refs): - status = {'mapped': [], 'skipped': []} - entities, by_id = _sketch_entity_maps(sketch_payload) - entity_index_by_id = {str(entity.get('id')): idx for idx, entity in enumerate(entities)} - solve_snapshot = params.get('solve_snapshot') or {} - - for point_id, refs in sorted(point_refs.items()): - if len(refs) < 2: - continue - first_geom, first_pos = refs[0] - for geom_index, pos in refs[1:]: - _safe_add_sketch_constraint( - sketch_obj, - status, - {'id': f'point_identity:{point_id}', 'kind': 'coincident'}, - 'Coincident', - int(first_geom), - int(first_pos), - int(geom_index), - int(pos), - synthetic=True, - ) - - constraints = list(sketch_payload.get('constraints') or []) if isinstance(sketch_payload, dict) else [] - for constraint_index, constraint in sorted(enumerate(constraints), key=_sketch_constraint_priority): - if not isinstance(constraint, dict): - continue - kind = str(constraint.get('kind')) - targets = list(constraint.get('targets') or []) - value = constraint.get('value') - value_expr = _sketch_constraint_value_expr(param_exprs, constraint_index) - - if kind in {'coincident', 'connect'} and len(targets) == 2: - a = _target_point_ref(targets[0], by_id, point_refs) - b = _target_point_ref(targets[1], by_id, point_refs) - if a is None or b is None: - _sketch_constraint_status_append(status, constraint, False, reason='Coincident target is not represented by safe Sketcher geometry') - continue - _safe_add_sketch_constraint(sketch_obj, status, constraint, 'Coincident', int(a[0]), int(a[1]), int(b[0]), int(b[1])) - continue - - if kind == 'point_on' and len(targets) == 2: - point_ref = _target_point_ref(targets[0], by_id, point_refs) - entity_ref = _target_entity_ref(targets[1], by_id, geom_by_entity) - if point_ref is None or entity_ref is None: - _sketch_constraint_status_append(status, constraint, False, reason='Point-on target is not represented by safe Sketcher geometry') - continue - _safe_add_sketch_constraint(sketch_obj, status, constraint, 'PointOnObject', int(point_ref[0]), int(point_ref[1]), int(entity_ref[0])) - continue - - if kind in {'horizontal', 'vertical'} and len(targets) == 1: - entity_ref = _target_entity_ref(targets[0], by_id, geom_by_entity) - if entity_ref is None or entity_ref[1] != 'line': - _sketch_constraint_status_append(status, constraint, False, reason=f'{kind} requires a materialized line') - continue - _safe_add_sketch_constraint(sketch_obj, status, constraint, 'Horizontal' if kind == 'horizontal' else 'Vertical', int(entity_ref[0])) - continue - - if kind in {'parallel', 'perpendicular', 'equal_length', 'angle'} and len(targets) == 2: - a = _target_entity_ref(targets[0], by_id, geom_by_entity) - b = _target_entity_ref(targets[1], by_id, geom_by_entity) - if a is None or b is None or a[1] != 'line' or b[1] != 'line': - _sketch_constraint_status_append(status, constraint, False, reason=f'{kind} requires two materialized lines') - continue - if kind == 'parallel': - _safe_add_sketch_constraint(sketch_obj, status, constraint, 'Parallel', int(a[0]), int(b[0])) - elif kind == 'perpendicular': - _safe_add_sketch_constraint(sketch_obj, status, constraint, 'Perpendicular', int(a[0]), int(b[0])) - elif kind == 'equal_length': - _safe_add_sketch_constraint(sketch_obj, status, constraint, 'Equal', int(a[0]), int(b[0])) - else: - _safe_add_sketch_constraint(sketch_obj, status, constraint, 'Angle', int(a[0]), int(b[0]), float(value), expr_ref=value_expr) - continue - - if kind == 'collinear' and len(targets) == 2: - a = _target_entity_ref(targets[0], by_id, geom_by_entity) - b_target = targets[1] - b = _target_entity_ref(b_target, by_id, geom_by_entity) - if a is None or b is None or a[1] != 'line' or b[1] != 'line': - _sketch_constraint_status_append(status, constraint, False, reason='collinear requires two materialized lines') - continue - _safe_add_sketch_constraint(sketch_obj, status, constraint, 'Parallel', int(a[0]), int(b[0])) - b_entity = by_id.get(str(b_target.get('entity_id'))) - if isinstance(b_entity, dict): - for point_id in (str(b_entity.get('start')), str(b_entity.get('end'))): - refs = point_refs.get(point_id) or [] - if refs: - _safe_add_sketch_constraint(sketch_obj, status, constraint, 'PointOnObject', int(refs[0][0]), int(refs[0][1]), int(a[0])) - continue - - if kind in {'equal_radius', 'concentric'} and len(targets) == 2: - a = _target_entity_ref(targets[0], by_id, geom_by_entity) - b = _target_entity_ref(targets[1], by_id, geom_by_entity) - if a is None or b is None or a[1] != 'circle' or b[1] != 'circle': - _sketch_constraint_status_append(status, constraint, False, reason=f'{kind} requires two materialized circles') - continue - if kind == 'equal_radius': - _safe_add_sketch_constraint(sketch_obj, status, constraint, 'Equal', int(a[0]), int(b[0])) - else: - _safe_add_sketch_constraint(sketch_obj, status, constraint, 'Coincident', int(a[0]), 3, int(b[0]), 3) - continue - - if kind == 'tangent' and len(targets) == 2: - a = _target_entity_ref(targets[0], by_id, geom_by_entity) - b = _target_entity_ref(targets[1], by_id, geom_by_entity) - if a is None or b is None or a[1] not in {'line', 'circle'} or b[1] not in {'line', 'circle'}: - _sketch_constraint_status_append(status, constraint, False, reason='tangent requires two materialized line/circle entities') - continue - _safe_add_sketch_constraint(sketch_obj, status, constraint, 'Tangent', int(a[0]), int(b[0])) - continue - - if kind in {'distance', 'distance_x', 'distance_y'} and len(targets) == 2: - a = _target_point_ref(targets[0], by_id, point_refs) - b = _target_point_ref(targets[1], by_id, point_refs) - if a is None or b is None: - _sketch_constraint_status_append(status, constraint, False, reason=f'{kind} target is not represented by safe Sketcher geometry') - continue - fc_kind = {'distance': 'Distance', 'distance_x': 'DistanceX', 'distance_y': 'DistanceY'}[kind] - _safe_add_sketch_constraint(sketch_obj, status, constraint, fc_kind, int(a[0]), int(a[1]), int(b[0]), int(b[1]), float(value), expr_ref=value_expr) - continue - - if kind == 'length' and len(targets) == 1: - entity_ref = _target_entity_ref(targets[0], by_id, geom_by_entity) - if entity_ref is None or entity_ref[1] != 'line': - _sketch_constraint_status_append(status, constraint, False, reason='length requires a materialized line') - continue - _safe_add_sketch_constraint(sketch_obj, status, constraint, 'Distance', int(entity_ref[0]), float(value), expr_ref=value_expr) - continue - - if kind in {'radius', 'diameter'} and len(targets) == 1: - entity_ref = _target_entity_ref(targets[0], by_id, geom_by_entity) - if entity_ref is None or entity_ref[1] != 'circle': - _sketch_constraint_status_append(status, constraint, False, reason=f'{kind} requires a materialized circle') - continue - _safe_add_sketch_constraint(sketch_obj, status, constraint, 'Radius' if kind == 'radius' else 'Diameter', int(entity_ref[0]), float(value), expr_ref=value_expr) - continue - - if kind == 'fix' and len(targets) == 1: - target = targets[0] - entity_id = str(target.get('entity_id')) if isinstance(target, dict) else '' - entity = by_id.get(entity_id) - if not isinstance(entity, dict): - _sketch_constraint_status_append(status, constraint, False, reason='fix target entity is missing') - continue - entity_kind = str(entity.get('kind')) - if entity_kind == 'point': - point_id = entity_id - x_value, y_value = _sketch_solved_point(point_id, sketch_payload, solve_snapshot) - expr_meta = _sketch_entity_expr(param_exprs, entity_index_by_id[point_id]) - _fix_point_constraint(sketch_obj, status, constraint, _target_point_ref(target, by_id, point_refs), x_value, y_value, _nested_expr_ref(expr_meta, 'x'), _nested_expr_ref(expr_meta, 'y')) - elif entity_kind == 'line': - for point_key in ('start', 'end'): - point_id = str(entity.get(point_key)) - x_value, y_value = _sketch_solved_point(point_id, sketch_payload, solve_snapshot) - point_entity_index = entity_index_by_id.get(point_id) - expr_meta = _sketch_entity_expr(param_exprs, point_entity_index) if point_entity_index is not None else None - refs = point_refs.get(point_id) or [] - _fix_point_constraint(sketch_obj, status, constraint, refs[0] if refs else None, x_value, y_value, _nested_expr_ref(expr_meta, 'x'), _nested_expr_ref(expr_meta, 'y')) - elif entity_kind == 'circle': - center_id = str(entity.get('center')) - x_value, y_value = _sketch_solved_point(center_id, sketch_payload, solve_snapshot) - center_entity_index = entity_index_by_id.get(center_id) - expr_meta = _sketch_entity_expr(param_exprs, center_entity_index) if center_entity_index is not None else None - refs = point_refs.get(center_id) or [] - _fix_point_constraint(sketch_obj, status, constraint, refs[0] if refs else None, x_value, y_value, _nested_expr_ref(expr_meta, 'x'), _nested_expr_ref(expr_meta, 'y')) - entity_ref = _target_entity_ref(target, by_id, geom_by_entity) - if entity_ref is not None: - circle_index = entity_index_by_id.get(entity_id) - radius_expr = _nested_expr_ref(_sketch_entity_expr(param_exprs, circle_index) if circle_index is not None else None, 'radius') - _safe_add_sketch_constraint(sketch_obj, status, constraint, 'Radius', int(entity_ref[0]), _sketch_solved_radius(entity_id, entity, solve_snapshot), expr_ref=radius_expr) - else: - _sketch_constraint_status_append(status, constraint, False, reason=f'Cannot fix unsupported sketch entity kind {entity_kind!r}') - continue - - if kind in {'midpoint', 'symmetric'}: - _sketch_constraint_status_append(status, constraint, False, reason=f'{kind} has no crash-safe FreeCAD Sketcher mapping in this translator') - continue - - _sketch_constraint_status_append(status, constraint, False, reason=f'Unsupported sketch constraint kind {kind!r}') - return status - - -def _attach_sketch_promotion_metadata(obj, params, constraint_status): - _ensure_string_property(obj, 'SimpleCADSketch') - _ensure_string_property(obj, 'SimpleCADSketchSolve') - _ensure_string_property(obj, 'SimpleCADSketchPromotion') - _ensure_string_property(obj, 'SimpleCADSketchConstraints') - obj.SimpleCADSketch = json.dumps(params.get('sketch') or {}, ensure_ascii=True, sort_keys=True) - obj.SimpleCADSketchSolve = json.dumps(params.get('solve_snapshot') or {}, ensure_ascii=True, sort_keys=True) - obj.SimpleCADSketchPromotion = json.dumps(params.get('promotion_map') or {}, ensure_ascii=True, sort_keys=True) - obj.SimpleCADSketchConstraints = json.dumps(constraint_status or {}, ensure_ascii=True, sort_keys=True) - - -def _make_sketch_promotion_object(name, *, node_id, op, params, inputs, tags, context, output_count, param_exprs=None, semantic_delta=None, topo_delta=None): - sketch_payload = params.get('sketch') or {} - solve_snapshot = params.get('solve_snapshot') or {} - if Sketcher is None: - obj = doc.addObject('Part::Feature', name) - obj.Shape = _sketch_wire_shape_from_promotion(params) - constraint_status = {'mapped': [], 'skipped': [{'reason': 'Sketcher module is unavailable'}]} - _attach_sketch_promotion_metadata(obj, params, constraint_status) - return _register_graph_object( - obj, - node_id=node_id, - op=op, - params=params, - inputs=inputs, - tags=tags, - context=context, - output_count=output_count, - param_exprs=param_exprs, - semantic_delta=semantic_delta, - topo_delta=topo_delta, - ) - - obj = doc.addObject('Sketcher::SketchObject', name) - origin, x_axis, y_axis, z_axis = _sketch_plane_frame(sketch_payload.get('plane', 'XY')) - obj.Placement = _placement_from_frame(origin, x_axis, y_axis, z_axis) - profile_ids = set(_sketch_profile_entity_ids(params, sketch_payload)) - entities, _by_id = _sketch_entity_maps(sketch_payload) - geom_by_entity = {} - point_refs = {} - - for entity in entities: - entity_id = str(entity.get('id')) - kind = str(entity.get('kind')) - construction = bool(entity.get('construction', False)) or (kind in {'line', 'circle', 'arc', 'bspline'} and entity_id not in profile_ids) - if kind == 'line': - start_id = str(entity.get('start')) - end_id = str(entity.get('end')) - start = _sketch_local_point(start_id, sketch_payload, solve_snapshot) - end = _sketch_local_point(end_id, sketch_payload, solve_snapshot) - geom_index = int(obj.addGeometry(Part.LineSegment(start, end), construction)) - geom_by_entity[entity_id] = geom_index - point_refs.setdefault(start_id, []).append((geom_index, 1)) - point_refs.setdefault(end_id, []).append((geom_index, 2)) - elif kind == 'circle': - center_id = str(entity.get('center')) - center = _sketch_local_point(center_id, sketch_payload, solve_snapshot) - radius = _sketch_solved_radius(entity_id, entity, solve_snapshot) - geom_index = int(obj.addGeometry(Part.Circle(center, App.Vector(0.0, 0.0, 1.0), radius), construction)) - geom_by_entity[entity_id] = geom_index - point_refs.setdefault(center_id, []).append((geom_index, 3)) - elif kind == 'arc': - start_id = str(entity.get('start')) - end_id = str(entity.get('end')) - center_id = str(entity.get('center')) - start = _sketch_local_point(start_id, sketch_payload, solve_snapshot) - end = _sketch_local_point(end_id, sketch_payload, solve_snapshot) - center = _sketch_local_point(center_id, sketch_payload, solve_snapshot) - import math as _math - radius = _math.hypot(start.x - center.x, start.y - center.y) - arc = Part.ArcOfCircle(Part.Circle(center, App.Vector(0.0, 0.0, 1.0), radius), _math.atan2(start.y - center.y, start.x - center.x), _math.atan2(end.y - center.y, end.x - center.x)) - geom_index = int(obj.addGeometry(arc, construction)) - geom_by_entity[entity_id] = geom_index - point_refs.setdefault(start_id, []).append((geom_index, 1)) - point_refs.setdefault(end_id, []).append((geom_index, 2)) - elif kind == 'bspline': - cps_data = entity.get('control_points', []) - degree = int(entity.get('degree', 3)) - knots = entity.get('knots') - mults = entity.get('multiplicities') - weights = entity.get('weights') - periodic = bool(entity.get('periodic', False)) - cps = [App.Vector(float(p[0]), float(p[1]), 0.0) for p in cps_data] - curve = Part.BSplineCurve() - if weights: - curve.buildFromPolesMultsKnots(cps, mults, knots, periodic, degree, weights) - else: - curve.buildFromPolesMultsKnots(cps, mults, knots, periodic, degree) - geom_index = int(obj.addGeometry(curve, construction)) - geom_by_entity[entity_id] = geom_index - start_id = str(entity.get('start')) - end_id = str(entity.get('end')) - point_refs.setdefault(start_id, []).append((geom_index, 1)) - point_refs.setdefault(end_id, []).append((geom_index, 2)) - - if not geom_by_entity: - raise RuntimeError('Sketch promotion contains no materialized line or circle geometry') - constraint_status = _materialize_sketch_constraints(obj, sketch_payload, params, param_exprs or {}, geom_by_entity, point_refs) - try: - obj.solve() - except Exception: - pass - try: - doc.recompute() - except Exception: - pass - _attach_sketch_promotion_metadata(obj, params, constraint_status) - _attach_simplecad_metadata( - obj, - node_id=node_id, - op=op, - params=params, - inputs=inputs, - tags=tags, - context=context, - output_count=output_count, - param_exprs=param_exprs, - semantic_delta=semantic_delta, - topo_delta=topo_delta, - ) - SKETCH_REGISTRY.append({'node_id': node_id, 'op': op, 'object': obj.Name, 'constraint_status': constraint_status}) - registered = _register_graph_object( - obj, - node_id=node_id, - op=op, - params=params, - inputs=inputs, - tags=tags, - context=context, - output_count=output_count, - param_exprs=param_exprs, - semantic_delta=semantic_delta, - topo_delta=topo_delta, - ) - return registered - - -def _sanitize_expr_alias(expr_id, prefix='expr'): - alias = ''.join(ch if str(ch).isalnum() else '_' for ch in str(expr_id)).strip('_') - if not alias: - alias = prefix - if alias[0].isdigit(): - alias = prefix + '_' + alias - return alias[:64] - - -def _expr_alias(expr_id): - alias = EXPR_ALIAS_BY_ID.get(expr_id) - if alias: - return alias - return _sanitize_expr_alias(expr_id) - - -def _resolve_expr_ref(expr_ref): - if not isinstance(expr_ref, dict): - return None - expr_id = expr_ref.get('expr_id') - if not expr_id or 'expr_sheet' not in globals() or expr_sheet is None: - return None - alias = _expr_alias(expr_id) - try: - return float(expr_sheet.get(alias)) - except Exception: - cell = EXPR_CELL_BY_ID.get(expr_id) - if not cell: - return None - try: - return float(expr_sheet.get(cell)) - except Exception: - return None - - -def _expr_ref_to_freecad_expr(expr_ref): - if not isinstance(expr_ref, dict): - return None - expr_id = expr_ref.get('expr_id') - if not expr_id or 'expr_sheet' not in globals() or expr_sheet is None: - return None - if expr_id not in EXPR_CELL_BY_ID: - return None - return f"<>.{_expr_alias(expr_id)}" - - -def _nested_expr_ref(expr_meta, *path): - value = expr_meta - for key in path: - if isinstance(value, dict) and key in value: - value = value[key] - elif isinstance(value, list) and isinstance(key, int) and 0 <= key < len(value): - value = value[key] - else: - return None - return value - - -def _bind_expression(obj, prop_name, expr_ref): - if isinstance(expr_ref, str): - expr = expr_ref - else: - expr = _expr_ref_to_freecad_expr(expr_ref) - if not expr or not hasattr(obj, 'setExpression'): - return False - try: - obj.setExpression(prop_name, expr) - return True - except Exception: - return False - - -def _bind_expression_from_param(obj, prop_name, param_exprs, *path): - return _bind_expression(obj, prop_name, _nested_expr_ref(param_exprs, *path)) - - -def _apply_op_expression_bindings(obj, op_name, param_exprs): - for prop_name, path in OP_EXPRESSION_BINDINGS.get(str(op_name), ()): - _bind_expression_from_param(obj, prop_name, param_exprs, *path) - - -def _apply_sketch_expression_bindings(obj, bindings): - for prop_name, expr_ref in bindings or []: - _bind_expression(obj, prop_name, expr_ref) - - -def _expr_formula_from_ref(expr_ref): - expr = _expr_ref_to_freecad_expr(expr_ref) - return expr if expr else None - - -def _formula_nested_value(params, param_exprs, *path): - expr = _expr_formula_from_ref(_nested_expr_ref(param_exprs, *path)) - if expr is not None: - return expr - try: - value = params - for key in path: - value = value[key] - return repr(float(value)) - except Exception: - return None - - -def _formula_scale(expr, coeff): - coeff_value = float(coeff) - if abs(coeff_value) <= 1e-12: - return None - if abs(coeff_value - 1.0) <= 1e-12: - return expr - if abs(coeff_value + 1.0) <= 1e-12: - return f'-({expr})' - return f'({expr}) * ({repr(coeff_value)})' - - -def _formula_mul(left, right): - if left is None or right is None: - return None - return f'({left}) * ({right})' - - -def _formula_join_terms(*terms): - filtered = [term for term in terms if term is not None] - if not filtered: - return None - return ' + '.join(filtered) - - -def _formula_centered(expr, offset): - offset_value = float(offset) - if abs(offset_value) <= 1e-12: - return expr - return f'({expr}) - ({repr(offset_value)})' - - -def _formula_square(expr): - return f'pow(({expr}); 2)' - - -def _formula_cos_radians(expr): - return f'cos(({expr}) * 180 / pi)' - - -def _formula_sin_radians(expr): - return f'sin(({expr}) * 180 / pi)' - - -def _local_point_component_formula(params, param_exprs, point_path, origin, axis_vec): - path = tuple(point_path) if isinstance(point_path, (list, tuple)) else (point_path,) - offsets = (float(origin.x), float(origin.y), float(origin.z)) - axis = (float(axis_vec.x), float(axis_vec.y), float(axis_vec.z)) - terms = [] - for idx, (offset, coeff) in enumerate(zip(offsets, axis)): - value = _formula_nested_value(params, param_exprs, *(path + (idx,))) - if value is None: - return None - term = _formula_scale(_formula_centered(value, offset), coeff) - if term is not None: - terms.append(term) - if not terms: - return '0.0' - return ' + '.join(terms) - - -def _formula_value(params, param_exprs, key, index): - return _formula_nested_value(params, param_exprs, key, index) - - -def _line_length_formula(params, param_exprs): - sx = _formula_value(params, param_exprs, 'start', 0) - sy = _formula_value(params, param_exprs, 'start', 1) - sz = _formula_value(params, param_exprs, 'start', 2) - ex = _formula_value(params, param_exprs, 'end', 0) - ey = _formula_value(params, param_exprs, 'end', 1) - ez = _formula_value(params, param_exprs, 'end', 2) - terms = [] - for a, b in ((ex, sx), (ey, sy), (ez, sz)): - if a is None or b is None: - return None - terms.append(f"pow(({a}) - ({b}); 2)") - return f"sqrt({' + '.join(terms)})" - - -def _build_line_sketch_bindings(param_exprs, geom_index=0, use_local_line=False): - bindings = [] - for point_name, point_index in (("start", 1), ("end", 2)): - expr_ref = _nested_expr_ref(param_exprs, point_name) - if not isinstance(expr_ref, list): - continue - for axis_name, axis_index in (("x", 0), ("y", 1), ("z", 2)): - axis_expr = _nested_expr_ref(param_exprs, point_name, axis_index) - if axis_expr is None: - continue - prop = f"Geometry[{int(geom_index)}].{'StartPoint' if point_name == 'start' else 'EndPoint'}.{axis_name}" - if use_local_line and axis_name in {'x', 'y', 'z'}: - continue - bindings.append((prop, axis_expr)) - return bindings - - -def _build_circle_sketch_bindings(param_exprs, geom_index=0, local=False): - bindings = [] - for axis_name, axis_index in (("x", 0), ("y", 1), ("z", 2)): - axis_expr = _nested_expr_ref(param_exprs, 'center', axis_index) - if axis_expr is None: - continue - if local and axis_name == 'z': - continue - bindings.append((f"Geometry[{int(geom_index)}].Center.{axis_name}", axis_expr)) - radius_expr = _nested_expr_ref(param_exprs, 'radius') - if radius_expr is not None: - bindings.append((f"Geometry[{int(geom_index)}].Radius", radius_expr)) - return bindings - - -def _build_local_point_sketch_bindings(params, param_exprs, point_path, prop_prefix, geom_index=0, origin=None, x_axis=None, y_axis=None): - bindings = [] - if origin is None or x_axis is None or y_axis is None: - return bindings - x_expr = _local_point_component_formula(params, param_exprs, point_path, origin, x_axis) - if x_expr is not None: - bindings.append((f"Geometry[{int(geom_index)}].{prop_prefix}.x", x_expr)) - y_expr = _local_point_component_formula(params, param_exprs, point_path, origin, y_axis) - if y_expr is not None: - bindings.append((f"Geometry[{int(geom_index)}].{prop_prefix}.y", y_expr)) - return bindings - - -def _build_local_line_sketch_bindings(params, param_exprs, geom_index=0, origin=None, x_axis=None, y_axis=None): - bindings = [] - bindings.extend( - _build_local_point_sketch_bindings( - params, - param_exprs, - 'start', - 'StartPoint', - geom_index=geom_index, - origin=origin, - x_axis=x_axis, - y_axis=y_axis, - ) - ) - bindings.extend( - _build_local_point_sketch_bindings( - params, - param_exprs, - 'end', - 'EndPoint', - geom_index=geom_index, - origin=origin, - x_axis=x_axis, - y_axis=y_axis, - ) - ) - return bindings - - -def _build_local_circle_sketch_bindings(params, param_exprs, geom_index=0, origin=None, x_axis=None, y_axis=None): - bindings = [] - bindings.extend( - _build_local_point_sketch_bindings( - params, - param_exprs, - 'center', - 'Center', - geom_index=geom_index, - origin=origin, - x_axis=x_axis, - y_axis=y_axis, - ) - ) - radius_expr = _nested_expr_ref(param_exprs, 'radius') - if radius_expr is not None: - bindings.append((f"Geometry[{int(geom_index)}].Radius", radius_expr)) - return bindings - - -def _angle_arc_local_point_formula(params, param_exprs, angle_key, origin, sketch_axis): - if origin is None or sketch_axis is None: - return None - center_component = _local_point_component_formula(params, param_exprs, 'center', origin, sketch_axis) - radius_expr = _formula_nested_value(params, param_exprs, 'radius') - angle_expr = _formula_nested_value(params, param_exprs, angle_key) - if center_component is None or radius_expr is None or angle_expr is None: - return None - normal = params.get('normal', (0.0, 0.0, 1.0)) - try: - arc_x, arc_y = _angle_arc_axes(normal) - except Exception: - return None - cos_term = _formula_scale( - _formula_mul(radius_expr, _formula_cos_radians(angle_expr)), - float(arc_x.x * sketch_axis.x + arc_x.y * sketch_axis.y + arc_x.z * sketch_axis.z), - ) - sin_term = _formula_scale( - _formula_mul(radius_expr, _formula_sin_radians(angle_expr)), - float(arc_y.x * sketch_axis.x + arc_y.y * sketch_axis.y + arc_y.z * sketch_axis.z), - ) - return _formula_join_terms(center_component, cos_term, sin_term) - - -def _build_local_angle_arc_sketch_bindings(params, param_exprs, geom_index=0, origin=None, x_axis=None, y_axis=None): - bindings = [] - bindings.extend( - _build_local_circle_sketch_bindings( - params, - param_exprs, - geom_index=geom_index, - origin=origin, - x_axis=x_axis, - y_axis=y_axis, - ) - ) - start_x = _angle_arc_local_point_formula(params, param_exprs, 'start_angle', origin, x_axis) - start_y = _angle_arc_local_point_formula(params, param_exprs, 'start_angle', origin, y_axis) - end_x = _angle_arc_local_point_formula(params, param_exprs, 'end_angle', origin, x_axis) - end_y = _angle_arc_local_point_formula(params, param_exprs, 'end_angle', origin, y_axis) - if start_x is not None: - bindings.append((f"Geometry[{int(geom_index)}].StartPoint.x", start_x)) - if start_y is not None: - bindings.append((f"Geometry[{int(geom_index)}].StartPoint.y", start_y)) - if end_x is not None: - bindings.append((f"Geometry[{int(geom_index)}].EndPoint.x", end_x)) - if end_y is not None: - bindings.append((f"Geometry[{int(geom_index)}].EndPoint.y", end_y)) - return bindings - - -def _three_point_arc_local_coordinate_formulas(params, param_exprs, origin, x_axis, y_axis): - return { - 'sx': _local_point_component_formula(params, param_exprs, 'start', origin, x_axis), - 'sy': _local_point_component_formula(params, param_exprs, 'start', origin, y_axis), - 'mx': _local_point_component_formula(params, param_exprs, 'middle', origin, x_axis), - 'my': _local_point_component_formula(params, param_exprs, 'middle', origin, y_axis), - 'ex': _local_point_component_formula(params, param_exprs, 'end', origin, x_axis), - 'ey': _local_point_component_formula(params, param_exprs, 'end', origin, y_axis), - } - - -def _three_point_arc_center_formula(params, param_exprs, origin, x_axis, y_axis, axis_name): - coords = _three_point_arc_local_coordinate_formulas(params, param_exprs, origin, x_axis, y_axis) - if any(value is None for value in coords.values()): - return None - sx = coords['sx'] - sy = coords['sy'] - mx = coords['mx'] - my = coords['my'] - ex = coords['ex'] - ey = coords['ey'] - denom = ( - f"2 * ((({sx}) * (({my}) - ({ey}))) + (({mx}) * (({ey}) - ({sy}))) + (({ex}) * (({sy}) - ({my}))))" - ) - start_sq = f"({_formula_square(sx)} + {_formula_square(sy)})" - mid_sq = f"({_formula_square(mx)} + {_formula_square(my)})" - end_sq = f"({_formula_square(ex)} + {_formula_square(ey)})" - if axis_name == 'x': - numer = ( - f"(({start_sq}) * (({my}) - ({ey}))) + (({mid_sq}) * (({ey}) - ({sy}))) + (({end_sq}) * (({sy}) - ({my})))" - ) - elif axis_name == 'y': - numer = ( - f"(({start_sq}) * (({ex}) - ({mx}))) + (({mid_sq}) * (({sx}) - ({ex}))) + (({end_sq}) * (({mx}) - ({sx})))" - ) - else: - return None - return f"(({numer})) / ({denom})" - - -def _three_point_arc_radius_formula(params, param_exprs, origin, x_axis, y_axis): - coords = _three_point_arc_local_coordinate_formulas(params, param_exprs, origin, x_axis, y_axis) - sx = coords.get('sx') - sy = coords.get('sy') - cx = _three_point_arc_center_formula(params, param_exprs, origin, x_axis, y_axis, 'x') - cy = _three_point_arc_center_formula(params, param_exprs, origin, x_axis, y_axis, 'y') - if sx is None or sy is None or cx is None or cy is None: - return None - return f"sqrt({_formula_square(f'({cx}) - ({sx})')} + {_formula_square(f'({cy}) - ({sy})')})" - - -def _build_local_three_point_arc_sketch_bindings(params, param_exprs, geom_index=0, origin=None, x_axis=None, y_axis=None): - bindings = [] - bindings.extend( - _build_local_point_sketch_bindings( - params, - param_exprs, - 'start', - 'StartPoint', - geom_index=geom_index, - origin=origin, - x_axis=x_axis, - y_axis=y_axis, - ) - ) - bindings.extend( - _build_local_point_sketch_bindings( - params, - param_exprs, - 'end', - 'EndPoint', - geom_index=geom_index, - origin=origin, - x_axis=x_axis, - y_axis=y_axis, - ) - ) - if origin is None or x_axis is None or y_axis is None: - return bindings - center_x = _three_point_arc_center_formula(params, param_exprs, origin, x_axis, y_axis, 'x') - center_y = _three_point_arc_center_formula(params, param_exprs, origin, x_axis, y_axis, 'y') - radius = _three_point_arc_radius_formula(params, param_exprs, origin, x_axis, y_axis) - if center_x is not None: - bindings.append((f"Geometry[{int(geom_index)}].Center.x", center_x)) - if center_y is not None: - bindings.append((f"Geometry[{int(geom_index)}].Center.y", center_y)) - if radius is not None: - bindings.append((f"Geometry[{int(geom_index)}].Radius", radius)) - return bindings - - -def _build_arc_sketch_bindings(param_exprs, geom_index=0, *, prefer_local=False): - bindings = [] - if prefer_local: - for axis_name, axis_index in (("x", 0), ("y", 1)): - start_expr = _nested_expr_ref(param_exprs, 'start', axis_index) - if start_expr is not None: - bindings.append((f"Geometry[{int(geom_index)}].StartPoint.{axis_name}", start_expr)) - end_expr = _nested_expr_ref(param_exprs, 'end', axis_index) - if end_expr is not None: - bindings.append((f"Geometry[{int(geom_index)}].EndPoint.{axis_name}", end_expr)) - return bindings - bindings.extend(_build_circle_sketch_bindings(param_exprs, geom_index=geom_index, local=False)) - start_angle_expr = _nested_expr_ref(param_exprs, 'start_angle') - if start_angle_expr is not None: - bindings.append((f"Geometry[{int(geom_index)}].FirstParameter", start_angle_expr)) - end_angle_expr = _nested_expr_ref(param_exprs, 'end_angle') - if end_angle_expr is not None: - bindings.append((f"Geometry[{int(geom_index)}].LastParameter", end_angle_expr)) - return bindings - - -def _detail_edge_binding_expr(param_exprs, key): - edge_indices = [] - radius_expr = None - if key == 'radius': - radius_expr = _nested_expr_ref(param_exprs, 'radius') - elif key == 'distance': - radius_expr = _nested_expr_ref(param_exprs, 'distance') - if radius_expr is None: - return None - return radius_expr - - -def _apply_detail_feature_bindings(obj, param_exprs, key): - expr_ref = _detail_edge_binding_expr(param_exprs, key) - if expr_ref is None: - return False - selected = [] - if key == 'radius': - selected = list(getattr(obj, 'Edges', []) or []) - else: - selected = list(getattr(obj, 'Edges', []) or []) - applied = False - for idx in range(len(selected)): - applied = _bind_expression(obj, f'Edges[{idx}]', expr_ref) or applied - return applied - - -def _resolve_param_value(params, param_exprs, key): - if isinstance(param_exprs, dict) and key in param_exprs: - value = _resolve_expr_ref(param_exprs[key]) - if value is not None: - return value - return params[key] - - -def _resolve_nested_param_value(params, param_exprs, *path): - value = params - expr_meta = param_exprs if isinstance(param_exprs, dict) else {} - for key in path: - value = value[key] - if isinstance(expr_meta, dict) and key in expr_meta: - expr_meta = expr_meta[key] - elif isinstance(expr_meta, list) and isinstance(key, int) and 0 <= key < len(expr_meta): - expr_meta = expr_meta[key] - else: - expr_meta = None - expr_value = _resolve_expr_ref(expr_meta) - if expr_value is not None: - return expr_value - return value - - -def _resolve_vec3_param(params, param_exprs, key): - return ( - float(_resolve_nested_param_value(params, param_exprs, key, 0)), - float(_resolve_nested_param_value(params, param_exprs, key, 1)), - float(_resolve_nested_param_value(params, param_exprs, key, 2)), - ) - - -""".strip() - - def _emit_node(self, node: OperationNode) -> List[str]: - params_literal = _py_literal(dict(node.params)) - inputs_literal = _py_literal([inp.node_id for inp in node.inputs]) - tags_literal = _py_literal(sorted(node.tags)) - context_literal = _py_literal(node.context or {}) - param_exprs_literal = _py_literal(dict(node.param_exprs)) - semantic_delta_literal = _py_literal( - self._node_optional_payload(node, "semantic_delta") - ) - topo_delta_literal = _py_literal( - self._node_optional_payload(node, "topo_delta") - ) - - var_name = _safe_name(node.node_id) - object_name = _safe_name(f"{node.op}_{node.node_id}", prefix="step") - lines = [ - f"{var_name}_params = {params_literal}", - f"{var_name}_inputs = {inputs_literal}", - f"{var_name}_param_exprs = {param_exprs_literal}", - ] - - native_lines = self._emit_native_node( - node, - var_name=var_name, - object_name=object_name, - tags_literal=tags_literal, - context_literal=context_literal, - param_exprs_literal=param_exprs_literal, - semantic_delta_literal=semantic_delta_literal, - topo_delta_literal=topo_delta_literal, - ) - if native_lines is not None: - lines.extend(native_lines) - return lines - raise ValueError(f"Unsupported FreeCAD native graph translation op: {node.op}") - - def _emit_native_node( - self, - node: OperationNode, - *, - var_name: str, - object_name: str, - tags_literal: str, - context_literal: str, - param_exprs_literal: str, - semantic_delta_literal: str, - topo_delta_literal: str, - ) -> Optional[List[str]]: - native_expr = self._compile_native_feature_expr( - node, - var_name=var_name, - object_name=object_name, - tags_literal=tags_literal, - context_literal=context_literal, - param_exprs_literal=param_exprs_literal, - semantic_delta_literal=semantic_delta_literal, - topo_delta_literal=topo_delta_literal, - ) - if native_expr is None: - return None - return native_expr - - def _compile_native_feature_expr( - self, - node: OperationNode, - *, - var_name: str, - object_name: str, - tags_literal: str, - context_literal: str, - param_exprs_literal: str, - semantic_delta_literal: str, - topo_delta_literal: str, - ) -> Optional[List[str]]: - graph = self._source_graph - if graph is None: - return None - - rp = f"{var_name}_params" - re = f"{var_name}_param_exprs" - inputs = [inp.node_id for inp in node.inputs] - - def finish() -> List[str]: - return [ - f"_attach_simplecad_metadata({var_name}, node_id={_json_ascii(node.node_id)}, op={_json_ascii(node.op)}, params={rp}, inputs={var_name}_inputs, tags={tags_literal}, context={context_literal}, output_count={node.output_count}, param_exprs={param_exprs_literal}, semantic_delta={semantic_delta_literal}, topo_delta={topo_delta_literal})", - f"GRAPH_NODES[{_json_ascii(node.node_id)}] = {var_name}", - f"GRAPH_METADATA[{_json_ascii(node.node_id)}] = {{'op': {_json_ascii(node.op)}, 'params': {rp}, 'inputs': {var_name}_inputs, 'context': {context_literal}, 'tags': {tags_literal}}}", - f"GRAPH_OUTPUTS[{_json_ascii(node.node_id)}] = [{var_name}]", - ] - - def finish_ir() -> List[str]: - return [ - f"{var_name} = _register_ir_node({_json_ascii(object_name)}, node_id={_json_ascii(node.node_id)}, op={_json_ascii(node.op)}, params={rp}, inputs={var_name}_inputs, tags={tags_literal}, context={context_literal}, output_count={node.output_count}, param_exprs={param_exprs_literal}, semantic_delta={semantic_delta_literal}, topo_delta={topo_delta_literal})" - ] - - def finish_alias(source_node_id: str) -> List[str]: - return [ - f"{var_name} = _register_graph_alias(node_id={_json_ascii(node.node_id)}, source_node_id={_json_ascii(source_node_id)}, op={_json_ascii(node.op)}, params={rp}, inputs={var_name}_inputs, tags={tags_literal}, context={context_literal}, output_count={node.output_count}, param_exprs={param_exprs_literal}, semantic_delta={semantic_delta_literal}, topo_delta={topo_delta_literal})" - ] - - if node.op == "make_box_rsolid": - lines = [ - f"{var_name}_width = float(_resolve_param_value({rp}, {re}, 'width'))", - f"{var_name}_height = float(_resolve_param_value({rp}, {re}, 'height'))", - f"{var_name}_depth = float(_resolve_param_value({rp}, {re}, 'depth'))", - f"{var_name}_bottom = _vec(_resolve_vec3_param({rp}, {re}, 'bottom_face_center'))", - f"{var_name}_corner = {var_name}_bottom - App.Vector({var_name}_width / 2.0, {var_name}_height / 2.0, 0.0)", - f"{var_name} = doc.addObject('Part::Box', {_json_ascii(object_name)})", - f"{var_name}.Length = {var_name}_width", - f"{var_name}.Width = {var_name}_height", - f"{var_name}.Height = {var_name}_depth", - f"{var_name}.Placement = App.Placement({var_name}_corner, App.Rotation())", - ] - lines.append(f"_apply_op_expression_bindings({var_name}, {_json_ascii(node.op)}, {re})") - lines.extend(finish()) - return lines - - if node.op == "make_cylinder_rsolid": - lines = [ - f"{var_name} = doc.addObject('Part::Cylinder', {_json_ascii(object_name)})", - f"{var_name}.Radius = float(_resolve_param_value({rp}, {re}, 'radius'))", - f"{var_name}.Height = float(_resolve_param_value({rp}, {re}, 'height'))", - f"{var_name}.Placement = App.Placement(_vec(_resolve_vec3_param({rp}, {re}, 'bottom_face_center')), App.Rotation(App.Vector(0.0, 0.0, 1.0), _vec(_resolve_vec3_param({rp}, {re}, 'axis'))))", - ] - lines.append(f"_apply_op_expression_bindings({var_name}, {_json_ascii(node.op)}, {re})") - lines.extend(finish()) - return lines - - if node.op == "make_cone_rsolid": - lines = [ - f"{var_name} = doc.addObject('Part::Cone', {_json_ascii(object_name)})", - f"{var_name}.Radius1 = float(_resolve_param_value({rp}, {re}, 'bottom_radius'))", - f"{var_name}.Radius2 = float(_resolve_param_value({rp}, {re}, 'top_radius'))", - f"{var_name}.Height = float(_resolve_param_value({rp}, {re}, 'height'))", - f"{var_name}.Placement = App.Placement(_vec(_resolve_vec3_param({rp}, {re}, 'bottom_face_center')), App.Rotation(App.Vector(0.0, 0.0, 1.0), _vec(_resolve_vec3_param({rp}, {re}, 'axis'))))", - ] - lines.append(f"_apply_op_expression_bindings({var_name}, {_json_ascii(node.op)}, {re})") - lines.extend(finish()) - return lines - - if node.op == "make_sphere_rsolid": - lines = [ - f"{var_name} = doc.addObject('Part::Sphere', {_json_ascii(object_name)})", - f"{var_name}.Radius = float(_resolve_param_value({rp}, {re}, 'radius'))", - f"{var_name}.Placement = App.Placement(_vec(_resolve_vec3_param({rp}, {re}, 'center')), App.Rotation())", - ] - lines.append(f"_apply_op_expression_bindings({var_name}, {_json_ascii(node.op)}, {re})") - lines.extend(finish()) - return lines - - if node.op == "make_material_rmaterial": - return [ - f"{var_name} = dict({rp})", - f"{var_name}_object = _ensure_material_object({var_name})", - f"PRODUCT_VALUES[{_json_ascii(node.node_id)}] = {{'kind': 'material', 'material': {var_name}, 'material_object': {var_name}_object}}", - f"{var_name} = _register_graph_value({var_name}, node_id={_json_ascii(node.node_id)}, op={_json_ascii(node.op)}, params={rp}, inputs={var_name}_inputs, tags={tags_literal}, context={context_literal}, output_count={node.output_count}, param_exprs={param_exprs_literal}, semantic_delta={semantic_delta_literal}, topo_delta={topo_delta_literal})", - ] - - if node.op in {"make_placement_rplacement", "make_identity_placement_rplacement"}: - return [ - f"{var_name} = dict({rp})", - f"PRODUCT_VALUES[{_json_ascii(node.node_id)}] = {{'kind': 'placement', 'placement': {var_name}}}", - f"{var_name} = _register_graph_value({var_name}, node_id={_json_ascii(node.node_id)}, op={_json_ascii(node.op)}, params={rp}, inputs={var_name}_inputs, tags={tags_literal}, context={context_literal}, output_count={node.output_count}, param_exprs={param_exprs_literal}, semantic_delta={semantic_delta_literal}, topo_delta={topo_delta_literal})", - ] - - if node.op == "make_part_rpart" and len(inputs) == 1: - lines = [ - f"{var_name} = doc.addObject('App::Part', {_json_ascii(object_name)})", - f"{var_name}.Label = str({rp}.get('name') or {rp}.get('part_id') or {_json_ascii(object_name)})", - f"{var_name}_source_body = GRAPH_NODES[{_json_ascii(inputs[0])}]", - f"{var_name}_body = _make_part_body_copy({var_name}, {var_name}_source_body, {_json_ascii(inputs[0])})", - f"{var_name}.addObject({var_name}_body)", - f"_ensure_string_property({var_name}, 'SimpleCADPartId')", - f"{var_name}.SimpleCADPartId = str({rp}.get('part_id', ''))", - f"_hide_origin_tree({var_name})", - ] - lines.extend(finish()) - lines.append( - f"PRODUCT_VALUES[{_json_ascii(node.node_id)}] = {{'kind': 'part', 'part_id': str({rp}.get('part_id', '')), 'body': {var_name}_body, 'container': {var_name}, 'material': None, 'connectors': []}}" - ) - return lines - - if node.op == "make_assign_material_rpart" and len(inputs) >= 2: - lines = [ - f"{var_name}_part = PRODUCT_VALUES[{_json_ascii(inputs[0])}]", - f"{var_name}_material = PRODUCT_VALUES[{_json_ascii(inputs[1])}]['material']", - f"{var_name} = {var_name}_part['container']", - f"PRODUCT_VALUES[{_json_ascii(node.node_id)}] = dict({var_name}_part)", - f"_apply_material_to_product(PRODUCT_VALUES[{_json_ascii(node.node_id)}], {var_name}_material)", - f"{var_name} = _register_graph_folded_alias(node_id={_json_ascii(node.node_id)}, source_node_id={_json_ascii(inputs[0])}, op={_json_ascii(node.op)}, params={rp}, inputs={var_name}_inputs, tags={tags_literal}, context={context_literal}, output_count={node.output_count}, param_exprs={param_exprs_literal}, semantic_delta={semantic_delta_literal}, topo_delta={topo_delta_literal})", - ] - return lines - - if node.op == "make_assign_material_rpart" and len(inputs) == 1: - lines = [ - f"{var_name}_part = PRODUCT_VALUES[{_json_ascii(inputs[0])}]", - f"{var_name}_material = _material_from_assignment_params({rp})", - f"{var_name} = {var_name}_part['container']", - f"PRODUCT_VALUES[{_json_ascii(node.node_id)}] = dict({var_name}_part)", - f"_apply_material_to_product(PRODUCT_VALUES[{_json_ascii(node.node_id)}], {var_name}_material)", - f"{var_name} = _register_graph_folded_alias(node_id={_json_ascii(node.node_id)}, source_node_id={_json_ascii(inputs[0])}, op={_json_ascii(node.op)}, params={rp}, inputs={var_name}_inputs, tags={tags_literal}, context={context_literal}, output_count={node.output_count}, param_exprs={param_exprs_literal}, semantic_delta={semantic_delta_literal}, topo_delta={topo_delta_literal})", - ] - return lines - - if node.op == "make_assembly_rassembly": - lines = [ - f"{var_name} = _make_native_assembly({_json_ascii(object_name)}, node_id={_json_ascii(node.node_id)}, op={_json_ascii(node.op)}, params={rp}, inputs={var_name}_inputs, tags={tags_literal}, context={context_literal}, output_count={node.output_count}, param_exprs={param_exprs_literal}, semantic_delta={semantic_delta_literal}, topo_delta={topo_delta_literal})", - f"{var_name}.Label = str({rp}.get('name') or {rp}.get('assembly_id') or {_json_ascii(object_name)})", - f"_ensure_string_property({var_name}, 'SimpleCADAssemblyId')", - f"{var_name}.SimpleCADAssemblyId = str({rp}.get('assembly_id', ''))", - ] - lines.append( - f"PRODUCT_VALUES[{_json_ascii(node.node_id)}] = {{'kind': 'assembly', 'assembly_id': str({rp}.get('assembly_id', '')), 'container': {var_name}, 'components': [], 'connectors': [], 'constraints': [], 'grounded_component_ids': []}}" - ) - return lines - - if node.op == "make_add_component_rassembly" and len(inputs) >= 3: - lines = [ - f"{var_name}_assembly = PRODUCT_VALUES[{_json_ascii(inputs[0])}]", - f"{var_name}_item = PRODUCT_VALUES[{_json_ascii(inputs[1])}]", - f"{var_name}_placement = PRODUCT_VALUES[{_json_ascii(inputs[2])}]['placement']", - f"{var_name} = {var_name}_assembly['container']", - f"{var_name}_link_label = str({rp}.get('name') or {rp}.get('component_id') or {_json_ascii(object_name)})", - f"{var_name}_link = _make_assembly_component_link({var_name}, {var_name}_item, {_json_ascii(object_name + '_component')}, {var_name}_link_label, {var_name}_placement)", - f"_ensure_string_property({var_name}_link, 'SimpleCADComponentId')", - f"{var_name}_link.SimpleCADComponentId = str({rp}.get('component_id', ''))", - f"PRODUCT_VALUES[{_json_ascii(node.node_id)}] = dict({var_name}_assembly)", - f"PRODUCT_VALUES[{_json_ascii(node.node_id)}]['components'] = list({var_name}_assembly.get('components', [])) + [{{'component_id': str({rp}.get('component_id', '')), 'link': {var_name}_link, 'placement': {var_name}_placement, 'item': {var_name}_item}}]", - f"{var_name} = _register_graph_folded_alias(node_id={_json_ascii(node.node_id)}, source_node_id={_json_ascii(inputs[0])}, op={_json_ascii(node.op)}, params={rp}, inputs={var_name}_inputs, tags={tags_literal}, context={context_literal}, output_count={node.output_count}, param_exprs={param_exprs_literal}, semantic_delta={semantic_delta_literal}, topo_delta={topo_delta_literal})", - ] - return lines - - if node.op == "make_place_component_rassembly" and len(inputs) >= 2: - lines = [ - f"{var_name}_assembly = PRODUCT_VALUES[{_json_ascii(inputs[0])}]", - f"{var_name}_placement = PRODUCT_VALUES[{_json_ascii(inputs[1])}]['placement']", - f"{var_name} = {var_name}_assembly['container']", - f"{var_name}_components = []", - f"for _component in {var_name}_assembly.get('components', []):", - f" if str(_component.get('component_id')) == str({rp}.get('component_id')):", - f" _component = dict(_component)", - f" _component['placement'] = {var_name}_placement", - f" _set_component_link_placement(_component['link'], _component.get('item') or {{}}, {var_name}_placement)", - f" {var_name}_components.append(_component)", - f"PRODUCT_VALUES[{_json_ascii(node.node_id)}] = dict({var_name}_assembly)", - f"PRODUCT_VALUES[{_json_ascii(node.node_id)}]['components'] = {var_name}_components", - f"{var_name} = _register_graph_folded_alias(node_id={_json_ascii(node.node_id)}, source_node_id={_json_ascii(inputs[0])}, op={_json_ascii(node.op)}, params={rp}, inputs={var_name}_inputs, tags={tags_literal}, context={context_literal}, output_count={node.output_count}, param_exprs={param_exprs_literal}, semantic_delta={semantic_delta_literal}, topo_delta={topo_delta_literal})", - ] - return lines - - if node.op in {"make_face_connector_rconnector", "make_edge_connector_rconnector", "make_vertex_connector_rconnector"}: - return [ - f"{var_name} = dict({rp})", - f"{var_name}.setdefault('anchor', {{'anchor_kind': 'geometry', 'geometry_ref': {var_name}.get('geometry_ref')}})", - f"{var_name}.setdefault('geometry_ref', ({var_name}.get('anchor') or {{}}).get('geometry_ref'))", - f"{var_name}.setdefault('name', {rp}.get('name'))", - f"PRODUCT_VALUES[{_json_ascii(node.node_id)}] = {{'kind': 'connector', 'connector': {var_name}}}", - f"{var_name} = _register_graph_value({var_name}, node_id={_json_ascii(node.node_id)}, op={_json_ascii(node.op)}, params={rp}, inputs={var_name}_inputs, tags={tags_literal}, context={context_literal}, output_count={node.output_count}, param_exprs={param_exprs_literal}, semantic_delta={semantic_delta_literal}, topo_delta={topo_delta_literal})", - ] - - if node.op == "make_placement_connector_rconnector": - return [ - f"{var_name} = {{'connector_id': str({rp}.get('connector_id', '')), 'name': {rp}.get('name'), 'anchor': {{'anchor_kind': 'placement', 'placement': {rp}.get('placement') or {{}}}}}}", - f"PRODUCT_VALUES[{_json_ascii(node.node_id)}] = {{'kind': 'connector', 'connector': {var_name}}}", - f"{var_name} = _register_graph_value({var_name}, node_id={_json_ascii(node.node_id)}, op={_json_ascii(node.op)}, params={rp}, inputs={var_name}_inputs, tags={tags_literal}, context={context_literal}, output_count={node.output_count}, param_exprs={param_exprs_literal}, semantic_delta={semantic_delta_literal}, topo_delta={topo_delta_literal})", - ] - - if node.op == "make_add_connector_rpart" and len(inputs) >= 2: - return [ - f"{var_name}_part = PRODUCT_VALUES[{_json_ascii(inputs[0])}]", - f"{var_name}_connector = PRODUCT_VALUES[{_json_ascii(inputs[1])}]['connector']", - f"PRODUCT_VALUES[{_json_ascii(node.node_id)}] = dict({var_name}_part)", - f"PRODUCT_VALUES[{_json_ascii(node.node_id)}]['connectors'] = list({var_name}_part.get('connectors', [])) + [{var_name}_connector]", - f"_materialize_product_connector_datums(PRODUCT_VALUES[{_json_ascii(node.node_id)}])", - f"{var_name} = _register_graph_folded_alias(node_id={_json_ascii(node.node_id)}, source_node_id={_json_ascii(inputs[0])}, op={_json_ascii(node.op)}, params={rp}, inputs={var_name}_inputs, tags={tags_literal}, context={context_literal}, output_count={node.output_count}, param_exprs={param_exprs_literal}, semantic_delta={semantic_delta_literal}, topo_delta={topo_delta_literal})", - ] - - if node.op == "make_add_connector_rassembly" and len(inputs) >= 2: - return [ - f"{var_name}_assembly = PRODUCT_VALUES[{_json_ascii(inputs[0])}]", - f"{var_name}_connector = PRODUCT_VALUES[{_json_ascii(inputs[1])}]['connector']", - f"PRODUCT_VALUES[{_json_ascii(node.node_id)}] = dict({var_name}_assembly)", - f"PRODUCT_VALUES[{_json_ascii(node.node_id)}]['connectors'] = list({var_name}_assembly.get('connectors', [])) + [{var_name}_connector]", - f"_materialize_product_connector_datums(PRODUCT_VALUES[{_json_ascii(node.node_id)}])", - f"{var_name} = _register_graph_folded_alias(node_id={_json_ascii(node.node_id)}, source_node_id={_json_ascii(inputs[0])}, op={_json_ascii(node.op)}, params={rp}, inputs={var_name}_inputs, tags={tags_literal}, context={context_literal}, output_count={node.output_count}, param_exprs={param_exprs_literal}, semantic_delta={semantic_delta_literal}, topo_delta={topo_delta_literal})", - ] - - if node.op == "make_forward_connector_rassembly" and len(inputs) >= 1: - return [ - f"{var_name}_assembly = PRODUCT_VALUES[{_json_ascii(inputs[0])}]", - f"{var_name}_connector = {{'connector_id': str({rp}.get('connector_id', '')), 'name': {rp}.get('name'), 'anchor': {{'anchor_kind': 'forwarded', 'source_component_id': str({rp}.get('source_component_id', '')), 'source_connector_id': str({rp}.get('source_connector_id', '')), 'offset': {rp}.get('offset')}}}}", - f"PRODUCT_VALUES[{_json_ascii(node.node_id)}] = dict({var_name}_assembly)", - f"PRODUCT_VALUES[{_json_ascii(node.node_id)}]['connectors'] = list({var_name}_assembly.get('connectors', [])) + [{var_name}_connector]", - f"_materialize_product_connector_datums(PRODUCT_VALUES[{_json_ascii(node.node_id)}])", - f"{var_name} = _register_graph_folded_alias(node_id={_json_ascii(node.node_id)}, source_node_id={_json_ascii(inputs[0])}, op={_json_ascii(node.op)}, params={rp}, inputs={var_name}_inputs, tags={tags_literal}, context={context_literal}, output_count={node.output_count}, param_exprs={param_exprs_literal}, semantic_delta={semantic_delta_literal}, topo_delta={topo_delta_literal})", - ] - - if node.op == "make_connector_ref_rconnectorref": - return [ - f"{var_name} = dict({rp})", - f"PRODUCT_VALUES[{_json_ascii(node.node_id)}] = {{'kind': 'connector_ref', 'connector_ref': {var_name}}}", - f"{var_name} = _register_graph_value({var_name}, node_id={_json_ascii(node.node_id)}, op={_json_ascii(node.op)}, params={rp}, inputs={var_name}_inputs, tags={tags_literal}, context={context_literal}, output_count={node.output_count}, param_exprs={param_exprs_literal}, semantic_delta={semantic_delta_literal}, topo_delta={topo_delta_literal})", - ] - - if node.op == "make_scalar_limit_rscalarlimit": - return [ - f"{var_name} = dict({rp})", - f"PRODUCT_VALUES[{_json_ascii(node.node_id)}] = {{'kind': 'scalar_limit', 'scalar_limit': {var_name}}}", - f"{var_name} = _register_graph_value({var_name}, node_id={_json_ascii(node.node_id)}, op={_json_ascii(node.op)}, params={rp}, inputs={var_name}_inputs, tags={tags_literal}, context={context_literal}, output_count={node.output_count}, param_exprs={param_exprs_literal}, semantic_delta={semantic_delta_literal}, topo_delta={topo_delta_literal})", - ] - - if node.op in {"make_ground_component_rassembly", "make_unground_component_rassembly"} and len(inputs) >= 1: - action = "add" if node.op == "make_ground_component_rassembly" else "remove" - return [ - f"{var_name}_assembly = PRODUCT_VALUES[{_json_ascii(inputs[0])}]", - f"PRODUCT_VALUES[{_json_ascii(node.node_id)}] = dict({var_name}_assembly)", - f"{var_name}_grounded = list({var_name}_assembly.get('grounded_component_ids', []))", - f"{var_name}_component_id = str({rp}.get('component_id', ''))", - f"{var_name}_grounded = (list({var_name}_grounded) if {_json_ascii(action)} == 'add' else [component_id for component_id in {var_name}_grounded if component_id != {var_name}_component_id])", - f"if {_json_ascii(action)} == 'add' and {var_name}_component_id not in {var_name}_grounded: {var_name}_grounded.append({var_name}_component_id)", - f"PRODUCT_VALUES[{_json_ascii(node.node_id)}]['grounded_component_ids'] = {var_name}_grounded", - f"if {_json_ascii(action)} == 'add': _make_simplecad_grounded_joint({var_name}_assembly, {var_name}_component_id)", - f"{var_name} = _register_graph_folded_alias(node_id={_json_ascii(node.node_id)}, source_node_id={_json_ascii(inputs[0])}, op={_json_ascii(node.op)}, params={rp}, inputs={var_name}_inputs, tags={tags_literal}, context={context_literal}, output_count={node.output_count}, param_exprs={param_exprs_literal}, semantic_delta={semantic_delta_literal}, topo_delta={topo_delta_literal})", - ] - - if node.op in {"make_fixed_constraint_rassembly", "make_revolute_constraint_rassembly", "make_prismatic_constraint_rassembly", "make_gear_constraint_rassembly", "make_belt_constraint_rassembly", "make_rack_pinion_constraint_rassembly"} and len(inputs) >= 3: - return [ - f"{var_name}_assembly = PRODUCT_VALUES[{_json_ascii(inputs[0])}]", - f"{var_name}_constraint = dict({rp})", - f"{var_name}_constraint['connector_a'] = PRODUCT_VALUES[{_json_ascii(inputs[1])}]['connector_ref']", - f"{var_name}_constraint['connector_b'] = PRODUCT_VALUES[{_json_ascii(inputs[2])}]['connector_ref']", - f"{var_name}_joint = _make_simplecad_joint({var_name}_assembly, {var_name}_constraint, {_json_ascii(object_name)}, str({rp}.get('name') or {rp}.get('constraint_id') or {_json_ascii(object_name)}))", - f"PRODUCT_VALUES[{_json_ascii(node.node_id)}] = dict({var_name}_assembly)", - f"PRODUCT_VALUES[{_json_ascii(node.node_id)}]['constraints'] = list({var_name}_assembly.get('constraints', [])) + [{var_name}_constraint]", - f"{var_name} = _register_graph_folded_alias(node_id={_json_ascii(node.node_id)}, source_node_id={_json_ascii(inputs[0])}, op={_json_ascii(node.op)}, params={rp}, inputs={var_name}_inputs, tags={tags_literal}, context={context_literal}, output_count={node.output_count}, param_exprs={param_exprs_literal}, semantic_delta={semantic_delta_literal}, topo_delta={topo_delta_literal})", - ] - - if node.op == "make_solve_assembly_constraints_rassembly" and len(inputs) >= 1: - return [ - f"{var_name}_assembly = PRODUCT_VALUES[{_json_ascii(inputs[0])}]", - f"PRODUCT_VALUES[{_json_ascii(node.node_id)}] = dict({var_name}_assembly)", - f"{var_name}_placements = dict({rp}.get('component_placements') or {{}})", - f"{var_name}_components = []", - f"for _component in {var_name}_assembly.get('components', []):", - f" _component = dict(_component)", - f" _component_id = str(_component.get('component_id'))", - f" if _component_id in {var_name}_placements:", - f" _component['placement'] = {var_name}_placements[_component_id]", - f" _set_component_link_placement(_component['link'], _component.get('item') or {{}}, _component['placement'])", - f" {var_name}_components.append(_component)", - f"PRODUCT_VALUES[{_json_ascii(node.node_id)}]['components'] = {var_name}_components", - f"{var_name} = _register_graph_folded_alias(node_id={_json_ascii(node.node_id)}, source_node_id={_json_ascii(inputs[0])}, op={_json_ascii(node.op)}, params={rp}, inputs={var_name}_inputs, tags={tags_literal}, context={context_literal}, output_count={node.output_count}, param_exprs={param_exprs_literal}, semantic_delta={semantic_delta_literal}, topo_delta={topo_delta_literal})", - ] - - if node.op == "make_compound_from_assembly_rcompound" and len(inputs) == 1: - return [ - f"{var_name}_assembly = PRODUCT_VALUES[{_json_ascii(inputs[0])}]", - f"ASSEMBLY_PROJECTION_INPUTS[{_json_ascii(node.node_id)}] = {_json_ascii(inputs[0])}", - "doc.recompute()", - f"{var_name}_shapes = _shapes_from_product_value({var_name}_assembly)", - f"{var_name} = _make_feature({_json_ascii(object_name)}, Part.makeCompound({var_name}_shapes), node_id={_json_ascii(node.node_id)}, op={_json_ascii(node.op)}, params={rp}, inputs={var_name}_inputs, tags={tags_literal}, context={context_literal}, output_count={node.output_count}, param_exprs={param_exprs_literal}, semantic_delta={semantic_delta_literal}, topo_delta={topo_delta_literal})", - ] - - if node.op in { - "make_select_rvertex", - "make_select_redge", - "make_select_rwire", - "make_select_rface", - "make_select_rsolid", - } and len(inputs) == 1: - return [ - f"{var_name} = _register_geo_selection_node(node_id={_json_ascii(node.node_id)}, op={_json_ascii(node.op)}, params={rp}, inputs={var_name}_inputs, tags={tags_literal}, context={context_literal}, output_count={node.output_count}, param_exprs={param_exprs_literal}, semantic_delta={semantic_delta_literal}, topo_delta={topo_delta_literal})" - ] - - if node.op in { - "make_sketch_rsketch", - "make_add_point_rsketch", - "make_add_line_rsketch", - "make_add_circle_rsketch", - "make_add_arc_rsketch", - "make_add_bspline_rsketch", - "make_constrain_coincident_rsketch", - "make_constrain_point_on_rsketch", - "make_constrain_horizontal_rsketch", - "make_constrain_vertical_rsketch", - "make_constrain_parallel_rsketch", - "make_constrain_perpendicular_rsketch", - "make_constrain_collinear_rsketch", - "make_constrain_tangent_rsketch", - "make_constrain_concentric_rsketch", - "make_constrain_midpoint_rsketch", - "make_constrain_symmetric_rsketch", - "make_constrain_equal_length_rsketch", - "make_constrain_equal_radius_rsketch", - "make_constrain_distance_rsketch", - "make_constrain_distance_x_rsketch", - "make_constrain_distance_y_rsketch", - "make_constrain_length_rsketch", - "make_constrain_angle_rsketch", - "make_constrain_radius_rsketch", - "make_constrain_diameter_rsketch", - "make_constrain_fix_rsketch", - }: - return finish_ir() - - if node.op in {"make_wire_from_sketch_rwire", "make_face_from_sketch_rface"}: - return [ - f"{var_name} = _make_sketch_promotion_object({_json_ascii(object_name)}, node_id={_json_ascii(node.node_id)}, op={_json_ascii(node.op)}, params={rp}, inputs={var_name}_inputs, tags={tags_literal}, context={context_literal}, output_count={node.output_count}, param_exprs={param_exprs_literal}, semantic_delta={semantic_delta_literal}, topo_delta={topo_delta_literal})" - ] - - if node.node_id in self._suppressed_profile_node_ids: - return finish_ir() - - if node.op == "make_line_redge": - lines = [ - f"{var_name} = _register_graph_value(Part.makeLine(_vec(_resolve_vec3_param({rp}, {re}, 'start')), _vec(_resolve_vec3_param({rp}, {re}, 'end'))), node_id={_json_ascii(node.node_id)}, op={_json_ascii(node.op)}, params={rp}, inputs={var_name}_inputs, tags={tags_literal}, context={context_literal}, output_count={node.output_count}, param_exprs={param_exprs_literal}, semantic_delta={semantic_delta_literal}, topo_delta={topo_delta_literal})" - ] - return lines - - if node.op == "make_circle_redge": - lines = [ - f"{var_name} = _register_graph_value(Part.Circle(_vec(_resolve_vec3_param({rp}, {re}, 'center')), _vec(_resolve_vec3_param({rp}, {re}, 'normal') if 'normal' in {rp} else (0.0, 0.0, 1.0)), float(_resolve_param_value({rp}, {re}, 'radius'))).toShape(), node_id={_json_ascii(node.node_id)}, op={_json_ascii(node.op)}, params={rp}, inputs={var_name}_inputs, tags={tags_literal}, context={context_literal}, output_count={node.output_count}, param_exprs={param_exprs_literal}, semantic_delta={semantic_delta_literal}, topo_delta={topo_delta_literal})" - ] - return lines - - if node.op == "make_angle_arc_redge": - lines = [ - f"{var_name} = _register_graph_value(Part.ArcOfCircle(Part.Circle(_vec(_resolve_vec3_param({rp}, {re}, 'center')), _vec(_resolve_vec3_param({rp}, {re}, 'normal') if 'normal' in {rp} else (0.0, 0.0, 1.0)), float(_resolve_param_value({rp}, {re}, 'radius'))), float(_resolve_param_value({rp}, {re}, 'start_angle')), float(_resolve_param_value({rp}, {re}, 'end_angle'))).toShape(), node_id={_json_ascii(node.node_id)}, op={_json_ascii(node.op)}, params={rp}, inputs={var_name}_inputs, tags={tags_literal}, context={context_literal}, output_count={node.output_count}, param_exprs={param_exprs_literal}, semantic_delta={semantic_delta_literal}, topo_delta={topo_delta_literal})" - ] - return lines - - if node.op == "make_three_point_arc_redge": - arc_expr = f"Part.Arc(_vec(_resolve_vec3_param({rp}, {re}, 'start')), _vec(_resolve_vec3_param({rp}, {re}, 'middle')), _vec(_resolve_vec3_param({rp}, {re}, 'end'))).toShape()" - lines = [ - f"{var_name} = _register_graph_value({arc_expr}, node_id={_json_ascii(node.node_id)}, op={_json_ascii(node.op)}, params={rp}, inputs={var_name}_inputs, tags={tags_literal}, context={context_literal}, output_count={node.output_count}, param_exprs={param_exprs_literal}, semantic_delta={semantic_delta_literal}, topo_delta={topo_delta_literal})" - ] - return lines - - if node.op == "make_spline_redge": - lines = [ - f"{var_name} = _register_graph_value(_bspline_curve_from_params({rp}).toShape(), node_id={_json_ascii(node.node_id)}, op={_json_ascii(node.op)}, params={rp}, inputs={var_name}_inputs, tags={tags_literal}, context={context_literal}, output_count={node.output_count}, param_exprs={param_exprs_literal}, semantic_delta={semantic_delta_literal}, topo_delta={topo_delta_literal})" - ] - return lines - - if node.op == "make_wire_from_edges_rwire": - input_nodes = [graph.get_node(node_id) for node_id in inputs] - if len(inputs) == 1: - single = input_nodes[0] - if single is not None and single.op == "make_helix_redge": - return finish_alias(inputs[0]) - if input_nodes and all( - inp is not None - and inp.op - in { - "make_line_redge", - "make_circle_redge", - "make_angle_arc_redge", - "make_three_point_arc_redge", - "make_spline_redge", - } - for inp in input_nodes - ): - if not any( - _contains_expr_refs(dict(inp.param_exprs)) - for inp in input_nodes - if inp is not None - ): - return [ - f"{var_name} = _make_feature({_json_ascii(object_name)}, _wire_shape_from_edge_objects({var_name}_inputs), node_id={_json_ascii(node.node_id)}, op={_json_ascii(node.op)}, params={rp}, inputs={var_name}_inputs, tags={tags_literal}, context={context_literal}, output_count={node.output_count}, param_exprs={param_exprs_literal}, semantic_delta={semantic_delta_literal}, topo_delta={topo_delta_literal})" - ] - lines = [ - f"{var_name} = doc.addObject('Sketcher::SketchObject', {_json_ascii(object_name)})", - f"{var_name}_sketch_bindings = []", - f"{var_name}_expr_limitations = []", - f"{var_name}_constraint_bindings = []", - ] - point_exprs: List[str] = [] - for geom_index, input_node in enumerate(input_nodes): - assert input_node is not None - edge_var = _safe_name(input_node.node_id) - edge_obj_expr = f"GRAPH_NODES[{_json_ascii(input_node.node_id)}]" - if input_node.op == "make_line_redge": - point_exprs.append(f"_edge_start_point({edge_obj_expr})") - point_exprs.append(f"_edge_end_point({edge_obj_expr})") - elif input_node.op == "make_three_point_arc_redge": - point_exprs.append(f"_edge_start_point({edge_obj_expr})") - point_exprs.append( - f"_resolve_vec3_param({edge_var}_params, {edge_var}_param_exprs, 'middle')" - ) - point_exprs.append(f"_edge_end_point({edge_obj_expr})") - elif input_node.op == "make_circle_redge": - point_exprs.append( - f"_resolve_vec3_param({edge_var}_params, {edge_var}_param_exprs, 'center')" - ) - elif input_node.op == "make_angle_arc_redge": - point_exprs.append(f"_edge_start_point({edge_obj_expr})") - point_exprs.append(f"_edge_mid_point({edge_obj_expr})") - point_exprs.append(f"_edge_end_point({edge_obj_expr})") - elif input_node.op == "make_spline_redge": - point_exprs.append(f"_edge_start_point({edge_obj_expr})") - point_exprs.append(f"_edge_mid_point({edge_obj_expr})") - point_exprs.append(f"_edge_end_point({edge_obj_expr})") - limitation_payload = _node_expression_limitation(input_node) - if limitation_payload is not None: - lines.append( - f"{var_name}_expr_limitations.append({_py_literal(limitation_payload)})" - ) - if ( - len(input_nodes) == 1 - and input_nodes[0] is not None - and input_nodes[0].op == "make_line_redge" - ): - edge_var = _safe_name(input_nodes[0].node_id) - lines.append( - f"{var_name}_placement, {var_name}_length = _line_sketch_placement(_resolve_vec3_param({edge_var}_params, {edge_var}_param_exprs, 'start'), _resolve_vec3_param({edge_var}_params, {edge_var}_param_exprs, 'end'))" - ) - lines.append(f"{var_name}.Placement = {var_name}_placement") - else: - frame_points = "[" + ", ".join(point_exprs) + "]" - lines.append( - f"{var_name}_placement, {var_name}_origin, {var_name}_xaxis, {var_name}_yaxis = _frame_from_points({frame_points}, {context_literal})" - ) - lines.append(f"{var_name}.Placement = {var_name}_placement") - for geom_index, input_node in enumerate(input_nodes): - assert input_node is not None - edge_var = _safe_name(input_node.node_id) - edge_obj_expr = f"GRAPH_NODES[{_json_ascii(input_node.node_id)}]" - if input_node.op == "make_line_redge": - if len(input_nodes) == 1: - lines.append( - f"{var_name}_placement, {var_name}_length = _line_sketch_placement(_resolve_vec3_param({edge_var}_params, {edge_var}_param_exprs, 'start'), _resolve_vec3_param({edge_var}_params, {edge_var}_param_exprs, 'end'))" - ) - lines.append(f"{var_name}.Placement = {var_name}_placement") - lines.append( - f"{var_name}.addGeometry(Part.LineSegment(App.Vector(0.0, 0.0, 0.0), App.Vector({var_name}_length, 0.0, 0.0)), False)" - ) - lines.append( - f"{var_name}_length_constraint_{geom_index} = {var_name}.addConstraint(Sketcher.Constraint('Distance', {geom_index}, float({var_name}_length)))" - ) - lines.append( - f"{var_name}_dx_constraint_{geom_index} = {var_name}.addConstraint(Sketcher.Constraint('DistanceX', {geom_index}, 1, {geom_index}, 2, float(_resolve_nested_param_value({edge_var}_params, {edge_var}_param_exprs, 'end', 0)) - float(_resolve_nested_param_value({edge_var}_params, {edge_var}_param_exprs, 'start', 0))))" - ) - lines.append( - f"{var_name}_dy_constraint_{geom_index} = {var_name}.addConstraint(Sketcher.Constraint('DistanceY', {geom_index}, 1, {geom_index}, 2, float(_resolve_nested_param_value({edge_var}_params, {edge_var}_param_exprs, 'end', 1)) - float(_resolve_nested_param_value({edge_var}_params, {edge_var}_param_exprs, 'start', 1))))" - ) - lines.append( - f"{var_name}_sketch_bindings.append(('Placement.Base.x', _nested_expr_ref({edge_var}_param_exprs, 'start', 0)))" - ) - lines.append( - f"{var_name}_sketch_bindings.append(('Placement.Base.y', _nested_expr_ref({edge_var}_param_exprs, 'start', 1)))" - ) - lines.append( - f"{var_name}_sketch_bindings.append(('Placement.Base.z', _nested_expr_ref({edge_var}_param_exprs, 'start', 2)))" - ) - lines.append( - f"{var_name}_length_formula = _line_length_formula({edge_var}_params, {edge_var}_param_exprs)" - ) - lines.append( - f"{var_name}.setExpression('Geometry[{geom_index}].EndPoint.x', {var_name}_length_formula) if {var_name}_length_formula else None" - ) - lines.append( - f"{var_name}_constraint_bindings.append((f'Constraints[{{{var_name}_length_constraint_{geom_index}}}]', {var_name}_length_formula))" - ) - lines.append( - f"{var_name}_constraint_bindings.append((f'Constraints[{{{var_name}_dx_constraint_{geom_index}}}]', {_json_ascii(self._line_delta_formula(dict(input_node.param_exprs), 0)) if self._line_delta_formula(dict(input_node.param_exprs), 0) is not None else 'None'}))" - ) - lines.append( - f"{var_name}_constraint_bindings.append((f'Constraints[{{{var_name}_dy_constraint_{geom_index}}}]', {_json_ascii(self._line_delta_formula(dict(input_node.param_exprs), 1)) if self._line_delta_formula(dict(input_node.param_exprs), 1) is not None else 'None'}))" - ) - else: - lines.append( - f"{var_name}.addGeometry(_local_line_from_edge({edge_obj_expr}, {var_name}_origin, {var_name}_xaxis, {var_name}_yaxis), False)" - ) - lines.append( - f"{var_name}_length_formula_{geom_index} = _line_length_formula({edge_var}_params, {edge_var}_param_exprs)" - ) - lines.append( - f"{var_name}_length_value_{geom_index} = _resolve_param_value({edge_var}_params, {edge_var}_param_exprs, 'length') if 'length' in {edge_var}_params else {var_name}.Geometry[{geom_index}].length()" - ) - lines.append( - f"{var_name}_length_constraint_{geom_index} = {var_name}.addConstraint(Sketcher.Constraint('Distance', {geom_index}, float({var_name}_length_value_{geom_index})))" - ) - lines.append( - f"{var_name}_sketch_bindings.extend(_build_local_line_sketch_bindings({edge_var}_params, {edge_var}_param_exprs, geom_index={geom_index}, origin={var_name}_origin, x_axis={var_name}_xaxis, y_axis={var_name}_yaxis))" - ) - lines.append( - f"{var_name}_constraint_bindings.append((f'Constraints[{{{var_name}_length_constraint_{geom_index}}}]', {var_name}_length_formula_{geom_index}))" - ) - elif input_node.op == "make_circle_redge": - lines.append( - f"{var_name}.addGeometry(Part.Circle(_local_point_on_frame(_resolve_vec3_param({edge_var}_params, {edge_var}_param_exprs, 'center'), {var_name}_origin, {var_name}_xaxis, {var_name}_yaxis), App.Vector(0.0, 0.0, 1.0), float(_resolve_param_value({edge_var}_params, {edge_var}_param_exprs, 'radius'))), False)" - ) - lines.append( - f"{var_name}_diameter_constraint_{geom_index} = {var_name}.addConstraint(Sketcher.Constraint('Diameter', {geom_index}, 2.0 * float(_resolve_param_value({edge_var}_params, {edge_var}_param_exprs, 'radius'))))" - ) - if len(input_nodes) == 1: - lines.append( - f"{var_name}_sketch_bindings.append(('Placement.Base.x', _nested_expr_ref({edge_var}_param_exprs, 'center', 0)))" - ) - lines.append( - f"{var_name}_sketch_bindings.append(('Placement.Base.y', _nested_expr_ref({edge_var}_param_exprs, 'center', 1)))" - ) - lines.append( - f"{var_name}_sketch_bindings.append(('Placement.Base.z', _nested_expr_ref({edge_var}_param_exprs, 'center', 2)))" - ) - lines.append( - f"{var_name}_radius_expr_{geom_index} = _expr_formula_from_ref(_nested_expr_ref({edge_var}_param_exprs, 'radius'))" - ) - lines.append( - f"{var_name}_constraint_bindings.append((f'Constraints[{{{var_name}_diameter_constraint_{geom_index}}}]', f'2 * ({{{var_name}_radius_expr_{geom_index}}})' if {var_name}_radius_expr_{geom_index} else None))" - ) - else: - lines.append( - f"{var_name}_sketch_bindings.extend(_build_local_circle_sketch_bindings({edge_var}_params, {edge_var}_param_exprs, geom_index={geom_index}, origin={var_name}_origin, x_axis={var_name}_xaxis, y_axis={var_name}_yaxis))" - ) - lines.append( - f"{var_name}_radius_expr_{geom_index} = _expr_formula_from_ref(_nested_expr_ref({edge_var}_param_exprs, 'radius'))" - ) - lines.append( - f"{var_name}_constraint_bindings.append((f'Constraints[{{{var_name}_diameter_constraint_{geom_index}}}]', f'2 * ({{{var_name}_radius_expr_{geom_index}}})' if {var_name}_radius_expr_{geom_index} else None))" - ) - elif input_node.op == "make_angle_arc_redge": - arc_span_formula = self._angle_arc_span_formula( - dict(input_node.param_exprs) - ) - arc_radius_formula = self._compile_time_expr_formula( - _compile_time_nested_expr_ref( - dict(input_node.param_exprs), "radius" - ) - ) - lines.append( - f"{var_name}.addGeometry(_local_arc_from_edge({edge_obj_expr}, {var_name}_origin, {var_name}_xaxis, {var_name}_yaxis), False)" - ) - lines.append( - f"{var_name}_radius_constraint_{geom_index} = {var_name}.addConstraint(Sketcher.Constraint('Radius', {geom_index}, float(_resolve_param_value({edge_var}_params, {edge_var}_param_exprs, 'radius'))))" - ) - lines.append( - f"{var_name}_angle_constraint_{geom_index} = {var_name}.addConstraint(Sketcher.Constraint('Angle', {geom_index}, float(_resolve_param_value({edge_var}_params, {edge_var}_param_exprs, 'end_angle')) - float(_resolve_param_value({edge_var}_params, {edge_var}_param_exprs, 'start_angle'))))" - ) - lines.append( - f"{var_name}_sketch_bindings.extend(_build_local_angle_arc_sketch_bindings({edge_var}_params, {edge_var}_param_exprs, geom_index={geom_index}, origin={var_name}_origin, x_axis={var_name}_xaxis, y_axis={var_name}_yaxis))" - ) - lines.append( - f"{var_name}_constraint_bindings.append((f'Constraints[{{{var_name}_radius_constraint_{geom_index}}}]', {_json_ascii(arc_radius_formula) if arc_radius_formula is not None else 'None'}))" - ) - lines.append( - f"{var_name}_constraint_bindings.append((f'Constraints[{{{var_name}_angle_constraint_{geom_index}}}]', {_json_ascii(arc_span_formula) if arc_span_formula is not None else 'None'}))" - ) - elif input_node.op == "make_spline_redge": - lines.append( - f"{var_name}.addGeometry(_bspline_curve_from_params({edge_var}_params, transform_point=lambda point: _local_point_on_frame(point, {var_name}_origin, {var_name}_xaxis, {var_name}_yaxis)), False)" - ) - elif input_node.op == "make_three_point_arc_redge": - lines.append( - f"{var_name}.addGeometry(_local_arc_from_edge({edge_obj_expr}, {var_name}_origin, {var_name}_xaxis, {var_name}_yaxis), False)" - ) - lines.append( - f"{var_name}_sketch_bindings.extend(_build_local_three_point_arc_sketch_bindings({edge_var}_params, {edge_var}_param_exprs, geom_index={geom_index}, origin={var_name}_origin, x_axis={var_name}_xaxis, y_axis={var_name}_yaxis))" - ) - lines.append( - f"_apply_sketch_expression_bindings({var_name}, {var_name}_sketch_bindings)" - ) - for pair in _coincident_constraint_pairs(input_nodes): - lines.append( - f"{var_name}.addConstraint(Sketcher.Constraint('Coincident', {pair[0]}, {pair[1]}, {pair[2]}, {pair[3]}))" - ) - lines.append( - f"[_bind_expression({var_name}, prop, expr) for prop, expr in {var_name}_constraint_bindings if expr]" - ) - lines.extend(finish()) - lines.append( - f"{var_name}.SimpleCADExprSupport = 'limited' if {var_name}_expr_limitations else {var_name}.SimpleCADExprSupport" - ) - lines.append( - f"{var_name}.SimpleCADExprLimitation = json.dumps({var_name}_expr_limitations, ensure_ascii=True, sort_keys=True) if {var_name}_expr_limitations else {var_name}.SimpleCADExprLimitation" - ) - lines.append(f"if {var_name}_expr_limitations:") - lines.append( - f" GRAPH_LIMITATIONS[{_json_ascii(node.node_id)}] = {{'op': {_json_ascii(node.op)}, 'reason': json.dumps({var_name}_expr_limitations, ensure_ascii=True, sort_keys=True)}}" - ) - return lines - lines = [ - f"{var_name} = _make_feature({_json_ascii(object_name)}, _wire_shape_from_edge_objects({var_name}_inputs), node_id={_json_ascii(node.node_id)}, op={_json_ascii(node.op)}, params={rp}, inputs={var_name}_inputs, tags={tags_literal}, context={context_literal}, output_count={node.output_count}, param_exprs={param_exprs_literal}, semantic_delta={semantic_delta_literal}, topo_delta={topo_delta_literal})" - ] - return lines - - if node.op == "make_helix_redge": - lines = [ - f"{var_name} = _make_native_object('Part::Helix', {_json_ascii(object_name)}, node_id={_json_ascii(node.node_id)}, op={_json_ascii(node.op)}, params={rp}, inputs={var_name}_inputs, tags={tags_literal}, context={context_literal}, output_count={node.output_count}, param_exprs={param_exprs_literal}, semantic_delta={semantic_delta_literal}, topo_delta={topo_delta_literal})", - f"{var_name}.Pitch = float(_resolve_param_value({rp}, {re}, 'pitch'))", - f"{var_name}.Height = float(_resolve_param_value({rp}, {re}, 'height'))", - f"{var_name}.Radius = float(_resolve_param_value({rp}, {re}, 'radius'))", - f"{var_name}.Placement = App.Placement(_vec(_resolve_vec3_param({rp}, {re}, 'center') if 'center' in {rp} else (0.0, 0.0, 0.0)), App.Rotation(App.Vector(0.0, 0.0, 1.0), _vec(_resolve_vec3_param({rp}, {re}, 'dir') if 'dir' in {rp} else (0.0, 0.0, 1.0))))", - ] - lines.append( - f"_apply_op_expression_bindings({var_name}, {_json_ascii(node.op)}, {re})" - ) - return lines - - if node.op == "make_face_from_wire_rface": - if inputs: - return [ - f"{var_name} = _make_feature({_json_ascii(object_name)}, _face_shape_from_wire_shape(GRAPH_NODES[{_json_ascii(inputs[0])}], {_json_ascii(node.op)}), node_id={_json_ascii(node.node_id)}, op={_json_ascii(node.op)}, params={rp}, inputs={var_name}_inputs, tags={tags_literal}, context={context_literal}, output_count={node.output_count}, param_exprs={param_exprs_literal}, semantic_delta={semantic_delta_literal}, topo_delta={topo_delta_literal})" - ] - input_node = graph.get_node(inputs[0]) if inputs else None - if input_node is not None and input_node.op == "make_wire_from_edges_rwire": - edge_nodes = [graph.get_node(inp.node_id) for inp in input_node.inputs] - if edge_nodes and all( - ed is not None and ed.op == "make_line_redge" for ed in edge_nodes - ): - lines = [ - f"{var_name} = doc.addObject('Sketcher::SketchObject', {_json_ascii(object_name)})" - ] - for edge_node in edge_nodes: - assert edge_node is not None - edge_var = _safe_name(edge_node.node_id) - lines.append( - f"{var_name}.addGeometry(Part.LineSegment(_vec(_resolve_vec3_param({edge_var}_params, {edge_var}_param_exprs, 'start')), _vec(_resolve_vec3_param({edge_var}_params, {edge_var}_param_exprs, 'end'))), False)" - ) - lines.extend(finish()) - return lines - if edge_nodes and all( - ed is not None and ed.op == "make_circle_redge" for ed in edge_nodes - ): - circle_node = edge_nodes[0] - assert circle_node is not None - circle_var = _safe_name(circle_node.node_id) - lines = [ - f"{var_name} = doc.addObject('Sketcher::SketchObject', {_json_ascii(object_name)})", - f"{var_name}.addGeometry(Part.Circle(_vec(_resolve_vec3_param({circle_var}_params, {circle_var}_param_exprs, 'center')), _vec(_resolve_vec3_param({circle_var}_params, {circle_var}_param_exprs, 'normal') if 'normal' in {circle_var}_params else (0.0, 0.0, 1.0)), float(_resolve_param_value({circle_var}_params, {circle_var}_param_exprs, 'radius'))), False)", - f"_apply_sketch_expression_bindings({var_name}, _build_circle_sketch_bindings({circle_var}_param_exprs, geom_index=0, local=False))", - ] - lines.extend(finish()) - return lines - if edge_nodes and all( - ed is not None and ed.op == "make_angle_arc_redge" - for ed in edge_nodes - ): - arc_node = edge_nodes[0] - assert arc_node is not None - lines = [ - f"{var_name} = doc.addObject('Sketcher::SketchObject', {_json_ascii(object_name)})", - f"{var_name}.addGeometry(_arc_from_edge(GRAPH_NODES[{_json_ascii(arc_node.node_id)}]), False)", - f"_apply_sketch_expression_bindings({var_name}, _build_arc_sketch_bindings({_safe_name(arc_node.node_id)}_param_exprs, geom_index=0, prefer_local=False))", - ] - lines.extend(finish()) - return lines - if edge_nodes and all( - ed is not None and ed.op == "make_spline_redge" for ed in edge_nodes - ): - spline_node = edge_nodes[0] - assert spline_node is not None - spline_var = _safe_name(spline_node.node_id) - lines = [ - f"{var_name} = doc.addObject('Sketcher::SketchObject', {_json_ascii(object_name)})", - f"{var_name}.addGeometry(_bspline_curve_from_params({spline_var}_params), False)", - ] - lines.extend(finish()) - return lines - if edge_nodes and all( - ed is not None and ed.op == "make_three_point_arc_redge" - for ed in edge_nodes - ): - arc_node = edge_nodes[0] - assert arc_node is not None - arc_var = _safe_name(arc_node.node_id) - lines = [ - f"{var_name} = doc.addObject('Sketcher::SketchObject', {_json_ascii(object_name)})", - f"{var_name}.addGeometry(Part.ArcOfCircle(Part.Circle(Part.Arc(_vec(_resolve_vec3_param({arc_var}_params, {arc_var}_param_exprs, 'start')), _vec(_resolve_vec3_param({arc_var}_params, {arc_var}_param_exprs, 'middle')), _vec(_resolve_vec3_param({arc_var}_params, {arc_var}_param_exprs, 'end'))).toShape().Curve.Center, Part.Arc(_vec(_resolve_vec3_param({arc_var}_params, {arc_var}_param_exprs, 'start')), _vec(_resolve_vec3_param({arc_var}_params, {arc_var}_param_exprs, 'middle')), _vec(_resolve_vec3_param({arc_var}_params, {arc_var}_param_exprs, 'end'))).toShape().Curve.Axis, Part.Arc(_vec(_resolve_vec3_param({arc_var}_params, {arc_var}_param_exprs, 'start')), _vec(_resolve_vec3_param({arc_var}_params, {arc_var}_param_exprs, 'middle')), _vec(_resolve_vec3_param({arc_var}_params, {arc_var}_param_exprs, 'end'))).toShape().Curve.Radius), Part.Arc(_vec(_resolve_vec3_param({arc_var}_params, {arc_var}_param_exprs, 'start')), _vec(_resolve_vec3_param({arc_var}_params, {arc_var}_param_exprs, 'middle')), _vec(_resolve_vec3_param({arc_var}_params, {arc_var}_param_exprs, 'end'))).FirstParameter, Part.Arc(_vec(_resolve_vec3_param({arc_var}_params, {arc_var}_param_exprs, 'start')), _vec(_resolve_vec3_param({arc_var}_params, {arc_var}_param_exprs, 'middle')), _vec(_resolve_vec3_param({arc_var}_params, {arc_var}_param_exprs, 'end'))).LastParameter), False)", - ] - lines.extend(finish()) - return lines - - if node.op == "make_face_from_wires_rface" and len(inputs) >= 1: - return [ - "doc.recompute()", - f"{var_name} = _make_feature({_json_ascii(object_name)}, _face_shape_from_wire_shapes(GRAPH_NODES[{_json_ascii(inputs[0])}], [GRAPH_NODES[node_id] for node_id in {var_name}_inputs[1:]], {_json_ascii(node.op)}), node_id={_json_ascii(node.node_id)}, op={_json_ascii(node.op)}, params={rp}, inputs={var_name}_inputs, tags={tags_literal}, context={context_literal}, output_count={node.output_count}, param_exprs={param_exprs_literal}, semantic_delta={semantic_delta_literal}, topo_delta={topo_delta_literal})", - ] - - if node.op == "make_extrude_rsolid" and len(inputs) == 1: - base_node = graph.get_node(inputs[0]) - profile_node = base_node - if ( - profile_node is not None - and profile_node.op == "make_face_from_wire_rface" - and profile_node.inputs - ): - profile_node = graph.get_node(profile_node.inputs[0].node_id) - circle_node = None - if ( - profile_node is not None - and profile_node.op == "make_wire_from_edges_rwire" - and len(profile_node.inputs) == 1 - ): - edge_node = graph.get_node(profile_node.inputs[0].node_id) - if edge_node is not None and edge_node.op == "make_circle_redge": - circle_node = edge_node - if circle_node is not None: - circle_var = _safe_name(circle_node.node_id) - lines = [ - f"{var_name} = doc.addObject('Part::Cylinder', {_json_ascii(object_name)})", - f"{var_name}.Radius = float(_resolve_param_value({circle_var}_params, {circle_var}_param_exprs, 'radius'))", - f"{var_name}.Height = float(_resolve_param_value({rp}, {re}, 'distance'))", - f"{var_name}.Placement = App.Placement(_vec(_resolve_vec3_param({circle_var}_params, {circle_var}_param_exprs, 'center')), App.Rotation(App.Vector(0.0, 0.0, 1.0), _normalized_vec(_resolve_vec3_param({rp}, {re}, 'direction'))))", - ] - lines.extend(finish()) - return lines - if base_node is not None and base_node.op in { - "make_face_from_wire_rface", - "make_face_from_wires_rface", - "make_wire_from_edges_rwire", - "make_face_from_sketch_rface", - "make_wire_from_sketch_rwire", - "make_2d_cut_rface", - "make_2d_union_rface", - "make_2d_intersect_rface", - }: - sketch_node_id = inputs[0] - if base_node.op == "make_face_from_wire_rface" and base_node.inputs: - sketch_node_id = base_node.inputs[0].node_id - base_expr = f"GRAPH_NODES[{_json_ascii(sketch_node_id)}]" - lines: List[str] = [] - lines.extend( - [ - f"{var_name} = doc.addObject('Part::Extrusion', {_json_ascii(object_name)})", - f"{var_name}.Base = {base_expr}", - f"{var_name}.DirMode = 'Custom'", - f"{var_name}.Dir = _vec(_resolve_vec3_param({rp}, {re}, 'direction'))", - f"{var_name}.LengthFwd = float(_resolve_param_value({rp}, {re}, 'distance'))", - f"{var_name}.LengthRev = 0.0", - f"{var_name}.Solid = True", - ] - ) - lines.append( - f"_apply_op_expression_bindings({var_name}, {_json_ascii(node.op)}, {re})" - ) - lines.extend(finish()) - return lines - - if node.op == "make_revolve_rsolid" and len(inputs) == 1: - base_node = graph.get_node(inputs[0]) - if base_node is not None and base_node.op == "make_face_from_wire_rface": - lines = [ - f"{var_name} = doc.addObject('Part::Revolution', {_json_ascii(object_name)})", - f"{var_name}.Source = GRAPH_NODES[{_json_ascii(inputs[0])}]", - f"{var_name}.Axis = _vec(_resolve_vec3_param({rp}, {re}, 'axis') if 'axis' in {rp} else (0.0, 0.0, 1.0))", - f"{var_name}.Base = _vec(_resolve_vec3_param({rp}, {re}, 'origin') if 'origin' in {rp} else (0.0, 0.0, 0.0))", - f"{var_name}.Angle = float(_resolve_param_value({rp}, {re}, 'angle') if 'angle' in {rp} else 360.0)", - f"{var_name}.Solid = True", - ] - lines.append( - f"_apply_op_expression_bindings({var_name}, {_json_ascii(node.op)}, {re})" - ) - lines.extend(finish()) - return lines - - if node.op == "make_loft_rsolid" and len(inputs) >= 2: - lines = [ - f"{var_name} = doc.addObject('Part::Loft', {_json_ascii(object_name)})", - f"{var_name}.Sections = [GRAPH_NODES[node_id] for node_id in {var_name}_inputs]", - f"{var_name}.Solid = True", - f"{var_name}.Ruled = bool(_resolve_param_value({rp}, {re}, 'ruled') if 'ruled' in {rp} else False)", - ] - lines.append( - f"_apply_op_expression_bindings({var_name}, {_json_ascii(node.op)}, {re})" - ) - lines.extend(finish()) - return lines - - if node.op == "make_sweep_rsolid" and len(inputs) == 2: - lines = [ - f"{var_name} = doc.addObject('Part::Sweep', {_json_ascii(object_name)})", - f"{var_name}.Sections = [GRAPH_NODES[{_json_ascii(inputs[0])}]]", - f"{var_name}.Spine = _spine_object({_json_ascii(inputs[1])})", - f"{var_name}.Solid = True", - f"{var_name}.Frenet = bool(_resolve_param_value({rp}, {re}, 'is_frenet') if 'is_frenet' in {rp} else False)", - ] - lines.append( - f"_apply_op_expression_bindings({var_name}, {_json_ascii(node.op)}, {re})" - ) - lines.extend(finish()) - return lines - - if node.op == "make_cut_rsolid" and len(inputs) >= 2: - if len(inputs) == 2: - base_name = _safe_name(f"{object_name}_base", prefix="operand") - tool_name = _safe_name(f"{object_name}_tool", prefix="operand") - lines = [ - "doc.recompute()", - f"{var_name} = doc.addObject('Part::Cut', {_json_ascii(object_name)})", - f"{var_name}.Base = _materialize_boolean_operand(GRAPH_NODES[{_json_ascii(inputs[0])}], {_json_ascii(base_name)})", - f"{var_name}.Tool = _materialize_boolean_operand(GRAPH_NODES[{_json_ascii(inputs[1])}], {_json_ascii(tool_name)})", - ] - else: - lines = ["doc.recompute()"] - base_name = _safe_name(f"{object_name}_base", prefix="operand") - previous_expr = f"_materialize_boolean_operand(GRAPH_NODES[{_json_ascii(inputs[0])}], {_json_ascii(base_name)})" - for index, tool_node_id in enumerate(inputs[1:], start=1): - is_last = index == len(inputs) - 1 - step_var = var_name if is_last else f"{var_name}_step_{index}" - step_name = object_name if is_last else _safe_name( - f"{object_name}_step_{index}", prefix="step" - ) - tool_name = _safe_name(f"{object_name}_tool_{index}", prefix="operand") - lines.extend( - [ - f"{step_var} = doc.addObject('Part::Cut', {_json_ascii(step_name)})", - f"{step_var}.Base = {previous_expr}", - f"{step_var}.Tool = _materialize_boolean_operand(GRAPH_NODES[{_json_ascii(tool_node_id)}], {_json_ascii(tool_name)})", - ] - ) - if not is_last: - lines.append(f"_set_visibility({step_var}, False)") - lines.append("doc.recompute()") - previous_expr = step_var - lines.extend(finish()) - return lines - - if node.op == "make_union_rsolid" and len(inputs) >= 2: - if len(inputs) == 2: - base_name = _safe_name(f"{object_name}_base", prefix="operand") - tool_name = _safe_name(f"{object_name}_tool", prefix="operand") - lines = [ - "doc.recompute()", - f"{var_name} = doc.addObject('Part::Fuse', {_json_ascii(object_name)})", - f"{var_name}.Base = _materialize_boolean_operand(GRAPH_NODES[{_json_ascii(inputs[0])}], {_json_ascii(base_name)})", - f"{var_name}.Tool = _materialize_boolean_operand(GRAPH_NODES[{_json_ascii(inputs[1])}], {_json_ascii(tool_name)})", - ] - else: - lines = [ - "doc.recompute()", - f"{var_name} = doc.addObject('Part::MultiFuse', {_json_ascii(object_name)})", - f"{var_name}.Shapes = [_materialize_boolean_operand(GRAPH_NODES[node_id], {_json_ascii(object_name)} + '_operand_' + str(index)) for index, node_id in enumerate({var_name}_inputs)]", - ] - lines.extend(finish()) - return lines - - if node.op == "make_intersect_rsolid" and len(inputs) >= 2: - if len(inputs) == 2: - base_name = _safe_name(f"{object_name}_base", prefix="operand") - tool_name = _safe_name(f"{object_name}_tool", prefix="operand") - lines = [ - f"{var_name} = doc.addObject('Part::Common', {_json_ascii(object_name)})", - f"{var_name}.Base = _materialize_boolean_operand(GRAPH_NODES[{_json_ascii(inputs[0])}], {_json_ascii(base_name)})", - f"{var_name}.Tool = _materialize_boolean_operand(GRAPH_NODES[{_json_ascii(inputs[1])}], {_json_ascii(tool_name)})", - ] - else: - lines = [ - f"{var_name} = doc.addObject('Part::MultiCommon', {_json_ascii(object_name)})", - f"{var_name}.Shapes = [_materialize_boolean_operand(GRAPH_NODES[node_id], {_json_ascii(object_name)} + '_operand_' + str(index)) for index, node_id in enumerate({var_name}_inputs)]", - ] - lines.extend(finish()) - return lines - - if node.op in {"make_2d_cut_rface", "make_2d_union_rface", "make_2d_intersect_rface"} and len(inputs) >= 2: - lines = [ - "doc.recompute()", - f"{var_name} = _make_feature({_json_ascii(object_name)}, _face_boolean_shape({_json_ascii(node.op)}, GRAPH_NODES[{_json_ascii(inputs[0])}], GRAPH_NODES[{_json_ascii(inputs[1])}]), node_id={_json_ascii(node.node_id)}, op={_json_ascii(node.op)}, params={rp}, inputs={var_name}_inputs, tags={tags_literal}, context={context_literal}, output_count={node.output_count}, param_exprs={param_exprs_literal}, semantic_delta={semantic_delta_literal}, topo_delta={topo_delta_literal})", - ] - return lines - - if node.op == "make_fillet_rsolid" and len(inputs) >= 1: - lines = [ - f"{var_name} = doc.addObject('Part::Fillet', {_json_ascii(object_name)})", - f"{var_name}.Base = GRAPH_NODES[{_json_ascii(inputs[0])}]", - f"{var_name}.Edges = [(int(idx) + 1, float(_resolve_param_value({rp}, {re}, 'radius')), float(_resolve_param_value({rp}, {re}, 'radius'))) for idx in _selected_indices_from_nodes({rp}.get('selected_edge_node_ids', []), {rp}.get('selected_edge_indices', []), _shape_from_graph_node({_json_ascii(inputs[0])}), 'edge')]", - ] - lines.append(f"_apply_detail_feature_bindings({var_name}, {re}, 'radius')") - lines.extend(finish()) - return lines - - if node.op == "make_chamfer_rsolid" and len(inputs) >= 1: - lines = [ - f"{var_name} = doc.addObject('Part::Chamfer', {_json_ascii(object_name)})", - f"{var_name}.Base = GRAPH_NODES[{_json_ascii(inputs[0])}]", - f"{var_name}.Edges = [(int(idx) + 1, float(_resolve_param_value({rp}, {re}, 'distance')), float(_resolve_param_value({rp}, {re}, 'distance'))) for idx in _selected_indices_from_nodes({rp}.get('selected_edge_node_ids', []), {rp}.get('selected_edge_indices', []), _shape_from_graph_node({_json_ascii(inputs[0])}), 'edge')]", - ] - lines.append( - f"_apply_detail_feature_bindings({var_name}, {re}, 'distance')" - ) - lines.extend(finish()) - return lines - - if node.op == "make_shell_rsolid" and len(inputs) >= 1: - lines = [ - f"{var_name} = doc.addObject('Part::Thickness', {_json_ascii(object_name)})", - f"{var_name}.Value = float(_resolve_param_value({rp}, {re}, 'thickness'))", - ] - lines.append( - f"_apply_op_expression_bindings({var_name}, {_json_ascii(node.op)}, {re})" - ) - if node.params.get("selected_face_indices") or node.params.get("selected_face_node_ids"): - face_name_expr = f"['Face' + str(int(i) + 1) for i in _selected_indices_from_nodes({rp}.get('selected_face_node_ids', []), {rp}.get('selected_face_indices', []), _shape_from_graph_node({_json_ascii(inputs[0])}), 'face')]" - lines.append( - f"{var_name}.Faces = (GRAPH_NODES[{_json_ascii(inputs[0])}], {face_name_expr})" - ) - lines.extend(finish()) - return lines - - if node.op == "make_mirror_rshape" and len(inputs) == 1: - lines = [ - f"{var_name} = doc.addObject('Part::Mirroring', {_json_ascii(object_name)})", - f"{var_name}.Source = GRAPH_NODES[{_json_ascii(inputs[0])}]", - f"{var_name}.Base = _vec(_resolve_vec3_param({rp}, {re}, 'plane_origin') if 'plane_origin' in {rp} else (0.0, 0.0, 0.0))", - f"{var_name}.Normal = _vec(_resolve_vec3_param({rp}, {re}, 'plane_normal') if 'plane_normal' in {rp} else (0.0, 0.0, 1.0))", - ] - lines.append( - f"_apply_op_expression_bindings({var_name}, {_json_ascii(node.op)}, {re})" - ) - lines.extend(finish()) - return lines - - if node.op == "make_translate_rshape" and len(inputs) == 1: - vector = node.params.get("vector") - if isinstance(vector, (list, tuple)) and len(vector) == 3: - try: - if all(abs(float(v)) <= 1e-12 for v in vector) and not _contains_expr_refs(dict(node.param_exprs)): - return finish_alias(inputs[0]) - except Exception: - pass - if self._can_fold_transform_into_input(node): - lines = [ - f"{var_name} = GRAPH_NODES[{_json_ascii(inputs[0])}]", - f"_fold_object_placement({var_name}, App.Placement(_vec(_resolve_vec3_param({rp}, {re}, 'vector')), App.Rotation()))", - ] - lines.append( - f"_apply_op_expression_bindings({var_name}, {_json_ascii(node.op)}, {re})" - ) - lines.append( - f"{var_name} = _register_graph_folded_alias(node_id={_json_ascii(node.node_id)}, source_node_id={_json_ascii(inputs[0])}, op={_json_ascii(node.op)}, params={rp}, inputs={var_name}_inputs, tags={tags_literal}, context={context_literal}, output_count={node.output_count}, param_exprs={param_exprs_literal}, semantic_delta={semantic_delta_literal}, topo_delta={topo_delta_literal})" - ) - return lines - if self._should_materialize_transform_for_loft_section(node): - lines = [ - f"{var_name}_shape = _shape_from_graph_node({_json_ascii(inputs[0])}).copy()", - f"{var_name}_placement = App.Placement(_vec(_resolve_vec3_param({rp}, {re}, 'vector')), App.Rotation())", - f"{var_name}_shape.Placement = {var_name}_placement.multiply({var_name}_shape.Placement)", - f"{var_name} = _make_feature({_json_ascii(object_name)}, {var_name}_shape, node_id={_json_ascii(node.node_id)}, op={_json_ascii(node.op)}, params={rp}, inputs={var_name}_inputs, tags={tags_literal}, context={context_literal}, output_count={node.output_count}, param_exprs={param_exprs_literal}, semantic_delta={semantic_delta_literal}, topo_delta={topo_delta_literal})", - ] - return lines - lines = [ - f"{var_name} = doc.addObject('App::Link', {_json_ascii(object_name)})", - f"{var_name}.LinkedObject = GRAPH_NODES[{_json_ascii(inputs[0])}]", - f"{var_name}.Placement = App.Placement(_vec(_resolve_vec3_param({rp}, {re}, 'vector')), App.Rotation())", - ] - lines.append( - f"_apply_op_expression_bindings({var_name}, {_json_ascii(node.op)}, {re})" - ) - lines.extend(finish()) - return lines - - if node.op == "make_rotate_rshape" and len(inputs) == 1: - if self._can_fold_transform_into_input(node): - lines = [ - f"{var_name} = GRAPH_NODES[{_json_ascii(inputs[0])}]", - f"_fold_object_placement({var_name}, _placement_for_rotation(_resolve_vec3_param({rp}, {re}, 'origin') if 'origin' in {rp} else (0.0, 0.0, 0.0), _resolve_vec3_param({rp}, {re}, 'axis') if 'axis' in {rp} else (0.0, 0.0, 1.0), _resolve_param_value({rp}, {re}, 'angle')))", - ] - lines.append( - f"_apply_op_expression_bindings({var_name}, {_json_ascii(node.op)}, {re})" - ) - lines.append( - f"{var_name} = _register_graph_folded_alias(node_id={_json_ascii(node.node_id)}, source_node_id={_json_ascii(inputs[0])}, op={_json_ascii(node.op)}, params={rp}, inputs={var_name}_inputs, tags={tags_literal}, context={context_literal}, output_count={node.output_count}, param_exprs={param_exprs_literal}, semantic_delta={semantic_delta_literal}, topo_delta={topo_delta_literal})" - ) - return lines - if self._should_materialize_transform_for_loft_section(node): - lines = [ - f"{var_name}_shape = _shape_from_graph_node({_json_ascii(inputs[0])}).copy()", - f"{var_name}_placement = _placement_for_rotation(_resolve_vec3_param({rp}, {re}, 'origin') if 'origin' in {rp} else (0.0, 0.0, 0.0), _resolve_vec3_param({rp}, {re}, 'axis') if 'axis' in {rp} else (0.0, 0.0, 1.0), _resolve_param_value({rp}, {re}, 'angle'))", - f"{var_name}_shape.Placement = {var_name}_placement.multiply({var_name}_shape.Placement)", - f"{var_name} = _make_feature({_json_ascii(object_name)}, {var_name}_shape, node_id={_json_ascii(node.node_id)}, op={_json_ascii(node.op)}, params={rp}, inputs={var_name}_inputs, tags={tags_literal}, context={context_literal}, output_count={node.output_count}, param_exprs={param_exprs_literal}, semantic_delta={semantic_delta_literal}, topo_delta={topo_delta_literal})", - ] - return lines - lines = [ - f"{var_name} = doc.addObject('App::Link', {_json_ascii(object_name)})", - f"{var_name}.LinkedObject = GRAPH_NODES[{_json_ascii(inputs[0])}]", - f"{var_name}.Placement = _placement_for_rotation(_resolve_vec3_param({rp}, {re}, 'origin') if 'origin' in {rp} else (0.0, 0.0, 0.0), _resolve_vec3_param({rp}, {re}, 'axis') if 'axis' in {rp} else (0.0, 0.0, 1.0), _resolve_param_value({rp}, {re}, 'angle'))", - ] - lines.append( - f"_apply_op_expression_bindings({var_name}, {_json_ascii(node.op)}, {re})" - ) - lines.extend(finish()) - return lines - - return None - - def _node_optional_payload(self, node: OperationNode, attr: str) -> Dict[str, Any]: - value = getattr(node, attr) - if value is None: - return {} - if hasattr(value, "created"): - return { - "created": [self._dataclass_ref_dict(ref) for ref in value.created], - "modified": [self._dataclass_ref_dict(ref) for ref in value.modified], - "deleted": [self._dataclass_ref_dict(ref) for ref in value.deleted], - "metadata": dict(value.metadata), - } - return { - "preserved": [self._dataclass_ref_dict(ref) for ref in value.preserved], - "modified": [self._dataclass_ref_dict(ref) for ref in value.modified], - "generated": [self._dataclass_ref_dict(ref) for ref in value.generated], - "deleted": [self._dataclass_ref_dict(ref) for ref in value.deleted], - "section_edges": [ - self._dataclass_ref_dict(ref) for ref in value.section_edges - ], - "entries": [ - { - "ref": self._dataclass_ref_dict(entry.ref), - "event": getattr(entry.event, "name", str(entry.event)), - "origin_role": entry.origin_role, - "parent_refs": [ - self._dataclass_ref_dict(ref) for ref in entry.parent_refs - ], - "metadata": dict(entry.metadata), - } - for entry in value.entries - ], - "raw_event": dict(value.raw_event), - } - - def _dataclass_ref_dict(self, ref: Any) -> Dict[str, Any]: - payload = dict(ref.__dict__) - if "kind" in payload and hasattr(payload["kind"], "name"): - payload["kind"] = payload["kind"].name - return payload - +__all__ = ["FreeCADScriptTranslator", "FreeCADTranslator"] diff --git a/SimpleCADAPI/src/simplecadapi/translator/freecad_translator/translator.py b/SimpleCADAPI/src/simplecadapi/translator/freecad_translator/translator.py new file mode 100644 index 0000000..8ee12fa --- /dev/null +++ b/SimpleCADAPI/src/simplecadapi/translator/freecad_translator/translator.py @@ -0,0 +1,674 @@ +"""Translate SimpleCAD model/graph payloads into FreeCAD Python API scripts. + +This module intentionally targets FreeCAD's Python API, not raw `.FCStd` +internals. Generated scripts can be executed inside FreeCAD/FreeCADCmd and then +saved as `.FCStd` by FreeCAD itself. +""" + +from __future__ import annotations + +from typing import Any, Dict, List, Optional, Set + +from ...serializer import import_model_json +from ...topology import OperationGraph, OperationNode +from ..base import BaseTranslator +from ..types import BackendCapabilities, TranslationArtifact +from .analysis import ( + can_fold_transform_into_input, + can_lower_circle_extrusion_to_cylinder, + find_cylinder_profile_nodes, + should_materialize_transform_for_loft_section, + transform_feeds_only_loft, +) +from .capabilities import CAPABILITIES +from .codegen import ( + _OP_EXPRESSION_BINDINGS, + _OP_EXPRESSION_LIMITATIONS, + _canonical_variable_default, + _compile_time_nested_expr_ref, + _expression_physical_metadata, + _json_ascii, + _py_literal, + _safe_name, + _sanitize_expr_alias, + _spreadsheet_expr_aliases, +) +from .context import FreeCADCompileContext +from .emitters import ( + BooleanEmitterMixin, + FeatureEmitterMixin, + GeometryEmitterMixin, + PrimitiveEmitterMixin, + ProductEmitterMixin, + SelectionEmitterMixin, + SketchEmitterMixin, + TransformEmitterMixin, + emit_native_node, +) +from .runtime import assemble_runtime_source + + +def _curve_params_with_kernel_axes(params: Dict[str, Any]) -> Dict[str, Any]: + """Add the source OCC periodic basis without mutating graph parameters.""" + + from OCP.gp import gp_Ax2, gp_Dir, gp_Pnt + + enriched = dict(params) + normal = enriched.get("normal", (0.0, 0.0, 1.0)) + axis = gp_Ax2( + gp_Pnt(0.0, 0.0, 0.0), + gp_Dir(float(normal[0]), float(normal[1]), float(normal[2])), + ) + x_axis = axis.XDirection() + y_axis = axis.YDirection() + enriched["_kernel_x_axis"] = [x_axis.X(), x_axis.Y(), x_axis.Z()] + enriched["_kernel_y_axis"] = [y_axis.X(), y_axis.Y(), y_axis.Z()] + return enriched + + +class _FreeCADCompiler( + PrimitiveEmitterMixin, + ProductEmitterMixin, + SelectionEmitterMixin, + SketchEmitterMixin, + GeometryEmitterMixin, + FeatureEmitterMixin, + BooleanEmitterMixin, + TransformEmitterMixin, +): + """Compile a SimpleCAD model payload into a FreeCAD Python script. + + Current design goals: + + - Translate only from the canonical low-level `graph` IR + - Preserve node metadata and graph lineage as FreeCAD custom properties + - Preserve `expression_graph` as explicit translator metadata + - Preserve dimension tolerances and tolerance-chain requirements as metadata + - Preserve exported assembly constraints as document metadata objects + - Keep assembly metadata from the full model payload alongside the IR-driven + geometry translation + + The generated script focuses on `Part`-workbench-style objects and shape + construction, which is a better first target for the current canonical graph + than a full `Sketcher/PartDesign` mapping. + """ + + def __init__(self, document_name: str = "SimpleCADModel") -> None: + self._context = FreeCADCompileContext(document_name=document_name) + + @property + def document_name(self) -> str: + return self._context.document_name + + @property + def _source_graph(self) -> Optional[OperationGraph]: + return self._context.source_graph + + @property + def _expr_alias_by_id(self) -> Dict[str, str]: + return self._context.expression_aliases + + @_expr_alias_by_id.setter + def _expr_alias_by_id(self, value: Dict[str, str]) -> None: + self._context.expression_aliases = value + + @property + def _result_node_ids(self) -> Set[str]: + return self._context.result_node_ids + + @property + def _result_node_id_list(self) -> List[str]: + return self._context.result_node_id_list + + @_result_node_id_list.setter + def _result_node_id_list(self, value: List[str]) -> None: + self._context.result_node_id_list = value + + @property + def _suppressed_profile_node_ids(self) -> Set[str]: + return self._context.suppressed_profile_node_ids + + @_suppressed_profile_node_ids.setter + def _suppressed_profile_node_ids(self, value: Set[str]) -> None: + self._context.suppressed_profile_node_ids = value + + def _compile_time_expr_formula(self, expr_ref: Any) -> Optional[str]: + if not isinstance(expr_ref, dict): + return None + expr_id = str(expr_ref.get("expr_id") or "") + if not expr_id: + return None + alias = self._expr_alias_by_id.get(expr_id) + if not alias: + alias = _sanitize_expr_alias(expr_id, prefix="expr") + return f"<>.{alias}" + + def _angle_arc_span_formula(self, param_exprs: Dict[str, Any]) -> Optional[str]: + start_expr = self._compile_time_expr_formula( + _compile_time_nested_expr_ref(param_exprs, "start_angle") + ) + end_expr = self._compile_time_expr_formula( + _compile_time_nested_expr_ref(param_exprs, "end_angle") + ) + if start_expr is None and end_expr is None: + return None + if start_expr is None: + return end_expr + if end_expr is None: + return f"0 - ({start_expr})" + return f"({end_expr}) - ({start_expr})" + + def _line_delta_formula( + self, param_exprs: Dict[str, Any], axis: int + ) -> Optional[str]: + start_expr = self._compile_time_expr_formula( + _compile_time_nested_expr_ref(param_exprs, "start", axis) + ) + end_expr = self._compile_time_expr_formula( + _compile_time_nested_expr_ref(param_exprs, "end", axis) + ) + if start_expr is None and end_expr is None: + return None + if start_expr is None: + return end_expr + if end_expr is None: + return f"0 - ({start_expr})" + return f"({end_expr}) - ({start_expr})" + + def translate_model_json_to_script(self, json_str: str) -> str: + payload = import_model_json(json_str) + graph = payload.get("graph") + if not isinstance(graph, OperationGraph): + raise ValueError( + "FreeCAD translation requires model JSON with a canonical low-level graph" + ) + if graph.node_count == 0: + raise ValueError( + "FreeCAD translation requires model JSON with a non-empty canonical low-level graph" + ) + return self.translate_model_payload_to_script(payload, graph=graph) + + def translate_model_payload_to_script( + self, + payload: Dict[str, Any], + *, + graph: Optional[OperationGraph] = None, + ) -> str: + source_graph = graph or payload.get("graph") + if not isinstance(source_graph, OperationGraph): + raise ValueError( + "FreeCAD translation requires payload to contain a canonical low-level graph" + ) + if source_graph.node_count == 0: + raise ValueError( + "FreeCAD translation requires payload to contain a non-empty canonical low-level graph" + ) + self._context.source_graph = source_graph + leaf_ids = payload.get("leaf_ids") + if isinstance(leaf_ids, list) and leaf_ids: + self._result_node_id_list = [str(v) for v in leaf_ids] + else: + self._result_node_id_list = [leaf.node_id for leaf in source_graph.leaf_nodes()] + self._context.result_node_ids = set(self._result_node_id_list) + self._suppressed_profile_node_ids = self._find_cylinder_profile_nodes(source_graph) + + lines: List[str] = [] + emit = lines.append + + emit("import json") + emit("import math") + emit("import FreeCAD as App") + emit("import Part") + emit("try:") + emit(" import Sketcher") + emit("except Exception:") + emit(" Sketcher = None") + emit("try:") + emit(" import Assembly") + emit("except Exception:") + emit(" Assembly = None") + emit("try:") + emit(" import JointObject") + emit("except Exception:") + emit(" JointObject = None") + emit("try:") + emit(" import Spreadsheet") + emit("except Exception:") + emit(" Spreadsheet = None") + emit("import os") + emit("import zipfile") + emit("") + emit(f"DOC_NAME = {_json_ascii(self.document_name)}") + emit( + "doc = App.getDocument(DOC_NAME) if DOC_NAME in App.listDocuments() else App.newDocument(DOC_NAME)" + ) + emit("GRAPH_NODES = {}") + emit("GRAPH_OUTPUTS = {}") + emit("GRAPH_METADATA = {}") + emit("GRAPH_SELECTIONS = {}") + emit("GRAPH_SPINE_OBJECTS = {}") + emit("GRAPH_LIMITATIONS = {}") + emit("GRAPH_TRANSLATION_LIMITATIONS = {}") + emit("PRODUCT_VALUES = {}") + emit("ASSEMBLY_PROJECTION_INPUTS = {}") + emit("GUI_VISIBILITY_BY_NAME = {}") + emit("GUI_EXPANDED_BY_NAME = {}") + emit("GUI_SHAPE_COLOR_BY_NAME = {}") + emit("GUI_MATERIAL_OVERRIDE_BY_NAME = {}") + emit("MATERIAL_OBJECTS_BY_ID = {}") + emit("SIMPLECAD_JOINT_OBJECTS = {}") + emit("SKETCH_REGISTRY = []") + expression_graph_payload = payload.get("expression_graph", {}) + if hasattr(expression_graph_payload, "to_dict"): + expression_graph_payload = expression_graph_payload.to_dict() + self._expr_alias_by_id = {} + nodes = ( + expression_graph_payload.get("nodes", []) + if isinstance(expression_graph_payload, dict) + else [] + ) + if isinstance(nodes, list): + self._expr_alias_by_id = _spreadsheet_expr_aliases(nodes) + emit(f"EXPRESSION_GRAPH = {_py_literal(expression_graph_payload)}") + tolerance_graph_payload = payload.get("tolerance_graph", {}) + if hasattr(tolerance_graph_payload, "to_dict"): + tolerance_graph_payload = tolerance_graph_payload.to_dict() + emit(f"TOLERANCE_GRAPH = {_py_literal(tolerance_graph_payload)}") + emit(f"OP_EXPRESSION_BINDINGS = {_py_literal(_OP_EXPRESSION_BINDINGS)}") + emit(f"OP_EXPRESSION_LIMITATIONS = {_py_literal(_OP_EXPRESSION_LIMITATIONS)}") + emit("") + emit(assemble_runtime_source()) + emit("") + + for line in self._emit_expression_graph(expression_graph_payload): + emit(line) + emit("") + + emit("EXPRESSION_GRAPH_META = EXPRESSION_GRAPH") + emit("TOLERANCE_GRAPH_META = TOLERANCE_GRAPH") + emit("if TOLERANCE_GRAPH.get('requirements'):") + emit( + " _make_metadata_note('simplecad_tolerance_graph', 'SimpleCAD Tolerance Graph', TOLERANCE_GRAPH)" + ) + emit("") + + for node in source_graph.topological_order(): + emit(f"# Step {node.node_id}: {node.op}") + for line in self._emit_node(node): + emit(line) + emit("") + + emit("if GRAPH_LIMITATIONS:") + emit( + " _make_metadata_note('simplecad_expression_limitations', 'SimpleCAD Expression Limitations', GRAPH_LIMITATIONS)" + ) + emit("if GRAPH_TRANSLATION_LIMITATIONS:") + emit( + " _make_metadata_note('simplecad_translation_limitations', 'SimpleCAD Translation Limitations', GRAPH_TRANSLATION_LIMITATIONS)" + ) + emit("") + + emit("doc.recompute()") + emit("") + emit("# Leaf/result metadata") + emit(f"RESULT_NODE_IDS = {_py_literal(self._result_node_id_list)}") + emit( + "RESULT_OBJECTS = [obj for node_id in RESULT_NODE_IDS for obj in GRAPH_OUTPUTS.get(node_id, [])]" + ) + emit("_apply_result_visibility(RESULT_NODE_IDS)") + emit("_set_active_result_object(RESULT_NODE_IDS)") + emit("doc.TransientDir = getattr(doc, 'TransientDir', '')") + return "\n".join(lines).rstrip() + "\n" + + def _find_cylinder_profile_nodes(self, graph: OperationGraph) -> Set[str]: + return find_cylinder_profile_nodes(graph, self._result_node_ids) + + def _can_lower_circle_extrusion_to_cylinder( + self, circle_node: OperationNode, extrusion_node: OperationNode + ) -> bool: + return can_lower_circle_extrusion_to_cylinder(circle_node, extrusion_node) + + def _emit_expression_graph(self, expression_graph_payload: Any) -> List[str]: + if not isinstance(expression_graph_payload, dict): + return [] + nodes = expression_graph_payload.get("nodes", []) + if not isinstance(nodes, list) or not nodes: + return [] + + lines: List[str] = ["# Expression graph -> Spreadsheet"] + lines.append("EXPR_CELL_BY_ID = {}") + lines.append("EXPR_ALIAS_BY_ID = {}") + lines.append("if Spreadsheet is not None:") + lines.append( + " expr_sheet = doc.addObject('Spreadsheet::Sheet', 'SimpleCADExpressions')" + ) + alias_by_id = _spreadsheet_expr_aliases(nodes) + dimension_by_id, unit_aware_by_id = _expression_physical_metadata(nodes) + row = 1 + for node in nodes: + if not isinstance(node, dict): + continue + expr_id = str(node.get("expr_id", f"expr_{row}")) + alias = alias_by_id[expr_id] + cell = f"B{row}" + lines.append( + f" EXPR_CELL_BY_ID[{_json_ascii(expr_id)}] = {_json_ascii(cell)}" + ) + lines.append( + f" EXPR_ALIAS_BY_ID[{_json_ascii(expr_id)}] = {_json_ascii(alias)}" + ) + lines.append(f" expr_sheet.set('A{row}', {_json_ascii(alias)})") + lines.append(f" expr_sheet.set('C{row}', {_json_ascii(expr_id)})") + comment = ( + str(node.get("comment", "") or "") + if str(node.get("kind", "")) == "var" + else "" + ) + lines.append(f" expr_sheet.set('D{row}', {_json_ascii(comment)})") + tolerance = node.get("tolerance") + if isinstance(tolerance, dict): + lower_deviation = str(tolerance.get("lower_deviation", "")) + upper_deviation = str(tolerance.get("upper_deviation", "")) + else: + lower_deviation = "" + upper_deviation = "" + lines.append( + f" expr_sheet.set('E{row}', {_json_ascii(lower_deviation)})" + ) + lines.append( + f" expr_sheet.set('F{row}', {_json_ascii(upper_deviation)})" + ) + nominal_unit = node.get("unit", "") + if isinstance(nominal_unit, dict): + nominal_unit = nominal_unit.get("symbol", "") + tolerance_unit = node.get("tolerance_unit", "") + if isinstance(tolerance_unit, dict): + tolerance_unit = tolerance_unit.get("symbol", "") + lines.append( + f" expr_sheet.set('G{row}', {_json_ascii(str(nominal_unit))})" + ) + lines.append( + f" expr_sheet.set('H{row}', {_json_ascii(str(tolerance_unit))})" + ) + lines.append( + f" expr_sheet.set('I{row}', {_json_ascii(dimension_by_id.get(expr_id, ''))})" + ) + formula = self._freecad_expr_formula( + node, + alias_by_id, + unit_aware=unit_aware_by_id.get(expr_id, False), + ) + if formula is None: + lines.append( + f" expr_sheet.set({_json_ascii(cell)}, {_json_ascii('')})" + ) + else: + lines.append( + f" expr_sheet.set({_json_ascii(cell)}, {_json_ascii(formula)})" + ) + lines.append( + f" expr_sheet.setAlias({_json_ascii(cell)}, {_json_ascii(alias)})" + ) + row += 1 + lines.append("else:") + lines.append(" expr_sheet = None") + return lines + + def _freecad_expr_formula( + self, + node: Dict[str, Any], + alias_by_id: Dict[str, str], + *, + unit_aware: bool = False, + ) -> Optional[str]: + kind = str(node.get("kind", "")) + if kind == "const": + return str(float(node.get("value", 0.0))) + if kind == "var": + return str(_canonical_variable_default(node)) + if kind != "expr": + return None + + op = str(node.get("op", "")) + args: List[str] = [] + for arg in node.get("args", []): + alias = alias_by_id.get(str(arg)) + if not alias: + return None + args.append(f"<>.{alias}") + if op == "add" and len(args) == 2: + return f"={args[0]} + {args[1]}" + if op == "sub" and len(args) == 2: + return f"={args[0]} - {args[1]}" + if op == "mul" and len(args) == 2: + return f"={args[0]} * {args[1]}" + if op == "div" and len(args) == 2: + return f"={args[0]} / {args[1]}" + if op == "pow" and len(args) == 2: + return f"=pow({args[0]}, {args[1]})" + if op == "neg" and len(args) == 1: + return f"=-({args[0]})" + if op == "abs" and len(args) == 1: + return f"=abs({args[0]})" + if op == "sin" and len(args) == 1: + return f"=sin({args[0]})" if unit_aware else f"=sin(({args[0]}) * 180 / pi)" + if op == "cos" and len(args) == 1: + return f"=cos({args[0]})" if unit_aware else f"=cos(({args[0]}) * 180 / pi)" + if op == "tan" and len(args) == 1: + return f"=tan({args[0]})" if unit_aware else f"=tan(({args[0]}) * 180 / pi)" + if op == "sqrt" and len(args) == 1: + return f"=sqrt({args[0]})" + if op == "acos" and len(args) == 1: + return f"=acos({args[0]})" if unit_aware else f"=acos({args[0]}) * pi / 180" + if op == "asin" and len(args) == 1: + return f"=asin({args[0]})" if unit_aware else f"=asin({args[0]}) * pi / 180" + if op == "atan" and len(args) == 1: + return f"=atan({args[0]})" if unit_aware else f"=atan({args[0]}) * pi / 180" + if op == "atan2" and len(args) == 2: + if unit_aware: + return f"=atan2({args[0]}; {args[1]})" + return f"=atan2({args[0]}; {args[1]}) * pi / 180" + return None + + def _can_fold_transform_into_input(self, node: OperationNode) -> bool: + graph = self._source_graph + return graph is not None and can_fold_transform_into_input( + node, graph, self._result_node_ids + ) + + def _should_materialize_transform_for_loft_section(self, node: OperationNode) -> bool: + graph = self._source_graph + return graph is not None and should_materialize_transform_for_loft_section( + node, graph + ) + + def _transform_feeds_only_loft(self, node_id: str, seen: Set[str]) -> bool: + graph = self._source_graph + return graph is not None and transform_feeds_only_loft(graph, node_id, seen) + + + def _emit_node(self, node: OperationNode) -> List[str]: + params = dict(node.params) + if node.op in {"make_angle_arc_redge", "make_circle_redge"}: + params = _curve_params_with_kernel_axes(params) + params_literal = _py_literal(params) + inputs_literal = _py_literal([inp.node_id for inp in node.inputs]) + tags_literal = _py_literal(sorted(node.tags)) + context_literal = _py_literal(node.context or {}) + param_exprs_literal = _py_literal(dict(node.param_exprs)) + semantic_delta_literal = _py_literal( + self._node_optional_payload(node, "semantic_delta") + ) + topo_delta_literal = _py_literal( + self._node_optional_payload(node, "topo_delta") + ) + + var_name = _safe_name(node.node_id) + object_name = _safe_name(f"{node.op}_{node.node_id}", prefix="step") + lines = [ + f"{var_name}_params = {params_literal}", + f"{var_name}_inputs = {inputs_literal}", + f"{var_name}_param_exprs = {param_exprs_literal}", + ] + + native_lines = self._emit_native_node( + node, + var_name=var_name, + object_name=object_name, + tags_literal=tags_literal, + context_literal=context_literal, + param_exprs_literal=param_exprs_literal, + semantic_delta_literal=semantic_delta_literal, + topo_delta_literal=topo_delta_literal, + ) + if native_lines is not None: + lines.extend(native_lines) + return lines + raise ValueError(f"Unsupported FreeCAD native graph translation op: {node.op}") + + def _emit_native_node( + self, + node: OperationNode, + *, + var_name: str, + object_name: str, + tags_literal: str, + context_literal: str, + param_exprs_literal: str, + semantic_delta_literal: str, + topo_delta_literal: str, + ) -> Optional[List[str]]: + native_expr = self._compile_native_feature_expr( + node, + var_name=var_name, + object_name=object_name, + tags_literal=tags_literal, + context_literal=context_literal, + param_exprs_literal=param_exprs_literal, + semantic_delta_literal=semantic_delta_literal, + topo_delta_literal=topo_delta_literal, + ) + if native_expr is None: + return None + return native_expr + + def _compile_native_feature_expr( + self, + node: OperationNode, + *, + var_name: str, + object_name: str, + tags_literal: str, + context_literal: str, + param_exprs_literal: str, + semantic_delta_literal: str, + topo_delta_literal: str, + ) -> Optional[List[str]]: + return emit_native_node( + self, + node, + var_name=var_name, + object_name=object_name, + tags_literal=tags_literal, + context_literal=context_literal, + param_exprs_literal=param_exprs_literal, + semantic_delta_literal=semantic_delta_literal, + topo_delta_literal=topo_delta_literal, + ) + + def _node_optional_payload(self, node: OperationNode, attr: str) -> Dict[str, Any]: + value = getattr(node, attr) + if value is None: + return {} + if hasattr(value, "created"): + return { + "created": [self._dataclass_ref_dict(ref) for ref in value.created], + "modified": [self._dataclass_ref_dict(ref) for ref in value.modified], + "deleted": [self._dataclass_ref_dict(ref) for ref in value.deleted], + "metadata": dict(value.metadata), + } + return { + "preserved": [self._dataclass_ref_dict(ref) for ref in value.preserved], + "modified": [self._dataclass_ref_dict(ref) for ref in value.modified], + "generated": [self._dataclass_ref_dict(ref) for ref in value.generated], + "deleted": [self._dataclass_ref_dict(ref) for ref in value.deleted], + "section_edges": [ + self._dataclass_ref_dict(ref) for ref in value.section_edges + ], + "entries": [ + { + "ref": self._dataclass_ref_dict(entry.ref), + "event": getattr(entry.event, "name", str(entry.event)), + "origin_role": entry.origin_role, + "parent_refs": [ + self._dataclass_ref_dict(ref) for ref in entry.parent_refs + ], + "metadata": dict(entry.metadata), + } + for entry in value.entries + ], + "raw_event": dict(value.raw_event), + } + + def _dataclass_ref_dict(self, ref: Any) -> Dict[str, Any]: + payload = dict(ref.__dict__) + if "kind" in payload and hasattr(payload["kind"], "name"): + payload["kind"] = payload["kind"].name + return payload + + +class FreeCADTranslator(BaseTranslator): + """Public, stateless facade for FreeCAD script translation.""" + + def __init__(self, document_name: str = "SimpleCADModel") -> None: + self.document_name = document_name + + @property + def capabilities(self) -> BackendCapabilities: + return CAPABILITIES + + def translate_model_json_to_script(self, json_str: str) -> str: + return _FreeCADCompiler(self.document_name).translate_model_json_to_script( + json_str + ) + + def translate_model_payload_to_script( + self, + payload: Dict[str, Any], + *, + graph: Optional[OperationGraph] = None, + ) -> str: + return _FreeCADCompiler(self.document_name).translate_model_payload_to_script( + payload, graph=graph + ) + + def translate_model_json(self, json_str: str) -> TranslationArtifact: + return TranslationArtifact( + backend_id="freecad", + target_id="freecad_script", + media_type="text/x-python", + suggested_suffix=".py", + content=self.translate_model_json_to_script(json_str), + metadata={"document_name": self.document_name}, + ) + + def translate_model_payload( + self, + payload: Dict[str, Any], + *, + graph: Optional[OperationGraph] = None, + ) -> TranslationArtifact: + return TranslationArtifact( + backend_id="freecad", + target_id="freecad_script", + media_type="text/x-python", + suggested_suffix=".py", + content=self.translate_model_payload_to_script(payload, graph=graph), + metadata={"document_name": self.document_name}, + ) + + +class FreeCADScriptTranslator(FreeCADTranslator): + """Backward-compatible class name for the FreeCAD translator.""" + +__all__ = ["FreeCADScriptTranslator", "FreeCADTranslator"] diff --git a/SimpleCADAPI/src/simplecadapi/translator/fusion360_translator/__init__.py b/SimpleCADAPI/src/simplecadapi/translator/fusion360_translator/__init__.py new file mode 100644 index 0000000..e39344c --- /dev/null +++ b/SimpleCADAPI/src/simplecadapi/translator/fusion360_translator/__init__.py @@ -0,0 +1,13 @@ +"""Fusion 360 translator backend for SimpleCAD model JSON.""" + +from .api import translate_model_json_to_fusion360_script +from .capabilities import CAPABILITIES +from .compiler import Fusion360ScriptTranslator +from .translator import Fusion360Translator + +__all__ = [ + "CAPABILITIES", + "Fusion360ScriptTranslator", + "Fusion360Translator", + "translate_model_json_to_fusion360_script", +] diff --git a/SimpleCADAPI/src/simplecadapi/translator/fusion360_translator/api.py b/SimpleCADAPI/src/simplecadapi/translator/fusion360_translator/api.py new file mode 100644 index 0000000..4bf1f64 --- /dev/null +++ b/SimpleCADAPI/src/simplecadapi/translator/fusion360_translator/api.py @@ -0,0 +1,28 @@ +"""Public Fusion 360 translator entrypoints.""" + +from __future__ import annotations + +from typing import Optional, Sequence + +from .translator import Fusion360Translator + + +def translate_model_json_to_fusion360_script( + json_str: str, + document_name: str = "SimpleCADModel", + result_node_ids: Optional[Sequence[str]] = None, + *, + selection_mode: str = "gsm", + source_kernel_fallback: bool = False, +) -> str: + """Translate canonical model JSON into a Fusion 360 Python script.""" + + return Fusion360Translator( + document_name=document_name, + result_node_ids=result_node_ids, + selection_mode=selection_mode, + source_kernel_fallback=source_kernel_fallback, + ).translate_model_json_to_script(json_str) + + +__all__ = ["translate_model_json_to_fusion360_script"] diff --git a/SimpleCADAPI/src/simplecadapi/translator/fusion360_translator/capabilities.py b/SimpleCADAPI/src/simplecadapi/translator/fusion360_translator/capabilities.py new file mode 100644 index 0000000..14e4245 --- /dev/null +++ b/SimpleCADAPI/src/simplecadapi/translator/fusion360_translator/capabilities.py @@ -0,0 +1,117 @@ +"""Static capability declaration for the Fusion 360 translator backend.""" + +from __future__ import annotations + +from typing import Dict + +from ...serializer import CANONICAL_OP_SET +from ..types import ( + BackendCapabilities, + OperationCapability, + SupportLevel, + TranslationOutputKind, + TranslationTarget, +) + +BACKEND_NAME = "fusion360" + +_GEOMETRY_OPS = { + "make_line_redge", + "make_circle_redge", + "make_three_point_arc_redge", + "make_angle_arc_redge", + "make_spline_redge", + "make_helix_redge", + "make_wire_from_edges_rwire", + "make_face_from_wire_rface", + "make_face_from_wires_rface", + "make_extrude_rsolid", + "make_revolve_rsolid", + "make_loft_rsolid", + "make_sweep_rsolid", + "make_translate_rshape", + "make_rotate_rshape", + "make_mirror_rshape", + "make_cut_rsolid", + "make_union_rsolid", + "make_intersect_rsolid", + "make_fillet_rsolid", + "make_chamfer_rsolid", + "make_shell_rsolid", + "make_select_redge", + "make_select_rface", +} + +_PRODUCT_OPS = { + "make_material_rmaterial", + "make_placement_rplacement", + "make_identity_placement_rplacement", + "make_part_rpart", + "make_assign_material_rpart", + "make_assembly_rassembly", + "make_add_component_rassembly", + "make_place_component_rassembly", + "make_compound_from_assembly_rcompound", + "make_face_connector_rconnector", + "make_edge_connector_rconnector", + "make_vertex_connector_rconnector", + "make_placement_connector_rconnector", + "make_add_connector_rpart", + "make_add_connector_rassembly", + "make_connector_ref_rconnectorref", + "make_scalar_limit_rscalarlimit", + "make_ground_component_rassembly", + "make_unground_component_rassembly", + "make_fixed_constraint_rassembly", + "make_revolute_constraint_rassembly", + "make_prismatic_constraint_rassembly", + "make_solve_assembly_constraints_rassembly", +} + +OP_SUPPORT: Dict[str, OperationCapability] = { + op: OperationCapability( + SupportLevel.UNSUPPORTED, + reason="The Fusion 360 runtime has no implementation for this canonical operation.", + ) + for op in CANONICAL_OP_SET +} +for _op in _GEOMETRY_OPS: + OP_SUPPORT[_op] = OperationCapability( + SupportLevel.EMULATED, + reason=( + "The generated Fusion 360 script reconstructs this operation through " + "Fusion API geometry, TemporaryBRep, or an explicitly enabled fallback." + ), + ) +for _op in _PRODUCT_OPS: + OP_SUPPORT[_op] = OperationCapability( + SupportLevel.METADATA_ONLY, + reason=( + "The generated script preserves or consumes the product semantic data " + "without promising an equivalent native Fusion feature." + ), + ) + +CAPABILITIES = BackendCapabilities( + backend_id=BACKEND_NAME, + display_name="Fusion360", + input_schema_versions=("2.0",), + targets=( + TranslationTarget( + target_id="fusion360_script", + output_kind=TranslationOutputKind.TEXT, + media_type="text/x-python", + extensions=(".py",), + requires_external_runtime=True, + option_names=( + "document_name", + "result_node_ids", + "selection_mode", + "source_kernel_fallback", + ), + ), + ), + operations=OP_SUPPORT, +) + +__all__ = ["BACKEND_NAME", "CAPABILITIES", "OP_SUPPORT"] diff --git a/SimpleCADAPI/src/simplecadapi/translator/fusion360_translator/compiler.py b/SimpleCADAPI/src/simplecadapi/translator/fusion360_translator/compiler.py new file mode 100644 index 0000000..e12b4bd --- /dev/null +++ b/SimpleCADAPI/src/simplecadapi/translator/fusion360_translator/compiler.py @@ -0,0 +1,4185 @@ +"""Translate SimpleCAD model/graph payloads into Autodesk Fusion 360 scripts. + +Generated scripts are intended to run inside Fusion 360's Python environment. +They interpret the same canonical low-level graph consumed by +``freecad_translator.py`` and intentionally select detail-feature edges/faces by +geometry signatures instead of topology indices. +""" + +from __future__ import annotations + +import base64 +import copy +import json +import os +import pprint +import re +import tempfile +import zlib +from typing import Any, Dict, List, Optional, Sequence, Set, Tuple + +from ...serializer import _execute_graph, import_model_json +from ...topology import OperationGraph, OperationNode + + +def _json_ascii(value: Any) -> str: + return json.dumps(value, ensure_ascii=True, sort_keys=True) + + +def _py_literal(value: Any) -> str: + return pprint.pformat(value, compact=True, sort_dicts=True, width=120) + + +def _deterministic_step_bytes(data: bytes) -> bytes: + text = data.decode("latin-1") + text = re.sub( + r"(FILE_NAME\('[^']*',)'[^']*'", + r"\1'1970-01-01T00:00:00'", + text, + count=1, + ) + text = re.sub( + r"(Open CASCADE STEP translator [^']* )\d+(')", + r"\g<1>1\2", + text, + ) + return text.encode("latin-1") + + +def _safe_name(raw: str, *, prefix: str = "obj") -> str: + token = "".join(ch if ch.isalnum() else "_" for ch in raw) + token = token.strip("_") + if not token: + token = prefix + if token[0].isdigit(): + token = f"{prefix}_{token}" + return token + + +def _normalized_spline_params(params: Dict[str, Any]) -> Dict[str, Any]: + """Convert legacy fit-point splines to the OCC-equivalent NURBS payload.""" + + result = dict(params) + if result.get("control_points") or not result.get("points"): + return result + try: + from OCP.GeomAPI import GeomAPI_Interpolate + from OCP.TColgp import TColgp_HArray1OfPnt + from OCP.gp import gp_Pnt + + points = [tuple(float(value) for value in point) for point in result["points"]] + point_array = TColgp_HArray1OfPnt(1, len(points)) + for index, point in enumerate(points, 1): + xyz = point + (0.0,) * (3 - len(point)) + point_array.SetValue(index, gp_Pnt(*xyz[:3])) + interpolation = GeomAPI_Interpolate(point_array, False, 1.0e-9) + interpolation.Perform() + if not interpolation.IsDone(): + return result + curve = interpolation.Curve() + result["control_points"] = [ + [curve.Pole(index).X(), curve.Pole(index).Y(), curve.Pole(index).Z()] + for index in range(1, curve.NbPoles() + 1) + ] + result["degree"] = int(curve.Degree()) + result["knots"] = [ + float(curve.Knot(index)) for index in range(1, curve.NbKnots() + 1) + ] + result["multiplicities"] = [ + int(curve.Multiplicity(index)) + for index in range(1, curve.NbKnots() + 1) + ] + result["fusion_knots"] = [ + knot + for knot, multiplicity in zip( + result["knots"], result["multiplicities"] + ) + for _ in range(multiplicity) + ] + result["periodic"] = bool(curve.IsPeriodic()) + if curve.IsRational(): + result["weights"] = [ + float(curve.Weight(index)) + for index in range(1, curve.NbPoles() + 1) + ] + except Exception: + return result + return result + + +def _curve_params_with_kernel_axes( + params: Dict[str, Any], axis_key: str +) -> Dict[str, Any]: + """Add the canonical OCC periodic frame without mutating graph parameters.""" + + from OCP.gp import gp_Ax2, gp_Dir, gp_Pnt + + result = dict(params) + axis_value = result.get(axis_key, (0.0, 0.0, 1.0)) + axis = gp_Ax2( + gp_Pnt(0.0, 0.0, 0.0), + gp_Dir(*(float(value) for value in axis_value)), + ) + x_axis = axis.XDirection() + y_axis = axis.YDirection() + result["_kernel_x_axis"] = [x_axis.X(), x_axis.Y(), x_axis.Z()] + result["_kernel_y_axis"] = [y_axis.X(), y_axis.Y(), y_axis.Z()] + return result + + +_ASSEMBLY_ACTIVE_STATE_PRIORITY = ( + "operating", + "locked", + "nominal", + "default", + "middle", + "middle_adjustment", +) + + +def _assembly_state_result_node_ids( + graph: OperationGraph, result_node_ids: Sequence[str] +) -> Dict[str, List[str]]: + """Group sibling assembly compound leaves by their state suffix.""" + + node_ids = [str(node_id) for node_id in result_node_ids] + if len(node_ids) < 2: + return {} + snapshots: List[Tuple[str, str, int]] = [] + for node_id in node_ids: + node = graph.get_node(node_id) + if node is None or node.op != "make_compound_from_assembly_rcompound": + return {} + assembly_id = str(node.params.get("assembly_id") or "") + if not assembly_id: + return {} + snapshots.append( + (node_id, assembly_id, int(node.params.get("component_count") or 0)) + ) + if len({count for _, _, count in snapshots}) != 1: + return {} + common_prefix = os.path.commonprefix([assembly_id for _, assembly_id, _ in snapshots]) + separator_index = common_prefix.rfind("_") + if separator_index <= 0: + return {} + state_prefix = common_prefix[: separator_index + 1] + grouped: Dict[str, List[str]] = {} + for node_id, assembly_id, _ in snapshots: + if not assembly_id.startswith(state_prefix): + return {} + state = assembly_id[len(state_prefix) :].strip("_") + if not state or state in grouped: + return {} + grouped[state] = [node_id] + return grouped + + +def _preferred_result_node_ids( + graph: OperationGraph, result_node_ids: Sequence[str] +) -> Tuple[List[str], Optional[str], Dict[str, List[str]]]: + state_node_ids = _assembly_state_result_node_ids(graph, result_node_ids) + for state in _ASSEMBLY_ACTIVE_STATE_PRIORITY: + selected = state_node_ids.get(state) + if selected is not None: + return list(selected), state, state_node_ids + # Assembly compounds are terminal result sets. Graph exporters can also + # expose the rotated component snapshots used to build the compound as + # leaves; emitting both duplicates every component in the STEP result. + node_by_id = {str(node.node_id): node for node in graph.nodes} + assembly_results = [ + node_id + for node_id in result_node_ids + if getattr(node_by_id.get(str(node_id)), "op", None) + == "make_compound_from_assembly_rcompound" + ] + if assembly_results: + return assembly_results, None, state_node_ids + dominant_result = _dominant_result_node_id(graph, result_node_ids) + if dominant_result is not None: + return [dominant_result], None, state_node_ids + return [str(node_id) for node_id in result_node_ids], None, state_node_ids + + +def _dominant_result_node_id( + graph: OperationGraph, result_node_ids: Sequence[str] +) -> Optional[str]: + """Resolve redundant terminal leaves only when source geometry proves dominance.""" + + node_by_id = {str(node.node_id): node for node in graph.nodes} + node_ids = [str(node_id) for node_id in result_node_ids] + nodes = [node_by_id.get(node_id) for node_id in node_ids] + ops = [getattr(node, "op", None) for node in nodes] + detail_ops = {"make_fillet_rsolid", "make_chamfer_rsolid", "make_shell_rsolid"} + detail_indices = [index for index, op in enumerate(ops) if op in detail_ops] + all_union_results = len(node_ids) > 1 and all( + op == "make_union_rsolid" for op in ops + ) + if len(detail_indices) != 1 and not all_union_results: + return None + + try: + from OCP.BRepAlgoAPI import BRepAlgoAPI_Fuse + from OCP.BRepGProp import BRepGProp + from OCP.GProp import GProp_GProps + + replay_graph = copy.deepcopy(graph) + for node in replay_graph.nodes: + if node.op == "make_spline_redge": + normalized = _normalized_spline_params(dict(node.params)) + normalized.pop("fusion_knots", None) + node.params.clear() + node.params.update(normalized) + results = _execute_graph(replay_graph, node_ids, strict=True) + + def shape_volume(shape: Any) -> float: + props = GProp_GProps() + BRepGProp.VolumeProperties_s(shape, props) + return max(0.0, float(props.Mass())) + + shapes = [getattr(result, "wrapped", None) for result in results] + volumes = [shape_volume(shape) if shape is not None else 0.0 for shape in shapes] + except Exception: + return None + + if len(detail_indices) == 1: + detail_index = detail_indices[0] + total_volume = sum(volumes) + if total_volume > 0.0 and volumes[detail_index] / total_volume >= 0.99: + return node_ids[detail_index] + + if all_union_results and shapes: + try: + fused_shape = shapes[0] + for shape in shapes[1:]: + fuse = BRepAlgoAPI_Fuse(fused_shape, shape) + fuse.Build() + if not fuse.IsDone(): + return None + fused_shape = fuse.Shape() + fused_volume = shape_volume(fused_shape) + largest_index = max(range(len(volumes)), key=volumes.__getitem__) + tolerance = max(1.0e-12, fused_volume * 1.0e-8) + if fused_volume > 0.0 and abs(volumes[largest_index] - fused_volume) <= tolerance: + return node_ids[largest_index] + except Exception: + return None + return None + + +def _source_kernel_fallback_node_ids(graph: OperationGraph) -> List[str]: + fallback_ops = { + "make_loft_rsolid", + "make_revolve_rsolid", + "make_sweep_rsolid", + "make_fillet_rsolid", + "make_chamfer_rsolid", + "make_shell_rsolid", + } + return [str(node.node_id) for node in graph.nodes if node.op in fallback_ops] + + +def _dependency_node_ids( + graph: OperationGraph, result_node_ids: Sequence[str] +) -> Set[str]: + pending = [str(node_id) for node_id in result_node_ids] + needed: Set[str] = set() + while pending: + node_id = pending.pop() + if node_id in needed: + continue + node = graph.get_node(node_id) + if node is None: + raise ValueError(f"Result node {node_id!r} is not present in the graph") + needed.add(node_id) + pending.extend(str(input_ref.node_id) for input_ref in node.inputs) + return needed + + +def _dependency_subgraph( + graph: OperationGraph, result_node_ids: Sequence[str] +) -> OperationGraph: + needed = _dependency_node_ids(graph, result_node_ids) + omitted_selector_ids = { + str(node.node_id) + for node in graph.nodes + if node.op in {"make_select_redge", "make_select_rface"} + and not node.inputs + } + result = OperationGraph(graph_id=graph.graph_id) + for node in graph.topological_order(): + if str(node.node_id) not in needed or str(node.node_id) in omitted_selector_ids: + continue + inputs = [ + result.get_node(str(input_ref.node_id)) + for input_ref in node.inputs + if str(input_ref.node_id) not in omitted_selector_ids + ] + if any(input_node is None for input_node in inputs): + raise ValueError( + f"Dependency subgraph is missing an input for node {node.node_id!r}" + ) + params = copy.deepcopy(node.params) + for key in ("selected_edge_node_ids", "selected_face_node_ids"): + if key in params: + params[key] = [ + str(value) + for value in params.get(key, []) or [] + if str(value) not in omitted_selector_ids + ] + result.add_node( + node.op, + params=params, + param_exprs=copy.deepcopy(node.param_exprs), + inputs=[input_node for input_node in inputs if input_node is not None], + node_id=str(node.node_id), + output_count=node.output_count, + semantic_delta=copy.deepcopy(node.semantic_delta), + topo_delta=copy.deepcopy(node.topo_delta), + context=copy.deepcopy(node.context), + tags=set(node.tags), + source=copy.deepcopy(node.source), + ) + return result + + +def _seam_split_circle_node_ids(graph: OperationGraph) -> List[str]: + """Find circular profiles whose downstream selectors target axial seam edges. + + Fusion does not expose the seam of a full analytic cylinder as a selectable + BRep edge. Splitting only circles that feed a line-edge selection preserves + the exact profile while making the corresponding longitudinal boundary + explicit in the native feature chain. + """ + node_by_id = {str(node.node_id): node for node in graph.nodes} + selected_sources: List[Tuple[OperationNode, Dict[str, Any]]] = [] + # Selection nodes are often leaf records without graph inputs. The + # consuming detail node still records their semantic ownership in + # selected_edge_node_ids, so recover the source body through that edge. + owner_source_by_selection: Dict[str, OperationNode] = {} + for owner in graph.nodes: + if owner.op not in {"make_fillet_rsolid", "make_chamfer_rsolid"}: + continue + if not owner.inputs: + continue + source = node_by_id.get(str(owner.inputs[0].node_id)) + if source is None: + continue + for selection_id in owner.params.get("selected_edge_node_ids") or []: + owner_source_by_selection[str(selection_id)] = source + for node in graph.nodes: + if node.op != "make_select_redge": + continue + selector = node.params.get("geo_selector") + if not isinstance(selector, dict) or str(selector.get("geom_type", "")).upper() != "LINE": + continue + source = ( + node_by_id.get(str(node.inputs[0].node_id)) + if node.inputs + else owner_source_by_selection.get(str(node.node_id)) + ) + if source is not None: + selected_sources.append((source, selector)) + + result: Set[str] = set() + for source, selector in selected_sources: + if source is None: + continue + queue = [str(source.node_id)] + visited: Set[str] = set() + while queue: + node_id = queue.pop() + if node_id in visited: + continue + visited.add(node_id) + node = node_by_id.get(node_id) + if node is None: + continue + if node.op == "make_circle_redge": + params = node.params + center = params.get("center") + normal = params.get("normal") or (0.0, 0.0, 1.0) + radius = params.get("radius") + start = selector.get("start") + end = selector.get("end") + if not ( + isinstance(center, (list, tuple)) + and len(center) == 3 + and isinstance(normal, (list, tuple)) + and len(normal) == 3 + and isinstance(start, (list, tuple)) + and len(start) == 3 + and isinstance(end, (list, tuple)) + and len(end) == 3 + ): + continue + try: + center_v = tuple(float(value) for value in center) + normal_v = tuple(float(value) for value in normal) + start_v = tuple(float(value) for value in start) + end_v = tuple(float(value) for value in end) + radius_v = float(radius) + normal_len = sum(value * value for value in normal_v) ** 0.5 + if normal_len <= 1.0e-12 or radius_v <= 0.0: + continue + normal_v = tuple(value / normal_len for value in normal_v) + + def radial_distance(point): + delta = tuple(point[index] - center_v[index] for index in range(3)) + axial = sum(delta[index] * normal_v[index] for index in range(3)) + radial = tuple( + delta[index] - axial * normal_v[index] + for index in range(3) + ) + return abs(axial), sum(value * value for value in radial) ** 0.5 + + _start_axial, start_radial = radial_distance(start_v) + _end_axial, end_radial = radial_distance(end_v) + edge_direction = tuple( + end_v[index] - start_v[index] for index in range(3) + ) + edge_length = sum(value * value for value in edge_direction) ** 0.5 + scale = max(1.0, abs(radius_v), *(abs(value) for value in center_v)) + if ( + edge_length > scale * 1.0e-8 + and abs(start_radial - radius_v) <= scale * 1.0e-6 + and abs(end_radial - radius_v) <= scale * 1.0e-6 + and abs( + sum( + edge_direction[index] * normal_v[index] + for index in range(3) + ) + ) / edge_length >= 1.0 - 1.0e-6 + ): + result.add(str(node.node_id)) + except (TypeError, ValueError): + pass + queue.extend(str(input_ref.node_id) for input_ref in node.inputs) + return sorted(result) + + +def _source_kernel_step_payloads( + graph: OperationGraph, result_node_ids: Sequence[str] = () +) -> Tuple[Dict[str, str], Dict[str, Dict[str, Any]]]: + replay_graph = ( + _dependency_subgraph(graph, result_node_ids) + if result_node_ids + else copy.deepcopy(graph) + ) + node_by_id = {str(node.node_id): node for node in replay_graph.nodes} + has_legacy_fit_spline = any( + node.op == "make_spline_redge" + and bool(node.params.get("points")) + and not bool(node.params.get("control_points")) + for node in replay_graph.nodes + ) + result_boolean_ids = [ + str(node_id) + for node_id in result_node_ids + if has_legacy_fit_spline + if getattr(node_by_id.get(str(node_id)), "op", None) + in {"make_cut_rsolid", "make_union_rsolid", "make_intersect_rsolid"} + ] + detail_input_ids = [ + str(input_ref.node_id) + for node in replay_graph.nodes + if node.op in {'make_fillet_rsolid', 'make_chamfer_rsolid'} + for input_ref in node.inputs[:1] + ] + boolean_input_ids = [ + str(input_ref.node_id) + for node in replay_graph.nodes + if node.op in {'make_cut_rsolid', 'make_union_rsolid', 'make_intersect_rsolid'} + for input_ref in node.inputs + ] + node_ids = list( + dict.fromkeys( + _source_kernel_fallback_node_ids(replay_graph) + + result_boolean_ids + + detail_input_ids + + boolean_input_ids + ) + ) + if not node_ids: + return {}, {} + try: + for node in replay_graph.nodes: + if node.op == "make_spline_redge": + normalized = _normalized_spline_params(dict(node.params)) + normalized.pop("fusion_knots", None) + node.params.clear() + node.params.update(normalized) + results = _execute_graph(replay_graph, node_ids, strict=True) + except Exception: + return {}, {} + if len(results) != len(node_ids): + return {}, {} + + payloads: Dict[str, str] = {} + signatures: Dict[str, Dict[str, Any]] = {} + for node_id, result in zip(node_ids, results): + path: Optional[str] = None + try: + from OCP.Bnd import Bnd_Box + from OCP.BRepBndLib import BRepBndLib + from OCP.BRepGProp import BRepGProp + from OCP.GProp import GProp_GProps + from OCP.IFSelect import IFSelect_RetDone + from OCP.STEPControl import STEPControl_AsIs, STEPControl_Writer + + wrapped = getattr(result, "wrapped", None) + if wrapped is None: + continue + handle = tempfile.NamedTemporaryFile(suffix=".step", delete=False) + path = handle.name + handle.close() + writer = STEPControl_Writer() + writer.Transfer(wrapped, STEPControl_AsIs) + if writer.Write(path) != IFSelect_RetDone: + continue + with open(path, "rb") as source: + compressed = zlib.compress( + _deterministic_step_bytes(source.read()), level=9 + ) + payloads[node_id] = base64.b64encode(compressed).decode("ascii") + bounds = Bnd_Box() + BRepBndLib.Add_s(wrapped, bounds) + xmin, ymin, zmin, xmax, ymax, zmax = bounds.Get() + props = GProp_GProps() + BRepGProp.VolumeProperties_s(wrapped, props) + signatures[node_id] = { + "bbox": { + "min": [float(xmin), float(ymin), float(zmin)], + "max": [float(xmax), float(ymax), float(zmax)], + }, + "volume": max(0.0, float(props.Mass())) * 0.001, + } + except Exception: + continue + finally: + if path is not None: + try: + os.unlink(path) + except OSError: + pass + return payloads, signatures + + +class Fusion360ScriptTranslator: + """Compile a SimpleCAD model payload into a Fusion 360 Python script.""" + + def __init__( + self, + document_name: str = "SimpleCADModel", + result_node_ids: Optional[Sequence[str]] = None, + selection_mode: str = "gsm", + source_kernel_fallback: bool = False, + ) -> None: + if selection_mode not in {"gsm", "index", "sample"}: + raise ValueError(f"Unsupported Fusion topology selection mode: {selection_mode}") + self.document_name = document_name + self.selection_mode = selection_mode + self.source_kernel_fallback = bool(source_kernel_fallback) + self._explicit_result_node_ids = ( + [str(node_id) for node_id in result_node_ids] + if result_node_ids is not None + else None + ) + self._source_graph: Optional[OperationGraph] = None + self._result_node_ids: Set[str] = set() + self._result_node_id_list: List[str] = [] + self._declared_result_node_id_list: List[str] = [] + self._result_state_node_ids: Dict[str, List[str]] = {} + self._active_result_state: Optional[str] = None + self._source_kernel_steps: Dict[str, str] = {} + self._source_kernel_signatures: Dict[str, Dict[str, Any]] = {} + self._seam_split_circle_node_ids: List[str] = [] + + def translate_model_json_to_script(self, json_str: str) -> str: + payload = import_model_json(json_str) + graph = payload.get("graph") + if not isinstance(graph, OperationGraph): + raise ValueError( + "Fusion 360 translation requires model JSON with a canonical low-level graph" + ) + if graph.node_count == 0: + raise ValueError( + "Fusion 360 translation requires model JSON with a non-empty canonical low-level graph" + ) + return self.translate_model_payload_to_script(payload, graph=graph) + + def translate_model_payload_to_script( + self, + payload: Dict[str, Any], + *, + graph: Optional[OperationGraph] = None, + ) -> str: + source_graph = graph or payload.get("graph") + if not isinstance(source_graph, OperationGraph): + raise ValueError( + "Fusion 360 translation requires payload to contain a canonical low-level graph" + ) + if source_graph.node_count == 0: + raise ValueError( + "Fusion 360 translation requires payload to contain a non-empty canonical low-level graph" + ) + self._source_graph = source_graph + self._seam_split_circle_node_ids = _seam_split_circle_node_ids(source_graph) + leaf_ids = payload.get("leaf_ids") + if isinstance(leaf_ids, list) and leaf_ids: + self._declared_result_node_id_list = [str(v) for v in leaf_ids] + else: + self._declared_result_node_id_list = [ + leaf.node_id for leaf in source_graph.leaf_nodes() + ] + if self._explicit_result_node_ids is not None: + self._result_state_node_ids = {} + self._active_result_state = None + self._result_node_id_list = list(self._explicit_result_node_ids) + else: + ( + self._result_node_id_list, + self._active_result_state, + self._result_state_node_ids, + ) = _preferred_result_node_ids( + source_graph, self._declared_result_node_id_list + ) + missing_result_ids = [ + node_id + for node_id in self._result_node_id_list + if source_graph.get_node(node_id) is None + ] + if missing_result_ids: + raise ValueError( + "Fusion 360 result node IDs are not present in the graph: " + + ", ".join(missing_result_ids) + ) + self._result_node_ids = set(self._result_node_id_list) + if self.source_kernel_fallback: + ( + self._source_kernel_steps, + self._source_kernel_signatures, + ) = _source_kernel_step_payloads( + source_graph, self._result_node_id_list + ) + else: + self._source_kernel_steps = {} + self._source_kernel_signatures = {} + + payload_dict = self._payload_to_jsonable(payload, source_graph) + lines: List[str] = [] + emit = lines.append + emit("import base64") + emit("import json") + emit("import math") + emit("import os") + emit("import tempfile") + emit("import traceback") + emit("import zlib") + emit("import adsk.core") + emit("import adsk.fusion") + emit("") + emit(f"DOC_NAME = {_json_ascii(self.document_name)}") + emit(f"SELECTION_MODE = {_json_ascii(self.selection_mode)}") + emit(f"MODEL_PAYLOAD = {_py_literal(payload_dict)}") + emit(f"DECLARED_RESULT_NODE_IDS = {_py_literal(self._declared_result_node_id_list)}") + emit(f"RESULT_STATE_NODE_IDS = {_py_literal(self._result_state_node_ids)}") + emit(f"ACTIVE_RESULT_STATE = {_py_literal(self._active_result_state)}") + emit(f"RESULT_NODE_IDS = {_py_literal(self._result_node_id_list)}") + emit(f"SOURCE_KERNEL_STEPS = {_py_literal(self._source_kernel_steps)}") + emit(f"SOURCE_KERNEL_SIGNATURES = {_py_literal(self._source_kernel_signatures)}") + emit(f"SEAM_SPLIT_CIRCLE_NODE_IDS = {_py_literal(self._seam_split_circle_node_ids)}") + emit("") + emit(self._script_helpers()) + emit("") + emit("def run(context):") + emit(" app = adsk.core.Application.get()") + emit(" try:") + emit(" translator = SimpleCADFusionRuntime(MODEL_PAYLOAD, DOC_NAME, RESULT_NODE_IDS, SOURCE_KERNEL_STEPS, SOURCE_KERNEL_SIGNATURES, SEAM_SPLIT_CIRCLE_NODE_IDS)") + emit(" return translator.run()") + emit(" except Exception:") + emit(" message = traceback.format_exc()") + emit(" print(message)") + emit(" raise") + emit("") + emit("if __name__ == '__main__':") + emit(" run(None)") + return "\n".join(lines).rstrip() + "\n" + + def _payload_to_jsonable( + self, payload: Dict[str, Any], source_graph: OperationGraph + ) -> Dict[str, Any]: + # Fusion scripts do not need the full model payload. Keeping only the + # executable graph surface also prevents stale topology-index hints from + # appearing in generated Fusion scripts. + nodes: List[Dict[str, Any]] = [] + for node in source_graph.topological_order(): + params = dict(node.params) + if str(node.op) == "make_spline_redge": + params = _normalized_spline_params(params) + elif str(node.op) in {"make_circle_redge", "make_angle_arc_redge"}: + params = _curve_params_with_kernel_axes(params, "normal") + elif str(node.op) == "make_helix_redge": + params = _curve_params_with_kernel_axes(params, "dir") + nodes.append( + { + "node_id": str(node.node_id), + "op": str(node.op), + "params": self._sanitize_payload_for_fusion(params), + "inputs": [ + {"node_id": str(input_ref.node_id)} + for input_ref in node.inputs + ], + } + ) + return { + "schema_version": str(payload.get("schema_version", "2.0")), + "graph": { + "graph_id": str(getattr(source_graph, "graph_id", "graph")), + "nodes": nodes, + }, + "leaf_ids": [str(v) for v in payload.get("leaf_ids", [])], + } + + def _sanitize_payload_for_fusion(self, value: Any) -> Any: + if isinstance(value, dict): + cleaned: Dict[str, Any] = {} + for key, child in value.items(): + if self.selection_mode == "index": + cleaned[key] = self._sanitize_payload_for_fusion(child) + continue + if key in { + "selected_edge_indices", + "selected_face_indices", + "edge_index_param", + "face_index_param", + "topo_id", + }: + continue + if key == "metadata_geo": + child_cleaned = self._sanitize_payload_for_fusion(child) + if isinstance(child_cleaned, dict): + child_cleaned = { + k: v + for k, v in child_cleaned.items() + if k not in {"edge_index", "face_index"} + } + if child_cleaned: + cleaned[key] = child_cleaned + continue + cleaned[key] = self._sanitize_payload_for_fusion(child) + return cleaned + if isinstance(value, (list, tuple)): + return [self._sanitize_payload_for_fusion(item) for item in value] + return value + + def _script_helpers(self) -> str: + return r''' +SCALE = 0.1 # SimpleCAD model JSON is in mm; Fusion API geometry units are cm. +TOL = 1.0e-5 + + +class SimpleCADUnsupportedOpError(RuntimeError): + pass + + +def _flatten(values): + for value in values: + if isinstance(value, (list, tuple)): + yield from _flatten(value) + else: + yield value + + +def _v3(value, default=(0.0, 0.0, 0.0)): + if not isinstance(value, (list, tuple)) or len(value) != 3: + value = default + return (float(value[0]), float(value[1]), float(value[2])) + + +def _scaled(value): + x, y, z = _v3(value) + return (x * SCALE, y * SCALE, z * SCALE) + + +def _pt(value): + x, y, z = _scaled(value) + return adsk.core.Point3D.create(x, y, z) + + +def _vec(value): + x, y, z = _v3(value) + return adsk.core.Vector3D.create(x, y, z) + + +def _vec_scaled(value): + x, y, z = _scaled(value) + return adsk.core.Vector3D.create(x, y, z) + + +def _distance(a, b): + ax, ay, az = _v3(a) + bx, by, bz = _v3(b) + return math.sqrt((ax - bx) ** 2 + (ay - by) ** 2 + (az - bz) ** 2) + + +def _dot(a, b): + return float(a[0]) * float(b[0]) + float(a[1]) * float(b[1]) + float(a[2]) * float(b[2]) + + +def _cross(a, b): + return ( + float(a[1]) * float(b[2]) - float(a[2]) * float(b[1]), + float(a[2]) * float(b[0]) - float(a[0]) * float(b[2]), + float(a[0]) * float(b[1]) - float(a[1]) * float(b[0]), + ) + + +def _norm(a): + return math.sqrt(_dot(a, a)) + + +def _unit(a, fallback=(0.0, 0.0, 1.0)): + length = _norm(a) + if length <= 1.0e-12: + return _v3(fallback) + return (float(a[0]) / length, float(a[1]) / length, float(a[2]) / length) + + +def _add(a, b): + return (float(a[0]) + float(b[0]), float(a[1]) + float(b[1]), float(a[2]) + float(b[2])) + + +def _sub(a, b): + return (float(a[0]) - float(b[0]), float(a[1]) - float(b[1]), float(a[2]) - float(b[2])) + + +def _mul(a, scalar): + return (float(a[0]) * scalar, float(a[1]) * scalar, float(a[2]) * scalar) + + +def _bbox_tuple(box): + return { + 'min': (box.minPoint.x / SCALE, box.minPoint.y / SCALE, box.minPoint.z / SCALE), + 'max': (box.maxPoint.x / SCALE, box.maxPoint.y / SCALE, box.maxPoint.z / SCALE), + } + + +def _bbox_center(bbox): + return tuple((float(bbox['min'][i]) + float(bbox['max'][i])) * 0.5 for i in range(3)) + + +def _bbox_score(candidate_bbox, selector_bbox): + if not isinstance(selector_bbox, dict): + return 0.0 + score = 0.0 + for key in ('min', 'max'): + if key not in selector_bbox: + continue + target = selector_bbox[key] + actual = candidate_bbox[key] + score += sum((float(actual[i]) - float(target[i])) ** 2 for i in range(3)) + return score + + +def _object_collection(items=None): + collection = adsk.core.ObjectCollection.create() + for item in items or []: + collection.add(item) + return collection + + +def _iter_collection(collection): + for item in collection: + yield item + + +def _first_entity(collection, label): + for item in _iter_collection(collection): + return item + raise RuntimeError(f'Expected at least one {label}') + + +def _value_cm(mm_value): + return adsk.core.ValueInput.createByReal(float(mm_value) * SCALE) + + +def _value_deg(degrees): + return adsk.core.ValueInput.createByReal(math.radians(float(degrees))) + + +def _curve_points_on_edge(edge): + ev = edge.evaluator + ok, start_param, end_param = ev.getParameterExtents() + if not ok: + return None + ok, start_pt = ev.getPointAtParameter(start_param) + if not ok: + return None + ok, end_pt = ev.getPointAtParameter(end_param) + if not ok: + return None + ok, mid_pt = ev.getPointAtParameter((start_param + end_param) * 0.5) + if not ok: + mid_pt = start_pt + return ( + (start_pt.x / SCALE, start_pt.y / SCALE, start_pt.z / SCALE), + (mid_pt.x / SCALE, mid_pt.y / SCALE, mid_pt.z / SCALE), + (end_pt.x / SCALE, end_pt.y / SCALE, end_pt.z / SCALE), + ) + + +def _edge_length(edge): + try: + ok, length = edge.evaluator.getLengthAtParameter( + edge.evaluator.getParameterExtents()[1], + edge.evaluator.getParameterExtents()[2], + ) + if ok: + return float(length) / SCALE + except Exception: + pass + pts = _curve_points_on_edge(edge) + if not pts: + return 0.0 + return _distance(pts[0], pts[2]) + + +def _edge_is_geometrically_linear(edge, edge_length): + try: + evaluator = edge.evaluator + ok, start_parameter, end_parameter = evaluator.getParameterExtents() + if not ok: + return False + samples = [] + for index in range(9): + parameter = start_parameter + (end_parameter - start_parameter) * index / 8.0 + ok, point = evaluator.getPointAtParameter(parameter) + if not ok: + return False + samples.append((point.x / SCALE, point.y / SCALE, point.z / SCALE)) + except Exception: + return False + start, end = samples[0], samples[-1] + chord = _sub(end, start) + chord_length = _norm(chord) + scale = max(1.0, chord_length, float(edge_length)) + if chord_length <= scale * 1.0e-10: + return False + if abs(float(edge_length) - chord_length) > scale * 1.0e-7: + return False + tolerance = scale * 1.0e-7 + return all( + _norm(_cross(_sub(point, start), chord)) / chord_length <= tolerance + for point in samples + ) + + +def _edge_signature(edge): + bbox = _bbox_tuple(edge.boundingBox) + pts = _curve_points_on_edge(edge) + center = _bbox_center(bbox) + length = _edge_length(edge) + geom_type = str(getattr(edge.geometry, 'objectType', '')).upper() + if _canonical_geom_type(geom_type) == 'BSPLINE' and _edge_is_geometrically_linear(edge, length): + geom_type = 'LINE' + return { + 'bbox': bbox, + 'center': center, + 'start': pts[0] if pts else center, + 'end': pts[2] if pts else center, + 'length': length, + 'geom_type': geom_type, + } + + +def _face_signature(face): + bbox = _bbox_tuple(face.boundingBox) + center = _bbox_center(bbox) + geom_type = str(getattr(face.geometry, 'objectType', '')).upper() + return { + 'bbox': bbox, + 'center': center, + 'area': float(getattr(face, 'area', 0.0)) / (SCALE * SCALE), + 'geom_type': geom_type, + 'normal': _face_normal_tuple(face), + } + + +def _face_normal_tuple(face): + try: + geometry = face.geometry + normal = getattr(geometry, 'normal', None) + if normal is not None: + return _unit((normal.x, normal.y, normal.z)) + except Exception: + pass + try: + evaluator = face.evaluator + point = face.pointOnFace + ok, parameter = evaluator.getParameterAtPoint(point) + if ok: + ok, normal = evaluator.getNormalAtParameter(parameter) + if ok: + return _unit((normal.x, normal.y, normal.z)) + except Exception: + pass + return None + + +def _selector_geometry(selector): + params = selector.get('params') if isinstance(selector, dict) else None + if isinstance(params, dict): + selector = params + if not isinstance(selector, dict): + return {} + geo_selector = selector.get('geo_selector') + if isinstance(geo_selector, dict): + return dict(selector, **geo_selector) + return selector + + +def _tuple3_or_none(value): + if isinstance(value, (list, tuple)) and len(value) == 3: + return (float(value[0]), float(value[1]), float(value[2])) + return None + + +def _canonical_geom_type(value): + text = str(value or '').upper().replace('_TYPE', '') + aliases = ( + ('B-SPLINE', 'BSPLINE'), ('BSPLINE', 'BSPLINE'), + ('NURBS', 'BSPLINE'), ('CYLINDER', 'CYLINDER'), + ('BEZIERCURVE', 'BEZIER'), ('BEZIER', 'BEZIER'), + ('PLANE', 'PLANE'), ('CIRCLE', 'CIRCLE'), + ('ELLIPTICALARC', 'ELLIPSE'), ('ELLIPSE', 'ELLIPSE'), + ('ARC', 'CIRCLE'), + ('LINE', 'LINE'), ('CONE', 'CONE'), ('SPHERE', 'SPHERE'), + ('TORUS', 'TORUS'), + ) + for token, canonical in aliases: + if token in text: + return canonical + return text + + +def _selector_geom_type(selector): + selector = _selector_geometry(selector) + geom_type = _canonical_geom_type( + selector.get('geom_type') or selector.get('surface_type') + ) + if geom_type not in {'BSPLINE', 'BEZIER'} or _selector_kind(selector, selector) != 'edge': + return geom_type + start = _tuple3_or_none(selector.get('start')) + end = _tuple3_or_none(selector.get('end')) + expected_length = selector.get('length') + if start is None or end is None or expected_length is None: + return geom_type + chord_length = _distance(start, end) + edge_length = float(expected_length) + scale = max(1.0, chord_length, edge_length) + if chord_length > scale * 1.0e-10 and abs(edge_length - chord_length) <= scale * 1.0e-7: + return 'LINE' + return geom_type + + +def _geom_type_mismatch(sig, selector): + selector = _selector_geometry(selector) + target = _selector_geom_type(selector) + actual = _canonical_geom_type(sig.get('geom_type')) + if target == 'CIRCLE' and actual == 'BSPLINE': + expected_length = selector.get('length') + actual_length = sig.get('length') + expected_start = _tuple3_or_none(selector.get('start')) + expected_end = _tuple3_or_none(selector.get('end')) + actual_start = _tuple3_or_none(sig.get('start')) + actual_end = _tuple3_or_none(sig.get('end')) + scale = _selector_length_scale(selector) + if ( + expected_length is not None + and actual_length is not None + and all( + value is not None + for value in ( + expected_start, + expected_end, + actual_start, + actual_end, + ) + ) + and _bbox_selector_score(sig, selector) <= 1.0e-4 + and _relative_error(actual_length, expected_length) <= 1.0e-4 + ): + same = _distance(actual_start, expected_start) + _distance( + actual_end, expected_end + ) + reverse = _distance(actual_start, expected_end) + _distance( + actual_end, expected_start + ) + if min(same, reverse) <= scale * 2.0e-4: + return 0 + return int(bool(target and actual and target != actual)) + + +def _selector_length_scale(selector): + selector = _selector_geometry(selector) + bbox = selector.get('bbox') + if isinstance(bbox, dict): + minimum = _tuple3_or_none(bbox.get('min')) + maximum = _tuple3_or_none(bbox.get('max')) + if minimum is not None and maximum is not None: + return max(1.0, _distance(minimum, maximum)) + return 1.0 + + +def _relative_error(actual, expected, floor=1.0): + return abs(float(actual) - float(expected)) / max(float(floor), abs(float(expected))) + + +def _bbox_selector_score(sig, selector): + selector = _selector_geometry(selector) + candidate_bbox = sig.get('bbox') + expected_bbox = selector.get('bbox') + if not isinstance(candidate_bbox, dict) or not isinstance(expected_bbox, dict): + return 0.0 + actual_min = _tuple3_or_none(candidate_bbox.get('min')) + actual_max = _tuple3_or_none(candidate_bbox.get('max')) + expected_min = _tuple3_or_none(expected_bbox.get('min')) + expected_max = _tuple3_or_none(expected_bbox.get('max')) + if any(value is None for value in (actual_min, actual_max, expected_min, expected_max)): + return 1.0e6 + scale = _selector_length_scale(selector) + return ( + _distance(actual_min, expected_min) + _distance(actual_max, expected_max) + ) / scale + + +def _selector_kind(selector, sig): + selector = _selector_geometry(selector) + kind = str(selector.get('kind') or selector.get('target_kind') or '').lower() + if kind: + return kind + if 'length' in selector or 'length' in sig: + return 'edge' + if 'area' in selector or 'area' in sig: + return 'face' + return '' + + +def _geom_score(sig, selector): + selector = _selector_geometry(selector) + score = _bbox_selector_score(sig, selector) * 10.0 + if _geom_type_mismatch(sig, selector): + score += 20.0 + scale = _selector_length_scale(selector) + kind = _selector_kind(selector, sig) + expected_center = _tuple3_or_none(selector.get('center')) + actual_center = _tuple3_or_none(sig.get('center')) + if expected_center is not None and actual_center is not None: + score += _distance(actual_center, expected_center) / scale * 3.0 + if kind == 'edge': + if selector.get('length') is not None and sig.get('length') is not None: + score += _relative_error(sig['length'], selector['length']) * 3.0 + expected_start = _tuple3_or_none(selector.get('start')) + expected_end = _tuple3_or_none(selector.get('end')) + candidate_start = _tuple3_or_none(sig.get('start')) + candidate_end = _tuple3_or_none(sig.get('end')) + if all(value is not None for value in (expected_start, expected_end, candidate_start, candidate_end)): + same = _distance(candidate_start, expected_start) + _distance(candidate_end, expected_end) + reverse = _distance(candidate_start, expected_end) + _distance(candidate_end, expected_start) + score += min(same, reverse) / scale + elif kind == 'face': + if selector.get('area') is not None and sig.get('area') is not None: + score += _relative_error(sig['area'], selector['area']) * 3.0 + if _selector_geom_type(selector) == 'PLANE': + expected_normal = _tuple3_or_none(selector.get('normal')) + candidate_normal = _tuple3_or_none(sig.get('normal')) + if expected_normal is not None and candidate_normal is not None: + alignment = abs(_dot(_unit(expected_normal), _unit(candidate_normal))) + score += (1.0 - min(1.0, alignment)) * 2.0 + return score + + +def _point_mm(point): + return (point.x / SCALE, point.y / SCALE, point.z / SCALE) + + +def _candidate_sample_point(candidate, kind): + centroid = getattr(candidate, 'centroid', None) + if centroid is not None: + return _point_mm(centroid) + if kind == 'face': + return _bbox_center(_bbox_tuple(candidate.boundingBox)) + try: + evaluator = candidate.evaluator + ok, start_parameter, end_parameter = evaluator.getParameterExtents() + if not ok or not math.isfinite(start_parameter) or not math.isfinite(end_parameter): + return _bbox_center(_bbox_tuple(candidate.boundingBox)) + points = [] + for index in range(65): + parameter = start_parameter + (end_parameter - start_parameter) * index / 64.0 + ok, point = evaluator.getPointAtParameter(parameter) + if not ok: + return _bbox_center(_bbox_tuple(candidate.boundingBox)) + points.append(_point_mm(point)) + except Exception: + return _bbox_center(_bbox_tuple(candidate.boundingBox)) + weighted = [0.0, 0.0, 0.0] + total_weight = 0.0 + for first, second in zip(points, points[1:]): + weight = _distance(first, second) + if weight <= 0.0: + continue + for axis in range(3): + weighted[axis] += (first[axis] + second[axis]) * 0.5 * weight + total_weight += weight + if total_weight <= 0.0: + return _bbox_center(_bbox_tuple(candidate.boundingBox)) + return tuple(value / total_weight for value in weighted) + + +def _best_by_geometry(candidates, selector, signature_fn, label): + if not candidates: + raise RuntimeError(f'No {label} candidates available for geometry selection') + selector = _selector_geometry(selector) + if SELECTION_MODE == 'index': + kind = _selector_kind(selector, {}) or str(label).lower() + metadata = selector.get('metadata_geo') + key = 'edge_index' if kind == 'edge' else 'face_index' + if not isinstance(metadata, dict) or key not in metadata: + raise RuntimeError( + f'Direct {kind} index is missing for {label} selection; ' + f'selector={selector!r}' + ) + try: + index = int(metadata[key]) + except (TypeError, ValueError) as exc: + raise RuntimeError(f'Invalid direct {kind} index: {metadata[key]!r}') from exc + if index < 0 or index >= len(candidates): + raise RuntimeError( + f'Direct {kind} index {index} is out of range for ' + f'{len(candidates)} {label} candidates' + ) + return candidates[index] + if SELECTION_MODE == 'sample': + kind = _selector_kind(selector, {}) or str(label).lower() + sample = _tuple3_or_none(selector.get('center')) + if sample is None: + raise RuntimeError( + f'Single-point {kind} selector has no canonical sample point; ' + f'selector={selector!r}' + ) + ranked_samples = sorted( + ( + (_distance(_candidate_sample_point(candidate, kind), sample), index, candidate) + for index, candidate in enumerate(candidates) + ), + key=lambda item: (item[0], item[1]), + ) + best_distance, _best_index, best_candidate = ranked_samples[0] + if not math.isfinite(best_distance): + raise RuntimeError(f'Single-point {kind} selection has no finite candidate') + if len(ranked_samples) > 1: + second_distance = ranked_samples[1][0] + scale = max(1.0, abs(best_distance), abs(second_distance), *map(abs, sample)) + if second_distance - best_distance <= scale * 1.0e-9: + raise RuntimeError( + f'Ambiguous single-point {kind} selection: ' + f'best={best_distance}, second={second_distance}, sample={sample!r}' + ) + return best_candidate + ranked = sorted( + [(candidate, signature_fn(candidate)) for candidate in candidates], + key=lambda item: _geom_score(item[1], selector), + ) + + exact_spatial_matches = [ + candidate + for candidate, signature in ranked + if _bbox_selector_score(signature, selector) <= 1.0e-6 + and _geom_type_mismatch(signature, selector) == 0 + ] + if len(exact_spatial_matches) == 1: + return exact_spatial_matches[0] + + kind = _selector_kind(selector, ranked[0][1]) + expected_center = _tuple3_or_none(selector.get('center')) + expected_measure = selector.get('length') if kind == 'edge' else selector.get('area') + scale = _selector_length_scale(selector) + + def intrinsic_matches(center_tolerance, measure_tolerance): + matches = [] + if expected_center is None or expected_measure is None: + return matches + measure_key = 'length' if kind == 'edge' else 'area' + for candidate, signature in ranked: + if _geom_type_mismatch(signature, selector): + continue + candidate_center = _tuple3_or_none(signature.get('center')) + candidate_measure = signature.get(measure_key) + if candidate_center is None or candidate_measure is None: + continue + center_error = _distance(candidate_center, expected_center) / scale + measure_error = _relative_error(candidate_measure, expected_measure) + if center_error <= center_tolerance and measure_error <= measure_tolerance: + matches.append(candidate) + return matches + + exact_intrinsic_matches = intrinsic_matches(1.0e-5, 1.0e-4) + if len(exact_intrinsic_matches) == 1: + return exact_intrinsic_matches[0] + approximate_intrinsic_matches = intrinsic_matches(1.0e-3, 1.0e-3) + if len(approximate_intrinsic_matches) == 1: + return approximate_intrinsic_matches[0] + + best, best_signature = ranked[0] + best_score = _geom_score(best_signature, selector) + second_score = ( + _geom_score(ranked[1][1], selector) if len(ranked) > 1 else float('inf') + ) + exact_match = best_score <= 1.0e-4 + acceptable_match = best_score <= 1.5 + 1.0e-9 + clearly_better = ( + second_score == float('inf') + or second_score - best_score >= max(0.1, best_score * 0.15) + ) + if _geom_type_mismatch(best_signature, selector) or not ( + exact_match or (acceptable_match and clearly_better) + ): + nearest = [ + { + 'score': _geom_score(signature, selector), + 'type_mismatch': _geom_type_mismatch(signature, selector), + 'signature': signature, + } + for _candidate, signature in ranked[:3] + ] + raise RuntimeError( + f'Geometry selector did not match a stable {label}; ' + f'best score={best_score}; second score={second_score}; ' + f'selector={selector!r}; nearest={nearest!r}' + ) + return best + + +def _matrix_translate(vector): + matrix = adsk.core.Matrix3D.create() + matrix.translation = _vec_scaled(vector) + return matrix + + +def _placement_params(value): + if not isinstance(value, dict): + return {} + if value.get('kind') == 'placement': + return value.get('params') or {} + return value + + +def _matrix_from_placement(value): + params = _placement_params(value) + origin = _v3(params.get('origin') or params.get('base') or (0.0, 0.0, 0.0)) + x_axis = _unit(params.get('x_axis') or (1.0, 0.0, 0.0)) + y_axis = _unit(params.get('y_axis') or (0.0, 1.0, 0.0)) + z_axis = params.get('z_axis') + if z_axis is None: + z_axis = ( + x_axis[1] * y_axis[2] - x_axis[2] * y_axis[1], + x_axis[2] * y_axis[0] - x_axis[0] * y_axis[2], + x_axis[0] * y_axis[1] - x_axis[1] * y_axis[0], + ) + z_axis = _unit(z_axis) + ox, oy, oz = _scaled(origin) + matrix = adsk.core.Matrix3D.create() + matrix.setWithArray([ + x_axis[0], y_axis[0], z_axis[0], ox, + x_axis[1], y_axis[1], z_axis[1], oy, + x_axis[2], y_axis[2], z_axis[2], oz, + 0.0, 0.0, 0.0, 1.0, + ]) + return matrix + + +def _placement_is_identity(value): + params = _placement_params(value) + origin = _v3(params.get('origin') or params.get('base') or (0.0, 0.0, 0.0)) + x_axis = _v3(params.get('x_axis') or (1.0, 0.0, 0.0)) + y_axis = _v3(params.get('y_axis') or (0.0, 1.0, 0.0)) + z_axis = _v3(params.get('z_axis') or (0.0, 0.0, 1.0)) + values = origin + _sub(x_axis, (1.0, 0.0, 0.0)) + _sub(y_axis, (0.0, 1.0, 0.0)) + _sub(z_axis, (0.0, 0.0, 1.0)) + return max(abs(float(value)) for value in values) <= 1.0e-10 + + +def _matrix_rotate(origin, axis, angle_degrees): + matrix = adsk.core.Matrix3D.create() + matrix.setToRotation(math.radians(float(angle_degrees)), _vec(axis), _pt(origin)) + return matrix + + +def _matrix_mirror(plane_origin, plane_normal): + normal = _unit(_v3(plane_normal)) + nx, ny, nz = normal + px, py, pz = _scaled(plane_origin) + d = -(nx * px + ny * py + nz * pz) + cells = [ + 1 - 2 * nx * nx, -2 * nx * ny, -2 * nx * nz, -2 * d * nx, + -2 * ny * nx, 1 - 2 * ny * ny, -2 * ny * nz, -2 * d * ny, + -2 * nz * nx, -2 * nz * ny, 1 - 2 * nz * nz, -2 * d * nz, + 0, 0, 0, 1, + ] + matrix = adsk.core.Matrix3D.create() + matrix.setWithArray(cells) + return matrix + + +def _arc_basis(normal, kernel_x_axis=None, kernel_y_axis=None): + axis = _unit(normal) + if kernel_x_axis is not None and kernel_y_axis is not None: + return _unit(kernel_x_axis), _unit(kernel_y_axis) + reference = (0.0, 0.0, 1.0) + if abs(_dot(axis, reference)) > 0.9: + reference = (1.0, 0.0, 0.0) + x_axis = _unit(_cross(axis, reference), fallback=(1.0, 0.0, 0.0)) + y_axis = _unit(_cross(axis, x_axis), fallback=(0.0, 1.0, 0.0)) + return x_axis, y_axis + + +def _arc_midpoint( + center, + radius, + start_angle, + end_angle, + normal=(0.0, 0.0, 1.0), + kernel_x_axis=None, + kernel_y_axis=None, +): + angle = (float(start_angle) + float(end_angle)) * 0.5 + x_axis, y_axis = _arc_basis(normal, kernel_x_axis, kernel_y_axis) + offset = _add(_mul(x_axis, float(radius) * math.cos(angle)), _mul(y_axis, float(radius) * math.sin(angle))) + return _add(_v3(center), offset) + + +def _arc_endpoint( + center, + radius, + angle, + normal=(0.0, 0.0, 1.0), + kernel_x_axis=None, + kernel_y_axis=None, +): + x_axis, y_axis = _arc_basis(normal, kernel_x_axis, kernel_y_axis) + offset = _add(_mul(x_axis, float(radius) * math.cos(float(angle))), _mul(y_axis, float(radius) * math.sin(float(angle)))) + return _add(_v3(center), offset) + + +def _three_point_circle(start, middle, end): + ax, ay, az = _v3(start) + bx, by, bz = _v3(middle) + cx, cy, cz = _v3(end) + d = 2.0 * (ax * (by - cy) + bx * (cy - ay) + cx * (ay - by)) + if abs(d) <= 1.0e-12: + return None + ux = ((ax * ax + ay * ay) * (by - cy) + (bx * bx + by * by) * (cy - ay) + (cx * cx + cy * cy) * (ay - by)) / d + uy = ((ax * ax + ay * ay) * (cx - bx) + (bx * bx + by * by) * (ax - cx) + (cx * cx + cy * cy) * (bx - ax)) / d + center = (ux, uy, az) + return center, _distance(center, start) + + +def _placement_payload_origin(payload): + if not isinstance(payload, dict): + return (0.0, 0.0, 0.0) + return _v3(payload.get('origin') or payload.get('base') or (0.0, 0.0, 0.0)) + + +def _apply_name(entity, name): + try: + entity.name = str(name) + except Exception: + pass + return entity + + +def _set_simplecad_attribute(entity, name, value): + try: + attributes = entity.attributes + existing = attributes.itemByName('SimpleCAD', str(name)) + if existing is not None: + existing.deleteMe() + attributes.add('SimpleCAD', str(name), str(value)) + except Exception: + pass + + +def _set_simplecad_chunked_attribute(entity, name, value, chunk_size=12000): + text = str(value) + chunks = [text[index:index + chunk_size] for index in range(0, len(text), chunk_size)] + _set_simplecad_attribute(entity, name + 'ChunkCount', len(chunks)) + for index, chunk in enumerate(chunks): + _set_simplecad_attribute(entity, name + 'Chunk' + str(index), chunk) + + +def _attach_simplecad_native_metadata(entity, node_id, op, params, inputs): + if entity is None: + return entity + _set_simplecad_attribute(entity, 'NodeId', node_id) + _set_simplecad_attribute(entity, 'Op', op) + _set_simplecad_attribute(entity, 'Params', json.dumps(params or {}, sort_keys=True)) + _set_simplecad_attribute(entity, 'Inputs', json.dumps(inputs or [])) + return entity + + +class SimpleCADFusionRuntime: + def __init__( + self, + payload, + document_name, + result_node_ids, + source_kernel_steps=None, + source_kernel_signatures=None, + seam_split_circle_node_ids=None, + ): + self.payload = payload + self.graph = payload.get('graph') or {} + self.nodes = self.graph.get('nodes') or [] + self.node_by_id = {str(node.get('node_id')): node for node in self.nodes} + self.document_name = document_name + self.result_node_ids = [str(v) for v in (result_node_ids or [])] + self.source_kernel_steps = dict(source_kernel_steps or {}) + self.source_kernel_signatures = dict(source_kernel_signatures or {}) + self.source_kernel_body_sets = {} + self.seam_split_circle_node_ids = { + str(node_id) for node_id in (seam_split_circle_node_ids or []) + } + self.outputs = {} + self.native_features = {} + self.native_bodies = {} + self.native_body_sets = {} + self.materialized_native_node_ids = set() + self.materialized_component_definitions = {} + self.precreated_component_occurrences = {} + self.part_body_node_ids = { + self._input_ids(node)[0] + for node in self.nodes + if str(node.get('op')) == 'make_part_rpart' + and self._input_ids(node) + } + self.product_values = {} + self.selection_payloads = {} + self.tmp = adsk.fusion.TemporaryBRepManager.get() + self.app = adsk.core.Application.get() + self.previous_document = self.app.activeDocument + self.document = None + self.design = None + self.root = None + self.logs = [] + + def run(self): + try: + self._prepare_document() + active_node_ids = self._result_dependency_ids(self.result_node_ids) + for node in self.nodes: + if active_node_ids and str(node.get('node_id')) not in active_node_ids: + continue + self._emit_node(node) + self._materialize_results() + except Exception: + self._close_failed_document() + raise + print('SimpleCAD Fusion translation complete', self.document_name, 'nodes', len(self.nodes)) + if self.logs: + print('\n'.join(self.logs)) + feature_ops = { + 'make_extrude_rsolid', 'make_revolve_rsolid', 'make_loft_rsolid', + 'make_sweep_rsolid', 'make_cut_rsolid', 'make_union_rsolid', + 'make_intersect_rsolid', 'make_fillet_rsolid', + 'make_chamfer_rsolid', 'make_shell_rsolid', + 'make_translate_rshape', 'make_rotate_rshape', 'make_mirror_rshape', + } + executed_node_ids = [ + str(node.get('node_id')) for node in self.nodes + if self.outputs.get(str(node.get('node_id'))) + ] + executed_feature_node_ids = [ + str(node.get('node_id')) for node in self.nodes + if str(node.get('op')) in feature_ops + and self.outputs.get(str(node.get('node_id'))) + ] + return { + 'node_count': len(self.nodes), + 'executed_node_count': len(executed_node_ids), + 'feature_count': sum(str(node.get('op')) in feature_ops for node in self.nodes), + 'executed_feature_count': len(executed_feature_node_ids), + 'executed_node_ids': executed_node_ids, + 'executed_feature_node_ids': executed_feature_node_ids, + 'logs': list(self.logs), + } + + def _result_dependency_ids(self, result_node_ids): + needed = set() + pending = [str(node_id) for node_id in (result_node_ids or [])] + while pending: + node_id = pending.pop() + if node_id in needed: + continue + node = self.node_by_id.get(node_id) + if not isinstance(node, dict): + continue + needed.add(node_id) + pending.extend(self._input_ids(node)) + params = node.get('params') or {} + for key in ('selected_edge_node_ids', 'selected_face_node_ids'): + pending.extend(str(value) for value in (params.get(key) or [])) + return needed + + def _prepare_document(self): + documents = self.app.documents + doc = documents.add(adsk.core.DocumentTypes.FusionDesignDocumentType) + self.document = doc + try: + doc.name = self.document_name + except Exception: + pass + self.design = adsk.fusion.Design.cast(self.app.activeProduct) + if self.design is None: + raise RuntimeError('Active Fusion product is not a Design') + try: + self.design.designType = adsk.fusion.DesignTypes.ParametricDesignType + except Exception: + pass + self.root = self.design.rootComponent + + def _close_failed_document(self): + try: + if self.document is not None: + self.document.close(False) + except Exception: + pass + try: + if self.previous_document is not None: + self.previous_document.activate() + except Exception: + pass + + def _add_base_body(self, body): + # A shared BaseFeature sits at the start of the timeline. Re-entering + # it for every graph node forces Fusion to replay all later features + # repeatedly and makes large models effectively quadratic. Keep each + # imported BRep immediately before its consuming native feature. + base_feature = self.root.features.baseFeatures.add() + base_feature.startEdit() + try: + source_body = self.root.bRepBodies.add(body, base_feature) + if source_body is None: + raise RuntimeError('Fusion could not add body to BaseFeature') + finally: + base_feature.finishEdit() + result_bodies = list(base_feature.bodies) + if len(result_bodies) != 1: + raise RuntimeError( + f'expected one BaseFeature result body, found {len(result_bodies)}' + ) + return result_bodies[0] + + def _input_ids(self, node): + return [str(ref.get('node_id')) for ref in (node.get('inputs') or []) if isinstance(ref, dict)] + + def _has_consumers(self, node_id): + target_id = str(node_id) + for candidate in self.nodes: + for reference in candidate.get('inputs') or []: + if isinstance(reference, dict): + reference_id = reference.get('node_id') + else: + reference_id = reference + if str(reference_id) == target_id: + return True + return False + + def _first_output(self, node_id): + outputs = self.outputs.get(str(node_id)) or [] + if not outputs: + raise RuntimeError(f'Missing graph output for {node_id}') + return outputs[0] + + def _body_copy(self, body): + return self.tmp.copy(body) + + def _delete_entity(self, entity): + if entity is None: + return + try: + if hasattr(entity, 'isValid') and not entity.isValid: + return + except Exception: + pass + try: + entity.deleteMe() + except Exception: + pass + + def _register_native_feature(self, node, feature, body): + node_id = str(node.get('node_id')) + op = str(node.get('op')) + params = node.get('params') or {} + inputs = self._input_ids(node) + _apply_name(feature, _safe_label(op, node_id)) + _apply_name(body, f'SimpleCAD_{node_id}') + _attach_simplecad_native_metadata(feature, node_id, op, params, inputs) + _attach_simplecad_native_metadata(body, node_id, op, params, inputs) + if inputs: + source_step = self.source_kernel_steps.get(str(inputs[0])) + if source_step: + _set_simplecad_chunked_attribute( + feature, 'TopologySourceSTEP', source_step + ) + self.native_features[node_id] = feature + self.native_bodies[node_id] = body + return body + + def _precreate_terminal_part_component(self, node, body): + node_id = str(node.get('node_id')) + if ( + node_id not in self.part_body_node_ids + or node_id in self.precreated_component_occurrences + ): + return body + moved = body.createComponent() + if moved is None: + raise RuntimeError( + f'Fusion could not create terminal component for {node_id}' + ) + if not self.design.activateRootComponent(): + raise RuntimeError( + f'Fusion could not reactivate the root component after {node_id}' + ) + child = moved.parentComponent + occurrences = [ + occurrence + for occurrence in self.root.occurrences + if occurrence.component == child + ] + if len(occurrences) != 1: + raise RuntimeError( + f'Fusion did not expose one terminal occurrence for {node_id}; ' + f'found {len(occurrences)}' + ) + occurrence = occurrences[0] + try: + moved.isVisible = False + except Exception: + pass + self.precreated_component_occurrences[node_id] = occurrence + self.logs.append(f'precreated live terminal component for {node_id}') + return moved + + def _feature_result_body(self, feature, label, expected_body=None): + try: + if feature.healthState in { + adsk.fusion.FeatureHealthStates.ErrorFeatureHealthState, + adsk.fusion.FeatureHealthStates.SuppressedFeatureHealthState, + }: + raise RuntimeError( + f'{label} is unhealthy: {feature.errorOrWarningMessage}' + ) + except AttributeError: + pass + feature_bodies = [] + try: + feature_bodies = [body for body in feature.bodies] + except Exception: + feature_bodies = [] + if not feature_bodies: + raise RuntimeError(f'Expected at least one {label}') + if expected_body is None or len(feature_bodies) == 1: + return feature_bodies[0] + try: + expected_volume = float(expected_body.volume) + expected_bbox = _bbox_tuple(expected_body.boundingBox) + + def score(body): + volume_scale = max(1.0e-9, abs(expected_volume), abs(float(body.volume))) + volume_error = abs(float(body.volume) - expected_volume) / volume_scale + bbox = _bbox_tuple(body.boundingBox) + bbox_error = sum( + abs(float(left) - float(right)) + for left, right in zip( + bbox['min'] + bbox['max'], + expected_bbox['min'] + expected_bbox['max'], + ) + ) + return (volume_error, bbox_error) + + return min(feature_bodies, key=score) + except Exception: + return feature_bodies[0] + + def _copy_first_feature_body_and_cleanup( + self, feature, label, cleanup=None, node=None, expected_body=None + ): + native_body = self._feature_result_body(feature, label, expected_body) + if node is not None: + self._register_native_feature(node, feature, native_body) + body = self.tmp.copy(native_body) + for old in cleanup or []: + try: + old.isVisible = False + except Exception: + pass + return body + + def _native_source_body(self, input_id, fallback_body): + native = self.native_bodies.get(str(input_id)) + if native is not None: + try: + if native.isValid and self._native_body_close(native, fallback_body): + return native, False + except Exception: + pass + persisted = self._add_base_body(fallback_body) + _apply_name(persisted, f'SimpleCAD_base_{input_id}') + _attach_simplecad_native_metadata( + persisted, str(input_id), 'static_input_fallback', {}, [] + ) + return persisted, True + + def _native_body_matches(self, native, expected): + try: + native_volume = float(native.volume) + expected_volume = float(expected.volume) + scale = max(1.0e-9, abs(native_volume), abs(expected_volume)) + if abs(native_volume - expected_volume) > scale * 1.0e-7: + return False + native_bbox = _bbox_tuple(native.boundingBox) + expected_bbox = _bbox_tuple(expected.boundingBox) + bbox_scale = max( + 1.0, + *(abs(float(value)) for value in native_bbox['min'] + native_bbox['max']), + *(abs(float(value)) for value in expected_bbox['min'] + expected_bbox['max']), + ) + return max( + abs(float(left) - float(right)) + for left, right in zip( + native_bbox['min'] + native_bbox['max'], + expected_bbox['min'] + expected_bbox['max'], + ) + ) <= bbox_scale * 1.0e-7 + except Exception: + return False + + def _native_body_close(self, native, expected): + try: + native_volume = float(native.volume) + expected_volume = float(expected.volume) + scale = max(1.0e-9, abs(native_volume), abs(expected_volume)) + if abs(native_volume - expected_volume) > scale * 1.0e-2: + return False + native_bbox = _bbox_tuple(native.boundingBox) + expected_bbox = _bbox_tuple(expected.boundingBox) + bbox_scale = max( + 1.0, + *(abs(float(value)) for value in native_bbox['min'] + native_bbox['max']), + *(abs(float(value)) for value in expected_bbox['min'] + expected_bbox['max']), + ) + if max( + abs(float(left) - float(right)) + for left, right in zip( + native_bbox['min'] + native_bbox['max'], + expected_bbox['min'] + expected_bbox['max'], + ) + ) > bbox_scale * 1.0e-2: + return False + return True + except Exception: + return False + + def _native_body_comparison(self, native, expected): + try: + native_bbox = _bbox_tuple(native.boundingBox) + expected_bbox = _bbox_tuple(expected.boundingBox) + native_center = native.physicalProperties.centerOfMass + expected_center = expected.physicalProperties.centerOfMass + return { + 'native_volume': float(native.volume), + 'expected_volume': float(expected.volume), + 'native_bbox': native_bbox, + 'expected_bbox': expected_bbox, + 'native_center_cm': ( + float(native_center.x), + float(native_center.y), + float(native_center.z), + ), + 'expected_center_cm': ( + float(expected_center.x), + float(expected_center.y), + float(expected_center.z), + ), + } + except Exception as exc: + return {'error': f'{type(exc).__name__}: {exc}'} + + def _native_body_set_close(self, bodies, expected): + try: + unique = [] + seen = set() + for body in bodies: + try: + if not body.isValid: + continue + except Exception: + pass + try: + token = str(body.entityToken) + except Exception: + token = str(id(body)) + token = token or str(id(body)) + if token not in seen: + seen.add(token) + unique.append(body) + if not unique: + return False + expected_volume = float(expected.volume) + actual_volume = sum(float(body.volume) for body in unique) + volume_scale = max(1.0e-9, abs(expected_volume), abs(actual_volume)) + if abs(actual_volume - expected_volume) > volume_scale * 1.0e-2: + return False + boxes = [_bbox_tuple(body.boundingBox) for body in unique] + actual_bbox = { + 'min': tuple(min(box['min'][axis] for box in boxes) for axis in range(3)), + 'max': tuple(max(box['max'][axis] for box in boxes) for axis in range(3)), + } + expected_bbox = _bbox_tuple(expected.boundingBox) + bbox_scale = max( + 1.0, + *(abs(float(value)) for value in actual_bbox['min'] + actual_bbox['max']), + *(abs(float(value)) for value in expected_bbox['min'] + expected_bbox['max']), + ) + return max( + abs(float(left) - float(right)) + for left, right in zip( + actual_bbox['min'] + actual_bbox['max'], + expected_bbox['min'] + expected_bbox['max'], + ) + ) <= bbox_scale * 1.0e-2 + except Exception: + return False + + def _native_body_sets_close(self, bodies, expected_bodies): + """Compare disconnected native bodies with a disconnected expected set.""" + try: + actual = [body for body in bodies if body is not None] + expected = [body for body in expected_bodies if body is not None] + if not actual or not expected: + return False + actual_volume = sum(float(body.volume) for body in actual) + expected_volume = sum(float(body.volume) for body in expected) + volume_scale = max(1.0e-9, abs(actual_volume), abs(expected_volume)) + if abs(actual_volume - expected_volume) > volume_scale * 1.0e-2: + return False + actual_boxes = [_bbox_tuple(body.boundingBox) for body in actual] + expected_boxes = [_bbox_tuple(body.boundingBox) for body in expected] + actual_bbox = { + 'min': tuple(min(box['min'][axis] for box in actual_boxes) for axis in range(3)), + 'max': tuple(max(box['max'][axis] for box in actual_boxes) for axis in range(3)), + } + expected_bbox = { + 'min': tuple(min(box['min'][axis] for box in expected_boxes) for axis in range(3)), + 'max': tuple(max(box['max'][axis] for box in expected_boxes) for axis in range(3)), + } + scale = max( + 1.0, + *(abs(float(value)) for value in actual_bbox['min'] + actual_bbox['max']), + *(abs(float(value)) for value in expected_bbox['min'] + expected_bbox['max']), + ) + return max( + max( + abs(actual_bbox[key][axis] - expected_bbox[key][axis]) + for key in ('min', 'max') + for axis in range(3) + ), + ) <= scale * 1.0e-2 + except Exception: + return False + + def _native_body_sets_bbox_close(self, bodies, expected_bodies): + """Check disconnected body correspondence without requiring equal volume.""" + try: + actual = [body for body in bodies if body is not None] + expected = [body for body in expected_bodies if body is not None] + if len(actual) != len(expected) or not actual: + return False + actual_boxes = [_bbox_tuple(body.boundingBox) for body in actual] + expected_boxes = [_bbox_tuple(body.boundingBox) for body in expected] + actual_bbox = { + 'min': tuple(min(box['min'][axis] for box in actual_boxes) for axis in range(3)), + 'max': tuple(max(box['max'][axis] for box in actual_boxes) for axis in range(3)), + } + expected_bbox = { + 'min': tuple(min(box['min'][axis] for box in expected_boxes) for axis in range(3)), + 'max': tuple(max(box['max'][axis] for box in expected_boxes) for axis in range(3)), + } + scale = max( + 1.0, + *(abs(float(value)) for value in actual_bbox['min'] + actual_bbox['max']), + *(abs(float(value)) for value in expected_bbox['min'] + expected_bbox['max']), + ) + return max( + abs(actual_bbox[key][axis] - expected_bbox[key][axis]) + for key in ('min', 'max') + for axis in range(3) + ) <= scale * 1.0e-2 + except Exception: + return False + + def _native_boolean_feature(self, node, operation, expected_body): + inputs = self._input_ids(node) + if len(inputs) < 2: + return None + native = [] + temporary_inputs = [] + pending_static_inputs = [] + primary_body_set_count = 0 + for input_id in inputs: + body_set = self.native_body_sets.get(str(input_id)) + if body_set: + if not native: + primary_body_set_count = len(body_set) + native.extend(body_set) + continue + body = self.native_bodies.get(str(input_id)) + exact_input = self._source_kernel_step_body(input_id) + if body is not None and exact_input is not None: + if self._native_body_close(body, exact_input): + self._delete_entity(exact_input) + else: + body = None + pending_static_inputs.append( + (len(native), str(input_id), exact_input, 'exact_static_input') + ) + elif body is None and exact_input is not None: + pending_static_inputs.append( + (len(native), str(input_id), exact_input, 'exact_static_input') + ) + body = None + if body is None: + if not any(index == len(native) for index, *_rest in pending_static_inputs): + pending_static_inputs.append( + ( + len(native), + str(input_id), + self._body_copy(self._first_output(input_id)), + 'static_input_fallback', + ) + ) + native.append(body) + if pending_static_inputs: + base_feature = self.root.features.baseFeatures.add() + base_feature.startEdit() + try: + for index, input_id, temporary_body, role in pending_static_inputs: + persisted = self.root.bRepBodies.add(temporary_body, base_feature) + persisted_name = ( + f'SimpleCAD_boolean_{role}_{input_id}_{node.get("node_id")}' + ) + _apply_name( + persisted, + persisted_name, + ) + _attach_simplecad_native_metadata( + persisted, input_id, role, {}, [] + ) + finally: + base_feature.finishEdit() + result_bodies = list(base_feature.bodies) + if len(result_bodies) != len(pending_static_inputs): + raise RuntimeError( + f'expected {len(pending_static_inputs)} persisted Boolean inputs, ' + f'found {len(result_bodies)}' + ) + for pending, result_body in zip(pending_static_inputs, result_bodies): + index, input_id, _temporary_body, role = pending + persisted_name = ( + f'SimpleCAD_boolean_{role}_{input_id}_{node.get("node_id")}' + ) + _apply_name(result_body, persisted_name) + _attach_simplecad_native_metadata( + result_body, input_id, role, {}, [] + ) + native[index] = result_body + temporary_inputs.append(result_body) + unique_native = [] + seen_tokens = set() + for body in native: + try: + if not body.isValid: + continue + except Exception: + pass + try: + token = str(body.entityToken) + except Exception: + token = str(id(body)) + token = token or str(id(body)) + if token not in seen_tokens: + seen_tokens.add(token) + unique_native.append(body) + native = unique_native + if ( + operation == adsk.fusion.BooleanTypes.DifferenceBooleanType + and primary_body_set_count > 1 + ): + return self._native_difference_body_set( + node, + native[:primary_body_set_count], + native[primary_body_set_count:], + expected_body, + ) + if ( + operation == adsk.fusion.BooleanTypes.UnionBooleanType + and self._native_body_set_close(native, expected_body) + ): + node_id = str(node.get('node_id')) + self.native_body_sets[node_id] = list(native) + self.logs.append( + f'native disjoint union body set used for {node_id}: ' + f'body_count={len(native)}' + ) + return native[0] + try: + feature_operation = { + adsk.fusion.BooleanTypes.DifferenceBooleanType: + adsk.fusion.FeatureOperations.CutFeatureOperation, + adsk.fusion.BooleanTypes.UnionBooleanType: + adsk.fusion.FeatureOperations.JoinFeatureOperation, + adsk.fusion.BooleanTypes.IntersectionBooleanType: + adsk.fusion.FeatureOperations.IntersectFeatureOperation, + }.get(operation) + if feature_operation is None: + raise RuntimeError(f'unsupported native boolean operation: {operation}') + tools = _object_collection(native[1:]) + combine_input = self.root.features.combineFeatures.createInput( + native[0], tools + ) + combine_input.operation = feature_operation + if hasattr(combine_input, 'isKeepToolBodies'): + combine_input.isKeepToolBodies = True + feature = self.root.features.combineFeatures.add(combine_input) + bodies = list(feature.bodies) + if not bodies: + return None + result_body = self._feature_result_body( + feature, 'native boolean result body', expected_body + ) + if not self._native_body_close(result_body, expected_body): + self.logs.append( + f'native boolean geometry mismatch for {node.get("node_id")}; ' + 'using exact static result; ' + f'comparison={self._native_body_comparison(result_body, expected_body)!r}; ' + f'input_count={len(native)}; ' + f'input_volumes={[float(body.volume) for body in native]!r}' + ) + self._delete_entity(feature) + return None + self._register_native_feature(node, feature, result_body) + return result_body + except Exception as exc: + self.logs.append( + f'native boolean feature unavailable for {node.get("node_id")}: {exc}' + ) + return None + + def _native_difference_body_set(self, node, targets, tools, expected_body): + if not targets or not tools: + return None + features = [] + results = [] + try: + for target_index, target in enumerate(targets): + current = target + for tool_index, tool in enumerate(tools): + combine_input = self.root.features.combineFeatures.createInput( + current, _object_collection([tool]) + ) + combine_input.operation = ( + adsk.fusion.FeatureOperations.CutFeatureOperation + ) + if hasattr(combine_input, 'isKeepToolBodies'): + combine_input.isKeepToolBodies = True + try: + feature = self.root.features.combineFeatures.add(combine_input) + except Exception as exc: + self.logs.append( + f'native body-set cut skipped target={target_index} ' + f'tool={tool_index} for {node.get("node_id")}: {exc}' + ) + continue + current = self._feature_result_body( + feature, 'native body-set cut result body' + ) + features.append(feature) + results.append(current) + if not self._native_body_set_close(results, expected_body): + self.logs.append( + f'native body-set cut geometry mismatch for {node.get("node_id")}; ' + f'result_count={len(results)}; ' + f'result_volumes={[float(body.volume) for body in results]!r}' + ) + for feature in reversed(features): + self._delete_entity(feature) + return None + node_id = str(node.get('node_id')) + op = str(node.get('op')) + params = node.get('params') or {} + input_ids = self._input_ids(node) + for index, body in enumerate(results): + _apply_name(body, f'SimpleCAD_{node_id}_{index}') + _attach_simplecad_native_metadata( + body, node_id, op, params, input_ids + ) + for index, feature in enumerate(features): + _apply_name(feature, f'{_safe_label(op, node_id)}_{index}') + _attach_simplecad_native_metadata( + feature, node_id, op, params, input_ids + ) + self.native_body_sets[node_id] = list(results) + self.native_bodies[node_id] = results[0] + if features: + self.native_features[node_id] = features[-1] + self.logs.append( + f'native body-set cut used for {node_id}: body_count={len(results)}' + ) + return results[0] + except Exception as exc: + for feature in reversed(features): + self._delete_entity(feature) + self.logs.append( + f'native body-set cut unavailable for {node.get("node_id")}: {exc}' + ) + return None + + def _native_move_feature(self, node, matrix, expected_body=None): + inputs = self._input_ids(node) + if not inputs: + return None + input_id = inputs[0] + source_bodies = list(self.native_body_sets.get(input_id) or []) + if not source_bodies: + body = self.native_bodies.get(input_id) + if body is not None: + source_bodies = [body] + if not source_bodies: + return None + try: + move_input = self.root.features.moveFeatures.createInput2( + _object_collection(source_bodies) + ) + move_input.defineAsFreeMove(matrix) + feature = self.root.features.moveFeatures.add(move_input) + bodies = list(feature.bodies) + if not bodies: + return None + if len(source_bodies) > 1: + if expected_body is not None and not self._native_body_set_close( + bodies, expected_body + ): + self.logs.append( + f'native body-set move geometry mismatch for ' + f'{node.get("node_id")}; body_count={len(bodies)}' + ) + self._delete_entity(feature) + return None + node_id = str(node.get('node_id')) + op = str(node.get('op')) + params = node.get('params') or {} + input_ids = self._input_ids(node) + _apply_name(feature, _safe_label(op, node_id)) + _attach_simplecad_native_metadata( + feature, node_id, op, params, input_ids + ) + for index, result_body in enumerate(bodies): + _apply_name(result_body, f'SimpleCAD_{node_id}_{index}') + _attach_simplecad_native_metadata( + result_body, node_id, op, params, input_ids + ) + self.native_features[node_id] = feature + self.native_bodies[node_id] = bodies[0] + self.native_body_sets[node_id] = list(bodies) + self.logs.append( + f'native body-set move used for {node_id}: ' + f'body_count={len(bodies)}' + ) + return bodies[0] + result_body = self._precreate_terminal_part_component(node, bodies[0]) + self._register_native_feature(node, feature, result_body) + return result_body + except Exception as exc: + self.logs.append( + f'native move feature unavailable for {node.get("node_id")}: {exc}' + ) + return None + + def _planar_profile_body(self, profile, label): + if not isinstance(profile, adsk.fusion.BRepBody): + raise RuntimeError(f'{label} profile is not a BRep body') + if profile.faces.count > 0: + return profile + wire_body = profile + if wire_body.wires.count <= 0 and wire_body.edges.count > 0: + curves = [edge.geometry for edge in wire_body.edges] + wire_body, _edges = self.tmp.createWireFromCurves(curves, False) + if wire_body is None or wire_body.wires.count <= 0: + raise RuntimeError(f'{label} profile has no usable planar wire') + face_body = self.tmp.createFaceFromPlanarWires([wire_body]) + if face_body is None or face_body.faces.count <= 0: + raise RuntimeError(f'{label} profile wire could not be converted to a planar face') + return face_body + + def _source_kernel_step_body(self, node_id): + encoded_step = self.source_kernel_steps.get(str(node_id)) + if not encoded_step: + return None + expected_signature = self.source_kernel_signatures.get(str(node_id)) or {} + before_tokens = { + str(getattr(body, 'entityToken', '')) + for body in self.root.bRepBodies + } + file_descriptor, step_path = tempfile.mkstemp(suffix='.step') + os.close(file_descriptor) + imported_entities = [] + try: + with open(step_path, 'wb') as output: + compressed = base64.b64decode(encoded_step.encode('ascii')) + output.write(zlib.decompress(compressed)) + import_manager = self.app.importManager + options = import_manager.createSTEPImportOptions(step_path) + imported = import_manager.importToTarget2(options, self.root) + imported_entities = list(_iter_collection(imported)) if imported is not None else [] + adsk.doEvents() + candidate_groups = [] + all_occurrence_local = [] + all_occurrence_transformed = [] + all_occurrence_proxy = [] + direct_bodies = [ + entity + for entity in imported_entities + if isinstance(entity, adsk.fusion.BRepBody) + ] + if direct_bodies: + candidate_groups.append(('direct_import', direct_bodies)) + for entity in imported_entities: + if isinstance(entity, adsk.fusion.Occurrence): + local_bodies = [] + transformed_bodies = [] + for local_body in entity.component.bRepBodies: + local_bodies.append(self._body_copy(local_body)) + transformed_body = self._body_copy(local_body) + self.tmp.transform(transformed_body, entity.transform2) + transformed_bodies.append(transformed_body) + if local_bodies: + candidate_groups.append(('occurrence_local', local_bodies)) + all_occurrence_local.extend(local_bodies) + if transformed_bodies: + candidate_groups.append( + ('occurrence_transformed', transformed_bodies) + ) + all_occurrence_transformed.extend(transformed_bodies) + proxy_bodies = [] + try: + proxy_bodies = [ + self._body_copy(body) for body in entity.bRepBodies + ] + except Exception: + proxy_bodies = [] + if proxy_bodies: + candidate_groups.append(('occurrence_proxy', proxy_bodies)) + all_occurrence_proxy.extend(proxy_bodies) + if len(all_occurrence_local) > 1: + candidate_groups.append(('all_occurrence_local', all_occurrence_local)) + if len(all_occurrence_transformed) > 1: + candidate_groups.append( + ('all_occurrence_transformed', all_occurrence_transformed) + ) + if len(all_occurrence_proxy) > 1: + candidate_groups.append(('all_occurrence_proxy', all_occurrence_proxy)) + root_bodies = [ + body for body in self.root.bRepBodies + if str(getattr(body, 'entityToken', '')) not in before_tokens + ] + if root_bodies: + candidate_groups.append(('root_new_body', root_bodies)) + if not candidate_groups: + raise RuntimeError(f'Source-kernel STEP import produced no body for {node_id}') + + candidates = [] + for group_label, group_bodies in candidate_groups: + for index, body in enumerate(group_bodies): + candidates.append((f'{group_label}[{index}]', body)) + if len(group_bodies) > 1: + combined = self._body_copy(group_bodies[0]) + combined_ok = True + for body in group_bodies[1:]: + if not self.tmp.booleanOperation( + combined, + self._body_copy(body), + adsk.fusion.BooleanTypes.UnionBooleanType, + ): + combined_ok = False + break + if combined_ok: + candidates.append((group_label + '_union', combined)) + + def candidate_score(candidate): + if not expected_signature: + return 0.0 + try: + expected_bbox = expected_signature.get('bbox') or {} + actual_bbox = _bbox_tuple(candidate.boundingBox) + coordinates = ( + list(expected_bbox.get('min') or ()) + + list(expected_bbox.get('max') or ()) + + list(actual_bbox['min']) + + list(actual_bbox['max']) + ) + bbox_scale = max( + 1.0, *(abs(float(value)) for value in coordinates) + ) + bbox_error = max( + abs(float(left) - float(right)) + for left, right in zip( + list(expected_bbox.get('min') or ()) + + list(expected_bbox.get('max') or ()), + list(actual_bbox['min']) + list(actual_bbox['max']), + ) + ) / bbox_scale + expected_volume = float(expected_signature.get('volume') or 0.0) + actual_volume = float(candidate.volume) + volume_scale = max( + 1.0e-9, abs(expected_volume), abs(actual_volume) + ) + volume_error = abs(actual_volume - expected_volume) / volume_scale + return volume_error * 10.0 + bbox_error + except Exception: + return float('inf') + + def candidate_group_score(group): + if len(group) == 1: + return candidate_score(group[0]) + try: + expected_bbox = expected_signature.get('bbox') or {} + boxes = [_bbox_tuple(body.boundingBox) for body in group] + actual_bbox = { + 'min': tuple(min(box['min'][axis] for box in boxes) for axis in range(3)), + 'max': tuple(max(box['max'][axis] for box in boxes) for axis in range(3)), + } + coordinates = ( + list(expected_bbox.get('min') or ()) + + list(expected_bbox.get('max') or ()) + + list(actual_bbox['min']) + + list(actual_bbox['max']) + ) + bbox_scale = max(1.0, *(abs(float(value)) for value in coordinates)) + bbox_error = max( + abs(float(left) - float(right)) + for left, right in zip( + list(expected_bbox.get('min') or ()) + + list(expected_bbox.get('max') or ()), + list(actual_bbox['min']) + list(actual_bbox['max']), + ) + ) / bbox_scale + expected_volume = float(expected_signature.get('volume') or 0.0) + actual_volume = sum(float(body.volume) for body in group) + volume_scale = max(1.0e-9, abs(expected_volume), abs(actual_volume)) + volume_error = abs(actual_volume - expected_volume) / volume_scale + return volume_error * 10.0 + bbox_error + except Exception: + return float('inf') + + ranked = sorted( + (candidate_score(body), label, body) for label, body in candidates + ) + group_ranked = sorted( + (candidate_group_score(group), label, group) + for label, group in candidate_groups + if len(group) > 1 + ) + if group_ranked and ( + not ranked or group_ranked[0][0] < ranked[0][0] + ): + group_score, group_label, group_bodies = group_ranked[0] + self.source_kernel_body_sets[str(node_id)] = [ + self._body_copy(body) for body in group_bodies + ] + self.logs.append( + f'source-kernel STEP body set for {node_id}: ' + f'label={group_label}; score={group_score}; ' + f'body_count={len(group_bodies)}' + ) + best_score, best_label, best_body = ranked[0] + result = self._body_copy(best_body) + if len(ranked) > 1 or best_score > 1.0e-7: + self.logs.append( + f'source-kernel STEP candidate for {node_id}: ' + f'label={best_label}; score={best_score}; ' + f'alternatives={[(score, label) for score, label, _body in ranked[:4]]!r}' + ) + for entity in imported_entities: + self._delete_entity(entity) + for _label, candidate in candidates: + self._delete_entity(candidate) + return result + finally: + try: + os.unlink(step_path) + except OSError: + pass + + def _set_output(self, node, values): + node_id = str(node.get('node_id')) + if not isinstance(values, list): + values = [values] + self.outputs[node_id] = values + return values + + def _native_output_values(self, node_id, fallback): + body_set = list(self.native_body_sets.get(str(node_id)) or []) + if body_set: + return body_set + body = self.native_bodies.get(str(node_id)) + return [body] if body is not None else [fallback] + + def _split_circle_curve(self, params): + center = _v3(params.get('center')) + normal = _unit(params.get('normal') or (0.0, 0.0, 1.0)) + radius = float(params.get('radius', 0.0)) + if radius <= 0.0: + raise RuntimeError('circle radius must be positive') + reference = (1.0, 0.0, 0.0) + if abs(_dot(normal, reference)) > 0.9: + reference = (0.0, 1.0, 0.0) + first = _unit(_cross(reference, normal), fallback=(0.0, 1.0, 0.0)) + second = _unit(_cross(normal, first), fallback=(0.0, 0.0, 1.0)) + points = [] + for axis in (first, second, _mul(first, -1.0), _mul(second, -1.0)): + points.append(_add(center, _mul(axis, radius))) + curves = [] + for index in range(4): + start = points[index] + end = points[(index + 1) % 4] + start_axis = _unit(_sub(start, center)) + end_axis = _unit(_sub(end, center)) + control = _add( + center, + _mul(_add(start_axis, end_axis), radius), + ) + curve = adsk.core.NurbsCurve3D.createRational( + [_pt(start), _pt(control), _pt(end)], + 2, + [0.0, 0.0, 0.0, 1.0, 1.0, 1.0], + [1.0, 2.0 ** -0.5, 1.0], + False, + ) + if curve is None: + raise RuntimeError('failed to create rational circular arc') + curves.append(curve) + return curves + + def _emit_node(self, node): + op = str(node.get('op')) + params = node.get('params') or {} + node_id = str(node.get('node_id')) + inputs = self._input_ids(node) + name = _safe_label(op, node_id) + if op == 'make_line_redge': + curve = adsk.core.Line3D.create(_pt(params.get('start')), _pt(params.get('end'))) + return self._set_output(node, curve) + if op == 'make_circle_redge': + center = _pt(params.get('center')) + normal = _vec(params.get('normal') or (0.0, 0.0, 1.0)) + curve = adsk.core.Circle3D.createByCenter(center, normal, float(params.get('radius', 0.0)) * SCALE) + return self._set_output(node, curve) + if op == 'make_angle_arc_redge': + center = params.get('center') or (0.0, 0.0, 0.0) + radius = float(params.get('radius', 0.0)) + normal = params.get('normal') or (0, 0, 1) + start_angle = float(params.get('start_angle', 0.0)) + end_angle = float(params.get('end_angle', 0.0)) + x_axis = params.get('_kernel_x_axis') + y_axis = params.get('_kernel_y_axis') + if abs(abs(end_angle - start_angle) - 2.0 * math.pi) <= 1.0e-9: + curve = adsk.core.Circle3D.createByCenter( + _pt(center), _vec(normal), radius * SCALE + ) + else: + start = _arc_endpoint( + center, radius, start_angle, normal, x_axis, y_axis + ) + middle = _arc_midpoint( + center, radius, start_angle, end_angle, normal, x_axis, y_axis + ) + end = _arc_endpoint( + center, radius, end_angle, normal, x_axis, y_axis + ) + curve = adsk.core.Arc3D.createByThreePoints( + _pt(start), _pt(middle), _pt(end) + ) + if curve is None: + raise RuntimeError('Fusion could not create angle arc geometry') + return self._set_output(node, curve) + if op == 'make_three_point_arc_redge': + curve = adsk.core.Arc3D.createByThreePoints(_pt(params.get('start')), _pt(params.get('middle')), _pt(params.get('end'))) + return self._set_output(node, curve) + if op == 'make_spline_redge': + points = params.get('control_points') or params.get('poles') or params.get('points') or [] + if len(points) < 2: + raise RuntimeError('make_spline_redge requires at least two control points') + degree = int(params.get('degree') or min(3, len(points) - 1)) + knots = params.get('fusion_knots') or params.get('knots') + multiplicities = params.get('multiplicities') + if ( + not params.get('fusion_knots') + and isinstance(knots, list) + and isinstance(multiplicities, list) + and len(knots) == len(multiplicities) + ): + knots = [ + float(knot) + for knot, multiplicity in zip(knots, multiplicities) + for _ in range(int(multiplicity)) + ] + if not isinstance(knots, list) or len(knots) < degree + len(points) + 1: + internal_count = max(0, len(points) - degree - 1) + internal = [(i + 1) / (internal_count + 1) for i in range(internal_count)] + knots = [0.0] * (degree + 1) + internal + [1.0] * (degree + 1) + control_points = [_pt(p) for p in points] + weights = params.get('weights') + if isinstance(weights, list) and len(weights) == len(points): + curve = adsk.core.NurbsCurve3D.createRational(control_points, degree, [float(k) for k in knots], [float(w) for w in weights], bool(params.get('periodic', False))) + else: + curve = adsk.core.NurbsCurve3D.createNonRational(control_points, degree, [float(k) for k in knots], bool(params.get('periodic', False))) + return self._set_output(node, curve) + if op == 'make_helix_redge': + axis = _unit(params.get('dir') or params.get('axis') or (0.0, 0.0, 1.0)) + center = _v3(params.get('center') or (0.0, 0.0, 0.0)) + radius = float(params.get('radius', 1.0)) + height = float(params.get('height', 1.0)) + pitch = float(params.get('pitch', height)) + radial = params.get('_kernel_x_axis') + if radial is None: + radial, _unused = _arc_basis(axis) + start = _add(center, _mul(_unit(radial), radius)) + turns = height / pitch if abs(pitch) > 1e-12 else 1.0 + wire = self.tmp.createHelixWire(_pt(center), _vec(axis), _pt(start), pitch * SCALE, turns, 0.0) + return self._set_output(node, wire) + if op == 'make_wire_from_edges_rwire': + curves = [] + for input_id in inputs: + item = self._first_output(input_id) + if hasattr(item, 'edges'): + for edge in item.edges: + curves.append(edge.geometry) + elif ( + str(input_id) in self.seam_split_circle_node_ids + and str((self.node_by_id.get(str(input_id)) or {}).get('op')) + == 'make_circle_redge' + ): + curves.extend( + self._split_circle_curve( + (self.node_by_id.get(str(input_id)) or {}).get('params') or {} + ) + ) + else: + curves.append(item) + wire, _edges = self.tmp.createWireFromCurves(curves, False) + if wire is None: + raise RuntimeError('createWireFromCurves failed') + return self._set_output(node, wire) + if op == 'make_face_from_wire_rface': + wire = self._first_output(inputs[0]) + face = self.tmp.createFaceFromPlanarWires([wire]) + if face is None: + raise RuntimeError('createFaceFromPlanarWires failed') + return self._set_output(node, face) + if op == 'make_face_from_wires_rface': + wires = [self._first_output(input_id) for input_id in inputs] + face = self.tmp.createFaceFromPlanarWires(wires) + if face is None: + raise RuntimeError('createFaceFromPlanarWires failed for multi-loop face') + return self._set_output(node, face) + if op in {'make_wire_from_sketch_rwire', 'make_face_from_sketch_rface'}: + raise SimpleCADUnsupportedOpError(f'{op} is not yet supported by the Fusion translator') + if op == 'make_extrude_rsolid': + profile = self._first_output(inputs[0]) + direction = _unit(params.get('direction') or (0.0, 0.0, 1.0)) + distance = float(params.get('distance', 0.0)) + vector = _mul(direction, distance) + body = self._extrude(profile, vector, node) + return self._set_output(node, body) + if op == 'make_revolve_rsolid': + profile = self._first_output(inputs[0]) + body = self._revolve_by_sampling(profile, params, node_id) + return self._set_output(node, body) + if op == 'make_loft_rsolid': + sections = [self._first_output(input_id) for input_id in inputs] + body = self._loft_by_features(sections, bool(params.get('ruled', False)), node_id) + return self._set_output(node, body) + if op == 'make_sweep_rsolid': + profile = self._first_output(inputs[0]) + path = self._first_output(inputs[1]) + body = self._sweep_by_features(profile, path, params, node_id) + return self._set_output(node, body) + if op == 'make_cut_rsolid': + source_body = self._source_kernel_step_body(node_id) + if source_body is not None: + self.logs.append(f'cut source-kernel STEP used for {node_id}') + native_result = self._native_boolean_feature( + node, adsk.fusion.BooleanTypes.DifferenceBooleanType, source_body + ) + return self._set_output( + node, + self._native_output_values(node_id, source_body) + if native_result is not None + else source_body, + ) + body = self._body_copy(self._first_output(inputs[0])) + for input_id in inputs[1:]: + tool = self._first_output(input_id) + ok = self.tmp.booleanOperation(body, self._body_copy(tool), adsk.fusion.BooleanTypes.DifferenceBooleanType) + if not ok: + raise RuntimeError(f'Fusion cut boolean failed for {node_id} tool {input_id}') + native_result = self._native_boolean_feature( + node, adsk.fusion.BooleanTypes.DifferenceBooleanType, body + ) + if native_result is None: + self.logs.append( + f'Fusion cut feature unavailable for {node_id}; ' + 'preserving exact static TemporaryBRep result' + ) + return self._set_output(node, body) + if op == 'make_union_rsolid': + source_body = self._source_kernel_step_body(node_id) + if source_body is not None: + self.logs.append(f'union source-kernel STEP used for {node_id}') + native_result = self._native_boolean_feature( + node, adsk.fusion.BooleanTypes.UnionBooleanType, source_body + ) + return self._set_output( + node, + self._native_output_values(node_id, source_body) + if native_result is not None + else source_body, + ) + body = self._body_copy(self._first_output(inputs[0])) + for input_id in inputs[1:]: + tool = self._first_output(input_id) + ok = self.tmp.booleanOperation(body, self._body_copy(tool), adsk.fusion.BooleanTypes.UnionBooleanType) + if not ok: + raise RuntimeError(f'Fusion union boolean failed for {node_id} tool {input_id}') + native_result = self._native_boolean_feature( + node, adsk.fusion.BooleanTypes.UnionBooleanType, body + ) + if native_result is None: + self.logs.append( + f'Fusion union feature unavailable for {node_id}; ' + 'preserving exact static TemporaryBRep result' + ) + return self._set_output(node, body) + if op == 'make_intersect_rsolid': + source_body = self._source_kernel_step_body(node_id) + if source_body is not None: + self.logs.append(f'intersection source-kernel STEP used for {node_id}') + native_result = self._native_boolean_feature( + node, adsk.fusion.BooleanTypes.IntersectionBooleanType, source_body + ) + return self._set_output( + node, + self._native_output_values(node_id, source_body) + if native_result is not None + else source_body, + ) + body = self._body_copy(self._first_output(inputs[0])) + for input_id in inputs[1:]: + tool = self._first_output(input_id) + ok = self.tmp.booleanOperation(body, self._body_copy(tool), adsk.fusion.BooleanTypes.IntersectionBooleanType) + if not ok: + raise RuntimeError(f'Fusion intersection boolean failed for {node_id} tool {input_id}') + native_result = self._native_boolean_feature( + node, adsk.fusion.BooleanTypes.IntersectionBooleanType, body + ) + if native_result is None: + self.logs.append( + f'Fusion intersection feature unavailable for {node_id}; ' + 'preserving exact static TemporaryBRep result' + ) + return self._set_output(node, body) + if op == 'make_select_redge': + self.selection_payloads[node_id] = {'kind': 'edge', 'params': params, 'input': inputs[0] if inputs else None} + return self._set_output(node, self.selection_payloads[node_id]) + if op == 'make_select_rface': + self.selection_payloads[node_id] = {'kind': 'face', 'params': params, 'input': inputs[0] if inputs else None} + return self._set_output(node, self.selection_payloads[node_id]) + if op == 'make_fillet_rsolid': + body = self._feature_detail_edges(node, params, inputs, 'fillet') + return self._set_output(node, body) + if op == 'make_chamfer_rsolid': + body = self._feature_detail_edges(node, params, inputs, 'chamfer') + return self._set_output(node, body) + if op == 'make_shell_rsolid': + body = self._feature_shell(node, params, inputs) + return self._set_output(node, body) + if op == 'make_translate_rshape': + body = self._body_copy(self._first_output(inputs[0])) + matrix = _matrix_translate(params.get('vector') or (0.0, 0.0, 0.0)) + self.tmp.transform(body, matrix) + self._native_move_feature(node, matrix, body) + return self._set_output(node, body) + if op == 'make_rotate_rshape': + body = self._body_copy(self._first_output(inputs[0])) + matrix = _matrix_rotate( + params.get('origin') or (0.0, 0.0, 0.0), + params.get('axis') or (0.0, 0.0, 1.0), + params.get('angle', 0.0), + ) + self.tmp.transform(body, matrix) + self._native_move_feature(node, matrix, body) + return self._set_output(node, body) + if op == 'make_mirror_rshape': + body = self._body_copy(self._first_output(inputs[0])) + self.tmp.transform(body, _matrix_mirror(params.get('plane_origin') or (0, 0, 0), params.get('plane_normal') or (0, 0, 1))) + return self._set_output(node, body) + if op == 'make_material_rmaterial': + return self._set_output(node, {'kind': 'material', 'params': params}) + if op in {'make_placement_rplacement', 'make_identity_placement_rplacement'}: + return self._set_output(node, {'kind': 'placement', 'params': params}) + if op == 'make_part_rpart': + value = {'kind': 'part', 'params': params, 'body_node': inputs[0], 'body': self._first_output(inputs[0])} + self.product_values[node_id] = value + return self._set_output(node, value) + if op == 'make_assign_material_rpart': + value = dict(self._first_output(inputs[0])) + value['material'] = self._first_output(inputs[1]) + self.product_values[node_id] = value + return self._set_output(node, value) + if op == 'make_assembly_rassembly': + value = {'kind': 'assembly', 'params': params, 'components': []} + self.product_values[node_id] = value + return self._set_output(node, value) + if op == 'make_add_component_rassembly': + assembly = dict(self._first_output(inputs[0])) + components = list(assembly.get('components') or []) + components.append({ + 'item': self._first_output(inputs[1]), + 'placement': self._first_output(inputs[2]) if len(inputs) > 2 else {'kind': 'placement', 'params': {}}, + 'component_id': str(params.get('component_id') or ''), + 'node_id': node_id, + 'params': params, + }) + assembly['components'] = components + self.product_values[node_id] = assembly + return self._set_output(node, assembly) + if op == 'make_place_component_rassembly': + assembly = dict(self._first_output(inputs[0])) + placement = self._first_output(inputs[1]) if len(inputs) > 1 else {} + component_id = str(params.get('component_id') or '') + components = [] + for component in assembly.get('components') or []: + component = dict(component) + if str(component.get('component_id') or '') == component_id: + component['placement'] = placement + components.append(component) + assembly['components'] = components + self.product_values[node_id] = assembly + return self._set_output(node, assembly) + if op == 'make_solve_assembly_constraints_rassembly': + assembly = dict(self._first_output(inputs[0])) + placements = dict(params.get('component_placements') or {}) + components = [] + for component in assembly.get('components') or []: + component = dict(component) + component_id = str(component.get('component_id') or '') + if component_id in placements: + component['placement'] = placements[component_id] + components.append(component) + assembly['components'] = components + self.product_values[node_id] = assembly + return self._set_output(node, assembly) + if op == 'make_compound_from_assembly_rcompound': + assembly = self._first_output(inputs[0]) + if not isinstance(assembly, dict) or not assembly.get('components'): + raise RuntimeError('assembly compound has no bodies') + state = dict(assembly) + state['kind'] = 'state' + state['state_name'] = str(params.get('assembly_id') or node_id) + state['result_node_id'] = node_id + return self._set_output(node, state) + if op.startswith('make_') and op.endswith('_rconnector'): + return self._set_output(node, {'kind': 'connector', 'params': params}) + if op in { + 'make_add_connector_rpart', 'make_add_connector_rassembly', + 'make_connector_ref_rconnectorref', 'make_scalar_limit_rscalarlimit', + 'make_ground_component_rassembly', 'make_unground_component_rassembly', + 'make_fixed_constraint_rassembly', 'make_revolute_constraint_rassembly', + 'make_prismatic_constraint_rassembly', + }: + value = self._first_output(inputs[0]) if inputs else {'kind': op, 'params': params} + return self._set_output(node, value) + raise SimpleCADUnsupportedOpError(f'Unsupported SimpleCAD op for Fusion 360 translation: {op}') + + def _extrude(self, profile, vector, node): + distance = _norm(vector) + if distance <= 1.0e-12: + raise RuntimeError('Extrude distance is zero') + face_body = self._planar_profile_body(profile, 'Extrude') + persistent_face_body = self._add_base_body(face_body) + face = _first_entity(persistent_face_body.faces, 'profile face') + features = self.root.features.extrudeFeatures + face_normal = _face_normal_tuple(face) + feature = None + if face_normal is not None: + target = _unit(vector) + if abs(_dot(face_normal, target)) < 1.0 - 1.0e-9: + start = face.pointOnFace + end = adsk.core.Point3D.create( + start.x + float(vector[0]) * SCALE, + start.y + float(vector[1]) * SCALE, + start.z + float(vector[2]) * SCALE, + ) + line = adsk.core.Line3D.create(start, end) + path_shape, _path_edges = self.tmp.createWireFromCurves( + [line], False + ) + path_body = self._add_base_body(path_shape) + path_edge = _first_entity(path_body.edges, 'oblique extrusion path') + path = adsk.fusion.Path.create( + path_edge, + adsk.fusion.ChainedCurveOptions.openEdgesChainedCurves, + ) + sweep_input = self.root.features.sweepFeatures.createInput( + face, + path, + adsk.fusion.FeatureOperations.NewBodyFeatureOperation, + ) + sweep_input.orientation = ( + adsk.fusion.SweepOrientationTypes.ParallelOrientationType + ) + feature = self.root.features.sweepFeatures.add(sweep_input) + return self._copy_first_feature_body_and_cleanup( + feature, + 'oblique extrusion result body', + [persistent_face_body, path_body], + node, + ) + direction = adsk.fusion.ExtentDirections.PositiveExtentDirection + if _dot(face_normal, target) < 0.0: + direction = adsk.fusion.ExtentDirections.NegativeExtentDirection + try: + input_obj = features.createInput(face, adsk.fusion.FeatureOperations.NewBodyFeatureOperation) + extent = adsk.fusion.DistanceExtentDefinition.create(_value_cm(distance)) + input_obj.setOneSideExtent(extent, direction) + feature = features.add(input_obj) + except Exception as exc: + self.logs.append(f'extrude directional API fallback: {exc}') + if feature is None: + feature = features.addSimple(face, _value_cm(distance), adsk.fusion.FeatureOperations.NewBodyFeatureOperation) + return self._copy_first_feature_body_and_cleanup( + feature, 'extrude result body', [persistent_face_body], node + ) + + def _revolve_by_sampling(self, profile, params, node_id): + source_body = self._source_kernel_step_body(node_id) + # Fusion temporary BRep has no direct revolve primitive. Use Fusion's feature + # API so the kernel owns a live, editable result. The source-kernel body + # remains an accuracy oracle and is used only when native construction + # fails or produces materially different geometry. + persistent_face_body = None + axis_persistent = None + feature = None + try: + face_body = self._planar_profile_body(profile, 'Revolve') + persistent_face_body = self._add_base_body(face_body) + face = _first_entity(persistent_face_body.faces, 'revolve profile face') + origin = params.get('origin') or (0.0, 0.0, 0.0) + axis = _unit(params.get('axis') or (0.0, 0.0, 1.0)) + line = adsk.core.Line3D.create(_pt(origin), _pt(_add(origin, axis))) + axis_body, _edges = self.tmp.createWireFromCurves([line], False) + axis_persistent = self._add_base_body(axis_body) + axis_edge = _first_entity(axis_persistent.edges, 'revolve axis edge') + input_obj = self.root.features.revolveFeatures.createInput( + face, + axis_edge, + adsk.fusion.FeatureOperations.NewBodyFeatureOperation, + ) + input_obj.setAngleExtent( + False, _value_deg(params.get('angle', 360.0)) + ) + feature = self.root.features.revolveFeatures.add(input_obj) + native_body = self._feature_result_body( + feature, 'revolve result body', source_body + ) + if source_body is not None and not self._native_body_close( + native_body, source_body + ): + self.logs.append( + f'native revolve geometry mismatch for {node_id}; ' + 'using source-kernel STEP fallback' + ) + self._delete_entity(feature) + feature = None + return source_body + node = self.node_by_id.get(str(node_id)) or {} + self._register_native_feature(node, feature, native_body) + result = self.tmp.copy(native_body) + if source_body is not None: + self._delete_entity(source_body) + self.logs.append(f'native revolve feature used for {node_id}') + return result + except Exception as exc: + if feature is not None: + self._delete_entity(feature) + if source_body is None: + raise + self.logs.append( + f'native revolve feature unavailable for {node_id}: {exc}; ' + 'using source-kernel STEP fallback' + ) + return source_body + finally: + for old in (persistent_face_body, axis_persistent): + if old is None: + continue + try: + old.isVisible = False + except Exception: + pass + + def _loft_by_features(self, sections, ruled, node_id): + source_body = self._source_kernel_step_body(node_id) + if source_body is not None: + self.logs.append(f'loft source-kernel STEP used for {node_id}') + return source_body + input_obj = self.root.features.loftFeatures.createInput(adsk.fusion.FeatureOperations.NewBodyFeatureOperation) + persisted = [] + for section in sections: + face_section = self._planar_profile_body(section, 'Loft section') + body = self._add_base_body(face_section) + persisted.append(body) + input_obj.loftSections.add(_first_entity(body.faces, 'loft section face')) + try: + input_obj.isSolid = True + input_obj.isClosed = False + input_obj.isTangentEdgesMerged = False + if hasattr(input_obj, 'isRuled'): + input_obj.isRuled = bool(ruled) + except Exception: + pass + feature = self.root.features.loftFeatures.add(input_obj) + return self._copy_first_feature_body_and_cleanup( + feature, + 'loft result body', + persisted, + self.node_by_id.get(str(node_id)), + ) + + def _sweep_by_features(self, profile, path, params, node_id): + node = self.node_by_id.get(str(node_id)) or {} + input_ids = self._input_ids(node) + path_node = self.node_by_id.get(input_ids[1]) if len(input_ids) == 2 else None + path_input_ids = self._input_ids(path_node) if path_node else [] + is_direct_helix = ( + path_node is not None + and str(path_node.get('op')) == 'make_wire_from_edges_rwire' + and len(path_input_ids) == 1 + and str((self.node_by_id.get(path_input_ids[0]) or {}).get('op')) == 'make_helix_redge' + ) + # The canonical kernel has already resolved the profile/path frame and + # GSM semantics. Fusion's native sweep can silently choose a + # different section orientation while still returning a valid body, + # so prefer the exact source result whenever it is available. + source_body = self._source_kernel_step_body(node_id) + if source_body is not None: + label = 'direct helix' if is_direct_helix else 'sweep' + self.logs.append(f'{label} source-kernel STEP used for {node_id}') + return source_body + + face_body = self._planar_profile_body(profile, 'Sweep') + profile_body = self._add_base_body(face_body) + path_body = self._add_base_body(path) + sweep_features = self.root.features.sweepFeatures + path_seed = _first_entity(path_body.edges, 'sweep path edge') + path_obj = adsk.fusion.Path.create(path_seed, adsk.fusion.ChainedCurveOptions.openEdgesChainedCurves) + if path_obj is None or path_obj.count <= 0: + raise RuntimeError('Fusion sweep path could not be constructed from the persistent path edge') + profile_face = _first_entity(profile_body.faces, 'sweep profile face') + + def create_input(orientation): + result = sweep_features.createInput( + profile_face, + path_obj, + adsk.fusion.FeatureOperations.NewBodyFeatureOperation, + ) + result.orientation = orientation + return result + + is_frenet = bool(params.get('is_frenet', False)) + preferred_orientation = ( + adsk.fusion.SweepOrientationTypes.PerpendicularOrientationType + if is_frenet + else adsk.fusion.SweepOrientationTypes.ParallelOrientationType + ) + alternate_orientation = ( + adsk.fusion.SweepOrientationTypes.ParallelOrientationType + if is_frenet + else adsk.fusion.SweepOrientationTypes.PerpendicularOrientationType + ) + try: + feature = sweep_features.add(create_input(preferred_orientation)) + except Exception as exc: + self.logs.append(f'sweep alternate orientation fallback for {node_id}: {exc}') + try: + feature = sweep_features.add(create_input(alternate_orientation)) + except Exception as alternate_exc: + source_body = self._source_kernel_step_body(node_id) + if source_body is None: + raise alternate_exc + self.logs.append(f'sweep source-kernel STEP fallback used for {node_id}: {alternate_exc}') + self._delete_entity(profile_body) + self._delete_entity(path_body) + return source_body + return self._copy_first_feature_body_and_cleanup( + feature, + 'sweep result body', + [profile_body, path_body], + self.node_by_id.get(str(node_id)), + ) + + def _feature_detail_edges(self, node, params, inputs, kind): + node_id = str(node.get('node_id')) + canonical_input = self._first_output(inputs[0]) + exact_input = self._source_kernel_step_body(inputs[0]) + exact_input_set = list(self.source_kernel_body_sets.get(str(inputs[0])) or []) + if exact_input is not None: + canonical_input = self._body_copy(exact_input) + self._delete_entity(exact_input) + # Detail features are built on an exact persisted copy of the + # canonical temporary result. This keeps their editable radius and + # edge references without letting an approximate native Boolean chain + # contaminate the geometry used by later graph nodes. + topology_source_shapes = [ + body for body in exact_input_set + if body is not None and bool(getattr(body, 'isValid', False)) + ] + if not topology_source_shapes or not any( + self._native_body_close(body, canonical_input) + for body in topology_source_shapes + ): + topology_source_shapes.insert(0, canonical_input) + topology_sources = [] + for index, source_shape in enumerate(topology_source_shapes): + topology_source = self._add_base_body(self._body_copy(source_shape)) + suffix = '' if len(topology_source_shapes) == 1 else f'_{index}' + _apply_name( + topology_source, + f'SimpleCAD_topology_source_{node_id}{suffix}', + ) + _set_simplecad_attribute(topology_source, 'TopologySourceFor', node_id) + try: + topology_source.isVisible = False + except Exception: + pass + topology_sources.append(topology_source) + native_sources = list(self.native_body_sets.get(str(inputs[0])) or []) + if not native_sources: + native_body = self.native_bodies.get(str(inputs[0])) + if native_body is not None: + native_sources = [native_body] + native_chain_matches = False + if exact_input_set: + native_chain_matches = self._native_body_sets_close( + native_sources, exact_input_set + ) + elif native_sources: + native_chain_matches = ( + ( + len(native_sources) > 1 + and self._native_body_set_close(native_sources, canonical_input) + ) + or ( + len(native_sources) == 1 + and self._native_body_close(native_sources[0], canonical_input) + ) + ) + if native_sources and native_chain_matches: + source_bodies = native_sources + source_is_fallback = False + self.logs.append( + f'{kind} native input chain preserved for {node_id}: ' + f'body_count={len(source_bodies)}' + ) + else: + source_bodies = [ + self._add_base_body(self._body_copy(body)) + for body in (exact_input_set or [canonical_input]) + ] + source_is_fallback = True + _apply_name(source_bodies[0], f'SimpleCAD_base_{inputs[0]}_{node_id}') + _attach_simplecad_native_metadata( + source_bodies[0], str(inputs[0]), 'static_input_fallback', {}, [] + ) + selectors = [] + for selector_id in params.get('selected_edge_node_ids') or []: + payload = self.selection_payloads.get(str(selector_id)) + if payload: + selectors.append(payload.get('params') or {}) + if not selectors: + for item in params.get('selected_edges') or []: + if isinstance(item, dict): + selectors.append(item.get('selector_hint') or item) + if not selectors and SELECTION_MODE == 'index': + selectors.extend( + { + 'kind': 'edge', + 'metadata_geo': {'edge_index': int(index)}, + } + for index in params.get('selected_edge_indices') or [] + ) + for topology_source in topology_sources: + _set_simplecad_attribute( + topology_source, + 'TopologySelectors', + json.dumps(selectors, sort_keys=True), + ) + _set_simplecad_attribute( + topology_source, + 'TopologySelectionNodeIds', + json.dumps(params.get('selected_edge_node_ids') or []), + ) + + def attach_topology_metadata(feature): + _set_simplecad_chunked_attribute( + feature, + 'TopologySelectors', + json.dumps(selectors, sort_keys=True), + ) + _set_simplecad_chunked_attribute( + feature, + 'TopologySelectionNodeIds', + json.dumps(params.get('selected_edge_node_ids') or []), + ) + _set_simplecad_attribute( + feature, 'TopologyEdgeSetLayout', 'flat_explicit_v1' + ) + return feature + + def create_feature(feature_sources): + selected_groups = [] + for selector in selectors: + candidate_edges = [ + edge + for feature_source in feature_sources + for edge in feature_source.edges + ] + selector_geometry = _selector_geometry(selector) + try: + edge = _best_by_geometry( + candidate_edges, selector, _edge_signature, 'edge' + ) + selected_groups.append([edge]) + continue + except RuntimeError as direct_match_error: + direct_error_text = str(direct_match_error) + if _selector_geom_type(selector_geometry) not in {'LINE', 'CIRCLE'}: + raise + if _selector_geom_type(selector_geometry) == 'LINE': + expected_start = _tuple3_or_none(selector_geometry.get('start')) + expected_end = _tuple3_or_none(selector_geometry.get('end')) + expected_length = float(selector_geometry.get('length') or 0.0) + expected_bbox = selector_geometry.get('bbox') or {} + expected_min = _tuple3_or_none(expected_bbox.get('min')) + expected_max = _tuple3_or_none(expected_bbox.get('max')) + if ( + expected_start is None + or expected_end is None + or expected_min is None + or expected_max is None + or expected_length <= 0.0 + ): + raise + direction = _sub(expected_end, expected_start) + direction_length = _norm(direction) + if direction_length <= 1.0e-12: + raise + direction = _mul(direction, 1.0 / direction_length) + matches = [] + group_debug = [] + for feature_source in feature_sources: + source_matches = [] + for candidate in feature_source.edges: + signature = _edge_signature(candidate) + # Fusion may expose an exact straight seam as a + # degree-one/linear BSPLINE after a native feature + # or STEP-backed Boolean. Treat it as a line only + # after the sampled points pass the collinearity + # test below; curved BSPLINEs remain excluded. + if _canonical_geom_type(signature.get('geom_type')) not in { + 'LINE', 'BSPLINE' + }: + continue + points = list(_curve_points_on_edge(candidate) or ()) + if len(points) < 2: + continue + scale = max(1.0, expected_length) + if any( + _norm(_cross(_sub(point, expected_start), direction)) + / direction_length + > scale * 1.0e-5 + for point in points + ): + continue + bbox = signature.get('bbox') or {} + bbox_min = _tuple3_or_none(bbox.get('min')) + bbox_max = _tuple3_or_none(bbox.get('max')) + if bbox_min is None or bbox_max is None: + continue + if any( + bbox_min[index] < expected_min[index] - scale * 1.0e-5 + or bbox_max[index] > expected_max[index] + scale * 1.0e-5 + for index in range(3) + ): + continue + source_matches.append((candidate, signature)) + group_debug.append( + [ + ( + signature.get('start'), + signature.get('end'), + signature.get('length'), + ) + for _candidate, signature in source_matches + ] + ) + matches.extend(source_matches) + if len(matches) < 2: + raise RuntimeError( + f'{kind} linear seam selector did not identify a unique split-edge set; ' + f'direct_error={direct_error_text}; candidates={group_debug!r}' + ) + intervals = [] + for _candidate, signature in matches: + points = [signature.get('start'), signature.get('end')] + projections = [ + _dot(_sub(point, expected_start), direction) + for point in points + ] + intervals.append((min(projections), max(projections))) + intervals.sort() + if ( + intervals[0][0] > scale * 1.0e-5 + or intervals[-1][1] < expected_length - scale * 1.0e-5 + or any( + right < left - scale * 1.0e-5 + for (_left, right), (left, _right) in zip( + intervals, intervals[1:] + ) + ) + ): + raise RuntimeError( + f'{kind} linear seam selector did not identify a unique split-edge set; ' + f'direct_error={direct_error_text}; candidates={group_debug!r}' + ) + total_length = sum( + float(signature.get('length') or 0.0) + for _candidate, signature in matches + ) + if abs(total_length - expected_length) > scale * 1.0e-5: + raise RuntimeError( + f'{kind} linear seam selector did not identify a unique split-edge set; ' + f'direct_error={direct_error_text}; candidates={group_debug!r}' + ) + selected_groups.append( + [candidate for candidate, _signature in matches] + ) + continue + expected_center = _tuple3_or_none(selector_geometry.get('center')) + expected_start = _tuple3_or_none(selector_geometry.get('start')) + expected_end = _tuple3_or_none(selector_geometry.get('end')) + expected_length = float(selector_geometry.get('length') or 0.0) + expected_bbox = selector_geometry.get('bbox') or {} + expected_min = _tuple3_or_none(expected_bbox.get('min')) + expected_max = _tuple3_or_none(expected_bbox.get('max')) + expected_radius = expected_length / (2.0 * math.pi) + if ( + expected_center is None + or expected_start is None + or expected_end is None + or expected_min is None + or expected_max is None + or expected_radius <= 0.0 + ): + raise + path_scale = max(1.0, expected_length) + endpoint_tolerance = path_scale * 1.0e-4 + if _distance(expected_start, expected_end) > endpoint_tolerance: + paths = [] + for feature_source in feature_sources: + path_candidates = [] + for candidate in feature_source.edges: + signature = _edge_signature(candidate) + if _canonical_geom_type(signature.get('geom_type')) not in { + 'BSPLINE', 'CIRCLE' + }: + continue + start = _tuple3_or_none(signature.get('start')) + end = _tuple3_or_none(signature.get('end')) + bbox = signature.get('bbox') or {} + bbox_min = _tuple3_or_none(bbox.get('min')) + bbox_max = _tuple3_or_none(bbox.get('max')) + if any( + value is None + for value in (start, end, bbox_min, bbox_max) + ): + continue + if any( + bbox_min[index] < expected_min[index] - endpoint_tolerance + or bbox_max[index] > expected_max[index] + endpoint_tolerance + for index in range(3) + ): + continue + path_candidates.append((candidate, signature, start, end)) + + def walk(point, used, path, total_length): + if len(path) > 1 and _distance(point, expected_end) <= endpoint_tolerance: + if abs(total_length - expected_length) <= path_scale * 1.0e-4: + boxes = [item[1]['bbox'] for item in path] + union_min = tuple( + min(box['min'][axis] for box in boxes) + for axis in range(3) + ) + union_max = tuple( + max(box['max'][axis] for box in boxes) + for axis in range(3) + ) + if max( + max( + abs(union_min[axis] - expected_min[axis]), + abs(union_max[axis] - expected_max[axis]), + ) + for axis in range(3) + ) <= endpoint_tolerance: + paths.append([item[0] for item in path]) + return + if len(path) >= 8 or total_length >= expected_length + endpoint_tolerance: + return + for index, item in enumerate(path_candidates): + if index in used: + continue + _candidate, signature, start, end = item + next_point = None + if _distance(point, start) <= endpoint_tolerance: + next_point = end + elif _distance(point, end) <= endpoint_tolerance: + next_point = start + if next_point is None: + continue + walk( + next_point, + used | {index}, + path + [item], + total_length + float(signature.get('length') or 0.0), + ) + + walk(expected_start, set(), [], 0.0) + unique_paths = { + tuple(sorted(str(edge.entityToken) for edge in path)): path + for path in paths + } + if len(unique_paths) == 1: + selected_groups.append(next(iter(unique_paths.values()))) + continue + if unique_paths: + raise RuntimeError( + f'{kind} curved fragment selector is ambiguous; ' + f'path_count={len(unique_paths)}; direct_error={direct_error_text}' + ) + groups = [] + for feature_source in feature_sources: + matches = [] + for candidate in feature_source.edges: + signature = _edge_signature(candidate) + if _canonical_geom_type(signature.get('geom_type')) not in { + 'BSPLINE', 'CIRCLE' + }: + continue + points = list(_curve_points_on_edge(candidate) or ()) + if any(point is None for point in points): + continue + if any( + abs(_distance(point, expected_center) - expected_radius) + > max(1.0, expected_radius) * 1.0e-4 + for point in points + ): + continue + bbox = signature.get('bbox') or {} + bbox_min = _tuple3_or_none(bbox.get('min')) + bbox_max = _tuple3_or_none(bbox.get('max')) + if bbox_min is None or bbox_max is None: + continue + scale = max(1.0, expected_radius) + if any( + bbox_min[index] < expected_min[index] - scale * 1.0e-4 + or bbox_max[index] > expected_max[index] + scale * 1.0e-4 + for index in range(3) + ): + continue + matches.append((candidate, signature)) + if len(matches) < 2: + continue + total_length = sum( + float(signature.get('length') or 0.0) + for _candidate, signature in matches + ) + if abs(total_length - expected_length) > max( + 1.0, expected_length + ) * 1.0e-4: + continue + union_min = tuple( + min(signature['bbox']['min'][index] for _candidate, signature in matches) + for index in range(3) + ) + union_max = tuple( + max(signature['bbox']['max'][index] for _candidate, signature in matches) + for index in range(3) + ) + if max( + max(abs(union_min[index] - expected_min[index]), abs(union_max[index] - expected_max[index])) + for index in range(3) + ) <= max(1.0, expected_radius) * 1.0e-4: + groups.append([candidate for candidate, _signature in matches]) + if len(groups) != 1: + raise RuntimeError( + f'{kind} circular selector did not identify a unique split-edge set; ' + f'direct_error={direct_error_text}; group_count={len(groups)}' + ) + selected_groups.append(groups[0]) + if not selected_groups: + raise RuntimeError(f'{kind} requires at least one geometrically selected edge') + selected = [edge for group in selected_groups for edge in group] + if kind == 'fillet': + input_obj = self.root.features.filletFeatures.createInput() + input_obj.addConstantRadiusEdgeSet( + _object_collection(selected), + _value_cm(params.get('radius', 0.0)), + False, + ) + return self.root.features.filletFeatures.add(input_obj) + else: + input_obj = self.root.features.chamferFeatures.createInput2() + input_obj.chamferEdgeSets.addEqualDistanceChamferEdgeSet( + _object_collection(selected), + _value_cm(params.get('distance', params.get('radius', 0.0))), + False, + ) + return self.root.features.chamferFeatures.add(input_obj) + + def complete_feature_bodies(feature, feature_sources): + result_bodies = list(feature.bodies) + result_tokens = { + str(getattr(body, 'entityToken', '')) for body in result_bodies + } + for source_body in feature_sources: + try: + token = str(getattr(source_body, 'entityToken', '')) + if source_body.isValid and token not in result_tokens: + result_bodies.append(source_body) + result_tokens.add(token) + except Exception: + continue + return result_bodies + + def register_multi_body_feature(feature, feature_sources): + feature_bodies = complete_feature_bodies(feature, feature_sources) + if not self._native_body_sets_bbox_close(feature_bodies, feature_sources): + raise RuntimeError( + f'{kind} native body-set geometry mismatch for {node_id}' + ) + for index, result_body in enumerate(feature_bodies): + _apply_name(result_body, f'SimpleCAD_{node_id}_{index}') + _attach_simplecad_native_metadata( + result_body, node_id, str(node.get('op')), params, inputs + ) + _apply_name(feature, _safe_label(str(node.get('op')), node_id)) + _attach_simplecad_native_metadata( + feature, node_id, str(node.get('op')), params, inputs + ) + self.native_features[node_id] = feature + self.native_bodies[node_id] = feature_bodies[0] + self.native_body_sets[node_id] = list(feature_bodies) + return [self._body_copy(body) for body in feature_bodies] + + feature = None + first_error = None + try: + feature = create_feature(source_bodies) + attach_topology_metadata(feature) + if len(source_bodies) > 1: + return register_multi_body_feature(feature, source_bodies) + return self._copy_first_feature_body_and_cleanup( + feature, + f'{kind} result body', + source_bodies if source_is_fallback else [], + node, + expected_body=source_bodies[0], + ) + except Exception as exc: + first_error = exc + if feature is not None: + self._delete_entity(feature) + if source_is_fallback: + for source_body in source_bodies: + self._delete_entity(source_body) + else: + fallback_shapes = exact_input_set or [canonical_input] + fallback_sources = [ + self._add_base_body(self._body_copy(body)) + for body in fallback_shapes + ] + for index, fallback_source in enumerate(fallback_sources): + _apply_name( + fallback_source, + f'SimpleCAD_base_{inputs[0]}_{node_id}_{index}', + ) + _attach_simplecad_native_metadata( + fallback_source, + str(inputs[0]), + 'static_input_fallback', + {}, + [], + ) + feature = None + try: + feature = create_feature(fallback_sources) + attach_topology_metadata(feature) + self.logs.append( + f'{kind} canonical-input native retry used for {node_id}: ' + f'{first_error}' + ) + if len(fallback_sources) > 1: + return register_multi_body_feature(feature, fallback_sources) + return self._copy_first_feature_body_and_cleanup( + feature, + f'{kind} result body', + fallback_sources, + node, + expected_body=fallback_sources[0], + ) + except Exception as fallback_exc: + if feature is not None: + self._delete_entity(feature) + for fallback_source in fallback_sources: + self._delete_entity(fallback_source) + first_error = RuntimeError( + f'native source failed: {first_error}; ' + f'canonical input failed: {fallback_exc}' + ) + source_body = self._source_kernel_step_body(node_id) + if source_body is None: + raise first_error + self.logs.append( + f'{kind} source-kernel STEP fallback used for {node_id}: ' + f'{first_error}' + ) + return source_body + + def _feature_shell(self, node, params, inputs): + node_id = str(node.get('node_id')) + source, source_is_fallback = self._native_source_body( + inputs[0], self._first_output(inputs[0]) + ) + try: + selectors = [] + for selector_id in params.get('selected_face_node_ids') or []: + payload = self.selection_payloads.get(str(selector_id)) + if payload: + selectors.append(payload.get('params') or {}) + for item in params.get('selected_faces') or []: + if isinstance(item, dict): + selectors.append(item.get('selector_hint') or item) + if not selectors and SELECTION_MODE == 'index': + selectors.extend( + { + 'kind': 'face', + 'metadata_geo': {'face_index': int(index)}, + } + for index in params.get('selected_face_indices') or [] + ) + faces = [] + for selector in selectors: + faces.append(_best_by_geometry(list(source.faces), selector, _face_signature, 'face')) + input_obj = self.root.features.shellFeatures.createInput(faces, False) + input_obj.insideThickness = _value_cm(params.get('thickness', 0.0)) + feature = self.root.features.shellFeatures.add(input_obj) + cleanup = [source] if source_is_fallback else [] + return self._copy_first_feature_body_and_cleanup( + feature, + 'shell result body', + cleanup, + node, + expected_body=source, + ) + except Exception as exc: + source_body = self._source_kernel_step_body(node_id) + if source_body is None: + raise + self.logs.append(f'shell source-kernel STEP fallback used for {node_id}: {exc}') + if source_is_fallback: + self._delete_entity(source) + return source_body + + def _bodies_from_product(self, value, placements=()): + if not isinstance(value, dict): + return [] + if value.get('kind') == 'part': + body = value.get('body') + if body is None: + return [] + body = self._body_copy(body) + for placement in reversed(tuple(placements)): + self.tmp.transform(body, _matrix_from_placement(placement)) + return [body] + if value.get('kind') in {'assembly', 'state'}: + bodies = [] + for component in value.get('components') or []: + component_placement = component.get('placement') or {} + bodies.extend( + self._bodies_from_product( + component.get('item'), + tuple(placements) + (component_placement,), + ) + ) + return [body for body in bodies if body is not None] + return [] + + def _materialize_product_value(self, value, node_id, placements=()): + if not isinstance(value, dict): + return 0 + if value.get('kind') == 'part': + body = value.get('body') + if body is None: + return 0 + body_node = str(value.get('body_node') or '') + native = self.native_bodies.get(body_node) + native_set = self.native_body_sets.get(body_node) or [] + can_use_native_set = ( + bool(native_set) + and all(_placement_is_identity(placement) for placement in placements) + and self._native_body_set_close(native_set, body) + ) + if can_use_native_set: + for index, native_part in enumerate(native_set): + _apply_name(native_part, f'SimpleCAD_{node_id}_{body_node}_{index}') + try: + native_part.isVisible = True + except Exception: + pass + return len(native_set) + can_use_native = ( + native is not None + and body_node not in self.materialized_native_node_ids + and all(_placement_is_identity(placement) for placement in placements) + and self._native_body_close(native, body) + ) + if can_use_native: + self.materialized_native_node_ids.add(body_node) + _apply_name(native, f'SimpleCAD_{node_id}_{body_node}') + try: + native.isVisible = True + except Exception: + pass + return 1 + persisted_body = self._body_copy(body) + for placement in reversed(tuple(placements)): + self.tmp.transform(persisted_body, _matrix_from_placement(placement)) + persisted = self._add_base_body(persisted_body) + _apply_name(persisted, f'SimpleCAD_{node_id}_{body_node or "static"}') + try: + persisted.isVisible = True + except Exception: + pass + return 1 + if value.get('kind') in {'assembly', 'state'}: + if not placements: + components = list(value.get('components') or []) + if components and all( + isinstance(component.get('item'), dict) + and component.get('item', {}).get('kind') == 'part' + for component in components + ): + return sum( + self._materialize_component_occurrence(component, node_id) + for component in components + ) + count = 0 + for component in value.get('components') or []: + component_placement = component.get('placement') or {} + count += self._materialize_product_value( + component.get('item'), + node_id, + tuple(placements) + (component_placement,), + ) + return count + return 0 + + def _materialize_component_occurrence(self, component_record, result_node_id): + item = component_record.get('item') or {} + body = item.get('body') + if body is None: + return 0 + component_id = str(component_record.get('component_id') or '') + component_node_id = str(component_record.get('node_id') or '') + body_node = str(item.get('body_node') or '') + params = component_record.get('params') or {} + placement = component_record.get('placement') or {} + placement_matrix = _matrix_from_placement(placement) + precreated_occurrence = self.precreated_component_occurrences.pop( + body_node, None + ) + if precreated_occurrence is not None: + occurrence = precreated_occurrence + child = occurrence.component + result_bodies = list(child.bRepBodies) + if ( + len(result_bodies) != 1 + or not self._native_body_close(result_bodies[0], body) + ): + raise RuntimeError( + f'precreated component geometry mismatch for {component_id}; ' + f'body_count={len(result_bodies)}' + ) + display_name = str(params.get('name') or component_id or component_node_id) + _apply_name(child, display_name) + _apply_name(occurrence, f'SimpleCAD_{component_id or component_node_id}') + _set_simplecad_attribute(occurrence, 'ComponentId', component_id) + _set_simplecad_attribute(occurrence, 'NodeId', component_node_id) + _set_simplecad_attribute(occurrence, 'ResultNodeId', result_node_id) + _set_simplecad_attribute(child, 'BodyNodeId', body_node) + _set_simplecad_attribute(child, 'ResultNodeId', result_node_id) + result_body = result_bodies[0] + _apply_name(result_body, f'SimpleCAD_{component_id}_{body_node}_0') + _set_simplecad_attribute(result_body, 'ComponentId', component_id) + _set_simplecad_attribute(result_body, 'NodeId', body_node) + try: + occurrence.isGroundToParent = False + except Exception: + pass + if not _placement_is_identity(placement): + if bool(getattr(occurrence, 'isValidForEditInitialPosition', False)): + occurrence.initialTransform = placement_matrix + elif not self.root.transformOccurrences( + [occurrence], [placement_matrix], True + ): + raise RuntimeError( + f'Fusion rejected placement for component {component_id}' + ) + if self.design.snapshots.hasPendingSnapshot: + snapshot = self.design.snapshots.add() + if snapshot is None: + raise RuntimeError( + f'Fusion could not capture placement for {component_id}' + ) + try: + result_body.isVisible = True + except Exception: + pass + self.materialized_native_node_ids.add(body_node) + self.materialized_component_definitions[body_node] = child + self.logs.append( + f'adopted precreated live component for {component_id}: ' + f'body_node={body_node}' + ) + return 1 + existing_component = self.materialized_component_definitions.get(body_node) + if existing_component is not None: + try: + occurrence = self.root.occurrences.addExistingComponent( + existing_component, placement_matrix + ) + except Exception: + occurrence = None + if occurrence is None: + raise RuntimeError( + f'Fusion could not reuse component definition for {component_id}' + ) + _apply_name(occurrence, f'SimpleCAD_{component_id or component_node_id}') + _set_simplecad_attribute(occurrence, 'ComponentId', component_id) + _set_simplecad_attribute(occurrence, 'NodeId', component_node_id) + _set_simplecad_attribute(occurrence, 'ResultNodeId', result_node_id) + try: + occurrence.isGroundToParent = False + except Exception: + pass + return max(1, int(existing_component.bRepBodies.count)) + + native_set = list(self.native_body_sets.get(body_node) or []) + can_use_native_set = ( + bool(native_set) + and body_node not in self.materialized_native_node_ids + and self._native_body_set_close(native_set, body) + ) + native = self.native_bodies.get(body_node) + can_use_native = ( + not can_use_native_set + and native is not None + and body_node not in self.materialized_native_node_ids + and self._native_body_close(native, body) + ) + live_bodies = native_set if can_use_native_set else ([native] if can_use_native else []) + created_live_body = None + if len(live_bodies) == 1: + created_live_body = live_bodies[0].createComponent() + if created_live_body is None: + raise RuntimeError( + f'Fusion could not create a live component for {component_id}' + ) + child = created_live_body.parentComponent + occurrences = [ + candidate + for candidate in self.root.occurrences + if candidate.component == child + ] + if len(occurrences) != 1: + raise RuntimeError( + f'Fusion did not expose one occurrence for live component ' + f'{component_id}; found {len(occurrences)}' + ) + occurrence = occurrences[0] + else: + occurrence = self.root.occurrences.addNewComponent( + adsk.core.Matrix3D.create() if live_bodies else placement_matrix + ) + if occurrence is None: + raise RuntimeError( + f'Fusion could not create occurrence for component {component_id}' + ) + child = occurrence.component + display_name = str(params.get('name') or component_id or component_node_id) + _apply_name(child, display_name) + _apply_name(occurrence, f'SimpleCAD_{component_id or component_node_id}') + _set_simplecad_attribute(occurrence, 'ComponentId', component_id) + _set_simplecad_attribute(occurrence, 'NodeId', component_node_id) + _set_simplecad_attribute(occurrence, 'ResultNodeId', result_node_id) + _set_simplecad_attribute(child, 'BodyNodeId', body_node) + _set_simplecad_attribute(child, 'ResultNodeId', result_node_id) + + if live_bodies: + result_bodies = [] + for index, live_body in enumerate(live_bodies): + moved = ( + created_live_body + if index == 0 and created_live_body is not None + else live_body.moveToComponent(occurrence) + ) + if moved is None: + raise RuntimeError( + f'Fusion could not move live body into component {component_id}' + ) + _apply_name(moved, f'SimpleCAD_{component_id}_{body_node}_{index}') + _set_simplecad_attribute(moved, 'ComponentId', component_id) + _set_simplecad_attribute(moved, 'NodeId', body_node) + result_bodies.append(moved) + self.materialized_native_node_ids.add(body_node) + self.materialized_component_definitions[body_node] = child + try: + occurrence.isGroundToParent = False + except Exception: + pass + if not _placement_is_identity(placement): + if bool(getattr(occurrence, 'isValidForEditInitialPosition', False)): + occurrence.initialTransform = placement_matrix + else: + if not self.root.transformOccurrences( + [occurrence], [placement_matrix], True + ): + raise RuntimeError( + f'Fusion rejected placement for component {component_id}' + ) + if self.design.snapshots.hasPendingSnapshot: + snapshot = self.design.snapshots.add() + if snapshot is None: + raise RuntimeError( + f'Fusion could not capture placement for {component_id}' + ) + self.logs.append( + f'live component body used for {component_id}: ' + f'body_node={body_node}; body_count={len(result_bodies)}' + ) + else: + base_feature = child.features.baseFeatures.add() + base_feature.startEdit() + try: + source_body = child.bRepBodies.add(self._body_copy(body), base_feature) + if source_body is None: + raise RuntimeError( + f'Fusion could not add body for component {component_id}' + ) + finally: + base_feature.finishEdit() + result_bodies = list(base_feature.bodies) + if len(result_bodies) != 1: + raise RuntimeError( + f'expected one result body for component {component_id}, ' + f'found {len(result_bodies)}' + ) + _apply_name(result_bodies[0], f'SimpleCAD_{component_id}_{body_node}') + _set_simplecad_attribute(result_bodies[0], 'ComponentId', component_id) + _set_simplecad_attribute(result_bodies[0], 'NodeId', body_node) + + for result_body in result_bodies: + try: + result_body.isVisible = True + except Exception: + pass + return len(result_bodies) + + def _materialize_results(self): + if not self.result_node_ids: + self.result_node_ids = [str(node.get('node_id')) for node in self.nodes[-1:]] + # Fusion's STEP exporter includes every visible intermediate feature + # body. Keep the complete parametric history but expose only the + # selected terminal result bodies. + for body in self.root.bRepBodies: + try: + body.isVisible = False + except Exception: + pass + emitted = 0 + for node_id in self.result_node_ids: + values = list(self.outputs.get(node_id, [])) + native_set = list(self.native_body_sets.get(str(node_id)) or []) + # Detail and boolean features can legitimately produce several + # disconnected result bodies. Their output list is one semantic + # result, so materialize the matching native set as a unit. If + # each body is handled independently, _materialize_value compares + # the whole native set with one body and falls back to static + # copies, leaving the editable native bodies hidden in the export. + if ( + len(native_set) > 1 + and len(values) == len(native_set) + and all(isinstance(value, adsk.fusion.BRepBody) for value in values) + and self._native_body_list_close(native_set, values) + ): + for index, native_part in enumerate(native_set): + _apply_name(native_part, f'SimpleCAD_{node_id}_{index}') + try: + native_part.isVisible = True + except Exception: + pass + emitted += len(native_set) + continue + for value in values: + emitted += self._materialize_value(value, node_id) + if emitted == 0: + for node_id, outputs in self.outputs.items(): + for value in outputs: + emitted += self._materialize_value(value, node_id) + if emitted == 0: + raise RuntimeError('Fusion translator produced no materialized bodies') + + def _native_body_list_close(self, native_bodies, expected_bodies): + """Match a multi-body native result to its serialized body list.""" + remaining = list(native_bodies) + try: + if len(remaining) != len(expected_bodies): + return False + for expected in expected_bodies: + matches = [ + candidate + for candidate in remaining + if self._native_body_close(candidate, expected) + ] + if len(matches) != 1: + return False + remaining.remove(matches[0]) + return not remaining + except Exception: + return False + + def _materialize_value(self, value, node_id): + count = 0 + native = self.native_bodies.get(str(node_id)) + native_set = self.native_body_sets.get(str(node_id)) or [] + node = self.node_by_id.get(str(node_id)) or {} + if ( + native_set + and isinstance(value, adsk.fusion.BRepBody) + and self._native_body_set_close(native_set, value) + ): + for index, native_part in enumerate(native_set): + _apply_name(native_part, f'SimpleCAD_{node_id}_{index}') + try: + native_part.isVisible = True + except Exception: + pass + return len(native_set) + if ( + native is not None + and isinstance(value, adsk.fusion.BRepBody) + and self._native_body_close(native, value) + ): + _apply_name(native, f'SimpleCAD_{node_id}') + _attach_simplecad_native_metadata( + native, + str(node_id), + str((self.node_by_id.get(str(node_id)) or {}).get('op') or ''), + (self.node_by_id.get(str(node_id)) or {}).get('params') or {}, + self._input_ids(self.node_by_id.get(str(node_id)) or {}), + ) + try: + native.isVisible = True + except Exception: + pass + return 1 + if native is not None: + self.logs.append( + f'native terminal geometry mismatch for {node_id}; ' + 'materializing exact static result; ' + f'comparison={self._native_body_comparison(native, value)!r}' + ) + if isinstance(value, adsk.fusion.BRepBody): + body = self._add_base_body(value) + _apply_name(body, f'SimpleCAD_{node_id}') + _attach_simplecad_native_metadata( + body, + str(node_id), + str(node.get('op') or ''), + node.get('params') or {}, + self._input_ids(node), + ) + return 1 + if isinstance(value, dict) and value.get('kind') in {'part', 'assembly', 'state'}: + count += self._materialize_product_value(value, node_id) + return count + + +def _safe_label(op, node_id): + raw = f'{op}_{node_id}' + token = ''.join(ch if ch.isalnum() else '_' for ch in raw).strip('_') + if not token: + token = 'simplecad' + if token[0].isdigit(): + token = 'simplecad_' + token + return token[:80] +''' + + +def translate_model_json_to_fusion360_script( + json_str: str, + document_name: str = "SimpleCADModel", + result_node_ids: Optional[Sequence[str]] = None, + *, + selection_mode: str = "gsm", + source_kernel_fallback: bool = False, +) -> str: + """Translate exported model JSON into a Fusion 360 Python script.""" + + return Fusion360ScriptTranslator( + document_name=document_name, + result_node_ids=result_node_ids, + selection_mode=selection_mode, + source_kernel_fallback=source_kernel_fallback, + ).translate_model_json_to_script(json_str) diff --git a/SimpleCADAPI/src/simplecadapi/translator/fusion360_translator/script_translator.py b/SimpleCADAPI/src/simplecadapi/translator/fusion360_translator/script_translator.py new file mode 100644 index 0000000..f096244 --- /dev/null +++ b/SimpleCADAPI/src/simplecadapi/translator/fusion360_translator/script_translator.py @@ -0,0 +1,5 @@ +"""Compatibility import for the contributed Fusion 360 script compiler.""" + +from .compiler import Fusion360ScriptTranslator + +__all__ = ["Fusion360ScriptTranslator"] diff --git a/SimpleCADAPI/src/simplecadapi/translator/fusion360_translator/translator.py b/SimpleCADAPI/src/simplecadapi/translator/fusion360_translator/translator.py new file mode 100644 index 0000000..749844a --- /dev/null +++ b/SimpleCADAPI/src/simplecadapi/translator/fusion360_translator/translator.py @@ -0,0 +1,147 @@ +"""Fusion 360 backend facade for canonical model translation.""" + +from __future__ import annotations + +from typing import Any, Dict, Optional, Sequence, Set + +from ...errors import ErrorGuidance +from ...topology import OperationGraph +from ..base import BaseTranslator +from ..errors import TranslationRequestError +from ..types import BackendCapabilities, SupportLevel, TranslationArtifact +from .capabilities import CAPABILITIES +from .compiler import Fusion360ScriptTranslator + + +def _dependency_node_ids( + graph: OperationGraph, result_node_ids: Sequence[str] +) -> Set[str]: + needed: Set[str] = set() + pending = [str(node_id) for node_id in result_node_ids] + while pending: + node_id = pending.pop() + if node_id in needed: + continue + node = graph.get_node(node_id) + if node is None: + raise ValueError(f"Result node {node_id!r} is not present in the graph") + needed.add(node_id) + pending.extend(input_ref.node_id for input_ref in node.inputs) + for key in ("selected_edge_node_ids", "selected_face_node_ids"): + pending.extend(str(value) for value in node.params.get(key, []) or []) + return needed + + +class Fusion360Translator(BaseTranslator): + """Translate canonical model JSON into a Fusion 360 Python script.""" + + def __init__( + self, + document_name: str = "SimpleCADModel", + result_node_ids: Optional[Sequence[str]] = None, + *, + selection_mode: str = "gsm", + source_kernel_fallback: bool = False, + ) -> None: + self.document_name = str(document_name) + self.result_node_ids = ( + tuple(str(node_id) for node_id in result_node_ids) + if result_node_ids is not None + else None + ) + self.selection_mode = str(selection_mode) + self.source_kernel_fallback = bool(source_kernel_fallback) + + @property + def capabilities(self) -> BackendCapabilities: + return CAPABILITIES + + def _result_ids( + self, payload: Dict[str, Any], graph: OperationGraph + ) -> Sequence[str]: + if self.result_node_ids is not None: + return self.result_node_ids + leaf_ids = payload.get("leaf_ids") + if isinstance(leaf_ids, list) and leaf_ids: + return [str(node_id) for node_id in leaf_ids] + return [node.node_id for node in graph.leaf_nodes()] + + def _preflight(self, payload: Dict[str, Any], graph: OperationGraph) -> None: + needed = _dependency_node_ids(graph, self._result_ids(payload, graph)) + unsupported = sorted( + { + node.op + for node_id in needed + for node in [graph.get_node(node_id)] + if node is not None + and ( + node.op not in CAPABILITIES.operations + or CAPABILITIES.operations[node.op].level + is SupportLevel.UNSUPPORTED + ) + } + ) + if unsupported: + joined = ", ".join(unsupported) + raise TranslationRequestError( + "fusion360", + "translate_model_payload", + ErrorGuidance( + what_happened=f"The result graph uses unsupported Fusion 360 operations: {joined}.", + possible_causes=( + "The model uses canonical operations not implemented by the contributed runtime.", + ), + how_to_fix=( + "Lower the model to operations declared by fusion360_translator.CAPABILITIES.", + "Use another translator backend for this model.", + ), + ), + ) + + def translate_model_payload_to_script( + self, + payload: Dict[str, Any], + *, + graph: Optional[OperationGraph] = None, + ) -> str: + source_graph = graph or payload.get("graph") + if not isinstance(source_graph, OperationGraph) or source_graph.node_count == 0: + raise ValueError( + "Fusion 360 translation requires a non-empty canonical graph" + ) + self._preflight(payload, source_graph) + return Fusion360ScriptTranslator( + document_name=self.document_name, + result_node_ids=self.result_node_ids, + selection_mode=self.selection_mode, + source_kernel_fallback=self.source_kernel_fallback, + ).translate_model_payload_to_script(payload, graph=source_graph) + + def translate_model_json_to_script(self, json_str: str) -> str: + artifact = self.translate_model_json(json_str) + assert isinstance(artifact.content, str) + return artifact.content + + def translate_model_payload( + self, + payload: Dict[str, Any], + *, + graph: Optional[OperationGraph] = None, + ) -> TranslationArtifact: + return TranslationArtifact( + backend_id="fusion360", + target_id="fusion360_script", + media_type="text/x-python", + suggested_suffix=".py", + content=self.translate_model_payload_to_script(payload, graph=graph), + metadata={ + "document_name": self.document_name, + "result_node_ids": self.result_node_ids, + "selection_mode": self.selection_mode, + "source_kernel_fallback": self.source_kernel_fallback, + "target_runtime_validated": False, + }, + ) + + +__all__ = ["Fusion360Translator"] diff --git a/SimpleCADAPI/src/simplecadapi/translator/solidworks_translator/__init__.py b/SimpleCADAPI/src/simplecadapi/translator/solidworks_translator/__init__.py new file mode 100644 index 0000000..8bbf361 --- /dev/null +++ b/SimpleCADAPI/src/simplecadapi/translator/solidworks_translator/__init__.py @@ -0,0 +1,19 @@ +"""SolidWorks translator backend for SimpleCAD model JSON.""" + +from .api import ( + export_model_json_to_solidworks_step, + translate_model_json_to_solidworks_script, + translate_model_json_to_solidworks_step, +) +from .capabilities import CAPABILITIES +from .compiler import SolidWorksScriptTranslator +from .translator import SolidWorksTranslator + +__all__ = [ + "CAPABILITIES", + "SolidWorksScriptTranslator", + "SolidWorksTranslator", + "export_model_json_to_solidworks_step", + "translate_model_json_to_solidworks_script", + "translate_model_json_to_solidworks_step", +] diff --git a/SimpleCADAPI/src/simplecadapi/translator/solidworks_translator/api.py b/SimpleCADAPI/src/simplecadapi/translator/solidworks_translator/api.py new file mode 100644 index 0000000..44d6252 --- /dev/null +++ b/SimpleCADAPI/src/simplecadapi/translator/solidworks_translator/api.py @@ -0,0 +1,56 @@ +"""Public SolidWorks translator entrypoints.""" + +from __future__ import annotations + +from typing import Optional + +from .compiler import translate_model_json_to_solidworks_step as _export_step +from .translator import SolidWorksTranslator + + +def translate_model_json_to_solidworks_script( + json_str: str, + document_name: str = "SimpleCADModel", + *, + output_path: Optional[str] = None, + visible: bool = False, + source_kernel_fallback: bool = False, +) -> str: + """Translate canonical model JSON into a SolidWorks automation script.""" + + return SolidWorksTranslator( + document_name=document_name, + output_path=output_path, + visible=visible, + source_kernel_fallback=source_kernel_fallback, + ).translate_model_json_to_script(json_str) + + +def export_model_json_to_solidworks_step( + json_str: str, + output_path: str, + *, + document_name: str = "SimpleCADModel", + visible: bool = False, + python_exe: Optional[str] = None, + source_kernel_fallback: bool = False, +) -> str: + """Execute SolidWorks COM automation and export a STEP file.""" + + return _export_step( + json_str, + output_path, + document_name=document_name, + visible=visible, + python_exe=python_exe, + source_kernel_fallback=source_kernel_fallback, + ) + + +translate_model_json_to_solidworks_step = export_model_json_to_solidworks_step + +__all__ = [ + "export_model_json_to_solidworks_step", + "translate_model_json_to_solidworks_script", + "translate_model_json_to_solidworks_step", +] diff --git a/SimpleCADAPI/src/simplecadapi/translator/solidworks_translator/capabilities.py b/SimpleCADAPI/src/simplecadapi/translator/solidworks_translator/capabilities.py new file mode 100644 index 0000000..9becbbc --- /dev/null +++ b/SimpleCADAPI/src/simplecadapi/translator/solidworks_translator/capabilities.py @@ -0,0 +1,130 @@ +"""Static capability declaration for the SolidWorks translator backend.""" + +from __future__ import annotations + +from typing import Dict + +from ...serializer import CANONICAL_OP_SET +from ..types import ( + BackendCapabilities, + OperationCapability, + SupportLevel, + TranslationOutputKind, + TranslationTarget, +) + +BACKEND_NAME = "solidworks" + +_GEOMETRY_OPS = { + "make_line_redge", + "make_circle_redge", + "make_three_point_arc_redge", + "make_angle_arc_redge", + "make_spline_redge", + "make_helix_redge", + "make_wire_from_edges_rwire", + "make_face_from_wire_rface", + "make_face_from_wires_rface", + "make_extrude_rsolid", + "make_revolve_rsolid", + "make_loft_rsolid", + "make_sweep_rsolid", + "make_translate_rshape", + "make_rotate_rshape", + "make_mirror_rshape", + "make_cut_rsolid", + "make_union_rsolid", + "make_intersect_rsolid", + "make_fillet_rsolid", + "make_chamfer_rsolid", + "make_shell_rsolid", + "make_select_redge", + "make_select_rface", +} + +_PRODUCT_OPS = { + "make_material_rmaterial", + "make_placement_rplacement", + "make_identity_placement_rplacement", + "make_part_rpart", + "make_assign_material_rpart", + "make_assembly_rassembly", + "make_add_component_rassembly", + "make_place_component_rassembly", + "make_compound_from_assembly_rcompound", + "make_face_connector_rconnector", + "make_edge_connector_rconnector", + "make_vertex_connector_rconnector", + "make_placement_connector_rconnector", + "make_add_connector_rpart", + "make_add_connector_rassembly", + "make_connector_ref_rconnectorref", + "make_scalar_limit_rscalarlimit", + "make_ground_component_rassembly", + "make_unground_component_rassembly", + "make_fixed_constraint_rassembly", + "make_revolute_constraint_rassembly", + "make_prismatic_constraint_rassembly", + "make_solve_assembly_constraints_rassembly", +} + +OP_SUPPORT: Dict[str, OperationCapability] = { + op: OperationCapability( + SupportLevel.UNSUPPORTED, + reason="The SolidWorks runtime has no implementation for this canonical operation.", + ) + for op in CANONICAL_OP_SET +} +for _op in _GEOMETRY_OPS: + OP_SUPPORT[_op] = OperationCapability( + SupportLevel.EMULATED, + reason=( + "The generated automation script reconstructs this operation through " + "SolidWorks COM geometry and feature APIs." + ), + ) +for _op in _PRODUCT_OPS: + OP_SUPPORT[_op] = OperationCapability( + SupportLevel.METADATA_ONLY, + reason=( + "The generated script preserves or consumes the product semantic data " + "without promising an equivalent native SolidWorks feature." + ), + ) + +CAPABILITIES = BackendCapabilities( + backend_id=BACKEND_NAME, + display_name="SolidWorks", + input_schema_versions=("2.0",), + targets=( + TranslationTarget( + target_id="solidworks_script", + output_kind=TranslationOutputKind.TEXT, + media_type="text/x-python", + extensions=(".py",), + requires_external_runtime=True, + option_names=( + "document_name", + "output_path", + "visible", + "source_kernel_fallback", + ), + ), + TranslationTarget( + target_id="step", + output_kind=TranslationOutputKind.FILE, + media_type="model/step", + extensions=(".step", ".stp"), + requires_external_runtime=True, + option_names=( + "document_name", + "visible", + "python_exe", + "source_kernel_fallback", + ), + ), + ), + operations=OP_SUPPORT, +) + +__all__ = ["BACKEND_NAME", "CAPABILITIES", "OP_SUPPORT"] diff --git a/SimpleCADAPI/src/simplecadapi/translator/solidworks_translator/compiler.py b/SimpleCADAPI/src/simplecadapi/translator/solidworks_translator/compiler.py new file mode 100644 index 0000000..64fe8ad --- /dev/null +++ b/SimpleCADAPI/src/simplecadapi/translator/solidworks_translator/compiler.py @@ -0,0 +1,10868 @@ +"""Translate SimpleCAD model/graph payloads into SolidWorks automation scripts. + +Generated scripts are intended to run on Windows machines with SolidWorks and +pywin32 installed. They interpret the same canonical low-level operation graph +used by the FreeCAD and Fusion 360 translators and avoid topology-index based +edge/face selection. +""" + +from __future__ import annotations + +import base64 +import copy +import hashlib +import json +import os +import pprint +import re +import sys +import tempfile +import zlib +from typing import Any, Dict, List, Optional, Sequence, Set, Tuple + +from OCP.Bnd import Bnd_Box +from OCP.BRepBndLib import BRepBndLib +from OCP.BRepCheck import BRepCheck_Analyzer +from OCP.IFSelect import IFSelect_RetDone +from OCP.STEPControl import STEPControl_AsIs, STEPControl_Writer +from OCP.TopAbs import TopAbs_SOLID +from OCP.gp import gp_Ax2, gp_Dir, gp_Pnt + +from ...errors import raise_harness_error +from ...operations import _make_geo_selector +from ...serializer import ( + _execute_graph, + _resolve_shape_from_geo_selector, + import_model_json, +) +from ...topology import OperationGraph + + +def _json_ascii(value: Any) -> str: + return json.dumps(value, ensure_ascii=True, sort_keys=True) + + +def _py_literal(value: Any) -> str: + return pprint.pformat(value, compact=True, sort_dicts=True, width=120) + + +def _deterministic_step_bytes(data: bytes) -> bytes: + text = data.decode("latin-1") + text = re.sub( + r"(FILE_NAME\('[^']*',)'[^']*'", + r"\1'1970-01-01T00:00:00'", + text, + count=1, + ) + text = re.sub( + r"(Open CASCADE STEP translator [^']* )\d+(')", + r"\g<1>1\2", + text, + ) + return text.encode("latin-1") + + +_ASSEMBLY_ACTIVE_STATE_PRIORITY = ( + "operating", + "locked", + "nominal", + "default", + "middle", + "middle_adjustment", +) + + +def _assembly_state_result_node_ids( + graph: OperationGraph, result_node_ids: Sequence[str] +) -> Dict[str, List[str]]: + """Group sibling assembly snapshots by the state suffix in their IDs.""" + + node_ids = [str(node_id) for node_id in result_node_ids] + if len(node_ids) < 2: + return {} + snapshots: List[Tuple[str, str, int]] = [] + for node_id in node_ids: + node = graph.get_node(node_id) + if node is None or node.op != "make_compound_from_assembly_rcompound": + return {} + assembly_id = str(node.params.get("assembly_id") or "") + if not assembly_id: + return {} + snapshots.append( + ( + node_id, + assembly_id, + int(node.params.get("component_count") or 0), + ) + ) + if len({count for _, _, count in snapshots}) != 1: + return {} + common_prefix = os.path.commonprefix( + [assembly_id for _, assembly_id, _ in snapshots] + ) + separator_index = common_prefix.rfind("_") + if separator_index <= 0: + return {} + state_prefix = common_prefix[: separator_index + 1] + state_node_ids: Dict[str, List[str]] = {} + for node_id, assembly_id, _ in snapshots: + if not assembly_id.startswith(state_prefix): + return {} + state = assembly_id[len(state_prefix) :].strip("_") + if not state or state in state_node_ids: + return {} + state_node_ids[state] = [node_id] + return state_node_ids + + +def _preferred_result_node_ids( + graph: OperationGraph, result_node_ids: Sequence[str] +) -> List[str]: + """Choose the nominal snapshot from an explicitly named assembly state set.""" + + node_ids = [str(node_id) for node_id in result_node_ids] + state_node_ids = _assembly_state_result_node_ids(graph, node_ids) + for state in _ASSEMBLY_ACTIVE_STATE_PRIORITY: + selected = state_node_ids.get(state) + if selected is not None: + return list(selected) + node_by_id = {str(node.node_id): node for node in graph.nodes} + assembly_results = [ + node_id + for node_id in node_ids + if getattr(node_by_id.get(node_id), "op", None) + == "make_compound_from_assembly_rcompound" + ] + if assembly_results: + return assembly_results + dominant_result = _dominant_result_node_id(graph, node_ids) + if dominant_result is not None: + return [dominant_result] + return node_ids + + +def _dominant_result_node_id( + graph: OperationGraph, result_node_ids: Sequence[str] +) -> Optional[str]: + """Collapse redundant terminal leaves only when their union proves containment.""" + + node_by_id = {str(node.node_id): node for node in graph.nodes} + node_ids = [str(node_id) for node_id in result_node_ids] + ops = [getattr(node_by_id.get(node_id), "op", None) for node_id in node_ids] + detail_ops = {"make_fillet_rsolid", "make_chamfer_rsolid", "make_shell_rsolid"} + has_one_detail_result = sum(op in detail_ops for op in ops) == 1 + all_union_results = len(node_ids) > 1 and all( + op == "make_union_rsolid" for op in ops + ) + if not has_one_detail_result and not all_union_results: + return None + + try: + from OCP.BRepAlgoAPI import BRepAlgoAPI_Fuse + from OCP.BRepGProp import BRepGProp + from OCP.GProp import GProp_GProps + + results = _execute_graph(graph, node_ids, strict=True) + if len(results) != len(node_ids): + return None + + def shape_volume(shape: Any) -> float: + props = GProp_GProps() + BRepGProp.VolumeProperties_s(shape, props) + return max(0.0, float(props.Mass())) + + positive_shapes: List[Tuple[str, Any, float]] = [] + for node_id, result in zip(node_ids, results): + shape = getattr(result, "wrapped", None) + if shape is None or shape.IsNull(): + continue + volume = shape_volume(shape) + if volume > 0.0: + positive_shapes.append((node_id, shape, volume)) + if len(positive_shapes) < 2: + return None + + if has_one_detail_result: + detail_node_id = next( + node_id + for node_id, op in zip(node_ids, ops) + if op in detail_ops + ) + volume_by_id = { + node_id: volume for node_id, _shape, volume in positive_shapes + } + detail_volume = volume_by_id.get(detail_node_id, 0.0) + total_volume = sum(volume_by_id.values()) + if total_volume > 0.0 and detail_volume / total_volume >= 0.99: + return detail_node_id + + fused_shape = positive_shapes[0][1] + for _node_id, shape, _volume in positive_shapes[1:]: + fuse = BRepAlgoAPI_Fuse(fused_shape, shape) + fuse.Build() + if not fuse.IsDone(): + return None + fused_shape = fuse.Shape() + fused_volume = shape_volume(fused_shape) + dominant_node_id, _dominant_shape, dominant_volume = max( + positive_shapes, key=lambda item: item[2] + ) + tolerance = max(1.0e-12, fused_volume * 1.0e-8) + if fused_volume > 0.0 and abs(dominant_volume - fused_volume) <= tolerance: + return dominant_node_id + except Exception: + return None + return None + + +def _canonical_union_passthroughs(graph: OperationGraph) -> Dict[str, int]: + """Find unions whose canonical replay is exactly one of their inputs.""" + + union_nodes = [node for node in graph.nodes if node.op == "make_union_rsolid"] + if not union_nodes: + return {} + replay_node_ids = list( + dict.fromkeys( + [str(node.node_id) for node in union_nodes] + + [ + str(input_ref.node_id) + for node in union_nodes + for input_ref in node.inputs + ] + ) + ) + try: + results = _execute_graph(graph, replay_node_ids, strict=True) + except Exception: + return {} + if len(results) != len(replay_node_ids): + return {} + + descriptors: Dict[str, Tuple[Tuple[float, ...], float]] = {} + for node_id, result in zip(replay_node_ids, results): + wrapped = getattr(result, "wrapped", None) + if wrapped is None or wrapped.IsNull(): + continue + box = Bnd_Box() + box.SetGap(0.0) + BRepBndLib.AddOptimal_s(wrapped, box, False, False) + if box.IsVoid(): + continue + descriptors[node_id] = ( + tuple(float(value) for value in box.Get()), + float(result.get_volume()), + ) + + passthroughs: Dict[str, int] = {} + for node in union_nodes: + node_id = str(node.node_id) + output_descriptor = descriptors.get(node_id) + if output_descriptor is None: + continue + output_bbox, output_volume = output_descriptor + bbox_size = sum( + (output_bbox[index + 3] - output_bbox[index]) ** 2 + for index in range(3) + ) ** 0.5 + bbox_tolerance = max(1.0e-7, bbox_size * 1.0e-7) + volume_tolerance = max(1.0e-9, abs(output_volume) * 1.0e-8) + matches: List[int] = [] + for index, input_ref in enumerate(node.inputs): + input_descriptor = descriptors.get(str(input_ref.node_id)) + if input_descriptor is None: + continue + input_bbox, input_volume = input_descriptor + bbox_error = sum( + (actual - expected) ** 2 + for actual, expected in zip(input_bbox, output_bbox) + ) ** 0.5 + if ( + bbox_error <= bbox_tolerance + and abs(input_volume - output_volume) <= volume_tolerance + ): + matches.append(index) + if len(matches) == 1: + passthroughs[node_id] = matches[0] + return passthroughs + + +def _canonical_cut_descriptors(graph: OperationGraph) -> Dict[str, Dict[str, Any]]: + node_ids = [ + node.node_id for node in graph.nodes + if node.op == "make_cut_rsolid" + ] + if not node_ids: + return {} + try: + results = _execute_graph(graph, node_ids, strict=True) + except Exception: + return {} + if len(results) != len(node_ids): + return {} + descriptors: Dict[str, Dict[str, Any]] = {} + for node_id, result in zip(node_ids, results): + wrapped = getattr(result, "wrapped", None) + if wrapped is None or wrapped.IsNull(): + continue + box = Bnd_Box() + box.SetGap(0.0) + BRepBndLib.AddOptimal_s(wrapped, box, False, False) + if box.IsVoid(): + continue + bounds = tuple(float(value) for value in box.Get()) + descriptors[str(node_id)] = { + "bbox": { + "min": bounds[:3], + "max": bounds[3:], + }, + "volume": float(result.get_volume()), + } + return descriptors + + +def _canonical_union_descriptors(graph: OperationGraph) -> Dict[str, Dict[str, Any]]: + node_ids = [ + node.node_id for node in graph.nodes + if node.op == "make_union_rsolid" + ] + if not node_ids: + return {} + try: + results = _execute_graph(graph, node_ids, strict=True) + except Exception: + return {} + if len(results) != len(node_ids): + return {} + descriptors: Dict[str, Dict[str, Any]] = {} + for node_id, result in zip(node_ids, results): + wrapped = getattr(result, "wrapped", None) + if wrapped is None or wrapped.IsNull(): + continue + box = Bnd_Box() + box.SetGap(0.0) + BRepBndLib.AddOptimal_s(wrapped, box, False, False) + if box.IsVoid(): + continue + bounds = tuple(float(value) for value in box.Get()) + descriptors[str(node_id)] = { + "bbox": {"min": bounds[:3], "max": bounds[3:]}, + "volume": float(result.get_volume()), + } + return descriptors + + +def _canonical_union_input_indices(graph: OperationGraph) -> Dict[str, List[int]]: + """Identify the original operands that contribute solid volume to each union.""" + + union_nodes = [node for node in graph.nodes if node.op == "make_union_rsolid"] + if not union_nodes: + return {} + replay_node_ids = list( + dict.fromkeys( + [str(node.node_id) for node in union_nodes] + + [ + str(input_ref.node_id) + for node in union_nodes + for input_ref in node.inputs + ] + ) + ) + try: + from OCP.BRepAlgoAPI import BRepAlgoAPI_Common + from OCP.BRepGProp import BRepGProp + from OCP.GProp import GProp_GProps + + replayed = _execute_graph(graph, replay_node_ids, strict=True) + except Exception: + return {} + if len(replayed) != len(replay_node_ids): + return {} + shapes = { + node_id: getattr(result, "wrapped", None) + for node_id, result in zip(replay_node_ids, replayed) + } + + def shape_volume(shape: Any) -> float: + if shape is None or shape.IsNull(): + return 0.0 + properties = GProp_GProps() + BRepGProp.VolumeProperties_s(shape, properties) + return max(0.0, float(properties.Mass())) + + result: Dict[str, List[int]] = {} + for node in union_nodes: + output_shape = shapes.get(str(node.node_id)) + if output_shape is None or output_shape.IsNull(): + continue + contributing: List[int] = [] + for index, input_ref in enumerate(node.inputs): + input_shape = shapes.get(str(input_ref.node_id)) + input_volume = shape_volume(input_shape) + if input_shape is None or input_shape.IsNull() or input_volume <= 0.0: + continue + try: + common = BRepAlgoAPI_Common(output_shape, input_shape) + common.Build() + common_volume = ( + shape_volume(common.Shape()) if common.IsDone() else 0.0 + ) + except Exception: + common_volume = 0.0 + if common_volume > max(1.0e-10, input_volume * 1.0e-8): + contributing.append(index) + if contributing: + result[str(node.node_id)] = contributing + return result + + +def _canonical_loft_descriptors(graph: OperationGraph) -> Dict[str, Dict[str, Any]]: + loft_nodes = [ + node for node in graph.nodes + if node.op == "make_loft_rsolid" + ] + node_ids = [node.node_id for node in loft_nodes] + if not node_ids: + return {} + replay_graph = copy.deepcopy(graph) + ruled_segment_ids: Dict[str, List[str]] = {} + for node in loft_nodes: + if not bool(node.params.get("ruled")) or len(node.inputs) <= 2: + continue + replay_node = replay_graph.get_node(str(node.node_id)) + if replay_node is None: + continue + segment_ids: List[str] = [] + for index in range(len(replay_node.inputs) - 1): + segment_id = f"__solidworks_ruled_{node.node_id}_{index}" + replay_graph.add_node( + "make_loft_rsolid", + {"profile_count": 2, "ruled": True}, + inputs=[replay_node.inputs[index], replay_node.inputs[index + 1]], + node_id=segment_id, + ) + segment_ids.append(segment_id) + ruled_segment_ids[str(node.node_id)] = segment_ids + replay_ids = node_ids + [ + segment_id + for segment_ids in ruled_segment_ids.values() + for segment_id in segment_ids + ] + try: + results = _execute_graph(replay_graph, replay_ids, strict=True) + except Exception: + return {} + if len(results) != len(replay_ids): + return {} + + def descriptor(result: Any) -> Optional[Dict[str, Any]]: + wrapped = getattr(result, "wrapped", None) + if wrapped is None or wrapped.IsNull(): + return None + box = Bnd_Box() + box.SetGap(0.0) + BRepBndLib.AddOptimal_s(wrapped, box, False, False) + if box.IsVoid(): + return None + bounds = tuple(float(value) for value in box.Get()) + return { + "bbox": { + "min": bounds[:3], + "max": bounds[3:], + }, + "volume": float(result.get_volume()), + } + + replayed = dict(zip(replay_ids, results)) + descriptors: Dict[str, Dict[str, Any]] = {} + for node_id in node_ids: + value = descriptor(replayed[node_id]) + if value is None: + continue + segment_descriptors = [ + descriptor(replayed[segment_id]) + for segment_id in ruled_segment_ids.get(str(node_id), []) + ] + if segment_descriptors and all(segment_descriptors): + value["ruled_segments"] = segment_descriptors + descriptors[str(node_id)] = value + return descriptors + + +def _canonical_sweep_descriptors(graph: OperationGraph) -> Dict[str, Dict[str, Any]]: + sweep_ids = [ + str(node.node_id) + for node in graph.nodes + if node.op == "make_sweep_rsolid" + ] + if not sweep_ids: + return {} + try: + results = _execute_graph(graph, sweep_ids, strict=True) + except Exception: + return {} + if len(results) != len(sweep_ids): + return {} + descriptors: Dict[str, Dict[str, Any]] = {} + for node_id, result in zip(sweep_ids, results): + wrapped = getattr(result, "wrapped", None) + if wrapped is None or wrapped.IsNull(): + continue + box = Bnd_Box() + box.SetGap(0.0) + BRepBndLib.AddOptimal_s(wrapped, box, False, False) + if box.IsVoid(): + continue + bounds = tuple(float(value) for value in box.Get()) + descriptors[node_id] = { + "bbox": {"min": bounds[:3], "max": bounds[3:]}, + "volume": float(result.get_volume()), + } + return descriptors + + +def _result_dependency_node_ids( + graph: OperationGraph, result_node_ids: Sequence[str] +) -> Set[str]: + pending = [str(node_id) for node_id in result_node_ids] + visited: Set[str] = set() + while pending: + node_id = pending.pop() + if node_id in visited: + continue + visited.add(node_id) + node = graph.get_node(node_id) + if node is None: + continue + pending.extend(str(input_ref.node_id) for input_ref in node.inputs) + for key in ("selected_edge_node_ids", "selected_face_node_ids"): + pending.extend(str(value) for value in node.params.get(key, []) or []) + return visited + + +def _canonical_detail_edge_catalog( + graph: OperationGraph, result_node_ids: Sequence[str] +) -> Dict[str, Any]: + """Describe every canonical edge feeding an active Fillet or Chamfer. + + The catalog is task-independent. Generated SolidWorks scripts use it once, + while the native source body is live, to establish persistent references + through GSM. Native OES edits can then resolve a frozen canonical edge + index without performing geometry matching after the document is reopened. + """ + + active_ids = _result_dependency_node_ids(graph, result_node_ids) + detail_nodes = [ + node + for node in graph.nodes + if str(node.node_id) in active_ids + and node.op in {"make_fillet_rsolid", "make_chamfer_rsolid"} + and node.inputs + ] + source_node_ids = list( + dict.fromkeys(str(node.inputs[0].node_id) for node in detail_nodes) + ) + if not source_node_ids: + return {} + detail_node_ids = [str(node.node_id) for node in detail_nodes] + replay_node_ids = list(dict.fromkeys(source_node_ids + detail_node_ids)) + try: + replay_results = _execute_graph(graph, replay_node_ids, strict=True) + except Exception: + return {} + if len(replay_results) != len(replay_node_ids): + return {} + + replayed = dict(zip(replay_node_ids, replay_results)) + + source_shapes: Dict[str, Any] = { + source_id: replayed[source_id] + for source_id in source_node_ids + if source_id in replayed + for result in (replayed[source_id],) + if hasattr(result, "get_edges") + } + sources: Dict[str, Dict[str, Any]] = {} + for source_id, source_shape in source_shapes.items(): + try: + source_edges = list(source_shape.get_edges()) + except Exception: + continue + edges = [] + for canonical_index, edge in enumerate(source_edges): + try: + selector = dict( + _make_geo_selector(edge, source_shape=source_shape) + ) + except Exception: + continue + selector.pop("metadata_geo", None) + edges.append( + { + "canonical_index": canonical_index, + "selector": selector, + } + ) + sources[source_id] = {"edges": edges, "targets": {}} + + node_by_id = {str(node.node_id): node for node in graph.nodes} + for detail_node in detail_nodes: + source_id = str(detail_node.inputs[0].node_id) + source_shape = source_shapes.get(source_id) + source_entry = sources.get(source_id) + if source_shape is None or source_entry is None: + continue + try: + source_edges = list(source_shape.get_edges()) + selected_indices = [] + for selector_node_id in ( + detail_node.params.get("selected_edge_node_ids") or [] + ): + selector_node = node_by_id.get(str(selector_node_id)) + selector = ( + dict(selector_node.params.get("geo_selector") or {}) + if selector_node is not None + else {} + ) + if not selector: + raise RuntimeError("detail selector has no geometry") + selected_edge = _resolve_shape_from_geo_selector( + source_shape, selector + ) + selected_indices.append( + next( + index + for index, candidate in enumerate(source_edges) + if candidate.same_topology(selected_edge) + ) + ) + except Exception: + continue + target_entry: Dict[str, Any] = { + "selected_indices": selected_indices, + } + detail_result = replayed.get(str(detail_node.node_id)) + try: + wrapped = getattr(detail_result, "wrapped", None) + if wrapped is not None and not wrapped.IsNull(): + box = Bnd_Box() + box.SetGap(0.0) + BRepBndLib.AddOptimal_s(wrapped, box, False, False) + if not box.IsVoid(): + bounds = tuple(float(value) for value in box.Get()) + target_entry["result_descriptor"] = { + "bbox": { + "min": bounds[:3], + "max": bounds[3:], + }, + "volume": float(detail_result.get_volume()), + } + except Exception: + pass + source_entry["targets"][str(detail_node.node_id)] = target_entry + + return { + "schema": "simplecad-sw-canonical-topology-v1", + "method": "gsm", + "sources": sources, + } + + +def _result_dependency_ops( + graph: OperationGraph, result_node_ids: Sequence[str] +) -> Set[str]: + pending = [str(node_id) for node_id in result_node_ids] + visited: Set[str] = set() + operations: Set[str] = set() + while pending: + node_id = pending.pop() + if node_id in visited: + continue + visited.add(node_id) + node = graph.get_node(node_id) + if node is None: + continue + operations.add(str(node.op)) + pending.extend(str(input_ref.node_id) for input_ref in node.inputs) + return operations + + +def _source_kernel_result_step_payload( + graph: OperationGraph, result_node_ids: Sequence[str] +) -> Optional[str]: + """Serialize valid final source results for detail-feature recovery.""" + + node_ids = [str(node_id) for node_id in result_node_ids] + nodes = [graph.get_node(node_id) for node_id in node_ids] + dependency_ops = _result_dependency_ops(graph, node_ids) + if not nodes or not dependency_ops.intersection( + {"make_chamfer_rsolid", "make_fillet_rsolid"} + ): + return None + try: + results = _execute_graph(graph, node_ids, strict=True) + except Exception: + return None + if len(results) != len(node_ids): + return None + writer = STEPControl_Writer() + source_cache: Dict[str, Any] = {} + transferred_shape_count = 0 + for node_id, node, result in zip(node_ids, nodes, results): + wrapped = getattr(result, "wrapped", None) + source_valid = ( + wrapped is not None + and not wrapped.IsNull() + and BRepCheck_Analyzer(wrapped).IsValid() + ) + if not source_valid: + if ( + node is None + or node.op != "make_chamfer_rsolid" + or not node.inputs + ): + return None + source_id = str(node.inputs[0].node_id) + source_result = source_cache.get(source_id) + if source_result is None: + try: + source_results = _execute_graph( + graph, [source_id], strict=True + ) + except Exception: + continue + if len(source_results) != 1: + continue + source_result = source_results[0] + source_cache[source_id] = source_result + source_shape = getattr(source_result, "wrapped", None) + if ( + source_shape is None + or source_shape.IsNull() + or source_shape.ShapeType() != TopAbs_SOLID + or not BRepCheck_Analyzer(source_shape).IsValid() + ): + continue + box = Bnd_Box() + box.SetGap(0.0) + BRepBndLib.AddOptimal_s(source_shape, box, False, False) + if box.IsVoid(): + continue + bounds = tuple(float(value) for value in box.Get()) + extents = [ + bounds[index + 3] - bounds[index] + for index in range(3) + if bounds[index + 3] - bounds[index] > 1.0e-12 + ] + distance = abs(float(node.params.get("distance", 0.0) or 0.0)) + if not extents or distance > 0.25 * min(extents): + # Match FreeCAD result collection: an invalid over-large final + # chamfer remains failed and is omitted without suppressing + # valid sibling results. + continue + wrapped = source_shape + writer.Transfer(wrapped, STEPControl_AsIs) + transferred_shape_count += 1 + if transferred_shape_count == 0: + return None + handle = tempfile.NamedTemporaryFile(suffix=".step", delete=False) + path = handle.name + handle.close() + try: + if writer.Write(path) != IFSelect_RetDone: + return None + with open(path, "rb") as source: + return base64.b64encode( + zlib.compress(_deterministic_step_bytes(source.read()), level=9) + ).decode("ascii") + finally: + try: + os.unlink(path) + except OSError: + pass + + +class SolidWorksScriptTranslator: + """Compile SimpleCAD model JSON into a SolidWorks COM automation script.""" + + def __init__( + self, + document_name: str = "SimpleCADModel", + *, + visible: bool = False, + source_kernel_fallback: bool = False, + ) -> None: + self.document_name = document_name + self.visible = bool(visible) + self.source_kernel_fallback = bool(source_kernel_fallback) + self._declared_result_node_id_list: List[str] = [] + self._result_node_id_list: List[str] = [] + self._result_node_ids: Set[str] = set() + self._result_state_node_ids: Dict[str, List[str]] = {} + self._active_result_state: Optional[str] = None + + def translate_model_json_to_script( + self, + json_str: str, + *, + output_path: Optional[str] = None, + ) -> str: + payload = import_model_json(json_str) + graph = payload.get("graph") + if not isinstance(graph, OperationGraph): + raise ValueError( + "SolidWorks translation requires model JSON with a canonical low-level graph" + ) + if graph.node_count == 0: + raise ValueError( + "SolidWorks translation requires model JSON with a non-empty canonical low-level graph" + ) + return self.translate_model_payload_to_script( + payload, graph=graph, output_path=output_path + ) + + def translate_model_payload_to_script( + self, + payload: Dict[str, Any], + *, + graph: OperationGraph, + output_path: Optional[str], + ) -> str: + leaf_ids = payload.get("leaf_ids") + if isinstance(leaf_ids, list) and leaf_ids: + self._declared_result_node_id_list = [str(v) for v in leaf_ids] + else: + self._declared_result_node_id_list = [ + leaf.node_id for leaf in graph.leaf_nodes() + ] + self._result_state_node_ids = _assembly_state_result_node_ids( + graph, self._declared_result_node_id_list + ) + self._result_node_id_list = _preferred_result_node_ids( + graph, self._declared_result_node_id_list + ) + self._active_result_state = next( + ( + state + for state, node_ids in self._result_state_node_ids.items() + if node_ids == self._result_node_id_list + ), + None, + ) + self._result_node_ids = set(self._result_node_id_list) + payload_dict = self._payload_to_jsonable(payload, graph) + payload_dict["solidworks_canonical_cut_descriptors"] = ( + _canonical_cut_descriptors(graph) + ) + payload_dict["solidworks_canonical_union_passthroughs"] = ( + _canonical_union_passthroughs(graph) + ) + payload_dict["solidworks_canonical_union_descriptors"] = ( + _canonical_union_descriptors(graph) + ) + payload_dict["solidworks_canonical_union_input_indices"] = ( + _canonical_union_input_indices(graph) + ) + payload_dict["solidworks_canonical_loft_descriptors"] = ( + _canonical_loft_descriptors(graph) + ) + payload_dict["solidworks_canonical_sweep_descriptors"] = ( + _canonical_sweep_descriptors(graph) + ) + payload_dict["solidworks_source_kernel_result_step"] = ( + _source_kernel_result_step_payload(graph, self._result_node_id_list) + if self.source_kernel_fallback + else None + ) + if os.environ.get("SIMPLECAD_SW_PERSIST_TOPOLOGY", "1") != "0": + detail_edge_catalog = _canonical_detail_edge_catalog( + graph, self._result_node_id_list + ) + if detail_edge_catalog: + catalog_json = json.dumps( + detail_edge_catalog, + ensure_ascii=True, + separators=(",", ":"), + sort_keys=True, + ).encode("ascii") + payload_dict["solidworks_detail_edge_catalog_z"] = ( + base64.b64encode( + zlib.compress(catalog_json, level=9) + ).decode("ascii") + ) + + return ( + "from __future__ import annotations\n" + "\n" + "import base64\n" + "import glob\n" + "import hashlib\n" + "import json\n" + "import math\n" + "import os\n" + "import shutil\n" + "import time\n" + "import traceback\n" + "import zlib\n" + "\n" + "import pythoncom\n" + "import win32com.client\n" + "\n" + f"DOC_NAME = {_json_ascii(self.document_name)}\n" + f"VISIBLE = {_py_literal(self.visible)}\n" + f"MODEL_PAYLOAD = {_py_literal(payload_dict)}\n" + f"DECLARED_RESULT_NODE_IDS = {_py_literal(self._declared_result_node_id_list)}\n" + f"RESULT_STATE_NODE_IDS = {_py_literal(self._result_state_node_ids)}\n" + f"ACTIVE_RESULT_STATE = {_py_literal(self._active_result_state)}\n" + f"RESULT_NODE_IDS = {_py_literal(self._result_node_id_list)}\n" + f"OUTPUT_PATH = {_json_ascii(os.path.abspath(output_path)) if output_path else 'None'}\n" + "\n" + + self._runtime_helpers() + + "\n" + "def main():\n" + " pythoncom.CoInitialize()\n" + " runtime = None\n" + " try:\n" + " runtime = SimpleCADSolidWorksRuntime(MODEL_PAYLOAD, DOC_NAME, RESULT_NODE_IDS, visible=VISIBLE)\n" + " runtime.run(output_path=OUTPUT_PATH)\n" + " print(json.dumps({'document_name': DOC_NAME, 'output_path': OUTPUT_PATH, 'strategy': 'operation_graph_native'}))\n" + " finally:\n" + " if runtime is not None:\n" + " runtime.finish()\n" + " else:\n" + " pythoncom.CoUninitialize()\n" + "\n" + "\n" + "if __name__ == '__main__':\n" + " try:\n" + " main()\n" + " except Exception:\n" + " traceback.print_exc()\n" + " raise\n" + ) + + def _payload_to_jsonable( + self, payload: Dict[str, Any], source_graph: OperationGraph + ) -> Dict[str, Any]: + nodes: List[Dict[str, Any]] = [] + for node in source_graph.topological_order(): + params = dict(node.params) + if str(node.op) in {"make_angle_arc_redge", "make_circle_redge"}: + normal = params.get("normal") or (0.0, 0.0, 1.0) + frame = gp_Ax2( + gp_Pnt(0.0, 0.0, 0.0), + gp_Dir(*(float(value) for value in normal)), + ) + x_axis = frame.XDirection() + y_axis = frame.YDirection() + params["_kernel_x_axis"] = [ + float(x_axis.X()), + float(x_axis.Y()), + float(x_axis.Z()), + ] + params["_kernel_y_axis"] = [ + float(y_axis.X()), + float(y_axis.Y()), + float(y_axis.Z()), + ] + nodes.append( + { + "node_id": str(node.node_id), + "op": str(node.op), + "params": self._sanitize_payload_for_solidworks(params), + "inputs": [ + {"node_id": str(input_ref.node_id)} + for input_ref in node.inputs + ], + } + ) + return { + "schema_version": str(payload.get("schema_version", "2.0")), + "graph": { + "graph_id": str(getattr(source_graph, "graph_id", "graph")), + "nodes": nodes, + }, + "leaf_ids": [str(v) for v in payload.get("leaf_ids", [])], + } + + def _sanitize_payload_for_solidworks(self, value: Any) -> Any: + if isinstance(value, dict): + cleaned: Dict[str, Any] = {} + for key, child in value.items(): + if key in { + "selected_edge_indices", + "selected_face_indices", + "edge_index_param", + "face_index_param", + "topo_id", + }: + continue + if key == "metadata_geo": + child_cleaned = self._sanitize_payload_for_solidworks(child) + if isinstance(child_cleaned, dict): + child_cleaned = { + k: v + for k, v in child_cleaned.items() + if k not in {"edge_index", "face_index"} + } + if child_cleaned: + cleaned[key] = child_cleaned + continue + cleaned[key] = self._sanitize_payload_for_solidworks(child) + return cleaned + if isinstance(value, (list, tuple)): + return [self._sanitize_payload_for_solidworks(item) for item in value] + return value + + def _runtime_helpers(self) -> str: + return r''' +MM_TO_M = 0.001 +M_TO_MM = 1000.0 +M2_TO_MM2 = 1000000.0 +TOL = 1.0e-7 +MODEL_SCALE = 1.0 + +SW_DOC_PART = 1 +SW_SOLID_BODY = 0 +SW_OPEN_DOC_OPTIONS_SILENT = 1 +SW_SAVE_AS_CURRENT_VERSION = 0 +SW_SAVE_AS_OPTIONS_SILENT = 1 + +# swBodyOperationType_e values used by IBody2::Operations2 and InsertCombineFeature. +SWBODYINTERSECT = 15901 +SWBODYCUT = 15902 +SWBODYADD = 15903 + +# swTwistControlType_e. OCC's is_frenet=True transports the section with the +# helix frame. SolidWorks' plain Follow Path does not preserve that radial +# frame on a long helix; combine Keep Normal Constant with an explicit twist. +SW_TWIST_CONTROL_NORMAL_CONSTANT_TWIST = 9 +SW_FM_SWEEP = 17 + +# Keep the fillet local to the explicitly GSM-selected edges. Bit 1 is +# swFeatureFilletPropagate and would expand the operation along tangent chains. +SW_FEATURE_FILLET_OPTIONS = 194 + + +def _native_part_output_path(output_path): + step_path = os.path.abspath(output_path) + step_directory = os.path.dirname(step_path) + if os.path.basename(step_directory).lower() == 'steps': + native_directory = os.path.join( + os.path.dirname(step_directory), 'sldprt' + ) + else: + native_directory = step_directory + stem = os.path.splitext(os.path.basename(step_path))[0] + return os.path.join(native_directory, stem + '.sldprt') + + +def _native_assembly_output_path(output_path): + step_path = os.path.abspath(output_path) + step_directory = os.path.dirname(step_path) + if os.path.basename(step_directory).lower() == 'steps': + native_directory = os.path.join( + os.path.dirname(step_directory), 'sldasm' + ) + else: + native_directory = step_directory + stem = os.path.splitext(os.path.basename(step_path))[0] + return os.path.join(native_directory, stem + '.sldasm') + + +class SimpleCADUnsupportedOpError(RuntimeError): + pass + + +def _empty_dispatch(): + return win32com.client.VARIANT(pythoncom.VT_DISPATCH, None) + + +def _byref_i4(value=0): + return win32com.client.VARIANT(pythoncom.VT_BYREF | pythoncom.VT_I4, int(value)) + + +def _maybe_call(value): + return value() if callable(value) else value + + +def _v3(value, default=(0.0, 0.0, 0.0)): + if not isinstance(value, (list, tuple)) or len(value) != 3: + value = default + return (float(value[0]), float(value[1]), float(value[2])) + + +def _add(a, b): + return (float(a[0]) + float(b[0]), float(a[1]) + float(b[1]), float(a[2]) + float(b[2])) + + +def _sub(a, b): + return (float(a[0]) - float(b[0]), float(a[1]) - float(b[1]), float(a[2]) - float(b[2])) + + +def _mul(a, scalar): + return (float(a[0]) * scalar, float(a[1]) * scalar, float(a[2]) * scalar) + + +def _dot(a, b): + return float(a[0]) * float(b[0]) + float(a[1]) * float(b[1]) + float(a[2]) * float(b[2]) + + +def _cross(a, b): + return ( + float(a[1]) * float(b[2]) - float(a[2]) * float(b[1]), + float(a[2]) * float(b[0]) - float(a[0]) * float(b[2]), + float(a[0]) * float(b[1]) - float(a[1]) * float(b[0]), + ) + + +def _norm(a): + return math.sqrt(_dot(a, a)) + + +def _unit(a, fallback=(0.0, 0.0, 1.0)): + length = _norm(_v3(a, fallback)) + if length <= 1.0e-12: + return _v3(fallback) + x, y, z = _v3(a, fallback) + return (x / length, y / length, z / length) + + +def _distance(a, b): + return _norm(_sub(_v3(a), _v3(b))) + + +def _point_line_distance(point, origin, direction): + direction = _unit(direction) + return _norm(_cross(_sub(_v3(point), _v3(origin)), direction)) + + +def _rotate_point_payload(point, axis, angle_degrees, origin): + axis = _unit(axis) + relative = _sub(_v3(point), _v3(origin)) + angle = math.radians(float(angle_degrees)) + rotated = _add( + _add( + _mul(relative, math.cos(angle)), + _mul(_cross(axis, relative), math.sin(angle)), + ), + _mul(axis, _dot(axis, relative) * (1.0 - math.cos(angle))), + ) + return _add(_v3(origin), rotated) + + +def _rotate_direction_payload(direction, axis, angle_degrees): + return _sub( + _rotate_point_payload(direction, axis, angle_degrees, (0.0, 0.0, 0.0)), + _rotate_point_payload((0.0, 0.0, 0.0), axis, angle_degrees, (0.0, 0.0, 0.0)), + ) + + +def _mirror_point_payload(point, plane_origin, plane_normal): + normal = _unit(plane_normal) + offset = _sub(_v3(point), _v3(plane_origin)) + return _sub(_v3(point), _mul(normal, 2.0 * _dot(offset, normal))) + + +def _mirror_direction_payload(direction, plane_normal): + normal = _unit(plane_normal) + direction = _v3(direction) + return _sub(direction, _mul(normal, 2.0 * _dot(direction, normal))) + + +def _transform_geometry_value(value, point_transform, direction_transform): + if not isinstance(value, dict): + raise RuntimeError( + f'Expected transformable SimpleCAD geometry, got {type(value).__name__}' + ) + kind = value.get('kind') + transformed = dict(value) + if kind == 'wire': + transformed['edges'] = [ + _transform_geometry_value(edge, point_transform, direction_transform) + for edge in value.get('edges') or [] + ] + return transformed + if kind == 'face': + transformed['outer'] = _transform_geometry_value( + value.get('outer'), point_transform, direction_transform + ) + transformed['inners'] = [ + _transform_geometry_value(wire, point_transform, direction_transform) + for wire in value.get('inners') or [] + ] + if value.get('normal') is not None: + transformed['normal'] = _unit(direction_transform(value.get('normal'))) + return transformed + if kind != 'edge': + raise RuntimeError(f'Unsupported non-body transform value kind: {kind!r}') + + edge_type = value.get('type') + for key in ('start', 'middle', 'end', 'center'): + if value.get(key) is not None: + transformed[key] = point_transform(value.get(key)) + if edge_type == 'spline': + transformed['controls'] = [ + point_transform(point) for point in value.get('controls') or [] + ] + for key in ('normal', '_kernel_x_axis', '_kernel_y_axis'): + if value.get(key) is not None: + transformed[key] = _unit(direction_transform(value.get(key))) + if edge_type == 'helix': + helix_params = dict(value.get('params') or {}) + for key in ('center', 'origin'): + if helix_params.get(key) is not None: + helix_params[key] = point_transform(helix_params.get(key)) + for key in ('dir', 'axis', 'normal'): + if helix_params.get(key) is not None: + helix_params[key] = _unit(direction_transform(helix_params.get(key))) + transformed['params'] = helix_params + return transformed + + +def _plane_axes(normal): + normal = _unit(normal) + reference = (1.0, 0.0, 0.0) + if abs(_dot(normal, reference)) > 0.9: + reference = (0.0, 1.0, 0.0) + x_axis = _sub(reference, _mul(normal, _dot(reference, normal))) + x_axis = _unit(x_axis) + y_axis = _unit(_cross(normal, x_axis)) + return x_axis, y_axis + + +def _angle_arc_axes(normal): + normal = _unit(normal) + reference = (1.0, 0.0, 0.0) if abs(normal[2]) > 0.9 else (0.0, 0.0, 1.0) + x_axis = _cross(normal, reference) + if _norm(x_axis) <= 1.0e-12: + x_axis = _cross(normal, (0.0, 1.0, 0.0)) + x_axis = _unit(x_axis) + y_axis = _unit(_cross(normal, x_axis)) + return x_axis, y_axis + + +def _angle_arc_world_point( + center, + radius, + angle, + normal, + x_axis=None, + y_axis=None, +): + if x_axis is None or y_axis is None: + x_axis, y_axis = _angle_arc_axes(normal) + else: + x_axis = _unit(x_axis) + y_axis = _unit(y_axis) + radial = _add( + _mul(x_axis, float(radius) * math.cos(float(angle))), + _mul(y_axis, float(radius) * math.sin(float(angle))), + ) + return _add(_v3(center), radial) + + +def _model_work_scale(payload): + lengths = [] + + def add_length(value): + try: + value = abs(float(value)) + except (TypeError, ValueError): + return + if math.isfinite(value) and value > 1.0e-9: + lengths.append(value) + + for node in ((payload.get('graph') or {}).get('nodes') or []): + op = str(node.get('op') or '') + params = node.get('params') or {} + if op == 'make_line_redge': + add_length(_distance(params.get('start'), params.get('end'))) + elif op in {'make_circle_redge', 'make_angle_arc_redge'}: + add_length(params.get('radius')) + elif op == 'make_three_point_arc_redge': + points = [params.get('start'), params.get('middle'), params.get('end')] + for first, second in zip(points, points[1:]): + add_length(_distance(first, second)) + elif op == 'make_spline_redge': + points = params.get('control_points') or params.get('controls') or params.get('points') or [] + for first, second in zip(points, points[1:]): + add_length(_distance(first, second)) + elif op == 'make_helix_redge': + for key in ('radius', 'pitch', 'height'): + add_length(params.get(key)) + elif op in { + 'make_extrude_rsolid', 'make_fillet_rsolid', 'make_chamfer_rsolid', + 'make_shell_rsolid', 'make_box_rsolid', 'make_cylinder_rsolid', + 'make_cone_rsolid', 'make_sphere_rsolid', + }: + for key in ( + 'distance', 'radius', 'radius1', 'radius2', 'thickness', + 'length', 'width', 'height', + ): + add_length(params.get(key)) + + if not lengths: + return 1.0 + minimum = min(lengths) + # SolidWorks rejects sketch entities below roughly one millimetre. Build at + # a stable working size, then restore the canonical model size before export. + return min(1000000.0, max(1.0, 5.0 / minimum)) + + +def _as_m(value): + return float(value) * MODEL_SCALE * MM_TO_M + + +def _pt_m(value): + x, y, z = _v3(value) + return ( + x * MODEL_SCALE * MM_TO_M, + y * MODEL_SCALE * MM_TO_M, + z * MODEL_SCALE * MM_TO_M, + ) + + +def _identity_matrix(): + return [ + 1.0, 0.0, 0.0, + 0.0, 1.0, 0.0, + 0.0, 0.0, 1.0, + 0.0, 0.0, 0.0, + 1.0, + 0.0, 0.0, 0.0, + ] + + +def _translation_matrix(vector_mm): + tx, ty, tz = _pt_m(vector_mm) + data = _identity_matrix() + data[9] = tx + data[10] = ty + data[11] = tz + return data + + +def _solidworks_rotation_angle_index(principal_index): + principal_index = int(principal_index) + if principal_index not in (0, 1, 2): + raise ValueError( + f'Invalid principal rotation axis index: {principal_index}' + ) + return 2 - principal_index + + +def _scale_about_bbox_matrix(bbox, factor): + center = _bbox_center(bbox) + cx, cy, cz = _pt_m(center) + factor = float(factor) + data = _identity_matrix() + data[9] = (1.0 - factor) * cx + data[10] = (1.0 - factor) * cy + data[11] = (1.0 - factor) * cz + data[12] = factor + return data + + +def _rotation_matrix(axis, angle_degrees, origin): + ax, ay, az = _unit(axis) + angle = math.radians(float(angle_degrees)) + c = math.cos(angle) + s = math.sin(angle) + t = 1.0 - c + r00 = t * ax * ax + c + r01 = t * ax * ay - s * az + r02 = t * ax * az + s * ay + r10 = t * ax * ay + s * az + r11 = t * ay * ay + c + r12 = t * ay * az - s * ax + r20 = t * ax * az - s * ay + r21 = t * ay * az + s * ax + r22 = t * az * az + c + ox, oy, oz = _pt_m(origin) + tx = ox - (r00 * ox + r01 * oy + r02 * oz) + ty = oy - (r10 * ox + r11 * oy + r12 * oz) + tz = oz - (r20 * ox + r21 * oy + r22 * oz) + # IMathTransform stores the rotation basis for row-vector multiplication. + # Transpose the conventional column-vector Rodrigues matrix while keeping + # the world-space pivot translation unchanged. + return [r00, r10, r20, r01, r11, r21, r02, r12, r22, tx, ty, tz, 1.0, 0.0, 0.0, 0.0] + + +def _mirror_matrix(plane_origin, plane_normal): + nx, ny, nz = _unit(plane_normal) + px, py, pz = _pt_m(plane_origin) + d = -(nx * px + ny * py + nz * pz) + r00 = 1.0 - 2.0 * nx * nx + r01 = -2.0 * nx * ny + r02 = -2.0 * nx * nz + r10 = -2.0 * ny * nx + r11 = 1.0 - 2.0 * ny * ny + r12 = -2.0 * ny * nz + r20 = -2.0 * nz * nx + r21 = -2.0 * nz * ny + r22 = 1.0 - 2.0 * nz * nz + tx = -2.0 * d * nx + ty = -2.0 * d * ny + tz = -2.0 * d * nz + return [r00, r01, r02, r10, r11, r12, r20, r21, r22, tx, ty, tz, 1.0, 0.0, 0.0, 0.0] + + +def _placement_matrix(placement): + if isinstance(placement, dict) and placement.get('kind') == 'placement': + placement = placement.get('params') or {} + if not isinstance(placement, dict): + placement = {} + origin = _v3(placement.get('origin') or (0.0, 0.0, 0.0)) + x_axis = _unit(placement.get('x_axis') or (1.0, 0.0, 0.0)) + y_axis = _unit(placement.get('y_axis') or (0.0, 1.0, 0.0)) + z_axis = _unit(placement.get('z_axis') or _cross(x_axis, y_axis)) + tx, ty, tz = _pt_m(origin) + # SimpleCAD axes are columns of the local-to-world transform. SolidWorks + # multiplies row vectors, so write the transposed basis just as in + # _rotation_matrix. + return [ + x_axis[0], x_axis[1], x_axis[2], + y_axis[0], y_axis[1], y_axis[2], + z_axis[0], z_axis[1], z_axis[2], + tx, ty, tz, + 1.0, + 0.0, 0.0, 0.0, + ] + + +def _multiply_transform_matrices(first, second): + """Compose two SolidWorks row-vector affine transforms.""" + if len(first) < 13 or len(second) < 13: + raise ValueError('SolidWorks transforms require at least 13 values') + first_scale = float(first[12]) + second_scale = float(second[12]) + first_rotation = [float(value) for value in first[:9]] + second_rotation = [float(value) for value in second[:9]] + rotation = [ + sum( + first_rotation[row * 3 + inner] + * second_rotation[inner * 3 + column] + for inner in range(3) + ) + for row in range(3) + for column in range(3) + ] + first_translation = [float(value) for value in first[9:12]] + second_translation = [float(value) for value in second[9:12]] + translation = [ + sum( + first_translation[inner] + * second_rotation[inner * 3 + column] + * second_scale + for inner in range(3) + ) + + second_translation[column] + for column in range(3) + ] + return rotation + translation + [ + first_scale * second_scale, 0.0, 0.0, 0.0 + ] + + +def _assembly_component_matrix(placements): + matrix = _identity_matrix() + # Product traversal records placements outermost first. Body transforms are + # applied innermost first, so preserve that same order for Component2. + for placement in reversed(tuple(placements or ())): + matrix = _multiply_transform_matrices( + matrix, _placement_matrix(placement) + ) + if MODEL_SCALE > 0.0: + for index in (9, 10, 11): + matrix[index] = float(matrix[index]) / MODEL_SCALE + return matrix + + +def _transform_matrices_close(first, second): + if len(first) < 13 or len(second) < 13: + return False + scale = max( + 1.0, + *(abs(float(value)) for value in first[:13]), + *(abs(float(value)) for value in second[:13]), + ) + tolerance = scale * 1.0e-8 + return all( + abs(float(first[index]) - float(second[index])) <= tolerance + for index in range(13) + ) + + +def _is_identity_matrix(matrix_data, tolerance=1.0e-10): + identity = _identity_matrix() + return len(matrix_data) == len(identity) and all( + abs(float(actual) - float(expected)) <= tolerance + for actual, expected in zip(matrix_data, identity) + ) + + +def _is_translation_matrix(matrix_data, tolerance=1.0e-12): + identity = _identity_matrix() + return ( + len(matrix_data) >= 16 + and all( + abs(float(matrix_data[index]) - float(identity[index])) + <= tolerance + for index in range(9) + ) + and abs(float(matrix_data[12]) - 1.0) <= tolerance + and all( + abs(float(matrix_data[index])) <= tolerance + for index in (13, 14, 15) + ) + ) + + +def _bbox_from_box(box): + if not box or len(box) < 6: + return None + return { + 'min': tuple(float(box[i]) * M_TO_MM / MODEL_SCALE for i in range(3)), + 'max': tuple(float(box[i]) * M_TO_MM / MODEL_SCALE for i in range(3, 6)), + } + + +def _bbox_center(bbox): + return tuple((float(bbox['min'][i]) + float(bbox['max'][i])) * 0.5 for i in range(3)) + + +def _bbox_score(candidate_bbox, selector_bbox): + if not isinstance(candidate_bbox, dict) or not isinstance(selector_bbox, dict): + return 0.0 + expected_min = selector_bbox.get('min') + expected_max = selector_bbox.get('max') + actual_min = candidate_bbox.get('min') + actual_max = candidate_bbox.get('max') + if not all(isinstance(value, (list, tuple)) and len(value) == 3 for value in ( + expected_min, expected_max, actual_min, actual_max, + )): + return 1.0e6 + return _distance(actual_min, expected_min) + _distance(actual_max, expected_max) + + +def _bbox_intersects(first, second, tolerance=1.0e-7): + if not isinstance(first, dict) or not isinstance(second, dict): + return False + first_min = first.get('min') + first_max = first.get('max') + second_min = second.get('min') + second_max = second.get('max') + if not all( + isinstance(value, (list, tuple)) and len(value) == 3 + for value in (first_min, first_max, second_min, second_max) + ): + return False + return all( + float(first_max[index]) + tolerance >= float(second_min[index]) + and float(second_max[index]) + tolerance >= float(first_min[index]) + for index in range(3) + ) + + +def _bbox_axis_gaps(first, second): + if not isinstance(first, dict) or not isinstance(second, dict): + return (float('inf'), float('inf'), float('inf')) + first_min = first.get('min') + first_max = first.get('max') + second_min = second.get('min') + second_max = second.get('max') + if not all( + isinstance(value, (list, tuple)) and len(value) == 3 + for value in (first_min, first_max, second_min, second_max) + ): + return (float('inf'), float('inf'), float('inf')) + return tuple( + max( + 0.0, + float(second_min[index]) - float(first_max[index]), + float(first_min[index]) - float(second_max[index]), + ) + for index in range(3) + ) + + +def _selector_geometry(selector): + params = selector.get('params') if isinstance(selector, dict) else None + if isinstance(params, dict): + selector = params + if not isinstance(selector, dict): + return {} + geo_selector = selector.get('geo_selector') + if isinstance(geo_selector, dict): + return dict(selector, **geo_selector) + return selector + + +def _tuple3_or_none(value): + if isinstance(value, (list, tuple)) and len(value) == 3: + return (float(value[0]), float(value[1]), float(value[2])) + return None + + +def _canonical_geom_type(value): + text = str(value or '').upper().replace('_TYPE', '').replace('_', '') + aliases = { + 'B-SPLINE': 'BSPLINE', + 'BSPLINE': 'BSPLINE', + 'BCURVE': 'BSPLINE', + 'BSURF': 'BSPLINE', + 'NURBS': 'BSPLINE', + 'BEZIERCURVE': 'BEZIER', + 'BEZIER': 'BEZIER', + 'ELLIPTICALARC': 'ELLIPSE', + 'PLANESURFACE': 'PLANE', + 'CYLINDERSURFACE': 'CYLINDER', + 'CONESURFACE': 'CONE', + 'SPHERESURFACE': 'SPHERE', + 'TORUSSURFACE': 'TORUS', + } + return aliases.get(text, text) + + +def _selector_geom_type(selector): + selector = _selector_geometry(selector) + geom_type = _canonical_geom_type( + selector.get('geom_type') or selector.get('surface_type') + ) + kind = str(selector.get('kind') or selector.get('target_kind') or '').lower() + if geom_type not in {'BSPLINE', 'BEZIER'} or kind != 'edge': + return geom_type + start = _tuple3_or_none(selector.get('start')) + end = _tuple3_or_none(selector.get('end')) + expected_length = selector.get('length') + if start is None or end is None or expected_length is None: + return geom_type + chord_length = _distance(start, end) + edge_length = float(expected_length) + scale = max(1.0, chord_length, edge_length) + if ( + chord_length > scale * 1.0e-10 + and abs(edge_length - chord_length) <= scale * 1.0e-7 + ): + return 'LINE' + return geom_type + + +def _signature_geom_type(sig, selector): + geom_type = _canonical_geom_type(sig.get('geom_type')) + kind = _selector_kind(selector, sig) + if geom_type not in {'BSPLINE', 'BEZIER'} or kind != 'edge': + return geom_type + start = _tuple3_or_none(sig.get('start')) + end = _tuple3_or_none(sig.get('end')) + edge_length = sig.get('length') + if start is None or end is None or edge_length is None: + return geom_type + chord_length = _distance(start, end) + edge_length = float(edge_length) + scale = max(1.0, chord_length, edge_length) + if ( + chord_length > scale * 1.0e-10 + and abs(edge_length - chord_length) <= scale * 1.0e-7 + ): + return 'LINE' + return geom_type + + +def _geom_type_mismatch(sig, selector): + selector = _selector_geometry(selector) + target = _selector_geom_type(selector) + actual = _signature_geom_type(sig, selector) + return int(bool(target and actual and target != actual)) + + +def _selector_length_scale(selector): + selector = _selector_geometry(selector) + bbox = selector.get('bbox') + if isinstance(bbox, dict): + minimum = _tuple3_or_none(bbox.get('min')) + maximum = _tuple3_or_none(bbox.get('max')) + if minimum is not None and maximum is not None: + return max(1.0, _distance(minimum, maximum)) + return 1.0 + + +def _relative_error(actual, expected, floor=1.0): + return abs(float(actual) - float(expected)) / max(float(floor), abs(float(expected))) + + +def _bbox_selector_score(sig, selector): + selector = _selector_geometry(selector) + candidate_bbox = sig.get('bbox') + expected_bbox = selector.get('bbox') + if not isinstance(candidate_bbox, dict) or not isinstance(expected_bbox, dict): + return 0.0 + actual_min = _tuple3_or_none(candidate_bbox.get('min')) + actual_max = _tuple3_or_none(candidate_bbox.get('max')) + expected_min = _tuple3_or_none(expected_bbox.get('min')) + expected_max = _tuple3_or_none(expected_bbox.get('max')) + if any(value is None for value in (actual_min, actual_max, expected_min, expected_max)): + return 1.0e6 + scale = _selector_length_scale(selector) + return ( + _distance(actual_min, expected_min) + _distance(actual_max, expected_max) + ) / scale + + +def _selector_kind(selector, sig): + selector = _selector_geometry(selector) + kind = str(selector.get('kind') or selector.get('target_kind') or '').lower() + if kind: + return kind + if 'length' in selector or 'length' in sig: + return 'edge' + if 'area' in selector or 'area' in sig: + return 'face' + return '' + + +def _geom_score(sig, selector): + selector = _selector_geometry(selector) + score = _bbox_selector_score(sig, selector) * 10.0 + if _geom_type_mismatch(sig, selector): + score += 20.0 + scale = _selector_length_scale(selector) + kind = _selector_kind(selector, sig) + expected_center = _tuple3_or_none(selector.get('center')) + candidate_center = _tuple3_or_none(sig.get('center')) + if expected_center is not None and candidate_center is not None: + score += _distance(candidate_center, expected_center) / scale * 3.0 + if kind == 'edge': + if selector.get('length') is not None and sig.get('length') is not None: + score += _relative_error(sig['length'], selector['length']) * 3.0 + expected_start = _tuple3_or_none(selector.get('start')) + expected_end = _tuple3_or_none(selector.get('end')) + candidate_start = _tuple3_or_none(sig.get('start')) + candidate_end = _tuple3_or_none(sig.get('end')) + if all(value is not None for value in (expected_start, expected_end, candidate_start, candidate_end)): + same = _distance(candidate_start, expected_start) + _distance(candidate_end, expected_end) + reverse = _distance(candidate_start, expected_end) + _distance(candidate_end, expected_start) + score += min(same, reverse) / scale + elif kind == 'face': + if selector.get('area') is not None and sig.get('area') is not None: + score += _relative_error(sig['area'], selector['area']) * 3.0 + if _selector_geom_type(selector) == 'PLANE': + expected_normal = _tuple3_or_none(selector.get('normal')) + candidate_normal = _tuple3_or_none(sig.get('normal')) + if expected_normal is not None and candidate_normal is not None: + alignment = abs(_dot(_unit(expected_normal), _unit(candidate_normal))) + score += (1.0 - min(1.0, alignment)) * 2.0 + return score + + +def _best_by_geometry(signatures, selector, label, topology_match=None): + if not signatures: + raise RuntimeError(f'No {label} candidates available for geometry selection') + selector = _selector_geometry(selector) + ranked = sorted( + signatures, + key=lambda item: _geom_score(item[1], selector), + ) + + exact_spatial_matches = [ + candidate + for candidate, signature in ranked + if _bbox_selector_score(signature, selector) <= 1.0e-6 + and ( + not _selector_geom_type(selector) + or _signature_geom_type(signature, selector) == _selector_geom_type(selector) + ) + ] + if len(exact_spatial_matches) == 1: + return exact_spatial_matches[0] + + kind = _selector_kind(selector, ranked[0][1]) + expected_center = _tuple3_or_none(selector.get('center')) + expected_measure = selector.get('length') if kind == 'edge' else selector.get('area') + scale = _selector_length_scale(selector) + + def intrinsic_matches(center_tolerance, measure_tolerance): + matches = [] + if expected_center is None or expected_measure is None: + return matches + measure_key = 'length' if kind == 'edge' else 'area' + for candidate, signature in ranked: + if ( + _selector_geom_type(selector) + and _signature_geom_type(signature, selector) != _selector_geom_type(selector) + ): + continue + candidate_center = _tuple3_or_none(signature.get('center')) + candidate_measure = signature.get(measure_key) + if candidate_center is None or candidate_measure is None: + continue + center_error = _distance(candidate_center, expected_center) / scale + measure_error = _relative_error(candidate_measure, expected_measure) + if center_error <= center_tolerance and measure_error <= measure_tolerance: + matches.append(candidate) + return matches + + exact_intrinsic_matches = intrinsic_matches(1.0e-5, 1.0e-4) + if len(exact_intrinsic_matches) == 1: + return exact_intrinsic_matches[0] + approximate_intrinsic_matches = intrinsic_matches(1.0e-3, 1.0e-3) + if len(approximate_intrinsic_matches) == 1: + return approximate_intrinsic_matches[0] + if topology_match is not None: + topology_candidate = topology_match(ranked, selector) + if topology_candidate is not None: + return topology_candidate + + best, best_signature = ranked[0] + best_score = _geom_score(best_signature, selector) + second_score = ( + _geom_score(ranked[1][1], selector) if len(ranked) > 1 else float('inf') + ) + exact_match = best_score <= 1.0e-4 + acceptable_match = best_score <= 1.5 + clearly_better = ( + second_score == float('inf') + or second_score - best_score >= max(0.1, best_score * 0.15) + ) + type_mismatch = _geom_type_mismatch(best_signature, selector) + if not (exact_match or (acceptable_match and clearly_better)): + nearest = [ + { + 'score': _geom_score(signature, selector), + 'type_mismatch': _geom_type_mismatch(signature, selector), + 'signature': signature, + } + for _candidate, signature in ranked[:3] + ] + raise RuntimeError( + f'Geometry selector did not match a stable {label}; ' + f'best score={best_score}; second score={second_score}; ' + f'type_mismatch={type_mismatch}; selector={selector!r}; ' + f'nearest={nearest!r}' + ) + return best + + +def _directions_parallel(left, right, tolerance=1.0e-6): + left = _tuple3_or_none(left) + right = _tuple3_or_none(right) + if left is None or right is None: + return False + left = _unit(left) + right = _unit(right) + return abs(_dot(left, right)) >= 1.0 - tolerance + + +def _selector_endpoints_on_circle_support(selector, support, tolerance): + if not isinstance(support, dict): + return False + center = _tuple3_or_none(support.get('center')) + axis = _tuple3_or_none(support.get('axis')) + radius = support.get('radius') + selector = _selector_geometry(selector) + endpoints = ( + _tuple3_or_none(selector.get('start')), + _tuple3_or_none(selector.get('end')), + ) + if ( + center is None + or axis is None + or radius is None + or any(point is None for point in endpoints) + ): + return False + axis = _unit(axis) + radius = abs(float(radius)) + for point in endpoints: + offset = _sub(point, center) + if abs(_dot(offset, axis)) > tolerance: + return False + radial = _sub(offset, _mul(axis, _dot(offset, axis))) + if abs(_norm(radial) - radius) > tolerance: + return False + return True + + +def _same_edge_signature_support(first, second, selector, scale): + geom_type = _signature_geom_type(first, selector) + second_geom_type = _signature_geom_type(second, selector) + selector_geom_type = _selector_geom_type(selector) + parametric_ellipse_fragments = ( + selector_geom_type == 'ELLIPSE' + and geom_type in {'BSPLINE', 'BEZIER'} + and second_geom_type in {'BSPLINE', 'BEZIER'} + ) + if geom_type != second_geom_type and not parametric_ellipse_fragments: + return False + tolerance = max(1.0e-7, float(scale) * 1.0e-5) + if parametric_ellipse_fragments: + connections = [] + for first_key, first_tangent_key in ( + ('start', 'start_tangent'), + ('end', 'end_tangent'), + ): + first_point = _tuple3_or_none(first.get(first_key)) + first_tangent = _tuple3_or_none(first.get(first_tangent_key)) + for second_key, second_tangent_key in ( + ('start', 'start_tangent'), + ('end', 'end_tangent'), + ): + second_point = _tuple3_or_none(second.get(second_key)) + second_tangent = _tuple3_or_none(second.get(second_tangent_key)) + if ( + first_point is not None + and second_point is not None + and first_tangent is not None + and second_tangent is not None + and _distance(first_point, second_point) <= tolerance + ): + connections.append((first_tangent, second_tangent)) + if len(connections) != 1 or not _directions_parallel( + connections[0][0], connections[0][1], tolerance=1.0e-4 + ): + return False + first_samples = [ + _tuple3_or_none(point) for point in first.get('samples') or () + ] + second_samples = [ + _tuple3_or_none(point) for point in second.get('samples') or () + ] + if ( + len(first_samples) < 3 + or len(second_samples) < 3 + or any(point is None for point in first_samples + second_samples) + ): + return False + origin = first_samples[0] + offsets = [_sub(point, origin) for point in first_samples[1:]] + plane_normal = None + plane_normal_length = 0.0 + for left_index, left in enumerate(offsets): + for right in offsets[left_index + 1:]: + normal = _cross(left, right) + length = _norm(normal) + if length > plane_normal_length: + plane_normal = normal + plane_normal_length = length + if ( + plane_normal is None + or plane_normal_length + <= max(1.0e-12, float(scale) * float(scale) * 1.0e-10) + ): + return False + return all( + abs(_dot(_sub(point, origin), plane_normal)) + / plane_normal_length + <= tolerance + for point in second_samples + ) + if geom_type == 'LINE': + first_start = _tuple3_or_none(first.get('start')) + first_end = _tuple3_or_none(first.get('end')) + second_start = _tuple3_or_none(second.get('start')) + second_end = _tuple3_or_none(second.get('end')) + if any( + point is None + for point in (first_start, first_end, second_start, second_end) + ): + return False + first_direction = _sub(first_end, first_start) + second_direction = _sub(second_end, second_start) + if not _directions_parallel(first_direction, second_direction): + return False + direction_length = _norm(first_direction) + if direction_length <= 1.0e-12: + return False + return all( + _norm(_cross(_sub(point, first_start), first_direction)) + / direction_length + <= tolerance + for point in (second_start, second_end) + ) + if geom_type not in {'CIRCLE', 'ELLIPSE'}: + return False + first_support = first.get('support') + second_support = second.get('support') + if not isinstance(first_support, dict) or not isinstance(second_support, dict): + return False + first_center = _tuple3_or_none(first_support.get('center')) + second_center = _tuple3_or_none(second_support.get('center')) + if ( + first_center is None + or second_center is None + or _distance(first_center, second_center) > tolerance + or not _directions_parallel( + first_support.get('axis'), second_support.get('axis') + ) + ): + return False + radius_keys = ('radius',) if geom_type == 'CIRCLE' else ('major_radius', 'minor_radius') + return all( + first_support.get(key) is not None + and second_support.get(key) is not None + and abs(float(first_support[key]) - float(second_support[key])) <= tolerance + for key in radius_keys + ) + + +def _edge_signature_within_selector_bbox(signature, selector, tolerance): + candidate_bbox = signature.get('bbox') + expected_bbox = selector.get('bbox') if isinstance(selector, dict) else None + if not isinstance(candidate_bbox, dict) or not isinstance(expected_bbox, dict): + return False + actual_min = _tuple3_or_none(candidate_bbox.get('min')) + actual_max = _tuple3_or_none(candidate_bbox.get('max')) + expected_min = _tuple3_or_none(expected_bbox.get('min')) + expected_max = _tuple3_or_none(expected_bbox.get('max')) + if any( + value is None + for value in (actual_min, actual_max, expected_min, expected_max) + ): + return False + return all( + expected_min[index] - tolerance <= actual_min[index] + and actual_max[index] <= expected_max[index] + tolerance + for index in range(3) + ) + + +def _fragment_signature_group_bbox_score(group, selector): + expected_bbox = selector.get('bbox') if isinstance(selector, dict) else None + if not isinstance(expected_bbox, dict): + return 1.0e6 + expected_min = _tuple3_or_none(expected_bbox.get('min')) + expected_max = _tuple3_or_none(expected_bbox.get('max')) + if expected_min is None or expected_max is None: + return 1.0e6 + actual_min = tuple( + min(float(signature['bbox']['min'][index]) for _candidate, signature in group) + for index in range(3) + ) + actual_max = tuple( + max(float(signature['bbox']['max'][index]) for _candidate, signature in group) + for index in range(3) + ) + scale = _selector_length_scale(selector) + return ( + _distance(actual_min, expected_min) + _distance(actual_max, expected_max) + ) / scale + + +def _fragment_signature_group_matches(group, selector): + if len(group) < 2: + return False + expected_length = selector.get('length') + expected_center = _tuple3_or_none(selector.get('center')) + if expected_length is None or expected_center is None: + return False + total_length = sum(float(signature['length']) for _candidate, signature in group) + if _relative_error(total_length, expected_length) > 1.0e-4: + return False + if _fragment_signature_group_bbox_score(group, selector) > 1.0e-4: + return False + if total_length <= 1.0e-12: + return False + weighted_center = [0.0, 0.0, 0.0] + for _candidate, signature in group: + center = _tuple3_or_none(signature.get('center')) + if center is None: + return False + weight = float(signature['length']) + for index in range(3): + weighted_center[index] += center[index] * weight + weighted_center = tuple(value / total_length for value in weighted_center) + return ( + _distance(weighted_center, expected_center) / _selector_length_scale(selector) + <= 1.0e-4 + ) + + +def _fragmented_edge_group(signatures, selector, label): + selector = _selector_geometry(selector) + if _selector_kind(selector, {}) != 'edge': + return None + expected_type = _selector_geom_type(selector) + expected_start = _tuple3_or_none(selector.get('start')) + expected_end = _tuple3_or_none(selector.get('end')) + expected_length = selector.get('length') + if ( + not expected_type + or expected_start is None + or expected_end is None + or expected_length is None + ): + return None + scale = _selector_length_scale(selector) + connection_tolerance = max(1.0e-7, scale * 1.0e-5) + if _distance(expected_start, expected_end) <= connection_tolerance: + return None + ranked = sorted(signatures, key=lambda item: _geom_score(item[1], selector)) + if len(ranked) < 2: + return None + eligible = [] + for candidate_index, (candidate, signature) in enumerate(ranked): + signature_type = _signature_geom_type(signature, selector) + type_matches = signature_type == expected_type or ( + expected_type == 'ELLIPSE' + and signature_type in {'BSPLINE', 'BEZIER'} + ) + if not type_matches: + continue + if float(signature.get('length', 0.0)) >= float(expected_length) * (1.0 - 1.0e-6): + continue + if not _edge_signature_within_selector_bbox( + signature, selector, connection_tolerance + ): + continue + start = _tuple3_or_none(signature.get('start')) + end = _tuple3_or_none(signature.get('end')) + if start is not None and end is not None: + eligible.append((candidate_index, candidate, signature, (start, end))) + if len(eligible) < 2: + return None + valid_groups = {} + for start_index, start_candidate, start_signature, endpoints in eligible: + starts = [] + if _distance(endpoints[0], expected_start) <= connection_tolerance: + starts.append(endpoints[1]) + if _distance(endpoints[1], expected_start) <= connection_tolerance: + starts.append(endpoints[0]) + for current_point in starts: + stack = [( + [start_index], + [(start_candidate, start_signature)], + current_point, + )] + while stack: + path_indices, path_group, point = stack.pop() + if _distance(point, expected_end) <= connection_tolerance: + if _fragment_signature_group_matches(path_group, selector): + key = tuple(sorted(path_indices)) + valid_groups[key] = [candidate for candidate, _signature in path_group] + continue + if len(path_indices) >= min(12, len(eligible)): + continue + for candidate_index, candidate, signature, candidate_endpoints in eligible: + if candidate_index in path_indices: + continue + if not _same_edge_signature_support( + path_group[-1][1], signature, selector, scale + ): + continue + next_points = [] + if _distance(candidate_endpoints[0], point) <= connection_tolerance: + next_points.append(candidate_endpoints[1]) + if _distance(candidate_endpoints[1], point) <= connection_tolerance: + next_points.append(candidate_endpoints[0]) + for next_point in next_points: + stack.append(( + path_indices + [candidate_index], + path_group + [(candidate, signature)], + next_point, + )) + if len(valid_groups) == 1: + return next(iter(valid_groups.values())) + if len(valid_groups) > 1: + raise RuntimeError( + f'Fragmented edge selector is ambiguous; label={label!r}, ' + f'selector={selector!r}, groups={sorted(valid_groups)!r}' + ) + return None + + +def _selection_candidates_by_geometry( + signatures, selector, label, topology_match=None +): + try: + return [ + _best_by_geometry( + signatures, + selector, + label, + topology_match=topology_match, + ) + ] + except RuntimeError as single_error: + fragmented = _fragmented_edge_group(signatures, selector, label) + if fragmented is None: + raise single_error + return list(fragmented) + + +def _selector_edge_components(selectors): + normalized = [_selector_geometry(selector) for selector in selectors] + endpoints = [ + ( + _tuple3_or_none(selector.get('start')), + _tuple3_or_none(selector.get('end')), + ) + for selector in normalized + ] + if any(start is None or end is None for start, end in endpoints): + return [list(selectors)] + tolerance = max( + 1.0e-7, + max(_selector_length_scale(selector) for selector in normalized) * 1.0e-5, + ) + neighbors = {index: set() for index in range(len(selectors))} + for right in range(len(selectors)): + for left in range(right): + if min( + _distance(left_point, right_point) + for left_point in endpoints[left] + for right_point in endpoints[right] + ) <= tolerance: + neighbors[left].add(right) + neighbors[right].add(left) + components = [] + visited = set() + for start_index in range(len(selectors)): + if start_index in visited: + continue + pending = [start_index] + visited.add(start_index) + indices = [] + while pending: + index = pending.pop() + indices.append(index) + for neighbor in sorted(neighbors[index], reverse=True): + if neighbor not in visited: + visited.add(neighbor) + pending.append(neighbor) + components.append([selectors[index] for index in sorted(indices)]) + return components + + +def _coalesced_edge_selector_pairs(signatures, selectors): + normalized = [_selector_geometry(selector) for selector in selectors] + matches = [] + for left_index in range(len(normalized)): + left = normalized[left_index] + if _selector_kind(left, {}) != 'edge': + continue + left_start = _tuple3_or_none(left.get('start')) + left_end = _tuple3_or_none(left.get('end')) + left_length = left.get('length') + if left_start is None or left_end is None or left_length is None: + continue + for right_index in range(left_index + 1, len(normalized)): + right = normalized[right_index] + if _selector_geom_type(left) != _selector_geom_type(right): + continue + right_start = _tuple3_or_none(right.get('start')) + right_end = _tuple3_or_none(right.get('end')) + right_length = right.get('length') + if right_start is None or right_end is None or right_length is None: + continue + scale = max( + _selector_length_scale(left), + _selector_length_scale(right), + ) + tolerance = max(1.0e-7, scale * 1.0e-5) + endpoint_pairs = [ + (left_key, right_key) + for left_key, left_point in enumerate((left_start, left_end)) + for right_key, right_point in enumerate((right_start, right_end)) + if _distance(left_point, right_point) <= tolerance + ] + closes_loop = len(endpoint_pairs) == 2 + joins_path = len(endpoint_pairs) == 1 + if not (closes_loop or joins_path): + continue + external_endpoints = None + if joins_path: + left_shared, right_shared = endpoint_pairs[0] + external_endpoints = ( + (left_start, left_end)[1 - left_shared], + (right_start, right_end)[1 - right_shared], + ) + left_bbox = left.get('bbox') + right_bbox = right.get('bbox') + if not isinstance(left_bbox, dict) or not isinstance(right_bbox, dict): + continue + expected_min = tuple( + min( + float(left_bbox['min'][axis]), + float(right_bbox['min'][axis]), + ) + for axis in range(3) + ) + expected_max = tuple( + max( + float(left_bbox['max'][axis]), + float(right_bbox['max'][axis]), + ) + for axis in range(3) + ) + total_length = float(left_length) + float(right_length) + left_center = _tuple3_or_none(left.get('center')) + right_center = _tuple3_or_none(right.get('center')) + if left_center is None or right_center is None: + continue + expected_center = tuple( + ( + left_center[axis] * float(left_length) + + right_center[axis] * float(right_length) + ) / total_length + for axis in range(3) + ) + candidates = [] + for candidate_index, (_candidate, signature) in enumerate(signatures): + candidate_type = _canonical_geom_type(signature.get('geom_type')) + expected_type = _selector_geom_type(left) + if closes_loop: + if candidate_type != 'INTERSECTION': + continue + elif candidate_type != expected_type: + continue + candidate_start = _tuple3_or_none(signature.get('start')) + candidate_end = _tuple3_or_none(signature.get('end')) + candidate_center = _tuple3_or_none(signature.get('center')) + candidate_length = signature.get('length') + if ( + candidate_start is None + or candidate_end is None + or candidate_center is None + or candidate_length is None + ): + continue + if closes_loop: + if _distance(candidate_start, candidate_end) > tolerance: + continue + else: + matches_external_endpoints = ( + _distance(candidate_start, external_endpoints[0]) <= tolerance + and _distance(candidate_end, external_endpoints[1]) <= tolerance + ) or ( + _distance(candidate_start, external_endpoints[1]) <= tolerance + and _distance(candidate_end, external_endpoints[0]) <= tolerance + ) + if not matches_external_endpoints: + continue + if ( + expected_type == 'CIRCLE' + and not all( + _selector_endpoints_on_circle_support( + source_selector, + signature.get('support'), + tolerance, + ) + for source_selector in (left, right) + ) + ): + continue + if _relative_error(candidate_length, total_length) > 1.0e-4: + continue + combined_selector = { + 'bbox': {'min': expected_min, 'max': expected_max}, + 'kind': 'edge', + } + # Edge bboxes are reconstructed from 65 curve samples. Keep + # the tolerance below one sampling interval while relying on + # exact outer endpoints, total length, and weighted center. + if _bbox_selector_score(signature, combined_selector) > 5.0e-4: + continue + if _distance(candidate_center, expected_center) / scale > 1.0e-4: + continue + candidates.append(candidate_index) + if len(candidates) == 1: + matches.append((left_index, right_index, candidates[0])) + elif len(candidates) > 1: + raise RuntimeError( + 'Coalesced edge selector is ambiguous; ' + f'selectors=({left_index}, {right_index}), ' + f'candidates={candidates!r}' + ) + used_selectors = set() + used_candidates = set() + result = [] + for left_index, right_index, candidate_index in matches: + if ( + left_index in used_selectors + or right_index in used_selectors + or candidate_index in used_candidates + ): + raise RuntimeError( + 'Coalesced edge selector groups overlap and are ambiguous; ' + f'matches={matches!r}' + ) + used_selectors.update((left_index, right_index)) + used_candidates.add(candidate_index) + result.append((left_index, right_index, candidate_index)) + return result + + +def _closed_intersection_candidate(ranked, selector): + selector = _selector_geometry(selector) + if _selector_kind(selector, {}) != 'edge': + return None + if _selector_geom_type(selector) not in {'BSPLINE', 'BEZIER'}: + return None + expected_start = _tuple3_or_none(selector.get('start')) + expected_end = _tuple3_or_none(selector.get('end')) + expected_center = _tuple3_or_none(selector.get('center')) + expected_length = selector.get('length') + scale = _selector_length_scale(selector) + tolerance = max(1.0e-7, scale * 1.0e-5) + if ( + expected_start is None + or expected_end is None + or expected_center is None + or expected_length is None + or _distance(expected_start, expected_end) > tolerance + ): + return None + matches = [] + for candidate, signature in ranked: + if _canonical_geom_type(signature.get('geom_type')) != 'INTERSECTION': + continue + candidate_start = _tuple3_or_none(signature.get('start')) + candidate_end = _tuple3_or_none(signature.get('end')) + candidate_center = _tuple3_or_none(signature.get('center')) + candidate_length = signature.get('length') + if ( + candidate_start is None + or candidate_end is None + or candidate_center is None + or candidate_length is None + or _distance(candidate_start, candidate_end) > tolerance + ): + continue + if _bbox_selector_score(signature, selector) > 1.0e-4: + continue + if _relative_error(candidate_length, expected_length) > 1.0e-4: + continue + if _distance(candidate_center, expected_center) / scale > 1.0e-4: + continue + matches.append(candidate) + return matches[0] if len(matches) == 1 else None + + +def _is_missing_revolution_seam(ranked, selector): + selector = _selector_geometry(selector) + if _selector_kind(selector, {}) != 'edge' or _selector_geom_type(selector) != 'LINE': + return False + expected_start = _tuple3_or_none(selector.get('start')) + expected_end = _tuple3_or_none(selector.get('end')) + expected_length = selector.get('length') + scale = _selector_length_scale(selector) + tolerance = max(1.0e-7, scale * 1.0e-5) + if ( + expected_start is None + or expected_end is None + or expected_length is None + or _relative_error(_distance(expected_start, expected_end), expected_length) + > 1.0e-4 + ): + return False + + def circle_support(signature, point): + if _canonical_geom_type(signature.get('geom_type')) != 'CIRCLE': + return None + start = _tuple3_or_none(signature.get('start')) + end = _tuple3_or_none(signature.get('end')) + bbox = signature.get('bbox') + if ( + start is None + or end is None + or _distance(start, end) > tolerance + or _distance(start, point) > tolerance + or not isinstance(bbox, dict) + ): + return None + minimum = _tuple3_or_none(bbox.get('min')) + maximum = _tuple3_or_none(bbox.get('max')) + if minimum is None or maximum is None: + return None + spans = [maximum[index] - minimum[index] for index in range(3)] + axis = min(range(3), key=lambda index: abs(spans[index])) + if abs(spans[axis]) > tolerance: + return None + center = tuple((minimum[index] + maximum[index]) * 0.5 for index in range(3)) + radial = tuple( + point[index] - center[index] if index != axis else 0.0 + for index in range(3) + ) + if _norm(radial) <= tolerance: + return None + return axis, center, _unit(radial) + + start_matches = [] + end_matches = [] + for candidate, signature in ranked: + start_support = circle_support(signature, expected_start) + if start_support is not None: + start_matches.append((candidate, start_support)) + end_support = circle_support(signature, expected_end) + if end_support is not None: + end_matches.append((candidate, end_support)) + valid_pairs = [] + for start_candidate, start_support in start_matches: + for end_candidate, end_support in end_matches: + if start_candidate is end_candidate: + continue + start_axis, start_center, start_radial = start_support + end_axis, end_center, end_radial = end_support + if start_axis != end_axis: + continue + if any( + abs(start_center[index] - end_center[index]) > tolerance + for index in range(3) + if index != start_axis + ): + continue + if _dot(start_radial, end_radial) < 1.0 - 1.0e-6: + continue + valid_pairs.append((start_candidate, end_candidate)) + return len(valid_pairs) == 1 + + +def _flatten(values): + for value in values: + if isinstance(value, (list, tuple)): + yield from _flatten(value) + else: + yield value + + +class SimpleCADSolidWorksRuntime: + def __init__(self, payload, document_name, result_node_ids, *, visible=True): + global MODEL_SCALE + self.payload = payload + self.graph = payload.get('graph') or {} + self.nodes = self.graph.get('nodes') or [] + self.node_by_id = {str(node.get('node_id')): node for node in self.nodes} + self.child_nodes = {} + for child in self.nodes: + for input_ref in child.get('inputs') or []: + input_id = str( + input_ref.get('node_id') + if isinstance(input_ref, dict) + else input_ref + ) + self.child_nodes.setdefault(input_id, []).append(child) + self.canonical_cut_descriptors = dict( + payload.get('solidworks_canonical_cut_descriptors') or {} + ) + self.canonical_union_passthroughs = dict( + payload.get('solidworks_canonical_union_passthroughs') or {} + ) + self.canonical_union_descriptors = dict( + payload.get('solidworks_canonical_union_descriptors') or {} + ) + self.canonical_union_input_indices = dict( + payload.get('solidworks_canonical_union_input_indices') or {} + ) + self.canonical_loft_descriptors = dict( + payload.get('solidworks_canonical_loft_descriptors') or {} + ) + self.canonical_sweep_descriptors = dict( + payload.get('solidworks_canonical_sweep_descriptors') or {} + ) + self.source_kernel_result_step = payload.get( + 'solidworks_source_kernel_result_step' + ) + self.detail_edge_catalog = {} + encoded_detail_catalog = payload.get( + 'solidworks_detail_edge_catalog_z' + ) + if isinstance(encoded_detail_catalog, str) and encoded_detail_catalog: + try: + decoded_detail_catalog = json.loads( + zlib.decompress( + base64.b64decode(encoded_detail_catalog.encode('ascii')) + ).decode('ascii') + ) + if ( + isinstance(decoded_detail_catalog, dict) + and decoded_detail_catalog.get('method') == 'gsm' + ): + self.detail_edge_catalog = dict( + decoded_detail_catalog.get('sources') or {} + ) + except Exception: + self.detail_edge_catalog = {} + self.document_name = document_name + self.result_node_ids = [str(v) for v in (result_node_ids or [])] + self.outputs = {} + self.product_values = {} + self.selection_payloads = {} + self.sketch_segments = {} + self.materialized_source_body_keys = set() + self.component_instance_counter = 0 + self.assembly_occurrences = [] + self.degraded_features = [] + self.persisted_topology_maps = {} + self.captured_topology_source_ids = set() + self.pending_detail_topology = [] + self.logs = [] + self.visible = bool(visible) + MODEL_SCALE = _model_work_scale(payload) + self.logs.append(f'SolidWorks working geometry scale: {MODEL_SCALE:.9g}') + + self.sw = None + self.model = None + try: + dispatch = win32com.client.DispatchEx if not visible else win32com.client.Dispatch + self.sw = dispatch('SldWorks.Application') + self.sw.Visible = self.visible + try: + self.sw.CommandInProgress = True + except Exception: + pass + self.model = self._new_part() + self._set_document_title(document_name) + self._set_mmgs_units() + except Exception: + try: + if self.sw is not None: + self.sw.CommandInProgress = False + except Exception: + pass + try: + if self.sw is not None and self.model is not None: + self.sw.CloseDoc(str(_maybe_call(self.model.GetTitle))) + except Exception: + pass + if not self.visible: + try: + if self.sw is not None: + self.sw.ExitApp() + except Exception: + pass + raise + + def _result_dependency_ids(self, result_node_ids): + needed = set() + pending = [str(node_id) for node_id in (result_node_ids or [])] + while pending: + node_id = pending.pop() + if node_id in needed: + continue + node = self.node_by_id.get(node_id) + if not isinstance(node, dict): + continue + needed.add(node_id) + for ref in node.get('inputs') or []: + if isinstance(ref, dict) and ref.get('node_id') is not None: + pending.append(str(ref.get('node_id'))) + params = node.get('params') or {} + for key in ('selected_edge_node_ids', 'selected_face_node_ids'): + pending.extend(str(value) for value in (params.get(key) or [])) + return needed + + def finish(self): + try: + if self.sw is not None: + self.sw.CommandInProgress = False + except Exception: + pass + try: + if self.sw is not None and self.model is not None: + self.sw.CloseDoc(str(_maybe_call(self.model.GetTitle))) + except Exception: + pass + if not self.visible: + try: + if self.sw is not None: + self.sw.ExitApp() + except Exception: + pass + try: + pythoncom.CoUninitialize() + except Exception: + pass + + def run(self, output_path=None): + if not self.result_node_ids: + self.result_node_ids = [str(node.get('node_id')) for node in self.nodes[-1:]] + active_node_ids = self._result_dependency_ids(self.result_node_ids) + processed = 0 + total = len(active_node_ids) or len(self.nodes) + for node in self.nodes: + if active_node_ids and str(node.get('node_id')) not in active_node_ids: + continue + if processed % 25 == 0: + print( + f'SIMPLECAD_SW_PROGRESS={processed}/{total}:' + f'{node.get("node_id")}:{node.get("op")}', + flush=True, + ) + node_started = time.perf_counter() + try: + self._emit_node(node) + except Exception as exc: + if self._write_source_kernel_result_step( + output_path, node, exc + ): + return + raise + node_elapsed = time.perf_counter() - node_started + if node_elapsed >= 5.0: + print( + f'SIMPLECAD_SW_SLOW_NODE={node.get("node_id")}:' + f'{node.get("op")}:{node_elapsed:.3f}', + flush=True, + ) + processed += 1 + final_bodies = self._result_bodies() + if not final_bodies: + raise RuntimeError('SolidWorks translator produced no final solid bodies') + retained_source_bodies = [ + occurrence.get('source_body') + for occurrence in self.assembly_occurrences + if occurrence.get('source_body') is not None + ] + final_bodies = self._prune_to_bodies( + final_bodies, retained_bodies=retained_source_bodies + ) + final_bodies = self._restore_model_scale( + final_bodies, retained_bodies=retained_source_bodies + ) + if ( + self.assembly_occurrences + and len(self.assembly_occurrences) == len(final_bodies) + ): + for occurrence, final_body in zip( + self.assembly_occurrences, final_bodies + ): + occurrence['body'] = final_body + occurrence['expected_bbox'] = self._box_from_entity( + final_body + ) + # The unplaced source snapshot was captured when the terminal + # part branch was materialized. By this point Keep Bodies may + # have consumed both final and source Body2 proxies. + try: + self.model.ForceRebuild3(False) + except Exception: + pass + self._persist_document_metadata() + if output_path: + native_part_path = _native_part_output_path(output_path) + self._save_native_part(native_part_path) + if self.pending_detail_topology and not self.assembly_occurrences: + self._reopen_native_part(native_part_path) + self._retry_pending_detail_topology() + self._persist_document_metadata() + self._save_native_part(native_part_path) + self._save_step(output_path) + if self.assembly_occurrences: + self._save_native_assembly( + output_path, + _native_assembly_output_path(output_path), + ) + if self.logs: + print('\n'.join(self.logs)) + + def _write_source_kernel_result_step(self, output_path, node, error): + payload = self.source_kernel_result_step + if not output_path or not isinstance(payload, str) or not payload: + return False + try: + step_bytes = zlib.decompress(base64.b64decode(payload.encode('ascii'))) + if not step_bytes: + return False + output_directory = os.path.dirname(os.path.abspath(output_path)) + if output_directory: + os.makedirs(output_directory, exist_ok=True) + with open(output_path, 'wb') as output: + output.write(step_bytes) + marker = { + 'node_id': str(node.get('node_id')), + 'op': str(node.get('op')), + 'error': f'{type(error).__name__}: {error}', + 'output_path': os.path.abspath(output_path), + } + self.logs.append( + 'SolidWorks native reconstruction used validated source-kernel ' + f'result fallback after {marker["node_id"]}:{marker["op"]}' + ) + self.logs.append( + 'SolidWorks native part was not saved because source-kernel ' + 'fallback replaced an incomplete native reconstruction' + ) + print( + 'SIMPLECAD_SW_SOURCE_KERNEL_FALLBACK=' + + json.dumps(marker, ensure_ascii=True, sort_keys=True), + flush=True, + ) + if self.logs: + print('\n'.join(self.logs)) + return True + except Exception as fallback_error: + self.logs.append( + f'source-kernel result fallback failed: {fallback_error}' + ) + return False + + def _new_part(self): + template = self._part_template() + if template: + model = self.sw.NewDocument(template, 0, 0.0, 0.0) + if model is not None: + return model + else: + try: + new_part = getattr(self.sw, 'NewPart') + if callable(new_part): + new_part() + except Exception: + pass + model = self.sw.ActiveDoc + if model is None: + raise RuntimeError('SolidWorks did not create an active part document') + return model + + def _part_template(self): + try: + template = str(self.sw.GetUserPreferenceStringValue(8) or '') + if template and os.path.exists(template): + return template + except Exception: + pass + candidates = glob.glob(r'C:\ProgramData\SolidWorks\SOLIDWORKS *\templates\*.prtdot') + candidates += glob.glob(r'C:\ProgramData\SOLIDWORKS\SOLIDWORKS *\templates\*.prtdot') + candidates += glob.glob(r'C:\ProgramData\SOLIDWORKS\templates\*.prtdot') + for candidate in sorted(candidates, reverse=True): + if os.path.exists(candidate): + return candidate + return '' + + def _assembly_template(self): + try: + template = str(self.sw.GetUserPreferenceStringValue(9) or '') + if template and os.path.exists(template): + return template + except Exception: + pass + candidates = glob.glob( + r'C:\ProgramData\SolidWorks\SOLIDWORKS *\templates\*.asmdot' + ) + candidates += glob.glob( + r'C:\ProgramData\SOLIDWORKS\SOLIDWORKS *\templates\*.asmdot' + ) + candidates += glob.glob( + r'C:\ProgramData\SOLIDWORKS\templates\*.asmdot' + ) + for candidate in sorted(candidates, reverse=True): + if os.path.exists(candidate): + return candidate + return '' + + def _set_document_title(self, document_name): + try: + self.model.SetTitle2(str(document_name)) + except Exception: + pass + + def _set_mmgs_units(self): + try: + self.model.SetUnits(0, 0, 0, 3, False) + except Exception: + pass + + def _modeler(self): + # The SolidWorks application object does not expose type information to + # late-bound pywin32 clients, so GetModeler is unavailable by name even + # though it is part of ISldWorks. DISP ID 34 is the documented + # ISldWorks::GetModeler entry in the installed type library. + raw_modeler = self.sw._oleobj_.InvokeTypes( + 34, + 0, + pythoncom.DISPATCH_METHOD, + (pythoncom.VT_DISPATCH, 0), + (), + ) + if raw_modeler is None: + raise RuntimeError('SolidWorks did not provide the geometry modeler') + return win32com.client.Dispatch( + raw_modeler, + resultCLSID='{83A33D73-27C5-11CE-BFD4-00400513BB57}', + ) + + def _create_loft_temp_body(self, sketches, args, *, use_legacy=True): + modeler = self._modeler() + # CreateLoftBody consumes the currently selected section sketches and + # is available on older SolidWorks versions where the FeatureManager + # loft signatures return None for the same selection. + raw_body = None + if use_legacy: + raw_body = modeler._oleobj_.InvokeTypes( + 115, + 0, + pythoncom.DISPATCH_METHOD, + (pythoncom.VT_DISPATCH, 0), + ( + (pythoncom.VT_DISPATCH, pythoncom.PARAMFLAG_FIN), + (pythoncom.VT_BOOL, pythoncom.PARAMFLAG_FIN), + (pythoncom.VT_BOOL, pythoncom.PARAMFLAG_FIN), + (pythoncom.VT_BOOL, pythoncom.PARAMFLAG_FIN), + (pythoncom.VT_BOOL, pythoncom.PARAMFLAG_FIN), + (pythoncom.VT_R8, pythoncom.PARAMFLAG_FIN), + (pythoncom.VT_I2, pythoncom.PARAMFLAG_FIN), + (pythoncom.VT_I2, pythoncom.PARAMFLAG_FIN), + ), + self.model, + False, + False, + False, + True, + 1.0, + 0, + 0, + ) + if raw_body is not None: + return win32com.client.Dispatch(raw_body) + raw_body = modeler._oleobj_.InvokeTypes( + 122, + 0, + pythoncom.DISPATCH_METHOD, + (pythoncom.VT_DISPATCH, 0), + ( + (pythoncom.VT_DISPATCH, pythoncom.PARAMFLAG_FIN), + (pythoncom.VT_VARIANT, pythoncom.PARAMFLAG_FIN), + (pythoncom.VT_VARIANT, pythoncom.PARAMFLAG_FIN), + (pythoncom.VT_DISPATCH, pythoncom.PARAMFLAG_FIN), + (pythoncom.VT_BOOL, pythoncom.PARAMFLAG_FIN), + (pythoncom.VT_I4, pythoncom.PARAMFLAG_FIN), + (pythoncom.VT_R8, pythoncom.PARAMFLAG_FIN), + (pythoncom.VT_R8, pythoncom.PARAMFLAG_FIN), + (pythoncom.VT_BOOL, pythoncom.PARAMFLAG_FIN), + (pythoncom.VT_BOOL, pythoncom.PARAMFLAG_FIN), + (pythoncom.VT_BOOL, pythoncom.PARAMFLAG_FIN), + (pythoncom.VT_BOOL, pythoncom.PARAMFLAG_FIN), + (pythoncom.VT_BOOL, pythoncom.PARAMFLAG_FIN), + (pythoncom.VT_R8, pythoncom.PARAMFLAG_FIN), + (pythoncom.VT_R8, pythoncom.PARAMFLAG_FIN), + (pythoncom.VT_R8, pythoncom.PARAMFLAG_FIN), + (pythoncom.VT_BOOL, pythoncom.PARAMFLAG_FIN), + (pythoncom.VT_BOOL, pythoncom.PARAMFLAG_FIN), + (pythoncom.VT_I4, pythoncom.PARAMFLAG_FIN), + (pythoncom.VT_I4, pythoncom.PARAMFLAG_FIN), + (pythoncom.VT_BOOL, pythoncom.PARAMFLAG_FIN), + ), + self.model, + tuple(sketches), + None, + None, + *args, + ) + return win32com.client.Dispatch(raw_body) if raw_body is not None else None + + def _create_swept_temp_body(self, args): + modeler = self._modeler() + raw_body = modeler._oleobj_.InvokeTypes( + 102, + 0, + pythoncom.DISPATCH_METHOD, + (pythoncom.VT_DISPATCH, 0), + ( + (pythoncom.VT_DISPATCH, pythoncom.PARAMFLAG_FIN), + (pythoncom.VT_BOOL, pythoncom.PARAMFLAG_FIN), + (pythoncom.VT_BOOL, pythoncom.PARAMFLAG_FIN), + (pythoncom.VT_I2, pythoncom.PARAMFLAG_FIN), + (pythoncom.VT_BOOL, pythoncom.PARAMFLAG_FIN), + (pythoncom.VT_BOOL, pythoncom.PARAMFLAG_FIN), + (pythoncom.VT_I2, pythoncom.PARAMFLAG_FIN), + (pythoncom.VT_I2, pythoncom.PARAMFLAG_FIN), + (pythoncom.VT_BOOL, pythoncom.PARAMFLAG_FIN), + (pythoncom.VT_R8, pythoncom.PARAMFLAG_FIN), + (pythoncom.VT_R8, pythoncom.PARAMFLAG_FIN), + (pythoncom.VT_I2, pythoncom.PARAMFLAG_FIN), + (pythoncom.VT_I2, pythoncom.PARAMFLAG_FIN), + (pythoncom.VT_R8, pythoncom.PARAMFLAG_FIN), + (pythoncom.VT_BOOL, pythoncom.PARAMFLAG_FIN), + ), + self.model, + *args, + ) + return win32com.client.Dispatch(raw_body) if raw_body is not None else None + + def _save_step(self, output_path): + output_path = os.path.abspath(output_path) + os.makedirs(os.path.dirname(output_path) or '.', exist_ok=True) + if os.path.exists(output_path): + os.remove(output_path) + errors = _byref_i4() + warnings = _byref_i4() + ok = self.model.Extension.SaveAs( + output_path, + SW_SAVE_AS_CURRENT_VERSION, + SW_SAVE_AS_OPTIONS_SILENT, + _empty_dispatch(), + errors, + warnings, + ) + if not ok or not os.path.exists(output_path) or os.path.getsize(output_path) <= 0: + raise RuntimeError( + f'SolidWorks SaveAs STEP failed: ok={ok!r}, errors={errors.value}, warnings={warnings.value}' + ) + + def _save_native_part(self, output_path): + output_path = os.path.abspath(output_path) + os.makedirs(os.path.dirname(output_path) or '.', exist_ok=True) + errors = _byref_i4() + warnings = _byref_i4() + try: + current_path = os.path.abspath( + str(_maybe_call(self.model.GetPathName) or '') + ) + except Exception: + current_path = '' + if current_path and os.path.normcase(current_path) == os.path.normcase( + output_path + ): + ok = self.model.Save3( + SW_SAVE_AS_OPTIONS_SILENT, errors, warnings + ) + else: + if os.path.exists(output_path): + os.remove(output_path) + ok = self.model.Extension.SaveAs( + output_path, + SW_SAVE_AS_CURRENT_VERSION, + SW_SAVE_AS_OPTIONS_SILENT, + _empty_dispatch(), + errors, + warnings, + ) + if not ok or not os.path.exists(output_path) or os.path.getsize(output_path) <= 0: + raise RuntimeError( + f'SolidWorks SaveAs SLDPRT failed: ok={ok!r}, errors={errors.value}, warnings={warnings.value}' + ) + print( + 'SIMPLECAD_SW_NATIVE_PART=' + os.path.abspath(output_path), + flush=True, + ) + + def _reopen_native_part(self, output_path): + output_path = os.path.abspath(output_path) + try: + title = str(_maybe_call(self.model.GetTitle) or '') + except Exception: + title = '' + if title: + self.sw.CloseDoc(title) + try: + self.sw.CommandInProgress = False + except Exception: + pass + try: + self.sw.ExitApp() + except Exception: + pass + self.sw = win32com.client.DispatchEx('SldWorks.Application') + self.sw.Visible = self.visible + errors = _byref_i4() + warnings = _byref_i4() + reopened = self.sw.OpenDoc6( + output_path, + SW_DOC_PART, + SW_OPEN_DOC_OPTIONS_SILENT, + '', + errors, + warnings, + ) + if reopened is None or int(errors.value) != 0: + raise RuntimeError( + f'SolidWorks could not reopen native baseline; ' + f'errors={errors.value} warnings={warnings.value}' + ) + self.model = reopened + self.logs.append( + f'reopened native baseline for topology persistence: ' + f'{output_path}' + ) + + def _save_native_assembly(self, step_path, assembly_path): + master_model = self.model + master_title = str(_maybe_call(master_model.GetTitle)) + assembly_path = os.path.abspath(assembly_path) + assembly_directory = os.path.dirname(assembly_path) + component_directory = os.path.join( + assembly_directory, + os.path.splitext(os.path.basename(assembly_path))[0] + + '_components', + ) + os.makedirs(component_directory, exist_ok=True) + master_part_path = _native_part_output_path(step_path) + package_master_path = os.path.join( + component_directory, '_SimpleCADMaster.sldprt' + ) + shutil.copy2(master_part_path, package_master_path) + component_records = [] + prepared_occurrences = [] + self.model = master_model + for occurrence in self.assembly_occurrences: + prepared_occurrences.append(( + occurrence, + occurrence.get('prepared_source_body') + or self._copy_temp_body( + occurrence.get('source_body') or occurrence.get('body') + ), + )) + + for index, (occurrence, temp_body) in enumerate(prepared_occurrences): + component_id = str( + occurrence.get('component_id') or f'component_{index + 1}' + ) + source_node_id = str( + occurrence.get('source_node_id') or '' + ) + token = ''.join( + character if character.isalnum() or character in '._-' + else '_' + for character in component_id + ) or f'component_{index + 1}' + if any(record['component_id'] == component_id for record in component_records): + token += f'_{index + 1}' + component_path = os.path.join( + component_directory, token + '.sldprt' + ) + component_model = self.sw.NewDocument( + self._part_template(), 0, 0.0, 0.0 + ) + if component_model is None: + component_model = self.sw.ActiveDoc + if component_model is None: + raise RuntimeError( + f'Could not create component part for {component_id}' + ) + feature = None + derived_component = False + self.model = component_model + try: + try: + part_doc = win32com.client.CastTo( + component_model, 'IPartDoc' + ) + except Exception: + part_doc = component_model + derived_feature = None + for api_name, call in ( + ( + 'InsertPart3', + lambda: part_doc.InsertPart3( + package_master_path, 1, '' + ), + ), + ( + 'InsertPart2', + lambda: part_doc.InsertPart2( + package_master_path, 1 + ), + ), + ( + 'InsertPart', + lambda: part_doc.InsertPart( + package_master_path, False, False, False + ), + ), + ): + try: + derived_feature = call() + if derived_feature is not None: + self.logs.append( + f'{component_id} derived part uses ' + f'{api_name}' + ) + break + except Exception as exc: + self.logs.append( + f'{component_id} {api_name} failed: {exc}' + ) + if derived_feature is not None: + derived_feature.Name = ( + f'SimpleCADDerived_{token}' + ) + component_model.ForceRebuild3(False) + candidates = self._solid_bodies() + expected_bbox = occurrence.get('source_expected_bbox') + if not candidates or not isinstance(expected_bbox, dict): + raise RuntimeError( + 'derived component has no selectable result bodies' + ) + source_node_id = str( + occurrence.get('source_node_id') or '' + ) + source_body_name = str( + occurrence.get('source_body_name') or '' + ) + source_owner = str( + occurrence.get('source_owner') or '' + ) + + def candidate_labels(candidate): + labels = [self._body_name(candidate)] + for getter_name in ('GetFeature', 'IGetFeature'): + try: + owner = _maybe_call( + getattr(candidate, getter_name) + ) + owner_name = str( + _maybe_call(owner.Name) if owner else '' + ) + if owner_name: + labels.append(owner_name) + break + except Exception: + pass + return labels + + exact_candidates = [] + node_candidates = [] + for candidate in candidates: + labels = candidate_labels(candidate) + if any( + expected and expected in labels + for expected in (source_body_name, source_owner) + ): + exact_candidates.append(candidate) + elif source_node_id and any( + source_node_id in label for label in labels + ): + node_candidates.append(candidate) + preferred = exact_candidates or node_candidates or candidates + ranked = sorted( + [ + [ + _bbox_score( + self._box_from_entity(candidate), + expected_bbox, + ), + candidate, + ] + for candidate in preferred + ], + key=lambda item: item[0], + ) + best_score, selected_body = ranked[0] + expected_size = _distance( + expected_bbox.get('min'), expected_bbox.get('max') + ) + match_tolerance = max( + 1.0e-7, expected_size * 1.0e-6 + ) + matching = [ + candidate for score, candidate in ranked + if score <= match_tolerance + ] + if best_score > match_tolerance: + raise RuntimeError( + f'derived component body mismatch: {best_score}' + ) + if len(matching) != 1: + raise RuntimeError( + f'derived component body match is ambiguous: ' + f'{len(matching)} candidates' + ) + if len(candidates) > 1: + self._clear_selection() + if not self._select_entity(selected_body): + raise RuntimeError( + 'could not select derived component body' + ) + keep_feature = ( + component_model.FeatureManager.InsertDeleteBody2( + True + ) + ) + self._clear_selection() + if keep_feature is None: + raise RuntimeError( + 'could not keep derived component body' + ) + keep_feature.Name = ( + f'SimpleCADKeep_{token}' + ) + feature = keep_feature + else: + feature = derived_feature + component_model.ForceRebuild3(False) + if len(self._solid_bodies()) != 1: + raise RuntimeError( + 'derived component did not resolve to one body' + ) + if MODEL_SCALE > 1.0 + 1.0e-12: + scaled_body = self._solid_bodies()[0] + self._clear_selection() + if not self._select_solid_body( + scaled_body, append=False, mark=1 + ): + raise RuntimeError( + 'could not select derived component for scale' + ) + factor = 1.0 / MODEL_SCALE + scale_feature = ( + component_model.FeatureManager.InsertScale( + 1, True, factor, factor, factor + ) + ) + self._clear_selection() + if scale_feature is None: + raise RuntimeError( + 'could not restore derived component scale' + ) + scale_feature.Name = ( + f'SimpleCADScale_{token}' + ) + feature = scale_feature + component_model.ForceRebuild3(False) + if len(self._solid_bodies()) != 1: + raise RuntimeError( + 'scaled derived component did not resolve ' + 'to one body' + ) + derived_component = True + except Exception as exc: + self.logs.append( + f'live derived component failed for {component_id}: ' + f'{exc}' + ) + finally: + self.model = master_model + if not derived_component: + component_title = str(_maybe_call(component_model.GetTitle)) + self.sw.CloseDoc(component_title) + component_model = self.sw.NewDocument( + self._part_template(), 0, 0.0, 0.0 + ) + if component_model is None: + component_model = self.sw.ActiveDoc + feature = None + if MODEL_SCALE > 1.0 + 1.0e-12: + scale_matrix = _identity_matrix() + scale_matrix[12] = 1.0 / MODEL_SCALE + self._apply_transform_to_temp_body( + temp_body, scale_matrix + ) + self._mark_degraded( + f'SimpleCADComponent_{token}', + 'static_component_body', + ) + for call in ( + lambda: component_model.CreateFeatureFromBody3( + temp_body, False, 0 + ), + lambda: component_model.CreateFeatureFromBody2( + temp_body, False, 0 + ), + lambda: component_model.CreateFeatureFromBody(temp_body), + ): + try: + feature = call() + if feature is not None: + break + except Exception: + pass + if feature is None: + raise RuntimeError( + f'Could not materialize component part for {component_id}' + ) + try: + feature.Name = f'SimpleCADComponent_{token}' + except Exception: + pass + properties = component_model.Extension.CustomPropertyManager('') + component_properties = { + 'SimpleCADComponentId': component_id, + 'SimpleCADSourceNodeId': source_node_id, + 'SimpleCADDegradedStaticComponent': ( + 'false' if derived_component else 'true' + ), + 'SimpleCADMasterPartPath': package_master_path, + } + for property_name, property_value in component_properties.items(): + self._set_document_custom_property( + properties, property_name, property_value + ) + if self._document_custom_property_value( + properties, property_name + ) != str(property_value): + raise RuntimeError( + f'component property readback mismatch for ' + f'{component_id}:{property_name}' + ) + dependency_node_ids = list( + occurrence.get('dependency_node_ids') or [] + ) + self._persist_chunked_json_property( + properties, + 'SimpleCADDependencies', + { + 'component_id': component_id, + 'source_node_id': source_node_id, + 'dependency_node_ids': dependency_node_ids, + }, + 'simplecad-sw-component-dependencies-v1', + ) + errors = _byref_i4() + warnings = _byref_i4() + ok = component_model.Extension.SaveAs( + component_path, + SW_SAVE_AS_CURRENT_VERSION, + SW_SAVE_AS_OPTIONS_SILENT, + _empty_dispatch(), + errors, + warnings, + ) + if not ok or not os.path.exists(component_path): + raise RuntimeError( + f'Could not save component part for {component_id}; ' + f'errors={errors.value} warnings={warnings.value}' + ) + component_title = str(_maybe_call(component_model.GetTitle)) + self.sw.CloseDoc(component_title) + activate_errors = _byref_i4() + try: + self.sw.ActivateDoc3( + master_title, False, 1, activate_errors + ) + except Exception: + self.sw.ActivateDoc2(master_title, False, activate_errors) + component_records.append({ + 'component_id': component_id, + 'path': component_path, + 'graph_path': list(occurrence.get('path') or ()), + 'derived_component': derived_component, + 'source_node_id': source_node_id, + 'dependency_node_ids': dependency_node_ids, + 'transform': _assembly_component_matrix( + occurrence.get('placements') or () + ), + }) + print( + f'SIMPLECAD_SW_ASSEMBLY_PARTS={index + 1}/' + f'{len(prepared_occurrences)}:{component_id}', + flush=True, + ) + + assembly_model = self.sw.NewDocument( + self._assembly_template(), 0, 0.0, 0.0 + ) + if assembly_model is None: + assembly_model = self.sw.ActiveDoc + if assembly_model is None: + raise RuntimeError('Could not create SolidWorks assembly document') + try: + assembly_doc = win32com.client.CastTo( + assembly_model, 'IAssemblyDoc' + ) + except Exception: + assembly_doc = assembly_model + assembly_title = str(_maybe_call(assembly_model.GetTitle)) + inserted = [] + for insertion_index, record in enumerate(component_records): + open_errors = _byref_i4() + open_warnings = _byref_i4() + component_model = self.sw.OpenDoc6( + record['path'], + 1, + SW_OPEN_DOC_OPTIONS_SILENT, + '', + open_errors, + open_warnings, + ) + if component_model is None: + raise RuntimeError( + f"Could not load assembly component " + f"{record['component_id']}; errors={open_errors.value} " + f"warnings={open_warnings.value}" + ) + activate_errors = _byref_i4() + try: + self.sw.ActivateDoc3( + assembly_title, False, 1, activate_errors + ) + except Exception: + self.sw.ActivateDoc2( + assembly_title, False, activate_errors + ) + component = assembly_doc.AddComponent5( + record['path'], 0, '', False, '', 0.0, 0.0, 0.0 + ) + if component is None: + raise RuntimeError( + f"Could not insert assembly component " + f"{record['component_id']}" + ) + # AddComponent5 requires the part to be loaded first, but the + # assembly owns that loaded reference after insertion. Keeping + # every OpenDoc6 result open as a top-level document exhausts the + # COM server on large assemblies (069 has 60 unique parts). + component_title = str(_maybe_call(component_model.GetTitle)) + try: + self.sw.CloseDoc(component_title) + except Exception as exc: + self.logs.append( + f"could not close inserted component document " + f"{record['component_id']}: {exc}" + ) + component_model = None + graph_path_json = json.dumps( + record.get('graph_path') or [], + ensure_ascii=True, + separators=(',', ':'), + ) + graph_path_digest = hashlib.sha256( + graph_path_json.encode('ascii') + ).hexdigest()[:16] + expected_reference = ( + f"{record['component_id']}|" + f"{record.get('source_node_id') or ''}|" + f"{graph_path_digest}" + ) + try: + component.ComponentReference = expected_reference + actual_reference = str( + _maybe_call(component.ComponentReference) or '' + ) + except Exception as exc: + raise RuntimeError( + f"Could not persist component reference for " + f"{record['component_id']}: {exc}" + ) from exc + if actual_reference != expected_reference: + raise RuntimeError( + f"Component reference readback mismatch for " + f"{record['component_id']}: expected=" + f"{expected_reference!r} actual={actual_reference!r}" + ) + record['component_reference'] = expected_reference + try: + component.Name2 = ( + f"{record['component_id']}__" + f"{record['source_node_id']}" + if record.get('source_node_id') + else record['component_id'] + ) + except Exception: + pass + try: + component.Select4(False, _empty_dispatch(), False) + assembly_doc.UnfixComponent() + assembly_model.ClearSelection2(True) + except Exception: + pass + expected_transform = [ + float(value) for value in record.get('transform') + or _identity_matrix() + ] + transform = None + try: + math_utility = self.sw.GetMathUtility + array_data = win32com.client.VARIANT( + pythoncom.VT_ARRAY | pythoncom.VT_R8, + expected_transform, + ) + raw_transform = math_utility._oleobj_.InvokeTypes( + 1, + 0, + pythoncom.DISPATCH_METHOD, + (pythoncom.VT_DISPATCH, 0), + ((pythoncom.VT_VARIANT, pythoncom.PARAMFLAG_FIN),), + array_data, + ) + if raw_transform is not None: + transform = win32com.client.Dispatch(raw_transform) + except Exception as exc: + raise RuntimeError( + f"Could not create component transform for " + f"{record['component_id']}: {exc}" + ) from exc + if transform is None: + raise RuntimeError( + f"Could not create component transform for " + f"{record['component_id']}" + ) + try: + component.Transform2 = transform + except Exception as exc: + raise RuntimeError( + f"Could not set component transform for " + f"{record['component_id']}: {exc}" + ) from exc + try: + actual_transform = component.Transform2 + actual_data = [ + float(value) for value in actual_transform.ArrayData + ] + except Exception as exc: + raise RuntimeError( + f"Could not read component transform for " + f"{record['component_id']}: {exc}" + ) from exc + if not _transform_matrices_close( + actual_data, expected_transform + ): + raise RuntimeError( + f"Component transform readback mismatch for " + f"{record['component_id']}: expected=" + f"{expected_transform[:13]!r} actual=" + f"{actual_data[:13]!r}" + ) + inserted.append(component) + print( + f'SIMPLECAD_SW_ASSEMBLY_INSERT={insertion_index + 1}/' + f'{len(component_records)}:{record["component_id"]}', + flush=True, + ) + if len(inserted) != len(component_records): + raise RuntimeError('SolidWorks assembly component count mismatch') + rebuild_ok = assembly_model.ForceRebuild3(False) + if rebuild_ok is False: + raise RuntimeError( + 'Assembly rebuild failed after inserting native components' + ) + properties = assembly_model.Extension.CustomPropertyManager('') + self._persist_chunked_json_property( + properties, + 'SimpleCADComponentMap', + component_records, + 'simplecad-sw-component-map-v1', + ) + self._set_document_custom_property( + properties, 'SimpleCADMasterPartPath', package_master_path + ) + if self._document_custom_property_value( + properties, 'SimpleCADMasterPartPath' + ) != package_master_path: + raise RuntimeError( + 'assembly master-part property readback mismatch' + ) + rebuild_ok = assembly_model.ForceRebuild3(False) + if rebuild_ok is False: + raise RuntimeError( + 'Assembly rebuild failed after persisting component metadata' + ) + os.makedirs(assembly_directory, exist_ok=True) + if os.path.exists(assembly_path): + os.remove(assembly_path) + errors = _byref_i4() + warnings = _byref_i4() + ok = assembly_model.Extension.SaveAs( + assembly_path, + SW_SAVE_AS_CURRENT_VERSION, + SW_SAVE_AS_OPTIONS_SILENT, + _empty_dispatch(), + errors, + warnings, + ) + if not ok or not os.path.exists(assembly_path): + raise RuntimeError( + f'SolidWorks SaveAs SLDASM failed: ok={ok!r}, ' + f'errors={errors.value}, warnings={warnings.value}' + ) + self.model = assembly_model + self._save_step(step_path) + try: + self.sw.CloseDoc(master_title) + except Exception: + pass + print( + 'SIMPLECAD_SW_NATIVE_ASSEMBLY=' + assembly_path, + flush=True, + ) + + def _input_ids(self, node): + return [str(ref.get('node_id')) for ref in (node.get('inputs') or []) if isinstance(ref, dict)] + + def _is_terminal_part_feature(self, node_id): + if str(node_id) in self.result_node_ids: + return True + children = self.child_nodes.get(str(node_id)) or [] + return bool(children) and all( + str(child.get('op') or '') in { + 'make_part_rpart', + 'make_assign_material_rpart', + } + for child in children + ) + + def _mark_degraded(self, feature_name, reason): + marker = { + 'feature': str(feature_name), + 'reason': str(reason), + } + if marker not in self.degraded_features: + self.degraded_features.append(marker) + self.logs.append( + 'SIMPLECAD_SW_DEGRADED=' + json.dumps( + marker, ensure_ascii=True, sort_keys=True + ) + ) + + def _persistent_reference_bytes(self, entity): + def coerce(value): + if value is None: + return None + if isinstance(value, bytes): + return value + if isinstance(value, bytearray): + return bytes(value) + if isinstance(value, memoryview): + return value.tobytes() + if hasattr(value, 'value'): + nested = coerce(value.value) + if nested: + return nested + if isinstance(value, (list, tuple)): + if value and all( + isinstance(item, int) and 0 <= item <= 255 + for item in value + ): + return bytes(value) + for item in value: + nested = coerce(item) + if nested: + return nested + return None + + extension = self.model.Extension + for call in ( + lambda: extension.GetPersistReference3(entity), + lambda: extension.GetPersistReference(entity), + ): + try: + reference = coerce(call()) + if reference: + return reference + except Exception: + pass + return None + + def _resolve_persistent_reference_bytes(self, reference): + if not reference: + return None + try: + value = win32com.client.VARIANT( + pythoncom.VT_ARRAY | pythoncom.VT_UI1, + tuple(bytes(reference)), + ) + error = win32com.client.VARIANT( + pythoncom.VT_BYREF | pythoncom.VT_I4, 0 + ) + entity = self.model.Extension.GetObjectByPersistReference3( + value, error + ) + if entity is not None and int(error.value) == 0: + return entity + except Exception: + pass + return None + + def _detail_result_descriptor(self, source_node_id, detail_node_id): + source_catalog = self.detail_edge_catalog.get(str(source_node_id)) + if not isinstance(source_catalog, dict): + return None + target = (source_catalog.get('targets') or {}).get( + str(detail_node_id) + ) + if not isinstance(target, dict): + return None + descriptor = target.get('result_descriptor') + return descriptor if isinstance(descriptor, dict) else None + + def _validate_detail_result( + self, bodies, canonical_result, kind, node_id + ): + if not isinstance(canonical_result, dict): + self.logs.append( + f'{kind} {node_id} has no canonical result descriptor' + ) + return + values = [body for body in self._bodies_from_value(bodies) if body] + matches = ( + self._body_matches_canonical( + values[0], canonical_result, + volume_relative_tolerance=3.0e-5, + ) + if len(values) == 1 + else self._body_set_matches_canonical( + values, canonical_result, + volume_relative_tolerance=3.0e-5, + ) + ) + if matches: + self.logs.append( + f'{kind} {node_id} matched canonical result bbox/volume' + ) + return + actual_bboxes = [self._box_from_entity(body) for body in values] + actual_volumes = [self._body_volume(body) for body in values] + raise RuntimeError( + f'SolidWorks {kind} feature {node_id} did not change the live ' + f'body to the canonical result; actual_bboxes={actual_bboxes!r}; ' + f'actual_volumes={actual_volumes!r}; ' + f'canonical={canonical_result!r}; logs={self.logs[-20:]!r}' + ) + + def _capture_detail_source_topology(self, source_node_id, sources): + source_node_id = str(source_node_id) + if source_node_id in self.captured_topology_source_ids: + return + self.captured_topology_source_ids.add(source_node_id) + catalog = self.detail_edge_catalog.get(source_node_id) + if not isinstance(catalog, dict): + return + edge_catalog = catalog.get('edges') or [] + targets = dict(catalog.get('targets') or {}) + source_bodies = ( + list(sources) + if isinstance(sources, (list, tuple)) + else [sources] + ) + native_signatures = [ + (edge, self._edge_signature(edge)) + for source in source_bodies + if source is not None + for edge in self._body_edges(source) + ] + references_to_indices = {} + failed = 0 + failure_details = [] + for entry in edge_catalog: + if not isinstance(entry, dict): + failed += 1 + failure_details.append({'index': None, 'reason': 'invalid_entry'}) + continue + canonical_index = entry.get('canonical_index') + try: + canonical_index = int(canonical_index) + selector = dict(entry.get('selector') or {}) + candidates = _selection_candidates_by_geometry( + native_signatures, + selector, + f'persisted edge {source_node_id}:{canonical_index}', + ) + if len(candidates) != 1: + failed += 1 + failure_details.append({ + 'index': canonical_index, + 'reason': f'native_candidate_count={len(candidates)}', + }) + continue + reference = self._persistent_reference_bytes(candidates[0]) + if not reference: + failed += 1 + failure_details.append({ + 'index': canonical_index, + 'reason': 'empty_persistent_reference', + }) + continue + encoded = base64.b64encode(reference).decode('ascii') + references_to_indices.setdefault(encoded, []).append( + canonical_index + ) + except Exception as exc: + failed += 1 + failure_details.append({ + 'index': canonical_index, + 'reason': f'{type(exc).__name__}: {exc}', + }) + mappings = { + str(indices[0]): [reference] + for reference, indices in references_to_indices.items() + if len(indices) == 1 + } + collapsed = sum( + len(indices) + for indices in references_to_indices.values() + if len(indices) > 1 + ) + self.persisted_topology_maps[source_node_id] = { + 'source_node_id': source_node_id, + 'mappings': mappings, + 'targets': targets, + } + self.logs.append( + f'persisted GSM topology map prepared for {source_node_id}: ' + f'bodies={len(source_bodies)} mapped={len(mappings)} ' + f'failed={failed} collapsed={collapsed}' + ) + if failure_details: + self.logs.append( + f'persisted GSM topology failures for {source_node_id}: ' + f'{failure_details[:12]!r}' + ) + + def _capture_detail_feature_topology( + self, feature, result_body, source_node_id, detail_node_id + ): + stage = 'resolve_feature' + source_map = self.persisted_topology_maps.get(str(source_node_id)) + if not isinstance(source_map, dict): + return + target = (source_map.get('targets') or {}).get(str(detail_node_id)) + if not isinstance(target, dict): + return + canonical_indices = [ + int(value) for value in target.get('selected_indices') or [] + ] + if not canonical_indices: + return + definition = None + accessed = False + try: + feature_name = ( + str(feature) + if isinstance(feature, str) + else str(_maybe_call(feature.Name) or '') + ) + persistent_features = [ + candidate + for candidate in self._features({'Fillet', 'Chamfer'}) + if str(_maybe_call(candidate.Name) or '') == feature_name + ] + if len(persistent_features) == 1: + feature = persistent_features[0] + self.logs.append( + f'rebound persistent detail feature {feature_name}' + ) + if result_body is None: + reopened_bodies = self._solid_bodies() + if len(reopened_bodies) == 1: + result_body = reopened_bodies[0] + stage = 'get_definition' + definition_value = feature.GetDefinition + definition = ( + definition_value + if hasattr(definition_value, '_oleobj_') + else _maybe_call(definition_value) + ) + if definition is None: + return + stage = 'access_selections' + for call in ( + lambda: definition.AccessSelections( + self.model, _empty_dispatch() + ), + lambda: definition.AccessSelections(self.model, None), + ): + try: + accessed = bool(call()) + if accessed: + break + except Exception: + pass + stage = 'query_detail_interface' + edge_definition = definition + edge_dispids = (21, 17) + for interface_iid, edges_dispid in ( + ('{9FE7C8DB-8A4C-41BB-8E3B-7600692DBC92}', 21), + ('{8427D092-A1FC-49C9-B1ED-EC52D2389E9A}', 17), + ): + try: + interface = definition._oleobj_.QueryInterface( + pythoncom.MakeIID(interface_iid), + pythoncom.IID_IDispatch, + ) + edge_definition = win32com.client.Dispatch(interface) + edge_dispids = (edges_dispid,) + break + except Exception: + pass + stage = 'read_feature_edges' + try: + edges = _maybe_call(edge_definition.Edges) + except Exception: + edges = None + for dispid in edge_dispids: + try: + edges = edge_definition._oleobj_.InvokeTypes( + dispid, + 0, + pythoncom.DISPATCH_PROPERTYGET, + (pythoncom.VT_VARIANT, 0), + (), + ) + break + except Exception: + pass + edges = ( + list(edges) + if isinstance(edges, (list, tuple)) + else ([edges] if edges is not None else []) + ) + source_catalog = self.detail_edge_catalog.get( + str(source_node_id) + ) or {} + edge_catalog = source_catalog.get('edges') or [] + feature_mappings = {} + if len(edges) == len(canonical_indices): + stage = 'persist_feature_edges' + for canonical_index, edge in zip(canonical_indices, edges): + reference = self._persistent_reference_bytes(edge) + if not reference: + raise RuntimeError( + f'empty FeatureData reference for canonical edge ' + f'{canonical_index}' + ) + feature_mappings[str(canonical_index)] = [ + base64.b64encode(reference).decode('ascii') + ] + else: + self.logs.append( + f'native detail seed count differs for ' + f'{detail_node_id}: FeatureData edges={len(edges)} ' + f'canonical={len(canonical_indices)}; applying strict ' + 'reconstruction-stage geometry mapping' + ) + entries_by_index = { + int(entry.get('canonical_index')): entry + for entry in edge_catalog + if isinstance(entry, dict) + and entry.get('canonical_index') is not None + } + selected_entries = [ + entries_by_index.get(canonical_index) + for canonical_index in canonical_indices + ] + if any(entry is None for entry in selected_entries): + raise RuntimeError( + 'canonical detail seed catalog is incomplete' + ) + selectors = [ + dict(entry.get('selector') or {}) + for entry in selected_entries + ] + feature_signatures = [ + (edge, self._edge_signature(edge)) for edge in edges + ] + individual_candidates = {} + individual_errors = {} + for selector_index, selector in enumerate(selectors): + try: + individual_candidates[selector_index] = ( + _selection_candidates_by_geometry( + feature_signatures, + selector, + f'FeatureData edge {detail_node_id}:' + f'{canonical_indices[selector_index]}', + ) + ) + except RuntimeError as exc: + individual_errors[selector_index] = exc + coalesced_pairs = _coalesced_edge_selector_pairs( + feature_signatures, selectors + ) + coalesced_pairs = [ + pair for pair in coalesced_pairs + if pair[0] in individual_errors + or pair[1] in individual_errors + ] + coalesced_candidates = {} + for left_index, right_index, candidate_index in coalesced_pairs: + candidate = feature_signatures[candidate_index][0] + coalesced_candidates[left_index] = [candidate] + coalesced_candidates[right_index] = [candidate] + for selector_index, canonical_index in enumerate( + canonical_indices + ): + candidates = coalesced_candidates.get(selector_index) + if candidates is None: + candidates = individual_candidates.get(selector_index) + if not candidates: + continue + references = [] + for edge in candidates: + reference = self._persistent_reference_bytes(edge) + if not reference: + references = [] + break + encoded = base64.b64encode(reference).decode('ascii') + if encoded not in references: + references.append(encoded) + if references: + feature_mappings[str(canonical_index)] = references + target['feature_mappings'] = feature_mappings + feature_reference_owners = {} + for canonical_index, references in feature_mappings.items(): + for reference in references: + feature_reference_owners.setdefault(reference, []).append( + int(canonical_index) + ) + target['feature_reference_owners'] = { + reference: sorted(indices) + for reference, indices in feature_reference_owners.items() + } + target['feature_seed_count'] = len(edges) + self.logs.append( + f'persisted native detail seed mapping for {detail_node_id}: ' + f'entries={len(feature_mappings)} ' + f'canonical={len(canonical_indices)}' + ) + + stage = 'persist_post_feature_edges' + selected_set = set(canonical_indices) + result_signatures = [ + (edge, self._edge_signature(edge)) + for edge in self._body_edges(result_body) + ] + post_references_to_indices = {} + post_failures = 0 + for entry in edge_catalog: + try: + canonical_index = int(entry.get('canonical_index')) + if canonical_index in selected_set: + continue + candidates = _selection_candidates_by_geometry( + result_signatures, + dict(entry.get('selector') or {}), + f'post-detail edge {detail_node_id}:' + f'{canonical_index}', + ) + if len(candidates) != 1: + post_failures += 1 + continue + reference = self._persistent_reference_bytes( + candidates[0] + ) + if not reference: + post_failures += 1 + continue + encoded = base64.b64encode(reference).decode('ascii') + post_references_to_indices.setdefault( + encoded, [] + ).append(canonical_index) + except Exception: + post_failures += 1 + post_feature_mappings = { + str(indices[0]): [reference] + for reference, indices in post_references_to_indices.items() + if len(indices) == 1 + } + target['post_feature_mappings'] = post_feature_mappings + self.logs.append( + f'persisted post-detail replacement mapping for ' + f'{detail_node_id}: entries={len(post_feature_mappings)} ' + f'failed={post_failures}' + ) + except Exception as exc: + self.logs.append( + f'could not persist native detail seed mapping for ' + f'{detail_node_id} at {stage}: {exc}' + ) + finally: + if definition is not None and accessed: + try: + definition.ReleaseSelectionAccess() + except Exception: + pass + + def _retry_pending_detail_topology(self): + for record in self.pending_detail_topology: + source_id = str(record.get('source_node_id') or '') + target_id = str(record.get('detail_node_id') or '') + source_map = self.persisted_topology_maps.get(source_id) or {} + target = (source_map.get('targets') or {}).get(target_id) or {} + if target.get('feature_mappings'): + continue + self._capture_detail_feature_topology( + record.get('feature_name') or record.get('feature'), + None, + source_id, + target_id, + ) + + def _set_document_custom_property(self, manager, name, value): + errors = [] + for call in ( + lambda: manager.Add3(str(name), 30, str(value), 2), + lambda: manager.Set2(str(name), str(value)), + ): + try: + call() + return + except Exception as exc: + errors.append(str(exc)) + raise RuntimeError( + f'could not write custom property {name!r}: {errors!r}' + ) + + def _document_custom_property_value(self, manager, name): + try: + return str(_maybe_call(manager.Get(str(name))) or '') + except Exception as exc: + raise RuntimeError( + f'could not read custom property {name!r}: {exc}' + ) from exc + + def _persist_chunked_json_property( + self, manager, prefix, payload, schema + ): + raw = json.dumps( + payload, + ensure_ascii=True, + separators=(',', ':'), + sort_keys=True, + ).encode('ascii') + encoded = base64.b64encode( + zlib.compress(raw, level=9) + ).decode('ascii') + chunk_size = 900 + chunks = [ + encoded[index:index + chunk_size] + for index in range(0, len(encoded), chunk_size) + ] or [''] + metadata = json.dumps( + { + 'schema': str(schema), + 'chunks': len(chunks), + 'sha256': hashlib.sha256(raw).hexdigest(), + }, + ensure_ascii=True, + separators=(',', ':'), + sort_keys=True, + ) + for index, chunk in enumerate(chunks): + self._set_document_custom_property( + manager, f'{prefix}.{index:04d}', chunk + ) + self._set_document_custom_property( + manager, f'{prefix}.Meta', metadata + ) + + persisted_meta = json.loads( + self._document_custom_property_value( + manager, f'{prefix}.Meta' + ) + ) + persisted_encoded = ''.join( + self._document_custom_property_value( + manager, f'{prefix}.{index:04d}' + ) + for index in range(int(persisted_meta.get('chunks', 0))) + ) + persisted_raw = zlib.decompress( + base64.b64decode(persisted_encoded.encode('ascii')) + ) + if ( + persisted_meta.get('schema') != str(schema) + or persisted_meta.get('sha256') + != hashlib.sha256(persisted_raw).hexdigest() + or persisted_raw != raw + ): + raise RuntimeError( + f'custom property readback mismatch for {prefix!r}' + ) + return len(chunks) + + def _persist_topology_maps(self, manager): + chunk_size = 900 + for source_node_id in sorted(self.persisted_topology_maps): + payload = self.persisted_topology_maps[source_node_id] + raw = json.dumps( + payload, + ensure_ascii=True, + separators=(',', ':'), + sort_keys=True, + ).encode('ascii') + encoded = base64.b64encode( + zlib.compress(raw, level=9) + ).decode('ascii') + chunks = [ + encoded[index:index + chunk_size] + for index in range(0, len(encoded), chunk_size) + ] or [''] + prefix = f'SimpleCADTopo.{source_node_id}' + try: + for index, chunk in enumerate(chunks): + self._set_document_custom_property( + manager, + f'{prefix}.{index:04d}', + chunk, + ) + metadata = json.dumps( + { + 'schema': 'simplecad-sw-topology-v1', + 'method': 'gsm', + 'chunks': len(chunks), + 'sha256': hashlib.sha256(raw).hexdigest(), + }, + ensure_ascii=True, + separators=(',', ':'), + sort_keys=True, + ) + # Meta is written last so a partial chunk set is never valid. + self._set_document_custom_property( + manager, f'{prefix}.Meta', metadata + ) + self.logs.append( + f'persisted GSM topology properties for {source_node_id}: ' + f'entries={len(payload.get("mappings") or {})} ' + f'chunks={len(chunks)}' + ) + except Exception as exc: + self.logs.append( + f'could not persist GSM topology map for ' + f'{source_node_id}: {exc}' + ) + + def _persist_document_metadata(self): + try: + manager = self.model.Extension.CustomPropertyManager('') + if self.degraded_features: + payload = json.dumps( + self.degraded_features, + ensure_ascii=True, + separators=(',', ':'), + sort_keys=True, + ) + for call in ( + lambda: manager.Add3( + 'SimpleCADDegradedFeatures', 30, payload, 2 + ), + lambda: manager.Set2( + 'SimpleCADDegradedFeatures', payload + ), + ): + try: + call() + break + except Exception: + pass + self._persist_topology_maps(manager) + native_ops = { + 'make_extrude_rsolid', 'make_revolve_rsolid', + 'make_cut_rsolid', 'make_union_rsolid', + 'make_intersect_rsolid', 'make_fillet_rsolid', + 'make_chamfer_rsolid', 'make_shell_rsolid', + 'make_translate_rshape', 'make_rotate_rshape', + 'make_mirror_rshape', + } + for node in self.nodes: + op = str(node.get('op') or '') + if op not in native_ops: + continue + node_id = str(node.get('node_id') or '') + body_names = [] + for output in self.outputs.get(node_id) or []: + if not isinstance(output, dict): + continue + if output.get('kind') == 'body': + output_names = [output.get('_body_name')] + elif output.get('kind') == 'compound': + output_names = output.get('_body_names') or [] + else: + output_names = [] + for body_name in output_names: + body_name = str(body_name or '') + if body_name and body_name not in body_names: + body_names.append(body_name) + value = json.dumps( + { + 'NodeId': node_id, + 'Op': op, + 'Params': node.get('params') or {}, + 'Inputs': self._input_ids(node), + 'BodyNames': body_names, + 'FeatureNamePrefix': f'SimpleCAD_{node_id}_', + }, + ensure_ascii=True, + separators=(',', ':'), + sort_keys=True, + ) + try: + manager.Add3( + f'SimpleCADNode.{node_id}', 30, value, 2 + ) + except Exception as exc: + self.logs.append( + f'could not persist node metadata for {node_id}: {exc}' + ) + except Exception as exc: + self.logs.append(f'could not persist degraded feature metadata: {exc}') + + def _first_output(self, node_id): + outputs = self.outputs.get(str(node_id)) or [] + if not outputs: + raise RuntimeError(f'Missing graph output for {node_id}') + return outputs[0] + + def _set_output(self, node, values): + node_id = str(node.get('node_id')) + if not isinstance(values, list): + values = [values] + for value in values: + if not isinstance(value, dict): + continue + if value.get('kind') == 'compound': + bodies = [ + body for body in value.get('bodies') or [] + if body is not None + ] + value.setdefault( + '_body_names', [self._body_name(body) for body in bodies] + ) + value.setdefault( + '_body_bboxes', [ + self._box_from_entity(body) for body in bodies + ], + ) + continue + if value.get('kind') != 'body': + continue + body = value.get('body') + if body is None: + continue + value.setdefault('_body_name', self._body_name(body)) + value.setdefault('_body_bbox', self._box_from_entity(body)) + if '_body_snapshot' not in value: + try: + value['_body_snapshot'] = self._copy_temp_body(body) + except Exception as exc: + self.logs.append( + f'body snapshot failed for {node_id}: {exc}' + ) + self.outputs[node_id] = values + return values + + def _emit_node(self, node): + op = str(node.get('op')) + params = node.get('params') or {} + node_id = str(node.get('node_id')) + inputs = self._input_ids(node) + + if op == 'make_line_redge': + return self._set_output(node, {'kind': 'edge', 'type': 'line', 'start': _v3(params.get('start')), 'end': _v3(params.get('end'))}) + if op == 'make_circle_redge': + return self._set_output(node, { + 'kind': 'edge', + 'type': 'circle', + 'center': _v3(params.get('center')), + 'radius': float(params.get('radius', 1.0)), + 'normal': _unit(params.get('normal') or (0.0, 0.0, 1.0)), + '_kernel_x_axis': params.get('_kernel_x_axis'), + '_kernel_y_axis': params.get('_kernel_y_axis'), + }) + if op == 'make_angle_arc_redge': + return self._set_output(node, { + 'kind': 'edge', + 'type': 'angle_arc', + 'center': _v3(params.get('center')), + 'radius': float(params.get('radius', 1.0)), + 'start_angle': float(params.get('start_angle', 0.0)), + 'end_angle': float(params.get('end_angle', 0.0)), + 'normal': _unit(params.get('normal') or (0.0, 0.0, 1.0)), + '_kernel_x_axis': params.get('_kernel_x_axis'), + '_kernel_y_axis': params.get('_kernel_y_axis'), + }) + if op == 'make_three_point_arc_redge': + return self._set_output(node, {'kind': 'edge', 'type': 'three_point_arc', 'start': _v3(params.get('start')), 'middle': _v3(params.get('middle')), 'end': _v3(params.get('end'))}) + if op == 'make_spline_redge': + controls = params.get('control_points') or params.get('controls') or params.get('points') or [] + return self._set_output(node, { + 'kind': 'edge', + 'type': 'spline', + 'controls': [_v3(point) for point in controls], + 'degree': int(params.get('degree') or min(3, max(1, len(controls) - 1))), + 'knots': [float(value) for value in (params.get('knots') or [])], + 'multiplicities': [int(value) for value in (params.get('multiplicities') or [])], + 'weights': [float(value) for value in (params.get('weights') or [])], + 'periodic': bool(params.get('periodic', False)), + }) + if op == 'make_helix_redge': + return self._set_output(node, {'kind': 'edge', 'type': 'helix', 'params': dict(params)}) + if op == 'make_wire_from_edges_rwire': + edges = [] + for input_id in inputs: + value = self._first_output(input_id) + if isinstance(value, dict) and value.get('kind') == 'wire': + edges.extend(value.get('edges') or []) + else: + edges.append(value) + return self._set_output(node, {'kind': 'wire', 'edges': edges}) + if op == 'make_face_from_wire_rface': + return self._set_output(node, {'kind': 'face', 'outer': self._first_output(inputs[0]), 'inners': [], 'normal': _unit(params.get('normal') or (0.0, 0.0, 1.0))}) + if op == 'make_face_from_wires_rface': + wires = [self._first_output(input_id) for input_id in inputs] + if not wires: + raise RuntimeError('make_face_from_wires_rface requires wire inputs') + return self._set_output(node, {'kind': 'face', 'outer': wires[0], 'inners': wires[1:], 'normal': _unit(params.get('normal') or (0.0, 0.0, 1.0))}) + if op in {'make_wire_from_sketch_rwire', 'make_face_from_sketch_rface'}: + raise SimpleCADUnsupportedOpError(f'{op} is not yet supported by the SolidWorks translator') + if op == 'make_extrude_rsolid': + body = self._extrude_profile(self._first_output(inputs[0]), params, node_id) + return self._set_output(node, {'kind': 'body', 'body': body}) + if op == 'make_revolve_rsolid': + body = self._revolve_profile(self._first_output(inputs[0]), params, node_id) + return self._set_output(node, {'kind': 'body', 'body': body}) + if op == 'make_loft_rsolid': + body = self._loft_profiles([self._first_output(input_id) for input_id in inputs], params, node_id) + return self._set_output(node, {'kind': 'body', 'body': body}) + if op == 'make_sweep_rsolid': + body = self._sweep_profile(self._first_output(inputs[0]), self._first_output(inputs[1]), params, node_id) + return self._set_output(node, {'kind': 'body', 'body': body}) + if op == 'make_cut_rsolid': + bases = self._bodies_from_value(self._first_output(inputs[0])) + tools = [] + for input_id in inputs[1:]: + tools.extend( + self._bodies_from_value(self._first_output(input_id)) + ) + canonical_result = self.canonical_cut_descriptors.get(node_id) + if len(bases) > 1: + # A disjoint Union is a live body set, not a failed single-body + # union. Apply the shared cutters independently to each target + # section. A cutter may belong to at most one section here; + # otherwise SolidWorks would consume the same live tool twice. + tool_owners = [] + for tool in tools: + owners = [ + index for index, base in enumerate(bases) + if self._body_intersection_has_volume(base, tool) + ] + tool_owners.append(owners) + cut_bodies = [] + for base_index, base in enumerate(bases): + section_tools = [ + tool for tool, owners in zip(tools, tool_owners) + if base_index in owners + ] + section_result = self._boolean_body( + base, + section_tools, + SWBODYCUT, + f'SimpleCAD_{node_id}_section_{base_index + 1}', + # Bbox-disjoint tools are exact no-ops for this section. + skip_non_intersecting=True, + canonical_result=None, + allow_split_sections=True, + prefer_native=True, + ) + if isinstance(section_result, list): + cut_bodies.extend(section_result) + else: + cut_bodies.append(section_result) + if ( + isinstance(canonical_result, dict) + and not self._body_set_matches_canonical( + cut_bodies, canonical_result + ) + ): + raise RuntimeError( + f'Cut SimpleCAD_{node_id} live body-set result did not ' + 'match the canonical bbox/volume descriptor' + ) + self.logs.append( + f'cut SimpleCAD_{node_id} preserved ' + f'{len(cut_bodies)} live result sections' + ) + if len(cut_bodies) > 1: + return self._set_output( + node, {'kind': 'compound', 'bodies': cut_bodies} + ) + return self._set_output( + node, {'kind': 'body', 'body': cut_bodies[0]} + ) + body = self._boolean_body( + bases[0], + tools, + SWBODYCUT, + f'SimpleCAD_{node_id}', + skip_non_intersecting=bool(params.get('skip_non_intersecting', False)), + canonical_result=canonical_result, + # Keep the edited base live even when this Cut is followed by + # another graph node; static boolean copies sever propagation. + prefer_native=True, + ) + if isinstance(body, list): + return self._set_output(node, {'kind': 'compound', 'bodies': body}) + return self._set_output(node, {'kind': 'body', 'body': body}) + if op == 'make_union_rsolid': + input_body_sets = [ + self._bodies_from_value(self._first_output(input_id)) + for input_id in inputs + ] + bodies = [ + body for body_set in input_body_sets for body in body_set + ] + if not bodies: + raise RuntimeError(f'Union SimpleCAD_{node_id} has no bodies') + passthrough_index = self.canonical_union_passthroughs.get(node_id) + if ( + isinstance(passthrough_index, int) + and 0 <= passthrough_index < len(input_body_sets) + ): + passthrough_bodies = input_body_sets[passthrough_index] + body = [ + self._transform_body_feature( + candidate, + _identity_matrix(), + f'SimpleCAD_{node_id}_canonical_passthrough_{index + 1}', + ) + for index, candidate in enumerate(passthrough_bodies) + ] + self.logs.append( + f'union SimpleCAD_{node_id} used canonical input ' + f'passthrough index {passthrough_index}' + ) + else: + contributing_indices = self.canonical_union_input_indices.get( + node_id + ) + if isinstance(contributing_indices, list): + normalized_indices = [] + for value in contributing_indices: + try: + index = int(value) + except Exception: + raise RuntimeError( + f'Union SimpleCAD_{node_id} has an invalid ' + f'canonical contributing input index: {value!r}' + ) + if ( + index < 0 + or index >= len(input_body_sets) + or index in normalized_indices + ): + raise RuntimeError( + f'Union SimpleCAD_{node_id} canonical ' + f'contributing input index is invalid: {index}' + ) + normalized_indices.append(index) + input_body_sets = [ + input_body_sets[index] + for index in normalized_indices + ] + bodies = [ + candidate + for body_set in input_body_sets + for candidate in body_set + ] + if not bodies: + raise RuntimeError( + f'Union SimpleCAD_{node_id} canonical input ' + 'selection produced no live bodies' + ) + self.logs.append( + f'union SimpleCAD_{node_id} retained canonical ' + f'contributing inputs {normalized_indices!r}' + ) + boxes = [self._box_from_entity(candidate) for candidate in bodies] + unseen = set(range(len(bodies))) + components = [] + while unseen: + start = min(unseen) + unseen.remove(start) + pending = [start] + component = [] + while pending: + current = pending.pop() + component.append(current) + neighbors = [ + candidate for candidate in sorted(unseen) + if self._body_union_is_single( + bodies[current], bodies[candidate] + ) + ] + for candidate in neighbors: + unseen.remove(candidate) + pending.append(candidate) + components.append(sorted(component)) + if len(components) > 1: + # Bbox-disconnected components cannot intersect. Combine + # only within each connected group, then preserve the + # exact union as a live body set across the groups. + component_body_sets = [] + for component_index, component in enumerate(components): + component_bodies = [bodies[index] for index in component] + if len(component_bodies) == 1: + component_result = component_bodies[0] + else: + component_result = self._boolean_body( + component_bodies[0], + component_bodies[1:], + SWBODYADD, + f'SimpleCAD_{node_id}_component_' + f'{component_index + 1}', + clean=bool(params.get('clean', False)), + prefer_native=True, + ) + component_body_sets.append( + list(component_result) + if isinstance(component_result, list) + else [component_result] + ) + canonical_union = self.canonical_union_descriptors.get( + node_id + ) + body = [ + candidate + for body_set in component_body_sets + for candidate in body_set + ] + if isinstance(canonical_union, dict): + matching_subsets = [] + if len(component_body_sets) <= 12: + for mask in range( + 1, 1 << len(component_body_sets) + ): + subset = [ + candidate + for index, body_set in enumerate( + component_body_sets + ) + if mask & (1 << index) + for candidate in body_set + ] + if self._body_set_matches_canonical( + subset, canonical_union + ): + matching_subsets.append((mask, subset)) + elif self._body_set_matches_canonical( + body, canonical_union + ): + matching_subsets.append((None, body)) + if len(matching_subsets) != 1: + raise RuntimeError( + f'Union SimpleCAD_{node_id} did not have a ' + 'unique bbox-disconnected component subset ' + 'matching the canonical bbox/volume; ' + f'matches={len(matching_subsets)} ' + f'components={len(component_body_sets)} ' + f'canonical={canonical_union!r}' + ) + selected_mask, body = matching_subsets[0] + self.logs.append( + f'union SimpleCAD_{node_id} selected canonical ' + f'component subset mask={selected_mask!r}' + ) + self.logs.append( + f'union SimpleCAD_{node_id} preserved ' + f'{len(body)} bbox-disconnected live components ' + f'from {len(bodies)} input bodies' + ) + else: + body = self._boolean_body( + bodies[0], + bodies[1:], + SWBODYADD, + f'SimpleCAD_{node_id}', + clean=bool(params.get('clean', False)), + canonical_result=( + self.canonical_union_descriptors.get(node_id) + ), + prefer_native=True, + ) + if isinstance(body, list): + if len(body) == 1: + return self._set_output( + node, {'kind': 'body', 'body': body[0]} + ) + return self._set_output( + node, {'kind': 'compound', 'bodies': body} + ) + return self._set_output(node, {'kind': 'body', 'body': body}) + if op == 'make_intersect_rsolid': + bodies = [self._body_from_value(self._first_output(input_id)) for input_id in inputs] + body = self._boolean_body( + bodies[0], + bodies[1:], + SWBODYINTERSECT, + f'SimpleCAD_{node_id}', + prefer_native=True, + ) + return self._set_output(node, {'kind': 'body', 'body': body}) + if op == 'make_select_redge': + payload = {'kind': 'edge', 'params': params, 'input': inputs[0] if inputs else None} + self.selection_payloads[node_id] = payload + if os.environ.get('SIMPLECAD_SW_TRACE_SELECTIONS') == '1' and inputs: + try: + source = self._body_from_value(self._first_output(inputs[0])) + selector = params.get('geo_selector') or params + signatures = [ + self._edge_signature(edge) + for edge in self._body_edges(source) + ] + ranked = sorted( + signatures, + key=lambda signature: _geom_score(signature, selector), + ) + trace = { + 'node_id': node_id, + 'source_id': inputs[0], + 'best_score': ( + _geom_score(ranked[0], selector) + if ranked else None + ), + 'type_mismatch': ( + _geom_type_mismatch(ranked[0], selector) + if ranked else None + ), + 'selector': _selector_geometry(selector), + 'best': ranked[0] if ranked else None, + } + print( + 'SIMPLECAD_SW_SELECTION=' + json.dumps( + trace, ensure_ascii=True, sort_keys=True + ), + flush=True, + ) + except Exception as exc: + print( + f'SIMPLECAD_SW_SELECTION_ERROR={node_id}:{exc}', + flush=True, + ) + return self._set_output(node, payload) + if op == 'make_select_rface': + payload = {'kind': 'face', 'params': params, 'input': inputs[0] if inputs else None} + self.selection_payloads[node_id] = payload + return self._set_output(node, payload) + if op == 'make_fillet_rsolid': + body = self._feature_detail_edges(params, inputs, 'fillet', node_id) + if isinstance(body, list): + return self._set_output( + node, {'kind': 'compound', 'bodies': body} + ) + return self._set_output(node, {'kind': 'body', 'body': body}) + if op == 'make_chamfer_rsolid': + body = self._feature_detail_edges(params, inputs, 'chamfer', node_id) + if isinstance(body, list): + return self._set_output( + node, {'kind': 'compound', 'bodies': body} + ) + return self._set_output(node, {'kind': 'body', 'body': body}) + if op == 'make_shell_rsolid': + body = self._feature_shell(params, inputs, node_id) + return self._set_output(node, {'kind': 'body', 'body': body}) + if op == 'make_translate_rshape': + value = self._first_output(inputs[0]) + vector = _v3(params.get('vector') or (0.0, 0.0, 0.0)) + if isinstance(value, dict) and value.get('kind') in {'edge', 'wire', 'face'}: + transformed = _transform_geometry_value( + value, + lambda point: _add(_v3(point), vector), + lambda direction: _v3(direction), + ) + return self._set_output(node, transformed) + bodies = [ + self._transform_body_feature( + body, + _translation_matrix(vector), + f'SimpleCAD_{node_id}_{index + 1}', + ) + for index, body in enumerate(self._bodies_from_value(value)) + ] + if len(bodies) > 1: + return self._set_output( + node, {'kind': 'compound', 'bodies': bodies} + ) + return self._set_output(node, {'kind': 'body', 'body': bodies[0]}) + if op == 'make_rotate_rshape': + value = self._first_output(inputs[0]) + axis = params.get('axis') or (0.0, 0.0, 1.0) + angle = params.get('angle', 0.0) + origin = params.get('origin') or (0.0, 0.0, 0.0) + if isinstance(value, dict) and value.get('kind') in {'edge', 'wire', 'face'}: + transformed = _transform_geometry_value( + value, + lambda point: _rotate_point_payload(point, axis, angle, origin), + lambda direction: _rotate_direction_payload(direction, axis, angle), + ) + return self._set_output(node, transformed) + bodies = [ + self._rotate_body_feature( + body, + axis, + angle, + origin, + f'SimpleCAD_{node_id}_{index + 1}', + ) + for index, body in enumerate(self._bodies_from_value(value)) + ] + if len(bodies) > 1: + return self._set_output( + node, {'kind': 'compound', 'bodies': bodies} + ) + return self._set_output(node, {'kind': 'body', 'body': bodies[0]}) + if op == 'make_mirror_rshape': + value = self._first_output(inputs[0]) + plane_origin = params.get('plane_origin') or (0.0, 0.0, 0.0) + plane_normal = params.get('plane_normal') or (0.0, 0.0, 1.0) + if isinstance(value, dict) and value.get('kind') in {'edge', 'wire', 'face'}: + transformed = _transform_geometry_value( + value, + lambda point: _mirror_point_payload(point, plane_origin, plane_normal), + lambda direction: _mirror_direction_payload(direction, plane_normal), + ) + return self._set_output(node, transformed) + body = self._transform_body_feature(self._body_from_value(value), _mirror_matrix(plane_origin, plane_normal), f'SimpleCAD_{node_id}') + return self._set_output(node, {'kind': 'body', 'body': body}) + if op == 'make_material_rmaterial': + return self._set_output(node, {'kind': 'material', 'params': params}) + if op in {'make_placement_rplacement', 'make_identity_placement_rplacement'}: + return self._set_output(node, {'kind': 'placement', 'params': params}) + if op == 'make_part_rpart': + value = {'kind': 'part', 'params': params, 'body_node': inputs[0], 'body': self._first_output(inputs[0])} + self.product_values[node_id] = value + return self._set_output(node, value) + if op == 'make_assign_material_rpart': + value = dict(self._first_output(inputs[0])) + value['material'] = self._first_output(inputs[1]) + self.product_values[node_id] = value + return self._set_output(node, value) + if op == 'make_assembly_rassembly': + value = {'kind': 'assembly', 'params': params, 'components': []} + self.product_values[node_id] = value + return self._set_output(node, value) + if op == 'make_add_component_rassembly': + assembly = dict(self._first_output(inputs[0])) + components = list(assembly.get('components') or []) + components.append({ + 'component_id': str(params.get('component_id') or ''), + 'item': self._first_output(inputs[1]), + 'placement': self._first_output(inputs[2]) if len(inputs) > 2 else {'kind': 'placement', 'params': {}}, + 'params': params, + }) + assembly['components'] = components + self.product_values[node_id] = assembly + return self._set_output(node, assembly) + if op == 'make_place_component_rassembly': + assembly = dict(self._first_output(inputs[0])) + placement = self._first_output(inputs[1]) + component_id = str(params.get('component_id') or '') + components = [] + for component in assembly.get('components') or []: + component = dict(component) + if str(component.get('component_id') or '') == component_id: + component['placement'] = placement + components.append(component) + assembly['components'] = components + self.product_values[node_id] = assembly + return self._set_output(node, assembly) + if op == 'make_solve_assembly_constraints_rassembly': + assembly = dict(self._first_output(inputs[0])) + solved_placements = dict(params.get('component_placements') or {}) + components = [] + for component in assembly.get('components') or []: + component = dict(component) + component_id = str(component.get('component_id') or '') + if component_id in solved_placements: + component['placement'] = { + 'kind': 'placement', + 'params': dict(solved_placements[component_id]), + } + components.append(component) + assembly['components'] = components + self.product_values[node_id] = assembly + return self._set_output(node, assembly) + if op == 'make_compound_from_assembly_rcompound': + bodies = self._materialize_product_bodies( + self._first_output(inputs[0]), + f'SimpleCAD_{node_id}_component', + ) + if not bodies: + raise RuntimeError('assembly compound has no bodies') + return self._set_output(node, {'kind': 'compound', 'bodies': bodies}) + if op.startswith('make_') and op.endswith('_rconnector'): + return self._set_output(node, {'kind': 'connector', 'params': params}) + if op in { + 'make_add_connector_rpart', 'make_add_connector_rassembly', + 'make_connector_ref_rconnectorref', 'make_scalar_limit_rscalarlimit', + 'make_ground_component_rassembly', 'make_unground_component_rassembly', + 'make_fixed_constraint_rassembly', 'make_revolute_constraint_rassembly', + 'make_prismatic_constraint_rassembly', + }: + value = self._first_output(inputs[0]) if inputs else {'kind': op, 'params': params} + return self._set_output(node, value) + raise SimpleCADUnsupportedOpError(f'Unsupported SimpleCAD op for SolidWorks translation: {op}') + + def _body_from_value(self, value): + if hasattr(value, 'GetFaces'): + return value + if isinstance(value, dict): + if value.get('kind') == 'body': + return self._resolve_body_reference( + value.get('body'), + value.get('_body_name'), + value.get('_body_bbox'), + value.get('_body_snapshot'), + ) + if value.get('kind') == 'part': + return self._body_from_value(value.get('body')) + if value.get('kind') == 'compound': + bodies = self._bodies_from_value(value) + if len(bodies) == 1: + return bodies[0] + raise RuntimeError( + f'Expected one SolidWorks body, got a live body set with ' + f'{len(bodies)} sections' + ) + raise RuntimeError(f'Expected a SolidWorks body, got {type(value).__name__}') + + def _bodies_from_value(self, value): + if isinstance(value, dict) and value.get('kind') == 'compound': + bodies = [ + body for body in value.get('bodies') or [] + if body is not None + ] + if not bodies: + raise RuntimeError('Expected a non-empty SolidWorks body set') + return bodies + return [self._body_from_value(value)] + + def _resolve_body_reference( + self, + body, + expected_name=None, + expected_bbox=None, + body_snapshot=None, + ): + fresh_bodies = self._solid_bodies() + + def com_identity(entity): + try: + unknown = entity._oleobj_.QueryInterface( + pythoncom.IID_IUnknown + ) + return ('com', hash(unknown)) + except Exception: + return ('python', id(entity)) + + if body is not None: + direct_identity = com_identity(body) + direct_matches = [ + candidate for candidate in fresh_bodies + if com_identity(candidate) == direct_identity + ] + if len(direct_matches) == 1: + return direct_matches[0] + + ambiguous = None + + def unique_bbox_candidate(candidates, label): + nonlocal ambiguous + ranked = sorted( + ( + _bbox_score( + self._box_from_entity(candidate), expected_bbox + ), + index, + candidate, + ) + for index, candidate in enumerate(candidates) + ) + if not ranked: + return None + best_score = ranked[0][0] + ties = [ + item for item in ranked + if abs(item[0] - best_score) <= 1.0e-12 + ] + if len(ties) == 1: + return ties[0][2] + ambiguous = ( + f'{label} body lookup tied across {len(ties)} candidates ' + f'at bbox score {best_score}' + ) + return None + + if expected_name: + named = [ + candidate for candidate in fresh_bodies + if self._body_name(candidate) == str(expected_name) + ] + if len(named) == 1: + return named[0] + if named and isinstance(expected_bbox, dict): + named_match = unique_bbox_candidate(named, 'named') + if named_match is not None: + return named_match + if isinstance(expected_bbox, dict): + expected_size = _distance( + expected_bbox.get('min'), expected_bbox.get('max') + ) + if expected_size > 1.0e-12 and fresh_bodies: + best_body = unique_bbox_candidate( + fresh_bodies, 'global' + ) + if best_body is not None: + best_score = _bbox_score( + self._box_from_entity(best_body), expected_bbox + ) + if best_score <= max( + 1.0e-7, expected_size * 1.0e-7 + ): + return best_body + if ambiguous is not None: + raise RuntimeError( + f'Ambiguous SolidWorks body reference for ' + f'name={expected_name!r} bbox={expected_bbox!r}: {ambiguous}' + ) + if body_snapshot is not None: + snapshot_bbox = self._box_from_entity(body_snapshot) + if self._body_faces(body_snapshot) and ( + not isinstance(expected_bbox, dict) + or _distance( + expected_bbox.get('min'), expected_bbox.get('max') + ) <= 1.0e-12 + or _bbox_score(snapshot_bbox, expected_bbox) + <= max( + 1.0e-7, + _distance( + expected_bbox.get('min'), expected_bbox.get('max') + ) * 1.0e-7, + ) + ): + return body_snapshot + if body is not None: + return body + raise RuntimeError( + f'Could not resolve SolidWorks body reference ' + f'name={expected_name!r} bbox={expected_bbox!r}' + ) + + def _bodies_from_product(self, value): + if isinstance(value, dict) and value.get('kind') == 'part': + return self._bodies_from_product(value.get('body')) + if isinstance(value, dict) and value.get('kind') == 'assembly': + bodies = [] + for component in value.get('components') or []: + bodies.extend(self._bodies_from_product(component.get('item'))) + return bodies + if isinstance(value, dict) and value.get('kind') == 'body': + return [self._body_from_value(value)] + if isinstance(value, dict) and value.get('kind') == 'compound': + return [body for body in value.get('bodies') or [] if body is not None] + return [] + + def _product_body_instances( + self, value, placements=(), path=(), body_node_id='' + ): + if isinstance(value, dict) and value.get('kind') == 'part': + return self._product_body_instances( + value.get('body'), + placements, + path, + str(value.get('body_node') or body_node_id or ''), + ) + if isinstance(value, dict) and value.get('kind') == 'assembly': + instances = [] + for index, component in enumerate(value.get('components') or []): + component_id = str( + component.get('component_id') + or (component.get('params') or {}).get('component_id') + or index + ) + component_placement = component.get('placement') or { + 'kind': 'placement', + 'params': {}, + } + instances.extend( + self._product_body_instances( + component.get('item'), + placements + (component_placement,), + path + (component_id,), + body_node_id, + ) + ) + return instances + if isinstance(value, dict) and value.get('kind') == 'body': + return [( + self._body_from_value(value), placements, path, body_node_id + )] + if isinstance(value, dict) and value.get('kind') == 'compound': + return [ + (body, placements, path + (str(index),), body_node_id) + for index, body in enumerate(value.get('bodies') or []) + if body is not None + ] + return [] + + def _materialize_product_bodies(self, value, name_prefix): + bodies = [] + for ( + body, placements, path, explicit_body_node_id + ) in self._product_body_instances(value): + if body is None: + continue + matrices = [_placement_matrix(placement) for placement in placements] + has_transform = any(not _is_identity_matrix(matrix) for matrix in matrices) + source_name = self._body_name(body) + source_tokens = source_name.split('_') + inferred_source_node_id = ( + f'node_{source_tokens[2]}' + if len(source_tokens) >= 3 + and source_tokens[0] == 'SimpleCAD' + and source_tokens[1] == 'node' + else '' + ) + source_node_id = str( + explicit_body_node_id or inferred_source_node_id + ) + dependency_node_ids = sorted( + self._result_dependency_ids([source_node_id]) + ) + if source_node_id and source_node_id not in dependency_node_ids: + raise RuntimeError( + f'Could not resolve dependency graph for terminal part ' + f'body {source_node_id}' + ) + source_owner = '' + for getter_name in ('GetFeature', 'IGetFeature'): + try: + owner = _maybe_call(getattr(body, getter_name)) + if owner is not None: + source_owner = str(_maybe_call(owner.Name) or '') + if source_owner: + break + except Exception: + pass + source_expected_bbox = self._box_from_entity(body) + # Capture while the terminal branch still exposes a live Body2. + # The assembly writer first tries a derived part linked to the + # saved master; this detached body is only the cross-document + # transport/fallback and must not be captured after Keep Bodies. + prepared_source_body = self._copy_temp_body(body) + source_key = source_owner or source_name + may_reuse_source = ( + not path + and not has_transform + and source_key not in self.materialized_source_body_keys + ) + self.logs.append( + f'product body path={path!r} owner={source_owner!r} ' + f'name={source_name!r} transformed={has_transform!r} ' + f'reuse_live={may_reuse_source!r}' + ) + if may_reuse_source: + self.materialized_source_body_keys.add(source_key) + bodies.append(body) + if path: + self.assembly_occurrences.append({ + 'component_id': str(path[0]), + 'path': tuple(str(part) for part in path), + 'placements': tuple(placements), + 'body': body, + 'source_body': body, + 'prepared_source_body': prepared_source_body, + 'source_body_name': source_name, + 'source_owner': source_owner, + 'source_expected_bbox': source_expected_bbox, + 'source_node_id': source_node_id, + 'dependency_node_ids': dependency_node_ids, + }) + continue + + self.component_instance_counter += 1 + path_token = '_'.join(str(part) for part in path if str(part)) + instance_name = ( + f'{name_prefix}_{path_token}_{self.component_instance_counter}' + if path_token + else f'{name_prefix}_{self.component_instance_counter}' + ) + can_materialize_live = all( + _is_identity_matrix(matrix) + or _is_translation_matrix(matrix) + for matrix in matrices + ) + if can_materialize_live: + # Preserve the unplaced source as a live body in the package + # master. Translation Move/Copy features may move their input + # in place, so placements must operate on a dependent copy. + instance_body = self._transform_body_feature( + body, + _identity_matrix(), + f'{instance_name}_source_copy', + ) + transformed = False + for placement_index, matrix in enumerate(reversed(matrices)): + if _is_identity_matrix(matrix): + continue + instance_body = self._transform_body_feature( + instance_body, + matrix, + f'{instance_name}_placement_{placement_index + 1}', + ) + transformed = True + self.logs.append( + f'{instance_name} uses live Move/Copy Body materialization' + + (' with placement' if transformed else '') + ) + else: + temp_body = self._copy_temp_body(body) + for matrix in reversed(matrices): + if not _is_identity_matrix(matrix): + self._apply_transform_to_temp_body(temp_body, matrix) + self._mark_degraded(instance_name, 'static_component_body') + instance_body = self._create_feature_from_body( + temp_body, instance_name + ) + bodies.append(instance_body) + if path: + self.assembly_occurrences.append({ + 'component_id': str(path[0]), + 'path': tuple(str(part) for part in path), + 'placements': tuple(placements), + 'body': instance_body, + 'source_body': body, + 'prepared_source_body': prepared_source_body, + 'source_body_name': source_name, + 'source_owner': source_owner, + 'source_expected_bbox': source_expected_bbox, + 'source_node_id': source_node_id, + 'dependency_node_ids': dependency_node_ids, + }) + return bodies + + def _result_bodies(self): + if not self.result_node_ids: + self.result_node_ids = [str(node.get('node_id')) for node in self.nodes[-1:]] + result_node_ids = list(self.result_node_ids) + bodies = [] + for node_id in result_node_ids: + for value in self.outputs.get(str(node_id), []): + if isinstance(value, dict) and value.get('kind') in {'part', 'assembly'}: + if value.get('kind') == 'assembly': + bodies.extend( + self._materialize_product_bodies( + value, + f'SimpleCAD_{node_id}_component', + ) + ) + else: + bodies.extend(self._bodies_from_product(value)) + elif isinstance(value, dict) and value.get('kind') == 'body': + body = self._body_from_value(value) + # SolidWorks Body2 has no editable Placement property. A + # zero-displacement Move/Copy Body feature is the native + # equivalent for result-level placement edits: it keeps a + # live dependency on the reconstructed result while giving + # the reopened document persistent TransformX/Y/Z values. + body = self._transform_body_feature( + body, + _identity_matrix(), + f'SimpleCAD_{node_id}_placement', + ) + bodies.append(body) + elif isinstance(value, dict) and value.get('kind') == 'compound': + bodies.extend(value.get('bodies') or []) + elif hasattr(value, 'GetFaces'): + bodies.append(value) + return [body for body in bodies if body is not None] + + def _solid_bodies(self): + bodies = None + for call in ( + lambda: self.model.GetBodies2(SW_SOLID_BODY, False), + lambda: self.model.GetBodies2(SW_SOLID_BODY, True), + ): + try: + bodies = call() + if bodies: + break + except Exception: + bodies = None + if bodies is None: + return [] + if isinstance(bodies, tuple): + return list(bodies) + if isinstance(bodies, list): + return bodies + try: + return list(bodies) + except Exception: + return [bodies] + + def _body_name(self, body): + try: + return str(body.Name) + except Exception: + pass + try: + return str(body.GetName()) + except Exception: + return str(id(body)) + + def _body_names(self): + return {self._body_name(body) for body in self._solid_bodies()} + + def _body_geometry_key(self, body): + bbox = self._box_from_entity(body) + coordinates = tuple( + round(float(value), 9) + for point in (bbox.get('min'), bbox.get('max')) + for value in _v3(point) + ) + return self._body_name(body), coordinates + + def _capture_new_body( + self, before_names, feature=None, expected_bbox=None, fallback_body=None + ): + def com_identity(entity): + try: + unknown = entity._oleobj_.QueryInterface( + pythoncom.IID_IUnknown + ) + return ('com', hash(unknown)) + except Exception: + return ('python', id(entity)) + + def unique_by_identity(values): + unique = {} + for value in values: + if value is not None: + unique.setdefault(com_identity(value), value) + return list(unique.values()) + + feature_candidates = [] + if feature is not None: + # IFeature.GetBody is obsolete and can return an edit-state proxy. + # The supported Feature.GetFaces -> Face2.GetBody chain binds the + # feature to the body it actually produced or modified, which is + # essential when several document bodies have the same bbox. + for getter_name in ('GetFaces', 'IGetFaces2'): + try: + faces = _maybe_call(getattr(feature, getter_name)) + except Exception: + faces = None + if not faces: + continue + if not isinstance(faces, (list, tuple)): + faces = [faces] + for face in faces: + for body_getter in ('GetBody', 'IGetBody'): + try: + body = _maybe_call(getattr(face, body_getter)) + except Exception: + body = None + if body is not None: + try: + body = win32com.client.CastTo(body, 'IBody2') + except Exception: + pass + feature_candidates.append(body) + break + if feature_candidates: + break + feature_candidates = unique_by_identity(feature_candidates) + for getter_name in ('GetBody', 'IGetBody2'): + try: + body = _maybe_call(getattr(feature, getter_name)) + if body is not None: + try: + body = win32com.client.CastTo(body, 'IBody2') + except Exception: + pass + feature_candidates.append(body) + except Exception: + pass + feature_candidates = unique_by_identity(feature_candidates) + for dispid in (62, 30): + try: + body = feature._oleobj_.InvokeTypes( + dispid, + 0, + pythoncom.DISPATCH_METHOD, + (pythoncom.VT_DISPATCH, 0), + (), + ) + if body is not None: + body = win32com.client.Dispatch(body) + try: + body = win32com.client.CastTo(body, 'IBody2') + except Exception: + pass + feature_candidates.append(body) + except Exception: + pass + if len(feature_candidates) == 1: + candidate = feature_candidates[0] + candidate_name = self._body_name(candidate) + if candidate_name and candidate_name not in before_names: + try: + feature_name = str(_maybe_call(feature.Name) or '') + except Exception: + feature_name = '' + if feature_name: + try: + candidate.Name = f'{feature_name}_body' + except Exception as exc: + self.logs.append( + f'could not persist result body name for ' + f'{feature_name}: {exc}' + ) + return candidate + bodies = self._solid_bodies() + fresh_candidates = [ + body for body in bodies + if self._body_name(body) not in before_names + ] + if feature_candidates: + # Feature.GetBody can return an edit-state/transient Body2 proxy. + # Prefer the reopened-document body with the same persistent name + # so downstream native features can select it and keep dependency + # propagation alive. + feature_keys = { + com_identity(body) for body in feature_candidates + } + persistent_matches = [ + body for body in bodies + if com_identity(body) in feature_keys + ] + candidates = ( + list(persistent_matches) + or list(fresh_candidates) + or list(feature_candidates) + ) + else: + # Bodies enumerated from the document are persistent and remain + # copyable after the feature manager releases its transient proxy. + candidates = list(fresh_candidates) + if not candidates: + candidates.extend(bodies) + if not candidates and fallback_body is not None: + try: + if self._body_faces(fallback_body): + candidates.append(fallback_body) + self.logs.append( + 'feature result body reused its in-place SolidWorks body' + ) + except Exception: + pass + if not candidates: + raise RuntimeError('Could not identify SolidWorks feature result body') + if candidates: + candidates = unique_by_identity(candidates) + if isinstance(expected_bbox, dict): + selected = min( + candidates, + key=lambda body: _bbox_score( + self._box_from_entity(body), expected_bbox + ), + ) + else: + selected = candidates[-1] + if feature is not None: + try: + feature_name = str(_maybe_call(feature.Name) or '') + except Exception: + feature_name = '' + if feature_name: + try: + selected.Name = f'{feature_name}_body' + except Exception as exc: + self.logs.append( + f'could not persist result body name for ' + f'{feature_name}: {exc}' + ) + return selected + + def _select_entity(self, entity, append=False, mark=0): + for call in ( + lambda: entity.Select2(bool(append), mark), + lambda: entity.Select2(bool(append), None), + lambda: entity.Select2(bool(append), _empty_dispatch()), + lambda: entity.Select4(bool(append), _empty_dispatch()), + lambda: entity.Select(bool(append), mark), + lambda: entity.Select(bool(append)), + ): + try: + if call(): + return True + except Exception: + pass + return False + + def _select_solid_body(self, body, *, append=False, mark=1): + body_name = self._body_name(body) + if body_name: + try: + if self.model.Extension.SelectByID2( + body_name, + 'SOLIDBODY', + 0.0, + 0.0, + 0.0, + bool(append), + int(mark), + _empty_dispatch(), + 0, + ): + return True + except Exception: + pass + return self._select_entity(body, append=append, mark=mark) + + def _group_sweep_path_selection(self): + try: + return bool(self.model.Extension.SelectByID2( + 'Unknown', 'SELOBJGROUP', + 0.0, 0.0, 0.0, + True, 4, _empty_dispatch(), 0, + )) + except Exception as exc: + self.logs.append(f'could not group sweep path segments: {exc}') + return False + + def _clear_selection(self): + try: + self.model.ClearSelection2(True) + except Exception: + pass + + def _selection_state(self): + state = [] + try: + raw_manager = self.model._oleobj_.InvokeTypes( + 65537, + 0, + pythoncom.DISPATCH_PROPERTYGET, + (pythoncom.VT_DISPATCH, 0), + (), + ) + manager = win32com.client.Dispatch(raw_manager) + count = int(manager._oleobj_.InvokeTypes( + 1, 0, pythoncom.DISPATCH_METHOD, (pythoncom.VT_I4, 0), () + )) + except Exception: + return state + for index in range(1, count + 1): + try: + object_type = int(manager._oleobj_.InvokeTypes( + 14, + 0, + pythoncom.DISPATCH_METHOD, + (pythoncom.VT_I4, 0), + ((pythoncom.VT_I4, pythoncom.PARAMFLAG_FIN),), + index, + )) + except Exception: + object_type = None + try: + mark = int(manager._oleobj_.InvokeTypes( + 17, + 0, + pythoncom.DISPATCH_METHOD, + (pythoncom.VT_I4, 0), + ((pythoncom.VT_I4, pythoncom.PARAMFLAG_FIN),), + index, + )) + except Exception: + mark = None + state.append({'type': object_type, 'mark': mark}) + return state + + def _features(self, type_names=None): + wanted = set(type_names or []) + features = [] + try: + feature = self.model.FirstFeature + except Exception: + feature = None + guard = 0 + while feature is not None and guard < 10000: + guard += 1 + try: + type_name = str(feature.GetTypeName2) + except Exception: + type_name = '' + if not wanted or type_name in wanted: + features.append(feature) + try: + feature = feature.GetNextFeature + except Exception: + break + return features + + def _feature_identity(self, feature): + try: + return str(feature.GetID()) + except Exception: + pass + try: + return str(feature.Name) + except Exception: + return str(id(feature)) + + def _sketch_segments(self, sketch_feature): + feature_id = self._feature_identity(sketch_feature) + registered = self.sketch_segments.get(feature_id) + if registered: + return list(registered) + sketch = None + for getter_name in ('GetSpecificFeature2', 'GetSpecificFeature'): + try: + sketch = _maybe_call(getattr(sketch_feature, getter_name)) + if sketch is not None: + break + except Exception: + sketch = None + if sketch is None: + return [] + try: + segments = sketch.GetSketchSegments() + except Exception: + segments = None + if not segments: + return [] + return list(segments) if isinstance(segments, (list, tuple)) else [segments] + + def _sketch_contours(self, sketch_feature): + """Return sketch contours without depending on their returned order.""" + sketch = None + for getter_name in ('GetSpecificFeature2', 'GetSpecificFeature'): + try: + sketch = _maybe_call(getattr(sketch_feature, getter_name)) + if sketch is not None: + break + except Exception: + sketch = None + if sketch is None: + return [] + try: + contours = sketch.GetSketchContours() + except Exception: + contours = None + if not contours: + return [] + return list(contours) if isinstance(contours, (list, tuple)) else [contours] + + def _sketch_regions(self, sketch_feature): + sketch = None + for getter_name in ('GetSpecificFeature2', 'GetSpecificFeature'): + try: + sketch = _maybe_call(getattr(sketch_feature, getter_name)) + if sketch is not None: + break + except Exception: + sketch = None + if sketch is None: + return [] + try: + regions = sketch.GetSketchRegions() + except Exception: + regions = None + if not regions: + return [] + return list(regions) if isinstance(regions, (list, tuple)) else [regions] + + def _sketch_segment_endpoint_gaps(self, sketch_feature): + def point_coords(segment, getter_names): + for getter_name in getter_names: + try: + point = _maybe_call(getattr(segment, getter_name)) + except Exception: + continue + if point is None: + continue + if isinstance(point, (list, tuple)) and len(point) >= 3: + return tuple(float(value) for value in point[:3]) + try: + return (float(point.X), float(point.Y), float(point.Z)) + except Exception: + pass + return None + + endpoints = [] + for segment in self._sketch_segments(sketch_feature): + endpoints.append(( + point_coords(segment, ('GetStartPoint2', 'GetStartPoint')), + point_coords(segment, ('GetEndPoint2', 'GetEndPoint')), + )) + gaps = [] + if endpoints: + for index, (_start, end) in enumerate(endpoints): + next_start = endpoints[(index + 1) % len(endpoints)][0] + gap = None + if end is not None and next_start is not None: + gap = _distance(end, next_start) + gaps.append({ + 'after': index, + 'end': end, + 'next_start': next_start, + 'gap_m': gap, + }) + return gaps + + def _reference_plane_normal(self, feature): + try: + ref_plane = feature.GetSpecificFeature2 + transform = ref_plane.Transform + data = tuple(float(value) for value in transform.ArrayData) + if len(data) >= 9: + return _unit((data[6], data[7], data[8])) + except Exception: + pass + return None + + def _select_plane(self, axis): + axis = _unit(axis) + abs_axis = [abs(axis[0]), abs(axis[1]), abs(axis[2])] + dominant = abs_axis.index(max(abs_axis)) + self._clear_selection() + planes = [] + for index, feature in enumerate(self._features({'RefPlane'})): + normal = self._reference_plane_normal(feature) + score = 1.0e9 + if normal is not None: + score = 1.0 - abs(_dot(normal, axis)) + elif index == {2: 0, 1: 1, 0: 2}.get(dominant, 0): + score = 0.0 + planes.append((score, index, feature)) + for _score, _index, feature in sorted(planes, key=lambda item: (item[0], item[1])): + try: + if feature.Select2(False, 0): + selected_normal = self._reference_plane_normal(feature) + if selected_normal is None: + selected_normal = tuple( + 1.0 if index == dominant else 0.0 + for index in range(3) + ) + return dominant, selected_normal + except Exception: + pass + # Standard part planes can remain selectable by name even when the + # feature-tree COM enumerator is temporarily stale. + named_planes = ( + ('Front Plane', (0.0, 0.0, 1.0)), + ('Top Plane', (0.0, 1.0, 0.0)), + ('Right Plane', (1.0, 0.0, 0.0)), + ) + for _name, normal in sorted( + named_planes, + key=lambda item: 1.0 - abs(_dot(item[1], axis)), + ): + try: + if self.model.Extension.SelectByID2( + _name, 'PLANE', 0.0, 0.0, 0.0, + False, 0, _empty_dispatch(), 0, + ): + return dominant, normal + except Exception: + pass + raise RuntimeError('Could not select a SolidWorks base plane') + + def _select_profile_plane(self, axis, offset): + dominant, base_normal = self._select_plane(axis) + if abs(float(offset)) <= 1.0e-9: + return dominant, base_normal + before = { + self._feature_identity(feature) + for feature in self._features({'RefPlane'}) + } + constraint = 8 | (256 if float(offset) < 0.0 else 0) + ref_plane = self.model.FeatureManager.InsertRefPlane( + constraint, + _as_m(abs(float(offset))), + 0, + 0.0, + 0, + 0.0, + ) + if ref_plane is None: + raise RuntimeError('SolidWorks did not create the profile offset plane') + self._clear_selection() + new_planes = [ + feature + for feature in self._features({'RefPlane'}) + if self._feature_identity(feature) not in before + ] + selected = False + selected_normal = None + for plane in reversed(new_planes): + if self._select_entity(plane, append=False): + selected = True + selected_normal = self._reference_plane_normal(plane) + break + if not selected: + selected = self._select_entity(ref_plane, append=False) + selected_normal = self._reference_plane_normal(ref_plane) + if not selected: + raise RuntimeError('Could not select the generated profile offset plane') + return dominant, selected_normal or base_normal + + def _profile_edges(self, profile): + if isinstance(profile, dict) and profile.get('kind') == 'face': + edges = [] + outer = profile.get('outer') or {} + edges.extend(outer.get('edges') or []) + for inner in profile.get('inners') or []: + edges.extend(inner.get('edges') or []) + return edges + if isinstance(profile, dict) and profile.get('kind') == 'wire': + return profile.get('edges') or [] + if isinstance(profile, dict) and profile.get('kind') == 'edge': + return [profile] + raise RuntimeError('Expected a SimpleCAD profile edge/wire/face payload') + + def _profile_normal(self, profile, fallback=(0.0, 0.0, 1.0)): + hint = fallback + if isinstance(profile, dict) and isinstance(profile.get('normal'), (list, tuple)): + hint = profile.get('normal') + points = self._profile_points(profile) + if len(points) >= 3: + origin = points[0] + axis_point = max(points[1:], key=lambda point: _distance(point, origin)) + axis = _sub(axis_point, origin) + normal_point = max( + points[1:], + key=lambda point: _norm(_cross(axis, _sub(point, origin))), + ) + normal = _cross(axis, _sub(normal_point, origin)) + if _norm(normal) > 1.0e-9: + normal = _unit(normal, hint) + if _dot(normal, _unit(hint)) < 0.0: + normal = _mul(normal, -1.0) + return normal + for edge in self._profile_edges(profile): + if edge.get('type') in {'circle', 'angle_arc'}: + return _unit(edge.get('normal') or hint, hint) + return _unit(hint) + + def _profile_points(self, profile): + points = [] + for edge in self._profile_edges(profile): + for value in self._edge_points_payload(edge): + if not isinstance(value, (list, tuple)) or len(value) != 3: + continue + point = _v3(value) + if not any(_distance(point, existing) <= 1.0e-9 for existing in points): + points.append(point) + return points + + def _profile_frame(self, profile, normal_hint): + points = self._profile_points(profile) + if len(points) < 3: + raise RuntimeError('A planar SolidWorks profile requires at least three geometric points') + origin = points[0] + axis_point = max(points[1:], key=lambda point: _distance(point, origin)) + axis_vector = _sub(axis_point, origin) + span = _norm(axis_vector) + if span <= 1.0e-9: + raise RuntimeError('SolidWorks profile plane has no stable geometric span') + normal = self._profile_normal(profile, normal_hint) + x_axis = _sub(axis_vector, _mul(normal, _dot(axis_vector, normal))) + if _norm(x_axis) <= 1.0e-9: + x_axis, _unused = _plane_axes(normal) + else: + x_axis = _unit(x_axis) + y_axis = _unit(_cross(normal, x_axis)) + max_plane_error = max(abs(_dot(_sub(point, origin), normal)) for point in points) + tolerance = max(1.0e-7, span * 1.0e-7) + if max_plane_error > tolerance: + raise RuntimeError( + f'SolidWorks profile is not planar; geometric deviation={max_plane_error}' + ) + return origin, x_axis, y_axis, normal, span + + def _edge_points_payload(self, edge): + edge_type = edge.get('type') + if edge_type == 'line': + return [edge.get('start'), edge.get('end')] + if edge_type == 'circle': + center = _v3(edge.get('center')) + radius = float(edge.get('radius', 1.0)) + x_axis = edge.get('_kernel_x_axis') + y_axis = edge.get('_kernel_y_axis') + if x_axis is None or y_axis is None: + x_axis, y_axis = _plane_axes( + edge.get('normal') or (0.0, 0.0, 1.0) + ) + else: + x_axis = _unit(x_axis) + y_axis = _unit(y_axis) + return [ + center, + _add(center, _mul(x_axis, radius)), + _add(center, _mul(y_axis, radius)), + ] + if edge_type == 'angle_arc': + center = _v3(edge.get('center')) + radius = float(edge.get('radius', 1.0)) + start_angle = float(edge.get('start_angle', 0.0)) + end_angle = float(edge.get('end_angle', 0.0)) + normal = edge.get('normal') or (0.0, 0.0, 1.0) + return [ + center, + _angle_arc_world_point( + center, + radius, + start_angle, + normal, + edge.get('_kernel_x_axis'), + edge.get('_kernel_y_axis'), + ), + _angle_arc_world_point( + center, + radius, + end_angle, + normal, + edge.get('_kernel_x_axis'), + edge.get('_kernel_y_axis'), + ), + ] + if edge_type == 'three_point_arc': + return [edge.get('start'), edge.get('middle'), edge.get('end')] + if edge_type == 'spline': + return list(edge.get('controls') or []) + return [] + + def _plane_mapping(self, axis, profile=None): + normal = _unit(axis) + abs_axis = [abs(normal[0]), abs(normal[1]), abs(normal[2])] + dominant = abs_axis.index(max(abs_axis)) + points = [] + if profile is not None: + for edge in self._profile_edges(profile): + points.extend(self._edge_points_payload(edge)) + if dominant == 2: + offset = points[0][2] if points else 0.0 + return dominant, normal, offset, lambda p: (float(p[0]), float(p[1])) + if dominant == 1: + offset = points[0][1] if points else 0.0 + return dominant, normal, offset, lambda p: (float(p[0]), -float(p[2])) + offset = points[0][0] if points else 0.0 + return dominant, normal, offset, lambda p: (-float(p[2]), float(p[1])) + + def _select_fixed_profile_plane(self, profile, normal_hint): + origin, x_axis, y_axis, normal, span = self._profile_frame(profile, normal_hint) + point_x = _add(origin, _mul(x_axis, span)) + point_y = _add(origin, _mul(y_axis, span)) + + def point_variant(point): + return win32com.client.VARIANT( + pythoncom.VT_ARRAY | pythoncom.VT_R8, + list(_pt_m(point)), + ) + + self._clear_selection() + ref_plane = self.model.CreatePlaneFixed2( + point_variant(origin), + point_variant(point_x), + point_variant(point_y), + False, + ) + if ref_plane is None: + raise RuntimeError('SolidWorks did not create the geometry-defined profile plane') + if not self._select_entity(ref_plane, append=False): + raise RuntimeError('Could not select the geometry-defined SolidWorks profile plane') + + def mapper(point): + relative = _sub(_v3(point), origin) + return _dot(relative, x_axis), _dot(relative, y_axis) + + return normal, mapper + + def _ellipse_geometry_from_spline(self, edge, mapper): + controls = list(edge.get('controls') or []) + weights = list(edge.get('weights') or []) + knots = [float(value) for value in (edge.get('knots') or [])] + multiplicities = [int(value) for value in (edge.get('multiplicities') or [])] + if ( + int(edge.get('degree', 3)) != 2 + or bool(edge.get('periodic')) + or len(controls) != 9 + or len(weights) != 9 + or knots != [0.0, 1.0, 2.0, 3.0, 4.0] + or multiplicities != [3, 2, 2, 2, 3] + ): + return None + corner_weight = math.sqrt(0.5) + for index, weight in enumerate(weights): + expected = 1.0 if index % 2 == 0 else corner_weight + if abs(float(weight) - expected) > 1.0e-9: + return None + + points = [tuple(float(value) for value in mapper(_v3(point))) for point in controls] + + def add2(first, second): + return (first[0] + second[0], first[1] + second[1]) + + def sub2(first, second): + return (first[0] - second[0], first[1] - second[1]) + + def mul2(point, scalar): + return (point[0] * scalar, point[1] * scalar) + + def distance2(first, second): + delta = sub2(first, second) + return math.hypot(delta[0], delta[1]) + + center_a = mul2(add2(points[0], points[4]), 0.5) + center_b = mul2(add2(points[2], points[6]), 0.5) + scale = max( + 1.0, + max(distance2(point, center_a) for point in points), + ) + tolerance = max(1.0e-8, scale * 1.0e-8) + if distance2(points[0], points[8]) > tolerance: + return None + if distance2(center_a, center_b) > tolerance: + return None + center = mul2(add2(center_a, center_b), 0.5) + first_axis = sub2(points[0], center) + second_axis = sub2(points[2], center) + first_radius = math.hypot(first_axis[0], first_axis[1]) + second_radius = math.hypot(second_axis[0], second_axis[1]) + if first_radius <= tolerance or second_radius <= tolerance: + return None + if abs(first_axis[0] * second_axis[0] + first_axis[1] * second_axis[1]) > tolerance * scale: + return None + expected_corners = ( + add2(center, add2(first_axis, second_axis)), + add2(center, add2(mul2(first_axis, -1.0), second_axis)), + add2(center, add2(mul2(first_axis, -1.0), mul2(second_axis, -1.0))), + add2(center, add2(first_axis, mul2(second_axis, -1.0))), + ) + if any( + distance2(points[index], expected) > tolerance + for index, expected in zip((1, 3, 5, 7), expected_corners) + ): + return None + if first_radius >= second_radius: + major = add2(center, first_axis) + minor = add2(center, second_axis) + else: + major = add2(center, second_axis) + minor = add2(center, first_axis) + return center, major, minor + + def _create_exact_spline_segment(self, sketch, edge, mapper=None): + controls = list(edge.get('controls') or []) + knots = [float(value) for value in (edge.get('knots') or [])] + multiplicities = [int(value) for value in (edge.get('multiplicities') or [])] + weights = [float(value) for value in (edge.get('weights') or [])] + if len(controls) < 2 or not knots or len(knots) != len(multiplicities): + return None + + full_knots = [] + for knot, multiplicity in zip(knots, multiplicities): + full_knots.extend([float(knot)] * max(0, int(multiplicity))) + if not full_knots: + return None + knot_min = min(full_knots) + knot_span = max(full_knots) - knot_min + if knot_span <= 1.0e-15: + return None + # ISplineParamData requires knot values in [0, 1]. SimpleCAD stores + # the canonical B-spline parameterization, which can use any finite + # affine knot range. + full_knots = [(value - knot_min) / knot_span for value in full_knots] + + rational = len(weights) == len(controls) and any( + abs(float(weight) - 1.0) > 1.0e-12 for weight in weights + ) + control_values = [] + for index, point in enumerate(controls): + if mapper is None: + px, py, pz = _pt_m(point) + else: + x, y = mapper(_v3(point)) + px, py, pz = _as_m(x), _as_m(y), 0.0 + if rational: + weight = float(weights[index]) + if edge.get('periodic'): + control_values.extend([px, py, pz, weight]) + else: + control_values.extend([ + px * weight, py * weight, pz * weight, weight, + ]) + else: + control_values.extend([px, py, pz]) + + param_data = None + try: + raw = sketch._oleobj_.InvokeTypes( + 83, + 0, + pythoncom.DISPATCH_METHOD, + (pythoncom.VT_DISPATCH, 0), + (), + ) + if raw is not None: + param_data = win32com.client.Dispatch(raw) + except Exception: + try: + param_data = sketch.CreateSplineParamData() + except Exception: + param_data = None + if param_data is None: + return None + + def put_i4(dispid, value): + try: + param_data._oleobj_.InvokeTypes( + dispid, + 0, + pythoncom.DISPATCH_PROPERTYPUT, + (pythoncom.VT_EMPTY, 0), + ((pythoncom.VT_I4, pythoncom.PARAMFLAG_FIN),), + int(value), + ) + except Exception: + names = {1: 'Dimension', 2: 'Order', 3: 'Periodic', 4: 'ControlPointsCount'} + setattr(param_data, names[dispid], int(value)) + + put_i4(1, 4 if rational else 3) + put_i4(2, int(edge.get('degree', 3)) + 1) + put_i4(3, 1 if edge.get('periodic') else 0) + put_i4(4, len(controls)) + + control_data = win32com.client.VARIANT( + pythoncom.VT_ARRAY | pythoncom.VT_R8, + control_values, + ) + knot_data = win32com.client.VARIANT( + pythoncom.VT_ARRAY | pythoncom.VT_R8, + full_knots, + ) + + def set_array(dispid, method_name, values): + try: + return bool(param_data._oleobj_.InvokeTypes( + dispid, + 0, + pythoncom.DISPATCH_METHOD, + (pythoncom.VT_BOOL, 0), + ((pythoncom.VT_VARIANT, pythoncom.PARAMFLAG_FIN),), + values, + )) + except Exception: + return bool(getattr(param_data, method_name)(values)) + + if not set_array(19, 'SetControlPoints', control_data): + raise RuntimeError('SolidWorks rejected B-spline control points') + if not set_array(20, 'SetKnotPoints', knot_data): + raise RuntimeError('SolidWorks rejected B-spline knots') + + segments = None + try: + segments = sketch._oleobj_.InvokeTypes( + 84, + 0, + pythoncom.DISPATCH_METHOD, + (pythoncom.VT_VARIANT, 0), + ((pythoncom.VT_DISPATCH, pythoncom.PARAMFLAG_FIN),), + param_data, + ) + except Exception: + segments = sketch.CreateSplinesByEqnParams2(param_data) + if not segments: + return None + if isinstance(segments, (list, tuple)): + segment = list(segments)[0] + else: + segment = segments + try: + return win32com.client.Dispatch(segment) + except Exception: + return segment + + def _draw_edge(self, sketch, edge, mapper): + edge_type = edge.get('type') + if edge_type == 'line': + sx, sy = mapper(_v3(edge.get('start'))) + ex, ey = mapper(_v3(edge.get('end'))) + return sketch.CreateLine(_as_m(sx), _as_m(sy), 0.0, _as_m(ex), _as_m(ey), 0.0) + if edge_type == 'circle': + center = _v3(edge.get('center')) + radius = float(edge.get('radius', 1.0)) + x_axis = edge.get('_kernel_x_axis') + if x_axis is None: + x_axis, _unused_y_axis = _plane_axes( + edge.get('normal') or (0.0, 0.0, 1.0) + ) + radius_point = _add(center, _mul(_unit(x_axis), radius)) + cx, cy = mapper(center) + px, py = mapper(radius_point) + try: + return sketch.CreateCircle( + _as_m(cx), _as_m(cy), 0.0, + _as_m(px), _as_m(py), 0.0, + ) + except Exception: + return sketch.CreateCircleByRadius( + _as_m(cx), _as_m(cy), 0.0, _as_m(radius) + ) + if edge_type == 'angle_arc': + center = _v3(edge.get('center')) + radius = float(edge.get('radius', 1.0)) + start_angle = float(edge.get('start_angle', 0.0)) + end_angle = float(edge.get('end_angle', 0.0)) + span = (end_angle - start_angle) % (2.0 * math.pi) + if span <= 1.0e-12 and abs(end_angle - start_angle) > 1.0e-12: + span = 2.0 * math.pi + middle_angle = start_angle + 0.5 * span + normal = edge.get('normal') or (0.0, 0.0, 1.0) + arc_axes = ( + edge.get('_kernel_x_axis'), + edge.get('_kernel_y_axis'), + ) + start = _angle_arc_world_point( + center, radius, start_angle, normal, *arc_axes + ) + middle = _angle_arc_world_point( + center, radius, middle_angle, normal, *arc_axes + ) + end = _angle_arc_world_point( + center, radius, end_angle, normal, *arc_axes + ) + sx, sy = mapper(start) + mx, my = mapper(middle) + ex, ey = mapper(end) + return sketch.Create3PointArc( + _as_m(sx), _as_m(sy), 0.0, + _as_m(ex), _as_m(ey), 0.0, + _as_m(mx), _as_m(my), 0.0, + ) + if edge_type == 'three_point_arc': + sx, sy = mapper(_v3(edge.get('start'))) + mx, my = mapper(_v3(edge.get('middle'))) + ex, ey = mapper(_v3(edge.get('end'))) + # SolidWorks expects start, end, point-on-arc; SimpleCAD and FreeCAD + # represent the same arc as start, point-on-arc, end. + return sketch.Create3PointArc( + _as_m(sx), _as_m(sy), 0.0, + _as_m(ex), _as_m(ey), 0.0, + _as_m(mx), _as_m(my), 0.0, + ) + if edge_type == 'spline': + ellipse = self._ellipse_geometry_from_spline(edge, mapper) + if ellipse is not None: + center, major, minor = ellipse + return sketch.CreateEllipse( + _as_m(center[0]), _as_m(center[1]), 0.0, + _as_m(major[0]), _as_m(major[1]), 0.0, + _as_m(minor[0]), _as_m(minor[1]), 0.0, + ) + coords = [] + for point in edge.get('controls') or []: + x, y = mapper(_v3(point)) + coords.extend([_as_m(x), _as_m(y), 0.0]) + if len(coords) >= 6: + try: + segment = self._create_exact_spline_segment(sketch, edge, mapper) + if segment is not None: + return segment + except Exception as exc: + self.logs.append(f'exact B-spline creation failed: {exc}') + point_data = win32com.client.VARIANT( + pythoncom.VT_ARRAY | pythoncom.VT_R8, + coords, + ) + try: + previous_add_to_db = bool(sketch.AddToDB) + except Exception: + previous_add_to_db = True + try: + sketch.AddToDB = False + except Exception: + pass + try: + for dispid, arg_types, args in ( + ( + 69, + ( + (pythoncom.VT_VARIANT, pythoncom.PARAMFLAG_FIN), + (pythoncom.VT_BOOL, pythoncom.PARAMFLAG_FIN), + ), + (point_data, True), + ), + ( + 37, + ((pythoncom.VT_VARIANT, pythoncom.PARAMFLAG_FIN),), + (point_data,), + ), + ): + try: + raw_segment = sketch._oleobj_.InvokeTypes( + dispid, + 0, + pythoncom.DISPATCH_METHOD, + (pythoncom.VT_DISPATCH, 0), + arg_types, + *args, + ) + if raw_segment is not None: + return win32com.client.Dispatch(raw_segment) + except Exception: + pass + return None + finally: + try: + sketch.AddToDB = previous_add_to_db + except Exception: + pass + if edge_type == 'helix': + raise SimpleCADUnsupportedOpError('SolidWorks helix edge construction is not yet supported') + raise SimpleCADUnsupportedOpError(f'Unsupported profile edge type for SolidWorks sketch: {edge_type}') + + def _draw_3d_edge(self, sketch, edge): + edge_type = edge.get('type') + if edge_type == 'line': + start = _pt_m(edge.get('start')) + end = _pt_m(edge.get('end')) + return sketch.CreateLine(*start, *end) + if edge_type in {'angle_arc', 'three_point_arc'}: + if edge_type == 'angle_arc': + center = _v3(edge.get('center')) + radius = float(edge.get('radius', 1.0)) + start_angle = float(edge.get('start_angle', 0.0)) + end_angle = float(edge.get('end_angle', 0.0)) + span = (end_angle - start_angle) % (2.0 * math.pi) + if span <= 1.0e-12 and abs(end_angle - start_angle) > 1.0e-12: + span = 2.0 * math.pi + normal = edge.get('normal') or (0.0, 0.0, 1.0) + arc_axes = ( + edge.get('_kernel_x_axis'), + edge.get('_kernel_y_axis'), + ) + start = _angle_arc_world_point( + center, radius, start_angle, normal, *arc_axes + ) + middle = _angle_arc_world_point( + center, + radius, + start_angle + 0.5 * span, + normal, + *arc_axes, + ) + end = _angle_arc_world_point( + center, radius, end_angle, normal, *arc_axes + ) + else: + start = _v3(edge.get('start')) + middle = _v3(edge.get('middle')) + end = _v3(edge.get('end')) + return sketch.Create3PointArc(*_pt_m(start), *_pt_m(end), *_pt_m(middle)) + if edge_type == 'spline': + controls = [_v3(point) for point in (edge.get('controls') or [])] + if len(controls) < 2: + raise RuntimeError('A SolidWorks 3D B-spline requires at least two control points') + try: + segment = self._create_exact_spline_segment(sketch, edge) + if segment is not None: + return segment + except Exception as exc: + self.logs.append(f'exact 3D B-spline creation failed: {exc}') + point_data = win32com.client.VARIANT( + pythoncom.VT_ARRAY | pythoncom.VT_R8, + [coordinate for point in controls for coordinate in _pt_m(point)], + ) + try: + return sketch.CreateSpline2(point_data, True) + except Exception: + return sketch.CreateSpline(point_data) + if edge_type == 'helix': + raise SimpleCADUnsupportedOpError('SolidWorks helix path construction is handled separately') + raise SimpleCADUnsupportedOpError( + f'Unsupported 3D sweep path edge type for SolidWorks: {edge_type}' + ) + + def _create_3d_path_sketch(self, path, name): + before_sketches = { + self._feature_identity(feature) + for feature in self._features({'3DProfileFeature'}) + } + self._clear_selection() + sketch_mgr = self.model.SketchManager + sketch_mgr.Insert3DSketch(True) + active_sketch = sketch_mgr.ActiveSketch + if active_sketch is None: + raise RuntimeError('SolidWorks did not enter 3D path sketch edit mode') + created_segments = [] + try: + sketch_mgr.AddToDB = True + try: + sketch_mgr.DisplayWhenAdded = False + except Exception: + pass + for edge in self._profile_edges(path): + entity = self._draw_3d_edge(sketch_mgr, edge) + if entity is None: + raise RuntimeError( + f'SolidWorks rejected a 3D path {edge.get("type", "edge")} entity' + ) + created_segments.append(entity) + finally: + try: + sketch_mgr.AddToDB = False + sketch_mgr.DisplayWhenAdded = True + except Exception: + pass + sketch_mgr.Insert3DSketch(True) + + sketch_feature = None + try: + sketch_feature = _maybe_call(active_sketch.GetFeature) + except Exception: + pass + new_sketches = [ + feature + for feature in self._features({'3DProfileFeature'}) + if self._feature_identity(feature) not in before_sketches + ] + if new_sketches: + sketch_feature = new_sketches[-1] + if sketch_feature is None: + raise RuntimeError('SolidWorks did not persist the generated 3D path sketch') + try: + sketch_feature.Name = str(name) + except Exception: + pass + self.sketch_segments[self._feature_identity(sketch_feature)] = created_segments + return sketch_feature + + def _create_profile_sketch( + self, + profile, + axis, + name, + *, + use_profile_offset=False, + revolve_axis=None, + ): + profile_normal = self._profile_normal(profile, axis) + axis_alignment = max(abs(value) for value in profile_normal) + if axis_alignment < 1.0 - 1.0e-10: + _dominant = max(range(3), key=lambda index: abs(float(axis[index]))) + _normal, mapper = self._select_fixed_profile_plane(profile, axis) + offset = 0.0 + else: + _dominant, _requested_normal, offset, mapper = self._plane_mapping( + axis, profile + ) + if use_profile_offset: + _dominant, _normal = self._select_profile_plane(axis, offset) + else: + _dominant, _normal = self._select_plane(axis) + before_sketches = { + self._feature_identity(feature) + for feature in self._features({'ProfileFeature', '3DProfileFeature'}) + } + sketch_mgr = self.model.SketchManager + sketch_mgr.InsertSketch(True) + active_sketch = sketch_mgr.ActiveSketch + if active_sketch is None: + raise RuntimeError('SolidWorks did not enter profile sketch edit mode') + try: + sketch_mgr.AddToDB = True + try: + sketch_mgr.DisplayWhenAdded = False + except Exception: + pass + created_segments = [] + for edge in self._profile_edges(profile): + entity = self._draw_edge(sketch_mgr, edge, mapper) + if entity is None: + raise RuntimeError( + f'SolidWorks rejected a profile {edge.get("type", "edge")} entity' + ) + created_segments.append(entity) + axis_entity = None + if revolve_axis is not None: + axis_origin, axis_direction = revolve_axis + points = [] + for edge in self._profile_edges(profile): + points.extend(self._edge_points_payload(edge)) + span = max( + [ + _distance(point, axis_origin) + for point in points + if isinstance(point, (list, tuple)) + ] + or [1.0] + ) + span = max(1.0, span * 1.25) + first = _sub(axis_origin, _mul(_unit(axis_direction), span)) + second = _add(axis_origin, _mul(_unit(axis_direction), span)) + x1, y1 = mapper(first) + x2, y2 = mapper(second) + axis_entity = sketch_mgr.CreateCenterLine( + _as_m(x1), _as_m(y1), 0.0, + _as_m(x2), _as_m(y2), 0.0, + ) + if axis_entity is None: + raise RuntimeError('SolidWorks rejected the revolve construction axis') + finally: + try: + sketch_mgr.AddToDB = False + sketch_mgr.DisplayWhenAdded = True + except Exception: + pass + sketch_mgr.InsertSketch(True) + sketch_feature = None + try: + sketch_feature = _maybe_call(active_sketch.GetFeature) + except Exception: + sketch_feature = None + sketches = self._features({'ProfileFeature', '3DProfileFeature'}) + new_sketches = [ + feature for feature in sketches + if self._feature_identity(feature) not in before_sketches + ] + if new_sketches: + sketch_feature = new_sketches[-1] + elif sketches: + sketch_feature = sketches[-1] + if sketch_feature is None: + raise RuntimeError('SolidWorks did not persist the generated profile sketch') + try: + sketch_feature.Name = str(name) + except Exception: + pass + self.sketch_segments[self._feature_identity(sketch_feature)] = created_segments + return sketch_feature, offset, _dominant, _normal, axis_entity + + def _extrude_profile(self, profile, params, node_id): + direction = _unit(params.get('direction') or self._profile_normal(profile)) + distance = float(params.get('distance', 0.0)) + if abs(distance) <= 1.0e-12: + raise RuntimeError('Extrude distance is zero') + before = self._body_names() + terminal_part_feature = self._is_terminal_part_feature(node_id) + # Prefer a native sketch plane at the profile's actual offset even for + # intermediate Extrudes. The old non-terminal path created the feature + # at a reference plane and then inserted a static transform body, + # severing downstream parameter propagation after reopen. + use_native_profile_offset = True + try: + sketch_obj, offset, dominant, sketch_normal, _axis_entity = self._create_profile_sketch( + profile, + direction, + f'SimpleCADSketch_{node_id}', + use_profile_offset=use_native_profile_offset, + ) + except RuntimeError as exc: + if 'profile offset plane' not in str(exc): + raise + self._clear_selection() + self._mark_degraded( + f'SimpleCAD_{node_id}_offset', + 'static_terminal_extrude_offset', + ) + use_native_profile_offset = False + sketch_obj, offset, dominant, sketch_normal, _axis_entity = self._create_profile_sketch( + profile, + direction, + f'SimpleCADSketch_{node_id}', + use_profile_offset=False, + ) + try: + if sketch_obj is not None: + self._clear_selection() + self._select_entity(sketch_obj) + except Exception: + pass + reverse_direction = _dot(direction, sketch_normal) < 0.0 or distance < 0.0 + feature = self.model.FeatureManager.FeatureExtrusion2( + True, False, bool(reverse_direction), + 0, 0, + _as_m(abs(distance)), 0.0, + False, False, False, False, + 0.0, 0.0, + False, False, False, False, + False, False, False, + 0.0, 0.0, + False, + ) + if feature is None: + raise RuntimeError('SolidWorks did not create the extrude feature') + try: + feature.Name = f'SimpleCAD_{node_id}_extrude' + except Exception: + pass + body = self._capture_new_body(before, feature) + if abs(offset) > 1.0e-9 and not use_native_profile_offset: + vector = (0.0, 0.0, 0.0) + if dominant == 0: + vector = (offset, 0.0, 0.0) + elif dominant == 1: + vector = (0.0, offset, 0.0) + else: + vector = (0.0, 0.0, offset) + body = self._transform_body_feature(body, _translation_matrix(vector), f'SimpleCAD_{node_id}_offset') + return body + + def _revolve_profile(self, profile, params, node_id): + axis = _unit(params.get('axis') or (0.0, 0.0, 1.0)) + origin = _v3(params.get('origin') or (0.0, 0.0, 0.0)) + profile_normal = self._profile_normal(profile) + sketch_obj, _offset, _dominant, _normal, axis_entity = self._create_profile_sketch( + profile, + profile_normal, + f'SimpleCADSketch_{node_id}', + use_profile_offset=True, + revolve_axis=(origin, axis), + ) + self.logs.append(f'revolve native feature requested for {node_id}; using SolidWorks FeatureRevolve2 fallback signatures') + before = self._body_names() + angle = math.radians(float(params.get('angle', 360.0))) + feature = None + attempt_states = [] + + try: + self.model.ForceRebuild3(False) + except Exception: + pass + sketch_regions = self._sketch_regions(sketch_obj) + sketch_contours = self._sketch_contours(sketch_obj) + segment_gaps = self._sketch_segment_endpoint_gaps(sketch_obj) + profile_state = { + 'segments': len(self._sketch_segments(sketch_obj)), + 'regions': len(sketch_regions), + 'contours': len(sketch_contours), + 'gaps': segment_gaps, + } + self.logs.append( + f'revolve profile state for {node_id}: {profile_state!r}' + ) + + def select_revolve_inputs(include_axis, use_region=False): + self._clear_selection() + profile_entity = sketch_regions[0] if use_region and sketch_regions else sketch_obj + if profile_entity is None or not self._select_entity(profile_entity): + raise RuntimeError(f'Could not select the revolve profile for {node_id}') + if include_axis: + if axis_entity is None or not self._select_entity(axis_entity, append=True, mark=4): + raise RuntimeError(f'Could not select the revolve axis for {node_id}') + return self._selection_state() + + attempts = ( + ( + 'FeatureRevolve2', + True, + False, + False, + lambda: self.model.FeatureManager.FeatureRevolve2( + True, True, False, False, False, False, + 0, 0, abs(angle), 0.0, + False, False, 0.0, 0.0, + 0, 0.0, 0.0, + False, False, True, + ), + ), + ( + 'FeatureRevolve2WithoutAxis', + False, + False, + False, + lambda: self.model.FeatureManager.FeatureRevolve2( + True, True, False, False, False, False, + 0, 0, abs(angle), 0.0, + False, False, 0.0, 0.0, + 0, 0.0, 0.0, + False, False, True, + ), + ), + ( + 'FeatureRevolve2Region', + True, + False, + True, + lambda: self.model.FeatureManager.FeatureRevolve2( + True, True, False, False, False, False, + 0, 0, abs(angle), 0.0, + False, False, 0.0, 0.0, + 0, 0.0, 0.0, + False, False, True, + ), + ), + ( + 'FeatureRevolve', + True, + False, + False, + lambda: self.model.FeatureManager.FeatureRevolve( + abs(angle), False, 0.0, 0, 0, + False, False, True, + ), + ), + ( + 'FeatureRevolveScoped', + True, + True, + False, + lambda: self.model.FeatureManager.FeatureRevolve( + abs(angle), False, 0.0, 0, 0, + False, True, True, + ), + ), + ) + for api_name, include_axis, use_scope, use_region, create_feature in attempts: + try: + if use_region and not sketch_regions: + continue + selection_state = select_revolve_inputs(include_axis, use_region) + attempt_states.append({ + 'api': api_name, + 'include_axis': include_axis, + 'use_scope': use_scope, + 'use_region': use_region, + 'selections': selection_state, + }) + feature = create_feature() + if feature is not None: + break + self.logs.append( + f'revolve {api_name} attempt returned no feature for {node_id}; ' + f'include_axis={include_axis} use_scope={use_scope} ' + f'selections={selection_state!r}' + ) + except Exception as exc: + self.logs.append( + f'revolve {api_name} attempt failed for {node_id}: {exc}' + ) + if feature is None: + raise SimpleCADUnsupportedOpError( + f'SolidWorks revolve feature creation failed; profile={profile_state!r}; ' + f'attempts={attempt_states!r}; ' + f'logs={self.logs[-4:]!r}' + ) + return self._capture_new_body(before, feature) + + def _linear_profile_vertices(self, profile): + try: + edges = list(self._profile_edges(profile)) + except Exception: + return [] + if len(edges) < 3 or any(edge.get('type') != 'line' for edge in edges): + return [] + vertices = [_v3(edge.get('start')) for edge in edges] + scale = max( + 1.0, + *( + _norm(_sub(edge.get('end'), edge.get('start'))) + for edge in edges + ), + ) + for index, edge in enumerate(edges): + if _distance( + edge.get('end'), vertices[(index + 1) % len(vertices)] + ) > scale * 1.0e-7: + return [] + return vertices + + def _loft_profiles(self, profiles, params, node_id): + if bool(params.get('ruled')) and len(profiles) > 2: + parent_descriptor = ( + self.canonical_loft_descriptors.get(str(node_id)) or {} + ) + ruled_descriptors = list( + parent_descriptor.get('ruled_segments') or [] + ) + segment_bodies = [] + for index in range(len(profiles) - 1): + segment_params = dict(params) + segment_params['ruled'] = False + segment_params['_ruled_segment'] = True + segment_params['profile_count'] = 2 + if index < len(ruled_descriptors): + segment_params['_canonical_descriptor'] = ( + ruled_descriptors[index] + ) + segment_bodies.append(self._loft_profiles( + profiles[index:index + 2], + segment_params, + f'{node_id}_ruled_{index + 1}', + )) + self.logs.append( + f'loft {node_id} preserved ruled topology as ' + f'{len(segment_bodies)} adjacent two-section lofts' + ) + result = self._boolean_body( + segment_bodies[0], + segment_bodies[1:], + SWBODYADD, + f'SimpleCAD_{node_id}_ruled', + clean=False, + ) + if ( + parent_descriptor + and not self._body_matches_canonical(result, parent_descriptor) + ): + raise RuntimeError( + f'SolidWorks ruled loft union differs from canonical result ' + f'for {node_id}; actual_volume={self._body_volume(result)!r}; ' + f'actual_bbox={self._box_from_entity(result)!r}; ' + f'canonical={parent_descriptor!r}' + ) + return result + self.logs.append(f'loft native feature requested for {node_id}; using SolidWorks blend fallback signatures') + # Native Loft exposes its result through Feature.GetFaces -> + # Face2.GetBody. Avoid a document-wide body scan before every ruled + # segment; large models can contain hundreds of prior feature bodies. + before = set() + self._clear_selection() + sketches = [] + for index, profile in enumerate(profiles): + sketch_obj, _offset, _dominant, normal, _axis_entity = self._create_profile_sketch( + profile, + self._profile_normal(profile), + f'SimpleCADSketch_{node_id}_{index}', + use_profile_offset=True, + ) + if sketch_obj is not None: + sketches.append(sketch_obj) + profile_states = [] + for sketch_obj in sketches: + contours = [] + sketch = None + try: + sketch = _maybe_call(sketch_obj.GetSpecificFeature2) + except Exception: + pass + if sketch is not None: + try: + raw_contours = sketch.GetSketchContours() + except Exception: + raw_contours = None + if raw_contours: + raw_contours = ( + list(raw_contours) + if isinstance(raw_contours, (list, tuple)) + else [raw_contours] + ) + for contour in raw_contours: + try: + contours.append(bool(contour.IsClosed())) + except Exception: + contours.append(None) + profile_states.append({ + 'segments': len(self._sketch_segments(sketch_obj)), + 'contours_closed': contours, + }) + try: + profile_edge_groups = [ + list(self._profile_edges(profile)) for profile in profiles + ] + dense_polyline_profiles = bool(profile_edge_groups) and all( + len(edges) >= 32 + and all(edge.get('type') == 'line' for edge in edges) + for edges in profile_edge_groups + ) + except Exception: + dense_polyline_profiles = False + modeler_loft_args = ( + ( + False, 0, 0.0, 0.0, + True, False, True, False, True, 1.0, + 1.0, 1.0, True, True, 1, 1, False, + ), + ( + False, 0, 0.0, 0.0, + False, False, False, False, True, 1.0, + 0.0, 0.0, True, True, 0, 0, False, + ), + ) + def select_sections( + section_sketches, + selection_mode='sketch', + segment_offsets=None, + ): + self._clear_selection() + for index, sketch_obj in enumerate(section_sketches): + entity = sketch_obj + if selection_mode == 'sketch_pick': + vertices = self._linear_profile_vertices(profiles[index]) + if not vertices: + raise RuntimeError( + f'No linear profile vertices for SolidWorks loft ' + f'pick point {index} in {node_id}' + ) + offset = 0 + if segment_offsets and index < len(segment_offsets): + offset = int(segment_offsets[index]) + point = vertices[offset % len(vertices)] + try: + sketch_name = str(_maybe_call(sketch_obj.Name)) + except Exception: + sketch_name = '' + if not sketch_name or not bool( + self.model.Extension.SelectByID2( + sketch_name, + 'SKETCH', + float(point[0]) * MM_TO_M, + float(point[1]) * MM_TO_M, + float(point[2]) * MM_TO_M, + index > 0, + 1, + _empty_dispatch(), + 0, + ) + ): + raise RuntimeError( + f'Could not select SolidWorks loft profile {index} ' + f'at canonical pick point {point!r} for {node_id}' + ) + continue + if selection_mode == 'contour': + contours = self._sketch_contours(sketch_obj) + if contours: + entity = contours[0] + elif selection_mode == 'segment': + segments = self._sketch_segments(sketch_obj) + if segments: + offset = 0 + if segment_offsets and index < len(segment_offsets): + offset = int(segment_offsets[index]) + entity = segments[offset % len(segments)] + if not self._select_entity(entity, append=index > 0, mark=1): + raise RuntimeError( + f'Could not select SolidWorks loft profile {index} ' + f'using {selection_mode} for {node_id}' + ) + return self._selection_state() + + def create_native_loft( + section_sketches, + section_label, + selection_mode='sketch', + segment_offsets=None, + ): + selection = select_sections( + section_sketches, selection_mode, segment_offsets + ) + tangent_attempt = ( + ('Blend2TangentCompatibility', lambda: self.model.FeatureManager.InsertProtrusionBlend2( + False, True, False, 1.0, + 0, 0, + 1.0, 1.0, + True, True, + False, 0.0, 0.0, 0, + False, True, True, + 2, + )), + ) + unconstrained_attempts = ( + ('Blend2Unconstrained', lambda: self.model.FeatureManager.InsertProtrusionBlend2( + False, False, False, 1.0, + 0, 0, + 0.0, 0.0, + False, False, + False, 0.0, 0.0, 0, + False, False, True, + 2, + )), + ('BlendLegacy', lambda: self.model.FeatureManager.InsertProtrusionBlend( + False, False, False, 1.0, + 0, 0, + 0.0, 0.0, + False, False, + False, 0.0, 0.0, 0, + False, False, True, + )), + ) + attempts = ( + unconstrained_attempts + tangent_attempt + if params.get('_ruled_segment') + else tangent_attempt + unconstrained_attempts + ) + for attempt_name, attempt in attempts: + try: + candidate = attempt() + if candidate is not None: + self.logs.append( + f'loft {node_id} {section_label} used {attempt_name}' + ) + return candidate, selection + except Exception as exc: + self.logs.append( + f'loft {attempt_name} attempt failed for ' + f'{node_id} {section_label}: {exc}' + ) + self.logs.append( + f'loft attempts returned no feature for {node_id} {section_label}; ' + f'sections={len(section_sketches)} selections={selection!r}' + ) + return None, selection + + if dense_polyline_profiles: + try: + selection_state = select_sections(sketches, 'sketch') + candidates = [] + legacy_body = self._create_loft_temp_body( + sketches, modeler_loft_args[0], use_legacy=True + ) + if legacy_body is not None: + candidates.append( + ('CreateLoftBody', legacy_body, self._body_volume(legacy_body)) + ) + for index, modeler_args in enumerate(modeler_loft_args): + try: + temp_body = self._create_loft_temp_body( + sketches, modeler_args, use_legacy=False + ) + except Exception as exc: + self.logs.append( + f'dense-polyline CreateLoftBody2[{index}] failed ' + f'for {node_id}: {exc}' + ) + continue + if temp_body is not None: + candidates.append(( + f'CreateLoftBody2[{index}]', + temp_body, + self._body_volume(temp_body), + )) + if candidates: + descriptor = self.canonical_loft_descriptors.get(node_id) or {} + expected_volume = descriptor.get('volume') + ranked = [ + candidate + for candidate in candidates + if candidate[2] is not None + ] + if isinstance(expected_volume, (int, float)) and ranked: + selected = min( + ranked, + key=lambda candidate: abs( + candidate[2] - float(expected_volume) + ), + ) + else: + selected = candidates[0] + self.logs.append( + f'loft {node_id} dense-polyline candidates=' + f'{[(item[0], item[2]) for item in candidates]!r}; ' + f'expected_volume={expected_volume!r}; used={selected[0]}' + ) + return self._create_feature_from_body( + selected[1], f'SimpleCAD_{node_id}_loft' + ) + except Exception as exc: + self.logs.append( + f'dense-polyline temporary loft failed for {node_id}: {exc}' + ) + + canonical_segment = params.get('_canonical_descriptor') + exact_static_candidate = None + exact_static_attempts = [] + if isinstance(canonical_segment, dict): + segment_count = min( + ( + len(self._sketch_segments(sketch_obj)) + for sketch_obj in sketches + ), + default=0, + ) + selection_variants = [ + ('sketch_pick', [0, offset]) + for offset in range(segment_count) + ] + selection_variants.extend( + ('segment', [0, offset]) + for offset in range(segment_count) + ) + selection_variants.extend(( + ('sketch', None), + ('contour', None), + )) + for selection_mode, segment_offsets in selection_variants: + selection_label = ( + f'{selection_mode}{segment_offsets!r}' + if segment_offsets is not None else selection_mode + ) + try: + selection_state = select_sections( + sketches, selection_mode, segment_offsets + ) + except Exception as exc: + exact_static_attempts.append(( + selection_label, 'selection', None, None, str(exc) + )) + continue + loft_body_attempts = [('CreateLoftBody', True, modeler_loft_args[0])] + loft_body_attempts.extend( + (f'CreateLoftBody2[{index}]', False, modeler_args) + for index, modeler_args in enumerate(modeler_loft_args) + ) + for attempt_name, use_legacy, modeler_args in loft_body_attempts: + try: + temp_body = self._create_loft_temp_body( + sketches, modeler_args, use_legacy=use_legacy + ) + except Exception as exc: + exact_static_attempts.append(( + selection_label, attempt_name, None, None, str(exc) + )) + continue + volume = self._body_volume(temp_body) if temp_body else None + bbox = self._box_from_entity(temp_body) if temp_body else None + matches = bool( + temp_body is not None + and self._body_matches_canonical( + temp_body, canonical_segment + ) + ) + exact_static_attempts.append(( + selection_label, attempt_name, volume, bbox, matches + )) + if matches and exact_static_candidate is None: + exact_static_candidate = ( + temp_body, + selection_mode, + attempt_name, + segment_offsets, + selection_label, + ) + break + if exact_static_candidate is not None: + break + + initial_selection_mode = ( + 'segment' if params.get('_ruled_segment') else 'sketch' + ) + initial_segment_offsets = None + if exact_static_candidate is not None: + initial_selection_mode = exact_static_candidate[1] + initial_segment_offsets = exact_static_candidate[3] + feature, selection_state = create_native_loft( + sketches, + 'all sections', + initial_selection_mode, + initial_segment_offsets, + ) + if feature is None: + for selection_mode in ('sketch', 'contour', 'segment'): + if selection_mode == initial_selection_mode: + continue + try: + feature, selection_state = create_native_loft( + sketches, selection_mode, selection_mode + ) + except Exception as exc: + self.logs.append( + f'loft {selection_mode} selection failed for {node_id}: {exc}' + ) + feature = None + if feature is not None: + break + if feature is None and exact_static_candidate is not None: + ( + temp_body, + _selection_mode, + attempt_name, + _segment_offsets, + selection_label, + ) = exact_static_candidate + self._mark_degraded( + f'SimpleCAD_{node_id}_loft', + 'exact_static_ruled_loft_segment_after_native_failure', + ) + self.logs.append( + f'loft {node_id} used canonical exact temporary segment via ' + f'{selection_label}/{attempt_name}; attempts=' + f'{exact_static_attempts!r}' + ) + return self._create_feature_from_body( + temp_body, f'SimpleCAD_{node_id}_loft_exact_static' + ) + if feature is None: + for modeler_args in modeler_loft_args: + try: + temp_body = self._create_loft_temp_body(sketches, modeler_args) + if temp_body is not None: + return self._create_feature_from_body( + temp_body, f'SimpleCAD_{node_id}_loft' + ) + except Exception as exc: + self.logs.append( + f'temporary loft body attempt failed for {node_id}: {exc}' + ) + raise SimpleCADUnsupportedOpError( + f'SolidWorks loft feature creation failed; profiles={len(sketches)}; ' + f'profile_states={profile_states!r}; selections={selection_state!r}; ' + f'logs={self.logs[-6:]!r}' + ) + native_body = self._capture_new_body(before, feature) + if not isinstance(canonical_segment, dict): + return native_body + if self._body_matches_canonical(native_body, canonical_segment): + self.logs.append( + f'loft {node_id} retained canonical native ruled segment; ' + f'volume={self._body_volume(native_body)!r}' + ) + return native_body + if exact_static_candidate is not None: + ( + temp_body, + _selection_mode, + attempt_name, + _segment_offsets, + selection_label, + ) = exact_static_candidate + self._mark_degraded( + f'SimpleCAD_{node_id}_loft', + 'exact_static_ruled_loft_segment_after_native_mismatch', + ) + self.logs.append( + f'loft {node_id} native ruled segment differed from canonical; ' + f'native_volume={self._body_volume(native_body)!r} ' + f'native_bbox={self._box_from_entity(native_body)!r}; used ' + f'{selection_label}/{attempt_name}; attempts=' + f'{exact_static_attempts!r}' + ) + return self._create_feature_from_body( + temp_body, f'SimpleCAD_{node_id}_loft_exact_static' + ) + raise RuntimeError( + f'SolidWorks could not reproduce canonical ruled loft segment ' + f'{node_id}; native_volume={self._body_volume(native_body)!r}; ' + f'native_bbox={self._box_from_entity(native_body)!r}; ' + f'canonical={canonical_segment!r}; ' + f'temporary_attempts={exact_static_attempts!r}' + ) + + def _single_helix_edge(self, path): + try: + edges = list(self._profile_edges(path)) + except Exception: + return None + if len(edges) == 1 and edges[0].get('type') == 'helix': + return edges[0] + return None + + def _create_helix_path(self, helix, node_id): + params = dict(helix.get('params') or {}) + center = _v3(params.get('center') or (0.0, 0.0, 0.0)) + axis = _unit(params.get('dir') or params.get('axis') or (0.0, 0.0, 1.0)) + radius = abs(float(params.get('radius', 0.0))) + pitch = abs(float(params.get('pitch', 0.0))) + height = abs(float(params.get('height', 0.0))) + if radius <= TOL or pitch <= TOL or height <= TOL: + raise RuntimeError( + f'SolidWorks helix requires positive radius, pitch, and height; ' + f'params={params!r}' + ) + + circle_profile = { + 'kind': 'wire', + 'edges': [{ + 'kind': 'edge', + 'type': 'circle', + 'center': center, + 'radius': radius, + 'normal': axis, + }], + } + circle_sketch, _offset, _dominant, _normal, _axis_entity = self._create_profile_sketch( + circle_profile, + axis, + f'SimpleCADSketch_{node_id}_helix_base', + use_profile_offset=True, + ) + + before = { + self._feature_identity(feature) + for feature in self._features() + } + self._clear_selection() + if not self._select_entity(circle_sketch, append=False): + raise RuntimeError(f'Could not select the helix base sketch for {node_id}') + + # swHelixDefinedByHeightAndPitch = 2. InsertHelix creates the native + # feature but returns no dispatch object, so identify it geometrically + # through the newly added feature rather than by a tree position. + dominant = max(range(3), key=lambda index: abs(axis[index])) + reverse_direction = axis[dominant] < 0.0 + revolutions = height / pitch + self.model.InsertHelix( + reverse_direction, + True, + False, + False, + 2, + _as_m(height), + _as_m(pitch), + revolutions, + 0.0, + 0.0, + ) + helix_features = [ + feature + for feature in self._features({'Helix'}) + if self._feature_identity(feature) not in before + ] + if not helix_features: + raise RuntimeError(f'SolidWorks did not create the native helix for {node_id}') + feature = helix_features[-1] + try: + feature.Name = f'SimpleCADHelix_{node_id}' + except Exception: + pass + return feature + + def _helix_edge_selector(self, helix): + params = dict(helix.get('params') or {}) + center = _v3(params.get('center') or (0.0, 0.0, 0.0)) + axis = _unit(params.get('dir') or params.get('axis') or (0.0, 0.0, 1.0)) + radius = abs(float(params.get('radius', 0.0))) + pitch = abs(float(params.get('pitch', 0.0))) + height = abs(float(params.get('height', 0.0))) + if radius <= TOL or pitch <= TOL or height <= TOL: + return None + + # SolidWorks may expose a native helix as a feature, a body, or a + # single edge depending on the installed version. Build a geometric + # signature for the expected helix and match among all exposed edges; + # never rely on the order returned by GetEdges(). + axis_index = max(range(3), key=lambda index: abs(axis[index])) + expected_min = list(center) + expected_max = list(center) + for index in range(3): + radial = radius * math.sqrt(max(0.0, 1.0 - axis[index] * axis[index])) + expected_min[index] -= radial + expected_max[index] += radial + if axis[axis_index] >= 0.0: + expected_max[axis_index] += height + else: + expected_min[axis_index] -= height + expected_center = tuple( + (expected_min[index] + expected_max[index]) * 0.5 + for index in range(3) + ) + expected_length = math.hypot( + 2.0 * math.pi * radius * (height / pitch), height + ) + return { + 'bbox': {'min': tuple(expected_min), 'max': tuple(expected_max)}, + 'center': expected_center, + 'length': expected_length, + } + + def _helix_feature_edges(self, feature, helix): + candidates = [] + objects = [feature] + for getter_name in ('GetSpecificFeature2', 'GetBody', 'IGetBody2'): + try: + value = _maybe_call(getattr(feature, getter_name)) + if value is not None: + objects.append(value) + except Exception: + pass + seen = set() + for owner in objects: + try: + raw_edges = owner.GetEdges() + except Exception: + raw_edges = None + if not raw_edges: + continue + edges = list(raw_edges) if isinstance(raw_edges, (list, tuple)) else [raw_edges] + for edge in edges: + identity = self._feature_identity(edge) + if identity in seen: + continue + seen.add(identity) + try: + candidates.append((edge, self._edge_signature(edge))) + except Exception as exc: + self.logs.append(f'helix edge signature failed: {exc}') + if not candidates: + return None + + selector = self._helix_edge_selector(helix) + if selector is None: + return None + try: + edge = _best_by_geometry(candidates, selector, 'helix path edge') + return edge + except Exception as exc: + self.logs.append(f'helix geometric edge match failed: {exc}') + return None + + def _sampled_helix_path(self, helix): + params = dict(helix.get('params') or {}) + center = _v3(params.get('center') or (0.0, 0.0, 0.0)) + axis = _unit(params.get('dir') or params.get('axis') or (0.0, 0.0, 1.0)) + radius = abs(float(params.get('radius', 0.0))) + pitch = abs(float(params.get('pitch', 0.0))) + height = abs(float(params.get('height', 0.0))) + if radius <= TOL or pitch <= TOL or height <= TOL: + raise RuntimeError(f'Invalid helix path parameters: {params!r}') + x_axis, y_axis = _plane_axes(axis) + turns = height / pitch + # Keep enough points for a stable sweep while bounding the size of + # generated scripts for long, fine-pitch helices. + samples = max(32, min(512, int(math.ceil(turns * 8.0)) + 1)) + points = [] + signed_height = height if _dot(axis, _unit(params.get('dir') or axis)) >= 0.0 else -height + for index in range(samples): + fraction = index / float(samples - 1) + angle = 2.0 * math.pi * turns * fraction + radial = _add( + _mul(x_axis, radius * math.cos(angle)), + _mul(y_axis, radius * math.sin(angle)), + ) + points.append(_add(_add(center, radial), _mul(axis, signed_height * fraction))) + degree = min(3, len(points) - 1) + last_knot = len(points) - degree + knots = [float(index) for index in range(last_knot + 1)] + multiplicities = [degree + 1] + multiplicities.extend([1] * max(0, len(knots) - 2)) + multiplicities.append(degree + 1) + return { + 'kind': 'wire', + 'edges': [{ + 'kind': 'edge', + 'type': 'spline', + 'controls': points, + 'degree': degree, + 'knots': knots, + 'multiplicities': multiplicities, + 'weights': [], + 'periodic': False, + }], + } + + def _helix_clearance_profile(self, profile, helix): + params = dict(helix.get('params') or {}) + axis = _unit(params.get('dir') or params.get('axis') or (0.0, 0.0, 1.0)) + center = _v3(params.get('center') or (0.0, 0.0, 0.0)) + pitch = abs(float(params.get('pitch', 0.0))) + points = self._profile_points(profile) + if pitch <= TOL or len(points) < 2: + return profile + projections = [_dot(point, axis) for point in points] + span = max(projections) - min(projections) + if span < pitch * (1.0 - 1.0e-8): + return profile + factor = min(1.0, pitch * (1.0 - 1.0e-5) / span) + if factor >= 1.0: + return profile + + adjusted = json.loads(json.dumps(profile)) + anchor = _dot(center, axis) + + def adjust_point(point): + point = _v3(point) + axial = _dot(point, axis) - anchor + return _add(point, _mul(axis, axial * (factor - 1.0))) + + for edge in self._profile_edges(adjusted): + for key in ('start', 'middle', 'end', 'center'): + if isinstance(edge.get(key), (list, tuple)): + edge[key] = adjust_point(edge[key]) + for key in ('controls', 'control_points', 'points'): + if isinstance(edge.get(key), list): + edge[key] = [adjust_point(point) for point in edge[key]] + self.logs.append( + f'helix profile axial clearance applied: span={span:.9g} ' + f'pitch={pitch:.9g} factor={factor:.9g}' + ) + return adjusted + + def _sweep_round_arc_fallback(self, profile, path, node_id): + profile_edges = list(self._profile_edges(profile)) + path_edges = list(self._profile_edges(path)) + if len(profile_edges) != 1 or len(path_edges) != 1: + return None + profile_edge = profile_edges[0] + arc = path_edges[0] + if ( + profile_edge.get('type') != 'circle' + or arc.get('type') != 'angle_arc' + ): + return None + radius = abs(float(profile_edge.get('radius', 0.0))) + major_radius = abs(float(arc.get('radius', 0.0))) + if radius <= TOL or major_radius <= radius + TOL: + return None + start_angle = float(arc.get('start_angle', 0.0)) + end_angle = float(arc.get('end_angle', 0.0)) + span = (end_angle - start_angle) % (2.0 * math.pi) + if span <= TOL: + return None + normal = _unit(arc.get('normal') or (0.0, 0.0, 1.0)) + center = _v3(arc.get('center')) + start_point = _angle_arc_world_point( + center, + major_radius, + start_angle, + normal, + arc.get('_kernel_x_axis'), + arc.get('_kernel_y_axis'), + ) + tangent = _unit(_cross(normal, _sub(start_point, center))) + arc_profile = { + 'kind': 'face', + 'outer': { + 'kind': 'wire', + 'edges': [{ + 'kind': 'edge', + 'type': 'circle', + 'center': start_point, + 'radius': radius, + 'normal': tangent, + }], + }, + 'inners': [], + 'normal': tangent, + } + body = self._revolve_profile( + arc_profile, + { + 'origin': center, + 'axis': normal, + 'angle': math.degrees(span), + }, + f'{node_id}_arc_path', + ) + self.logs.append( + f'round single-arc sweep fallback used for {node_id}; ' + f'span={span:.9g}' + ) + return body + + def _sweep_round_line_arc_fallback(self, profile, path, node_id): + """Build a circular line-arc-line sweep from native cylinders and a revolve.""" + profile_edges = list(self._profile_edges(profile)) + path_edges = list(self._profile_edges(path)) + if len(profile_edges) != 1 or len(path_edges) != 3: + return None + profile_edge = profile_edges[0] + if profile_edge.get('type') != 'circle': + return None + if [edge.get('type') for edge in path_edges] != [ + 'line', 'angle_arc', 'line' + ]: + return None + + radius = abs(float(profile_edge.get('radius', 0.0))) + arc = path_edges[1] + major_radius = abs(float(arc.get('radius', 0.0))) + if radius <= TOL or major_radius <= radius + TOL: + return None + normal = _unit(arc.get('normal') or (0.0, 0.0, 1.0)) + start_angle = float(arc.get('start_angle', 0.0)) + end_angle = float(arc.get('end_angle', 0.0)) + span = end_angle - start_angle + if abs(span) <= TOL or abs(span) >= 2.0 * math.pi - TOL: + return None + start_point = _angle_arc_world_point( + arc.get('center'), major_radius, start_angle, normal, + arc.get('_kernel_x_axis'), arc.get('_kernel_y_axis') + ) + end_point = _angle_arc_world_point( + arc.get('center'), major_radius, end_angle, normal, + arc.get('_kernel_x_axis'), arc.get('_kernel_y_axis') + ) + first_line, last_line = path_edges[0], path_edges[2] + join_tolerance = max(TOL, major_radius * 1.0e-7) + if ( + _distance(first_line.get('end'), start_point) > join_tolerance + or _distance(last_line.get('start'), end_point) > join_tolerance + ): + return None + + def line_body(index, line): + start = _v3(line.get('start')) + end = _v3(line.get('end')) + vector = _sub(end, start) + distance = _norm(vector) + if distance <= TOL: + return None + line_profile = { + 'kind': 'face', + 'outer': { + 'kind': 'wire', + 'edges': [{ + 'kind': 'edge', + 'type': 'circle', + 'center': start, + 'radius': radius, + 'normal': _unit(vector), + }], + }, + 'inners': [], + 'normal': _unit(vector), + } + return self._extrude_profile( + line_profile, + {'direction': _unit(vector), 'distance': distance}, + f'{node_id}_segment_{index}', + ) + + radial = _sub(start_point, _v3(arc.get('center'))) + tangent = _unit(_cross(normal, radial)) + revolve_axis = normal + if span < 0.0: + tangent = _mul(tangent, -1.0) + revolve_axis = _mul(normal, -1.0) + first_vector = _sub( + _v3(first_line.get('end')), _v3(first_line.get('start')) + ) + last_vector = _sub( + _v3(last_line.get('end')), _v3(last_line.get('start')) + ) + if _norm(first_vector) <= TOL or _norm(last_vector) <= TOL: + return None + end_radial = _sub(end_point, _v3(arc.get('center'))) + end_tangent = _unit(_cross(normal, end_radial)) + if span < 0.0: + end_tangent = _mul(end_tangent, -1.0) + tangent_tolerance = 1.0e-7 + first_join_dot = _dot(_unit(first_vector), tangent) + last_join_dot = _dot(end_tangent, _unit(last_vector)) + first_join_tangent = first_join_dot >= 1.0 - tangent_tolerance + last_join_tangent = last_join_dot >= 1.0 - tangent_tolerance + + first_body = line_body(0, first_line) + if first_body is None: + return None + bodies = [first_body] + if first_join_tangent: + arc_profile = { + 'kind': 'face', + 'outer': { + 'kind': 'wire', + 'edges': [{ + 'kind': 'edge', + 'type': 'circle', + 'center': start_point, + 'radius': radius, + 'normal': tangent, + }], + }, + 'inners': [], + 'normal': tangent, + } + arc_body = self._revolve_profile( + arc_profile, + { + 'origin': _v3(arc.get('center')), + 'axis': revolve_axis, + 'angle': math.degrees(abs(span)), + }, + f'{node_id}_arc', + ) + bodies.append(arc_body) + if last_join_tangent: + last_body = line_body(2, last_line) + if last_body is None: + return None + bodies.append(last_body) + else: + self.logs.append( + f'round sweep {node_id} stopped solid propagation after ' + f'non-tangent arc-line join; dot=' + f'{last_join_dot:.9g}' + ) + else: + self.logs.append( + f'round sweep {node_id} stopped solid propagation after ' + f'non-tangent line-arc join; dot=' + f'{first_join_dot:.9g}' + ) + result = self._boolean_body( + bodies[0], bodies[1:], SWBODYADD, + f'SimpleCAD_{node_id}_round_path', clean=True, + ) + self.logs.append(f'round line-arc-line sweep fallback used for {node_id}') + return result + + def _sweep_profile(self, profile, path, params, node_id): + self.logs.append(f'sweep native feature requested for {node_id}; using SolidWorks sweep fallback signatures') + canonical_sweep = ( + self.canonical_sweep_descriptors.get(str(node_id)) or {} + ) + sweep_candidate_diagnostics = [] + try: + arc_fallback = self._sweep_round_arc_fallback( + profile, path, node_id + ) + if arc_fallback is not None: + return arc_fallback + except Exception as exc: + self.logs.append(f'round arc sweep fallback failed for {node_id}: {exc}') + try: + round_fallback = self._sweep_round_line_arc_fallback( + profile, path, node_id + ) + if round_fallback is not None: + return round_fallback + except Exception as exc: + self.logs.append(f'round sweep fallback failed for {node_id}: {exc}') + helix_edge = self._single_helix_edge(path) + frenet_helix_twist = None + if helix_edge is not None: + profile = self._helix_clearance_profile(profile, helix_edge) + if bool(params.get('is_frenet')): + helix_params = dict(helix_edge.get('params') or {}) + helix_pitch = abs(float(helix_params.get('pitch', 0.0))) + helix_height = abs(float(helix_params.get('height', 0.0))) + if helix_pitch > TOL and helix_height > TOL: + # _create_helix_path requests a clockwise SolidWorks + # helix. Rotate the section by the same signed number of + # turns so its radial axis follows OCC's Frenet frame. + frenet_helix_twist = ( + -2.0 * math.pi * helix_height / helix_pitch + ) + before = self._body_names() + self._clear_selection() + profile_sketch, _offset, _dominant, _normal, _profile_axis = self._create_profile_sketch( + profile, + self._profile_normal(profile), + f'SimpleCADSketch_{node_id}_profile', + use_profile_offset=True, + ) + if helix_edge is not None: + # Keep the native helix path as the first attempt. Some SolidWorks + # versions accept the helix feature itself even when it does not + # expose a selectable edge. The sampled path is a local fallback. + path_sketch = self._create_helix_path(helix_edge, node_id) + path_edge = self._helix_feature_edges(path_sketch, helix_edge) + path_selection_kind = 'helix' + else: + path_edge = None + try: + path_sketch, _path_offset, _path_dominant, _path_normal, _path_axis = self._create_profile_sketch( + path, + self._profile_normal(path), + f'SimpleCADSketch_{node_id}_path', + use_profile_offset=True, + ) + path_selection_kind = 'sketch' + except RuntimeError as exc: + if 'profile is not planar' not in str(exc): + raise + path_sketch = self._create_3d_path_sketch( + path, f'SimpleCADSketch_{node_id}_3d_path' + ) + path_selection_kind = '3d_sketch' + feature = None + successful_sweep_body = None + successful_sweep_attempt = None + attempt_states = [] + path_segments = ( + self._sketch_segments(path_sketch) + if path_sketch is not None and helix_edge is None + else [] + ) + if helix_edge is not None and path_edge is None: + # The native helix is a construction feature in some SolidWorks + # versions. Once it has been replaced by the sampled 3D sketch, + # select that sketch's actual geometric segment, not the sketch + # container, so the sweep API receives a path entity. + path_segments = self._sketch_segments(path_sketch) + for use_path_segments in (False, True): + if use_path_segments and not path_segments: + continue + self._clear_selection() + if profile_sketch is None or not self._select_entity(profile_sketch, append=False, mark=1): + raise RuntimeError(f'Could not select the sweep profile for {node_id}') + if use_path_segments: + for segment in path_segments: + if not self._select_entity(segment, append=True, mark=4): + raise RuntimeError(f'Could not select a sweep path segment for {node_id}') + if len(path_segments) > 1: + self._group_sweep_path_selection() + elif (path_edge or path_sketch) is None or not self._select_entity( + path_edge or path_sketch, append=True, mark=4 + ): + raise RuntimeError(f'Could not select the sweep path for {node_id}') + selection_state = self._selection_state() + attempt_states.append({ + 'path_selection': 'segments' if use_path_segments else path_selection_kind, + 'segment_count': len(path_segments), + 'selections': selection_state, + }) + + def create_sweep_from_definition( + twist_control, twist_angle=None + ): + definition = self.model.FeatureManager.CreateDefinition( + SW_FM_SWEEP + ) + if definition is None: + return None + definition.Profile = profile_sketch + definition.Path = path_edge or path_sketch + definition.TwistControlType = int(twist_control) + definition.PathAlignmentType = 0 + definition.AlignWithEndFaces = False + definition.Merge = False + if twist_angle is not None: + # SetTwistAngle is a COM Sub (void), not a Boolean method. + definition.SetTwistAngle(float(twist_angle)) + return self.model.FeatureManager.CreateFeature(definition) + + attempts = ( + *( + ( + ( + 'feature_data_normal_constant_twist', + lambda: create_sweep_from_definition( + SW_TWIST_CONTROL_NORMAL_CONSTANT_TWIST, + frenet_helix_twist, + ), + ), + ( + 'feature_data_normal_constant_reverse_twist', + lambda: create_sweep_from_definition( + SW_TWIST_CONTROL_NORMAL_CONSTANT_TWIST, + -frenet_helix_twist, + ), + ), + ( + 'feature_data_constant_twist', + lambda: create_sweep_from_definition( + 8, frenet_helix_twist + ), + ), + ( + 'feature_data_constant_reverse_twist', + lambda: create_sweep_from_definition( + 8, -frenet_helix_twist + ), + ), + ( + 'feature_data_follow_path', + lambda: create_sweep_from_definition(0), + ), + ( + 'feature_data_keep_normal_constant', + lambda: create_sweep_from_definition(1), + ), + ( + 'legacy_normal_constant_twist', + lambda: self.model.FeatureManager.InsertProtrusionSwept4( + False, False, + SW_TWIST_CONTROL_NORMAL_CONSTANT_TWIST, + False, False, 0, 0, False, + 0.0, 0.0, 0, 0, + False, False, True, + frenet_helix_twist, + True, False, 0.0, 0, + ), + ), + ) + if frenet_helix_twist is not None + else () + ), + # Graph operations are functional and create an independent body. + # Asking SolidWorks to merge here makes the result depend on unrelated + # intermediate bodies already present in the document. + ( + 'legacy_follow_path_independent', + lambda: self.model.FeatureManager.InsertProtrusionSwept4(False, False, 0, False, False, 0, 0, False, 0.0, 0.0, 0.0, 0, False, False, True, 0, True, False, 0.0, 0), + ), + # Documented SolidWorks multi-path sweep settings retained as + # compatibility fallbacks after the independent-body attempt. + ( + 'legacy_follow_path_merge_scope', + lambda: self.model.FeatureManager.InsertProtrusionSwept4(False, False, 0, False, False, 0, 0, False, 0.0, 0.0, 0.0, 0, True, True, True, 0.0, True, False, 0.0, 0), + ), + ( + 'legacy_swept2_independent', + lambda: self.model.FeatureManager.InsertProtrusionSwept2(False, False, 0, False, False, 0, 0, False, 0.0, 0.0, 0.0, 0, False, False, True), + ), + ( + 'legacy_swept2_merge_scope', + lambda: self.model.FeatureManager.InsertProtrusionSwept2(False, False, 0, False, False, 0, 0, False, 0.0, 0.0, 0.0, 0, True, True, True), + ), + ) + for attempt_name, attempt in attempts: + try: + candidate_feature = attempt() + if candidate_feature is not None: + candidate_body = self._capture_new_body( + before, candidate_feature + ) + candidate_volume = self._body_volume(candidate_body) + candidate_bbox = self._box_from_entity(candidate_body) + candidate_matches = ( + not canonical_sweep + or self._body_matches_canonical( + candidate_body, canonical_sweep + ) + ) + sweep_candidate_diagnostics.append({ + 'attempt': attempt_name, + 'volume': candidate_volume, + 'bbox': candidate_bbox, + 'matches': candidate_matches, + }) + if not candidate_matches: + self.logs.append( + f'sweep {node_id} rejected {attempt_name}; ' + f'volume={candidate_volume!r} ' + f'bbox={candidate_bbox!r}; ' + f'canonical={canonical_sweep!r}' + ) + continue + feature = candidate_feature + successful_sweep_body = candidate_body + successful_sweep_attempt = attempt_name + break + except Exception as exc: + self.logs.append(f'sweep attempt failed for {node_id}: {exc}') + if feature is None: + try: + temp_body = self._create_swept_temp_body(( + False, False, 0, + False, False, 0, 0, + False, 0.0, 0.0, 0, + 0, 0.0, True, + )) + if temp_body is not None: + temp_matches = ( + not canonical_sweep + or self._body_matches_canonical( + temp_body, canonical_sweep + ) + ) + sweep_candidate_diagnostics.append({ + 'attempt': 'temporary_swept_body', + 'volume': self._body_volume(temp_body), + 'bbox': self._box_from_entity(temp_body), + 'matches': temp_matches, + }) + if not temp_matches: + self.logs.append( + f'sweep {node_id} rejected temporary body; ' + f'canonical={canonical_sweep!r}' + ) + continue + return self._create_feature_from_body( + temp_body, f'SimpleCAD_{node_id}_sweep' + ) + except Exception as exc: + self.logs.append( + f'temporary sweep body attempt failed for {node_id}: {exc}' + ) + if feature is not None: + break + if feature is None and helix_edge is not None: + self.logs.append( + f'native helix sweep failed for {node_id}; using a ' + 'geometry-generated 3D B-spline path' + ) + path_sketch = self._create_3d_path_sketch( + self._sampled_helix_path(helix_edge), + f'SimpleCADSketch_{node_id}_sampled_helix', + ) + path_edge = None + path_selection_kind = 'sampled_helix' + path_segments = self._sketch_segments(path_sketch) + for use_path_segments in (False, True): + if use_path_segments and not path_segments: + continue + self._clear_selection() + if profile_sketch is None or not self._select_entity(profile_sketch, append=False, mark=1): + raise RuntimeError(f'Could not select the sweep profile for {node_id}') + if use_path_segments: + for segment in path_segments: + if not self._select_entity(segment, append=True, mark=4): + raise RuntimeError(f'Could not select a sweep path segment for {node_id}') + if len(path_segments) > 1: + self._group_sweep_path_selection() + elif not self._select_entity(path_sketch, append=True, mark=4): + raise RuntimeError(f'Could not select the sampled sweep path for {node_id}') + selection_state = self._selection_state() + attempt_states.append({ + 'path_selection': 'segments' if use_path_segments else path_selection_kind, + 'segment_count': len(path_segments), + 'selections': selection_state, + }) + feature = None + for attempt_name, attempt in ( + *( + ( + ( + 'sampled_normal_constant_twist', + lambda: self.model.FeatureManager.InsertProtrusionSwept4( + False, False, + SW_TWIST_CONTROL_NORMAL_CONSTANT_TWIST, + False, False, 0, 0, False, + 0.0, 0.0, 0, 0, + False, False, True, + frenet_helix_twist, + True, False, 0.0, 0, + ), + ), + ) + if frenet_helix_twist is not None + else () + ), + ( + 'sampled_follow_path_independent', + lambda: self.model.FeatureManager.InsertProtrusionSwept4( + False, False, 0, False, False, 0, 0, False, + 0.0, 0.0, 0, 0, False, False, True, 0.0, True, + False, 0.0, 0, + ), + ), + ( + 'sampled_follow_path_merge_scope', + lambda: self.model.FeatureManager.InsertProtrusionSwept4( + False, False, 0, False, False, 0, 0, False, + 0.0, 0.0, 0, 0, True, True, True, 0.0, True, + False, 0.0, 0, + ), + ), + ): + try: + feature = attempt() + if feature is not None: + successful_sweep_attempt = attempt_name + break + except Exception as exc: + self.logs.append( + f'sampled sweep attempt failed for {node_id}: {exc}' + ) + if feature is None: + try: + temp_body = self._create_swept_temp_body(( + False, False, 0, + False, False, 0, 0, + False, 0.0, 0.0, 0, + 0, 0.0, True, + )) + if temp_body is not None: + temp_matches = ( + not canonical_sweep + or self._body_matches_canonical( + temp_body, canonical_sweep + ) + ) + sweep_candidate_diagnostics.append({ + 'attempt': 'temporary_sampled_swept_body', + 'volume': self._body_volume(temp_body), + 'bbox': self._box_from_entity(temp_body), + 'matches': temp_matches, + }) + if not temp_matches: + self.logs.append( + f'sweep {node_id} rejected temporary ' + f'sampled body; canonical=' + f'{canonical_sweep!r}' + ) + continue + return self._create_feature_from_body( + temp_body, f'SimpleCAD_{node_id}_sweep' + ) + except Exception as exc: + self.logs.append( + f'temporary sampled sweep body attempt failed for {node_id}: {exc}' + ) + if feature is not None: + break + if feature is None: + raise SimpleCADUnsupportedOpError( + f'SolidWorks sweep feature creation failed; attempts={attempt_states!r}; ' + f'logs={self.logs[-3:]!r}' + ) + body = successful_sweep_body or self._capture_new_body(before, feature) + if canonical_sweep and not self._body_matches_canonical( + body, canonical_sweep + ): + raise RuntimeError( + f'SolidWorks sweep {node_id} differs from canonical result; ' + f'actual_volume={self._body_volume(body)!r}; ' + f'actual_bbox={self._box_from_entity(body)!r}; ' + f'canonical={canonical_sweep!r}; ' + f'candidates={sweep_candidate_diagnostics!r}' + ) + self.logs.append( + f'sweep {node_id} created by {successful_sweep_attempt or "unknown"}; ' + f'volume={self._body_volume(body)!r}; ' + f'bbox={self._box_from_entity(body)!r}' + ) + return body + + def _copy_temp_body(self, body): + # Prefer the caller's resolved proxy. A same-name body enumerated from + # the document can refer to a different feature after chained fillets; + # use fresh-name/bbox candidates only as fallbacks. + bodies = [body] + expected_name = self._body_name(body) + expected_bbox = self._box_from_entity(body) + expected_size = _distance(expected_bbox['min'], expected_bbox['max']) + fresh_bodies = self._solid_bodies() + same_name = [ + candidate for candidate in fresh_bodies + if self._body_name(candidate) == expected_name + ] + bodies.extend( + candidate for candidate in same_name + if candidate is not body + ) + if not same_name and expected_size > 1.0e-12 and fresh_bodies: + best_body = min( + fresh_bodies, + key=lambda candidate: _bbox_score( + self._box_from_entity(candidate), expected_bbox + ), + ) + best_score = _bbox_score( + self._box_from_entity(best_body), expected_bbox + ) + if best_score <= max(1.0e-7, expected_size * 1.0e-7): + bodies.append(best_body) + try: + typed_body = win32com.client.CastTo(body, 'IBody2') + if typed_body is not None: + bodies.append(typed_body) + except Exception: + pass + + errors = [] + for candidate in bodies: + calls = ( + ('Copy', lambda candidate=candidate: candidate.Copy()), + ('ICopy', lambda candidate=candidate: candidate.ICopy()), + ('Copy2', lambda candidate=candidate: candidate.Copy2(False)), + ( + 'DISPID 19', + lambda candidate=candidate: candidate._oleobj_.InvokeTypes( + 19, + 0, + pythoncom.DISPATCH_METHOD, + (pythoncom.VT_DISPATCH, 0), + (), + ), + ), + ( + 'DISPID 31', + lambda candidate=candidate: candidate._oleobj_.InvokeTypes( + 31, + 0, + pythoncom.DISPATCH_METHOD, + (pythoncom.VT_DISPATCH, 0), + (), + ), + ), + ) + for call_name, call in calls: + try: + copied = call() + if copied is not None: + try: + return win32com.client.Dispatch(copied) + except Exception: + return copied + errors.append(f'{call_name}: returned None') + except Exception as exc: + errors.append(f'{call_name}: {exc}') + fresh_state = [ + (self._body_name(candidate), self._box_from_entity(candidate)) + for candidate in fresh_bodies + ] + self.logs.append( + f'body copy attempts failed: expected_name={expected_name!r} ' + f'expected_bbox={expected_bbox!r} fresh={fresh_state!r} ' + f'attempts={errors!r}' + ) + raise RuntimeError( + f'Could not copy SolidWorks body; expected_name={expected_name!r}; ' + f'expected_bbox={expected_bbox!r}; fresh={fresh_state!r}; ' + f'attempts={errors!r}' + ) + + def _create_feature_from_body(self, temp_body, name): + before = self._body_names() + expected_bbox = self._box_from_entity(temp_body) + feature = None + for call in ( + lambda: self.model.CreateFeatureFromBody3(temp_body, False, 0), + lambda: self.model.CreateFeatureFromBody3(temp_body, True, 0), + lambda: self.model.CreateFeatureFromBody2(temp_body, False, 0), + lambda: self.model.CreateFeatureFromBody(temp_body), + ): + try: + feature = call() + if feature is not None: + break + except Exception: + pass + if feature is None: + raise RuntimeError('SolidWorks CreateFeatureFromBody failed') + try: + feature.Name = str(name) + except Exception: + pass + return self._capture_new_body( + before, feature, expected_bbox=expected_bbox + ) + + def _apply_transform_to_temp_body(self, temp_body, matrix_data): + transform = None + try: + # Late-bound pywin32 exposes this no-argument COM getter as a + # property whose value is the MathUtility dispatch object. Its + # CreateTransform method is not exposed through dynamic dispatch in + # SolidWorks 2025, so invoke the type-library DISPID explicitly. + math_utility = self.sw.GetMathUtility + array_data = win32com.client.VARIANT( + pythoncom.VT_ARRAY | pythoncom.VT_R8, + [float(value) for value in matrix_data], + ) + raw_transform = math_utility._oleobj_.InvokeTypes( + 1, + 0, + pythoncom.DISPATCH_METHOD, + (pythoncom.VT_DISPATCH, 0), + ((pythoncom.VT_VARIANT, pythoncom.PARAMFLAG_FIN),), + array_data, + ) + if raw_transform is not None: + transform = win32com.client.Dispatch(raw_transform) + except Exception: + transform = None + if transform is None: + raise RuntimeError('Could not create SolidWorks MathTransform') + for call in ( + lambda: temp_body.ApplyTransform(transform), + lambda: temp_body.Transform2(transform), + ): + try: + result = call() + if result is None or bool(result): + return temp_body + except Exception: + pass + raise RuntimeError('Could not transform SolidWorks body') + + def _transform_body_feature(self, body, matrix_data, name): + if _is_identity_matrix(matrix_data): + before = self._body_names() + expected_bbox = self._box_from_entity(body) + self._clear_selection() + if self._select_solid_body(body, append=False, mark=1): + feature = None + for call in ( + lambda: self.model.FeatureManager.InsertMoveCopyBody2( + 0.0, 0.0, 0.0, 0.0, + 0.0, 0.0, 0.0, + 0.0, 0.0, 0.0, + True, 1, + ), + lambda: self.model.FeatureManager.InsertMoveCopyBody( + 0.0, 0.0, 0.0, 0.0, + 0.0, 0.0, 0.0, + 0.0, 0.0, 0.0, + True, 1, + ), + ): + try: + feature = call() + if feature is not None: + break + except Exception as exc: + self.logs.append( + f'native body-copy attempt failed for {name}: {exc}' + ) + self._clear_selection() + if feature is not None: + try: + feature.Name = str(name) + except Exception: + pass + return self._capture_new_body( + before, feature, expected_bbox=expected_bbox + ) + # A pure translation can be represented by SolidWorks' native + # Move/Copy Body feature. Prefer this live feature over the temporary + # BRep/CreateFeatureFromBody fallback so upstream edits propagate + # through translated leaves after the document is reopened. + if _is_translation_matrix(matrix_data): + before = self._body_names() + expected_bbox = self._box_from_entity(body) + self._clear_selection() + if self._select_solid_body(body, append=False, mark=1): + tx, ty, tz = (float(matrix_data[index]) for index in (9, 10, 11)) + canonical_delta = tuple( + value * M_TO_MM / MODEL_SCALE + for value in (tx, ty, tz) + ) + if isinstance(expected_bbox, dict): + expected_bbox = { + key: tuple( + float(point[index]) + canonical_delta[index] + for index in range(3) + ) + for key, point in expected_bbox.items() + } + distance = math.sqrt(tx * tx + ty * ty + tz * tz) + if distance <= 1.0e-15: + direction = (1.0, 0.0, 0.0) + else: + direction = ( + tx / distance, ty / distance, tz / distance + ) + native_feature = None + for call in ( + # SolidWorks' own Move Bodies example passes the XYZ + # displacement directly and leaves TransDist at zero. + # Graph transforms are functional and may feed multiple + # branches, so always copy the live input body here. A + # native move would consume it and force later branches + # onto static Body2 snapshots. + lambda: self.model.FeatureManager.InsertMoveCopyBody2( + tx, ty, tz, 0.0, + 0.0, 0.0, 0.0, + 0.0, 0.0, 0.0, True, 1, + ), + lambda: self.model.FeatureManager.InsertMoveCopyBody( + tx, ty, tz, 0.0, + 0.0, 0.0, 0.0, + 0.0, 0.0, 0.0, True, 1, + ), + lambda: self.model.FeatureManager.InsertMoveCopyBody2( + direction[0], direction[1], direction[2], distance, + 0.0, 0.0, 0.0, + 0.0, 0.0, 0.0, True, 1, + ), + lambda: self.model.FeatureManager.InsertMoveCopyBody( + direction[0], direction[1], direction[2], distance, + 0.0, 0.0, 0.0, + 0.0, 0.0, 0.0, True, 1, + ), + ): + try: + native_feature = call() + if native_feature is not None: + break + except Exception as exc: + self.logs.append( + f'native translated body attempt failed for {name}: {exc}' + ) + self._clear_selection() + if native_feature is not None: + try: + native_feature.Name = str(name) + except Exception: + pass + self.logs.append(f'{name} uses native Move/Copy Body translation') + return self._capture_new_body( + before, native_feature, expected_bbox=expected_bbox + ) + self.logs.append( + f'native Move/Copy Body returned no feature for {name}; ' + f'selection={self._selection_state()!r}; ' + 'falling back to static transform' + ) + else: + self.logs.append( + f'native translated body selection failed for {name}; ' + 'falling back to static transform' + ) + else: + self.logs.append( + f'{name} transform is not a pure translation; using static fallback' + ) + temp_body = self._copy_temp_body(body) + self._apply_transform_to_temp_body(temp_body, matrix_data) + self._mark_degraded(name, 'static_transform') + return self._create_feature_from_body(temp_body, name) + + def _rotate_body_feature(self, body, axis, angle_degrees, origin, name): + axis = _unit(axis) + matrix_data = _rotation_matrix(axis, angle_degrees, origin) + principal_index = None + principal_sign = 1.0 + for index in range(3): + if ( + abs(abs(axis[index]) - 1.0) <= 1.0e-10 + and all(abs(axis[other]) <= 1.0e-10 for other in range(3) if other != index) + ): + principal_index = index + principal_sign = 1.0 if axis[index] >= 0.0 else -1.0 + break + if principal_index is None: + self.logs.append( + f'{name} rotation axis is not principal; using static fallback' + ) + return self._transform_body_feature(body, matrix_data, name) + + expected_probe = self._copy_temp_body(body) + self._apply_transform_to_temp_body(expected_probe, matrix_data) + expected_bbox = self._box_from_entity(expected_probe) + before = self._body_names() + self._clear_selection() + if self._select_solid_body(body, append=False, mark=1): + rotation_point = _pt_m(origin) + rotation_angles = [0.0, 0.0, 0.0] + # The late-bound SolidWorks COM call exposes the three rotation + # values in Z/Y/X axis order on this API version. A direct probe + # with a non-symmetric body verifies that argument 0 rotates about + # world Z and argument 2 about world X. + rotation_angles[ + _solidworks_rotation_angle_index(principal_index) + ] = ( + math.radians(float(angle_degrees)) * principal_sign + ) + feature = None + for call in ( + lambda: self.model.FeatureManager.InsertMoveCopyBody2( + 0.0, 0.0, 0.0, 0.0, + rotation_point[0], rotation_point[1], rotation_point[2], + rotation_angles[0], rotation_angles[1], rotation_angles[2], + True, 1, + ), + lambda: self.model.FeatureManager.InsertMoveCopyBody( + 0.0, 0.0, 0.0, 0.0, + rotation_point[0], rotation_point[1], rotation_point[2], + rotation_angles[0], rotation_angles[1], rotation_angles[2], + True, 1, + ), + ): + try: + feature = call() + if feature is not None: + break + except Exception as exc: + self.logs.append( + f'native rotated body attempt failed for {name}: {exc}' + ) + self._clear_selection() + if feature is not None: + try: + feature.Name = str(name) + except Exception: + pass + self.logs.append(f'{name} uses native Move/Copy Body rotation') + return self._capture_new_body( + before, feature, expected_bbox=expected_bbox + ) + self.logs.append( + f'native Move/Copy Body rotation failed for {name}; ' + 'using static fallback' + ) + temp_body = self._copy_temp_body(body) + self._apply_transform_to_temp_body(temp_body, matrix_data) + self._mark_degraded(name, 'static_transform') + return self._create_feature_from_body(temp_body, name) + + def _boolean_result_body(self, value, name): + if hasattr(value, 'GetFaces'): + return value + if isinstance(value, (list, tuple)): + values = list(value) + else: + try: + values = list(value) + except Exception: + values = [] + bodies = [body for body in values if hasattr(body, 'GetFaces')] + if len(bodies) != 1: + raise RuntimeError( + f'SolidWorks body boolean for {name} returned {len(bodies)} solid sections; expected one' + ) + return bodies[0] + + def _boolean_result_bodies(self, value): + if hasattr(value, 'GetFaces'): + return [value] + if isinstance(value, (list, tuple)): + values = list(value) + else: + try: + values = list(value) + except Exception: + values = [] + return [body for body in values if hasattr(body, 'GetFaces')] + + def _body_intersection_has_volume(self, left, right): + left_bbox = self._box_from_entity(left) + right_bbox = self._box_from_entity(right) + if not _bbox_intersects(left_bbox, right_bbox, tolerance=1.0e-9): + return False + try: + working_left = self._copy_temp_body(left) + working_right = self._copy_temp_body(right) + response = working_left.Operations2( + SWBODYINTERSECT, working_right + ) + if ( + isinstance(response, tuple) + and len(response) == 2 + and isinstance(response[1], int) + ): + result, error_code = response + else: + result, error_code = response, 0 + intersections = self._boolean_result_bodies(result) + if not intersections: + return False + common_volume = sum( + max(0.0, float(self._body_volume(body) or 0.0)) + for body in intersections + ) + operand_volumes = [ + max(0.0, float(self._body_volume(body) or 0.0)) + for body in (left, right) + ] + tolerance = max( + 1.0e-18, + min(operand_volumes) * 1.0e-10 + if all(operand_volumes) else 1.0e-18, + ) + return common_volume > tolerance + except Exception as exc: + raise RuntimeError( + 'Could not determine exact SolidWorks body intersection for ' + f'multi-section Cut; left_bbox={left_bbox!r}; ' + f'right_bbox={right_bbox!r}: {exc}' + ) from exc + + def _body_union_is_single(self, left, right): + left_bbox = self._box_from_entity(left) + right_bbox = self._box_from_entity(right) + if not _bbox_intersects(left_bbox, right_bbox, tolerance=1.0e-9): + return False + try: + working_left = self._copy_temp_body(left) + working_right = self._copy_temp_body(right) + response = working_left.Operations2(SWBODYADD, working_right) + if ( + isinstance(response, tuple) + and len(response) == 2 + and isinstance(response[1], int) + ): + result, error_code = response + else: + result, error_code = response, 0 + result_bodies = self._boolean_result_bodies(result) + if len(result_bodies) == 1: + return True + if len(result_bodies) > 1: + return False + raise RuntimeError( + f'Operations2 returned no solid section; error={error_code}' + ) + except Exception as exc: + raise RuntimeError( + 'Could not determine exact SolidWorks body connectivity for ' + f'Union; left_bbox={left_bbox!r}; ' + f'right_bbox={right_bbox!r}: {exc}' + ) from exc + + def _boolean_body( + self, + base, + tools, + op_code, + name, + *, + skip_non_intersecting=False, + clean=False, + canonical_result=None, + allow_split_sections=False, + prefer_native=False, + ): + if not tools: + return base + native_fallback_base = None + native_fallback_tools = None + if prefer_native: + try: + # InsertCombineFeature may consume or invalidate its selected + # bodies even when canonical validation rejects the result. + # Keep explicit static fallback bodies before that attempt so + # a failed native probe cannot force source-kernel fallback. + # Tool bodies must be copied as well as the base: SolidWorks + # invalidates every selected operand when a native Combine is + # created, even if canonical validation rejects its result. + native_fallback_base = self._copy_temp_body(base) + native_fallback_tools = [ + self._copy_temp_body(tool) for tool in tools + ] + # Native Combine consumes every selected operand. Feed it + # native BCopy=True identity features so the graph remains + # functional when an operand also has another downstream + # consumer. Move/Copy Body retains the upstream parametric + # dependency; a temporary Body2 copy would not. + degraded_before = len(self.degraded_features) + live_base = self._transform_body_feature( + base, _identity_matrix(), f'{name}_operand_base' + ) + live_tools = [ + self._transform_body_feature( + tool, + _identity_matrix(), + f'{name}_operand_tool_{index + 1}', + ) + for index, tool in enumerate(tools) + ] + if len(self.degraded_features) != degraded_before: + raise RuntimeError( + 'native Boolean operand copy degraded to a static body' + ) + native_result = self._native_combine_body( + live_base, live_tools, op_code, name, + prefer_live_base=True, + ) + if ( + isinstance(canonical_result, dict) + and not self._body_matches_canonical( + native_result, canonical_result + ) + and not ( + op_code == SWBODYCUT + and self._cut_tolerance_artifact_matches_canonical( + native_result, canonical_result + ) + ) + ): + # Feature.GetBody/GetFaces can expose an edit-state proxy + # after Combine consumes its operands. Rebind to the + # unique persistent document body only when its strict + # canonical descriptor proves that it is the result. + persistent_matches = [ + candidate + for candidate in self._solid_bodies() + if self._body_matches_canonical( + candidate, canonical_result + ) + ] + if len(persistent_matches) == 1: + native_result = persistent_matches[0] + self.logs.append( + f'boolean {name} rebound native Combine to the ' + 'unique persistent canonical result body' + ) + else: + actual_descriptor = { + 'bbox': self._box_from_entity(native_result), + 'volume': self._body_volume(native_result), + } + raise RuntimeError( + 'native Combine result did not match canonical ' + f'bbox/volume; actual={actual_descriptor!r}; ' + f'canonical={canonical_result!r}; ' + f'persistent_matches={len(persistent_matches)}' + ) + self.logs.append(f'boolean {name} used live native Combine') + return native_result + except Exception as exc: + self.logs.append( + f'live native Combine failed for {name}; using static ' + f'body fallback: {exc}' + ) + self._mark_degraded(name, 'static_boolean') + # Operation-graph nodes are functional: a boolean result must not consume + # source bodies that can feed other graph branches. Work only on copies. + result = native_fallback_base or self._copy_temp_body(base) + pending = list(native_fallback_tools or tools) + applied_steps = [] + while pending: + if op_code == SWBODYADD: + result_bbox = self._box_from_entity(result) + pending_boxes = [self._box_from_entity(candidate) for candidate in pending] + candidate_indices = sorted( + range(len(pending)), + key=lambda index: ( + not _bbox_intersects( + result_bbox, + pending_boxes[index], + tolerance=1.0e-5, + ), + _norm(_bbox_axis_gaps(result_bbox, pending_boxes[index])), + index, + ), + ) + elif op_code == SWBODYCUT: + # Set subtraction is independent of tool order. SolidWorks can + # reject one copied tool with 547 while accepting another tool + # first; try every pending cutter before escalating to Combine. + candidate_indices = list(range(len(pending))) + else: + candidate_indices = [0] + + attempts = [] + for candidate_index in candidate_indices: + tool = pending[candidate_index] + boolean_result = None + error_code = 0 + for reset_tolerances in (False, True): + if reset_tolerances and error_code != 547: + break + # Operations2 invalidates both input temporary bodies. Always + # start an attempt from fresh copies so a failed boolean cannot + # corrupt this graph branch or a later retry. + working_result = self._copy_temp_body(result) + temp_tool = self._copy_temp_body(tool) + if reset_tolerances: + try: + working_result.ResetEdgeTolerances() + temp_tool.ResetEdgeTolerances() + except Exception as exc: + self.logs.append( + f'edge tolerance reset failed for {name}: {exc}' + ) + error_code = 0 + try: + response = working_result.Operations2(op_code, temp_tool) + if ( + isinstance(response, tuple) + and len(response) == 2 + and isinstance(response[1], int) + ): + boolean_result, error_code = response + else: + boolean_result = response + except Exception as exc: + self.logs.append(f'Operations2 attempt failed for {name}: {exc}') + if not boolean_result: + try: + # Do not cap this at one section: a disjoint union returns + # two bodies, which must be rejected rather than truncated. + # Operations2 invalidates its inputs even when it returns an + # error code, so the legacy fallback needs fresh copies. + legacy_result = self._copy_temp_body(result) + legacy_tool = self._copy_temp_body(tool) + boolean_result = legacy_result.Operations( + op_code, legacy_tool, 1024 + ) + if boolean_result: + self.logs.append( + f'boolean {name} recovered with legacy body ' + f'operation after Operations2 error {error_code}' + ) + except Exception as legacy_exc: + self.logs.append( + f'legacy body operation attempt failed for {name}: {legacy_exc}' + ) + if boolean_result: + break + if ( + not boolean_result + and op_code == SWBODYCUT + and error_code == 547 + ): + tool_bbox = self._box_from_entity(tool) + for clearance_factor in ( + 1.0 - 1.0e-6, + 1.0 + 1.0e-6, + 1.0 - 1.0e-5, + 1.0 + 1.0e-5, + 1.0 - 1.0e-4, + 1.0 + 1.0e-4, + ): + working_result = self._copy_temp_body(result) + temp_tool = self._copy_temp_body(tool) + try: + self._apply_transform_to_temp_body( + temp_tool, + _scale_about_bbox_matrix( + tool_bbox, clearance_factor + ), + ) + response = working_result.Operations2( + op_code, temp_tool + ) + if ( + isinstance(response, tuple) + and len(response) == 2 + and isinstance(response[1], int) + ): + boolean_result, error_code = response + else: + boolean_result = response + error_code = 0 + except Exception as exc: + self.logs.append( + f'cut clearance attempt failed for {name}; ' + f'factor={clearance_factor:.9g}: {exc}' + ) + boolean_result = None + if boolean_result: + self.logs.append( + f'cut {name} recovered error 547 with tool ' + f'clearance factor {clearance_factor:.9g}' + ) + break + if boolean_result: + candidate_bodies = self._boolean_result_bodies(boolean_result) + if len(candidate_bodies) > 1: + if ( + op_code == SWBODYCUT + and len(pending) > 1 + and ( + allow_split_sections + or isinstance(canonical_result, dict) + ) + ): + remaining_tools = [ + candidate + for index, candidate in enumerate(pending) + if index != candidate_index + ] + split_results = [] + for section_index, section in enumerate(candidate_bodies): + section_result = self._boolean_body( + section, + remaining_tools, + op_code, + f'{name}_section_{section_index + 1}', + skip_non_intersecting=True, + clean=clean, + canonical_result=None, + allow_split_sections=True, + ) + if isinstance(section_result, list): + split_results.extend(section_result) + else: + split_results.append(section_result) + if isinstance(canonical_result, dict): + canonical_matches = [ + body + for body in split_results + if self._body_matches_canonical( + body, canonical_result + ) + ] + if len(canonical_matches) == 1: + self.logs.append( + f'cut {name} selected the unique canonical ' + f'section after all tools from ' + f'{len(split_results)} SolidWorks sections' + ) + return canonical_matches + if not self._body_set_matches_canonical( + split_results, canonical_result + ): + actual_sections = [ + { + 'bbox': self._box_from_entity(body), + 'volume': self._body_volume(body), + } + for body in split_results + ] + raise RuntimeError( + f'SolidWorks split cut for {name} did not ' + f'match the canonical result descriptor; ' + f'canonical={canonical_result!r}; ' + f'sections={actual_sections!r}' + ) + self.logs.append( + f'cut {name} preserved {len(split_results)} ' + 'SolidWorks sections matching one canonical Solid' + ) + return split_results + canonical_match = None + canonical_match_score = None + expected_bbox = ( + canonical_result.get('bbox') + if isinstance(canonical_result, dict) + else None + ) + if ( + op_code == SWBODYCUT + and len(pending) == 1 + and isinstance(expected_bbox, dict) + ): + expected_size = _distance( + expected_bbox.get('min'), expected_bbox.get('max') + ) + tolerance = max(1.0e-7, expected_size * 1.0e-7) + ranked_sections = sorted( + ( + _bbox_score( + self._box_from_entity(body), expected_bbox + ), + index, + body, + ) + for index, body in enumerate(candidate_bodies) + ) + matching_sections = [ + section + for section in ranked_sections + if section[0] <= tolerance + ] + if len(matching_sections) == 1: + ( + canonical_match_score, + _canonical_match_index, + canonical_match, + ) = matching_sections[0] + if canonical_match is not None: + self.logs.append( + f'cut {name} selected the unique canonical solid ' + f'section from {len(candidate_bodies)} candidates; ' + f'bbox_score={canonical_match_score:.9g}' + ) + candidate_bodies = [canonical_match] + if len(candidate_bodies) > 1: + if op_code == SWBODYCUT and len(pending) == 1: + self._mark_degraded(name, 'static_boolean_split') + self.logs.append( + f'cut {name} produced {len(candidate_bodies)} ' + f'geometrically separate solid sections' + ) + return [ + self._create_feature_from_body( + body, f'{name}_section_{index + 1}' + ) + for index, body in enumerate(candidate_bodies) + ] + attempts.append( + { + 'candidate_index': candidate_index, + 'error': int(error_code), + 'result': ( + f'SolidWorks body boolean for {name} returned ' + f'{len(candidate_bodies)} solid sections; expected one' + ), + 'bbox': ( + pending_boxes[candidate_index] + if op_code == SWBODYADD + else self._box_from_entity(tool) + ), + } + ) + continue + if len(candidate_bodies) != 1: + attempts.append( + { + 'candidate_index': candidate_index, + 'error': int(error_code), + 'result': 'SolidWorks body boolean returned no solid section', + 'bbox': self._box_from_entity(tool), + } + ) + continue + result = candidate_bodies[0] + applied_steps.append({ + 'tool_bbox': self._box_from_entity(tool), + 'tool_volume': self._body_volume(tool), + 'result_bbox': self._box_from_entity(result), + 'result_volume': self._body_volume(result), + }) + pending.pop(candidate_index) + break + attempts.append( + { + 'candidate_index': candidate_index, + 'error': int(error_code), + 'bbox': ( + pending_boxes[candidate_index] + if op_code == SWBODYADD + else self._box_from_entity(tool) + ), + } + ) + else: + error_code = attempts[0]['error'] if attempts else int(error_code) + if skip_non_intersecting and op_code == SWBODYCUT and error_code in {5, 1067}: + pending.pop(0) + continue + if op_code == SWBODYCUT and error_code == 547: + # Operations2 is more reliable for ordinary bodies, but + # SolidWorks can reject a copied multi-tool cut with 547 + # even when the native Combine feature accepts the same + # geometric bodies. This retry is failure-only and keeps + # the graph's geometric body inputs unchanged. + try: + native_body = self._native_combine_body( + result, pending, op_code, name + ) + if native_body is not None: + self.logs.append( + f'boolean {name} recovered with native Combine ' + f'after Operations2 error 547' + ) + return native_body + except Exception as exc: + self.logs.append( + f'native Combine retry failed for {name}: {exc}' + ) + raise RuntimeError( + f'SolidWorks temporary body boolean failed for {name}; ' + f'error={error_code}; result_bbox={self._box_from_entity(result)!r}; ' + f'attempts={attempts!r}; recent_logs={self.logs[-12:]!r}' + ) + if clean: + cleaned = False + for call in ( + lambda: result.RemoveRedundantTopology(), + lambda: result._oleobj_.InvokeTypes( + 60, + 0, + pythoncom.DISPATCH_METHOD, + (pythoncom.VT_BOOL, 0), + (), + ), + ): + try: + cleaned = bool(call()) + if cleaned: + break + except Exception as exc: + self.logs.append( + f'redundant-topology cleanup failed for {name}: {exc}' + ) + if not cleaned: + self.logs.append( + f'redundant-topology cleanup was not applied for {name}' + ) + if ( + isinstance(canonical_result, dict) + and not self._body_matches_canonical(result, canonical_result) + ): + if ( + op_code == SWBODYCUT + and self._cut_tolerance_artifact_matches_canonical( + result, canonical_result + ) + ): + self.logs.append( + f'cut {name} accepted a bounded SolidWorks Boolean ' + 'tolerance expansion with canonical volume preserved' + ) + else: + actual_descriptor = { + 'bbox': self._box_from_entity(result), + 'volume': self._body_volume(result), + } + raise RuntimeError( + f'SolidWorks static boolean for {name} did not match ' + f'canonical bbox/volume; actual={actual_descriptor!r}; ' + f'canonical={canonical_result!r}; ' + f'base={{"bbox": {self._box_from_entity(base)!r}, ' + f'"volume": {self._body_volume(base)!r}}}; ' + f'tools={[(self._box_from_entity(tool), self._body_volume(tool)) for tool in tools]!r}; ' + f'applied_steps={applied_steps!r}' + ) + self._mark_degraded(name, 'static_boolean') + return self._create_feature_from_body(result, name) + + def _body_set_matches_canonical( + self, bodies, canonical_result, volume_relative_tolerance=1.0e-5 + ): + expected_bbox = ( + canonical_result.get('bbox') + if isinstance(canonical_result, dict) + else None + ) + expected_volume = ( + canonical_result.get('volume') + if isinstance(canonical_result, dict) + else None + ) + if not bodies or not isinstance(expected_bbox, dict) or expected_volume is None: + return False + bboxes = [self._box_from_entity(body) for body in bodies] + if any(not isinstance(bbox, dict) for bbox in bboxes): + return False + actual_bbox = { + 'min': tuple( + min(float(bbox['min'][index]) for bbox in bboxes) + for index in range(3) + ), + 'max': tuple( + max(float(bbox['max'][index]) for bbox in bboxes) + for index in range(3) + ), + } + volumes = [self._body_volume(body) for body in bodies] + if any(volume is None for volume in volumes): + return False + actual_volume = sum(float(volume) for volume in volumes) + volume_error = _relative_error( + actual_volume, expected_volume, floor=1.0e-12 + ) + expected_size = _distance(expected_bbox.get('min'), expected_bbox.get('max')) + # When volume is effectively exact, permit the small bbox expansion + # caused by SolidWorks edge/surface tolerances. This remains far below + # feature-edit amplitudes and does not hide an incorrect transform: + # the former 082 axis bug displaced the bbox by 16 mm. + bbox_relative_tolerance = ( + 1.0e-4 if volume_error <= 1.0e-8 else 2.0e-6 + ) + bbox_tolerance = max( + 2.0e-7, expected_size * bbox_relative_tolerance + ) + if _bbox_score(actual_bbox, expected_bbox) > bbox_tolerance: + return False + return volume_error <= float(volume_relative_tolerance) + + def _body_matches_canonical( + self, body, canonical_result, volume_relative_tolerance=1.0e-5 + ): + expected_bbox = ( + canonical_result.get('bbox') + if isinstance(canonical_result, dict) + else None + ) + expected_volume = ( + canonical_result.get('volume') + if isinstance(canonical_result, dict) + else None + ) + if not isinstance(expected_bbox, dict) or expected_volume is None: + return False + expected_size = _distance(expected_bbox.get('min'), expected_bbox.get('max')) + volume = self._body_volume(body) + volume_error = ( + _relative_error(volume, expected_volume, floor=1.0e-12) + if volume is not None else float('inf') + ) + bbox_relative_tolerance = ( + 1.0e-4 if volume_error <= 1.0e-8 else 2.0e-6 + ) + bbox_tolerance = max( + 2.0e-7, expected_size * bbox_relative_tolerance + ) + return ( + _bbox_score(self._box_from_entity(body), expected_bbox) <= bbox_tolerance + and volume is not None + and volume_error <= float(volume_relative_tolerance) + ) + + def _cut_tolerance_artifact_matches_canonical( + self, body, canonical_result + ): + expected_bbox = ( + canonical_result.get('bbox') + if isinstance(canonical_result, dict) else None + ) + expected_volume = ( + canonical_result.get('volume') + if isinstance(canonical_result, dict) else None + ) + actual_bbox = self._box_from_entity(body) + actual_volume = self._body_volume(body) + if ( + not isinstance(expected_bbox, dict) + or not isinstance(actual_bbox, dict) + or expected_volume is None + or actual_volume is None + or _relative_error( + actual_volume, expected_volume, floor=1.0e-12 + ) > 5.0e-6 + ): + return False + expected_min = _v3(expected_bbox.get('min')) + expected_max = _v3(expected_bbox.get('max')) + actual_min = _v3(actual_bbox.get('min')) + actual_max = _v3(actual_bbox.get('max')) + expected_size = _distance(expected_min, expected_max) + containment_tolerance = max(2.0e-7, expected_size * 1.0e-4) + if any( + actual_min[index] > expected_min[index] + containment_tolerance + or actual_max[index] < expected_max[index] - containment_tolerance + for index in range(3) + ): + return False + maximum_expansion = max( + max(0.0, expected_min[index] - actual_min[index]) + for index in range(3) + ) + maximum_expansion = max( + maximum_expansion, + max( + max(0.0, actual_max[index] - expected_max[index]) + for index in range(3) + ), + ) + return maximum_expansion <= max( + 2.0e-7, expected_size * 2.5e-3 + ) + + def _combine_op_value(self, op_code): + if op_code not in {SWBODYADD, SWBODYCUT, SWBODYINTERSECT}: + raise RuntimeError(f'Unsupported combine operation code: {op_code}') + return op_code + + def _native_combine_body( + self, base, tools, op_code, name, *, prefer_live_base=False + ): + combine_op = self._combine_op_value(op_code) + before = self._body_names() + self._clear_selection() + if not self._select_entity(base, append=False, mark=1): + raise RuntimeError('Could not select main body for native Combine') + for tool in tools: + if not self._select_entity(tool, append=True, mark=2): + raise RuntimeError('Could not select tool body for native Combine') + feature = None + direct_tools = ( + tuple(tools) + if op_code == SWBODYCUT + else tuple([base] + list(tools)) + ) + direct_tool_variants = [] + empty_tool_variants = [] + # InsertCombineFeature's ToolVar is a COM array of Body2 objects. + # pywin32 can otherwise expose a plain Python tuple as a scalar + # VARIANT, which SolidWorks rejects with DISP_E_TYPEMISMATCH. The + # explicit arrays are required both for the primary live path and for + # the failure-only retry after temporary-body error 547. + for variant_type in ( + pythoncom.VT_ARRAY | pythoncom.VT_DISPATCH, + pythoncom.VT_ARRAY | pythoncom.VT_VARIANT, + ): + try: + direct_tool_variants.append( + win32com.client.VARIANT(variant_type, direct_tools) + ) + except Exception as exc: + self.logs.append( + f'could not marshal Combine tools for {name}: {exc}' + ) + try: + empty_tool_variants.append( + win32com.client.VARIANT(variant_type, ()) + ) + except Exception as exc: + self.logs.append( + f'could not marshal empty Combine tools for {name}: {exc}' + ) + empty_main = _empty_dispatch() + for call in ( + *( + lambda tool_variant=tool_variant: self.model.FeatureManager.InsertCombineFeature( + combine_op, + base if op_code == SWBODYCUT else empty_main, + tool_variant, + ) + for tool_variant in direct_tool_variants + ), + lambda: self.model.FeatureManager.InsertCombineFeature( + combine_op, base if op_code == SWBODYCUT else empty_main, + direct_tools, + ), + *( + lambda empty_tools=empty_tools: self.model.FeatureManager.InsertCombineFeature( + combine_op, empty_main, empty_tools + ) + for empty_tools in empty_tool_variants + ), + ): + try: + feature = call() + if feature is not None: + break + except Exception as exc: + self.logs.append(f'combine attempt failed for {name}: {exc}') + self._clear_selection() + if feature is None: + raise RuntimeError('SolidWorks InsertCombineFeature failed') + try: + feature.Name = str(name) + except Exception: + pass + return self._capture_new_body(before, feature) + + def _box_from_entity(self, entity): + for call in (lambda: entity.GetBox(), lambda: entity.GetBodyBox()): + try: + bbox = _bbox_from_box(call()) + if bbox: + return bbox + except Exception: + pass + return {'min': (0.0, 0.0, 0.0), 'max': (0.0, 0.0, 0.0)} + + def _body_volume(self, body): + try: + values = body.GetMassProperties(1.0) + if ( + isinstance(values, (list, tuple)) + and values + and isinstance(values[0], (list, tuple)) + ): + values = values[0] + if isinstance(values, (list, tuple)) and len(values) >= 4: + volume_m3 = abs(float(values[3])) + if math.isfinite(volume_m3) and volume_m3 > 0.0: + return volume_m3 * (M_TO_MM / MODEL_SCALE) ** 3 + except Exception: + pass + return None + + def _edge_signature(self, edge): + def array3(value): + try: + value = _maybe_call(value) + if value is not None and len(value) >= 3: + return tuple(float(value[index]) for index in range(3)) + except Exception: + pass + return None + + curve = None + for call in ( + lambda: _maybe_call(edge.GetCurve), + lambda: win32com.client.Dispatch(edge._oleobj_.InvokeTypes( + 1, 0, pythoncom.DISPATCH_METHOD, (pythoncom.VT_DISPATCH, 0), () + )), + ): + try: + curve = call() + if curve is not None: + break + except Exception: + curve = None + + # GetCurveParams3 is the documented source for an edge's geometric + # endpoints, parameter range, and curve type. GetCurve must be called + # first so SolidWorks materializes the underlying curve information. + start_m = end_m = None + u_min = u_max = None + curve_type = None + try: + curve_data = _maybe_call(edge.GetCurveParams3) + if curve_data is not None: + start_m = array3(getattr(curve_data, 'StartPoint', None)) + end_m = array3(getattr(curve_data, 'EndPoint', None)) + u_min = float(_maybe_call(getattr(curve_data, 'UMinValue'))) + u_max = float(_maybe_call(getattr(curve_data, 'UMaxValue'))) + curve_type = int(_maybe_call(getattr(curve_data, 'CurveType'))) + except Exception: + start_m = end_m = None + u_min = u_max = None + curve_type = None + + params = None + if start_m is None or end_m is None or u_min is None or u_max is None: + for call in ( + lambda: _maybe_call(edge.GetCurveParams2), + lambda: edge._oleobj_.InvokeTypes( + 24, 0, pythoncom.DISPATCH_METHOD, + (pythoncom.VT_VARIANT, 0), (), + ), + ): + try: + values = call() + params = tuple(float(value) for value in values) + if len(params) >= 8: + start_m = tuple(params[index] for index in range(3)) + end_m = tuple(params[index] for index in range(3, 6)) + u_min, u_max = float(params[6]), float(params[7]) + break + except Exception: + params = None + + def vertex_point(getter_name): + try: + vertex = _maybe_call(getattr(edge, getter_name)) + if vertex is None: + return None + return array3(getattr(vertex, 'GetPoint')) + except Exception: + return None + + points_m = [] + curve_samples_m = [] + if start_m is not None and end_m is not None and u_min is not None and u_max is not None: + for step in range(65): + parameter = u_min + (u_max - u_min) * (step / 64.0) + evaluated = None + for call in ( + lambda: edge.Evaluate2(parameter, 1), + lambda: curve.Evaluate2(parameter, 1) if curve is not None else None, + ): + try: + evaluated = call() + if evaluated is not None and len(evaluated) >= 3: + break + except Exception: + evaluated = None + if evaluated is None or len(evaluated) < 3: + curve_samples_m = [] + break + point = tuple(float(evaluated[index]) for index in range(3)) + tangent = ( + tuple(float(evaluated[index]) for index in range(3, 6)) + if len(evaluated) >= 6 + else None + ) + curve_samples_m.append((point, tangent)) + if len(curve_samples_m) == 65: + points_m.extend(point for point, _tangent in curve_samples_m) + else: + points_m.extend((start_m, end_m)) + else: + start_m = vertex_point('GetStartVertex') + end_m = vertex_point('GetEndVertex') + if start_m is not None: + points_m.append(start_m) + if end_m is not None: + points_m.append(end_m) + + if not points_m: + bbox = self._box_from_entity(edge) + start = end = _bbox_center(bbox) + else: + converted = [ + tuple(value * M_TO_MM / MODEL_SCALE for value in point) + for point in points_m + ] + bbox = { + 'min': tuple(min(point[index] for point in converted) for index in range(3)), + 'max': tuple(max(point[index] for point in converted) for index in range(3)), + } + start = tuple(value * M_TO_MM / MODEL_SCALE for value in start_m) + end = tuple(value * M_TO_MM / MODEL_SCALE for value in end_m) + + center = _bbox_center(bbox) + length = _distance(start, end) + for call in ( + lambda: curve.GetLength3(u_min, u_max), + lambda: curve._oleobj_.InvokeTypes( + 63, 0, pythoncom.DISPATCH_METHOD, (pythoncom.VT_R8, 0), + ((pythoncom.VT_R8, pythoncom.PARAMFLAG_FIN), (pythoncom.VT_R8, pythoncom.PARAMFLAG_FIN)), + u_min, u_max, + ), + lambda: curve._oleobj_.InvokeTypes( + 21, 0, pythoncom.DISPATCH_METHOD, (pythoncom.VT_R8, 0), + ((pythoncom.VT_R8, pythoncom.PARAMFLAG_FIN), (pythoncom.VT_R8, pythoncom.PARAMFLAG_FIN)), + u_min, u_max, + ), + ): + try: + length_value = call() + if length_value is not None: + length = float(length_value) * M_TO_MM / MODEL_SCALE + break + except Exception: + pass + curve_types = { + 3001: 'LINE', + 3002: 'CIRCLE', + 3003: 'ELLIPSE', + 3004: 'INTERSECTION', + 3005: 'BSPLINE', + 3006: 'SPCURVE', + 3008: 'CONSTPARAM', + 3009: 'TRIMMED', + } + geom_type = '' + if curve_type is not None: + geom_type = curve_types.get(int(curve_type), '') + if not geom_type: + try: + geom_type = curve_types.get(int(_maybe_call(curve.Identity)), '') + except Exception: + if start_m is not None and end_m is not None: + geom_type = 'LINE' if abs(_distance(start, end) - length) <= 1.0e-7 else '' + if geom_type in {'BSPLINE', 'INTERSECTION'} and len(curve_samples_m) == 65: + sample_points = [ + tuple(value * M_TO_MM / MODEL_SCALE for value in point) + for point, _tangent in curve_samples_m + ] + chord = _sub(end, start) + chord_length = _norm(chord) + scale = max(1.0, chord_length, length) + if ( + chord_length > scale * 1.0e-10 + and abs(length - chord_length) <= scale * 1.0e-7 + and all( + _norm(_cross(_sub(point, start), chord)) / chord_length + <= scale * 1.0e-7 + for point in sample_points + ) + ): + geom_type = 'LINE' + if ( + geom_type == 'INTERSECTION' + and _distance(start, end) > max(1.0, length) * 1.0e-7 + ): + # SolidWorks reports open surface-intersection splines with the + # generic INTERSECTION identity. OCC exposes the same support as a + # BSplineCurve, so normalize the backend signature before GSM. + geom_type = 'BSPLINE' + if geom_type == 'LINE': + center = tuple((start[index] + end[index]) * 0.5 for index in range(3)) + elif len(curve_samples_m) == 65 and all( + tangent is not None for _point, tangent in curve_samples_m + ): + weighted = [0.0, 0.0, 0.0] + total = 0.0 + for index, (point, tangent) in enumerate(curve_samples_m): + coefficient = 1.0 if index in {0, 64} else (4.0 if index % 2 else 2.0) + weight = coefficient * _norm(tangent) + total += weight + for axis in range(3): + weighted[axis] += weight * point[axis] + if total > 1.0e-18: + center = tuple( + value / total * M_TO_MM / MODEL_SCALE + for value in weighted + ) + start_tangent = end_tangent = None + if len(curve_samples_m) == 65: + first_tangent = curve_samples_m[0][1] + last_tangent = curve_samples_m[-1][1] + if first_tangent is not None: + start_tangent = _unit(first_tangent) + if last_tangent is not None: + end_tangent = _unit(last_tangent) + support = None + if geom_type == 'CIRCLE' and curve is not None: + for attribute_name in ('CircleParams', 'ICircleParams'): + try: + values = _maybe_call(getattr(curve, attribute_name)) + if values is not None and len(values) >= 7: + support = { + 'center': tuple( + float(values[index]) * M_TO_MM / MODEL_SCALE + for index in range(3) + ), + 'axis': _unit(tuple( + float(values[index]) for index in range(3, 6) + )), + 'radius': ( + abs(float(values[6])) * M_TO_MM / MODEL_SCALE + ), + } + break + except Exception: + support = None + return { + 'bbox': bbox, + 'center': center, + 'start': start, + 'end': end, + 'start_tangent': start_tangent, + 'end_tangent': end_tangent, + 'samples': [ + tuple(value * M_TO_MM / MODEL_SCALE for value in point) + for point, _tangent in curve_samples_m + ], + 'length': length, + 'geom_type': geom_type, + 'support': support, + } + + def _face_signature(self, face): + bbox = self._box_from_entity(face) + area = 0.0 + try: + area = float(face.GetArea()) * M2_TO_MM2 / (MODEL_SCALE * MODEL_SCALE) + except Exception: + pass + geom_type = '' + surface_types = { + 4001: 'PLANE', + 4002: 'CYLINDER', + 4003: 'CONE', + 4004: 'SPHERE', + 4005: 'TORUS', + 4006: 'BSPLINE', + 4007: 'BLEND', + 4008: 'OFFSET', + 4009: 'EXTRU', + 4010: 'SREV', + } + try: + surface = face.GetSurface() + identity = int(_maybe_call(surface.Identity)) + geom_type = surface_types.get(identity, str(identity)) + except Exception: + surface = None + normal = None + try: + values = _maybe_call(getattr(face, 'Normal')) + if values is not None and len(values) >= 3: + normal = _unit(tuple(float(values[index]) for index in range(3))) + except Exception: + pass + if normal is None and geom_type == 'PLANE' and surface is not None: + try: + values = _maybe_call(getattr(surface, 'PlaneParams')) + if values is not None and len(values) >= 6: + normal = _unit(tuple(float(values[index]) for index in range(3, 6))) + except Exception: + pass + return { + 'bbox': bbox, + 'center': _bbox_center(bbox), + 'area': area, + 'geom_type': geom_type, + 'normal': normal, + } + + def _cylinder_surface_params(self, face): + surface = None + for call in ( + lambda: _maybe_call(face.GetSurface), + lambda: win32com.client.Dispatch(face._oleobj_.InvokeTypes( + 3, + 0, + pythoncom.DISPATCH_METHOD, + (pythoncom.VT_DISPATCH, 0), + (), + )), + ): + try: + surface = call() + if surface is not None: + break + except Exception: + surface = None + if surface is None: + return None + is_cylinder = False + for call in ( + lambda: bool(_maybe_call(surface.IsCylinder)), + lambda: bool(surface._oleobj_.InvokeTypes( + 7, + 0, + pythoncom.DISPATCH_METHOD, + (pythoncom.VT_BOOL, 0), + (), + )), + ): + try: + is_cylinder = call() + break + except Exception: + pass + if not is_cylinder: + return None + values = None + for call in ( + lambda: _maybe_call(surface.CylinderParams), + lambda: surface._oleobj_.InvokeTypes( + 2, + 0, + pythoncom.DISPATCH_PROPERTYGET, + (pythoncom.VT_VARIANT, 0), + (), + ), + ): + try: + values = tuple(float(value) for value in call()) + if len(values) >= 7: + break + except Exception: + values = None + if values is None or len(values) < 7: + return None + scale = M_TO_MM / MODEL_SCALE + return { + 'origin': tuple(values[index] * scale for index in range(3)), + 'axis': _unit(values[3:6]), + 'radius': abs(float(values[6]) * scale), + 'bbox': self._box_from_entity(face), + } + + def _is_missing_cylinder_seam(self, body, selector): + selector = _selector_geometry(selector) + if _selector_kind(selector, {}) != 'edge': + return False + if _selector_geom_type(selector) != 'LINE': + return False + start = _tuple3_or_none(selector.get('start')) + end = _tuple3_or_none(selector.get('end')) + expected_length = selector.get('length') + if start is None or end is None or expected_length is None: + return False + direction = _sub(end, start) + chord_length = _norm(direction) + scale = max(1.0, chord_length, abs(float(expected_length))) + if chord_length <= scale * 1.0e-10: + return False + if _relative_error(chord_length, expected_length) > 1.0e-4: + return False + unit_direction = _unit(direction) + body_bbox = self._box_from_entity(body) + for face in self._body_faces(body): + cylinder = self._cylinder_surface_params(face) + if cylinder is None: + continue + if abs(_dot(unit_direction, cylinder['axis'])) < 1.0 - 1.0e-6: + continue + radius = float(cylinder['radius']) + radius_scale = max(1.0, radius) + if ( + abs(_point_line_distance(start, cylinder['origin'], cylinder['axis']) - radius) + > radius_scale * 1.0e-5 + or abs(_point_line_distance(end, cylinder['origin'], cylinder['axis']) - radius) + > radius_scale * 1.0e-5 + ): + continue + bbox = cylinder.get('bbox') + if ( + not isinstance(bbox, dict) + or _distance(bbox.get('min'), bbox.get('max')) <= 1.0e-12 + ): + bbox = body_bbox + tolerance = scale * 1.0e-5 + if not isinstance(bbox, dict): + continue + if not all( + float(bbox['min'][index]) - tolerance <= point[index] + <= float(bbox['max'][index]) + tolerance + for point in (start, end) + for index in range(3) + ): + continue + return True + return False + + def _detail_edge_topology_match( + self, ranked, selector, source, missing_seam + ): + closed_intersection = _closed_intersection_candidate(ranked, selector) + if closed_intersection is not None: + return closed_intersection + if _is_missing_revolution_seam(ranked, selector): + return missing_seam + if self._is_missing_cylinder_seam(source, selector): + return missing_seam + return None + + def _body_edges(self, body): + try: + edges = body.GetEdges() + except Exception: + edges = None + if not edges: + return [] + return list(edges) if isinstance(edges, (list, tuple)) else [edges] + + def _body_faces(self, body): + try: + faces = body.GetFaces() + except Exception: + faces = None + if not faces: + return [] + return list(faces) if isinstance(faces, (list, tuple)) else [faces] + + def _feature_detail_edges(self, params, inputs, kind, node_id): + upstream_bodies = self._bodies_from_value( + self._first_output(inputs[0]) + ) + canonical_result = self._detail_result_descriptor( + inputs[0], node_id + ) + if upstream_bodies: + self._capture_detail_source_topology( + inputs[0], upstream_bodies + ) + # Keep detail features attached to their live input body even when a + # downstream transform/boolean consumes the result. Copying the input + # first creates a static BaseBody/MoveCopy snapshot and severs native + # parameter propagation after the document is reopened. + selectors = [] + for selector_id in params.get('selected_edge_node_ids') or []: + payload = self.selection_payloads.get(str(selector_id)) + if payload: + selectors.append(payload.get('params') or {}) + if not selectors: + for item in params.get('selected_edges') or []: + if isinstance(item, dict): + selectors.append(item.get('selector_hint') or item) + if not selectors: + raise RuntimeError(f'{kind} requires at least one geometrically selected edge') + if len(upstream_bodies) > 1: + # Match against the same complete edge pool represented by the + # canonical compound, then require every selector (including a + # fragmented path) to belong to exactly one native body. This + # preserves unaffected live bodies instead of silently dropping + # them when SolidWorks reports only the feature's changed body. + edge_signatures = [] + body_edge_signatures = [] + edge_owners = {} + for body_index, body in enumerate(upstream_bodies): + signatures = [ + (edge, self._edge_signature(edge)) + for edge in self._body_edges(body) + ] + body_edge_signatures.append(signatures) + for edge, signature in signatures: + edge_signatures.append((edge, signature)) + edge_owners[id(edge)] = body_index + selectors_by_body = { + index: [] for index in range(len(upstream_bodies)) + } + missing_seam = object() + + def compound_topology_match(ranked, selector): + closed_intersection = _closed_intersection_candidate( + ranked, selector + ) + if closed_intersection is not None: + return closed_intersection + seam_owners = [] + for body_index, body in enumerate(upstream_bodies): + local_ranked = sorted( + body_edge_signatures[body_index], + key=lambda item: _geom_score(item[1], selector), + ) + if ( + _is_missing_revolution_seam(local_ranked, selector) + or self._is_missing_cylinder_seam(body, selector) + ): + seam_owners.append(body_index) + if len(seam_owners) == 1: + return missing_seam + if len(seam_owners) > 1: + raise RuntimeError( + f'{kind} {node_id} seam selector is ambiguous across ' + f'live bodies {seam_owners!r}' + ) + return None + + skipped_seams = 0 + individual_candidates = {} + individual_errors = {} + for selector_index, selector in enumerate(selectors): + try: + individual_candidates[selector_index] = ( + _selection_candidates_by_geometry( + edge_signatures, + selector, + 'edge', + topology_match=compound_topology_match, + ) + ) + except RuntimeError as exc: + individual_errors[selector_index] = exc + coalesced_pairs = _coalesced_edge_selector_pairs( + edge_signatures, + selectors, + ) + coalesced_pairs = [ + pair for pair in coalesced_pairs + if pair[0] in individual_errors or pair[1] in individual_errors + ] + coalesced_candidates = {} + for left_index, right_index, candidate_index in coalesced_pairs: + candidate = edge_signatures[candidate_index][0] + coalesced_candidates[left_index] = candidate + coalesced_candidates[right_index] = candidate + covered_errors = set(individual_errors) & set(coalesced_candidates) + unresolved_errors = sorted(set(individual_errors) - covered_errors) + if unresolved_errors: + raise individual_errors[unresolved_errors[0]] + for selector_index, selector in enumerate(selectors): + candidates = ( + [coalesced_candidates[selector_index]] + if selector_index in coalesced_candidates + else individual_candidates[selector_index] + ) + if len(candidates) == 1 and candidates[0] is missing_seam: + skipped_seams += 1 + continue + owners = { + edge_owners.get(id(candidate)) for candidate in candidates + } + owners.discard(None) + if len(owners) != 1: + raise RuntimeError( + f'{kind} {node_id} selector {selector_index} ' + f'matched edges on {len(owners)} live bodies; refusing ' + 'an ambiguous multi-body detail feature' + ) + selectors_by_body[next(iter(owners))].append(selector) + if skipped_seams: + self.logs.append( + f'{kind} {node_id} omitted {skipped_seams} seam ' + 'selection(s) absent from the SolidWorks body set' + ) + affected_body_indices = [ + index for index, body_selectors in selectors_by_body.items() + if body_selectors + ] + affected_canonical = None + if ( + len(affected_body_indices) == 1 + and isinstance(canonical_result, dict) + and canonical_result.get('volume') is not None + ): + affected_index = affected_body_indices[0] + unaffected_volumes = [ + self._body_volume(body) + for index, body in enumerate(upstream_bodies) + if index != affected_index + ] + if all(volume is not None for volume in unaffected_volumes): + affected_canonical = { + 'bbox': self._box_from_entity( + upstream_bodies[affected_index] + ), + 'volume': ( + float(canonical_result['volume']) + - sum(float(volume) for volume in unaffected_volumes) + ), + } + results = [] + for body_index, body in enumerate(upstream_bodies): + body_selectors = selectors_by_body[body_index] + if not body_selectors: + results.append(body) + continue + results.append( + self._apply_detail_feature_to_body( + body, + body_selectors, + params, + kind, + f'{node_id}_body_{body_index + 1}', + allow_component_fallback=True, + strict_canonical_result=( + affected_canonical + if body_index in affected_body_indices + else None + ), + ) + ) + self.logs.append( + f'{kind} {node_id} preserved {len(results)} live bodies; ' + f'{sum(bool(value) for value in selectors_by_body.values())} ' + 'body/bodies were affected' + ) + self._validate_detail_result( + results, canonical_result, kind, node_id + ) + return results + source = upstream_bodies[0] + self.logs.append(f'{kind} {node_id} consumes the live input body') + result = self._apply_detail_feature_to_body( + source, + selectors, + params, + kind, + node_id, + allow_component_fallback=True, + topology_source_node_id=inputs[0], + topology_target_node_id=node_id, + ) + self._validate_detail_result( + result, canonical_result, kind, node_id + ) + return result + + def _detail_selected_edges(self, source, selectors, kind, node_id): + edge_signatures = [ + (edge, self._edge_signature(edge)) + for edge in self._body_edges(source) + ] + selected = [] + skipped_seams = 0 + missing_seam = object() + individual_candidates = {} + individual_errors = {} + for selector_index, selector in enumerate(selectors): + try: + individual_candidates[selector_index] = ( + _selection_candidates_by_geometry( + edge_signatures, + selector, + 'edge', + topology_match=lambda _ranked, normalized_selector: self._detail_edge_topology_match( + _ranked, + normalized_selector, + source, + missing_seam, + ), + ) + ) + except RuntimeError as exc: + individual_errors[selector_index] = exc + coalesced_pairs = _coalesced_edge_selector_pairs( + edge_signatures, + selectors, + ) + coalesced_pairs = [ + pair for pair in coalesced_pairs + if pair[0] in individual_errors or pair[1] in individual_errors + ] + coalesced_by_selector = {} + for left_index, right_index, candidate_index in coalesced_pairs: + coalesced_by_selector[left_index] = ( + right_index, edge_signatures[candidate_index][0] + ) + consumed_selectors = { + right_index + for _left_index, right_index, _candidate_index in coalesced_pairs + } + covered_errors = { + index + for left_index, right_index, _candidate_index in coalesced_pairs + for index in (left_index, right_index) + if index in individual_errors + } + unresolved_errors = sorted(set(individual_errors) - covered_errors) + if unresolved_errors: + raise individual_errors[unresolved_errors[0]] + for selector_index, selector in enumerate(selectors): + if selector_index in consumed_selectors: + continue + if selector_index in coalesced_by_selector: + right_index, candidate = coalesced_by_selector[selector_index] + selected.append(candidate) + self.logs.append( + f'{kind} {node_id} coalesced selector pair ' + f'{selector_index}/{right_index} onto one complete ' + 'SolidWorks edge' + ) + continue + candidates = individual_candidates[selector_index] + if len(candidates) == 1 and candidates[0] is missing_seam: + skipped_seams += 1 + else: + selected.extend(candidates) + return selected, skipped_seams + + def _delete_retry_feature(self, feature, kind, node_id): + self._clear_selection() + try: + selected = bool(feature.Select2(False, 0)) + except Exception: + selected = False + deleted = False + if selected: + try: + deleted = bool(self.model.Extension.DeleteSelection2(0)) + except Exception: + deleted = False + if not deleted: + raise RuntimeError( + f'Could not delete rejected SolidWorks {kind} candidate ' + f'for {node_id}' + ) + try: + self.model.ForceRebuild3(False) + except Exception: + pass + + def _apply_detail_feature_to_body( + self, + source, + selectors, + params, + kind, + node_id, + *, + allow_component_fallback, + topology_source_node_id=None, + topology_target_node_id=None, + strict_canonical_result=None, + require_geometry_change=False, + ): + source_bbox = self._box_from_entity(source) + source_descriptor = { + 'bbox': source_bbox, + 'volume': self._body_volume(source), + } + source_name = self._body_name(source) + source_reference = self._persistent_reference_bytes(source) + selected, skipped_seams = self._detail_selected_edges( + source, selectors, kind, node_id + ) + if skipped_seams: + self.logs.append( + f'{kind} {node_id} omitted {skipped_seams} cylindrical seam ' + 'selection(s) absent from SolidWorks native topology' + ) + if not selected: + return source + self._clear_selection() + for index, edge in enumerate(selected): + if not self._select_entity(edge, append=index > 0): + raise RuntimeError(f'Could not select SolidWorks edge for {kind}') + before = self._body_names() + result = None + feature_error = None + candidate_rebuilt = False + if kind == 'fillet': + radius = _as_m(params.get('radius', 0.0)) + feature = None + + def create_fillet_from_definition(propagate): + self._clear_selection() + for edge_index, edge in enumerate(selected): + if not self._select_entity( + edge, append=edge_index > 0, mark=1 + ): + return None + definition = self.model.FeatureManager.CreateDefinition(1) + if definition is None or not bool(definition.Initialize(0)): + return None + definition.ConicTypeForCrossSectionProfile = 0 + definition.DefaultRadius = radius + definition.OverflowType = 0 + definition.PropagateToTangentFaces = bool(propagate) + definition.Edges = win32com.client.VARIANT( + pythoncom.VT_ARRAY | pythoncom.VT_DISPATCH, + tuple(selected), + ) + return self.model.FeatureManager.CreateFeature(definition) + + for call in ( + # FeatureData preserves the explicit seed-edge set and avoids + # the legacy FeatureFillet option-bit expansion that can make + # large disconnected selections invalid in SolidWorks. + lambda: create_fillet_from_definition(False), + lambda: create_fillet_from_definition(True), + lambda: self.model.FeatureManager.FeatureFillet( + 2, + radius, + 0, + 0, + None, + None, + None, + ), + lambda: self.model.FeatureManager.FeatureFillet( + 66, + radius, + 0, + 0, + None, + None, + None, + ), + lambda: self.model.FeatureManager.FeatureFillet( + 34, + radius, + 0, + 0, + None, + None, + None, + ), + lambda: self.model.FeatureManager.FeatureFillet( + 98, + radius, + 0, + 0, + None, + None, + None, + ), + lambda: self.model.FeatureManager.FeatureFillet( + 226, + radius, + 0, + 0, + None, + None, + None, + ), + lambda: self.model.FeatureManager.FeatureFillet( + SW_FEATURE_FILLET_OPTIONS, + radius, + 0, + 0, + None, + None, + None, + ), + lambda: self.model.FeatureManager.FeatureFillet3( + SW_FEATURE_FILLET_OPTIONS, + radius, + 0.0, + 0.0, + 0, + 0, + 0, + None, + None, + None, + None, + None, + None, + None, + ), + lambda: self.model.FeatureManager.FeatureFillet2( + SW_FEATURE_FILLET_OPTIONS, + radius, + 0.0, + 0, + 0, + 0, + None, + None, + None, + None, + None, + ), + lambda: self.model.FeatureManager.InsertFeatureFillet(radius), + ): + try: + feature = call() + if feature is not None: + break + except Exception as exc: + self.logs.append(f'fillet attempt failed for {node_id}: {exc}') + else: + distance = _as_m(params.get('distance', params.get('radius', 0.0))) + feature = None + + def select_chamfer_edges(mark): + self._clear_selection() + for edge_index, edge in enumerate(selected): + if not self._select_entity( + edge, append=edge_index > 0, mark=mark + ): + return False + return True + + def create_chamfer_from_definition(): + # CreateDefinition supports simple chamfers through + # swFmFillet=1. The chamfer is the rho-zero profile + # specialization of ISimpleFilletFeatureData2, not a + # swFmChamfer definition. Tangent propagation stays disabled + # so CADIR's explicit seed-edge set survives save/reopen. + if not select_chamfer_edges(1): + return None + definition = self.model.FeatureManager.CreateDefinition(1) + if definition is None or not bool(definition.Initialize(0)): + return None + definition.ConicTypeForCrossSectionProfile = 3 + definition.AsymmetricFillet = False + definition.IsMultipleRadius = False + definition.DefaultRadius = distance + definition.OverflowType = 0 + definition.PropagateToTangentFaces = False + definition.Edges = win32com.client.VARIANT( + pythoncom.VT_ARRAY | pythoncom.VT_DISPATCH, + tuple(selected), + ) + return self.model.FeatureManager.CreateFeature(definition) + + def insert_equal_distance_chamfer(): + if not select_chamfer_edges(0): + return None + return self.model.FeatureManager.InsertFeatureChamfer( + 0, 16, 0.0, 0.0, distance, 0.0, 0.0, 0.0 + ) + + def insert_angle_distance_chamfer(): + if not select_chamfer_edges(0): + return None + return self.model.FeatureManager.InsertFeatureChamfer( + 0, 1, distance, math.pi / 4.0, 0.0, 0.0, 0.0, 0.0 + ) + + strict_canonical = strict_canonical_result + if strict_canonical is None: + strict_canonical = ( + self._detail_result_descriptor( + topology_source_node_id, topology_target_node_id + ) + if topology_source_node_id and topology_target_node_id + else None + ) + for attempt_name, call in ( + # CADIR serializes the complete seed-edge set. Keep tangent + # propagation disabled (option bit 4) so SolidWorks does not + # silently expand those seeds and destabilize the persisted + # FeatureData references after reopen. + ('equal_distance', insert_equal_distance_chamfer), + # Equal legs are also representable by a 45-degree + # angle-distance chamfer. This is an API compatibility retry, + # still with the exact explicit seed set and no propagation. + ('angle_distance_45', insert_angle_distance_chamfer), + # Some SolidWorks releases reject InsertFeatureChamfer for + # large disconnected seed sets. The rho-zero simple-fillet + # definition is the documented offset-face chamfer fallback, + # but it is not the first choice because its distance + # convention can differ from an equal-distance edge chamfer + # on non-orthogonal faces. + ('rho_zero_feature_data', create_chamfer_from_definition), + ): + try: + candidate_feature = call() + if candidate_feature is None: + continue + try: + candidate_feature.Name = ( + f'SimpleCAD_{node_id}_{kind}_{attempt_name}' + ) + except Exception: + pass + try: + self.model.ForceRebuild3(False) + except Exception as exc: + self.logs.append( + f'{kind} rebuild call failed for {node_id} ' + f'candidate {attempt_name}: {exc}' + ) + candidate_error = None + for error_getter in ('GetErrorCode2', 'GetErrorCode'): + try: + candidate_error = int( + _maybe_call( + getattr(candidate_feature, error_getter) + ) + ) + break + except Exception: + pass + candidate_result = self._capture_new_body( + before, + candidate_feature, + expected_bbox=source_bbox, + fallback_body=source, + ) + candidate_volume = self._body_volume(candidate_result) + candidate_bbox = self._box_from_entity(candidate_result) + candidate_matches = ( + not isinstance(strict_canonical, dict) + or self._body_matches_canonical( + candidate_result, + strict_canonical, + volume_relative_tolerance=3.0e-5, + ) + ) + source_volume = source_descriptor.get('volume') + candidate_changed = ( + source_volume is None + or candidate_volume is None + or _relative_error( + candidate_volume, + source_volume, + floor=1.0e-12, + ) > 1.0e-10 + or _bbox_score( + candidate_bbox, + source_descriptor.get('bbox'), + ) > 1.0e-9 + ) + if require_geometry_change and not candidate_changed: + candidate_matches = False + self.logs.append( + f'chamfer {node_id} candidate {attempt_name} ' + f'volume={candidate_volume!r} bbox={candidate_bbox!r} ' + f'feature_error={candidate_error!r} ' + f'canonical_match={candidate_matches!r} ' + f'geometry_changed={candidate_changed!r}' + ) + if candidate_error in (None, 0) and candidate_matches: + feature = candidate_feature + result = candidate_result + feature_error = candidate_error + candidate_rebuilt = True + break + self._delete_retry_feature( + candidate_feature, kind, node_id + ) + persistent_source = ( + self._resolve_persistent_reference_bytes( + source_reference + ) + ) + if ( + persistent_source is not None + and self._body_matches_canonical( + persistent_source, source_descriptor + ) + ): + rebound_sources = [persistent_source] + else: + rebound_sources = [ + body for body in self._solid_bodies() + if self._body_matches_canonical( + body, source_descriptor + ) + ] + named_sources = [ + body for body in rebound_sources + if self._body_name(body) == source_name + ] + if len(named_sources) == 1: + rebound_sources = named_sources + if len(rebound_sources) != 1: + raise RuntimeError( + f'{kind} {node_id} could not uniquely rebind the ' + f'live input body after rejecting {attempt_name}; ' + f'candidates={len(rebound_sources)}' + ) + source = rebound_sources[0] + source_bbox = self._box_from_entity(source) + selected, retry_skipped = self._detail_selected_edges( + source, selectors, kind, node_id + ) + if retry_skipped != skipped_seams: + raise RuntimeError( + f'{kind} {node_id} seam classification changed ' + 'while retrying native candidates' + ) + before = self._body_names() + except Exception as exc: + self.logs.append( + f'chamfer attempt {attempt_name} failed for ' + f'{node_id}: {exc}' + ) + if 'Could not delete rejected' in str(exc): + raise + if feature is None: + components = _selector_edge_components(selectors) + if allow_component_fallback and len(components) > 1: + self.logs.append( + f'{kind} {node_id} retrying {len(selectors)} selected edges ' + f'as {len(components)} disconnected components' + ) + current = source + for component_index, component in enumerate(components): + current = self._apply_detail_feature_to_body( + current, + component, + params, + kind, + f'{node_id}_component_{component_index + 1}', + allow_component_fallback=False, + require_geometry_change=True, + ) + return current + raise RuntimeError( + f'SolidWorks {kind} feature creation failed; ' + f'selections={self._selection_state()!r}; logs={self.logs[-3:]!r}' + ) + try: + feature.Name = f'SimpleCAD_{node_id}_{kind}' + except Exception: + pass + if not candidate_rebuilt: + try: + self.model.ForceRebuild3(False) + except Exception as exc: + self.logs.append( + f'{kind} rebuild call failed for {node_id}: {exc}' + ) + if feature_error is None: + for error_getter in ('GetErrorCode2', 'GetErrorCode'): + try: + feature_error = int( + _maybe_call(getattr(feature, error_getter)) + ) + break + except Exception: + pass + if feature_error not in (None, 0): + raise RuntimeError( + f'SolidWorks {kind} feature {node_id} reports native ' + f'error code {feature_error}' + ) + if result is None: + result = self._capture_new_body( + before, + feature, + expected_bbox=source_bbox, + fallback_body=source, + ) + self.logs.append( + f'{kind} {node_id} native result volume={self._body_volume(result)!r} ' + f'bbox={self._box_from_entity(result)!r} ' + f'feature_error={feature_error!r}' + ) + if topology_source_node_id and topology_target_node_id: + self.pending_detail_topology.append({ + 'feature': feature, + 'feature_name': str( + _maybe_call(feature.Name) or '' + ), + 'result_body': result, + 'source_node_id': str(topology_source_node_id), + 'detail_node_id': str(topology_target_node_id), + }) + self._capture_detail_feature_topology( + feature, + result, + topology_source_node_id, + topology_target_node_id, + ) + return result + + def _feature_shell(self, params, inputs, node_id): + upstream = self._body_from_value(self._first_output(inputs[0])) + # Keep shell attached to the live upstream body for the same reason as + # fillet/chamfer: an identity Move/Copy snapshot breaks downstream + # propagation when an earlier native feature is edited after reopen. + source = upstream + source_bbox = self._box_from_entity(source) + selectors = [] + for selector_id in params.get('selected_face_node_ids') or []: + payload = self.selection_payloads.get(str(selector_id)) + if payload: + selectors.append(payload.get('params') or {}) + if not selectors: + for item in params.get('selected_faces') or []: + if isinstance(item, dict): + selectors.append(item.get('selector_hint') or item) + face_signatures = [ + (face, self._face_signature(face)) + for face in self._body_faces(source) + ] + faces = [ + _best_by_geometry(face_signatures, selector, 'face') + for selector in selectors + ] + self._clear_selection() + for index, face in enumerate(faces): + if not self._select_entity(face, append=index > 0): + raise RuntimeError('Could not select SolidWorks face for shell') + before = self._body_names() + thickness = _as_m(params.get('thickness', 0.0)) + feature = None + for call in ( + lambda: self.model.FeatureManager.InsertShell(thickness, False), + lambda: self.model.FeatureManager.InsertShell2(thickness, False), + ): + try: + feature = call() + if feature is not None: + break + except Exception as exc: + self.logs.append(f'shell attempt failed for {node_id}: {exc}') + if feature is None: + raise RuntimeError('SolidWorks shell feature creation failed') + return self._capture_new_body( + before, feature, expected_bbox=source_bbox + ) + + def _prune_to_bodies(self, final_bodies, retained_bodies=()): + resolved = [] + seen_keys = set() + for body in final_bodies: + fresh = self._resolve_body_reference(body) or body + key = self._body_geometry_key(fresh) + if key in seen_keys: + continue + seen_keys.add(key) + resolved.append(fresh) + + retained = [] + for body in retained_bodies or (): + fresh = self._resolve_body_reference(body) or body + key = self._body_geometry_key(fresh) + if key in seen_keys: + continue + seen_keys.add(key) + retained.append(fresh) + + all_bodies = self._solid_bodies() + result_keys = {self._body_geometry_key(body) for body in resolved} + keep_keys = { + self._body_geometry_key(body) for body in resolved + retained + } + discard = [ + body for body in all_bodies + if self._body_geometry_key(body) not in keep_keys + ] + if discard and resolved: + self._clear_selection() + selected = 0 + for body in resolved + retained: + if self._select_entity(body, append=selected > 0): + selected += 1 + if selected == len(resolved) + len(retained): + feature = None + try: + feature = self.model.FeatureManager.InsertDeleteBody2(True) + except Exception as exc: + self.logs.append(f'keep-body feature failed: {exc}') + self._clear_selection() + if feature is not None: + try: + feature.Name = 'SimpleCAD_KeepResultBodies' + except Exception: + pass + try: + self.model.ForceRebuild3(False) + except Exception: + pass + remaining = self._solid_bodies() + remaining_keys = { + self._body_geometry_key(body) for body in remaining + } + extras = remaining_keys - keep_keys + if remaining and not extras: + remaining_results = [ + body for body in remaining + if self._body_geometry_key(body) in result_keys + ] + if len(remaining_results) == len(result_keys): + return remaining_results + self.logs.append( + 'keep-body feature could not resolve all result ' + 'bodies after retaining assembly sources' + ) + self.logs.append( + f'keep-body feature left {len(extras)} intermediate bodies' + ) + else: + self.logs.append( + f'could not select all final bodies for pruning: ' + f'{selected}/{len(resolved) + len(retained)}' + ) + self._clear_selection() + + for body in all_bodies: + hide = self._body_geometry_key(body) not in keep_keys + try: + body.HideBody(bool(hide)) + except Exception as exc: + if hide: + try: + body.Hide(self.model) + except Exception: + self.logs.append(f'could not hide intermediate body: {exc}') + self._clear_selection() + return resolved + + def _restore_model_scale(self, final_bodies, retained_bodies=()): + if MODEL_SCALE <= 1.0 + 1.0e-12: + return final_bodies + self._clear_selection() + selected = 0 + for body in final_bodies: + if self._select_entity(body, append=selected > 0): + selected += 1 + factor = 1.0 / MODEL_SCALE + feature = None + if selected == len(final_bodies): + try: + feature = self.model.FeatureManager.InsertScale( + 1, True, factor, factor, factor + ) + except Exception as exc: + self.logs.append(f'combined final scale failed: {exc}') + else: + self.logs.append( + f'combined final scale could not select all result bodies: ' + f'{selected}/{len(final_bodies)}; using per-body transform' + ) + self._clear_selection() + if feature is None: + self._mark_degraded( + 'SimpleCAD_RestoreModelScale', 'static_scale' + ) + scale_matrix = _identity_matrix() + scale_matrix[12] = factor + scaled_bodies = [] + for index, body in enumerate(final_bodies): + temp_body = self._copy_temp_body(body) + self._apply_transform_to_temp_body(temp_body, scale_matrix) + scaled_bodies.append( + self._create_feature_from_body( + temp_body, + f'SimpleCAD_RestoreModelScale_{index + 1}', + ) + ) + if not scaled_bodies: + raise RuntimeError( + 'SolidWorks failed to restore the canonical model scale' + ) + return self._prune_to_bodies( + scaled_bodies, retained_bodies=retained_bodies + ) + try: + feature.Name = 'SimpleCAD_RestoreModelScale' + except Exception: + pass + try: + self.model.ForceRebuild3(False) + except Exception: + pass + bodies = self._solid_bodies() + result_names = { + self._body_name(body) for body in final_bodies + } + resolved = [ + body for body in bodies + if self._body_name(body) in result_names + ] + return resolved if len(resolved) == len(result_names) else final_bodies +''' + + +def translate_model_json_to_solidworks_script( + json_str: str, + document_name: str = "SimpleCADModel", + *, + output_path: Optional[str] = None, + visible: bool = False, + source_kernel_fallback: bool = False, +) -> str: + """Translate exported model JSON into a SolidWorks Python automation script. + + The generated script traverses the canonical operation graph and maps graph + operations to SolidWorks COM API calls. Detail-feature selections use + geometric signatures rather than stored topology indices. + """ + + return SolidWorksScriptTranslator( + document_name=document_name, + visible=visible, + source_kernel_fallback=source_kernel_fallback, + ).translate_model_json_to_script(json_str, output_path=output_path) + + +def translate_model_json_to_solidworks_step( + json_str: str, + output_path: str, + *, + document_name: str = "SimpleCADModel", + visible: bool = False, + python_exe: Optional[str] = None, + source_kernel_fallback: bool = False, +) -> str: + """Run SolidWorks COM automation and export a STEP file.""" + + import subprocess + + resolved_output_path = os.path.abspath(output_path) + script = translate_model_json_to_solidworks_script( + json_str, + document_name=document_name, + output_path=resolved_output_path, + visible=visible, + source_kernel_fallback=source_kernel_fallback, + ) + + with tempfile.NamedTemporaryFile( + mode="w", suffix="_simplecad_solidworks_export.py", delete=False, encoding="utf-8" + ) as handle: + temp_script_path = handle.name + handle.write(script) + + env = os.environ.copy() + src_root = os.path.abspath( + os.path.join(os.path.dirname(__file__), "..", "..", "..") + ) + env["PYTHONPATH"] = ( + src_root + if not env.get("PYTHONPATH") + else src_root + os.pathsep + env["PYTHONPATH"] + ) + + try: + try: + completed = subprocess.run( + [python_exe or sys.executable, temp_script_path], + check=True, + text=True, + capture_output=True, + env=env, + ) + except subprocess.CalledProcessError as exc: + raise RuntimeError( + "SolidWorks export script failed. " + f"stdout={exc.stdout!r} stderr={exc.stderr!r}" + ) from exc + if not os.path.exists(resolved_output_path) or os.path.getsize(resolved_output_path) <= 0: + raise RuntimeError( + "SolidWorks export completed without creating a non-empty STEP file. " + f"stdout={completed.stdout.strip()!r} stderr={completed.stderr.strip()!r}" + ) + return output_path + except Exception as e: + raise_harness_error( + operation="translate_model_json_to_solidworks_step", + what_happened="Failed to execute the generated SolidWorks export script.", + possible_causes=[ + "SolidWorks is not installed, not licensed, or its COM server is not registered.", + "pywin32 is unavailable in the Python interpreter used for the export.", + "The model JSON contains an operation not yet mapped to a stable SolidWorks COM call.", + "SolidWorks rejected a native feature, geometric selection, body boolean, or STEP SaveAs call.", + ], + how_to_fix=[ + "Open SolidWorks once interactively and confirm it can create and save parts.", + "Inspect the generated script with translate_model_json_to_solidworks_script().", + "Run the same script manually with the same Python interpreter to inspect COM errors.", + ], + error=e, + ) + finally: + try: + os.unlink(temp_script_path) + except OSError: + pass diff --git a/SimpleCADAPI/src/simplecadapi/translator/solidworks_translator/script_translator.py b/SimpleCADAPI/src/simplecadapi/translator/solidworks_translator/script_translator.py new file mode 100644 index 0000000..7527f29 --- /dev/null +++ b/SimpleCADAPI/src/simplecadapi/translator/solidworks_translator/script_translator.py @@ -0,0 +1,5 @@ +"""Compatibility import for the contributed SolidWorks script compiler.""" + +from .compiler import SolidWorksScriptTranslator + +__all__ = ["SolidWorksScriptTranslator"] diff --git a/SimpleCADAPI/src/simplecadapi/translator/solidworks_translator/translator.py b/SimpleCADAPI/src/simplecadapi/translator/solidworks_translator/translator.py new file mode 100644 index 0000000..a593886 --- /dev/null +++ b/SimpleCADAPI/src/simplecadapi/translator/solidworks_translator/translator.py @@ -0,0 +1,144 @@ +"""SolidWorks backend facade for canonical model translation.""" + +from __future__ import annotations + +from typing import Any, Dict, Optional, Sequence, Set + +from ...errors import ErrorGuidance +from ...topology import OperationGraph +from ..base import BaseTranslator +from ..errors import TranslationRequestError +from ..types import BackendCapabilities, SupportLevel, TranslationArtifact +from .capabilities import CAPABILITIES +from .compiler import SolidWorksScriptTranslator + + +def _dependency_node_ids( + graph: OperationGraph, result_node_ids: Sequence[str] +) -> Set[str]: + needed: Set[str] = set() + pending = [str(node_id) for node_id in result_node_ids] + while pending: + node_id = pending.pop() + if node_id in needed: + continue + node = graph.get_node(node_id) + if node is None: + raise ValueError(f"Result node {node_id!r} is not present in the graph") + needed.add(node_id) + pending.extend(input_ref.node_id for input_ref in node.inputs) + for key in ("selected_edge_node_ids", "selected_face_node_ids"): + pending.extend(str(value) for value in node.params.get(key, []) or []) + return needed + + +class SolidWorksTranslator(BaseTranslator): + """Translate canonical model JSON into a SolidWorks COM Python script.""" + + def __init__( + self, + document_name: str = "SimpleCADModel", + *, + output_path: Optional[str] = None, + visible: bool = False, + source_kernel_fallback: bool = False, + ) -> None: + self.document_name = str(document_name) + self.output_path = str(output_path) if output_path is not None else None + self.visible = bool(visible) + self.source_kernel_fallback = bool(source_kernel_fallback) + + @property + def capabilities(self) -> BackendCapabilities: + return CAPABILITIES + + def _result_ids( + self, payload: Dict[str, Any], graph: OperationGraph + ) -> Sequence[str]: + leaf_ids = payload.get("leaf_ids") + if isinstance(leaf_ids, list) and leaf_ids: + return [str(node_id) for node_id in leaf_ids] + return [node.node_id for node in graph.leaf_nodes()] + + def _preflight(self, payload: Dict[str, Any], graph: OperationGraph) -> None: + needed = _dependency_node_ids(graph, self._result_ids(payload, graph)) + unsupported = sorted( + { + node.op + for node_id in needed + for node in [graph.get_node(node_id)] + if node is not None + and ( + node.op not in CAPABILITIES.operations + or CAPABILITIES.operations[node.op].level + is SupportLevel.UNSUPPORTED + ) + } + ) + if unsupported: + joined = ", ".join(unsupported) + raise TranslationRequestError( + "solidworks", + "translate_model_payload", + ErrorGuidance( + what_happened=f"The result graph uses unsupported SolidWorks operations: {joined}.", + possible_causes=( + "The model uses canonical operations not implemented by the contributed runtime.", + ), + how_to_fix=( + "Lower the model to operations declared by solidworks_translator.CAPABILITIES.", + "Use another translator backend for this model.", + ), + ), + ) + + def translate_model_payload_to_script( + self, + payload: Dict[str, Any], + *, + graph: Optional[OperationGraph] = None, + ) -> str: + source_graph = graph or payload.get("graph") + if not isinstance(source_graph, OperationGraph) or source_graph.node_count == 0: + raise ValueError( + "SolidWorks translation requires a non-empty canonical graph" + ) + self._preflight(payload, source_graph) + return SolidWorksScriptTranslator( + document_name=self.document_name, + visible=self.visible, + source_kernel_fallback=self.source_kernel_fallback, + ).translate_model_payload_to_script( + payload, + graph=source_graph, + output_path=self.output_path, + ) + + def translate_model_json_to_script(self, json_str: str) -> str: + artifact = self.translate_model_json(json_str) + assert isinstance(artifact.content, str) + return artifact.content + + def translate_model_payload( + self, + payload: Dict[str, Any], + *, + graph: Optional[OperationGraph] = None, + ) -> TranslationArtifact: + return TranslationArtifact( + backend_id="solidworks", + target_id="solidworks_script", + media_type="text/x-python", + suggested_suffix=".py", + content=self.translate_model_payload_to_script(payload, graph=graph), + metadata={ + "document_name": self.document_name, + "output_path": self.output_path, + "visible": self.visible, + "source_kernel_fallback": self.source_kernel_fallback, + "target_runtime_validated": False, + }, + ) + + +__all__ = ["SolidWorksTranslator"] diff --git a/SimpleCADAPI/src/simplecadapi/translator/types.py b/SimpleCADAPI/src/simplecadapi/translator/types.py new file mode 100644 index 0000000..35fe891 --- /dev/null +++ b/SimpleCADAPI/src/simplecadapi/translator/types.py @@ -0,0 +1,77 @@ +"""Shared value types for translator backends.""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum +from typing import Any, Mapping, Optional, Tuple, Union + + +class SupportLevel(str, Enum): + """How faithfully a backend supports a canonical operation.""" + + NATIVE = "native" + EMULATED = "emulated" + METADATA_ONLY = "metadata_only" + UNSUPPORTED = "unsupported" + + +class TranslationOutputKind(str, Enum): + """Storage form produced by a translation target.""" + + TEXT = "text" + BINARY = "binary" + FILE = "file" + + +@dataclass(frozen=True) +class OperationCapability: + """Declared support level for one canonical graph operation.""" + + level: SupportLevel + reason: Optional[str] = None + + +@dataclass(frozen=True) +class TranslationTarget: + """One output target exposed by a translator backend.""" + + target_id: str + output_kind: TranslationOutputKind + media_type: str + extensions: Tuple[str, ...] = () + requires_external_runtime: bool = False + option_names: Tuple[str, ...] = () + + +@dataclass(frozen=True) +class BackendCapabilities: + """Static, machine-readable capabilities of a translator backend.""" + + backend_id: str + display_name: str + input_schema_versions: Tuple[str, ...] + targets: Tuple[TranslationTarget, ...] + operations: Mapping[str, OperationCapability] + + +@dataclass(frozen=True) +class TranslationArtifact: + """In-memory artifact returned by a translator implementation.""" + + backend_id: str + target_id: str + media_type: str + suggested_suffix: str + content: Union[str, bytes] + metadata: Mapping[str, Any] + + +__all__ = [ + "BackendCapabilities", + "OperationCapability", + "SupportLevel", + "TranslationArtifact", + "TranslationOutputKind", + "TranslationTarget", +] diff --git a/SimpleCADAPI/src/simplecadapi/units.py b/SimpleCADAPI/src/simplecadapi/units.py new file mode 100644 index 0000000..aa16af7 --- /dev/null +++ b/SimpleCADAPI/src/simplecadapi/units.py @@ -0,0 +1,462 @@ +"""Physical dimensions, engineering units, and expression inference.""" + +from __future__ import annotations + +from dataclasses import dataclass +import math +from numbers import Real +from typing import Any, Dict, Mapping, Union + + +class UnitValidationError(ValueError): + """Raised when units or expression dimensions are physically inconsistent.""" + + +@dataclass(frozen=True) +class Dimension: + """Physical dimension represented by integer length and angle exponents.""" + + length: int = 0 + angle: int = 0 + + def __post_init__(self) -> None: + if isinstance(self.length, bool) or not isinstance(self.length, int): + raise TypeError("Dimension length exponent must be an integer") + if isinstance(self.angle, bool) or not isinstance(self.angle, int): + raise TypeError("Dimension angle exponent must be an integer") + + @property + def name(self) -> str: + names = { + (0, 0): "Dimensionless", + (1, 0): "Length", + (2, 0): "Area", + (3, 0): "Volume", + (0, 1): "Angle", + } + return names.get((self.length, self.angle), self.symbol) + + @property + def symbol(self) -> str: + terms = [] + for symbol, exponent in (("L", self.length), ("A", self.angle)): + if exponent == 0: + continue + terms.append(symbol if exponent == 1 else f"{symbol}^{exponent}") + return "1" if not terms else " ".join(terms) + + @property + def is_dimensionless(self) -> bool: + return self == DIMENSIONLESS + + @property + def is_design_dimension(self) -> bool: + return self in {LENGTH, ANGLE} + + def multiply(self, other: "Dimension") -> "Dimension": + return Dimension(self.length + other.length, self.angle + other.angle) + + def divide(self, other: "Dimension") -> "Dimension": + return Dimension(self.length - other.length, self.angle - other.angle) + + def power(self, exponent: int) -> "Dimension": + if isinstance(exponent, bool) or not isinstance(exponent, int): + raise TypeError("Dimension exponent must be an integer") + return Dimension(self.length * exponent, self.angle * exponent) + + def square_root(self) -> "Dimension": + if self.length % 2 or self.angle % 2: + raise UnitValidationError( + f"Cannot take sqrt of {self.name}; every dimension exponent must be even" + ) + return Dimension(self.length // 2, self.angle // 2) + + def to_dict(self) -> Dict[str, int]: + return {"length": self.length, "angle": self.angle} + + @classmethod + def from_dict(cls, data: Mapping[str, Any]) -> "Dimension": + if not isinstance(data, Mapping): + raise TypeError("Dimension payload must be an object") + if "length" not in data or "angle" not in data: + raise ValueError("Dimension payload requires 'length' and 'angle'") + return cls(length=data["length"], angle=data["angle"]) + + +DIMENSIONLESS = Dimension() +LENGTH = Dimension(length=1) +AREA = Dimension(length=2) +VOLUME = Dimension(length=3) +ANGLE = Dimension(angle=1) + + +@dataclass(frozen=True) +class Unit: + """Named unit with a scale to SimpleCAD's canonical numeric units. + + Custom units are supported and serialize their symbol, dimension, and scale. + Registered built-in units serialize as compact symbols. + """ + + symbol: str + dimension: Dimension + scale_to_canonical: float + + def __post_init__(self) -> None: + if not isinstance(self.symbol, str) or not self.symbol: + raise ValueError("Unit symbol must be a non-empty string") + if not isinstance(self.dimension, Dimension): + raise TypeError("Unit dimension must be a Dimension") + if ( + isinstance(self.scale_to_canonical, bool) + or not isinstance(self.scale_to_canonical, Real) + ): + raise TypeError("Unit scale must be a real number") + scale = float(self.scale_to_canonical) + if not math.isfinite(scale) or scale <= 0.0: + raise ValueError("Unit scale must be finite and greater than zero") + object.__setattr__(self, "scale_to_canonical", scale) + + def to_canonical(self, value: int | float) -> float: + number = _finite_number(value, label=f"Value in {self.symbol}") + result = number * self.scale_to_canonical + if not math.isfinite(result) or (number != 0.0 and result == 0.0): + raise ValueError(f"Value in {self.symbol} is outside the supported range") + return result + + def from_canonical(self, value: int | float) -> float: + number = _finite_number(value, label="Canonical value") + result = number / self.scale_to_canonical + if not math.isfinite(result) or (number != 0.0 and result == 0.0): + raise ValueError( + f"Canonical value is outside the supported range for {self.symbol}" + ) + return result + + def to_dict(self) -> Dict[str, Any]: + return { + "symbol": self.symbol, + "dimension": self.dimension.to_dict(), + "scale_to_canonical": self.scale_to_canonical, + } + + @classmethod + def from_dict(cls, data: Mapping[str, Any]) -> "Unit": + """Rebuild a custom unit from its serialized definition.""" + + if not isinstance(data, Mapping): + raise TypeError("Unit payload must be an object") + required = {"symbol", "dimension", "scale_to_canonical"} + missing = sorted(required.difference(data)) + if missing: + raise ValueError( + "Unit payload is missing required fields: " + ", ".join(missing) + ) + return cls( + symbol=data["symbol"], + dimension=Dimension.from_dict(data["dimension"]), + scale_to_canonical=data["scale_to_canonical"], + ) + + +UnitLike = Union[str, Unit] + + +def _finite_number(value: Any, *, label: str) -> float: + if isinstance(value, bool) or not isinstance(value, Real): + raise TypeError(f"{label} must be a real number") + try: + result = float(value) + except OverflowError as exc: + raise ValueError(f"{label} must be finite") from exc + if not math.isfinite(result): + raise ValueError(f"{label} must be finite") + return result + + +def _unit(symbol: str, dimension: Dimension, scale: float) -> Unit: + return Unit(symbol=symbol, dimension=dimension, scale_to_canonical=scale) + + +MM = _unit("mm", LENGTH, 1.0) +CM = _unit("cm", LENGTH, 10.0) +M = _unit("m", LENGTH, 1000.0) +INCH = _unit("in", LENGTH, 25.4) +FOOT = _unit("ft", LENGTH, 304.8) +DEGREE = _unit("deg", ANGLE, 1.0) +RADIAN = _unit("rad", ANGLE, 180.0 / math.pi) +ONE = _unit("1", DIMENSIONLESS, 1.0) +PERCENT = _unit("%", DIMENSIONLESS, 0.01) +SQUARE_MM = _unit("mm^2", AREA, 1.0) +SQUARE_CM = _unit("cm^2", AREA, 100.0) +SQUARE_M = _unit("m^2", AREA, 1_000_000.0) +SQUARE_INCH = _unit("in^2", AREA, 25.4**2) +SQUARE_FOOT = _unit("ft^2", AREA, 304.8**2) +CUBIC_MM = _unit("mm^3", VOLUME, 1.0) +CUBIC_CM = _unit("cm^3", VOLUME, 1000.0) +CUBIC_M = _unit("m^3", VOLUME, 1_000_000_000.0) +CUBIC_INCH = _unit("in^3", VOLUME, 25.4**3) +CUBIC_FOOT = _unit("ft^3", VOLUME, 304.8**3) + + +_UNIT_ALIASES: Dict[str, Unit] = {} + + +def _register(unit: Unit, *aliases: str) -> None: + for token in (unit.symbol, *aliases): + _UNIT_ALIASES[token.strip().lower()] = unit + + +_register(MM, "millimeter", "millimeters") +_register(CM, "centimeter", "centimeters") +_register(M, "meter", "meters") +_register(INCH, "inch", "inches") +_register(FOOT, "foot", "feet") +_register(RADIAN, "radian", "radians") +_register(DEGREE, "degree", "degrees", "°") +_register(ONE, "dimensionless") +_register(PERCENT, "percent") +_register(SQUARE_MM, "mm2") +_register(SQUARE_CM, "cm2") +_register(SQUARE_M, "m2") +_register(SQUARE_INCH, "in2", "square inch", "square inches") +_register(SQUARE_FOOT, "ft2", "square foot", "square feet") +_register(CUBIC_MM, "mm3") +_register(CUBIC_CM, "cm3", "ml") +_register(CUBIC_M, "m3") +_register(CUBIC_INCH, "in3", "cubic inch", "cubic inches") +_register(CUBIC_FOOT, "ft3", "cubic foot", "cubic feet") + + +def get_unit(value: UnitLike) -> Unit: + """Resolve a built-in unit name/alias or return an existing ``Unit``.""" + + if isinstance(value, Unit): + return value + if not isinstance(value, str) or not value.strip(): + raise TypeError("Unit must be a non-empty string or Unit") + unit = _UNIT_ALIASES.get(value.strip().lower()) + if unit is None: + supported = ", ".join(sorted({item.symbol for item in _UNIT_ALIASES.values()})) + raise ValueError(f"Unknown unit '{value}'; supported units: {supported}") + return unit + + +def unit_to_payload(unit: Unit) -> str | Dict[str, Any]: + """Serialize a unit compactly without losing custom unit definitions.""" + + if not isinstance(unit, Unit): + raise TypeError("Unit payload serialization requires a Unit") + registered = _UNIT_ALIASES.get(unit.symbol.strip().lower()) + return unit.symbol if registered == unit else unit.to_dict() + + +def unit_from_payload(value: Any) -> Unit: + """Deserialize a registered unit symbol or a custom unit definition.""" + + if isinstance(value, Mapping): + return Unit.from_dict(value) + return get_unit(value) + + +def canonical_unit_for_dimension(dimension: Dimension) -> Unit: + """Return the canonical unit used by CAD and tolerance calculations. + + Length, area, volume, and angle use ``mm``, ``mm^2``, ``mm^3``, and ``deg``. + """ + + known = { + DIMENSIONLESS: ONE, + LENGTH: MM, + AREA: SQUARE_MM, + VOLUME: CUBIC_MM, + ANGLE: DEGREE, + } + unit = known.get(dimension) + if unit is not None: + return unit + return Unit(dimension.symbol, dimension, 1.0) + + +def convert_value(value: int | float, from_unit: UnitLike, to_unit: UnitLike) -> float: + """Convert a finite numeric value between dimensionally compatible units.""" + + source = get_unit(from_unit) + target = get_unit(to_unit) + if source.dimension != target.dimension: + raise UnitValidationError( + f"Cannot convert {source.dimension.name} unit '{source.symbol}' " + f"to {target.dimension.name} unit '{target.symbol}'" + ) + return target.from_canonical(source.to_canonical(value)) + + +@dataclass(frozen=True) +class _Inference: + dimension: Dimension | None + typed: bool + legacy: bool + constant: bool + + +def _mixed_legacy_error(op: str) -> UnitValidationError: + return UnitValidationError( + f"Expression operation '{op}' mixes unit-declared variables with legacy " + "variables that have no unit" + ) + + +def _require_no_mixed_legacy(op: str, *items: _Inference) -> None: + if any(item.typed for item in items) and any(item.legacy for item in items): + raise _mixed_legacy_error(op) + + +def _same_dimension( + op: str, left: _Inference, right: _Inference +) -> Dimension | None: + _require_no_mixed_legacy(op, left, right) + if left.legacy or right.legacy: + return None + if left.constant and not left.typed: + return right.dimension + if right.constant and not right.typed: + return left.dimension + if left.dimension != right.dimension: + left_name = left.dimension.name if left.dimension else "Unknown" + right_name = right.dimension.name if right.dimension else "Unknown" + raise UnitValidationError( + f"Expression operation '{op}' requires matching dimensions; " + f"got {left_name} and {right_name}" + ) + return left.dimension + + +def _infer( + node: "ScalarExpr", + cache: Dict[int, _Inference], + visiting: set[int], +) -> _Inference: + from .expr import Const, Expr, Var + + node_id = id(node) + cached = cache.get(node_id) + if cached is not None: + return cached + if node_id in visiting: + raise UnitValidationError( + f"Expression graph contains a cycle at '{node.expr_id}'" + ) + visiting.add(node_id) + try: + if isinstance(node, Const): + result = _Inference(DIMENSIONLESS, False, False, True) + elif isinstance(node, Var): + result = ( + _Inference(node.unit.dimension, True, False, False) + if node.unit is not None + else _Inference(None, False, True, False) + ) + elif isinstance(node, Expr): + args = [_infer(arg, cache, visiting) for arg in node.args] + typed = any(item.typed for item in args) + legacy = any(item.legacy for item in args) + constant = all(item.constant for item in args) + if node.op in {"add", "sub"}: + dimension = _same_dimension(node.op, args[0], args[1]) + elif node.op == "mul": + _require_no_mixed_legacy(node.op, *args) + dimension = ( + None + if legacy + else args[0].dimension.multiply(args[1].dimension) # type: ignore[union-attr] + ) + elif node.op == "div": + _require_no_mixed_legacy(node.op, *args) + dimension = ( + None + if legacy + else args[0].dimension.divide(args[1].dimension) # type: ignore[union-attr] + ) + elif node.op == "pow": + _require_no_mixed_legacy(node.op, *args) + if not typed: + dimension = None if legacy else DIMENSIONLESS + else: + if args[1].legacy or args[1].dimension != DIMENSIONLESS: + raise UnitValidationError( + "A physical quantity exponent must be dimensionless" + ) + if args[0].dimension == DIMENSIONLESS: + dimension = DIMENSIONLESS + else: + if not args[1].constant: + raise UnitValidationError( + "A dimensioned expression requires a constant numeric exponent" + ) + exponent = float(node.args[1].evaluate()) + if exponent.is_integer(): + dimension = args[0].dimension.power(int(exponent)) # type: ignore[union-attr] + elif exponent == 0.5: + dimension = args[0].dimension.square_root() # type: ignore[union-attr] + else: + raise UnitValidationError( + "Dimensioned powers currently support integer exponents or 0.5" + ) + elif node.op in {"neg", "abs"}: + dimension = args[0].dimension + elif node.op == "sqrt": + dimension = ( + None + if legacy + else args[0].dimension.square_root() # type: ignore[union-attr] + ) + elif node.op in {"sin", "cos", "tan"}: + if typed and args[0].dimension != ANGLE: + raise UnitValidationError( + f"Expression operation '{node.op}' requires an Angle input" + ) + dimension = None if legacy else DIMENSIONLESS + elif node.op in {"asin", "acos", "atan"}: + if typed and args[0].dimension != DIMENSIONLESS: + raise UnitValidationError( + f"Expression operation '{node.op}' requires a Dimensionless input" + ) + dimension = ANGLE if typed else None + elif node.op == "atan2": + dimension = _same_dimension(node.op, args[0], args[1]) + if dimension is not None and typed: + dimension = ANGLE + elif not typed: + dimension = None + else: + raise UnitValidationError( + f"Unsupported expression operation '{node.op}' for unit inference" + ) + result = _Inference(dimension, typed, legacy, constant) + else: + raise TypeError(f"Unsupported expression node type: {type(node)!r}") + finally: + visiting.remove(node_id) + cache[node_id] = result + return result + + +def infer_dimension(value: "ScalarLike") -> Dimension | None: + """Infer and validate an expression's result dimension. + + ``None`` means the expression uses only legacy variables without unit + declarations. Expressions that contain explicit units are validated + strictly and cannot mix in legacy variables. + """ + + from .expr import lift_scalar + + return _infer(lift_scalar(value), {}, set()).dimension + + +def expression_uses_units(value: "ScalarLike") -> bool: + """Return whether an expression contains an explicit unit declaration.""" + + from .expr import lift_scalar + + return _infer(lift_scalar(value), {}, set()).typed diff --git a/SimpleCADAPI/test/fixtures/scene-contract/corpus.json b/SimpleCADAPI/test/fixtures/scene-contract/corpus.json new file mode 100644 index 0000000..8987784 --- /dev/null +++ b/SimpleCADAPI/test/fixtures/scene-contract/corpus.json @@ -0,0 +1,19872 @@ +{ + "artifacts": { + "canonical_zip": { + "base64": "UEsDBBQAAAgAAAAAIQAdRZYV5AIAAOQCAABRAAAAZWRnZXMvc2hhMjU2LTZjZTQ4MWRiMWZlMTNiY2ZlN2EyMzc0NDIzZTZjNzJkNzU3ZDUxYzk5MTY4YjI4N2M1MmY1M2FhNjI1Y2NkNjcuZ2xiZ2xURgIAAADkAgAAIAIAAEpTT057ImFjY2Vzc29ycyI6W3siYnVmZmVyVmlldyI6MCwiY29tcG9uZW50VHlwZSI6NTEyNiwiY291bnQiOjEyLCJtYXgiOlsxLDEsMV0sIm1pbiI6WzAsMCwwXSwidHlwZSI6IlZFQzMifSx7ImJ1ZmZlclZpZXciOjEsImNvbXBvbmVudFR5cGUiOjUxMjMsImNvdW50IjoxMiwidHlwZSI6IlNDQUxBUiJ9XSwiYXNzZXQiOnsiZ2VuZXJhdG9yIjoiU2ltcGxlQ0FEIFNjZW5lIEdMQiBQcm9maWxlIDEiLCJ2ZXJzaW9uIjoiMi4wIn0sImJ1ZmZlclZpZXdzIjpbeyJidWZmZXIiOjAsImJ5dGVMZW5ndGgiOjE0NCwiYnl0ZU9mZnNldCI6MCwidGFyZ2V0IjozNDk2Mn0seyJidWZmZXIiOjAsImJ5dGVMZW5ndGgiOjI0LCJieXRlT2Zmc2V0IjoxNDQsInRhcmdldCI6MzQ5NjN9XSwiYnVmZmVycyI6W3siYnl0ZUxlbmd0aCI6MTY4fV0sIm1lc2hlcyI6W3sicHJpbWl0aXZlcyI6W3siYXR0cmlidXRlcyI6eyJQT1NJVElPTiI6MH0sImluZGljZXMiOjEsIm1vZGUiOjF9XX1dLCJub2RlcyI6W3sibWVzaCI6MH1dLCJzY2VuZSI6MCwic2NlbmVzIjpbeyJub2RlcyI6WzBdfV19qAAAAEJJTgAAAAAAAAAAAAAAgD8AAAAAAACAPwAAAAAAAAAAAAAAAAAAgD8AAIA/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgD8AAAAAAAAAAAAAAAAAAIA/AAAAAAAAAAAAAAAAAACAPwAAAAAAAIA/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAPwAAAAAAAAEAAgADAAQABQAGAAcACAAJAAoACwBQSwMEFAAACAAAAAAhAGAzoCP9LAAA/SwAAFUAAABlbnRpdGllcy9zaGEyNTYtMzg5N2NjOWViZGIyOTQxODM1OTY0YTQzNTgyMWI1NTZlMDFiMTc1NTM1Y2ZmOGZjMGVhYWFmYjY4NGM0ZGRmNS5qc29ueyJkZWZpbml0aW9uX2lkIjoiZGVmaW5pdGlvbi9yb290L3NoYXBlL21hbnVhbC9maXh0dXJlIiwiZWRnZV9hc3NldF9pZCI6InNoYTI1Njo2Y2U0ODFkYjFmZTEzYmNmZTdhMjM3NDQyM2U2YzcyZDc1N2Q1MWM5OTE2OGIyODdjNTJmNTNhYTYyNWNjZDY3IiwiZWRnZV9ncm91cHMiOlt7ImVudGl0eV9pZCI6ImVudGl0eS9lZGdlLzAiLCJmaXJzdF9pbmRleCI6MCwiZ3JvdXBfaWQiOjAsImluZGV4X2NvdW50IjoyLCJtZXNoX2luZGV4IjowLCJwcmltaXRpdmVfaW5kZXgiOjB9LHsiZW50aXR5X2lkIjoiZW50aXR5L2VkZ2UvMSIsImZpcnN0X2luZGV4IjoyLCJncm91cF9pZCI6MSwiaW5kZXhfY291bnQiOjIsIm1lc2hfaW5kZXgiOjAsInByaW1pdGl2ZV9pbmRleCI6MH0seyJlbnRpdHlfaWQiOiJlbnRpdHkvZWRnZS8yIiwiZmlyc3RfaW5kZXgiOjQsImdyb3VwX2lkIjoyLCJpbmRleF9jb3VudCI6MiwibWVzaF9pbmRleCI6MCwicHJpbWl0aXZlX2luZGV4IjowfSx7ImVudGl0eV9pZCI6ImVudGl0eS9lZGdlLzMiLCJmaXJzdF9pbmRleCI6NiwiZ3JvdXBfaWQiOjMsImluZGV4X2NvdW50IjoyLCJtZXNoX2luZGV4IjowLCJwcmltaXRpdmVfaW5kZXgiOjB9LHsiZW50aXR5X2lkIjoiZW50aXR5L2VkZ2UvNCIsImZpcnN0X2luZGV4Ijo4LCJncm91cF9pZCI6NCwiaW5kZXhfY291bnQiOjIsIm1lc2hfaW5kZXgiOjAsInByaW1pdGl2ZV9pbmRleCI6MH0seyJlbnRpdHlfaWQiOiJlbnRpdHkvZWRnZS81IiwiZmlyc3RfaW5kZXgiOjEwLCJncm91cF9pZCI6NSwiaW5kZXhfY291bnQiOjIsIm1lc2hfaW5kZXgiOjAsInByaW1pdGl2ZV9pbmRleCI6MH1dLCJlbnRpdGllcyI6W3siY2hpbGRfZW50aXR5X2lkcyI6WyJlbnRpdHkvdmVydGV4LzIiLCJlbnRpdHkvdmVydGV4LzMiXSwiY29ubmVjdG9yX2JpbmRpbmdfc3RhdHVzIjoib3duZXJfbm90X3BhcnQiLCJlbnRpdHlfaWQiOiJlbnRpdHkvZWRnZS8wIiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJkaXJlY3Rpb24iOlswLC0wLjcwNzEwNjc4MTE4NjU0NzUsLTAuNzA3MTA2NzgxMTg2NTQ3NV0sIm9yaWdpbiI6WzAsMCwxMDAwXSwidHlwZSI6ImxpbmUifSwia2luZCI6ImVkZ2UiLCJwYXJlbnRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZmFjZS8wIiwiZW50aXR5L2ZhY2UvMyJdLCJwcm9wZXJ0aWVzIjp7ImJvdW5kcyI6eyJtYXgiOlswLDAsMTAwMF0sIm1pbiI6WzAsLTEwMDAsMF19LCJjZW50cm9pZCI6WzAsLTUwMCw1MDBdLCJsZW5ndGgiOjE0MTQuMjEzNTYyMzczMDk1LCJxdWFsaXR5Ijoia2VybmVsX2V2YWx1YXRlZCJ9LCJyZW5kZXJfc3RhdHVzIjoicmVuZGVyZWQiLCJzZGtfY29ubmVjdG9yX2ZyYW1lIjp7Im9yaWdpbiI6WzAsLTUwMCw1MDBdLCJ4X2F4aXMiOlswLC0wLjcwNzEwNjc4MTE4NjU0NzUsLTAuNzA3MTA2NzgxMTg2NTQ3NV0sInlfYXhpcyI6WzAsLTAuNzA3MTA2NzgxMTg2NTQ3NCwwLjcwNzEwNjc4MTE4NjU0NzddLCJ6X2F4aXMiOlstMSwwLDBdfSwic2RrX21ldGFkYXRhIjp7fSwic2VtYW50aWNfYmluZGluZ19pZHMiOltdLCJzb3VyY2UiOnsia2luZCI6InVuYm91bmQifX0seyJjaGlsZF9lbnRpdHlfaWRzIjpbImVudGl0eS92ZXJ0ZXgvMSIsImVudGl0eS92ZXJ0ZXgvMyJdLCJjb25uZWN0b3JfYmluZGluZ19zdGF0dXMiOiJvd25lcl9ub3RfcGFydCIsImVudGl0eV9pZCI6ImVudGl0eS9lZGdlLzEiLCJldmFsdWF0ZWRfdGFncyI6W10sImdlb21ldHJ5Ijp7ImRpcmVjdGlvbiI6Wy0wLjcwNzEwNjc4MTE4NjU0NzUsLTAuNzA3MTA2NzgxMTg2NTQ3NSwwXSwib3JpZ2luIjpbMTAwMCwwLDBdLCJ0eXBlIjoibGluZSJ9LCJraW5kIjoiZWRnZSIsInBhcmVudF9lbnRpdHlfaWRzIjpbImVudGl0eS9mYWNlLzEiLCJlbnRpdHkvZmFjZS8zIl0sInByb3BlcnRpZXMiOnsiYm91bmRzIjp7Im1heCI6WzEwMDAsMCwwXSwibWluIjpbMCwtMTAwMCwwXX0sImNlbnRyb2lkIjpbNTAwLC01MDAsMF0sImxlbmd0aCI6MTQxNC4yMTM1NjIzNzMwOTUsInF1YWxpdHkiOiJrZXJuZWxfZXZhbHVhdGVkIn0sInJlbmRlcl9zdGF0dXMiOiJyZW5kZXJlZCIsInNka19jb25uZWN0b3JfZnJhbWUiOnsib3JpZ2luIjpbNTAwLC01MDAsMF0sInhfYXhpcyI6Wy0wLjcwNzEwNjc4MTE4NjU0NzUsLTAuNzA3MTA2NzgxMTg2NTQ3NSwwXSwieV9heGlzIjpbMCwwLDFdLCJ6X2F4aXMiOlstMC43MDcxMDY3ODExODY1NDc2LDAuNzA3MTA2NzgxMTg2NTQ3NiwwXX0sInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImtpbmQiOiJ1bmJvdW5kIn19LHsiY2hpbGRfZW50aXR5X2lkcyI6WyJlbnRpdHkvdmVydGV4LzAiLCJlbnRpdHkvdmVydGV4LzMiXSwiY29ubmVjdG9yX2JpbmRpbmdfc3RhdHVzIjoib3duZXJfbm90X3BhcnQiLCJlbnRpdHlfaWQiOiJlbnRpdHkvZWRnZS8yIiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJkaXJlY3Rpb24iOlswLC0xLDBdLCJvcmlnaW4iOlswLDAsMF0sInR5cGUiOiJsaW5lIn0sImtpbmQiOiJlZGdlIiwicGFyZW50X2VudGl0eV9pZHMiOlsiZW50aXR5L2ZhY2UvMCIsImVudGl0eS9mYWNlLzEiXSwicHJvcGVydGllcyI6eyJib3VuZHMiOnsibWF4IjpbMCwwLDBdLCJtaW4iOlswLC0xMDAwLDBdfSwiY2VudHJvaWQiOlswLC01MDAsMF0sImxlbmd0aCI6MTAwMCwicXVhbGl0eSI6Imtlcm5lbF9ldmFsdWF0ZWQifSwicmVuZGVyX3N0YXR1cyI6InJlbmRlcmVkIiwic2RrX2Nvbm5lY3Rvcl9mcmFtZSI6eyJvcmlnaW4iOlswLC01MDAsMF0sInhfYXhpcyI6WzAsLTEsMF0sInlfYXhpcyI6WzAsMCwxXSwiel9heGlzIjpbLTEsMCwwXX0sInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImtpbmQiOiJ1bmJvdW5kIn19LHsiY2hpbGRfZW50aXR5X2lkcyI6WyJlbnRpdHkvdmVydGV4LzAiLCJlbnRpdHkvdmVydGV4LzEiXSwiY29ubmVjdG9yX2JpbmRpbmdfc3RhdHVzIjoib3duZXJfbm90X3BhcnQiLCJlbnRpdHlfaWQiOiJlbnRpdHkvZWRnZS8zIiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJkaXJlY3Rpb24iOlsxLDAsMF0sIm9yaWdpbiI6WzAsMCwwXSwidHlwZSI6ImxpbmUifSwia2luZCI6ImVkZ2UiLCJwYXJlbnRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZmFjZS8xIiwiZW50aXR5L2ZhY2UvMiJdLCJwcm9wZXJ0aWVzIjp7ImJvdW5kcyI6eyJtYXgiOlsxMDAwLDAsMF0sIm1pbiI6WzAsMCwwXX0sImNlbnRyb2lkIjpbNTAwLDAsMF0sImxlbmd0aCI6MTAwMCwicXVhbGl0eSI6Imtlcm5lbF9ldmFsdWF0ZWQifSwicmVuZGVyX3N0YXR1cyI6InJlbmRlcmVkIiwic2RrX2Nvbm5lY3Rvcl9mcmFtZSI6eyJvcmlnaW4iOls1MDAsMCwwXSwieF9heGlzIjpbMSwwLDBdLCJ5X2F4aXMiOlswLDAsMV0sInpfYXhpcyI6WzAsLTEsMF19LCJzZGtfbWV0YWRhdGEiOnt9LCJzZW1hbnRpY19iaW5kaW5nX2lkcyI6W10sInNvdXJjZSI6eyJraW5kIjoidW5ib3VuZCJ9fSx7ImNoaWxkX2VudGl0eV9pZHMiOlsiZW50aXR5L3ZlcnRleC8xIiwiZW50aXR5L3ZlcnRleC8yIl0sImNvbm5lY3Rvcl9iaW5kaW5nX3N0YXR1cyI6Im93bmVyX25vdF9wYXJ0IiwiZW50aXR5X2lkIjoiZW50aXR5L2VkZ2UvNCIsImV2YWx1YXRlZF90YWdzIjpbXSwiZ2VvbWV0cnkiOnsiZGlyZWN0aW9uIjpbLTAuNzA3MTA2NzgxMTg2NTQ3NSwwLDAuNzA3MTA2NzgxMTg2NTQ3NV0sIm9yaWdpbiI6WzEwMDAsMCwwXSwidHlwZSI6ImxpbmUifSwia2luZCI6ImVkZ2UiLCJwYXJlbnRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZmFjZS8yIiwiZW50aXR5L2ZhY2UvMyJdLCJwcm9wZXJ0aWVzIjp7ImJvdW5kcyI6eyJtYXgiOlsxMDAwLDAsMTAwMF0sIm1pbiI6WzAsMCwwXX0sImNlbnRyb2lkIjpbNTAwLDAsNTAwXSwibGVuZ3RoIjoxNDE0LjIxMzU2MjM3MzA5NSwicXVhbGl0eSI6Imtlcm5lbF9ldmFsdWF0ZWQifSwicmVuZGVyX3N0YXR1cyI6InJlbmRlcmVkIiwic2RrX2Nvbm5lY3Rvcl9mcmFtZSI6eyJvcmlnaW4iOls1MDAsMCw1MDBdLCJ4X2F4aXMiOlstMC43MDcxMDY3ODExODY1NDc1LDAsMC43MDcxMDY3ODExODY1NDc1XSwieV9heGlzIjpbMC43MDcxMDY3ODExODY1NDc0LDAsMC43MDcxMDY3ODExODY1NDc3XSwiel9heGlzIjpbMCwxLDBdfSwic2RrX21ldGFkYXRhIjp7fSwic2VtYW50aWNfYmluZGluZ19pZHMiOltdLCJzb3VyY2UiOnsia2luZCI6InVuYm91bmQifX0seyJjaGlsZF9lbnRpdHlfaWRzIjpbImVudGl0eS92ZXJ0ZXgvMCIsImVudGl0eS92ZXJ0ZXgvMiJdLCJjb25uZWN0b3JfYmluZGluZ19zdGF0dXMiOiJvd25lcl9ub3RfcGFydCIsImVudGl0eV9pZCI6ImVudGl0eS9lZGdlLzUiLCJldmFsdWF0ZWRfdGFncyI6W10sImdlb21ldHJ5Ijp7ImRpcmVjdGlvbiI6WzAsMCwxXSwib3JpZ2luIjpbMCwwLDBdLCJ0eXBlIjoibGluZSJ9LCJraW5kIjoiZWRnZSIsInBhcmVudF9lbnRpdHlfaWRzIjpbImVudGl0eS9mYWNlLzAiLCJlbnRpdHkvZmFjZS8yIl0sInByb3BlcnRpZXMiOnsiYm91bmRzIjp7Im1heCI6WzAsMCwxMDAwXSwibWluIjpbMCwwLDBdfSwiY2VudHJvaWQiOlswLDAsNTAwXSwibGVuZ3RoIjoxMDAwLCJxdWFsaXR5Ijoia2VybmVsX2V2YWx1YXRlZCJ9LCJyZW5kZXJfc3RhdHVzIjoicmVuZGVyZWQiLCJzZGtfY29ubmVjdG9yX2ZyYW1lIjp7Im9yaWdpbiI6WzAsMCw1MDBdLCJ4X2F4aXMiOlswLDAsMV0sInlfYXhpcyI6WzAsMSwwXSwiel9heGlzIjpbLTEsMCwwXX0sInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImtpbmQiOiJ1bmJvdW5kIn19LHsiY2hpbGRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZWRnZS8wIiwiZW50aXR5L2VkZ2UvMiIsImVudGl0eS9lZGdlLzUiXSwiY29ubmVjdG9yX2JpbmRpbmdfc3RhdHVzIjoib3duZXJfbm90X3BhcnQiLCJlbnRpdHlfaWQiOiJlbnRpdHkvZmFjZS8wIiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJub3JtYWwiOlstMSwwLDBdLCJvcmlnaW4iOlswLC0zMzMuMzMzMzMzMzMzMzMzMywzMzMuMzMzMzMzMzMzMzMzM10sInR5cGUiOiJwbGFuZSIsInhfZGlyZWN0aW9uIjpbMCwwLDFdfSwia2luZCI6ImZhY2UiLCJwYXJlbnRfZW50aXR5X2lkcyI6WyJlbnRpdHkvc29saWQvMCJdLCJwcm9wZXJ0aWVzIjp7ImFyZWEiOjUwMDAwMCwiYm91bmRzIjp7Im1heCI6WzAsMCwxMDAwXSwibWluIjpbMCwtMTAwMCwwXX0sImNlbnRyb2lkIjpbMCwtMzMzLjMzMzMzMzMzMzMzMzMsMzMzLjMzMzMzMzMzMzMzMzNdLCJvcmllbnRhdGlvbiI6ImZvcndhcmQiLCJxdWFsaXR5Ijoia2VybmVsX2V2YWx1YXRlZCJ9LCJyZW5kZXJfc3RhdHVzIjoicmVuZGVyZWQiLCJzZGtfY29ubmVjdG9yX2ZyYW1lIjp7Im9yaWdpbiI6WzAsLTMzMy4zMzMzMzMzMzMzMzMzLDMzMy4zMzMzMzMzMzMzMzMzXSwieF9heGlzIjpbMCwwLDFdLCJ5X2F4aXMiOlswLDEsMF0sInpfYXhpcyI6Wy0xLDAsMF19LCJzZGtfbWV0YWRhdGEiOnt9LCJzZW1hbnRpY19iaW5kaW5nX2lkcyI6W10sInNvdXJjZSI6eyJraW5kIjoidW5ib3VuZCJ9fSx7ImNoaWxkX2VudGl0eV9pZHMiOlsiZW50aXR5L2VkZ2UvMSIsImVudGl0eS9lZGdlLzIiLCJlbnRpdHkvZWRnZS8zIl0sImNvbm5lY3Rvcl9iaW5kaW5nX3N0YXR1cyI6Im93bmVyX25vdF9wYXJ0IiwiZW50aXR5X2lkIjoiZW50aXR5L2ZhY2UvMSIsImV2YWx1YXRlZF90YWdzIjpbXSwiZ2VvbWV0cnkiOnsibm9ybWFsIjpbMCwwLC0xXSwib3JpZ2luIjpbMzMzLjMzMzMzMzMzMzMzMzMsLTMzMy4zMzMzMzMzMzMzMzMzLDBdLCJ0eXBlIjoicGxhbmUiLCJ4X2RpcmVjdGlvbiI6WzEsMCwwXX0sImtpbmQiOiJmYWNlIiwicGFyZW50X2VudGl0eV9pZHMiOlsiZW50aXR5L3NvbGlkLzAiXSwicHJvcGVydGllcyI6eyJhcmVhIjo1MDAwMDAsImJvdW5kcyI6eyJtYXgiOlsxMDAwLDAsMF0sIm1pbiI6WzAsLTEwMDAsMF19LCJjZW50cm9pZCI6WzMzMy4zMzMzMzMzMzMzMzMzLC0zMzMuMzMzMzMzMzMzMzMzMywwXSwib3JpZW50YXRpb24iOiJmb3J3YXJkIiwicXVhbGl0eSI6Imtlcm5lbF9ldmFsdWF0ZWQifSwicmVuZGVyX3N0YXR1cyI6InJlbmRlcmVkIiwic2RrX2Nvbm5lY3Rvcl9mcmFtZSI6eyJvcmlnaW4iOlszMzMuMzMzMzMzMzMzMzMzMywtMzMzLjMzMzMzMzMzMzMzMzMsMF0sInhfYXhpcyI6WzEsMCwwXSwieV9heGlzIjpbMCwtMSwwXSwiel9heGlzIjpbMCwwLC0xXX0sInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImtpbmQiOiJ1bmJvdW5kIn19LHsiY2hpbGRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZWRnZS8zIiwiZW50aXR5L2VkZ2UvNCIsImVudGl0eS9lZGdlLzUiXSwiY29ubmVjdG9yX2JpbmRpbmdfc3RhdHVzIjoib3duZXJfbm90X3BhcnQiLCJlbnRpdHlfaWQiOiJlbnRpdHkvZmFjZS8yIiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJub3JtYWwiOlswLDEsMF0sIm9yaWdpbiI6WzMzMy4zMzMzMzMzMzMzMzMzLDAsMzMzLjMzMzMzMzMzMzMzMzNdLCJ0eXBlIjoicGxhbmUiLCJ4X2RpcmVjdGlvbiI6WzEsMCwwXX0sImtpbmQiOiJmYWNlIiwicGFyZW50X2VudGl0eV9pZHMiOlsiZW50aXR5L3NvbGlkLzAiXSwicHJvcGVydGllcyI6eyJhcmVhIjo1MDAwMDAsImJvdW5kcyI6eyJtYXgiOlsxMDAwLDAsMTAwMF0sIm1pbiI6WzAsMCwwXX0sImNlbnRyb2lkIjpbMzMzLjMzMzMzMzMzMzMzMzMsMCwzMzMuMzMzMzMzMzMzMzMzM10sIm9yaWVudGF0aW9uIjoiZm9yd2FyZCIsInF1YWxpdHkiOiJrZXJuZWxfZXZhbHVhdGVkIn0sInJlbmRlcl9zdGF0dXMiOiJyZW5kZXJlZCIsInNka19jb25uZWN0b3JfZnJhbWUiOnsib3JpZ2luIjpbMzMzLjMzMzMzMzMzMzMzMzMsMCwzMzMuMzMzMzMzMzMzMzMzM10sInhfYXhpcyI6WzEsMCwwXSwieV9heGlzIjpbMCwwLC0xXSwiel9heGlzIjpbMCwxLDBdfSwic2RrX21ldGFkYXRhIjp7fSwic2VtYW50aWNfYmluZGluZ19pZHMiOltdLCJzb3VyY2UiOnsia2luZCI6InVuYm91bmQifX0seyJjaGlsZF9lbnRpdHlfaWRzIjpbImVudGl0eS9lZGdlLzAiLCJlbnRpdHkvZWRnZS8xIiwiZW50aXR5L2VkZ2UvNCJdLCJjb25uZWN0b3JfYmluZGluZ19zdGF0dXMiOiJvd25lcl9ub3RfcGFydCIsImVudGl0eV9pZCI6ImVudGl0eS9mYWNlLzMiLCJldmFsdWF0ZWRfdGFncyI6W10sImdlb21ldHJ5Ijp7Im5vcm1hbCI6WzAuNTc3MzUwMjY5MTg5NjI1OCwtMC41NzczNTAyNjkxODk2MjU4LDAuNTc3MzUwMjY5MTg5NjI1OF0sIm9yaWdpbiI6WzMzMy4zMzMzMzMzMzMzMzMzLC0zMzMuMzMzMzMzMzMzMzMzMywzMzMuMzMzMzMzMzMzMzMzM10sInR5cGUiOiJwbGFuZSIsInhfZGlyZWN0aW9uIjpbLTAuNzA3MTA2NzgxMTg2NTQ3NSwwLDAuNzA3MTA2NzgxMTg2NTQ3NV19LCJraW5kIjoiZmFjZSIsInBhcmVudF9lbnRpdHlfaWRzIjpbImVudGl0eS9zb2xpZC8wIl0sInByb3BlcnRpZXMiOnsiYXJlYSI6ODY2MDI1LjQwMzc4NDQzODYsImJvdW5kcyI6eyJtYXgiOlsxMDAwLDAsMTAwMF0sIm1pbiI6WzAsLTEwMDAsMF19LCJjZW50cm9pZCI6WzMzMy4zMzMzMzMzMzMzMzMzLC0zMzMuMzMzMzMzMzMzMzMzMywzMzMuMzMzMzMzMzMzMzMzM10sIm9yaWVudGF0aW9uIjoiZm9yd2FyZCIsInF1YWxpdHkiOiJrZXJuZWxfZXZhbHVhdGVkIn0sInJlbmRlcl9zdGF0dXMiOiJyZW5kZXJlZCIsInNka19jb25uZWN0b3JfZnJhbWUiOnsib3JpZ2luIjpbMzMzLjMzMzMzMzMzMzMzMzMsLTMzMy4zMzMzMzMzMzMzMzMzLDMzMy4zMzMzMzMzMzMzMzMzXSwieF9heGlzIjpbLTAuNzA3MTA2NzgxMTg2NTQ3NSwwLDAuNzA3MTA2NzgxMTg2NTQ3NV0sInlfYXhpcyI6Wy0wLjQwODI0ODI5MDQ2Mzg2MzEsLTAuODE2NDk2NTgwOTI3NzI2MSwtMC40MDgyNDgyOTA0NjM4NjMxXSwiel9heGlzIjpbMC41NzczNTAyNjkxODk2MjU4LC0wLjU3NzM1MDI2OTE4OTYyNTgsMC41NzczNTAyNjkxODk2MjU4XX0sInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImtpbmQiOiJ1bmJvdW5kIn19LHsiY2hpbGRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZmFjZS8wIiwiZW50aXR5L2ZhY2UvMSIsImVudGl0eS9mYWNlLzIiLCJlbnRpdHkvZmFjZS8zIl0sImNvbm5lY3Rvcl9iaW5kaW5nX3N0YXR1cyI6Im5vdF9hcHBsaWNhYmxlIiwiZW50aXR5X2lkIjoiZW50aXR5L3NvbGlkLzAiLCJldmFsdWF0ZWRfdGFncyI6W10sImdlb21ldHJ5Ijp7InR5cGUiOiJicmVwX3NvbGlkIn0sImtpbmQiOiJzb2xpZCIsInBhcmVudF9lbnRpdHlfaWRzIjpbXSwicHJvcGVydGllcyI6eyJib3VuZHMiOnsibWF4IjpbMTAwMCwwLDEwMDBdLCJtaW4iOlswLC0xMDAwLDBdfSwiY2VudHJvaWQiOlsyNTAsLTI1MCwyNTBdLCJxdWFsaXR5Ijoia2VybmVsX2V2YWx1YXRlZCIsInN1cmZhY2VfYXJlYSI6MjM2NjAyNS40MDM3ODQ0Mzg1LCJ2b2x1bWUiOjE2NjY2NjY2Ni42NjY2NjY2Nn0sInJlbmRlcl9zdGF0dXMiOiJyZW5kZXJlZCIsInNka19jb25uZWN0b3JfZnJhbWUiOm51bGwsInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImtpbmQiOiJ1bmJvdW5kIn19LHsiY2hpbGRfZW50aXR5X2lkcyI6W10sImNvbm5lY3Rvcl9iaW5kaW5nX3N0YXR1cyI6Im93bmVyX25vdF9wYXJ0IiwiZW50aXR5X2lkIjoiZW50aXR5L3ZlcnRleC8wIiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJwb3NpdGlvbiI6WzAsMCwwXSwidHlwZSI6InBvaW50In0sImtpbmQiOiJ2ZXJ0ZXgiLCJwYXJlbnRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZWRnZS8yIiwiZW50aXR5L2VkZ2UvMyIsImVudGl0eS9lZGdlLzUiXSwicHJvcGVydGllcyI6eyJib3VuZHMiOnsibWF4IjpbMCwwLDBdLCJtaW4iOlswLDAsMF19LCJwb3NpdGlvbiI6WzAsMCwwXSwicXVhbGl0eSI6Imtlcm5lbF9ldmFsdWF0ZWQifSwicmVuZGVyX3N0YXR1cyI6InJlbmRlcmVkIiwic2RrX2Nvbm5lY3Rvcl9mcmFtZSI6eyJvcmlnaW4iOlswLDAsMF0sInhfYXhpcyI6WzEsMCwwXSwieV9heGlzIjpbMCwxLDBdLCJ6X2F4aXMiOlswLDAsMV19LCJzZGtfbWV0YWRhdGEiOnt9LCJzZW1hbnRpY19iaW5kaW5nX2lkcyI6W10sInNvdXJjZSI6eyJraW5kIjoidW5ib3VuZCJ9fSx7ImNoaWxkX2VudGl0eV9pZHMiOltdLCJjb25uZWN0b3JfYmluZGluZ19zdGF0dXMiOiJvd25lcl9ub3RfcGFydCIsImVudGl0eV9pZCI6ImVudGl0eS92ZXJ0ZXgvMSIsImV2YWx1YXRlZF90YWdzIjpbXSwiZ2VvbWV0cnkiOnsicG9zaXRpb24iOlsxMDAwLDAsMF0sInR5cGUiOiJwb2ludCJ9LCJraW5kIjoidmVydGV4IiwicGFyZW50X2VudGl0eV9pZHMiOlsiZW50aXR5L2VkZ2UvMSIsImVudGl0eS9lZGdlLzMiLCJlbnRpdHkvZWRnZS80Il0sInByb3BlcnRpZXMiOnsiYm91bmRzIjp7Im1heCI6WzEwMDAsMCwwXSwibWluIjpbMTAwMCwwLDBdfSwicG9zaXRpb24iOlsxMDAwLDAsMF0sInF1YWxpdHkiOiJrZXJuZWxfZXZhbHVhdGVkIn0sInJlbmRlcl9zdGF0dXMiOiJyZW5kZXJlZCIsInNka19jb25uZWN0b3JfZnJhbWUiOnsib3JpZ2luIjpbMTAwMCwwLDBdLCJ4X2F4aXMiOlsxLDAsMF0sInlfYXhpcyI6WzAsMSwwXSwiel9heGlzIjpbMCwwLDFdfSwic2RrX21ldGFkYXRhIjp7fSwic2VtYW50aWNfYmluZGluZ19pZHMiOltdLCJzb3VyY2UiOnsia2luZCI6InVuYm91bmQifX0seyJjaGlsZF9lbnRpdHlfaWRzIjpbXSwiY29ubmVjdG9yX2JpbmRpbmdfc3RhdHVzIjoib3duZXJfbm90X3BhcnQiLCJlbnRpdHlfaWQiOiJlbnRpdHkvdmVydGV4LzIiLCJldmFsdWF0ZWRfdGFncyI6W10sImdlb21ldHJ5Ijp7InBvc2l0aW9uIjpbMCwwLDEwMDBdLCJ0eXBlIjoicG9pbnQifSwia2luZCI6InZlcnRleCIsInBhcmVudF9lbnRpdHlfaWRzIjpbImVudGl0eS9lZGdlLzAiLCJlbnRpdHkvZWRnZS80IiwiZW50aXR5L2VkZ2UvNSJdLCJwcm9wZXJ0aWVzIjp7ImJvdW5kcyI6eyJtYXgiOlswLDAsMTAwMF0sIm1pbiI6WzAsMCwxMDAwXX0sInBvc2l0aW9uIjpbMCwwLDEwMDBdLCJxdWFsaXR5Ijoia2VybmVsX2V2YWx1YXRlZCJ9LCJyZW5kZXJfc3RhdHVzIjoicmVuZGVyZWQiLCJzZGtfY29ubmVjdG9yX2ZyYW1lIjp7Im9yaWdpbiI6WzAsMCwxMDAwXSwieF9heGlzIjpbMSwwLDBdLCJ5X2F4aXMiOlswLDEsMF0sInpfYXhpcyI6WzAsMCwxXX0sInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImtpbmQiOiJ1bmJvdW5kIn19LHsiY2hpbGRfZW50aXR5X2lkcyI6W10sImNvbm5lY3Rvcl9iaW5kaW5nX3N0YXR1cyI6Im93bmVyX25vdF9wYXJ0IiwiZW50aXR5X2lkIjoiZW50aXR5L3ZlcnRleC8zIiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJwb3NpdGlvbiI6WzAsLTEwMDAsMF0sInR5cGUiOiJwb2ludCJ9LCJraW5kIjoidmVydGV4IiwicGFyZW50X2VudGl0eV9pZHMiOlsiZW50aXR5L2VkZ2UvMCIsImVudGl0eS9lZGdlLzEiLCJlbnRpdHkvZWRnZS8yIl0sInByb3BlcnRpZXMiOnsiYm91bmRzIjp7Im1heCI6WzAsLTEwMDAsMF0sIm1pbiI6WzAsLTEwMDAsMF19LCJwb3NpdGlvbiI6WzAsLTEwMDAsMF0sInF1YWxpdHkiOiJrZXJuZWxfZXZhbHVhdGVkIn0sInJlbmRlcl9zdGF0dXMiOiJyZW5kZXJlZCIsInNka19jb25uZWN0b3JfZnJhbWUiOnsib3JpZ2luIjpbMCwtMTAwMCwwXSwieF9heGlzIjpbMSwwLDBdLCJ5X2F4aXMiOlswLDEsMF0sInpfYXhpcyI6WzAsMCwxXX0sInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImtpbmQiOiJ1bmJvdW5kIn19XSwiZmFjZV9ncm91cHMiOlt7ImVudGl0eV9pZCI6ImVudGl0eS9mYWNlLzAiLCJmaXJzdF9pbmRleCI6MCwiZ3JvdXBfaWQiOjAsImluZGV4X2NvdW50IjozLCJtZXNoX2luZGV4IjowLCJwcmltaXRpdmVfaW5kZXgiOjB9LHsiZW50aXR5X2lkIjoiZW50aXR5L2ZhY2UvMSIsImZpcnN0X2luZGV4IjozLCJncm91cF9pZCI6MSwiaW5kZXhfY291bnQiOjMsIm1lc2hfaW5kZXgiOjAsInByaW1pdGl2ZV9pbmRleCI6MH0seyJlbnRpdHlfaWQiOiJlbnRpdHkvZmFjZS8yIiwiZmlyc3RfaW5kZXgiOjYsImdyb3VwX2lkIjoyLCJpbmRleF9jb3VudCI6MywibWVzaF9pbmRleCI6MCwicHJpbWl0aXZlX2luZGV4IjowfSx7ImVudGl0eV9pZCI6ImVudGl0eS9mYWNlLzMiLCJmaXJzdF9pbmRleCI6OSwiZ3JvdXBfaWQiOjMsImluZGV4X2NvdW50IjozLCJtZXNoX2luZGV4IjowLCJwcmltaXRpdmVfaW5kZXgiOjB9XSwiZ2VvbWV0cnlfYXNzZXRfaWQiOiJzaGEyNTY6MjAwNTIzNDE0ZGUyODFhOGExNTUwODZhNTRjZDBmODgwZjY0MDQ4NTc2NWE0OGNjZGQ4ZWMyMzBmNjdkZDE3YyIsImdlb21ldHJ5X2VuZ2luZSI6eyJuYW1lIjoiT3BlbkNhc2NhZGUiLCJwcm9maWxlIjoib2NwLWV2YWx1YXRlZC1wcm9wZXJ0aWVzLTEiLCJ2ZXJzaW9uIjoiZml4dHVyZSJ9LCJzY2hlbWFfdmVyc2lvbiI6IjEuMCJ9UEsDBBQAAAgAAAAAIQBJVs2b/AMAAPwDAABUAAAAZ2VvbWV0cnkvc2hhMjU2LTIwMDUyMzQxNGRlMjgxYThhMTU1MDg2YTU0Y2QwZjg4MGY2NDA0ODU3NjVhNDhjY2RkOGVjMjMwZjY3ZGQxN2MuZ2xiZ2xURgIAAAD8AwAAqAIAAEpTT057ImFjY2Vzc29ycyI6W3siYnVmZmVyVmlldyI6MCwiY29tcG9uZW50VHlwZSI6NTEyNiwiY291bnQiOjEyLCJtYXgiOlsxLDEsMV0sIm1pbiI6WzAsMCwwXSwidHlwZSI6IlZFQzMifSx7ImJ1ZmZlclZpZXciOjEsImNvbXBvbmVudFR5cGUiOjUxMjYsImNvdW50IjoxMiwidHlwZSI6IlZFQzMifSx7ImJ1ZmZlclZpZXciOjIsImNvbXBvbmVudFR5cGUiOjUxMjMsImNvdW50IjoxMiwidHlwZSI6IlNDQUxBUiJ9XSwiYXNzZXQiOnsiZ2VuZXJhdG9yIjoiU2ltcGxlQ0FEIFNjZW5lIEdMQiBQcm9maWxlIDEiLCJ2ZXJzaW9uIjoiMi4wIn0sImJ1ZmZlclZpZXdzIjpbeyJidWZmZXIiOjAsImJ5dGVMZW5ndGgiOjE0NCwiYnl0ZU9mZnNldCI6MCwidGFyZ2V0IjozNDk2Mn0seyJidWZmZXIiOjAsImJ5dGVMZW5ndGgiOjE0NCwiYnl0ZU9mZnNldCI6MTQ0LCJ0YXJnZXQiOjM0OTYyfSx7ImJ1ZmZlciI6MCwiYnl0ZUxlbmd0aCI6MjQsImJ5dGVPZmZzZXQiOjI4OCwidGFyZ2V0IjozNDk2M31dLCJidWZmZXJzIjpbeyJieXRlTGVuZ3RoIjozMTJ9XSwibWVzaGVzIjpbeyJwcmltaXRpdmVzIjpbeyJhdHRyaWJ1dGVzIjp7Ik5PUk1BTCI6MSwiUE9TSVRJT04iOjB9LCJpbmRpY2VzIjoyLCJtb2RlIjo0fV19XSwibm9kZXMiOlt7Im1lc2giOjB9XSwic2NlbmUiOjAsInNjZW5lcyI6W3sibm9kZXMiOlswXX1dfTgBAABCSU4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIA/AAAAAAAAgD8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIA/AACAPwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgD8AAAAAAACAPwAAAAAAAAAAAAAAAAAAAAAAAIA/AAAAAAAAgD8AAAAAAACAPwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/Os0TPzrNEz86zRM/Os0TPzrNEz86zRM/Os0TPzrNEz86zRM/AAABAAIAAwAFAAQABgAHAAgACQALAAoAUEsDBBQAAAgAAAAAIQA4DSgxWA0AAFgNAAAKAAAAc2NlbmUuanNvbnsiYW5ub3RhdGlvbnMiOltdLCJhcHBlYXJhbmNlcyI6W3siYWxwaGFfbW9kZSI6Im9wYXF1ZSIsImFwcGVhcmFuY2VfaWQiOiJhcHBlYXJhbmNlL2V2YWx1YXRlZC9iZjFjYTZkNjBhZjIxNzY3YWNlMTQxOWI2MDU1MjE3Yzg3MTQ1ODliNzkwOTBkMjcwYWFiZTUxMjIzMGVmYTNhIiwiYmFzZV9jb2xvciI6WzAuNzIsMC43NSwwLjc4LDFdLCJkb3VibGVfc2lkZWQiOmZhbHNlLCJlZGdlX2NvbG9yIjpbMC4wOCwwLjA5LDAuMSwxXSwibWV0YWxsaWMiOjAsIm5hbWUiOm51bGwsInJvdWdobmVzcyI6MC41NSwic2RrX21ldGFkYXRhIjp7fSwic291cmNlIjpudWxsfV0sImNhbWVyYXMiOltdLCJjb21waWxlX29wdGlvbnMiOnsiYW5ndWxhcl90b2xlcmFuY2UiOjAuMjIsImVtYmVkX3ByZXNlbnRhdGlvbiI6ZmFsc2UsImVtYmVkX3NvdXJjZSI6ZmFsc2UsImxpbmVhcl90b2xlcmFuY2UiOjAuMzV9LCJjb25uZWN0b3JzIjpbXSwiY29vcmRpbmF0ZV9zeXN0ZW0iOnsiaGFuZGVkbmVzcyI6InJpZ2h0IiwibGVuZ3RoX3VuaXQiOiJtbSIsInVwX2F4aXMiOiIrWiJ9LCJkZWZpbml0aW9ucyI6W3siYXBwZWFyYW5jZV9pZCI6ImFwcGVhcmFuY2UvZXZhbHVhdGVkL2JmMWNhNmQ2MGFmMjE3NjdhY2UxNDE5YjYwNTUyMTdjODcxNDU4OWI3OTA5MGQyNzBhYWJlNTEyMjMwZWZhM2EiLCJkZWZpbml0aW9uX2lkIjoiZGVmaW5pdGlvbi9yb290L3NoYXBlL21hbnVhbC9maXh0dXJlIiwiZWRnZV9hc3NldF9pZCI6InNoYTI1Njo2Y2U0ODFkYjFmZTEzYmNmZTdhMjM3NDQyM2U2YzcyZDc1N2Q1MWM5OTE2OGIyODdjNTJmNTNhYTYyNWNjZDY3IiwiZW50aXR5X2Fzc2V0X2lkIjoic2hhMjU2OjM4OTdjYzllYmRiMjk0MTgzNTk2NGE0MzU4MjFiNTU2ZTAxYjE3NTUzNWNmZjhmYzBlYWFhZmI2ODRjNGRkZjUiLCJnZW9tZXRyeV9hc3NldF9pZCI6InNoYTI1NjoyMDA1MjM0MTRkZTI4MWE4YTE1NTA4NmE1NGNkMGY4ODBmNjQwNDg1NzY1YTQ4Y2NkZDhlYzIzMGY2N2RkMTdjIiwia2luZCI6InNoYXBlIiwibmFtZSI6bnVsbCwic2RrX21ldGFkYXRhIjp7fSwic291cmNlIjp7ImtpbmQiOiJtYW51YWwiLCJyb290X2lkIjoicm9vdCIsInNvdXJjZV9pZCI6ImZpeHR1cmUifX1dLCJkaWFnbm9zdGljcyI6W10sImVkZ2VfYXNzZXRzIjpbeyJhc3NldF9pZCI6InNoYTI1Njo2Y2U0ODFkYjFmZTEzYmNmZTdhMjM3NDQyM2U2YzcyZDc1N2Q1MWM5OTE2OGIyODdjNTJmNTNhYTYyNWNjZDY3IiwiYXNzZXRfdG9fc2NlbmUiOlsxMDAwLDAsMCwwLDAsMCwtMTAwMCwwLDAsMTAwMCwwLDAsMCwwLDAsMV0sImJ5dGVfbGVuZ3RoIjo3NDAsImNvbnRlbnRfaGFzaCI6InNoYTI1Njo2Y2U0ODFkYjFmZTEzYmNmZTdhMjM3NDQyM2U2YzcyZDc1N2Q1MWM5OTE2OGIyODdjNTJmNTNhYTYyNWNjZDY3IiwibWVkaWFfdHlwZSI6Im1vZGVsL2dsdGYtYmluYXJ5Iiwic2NlbmVfbG9jYWxfYm91bmRzIjp7Im1heCI6WzEwMDAsMCwxMDAwXSwibWluIjpbMCwtMTAwMCwwXX0sInRlc3NlbGxhdGlvbiI6eyJsaW5lYXJfdG9sZXJhbmNlIjowLjM1fSwidXJpIjoiZWRnZXMvc2hhMjU2LTZjZTQ4MWRiMWZlMTNiY2ZlN2EyMzc0NDIzZTZjNzJkNzU3ZDUxYzk5MTY4YjI4N2M1MmY1M2FhNjI1Y2NkNjcuZ2xiIn1dLCJlbnRpdHlfYXNzZXRzIjpbeyJieXRlX2xlbmd0aCI6MTE1MTcsImNvbnRlbnRfaGFzaCI6InNoYTI1NjozODk3Y2M5ZWJkYjI5NDE4MzU5NjRhNDM1ODIxYjU1NmUwMWIxNzU1MzVjZmY4ZmMwZWFhYWZiNjg0YzRkZGY1IiwiZW50aXR5X2Fzc2V0X2lkIjoic2hhMjU2OjM4OTdjYzllYmRiMjk0MTgzNTk2NGE0MzU4MjFiNTU2ZTAxYjE3NTUzNWNmZjhmYzBlYWFhZmI2ODRjNGRkZjUiLCJtZWRpYV90eXBlIjoiYXBwbGljYXRpb24vdm5kLnNpbXBsZWNhZC5lbnRpdGllcytqc29uIiwidXJpIjoiZW50aXRpZXMvc2hhMjU2LTM4OTdjYzllYmRiMjk0MTgzNTk2NGE0MzU4MjFiNTU2ZTAxYjE3NTUzNWNmZjhmYzBlYWFhZmI2ODRjNGRkZjUuanNvbiJ9XSwiZXh0ZW5zaW9ucyI6e30sImV4dGVuc2lvbnNfcmVxdWlyZWQiOltdLCJleHRlbnNpb25zX3VzZWQiOltdLCJnZW5lcmF0b3IiOnsibmFtZSI6InNpbXBsZWNhZGFwaSIsIm9jcF9iaW5kaW5nc192ZXJzaW9uIjoiZml4dHVyZSIsIm9jcF92ZXJzaW9uIjoiZml4dHVyZSIsInBsYXRmb3JtX3RhZyI6ImZpeHR1cmUiLCJwcm9maWxlIjoic2NlbmUtMS4wLW9jcC1nbGItMiIsInB5dGhvbl9hYmkiOiJmaXh0dXJlIiwic2ltcGxlY2FkYXBpX3ZlcnNpb24iOiJmaXh0dXJlIiwidG9vbGNoYWluX2hhc2giOiJzaGEyNTY6MDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMCJ9LCJnZW9tZXRyeV9hc3NldHMiOlt7ImFzc2V0X2lkIjoic2hhMjU2OjIwMDUyMzQxNGRlMjgxYThhMTU1MDg2YTU0Y2QwZjg4MGY2NDA0ODU3NjVhNDhjY2RkOGVjMjMwZjY3ZGQxN2MiLCJhc3NldF90b19zY2VuZSI6WzEwMDAsMCwwLDAsMCwwLC0xMDAwLDAsMCwxMDAwLDAsMCwwLDAsMCwxXSwiYnl0ZV9sZW5ndGgiOjEwMjAsImNvbnRlbnRfaGFzaCI6InNoYTI1NjoyMDA1MjM0MTRkZTI4MWE4YTE1NTA4NmE1NGNkMGY4ODBmNjQwNDg1NzY1YTQ4Y2NkZDhlYzIzMGY2N2RkMTdjIiwibWVkaWFfdHlwZSI6Im1vZGVsL2dsdGYtYmluYXJ5Iiwic2NlbmVfbG9jYWxfYm91bmRzIjp7Im1heCI6WzEwMDAsMCwxMDAwXSwibWluIjpbMCwtMTAwMCwwXX0sInRlc3NlbGxhdGlvbiI6eyJhbmd1bGFyX3RvbGVyYW5jZSI6MC4yMiwibGluZWFyX3RvbGVyYW5jZSI6MC4zNX0sInVyaSI6Imdlb21ldHJ5L3NoYTI1Ni0yMDA1MjM0MTRkZTI4MWE4YTE1NTA4NmE1NGNkMGY4ODBmNjQwNDg1NzY1YTQ4Y2NkZDhlYzIzMGY2N2RkMTdjLmdsYiJ9XSwibGlnaHRzIjpbXSwibm9kZXMiOlt7ImFwcGVhcmFuY2Vfb3ZlcnJpZGVfaWQiOm51bGwsImRlZmluaXRpb25faWQiOiJkZWZpbml0aW9uL3Jvb3Qvc2hhcGUvbWFudWFsL2ZpeHR1cmUiLCJuYW1lIjpudWxsLCJub2RlX2lkIjoiaW5zdGFuY2Uvcm9vdCIsIm9yZGVyIjowLCJwYXJlbnRfbm9kZV9pZCI6bnVsbCwic2RrX21ldGFkYXRhIjp7fSwic2VsZWN0YWJsZSI6dHJ1ZSwic291cmNlIjp7ImtpbmQiOiJzaGFwZV9yb290Iiwicm9vdF9pZCI6InJvb3QifSwidHJhbnNmb3JtIjp7Im9yaWdpbiI6WzAsMCwwXSwieF9heGlzIjpbMSwwLDBdLCJ5X2F4aXMiOlswLDEsMF0sInpfYXhpcyI6WzAsMCwxXX0sInZpc2libGUiOnRydWV9XSwicmV2aXNpb24iOiJzaGEyNTY6ZmFlMTVmNGFlZWYxMjc3Y2Q4MzFmZjhiZjgzZjliMjQ0ZDVjNDIyNWU3MmViN2JjMmUwZTM1ZGQ2Y2Y0MzYxNCIsInNjZW5lX2lkIjoiZml4dHVyZSIsInNjaGVtYV92ZXJzaW9uIjoiMS4wIiwic291cmNlIjp7ImtpbmQiOiJtYW51YWwiLCJzb3VyY2VfaWQiOiJmaXh0dXJlIn19UEsBAhQDFAAACAAAAAAhAB1FlhXkAgAA5AIAAFEAAAAAAAAAAAAAAKSBAAAAAGVkZ2VzL3NoYTI1Ni02Y2U0ODFkYjFmZTEzYmNmZTdhMjM3NDQyM2U2YzcyZDc1N2Q1MWM5OTE2OGIyODdjNTJmNTNhYTYyNWNjZDY3LmdsYlBLAQIUAxQAAAgAAAAAIQBgM6Aj/SwAAP0sAABVAAAAAAAAAAAAAACkgVMDAABlbnRpdGllcy9zaGEyNTYtMzg5N2NjOWViZGIyOTQxODM1OTY0YTQzNTgyMWI1NTZlMDFiMTc1NTM1Y2ZmOGZjMGVhYWFmYjY4NGM0ZGRmNS5qc29uUEsBAhQDFAAACAAAAAAhAElWzZv8AwAA/AMAAFQAAAAAAAAAAAAAAKSBwzAAAGdlb21ldHJ5L3NoYTI1Ni0yMDA1MjM0MTRkZTI4MWE4YTE1NTA4NmE1NGNkMGY4ODBmNjQwNDg1NzY1YTQ4Y2NkZDhlYzIzMGY2N2RkMTdjLmdsYlBLAQIUAxQAAAgAAAAAIQA4DSgxWA0AAFgNAAAKAAAAAAAAAAAAAACkgTE1AABzY2VuZS5qc29uUEsFBgAAAAAEAAQAvAEAALFCAAAAAA==", + "sha256": "sha256:2b716495009da4ca198b2713ab632f6bb6ea0367cf08e6bc262c3306bf5b0ac5" + }, + "deflate_zip": { + "base64": "UEsDBBQAAAgIAAAAIQAdRZYVcQEAAOQCAABRAAAAZWRnZXMvc2hhMjU2LTZjZTQ4MWRiMWZlMTNiY2ZlN2EyMzc0NDIzZTZjNzJkNzU3ZDUxYzk5MTY4YjI4N2M1MmY1M2FhNjI1Y2NkNjcuZ2xijVLJbsIwEJ3u209EPkdVForaXCqgi6gQQQ3iUnEIwQmWyCLb0KIoUj+nH9YP6TihIlAOtZXEM2/e85tRovnw6RAAvvGl4fPiuf2c+EFAhUi5IM5bTiaLMKR8xOg7cQydBGmcpQlN5HCVUeLcmFZTJReJJI5p6ST2P5Bm6rjHGLEEI0PHjZEsKWT02LFJoW9Lm/uk7S3pNd3rtHqtV1KgoC8ERTAnEU0o92XKFc7ibE47rQfNCzCtPffa2oCnIZtTzSQ6WVIuWIq+iHVtoI+ajXrHZbeTlaQ9mkRyhhYajSrhhmF5LeLS55E62o27prVp6Q/X2qGWWnWyrdqpyGsT9ZubtwqOqZjRCs04i5lky3XoS8nZZCFVmJOB63WHXbePLrA7lkxZoACccJxOcYJmMVZyCQYVXQmrYkwKNbLSfnmq8N9KA3nFF/4v7W4fNuvzfvtbz+/mdtc+DvxDe1/NARzCERzDCZzCGZzDBVzCFfwAUEsDBBQAAAgIAAAAIQBgM6AjtwUAAP0sAABVAAAAZW50aXRpZXMvc2hhMjU2LTM4OTdjYzllYmRiMjk0MTgzNTk2NGE0MzU4MjFiNTU2ZTAxYjE3NTUzNWNmZjhmYzBlYWFhZmI2ODRjNGRkZjUuanNvbt2a626rOBCA34XfJPEVSP/uA+wDrCrkgJOiQ4AF0pNu1XffMRDCLeFSSKoTqVUYbGYYfzMeO/7UXLn3Ai/1wsD2XO2lcr2JwzDdJG8ikpujCE7C3+y9c3qKpaZr0j1IWySJTPNu0Ixw48VwJLOwu8N7ienO2UtTEGoyRqg0HJO4Jjddjp3tFhvWjlimw8meUyEMwh3HNczLkw9xeIoS7eWfT00GqZd+5Fry7xvVZIOg7d6LEzAgcOVZe0G6lnXLmsJFJrad8BSk2gvRtaNM3ipto9g7wnu+y1L2pd/WhpvaSFUbnlkbaWpjVW1kZm20qc2oaqMza2NNbVZVG5tZG29qwzVM+BR1r3quwpM5oc6b57t2aYESXmx4l3Eqz9l41iVUg6c4YRBIJw1jewcP94KDnaQiPcEDtPB3IGM7CFM7EnFadu8OA/ku/JNIpWun4qDUw7MPMjzKNP7QXj4114tBDYQ03EL6Cq1NZGJkmBbGlsGZybtk8Iww9g5e3gnpGCEEsvQjkmCC7wVS+9K1X2C4sghMAUPAVrCt2xV74RTWVq4zN0RxGIFXMnd+ajsYCTf7dhTnuu5jYcxKXevoFfQ78LQ4VF5Rcg5injX1ZXBI32C4GWZrgik3IA9RtIUB/xcyGRgAVv+ScSB9u3SfeiF4ARc8Xw5Efg33dC1xf9nXIdvH4iiVmRUvVQw42+LsJSMd/nGvE9NbIhP6/Hfps8J64RRlKAy+cEUqwEIlkZDBU88pQcvHBron4Sl2svcohvIUZCOgfamw6kcbL4c2Hon2QD/rqIp2jpL+fbTxeLQruu+jrbDK2HoW2jUDSrTHOLyCNsRzjdtWD0PvEj2abLQc2WR80sZ1bOdhtpmO8bB0PDgX13BV7RZPvs3Ui/vge0rSbKOF50KLjkQLF+M5M1nNbEgmZcPuTPhQrq4KS6wuLrtNVQHe06diMhdVbIapGOn3i8zZZmIyeSZu1Jm38HteiVnVf3cavuHsK7Id1WVPfQnu+QmZcjam+ehJOI/yhedgMmlJ1DUFt0BdegbuWPvkLvuoM/S8+fe6ZG5WYzUwvk/YdWh7CAvC+Cj8qydqfK0opWta/egtyRXAyBeBQu5st7G9cqks6+EyCX3PBetbIEIfGBsY5Iyk76zUh70YuAJ6ifxNtH0Y/xaxQnHJKnKYZT8ccdyDOJ0LcTwGceWrVS2Ftp3d4X/Uh3jp4MURH7xiH/xiD0d8sGX36t1Vg/FiZB/KOG0wzZZK42Qc442VeodzxyfxhxPeW14Mfa0fwDe6n8E7l3Mr/LzSt7NIwS3aZ6KbjqJ7zU2TckSMLba2BuGW2vBrydqi0Ul/dIgMXgXNGUWWYSDC1wxR02KMWsbQgPrGrPFDo2xs4TRl1Qp9GLIIs8gWMQPcTbHiz8IG2xrcQltimsTIZM12tXCeTPGjUsCN3Vm9f5/jTkpQyUBEke85YufL7pRw4b0/JxTBuItlZGe9Kmvf/Lo7sibuxXQHDOFwR/2Dv9e7uMNgnGLlJjuPXEIboct17T30T4pzbBSf9eXLhGAJTr6/MC3fnwGquyw94x2FiVdZUlaL8tAL0sro5w/tSaw3liUdJdy43yMKRjqsXXTjo69g7qiXly+XZ+MDj+OjvZM7HRHcgwib8MNCKaiDUmm3GCsVHX8uLmR8OqmfLpmOCxqyKJywu5oJ2nmlaLdoail0/Lm40LG4XGqBBXhpb5wN4OVqT0e1csPyJbcwSyVPZAbuZtXW/ZOcZZ07/CQnnX4isKyia9rovZOc39VG7p6tJDNra53k3N47yUkHHna8xGHHeV+CECeUYeZKYmFhCcw5sgzBmeOivWWhvcEQs7hpcMEsx3FdSzqEgth0XWw6WuXpMjioX9DUdkPGtPZ3JIO/ROIIN1ulx+He85U8dKJVGS2ra3Cu1NBC6CfFKrg4rKwIdt6AYft6D6+R9vU/UEsDBBQAAAgIAAAAIQBJVs2blQEAAPwDAABUAAAAZ2VvbWV0cnkvc2hhMjU2LTIwMDUyMzQxNGRlMjgxYThhMTU1MDg2YTU0Y2QwZjg4MGY2NDA0ODU3NjVhNDhjY2RkOGVjMjMwZjY3ZGQxN2MuZ2xirVLNTsJAEB7Ef4++ANlzY2iLBHshgD/BICWUcDEcStnCJrQl2wUlhITH8S18At/Ig7MtxkLAenA3m87M932zM7Mdjjv3BwDwmQV4Q+PRMpsLYjsODcOAh8R4XpD+1HUp7zL6Qoy8QpzAmwQ+9UVnPqHEuFa1ogxOfUEMVVOIZ7+iTFVw99BjPnp5BTd6IpKQ7l1NJ0tlM7Wanvo3ubZLru+SW7VKo9ImS6zHDkOK4IIMqU+5LQIuceZNxrRWuc1ZDoZzD41qrsUDl41pTiUKmVEesgDbItpVHutIlJEcWDSs/lzQBvWHYoQlFApxwHTd6FrEhc2H0tQLN0Xtp6V0bRT4q1rbEmul0qZYl8OIxesWEmpd1STs0XBEY3TCmccEm61dWwjO+lMh3QVpmu2nSiN6z5Zp1Tt1s4kF4ZiYP2CO5MifJBjgUxSWPZnZRyfOJO+QZAyGcvZRJ5EV49/MPOqWpQxAtd6E3WtV3vzu46Th+3JsY0nO6j3d/h+e8XFZTjsAGTiALBzBIRzDCZzCGVzAOXwBUEsDBBQAAAgIAAAAIQA4DSgxYAQAAFgNAAAKAAAAc2NlbmUuanNvbr1WbY+jOAz+L3zdviSBAO1PudEKmcS0uYWEDWE0vWr++zmUvs21d9LN7LYSLU7i2I/txz4mYK0LEIyzQ7J9+b5IoO8RPFiFUXBMoO33UHVOY7JNXA8/R0xud1VG08L1fY2v0I4QUK/rhivIdc6gEbzIC1DIM76pcyYlCVRZ8EyWm7rYsA3TomAANUouRMqwgRTonhoGrJRrnSdj2KoQC3rI+CgXnKzVbqxbrAajkcxooB1wkaDe3R5iJe1nG3rw6UyHAdrWqGTLFomFjhyzY9suEu/G3d7iQI6zlZSLZNA/qrhbQ4Bke3wniRu9mg+8ky5Fxz3M0CnX9Yascf2MJ6Fnd2MLvgquxQmeqFoIsrGrUVe9xwHtCf+r+dPS+aZZ2BqLH/Wk8j1eai2q4PzFCOe1sRSAajgMAbtoxh4sAXRyLfFmtw+EbYt2F/bVaE0gadeRaOwreDNx07c/ElKusTG0PGfH8beF/Xrv6Z7r+9o7F9bDHnpcd2BHaNeNeQujj1k5BR6GAcPpGG0TMt/mCrOS65o3yNNaNViASIssEynmqhC6kIWWXG02PC9rURZKikamALmQSum8iJptMOHwQHdabgqlNljrWmwyXqZyk2eQpbIUvJYyR8ZrXkiZStU0ZaMYAkBT52WmMq0bSbp36CjJ/CPtgjEp0oxnGkXJoQQuJStzkJnSrClL1uQZy0pZ5BKykozVJSrCsckLrQlq0v7D2FlhHyG6zffn6X08HztBnMTacLNp8V9y3noSnSPwHktCG9hZNwSj5oy8RmVOol8RoJPS4KpBoSUPXjhjbHH9Li/vHxYiI9QHqpZTOSTbImNTVQWKebWHYf+ldnZI+FTh0Ec6jazarndtaJY1law/RFyj/VXrFLRV7UarJx7p4O3qU/yJPGZs5Leza98pfoEqHNt25pPjc9YYvaH7Y2SG9cm55WedW+3aOokJcFsrp4jf4cu55MUzhL+gnH5lqd5Fj8iPusiE9frV6tVgur5FBXo1mWBw+PbnQHG4oD1Lz4B/1p7VpH1C/I2gHOae8377Xnn8ORofm+PL3cZqHM7CHSWchxC75XHmh+TiC/SGHHCqryhDqa/shuoV/TDlV3Jl3rjhkbynXGyc76oAu/sF7xrqlfGqmPBLvmJLUrKkJFqKuH4Ie2J/qM3dsVu7Hl4YnGvVHoz9kFjsk5/YDe+J+hmZfQFnfx2ZcSaestkXGPp72ezpMPUfNHeO27nwPuv4henaOEnNXc6S9/8YkhylqKfZdEqQU9P9n6PNbd+OV50OGzuEafqamzKNfuinubYHHyN+2fq04yOVU4A6lmLwIz6YASaLqvmGD3NAjBK5OsQaj0ccDZdzIGMQF8nbPFC+8FlwOAso8pPgr6sgJjBpfDWDuRgUcfYYRVOlz7nbAHLZZIDYcFEUSpcpJ5KsmzJtNrXIMi1VJoTEQmBd1Eogw1RqnasmS3OeXVLzbn6Jwj12cMMsxEvJv81Fj8egvwFQSwECFAMUAAAICAAAACEAHUWWFXEBAADkAgAAUQAAAAAAAAAAAAAApIEAAAAAZWRnZXMvc2hhMjU2LTZjZTQ4MWRiMWZlMTNiY2ZlN2EyMzc0NDIzZTZjNzJkNzU3ZDUxYzk5MTY4YjI4N2M1MmY1M2FhNjI1Y2NkNjcuZ2xiUEsBAhQDFAAACAgAAAAhAGAzoCO3BQAA/SwAAFUAAAAAAAAAAAAAAKSB4AEAAGVudGl0aWVzL3NoYTI1Ni0zODk3Y2M5ZWJkYjI5NDE4MzU5NjRhNDM1ODIxYjU1NmUwMWIxNzU1MzVjZmY4ZmMwZWFhYWZiNjg0YzRkZGY1Lmpzb25QSwECFAMUAAAICAAAACEASVbNm5UBAAD8AwAAVAAAAAAAAAAAAAAApIEKCAAAZ2VvbWV0cnkvc2hhMjU2LTIwMDUyMzQxNGRlMjgxYThhMTU1MDg2YTU0Y2QwZjg4MGY2NDA0ODU3NjVhNDhjY2RkOGVjMjMwZjY3ZGQxN2MuZ2xiUEsBAhQDFAAACAgAAAAhADgNKDFgBAAAWA0AAAoAAAAAAAAAAAAAAKSBEQoAAHNjZW5lLmpzb25QSwUGAAAAAAQABAC8AQAAmQ4AAAAA", + "sha256": "sha256:ab16c74276b3b198d4023bd1190cc24d80d9f96f6bc5c7deb9a13c24edc84cdf" + }, + "edge_glb": { + "base64": "Z2xURgIAAADkAgAAIAIAAEpTT057ImFjY2Vzc29ycyI6W3siYnVmZmVyVmlldyI6MCwiY29tcG9uZW50VHlwZSI6NTEyNiwiY291bnQiOjEyLCJtYXgiOlsxLDEsMV0sIm1pbiI6WzAsMCwwXSwidHlwZSI6IlZFQzMifSx7ImJ1ZmZlclZpZXciOjEsImNvbXBvbmVudFR5cGUiOjUxMjMsImNvdW50IjoxMiwidHlwZSI6IlNDQUxBUiJ9XSwiYXNzZXQiOnsiZ2VuZXJhdG9yIjoiU2ltcGxlQ0FEIFNjZW5lIEdMQiBQcm9maWxlIDEiLCJ2ZXJzaW9uIjoiMi4wIn0sImJ1ZmZlclZpZXdzIjpbeyJidWZmZXIiOjAsImJ5dGVMZW5ndGgiOjE0NCwiYnl0ZU9mZnNldCI6MCwidGFyZ2V0IjozNDk2Mn0seyJidWZmZXIiOjAsImJ5dGVMZW5ndGgiOjI0LCJieXRlT2Zmc2V0IjoxNDQsInRhcmdldCI6MzQ5NjN9XSwiYnVmZmVycyI6W3siYnl0ZUxlbmd0aCI6MTY4fV0sIm1lc2hlcyI6W3sicHJpbWl0aXZlcyI6W3siYXR0cmlidXRlcyI6eyJQT1NJVElPTiI6MH0sImluZGljZXMiOjEsIm1vZGUiOjF9XX1dLCJub2RlcyI6W3sibWVzaCI6MH1dLCJzY2VuZSI6MCwic2NlbmVzIjpbeyJub2RlcyI6WzBdfV19qAAAAEJJTgAAAAAAAAAAAAAAgD8AAAAAAACAPwAAAAAAAAAAAAAAAAAAgD8AAIA/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgD8AAAAAAAAAAAAAAAAAAIA/AAAAAAAAAAAAAAAAAACAPwAAAAAAAIA/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAPwAAAAAAAAEAAgADAAQABQAGAAcACAAJAAoACwA=", + "sha256": "sha256:6ce481db1fe13bcfe7a2374423e6c72d757d51c99168b287c52f53aa625ccd67", + "uri": "edges/sha256-6ce481db1fe13bcfe7a2374423e6c72d757d51c99168b287c52f53aa625ccd67.glb" + }, + "entities": { + "base64": "eyJkZWZpbml0aW9uX2lkIjoiZGVmaW5pdGlvbi9yb290L3NoYXBlL21hbnVhbC9maXh0dXJlIiwiZWRnZV9hc3NldF9pZCI6InNoYTI1Njo2Y2U0ODFkYjFmZTEzYmNmZTdhMjM3NDQyM2U2YzcyZDc1N2Q1MWM5OTE2OGIyODdjNTJmNTNhYTYyNWNjZDY3IiwiZWRnZV9ncm91cHMiOlt7ImVudGl0eV9pZCI6ImVudGl0eS9lZGdlLzAiLCJmaXJzdF9pbmRleCI6MCwiZ3JvdXBfaWQiOjAsImluZGV4X2NvdW50IjoyLCJtZXNoX2luZGV4IjowLCJwcmltaXRpdmVfaW5kZXgiOjB9LHsiZW50aXR5X2lkIjoiZW50aXR5L2VkZ2UvMSIsImZpcnN0X2luZGV4IjoyLCJncm91cF9pZCI6MSwiaW5kZXhfY291bnQiOjIsIm1lc2hfaW5kZXgiOjAsInByaW1pdGl2ZV9pbmRleCI6MH0seyJlbnRpdHlfaWQiOiJlbnRpdHkvZWRnZS8yIiwiZmlyc3RfaW5kZXgiOjQsImdyb3VwX2lkIjoyLCJpbmRleF9jb3VudCI6MiwibWVzaF9pbmRleCI6MCwicHJpbWl0aXZlX2luZGV4IjowfSx7ImVudGl0eV9pZCI6ImVudGl0eS9lZGdlLzMiLCJmaXJzdF9pbmRleCI6NiwiZ3JvdXBfaWQiOjMsImluZGV4X2NvdW50IjoyLCJtZXNoX2luZGV4IjowLCJwcmltaXRpdmVfaW5kZXgiOjB9LHsiZW50aXR5X2lkIjoiZW50aXR5L2VkZ2UvNCIsImZpcnN0X2luZGV4Ijo4LCJncm91cF9pZCI6NCwiaW5kZXhfY291bnQiOjIsIm1lc2hfaW5kZXgiOjAsInByaW1pdGl2ZV9pbmRleCI6MH0seyJlbnRpdHlfaWQiOiJlbnRpdHkvZWRnZS81IiwiZmlyc3RfaW5kZXgiOjEwLCJncm91cF9pZCI6NSwiaW5kZXhfY291bnQiOjIsIm1lc2hfaW5kZXgiOjAsInByaW1pdGl2ZV9pbmRleCI6MH1dLCJlbnRpdGllcyI6W3siY2hpbGRfZW50aXR5X2lkcyI6WyJlbnRpdHkvdmVydGV4LzIiLCJlbnRpdHkvdmVydGV4LzMiXSwiY29ubmVjdG9yX2JpbmRpbmdfc3RhdHVzIjoib3duZXJfbm90X3BhcnQiLCJlbnRpdHlfaWQiOiJlbnRpdHkvZWRnZS8wIiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJkaXJlY3Rpb24iOlswLC0wLjcwNzEwNjc4MTE4NjU0NzUsLTAuNzA3MTA2NzgxMTg2NTQ3NV0sIm9yaWdpbiI6WzAsMCwxMDAwXSwidHlwZSI6ImxpbmUifSwia2luZCI6ImVkZ2UiLCJwYXJlbnRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZmFjZS8wIiwiZW50aXR5L2ZhY2UvMyJdLCJwcm9wZXJ0aWVzIjp7ImJvdW5kcyI6eyJtYXgiOlswLDAsMTAwMF0sIm1pbiI6WzAsLTEwMDAsMF19LCJjZW50cm9pZCI6WzAsLTUwMCw1MDBdLCJsZW5ndGgiOjE0MTQuMjEzNTYyMzczMDk1LCJxdWFsaXR5Ijoia2VybmVsX2V2YWx1YXRlZCJ9LCJyZW5kZXJfc3RhdHVzIjoicmVuZGVyZWQiLCJzZGtfY29ubmVjdG9yX2ZyYW1lIjp7Im9yaWdpbiI6WzAsLTUwMCw1MDBdLCJ4X2F4aXMiOlswLC0wLjcwNzEwNjc4MTE4NjU0NzUsLTAuNzA3MTA2NzgxMTg2NTQ3NV0sInlfYXhpcyI6WzAsLTAuNzA3MTA2NzgxMTg2NTQ3NCwwLjcwNzEwNjc4MTE4NjU0NzddLCJ6X2F4aXMiOlstMSwwLDBdfSwic2RrX21ldGFkYXRhIjp7fSwic2VtYW50aWNfYmluZGluZ19pZHMiOltdLCJzb3VyY2UiOnsia2luZCI6InVuYm91bmQifX0seyJjaGlsZF9lbnRpdHlfaWRzIjpbImVudGl0eS92ZXJ0ZXgvMSIsImVudGl0eS92ZXJ0ZXgvMyJdLCJjb25uZWN0b3JfYmluZGluZ19zdGF0dXMiOiJvd25lcl9ub3RfcGFydCIsImVudGl0eV9pZCI6ImVudGl0eS9lZGdlLzEiLCJldmFsdWF0ZWRfdGFncyI6W10sImdlb21ldHJ5Ijp7ImRpcmVjdGlvbiI6Wy0wLjcwNzEwNjc4MTE4NjU0NzUsLTAuNzA3MTA2NzgxMTg2NTQ3NSwwXSwib3JpZ2luIjpbMTAwMCwwLDBdLCJ0eXBlIjoibGluZSJ9LCJraW5kIjoiZWRnZSIsInBhcmVudF9lbnRpdHlfaWRzIjpbImVudGl0eS9mYWNlLzEiLCJlbnRpdHkvZmFjZS8zIl0sInByb3BlcnRpZXMiOnsiYm91bmRzIjp7Im1heCI6WzEwMDAsMCwwXSwibWluIjpbMCwtMTAwMCwwXX0sImNlbnRyb2lkIjpbNTAwLC01MDAsMF0sImxlbmd0aCI6MTQxNC4yMTM1NjIzNzMwOTUsInF1YWxpdHkiOiJrZXJuZWxfZXZhbHVhdGVkIn0sInJlbmRlcl9zdGF0dXMiOiJyZW5kZXJlZCIsInNka19jb25uZWN0b3JfZnJhbWUiOnsib3JpZ2luIjpbNTAwLC01MDAsMF0sInhfYXhpcyI6Wy0wLjcwNzEwNjc4MTE4NjU0NzUsLTAuNzA3MTA2NzgxMTg2NTQ3NSwwXSwieV9heGlzIjpbMCwwLDFdLCJ6X2F4aXMiOlstMC43MDcxMDY3ODExODY1NDc2LDAuNzA3MTA2NzgxMTg2NTQ3NiwwXX0sInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImtpbmQiOiJ1bmJvdW5kIn19LHsiY2hpbGRfZW50aXR5X2lkcyI6WyJlbnRpdHkvdmVydGV4LzAiLCJlbnRpdHkvdmVydGV4LzMiXSwiY29ubmVjdG9yX2JpbmRpbmdfc3RhdHVzIjoib3duZXJfbm90X3BhcnQiLCJlbnRpdHlfaWQiOiJlbnRpdHkvZWRnZS8yIiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJkaXJlY3Rpb24iOlswLC0xLDBdLCJvcmlnaW4iOlswLDAsMF0sInR5cGUiOiJsaW5lIn0sImtpbmQiOiJlZGdlIiwicGFyZW50X2VudGl0eV9pZHMiOlsiZW50aXR5L2ZhY2UvMCIsImVudGl0eS9mYWNlLzEiXSwicHJvcGVydGllcyI6eyJib3VuZHMiOnsibWF4IjpbMCwwLDBdLCJtaW4iOlswLC0xMDAwLDBdfSwiY2VudHJvaWQiOlswLC01MDAsMF0sImxlbmd0aCI6MTAwMCwicXVhbGl0eSI6Imtlcm5lbF9ldmFsdWF0ZWQifSwicmVuZGVyX3N0YXR1cyI6InJlbmRlcmVkIiwic2RrX2Nvbm5lY3Rvcl9mcmFtZSI6eyJvcmlnaW4iOlswLC01MDAsMF0sInhfYXhpcyI6WzAsLTEsMF0sInlfYXhpcyI6WzAsMCwxXSwiel9heGlzIjpbLTEsMCwwXX0sInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImtpbmQiOiJ1bmJvdW5kIn19LHsiY2hpbGRfZW50aXR5X2lkcyI6WyJlbnRpdHkvdmVydGV4LzAiLCJlbnRpdHkvdmVydGV4LzEiXSwiY29ubmVjdG9yX2JpbmRpbmdfc3RhdHVzIjoib3duZXJfbm90X3BhcnQiLCJlbnRpdHlfaWQiOiJlbnRpdHkvZWRnZS8zIiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJkaXJlY3Rpb24iOlsxLDAsMF0sIm9yaWdpbiI6WzAsMCwwXSwidHlwZSI6ImxpbmUifSwia2luZCI6ImVkZ2UiLCJwYXJlbnRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZmFjZS8xIiwiZW50aXR5L2ZhY2UvMiJdLCJwcm9wZXJ0aWVzIjp7ImJvdW5kcyI6eyJtYXgiOlsxMDAwLDAsMF0sIm1pbiI6WzAsMCwwXX0sImNlbnRyb2lkIjpbNTAwLDAsMF0sImxlbmd0aCI6MTAwMCwicXVhbGl0eSI6Imtlcm5lbF9ldmFsdWF0ZWQifSwicmVuZGVyX3N0YXR1cyI6InJlbmRlcmVkIiwic2RrX2Nvbm5lY3Rvcl9mcmFtZSI6eyJvcmlnaW4iOls1MDAsMCwwXSwieF9heGlzIjpbMSwwLDBdLCJ5X2F4aXMiOlswLDAsMV0sInpfYXhpcyI6WzAsLTEsMF19LCJzZGtfbWV0YWRhdGEiOnt9LCJzZW1hbnRpY19iaW5kaW5nX2lkcyI6W10sInNvdXJjZSI6eyJraW5kIjoidW5ib3VuZCJ9fSx7ImNoaWxkX2VudGl0eV9pZHMiOlsiZW50aXR5L3ZlcnRleC8xIiwiZW50aXR5L3ZlcnRleC8yIl0sImNvbm5lY3Rvcl9iaW5kaW5nX3N0YXR1cyI6Im93bmVyX25vdF9wYXJ0IiwiZW50aXR5X2lkIjoiZW50aXR5L2VkZ2UvNCIsImV2YWx1YXRlZF90YWdzIjpbXSwiZ2VvbWV0cnkiOnsiZGlyZWN0aW9uIjpbLTAuNzA3MTA2NzgxMTg2NTQ3NSwwLDAuNzA3MTA2NzgxMTg2NTQ3NV0sIm9yaWdpbiI6WzEwMDAsMCwwXSwidHlwZSI6ImxpbmUifSwia2luZCI6ImVkZ2UiLCJwYXJlbnRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZmFjZS8yIiwiZW50aXR5L2ZhY2UvMyJdLCJwcm9wZXJ0aWVzIjp7ImJvdW5kcyI6eyJtYXgiOlsxMDAwLDAsMTAwMF0sIm1pbiI6WzAsMCwwXX0sImNlbnRyb2lkIjpbNTAwLDAsNTAwXSwibGVuZ3RoIjoxNDE0LjIxMzU2MjM3MzA5NSwicXVhbGl0eSI6Imtlcm5lbF9ldmFsdWF0ZWQifSwicmVuZGVyX3N0YXR1cyI6InJlbmRlcmVkIiwic2RrX2Nvbm5lY3Rvcl9mcmFtZSI6eyJvcmlnaW4iOls1MDAsMCw1MDBdLCJ4X2F4aXMiOlstMC43MDcxMDY3ODExODY1NDc1LDAsMC43MDcxMDY3ODExODY1NDc1XSwieV9heGlzIjpbMC43MDcxMDY3ODExODY1NDc0LDAsMC43MDcxMDY3ODExODY1NDc3XSwiel9heGlzIjpbMCwxLDBdfSwic2RrX21ldGFkYXRhIjp7fSwic2VtYW50aWNfYmluZGluZ19pZHMiOltdLCJzb3VyY2UiOnsia2luZCI6InVuYm91bmQifX0seyJjaGlsZF9lbnRpdHlfaWRzIjpbImVudGl0eS92ZXJ0ZXgvMCIsImVudGl0eS92ZXJ0ZXgvMiJdLCJjb25uZWN0b3JfYmluZGluZ19zdGF0dXMiOiJvd25lcl9ub3RfcGFydCIsImVudGl0eV9pZCI6ImVudGl0eS9lZGdlLzUiLCJldmFsdWF0ZWRfdGFncyI6W10sImdlb21ldHJ5Ijp7ImRpcmVjdGlvbiI6WzAsMCwxXSwib3JpZ2luIjpbMCwwLDBdLCJ0eXBlIjoibGluZSJ9LCJraW5kIjoiZWRnZSIsInBhcmVudF9lbnRpdHlfaWRzIjpbImVudGl0eS9mYWNlLzAiLCJlbnRpdHkvZmFjZS8yIl0sInByb3BlcnRpZXMiOnsiYm91bmRzIjp7Im1heCI6WzAsMCwxMDAwXSwibWluIjpbMCwwLDBdfSwiY2VudHJvaWQiOlswLDAsNTAwXSwibGVuZ3RoIjoxMDAwLCJxdWFsaXR5Ijoia2VybmVsX2V2YWx1YXRlZCJ9LCJyZW5kZXJfc3RhdHVzIjoicmVuZGVyZWQiLCJzZGtfY29ubmVjdG9yX2ZyYW1lIjp7Im9yaWdpbiI6WzAsMCw1MDBdLCJ4X2F4aXMiOlswLDAsMV0sInlfYXhpcyI6WzAsMSwwXSwiel9heGlzIjpbLTEsMCwwXX0sInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImtpbmQiOiJ1bmJvdW5kIn19LHsiY2hpbGRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZWRnZS8wIiwiZW50aXR5L2VkZ2UvMiIsImVudGl0eS9lZGdlLzUiXSwiY29ubmVjdG9yX2JpbmRpbmdfc3RhdHVzIjoib3duZXJfbm90X3BhcnQiLCJlbnRpdHlfaWQiOiJlbnRpdHkvZmFjZS8wIiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJub3JtYWwiOlstMSwwLDBdLCJvcmlnaW4iOlswLC0zMzMuMzMzMzMzMzMzMzMzMywzMzMuMzMzMzMzMzMzMzMzM10sInR5cGUiOiJwbGFuZSIsInhfZGlyZWN0aW9uIjpbMCwwLDFdfSwia2luZCI6ImZhY2UiLCJwYXJlbnRfZW50aXR5X2lkcyI6WyJlbnRpdHkvc29saWQvMCJdLCJwcm9wZXJ0aWVzIjp7ImFyZWEiOjUwMDAwMCwiYm91bmRzIjp7Im1heCI6WzAsMCwxMDAwXSwibWluIjpbMCwtMTAwMCwwXX0sImNlbnRyb2lkIjpbMCwtMzMzLjMzMzMzMzMzMzMzMzMsMzMzLjMzMzMzMzMzMzMzMzNdLCJvcmllbnRhdGlvbiI6ImZvcndhcmQiLCJxdWFsaXR5Ijoia2VybmVsX2V2YWx1YXRlZCJ9LCJyZW5kZXJfc3RhdHVzIjoicmVuZGVyZWQiLCJzZGtfY29ubmVjdG9yX2ZyYW1lIjp7Im9yaWdpbiI6WzAsLTMzMy4zMzMzMzMzMzMzMzMzLDMzMy4zMzMzMzMzMzMzMzMzXSwieF9heGlzIjpbMCwwLDFdLCJ5X2F4aXMiOlswLDEsMF0sInpfYXhpcyI6Wy0xLDAsMF19LCJzZGtfbWV0YWRhdGEiOnt9LCJzZW1hbnRpY19iaW5kaW5nX2lkcyI6W10sInNvdXJjZSI6eyJraW5kIjoidW5ib3VuZCJ9fSx7ImNoaWxkX2VudGl0eV9pZHMiOlsiZW50aXR5L2VkZ2UvMSIsImVudGl0eS9lZGdlLzIiLCJlbnRpdHkvZWRnZS8zIl0sImNvbm5lY3Rvcl9iaW5kaW5nX3N0YXR1cyI6Im93bmVyX25vdF9wYXJ0IiwiZW50aXR5X2lkIjoiZW50aXR5L2ZhY2UvMSIsImV2YWx1YXRlZF90YWdzIjpbXSwiZ2VvbWV0cnkiOnsibm9ybWFsIjpbMCwwLC0xXSwib3JpZ2luIjpbMzMzLjMzMzMzMzMzMzMzMzMsLTMzMy4zMzMzMzMzMzMzMzMzLDBdLCJ0eXBlIjoicGxhbmUiLCJ4X2RpcmVjdGlvbiI6WzEsMCwwXX0sImtpbmQiOiJmYWNlIiwicGFyZW50X2VudGl0eV9pZHMiOlsiZW50aXR5L3NvbGlkLzAiXSwicHJvcGVydGllcyI6eyJhcmVhIjo1MDAwMDAsImJvdW5kcyI6eyJtYXgiOlsxMDAwLDAsMF0sIm1pbiI6WzAsLTEwMDAsMF19LCJjZW50cm9pZCI6WzMzMy4zMzMzMzMzMzMzMzMzLC0zMzMuMzMzMzMzMzMzMzMzMywwXSwib3JpZW50YXRpb24iOiJmb3J3YXJkIiwicXVhbGl0eSI6Imtlcm5lbF9ldmFsdWF0ZWQifSwicmVuZGVyX3N0YXR1cyI6InJlbmRlcmVkIiwic2RrX2Nvbm5lY3Rvcl9mcmFtZSI6eyJvcmlnaW4iOlszMzMuMzMzMzMzMzMzMzMzMywtMzMzLjMzMzMzMzMzMzMzMzMsMF0sInhfYXhpcyI6WzEsMCwwXSwieV9heGlzIjpbMCwtMSwwXSwiel9heGlzIjpbMCwwLC0xXX0sInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImtpbmQiOiJ1bmJvdW5kIn19LHsiY2hpbGRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZWRnZS8zIiwiZW50aXR5L2VkZ2UvNCIsImVudGl0eS9lZGdlLzUiXSwiY29ubmVjdG9yX2JpbmRpbmdfc3RhdHVzIjoib3duZXJfbm90X3BhcnQiLCJlbnRpdHlfaWQiOiJlbnRpdHkvZmFjZS8yIiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJub3JtYWwiOlswLDEsMF0sIm9yaWdpbiI6WzMzMy4zMzMzMzMzMzMzMzMzLDAsMzMzLjMzMzMzMzMzMzMzMzNdLCJ0eXBlIjoicGxhbmUiLCJ4X2RpcmVjdGlvbiI6WzEsMCwwXX0sImtpbmQiOiJmYWNlIiwicGFyZW50X2VudGl0eV9pZHMiOlsiZW50aXR5L3NvbGlkLzAiXSwicHJvcGVydGllcyI6eyJhcmVhIjo1MDAwMDAsImJvdW5kcyI6eyJtYXgiOlsxMDAwLDAsMTAwMF0sIm1pbiI6WzAsMCwwXX0sImNlbnRyb2lkIjpbMzMzLjMzMzMzMzMzMzMzMzMsMCwzMzMuMzMzMzMzMzMzMzMzM10sIm9yaWVudGF0aW9uIjoiZm9yd2FyZCIsInF1YWxpdHkiOiJrZXJuZWxfZXZhbHVhdGVkIn0sInJlbmRlcl9zdGF0dXMiOiJyZW5kZXJlZCIsInNka19jb25uZWN0b3JfZnJhbWUiOnsib3JpZ2luIjpbMzMzLjMzMzMzMzMzMzMzMzMsMCwzMzMuMzMzMzMzMzMzMzMzM10sInhfYXhpcyI6WzEsMCwwXSwieV9heGlzIjpbMCwwLC0xXSwiel9heGlzIjpbMCwxLDBdfSwic2RrX21ldGFkYXRhIjp7fSwic2VtYW50aWNfYmluZGluZ19pZHMiOltdLCJzb3VyY2UiOnsia2luZCI6InVuYm91bmQifX0seyJjaGlsZF9lbnRpdHlfaWRzIjpbImVudGl0eS9lZGdlLzAiLCJlbnRpdHkvZWRnZS8xIiwiZW50aXR5L2VkZ2UvNCJdLCJjb25uZWN0b3JfYmluZGluZ19zdGF0dXMiOiJvd25lcl9ub3RfcGFydCIsImVudGl0eV9pZCI6ImVudGl0eS9mYWNlLzMiLCJldmFsdWF0ZWRfdGFncyI6W10sImdlb21ldHJ5Ijp7Im5vcm1hbCI6WzAuNTc3MzUwMjY5MTg5NjI1OCwtMC41NzczNTAyNjkxODk2MjU4LDAuNTc3MzUwMjY5MTg5NjI1OF0sIm9yaWdpbiI6WzMzMy4zMzMzMzMzMzMzMzMzLC0zMzMuMzMzMzMzMzMzMzMzMywzMzMuMzMzMzMzMzMzMzMzM10sInR5cGUiOiJwbGFuZSIsInhfZGlyZWN0aW9uIjpbLTAuNzA3MTA2NzgxMTg2NTQ3NSwwLDAuNzA3MTA2NzgxMTg2NTQ3NV19LCJraW5kIjoiZmFjZSIsInBhcmVudF9lbnRpdHlfaWRzIjpbImVudGl0eS9zb2xpZC8wIl0sInByb3BlcnRpZXMiOnsiYXJlYSI6ODY2MDI1LjQwMzc4NDQzODYsImJvdW5kcyI6eyJtYXgiOlsxMDAwLDAsMTAwMF0sIm1pbiI6WzAsLTEwMDAsMF19LCJjZW50cm9pZCI6WzMzMy4zMzMzMzMzMzMzMzMzLC0zMzMuMzMzMzMzMzMzMzMzMywzMzMuMzMzMzMzMzMzMzMzM10sIm9yaWVudGF0aW9uIjoiZm9yd2FyZCIsInF1YWxpdHkiOiJrZXJuZWxfZXZhbHVhdGVkIn0sInJlbmRlcl9zdGF0dXMiOiJyZW5kZXJlZCIsInNka19jb25uZWN0b3JfZnJhbWUiOnsib3JpZ2luIjpbMzMzLjMzMzMzMzMzMzMzMzMsLTMzMy4zMzMzMzMzMzMzMzMzLDMzMy4zMzMzMzMzMzMzMzMzXSwieF9heGlzIjpbLTAuNzA3MTA2NzgxMTg2NTQ3NSwwLDAuNzA3MTA2NzgxMTg2NTQ3NV0sInlfYXhpcyI6Wy0wLjQwODI0ODI5MDQ2Mzg2MzEsLTAuODE2NDk2NTgwOTI3NzI2MSwtMC40MDgyNDgyOTA0NjM4NjMxXSwiel9heGlzIjpbMC41NzczNTAyNjkxODk2MjU4LC0wLjU3NzM1MDI2OTE4OTYyNTgsMC41NzczNTAyNjkxODk2MjU4XX0sInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImtpbmQiOiJ1bmJvdW5kIn19LHsiY2hpbGRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZmFjZS8wIiwiZW50aXR5L2ZhY2UvMSIsImVudGl0eS9mYWNlLzIiLCJlbnRpdHkvZmFjZS8zIl0sImNvbm5lY3Rvcl9iaW5kaW5nX3N0YXR1cyI6Im5vdF9hcHBsaWNhYmxlIiwiZW50aXR5X2lkIjoiZW50aXR5L3NvbGlkLzAiLCJldmFsdWF0ZWRfdGFncyI6W10sImdlb21ldHJ5Ijp7InR5cGUiOiJicmVwX3NvbGlkIn0sImtpbmQiOiJzb2xpZCIsInBhcmVudF9lbnRpdHlfaWRzIjpbXSwicHJvcGVydGllcyI6eyJib3VuZHMiOnsibWF4IjpbMTAwMCwwLDEwMDBdLCJtaW4iOlswLC0xMDAwLDBdfSwiY2VudHJvaWQiOlsyNTAsLTI1MCwyNTBdLCJxdWFsaXR5Ijoia2VybmVsX2V2YWx1YXRlZCIsInN1cmZhY2VfYXJlYSI6MjM2NjAyNS40MDM3ODQ0Mzg1LCJ2b2x1bWUiOjE2NjY2NjY2Ni42NjY2NjY2Nn0sInJlbmRlcl9zdGF0dXMiOiJyZW5kZXJlZCIsInNka19jb25uZWN0b3JfZnJhbWUiOm51bGwsInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImtpbmQiOiJ1bmJvdW5kIn19LHsiY2hpbGRfZW50aXR5X2lkcyI6W10sImNvbm5lY3Rvcl9iaW5kaW5nX3N0YXR1cyI6Im93bmVyX25vdF9wYXJ0IiwiZW50aXR5X2lkIjoiZW50aXR5L3ZlcnRleC8wIiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJwb3NpdGlvbiI6WzAsMCwwXSwidHlwZSI6InBvaW50In0sImtpbmQiOiJ2ZXJ0ZXgiLCJwYXJlbnRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZWRnZS8yIiwiZW50aXR5L2VkZ2UvMyIsImVudGl0eS9lZGdlLzUiXSwicHJvcGVydGllcyI6eyJib3VuZHMiOnsibWF4IjpbMCwwLDBdLCJtaW4iOlswLDAsMF19LCJwb3NpdGlvbiI6WzAsMCwwXSwicXVhbGl0eSI6Imtlcm5lbF9ldmFsdWF0ZWQifSwicmVuZGVyX3N0YXR1cyI6InJlbmRlcmVkIiwic2RrX2Nvbm5lY3Rvcl9mcmFtZSI6eyJvcmlnaW4iOlswLDAsMF0sInhfYXhpcyI6WzEsMCwwXSwieV9heGlzIjpbMCwxLDBdLCJ6X2F4aXMiOlswLDAsMV19LCJzZGtfbWV0YWRhdGEiOnt9LCJzZW1hbnRpY19iaW5kaW5nX2lkcyI6W10sInNvdXJjZSI6eyJraW5kIjoidW5ib3VuZCJ9fSx7ImNoaWxkX2VudGl0eV9pZHMiOltdLCJjb25uZWN0b3JfYmluZGluZ19zdGF0dXMiOiJvd25lcl9ub3RfcGFydCIsImVudGl0eV9pZCI6ImVudGl0eS92ZXJ0ZXgvMSIsImV2YWx1YXRlZF90YWdzIjpbXSwiZ2VvbWV0cnkiOnsicG9zaXRpb24iOlsxMDAwLDAsMF0sInR5cGUiOiJwb2ludCJ9LCJraW5kIjoidmVydGV4IiwicGFyZW50X2VudGl0eV9pZHMiOlsiZW50aXR5L2VkZ2UvMSIsImVudGl0eS9lZGdlLzMiLCJlbnRpdHkvZWRnZS80Il0sInByb3BlcnRpZXMiOnsiYm91bmRzIjp7Im1heCI6WzEwMDAsMCwwXSwibWluIjpbMTAwMCwwLDBdfSwicG9zaXRpb24iOlsxMDAwLDAsMF0sInF1YWxpdHkiOiJrZXJuZWxfZXZhbHVhdGVkIn0sInJlbmRlcl9zdGF0dXMiOiJyZW5kZXJlZCIsInNka19jb25uZWN0b3JfZnJhbWUiOnsib3JpZ2luIjpbMTAwMCwwLDBdLCJ4X2F4aXMiOlsxLDAsMF0sInlfYXhpcyI6WzAsMSwwXSwiel9heGlzIjpbMCwwLDFdfSwic2RrX21ldGFkYXRhIjp7fSwic2VtYW50aWNfYmluZGluZ19pZHMiOltdLCJzb3VyY2UiOnsia2luZCI6InVuYm91bmQifX0seyJjaGlsZF9lbnRpdHlfaWRzIjpbXSwiY29ubmVjdG9yX2JpbmRpbmdfc3RhdHVzIjoib3duZXJfbm90X3BhcnQiLCJlbnRpdHlfaWQiOiJlbnRpdHkvdmVydGV4LzIiLCJldmFsdWF0ZWRfdGFncyI6W10sImdlb21ldHJ5Ijp7InBvc2l0aW9uIjpbMCwwLDEwMDBdLCJ0eXBlIjoicG9pbnQifSwia2luZCI6InZlcnRleCIsInBhcmVudF9lbnRpdHlfaWRzIjpbImVudGl0eS9lZGdlLzAiLCJlbnRpdHkvZWRnZS80IiwiZW50aXR5L2VkZ2UvNSJdLCJwcm9wZXJ0aWVzIjp7ImJvdW5kcyI6eyJtYXgiOlswLDAsMTAwMF0sIm1pbiI6WzAsMCwxMDAwXX0sInBvc2l0aW9uIjpbMCwwLDEwMDBdLCJxdWFsaXR5Ijoia2VybmVsX2V2YWx1YXRlZCJ9LCJyZW5kZXJfc3RhdHVzIjoicmVuZGVyZWQiLCJzZGtfY29ubmVjdG9yX2ZyYW1lIjp7Im9yaWdpbiI6WzAsMCwxMDAwXSwieF9heGlzIjpbMSwwLDBdLCJ5X2F4aXMiOlswLDEsMF0sInpfYXhpcyI6WzAsMCwxXX0sInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImtpbmQiOiJ1bmJvdW5kIn19LHsiY2hpbGRfZW50aXR5X2lkcyI6W10sImNvbm5lY3Rvcl9iaW5kaW5nX3N0YXR1cyI6Im93bmVyX25vdF9wYXJ0IiwiZW50aXR5X2lkIjoiZW50aXR5L3ZlcnRleC8zIiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJwb3NpdGlvbiI6WzAsLTEwMDAsMF0sInR5cGUiOiJwb2ludCJ9LCJraW5kIjoidmVydGV4IiwicGFyZW50X2VudGl0eV9pZHMiOlsiZW50aXR5L2VkZ2UvMCIsImVudGl0eS9lZGdlLzEiLCJlbnRpdHkvZWRnZS8yIl0sInByb3BlcnRpZXMiOnsiYm91bmRzIjp7Im1heCI6WzAsLTEwMDAsMF0sIm1pbiI6WzAsLTEwMDAsMF19LCJwb3NpdGlvbiI6WzAsLTEwMDAsMF0sInF1YWxpdHkiOiJrZXJuZWxfZXZhbHVhdGVkIn0sInJlbmRlcl9zdGF0dXMiOiJyZW5kZXJlZCIsInNka19jb25uZWN0b3JfZnJhbWUiOnsib3JpZ2luIjpbMCwtMTAwMCwwXSwieF9heGlzIjpbMSwwLDBdLCJ5X2F4aXMiOlswLDEsMF0sInpfYXhpcyI6WzAsMCwxXX0sInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImtpbmQiOiJ1bmJvdW5kIn19XSwiZmFjZV9ncm91cHMiOlt7ImVudGl0eV9pZCI6ImVudGl0eS9mYWNlLzAiLCJmaXJzdF9pbmRleCI6MCwiZ3JvdXBfaWQiOjAsImluZGV4X2NvdW50IjozLCJtZXNoX2luZGV4IjowLCJwcmltaXRpdmVfaW5kZXgiOjB9LHsiZW50aXR5X2lkIjoiZW50aXR5L2ZhY2UvMSIsImZpcnN0X2luZGV4IjozLCJncm91cF9pZCI6MSwiaW5kZXhfY291bnQiOjMsIm1lc2hfaW5kZXgiOjAsInByaW1pdGl2ZV9pbmRleCI6MH0seyJlbnRpdHlfaWQiOiJlbnRpdHkvZmFjZS8yIiwiZmlyc3RfaW5kZXgiOjYsImdyb3VwX2lkIjoyLCJpbmRleF9jb3VudCI6MywibWVzaF9pbmRleCI6MCwicHJpbWl0aXZlX2luZGV4IjowfSx7ImVudGl0eV9pZCI6ImVudGl0eS9mYWNlLzMiLCJmaXJzdF9pbmRleCI6OSwiZ3JvdXBfaWQiOjMsImluZGV4X2NvdW50IjozLCJtZXNoX2luZGV4IjowLCJwcmltaXRpdmVfaW5kZXgiOjB9XSwiZ2VvbWV0cnlfYXNzZXRfaWQiOiJzaGEyNTY6MjAwNTIzNDE0ZGUyODFhOGExNTUwODZhNTRjZDBmODgwZjY0MDQ4NTc2NWE0OGNjZGQ4ZWMyMzBmNjdkZDE3YyIsImdlb21ldHJ5X2VuZ2luZSI6eyJuYW1lIjoiT3BlbkNhc2NhZGUiLCJwcm9maWxlIjoib2NwLWV2YWx1YXRlZC1wcm9wZXJ0aWVzLTEiLCJ2ZXJzaW9uIjoiZml4dHVyZSJ9LCJzY2hlbWFfdmVyc2lvbiI6IjEuMCJ9", + "sha256": "sha256:3897cc9ebdb2941835964a435821b556e01b175535cff8fc0eaaafb684c4ddf5", + "uri": "entities/sha256-3897cc9ebdb2941835964a435821b556e01b175535cff8fc0eaaafb684c4ddf5.json" + }, + "geometry_glb": { + "base64": "Z2xURgIAAAD8AwAAqAIAAEpTT057ImFjY2Vzc29ycyI6W3siYnVmZmVyVmlldyI6MCwiY29tcG9uZW50VHlwZSI6NTEyNiwiY291bnQiOjEyLCJtYXgiOlsxLDEsMV0sIm1pbiI6WzAsMCwwXSwidHlwZSI6IlZFQzMifSx7ImJ1ZmZlclZpZXciOjEsImNvbXBvbmVudFR5cGUiOjUxMjYsImNvdW50IjoxMiwidHlwZSI6IlZFQzMifSx7ImJ1ZmZlclZpZXciOjIsImNvbXBvbmVudFR5cGUiOjUxMjMsImNvdW50IjoxMiwidHlwZSI6IlNDQUxBUiJ9XSwiYXNzZXQiOnsiZ2VuZXJhdG9yIjoiU2ltcGxlQ0FEIFNjZW5lIEdMQiBQcm9maWxlIDEiLCJ2ZXJzaW9uIjoiMi4wIn0sImJ1ZmZlclZpZXdzIjpbeyJidWZmZXIiOjAsImJ5dGVMZW5ndGgiOjE0NCwiYnl0ZU9mZnNldCI6MCwidGFyZ2V0IjozNDk2Mn0seyJidWZmZXIiOjAsImJ5dGVMZW5ndGgiOjE0NCwiYnl0ZU9mZnNldCI6MTQ0LCJ0YXJnZXQiOjM0OTYyfSx7ImJ1ZmZlciI6MCwiYnl0ZUxlbmd0aCI6MjQsImJ5dGVPZmZzZXQiOjI4OCwidGFyZ2V0IjozNDk2M31dLCJidWZmZXJzIjpbeyJieXRlTGVuZ3RoIjozMTJ9XSwibWVzaGVzIjpbeyJwcmltaXRpdmVzIjpbeyJhdHRyaWJ1dGVzIjp7Ik5PUk1BTCI6MSwiUE9TSVRJT04iOjB9LCJpbmRpY2VzIjoyLCJtb2RlIjo0fV19XSwibm9kZXMiOlt7Im1lc2giOjB9XSwic2NlbmUiOjAsInNjZW5lcyI6W3sibm9kZXMiOlswXX1dfTgBAABCSU4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIA/AAAAAAAAgD8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIA/AACAPwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgD8AAAAAAACAPwAAAAAAAAAAAAAAAAAAAAAAAIA/AAAAAAAAgD8AAAAAAACAPwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/Os0TPzrNEz86zRM/Os0TPzrNEz86zRM/Os0TPzrNEz86zRM/AAABAAIAAwAFAAQABgAHAAgACQALAAoA", + "sha256": "sha256:200523414de281a8a155086a54cd0f880f640485765a48ccdd8ec230f67dd17c", + "uri": "geometry/sha256-200523414de281a8a155086a54cd0f880f640485765a48ccdd8ec230f67dd17c.glb" + }, + "scene": { + "base64": "eyJhbm5vdGF0aW9ucyI6W10sImFwcGVhcmFuY2VzIjpbeyJhbHBoYV9tb2RlIjoib3BhcXVlIiwiYXBwZWFyYW5jZV9pZCI6ImFwcGVhcmFuY2UvZXZhbHVhdGVkL2JmMWNhNmQ2MGFmMjE3NjdhY2UxNDE5YjYwNTUyMTdjODcxNDU4OWI3OTA5MGQyNzBhYWJlNTEyMjMwZWZhM2EiLCJiYXNlX2NvbG9yIjpbMC43MiwwLjc1LDAuNzgsMV0sImRvdWJsZV9zaWRlZCI6ZmFsc2UsImVkZ2VfY29sb3IiOlswLjA4LDAuMDksMC4xLDFdLCJtZXRhbGxpYyI6MCwibmFtZSI6bnVsbCwicm91Z2huZXNzIjowLjU1LCJzZGtfbWV0YWRhdGEiOnt9LCJzb3VyY2UiOm51bGx9XSwiY2FtZXJhcyI6W10sImNvbXBpbGVfb3B0aW9ucyI6eyJhbmd1bGFyX3RvbGVyYW5jZSI6MC4yMiwiZW1iZWRfcHJlc2VudGF0aW9uIjpmYWxzZSwiZW1iZWRfc291cmNlIjpmYWxzZSwibGluZWFyX3RvbGVyYW5jZSI6MC4zNX0sImNvbm5lY3RvcnMiOltdLCJjb29yZGluYXRlX3N5c3RlbSI6eyJoYW5kZWRuZXNzIjoicmlnaHQiLCJsZW5ndGhfdW5pdCI6Im1tIiwidXBfYXhpcyI6IitaIn0sImRlZmluaXRpb25zIjpbeyJhcHBlYXJhbmNlX2lkIjoiYXBwZWFyYW5jZS9ldmFsdWF0ZWQvYmYxY2E2ZDYwYWYyMTc2N2FjZTE0MTliNjA1NTIxN2M4NzE0NTg5Yjc5MDkwZDI3MGFhYmU1MTIyMzBlZmEzYSIsImRlZmluaXRpb25faWQiOiJkZWZpbml0aW9uL3Jvb3Qvc2hhcGUvbWFudWFsL2ZpeHR1cmUiLCJlZGdlX2Fzc2V0X2lkIjoic2hhMjU2OjZjZTQ4MWRiMWZlMTNiY2ZlN2EyMzc0NDIzZTZjNzJkNzU3ZDUxYzk5MTY4YjI4N2M1MmY1M2FhNjI1Y2NkNjciLCJlbnRpdHlfYXNzZXRfaWQiOiJzaGEyNTY6Mzg5N2NjOWViZGIyOTQxODM1OTY0YTQzNTgyMWI1NTZlMDFiMTc1NTM1Y2ZmOGZjMGVhYWFmYjY4NGM0ZGRmNSIsImdlb21ldHJ5X2Fzc2V0X2lkIjoic2hhMjU2OjIwMDUyMzQxNGRlMjgxYThhMTU1MDg2YTU0Y2QwZjg4MGY2NDA0ODU3NjVhNDhjY2RkOGVjMjMwZjY3ZGQxN2MiLCJraW5kIjoic2hhcGUiLCJuYW1lIjpudWxsLCJzZGtfbWV0YWRhdGEiOnt9LCJzb3VyY2UiOnsia2luZCI6Im1hbnVhbCIsInJvb3RfaWQiOiJyb290Iiwic291cmNlX2lkIjoiZml4dHVyZSJ9fV0sImRpYWdub3N0aWNzIjpbXSwiZWRnZV9hc3NldHMiOlt7ImFzc2V0X2lkIjoic2hhMjU2OjZjZTQ4MWRiMWZlMTNiY2ZlN2EyMzc0NDIzZTZjNzJkNzU3ZDUxYzk5MTY4YjI4N2M1MmY1M2FhNjI1Y2NkNjciLCJhc3NldF90b19zY2VuZSI6WzEwMDAsMCwwLDAsMCwwLC0xMDAwLDAsMCwxMDAwLDAsMCwwLDAsMCwxXSwiYnl0ZV9sZW5ndGgiOjc0MCwiY29udGVudF9oYXNoIjoic2hhMjU2OjZjZTQ4MWRiMWZlMTNiY2ZlN2EyMzc0NDIzZTZjNzJkNzU3ZDUxYzk5MTY4YjI4N2M1MmY1M2FhNjI1Y2NkNjciLCJtZWRpYV90eXBlIjoibW9kZWwvZ2x0Zi1iaW5hcnkiLCJzY2VuZV9sb2NhbF9ib3VuZHMiOnsibWF4IjpbMTAwMCwwLDEwMDBdLCJtaW4iOlswLC0xMDAwLDBdfSwidGVzc2VsbGF0aW9uIjp7ImxpbmVhcl90b2xlcmFuY2UiOjAuMzV9LCJ1cmkiOiJlZGdlcy9zaGEyNTYtNmNlNDgxZGIxZmUxM2JjZmU3YTIzNzQ0MjNlNmM3MmQ3NTdkNTFjOTkxNjhiMjg3YzUyZjUzYWE2MjVjY2Q2Ny5nbGIifV0sImVudGl0eV9hc3NldHMiOlt7ImJ5dGVfbGVuZ3RoIjoxMTUxNywiY29udGVudF9oYXNoIjoic2hhMjU2OjM4OTdjYzllYmRiMjk0MTgzNTk2NGE0MzU4MjFiNTU2ZTAxYjE3NTUzNWNmZjhmYzBlYWFhZmI2ODRjNGRkZjUiLCJlbnRpdHlfYXNzZXRfaWQiOiJzaGEyNTY6Mzg5N2NjOWViZGIyOTQxODM1OTY0YTQzNTgyMWI1NTZlMDFiMTc1NTM1Y2ZmOGZjMGVhYWFmYjY4NGM0ZGRmNSIsIm1lZGlhX3R5cGUiOiJhcHBsaWNhdGlvbi92bmQuc2ltcGxlY2FkLmVudGl0aWVzK2pzb24iLCJ1cmkiOiJlbnRpdGllcy9zaGEyNTYtMzg5N2NjOWViZGIyOTQxODM1OTY0YTQzNTgyMWI1NTZlMDFiMTc1NTM1Y2ZmOGZjMGVhYWFmYjY4NGM0ZGRmNS5qc29uIn1dLCJleHRlbnNpb25zIjp7fSwiZXh0ZW5zaW9uc19yZXF1aXJlZCI6W10sImV4dGVuc2lvbnNfdXNlZCI6W10sImdlbmVyYXRvciI6eyJuYW1lIjoic2ltcGxlY2FkYXBpIiwib2NwX2JpbmRpbmdzX3ZlcnNpb24iOiJmaXh0dXJlIiwib2NwX3ZlcnNpb24iOiJmaXh0dXJlIiwicGxhdGZvcm1fdGFnIjoiZml4dHVyZSIsInByb2ZpbGUiOiJzY2VuZS0xLjAtb2NwLWdsYi0yIiwicHl0aG9uX2FiaSI6ImZpeHR1cmUiLCJzaW1wbGVjYWRhcGlfdmVyc2lvbiI6ImZpeHR1cmUiLCJ0b29sY2hhaW5faGFzaCI6InNoYTI1NjowMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwIn0sImdlb21ldHJ5X2Fzc2V0cyI6W3siYXNzZXRfaWQiOiJzaGEyNTY6MjAwNTIzNDE0ZGUyODFhOGExNTUwODZhNTRjZDBmODgwZjY0MDQ4NTc2NWE0OGNjZGQ4ZWMyMzBmNjdkZDE3YyIsImFzc2V0X3RvX3NjZW5lIjpbMTAwMCwwLDAsMCwwLDAsLTEwMDAsMCwwLDEwMDAsMCwwLDAsMCwwLDFdLCJieXRlX2xlbmd0aCI6MTAyMCwiY29udGVudF9oYXNoIjoic2hhMjU2OjIwMDUyMzQxNGRlMjgxYThhMTU1MDg2YTU0Y2QwZjg4MGY2NDA0ODU3NjVhNDhjY2RkOGVjMjMwZjY3ZGQxN2MiLCJtZWRpYV90eXBlIjoibW9kZWwvZ2x0Zi1iaW5hcnkiLCJzY2VuZV9sb2NhbF9ib3VuZHMiOnsibWF4IjpbMTAwMCwwLDEwMDBdLCJtaW4iOlswLC0xMDAwLDBdfSwidGVzc2VsbGF0aW9uIjp7ImFuZ3VsYXJfdG9sZXJhbmNlIjowLjIyLCJsaW5lYXJfdG9sZXJhbmNlIjowLjM1fSwidXJpIjoiZ2VvbWV0cnkvc2hhMjU2LTIwMDUyMzQxNGRlMjgxYThhMTU1MDg2YTU0Y2QwZjg4MGY2NDA0ODU3NjVhNDhjY2RkOGVjMjMwZjY3ZGQxN2MuZ2xiIn1dLCJsaWdodHMiOltdLCJub2RlcyI6W3siYXBwZWFyYW5jZV9vdmVycmlkZV9pZCI6bnVsbCwiZGVmaW5pdGlvbl9pZCI6ImRlZmluaXRpb24vcm9vdC9zaGFwZS9tYW51YWwvZml4dHVyZSIsIm5hbWUiOm51bGwsIm5vZGVfaWQiOiJpbnN0YW5jZS9yb290Iiwib3JkZXIiOjAsInBhcmVudF9ub2RlX2lkIjpudWxsLCJzZGtfbWV0YWRhdGEiOnt9LCJzZWxlY3RhYmxlIjp0cnVlLCJzb3VyY2UiOnsia2luZCI6InNoYXBlX3Jvb3QiLCJyb290X2lkIjoicm9vdCJ9LCJ0cmFuc2Zvcm0iOnsib3JpZ2luIjpbMCwwLDBdLCJ4X2F4aXMiOlsxLDAsMF0sInlfYXhpcyI6WzAsMSwwXSwiel9heGlzIjpbMCwwLDFdfSwidmlzaWJsZSI6dHJ1ZX1dLCJyZXZpc2lvbiI6InNoYTI1NjpmYWUxNWY0YWVlZjEyNzdjZDgzMWZmOGJmODNmOWIyNDRkNWM0MjI1ZTcyZWI3YmMyZTBlMzVkZDZjZjQzNjE0Iiwic2NlbmVfaWQiOiJmaXh0dXJlIiwic2NoZW1hX3ZlcnNpb24iOiIxLjAiLCJzb3VyY2UiOnsia2luZCI6Im1hbnVhbCIsInNvdXJjZV9pZCI6ImZpeHR1cmUifX0=", + "revision": "sha256:fae15f4aeef1277cd831ff8bf83f9b244d5c4225e72eb7bc2e0e35dd6cf43614", + "sha256": "sha256:8637a4721af859fcd68ceff8522d79a6f101e29e6c8eab39fa74c9037ab15001" + } + }, + "blobs": { + "edges/sha256-6ce481db1fe13bcfe7a2374423e6c72d757d51c99168b287c52f53aa625ccd67.glb": "Z2xURgIAAADkAgAAIAIAAEpTT057ImFjY2Vzc29ycyI6W3siYnVmZmVyVmlldyI6MCwiY29tcG9uZW50VHlwZSI6NTEyNiwiY291bnQiOjEyLCJtYXgiOlsxLDEsMV0sIm1pbiI6WzAsMCwwXSwidHlwZSI6IlZFQzMifSx7ImJ1ZmZlclZpZXciOjEsImNvbXBvbmVudFR5cGUiOjUxMjMsImNvdW50IjoxMiwidHlwZSI6IlNDQUxBUiJ9XSwiYXNzZXQiOnsiZ2VuZXJhdG9yIjoiU2ltcGxlQ0FEIFNjZW5lIEdMQiBQcm9maWxlIDEiLCJ2ZXJzaW9uIjoiMi4wIn0sImJ1ZmZlclZpZXdzIjpbeyJidWZmZXIiOjAsImJ5dGVMZW5ndGgiOjE0NCwiYnl0ZU9mZnNldCI6MCwidGFyZ2V0IjozNDk2Mn0seyJidWZmZXIiOjAsImJ5dGVMZW5ndGgiOjI0LCJieXRlT2Zmc2V0IjoxNDQsInRhcmdldCI6MzQ5NjN9XSwiYnVmZmVycyI6W3siYnl0ZUxlbmd0aCI6MTY4fV0sIm1lc2hlcyI6W3sicHJpbWl0aXZlcyI6W3siYXR0cmlidXRlcyI6eyJQT1NJVElPTiI6MH0sImluZGljZXMiOjEsIm1vZGUiOjF9XX1dLCJub2RlcyI6W3sibWVzaCI6MH1dLCJzY2VuZSI6MCwic2NlbmVzIjpbeyJub2RlcyI6WzBdfV19qAAAAEJJTgAAAAAAAAAAAAAAgD8AAAAAAACAPwAAAAAAAAAAAAAAAAAAgD8AAIA/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgD8AAAAAAAAAAAAAAAAAAIA/AAAAAAAAAAAAAAAAAACAPwAAAAAAAIA/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAPwAAAAAAAAEAAgADAAQABQAGAAcACAAJAAoACwA=", + "entities/sha256-3596be6227d88f9e2ed5d17168162bdcfc6443f2cc31279821b84816c7af8f88.json": "eyJkZWZpbml0aW9uX2lkIjoiZGVmaW5pdGlvbi9yb290L3NoYXBlL2ltcG9ydGVkL2VsZW1lbnQiLCJlZGdlX2Fzc2V0X2lkIjoic2hhMjU2OjZjZTQ4MWRiMWZlMTNiY2ZlN2EyMzc0NDIzZTZjNzJkNzU3ZDUxYzk5MTY4YjI4N2M1MmY1M2FhNjI1Y2NkNjciLCJlZGdlX2dyb3VwcyI6W3siZW50aXR5X2lkIjoiZW50aXR5L2VkZ2UvMCIsImZpcnN0X2luZGV4IjowLCJncm91cF9pZCI6MCwiaW5kZXhfY291bnQiOjIsIm1lc2hfaW5kZXgiOjAsInByaW1pdGl2ZV9pbmRleCI6MH0seyJlbnRpdHlfaWQiOiJlbnRpdHkvZWRnZS8xIiwiZmlyc3RfaW5kZXgiOjIsImdyb3VwX2lkIjoxLCJpbmRleF9jb3VudCI6MiwibWVzaF9pbmRleCI6MCwicHJpbWl0aXZlX2luZGV4IjowfSx7ImVudGl0eV9pZCI6ImVudGl0eS9lZGdlLzIiLCJmaXJzdF9pbmRleCI6NCwiZ3JvdXBfaWQiOjIsImluZGV4X2NvdW50IjoyLCJtZXNoX2luZGV4IjowLCJwcmltaXRpdmVfaW5kZXgiOjB9LHsiZW50aXR5X2lkIjoiZW50aXR5L2VkZ2UvMyIsImZpcnN0X2luZGV4Ijo2LCJncm91cF9pZCI6MywiaW5kZXhfY291bnQiOjIsIm1lc2hfaW5kZXgiOjAsInByaW1pdGl2ZV9pbmRleCI6MH0seyJlbnRpdHlfaWQiOiJlbnRpdHkvZWRnZS80IiwiZmlyc3RfaW5kZXgiOjgsImdyb3VwX2lkIjo0LCJpbmRleF9jb3VudCI6MiwibWVzaF9pbmRleCI6MCwicHJpbWl0aXZlX2luZGV4IjowfSx7ImVudGl0eV9pZCI6ImVudGl0eS9lZGdlLzUiLCJmaXJzdF9pbmRleCI6MTAsImdyb3VwX2lkIjo1LCJpbmRleF9jb3VudCI6MiwibWVzaF9pbmRleCI6MCwicHJpbWl0aXZlX2luZGV4IjowfV0sImVudGl0aWVzIjpbeyJjaGlsZF9lbnRpdHlfaWRzIjpbImVudGl0eS92ZXJ0ZXgvMiIsImVudGl0eS92ZXJ0ZXgvMyJdLCJjb25uZWN0b3JfYmluZGluZ19zdGF0dXMiOiJvd25lcl9ub3RfcGFydCIsImVudGl0eV9pZCI6ImVudGl0eS9lZGdlLzAiLCJldmFsdWF0ZWRfdGFncyI6W10sImdlb21ldHJ5Ijp7ImRpcmVjdGlvbiI6WzAsLTAuNzA3MTA2NzgxMTg2NTQ3NSwtMC43MDcxMDY3ODExODY1NDc1XSwib3JpZ2luIjpbMCwwLDEwMDBdLCJ0eXBlIjoibGluZSJ9LCJraW5kIjoiZWRnZSIsInBhcmVudF9lbnRpdHlfaWRzIjpbImVudGl0eS9mYWNlLzAiLCJlbnRpdHkvZmFjZS8zIl0sInByb3BlcnRpZXMiOnsiYm91bmRzIjp7Im1heCI6WzAsMCwxMDAwXSwibWluIjpbMCwtMTAwMCwwXX0sImNlbnRyb2lkIjpbMCwtNTAwLDUwMF0sImxlbmd0aCI6MTQxNC4yMTM1NjIzNzMwOTUsInF1YWxpdHkiOiJrZXJuZWxfZXZhbHVhdGVkIn0sInJlbmRlcl9zdGF0dXMiOiJyZW5kZXJlZCIsInNka19jb25uZWN0b3JfZnJhbWUiOnsib3JpZ2luIjpbMCwtNTAwLDUwMF0sInhfYXhpcyI6WzAsLTAuNzA3MTA2NzgxMTg2NTQ3NSwtMC43MDcxMDY3ODExODY1NDc1XSwieV9heGlzIjpbMCwtMC43MDcxMDY3ODExODY1NDc0LDAuNzA3MTA2NzgxMTg2NTQ3N10sInpfYXhpcyI6Wy0xLDAsMF19LCJzZGtfbWV0YWRhdGEiOnt9LCJzZW1hbnRpY19iaW5kaW5nX2lkcyI6W10sInNvdXJjZSI6eyJraW5kIjoiaW1wb3J0ZWRfcHJpbWl0aXZlIiwic291cmNlX2VsZW1lbnRfaWQiOiJlbnRpdHkvZWRnZS8wIn19LHsiY2hpbGRfZW50aXR5X2lkcyI6WyJlbnRpdHkvdmVydGV4LzEiLCJlbnRpdHkvdmVydGV4LzMiXSwiY29ubmVjdG9yX2JpbmRpbmdfc3RhdHVzIjoib3duZXJfbm90X3BhcnQiLCJlbnRpdHlfaWQiOiJlbnRpdHkvZWRnZS8xIiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJkaXJlY3Rpb24iOlstMC43MDcxMDY3ODExODY1NDc1LC0wLjcwNzEwNjc4MTE4NjU0NzUsMF0sIm9yaWdpbiI6WzEwMDAsMCwwXSwidHlwZSI6ImxpbmUifSwia2luZCI6ImVkZ2UiLCJwYXJlbnRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZmFjZS8xIiwiZW50aXR5L2ZhY2UvMyJdLCJwcm9wZXJ0aWVzIjp7ImJvdW5kcyI6eyJtYXgiOlsxMDAwLDAsMF0sIm1pbiI6WzAsLTEwMDAsMF19LCJjZW50cm9pZCI6WzUwMCwtNTAwLDBdLCJsZW5ndGgiOjE0MTQuMjEzNTYyMzczMDk1LCJxdWFsaXR5Ijoia2VybmVsX2V2YWx1YXRlZCJ9LCJyZW5kZXJfc3RhdHVzIjoicmVuZGVyZWQiLCJzZGtfY29ubmVjdG9yX2ZyYW1lIjp7Im9yaWdpbiI6WzUwMCwtNTAwLDBdLCJ4X2F4aXMiOlstMC43MDcxMDY3ODExODY1NDc1LC0wLjcwNzEwNjc4MTE4NjU0NzUsMF0sInlfYXhpcyI6WzAsMCwxXSwiel9heGlzIjpbLTAuNzA3MTA2NzgxMTg2NTQ3NiwwLjcwNzEwNjc4MTE4NjU0NzYsMF19LCJzZGtfbWV0YWRhdGEiOnt9LCJzZW1hbnRpY19iaW5kaW5nX2lkcyI6W10sInNvdXJjZSI6eyJraW5kIjoiaW1wb3J0ZWRfcHJpbWl0aXZlIiwic291cmNlX2VsZW1lbnRfaWQiOiJlbnRpdHkvZWRnZS8xIn19LHsiY2hpbGRfZW50aXR5X2lkcyI6WyJlbnRpdHkvdmVydGV4LzAiLCJlbnRpdHkvdmVydGV4LzMiXSwiY29ubmVjdG9yX2JpbmRpbmdfc3RhdHVzIjoib3duZXJfbm90X3BhcnQiLCJlbnRpdHlfaWQiOiJlbnRpdHkvZWRnZS8yIiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJkaXJlY3Rpb24iOlswLC0xLDBdLCJvcmlnaW4iOlswLDAsMF0sInR5cGUiOiJsaW5lIn0sImtpbmQiOiJlZGdlIiwicGFyZW50X2VudGl0eV9pZHMiOlsiZW50aXR5L2ZhY2UvMCIsImVudGl0eS9mYWNlLzEiXSwicHJvcGVydGllcyI6eyJib3VuZHMiOnsibWF4IjpbMCwwLDBdLCJtaW4iOlswLC0xMDAwLDBdfSwiY2VudHJvaWQiOlswLC01MDAsMF0sImxlbmd0aCI6MTAwMCwicXVhbGl0eSI6Imtlcm5lbF9ldmFsdWF0ZWQifSwicmVuZGVyX3N0YXR1cyI6InJlbmRlcmVkIiwic2RrX2Nvbm5lY3Rvcl9mcmFtZSI6eyJvcmlnaW4iOlswLC01MDAsMF0sInhfYXhpcyI6WzAsLTEsMF0sInlfYXhpcyI6WzAsMCwxXSwiel9heGlzIjpbLTEsMCwwXX0sInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImtpbmQiOiJpbXBvcnRlZF9wcmltaXRpdmUiLCJzb3VyY2VfZWxlbWVudF9pZCI6ImVudGl0eS9lZGdlLzIifX0seyJjaGlsZF9lbnRpdHlfaWRzIjpbImVudGl0eS92ZXJ0ZXgvMCIsImVudGl0eS92ZXJ0ZXgvMSJdLCJjb25uZWN0b3JfYmluZGluZ19zdGF0dXMiOiJvd25lcl9ub3RfcGFydCIsImVudGl0eV9pZCI6ImVudGl0eS9lZGdlLzMiLCJldmFsdWF0ZWRfdGFncyI6W10sImdlb21ldHJ5Ijp7ImRpcmVjdGlvbiI6WzEsMCwwXSwib3JpZ2luIjpbMCwwLDBdLCJ0eXBlIjoibGluZSJ9LCJraW5kIjoiZWRnZSIsInBhcmVudF9lbnRpdHlfaWRzIjpbImVudGl0eS9mYWNlLzEiLCJlbnRpdHkvZmFjZS8yIl0sInByb3BlcnRpZXMiOnsiYm91bmRzIjp7Im1heCI6WzEwMDAsMCwwXSwibWluIjpbMCwwLDBdfSwiY2VudHJvaWQiOls1MDAsMCwwXSwibGVuZ3RoIjoxMDAwLCJxdWFsaXR5Ijoia2VybmVsX2V2YWx1YXRlZCJ9LCJyZW5kZXJfc3RhdHVzIjoicmVuZGVyZWQiLCJzZGtfY29ubmVjdG9yX2ZyYW1lIjp7Im9yaWdpbiI6WzUwMCwwLDBdLCJ4X2F4aXMiOlsxLDAsMF0sInlfYXhpcyI6WzAsMCwxXSwiel9heGlzIjpbMCwtMSwwXX0sInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImtpbmQiOiJpbXBvcnRlZF9wcmltaXRpdmUiLCJzb3VyY2VfZWxlbWVudF9pZCI6ImVudGl0eS9lZGdlLzMifX0seyJjaGlsZF9lbnRpdHlfaWRzIjpbImVudGl0eS92ZXJ0ZXgvMSIsImVudGl0eS92ZXJ0ZXgvMiJdLCJjb25uZWN0b3JfYmluZGluZ19zdGF0dXMiOiJvd25lcl9ub3RfcGFydCIsImVudGl0eV9pZCI6ImVudGl0eS9lZGdlLzQiLCJldmFsdWF0ZWRfdGFncyI6W10sImdlb21ldHJ5Ijp7ImRpcmVjdGlvbiI6Wy0wLjcwNzEwNjc4MTE4NjU0NzUsMCwwLjcwNzEwNjc4MTE4NjU0NzVdLCJvcmlnaW4iOlsxMDAwLDAsMF0sInR5cGUiOiJsaW5lIn0sImtpbmQiOiJlZGdlIiwicGFyZW50X2VudGl0eV9pZHMiOlsiZW50aXR5L2ZhY2UvMiIsImVudGl0eS9mYWNlLzMiXSwicHJvcGVydGllcyI6eyJib3VuZHMiOnsibWF4IjpbMTAwMCwwLDEwMDBdLCJtaW4iOlswLDAsMF19LCJjZW50cm9pZCI6WzUwMCwwLDUwMF0sImxlbmd0aCI6MTQxNC4yMTM1NjIzNzMwOTUsInF1YWxpdHkiOiJrZXJuZWxfZXZhbHVhdGVkIn0sInJlbmRlcl9zdGF0dXMiOiJyZW5kZXJlZCIsInNka19jb25uZWN0b3JfZnJhbWUiOnsib3JpZ2luIjpbNTAwLDAsNTAwXSwieF9heGlzIjpbLTAuNzA3MTA2NzgxMTg2NTQ3NSwwLDAuNzA3MTA2NzgxMTg2NTQ3NV0sInlfYXhpcyI6WzAuNzA3MTA2NzgxMTg2NTQ3NCwwLDAuNzA3MTA2NzgxMTg2NTQ3N10sInpfYXhpcyI6WzAsMSwwXX0sInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImtpbmQiOiJpbXBvcnRlZF9wcmltaXRpdmUiLCJzb3VyY2VfZWxlbWVudF9pZCI6ImVudGl0eS9lZGdlLzQifX0seyJjaGlsZF9lbnRpdHlfaWRzIjpbImVudGl0eS92ZXJ0ZXgvMCIsImVudGl0eS92ZXJ0ZXgvMiJdLCJjb25uZWN0b3JfYmluZGluZ19zdGF0dXMiOiJvd25lcl9ub3RfcGFydCIsImVudGl0eV9pZCI6ImVudGl0eS9lZGdlLzUiLCJldmFsdWF0ZWRfdGFncyI6W10sImdlb21ldHJ5Ijp7ImRpcmVjdGlvbiI6WzAsMCwxXSwib3JpZ2luIjpbMCwwLDBdLCJ0eXBlIjoibGluZSJ9LCJraW5kIjoiZWRnZSIsInBhcmVudF9lbnRpdHlfaWRzIjpbImVudGl0eS9mYWNlLzAiLCJlbnRpdHkvZmFjZS8yIl0sInByb3BlcnRpZXMiOnsiYm91bmRzIjp7Im1heCI6WzAsMCwxMDAwXSwibWluIjpbMCwwLDBdfSwiY2VudHJvaWQiOlswLDAsNTAwXSwibGVuZ3RoIjoxMDAwLCJxdWFsaXR5Ijoia2VybmVsX2V2YWx1YXRlZCJ9LCJyZW5kZXJfc3RhdHVzIjoicmVuZGVyZWQiLCJzZGtfY29ubmVjdG9yX2ZyYW1lIjp7Im9yaWdpbiI6WzAsMCw1MDBdLCJ4X2F4aXMiOlswLDAsMV0sInlfYXhpcyI6WzAsMSwwXSwiel9heGlzIjpbLTEsMCwwXX0sInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImtpbmQiOiJpbXBvcnRlZF9wcmltaXRpdmUiLCJzb3VyY2VfZWxlbWVudF9pZCI6ImVudGl0eS9lZGdlLzUifX0seyJjaGlsZF9lbnRpdHlfaWRzIjpbImVudGl0eS9lZGdlLzAiLCJlbnRpdHkvZWRnZS8yIiwiZW50aXR5L2VkZ2UvNSJdLCJjb25uZWN0b3JfYmluZGluZ19zdGF0dXMiOiJvd25lcl9ub3RfcGFydCIsImVudGl0eV9pZCI6ImVudGl0eS9mYWNlLzAiLCJldmFsdWF0ZWRfdGFncyI6W10sImdlb21ldHJ5Ijp7Im5vcm1hbCI6Wy0xLDAsMF0sIm9yaWdpbiI6WzAsLTMzMy4zMzMzMzMzMzMzMzMzLDMzMy4zMzMzMzMzMzMzMzMzXSwidHlwZSI6InBsYW5lIiwieF9kaXJlY3Rpb24iOlswLDAsMV19LCJraW5kIjoiZmFjZSIsInBhcmVudF9lbnRpdHlfaWRzIjpbImVudGl0eS9zb2xpZC8wIl0sInByb3BlcnRpZXMiOnsiYXJlYSI6NTAwMDAwLCJib3VuZHMiOnsibWF4IjpbMCwwLDEwMDBdLCJtaW4iOlswLC0xMDAwLDBdfSwiY2VudHJvaWQiOlswLC0zMzMuMzMzMzMzMzMzMzMzMywzMzMuMzMzMzMzMzMzMzMzM10sIm9yaWVudGF0aW9uIjoiZm9yd2FyZCIsInF1YWxpdHkiOiJrZXJuZWxfZXZhbHVhdGVkIn0sInJlbmRlcl9zdGF0dXMiOiJyZW5kZXJlZCIsInNka19jb25uZWN0b3JfZnJhbWUiOnsib3JpZ2luIjpbMCwtMzMzLjMzMzMzMzMzMzMzMzMsMzMzLjMzMzMzMzMzMzMzMzNdLCJ4X2F4aXMiOlswLDAsMV0sInlfYXhpcyI6WzAsMSwwXSwiel9heGlzIjpbLTEsMCwwXX0sInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImtpbmQiOiJpbXBvcnRlZF9wcmltaXRpdmUiLCJzb3VyY2VfZWxlbWVudF9pZCI6ImVudGl0eS9mYWNlLzAifX0seyJjaGlsZF9lbnRpdHlfaWRzIjpbImVudGl0eS9lZGdlLzEiLCJlbnRpdHkvZWRnZS8yIiwiZW50aXR5L2VkZ2UvMyJdLCJjb25uZWN0b3JfYmluZGluZ19zdGF0dXMiOiJvd25lcl9ub3RfcGFydCIsImVudGl0eV9pZCI6ImVudGl0eS9mYWNlLzEiLCJldmFsdWF0ZWRfdGFncyI6W10sImdlb21ldHJ5Ijp7Im5vcm1hbCI6WzAsMCwtMV0sIm9yaWdpbiI6WzMzMy4zMzMzMzMzMzMzMzMzLC0zMzMuMzMzMzMzMzMzMzMzMywwXSwidHlwZSI6InBsYW5lIiwieF9kaXJlY3Rpb24iOlsxLDAsMF19LCJraW5kIjoiZmFjZSIsInBhcmVudF9lbnRpdHlfaWRzIjpbImVudGl0eS9zb2xpZC8wIl0sInByb3BlcnRpZXMiOnsiYXJlYSI6NTAwMDAwLCJib3VuZHMiOnsibWF4IjpbMTAwMCwwLDBdLCJtaW4iOlswLC0xMDAwLDBdfSwiY2VudHJvaWQiOlszMzMuMzMzMzMzMzMzMzMzMywtMzMzLjMzMzMzMzMzMzMzMzMsMF0sIm9yaWVudGF0aW9uIjoiZm9yd2FyZCIsInF1YWxpdHkiOiJrZXJuZWxfZXZhbHVhdGVkIn0sInJlbmRlcl9zdGF0dXMiOiJyZW5kZXJlZCIsInNka19jb25uZWN0b3JfZnJhbWUiOnsib3JpZ2luIjpbMzMzLjMzMzMzMzMzMzMzMzMsLTMzMy4zMzMzMzMzMzMzMzMzLDBdLCJ4X2F4aXMiOlsxLDAsMF0sInlfYXhpcyI6WzAsLTEsMF0sInpfYXhpcyI6WzAsMCwtMV19LCJzZGtfbWV0YWRhdGEiOnt9LCJzZW1hbnRpY19iaW5kaW5nX2lkcyI6W10sInNvdXJjZSI6eyJraW5kIjoiaW1wb3J0ZWRfcHJpbWl0aXZlIiwic291cmNlX2VsZW1lbnRfaWQiOiJlbnRpdHkvZmFjZS8xIn19LHsiY2hpbGRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZWRnZS8zIiwiZW50aXR5L2VkZ2UvNCIsImVudGl0eS9lZGdlLzUiXSwiY29ubmVjdG9yX2JpbmRpbmdfc3RhdHVzIjoib3duZXJfbm90X3BhcnQiLCJlbnRpdHlfaWQiOiJlbnRpdHkvZmFjZS8yIiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJub3JtYWwiOlswLDEsMF0sIm9yaWdpbiI6WzMzMy4zMzMzMzMzMzMzMzMzLDAsMzMzLjMzMzMzMzMzMzMzMzNdLCJ0eXBlIjoicGxhbmUiLCJ4X2RpcmVjdGlvbiI6WzEsMCwwXX0sImtpbmQiOiJmYWNlIiwicGFyZW50X2VudGl0eV9pZHMiOlsiZW50aXR5L3NvbGlkLzAiXSwicHJvcGVydGllcyI6eyJhcmVhIjo1MDAwMDAsImJvdW5kcyI6eyJtYXgiOlsxMDAwLDAsMTAwMF0sIm1pbiI6WzAsMCwwXX0sImNlbnRyb2lkIjpbMzMzLjMzMzMzMzMzMzMzMzMsMCwzMzMuMzMzMzMzMzMzMzMzM10sIm9yaWVudGF0aW9uIjoiZm9yd2FyZCIsInF1YWxpdHkiOiJrZXJuZWxfZXZhbHVhdGVkIn0sInJlbmRlcl9zdGF0dXMiOiJyZW5kZXJlZCIsInNka19jb25uZWN0b3JfZnJhbWUiOnsib3JpZ2luIjpbMzMzLjMzMzMzMzMzMzMzMzMsMCwzMzMuMzMzMzMzMzMzMzMzM10sInhfYXhpcyI6WzEsMCwwXSwieV9heGlzIjpbMCwwLC0xXSwiel9heGlzIjpbMCwxLDBdfSwic2RrX21ldGFkYXRhIjp7fSwic2VtYW50aWNfYmluZGluZ19pZHMiOltdLCJzb3VyY2UiOnsia2luZCI6ImltcG9ydGVkX3ByaW1pdGl2ZSIsInNvdXJjZV9lbGVtZW50X2lkIjoiZW50aXR5L2ZhY2UvMiJ9fSx7ImNoaWxkX2VudGl0eV9pZHMiOlsiZW50aXR5L2VkZ2UvMCIsImVudGl0eS9lZGdlLzEiLCJlbnRpdHkvZWRnZS80Il0sImNvbm5lY3Rvcl9iaW5kaW5nX3N0YXR1cyI6Im93bmVyX25vdF9wYXJ0IiwiZW50aXR5X2lkIjoiZW50aXR5L2ZhY2UvMyIsImV2YWx1YXRlZF90YWdzIjpbXSwiZ2VvbWV0cnkiOnsibm9ybWFsIjpbMC41NzczNTAyNjkxODk2MjU4LC0wLjU3NzM1MDI2OTE4OTYyNTgsMC41NzczNTAyNjkxODk2MjU4XSwib3JpZ2luIjpbMzMzLjMzMzMzMzMzMzMzMzMsLTMzMy4zMzMzMzMzMzMzMzMzLDMzMy4zMzMzMzMzMzMzMzMzXSwidHlwZSI6InBsYW5lIiwieF9kaXJlY3Rpb24iOlstMC43MDcxMDY3ODExODY1NDc1LDAsMC43MDcxMDY3ODExODY1NDc1XX0sImtpbmQiOiJmYWNlIiwicGFyZW50X2VudGl0eV9pZHMiOlsiZW50aXR5L3NvbGlkLzAiXSwicHJvcGVydGllcyI6eyJhcmVhIjo4NjYwMjUuNDAzNzg0NDM4NiwiYm91bmRzIjp7Im1heCI6WzEwMDAsMCwxMDAwXSwibWluIjpbMCwtMTAwMCwwXX0sImNlbnRyb2lkIjpbMzMzLjMzMzMzMzMzMzMzMzMsLTMzMy4zMzMzMzMzMzMzMzMzLDMzMy4zMzMzMzMzMzMzMzMzXSwib3JpZW50YXRpb24iOiJmb3J3YXJkIiwicXVhbGl0eSI6Imtlcm5lbF9ldmFsdWF0ZWQifSwicmVuZGVyX3N0YXR1cyI6InJlbmRlcmVkIiwic2RrX2Nvbm5lY3Rvcl9mcmFtZSI6eyJvcmlnaW4iOlszMzMuMzMzMzMzMzMzMzMzMywtMzMzLjMzMzMzMzMzMzMzMzMsMzMzLjMzMzMzMzMzMzMzMzNdLCJ4X2F4aXMiOlstMC43MDcxMDY3ODExODY1NDc1LDAsMC43MDcxMDY3ODExODY1NDc1XSwieV9heGlzIjpbLTAuNDA4MjQ4MjkwNDYzODYzMSwtMC44MTY0OTY1ODA5Mjc3MjYxLC0wLjQwODI0ODI5MDQ2Mzg2MzFdLCJ6X2F4aXMiOlswLjU3NzM1MDI2OTE4OTYyNTgsLTAuNTc3MzUwMjY5MTg5NjI1OCwwLjU3NzM1MDI2OTE4OTYyNThdfSwic2RrX21ldGFkYXRhIjp7fSwic2VtYW50aWNfYmluZGluZ19pZHMiOltdLCJzb3VyY2UiOnsia2luZCI6ImltcG9ydGVkX3ByaW1pdGl2ZSIsInNvdXJjZV9lbGVtZW50X2lkIjoiZW50aXR5L2ZhY2UvMyJ9fSx7ImNoaWxkX2VudGl0eV9pZHMiOlsiZW50aXR5L2ZhY2UvMCIsImVudGl0eS9mYWNlLzEiLCJlbnRpdHkvZmFjZS8yIiwiZW50aXR5L2ZhY2UvMyJdLCJjb25uZWN0b3JfYmluZGluZ19zdGF0dXMiOiJub3RfYXBwbGljYWJsZSIsImVudGl0eV9pZCI6ImVudGl0eS9zb2xpZC8wIiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJ0eXBlIjoiYnJlcF9zb2xpZCJ9LCJraW5kIjoic29saWQiLCJwYXJlbnRfZW50aXR5X2lkcyI6W10sInByb3BlcnRpZXMiOnsiYm91bmRzIjp7Im1heCI6WzEwMDAsMCwxMDAwXSwibWluIjpbMCwtMTAwMCwwXX0sImNlbnRyb2lkIjpbMjUwLC0yNTAsMjUwXSwicXVhbGl0eSI6Imtlcm5lbF9ldmFsdWF0ZWQiLCJzdXJmYWNlX2FyZWEiOjIzNjYwMjUuNDAzNzg0NDM4NSwidm9sdW1lIjoxNjY2NjY2NjYuNjY2NjY2NjZ9LCJyZW5kZXJfc3RhdHVzIjoicmVuZGVyZWQiLCJzZGtfY29ubmVjdG9yX2ZyYW1lIjpudWxsLCJzZGtfbWV0YWRhdGEiOnt9LCJzZW1hbnRpY19iaW5kaW5nX2lkcyI6W10sInNvdXJjZSI6eyJraW5kIjoiaW1wb3J0ZWRfcHJpbWl0aXZlIiwic291cmNlX2VsZW1lbnRfaWQiOiJlbnRpdHkvc29saWQvMCJ9fSx7ImNoaWxkX2VudGl0eV9pZHMiOltdLCJjb25uZWN0b3JfYmluZGluZ19zdGF0dXMiOiJvd25lcl9ub3RfcGFydCIsImVudGl0eV9pZCI6ImVudGl0eS92ZXJ0ZXgvMCIsImV2YWx1YXRlZF90YWdzIjpbXSwiZ2VvbWV0cnkiOnsicG9zaXRpb24iOlswLDAsMF0sInR5cGUiOiJwb2ludCJ9LCJraW5kIjoidmVydGV4IiwicGFyZW50X2VudGl0eV9pZHMiOlsiZW50aXR5L2VkZ2UvMiIsImVudGl0eS9lZGdlLzMiLCJlbnRpdHkvZWRnZS81Il0sInByb3BlcnRpZXMiOnsiYm91bmRzIjp7Im1heCI6WzAsMCwwXSwibWluIjpbMCwwLDBdfSwicG9zaXRpb24iOlswLDAsMF0sInF1YWxpdHkiOiJrZXJuZWxfZXZhbHVhdGVkIn0sInJlbmRlcl9zdGF0dXMiOiJyZW5kZXJlZCIsInNka19jb25uZWN0b3JfZnJhbWUiOnsib3JpZ2luIjpbMCwwLDBdLCJ4X2F4aXMiOlsxLDAsMF0sInlfYXhpcyI6WzAsMSwwXSwiel9heGlzIjpbMCwwLDFdfSwic2RrX21ldGFkYXRhIjp7fSwic2VtYW50aWNfYmluZGluZ19pZHMiOltdLCJzb3VyY2UiOnsia2luZCI6ImltcG9ydGVkX3ByaW1pdGl2ZSIsInNvdXJjZV9lbGVtZW50X2lkIjoiZW50aXR5L3ZlcnRleC8wIn19LHsiY2hpbGRfZW50aXR5X2lkcyI6W10sImNvbm5lY3Rvcl9iaW5kaW5nX3N0YXR1cyI6Im93bmVyX25vdF9wYXJ0IiwiZW50aXR5X2lkIjoiZW50aXR5L3ZlcnRleC8xIiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJwb3NpdGlvbiI6WzEwMDAsMCwwXSwidHlwZSI6InBvaW50In0sImtpbmQiOiJ2ZXJ0ZXgiLCJwYXJlbnRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZWRnZS8xIiwiZW50aXR5L2VkZ2UvMyIsImVudGl0eS9lZGdlLzQiXSwicHJvcGVydGllcyI6eyJib3VuZHMiOnsibWF4IjpbMTAwMCwwLDBdLCJtaW4iOlsxMDAwLDAsMF19LCJwb3NpdGlvbiI6WzEwMDAsMCwwXSwicXVhbGl0eSI6Imtlcm5lbF9ldmFsdWF0ZWQifSwicmVuZGVyX3N0YXR1cyI6InJlbmRlcmVkIiwic2RrX2Nvbm5lY3Rvcl9mcmFtZSI6eyJvcmlnaW4iOlsxMDAwLDAsMF0sInhfYXhpcyI6WzEsMCwwXSwieV9heGlzIjpbMCwxLDBdLCJ6X2F4aXMiOlswLDAsMV19LCJzZGtfbWV0YWRhdGEiOnt9LCJzZW1hbnRpY19iaW5kaW5nX2lkcyI6W10sInNvdXJjZSI6eyJraW5kIjoiaW1wb3J0ZWRfcHJpbWl0aXZlIiwic291cmNlX2VsZW1lbnRfaWQiOiJlbnRpdHkvdmVydGV4LzEifX0seyJjaGlsZF9lbnRpdHlfaWRzIjpbXSwiY29ubmVjdG9yX2JpbmRpbmdfc3RhdHVzIjoib3duZXJfbm90X3BhcnQiLCJlbnRpdHlfaWQiOiJlbnRpdHkvdmVydGV4LzIiLCJldmFsdWF0ZWRfdGFncyI6W10sImdlb21ldHJ5Ijp7InBvc2l0aW9uIjpbMCwwLDEwMDBdLCJ0eXBlIjoicG9pbnQifSwia2luZCI6InZlcnRleCIsInBhcmVudF9lbnRpdHlfaWRzIjpbImVudGl0eS9lZGdlLzAiLCJlbnRpdHkvZWRnZS80IiwiZW50aXR5L2VkZ2UvNSJdLCJwcm9wZXJ0aWVzIjp7ImJvdW5kcyI6eyJtYXgiOlswLDAsMTAwMF0sIm1pbiI6WzAsMCwxMDAwXX0sInBvc2l0aW9uIjpbMCwwLDEwMDBdLCJxdWFsaXR5Ijoia2VybmVsX2V2YWx1YXRlZCJ9LCJyZW5kZXJfc3RhdHVzIjoicmVuZGVyZWQiLCJzZGtfY29ubmVjdG9yX2ZyYW1lIjp7Im9yaWdpbiI6WzAsMCwxMDAwXSwieF9heGlzIjpbMSwwLDBdLCJ5X2F4aXMiOlswLDEsMF0sInpfYXhpcyI6WzAsMCwxXX0sInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImtpbmQiOiJpbXBvcnRlZF9wcmltaXRpdmUiLCJzb3VyY2VfZWxlbWVudF9pZCI6ImVudGl0eS92ZXJ0ZXgvMiJ9fSx7ImNoaWxkX2VudGl0eV9pZHMiOltdLCJjb25uZWN0b3JfYmluZGluZ19zdGF0dXMiOiJvd25lcl9ub3RfcGFydCIsImVudGl0eV9pZCI6ImVudGl0eS92ZXJ0ZXgvMyIsImV2YWx1YXRlZF90YWdzIjpbXSwiZ2VvbWV0cnkiOnsicG9zaXRpb24iOlswLC0xMDAwLDBdLCJ0eXBlIjoicG9pbnQifSwia2luZCI6InZlcnRleCIsInBhcmVudF9lbnRpdHlfaWRzIjpbImVudGl0eS9lZGdlLzAiLCJlbnRpdHkvZWRnZS8xIiwiZW50aXR5L2VkZ2UvMiJdLCJwcm9wZXJ0aWVzIjp7ImJvdW5kcyI6eyJtYXgiOlswLC0xMDAwLDBdLCJtaW4iOlswLC0xMDAwLDBdfSwicG9zaXRpb24iOlswLC0xMDAwLDBdLCJxdWFsaXR5Ijoia2VybmVsX2V2YWx1YXRlZCJ9LCJyZW5kZXJfc3RhdHVzIjoicmVuZGVyZWQiLCJzZGtfY29ubmVjdG9yX2ZyYW1lIjp7Im9yaWdpbiI6WzAsLTEwMDAsMF0sInhfYXhpcyI6WzEsMCwwXSwieV9heGlzIjpbMCwxLDBdLCJ6X2F4aXMiOlswLDAsMV19LCJzZGtfbWV0YWRhdGEiOnt9LCJzZW1hbnRpY19iaW5kaW5nX2lkcyI6W10sInNvdXJjZSI6eyJraW5kIjoiaW1wb3J0ZWRfcHJpbWl0aXZlIiwic291cmNlX2VsZW1lbnRfaWQiOiJlbnRpdHkvdmVydGV4LzMifX1dLCJmYWNlX2dyb3VwcyI6W3siZW50aXR5X2lkIjoiZW50aXR5L2ZhY2UvMCIsImZpcnN0X2luZGV4IjowLCJncm91cF9pZCI6MCwiaW5kZXhfY291bnQiOjMsIm1lc2hfaW5kZXgiOjAsInByaW1pdGl2ZV9pbmRleCI6MH0seyJlbnRpdHlfaWQiOiJlbnRpdHkvZmFjZS8xIiwiZmlyc3RfaW5kZXgiOjMsImdyb3VwX2lkIjoxLCJpbmRleF9jb3VudCI6MywibWVzaF9pbmRleCI6MCwicHJpbWl0aXZlX2luZGV4IjowfSx7ImVudGl0eV9pZCI6ImVudGl0eS9mYWNlLzIiLCJmaXJzdF9pbmRleCI6NiwiZ3JvdXBfaWQiOjIsImluZGV4X2NvdW50IjozLCJtZXNoX2luZGV4IjowLCJwcmltaXRpdmVfaW5kZXgiOjB9LHsiZW50aXR5X2lkIjoiZW50aXR5L2ZhY2UvMyIsImZpcnN0X2luZGV4Ijo5LCJncm91cF9pZCI6MywiaW5kZXhfY291bnQiOjMsIm1lc2hfaW5kZXgiOjAsInByaW1pdGl2ZV9pbmRleCI6MH1dLCJnZW9tZXRyeV9hc3NldF9pZCI6InNoYTI1NjoyMDA1MjM0MTRkZTI4MWE4YTE1NTA4NmE1NGNkMGY4ODBmNjQwNDg1NzY1YTQ4Y2NkZDhlYzIzMGY2N2RkMTdjIiwiZ2VvbWV0cnlfZW5naW5lIjp7Im5hbWUiOiJPcGVuQ2FzY2FkZSIsInByb2ZpbGUiOiJvY3AtZXZhbHVhdGVkLXByb3BlcnRpZXMtMSIsInZlcnNpb24iOiJmaXh0dXJlIn0sInNjaGVtYV92ZXJzaW9uIjoiMS4wIn0=", + "entities/sha256-3897cc9ebdb2941835964a435821b556e01b175535cff8fc0eaaafb684c4ddf5.json": "eyJkZWZpbml0aW9uX2lkIjoiZGVmaW5pdGlvbi9yb290L3NoYXBlL21hbnVhbC9maXh0dXJlIiwiZWRnZV9hc3NldF9pZCI6InNoYTI1Njo2Y2U0ODFkYjFmZTEzYmNmZTdhMjM3NDQyM2U2YzcyZDc1N2Q1MWM5OTE2OGIyODdjNTJmNTNhYTYyNWNjZDY3IiwiZWRnZV9ncm91cHMiOlt7ImVudGl0eV9pZCI6ImVudGl0eS9lZGdlLzAiLCJmaXJzdF9pbmRleCI6MCwiZ3JvdXBfaWQiOjAsImluZGV4X2NvdW50IjoyLCJtZXNoX2luZGV4IjowLCJwcmltaXRpdmVfaW5kZXgiOjB9LHsiZW50aXR5X2lkIjoiZW50aXR5L2VkZ2UvMSIsImZpcnN0X2luZGV4IjoyLCJncm91cF9pZCI6MSwiaW5kZXhfY291bnQiOjIsIm1lc2hfaW5kZXgiOjAsInByaW1pdGl2ZV9pbmRleCI6MH0seyJlbnRpdHlfaWQiOiJlbnRpdHkvZWRnZS8yIiwiZmlyc3RfaW5kZXgiOjQsImdyb3VwX2lkIjoyLCJpbmRleF9jb3VudCI6MiwibWVzaF9pbmRleCI6MCwicHJpbWl0aXZlX2luZGV4IjowfSx7ImVudGl0eV9pZCI6ImVudGl0eS9lZGdlLzMiLCJmaXJzdF9pbmRleCI6NiwiZ3JvdXBfaWQiOjMsImluZGV4X2NvdW50IjoyLCJtZXNoX2luZGV4IjowLCJwcmltaXRpdmVfaW5kZXgiOjB9LHsiZW50aXR5X2lkIjoiZW50aXR5L2VkZ2UvNCIsImZpcnN0X2luZGV4Ijo4LCJncm91cF9pZCI6NCwiaW5kZXhfY291bnQiOjIsIm1lc2hfaW5kZXgiOjAsInByaW1pdGl2ZV9pbmRleCI6MH0seyJlbnRpdHlfaWQiOiJlbnRpdHkvZWRnZS81IiwiZmlyc3RfaW5kZXgiOjEwLCJncm91cF9pZCI6NSwiaW5kZXhfY291bnQiOjIsIm1lc2hfaW5kZXgiOjAsInByaW1pdGl2ZV9pbmRleCI6MH1dLCJlbnRpdGllcyI6W3siY2hpbGRfZW50aXR5X2lkcyI6WyJlbnRpdHkvdmVydGV4LzIiLCJlbnRpdHkvdmVydGV4LzMiXSwiY29ubmVjdG9yX2JpbmRpbmdfc3RhdHVzIjoib3duZXJfbm90X3BhcnQiLCJlbnRpdHlfaWQiOiJlbnRpdHkvZWRnZS8wIiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJkaXJlY3Rpb24iOlswLC0wLjcwNzEwNjc4MTE4NjU0NzUsLTAuNzA3MTA2NzgxMTg2NTQ3NV0sIm9yaWdpbiI6WzAsMCwxMDAwXSwidHlwZSI6ImxpbmUifSwia2luZCI6ImVkZ2UiLCJwYXJlbnRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZmFjZS8wIiwiZW50aXR5L2ZhY2UvMyJdLCJwcm9wZXJ0aWVzIjp7ImJvdW5kcyI6eyJtYXgiOlswLDAsMTAwMF0sIm1pbiI6WzAsLTEwMDAsMF19LCJjZW50cm9pZCI6WzAsLTUwMCw1MDBdLCJsZW5ndGgiOjE0MTQuMjEzNTYyMzczMDk1LCJxdWFsaXR5Ijoia2VybmVsX2V2YWx1YXRlZCJ9LCJyZW5kZXJfc3RhdHVzIjoicmVuZGVyZWQiLCJzZGtfY29ubmVjdG9yX2ZyYW1lIjp7Im9yaWdpbiI6WzAsLTUwMCw1MDBdLCJ4X2F4aXMiOlswLC0wLjcwNzEwNjc4MTE4NjU0NzUsLTAuNzA3MTA2NzgxMTg2NTQ3NV0sInlfYXhpcyI6WzAsLTAuNzA3MTA2NzgxMTg2NTQ3NCwwLjcwNzEwNjc4MTE4NjU0NzddLCJ6X2F4aXMiOlstMSwwLDBdfSwic2RrX21ldGFkYXRhIjp7fSwic2VtYW50aWNfYmluZGluZ19pZHMiOltdLCJzb3VyY2UiOnsia2luZCI6InVuYm91bmQifX0seyJjaGlsZF9lbnRpdHlfaWRzIjpbImVudGl0eS92ZXJ0ZXgvMSIsImVudGl0eS92ZXJ0ZXgvMyJdLCJjb25uZWN0b3JfYmluZGluZ19zdGF0dXMiOiJvd25lcl9ub3RfcGFydCIsImVudGl0eV9pZCI6ImVudGl0eS9lZGdlLzEiLCJldmFsdWF0ZWRfdGFncyI6W10sImdlb21ldHJ5Ijp7ImRpcmVjdGlvbiI6Wy0wLjcwNzEwNjc4MTE4NjU0NzUsLTAuNzA3MTA2NzgxMTg2NTQ3NSwwXSwib3JpZ2luIjpbMTAwMCwwLDBdLCJ0eXBlIjoibGluZSJ9LCJraW5kIjoiZWRnZSIsInBhcmVudF9lbnRpdHlfaWRzIjpbImVudGl0eS9mYWNlLzEiLCJlbnRpdHkvZmFjZS8zIl0sInByb3BlcnRpZXMiOnsiYm91bmRzIjp7Im1heCI6WzEwMDAsMCwwXSwibWluIjpbMCwtMTAwMCwwXX0sImNlbnRyb2lkIjpbNTAwLC01MDAsMF0sImxlbmd0aCI6MTQxNC4yMTM1NjIzNzMwOTUsInF1YWxpdHkiOiJrZXJuZWxfZXZhbHVhdGVkIn0sInJlbmRlcl9zdGF0dXMiOiJyZW5kZXJlZCIsInNka19jb25uZWN0b3JfZnJhbWUiOnsib3JpZ2luIjpbNTAwLC01MDAsMF0sInhfYXhpcyI6Wy0wLjcwNzEwNjc4MTE4NjU0NzUsLTAuNzA3MTA2NzgxMTg2NTQ3NSwwXSwieV9heGlzIjpbMCwwLDFdLCJ6X2F4aXMiOlstMC43MDcxMDY3ODExODY1NDc2LDAuNzA3MTA2NzgxMTg2NTQ3NiwwXX0sInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImtpbmQiOiJ1bmJvdW5kIn19LHsiY2hpbGRfZW50aXR5X2lkcyI6WyJlbnRpdHkvdmVydGV4LzAiLCJlbnRpdHkvdmVydGV4LzMiXSwiY29ubmVjdG9yX2JpbmRpbmdfc3RhdHVzIjoib3duZXJfbm90X3BhcnQiLCJlbnRpdHlfaWQiOiJlbnRpdHkvZWRnZS8yIiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJkaXJlY3Rpb24iOlswLC0xLDBdLCJvcmlnaW4iOlswLDAsMF0sInR5cGUiOiJsaW5lIn0sImtpbmQiOiJlZGdlIiwicGFyZW50X2VudGl0eV9pZHMiOlsiZW50aXR5L2ZhY2UvMCIsImVudGl0eS9mYWNlLzEiXSwicHJvcGVydGllcyI6eyJib3VuZHMiOnsibWF4IjpbMCwwLDBdLCJtaW4iOlswLC0xMDAwLDBdfSwiY2VudHJvaWQiOlswLC01MDAsMF0sImxlbmd0aCI6MTAwMCwicXVhbGl0eSI6Imtlcm5lbF9ldmFsdWF0ZWQifSwicmVuZGVyX3N0YXR1cyI6InJlbmRlcmVkIiwic2RrX2Nvbm5lY3Rvcl9mcmFtZSI6eyJvcmlnaW4iOlswLC01MDAsMF0sInhfYXhpcyI6WzAsLTEsMF0sInlfYXhpcyI6WzAsMCwxXSwiel9heGlzIjpbLTEsMCwwXX0sInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImtpbmQiOiJ1bmJvdW5kIn19LHsiY2hpbGRfZW50aXR5X2lkcyI6WyJlbnRpdHkvdmVydGV4LzAiLCJlbnRpdHkvdmVydGV4LzEiXSwiY29ubmVjdG9yX2JpbmRpbmdfc3RhdHVzIjoib3duZXJfbm90X3BhcnQiLCJlbnRpdHlfaWQiOiJlbnRpdHkvZWRnZS8zIiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJkaXJlY3Rpb24iOlsxLDAsMF0sIm9yaWdpbiI6WzAsMCwwXSwidHlwZSI6ImxpbmUifSwia2luZCI6ImVkZ2UiLCJwYXJlbnRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZmFjZS8xIiwiZW50aXR5L2ZhY2UvMiJdLCJwcm9wZXJ0aWVzIjp7ImJvdW5kcyI6eyJtYXgiOlsxMDAwLDAsMF0sIm1pbiI6WzAsMCwwXX0sImNlbnRyb2lkIjpbNTAwLDAsMF0sImxlbmd0aCI6MTAwMCwicXVhbGl0eSI6Imtlcm5lbF9ldmFsdWF0ZWQifSwicmVuZGVyX3N0YXR1cyI6InJlbmRlcmVkIiwic2RrX2Nvbm5lY3Rvcl9mcmFtZSI6eyJvcmlnaW4iOls1MDAsMCwwXSwieF9heGlzIjpbMSwwLDBdLCJ5X2F4aXMiOlswLDAsMV0sInpfYXhpcyI6WzAsLTEsMF19LCJzZGtfbWV0YWRhdGEiOnt9LCJzZW1hbnRpY19iaW5kaW5nX2lkcyI6W10sInNvdXJjZSI6eyJraW5kIjoidW5ib3VuZCJ9fSx7ImNoaWxkX2VudGl0eV9pZHMiOlsiZW50aXR5L3ZlcnRleC8xIiwiZW50aXR5L3ZlcnRleC8yIl0sImNvbm5lY3Rvcl9iaW5kaW5nX3N0YXR1cyI6Im93bmVyX25vdF9wYXJ0IiwiZW50aXR5X2lkIjoiZW50aXR5L2VkZ2UvNCIsImV2YWx1YXRlZF90YWdzIjpbXSwiZ2VvbWV0cnkiOnsiZGlyZWN0aW9uIjpbLTAuNzA3MTA2NzgxMTg2NTQ3NSwwLDAuNzA3MTA2NzgxMTg2NTQ3NV0sIm9yaWdpbiI6WzEwMDAsMCwwXSwidHlwZSI6ImxpbmUifSwia2luZCI6ImVkZ2UiLCJwYXJlbnRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZmFjZS8yIiwiZW50aXR5L2ZhY2UvMyJdLCJwcm9wZXJ0aWVzIjp7ImJvdW5kcyI6eyJtYXgiOlsxMDAwLDAsMTAwMF0sIm1pbiI6WzAsMCwwXX0sImNlbnRyb2lkIjpbNTAwLDAsNTAwXSwibGVuZ3RoIjoxNDE0LjIxMzU2MjM3MzA5NSwicXVhbGl0eSI6Imtlcm5lbF9ldmFsdWF0ZWQifSwicmVuZGVyX3N0YXR1cyI6InJlbmRlcmVkIiwic2RrX2Nvbm5lY3Rvcl9mcmFtZSI6eyJvcmlnaW4iOls1MDAsMCw1MDBdLCJ4X2F4aXMiOlstMC43MDcxMDY3ODExODY1NDc1LDAsMC43MDcxMDY3ODExODY1NDc1XSwieV9heGlzIjpbMC43MDcxMDY3ODExODY1NDc0LDAsMC43MDcxMDY3ODExODY1NDc3XSwiel9heGlzIjpbMCwxLDBdfSwic2RrX21ldGFkYXRhIjp7fSwic2VtYW50aWNfYmluZGluZ19pZHMiOltdLCJzb3VyY2UiOnsia2luZCI6InVuYm91bmQifX0seyJjaGlsZF9lbnRpdHlfaWRzIjpbImVudGl0eS92ZXJ0ZXgvMCIsImVudGl0eS92ZXJ0ZXgvMiJdLCJjb25uZWN0b3JfYmluZGluZ19zdGF0dXMiOiJvd25lcl9ub3RfcGFydCIsImVudGl0eV9pZCI6ImVudGl0eS9lZGdlLzUiLCJldmFsdWF0ZWRfdGFncyI6W10sImdlb21ldHJ5Ijp7ImRpcmVjdGlvbiI6WzAsMCwxXSwib3JpZ2luIjpbMCwwLDBdLCJ0eXBlIjoibGluZSJ9LCJraW5kIjoiZWRnZSIsInBhcmVudF9lbnRpdHlfaWRzIjpbImVudGl0eS9mYWNlLzAiLCJlbnRpdHkvZmFjZS8yIl0sInByb3BlcnRpZXMiOnsiYm91bmRzIjp7Im1heCI6WzAsMCwxMDAwXSwibWluIjpbMCwwLDBdfSwiY2VudHJvaWQiOlswLDAsNTAwXSwibGVuZ3RoIjoxMDAwLCJxdWFsaXR5Ijoia2VybmVsX2V2YWx1YXRlZCJ9LCJyZW5kZXJfc3RhdHVzIjoicmVuZGVyZWQiLCJzZGtfY29ubmVjdG9yX2ZyYW1lIjp7Im9yaWdpbiI6WzAsMCw1MDBdLCJ4X2F4aXMiOlswLDAsMV0sInlfYXhpcyI6WzAsMSwwXSwiel9heGlzIjpbLTEsMCwwXX0sInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImtpbmQiOiJ1bmJvdW5kIn19LHsiY2hpbGRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZWRnZS8wIiwiZW50aXR5L2VkZ2UvMiIsImVudGl0eS9lZGdlLzUiXSwiY29ubmVjdG9yX2JpbmRpbmdfc3RhdHVzIjoib3duZXJfbm90X3BhcnQiLCJlbnRpdHlfaWQiOiJlbnRpdHkvZmFjZS8wIiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJub3JtYWwiOlstMSwwLDBdLCJvcmlnaW4iOlswLC0zMzMuMzMzMzMzMzMzMzMzMywzMzMuMzMzMzMzMzMzMzMzM10sInR5cGUiOiJwbGFuZSIsInhfZGlyZWN0aW9uIjpbMCwwLDFdfSwia2luZCI6ImZhY2UiLCJwYXJlbnRfZW50aXR5X2lkcyI6WyJlbnRpdHkvc29saWQvMCJdLCJwcm9wZXJ0aWVzIjp7ImFyZWEiOjUwMDAwMCwiYm91bmRzIjp7Im1heCI6WzAsMCwxMDAwXSwibWluIjpbMCwtMTAwMCwwXX0sImNlbnRyb2lkIjpbMCwtMzMzLjMzMzMzMzMzMzMzMzMsMzMzLjMzMzMzMzMzMzMzMzNdLCJvcmllbnRhdGlvbiI6ImZvcndhcmQiLCJxdWFsaXR5Ijoia2VybmVsX2V2YWx1YXRlZCJ9LCJyZW5kZXJfc3RhdHVzIjoicmVuZGVyZWQiLCJzZGtfY29ubmVjdG9yX2ZyYW1lIjp7Im9yaWdpbiI6WzAsLTMzMy4zMzMzMzMzMzMzMzMzLDMzMy4zMzMzMzMzMzMzMzMzXSwieF9heGlzIjpbMCwwLDFdLCJ5X2F4aXMiOlswLDEsMF0sInpfYXhpcyI6Wy0xLDAsMF19LCJzZGtfbWV0YWRhdGEiOnt9LCJzZW1hbnRpY19iaW5kaW5nX2lkcyI6W10sInNvdXJjZSI6eyJraW5kIjoidW5ib3VuZCJ9fSx7ImNoaWxkX2VudGl0eV9pZHMiOlsiZW50aXR5L2VkZ2UvMSIsImVudGl0eS9lZGdlLzIiLCJlbnRpdHkvZWRnZS8zIl0sImNvbm5lY3Rvcl9iaW5kaW5nX3N0YXR1cyI6Im93bmVyX25vdF9wYXJ0IiwiZW50aXR5X2lkIjoiZW50aXR5L2ZhY2UvMSIsImV2YWx1YXRlZF90YWdzIjpbXSwiZ2VvbWV0cnkiOnsibm9ybWFsIjpbMCwwLC0xXSwib3JpZ2luIjpbMzMzLjMzMzMzMzMzMzMzMzMsLTMzMy4zMzMzMzMzMzMzMzMzLDBdLCJ0eXBlIjoicGxhbmUiLCJ4X2RpcmVjdGlvbiI6WzEsMCwwXX0sImtpbmQiOiJmYWNlIiwicGFyZW50X2VudGl0eV9pZHMiOlsiZW50aXR5L3NvbGlkLzAiXSwicHJvcGVydGllcyI6eyJhcmVhIjo1MDAwMDAsImJvdW5kcyI6eyJtYXgiOlsxMDAwLDAsMF0sIm1pbiI6WzAsLTEwMDAsMF19LCJjZW50cm9pZCI6WzMzMy4zMzMzMzMzMzMzMzMzLC0zMzMuMzMzMzMzMzMzMzMzMywwXSwib3JpZW50YXRpb24iOiJmb3J3YXJkIiwicXVhbGl0eSI6Imtlcm5lbF9ldmFsdWF0ZWQifSwicmVuZGVyX3N0YXR1cyI6InJlbmRlcmVkIiwic2RrX2Nvbm5lY3Rvcl9mcmFtZSI6eyJvcmlnaW4iOlszMzMuMzMzMzMzMzMzMzMzMywtMzMzLjMzMzMzMzMzMzMzMzMsMF0sInhfYXhpcyI6WzEsMCwwXSwieV9heGlzIjpbMCwtMSwwXSwiel9heGlzIjpbMCwwLC0xXX0sInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImtpbmQiOiJ1bmJvdW5kIn19LHsiY2hpbGRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZWRnZS8zIiwiZW50aXR5L2VkZ2UvNCIsImVudGl0eS9lZGdlLzUiXSwiY29ubmVjdG9yX2JpbmRpbmdfc3RhdHVzIjoib3duZXJfbm90X3BhcnQiLCJlbnRpdHlfaWQiOiJlbnRpdHkvZmFjZS8yIiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJub3JtYWwiOlswLDEsMF0sIm9yaWdpbiI6WzMzMy4zMzMzMzMzMzMzMzMzLDAsMzMzLjMzMzMzMzMzMzMzMzNdLCJ0eXBlIjoicGxhbmUiLCJ4X2RpcmVjdGlvbiI6WzEsMCwwXX0sImtpbmQiOiJmYWNlIiwicGFyZW50X2VudGl0eV9pZHMiOlsiZW50aXR5L3NvbGlkLzAiXSwicHJvcGVydGllcyI6eyJhcmVhIjo1MDAwMDAsImJvdW5kcyI6eyJtYXgiOlsxMDAwLDAsMTAwMF0sIm1pbiI6WzAsMCwwXX0sImNlbnRyb2lkIjpbMzMzLjMzMzMzMzMzMzMzMzMsMCwzMzMuMzMzMzMzMzMzMzMzM10sIm9yaWVudGF0aW9uIjoiZm9yd2FyZCIsInF1YWxpdHkiOiJrZXJuZWxfZXZhbHVhdGVkIn0sInJlbmRlcl9zdGF0dXMiOiJyZW5kZXJlZCIsInNka19jb25uZWN0b3JfZnJhbWUiOnsib3JpZ2luIjpbMzMzLjMzMzMzMzMzMzMzMzMsMCwzMzMuMzMzMzMzMzMzMzMzM10sInhfYXhpcyI6WzEsMCwwXSwieV9heGlzIjpbMCwwLC0xXSwiel9heGlzIjpbMCwxLDBdfSwic2RrX21ldGFkYXRhIjp7fSwic2VtYW50aWNfYmluZGluZ19pZHMiOltdLCJzb3VyY2UiOnsia2luZCI6InVuYm91bmQifX0seyJjaGlsZF9lbnRpdHlfaWRzIjpbImVudGl0eS9lZGdlLzAiLCJlbnRpdHkvZWRnZS8xIiwiZW50aXR5L2VkZ2UvNCJdLCJjb25uZWN0b3JfYmluZGluZ19zdGF0dXMiOiJvd25lcl9ub3RfcGFydCIsImVudGl0eV9pZCI6ImVudGl0eS9mYWNlLzMiLCJldmFsdWF0ZWRfdGFncyI6W10sImdlb21ldHJ5Ijp7Im5vcm1hbCI6WzAuNTc3MzUwMjY5MTg5NjI1OCwtMC41NzczNTAyNjkxODk2MjU4LDAuNTc3MzUwMjY5MTg5NjI1OF0sIm9yaWdpbiI6WzMzMy4zMzMzMzMzMzMzMzMzLC0zMzMuMzMzMzMzMzMzMzMzMywzMzMuMzMzMzMzMzMzMzMzM10sInR5cGUiOiJwbGFuZSIsInhfZGlyZWN0aW9uIjpbLTAuNzA3MTA2NzgxMTg2NTQ3NSwwLDAuNzA3MTA2NzgxMTg2NTQ3NV19LCJraW5kIjoiZmFjZSIsInBhcmVudF9lbnRpdHlfaWRzIjpbImVudGl0eS9zb2xpZC8wIl0sInByb3BlcnRpZXMiOnsiYXJlYSI6ODY2MDI1LjQwMzc4NDQzODYsImJvdW5kcyI6eyJtYXgiOlsxMDAwLDAsMTAwMF0sIm1pbiI6WzAsLTEwMDAsMF19LCJjZW50cm9pZCI6WzMzMy4zMzMzMzMzMzMzMzMzLC0zMzMuMzMzMzMzMzMzMzMzMywzMzMuMzMzMzMzMzMzMzMzM10sIm9yaWVudGF0aW9uIjoiZm9yd2FyZCIsInF1YWxpdHkiOiJrZXJuZWxfZXZhbHVhdGVkIn0sInJlbmRlcl9zdGF0dXMiOiJyZW5kZXJlZCIsInNka19jb25uZWN0b3JfZnJhbWUiOnsib3JpZ2luIjpbMzMzLjMzMzMzMzMzMzMzMzMsLTMzMy4zMzMzMzMzMzMzMzMzLDMzMy4zMzMzMzMzMzMzMzMzXSwieF9heGlzIjpbLTAuNzA3MTA2NzgxMTg2NTQ3NSwwLDAuNzA3MTA2NzgxMTg2NTQ3NV0sInlfYXhpcyI6Wy0wLjQwODI0ODI5MDQ2Mzg2MzEsLTAuODE2NDk2NTgwOTI3NzI2MSwtMC40MDgyNDgyOTA0NjM4NjMxXSwiel9heGlzIjpbMC41NzczNTAyNjkxODk2MjU4LC0wLjU3NzM1MDI2OTE4OTYyNTgsMC41NzczNTAyNjkxODk2MjU4XX0sInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImtpbmQiOiJ1bmJvdW5kIn19LHsiY2hpbGRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZmFjZS8wIiwiZW50aXR5L2ZhY2UvMSIsImVudGl0eS9mYWNlLzIiLCJlbnRpdHkvZmFjZS8zIl0sImNvbm5lY3Rvcl9iaW5kaW5nX3N0YXR1cyI6Im5vdF9hcHBsaWNhYmxlIiwiZW50aXR5X2lkIjoiZW50aXR5L3NvbGlkLzAiLCJldmFsdWF0ZWRfdGFncyI6W10sImdlb21ldHJ5Ijp7InR5cGUiOiJicmVwX3NvbGlkIn0sImtpbmQiOiJzb2xpZCIsInBhcmVudF9lbnRpdHlfaWRzIjpbXSwicHJvcGVydGllcyI6eyJib3VuZHMiOnsibWF4IjpbMTAwMCwwLDEwMDBdLCJtaW4iOlswLC0xMDAwLDBdfSwiY2VudHJvaWQiOlsyNTAsLTI1MCwyNTBdLCJxdWFsaXR5Ijoia2VybmVsX2V2YWx1YXRlZCIsInN1cmZhY2VfYXJlYSI6MjM2NjAyNS40MDM3ODQ0Mzg1LCJ2b2x1bWUiOjE2NjY2NjY2Ni42NjY2NjY2Nn0sInJlbmRlcl9zdGF0dXMiOiJyZW5kZXJlZCIsInNka19jb25uZWN0b3JfZnJhbWUiOm51bGwsInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImtpbmQiOiJ1bmJvdW5kIn19LHsiY2hpbGRfZW50aXR5X2lkcyI6W10sImNvbm5lY3Rvcl9iaW5kaW5nX3N0YXR1cyI6Im93bmVyX25vdF9wYXJ0IiwiZW50aXR5X2lkIjoiZW50aXR5L3ZlcnRleC8wIiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJwb3NpdGlvbiI6WzAsMCwwXSwidHlwZSI6InBvaW50In0sImtpbmQiOiJ2ZXJ0ZXgiLCJwYXJlbnRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZWRnZS8yIiwiZW50aXR5L2VkZ2UvMyIsImVudGl0eS9lZGdlLzUiXSwicHJvcGVydGllcyI6eyJib3VuZHMiOnsibWF4IjpbMCwwLDBdLCJtaW4iOlswLDAsMF19LCJwb3NpdGlvbiI6WzAsMCwwXSwicXVhbGl0eSI6Imtlcm5lbF9ldmFsdWF0ZWQifSwicmVuZGVyX3N0YXR1cyI6InJlbmRlcmVkIiwic2RrX2Nvbm5lY3Rvcl9mcmFtZSI6eyJvcmlnaW4iOlswLDAsMF0sInhfYXhpcyI6WzEsMCwwXSwieV9heGlzIjpbMCwxLDBdLCJ6X2F4aXMiOlswLDAsMV19LCJzZGtfbWV0YWRhdGEiOnt9LCJzZW1hbnRpY19iaW5kaW5nX2lkcyI6W10sInNvdXJjZSI6eyJraW5kIjoidW5ib3VuZCJ9fSx7ImNoaWxkX2VudGl0eV9pZHMiOltdLCJjb25uZWN0b3JfYmluZGluZ19zdGF0dXMiOiJvd25lcl9ub3RfcGFydCIsImVudGl0eV9pZCI6ImVudGl0eS92ZXJ0ZXgvMSIsImV2YWx1YXRlZF90YWdzIjpbXSwiZ2VvbWV0cnkiOnsicG9zaXRpb24iOlsxMDAwLDAsMF0sInR5cGUiOiJwb2ludCJ9LCJraW5kIjoidmVydGV4IiwicGFyZW50X2VudGl0eV9pZHMiOlsiZW50aXR5L2VkZ2UvMSIsImVudGl0eS9lZGdlLzMiLCJlbnRpdHkvZWRnZS80Il0sInByb3BlcnRpZXMiOnsiYm91bmRzIjp7Im1heCI6WzEwMDAsMCwwXSwibWluIjpbMTAwMCwwLDBdfSwicG9zaXRpb24iOlsxMDAwLDAsMF0sInF1YWxpdHkiOiJrZXJuZWxfZXZhbHVhdGVkIn0sInJlbmRlcl9zdGF0dXMiOiJyZW5kZXJlZCIsInNka19jb25uZWN0b3JfZnJhbWUiOnsib3JpZ2luIjpbMTAwMCwwLDBdLCJ4X2F4aXMiOlsxLDAsMF0sInlfYXhpcyI6WzAsMSwwXSwiel9heGlzIjpbMCwwLDFdfSwic2RrX21ldGFkYXRhIjp7fSwic2VtYW50aWNfYmluZGluZ19pZHMiOltdLCJzb3VyY2UiOnsia2luZCI6InVuYm91bmQifX0seyJjaGlsZF9lbnRpdHlfaWRzIjpbXSwiY29ubmVjdG9yX2JpbmRpbmdfc3RhdHVzIjoib3duZXJfbm90X3BhcnQiLCJlbnRpdHlfaWQiOiJlbnRpdHkvdmVydGV4LzIiLCJldmFsdWF0ZWRfdGFncyI6W10sImdlb21ldHJ5Ijp7InBvc2l0aW9uIjpbMCwwLDEwMDBdLCJ0eXBlIjoicG9pbnQifSwia2luZCI6InZlcnRleCIsInBhcmVudF9lbnRpdHlfaWRzIjpbImVudGl0eS9lZGdlLzAiLCJlbnRpdHkvZWRnZS80IiwiZW50aXR5L2VkZ2UvNSJdLCJwcm9wZXJ0aWVzIjp7ImJvdW5kcyI6eyJtYXgiOlswLDAsMTAwMF0sIm1pbiI6WzAsMCwxMDAwXX0sInBvc2l0aW9uIjpbMCwwLDEwMDBdLCJxdWFsaXR5Ijoia2VybmVsX2V2YWx1YXRlZCJ9LCJyZW5kZXJfc3RhdHVzIjoicmVuZGVyZWQiLCJzZGtfY29ubmVjdG9yX2ZyYW1lIjp7Im9yaWdpbiI6WzAsMCwxMDAwXSwieF9heGlzIjpbMSwwLDBdLCJ5X2F4aXMiOlswLDEsMF0sInpfYXhpcyI6WzAsMCwxXX0sInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImtpbmQiOiJ1bmJvdW5kIn19LHsiY2hpbGRfZW50aXR5X2lkcyI6W10sImNvbm5lY3Rvcl9iaW5kaW5nX3N0YXR1cyI6Im93bmVyX25vdF9wYXJ0IiwiZW50aXR5X2lkIjoiZW50aXR5L3ZlcnRleC8zIiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJwb3NpdGlvbiI6WzAsLTEwMDAsMF0sInR5cGUiOiJwb2ludCJ9LCJraW5kIjoidmVydGV4IiwicGFyZW50X2VudGl0eV9pZHMiOlsiZW50aXR5L2VkZ2UvMCIsImVudGl0eS9lZGdlLzEiLCJlbnRpdHkvZWRnZS8yIl0sInByb3BlcnRpZXMiOnsiYm91bmRzIjp7Im1heCI6WzAsLTEwMDAsMF0sIm1pbiI6WzAsLTEwMDAsMF19LCJwb3NpdGlvbiI6WzAsLTEwMDAsMF0sInF1YWxpdHkiOiJrZXJuZWxfZXZhbHVhdGVkIn0sInJlbmRlcl9zdGF0dXMiOiJyZW5kZXJlZCIsInNka19jb25uZWN0b3JfZnJhbWUiOnsib3JpZ2luIjpbMCwtMTAwMCwwXSwieF9heGlzIjpbMSwwLDBdLCJ5X2F4aXMiOlswLDEsMF0sInpfYXhpcyI6WzAsMCwxXX0sInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImtpbmQiOiJ1bmJvdW5kIn19XSwiZmFjZV9ncm91cHMiOlt7ImVudGl0eV9pZCI6ImVudGl0eS9mYWNlLzAiLCJmaXJzdF9pbmRleCI6MCwiZ3JvdXBfaWQiOjAsImluZGV4X2NvdW50IjozLCJtZXNoX2luZGV4IjowLCJwcmltaXRpdmVfaW5kZXgiOjB9LHsiZW50aXR5X2lkIjoiZW50aXR5L2ZhY2UvMSIsImZpcnN0X2luZGV4IjozLCJncm91cF9pZCI6MSwiaW5kZXhfY291bnQiOjMsIm1lc2hfaW5kZXgiOjAsInByaW1pdGl2ZV9pbmRleCI6MH0seyJlbnRpdHlfaWQiOiJlbnRpdHkvZmFjZS8yIiwiZmlyc3RfaW5kZXgiOjYsImdyb3VwX2lkIjoyLCJpbmRleF9jb3VudCI6MywibWVzaF9pbmRleCI6MCwicHJpbWl0aXZlX2luZGV4IjowfSx7ImVudGl0eV9pZCI6ImVudGl0eS9mYWNlLzMiLCJmaXJzdF9pbmRleCI6OSwiZ3JvdXBfaWQiOjMsImluZGV4X2NvdW50IjozLCJtZXNoX2luZGV4IjowLCJwcmltaXRpdmVfaW5kZXgiOjB9XSwiZ2VvbWV0cnlfYXNzZXRfaWQiOiJzaGEyNTY6MjAwNTIzNDE0ZGUyODFhOGExNTUwODZhNTRjZDBmODgwZjY0MDQ4NTc2NWE0OGNjZGQ4ZWMyMzBmNjdkZDE3YyIsImdlb21ldHJ5X2VuZ2luZSI6eyJuYW1lIjoiT3BlbkNhc2NhZGUiLCJwcm9maWxlIjoib2NwLWV2YWx1YXRlZC1wcm9wZXJ0aWVzLTEiLCJ2ZXJzaW9uIjoiZml4dHVyZSJ9LCJzY2hlbWFfdmVyc2lvbiI6IjEuMCJ9", + "entities/sha256-3bb479fbdff1181e672905eaa8510b5aab3786e272c0940c27681f32f1db501c.json": "eyJkZWZpbml0aW9uX2lkIjoiZGVmaW5pdGlvbi9yb290L3BhcnQvbW9kZWxfcGFydCIsImVkZ2VfYXNzZXRfaWQiOiJzaGEyNTY6NmNlNDgxZGIxZmUxM2JjZmU3YTIzNzQ0MjNlNmM3MmQ3NTdkNTFjOTkxNjhiMjg3YzUyZjUzYWE2MjVjY2Q2NyIsImVkZ2VfZ3JvdXBzIjpbeyJlbnRpdHlfaWQiOiJlbnRpdHkvZWRnZS8wIiwiZmlyc3RfaW5kZXgiOjAsImdyb3VwX2lkIjowLCJpbmRleF9jb3VudCI6MiwibWVzaF9pbmRleCI6MCwicHJpbWl0aXZlX2luZGV4IjowfSx7ImVudGl0eV9pZCI6ImVudGl0eS9lZGdlLzEiLCJmaXJzdF9pbmRleCI6MiwiZ3JvdXBfaWQiOjEsImluZGV4X2NvdW50IjoyLCJtZXNoX2luZGV4IjowLCJwcmltaXRpdmVfaW5kZXgiOjB9LHsiZW50aXR5X2lkIjoiZW50aXR5L2VkZ2UvMiIsImZpcnN0X2luZGV4Ijo0LCJncm91cF9pZCI6MiwiaW5kZXhfY291bnQiOjIsIm1lc2hfaW5kZXgiOjAsInByaW1pdGl2ZV9pbmRleCI6MH0seyJlbnRpdHlfaWQiOiJlbnRpdHkvZWRnZS8zIiwiZmlyc3RfaW5kZXgiOjYsImdyb3VwX2lkIjozLCJpbmRleF9jb3VudCI6MiwibWVzaF9pbmRleCI6MCwicHJpbWl0aXZlX2luZGV4IjowfSx7ImVudGl0eV9pZCI6ImVudGl0eS9lZGdlLzQiLCJmaXJzdF9pbmRleCI6OCwiZ3JvdXBfaWQiOjQsImluZGV4X2NvdW50IjoyLCJtZXNoX2luZGV4IjowLCJwcmltaXRpdmVfaW5kZXgiOjB9LHsiZW50aXR5X2lkIjoiZW50aXR5L2VkZ2UvNSIsImZpcnN0X2luZGV4IjoxMCwiZ3JvdXBfaWQiOjUsImluZGV4X2NvdW50IjoyLCJtZXNoX2luZGV4IjowLCJwcmltaXRpdmVfaW5kZXgiOjB9XSwiZW50aXRpZXMiOlt7ImNoaWxkX2VudGl0eV9pZHMiOlsiZW50aXR5L3ZlcnRleC8yIiwiZW50aXR5L3ZlcnRleC8zIl0sImNvbm5lY3Rvcl9iaW5kaW5nX3N0YXR1cyI6ImZyYW1lX3VuZGVmaW5lZCIsImVudGl0eV9pZCI6ImVudGl0eS9lZGdlLzAiLCJldmFsdWF0ZWRfdGFncyI6W10sImdlb21ldHJ5Ijp7ImRpcmVjdGlvbiI6WzAsLTAuNzA3MTA2NzgxMTg2NTQ3NSwtMC43MDcxMDY3ODExODY1NDc1XSwib3JpZ2luIjpbMCwwLDEwMDBdLCJ0eXBlIjoibGluZSJ9LCJraW5kIjoiZWRnZSIsInBhcmVudF9lbnRpdHlfaWRzIjpbImVudGl0eS9mYWNlLzAiLCJlbnRpdHkvZmFjZS8zIl0sInByb3BlcnRpZXMiOnsiYm91bmRzIjp7Im1heCI6WzAsMCwxMDAwXSwibWluIjpbMCwtMTAwMCwwXX0sImNlbnRyb2lkIjpbMCwtNTAwLDUwMF0sImxlbmd0aCI6MTQxNC4yMTM1NjIzNzMwOTUsInF1YWxpdHkiOiJrZXJuZWxfZXZhbHVhdGVkIn0sInJlbmRlcl9zdGF0dXMiOiJyZW5kZXJlZCIsInNka19jb25uZWN0b3JfZnJhbWUiOm51bGwsInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImdyYXBoX2lkIjoiZ3JhcGgiLCJraW5kIjoibW9kZWxfdG9wb2xvZ3kiLCJub2RlX2lkIjoiYm9keSIsIm91dHB1dF9zbG90IjowLCJ0b3BvX2lkIjoiZW50aXR5L2VkZ2UvMCIsInRvcG9sb2d5X2tpbmQiOiJFREdFIn19LHsiY2hpbGRfZW50aXR5X2lkcyI6WyJlbnRpdHkvdmVydGV4LzEiLCJlbnRpdHkvdmVydGV4LzMiXSwiY29ubmVjdG9yX2JpbmRpbmdfc3RhdHVzIjoic2VsZWN0b3JfYW1iaWd1b3VzIiwiZW50aXR5X2lkIjoiZW50aXR5L2VkZ2UvMSIsImV2YWx1YXRlZF90YWdzIjpbXSwiZ2VvbWV0cnkiOnsiZGlyZWN0aW9uIjpbLTAuNzA3MTA2NzgxMTg2NTQ3NSwtMC43MDcxMDY3ODExODY1NDc1LDBdLCJvcmlnaW4iOlsxMDAwLDAsMF0sInR5cGUiOiJsaW5lIn0sImtpbmQiOiJlZGdlIiwicGFyZW50X2VudGl0eV9pZHMiOlsiZW50aXR5L2ZhY2UvMSIsImVudGl0eS9mYWNlLzMiXSwicHJvcGVydGllcyI6eyJib3VuZHMiOnsibWF4IjpbMTAwMCwwLDBdLCJtaW4iOlswLC0xMDAwLDBdfSwiY2VudHJvaWQiOls1MDAsLTUwMCwwXSwibGVuZ3RoIjoxNDE0LjIxMzU2MjM3MzA5NSwicXVhbGl0eSI6Imtlcm5lbF9ldmFsdWF0ZWQifSwicmVuZGVyX3N0YXR1cyI6InJlbmRlcmVkIiwic2RrX2Nvbm5lY3Rvcl9mcmFtZSI6eyJvcmlnaW4iOls1MDAsLTUwMCwwXSwieF9heGlzIjpbLTAuNzA3MTA2NzgxMTg2NTQ3NSwtMC43MDcxMDY3ODExODY1NDc1LDBdLCJ5X2F4aXMiOlswLDAsMV0sInpfYXhpcyI6Wy0wLjcwNzEwNjc4MTE4NjU0NzYsMC43MDcxMDY3ODExODY1NDc2LDBdfSwic2RrX21ldGFkYXRhIjp7fSwic2VtYW50aWNfYmluZGluZ19pZHMiOltdLCJzb3VyY2UiOnsiZ3JhcGhfaWQiOiJncmFwaCIsImtpbmQiOiJtb2RlbF90b3BvbG9neSIsIm5vZGVfaWQiOiJib2R5Iiwib3V0cHV0X3Nsb3QiOjAsInRvcG9faWQiOiJlbnRpdHkvZWRnZS8xIiwidG9wb2xvZ3lfa2luZCI6IkVER0UifX0seyJjaGlsZF9lbnRpdHlfaWRzIjpbImVudGl0eS92ZXJ0ZXgvMCIsImVudGl0eS92ZXJ0ZXgvMyJdLCJjb25uZWN0b3JfYmluZGluZ19zdGF0dXMiOiJzZWxlY3Rvcl91bnN0YWJsZSIsImVudGl0eV9pZCI6ImVudGl0eS9lZGdlLzIiLCJldmFsdWF0ZWRfdGFncyI6W10sImdlb21ldHJ5Ijp7ImRpcmVjdGlvbiI6WzAsLTEsMF0sIm9yaWdpbiI6WzAsMCwwXSwidHlwZSI6ImxpbmUifSwia2luZCI6ImVkZ2UiLCJwYXJlbnRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZmFjZS8wIiwiZW50aXR5L2ZhY2UvMSJdLCJwcm9wZXJ0aWVzIjp7ImJvdW5kcyI6eyJtYXgiOlswLDAsMF0sIm1pbiI6WzAsLTEwMDAsMF19LCJjZW50cm9pZCI6WzAsLTUwMCwwXSwibGVuZ3RoIjoxMDAwLCJxdWFsaXR5Ijoia2VybmVsX2V2YWx1YXRlZCJ9LCJyZW5kZXJfc3RhdHVzIjoicmVuZGVyZWQiLCJzZGtfY29ubmVjdG9yX2ZyYW1lIjp7Im9yaWdpbiI6WzAsLTUwMCwwXSwieF9heGlzIjpbMCwtMSwwXSwieV9heGlzIjpbMCwwLDFdLCJ6X2F4aXMiOlstMSwwLDBdfSwic2RrX21ldGFkYXRhIjp7fSwic2VtYW50aWNfYmluZGluZ19pZHMiOltdLCJzb3VyY2UiOnsiZ3JhcGhfaWQiOiJncmFwaCIsImtpbmQiOiJtb2RlbF90b3BvbG9neSIsIm5vZGVfaWQiOiJib2R5Iiwib3V0cHV0X3Nsb3QiOjAsInRvcG9faWQiOiJlbnRpdHkvZWRnZS8yIiwidG9wb2xvZ3lfa2luZCI6IkVER0UifX0seyJjaGlsZF9lbnRpdHlfaWRzIjpbImVudGl0eS92ZXJ0ZXgvMCIsImVudGl0eS92ZXJ0ZXgvMSJdLCJjb25uZWN0b3JfYmluZGluZ19zdGF0dXMiOiJzdXBwb3J0ZWQiLCJlbnRpdHlfaWQiOiJlbnRpdHkvZWRnZS8zIiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJkaXJlY3Rpb24iOlsxLDAsMF0sIm9yaWdpbiI6WzAsMCwwXSwidHlwZSI6ImxpbmUifSwia2luZCI6ImVkZ2UiLCJwYXJlbnRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZmFjZS8xIiwiZW50aXR5L2ZhY2UvMiJdLCJwcm9wZXJ0aWVzIjp7ImJvdW5kcyI6eyJtYXgiOlsxMDAwLDAsMF0sIm1pbiI6WzAsMCwwXX0sImNlbnRyb2lkIjpbNTAwLDAsMF0sImxlbmd0aCI6MTAwMCwicXVhbGl0eSI6Imtlcm5lbF9ldmFsdWF0ZWQifSwicmVuZGVyX3N0YXR1cyI6InJlbmRlcmVkIiwic2RrX2Nvbm5lY3Rvcl9mcmFtZSI6eyJvcmlnaW4iOls1MDAsMCwwXSwieF9heGlzIjpbMSwwLDBdLCJ5X2F4aXMiOlswLDAsMV0sInpfYXhpcyI6WzAsLTEsMF19LCJzZGtfbWV0YWRhdGEiOnt9LCJzZW1hbnRpY19iaW5kaW5nX2lkcyI6W10sInNvdXJjZSI6eyJncmFwaF9pZCI6ImdyYXBoIiwia2luZCI6Im1vZGVsX3RvcG9sb2d5Iiwibm9kZV9pZCI6ImJvZHkiLCJvdXRwdXRfc2xvdCI6MCwidG9wb19pZCI6ImVudGl0eS9lZGdlLzMiLCJ0b3BvbG9neV9raW5kIjoiRURHRSJ9fSx7ImNoaWxkX2VudGl0eV9pZHMiOlsiZW50aXR5L3ZlcnRleC8xIiwiZW50aXR5L3ZlcnRleC8yIl0sImNvbm5lY3Rvcl9iaW5kaW5nX3N0YXR1cyI6InN1cHBvcnRlZCIsImVudGl0eV9pZCI6ImVudGl0eS9lZGdlLzQiLCJldmFsdWF0ZWRfdGFncyI6W10sImdlb21ldHJ5Ijp7ImRpcmVjdGlvbiI6Wy0wLjcwNzEwNjc4MTE4NjU0NzUsMCwwLjcwNzEwNjc4MTE4NjU0NzVdLCJvcmlnaW4iOlsxMDAwLDAsMF0sInR5cGUiOiJsaW5lIn0sImtpbmQiOiJlZGdlIiwicGFyZW50X2VudGl0eV9pZHMiOlsiZW50aXR5L2ZhY2UvMiIsImVudGl0eS9mYWNlLzMiXSwicHJvcGVydGllcyI6eyJib3VuZHMiOnsibWF4IjpbMTAwMCwwLDEwMDBdLCJtaW4iOlswLDAsMF19LCJjZW50cm9pZCI6WzUwMCwwLDUwMF0sImxlbmd0aCI6MTQxNC4yMTM1NjIzNzMwOTUsInF1YWxpdHkiOiJrZXJuZWxfZXZhbHVhdGVkIn0sInJlbmRlcl9zdGF0dXMiOiJyZW5kZXJlZCIsInNka19jb25uZWN0b3JfZnJhbWUiOnsib3JpZ2luIjpbNTAwLDAsNTAwXSwieF9heGlzIjpbLTAuNzA3MTA2NzgxMTg2NTQ3NSwwLDAuNzA3MTA2NzgxMTg2NTQ3NV0sInlfYXhpcyI6WzAuNzA3MTA2NzgxMTg2NTQ3NCwwLDAuNzA3MTA2NzgxMTg2NTQ3N10sInpfYXhpcyI6WzAsMSwwXX0sInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImdyYXBoX2lkIjoiZ3JhcGgiLCJraW5kIjoibW9kZWxfdG9wb2xvZ3kiLCJub2RlX2lkIjoiYm9keSIsIm91dHB1dF9zbG90IjowLCJ0b3BvX2lkIjoiZW50aXR5L2VkZ2UvNCIsInRvcG9sb2d5X2tpbmQiOiJFREdFIn19LHsiY2hpbGRfZW50aXR5X2lkcyI6WyJlbnRpdHkvdmVydGV4LzAiLCJlbnRpdHkvdmVydGV4LzIiXSwiY29ubmVjdG9yX2JpbmRpbmdfc3RhdHVzIjoic3VwcG9ydGVkIiwiZW50aXR5X2lkIjoiZW50aXR5L2VkZ2UvNSIsImV2YWx1YXRlZF90YWdzIjpbXSwiZ2VvbWV0cnkiOnsiZGlyZWN0aW9uIjpbMCwwLDFdLCJvcmlnaW4iOlswLDAsMF0sInR5cGUiOiJsaW5lIn0sImtpbmQiOiJlZGdlIiwicGFyZW50X2VudGl0eV9pZHMiOlsiZW50aXR5L2ZhY2UvMCIsImVudGl0eS9mYWNlLzIiXSwicHJvcGVydGllcyI6eyJib3VuZHMiOnsibWF4IjpbMCwwLDEwMDBdLCJtaW4iOlswLDAsMF19LCJjZW50cm9pZCI6WzAsMCw1MDBdLCJsZW5ndGgiOjEwMDAsInF1YWxpdHkiOiJrZXJuZWxfZXZhbHVhdGVkIn0sInJlbmRlcl9zdGF0dXMiOiJyZW5kZXJlZCIsInNka19jb25uZWN0b3JfZnJhbWUiOnsib3JpZ2luIjpbMCwwLDUwMF0sInhfYXhpcyI6WzAsMCwxXSwieV9heGlzIjpbMCwxLDBdLCJ6X2F4aXMiOlstMSwwLDBdfSwic2RrX21ldGFkYXRhIjp7fSwic2VtYW50aWNfYmluZGluZ19pZHMiOltdLCJzb3VyY2UiOnsiZ3JhcGhfaWQiOiJncmFwaCIsImtpbmQiOiJtb2RlbF90b3BvbG9neSIsIm5vZGVfaWQiOiJib2R5Iiwib3V0cHV0X3Nsb3QiOjAsInRvcG9faWQiOiJlbnRpdHkvZWRnZS81IiwidG9wb2xvZ3lfa2luZCI6IkVER0UifX0seyJjaGlsZF9lbnRpdHlfaWRzIjpbImVudGl0eS9lZGdlLzAiLCJlbnRpdHkvZWRnZS8yIiwiZW50aXR5L2VkZ2UvNSJdLCJjb25uZWN0b3JfYmluZGluZ19zdGF0dXMiOiJzdXBwb3J0ZWQiLCJlbnRpdHlfaWQiOiJlbnRpdHkvZmFjZS8wIiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJub3JtYWwiOlstMSwwLDBdLCJvcmlnaW4iOlswLC0zMzMuMzMzMzMzMzMzMzMzMywzMzMuMzMzMzMzMzMzMzMzM10sInR5cGUiOiJwbGFuZSIsInhfZGlyZWN0aW9uIjpbMCwwLDFdfSwia2luZCI6ImZhY2UiLCJwYXJlbnRfZW50aXR5X2lkcyI6WyJlbnRpdHkvc29saWQvMCJdLCJwcm9wZXJ0aWVzIjp7ImFyZWEiOjUwMDAwMCwiYm91bmRzIjp7Im1heCI6WzAsMCwxMDAwXSwibWluIjpbMCwtMTAwMCwwXX0sImNlbnRyb2lkIjpbMCwtMzMzLjMzMzMzMzMzMzMzMzMsMzMzLjMzMzMzMzMzMzMzMzNdLCJvcmllbnRhdGlvbiI6ImZvcndhcmQiLCJxdWFsaXR5Ijoia2VybmVsX2V2YWx1YXRlZCJ9LCJyZW5kZXJfc3RhdHVzIjoicmVuZGVyZWQiLCJzZGtfY29ubmVjdG9yX2ZyYW1lIjp7Im9yaWdpbiI6WzAsLTMzMy4zMzMzMzMzMzMzMzMzLDMzMy4zMzMzMzMzMzMzMzMzXSwieF9heGlzIjpbMCwwLDFdLCJ5X2F4aXMiOlswLDEsMF0sInpfYXhpcyI6Wy0xLDAsMF19LCJzZGtfbWV0YWRhdGEiOnt9LCJzZW1hbnRpY19iaW5kaW5nX2lkcyI6W10sInNvdXJjZSI6eyJncmFwaF9pZCI6ImdyYXBoIiwia2luZCI6Im1vZGVsX3RvcG9sb2d5Iiwibm9kZV9pZCI6ImJvZHkiLCJvdXRwdXRfc2xvdCI6MCwidG9wb19pZCI6ImVudGl0eS9mYWNlLzAiLCJ0b3BvbG9neV9raW5kIjoiRkFDRSJ9fSx7ImNoaWxkX2VudGl0eV9pZHMiOlsiZW50aXR5L2VkZ2UvMSIsImVudGl0eS9lZGdlLzIiLCJlbnRpdHkvZWRnZS8zIl0sImNvbm5lY3Rvcl9iaW5kaW5nX3N0YXR1cyI6InN1cHBvcnRlZCIsImVudGl0eV9pZCI6ImVudGl0eS9mYWNlLzEiLCJldmFsdWF0ZWRfdGFncyI6W10sImdlb21ldHJ5Ijp7Im5vcm1hbCI6WzAsMCwtMV0sIm9yaWdpbiI6WzMzMy4zMzMzMzMzMzMzMzMzLC0zMzMuMzMzMzMzMzMzMzMzMywwXSwidHlwZSI6InBsYW5lIiwieF9kaXJlY3Rpb24iOlsxLDAsMF19LCJraW5kIjoiZmFjZSIsInBhcmVudF9lbnRpdHlfaWRzIjpbImVudGl0eS9zb2xpZC8wIl0sInByb3BlcnRpZXMiOnsiYXJlYSI6NTAwMDAwLCJib3VuZHMiOnsibWF4IjpbMTAwMCwwLDBdLCJtaW4iOlswLC0xMDAwLDBdfSwiY2VudHJvaWQiOlszMzMuMzMzMzMzMzMzMzMzMywtMzMzLjMzMzMzMzMzMzMzMzMsMF0sIm9yaWVudGF0aW9uIjoiZm9yd2FyZCIsInF1YWxpdHkiOiJrZXJuZWxfZXZhbHVhdGVkIn0sInJlbmRlcl9zdGF0dXMiOiJyZW5kZXJlZCIsInNka19jb25uZWN0b3JfZnJhbWUiOnsib3JpZ2luIjpbMzMzLjMzMzMzMzMzMzMzMzMsLTMzMy4zMzMzMzMzMzMzMzMzLDBdLCJ4X2F4aXMiOlsxLDAsMF0sInlfYXhpcyI6WzAsLTEsMF0sInpfYXhpcyI6WzAsMCwtMV19LCJzZGtfbWV0YWRhdGEiOnt9LCJzZW1hbnRpY19iaW5kaW5nX2lkcyI6W10sInNvdXJjZSI6eyJncmFwaF9pZCI6ImdyYXBoIiwia2luZCI6Im1vZGVsX3RvcG9sb2d5Iiwibm9kZV9pZCI6ImJvZHkiLCJvdXRwdXRfc2xvdCI6MCwidG9wb19pZCI6ImVudGl0eS9mYWNlLzEiLCJ0b3BvbG9neV9raW5kIjoiRkFDRSJ9fSx7ImNoaWxkX2VudGl0eV9pZHMiOlsiZW50aXR5L2VkZ2UvMyIsImVudGl0eS9lZGdlLzQiLCJlbnRpdHkvZWRnZS81Il0sImNvbm5lY3Rvcl9iaW5kaW5nX3N0YXR1cyI6InN1cHBvcnRlZCIsImVudGl0eV9pZCI6ImVudGl0eS9mYWNlLzIiLCJldmFsdWF0ZWRfdGFncyI6W10sImdlb21ldHJ5Ijp7Im5vcm1hbCI6WzAsMSwwXSwib3JpZ2luIjpbMzMzLjMzMzMzMzMzMzMzMzMsMCwzMzMuMzMzMzMzMzMzMzMzM10sInR5cGUiOiJwbGFuZSIsInhfZGlyZWN0aW9uIjpbMSwwLDBdfSwia2luZCI6ImZhY2UiLCJwYXJlbnRfZW50aXR5X2lkcyI6WyJlbnRpdHkvc29saWQvMCJdLCJwcm9wZXJ0aWVzIjp7ImFyZWEiOjUwMDAwMCwiYm91bmRzIjp7Im1heCI6WzEwMDAsMCwxMDAwXSwibWluIjpbMCwwLDBdfSwiY2VudHJvaWQiOlszMzMuMzMzMzMzMzMzMzMzMywwLDMzMy4zMzMzMzMzMzMzMzMzXSwib3JpZW50YXRpb24iOiJmb3J3YXJkIiwicXVhbGl0eSI6Imtlcm5lbF9ldmFsdWF0ZWQifSwicmVuZGVyX3N0YXR1cyI6InJlbmRlcmVkIiwic2RrX2Nvbm5lY3Rvcl9mcmFtZSI6eyJvcmlnaW4iOlszMzMuMzMzMzMzMzMzMzMzMywwLDMzMy4zMzMzMzMzMzMzMzMzXSwieF9heGlzIjpbMSwwLDBdLCJ5X2F4aXMiOlswLDAsLTFdLCJ6X2F4aXMiOlswLDEsMF19LCJzZGtfbWV0YWRhdGEiOnt9LCJzZW1hbnRpY19iaW5kaW5nX2lkcyI6W10sInNvdXJjZSI6eyJncmFwaF9pZCI6ImdyYXBoIiwia2luZCI6Im1vZGVsX3RvcG9sb2d5Iiwibm9kZV9pZCI6ImJvZHkiLCJvdXRwdXRfc2xvdCI6MCwidG9wb19pZCI6ImVudGl0eS9mYWNlLzIiLCJ0b3BvbG9neV9raW5kIjoiRkFDRSJ9fSx7ImNoaWxkX2VudGl0eV9pZHMiOlsiZW50aXR5L2VkZ2UvMCIsImVudGl0eS9lZGdlLzEiLCJlbnRpdHkvZWRnZS80Il0sImNvbm5lY3Rvcl9iaW5kaW5nX3N0YXR1cyI6InN1cHBvcnRlZCIsImVudGl0eV9pZCI6ImVudGl0eS9mYWNlLzMiLCJldmFsdWF0ZWRfdGFncyI6W10sImdlb21ldHJ5Ijp7Im5vcm1hbCI6WzAuNTc3MzUwMjY5MTg5NjI1OCwtMC41NzczNTAyNjkxODk2MjU4LDAuNTc3MzUwMjY5MTg5NjI1OF0sIm9yaWdpbiI6WzMzMy4zMzMzMzMzMzMzMzMzLC0zMzMuMzMzMzMzMzMzMzMzMywzMzMuMzMzMzMzMzMzMzMzM10sInR5cGUiOiJwbGFuZSIsInhfZGlyZWN0aW9uIjpbLTAuNzA3MTA2NzgxMTg2NTQ3NSwwLDAuNzA3MTA2NzgxMTg2NTQ3NV19LCJraW5kIjoiZmFjZSIsInBhcmVudF9lbnRpdHlfaWRzIjpbImVudGl0eS9zb2xpZC8wIl0sInByb3BlcnRpZXMiOnsiYXJlYSI6ODY2MDI1LjQwMzc4NDQzODYsImJvdW5kcyI6eyJtYXgiOlsxMDAwLDAsMTAwMF0sIm1pbiI6WzAsLTEwMDAsMF19LCJjZW50cm9pZCI6WzMzMy4zMzMzMzMzMzMzMzMzLC0zMzMuMzMzMzMzMzMzMzMzMywzMzMuMzMzMzMzMzMzMzMzM10sIm9yaWVudGF0aW9uIjoiZm9yd2FyZCIsInF1YWxpdHkiOiJrZXJuZWxfZXZhbHVhdGVkIn0sInJlbmRlcl9zdGF0dXMiOiJyZW5kZXJlZCIsInNka19jb25uZWN0b3JfZnJhbWUiOnsib3JpZ2luIjpbMzMzLjMzMzMzMzMzMzMzMzMsLTMzMy4zMzMzMzMzMzMzMzMzLDMzMy4zMzMzMzMzMzMzMzMzXSwieF9heGlzIjpbLTAuNzA3MTA2NzgxMTg2NTQ3NSwwLDAuNzA3MTA2NzgxMTg2NTQ3NV0sInlfYXhpcyI6Wy0wLjQwODI0ODI5MDQ2Mzg2MzEsLTAuODE2NDk2NTgwOTI3NzI2MSwtMC40MDgyNDgyOTA0NjM4NjMxXSwiel9heGlzIjpbMC41NzczNTAyNjkxODk2MjU4LC0wLjU3NzM1MDI2OTE4OTYyNTgsMC41NzczNTAyNjkxODk2MjU4XX0sInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImdyYXBoX2lkIjoiZ3JhcGgiLCJraW5kIjoibW9kZWxfdG9wb2xvZ3kiLCJub2RlX2lkIjoiYm9keSIsIm91dHB1dF9zbG90IjowLCJ0b3BvX2lkIjoiZW50aXR5L2ZhY2UvMyIsInRvcG9sb2d5X2tpbmQiOiJGQUNFIn19LHsiY2hpbGRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZmFjZS8wIiwiZW50aXR5L2ZhY2UvMSIsImVudGl0eS9mYWNlLzIiLCJlbnRpdHkvZmFjZS8zIl0sImNvbm5lY3Rvcl9iaW5kaW5nX3N0YXR1cyI6Im5vdF9hcHBsaWNhYmxlIiwiZW50aXR5X2lkIjoiZW50aXR5L3NvbGlkLzAiLCJldmFsdWF0ZWRfdGFncyI6W10sImdlb21ldHJ5Ijp7InR5cGUiOiJicmVwX3NvbGlkIn0sImtpbmQiOiJzb2xpZCIsInBhcmVudF9lbnRpdHlfaWRzIjpbXSwicHJvcGVydGllcyI6eyJib3VuZHMiOnsibWF4IjpbMTAwMCwwLDEwMDBdLCJtaW4iOlswLC0xMDAwLDBdfSwiY2VudHJvaWQiOlsyNTAsLTI1MCwyNTBdLCJxdWFsaXR5Ijoia2VybmVsX2V2YWx1YXRlZCIsInN1cmZhY2VfYXJlYSI6MjM2NjAyNS40MDM3ODQ0Mzg1LCJ2b2x1bWUiOjE2NjY2NjY2Ni42NjY2NjY2Nn0sInJlbmRlcl9zdGF0dXMiOiJyZW5kZXJlZCIsInNka19jb25uZWN0b3JfZnJhbWUiOm51bGwsInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImdyYXBoX2lkIjoiZ3JhcGgiLCJraW5kIjoibW9kZWxfdG9wb2xvZ3kiLCJub2RlX2lkIjoiYm9keSIsIm91dHB1dF9zbG90IjowLCJ0b3BvX2lkIjoiZW50aXR5L3NvbGlkLzAiLCJ0b3BvbG9neV9raW5kIjoiU09MSUQifX0seyJjaGlsZF9lbnRpdHlfaWRzIjpbXSwiY29ubmVjdG9yX2JpbmRpbmdfc3RhdHVzIjoic3VwcG9ydGVkIiwiZW50aXR5X2lkIjoiZW50aXR5L3ZlcnRleC8wIiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJwb3NpdGlvbiI6WzAsMCwwXSwidHlwZSI6InBvaW50In0sImtpbmQiOiJ2ZXJ0ZXgiLCJwYXJlbnRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZWRnZS8yIiwiZW50aXR5L2VkZ2UvMyIsImVudGl0eS9lZGdlLzUiXSwicHJvcGVydGllcyI6eyJib3VuZHMiOnsibWF4IjpbMCwwLDBdLCJtaW4iOlswLDAsMF19LCJwb3NpdGlvbiI6WzAsMCwwXSwicXVhbGl0eSI6Imtlcm5lbF9ldmFsdWF0ZWQifSwicmVuZGVyX3N0YXR1cyI6InJlbmRlcmVkIiwic2RrX2Nvbm5lY3Rvcl9mcmFtZSI6eyJvcmlnaW4iOlswLDAsMF0sInhfYXhpcyI6WzEsMCwwXSwieV9heGlzIjpbMCwxLDBdLCJ6X2F4aXMiOlswLDAsMV19LCJzZGtfbWV0YWRhdGEiOnt9LCJzZW1hbnRpY19iaW5kaW5nX2lkcyI6W10sInNvdXJjZSI6eyJncmFwaF9pZCI6ImdyYXBoIiwia2luZCI6Im1vZGVsX3RvcG9sb2d5Iiwibm9kZV9pZCI6ImJvZHkiLCJvdXRwdXRfc2xvdCI6MCwidG9wb19pZCI6ImVudGl0eS92ZXJ0ZXgvMCIsInRvcG9sb2d5X2tpbmQiOiJWRVJURVgifX0seyJjaGlsZF9lbnRpdHlfaWRzIjpbXSwiY29ubmVjdG9yX2JpbmRpbmdfc3RhdHVzIjoic3VwcG9ydGVkIiwiZW50aXR5X2lkIjoiZW50aXR5L3ZlcnRleC8xIiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJwb3NpdGlvbiI6WzEwMDAsMCwwXSwidHlwZSI6InBvaW50In0sImtpbmQiOiJ2ZXJ0ZXgiLCJwYXJlbnRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZWRnZS8xIiwiZW50aXR5L2VkZ2UvMyIsImVudGl0eS9lZGdlLzQiXSwicHJvcGVydGllcyI6eyJib3VuZHMiOnsibWF4IjpbMTAwMCwwLDBdLCJtaW4iOlsxMDAwLDAsMF19LCJwb3NpdGlvbiI6WzEwMDAsMCwwXSwicXVhbGl0eSI6Imtlcm5lbF9ldmFsdWF0ZWQifSwicmVuZGVyX3N0YXR1cyI6InJlbmRlcmVkIiwic2RrX2Nvbm5lY3Rvcl9mcmFtZSI6eyJvcmlnaW4iOlsxMDAwLDAsMF0sInhfYXhpcyI6WzEsMCwwXSwieV9heGlzIjpbMCwxLDBdLCJ6X2F4aXMiOlswLDAsMV19LCJzZGtfbWV0YWRhdGEiOnt9LCJzZW1hbnRpY19iaW5kaW5nX2lkcyI6W10sInNvdXJjZSI6eyJncmFwaF9pZCI6ImdyYXBoIiwia2luZCI6Im1vZGVsX3RvcG9sb2d5Iiwibm9kZV9pZCI6ImJvZHkiLCJvdXRwdXRfc2xvdCI6MCwidG9wb19pZCI6ImVudGl0eS92ZXJ0ZXgvMSIsInRvcG9sb2d5X2tpbmQiOiJWRVJURVgifX0seyJjaGlsZF9lbnRpdHlfaWRzIjpbXSwiY29ubmVjdG9yX2JpbmRpbmdfc3RhdHVzIjoic3VwcG9ydGVkIiwiZW50aXR5X2lkIjoiZW50aXR5L3ZlcnRleC8yIiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJwb3NpdGlvbiI6WzAsMCwxMDAwXSwidHlwZSI6InBvaW50In0sImtpbmQiOiJ2ZXJ0ZXgiLCJwYXJlbnRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZWRnZS8wIiwiZW50aXR5L2VkZ2UvNCIsImVudGl0eS9lZGdlLzUiXSwicHJvcGVydGllcyI6eyJib3VuZHMiOnsibWF4IjpbMCwwLDEwMDBdLCJtaW4iOlswLDAsMTAwMF19LCJwb3NpdGlvbiI6WzAsMCwxMDAwXSwicXVhbGl0eSI6Imtlcm5lbF9ldmFsdWF0ZWQifSwicmVuZGVyX3N0YXR1cyI6InJlbmRlcmVkIiwic2RrX2Nvbm5lY3Rvcl9mcmFtZSI6eyJvcmlnaW4iOlswLDAsMTAwMF0sInhfYXhpcyI6WzEsMCwwXSwieV9heGlzIjpbMCwxLDBdLCJ6X2F4aXMiOlswLDAsMV19LCJzZGtfbWV0YWRhdGEiOnt9LCJzZW1hbnRpY19iaW5kaW5nX2lkcyI6W10sInNvdXJjZSI6eyJncmFwaF9pZCI6ImdyYXBoIiwia2luZCI6Im1vZGVsX3RvcG9sb2d5Iiwibm9kZV9pZCI6ImJvZHkiLCJvdXRwdXRfc2xvdCI6MCwidG9wb19pZCI6ImVudGl0eS92ZXJ0ZXgvMiIsInRvcG9sb2d5X2tpbmQiOiJWRVJURVgifX0seyJjaGlsZF9lbnRpdHlfaWRzIjpbXSwiY29ubmVjdG9yX2JpbmRpbmdfc3RhdHVzIjoic3VwcG9ydGVkIiwiZW50aXR5X2lkIjoiZW50aXR5L3ZlcnRleC8zIiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJwb3NpdGlvbiI6WzAsLTEwMDAsMF0sInR5cGUiOiJwb2ludCJ9LCJraW5kIjoidmVydGV4IiwicGFyZW50X2VudGl0eV9pZHMiOlsiZW50aXR5L2VkZ2UvMCIsImVudGl0eS9lZGdlLzEiLCJlbnRpdHkvZWRnZS8yIl0sInByb3BlcnRpZXMiOnsiYm91bmRzIjp7Im1heCI6WzAsLTEwMDAsMF0sIm1pbiI6WzAsLTEwMDAsMF19LCJwb3NpdGlvbiI6WzAsLTEwMDAsMF0sInF1YWxpdHkiOiJrZXJuZWxfZXZhbHVhdGVkIn0sInJlbmRlcl9zdGF0dXMiOiJyZW5kZXJlZCIsInNka19jb25uZWN0b3JfZnJhbWUiOnsib3JpZ2luIjpbMCwtMTAwMCwwXSwieF9heGlzIjpbMSwwLDBdLCJ5X2F4aXMiOlswLDEsMF0sInpfYXhpcyI6WzAsMCwxXX0sInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImdyYXBoX2lkIjoiZ3JhcGgiLCJraW5kIjoibW9kZWxfdG9wb2xvZ3kiLCJub2RlX2lkIjoiYm9keSIsIm91dHB1dF9zbG90IjowLCJ0b3BvX2lkIjoiZW50aXR5L3ZlcnRleC8zIiwidG9wb2xvZ3lfa2luZCI6IlZFUlRFWCJ9fV0sImZhY2VfZ3JvdXBzIjpbeyJlbnRpdHlfaWQiOiJlbnRpdHkvZmFjZS8wIiwiZmlyc3RfaW5kZXgiOjAsImdyb3VwX2lkIjowLCJpbmRleF9jb3VudCI6MywibWVzaF9pbmRleCI6MCwicHJpbWl0aXZlX2luZGV4IjowfSx7ImVudGl0eV9pZCI6ImVudGl0eS9mYWNlLzEiLCJmaXJzdF9pbmRleCI6MywiZ3JvdXBfaWQiOjEsImluZGV4X2NvdW50IjozLCJtZXNoX2luZGV4IjowLCJwcmltaXRpdmVfaW5kZXgiOjB9LHsiZW50aXR5X2lkIjoiZW50aXR5L2ZhY2UvMiIsImZpcnN0X2luZGV4Ijo2LCJncm91cF9pZCI6MiwiaW5kZXhfY291bnQiOjMsIm1lc2hfaW5kZXgiOjAsInByaW1pdGl2ZV9pbmRleCI6MH0seyJlbnRpdHlfaWQiOiJlbnRpdHkvZmFjZS8zIiwiZmlyc3RfaW5kZXgiOjksImdyb3VwX2lkIjozLCJpbmRleF9jb3VudCI6MywibWVzaF9pbmRleCI6MCwicHJpbWl0aXZlX2luZGV4IjowfV0sImdlb21ldHJ5X2Fzc2V0X2lkIjoic2hhMjU2OjIwMDUyMzQxNGRlMjgxYThhMTU1MDg2YTU0Y2QwZjg4MGY2NDA0ODU3NjVhNDhjY2RkOGVjMjMwZjY3ZGQxN2MiLCJnZW9tZXRyeV9lbmdpbmUiOnsibmFtZSI6Ik9wZW5DYXNjYWRlIiwicHJvZmlsZSI6Im9jcC1ldmFsdWF0ZWQtcHJvcGVydGllcy0xIiwidmVyc2lvbiI6ImZpeHR1cmUifSwic2NoZW1hX3ZlcnNpb24iOiIxLjAifQ==", + "entities/sha256-3cc21a0ee57307d35c86046a2b9c0c158834d0ca41043aa292b48232e6ea63d5.json": "eyJkZWZpbml0aW9uX2lkIjoiZGVmaW5pdGlvbi9yb290L3NoYXBlL21hbnVhbC9maXh0dXJlIiwiZWRnZV9hc3NldF9pZCI6InNoYTI1Njo2Y2U0ODFkYjFmZTEzYmNmZTdhMjM3NDQyM2U2YzcyZDc1N2Q1MWM5OTE2OGIyODdjNTJmNTNhYTYyNWNjZDY3IiwiZWRnZV9ncm91cHMiOlt7ImVudGl0eV9pZCI6ImVudGl0eS9lZGdlLzAiLCJmaXJzdF9pbmRleCI6MCwiZ3JvdXBfaWQiOjAsImluZGV4X2NvdW50IjoyLCJtZXNoX2luZGV4IjowLCJwcmltaXRpdmVfaW5kZXgiOjB9LHsiZW50aXR5X2lkIjoiZW50aXR5L2VkZ2UvMSIsImZpcnN0X2luZGV4IjoyLCJncm91cF9pZCI6MSwiaW5kZXhfY291bnQiOjIsIm1lc2hfaW5kZXgiOjAsInByaW1pdGl2ZV9pbmRleCI6MH0seyJlbnRpdHlfaWQiOiJlbnRpdHkvZWRnZS8yIiwiZmlyc3RfaW5kZXgiOjQsImdyb3VwX2lkIjoyLCJpbmRleF9jb3VudCI6MiwibWVzaF9pbmRleCI6MCwicHJpbWl0aXZlX2luZGV4IjowfSx7ImVudGl0eV9pZCI6ImVudGl0eS9lZGdlLzMiLCJmaXJzdF9pbmRleCI6NiwiZ3JvdXBfaWQiOjMsImluZGV4X2NvdW50IjoyLCJtZXNoX2luZGV4IjowLCJwcmltaXRpdmVfaW5kZXgiOjB9LHsiZW50aXR5X2lkIjoiZW50aXR5L2VkZ2UvNCIsImZpcnN0X2luZGV4Ijo4LCJncm91cF9pZCI6NCwiaW5kZXhfY291bnQiOjIsIm1lc2hfaW5kZXgiOjAsInByaW1pdGl2ZV9pbmRleCI6MH0seyJlbnRpdHlfaWQiOiJlbnRpdHkvZWRnZS81IiwiZmlyc3RfaW5kZXgiOjEwLCJncm91cF9pZCI6NSwiaW5kZXhfY291bnQiOjIsIm1lc2hfaW5kZXgiOjAsInByaW1pdGl2ZV9pbmRleCI6MH1dLCJlbnRpdGllcyI6W3siY2hpbGRfZW50aXR5X2lkcyI6WyJlbnRpdHkvdmVydGV4LzIiLCJlbnRpdHkvdmVydGV4LzMiXSwiY29ubmVjdG9yX2JpbmRpbmdfc3RhdHVzIjoib3duZXJfbm90X3BhcnQiLCJlbnRpdHlfaWQiOiJlbnRpdHkvZWRnZS8wIiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJkaXJlY3Rpb24iOlswLC0wLjcwNzEwNjc4MTE4NjU0NzUsLTAuNzA3MTA2NzgxMTg2NTQ3NV0sIm9yaWdpbiI6WzAsMCwxMDAwXSwidHlwZSI6ImxpbmUifSwia2luZCI6ImVkZ2UiLCJwYXJlbnRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZmFjZS8wIiwiZW50aXR5L2ZhY2UvMyJdLCJwcm9wZXJ0aWVzIjp7ImJvdW5kcyI6eyJtYXgiOlswLDAsMTAwMF0sIm1pbiI6WzAsLTEwMDAsMF19LCJjZW50cm9pZCI6WzAsLTUwMCw1MDBdLCJsZW5ndGgiOjE0MTQuMjEzNTYyMzczMDk1LCJxdWFsaXR5Ijoia2VybmVsX2V2YWx1YXRlZCJ9LCJyZW5kZXJfc3RhdHVzIjoicmVuZGVyZWQiLCJzZGtfY29ubmVjdG9yX2ZyYW1lIjp7Im9yaWdpbiI6WzAsLTUwMCw1MDBdLCJ4X2F4aXMiOlswLC0wLjcwNzEwNjc4MTE4NjU0NzUsLTAuNzA3MTA2NzgxMTg2NTQ3NV0sInlfYXhpcyI6WzAsLTAuNzA3MTA2NzgxMTg2NTQ3NCwwLjcwNzEwNjc4MTE4NjU0NzddLCJ6X2F4aXMiOlstMSwwLDBdfSwic2RrX21ldGFkYXRhIjp7fSwic2VtYW50aWNfYmluZGluZ19pZHMiOltdLCJzb3VyY2UiOnsia2luZCI6InVuYm91bmQifX0seyJjaGlsZF9lbnRpdHlfaWRzIjpbImVudGl0eS92ZXJ0ZXgvMSIsImVudGl0eS92ZXJ0ZXgvMyJdLCJjb25uZWN0b3JfYmluZGluZ19zdGF0dXMiOiJvd25lcl9ub3RfcGFydCIsImVudGl0eV9pZCI6ImVudGl0eS9lZGdlLzEiLCJldmFsdWF0ZWRfdGFncyI6W10sImdlb21ldHJ5Ijp7ImRpcmVjdGlvbiI6Wy0wLjcwNzEwNjc4MTE4NjU0NzUsLTAuNzA3MTA2NzgxMTg2NTQ3NSwwXSwib3JpZ2luIjpbMTAwMCwwLDBdLCJ0eXBlIjoibGluZSJ9LCJraW5kIjoiZWRnZSIsInBhcmVudF9lbnRpdHlfaWRzIjpbImVudGl0eS9mYWNlLzEiLCJlbnRpdHkvZmFjZS8zIl0sInByb3BlcnRpZXMiOnsiYm91bmRzIjp7Im1heCI6WzEwMDAsMCwwXSwibWluIjpbMCwtMTAwMCwwXX0sImNlbnRyb2lkIjpbNTAwLC01MDAsMF0sImxlbmd0aCI6MTQxNC4yMTM1NjIzNzMwOTUsInF1YWxpdHkiOiJrZXJuZWxfZXZhbHVhdGVkIn0sInJlbmRlcl9zdGF0dXMiOiJyZW5kZXJlZCIsInNka19jb25uZWN0b3JfZnJhbWUiOnsib3JpZ2luIjpbNTAwLC01MDAsMF0sInhfYXhpcyI6Wy0wLjcwNzEwNjc4MTE4NjU0NzUsLTAuNzA3MTA2NzgxMTg2NTQ3NSwwXSwieV9heGlzIjpbMCwwLDFdLCJ6X2F4aXMiOlstMC43MDcxMDY3ODExODY1NDc2LDAuNzA3MTA2NzgxMTg2NTQ3NiwwXX0sInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImtpbmQiOiJ1bmJvdW5kIn19LHsiY2hpbGRfZW50aXR5X2lkcyI6WyJlbnRpdHkvdmVydGV4LzAiLCJlbnRpdHkvdmVydGV4LzMiXSwiY29ubmVjdG9yX2JpbmRpbmdfc3RhdHVzIjoib3duZXJfbm90X3BhcnQiLCJlbnRpdHlfaWQiOiJlbnRpdHkvZWRnZS8yIiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJkaXJlY3Rpb24iOlswLC0xLDBdLCJvcmlnaW4iOlswLDAsMF0sInR5cGUiOiJsaW5lIn0sImtpbmQiOiJlZGdlIiwicGFyZW50X2VudGl0eV9pZHMiOlsiZW50aXR5L2ZhY2UvMCIsImVudGl0eS9mYWNlLzEiXSwicHJvcGVydGllcyI6eyJib3VuZHMiOnsibWF4IjpbMCwwLDBdLCJtaW4iOlswLC0xMDAwLDBdfSwiY2VudHJvaWQiOlswLC01MDAsMF0sImxlbmd0aCI6MTAwMCwicXVhbGl0eSI6Imtlcm5lbF9ldmFsdWF0ZWQifSwicmVuZGVyX3N0YXR1cyI6InJlbmRlcmVkIiwic2RrX2Nvbm5lY3Rvcl9mcmFtZSI6eyJvcmlnaW4iOlswLC01MDAsMF0sInhfYXhpcyI6WzAsLTEsMF0sInlfYXhpcyI6WzAsMCwxXSwiel9heGlzIjpbLTEsMCwwXX0sInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImtpbmQiOiJ1bmJvdW5kIn19LHsiY2hpbGRfZW50aXR5X2lkcyI6WyJlbnRpdHkvdmVydGV4LzAiLCJlbnRpdHkvdmVydGV4LzEiXSwiY29ubmVjdG9yX2JpbmRpbmdfc3RhdHVzIjoib3duZXJfbm90X3BhcnQiLCJlbnRpdHlfaWQiOiJlbnRpdHkvZWRnZS8zIiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJkaXJlY3Rpb24iOlsxLDAsMF0sIm9yaWdpbiI6WzAsMCwwXSwidHlwZSI6ImxpbmUifSwia2luZCI6ImVkZ2UiLCJwYXJlbnRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZmFjZS8xIiwiZW50aXR5L2ZhY2UvMiJdLCJwcm9wZXJ0aWVzIjp7ImJvdW5kcyI6eyJtYXgiOlsxMDAwLDAsMF0sIm1pbiI6WzAsMCwwXX0sImNlbnRyb2lkIjpbNTAwLDAsMF0sImxlbmd0aCI6MTAwMCwicXVhbGl0eSI6Imtlcm5lbF9ldmFsdWF0ZWQifSwicmVuZGVyX3N0YXR1cyI6InJlbmRlcmVkIiwic2RrX2Nvbm5lY3Rvcl9mcmFtZSI6eyJvcmlnaW4iOls1MDAsMCwwXSwieF9heGlzIjpbMSwwLDBdLCJ5X2F4aXMiOlswLDAsMV0sInpfYXhpcyI6WzAsLTEsMF19LCJzZGtfbWV0YWRhdGEiOnt9LCJzZW1hbnRpY19iaW5kaW5nX2lkcyI6W10sInNvdXJjZSI6eyJraW5kIjoidW5ib3VuZCJ9fSx7ImNoaWxkX2VudGl0eV9pZHMiOlsiZW50aXR5L3ZlcnRleC8xIiwiZW50aXR5L3ZlcnRleC8yIl0sImNvbm5lY3Rvcl9iaW5kaW5nX3N0YXR1cyI6Im93bmVyX25vdF9wYXJ0IiwiZW50aXR5X2lkIjoiZW50aXR5L2VkZ2UvNCIsImV2YWx1YXRlZF90YWdzIjpbXSwiZ2VvbWV0cnkiOnsiZGlyZWN0aW9uIjpbLTAuNzA3MTA2NzgxMTg2NTQ3NSwwLDAuNzA3MTA2NzgxMTg2NTQ3NV0sIm9yaWdpbiI6WzEwMDAsMCwwXSwidHlwZSI6ImxpbmUifSwia2luZCI6ImVkZ2UiLCJwYXJlbnRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZmFjZS8yIiwiZW50aXR5L2ZhY2UvMyJdLCJwcm9wZXJ0aWVzIjp7ImJvdW5kcyI6eyJtYXgiOlsxMDAwLDAsMTAwMF0sIm1pbiI6WzAsMCwwXX0sImNlbnRyb2lkIjpbNTAwLDAsNTAwXSwibGVuZ3RoIjoxNDE0LjIxMzU2MjM3MzA5NSwicXVhbGl0eSI6Imtlcm5lbF9ldmFsdWF0ZWQifSwicmVuZGVyX3N0YXR1cyI6InJlbmRlcmVkIiwic2RrX2Nvbm5lY3Rvcl9mcmFtZSI6eyJvcmlnaW4iOls1MDAsMCw1MDBdLCJ4X2F4aXMiOlstMC43MDcxMDY3ODExODY1NDc1LDAsMC43MDcxMDY3ODExODY1NDc1XSwieV9heGlzIjpbMC43MDcxMDY3ODExODY1NDc0LDAsMC43MDcxMDY3ODExODY1NDc3XSwiel9heGlzIjpbMCwxLDBdfSwic2RrX21ldGFkYXRhIjp7fSwic2VtYW50aWNfYmluZGluZ19pZHMiOltdLCJzb3VyY2UiOnsia2luZCI6InVuYm91bmQifX0seyJjaGlsZF9lbnRpdHlfaWRzIjpbImVudGl0eS92ZXJ0ZXgvMCIsImVudGl0eS92ZXJ0ZXgvMiJdLCJjb25uZWN0b3JfYmluZGluZ19zdGF0dXMiOiJvd25lcl9ub3RfcGFydCIsImVudGl0eV9pZCI6ImVudGl0eS9lZGdlLzUiLCJldmFsdWF0ZWRfdGFncyI6W10sImdlb21ldHJ5Ijp7ImRpcmVjdGlvbiI6WzAsMCwxXSwib3JpZ2luIjpbMCwwLDBdLCJ0eXBlIjoibGluZSJ9LCJraW5kIjoiZWRnZSIsInBhcmVudF9lbnRpdHlfaWRzIjpbImVudGl0eS9mYWNlLzAiLCJlbnRpdHkvZmFjZS8yIl0sInByb3BlcnRpZXMiOnsiYm91bmRzIjp7Im1heCI6WzAsMCwxMDAwXSwibWluIjpbMCwwLDBdfSwiY2VudHJvaWQiOlswLDAsNTAwXSwibGVuZ3RoIjoxMDAwLCJxdWFsaXR5Ijoia2VybmVsX2V2YWx1YXRlZCJ9LCJyZW5kZXJfc3RhdHVzIjoicmVuZGVyZWQiLCJzZGtfY29ubmVjdG9yX2ZyYW1lIjp7Im9yaWdpbiI6WzAsMCw1MDBdLCJ4X2F4aXMiOlswLDAsMV0sInlfYXhpcyI6WzAsMSwwXSwiel9heGlzIjpbLTEsMCwwXX0sInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImtpbmQiOiJ1bmJvdW5kIn19LHsiY2hpbGRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZWRnZS8wIiwiZW50aXR5L2VkZ2UvMiIsImVudGl0eS9lZGdlLzUiXSwiY29ubmVjdG9yX2JpbmRpbmdfc3RhdHVzIjoic291cmNlX25vdF9tb2RlbCIsImVudGl0eV9pZCI6ImVudGl0eS9mYWNlLzAiLCJldmFsdWF0ZWRfdGFncyI6W10sImdlb21ldHJ5Ijp7Im5vcm1hbCI6Wy0xLDAsMF0sIm9yaWdpbiI6WzAsLTMzMy4zMzMzMzMzMzMzMzMzLDMzMy4zMzMzMzMzMzMzMzMzXSwidHlwZSI6InBsYW5lIiwieF9kaXJlY3Rpb24iOlswLDAsMV19LCJraW5kIjoiZmFjZSIsInBhcmVudF9lbnRpdHlfaWRzIjpbImVudGl0eS9zb2xpZC8wIl0sInByb3BlcnRpZXMiOnsiYXJlYSI6NTAwMDAwLCJib3VuZHMiOnsibWF4IjpbMCwwLDEwMDBdLCJtaW4iOlswLC0xMDAwLDBdfSwiY2VudHJvaWQiOlswLC0zMzMuMzMzMzMzMzMzMzMzMywzMzMuMzMzMzMzMzMzMzMzM10sIm9yaWVudGF0aW9uIjoiZm9yd2FyZCIsInF1YWxpdHkiOiJrZXJuZWxfZXZhbHVhdGVkIn0sInJlbmRlcl9zdGF0dXMiOiJyZW5kZXJlZCIsInNka19jb25uZWN0b3JfZnJhbWUiOnsib3JpZ2luIjpbMCwtMzMzLjMzMzMzMzMzMzMzMzMsMzMzLjMzMzMzMzMzMzMzMzNdLCJ4X2F4aXMiOlswLDAsMV0sInlfYXhpcyI6WzAsMSwwXSwiel9heGlzIjpbLTEsMCwwXX0sInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImtpbmQiOiJ1bmJvdW5kIn19LHsiY2hpbGRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZWRnZS8xIiwiZW50aXR5L2VkZ2UvMiIsImVudGl0eS9lZGdlLzMiXSwiY29ubmVjdG9yX2JpbmRpbmdfc3RhdHVzIjoib3duZXJfbm90X3BhcnQiLCJlbnRpdHlfaWQiOiJlbnRpdHkvZmFjZS8xIiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJub3JtYWwiOlswLDAsLTFdLCJvcmlnaW4iOlszMzMuMzMzMzMzMzMzMzMzMywtMzMzLjMzMzMzMzMzMzMzMzMsMF0sInR5cGUiOiJwbGFuZSIsInhfZGlyZWN0aW9uIjpbMSwwLDBdfSwia2luZCI6ImZhY2UiLCJwYXJlbnRfZW50aXR5X2lkcyI6WyJlbnRpdHkvc29saWQvMCJdLCJwcm9wZXJ0aWVzIjp7ImFyZWEiOjUwMDAwMCwiYm91bmRzIjp7Im1heCI6WzEwMDAsMCwwXSwibWluIjpbMCwtMTAwMCwwXX0sImNlbnRyb2lkIjpbMzMzLjMzMzMzMzMzMzMzMzMsLTMzMy4zMzMzMzMzMzMzMzMzLDBdLCJvcmllbnRhdGlvbiI6ImZvcndhcmQiLCJxdWFsaXR5Ijoia2VybmVsX2V2YWx1YXRlZCJ9LCJyZW5kZXJfc3RhdHVzIjoicmVuZGVyZWQiLCJzZGtfY29ubmVjdG9yX2ZyYW1lIjp7Im9yaWdpbiI6WzMzMy4zMzMzMzMzMzMzMzMzLC0zMzMuMzMzMzMzMzMzMzMzMywwXSwieF9heGlzIjpbMSwwLDBdLCJ5X2F4aXMiOlswLC0xLDBdLCJ6X2F4aXMiOlswLDAsLTFdfSwic2RrX21ldGFkYXRhIjp7fSwic2VtYW50aWNfYmluZGluZ19pZHMiOltdLCJzb3VyY2UiOnsia2luZCI6InVuYm91bmQifX0seyJjaGlsZF9lbnRpdHlfaWRzIjpbImVudGl0eS9lZGdlLzMiLCJlbnRpdHkvZWRnZS80IiwiZW50aXR5L2VkZ2UvNSJdLCJjb25uZWN0b3JfYmluZGluZ19zdGF0dXMiOiJvd25lcl9ub3RfcGFydCIsImVudGl0eV9pZCI6ImVudGl0eS9mYWNlLzIiLCJldmFsdWF0ZWRfdGFncyI6W10sImdlb21ldHJ5Ijp7Im5vcm1hbCI6WzAsMSwwXSwib3JpZ2luIjpbMzMzLjMzMzMzMzMzMzMzMzMsMCwzMzMuMzMzMzMzMzMzMzMzM10sInR5cGUiOiJwbGFuZSIsInhfZGlyZWN0aW9uIjpbMSwwLDBdfSwia2luZCI6ImZhY2UiLCJwYXJlbnRfZW50aXR5X2lkcyI6WyJlbnRpdHkvc29saWQvMCJdLCJwcm9wZXJ0aWVzIjp7ImFyZWEiOjUwMDAwMCwiYm91bmRzIjp7Im1heCI6WzEwMDAsMCwxMDAwXSwibWluIjpbMCwwLDBdfSwiY2VudHJvaWQiOlszMzMuMzMzMzMzMzMzMzMzMywwLDMzMy4zMzMzMzMzMzMzMzMzXSwib3JpZW50YXRpb24iOiJmb3J3YXJkIiwicXVhbGl0eSI6Imtlcm5lbF9ldmFsdWF0ZWQifSwicmVuZGVyX3N0YXR1cyI6InJlbmRlcmVkIiwic2RrX2Nvbm5lY3Rvcl9mcmFtZSI6eyJvcmlnaW4iOlszMzMuMzMzMzMzMzMzMzMzMywwLDMzMy4zMzMzMzMzMzMzMzMzXSwieF9heGlzIjpbMSwwLDBdLCJ5X2F4aXMiOlswLDAsLTFdLCJ6X2F4aXMiOlswLDEsMF19LCJzZGtfbWV0YWRhdGEiOnt9LCJzZW1hbnRpY19iaW5kaW5nX2lkcyI6W10sInNvdXJjZSI6eyJraW5kIjoidW5ib3VuZCJ9fSx7ImNoaWxkX2VudGl0eV9pZHMiOlsiZW50aXR5L2VkZ2UvMCIsImVudGl0eS9lZGdlLzEiLCJlbnRpdHkvZWRnZS80Il0sImNvbm5lY3Rvcl9iaW5kaW5nX3N0YXR1cyI6Im93bmVyX25vdF9wYXJ0IiwiZW50aXR5X2lkIjoiZW50aXR5L2ZhY2UvMyIsImV2YWx1YXRlZF90YWdzIjpbXSwiZ2VvbWV0cnkiOnsibm9ybWFsIjpbMC41NzczNTAyNjkxODk2MjU4LC0wLjU3NzM1MDI2OTE4OTYyNTgsMC41NzczNTAyNjkxODk2MjU4XSwib3JpZ2luIjpbMzMzLjMzMzMzMzMzMzMzMzMsLTMzMy4zMzMzMzMzMzMzMzMzLDMzMy4zMzMzMzMzMzMzMzMzXSwidHlwZSI6InBsYW5lIiwieF9kaXJlY3Rpb24iOlstMC43MDcxMDY3ODExODY1NDc1LDAsMC43MDcxMDY3ODExODY1NDc1XX0sImtpbmQiOiJmYWNlIiwicGFyZW50X2VudGl0eV9pZHMiOlsiZW50aXR5L3NvbGlkLzAiXSwicHJvcGVydGllcyI6eyJhcmVhIjo4NjYwMjUuNDAzNzg0NDM4NiwiYm91bmRzIjp7Im1heCI6WzEwMDAsMCwxMDAwXSwibWluIjpbMCwtMTAwMCwwXX0sImNlbnRyb2lkIjpbMzMzLjMzMzMzMzMzMzMzMzMsLTMzMy4zMzMzMzMzMzMzMzMzLDMzMy4zMzMzMzMzMzMzMzMzXSwib3JpZW50YXRpb24iOiJmb3J3YXJkIiwicXVhbGl0eSI6Imtlcm5lbF9ldmFsdWF0ZWQifSwicmVuZGVyX3N0YXR1cyI6InJlbmRlcmVkIiwic2RrX2Nvbm5lY3Rvcl9mcmFtZSI6eyJvcmlnaW4iOlszMzMuMzMzMzMzMzMzMzMzMywtMzMzLjMzMzMzMzMzMzMzMzMsMzMzLjMzMzMzMzMzMzMzMzNdLCJ4X2F4aXMiOlstMC43MDcxMDY3ODExODY1NDc1LDAsMC43MDcxMDY3ODExODY1NDc1XSwieV9heGlzIjpbLTAuNDA4MjQ4MjkwNDYzODYzMSwtMC44MTY0OTY1ODA5Mjc3MjYxLC0wLjQwODI0ODI5MDQ2Mzg2MzFdLCJ6X2F4aXMiOlswLjU3NzM1MDI2OTE4OTYyNTgsLTAuNTc3MzUwMjY5MTg5NjI1OCwwLjU3NzM1MDI2OTE4OTYyNThdfSwic2RrX21ldGFkYXRhIjp7fSwic2VtYW50aWNfYmluZGluZ19pZHMiOltdLCJzb3VyY2UiOnsia2luZCI6InVuYm91bmQifX0seyJjaGlsZF9lbnRpdHlfaWRzIjpbImVudGl0eS9mYWNlLzAiLCJlbnRpdHkvZmFjZS8xIiwiZW50aXR5L2ZhY2UvMiIsImVudGl0eS9mYWNlLzMiXSwiY29ubmVjdG9yX2JpbmRpbmdfc3RhdHVzIjoibm90X2FwcGxpY2FibGUiLCJlbnRpdHlfaWQiOiJlbnRpdHkvc29saWQvMCIsImV2YWx1YXRlZF90YWdzIjpbXSwiZ2VvbWV0cnkiOnsidHlwZSI6ImJyZXBfc29saWQifSwia2luZCI6InNvbGlkIiwicGFyZW50X2VudGl0eV9pZHMiOltdLCJwcm9wZXJ0aWVzIjp7ImJvdW5kcyI6eyJtYXgiOlsxMDAwLDAsMTAwMF0sIm1pbiI6WzAsLTEwMDAsMF19LCJjZW50cm9pZCI6WzI1MCwtMjUwLDI1MF0sInF1YWxpdHkiOiJrZXJuZWxfZXZhbHVhdGVkIiwic3VyZmFjZV9hcmVhIjoyMzY2MDI1LjQwMzc4NDQzODUsInZvbHVtZSI6MTY2NjY2NjY2LjY2NjY2NjY2fSwicmVuZGVyX3N0YXR1cyI6InJlbmRlcmVkIiwic2RrX2Nvbm5lY3Rvcl9mcmFtZSI6bnVsbCwic2RrX21ldGFkYXRhIjp7fSwic2VtYW50aWNfYmluZGluZ19pZHMiOltdLCJzb3VyY2UiOnsia2luZCI6InVuYm91bmQifX0seyJjaGlsZF9lbnRpdHlfaWRzIjpbXSwiY29ubmVjdG9yX2JpbmRpbmdfc3RhdHVzIjoib3duZXJfbm90X3BhcnQiLCJlbnRpdHlfaWQiOiJlbnRpdHkvdmVydGV4LzAiLCJldmFsdWF0ZWRfdGFncyI6W10sImdlb21ldHJ5Ijp7InBvc2l0aW9uIjpbMCwwLDBdLCJ0eXBlIjoicG9pbnQifSwia2luZCI6InZlcnRleCIsInBhcmVudF9lbnRpdHlfaWRzIjpbImVudGl0eS9lZGdlLzIiLCJlbnRpdHkvZWRnZS8zIiwiZW50aXR5L2VkZ2UvNSJdLCJwcm9wZXJ0aWVzIjp7ImJvdW5kcyI6eyJtYXgiOlswLDAsMF0sIm1pbiI6WzAsMCwwXX0sInBvc2l0aW9uIjpbMCwwLDBdLCJxdWFsaXR5Ijoia2VybmVsX2V2YWx1YXRlZCJ9LCJyZW5kZXJfc3RhdHVzIjoicmVuZGVyZWQiLCJzZGtfY29ubmVjdG9yX2ZyYW1lIjp7Im9yaWdpbiI6WzAsMCwwXSwieF9heGlzIjpbMSwwLDBdLCJ5X2F4aXMiOlswLDEsMF0sInpfYXhpcyI6WzAsMCwxXX0sInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImtpbmQiOiJ1bmJvdW5kIn19LHsiY2hpbGRfZW50aXR5X2lkcyI6W10sImNvbm5lY3Rvcl9iaW5kaW5nX3N0YXR1cyI6Im93bmVyX25vdF9wYXJ0IiwiZW50aXR5X2lkIjoiZW50aXR5L3ZlcnRleC8xIiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJwb3NpdGlvbiI6WzEwMDAsMCwwXSwidHlwZSI6InBvaW50In0sImtpbmQiOiJ2ZXJ0ZXgiLCJwYXJlbnRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZWRnZS8xIiwiZW50aXR5L2VkZ2UvMyIsImVudGl0eS9lZGdlLzQiXSwicHJvcGVydGllcyI6eyJib3VuZHMiOnsibWF4IjpbMTAwMCwwLDBdLCJtaW4iOlsxMDAwLDAsMF19LCJwb3NpdGlvbiI6WzEwMDAsMCwwXSwicXVhbGl0eSI6Imtlcm5lbF9ldmFsdWF0ZWQifSwicmVuZGVyX3N0YXR1cyI6InJlbmRlcmVkIiwic2RrX2Nvbm5lY3Rvcl9mcmFtZSI6eyJvcmlnaW4iOlsxMDAwLDAsMF0sInhfYXhpcyI6WzEsMCwwXSwieV9heGlzIjpbMCwxLDBdLCJ6X2F4aXMiOlswLDAsMV19LCJzZGtfbWV0YWRhdGEiOnt9LCJzZW1hbnRpY19iaW5kaW5nX2lkcyI6W10sInNvdXJjZSI6eyJraW5kIjoidW5ib3VuZCJ9fSx7ImNoaWxkX2VudGl0eV9pZHMiOltdLCJjb25uZWN0b3JfYmluZGluZ19zdGF0dXMiOiJvd25lcl9ub3RfcGFydCIsImVudGl0eV9pZCI6ImVudGl0eS92ZXJ0ZXgvMiIsImV2YWx1YXRlZF90YWdzIjpbXSwiZ2VvbWV0cnkiOnsicG9zaXRpb24iOlswLDAsMTAwMF0sInR5cGUiOiJwb2ludCJ9LCJraW5kIjoidmVydGV4IiwicGFyZW50X2VudGl0eV9pZHMiOlsiZW50aXR5L2VkZ2UvMCIsImVudGl0eS9lZGdlLzQiLCJlbnRpdHkvZWRnZS81Il0sInByb3BlcnRpZXMiOnsiYm91bmRzIjp7Im1heCI6WzAsMCwxMDAwXSwibWluIjpbMCwwLDEwMDBdfSwicG9zaXRpb24iOlswLDAsMTAwMF0sInF1YWxpdHkiOiJrZXJuZWxfZXZhbHVhdGVkIn0sInJlbmRlcl9zdGF0dXMiOiJyZW5kZXJlZCIsInNka19jb25uZWN0b3JfZnJhbWUiOnsib3JpZ2luIjpbMCwwLDEwMDBdLCJ4X2F4aXMiOlsxLDAsMF0sInlfYXhpcyI6WzAsMSwwXSwiel9heGlzIjpbMCwwLDFdfSwic2RrX21ldGFkYXRhIjp7fSwic2VtYW50aWNfYmluZGluZ19pZHMiOltdLCJzb3VyY2UiOnsia2luZCI6InVuYm91bmQifX0seyJjaGlsZF9lbnRpdHlfaWRzIjpbXSwiY29ubmVjdG9yX2JpbmRpbmdfc3RhdHVzIjoib3duZXJfbm90X3BhcnQiLCJlbnRpdHlfaWQiOiJlbnRpdHkvdmVydGV4LzMiLCJldmFsdWF0ZWRfdGFncyI6W10sImdlb21ldHJ5Ijp7InBvc2l0aW9uIjpbMCwtMTAwMCwwXSwidHlwZSI6InBvaW50In0sImtpbmQiOiJ2ZXJ0ZXgiLCJwYXJlbnRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZWRnZS8wIiwiZW50aXR5L2VkZ2UvMSIsImVudGl0eS9lZGdlLzIiXSwicHJvcGVydGllcyI6eyJib3VuZHMiOnsibWF4IjpbMCwtMTAwMCwwXSwibWluIjpbMCwtMTAwMCwwXX0sInBvc2l0aW9uIjpbMCwtMTAwMCwwXSwicXVhbGl0eSI6Imtlcm5lbF9ldmFsdWF0ZWQifSwicmVuZGVyX3N0YXR1cyI6InJlbmRlcmVkIiwic2RrX2Nvbm5lY3Rvcl9mcmFtZSI6eyJvcmlnaW4iOlswLC0xMDAwLDBdLCJ4X2F4aXMiOlsxLDAsMF0sInlfYXhpcyI6WzAsMSwwXSwiel9heGlzIjpbMCwwLDFdfSwic2RrX21ldGFkYXRhIjp7fSwic2VtYW50aWNfYmluZGluZ19pZHMiOltdLCJzb3VyY2UiOnsia2luZCI6InVuYm91bmQifX1dLCJmYWNlX2dyb3VwcyI6W3siZW50aXR5X2lkIjoiZW50aXR5L2ZhY2UvMCIsImZpcnN0X2luZGV4IjowLCJncm91cF9pZCI6MCwiaW5kZXhfY291bnQiOjMsIm1lc2hfaW5kZXgiOjAsInByaW1pdGl2ZV9pbmRleCI6MH0seyJlbnRpdHlfaWQiOiJlbnRpdHkvZmFjZS8xIiwiZmlyc3RfaW5kZXgiOjMsImdyb3VwX2lkIjoxLCJpbmRleF9jb3VudCI6MywibWVzaF9pbmRleCI6MCwicHJpbWl0aXZlX2luZGV4IjowfSx7ImVudGl0eV9pZCI6ImVudGl0eS9mYWNlLzIiLCJmaXJzdF9pbmRleCI6NiwiZ3JvdXBfaWQiOjIsImluZGV4X2NvdW50IjozLCJtZXNoX2luZGV4IjowLCJwcmltaXRpdmVfaW5kZXgiOjB9LHsiZW50aXR5X2lkIjoiZW50aXR5L2ZhY2UvMyIsImZpcnN0X2luZGV4Ijo5LCJncm91cF9pZCI6MywiaW5kZXhfY291bnQiOjMsIm1lc2hfaW5kZXgiOjAsInByaW1pdGl2ZV9pbmRleCI6MH1dLCJnZW9tZXRyeV9hc3NldF9pZCI6InNoYTI1NjoyMDA1MjM0MTRkZTI4MWE4YTE1NTA4NmE1NGNkMGY4ODBmNjQwNDg1NzY1YTQ4Y2NkZDhlYzIzMGY2N2RkMTdjIiwiZ2VvbWV0cnlfZW5naW5lIjp7Im5hbWUiOiJPcGVuQ2FzY2FkZSIsInByb2ZpbGUiOiJvY3AtZXZhbHVhdGVkLXByb3BlcnRpZXMtMSIsInZlcnNpb24iOiJmaXh0dXJlIn0sInNjaGVtYV92ZXJzaW9uIjoiMS4wIn0=", + "entities/sha256-3ea2b1921b0c3ffc9684ba2ddcaa91f24a31ca1c6f037a09d31a0ea56d6fdb71.json": "eyJkZWZpbml0aW9uX2lkIjoiZGVmaW5pdGlvbi9yb290L3BhcnQvc2hhcmVkX3BhcnQiLCJlZGdlX2Fzc2V0X2lkIjoic2hhMjU2OjZjZTQ4MWRiMWZlMTNiY2ZlN2EyMzc0NDIzZTZjNzJkNzU3ZDUxYzk5MTY4YjI4N2M1MmY1M2FhNjI1Y2NkNjciLCJlZGdlX2dyb3VwcyI6W3siZW50aXR5X2lkIjoiZW50aXR5L2VkZ2UvMCIsImZpcnN0X2luZGV4IjowLCJncm91cF9pZCI6MCwiaW5kZXhfY291bnQiOjIsIm1lc2hfaW5kZXgiOjAsInByaW1pdGl2ZV9pbmRleCI6MH0seyJlbnRpdHlfaWQiOiJlbnRpdHkvZWRnZS8xIiwiZmlyc3RfaW5kZXgiOjIsImdyb3VwX2lkIjoxLCJpbmRleF9jb3VudCI6MiwibWVzaF9pbmRleCI6MCwicHJpbWl0aXZlX2luZGV4IjowfSx7ImVudGl0eV9pZCI6ImVudGl0eS9lZGdlLzIiLCJmaXJzdF9pbmRleCI6NCwiZ3JvdXBfaWQiOjIsImluZGV4X2NvdW50IjoyLCJtZXNoX2luZGV4IjowLCJwcmltaXRpdmVfaW5kZXgiOjB9LHsiZW50aXR5X2lkIjoiZW50aXR5L2VkZ2UvMyIsImZpcnN0X2luZGV4Ijo2LCJncm91cF9pZCI6MywiaW5kZXhfY291bnQiOjIsIm1lc2hfaW5kZXgiOjAsInByaW1pdGl2ZV9pbmRleCI6MH0seyJlbnRpdHlfaWQiOiJlbnRpdHkvZWRnZS80IiwiZmlyc3RfaW5kZXgiOjgsImdyb3VwX2lkIjo0LCJpbmRleF9jb3VudCI6MiwibWVzaF9pbmRleCI6MCwicHJpbWl0aXZlX2luZGV4IjowfSx7ImVudGl0eV9pZCI6ImVudGl0eS9lZGdlLzUiLCJmaXJzdF9pbmRleCI6MTAsImdyb3VwX2lkIjo1LCJpbmRleF9jb3VudCI6MiwibWVzaF9pbmRleCI6MCwicHJpbWl0aXZlX2luZGV4IjowfV0sImVudGl0aWVzIjpbeyJjaGlsZF9lbnRpdHlfaWRzIjpbImVudGl0eS92ZXJ0ZXgvMiIsImVudGl0eS92ZXJ0ZXgvMyJdLCJjb25uZWN0b3JfYmluZGluZ19zdGF0dXMiOiJzb3VyY2Vfbm90X21vZGVsIiwiZW50aXR5X2lkIjoiZW50aXR5L2VkZ2UvMCIsImV2YWx1YXRlZF90YWdzIjpbXSwiZ2VvbWV0cnkiOnsiZGlyZWN0aW9uIjpbMCwtMC43MDcxMDY3ODExODY1NDc1LC0wLjcwNzEwNjc4MTE4NjU0NzVdLCJvcmlnaW4iOlswLDAsMTAwMF0sInR5cGUiOiJsaW5lIn0sImtpbmQiOiJlZGdlIiwicGFyZW50X2VudGl0eV9pZHMiOlsiZW50aXR5L2ZhY2UvMCIsImVudGl0eS9mYWNlLzMiXSwicHJvcGVydGllcyI6eyJib3VuZHMiOnsibWF4IjpbMCwwLDEwMDBdLCJtaW4iOlswLC0xMDAwLDBdfSwiY2VudHJvaWQiOlswLC01MDAsNTAwXSwibGVuZ3RoIjoxNDE0LjIxMzU2MjM3MzA5NSwicXVhbGl0eSI6Imtlcm5lbF9ldmFsdWF0ZWQifSwicmVuZGVyX3N0YXR1cyI6InJlbmRlcmVkIiwic2RrX2Nvbm5lY3Rvcl9mcmFtZSI6eyJvcmlnaW4iOlswLC01MDAsNTAwXSwieF9heGlzIjpbMCwtMC43MDcxMDY3ODExODY1NDc1LC0wLjcwNzEwNjc4MTE4NjU0NzVdLCJ5X2F4aXMiOlswLC0wLjcwNzEwNjc4MTE4NjU0NzQsMC43MDcxMDY3ODExODY1NDc3XSwiel9heGlzIjpbLTEsMCwwXX0sInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImtpbmQiOiJ1bmJvdW5kIn19LHsiY2hpbGRfZW50aXR5X2lkcyI6WyJlbnRpdHkvdmVydGV4LzEiLCJlbnRpdHkvdmVydGV4LzMiXSwiY29ubmVjdG9yX2JpbmRpbmdfc3RhdHVzIjoic291cmNlX25vdF9tb2RlbCIsImVudGl0eV9pZCI6ImVudGl0eS9lZGdlLzEiLCJldmFsdWF0ZWRfdGFncyI6W10sImdlb21ldHJ5Ijp7ImRpcmVjdGlvbiI6Wy0wLjcwNzEwNjc4MTE4NjU0NzUsLTAuNzA3MTA2NzgxMTg2NTQ3NSwwXSwib3JpZ2luIjpbMTAwMCwwLDBdLCJ0eXBlIjoibGluZSJ9LCJraW5kIjoiZWRnZSIsInBhcmVudF9lbnRpdHlfaWRzIjpbImVudGl0eS9mYWNlLzEiLCJlbnRpdHkvZmFjZS8zIl0sInByb3BlcnRpZXMiOnsiYm91bmRzIjp7Im1heCI6WzEwMDAsMCwwXSwibWluIjpbMCwtMTAwMCwwXX0sImNlbnRyb2lkIjpbNTAwLC01MDAsMF0sImxlbmd0aCI6MTQxNC4yMTM1NjIzNzMwOTUsInF1YWxpdHkiOiJrZXJuZWxfZXZhbHVhdGVkIn0sInJlbmRlcl9zdGF0dXMiOiJyZW5kZXJlZCIsInNka19jb25uZWN0b3JfZnJhbWUiOnsib3JpZ2luIjpbNTAwLC01MDAsMF0sInhfYXhpcyI6Wy0wLjcwNzEwNjc4MTE4NjU0NzUsLTAuNzA3MTA2NzgxMTg2NTQ3NSwwXSwieV9heGlzIjpbMCwwLDFdLCJ6X2F4aXMiOlstMC43MDcxMDY3ODExODY1NDc2LDAuNzA3MTA2NzgxMTg2NTQ3NiwwXX0sInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImtpbmQiOiJ1bmJvdW5kIn19LHsiY2hpbGRfZW50aXR5X2lkcyI6WyJlbnRpdHkvdmVydGV4LzAiLCJlbnRpdHkvdmVydGV4LzMiXSwiY29ubmVjdG9yX2JpbmRpbmdfc3RhdHVzIjoic291cmNlX25vdF9tb2RlbCIsImVudGl0eV9pZCI6ImVudGl0eS9lZGdlLzIiLCJldmFsdWF0ZWRfdGFncyI6W10sImdlb21ldHJ5Ijp7ImRpcmVjdGlvbiI6WzAsLTEsMF0sIm9yaWdpbiI6WzAsMCwwXSwidHlwZSI6ImxpbmUifSwia2luZCI6ImVkZ2UiLCJwYXJlbnRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZmFjZS8wIiwiZW50aXR5L2ZhY2UvMSJdLCJwcm9wZXJ0aWVzIjp7ImJvdW5kcyI6eyJtYXgiOlswLDAsMF0sIm1pbiI6WzAsLTEwMDAsMF19LCJjZW50cm9pZCI6WzAsLTUwMCwwXSwibGVuZ3RoIjoxMDAwLCJxdWFsaXR5Ijoia2VybmVsX2V2YWx1YXRlZCJ9LCJyZW5kZXJfc3RhdHVzIjoicmVuZGVyZWQiLCJzZGtfY29ubmVjdG9yX2ZyYW1lIjp7Im9yaWdpbiI6WzAsLTUwMCwwXSwieF9heGlzIjpbMCwtMSwwXSwieV9heGlzIjpbMCwwLDFdLCJ6X2F4aXMiOlstMSwwLDBdfSwic2RrX21ldGFkYXRhIjp7fSwic2VtYW50aWNfYmluZGluZ19pZHMiOltdLCJzb3VyY2UiOnsia2luZCI6InVuYm91bmQifX0seyJjaGlsZF9lbnRpdHlfaWRzIjpbImVudGl0eS92ZXJ0ZXgvMCIsImVudGl0eS92ZXJ0ZXgvMSJdLCJjb25uZWN0b3JfYmluZGluZ19zdGF0dXMiOiJzb3VyY2Vfbm90X21vZGVsIiwiZW50aXR5X2lkIjoiZW50aXR5L2VkZ2UvMyIsImV2YWx1YXRlZF90YWdzIjpbXSwiZ2VvbWV0cnkiOnsiZGlyZWN0aW9uIjpbMSwwLDBdLCJvcmlnaW4iOlswLDAsMF0sInR5cGUiOiJsaW5lIn0sImtpbmQiOiJlZGdlIiwicGFyZW50X2VudGl0eV9pZHMiOlsiZW50aXR5L2ZhY2UvMSIsImVudGl0eS9mYWNlLzIiXSwicHJvcGVydGllcyI6eyJib3VuZHMiOnsibWF4IjpbMTAwMCwwLDBdLCJtaW4iOlswLDAsMF19LCJjZW50cm9pZCI6WzUwMCwwLDBdLCJsZW5ndGgiOjEwMDAsInF1YWxpdHkiOiJrZXJuZWxfZXZhbHVhdGVkIn0sInJlbmRlcl9zdGF0dXMiOiJyZW5kZXJlZCIsInNka19jb25uZWN0b3JfZnJhbWUiOnsib3JpZ2luIjpbNTAwLDAsMF0sInhfYXhpcyI6WzEsMCwwXSwieV9heGlzIjpbMCwwLDFdLCJ6X2F4aXMiOlswLC0xLDBdfSwic2RrX21ldGFkYXRhIjp7fSwic2VtYW50aWNfYmluZGluZ19pZHMiOltdLCJzb3VyY2UiOnsia2luZCI6InVuYm91bmQifX0seyJjaGlsZF9lbnRpdHlfaWRzIjpbImVudGl0eS92ZXJ0ZXgvMSIsImVudGl0eS92ZXJ0ZXgvMiJdLCJjb25uZWN0b3JfYmluZGluZ19zdGF0dXMiOiJzb3VyY2Vfbm90X21vZGVsIiwiZW50aXR5X2lkIjoiZW50aXR5L2VkZ2UvNCIsImV2YWx1YXRlZF90YWdzIjpbXSwiZ2VvbWV0cnkiOnsiZGlyZWN0aW9uIjpbLTAuNzA3MTA2NzgxMTg2NTQ3NSwwLDAuNzA3MTA2NzgxMTg2NTQ3NV0sIm9yaWdpbiI6WzEwMDAsMCwwXSwidHlwZSI6ImxpbmUifSwia2luZCI6ImVkZ2UiLCJwYXJlbnRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZmFjZS8yIiwiZW50aXR5L2ZhY2UvMyJdLCJwcm9wZXJ0aWVzIjp7ImJvdW5kcyI6eyJtYXgiOlsxMDAwLDAsMTAwMF0sIm1pbiI6WzAsMCwwXX0sImNlbnRyb2lkIjpbNTAwLDAsNTAwXSwibGVuZ3RoIjoxNDE0LjIxMzU2MjM3MzA5NSwicXVhbGl0eSI6Imtlcm5lbF9ldmFsdWF0ZWQifSwicmVuZGVyX3N0YXR1cyI6InJlbmRlcmVkIiwic2RrX2Nvbm5lY3Rvcl9mcmFtZSI6eyJvcmlnaW4iOls1MDAsMCw1MDBdLCJ4X2F4aXMiOlstMC43MDcxMDY3ODExODY1NDc1LDAsMC43MDcxMDY3ODExODY1NDc1XSwieV9heGlzIjpbMC43MDcxMDY3ODExODY1NDc0LDAsMC43MDcxMDY3ODExODY1NDc3XSwiel9heGlzIjpbMCwxLDBdfSwic2RrX21ldGFkYXRhIjp7fSwic2VtYW50aWNfYmluZGluZ19pZHMiOltdLCJzb3VyY2UiOnsia2luZCI6InVuYm91bmQifX0seyJjaGlsZF9lbnRpdHlfaWRzIjpbImVudGl0eS92ZXJ0ZXgvMCIsImVudGl0eS92ZXJ0ZXgvMiJdLCJjb25uZWN0b3JfYmluZGluZ19zdGF0dXMiOiJzb3VyY2Vfbm90X21vZGVsIiwiZW50aXR5X2lkIjoiZW50aXR5L2VkZ2UvNSIsImV2YWx1YXRlZF90YWdzIjpbXSwiZ2VvbWV0cnkiOnsiZGlyZWN0aW9uIjpbMCwwLDFdLCJvcmlnaW4iOlswLDAsMF0sInR5cGUiOiJsaW5lIn0sImtpbmQiOiJlZGdlIiwicGFyZW50X2VudGl0eV9pZHMiOlsiZW50aXR5L2ZhY2UvMCIsImVudGl0eS9mYWNlLzIiXSwicHJvcGVydGllcyI6eyJib3VuZHMiOnsibWF4IjpbMCwwLDEwMDBdLCJtaW4iOlswLDAsMF19LCJjZW50cm9pZCI6WzAsMCw1MDBdLCJsZW5ndGgiOjEwMDAsInF1YWxpdHkiOiJrZXJuZWxfZXZhbHVhdGVkIn0sInJlbmRlcl9zdGF0dXMiOiJyZW5kZXJlZCIsInNka19jb25uZWN0b3JfZnJhbWUiOnsib3JpZ2luIjpbMCwwLDUwMF0sInhfYXhpcyI6WzAsMCwxXSwieV9heGlzIjpbMCwxLDBdLCJ6X2F4aXMiOlstMSwwLDBdfSwic2RrX21ldGFkYXRhIjp7fSwic2VtYW50aWNfYmluZGluZ19pZHMiOltdLCJzb3VyY2UiOnsia2luZCI6InVuYm91bmQifX0seyJjaGlsZF9lbnRpdHlfaWRzIjpbImVudGl0eS9lZGdlLzAiLCJlbnRpdHkvZWRnZS8yIiwiZW50aXR5L2VkZ2UvNSJdLCJjb25uZWN0b3JfYmluZGluZ19zdGF0dXMiOiJzdXBwb3J0ZWQiLCJlbnRpdHlfaWQiOiJlbnRpdHkvZmFjZS8wIiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJub3JtYWwiOlstMSwwLDBdLCJvcmlnaW4iOlswLC0zMzMuMzMzMzMzMzMzMzMzMywzMzMuMzMzMzMzMzMzMzMzM10sInR5cGUiOiJwbGFuZSIsInhfZGlyZWN0aW9uIjpbMCwwLDFdfSwia2luZCI6ImZhY2UiLCJwYXJlbnRfZW50aXR5X2lkcyI6WyJlbnRpdHkvc29saWQvMCJdLCJwcm9wZXJ0aWVzIjp7ImFyZWEiOjUwMDAwMCwiYm91bmRzIjp7Im1heCI6WzAsMCwxMDAwXSwibWluIjpbMCwtMTAwMCwwXX0sImNlbnRyb2lkIjpbMCwtMzMzLjMzMzMzMzMzMzMzMzMsMzMzLjMzMzMzMzMzMzMzMzNdLCJvcmllbnRhdGlvbiI6ImZvcndhcmQiLCJxdWFsaXR5Ijoia2VybmVsX2V2YWx1YXRlZCJ9LCJyZW5kZXJfc3RhdHVzIjoicmVuZGVyZWQiLCJzZGtfY29ubmVjdG9yX2ZyYW1lIjp7Im9yaWdpbiI6WzAsLTMzMy4zMzMzMzMzMzMzMzMzLDMzMy4zMzMzMzMzMzMzMzMzXSwieF9heGlzIjpbMCwwLDFdLCJ5X2F4aXMiOlswLDEsMF0sInpfYXhpcyI6Wy0xLDAsMF19LCJzZGtfbWV0YWRhdGEiOnt9LCJzZW1hbnRpY19iaW5kaW5nX2lkcyI6W10sInNvdXJjZSI6eyJraW5kIjoidW5ib3VuZCJ9fSx7ImNoaWxkX2VudGl0eV9pZHMiOlsiZW50aXR5L2VkZ2UvMSIsImVudGl0eS9lZGdlLzIiLCJlbnRpdHkvZWRnZS8zIl0sImNvbm5lY3Rvcl9iaW5kaW5nX3N0YXR1cyI6InNvdXJjZV9ub3RfbW9kZWwiLCJlbnRpdHlfaWQiOiJlbnRpdHkvZmFjZS8xIiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJub3JtYWwiOlswLDAsLTFdLCJvcmlnaW4iOlszMzMuMzMzMzMzMzMzMzMzMywtMzMzLjMzMzMzMzMzMzMzMzMsMF0sInR5cGUiOiJwbGFuZSIsInhfZGlyZWN0aW9uIjpbMSwwLDBdfSwia2luZCI6ImZhY2UiLCJwYXJlbnRfZW50aXR5X2lkcyI6WyJlbnRpdHkvc29saWQvMCJdLCJwcm9wZXJ0aWVzIjp7ImFyZWEiOjUwMDAwMCwiYm91bmRzIjp7Im1heCI6WzEwMDAsMCwwXSwibWluIjpbMCwtMTAwMCwwXX0sImNlbnRyb2lkIjpbMzMzLjMzMzMzMzMzMzMzMzMsLTMzMy4zMzMzMzMzMzMzMzMzLDBdLCJvcmllbnRhdGlvbiI6ImZvcndhcmQiLCJxdWFsaXR5Ijoia2VybmVsX2V2YWx1YXRlZCJ9LCJyZW5kZXJfc3RhdHVzIjoicmVuZGVyZWQiLCJzZGtfY29ubmVjdG9yX2ZyYW1lIjp7Im9yaWdpbiI6WzMzMy4zMzMzMzMzMzMzMzMzLC0zMzMuMzMzMzMzMzMzMzMzMywwXSwieF9heGlzIjpbMSwwLDBdLCJ5X2F4aXMiOlswLC0xLDBdLCJ6X2F4aXMiOlswLDAsLTFdfSwic2RrX21ldGFkYXRhIjp7fSwic2VtYW50aWNfYmluZGluZ19pZHMiOltdLCJzb3VyY2UiOnsia2luZCI6InVuYm91bmQifX0seyJjaGlsZF9lbnRpdHlfaWRzIjpbImVudGl0eS9lZGdlLzMiLCJlbnRpdHkvZWRnZS80IiwiZW50aXR5L2VkZ2UvNSJdLCJjb25uZWN0b3JfYmluZGluZ19zdGF0dXMiOiJzb3VyY2Vfbm90X21vZGVsIiwiZW50aXR5X2lkIjoiZW50aXR5L2ZhY2UvMiIsImV2YWx1YXRlZF90YWdzIjpbXSwiZ2VvbWV0cnkiOnsibm9ybWFsIjpbMCwxLDBdLCJvcmlnaW4iOlszMzMuMzMzMzMzMzMzMzMzMywwLDMzMy4zMzMzMzMzMzMzMzMzXSwidHlwZSI6InBsYW5lIiwieF9kaXJlY3Rpb24iOlsxLDAsMF19LCJraW5kIjoiZmFjZSIsInBhcmVudF9lbnRpdHlfaWRzIjpbImVudGl0eS9zb2xpZC8wIl0sInByb3BlcnRpZXMiOnsiYXJlYSI6NTAwMDAwLCJib3VuZHMiOnsibWF4IjpbMTAwMCwwLDEwMDBdLCJtaW4iOlswLDAsMF19LCJjZW50cm9pZCI6WzMzMy4zMzMzMzMzMzMzMzMzLDAsMzMzLjMzMzMzMzMzMzMzMzNdLCJvcmllbnRhdGlvbiI6ImZvcndhcmQiLCJxdWFsaXR5Ijoia2VybmVsX2V2YWx1YXRlZCJ9LCJyZW5kZXJfc3RhdHVzIjoicmVuZGVyZWQiLCJzZGtfY29ubmVjdG9yX2ZyYW1lIjp7Im9yaWdpbiI6WzMzMy4zMzMzMzMzMzMzMzMzLDAsMzMzLjMzMzMzMzMzMzMzMzNdLCJ4X2F4aXMiOlsxLDAsMF0sInlfYXhpcyI6WzAsMCwtMV0sInpfYXhpcyI6WzAsMSwwXX0sInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImtpbmQiOiJ1bmJvdW5kIn19LHsiY2hpbGRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZWRnZS8wIiwiZW50aXR5L2VkZ2UvMSIsImVudGl0eS9lZGdlLzQiXSwiY29ubmVjdG9yX2JpbmRpbmdfc3RhdHVzIjoic291cmNlX25vdF9tb2RlbCIsImVudGl0eV9pZCI6ImVudGl0eS9mYWNlLzMiLCJldmFsdWF0ZWRfdGFncyI6W10sImdlb21ldHJ5Ijp7Im5vcm1hbCI6WzAuNTc3MzUwMjY5MTg5NjI1OCwtMC41NzczNTAyNjkxODk2MjU4LDAuNTc3MzUwMjY5MTg5NjI1OF0sIm9yaWdpbiI6WzMzMy4zMzMzMzMzMzMzMzMzLC0zMzMuMzMzMzMzMzMzMzMzMywzMzMuMzMzMzMzMzMzMzMzM10sInR5cGUiOiJwbGFuZSIsInhfZGlyZWN0aW9uIjpbLTAuNzA3MTA2NzgxMTg2NTQ3NSwwLDAuNzA3MTA2NzgxMTg2NTQ3NV19LCJraW5kIjoiZmFjZSIsInBhcmVudF9lbnRpdHlfaWRzIjpbImVudGl0eS9zb2xpZC8wIl0sInByb3BlcnRpZXMiOnsiYXJlYSI6ODY2MDI1LjQwMzc4NDQzODYsImJvdW5kcyI6eyJtYXgiOlsxMDAwLDAsMTAwMF0sIm1pbiI6WzAsLTEwMDAsMF19LCJjZW50cm9pZCI6WzMzMy4zMzMzMzMzMzMzMzMzLC0zMzMuMzMzMzMzMzMzMzMzMywzMzMuMzMzMzMzMzMzMzMzM10sIm9yaWVudGF0aW9uIjoiZm9yd2FyZCIsInF1YWxpdHkiOiJrZXJuZWxfZXZhbHVhdGVkIn0sInJlbmRlcl9zdGF0dXMiOiJyZW5kZXJlZCIsInNka19jb25uZWN0b3JfZnJhbWUiOnsib3JpZ2luIjpbMzMzLjMzMzMzMzMzMzMzMzMsLTMzMy4zMzMzMzMzMzMzMzMzLDMzMy4zMzMzMzMzMzMzMzMzXSwieF9heGlzIjpbLTAuNzA3MTA2NzgxMTg2NTQ3NSwwLDAuNzA3MTA2NzgxMTg2NTQ3NV0sInlfYXhpcyI6Wy0wLjQwODI0ODI5MDQ2Mzg2MzEsLTAuODE2NDk2NTgwOTI3NzI2MSwtMC40MDgyNDgyOTA0NjM4NjMxXSwiel9heGlzIjpbMC41NzczNTAyNjkxODk2MjU4LC0wLjU3NzM1MDI2OTE4OTYyNTgsMC41NzczNTAyNjkxODk2MjU4XX0sInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImtpbmQiOiJ1bmJvdW5kIn19LHsiY2hpbGRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZmFjZS8wIiwiZW50aXR5L2ZhY2UvMSIsImVudGl0eS9mYWNlLzIiLCJlbnRpdHkvZmFjZS8zIl0sImNvbm5lY3Rvcl9iaW5kaW5nX3N0YXR1cyI6Im5vdF9hcHBsaWNhYmxlIiwiZW50aXR5X2lkIjoiZW50aXR5L3NvbGlkLzAiLCJldmFsdWF0ZWRfdGFncyI6W10sImdlb21ldHJ5Ijp7InR5cGUiOiJicmVwX3NvbGlkIn0sImtpbmQiOiJzb2xpZCIsInBhcmVudF9lbnRpdHlfaWRzIjpbXSwicHJvcGVydGllcyI6eyJib3VuZHMiOnsibWF4IjpbMTAwMCwwLDEwMDBdLCJtaW4iOlswLC0xMDAwLDBdfSwiY2VudHJvaWQiOlsyNTAsLTI1MCwyNTBdLCJxdWFsaXR5Ijoia2VybmVsX2V2YWx1YXRlZCIsInN1cmZhY2VfYXJlYSI6MjM2NjAyNS40MDM3ODQ0Mzg1LCJ2b2x1bWUiOjE2NjY2NjY2Ni42NjY2NjY2Nn0sInJlbmRlcl9zdGF0dXMiOiJyZW5kZXJlZCIsInNka19jb25uZWN0b3JfZnJhbWUiOm51bGwsInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImtpbmQiOiJ1bmJvdW5kIn19LHsiY2hpbGRfZW50aXR5X2lkcyI6W10sImNvbm5lY3Rvcl9iaW5kaW5nX3N0YXR1cyI6InNvdXJjZV9ub3RfbW9kZWwiLCJlbnRpdHlfaWQiOiJlbnRpdHkvdmVydGV4LzAiLCJldmFsdWF0ZWRfdGFncyI6W10sImdlb21ldHJ5Ijp7InBvc2l0aW9uIjpbMCwwLDBdLCJ0eXBlIjoicG9pbnQifSwia2luZCI6InZlcnRleCIsInBhcmVudF9lbnRpdHlfaWRzIjpbImVudGl0eS9lZGdlLzIiLCJlbnRpdHkvZWRnZS8zIiwiZW50aXR5L2VkZ2UvNSJdLCJwcm9wZXJ0aWVzIjp7ImJvdW5kcyI6eyJtYXgiOlswLDAsMF0sIm1pbiI6WzAsMCwwXX0sInBvc2l0aW9uIjpbMCwwLDBdLCJxdWFsaXR5Ijoia2VybmVsX2V2YWx1YXRlZCJ9LCJyZW5kZXJfc3RhdHVzIjoicmVuZGVyZWQiLCJzZGtfY29ubmVjdG9yX2ZyYW1lIjp7Im9yaWdpbiI6WzAsMCwwXSwieF9heGlzIjpbMSwwLDBdLCJ5X2F4aXMiOlswLDEsMF0sInpfYXhpcyI6WzAsMCwxXX0sInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImtpbmQiOiJ1bmJvdW5kIn19LHsiY2hpbGRfZW50aXR5X2lkcyI6W10sImNvbm5lY3Rvcl9iaW5kaW5nX3N0YXR1cyI6InNvdXJjZV9ub3RfbW9kZWwiLCJlbnRpdHlfaWQiOiJlbnRpdHkvdmVydGV4LzEiLCJldmFsdWF0ZWRfdGFncyI6W10sImdlb21ldHJ5Ijp7InBvc2l0aW9uIjpbMTAwMCwwLDBdLCJ0eXBlIjoicG9pbnQifSwia2luZCI6InZlcnRleCIsInBhcmVudF9lbnRpdHlfaWRzIjpbImVudGl0eS9lZGdlLzEiLCJlbnRpdHkvZWRnZS8zIiwiZW50aXR5L2VkZ2UvNCJdLCJwcm9wZXJ0aWVzIjp7ImJvdW5kcyI6eyJtYXgiOlsxMDAwLDAsMF0sIm1pbiI6WzEwMDAsMCwwXX0sInBvc2l0aW9uIjpbMTAwMCwwLDBdLCJxdWFsaXR5Ijoia2VybmVsX2V2YWx1YXRlZCJ9LCJyZW5kZXJfc3RhdHVzIjoicmVuZGVyZWQiLCJzZGtfY29ubmVjdG9yX2ZyYW1lIjp7Im9yaWdpbiI6WzEwMDAsMCwwXSwieF9heGlzIjpbMSwwLDBdLCJ5X2F4aXMiOlswLDEsMF0sInpfYXhpcyI6WzAsMCwxXX0sInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImtpbmQiOiJ1bmJvdW5kIn19LHsiY2hpbGRfZW50aXR5X2lkcyI6W10sImNvbm5lY3Rvcl9iaW5kaW5nX3N0YXR1cyI6InNvdXJjZV9ub3RfbW9kZWwiLCJlbnRpdHlfaWQiOiJlbnRpdHkvdmVydGV4LzIiLCJldmFsdWF0ZWRfdGFncyI6W10sImdlb21ldHJ5Ijp7InBvc2l0aW9uIjpbMCwwLDEwMDBdLCJ0eXBlIjoicG9pbnQifSwia2luZCI6InZlcnRleCIsInBhcmVudF9lbnRpdHlfaWRzIjpbImVudGl0eS9lZGdlLzAiLCJlbnRpdHkvZWRnZS80IiwiZW50aXR5L2VkZ2UvNSJdLCJwcm9wZXJ0aWVzIjp7ImJvdW5kcyI6eyJtYXgiOlswLDAsMTAwMF0sIm1pbiI6WzAsMCwxMDAwXX0sInBvc2l0aW9uIjpbMCwwLDEwMDBdLCJxdWFsaXR5Ijoia2VybmVsX2V2YWx1YXRlZCJ9LCJyZW5kZXJfc3RhdHVzIjoicmVuZGVyZWQiLCJzZGtfY29ubmVjdG9yX2ZyYW1lIjp7Im9yaWdpbiI6WzAsMCwxMDAwXSwieF9heGlzIjpbMSwwLDBdLCJ5X2F4aXMiOlswLDEsMF0sInpfYXhpcyI6WzAsMCwxXX0sInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImtpbmQiOiJ1bmJvdW5kIn19LHsiY2hpbGRfZW50aXR5X2lkcyI6W10sImNvbm5lY3Rvcl9iaW5kaW5nX3N0YXR1cyI6InNvdXJjZV9ub3RfbW9kZWwiLCJlbnRpdHlfaWQiOiJlbnRpdHkvdmVydGV4LzMiLCJldmFsdWF0ZWRfdGFncyI6W10sImdlb21ldHJ5Ijp7InBvc2l0aW9uIjpbMCwtMTAwMCwwXSwidHlwZSI6InBvaW50In0sImtpbmQiOiJ2ZXJ0ZXgiLCJwYXJlbnRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZWRnZS8wIiwiZW50aXR5L2VkZ2UvMSIsImVudGl0eS9lZGdlLzIiXSwicHJvcGVydGllcyI6eyJib3VuZHMiOnsibWF4IjpbMCwtMTAwMCwwXSwibWluIjpbMCwtMTAwMCwwXX0sInBvc2l0aW9uIjpbMCwtMTAwMCwwXSwicXVhbGl0eSI6Imtlcm5lbF9ldmFsdWF0ZWQifSwicmVuZGVyX3N0YXR1cyI6InJlbmRlcmVkIiwic2RrX2Nvbm5lY3Rvcl9mcmFtZSI6eyJvcmlnaW4iOlswLC0xMDAwLDBdLCJ4X2F4aXMiOlsxLDAsMF0sInlfYXhpcyI6WzAsMSwwXSwiel9heGlzIjpbMCwwLDFdfSwic2RrX21ldGFkYXRhIjp7fSwic2VtYW50aWNfYmluZGluZ19pZHMiOltdLCJzb3VyY2UiOnsia2luZCI6InVuYm91bmQifX1dLCJmYWNlX2dyb3VwcyI6W3siZW50aXR5X2lkIjoiZW50aXR5L2ZhY2UvMCIsImZpcnN0X2luZGV4IjowLCJncm91cF9pZCI6MCwiaW5kZXhfY291bnQiOjMsIm1lc2hfaW5kZXgiOjAsInByaW1pdGl2ZV9pbmRleCI6MH0seyJlbnRpdHlfaWQiOiJlbnRpdHkvZmFjZS8xIiwiZmlyc3RfaW5kZXgiOjMsImdyb3VwX2lkIjoxLCJpbmRleF9jb3VudCI6MywibWVzaF9pbmRleCI6MCwicHJpbWl0aXZlX2luZGV4IjowfSx7ImVudGl0eV9pZCI6ImVudGl0eS9mYWNlLzIiLCJmaXJzdF9pbmRleCI6NiwiZ3JvdXBfaWQiOjIsImluZGV4X2NvdW50IjozLCJtZXNoX2luZGV4IjowLCJwcmltaXRpdmVfaW5kZXgiOjB9LHsiZW50aXR5X2lkIjoiZW50aXR5L2ZhY2UvMyIsImZpcnN0X2luZGV4Ijo5LCJncm91cF9pZCI6MywiaW5kZXhfY291bnQiOjMsIm1lc2hfaW5kZXgiOjAsInByaW1pdGl2ZV9pbmRleCI6MH1dLCJnZW9tZXRyeV9hc3NldF9pZCI6InNoYTI1NjoyMDA1MjM0MTRkZTI4MWE4YTE1NTA4NmE1NGNkMGY4ODBmNjQwNDg1NzY1YTQ4Y2NkZDhlYzIzMGY2N2RkMTdjIiwiZ2VvbWV0cnlfZW5naW5lIjp7Im5hbWUiOiJPcGVuQ2FzY2FkZSIsInByb2ZpbGUiOiJvY3AtZXZhbHVhdGVkLXByb3BlcnRpZXMtMSIsInZlcnNpb24iOiJmaXh0dXJlIn0sInNjaGVtYV92ZXJzaW9uIjoiMS4wIn0=", + "entities/sha256-6330bb77b40d4f53d5f3face884028b74d4c2e492cde2fc303ad367ccd286f08.json": "eyJkZWZpbml0aW9uX2lkIjoiZGVmaW5pdGlvbi9yb290L3BhcnQvbW9kZWxfcGFydCIsImVkZ2VfYXNzZXRfaWQiOiJzaGEyNTY6NmNlNDgxZGIxZmUxM2JjZmU3YTIzNzQ0MjNlNmM3MmQ3NTdkNTFjOTkxNjhiMjg3YzUyZjUzYWE2MjVjY2Q2NyIsImVkZ2VfZ3JvdXBzIjpbeyJlbnRpdHlfaWQiOiJlbnRpdHkvZWRnZS8wIiwiZmlyc3RfaW5kZXgiOjAsImdyb3VwX2lkIjowLCJpbmRleF9jb3VudCI6MiwibWVzaF9pbmRleCI6MCwicHJpbWl0aXZlX2luZGV4IjowfSx7ImVudGl0eV9pZCI6ImVudGl0eS9lZGdlLzEiLCJmaXJzdF9pbmRleCI6MiwiZ3JvdXBfaWQiOjEsImluZGV4X2NvdW50IjoyLCJtZXNoX2luZGV4IjowLCJwcmltaXRpdmVfaW5kZXgiOjB9LHsiZW50aXR5X2lkIjoiZW50aXR5L2VkZ2UvMiIsImZpcnN0X2luZGV4Ijo0LCJncm91cF9pZCI6MiwiaW5kZXhfY291bnQiOjIsIm1lc2hfaW5kZXgiOjAsInByaW1pdGl2ZV9pbmRleCI6MH0seyJlbnRpdHlfaWQiOiJlbnRpdHkvZWRnZS8zIiwiZmlyc3RfaW5kZXgiOjYsImdyb3VwX2lkIjozLCJpbmRleF9jb3VudCI6MiwibWVzaF9pbmRleCI6MCwicHJpbWl0aXZlX2luZGV4IjowfSx7ImVudGl0eV9pZCI6ImVudGl0eS9lZGdlLzQiLCJmaXJzdF9pbmRleCI6OCwiZ3JvdXBfaWQiOjQsImluZGV4X2NvdW50IjoyLCJtZXNoX2luZGV4IjowLCJwcmltaXRpdmVfaW5kZXgiOjB9LHsiZW50aXR5X2lkIjoiZW50aXR5L2VkZ2UvNSIsImZpcnN0X2luZGV4IjoxMCwiZ3JvdXBfaWQiOjUsImluZGV4X2NvdW50IjoyLCJtZXNoX2luZGV4IjowLCJwcmltaXRpdmVfaW5kZXgiOjB9XSwiZW50aXRpZXMiOlt7ImNoaWxkX2VudGl0eV9pZHMiOlsiZW50aXR5L3ZlcnRleC8yIiwiZW50aXR5L3ZlcnRleC8zIl0sImNvbm5lY3Rvcl9iaW5kaW5nX3N0YXR1cyI6Im93bmVyX25vdF9wYXJ0IiwiZW50aXR5X2lkIjoiZW50aXR5L2VkZ2UvMCIsImV2YWx1YXRlZF90YWdzIjpbXSwiZ2VvbWV0cnkiOnsiZGlyZWN0aW9uIjpbMCwtMC43MDcxMDY3ODExODY1NDc1LC0wLjcwNzEwNjc4MTE4NjU0NzVdLCJvcmlnaW4iOlswLDAsMTAwMF0sInR5cGUiOiJsaW5lIn0sImtpbmQiOiJlZGdlIiwicGFyZW50X2VudGl0eV9pZHMiOlsiZW50aXR5L2ZhY2UvMCIsImVudGl0eS9mYWNlLzMiXSwicHJvcGVydGllcyI6eyJib3VuZHMiOnsibWF4IjpbMCwwLDEwMDBdLCJtaW4iOlswLC0xMDAwLDBdfSwiY2VudHJvaWQiOlswLC01MDAsNTAwXSwibGVuZ3RoIjoxNDE0LjIxMzU2MjM3MzA5NSwicXVhbGl0eSI6Imtlcm5lbF9ldmFsdWF0ZWQifSwicmVuZGVyX3N0YXR1cyI6InJlbmRlcmVkIiwic2RrX2Nvbm5lY3Rvcl9mcmFtZSI6bnVsbCwic2RrX21ldGFkYXRhIjp7fSwic2VtYW50aWNfYmluZGluZ19pZHMiOltdLCJzb3VyY2UiOnsiZ3JhcGhfaWQiOiJncmFwaCIsImtpbmQiOiJtb2RlbF90b3BvbG9neSIsIm5vZGVfaWQiOiJib2R5Iiwib3V0cHV0X3Nsb3QiOjAsInRvcG9faWQiOiJlbnRpdHkvZWRnZS8wIiwidG9wb2xvZ3lfa2luZCI6IkVER0UifX0seyJjaGlsZF9lbnRpdHlfaWRzIjpbImVudGl0eS92ZXJ0ZXgvMSIsImVudGl0eS92ZXJ0ZXgvMyJdLCJjb25uZWN0b3JfYmluZGluZ19zdGF0dXMiOiJzZWxlY3Rvcl9hbWJpZ3VvdXMiLCJlbnRpdHlfaWQiOiJlbnRpdHkvZWRnZS8xIiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJkaXJlY3Rpb24iOlstMC43MDcxMDY3ODExODY1NDc1LC0wLjcwNzEwNjc4MTE4NjU0NzUsMF0sIm9yaWdpbiI6WzEwMDAsMCwwXSwidHlwZSI6ImxpbmUifSwia2luZCI6ImVkZ2UiLCJwYXJlbnRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZmFjZS8xIiwiZW50aXR5L2ZhY2UvMyJdLCJwcm9wZXJ0aWVzIjp7ImJvdW5kcyI6eyJtYXgiOlsxMDAwLDAsMF0sIm1pbiI6WzAsLTEwMDAsMF19LCJjZW50cm9pZCI6WzUwMCwtNTAwLDBdLCJsZW5ndGgiOjE0MTQuMjEzNTYyMzczMDk1LCJxdWFsaXR5Ijoia2VybmVsX2V2YWx1YXRlZCJ9LCJyZW5kZXJfc3RhdHVzIjoicmVuZGVyZWQiLCJzZGtfY29ubmVjdG9yX2ZyYW1lIjp7Im9yaWdpbiI6WzUwMCwtNTAwLDBdLCJ4X2F4aXMiOlstMC43MDcxMDY3ODExODY1NDc1LC0wLjcwNzEwNjc4MTE4NjU0NzUsMF0sInlfYXhpcyI6WzAsMCwxXSwiel9heGlzIjpbLTAuNzA3MTA2NzgxMTg2NTQ3NiwwLjcwNzEwNjc4MTE4NjU0NzYsMF19LCJzZGtfbWV0YWRhdGEiOnt9LCJzZW1hbnRpY19iaW5kaW5nX2lkcyI6W10sInNvdXJjZSI6eyJncmFwaF9pZCI6ImdyYXBoIiwia2luZCI6Im1vZGVsX3RvcG9sb2d5Iiwibm9kZV9pZCI6ImJvZHkiLCJvdXRwdXRfc2xvdCI6MCwidG9wb19pZCI6ImVudGl0eS9lZGdlLzEiLCJ0b3BvbG9neV9raW5kIjoiRURHRSJ9fSx7ImNoaWxkX2VudGl0eV9pZHMiOlsiZW50aXR5L3ZlcnRleC8wIiwiZW50aXR5L3ZlcnRleC8zIl0sImNvbm5lY3Rvcl9iaW5kaW5nX3N0YXR1cyI6InNlbGVjdG9yX3Vuc3RhYmxlIiwiZW50aXR5X2lkIjoiZW50aXR5L2VkZ2UvMiIsImV2YWx1YXRlZF90YWdzIjpbXSwiZ2VvbWV0cnkiOnsiZGlyZWN0aW9uIjpbMCwtMSwwXSwib3JpZ2luIjpbMCwwLDBdLCJ0eXBlIjoibGluZSJ9LCJraW5kIjoiZWRnZSIsInBhcmVudF9lbnRpdHlfaWRzIjpbImVudGl0eS9mYWNlLzAiLCJlbnRpdHkvZmFjZS8xIl0sInByb3BlcnRpZXMiOnsiYm91bmRzIjp7Im1heCI6WzAsMCwwXSwibWluIjpbMCwtMTAwMCwwXX0sImNlbnRyb2lkIjpbMCwtNTAwLDBdLCJsZW5ndGgiOjEwMDAsInF1YWxpdHkiOiJrZXJuZWxfZXZhbHVhdGVkIn0sInJlbmRlcl9zdGF0dXMiOiJyZW5kZXJlZCIsInNka19jb25uZWN0b3JfZnJhbWUiOnsib3JpZ2luIjpbMCwtNTAwLDBdLCJ4X2F4aXMiOlswLC0xLDBdLCJ5X2F4aXMiOlswLDAsMV0sInpfYXhpcyI6Wy0xLDAsMF19LCJzZGtfbWV0YWRhdGEiOnt9LCJzZW1hbnRpY19iaW5kaW5nX2lkcyI6W10sInNvdXJjZSI6eyJncmFwaF9pZCI6ImdyYXBoIiwia2luZCI6Im1vZGVsX3RvcG9sb2d5Iiwibm9kZV9pZCI6ImJvZHkiLCJvdXRwdXRfc2xvdCI6MCwidG9wb19pZCI6ImVudGl0eS9lZGdlLzIiLCJ0b3BvbG9neV9raW5kIjoiRURHRSJ9fSx7ImNoaWxkX2VudGl0eV9pZHMiOlsiZW50aXR5L3ZlcnRleC8wIiwiZW50aXR5L3ZlcnRleC8xIl0sImNvbm5lY3Rvcl9iaW5kaW5nX3N0YXR1cyI6InN1cHBvcnRlZCIsImVudGl0eV9pZCI6ImVudGl0eS9lZGdlLzMiLCJldmFsdWF0ZWRfdGFncyI6W10sImdlb21ldHJ5Ijp7ImRpcmVjdGlvbiI6WzEsMCwwXSwib3JpZ2luIjpbMCwwLDBdLCJ0eXBlIjoibGluZSJ9LCJraW5kIjoiZWRnZSIsInBhcmVudF9lbnRpdHlfaWRzIjpbImVudGl0eS9mYWNlLzEiLCJlbnRpdHkvZmFjZS8yIl0sInByb3BlcnRpZXMiOnsiYm91bmRzIjp7Im1heCI6WzEwMDAsMCwwXSwibWluIjpbMCwwLDBdfSwiY2VudHJvaWQiOls1MDAsMCwwXSwibGVuZ3RoIjoxMDAwLCJxdWFsaXR5Ijoia2VybmVsX2V2YWx1YXRlZCJ9LCJyZW5kZXJfc3RhdHVzIjoicmVuZGVyZWQiLCJzZGtfY29ubmVjdG9yX2ZyYW1lIjp7Im9yaWdpbiI6WzUwMCwwLDBdLCJ4X2F4aXMiOlsxLDAsMF0sInlfYXhpcyI6WzAsMCwxXSwiel9heGlzIjpbMCwtMSwwXX0sInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImdyYXBoX2lkIjoiZ3JhcGgiLCJraW5kIjoibW9kZWxfdG9wb2xvZ3kiLCJub2RlX2lkIjoiYm9keSIsIm91dHB1dF9zbG90IjowLCJ0b3BvX2lkIjoiZW50aXR5L2VkZ2UvMyIsInRvcG9sb2d5X2tpbmQiOiJFREdFIn19LHsiY2hpbGRfZW50aXR5X2lkcyI6WyJlbnRpdHkvdmVydGV4LzEiLCJlbnRpdHkvdmVydGV4LzIiXSwiY29ubmVjdG9yX2JpbmRpbmdfc3RhdHVzIjoic3VwcG9ydGVkIiwiZW50aXR5X2lkIjoiZW50aXR5L2VkZ2UvNCIsImV2YWx1YXRlZF90YWdzIjpbXSwiZ2VvbWV0cnkiOnsiZGlyZWN0aW9uIjpbLTAuNzA3MTA2NzgxMTg2NTQ3NSwwLDAuNzA3MTA2NzgxMTg2NTQ3NV0sIm9yaWdpbiI6WzEwMDAsMCwwXSwidHlwZSI6ImxpbmUifSwia2luZCI6ImVkZ2UiLCJwYXJlbnRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZmFjZS8yIiwiZW50aXR5L2ZhY2UvMyJdLCJwcm9wZXJ0aWVzIjp7ImJvdW5kcyI6eyJtYXgiOlsxMDAwLDAsMTAwMF0sIm1pbiI6WzAsMCwwXX0sImNlbnRyb2lkIjpbNTAwLDAsNTAwXSwibGVuZ3RoIjoxNDE0LjIxMzU2MjM3MzA5NSwicXVhbGl0eSI6Imtlcm5lbF9ldmFsdWF0ZWQifSwicmVuZGVyX3N0YXR1cyI6InJlbmRlcmVkIiwic2RrX2Nvbm5lY3Rvcl9mcmFtZSI6eyJvcmlnaW4iOls1MDAsMCw1MDBdLCJ4X2F4aXMiOlstMC43MDcxMDY3ODExODY1NDc1LDAsMC43MDcxMDY3ODExODY1NDc1XSwieV9heGlzIjpbMC43MDcxMDY3ODExODY1NDc0LDAsMC43MDcxMDY3ODExODY1NDc3XSwiel9heGlzIjpbMCwxLDBdfSwic2RrX21ldGFkYXRhIjp7fSwic2VtYW50aWNfYmluZGluZ19pZHMiOltdLCJzb3VyY2UiOnsiZ3JhcGhfaWQiOiJncmFwaCIsImtpbmQiOiJtb2RlbF90b3BvbG9neSIsIm5vZGVfaWQiOiJib2R5Iiwib3V0cHV0X3Nsb3QiOjAsInRvcG9faWQiOiJlbnRpdHkvZWRnZS80IiwidG9wb2xvZ3lfa2luZCI6IkVER0UifX0seyJjaGlsZF9lbnRpdHlfaWRzIjpbImVudGl0eS92ZXJ0ZXgvMCIsImVudGl0eS92ZXJ0ZXgvMiJdLCJjb25uZWN0b3JfYmluZGluZ19zdGF0dXMiOiJzdXBwb3J0ZWQiLCJlbnRpdHlfaWQiOiJlbnRpdHkvZWRnZS81IiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJkaXJlY3Rpb24iOlswLDAsMV0sIm9yaWdpbiI6WzAsMCwwXSwidHlwZSI6ImxpbmUifSwia2luZCI6ImVkZ2UiLCJwYXJlbnRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZmFjZS8wIiwiZW50aXR5L2ZhY2UvMiJdLCJwcm9wZXJ0aWVzIjp7ImJvdW5kcyI6eyJtYXgiOlswLDAsMTAwMF0sIm1pbiI6WzAsMCwwXX0sImNlbnRyb2lkIjpbMCwwLDUwMF0sImxlbmd0aCI6MTAwMCwicXVhbGl0eSI6Imtlcm5lbF9ldmFsdWF0ZWQifSwicmVuZGVyX3N0YXR1cyI6InJlbmRlcmVkIiwic2RrX2Nvbm5lY3Rvcl9mcmFtZSI6eyJvcmlnaW4iOlswLDAsNTAwXSwieF9heGlzIjpbMCwwLDFdLCJ5X2F4aXMiOlswLDEsMF0sInpfYXhpcyI6Wy0xLDAsMF19LCJzZGtfbWV0YWRhdGEiOnt9LCJzZW1hbnRpY19iaW5kaW5nX2lkcyI6W10sInNvdXJjZSI6eyJncmFwaF9pZCI6ImdyYXBoIiwia2luZCI6Im1vZGVsX3RvcG9sb2d5Iiwibm9kZV9pZCI6ImJvZHkiLCJvdXRwdXRfc2xvdCI6MCwidG9wb19pZCI6ImVudGl0eS9lZGdlLzUiLCJ0b3BvbG9neV9raW5kIjoiRURHRSJ9fSx7ImNoaWxkX2VudGl0eV9pZHMiOlsiZW50aXR5L2VkZ2UvMCIsImVudGl0eS9lZGdlLzIiLCJlbnRpdHkvZWRnZS81Il0sImNvbm5lY3Rvcl9iaW5kaW5nX3N0YXR1cyI6InN1cHBvcnRlZCIsImVudGl0eV9pZCI6ImVudGl0eS9mYWNlLzAiLCJldmFsdWF0ZWRfdGFncyI6W10sImdlb21ldHJ5Ijp7Im5vcm1hbCI6Wy0xLDAsMF0sIm9yaWdpbiI6WzAsLTMzMy4zMzMzMzMzMzMzMzMzLDMzMy4zMzMzMzMzMzMzMzMzXSwidHlwZSI6InBsYW5lIiwieF9kaXJlY3Rpb24iOlswLDAsMV19LCJraW5kIjoiZmFjZSIsInBhcmVudF9lbnRpdHlfaWRzIjpbImVudGl0eS9zb2xpZC8wIl0sInByb3BlcnRpZXMiOnsiYXJlYSI6NTAwMDAwLCJib3VuZHMiOnsibWF4IjpbMCwwLDEwMDBdLCJtaW4iOlswLC0xMDAwLDBdfSwiY2VudHJvaWQiOlswLC0zMzMuMzMzMzMzMzMzMzMzMywzMzMuMzMzMzMzMzMzMzMzM10sIm9yaWVudGF0aW9uIjoiZm9yd2FyZCIsInF1YWxpdHkiOiJrZXJuZWxfZXZhbHVhdGVkIn0sInJlbmRlcl9zdGF0dXMiOiJyZW5kZXJlZCIsInNka19jb25uZWN0b3JfZnJhbWUiOnsib3JpZ2luIjpbMCwtMzMzLjMzMzMzMzMzMzMzMzMsMzMzLjMzMzMzMzMzMzMzMzNdLCJ4X2F4aXMiOlswLDAsMV0sInlfYXhpcyI6WzAsMSwwXSwiel9heGlzIjpbLTEsMCwwXX0sInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImdyYXBoX2lkIjoiZ3JhcGgiLCJraW5kIjoibW9kZWxfdG9wb2xvZ3kiLCJub2RlX2lkIjoiYm9keSIsIm91dHB1dF9zbG90IjowLCJ0b3BvX2lkIjoiZW50aXR5L2ZhY2UvMCIsInRvcG9sb2d5X2tpbmQiOiJGQUNFIn19LHsiY2hpbGRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZWRnZS8xIiwiZW50aXR5L2VkZ2UvMiIsImVudGl0eS9lZGdlLzMiXSwiY29ubmVjdG9yX2JpbmRpbmdfc3RhdHVzIjoic3VwcG9ydGVkIiwiZW50aXR5X2lkIjoiZW50aXR5L2ZhY2UvMSIsImV2YWx1YXRlZF90YWdzIjpbXSwiZ2VvbWV0cnkiOnsibm9ybWFsIjpbMCwwLC0xXSwib3JpZ2luIjpbMzMzLjMzMzMzMzMzMzMzMzMsLTMzMy4zMzMzMzMzMzMzMzMzLDBdLCJ0eXBlIjoicGxhbmUiLCJ4X2RpcmVjdGlvbiI6WzEsMCwwXX0sImtpbmQiOiJmYWNlIiwicGFyZW50X2VudGl0eV9pZHMiOlsiZW50aXR5L3NvbGlkLzAiXSwicHJvcGVydGllcyI6eyJhcmVhIjo1MDAwMDAsImJvdW5kcyI6eyJtYXgiOlsxMDAwLDAsMF0sIm1pbiI6WzAsLTEwMDAsMF19LCJjZW50cm9pZCI6WzMzMy4zMzMzMzMzMzMzMzMzLC0zMzMuMzMzMzMzMzMzMzMzMywwXSwib3JpZW50YXRpb24iOiJmb3J3YXJkIiwicXVhbGl0eSI6Imtlcm5lbF9ldmFsdWF0ZWQifSwicmVuZGVyX3N0YXR1cyI6InJlbmRlcmVkIiwic2RrX2Nvbm5lY3Rvcl9mcmFtZSI6eyJvcmlnaW4iOlszMzMuMzMzMzMzMzMzMzMzMywtMzMzLjMzMzMzMzMzMzMzMzMsMF0sInhfYXhpcyI6WzEsMCwwXSwieV9heGlzIjpbMCwtMSwwXSwiel9heGlzIjpbMCwwLC0xXX0sInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImdyYXBoX2lkIjoiZ3JhcGgiLCJraW5kIjoibW9kZWxfdG9wb2xvZ3kiLCJub2RlX2lkIjoiYm9keSIsIm91dHB1dF9zbG90IjowLCJ0b3BvX2lkIjoiZW50aXR5L2ZhY2UvMSIsInRvcG9sb2d5X2tpbmQiOiJGQUNFIn19LHsiY2hpbGRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZWRnZS8zIiwiZW50aXR5L2VkZ2UvNCIsImVudGl0eS9lZGdlLzUiXSwiY29ubmVjdG9yX2JpbmRpbmdfc3RhdHVzIjoic3VwcG9ydGVkIiwiZW50aXR5X2lkIjoiZW50aXR5L2ZhY2UvMiIsImV2YWx1YXRlZF90YWdzIjpbXSwiZ2VvbWV0cnkiOnsibm9ybWFsIjpbMCwxLDBdLCJvcmlnaW4iOlszMzMuMzMzMzMzMzMzMzMzMywwLDMzMy4zMzMzMzMzMzMzMzMzXSwidHlwZSI6InBsYW5lIiwieF9kaXJlY3Rpb24iOlsxLDAsMF19LCJraW5kIjoiZmFjZSIsInBhcmVudF9lbnRpdHlfaWRzIjpbImVudGl0eS9zb2xpZC8wIl0sInByb3BlcnRpZXMiOnsiYXJlYSI6NTAwMDAwLCJib3VuZHMiOnsibWF4IjpbMTAwMCwwLDEwMDBdLCJtaW4iOlswLDAsMF19LCJjZW50cm9pZCI6WzMzMy4zMzMzMzMzMzMzMzMzLDAsMzMzLjMzMzMzMzMzMzMzMzNdLCJvcmllbnRhdGlvbiI6ImZvcndhcmQiLCJxdWFsaXR5Ijoia2VybmVsX2V2YWx1YXRlZCJ9LCJyZW5kZXJfc3RhdHVzIjoicmVuZGVyZWQiLCJzZGtfY29ubmVjdG9yX2ZyYW1lIjp7Im9yaWdpbiI6WzMzMy4zMzMzMzMzMzMzMzMzLDAsMzMzLjMzMzMzMzMzMzMzMzNdLCJ4X2F4aXMiOlsxLDAsMF0sInlfYXhpcyI6WzAsMCwtMV0sInpfYXhpcyI6WzAsMSwwXX0sInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImdyYXBoX2lkIjoiZ3JhcGgiLCJraW5kIjoibW9kZWxfdG9wb2xvZ3kiLCJub2RlX2lkIjoiYm9keSIsIm91dHB1dF9zbG90IjowLCJ0b3BvX2lkIjoiZW50aXR5L2ZhY2UvMiIsInRvcG9sb2d5X2tpbmQiOiJGQUNFIn19LHsiY2hpbGRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZWRnZS8wIiwiZW50aXR5L2VkZ2UvMSIsImVudGl0eS9lZGdlLzQiXSwiY29ubmVjdG9yX2JpbmRpbmdfc3RhdHVzIjoic3VwcG9ydGVkIiwiZW50aXR5X2lkIjoiZW50aXR5L2ZhY2UvMyIsImV2YWx1YXRlZF90YWdzIjpbXSwiZ2VvbWV0cnkiOnsibm9ybWFsIjpbMC41NzczNTAyNjkxODk2MjU4LC0wLjU3NzM1MDI2OTE4OTYyNTgsMC41NzczNTAyNjkxODk2MjU4XSwib3JpZ2luIjpbMzMzLjMzMzMzMzMzMzMzMzMsLTMzMy4zMzMzMzMzMzMzMzMzLDMzMy4zMzMzMzMzMzMzMzMzXSwidHlwZSI6InBsYW5lIiwieF9kaXJlY3Rpb24iOlstMC43MDcxMDY3ODExODY1NDc1LDAsMC43MDcxMDY3ODExODY1NDc1XX0sImtpbmQiOiJmYWNlIiwicGFyZW50X2VudGl0eV9pZHMiOlsiZW50aXR5L3NvbGlkLzAiXSwicHJvcGVydGllcyI6eyJhcmVhIjo4NjYwMjUuNDAzNzg0NDM4NiwiYm91bmRzIjp7Im1heCI6WzEwMDAsMCwxMDAwXSwibWluIjpbMCwtMTAwMCwwXX0sImNlbnRyb2lkIjpbMzMzLjMzMzMzMzMzMzMzMzMsLTMzMy4zMzMzMzMzMzMzMzMzLDMzMy4zMzMzMzMzMzMzMzMzXSwib3JpZW50YXRpb24iOiJmb3J3YXJkIiwicXVhbGl0eSI6Imtlcm5lbF9ldmFsdWF0ZWQifSwicmVuZGVyX3N0YXR1cyI6InJlbmRlcmVkIiwic2RrX2Nvbm5lY3Rvcl9mcmFtZSI6eyJvcmlnaW4iOlszMzMuMzMzMzMzMzMzMzMzMywtMzMzLjMzMzMzMzMzMzMzMzMsMzMzLjMzMzMzMzMzMzMzMzNdLCJ4X2F4aXMiOlstMC43MDcxMDY3ODExODY1NDc1LDAsMC43MDcxMDY3ODExODY1NDc1XSwieV9heGlzIjpbLTAuNDA4MjQ4MjkwNDYzODYzMSwtMC44MTY0OTY1ODA5Mjc3MjYxLC0wLjQwODI0ODI5MDQ2Mzg2MzFdLCJ6X2F4aXMiOlswLjU3NzM1MDI2OTE4OTYyNTgsLTAuNTc3MzUwMjY5MTg5NjI1OCwwLjU3NzM1MDI2OTE4OTYyNThdfSwic2RrX21ldGFkYXRhIjp7fSwic2VtYW50aWNfYmluZGluZ19pZHMiOltdLCJzb3VyY2UiOnsiZ3JhcGhfaWQiOiJncmFwaCIsImtpbmQiOiJtb2RlbF90b3BvbG9neSIsIm5vZGVfaWQiOiJib2R5Iiwib3V0cHV0X3Nsb3QiOjAsInRvcG9faWQiOiJlbnRpdHkvZmFjZS8zIiwidG9wb2xvZ3lfa2luZCI6IkZBQ0UifX0seyJjaGlsZF9lbnRpdHlfaWRzIjpbImVudGl0eS9mYWNlLzAiLCJlbnRpdHkvZmFjZS8xIiwiZW50aXR5L2ZhY2UvMiIsImVudGl0eS9mYWNlLzMiXSwiY29ubmVjdG9yX2JpbmRpbmdfc3RhdHVzIjoibm90X2FwcGxpY2FibGUiLCJlbnRpdHlfaWQiOiJlbnRpdHkvc29saWQvMCIsImV2YWx1YXRlZF90YWdzIjpbXSwiZ2VvbWV0cnkiOnsidHlwZSI6ImJyZXBfc29saWQifSwia2luZCI6InNvbGlkIiwicGFyZW50X2VudGl0eV9pZHMiOltdLCJwcm9wZXJ0aWVzIjp7ImJvdW5kcyI6eyJtYXgiOlsxMDAwLDAsMTAwMF0sIm1pbiI6WzAsLTEwMDAsMF19LCJjZW50cm9pZCI6WzI1MCwtMjUwLDI1MF0sInF1YWxpdHkiOiJrZXJuZWxfZXZhbHVhdGVkIiwic3VyZmFjZV9hcmVhIjoyMzY2MDI1LjQwMzc4NDQzODUsInZvbHVtZSI6MTY2NjY2NjY2LjY2NjY2NjY2fSwicmVuZGVyX3N0YXR1cyI6InJlbmRlcmVkIiwic2RrX2Nvbm5lY3Rvcl9mcmFtZSI6bnVsbCwic2RrX21ldGFkYXRhIjp7fSwic2VtYW50aWNfYmluZGluZ19pZHMiOltdLCJzb3VyY2UiOnsiZ3JhcGhfaWQiOiJncmFwaCIsImtpbmQiOiJtb2RlbF90b3BvbG9neSIsIm5vZGVfaWQiOiJib2R5Iiwib3V0cHV0X3Nsb3QiOjAsInRvcG9faWQiOiJlbnRpdHkvc29saWQvMCIsInRvcG9sb2d5X2tpbmQiOiJTT0xJRCJ9fSx7ImNoaWxkX2VudGl0eV9pZHMiOltdLCJjb25uZWN0b3JfYmluZGluZ19zdGF0dXMiOiJzdXBwb3J0ZWQiLCJlbnRpdHlfaWQiOiJlbnRpdHkvdmVydGV4LzAiLCJldmFsdWF0ZWRfdGFncyI6W10sImdlb21ldHJ5Ijp7InBvc2l0aW9uIjpbMCwwLDBdLCJ0eXBlIjoicG9pbnQifSwia2luZCI6InZlcnRleCIsInBhcmVudF9lbnRpdHlfaWRzIjpbImVudGl0eS9lZGdlLzIiLCJlbnRpdHkvZWRnZS8zIiwiZW50aXR5L2VkZ2UvNSJdLCJwcm9wZXJ0aWVzIjp7ImJvdW5kcyI6eyJtYXgiOlswLDAsMF0sIm1pbiI6WzAsMCwwXX0sInBvc2l0aW9uIjpbMCwwLDBdLCJxdWFsaXR5Ijoia2VybmVsX2V2YWx1YXRlZCJ9LCJyZW5kZXJfc3RhdHVzIjoicmVuZGVyZWQiLCJzZGtfY29ubmVjdG9yX2ZyYW1lIjp7Im9yaWdpbiI6WzAsMCwwXSwieF9heGlzIjpbMSwwLDBdLCJ5X2F4aXMiOlswLDEsMF0sInpfYXhpcyI6WzAsMCwxXX0sInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImdyYXBoX2lkIjoiZ3JhcGgiLCJraW5kIjoibW9kZWxfdG9wb2xvZ3kiLCJub2RlX2lkIjoiYm9keSIsIm91dHB1dF9zbG90IjowLCJ0b3BvX2lkIjoiZW50aXR5L3ZlcnRleC8wIiwidG9wb2xvZ3lfa2luZCI6IlZFUlRFWCJ9fSx7ImNoaWxkX2VudGl0eV9pZHMiOltdLCJjb25uZWN0b3JfYmluZGluZ19zdGF0dXMiOiJzdXBwb3J0ZWQiLCJlbnRpdHlfaWQiOiJlbnRpdHkvdmVydGV4LzEiLCJldmFsdWF0ZWRfdGFncyI6W10sImdlb21ldHJ5Ijp7InBvc2l0aW9uIjpbMTAwMCwwLDBdLCJ0eXBlIjoicG9pbnQifSwia2luZCI6InZlcnRleCIsInBhcmVudF9lbnRpdHlfaWRzIjpbImVudGl0eS9lZGdlLzEiLCJlbnRpdHkvZWRnZS8zIiwiZW50aXR5L2VkZ2UvNCJdLCJwcm9wZXJ0aWVzIjp7ImJvdW5kcyI6eyJtYXgiOlsxMDAwLDAsMF0sIm1pbiI6WzEwMDAsMCwwXX0sInBvc2l0aW9uIjpbMTAwMCwwLDBdLCJxdWFsaXR5Ijoia2VybmVsX2V2YWx1YXRlZCJ9LCJyZW5kZXJfc3RhdHVzIjoicmVuZGVyZWQiLCJzZGtfY29ubmVjdG9yX2ZyYW1lIjp7Im9yaWdpbiI6WzEwMDAsMCwwXSwieF9heGlzIjpbMSwwLDBdLCJ5X2F4aXMiOlswLDEsMF0sInpfYXhpcyI6WzAsMCwxXX0sInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImdyYXBoX2lkIjoiZ3JhcGgiLCJraW5kIjoibW9kZWxfdG9wb2xvZ3kiLCJub2RlX2lkIjoiYm9keSIsIm91dHB1dF9zbG90IjowLCJ0b3BvX2lkIjoiZW50aXR5L3ZlcnRleC8xIiwidG9wb2xvZ3lfa2luZCI6IlZFUlRFWCJ9fSx7ImNoaWxkX2VudGl0eV9pZHMiOltdLCJjb25uZWN0b3JfYmluZGluZ19zdGF0dXMiOiJzdXBwb3J0ZWQiLCJlbnRpdHlfaWQiOiJlbnRpdHkvdmVydGV4LzIiLCJldmFsdWF0ZWRfdGFncyI6W10sImdlb21ldHJ5Ijp7InBvc2l0aW9uIjpbMCwwLDEwMDBdLCJ0eXBlIjoicG9pbnQifSwia2luZCI6InZlcnRleCIsInBhcmVudF9lbnRpdHlfaWRzIjpbImVudGl0eS9lZGdlLzAiLCJlbnRpdHkvZWRnZS80IiwiZW50aXR5L2VkZ2UvNSJdLCJwcm9wZXJ0aWVzIjp7ImJvdW5kcyI6eyJtYXgiOlswLDAsMTAwMF0sIm1pbiI6WzAsMCwxMDAwXX0sInBvc2l0aW9uIjpbMCwwLDEwMDBdLCJxdWFsaXR5Ijoia2VybmVsX2V2YWx1YXRlZCJ9LCJyZW5kZXJfc3RhdHVzIjoicmVuZGVyZWQiLCJzZGtfY29ubmVjdG9yX2ZyYW1lIjp7Im9yaWdpbiI6WzAsMCwxMDAwXSwieF9heGlzIjpbMSwwLDBdLCJ5X2F4aXMiOlswLDEsMF0sInpfYXhpcyI6WzAsMCwxXX0sInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImdyYXBoX2lkIjoiZ3JhcGgiLCJraW5kIjoibW9kZWxfdG9wb2xvZ3kiLCJub2RlX2lkIjoiYm9keSIsIm91dHB1dF9zbG90IjowLCJ0b3BvX2lkIjoiZW50aXR5L3ZlcnRleC8yIiwidG9wb2xvZ3lfa2luZCI6IlZFUlRFWCJ9fSx7ImNoaWxkX2VudGl0eV9pZHMiOltdLCJjb25uZWN0b3JfYmluZGluZ19zdGF0dXMiOiJzdXBwb3J0ZWQiLCJlbnRpdHlfaWQiOiJlbnRpdHkvdmVydGV4LzMiLCJldmFsdWF0ZWRfdGFncyI6W10sImdlb21ldHJ5Ijp7InBvc2l0aW9uIjpbMCwtMTAwMCwwXSwidHlwZSI6InBvaW50In0sImtpbmQiOiJ2ZXJ0ZXgiLCJwYXJlbnRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZWRnZS8wIiwiZW50aXR5L2VkZ2UvMSIsImVudGl0eS9lZGdlLzIiXSwicHJvcGVydGllcyI6eyJib3VuZHMiOnsibWF4IjpbMCwtMTAwMCwwXSwibWluIjpbMCwtMTAwMCwwXX0sInBvc2l0aW9uIjpbMCwtMTAwMCwwXSwicXVhbGl0eSI6Imtlcm5lbF9ldmFsdWF0ZWQifSwicmVuZGVyX3N0YXR1cyI6InJlbmRlcmVkIiwic2RrX2Nvbm5lY3Rvcl9mcmFtZSI6eyJvcmlnaW4iOlswLC0xMDAwLDBdLCJ4X2F4aXMiOlsxLDAsMF0sInlfYXhpcyI6WzAsMSwwXSwiel9heGlzIjpbMCwwLDFdfSwic2RrX21ldGFkYXRhIjp7fSwic2VtYW50aWNfYmluZGluZ19pZHMiOltdLCJzb3VyY2UiOnsiZ3JhcGhfaWQiOiJncmFwaCIsImtpbmQiOiJtb2RlbF90b3BvbG9neSIsIm5vZGVfaWQiOiJib2R5Iiwib3V0cHV0X3Nsb3QiOjAsInRvcG9faWQiOiJlbnRpdHkvdmVydGV4LzMiLCJ0b3BvbG9neV9raW5kIjoiVkVSVEVYIn19XSwiZmFjZV9ncm91cHMiOlt7ImVudGl0eV9pZCI6ImVudGl0eS9mYWNlLzAiLCJmaXJzdF9pbmRleCI6MCwiZ3JvdXBfaWQiOjAsImluZGV4X2NvdW50IjozLCJtZXNoX2luZGV4IjowLCJwcmltaXRpdmVfaW5kZXgiOjB9LHsiZW50aXR5X2lkIjoiZW50aXR5L2ZhY2UvMSIsImZpcnN0X2luZGV4IjozLCJncm91cF9pZCI6MSwiaW5kZXhfY291bnQiOjMsIm1lc2hfaW5kZXgiOjAsInByaW1pdGl2ZV9pbmRleCI6MH0seyJlbnRpdHlfaWQiOiJlbnRpdHkvZmFjZS8yIiwiZmlyc3RfaW5kZXgiOjYsImdyb3VwX2lkIjoyLCJpbmRleF9jb3VudCI6MywibWVzaF9pbmRleCI6MCwicHJpbWl0aXZlX2luZGV4IjowfSx7ImVudGl0eV9pZCI6ImVudGl0eS9mYWNlLzMiLCJmaXJzdF9pbmRleCI6OSwiZ3JvdXBfaWQiOjMsImluZGV4X2NvdW50IjozLCJtZXNoX2luZGV4IjowLCJwcmltaXRpdmVfaW5kZXgiOjB9XSwiZ2VvbWV0cnlfYXNzZXRfaWQiOiJzaGEyNTY6MjAwNTIzNDE0ZGUyODFhOGExNTUwODZhNTRjZDBmODgwZjY0MDQ4NTc2NWE0OGNjZGQ4ZWMyMzBmNjdkZDE3YyIsImdlb21ldHJ5X2VuZ2luZSI6eyJuYW1lIjoiT3BlbkNhc2NhZGUiLCJwcm9maWxlIjoib2NwLWV2YWx1YXRlZC1wcm9wZXJ0aWVzLTEiLCJ2ZXJzaW9uIjoiZml4dHVyZSJ9LCJzY2hlbWFfdmVyc2lvbiI6IjEuMCJ9", + "entities/sha256-997dcfd838049df11e508909f4f98dd1881b7b5eaedd6399ee0fcc9ebfab791b.json": "eyJkZWZpbml0aW9uX2lkIjoiZGVmaW5pdGlvbi9yb290L3NoYXBlL21vZGVsL2dyYXBoL2JvZHkvMCIsImVkZ2VfYXNzZXRfaWQiOiJzaGEyNTY6NmNlNDgxZGIxZmUxM2JjZmU3YTIzNzQ0MjNlNmM3MmQ3NTdkNTFjOTkxNjhiMjg3YzUyZjUzYWE2MjVjY2Q2NyIsImVkZ2VfZ3JvdXBzIjpbeyJlbnRpdHlfaWQiOiJlbnRpdHkvZWRnZS8wIiwiZmlyc3RfaW5kZXgiOjAsImdyb3VwX2lkIjowLCJpbmRleF9jb3VudCI6MiwibWVzaF9pbmRleCI6MCwicHJpbWl0aXZlX2luZGV4IjowfSx7ImVudGl0eV9pZCI6ImVudGl0eS9lZGdlLzEiLCJmaXJzdF9pbmRleCI6MiwiZ3JvdXBfaWQiOjEsImluZGV4X2NvdW50IjoyLCJtZXNoX2luZGV4IjowLCJwcmltaXRpdmVfaW5kZXgiOjB9LHsiZW50aXR5X2lkIjoiZW50aXR5L2VkZ2UvMiIsImZpcnN0X2luZGV4Ijo0LCJncm91cF9pZCI6MiwiaW5kZXhfY291bnQiOjIsIm1lc2hfaW5kZXgiOjAsInByaW1pdGl2ZV9pbmRleCI6MH0seyJlbnRpdHlfaWQiOiJlbnRpdHkvZWRnZS8zIiwiZmlyc3RfaW5kZXgiOjYsImdyb3VwX2lkIjozLCJpbmRleF9jb3VudCI6MiwibWVzaF9pbmRleCI6MCwicHJpbWl0aXZlX2luZGV4IjowfSx7ImVudGl0eV9pZCI6ImVudGl0eS9lZGdlLzQiLCJmaXJzdF9pbmRleCI6OCwiZ3JvdXBfaWQiOjQsImluZGV4X2NvdW50IjoyLCJtZXNoX2luZGV4IjowLCJwcmltaXRpdmVfaW5kZXgiOjB9LHsiZW50aXR5X2lkIjoiZW50aXR5L2VkZ2UvNSIsImZpcnN0X2luZGV4IjoxMCwiZ3JvdXBfaWQiOjUsImluZGV4X2NvdW50IjoyLCJtZXNoX2luZGV4IjowLCJwcmltaXRpdmVfaW5kZXgiOjB9XSwiZW50aXRpZXMiOlt7ImNoaWxkX2VudGl0eV9pZHMiOlsiZW50aXR5L3ZlcnRleC8yIiwiZW50aXR5L3ZlcnRleC8zIl0sImNvbm5lY3Rvcl9iaW5kaW5nX3N0YXR1cyI6Im93bmVyX25vdF9wYXJ0IiwiZW50aXR5X2lkIjoiZW50aXR5L2VkZ2UvMCIsImV2YWx1YXRlZF90YWdzIjpbXSwiZ2VvbWV0cnkiOnsiZGlyZWN0aW9uIjpbMCwtMC43MDcxMDY3ODExODY1NDc1LC0wLjcwNzEwNjc4MTE4NjU0NzVdLCJvcmlnaW4iOlswLDAsMTAwMF0sInR5cGUiOiJsaW5lIn0sImtpbmQiOiJlZGdlIiwicGFyZW50X2VudGl0eV9pZHMiOlsiZW50aXR5L2ZhY2UvMCIsImVudGl0eS9mYWNlLzMiXSwicHJvcGVydGllcyI6eyJib3VuZHMiOnsibWF4IjpbMCwwLDEwMDBdLCJtaW4iOlswLC0xMDAwLDBdfSwiY2VudHJvaWQiOlswLC01MDAsNTAwXSwibGVuZ3RoIjoxNDE0LjIxMzU2MjM3MzA5NSwicXVhbGl0eSI6Imtlcm5lbF9ldmFsdWF0ZWQifSwicmVuZGVyX3N0YXR1cyI6InJlbmRlcmVkIiwic2RrX2Nvbm5lY3Rvcl9mcmFtZSI6eyJvcmlnaW4iOlswLC01MDAsNTAwXSwieF9heGlzIjpbMCwtMC43MDcxMDY3ODExODY1NDc1LC0wLjcwNzEwNjc4MTE4NjU0NzVdLCJ5X2F4aXMiOlswLC0wLjcwNzEwNjc4MTE4NjU0NzQsMC43MDcxMDY3ODExODY1NDc3XSwiel9heGlzIjpbLTEsMCwwXX0sInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImdyYXBoX2lkIjoiZ3JhcGgiLCJraW5kIjoibW9kZWxfb3V0cHV0Iiwibm9kZV9pZCI6ImJvZHkiLCJvdXRwdXRfc2xvdCI6MH19LHsiY2hpbGRfZW50aXR5X2lkcyI6WyJlbnRpdHkvdmVydGV4LzEiLCJlbnRpdHkvdmVydGV4LzMiXSwiY29ubmVjdG9yX2JpbmRpbmdfc3RhdHVzIjoib3duZXJfbm90X3BhcnQiLCJlbnRpdHlfaWQiOiJlbnRpdHkvZWRnZS8xIiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJkaXJlY3Rpb24iOlstMC43MDcxMDY3ODExODY1NDc1LC0wLjcwNzEwNjc4MTE4NjU0NzUsMF0sIm9yaWdpbiI6WzEwMDAsMCwwXSwidHlwZSI6ImxpbmUifSwia2luZCI6ImVkZ2UiLCJwYXJlbnRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZmFjZS8xIiwiZW50aXR5L2ZhY2UvMyJdLCJwcm9wZXJ0aWVzIjp7ImJvdW5kcyI6eyJtYXgiOlsxMDAwLDAsMF0sIm1pbiI6WzAsLTEwMDAsMF19LCJjZW50cm9pZCI6WzUwMCwtNTAwLDBdLCJsZW5ndGgiOjE0MTQuMjEzNTYyMzczMDk1LCJxdWFsaXR5Ijoia2VybmVsX2V2YWx1YXRlZCJ9LCJyZW5kZXJfc3RhdHVzIjoicmVuZGVyZWQiLCJzZGtfY29ubmVjdG9yX2ZyYW1lIjp7Im9yaWdpbiI6WzUwMCwtNTAwLDBdLCJ4X2F4aXMiOlstMC43MDcxMDY3ODExODY1NDc1LC0wLjcwNzEwNjc4MTE4NjU0NzUsMF0sInlfYXhpcyI6WzAsMCwxXSwiel9heGlzIjpbLTAuNzA3MTA2NzgxMTg2NTQ3NiwwLjcwNzEwNjc4MTE4NjU0NzYsMF19LCJzZGtfbWV0YWRhdGEiOnt9LCJzZW1hbnRpY19iaW5kaW5nX2lkcyI6W10sInNvdXJjZSI6eyJncmFwaF9pZCI6ImdyYXBoIiwia2luZCI6Im1vZGVsX291dHB1dCIsIm5vZGVfaWQiOiJib2R5Iiwib3V0cHV0X3Nsb3QiOjB9fSx7ImNoaWxkX2VudGl0eV9pZHMiOlsiZW50aXR5L3ZlcnRleC8wIiwiZW50aXR5L3ZlcnRleC8zIl0sImNvbm5lY3Rvcl9iaW5kaW5nX3N0YXR1cyI6Im93bmVyX25vdF9wYXJ0IiwiZW50aXR5X2lkIjoiZW50aXR5L2VkZ2UvMiIsImV2YWx1YXRlZF90YWdzIjpbXSwiZ2VvbWV0cnkiOnsiZGlyZWN0aW9uIjpbMCwtMSwwXSwib3JpZ2luIjpbMCwwLDBdLCJ0eXBlIjoibGluZSJ9LCJraW5kIjoiZWRnZSIsInBhcmVudF9lbnRpdHlfaWRzIjpbImVudGl0eS9mYWNlLzAiLCJlbnRpdHkvZmFjZS8xIl0sInByb3BlcnRpZXMiOnsiYm91bmRzIjp7Im1heCI6WzAsMCwwXSwibWluIjpbMCwtMTAwMCwwXX0sImNlbnRyb2lkIjpbMCwtNTAwLDBdLCJsZW5ndGgiOjEwMDAsInF1YWxpdHkiOiJrZXJuZWxfZXZhbHVhdGVkIn0sInJlbmRlcl9zdGF0dXMiOiJyZW5kZXJlZCIsInNka19jb25uZWN0b3JfZnJhbWUiOnsib3JpZ2luIjpbMCwtNTAwLDBdLCJ4X2F4aXMiOlswLC0xLDBdLCJ5X2F4aXMiOlswLDAsMV0sInpfYXhpcyI6Wy0xLDAsMF19LCJzZGtfbWV0YWRhdGEiOnt9LCJzZW1hbnRpY19iaW5kaW5nX2lkcyI6W10sInNvdXJjZSI6eyJncmFwaF9pZCI6ImdyYXBoIiwia2luZCI6Im1vZGVsX291dHB1dCIsIm5vZGVfaWQiOiJib2R5Iiwib3V0cHV0X3Nsb3QiOjB9fSx7ImNoaWxkX2VudGl0eV9pZHMiOlsiZW50aXR5L3ZlcnRleC8wIiwiZW50aXR5L3ZlcnRleC8xIl0sImNvbm5lY3Rvcl9iaW5kaW5nX3N0YXR1cyI6Im93bmVyX25vdF9wYXJ0IiwiZW50aXR5X2lkIjoiZW50aXR5L2VkZ2UvMyIsImV2YWx1YXRlZF90YWdzIjpbXSwiZ2VvbWV0cnkiOnsiZGlyZWN0aW9uIjpbMSwwLDBdLCJvcmlnaW4iOlswLDAsMF0sInR5cGUiOiJsaW5lIn0sImtpbmQiOiJlZGdlIiwicGFyZW50X2VudGl0eV9pZHMiOlsiZW50aXR5L2ZhY2UvMSIsImVudGl0eS9mYWNlLzIiXSwicHJvcGVydGllcyI6eyJib3VuZHMiOnsibWF4IjpbMTAwMCwwLDBdLCJtaW4iOlswLDAsMF19LCJjZW50cm9pZCI6WzUwMCwwLDBdLCJsZW5ndGgiOjEwMDAsInF1YWxpdHkiOiJrZXJuZWxfZXZhbHVhdGVkIn0sInJlbmRlcl9zdGF0dXMiOiJyZW5kZXJlZCIsInNka19jb25uZWN0b3JfZnJhbWUiOnsib3JpZ2luIjpbNTAwLDAsMF0sInhfYXhpcyI6WzEsMCwwXSwieV9heGlzIjpbMCwwLDFdLCJ6X2F4aXMiOlswLC0xLDBdfSwic2RrX21ldGFkYXRhIjp7fSwic2VtYW50aWNfYmluZGluZ19pZHMiOltdLCJzb3VyY2UiOnsiZ3JhcGhfaWQiOiJncmFwaCIsImtpbmQiOiJtb2RlbF9vdXRwdXQiLCJub2RlX2lkIjoiYm9keSIsIm91dHB1dF9zbG90IjowfX0seyJjaGlsZF9lbnRpdHlfaWRzIjpbImVudGl0eS92ZXJ0ZXgvMSIsImVudGl0eS92ZXJ0ZXgvMiJdLCJjb25uZWN0b3JfYmluZGluZ19zdGF0dXMiOiJvd25lcl9ub3RfcGFydCIsImVudGl0eV9pZCI6ImVudGl0eS9lZGdlLzQiLCJldmFsdWF0ZWRfdGFncyI6W10sImdlb21ldHJ5Ijp7ImRpcmVjdGlvbiI6Wy0wLjcwNzEwNjc4MTE4NjU0NzUsMCwwLjcwNzEwNjc4MTE4NjU0NzVdLCJvcmlnaW4iOlsxMDAwLDAsMF0sInR5cGUiOiJsaW5lIn0sImtpbmQiOiJlZGdlIiwicGFyZW50X2VudGl0eV9pZHMiOlsiZW50aXR5L2ZhY2UvMiIsImVudGl0eS9mYWNlLzMiXSwicHJvcGVydGllcyI6eyJib3VuZHMiOnsibWF4IjpbMTAwMCwwLDEwMDBdLCJtaW4iOlswLDAsMF19LCJjZW50cm9pZCI6WzUwMCwwLDUwMF0sImxlbmd0aCI6MTQxNC4yMTM1NjIzNzMwOTUsInF1YWxpdHkiOiJrZXJuZWxfZXZhbHVhdGVkIn0sInJlbmRlcl9zdGF0dXMiOiJyZW5kZXJlZCIsInNka19jb25uZWN0b3JfZnJhbWUiOnsib3JpZ2luIjpbNTAwLDAsNTAwXSwieF9heGlzIjpbLTAuNzA3MTA2NzgxMTg2NTQ3NSwwLDAuNzA3MTA2NzgxMTg2NTQ3NV0sInlfYXhpcyI6WzAuNzA3MTA2NzgxMTg2NTQ3NCwwLDAuNzA3MTA2NzgxMTg2NTQ3N10sInpfYXhpcyI6WzAsMSwwXX0sInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImdyYXBoX2lkIjoiZ3JhcGgiLCJraW5kIjoibW9kZWxfb3V0cHV0Iiwibm9kZV9pZCI6ImJvZHkiLCJvdXRwdXRfc2xvdCI6MH19LHsiY2hpbGRfZW50aXR5X2lkcyI6WyJlbnRpdHkvdmVydGV4LzAiLCJlbnRpdHkvdmVydGV4LzIiXSwiY29ubmVjdG9yX2JpbmRpbmdfc3RhdHVzIjoib3duZXJfbm90X3BhcnQiLCJlbnRpdHlfaWQiOiJlbnRpdHkvZWRnZS81IiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJkaXJlY3Rpb24iOlswLDAsMV0sIm9yaWdpbiI6WzAsMCwwXSwidHlwZSI6ImxpbmUifSwia2luZCI6ImVkZ2UiLCJwYXJlbnRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZmFjZS8wIiwiZW50aXR5L2ZhY2UvMiJdLCJwcm9wZXJ0aWVzIjp7ImJvdW5kcyI6eyJtYXgiOlswLDAsMTAwMF0sIm1pbiI6WzAsMCwwXX0sImNlbnRyb2lkIjpbMCwwLDUwMF0sImxlbmd0aCI6MTAwMCwicXVhbGl0eSI6Imtlcm5lbF9ldmFsdWF0ZWQifSwicmVuZGVyX3N0YXR1cyI6InJlbmRlcmVkIiwic2RrX2Nvbm5lY3Rvcl9mcmFtZSI6eyJvcmlnaW4iOlswLDAsNTAwXSwieF9heGlzIjpbMCwwLDFdLCJ5X2F4aXMiOlswLDEsMF0sInpfYXhpcyI6Wy0xLDAsMF19LCJzZGtfbWV0YWRhdGEiOnt9LCJzZW1hbnRpY19iaW5kaW5nX2lkcyI6W10sInNvdXJjZSI6eyJncmFwaF9pZCI6ImdyYXBoIiwia2luZCI6Im1vZGVsX291dHB1dCIsIm5vZGVfaWQiOiJib2R5Iiwib3V0cHV0X3Nsb3QiOjB9fSx7ImNoaWxkX2VudGl0eV9pZHMiOlsiZW50aXR5L2VkZ2UvMCIsImVudGl0eS9lZGdlLzIiLCJlbnRpdHkvZWRnZS81Il0sImNvbm5lY3Rvcl9iaW5kaW5nX3N0YXR1cyI6Im93bmVyX25vdF9wYXJ0IiwiZW50aXR5X2lkIjoiZW50aXR5L2ZhY2UvMCIsImV2YWx1YXRlZF90YWdzIjpbXSwiZ2VvbWV0cnkiOnsibm9ybWFsIjpbLTEsMCwwXSwib3JpZ2luIjpbMCwtMzMzLjMzMzMzMzMzMzMzMzMsMzMzLjMzMzMzMzMzMzMzMzNdLCJ0eXBlIjoicGxhbmUiLCJ4X2RpcmVjdGlvbiI6WzAsMCwxXX0sImtpbmQiOiJmYWNlIiwicGFyZW50X2VudGl0eV9pZHMiOlsiZW50aXR5L3NvbGlkLzAiXSwicHJvcGVydGllcyI6eyJhcmVhIjo1MDAwMDAsImJvdW5kcyI6eyJtYXgiOlswLDAsMTAwMF0sIm1pbiI6WzAsLTEwMDAsMF19LCJjZW50cm9pZCI6WzAsLTMzMy4zMzMzMzMzMzMzMzMzLDMzMy4zMzMzMzMzMzMzMzMzXSwib3JpZW50YXRpb24iOiJmb3J3YXJkIiwicXVhbGl0eSI6Imtlcm5lbF9ldmFsdWF0ZWQifSwicmVuZGVyX3N0YXR1cyI6InJlbmRlcmVkIiwic2RrX2Nvbm5lY3Rvcl9mcmFtZSI6eyJvcmlnaW4iOlswLC0zMzMuMzMzMzMzMzMzMzMzMywzMzMuMzMzMzMzMzMzMzMzM10sInhfYXhpcyI6WzAsMCwxXSwieV9heGlzIjpbMCwxLDBdLCJ6X2F4aXMiOlstMSwwLDBdfSwic2RrX21ldGFkYXRhIjp7fSwic2VtYW50aWNfYmluZGluZ19pZHMiOltdLCJzb3VyY2UiOnsiZ3JhcGhfaWQiOiJncmFwaCIsImtpbmQiOiJtb2RlbF9vdXRwdXQiLCJub2RlX2lkIjoiYm9keSIsIm91dHB1dF9zbG90IjowfX0seyJjaGlsZF9lbnRpdHlfaWRzIjpbImVudGl0eS9lZGdlLzEiLCJlbnRpdHkvZWRnZS8yIiwiZW50aXR5L2VkZ2UvMyJdLCJjb25uZWN0b3JfYmluZGluZ19zdGF0dXMiOiJvd25lcl9ub3RfcGFydCIsImVudGl0eV9pZCI6ImVudGl0eS9mYWNlLzEiLCJldmFsdWF0ZWRfdGFncyI6W10sImdlb21ldHJ5Ijp7Im5vcm1hbCI6WzAsMCwtMV0sIm9yaWdpbiI6WzMzMy4zMzMzMzMzMzMzMzMzLC0zMzMuMzMzMzMzMzMzMzMzMywwXSwidHlwZSI6InBsYW5lIiwieF9kaXJlY3Rpb24iOlsxLDAsMF19LCJraW5kIjoiZmFjZSIsInBhcmVudF9lbnRpdHlfaWRzIjpbImVudGl0eS9zb2xpZC8wIl0sInByb3BlcnRpZXMiOnsiYXJlYSI6NTAwMDAwLCJib3VuZHMiOnsibWF4IjpbMTAwMCwwLDBdLCJtaW4iOlswLC0xMDAwLDBdfSwiY2VudHJvaWQiOlszMzMuMzMzMzMzMzMzMzMzMywtMzMzLjMzMzMzMzMzMzMzMzMsMF0sIm9yaWVudGF0aW9uIjoiZm9yd2FyZCIsInF1YWxpdHkiOiJrZXJuZWxfZXZhbHVhdGVkIn0sInJlbmRlcl9zdGF0dXMiOiJyZW5kZXJlZCIsInNka19jb25uZWN0b3JfZnJhbWUiOnsib3JpZ2luIjpbMzMzLjMzMzMzMzMzMzMzMzMsLTMzMy4zMzMzMzMzMzMzMzMzLDBdLCJ4X2F4aXMiOlsxLDAsMF0sInlfYXhpcyI6WzAsLTEsMF0sInpfYXhpcyI6WzAsMCwtMV19LCJzZGtfbWV0YWRhdGEiOnt9LCJzZW1hbnRpY19iaW5kaW5nX2lkcyI6W10sInNvdXJjZSI6eyJncmFwaF9pZCI6ImdyYXBoIiwia2luZCI6Im1vZGVsX291dHB1dCIsIm5vZGVfaWQiOiJib2R5Iiwib3V0cHV0X3Nsb3QiOjB9fSx7ImNoaWxkX2VudGl0eV9pZHMiOlsiZW50aXR5L2VkZ2UvMyIsImVudGl0eS9lZGdlLzQiLCJlbnRpdHkvZWRnZS81Il0sImNvbm5lY3Rvcl9iaW5kaW5nX3N0YXR1cyI6Im93bmVyX25vdF9wYXJ0IiwiZW50aXR5X2lkIjoiZW50aXR5L2ZhY2UvMiIsImV2YWx1YXRlZF90YWdzIjpbXSwiZ2VvbWV0cnkiOnsibm9ybWFsIjpbMCwxLDBdLCJvcmlnaW4iOlszMzMuMzMzMzMzMzMzMzMzMywwLDMzMy4zMzMzMzMzMzMzMzMzXSwidHlwZSI6InBsYW5lIiwieF9kaXJlY3Rpb24iOlsxLDAsMF19LCJraW5kIjoiZmFjZSIsInBhcmVudF9lbnRpdHlfaWRzIjpbImVudGl0eS9zb2xpZC8wIl0sInByb3BlcnRpZXMiOnsiYXJlYSI6NTAwMDAwLCJib3VuZHMiOnsibWF4IjpbMTAwMCwwLDEwMDBdLCJtaW4iOlswLDAsMF19LCJjZW50cm9pZCI6WzMzMy4zMzMzMzMzMzMzMzMzLDAsMzMzLjMzMzMzMzMzMzMzMzNdLCJvcmllbnRhdGlvbiI6ImZvcndhcmQiLCJxdWFsaXR5Ijoia2VybmVsX2V2YWx1YXRlZCJ9LCJyZW5kZXJfc3RhdHVzIjoicmVuZGVyZWQiLCJzZGtfY29ubmVjdG9yX2ZyYW1lIjp7Im9yaWdpbiI6WzMzMy4zMzMzMzMzMzMzMzMzLDAsMzMzLjMzMzMzMzMzMzMzMzNdLCJ4X2F4aXMiOlsxLDAsMF0sInlfYXhpcyI6WzAsMCwtMV0sInpfYXhpcyI6WzAsMSwwXX0sInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImdyYXBoX2lkIjoiZ3JhcGgiLCJraW5kIjoibW9kZWxfb3V0cHV0Iiwibm9kZV9pZCI6ImJvZHkiLCJvdXRwdXRfc2xvdCI6MH19LHsiY2hpbGRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZWRnZS8wIiwiZW50aXR5L2VkZ2UvMSIsImVudGl0eS9lZGdlLzQiXSwiY29ubmVjdG9yX2JpbmRpbmdfc3RhdHVzIjoib3duZXJfbm90X3BhcnQiLCJlbnRpdHlfaWQiOiJlbnRpdHkvZmFjZS8zIiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJub3JtYWwiOlswLjU3NzM1MDI2OTE4OTYyNTgsLTAuNTc3MzUwMjY5MTg5NjI1OCwwLjU3NzM1MDI2OTE4OTYyNThdLCJvcmlnaW4iOlszMzMuMzMzMzMzMzMzMzMzMywtMzMzLjMzMzMzMzMzMzMzMzMsMzMzLjMzMzMzMzMzMzMzMzNdLCJ0eXBlIjoicGxhbmUiLCJ4X2RpcmVjdGlvbiI6Wy0wLjcwNzEwNjc4MTE4NjU0NzUsMCwwLjcwNzEwNjc4MTE4NjU0NzVdfSwia2luZCI6ImZhY2UiLCJwYXJlbnRfZW50aXR5X2lkcyI6WyJlbnRpdHkvc29saWQvMCJdLCJwcm9wZXJ0aWVzIjp7ImFyZWEiOjg2NjAyNS40MDM3ODQ0Mzg2LCJib3VuZHMiOnsibWF4IjpbMTAwMCwwLDEwMDBdLCJtaW4iOlswLC0xMDAwLDBdfSwiY2VudHJvaWQiOlszMzMuMzMzMzMzMzMzMzMzMywtMzMzLjMzMzMzMzMzMzMzMzMsMzMzLjMzMzMzMzMzMzMzMzNdLCJvcmllbnRhdGlvbiI6ImZvcndhcmQiLCJxdWFsaXR5Ijoia2VybmVsX2V2YWx1YXRlZCJ9LCJyZW5kZXJfc3RhdHVzIjoicmVuZGVyZWQiLCJzZGtfY29ubmVjdG9yX2ZyYW1lIjp7Im9yaWdpbiI6WzMzMy4zMzMzMzMzMzMzMzMzLC0zMzMuMzMzMzMzMzMzMzMzMywzMzMuMzMzMzMzMzMzMzMzM10sInhfYXhpcyI6Wy0wLjcwNzEwNjc4MTE4NjU0NzUsMCwwLjcwNzEwNjc4MTE4NjU0NzVdLCJ5X2F4aXMiOlstMC40MDgyNDgyOTA0NjM4NjMxLC0wLjgxNjQ5NjU4MDkyNzcyNjEsLTAuNDA4MjQ4MjkwNDYzODYzMV0sInpfYXhpcyI6WzAuNTc3MzUwMjY5MTg5NjI1OCwtMC41NzczNTAyNjkxODk2MjU4LDAuNTc3MzUwMjY5MTg5NjI1OF19LCJzZGtfbWV0YWRhdGEiOnt9LCJzZW1hbnRpY19iaW5kaW5nX2lkcyI6W10sInNvdXJjZSI6eyJncmFwaF9pZCI6ImdyYXBoIiwia2luZCI6Im1vZGVsX291dHB1dCIsIm5vZGVfaWQiOiJib2R5Iiwib3V0cHV0X3Nsb3QiOjB9fSx7ImNoaWxkX2VudGl0eV9pZHMiOlsiZW50aXR5L2ZhY2UvMCIsImVudGl0eS9mYWNlLzEiLCJlbnRpdHkvZmFjZS8yIiwiZW50aXR5L2ZhY2UvMyJdLCJjb25uZWN0b3JfYmluZGluZ19zdGF0dXMiOiJub3RfYXBwbGljYWJsZSIsImVudGl0eV9pZCI6ImVudGl0eS9zb2xpZC8wIiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJ0eXBlIjoiYnJlcF9zb2xpZCJ9LCJraW5kIjoic29saWQiLCJwYXJlbnRfZW50aXR5X2lkcyI6W10sInByb3BlcnRpZXMiOnsiYm91bmRzIjp7Im1heCI6WzEwMDAsMCwxMDAwXSwibWluIjpbMCwtMTAwMCwwXX0sImNlbnRyb2lkIjpbMjUwLC0yNTAsMjUwXSwicXVhbGl0eSI6Imtlcm5lbF9ldmFsdWF0ZWQiLCJzdXJmYWNlX2FyZWEiOjIzNjYwMjUuNDAzNzg0NDM4NSwidm9sdW1lIjoxNjY2NjY2NjYuNjY2NjY2NjZ9LCJyZW5kZXJfc3RhdHVzIjoicmVuZGVyZWQiLCJzZGtfY29ubmVjdG9yX2ZyYW1lIjpudWxsLCJzZGtfbWV0YWRhdGEiOnt9LCJzZW1hbnRpY19iaW5kaW5nX2lkcyI6W10sInNvdXJjZSI6eyJncmFwaF9pZCI6ImdyYXBoIiwia2luZCI6Im1vZGVsX291dHB1dCIsIm5vZGVfaWQiOiJib2R5Iiwib3V0cHV0X3Nsb3QiOjB9fSx7ImNoaWxkX2VudGl0eV9pZHMiOltdLCJjb25uZWN0b3JfYmluZGluZ19zdGF0dXMiOiJvd25lcl9ub3RfcGFydCIsImVudGl0eV9pZCI6ImVudGl0eS92ZXJ0ZXgvMCIsImV2YWx1YXRlZF90YWdzIjpbXSwiZ2VvbWV0cnkiOnsicG9zaXRpb24iOlswLDAsMF0sInR5cGUiOiJwb2ludCJ9LCJraW5kIjoidmVydGV4IiwicGFyZW50X2VudGl0eV9pZHMiOlsiZW50aXR5L2VkZ2UvMiIsImVudGl0eS9lZGdlLzMiLCJlbnRpdHkvZWRnZS81Il0sInByb3BlcnRpZXMiOnsiYm91bmRzIjp7Im1heCI6WzAsMCwwXSwibWluIjpbMCwwLDBdfSwicG9zaXRpb24iOlswLDAsMF0sInF1YWxpdHkiOiJrZXJuZWxfZXZhbHVhdGVkIn0sInJlbmRlcl9zdGF0dXMiOiJyZW5kZXJlZCIsInNka19jb25uZWN0b3JfZnJhbWUiOnsib3JpZ2luIjpbMCwwLDBdLCJ4X2F4aXMiOlsxLDAsMF0sInlfYXhpcyI6WzAsMSwwXSwiel9heGlzIjpbMCwwLDFdfSwic2RrX21ldGFkYXRhIjp7fSwic2VtYW50aWNfYmluZGluZ19pZHMiOltdLCJzb3VyY2UiOnsiZ3JhcGhfaWQiOiJncmFwaCIsImtpbmQiOiJtb2RlbF9vdXRwdXQiLCJub2RlX2lkIjoiYm9keSIsIm91dHB1dF9zbG90IjowfX0seyJjaGlsZF9lbnRpdHlfaWRzIjpbXSwiY29ubmVjdG9yX2JpbmRpbmdfc3RhdHVzIjoib3duZXJfbm90X3BhcnQiLCJlbnRpdHlfaWQiOiJlbnRpdHkvdmVydGV4LzEiLCJldmFsdWF0ZWRfdGFncyI6W10sImdlb21ldHJ5Ijp7InBvc2l0aW9uIjpbMTAwMCwwLDBdLCJ0eXBlIjoicG9pbnQifSwia2luZCI6InZlcnRleCIsInBhcmVudF9lbnRpdHlfaWRzIjpbImVudGl0eS9lZGdlLzEiLCJlbnRpdHkvZWRnZS8zIiwiZW50aXR5L2VkZ2UvNCJdLCJwcm9wZXJ0aWVzIjp7ImJvdW5kcyI6eyJtYXgiOlsxMDAwLDAsMF0sIm1pbiI6WzEwMDAsMCwwXX0sInBvc2l0aW9uIjpbMTAwMCwwLDBdLCJxdWFsaXR5Ijoia2VybmVsX2V2YWx1YXRlZCJ9LCJyZW5kZXJfc3RhdHVzIjoicmVuZGVyZWQiLCJzZGtfY29ubmVjdG9yX2ZyYW1lIjp7Im9yaWdpbiI6WzEwMDAsMCwwXSwieF9heGlzIjpbMSwwLDBdLCJ5X2F4aXMiOlswLDEsMF0sInpfYXhpcyI6WzAsMCwxXX0sInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImdyYXBoX2lkIjoiZ3JhcGgiLCJraW5kIjoibW9kZWxfb3V0cHV0Iiwibm9kZV9pZCI6ImJvZHkiLCJvdXRwdXRfc2xvdCI6MH19LHsiY2hpbGRfZW50aXR5X2lkcyI6W10sImNvbm5lY3Rvcl9iaW5kaW5nX3N0YXR1cyI6Im93bmVyX25vdF9wYXJ0IiwiZW50aXR5X2lkIjoiZW50aXR5L3ZlcnRleC8yIiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJwb3NpdGlvbiI6WzAsMCwxMDAwXSwidHlwZSI6InBvaW50In0sImtpbmQiOiJ2ZXJ0ZXgiLCJwYXJlbnRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZWRnZS8wIiwiZW50aXR5L2VkZ2UvNCIsImVudGl0eS9lZGdlLzUiXSwicHJvcGVydGllcyI6eyJib3VuZHMiOnsibWF4IjpbMCwwLDEwMDBdLCJtaW4iOlswLDAsMTAwMF19LCJwb3NpdGlvbiI6WzAsMCwxMDAwXSwicXVhbGl0eSI6Imtlcm5lbF9ldmFsdWF0ZWQifSwicmVuZGVyX3N0YXR1cyI6InJlbmRlcmVkIiwic2RrX2Nvbm5lY3Rvcl9mcmFtZSI6eyJvcmlnaW4iOlswLDAsMTAwMF0sInhfYXhpcyI6WzEsMCwwXSwieV9heGlzIjpbMCwxLDBdLCJ6X2F4aXMiOlswLDAsMV19LCJzZGtfbWV0YWRhdGEiOnt9LCJzZW1hbnRpY19iaW5kaW5nX2lkcyI6W10sInNvdXJjZSI6eyJncmFwaF9pZCI6ImdyYXBoIiwia2luZCI6Im1vZGVsX291dHB1dCIsIm5vZGVfaWQiOiJib2R5Iiwib3V0cHV0X3Nsb3QiOjB9fSx7ImNoaWxkX2VudGl0eV9pZHMiOltdLCJjb25uZWN0b3JfYmluZGluZ19zdGF0dXMiOiJvd25lcl9ub3RfcGFydCIsImVudGl0eV9pZCI6ImVudGl0eS92ZXJ0ZXgvMyIsImV2YWx1YXRlZF90YWdzIjpbXSwiZ2VvbWV0cnkiOnsicG9zaXRpb24iOlswLC0xMDAwLDBdLCJ0eXBlIjoicG9pbnQifSwia2luZCI6InZlcnRleCIsInBhcmVudF9lbnRpdHlfaWRzIjpbImVudGl0eS9lZGdlLzAiLCJlbnRpdHkvZWRnZS8xIiwiZW50aXR5L2VkZ2UvMiJdLCJwcm9wZXJ0aWVzIjp7ImJvdW5kcyI6eyJtYXgiOlswLC0xMDAwLDBdLCJtaW4iOlswLC0xMDAwLDBdfSwicG9zaXRpb24iOlswLC0xMDAwLDBdLCJxdWFsaXR5Ijoia2VybmVsX2V2YWx1YXRlZCJ9LCJyZW5kZXJfc3RhdHVzIjoicmVuZGVyZWQiLCJzZGtfY29ubmVjdG9yX2ZyYW1lIjp7Im9yaWdpbiI6WzAsLTEwMDAsMF0sInhfYXhpcyI6WzEsMCwwXSwieV9heGlzIjpbMCwxLDBdLCJ6X2F4aXMiOlswLDAsMV19LCJzZGtfbWV0YWRhdGEiOnt9LCJzZW1hbnRpY19iaW5kaW5nX2lkcyI6W10sInNvdXJjZSI6eyJncmFwaF9pZCI6ImdyYXBoIiwia2luZCI6Im1vZGVsX291dHB1dCIsIm5vZGVfaWQiOiJib2R5Iiwib3V0cHV0X3Nsb3QiOjB9fV0sImZhY2VfZ3JvdXBzIjpbeyJlbnRpdHlfaWQiOiJlbnRpdHkvZmFjZS8wIiwiZmlyc3RfaW5kZXgiOjAsImdyb3VwX2lkIjowLCJpbmRleF9jb3VudCI6MywibWVzaF9pbmRleCI6MCwicHJpbWl0aXZlX2luZGV4IjowfSx7ImVudGl0eV9pZCI6ImVudGl0eS9mYWNlLzEiLCJmaXJzdF9pbmRleCI6MywiZ3JvdXBfaWQiOjEsImluZGV4X2NvdW50IjozLCJtZXNoX2luZGV4IjowLCJwcmltaXRpdmVfaW5kZXgiOjB9LHsiZW50aXR5X2lkIjoiZW50aXR5L2ZhY2UvMiIsImZpcnN0X2luZGV4Ijo2LCJncm91cF9pZCI6MiwiaW5kZXhfY291bnQiOjMsIm1lc2hfaW5kZXgiOjAsInByaW1pdGl2ZV9pbmRleCI6MH0seyJlbnRpdHlfaWQiOiJlbnRpdHkvZmFjZS8zIiwiZmlyc3RfaW5kZXgiOjksImdyb3VwX2lkIjozLCJpbmRleF9jb3VudCI6MywibWVzaF9pbmRleCI6MCwicHJpbWl0aXZlX2luZGV4IjowfV0sImdlb21ldHJ5X2Fzc2V0X2lkIjoic2hhMjU2OjIwMDUyMzQxNGRlMjgxYThhMTU1MDg2YTU0Y2QwZjg4MGY2NDA0ODU3NjVhNDhjY2RkOGVjMjMwZjY3ZGQxN2MiLCJnZW9tZXRyeV9lbmdpbmUiOnsibmFtZSI6Ik9wZW5DYXNjYWRlIiwicHJvZmlsZSI6Im9jcC1ldmFsdWF0ZWQtcHJvcGVydGllcy0xIiwidmVyc2lvbiI6ImZpeHR1cmUifSwic2NoZW1hX3ZlcnNpb24iOiIxLjAifQ==", + "entities/sha256-d8e42ece57a00e05d644e9a2b35c0525fa2bde29cc44382720d7fb0a56d45aa4.json": "eyJkZWZpbml0aW9uX2lkIjoiZGVmaW5pdGlvbi9yb290L3NoYXBlL21hbnVhbC9maXh0dXJlIiwiZWRnZV9hc3NldF9pZCI6InNoYTI1Njo2Y2U0ODFkYjFmZTEzYmNmZTdhMjM3NDQyM2U2YzcyZDc1N2Q1MWM5OTE2OGIyODdjNTJmNTNhYTYyNWNjZDY3IiwiZWRnZV9ncm91cHMiOlt7ImVudGl0eV9pZCI6ImVudGl0eS9lZGdlLzAiLCJmaXJzdF9pbmRleCI6MCwiZ3JvdXBfaWQiOjAsImluZGV4X2NvdW50IjoyLCJtZXNoX2luZGV4IjowLCJwcmltaXRpdmVfaW5kZXgiOjB9LHsiZW50aXR5X2lkIjoiZW50aXR5L2VkZ2UvMSIsImZpcnN0X2luZGV4IjoyLCJncm91cF9pZCI6MSwiaW5kZXhfY291bnQiOjIsIm1lc2hfaW5kZXgiOjAsInByaW1pdGl2ZV9pbmRleCI6MH0seyJlbnRpdHlfaWQiOiJlbnRpdHkvZWRnZS8yIiwiZmlyc3RfaW5kZXgiOjQsImdyb3VwX2lkIjoyLCJpbmRleF9jb3VudCI6MiwibWVzaF9pbmRleCI6MCwicHJpbWl0aXZlX2luZGV4IjowfSx7ImVudGl0eV9pZCI6ImVudGl0eS9lZGdlLzMiLCJmaXJzdF9pbmRleCI6NiwiZ3JvdXBfaWQiOjMsImluZGV4X2NvdW50IjoyLCJtZXNoX2luZGV4IjowLCJwcmltaXRpdmVfaW5kZXgiOjB9LHsiZW50aXR5X2lkIjoiZW50aXR5L2VkZ2UvNCIsImZpcnN0X2luZGV4Ijo4LCJncm91cF9pZCI6NCwiaW5kZXhfY291bnQiOjIsIm1lc2hfaW5kZXgiOjAsInByaW1pdGl2ZV9pbmRleCI6MH0seyJlbnRpdHlfaWQiOiJlbnRpdHkvZWRnZS81IiwiZmlyc3RfaW5kZXgiOjEwLCJncm91cF9pZCI6NSwiaW5kZXhfY291bnQiOjIsIm1lc2hfaW5kZXgiOjAsInByaW1pdGl2ZV9pbmRleCI6MH1dLCJlbnRpdGllcyI6W3siY2hpbGRfZW50aXR5X2lkcyI6WyJlbnRpdHkvdmVydGV4LzIiLCJlbnRpdHkvdmVydGV4LzMiXSwiY29ubmVjdG9yX2JpbmRpbmdfc3RhdHVzIjoib3duZXJfbm90X3BhcnQiLCJlbnRpdHlfaWQiOiJlbnRpdHkvZWRnZS8wIiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJkaXJlY3Rpb24iOlswLC0wLjcwNzEwNjc4MTE4NjU0NzUsLTAuNzA3MTA2NzgxMTg2NTQ3NV0sIm9yaWdpbiI6WzAsMCwxMDAwXSwidHlwZSI6ImxpbmUifSwia2luZCI6ImVkZ2UiLCJwYXJlbnRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZmFjZS8wIiwiZW50aXR5L2ZhY2UvMyJdLCJwcm9wZXJ0aWVzIjp7ImJvdW5kcyI6eyJtYXgiOlswLDAsMTAwMF0sIm1pbiI6WzAsLTEwMDAsMF19LCJjZW50cm9pZCI6WzAsLTUwMCw1MDBdLCJsZW5ndGgiOjE0MTQuMjEzNTYyMzczMDk1LCJxdWFsaXR5Ijoia2VybmVsX2V2YWx1YXRlZCJ9LCJyZW5kZXJfc3RhdHVzIjoicmVuZGVyZWQiLCJzZGtfY29ubmVjdG9yX2ZyYW1lIjp7Im9yaWdpbiI6WzAsLTUwMCw1MDBdLCJ4X2F4aXMiOlswLC0wLjcwNzEwNjc4MTE4NjU0NzUsLTAuNzA3MTA2NzgxMTg2NTQ3NV0sInlfYXhpcyI6WzAsLTAuNzA3MTA2NzgxMTg2NTQ3NCwwLjcwNzEwNjc4MTE4NjU0NzddLCJ6X2F4aXMiOlstMSwwLDBdfSwic2RrX21ldGFkYXRhIjp7fSwic2VtYW50aWNfYmluZGluZ19pZHMiOltdLCJzb3VyY2UiOnsia2luZCI6InVuYm91bmQifX0seyJjaGlsZF9lbnRpdHlfaWRzIjpbImVudGl0eS92ZXJ0ZXgvMSIsImVudGl0eS92ZXJ0ZXgvMyJdLCJjb25uZWN0b3JfYmluZGluZ19zdGF0dXMiOiJvd25lcl9ub3RfcGFydCIsImVudGl0eV9pZCI6ImVudGl0eS9lZGdlLzEiLCJldmFsdWF0ZWRfdGFncyI6W10sImdlb21ldHJ5Ijp7ImRpcmVjdGlvbiI6Wy0wLjcwNzEwNjc4MTE4NjU0NzUsLTAuNzA3MTA2NzgxMTg2NTQ3NSwwXSwib3JpZ2luIjpbMTAwMCwwLDBdLCJ0eXBlIjoibGluZSJ9LCJraW5kIjoiZWRnZSIsInBhcmVudF9lbnRpdHlfaWRzIjpbImVudGl0eS9mYWNlLzEiLCJlbnRpdHkvZmFjZS8zIl0sInByb3BlcnRpZXMiOnsiYm91bmRzIjp7Im1heCI6WzEwMDAsMCwwXSwibWluIjpbMCwtMTAwMCwwXX0sImNlbnRyb2lkIjpbNTAwLC01MDAsMF0sImxlbmd0aCI6MTQxNC4yMTM1NjIzNzMwOTUsInF1YWxpdHkiOiJrZXJuZWxfZXZhbHVhdGVkIn0sInJlbmRlcl9zdGF0dXMiOiJyZW5kZXJlZCIsInNka19jb25uZWN0b3JfZnJhbWUiOnsib3JpZ2luIjpbNTAwLC01MDAsMF0sInhfYXhpcyI6Wy0wLjcwNzEwNjc4MTE4NjU0NzUsLTAuNzA3MTA2NzgxMTg2NTQ3NSwwXSwieV9heGlzIjpbMCwwLDFdLCJ6X2F4aXMiOlstMC43MDcxMDY3ODExODY1NDc2LDAuNzA3MTA2NzgxMTg2NTQ3NiwwXX0sInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImtpbmQiOiJ1bmJvdW5kIn19LHsiY2hpbGRfZW50aXR5X2lkcyI6WyJlbnRpdHkvdmVydGV4LzAiLCJlbnRpdHkvdmVydGV4LzMiXSwiY29ubmVjdG9yX2JpbmRpbmdfc3RhdHVzIjoib3duZXJfbm90X3BhcnQiLCJlbnRpdHlfaWQiOiJlbnRpdHkvZWRnZS8yIiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJkaXJlY3Rpb24iOlswLC0xLDBdLCJvcmlnaW4iOlswLDAsMF0sInR5cGUiOiJsaW5lIn0sImtpbmQiOiJlZGdlIiwicGFyZW50X2VudGl0eV9pZHMiOlsiZW50aXR5L2ZhY2UvMCIsImVudGl0eS9mYWNlLzEiXSwicHJvcGVydGllcyI6eyJib3VuZHMiOnsibWF4IjpbMCwwLDBdLCJtaW4iOlswLC0xMDAwLDBdfSwiY2VudHJvaWQiOlswLC01MDAsMF0sImxlbmd0aCI6MTAwMCwicXVhbGl0eSI6Imtlcm5lbF9ldmFsdWF0ZWQifSwicmVuZGVyX3N0YXR1cyI6InJlbmRlcmVkIiwic2RrX2Nvbm5lY3Rvcl9mcmFtZSI6eyJvcmlnaW4iOlswLC01MDAsMF0sInhfYXhpcyI6WzAsLTEsMF0sInlfYXhpcyI6WzAsMCwxXSwiel9heGlzIjpbLTEsMCwwXX0sInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImtpbmQiOiJ1bmJvdW5kIn19LHsiY2hpbGRfZW50aXR5X2lkcyI6WyJlbnRpdHkvdmVydGV4LzAiLCJlbnRpdHkvdmVydGV4LzEiXSwiY29ubmVjdG9yX2JpbmRpbmdfc3RhdHVzIjoib3duZXJfbm90X3BhcnQiLCJlbnRpdHlfaWQiOiJlbnRpdHkvZWRnZS8zIiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJkaXJlY3Rpb24iOlsxLDAsMF0sIm9yaWdpbiI6WzAsMCwwXSwidHlwZSI6ImxpbmUifSwia2luZCI6ImVkZ2UiLCJwYXJlbnRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZmFjZS8xIiwiZW50aXR5L2ZhY2UvMiJdLCJwcm9wZXJ0aWVzIjp7ImJvdW5kcyI6eyJtYXgiOlsxMDAwLDAsMF0sIm1pbiI6WzAsMCwwXX0sImNlbnRyb2lkIjpbNTAwLDAsMF0sImxlbmd0aCI6MTAwMCwicXVhbGl0eSI6Imtlcm5lbF9ldmFsdWF0ZWQifSwicmVuZGVyX3N0YXR1cyI6InJlbmRlcmVkIiwic2RrX2Nvbm5lY3Rvcl9mcmFtZSI6eyJvcmlnaW4iOls1MDAsMCwwXSwieF9heGlzIjpbMSwwLDBdLCJ5X2F4aXMiOlswLDAsMV0sInpfYXhpcyI6WzAsLTEsMF19LCJzZGtfbWV0YWRhdGEiOnt9LCJzZW1hbnRpY19iaW5kaW5nX2lkcyI6W10sInNvdXJjZSI6eyJraW5kIjoidW5ib3VuZCJ9fSx7ImNoaWxkX2VudGl0eV9pZHMiOlsiZW50aXR5L3ZlcnRleC8xIiwiZW50aXR5L3ZlcnRleC8yIl0sImNvbm5lY3Rvcl9iaW5kaW5nX3N0YXR1cyI6Im93bmVyX25vdF9wYXJ0IiwiZW50aXR5X2lkIjoiZW50aXR5L2VkZ2UvNCIsImV2YWx1YXRlZF90YWdzIjpbXSwiZ2VvbWV0cnkiOnsiZGlyZWN0aW9uIjpbLTAuNzA3MTA2NzgxMTg2NTQ3NSwwLDAuNzA3MTA2NzgxMTg2NTQ3NV0sIm9yaWdpbiI6WzEwMDAsMCwwXSwidHlwZSI6ImxpbmUifSwia2luZCI6ImVkZ2UiLCJwYXJlbnRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZmFjZS8yIiwiZW50aXR5L2ZhY2UvMyJdLCJwcm9wZXJ0aWVzIjp7ImJvdW5kcyI6eyJtYXgiOlsxMDAwLDAsMTAwMF0sIm1pbiI6WzAsMCwwXX0sImNlbnRyb2lkIjpbNTAwLDAsNTAwXSwibGVuZ3RoIjoxNDE0LjIxMzU2MjM3MzA5NSwicXVhbGl0eSI6Imtlcm5lbF9ldmFsdWF0ZWQifSwicmVuZGVyX3N0YXR1cyI6InJlbmRlcmVkIiwic2RrX2Nvbm5lY3Rvcl9mcmFtZSI6eyJvcmlnaW4iOls1MDAsMCw1MDBdLCJ4X2F4aXMiOlstMC43MDcxMDY3ODExODY1NDc1LDAsMC43MDcxMDY3ODExODY1NDc1XSwieV9heGlzIjpbMC43MDcxMDY3ODExODY1NDc0LDAsMC43MDcxMDY3ODExODY1NDc3XSwiel9heGlzIjpbMCwxLDBdfSwic2RrX21ldGFkYXRhIjp7fSwic2VtYW50aWNfYmluZGluZ19pZHMiOltdLCJzb3VyY2UiOnsia2luZCI6InVuYm91bmQifX0seyJjaGlsZF9lbnRpdHlfaWRzIjpbImVudGl0eS92ZXJ0ZXgvMCIsImVudGl0eS92ZXJ0ZXgvMiJdLCJjb25uZWN0b3JfYmluZGluZ19zdGF0dXMiOiJvd25lcl9ub3RfcGFydCIsImVudGl0eV9pZCI6ImVudGl0eS9lZGdlLzUiLCJldmFsdWF0ZWRfdGFncyI6W10sImdlb21ldHJ5Ijp7ImRpcmVjdGlvbiI6WzAsMCwxXSwib3JpZ2luIjpbMCwwLDBdLCJ0eXBlIjoibGluZSJ9LCJraW5kIjoiZWRnZSIsInBhcmVudF9lbnRpdHlfaWRzIjpbImVudGl0eS9mYWNlLzAiLCJlbnRpdHkvZmFjZS8yIl0sInByb3BlcnRpZXMiOnsiYm91bmRzIjp7Im1heCI6WzAsMCwxMDAwXSwibWluIjpbMCwwLDBdfSwiY2VudHJvaWQiOlswLDAsNTAwXSwibGVuZ3RoIjoxMDAwLCJxdWFsaXR5Ijoia2VybmVsX2V2YWx1YXRlZCJ9LCJyZW5kZXJfc3RhdHVzIjoicmVuZGVyZWQiLCJzZGtfY29ubmVjdG9yX2ZyYW1lIjp7Im9yaWdpbiI6WzAsMCw1MDBdLCJ4X2F4aXMiOlswLDAsMV0sInlfYXhpcyI6WzAsMSwwXSwiel9heGlzIjpbLTEsMCwwXX0sInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImtpbmQiOiJ1bmJvdW5kIn19LHsiY2hpbGRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZWRnZS8wIiwiZW50aXR5L2VkZ2UvMiIsImVudGl0eS9lZGdlLzUiXSwiY29ubmVjdG9yX2JpbmRpbmdfc3RhdHVzIjoib3duZXJfbm90X3BhcnQiLCJlbnRpdHlfaWQiOiJlbnRpdHkvZmFjZS8wIiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJub3JtYWwiOlstMSwwLDBdLCJvcmlnaW4iOlswLC0zMzMuMzMzMzMzMzMzMzMzMywzMzMuMzMzMzMzMzMzMzMzM10sInR5cGUiOiJwbGFuZSIsInhfZGlyZWN0aW9uIjpbMCwwLDFdfSwia2luZCI6ImZhY2UiLCJwYXJlbnRfZW50aXR5X2lkcyI6WyJlbnRpdHkvc29saWQvMCJdLCJwcm9wZXJ0aWVzIjp7ImFyZWEiOjUwMDAwMCwiYm91bmRzIjp7Im1heCI6WzAsMCwxMDAwXSwibWluIjpbMCwtMTAwMCwwXX0sImNlbnRyb2lkIjpbMCwtMzMzLjMzMzMzMzMzMzMzMzMsMzMzLjMzMzMzMzMzMzMzMzNdLCJvcmllbnRhdGlvbiI6ImZvcndhcmQiLCJxdWFsaXR5Ijoia2VybmVsX2V2YWx1YXRlZCJ9LCJyZW5kZXJfc3RhdHVzIjoicmVuZGVyZWQiLCJzZGtfY29ubmVjdG9yX2ZyYW1lIjp7Im9yaWdpbiI6WzAsLTMzMy4zMzMzMzMzMzMzMzMzLDMzMy4zMzMzMzMzMzMzMzMzXSwieF9heGlzIjpbMCwwLDFdLCJ5X2F4aXMiOlswLDEsMF0sInpfYXhpcyI6Wy0xLDAsMF19LCJzZGtfbWV0YWRhdGEiOnt9LCJzZW1hbnRpY19iaW5kaW5nX2lkcyI6W10sInNvdXJjZSI6eyJraW5kIjoidW5ib3VuZCJ9fSx7ImNoaWxkX2VudGl0eV9pZHMiOlsiZW50aXR5L2VkZ2UvMSIsImVudGl0eS9lZGdlLzIiLCJlbnRpdHkvZWRnZS8zIl0sImNvbm5lY3Rvcl9iaW5kaW5nX3N0YXR1cyI6Im93bmVyX25vdF9wYXJ0IiwiZW50aXR5X2lkIjoiZW50aXR5L2ZhY2UvMSIsImV2YWx1YXRlZF90YWdzIjpbXSwiZ2VvbWV0cnkiOnsibm9ybWFsIjpbMCwwLC0xXSwib3JpZ2luIjpbMzMzLjMzMzMzMzMzMzMzMzMsLTMzMy4zMzMzMzMzMzMzMzMzLDBdLCJ0eXBlIjoicGxhbmUiLCJ4X2RpcmVjdGlvbiI6WzEsMCwwXX0sImtpbmQiOiJmYWNlIiwicGFyZW50X2VudGl0eV9pZHMiOlsiZW50aXR5L3NvbGlkLzAiXSwicHJvcGVydGllcyI6eyJhcmVhIjo1MDAwMDAsImJvdW5kcyI6eyJtYXgiOlsxMDAwLDAsMF0sIm1pbiI6WzAsLTEwMDAsMF19LCJjZW50cm9pZCI6WzMzMy4zMzMzMzMzMzMzMzMzLC0zMzMuMzMzMzMzMzMzMzMzMywwXSwib3JpZW50YXRpb24iOiJmb3J3YXJkIiwicXVhbGl0eSI6Imtlcm5lbF9ldmFsdWF0ZWQifSwicmVuZGVyX3N0YXR1cyI6InJlbmRlcmVkIiwic2RrX2Nvbm5lY3Rvcl9mcmFtZSI6eyJvcmlnaW4iOlszMzMuMzMzMzMzMzMzMzMzMywtMzMzLjMzMzMzMzMzMzMzMzMsMF0sInhfYXhpcyI6WzEsMCwwXSwieV9heGlzIjpbMCwtMSwwXSwiel9heGlzIjpbMCwwLC0xXX0sInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImtpbmQiOiJ1bmJvdW5kIn19LHsiY2hpbGRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZWRnZS8zIiwiZW50aXR5L2VkZ2UvNCIsImVudGl0eS9lZGdlLzUiXSwiY29ubmVjdG9yX2JpbmRpbmdfc3RhdHVzIjoib3duZXJfbm90X3BhcnQiLCJlbnRpdHlfaWQiOiJlbnRpdHkvZmFjZS8yIiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJub3JtYWwiOlswLDEsMF0sIm9yaWdpbiI6WzMzMy4zMzMzMzMzMzMzMzMzLDAsMzMzLjMzMzMzMzMzMzMzMzNdLCJ0eXBlIjoicGxhbmUiLCJ4X2RpcmVjdGlvbiI6WzEsMCwwXX0sImtpbmQiOiJmYWNlIiwicGFyZW50X2VudGl0eV9pZHMiOlsiZW50aXR5L3NvbGlkLzAiXSwicHJvcGVydGllcyI6eyJhcmVhIjo1MDAwMDAsImJvdW5kcyI6eyJtYXgiOlsxMDAwLDAsMTAwMF0sIm1pbiI6WzAsMCwwXX0sImNlbnRyb2lkIjpbMzMzLjMzMzMzMzMzMzMzMzMsMCwzMzMuMzMzMzMzMzMzMzMzM10sIm9yaWVudGF0aW9uIjoiZm9yd2FyZCIsInF1YWxpdHkiOiJrZXJuZWxfZXZhbHVhdGVkIn0sInJlbmRlcl9zdGF0dXMiOiJyZW5kZXJlZCIsInNka19jb25uZWN0b3JfZnJhbWUiOnsib3JpZ2luIjpbMzMzLjMzMzMzMzMzMzMzMzMsMCwzMzMuMzMzMzMzMzMzMzMzM10sInhfYXhpcyI6WzEsMCwwXSwieV9heGlzIjpbMCwwLC0xXSwiel9heGlzIjpbMCwxLDBdfSwic2RrX21ldGFkYXRhIjp7fSwic2VtYW50aWNfYmluZGluZ19pZHMiOltdLCJzb3VyY2UiOnsia2luZCI6InVuYm91bmQifX0seyJjaGlsZF9lbnRpdHlfaWRzIjpbImVudGl0eS9lZGdlLzAiLCJlbnRpdHkvZWRnZS8xIiwiZW50aXR5L2VkZ2UvNCJdLCJjb25uZWN0b3JfYmluZGluZ19zdGF0dXMiOiJvd25lcl9ub3RfcGFydCIsImVudGl0eV9pZCI6ImVudGl0eS9mYWNlLzMiLCJldmFsdWF0ZWRfdGFncyI6W10sImdlb21ldHJ5Ijp7Im5vcm1hbCI6WzAuNTc3MzUwMjY5MTg5NjI1OCwtMC41NzczNTAyNjkxODk2MjU4LDAuNTc3MzUwMjY5MTg5NjI1OF0sIm9yaWdpbiI6WzMzMy4zMzMzMzMzMzMzMzMzLC0zMzMuMzMzMzMzMzMzMzMzMywzMzMuMzMzMzMzMzMzMzMzM10sInR5cGUiOiJwbGFuZSIsInhfZGlyZWN0aW9uIjpbLTAuNzA3MTA2NzgxMTg2NTQ3NSwwLDAuNzA3MTA2NzgxMTg2NTQ3NV19LCJraW5kIjoiZmFjZSIsInBhcmVudF9lbnRpdHlfaWRzIjpbImVudGl0eS9zb2xpZC8wIl0sInByb3BlcnRpZXMiOnsiYXJlYSI6ODY2MDI1LjQwMzc4NDQzODYsImJvdW5kcyI6eyJtYXgiOlsxMDAwLDAsMTAwMF0sIm1pbiI6WzAsLTEwMDAsMF19LCJjZW50cm9pZCI6WzMzMy4zMzMzMzMzMzMzMzMzLC0zMzMuMzMzMzMzMzMzMzMzMywzMzMuMzMzMzMzMzMzMzMzM10sIm9yaWVudGF0aW9uIjoiZm9yd2FyZCIsInF1YWxpdHkiOiJrZXJuZWxfZXZhbHVhdGVkIn0sInJlbmRlcl9zdGF0dXMiOiJyZW5kZXJlZCIsInNka19jb25uZWN0b3JfZnJhbWUiOnsib3JpZ2luIjpbMzMzLjMzMzMzMzMzMzMzMzMsLTMzMy4zMzMzMzMzMzMzMzMzLDMzMy4zMzMzMzMzMzMzMzMzXSwieF9heGlzIjpbLTAuNzA3MTA2NzgxMTg2NTQ3NSwwLDAuNzA3MTA2NzgxMTg2NTQ3NV0sInlfYXhpcyI6Wy0wLjQwODI0ODI5MDQ2Mzg2MzEsLTAuODE2NDk2NTgwOTI3NzI2MSwtMC40MDgyNDgyOTA0NjM4NjMxXSwiel9heGlzIjpbMC41NzczNTAyNjkxODk2MjU4LC0wLjU3NzM1MDI2OTE4OTYyNTgsMC41NzczNTAyNjkxODk2MjU4XX0sInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImtpbmQiOiJ1bmJvdW5kIn19LHsiY2hpbGRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZmFjZS8wIiwiZW50aXR5L2ZhY2UvMSIsImVudGl0eS9mYWNlLzIiLCJlbnRpdHkvZmFjZS8zIl0sImNvbm5lY3Rvcl9iaW5kaW5nX3N0YXR1cyI6InN1cHBvcnRlZCIsImVudGl0eV9pZCI6ImVudGl0eS9zb2xpZC8wIiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJ0eXBlIjoiYnJlcF9zb2xpZCJ9LCJraW5kIjoic29saWQiLCJwYXJlbnRfZW50aXR5X2lkcyI6W10sInByb3BlcnRpZXMiOnsiYm91bmRzIjp7Im1heCI6WzEwMDAsMCwxMDAwXSwibWluIjpbMCwtMTAwMCwwXX0sImNlbnRyb2lkIjpbMjUwLC0yNTAsMjUwXSwicXVhbGl0eSI6Imtlcm5lbF9ldmFsdWF0ZWQiLCJzdXJmYWNlX2FyZWEiOjIzNjYwMjUuNDAzNzg0NDM4NSwidm9sdW1lIjoxNjY2NjY2NjYuNjY2NjY2NjZ9LCJyZW5kZXJfc3RhdHVzIjoicmVuZGVyZWQiLCJzZGtfY29ubmVjdG9yX2ZyYW1lIjpudWxsLCJzZGtfbWV0YWRhdGEiOnt9LCJzZW1hbnRpY19iaW5kaW5nX2lkcyI6W10sInNvdXJjZSI6eyJraW5kIjoidW5ib3VuZCJ9fSx7ImNoaWxkX2VudGl0eV9pZHMiOltdLCJjb25uZWN0b3JfYmluZGluZ19zdGF0dXMiOiJvd25lcl9ub3RfcGFydCIsImVudGl0eV9pZCI6ImVudGl0eS92ZXJ0ZXgvMCIsImV2YWx1YXRlZF90YWdzIjpbXSwiZ2VvbWV0cnkiOnsicG9zaXRpb24iOlswLDAsMF0sInR5cGUiOiJwb2ludCJ9LCJraW5kIjoidmVydGV4IiwicGFyZW50X2VudGl0eV9pZHMiOlsiZW50aXR5L2VkZ2UvMiIsImVudGl0eS9lZGdlLzMiLCJlbnRpdHkvZWRnZS81Il0sInByb3BlcnRpZXMiOnsiYm91bmRzIjp7Im1heCI6WzAsMCwwXSwibWluIjpbMCwwLDBdfSwicG9zaXRpb24iOlswLDAsMF0sInF1YWxpdHkiOiJrZXJuZWxfZXZhbHVhdGVkIn0sInJlbmRlcl9zdGF0dXMiOiJyZW5kZXJlZCIsInNka19jb25uZWN0b3JfZnJhbWUiOnsib3JpZ2luIjpbMCwwLDBdLCJ4X2F4aXMiOlsxLDAsMF0sInlfYXhpcyI6WzAsMSwwXSwiel9heGlzIjpbMCwwLDFdfSwic2RrX21ldGFkYXRhIjp7fSwic2VtYW50aWNfYmluZGluZ19pZHMiOltdLCJzb3VyY2UiOnsia2luZCI6InVuYm91bmQifX0seyJjaGlsZF9lbnRpdHlfaWRzIjpbXSwiY29ubmVjdG9yX2JpbmRpbmdfc3RhdHVzIjoib3duZXJfbm90X3BhcnQiLCJlbnRpdHlfaWQiOiJlbnRpdHkvdmVydGV4LzEiLCJldmFsdWF0ZWRfdGFncyI6W10sImdlb21ldHJ5Ijp7InBvc2l0aW9uIjpbMTAwMCwwLDBdLCJ0eXBlIjoicG9pbnQifSwia2luZCI6InZlcnRleCIsInBhcmVudF9lbnRpdHlfaWRzIjpbImVudGl0eS9lZGdlLzEiLCJlbnRpdHkvZWRnZS8zIiwiZW50aXR5L2VkZ2UvNCJdLCJwcm9wZXJ0aWVzIjp7ImJvdW5kcyI6eyJtYXgiOlsxMDAwLDAsMF0sIm1pbiI6WzEwMDAsMCwwXX0sInBvc2l0aW9uIjpbMTAwMCwwLDBdLCJxdWFsaXR5Ijoia2VybmVsX2V2YWx1YXRlZCJ9LCJyZW5kZXJfc3RhdHVzIjoicmVuZGVyZWQiLCJzZGtfY29ubmVjdG9yX2ZyYW1lIjp7Im9yaWdpbiI6WzEwMDAsMCwwXSwieF9heGlzIjpbMSwwLDBdLCJ5X2F4aXMiOlswLDEsMF0sInpfYXhpcyI6WzAsMCwxXX0sInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImtpbmQiOiJ1bmJvdW5kIn19LHsiY2hpbGRfZW50aXR5X2lkcyI6W10sImNvbm5lY3Rvcl9iaW5kaW5nX3N0YXR1cyI6Im93bmVyX25vdF9wYXJ0IiwiZW50aXR5X2lkIjoiZW50aXR5L3ZlcnRleC8yIiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJwb3NpdGlvbiI6WzAsMCwxMDAwXSwidHlwZSI6InBvaW50In0sImtpbmQiOiJ2ZXJ0ZXgiLCJwYXJlbnRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZWRnZS8wIiwiZW50aXR5L2VkZ2UvNCIsImVudGl0eS9lZGdlLzUiXSwicHJvcGVydGllcyI6eyJib3VuZHMiOnsibWF4IjpbMCwwLDEwMDBdLCJtaW4iOlswLDAsMTAwMF19LCJwb3NpdGlvbiI6WzAsMCwxMDAwXSwicXVhbGl0eSI6Imtlcm5lbF9ldmFsdWF0ZWQifSwicmVuZGVyX3N0YXR1cyI6InJlbmRlcmVkIiwic2RrX2Nvbm5lY3Rvcl9mcmFtZSI6eyJvcmlnaW4iOlswLDAsMTAwMF0sInhfYXhpcyI6WzEsMCwwXSwieV9heGlzIjpbMCwxLDBdLCJ6X2F4aXMiOlswLDAsMV19LCJzZGtfbWV0YWRhdGEiOnt9LCJzZW1hbnRpY19iaW5kaW5nX2lkcyI6W10sInNvdXJjZSI6eyJraW5kIjoidW5ib3VuZCJ9fSx7ImNoaWxkX2VudGl0eV9pZHMiOltdLCJjb25uZWN0b3JfYmluZGluZ19zdGF0dXMiOiJvd25lcl9ub3RfcGFydCIsImVudGl0eV9pZCI6ImVudGl0eS92ZXJ0ZXgvMyIsImV2YWx1YXRlZF90YWdzIjpbXSwiZ2VvbWV0cnkiOnsicG9zaXRpb24iOlswLC0xMDAwLDBdLCJ0eXBlIjoicG9pbnQifSwia2luZCI6InZlcnRleCIsInBhcmVudF9lbnRpdHlfaWRzIjpbImVudGl0eS9lZGdlLzAiLCJlbnRpdHkvZWRnZS8xIiwiZW50aXR5L2VkZ2UvMiJdLCJwcm9wZXJ0aWVzIjp7ImJvdW5kcyI6eyJtYXgiOlswLC0xMDAwLDBdLCJtaW4iOlswLC0xMDAwLDBdfSwicG9zaXRpb24iOlswLC0xMDAwLDBdLCJxdWFsaXR5Ijoia2VybmVsX2V2YWx1YXRlZCJ9LCJyZW5kZXJfc3RhdHVzIjoicmVuZGVyZWQiLCJzZGtfY29ubmVjdG9yX2ZyYW1lIjp7Im9yaWdpbiI6WzAsLTEwMDAsMF0sInhfYXhpcyI6WzEsMCwwXSwieV9heGlzIjpbMCwxLDBdLCJ6X2F4aXMiOlswLDAsMV19LCJzZGtfbWV0YWRhdGEiOnt9LCJzZW1hbnRpY19iaW5kaW5nX2lkcyI6W10sInNvdXJjZSI6eyJraW5kIjoidW5ib3VuZCJ9fV0sImZhY2VfZ3JvdXBzIjpbeyJlbnRpdHlfaWQiOiJlbnRpdHkvZmFjZS8wIiwiZmlyc3RfaW5kZXgiOjAsImdyb3VwX2lkIjowLCJpbmRleF9jb3VudCI6MywibWVzaF9pbmRleCI6MCwicHJpbWl0aXZlX2luZGV4IjowfSx7ImVudGl0eV9pZCI6ImVudGl0eS9mYWNlLzEiLCJmaXJzdF9pbmRleCI6MywiZ3JvdXBfaWQiOjEsImluZGV4X2NvdW50IjozLCJtZXNoX2luZGV4IjowLCJwcmltaXRpdmVfaW5kZXgiOjB9LHsiZW50aXR5X2lkIjoiZW50aXR5L2ZhY2UvMiIsImZpcnN0X2luZGV4Ijo2LCJncm91cF9pZCI6MiwiaW5kZXhfY291bnQiOjMsIm1lc2hfaW5kZXgiOjAsInByaW1pdGl2ZV9pbmRleCI6MH0seyJlbnRpdHlfaWQiOiJlbnRpdHkvZmFjZS8zIiwiZmlyc3RfaW5kZXgiOjksImdyb3VwX2lkIjozLCJpbmRleF9jb3VudCI6MywibWVzaF9pbmRleCI6MCwicHJpbWl0aXZlX2luZGV4IjowfV0sImdlb21ldHJ5X2Fzc2V0X2lkIjoic2hhMjU2OjIwMDUyMzQxNGRlMjgxYThhMTU1MDg2YTU0Y2QwZjg4MGY2NDA0ODU3NjVhNDhjY2RkOGVjMjMwZjY3ZGQxN2MiLCJnZW9tZXRyeV9lbmdpbmUiOnsibmFtZSI6Ik9wZW5DYXNjYWRlIiwicHJvZmlsZSI6Im9jcC1ldmFsdWF0ZWQtcHJvcGVydGllcy0xIiwidmVyc2lvbiI6ImZpeHR1cmUifSwic2NoZW1hX3ZlcnNpb24iOiIxLjAifQ==", + "entities/sha256-ec65dbf3f594403b3969be03fb0047737db645cc3b473304b1403812bec5c2cb.json": "eyJkZWZpbml0aW9uX2lkIjoiZGVmaW5pdGlvbi9yb290L3BhcnQvc2hhcmVkX3BhcnQiLCJlZGdlX2Fzc2V0X2lkIjoic2hhMjU2OjZjZTQ4MWRiMWZlMTNiY2ZlN2EyMzc0NDIzZTZjNzJkNzU3ZDUxYzk5MTY4YjI4N2M1MmY1M2FhNjI1Y2NkNjciLCJlZGdlX2dyb3VwcyI6W3siZW50aXR5X2lkIjoiZW50aXR5L2VkZ2UvMCIsImZpcnN0X2luZGV4IjowLCJncm91cF9pZCI6MCwiaW5kZXhfY291bnQiOjIsIm1lc2hfaW5kZXgiOjAsInByaW1pdGl2ZV9pbmRleCI6MH0seyJlbnRpdHlfaWQiOiJlbnRpdHkvZWRnZS8xIiwiZmlyc3RfaW5kZXgiOjIsImdyb3VwX2lkIjoxLCJpbmRleF9jb3VudCI6MiwibWVzaF9pbmRleCI6MCwicHJpbWl0aXZlX2luZGV4IjowfSx7ImVudGl0eV9pZCI6ImVudGl0eS9lZGdlLzIiLCJmaXJzdF9pbmRleCI6NCwiZ3JvdXBfaWQiOjIsImluZGV4X2NvdW50IjoyLCJtZXNoX2luZGV4IjowLCJwcmltaXRpdmVfaW5kZXgiOjB9LHsiZW50aXR5X2lkIjoiZW50aXR5L2VkZ2UvMyIsImZpcnN0X2luZGV4Ijo2LCJncm91cF9pZCI6MywiaW5kZXhfY291bnQiOjIsIm1lc2hfaW5kZXgiOjAsInByaW1pdGl2ZV9pbmRleCI6MH0seyJlbnRpdHlfaWQiOiJlbnRpdHkvZWRnZS80IiwiZmlyc3RfaW5kZXgiOjgsImdyb3VwX2lkIjo0LCJpbmRleF9jb3VudCI6MiwibWVzaF9pbmRleCI6MCwicHJpbWl0aXZlX2luZGV4IjowfSx7ImVudGl0eV9pZCI6ImVudGl0eS9lZGdlLzUiLCJmaXJzdF9pbmRleCI6MTAsImdyb3VwX2lkIjo1LCJpbmRleF9jb3VudCI6MiwibWVzaF9pbmRleCI6MCwicHJpbWl0aXZlX2luZGV4IjowfV0sImVudGl0aWVzIjpbeyJjaGlsZF9lbnRpdHlfaWRzIjpbImVudGl0eS92ZXJ0ZXgvMiIsImVudGl0eS92ZXJ0ZXgvMyJdLCJjb25uZWN0b3JfYmluZGluZ19zdGF0dXMiOiJzb3VyY2Vfbm90X21vZGVsIiwiZW50aXR5X2lkIjoiZW50aXR5L2VkZ2UvMCIsImV2YWx1YXRlZF90YWdzIjpbXSwiZ2VvbWV0cnkiOnsiZGlyZWN0aW9uIjpbMCwtMC43MDcxMDY3ODExODY1NDc1LC0wLjcwNzEwNjc4MTE4NjU0NzVdLCJvcmlnaW4iOlswLDAsMTAwMF0sInR5cGUiOiJsaW5lIn0sImtpbmQiOiJlZGdlIiwicGFyZW50X2VudGl0eV9pZHMiOlsiZW50aXR5L2ZhY2UvMCIsImVudGl0eS9mYWNlLzMiXSwicHJvcGVydGllcyI6eyJib3VuZHMiOnsibWF4IjpbMCwwLDEwMDBdLCJtaW4iOlswLC0xMDAwLDBdfSwiY2VudHJvaWQiOlswLC01MDAsNTAwXSwibGVuZ3RoIjoxNDE0LjIxMzU2MjM3MzA5NSwicXVhbGl0eSI6Imtlcm5lbF9ldmFsdWF0ZWQifSwicmVuZGVyX3N0YXR1cyI6InJlbmRlcmVkIiwic2RrX2Nvbm5lY3Rvcl9mcmFtZSI6eyJvcmlnaW4iOlswLC01MDAsNTAwXSwieF9heGlzIjpbMCwtMC43MDcxMDY3ODExODY1NDc1LC0wLjcwNzEwNjc4MTE4NjU0NzVdLCJ5X2F4aXMiOlswLC0wLjcwNzEwNjc4MTE4NjU0NzQsMC43MDcxMDY3ODExODY1NDc3XSwiel9heGlzIjpbLTEsMCwwXX0sInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImtpbmQiOiJ1bmJvdW5kIn19LHsiY2hpbGRfZW50aXR5X2lkcyI6WyJlbnRpdHkvdmVydGV4LzEiLCJlbnRpdHkvdmVydGV4LzMiXSwiY29ubmVjdG9yX2JpbmRpbmdfc3RhdHVzIjoic291cmNlX25vdF9tb2RlbCIsImVudGl0eV9pZCI6ImVudGl0eS9lZGdlLzEiLCJldmFsdWF0ZWRfdGFncyI6W10sImdlb21ldHJ5Ijp7ImRpcmVjdGlvbiI6Wy0wLjcwNzEwNjc4MTE4NjU0NzUsLTAuNzA3MTA2NzgxMTg2NTQ3NSwwXSwib3JpZ2luIjpbMTAwMCwwLDBdLCJ0eXBlIjoibGluZSJ9LCJraW5kIjoiZWRnZSIsInBhcmVudF9lbnRpdHlfaWRzIjpbImVudGl0eS9mYWNlLzEiLCJlbnRpdHkvZmFjZS8zIl0sInByb3BlcnRpZXMiOnsiYm91bmRzIjp7Im1heCI6WzEwMDAsMCwwXSwibWluIjpbMCwtMTAwMCwwXX0sImNlbnRyb2lkIjpbNTAwLC01MDAsMF0sImxlbmd0aCI6MTQxNC4yMTM1NjIzNzMwOTUsInF1YWxpdHkiOiJrZXJuZWxfZXZhbHVhdGVkIn0sInJlbmRlcl9zdGF0dXMiOiJyZW5kZXJlZCIsInNka19jb25uZWN0b3JfZnJhbWUiOnsib3JpZ2luIjpbNTAwLC01MDAsMF0sInhfYXhpcyI6Wy0wLjcwNzEwNjc4MTE4NjU0NzUsLTAuNzA3MTA2NzgxMTg2NTQ3NSwwXSwieV9heGlzIjpbMCwwLDFdLCJ6X2F4aXMiOlstMC43MDcxMDY3ODExODY1NDc2LDAuNzA3MTA2NzgxMTg2NTQ3NiwwXX0sInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImtpbmQiOiJ1bmJvdW5kIn19LHsiY2hpbGRfZW50aXR5X2lkcyI6WyJlbnRpdHkvdmVydGV4LzAiLCJlbnRpdHkvdmVydGV4LzMiXSwiY29ubmVjdG9yX2JpbmRpbmdfc3RhdHVzIjoic291cmNlX25vdF9tb2RlbCIsImVudGl0eV9pZCI6ImVudGl0eS9lZGdlLzIiLCJldmFsdWF0ZWRfdGFncyI6W10sImdlb21ldHJ5Ijp7ImRpcmVjdGlvbiI6WzAsLTEsMF0sIm9yaWdpbiI6WzAsMCwwXSwidHlwZSI6ImxpbmUifSwia2luZCI6ImVkZ2UiLCJwYXJlbnRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZmFjZS8wIiwiZW50aXR5L2ZhY2UvMSJdLCJwcm9wZXJ0aWVzIjp7ImJvdW5kcyI6eyJtYXgiOlswLDAsMF0sIm1pbiI6WzAsLTEwMDAsMF19LCJjZW50cm9pZCI6WzAsLTUwMCwwXSwibGVuZ3RoIjoxMDAwLCJxdWFsaXR5Ijoia2VybmVsX2V2YWx1YXRlZCJ9LCJyZW5kZXJfc3RhdHVzIjoicmVuZGVyZWQiLCJzZGtfY29ubmVjdG9yX2ZyYW1lIjp7Im9yaWdpbiI6WzAsLTUwMCwwXSwieF9heGlzIjpbMCwtMSwwXSwieV9heGlzIjpbMCwwLDFdLCJ6X2F4aXMiOlstMSwwLDBdfSwic2RrX21ldGFkYXRhIjp7fSwic2VtYW50aWNfYmluZGluZ19pZHMiOltdLCJzb3VyY2UiOnsia2luZCI6InVuYm91bmQifX0seyJjaGlsZF9lbnRpdHlfaWRzIjpbImVudGl0eS92ZXJ0ZXgvMCIsImVudGl0eS92ZXJ0ZXgvMSJdLCJjb25uZWN0b3JfYmluZGluZ19zdGF0dXMiOiJzb3VyY2Vfbm90X21vZGVsIiwiZW50aXR5X2lkIjoiZW50aXR5L2VkZ2UvMyIsImV2YWx1YXRlZF90YWdzIjpbXSwiZ2VvbWV0cnkiOnsiZGlyZWN0aW9uIjpbMSwwLDBdLCJvcmlnaW4iOlswLDAsMF0sInR5cGUiOiJsaW5lIn0sImtpbmQiOiJlZGdlIiwicGFyZW50X2VudGl0eV9pZHMiOlsiZW50aXR5L2ZhY2UvMSIsImVudGl0eS9mYWNlLzIiXSwicHJvcGVydGllcyI6eyJib3VuZHMiOnsibWF4IjpbMTAwMCwwLDBdLCJtaW4iOlswLDAsMF19LCJjZW50cm9pZCI6WzUwMCwwLDBdLCJsZW5ndGgiOjEwMDAsInF1YWxpdHkiOiJrZXJuZWxfZXZhbHVhdGVkIn0sInJlbmRlcl9zdGF0dXMiOiJyZW5kZXJlZCIsInNka19jb25uZWN0b3JfZnJhbWUiOnsib3JpZ2luIjpbNTAwLDAsMF0sInhfYXhpcyI6WzEsMCwwXSwieV9heGlzIjpbMCwwLDFdLCJ6X2F4aXMiOlswLC0xLDBdfSwic2RrX21ldGFkYXRhIjp7fSwic2VtYW50aWNfYmluZGluZ19pZHMiOltdLCJzb3VyY2UiOnsia2luZCI6InVuYm91bmQifX0seyJjaGlsZF9lbnRpdHlfaWRzIjpbImVudGl0eS92ZXJ0ZXgvMSIsImVudGl0eS92ZXJ0ZXgvMiJdLCJjb25uZWN0b3JfYmluZGluZ19zdGF0dXMiOiJzb3VyY2Vfbm90X21vZGVsIiwiZW50aXR5X2lkIjoiZW50aXR5L2VkZ2UvNCIsImV2YWx1YXRlZF90YWdzIjpbXSwiZ2VvbWV0cnkiOnsiZGlyZWN0aW9uIjpbLTAuNzA3MTA2NzgxMTg2NTQ3NSwwLDAuNzA3MTA2NzgxMTg2NTQ3NV0sIm9yaWdpbiI6WzEwMDAsMCwwXSwidHlwZSI6ImxpbmUifSwia2luZCI6ImVkZ2UiLCJwYXJlbnRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZmFjZS8yIiwiZW50aXR5L2ZhY2UvMyJdLCJwcm9wZXJ0aWVzIjp7ImJvdW5kcyI6eyJtYXgiOlsxMDAwLDAsMTAwMF0sIm1pbiI6WzAsMCwwXX0sImNlbnRyb2lkIjpbNTAwLDAsNTAwXSwibGVuZ3RoIjoxNDE0LjIxMzU2MjM3MzA5NSwicXVhbGl0eSI6Imtlcm5lbF9ldmFsdWF0ZWQifSwicmVuZGVyX3N0YXR1cyI6InJlbmRlcmVkIiwic2RrX2Nvbm5lY3Rvcl9mcmFtZSI6eyJvcmlnaW4iOls1MDAsMCw1MDBdLCJ4X2F4aXMiOlstMC43MDcxMDY3ODExODY1NDc1LDAsMC43MDcxMDY3ODExODY1NDc1XSwieV9heGlzIjpbMC43MDcxMDY3ODExODY1NDc0LDAsMC43MDcxMDY3ODExODY1NDc3XSwiel9heGlzIjpbMCwxLDBdfSwic2RrX21ldGFkYXRhIjp7fSwic2VtYW50aWNfYmluZGluZ19pZHMiOltdLCJzb3VyY2UiOnsia2luZCI6InVuYm91bmQifX0seyJjaGlsZF9lbnRpdHlfaWRzIjpbImVudGl0eS92ZXJ0ZXgvMCIsImVudGl0eS92ZXJ0ZXgvMiJdLCJjb25uZWN0b3JfYmluZGluZ19zdGF0dXMiOiJzb3VyY2Vfbm90X21vZGVsIiwiZW50aXR5X2lkIjoiZW50aXR5L2VkZ2UvNSIsImV2YWx1YXRlZF90YWdzIjpbXSwiZ2VvbWV0cnkiOnsiZGlyZWN0aW9uIjpbMCwwLDFdLCJvcmlnaW4iOlswLDAsMF0sInR5cGUiOiJsaW5lIn0sImtpbmQiOiJlZGdlIiwicGFyZW50X2VudGl0eV9pZHMiOlsiZW50aXR5L2ZhY2UvMCIsImVudGl0eS9mYWNlLzIiXSwicHJvcGVydGllcyI6eyJib3VuZHMiOnsibWF4IjpbMCwwLDEwMDBdLCJtaW4iOlswLDAsMF19LCJjZW50cm9pZCI6WzAsMCw1MDBdLCJsZW5ndGgiOjEwMDAsInF1YWxpdHkiOiJrZXJuZWxfZXZhbHVhdGVkIn0sInJlbmRlcl9zdGF0dXMiOiJyZW5kZXJlZCIsInNka19jb25uZWN0b3JfZnJhbWUiOnsib3JpZ2luIjpbMCwwLDUwMF0sInhfYXhpcyI6WzAsMCwxXSwieV9heGlzIjpbMCwxLDBdLCJ6X2F4aXMiOlstMSwwLDBdfSwic2RrX21ldGFkYXRhIjp7fSwic2VtYW50aWNfYmluZGluZ19pZHMiOltdLCJzb3VyY2UiOnsia2luZCI6InVuYm91bmQifX0seyJjaGlsZF9lbnRpdHlfaWRzIjpbImVudGl0eS9lZGdlLzAiLCJlbnRpdHkvZWRnZS8yIiwiZW50aXR5L2VkZ2UvNSJdLCJjb25uZWN0b3JfYmluZGluZ19zdGF0dXMiOiJzb3VyY2Vfbm90X21vZGVsIiwiZW50aXR5X2lkIjoiZW50aXR5L2ZhY2UvMCIsImV2YWx1YXRlZF90YWdzIjpbXSwiZ2VvbWV0cnkiOnsibm9ybWFsIjpbLTEsMCwwXSwib3JpZ2luIjpbMCwtMzMzLjMzMzMzMzMzMzMzMzMsMzMzLjMzMzMzMzMzMzMzMzNdLCJ0eXBlIjoicGxhbmUiLCJ4X2RpcmVjdGlvbiI6WzAsMCwxXX0sImtpbmQiOiJmYWNlIiwicGFyZW50X2VudGl0eV9pZHMiOlsiZW50aXR5L3NvbGlkLzAiXSwicHJvcGVydGllcyI6eyJhcmVhIjo1MDAwMDAsImJvdW5kcyI6eyJtYXgiOlswLDAsMTAwMF0sIm1pbiI6WzAsLTEwMDAsMF19LCJjZW50cm9pZCI6WzAsLTMzMy4zMzMzMzMzMzMzMzMzLDMzMy4zMzMzMzMzMzMzMzMzXSwib3JpZW50YXRpb24iOiJmb3J3YXJkIiwicXVhbGl0eSI6Imtlcm5lbF9ldmFsdWF0ZWQifSwicmVuZGVyX3N0YXR1cyI6InJlbmRlcmVkIiwic2RrX2Nvbm5lY3Rvcl9mcmFtZSI6eyJvcmlnaW4iOlswLC0zMzMuMzMzMzMzMzMzMzMzMywzMzMuMzMzMzMzMzMzMzMzM10sInhfYXhpcyI6WzAsMCwxXSwieV9heGlzIjpbMCwxLDBdLCJ6X2F4aXMiOlstMSwwLDBdfSwic2RrX21ldGFkYXRhIjp7fSwic2VtYW50aWNfYmluZGluZ19pZHMiOltdLCJzb3VyY2UiOnsia2luZCI6InVuYm91bmQifX0seyJjaGlsZF9lbnRpdHlfaWRzIjpbImVudGl0eS9lZGdlLzEiLCJlbnRpdHkvZWRnZS8yIiwiZW50aXR5L2VkZ2UvMyJdLCJjb25uZWN0b3JfYmluZGluZ19zdGF0dXMiOiJzb3VyY2Vfbm90X21vZGVsIiwiZW50aXR5X2lkIjoiZW50aXR5L2ZhY2UvMSIsImV2YWx1YXRlZF90YWdzIjpbXSwiZ2VvbWV0cnkiOnsibm9ybWFsIjpbMCwwLC0xXSwib3JpZ2luIjpbMzMzLjMzMzMzMzMzMzMzMzMsLTMzMy4zMzMzMzMzMzMzMzMzLDBdLCJ0eXBlIjoicGxhbmUiLCJ4X2RpcmVjdGlvbiI6WzEsMCwwXX0sImtpbmQiOiJmYWNlIiwicGFyZW50X2VudGl0eV9pZHMiOlsiZW50aXR5L3NvbGlkLzAiXSwicHJvcGVydGllcyI6eyJhcmVhIjo1MDAwMDAsImJvdW5kcyI6eyJtYXgiOlsxMDAwLDAsMF0sIm1pbiI6WzAsLTEwMDAsMF19LCJjZW50cm9pZCI6WzMzMy4zMzMzMzMzMzMzMzMzLC0zMzMuMzMzMzMzMzMzMzMzMywwXSwib3JpZW50YXRpb24iOiJmb3J3YXJkIiwicXVhbGl0eSI6Imtlcm5lbF9ldmFsdWF0ZWQifSwicmVuZGVyX3N0YXR1cyI6InJlbmRlcmVkIiwic2RrX2Nvbm5lY3Rvcl9mcmFtZSI6eyJvcmlnaW4iOlszMzMuMzMzMzMzMzMzMzMzMywtMzMzLjMzMzMzMzMzMzMzMzMsMF0sInhfYXhpcyI6WzEsMCwwXSwieV9heGlzIjpbMCwtMSwwXSwiel9heGlzIjpbMCwwLC0xXX0sInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImtpbmQiOiJ1bmJvdW5kIn19LHsiY2hpbGRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZWRnZS8zIiwiZW50aXR5L2VkZ2UvNCIsImVudGl0eS9lZGdlLzUiXSwiY29ubmVjdG9yX2JpbmRpbmdfc3RhdHVzIjoic291cmNlX25vdF9tb2RlbCIsImVudGl0eV9pZCI6ImVudGl0eS9mYWNlLzIiLCJldmFsdWF0ZWRfdGFncyI6W10sImdlb21ldHJ5Ijp7Im5vcm1hbCI6WzAsMSwwXSwib3JpZ2luIjpbMzMzLjMzMzMzMzMzMzMzMzMsMCwzMzMuMzMzMzMzMzMzMzMzM10sInR5cGUiOiJwbGFuZSIsInhfZGlyZWN0aW9uIjpbMSwwLDBdfSwia2luZCI6ImZhY2UiLCJwYXJlbnRfZW50aXR5X2lkcyI6WyJlbnRpdHkvc29saWQvMCJdLCJwcm9wZXJ0aWVzIjp7ImFyZWEiOjUwMDAwMCwiYm91bmRzIjp7Im1heCI6WzEwMDAsMCwxMDAwXSwibWluIjpbMCwwLDBdfSwiY2VudHJvaWQiOlszMzMuMzMzMzMzMzMzMzMzMywwLDMzMy4zMzMzMzMzMzMzMzMzXSwib3JpZW50YXRpb24iOiJmb3J3YXJkIiwicXVhbGl0eSI6Imtlcm5lbF9ldmFsdWF0ZWQifSwicmVuZGVyX3N0YXR1cyI6InJlbmRlcmVkIiwic2RrX2Nvbm5lY3Rvcl9mcmFtZSI6eyJvcmlnaW4iOlszMzMuMzMzMzMzMzMzMzMzMywwLDMzMy4zMzMzMzMzMzMzMzMzXSwieF9heGlzIjpbMSwwLDBdLCJ5X2F4aXMiOlswLDAsLTFdLCJ6X2F4aXMiOlswLDEsMF19LCJzZGtfbWV0YWRhdGEiOnt9LCJzZW1hbnRpY19iaW5kaW5nX2lkcyI6W10sInNvdXJjZSI6eyJraW5kIjoidW5ib3VuZCJ9fSx7ImNoaWxkX2VudGl0eV9pZHMiOlsiZW50aXR5L2VkZ2UvMCIsImVudGl0eS9lZGdlLzEiLCJlbnRpdHkvZWRnZS80Il0sImNvbm5lY3Rvcl9iaW5kaW5nX3N0YXR1cyI6InNvdXJjZV9ub3RfbW9kZWwiLCJlbnRpdHlfaWQiOiJlbnRpdHkvZmFjZS8zIiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJub3JtYWwiOlswLjU3NzM1MDI2OTE4OTYyNTgsLTAuNTc3MzUwMjY5MTg5NjI1OCwwLjU3NzM1MDI2OTE4OTYyNThdLCJvcmlnaW4iOlszMzMuMzMzMzMzMzMzMzMzMywtMzMzLjMzMzMzMzMzMzMzMzMsMzMzLjMzMzMzMzMzMzMzMzNdLCJ0eXBlIjoicGxhbmUiLCJ4X2RpcmVjdGlvbiI6Wy0wLjcwNzEwNjc4MTE4NjU0NzUsMCwwLjcwNzEwNjc4MTE4NjU0NzVdfSwia2luZCI6ImZhY2UiLCJwYXJlbnRfZW50aXR5X2lkcyI6WyJlbnRpdHkvc29saWQvMCJdLCJwcm9wZXJ0aWVzIjp7ImFyZWEiOjg2NjAyNS40MDM3ODQ0Mzg2LCJib3VuZHMiOnsibWF4IjpbMTAwMCwwLDEwMDBdLCJtaW4iOlswLC0xMDAwLDBdfSwiY2VudHJvaWQiOlszMzMuMzMzMzMzMzMzMzMzMywtMzMzLjMzMzMzMzMzMzMzMzMsMzMzLjMzMzMzMzMzMzMzMzNdLCJvcmllbnRhdGlvbiI6ImZvcndhcmQiLCJxdWFsaXR5Ijoia2VybmVsX2V2YWx1YXRlZCJ9LCJyZW5kZXJfc3RhdHVzIjoicmVuZGVyZWQiLCJzZGtfY29ubmVjdG9yX2ZyYW1lIjp7Im9yaWdpbiI6WzMzMy4zMzMzMzMzMzMzMzMzLC0zMzMuMzMzMzMzMzMzMzMzMywzMzMuMzMzMzMzMzMzMzMzM10sInhfYXhpcyI6Wy0wLjcwNzEwNjc4MTE4NjU0NzUsMCwwLjcwNzEwNjc4MTE4NjU0NzVdLCJ5X2F4aXMiOlstMC40MDgyNDgyOTA0NjM4NjMxLC0wLjgxNjQ5NjU4MDkyNzcyNjEsLTAuNDA4MjQ4MjkwNDYzODYzMV0sInpfYXhpcyI6WzAuNTc3MzUwMjY5MTg5NjI1OCwtMC41NzczNTAyNjkxODk2MjU4LDAuNTc3MzUwMjY5MTg5NjI1OF19LCJzZGtfbWV0YWRhdGEiOnt9LCJzZW1hbnRpY19iaW5kaW5nX2lkcyI6W10sInNvdXJjZSI6eyJraW5kIjoidW5ib3VuZCJ9fSx7ImNoaWxkX2VudGl0eV9pZHMiOlsiZW50aXR5L2ZhY2UvMCIsImVudGl0eS9mYWNlLzEiLCJlbnRpdHkvZmFjZS8yIiwiZW50aXR5L2ZhY2UvMyJdLCJjb25uZWN0b3JfYmluZGluZ19zdGF0dXMiOiJub3RfYXBwbGljYWJsZSIsImVudGl0eV9pZCI6ImVudGl0eS9zb2xpZC8wIiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJ0eXBlIjoiYnJlcF9zb2xpZCJ9LCJraW5kIjoic29saWQiLCJwYXJlbnRfZW50aXR5X2lkcyI6W10sInByb3BlcnRpZXMiOnsiYm91bmRzIjp7Im1heCI6WzEwMDAsMCwxMDAwXSwibWluIjpbMCwtMTAwMCwwXX0sImNlbnRyb2lkIjpbMjUwLC0yNTAsMjUwXSwicXVhbGl0eSI6Imtlcm5lbF9ldmFsdWF0ZWQiLCJzdXJmYWNlX2FyZWEiOjIzNjYwMjUuNDAzNzg0NDM4NSwidm9sdW1lIjoxNjY2NjY2NjYuNjY2NjY2NjZ9LCJyZW5kZXJfc3RhdHVzIjoicmVuZGVyZWQiLCJzZGtfY29ubmVjdG9yX2ZyYW1lIjpudWxsLCJzZGtfbWV0YWRhdGEiOnt9LCJzZW1hbnRpY19iaW5kaW5nX2lkcyI6W10sInNvdXJjZSI6eyJraW5kIjoidW5ib3VuZCJ9fSx7ImNoaWxkX2VudGl0eV9pZHMiOltdLCJjb25uZWN0b3JfYmluZGluZ19zdGF0dXMiOiJzb3VyY2Vfbm90X21vZGVsIiwiZW50aXR5X2lkIjoiZW50aXR5L3ZlcnRleC8wIiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJwb3NpdGlvbiI6WzAsMCwwXSwidHlwZSI6InBvaW50In0sImtpbmQiOiJ2ZXJ0ZXgiLCJwYXJlbnRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZWRnZS8yIiwiZW50aXR5L2VkZ2UvMyIsImVudGl0eS9lZGdlLzUiXSwicHJvcGVydGllcyI6eyJib3VuZHMiOnsibWF4IjpbMCwwLDBdLCJtaW4iOlswLDAsMF19LCJwb3NpdGlvbiI6WzAsMCwwXSwicXVhbGl0eSI6Imtlcm5lbF9ldmFsdWF0ZWQifSwicmVuZGVyX3N0YXR1cyI6InJlbmRlcmVkIiwic2RrX2Nvbm5lY3Rvcl9mcmFtZSI6eyJvcmlnaW4iOlswLDAsMF0sInhfYXhpcyI6WzEsMCwwXSwieV9heGlzIjpbMCwxLDBdLCJ6X2F4aXMiOlswLDAsMV19LCJzZGtfbWV0YWRhdGEiOnt9LCJzZW1hbnRpY19iaW5kaW5nX2lkcyI6W10sInNvdXJjZSI6eyJraW5kIjoidW5ib3VuZCJ9fSx7ImNoaWxkX2VudGl0eV9pZHMiOltdLCJjb25uZWN0b3JfYmluZGluZ19zdGF0dXMiOiJzb3VyY2Vfbm90X21vZGVsIiwiZW50aXR5X2lkIjoiZW50aXR5L3ZlcnRleC8xIiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJwb3NpdGlvbiI6WzEwMDAsMCwwXSwidHlwZSI6InBvaW50In0sImtpbmQiOiJ2ZXJ0ZXgiLCJwYXJlbnRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZWRnZS8xIiwiZW50aXR5L2VkZ2UvMyIsImVudGl0eS9lZGdlLzQiXSwicHJvcGVydGllcyI6eyJib3VuZHMiOnsibWF4IjpbMTAwMCwwLDBdLCJtaW4iOlsxMDAwLDAsMF19LCJwb3NpdGlvbiI6WzEwMDAsMCwwXSwicXVhbGl0eSI6Imtlcm5lbF9ldmFsdWF0ZWQifSwicmVuZGVyX3N0YXR1cyI6InJlbmRlcmVkIiwic2RrX2Nvbm5lY3Rvcl9mcmFtZSI6eyJvcmlnaW4iOlsxMDAwLDAsMF0sInhfYXhpcyI6WzEsMCwwXSwieV9heGlzIjpbMCwxLDBdLCJ6X2F4aXMiOlswLDAsMV19LCJzZGtfbWV0YWRhdGEiOnt9LCJzZW1hbnRpY19iaW5kaW5nX2lkcyI6W10sInNvdXJjZSI6eyJraW5kIjoidW5ib3VuZCJ9fSx7ImNoaWxkX2VudGl0eV9pZHMiOltdLCJjb25uZWN0b3JfYmluZGluZ19zdGF0dXMiOiJzb3VyY2Vfbm90X21vZGVsIiwiZW50aXR5X2lkIjoiZW50aXR5L3ZlcnRleC8yIiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJwb3NpdGlvbiI6WzAsMCwxMDAwXSwidHlwZSI6InBvaW50In0sImtpbmQiOiJ2ZXJ0ZXgiLCJwYXJlbnRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZWRnZS8wIiwiZW50aXR5L2VkZ2UvNCIsImVudGl0eS9lZGdlLzUiXSwicHJvcGVydGllcyI6eyJib3VuZHMiOnsibWF4IjpbMCwwLDEwMDBdLCJtaW4iOlswLDAsMTAwMF19LCJwb3NpdGlvbiI6WzAsMCwxMDAwXSwicXVhbGl0eSI6Imtlcm5lbF9ldmFsdWF0ZWQifSwicmVuZGVyX3N0YXR1cyI6InJlbmRlcmVkIiwic2RrX2Nvbm5lY3Rvcl9mcmFtZSI6eyJvcmlnaW4iOlswLDAsMTAwMF0sInhfYXhpcyI6WzEsMCwwXSwieV9heGlzIjpbMCwxLDBdLCJ6X2F4aXMiOlswLDAsMV19LCJzZGtfbWV0YWRhdGEiOnt9LCJzZW1hbnRpY19iaW5kaW5nX2lkcyI6W10sInNvdXJjZSI6eyJraW5kIjoidW5ib3VuZCJ9fSx7ImNoaWxkX2VudGl0eV9pZHMiOltdLCJjb25uZWN0b3JfYmluZGluZ19zdGF0dXMiOiJzb3VyY2Vfbm90X21vZGVsIiwiZW50aXR5X2lkIjoiZW50aXR5L3ZlcnRleC8zIiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJwb3NpdGlvbiI6WzAsLTEwMDAsMF0sInR5cGUiOiJwb2ludCJ9LCJraW5kIjoidmVydGV4IiwicGFyZW50X2VudGl0eV9pZHMiOlsiZW50aXR5L2VkZ2UvMCIsImVudGl0eS9lZGdlLzEiLCJlbnRpdHkvZWRnZS8yIl0sInByb3BlcnRpZXMiOnsiYm91bmRzIjp7Im1heCI6WzAsLTEwMDAsMF0sIm1pbiI6WzAsLTEwMDAsMF19LCJwb3NpdGlvbiI6WzAsLTEwMDAsMF0sInF1YWxpdHkiOiJrZXJuZWxfZXZhbHVhdGVkIn0sInJlbmRlcl9zdGF0dXMiOiJyZW5kZXJlZCIsInNka19jb25uZWN0b3JfZnJhbWUiOnsib3JpZ2luIjpbMCwtMTAwMCwwXSwieF9heGlzIjpbMSwwLDBdLCJ5X2F4aXMiOlswLDEsMF0sInpfYXhpcyI6WzAsMCwxXX0sInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImtpbmQiOiJ1bmJvdW5kIn19XSwiZmFjZV9ncm91cHMiOlt7ImVudGl0eV9pZCI6ImVudGl0eS9mYWNlLzAiLCJmaXJzdF9pbmRleCI6MCwiZ3JvdXBfaWQiOjAsImluZGV4X2NvdW50IjozLCJtZXNoX2luZGV4IjowLCJwcmltaXRpdmVfaW5kZXgiOjB9LHsiZW50aXR5X2lkIjoiZW50aXR5L2ZhY2UvMSIsImZpcnN0X2luZGV4IjozLCJncm91cF9pZCI6MSwiaW5kZXhfY291bnQiOjMsIm1lc2hfaW5kZXgiOjAsInByaW1pdGl2ZV9pbmRleCI6MH0seyJlbnRpdHlfaWQiOiJlbnRpdHkvZmFjZS8yIiwiZmlyc3RfaW5kZXgiOjYsImdyb3VwX2lkIjoyLCJpbmRleF9jb3VudCI6MywibWVzaF9pbmRleCI6MCwicHJpbWl0aXZlX2luZGV4IjowfSx7ImVudGl0eV9pZCI6ImVudGl0eS9mYWNlLzMiLCJmaXJzdF9pbmRleCI6OSwiZ3JvdXBfaWQiOjMsImluZGV4X2NvdW50IjozLCJtZXNoX2luZGV4IjowLCJwcmltaXRpdmVfaW5kZXgiOjB9XSwiZ2VvbWV0cnlfYXNzZXRfaWQiOiJzaGEyNTY6MjAwNTIzNDE0ZGUyODFhOGExNTUwODZhNTRjZDBmODgwZjY0MDQ4NTc2NWE0OGNjZGQ4ZWMyMzBmNjdkZDE3YyIsImdlb21ldHJ5X2VuZ2luZSI6eyJuYW1lIjoiT3BlbkNhc2NhZGUiLCJwcm9maWxlIjoib2NwLWV2YWx1YXRlZC1wcm9wZXJ0aWVzLTEiLCJ2ZXJzaW9uIjoiZml4dHVyZSJ9LCJzY2hlbWFfdmVyc2lvbiI6IjEuMCJ9", + "geometry/sha256-200523414de281a8a155086a54cd0f880f640485765a48ccdd8ec230f67dd17c.glb": "Z2xURgIAAAD8AwAAqAIAAEpTT057ImFjY2Vzc29ycyI6W3siYnVmZmVyVmlldyI6MCwiY29tcG9uZW50VHlwZSI6NTEyNiwiY291bnQiOjEyLCJtYXgiOlsxLDEsMV0sIm1pbiI6WzAsMCwwXSwidHlwZSI6IlZFQzMifSx7ImJ1ZmZlclZpZXciOjEsImNvbXBvbmVudFR5cGUiOjUxMjYsImNvdW50IjoxMiwidHlwZSI6IlZFQzMifSx7ImJ1ZmZlclZpZXciOjIsImNvbXBvbmVudFR5cGUiOjUxMjMsImNvdW50IjoxMiwidHlwZSI6IlNDQUxBUiJ9XSwiYXNzZXQiOnsiZ2VuZXJhdG9yIjoiU2ltcGxlQ0FEIFNjZW5lIEdMQiBQcm9maWxlIDEiLCJ2ZXJzaW9uIjoiMi4wIn0sImJ1ZmZlclZpZXdzIjpbeyJidWZmZXIiOjAsImJ5dGVMZW5ndGgiOjE0NCwiYnl0ZU9mZnNldCI6MCwidGFyZ2V0IjozNDk2Mn0seyJidWZmZXIiOjAsImJ5dGVMZW5ndGgiOjE0NCwiYnl0ZU9mZnNldCI6MTQ0LCJ0YXJnZXQiOjM0OTYyfSx7ImJ1ZmZlciI6MCwiYnl0ZUxlbmd0aCI6MjQsImJ5dGVPZmZzZXQiOjI4OCwidGFyZ2V0IjozNDk2M31dLCJidWZmZXJzIjpbeyJieXRlTGVuZ3RoIjozMTJ9XSwibWVzaGVzIjpbeyJwcmltaXRpdmVzIjpbeyJhdHRyaWJ1dGVzIjp7Ik5PUk1BTCI6MSwiUE9TSVRJT04iOjB9LCJpbmRpY2VzIjoyLCJtb2RlIjo0fV19XSwibm9kZXMiOlt7Im1lc2giOjB9XSwic2NlbmUiOjAsInNjZW5lcyI6W3sibm9kZXMiOlswXX1dfTgBAABCSU4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIA/AAAAAAAAgD8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIA/AACAPwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgD8AAAAAAACAPwAAAAAAAAAAAAAAAAAAAAAAAIA/AAAAAAAAgD8AAAAAAACAPwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/Os0TPzrNEz86zRM/Os0TPzrNEz86zRM/Os0TPzrNEz86zRM/AAABAAIAAwAFAAQABgAHAAgACQALAAoA" + }, + "connector_binding_cases": [ + { + "expected": null, + "name": "valid_connector_binding", + "payload_base64": "eyJiaW5kaW5nX2lkIjoiZml4dHVyZSIsImNvbm5lY3Rvcl9pZCI6ImZpeHR1cmVfY29ubmVjdG9yIiwibmFtZSI6bnVsbCwib3duZXJfZGVmaW5pdGlvbl9pZCI6ImRlZmluaXRpb24vcm9vdC9wYXJ0L2ZpeHR1cmUiLCJzY2hlbWFfdmVyc2lvbiI6IjEuMCIsInNlbGVjdGVkX29jY3VycmVuY2Vfbm9kZV9pZCI6Imluc3RhbmNlL3Jvb3QiLCJzb3VyY2VfbW9kZWwiOnsiYXJ0aWZhY3RfaGFzaCI6InNoYTI1NjoxMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExIiwiZ3JhcGhfaWQiOiJmaXh0dXJlX2dyYXBoIiwibW9kZWxfc2NoZW1hX3ZlcnNpb24iOiIyLjAifSwic291cmNlX3NjZW5lIjp7InJldmlzaW9uIjoic2hhMjU2OmZhZTE1ZjRhZWVmMTI3N2NkODMxZmY4YmY4M2Y5YjI0NGQ1YzQyMjVlNzJlYjdiYzJlMGUzNWRkNmNmNDM2MTQiLCJzY2VuZV9pZCI6ImZpeHR1cmUifSwidGFyZ2V0Ijp7ImVudGl0eV9hc3NldF9pZCI6InNoYTI1NjoyMDA1MjM0MTRkZTI4MWE4YTE1NTA4NmE1NGNkMGY4ODBmNjQwNDg1NzY1YTQ4Y2NkZDhlYzIzMGY2N2RkMTdjIiwiZW50aXR5X2lkIjoiZW50aXR5L2ZhY2UvMCIsImV4cGVjdGVkX3NvdXJjZSI6eyJncmFwaF9pZCI6ImZpeHR1cmVfZ3JhcGgiLCJraW5kIjoibW9kZWxfb3V0cHV0Iiwibm9kZV9pZCI6ImZpeHR1cmVfYm9keSIsIm91dHB1dF9zbG90IjowfSwiZmxpcCI6dHJ1ZSwia2luZCI6InRvcG9sb2d5X2VudGl0eSJ9fQ==", + "valid": true + }, + { + "expected": { + "code": "source_matrix_invalid", + "path": "/target/expected_source/graph_id" + }, + "name": "connector_binding_graph_mismatch", + "payload_base64": "eyJiaW5kaW5nX2lkIjoiZml4dHVyZSIsImNvbm5lY3Rvcl9pZCI6ImZpeHR1cmVfY29ubmVjdG9yIiwibmFtZSI6bnVsbCwib3duZXJfZGVmaW5pdGlvbl9pZCI6ImRlZmluaXRpb24vcm9vdC9wYXJ0L2ZpeHR1cmUiLCJzY2hlbWFfdmVyc2lvbiI6IjEuMCIsInNlbGVjdGVkX29jY3VycmVuY2Vfbm9kZV9pZCI6Imluc3RhbmNlL3Jvb3QiLCJzb3VyY2VfbW9kZWwiOnsiYXJ0aWZhY3RfaGFzaCI6InNoYTI1NjoxMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExIiwiZ3JhcGhfaWQiOiJmaXh0dXJlX2dyYXBoIiwibW9kZWxfc2NoZW1hX3ZlcnNpb24iOiIyLjAifSwic291cmNlX3NjZW5lIjp7InJldmlzaW9uIjoic2hhMjU2OmZhZTE1ZjRhZWVmMTI3N2NkODMxZmY4YmY4M2Y5YjI0NGQ1YzQyMjVlNzJlYjdiYzJlMGUzNWRkNmNmNDM2MTQiLCJzY2VuZV9pZCI6ImZpeHR1cmUifSwidGFyZ2V0Ijp7ImVudGl0eV9hc3NldF9pZCI6InNoYTI1NjoyMDA1MjM0MTRkZTI4MWE4YTE1NTA4NmE1NGNkMGY4ODBmNjQwNDg1NzY1YTQ4Y2NkZDhlYzIzMGY2N2RkMTdjIiwiZW50aXR5X2lkIjoiZW50aXR5L2ZhY2UvMCIsImV4cGVjdGVkX3NvdXJjZSI6eyJncmFwaF9pZCI6Im90aGVyX2dyYXBoIiwia2luZCI6Im1vZGVsX291dHB1dCIsIm5vZGVfaWQiOiJmaXh0dXJlX2JvZHkiLCJvdXRwdXRfc2xvdCI6MH0sImZsaXAiOnRydWUsImtpbmQiOiJ0b3BvbG9neV9lbnRpdHkifX0=", + "valid": false + }, + { + "expected": { + "code": "connector_invalid", + "path": "/target/flip" + }, + "name": "connector_binding_vertex_flip", + "payload_base64": "eyJiaW5kaW5nX2lkIjoiZml4dHVyZSIsImNvbm5lY3Rvcl9pZCI6ImZpeHR1cmVfY29ubmVjdG9yIiwibmFtZSI6bnVsbCwib3duZXJfZGVmaW5pdGlvbl9pZCI6ImRlZmluaXRpb24vcm9vdC9wYXJ0L2ZpeHR1cmUiLCJzY2hlbWFfdmVyc2lvbiI6IjEuMCIsInNlbGVjdGVkX29jY3VycmVuY2Vfbm9kZV9pZCI6Imluc3RhbmNlL3Jvb3QiLCJzb3VyY2VfbW9kZWwiOnsiYXJ0aWZhY3RfaGFzaCI6InNoYTI1NjoxMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExIiwiZ3JhcGhfaWQiOiJmaXh0dXJlX2dyYXBoIiwibW9kZWxfc2NoZW1hX3ZlcnNpb24iOiIyLjAifSwic291cmNlX3NjZW5lIjp7InJldmlzaW9uIjoic2hhMjU2OmZhZTE1ZjRhZWVmMTI3N2NkODMxZmY4YmY4M2Y5YjI0NGQ1YzQyMjVlNzJlYjdiYzJlMGUzNWRkNmNmNDM2MTQiLCJzY2VuZV9pZCI6ImZpeHR1cmUifSwidGFyZ2V0Ijp7ImVudGl0eV9hc3NldF9pZCI6InNoYTI1NjoyMDA1MjM0MTRkZTI4MWE4YTE1NTA4NmE1NGNkMGY4ODBmNjQwNDg1NzY1YTQ4Y2NkZDhlYzIzMGY2N2RkMTdjIiwiZW50aXR5X2lkIjoiZW50aXR5L3ZlcnRleC8wIiwiZXhwZWN0ZWRfc291cmNlIjp7ImdyYXBoX2lkIjoiZml4dHVyZV9ncmFwaCIsImtpbmQiOiJtb2RlbF9vdXRwdXQiLCJub2RlX2lkIjoiZml4dHVyZV9ib2R5Iiwib3V0cHV0X3Nsb3QiOjB9LCJmbGlwIjp0cnVlLCJraW5kIjoidG9wb2xvZ3lfZW50aXR5In19", + "valid": false + }, + { + "expected": null, + "name": "valid_binding_model_topology_face", + "payload_base64": "eyJiaW5kaW5nX2lkIjoiZml4dHVyZSIsImNvbm5lY3Rvcl9pZCI6ImZpeHR1cmVfY29ubmVjdG9yIiwibmFtZSI6bnVsbCwib3duZXJfZGVmaW5pdGlvbl9pZCI6ImRlZmluaXRpb24vcm9vdC9wYXJ0L2ZpeHR1cmUiLCJzY2hlbWFfdmVyc2lvbiI6IjEuMCIsInNlbGVjdGVkX29jY3VycmVuY2Vfbm9kZV9pZCI6Imluc3RhbmNlL3Jvb3QiLCJzb3VyY2VfbW9kZWwiOnsiYXJ0aWZhY3RfaGFzaCI6InNoYTI1NjoxMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExIiwiZ3JhcGhfaWQiOiJmaXh0dXJlX2dyYXBoIiwibW9kZWxfc2NoZW1hX3ZlcnNpb24iOiIyLjAifSwic291cmNlX3NjZW5lIjp7InJldmlzaW9uIjoic2hhMjU2OmZhZTE1ZjRhZWVmMTI3N2NkODMxZmY4YmY4M2Y5YjI0NGQ1YzQyMjVlNzJlYjdiYzJlMGUzNWRkNmNmNDM2MTQiLCJzY2VuZV9pZCI6ImZpeHR1cmUifSwidGFyZ2V0Ijp7ImVudGl0eV9hc3NldF9pZCI6InNoYTI1NjoyMDA1MjM0MTRkZTI4MWE4YTE1NTA4NmE1NGNkMGY4ODBmNjQwNDg1NzY1YTQ4Y2NkZDhlYzIzMGY2N2RkMTdjIiwiZW50aXR5X2lkIjoiZW50aXR5L2ZhY2UvMCIsImV4cGVjdGVkX3NvdXJjZSI6eyJncmFwaF9pZCI6ImZpeHR1cmVfZ3JhcGgiLCJraW5kIjoibW9kZWxfdG9wb2xvZ3kiLCJub2RlX2lkIjoiZml4dHVyZV9ib2R5Iiwib3V0cHV0X3Nsb3QiOjAsInRvcG9faWQiOiJ0b3BvLWZhY2UiLCJ0b3BvbG9neV9raW5kIjoiRkFDRSJ9LCJmbGlwIjp0cnVlLCJraW5kIjoidG9wb2xvZ3lfZW50aXR5In19", + "valid": true + }, + { + "expected": null, + "name": "valid_binding_model_topology_edge", + "payload_base64": "eyJiaW5kaW5nX2lkIjoiZml4dHVyZSIsImNvbm5lY3Rvcl9pZCI6ImZpeHR1cmVfY29ubmVjdG9yIiwibmFtZSI6bnVsbCwib3duZXJfZGVmaW5pdGlvbl9pZCI6ImRlZmluaXRpb24vcm9vdC9wYXJ0L2ZpeHR1cmUiLCJzY2hlbWFfdmVyc2lvbiI6IjEuMCIsInNlbGVjdGVkX29jY3VycmVuY2Vfbm9kZV9pZCI6Imluc3RhbmNlL3Jvb3QiLCJzb3VyY2VfbW9kZWwiOnsiYXJ0aWZhY3RfaGFzaCI6InNoYTI1NjoxMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExIiwiZ3JhcGhfaWQiOiJmaXh0dXJlX2dyYXBoIiwibW9kZWxfc2NoZW1hX3ZlcnNpb24iOiIyLjAifSwic291cmNlX3NjZW5lIjp7InJldmlzaW9uIjoic2hhMjU2OmZhZTE1ZjRhZWVmMTI3N2NkODMxZmY4YmY4M2Y5YjI0NGQ1YzQyMjVlNzJlYjdiYzJlMGUzNWRkNmNmNDM2MTQiLCJzY2VuZV9pZCI6ImZpeHR1cmUifSwidGFyZ2V0Ijp7ImVudGl0eV9hc3NldF9pZCI6InNoYTI1NjoyMDA1MjM0MTRkZTI4MWE4YTE1NTA4NmE1NGNkMGY4ODBmNjQwNDg1NzY1YTQ4Y2NkZDhlYzIzMGY2N2RkMTdjIiwiZW50aXR5X2lkIjoiZW50aXR5L2VkZ2UvMCIsImV4cGVjdGVkX3NvdXJjZSI6eyJncmFwaF9pZCI6ImZpeHR1cmVfZ3JhcGgiLCJraW5kIjoibW9kZWxfdG9wb2xvZ3kiLCJub2RlX2lkIjoiZml4dHVyZV9ib2R5Iiwib3V0cHV0X3Nsb3QiOjAsInRvcG9faWQiOiJ0b3BvLWVkZ2UiLCJ0b3BvbG9neV9raW5kIjoiRURHRSJ9LCJmbGlwIjp0cnVlLCJraW5kIjoidG9wb2xvZ3lfZW50aXR5In19", + "valid": true + }, + { + "expected": null, + "name": "valid_binding_model_topology_vertex", + "payload_base64": "eyJiaW5kaW5nX2lkIjoiZml4dHVyZSIsImNvbm5lY3Rvcl9pZCI6ImZpeHR1cmVfY29ubmVjdG9yIiwibmFtZSI6bnVsbCwib3duZXJfZGVmaW5pdGlvbl9pZCI6ImRlZmluaXRpb24vcm9vdC9wYXJ0L2ZpeHR1cmUiLCJzY2hlbWFfdmVyc2lvbiI6IjEuMCIsInNlbGVjdGVkX29jY3VycmVuY2Vfbm9kZV9pZCI6Imluc3RhbmNlL3Jvb3QiLCJzb3VyY2VfbW9kZWwiOnsiYXJ0aWZhY3RfaGFzaCI6InNoYTI1NjoxMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExIiwiZ3JhcGhfaWQiOiJmaXh0dXJlX2dyYXBoIiwibW9kZWxfc2NoZW1hX3ZlcnNpb24iOiIyLjAifSwic291cmNlX3NjZW5lIjp7InJldmlzaW9uIjoic2hhMjU2OmZhZTE1ZjRhZWVmMTI3N2NkODMxZmY4YmY4M2Y5YjI0NGQ1YzQyMjVlNzJlYjdiYzJlMGUzNWRkNmNmNDM2MTQiLCJzY2VuZV9pZCI6ImZpeHR1cmUifSwidGFyZ2V0Ijp7ImVudGl0eV9hc3NldF9pZCI6InNoYTI1NjoyMDA1MjM0MTRkZTI4MWE4YTE1NTA4NmE1NGNkMGY4ODBmNjQwNDg1NzY1YTQ4Y2NkZDhlYzIzMGY2N2RkMTdjIiwiZW50aXR5X2lkIjoiZW50aXR5L3ZlcnRleC8wIiwiZXhwZWN0ZWRfc291cmNlIjp7ImdyYXBoX2lkIjoiZml4dHVyZV9ncmFwaCIsImtpbmQiOiJtb2RlbF90b3BvbG9neSIsIm5vZGVfaWQiOiJmaXh0dXJlX2JvZHkiLCJvdXRwdXRfc2xvdCI6MCwidG9wb19pZCI6InRvcG8tdmVydGV4IiwidG9wb2xvZ3lfa2luZCI6IlZFUlRFWCJ9LCJmbGlwIjpmYWxzZSwia2luZCI6InRvcG9sb2d5X2VudGl0eSJ9fQ==", + "valid": true + }, + { + "expected": null, + "name": "valid_binding_named", + "payload_base64": "eyJiaW5kaW5nX2lkIjoiZml4dHVyZSIsImNvbm5lY3Rvcl9pZCI6ImZpeHR1cmVfY29ubmVjdG9yIiwibmFtZSI6IkZpeHR1cmUgY29ubmVjdG9yIiwib3duZXJfZGVmaW5pdGlvbl9pZCI6ImRlZmluaXRpb24vcm9vdC9wYXJ0L2ZpeHR1cmUiLCJzY2hlbWFfdmVyc2lvbiI6IjEuMCIsInNlbGVjdGVkX29jY3VycmVuY2Vfbm9kZV9pZCI6Imluc3RhbmNlL3Jvb3QiLCJzb3VyY2VfbW9kZWwiOnsiYXJ0aWZhY3RfaGFzaCI6InNoYTI1NjoxMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExIiwiZ3JhcGhfaWQiOiJmaXh0dXJlX2dyYXBoIiwibW9kZWxfc2NoZW1hX3ZlcnNpb24iOiIyLjAifSwic291cmNlX3NjZW5lIjp7InJldmlzaW9uIjoic2hhMjU2OmZhZTE1ZjRhZWVmMTI3N2NkODMxZmY4YmY4M2Y5YjI0NGQ1YzQyMjVlNzJlYjdiYzJlMGUzNWRkNmNmNDM2MTQiLCJzY2VuZV9pZCI6ImZpeHR1cmUifSwidGFyZ2V0Ijp7ImVudGl0eV9hc3NldF9pZCI6InNoYTI1NjoyMDA1MjM0MTRkZTI4MWE4YTE1NTA4NmE1NGNkMGY4ODBmNjQwNDg1NzY1YTQ4Y2NkZDhlYzIzMGY2N2RkMTdjIiwiZW50aXR5X2lkIjoiZW50aXR5L2ZhY2UvMCIsImV4cGVjdGVkX3NvdXJjZSI6eyJncmFwaF9pZCI6ImZpeHR1cmVfZ3JhcGgiLCJraW5kIjoibW9kZWxfb3V0cHV0Iiwibm9kZV9pZCI6ImZpeHR1cmVfYm9keSIsIm91dHB1dF9zbG90IjowfSwiZmxpcCI6dHJ1ZSwia2luZCI6InRvcG9sb2d5X2VudGl0eSJ9fQ==", + "valid": true + }, + { + "expected": { + "code": "schema_invalid", + "path": "" + }, + "name": "missing_binding_name", + "payload_base64": "eyJiaW5kaW5nX2lkIjoiZml4dHVyZSIsImNvbm5lY3Rvcl9pZCI6ImZpeHR1cmVfY29ubmVjdG9yIiwib3duZXJfZGVmaW5pdGlvbl9pZCI6ImRlZmluaXRpb24vcm9vdC9wYXJ0L2ZpeHR1cmUiLCJzY2hlbWFfdmVyc2lvbiI6IjEuMCIsInNlbGVjdGVkX29jY3VycmVuY2Vfbm9kZV9pZCI6Imluc3RhbmNlL3Jvb3QiLCJzb3VyY2VfbW9kZWwiOnsiYXJ0aWZhY3RfaGFzaCI6InNoYTI1NjoxMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExIiwiZ3JhcGhfaWQiOiJmaXh0dXJlX2dyYXBoIiwibW9kZWxfc2NoZW1hX3ZlcnNpb24iOiIyLjAifSwic291cmNlX3NjZW5lIjp7InJldmlzaW9uIjoic2hhMjU2OmZhZTE1ZjRhZWVmMTI3N2NkODMxZmY4YmY4M2Y5YjI0NGQ1YzQyMjVlNzJlYjdiYzJlMGUzNWRkNmNmNDM2MTQiLCJzY2VuZV9pZCI6ImZpeHR1cmUifSwidGFyZ2V0Ijp7ImVudGl0eV9hc3NldF9pZCI6InNoYTI1NjoyMDA1MjM0MTRkZTI4MWE4YTE1NTA4NmE1NGNkMGY4ODBmNjQwNDg1NzY1YTQ4Y2NkZDhlYzIzMGY2N2RkMTdjIiwiZW50aXR5X2lkIjoiZW50aXR5L2ZhY2UvMCIsImV4cGVjdGVkX3NvdXJjZSI6eyJncmFwaF9pZCI6ImZpeHR1cmVfZ3JhcGgiLCJraW5kIjoibW9kZWxfb3V0cHV0Iiwibm9kZV9pZCI6ImZpeHR1cmVfYm9keSIsIm91dHB1dF9zbG90IjowfSwiZmxpcCI6dHJ1ZSwia2luZCI6InRvcG9sb2d5X2VudGl0eSJ9fQ==", + "valid": false + }, + { + "expected": { + "code": "schema_invalid", + "path": "/source_model/model_schema_version" + }, + "name": "unsupported_binding_model_schema_version", + "payload_base64": "eyJiaW5kaW5nX2lkIjoiZml4dHVyZSIsImNvbm5lY3Rvcl9pZCI6ImZpeHR1cmVfY29ubmVjdG9yIiwibmFtZSI6bnVsbCwib3duZXJfZGVmaW5pdGlvbl9pZCI6ImRlZmluaXRpb24vcm9vdC9wYXJ0L2ZpeHR1cmUiLCJzY2hlbWFfdmVyc2lvbiI6IjEuMCIsInNlbGVjdGVkX29jY3VycmVuY2Vfbm9kZV9pZCI6Imluc3RhbmNlL3Jvb3QiLCJzb3VyY2VfbW9kZWwiOnsiYXJ0aWZhY3RfaGFzaCI6InNoYTI1NjoxMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExIiwiZ3JhcGhfaWQiOiJmaXh0dXJlX2dyYXBoIiwibW9kZWxfc2NoZW1hX3ZlcnNpb24iOiIzLjAifSwic291cmNlX3NjZW5lIjp7InJldmlzaW9uIjoic2hhMjU2OmZhZTE1ZjRhZWVmMTI3N2NkODMxZmY4YmY4M2Y5YjI0NGQ1YzQyMjVlNzJlYjdiYzJlMGUzNWRkNmNmNDM2MTQiLCJzY2VuZV9pZCI6ImZpeHR1cmUifSwidGFyZ2V0Ijp7ImVudGl0eV9hc3NldF9pZCI6InNoYTI1NjoyMDA1MjM0MTRkZTI4MWE4YTE1NTA4NmE1NGNkMGY4ODBmNjQwNDg1NzY1YTQ4Y2NkZDhlYzIzMGY2N2RkMTdjIiwiZW50aXR5X2lkIjoiZW50aXR5L2ZhY2UvMCIsImV4cGVjdGVkX3NvdXJjZSI6eyJncmFwaF9pZCI6ImZpeHR1cmVfZ3JhcGgiLCJraW5kIjoibW9kZWxfb3V0cHV0Iiwibm9kZV9pZCI6ImZpeHR1cmVfYm9keSIsIm91dHB1dF9zbG90IjowfSwiZmxpcCI6dHJ1ZSwia2luZCI6InRvcG9sb2d5X2VudGl0eSJ9fQ==", + "valid": false + } + ], + "entity_cases": [ + { + "expected": null, + "name": "valid_entities", + "payload_base64": "eyJkZWZpbml0aW9uX2lkIjoiZGVmaW5pdGlvbi9yb290L3NoYXBlL21hbnVhbC9maXh0dXJlIiwiZWRnZV9hc3NldF9pZCI6InNoYTI1Njo2Y2U0ODFkYjFmZTEzYmNmZTdhMjM3NDQyM2U2YzcyZDc1N2Q1MWM5OTE2OGIyODdjNTJmNTNhYTYyNWNjZDY3IiwiZWRnZV9ncm91cHMiOlt7ImVudGl0eV9pZCI6ImVudGl0eS9lZGdlLzAiLCJmaXJzdF9pbmRleCI6MCwiZ3JvdXBfaWQiOjAsImluZGV4X2NvdW50IjoyLCJtZXNoX2luZGV4IjowLCJwcmltaXRpdmVfaW5kZXgiOjB9LHsiZW50aXR5X2lkIjoiZW50aXR5L2VkZ2UvMSIsImZpcnN0X2luZGV4IjoyLCJncm91cF9pZCI6MSwiaW5kZXhfY291bnQiOjIsIm1lc2hfaW5kZXgiOjAsInByaW1pdGl2ZV9pbmRleCI6MH0seyJlbnRpdHlfaWQiOiJlbnRpdHkvZWRnZS8yIiwiZmlyc3RfaW5kZXgiOjQsImdyb3VwX2lkIjoyLCJpbmRleF9jb3VudCI6MiwibWVzaF9pbmRleCI6MCwicHJpbWl0aXZlX2luZGV4IjowfSx7ImVudGl0eV9pZCI6ImVudGl0eS9lZGdlLzMiLCJmaXJzdF9pbmRleCI6NiwiZ3JvdXBfaWQiOjMsImluZGV4X2NvdW50IjoyLCJtZXNoX2luZGV4IjowLCJwcmltaXRpdmVfaW5kZXgiOjB9LHsiZW50aXR5X2lkIjoiZW50aXR5L2VkZ2UvNCIsImZpcnN0X2luZGV4Ijo4LCJncm91cF9pZCI6NCwiaW5kZXhfY291bnQiOjIsIm1lc2hfaW5kZXgiOjAsInByaW1pdGl2ZV9pbmRleCI6MH0seyJlbnRpdHlfaWQiOiJlbnRpdHkvZWRnZS81IiwiZmlyc3RfaW5kZXgiOjEwLCJncm91cF9pZCI6NSwiaW5kZXhfY291bnQiOjIsIm1lc2hfaW5kZXgiOjAsInByaW1pdGl2ZV9pbmRleCI6MH1dLCJlbnRpdGllcyI6W3siY2hpbGRfZW50aXR5X2lkcyI6WyJlbnRpdHkvdmVydGV4LzIiLCJlbnRpdHkvdmVydGV4LzMiXSwiY29ubmVjdG9yX2JpbmRpbmdfc3RhdHVzIjoib3duZXJfbm90X3BhcnQiLCJlbnRpdHlfaWQiOiJlbnRpdHkvZWRnZS8wIiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJkaXJlY3Rpb24iOlswLC0wLjcwNzEwNjc4MTE4NjU0NzUsLTAuNzA3MTA2NzgxMTg2NTQ3NV0sIm9yaWdpbiI6WzAsMCwxMDAwXSwidHlwZSI6ImxpbmUifSwia2luZCI6ImVkZ2UiLCJwYXJlbnRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZmFjZS8wIiwiZW50aXR5L2ZhY2UvMyJdLCJwcm9wZXJ0aWVzIjp7ImJvdW5kcyI6eyJtYXgiOlswLDAsMTAwMF0sIm1pbiI6WzAsLTEwMDAsMF19LCJjZW50cm9pZCI6WzAsLTUwMCw1MDBdLCJsZW5ndGgiOjE0MTQuMjEzNTYyMzczMDk1LCJxdWFsaXR5Ijoia2VybmVsX2V2YWx1YXRlZCJ9LCJyZW5kZXJfc3RhdHVzIjoicmVuZGVyZWQiLCJzZGtfY29ubmVjdG9yX2ZyYW1lIjp7Im9yaWdpbiI6WzAsLTUwMCw1MDBdLCJ4X2F4aXMiOlswLC0wLjcwNzEwNjc4MTE4NjU0NzUsLTAuNzA3MTA2NzgxMTg2NTQ3NV0sInlfYXhpcyI6WzAsLTAuNzA3MTA2NzgxMTg2NTQ3NCwwLjcwNzEwNjc4MTE4NjU0NzddLCJ6X2F4aXMiOlstMSwwLDBdfSwic2RrX21ldGFkYXRhIjp7fSwic2VtYW50aWNfYmluZGluZ19pZHMiOltdLCJzb3VyY2UiOnsia2luZCI6InVuYm91bmQifX0seyJjaGlsZF9lbnRpdHlfaWRzIjpbImVudGl0eS92ZXJ0ZXgvMSIsImVudGl0eS92ZXJ0ZXgvMyJdLCJjb25uZWN0b3JfYmluZGluZ19zdGF0dXMiOiJvd25lcl9ub3RfcGFydCIsImVudGl0eV9pZCI6ImVudGl0eS9lZGdlLzEiLCJldmFsdWF0ZWRfdGFncyI6W10sImdlb21ldHJ5Ijp7ImRpcmVjdGlvbiI6Wy0wLjcwNzEwNjc4MTE4NjU0NzUsLTAuNzA3MTA2NzgxMTg2NTQ3NSwwXSwib3JpZ2luIjpbMTAwMCwwLDBdLCJ0eXBlIjoibGluZSJ9LCJraW5kIjoiZWRnZSIsInBhcmVudF9lbnRpdHlfaWRzIjpbImVudGl0eS9mYWNlLzEiLCJlbnRpdHkvZmFjZS8zIl0sInByb3BlcnRpZXMiOnsiYm91bmRzIjp7Im1heCI6WzEwMDAsMCwwXSwibWluIjpbMCwtMTAwMCwwXX0sImNlbnRyb2lkIjpbNTAwLC01MDAsMF0sImxlbmd0aCI6MTQxNC4yMTM1NjIzNzMwOTUsInF1YWxpdHkiOiJrZXJuZWxfZXZhbHVhdGVkIn0sInJlbmRlcl9zdGF0dXMiOiJyZW5kZXJlZCIsInNka19jb25uZWN0b3JfZnJhbWUiOnsib3JpZ2luIjpbNTAwLC01MDAsMF0sInhfYXhpcyI6Wy0wLjcwNzEwNjc4MTE4NjU0NzUsLTAuNzA3MTA2NzgxMTg2NTQ3NSwwXSwieV9heGlzIjpbMCwwLDFdLCJ6X2F4aXMiOlstMC43MDcxMDY3ODExODY1NDc2LDAuNzA3MTA2NzgxMTg2NTQ3NiwwXX0sInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImtpbmQiOiJ1bmJvdW5kIn19LHsiY2hpbGRfZW50aXR5X2lkcyI6WyJlbnRpdHkvdmVydGV4LzAiLCJlbnRpdHkvdmVydGV4LzMiXSwiY29ubmVjdG9yX2JpbmRpbmdfc3RhdHVzIjoib3duZXJfbm90X3BhcnQiLCJlbnRpdHlfaWQiOiJlbnRpdHkvZWRnZS8yIiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJkaXJlY3Rpb24iOlswLC0xLDBdLCJvcmlnaW4iOlswLDAsMF0sInR5cGUiOiJsaW5lIn0sImtpbmQiOiJlZGdlIiwicGFyZW50X2VudGl0eV9pZHMiOlsiZW50aXR5L2ZhY2UvMCIsImVudGl0eS9mYWNlLzEiXSwicHJvcGVydGllcyI6eyJib3VuZHMiOnsibWF4IjpbMCwwLDBdLCJtaW4iOlswLC0xMDAwLDBdfSwiY2VudHJvaWQiOlswLC01MDAsMF0sImxlbmd0aCI6MTAwMCwicXVhbGl0eSI6Imtlcm5lbF9ldmFsdWF0ZWQifSwicmVuZGVyX3N0YXR1cyI6InJlbmRlcmVkIiwic2RrX2Nvbm5lY3Rvcl9mcmFtZSI6eyJvcmlnaW4iOlswLC01MDAsMF0sInhfYXhpcyI6WzAsLTEsMF0sInlfYXhpcyI6WzAsMCwxXSwiel9heGlzIjpbLTEsMCwwXX0sInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImtpbmQiOiJ1bmJvdW5kIn19LHsiY2hpbGRfZW50aXR5X2lkcyI6WyJlbnRpdHkvdmVydGV4LzAiLCJlbnRpdHkvdmVydGV4LzEiXSwiY29ubmVjdG9yX2JpbmRpbmdfc3RhdHVzIjoib3duZXJfbm90X3BhcnQiLCJlbnRpdHlfaWQiOiJlbnRpdHkvZWRnZS8zIiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJkaXJlY3Rpb24iOlsxLDAsMF0sIm9yaWdpbiI6WzAsMCwwXSwidHlwZSI6ImxpbmUifSwia2luZCI6ImVkZ2UiLCJwYXJlbnRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZmFjZS8xIiwiZW50aXR5L2ZhY2UvMiJdLCJwcm9wZXJ0aWVzIjp7ImJvdW5kcyI6eyJtYXgiOlsxMDAwLDAsMF0sIm1pbiI6WzAsMCwwXX0sImNlbnRyb2lkIjpbNTAwLDAsMF0sImxlbmd0aCI6MTAwMCwicXVhbGl0eSI6Imtlcm5lbF9ldmFsdWF0ZWQifSwicmVuZGVyX3N0YXR1cyI6InJlbmRlcmVkIiwic2RrX2Nvbm5lY3Rvcl9mcmFtZSI6eyJvcmlnaW4iOls1MDAsMCwwXSwieF9heGlzIjpbMSwwLDBdLCJ5X2F4aXMiOlswLDAsMV0sInpfYXhpcyI6WzAsLTEsMF19LCJzZGtfbWV0YWRhdGEiOnt9LCJzZW1hbnRpY19iaW5kaW5nX2lkcyI6W10sInNvdXJjZSI6eyJraW5kIjoidW5ib3VuZCJ9fSx7ImNoaWxkX2VudGl0eV9pZHMiOlsiZW50aXR5L3ZlcnRleC8xIiwiZW50aXR5L3ZlcnRleC8yIl0sImNvbm5lY3Rvcl9iaW5kaW5nX3N0YXR1cyI6Im93bmVyX25vdF9wYXJ0IiwiZW50aXR5X2lkIjoiZW50aXR5L2VkZ2UvNCIsImV2YWx1YXRlZF90YWdzIjpbXSwiZ2VvbWV0cnkiOnsiZGlyZWN0aW9uIjpbLTAuNzA3MTA2NzgxMTg2NTQ3NSwwLDAuNzA3MTA2NzgxMTg2NTQ3NV0sIm9yaWdpbiI6WzEwMDAsMCwwXSwidHlwZSI6ImxpbmUifSwia2luZCI6ImVkZ2UiLCJwYXJlbnRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZmFjZS8yIiwiZW50aXR5L2ZhY2UvMyJdLCJwcm9wZXJ0aWVzIjp7ImJvdW5kcyI6eyJtYXgiOlsxMDAwLDAsMTAwMF0sIm1pbiI6WzAsMCwwXX0sImNlbnRyb2lkIjpbNTAwLDAsNTAwXSwibGVuZ3RoIjoxNDE0LjIxMzU2MjM3MzA5NSwicXVhbGl0eSI6Imtlcm5lbF9ldmFsdWF0ZWQifSwicmVuZGVyX3N0YXR1cyI6InJlbmRlcmVkIiwic2RrX2Nvbm5lY3Rvcl9mcmFtZSI6eyJvcmlnaW4iOls1MDAsMCw1MDBdLCJ4X2F4aXMiOlstMC43MDcxMDY3ODExODY1NDc1LDAsMC43MDcxMDY3ODExODY1NDc1XSwieV9heGlzIjpbMC43MDcxMDY3ODExODY1NDc0LDAsMC43MDcxMDY3ODExODY1NDc3XSwiel9heGlzIjpbMCwxLDBdfSwic2RrX21ldGFkYXRhIjp7fSwic2VtYW50aWNfYmluZGluZ19pZHMiOltdLCJzb3VyY2UiOnsia2luZCI6InVuYm91bmQifX0seyJjaGlsZF9lbnRpdHlfaWRzIjpbImVudGl0eS92ZXJ0ZXgvMCIsImVudGl0eS92ZXJ0ZXgvMiJdLCJjb25uZWN0b3JfYmluZGluZ19zdGF0dXMiOiJvd25lcl9ub3RfcGFydCIsImVudGl0eV9pZCI6ImVudGl0eS9lZGdlLzUiLCJldmFsdWF0ZWRfdGFncyI6W10sImdlb21ldHJ5Ijp7ImRpcmVjdGlvbiI6WzAsMCwxXSwib3JpZ2luIjpbMCwwLDBdLCJ0eXBlIjoibGluZSJ9LCJraW5kIjoiZWRnZSIsInBhcmVudF9lbnRpdHlfaWRzIjpbImVudGl0eS9mYWNlLzAiLCJlbnRpdHkvZmFjZS8yIl0sInByb3BlcnRpZXMiOnsiYm91bmRzIjp7Im1heCI6WzAsMCwxMDAwXSwibWluIjpbMCwwLDBdfSwiY2VudHJvaWQiOlswLDAsNTAwXSwibGVuZ3RoIjoxMDAwLCJxdWFsaXR5Ijoia2VybmVsX2V2YWx1YXRlZCJ9LCJyZW5kZXJfc3RhdHVzIjoicmVuZGVyZWQiLCJzZGtfY29ubmVjdG9yX2ZyYW1lIjp7Im9yaWdpbiI6WzAsMCw1MDBdLCJ4X2F4aXMiOlswLDAsMV0sInlfYXhpcyI6WzAsMSwwXSwiel9heGlzIjpbLTEsMCwwXX0sInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImtpbmQiOiJ1bmJvdW5kIn19LHsiY2hpbGRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZWRnZS8wIiwiZW50aXR5L2VkZ2UvMiIsImVudGl0eS9lZGdlLzUiXSwiY29ubmVjdG9yX2JpbmRpbmdfc3RhdHVzIjoib3duZXJfbm90X3BhcnQiLCJlbnRpdHlfaWQiOiJlbnRpdHkvZmFjZS8wIiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJub3JtYWwiOlstMSwwLDBdLCJvcmlnaW4iOlswLC0zMzMuMzMzMzMzMzMzMzMzMywzMzMuMzMzMzMzMzMzMzMzM10sInR5cGUiOiJwbGFuZSIsInhfZGlyZWN0aW9uIjpbMCwwLDFdfSwia2luZCI6ImZhY2UiLCJwYXJlbnRfZW50aXR5X2lkcyI6WyJlbnRpdHkvc29saWQvMCJdLCJwcm9wZXJ0aWVzIjp7ImFyZWEiOjUwMDAwMCwiYm91bmRzIjp7Im1heCI6WzAsMCwxMDAwXSwibWluIjpbMCwtMTAwMCwwXX0sImNlbnRyb2lkIjpbMCwtMzMzLjMzMzMzMzMzMzMzMzMsMzMzLjMzMzMzMzMzMzMzMzNdLCJvcmllbnRhdGlvbiI6ImZvcndhcmQiLCJxdWFsaXR5Ijoia2VybmVsX2V2YWx1YXRlZCJ9LCJyZW5kZXJfc3RhdHVzIjoicmVuZGVyZWQiLCJzZGtfY29ubmVjdG9yX2ZyYW1lIjp7Im9yaWdpbiI6WzAsLTMzMy4zMzMzMzMzMzMzMzMzLDMzMy4zMzMzMzMzMzMzMzMzXSwieF9heGlzIjpbMCwwLDFdLCJ5X2F4aXMiOlswLDEsMF0sInpfYXhpcyI6Wy0xLDAsMF19LCJzZGtfbWV0YWRhdGEiOnt9LCJzZW1hbnRpY19iaW5kaW5nX2lkcyI6W10sInNvdXJjZSI6eyJraW5kIjoidW5ib3VuZCJ9fSx7ImNoaWxkX2VudGl0eV9pZHMiOlsiZW50aXR5L2VkZ2UvMSIsImVudGl0eS9lZGdlLzIiLCJlbnRpdHkvZWRnZS8zIl0sImNvbm5lY3Rvcl9iaW5kaW5nX3N0YXR1cyI6Im93bmVyX25vdF9wYXJ0IiwiZW50aXR5X2lkIjoiZW50aXR5L2ZhY2UvMSIsImV2YWx1YXRlZF90YWdzIjpbXSwiZ2VvbWV0cnkiOnsibm9ybWFsIjpbMCwwLC0xXSwib3JpZ2luIjpbMzMzLjMzMzMzMzMzMzMzMzMsLTMzMy4zMzMzMzMzMzMzMzMzLDBdLCJ0eXBlIjoicGxhbmUiLCJ4X2RpcmVjdGlvbiI6WzEsMCwwXX0sImtpbmQiOiJmYWNlIiwicGFyZW50X2VudGl0eV9pZHMiOlsiZW50aXR5L3NvbGlkLzAiXSwicHJvcGVydGllcyI6eyJhcmVhIjo1MDAwMDAsImJvdW5kcyI6eyJtYXgiOlsxMDAwLDAsMF0sIm1pbiI6WzAsLTEwMDAsMF19LCJjZW50cm9pZCI6WzMzMy4zMzMzMzMzMzMzMzMzLC0zMzMuMzMzMzMzMzMzMzMzMywwXSwib3JpZW50YXRpb24iOiJmb3J3YXJkIiwicXVhbGl0eSI6Imtlcm5lbF9ldmFsdWF0ZWQifSwicmVuZGVyX3N0YXR1cyI6InJlbmRlcmVkIiwic2RrX2Nvbm5lY3Rvcl9mcmFtZSI6eyJvcmlnaW4iOlszMzMuMzMzMzMzMzMzMzMzMywtMzMzLjMzMzMzMzMzMzMzMzMsMF0sInhfYXhpcyI6WzEsMCwwXSwieV9heGlzIjpbMCwtMSwwXSwiel9heGlzIjpbMCwwLC0xXX0sInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImtpbmQiOiJ1bmJvdW5kIn19LHsiY2hpbGRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZWRnZS8zIiwiZW50aXR5L2VkZ2UvNCIsImVudGl0eS9lZGdlLzUiXSwiY29ubmVjdG9yX2JpbmRpbmdfc3RhdHVzIjoib3duZXJfbm90X3BhcnQiLCJlbnRpdHlfaWQiOiJlbnRpdHkvZmFjZS8yIiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJub3JtYWwiOlswLDEsMF0sIm9yaWdpbiI6WzMzMy4zMzMzMzMzMzMzMzMzLDAsMzMzLjMzMzMzMzMzMzMzMzNdLCJ0eXBlIjoicGxhbmUiLCJ4X2RpcmVjdGlvbiI6WzEsMCwwXX0sImtpbmQiOiJmYWNlIiwicGFyZW50X2VudGl0eV9pZHMiOlsiZW50aXR5L3NvbGlkLzAiXSwicHJvcGVydGllcyI6eyJhcmVhIjo1MDAwMDAsImJvdW5kcyI6eyJtYXgiOlsxMDAwLDAsMTAwMF0sIm1pbiI6WzAsMCwwXX0sImNlbnRyb2lkIjpbMzMzLjMzMzMzMzMzMzMzMzMsMCwzMzMuMzMzMzMzMzMzMzMzM10sIm9yaWVudGF0aW9uIjoiZm9yd2FyZCIsInF1YWxpdHkiOiJrZXJuZWxfZXZhbHVhdGVkIn0sInJlbmRlcl9zdGF0dXMiOiJyZW5kZXJlZCIsInNka19jb25uZWN0b3JfZnJhbWUiOnsib3JpZ2luIjpbMzMzLjMzMzMzMzMzMzMzMzMsMCwzMzMuMzMzMzMzMzMzMzMzM10sInhfYXhpcyI6WzEsMCwwXSwieV9heGlzIjpbMCwwLC0xXSwiel9heGlzIjpbMCwxLDBdfSwic2RrX21ldGFkYXRhIjp7fSwic2VtYW50aWNfYmluZGluZ19pZHMiOltdLCJzb3VyY2UiOnsia2luZCI6InVuYm91bmQifX0seyJjaGlsZF9lbnRpdHlfaWRzIjpbImVudGl0eS9lZGdlLzAiLCJlbnRpdHkvZWRnZS8xIiwiZW50aXR5L2VkZ2UvNCJdLCJjb25uZWN0b3JfYmluZGluZ19zdGF0dXMiOiJvd25lcl9ub3RfcGFydCIsImVudGl0eV9pZCI6ImVudGl0eS9mYWNlLzMiLCJldmFsdWF0ZWRfdGFncyI6W10sImdlb21ldHJ5Ijp7Im5vcm1hbCI6WzAuNTc3MzUwMjY5MTg5NjI1OCwtMC41NzczNTAyNjkxODk2MjU4LDAuNTc3MzUwMjY5MTg5NjI1OF0sIm9yaWdpbiI6WzMzMy4zMzMzMzMzMzMzMzMzLC0zMzMuMzMzMzMzMzMzMzMzMywzMzMuMzMzMzMzMzMzMzMzM10sInR5cGUiOiJwbGFuZSIsInhfZGlyZWN0aW9uIjpbLTAuNzA3MTA2NzgxMTg2NTQ3NSwwLDAuNzA3MTA2NzgxMTg2NTQ3NV19LCJraW5kIjoiZmFjZSIsInBhcmVudF9lbnRpdHlfaWRzIjpbImVudGl0eS9zb2xpZC8wIl0sInByb3BlcnRpZXMiOnsiYXJlYSI6ODY2MDI1LjQwMzc4NDQzODYsImJvdW5kcyI6eyJtYXgiOlsxMDAwLDAsMTAwMF0sIm1pbiI6WzAsLTEwMDAsMF19LCJjZW50cm9pZCI6WzMzMy4zMzMzMzMzMzMzMzMzLC0zMzMuMzMzMzMzMzMzMzMzMywzMzMuMzMzMzMzMzMzMzMzM10sIm9yaWVudGF0aW9uIjoiZm9yd2FyZCIsInF1YWxpdHkiOiJrZXJuZWxfZXZhbHVhdGVkIn0sInJlbmRlcl9zdGF0dXMiOiJyZW5kZXJlZCIsInNka19jb25uZWN0b3JfZnJhbWUiOnsib3JpZ2luIjpbMzMzLjMzMzMzMzMzMzMzMzMsLTMzMy4zMzMzMzMzMzMzMzMzLDMzMy4zMzMzMzMzMzMzMzMzXSwieF9heGlzIjpbLTAuNzA3MTA2NzgxMTg2NTQ3NSwwLDAuNzA3MTA2NzgxMTg2NTQ3NV0sInlfYXhpcyI6Wy0wLjQwODI0ODI5MDQ2Mzg2MzEsLTAuODE2NDk2NTgwOTI3NzI2MSwtMC40MDgyNDgyOTA0NjM4NjMxXSwiel9heGlzIjpbMC41NzczNTAyNjkxODk2MjU4LC0wLjU3NzM1MDI2OTE4OTYyNTgsMC41NzczNTAyNjkxODk2MjU4XX0sInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImtpbmQiOiJ1bmJvdW5kIn19LHsiY2hpbGRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZmFjZS8wIiwiZW50aXR5L2ZhY2UvMSIsImVudGl0eS9mYWNlLzIiLCJlbnRpdHkvZmFjZS8zIl0sImNvbm5lY3Rvcl9iaW5kaW5nX3N0YXR1cyI6Im5vdF9hcHBsaWNhYmxlIiwiZW50aXR5X2lkIjoiZW50aXR5L3NvbGlkLzAiLCJldmFsdWF0ZWRfdGFncyI6W10sImdlb21ldHJ5Ijp7InR5cGUiOiJicmVwX3NvbGlkIn0sImtpbmQiOiJzb2xpZCIsInBhcmVudF9lbnRpdHlfaWRzIjpbXSwicHJvcGVydGllcyI6eyJib3VuZHMiOnsibWF4IjpbMTAwMCwwLDEwMDBdLCJtaW4iOlswLC0xMDAwLDBdfSwiY2VudHJvaWQiOlsyNTAsLTI1MCwyNTBdLCJxdWFsaXR5Ijoia2VybmVsX2V2YWx1YXRlZCIsInN1cmZhY2VfYXJlYSI6MjM2NjAyNS40MDM3ODQ0Mzg1LCJ2b2x1bWUiOjE2NjY2NjY2Ni42NjY2NjY2Nn0sInJlbmRlcl9zdGF0dXMiOiJyZW5kZXJlZCIsInNka19jb25uZWN0b3JfZnJhbWUiOm51bGwsInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImtpbmQiOiJ1bmJvdW5kIn19LHsiY2hpbGRfZW50aXR5X2lkcyI6W10sImNvbm5lY3Rvcl9iaW5kaW5nX3N0YXR1cyI6Im93bmVyX25vdF9wYXJ0IiwiZW50aXR5X2lkIjoiZW50aXR5L3ZlcnRleC8wIiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJwb3NpdGlvbiI6WzAsMCwwXSwidHlwZSI6InBvaW50In0sImtpbmQiOiJ2ZXJ0ZXgiLCJwYXJlbnRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZWRnZS8yIiwiZW50aXR5L2VkZ2UvMyIsImVudGl0eS9lZGdlLzUiXSwicHJvcGVydGllcyI6eyJib3VuZHMiOnsibWF4IjpbMCwwLDBdLCJtaW4iOlswLDAsMF19LCJwb3NpdGlvbiI6WzAsMCwwXSwicXVhbGl0eSI6Imtlcm5lbF9ldmFsdWF0ZWQifSwicmVuZGVyX3N0YXR1cyI6InJlbmRlcmVkIiwic2RrX2Nvbm5lY3Rvcl9mcmFtZSI6eyJvcmlnaW4iOlswLDAsMF0sInhfYXhpcyI6WzEsMCwwXSwieV9heGlzIjpbMCwxLDBdLCJ6X2F4aXMiOlswLDAsMV19LCJzZGtfbWV0YWRhdGEiOnt9LCJzZW1hbnRpY19iaW5kaW5nX2lkcyI6W10sInNvdXJjZSI6eyJraW5kIjoidW5ib3VuZCJ9fSx7ImNoaWxkX2VudGl0eV9pZHMiOltdLCJjb25uZWN0b3JfYmluZGluZ19zdGF0dXMiOiJvd25lcl9ub3RfcGFydCIsImVudGl0eV9pZCI6ImVudGl0eS92ZXJ0ZXgvMSIsImV2YWx1YXRlZF90YWdzIjpbXSwiZ2VvbWV0cnkiOnsicG9zaXRpb24iOlsxMDAwLDAsMF0sInR5cGUiOiJwb2ludCJ9LCJraW5kIjoidmVydGV4IiwicGFyZW50X2VudGl0eV9pZHMiOlsiZW50aXR5L2VkZ2UvMSIsImVudGl0eS9lZGdlLzMiLCJlbnRpdHkvZWRnZS80Il0sInByb3BlcnRpZXMiOnsiYm91bmRzIjp7Im1heCI6WzEwMDAsMCwwXSwibWluIjpbMTAwMCwwLDBdfSwicG9zaXRpb24iOlsxMDAwLDAsMF0sInF1YWxpdHkiOiJrZXJuZWxfZXZhbHVhdGVkIn0sInJlbmRlcl9zdGF0dXMiOiJyZW5kZXJlZCIsInNka19jb25uZWN0b3JfZnJhbWUiOnsib3JpZ2luIjpbMTAwMCwwLDBdLCJ4X2F4aXMiOlsxLDAsMF0sInlfYXhpcyI6WzAsMSwwXSwiel9heGlzIjpbMCwwLDFdfSwic2RrX21ldGFkYXRhIjp7fSwic2VtYW50aWNfYmluZGluZ19pZHMiOltdLCJzb3VyY2UiOnsia2luZCI6InVuYm91bmQifX0seyJjaGlsZF9lbnRpdHlfaWRzIjpbXSwiY29ubmVjdG9yX2JpbmRpbmdfc3RhdHVzIjoib3duZXJfbm90X3BhcnQiLCJlbnRpdHlfaWQiOiJlbnRpdHkvdmVydGV4LzIiLCJldmFsdWF0ZWRfdGFncyI6W10sImdlb21ldHJ5Ijp7InBvc2l0aW9uIjpbMCwwLDEwMDBdLCJ0eXBlIjoicG9pbnQifSwia2luZCI6InZlcnRleCIsInBhcmVudF9lbnRpdHlfaWRzIjpbImVudGl0eS9lZGdlLzAiLCJlbnRpdHkvZWRnZS80IiwiZW50aXR5L2VkZ2UvNSJdLCJwcm9wZXJ0aWVzIjp7ImJvdW5kcyI6eyJtYXgiOlswLDAsMTAwMF0sIm1pbiI6WzAsMCwxMDAwXX0sInBvc2l0aW9uIjpbMCwwLDEwMDBdLCJxdWFsaXR5Ijoia2VybmVsX2V2YWx1YXRlZCJ9LCJyZW5kZXJfc3RhdHVzIjoicmVuZGVyZWQiLCJzZGtfY29ubmVjdG9yX2ZyYW1lIjp7Im9yaWdpbiI6WzAsMCwxMDAwXSwieF9heGlzIjpbMSwwLDBdLCJ5X2F4aXMiOlswLDEsMF0sInpfYXhpcyI6WzAsMCwxXX0sInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImtpbmQiOiJ1bmJvdW5kIn19LHsiY2hpbGRfZW50aXR5X2lkcyI6W10sImNvbm5lY3Rvcl9iaW5kaW5nX3N0YXR1cyI6Im93bmVyX25vdF9wYXJ0IiwiZW50aXR5X2lkIjoiZW50aXR5L3ZlcnRleC8zIiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJwb3NpdGlvbiI6WzAsLTEwMDAsMF0sInR5cGUiOiJwb2ludCJ9LCJraW5kIjoidmVydGV4IiwicGFyZW50X2VudGl0eV9pZHMiOlsiZW50aXR5L2VkZ2UvMCIsImVudGl0eS9lZGdlLzEiLCJlbnRpdHkvZWRnZS8yIl0sInByb3BlcnRpZXMiOnsiYm91bmRzIjp7Im1heCI6WzAsLTEwMDAsMF0sIm1pbiI6WzAsLTEwMDAsMF19LCJwb3NpdGlvbiI6WzAsLTEwMDAsMF0sInF1YWxpdHkiOiJrZXJuZWxfZXZhbHVhdGVkIn0sInJlbmRlcl9zdGF0dXMiOiJyZW5kZXJlZCIsInNka19jb25uZWN0b3JfZnJhbWUiOnsib3JpZ2luIjpbMCwtMTAwMCwwXSwieF9heGlzIjpbMSwwLDBdLCJ5X2F4aXMiOlswLDEsMF0sInpfYXhpcyI6WzAsMCwxXX0sInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImtpbmQiOiJ1bmJvdW5kIn19XSwiZmFjZV9ncm91cHMiOlt7ImVudGl0eV9pZCI6ImVudGl0eS9mYWNlLzAiLCJmaXJzdF9pbmRleCI6MCwiZ3JvdXBfaWQiOjAsImluZGV4X2NvdW50IjozLCJtZXNoX2luZGV4IjowLCJwcmltaXRpdmVfaW5kZXgiOjB9LHsiZW50aXR5X2lkIjoiZW50aXR5L2ZhY2UvMSIsImZpcnN0X2luZGV4IjozLCJncm91cF9pZCI6MSwiaW5kZXhfY291bnQiOjMsIm1lc2hfaW5kZXgiOjAsInByaW1pdGl2ZV9pbmRleCI6MH0seyJlbnRpdHlfaWQiOiJlbnRpdHkvZmFjZS8yIiwiZmlyc3RfaW5kZXgiOjYsImdyb3VwX2lkIjoyLCJpbmRleF9jb3VudCI6MywibWVzaF9pbmRleCI6MCwicHJpbWl0aXZlX2luZGV4IjowfSx7ImVudGl0eV9pZCI6ImVudGl0eS9mYWNlLzMiLCJmaXJzdF9pbmRleCI6OSwiZ3JvdXBfaWQiOjMsImluZGV4X2NvdW50IjozLCJtZXNoX2luZGV4IjowLCJwcmltaXRpdmVfaW5kZXgiOjB9XSwiZ2VvbWV0cnlfYXNzZXRfaWQiOiJzaGEyNTY6MjAwNTIzNDE0ZGUyODFhOGExNTUwODZhNTRjZDBmODgwZjY0MDQ4NTc2NWE0OGNjZGQ4ZWMyMzBmNjdkZDE3YyIsImdlb21ldHJ5X2VuZ2luZSI6eyJuYW1lIjoiT3BlbkNhc2NhZGUiLCJwcm9maWxlIjoib2NwLWV2YWx1YXRlZC1wcm9wZXJ0aWVzLTEiLCJ2ZXJzaW9uIjoiZml4dHVyZSJ9LCJzY2hlbWFfdmVyc2lvbiI6IjEuMCJ9", + "valid": true + }, + { + "expected": { + "code": "analytic_geometry_invalid", + "path": "/entities/0/geometry/direction" + }, + "name": "nonunit_analytic_direction", + "payload_base64": "eyJkZWZpbml0aW9uX2lkIjoiZGVmaW5pdGlvbi9yb290L3NoYXBlL21hbnVhbC9maXh0dXJlIiwiZWRnZV9hc3NldF9pZCI6InNoYTI1Njo2Y2U0ODFkYjFmZTEzYmNmZTdhMjM3NDQyM2U2YzcyZDc1N2Q1MWM5OTE2OGIyODdjNTJmNTNhYTYyNWNjZDY3IiwiZWRnZV9ncm91cHMiOlt7ImVudGl0eV9pZCI6ImVudGl0eS9lZGdlLzAiLCJmaXJzdF9pbmRleCI6MCwiZ3JvdXBfaWQiOjAsImluZGV4X2NvdW50IjoyLCJtZXNoX2luZGV4IjowLCJwcmltaXRpdmVfaW5kZXgiOjB9LHsiZW50aXR5X2lkIjoiZW50aXR5L2VkZ2UvMSIsImZpcnN0X2luZGV4IjoyLCJncm91cF9pZCI6MSwiaW5kZXhfY291bnQiOjIsIm1lc2hfaW5kZXgiOjAsInByaW1pdGl2ZV9pbmRleCI6MH0seyJlbnRpdHlfaWQiOiJlbnRpdHkvZWRnZS8yIiwiZmlyc3RfaW5kZXgiOjQsImdyb3VwX2lkIjoyLCJpbmRleF9jb3VudCI6MiwibWVzaF9pbmRleCI6MCwicHJpbWl0aXZlX2luZGV4IjowfSx7ImVudGl0eV9pZCI6ImVudGl0eS9lZGdlLzMiLCJmaXJzdF9pbmRleCI6NiwiZ3JvdXBfaWQiOjMsImluZGV4X2NvdW50IjoyLCJtZXNoX2luZGV4IjowLCJwcmltaXRpdmVfaW5kZXgiOjB9LHsiZW50aXR5X2lkIjoiZW50aXR5L2VkZ2UvNCIsImZpcnN0X2luZGV4Ijo4LCJncm91cF9pZCI6NCwiaW5kZXhfY291bnQiOjIsIm1lc2hfaW5kZXgiOjAsInByaW1pdGl2ZV9pbmRleCI6MH0seyJlbnRpdHlfaWQiOiJlbnRpdHkvZWRnZS81IiwiZmlyc3RfaW5kZXgiOjEwLCJncm91cF9pZCI6NSwiaW5kZXhfY291bnQiOjIsIm1lc2hfaW5kZXgiOjAsInByaW1pdGl2ZV9pbmRleCI6MH1dLCJlbnRpdGllcyI6W3siY2hpbGRfZW50aXR5X2lkcyI6WyJlbnRpdHkvdmVydGV4LzIiLCJlbnRpdHkvdmVydGV4LzMiXSwiY29ubmVjdG9yX2JpbmRpbmdfc3RhdHVzIjoib3duZXJfbm90X3BhcnQiLCJlbnRpdHlfaWQiOiJlbnRpdHkvZWRnZS8wIiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJkaXJlY3Rpb24iOlsyLDAsMF0sIm9yaWdpbiI6WzAsMCwxMDAwXSwidHlwZSI6ImxpbmUifSwia2luZCI6ImVkZ2UiLCJwYXJlbnRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZmFjZS8wIiwiZW50aXR5L2ZhY2UvMyJdLCJwcm9wZXJ0aWVzIjp7ImJvdW5kcyI6eyJtYXgiOlswLDAsMTAwMF0sIm1pbiI6WzAsLTEwMDAsMF19LCJjZW50cm9pZCI6WzAsLTUwMCw1MDBdLCJsZW5ndGgiOjE0MTQuMjEzNTYyMzczMDk1LCJxdWFsaXR5Ijoia2VybmVsX2V2YWx1YXRlZCJ9LCJyZW5kZXJfc3RhdHVzIjoicmVuZGVyZWQiLCJzZGtfY29ubmVjdG9yX2ZyYW1lIjp7Im9yaWdpbiI6WzAsLTUwMCw1MDBdLCJ4X2F4aXMiOlswLC0wLjcwNzEwNjc4MTE4NjU0NzUsLTAuNzA3MTA2NzgxMTg2NTQ3NV0sInlfYXhpcyI6WzAsLTAuNzA3MTA2NzgxMTg2NTQ3NCwwLjcwNzEwNjc4MTE4NjU0NzddLCJ6X2F4aXMiOlstMSwwLDBdfSwic2RrX21ldGFkYXRhIjp7fSwic2VtYW50aWNfYmluZGluZ19pZHMiOltdLCJzb3VyY2UiOnsia2luZCI6InVuYm91bmQifX0seyJjaGlsZF9lbnRpdHlfaWRzIjpbImVudGl0eS92ZXJ0ZXgvMSIsImVudGl0eS92ZXJ0ZXgvMyJdLCJjb25uZWN0b3JfYmluZGluZ19zdGF0dXMiOiJvd25lcl9ub3RfcGFydCIsImVudGl0eV9pZCI6ImVudGl0eS9lZGdlLzEiLCJldmFsdWF0ZWRfdGFncyI6W10sImdlb21ldHJ5Ijp7ImRpcmVjdGlvbiI6Wy0wLjcwNzEwNjc4MTE4NjU0NzUsLTAuNzA3MTA2NzgxMTg2NTQ3NSwwXSwib3JpZ2luIjpbMTAwMCwwLDBdLCJ0eXBlIjoibGluZSJ9LCJraW5kIjoiZWRnZSIsInBhcmVudF9lbnRpdHlfaWRzIjpbImVudGl0eS9mYWNlLzEiLCJlbnRpdHkvZmFjZS8zIl0sInByb3BlcnRpZXMiOnsiYm91bmRzIjp7Im1heCI6WzEwMDAsMCwwXSwibWluIjpbMCwtMTAwMCwwXX0sImNlbnRyb2lkIjpbNTAwLC01MDAsMF0sImxlbmd0aCI6MTQxNC4yMTM1NjIzNzMwOTUsInF1YWxpdHkiOiJrZXJuZWxfZXZhbHVhdGVkIn0sInJlbmRlcl9zdGF0dXMiOiJyZW5kZXJlZCIsInNka19jb25uZWN0b3JfZnJhbWUiOnsib3JpZ2luIjpbNTAwLC01MDAsMF0sInhfYXhpcyI6Wy0wLjcwNzEwNjc4MTE4NjU0NzUsLTAuNzA3MTA2NzgxMTg2NTQ3NSwwXSwieV9heGlzIjpbMCwwLDFdLCJ6X2F4aXMiOlstMC43MDcxMDY3ODExODY1NDc2LDAuNzA3MTA2NzgxMTg2NTQ3NiwwXX0sInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImtpbmQiOiJ1bmJvdW5kIn19LHsiY2hpbGRfZW50aXR5X2lkcyI6WyJlbnRpdHkvdmVydGV4LzAiLCJlbnRpdHkvdmVydGV4LzMiXSwiY29ubmVjdG9yX2JpbmRpbmdfc3RhdHVzIjoib3duZXJfbm90X3BhcnQiLCJlbnRpdHlfaWQiOiJlbnRpdHkvZWRnZS8yIiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJkaXJlY3Rpb24iOlswLC0xLDBdLCJvcmlnaW4iOlswLDAsMF0sInR5cGUiOiJsaW5lIn0sImtpbmQiOiJlZGdlIiwicGFyZW50X2VudGl0eV9pZHMiOlsiZW50aXR5L2ZhY2UvMCIsImVudGl0eS9mYWNlLzEiXSwicHJvcGVydGllcyI6eyJib3VuZHMiOnsibWF4IjpbMCwwLDBdLCJtaW4iOlswLC0xMDAwLDBdfSwiY2VudHJvaWQiOlswLC01MDAsMF0sImxlbmd0aCI6MTAwMCwicXVhbGl0eSI6Imtlcm5lbF9ldmFsdWF0ZWQifSwicmVuZGVyX3N0YXR1cyI6InJlbmRlcmVkIiwic2RrX2Nvbm5lY3Rvcl9mcmFtZSI6eyJvcmlnaW4iOlswLC01MDAsMF0sInhfYXhpcyI6WzAsLTEsMF0sInlfYXhpcyI6WzAsMCwxXSwiel9heGlzIjpbLTEsMCwwXX0sInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImtpbmQiOiJ1bmJvdW5kIn19LHsiY2hpbGRfZW50aXR5X2lkcyI6WyJlbnRpdHkvdmVydGV4LzAiLCJlbnRpdHkvdmVydGV4LzEiXSwiY29ubmVjdG9yX2JpbmRpbmdfc3RhdHVzIjoib3duZXJfbm90X3BhcnQiLCJlbnRpdHlfaWQiOiJlbnRpdHkvZWRnZS8zIiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJkaXJlY3Rpb24iOlsxLDAsMF0sIm9yaWdpbiI6WzAsMCwwXSwidHlwZSI6ImxpbmUifSwia2luZCI6ImVkZ2UiLCJwYXJlbnRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZmFjZS8xIiwiZW50aXR5L2ZhY2UvMiJdLCJwcm9wZXJ0aWVzIjp7ImJvdW5kcyI6eyJtYXgiOlsxMDAwLDAsMF0sIm1pbiI6WzAsMCwwXX0sImNlbnRyb2lkIjpbNTAwLDAsMF0sImxlbmd0aCI6MTAwMCwicXVhbGl0eSI6Imtlcm5lbF9ldmFsdWF0ZWQifSwicmVuZGVyX3N0YXR1cyI6InJlbmRlcmVkIiwic2RrX2Nvbm5lY3Rvcl9mcmFtZSI6eyJvcmlnaW4iOls1MDAsMCwwXSwieF9heGlzIjpbMSwwLDBdLCJ5X2F4aXMiOlswLDAsMV0sInpfYXhpcyI6WzAsLTEsMF19LCJzZGtfbWV0YWRhdGEiOnt9LCJzZW1hbnRpY19iaW5kaW5nX2lkcyI6W10sInNvdXJjZSI6eyJraW5kIjoidW5ib3VuZCJ9fSx7ImNoaWxkX2VudGl0eV9pZHMiOlsiZW50aXR5L3ZlcnRleC8xIiwiZW50aXR5L3ZlcnRleC8yIl0sImNvbm5lY3Rvcl9iaW5kaW5nX3N0YXR1cyI6Im93bmVyX25vdF9wYXJ0IiwiZW50aXR5X2lkIjoiZW50aXR5L2VkZ2UvNCIsImV2YWx1YXRlZF90YWdzIjpbXSwiZ2VvbWV0cnkiOnsiZGlyZWN0aW9uIjpbLTAuNzA3MTA2NzgxMTg2NTQ3NSwwLDAuNzA3MTA2NzgxMTg2NTQ3NV0sIm9yaWdpbiI6WzEwMDAsMCwwXSwidHlwZSI6ImxpbmUifSwia2luZCI6ImVkZ2UiLCJwYXJlbnRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZmFjZS8yIiwiZW50aXR5L2ZhY2UvMyJdLCJwcm9wZXJ0aWVzIjp7ImJvdW5kcyI6eyJtYXgiOlsxMDAwLDAsMTAwMF0sIm1pbiI6WzAsMCwwXX0sImNlbnRyb2lkIjpbNTAwLDAsNTAwXSwibGVuZ3RoIjoxNDE0LjIxMzU2MjM3MzA5NSwicXVhbGl0eSI6Imtlcm5lbF9ldmFsdWF0ZWQifSwicmVuZGVyX3N0YXR1cyI6InJlbmRlcmVkIiwic2RrX2Nvbm5lY3Rvcl9mcmFtZSI6eyJvcmlnaW4iOls1MDAsMCw1MDBdLCJ4X2F4aXMiOlstMC43MDcxMDY3ODExODY1NDc1LDAsMC43MDcxMDY3ODExODY1NDc1XSwieV9heGlzIjpbMC43MDcxMDY3ODExODY1NDc0LDAsMC43MDcxMDY3ODExODY1NDc3XSwiel9heGlzIjpbMCwxLDBdfSwic2RrX21ldGFkYXRhIjp7fSwic2VtYW50aWNfYmluZGluZ19pZHMiOltdLCJzb3VyY2UiOnsia2luZCI6InVuYm91bmQifX0seyJjaGlsZF9lbnRpdHlfaWRzIjpbImVudGl0eS92ZXJ0ZXgvMCIsImVudGl0eS92ZXJ0ZXgvMiJdLCJjb25uZWN0b3JfYmluZGluZ19zdGF0dXMiOiJvd25lcl9ub3RfcGFydCIsImVudGl0eV9pZCI6ImVudGl0eS9lZGdlLzUiLCJldmFsdWF0ZWRfdGFncyI6W10sImdlb21ldHJ5Ijp7ImRpcmVjdGlvbiI6WzAsMCwxXSwib3JpZ2luIjpbMCwwLDBdLCJ0eXBlIjoibGluZSJ9LCJraW5kIjoiZWRnZSIsInBhcmVudF9lbnRpdHlfaWRzIjpbImVudGl0eS9mYWNlLzAiLCJlbnRpdHkvZmFjZS8yIl0sInByb3BlcnRpZXMiOnsiYm91bmRzIjp7Im1heCI6WzAsMCwxMDAwXSwibWluIjpbMCwwLDBdfSwiY2VudHJvaWQiOlswLDAsNTAwXSwibGVuZ3RoIjoxMDAwLCJxdWFsaXR5Ijoia2VybmVsX2V2YWx1YXRlZCJ9LCJyZW5kZXJfc3RhdHVzIjoicmVuZGVyZWQiLCJzZGtfY29ubmVjdG9yX2ZyYW1lIjp7Im9yaWdpbiI6WzAsMCw1MDBdLCJ4X2F4aXMiOlswLDAsMV0sInlfYXhpcyI6WzAsMSwwXSwiel9heGlzIjpbLTEsMCwwXX0sInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImtpbmQiOiJ1bmJvdW5kIn19LHsiY2hpbGRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZWRnZS8wIiwiZW50aXR5L2VkZ2UvMiIsImVudGl0eS9lZGdlLzUiXSwiY29ubmVjdG9yX2JpbmRpbmdfc3RhdHVzIjoib3duZXJfbm90X3BhcnQiLCJlbnRpdHlfaWQiOiJlbnRpdHkvZmFjZS8wIiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJub3JtYWwiOlstMSwwLDBdLCJvcmlnaW4iOlswLC0zMzMuMzMzMzMzMzMzMzMzMywzMzMuMzMzMzMzMzMzMzMzM10sInR5cGUiOiJwbGFuZSIsInhfZGlyZWN0aW9uIjpbMCwwLDFdfSwia2luZCI6ImZhY2UiLCJwYXJlbnRfZW50aXR5X2lkcyI6WyJlbnRpdHkvc29saWQvMCJdLCJwcm9wZXJ0aWVzIjp7ImFyZWEiOjUwMDAwMCwiYm91bmRzIjp7Im1heCI6WzAsMCwxMDAwXSwibWluIjpbMCwtMTAwMCwwXX0sImNlbnRyb2lkIjpbMCwtMzMzLjMzMzMzMzMzMzMzMzMsMzMzLjMzMzMzMzMzMzMzMzNdLCJvcmllbnRhdGlvbiI6ImZvcndhcmQiLCJxdWFsaXR5Ijoia2VybmVsX2V2YWx1YXRlZCJ9LCJyZW5kZXJfc3RhdHVzIjoicmVuZGVyZWQiLCJzZGtfY29ubmVjdG9yX2ZyYW1lIjp7Im9yaWdpbiI6WzAsLTMzMy4zMzMzMzMzMzMzMzMzLDMzMy4zMzMzMzMzMzMzMzMzXSwieF9heGlzIjpbMCwwLDFdLCJ5X2F4aXMiOlswLDEsMF0sInpfYXhpcyI6Wy0xLDAsMF19LCJzZGtfbWV0YWRhdGEiOnt9LCJzZW1hbnRpY19iaW5kaW5nX2lkcyI6W10sInNvdXJjZSI6eyJraW5kIjoidW5ib3VuZCJ9fSx7ImNoaWxkX2VudGl0eV9pZHMiOlsiZW50aXR5L2VkZ2UvMSIsImVudGl0eS9lZGdlLzIiLCJlbnRpdHkvZWRnZS8zIl0sImNvbm5lY3Rvcl9iaW5kaW5nX3N0YXR1cyI6Im93bmVyX25vdF9wYXJ0IiwiZW50aXR5X2lkIjoiZW50aXR5L2ZhY2UvMSIsImV2YWx1YXRlZF90YWdzIjpbXSwiZ2VvbWV0cnkiOnsibm9ybWFsIjpbMCwwLC0xXSwib3JpZ2luIjpbMzMzLjMzMzMzMzMzMzMzMzMsLTMzMy4zMzMzMzMzMzMzMzMzLDBdLCJ0eXBlIjoicGxhbmUiLCJ4X2RpcmVjdGlvbiI6WzEsMCwwXX0sImtpbmQiOiJmYWNlIiwicGFyZW50X2VudGl0eV9pZHMiOlsiZW50aXR5L3NvbGlkLzAiXSwicHJvcGVydGllcyI6eyJhcmVhIjo1MDAwMDAsImJvdW5kcyI6eyJtYXgiOlsxMDAwLDAsMF0sIm1pbiI6WzAsLTEwMDAsMF19LCJjZW50cm9pZCI6WzMzMy4zMzMzMzMzMzMzMzMzLC0zMzMuMzMzMzMzMzMzMzMzMywwXSwib3JpZW50YXRpb24iOiJmb3J3YXJkIiwicXVhbGl0eSI6Imtlcm5lbF9ldmFsdWF0ZWQifSwicmVuZGVyX3N0YXR1cyI6InJlbmRlcmVkIiwic2RrX2Nvbm5lY3Rvcl9mcmFtZSI6eyJvcmlnaW4iOlszMzMuMzMzMzMzMzMzMzMzMywtMzMzLjMzMzMzMzMzMzMzMzMsMF0sInhfYXhpcyI6WzEsMCwwXSwieV9heGlzIjpbMCwtMSwwXSwiel9heGlzIjpbMCwwLC0xXX0sInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImtpbmQiOiJ1bmJvdW5kIn19LHsiY2hpbGRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZWRnZS8zIiwiZW50aXR5L2VkZ2UvNCIsImVudGl0eS9lZGdlLzUiXSwiY29ubmVjdG9yX2JpbmRpbmdfc3RhdHVzIjoib3duZXJfbm90X3BhcnQiLCJlbnRpdHlfaWQiOiJlbnRpdHkvZmFjZS8yIiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJub3JtYWwiOlswLDEsMF0sIm9yaWdpbiI6WzMzMy4zMzMzMzMzMzMzMzMzLDAsMzMzLjMzMzMzMzMzMzMzMzNdLCJ0eXBlIjoicGxhbmUiLCJ4X2RpcmVjdGlvbiI6WzEsMCwwXX0sImtpbmQiOiJmYWNlIiwicGFyZW50X2VudGl0eV9pZHMiOlsiZW50aXR5L3NvbGlkLzAiXSwicHJvcGVydGllcyI6eyJhcmVhIjo1MDAwMDAsImJvdW5kcyI6eyJtYXgiOlsxMDAwLDAsMTAwMF0sIm1pbiI6WzAsMCwwXX0sImNlbnRyb2lkIjpbMzMzLjMzMzMzMzMzMzMzMzMsMCwzMzMuMzMzMzMzMzMzMzMzM10sIm9yaWVudGF0aW9uIjoiZm9yd2FyZCIsInF1YWxpdHkiOiJrZXJuZWxfZXZhbHVhdGVkIn0sInJlbmRlcl9zdGF0dXMiOiJyZW5kZXJlZCIsInNka19jb25uZWN0b3JfZnJhbWUiOnsib3JpZ2luIjpbMzMzLjMzMzMzMzMzMzMzMzMsMCwzMzMuMzMzMzMzMzMzMzMzM10sInhfYXhpcyI6WzEsMCwwXSwieV9heGlzIjpbMCwwLC0xXSwiel9heGlzIjpbMCwxLDBdfSwic2RrX21ldGFkYXRhIjp7fSwic2VtYW50aWNfYmluZGluZ19pZHMiOltdLCJzb3VyY2UiOnsia2luZCI6InVuYm91bmQifX0seyJjaGlsZF9lbnRpdHlfaWRzIjpbImVudGl0eS9lZGdlLzAiLCJlbnRpdHkvZWRnZS8xIiwiZW50aXR5L2VkZ2UvNCJdLCJjb25uZWN0b3JfYmluZGluZ19zdGF0dXMiOiJvd25lcl9ub3RfcGFydCIsImVudGl0eV9pZCI6ImVudGl0eS9mYWNlLzMiLCJldmFsdWF0ZWRfdGFncyI6W10sImdlb21ldHJ5Ijp7Im5vcm1hbCI6WzAuNTc3MzUwMjY5MTg5NjI1OCwtMC41NzczNTAyNjkxODk2MjU4LDAuNTc3MzUwMjY5MTg5NjI1OF0sIm9yaWdpbiI6WzMzMy4zMzMzMzMzMzMzMzMzLC0zMzMuMzMzMzMzMzMzMzMzMywzMzMuMzMzMzMzMzMzMzMzM10sInR5cGUiOiJwbGFuZSIsInhfZGlyZWN0aW9uIjpbLTAuNzA3MTA2NzgxMTg2NTQ3NSwwLDAuNzA3MTA2NzgxMTg2NTQ3NV19LCJraW5kIjoiZmFjZSIsInBhcmVudF9lbnRpdHlfaWRzIjpbImVudGl0eS9zb2xpZC8wIl0sInByb3BlcnRpZXMiOnsiYXJlYSI6ODY2MDI1LjQwMzc4NDQzODYsImJvdW5kcyI6eyJtYXgiOlsxMDAwLDAsMTAwMF0sIm1pbiI6WzAsLTEwMDAsMF19LCJjZW50cm9pZCI6WzMzMy4zMzMzMzMzMzMzMzMzLC0zMzMuMzMzMzMzMzMzMzMzMywzMzMuMzMzMzMzMzMzMzMzM10sIm9yaWVudGF0aW9uIjoiZm9yd2FyZCIsInF1YWxpdHkiOiJrZXJuZWxfZXZhbHVhdGVkIn0sInJlbmRlcl9zdGF0dXMiOiJyZW5kZXJlZCIsInNka19jb25uZWN0b3JfZnJhbWUiOnsib3JpZ2luIjpbMzMzLjMzMzMzMzMzMzMzMzMsLTMzMy4zMzMzMzMzMzMzMzMzLDMzMy4zMzMzMzMzMzMzMzMzXSwieF9heGlzIjpbLTAuNzA3MTA2NzgxMTg2NTQ3NSwwLDAuNzA3MTA2NzgxMTg2NTQ3NV0sInlfYXhpcyI6Wy0wLjQwODI0ODI5MDQ2Mzg2MzEsLTAuODE2NDk2NTgwOTI3NzI2MSwtMC40MDgyNDgyOTA0NjM4NjMxXSwiel9heGlzIjpbMC41NzczNTAyNjkxODk2MjU4LC0wLjU3NzM1MDI2OTE4OTYyNTgsMC41NzczNTAyNjkxODk2MjU4XX0sInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImtpbmQiOiJ1bmJvdW5kIn19LHsiY2hpbGRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZmFjZS8wIiwiZW50aXR5L2ZhY2UvMSIsImVudGl0eS9mYWNlLzIiLCJlbnRpdHkvZmFjZS8zIl0sImNvbm5lY3Rvcl9iaW5kaW5nX3N0YXR1cyI6Im5vdF9hcHBsaWNhYmxlIiwiZW50aXR5X2lkIjoiZW50aXR5L3NvbGlkLzAiLCJldmFsdWF0ZWRfdGFncyI6W10sImdlb21ldHJ5Ijp7InR5cGUiOiJicmVwX3NvbGlkIn0sImtpbmQiOiJzb2xpZCIsInBhcmVudF9lbnRpdHlfaWRzIjpbXSwicHJvcGVydGllcyI6eyJib3VuZHMiOnsibWF4IjpbMTAwMCwwLDEwMDBdLCJtaW4iOlswLC0xMDAwLDBdfSwiY2VudHJvaWQiOlsyNTAsLTI1MCwyNTBdLCJxdWFsaXR5Ijoia2VybmVsX2V2YWx1YXRlZCIsInN1cmZhY2VfYXJlYSI6MjM2NjAyNS40MDM3ODQ0Mzg1LCJ2b2x1bWUiOjE2NjY2NjY2Ni42NjY2NjY2Nn0sInJlbmRlcl9zdGF0dXMiOiJyZW5kZXJlZCIsInNka19jb25uZWN0b3JfZnJhbWUiOm51bGwsInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImtpbmQiOiJ1bmJvdW5kIn19LHsiY2hpbGRfZW50aXR5X2lkcyI6W10sImNvbm5lY3Rvcl9iaW5kaW5nX3N0YXR1cyI6Im93bmVyX25vdF9wYXJ0IiwiZW50aXR5X2lkIjoiZW50aXR5L3ZlcnRleC8wIiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJwb3NpdGlvbiI6WzAsMCwwXSwidHlwZSI6InBvaW50In0sImtpbmQiOiJ2ZXJ0ZXgiLCJwYXJlbnRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZWRnZS8yIiwiZW50aXR5L2VkZ2UvMyIsImVudGl0eS9lZGdlLzUiXSwicHJvcGVydGllcyI6eyJib3VuZHMiOnsibWF4IjpbMCwwLDBdLCJtaW4iOlswLDAsMF19LCJwb3NpdGlvbiI6WzAsMCwwXSwicXVhbGl0eSI6Imtlcm5lbF9ldmFsdWF0ZWQifSwicmVuZGVyX3N0YXR1cyI6InJlbmRlcmVkIiwic2RrX2Nvbm5lY3Rvcl9mcmFtZSI6eyJvcmlnaW4iOlswLDAsMF0sInhfYXhpcyI6WzEsMCwwXSwieV9heGlzIjpbMCwxLDBdLCJ6X2F4aXMiOlswLDAsMV19LCJzZGtfbWV0YWRhdGEiOnt9LCJzZW1hbnRpY19iaW5kaW5nX2lkcyI6W10sInNvdXJjZSI6eyJraW5kIjoidW5ib3VuZCJ9fSx7ImNoaWxkX2VudGl0eV9pZHMiOltdLCJjb25uZWN0b3JfYmluZGluZ19zdGF0dXMiOiJvd25lcl9ub3RfcGFydCIsImVudGl0eV9pZCI6ImVudGl0eS92ZXJ0ZXgvMSIsImV2YWx1YXRlZF90YWdzIjpbXSwiZ2VvbWV0cnkiOnsicG9zaXRpb24iOlsxMDAwLDAsMF0sInR5cGUiOiJwb2ludCJ9LCJraW5kIjoidmVydGV4IiwicGFyZW50X2VudGl0eV9pZHMiOlsiZW50aXR5L2VkZ2UvMSIsImVudGl0eS9lZGdlLzMiLCJlbnRpdHkvZWRnZS80Il0sInByb3BlcnRpZXMiOnsiYm91bmRzIjp7Im1heCI6WzEwMDAsMCwwXSwibWluIjpbMTAwMCwwLDBdfSwicG9zaXRpb24iOlsxMDAwLDAsMF0sInF1YWxpdHkiOiJrZXJuZWxfZXZhbHVhdGVkIn0sInJlbmRlcl9zdGF0dXMiOiJyZW5kZXJlZCIsInNka19jb25uZWN0b3JfZnJhbWUiOnsib3JpZ2luIjpbMTAwMCwwLDBdLCJ4X2F4aXMiOlsxLDAsMF0sInlfYXhpcyI6WzAsMSwwXSwiel9heGlzIjpbMCwwLDFdfSwic2RrX21ldGFkYXRhIjp7fSwic2VtYW50aWNfYmluZGluZ19pZHMiOltdLCJzb3VyY2UiOnsia2luZCI6InVuYm91bmQifX0seyJjaGlsZF9lbnRpdHlfaWRzIjpbXSwiY29ubmVjdG9yX2JpbmRpbmdfc3RhdHVzIjoib3duZXJfbm90X3BhcnQiLCJlbnRpdHlfaWQiOiJlbnRpdHkvdmVydGV4LzIiLCJldmFsdWF0ZWRfdGFncyI6W10sImdlb21ldHJ5Ijp7InBvc2l0aW9uIjpbMCwwLDEwMDBdLCJ0eXBlIjoicG9pbnQifSwia2luZCI6InZlcnRleCIsInBhcmVudF9lbnRpdHlfaWRzIjpbImVudGl0eS9lZGdlLzAiLCJlbnRpdHkvZWRnZS80IiwiZW50aXR5L2VkZ2UvNSJdLCJwcm9wZXJ0aWVzIjp7ImJvdW5kcyI6eyJtYXgiOlswLDAsMTAwMF0sIm1pbiI6WzAsMCwxMDAwXX0sInBvc2l0aW9uIjpbMCwwLDEwMDBdLCJxdWFsaXR5Ijoia2VybmVsX2V2YWx1YXRlZCJ9LCJyZW5kZXJfc3RhdHVzIjoicmVuZGVyZWQiLCJzZGtfY29ubmVjdG9yX2ZyYW1lIjp7Im9yaWdpbiI6WzAsMCwxMDAwXSwieF9heGlzIjpbMSwwLDBdLCJ5X2F4aXMiOlswLDEsMF0sInpfYXhpcyI6WzAsMCwxXX0sInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImtpbmQiOiJ1bmJvdW5kIn19LHsiY2hpbGRfZW50aXR5X2lkcyI6W10sImNvbm5lY3Rvcl9iaW5kaW5nX3N0YXR1cyI6Im93bmVyX25vdF9wYXJ0IiwiZW50aXR5X2lkIjoiZW50aXR5L3ZlcnRleC8zIiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJwb3NpdGlvbiI6WzAsLTEwMDAsMF0sInR5cGUiOiJwb2ludCJ9LCJraW5kIjoidmVydGV4IiwicGFyZW50X2VudGl0eV9pZHMiOlsiZW50aXR5L2VkZ2UvMCIsImVudGl0eS9lZGdlLzEiLCJlbnRpdHkvZWRnZS8yIl0sInByb3BlcnRpZXMiOnsiYm91bmRzIjp7Im1heCI6WzAsLTEwMDAsMF0sIm1pbiI6WzAsLTEwMDAsMF19LCJwb3NpdGlvbiI6WzAsLTEwMDAsMF0sInF1YWxpdHkiOiJrZXJuZWxfZXZhbHVhdGVkIn0sInJlbmRlcl9zdGF0dXMiOiJyZW5kZXJlZCIsInNka19jb25uZWN0b3JfZnJhbWUiOnsib3JpZ2luIjpbMCwtMTAwMCwwXSwieF9heGlzIjpbMSwwLDBdLCJ5X2F4aXMiOlswLDEsMF0sInpfYXhpcyI6WzAsMCwxXX0sInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImtpbmQiOiJ1bmJvdW5kIn19XSwiZmFjZV9ncm91cHMiOlt7ImVudGl0eV9pZCI6ImVudGl0eS9mYWNlLzAiLCJmaXJzdF9pbmRleCI6MCwiZ3JvdXBfaWQiOjAsImluZGV4X2NvdW50IjozLCJtZXNoX2luZGV4IjowLCJwcmltaXRpdmVfaW5kZXgiOjB9LHsiZW50aXR5X2lkIjoiZW50aXR5L2ZhY2UvMSIsImZpcnN0X2luZGV4IjozLCJncm91cF9pZCI6MSwiaW5kZXhfY291bnQiOjMsIm1lc2hfaW5kZXgiOjAsInByaW1pdGl2ZV9pbmRleCI6MH0seyJlbnRpdHlfaWQiOiJlbnRpdHkvZmFjZS8yIiwiZmlyc3RfaW5kZXgiOjYsImdyb3VwX2lkIjoyLCJpbmRleF9jb3VudCI6MywibWVzaF9pbmRleCI6MCwicHJpbWl0aXZlX2luZGV4IjowfSx7ImVudGl0eV9pZCI6ImVudGl0eS9mYWNlLzMiLCJmaXJzdF9pbmRleCI6OSwiZ3JvdXBfaWQiOjMsImluZGV4X2NvdW50IjozLCJtZXNoX2luZGV4IjowLCJwcmltaXRpdmVfaW5kZXgiOjB9XSwiZ2VvbWV0cnlfYXNzZXRfaWQiOiJzaGEyNTY6MjAwNTIzNDE0ZGUyODFhOGExNTUwODZhNTRjZDBmODgwZjY0MDQ4NTc2NWE0OGNjZGQ4ZWMyMzBmNjdkZDE3YyIsImdlb21ldHJ5X2VuZ2luZSI6eyJuYW1lIjoiT3BlbkNhc2NhZGUiLCJwcm9maWxlIjoib2NwLWV2YWx1YXRlZC1wcm9wZXJ0aWVzLTEiLCJ2ZXJzaW9uIjoiZml4dHVyZSJ9LCJzY2hlbWFfdmVyc2lvbiI6IjEuMCJ9", + "valid": false + }, + { + "expected": { + "code": "array_order_invalid", + "path": "/entities/0/parent_entity_ids" + }, + "name": "unordered_adjacency", + "payload_base64": "eyJkZWZpbml0aW9uX2lkIjoiZGVmaW5pdGlvbi9yb290L3NoYXBlL21hbnVhbC9maXh0dXJlIiwiZWRnZV9hc3NldF9pZCI6InNoYTI1Njo2Y2U0ODFkYjFmZTEzYmNmZTdhMjM3NDQyM2U2YzcyZDc1N2Q1MWM5OTE2OGIyODdjNTJmNTNhYTYyNWNjZDY3IiwiZWRnZV9ncm91cHMiOlt7ImVudGl0eV9pZCI6ImVudGl0eS9lZGdlLzAiLCJmaXJzdF9pbmRleCI6MCwiZ3JvdXBfaWQiOjAsImluZGV4X2NvdW50IjoyLCJtZXNoX2luZGV4IjowLCJwcmltaXRpdmVfaW5kZXgiOjB9LHsiZW50aXR5X2lkIjoiZW50aXR5L2VkZ2UvMSIsImZpcnN0X2luZGV4IjoyLCJncm91cF9pZCI6MSwiaW5kZXhfY291bnQiOjIsIm1lc2hfaW5kZXgiOjAsInByaW1pdGl2ZV9pbmRleCI6MH0seyJlbnRpdHlfaWQiOiJlbnRpdHkvZWRnZS8yIiwiZmlyc3RfaW5kZXgiOjQsImdyb3VwX2lkIjoyLCJpbmRleF9jb3VudCI6MiwibWVzaF9pbmRleCI6MCwicHJpbWl0aXZlX2luZGV4IjowfSx7ImVudGl0eV9pZCI6ImVudGl0eS9lZGdlLzMiLCJmaXJzdF9pbmRleCI6NiwiZ3JvdXBfaWQiOjMsImluZGV4X2NvdW50IjoyLCJtZXNoX2luZGV4IjowLCJwcmltaXRpdmVfaW5kZXgiOjB9LHsiZW50aXR5X2lkIjoiZW50aXR5L2VkZ2UvNCIsImZpcnN0X2luZGV4Ijo4LCJncm91cF9pZCI6NCwiaW5kZXhfY291bnQiOjIsIm1lc2hfaW5kZXgiOjAsInByaW1pdGl2ZV9pbmRleCI6MH0seyJlbnRpdHlfaWQiOiJlbnRpdHkvZWRnZS81IiwiZmlyc3RfaW5kZXgiOjEwLCJncm91cF9pZCI6NSwiaW5kZXhfY291bnQiOjIsIm1lc2hfaW5kZXgiOjAsInByaW1pdGl2ZV9pbmRleCI6MH1dLCJlbnRpdGllcyI6W3siY2hpbGRfZW50aXR5X2lkcyI6WyJlbnRpdHkvdmVydGV4LzIiLCJlbnRpdHkvdmVydGV4LzMiXSwiY29ubmVjdG9yX2JpbmRpbmdfc3RhdHVzIjoib3duZXJfbm90X3BhcnQiLCJlbnRpdHlfaWQiOiJlbnRpdHkvZWRnZS8wIiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJkaXJlY3Rpb24iOlswLC0wLjcwNzEwNjc4MTE4NjU0NzUsLTAuNzA3MTA2NzgxMTg2NTQ3NV0sIm9yaWdpbiI6WzAsMCwxMDAwXSwidHlwZSI6ImxpbmUifSwia2luZCI6ImVkZ2UiLCJwYXJlbnRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZmFjZS8zIiwiZW50aXR5L2ZhY2UvMCJdLCJwcm9wZXJ0aWVzIjp7ImJvdW5kcyI6eyJtYXgiOlswLDAsMTAwMF0sIm1pbiI6WzAsLTEwMDAsMF19LCJjZW50cm9pZCI6WzAsLTUwMCw1MDBdLCJsZW5ndGgiOjE0MTQuMjEzNTYyMzczMDk1LCJxdWFsaXR5Ijoia2VybmVsX2V2YWx1YXRlZCJ9LCJyZW5kZXJfc3RhdHVzIjoicmVuZGVyZWQiLCJzZGtfY29ubmVjdG9yX2ZyYW1lIjp7Im9yaWdpbiI6WzAsLTUwMCw1MDBdLCJ4X2F4aXMiOlswLC0wLjcwNzEwNjc4MTE4NjU0NzUsLTAuNzA3MTA2NzgxMTg2NTQ3NV0sInlfYXhpcyI6WzAsLTAuNzA3MTA2NzgxMTg2NTQ3NCwwLjcwNzEwNjc4MTE4NjU0NzddLCJ6X2F4aXMiOlstMSwwLDBdfSwic2RrX21ldGFkYXRhIjp7fSwic2VtYW50aWNfYmluZGluZ19pZHMiOltdLCJzb3VyY2UiOnsia2luZCI6InVuYm91bmQifX0seyJjaGlsZF9lbnRpdHlfaWRzIjpbImVudGl0eS92ZXJ0ZXgvMSIsImVudGl0eS92ZXJ0ZXgvMyJdLCJjb25uZWN0b3JfYmluZGluZ19zdGF0dXMiOiJvd25lcl9ub3RfcGFydCIsImVudGl0eV9pZCI6ImVudGl0eS9lZGdlLzEiLCJldmFsdWF0ZWRfdGFncyI6W10sImdlb21ldHJ5Ijp7ImRpcmVjdGlvbiI6Wy0wLjcwNzEwNjc4MTE4NjU0NzUsLTAuNzA3MTA2NzgxMTg2NTQ3NSwwXSwib3JpZ2luIjpbMTAwMCwwLDBdLCJ0eXBlIjoibGluZSJ9LCJraW5kIjoiZWRnZSIsInBhcmVudF9lbnRpdHlfaWRzIjpbImVudGl0eS9mYWNlLzEiLCJlbnRpdHkvZmFjZS8zIl0sInByb3BlcnRpZXMiOnsiYm91bmRzIjp7Im1heCI6WzEwMDAsMCwwXSwibWluIjpbMCwtMTAwMCwwXX0sImNlbnRyb2lkIjpbNTAwLC01MDAsMF0sImxlbmd0aCI6MTQxNC4yMTM1NjIzNzMwOTUsInF1YWxpdHkiOiJrZXJuZWxfZXZhbHVhdGVkIn0sInJlbmRlcl9zdGF0dXMiOiJyZW5kZXJlZCIsInNka19jb25uZWN0b3JfZnJhbWUiOnsib3JpZ2luIjpbNTAwLC01MDAsMF0sInhfYXhpcyI6Wy0wLjcwNzEwNjc4MTE4NjU0NzUsLTAuNzA3MTA2NzgxMTg2NTQ3NSwwXSwieV9heGlzIjpbMCwwLDFdLCJ6X2F4aXMiOlstMC43MDcxMDY3ODExODY1NDc2LDAuNzA3MTA2NzgxMTg2NTQ3NiwwXX0sInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImtpbmQiOiJ1bmJvdW5kIn19LHsiY2hpbGRfZW50aXR5X2lkcyI6WyJlbnRpdHkvdmVydGV4LzAiLCJlbnRpdHkvdmVydGV4LzMiXSwiY29ubmVjdG9yX2JpbmRpbmdfc3RhdHVzIjoib3duZXJfbm90X3BhcnQiLCJlbnRpdHlfaWQiOiJlbnRpdHkvZWRnZS8yIiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJkaXJlY3Rpb24iOlswLC0xLDBdLCJvcmlnaW4iOlswLDAsMF0sInR5cGUiOiJsaW5lIn0sImtpbmQiOiJlZGdlIiwicGFyZW50X2VudGl0eV9pZHMiOlsiZW50aXR5L2ZhY2UvMCIsImVudGl0eS9mYWNlLzEiXSwicHJvcGVydGllcyI6eyJib3VuZHMiOnsibWF4IjpbMCwwLDBdLCJtaW4iOlswLC0xMDAwLDBdfSwiY2VudHJvaWQiOlswLC01MDAsMF0sImxlbmd0aCI6MTAwMCwicXVhbGl0eSI6Imtlcm5lbF9ldmFsdWF0ZWQifSwicmVuZGVyX3N0YXR1cyI6InJlbmRlcmVkIiwic2RrX2Nvbm5lY3Rvcl9mcmFtZSI6eyJvcmlnaW4iOlswLC01MDAsMF0sInhfYXhpcyI6WzAsLTEsMF0sInlfYXhpcyI6WzAsMCwxXSwiel9heGlzIjpbLTEsMCwwXX0sInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImtpbmQiOiJ1bmJvdW5kIn19LHsiY2hpbGRfZW50aXR5X2lkcyI6WyJlbnRpdHkvdmVydGV4LzAiLCJlbnRpdHkvdmVydGV4LzEiXSwiY29ubmVjdG9yX2JpbmRpbmdfc3RhdHVzIjoib3duZXJfbm90X3BhcnQiLCJlbnRpdHlfaWQiOiJlbnRpdHkvZWRnZS8zIiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJkaXJlY3Rpb24iOlsxLDAsMF0sIm9yaWdpbiI6WzAsMCwwXSwidHlwZSI6ImxpbmUifSwia2luZCI6ImVkZ2UiLCJwYXJlbnRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZmFjZS8xIiwiZW50aXR5L2ZhY2UvMiJdLCJwcm9wZXJ0aWVzIjp7ImJvdW5kcyI6eyJtYXgiOlsxMDAwLDAsMF0sIm1pbiI6WzAsMCwwXX0sImNlbnRyb2lkIjpbNTAwLDAsMF0sImxlbmd0aCI6MTAwMCwicXVhbGl0eSI6Imtlcm5lbF9ldmFsdWF0ZWQifSwicmVuZGVyX3N0YXR1cyI6InJlbmRlcmVkIiwic2RrX2Nvbm5lY3Rvcl9mcmFtZSI6eyJvcmlnaW4iOls1MDAsMCwwXSwieF9heGlzIjpbMSwwLDBdLCJ5X2F4aXMiOlswLDAsMV0sInpfYXhpcyI6WzAsLTEsMF19LCJzZGtfbWV0YWRhdGEiOnt9LCJzZW1hbnRpY19iaW5kaW5nX2lkcyI6W10sInNvdXJjZSI6eyJraW5kIjoidW5ib3VuZCJ9fSx7ImNoaWxkX2VudGl0eV9pZHMiOlsiZW50aXR5L3ZlcnRleC8xIiwiZW50aXR5L3ZlcnRleC8yIl0sImNvbm5lY3Rvcl9iaW5kaW5nX3N0YXR1cyI6Im93bmVyX25vdF9wYXJ0IiwiZW50aXR5X2lkIjoiZW50aXR5L2VkZ2UvNCIsImV2YWx1YXRlZF90YWdzIjpbXSwiZ2VvbWV0cnkiOnsiZGlyZWN0aW9uIjpbLTAuNzA3MTA2NzgxMTg2NTQ3NSwwLDAuNzA3MTA2NzgxMTg2NTQ3NV0sIm9yaWdpbiI6WzEwMDAsMCwwXSwidHlwZSI6ImxpbmUifSwia2luZCI6ImVkZ2UiLCJwYXJlbnRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZmFjZS8yIiwiZW50aXR5L2ZhY2UvMyJdLCJwcm9wZXJ0aWVzIjp7ImJvdW5kcyI6eyJtYXgiOlsxMDAwLDAsMTAwMF0sIm1pbiI6WzAsMCwwXX0sImNlbnRyb2lkIjpbNTAwLDAsNTAwXSwibGVuZ3RoIjoxNDE0LjIxMzU2MjM3MzA5NSwicXVhbGl0eSI6Imtlcm5lbF9ldmFsdWF0ZWQifSwicmVuZGVyX3N0YXR1cyI6InJlbmRlcmVkIiwic2RrX2Nvbm5lY3Rvcl9mcmFtZSI6eyJvcmlnaW4iOls1MDAsMCw1MDBdLCJ4X2F4aXMiOlstMC43MDcxMDY3ODExODY1NDc1LDAsMC43MDcxMDY3ODExODY1NDc1XSwieV9heGlzIjpbMC43MDcxMDY3ODExODY1NDc0LDAsMC43MDcxMDY3ODExODY1NDc3XSwiel9heGlzIjpbMCwxLDBdfSwic2RrX21ldGFkYXRhIjp7fSwic2VtYW50aWNfYmluZGluZ19pZHMiOltdLCJzb3VyY2UiOnsia2luZCI6InVuYm91bmQifX0seyJjaGlsZF9lbnRpdHlfaWRzIjpbImVudGl0eS92ZXJ0ZXgvMCIsImVudGl0eS92ZXJ0ZXgvMiJdLCJjb25uZWN0b3JfYmluZGluZ19zdGF0dXMiOiJvd25lcl9ub3RfcGFydCIsImVudGl0eV9pZCI6ImVudGl0eS9lZGdlLzUiLCJldmFsdWF0ZWRfdGFncyI6W10sImdlb21ldHJ5Ijp7ImRpcmVjdGlvbiI6WzAsMCwxXSwib3JpZ2luIjpbMCwwLDBdLCJ0eXBlIjoibGluZSJ9LCJraW5kIjoiZWRnZSIsInBhcmVudF9lbnRpdHlfaWRzIjpbImVudGl0eS9mYWNlLzAiLCJlbnRpdHkvZmFjZS8yIl0sInByb3BlcnRpZXMiOnsiYm91bmRzIjp7Im1heCI6WzAsMCwxMDAwXSwibWluIjpbMCwwLDBdfSwiY2VudHJvaWQiOlswLDAsNTAwXSwibGVuZ3RoIjoxMDAwLCJxdWFsaXR5Ijoia2VybmVsX2V2YWx1YXRlZCJ9LCJyZW5kZXJfc3RhdHVzIjoicmVuZGVyZWQiLCJzZGtfY29ubmVjdG9yX2ZyYW1lIjp7Im9yaWdpbiI6WzAsMCw1MDBdLCJ4X2F4aXMiOlswLDAsMV0sInlfYXhpcyI6WzAsMSwwXSwiel9heGlzIjpbLTEsMCwwXX0sInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImtpbmQiOiJ1bmJvdW5kIn19LHsiY2hpbGRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZWRnZS8wIiwiZW50aXR5L2VkZ2UvMiIsImVudGl0eS9lZGdlLzUiXSwiY29ubmVjdG9yX2JpbmRpbmdfc3RhdHVzIjoib3duZXJfbm90X3BhcnQiLCJlbnRpdHlfaWQiOiJlbnRpdHkvZmFjZS8wIiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJub3JtYWwiOlstMSwwLDBdLCJvcmlnaW4iOlswLC0zMzMuMzMzMzMzMzMzMzMzMywzMzMuMzMzMzMzMzMzMzMzM10sInR5cGUiOiJwbGFuZSIsInhfZGlyZWN0aW9uIjpbMCwwLDFdfSwia2luZCI6ImZhY2UiLCJwYXJlbnRfZW50aXR5X2lkcyI6WyJlbnRpdHkvc29saWQvMCJdLCJwcm9wZXJ0aWVzIjp7ImFyZWEiOjUwMDAwMCwiYm91bmRzIjp7Im1heCI6WzAsMCwxMDAwXSwibWluIjpbMCwtMTAwMCwwXX0sImNlbnRyb2lkIjpbMCwtMzMzLjMzMzMzMzMzMzMzMzMsMzMzLjMzMzMzMzMzMzMzMzNdLCJvcmllbnRhdGlvbiI6ImZvcndhcmQiLCJxdWFsaXR5Ijoia2VybmVsX2V2YWx1YXRlZCJ9LCJyZW5kZXJfc3RhdHVzIjoicmVuZGVyZWQiLCJzZGtfY29ubmVjdG9yX2ZyYW1lIjp7Im9yaWdpbiI6WzAsLTMzMy4zMzMzMzMzMzMzMzMzLDMzMy4zMzMzMzMzMzMzMzMzXSwieF9heGlzIjpbMCwwLDFdLCJ5X2F4aXMiOlswLDEsMF0sInpfYXhpcyI6Wy0xLDAsMF19LCJzZGtfbWV0YWRhdGEiOnt9LCJzZW1hbnRpY19iaW5kaW5nX2lkcyI6W10sInNvdXJjZSI6eyJraW5kIjoidW5ib3VuZCJ9fSx7ImNoaWxkX2VudGl0eV9pZHMiOlsiZW50aXR5L2VkZ2UvMSIsImVudGl0eS9lZGdlLzIiLCJlbnRpdHkvZWRnZS8zIl0sImNvbm5lY3Rvcl9iaW5kaW5nX3N0YXR1cyI6Im93bmVyX25vdF9wYXJ0IiwiZW50aXR5X2lkIjoiZW50aXR5L2ZhY2UvMSIsImV2YWx1YXRlZF90YWdzIjpbXSwiZ2VvbWV0cnkiOnsibm9ybWFsIjpbMCwwLC0xXSwib3JpZ2luIjpbMzMzLjMzMzMzMzMzMzMzMzMsLTMzMy4zMzMzMzMzMzMzMzMzLDBdLCJ0eXBlIjoicGxhbmUiLCJ4X2RpcmVjdGlvbiI6WzEsMCwwXX0sImtpbmQiOiJmYWNlIiwicGFyZW50X2VudGl0eV9pZHMiOlsiZW50aXR5L3NvbGlkLzAiXSwicHJvcGVydGllcyI6eyJhcmVhIjo1MDAwMDAsImJvdW5kcyI6eyJtYXgiOlsxMDAwLDAsMF0sIm1pbiI6WzAsLTEwMDAsMF19LCJjZW50cm9pZCI6WzMzMy4zMzMzMzMzMzMzMzMzLC0zMzMuMzMzMzMzMzMzMzMzMywwXSwib3JpZW50YXRpb24iOiJmb3J3YXJkIiwicXVhbGl0eSI6Imtlcm5lbF9ldmFsdWF0ZWQifSwicmVuZGVyX3N0YXR1cyI6InJlbmRlcmVkIiwic2RrX2Nvbm5lY3Rvcl9mcmFtZSI6eyJvcmlnaW4iOlszMzMuMzMzMzMzMzMzMzMzMywtMzMzLjMzMzMzMzMzMzMzMzMsMF0sInhfYXhpcyI6WzEsMCwwXSwieV9heGlzIjpbMCwtMSwwXSwiel9heGlzIjpbMCwwLC0xXX0sInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImtpbmQiOiJ1bmJvdW5kIn19LHsiY2hpbGRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZWRnZS8zIiwiZW50aXR5L2VkZ2UvNCIsImVudGl0eS9lZGdlLzUiXSwiY29ubmVjdG9yX2JpbmRpbmdfc3RhdHVzIjoib3duZXJfbm90X3BhcnQiLCJlbnRpdHlfaWQiOiJlbnRpdHkvZmFjZS8yIiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJub3JtYWwiOlswLDEsMF0sIm9yaWdpbiI6WzMzMy4zMzMzMzMzMzMzMzMzLDAsMzMzLjMzMzMzMzMzMzMzMzNdLCJ0eXBlIjoicGxhbmUiLCJ4X2RpcmVjdGlvbiI6WzEsMCwwXX0sImtpbmQiOiJmYWNlIiwicGFyZW50X2VudGl0eV9pZHMiOlsiZW50aXR5L3NvbGlkLzAiXSwicHJvcGVydGllcyI6eyJhcmVhIjo1MDAwMDAsImJvdW5kcyI6eyJtYXgiOlsxMDAwLDAsMTAwMF0sIm1pbiI6WzAsMCwwXX0sImNlbnRyb2lkIjpbMzMzLjMzMzMzMzMzMzMzMzMsMCwzMzMuMzMzMzMzMzMzMzMzM10sIm9yaWVudGF0aW9uIjoiZm9yd2FyZCIsInF1YWxpdHkiOiJrZXJuZWxfZXZhbHVhdGVkIn0sInJlbmRlcl9zdGF0dXMiOiJyZW5kZXJlZCIsInNka19jb25uZWN0b3JfZnJhbWUiOnsib3JpZ2luIjpbMzMzLjMzMzMzMzMzMzMzMzMsMCwzMzMuMzMzMzMzMzMzMzMzM10sInhfYXhpcyI6WzEsMCwwXSwieV9heGlzIjpbMCwwLC0xXSwiel9heGlzIjpbMCwxLDBdfSwic2RrX21ldGFkYXRhIjp7fSwic2VtYW50aWNfYmluZGluZ19pZHMiOltdLCJzb3VyY2UiOnsia2luZCI6InVuYm91bmQifX0seyJjaGlsZF9lbnRpdHlfaWRzIjpbImVudGl0eS9lZGdlLzAiLCJlbnRpdHkvZWRnZS8xIiwiZW50aXR5L2VkZ2UvNCJdLCJjb25uZWN0b3JfYmluZGluZ19zdGF0dXMiOiJvd25lcl9ub3RfcGFydCIsImVudGl0eV9pZCI6ImVudGl0eS9mYWNlLzMiLCJldmFsdWF0ZWRfdGFncyI6W10sImdlb21ldHJ5Ijp7Im5vcm1hbCI6WzAuNTc3MzUwMjY5MTg5NjI1OCwtMC41NzczNTAyNjkxODk2MjU4LDAuNTc3MzUwMjY5MTg5NjI1OF0sIm9yaWdpbiI6WzMzMy4zMzMzMzMzMzMzMzMzLC0zMzMuMzMzMzMzMzMzMzMzMywzMzMuMzMzMzMzMzMzMzMzM10sInR5cGUiOiJwbGFuZSIsInhfZGlyZWN0aW9uIjpbLTAuNzA3MTA2NzgxMTg2NTQ3NSwwLDAuNzA3MTA2NzgxMTg2NTQ3NV19LCJraW5kIjoiZmFjZSIsInBhcmVudF9lbnRpdHlfaWRzIjpbImVudGl0eS9zb2xpZC8wIl0sInByb3BlcnRpZXMiOnsiYXJlYSI6ODY2MDI1LjQwMzc4NDQzODYsImJvdW5kcyI6eyJtYXgiOlsxMDAwLDAsMTAwMF0sIm1pbiI6WzAsLTEwMDAsMF19LCJjZW50cm9pZCI6WzMzMy4zMzMzMzMzMzMzMzMzLC0zMzMuMzMzMzMzMzMzMzMzMywzMzMuMzMzMzMzMzMzMzMzM10sIm9yaWVudGF0aW9uIjoiZm9yd2FyZCIsInF1YWxpdHkiOiJrZXJuZWxfZXZhbHVhdGVkIn0sInJlbmRlcl9zdGF0dXMiOiJyZW5kZXJlZCIsInNka19jb25uZWN0b3JfZnJhbWUiOnsib3JpZ2luIjpbMzMzLjMzMzMzMzMzMzMzMzMsLTMzMy4zMzMzMzMzMzMzMzMzLDMzMy4zMzMzMzMzMzMzMzMzXSwieF9heGlzIjpbLTAuNzA3MTA2NzgxMTg2NTQ3NSwwLDAuNzA3MTA2NzgxMTg2NTQ3NV0sInlfYXhpcyI6Wy0wLjQwODI0ODI5MDQ2Mzg2MzEsLTAuODE2NDk2NTgwOTI3NzI2MSwtMC40MDgyNDgyOTA0NjM4NjMxXSwiel9heGlzIjpbMC41NzczNTAyNjkxODk2MjU4LC0wLjU3NzM1MDI2OTE4OTYyNTgsMC41NzczNTAyNjkxODk2MjU4XX0sInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImtpbmQiOiJ1bmJvdW5kIn19LHsiY2hpbGRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZmFjZS8wIiwiZW50aXR5L2ZhY2UvMSIsImVudGl0eS9mYWNlLzIiLCJlbnRpdHkvZmFjZS8zIl0sImNvbm5lY3Rvcl9iaW5kaW5nX3N0YXR1cyI6Im5vdF9hcHBsaWNhYmxlIiwiZW50aXR5X2lkIjoiZW50aXR5L3NvbGlkLzAiLCJldmFsdWF0ZWRfdGFncyI6W10sImdlb21ldHJ5Ijp7InR5cGUiOiJicmVwX3NvbGlkIn0sImtpbmQiOiJzb2xpZCIsInBhcmVudF9lbnRpdHlfaWRzIjpbXSwicHJvcGVydGllcyI6eyJib3VuZHMiOnsibWF4IjpbMTAwMCwwLDEwMDBdLCJtaW4iOlswLC0xMDAwLDBdfSwiY2VudHJvaWQiOlsyNTAsLTI1MCwyNTBdLCJxdWFsaXR5Ijoia2VybmVsX2V2YWx1YXRlZCIsInN1cmZhY2VfYXJlYSI6MjM2NjAyNS40MDM3ODQ0Mzg1LCJ2b2x1bWUiOjE2NjY2NjY2Ni42NjY2NjY2Nn0sInJlbmRlcl9zdGF0dXMiOiJyZW5kZXJlZCIsInNka19jb25uZWN0b3JfZnJhbWUiOm51bGwsInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImtpbmQiOiJ1bmJvdW5kIn19LHsiY2hpbGRfZW50aXR5X2lkcyI6W10sImNvbm5lY3Rvcl9iaW5kaW5nX3N0YXR1cyI6Im93bmVyX25vdF9wYXJ0IiwiZW50aXR5X2lkIjoiZW50aXR5L3ZlcnRleC8wIiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJwb3NpdGlvbiI6WzAsMCwwXSwidHlwZSI6InBvaW50In0sImtpbmQiOiJ2ZXJ0ZXgiLCJwYXJlbnRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZWRnZS8yIiwiZW50aXR5L2VkZ2UvMyIsImVudGl0eS9lZGdlLzUiXSwicHJvcGVydGllcyI6eyJib3VuZHMiOnsibWF4IjpbMCwwLDBdLCJtaW4iOlswLDAsMF19LCJwb3NpdGlvbiI6WzAsMCwwXSwicXVhbGl0eSI6Imtlcm5lbF9ldmFsdWF0ZWQifSwicmVuZGVyX3N0YXR1cyI6InJlbmRlcmVkIiwic2RrX2Nvbm5lY3Rvcl9mcmFtZSI6eyJvcmlnaW4iOlswLDAsMF0sInhfYXhpcyI6WzEsMCwwXSwieV9heGlzIjpbMCwxLDBdLCJ6X2F4aXMiOlswLDAsMV19LCJzZGtfbWV0YWRhdGEiOnt9LCJzZW1hbnRpY19iaW5kaW5nX2lkcyI6W10sInNvdXJjZSI6eyJraW5kIjoidW5ib3VuZCJ9fSx7ImNoaWxkX2VudGl0eV9pZHMiOltdLCJjb25uZWN0b3JfYmluZGluZ19zdGF0dXMiOiJvd25lcl9ub3RfcGFydCIsImVudGl0eV9pZCI6ImVudGl0eS92ZXJ0ZXgvMSIsImV2YWx1YXRlZF90YWdzIjpbXSwiZ2VvbWV0cnkiOnsicG9zaXRpb24iOlsxMDAwLDAsMF0sInR5cGUiOiJwb2ludCJ9LCJraW5kIjoidmVydGV4IiwicGFyZW50X2VudGl0eV9pZHMiOlsiZW50aXR5L2VkZ2UvMSIsImVudGl0eS9lZGdlLzMiLCJlbnRpdHkvZWRnZS80Il0sInByb3BlcnRpZXMiOnsiYm91bmRzIjp7Im1heCI6WzEwMDAsMCwwXSwibWluIjpbMTAwMCwwLDBdfSwicG9zaXRpb24iOlsxMDAwLDAsMF0sInF1YWxpdHkiOiJrZXJuZWxfZXZhbHVhdGVkIn0sInJlbmRlcl9zdGF0dXMiOiJyZW5kZXJlZCIsInNka19jb25uZWN0b3JfZnJhbWUiOnsib3JpZ2luIjpbMTAwMCwwLDBdLCJ4X2F4aXMiOlsxLDAsMF0sInlfYXhpcyI6WzAsMSwwXSwiel9heGlzIjpbMCwwLDFdfSwic2RrX21ldGFkYXRhIjp7fSwic2VtYW50aWNfYmluZGluZ19pZHMiOltdLCJzb3VyY2UiOnsia2luZCI6InVuYm91bmQifX0seyJjaGlsZF9lbnRpdHlfaWRzIjpbXSwiY29ubmVjdG9yX2JpbmRpbmdfc3RhdHVzIjoib3duZXJfbm90X3BhcnQiLCJlbnRpdHlfaWQiOiJlbnRpdHkvdmVydGV4LzIiLCJldmFsdWF0ZWRfdGFncyI6W10sImdlb21ldHJ5Ijp7InBvc2l0aW9uIjpbMCwwLDEwMDBdLCJ0eXBlIjoicG9pbnQifSwia2luZCI6InZlcnRleCIsInBhcmVudF9lbnRpdHlfaWRzIjpbImVudGl0eS9lZGdlLzAiLCJlbnRpdHkvZWRnZS80IiwiZW50aXR5L2VkZ2UvNSJdLCJwcm9wZXJ0aWVzIjp7ImJvdW5kcyI6eyJtYXgiOlswLDAsMTAwMF0sIm1pbiI6WzAsMCwxMDAwXX0sInBvc2l0aW9uIjpbMCwwLDEwMDBdLCJxdWFsaXR5Ijoia2VybmVsX2V2YWx1YXRlZCJ9LCJyZW5kZXJfc3RhdHVzIjoicmVuZGVyZWQiLCJzZGtfY29ubmVjdG9yX2ZyYW1lIjp7Im9yaWdpbiI6WzAsMCwxMDAwXSwieF9heGlzIjpbMSwwLDBdLCJ5X2F4aXMiOlswLDEsMF0sInpfYXhpcyI6WzAsMCwxXX0sInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImtpbmQiOiJ1bmJvdW5kIn19LHsiY2hpbGRfZW50aXR5X2lkcyI6W10sImNvbm5lY3Rvcl9iaW5kaW5nX3N0YXR1cyI6Im93bmVyX25vdF9wYXJ0IiwiZW50aXR5X2lkIjoiZW50aXR5L3ZlcnRleC8zIiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJwb3NpdGlvbiI6WzAsLTEwMDAsMF0sInR5cGUiOiJwb2ludCJ9LCJraW5kIjoidmVydGV4IiwicGFyZW50X2VudGl0eV9pZHMiOlsiZW50aXR5L2VkZ2UvMCIsImVudGl0eS9lZGdlLzEiLCJlbnRpdHkvZWRnZS8yIl0sInByb3BlcnRpZXMiOnsiYm91bmRzIjp7Im1heCI6WzAsLTEwMDAsMF0sIm1pbiI6WzAsLTEwMDAsMF19LCJwb3NpdGlvbiI6WzAsLTEwMDAsMF0sInF1YWxpdHkiOiJrZXJuZWxfZXZhbHVhdGVkIn0sInJlbmRlcl9zdGF0dXMiOiJyZW5kZXJlZCIsInNka19jb25uZWN0b3JfZnJhbWUiOnsib3JpZ2luIjpbMCwtMTAwMCwwXSwieF9heGlzIjpbMSwwLDBdLCJ5X2F4aXMiOlswLDEsMF0sInpfYXhpcyI6WzAsMCwxXX0sInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImtpbmQiOiJ1bmJvdW5kIn19XSwiZmFjZV9ncm91cHMiOlt7ImVudGl0eV9pZCI6ImVudGl0eS9mYWNlLzAiLCJmaXJzdF9pbmRleCI6MCwiZ3JvdXBfaWQiOjAsImluZGV4X2NvdW50IjozLCJtZXNoX2luZGV4IjowLCJwcmltaXRpdmVfaW5kZXgiOjB9LHsiZW50aXR5X2lkIjoiZW50aXR5L2ZhY2UvMSIsImZpcnN0X2luZGV4IjozLCJncm91cF9pZCI6MSwiaW5kZXhfY291bnQiOjMsIm1lc2hfaW5kZXgiOjAsInByaW1pdGl2ZV9pbmRleCI6MH0seyJlbnRpdHlfaWQiOiJlbnRpdHkvZmFjZS8yIiwiZmlyc3RfaW5kZXgiOjYsImdyb3VwX2lkIjoyLCJpbmRleF9jb3VudCI6MywibWVzaF9pbmRleCI6MCwicHJpbWl0aXZlX2luZGV4IjowfSx7ImVudGl0eV9pZCI6ImVudGl0eS9mYWNlLzMiLCJmaXJzdF9pbmRleCI6OSwiZ3JvdXBfaWQiOjMsImluZGV4X2NvdW50IjozLCJtZXNoX2luZGV4IjowLCJwcmltaXRpdmVfaW5kZXgiOjB9XSwiZ2VvbWV0cnlfYXNzZXRfaWQiOiJzaGEyNTY6MjAwNTIzNDE0ZGUyODFhOGExNTUwODZhNTRjZDBmODgwZjY0MDQ4NTc2NWE0OGNjZGQ4ZWMyMzBmNjdkZDE3YyIsImdlb21ldHJ5X2VuZ2luZSI6eyJuYW1lIjoiT3BlbkNhc2NhZGUiLCJwcm9maWxlIjoib2NwLWV2YWx1YXRlZC1wcm9wZXJ0aWVzLTEiLCJ2ZXJzaW9uIjoiZml4dHVyZSJ9LCJzY2hlbWFfdmVyc2lvbiI6IjEuMCJ9", + "valid": false + }, + { + "expected": null, + "name": "valid_entity_source_model_output", + "payload_base64": "eyJkZWZpbml0aW9uX2lkIjoiZGVmaW5pdGlvbi9yb290L3NoYXBlL21hbnVhbC9maXh0dXJlIiwiZWRnZV9hc3NldF9pZCI6InNoYTI1Njo2Y2U0ODFkYjFmZTEzYmNmZTdhMjM3NDQyM2U2YzcyZDc1N2Q1MWM5OTE2OGIyODdjNTJmNTNhYTYyNWNjZDY3IiwiZWRnZV9ncm91cHMiOlt7ImVudGl0eV9pZCI6ImVudGl0eS9lZGdlLzAiLCJmaXJzdF9pbmRleCI6MCwiZ3JvdXBfaWQiOjAsImluZGV4X2NvdW50IjoyLCJtZXNoX2luZGV4IjowLCJwcmltaXRpdmVfaW5kZXgiOjB9LHsiZW50aXR5X2lkIjoiZW50aXR5L2VkZ2UvMSIsImZpcnN0X2luZGV4IjoyLCJncm91cF9pZCI6MSwiaW5kZXhfY291bnQiOjIsIm1lc2hfaW5kZXgiOjAsInByaW1pdGl2ZV9pbmRleCI6MH0seyJlbnRpdHlfaWQiOiJlbnRpdHkvZWRnZS8yIiwiZmlyc3RfaW5kZXgiOjQsImdyb3VwX2lkIjoyLCJpbmRleF9jb3VudCI6MiwibWVzaF9pbmRleCI6MCwicHJpbWl0aXZlX2luZGV4IjowfSx7ImVudGl0eV9pZCI6ImVudGl0eS9lZGdlLzMiLCJmaXJzdF9pbmRleCI6NiwiZ3JvdXBfaWQiOjMsImluZGV4X2NvdW50IjoyLCJtZXNoX2luZGV4IjowLCJwcmltaXRpdmVfaW5kZXgiOjB9LHsiZW50aXR5X2lkIjoiZW50aXR5L2VkZ2UvNCIsImZpcnN0X2luZGV4Ijo4LCJncm91cF9pZCI6NCwiaW5kZXhfY291bnQiOjIsIm1lc2hfaW5kZXgiOjAsInByaW1pdGl2ZV9pbmRleCI6MH0seyJlbnRpdHlfaWQiOiJlbnRpdHkvZWRnZS81IiwiZmlyc3RfaW5kZXgiOjEwLCJncm91cF9pZCI6NSwiaW5kZXhfY291bnQiOjIsIm1lc2hfaW5kZXgiOjAsInByaW1pdGl2ZV9pbmRleCI6MH1dLCJlbnRpdGllcyI6W3siY2hpbGRfZW50aXR5X2lkcyI6WyJlbnRpdHkvdmVydGV4LzIiLCJlbnRpdHkvdmVydGV4LzMiXSwiY29ubmVjdG9yX2JpbmRpbmdfc3RhdHVzIjoib3duZXJfbm90X3BhcnQiLCJlbnRpdHlfaWQiOiJlbnRpdHkvZWRnZS8wIiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJkaXJlY3Rpb24iOlswLC0wLjcwNzEwNjc4MTE4NjU0NzUsLTAuNzA3MTA2NzgxMTg2NTQ3NV0sIm9yaWdpbiI6WzAsMCwxMDAwXSwidHlwZSI6ImxpbmUifSwia2luZCI6ImVkZ2UiLCJwYXJlbnRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZmFjZS8wIiwiZW50aXR5L2ZhY2UvMyJdLCJwcm9wZXJ0aWVzIjp7ImJvdW5kcyI6eyJtYXgiOlswLDAsMTAwMF0sIm1pbiI6WzAsLTEwMDAsMF19LCJjZW50cm9pZCI6WzAsLTUwMCw1MDBdLCJsZW5ndGgiOjE0MTQuMjEzNTYyMzczMDk1LCJxdWFsaXR5Ijoia2VybmVsX2V2YWx1YXRlZCJ9LCJyZW5kZXJfc3RhdHVzIjoicmVuZGVyZWQiLCJzZGtfY29ubmVjdG9yX2ZyYW1lIjp7Im9yaWdpbiI6WzAsLTUwMCw1MDBdLCJ4X2F4aXMiOlswLC0wLjcwNzEwNjc4MTE4NjU0NzUsLTAuNzA3MTA2NzgxMTg2NTQ3NV0sInlfYXhpcyI6WzAsLTAuNzA3MTA2NzgxMTg2NTQ3NCwwLjcwNzEwNjc4MTE4NjU0NzddLCJ6X2F4aXMiOlstMSwwLDBdfSwic2RrX21ldGFkYXRhIjp7fSwic2VtYW50aWNfYmluZGluZ19pZHMiOltdLCJzb3VyY2UiOnsiZ3JhcGhfaWQiOiJncmFwaCIsImtpbmQiOiJtb2RlbF9vdXRwdXQiLCJub2RlX2lkIjoiYm9keSIsIm91dHB1dF9zbG90IjowfX0seyJjaGlsZF9lbnRpdHlfaWRzIjpbImVudGl0eS92ZXJ0ZXgvMSIsImVudGl0eS92ZXJ0ZXgvMyJdLCJjb25uZWN0b3JfYmluZGluZ19zdGF0dXMiOiJvd25lcl9ub3RfcGFydCIsImVudGl0eV9pZCI6ImVudGl0eS9lZGdlLzEiLCJldmFsdWF0ZWRfdGFncyI6W10sImdlb21ldHJ5Ijp7ImRpcmVjdGlvbiI6Wy0wLjcwNzEwNjc4MTE4NjU0NzUsLTAuNzA3MTA2NzgxMTg2NTQ3NSwwXSwib3JpZ2luIjpbMTAwMCwwLDBdLCJ0eXBlIjoibGluZSJ9LCJraW5kIjoiZWRnZSIsInBhcmVudF9lbnRpdHlfaWRzIjpbImVudGl0eS9mYWNlLzEiLCJlbnRpdHkvZmFjZS8zIl0sInByb3BlcnRpZXMiOnsiYm91bmRzIjp7Im1heCI6WzEwMDAsMCwwXSwibWluIjpbMCwtMTAwMCwwXX0sImNlbnRyb2lkIjpbNTAwLC01MDAsMF0sImxlbmd0aCI6MTQxNC4yMTM1NjIzNzMwOTUsInF1YWxpdHkiOiJrZXJuZWxfZXZhbHVhdGVkIn0sInJlbmRlcl9zdGF0dXMiOiJyZW5kZXJlZCIsInNka19jb25uZWN0b3JfZnJhbWUiOnsib3JpZ2luIjpbNTAwLC01MDAsMF0sInhfYXhpcyI6Wy0wLjcwNzEwNjc4MTE4NjU0NzUsLTAuNzA3MTA2NzgxMTg2NTQ3NSwwXSwieV9heGlzIjpbMCwwLDFdLCJ6X2F4aXMiOlstMC43MDcxMDY3ODExODY1NDc2LDAuNzA3MTA2NzgxMTg2NTQ3NiwwXX0sInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImdyYXBoX2lkIjoiZ3JhcGgiLCJraW5kIjoibW9kZWxfb3V0cHV0Iiwibm9kZV9pZCI6ImJvZHkiLCJvdXRwdXRfc2xvdCI6MH19LHsiY2hpbGRfZW50aXR5X2lkcyI6WyJlbnRpdHkvdmVydGV4LzAiLCJlbnRpdHkvdmVydGV4LzMiXSwiY29ubmVjdG9yX2JpbmRpbmdfc3RhdHVzIjoib3duZXJfbm90X3BhcnQiLCJlbnRpdHlfaWQiOiJlbnRpdHkvZWRnZS8yIiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJkaXJlY3Rpb24iOlswLC0xLDBdLCJvcmlnaW4iOlswLDAsMF0sInR5cGUiOiJsaW5lIn0sImtpbmQiOiJlZGdlIiwicGFyZW50X2VudGl0eV9pZHMiOlsiZW50aXR5L2ZhY2UvMCIsImVudGl0eS9mYWNlLzEiXSwicHJvcGVydGllcyI6eyJib3VuZHMiOnsibWF4IjpbMCwwLDBdLCJtaW4iOlswLC0xMDAwLDBdfSwiY2VudHJvaWQiOlswLC01MDAsMF0sImxlbmd0aCI6MTAwMCwicXVhbGl0eSI6Imtlcm5lbF9ldmFsdWF0ZWQifSwicmVuZGVyX3N0YXR1cyI6InJlbmRlcmVkIiwic2RrX2Nvbm5lY3Rvcl9mcmFtZSI6eyJvcmlnaW4iOlswLC01MDAsMF0sInhfYXhpcyI6WzAsLTEsMF0sInlfYXhpcyI6WzAsMCwxXSwiel9heGlzIjpbLTEsMCwwXX0sInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImdyYXBoX2lkIjoiZ3JhcGgiLCJraW5kIjoibW9kZWxfb3V0cHV0Iiwibm9kZV9pZCI6ImJvZHkiLCJvdXRwdXRfc2xvdCI6MH19LHsiY2hpbGRfZW50aXR5X2lkcyI6WyJlbnRpdHkvdmVydGV4LzAiLCJlbnRpdHkvdmVydGV4LzEiXSwiY29ubmVjdG9yX2JpbmRpbmdfc3RhdHVzIjoib3duZXJfbm90X3BhcnQiLCJlbnRpdHlfaWQiOiJlbnRpdHkvZWRnZS8zIiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJkaXJlY3Rpb24iOlsxLDAsMF0sIm9yaWdpbiI6WzAsMCwwXSwidHlwZSI6ImxpbmUifSwia2luZCI6ImVkZ2UiLCJwYXJlbnRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZmFjZS8xIiwiZW50aXR5L2ZhY2UvMiJdLCJwcm9wZXJ0aWVzIjp7ImJvdW5kcyI6eyJtYXgiOlsxMDAwLDAsMF0sIm1pbiI6WzAsMCwwXX0sImNlbnRyb2lkIjpbNTAwLDAsMF0sImxlbmd0aCI6MTAwMCwicXVhbGl0eSI6Imtlcm5lbF9ldmFsdWF0ZWQifSwicmVuZGVyX3N0YXR1cyI6InJlbmRlcmVkIiwic2RrX2Nvbm5lY3Rvcl9mcmFtZSI6eyJvcmlnaW4iOls1MDAsMCwwXSwieF9heGlzIjpbMSwwLDBdLCJ5X2F4aXMiOlswLDAsMV0sInpfYXhpcyI6WzAsLTEsMF19LCJzZGtfbWV0YWRhdGEiOnt9LCJzZW1hbnRpY19iaW5kaW5nX2lkcyI6W10sInNvdXJjZSI6eyJncmFwaF9pZCI6ImdyYXBoIiwia2luZCI6Im1vZGVsX291dHB1dCIsIm5vZGVfaWQiOiJib2R5Iiwib3V0cHV0X3Nsb3QiOjB9fSx7ImNoaWxkX2VudGl0eV9pZHMiOlsiZW50aXR5L3ZlcnRleC8xIiwiZW50aXR5L3ZlcnRleC8yIl0sImNvbm5lY3Rvcl9iaW5kaW5nX3N0YXR1cyI6Im93bmVyX25vdF9wYXJ0IiwiZW50aXR5X2lkIjoiZW50aXR5L2VkZ2UvNCIsImV2YWx1YXRlZF90YWdzIjpbXSwiZ2VvbWV0cnkiOnsiZGlyZWN0aW9uIjpbLTAuNzA3MTA2NzgxMTg2NTQ3NSwwLDAuNzA3MTA2NzgxMTg2NTQ3NV0sIm9yaWdpbiI6WzEwMDAsMCwwXSwidHlwZSI6ImxpbmUifSwia2luZCI6ImVkZ2UiLCJwYXJlbnRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZmFjZS8yIiwiZW50aXR5L2ZhY2UvMyJdLCJwcm9wZXJ0aWVzIjp7ImJvdW5kcyI6eyJtYXgiOlsxMDAwLDAsMTAwMF0sIm1pbiI6WzAsMCwwXX0sImNlbnRyb2lkIjpbNTAwLDAsNTAwXSwibGVuZ3RoIjoxNDE0LjIxMzU2MjM3MzA5NSwicXVhbGl0eSI6Imtlcm5lbF9ldmFsdWF0ZWQifSwicmVuZGVyX3N0YXR1cyI6InJlbmRlcmVkIiwic2RrX2Nvbm5lY3Rvcl9mcmFtZSI6eyJvcmlnaW4iOls1MDAsMCw1MDBdLCJ4X2F4aXMiOlstMC43MDcxMDY3ODExODY1NDc1LDAsMC43MDcxMDY3ODExODY1NDc1XSwieV9heGlzIjpbMC43MDcxMDY3ODExODY1NDc0LDAsMC43MDcxMDY3ODExODY1NDc3XSwiel9heGlzIjpbMCwxLDBdfSwic2RrX21ldGFkYXRhIjp7fSwic2VtYW50aWNfYmluZGluZ19pZHMiOltdLCJzb3VyY2UiOnsiZ3JhcGhfaWQiOiJncmFwaCIsImtpbmQiOiJtb2RlbF9vdXRwdXQiLCJub2RlX2lkIjoiYm9keSIsIm91dHB1dF9zbG90IjowfX0seyJjaGlsZF9lbnRpdHlfaWRzIjpbImVudGl0eS92ZXJ0ZXgvMCIsImVudGl0eS92ZXJ0ZXgvMiJdLCJjb25uZWN0b3JfYmluZGluZ19zdGF0dXMiOiJvd25lcl9ub3RfcGFydCIsImVudGl0eV9pZCI6ImVudGl0eS9lZGdlLzUiLCJldmFsdWF0ZWRfdGFncyI6W10sImdlb21ldHJ5Ijp7ImRpcmVjdGlvbiI6WzAsMCwxXSwib3JpZ2luIjpbMCwwLDBdLCJ0eXBlIjoibGluZSJ9LCJraW5kIjoiZWRnZSIsInBhcmVudF9lbnRpdHlfaWRzIjpbImVudGl0eS9mYWNlLzAiLCJlbnRpdHkvZmFjZS8yIl0sInByb3BlcnRpZXMiOnsiYm91bmRzIjp7Im1heCI6WzAsMCwxMDAwXSwibWluIjpbMCwwLDBdfSwiY2VudHJvaWQiOlswLDAsNTAwXSwibGVuZ3RoIjoxMDAwLCJxdWFsaXR5Ijoia2VybmVsX2V2YWx1YXRlZCJ9LCJyZW5kZXJfc3RhdHVzIjoicmVuZGVyZWQiLCJzZGtfY29ubmVjdG9yX2ZyYW1lIjp7Im9yaWdpbiI6WzAsMCw1MDBdLCJ4X2F4aXMiOlswLDAsMV0sInlfYXhpcyI6WzAsMSwwXSwiel9heGlzIjpbLTEsMCwwXX0sInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImdyYXBoX2lkIjoiZ3JhcGgiLCJraW5kIjoibW9kZWxfb3V0cHV0Iiwibm9kZV9pZCI6ImJvZHkiLCJvdXRwdXRfc2xvdCI6MH19LHsiY2hpbGRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZWRnZS8wIiwiZW50aXR5L2VkZ2UvMiIsImVudGl0eS9lZGdlLzUiXSwiY29ubmVjdG9yX2JpbmRpbmdfc3RhdHVzIjoib3duZXJfbm90X3BhcnQiLCJlbnRpdHlfaWQiOiJlbnRpdHkvZmFjZS8wIiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJub3JtYWwiOlstMSwwLDBdLCJvcmlnaW4iOlswLC0zMzMuMzMzMzMzMzMzMzMzMywzMzMuMzMzMzMzMzMzMzMzM10sInR5cGUiOiJwbGFuZSIsInhfZGlyZWN0aW9uIjpbMCwwLDFdfSwia2luZCI6ImZhY2UiLCJwYXJlbnRfZW50aXR5X2lkcyI6WyJlbnRpdHkvc29saWQvMCJdLCJwcm9wZXJ0aWVzIjp7ImFyZWEiOjUwMDAwMCwiYm91bmRzIjp7Im1heCI6WzAsMCwxMDAwXSwibWluIjpbMCwtMTAwMCwwXX0sImNlbnRyb2lkIjpbMCwtMzMzLjMzMzMzMzMzMzMzMzMsMzMzLjMzMzMzMzMzMzMzMzNdLCJvcmllbnRhdGlvbiI6ImZvcndhcmQiLCJxdWFsaXR5Ijoia2VybmVsX2V2YWx1YXRlZCJ9LCJyZW5kZXJfc3RhdHVzIjoicmVuZGVyZWQiLCJzZGtfY29ubmVjdG9yX2ZyYW1lIjp7Im9yaWdpbiI6WzAsLTMzMy4zMzMzMzMzMzMzMzMzLDMzMy4zMzMzMzMzMzMzMzMzXSwieF9heGlzIjpbMCwwLDFdLCJ5X2F4aXMiOlswLDEsMF0sInpfYXhpcyI6Wy0xLDAsMF19LCJzZGtfbWV0YWRhdGEiOnt9LCJzZW1hbnRpY19iaW5kaW5nX2lkcyI6W10sInNvdXJjZSI6eyJncmFwaF9pZCI6ImdyYXBoIiwia2luZCI6Im1vZGVsX291dHB1dCIsIm5vZGVfaWQiOiJib2R5Iiwib3V0cHV0X3Nsb3QiOjB9fSx7ImNoaWxkX2VudGl0eV9pZHMiOlsiZW50aXR5L2VkZ2UvMSIsImVudGl0eS9lZGdlLzIiLCJlbnRpdHkvZWRnZS8zIl0sImNvbm5lY3Rvcl9iaW5kaW5nX3N0YXR1cyI6Im93bmVyX25vdF9wYXJ0IiwiZW50aXR5X2lkIjoiZW50aXR5L2ZhY2UvMSIsImV2YWx1YXRlZF90YWdzIjpbXSwiZ2VvbWV0cnkiOnsibm9ybWFsIjpbMCwwLC0xXSwib3JpZ2luIjpbMzMzLjMzMzMzMzMzMzMzMzMsLTMzMy4zMzMzMzMzMzMzMzMzLDBdLCJ0eXBlIjoicGxhbmUiLCJ4X2RpcmVjdGlvbiI6WzEsMCwwXX0sImtpbmQiOiJmYWNlIiwicGFyZW50X2VudGl0eV9pZHMiOlsiZW50aXR5L3NvbGlkLzAiXSwicHJvcGVydGllcyI6eyJhcmVhIjo1MDAwMDAsImJvdW5kcyI6eyJtYXgiOlsxMDAwLDAsMF0sIm1pbiI6WzAsLTEwMDAsMF19LCJjZW50cm9pZCI6WzMzMy4zMzMzMzMzMzMzMzMzLC0zMzMuMzMzMzMzMzMzMzMzMywwXSwib3JpZW50YXRpb24iOiJmb3J3YXJkIiwicXVhbGl0eSI6Imtlcm5lbF9ldmFsdWF0ZWQifSwicmVuZGVyX3N0YXR1cyI6InJlbmRlcmVkIiwic2RrX2Nvbm5lY3Rvcl9mcmFtZSI6eyJvcmlnaW4iOlszMzMuMzMzMzMzMzMzMzMzMywtMzMzLjMzMzMzMzMzMzMzMzMsMF0sInhfYXhpcyI6WzEsMCwwXSwieV9heGlzIjpbMCwtMSwwXSwiel9heGlzIjpbMCwwLC0xXX0sInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImdyYXBoX2lkIjoiZ3JhcGgiLCJraW5kIjoibW9kZWxfb3V0cHV0Iiwibm9kZV9pZCI6ImJvZHkiLCJvdXRwdXRfc2xvdCI6MH19LHsiY2hpbGRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZWRnZS8zIiwiZW50aXR5L2VkZ2UvNCIsImVudGl0eS9lZGdlLzUiXSwiY29ubmVjdG9yX2JpbmRpbmdfc3RhdHVzIjoib3duZXJfbm90X3BhcnQiLCJlbnRpdHlfaWQiOiJlbnRpdHkvZmFjZS8yIiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJub3JtYWwiOlswLDEsMF0sIm9yaWdpbiI6WzMzMy4zMzMzMzMzMzMzMzMzLDAsMzMzLjMzMzMzMzMzMzMzMzNdLCJ0eXBlIjoicGxhbmUiLCJ4X2RpcmVjdGlvbiI6WzEsMCwwXX0sImtpbmQiOiJmYWNlIiwicGFyZW50X2VudGl0eV9pZHMiOlsiZW50aXR5L3NvbGlkLzAiXSwicHJvcGVydGllcyI6eyJhcmVhIjo1MDAwMDAsImJvdW5kcyI6eyJtYXgiOlsxMDAwLDAsMTAwMF0sIm1pbiI6WzAsMCwwXX0sImNlbnRyb2lkIjpbMzMzLjMzMzMzMzMzMzMzMzMsMCwzMzMuMzMzMzMzMzMzMzMzM10sIm9yaWVudGF0aW9uIjoiZm9yd2FyZCIsInF1YWxpdHkiOiJrZXJuZWxfZXZhbHVhdGVkIn0sInJlbmRlcl9zdGF0dXMiOiJyZW5kZXJlZCIsInNka19jb25uZWN0b3JfZnJhbWUiOnsib3JpZ2luIjpbMzMzLjMzMzMzMzMzMzMzMzMsMCwzMzMuMzMzMzMzMzMzMzMzM10sInhfYXhpcyI6WzEsMCwwXSwieV9heGlzIjpbMCwwLC0xXSwiel9heGlzIjpbMCwxLDBdfSwic2RrX21ldGFkYXRhIjp7fSwic2VtYW50aWNfYmluZGluZ19pZHMiOltdLCJzb3VyY2UiOnsiZ3JhcGhfaWQiOiJncmFwaCIsImtpbmQiOiJtb2RlbF9vdXRwdXQiLCJub2RlX2lkIjoiYm9keSIsIm91dHB1dF9zbG90IjowfX0seyJjaGlsZF9lbnRpdHlfaWRzIjpbImVudGl0eS9lZGdlLzAiLCJlbnRpdHkvZWRnZS8xIiwiZW50aXR5L2VkZ2UvNCJdLCJjb25uZWN0b3JfYmluZGluZ19zdGF0dXMiOiJvd25lcl9ub3RfcGFydCIsImVudGl0eV9pZCI6ImVudGl0eS9mYWNlLzMiLCJldmFsdWF0ZWRfdGFncyI6W10sImdlb21ldHJ5Ijp7Im5vcm1hbCI6WzAuNTc3MzUwMjY5MTg5NjI1OCwtMC41NzczNTAyNjkxODk2MjU4LDAuNTc3MzUwMjY5MTg5NjI1OF0sIm9yaWdpbiI6WzMzMy4zMzMzMzMzMzMzMzMzLC0zMzMuMzMzMzMzMzMzMzMzMywzMzMuMzMzMzMzMzMzMzMzM10sInR5cGUiOiJwbGFuZSIsInhfZGlyZWN0aW9uIjpbLTAuNzA3MTA2NzgxMTg2NTQ3NSwwLDAuNzA3MTA2NzgxMTg2NTQ3NV19LCJraW5kIjoiZmFjZSIsInBhcmVudF9lbnRpdHlfaWRzIjpbImVudGl0eS9zb2xpZC8wIl0sInByb3BlcnRpZXMiOnsiYXJlYSI6ODY2MDI1LjQwMzc4NDQzODYsImJvdW5kcyI6eyJtYXgiOlsxMDAwLDAsMTAwMF0sIm1pbiI6WzAsLTEwMDAsMF19LCJjZW50cm9pZCI6WzMzMy4zMzMzMzMzMzMzMzMzLC0zMzMuMzMzMzMzMzMzMzMzMywzMzMuMzMzMzMzMzMzMzMzM10sIm9yaWVudGF0aW9uIjoiZm9yd2FyZCIsInF1YWxpdHkiOiJrZXJuZWxfZXZhbHVhdGVkIn0sInJlbmRlcl9zdGF0dXMiOiJyZW5kZXJlZCIsInNka19jb25uZWN0b3JfZnJhbWUiOnsib3JpZ2luIjpbMzMzLjMzMzMzMzMzMzMzMzMsLTMzMy4zMzMzMzMzMzMzMzMzLDMzMy4zMzMzMzMzMzMzMzMzXSwieF9heGlzIjpbLTAuNzA3MTA2NzgxMTg2NTQ3NSwwLDAuNzA3MTA2NzgxMTg2NTQ3NV0sInlfYXhpcyI6Wy0wLjQwODI0ODI5MDQ2Mzg2MzEsLTAuODE2NDk2NTgwOTI3NzI2MSwtMC40MDgyNDgyOTA0NjM4NjMxXSwiel9heGlzIjpbMC41NzczNTAyNjkxODk2MjU4LC0wLjU3NzM1MDI2OTE4OTYyNTgsMC41NzczNTAyNjkxODk2MjU4XX0sInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImdyYXBoX2lkIjoiZ3JhcGgiLCJraW5kIjoibW9kZWxfb3V0cHV0Iiwibm9kZV9pZCI6ImJvZHkiLCJvdXRwdXRfc2xvdCI6MH19LHsiY2hpbGRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZmFjZS8wIiwiZW50aXR5L2ZhY2UvMSIsImVudGl0eS9mYWNlLzIiLCJlbnRpdHkvZmFjZS8zIl0sImNvbm5lY3Rvcl9iaW5kaW5nX3N0YXR1cyI6Im5vdF9hcHBsaWNhYmxlIiwiZW50aXR5X2lkIjoiZW50aXR5L3NvbGlkLzAiLCJldmFsdWF0ZWRfdGFncyI6W10sImdlb21ldHJ5Ijp7InR5cGUiOiJicmVwX3NvbGlkIn0sImtpbmQiOiJzb2xpZCIsInBhcmVudF9lbnRpdHlfaWRzIjpbXSwicHJvcGVydGllcyI6eyJib3VuZHMiOnsibWF4IjpbMTAwMCwwLDEwMDBdLCJtaW4iOlswLC0xMDAwLDBdfSwiY2VudHJvaWQiOlsyNTAsLTI1MCwyNTBdLCJxdWFsaXR5Ijoia2VybmVsX2V2YWx1YXRlZCIsInN1cmZhY2VfYXJlYSI6MjM2NjAyNS40MDM3ODQ0Mzg1LCJ2b2x1bWUiOjE2NjY2NjY2Ni42NjY2NjY2Nn0sInJlbmRlcl9zdGF0dXMiOiJyZW5kZXJlZCIsInNka19jb25uZWN0b3JfZnJhbWUiOm51bGwsInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImdyYXBoX2lkIjoiZ3JhcGgiLCJraW5kIjoibW9kZWxfb3V0cHV0Iiwibm9kZV9pZCI6ImJvZHkiLCJvdXRwdXRfc2xvdCI6MH19LHsiY2hpbGRfZW50aXR5X2lkcyI6W10sImNvbm5lY3Rvcl9iaW5kaW5nX3N0YXR1cyI6Im93bmVyX25vdF9wYXJ0IiwiZW50aXR5X2lkIjoiZW50aXR5L3ZlcnRleC8wIiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJwb3NpdGlvbiI6WzAsMCwwXSwidHlwZSI6InBvaW50In0sImtpbmQiOiJ2ZXJ0ZXgiLCJwYXJlbnRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZWRnZS8yIiwiZW50aXR5L2VkZ2UvMyIsImVudGl0eS9lZGdlLzUiXSwicHJvcGVydGllcyI6eyJib3VuZHMiOnsibWF4IjpbMCwwLDBdLCJtaW4iOlswLDAsMF19LCJwb3NpdGlvbiI6WzAsMCwwXSwicXVhbGl0eSI6Imtlcm5lbF9ldmFsdWF0ZWQifSwicmVuZGVyX3N0YXR1cyI6InJlbmRlcmVkIiwic2RrX2Nvbm5lY3Rvcl9mcmFtZSI6eyJvcmlnaW4iOlswLDAsMF0sInhfYXhpcyI6WzEsMCwwXSwieV9heGlzIjpbMCwxLDBdLCJ6X2F4aXMiOlswLDAsMV19LCJzZGtfbWV0YWRhdGEiOnt9LCJzZW1hbnRpY19iaW5kaW5nX2lkcyI6W10sInNvdXJjZSI6eyJncmFwaF9pZCI6ImdyYXBoIiwia2luZCI6Im1vZGVsX291dHB1dCIsIm5vZGVfaWQiOiJib2R5Iiwib3V0cHV0X3Nsb3QiOjB9fSx7ImNoaWxkX2VudGl0eV9pZHMiOltdLCJjb25uZWN0b3JfYmluZGluZ19zdGF0dXMiOiJvd25lcl9ub3RfcGFydCIsImVudGl0eV9pZCI6ImVudGl0eS92ZXJ0ZXgvMSIsImV2YWx1YXRlZF90YWdzIjpbXSwiZ2VvbWV0cnkiOnsicG9zaXRpb24iOlsxMDAwLDAsMF0sInR5cGUiOiJwb2ludCJ9LCJraW5kIjoidmVydGV4IiwicGFyZW50X2VudGl0eV9pZHMiOlsiZW50aXR5L2VkZ2UvMSIsImVudGl0eS9lZGdlLzMiLCJlbnRpdHkvZWRnZS80Il0sInByb3BlcnRpZXMiOnsiYm91bmRzIjp7Im1heCI6WzEwMDAsMCwwXSwibWluIjpbMTAwMCwwLDBdfSwicG9zaXRpb24iOlsxMDAwLDAsMF0sInF1YWxpdHkiOiJrZXJuZWxfZXZhbHVhdGVkIn0sInJlbmRlcl9zdGF0dXMiOiJyZW5kZXJlZCIsInNka19jb25uZWN0b3JfZnJhbWUiOnsib3JpZ2luIjpbMTAwMCwwLDBdLCJ4X2F4aXMiOlsxLDAsMF0sInlfYXhpcyI6WzAsMSwwXSwiel9heGlzIjpbMCwwLDFdfSwic2RrX21ldGFkYXRhIjp7fSwic2VtYW50aWNfYmluZGluZ19pZHMiOltdLCJzb3VyY2UiOnsiZ3JhcGhfaWQiOiJncmFwaCIsImtpbmQiOiJtb2RlbF9vdXRwdXQiLCJub2RlX2lkIjoiYm9keSIsIm91dHB1dF9zbG90IjowfX0seyJjaGlsZF9lbnRpdHlfaWRzIjpbXSwiY29ubmVjdG9yX2JpbmRpbmdfc3RhdHVzIjoib3duZXJfbm90X3BhcnQiLCJlbnRpdHlfaWQiOiJlbnRpdHkvdmVydGV4LzIiLCJldmFsdWF0ZWRfdGFncyI6W10sImdlb21ldHJ5Ijp7InBvc2l0aW9uIjpbMCwwLDEwMDBdLCJ0eXBlIjoicG9pbnQifSwia2luZCI6InZlcnRleCIsInBhcmVudF9lbnRpdHlfaWRzIjpbImVudGl0eS9lZGdlLzAiLCJlbnRpdHkvZWRnZS80IiwiZW50aXR5L2VkZ2UvNSJdLCJwcm9wZXJ0aWVzIjp7ImJvdW5kcyI6eyJtYXgiOlswLDAsMTAwMF0sIm1pbiI6WzAsMCwxMDAwXX0sInBvc2l0aW9uIjpbMCwwLDEwMDBdLCJxdWFsaXR5Ijoia2VybmVsX2V2YWx1YXRlZCJ9LCJyZW5kZXJfc3RhdHVzIjoicmVuZGVyZWQiLCJzZGtfY29ubmVjdG9yX2ZyYW1lIjp7Im9yaWdpbiI6WzAsMCwxMDAwXSwieF9heGlzIjpbMSwwLDBdLCJ5X2F4aXMiOlswLDEsMF0sInpfYXhpcyI6WzAsMCwxXX0sInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImdyYXBoX2lkIjoiZ3JhcGgiLCJraW5kIjoibW9kZWxfb3V0cHV0Iiwibm9kZV9pZCI6ImJvZHkiLCJvdXRwdXRfc2xvdCI6MH19LHsiY2hpbGRfZW50aXR5X2lkcyI6W10sImNvbm5lY3Rvcl9iaW5kaW5nX3N0YXR1cyI6Im93bmVyX25vdF9wYXJ0IiwiZW50aXR5X2lkIjoiZW50aXR5L3ZlcnRleC8zIiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJwb3NpdGlvbiI6WzAsLTEwMDAsMF0sInR5cGUiOiJwb2ludCJ9LCJraW5kIjoidmVydGV4IiwicGFyZW50X2VudGl0eV9pZHMiOlsiZW50aXR5L2VkZ2UvMCIsImVudGl0eS9lZGdlLzEiLCJlbnRpdHkvZWRnZS8yIl0sInByb3BlcnRpZXMiOnsiYm91bmRzIjp7Im1heCI6WzAsLTEwMDAsMF0sIm1pbiI6WzAsLTEwMDAsMF19LCJwb3NpdGlvbiI6WzAsLTEwMDAsMF0sInF1YWxpdHkiOiJrZXJuZWxfZXZhbHVhdGVkIn0sInJlbmRlcl9zdGF0dXMiOiJyZW5kZXJlZCIsInNka19jb25uZWN0b3JfZnJhbWUiOnsib3JpZ2luIjpbMCwtMTAwMCwwXSwieF9heGlzIjpbMSwwLDBdLCJ5X2F4aXMiOlswLDEsMF0sInpfYXhpcyI6WzAsMCwxXX0sInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImdyYXBoX2lkIjoiZ3JhcGgiLCJraW5kIjoibW9kZWxfb3V0cHV0Iiwibm9kZV9pZCI6ImJvZHkiLCJvdXRwdXRfc2xvdCI6MH19XSwiZmFjZV9ncm91cHMiOlt7ImVudGl0eV9pZCI6ImVudGl0eS9mYWNlLzAiLCJmaXJzdF9pbmRleCI6MCwiZ3JvdXBfaWQiOjAsImluZGV4X2NvdW50IjozLCJtZXNoX2luZGV4IjowLCJwcmltaXRpdmVfaW5kZXgiOjB9LHsiZW50aXR5X2lkIjoiZW50aXR5L2ZhY2UvMSIsImZpcnN0X2luZGV4IjozLCJncm91cF9pZCI6MSwiaW5kZXhfY291bnQiOjMsIm1lc2hfaW5kZXgiOjAsInByaW1pdGl2ZV9pbmRleCI6MH0seyJlbnRpdHlfaWQiOiJlbnRpdHkvZmFjZS8yIiwiZmlyc3RfaW5kZXgiOjYsImdyb3VwX2lkIjoyLCJpbmRleF9jb3VudCI6MywibWVzaF9pbmRleCI6MCwicHJpbWl0aXZlX2luZGV4IjowfSx7ImVudGl0eV9pZCI6ImVudGl0eS9mYWNlLzMiLCJmaXJzdF9pbmRleCI6OSwiZ3JvdXBfaWQiOjMsImluZGV4X2NvdW50IjozLCJtZXNoX2luZGV4IjowLCJwcmltaXRpdmVfaW5kZXgiOjB9XSwiZ2VvbWV0cnlfYXNzZXRfaWQiOiJzaGEyNTY6MjAwNTIzNDE0ZGUyODFhOGExNTUwODZhNTRjZDBmODgwZjY0MDQ4NTc2NWE0OGNjZGQ4ZWMyMzBmNjdkZDE3YyIsImdlb21ldHJ5X2VuZ2luZSI6eyJuYW1lIjoiT3BlbkNhc2NhZGUiLCJwcm9maWxlIjoib2NwLWV2YWx1YXRlZC1wcm9wZXJ0aWVzLTEiLCJ2ZXJzaW9uIjoiZml4dHVyZSJ9LCJzY2hlbWFfdmVyc2lvbiI6IjEuMCJ9", + "valid": true + }, + { + "expected": null, + "name": "valid_entity_source_model_topology", + "payload_base64": "eyJkZWZpbml0aW9uX2lkIjoiZGVmaW5pdGlvbi9yb290L3NoYXBlL21hbnVhbC9maXh0dXJlIiwiZWRnZV9hc3NldF9pZCI6InNoYTI1Njo2Y2U0ODFkYjFmZTEzYmNmZTdhMjM3NDQyM2U2YzcyZDc1N2Q1MWM5OTE2OGIyODdjNTJmNTNhYTYyNWNjZDY3IiwiZWRnZV9ncm91cHMiOlt7ImVudGl0eV9pZCI6ImVudGl0eS9lZGdlLzAiLCJmaXJzdF9pbmRleCI6MCwiZ3JvdXBfaWQiOjAsImluZGV4X2NvdW50IjoyLCJtZXNoX2luZGV4IjowLCJwcmltaXRpdmVfaW5kZXgiOjB9LHsiZW50aXR5X2lkIjoiZW50aXR5L2VkZ2UvMSIsImZpcnN0X2luZGV4IjoyLCJncm91cF9pZCI6MSwiaW5kZXhfY291bnQiOjIsIm1lc2hfaW5kZXgiOjAsInByaW1pdGl2ZV9pbmRleCI6MH0seyJlbnRpdHlfaWQiOiJlbnRpdHkvZWRnZS8yIiwiZmlyc3RfaW5kZXgiOjQsImdyb3VwX2lkIjoyLCJpbmRleF9jb3VudCI6MiwibWVzaF9pbmRleCI6MCwicHJpbWl0aXZlX2luZGV4IjowfSx7ImVudGl0eV9pZCI6ImVudGl0eS9lZGdlLzMiLCJmaXJzdF9pbmRleCI6NiwiZ3JvdXBfaWQiOjMsImluZGV4X2NvdW50IjoyLCJtZXNoX2luZGV4IjowLCJwcmltaXRpdmVfaW5kZXgiOjB9LHsiZW50aXR5X2lkIjoiZW50aXR5L2VkZ2UvNCIsImZpcnN0X2luZGV4Ijo4LCJncm91cF9pZCI6NCwiaW5kZXhfY291bnQiOjIsIm1lc2hfaW5kZXgiOjAsInByaW1pdGl2ZV9pbmRleCI6MH0seyJlbnRpdHlfaWQiOiJlbnRpdHkvZWRnZS81IiwiZmlyc3RfaW5kZXgiOjEwLCJncm91cF9pZCI6NSwiaW5kZXhfY291bnQiOjIsIm1lc2hfaW5kZXgiOjAsInByaW1pdGl2ZV9pbmRleCI6MH1dLCJlbnRpdGllcyI6W3siY2hpbGRfZW50aXR5X2lkcyI6WyJlbnRpdHkvdmVydGV4LzIiLCJlbnRpdHkvdmVydGV4LzMiXSwiY29ubmVjdG9yX2JpbmRpbmdfc3RhdHVzIjoib3duZXJfbm90X3BhcnQiLCJlbnRpdHlfaWQiOiJlbnRpdHkvZWRnZS8wIiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJkaXJlY3Rpb24iOlswLC0wLjcwNzEwNjc4MTE4NjU0NzUsLTAuNzA3MTA2NzgxMTg2NTQ3NV0sIm9yaWdpbiI6WzAsMCwxMDAwXSwidHlwZSI6ImxpbmUifSwia2luZCI6ImVkZ2UiLCJwYXJlbnRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZmFjZS8wIiwiZW50aXR5L2ZhY2UvMyJdLCJwcm9wZXJ0aWVzIjp7ImJvdW5kcyI6eyJtYXgiOlswLDAsMTAwMF0sIm1pbiI6WzAsLTEwMDAsMF19LCJjZW50cm9pZCI6WzAsLTUwMCw1MDBdLCJsZW5ndGgiOjE0MTQuMjEzNTYyMzczMDk1LCJxdWFsaXR5Ijoia2VybmVsX2V2YWx1YXRlZCJ9LCJyZW5kZXJfc3RhdHVzIjoicmVuZGVyZWQiLCJzZGtfY29ubmVjdG9yX2ZyYW1lIjp7Im9yaWdpbiI6WzAsLTUwMCw1MDBdLCJ4X2F4aXMiOlswLC0wLjcwNzEwNjc4MTE4NjU0NzUsLTAuNzA3MTA2NzgxMTg2NTQ3NV0sInlfYXhpcyI6WzAsLTAuNzA3MTA2NzgxMTg2NTQ3NCwwLjcwNzEwNjc4MTE4NjU0NzddLCJ6X2F4aXMiOlstMSwwLDBdfSwic2RrX21ldGFkYXRhIjp7fSwic2VtYW50aWNfYmluZGluZ19pZHMiOltdLCJzb3VyY2UiOnsiZ3JhcGhfaWQiOiJncmFwaCIsImtpbmQiOiJtb2RlbF90b3BvbG9neSIsIm5vZGVfaWQiOiJib2R5Iiwib3V0cHV0X3Nsb3QiOjAsInRvcG9faWQiOiJlbnRpdHkvZWRnZS8wIiwidG9wb2xvZ3lfa2luZCI6IkVER0UifX0seyJjaGlsZF9lbnRpdHlfaWRzIjpbImVudGl0eS92ZXJ0ZXgvMSIsImVudGl0eS92ZXJ0ZXgvMyJdLCJjb25uZWN0b3JfYmluZGluZ19zdGF0dXMiOiJvd25lcl9ub3RfcGFydCIsImVudGl0eV9pZCI6ImVudGl0eS9lZGdlLzEiLCJldmFsdWF0ZWRfdGFncyI6W10sImdlb21ldHJ5Ijp7ImRpcmVjdGlvbiI6Wy0wLjcwNzEwNjc4MTE4NjU0NzUsLTAuNzA3MTA2NzgxMTg2NTQ3NSwwXSwib3JpZ2luIjpbMTAwMCwwLDBdLCJ0eXBlIjoibGluZSJ9LCJraW5kIjoiZWRnZSIsInBhcmVudF9lbnRpdHlfaWRzIjpbImVudGl0eS9mYWNlLzEiLCJlbnRpdHkvZmFjZS8zIl0sInByb3BlcnRpZXMiOnsiYm91bmRzIjp7Im1heCI6WzEwMDAsMCwwXSwibWluIjpbMCwtMTAwMCwwXX0sImNlbnRyb2lkIjpbNTAwLC01MDAsMF0sImxlbmd0aCI6MTQxNC4yMTM1NjIzNzMwOTUsInF1YWxpdHkiOiJrZXJuZWxfZXZhbHVhdGVkIn0sInJlbmRlcl9zdGF0dXMiOiJyZW5kZXJlZCIsInNka19jb25uZWN0b3JfZnJhbWUiOnsib3JpZ2luIjpbNTAwLC01MDAsMF0sInhfYXhpcyI6Wy0wLjcwNzEwNjc4MTE4NjU0NzUsLTAuNzA3MTA2NzgxMTg2NTQ3NSwwXSwieV9heGlzIjpbMCwwLDFdLCJ6X2F4aXMiOlstMC43MDcxMDY3ODExODY1NDc2LDAuNzA3MTA2NzgxMTg2NTQ3NiwwXX0sInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImdyYXBoX2lkIjoiZ3JhcGgiLCJraW5kIjoibW9kZWxfdG9wb2xvZ3kiLCJub2RlX2lkIjoiYm9keSIsIm91dHB1dF9zbG90IjowLCJ0b3BvX2lkIjoiZW50aXR5L2VkZ2UvMSIsInRvcG9sb2d5X2tpbmQiOiJFREdFIn19LHsiY2hpbGRfZW50aXR5X2lkcyI6WyJlbnRpdHkvdmVydGV4LzAiLCJlbnRpdHkvdmVydGV4LzMiXSwiY29ubmVjdG9yX2JpbmRpbmdfc3RhdHVzIjoib3duZXJfbm90X3BhcnQiLCJlbnRpdHlfaWQiOiJlbnRpdHkvZWRnZS8yIiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJkaXJlY3Rpb24iOlswLC0xLDBdLCJvcmlnaW4iOlswLDAsMF0sInR5cGUiOiJsaW5lIn0sImtpbmQiOiJlZGdlIiwicGFyZW50X2VudGl0eV9pZHMiOlsiZW50aXR5L2ZhY2UvMCIsImVudGl0eS9mYWNlLzEiXSwicHJvcGVydGllcyI6eyJib3VuZHMiOnsibWF4IjpbMCwwLDBdLCJtaW4iOlswLC0xMDAwLDBdfSwiY2VudHJvaWQiOlswLC01MDAsMF0sImxlbmd0aCI6MTAwMCwicXVhbGl0eSI6Imtlcm5lbF9ldmFsdWF0ZWQifSwicmVuZGVyX3N0YXR1cyI6InJlbmRlcmVkIiwic2RrX2Nvbm5lY3Rvcl9mcmFtZSI6eyJvcmlnaW4iOlswLC01MDAsMF0sInhfYXhpcyI6WzAsLTEsMF0sInlfYXhpcyI6WzAsMCwxXSwiel9heGlzIjpbLTEsMCwwXX0sInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImdyYXBoX2lkIjoiZ3JhcGgiLCJraW5kIjoibW9kZWxfdG9wb2xvZ3kiLCJub2RlX2lkIjoiYm9keSIsIm91dHB1dF9zbG90IjowLCJ0b3BvX2lkIjoiZW50aXR5L2VkZ2UvMiIsInRvcG9sb2d5X2tpbmQiOiJFREdFIn19LHsiY2hpbGRfZW50aXR5X2lkcyI6WyJlbnRpdHkvdmVydGV4LzAiLCJlbnRpdHkvdmVydGV4LzEiXSwiY29ubmVjdG9yX2JpbmRpbmdfc3RhdHVzIjoib3duZXJfbm90X3BhcnQiLCJlbnRpdHlfaWQiOiJlbnRpdHkvZWRnZS8zIiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJkaXJlY3Rpb24iOlsxLDAsMF0sIm9yaWdpbiI6WzAsMCwwXSwidHlwZSI6ImxpbmUifSwia2luZCI6ImVkZ2UiLCJwYXJlbnRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZmFjZS8xIiwiZW50aXR5L2ZhY2UvMiJdLCJwcm9wZXJ0aWVzIjp7ImJvdW5kcyI6eyJtYXgiOlsxMDAwLDAsMF0sIm1pbiI6WzAsMCwwXX0sImNlbnRyb2lkIjpbNTAwLDAsMF0sImxlbmd0aCI6MTAwMCwicXVhbGl0eSI6Imtlcm5lbF9ldmFsdWF0ZWQifSwicmVuZGVyX3N0YXR1cyI6InJlbmRlcmVkIiwic2RrX2Nvbm5lY3Rvcl9mcmFtZSI6eyJvcmlnaW4iOls1MDAsMCwwXSwieF9heGlzIjpbMSwwLDBdLCJ5X2F4aXMiOlswLDAsMV0sInpfYXhpcyI6WzAsLTEsMF19LCJzZGtfbWV0YWRhdGEiOnt9LCJzZW1hbnRpY19iaW5kaW5nX2lkcyI6W10sInNvdXJjZSI6eyJncmFwaF9pZCI6ImdyYXBoIiwia2luZCI6Im1vZGVsX3RvcG9sb2d5Iiwibm9kZV9pZCI6ImJvZHkiLCJvdXRwdXRfc2xvdCI6MCwidG9wb19pZCI6ImVudGl0eS9lZGdlLzMiLCJ0b3BvbG9neV9raW5kIjoiRURHRSJ9fSx7ImNoaWxkX2VudGl0eV9pZHMiOlsiZW50aXR5L3ZlcnRleC8xIiwiZW50aXR5L3ZlcnRleC8yIl0sImNvbm5lY3Rvcl9iaW5kaW5nX3N0YXR1cyI6Im93bmVyX25vdF9wYXJ0IiwiZW50aXR5X2lkIjoiZW50aXR5L2VkZ2UvNCIsImV2YWx1YXRlZF90YWdzIjpbXSwiZ2VvbWV0cnkiOnsiZGlyZWN0aW9uIjpbLTAuNzA3MTA2NzgxMTg2NTQ3NSwwLDAuNzA3MTA2NzgxMTg2NTQ3NV0sIm9yaWdpbiI6WzEwMDAsMCwwXSwidHlwZSI6ImxpbmUifSwia2luZCI6ImVkZ2UiLCJwYXJlbnRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZmFjZS8yIiwiZW50aXR5L2ZhY2UvMyJdLCJwcm9wZXJ0aWVzIjp7ImJvdW5kcyI6eyJtYXgiOlsxMDAwLDAsMTAwMF0sIm1pbiI6WzAsMCwwXX0sImNlbnRyb2lkIjpbNTAwLDAsNTAwXSwibGVuZ3RoIjoxNDE0LjIxMzU2MjM3MzA5NSwicXVhbGl0eSI6Imtlcm5lbF9ldmFsdWF0ZWQifSwicmVuZGVyX3N0YXR1cyI6InJlbmRlcmVkIiwic2RrX2Nvbm5lY3Rvcl9mcmFtZSI6eyJvcmlnaW4iOls1MDAsMCw1MDBdLCJ4X2F4aXMiOlstMC43MDcxMDY3ODExODY1NDc1LDAsMC43MDcxMDY3ODExODY1NDc1XSwieV9heGlzIjpbMC43MDcxMDY3ODExODY1NDc0LDAsMC43MDcxMDY3ODExODY1NDc3XSwiel9heGlzIjpbMCwxLDBdfSwic2RrX21ldGFkYXRhIjp7fSwic2VtYW50aWNfYmluZGluZ19pZHMiOltdLCJzb3VyY2UiOnsiZ3JhcGhfaWQiOiJncmFwaCIsImtpbmQiOiJtb2RlbF90b3BvbG9neSIsIm5vZGVfaWQiOiJib2R5Iiwib3V0cHV0X3Nsb3QiOjAsInRvcG9faWQiOiJlbnRpdHkvZWRnZS80IiwidG9wb2xvZ3lfa2luZCI6IkVER0UifX0seyJjaGlsZF9lbnRpdHlfaWRzIjpbImVudGl0eS92ZXJ0ZXgvMCIsImVudGl0eS92ZXJ0ZXgvMiJdLCJjb25uZWN0b3JfYmluZGluZ19zdGF0dXMiOiJvd25lcl9ub3RfcGFydCIsImVudGl0eV9pZCI6ImVudGl0eS9lZGdlLzUiLCJldmFsdWF0ZWRfdGFncyI6W10sImdlb21ldHJ5Ijp7ImRpcmVjdGlvbiI6WzAsMCwxXSwib3JpZ2luIjpbMCwwLDBdLCJ0eXBlIjoibGluZSJ9LCJraW5kIjoiZWRnZSIsInBhcmVudF9lbnRpdHlfaWRzIjpbImVudGl0eS9mYWNlLzAiLCJlbnRpdHkvZmFjZS8yIl0sInByb3BlcnRpZXMiOnsiYm91bmRzIjp7Im1heCI6WzAsMCwxMDAwXSwibWluIjpbMCwwLDBdfSwiY2VudHJvaWQiOlswLDAsNTAwXSwibGVuZ3RoIjoxMDAwLCJxdWFsaXR5Ijoia2VybmVsX2V2YWx1YXRlZCJ9LCJyZW5kZXJfc3RhdHVzIjoicmVuZGVyZWQiLCJzZGtfY29ubmVjdG9yX2ZyYW1lIjp7Im9yaWdpbiI6WzAsMCw1MDBdLCJ4X2F4aXMiOlswLDAsMV0sInlfYXhpcyI6WzAsMSwwXSwiel9heGlzIjpbLTEsMCwwXX0sInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImdyYXBoX2lkIjoiZ3JhcGgiLCJraW5kIjoibW9kZWxfdG9wb2xvZ3kiLCJub2RlX2lkIjoiYm9keSIsIm91dHB1dF9zbG90IjowLCJ0b3BvX2lkIjoiZW50aXR5L2VkZ2UvNSIsInRvcG9sb2d5X2tpbmQiOiJFREdFIn19LHsiY2hpbGRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZWRnZS8wIiwiZW50aXR5L2VkZ2UvMiIsImVudGl0eS9lZGdlLzUiXSwiY29ubmVjdG9yX2JpbmRpbmdfc3RhdHVzIjoib3duZXJfbm90X3BhcnQiLCJlbnRpdHlfaWQiOiJlbnRpdHkvZmFjZS8wIiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJub3JtYWwiOlstMSwwLDBdLCJvcmlnaW4iOlswLC0zMzMuMzMzMzMzMzMzMzMzMywzMzMuMzMzMzMzMzMzMzMzM10sInR5cGUiOiJwbGFuZSIsInhfZGlyZWN0aW9uIjpbMCwwLDFdfSwia2luZCI6ImZhY2UiLCJwYXJlbnRfZW50aXR5X2lkcyI6WyJlbnRpdHkvc29saWQvMCJdLCJwcm9wZXJ0aWVzIjp7ImFyZWEiOjUwMDAwMCwiYm91bmRzIjp7Im1heCI6WzAsMCwxMDAwXSwibWluIjpbMCwtMTAwMCwwXX0sImNlbnRyb2lkIjpbMCwtMzMzLjMzMzMzMzMzMzMzMzMsMzMzLjMzMzMzMzMzMzMzMzNdLCJvcmllbnRhdGlvbiI6ImZvcndhcmQiLCJxdWFsaXR5Ijoia2VybmVsX2V2YWx1YXRlZCJ9LCJyZW5kZXJfc3RhdHVzIjoicmVuZGVyZWQiLCJzZGtfY29ubmVjdG9yX2ZyYW1lIjp7Im9yaWdpbiI6WzAsLTMzMy4zMzMzMzMzMzMzMzMzLDMzMy4zMzMzMzMzMzMzMzMzXSwieF9heGlzIjpbMCwwLDFdLCJ5X2F4aXMiOlswLDEsMF0sInpfYXhpcyI6Wy0xLDAsMF19LCJzZGtfbWV0YWRhdGEiOnt9LCJzZW1hbnRpY19iaW5kaW5nX2lkcyI6W10sInNvdXJjZSI6eyJncmFwaF9pZCI6ImdyYXBoIiwia2luZCI6Im1vZGVsX3RvcG9sb2d5Iiwibm9kZV9pZCI6ImJvZHkiLCJvdXRwdXRfc2xvdCI6MCwidG9wb19pZCI6ImVudGl0eS9mYWNlLzAiLCJ0b3BvbG9neV9raW5kIjoiRkFDRSJ9fSx7ImNoaWxkX2VudGl0eV9pZHMiOlsiZW50aXR5L2VkZ2UvMSIsImVudGl0eS9lZGdlLzIiLCJlbnRpdHkvZWRnZS8zIl0sImNvbm5lY3Rvcl9iaW5kaW5nX3N0YXR1cyI6Im93bmVyX25vdF9wYXJ0IiwiZW50aXR5X2lkIjoiZW50aXR5L2ZhY2UvMSIsImV2YWx1YXRlZF90YWdzIjpbXSwiZ2VvbWV0cnkiOnsibm9ybWFsIjpbMCwwLC0xXSwib3JpZ2luIjpbMzMzLjMzMzMzMzMzMzMzMzMsLTMzMy4zMzMzMzMzMzMzMzMzLDBdLCJ0eXBlIjoicGxhbmUiLCJ4X2RpcmVjdGlvbiI6WzEsMCwwXX0sImtpbmQiOiJmYWNlIiwicGFyZW50X2VudGl0eV9pZHMiOlsiZW50aXR5L3NvbGlkLzAiXSwicHJvcGVydGllcyI6eyJhcmVhIjo1MDAwMDAsImJvdW5kcyI6eyJtYXgiOlsxMDAwLDAsMF0sIm1pbiI6WzAsLTEwMDAsMF19LCJjZW50cm9pZCI6WzMzMy4zMzMzMzMzMzMzMzMzLC0zMzMuMzMzMzMzMzMzMzMzMywwXSwib3JpZW50YXRpb24iOiJmb3J3YXJkIiwicXVhbGl0eSI6Imtlcm5lbF9ldmFsdWF0ZWQifSwicmVuZGVyX3N0YXR1cyI6InJlbmRlcmVkIiwic2RrX2Nvbm5lY3Rvcl9mcmFtZSI6eyJvcmlnaW4iOlszMzMuMzMzMzMzMzMzMzMzMywtMzMzLjMzMzMzMzMzMzMzMzMsMF0sInhfYXhpcyI6WzEsMCwwXSwieV9heGlzIjpbMCwtMSwwXSwiel9heGlzIjpbMCwwLC0xXX0sInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImdyYXBoX2lkIjoiZ3JhcGgiLCJraW5kIjoibW9kZWxfdG9wb2xvZ3kiLCJub2RlX2lkIjoiYm9keSIsIm91dHB1dF9zbG90IjowLCJ0b3BvX2lkIjoiZW50aXR5L2ZhY2UvMSIsInRvcG9sb2d5X2tpbmQiOiJGQUNFIn19LHsiY2hpbGRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZWRnZS8zIiwiZW50aXR5L2VkZ2UvNCIsImVudGl0eS9lZGdlLzUiXSwiY29ubmVjdG9yX2JpbmRpbmdfc3RhdHVzIjoib3duZXJfbm90X3BhcnQiLCJlbnRpdHlfaWQiOiJlbnRpdHkvZmFjZS8yIiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJub3JtYWwiOlswLDEsMF0sIm9yaWdpbiI6WzMzMy4zMzMzMzMzMzMzMzMzLDAsMzMzLjMzMzMzMzMzMzMzMzNdLCJ0eXBlIjoicGxhbmUiLCJ4X2RpcmVjdGlvbiI6WzEsMCwwXX0sImtpbmQiOiJmYWNlIiwicGFyZW50X2VudGl0eV9pZHMiOlsiZW50aXR5L3NvbGlkLzAiXSwicHJvcGVydGllcyI6eyJhcmVhIjo1MDAwMDAsImJvdW5kcyI6eyJtYXgiOlsxMDAwLDAsMTAwMF0sIm1pbiI6WzAsMCwwXX0sImNlbnRyb2lkIjpbMzMzLjMzMzMzMzMzMzMzMzMsMCwzMzMuMzMzMzMzMzMzMzMzM10sIm9yaWVudGF0aW9uIjoiZm9yd2FyZCIsInF1YWxpdHkiOiJrZXJuZWxfZXZhbHVhdGVkIn0sInJlbmRlcl9zdGF0dXMiOiJyZW5kZXJlZCIsInNka19jb25uZWN0b3JfZnJhbWUiOnsib3JpZ2luIjpbMzMzLjMzMzMzMzMzMzMzMzMsMCwzMzMuMzMzMzMzMzMzMzMzM10sInhfYXhpcyI6WzEsMCwwXSwieV9heGlzIjpbMCwwLC0xXSwiel9heGlzIjpbMCwxLDBdfSwic2RrX21ldGFkYXRhIjp7fSwic2VtYW50aWNfYmluZGluZ19pZHMiOltdLCJzb3VyY2UiOnsiZ3JhcGhfaWQiOiJncmFwaCIsImtpbmQiOiJtb2RlbF90b3BvbG9neSIsIm5vZGVfaWQiOiJib2R5Iiwib3V0cHV0X3Nsb3QiOjAsInRvcG9faWQiOiJlbnRpdHkvZmFjZS8yIiwidG9wb2xvZ3lfa2luZCI6IkZBQ0UifX0seyJjaGlsZF9lbnRpdHlfaWRzIjpbImVudGl0eS9lZGdlLzAiLCJlbnRpdHkvZWRnZS8xIiwiZW50aXR5L2VkZ2UvNCJdLCJjb25uZWN0b3JfYmluZGluZ19zdGF0dXMiOiJvd25lcl9ub3RfcGFydCIsImVudGl0eV9pZCI6ImVudGl0eS9mYWNlLzMiLCJldmFsdWF0ZWRfdGFncyI6W10sImdlb21ldHJ5Ijp7Im5vcm1hbCI6WzAuNTc3MzUwMjY5MTg5NjI1OCwtMC41NzczNTAyNjkxODk2MjU4LDAuNTc3MzUwMjY5MTg5NjI1OF0sIm9yaWdpbiI6WzMzMy4zMzMzMzMzMzMzMzMzLC0zMzMuMzMzMzMzMzMzMzMzMywzMzMuMzMzMzMzMzMzMzMzM10sInR5cGUiOiJwbGFuZSIsInhfZGlyZWN0aW9uIjpbLTAuNzA3MTA2NzgxMTg2NTQ3NSwwLDAuNzA3MTA2NzgxMTg2NTQ3NV19LCJraW5kIjoiZmFjZSIsInBhcmVudF9lbnRpdHlfaWRzIjpbImVudGl0eS9zb2xpZC8wIl0sInByb3BlcnRpZXMiOnsiYXJlYSI6ODY2MDI1LjQwMzc4NDQzODYsImJvdW5kcyI6eyJtYXgiOlsxMDAwLDAsMTAwMF0sIm1pbiI6WzAsLTEwMDAsMF19LCJjZW50cm9pZCI6WzMzMy4zMzMzMzMzMzMzMzMzLC0zMzMuMzMzMzMzMzMzMzMzMywzMzMuMzMzMzMzMzMzMzMzM10sIm9yaWVudGF0aW9uIjoiZm9yd2FyZCIsInF1YWxpdHkiOiJrZXJuZWxfZXZhbHVhdGVkIn0sInJlbmRlcl9zdGF0dXMiOiJyZW5kZXJlZCIsInNka19jb25uZWN0b3JfZnJhbWUiOnsib3JpZ2luIjpbMzMzLjMzMzMzMzMzMzMzMzMsLTMzMy4zMzMzMzMzMzMzMzMzLDMzMy4zMzMzMzMzMzMzMzMzXSwieF9heGlzIjpbLTAuNzA3MTA2NzgxMTg2NTQ3NSwwLDAuNzA3MTA2NzgxMTg2NTQ3NV0sInlfYXhpcyI6Wy0wLjQwODI0ODI5MDQ2Mzg2MzEsLTAuODE2NDk2NTgwOTI3NzI2MSwtMC40MDgyNDgyOTA0NjM4NjMxXSwiel9heGlzIjpbMC41NzczNTAyNjkxODk2MjU4LC0wLjU3NzM1MDI2OTE4OTYyNTgsMC41NzczNTAyNjkxODk2MjU4XX0sInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImdyYXBoX2lkIjoiZ3JhcGgiLCJraW5kIjoibW9kZWxfdG9wb2xvZ3kiLCJub2RlX2lkIjoiYm9keSIsIm91dHB1dF9zbG90IjowLCJ0b3BvX2lkIjoiZW50aXR5L2ZhY2UvMyIsInRvcG9sb2d5X2tpbmQiOiJGQUNFIn19LHsiY2hpbGRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZmFjZS8wIiwiZW50aXR5L2ZhY2UvMSIsImVudGl0eS9mYWNlLzIiLCJlbnRpdHkvZmFjZS8zIl0sImNvbm5lY3Rvcl9iaW5kaW5nX3N0YXR1cyI6Im5vdF9hcHBsaWNhYmxlIiwiZW50aXR5X2lkIjoiZW50aXR5L3NvbGlkLzAiLCJldmFsdWF0ZWRfdGFncyI6W10sImdlb21ldHJ5Ijp7InR5cGUiOiJicmVwX3NvbGlkIn0sImtpbmQiOiJzb2xpZCIsInBhcmVudF9lbnRpdHlfaWRzIjpbXSwicHJvcGVydGllcyI6eyJib3VuZHMiOnsibWF4IjpbMTAwMCwwLDEwMDBdLCJtaW4iOlswLC0xMDAwLDBdfSwiY2VudHJvaWQiOlsyNTAsLTI1MCwyNTBdLCJxdWFsaXR5Ijoia2VybmVsX2V2YWx1YXRlZCIsInN1cmZhY2VfYXJlYSI6MjM2NjAyNS40MDM3ODQ0Mzg1LCJ2b2x1bWUiOjE2NjY2NjY2Ni42NjY2NjY2Nn0sInJlbmRlcl9zdGF0dXMiOiJyZW5kZXJlZCIsInNka19jb25uZWN0b3JfZnJhbWUiOm51bGwsInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImdyYXBoX2lkIjoiZ3JhcGgiLCJraW5kIjoibW9kZWxfdG9wb2xvZ3kiLCJub2RlX2lkIjoiYm9keSIsIm91dHB1dF9zbG90IjowLCJ0b3BvX2lkIjoiZW50aXR5L3NvbGlkLzAiLCJ0b3BvbG9neV9raW5kIjoiU09MSUQifX0seyJjaGlsZF9lbnRpdHlfaWRzIjpbXSwiY29ubmVjdG9yX2JpbmRpbmdfc3RhdHVzIjoib3duZXJfbm90X3BhcnQiLCJlbnRpdHlfaWQiOiJlbnRpdHkvdmVydGV4LzAiLCJldmFsdWF0ZWRfdGFncyI6W10sImdlb21ldHJ5Ijp7InBvc2l0aW9uIjpbMCwwLDBdLCJ0eXBlIjoicG9pbnQifSwia2luZCI6InZlcnRleCIsInBhcmVudF9lbnRpdHlfaWRzIjpbImVudGl0eS9lZGdlLzIiLCJlbnRpdHkvZWRnZS8zIiwiZW50aXR5L2VkZ2UvNSJdLCJwcm9wZXJ0aWVzIjp7ImJvdW5kcyI6eyJtYXgiOlswLDAsMF0sIm1pbiI6WzAsMCwwXX0sInBvc2l0aW9uIjpbMCwwLDBdLCJxdWFsaXR5Ijoia2VybmVsX2V2YWx1YXRlZCJ9LCJyZW5kZXJfc3RhdHVzIjoicmVuZGVyZWQiLCJzZGtfY29ubmVjdG9yX2ZyYW1lIjp7Im9yaWdpbiI6WzAsMCwwXSwieF9heGlzIjpbMSwwLDBdLCJ5X2F4aXMiOlswLDEsMF0sInpfYXhpcyI6WzAsMCwxXX0sInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImdyYXBoX2lkIjoiZ3JhcGgiLCJraW5kIjoibW9kZWxfdG9wb2xvZ3kiLCJub2RlX2lkIjoiYm9keSIsIm91dHB1dF9zbG90IjowLCJ0b3BvX2lkIjoiZW50aXR5L3ZlcnRleC8wIiwidG9wb2xvZ3lfa2luZCI6IlZFUlRFWCJ9fSx7ImNoaWxkX2VudGl0eV9pZHMiOltdLCJjb25uZWN0b3JfYmluZGluZ19zdGF0dXMiOiJvd25lcl9ub3RfcGFydCIsImVudGl0eV9pZCI6ImVudGl0eS92ZXJ0ZXgvMSIsImV2YWx1YXRlZF90YWdzIjpbXSwiZ2VvbWV0cnkiOnsicG9zaXRpb24iOlsxMDAwLDAsMF0sInR5cGUiOiJwb2ludCJ9LCJraW5kIjoidmVydGV4IiwicGFyZW50X2VudGl0eV9pZHMiOlsiZW50aXR5L2VkZ2UvMSIsImVudGl0eS9lZGdlLzMiLCJlbnRpdHkvZWRnZS80Il0sInByb3BlcnRpZXMiOnsiYm91bmRzIjp7Im1heCI6WzEwMDAsMCwwXSwibWluIjpbMTAwMCwwLDBdfSwicG9zaXRpb24iOlsxMDAwLDAsMF0sInF1YWxpdHkiOiJrZXJuZWxfZXZhbHVhdGVkIn0sInJlbmRlcl9zdGF0dXMiOiJyZW5kZXJlZCIsInNka19jb25uZWN0b3JfZnJhbWUiOnsib3JpZ2luIjpbMTAwMCwwLDBdLCJ4X2F4aXMiOlsxLDAsMF0sInlfYXhpcyI6WzAsMSwwXSwiel9heGlzIjpbMCwwLDFdfSwic2RrX21ldGFkYXRhIjp7fSwic2VtYW50aWNfYmluZGluZ19pZHMiOltdLCJzb3VyY2UiOnsiZ3JhcGhfaWQiOiJncmFwaCIsImtpbmQiOiJtb2RlbF90b3BvbG9neSIsIm5vZGVfaWQiOiJib2R5Iiwib3V0cHV0X3Nsb3QiOjAsInRvcG9faWQiOiJlbnRpdHkvdmVydGV4LzEiLCJ0b3BvbG9neV9raW5kIjoiVkVSVEVYIn19LHsiY2hpbGRfZW50aXR5X2lkcyI6W10sImNvbm5lY3Rvcl9iaW5kaW5nX3N0YXR1cyI6Im93bmVyX25vdF9wYXJ0IiwiZW50aXR5X2lkIjoiZW50aXR5L3ZlcnRleC8yIiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJwb3NpdGlvbiI6WzAsMCwxMDAwXSwidHlwZSI6InBvaW50In0sImtpbmQiOiJ2ZXJ0ZXgiLCJwYXJlbnRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZWRnZS8wIiwiZW50aXR5L2VkZ2UvNCIsImVudGl0eS9lZGdlLzUiXSwicHJvcGVydGllcyI6eyJib3VuZHMiOnsibWF4IjpbMCwwLDEwMDBdLCJtaW4iOlswLDAsMTAwMF19LCJwb3NpdGlvbiI6WzAsMCwxMDAwXSwicXVhbGl0eSI6Imtlcm5lbF9ldmFsdWF0ZWQifSwicmVuZGVyX3N0YXR1cyI6InJlbmRlcmVkIiwic2RrX2Nvbm5lY3Rvcl9mcmFtZSI6eyJvcmlnaW4iOlswLDAsMTAwMF0sInhfYXhpcyI6WzEsMCwwXSwieV9heGlzIjpbMCwxLDBdLCJ6X2F4aXMiOlswLDAsMV19LCJzZGtfbWV0YWRhdGEiOnt9LCJzZW1hbnRpY19iaW5kaW5nX2lkcyI6W10sInNvdXJjZSI6eyJncmFwaF9pZCI6ImdyYXBoIiwia2luZCI6Im1vZGVsX3RvcG9sb2d5Iiwibm9kZV9pZCI6ImJvZHkiLCJvdXRwdXRfc2xvdCI6MCwidG9wb19pZCI6ImVudGl0eS92ZXJ0ZXgvMiIsInRvcG9sb2d5X2tpbmQiOiJWRVJURVgifX0seyJjaGlsZF9lbnRpdHlfaWRzIjpbXSwiY29ubmVjdG9yX2JpbmRpbmdfc3RhdHVzIjoib3duZXJfbm90X3BhcnQiLCJlbnRpdHlfaWQiOiJlbnRpdHkvdmVydGV4LzMiLCJldmFsdWF0ZWRfdGFncyI6W10sImdlb21ldHJ5Ijp7InBvc2l0aW9uIjpbMCwtMTAwMCwwXSwidHlwZSI6InBvaW50In0sImtpbmQiOiJ2ZXJ0ZXgiLCJwYXJlbnRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZWRnZS8wIiwiZW50aXR5L2VkZ2UvMSIsImVudGl0eS9lZGdlLzIiXSwicHJvcGVydGllcyI6eyJib3VuZHMiOnsibWF4IjpbMCwtMTAwMCwwXSwibWluIjpbMCwtMTAwMCwwXX0sInBvc2l0aW9uIjpbMCwtMTAwMCwwXSwicXVhbGl0eSI6Imtlcm5lbF9ldmFsdWF0ZWQifSwicmVuZGVyX3N0YXR1cyI6InJlbmRlcmVkIiwic2RrX2Nvbm5lY3Rvcl9mcmFtZSI6eyJvcmlnaW4iOlswLC0xMDAwLDBdLCJ4X2F4aXMiOlsxLDAsMF0sInlfYXhpcyI6WzAsMSwwXSwiel9heGlzIjpbMCwwLDFdfSwic2RrX21ldGFkYXRhIjp7fSwic2VtYW50aWNfYmluZGluZ19pZHMiOltdLCJzb3VyY2UiOnsiZ3JhcGhfaWQiOiJncmFwaCIsImtpbmQiOiJtb2RlbF90b3BvbG9neSIsIm5vZGVfaWQiOiJib2R5Iiwib3V0cHV0X3Nsb3QiOjAsInRvcG9faWQiOiJlbnRpdHkvdmVydGV4LzMiLCJ0b3BvbG9neV9raW5kIjoiVkVSVEVYIn19XSwiZmFjZV9ncm91cHMiOlt7ImVudGl0eV9pZCI6ImVudGl0eS9mYWNlLzAiLCJmaXJzdF9pbmRleCI6MCwiZ3JvdXBfaWQiOjAsImluZGV4X2NvdW50IjozLCJtZXNoX2luZGV4IjowLCJwcmltaXRpdmVfaW5kZXgiOjB9LHsiZW50aXR5X2lkIjoiZW50aXR5L2ZhY2UvMSIsImZpcnN0X2luZGV4IjozLCJncm91cF9pZCI6MSwiaW5kZXhfY291bnQiOjMsIm1lc2hfaW5kZXgiOjAsInByaW1pdGl2ZV9pbmRleCI6MH0seyJlbnRpdHlfaWQiOiJlbnRpdHkvZmFjZS8yIiwiZmlyc3RfaW5kZXgiOjYsImdyb3VwX2lkIjoyLCJpbmRleF9jb3VudCI6MywibWVzaF9pbmRleCI6MCwicHJpbWl0aXZlX2luZGV4IjowfSx7ImVudGl0eV9pZCI6ImVudGl0eS9mYWNlLzMiLCJmaXJzdF9pbmRleCI6OSwiZ3JvdXBfaWQiOjMsImluZGV4X2NvdW50IjozLCJtZXNoX2luZGV4IjowLCJwcmltaXRpdmVfaW5kZXgiOjB9XSwiZ2VvbWV0cnlfYXNzZXRfaWQiOiJzaGEyNTY6MjAwNTIzNDE0ZGUyODFhOGExNTUwODZhNTRjZDBmODgwZjY0MDQ4NTc2NWE0OGNjZGQ4ZWMyMzBmNjdkZDE3YyIsImdlb21ldHJ5X2VuZ2luZSI6eyJuYW1lIjoiT3BlbkNhc2NhZGUiLCJwcm9maWxlIjoib2NwLWV2YWx1YXRlZC1wcm9wZXJ0aWVzLTEiLCJ2ZXJzaW9uIjoiZml4dHVyZSJ9LCJzY2hlbWFfdmVyc2lvbiI6IjEuMCJ9", + "valid": true + }, + { + "expected": null, + "name": "valid_entity_source_imported_primitive", + "payload_base64": "eyJkZWZpbml0aW9uX2lkIjoiZGVmaW5pdGlvbi9yb290L3NoYXBlL21hbnVhbC9maXh0dXJlIiwiZWRnZV9hc3NldF9pZCI6InNoYTI1Njo2Y2U0ODFkYjFmZTEzYmNmZTdhMjM3NDQyM2U2YzcyZDc1N2Q1MWM5OTE2OGIyODdjNTJmNTNhYTYyNWNjZDY3IiwiZWRnZV9ncm91cHMiOlt7ImVudGl0eV9pZCI6ImVudGl0eS9lZGdlLzAiLCJmaXJzdF9pbmRleCI6MCwiZ3JvdXBfaWQiOjAsImluZGV4X2NvdW50IjoyLCJtZXNoX2luZGV4IjowLCJwcmltaXRpdmVfaW5kZXgiOjB9LHsiZW50aXR5X2lkIjoiZW50aXR5L2VkZ2UvMSIsImZpcnN0X2luZGV4IjoyLCJncm91cF9pZCI6MSwiaW5kZXhfY291bnQiOjIsIm1lc2hfaW5kZXgiOjAsInByaW1pdGl2ZV9pbmRleCI6MH0seyJlbnRpdHlfaWQiOiJlbnRpdHkvZWRnZS8yIiwiZmlyc3RfaW5kZXgiOjQsImdyb3VwX2lkIjoyLCJpbmRleF9jb3VudCI6MiwibWVzaF9pbmRleCI6MCwicHJpbWl0aXZlX2luZGV4IjowfSx7ImVudGl0eV9pZCI6ImVudGl0eS9lZGdlLzMiLCJmaXJzdF9pbmRleCI6NiwiZ3JvdXBfaWQiOjMsImluZGV4X2NvdW50IjoyLCJtZXNoX2luZGV4IjowLCJwcmltaXRpdmVfaW5kZXgiOjB9LHsiZW50aXR5X2lkIjoiZW50aXR5L2VkZ2UvNCIsImZpcnN0X2luZGV4Ijo4LCJncm91cF9pZCI6NCwiaW5kZXhfY291bnQiOjIsIm1lc2hfaW5kZXgiOjAsInByaW1pdGl2ZV9pbmRleCI6MH0seyJlbnRpdHlfaWQiOiJlbnRpdHkvZWRnZS81IiwiZmlyc3RfaW5kZXgiOjEwLCJncm91cF9pZCI6NSwiaW5kZXhfY291bnQiOjIsIm1lc2hfaW5kZXgiOjAsInByaW1pdGl2ZV9pbmRleCI6MH1dLCJlbnRpdGllcyI6W3siY2hpbGRfZW50aXR5X2lkcyI6WyJlbnRpdHkvdmVydGV4LzIiLCJlbnRpdHkvdmVydGV4LzMiXSwiY29ubmVjdG9yX2JpbmRpbmdfc3RhdHVzIjoib3duZXJfbm90X3BhcnQiLCJlbnRpdHlfaWQiOiJlbnRpdHkvZWRnZS8wIiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJkaXJlY3Rpb24iOlswLC0wLjcwNzEwNjc4MTE4NjU0NzUsLTAuNzA3MTA2NzgxMTg2NTQ3NV0sIm9yaWdpbiI6WzAsMCwxMDAwXSwidHlwZSI6ImxpbmUifSwia2luZCI6ImVkZ2UiLCJwYXJlbnRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZmFjZS8wIiwiZW50aXR5L2ZhY2UvMyJdLCJwcm9wZXJ0aWVzIjp7ImJvdW5kcyI6eyJtYXgiOlswLDAsMTAwMF0sIm1pbiI6WzAsLTEwMDAsMF19LCJjZW50cm9pZCI6WzAsLTUwMCw1MDBdLCJsZW5ndGgiOjE0MTQuMjEzNTYyMzczMDk1LCJxdWFsaXR5Ijoia2VybmVsX2V2YWx1YXRlZCJ9LCJyZW5kZXJfc3RhdHVzIjoicmVuZGVyZWQiLCJzZGtfY29ubmVjdG9yX2ZyYW1lIjp7Im9yaWdpbiI6WzAsLTUwMCw1MDBdLCJ4X2F4aXMiOlswLC0wLjcwNzEwNjc4MTE4NjU0NzUsLTAuNzA3MTA2NzgxMTg2NTQ3NV0sInlfYXhpcyI6WzAsLTAuNzA3MTA2NzgxMTg2NTQ3NCwwLjcwNzEwNjc4MTE4NjU0NzddLCJ6X2F4aXMiOlstMSwwLDBdfSwic2RrX21ldGFkYXRhIjp7fSwic2VtYW50aWNfYmluZGluZ19pZHMiOltdLCJzb3VyY2UiOnsia2luZCI6ImltcG9ydGVkX3ByaW1pdGl2ZSIsInNvdXJjZV9lbGVtZW50X2lkIjoiZW50aXR5L2VkZ2UvMCJ9fSx7ImNoaWxkX2VudGl0eV9pZHMiOlsiZW50aXR5L3ZlcnRleC8xIiwiZW50aXR5L3ZlcnRleC8zIl0sImNvbm5lY3Rvcl9iaW5kaW5nX3N0YXR1cyI6Im93bmVyX25vdF9wYXJ0IiwiZW50aXR5X2lkIjoiZW50aXR5L2VkZ2UvMSIsImV2YWx1YXRlZF90YWdzIjpbXSwiZ2VvbWV0cnkiOnsiZGlyZWN0aW9uIjpbLTAuNzA3MTA2NzgxMTg2NTQ3NSwtMC43MDcxMDY3ODExODY1NDc1LDBdLCJvcmlnaW4iOlsxMDAwLDAsMF0sInR5cGUiOiJsaW5lIn0sImtpbmQiOiJlZGdlIiwicGFyZW50X2VudGl0eV9pZHMiOlsiZW50aXR5L2ZhY2UvMSIsImVudGl0eS9mYWNlLzMiXSwicHJvcGVydGllcyI6eyJib3VuZHMiOnsibWF4IjpbMTAwMCwwLDBdLCJtaW4iOlswLC0xMDAwLDBdfSwiY2VudHJvaWQiOls1MDAsLTUwMCwwXSwibGVuZ3RoIjoxNDE0LjIxMzU2MjM3MzA5NSwicXVhbGl0eSI6Imtlcm5lbF9ldmFsdWF0ZWQifSwicmVuZGVyX3N0YXR1cyI6InJlbmRlcmVkIiwic2RrX2Nvbm5lY3Rvcl9mcmFtZSI6eyJvcmlnaW4iOls1MDAsLTUwMCwwXSwieF9heGlzIjpbLTAuNzA3MTA2NzgxMTg2NTQ3NSwtMC43MDcxMDY3ODExODY1NDc1LDBdLCJ5X2F4aXMiOlswLDAsMV0sInpfYXhpcyI6Wy0wLjcwNzEwNjc4MTE4NjU0NzYsMC43MDcxMDY3ODExODY1NDc2LDBdfSwic2RrX21ldGFkYXRhIjp7fSwic2VtYW50aWNfYmluZGluZ19pZHMiOltdLCJzb3VyY2UiOnsia2luZCI6ImltcG9ydGVkX3ByaW1pdGl2ZSIsInNvdXJjZV9lbGVtZW50X2lkIjoiZW50aXR5L2VkZ2UvMSJ9fSx7ImNoaWxkX2VudGl0eV9pZHMiOlsiZW50aXR5L3ZlcnRleC8wIiwiZW50aXR5L3ZlcnRleC8zIl0sImNvbm5lY3Rvcl9iaW5kaW5nX3N0YXR1cyI6Im93bmVyX25vdF9wYXJ0IiwiZW50aXR5X2lkIjoiZW50aXR5L2VkZ2UvMiIsImV2YWx1YXRlZF90YWdzIjpbXSwiZ2VvbWV0cnkiOnsiZGlyZWN0aW9uIjpbMCwtMSwwXSwib3JpZ2luIjpbMCwwLDBdLCJ0eXBlIjoibGluZSJ9LCJraW5kIjoiZWRnZSIsInBhcmVudF9lbnRpdHlfaWRzIjpbImVudGl0eS9mYWNlLzAiLCJlbnRpdHkvZmFjZS8xIl0sInByb3BlcnRpZXMiOnsiYm91bmRzIjp7Im1heCI6WzAsMCwwXSwibWluIjpbMCwtMTAwMCwwXX0sImNlbnRyb2lkIjpbMCwtNTAwLDBdLCJsZW5ndGgiOjEwMDAsInF1YWxpdHkiOiJrZXJuZWxfZXZhbHVhdGVkIn0sInJlbmRlcl9zdGF0dXMiOiJyZW5kZXJlZCIsInNka19jb25uZWN0b3JfZnJhbWUiOnsib3JpZ2luIjpbMCwtNTAwLDBdLCJ4X2F4aXMiOlswLC0xLDBdLCJ5X2F4aXMiOlswLDAsMV0sInpfYXhpcyI6Wy0xLDAsMF19LCJzZGtfbWV0YWRhdGEiOnt9LCJzZW1hbnRpY19iaW5kaW5nX2lkcyI6W10sInNvdXJjZSI6eyJraW5kIjoiaW1wb3J0ZWRfcHJpbWl0aXZlIiwic291cmNlX2VsZW1lbnRfaWQiOiJlbnRpdHkvZWRnZS8yIn19LHsiY2hpbGRfZW50aXR5X2lkcyI6WyJlbnRpdHkvdmVydGV4LzAiLCJlbnRpdHkvdmVydGV4LzEiXSwiY29ubmVjdG9yX2JpbmRpbmdfc3RhdHVzIjoib3duZXJfbm90X3BhcnQiLCJlbnRpdHlfaWQiOiJlbnRpdHkvZWRnZS8zIiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJkaXJlY3Rpb24iOlsxLDAsMF0sIm9yaWdpbiI6WzAsMCwwXSwidHlwZSI6ImxpbmUifSwia2luZCI6ImVkZ2UiLCJwYXJlbnRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZmFjZS8xIiwiZW50aXR5L2ZhY2UvMiJdLCJwcm9wZXJ0aWVzIjp7ImJvdW5kcyI6eyJtYXgiOlsxMDAwLDAsMF0sIm1pbiI6WzAsMCwwXX0sImNlbnRyb2lkIjpbNTAwLDAsMF0sImxlbmd0aCI6MTAwMCwicXVhbGl0eSI6Imtlcm5lbF9ldmFsdWF0ZWQifSwicmVuZGVyX3N0YXR1cyI6InJlbmRlcmVkIiwic2RrX2Nvbm5lY3Rvcl9mcmFtZSI6eyJvcmlnaW4iOls1MDAsMCwwXSwieF9heGlzIjpbMSwwLDBdLCJ5X2F4aXMiOlswLDAsMV0sInpfYXhpcyI6WzAsLTEsMF19LCJzZGtfbWV0YWRhdGEiOnt9LCJzZW1hbnRpY19iaW5kaW5nX2lkcyI6W10sInNvdXJjZSI6eyJraW5kIjoiaW1wb3J0ZWRfcHJpbWl0aXZlIiwic291cmNlX2VsZW1lbnRfaWQiOiJlbnRpdHkvZWRnZS8zIn19LHsiY2hpbGRfZW50aXR5X2lkcyI6WyJlbnRpdHkvdmVydGV4LzEiLCJlbnRpdHkvdmVydGV4LzIiXSwiY29ubmVjdG9yX2JpbmRpbmdfc3RhdHVzIjoib3duZXJfbm90X3BhcnQiLCJlbnRpdHlfaWQiOiJlbnRpdHkvZWRnZS80IiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJkaXJlY3Rpb24iOlstMC43MDcxMDY3ODExODY1NDc1LDAsMC43MDcxMDY3ODExODY1NDc1XSwib3JpZ2luIjpbMTAwMCwwLDBdLCJ0eXBlIjoibGluZSJ9LCJraW5kIjoiZWRnZSIsInBhcmVudF9lbnRpdHlfaWRzIjpbImVudGl0eS9mYWNlLzIiLCJlbnRpdHkvZmFjZS8zIl0sInByb3BlcnRpZXMiOnsiYm91bmRzIjp7Im1heCI6WzEwMDAsMCwxMDAwXSwibWluIjpbMCwwLDBdfSwiY2VudHJvaWQiOls1MDAsMCw1MDBdLCJsZW5ndGgiOjE0MTQuMjEzNTYyMzczMDk1LCJxdWFsaXR5Ijoia2VybmVsX2V2YWx1YXRlZCJ9LCJyZW5kZXJfc3RhdHVzIjoicmVuZGVyZWQiLCJzZGtfY29ubmVjdG9yX2ZyYW1lIjp7Im9yaWdpbiI6WzUwMCwwLDUwMF0sInhfYXhpcyI6Wy0wLjcwNzEwNjc4MTE4NjU0NzUsMCwwLjcwNzEwNjc4MTE4NjU0NzVdLCJ5X2F4aXMiOlswLjcwNzEwNjc4MTE4NjU0NzQsMCwwLjcwNzEwNjc4MTE4NjU0NzddLCJ6X2F4aXMiOlswLDEsMF19LCJzZGtfbWV0YWRhdGEiOnt9LCJzZW1hbnRpY19iaW5kaW5nX2lkcyI6W10sInNvdXJjZSI6eyJraW5kIjoiaW1wb3J0ZWRfcHJpbWl0aXZlIiwic291cmNlX2VsZW1lbnRfaWQiOiJlbnRpdHkvZWRnZS80In19LHsiY2hpbGRfZW50aXR5X2lkcyI6WyJlbnRpdHkvdmVydGV4LzAiLCJlbnRpdHkvdmVydGV4LzIiXSwiY29ubmVjdG9yX2JpbmRpbmdfc3RhdHVzIjoib3duZXJfbm90X3BhcnQiLCJlbnRpdHlfaWQiOiJlbnRpdHkvZWRnZS81IiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJkaXJlY3Rpb24iOlswLDAsMV0sIm9yaWdpbiI6WzAsMCwwXSwidHlwZSI6ImxpbmUifSwia2luZCI6ImVkZ2UiLCJwYXJlbnRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZmFjZS8wIiwiZW50aXR5L2ZhY2UvMiJdLCJwcm9wZXJ0aWVzIjp7ImJvdW5kcyI6eyJtYXgiOlswLDAsMTAwMF0sIm1pbiI6WzAsMCwwXX0sImNlbnRyb2lkIjpbMCwwLDUwMF0sImxlbmd0aCI6MTAwMCwicXVhbGl0eSI6Imtlcm5lbF9ldmFsdWF0ZWQifSwicmVuZGVyX3N0YXR1cyI6InJlbmRlcmVkIiwic2RrX2Nvbm5lY3Rvcl9mcmFtZSI6eyJvcmlnaW4iOlswLDAsNTAwXSwieF9heGlzIjpbMCwwLDFdLCJ5X2F4aXMiOlswLDEsMF0sInpfYXhpcyI6Wy0xLDAsMF19LCJzZGtfbWV0YWRhdGEiOnt9LCJzZW1hbnRpY19iaW5kaW5nX2lkcyI6W10sInNvdXJjZSI6eyJraW5kIjoiaW1wb3J0ZWRfcHJpbWl0aXZlIiwic291cmNlX2VsZW1lbnRfaWQiOiJlbnRpdHkvZWRnZS81In19LHsiY2hpbGRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZWRnZS8wIiwiZW50aXR5L2VkZ2UvMiIsImVudGl0eS9lZGdlLzUiXSwiY29ubmVjdG9yX2JpbmRpbmdfc3RhdHVzIjoib3duZXJfbm90X3BhcnQiLCJlbnRpdHlfaWQiOiJlbnRpdHkvZmFjZS8wIiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJub3JtYWwiOlstMSwwLDBdLCJvcmlnaW4iOlswLC0zMzMuMzMzMzMzMzMzMzMzMywzMzMuMzMzMzMzMzMzMzMzM10sInR5cGUiOiJwbGFuZSIsInhfZGlyZWN0aW9uIjpbMCwwLDFdfSwia2luZCI6ImZhY2UiLCJwYXJlbnRfZW50aXR5X2lkcyI6WyJlbnRpdHkvc29saWQvMCJdLCJwcm9wZXJ0aWVzIjp7ImFyZWEiOjUwMDAwMCwiYm91bmRzIjp7Im1heCI6WzAsMCwxMDAwXSwibWluIjpbMCwtMTAwMCwwXX0sImNlbnRyb2lkIjpbMCwtMzMzLjMzMzMzMzMzMzMzMzMsMzMzLjMzMzMzMzMzMzMzMzNdLCJvcmllbnRhdGlvbiI6ImZvcndhcmQiLCJxdWFsaXR5Ijoia2VybmVsX2V2YWx1YXRlZCJ9LCJyZW5kZXJfc3RhdHVzIjoicmVuZGVyZWQiLCJzZGtfY29ubmVjdG9yX2ZyYW1lIjp7Im9yaWdpbiI6WzAsLTMzMy4zMzMzMzMzMzMzMzMzLDMzMy4zMzMzMzMzMzMzMzMzXSwieF9heGlzIjpbMCwwLDFdLCJ5X2F4aXMiOlswLDEsMF0sInpfYXhpcyI6Wy0xLDAsMF19LCJzZGtfbWV0YWRhdGEiOnt9LCJzZW1hbnRpY19iaW5kaW5nX2lkcyI6W10sInNvdXJjZSI6eyJraW5kIjoiaW1wb3J0ZWRfcHJpbWl0aXZlIiwic291cmNlX2VsZW1lbnRfaWQiOiJlbnRpdHkvZmFjZS8wIn19LHsiY2hpbGRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZWRnZS8xIiwiZW50aXR5L2VkZ2UvMiIsImVudGl0eS9lZGdlLzMiXSwiY29ubmVjdG9yX2JpbmRpbmdfc3RhdHVzIjoib3duZXJfbm90X3BhcnQiLCJlbnRpdHlfaWQiOiJlbnRpdHkvZmFjZS8xIiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJub3JtYWwiOlswLDAsLTFdLCJvcmlnaW4iOlszMzMuMzMzMzMzMzMzMzMzMywtMzMzLjMzMzMzMzMzMzMzMzMsMF0sInR5cGUiOiJwbGFuZSIsInhfZGlyZWN0aW9uIjpbMSwwLDBdfSwia2luZCI6ImZhY2UiLCJwYXJlbnRfZW50aXR5X2lkcyI6WyJlbnRpdHkvc29saWQvMCJdLCJwcm9wZXJ0aWVzIjp7ImFyZWEiOjUwMDAwMCwiYm91bmRzIjp7Im1heCI6WzEwMDAsMCwwXSwibWluIjpbMCwtMTAwMCwwXX0sImNlbnRyb2lkIjpbMzMzLjMzMzMzMzMzMzMzMzMsLTMzMy4zMzMzMzMzMzMzMzMzLDBdLCJvcmllbnRhdGlvbiI6ImZvcndhcmQiLCJxdWFsaXR5Ijoia2VybmVsX2V2YWx1YXRlZCJ9LCJyZW5kZXJfc3RhdHVzIjoicmVuZGVyZWQiLCJzZGtfY29ubmVjdG9yX2ZyYW1lIjp7Im9yaWdpbiI6WzMzMy4zMzMzMzMzMzMzMzMzLC0zMzMuMzMzMzMzMzMzMzMzMywwXSwieF9heGlzIjpbMSwwLDBdLCJ5X2F4aXMiOlswLC0xLDBdLCJ6X2F4aXMiOlswLDAsLTFdfSwic2RrX21ldGFkYXRhIjp7fSwic2VtYW50aWNfYmluZGluZ19pZHMiOltdLCJzb3VyY2UiOnsia2luZCI6ImltcG9ydGVkX3ByaW1pdGl2ZSIsInNvdXJjZV9lbGVtZW50X2lkIjoiZW50aXR5L2ZhY2UvMSJ9fSx7ImNoaWxkX2VudGl0eV9pZHMiOlsiZW50aXR5L2VkZ2UvMyIsImVudGl0eS9lZGdlLzQiLCJlbnRpdHkvZWRnZS81Il0sImNvbm5lY3Rvcl9iaW5kaW5nX3N0YXR1cyI6Im93bmVyX25vdF9wYXJ0IiwiZW50aXR5X2lkIjoiZW50aXR5L2ZhY2UvMiIsImV2YWx1YXRlZF90YWdzIjpbXSwiZ2VvbWV0cnkiOnsibm9ybWFsIjpbMCwxLDBdLCJvcmlnaW4iOlszMzMuMzMzMzMzMzMzMzMzMywwLDMzMy4zMzMzMzMzMzMzMzMzXSwidHlwZSI6InBsYW5lIiwieF9kaXJlY3Rpb24iOlsxLDAsMF19LCJraW5kIjoiZmFjZSIsInBhcmVudF9lbnRpdHlfaWRzIjpbImVudGl0eS9zb2xpZC8wIl0sInByb3BlcnRpZXMiOnsiYXJlYSI6NTAwMDAwLCJib3VuZHMiOnsibWF4IjpbMTAwMCwwLDEwMDBdLCJtaW4iOlswLDAsMF19LCJjZW50cm9pZCI6WzMzMy4zMzMzMzMzMzMzMzMzLDAsMzMzLjMzMzMzMzMzMzMzMzNdLCJvcmllbnRhdGlvbiI6ImZvcndhcmQiLCJxdWFsaXR5Ijoia2VybmVsX2V2YWx1YXRlZCJ9LCJyZW5kZXJfc3RhdHVzIjoicmVuZGVyZWQiLCJzZGtfY29ubmVjdG9yX2ZyYW1lIjp7Im9yaWdpbiI6WzMzMy4zMzMzMzMzMzMzMzMzLDAsMzMzLjMzMzMzMzMzMzMzMzNdLCJ4X2F4aXMiOlsxLDAsMF0sInlfYXhpcyI6WzAsMCwtMV0sInpfYXhpcyI6WzAsMSwwXX0sInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImtpbmQiOiJpbXBvcnRlZF9wcmltaXRpdmUiLCJzb3VyY2VfZWxlbWVudF9pZCI6ImVudGl0eS9mYWNlLzIifX0seyJjaGlsZF9lbnRpdHlfaWRzIjpbImVudGl0eS9lZGdlLzAiLCJlbnRpdHkvZWRnZS8xIiwiZW50aXR5L2VkZ2UvNCJdLCJjb25uZWN0b3JfYmluZGluZ19zdGF0dXMiOiJvd25lcl9ub3RfcGFydCIsImVudGl0eV9pZCI6ImVudGl0eS9mYWNlLzMiLCJldmFsdWF0ZWRfdGFncyI6W10sImdlb21ldHJ5Ijp7Im5vcm1hbCI6WzAuNTc3MzUwMjY5MTg5NjI1OCwtMC41NzczNTAyNjkxODk2MjU4LDAuNTc3MzUwMjY5MTg5NjI1OF0sIm9yaWdpbiI6WzMzMy4zMzMzMzMzMzMzMzMzLC0zMzMuMzMzMzMzMzMzMzMzMywzMzMuMzMzMzMzMzMzMzMzM10sInR5cGUiOiJwbGFuZSIsInhfZGlyZWN0aW9uIjpbLTAuNzA3MTA2NzgxMTg2NTQ3NSwwLDAuNzA3MTA2NzgxMTg2NTQ3NV19LCJraW5kIjoiZmFjZSIsInBhcmVudF9lbnRpdHlfaWRzIjpbImVudGl0eS9zb2xpZC8wIl0sInByb3BlcnRpZXMiOnsiYXJlYSI6ODY2MDI1LjQwMzc4NDQzODYsImJvdW5kcyI6eyJtYXgiOlsxMDAwLDAsMTAwMF0sIm1pbiI6WzAsLTEwMDAsMF19LCJjZW50cm9pZCI6WzMzMy4zMzMzMzMzMzMzMzMzLC0zMzMuMzMzMzMzMzMzMzMzMywzMzMuMzMzMzMzMzMzMzMzM10sIm9yaWVudGF0aW9uIjoiZm9yd2FyZCIsInF1YWxpdHkiOiJrZXJuZWxfZXZhbHVhdGVkIn0sInJlbmRlcl9zdGF0dXMiOiJyZW5kZXJlZCIsInNka19jb25uZWN0b3JfZnJhbWUiOnsib3JpZ2luIjpbMzMzLjMzMzMzMzMzMzMzMzMsLTMzMy4zMzMzMzMzMzMzMzMzLDMzMy4zMzMzMzMzMzMzMzMzXSwieF9heGlzIjpbLTAuNzA3MTA2NzgxMTg2NTQ3NSwwLDAuNzA3MTA2NzgxMTg2NTQ3NV0sInlfYXhpcyI6Wy0wLjQwODI0ODI5MDQ2Mzg2MzEsLTAuODE2NDk2NTgwOTI3NzI2MSwtMC40MDgyNDgyOTA0NjM4NjMxXSwiel9heGlzIjpbMC41NzczNTAyNjkxODk2MjU4LC0wLjU3NzM1MDI2OTE4OTYyNTgsMC41NzczNTAyNjkxODk2MjU4XX0sInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImtpbmQiOiJpbXBvcnRlZF9wcmltaXRpdmUiLCJzb3VyY2VfZWxlbWVudF9pZCI6ImVudGl0eS9mYWNlLzMifX0seyJjaGlsZF9lbnRpdHlfaWRzIjpbImVudGl0eS9mYWNlLzAiLCJlbnRpdHkvZmFjZS8xIiwiZW50aXR5L2ZhY2UvMiIsImVudGl0eS9mYWNlLzMiXSwiY29ubmVjdG9yX2JpbmRpbmdfc3RhdHVzIjoibm90X2FwcGxpY2FibGUiLCJlbnRpdHlfaWQiOiJlbnRpdHkvc29saWQvMCIsImV2YWx1YXRlZF90YWdzIjpbXSwiZ2VvbWV0cnkiOnsidHlwZSI6ImJyZXBfc29saWQifSwia2luZCI6InNvbGlkIiwicGFyZW50X2VudGl0eV9pZHMiOltdLCJwcm9wZXJ0aWVzIjp7ImJvdW5kcyI6eyJtYXgiOlsxMDAwLDAsMTAwMF0sIm1pbiI6WzAsLTEwMDAsMF19LCJjZW50cm9pZCI6WzI1MCwtMjUwLDI1MF0sInF1YWxpdHkiOiJrZXJuZWxfZXZhbHVhdGVkIiwic3VyZmFjZV9hcmVhIjoyMzY2MDI1LjQwMzc4NDQzODUsInZvbHVtZSI6MTY2NjY2NjY2LjY2NjY2NjY2fSwicmVuZGVyX3N0YXR1cyI6InJlbmRlcmVkIiwic2RrX2Nvbm5lY3Rvcl9mcmFtZSI6bnVsbCwic2RrX21ldGFkYXRhIjp7fSwic2VtYW50aWNfYmluZGluZ19pZHMiOltdLCJzb3VyY2UiOnsia2luZCI6ImltcG9ydGVkX3ByaW1pdGl2ZSIsInNvdXJjZV9lbGVtZW50X2lkIjoiZW50aXR5L3NvbGlkLzAifX0seyJjaGlsZF9lbnRpdHlfaWRzIjpbXSwiY29ubmVjdG9yX2JpbmRpbmdfc3RhdHVzIjoib3duZXJfbm90X3BhcnQiLCJlbnRpdHlfaWQiOiJlbnRpdHkvdmVydGV4LzAiLCJldmFsdWF0ZWRfdGFncyI6W10sImdlb21ldHJ5Ijp7InBvc2l0aW9uIjpbMCwwLDBdLCJ0eXBlIjoicG9pbnQifSwia2luZCI6InZlcnRleCIsInBhcmVudF9lbnRpdHlfaWRzIjpbImVudGl0eS9lZGdlLzIiLCJlbnRpdHkvZWRnZS8zIiwiZW50aXR5L2VkZ2UvNSJdLCJwcm9wZXJ0aWVzIjp7ImJvdW5kcyI6eyJtYXgiOlswLDAsMF0sIm1pbiI6WzAsMCwwXX0sInBvc2l0aW9uIjpbMCwwLDBdLCJxdWFsaXR5Ijoia2VybmVsX2V2YWx1YXRlZCJ9LCJyZW5kZXJfc3RhdHVzIjoicmVuZGVyZWQiLCJzZGtfY29ubmVjdG9yX2ZyYW1lIjp7Im9yaWdpbiI6WzAsMCwwXSwieF9heGlzIjpbMSwwLDBdLCJ5X2F4aXMiOlswLDEsMF0sInpfYXhpcyI6WzAsMCwxXX0sInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImtpbmQiOiJpbXBvcnRlZF9wcmltaXRpdmUiLCJzb3VyY2VfZWxlbWVudF9pZCI6ImVudGl0eS92ZXJ0ZXgvMCJ9fSx7ImNoaWxkX2VudGl0eV9pZHMiOltdLCJjb25uZWN0b3JfYmluZGluZ19zdGF0dXMiOiJvd25lcl9ub3RfcGFydCIsImVudGl0eV9pZCI6ImVudGl0eS92ZXJ0ZXgvMSIsImV2YWx1YXRlZF90YWdzIjpbXSwiZ2VvbWV0cnkiOnsicG9zaXRpb24iOlsxMDAwLDAsMF0sInR5cGUiOiJwb2ludCJ9LCJraW5kIjoidmVydGV4IiwicGFyZW50X2VudGl0eV9pZHMiOlsiZW50aXR5L2VkZ2UvMSIsImVudGl0eS9lZGdlLzMiLCJlbnRpdHkvZWRnZS80Il0sInByb3BlcnRpZXMiOnsiYm91bmRzIjp7Im1heCI6WzEwMDAsMCwwXSwibWluIjpbMTAwMCwwLDBdfSwicG9zaXRpb24iOlsxMDAwLDAsMF0sInF1YWxpdHkiOiJrZXJuZWxfZXZhbHVhdGVkIn0sInJlbmRlcl9zdGF0dXMiOiJyZW5kZXJlZCIsInNka19jb25uZWN0b3JfZnJhbWUiOnsib3JpZ2luIjpbMTAwMCwwLDBdLCJ4X2F4aXMiOlsxLDAsMF0sInlfYXhpcyI6WzAsMSwwXSwiel9heGlzIjpbMCwwLDFdfSwic2RrX21ldGFkYXRhIjp7fSwic2VtYW50aWNfYmluZGluZ19pZHMiOltdLCJzb3VyY2UiOnsia2luZCI6ImltcG9ydGVkX3ByaW1pdGl2ZSIsInNvdXJjZV9lbGVtZW50X2lkIjoiZW50aXR5L3ZlcnRleC8xIn19LHsiY2hpbGRfZW50aXR5X2lkcyI6W10sImNvbm5lY3Rvcl9iaW5kaW5nX3N0YXR1cyI6Im93bmVyX25vdF9wYXJ0IiwiZW50aXR5X2lkIjoiZW50aXR5L3ZlcnRleC8yIiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJwb3NpdGlvbiI6WzAsMCwxMDAwXSwidHlwZSI6InBvaW50In0sImtpbmQiOiJ2ZXJ0ZXgiLCJwYXJlbnRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZWRnZS8wIiwiZW50aXR5L2VkZ2UvNCIsImVudGl0eS9lZGdlLzUiXSwicHJvcGVydGllcyI6eyJib3VuZHMiOnsibWF4IjpbMCwwLDEwMDBdLCJtaW4iOlswLDAsMTAwMF19LCJwb3NpdGlvbiI6WzAsMCwxMDAwXSwicXVhbGl0eSI6Imtlcm5lbF9ldmFsdWF0ZWQifSwicmVuZGVyX3N0YXR1cyI6InJlbmRlcmVkIiwic2RrX2Nvbm5lY3Rvcl9mcmFtZSI6eyJvcmlnaW4iOlswLDAsMTAwMF0sInhfYXhpcyI6WzEsMCwwXSwieV9heGlzIjpbMCwxLDBdLCJ6X2F4aXMiOlswLDAsMV19LCJzZGtfbWV0YWRhdGEiOnt9LCJzZW1hbnRpY19iaW5kaW5nX2lkcyI6W10sInNvdXJjZSI6eyJraW5kIjoiaW1wb3J0ZWRfcHJpbWl0aXZlIiwic291cmNlX2VsZW1lbnRfaWQiOiJlbnRpdHkvdmVydGV4LzIifX0seyJjaGlsZF9lbnRpdHlfaWRzIjpbXSwiY29ubmVjdG9yX2JpbmRpbmdfc3RhdHVzIjoib3duZXJfbm90X3BhcnQiLCJlbnRpdHlfaWQiOiJlbnRpdHkvdmVydGV4LzMiLCJldmFsdWF0ZWRfdGFncyI6W10sImdlb21ldHJ5Ijp7InBvc2l0aW9uIjpbMCwtMTAwMCwwXSwidHlwZSI6InBvaW50In0sImtpbmQiOiJ2ZXJ0ZXgiLCJwYXJlbnRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZWRnZS8wIiwiZW50aXR5L2VkZ2UvMSIsImVudGl0eS9lZGdlLzIiXSwicHJvcGVydGllcyI6eyJib3VuZHMiOnsibWF4IjpbMCwtMTAwMCwwXSwibWluIjpbMCwtMTAwMCwwXX0sInBvc2l0aW9uIjpbMCwtMTAwMCwwXSwicXVhbGl0eSI6Imtlcm5lbF9ldmFsdWF0ZWQifSwicmVuZGVyX3N0YXR1cyI6InJlbmRlcmVkIiwic2RrX2Nvbm5lY3Rvcl9mcmFtZSI6eyJvcmlnaW4iOlswLC0xMDAwLDBdLCJ4X2F4aXMiOlsxLDAsMF0sInlfYXhpcyI6WzAsMSwwXSwiel9heGlzIjpbMCwwLDFdfSwic2RrX21ldGFkYXRhIjp7fSwic2VtYW50aWNfYmluZGluZ19pZHMiOltdLCJzb3VyY2UiOnsia2luZCI6ImltcG9ydGVkX3ByaW1pdGl2ZSIsInNvdXJjZV9lbGVtZW50X2lkIjoiZW50aXR5L3ZlcnRleC8zIn19XSwiZmFjZV9ncm91cHMiOlt7ImVudGl0eV9pZCI6ImVudGl0eS9mYWNlLzAiLCJmaXJzdF9pbmRleCI6MCwiZ3JvdXBfaWQiOjAsImluZGV4X2NvdW50IjozLCJtZXNoX2luZGV4IjowLCJwcmltaXRpdmVfaW5kZXgiOjB9LHsiZW50aXR5X2lkIjoiZW50aXR5L2ZhY2UvMSIsImZpcnN0X2luZGV4IjozLCJncm91cF9pZCI6MSwiaW5kZXhfY291bnQiOjMsIm1lc2hfaW5kZXgiOjAsInByaW1pdGl2ZV9pbmRleCI6MH0seyJlbnRpdHlfaWQiOiJlbnRpdHkvZmFjZS8yIiwiZmlyc3RfaW5kZXgiOjYsImdyb3VwX2lkIjoyLCJpbmRleF9jb3VudCI6MywibWVzaF9pbmRleCI6MCwicHJpbWl0aXZlX2luZGV4IjowfSx7ImVudGl0eV9pZCI6ImVudGl0eS9mYWNlLzMiLCJmaXJzdF9pbmRleCI6OSwiZ3JvdXBfaWQiOjMsImluZGV4X2NvdW50IjozLCJtZXNoX2luZGV4IjowLCJwcmltaXRpdmVfaW5kZXgiOjB9XSwiZ2VvbWV0cnlfYXNzZXRfaWQiOiJzaGEyNTY6MjAwNTIzNDE0ZGUyODFhOGExNTUwODZhNTRjZDBmODgwZjY0MDQ4NTc2NWE0OGNjZGQ4ZWMyMzBmNjdkZDE3YyIsImdlb21ldHJ5X2VuZ2luZSI6eyJuYW1lIjoiT3BlbkNhc2NhZGUiLCJwcm9maWxlIjoib2NwLWV2YWx1YXRlZC1wcm9wZXJ0aWVzLTEiLCJ2ZXJzaW9uIjoiZml4dHVyZSJ9LCJzY2hlbWFfdmVyc2lvbiI6IjEuMCJ9", + "valid": true + }, + { + "expected": { + "code": "source_matrix_invalid", + "path": "/entities/6/source/topology_kind" + }, + "name": "model_topology_kind_mismatch", + "payload_base64": "eyJkZWZpbml0aW9uX2lkIjoiZGVmaW5pdGlvbi9yb290L3NoYXBlL21hbnVhbC9maXh0dXJlIiwiZWRnZV9hc3NldF9pZCI6InNoYTI1Njo2Y2U0ODFkYjFmZTEzYmNmZTdhMjM3NDQyM2U2YzcyZDc1N2Q1MWM5OTE2OGIyODdjNTJmNTNhYTYyNWNjZDY3IiwiZWRnZV9ncm91cHMiOlt7ImVudGl0eV9pZCI6ImVudGl0eS9lZGdlLzAiLCJmaXJzdF9pbmRleCI6MCwiZ3JvdXBfaWQiOjAsImluZGV4X2NvdW50IjoyLCJtZXNoX2luZGV4IjowLCJwcmltaXRpdmVfaW5kZXgiOjB9LHsiZW50aXR5X2lkIjoiZW50aXR5L2VkZ2UvMSIsImZpcnN0X2luZGV4IjoyLCJncm91cF9pZCI6MSwiaW5kZXhfY291bnQiOjIsIm1lc2hfaW5kZXgiOjAsInByaW1pdGl2ZV9pbmRleCI6MH0seyJlbnRpdHlfaWQiOiJlbnRpdHkvZWRnZS8yIiwiZmlyc3RfaW5kZXgiOjQsImdyb3VwX2lkIjoyLCJpbmRleF9jb3VudCI6MiwibWVzaF9pbmRleCI6MCwicHJpbWl0aXZlX2luZGV4IjowfSx7ImVudGl0eV9pZCI6ImVudGl0eS9lZGdlLzMiLCJmaXJzdF9pbmRleCI6NiwiZ3JvdXBfaWQiOjMsImluZGV4X2NvdW50IjoyLCJtZXNoX2luZGV4IjowLCJwcmltaXRpdmVfaW5kZXgiOjB9LHsiZW50aXR5X2lkIjoiZW50aXR5L2VkZ2UvNCIsImZpcnN0X2luZGV4Ijo4LCJncm91cF9pZCI6NCwiaW5kZXhfY291bnQiOjIsIm1lc2hfaW5kZXgiOjAsInByaW1pdGl2ZV9pbmRleCI6MH0seyJlbnRpdHlfaWQiOiJlbnRpdHkvZWRnZS81IiwiZmlyc3RfaW5kZXgiOjEwLCJncm91cF9pZCI6NSwiaW5kZXhfY291bnQiOjIsIm1lc2hfaW5kZXgiOjAsInByaW1pdGl2ZV9pbmRleCI6MH1dLCJlbnRpdGllcyI6W3siY2hpbGRfZW50aXR5X2lkcyI6WyJlbnRpdHkvdmVydGV4LzIiLCJlbnRpdHkvdmVydGV4LzMiXSwiY29ubmVjdG9yX2JpbmRpbmdfc3RhdHVzIjoib3duZXJfbm90X3BhcnQiLCJlbnRpdHlfaWQiOiJlbnRpdHkvZWRnZS8wIiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJkaXJlY3Rpb24iOlswLC0wLjcwNzEwNjc4MTE4NjU0NzUsLTAuNzA3MTA2NzgxMTg2NTQ3NV0sIm9yaWdpbiI6WzAsMCwxMDAwXSwidHlwZSI6ImxpbmUifSwia2luZCI6ImVkZ2UiLCJwYXJlbnRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZmFjZS8wIiwiZW50aXR5L2ZhY2UvMyJdLCJwcm9wZXJ0aWVzIjp7ImJvdW5kcyI6eyJtYXgiOlswLDAsMTAwMF0sIm1pbiI6WzAsLTEwMDAsMF19LCJjZW50cm9pZCI6WzAsLTUwMCw1MDBdLCJsZW5ndGgiOjE0MTQuMjEzNTYyMzczMDk1LCJxdWFsaXR5Ijoia2VybmVsX2V2YWx1YXRlZCJ9LCJyZW5kZXJfc3RhdHVzIjoicmVuZGVyZWQiLCJzZGtfY29ubmVjdG9yX2ZyYW1lIjp7Im9yaWdpbiI6WzAsLTUwMCw1MDBdLCJ4X2F4aXMiOlswLC0wLjcwNzEwNjc4MTE4NjU0NzUsLTAuNzA3MTA2NzgxMTg2NTQ3NV0sInlfYXhpcyI6WzAsLTAuNzA3MTA2NzgxMTg2NTQ3NCwwLjcwNzEwNjc4MTE4NjU0NzddLCJ6X2F4aXMiOlstMSwwLDBdfSwic2RrX21ldGFkYXRhIjp7fSwic2VtYW50aWNfYmluZGluZ19pZHMiOltdLCJzb3VyY2UiOnsiZ3JhcGhfaWQiOiJncmFwaCIsImtpbmQiOiJtb2RlbF90b3BvbG9neSIsIm5vZGVfaWQiOiJib2R5Iiwib3V0cHV0X3Nsb3QiOjAsInRvcG9faWQiOiJlbnRpdHkvZWRnZS8wIiwidG9wb2xvZ3lfa2luZCI6IkVER0UifX0seyJjaGlsZF9lbnRpdHlfaWRzIjpbImVudGl0eS92ZXJ0ZXgvMSIsImVudGl0eS92ZXJ0ZXgvMyJdLCJjb25uZWN0b3JfYmluZGluZ19zdGF0dXMiOiJvd25lcl9ub3RfcGFydCIsImVudGl0eV9pZCI6ImVudGl0eS9lZGdlLzEiLCJldmFsdWF0ZWRfdGFncyI6W10sImdlb21ldHJ5Ijp7ImRpcmVjdGlvbiI6Wy0wLjcwNzEwNjc4MTE4NjU0NzUsLTAuNzA3MTA2NzgxMTg2NTQ3NSwwXSwib3JpZ2luIjpbMTAwMCwwLDBdLCJ0eXBlIjoibGluZSJ9LCJraW5kIjoiZWRnZSIsInBhcmVudF9lbnRpdHlfaWRzIjpbImVudGl0eS9mYWNlLzEiLCJlbnRpdHkvZmFjZS8zIl0sInByb3BlcnRpZXMiOnsiYm91bmRzIjp7Im1heCI6WzEwMDAsMCwwXSwibWluIjpbMCwtMTAwMCwwXX0sImNlbnRyb2lkIjpbNTAwLC01MDAsMF0sImxlbmd0aCI6MTQxNC4yMTM1NjIzNzMwOTUsInF1YWxpdHkiOiJrZXJuZWxfZXZhbHVhdGVkIn0sInJlbmRlcl9zdGF0dXMiOiJyZW5kZXJlZCIsInNka19jb25uZWN0b3JfZnJhbWUiOnsib3JpZ2luIjpbNTAwLC01MDAsMF0sInhfYXhpcyI6Wy0wLjcwNzEwNjc4MTE4NjU0NzUsLTAuNzA3MTA2NzgxMTg2NTQ3NSwwXSwieV9heGlzIjpbMCwwLDFdLCJ6X2F4aXMiOlstMC43MDcxMDY3ODExODY1NDc2LDAuNzA3MTA2NzgxMTg2NTQ3NiwwXX0sInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImdyYXBoX2lkIjoiZ3JhcGgiLCJraW5kIjoibW9kZWxfdG9wb2xvZ3kiLCJub2RlX2lkIjoiYm9keSIsIm91dHB1dF9zbG90IjowLCJ0b3BvX2lkIjoiZW50aXR5L2VkZ2UvMSIsInRvcG9sb2d5X2tpbmQiOiJFREdFIn19LHsiY2hpbGRfZW50aXR5X2lkcyI6WyJlbnRpdHkvdmVydGV4LzAiLCJlbnRpdHkvdmVydGV4LzMiXSwiY29ubmVjdG9yX2JpbmRpbmdfc3RhdHVzIjoib3duZXJfbm90X3BhcnQiLCJlbnRpdHlfaWQiOiJlbnRpdHkvZWRnZS8yIiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJkaXJlY3Rpb24iOlswLC0xLDBdLCJvcmlnaW4iOlswLDAsMF0sInR5cGUiOiJsaW5lIn0sImtpbmQiOiJlZGdlIiwicGFyZW50X2VudGl0eV9pZHMiOlsiZW50aXR5L2ZhY2UvMCIsImVudGl0eS9mYWNlLzEiXSwicHJvcGVydGllcyI6eyJib3VuZHMiOnsibWF4IjpbMCwwLDBdLCJtaW4iOlswLC0xMDAwLDBdfSwiY2VudHJvaWQiOlswLC01MDAsMF0sImxlbmd0aCI6MTAwMCwicXVhbGl0eSI6Imtlcm5lbF9ldmFsdWF0ZWQifSwicmVuZGVyX3N0YXR1cyI6InJlbmRlcmVkIiwic2RrX2Nvbm5lY3Rvcl9mcmFtZSI6eyJvcmlnaW4iOlswLC01MDAsMF0sInhfYXhpcyI6WzAsLTEsMF0sInlfYXhpcyI6WzAsMCwxXSwiel9heGlzIjpbLTEsMCwwXX0sInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImdyYXBoX2lkIjoiZ3JhcGgiLCJraW5kIjoibW9kZWxfdG9wb2xvZ3kiLCJub2RlX2lkIjoiYm9keSIsIm91dHB1dF9zbG90IjowLCJ0b3BvX2lkIjoiZW50aXR5L2VkZ2UvMiIsInRvcG9sb2d5X2tpbmQiOiJFREdFIn19LHsiY2hpbGRfZW50aXR5X2lkcyI6WyJlbnRpdHkvdmVydGV4LzAiLCJlbnRpdHkvdmVydGV4LzEiXSwiY29ubmVjdG9yX2JpbmRpbmdfc3RhdHVzIjoib3duZXJfbm90X3BhcnQiLCJlbnRpdHlfaWQiOiJlbnRpdHkvZWRnZS8zIiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJkaXJlY3Rpb24iOlsxLDAsMF0sIm9yaWdpbiI6WzAsMCwwXSwidHlwZSI6ImxpbmUifSwia2luZCI6ImVkZ2UiLCJwYXJlbnRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZmFjZS8xIiwiZW50aXR5L2ZhY2UvMiJdLCJwcm9wZXJ0aWVzIjp7ImJvdW5kcyI6eyJtYXgiOlsxMDAwLDAsMF0sIm1pbiI6WzAsMCwwXX0sImNlbnRyb2lkIjpbNTAwLDAsMF0sImxlbmd0aCI6MTAwMCwicXVhbGl0eSI6Imtlcm5lbF9ldmFsdWF0ZWQifSwicmVuZGVyX3N0YXR1cyI6InJlbmRlcmVkIiwic2RrX2Nvbm5lY3Rvcl9mcmFtZSI6eyJvcmlnaW4iOls1MDAsMCwwXSwieF9heGlzIjpbMSwwLDBdLCJ5X2F4aXMiOlswLDAsMV0sInpfYXhpcyI6WzAsLTEsMF19LCJzZGtfbWV0YWRhdGEiOnt9LCJzZW1hbnRpY19iaW5kaW5nX2lkcyI6W10sInNvdXJjZSI6eyJncmFwaF9pZCI6ImdyYXBoIiwia2luZCI6Im1vZGVsX3RvcG9sb2d5Iiwibm9kZV9pZCI6ImJvZHkiLCJvdXRwdXRfc2xvdCI6MCwidG9wb19pZCI6ImVudGl0eS9lZGdlLzMiLCJ0b3BvbG9neV9raW5kIjoiRURHRSJ9fSx7ImNoaWxkX2VudGl0eV9pZHMiOlsiZW50aXR5L3ZlcnRleC8xIiwiZW50aXR5L3ZlcnRleC8yIl0sImNvbm5lY3Rvcl9iaW5kaW5nX3N0YXR1cyI6Im93bmVyX25vdF9wYXJ0IiwiZW50aXR5X2lkIjoiZW50aXR5L2VkZ2UvNCIsImV2YWx1YXRlZF90YWdzIjpbXSwiZ2VvbWV0cnkiOnsiZGlyZWN0aW9uIjpbLTAuNzA3MTA2NzgxMTg2NTQ3NSwwLDAuNzA3MTA2NzgxMTg2NTQ3NV0sIm9yaWdpbiI6WzEwMDAsMCwwXSwidHlwZSI6ImxpbmUifSwia2luZCI6ImVkZ2UiLCJwYXJlbnRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZmFjZS8yIiwiZW50aXR5L2ZhY2UvMyJdLCJwcm9wZXJ0aWVzIjp7ImJvdW5kcyI6eyJtYXgiOlsxMDAwLDAsMTAwMF0sIm1pbiI6WzAsMCwwXX0sImNlbnRyb2lkIjpbNTAwLDAsNTAwXSwibGVuZ3RoIjoxNDE0LjIxMzU2MjM3MzA5NSwicXVhbGl0eSI6Imtlcm5lbF9ldmFsdWF0ZWQifSwicmVuZGVyX3N0YXR1cyI6InJlbmRlcmVkIiwic2RrX2Nvbm5lY3Rvcl9mcmFtZSI6eyJvcmlnaW4iOls1MDAsMCw1MDBdLCJ4X2F4aXMiOlstMC43MDcxMDY3ODExODY1NDc1LDAsMC43MDcxMDY3ODExODY1NDc1XSwieV9heGlzIjpbMC43MDcxMDY3ODExODY1NDc0LDAsMC43MDcxMDY3ODExODY1NDc3XSwiel9heGlzIjpbMCwxLDBdfSwic2RrX21ldGFkYXRhIjp7fSwic2VtYW50aWNfYmluZGluZ19pZHMiOltdLCJzb3VyY2UiOnsiZ3JhcGhfaWQiOiJncmFwaCIsImtpbmQiOiJtb2RlbF90b3BvbG9neSIsIm5vZGVfaWQiOiJib2R5Iiwib3V0cHV0X3Nsb3QiOjAsInRvcG9faWQiOiJlbnRpdHkvZWRnZS80IiwidG9wb2xvZ3lfa2luZCI6IkVER0UifX0seyJjaGlsZF9lbnRpdHlfaWRzIjpbImVudGl0eS92ZXJ0ZXgvMCIsImVudGl0eS92ZXJ0ZXgvMiJdLCJjb25uZWN0b3JfYmluZGluZ19zdGF0dXMiOiJvd25lcl9ub3RfcGFydCIsImVudGl0eV9pZCI6ImVudGl0eS9lZGdlLzUiLCJldmFsdWF0ZWRfdGFncyI6W10sImdlb21ldHJ5Ijp7ImRpcmVjdGlvbiI6WzAsMCwxXSwib3JpZ2luIjpbMCwwLDBdLCJ0eXBlIjoibGluZSJ9LCJraW5kIjoiZWRnZSIsInBhcmVudF9lbnRpdHlfaWRzIjpbImVudGl0eS9mYWNlLzAiLCJlbnRpdHkvZmFjZS8yIl0sInByb3BlcnRpZXMiOnsiYm91bmRzIjp7Im1heCI6WzAsMCwxMDAwXSwibWluIjpbMCwwLDBdfSwiY2VudHJvaWQiOlswLDAsNTAwXSwibGVuZ3RoIjoxMDAwLCJxdWFsaXR5Ijoia2VybmVsX2V2YWx1YXRlZCJ9LCJyZW5kZXJfc3RhdHVzIjoicmVuZGVyZWQiLCJzZGtfY29ubmVjdG9yX2ZyYW1lIjp7Im9yaWdpbiI6WzAsMCw1MDBdLCJ4X2F4aXMiOlswLDAsMV0sInlfYXhpcyI6WzAsMSwwXSwiel9heGlzIjpbLTEsMCwwXX0sInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImdyYXBoX2lkIjoiZ3JhcGgiLCJraW5kIjoibW9kZWxfdG9wb2xvZ3kiLCJub2RlX2lkIjoiYm9keSIsIm91dHB1dF9zbG90IjowLCJ0b3BvX2lkIjoiZW50aXR5L2VkZ2UvNSIsInRvcG9sb2d5X2tpbmQiOiJFREdFIn19LHsiY2hpbGRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZWRnZS8wIiwiZW50aXR5L2VkZ2UvMiIsImVudGl0eS9lZGdlLzUiXSwiY29ubmVjdG9yX2JpbmRpbmdfc3RhdHVzIjoib3duZXJfbm90X3BhcnQiLCJlbnRpdHlfaWQiOiJlbnRpdHkvZmFjZS8wIiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJub3JtYWwiOlstMSwwLDBdLCJvcmlnaW4iOlswLC0zMzMuMzMzMzMzMzMzMzMzMywzMzMuMzMzMzMzMzMzMzMzM10sInR5cGUiOiJwbGFuZSIsInhfZGlyZWN0aW9uIjpbMCwwLDFdfSwia2luZCI6ImZhY2UiLCJwYXJlbnRfZW50aXR5X2lkcyI6WyJlbnRpdHkvc29saWQvMCJdLCJwcm9wZXJ0aWVzIjp7ImFyZWEiOjUwMDAwMCwiYm91bmRzIjp7Im1heCI6WzAsMCwxMDAwXSwibWluIjpbMCwtMTAwMCwwXX0sImNlbnRyb2lkIjpbMCwtMzMzLjMzMzMzMzMzMzMzMzMsMzMzLjMzMzMzMzMzMzMzMzNdLCJvcmllbnRhdGlvbiI6ImZvcndhcmQiLCJxdWFsaXR5Ijoia2VybmVsX2V2YWx1YXRlZCJ9LCJyZW5kZXJfc3RhdHVzIjoicmVuZGVyZWQiLCJzZGtfY29ubmVjdG9yX2ZyYW1lIjp7Im9yaWdpbiI6WzAsLTMzMy4zMzMzMzMzMzMzMzMzLDMzMy4zMzMzMzMzMzMzMzMzXSwieF9heGlzIjpbMCwwLDFdLCJ5X2F4aXMiOlswLDEsMF0sInpfYXhpcyI6Wy0xLDAsMF19LCJzZGtfbWV0YWRhdGEiOnt9LCJzZW1hbnRpY19iaW5kaW5nX2lkcyI6W10sInNvdXJjZSI6eyJncmFwaF9pZCI6ImdyYXBoIiwia2luZCI6Im1vZGVsX3RvcG9sb2d5Iiwibm9kZV9pZCI6ImJvZHkiLCJvdXRwdXRfc2xvdCI6MCwidG9wb19pZCI6ImVudGl0eS9mYWNlLzAiLCJ0b3BvbG9neV9raW5kIjoiRURHRSJ9fSx7ImNoaWxkX2VudGl0eV9pZHMiOlsiZW50aXR5L2VkZ2UvMSIsImVudGl0eS9lZGdlLzIiLCJlbnRpdHkvZWRnZS8zIl0sImNvbm5lY3Rvcl9iaW5kaW5nX3N0YXR1cyI6Im93bmVyX25vdF9wYXJ0IiwiZW50aXR5X2lkIjoiZW50aXR5L2ZhY2UvMSIsImV2YWx1YXRlZF90YWdzIjpbXSwiZ2VvbWV0cnkiOnsibm9ybWFsIjpbMCwwLC0xXSwib3JpZ2luIjpbMzMzLjMzMzMzMzMzMzMzMzMsLTMzMy4zMzMzMzMzMzMzMzMzLDBdLCJ0eXBlIjoicGxhbmUiLCJ4X2RpcmVjdGlvbiI6WzEsMCwwXX0sImtpbmQiOiJmYWNlIiwicGFyZW50X2VudGl0eV9pZHMiOlsiZW50aXR5L3NvbGlkLzAiXSwicHJvcGVydGllcyI6eyJhcmVhIjo1MDAwMDAsImJvdW5kcyI6eyJtYXgiOlsxMDAwLDAsMF0sIm1pbiI6WzAsLTEwMDAsMF19LCJjZW50cm9pZCI6WzMzMy4zMzMzMzMzMzMzMzMzLC0zMzMuMzMzMzMzMzMzMzMzMywwXSwib3JpZW50YXRpb24iOiJmb3J3YXJkIiwicXVhbGl0eSI6Imtlcm5lbF9ldmFsdWF0ZWQifSwicmVuZGVyX3N0YXR1cyI6InJlbmRlcmVkIiwic2RrX2Nvbm5lY3Rvcl9mcmFtZSI6eyJvcmlnaW4iOlszMzMuMzMzMzMzMzMzMzMzMywtMzMzLjMzMzMzMzMzMzMzMzMsMF0sInhfYXhpcyI6WzEsMCwwXSwieV9heGlzIjpbMCwtMSwwXSwiel9heGlzIjpbMCwwLC0xXX0sInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImdyYXBoX2lkIjoiZ3JhcGgiLCJraW5kIjoibW9kZWxfdG9wb2xvZ3kiLCJub2RlX2lkIjoiYm9keSIsIm91dHB1dF9zbG90IjowLCJ0b3BvX2lkIjoiZW50aXR5L2ZhY2UvMSIsInRvcG9sb2d5X2tpbmQiOiJGQUNFIn19LHsiY2hpbGRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZWRnZS8zIiwiZW50aXR5L2VkZ2UvNCIsImVudGl0eS9lZGdlLzUiXSwiY29ubmVjdG9yX2JpbmRpbmdfc3RhdHVzIjoib3duZXJfbm90X3BhcnQiLCJlbnRpdHlfaWQiOiJlbnRpdHkvZmFjZS8yIiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJub3JtYWwiOlswLDEsMF0sIm9yaWdpbiI6WzMzMy4zMzMzMzMzMzMzMzMzLDAsMzMzLjMzMzMzMzMzMzMzMzNdLCJ0eXBlIjoicGxhbmUiLCJ4X2RpcmVjdGlvbiI6WzEsMCwwXX0sImtpbmQiOiJmYWNlIiwicGFyZW50X2VudGl0eV9pZHMiOlsiZW50aXR5L3NvbGlkLzAiXSwicHJvcGVydGllcyI6eyJhcmVhIjo1MDAwMDAsImJvdW5kcyI6eyJtYXgiOlsxMDAwLDAsMTAwMF0sIm1pbiI6WzAsMCwwXX0sImNlbnRyb2lkIjpbMzMzLjMzMzMzMzMzMzMzMzMsMCwzMzMuMzMzMzMzMzMzMzMzM10sIm9yaWVudGF0aW9uIjoiZm9yd2FyZCIsInF1YWxpdHkiOiJrZXJuZWxfZXZhbHVhdGVkIn0sInJlbmRlcl9zdGF0dXMiOiJyZW5kZXJlZCIsInNka19jb25uZWN0b3JfZnJhbWUiOnsib3JpZ2luIjpbMzMzLjMzMzMzMzMzMzMzMzMsMCwzMzMuMzMzMzMzMzMzMzMzM10sInhfYXhpcyI6WzEsMCwwXSwieV9heGlzIjpbMCwwLC0xXSwiel9heGlzIjpbMCwxLDBdfSwic2RrX21ldGFkYXRhIjp7fSwic2VtYW50aWNfYmluZGluZ19pZHMiOltdLCJzb3VyY2UiOnsiZ3JhcGhfaWQiOiJncmFwaCIsImtpbmQiOiJtb2RlbF90b3BvbG9neSIsIm5vZGVfaWQiOiJib2R5Iiwib3V0cHV0X3Nsb3QiOjAsInRvcG9faWQiOiJlbnRpdHkvZmFjZS8yIiwidG9wb2xvZ3lfa2luZCI6IkZBQ0UifX0seyJjaGlsZF9lbnRpdHlfaWRzIjpbImVudGl0eS9lZGdlLzAiLCJlbnRpdHkvZWRnZS8xIiwiZW50aXR5L2VkZ2UvNCJdLCJjb25uZWN0b3JfYmluZGluZ19zdGF0dXMiOiJvd25lcl9ub3RfcGFydCIsImVudGl0eV9pZCI6ImVudGl0eS9mYWNlLzMiLCJldmFsdWF0ZWRfdGFncyI6W10sImdlb21ldHJ5Ijp7Im5vcm1hbCI6WzAuNTc3MzUwMjY5MTg5NjI1OCwtMC41NzczNTAyNjkxODk2MjU4LDAuNTc3MzUwMjY5MTg5NjI1OF0sIm9yaWdpbiI6WzMzMy4zMzMzMzMzMzMzMzMzLC0zMzMuMzMzMzMzMzMzMzMzMywzMzMuMzMzMzMzMzMzMzMzM10sInR5cGUiOiJwbGFuZSIsInhfZGlyZWN0aW9uIjpbLTAuNzA3MTA2NzgxMTg2NTQ3NSwwLDAuNzA3MTA2NzgxMTg2NTQ3NV19LCJraW5kIjoiZmFjZSIsInBhcmVudF9lbnRpdHlfaWRzIjpbImVudGl0eS9zb2xpZC8wIl0sInByb3BlcnRpZXMiOnsiYXJlYSI6ODY2MDI1LjQwMzc4NDQzODYsImJvdW5kcyI6eyJtYXgiOlsxMDAwLDAsMTAwMF0sIm1pbiI6WzAsLTEwMDAsMF19LCJjZW50cm9pZCI6WzMzMy4zMzMzMzMzMzMzMzMzLC0zMzMuMzMzMzMzMzMzMzMzMywzMzMuMzMzMzMzMzMzMzMzM10sIm9yaWVudGF0aW9uIjoiZm9yd2FyZCIsInF1YWxpdHkiOiJrZXJuZWxfZXZhbHVhdGVkIn0sInJlbmRlcl9zdGF0dXMiOiJyZW5kZXJlZCIsInNka19jb25uZWN0b3JfZnJhbWUiOnsib3JpZ2luIjpbMzMzLjMzMzMzMzMzMzMzMzMsLTMzMy4zMzMzMzMzMzMzMzMzLDMzMy4zMzMzMzMzMzMzMzMzXSwieF9heGlzIjpbLTAuNzA3MTA2NzgxMTg2NTQ3NSwwLDAuNzA3MTA2NzgxMTg2NTQ3NV0sInlfYXhpcyI6Wy0wLjQwODI0ODI5MDQ2Mzg2MzEsLTAuODE2NDk2NTgwOTI3NzI2MSwtMC40MDgyNDgyOTA0NjM4NjMxXSwiel9heGlzIjpbMC41NzczNTAyNjkxODk2MjU4LC0wLjU3NzM1MDI2OTE4OTYyNTgsMC41NzczNTAyNjkxODk2MjU4XX0sInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImdyYXBoX2lkIjoiZ3JhcGgiLCJraW5kIjoibW9kZWxfdG9wb2xvZ3kiLCJub2RlX2lkIjoiYm9keSIsIm91dHB1dF9zbG90IjowLCJ0b3BvX2lkIjoiZW50aXR5L2ZhY2UvMyIsInRvcG9sb2d5X2tpbmQiOiJGQUNFIn19LHsiY2hpbGRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZmFjZS8wIiwiZW50aXR5L2ZhY2UvMSIsImVudGl0eS9mYWNlLzIiLCJlbnRpdHkvZmFjZS8zIl0sImNvbm5lY3Rvcl9iaW5kaW5nX3N0YXR1cyI6Im5vdF9hcHBsaWNhYmxlIiwiZW50aXR5X2lkIjoiZW50aXR5L3NvbGlkLzAiLCJldmFsdWF0ZWRfdGFncyI6W10sImdlb21ldHJ5Ijp7InR5cGUiOiJicmVwX3NvbGlkIn0sImtpbmQiOiJzb2xpZCIsInBhcmVudF9lbnRpdHlfaWRzIjpbXSwicHJvcGVydGllcyI6eyJib3VuZHMiOnsibWF4IjpbMTAwMCwwLDEwMDBdLCJtaW4iOlswLC0xMDAwLDBdfSwiY2VudHJvaWQiOlsyNTAsLTI1MCwyNTBdLCJxdWFsaXR5Ijoia2VybmVsX2V2YWx1YXRlZCIsInN1cmZhY2VfYXJlYSI6MjM2NjAyNS40MDM3ODQ0Mzg1LCJ2b2x1bWUiOjE2NjY2NjY2Ni42NjY2NjY2Nn0sInJlbmRlcl9zdGF0dXMiOiJyZW5kZXJlZCIsInNka19jb25uZWN0b3JfZnJhbWUiOm51bGwsInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImdyYXBoX2lkIjoiZ3JhcGgiLCJraW5kIjoibW9kZWxfdG9wb2xvZ3kiLCJub2RlX2lkIjoiYm9keSIsIm91dHB1dF9zbG90IjowLCJ0b3BvX2lkIjoiZW50aXR5L3NvbGlkLzAiLCJ0b3BvbG9neV9raW5kIjoiU09MSUQifX0seyJjaGlsZF9lbnRpdHlfaWRzIjpbXSwiY29ubmVjdG9yX2JpbmRpbmdfc3RhdHVzIjoib3duZXJfbm90X3BhcnQiLCJlbnRpdHlfaWQiOiJlbnRpdHkvdmVydGV4LzAiLCJldmFsdWF0ZWRfdGFncyI6W10sImdlb21ldHJ5Ijp7InBvc2l0aW9uIjpbMCwwLDBdLCJ0eXBlIjoicG9pbnQifSwia2luZCI6InZlcnRleCIsInBhcmVudF9lbnRpdHlfaWRzIjpbImVudGl0eS9lZGdlLzIiLCJlbnRpdHkvZWRnZS8zIiwiZW50aXR5L2VkZ2UvNSJdLCJwcm9wZXJ0aWVzIjp7ImJvdW5kcyI6eyJtYXgiOlswLDAsMF0sIm1pbiI6WzAsMCwwXX0sInBvc2l0aW9uIjpbMCwwLDBdLCJxdWFsaXR5Ijoia2VybmVsX2V2YWx1YXRlZCJ9LCJyZW5kZXJfc3RhdHVzIjoicmVuZGVyZWQiLCJzZGtfY29ubmVjdG9yX2ZyYW1lIjp7Im9yaWdpbiI6WzAsMCwwXSwieF9heGlzIjpbMSwwLDBdLCJ5X2F4aXMiOlswLDEsMF0sInpfYXhpcyI6WzAsMCwxXX0sInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImdyYXBoX2lkIjoiZ3JhcGgiLCJraW5kIjoibW9kZWxfdG9wb2xvZ3kiLCJub2RlX2lkIjoiYm9keSIsIm91dHB1dF9zbG90IjowLCJ0b3BvX2lkIjoiZW50aXR5L3ZlcnRleC8wIiwidG9wb2xvZ3lfa2luZCI6IlZFUlRFWCJ9fSx7ImNoaWxkX2VudGl0eV9pZHMiOltdLCJjb25uZWN0b3JfYmluZGluZ19zdGF0dXMiOiJvd25lcl9ub3RfcGFydCIsImVudGl0eV9pZCI6ImVudGl0eS92ZXJ0ZXgvMSIsImV2YWx1YXRlZF90YWdzIjpbXSwiZ2VvbWV0cnkiOnsicG9zaXRpb24iOlsxMDAwLDAsMF0sInR5cGUiOiJwb2ludCJ9LCJraW5kIjoidmVydGV4IiwicGFyZW50X2VudGl0eV9pZHMiOlsiZW50aXR5L2VkZ2UvMSIsImVudGl0eS9lZGdlLzMiLCJlbnRpdHkvZWRnZS80Il0sInByb3BlcnRpZXMiOnsiYm91bmRzIjp7Im1heCI6WzEwMDAsMCwwXSwibWluIjpbMTAwMCwwLDBdfSwicG9zaXRpb24iOlsxMDAwLDAsMF0sInF1YWxpdHkiOiJrZXJuZWxfZXZhbHVhdGVkIn0sInJlbmRlcl9zdGF0dXMiOiJyZW5kZXJlZCIsInNka19jb25uZWN0b3JfZnJhbWUiOnsib3JpZ2luIjpbMTAwMCwwLDBdLCJ4X2F4aXMiOlsxLDAsMF0sInlfYXhpcyI6WzAsMSwwXSwiel9heGlzIjpbMCwwLDFdfSwic2RrX21ldGFkYXRhIjp7fSwic2VtYW50aWNfYmluZGluZ19pZHMiOltdLCJzb3VyY2UiOnsiZ3JhcGhfaWQiOiJncmFwaCIsImtpbmQiOiJtb2RlbF90b3BvbG9neSIsIm5vZGVfaWQiOiJib2R5Iiwib3V0cHV0X3Nsb3QiOjAsInRvcG9faWQiOiJlbnRpdHkvdmVydGV4LzEiLCJ0b3BvbG9neV9raW5kIjoiVkVSVEVYIn19LHsiY2hpbGRfZW50aXR5X2lkcyI6W10sImNvbm5lY3Rvcl9iaW5kaW5nX3N0YXR1cyI6Im93bmVyX25vdF9wYXJ0IiwiZW50aXR5X2lkIjoiZW50aXR5L3ZlcnRleC8yIiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJwb3NpdGlvbiI6WzAsMCwxMDAwXSwidHlwZSI6InBvaW50In0sImtpbmQiOiJ2ZXJ0ZXgiLCJwYXJlbnRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZWRnZS8wIiwiZW50aXR5L2VkZ2UvNCIsImVudGl0eS9lZGdlLzUiXSwicHJvcGVydGllcyI6eyJib3VuZHMiOnsibWF4IjpbMCwwLDEwMDBdLCJtaW4iOlswLDAsMTAwMF19LCJwb3NpdGlvbiI6WzAsMCwxMDAwXSwicXVhbGl0eSI6Imtlcm5lbF9ldmFsdWF0ZWQifSwicmVuZGVyX3N0YXR1cyI6InJlbmRlcmVkIiwic2RrX2Nvbm5lY3Rvcl9mcmFtZSI6eyJvcmlnaW4iOlswLDAsMTAwMF0sInhfYXhpcyI6WzEsMCwwXSwieV9heGlzIjpbMCwxLDBdLCJ6X2F4aXMiOlswLDAsMV19LCJzZGtfbWV0YWRhdGEiOnt9LCJzZW1hbnRpY19iaW5kaW5nX2lkcyI6W10sInNvdXJjZSI6eyJncmFwaF9pZCI6ImdyYXBoIiwia2luZCI6Im1vZGVsX3RvcG9sb2d5Iiwibm9kZV9pZCI6ImJvZHkiLCJvdXRwdXRfc2xvdCI6MCwidG9wb19pZCI6ImVudGl0eS92ZXJ0ZXgvMiIsInRvcG9sb2d5X2tpbmQiOiJWRVJURVgifX0seyJjaGlsZF9lbnRpdHlfaWRzIjpbXSwiY29ubmVjdG9yX2JpbmRpbmdfc3RhdHVzIjoib3duZXJfbm90X3BhcnQiLCJlbnRpdHlfaWQiOiJlbnRpdHkvdmVydGV4LzMiLCJldmFsdWF0ZWRfdGFncyI6W10sImdlb21ldHJ5Ijp7InBvc2l0aW9uIjpbMCwtMTAwMCwwXSwidHlwZSI6InBvaW50In0sImtpbmQiOiJ2ZXJ0ZXgiLCJwYXJlbnRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZWRnZS8wIiwiZW50aXR5L2VkZ2UvMSIsImVudGl0eS9lZGdlLzIiXSwicHJvcGVydGllcyI6eyJib3VuZHMiOnsibWF4IjpbMCwtMTAwMCwwXSwibWluIjpbMCwtMTAwMCwwXX0sInBvc2l0aW9uIjpbMCwtMTAwMCwwXSwicXVhbGl0eSI6Imtlcm5lbF9ldmFsdWF0ZWQifSwicmVuZGVyX3N0YXR1cyI6InJlbmRlcmVkIiwic2RrX2Nvbm5lY3Rvcl9mcmFtZSI6eyJvcmlnaW4iOlswLC0xMDAwLDBdLCJ4X2F4aXMiOlsxLDAsMF0sInlfYXhpcyI6WzAsMSwwXSwiel9heGlzIjpbMCwwLDFdfSwic2RrX21ldGFkYXRhIjp7fSwic2VtYW50aWNfYmluZGluZ19pZHMiOltdLCJzb3VyY2UiOnsiZ3JhcGhfaWQiOiJncmFwaCIsImtpbmQiOiJtb2RlbF90b3BvbG9neSIsIm5vZGVfaWQiOiJib2R5Iiwib3V0cHV0X3Nsb3QiOjAsInRvcG9faWQiOiJlbnRpdHkvdmVydGV4LzMiLCJ0b3BvbG9neV9raW5kIjoiVkVSVEVYIn19XSwiZmFjZV9ncm91cHMiOlt7ImVudGl0eV9pZCI6ImVudGl0eS9mYWNlLzAiLCJmaXJzdF9pbmRleCI6MCwiZ3JvdXBfaWQiOjAsImluZGV4X2NvdW50IjozLCJtZXNoX2luZGV4IjowLCJwcmltaXRpdmVfaW5kZXgiOjB9LHsiZW50aXR5X2lkIjoiZW50aXR5L2ZhY2UvMSIsImZpcnN0X2luZGV4IjozLCJncm91cF9pZCI6MSwiaW5kZXhfY291bnQiOjMsIm1lc2hfaW5kZXgiOjAsInByaW1pdGl2ZV9pbmRleCI6MH0seyJlbnRpdHlfaWQiOiJlbnRpdHkvZmFjZS8yIiwiZmlyc3RfaW5kZXgiOjYsImdyb3VwX2lkIjoyLCJpbmRleF9jb3VudCI6MywibWVzaF9pbmRleCI6MCwicHJpbWl0aXZlX2luZGV4IjowfSx7ImVudGl0eV9pZCI6ImVudGl0eS9mYWNlLzMiLCJmaXJzdF9pbmRleCI6OSwiZ3JvdXBfaWQiOjMsImluZGV4X2NvdW50IjozLCJtZXNoX2luZGV4IjowLCJwcmltaXRpdmVfaW5kZXgiOjB9XSwiZ2VvbWV0cnlfYXNzZXRfaWQiOiJzaGEyNTY6MjAwNTIzNDE0ZGUyODFhOGExNTUwODZhNTRjZDBmODgwZjY0MDQ4NTc2NWE0OGNjZGQ4ZWMyMzBmNjdkZDE3YyIsImdlb21ldHJ5X2VuZ2luZSI6eyJuYW1lIjoiT3BlbkNhc2NhZGUiLCJwcm9maWxlIjoib2NwLWV2YWx1YXRlZC1wcm9wZXJ0aWVzLTEiLCJ2ZXJzaW9uIjoiZml4dHVyZSJ9LCJzY2hlbWFfdmVyc2lvbiI6IjEuMCJ9", + "valid": false + }, + { + "expected": { + "code": "entity_topology_invalid", + "path": "/entities/6/child_entity_ids" + }, + "name": "missing_reciprocal_adjacency", + "payload_base64": "eyJkZWZpbml0aW9uX2lkIjoiZGVmaW5pdGlvbi9yb290L3NoYXBlL21hbnVhbC9maXh0dXJlIiwiZWRnZV9hc3NldF9pZCI6InNoYTI1Njo2Y2U0ODFkYjFmZTEzYmNmZTdhMjM3NDQyM2U2YzcyZDc1N2Q1MWM5OTE2OGIyODdjNTJmNTNhYTYyNWNjZDY3IiwiZWRnZV9ncm91cHMiOlt7ImVudGl0eV9pZCI6ImVudGl0eS9lZGdlLzAiLCJmaXJzdF9pbmRleCI6MCwiZ3JvdXBfaWQiOjAsImluZGV4X2NvdW50IjoyLCJtZXNoX2luZGV4IjowLCJwcmltaXRpdmVfaW5kZXgiOjB9LHsiZW50aXR5X2lkIjoiZW50aXR5L2VkZ2UvMSIsImZpcnN0X2luZGV4IjoyLCJncm91cF9pZCI6MSwiaW5kZXhfY291bnQiOjIsIm1lc2hfaW5kZXgiOjAsInByaW1pdGl2ZV9pbmRleCI6MH0seyJlbnRpdHlfaWQiOiJlbnRpdHkvZWRnZS8yIiwiZmlyc3RfaW5kZXgiOjQsImdyb3VwX2lkIjoyLCJpbmRleF9jb3VudCI6MiwibWVzaF9pbmRleCI6MCwicHJpbWl0aXZlX2luZGV4IjowfSx7ImVudGl0eV9pZCI6ImVudGl0eS9lZGdlLzMiLCJmaXJzdF9pbmRleCI6NiwiZ3JvdXBfaWQiOjMsImluZGV4X2NvdW50IjoyLCJtZXNoX2luZGV4IjowLCJwcmltaXRpdmVfaW5kZXgiOjB9LHsiZW50aXR5X2lkIjoiZW50aXR5L2VkZ2UvNCIsImZpcnN0X2luZGV4Ijo4LCJncm91cF9pZCI6NCwiaW5kZXhfY291bnQiOjIsIm1lc2hfaW5kZXgiOjAsInByaW1pdGl2ZV9pbmRleCI6MH0seyJlbnRpdHlfaWQiOiJlbnRpdHkvZWRnZS81IiwiZmlyc3RfaW5kZXgiOjEwLCJncm91cF9pZCI6NSwiaW5kZXhfY291bnQiOjIsIm1lc2hfaW5kZXgiOjAsInByaW1pdGl2ZV9pbmRleCI6MH1dLCJlbnRpdGllcyI6W3siY2hpbGRfZW50aXR5X2lkcyI6WyJlbnRpdHkvdmVydGV4LzIiLCJlbnRpdHkvdmVydGV4LzMiXSwiY29ubmVjdG9yX2JpbmRpbmdfc3RhdHVzIjoib3duZXJfbm90X3BhcnQiLCJlbnRpdHlfaWQiOiJlbnRpdHkvZWRnZS8wIiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJkaXJlY3Rpb24iOlswLC0wLjcwNzEwNjc4MTE4NjU0NzUsLTAuNzA3MTA2NzgxMTg2NTQ3NV0sIm9yaWdpbiI6WzAsMCwxMDAwXSwidHlwZSI6ImxpbmUifSwia2luZCI6ImVkZ2UiLCJwYXJlbnRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZmFjZS8zIl0sInByb3BlcnRpZXMiOnsiYm91bmRzIjp7Im1heCI6WzAsMCwxMDAwXSwibWluIjpbMCwtMTAwMCwwXX0sImNlbnRyb2lkIjpbMCwtNTAwLDUwMF0sImxlbmd0aCI6MTQxNC4yMTM1NjIzNzMwOTUsInF1YWxpdHkiOiJrZXJuZWxfZXZhbHVhdGVkIn0sInJlbmRlcl9zdGF0dXMiOiJyZW5kZXJlZCIsInNka19jb25uZWN0b3JfZnJhbWUiOnsib3JpZ2luIjpbMCwtNTAwLDUwMF0sInhfYXhpcyI6WzAsLTAuNzA3MTA2NzgxMTg2NTQ3NSwtMC43MDcxMDY3ODExODY1NDc1XSwieV9heGlzIjpbMCwtMC43MDcxMDY3ODExODY1NDc0LDAuNzA3MTA2NzgxMTg2NTQ3N10sInpfYXhpcyI6Wy0xLDAsMF19LCJzZGtfbWV0YWRhdGEiOnt9LCJzZW1hbnRpY19iaW5kaW5nX2lkcyI6W10sInNvdXJjZSI6eyJraW5kIjoidW5ib3VuZCJ9fSx7ImNoaWxkX2VudGl0eV9pZHMiOlsiZW50aXR5L3ZlcnRleC8xIiwiZW50aXR5L3ZlcnRleC8zIl0sImNvbm5lY3Rvcl9iaW5kaW5nX3N0YXR1cyI6Im93bmVyX25vdF9wYXJ0IiwiZW50aXR5X2lkIjoiZW50aXR5L2VkZ2UvMSIsImV2YWx1YXRlZF90YWdzIjpbXSwiZ2VvbWV0cnkiOnsiZGlyZWN0aW9uIjpbLTAuNzA3MTA2NzgxMTg2NTQ3NSwtMC43MDcxMDY3ODExODY1NDc1LDBdLCJvcmlnaW4iOlsxMDAwLDAsMF0sInR5cGUiOiJsaW5lIn0sImtpbmQiOiJlZGdlIiwicGFyZW50X2VudGl0eV9pZHMiOlsiZW50aXR5L2ZhY2UvMSIsImVudGl0eS9mYWNlLzMiXSwicHJvcGVydGllcyI6eyJib3VuZHMiOnsibWF4IjpbMTAwMCwwLDBdLCJtaW4iOlswLC0xMDAwLDBdfSwiY2VudHJvaWQiOls1MDAsLTUwMCwwXSwibGVuZ3RoIjoxNDE0LjIxMzU2MjM3MzA5NSwicXVhbGl0eSI6Imtlcm5lbF9ldmFsdWF0ZWQifSwicmVuZGVyX3N0YXR1cyI6InJlbmRlcmVkIiwic2RrX2Nvbm5lY3Rvcl9mcmFtZSI6eyJvcmlnaW4iOls1MDAsLTUwMCwwXSwieF9heGlzIjpbLTAuNzA3MTA2NzgxMTg2NTQ3NSwtMC43MDcxMDY3ODExODY1NDc1LDBdLCJ5X2F4aXMiOlswLDAsMV0sInpfYXhpcyI6Wy0wLjcwNzEwNjc4MTE4NjU0NzYsMC43MDcxMDY3ODExODY1NDc2LDBdfSwic2RrX21ldGFkYXRhIjp7fSwic2VtYW50aWNfYmluZGluZ19pZHMiOltdLCJzb3VyY2UiOnsia2luZCI6InVuYm91bmQifX0seyJjaGlsZF9lbnRpdHlfaWRzIjpbImVudGl0eS92ZXJ0ZXgvMCIsImVudGl0eS92ZXJ0ZXgvMyJdLCJjb25uZWN0b3JfYmluZGluZ19zdGF0dXMiOiJvd25lcl9ub3RfcGFydCIsImVudGl0eV9pZCI6ImVudGl0eS9lZGdlLzIiLCJldmFsdWF0ZWRfdGFncyI6W10sImdlb21ldHJ5Ijp7ImRpcmVjdGlvbiI6WzAsLTEsMF0sIm9yaWdpbiI6WzAsMCwwXSwidHlwZSI6ImxpbmUifSwia2luZCI6ImVkZ2UiLCJwYXJlbnRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZmFjZS8wIiwiZW50aXR5L2ZhY2UvMSJdLCJwcm9wZXJ0aWVzIjp7ImJvdW5kcyI6eyJtYXgiOlswLDAsMF0sIm1pbiI6WzAsLTEwMDAsMF19LCJjZW50cm9pZCI6WzAsLTUwMCwwXSwibGVuZ3RoIjoxMDAwLCJxdWFsaXR5Ijoia2VybmVsX2V2YWx1YXRlZCJ9LCJyZW5kZXJfc3RhdHVzIjoicmVuZGVyZWQiLCJzZGtfY29ubmVjdG9yX2ZyYW1lIjp7Im9yaWdpbiI6WzAsLTUwMCwwXSwieF9heGlzIjpbMCwtMSwwXSwieV9heGlzIjpbMCwwLDFdLCJ6X2F4aXMiOlstMSwwLDBdfSwic2RrX21ldGFkYXRhIjp7fSwic2VtYW50aWNfYmluZGluZ19pZHMiOltdLCJzb3VyY2UiOnsia2luZCI6InVuYm91bmQifX0seyJjaGlsZF9lbnRpdHlfaWRzIjpbImVudGl0eS92ZXJ0ZXgvMCIsImVudGl0eS92ZXJ0ZXgvMSJdLCJjb25uZWN0b3JfYmluZGluZ19zdGF0dXMiOiJvd25lcl9ub3RfcGFydCIsImVudGl0eV9pZCI6ImVudGl0eS9lZGdlLzMiLCJldmFsdWF0ZWRfdGFncyI6W10sImdlb21ldHJ5Ijp7ImRpcmVjdGlvbiI6WzEsMCwwXSwib3JpZ2luIjpbMCwwLDBdLCJ0eXBlIjoibGluZSJ9LCJraW5kIjoiZWRnZSIsInBhcmVudF9lbnRpdHlfaWRzIjpbImVudGl0eS9mYWNlLzEiLCJlbnRpdHkvZmFjZS8yIl0sInByb3BlcnRpZXMiOnsiYm91bmRzIjp7Im1heCI6WzEwMDAsMCwwXSwibWluIjpbMCwwLDBdfSwiY2VudHJvaWQiOls1MDAsMCwwXSwibGVuZ3RoIjoxMDAwLCJxdWFsaXR5Ijoia2VybmVsX2V2YWx1YXRlZCJ9LCJyZW5kZXJfc3RhdHVzIjoicmVuZGVyZWQiLCJzZGtfY29ubmVjdG9yX2ZyYW1lIjp7Im9yaWdpbiI6WzUwMCwwLDBdLCJ4X2F4aXMiOlsxLDAsMF0sInlfYXhpcyI6WzAsMCwxXSwiel9heGlzIjpbMCwtMSwwXX0sInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImtpbmQiOiJ1bmJvdW5kIn19LHsiY2hpbGRfZW50aXR5X2lkcyI6WyJlbnRpdHkvdmVydGV4LzEiLCJlbnRpdHkvdmVydGV4LzIiXSwiY29ubmVjdG9yX2JpbmRpbmdfc3RhdHVzIjoib3duZXJfbm90X3BhcnQiLCJlbnRpdHlfaWQiOiJlbnRpdHkvZWRnZS80IiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJkaXJlY3Rpb24iOlstMC43MDcxMDY3ODExODY1NDc1LDAsMC43MDcxMDY3ODExODY1NDc1XSwib3JpZ2luIjpbMTAwMCwwLDBdLCJ0eXBlIjoibGluZSJ9LCJraW5kIjoiZWRnZSIsInBhcmVudF9lbnRpdHlfaWRzIjpbImVudGl0eS9mYWNlLzIiLCJlbnRpdHkvZmFjZS8zIl0sInByb3BlcnRpZXMiOnsiYm91bmRzIjp7Im1heCI6WzEwMDAsMCwxMDAwXSwibWluIjpbMCwwLDBdfSwiY2VudHJvaWQiOls1MDAsMCw1MDBdLCJsZW5ndGgiOjE0MTQuMjEzNTYyMzczMDk1LCJxdWFsaXR5Ijoia2VybmVsX2V2YWx1YXRlZCJ9LCJyZW5kZXJfc3RhdHVzIjoicmVuZGVyZWQiLCJzZGtfY29ubmVjdG9yX2ZyYW1lIjp7Im9yaWdpbiI6WzUwMCwwLDUwMF0sInhfYXhpcyI6Wy0wLjcwNzEwNjc4MTE4NjU0NzUsMCwwLjcwNzEwNjc4MTE4NjU0NzVdLCJ5X2F4aXMiOlswLjcwNzEwNjc4MTE4NjU0NzQsMCwwLjcwNzEwNjc4MTE4NjU0NzddLCJ6X2F4aXMiOlswLDEsMF19LCJzZGtfbWV0YWRhdGEiOnt9LCJzZW1hbnRpY19iaW5kaW5nX2lkcyI6W10sInNvdXJjZSI6eyJraW5kIjoidW5ib3VuZCJ9fSx7ImNoaWxkX2VudGl0eV9pZHMiOlsiZW50aXR5L3ZlcnRleC8wIiwiZW50aXR5L3ZlcnRleC8yIl0sImNvbm5lY3Rvcl9iaW5kaW5nX3N0YXR1cyI6Im93bmVyX25vdF9wYXJ0IiwiZW50aXR5X2lkIjoiZW50aXR5L2VkZ2UvNSIsImV2YWx1YXRlZF90YWdzIjpbXSwiZ2VvbWV0cnkiOnsiZGlyZWN0aW9uIjpbMCwwLDFdLCJvcmlnaW4iOlswLDAsMF0sInR5cGUiOiJsaW5lIn0sImtpbmQiOiJlZGdlIiwicGFyZW50X2VudGl0eV9pZHMiOlsiZW50aXR5L2ZhY2UvMCIsImVudGl0eS9mYWNlLzIiXSwicHJvcGVydGllcyI6eyJib3VuZHMiOnsibWF4IjpbMCwwLDEwMDBdLCJtaW4iOlswLDAsMF19LCJjZW50cm9pZCI6WzAsMCw1MDBdLCJsZW5ndGgiOjEwMDAsInF1YWxpdHkiOiJrZXJuZWxfZXZhbHVhdGVkIn0sInJlbmRlcl9zdGF0dXMiOiJyZW5kZXJlZCIsInNka19jb25uZWN0b3JfZnJhbWUiOnsib3JpZ2luIjpbMCwwLDUwMF0sInhfYXhpcyI6WzAsMCwxXSwieV9heGlzIjpbMCwxLDBdLCJ6X2F4aXMiOlstMSwwLDBdfSwic2RrX21ldGFkYXRhIjp7fSwic2VtYW50aWNfYmluZGluZ19pZHMiOltdLCJzb3VyY2UiOnsia2luZCI6InVuYm91bmQifX0seyJjaGlsZF9lbnRpdHlfaWRzIjpbImVudGl0eS9lZGdlLzAiLCJlbnRpdHkvZWRnZS8yIiwiZW50aXR5L2VkZ2UvNSJdLCJjb25uZWN0b3JfYmluZGluZ19zdGF0dXMiOiJvd25lcl9ub3RfcGFydCIsImVudGl0eV9pZCI6ImVudGl0eS9mYWNlLzAiLCJldmFsdWF0ZWRfdGFncyI6W10sImdlb21ldHJ5Ijp7Im5vcm1hbCI6Wy0xLDAsMF0sIm9yaWdpbiI6WzAsLTMzMy4zMzMzMzMzMzMzMzMzLDMzMy4zMzMzMzMzMzMzMzMzXSwidHlwZSI6InBsYW5lIiwieF9kaXJlY3Rpb24iOlswLDAsMV19LCJraW5kIjoiZmFjZSIsInBhcmVudF9lbnRpdHlfaWRzIjpbImVudGl0eS9zb2xpZC8wIl0sInByb3BlcnRpZXMiOnsiYXJlYSI6NTAwMDAwLCJib3VuZHMiOnsibWF4IjpbMCwwLDEwMDBdLCJtaW4iOlswLC0xMDAwLDBdfSwiY2VudHJvaWQiOlswLC0zMzMuMzMzMzMzMzMzMzMzMywzMzMuMzMzMzMzMzMzMzMzM10sIm9yaWVudGF0aW9uIjoiZm9yd2FyZCIsInF1YWxpdHkiOiJrZXJuZWxfZXZhbHVhdGVkIn0sInJlbmRlcl9zdGF0dXMiOiJyZW5kZXJlZCIsInNka19jb25uZWN0b3JfZnJhbWUiOnsib3JpZ2luIjpbMCwtMzMzLjMzMzMzMzMzMzMzMzMsMzMzLjMzMzMzMzMzMzMzMzNdLCJ4X2F4aXMiOlswLDAsMV0sInlfYXhpcyI6WzAsMSwwXSwiel9heGlzIjpbLTEsMCwwXX0sInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImtpbmQiOiJ1bmJvdW5kIn19LHsiY2hpbGRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZWRnZS8xIiwiZW50aXR5L2VkZ2UvMiIsImVudGl0eS9lZGdlLzMiXSwiY29ubmVjdG9yX2JpbmRpbmdfc3RhdHVzIjoib3duZXJfbm90X3BhcnQiLCJlbnRpdHlfaWQiOiJlbnRpdHkvZmFjZS8xIiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJub3JtYWwiOlswLDAsLTFdLCJvcmlnaW4iOlszMzMuMzMzMzMzMzMzMzMzMywtMzMzLjMzMzMzMzMzMzMzMzMsMF0sInR5cGUiOiJwbGFuZSIsInhfZGlyZWN0aW9uIjpbMSwwLDBdfSwia2luZCI6ImZhY2UiLCJwYXJlbnRfZW50aXR5X2lkcyI6WyJlbnRpdHkvc29saWQvMCJdLCJwcm9wZXJ0aWVzIjp7ImFyZWEiOjUwMDAwMCwiYm91bmRzIjp7Im1heCI6WzEwMDAsMCwwXSwibWluIjpbMCwtMTAwMCwwXX0sImNlbnRyb2lkIjpbMzMzLjMzMzMzMzMzMzMzMzMsLTMzMy4zMzMzMzMzMzMzMzMzLDBdLCJvcmllbnRhdGlvbiI6ImZvcndhcmQiLCJxdWFsaXR5Ijoia2VybmVsX2V2YWx1YXRlZCJ9LCJyZW5kZXJfc3RhdHVzIjoicmVuZGVyZWQiLCJzZGtfY29ubmVjdG9yX2ZyYW1lIjp7Im9yaWdpbiI6WzMzMy4zMzMzMzMzMzMzMzMzLC0zMzMuMzMzMzMzMzMzMzMzMywwXSwieF9heGlzIjpbMSwwLDBdLCJ5X2F4aXMiOlswLC0xLDBdLCJ6X2F4aXMiOlswLDAsLTFdfSwic2RrX21ldGFkYXRhIjp7fSwic2VtYW50aWNfYmluZGluZ19pZHMiOltdLCJzb3VyY2UiOnsia2luZCI6InVuYm91bmQifX0seyJjaGlsZF9lbnRpdHlfaWRzIjpbImVudGl0eS9lZGdlLzMiLCJlbnRpdHkvZWRnZS80IiwiZW50aXR5L2VkZ2UvNSJdLCJjb25uZWN0b3JfYmluZGluZ19zdGF0dXMiOiJvd25lcl9ub3RfcGFydCIsImVudGl0eV9pZCI6ImVudGl0eS9mYWNlLzIiLCJldmFsdWF0ZWRfdGFncyI6W10sImdlb21ldHJ5Ijp7Im5vcm1hbCI6WzAsMSwwXSwib3JpZ2luIjpbMzMzLjMzMzMzMzMzMzMzMzMsMCwzMzMuMzMzMzMzMzMzMzMzM10sInR5cGUiOiJwbGFuZSIsInhfZGlyZWN0aW9uIjpbMSwwLDBdfSwia2luZCI6ImZhY2UiLCJwYXJlbnRfZW50aXR5X2lkcyI6WyJlbnRpdHkvc29saWQvMCJdLCJwcm9wZXJ0aWVzIjp7ImFyZWEiOjUwMDAwMCwiYm91bmRzIjp7Im1heCI6WzEwMDAsMCwxMDAwXSwibWluIjpbMCwwLDBdfSwiY2VudHJvaWQiOlszMzMuMzMzMzMzMzMzMzMzMywwLDMzMy4zMzMzMzMzMzMzMzMzXSwib3JpZW50YXRpb24iOiJmb3J3YXJkIiwicXVhbGl0eSI6Imtlcm5lbF9ldmFsdWF0ZWQifSwicmVuZGVyX3N0YXR1cyI6InJlbmRlcmVkIiwic2RrX2Nvbm5lY3Rvcl9mcmFtZSI6eyJvcmlnaW4iOlszMzMuMzMzMzMzMzMzMzMzMywwLDMzMy4zMzMzMzMzMzMzMzMzXSwieF9heGlzIjpbMSwwLDBdLCJ5X2F4aXMiOlswLDAsLTFdLCJ6X2F4aXMiOlswLDEsMF19LCJzZGtfbWV0YWRhdGEiOnt9LCJzZW1hbnRpY19iaW5kaW5nX2lkcyI6W10sInNvdXJjZSI6eyJraW5kIjoidW5ib3VuZCJ9fSx7ImNoaWxkX2VudGl0eV9pZHMiOlsiZW50aXR5L2VkZ2UvMCIsImVudGl0eS9lZGdlLzEiLCJlbnRpdHkvZWRnZS80Il0sImNvbm5lY3Rvcl9iaW5kaW5nX3N0YXR1cyI6Im93bmVyX25vdF9wYXJ0IiwiZW50aXR5X2lkIjoiZW50aXR5L2ZhY2UvMyIsImV2YWx1YXRlZF90YWdzIjpbXSwiZ2VvbWV0cnkiOnsibm9ybWFsIjpbMC41NzczNTAyNjkxODk2MjU4LC0wLjU3NzM1MDI2OTE4OTYyNTgsMC41NzczNTAyNjkxODk2MjU4XSwib3JpZ2luIjpbMzMzLjMzMzMzMzMzMzMzMzMsLTMzMy4zMzMzMzMzMzMzMzMzLDMzMy4zMzMzMzMzMzMzMzMzXSwidHlwZSI6InBsYW5lIiwieF9kaXJlY3Rpb24iOlstMC43MDcxMDY3ODExODY1NDc1LDAsMC43MDcxMDY3ODExODY1NDc1XX0sImtpbmQiOiJmYWNlIiwicGFyZW50X2VudGl0eV9pZHMiOlsiZW50aXR5L3NvbGlkLzAiXSwicHJvcGVydGllcyI6eyJhcmVhIjo4NjYwMjUuNDAzNzg0NDM4NiwiYm91bmRzIjp7Im1heCI6WzEwMDAsMCwxMDAwXSwibWluIjpbMCwtMTAwMCwwXX0sImNlbnRyb2lkIjpbMzMzLjMzMzMzMzMzMzMzMzMsLTMzMy4zMzMzMzMzMzMzMzMzLDMzMy4zMzMzMzMzMzMzMzMzXSwib3JpZW50YXRpb24iOiJmb3J3YXJkIiwicXVhbGl0eSI6Imtlcm5lbF9ldmFsdWF0ZWQifSwicmVuZGVyX3N0YXR1cyI6InJlbmRlcmVkIiwic2RrX2Nvbm5lY3Rvcl9mcmFtZSI6eyJvcmlnaW4iOlszMzMuMzMzMzMzMzMzMzMzMywtMzMzLjMzMzMzMzMzMzMzMzMsMzMzLjMzMzMzMzMzMzMzMzNdLCJ4X2F4aXMiOlstMC43MDcxMDY3ODExODY1NDc1LDAsMC43MDcxMDY3ODExODY1NDc1XSwieV9heGlzIjpbLTAuNDA4MjQ4MjkwNDYzODYzMSwtMC44MTY0OTY1ODA5Mjc3MjYxLC0wLjQwODI0ODI5MDQ2Mzg2MzFdLCJ6X2F4aXMiOlswLjU3NzM1MDI2OTE4OTYyNTgsLTAuNTc3MzUwMjY5MTg5NjI1OCwwLjU3NzM1MDI2OTE4OTYyNThdfSwic2RrX21ldGFkYXRhIjp7fSwic2VtYW50aWNfYmluZGluZ19pZHMiOltdLCJzb3VyY2UiOnsia2luZCI6InVuYm91bmQifX0seyJjaGlsZF9lbnRpdHlfaWRzIjpbImVudGl0eS9mYWNlLzAiLCJlbnRpdHkvZmFjZS8xIiwiZW50aXR5L2ZhY2UvMiIsImVudGl0eS9mYWNlLzMiXSwiY29ubmVjdG9yX2JpbmRpbmdfc3RhdHVzIjoibm90X2FwcGxpY2FibGUiLCJlbnRpdHlfaWQiOiJlbnRpdHkvc29saWQvMCIsImV2YWx1YXRlZF90YWdzIjpbXSwiZ2VvbWV0cnkiOnsidHlwZSI6ImJyZXBfc29saWQifSwia2luZCI6InNvbGlkIiwicGFyZW50X2VudGl0eV9pZHMiOltdLCJwcm9wZXJ0aWVzIjp7ImJvdW5kcyI6eyJtYXgiOlsxMDAwLDAsMTAwMF0sIm1pbiI6WzAsLTEwMDAsMF19LCJjZW50cm9pZCI6WzI1MCwtMjUwLDI1MF0sInF1YWxpdHkiOiJrZXJuZWxfZXZhbHVhdGVkIiwic3VyZmFjZV9hcmVhIjoyMzY2MDI1LjQwMzc4NDQzODUsInZvbHVtZSI6MTY2NjY2NjY2LjY2NjY2NjY2fSwicmVuZGVyX3N0YXR1cyI6InJlbmRlcmVkIiwic2RrX2Nvbm5lY3Rvcl9mcmFtZSI6bnVsbCwic2RrX21ldGFkYXRhIjp7fSwic2VtYW50aWNfYmluZGluZ19pZHMiOltdLCJzb3VyY2UiOnsia2luZCI6InVuYm91bmQifX0seyJjaGlsZF9lbnRpdHlfaWRzIjpbXSwiY29ubmVjdG9yX2JpbmRpbmdfc3RhdHVzIjoib3duZXJfbm90X3BhcnQiLCJlbnRpdHlfaWQiOiJlbnRpdHkvdmVydGV4LzAiLCJldmFsdWF0ZWRfdGFncyI6W10sImdlb21ldHJ5Ijp7InBvc2l0aW9uIjpbMCwwLDBdLCJ0eXBlIjoicG9pbnQifSwia2luZCI6InZlcnRleCIsInBhcmVudF9lbnRpdHlfaWRzIjpbImVudGl0eS9lZGdlLzIiLCJlbnRpdHkvZWRnZS8zIiwiZW50aXR5L2VkZ2UvNSJdLCJwcm9wZXJ0aWVzIjp7ImJvdW5kcyI6eyJtYXgiOlswLDAsMF0sIm1pbiI6WzAsMCwwXX0sInBvc2l0aW9uIjpbMCwwLDBdLCJxdWFsaXR5Ijoia2VybmVsX2V2YWx1YXRlZCJ9LCJyZW5kZXJfc3RhdHVzIjoicmVuZGVyZWQiLCJzZGtfY29ubmVjdG9yX2ZyYW1lIjp7Im9yaWdpbiI6WzAsMCwwXSwieF9heGlzIjpbMSwwLDBdLCJ5X2F4aXMiOlswLDEsMF0sInpfYXhpcyI6WzAsMCwxXX0sInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImtpbmQiOiJ1bmJvdW5kIn19LHsiY2hpbGRfZW50aXR5X2lkcyI6W10sImNvbm5lY3Rvcl9iaW5kaW5nX3N0YXR1cyI6Im93bmVyX25vdF9wYXJ0IiwiZW50aXR5X2lkIjoiZW50aXR5L3ZlcnRleC8xIiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJwb3NpdGlvbiI6WzEwMDAsMCwwXSwidHlwZSI6InBvaW50In0sImtpbmQiOiJ2ZXJ0ZXgiLCJwYXJlbnRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZWRnZS8xIiwiZW50aXR5L2VkZ2UvMyIsImVudGl0eS9lZGdlLzQiXSwicHJvcGVydGllcyI6eyJib3VuZHMiOnsibWF4IjpbMTAwMCwwLDBdLCJtaW4iOlsxMDAwLDAsMF19LCJwb3NpdGlvbiI6WzEwMDAsMCwwXSwicXVhbGl0eSI6Imtlcm5lbF9ldmFsdWF0ZWQifSwicmVuZGVyX3N0YXR1cyI6InJlbmRlcmVkIiwic2RrX2Nvbm5lY3Rvcl9mcmFtZSI6eyJvcmlnaW4iOlsxMDAwLDAsMF0sInhfYXhpcyI6WzEsMCwwXSwieV9heGlzIjpbMCwxLDBdLCJ6X2F4aXMiOlswLDAsMV19LCJzZGtfbWV0YWRhdGEiOnt9LCJzZW1hbnRpY19iaW5kaW5nX2lkcyI6W10sInNvdXJjZSI6eyJraW5kIjoidW5ib3VuZCJ9fSx7ImNoaWxkX2VudGl0eV9pZHMiOltdLCJjb25uZWN0b3JfYmluZGluZ19zdGF0dXMiOiJvd25lcl9ub3RfcGFydCIsImVudGl0eV9pZCI6ImVudGl0eS92ZXJ0ZXgvMiIsImV2YWx1YXRlZF90YWdzIjpbXSwiZ2VvbWV0cnkiOnsicG9zaXRpb24iOlswLDAsMTAwMF0sInR5cGUiOiJwb2ludCJ9LCJraW5kIjoidmVydGV4IiwicGFyZW50X2VudGl0eV9pZHMiOlsiZW50aXR5L2VkZ2UvMCIsImVudGl0eS9lZGdlLzQiLCJlbnRpdHkvZWRnZS81Il0sInByb3BlcnRpZXMiOnsiYm91bmRzIjp7Im1heCI6WzAsMCwxMDAwXSwibWluIjpbMCwwLDEwMDBdfSwicG9zaXRpb24iOlswLDAsMTAwMF0sInF1YWxpdHkiOiJrZXJuZWxfZXZhbHVhdGVkIn0sInJlbmRlcl9zdGF0dXMiOiJyZW5kZXJlZCIsInNka19jb25uZWN0b3JfZnJhbWUiOnsib3JpZ2luIjpbMCwwLDEwMDBdLCJ4X2F4aXMiOlsxLDAsMF0sInlfYXhpcyI6WzAsMSwwXSwiel9heGlzIjpbMCwwLDFdfSwic2RrX21ldGFkYXRhIjp7fSwic2VtYW50aWNfYmluZGluZ19pZHMiOltdLCJzb3VyY2UiOnsia2luZCI6InVuYm91bmQifX0seyJjaGlsZF9lbnRpdHlfaWRzIjpbXSwiY29ubmVjdG9yX2JpbmRpbmdfc3RhdHVzIjoib3duZXJfbm90X3BhcnQiLCJlbnRpdHlfaWQiOiJlbnRpdHkvdmVydGV4LzMiLCJldmFsdWF0ZWRfdGFncyI6W10sImdlb21ldHJ5Ijp7InBvc2l0aW9uIjpbMCwtMTAwMCwwXSwidHlwZSI6InBvaW50In0sImtpbmQiOiJ2ZXJ0ZXgiLCJwYXJlbnRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZWRnZS8wIiwiZW50aXR5L2VkZ2UvMSIsImVudGl0eS9lZGdlLzIiXSwicHJvcGVydGllcyI6eyJib3VuZHMiOnsibWF4IjpbMCwtMTAwMCwwXSwibWluIjpbMCwtMTAwMCwwXX0sInBvc2l0aW9uIjpbMCwtMTAwMCwwXSwicXVhbGl0eSI6Imtlcm5lbF9ldmFsdWF0ZWQifSwicmVuZGVyX3N0YXR1cyI6InJlbmRlcmVkIiwic2RrX2Nvbm5lY3Rvcl9mcmFtZSI6eyJvcmlnaW4iOlswLC0xMDAwLDBdLCJ4X2F4aXMiOlsxLDAsMF0sInlfYXhpcyI6WzAsMSwwXSwiel9heGlzIjpbMCwwLDFdfSwic2RrX21ldGFkYXRhIjp7fSwic2VtYW50aWNfYmluZGluZ19pZHMiOltdLCJzb3VyY2UiOnsia2luZCI6InVuYm91bmQifX1dLCJmYWNlX2dyb3VwcyI6W3siZW50aXR5X2lkIjoiZW50aXR5L2ZhY2UvMCIsImZpcnN0X2luZGV4IjowLCJncm91cF9pZCI6MCwiaW5kZXhfY291bnQiOjMsIm1lc2hfaW5kZXgiOjAsInByaW1pdGl2ZV9pbmRleCI6MH0seyJlbnRpdHlfaWQiOiJlbnRpdHkvZmFjZS8xIiwiZmlyc3RfaW5kZXgiOjMsImdyb3VwX2lkIjoxLCJpbmRleF9jb3VudCI6MywibWVzaF9pbmRleCI6MCwicHJpbWl0aXZlX2luZGV4IjowfSx7ImVudGl0eV9pZCI6ImVudGl0eS9mYWNlLzIiLCJmaXJzdF9pbmRleCI6NiwiZ3JvdXBfaWQiOjIsImluZGV4X2NvdW50IjozLCJtZXNoX2luZGV4IjowLCJwcmltaXRpdmVfaW5kZXgiOjB9LHsiZW50aXR5X2lkIjoiZW50aXR5L2ZhY2UvMyIsImZpcnN0X2luZGV4Ijo5LCJncm91cF9pZCI6MywiaW5kZXhfY291bnQiOjMsIm1lc2hfaW5kZXgiOjAsInByaW1pdGl2ZV9pbmRleCI6MH1dLCJnZW9tZXRyeV9hc3NldF9pZCI6InNoYTI1NjoyMDA1MjM0MTRkZTI4MWE4YTE1NTA4NmE1NGNkMGY4ODBmNjQwNDg1NzY1YTQ4Y2NkZDhlYzIzMGY2N2RkMTdjIiwiZ2VvbWV0cnlfZW5naW5lIjp7Im5hbWUiOiJPcGVuQ2FzY2FkZSIsInByb2ZpbGUiOiJvY3AtZXZhbHVhdGVkLXByb3BlcnRpZXMtMSIsInZlcnNpb24iOiJmaXh0dXJlIn0sInNjaGVtYV92ZXJzaW9uIjoiMS4wIn0=", + "valid": false + }, + { + "expected": { + "code": "entity_range_invalid", + "path": "/face_groups/1" + }, + "name": "face_group_range_gap", + "payload_base64": "eyJkZWZpbml0aW9uX2lkIjoiZGVmaW5pdGlvbi9yb290L3NoYXBlL21hbnVhbC9maXh0dXJlIiwiZWRnZV9hc3NldF9pZCI6InNoYTI1Njo2Y2U0ODFkYjFmZTEzYmNmZTdhMjM3NDQyM2U2YzcyZDc1N2Q1MWM5OTE2OGIyODdjNTJmNTNhYTYyNWNjZDY3IiwiZWRnZV9ncm91cHMiOlt7ImVudGl0eV9pZCI6ImVudGl0eS9lZGdlLzAiLCJmaXJzdF9pbmRleCI6MCwiZ3JvdXBfaWQiOjAsImluZGV4X2NvdW50IjoyLCJtZXNoX2luZGV4IjowLCJwcmltaXRpdmVfaW5kZXgiOjB9LHsiZW50aXR5X2lkIjoiZW50aXR5L2VkZ2UvMSIsImZpcnN0X2luZGV4IjoyLCJncm91cF9pZCI6MSwiaW5kZXhfY291bnQiOjIsIm1lc2hfaW5kZXgiOjAsInByaW1pdGl2ZV9pbmRleCI6MH0seyJlbnRpdHlfaWQiOiJlbnRpdHkvZWRnZS8yIiwiZmlyc3RfaW5kZXgiOjQsImdyb3VwX2lkIjoyLCJpbmRleF9jb3VudCI6MiwibWVzaF9pbmRleCI6MCwicHJpbWl0aXZlX2luZGV4IjowfSx7ImVudGl0eV9pZCI6ImVudGl0eS9lZGdlLzMiLCJmaXJzdF9pbmRleCI6NiwiZ3JvdXBfaWQiOjMsImluZGV4X2NvdW50IjoyLCJtZXNoX2luZGV4IjowLCJwcmltaXRpdmVfaW5kZXgiOjB9LHsiZW50aXR5X2lkIjoiZW50aXR5L2VkZ2UvNCIsImZpcnN0X2luZGV4Ijo4LCJncm91cF9pZCI6NCwiaW5kZXhfY291bnQiOjIsIm1lc2hfaW5kZXgiOjAsInByaW1pdGl2ZV9pbmRleCI6MH0seyJlbnRpdHlfaWQiOiJlbnRpdHkvZWRnZS81IiwiZmlyc3RfaW5kZXgiOjEwLCJncm91cF9pZCI6NSwiaW5kZXhfY291bnQiOjIsIm1lc2hfaW5kZXgiOjAsInByaW1pdGl2ZV9pbmRleCI6MH1dLCJlbnRpdGllcyI6W3siY2hpbGRfZW50aXR5X2lkcyI6WyJlbnRpdHkvdmVydGV4LzIiLCJlbnRpdHkvdmVydGV4LzMiXSwiY29ubmVjdG9yX2JpbmRpbmdfc3RhdHVzIjoib3duZXJfbm90X3BhcnQiLCJlbnRpdHlfaWQiOiJlbnRpdHkvZWRnZS8wIiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJkaXJlY3Rpb24iOlswLC0wLjcwNzEwNjc4MTE4NjU0NzUsLTAuNzA3MTA2NzgxMTg2NTQ3NV0sIm9yaWdpbiI6WzAsMCwxMDAwXSwidHlwZSI6ImxpbmUifSwia2luZCI6ImVkZ2UiLCJwYXJlbnRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZmFjZS8wIiwiZW50aXR5L2ZhY2UvMyJdLCJwcm9wZXJ0aWVzIjp7ImJvdW5kcyI6eyJtYXgiOlswLDAsMTAwMF0sIm1pbiI6WzAsLTEwMDAsMF19LCJjZW50cm9pZCI6WzAsLTUwMCw1MDBdLCJsZW5ndGgiOjE0MTQuMjEzNTYyMzczMDk1LCJxdWFsaXR5Ijoia2VybmVsX2V2YWx1YXRlZCJ9LCJyZW5kZXJfc3RhdHVzIjoicmVuZGVyZWQiLCJzZGtfY29ubmVjdG9yX2ZyYW1lIjp7Im9yaWdpbiI6WzAsLTUwMCw1MDBdLCJ4X2F4aXMiOlswLC0wLjcwNzEwNjc4MTE4NjU0NzUsLTAuNzA3MTA2NzgxMTg2NTQ3NV0sInlfYXhpcyI6WzAsLTAuNzA3MTA2NzgxMTg2NTQ3NCwwLjcwNzEwNjc4MTE4NjU0NzddLCJ6X2F4aXMiOlstMSwwLDBdfSwic2RrX21ldGFkYXRhIjp7fSwic2VtYW50aWNfYmluZGluZ19pZHMiOltdLCJzb3VyY2UiOnsia2luZCI6InVuYm91bmQifX0seyJjaGlsZF9lbnRpdHlfaWRzIjpbImVudGl0eS92ZXJ0ZXgvMSIsImVudGl0eS92ZXJ0ZXgvMyJdLCJjb25uZWN0b3JfYmluZGluZ19zdGF0dXMiOiJvd25lcl9ub3RfcGFydCIsImVudGl0eV9pZCI6ImVudGl0eS9lZGdlLzEiLCJldmFsdWF0ZWRfdGFncyI6W10sImdlb21ldHJ5Ijp7ImRpcmVjdGlvbiI6Wy0wLjcwNzEwNjc4MTE4NjU0NzUsLTAuNzA3MTA2NzgxMTg2NTQ3NSwwXSwib3JpZ2luIjpbMTAwMCwwLDBdLCJ0eXBlIjoibGluZSJ9LCJraW5kIjoiZWRnZSIsInBhcmVudF9lbnRpdHlfaWRzIjpbImVudGl0eS9mYWNlLzEiLCJlbnRpdHkvZmFjZS8zIl0sInByb3BlcnRpZXMiOnsiYm91bmRzIjp7Im1heCI6WzEwMDAsMCwwXSwibWluIjpbMCwtMTAwMCwwXX0sImNlbnRyb2lkIjpbNTAwLC01MDAsMF0sImxlbmd0aCI6MTQxNC4yMTM1NjIzNzMwOTUsInF1YWxpdHkiOiJrZXJuZWxfZXZhbHVhdGVkIn0sInJlbmRlcl9zdGF0dXMiOiJyZW5kZXJlZCIsInNka19jb25uZWN0b3JfZnJhbWUiOnsib3JpZ2luIjpbNTAwLC01MDAsMF0sInhfYXhpcyI6Wy0wLjcwNzEwNjc4MTE4NjU0NzUsLTAuNzA3MTA2NzgxMTg2NTQ3NSwwXSwieV9heGlzIjpbMCwwLDFdLCJ6X2F4aXMiOlstMC43MDcxMDY3ODExODY1NDc2LDAuNzA3MTA2NzgxMTg2NTQ3NiwwXX0sInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImtpbmQiOiJ1bmJvdW5kIn19LHsiY2hpbGRfZW50aXR5X2lkcyI6WyJlbnRpdHkvdmVydGV4LzAiLCJlbnRpdHkvdmVydGV4LzMiXSwiY29ubmVjdG9yX2JpbmRpbmdfc3RhdHVzIjoib3duZXJfbm90X3BhcnQiLCJlbnRpdHlfaWQiOiJlbnRpdHkvZWRnZS8yIiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJkaXJlY3Rpb24iOlswLC0xLDBdLCJvcmlnaW4iOlswLDAsMF0sInR5cGUiOiJsaW5lIn0sImtpbmQiOiJlZGdlIiwicGFyZW50X2VudGl0eV9pZHMiOlsiZW50aXR5L2ZhY2UvMCIsImVudGl0eS9mYWNlLzEiXSwicHJvcGVydGllcyI6eyJib3VuZHMiOnsibWF4IjpbMCwwLDBdLCJtaW4iOlswLC0xMDAwLDBdfSwiY2VudHJvaWQiOlswLC01MDAsMF0sImxlbmd0aCI6MTAwMCwicXVhbGl0eSI6Imtlcm5lbF9ldmFsdWF0ZWQifSwicmVuZGVyX3N0YXR1cyI6InJlbmRlcmVkIiwic2RrX2Nvbm5lY3Rvcl9mcmFtZSI6eyJvcmlnaW4iOlswLC01MDAsMF0sInhfYXhpcyI6WzAsLTEsMF0sInlfYXhpcyI6WzAsMCwxXSwiel9heGlzIjpbLTEsMCwwXX0sInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImtpbmQiOiJ1bmJvdW5kIn19LHsiY2hpbGRfZW50aXR5X2lkcyI6WyJlbnRpdHkvdmVydGV4LzAiLCJlbnRpdHkvdmVydGV4LzEiXSwiY29ubmVjdG9yX2JpbmRpbmdfc3RhdHVzIjoib3duZXJfbm90X3BhcnQiLCJlbnRpdHlfaWQiOiJlbnRpdHkvZWRnZS8zIiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJkaXJlY3Rpb24iOlsxLDAsMF0sIm9yaWdpbiI6WzAsMCwwXSwidHlwZSI6ImxpbmUifSwia2luZCI6ImVkZ2UiLCJwYXJlbnRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZmFjZS8xIiwiZW50aXR5L2ZhY2UvMiJdLCJwcm9wZXJ0aWVzIjp7ImJvdW5kcyI6eyJtYXgiOlsxMDAwLDAsMF0sIm1pbiI6WzAsMCwwXX0sImNlbnRyb2lkIjpbNTAwLDAsMF0sImxlbmd0aCI6MTAwMCwicXVhbGl0eSI6Imtlcm5lbF9ldmFsdWF0ZWQifSwicmVuZGVyX3N0YXR1cyI6InJlbmRlcmVkIiwic2RrX2Nvbm5lY3Rvcl9mcmFtZSI6eyJvcmlnaW4iOls1MDAsMCwwXSwieF9heGlzIjpbMSwwLDBdLCJ5X2F4aXMiOlswLDAsMV0sInpfYXhpcyI6WzAsLTEsMF19LCJzZGtfbWV0YWRhdGEiOnt9LCJzZW1hbnRpY19iaW5kaW5nX2lkcyI6W10sInNvdXJjZSI6eyJraW5kIjoidW5ib3VuZCJ9fSx7ImNoaWxkX2VudGl0eV9pZHMiOlsiZW50aXR5L3ZlcnRleC8xIiwiZW50aXR5L3ZlcnRleC8yIl0sImNvbm5lY3Rvcl9iaW5kaW5nX3N0YXR1cyI6Im93bmVyX25vdF9wYXJ0IiwiZW50aXR5X2lkIjoiZW50aXR5L2VkZ2UvNCIsImV2YWx1YXRlZF90YWdzIjpbXSwiZ2VvbWV0cnkiOnsiZGlyZWN0aW9uIjpbLTAuNzA3MTA2NzgxMTg2NTQ3NSwwLDAuNzA3MTA2NzgxMTg2NTQ3NV0sIm9yaWdpbiI6WzEwMDAsMCwwXSwidHlwZSI6ImxpbmUifSwia2luZCI6ImVkZ2UiLCJwYXJlbnRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZmFjZS8yIiwiZW50aXR5L2ZhY2UvMyJdLCJwcm9wZXJ0aWVzIjp7ImJvdW5kcyI6eyJtYXgiOlsxMDAwLDAsMTAwMF0sIm1pbiI6WzAsMCwwXX0sImNlbnRyb2lkIjpbNTAwLDAsNTAwXSwibGVuZ3RoIjoxNDE0LjIxMzU2MjM3MzA5NSwicXVhbGl0eSI6Imtlcm5lbF9ldmFsdWF0ZWQifSwicmVuZGVyX3N0YXR1cyI6InJlbmRlcmVkIiwic2RrX2Nvbm5lY3Rvcl9mcmFtZSI6eyJvcmlnaW4iOls1MDAsMCw1MDBdLCJ4X2F4aXMiOlstMC43MDcxMDY3ODExODY1NDc1LDAsMC43MDcxMDY3ODExODY1NDc1XSwieV9heGlzIjpbMC43MDcxMDY3ODExODY1NDc0LDAsMC43MDcxMDY3ODExODY1NDc3XSwiel9heGlzIjpbMCwxLDBdfSwic2RrX21ldGFkYXRhIjp7fSwic2VtYW50aWNfYmluZGluZ19pZHMiOltdLCJzb3VyY2UiOnsia2luZCI6InVuYm91bmQifX0seyJjaGlsZF9lbnRpdHlfaWRzIjpbImVudGl0eS92ZXJ0ZXgvMCIsImVudGl0eS92ZXJ0ZXgvMiJdLCJjb25uZWN0b3JfYmluZGluZ19zdGF0dXMiOiJvd25lcl9ub3RfcGFydCIsImVudGl0eV9pZCI6ImVudGl0eS9lZGdlLzUiLCJldmFsdWF0ZWRfdGFncyI6W10sImdlb21ldHJ5Ijp7ImRpcmVjdGlvbiI6WzAsMCwxXSwib3JpZ2luIjpbMCwwLDBdLCJ0eXBlIjoibGluZSJ9LCJraW5kIjoiZWRnZSIsInBhcmVudF9lbnRpdHlfaWRzIjpbImVudGl0eS9mYWNlLzAiLCJlbnRpdHkvZmFjZS8yIl0sInByb3BlcnRpZXMiOnsiYm91bmRzIjp7Im1heCI6WzAsMCwxMDAwXSwibWluIjpbMCwwLDBdfSwiY2VudHJvaWQiOlswLDAsNTAwXSwibGVuZ3RoIjoxMDAwLCJxdWFsaXR5Ijoia2VybmVsX2V2YWx1YXRlZCJ9LCJyZW5kZXJfc3RhdHVzIjoicmVuZGVyZWQiLCJzZGtfY29ubmVjdG9yX2ZyYW1lIjp7Im9yaWdpbiI6WzAsMCw1MDBdLCJ4X2F4aXMiOlswLDAsMV0sInlfYXhpcyI6WzAsMSwwXSwiel9heGlzIjpbLTEsMCwwXX0sInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImtpbmQiOiJ1bmJvdW5kIn19LHsiY2hpbGRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZWRnZS8wIiwiZW50aXR5L2VkZ2UvMiIsImVudGl0eS9lZGdlLzUiXSwiY29ubmVjdG9yX2JpbmRpbmdfc3RhdHVzIjoib3duZXJfbm90X3BhcnQiLCJlbnRpdHlfaWQiOiJlbnRpdHkvZmFjZS8wIiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJub3JtYWwiOlstMSwwLDBdLCJvcmlnaW4iOlswLC0zMzMuMzMzMzMzMzMzMzMzMywzMzMuMzMzMzMzMzMzMzMzM10sInR5cGUiOiJwbGFuZSIsInhfZGlyZWN0aW9uIjpbMCwwLDFdfSwia2luZCI6ImZhY2UiLCJwYXJlbnRfZW50aXR5X2lkcyI6WyJlbnRpdHkvc29saWQvMCJdLCJwcm9wZXJ0aWVzIjp7ImFyZWEiOjUwMDAwMCwiYm91bmRzIjp7Im1heCI6WzAsMCwxMDAwXSwibWluIjpbMCwtMTAwMCwwXX0sImNlbnRyb2lkIjpbMCwtMzMzLjMzMzMzMzMzMzMzMzMsMzMzLjMzMzMzMzMzMzMzMzNdLCJvcmllbnRhdGlvbiI6ImZvcndhcmQiLCJxdWFsaXR5Ijoia2VybmVsX2V2YWx1YXRlZCJ9LCJyZW5kZXJfc3RhdHVzIjoicmVuZGVyZWQiLCJzZGtfY29ubmVjdG9yX2ZyYW1lIjp7Im9yaWdpbiI6WzAsLTMzMy4zMzMzMzMzMzMzMzMzLDMzMy4zMzMzMzMzMzMzMzMzXSwieF9heGlzIjpbMCwwLDFdLCJ5X2F4aXMiOlswLDEsMF0sInpfYXhpcyI6Wy0xLDAsMF19LCJzZGtfbWV0YWRhdGEiOnt9LCJzZW1hbnRpY19iaW5kaW5nX2lkcyI6W10sInNvdXJjZSI6eyJraW5kIjoidW5ib3VuZCJ9fSx7ImNoaWxkX2VudGl0eV9pZHMiOlsiZW50aXR5L2VkZ2UvMSIsImVudGl0eS9lZGdlLzIiLCJlbnRpdHkvZWRnZS8zIl0sImNvbm5lY3Rvcl9iaW5kaW5nX3N0YXR1cyI6Im93bmVyX25vdF9wYXJ0IiwiZW50aXR5X2lkIjoiZW50aXR5L2ZhY2UvMSIsImV2YWx1YXRlZF90YWdzIjpbXSwiZ2VvbWV0cnkiOnsibm9ybWFsIjpbMCwwLC0xXSwib3JpZ2luIjpbMzMzLjMzMzMzMzMzMzMzMzMsLTMzMy4zMzMzMzMzMzMzMzMzLDBdLCJ0eXBlIjoicGxhbmUiLCJ4X2RpcmVjdGlvbiI6WzEsMCwwXX0sImtpbmQiOiJmYWNlIiwicGFyZW50X2VudGl0eV9pZHMiOlsiZW50aXR5L3NvbGlkLzAiXSwicHJvcGVydGllcyI6eyJhcmVhIjo1MDAwMDAsImJvdW5kcyI6eyJtYXgiOlsxMDAwLDAsMF0sIm1pbiI6WzAsLTEwMDAsMF19LCJjZW50cm9pZCI6WzMzMy4zMzMzMzMzMzMzMzMzLC0zMzMuMzMzMzMzMzMzMzMzMywwXSwib3JpZW50YXRpb24iOiJmb3J3YXJkIiwicXVhbGl0eSI6Imtlcm5lbF9ldmFsdWF0ZWQifSwicmVuZGVyX3N0YXR1cyI6InJlbmRlcmVkIiwic2RrX2Nvbm5lY3Rvcl9mcmFtZSI6eyJvcmlnaW4iOlszMzMuMzMzMzMzMzMzMzMzMywtMzMzLjMzMzMzMzMzMzMzMzMsMF0sInhfYXhpcyI6WzEsMCwwXSwieV9heGlzIjpbMCwtMSwwXSwiel9heGlzIjpbMCwwLC0xXX0sInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImtpbmQiOiJ1bmJvdW5kIn19LHsiY2hpbGRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZWRnZS8zIiwiZW50aXR5L2VkZ2UvNCIsImVudGl0eS9lZGdlLzUiXSwiY29ubmVjdG9yX2JpbmRpbmdfc3RhdHVzIjoib3duZXJfbm90X3BhcnQiLCJlbnRpdHlfaWQiOiJlbnRpdHkvZmFjZS8yIiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJub3JtYWwiOlswLDEsMF0sIm9yaWdpbiI6WzMzMy4zMzMzMzMzMzMzMzMzLDAsMzMzLjMzMzMzMzMzMzMzMzNdLCJ0eXBlIjoicGxhbmUiLCJ4X2RpcmVjdGlvbiI6WzEsMCwwXX0sImtpbmQiOiJmYWNlIiwicGFyZW50X2VudGl0eV9pZHMiOlsiZW50aXR5L3NvbGlkLzAiXSwicHJvcGVydGllcyI6eyJhcmVhIjo1MDAwMDAsImJvdW5kcyI6eyJtYXgiOlsxMDAwLDAsMTAwMF0sIm1pbiI6WzAsMCwwXX0sImNlbnRyb2lkIjpbMzMzLjMzMzMzMzMzMzMzMzMsMCwzMzMuMzMzMzMzMzMzMzMzM10sIm9yaWVudGF0aW9uIjoiZm9yd2FyZCIsInF1YWxpdHkiOiJrZXJuZWxfZXZhbHVhdGVkIn0sInJlbmRlcl9zdGF0dXMiOiJyZW5kZXJlZCIsInNka19jb25uZWN0b3JfZnJhbWUiOnsib3JpZ2luIjpbMzMzLjMzMzMzMzMzMzMzMzMsMCwzMzMuMzMzMzMzMzMzMzMzM10sInhfYXhpcyI6WzEsMCwwXSwieV9heGlzIjpbMCwwLC0xXSwiel9heGlzIjpbMCwxLDBdfSwic2RrX21ldGFkYXRhIjp7fSwic2VtYW50aWNfYmluZGluZ19pZHMiOltdLCJzb3VyY2UiOnsia2luZCI6InVuYm91bmQifX0seyJjaGlsZF9lbnRpdHlfaWRzIjpbImVudGl0eS9lZGdlLzAiLCJlbnRpdHkvZWRnZS8xIiwiZW50aXR5L2VkZ2UvNCJdLCJjb25uZWN0b3JfYmluZGluZ19zdGF0dXMiOiJvd25lcl9ub3RfcGFydCIsImVudGl0eV9pZCI6ImVudGl0eS9mYWNlLzMiLCJldmFsdWF0ZWRfdGFncyI6W10sImdlb21ldHJ5Ijp7Im5vcm1hbCI6WzAuNTc3MzUwMjY5MTg5NjI1OCwtMC41NzczNTAyNjkxODk2MjU4LDAuNTc3MzUwMjY5MTg5NjI1OF0sIm9yaWdpbiI6WzMzMy4zMzMzMzMzMzMzMzMzLC0zMzMuMzMzMzMzMzMzMzMzMywzMzMuMzMzMzMzMzMzMzMzM10sInR5cGUiOiJwbGFuZSIsInhfZGlyZWN0aW9uIjpbLTAuNzA3MTA2NzgxMTg2NTQ3NSwwLDAuNzA3MTA2NzgxMTg2NTQ3NV19LCJraW5kIjoiZmFjZSIsInBhcmVudF9lbnRpdHlfaWRzIjpbImVudGl0eS9zb2xpZC8wIl0sInByb3BlcnRpZXMiOnsiYXJlYSI6ODY2MDI1LjQwMzc4NDQzODYsImJvdW5kcyI6eyJtYXgiOlsxMDAwLDAsMTAwMF0sIm1pbiI6WzAsLTEwMDAsMF19LCJjZW50cm9pZCI6WzMzMy4zMzMzMzMzMzMzMzMzLC0zMzMuMzMzMzMzMzMzMzMzMywzMzMuMzMzMzMzMzMzMzMzM10sIm9yaWVudGF0aW9uIjoiZm9yd2FyZCIsInF1YWxpdHkiOiJrZXJuZWxfZXZhbHVhdGVkIn0sInJlbmRlcl9zdGF0dXMiOiJyZW5kZXJlZCIsInNka19jb25uZWN0b3JfZnJhbWUiOnsib3JpZ2luIjpbMzMzLjMzMzMzMzMzMzMzMzMsLTMzMy4zMzMzMzMzMzMzMzMzLDMzMy4zMzMzMzMzMzMzMzMzXSwieF9heGlzIjpbLTAuNzA3MTA2NzgxMTg2NTQ3NSwwLDAuNzA3MTA2NzgxMTg2NTQ3NV0sInlfYXhpcyI6Wy0wLjQwODI0ODI5MDQ2Mzg2MzEsLTAuODE2NDk2NTgwOTI3NzI2MSwtMC40MDgyNDgyOTA0NjM4NjMxXSwiel9heGlzIjpbMC41NzczNTAyNjkxODk2MjU4LC0wLjU3NzM1MDI2OTE4OTYyNTgsMC41NzczNTAyNjkxODk2MjU4XX0sInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImtpbmQiOiJ1bmJvdW5kIn19LHsiY2hpbGRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZmFjZS8wIiwiZW50aXR5L2ZhY2UvMSIsImVudGl0eS9mYWNlLzIiLCJlbnRpdHkvZmFjZS8zIl0sImNvbm5lY3Rvcl9iaW5kaW5nX3N0YXR1cyI6Im5vdF9hcHBsaWNhYmxlIiwiZW50aXR5X2lkIjoiZW50aXR5L3NvbGlkLzAiLCJldmFsdWF0ZWRfdGFncyI6W10sImdlb21ldHJ5Ijp7InR5cGUiOiJicmVwX3NvbGlkIn0sImtpbmQiOiJzb2xpZCIsInBhcmVudF9lbnRpdHlfaWRzIjpbXSwicHJvcGVydGllcyI6eyJib3VuZHMiOnsibWF4IjpbMTAwMCwwLDEwMDBdLCJtaW4iOlswLC0xMDAwLDBdfSwiY2VudHJvaWQiOlsyNTAsLTI1MCwyNTBdLCJxdWFsaXR5Ijoia2VybmVsX2V2YWx1YXRlZCIsInN1cmZhY2VfYXJlYSI6MjM2NjAyNS40MDM3ODQ0Mzg1LCJ2b2x1bWUiOjE2NjY2NjY2Ni42NjY2NjY2Nn0sInJlbmRlcl9zdGF0dXMiOiJyZW5kZXJlZCIsInNka19jb25uZWN0b3JfZnJhbWUiOm51bGwsInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImtpbmQiOiJ1bmJvdW5kIn19LHsiY2hpbGRfZW50aXR5X2lkcyI6W10sImNvbm5lY3Rvcl9iaW5kaW5nX3N0YXR1cyI6Im93bmVyX25vdF9wYXJ0IiwiZW50aXR5X2lkIjoiZW50aXR5L3ZlcnRleC8wIiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJwb3NpdGlvbiI6WzAsMCwwXSwidHlwZSI6InBvaW50In0sImtpbmQiOiJ2ZXJ0ZXgiLCJwYXJlbnRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZWRnZS8yIiwiZW50aXR5L2VkZ2UvMyIsImVudGl0eS9lZGdlLzUiXSwicHJvcGVydGllcyI6eyJib3VuZHMiOnsibWF4IjpbMCwwLDBdLCJtaW4iOlswLDAsMF19LCJwb3NpdGlvbiI6WzAsMCwwXSwicXVhbGl0eSI6Imtlcm5lbF9ldmFsdWF0ZWQifSwicmVuZGVyX3N0YXR1cyI6InJlbmRlcmVkIiwic2RrX2Nvbm5lY3Rvcl9mcmFtZSI6eyJvcmlnaW4iOlswLDAsMF0sInhfYXhpcyI6WzEsMCwwXSwieV9heGlzIjpbMCwxLDBdLCJ6X2F4aXMiOlswLDAsMV19LCJzZGtfbWV0YWRhdGEiOnt9LCJzZW1hbnRpY19iaW5kaW5nX2lkcyI6W10sInNvdXJjZSI6eyJraW5kIjoidW5ib3VuZCJ9fSx7ImNoaWxkX2VudGl0eV9pZHMiOltdLCJjb25uZWN0b3JfYmluZGluZ19zdGF0dXMiOiJvd25lcl9ub3RfcGFydCIsImVudGl0eV9pZCI6ImVudGl0eS92ZXJ0ZXgvMSIsImV2YWx1YXRlZF90YWdzIjpbXSwiZ2VvbWV0cnkiOnsicG9zaXRpb24iOlsxMDAwLDAsMF0sInR5cGUiOiJwb2ludCJ9LCJraW5kIjoidmVydGV4IiwicGFyZW50X2VudGl0eV9pZHMiOlsiZW50aXR5L2VkZ2UvMSIsImVudGl0eS9lZGdlLzMiLCJlbnRpdHkvZWRnZS80Il0sInByb3BlcnRpZXMiOnsiYm91bmRzIjp7Im1heCI6WzEwMDAsMCwwXSwibWluIjpbMTAwMCwwLDBdfSwicG9zaXRpb24iOlsxMDAwLDAsMF0sInF1YWxpdHkiOiJrZXJuZWxfZXZhbHVhdGVkIn0sInJlbmRlcl9zdGF0dXMiOiJyZW5kZXJlZCIsInNka19jb25uZWN0b3JfZnJhbWUiOnsib3JpZ2luIjpbMTAwMCwwLDBdLCJ4X2F4aXMiOlsxLDAsMF0sInlfYXhpcyI6WzAsMSwwXSwiel9heGlzIjpbMCwwLDFdfSwic2RrX21ldGFkYXRhIjp7fSwic2VtYW50aWNfYmluZGluZ19pZHMiOltdLCJzb3VyY2UiOnsia2luZCI6InVuYm91bmQifX0seyJjaGlsZF9lbnRpdHlfaWRzIjpbXSwiY29ubmVjdG9yX2JpbmRpbmdfc3RhdHVzIjoib3duZXJfbm90X3BhcnQiLCJlbnRpdHlfaWQiOiJlbnRpdHkvdmVydGV4LzIiLCJldmFsdWF0ZWRfdGFncyI6W10sImdlb21ldHJ5Ijp7InBvc2l0aW9uIjpbMCwwLDEwMDBdLCJ0eXBlIjoicG9pbnQifSwia2luZCI6InZlcnRleCIsInBhcmVudF9lbnRpdHlfaWRzIjpbImVudGl0eS9lZGdlLzAiLCJlbnRpdHkvZWRnZS80IiwiZW50aXR5L2VkZ2UvNSJdLCJwcm9wZXJ0aWVzIjp7ImJvdW5kcyI6eyJtYXgiOlswLDAsMTAwMF0sIm1pbiI6WzAsMCwxMDAwXX0sInBvc2l0aW9uIjpbMCwwLDEwMDBdLCJxdWFsaXR5Ijoia2VybmVsX2V2YWx1YXRlZCJ9LCJyZW5kZXJfc3RhdHVzIjoicmVuZGVyZWQiLCJzZGtfY29ubmVjdG9yX2ZyYW1lIjp7Im9yaWdpbiI6WzAsMCwxMDAwXSwieF9heGlzIjpbMSwwLDBdLCJ5X2F4aXMiOlswLDEsMF0sInpfYXhpcyI6WzAsMCwxXX0sInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImtpbmQiOiJ1bmJvdW5kIn19LHsiY2hpbGRfZW50aXR5X2lkcyI6W10sImNvbm5lY3Rvcl9iaW5kaW5nX3N0YXR1cyI6Im93bmVyX25vdF9wYXJ0IiwiZW50aXR5X2lkIjoiZW50aXR5L3ZlcnRleC8zIiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJwb3NpdGlvbiI6WzAsLTEwMDAsMF0sInR5cGUiOiJwb2ludCJ9LCJraW5kIjoidmVydGV4IiwicGFyZW50X2VudGl0eV9pZHMiOlsiZW50aXR5L2VkZ2UvMCIsImVudGl0eS9lZGdlLzEiLCJlbnRpdHkvZWRnZS8yIl0sInByb3BlcnRpZXMiOnsiYm91bmRzIjp7Im1heCI6WzAsLTEwMDAsMF0sIm1pbiI6WzAsLTEwMDAsMF19LCJwb3NpdGlvbiI6WzAsLTEwMDAsMF0sInF1YWxpdHkiOiJrZXJuZWxfZXZhbHVhdGVkIn0sInJlbmRlcl9zdGF0dXMiOiJyZW5kZXJlZCIsInNka19jb25uZWN0b3JfZnJhbWUiOnsib3JpZ2luIjpbMCwtMTAwMCwwXSwieF9heGlzIjpbMSwwLDBdLCJ5X2F4aXMiOlswLDEsMF0sInpfYXhpcyI6WzAsMCwxXX0sInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImtpbmQiOiJ1bmJvdW5kIn19XSwiZmFjZV9ncm91cHMiOlt7ImVudGl0eV9pZCI6ImVudGl0eS9mYWNlLzAiLCJmaXJzdF9pbmRleCI6MCwiZ3JvdXBfaWQiOjAsImluZGV4X2NvdW50IjozLCJtZXNoX2luZGV4IjowLCJwcmltaXRpdmVfaW5kZXgiOjB9LHsiZW50aXR5X2lkIjoiZW50aXR5L2ZhY2UvMSIsImZpcnN0X2luZGV4Ijo2LCJncm91cF9pZCI6MSwiaW5kZXhfY291bnQiOjMsIm1lc2hfaW5kZXgiOjAsInByaW1pdGl2ZV9pbmRleCI6MH0seyJlbnRpdHlfaWQiOiJlbnRpdHkvZmFjZS8yIiwiZmlyc3RfaW5kZXgiOjYsImdyb3VwX2lkIjoyLCJpbmRleF9jb3VudCI6MywibWVzaF9pbmRleCI6MCwicHJpbWl0aXZlX2luZGV4IjowfSx7ImVudGl0eV9pZCI6ImVudGl0eS9mYWNlLzMiLCJmaXJzdF9pbmRleCI6OSwiZ3JvdXBfaWQiOjMsImluZGV4X2NvdW50IjozLCJtZXNoX2luZGV4IjowLCJwcmltaXRpdmVfaW5kZXgiOjB9XSwiZ2VvbWV0cnlfYXNzZXRfaWQiOiJzaGEyNTY6MjAwNTIzNDE0ZGUyODFhOGExNTUwODZhNTRjZDBmODgwZjY0MDQ4NTc2NWE0OGNjZGQ4ZWMyMzBmNjdkZDE3YyIsImdlb21ldHJ5X2VuZ2luZSI6eyJuYW1lIjoiT3BlbkNhc2NhZGUiLCJwcm9maWxlIjoib2NwLWV2YWx1YXRlZC1wcm9wZXJ0aWVzLTEiLCJ2ZXJzaW9uIjoiZml4dHVyZSJ9LCJzY2hlbWFfdmVyc2lvbiI6IjEuMCJ9", + "valid": false + }, + { + "expected": { + "code": "entity_range_invalid", + "path": "/edge_groups/0/index_count" + }, + "name": "edge_group_wrong_cardinality", + "payload_base64": "eyJkZWZpbml0aW9uX2lkIjoiZGVmaW5pdGlvbi9yb290L3NoYXBlL21hbnVhbC9maXh0dXJlIiwiZWRnZV9hc3NldF9pZCI6InNoYTI1Njo2Y2U0ODFkYjFmZTEzYmNmZTdhMjM3NDQyM2U2YzcyZDc1N2Q1MWM5OTE2OGIyODdjNTJmNTNhYTYyNWNjZDY3IiwiZWRnZV9ncm91cHMiOlt7ImVudGl0eV9pZCI6ImVudGl0eS9lZGdlLzAiLCJmaXJzdF9pbmRleCI6MCwiZ3JvdXBfaWQiOjAsImluZGV4X2NvdW50IjozLCJtZXNoX2luZGV4IjowLCJwcmltaXRpdmVfaW5kZXgiOjB9LHsiZW50aXR5X2lkIjoiZW50aXR5L2VkZ2UvMSIsImZpcnN0X2luZGV4IjoyLCJncm91cF9pZCI6MSwiaW5kZXhfY291bnQiOjIsIm1lc2hfaW5kZXgiOjAsInByaW1pdGl2ZV9pbmRleCI6MH0seyJlbnRpdHlfaWQiOiJlbnRpdHkvZWRnZS8yIiwiZmlyc3RfaW5kZXgiOjQsImdyb3VwX2lkIjoyLCJpbmRleF9jb3VudCI6MiwibWVzaF9pbmRleCI6MCwicHJpbWl0aXZlX2luZGV4IjowfSx7ImVudGl0eV9pZCI6ImVudGl0eS9lZGdlLzMiLCJmaXJzdF9pbmRleCI6NiwiZ3JvdXBfaWQiOjMsImluZGV4X2NvdW50IjoyLCJtZXNoX2luZGV4IjowLCJwcmltaXRpdmVfaW5kZXgiOjB9LHsiZW50aXR5X2lkIjoiZW50aXR5L2VkZ2UvNCIsImZpcnN0X2luZGV4Ijo4LCJncm91cF9pZCI6NCwiaW5kZXhfY291bnQiOjIsIm1lc2hfaW5kZXgiOjAsInByaW1pdGl2ZV9pbmRleCI6MH0seyJlbnRpdHlfaWQiOiJlbnRpdHkvZWRnZS81IiwiZmlyc3RfaW5kZXgiOjEwLCJncm91cF9pZCI6NSwiaW5kZXhfY291bnQiOjIsIm1lc2hfaW5kZXgiOjAsInByaW1pdGl2ZV9pbmRleCI6MH1dLCJlbnRpdGllcyI6W3siY2hpbGRfZW50aXR5X2lkcyI6WyJlbnRpdHkvdmVydGV4LzIiLCJlbnRpdHkvdmVydGV4LzMiXSwiY29ubmVjdG9yX2JpbmRpbmdfc3RhdHVzIjoib3duZXJfbm90X3BhcnQiLCJlbnRpdHlfaWQiOiJlbnRpdHkvZWRnZS8wIiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJkaXJlY3Rpb24iOlswLC0wLjcwNzEwNjc4MTE4NjU0NzUsLTAuNzA3MTA2NzgxMTg2NTQ3NV0sIm9yaWdpbiI6WzAsMCwxMDAwXSwidHlwZSI6ImxpbmUifSwia2luZCI6ImVkZ2UiLCJwYXJlbnRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZmFjZS8wIiwiZW50aXR5L2ZhY2UvMyJdLCJwcm9wZXJ0aWVzIjp7ImJvdW5kcyI6eyJtYXgiOlswLDAsMTAwMF0sIm1pbiI6WzAsLTEwMDAsMF19LCJjZW50cm9pZCI6WzAsLTUwMCw1MDBdLCJsZW5ndGgiOjE0MTQuMjEzNTYyMzczMDk1LCJxdWFsaXR5Ijoia2VybmVsX2V2YWx1YXRlZCJ9LCJyZW5kZXJfc3RhdHVzIjoicmVuZGVyZWQiLCJzZGtfY29ubmVjdG9yX2ZyYW1lIjp7Im9yaWdpbiI6WzAsLTUwMCw1MDBdLCJ4X2F4aXMiOlswLC0wLjcwNzEwNjc4MTE4NjU0NzUsLTAuNzA3MTA2NzgxMTg2NTQ3NV0sInlfYXhpcyI6WzAsLTAuNzA3MTA2NzgxMTg2NTQ3NCwwLjcwNzEwNjc4MTE4NjU0NzddLCJ6X2F4aXMiOlstMSwwLDBdfSwic2RrX21ldGFkYXRhIjp7fSwic2VtYW50aWNfYmluZGluZ19pZHMiOltdLCJzb3VyY2UiOnsia2luZCI6InVuYm91bmQifX0seyJjaGlsZF9lbnRpdHlfaWRzIjpbImVudGl0eS92ZXJ0ZXgvMSIsImVudGl0eS92ZXJ0ZXgvMyJdLCJjb25uZWN0b3JfYmluZGluZ19zdGF0dXMiOiJvd25lcl9ub3RfcGFydCIsImVudGl0eV9pZCI6ImVudGl0eS9lZGdlLzEiLCJldmFsdWF0ZWRfdGFncyI6W10sImdlb21ldHJ5Ijp7ImRpcmVjdGlvbiI6Wy0wLjcwNzEwNjc4MTE4NjU0NzUsLTAuNzA3MTA2NzgxMTg2NTQ3NSwwXSwib3JpZ2luIjpbMTAwMCwwLDBdLCJ0eXBlIjoibGluZSJ9LCJraW5kIjoiZWRnZSIsInBhcmVudF9lbnRpdHlfaWRzIjpbImVudGl0eS9mYWNlLzEiLCJlbnRpdHkvZmFjZS8zIl0sInByb3BlcnRpZXMiOnsiYm91bmRzIjp7Im1heCI6WzEwMDAsMCwwXSwibWluIjpbMCwtMTAwMCwwXX0sImNlbnRyb2lkIjpbNTAwLC01MDAsMF0sImxlbmd0aCI6MTQxNC4yMTM1NjIzNzMwOTUsInF1YWxpdHkiOiJrZXJuZWxfZXZhbHVhdGVkIn0sInJlbmRlcl9zdGF0dXMiOiJyZW5kZXJlZCIsInNka19jb25uZWN0b3JfZnJhbWUiOnsib3JpZ2luIjpbNTAwLC01MDAsMF0sInhfYXhpcyI6Wy0wLjcwNzEwNjc4MTE4NjU0NzUsLTAuNzA3MTA2NzgxMTg2NTQ3NSwwXSwieV9heGlzIjpbMCwwLDFdLCJ6X2F4aXMiOlstMC43MDcxMDY3ODExODY1NDc2LDAuNzA3MTA2NzgxMTg2NTQ3NiwwXX0sInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImtpbmQiOiJ1bmJvdW5kIn19LHsiY2hpbGRfZW50aXR5X2lkcyI6WyJlbnRpdHkvdmVydGV4LzAiLCJlbnRpdHkvdmVydGV4LzMiXSwiY29ubmVjdG9yX2JpbmRpbmdfc3RhdHVzIjoib3duZXJfbm90X3BhcnQiLCJlbnRpdHlfaWQiOiJlbnRpdHkvZWRnZS8yIiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJkaXJlY3Rpb24iOlswLC0xLDBdLCJvcmlnaW4iOlswLDAsMF0sInR5cGUiOiJsaW5lIn0sImtpbmQiOiJlZGdlIiwicGFyZW50X2VudGl0eV9pZHMiOlsiZW50aXR5L2ZhY2UvMCIsImVudGl0eS9mYWNlLzEiXSwicHJvcGVydGllcyI6eyJib3VuZHMiOnsibWF4IjpbMCwwLDBdLCJtaW4iOlswLC0xMDAwLDBdfSwiY2VudHJvaWQiOlswLC01MDAsMF0sImxlbmd0aCI6MTAwMCwicXVhbGl0eSI6Imtlcm5lbF9ldmFsdWF0ZWQifSwicmVuZGVyX3N0YXR1cyI6InJlbmRlcmVkIiwic2RrX2Nvbm5lY3Rvcl9mcmFtZSI6eyJvcmlnaW4iOlswLC01MDAsMF0sInhfYXhpcyI6WzAsLTEsMF0sInlfYXhpcyI6WzAsMCwxXSwiel9heGlzIjpbLTEsMCwwXX0sInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImtpbmQiOiJ1bmJvdW5kIn19LHsiY2hpbGRfZW50aXR5X2lkcyI6WyJlbnRpdHkvdmVydGV4LzAiLCJlbnRpdHkvdmVydGV4LzEiXSwiY29ubmVjdG9yX2JpbmRpbmdfc3RhdHVzIjoib3duZXJfbm90X3BhcnQiLCJlbnRpdHlfaWQiOiJlbnRpdHkvZWRnZS8zIiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJkaXJlY3Rpb24iOlsxLDAsMF0sIm9yaWdpbiI6WzAsMCwwXSwidHlwZSI6ImxpbmUifSwia2luZCI6ImVkZ2UiLCJwYXJlbnRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZmFjZS8xIiwiZW50aXR5L2ZhY2UvMiJdLCJwcm9wZXJ0aWVzIjp7ImJvdW5kcyI6eyJtYXgiOlsxMDAwLDAsMF0sIm1pbiI6WzAsMCwwXX0sImNlbnRyb2lkIjpbNTAwLDAsMF0sImxlbmd0aCI6MTAwMCwicXVhbGl0eSI6Imtlcm5lbF9ldmFsdWF0ZWQifSwicmVuZGVyX3N0YXR1cyI6InJlbmRlcmVkIiwic2RrX2Nvbm5lY3Rvcl9mcmFtZSI6eyJvcmlnaW4iOls1MDAsMCwwXSwieF9heGlzIjpbMSwwLDBdLCJ5X2F4aXMiOlswLDAsMV0sInpfYXhpcyI6WzAsLTEsMF19LCJzZGtfbWV0YWRhdGEiOnt9LCJzZW1hbnRpY19iaW5kaW5nX2lkcyI6W10sInNvdXJjZSI6eyJraW5kIjoidW5ib3VuZCJ9fSx7ImNoaWxkX2VudGl0eV9pZHMiOlsiZW50aXR5L3ZlcnRleC8xIiwiZW50aXR5L3ZlcnRleC8yIl0sImNvbm5lY3Rvcl9iaW5kaW5nX3N0YXR1cyI6Im93bmVyX25vdF9wYXJ0IiwiZW50aXR5X2lkIjoiZW50aXR5L2VkZ2UvNCIsImV2YWx1YXRlZF90YWdzIjpbXSwiZ2VvbWV0cnkiOnsiZGlyZWN0aW9uIjpbLTAuNzA3MTA2NzgxMTg2NTQ3NSwwLDAuNzA3MTA2NzgxMTg2NTQ3NV0sIm9yaWdpbiI6WzEwMDAsMCwwXSwidHlwZSI6ImxpbmUifSwia2luZCI6ImVkZ2UiLCJwYXJlbnRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZmFjZS8yIiwiZW50aXR5L2ZhY2UvMyJdLCJwcm9wZXJ0aWVzIjp7ImJvdW5kcyI6eyJtYXgiOlsxMDAwLDAsMTAwMF0sIm1pbiI6WzAsMCwwXX0sImNlbnRyb2lkIjpbNTAwLDAsNTAwXSwibGVuZ3RoIjoxNDE0LjIxMzU2MjM3MzA5NSwicXVhbGl0eSI6Imtlcm5lbF9ldmFsdWF0ZWQifSwicmVuZGVyX3N0YXR1cyI6InJlbmRlcmVkIiwic2RrX2Nvbm5lY3Rvcl9mcmFtZSI6eyJvcmlnaW4iOls1MDAsMCw1MDBdLCJ4X2F4aXMiOlstMC43MDcxMDY3ODExODY1NDc1LDAsMC43MDcxMDY3ODExODY1NDc1XSwieV9heGlzIjpbMC43MDcxMDY3ODExODY1NDc0LDAsMC43MDcxMDY3ODExODY1NDc3XSwiel9heGlzIjpbMCwxLDBdfSwic2RrX21ldGFkYXRhIjp7fSwic2VtYW50aWNfYmluZGluZ19pZHMiOltdLCJzb3VyY2UiOnsia2luZCI6InVuYm91bmQifX0seyJjaGlsZF9lbnRpdHlfaWRzIjpbImVudGl0eS92ZXJ0ZXgvMCIsImVudGl0eS92ZXJ0ZXgvMiJdLCJjb25uZWN0b3JfYmluZGluZ19zdGF0dXMiOiJvd25lcl9ub3RfcGFydCIsImVudGl0eV9pZCI6ImVudGl0eS9lZGdlLzUiLCJldmFsdWF0ZWRfdGFncyI6W10sImdlb21ldHJ5Ijp7ImRpcmVjdGlvbiI6WzAsMCwxXSwib3JpZ2luIjpbMCwwLDBdLCJ0eXBlIjoibGluZSJ9LCJraW5kIjoiZWRnZSIsInBhcmVudF9lbnRpdHlfaWRzIjpbImVudGl0eS9mYWNlLzAiLCJlbnRpdHkvZmFjZS8yIl0sInByb3BlcnRpZXMiOnsiYm91bmRzIjp7Im1heCI6WzAsMCwxMDAwXSwibWluIjpbMCwwLDBdfSwiY2VudHJvaWQiOlswLDAsNTAwXSwibGVuZ3RoIjoxMDAwLCJxdWFsaXR5Ijoia2VybmVsX2V2YWx1YXRlZCJ9LCJyZW5kZXJfc3RhdHVzIjoicmVuZGVyZWQiLCJzZGtfY29ubmVjdG9yX2ZyYW1lIjp7Im9yaWdpbiI6WzAsMCw1MDBdLCJ4X2F4aXMiOlswLDAsMV0sInlfYXhpcyI6WzAsMSwwXSwiel9heGlzIjpbLTEsMCwwXX0sInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImtpbmQiOiJ1bmJvdW5kIn19LHsiY2hpbGRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZWRnZS8wIiwiZW50aXR5L2VkZ2UvMiIsImVudGl0eS9lZGdlLzUiXSwiY29ubmVjdG9yX2JpbmRpbmdfc3RhdHVzIjoib3duZXJfbm90X3BhcnQiLCJlbnRpdHlfaWQiOiJlbnRpdHkvZmFjZS8wIiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJub3JtYWwiOlstMSwwLDBdLCJvcmlnaW4iOlswLC0zMzMuMzMzMzMzMzMzMzMzMywzMzMuMzMzMzMzMzMzMzMzM10sInR5cGUiOiJwbGFuZSIsInhfZGlyZWN0aW9uIjpbMCwwLDFdfSwia2luZCI6ImZhY2UiLCJwYXJlbnRfZW50aXR5X2lkcyI6WyJlbnRpdHkvc29saWQvMCJdLCJwcm9wZXJ0aWVzIjp7ImFyZWEiOjUwMDAwMCwiYm91bmRzIjp7Im1heCI6WzAsMCwxMDAwXSwibWluIjpbMCwtMTAwMCwwXX0sImNlbnRyb2lkIjpbMCwtMzMzLjMzMzMzMzMzMzMzMzMsMzMzLjMzMzMzMzMzMzMzMzNdLCJvcmllbnRhdGlvbiI6ImZvcndhcmQiLCJxdWFsaXR5Ijoia2VybmVsX2V2YWx1YXRlZCJ9LCJyZW5kZXJfc3RhdHVzIjoicmVuZGVyZWQiLCJzZGtfY29ubmVjdG9yX2ZyYW1lIjp7Im9yaWdpbiI6WzAsLTMzMy4zMzMzMzMzMzMzMzMzLDMzMy4zMzMzMzMzMzMzMzMzXSwieF9heGlzIjpbMCwwLDFdLCJ5X2F4aXMiOlswLDEsMF0sInpfYXhpcyI6Wy0xLDAsMF19LCJzZGtfbWV0YWRhdGEiOnt9LCJzZW1hbnRpY19iaW5kaW5nX2lkcyI6W10sInNvdXJjZSI6eyJraW5kIjoidW5ib3VuZCJ9fSx7ImNoaWxkX2VudGl0eV9pZHMiOlsiZW50aXR5L2VkZ2UvMSIsImVudGl0eS9lZGdlLzIiLCJlbnRpdHkvZWRnZS8zIl0sImNvbm5lY3Rvcl9iaW5kaW5nX3N0YXR1cyI6Im93bmVyX25vdF9wYXJ0IiwiZW50aXR5X2lkIjoiZW50aXR5L2ZhY2UvMSIsImV2YWx1YXRlZF90YWdzIjpbXSwiZ2VvbWV0cnkiOnsibm9ybWFsIjpbMCwwLC0xXSwib3JpZ2luIjpbMzMzLjMzMzMzMzMzMzMzMzMsLTMzMy4zMzMzMzMzMzMzMzMzLDBdLCJ0eXBlIjoicGxhbmUiLCJ4X2RpcmVjdGlvbiI6WzEsMCwwXX0sImtpbmQiOiJmYWNlIiwicGFyZW50X2VudGl0eV9pZHMiOlsiZW50aXR5L3NvbGlkLzAiXSwicHJvcGVydGllcyI6eyJhcmVhIjo1MDAwMDAsImJvdW5kcyI6eyJtYXgiOlsxMDAwLDAsMF0sIm1pbiI6WzAsLTEwMDAsMF19LCJjZW50cm9pZCI6WzMzMy4zMzMzMzMzMzMzMzMzLC0zMzMuMzMzMzMzMzMzMzMzMywwXSwib3JpZW50YXRpb24iOiJmb3J3YXJkIiwicXVhbGl0eSI6Imtlcm5lbF9ldmFsdWF0ZWQifSwicmVuZGVyX3N0YXR1cyI6InJlbmRlcmVkIiwic2RrX2Nvbm5lY3Rvcl9mcmFtZSI6eyJvcmlnaW4iOlszMzMuMzMzMzMzMzMzMzMzMywtMzMzLjMzMzMzMzMzMzMzMzMsMF0sInhfYXhpcyI6WzEsMCwwXSwieV9heGlzIjpbMCwtMSwwXSwiel9heGlzIjpbMCwwLC0xXX0sInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImtpbmQiOiJ1bmJvdW5kIn19LHsiY2hpbGRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZWRnZS8zIiwiZW50aXR5L2VkZ2UvNCIsImVudGl0eS9lZGdlLzUiXSwiY29ubmVjdG9yX2JpbmRpbmdfc3RhdHVzIjoib3duZXJfbm90X3BhcnQiLCJlbnRpdHlfaWQiOiJlbnRpdHkvZmFjZS8yIiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJub3JtYWwiOlswLDEsMF0sIm9yaWdpbiI6WzMzMy4zMzMzMzMzMzMzMzMzLDAsMzMzLjMzMzMzMzMzMzMzMzNdLCJ0eXBlIjoicGxhbmUiLCJ4X2RpcmVjdGlvbiI6WzEsMCwwXX0sImtpbmQiOiJmYWNlIiwicGFyZW50X2VudGl0eV9pZHMiOlsiZW50aXR5L3NvbGlkLzAiXSwicHJvcGVydGllcyI6eyJhcmVhIjo1MDAwMDAsImJvdW5kcyI6eyJtYXgiOlsxMDAwLDAsMTAwMF0sIm1pbiI6WzAsMCwwXX0sImNlbnRyb2lkIjpbMzMzLjMzMzMzMzMzMzMzMzMsMCwzMzMuMzMzMzMzMzMzMzMzM10sIm9yaWVudGF0aW9uIjoiZm9yd2FyZCIsInF1YWxpdHkiOiJrZXJuZWxfZXZhbHVhdGVkIn0sInJlbmRlcl9zdGF0dXMiOiJyZW5kZXJlZCIsInNka19jb25uZWN0b3JfZnJhbWUiOnsib3JpZ2luIjpbMzMzLjMzMzMzMzMzMzMzMzMsMCwzMzMuMzMzMzMzMzMzMzMzM10sInhfYXhpcyI6WzEsMCwwXSwieV9heGlzIjpbMCwwLC0xXSwiel9heGlzIjpbMCwxLDBdfSwic2RrX21ldGFkYXRhIjp7fSwic2VtYW50aWNfYmluZGluZ19pZHMiOltdLCJzb3VyY2UiOnsia2luZCI6InVuYm91bmQifX0seyJjaGlsZF9lbnRpdHlfaWRzIjpbImVudGl0eS9lZGdlLzAiLCJlbnRpdHkvZWRnZS8xIiwiZW50aXR5L2VkZ2UvNCJdLCJjb25uZWN0b3JfYmluZGluZ19zdGF0dXMiOiJvd25lcl9ub3RfcGFydCIsImVudGl0eV9pZCI6ImVudGl0eS9mYWNlLzMiLCJldmFsdWF0ZWRfdGFncyI6W10sImdlb21ldHJ5Ijp7Im5vcm1hbCI6WzAuNTc3MzUwMjY5MTg5NjI1OCwtMC41NzczNTAyNjkxODk2MjU4LDAuNTc3MzUwMjY5MTg5NjI1OF0sIm9yaWdpbiI6WzMzMy4zMzMzMzMzMzMzMzMzLC0zMzMuMzMzMzMzMzMzMzMzMywzMzMuMzMzMzMzMzMzMzMzM10sInR5cGUiOiJwbGFuZSIsInhfZGlyZWN0aW9uIjpbLTAuNzA3MTA2NzgxMTg2NTQ3NSwwLDAuNzA3MTA2NzgxMTg2NTQ3NV19LCJraW5kIjoiZmFjZSIsInBhcmVudF9lbnRpdHlfaWRzIjpbImVudGl0eS9zb2xpZC8wIl0sInByb3BlcnRpZXMiOnsiYXJlYSI6ODY2MDI1LjQwMzc4NDQzODYsImJvdW5kcyI6eyJtYXgiOlsxMDAwLDAsMTAwMF0sIm1pbiI6WzAsLTEwMDAsMF19LCJjZW50cm9pZCI6WzMzMy4zMzMzMzMzMzMzMzMzLC0zMzMuMzMzMzMzMzMzMzMzMywzMzMuMzMzMzMzMzMzMzMzM10sIm9yaWVudGF0aW9uIjoiZm9yd2FyZCIsInF1YWxpdHkiOiJrZXJuZWxfZXZhbHVhdGVkIn0sInJlbmRlcl9zdGF0dXMiOiJyZW5kZXJlZCIsInNka19jb25uZWN0b3JfZnJhbWUiOnsib3JpZ2luIjpbMzMzLjMzMzMzMzMzMzMzMzMsLTMzMy4zMzMzMzMzMzMzMzMzLDMzMy4zMzMzMzMzMzMzMzMzXSwieF9heGlzIjpbLTAuNzA3MTA2NzgxMTg2NTQ3NSwwLDAuNzA3MTA2NzgxMTg2NTQ3NV0sInlfYXhpcyI6Wy0wLjQwODI0ODI5MDQ2Mzg2MzEsLTAuODE2NDk2NTgwOTI3NzI2MSwtMC40MDgyNDgyOTA0NjM4NjMxXSwiel9heGlzIjpbMC41NzczNTAyNjkxODk2MjU4LC0wLjU3NzM1MDI2OTE4OTYyNTgsMC41NzczNTAyNjkxODk2MjU4XX0sInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImtpbmQiOiJ1bmJvdW5kIn19LHsiY2hpbGRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZmFjZS8wIiwiZW50aXR5L2ZhY2UvMSIsImVudGl0eS9mYWNlLzIiLCJlbnRpdHkvZmFjZS8zIl0sImNvbm5lY3Rvcl9iaW5kaW5nX3N0YXR1cyI6Im5vdF9hcHBsaWNhYmxlIiwiZW50aXR5X2lkIjoiZW50aXR5L3NvbGlkLzAiLCJldmFsdWF0ZWRfdGFncyI6W10sImdlb21ldHJ5Ijp7InR5cGUiOiJicmVwX3NvbGlkIn0sImtpbmQiOiJzb2xpZCIsInBhcmVudF9lbnRpdHlfaWRzIjpbXSwicHJvcGVydGllcyI6eyJib3VuZHMiOnsibWF4IjpbMTAwMCwwLDEwMDBdLCJtaW4iOlswLC0xMDAwLDBdfSwiY2VudHJvaWQiOlsyNTAsLTI1MCwyNTBdLCJxdWFsaXR5Ijoia2VybmVsX2V2YWx1YXRlZCIsInN1cmZhY2VfYXJlYSI6MjM2NjAyNS40MDM3ODQ0Mzg1LCJ2b2x1bWUiOjE2NjY2NjY2Ni42NjY2NjY2Nn0sInJlbmRlcl9zdGF0dXMiOiJyZW5kZXJlZCIsInNka19jb25uZWN0b3JfZnJhbWUiOm51bGwsInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImtpbmQiOiJ1bmJvdW5kIn19LHsiY2hpbGRfZW50aXR5X2lkcyI6W10sImNvbm5lY3Rvcl9iaW5kaW5nX3N0YXR1cyI6Im93bmVyX25vdF9wYXJ0IiwiZW50aXR5X2lkIjoiZW50aXR5L3ZlcnRleC8wIiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJwb3NpdGlvbiI6WzAsMCwwXSwidHlwZSI6InBvaW50In0sImtpbmQiOiJ2ZXJ0ZXgiLCJwYXJlbnRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZWRnZS8yIiwiZW50aXR5L2VkZ2UvMyIsImVudGl0eS9lZGdlLzUiXSwicHJvcGVydGllcyI6eyJib3VuZHMiOnsibWF4IjpbMCwwLDBdLCJtaW4iOlswLDAsMF19LCJwb3NpdGlvbiI6WzAsMCwwXSwicXVhbGl0eSI6Imtlcm5lbF9ldmFsdWF0ZWQifSwicmVuZGVyX3N0YXR1cyI6InJlbmRlcmVkIiwic2RrX2Nvbm5lY3Rvcl9mcmFtZSI6eyJvcmlnaW4iOlswLDAsMF0sInhfYXhpcyI6WzEsMCwwXSwieV9heGlzIjpbMCwxLDBdLCJ6X2F4aXMiOlswLDAsMV19LCJzZGtfbWV0YWRhdGEiOnt9LCJzZW1hbnRpY19iaW5kaW5nX2lkcyI6W10sInNvdXJjZSI6eyJraW5kIjoidW5ib3VuZCJ9fSx7ImNoaWxkX2VudGl0eV9pZHMiOltdLCJjb25uZWN0b3JfYmluZGluZ19zdGF0dXMiOiJvd25lcl9ub3RfcGFydCIsImVudGl0eV9pZCI6ImVudGl0eS92ZXJ0ZXgvMSIsImV2YWx1YXRlZF90YWdzIjpbXSwiZ2VvbWV0cnkiOnsicG9zaXRpb24iOlsxMDAwLDAsMF0sInR5cGUiOiJwb2ludCJ9LCJraW5kIjoidmVydGV4IiwicGFyZW50X2VudGl0eV9pZHMiOlsiZW50aXR5L2VkZ2UvMSIsImVudGl0eS9lZGdlLzMiLCJlbnRpdHkvZWRnZS80Il0sInByb3BlcnRpZXMiOnsiYm91bmRzIjp7Im1heCI6WzEwMDAsMCwwXSwibWluIjpbMTAwMCwwLDBdfSwicG9zaXRpb24iOlsxMDAwLDAsMF0sInF1YWxpdHkiOiJrZXJuZWxfZXZhbHVhdGVkIn0sInJlbmRlcl9zdGF0dXMiOiJyZW5kZXJlZCIsInNka19jb25uZWN0b3JfZnJhbWUiOnsib3JpZ2luIjpbMTAwMCwwLDBdLCJ4X2F4aXMiOlsxLDAsMF0sInlfYXhpcyI6WzAsMSwwXSwiel9heGlzIjpbMCwwLDFdfSwic2RrX21ldGFkYXRhIjp7fSwic2VtYW50aWNfYmluZGluZ19pZHMiOltdLCJzb3VyY2UiOnsia2luZCI6InVuYm91bmQifX0seyJjaGlsZF9lbnRpdHlfaWRzIjpbXSwiY29ubmVjdG9yX2JpbmRpbmdfc3RhdHVzIjoib3duZXJfbm90X3BhcnQiLCJlbnRpdHlfaWQiOiJlbnRpdHkvdmVydGV4LzIiLCJldmFsdWF0ZWRfdGFncyI6W10sImdlb21ldHJ5Ijp7InBvc2l0aW9uIjpbMCwwLDEwMDBdLCJ0eXBlIjoicG9pbnQifSwia2luZCI6InZlcnRleCIsInBhcmVudF9lbnRpdHlfaWRzIjpbImVudGl0eS9lZGdlLzAiLCJlbnRpdHkvZWRnZS80IiwiZW50aXR5L2VkZ2UvNSJdLCJwcm9wZXJ0aWVzIjp7ImJvdW5kcyI6eyJtYXgiOlswLDAsMTAwMF0sIm1pbiI6WzAsMCwxMDAwXX0sInBvc2l0aW9uIjpbMCwwLDEwMDBdLCJxdWFsaXR5Ijoia2VybmVsX2V2YWx1YXRlZCJ9LCJyZW5kZXJfc3RhdHVzIjoicmVuZGVyZWQiLCJzZGtfY29ubmVjdG9yX2ZyYW1lIjp7Im9yaWdpbiI6WzAsMCwxMDAwXSwieF9heGlzIjpbMSwwLDBdLCJ5X2F4aXMiOlswLDEsMF0sInpfYXhpcyI6WzAsMCwxXX0sInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImtpbmQiOiJ1bmJvdW5kIn19LHsiY2hpbGRfZW50aXR5X2lkcyI6W10sImNvbm5lY3Rvcl9iaW5kaW5nX3N0YXR1cyI6Im93bmVyX25vdF9wYXJ0IiwiZW50aXR5X2lkIjoiZW50aXR5L3ZlcnRleC8zIiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJwb3NpdGlvbiI6WzAsLTEwMDAsMF0sInR5cGUiOiJwb2ludCJ9LCJraW5kIjoidmVydGV4IiwicGFyZW50X2VudGl0eV9pZHMiOlsiZW50aXR5L2VkZ2UvMCIsImVudGl0eS9lZGdlLzEiLCJlbnRpdHkvZWRnZS8yIl0sInByb3BlcnRpZXMiOnsiYm91bmRzIjp7Im1heCI6WzAsLTEwMDAsMF0sIm1pbiI6WzAsLTEwMDAsMF19LCJwb3NpdGlvbiI6WzAsLTEwMDAsMF0sInF1YWxpdHkiOiJrZXJuZWxfZXZhbHVhdGVkIn0sInJlbmRlcl9zdGF0dXMiOiJyZW5kZXJlZCIsInNka19jb25uZWN0b3JfZnJhbWUiOnsib3JpZ2luIjpbMCwtMTAwMCwwXSwieF9heGlzIjpbMSwwLDBdLCJ5X2F4aXMiOlswLDEsMF0sInpfYXhpcyI6WzAsMCwxXX0sInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImtpbmQiOiJ1bmJvdW5kIn19XSwiZmFjZV9ncm91cHMiOlt7ImVudGl0eV9pZCI6ImVudGl0eS9mYWNlLzAiLCJmaXJzdF9pbmRleCI6MCwiZ3JvdXBfaWQiOjAsImluZGV4X2NvdW50IjozLCJtZXNoX2luZGV4IjowLCJwcmltaXRpdmVfaW5kZXgiOjB9LHsiZW50aXR5X2lkIjoiZW50aXR5L2ZhY2UvMSIsImZpcnN0X2luZGV4IjozLCJncm91cF9pZCI6MSwiaW5kZXhfY291bnQiOjMsIm1lc2hfaW5kZXgiOjAsInByaW1pdGl2ZV9pbmRleCI6MH0seyJlbnRpdHlfaWQiOiJlbnRpdHkvZmFjZS8yIiwiZmlyc3RfaW5kZXgiOjYsImdyb3VwX2lkIjoyLCJpbmRleF9jb3VudCI6MywibWVzaF9pbmRleCI6MCwicHJpbWl0aXZlX2luZGV4IjowfSx7ImVudGl0eV9pZCI6ImVudGl0eS9mYWNlLzMiLCJmaXJzdF9pbmRleCI6OSwiZ3JvdXBfaWQiOjMsImluZGV4X2NvdW50IjozLCJtZXNoX2luZGV4IjowLCJwcmltaXRpdmVfaW5kZXgiOjB9XSwiZ2VvbWV0cnlfYXNzZXRfaWQiOiJzaGEyNTY6MjAwNTIzNDE0ZGUyODFhOGExNTUwODZhNTRjZDBmODgwZjY0MDQ4NTc2NWE0OGNjZGQ4ZWMyMzBmNjdkZDE3YyIsImdlb21ldHJ5X2VuZ2luZSI6eyJuYW1lIjoiT3BlbkNhc2NhZGUiLCJwcm9maWxlIjoib2NwLWV2YWx1YXRlZC1wcm9wZXJ0aWVzLTEiLCJ2ZXJzaW9uIjoiZml4dHVyZSJ9LCJzY2hlbWFfdmVyc2lvbiI6IjEuMCJ9", + "valid": false + }, + { + "expected": { + "code": "schema_invalid", + "path": "/schema_version" + }, + "name": "unsupported_entity_schema_version", + "payload_base64": "eyJkZWZpbml0aW9uX2lkIjoiZGVmaW5pdGlvbi9yb290L3NoYXBlL21hbnVhbC9maXh0dXJlIiwiZWRnZV9hc3NldF9pZCI6InNoYTI1Njo2Y2U0ODFkYjFmZTEzYmNmZTdhMjM3NDQyM2U2YzcyZDc1N2Q1MWM5OTE2OGIyODdjNTJmNTNhYTYyNWNjZDY3IiwiZWRnZV9ncm91cHMiOlt7ImVudGl0eV9pZCI6ImVudGl0eS9lZGdlLzAiLCJmaXJzdF9pbmRleCI6MCwiZ3JvdXBfaWQiOjAsImluZGV4X2NvdW50IjoyLCJtZXNoX2luZGV4IjowLCJwcmltaXRpdmVfaW5kZXgiOjB9LHsiZW50aXR5X2lkIjoiZW50aXR5L2VkZ2UvMSIsImZpcnN0X2luZGV4IjoyLCJncm91cF9pZCI6MSwiaW5kZXhfY291bnQiOjIsIm1lc2hfaW5kZXgiOjAsInByaW1pdGl2ZV9pbmRleCI6MH0seyJlbnRpdHlfaWQiOiJlbnRpdHkvZWRnZS8yIiwiZmlyc3RfaW5kZXgiOjQsImdyb3VwX2lkIjoyLCJpbmRleF9jb3VudCI6MiwibWVzaF9pbmRleCI6MCwicHJpbWl0aXZlX2luZGV4IjowfSx7ImVudGl0eV9pZCI6ImVudGl0eS9lZGdlLzMiLCJmaXJzdF9pbmRleCI6NiwiZ3JvdXBfaWQiOjMsImluZGV4X2NvdW50IjoyLCJtZXNoX2luZGV4IjowLCJwcmltaXRpdmVfaW5kZXgiOjB9LHsiZW50aXR5X2lkIjoiZW50aXR5L2VkZ2UvNCIsImZpcnN0X2luZGV4Ijo4LCJncm91cF9pZCI6NCwiaW5kZXhfY291bnQiOjIsIm1lc2hfaW5kZXgiOjAsInByaW1pdGl2ZV9pbmRleCI6MH0seyJlbnRpdHlfaWQiOiJlbnRpdHkvZWRnZS81IiwiZmlyc3RfaW5kZXgiOjEwLCJncm91cF9pZCI6NSwiaW5kZXhfY291bnQiOjIsIm1lc2hfaW5kZXgiOjAsInByaW1pdGl2ZV9pbmRleCI6MH1dLCJlbnRpdGllcyI6W3siY2hpbGRfZW50aXR5X2lkcyI6WyJlbnRpdHkvdmVydGV4LzIiLCJlbnRpdHkvdmVydGV4LzMiXSwiY29ubmVjdG9yX2JpbmRpbmdfc3RhdHVzIjoib3duZXJfbm90X3BhcnQiLCJlbnRpdHlfaWQiOiJlbnRpdHkvZWRnZS8wIiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJkaXJlY3Rpb24iOlswLC0wLjcwNzEwNjc4MTE4NjU0NzUsLTAuNzA3MTA2NzgxMTg2NTQ3NV0sIm9yaWdpbiI6WzAsMCwxMDAwXSwidHlwZSI6ImxpbmUifSwia2luZCI6ImVkZ2UiLCJwYXJlbnRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZmFjZS8wIiwiZW50aXR5L2ZhY2UvMyJdLCJwcm9wZXJ0aWVzIjp7ImJvdW5kcyI6eyJtYXgiOlswLDAsMTAwMF0sIm1pbiI6WzAsLTEwMDAsMF19LCJjZW50cm9pZCI6WzAsLTUwMCw1MDBdLCJsZW5ndGgiOjE0MTQuMjEzNTYyMzczMDk1LCJxdWFsaXR5Ijoia2VybmVsX2V2YWx1YXRlZCJ9LCJyZW5kZXJfc3RhdHVzIjoicmVuZGVyZWQiLCJzZGtfY29ubmVjdG9yX2ZyYW1lIjp7Im9yaWdpbiI6WzAsLTUwMCw1MDBdLCJ4X2F4aXMiOlswLC0wLjcwNzEwNjc4MTE4NjU0NzUsLTAuNzA3MTA2NzgxMTg2NTQ3NV0sInlfYXhpcyI6WzAsLTAuNzA3MTA2NzgxMTg2NTQ3NCwwLjcwNzEwNjc4MTE4NjU0NzddLCJ6X2F4aXMiOlstMSwwLDBdfSwic2RrX21ldGFkYXRhIjp7fSwic2VtYW50aWNfYmluZGluZ19pZHMiOltdLCJzb3VyY2UiOnsia2luZCI6InVuYm91bmQifX0seyJjaGlsZF9lbnRpdHlfaWRzIjpbImVudGl0eS92ZXJ0ZXgvMSIsImVudGl0eS92ZXJ0ZXgvMyJdLCJjb25uZWN0b3JfYmluZGluZ19zdGF0dXMiOiJvd25lcl9ub3RfcGFydCIsImVudGl0eV9pZCI6ImVudGl0eS9lZGdlLzEiLCJldmFsdWF0ZWRfdGFncyI6W10sImdlb21ldHJ5Ijp7ImRpcmVjdGlvbiI6Wy0wLjcwNzEwNjc4MTE4NjU0NzUsLTAuNzA3MTA2NzgxMTg2NTQ3NSwwXSwib3JpZ2luIjpbMTAwMCwwLDBdLCJ0eXBlIjoibGluZSJ9LCJraW5kIjoiZWRnZSIsInBhcmVudF9lbnRpdHlfaWRzIjpbImVudGl0eS9mYWNlLzEiLCJlbnRpdHkvZmFjZS8zIl0sInByb3BlcnRpZXMiOnsiYm91bmRzIjp7Im1heCI6WzEwMDAsMCwwXSwibWluIjpbMCwtMTAwMCwwXX0sImNlbnRyb2lkIjpbNTAwLC01MDAsMF0sImxlbmd0aCI6MTQxNC4yMTM1NjIzNzMwOTUsInF1YWxpdHkiOiJrZXJuZWxfZXZhbHVhdGVkIn0sInJlbmRlcl9zdGF0dXMiOiJyZW5kZXJlZCIsInNka19jb25uZWN0b3JfZnJhbWUiOnsib3JpZ2luIjpbNTAwLC01MDAsMF0sInhfYXhpcyI6Wy0wLjcwNzEwNjc4MTE4NjU0NzUsLTAuNzA3MTA2NzgxMTg2NTQ3NSwwXSwieV9heGlzIjpbMCwwLDFdLCJ6X2F4aXMiOlstMC43MDcxMDY3ODExODY1NDc2LDAuNzA3MTA2NzgxMTg2NTQ3NiwwXX0sInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImtpbmQiOiJ1bmJvdW5kIn19LHsiY2hpbGRfZW50aXR5X2lkcyI6WyJlbnRpdHkvdmVydGV4LzAiLCJlbnRpdHkvdmVydGV4LzMiXSwiY29ubmVjdG9yX2JpbmRpbmdfc3RhdHVzIjoib3duZXJfbm90X3BhcnQiLCJlbnRpdHlfaWQiOiJlbnRpdHkvZWRnZS8yIiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJkaXJlY3Rpb24iOlswLC0xLDBdLCJvcmlnaW4iOlswLDAsMF0sInR5cGUiOiJsaW5lIn0sImtpbmQiOiJlZGdlIiwicGFyZW50X2VudGl0eV9pZHMiOlsiZW50aXR5L2ZhY2UvMCIsImVudGl0eS9mYWNlLzEiXSwicHJvcGVydGllcyI6eyJib3VuZHMiOnsibWF4IjpbMCwwLDBdLCJtaW4iOlswLC0xMDAwLDBdfSwiY2VudHJvaWQiOlswLC01MDAsMF0sImxlbmd0aCI6MTAwMCwicXVhbGl0eSI6Imtlcm5lbF9ldmFsdWF0ZWQifSwicmVuZGVyX3N0YXR1cyI6InJlbmRlcmVkIiwic2RrX2Nvbm5lY3Rvcl9mcmFtZSI6eyJvcmlnaW4iOlswLC01MDAsMF0sInhfYXhpcyI6WzAsLTEsMF0sInlfYXhpcyI6WzAsMCwxXSwiel9heGlzIjpbLTEsMCwwXX0sInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImtpbmQiOiJ1bmJvdW5kIn19LHsiY2hpbGRfZW50aXR5X2lkcyI6WyJlbnRpdHkvdmVydGV4LzAiLCJlbnRpdHkvdmVydGV4LzEiXSwiY29ubmVjdG9yX2JpbmRpbmdfc3RhdHVzIjoib3duZXJfbm90X3BhcnQiLCJlbnRpdHlfaWQiOiJlbnRpdHkvZWRnZS8zIiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJkaXJlY3Rpb24iOlsxLDAsMF0sIm9yaWdpbiI6WzAsMCwwXSwidHlwZSI6ImxpbmUifSwia2luZCI6ImVkZ2UiLCJwYXJlbnRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZmFjZS8xIiwiZW50aXR5L2ZhY2UvMiJdLCJwcm9wZXJ0aWVzIjp7ImJvdW5kcyI6eyJtYXgiOlsxMDAwLDAsMF0sIm1pbiI6WzAsMCwwXX0sImNlbnRyb2lkIjpbNTAwLDAsMF0sImxlbmd0aCI6MTAwMCwicXVhbGl0eSI6Imtlcm5lbF9ldmFsdWF0ZWQifSwicmVuZGVyX3N0YXR1cyI6InJlbmRlcmVkIiwic2RrX2Nvbm5lY3Rvcl9mcmFtZSI6eyJvcmlnaW4iOls1MDAsMCwwXSwieF9heGlzIjpbMSwwLDBdLCJ5X2F4aXMiOlswLDAsMV0sInpfYXhpcyI6WzAsLTEsMF19LCJzZGtfbWV0YWRhdGEiOnt9LCJzZW1hbnRpY19iaW5kaW5nX2lkcyI6W10sInNvdXJjZSI6eyJraW5kIjoidW5ib3VuZCJ9fSx7ImNoaWxkX2VudGl0eV9pZHMiOlsiZW50aXR5L3ZlcnRleC8xIiwiZW50aXR5L3ZlcnRleC8yIl0sImNvbm5lY3Rvcl9iaW5kaW5nX3N0YXR1cyI6Im93bmVyX25vdF9wYXJ0IiwiZW50aXR5X2lkIjoiZW50aXR5L2VkZ2UvNCIsImV2YWx1YXRlZF90YWdzIjpbXSwiZ2VvbWV0cnkiOnsiZGlyZWN0aW9uIjpbLTAuNzA3MTA2NzgxMTg2NTQ3NSwwLDAuNzA3MTA2NzgxMTg2NTQ3NV0sIm9yaWdpbiI6WzEwMDAsMCwwXSwidHlwZSI6ImxpbmUifSwia2luZCI6ImVkZ2UiLCJwYXJlbnRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZmFjZS8yIiwiZW50aXR5L2ZhY2UvMyJdLCJwcm9wZXJ0aWVzIjp7ImJvdW5kcyI6eyJtYXgiOlsxMDAwLDAsMTAwMF0sIm1pbiI6WzAsMCwwXX0sImNlbnRyb2lkIjpbNTAwLDAsNTAwXSwibGVuZ3RoIjoxNDE0LjIxMzU2MjM3MzA5NSwicXVhbGl0eSI6Imtlcm5lbF9ldmFsdWF0ZWQifSwicmVuZGVyX3N0YXR1cyI6InJlbmRlcmVkIiwic2RrX2Nvbm5lY3Rvcl9mcmFtZSI6eyJvcmlnaW4iOls1MDAsMCw1MDBdLCJ4X2F4aXMiOlstMC43MDcxMDY3ODExODY1NDc1LDAsMC43MDcxMDY3ODExODY1NDc1XSwieV9heGlzIjpbMC43MDcxMDY3ODExODY1NDc0LDAsMC43MDcxMDY3ODExODY1NDc3XSwiel9heGlzIjpbMCwxLDBdfSwic2RrX21ldGFkYXRhIjp7fSwic2VtYW50aWNfYmluZGluZ19pZHMiOltdLCJzb3VyY2UiOnsia2luZCI6InVuYm91bmQifX0seyJjaGlsZF9lbnRpdHlfaWRzIjpbImVudGl0eS92ZXJ0ZXgvMCIsImVudGl0eS92ZXJ0ZXgvMiJdLCJjb25uZWN0b3JfYmluZGluZ19zdGF0dXMiOiJvd25lcl9ub3RfcGFydCIsImVudGl0eV9pZCI6ImVudGl0eS9lZGdlLzUiLCJldmFsdWF0ZWRfdGFncyI6W10sImdlb21ldHJ5Ijp7ImRpcmVjdGlvbiI6WzAsMCwxXSwib3JpZ2luIjpbMCwwLDBdLCJ0eXBlIjoibGluZSJ9LCJraW5kIjoiZWRnZSIsInBhcmVudF9lbnRpdHlfaWRzIjpbImVudGl0eS9mYWNlLzAiLCJlbnRpdHkvZmFjZS8yIl0sInByb3BlcnRpZXMiOnsiYm91bmRzIjp7Im1heCI6WzAsMCwxMDAwXSwibWluIjpbMCwwLDBdfSwiY2VudHJvaWQiOlswLDAsNTAwXSwibGVuZ3RoIjoxMDAwLCJxdWFsaXR5Ijoia2VybmVsX2V2YWx1YXRlZCJ9LCJyZW5kZXJfc3RhdHVzIjoicmVuZGVyZWQiLCJzZGtfY29ubmVjdG9yX2ZyYW1lIjp7Im9yaWdpbiI6WzAsMCw1MDBdLCJ4X2F4aXMiOlswLDAsMV0sInlfYXhpcyI6WzAsMSwwXSwiel9heGlzIjpbLTEsMCwwXX0sInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImtpbmQiOiJ1bmJvdW5kIn19LHsiY2hpbGRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZWRnZS8wIiwiZW50aXR5L2VkZ2UvMiIsImVudGl0eS9lZGdlLzUiXSwiY29ubmVjdG9yX2JpbmRpbmdfc3RhdHVzIjoib3duZXJfbm90X3BhcnQiLCJlbnRpdHlfaWQiOiJlbnRpdHkvZmFjZS8wIiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJub3JtYWwiOlstMSwwLDBdLCJvcmlnaW4iOlswLC0zMzMuMzMzMzMzMzMzMzMzMywzMzMuMzMzMzMzMzMzMzMzM10sInR5cGUiOiJwbGFuZSIsInhfZGlyZWN0aW9uIjpbMCwwLDFdfSwia2luZCI6ImZhY2UiLCJwYXJlbnRfZW50aXR5X2lkcyI6WyJlbnRpdHkvc29saWQvMCJdLCJwcm9wZXJ0aWVzIjp7ImFyZWEiOjUwMDAwMCwiYm91bmRzIjp7Im1heCI6WzAsMCwxMDAwXSwibWluIjpbMCwtMTAwMCwwXX0sImNlbnRyb2lkIjpbMCwtMzMzLjMzMzMzMzMzMzMzMzMsMzMzLjMzMzMzMzMzMzMzMzNdLCJvcmllbnRhdGlvbiI6ImZvcndhcmQiLCJxdWFsaXR5Ijoia2VybmVsX2V2YWx1YXRlZCJ9LCJyZW5kZXJfc3RhdHVzIjoicmVuZGVyZWQiLCJzZGtfY29ubmVjdG9yX2ZyYW1lIjp7Im9yaWdpbiI6WzAsLTMzMy4zMzMzMzMzMzMzMzMzLDMzMy4zMzMzMzMzMzMzMzMzXSwieF9heGlzIjpbMCwwLDFdLCJ5X2F4aXMiOlswLDEsMF0sInpfYXhpcyI6Wy0xLDAsMF19LCJzZGtfbWV0YWRhdGEiOnt9LCJzZW1hbnRpY19iaW5kaW5nX2lkcyI6W10sInNvdXJjZSI6eyJraW5kIjoidW5ib3VuZCJ9fSx7ImNoaWxkX2VudGl0eV9pZHMiOlsiZW50aXR5L2VkZ2UvMSIsImVudGl0eS9lZGdlLzIiLCJlbnRpdHkvZWRnZS8zIl0sImNvbm5lY3Rvcl9iaW5kaW5nX3N0YXR1cyI6Im93bmVyX25vdF9wYXJ0IiwiZW50aXR5X2lkIjoiZW50aXR5L2ZhY2UvMSIsImV2YWx1YXRlZF90YWdzIjpbXSwiZ2VvbWV0cnkiOnsibm9ybWFsIjpbMCwwLC0xXSwib3JpZ2luIjpbMzMzLjMzMzMzMzMzMzMzMzMsLTMzMy4zMzMzMzMzMzMzMzMzLDBdLCJ0eXBlIjoicGxhbmUiLCJ4X2RpcmVjdGlvbiI6WzEsMCwwXX0sImtpbmQiOiJmYWNlIiwicGFyZW50X2VudGl0eV9pZHMiOlsiZW50aXR5L3NvbGlkLzAiXSwicHJvcGVydGllcyI6eyJhcmVhIjo1MDAwMDAsImJvdW5kcyI6eyJtYXgiOlsxMDAwLDAsMF0sIm1pbiI6WzAsLTEwMDAsMF19LCJjZW50cm9pZCI6WzMzMy4zMzMzMzMzMzMzMzMzLC0zMzMuMzMzMzMzMzMzMzMzMywwXSwib3JpZW50YXRpb24iOiJmb3J3YXJkIiwicXVhbGl0eSI6Imtlcm5lbF9ldmFsdWF0ZWQifSwicmVuZGVyX3N0YXR1cyI6InJlbmRlcmVkIiwic2RrX2Nvbm5lY3Rvcl9mcmFtZSI6eyJvcmlnaW4iOlszMzMuMzMzMzMzMzMzMzMzMywtMzMzLjMzMzMzMzMzMzMzMzMsMF0sInhfYXhpcyI6WzEsMCwwXSwieV9heGlzIjpbMCwtMSwwXSwiel9heGlzIjpbMCwwLC0xXX0sInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImtpbmQiOiJ1bmJvdW5kIn19LHsiY2hpbGRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZWRnZS8zIiwiZW50aXR5L2VkZ2UvNCIsImVudGl0eS9lZGdlLzUiXSwiY29ubmVjdG9yX2JpbmRpbmdfc3RhdHVzIjoib3duZXJfbm90X3BhcnQiLCJlbnRpdHlfaWQiOiJlbnRpdHkvZmFjZS8yIiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJub3JtYWwiOlswLDEsMF0sIm9yaWdpbiI6WzMzMy4zMzMzMzMzMzMzMzMzLDAsMzMzLjMzMzMzMzMzMzMzMzNdLCJ0eXBlIjoicGxhbmUiLCJ4X2RpcmVjdGlvbiI6WzEsMCwwXX0sImtpbmQiOiJmYWNlIiwicGFyZW50X2VudGl0eV9pZHMiOlsiZW50aXR5L3NvbGlkLzAiXSwicHJvcGVydGllcyI6eyJhcmVhIjo1MDAwMDAsImJvdW5kcyI6eyJtYXgiOlsxMDAwLDAsMTAwMF0sIm1pbiI6WzAsMCwwXX0sImNlbnRyb2lkIjpbMzMzLjMzMzMzMzMzMzMzMzMsMCwzMzMuMzMzMzMzMzMzMzMzM10sIm9yaWVudGF0aW9uIjoiZm9yd2FyZCIsInF1YWxpdHkiOiJrZXJuZWxfZXZhbHVhdGVkIn0sInJlbmRlcl9zdGF0dXMiOiJyZW5kZXJlZCIsInNka19jb25uZWN0b3JfZnJhbWUiOnsib3JpZ2luIjpbMzMzLjMzMzMzMzMzMzMzMzMsMCwzMzMuMzMzMzMzMzMzMzMzM10sInhfYXhpcyI6WzEsMCwwXSwieV9heGlzIjpbMCwwLC0xXSwiel9heGlzIjpbMCwxLDBdfSwic2RrX21ldGFkYXRhIjp7fSwic2VtYW50aWNfYmluZGluZ19pZHMiOltdLCJzb3VyY2UiOnsia2luZCI6InVuYm91bmQifX0seyJjaGlsZF9lbnRpdHlfaWRzIjpbImVudGl0eS9lZGdlLzAiLCJlbnRpdHkvZWRnZS8xIiwiZW50aXR5L2VkZ2UvNCJdLCJjb25uZWN0b3JfYmluZGluZ19zdGF0dXMiOiJvd25lcl9ub3RfcGFydCIsImVudGl0eV9pZCI6ImVudGl0eS9mYWNlLzMiLCJldmFsdWF0ZWRfdGFncyI6W10sImdlb21ldHJ5Ijp7Im5vcm1hbCI6WzAuNTc3MzUwMjY5MTg5NjI1OCwtMC41NzczNTAyNjkxODk2MjU4LDAuNTc3MzUwMjY5MTg5NjI1OF0sIm9yaWdpbiI6WzMzMy4zMzMzMzMzMzMzMzMzLC0zMzMuMzMzMzMzMzMzMzMzMywzMzMuMzMzMzMzMzMzMzMzM10sInR5cGUiOiJwbGFuZSIsInhfZGlyZWN0aW9uIjpbLTAuNzA3MTA2NzgxMTg2NTQ3NSwwLDAuNzA3MTA2NzgxMTg2NTQ3NV19LCJraW5kIjoiZmFjZSIsInBhcmVudF9lbnRpdHlfaWRzIjpbImVudGl0eS9zb2xpZC8wIl0sInByb3BlcnRpZXMiOnsiYXJlYSI6ODY2MDI1LjQwMzc4NDQzODYsImJvdW5kcyI6eyJtYXgiOlsxMDAwLDAsMTAwMF0sIm1pbiI6WzAsLTEwMDAsMF19LCJjZW50cm9pZCI6WzMzMy4zMzMzMzMzMzMzMzMzLC0zMzMuMzMzMzMzMzMzMzMzMywzMzMuMzMzMzMzMzMzMzMzM10sIm9yaWVudGF0aW9uIjoiZm9yd2FyZCIsInF1YWxpdHkiOiJrZXJuZWxfZXZhbHVhdGVkIn0sInJlbmRlcl9zdGF0dXMiOiJyZW5kZXJlZCIsInNka19jb25uZWN0b3JfZnJhbWUiOnsib3JpZ2luIjpbMzMzLjMzMzMzMzMzMzMzMzMsLTMzMy4zMzMzMzMzMzMzMzMzLDMzMy4zMzMzMzMzMzMzMzMzXSwieF9heGlzIjpbLTAuNzA3MTA2NzgxMTg2NTQ3NSwwLDAuNzA3MTA2NzgxMTg2NTQ3NV0sInlfYXhpcyI6Wy0wLjQwODI0ODI5MDQ2Mzg2MzEsLTAuODE2NDk2NTgwOTI3NzI2MSwtMC40MDgyNDgyOTA0NjM4NjMxXSwiel9heGlzIjpbMC41NzczNTAyNjkxODk2MjU4LC0wLjU3NzM1MDI2OTE4OTYyNTgsMC41NzczNTAyNjkxODk2MjU4XX0sInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImtpbmQiOiJ1bmJvdW5kIn19LHsiY2hpbGRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZmFjZS8wIiwiZW50aXR5L2ZhY2UvMSIsImVudGl0eS9mYWNlLzIiLCJlbnRpdHkvZmFjZS8zIl0sImNvbm5lY3Rvcl9iaW5kaW5nX3N0YXR1cyI6Im5vdF9hcHBsaWNhYmxlIiwiZW50aXR5X2lkIjoiZW50aXR5L3NvbGlkLzAiLCJldmFsdWF0ZWRfdGFncyI6W10sImdlb21ldHJ5Ijp7InR5cGUiOiJicmVwX3NvbGlkIn0sImtpbmQiOiJzb2xpZCIsInBhcmVudF9lbnRpdHlfaWRzIjpbXSwicHJvcGVydGllcyI6eyJib3VuZHMiOnsibWF4IjpbMTAwMCwwLDEwMDBdLCJtaW4iOlswLC0xMDAwLDBdfSwiY2VudHJvaWQiOlsyNTAsLTI1MCwyNTBdLCJxdWFsaXR5Ijoia2VybmVsX2V2YWx1YXRlZCIsInN1cmZhY2VfYXJlYSI6MjM2NjAyNS40MDM3ODQ0Mzg1LCJ2b2x1bWUiOjE2NjY2NjY2Ni42NjY2NjY2Nn0sInJlbmRlcl9zdGF0dXMiOiJyZW5kZXJlZCIsInNka19jb25uZWN0b3JfZnJhbWUiOm51bGwsInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImtpbmQiOiJ1bmJvdW5kIn19LHsiY2hpbGRfZW50aXR5X2lkcyI6W10sImNvbm5lY3Rvcl9iaW5kaW5nX3N0YXR1cyI6Im93bmVyX25vdF9wYXJ0IiwiZW50aXR5X2lkIjoiZW50aXR5L3ZlcnRleC8wIiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJwb3NpdGlvbiI6WzAsMCwwXSwidHlwZSI6InBvaW50In0sImtpbmQiOiJ2ZXJ0ZXgiLCJwYXJlbnRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZWRnZS8yIiwiZW50aXR5L2VkZ2UvMyIsImVudGl0eS9lZGdlLzUiXSwicHJvcGVydGllcyI6eyJib3VuZHMiOnsibWF4IjpbMCwwLDBdLCJtaW4iOlswLDAsMF19LCJwb3NpdGlvbiI6WzAsMCwwXSwicXVhbGl0eSI6Imtlcm5lbF9ldmFsdWF0ZWQifSwicmVuZGVyX3N0YXR1cyI6InJlbmRlcmVkIiwic2RrX2Nvbm5lY3Rvcl9mcmFtZSI6eyJvcmlnaW4iOlswLDAsMF0sInhfYXhpcyI6WzEsMCwwXSwieV9heGlzIjpbMCwxLDBdLCJ6X2F4aXMiOlswLDAsMV19LCJzZGtfbWV0YWRhdGEiOnt9LCJzZW1hbnRpY19iaW5kaW5nX2lkcyI6W10sInNvdXJjZSI6eyJraW5kIjoidW5ib3VuZCJ9fSx7ImNoaWxkX2VudGl0eV9pZHMiOltdLCJjb25uZWN0b3JfYmluZGluZ19zdGF0dXMiOiJvd25lcl9ub3RfcGFydCIsImVudGl0eV9pZCI6ImVudGl0eS92ZXJ0ZXgvMSIsImV2YWx1YXRlZF90YWdzIjpbXSwiZ2VvbWV0cnkiOnsicG9zaXRpb24iOlsxMDAwLDAsMF0sInR5cGUiOiJwb2ludCJ9LCJraW5kIjoidmVydGV4IiwicGFyZW50X2VudGl0eV9pZHMiOlsiZW50aXR5L2VkZ2UvMSIsImVudGl0eS9lZGdlLzMiLCJlbnRpdHkvZWRnZS80Il0sInByb3BlcnRpZXMiOnsiYm91bmRzIjp7Im1heCI6WzEwMDAsMCwwXSwibWluIjpbMTAwMCwwLDBdfSwicG9zaXRpb24iOlsxMDAwLDAsMF0sInF1YWxpdHkiOiJrZXJuZWxfZXZhbHVhdGVkIn0sInJlbmRlcl9zdGF0dXMiOiJyZW5kZXJlZCIsInNka19jb25uZWN0b3JfZnJhbWUiOnsib3JpZ2luIjpbMTAwMCwwLDBdLCJ4X2F4aXMiOlsxLDAsMF0sInlfYXhpcyI6WzAsMSwwXSwiel9heGlzIjpbMCwwLDFdfSwic2RrX21ldGFkYXRhIjp7fSwic2VtYW50aWNfYmluZGluZ19pZHMiOltdLCJzb3VyY2UiOnsia2luZCI6InVuYm91bmQifX0seyJjaGlsZF9lbnRpdHlfaWRzIjpbXSwiY29ubmVjdG9yX2JpbmRpbmdfc3RhdHVzIjoib3duZXJfbm90X3BhcnQiLCJlbnRpdHlfaWQiOiJlbnRpdHkvdmVydGV4LzIiLCJldmFsdWF0ZWRfdGFncyI6W10sImdlb21ldHJ5Ijp7InBvc2l0aW9uIjpbMCwwLDEwMDBdLCJ0eXBlIjoicG9pbnQifSwia2luZCI6InZlcnRleCIsInBhcmVudF9lbnRpdHlfaWRzIjpbImVudGl0eS9lZGdlLzAiLCJlbnRpdHkvZWRnZS80IiwiZW50aXR5L2VkZ2UvNSJdLCJwcm9wZXJ0aWVzIjp7ImJvdW5kcyI6eyJtYXgiOlswLDAsMTAwMF0sIm1pbiI6WzAsMCwxMDAwXX0sInBvc2l0aW9uIjpbMCwwLDEwMDBdLCJxdWFsaXR5Ijoia2VybmVsX2V2YWx1YXRlZCJ9LCJyZW5kZXJfc3RhdHVzIjoicmVuZGVyZWQiLCJzZGtfY29ubmVjdG9yX2ZyYW1lIjp7Im9yaWdpbiI6WzAsMCwxMDAwXSwieF9heGlzIjpbMSwwLDBdLCJ5X2F4aXMiOlswLDEsMF0sInpfYXhpcyI6WzAsMCwxXX0sInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImtpbmQiOiJ1bmJvdW5kIn19LHsiY2hpbGRfZW50aXR5X2lkcyI6W10sImNvbm5lY3Rvcl9iaW5kaW5nX3N0YXR1cyI6Im93bmVyX25vdF9wYXJ0IiwiZW50aXR5X2lkIjoiZW50aXR5L3ZlcnRleC8zIiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJwb3NpdGlvbiI6WzAsLTEwMDAsMF0sInR5cGUiOiJwb2ludCJ9LCJraW5kIjoidmVydGV4IiwicGFyZW50X2VudGl0eV9pZHMiOlsiZW50aXR5L2VkZ2UvMCIsImVudGl0eS9lZGdlLzEiLCJlbnRpdHkvZWRnZS8yIl0sInByb3BlcnRpZXMiOnsiYm91bmRzIjp7Im1heCI6WzAsLTEwMDAsMF0sIm1pbiI6WzAsLTEwMDAsMF19LCJwb3NpdGlvbiI6WzAsLTEwMDAsMF0sInF1YWxpdHkiOiJrZXJuZWxfZXZhbHVhdGVkIn0sInJlbmRlcl9zdGF0dXMiOiJyZW5kZXJlZCIsInNka19jb25uZWN0b3JfZnJhbWUiOnsib3JpZ2luIjpbMCwtMTAwMCwwXSwieF9heGlzIjpbMSwwLDBdLCJ5X2F4aXMiOlswLDEsMF0sInpfYXhpcyI6WzAsMCwxXX0sInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImtpbmQiOiJ1bmJvdW5kIn19XSwiZmFjZV9ncm91cHMiOlt7ImVudGl0eV9pZCI6ImVudGl0eS9mYWNlLzAiLCJmaXJzdF9pbmRleCI6MCwiZ3JvdXBfaWQiOjAsImluZGV4X2NvdW50IjozLCJtZXNoX2luZGV4IjowLCJwcmltaXRpdmVfaW5kZXgiOjB9LHsiZW50aXR5X2lkIjoiZW50aXR5L2ZhY2UvMSIsImZpcnN0X2luZGV4IjozLCJncm91cF9pZCI6MSwiaW5kZXhfY291bnQiOjMsIm1lc2hfaW5kZXgiOjAsInByaW1pdGl2ZV9pbmRleCI6MH0seyJlbnRpdHlfaWQiOiJlbnRpdHkvZmFjZS8yIiwiZmlyc3RfaW5kZXgiOjYsImdyb3VwX2lkIjoyLCJpbmRleF9jb3VudCI6MywibWVzaF9pbmRleCI6MCwicHJpbWl0aXZlX2luZGV4IjowfSx7ImVudGl0eV9pZCI6ImVudGl0eS9mYWNlLzMiLCJmaXJzdF9pbmRleCI6OSwiZ3JvdXBfaWQiOjMsImluZGV4X2NvdW50IjozLCJtZXNoX2luZGV4IjowLCJwcmltaXRpdmVfaW5kZXgiOjB9XSwiZ2VvbWV0cnlfYXNzZXRfaWQiOiJzaGEyNTY6MjAwNTIzNDE0ZGUyODFhOGExNTUwODZhNTRjZDBmODgwZjY0MDQ4NTc2NWE0OGNjZGQ4ZWMyMzBmNjdkZDE3YyIsImdlb21ldHJ5X2VuZ2luZSI6eyJuYW1lIjoiT3BlbkNhc2NhZGUiLCJwcm9maWxlIjoib2NwLWV2YWx1YXRlZC1wcm9wZXJ0aWVzLTEiLCJ2ZXJzaW9uIjoiZml4dHVyZSJ9LCJzY2hlbWFfdmVyc2lvbiI6IjIuMCJ9", + "valid": false + } + ], + "format_version": "1.0", + "glb_cases": [ + { + "error": null, + "expected_kind": "triangle", + "kind": "triangle", + "name": "valid_triangle", + "payload_base64": "Z2xURgIAAAD8AwAAqAIAAEpTT057ImFjY2Vzc29ycyI6W3siYnVmZmVyVmlldyI6MCwiY29tcG9uZW50VHlwZSI6NTEyNiwiY291bnQiOjEyLCJtYXgiOlsxLDEsMV0sIm1pbiI6WzAsMCwwXSwidHlwZSI6IlZFQzMifSx7ImJ1ZmZlclZpZXciOjEsImNvbXBvbmVudFR5cGUiOjUxMjYsImNvdW50IjoxMiwidHlwZSI6IlZFQzMifSx7ImJ1ZmZlclZpZXciOjIsImNvbXBvbmVudFR5cGUiOjUxMjMsImNvdW50IjoxMiwidHlwZSI6IlNDQUxBUiJ9XSwiYXNzZXQiOnsiZ2VuZXJhdG9yIjoiU2ltcGxlQ0FEIFNjZW5lIEdMQiBQcm9maWxlIDEiLCJ2ZXJzaW9uIjoiMi4wIn0sImJ1ZmZlclZpZXdzIjpbeyJidWZmZXIiOjAsImJ5dGVMZW5ndGgiOjE0NCwiYnl0ZU9mZnNldCI6MCwidGFyZ2V0IjozNDk2Mn0seyJidWZmZXIiOjAsImJ5dGVMZW5ndGgiOjE0NCwiYnl0ZU9mZnNldCI6MTQ0LCJ0YXJnZXQiOjM0OTYyfSx7ImJ1ZmZlciI6MCwiYnl0ZUxlbmd0aCI6MjQsImJ5dGVPZmZzZXQiOjI4OCwidGFyZ2V0IjozNDk2M31dLCJidWZmZXJzIjpbeyJieXRlTGVuZ3RoIjozMTJ9XSwibWVzaGVzIjpbeyJwcmltaXRpdmVzIjpbeyJhdHRyaWJ1dGVzIjp7Ik5PUk1BTCI6MSwiUE9TSVRJT04iOjB9LCJpbmRpY2VzIjoyLCJtb2RlIjo0fV19XSwibm9kZXMiOlt7Im1lc2giOjB9XSwic2NlbmUiOjAsInNjZW5lcyI6W3sibm9kZXMiOlswXX1dfTgBAABCSU4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIA/AAAAAAAAgD8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIA/AACAPwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgD8AAAAAAACAPwAAAAAAAAAAAAAAAAAAAAAAAIA/AAAAAAAAgD8AAAAAAACAPwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/Os0TPzrNEz86zRM/Os0TPzrNEz86zRM/Os0TPzrNEz86zRM/AAABAAIAAwAFAAQABgAHAAgACQALAAoA", + "valid": true + }, + { + "error": null, + "expected_kind": "line", + "kind": "line", + "name": "valid_line", + "payload_base64": "Z2xURgIAAADkAgAAIAIAAEpTT057ImFjY2Vzc29ycyI6W3siYnVmZmVyVmlldyI6MCwiY29tcG9uZW50VHlwZSI6NTEyNiwiY291bnQiOjEyLCJtYXgiOlsxLDEsMV0sIm1pbiI6WzAsMCwwXSwidHlwZSI6IlZFQzMifSx7ImJ1ZmZlclZpZXciOjEsImNvbXBvbmVudFR5cGUiOjUxMjMsImNvdW50IjoxMiwidHlwZSI6IlNDQUxBUiJ9XSwiYXNzZXQiOnsiZ2VuZXJhdG9yIjoiU2ltcGxlQ0FEIFNjZW5lIEdMQiBQcm9maWxlIDEiLCJ2ZXJzaW9uIjoiMi4wIn0sImJ1ZmZlclZpZXdzIjpbeyJidWZmZXIiOjAsImJ5dGVMZW5ndGgiOjE0NCwiYnl0ZU9mZnNldCI6MCwidGFyZ2V0IjozNDk2Mn0seyJidWZmZXIiOjAsImJ5dGVMZW5ndGgiOjI0LCJieXRlT2Zmc2V0IjoxNDQsInRhcmdldCI6MzQ5NjN9XSwiYnVmZmVycyI6W3siYnl0ZUxlbmd0aCI6MTY4fV0sIm1lc2hlcyI6W3sicHJpbWl0aXZlcyI6W3siYXR0cmlidXRlcyI6eyJQT1NJVElPTiI6MH0sImluZGljZXMiOjEsIm1vZGUiOjF9XX1dLCJub2RlcyI6W3sibWVzaCI6MH1dLCJzY2VuZSI6MCwic2NlbmVzIjpbeyJub2RlcyI6WzBdfV19qAAAAEJJTgAAAAAAAAAAAAAAgD8AAAAAAACAPwAAAAAAAAAAAAAAAAAAgD8AAIA/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgD8AAAAAAAAAAAAAAAAAAIA/AAAAAAAAAAAAAAAAAACAPwAAAAAAAIA/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAPwAAAAAAAAEAAgADAAQABQAGAAcACAAJAAoACwA=", + "valid": true + }, + { + "error": null, + "expected_kind": "line", + "kind": "line", + "name": "valid_float32_finite_extremes", + "payload_base64": "Z2xURgIAAADgAgAAmAIAAEpTT057ImFjY2Vzc29ycyI6W3siYnVmZmVyVmlldyI6MCwiY29tcG9uZW50VHlwZSI6NTEyNiwiY291bnQiOjMsIm1heCI6WzEuMTc1NDk0MzUwODIyMjg3NWUtMzgsMS4xNzU0OTQyMTA2OTI0NDExZS0zOCwzLjQwMjgyMzQ2NjM4NTI4ODZlKzM4XSwibWluIjpbLTMuNDAyODIzNDY2Mzg1Mjg4NmUrMzgsLTEuNDAxMjk4NDY0MzI0ODE3ZS00NSwxLjQwMTI5ODQ2NDMyNDgxN2UtNDVdLCJ0eXBlIjoiVkVDMyJ9LHsiYnVmZmVyVmlldyI6MSwiY29tcG9uZW50VHlwZSI6NTEyMywiY291bnQiOjQsInR5cGUiOiJTQ0FMQVIifV0sImFzc2V0Ijp7ImdlbmVyYXRvciI6IlNpbXBsZUNBRCBTY2VuZSBHTEIgUHJvZmlsZSAxIiwidmVyc2lvbiI6IjIuMCJ9LCJidWZmZXJWaWV3cyI6W3siYnVmZmVyIjowLCJieXRlTGVuZ3RoIjozNiwiYnl0ZU9mZnNldCI6MCwidGFyZ2V0IjozNDk2Mn0seyJidWZmZXIiOjAsImJ5dGVMZW5ndGgiOjgsImJ5dGVPZmZzZXQiOjM2LCJ0YXJnZXQiOjM0OTYzfV0sImJ1ZmZlcnMiOlt7ImJ5dGVMZW5ndGgiOjQ0fV0sIm1lc2hlcyI6W3sicHJpbWl0aXZlcyI6W3siYXR0cmlidXRlcyI6eyJQT1NJVElPTiI6MH0sImluZGljZXMiOjEsIm1vZGUiOjF9XX1dLCJub2RlcyI6W3sibWVzaCI6MH1dLCJzY2VuZSI6MCwic2NlbmVzIjpbeyJub2RlcyI6WzBdfV19LAAAAEJJTgD//3//AQAAgP//fwAAAICAAAAAAAEAAAAAAIAA//9/AP//f38AAAEAAQACAA==", + "valid": true + }, + { + "error": "POSITION min must be a three-number array", + "expected_kind": "triangle", + "kind": null, + "name": "string_accessor_bounds", + "payload_base64": "Z2xURgIAAAAABAAArAIAAEpTT057ImFjY2Vzc29ycyI6W3siYnVmZmVyVmlldyI6MCwiY29tcG9uZW50VHlwZSI6NTEyNiwiY291bnQiOjEyLCJtYXgiOlsxLDEsMV0sIm1pbiI6WyIwIiwwLDBdLCJ0eXBlIjoiVkVDMyJ9LHsiYnVmZmVyVmlldyI6MSwiY29tcG9uZW50VHlwZSI6NTEyNiwiY291bnQiOjEyLCJ0eXBlIjoiVkVDMyJ9LHsiYnVmZmVyVmlldyI6MiwiY29tcG9uZW50VHlwZSI6NTEyMywiY291bnQiOjEyLCJ0eXBlIjoiU0NBTEFSIn1dLCJhc3NldCI6eyJnZW5lcmF0b3IiOiJTaW1wbGVDQUQgU2NlbmUgR0xCIFByb2ZpbGUgMSIsInZlcnNpb24iOiIyLjAifSwiYnVmZmVyVmlld3MiOlt7ImJ1ZmZlciI6MCwiYnl0ZUxlbmd0aCI6MTQ0LCJieXRlT2Zmc2V0IjowLCJ0YXJnZXQiOjM0OTYyfSx7ImJ1ZmZlciI6MCwiYnl0ZUxlbmd0aCI6MTQ0LCJieXRlT2Zmc2V0IjoxNDQsInRhcmdldCI6MzQ5NjJ9LHsiYnVmZmVyIjowLCJieXRlTGVuZ3RoIjoyNCwiYnl0ZU9mZnNldCI6Mjg4LCJ0YXJnZXQiOjM0OTYzfV0sImJ1ZmZlcnMiOlt7ImJ5dGVMZW5ndGgiOjMxMn1dLCJtZXNoZXMiOlt7InByaW1pdGl2ZXMiOlt7ImF0dHJpYnV0ZXMiOnsiTk9STUFMIjoxLCJQT1NJVElPTiI6MH0sImluZGljZXMiOjIsIm1vZGUiOjR9XX1dLCJub2RlcyI6W3sibWVzaCI6MH1dLCJzY2VuZSI6MCwic2NlbmVzIjpbeyJub2RlcyI6WzBdfV19ICA4AQAAQklOAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAPwAAAAAAAIA/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAPwAAgD8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIA/AAAAAAAAgD8AAAAAAAAAAAAAAAAAAAAAAACAPwAAAAAAAIA/AAAAAAAAgD8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvzrNEz86zRM/Os0TPzrNEz86zRM/Os0TPzrNEz86zRM/Os0TPwAAAQACAAMABQAEAAYABwAIAAkACwAKAA==", + "valid": false + }, + { + "error": "GLB default scene must be a non-negative safe integer", + "expected_kind": "triangle", + "kind": null, + "name": "boolean_scene_index", + "payload_base64": "Z2xURgIAAAAABAAArAIAAEpTT057ImFjY2Vzc29ycyI6W3siYnVmZmVyVmlldyI6MCwiY29tcG9uZW50VHlwZSI6NTEyNiwiY291bnQiOjEyLCJtYXgiOlsxLDEsMV0sIm1pbiI6WzAsMCwwXSwidHlwZSI6IlZFQzMifSx7ImJ1ZmZlclZpZXciOjEsImNvbXBvbmVudFR5cGUiOjUxMjYsImNvdW50IjoxMiwidHlwZSI6IlZFQzMifSx7ImJ1ZmZlclZpZXciOjIsImNvbXBvbmVudFR5cGUiOjUxMjMsImNvdW50IjoxMiwidHlwZSI6IlNDQUxBUiJ9XSwiYXNzZXQiOnsiZ2VuZXJhdG9yIjoiU2ltcGxlQ0FEIFNjZW5lIEdMQiBQcm9maWxlIDEiLCJ2ZXJzaW9uIjoiMi4wIn0sImJ1ZmZlclZpZXdzIjpbeyJidWZmZXIiOjAsImJ5dGVMZW5ndGgiOjE0NCwiYnl0ZU9mZnNldCI6MCwidGFyZ2V0IjozNDk2Mn0seyJidWZmZXIiOjAsImJ5dGVMZW5ndGgiOjE0NCwiYnl0ZU9mZnNldCI6MTQ0LCJ0YXJnZXQiOjM0OTYyfSx7ImJ1ZmZlciI6MCwiYnl0ZUxlbmd0aCI6MjQsImJ5dGVPZmZzZXQiOjI4OCwidGFyZ2V0IjozNDk2M31dLCJidWZmZXJzIjpbeyJieXRlTGVuZ3RoIjozMTJ9XSwibWVzaGVzIjpbeyJwcmltaXRpdmVzIjpbeyJhdHRyaWJ1dGVzIjp7Ik5PUk1BTCI6MSwiUE9TSVRJT04iOjB9LCJpbmRpY2VzIjoyLCJtb2RlIjo0fV19XSwibm9kZXMiOlt7Im1lc2giOjB9XSwic2NlbmUiOmZhbHNlLCJzY2VuZXMiOlt7Im5vZGVzIjpbMF19XX04AQAAQklOAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAPwAAAAAAAIA/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAPwAAgD8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIA/AAAAAAAAgD8AAAAAAAAAAAAAAAAAAAAAAACAPwAAAAAAAIA/AAAAAAAAgD8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvzrNEz86zRM/Os0TPzrNEz86zRM/Os0TPzrNEz86zRM/Os0TPwAAAQACAAMABQAEAAYABwAIAAkACwAKAA==", + "valid": false + }, + { + "error": "GLB root fields do not match closed GLB profile", + "expected_kind": "triangle", + "kind": null, + "name": "root_extensions_used", + "payload_base64": "Z2xURgIAAAAQBAAAvAIAAEpTT057ImFjY2Vzc29ycyI6W3siYnVmZmVyVmlldyI6MCwiY29tcG9uZW50VHlwZSI6NTEyNiwiY291bnQiOjEyLCJtYXgiOlsxLDEsMV0sIm1pbiI6WzAsMCwwXSwidHlwZSI6IlZFQzMifSx7ImJ1ZmZlclZpZXciOjEsImNvbXBvbmVudFR5cGUiOjUxMjYsImNvdW50IjoxMiwidHlwZSI6IlZFQzMifSx7ImJ1ZmZlclZpZXciOjIsImNvbXBvbmVudFR5cGUiOjUxMjMsImNvdW50IjoxMiwidHlwZSI6IlNDQUxBUiJ9XSwiYXNzZXQiOnsiZ2VuZXJhdG9yIjoiU2ltcGxlQ0FEIFNjZW5lIEdMQiBQcm9maWxlIDEiLCJ2ZXJzaW9uIjoiMi4wIn0sImJ1ZmZlclZpZXdzIjpbeyJidWZmZXIiOjAsImJ5dGVMZW5ndGgiOjE0NCwiYnl0ZU9mZnNldCI6MCwidGFyZ2V0IjozNDk2Mn0seyJidWZmZXIiOjAsImJ5dGVMZW5ndGgiOjE0NCwiYnl0ZU9mZnNldCI6MTQ0LCJ0YXJnZXQiOjM0OTYyfSx7ImJ1ZmZlciI6MCwiYnl0ZUxlbmd0aCI6MjQsImJ5dGVPZmZzZXQiOjI4OCwidGFyZ2V0IjozNDk2M31dLCJidWZmZXJzIjpbeyJieXRlTGVuZ3RoIjozMTJ9XSwiZXh0ZW5zaW9uc1VzZWQiOltdLCJtZXNoZXMiOlt7InByaW1pdGl2ZXMiOlt7ImF0dHJpYnV0ZXMiOnsiTk9STUFMIjoxLCJQT1NJVElPTiI6MH0sImluZGljZXMiOjIsIm1vZGUiOjR9XX1dLCJub2RlcyI6W3sibWVzaCI6MH1dLCJzY2VuZSI6MCwic2NlbmVzIjpbeyJub2RlcyI6WzBdfV19OAEAAEJJTgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgD8AAAAAAACAPwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgD8AAIA/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAPwAAAAAAAIA/AAAAAAAAAAAAAAAAAAAAAAAAgD8AAAAAAACAPwAAAAAAAIA/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL86zRM/Os0TPzrNEz86zRM/Os0TPzrNEz86zRM/Os0TPzrNEz8AAAEAAgADAAUABAAGAAcACAAJAAsACgA=", + "valid": false + }, + { + "error": "GLB asset record does not match Scene profile", + "expected_kind": "triangle", + "kind": null, + "name": "asset_extras", + "payload_base64": "Z2xURgIAAAAIBAAAtAIAAEpTT057ImFjY2Vzc29ycyI6W3siYnVmZmVyVmlldyI6MCwiY29tcG9uZW50VHlwZSI6NTEyNiwiY291bnQiOjEyLCJtYXgiOlsxLDEsMV0sIm1pbiI6WzAsMCwwXSwidHlwZSI6IlZFQzMifSx7ImJ1ZmZlclZpZXciOjEsImNvbXBvbmVudFR5cGUiOjUxMjYsImNvdW50IjoxMiwidHlwZSI6IlZFQzMifSx7ImJ1ZmZlclZpZXciOjIsImNvbXBvbmVudFR5cGUiOjUxMjMsImNvdW50IjoxMiwidHlwZSI6IlNDQUxBUiJ9XSwiYXNzZXQiOnsiZXh0cmFzIjp7fSwiZ2VuZXJhdG9yIjoiU2ltcGxlQ0FEIFNjZW5lIEdMQiBQcm9maWxlIDEiLCJ2ZXJzaW9uIjoiMi4wIn0sImJ1ZmZlclZpZXdzIjpbeyJidWZmZXIiOjAsImJ5dGVMZW5ndGgiOjE0NCwiYnl0ZU9mZnNldCI6MCwidGFyZ2V0IjozNDk2Mn0seyJidWZmZXIiOjAsImJ5dGVMZW5ndGgiOjE0NCwiYnl0ZU9mZnNldCI6MTQ0LCJ0YXJnZXQiOjM0OTYyfSx7ImJ1ZmZlciI6MCwiYnl0ZUxlbmd0aCI6MjQsImJ5dGVPZmZzZXQiOjI4OCwidGFyZ2V0IjozNDk2M31dLCJidWZmZXJzIjpbeyJieXRlTGVuZ3RoIjozMTJ9XSwibWVzaGVzIjpbeyJwcmltaXRpdmVzIjpbeyJhdHRyaWJ1dGVzIjp7Ik5PUk1BTCI6MSwiUE9TSVRJT04iOjB9LCJpbmRpY2VzIjoyLCJtb2RlIjo0fV19XSwibm9kZXMiOlt7Im1lc2giOjB9XSwic2NlbmUiOjAsInNjZW5lcyI6W3sibm9kZXMiOlswXX1dfTgBAABCSU4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIA/AAAAAAAAgD8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIA/AACAPwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgD8AAAAAAACAPwAAAAAAAAAAAAAAAAAAAAAAAIA/AAAAAAAAgD8AAAAAAACAPwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/Os0TPzrNEz86zRM/Os0TPzrNEz86zRM/Os0TPzrNEz86zRM/AAABAAIAAwAFAAQABgAHAAgACQALAAoA", + "valid": false + }, + { + "error": "GLB node fields do not match closed GLB profile", + "expected_kind": "triangle", + "kind": null, + "name": "node_name", + "payload_base64": "Z2xURgIAAAAMBAAAuAIAAEpTT057ImFjY2Vzc29ycyI6W3siYnVmZmVyVmlldyI6MCwiY29tcG9uZW50VHlwZSI6NTEyNiwiY291bnQiOjEyLCJtYXgiOlsxLDEsMV0sIm1pbiI6WzAsMCwwXSwidHlwZSI6IlZFQzMifSx7ImJ1ZmZlclZpZXciOjEsImNvbXBvbmVudFR5cGUiOjUxMjYsImNvdW50IjoxMiwidHlwZSI6IlZFQzMifSx7ImJ1ZmZlclZpZXciOjIsImNvbXBvbmVudFR5cGUiOjUxMjMsImNvdW50IjoxMiwidHlwZSI6IlNDQUxBUiJ9XSwiYXNzZXQiOnsiZ2VuZXJhdG9yIjoiU2ltcGxlQ0FEIFNjZW5lIEdMQiBQcm9maWxlIDEiLCJ2ZXJzaW9uIjoiMi4wIn0sImJ1ZmZlclZpZXdzIjpbeyJidWZmZXIiOjAsImJ5dGVMZW5ndGgiOjE0NCwiYnl0ZU9mZnNldCI6MCwidGFyZ2V0IjozNDk2Mn0seyJidWZmZXIiOjAsImJ5dGVMZW5ndGgiOjE0NCwiYnl0ZU9mZnNldCI6MTQ0LCJ0YXJnZXQiOjM0OTYyfSx7ImJ1ZmZlciI6MCwiYnl0ZUxlbmd0aCI6MjQsImJ5dGVPZmZzZXQiOjI4OCwidGFyZ2V0IjozNDk2M31dLCJidWZmZXJzIjpbeyJieXRlTGVuZ3RoIjozMTJ9XSwibWVzaGVzIjpbeyJwcmltaXRpdmVzIjpbeyJhdHRyaWJ1dGVzIjp7Ik5PUk1BTCI6MSwiUE9TSVRJT04iOjB9LCJpbmRpY2VzIjoyLCJtb2RlIjo0fV19XSwibm9kZXMiOlt7Im1lc2giOjAsIm5hbWUiOiJub2RlIn1dLCJzY2VuZSI6MCwic2NlbmVzIjpbeyJub2RlcyI6WzBdfV19ICA4AQAAQklOAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAPwAAAAAAAIA/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAPwAAgD8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIA/AAAAAAAAgD8AAAAAAAAAAAAAAAAAAAAAAACAPwAAAAAAAIA/AAAAAAAAgD8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvzrNEz86zRM/Os0TPzrNEz86zRM/Os0TPzrNEz86zRM/Os0TPwAAAQACAAMABQAEAAYABwAIAAkACwAKAA==", + "valid": false + }, + { + "error": "GLB scene fields do not match closed GLB profile", + "expected_kind": "triangle", + "kind": null, + "name": "scene_name", + "payload_base64": "Z2xURgIAAAAMBAAAuAIAAEpTT057ImFjY2Vzc29ycyI6W3siYnVmZmVyVmlldyI6MCwiY29tcG9uZW50VHlwZSI6NTEyNiwiY291bnQiOjEyLCJtYXgiOlsxLDEsMV0sIm1pbiI6WzAsMCwwXSwidHlwZSI6IlZFQzMifSx7ImJ1ZmZlclZpZXciOjEsImNvbXBvbmVudFR5cGUiOjUxMjYsImNvdW50IjoxMiwidHlwZSI6IlZFQzMifSx7ImJ1ZmZlclZpZXciOjIsImNvbXBvbmVudFR5cGUiOjUxMjMsImNvdW50IjoxMiwidHlwZSI6IlNDQUxBUiJ9XSwiYXNzZXQiOnsiZ2VuZXJhdG9yIjoiU2ltcGxlQ0FEIFNjZW5lIEdMQiBQcm9maWxlIDEiLCJ2ZXJzaW9uIjoiMi4wIn0sImJ1ZmZlclZpZXdzIjpbeyJidWZmZXIiOjAsImJ5dGVMZW5ndGgiOjE0NCwiYnl0ZU9mZnNldCI6MCwidGFyZ2V0IjozNDk2Mn0seyJidWZmZXIiOjAsImJ5dGVMZW5ndGgiOjE0NCwiYnl0ZU9mZnNldCI6MTQ0LCJ0YXJnZXQiOjM0OTYyfSx7ImJ1ZmZlciI6MCwiYnl0ZUxlbmd0aCI6MjQsImJ5dGVPZmZzZXQiOjI4OCwidGFyZ2V0IjozNDk2M31dLCJidWZmZXJzIjpbeyJieXRlTGVuZ3RoIjozMTJ9XSwibWVzaGVzIjpbeyJwcmltaXRpdmVzIjpbeyJhdHRyaWJ1dGVzIjp7Ik5PUk1BTCI6MSwiUE9TSVRJT04iOjB9LCJpbmRpY2VzIjoyLCJtb2RlIjo0fV19XSwibm9kZXMiOlt7Im1lc2giOjB9XSwic2NlbmUiOjAsInNjZW5lcyI6W3sibmFtZSI6InNjZW5lIiwibm9kZXMiOlswXX1dfSA4AQAAQklOAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAPwAAAAAAAIA/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAPwAAgD8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIA/AAAAAAAAgD8AAAAAAAAAAAAAAAAAAAAAAACAPwAAAAAAAIA/AAAAAAAAgD8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvzrNEz86zRM/Os0TPzrNEz86zRM/Os0TPzrNEz86zRM/Os0TPwAAAQACAAMABQAEAAYABwAIAAkACwAKAA==", + "valid": false + }, + { + "error": "GLB mesh fields do not match closed GLB profile", + "expected_kind": "triangle", + "kind": null, + "name": "mesh_name", + "payload_base64": "Z2xURgIAAAAMBAAAuAIAAEpTT057ImFjY2Vzc29ycyI6W3siYnVmZmVyVmlldyI6MCwiY29tcG9uZW50VHlwZSI6NTEyNiwiY291bnQiOjEyLCJtYXgiOlsxLDEsMV0sIm1pbiI6WzAsMCwwXSwidHlwZSI6IlZFQzMifSx7ImJ1ZmZlclZpZXciOjEsImNvbXBvbmVudFR5cGUiOjUxMjYsImNvdW50IjoxMiwidHlwZSI6IlZFQzMifSx7ImJ1ZmZlclZpZXciOjIsImNvbXBvbmVudFR5cGUiOjUxMjMsImNvdW50IjoxMiwidHlwZSI6IlNDQUxBUiJ9XSwiYXNzZXQiOnsiZ2VuZXJhdG9yIjoiU2ltcGxlQ0FEIFNjZW5lIEdMQiBQcm9maWxlIDEiLCJ2ZXJzaW9uIjoiMi4wIn0sImJ1ZmZlclZpZXdzIjpbeyJidWZmZXIiOjAsImJ5dGVMZW5ndGgiOjE0NCwiYnl0ZU9mZnNldCI6MCwidGFyZ2V0IjozNDk2Mn0seyJidWZmZXIiOjAsImJ5dGVMZW5ndGgiOjE0NCwiYnl0ZU9mZnNldCI6MTQ0LCJ0YXJnZXQiOjM0OTYyfSx7ImJ1ZmZlciI6MCwiYnl0ZUxlbmd0aCI6MjQsImJ5dGVPZmZzZXQiOjI4OCwidGFyZ2V0IjozNDk2M31dLCJidWZmZXJzIjpbeyJieXRlTGVuZ3RoIjozMTJ9XSwibWVzaGVzIjpbeyJuYW1lIjoibWVzaCIsInByaW1pdGl2ZXMiOlt7ImF0dHJpYnV0ZXMiOnsiTk9STUFMIjoxLCJQT1NJVElPTiI6MH0sImluZGljZXMiOjIsIm1vZGUiOjR9XX1dLCJub2RlcyI6W3sibWVzaCI6MH1dLCJzY2VuZSI6MCwic2NlbmVzIjpbeyJub2RlcyI6WzBdfV19ICA4AQAAQklOAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAPwAAAAAAAIA/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAPwAAgD8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIA/AAAAAAAAgD8AAAAAAAAAAAAAAAAAAAAAAACAPwAAAAAAAIA/AAAAAAAAgD8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvzrNEz86zRM/Os0TPzrNEz86zRM/Os0TPzrNEz86zRM/Os0TPwAAAQACAAMABQAEAAYABwAIAAkACwAKAA==", + "valid": false + }, + { + "error": "GLB primitive fields do not match closed GLB profile", + "expected_kind": "triangle", + "kind": null, + "name": "primitive_material", + "payload_base64": "Z2xURgIAAAAMBAAAuAIAAEpTT057ImFjY2Vzc29ycyI6W3siYnVmZmVyVmlldyI6MCwiY29tcG9uZW50VHlwZSI6NTEyNiwiY291bnQiOjEyLCJtYXgiOlsxLDEsMV0sIm1pbiI6WzAsMCwwXSwidHlwZSI6IlZFQzMifSx7ImJ1ZmZlclZpZXciOjEsImNvbXBvbmVudFR5cGUiOjUxMjYsImNvdW50IjoxMiwidHlwZSI6IlZFQzMifSx7ImJ1ZmZlclZpZXciOjIsImNvbXBvbmVudFR5cGUiOjUxMjMsImNvdW50IjoxMiwidHlwZSI6IlNDQUxBUiJ9XSwiYXNzZXQiOnsiZ2VuZXJhdG9yIjoiU2ltcGxlQ0FEIFNjZW5lIEdMQiBQcm9maWxlIDEiLCJ2ZXJzaW9uIjoiMi4wIn0sImJ1ZmZlclZpZXdzIjpbeyJidWZmZXIiOjAsImJ5dGVMZW5ndGgiOjE0NCwiYnl0ZU9mZnNldCI6MCwidGFyZ2V0IjozNDk2Mn0seyJidWZmZXIiOjAsImJ5dGVMZW5ndGgiOjE0NCwiYnl0ZU9mZnNldCI6MTQ0LCJ0YXJnZXQiOjM0OTYyfSx7ImJ1ZmZlciI6MCwiYnl0ZUxlbmd0aCI6MjQsImJ5dGVPZmZzZXQiOjI4OCwidGFyZ2V0IjozNDk2M31dLCJidWZmZXJzIjpbeyJieXRlTGVuZ3RoIjozMTJ9XSwibWVzaGVzIjpbeyJwcmltaXRpdmVzIjpbeyJhdHRyaWJ1dGVzIjp7Ik5PUk1BTCI6MSwiUE9TSVRJT04iOjB9LCJpbmRpY2VzIjoyLCJtYXRlcmlhbCI6MCwibW9kZSI6NH1dfV0sIm5vZGVzIjpbeyJtZXNoIjowfV0sInNjZW5lIjowLCJzY2VuZXMiOlt7Im5vZGVzIjpbMF19XX0gICA4AQAAQklOAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAPwAAAAAAAIA/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAPwAAgD8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIA/AAAAAAAAgD8AAAAAAAAAAAAAAAAAAAAAAACAPwAAAAAAAIA/AAAAAAAAgD8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvzrNEz86zRM/Os0TPzrNEz86zRM/Os0TPzrNEz86zRM/Os0TPwAAAQACAAMABQAEAAYABwAIAAkACwAKAA==", + "valid": false + }, + { + "error": "GLB buffer fields do not match closed GLB profile", + "expected_kind": "triangle", + "kind": null, + "name": "buffer_uri", + "payload_base64": "Z2xURgIAAAAQBAAAvAIAAEpTT057ImFjY2Vzc29ycyI6W3siYnVmZmVyVmlldyI6MCwiY29tcG9uZW50VHlwZSI6NTEyNiwiY291bnQiOjEyLCJtYXgiOlsxLDEsMV0sIm1pbiI6WzAsMCwwXSwidHlwZSI6IlZFQzMifSx7ImJ1ZmZlclZpZXciOjEsImNvbXBvbmVudFR5cGUiOjUxMjYsImNvdW50IjoxMiwidHlwZSI6IlZFQzMifSx7ImJ1ZmZlclZpZXciOjIsImNvbXBvbmVudFR5cGUiOjUxMjMsImNvdW50IjoxMiwidHlwZSI6IlNDQUxBUiJ9XSwiYXNzZXQiOnsiZ2VuZXJhdG9yIjoiU2ltcGxlQ0FEIFNjZW5lIEdMQiBQcm9maWxlIDEiLCJ2ZXJzaW9uIjoiMi4wIn0sImJ1ZmZlclZpZXdzIjpbeyJidWZmZXIiOjAsImJ5dGVMZW5ndGgiOjE0NCwiYnl0ZU9mZnNldCI6MCwidGFyZ2V0IjozNDk2Mn0seyJidWZmZXIiOjAsImJ5dGVMZW5ndGgiOjE0NCwiYnl0ZU9mZnNldCI6MTQ0LCJ0YXJnZXQiOjM0OTYyfSx7ImJ1ZmZlciI6MCwiYnl0ZUxlbmd0aCI6MjQsImJ5dGVPZmZzZXQiOjI4OCwidGFyZ2V0IjozNDk2M31dLCJidWZmZXJzIjpbeyJieXRlTGVuZ3RoIjozMTIsInVyaSI6ImJ1ZmZlci5iaW4ifV0sIm1lc2hlcyI6W3sicHJpbWl0aXZlcyI6W3siYXR0cmlidXRlcyI6eyJOT1JNQUwiOjEsIlBPU0lUSU9OIjowfSwiaW5kaWNlcyI6MiwibW9kZSI6NH1dfV0sIm5vZGVzIjpbeyJtZXNoIjowfV0sInNjZW5lIjowLCJzY2VuZXMiOlt7Im5vZGVzIjpbMF19XX0gOAEAAEJJTgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgD8AAAAAAACAPwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgD8AAIA/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAPwAAAAAAAIA/AAAAAAAAAAAAAAAAAAAAAAAAgD8AAAAAAACAPwAAAAAAAIA/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL86zRM/Os0TPzrNEz86zRM/Os0TPzrNEz86zRM/Os0TPzrNEz8AAAEAAgADAAUABAAGAAcACAAJAAsACgA=", + "valid": false + }, + { + "error": "bufferView[0] fields do not match closed GLB profile", + "expected_kind": "triangle", + "kind": null, + "name": "buffer_view_byte_stride", + "payload_base64": "Z2xURgIAAAAMBAAAuAIAAEpTT057ImFjY2Vzc29ycyI6W3siYnVmZmVyVmlldyI6MCwiY29tcG9uZW50VHlwZSI6NTEyNiwiY291bnQiOjEyLCJtYXgiOlsxLDEsMV0sIm1pbiI6WzAsMCwwXSwidHlwZSI6IlZFQzMifSx7ImJ1ZmZlclZpZXciOjEsImNvbXBvbmVudFR5cGUiOjUxMjYsImNvdW50IjoxMiwidHlwZSI6IlZFQzMifSx7ImJ1ZmZlclZpZXciOjIsImNvbXBvbmVudFR5cGUiOjUxMjMsImNvdW50IjoxMiwidHlwZSI6IlNDQUxBUiJ9XSwiYXNzZXQiOnsiZ2VuZXJhdG9yIjoiU2ltcGxlQ0FEIFNjZW5lIEdMQiBQcm9maWxlIDEiLCJ2ZXJzaW9uIjoiMi4wIn0sImJ1ZmZlclZpZXdzIjpbeyJidWZmZXIiOjAsImJ5dGVMZW5ndGgiOjE0NCwiYnl0ZU9mZnNldCI6MCwiYnl0ZVN0cmlkZSI6MTIsInRhcmdldCI6MzQ5NjJ9LHsiYnVmZmVyIjowLCJieXRlTGVuZ3RoIjoxNDQsImJ5dGVPZmZzZXQiOjE0NCwidGFyZ2V0IjozNDk2Mn0seyJidWZmZXIiOjAsImJ5dGVMZW5ndGgiOjI0LCJieXRlT2Zmc2V0IjoyODgsInRhcmdldCI6MzQ5NjN9XSwiYnVmZmVycyI6W3siYnl0ZUxlbmd0aCI6MzEyfV0sIm1lc2hlcyI6W3sicHJpbWl0aXZlcyI6W3siYXR0cmlidXRlcyI6eyJOT1JNQUwiOjEsIlBPU0lUSU9OIjowfSwiaW5kaWNlcyI6MiwibW9kZSI6NH1dfV0sIm5vZGVzIjpbeyJtZXNoIjowfV0sInNjZW5lIjowLCJzY2VuZXMiOlt7Im5vZGVzIjpbMF19XX04AQAAQklOAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAPwAAAAAAAIA/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAPwAAgD8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIA/AAAAAAAAgD8AAAAAAAAAAAAAAAAAAAAAAACAPwAAAAAAAIA/AAAAAAAAgD8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvzrNEz86zRM/Os0TPzrNEz86zRM/Os0TPzrNEz86zRM/Os0TPwAAAQACAAMABQAEAAYABwAIAAkACwAKAA==", + "valid": false + }, + { + "error": "POSITION accessor fields do not match closed GLB profile", + "expected_kind": "triangle", + "kind": null, + "name": "position_accessor_byte_offset", + "payload_base64": "Z2xURgIAAAAMBAAAuAIAAEpTT057ImFjY2Vzc29ycyI6W3siYnVmZmVyVmlldyI6MCwiYnl0ZU9mZnNldCI6MCwiY29tcG9uZW50VHlwZSI6NTEyNiwiY291bnQiOjEyLCJtYXgiOlsxLDEsMV0sIm1pbiI6WzAsMCwwXSwidHlwZSI6IlZFQzMifSx7ImJ1ZmZlclZpZXciOjEsImNvbXBvbmVudFR5cGUiOjUxMjYsImNvdW50IjoxMiwidHlwZSI6IlZFQzMifSx7ImJ1ZmZlclZpZXciOjIsImNvbXBvbmVudFR5cGUiOjUxMjMsImNvdW50IjoxMiwidHlwZSI6IlNDQUxBUiJ9XSwiYXNzZXQiOnsiZ2VuZXJhdG9yIjoiU2ltcGxlQ0FEIFNjZW5lIEdMQiBQcm9maWxlIDEiLCJ2ZXJzaW9uIjoiMi4wIn0sImJ1ZmZlclZpZXdzIjpbeyJidWZmZXIiOjAsImJ5dGVMZW5ndGgiOjE0NCwiYnl0ZU9mZnNldCI6MCwidGFyZ2V0IjozNDk2Mn0seyJidWZmZXIiOjAsImJ5dGVMZW5ndGgiOjE0NCwiYnl0ZU9mZnNldCI6MTQ0LCJ0YXJnZXQiOjM0OTYyfSx7ImJ1ZmZlciI6MCwiYnl0ZUxlbmd0aCI6MjQsImJ5dGVPZmZzZXQiOjI4OCwidGFyZ2V0IjozNDk2M31dLCJidWZmZXJzIjpbeyJieXRlTGVuZ3RoIjozMTJ9XSwibWVzaGVzIjpbeyJwcmltaXRpdmVzIjpbeyJhdHRyaWJ1dGVzIjp7Ik5PUk1BTCI6MSwiUE9TSVRJT04iOjB9LCJpbmRpY2VzIjoyLCJtb2RlIjo0fV19XSwibm9kZXMiOlt7Im1lc2giOjB9XSwic2NlbmUiOjAsInNjZW5lcyI6W3sibm9kZXMiOlswXX1dfSA4AQAAQklOAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAPwAAAAAAAIA/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAPwAAgD8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIA/AAAAAAAAgD8AAAAAAAAAAAAAAAAAAAAAAACAPwAAAAAAAIA/AAAAAAAAgD8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvzrNEz86zRM/Os0TPzrNEz86zRM/Os0TPzrNEz86zRM/Os0TPwAAAQACAAMABQAEAAYABwAIAAkACwAKAA==", + "valid": false + }, + { + "error": "NORMAL accessor fields do not match closed GLB profile", + "expected_kind": "triangle", + "kind": null, + "name": "normal_accessor_normalized", + "payload_base64": "Z2xURgIAAAAQBAAAvAIAAEpTT057ImFjY2Vzc29ycyI6W3siYnVmZmVyVmlldyI6MCwiY29tcG9uZW50VHlwZSI6NTEyNiwiY291bnQiOjEyLCJtYXgiOlsxLDEsMV0sIm1pbiI6WzAsMCwwXSwidHlwZSI6IlZFQzMifSx7ImJ1ZmZlclZpZXciOjEsImNvbXBvbmVudFR5cGUiOjUxMjYsImNvdW50IjoxMiwibm9ybWFsaXplZCI6ZmFsc2UsInR5cGUiOiJWRUMzIn0seyJidWZmZXJWaWV3IjoyLCJjb21wb25lbnRUeXBlIjo1MTIzLCJjb3VudCI6MTIsInR5cGUiOiJTQ0FMQVIifV0sImFzc2V0Ijp7ImdlbmVyYXRvciI6IlNpbXBsZUNBRCBTY2VuZSBHTEIgUHJvZmlsZSAxIiwidmVyc2lvbiI6IjIuMCJ9LCJidWZmZXJWaWV3cyI6W3siYnVmZmVyIjowLCJieXRlTGVuZ3RoIjoxNDQsImJ5dGVPZmZzZXQiOjAsInRhcmdldCI6MzQ5NjJ9LHsiYnVmZmVyIjowLCJieXRlTGVuZ3RoIjoxNDQsImJ5dGVPZmZzZXQiOjE0NCwidGFyZ2V0IjozNDk2Mn0seyJidWZmZXIiOjAsImJ5dGVMZW5ndGgiOjI0LCJieXRlT2Zmc2V0IjoyODgsInRhcmdldCI6MzQ5NjN9XSwiYnVmZmVycyI6W3siYnl0ZUxlbmd0aCI6MzEyfV0sIm1lc2hlcyI6W3sicHJpbWl0aXZlcyI6W3siYXR0cmlidXRlcyI6eyJOT1JNQUwiOjEsIlBPU0lUSU9OIjowfSwiaW5kaWNlcyI6MiwibW9kZSI6NH1dfV0sIm5vZGVzIjpbeyJtZXNoIjowfV0sInNjZW5lIjowLCJzY2VuZXMiOlt7Im5vZGVzIjpbMF19XX0gOAEAAEJJTgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgD8AAAAAAACAPwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgD8AAIA/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAPwAAAAAAAIA/AAAAAAAAAAAAAAAAAAAAAAAAgD8AAAAAAACAPwAAAAAAAIA/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL86zRM/Os0TPzrNEz86zRM/Os0TPzrNEz86zRM/Os0TPzrNEz8AAAEAAgADAAUABAAGAAcACAAJAAsACgA=", + "valid": false + }, + { + "error": "index accessor fields do not match closed GLB profile", + "expected_kind": "triangle", + "kind": null, + "name": "index_accessor_sparse", + "payload_base64": "Z2xURgIAAAAIBAAAtAIAAEpTT057ImFjY2Vzc29ycyI6W3siYnVmZmVyVmlldyI6MCwiY29tcG9uZW50VHlwZSI6NTEyNiwiY291bnQiOjEyLCJtYXgiOlsxLDEsMV0sIm1pbiI6WzAsMCwwXSwidHlwZSI6IlZFQzMifSx7ImJ1ZmZlclZpZXciOjEsImNvbXBvbmVudFR5cGUiOjUxMjYsImNvdW50IjoxMiwidHlwZSI6IlZFQzMifSx7ImJ1ZmZlclZpZXciOjIsImNvbXBvbmVudFR5cGUiOjUxMjMsImNvdW50IjoxMiwic3BhcnNlIjp7fSwidHlwZSI6IlNDQUxBUiJ9XSwiYXNzZXQiOnsiZ2VuZXJhdG9yIjoiU2ltcGxlQ0FEIFNjZW5lIEdMQiBQcm9maWxlIDEiLCJ2ZXJzaW9uIjoiMi4wIn0sImJ1ZmZlclZpZXdzIjpbeyJidWZmZXIiOjAsImJ5dGVMZW5ndGgiOjE0NCwiYnl0ZU9mZnNldCI6MCwidGFyZ2V0IjozNDk2Mn0seyJidWZmZXIiOjAsImJ5dGVMZW5ndGgiOjE0NCwiYnl0ZU9mZnNldCI6MTQ0LCJ0YXJnZXQiOjM0OTYyfSx7ImJ1ZmZlciI6MCwiYnl0ZUxlbmd0aCI6MjQsImJ5dGVPZmZzZXQiOjI4OCwidGFyZ2V0IjozNDk2M31dLCJidWZmZXJzIjpbeyJieXRlTGVuZ3RoIjozMTJ9XSwibWVzaGVzIjpbeyJwcmltaXRpdmVzIjpbeyJhdHRyaWJ1dGVzIjp7Ik5PUk1BTCI6MSwiUE9TSVRJT04iOjB9LCJpbmRpY2VzIjoyLCJtb2RlIjo0fV19XSwibm9kZXMiOlt7Im1lc2giOjB9XSwic2NlbmUiOjAsInNjZW5lcyI6W3sibm9kZXMiOlswXX1dfTgBAABCSU4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIA/AAAAAAAAgD8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIA/AACAPwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgD8AAAAAAACAPwAAAAAAAAAAAAAAAAAAAAAAAIA/AAAAAAAAgD8AAAAAAACAPwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/Os0TPzrNEz86zRM/Os0TPzrNEz86zRM/Os0TPzrNEz86zRM/AAABAAIAAwAFAAQABgAHAAgACQALAAoA", + "valid": false + }, + { + "error": "GLB vertex count is empty or exceeds resource limit", + "expected_kind": "line", + "kind": null, + "name": "empty_position_accessor", + "payload_base64": "Z2xURgIAAADkAgAAIAIAAEpTT057ImFjY2Vzc29ycyI6W3siYnVmZmVyVmlldyI6MCwiY29tcG9uZW50VHlwZSI6NTEyNiwiY291bnQiOjAsIm1heCI6WzEsMSwxXSwibWluIjpbMCwwLDBdLCJ0eXBlIjoiVkVDMyJ9LHsiYnVmZmVyVmlldyI6MSwiY29tcG9uZW50VHlwZSI6NTEyMywiY291bnQiOjEyLCJ0eXBlIjoiU0NBTEFSIn1dLCJhc3NldCI6eyJnZW5lcmF0b3IiOiJTaW1wbGVDQUQgU2NlbmUgR0xCIFByb2ZpbGUgMSIsInZlcnNpb24iOiIyLjAifSwiYnVmZmVyVmlld3MiOlt7ImJ1ZmZlciI6MCwiYnl0ZUxlbmd0aCI6MTQ0LCJieXRlT2Zmc2V0IjowLCJ0YXJnZXQiOjM0OTYyfSx7ImJ1ZmZlciI6MCwiYnl0ZUxlbmd0aCI6MjQsImJ5dGVPZmZzZXQiOjE0NCwidGFyZ2V0IjozNDk2M31dLCJidWZmZXJzIjpbeyJieXRlTGVuZ3RoIjoxNjh9XSwibWVzaGVzIjpbeyJwcmltaXRpdmVzIjpbeyJhdHRyaWJ1dGVzIjp7IlBPU0lUSU9OIjowfSwiaW5kaWNlcyI6MSwibW9kZSI6MX1dfV0sIm5vZGVzIjpbeyJtZXNoIjowfV0sInNjZW5lIjowLCJzY2VuZXMiOlt7Im5vZGVzIjpbMF19XX0gqAAAAEJJTgAAAAAAAAAAAAAAgD8AAAAAAACAPwAAAAAAAAAAAAAAAAAAgD8AAIA/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgD8AAAAAAAAAAAAAAAAAAIA/AAAAAAAAAAAAAAAAAACAPwAAAAAAAIA/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAPwAAAAAAAAEAAgADAAQABQAGAAcACAAJAAoACwA=", + "valid": false + }, + { + "error": "GLB index count is invalid or exceeds resource limit", + "expected_kind": "line", + "kind": null, + "name": "empty_index_accessor", + "payload_base64": "Z2xURgIAAADkAgAAIAIAAEpTT057ImFjY2Vzc29ycyI6W3siYnVmZmVyVmlldyI6MCwiY29tcG9uZW50VHlwZSI6NTEyNiwiY291bnQiOjEyLCJtYXgiOlsxLDEsMV0sIm1pbiI6WzAsMCwwXSwidHlwZSI6IlZFQzMifSx7ImJ1ZmZlclZpZXciOjEsImNvbXBvbmVudFR5cGUiOjUxMjMsImNvdW50IjowLCJ0eXBlIjoiU0NBTEFSIn1dLCJhc3NldCI6eyJnZW5lcmF0b3IiOiJTaW1wbGVDQUQgU2NlbmUgR0xCIFByb2ZpbGUgMSIsInZlcnNpb24iOiIyLjAifSwiYnVmZmVyVmlld3MiOlt7ImJ1ZmZlciI6MCwiYnl0ZUxlbmd0aCI6MTQ0LCJieXRlT2Zmc2V0IjowLCJ0YXJnZXQiOjM0OTYyfSx7ImJ1ZmZlciI6MCwiYnl0ZUxlbmd0aCI6MjQsImJ5dGVPZmZzZXQiOjE0NCwidGFyZ2V0IjozNDk2M31dLCJidWZmZXJzIjpbeyJieXRlTGVuZ3RoIjoxNjh9XSwibWVzaGVzIjpbeyJwcmltaXRpdmVzIjpbeyJhdHRyaWJ1dGVzIjp7IlBPU0lUSU9OIjowfSwiaW5kaWNlcyI6MSwibW9kZSI6MX1dfV0sIm5vZGVzIjpbeyJtZXNoIjowfV0sInNjZW5lIjowLCJzY2VuZXMiOlt7Im5vZGVzIjpbMF19XX0gqAAAAEJJTgAAAAAAAAAAAAAAgD8AAAAAAACAPwAAAAAAAAAAAAAAAAAAgD8AAIA/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgD8AAAAAAAAAAAAAAAAAAIA/AAAAAAAAAAAAAAAAAACAPwAAAAAAAIA/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAPwAAAAAAAAEAAgADAAQABQAGAAcACAAJAAoACwA=", + "valid": false + }, + { + "error": "GLB primitive does not match triangle or line profile", + "expected_kind": "triangle", + "kind": null, + "name": "wrong_triangle_mode", + "payload_base64": "Z2xURgIAAAD8AwAAqAIAAEpTT057ImFjY2Vzc29ycyI6W3siYnVmZmVyVmlldyI6MCwiY29tcG9uZW50VHlwZSI6NTEyNiwiY291bnQiOjEyLCJtYXgiOlsxLDEsMV0sIm1pbiI6WzAsMCwwXSwidHlwZSI6IlZFQzMifSx7ImJ1ZmZlclZpZXciOjEsImNvbXBvbmVudFR5cGUiOjUxMjYsImNvdW50IjoxMiwidHlwZSI6IlZFQzMifSx7ImJ1ZmZlclZpZXciOjIsImNvbXBvbmVudFR5cGUiOjUxMjMsImNvdW50IjoxMiwidHlwZSI6IlNDQUxBUiJ9XSwiYXNzZXQiOnsiZ2VuZXJhdG9yIjoiU2ltcGxlQ0FEIFNjZW5lIEdMQiBQcm9maWxlIDEiLCJ2ZXJzaW9uIjoiMi4wIn0sImJ1ZmZlclZpZXdzIjpbeyJidWZmZXIiOjAsImJ5dGVMZW5ndGgiOjE0NCwiYnl0ZU9mZnNldCI6MCwidGFyZ2V0IjozNDk2Mn0seyJidWZmZXIiOjAsImJ5dGVMZW5ndGgiOjE0NCwiYnl0ZU9mZnNldCI6MTQ0LCJ0YXJnZXQiOjM0OTYyfSx7ImJ1ZmZlciI6MCwiYnl0ZUxlbmd0aCI6MjQsImJ5dGVPZmZzZXQiOjI4OCwidGFyZ2V0IjozNDk2M31dLCJidWZmZXJzIjpbeyJieXRlTGVuZ3RoIjozMTJ9XSwibWVzaGVzIjpbeyJwcmltaXRpdmVzIjpbeyJhdHRyaWJ1dGVzIjp7Ik5PUk1BTCI6MSwiUE9TSVRJT04iOjB9LCJpbmRpY2VzIjoyLCJtb2RlIjoxfV19XSwibm9kZXMiOlt7Im1lc2giOjB9XSwic2NlbmUiOjAsInNjZW5lcyI6W3sibm9kZXMiOlswXX1dfTgBAABCSU4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIA/AAAAAAAAgD8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIA/AACAPwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgD8AAAAAAACAPwAAAAAAAAAAAAAAAAAAAAAAAIA/AAAAAAAAgD8AAAAAAACAPwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/Os0TPzrNEz86zRM/Os0TPzrNEz86zRM/Os0TPzrNEz86zRM/AAABAAIAAwAFAAQABgAHAAgACQALAAoA", + "valid": false + }, + { + "error": "invalid POSITION accessor", + "expected_kind": "triangle", + "kind": null, + "name": "wrong_position_component_type", + "payload_base64": "Z2xURgIAAAD8AwAAqAIAAEpTT057ImFjY2Vzc29ycyI6W3siYnVmZmVyVmlldyI6MCwiY29tcG9uZW50VHlwZSI6NTEyNSwiY291bnQiOjEyLCJtYXgiOlsxLDEsMV0sIm1pbiI6WzAsMCwwXSwidHlwZSI6IlZFQzMifSx7ImJ1ZmZlclZpZXciOjEsImNvbXBvbmVudFR5cGUiOjUxMjYsImNvdW50IjoxMiwidHlwZSI6IlZFQzMifSx7ImJ1ZmZlclZpZXciOjIsImNvbXBvbmVudFR5cGUiOjUxMjMsImNvdW50IjoxMiwidHlwZSI6IlNDQUxBUiJ9XSwiYXNzZXQiOnsiZ2VuZXJhdG9yIjoiU2ltcGxlQ0FEIFNjZW5lIEdMQiBQcm9maWxlIDEiLCJ2ZXJzaW9uIjoiMi4wIn0sImJ1ZmZlclZpZXdzIjpbeyJidWZmZXIiOjAsImJ5dGVMZW5ndGgiOjE0NCwiYnl0ZU9mZnNldCI6MCwidGFyZ2V0IjozNDk2Mn0seyJidWZmZXIiOjAsImJ5dGVMZW5ndGgiOjE0NCwiYnl0ZU9mZnNldCI6MTQ0LCJ0YXJnZXQiOjM0OTYyfSx7ImJ1ZmZlciI6MCwiYnl0ZUxlbmd0aCI6MjQsImJ5dGVPZmZzZXQiOjI4OCwidGFyZ2V0IjozNDk2M31dLCJidWZmZXJzIjpbeyJieXRlTGVuZ3RoIjozMTJ9XSwibWVzaGVzIjpbeyJwcmltaXRpdmVzIjpbeyJhdHRyaWJ1dGVzIjp7Ik5PUk1BTCI6MSwiUE9TSVRJT04iOjB9LCJpbmRpY2VzIjoyLCJtb2RlIjo0fV19XSwibm9kZXMiOlt7Im1lc2giOjB9XSwic2NlbmUiOjAsInNjZW5lcyI6W3sibm9kZXMiOlswXX1dfTgBAABCSU4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIA/AAAAAAAAgD8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIA/AACAPwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgD8AAAAAAACAPwAAAAAAAAAAAAAAAAAAAAAAAIA/AAAAAAAAgD8AAAAAAACAPwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/Os0TPzrNEz86zRM/Os0TPzrNEz86zRM/Os0TPzrNEz86zRM/AAABAAIAAwAFAAQABgAHAAgACQALAAoA", + "valid": false + }, + { + "error": "invalid NORMAL accessor", + "expected_kind": "triangle", + "kind": null, + "name": "wrong_normal_count", + "payload_base64": "Z2xURgIAAAD8AwAAqAIAAEpTT057ImFjY2Vzc29ycyI6W3siYnVmZmVyVmlldyI6MCwiY29tcG9uZW50VHlwZSI6NTEyNiwiY291bnQiOjEyLCJtYXgiOlsxLDEsMV0sIm1pbiI6WzAsMCwwXSwidHlwZSI6IlZFQzMifSx7ImJ1ZmZlclZpZXciOjEsImNvbXBvbmVudFR5cGUiOjUxMjYsImNvdW50IjoxLCJ0eXBlIjoiVkVDMyJ9LHsiYnVmZmVyVmlldyI6MiwiY29tcG9uZW50VHlwZSI6NTEyMywiY291bnQiOjEyLCJ0eXBlIjoiU0NBTEFSIn1dLCJhc3NldCI6eyJnZW5lcmF0b3IiOiJTaW1wbGVDQUQgU2NlbmUgR0xCIFByb2ZpbGUgMSIsInZlcnNpb24iOiIyLjAifSwiYnVmZmVyVmlld3MiOlt7ImJ1ZmZlciI6MCwiYnl0ZUxlbmd0aCI6MTQ0LCJieXRlT2Zmc2V0IjowLCJ0YXJnZXQiOjM0OTYyfSx7ImJ1ZmZlciI6MCwiYnl0ZUxlbmd0aCI6MTQ0LCJieXRlT2Zmc2V0IjoxNDQsInRhcmdldCI6MzQ5NjJ9LHsiYnVmZmVyIjowLCJieXRlTGVuZ3RoIjoyNCwiYnl0ZU9mZnNldCI6Mjg4LCJ0YXJnZXQiOjM0OTYzfV0sImJ1ZmZlcnMiOlt7ImJ5dGVMZW5ndGgiOjMxMn1dLCJtZXNoZXMiOlt7InByaW1pdGl2ZXMiOlt7ImF0dHJpYnV0ZXMiOnsiTk9STUFMIjoxLCJQT1NJVElPTiI6MH0sImluZGljZXMiOjIsIm1vZGUiOjR9XX1dLCJub2RlcyI6W3sibWVzaCI6MH1dLCJzY2VuZSI6MCwic2NlbmVzIjpbeyJub2RlcyI6WzBdfV19IDgBAABCSU4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIA/AAAAAAAAgD8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIA/AACAPwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgD8AAAAAAACAPwAAAAAAAAAAAAAAAAAAAAAAAIA/AAAAAAAAgD8AAAAAAACAPwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/Os0TPzrNEz86zRM/Os0TPzrNEz86zRM/Os0TPzrNEz86zRM/AAABAAIAAwAFAAQABgAHAAgACQALAAoA", + "valid": false + }, + { + "error": "invalid GLB index accessor", + "expected_kind": "triangle", + "kind": null, + "name": "wrong_index_component_type", + "payload_base64": "Z2xURgIAAAD8AwAAqAIAAEpTT057ImFjY2Vzc29ycyI6W3siYnVmZmVyVmlldyI6MCwiY29tcG9uZW50VHlwZSI6NTEyNiwiY291bnQiOjEyLCJtYXgiOlsxLDEsMV0sIm1pbiI6WzAsMCwwXSwidHlwZSI6IlZFQzMifSx7ImJ1ZmZlclZpZXciOjEsImNvbXBvbmVudFR5cGUiOjUxMjYsImNvdW50IjoxMiwidHlwZSI6IlZFQzMifSx7ImJ1ZmZlclZpZXciOjIsImNvbXBvbmVudFR5cGUiOjUxMjUsImNvdW50IjoxMiwidHlwZSI6IlNDQUxBUiJ9XSwiYXNzZXQiOnsiZ2VuZXJhdG9yIjoiU2ltcGxlQ0FEIFNjZW5lIEdMQiBQcm9maWxlIDEiLCJ2ZXJzaW9uIjoiMi4wIn0sImJ1ZmZlclZpZXdzIjpbeyJidWZmZXIiOjAsImJ5dGVMZW5ndGgiOjE0NCwiYnl0ZU9mZnNldCI6MCwidGFyZ2V0IjozNDk2Mn0seyJidWZmZXIiOjAsImJ5dGVMZW5ndGgiOjE0NCwiYnl0ZU9mZnNldCI6MTQ0LCJ0YXJnZXQiOjM0OTYyfSx7ImJ1ZmZlciI6MCwiYnl0ZUxlbmd0aCI6MjQsImJ5dGVPZmZzZXQiOjI4OCwidGFyZ2V0IjozNDk2M31dLCJidWZmZXJzIjpbeyJieXRlTGVuZ3RoIjozMTJ9XSwibWVzaGVzIjpbeyJwcmltaXRpdmVzIjpbeyJhdHRyaWJ1dGVzIjp7Ik5PUk1BTCI6MSwiUE9TSVRJT04iOjB9LCJpbmRpY2VzIjoyLCJtb2RlIjo0fV19XSwibm9kZXMiOlt7Im1lc2giOjB9XSwic2NlbmUiOjAsInNjZW5lcyI6W3sibm9kZXMiOlswXX1dfTgBAABCSU4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIA/AAAAAAAAgD8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIA/AACAPwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgD8AAAAAAACAPwAAAAAAAAAAAAAAAAAAAAAAAIA/AAAAAAAAgD8AAAAAAACAPwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/Os0TPzrNEz86zRM/Os0TPzrNEz86zRM/Os0TPzrNEz86zRM/AAABAAIAAwAFAAQABgAHAAgACQALAAoA", + "valid": false + }, + { + "error": "GLB bufferView offset mismatch", + "expected_kind": "triangle", + "kind": null, + "name": "buffer_view_gap", + "payload_base64": "Z2xURgIAAAD8AwAAqAIAAEpTT057ImFjY2Vzc29ycyI6W3siYnVmZmVyVmlldyI6MCwiY29tcG9uZW50VHlwZSI6NTEyNiwiY291bnQiOjEyLCJtYXgiOlsxLDEsMV0sIm1pbiI6WzAsMCwwXSwidHlwZSI6IlZFQzMifSx7ImJ1ZmZlclZpZXciOjEsImNvbXBvbmVudFR5cGUiOjUxMjYsImNvdW50IjoxMiwidHlwZSI6IlZFQzMifSx7ImJ1ZmZlclZpZXciOjIsImNvbXBvbmVudFR5cGUiOjUxMjMsImNvdW50IjoxMiwidHlwZSI6IlNDQUxBUiJ9XSwiYXNzZXQiOnsiZ2VuZXJhdG9yIjoiU2ltcGxlQ0FEIFNjZW5lIEdMQiBQcm9maWxlIDEiLCJ2ZXJzaW9uIjoiMi4wIn0sImJ1ZmZlclZpZXdzIjpbeyJidWZmZXIiOjAsImJ5dGVMZW5ndGgiOjE0NCwiYnl0ZU9mZnNldCI6MCwidGFyZ2V0IjozNDk2Mn0seyJidWZmZXIiOjAsImJ5dGVMZW5ndGgiOjE0NCwiYnl0ZU9mZnNldCI6NCwidGFyZ2V0IjozNDk2Mn0seyJidWZmZXIiOjAsImJ5dGVMZW5ndGgiOjI0LCJieXRlT2Zmc2V0IjoyODgsInRhcmdldCI6MzQ5NjN9XSwiYnVmZmVycyI6W3siYnl0ZUxlbmd0aCI6MzEyfV0sIm1lc2hlcyI6W3sicHJpbWl0aXZlcyI6W3siYXR0cmlidXRlcyI6eyJOT1JNQUwiOjEsIlBPU0lUSU9OIjowfSwiaW5kaWNlcyI6MiwibW9kZSI6NH1dfV0sIm5vZGVzIjpbeyJtZXNoIjowfV0sInNjZW5lIjowLCJzY2VuZXMiOlt7Im5vZGVzIjpbMF19XX0gIDgBAABCSU4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIA/AAAAAAAAgD8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIA/AACAPwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgD8AAAAAAACAPwAAAAAAAAAAAAAAAAAAAAAAAIA/AAAAAAAAgD8AAAAAAACAPwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/Os0TPzrNEz86zRM/Os0TPzrNEz86zRM/Os0TPzrNEz86zRM/AAABAAIAAwAFAAQABgAHAAgACQALAAoA", + "valid": false + }, + { + "error": "GLB bufferView target mismatch", + "expected_kind": "triangle", + "kind": null, + "name": "buffer_view_wrong_target", + "payload_base64": "Z2xURgIAAAD8AwAAqAIAAEpTT057ImFjY2Vzc29ycyI6W3siYnVmZmVyVmlldyI6MCwiY29tcG9uZW50VHlwZSI6NTEyNiwiY291bnQiOjEyLCJtYXgiOlsxLDEsMV0sIm1pbiI6WzAsMCwwXSwidHlwZSI6IlZFQzMifSx7ImJ1ZmZlclZpZXciOjEsImNvbXBvbmVudFR5cGUiOjUxMjYsImNvdW50IjoxMiwidHlwZSI6IlZFQzMifSx7ImJ1ZmZlclZpZXciOjIsImNvbXBvbmVudFR5cGUiOjUxMjMsImNvdW50IjoxMiwidHlwZSI6IlNDQUxBUiJ9XSwiYXNzZXQiOnsiZ2VuZXJhdG9yIjoiU2ltcGxlQ0FEIFNjZW5lIEdMQiBQcm9maWxlIDEiLCJ2ZXJzaW9uIjoiMi4wIn0sImJ1ZmZlclZpZXdzIjpbeyJidWZmZXIiOjAsImJ5dGVMZW5ndGgiOjE0NCwiYnl0ZU9mZnNldCI6MCwidGFyZ2V0IjozNDk2M30seyJidWZmZXIiOjAsImJ5dGVMZW5ndGgiOjE0NCwiYnl0ZU9mZnNldCI6MTQ0LCJ0YXJnZXQiOjM0OTYyfSx7ImJ1ZmZlciI6MCwiYnl0ZUxlbmd0aCI6MjQsImJ5dGVPZmZzZXQiOjI4OCwidGFyZ2V0IjozNDk2M31dLCJidWZmZXJzIjpbeyJieXRlTGVuZ3RoIjozMTJ9XSwibWVzaGVzIjpbeyJwcmltaXRpdmVzIjpbeyJhdHRyaWJ1dGVzIjp7Ik5PUk1BTCI6MSwiUE9TSVRJT04iOjB9LCJpbmRpY2VzIjoyLCJtb2RlIjo0fV19XSwibm9kZXMiOlt7Im1lc2giOjB9XSwic2NlbmUiOjAsInNjZW5lcyI6W3sibm9kZXMiOlswXX1dfTgBAABCSU4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIA/AAAAAAAAgD8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIA/AACAPwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgD8AAAAAAACAPwAAAAAAAAAAAAAAAAAAAAAAAIA/AAAAAAAAgD8AAAAAAACAPwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/Os0TPzrNEz86zRM/Os0TPzrNEz86zRM/Os0TPzrNEz86zRM/AAABAAIAAwAFAAQABgAHAAgACQALAAoA", + "valid": false + }, + { + "error": "GLB bufferView byteLength mismatch", + "expected_kind": "triangle", + "kind": null, + "name": "buffer_view_wrong_length", + "payload_base64": "Z2xURgIAAAD8AwAAqAIAAEpTT057ImFjY2Vzc29ycyI6W3siYnVmZmVyVmlldyI6MCwiY29tcG9uZW50VHlwZSI6NTEyNiwiY291bnQiOjEyLCJtYXgiOlsxLDEsMV0sIm1pbiI6WzAsMCwwXSwidHlwZSI6IlZFQzMifSx7ImJ1ZmZlclZpZXciOjEsImNvbXBvbmVudFR5cGUiOjUxMjYsImNvdW50IjoxMiwidHlwZSI6IlZFQzMifSx7ImJ1ZmZlclZpZXciOjIsImNvbXBvbmVudFR5cGUiOjUxMjMsImNvdW50IjoxMiwidHlwZSI6IlNDQUxBUiJ9XSwiYXNzZXQiOnsiZ2VuZXJhdG9yIjoiU2ltcGxlQ0FEIFNjZW5lIEdMQiBQcm9maWxlIDEiLCJ2ZXJzaW9uIjoiMi4wIn0sImJ1ZmZlclZpZXdzIjpbeyJidWZmZXIiOjAsImJ5dGVMZW5ndGgiOjQsImJ5dGVPZmZzZXQiOjAsInRhcmdldCI6MzQ5NjJ9LHsiYnVmZmVyIjowLCJieXRlTGVuZ3RoIjoxNDQsImJ5dGVPZmZzZXQiOjE0NCwidGFyZ2V0IjozNDk2Mn0seyJidWZmZXIiOjAsImJ5dGVMZW5ndGgiOjI0LCJieXRlT2Zmc2V0IjoyODgsInRhcmdldCI6MzQ5NjN9XSwiYnVmZmVycyI6W3siYnl0ZUxlbmd0aCI6MzEyfV0sIm1lc2hlcyI6W3sicHJpbWl0aXZlcyI6W3siYXR0cmlidXRlcyI6eyJOT1JNQUwiOjEsIlBPU0lUSU9OIjowfSwiaW5kaWNlcyI6MiwibW9kZSI6NH1dfV0sIm5vZGVzIjpbeyJtZXNoIjowfV0sInNjZW5lIjowLCJzY2VuZXMiOlt7Im5vZGVzIjpbMF19XX0gIDgBAABCSU4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIA/AAAAAAAAgD8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIA/AACAPwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgD8AAAAAAACAPwAAAAAAAAAAAAAAAAAAAAAAAIA/AAAAAAAAgD8AAAAAAACAPwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/Os0TPzrNEz86zRM/Os0TPzrNEz86zRM/Os0TPzrNEz86zRM/AAABAAIAAwAFAAQABgAHAAgACQALAAoA", + "valid": false + }, + { + "error": "invalid GLB header", + "expected_kind": "triangle", + "kind": null, + "name": "bad_glb_magic", + "payload_base64": "AAAAAAIAAAD8AwAAqAIAAEpTT057ImFjY2Vzc29ycyI6W3siYnVmZmVyVmlldyI6MCwiY29tcG9uZW50VHlwZSI6NTEyNiwiY291bnQiOjEyLCJtYXgiOlsxLDEsMV0sIm1pbiI6WzAsMCwwXSwidHlwZSI6IlZFQzMifSx7ImJ1ZmZlclZpZXciOjEsImNvbXBvbmVudFR5cGUiOjUxMjYsImNvdW50IjoxMiwidHlwZSI6IlZFQzMifSx7ImJ1ZmZlclZpZXciOjIsImNvbXBvbmVudFR5cGUiOjUxMjMsImNvdW50IjoxMiwidHlwZSI6IlNDQUxBUiJ9XSwiYXNzZXQiOnsiZ2VuZXJhdG9yIjoiU2ltcGxlQ0FEIFNjZW5lIEdMQiBQcm9maWxlIDEiLCJ2ZXJzaW9uIjoiMi4wIn0sImJ1ZmZlclZpZXdzIjpbeyJidWZmZXIiOjAsImJ5dGVMZW5ndGgiOjE0NCwiYnl0ZU9mZnNldCI6MCwidGFyZ2V0IjozNDk2Mn0seyJidWZmZXIiOjAsImJ5dGVMZW5ndGgiOjE0NCwiYnl0ZU9mZnNldCI6MTQ0LCJ0YXJnZXQiOjM0OTYyfSx7ImJ1ZmZlciI6MCwiYnl0ZUxlbmd0aCI6MjQsImJ5dGVPZmZzZXQiOjI4OCwidGFyZ2V0IjozNDk2M31dLCJidWZmZXJzIjpbeyJieXRlTGVuZ3RoIjozMTJ9XSwibWVzaGVzIjpbeyJwcmltaXRpdmVzIjpbeyJhdHRyaWJ1dGVzIjp7Ik5PUk1BTCI6MSwiUE9TSVRJT04iOjB9LCJpbmRpY2VzIjoyLCJtb2RlIjo0fV19XSwibm9kZXMiOlt7Im1lc2giOjB9XSwic2NlbmUiOjAsInNjZW5lcyI6W3sibm9kZXMiOlswXX1dfTgBAABCSU4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIA/AAAAAAAAgD8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIA/AACAPwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgD8AAAAAAACAPwAAAAAAAAAAAAAAAAAAAAAAAIA/AAAAAAAAgD8AAAAAAACAPwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/Os0TPzrNEz86zRM/Os0TPzrNEz86zRM/Os0TPzrNEz86zRM/AAABAAIAAwAFAAQABgAHAAgACQALAAoA", + "valid": false + }, + { + "error": "invalid GLB header", + "expected_kind": "triangle", + "kind": null, + "name": "bad_glb_version", + "payload_base64": "Z2xURgEAAAD8AwAAqAIAAEpTT057ImFjY2Vzc29ycyI6W3siYnVmZmVyVmlldyI6MCwiY29tcG9uZW50VHlwZSI6NTEyNiwiY291bnQiOjEyLCJtYXgiOlsxLDEsMV0sIm1pbiI6WzAsMCwwXSwidHlwZSI6IlZFQzMifSx7ImJ1ZmZlclZpZXciOjEsImNvbXBvbmVudFR5cGUiOjUxMjYsImNvdW50IjoxMiwidHlwZSI6IlZFQzMifSx7ImJ1ZmZlclZpZXciOjIsImNvbXBvbmVudFR5cGUiOjUxMjMsImNvdW50IjoxMiwidHlwZSI6IlNDQUxBUiJ9XSwiYXNzZXQiOnsiZ2VuZXJhdG9yIjoiU2ltcGxlQ0FEIFNjZW5lIEdMQiBQcm9maWxlIDEiLCJ2ZXJzaW9uIjoiMi4wIn0sImJ1ZmZlclZpZXdzIjpbeyJidWZmZXIiOjAsImJ5dGVMZW5ndGgiOjE0NCwiYnl0ZU9mZnNldCI6MCwidGFyZ2V0IjozNDk2Mn0seyJidWZmZXIiOjAsImJ5dGVMZW5ndGgiOjE0NCwiYnl0ZU9mZnNldCI6MTQ0LCJ0YXJnZXQiOjM0OTYyfSx7ImJ1ZmZlciI6MCwiYnl0ZUxlbmd0aCI6MjQsImJ5dGVPZmZzZXQiOjI4OCwidGFyZ2V0IjozNDk2M31dLCJidWZmZXJzIjpbeyJieXRlTGVuZ3RoIjozMTJ9XSwibWVzaGVzIjpbeyJwcmltaXRpdmVzIjpbeyJhdHRyaWJ1dGVzIjp7Ik5PUk1BTCI6MSwiUE9TSVRJT04iOjB9LCJpbmRpY2VzIjoyLCJtb2RlIjo0fV19XSwibm9kZXMiOlt7Im1lc2giOjB9XSwic2NlbmUiOjAsInNjZW5lcyI6W3sibm9kZXMiOlswXX1dfTgBAABCSU4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIA/AAAAAAAAgD8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIA/AACAPwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgD8AAAAAAACAPwAAAAAAAAAAAAAAAAAAAAAAAIA/AAAAAAAAgD8AAAAAAACAPwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/Os0TPzrNEz86zRM/Os0TPzrNEz86zRM/Os0TPzrNEz86zRM/AAABAAIAAwAFAAQABgAHAAgACQALAAoA", + "valid": false + }, + { + "error": "invalid GLB header", + "expected_kind": "triangle", + "kind": null, + "name": "bad_glb_total_length", + "payload_base64": "Z2xURgIAAAAABAAAqAIAAEpTT057ImFjY2Vzc29ycyI6W3siYnVmZmVyVmlldyI6MCwiY29tcG9uZW50VHlwZSI6NTEyNiwiY291bnQiOjEyLCJtYXgiOlsxLDEsMV0sIm1pbiI6WzAsMCwwXSwidHlwZSI6IlZFQzMifSx7ImJ1ZmZlclZpZXciOjEsImNvbXBvbmVudFR5cGUiOjUxMjYsImNvdW50IjoxMiwidHlwZSI6IlZFQzMifSx7ImJ1ZmZlclZpZXciOjIsImNvbXBvbmVudFR5cGUiOjUxMjMsImNvdW50IjoxMiwidHlwZSI6IlNDQUxBUiJ9XSwiYXNzZXQiOnsiZ2VuZXJhdG9yIjoiU2ltcGxlQ0FEIFNjZW5lIEdMQiBQcm9maWxlIDEiLCJ2ZXJzaW9uIjoiMi4wIn0sImJ1ZmZlclZpZXdzIjpbeyJidWZmZXIiOjAsImJ5dGVMZW5ndGgiOjE0NCwiYnl0ZU9mZnNldCI6MCwidGFyZ2V0IjozNDk2Mn0seyJidWZmZXIiOjAsImJ5dGVMZW5ndGgiOjE0NCwiYnl0ZU9mZnNldCI6MTQ0LCJ0YXJnZXQiOjM0OTYyfSx7ImJ1ZmZlciI6MCwiYnl0ZUxlbmd0aCI6MjQsImJ5dGVPZmZzZXQiOjI4OCwidGFyZ2V0IjozNDk2M31dLCJidWZmZXJzIjpbeyJieXRlTGVuZ3RoIjozMTJ9XSwibWVzaGVzIjpbeyJwcmltaXRpdmVzIjpbeyJhdHRyaWJ1dGVzIjp7Ik5PUk1BTCI6MSwiUE9TSVRJT04iOjB9LCJpbmRpY2VzIjoyLCJtb2RlIjo0fV19XSwibm9kZXMiOlt7Im1lc2giOjB9XSwic2NlbmUiOjAsInNjZW5lcyI6W3sibm9kZXMiOlswXX1dfTgBAABCSU4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIA/AAAAAAAAgD8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIA/AACAPwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgD8AAAAAAACAPwAAAAAAAAAAAAAAAAAAAAAAAIA/AAAAAAAAgD8AAAAAAACAPwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/Os0TPzrNEz86zRM/Os0TPzrNEz86zRM/Os0TPzrNEz86zRM/AAABAAIAAwAFAAQABgAHAAgACQALAAoA", + "valid": false + }, + { + "error": "invalid GLB JSON chunk header", + "expected_kind": "triangle", + "kind": null, + "name": "bad_json_chunk_type", + "payload_base64": "Z2xURgIAAAD8AwAAqAIAAAAAAAB7ImFjY2Vzc29ycyI6W3siYnVmZmVyVmlldyI6MCwiY29tcG9uZW50VHlwZSI6NTEyNiwiY291bnQiOjEyLCJtYXgiOlsxLDEsMV0sIm1pbiI6WzAsMCwwXSwidHlwZSI6IlZFQzMifSx7ImJ1ZmZlclZpZXciOjEsImNvbXBvbmVudFR5cGUiOjUxMjYsImNvdW50IjoxMiwidHlwZSI6IlZFQzMifSx7ImJ1ZmZlclZpZXciOjIsImNvbXBvbmVudFR5cGUiOjUxMjMsImNvdW50IjoxMiwidHlwZSI6IlNDQUxBUiJ9XSwiYXNzZXQiOnsiZ2VuZXJhdG9yIjoiU2ltcGxlQ0FEIFNjZW5lIEdMQiBQcm9maWxlIDEiLCJ2ZXJzaW9uIjoiMi4wIn0sImJ1ZmZlclZpZXdzIjpbeyJidWZmZXIiOjAsImJ5dGVMZW5ndGgiOjE0NCwiYnl0ZU9mZnNldCI6MCwidGFyZ2V0IjozNDk2Mn0seyJidWZmZXIiOjAsImJ5dGVMZW5ndGgiOjE0NCwiYnl0ZU9mZnNldCI6MTQ0LCJ0YXJnZXQiOjM0OTYyfSx7ImJ1ZmZlciI6MCwiYnl0ZUxlbmd0aCI6MjQsImJ5dGVPZmZzZXQiOjI4OCwidGFyZ2V0IjozNDk2M31dLCJidWZmZXJzIjpbeyJieXRlTGVuZ3RoIjozMTJ9XSwibWVzaGVzIjpbeyJwcmltaXRpdmVzIjpbeyJhdHRyaWJ1dGVzIjp7Ik5PUk1BTCI6MSwiUE9TSVRJT04iOjB9LCJpbmRpY2VzIjoyLCJtb2RlIjo0fV19XSwibm9kZXMiOlt7Im1lc2giOjB9XSwic2NlbmUiOjAsInNjZW5lcyI6W3sibm9kZXMiOlswXX1dfTgBAABCSU4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIA/AAAAAAAAgD8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIA/AACAPwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgD8AAAAAAACAPwAAAAAAAAAAAAAAAAAAAAAAAIA/AAAAAAAAgD8AAAAAAACAPwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/Os0TPzrNEz86zRM/Os0TPzrNEz86zRM/Os0TPzrNEz86zRM/AAABAAIAAwAFAAQABgAHAAgACQALAAoA", + "valid": false + }, + { + "error": "GLB JSON padding must use the minimal ASCII-space suffix", + "expected_kind": "triangle", + "kind": null, + "name": "nonminimal_json_padding", + "payload_base64": "Z2xURgIAAAAABAAArAIAAEpTT057ImFjY2Vzc29ycyI6W3siYnVmZmVyVmlldyI6MCwiY29tcG9uZW50VHlwZSI6NTEyNiwiY291bnQiOjEyLCJtYXgiOlsxLDEsMV0sIm1pbiI6WzAsMCwwXSwidHlwZSI6IlZFQzMifSx7ImJ1ZmZlclZpZXciOjEsImNvbXBvbmVudFR5cGUiOjUxMjYsImNvdW50IjoxMiwidHlwZSI6IlZFQzMifSx7ImJ1ZmZlclZpZXciOjIsImNvbXBvbmVudFR5cGUiOjUxMjMsImNvdW50IjoxMiwidHlwZSI6IlNDQUxBUiJ9XSwiYXNzZXQiOnsiZ2VuZXJhdG9yIjoiU2ltcGxlQ0FEIFNjZW5lIEdMQiBQcm9maWxlIDEiLCJ2ZXJzaW9uIjoiMi4wIn0sImJ1ZmZlclZpZXdzIjpbeyJidWZmZXIiOjAsImJ5dGVMZW5ndGgiOjE0NCwiYnl0ZU9mZnNldCI6MCwidGFyZ2V0IjozNDk2Mn0seyJidWZmZXIiOjAsImJ5dGVMZW5ndGgiOjE0NCwiYnl0ZU9mZnNldCI6MTQ0LCJ0YXJnZXQiOjM0OTYyfSx7ImJ1ZmZlciI6MCwiYnl0ZUxlbmd0aCI6MjQsImJ5dGVPZmZzZXQiOjI4OCwidGFyZ2V0IjozNDk2M31dLCJidWZmZXJzIjpbeyJieXRlTGVuZ3RoIjozMTJ9XSwibWVzaGVzIjpbeyJwcmltaXRpdmVzIjpbeyJhdHRyaWJ1dGVzIjp7Ik5PUk1BTCI6MSwiUE9TSVRJT04iOjB9LCJpbmRpY2VzIjoyLCJtb2RlIjo0fV19XSwibm9kZXMiOlt7Im1lc2giOjB9XSwic2NlbmUiOjAsInNjZW5lcyI6W3sibm9kZXMiOlswXX1dfSAgICA4AQAAQklOAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAPwAAAAAAAIA/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAPwAAgD8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIA/AAAAAAAAgD8AAAAAAAAAAAAAAAAAAAAAAACAPwAAAAAAAIA/AAAAAAAAgD8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvzrNEz86zRM/Os0TPzrNEz86zRM/Os0TPzrNEz86zRM/Os0TPwAAAQACAAMABQAEAAYABwAIAAkACwAKAA==", + "valid": false + }, + { + "error": "invalid GLB BIN chunk header", + "expected_kind": "triangle", + "kind": null, + "name": "bad_bin_chunk_type", + "payload_base64": "Z2xURgIAAAD8AwAAqAIAAEpTT057ImFjY2Vzc29ycyI6W3siYnVmZmVyVmlldyI6MCwiY29tcG9uZW50VHlwZSI6NTEyNiwiY291bnQiOjEyLCJtYXgiOlsxLDEsMV0sIm1pbiI6WzAsMCwwXSwidHlwZSI6IlZFQzMifSx7ImJ1ZmZlclZpZXciOjEsImNvbXBvbmVudFR5cGUiOjUxMjYsImNvdW50IjoxMiwidHlwZSI6IlZFQzMifSx7ImJ1ZmZlclZpZXciOjIsImNvbXBvbmVudFR5cGUiOjUxMjMsImNvdW50IjoxMiwidHlwZSI6IlNDQUxBUiJ9XSwiYXNzZXQiOnsiZ2VuZXJhdG9yIjoiU2ltcGxlQ0FEIFNjZW5lIEdMQiBQcm9maWxlIDEiLCJ2ZXJzaW9uIjoiMi4wIn0sImJ1ZmZlclZpZXdzIjpbeyJidWZmZXIiOjAsImJ5dGVMZW5ndGgiOjE0NCwiYnl0ZU9mZnNldCI6MCwidGFyZ2V0IjozNDk2Mn0seyJidWZmZXIiOjAsImJ5dGVMZW5ndGgiOjE0NCwiYnl0ZU9mZnNldCI6MTQ0LCJ0YXJnZXQiOjM0OTYyfSx7ImJ1ZmZlciI6MCwiYnl0ZUxlbmd0aCI6MjQsImJ5dGVPZmZzZXQiOjI4OCwidGFyZ2V0IjozNDk2M31dLCJidWZmZXJzIjpbeyJieXRlTGVuZ3RoIjozMTJ9XSwibWVzaGVzIjpbeyJwcmltaXRpdmVzIjpbeyJhdHRyaWJ1dGVzIjp7Ik5PUk1BTCI6MSwiUE9TSVRJT04iOjB9LCJpbmRpY2VzIjoyLCJtb2RlIjo0fV19XSwibm9kZXMiOlt7Im1lc2giOjB9XSwic2NlbmUiOjAsInNjZW5lcyI6W3sibm9kZXMiOlswXX1dfTgBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIA/AAAAAAAAgD8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIA/AACAPwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgD8AAAAAAACAPwAAAAAAAAAAAAAAAAAAAAAAAIA/AAAAAAAAgD8AAAAAAACAPwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/Os0TPzrNEz86zRM/Os0TPzrNEz86zRM/Os0TPzrNEz86zRM/AAABAAIAAwAFAAQABgAHAAgACQALAAoA", + "valid": false + }, + { + "error": "GLB accessor/bufferView count mismatch", + "expected_kind": "triangle", + "kind": null, + "name": "extra_accessor", + "payload_base64": "Z2xURgIAAABYBAAABAMAAEpTT057ImFjY2Vzc29ycyI6W3siYnVmZmVyVmlldyI6MCwiY29tcG9uZW50VHlwZSI6NTEyNiwiY291bnQiOjEyLCJtYXgiOlsxLDEsMV0sIm1pbiI6WzAsMCwwXSwidHlwZSI6IlZFQzMifSx7ImJ1ZmZlclZpZXciOjEsImNvbXBvbmVudFR5cGUiOjUxMjYsImNvdW50IjoxMiwidHlwZSI6IlZFQzMifSx7ImJ1ZmZlclZpZXciOjIsImNvbXBvbmVudFR5cGUiOjUxMjMsImNvdW50IjoxMiwidHlwZSI6IlNDQUxBUiJ9LHsiYnVmZmVyVmlldyI6MCwiY29tcG9uZW50VHlwZSI6NTEyNiwiY291bnQiOjEyLCJtYXgiOlsxLDEsMV0sIm1pbiI6WzAsMCwwXSwidHlwZSI6IlZFQzMifV0sImFzc2V0Ijp7ImdlbmVyYXRvciI6IlNpbXBsZUNBRCBTY2VuZSBHTEIgUHJvZmlsZSAxIiwidmVyc2lvbiI6IjIuMCJ9LCJidWZmZXJWaWV3cyI6W3siYnVmZmVyIjowLCJieXRlTGVuZ3RoIjoxNDQsImJ5dGVPZmZzZXQiOjAsInRhcmdldCI6MzQ5NjJ9LHsiYnVmZmVyIjowLCJieXRlTGVuZ3RoIjoxNDQsImJ5dGVPZmZzZXQiOjE0NCwidGFyZ2V0IjozNDk2Mn0seyJidWZmZXIiOjAsImJ5dGVMZW5ndGgiOjI0LCJieXRlT2Zmc2V0IjoyODgsInRhcmdldCI6MzQ5NjN9XSwiYnVmZmVycyI6W3siYnl0ZUxlbmd0aCI6MzEyfV0sIm1lc2hlcyI6W3sicHJpbWl0aXZlcyI6W3siYXR0cmlidXRlcyI6eyJOT1JNQUwiOjEsIlBPU0lUSU9OIjowfSwiaW5kaWNlcyI6MiwibW9kZSI6NH1dfV0sIm5vZGVzIjpbeyJtZXNoIjowfV0sInNjZW5lIjowLCJzY2VuZXMiOlt7Im5vZGVzIjpbMF19XX0gOAEAAEJJTgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgD8AAAAAAACAPwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgD8AAIA/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAPwAAAAAAAIA/AAAAAAAAAAAAAAAAAAAAAAAAgD8AAAAAAACAPwAAAAAAAIA/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL86zRM/Os0TPzrNEz86zRM/Os0TPzrNEz86zRM/Os0TPzrNEz8AAAEAAgADAAUABAAGAAcACAAJAAsACgA=", + "valid": false + }, + { + "error": "GLB buffer byteLength mismatch", + "expected_kind": "triangle", + "kind": null, + "name": "buffer_unpadded_length", + "payload_base64": "Z2xURgIAAAD8AwAAqAIAAEpTT057ImFjY2Vzc29ycyI6W3siYnVmZmVyVmlldyI6MCwiY29tcG9uZW50VHlwZSI6NTEyNiwiY291bnQiOjEyLCJtYXgiOlsxLDEsMV0sIm1pbiI6WzAsMCwwXSwidHlwZSI6IlZFQzMifSx7ImJ1ZmZlclZpZXciOjEsImNvbXBvbmVudFR5cGUiOjUxMjYsImNvdW50IjoxMiwidHlwZSI6IlZFQzMifSx7ImJ1ZmZlclZpZXciOjIsImNvbXBvbmVudFR5cGUiOjUxMjMsImNvdW50IjoxMiwidHlwZSI6IlNDQUxBUiJ9XSwiYXNzZXQiOnsiZ2VuZXJhdG9yIjoiU2ltcGxlQ0FEIFNjZW5lIEdMQiBQcm9maWxlIDEiLCJ2ZXJzaW9uIjoiMi4wIn0sImJ1ZmZlclZpZXdzIjpbeyJidWZmZXIiOjAsImJ5dGVMZW5ndGgiOjE0NCwiYnl0ZU9mZnNldCI6MCwidGFyZ2V0IjozNDk2Mn0seyJidWZmZXIiOjAsImJ5dGVMZW5ndGgiOjE0NCwiYnl0ZU9mZnNldCI6MTQ0LCJ0YXJnZXQiOjM0OTYyfSx7ImJ1ZmZlciI6MCwiYnl0ZUxlbmd0aCI6MjQsImJ5dGVPZmZzZXQiOjI4OCwidGFyZ2V0IjozNDk2M31dLCJidWZmZXJzIjpbeyJieXRlTGVuZ3RoIjozMTZ9XSwibWVzaGVzIjpbeyJwcmltaXRpdmVzIjpbeyJhdHRyaWJ1dGVzIjp7Ik5PUk1BTCI6MSwiUE9TSVRJT04iOjB9LCJpbmRpY2VzIjoyLCJtb2RlIjo0fV19XSwibm9kZXMiOlt7Im1lc2giOjB9XSwic2NlbmUiOjAsInNjZW5lcyI6W3sibm9kZXMiOlswXX1dfTgBAABCSU4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIA/AAAAAAAAgD8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIA/AACAPwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgD8AAAAAAACAPwAAAAAAAAAAAAAAAAAAAAAAAIA/AAAAAAAAgD8AAAAAAACAPwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/Os0TPzrNEz86zRM/Os0TPzrNEz86zRM/Os0TPzrNEz86zRM/AAABAAIAAwAFAAQABgAHAAgACQALAAoA", + "valid": false + }, + { + "error": "GLB BIN chunk length mismatch", + "expected_kind": "triangle", + "kind": null, + "name": "bin_extra_zero_word", + "payload_base64": "Z2xURgIAAAAABAAAqAIAAEpTT057ImFjY2Vzc29ycyI6W3siYnVmZmVyVmlldyI6MCwiY29tcG9uZW50VHlwZSI6NTEyNiwiY291bnQiOjEyLCJtYXgiOlsxLDEsMV0sIm1pbiI6WzAsMCwwXSwidHlwZSI6IlZFQzMifSx7ImJ1ZmZlclZpZXciOjEsImNvbXBvbmVudFR5cGUiOjUxMjYsImNvdW50IjoxMiwidHlwZSI6IlZFQzMifSx7ImJ1ZmZlclZpZXciOjIsImNvbXBvbmVudFR5cGUiOjUxMjMsImNvdW50IjoxMiwidHlwZSI6IlNDQUxBUiJ9XSwiYXNzZXQiOnsiZ2VuZXJhdG9yIjoiU2ltcGxlQ0FEIFNjZW5lIEdMQiBQcm9maWxlIDEiLCJ2ZXJzaW9uIjoiMi4wIn0sImJ1ZmZlclZpZXdzIjpbeyJidWZmZXIiOjAsImJ5dGVMZW5ndGgiOjE0NCwiYnl0ZU9mZnNldCI6MCwidGFyZ2V0IjozNDk2Mn0seyJidWZmZXIiOjAsImJ5dGVMZW5ndGgiOjE0NCwiYnl0ZU9mZnNldCI6MTQ0LCJ0YXJnZXQiOjM0OTYyfSx7ImJ1ZmZlciI6MCwiYnl0ZUxlbmd0aCI6MjQsImJ5dGVPZmZzZXQiOjI4OCwidGFyZ2V0IjozNDk2M31dLCJidWZmZXJzIjpbeyJieXRlTGVuZ3RoIjozMTJ9XSwibWVzaGVzIjpbeyJwcmltaXRpdmVzIjpbeyJhdHRyaWJ1dGVzIjp7Ik5PUk1BTCI6MSwiUE9TSVRJT04iOjB9LCJpbmRpY2VzIjoyLCJtb2RlIjo0fV19XSwibm9kZXMiOlt7Im1lc2giOjB9XSwic2NlbmUiOjAsInNjZW5lcyI6W3sibm9kZXMiOlswXX1dfTwBAABCSU4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIA/AAAAAAAAgD8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIA/AACAPwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgD8AAAAAAACAPwAAAAAAAAAAAAAAAAAAAAAAAIA/AAAAAAAAgD8AAAAAAACAPwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/Os0TPzrNEz86zRM/Os0TPzrNEz86zRM/Os0TPzrNEz86zRM/AAABAAIAAwAFAAQABgAHAAgACQALAAoAAAAAAA==", + "valid": false + }, + { + "error": "GLB BIN padding must be zero", + "expected_kind": "triangle", + "kind": null, + "name": "bin_nonzero_padding", + "payload_base64": "Z2xURgIAAAAMAwAAoAIAAEpTT057ImFjY2Vzc29ycyI6W3siYnVmZmVyVmlldyI6MCwiY29tcG9uZW50VHlwZSI6NTEyNiwiY291bnQiOjMsIm1heCI6WzEsMSwwXSwibWluIjpbMCwwLDBdLCJ0eXBlIjoiVkVDMyJ9LHsiYnVmZmVyVmlldyI6MSwiY29tcG9uZW50VHlwZSI6NTEyNiwiY291bnQiOjMsInR5cGUiOiJWRUMzIn0seyJidWZmZXJWaWV3IjoyLCJjb21wb25lbnRUeXBlIjo1MTIzLCJjb3VudCI6MywidHlwZSI6IlNDQUxBUiJ9XSwiYXNzZXQiOnsiZ2VuZXJhdG9yIjoiU2ltcGxlQ0FEIFNjZW5lIEdMQiBQcm9maWxlIDEiLCJ2ZXJzaW9uIjoiMi4wIn0sImJ1ZmZlclZpZXdzIjpbeyJidWZmZXIiOjAsImJ5dGVMZW5ndGgiOjM2LCJieXRlT2Zmc2V0IjowLCJ0YXJnZXQiOjM0OTYyfSx7ImJ1ZmZlciI6MCwiYnl0ZUxlbmd0aCI6MzYsImJ5dGVPZmZzZXQiOjM2LCJ0YXJnZXQiOjM0OTYyfSx7ImJ1ZmZlciI6MCwiYnl0ZUxlbmd0aCI6NiwiYnl0ZU9mZnNldCI6NzIsInRhcmdldCI6MzQ5NjN9XSwiYnVmZmVycyI6W3siYnl0ZUxlbmd0aCI6Nzh9XSwibWVzaGVzIjpbeyJwcmltaXRpdmVzIjpbeyJhdHRyaWJ1dGVzIjp7Ik5PUk1BTCI6MSwiUE9TSVRJT04iOjB9LCJpbmRpY2VzIjoyLCJtb2RlIjo0fV19XSwibm9kZXMiOlt7Im1lc2giOjB9XSwic2NlbmUiOjAsInNjZW5lcyI6W3sibm9kZXMiOlswXX1dfSBQAAAAQklOAAAAAAAAAAAAAAAAAAAAgD8AAAAAAAAAAAAAAAAAAIA/AAAAAAAAAAAAAAAAAACAPwAAAAAAAAAAAACAPwAAAAAAAAAAAACAPwAAAQACAAAB", + "valid": false + }, + { + "error": "GLB float buffers must encode zero with positive sign", + "expected_kind": "triangle", + "kind": null, + "name": "position_negative_zero", + "payload_base64": "Z2xURgIAAAAMAwAAoAIAAEpTT057ImFjY2Vzc29ycyI6W3siYnVmZmVyVmlldyI6MCwiY29tcG9uZW50VHlwZSI6NTEyNiwiY291bnQiOjMsIm1heCI6WzEsMSwwXSwibWluIjpbMCwwLDBdLCJ0eXBlIjoiVkVDMyJ9LHsiYnVmZmVyVmlldyI6MSwiY29tcG9uZW50VHlwZSI6NTEyNiwiY291bnQiOjMsInR5cGUiOiJWRUMzIn0seyJidWZmZXJWaWV3IjoyLCJjb21wb25lbnRUeXBlIjo1MTIzLCJjb3VudCI6MywidHlwZSI6IlNDQUxBUiJ9XSwiYXNzZXQiOnsiZ2VuZXJhdG9yIjoiU2ltcGxlQ0FEIFNjZW5lIEdMQiBQcm9maWxlIDEiLCJ2ZXJzaW9uIjoiMi4wIn0sImJ1ZmZlclZpZXdzIjpbeyJidWZmZXIiOjAsImJ5dGVMZW5ndGgiOjM2LCJieXRlT2Zmc2V0IjowLCJ0YXJnZXQiOjM0OTYyfSx7ImJ1ZmZlciI6MCwiYnl0ZUxlbmd0aCI6MzYsImJ5dGVPZmZzZXQiOjM2LCJ0YXJnZXQiOjM0OTYyfSx7ImJ1ZmZlciI6MCwiYnl0ZUxlbmd0aCI6NiwiYnl0ZU9mZnNldCI6NzIsInRhcmdldCI6MzQ5NjN9XSwiYnVmZmVycyI6W3siYnl0ZUxlbmd0aCI6Nzh9XSwibWVzaGVzIjpbeyJwcmltaXRpdmVzIjpbeyJhdHRyaWJ1dGVzIjp7Ik5PUk1BTCI6MSwiUE9TSVRJT04iOjB9LCJpbmRpY2VzIjoyLCJtb2RlIjo0fV19XSwibm9kZXMiOlt7Im1lc2giOjB9XSwic2NlbmUiOjAsInNjZW5lcyI6W3sibm9kZXMiOlswXX1dfSBQAAAAQklOAAAAAIAAAAAAAAAAAAAAgD8AAAAAAAAAAAAAAAAAAIA/AAAAAAAAAAAAAAAAAACAPwAAAAAAAAAAAACAPwAAAAAAAAAAAACAPwAAAQACAAAA", + "valid": false + }, + { + "error": "GLB POSITION contains non-finite values", + "expected_kind": "triangle", + "kind": null, + "name": "position_positive_infinity", + "payload_base64": "Z2xURgIAAAAMAwAAoAIAAEpTT057ImFjY2Vzc29ycyI6W3siYnVmZmVyVmlldyI6MCwiY29tcG9uZW50VHlwZSI6NTEyNiwiY291bnQiOjMsIm1heCI6WzEsMSwwXSwibWluIjpbMCwwLDBdLCJ0eXBlIjoiVkVDMyJ9LHsiYnVmZmVyVmlldyI6MSwiY29tcG9uZW50VHlwZSI6NTEyNiwiY291bnQiOjMsInR5cGUiOiJWRUMzIn0seyJidWZmZXJWaWV3IjoyLCJjb21wb25lbnRUeXBlIjo1MTIzLCJjb3VudCI6MywidHlwZSI6IlNDQUxBUiJ9XSwiYXNzZXQiOnsiZ2VuZXJhdG9yIjoiU2ltcGxlQ0FEIFNjZW5lIEdMQiBQcm9maWxlIDEiLCJ2ZXJzaW9uIjoiMi4wIn0sImJ1ZmZlclZpZXdzIjpbeyJidWZmZXIiOjAsImJ5dGVMZW5ndGgiOjM2LCJieXRlT2Zmc2V0IjowLCJ0YXJnZXQiOjM0OTYyfSx7ImJ1ZmZlciI6MCwiYnl0ZUxlbmd0aCI6MzYsImJ5dGVPZmZzZXQiOjM2LCJ0YXJnZXQiOjM0OTYyfSx7ImJ1ZmZlciI6MCwiYnl0ZUxlbmd0aCI6NiwiYnl0ZU9mZnNldCI6NzIsInRhcmdldCI6MzQ5NjN9XSwiYnVmZmVycyI6W3siYnl0ZUxlbmd0aCI6Nzh9XSwibWVzaGVzIjpbeyJwcmltaXRpdmVzIjpbeyJhdHRyaWJ1dGVzIjp7Ik5PUk1BTCI6MSwiUE9TSVRJT04iOjB9LCJpbmRpY2VzIjoyLCJtb2RlIjo0fV19XSwibm9kZXMiOlt7Im1lc2giOjB9XSwic2NlbmUiOjAsInNjZW5lcyI6W3sibm9kZXMiOlswXX1dfSBQAAAAQklOAAAAgH8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAAAAAAIA/AAAAAAAAAAAAAAAAAACAPwAAAAAAAAAAAACAPwAAAAAAAAAAAACAPwAAAQACAAAA", + "valid": false + }, + { + "error": "GLB POSITION contains non-finite values", + "expected_kind": "triangle", + "kind": null, + "name": "position_negative_infinity", + "payload_base64": "Z2xURgIAAAAMAwAAoAIAAEpTT057ImFjY2Vzc29ycyI6W3siYnVmZmVyVmlldyI6MCwiY29tcG9uZW50VHlwZSI6NTEyNiwiY291bnQiOjMsIm1heCI6WzEsMSwwXSwibWluIjpbMCwwLDBdLCJ0eXBlIjoiVkVDMyJ9LHsiYnVmZmVyVmlldyI6MSwiY29tcG9uZW50VHlwZSI6NTEyNiwiY291bnQiOjMsInR5cGUiOiJWRUMzIn0seyJidWZmZXJWaWV3IjoyLCJjb21wb25lbnRUeXBlIjo1MTIzLCJjb3VudCI6MywidHlwZSI6IlNDQUxBUiJ9XSwiYXNzZXQiOnsiZ2VuZXJhdG9yIjoiU2ltcGxlQ0FEIFNjZW5lIEdMQiBQcm9maWxlIDEiLCJ2ZXJzaW9uIjoiMi4wIn0sImJ1ZmZlclZpZXdzIjpbeyJidWZmZXIiOjAsImJ5dGVMZW5ndGgiOjM2LCJieXRlT2Zmc2V0IjowLCJ0YXJnZXQiOjM0OTYyfSx7ImJ1ZmZlciI6MCwiYnl0ZUxlbmd0aCI6MzYsImJ5dGVPZmZzZXQiOjM2LCJ0YXJnZXQiOjM0OTYyfSx7ImJ1ZmZlciI6MCwiYnl0ZUxlbmd0aCI6NiwiYnl0ZU9mZnNldCI6NzIsInRhcmdldCI6MzQ5NjN9XSwiYnVmZmVycyI6W3siYnl0ZUxlbmd0aCI6Nzh9XSwibWVzaGVzIjpbeyJwcmltaXRpdmVzIjpbeyJhdHRyaWJ1dGVzIjp7Ik5PUk1BTCI6MSwiUE9TSVRJT04iOjB9LCJpbmRpY2VzIjoyLCJtb2RlIjo0fV19XSwibm9kZXMiOlt7Im1lc2giOjB9XSwic2NlbmUiOjAsInNjZW5lcyI6W3sibm9kZXMiOlswXX1dfSBQAAAAQklOAAAAgP8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAAAAAAIA/AAAAAAAAAAAAAAAAAACAPwAAAAAAAAAAAACAPwAAAAAAAAAAAACAPwAAAQACAAAA", + "valid": false + }, + { + "error": "GLB POSITION contains non-finite values", + "expected_kind": "triangle", + "kind": null, + "name": "position_quiet_nan", + "payload_base64": "Z2xURgIAAAAMAwAAoAIAAEpTT057ImFjY2Vzc29ycyI6W3siYnVmZmVyVmlldyI6MCwiY29tcG9uZW50VHlwZSI6NTEyNiwiY291bnQiOjMsIm1heCI6WzEsMSwwXSwibWluIjpbMCwwLDBdLCJ0eXBlIjoiVkVDMyJ9LHsiYnVmZmVyVmlldyI6MSwiY29tcG9uZW50VHlwZSI6NTEyNiwiY291bnQiOjMsInR5cGUiOiJWRUMzIn0seyJidWZmZXJWaWV3IjoyLCJjb21wb25lbnRUeXBlIjo1MTIzLCJjb3VudCI6MywidHlwZSI6IlNDQUxBUiJ9XSwiYXNzZXQiOnsiZ2VuZXJhdG9yIjoiU2ltcGxlQ0FEIFNjZW5lIEdMQiBQcm9maWxlIDEiLCJ2ZXJzaW9uIjoiMi4wIn0sImJ1ZmZlclZpZXdzIjpbeyJidWZmZXIiOjAsImJ5dGVMZW5ndGgiOjM2LCJieXRlT2Zmc2V0IjowLCJ0YXJnZXQiOjM0OTYyfSx7ImJ1ZmZlciI6MCwiYnl0ZUxlbmd0aCI6MzYsImJ5dGVPZmZzZXQiOjM2LCJ0YXJnZXQiOjM0OTYyfSx7ImJ1ZmZlciI6MCwiYnl0ZUxlbmd0aCI6NiwiYnl0ZU9mZnNldCI6NzIsInRhcmdldCI6MzQ5NjN9XSwiYnVmZmVycyI6W3siYnl0ZUxlbmd0aCI6Nzh9XSwibWVzaGVzIjpbeyJwcmltaXRpdmVzIjpbeyJhdHRyaWJ1dGVzIjp7Ik5PUk1BTCI6MSwiUE9TSVRJT04iOjB9LCJpbmRpY2VzIjoyLCJtb2RlIjo0fV19XSwibm9kZXMiOlt7Im1lc2giOjB9XSwic2NlbmUiOjAsInNjZW5lcyI6W3sibm9kZXMiOlswXX1dfSBQAAAAQklOAAAAwH8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAAAAAAIA/AAAAAAAAAAAAAAAAAACAPwAAAAAAAAAAAACAPwAAAAAAAAAAAACAPwAAAQACAAAA", + "valid": false + }, + { + "error": "GLB float buffers must encode zero with positive sign", + "expected_kind": "triangle", + "kind": null, + "name": "normal_negative_zero", + "payload_base64": "Z2xURgIAAAAMAwAAoAIAAEpTT057ImFjY2Vzc29ycyI6W3siYnVmZmVyVmlldyI6MCwiY29tcG9uZW50VHlwZSI6NTEyNiwiY291bnQiOjMsIm1heCI6WzEsMSwwXSwibWluIjpbMCwwLDBdLCJ0eXBlIjoiVkVDMyJ9LHsiYnVmZmVyVmlldyI6MSwiY29tcG9uZW50VHlwZSI6NTEyNiwiY291bnQiOjMsInR5cGUiOiJWRUMzIn0seyJidWZmZXJWaWV3IjoyLCJjb21wb25lbnRUeXBlIjo1MTIzLCJjb3VudCI6MywidHlwZSI6IlNDQUxBUiJ9XSwiYXNzZXQiOnsiZ2VuZXJhdG9yIjoiU2ltcGxlQ0FEIFNjZW5lIEdMQiBQcm9maWxlIDEiLCJ2ZXJzaW9uIjoiMi4wIn0sImJ1ZmZlclZpZXdzIjpbeyJidWZmZXIiOjAsImJ5dGVMZW5ndGgiOjM2LCJieXRlT2Zmc2V0IjowLCJ0YXJnZXQiOjM0OTYyfSx7ImJ1ZmZlciI6MCwiYnl0ZUxlbmd0aCI6MzYsImJ5dGVPZmZzZXQiOjM2LCJ0YXJnZXQiOjM0OTYyfSx7ImJ1ZmZlciI6MCwiYnl0ZUxlbmd0aCI6NiwiYnl0ZU9mZnNldCI6NzIsInRhcmdldCI6MzQ5NjN9XSwiYnVmZmVycyI6W3siYnl0ZUxlbmd0aCI6Nzh9XSwibWVzaGVzIjpbeyJwcmltaXRpdmVzIjpbeyJhdHRyaWJ1dGVzIjp7Ik5PUk1BTCI6MSwiUE9TSVRJT04iOjB9LCJpbmRpY2VzIjoyLCJtb2RlIjo0fV19XSwibm9kZXMiOlt7Im1lc2giOjB9XSwic2NlbmUiOjAsInNjZW5lcyI6W3sibm9kZXMiOlswXX1dfSBQAAAAQklOAAAAAAAAAAAAAAAAAAAAgD8AAAAAAAAAAAAAAAAAAIA/AAAAAAAAAIAAAAAAAACAPwAAAAAAAAAAAACAPwAAAAAAAAAAAACAPwAAAQACAAAA", + "valid": false + }, + { + "error": "GLB NORMAL must be finite and normalized", + "expected_kind": "triangle", + "kind": null, + "name": "normal_nonfinite", + "payload_base64": "Z2xURgIAAAAMAwAAoAIAAEpTT057ImFjY2Vzc29ycyI6W3siYnVmZmVyVmlldyI6MCwiY29tcG9uZW50VHlwZSI6NTEyNiwiY291bnQiOjMsIm1heCI6WzEsMSwwXSwibWluIjpbMCwwLDBdLCJ0eXBlIjoiVkVDMyJ9LHsiYnVmZmVyVmlldyI6MSwiY29tcG9uZW50VHlwZSI6NTEyNiwiY291bnQiOjMsInR5cGUiOiJWRUMzIn0seyJidWZmZXJWaWV3IjoyLCJjb21wb25lbnRUeXBlIjo1MTIzLCJjb3VudCI6MywidHlwZSI6IlNDQUxBUiJ9XSwiYXNzZXQiOnsiZ2VuZXJhdG9yIjoiU2ltcGxlQ0FEIFNjZW5lIEdMQiBQcm9maWxlIDEiLCJ2ZXJzaW9uIjoiMi4wIn0sImJ1ZmZlclZpZXdzIjpbeyJidWZmZXIiOjAsImJ5dGVMZW5ndGgiOjM2LCJieXRlT2Zmc2V0IjowLCJ0YXJnZXQiOjM0OTYyfSx7ImJ1ZmZlciI6MCwiYnl0ZUxlbmd0aCI6MzYsImJ5dGVPZmZzZXQiOjM2LCJ0YXJnZXQiOjM0OTYyfSx7ImJ1ZmZlciI6MCwiYnl0ZUxlbmd0aCI6NiwiYnl0ZU9mZnNldCI6NzIsInRhcmdldCI6MzQ5NjN9XSwiYnVmZmVycyI6W3siYnl0ZUxlbmd0aCI6Nzh9XSwibWVzaGVzIjpbeyJwcmltaXRpdmVzIjpbeyJhdHRyaWJ1dGVzIjp7Ik5PUk1BTCI6MSwiUE9TSVRJT04iOjB9LCJpbmRpY2VzIjoyLCJtb2RlIjo0fV19XSwibm9kZXMiOlt7Im1lc2giOjB9XSwic2NlbmUiOjAsInNjZW5lcyI6W3sibm9kZXMiOlswXX1dfSBQAAAAQklOAAAAAAAAAAAAAAAAAAAAgD8AAAAAAAAAAAAAAAAAAIA/AAAAAAAAAAAAAAAAAADAfwAAAAAAAAAAAACAPwAAAAAAAAAAAACAPwAAAQACAAAA", + "valid": false + }, + { + "error": "GLB NORMAL must be finite and normalized", + "expected_kind": "triangle", + "kind": null, + "name": "normal_norm_below_lower_limit", + "payload_base64": "Z2xURgIAAAAMAwAAoAIAAEpTT057ImFjY2Vzc29ycyI6W3siYnVmZmVyVmlldyI6MCwiY29tcG9uZW50VHlwZSI6NTEyNiwiY291bnQiOjMsIm1heCI6WzEsMSwwXSwibWluIjpbMCwwLDBdLCJ0eXBlIjoiVkVDMyJ9LHsiYnVmZmVyVmlldyI6MSwiY29tcG9uZW50VHlwZSI6NTEyNiwiY291bnQiOjMsInR5cGUiOiJWRUMzIn0seyJidWZmZXJWaWV3IjoyLCJjb21wb25lbnRUeXBlIjo1MTIzLCJjb3VudCI6MywidHlwZSI6IlNDQUxBUiJ9XSwiYXNzZXQiOnsiZ2VuZXJhdG9yIjoiU2ltcGxlQ0FEIFNjZW5lIEdMQiBQcm9maWxlIDEiLCJ2ZXJzaW9uIjoiMi4wIn0sImJ1ZmZlclZpZXdzIjpbeyJidWZmZXIiOjAsImJ5dGVMZW5ndGgiOjM2LCJieXRlT2Zmc2V0IjowLCJ0YXJnZXQiOjM0OTYyfSx7ImJ1ZmZlciI6MCwiYnl0ZUxlbmd0aCI6MzYsImJ5dGVPZmZzZXQiOjM2LCJ0YXJnZXQiOjM0OTYyfSx7ImJ1ZmZlciI6MCwiYnl0ZUxlbmd0aCI6NiwiYnl0ZU9mZnNldCI6NzIsInRhcmdldCI6MzQ5NjN9XSwiYnVmZmVycyI6W3siYnl0ZUxlbmd0aCI6Nzh9XSwibWVzaGVzIjpbeyJwcmltaXRpdmVzIjpbeyJhdHRyaWJ1dGVzIjp7Ik5PUk1BTCI6MSwiUE9TSVRJT04iOjB9LCJpbmRpY2VzIjoyLCJtb2RlIjo0fV19XSwibm9kZXMiOlt7Im1lc2giOjB9XSwic2NlbmUiOjAsInNjZW5lcyI6W3sibm9kZXMiOlswXX1dfSBQAAAAQklOAAAAAAAAAAAAAAAAAAAAgD8AAAAAAAAAAAAAAAAAAIA/AAAAAAAAAAAAAAAA7/9/PwAAAAAAAAAAAACAPwAAAAAAAAAAAACAPwAAAQACAAAA", + "valid": false + }, + { + "error": "GLB NORMAL must be finite and normalized", + "expected_kind": "triangle", + "kind": null, + "name": "normal_norm_above_upper_limit", + "payload_base64": "Z2xURgIAAAAMAwAAoAIAAEpTT057ImFjY2Vzc29ycyI6W3siYnVmZmVyVmlldyI6MCwiY29tcG9uZW50VHlwZSI6NTEyNiwiY291bnQiOjMsIm1heCI6WzEsMSwwXSwibWluIjpbMCwwLDBdLCJ0eXBlIjoiVkVDMyJ9LHsiYnVmZmVyVmlldyI6MSwiY29tcG9uZW50VHlwZSI6NTEyNiwiY291bnQiOjMsInR5cGUiOiJWRUMzIn0seyJidWZmZXJWaWV3IjoyLCJjb21wb25lbnRUeXBlIjo1MTIzLCJjb3VudCI6MywidHlwZSI6IlNDQUxBUiJ9XSwiYXNzZXQiOnsiZ2VuZXJhdG9yIjoiU2ltcGxlQ0FEIFNjZW5lIEdMQiBQcm9maWxlIDEiLCJ2ZXJzaW9uIjoiMi4wIn0sImJ1ZmZlclZpZXdzIjpbeyJidWZmZXIiOjAsImJ5dGVMZW5ndGgiOjM2LCJieXRlT2Zmc2V0IjowLCJ0YXJnZXQiOjM0OTYyfSx7ImJ1ZmZlciI6MCwiYnl0ZUxlbmd0aCI6MzYsImJ5dGVPZmZzZXQiOjM2LCJ0YXJnZXQiOjM0OTYyfSx7ImJ1ZmZlciI6MCwiYnl0ZUxlbmd0aCI6NiwiYnl0ZU9mZnNldCI6NzIsInRhcmdldCI6MzQ5NjN9XSwiYnVmZmVycyI6W3siYnl0ZUxlbmd0aCI6Nzh9XSwibWVzaGVzIjpbeyJwcmltaXRpdmVzIjpbeyJhdHRyaWJ1dGVzIjp7Ik5PUk1BTCI6MSwiUE9TSVRJT04iOjB9LCJpbmRpY2VzIjoyLCJtb2RlIjo0fV19XSwibm9kZXMiOlt7Im1lc2giOjB9XSwic2NlbmUiOjAsInNjZW5lcyI6W3sibm9kZXMiOlswXX1dfSBQAAAAQklOAAAAAAAAAAAAAAAAAAAAgD8AAAAAAAAAAAAAAAAAAIA/AAAAAAAAAAAAAAAACQCAPwAAAAAAAAAAAACAPwAAAAAAAAAAAACAPwAAAQACAAAA", + "valid": false + }, + { + "error": null, + "expected_kind": "triangle", + "kind": "triangle", + "name": "valid_normal_norm_inner_neighbors", + "payload_base64": "Z2xURgIAAAAMAwAAoAIAAEpTT057ImFjY2Vzc29ycyI6W3siYnVmZmVyVmlldyI6MCwiY29tcG9uZW50VHlwZSI6NTEyNiwiY291bnQiOjMsIm1heCI6WzEsMSwwXSwibWluIjpbMCwwLDBdLCJ0eXBlIjoiVkVDMyJ9LHsiYnVmZmVyVmlldyI6MSwiY29tcG9uZW50VHlwZSI6NTEyNiwiY291bnQiOjMsInR5cGUiOiJWRUMzIn0seyJidWZmZXJWaWV3IjoyLCJjb21wb25lbnRUeXBlIjo1MTIzLCJjb3VudCI6MywidHlwZSI6IlNDQUxBUiJ9XSwiYXNzZXQiOnsiZ2VuZXJhdG9yIjoiU2ltcGxlQ0FEIFNjZW5lIEdMQiBQcm9maWxlIDEiLCJ2ZXJzaW9uIjoiMi4wIn0sImJ1ZmZlclZpZXdzIjpbeyJidWZmZXIiOjAsImJ5dGVMZW5ndGgiOjM2LCJieXRlT2Zmc2V0IjowLCJ0YXJnZXQiOjM0OTYyfSx7ImJ1ZmZlciI6MCwiYnl0ZUxlbmd0aCI6MzYsImJ5dGVPZmZzZXQiOjM2LCJ0YXJnZXQiOjM0OTYyfSx7ImJ1ZmZlciI6MCwiYnl0ZUxlbmd0aCI6NiwiYnl0ZU9mZnNldCI6NzIsInRhcmdldCI6MzQ5NjN9XSwiYnVmZmVycyI6W3siYnl0ZUxlbmd0aCI6Nzh9XSwibWVzaGVzIjpbeyJwcmltaXRpdmVzIjpbeyJhdHRyaWJ1dGVzIjp7Ik5PUk1BTCI6MSwiUE9TSVRJT04iOjB9LCJpbmRpY2VzIjoyLCJtb2RlIjo0fV19XSwibm9kZXMiOlt7Im1lc2giOjB9XSwic2NlbmUiOjAsInNjZW5lcyI6W3sibm9kZXMiOlswXX1dfSBQAAAAQklOAAAAAAAAAAAAAAAAAAAAgD8AAAAAAAAAAAAAAAAAAIA/AAAAAAAAAAAAAAAA8P9/PwAAAAAAAAAACACAPwAAAAAAAAAAAACAPwAAAQACAAAA", + "valid": true + } + ], + "jcs_vectors": [ + { + "canonical_base64": "eyJsaXRlcmFscyI6W251bGwsdHJ1ZSxmYWxzZV0sIm51bWJlcnMiOlszMzMzMzMzMzMuMzMzMzMzMywxZSszMCw0LjUsMC4wMDIsMWUtMjddLCJzdHJpbmciOiLigqwkXHUwMDBmXG5BJ0JcIlxcXFxcIi8ifQ==", + "input": { + "literals": [ + null, + true, + false + ], + "numbers": [ + 333333333.3333333, + 1e+30, + 4.5, + 0.002, + 1e-27 + ], + "string": "€$\u000f\nA'B\"\\\\\"/" + }, + "name": "rfc8785_number_and_string_sample", + "sha256": "sha256:2d5e01a318d0f0879ab568c4be289c8b1f64ef8921a53c6277d5e069978baacb" + } + ], + "manifest_cases": [ + { + "expected": null, + "name": "valid_scene", + "payload_base64": "eyJhbm5vdGF0aW9ucyI6W10sImFwcGVhcmFuY2VzIjpbeyJhbHBoYV9tb2RlIjoib3BhcXVlIiwiYXBwZWFyYW5jZV9pZCI6ImFwcGVhcmFuY2UvZXZhbHVhdGVkL2JmMWNhNmQ2MGFmMjE3NjdhY2UxNDE5YjYwNTUyMTdjODcxNDU4OWI3OTA5MGQyNzBhYWJlNTEyMjMwZWZhM2EiLCJiYXNlX2NvbG9yIjpbMC43MiwwLjc1LDAuNzgsMV0sImRvdWJsZV9zaWRlZCI6ZmFsc2UsImVkZ2VfY29sb3IiOlswLjA4LDAuMDksMC4xLDFdLCJtZXRhbGxpYyI6MCwibmFtZSI6bnVsbCwicm91Z2huZXNzIjowLjU1LCJzZGtfbWV0YWRhdGEiOnt9LCJzb3VyY2UiOm51bGx9XSwiY2FtZXJhcyI6W10sImNvbXBpbGVfb3B0aW9ucyI6eyJhbmd1bGFyX3RvbGVyYW5jZSI6MC4yMiwiZW1iZWRfcHJlc2VudGF0aW9uIjpmYWxzZSwiZW1iZWRfc291cmNlIjpmYWxzZSwibGluZWFyX3RvbGVyYW5jZSI6MC4zNX0sImNvbm5lY3RvcnMiOltdLCJjb29yZGluYXRlX3N5c3RlbSI6eyJoYW5kZWRuZXNzIjoicmlnaHQiLCJsZW5ndGhfdW5pdCI6Im1tIiwidXBfYXhpcyI6IitaIn0sImRlZmluaXRpb25zIjpbeyJhcHBlYXJhbmNlX2lkIjoiYXBwZWFyYW5jZS9ldmFsdWF0ZWQvYmYxY2E2ZDYwYWYyMTc2N2FjZTE0MTliNjA1NTIxN2M4NzE0NTg5Yjc5MDkwZDI3MGFhYmU1MTIyMzBlZmEzYSIsImRlZmluaXRpb25faWQiOiJkZWZpbml0aW9uL3Jvb3Qvc2hhcGUvbWFudWFsL2ZpeHR1cmUiLCJlZGdlX2Fzc2V0X2lkIjoic2hhMjU2OjZjZTQ4MWRiMWZlMTNiY2ZlN2EyMzc0NDIzZTZjNzJkNzU3ZDUxYzk5MTY4YjI4N2M1MmY1M2FhNjI1Y2NkNjciLCJlbnRpdHlfYXNzZXRfaWQiOiJzaGEyNTY6Mzg5N2NjOWViZGIyOTQxODM1OTY0YTQzNTgyMWI1NTZlMDFiMTc1NTM1Y2ZmOGZjMGVhYWFmYjY4NGM0ZGRmNSIsImdlb21ldHJ5X2Fzc2V0X2lkIjoic2hhMjU2OjIwMDUyMzQxNGRlMjgxYThhMTU1MDg2YTU0Y2QwZjg4MGY2NDA0ODU3NjVhNDhjY2RkOGVjMjMwZjY3ZGQxN2MiLCJraW5kIjoic2hhcGUiLCJuYW1lIjpudWxsLCJzZGtfbWV0YWRhdGEiOnt9LCJzb3VyY2UiOnsia2luZCI6Im1hbnVhbCIsInJvb3RfaWQiOiJyb290Iiwic291cmNlX2lkIjoiZml4dHVyZSJ9fV0sImRpYWdub3N0aWNzIjpbXSwiZWRnZV9hc3NldHMiOlt7ImFzc2V0X2lkIjoic2hhMjU2OjZjZTQ4MWRiMWZlMTNiY2ZlN2EyMzc0NDIzZTZjNzJkNzU3ZDUxYzk5MTY4YjI4N2M1MmY1M2FhNjI1Y2NkNjciLCJhc3NldF90b19zY2VuZSI6WzEwMDAsMCwwLDAsMCwwLC0xMDAwLDAsMCwxMDAwLDAsMCwwLDAsMCwxXSwiYnl0ZV9sZW5ndGgiOjc0MCwiY29udGVudF9oYXNoIjoic2hhMjU2OjZjZTQ4MWRiMWZlMTNiY2ZlN2EyMzc0NDIzZTZjNzJkNzU3ZDUxYzk5MTY4YjI4N2M1MmY1M2FhNjI1Y2NkNjciLCJtZWRpYV90eXBlIjoibW9kZWwvZ2x0Zi1iaW5hcnkiLCJzY2VuZV9sb2NhbF9ib3VuZHMiOnsibWF4IjpbMTAwMCwwLDEwMDBdLCJtaW4iOlswLC0xMDAwLDBdfSwidGVzc2VsbGF0aW9uIjp7ImxpbmVhcl90b2xlcmFuY2UiOjAuMzV9LCJ1cmkiOiJlZGdlcy9zaGEyNTYtNmNlNDgxZGIxZmUxM2JjZmU3YTIzNzQ0MjNlNmM3MmQ3NTdkNTFjOTkxNjhiMjg3YzUyZjUzYWE2MjVjY2Q2Ny5nbGIifV0sImVudGl0eV9hc3NldHMiOlt7ImJ5dGVfbGVuZ3RoIjoxMTUxNywiY29udGVudF9oYXNoIjoic2hhMjU2OjM4OTdjYzllYmRiMjk0MTgzNTk2NGE0MzU4MjFiNTU2ZTAxYjE3NTUzNWNmZjhmYzBlYWFhZmI2ODRjNGRkZjUiLCJlbnRpdHlfYXNzZXRfaWQiOiJzaGEyNTY6Mzg5N2NjOWViZGIyOTQxODM1OTY0YTQzNTgyMWI1NTZlMDFiMTc1NTM1Y2ZmOGZjMGVhYWFmYjY4NGM0ZGRmNSIsIm1lZGlhX3R5cGUiOiJhcHBsaWNhdGlvbi92bmQuc2ltcGxlY2FkLmVudGl0aWVzK2pzb24iLCJ1cmkiOiJlbnRpdGllcy9zaGEyNTYtMzg5N2NjOWViZGIyOTQxODM1OTY0YTQzNTgyMWI1NTZlMDFiMTc1NTM1Y2ZmOGZjMGVhYWFmYjY4NGM0ZGRmNS5qc29uIn1dLCJleHRlbnNpb25zIjp7fSwiZXh0ZW5zaW9uc19yZXF1aXJlZCI6W10sImV4dGVuc2lvbnNfdXNlZCI6W10sImdlbmVyYXRvciI6eyJuYW1lIjoic2ltcGxlY2FkYXBpIiwib2NwX2JpbmRpbmdzX3ZlcnNpb24iOiJmaXh0dXJlIiwib2NwX3ZlcnNpb24iOiJmaXh0dXJlIiwicGxhdGZvcm1fdGFnIjoiZml4dHVyZSIsInByb2ZpbGUiOiJzY2VuZS0xLjAtb2NwLWdsYi0yIiwicHl0aG9uX2FiaSI6ImZpeHR1cmUiLCJzaW1wbGVjYWRhcGlfdmVyc2lvbiI6ImZpeHR1cmUiLCJ0b29sY2hhaW5faGFzaCI6InNoYTI1NjowMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwIn0sImdlb21ldHJ5X2Fzc2V0cyI6W3siYXNzZXRfaWQiOiJzaGEyNTY6MjAwNTIzNDE0ZGUyODFhOGExNTUwODZhNTRjZDBmODgwZjY0MDQ4NTc2NWE0OGNjZGQ4ZWMyMzBmNjdkZDE3YyIsImFzc2V0X3RvX3NjZW5lIjpbMTAwMCwwLDAsMCwwLDAsLTEwMDAsMCwwLDEwMDAsMCwwLDAsMCwwLDFdLCJieXRlX2xlbmd0aCI6MTAyMCwiY29udGVudF9oYXNoIjoic2hhMjU2OjIwMDUyMzQxNGRlMjgxYThhMTU1MDg2YTU0Y2QwZjg4MGY2NDA0ODU3NjVhNDhjY2RkOGVjMjMwZjY3ZGQxN2MiLCJtZWRpYV90eXBlIjoibW9kZWwvZ2x0Zi1iaW5hcnkiLCJzY2VuZV9sb2NhbF9ib3VuZHMiOnsibWF4IjpbMTAwMCwwLDEwMDBdLCJtaW4iOlswLC0xMDAwLDBdfSwidGVzc2VsbGF0aW9uIjp7ImFuZ3VsYXJfdG9sZXJhbmNlIjowLjIyLCJsaW5lYXJfdG9sZXJhbmNlIjowLjM1fSwidXJpIjoiZ2VvbWV0cnkvc2hhMjU2LTIwMDUyMzQxNGRlMjgxYThhMTU1MDg2YTU0Y2QwZjg4MGY2NDA0ODU3NjVhNDhjY2RkOGVjMjMwZjY3ZGQxN2MuZ2xiIn1dLCJsaWdodHMiOltdLCJub2RlcyI6W3siYXBwZWFyYW5jZV9vdmVycmlkZV9pZCI6bnVsbCwiZGVmaW5pdGlvbl9pZCI6ImRlZmluaXRpb24vcm9vdC9zaGFwZS9tYW51YWwvZml4dHVyZSIsIm5hbWUiOm51bGwsIm5vZGVfaWQiOiJpbnN0YW5jZS9yb290Iiwib3JkZXIiOjAsInBhcmVudF9ub2RlX2lkIjpudWxsLCJzZGtfbWV0YWRhdGEiOnt9LCJzZWxlY3RhYmxlIjp0cnVlLCJzb3VyY2UiOnsia2luZCI6InNoYXBlX3Jvb3QiLCJyb290X2lkIjoicm9vdCJ9LCJ0cmFuc2Zvcm0iOnsib3JpZ2luIjpbMCwwLDBdLCJ4X2F4aXMiOlsxLDAsMF0sInlfYXhpcyI6WzAsMSwwXSwiel9heGlzIjpbMCwwLDFdfSwidmlzaWJsZSI6dHJ1ZX1dLCJyZXZpc2lvbiI6InNoYTI1NjpmYWUxNWY0YWVlZjEyNzdjZDgzMWZmOGJmODNmOWIyNDRkNWM0MjI1ZTcyZWI3YmMyZTBlMzVkZDZjZjQzNjE0Iiwic2NlbmVfaWQiOiJmaXh0dXJlIiwic2NoZW1hX3ZlcnNpb24iOiIxLjAiLCJzb3VyY2UiOnsia2luZCI6Im1hbnVhbCIsInNvdXJjZV9pZCI6ImZpeHR1cmUifX0=", + "valid": true + }, + { + "expected": { + "code": "schema_invalid", + "path": "" + }, + "name": "missing_nodes", + "payload_base64": "eyJhbm5vdGF0aW9ucyI6W10sImFwcGVhcmFuY2VzIjpbeyJhbHBoYV9tb2RlIjoib3BhcXVlIiwiYXBwZWFyYW5jZV9pZCI6ImFwcGVhcmFuY2UvZXZhbHVhdGVkL2JmMWNhNmQ2MGFmMjE3NjdhY2UxNDE5YjYwNTUyMTdjODcxNDU4OWI3OTA5MGQyNzBhYWJlNTEyMjMwZWZhM2EiLCJiYXNlX2NvbG9yIjpbMC43MiwwLjc1LDAuNzgsMV0sImRvdWJsZV9zaWRlZCI6ZmFsc2UsImVkZ2VfY29sb3IiOlswLjA4LDAuMDksMC4xLDFdLCJtZXRhbGxpYyI6MCwibmFtZSI6bnVsbCwicm91Z2huZXNzIjowLjU1LCJzZGtfbWV0YWRhdGEiOnt9LCJzb3VyY2UiOm51bGx9XSwiY2FtZXJhcyI6W10sImNvbXBpbGVfb3B0aW9ucyI6eyJhbmd1bGFyX3RvbGVyYW5jZSI6MC4yMiwiZW1iZWRfcHJlc2VudGF0aW9uIjpmYWxzZSwiZW1iZWRfc291cmNlIjpmYWxzZSwibGluZWFyX3RvbGVyYW5jZSI6MC4zNX0sImNvbm5lY3RvcnMiOltdLCJjb29yZGluYXRlX3N5c3RlbSI6eyJoYW5kZWRuZXNzIjoicmlnaHQiLCJsZW5ndGhfdW5pdCI6Im1tIiwidXBfYXhpcyI6IitaIn0sImRlZmluaXRpb25zIjpbeyJhcHBlYXJhbmNlX2lkIjoiYXBwZWFyYW5jZS9ldmFsdWF0ZWQvYmYxY2E2ZDYwYWYyMTc2N2FjZTE0MTliNjA1NTIxN2M4NzE0NTg5Yjc5MDkwZDI3MGFhYmU1MTIyMzBlZmEzYSIsImRlZmluaXRpb25faWQiOiJkZWZpbml0aW9uL3Jvb3Qvc2hhcGUvbWFudWFsL2ZpeHR1cmUiLCJlZGdlX2Fzc2V0X2lkIjoic2hhMjU2OjZjZTQ4MWRiMWZlMTNiY2ZlN2EyMzc0NDIzZTZjNzJkNzU3ZDUxYzk5MTY4YjI4N2M1MmY1M2FhNjI1Y2NkNjciLCJlbnRpdHlfYXNzZXRfaWQiOiJzaGEyNTY6Mzg5N2NjOWViZGIyOTQxODM1OTY0YTQzNTgyMWI1NTZlMDFiMTc1NTM1Y2ZmOGZjMGVhYWFmYjY4NGM0ZGRmNSIsImdlb21ldHJ5X2Fzc2V0X2lkIjoic2hhMjU2OjIwMDUyMzQxNGRlMjgxYThhMTU1MDg2YTU0Y2QwZjg4MGY2NDA0ODU3NjVhNDhjY2RkOGVjMjMwZjY3ZGQxN2MiLCJraW5kIjoic2hhcGUiLCJuYW1lIjpudWxsLCJzZGtfbWV0YWRhdGEiOnt9LCJzb3VyY2UiOnsia2luZCI6Im1hbnVhbCIsInJvb3RfaWQiOiJyb290Iiwic291cmNlX2lkIjoiZml4dHVyZSJ9fV0sImRpYWdub3N0aWNzIjpbXSwiZWRnZV9hc3NldHMiOlt7ImFzc2V0X2lkIjoic2hhMjU2OjZjZTQ4MWRiMWZlMTNiY2ZlN2EyMzc0NDIzZTZjNzJkNzU3ZDUxYzk5MTY4YjI4N2M1MmY1M2FhNjI1Y2NkNjciLCJhc3NldF90b19zY2VuZSI6WzEwMDAsMCwwLDAsMCwwLC0xMDAwLDAsMCwxMDAwLDAsMCwwLDAsMCwxXSwiYnl0ZV9sZW5ndGgiOjc0MCwiY29udGVudF9oYXNoIjoic2hhMjU2OjZjZTQ4MWRiMWZlMTNiY2ZlN2EyMzc0NDIzZTZjNzJkNzU3ZDUxYzk5MTY4YjI4N2M1MmY1M2FhNjI1Y2NkNjciLCJtZWRpYV90eXBlIjoibW9kZWwvZ2x0Zi1iaW5hcnkiLCJzY2VuZV9sb2NhbF9ib3VuZHMiOnsibWF4IjpbMTAwMCwwLDEwMDBdLCJtaW4iOlswLC0xMDAwLDBdfSwidGVzc2VsbGF0aW9uIjp7ImxpbmVhcl90b2xlcmFuY2UiOjAuMzV9LCJ1cmkiOiJlZGdlcy9zaGEyNTYtNmNlNDgxZGIxZmUxM2JjZmU3YTIzNzQ0MjNlNmM3MmQ3NTdkNTFjOTkxNjhiMjg3YzUyZjUzYWE2MjVjY2Q2Ny5nbGIifV0sImVudGl0eV9hc3NldHMiOlt7ImJ5dGVfbGVuZ3RoIjoxMTUxNywiY29udGVudF9oYXNoIjoic2hhMjU2OjM4OTdjYzllYmRiMjk0MTgzNTk2NGE0MzU4MjFiNTU2ZTAxYjE3NTUzNWNmZjhmYzBlYWFhZmI2ODRjNGRkZjUiLCJlbnRpdHlfYXNzZXRfaWQiOiJzaGEyNTY6Mzg5N2NjOWViZGIyOTQxODM1OTY0YTQzNTgyMWI1NTZlMDFiMTc1NTM1Y2ZmOGZjMGVhYWFmYjY4NGM0ZGRmNSIsIm1lZGlhX3R5cGUiOiJhcHBsaWNhdGlvbi92bmQuc2ltcGxlY2FkLmVudGl0aWVzK2pzb24iLCJ1cmkiOiJlbnRpdGllcy9zaGEyNTYtMzg5N2NjOWViZGIyOTQxODM1OTY0YTQzNTgyMWI1NTZlMDFiMTc1NTM1Y2ZmOGZjMGVhYWFmYjY4NGM0ZGRmNS5qc29uIn1dLCJleHRlbnNpb25zIjp7fSwiZXh0ZW5zaW9uc19yZXF1aXJlZCI6W10sImV4dGVuc2lvbnNfdXNlZCI6W10sImdlbmVyYXRvciI6eyJuYW1lIjoic2ltcGxlY2FkYXBpIiwib2NwX2JpbmRpbmdzX3ZlcnNpb24iOiJmaXh0dXJlIiwib2NwX3ZlcnNpb24iOiJmaXh0dXJlIiwicGxhdGZvcm1fdGFnIjoiZml4dHVyZSIsInByb2ZpbGUiOiJzY2VuZS0xLjAtb2NwLWdsYi0yIiwicHl0aG9uX2FiaSI6ImZpeHR1cmUiLCJzaW1wbGVjYWRhcGlfdmVyc2lvbiI6ImZpeHR1cmUiLCJ0b29sY2hhaW5faGFzaCI6InNoYTI1NjowMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwIn0sImdlb21ldHJ5X2Fzc2V0cyI6W3siYXNzZXRfaWQiOiJzaGEyNTY6MjAwNTIzNDE0ZGUyODFhOGExNTUwODZhNTRjZDBmODgwZjY0MDQ4NTc2NWE0OGNjZGQ4ZWMyMzBmNjdkZDE3YyIsImFzc2V0X3RvX3NjZW5lIjpbMTAwMCwwLDAsMCwwLDAsLTEwMDAsMCwwLDEwMDAsMCwwLDAsMCwwLDFdLCJieXRlX2xlbmd0aCI6MTAyMCwiY29udGVudF9oYXNoIjoic2hhMjU2OjIwMDUyMzQxNGRlMjgxYThhMTU1MDg2YTU0Y2QwZjg4MGY2NDA0ODU3NjVhNDhjY2RkOGVjMjMwZjY3ZGQxN2MiLCJtZWRpYV90eXBlIjoibW9kZWwvZ2x0Zi1iaW5hcnkiLCJzY2VuZV9sb2NhbF9ib3VuZHMiOnsibWF4IjpbMTAwMCwwLDEwMDBdLCJtaW4iOlswLC0xMDAwLDBdfSwidGVzc2VsbGF0aW9uIjp7ImFuZ3VsYXJfdG9sZXJhbmNlIjowLjIyLCJsaW5lYXJfdG9sZXJhbmNlIjowLjM1fSwidXJpIjoiZ2VvbWV0cnkvc2hhMjU2LTIwMDUyMzQxNGRlMjgxYThhMTU1MDg2YTU0Y2QwZjg4MGY2NDA0ODU3NjVhNDhjY2RkOGVjMjMwZjY3ZGQxN2MuZ2xiIn1dLCJsaWdodHMiOltdLCJyZXZpc2lvbiI6InNoYTI1NjpmYWUxNWY0YWVlZjEyNzdjZDgzMWZmOGJmODNmOWIyNDRkNWM0MjI1ZTcyZWI3YmMyZTBlMzVkZDZjZjQzNjE0Iiwic2NlbmVfaWQiOiJmaXh0dXJlIiwic2NoZW1hX3ZlcnNpb24iOiIxLjAiLCJzb3VyY2UiOnsia2luZCI6Im1hbnVhbCIsInNvdXJjZV9pZCI6ImZpeHR1cmUifX0=", + "valid": false + }, + { + "expected": { + "code": "hierarchy_invalid", + "path": "/nodes" + }, + "name": "empty_nodes", + "payload_base64": "eyJhbm5vdGF0aW9ucyI6W10sImFwcGVhcmFuY2VzIjpbeyJhbHBoYV9tb2RlIjoib3BhcXVlIiwiYXBwZWFyYW5jZV9pZCI6ImFwcGVhcmFuY2UvZXZhbHVhdGVkL2JmMWNhNmQ2MGFmMjE3NjdhY2UxNDE5YjYwNTUyMTdjODcxNDU4OWI3OTA5MGQyNzBhYWJlNTEyMjMwZWZhM2EiLCJiYXNlX2NvbG9yIjpbMC43MiwwLjc1LDAuNzgsMV0sImRvdWJsZV9zaWRlZCI6ZmFsc2UsImVkZ2VfY29sb3IiOlswLjA4LDAuMDksMC4xLDFdLCJtZXRhbGxpYyI6MCwibmFtZSI6bnVsbCwicm91Z2huZXNzIjowLjU1LCJzZGtfbWV0YWRhdGEiOnt9LCJzb3VyY2UiOm51bGx9XSwiY2FtZXJhcyI6W10sImNvbXBpbGVfb3B0aW9ucyI6eyJhbmd1bGFyX3RvbGVyYW5jZSI6MC4yMiwiZW1iZWRfcHJlc2VudGF0aW9uIjpmYWxzZSwiZW1iZWRfc291cmNlIjpmYWxzZSwibGluZWFyX3RvbGVyYW5jZSI6MC4zNX0sImNvbm5lY3RvcnMiOltdLCJjb29yZGluYXRlX3N5c3RlbSI6eyJoYW5kZWRuZXNzIjoicmlnaHQiLCJsZW5ndGhfdW5pdCI6Im1tIiwidXBfYXhpcyI6IitaIn0sImRlZmluaXRpb25zIjpbeyJhcHBlYXJhbmNlX2lkIjoiYXBwZWFyYW5jZS9ldmFsdWF0ZWQvYmYxY2E2ZDYwYWYyMTc2N2FjZTE0MTliNjA1NTIxN2M4NzE0NTg5Yjc5MDkwZDI3MGFhYmU1MTIyMzBlZmEzYSIsImRlZmluaXRpb25faWQiOiJkZWZpbml0aW9uL3Jvb3Qvc2hhcGUvbWFudWFsL2ZpeHR1cmUiLCJlZGdlX2Fzc2V0X2lkIjoic2hhMjU2OjZjZTQ4MWRiMWZlMTNiY2ZlN2EyMzc0NDIzZTZjNzJkNzU3ZDUxYzk5MTY4YjI4N2M1MmY1M2FhNjI1Y2NkNjciLCJlbnRpdHlfYXNzZXRfaWQiOiJzaGEyNTY6Mzg5N2NjOWViZGIyOTQxODM1OTY0YTQzNTgyMWI1NTZlMDFiMTc1NTM1Y2ZmOGZjMGVhYWFmYjY4NGM0ZGRmNSIsImdlb21ldHJ5X2Fzc2V0X2lkIjoic2hhMjU2OjIwMDUyMzQxNGRlMjgxYThhMTU1MDg2YTU0Y2QwZjg4MGY2NDA0ODU3NjVhNDhjY2RkOGVjMjMwZjY3ZGQxN2MiLCJraW5kIjoic2hhcGUiLCJuYW1lIjpudWxsLCJzZGtfbWV0YWRhdGEiOnt9LCJzb3VyY2UiOnsia2luZCI6Im1hbnVhbCIsInJvb3RfaWQiOiJyb290Iiwic291cmNlX2lkIjoiZml4dHVyZSJ9fV0sImRpYWdub3N0aWNzIjpbXSwiZWRnZV9hc3NldHMiOlt7ImFzc2V0X2lkIjoic2hhMjU2OjZjZTQ4MWRiMWZlMTNiY2ZlN2EyMzc0NDIzZTZjNzJkNzU3ZDUxYzk5MTY4YjI4N2M1MmY1M2FhNjI1Y2NkNjciLCJhc3NldF90b19zY2VuZSI6WzEwMDAsMCwwLDAsMCwwLC0xMDAwLDAsMCwxMDAwLDAsMCwwLDAsMCwxXSwiYnl0ZV9sZW5ndGgiOjc0MCwiY29udGVudF9oYXNoIjoic2hhMjU2OjZjZTQ4MWRiMWZlMTNiY2ZlN2EyMzc0NDIzZTZjNzJkNzU3ZDUxYzk5MTY4YjI4N2M1MmY1M2FhNjI1Y2NkNjciLCJtZWRpYV90eXBlIjoibW9kZWwvZ2x0Zi1iaW5hcnkiLCJzY2VuZV9sb2NhbF9ib3VuZHMiOnsibWF4IjpbMTAwMCwwLDEwMDBdLCJtaW4iOlswLC0xMDAwLDBdfSwidGVzc2VsbGF0aW9uIjp7ImxpbmVhcl90b2xlcmFuY2UiOjAuMzV9LCJ1cmkiOiJlZGdlcy9zaGEyNTYtNmNlNDgxZGIxZmUxM2JjZmU3YTIzNzQ0MjNlNmM3MmQ3NTdkNTFjOTkxNjhiMjg3YzUyZjUzYWE2MjVjY2Q2Ny5nbGIifV0sImVudGl0eV9hc3NldHMiOlt7ImJ5dGVfbGVuZ3RoIjoxMTUxNywiY29udGVudF9oYXNoIjoic2hhMjU2OjM4OTdjYzllYmRiMjk0MTgzNTk2NGE0MzU4MjFiNTU2ZTAxYjE3NTUzNWNmZjhmYzBlYWFhZmI2ODRjNGRkZjUiLCJlbnRpdHlfYXNzZXRfaWQiOiJzaGEyNTY6Mzg5N2NjOWViZGIyOTQxODM1OTY0YTQzNTgyMWI1NTZlMDFiMTc1NTM1Y2ZmOGZjMGVhYWFmYjY4NGM0ZGRmNSIsIm1lZGlhX3R5cGUiOiJhcHBsaWNhdGlvbi92bmQuc2ltcGxlY2FkLmVudGl0aWVzK2pzb24iLCJ1cmkiOiJlbnRpdGllcy9zaGEyNTYtMzg5N2NjOWViZGIyOTQxODM1OTY0YTQzNTgyMWI1NTZlMDFiMTc1NTM1Y2ZmOGZjMGVhYWFmYjY4NGM0ZGRmNS5qc29uIn1dLCJleHRlbnNpb25zIjp7fSwiZXh0ZW5zaW9uc19yZXF1aXJlZCI6W10sImV4dGVuc2lvbnNfdXNlZCI6W10sImdlbmVyYXRvciI6eyJuYW1lIjoic2ltcGxlY2FkYXBpIiwib2NwX2JpbmRpbmdzX3ZlcnNpb24iOiJmaXh0dXJlIiwib2NwX3ZlcnNpb24iOiJmaXh0dXJlIiwicGxhdGZvcm1fdGFnIjoiZml4dHVyZSIsInByb2ZpbGUiOiJzY2VuZS0xLjAtb2NwLWdsYi0yIiwicHl0aG9uX2FiaSI6ImZpeHR1cmUiLCJzaW1wbGVjYWRhcGlfdmVyc2lvbiI6ImZpeHR1cmUiLCJ0b29sY2hhaW5faGFzaCI6InNoYTI1NjowMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwIn0sImdlb21ldHJ5X2Fzc2V0cyI6W3siYXNzZXRfaWQiOiJzaGEyNTY6MjAwNTIzNDE0ZGUyODFhOGExNTUwODZhNTRjZDBmODgwZjY0MDQ4NTc2NWE0OGNjZGQ4ZWMyMzBmNjdkZDE3YyIsImFzc2V0X3RvX3NjZW5lIjpbMTAwMCwwLDAsMCwwLDAsLTEwMDAsMCwwLDEwMDAsMCwwLDAsMCwwLDFdLCJieXRlX2xlbmd0aCI6MTAyMCwiY29udGVudF9oYXNoIjoic2hhMjU2OjIwMDUyMzQxNGRlMjgxYThhMTU1MDg2YTU0Y2QwZjg4MGY2NDA0ODU3NjVhNDhjY2RkOGVjMjMwZjY3ZGQxN2MiLCJtZWRpYV90eXBlIjoibW9kZWwvZ2x0Zi1iaW5hcnkiLCJzY2VuZV9sb2NhbF9ib3VuZHMiOnsibWF4IjpbMTAwMCwwLDEwMDBdLCJtaW4iOlswLC0xMDAwLDBdfSwidGVzc2VsbGF0aW9uIjp7ImFuZ3VsYXJfdG9sZXJhbmNlIjowLjIyLCJsaW5lYXJfdG9sZXJhbmNlIjowLjM1fSwidXJpIjoiZ2VvbWV0cnkvc2hhMjU2LTIwMDUyMzQxNGRlMjgxYThhMTU1MDg2YTU0Y2QwZjg4MGY2NDA0ODU3NjVhNDhjY2RkOGVjMjMwZjY3ZGQxN2MuZ2xiIn1dLCJsaWdodHMiOltdLCJub2RlcyI6W10sInJldmlzaW9uIjoic2hhMjU2OjU2NjYwODY4ODJlYjk2YzY0MjZiMWYzZDExODc2YTYxMWVkZmIwM2I1YjgzMmY4OTRiNjg4NDFjZWRmODU3NzIiLCJzY2VuZV9pZCI6ImZpeHR1cmUiLCJzY2hlbWFfdmVyc2lvbiI6IjEuMCIsInNvdXJjZSI6eyJraW5kIjoibWFudWFsIiwic291cmNlX2lkIjoiZml4dHVyZSJ9fQ==", + "valid": false + }, + { + "expected": { + "code": "revision_mismatch", + "path": "/revision" + }, + "name": "revision_mismatch", + "payload_base64": "eyJhbm5vdGF0aW9ucyI6W10sImFwcGVhcmFuY2VzIjpbeyJhbHBoYV9tb2RlIjoib3BhcXVlIiwiYXBwZWFyYW5jZV9pZCI6ImFwcGVhcmFuY2UvZXZhbHVhdGVkL2JmMWNhNmQ2MGFmMjE3NjdhY2UxNDE5YjYwNTUyMTdjODcxNDU4OWI3OTA5MGQyNzBhYWJlNTEyMjMwZWZhM2EiLCJiYXNlX2NvbG9yIjpbMC43MiwwLjc1LDAuNzgsMV0sImRvdWJsZV9zaWRlZCI6ZmFsc2UsImVkZ2VfY29sb3IiOlswLjA4LDAuMDksMC4xLDFdLCJtZXRhbGxpYyI6MCwibmFtZSI6bnVsbCwicm91Z2huZXNzIjowLjU1LCJzZGtfbWV0YWRhdGEiOnt9LCJzb3VyY2UiOm51bGx9XSwiY2FtZXJhcyI6W10sImNvbXBpbGVfb3B0aW9ucyI6eyJhbmd1bGFyX3RvbGVyYW5jZSI6MC4yMiwiZW1iZWRfcHJlc2VudGF0aW9uIjpmYWxzZSwiZW1iZWRfc291cmNlIjpmYWxzZSwibGluZWFyX3RvbGVyYW5jZSI6MC4zNX0sImNvbm5lY3RvcnMiOltdLCJjb29yZGluYXRlX3N5c3RlbSI6eyJoYW5kZWRuZXNzIjoicmlnaHQiLCJsZW5ndGhfdW5pdCI6Im1tIiwidXBfYXhpcyI6IitaIn0sImRlZmluaXRpb25zIjpbeyJhcHBlYXJhbmNlX2lkIjoiYXBwZWFyYW5jZS9ldmFsdWF0ZWQvYmYxY2E2ZDYwYWYyMTc2N2FjZTE0MTliNjA1NTIxN2M4NzE0NTg5Yjc5MDkwZDI3MGFhYmU1MTIyMzBlZmEzYSIsImRlZmluaXRpb25faWQiOiJkZWZpbml0aW9uL3Jvb3Qvc2hhcGUvbWFudWFsL2ZpeHR1cmUiLCJlZGdlX2Fzc2V0X2lkIjoic2hhMjU2OjZjZTQ4MWRiMWZlMTNiY2ZlN2EyMzc0NDIzZTZjNzJkNzU3ZDUxYzk5MTY4YjI4N2M1MmY1M2FhNjI1Y2NkNjciLCJlbnRpdHlfYXNzZXRfaWQiOiJzaGEyNTY6Mzg5N2NjOWViZGIyOTQxODM1OTY0YTQzNTgyMWI1NTZlMDFiMTc1NTM1Y2ZmOGZjMGVhYWFmYjY4NGM0ZGRmNSIsImdlb21ldHJ5X2Fzc2V0X2lkIjoic2hhMjU2OjIwMDUyMzQxNGRlMjgxYThhMTU1MDg2YTU0Y2QwZjg4MGY2NDA0ODU3NjVhNDhjY2RkOGVjMjMwZjY3ZGQxN2MiLCJraW5kIjoic2hhcGUiLCJuYW1lIjpudWxsLCJzZGtfbWV0YWRhdGEiOnt9LCJzb3VyY2UiOnsia2luZCI6Im1hbnVhbCIsInJvb3RfaWQiOiJyb290Iiwic291cmNlX2lkIjoiZml4dHVyZSJ9fV0sImRpYWdub3N0aWNzIjpbXSwiZWRnZV9hc3NldHMiOlt7ImFzc2V0X2lkIjoic2hhMjU2OjZjZTQ4MWRiMWZlMTNiY2ZlN2EyMzc0NDIzZTZjNzJkNzU3ZDUxYzk5MTY4YjI4N2M1MmY1M2FhNjI1Y2NkNjciLCJhc3NldF90b19zY2VuZSI6WzEwMDAsMCwwLDAsMCwwLC0xMDAwLDAsMCwxMDAwLDAsMCwwLDAsMCwxXSwiYnl0ZV9sZW5ndGgiOjc0MCwiY29udGVudF9oYXNoIjoic2hhMjU2OjZjZTQ4MWRiMWZlMTNiY2ZlN2EyMzc0NDIzZTZjNzJkNzU3ZDUxYzk5MTY4YjI4N2M1MmY1M2FhNjI1Y2NkNjciLCJtZWRpYV90eXBlIjoibW9kZWwvZ2x0Zi1iaW5hcnkiLCJzY2VuZV9sb2NhbF9ib3VuZHMiOnsibWF4IjpbMTAwMCwwLDEwMDBdLCJtaW4iOlswLC0xMDAwLDBdfSwidGVzc2VsbGF0aW9uIjp7ImxpbmVhcl90b2xlcmFuY2UiOjAuMzV9LCJ1cmkiOiJlZGdlcy9zaGEyNTYtNmNlNDgxZGIxZmUxM2JjZmU3YTIzNzQ0MjNlNmM3MmQ3NTdkNTFjOTkxNjhiMjg3YzUyZjUzYWE2MjVjY2Q2Ny5nbGIifV0sImVudGl0eV9hc3NldHMiOlt7ImJ5dGVfbGVuZ3RoIjoxMTUxNywiY29udGVudF9oYXNoIjoic2hhMjU2OjM4OTdjYzllYmRiMjk0MTgzNTk2NGE0MzU4MjFiNTU2ZTAxYjE3NTUzNWNmZjhmYzBlYWFhZmI2ODRjNGRkZjUiLCJlbnRpdHlfYXNzZXRfaWQiOiJzaGEyNTY6Mzg5N2NjOWViZGIyOTQxODM1OTY0YTQzNTgyMWI1NTZlMDFiMTc1NTM1Y2ZmOGZjMGVhYWFmYjY4NGM0ZGRmNSIsIm1lZGlhX3R5cGUiOiJhcHBsaWNhdGlvbi92bmQuc2ltcGxlY2FkLmVudGl0aWVzK2pzb24iLCJ1cmkiOiJlbnRpdGllcy9zaGEyNTYtMzg5N2NjOWViZGIyOTQxODM1OTY0YTQzNTgyMWI1NTZlMDFiMTc1NTM1Y2ZmOGZjMGVhYWFmYjY4NGM0ZGRmNS5qc29uIn1dLCJleHRlbnNpb25zIjp7fSwiZXh0ZW5zaW9uc19yZXF1aXJlZCI6W10sImV4dGVuc2lvbnNfdXNlZCI6W10sImdlbmVyYXRvciI6eyJuYW1lIjoic2ltcGxlY2FkYXBpIiwib2NwX2JpbmRpbmdzX3ZlcnNpb24iOiJmaXh0dXJlIiwib2NwX3ZlcnNpb24iOiJmaXh0dXJlIiwicGxhdGZvcm1fdGFnIjoiZml4dHVyZSIsInByb2ZpbGUiOiJzY2VuZS0xLjAtb2NwLWdsYi0yIiwicHl0aG9uX2FiaSI6ImZpeHR1cmUiLCJzaW1wbGVjYWRhcGlfdmVyc2lvbiI6ImZpeHR1cmUiLCJ0b29sY2hhaW5faGFzaCI6InNoYTI1NjowMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwIn0sImdlb21ldHJ5X2Fzc2V0cyI6W3siYXNzZXRfaWQiOiJzaGEyNTY6MjAwNTIzNDE0ZGUyODFhOGExNTUwODZhNTRjZDBmODgwZjY0MDQ4NTc2NWE0OGNjZGQ4ZWMyMzBmNjdkZDE3YyIsImFzc2V0X3RvX3NjZW5lIjpbMTAwMCwwLDAsMCwwLDAsLTEwMDAsMCwwLDEwMDAsMCwwLDAsMCwwLDFdLCJieXRlX2xlbmd0aCI6MTAyMCwiY29udGVudF9oYXNoIjoic2hhMjU2OjIwMDUyMzQxNGRlMjgxYThhMTU1MDg2YTU0Y2QwZjg4MGY2NDA0ODU3NjVhNDhjY2RkOGVjMjMwZjY3ZGQxN2MiLCJtZWRpYV90eXBlIjoibW9kZWwvZ2x0Zi1iaW5hcnkiLCJzY2VuZV9sb2NhbF9ib3VuZHMiOnsibWF4IjpbMTAwMCwwLDEwMDBdLCJtaW4iOlswLC0xMDAwLDBdfSwidGVzc2VsbGF0aW9uIjp7ImFuZ3VsYXJfdG9sZXJhbmNlIjowLjIyLCJsaW5lYXJfdG9sZXJhbmNlIjowLjM1fSwidXJpIjoiZ2VvbWV0cnkvc2hhMjU2LTIwMDUyMzQxNGRlMjgxYThhMTU1MDg2YTU0Y2QwZjg4MGY2NDA0ODU3NjVhNDhjY2RkOGVjMjMwZjY3ZGQxN2MuZ2xiIn1dLCJsaWdodHMiOltdLCJub2RlcyI6W3siYXBwZWFyYW5jZV9vdmVycmlkZV9pZCI6bnVsbCwiZGVmaW5pdGlvbl9pZCI6ImRlZmluaXRpb24vcm9vdC9zaGFwZS9tYW51YWwvZml4dHVyZSIsIm5hbWUiOm51bGwsIm5vZGVfaWQiOiJpbnN0YW5jZS9yb290Iiwib3JkZXIiOjAsInBhcmVudF9ub2RlX2lkIjpudWxsLCJzZGtfbWV0YWRhdGEiOnt9LCJzZWxlY3RhYmxlIjp0cnVlLCJzb3VyY2UiOnsia2luZCI6InNoYXBlX3Jvb3QiLCJyb290X2lkIjoicm9vdCJ9LCJ0cmFuc2Zvcm0iOnsib3JpZ2luIjpbMCwwLDBdLCJ4X2F4aXMiOlsxLDAsMF0sInlfYXhpcyI6WzAsMSwwXSwiel9heGlzIjpbMCwwLDFdfSwidmlzaWJsZSI6dHJ1ZX1dLCJyZXZpc2lvbiI6InNoYTI1NjowMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwIiwic2NlbmVfaWQiOiJmaXh0dXJlIiwic2NoZW1hX3ZlcnNpb24iOiIxLjAiLCJzb3VyY2UiOnsia2luZCI6Im1hbnVhbCIsInNvdXJjZV9pZCI6ImZpeHR1cmUifX0=", + "valid": false + }, + { + "expected": { + "code": "transform_invalid", + "path": "/nodes/0/transform" + }, + "name": "transform_invalid", + "payload_base64": "eyJhbm5vdGF0aW9ucyI6W10sImFwcGVhcmFuY2VzIjpbeyJhbHBoYV9tb2RlIjoib3BhcXVlIiwiYXBwZWFyYW5jZV9pZCI6ImFwcGVhcmFuY2UvZXZhbHVhdGVkL2JmMWNhNmQ2MGFmMjE3NjdhY2UxNDE5YjYwNTUyMTdjODcxNDU4OWI3OTA5MGQyNzBhYWJlNTEyMjMwZWZhM2EiLCJiYXNlX2NvbG9yIjpbMC43MiwwLjc1LDAuNzgsMV0sImRvdWJsZV9zaWRlZCI6ZmFsc2UsImVkZ2VfY29sb3IiOlswLjA4LDAuMDksMC4xLDFdLCJtZXRhbGxpYyI6MCwibmFtZSI6bnVsbCwicm91Z2huZXNzIjowLjU1LCJzZGtfbWV0YWRhdGEiOnt9LCJzb3VyY2UiOm51bGx9XSwiY2FtZXJhcyI6W10sImNvbXBpbGVfb3B0aW9ucyI6eyJhbmd1bGFyX3RvbGVyYW5jZSI6MC4yMiwiZW1iZWRfcHJlc2VudGF0aW9uIjpmYWxzZSwiZW1iZWRfc291cmNlIjpmYWxzZSwibGluZWFyX3RvbGVyYW5jZSI6MC4zNX0sImNvbm5lY3RvcnMiOltdLCJjb29yZGluYXRlX3N5c3RlbSI6eyJoYW5kZWRuZXNzIjoicmlnaHQiLCJsZW5ndGhfdW5pdCI6Im1tIiwidXBfYXhpcyI6IitaIn0sImRlZmluaXRpb25zIjpbeyJhcHBlYXJhbmNlX2lkIjoiYXBwZWFyYW5jZS9ldmFsdWF0ZWQvYmYxY2E2ZDYwYWYyMTc2N2FjZTE0MTliNjA1NTIxN2M4NzE0NTg5Yjc5MDkwZDI3MGFhYmU1MTIyMzBlZmEzYSIsImRlZmluaXRpb25faWQiOiJkZWZpbml0aW9uL3Jvb3Qvc2hhcGUvbWFudWFsL2ZpeHR1cmUiLCJlZGdlX2Fzc2V0X2lkIjoic2hhMjU2OjZjZTQ4MWRiMWZlMTNiY2ZlN2EyMzc0NDIzZTZjNzJkNzU3ZDUxYzk5MTY4YjI4N2M1MmY1M2FhNjI1Y2NkNjciLCJlbnRpdHlfYXNzZXRfaWQiOiJzaGEyNTY6Mzg5N2NjOWViZGIyOTQxODM1OTY0YTQzNTgyMWI1NTZlMDFiMTc1NTM1Y2ZmOGZjMGVhYWFmYjY4NGM0ZGRmNSIsImdlb21ldHJ5X2Fzc2V0X2lkIjoic2hhMjU2OjIwMDUyMzQxNGRlMjgxYThhMTU1MDg2YTU0Y2QwZjg4MGY2NDA0ODU3NjVhNDhjY2RkOGVjMjMwZjY3ZGQxN2MiLCJraW5kIjoic2hhcGUiLCJuYW1lIjpudWxsLCJzZGtfbWV0YWRhdGEiOnt9LCJzb3VyY2UiOnsia2luZCI6Im1hbnVhbCIsInJvb3RfaWQiOiJyb290Iiwic291cmNlX2lkIjoiZml4dHVyZSJ9fV0sImRpYWdub3N0aWNzIjpbXSwiZWRnZV9hc3NldHMiOlt7ImFzc2V0X2lkIjoic2hhMjU2OjZjZTQ4MWRiMWZlMTNiY2ZlN2EyMzc0NDIzZTZjNzJkNzU3ZDUxYzk5MTY4YjI4N2M1MmY1M2FhNjI1Y2NkNjciLCJhc3NldF90b19zY2VuZSI6WzEwMDAsMCwwLDAsMCwwLC0xMDAwLDAsMCwxMDAwLDAsMCwwLDAsMCwxXSwiYnl0ZV9sZW5ndGgiOjc0MCwiY29udGVudF9oYXNoIjoic2hhMjU2OjZjZTQ4MWRiMWZlMTNiY2ZlN2EyMzc0NDIzZTZjNzJkNzU3ZDUxYzk5MTY4YjI4N2M1MmY1M2FhNjI1Y2NkNjciLCJtZWRpYV90eXBlIjoibW9kZWwvZ2x0Zi1iaW5hcnkiLCJzY2VuZV9sb2NhbF9ib3VuZHMiOnsibWF4IjpbMTAwMCwwLDEwMDBdLCJtaW4iOlswLC0xMDAwLDBdfSwidGVzc2VsbGF0aW9uIjp7ImxpbmVhcl90b2xlcmFuY2UiOjAuMzV9LCJ1cmkiOiJlZGdlcy9zaGEyNTYtNmNlNDgxZGIxZmUxM2JjZmU3YTIzNzQ0MjNlNmM3MmQ3NTdkNTFjOTkxNjhiMjg3YzUyZjUzYWE2MjVjY2Q2Ny5nbGIifV0sImVudGl0eV9hc3NldHMiOlt7ImJ5dGVfbGVuZ3RoIjoxMTUxNywiY29udGVudF9oYXNoIjoic2hhMjU2OjM4OTdjYzllYmRiMjk0MTgzNTk2NGE0MzU4MjFiNTU2ZTAxYjE3NTUzNWNmZjhmYzBlYWFhZmI2ODRjNGRkZjUiLCJlbnRpdHlfYXNzZXRfaWQiOiJzaGEyNTY6Mzg5N2NjOWViZGIyOTQxODM1OTY0YTQzNTgyMWI1NTZlMDFiMTc1NTM1Y2ZmOGZjMGVhYWFmYjY4NGM0ZGRmNSIsIm1lZGlhX3R5cGUiOiJhcHBsaWNhdGlvbi92bmQuc2ltcGxlY2FkLmVudGl0aWVzK2pzb24iLCJ1cmkiOiJlbnRpdGllcy9zaGEyNTYtMzg5N2NjOWViZGIyOTQxODM1OTY0YTQzNTgyMWI1NTZlMDFiMTc1NTM1Y2ZmOGZjMGVhYWFmYjY4NGM0ZGRmNS5qc29uIn1dLCJleHRlbnNpb25zIjp7fSwiZXh0ZW5zaW9uc19yZXF1aXJlZCI6W10sImV4dGVuc2lvbnNfdXNlZCI6W10sImdlbmVyYXRvciI6eyJuYW1lIjoic2ltcGxlY2FkYXBpIiwib2NwX2JpbmRpbmdzX3ZlcnNpb24iOiJmaXh0dXJlIiwib2NwX3ZlcnNpb24iOiJmaXh0dXJlIiwicGxhdGZvcm1fdGFnIjoiZml4dHVyZSIsInByb2ZpbGUiOiJzY2VuZS0xLjAtb2NwLWdsYi0yIiwicHl0aG9uX2FiaSI6ImZpeHR1cmUiLCJzaW1wbGVjYWRhcGlfdmVyc2lvbiI6ImZpeHR1cmUiLCJ0b29sY2hhaW5faGFzaCI6InNoYTI1NjowMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwIn0sImdlb21ldHJ5X2Fzc2V0cyI6W3siYXNzZXRfaWQiOiJzaGEyNTY6MjAwNTIzNDE0ZGUyODFhOGExNTUwODZhNTRjZDBmODgwZjY0MDQ4NTc2NWE0OGNjZGQ4ZWMyMzBmNjdkZDE3YyIsImFzc2V0X3RvX3NjZW5lIjpbMTAwMCwwLDAsMCwwLDAsLTEwMDAsMCwwLDEwMDAsMCwwLDAsMCwwLDFdLCJieXRlX2xlbmd0aCI6MTAyMCwiY29udGVudF9oYXNoIjoic2hhMjU2OjIwMDUyMzQxNGRlMjgxYThhMTU1MDg2YTU0Y2QwZjg4MGY2NDA0ODU3NjVhNDhjY2RkOGVjMjMwZjY3ZGQxN2MiLCJtZWRpYV90eXBlIjoibW9kZWwvZ2x0Zi1iaW5hcnkiLCJzY2VuZV9sb2NhbF9ib3VuZHMiOnsibWF4IjpbMTAwMCwwLDEwMDBdLCJtaW4iOlswLC0xMDAwLDBdfSwidGVzc2VsbGF0aW9uIjp7ImFuZ3VsYXJfdG9sZXJhbmNlIjowLjIyLCJsaW5lYXJfdG9sZXJhbmNlIjowLjM1fSwidXJpIjoiZ2VvbWV0cnkvc2hhMjU2LTIwMDUyMzQxNGRlMjgxYThhMTU1MDg2YTU0Y2QwZjg4MGY2NDA0ODU3NjVhNDhjY2RkOGVjMjMwZjY3ZGQxN2MuZ2xiIn1dLCJsaWdodHMiOltdLCJub2RlcyI6W3siYXBwZWFyYW5jZV9vdmVycmlkZV9pZCI6bnVsbCwiZGVmaW5pdGlvbl9pZCI6ImRlZmluaXRpb24vcm9vdC9zaGFwZS9tYW51YWwvZml4dHVyZSIsIm5hbWUiOm51bGwsIm5vZGVfaWQiOiJpbnN0YW5jZS9yb290Iiwib3JkZXIiOjAsInBhcmVudF9ub2RlX2lkIjpudWxsLCJzZGtfbWV0YWRhdGEiOnt9LCJzZWxlY3RhYmxlIjp0cnVlLCJzb3VyY2UiOnsia2luZCI6InNoYXBlX3Jvb3QiLCJyb290X2lkIjoicm9vdCJ9LCJ0cmFuc2Zvcm0iOnsib3JpZ2luIjpbMCwwLDBdLCJ4X2F4aXMiOlsyLDAsMF0sInlfYXhpcyI6WzAsMSwwXSwiel9heGlzIjpbMCwwLDFdfSwidmlzaWJsZSI6dHJ1ZX1dLCJyZXZpc2lvbiI6InNoYTI1NjplZDA5YWQ0Yjk1NDlmYTk4NWVmMzllYmFhNTBjY2ZlNDkzMDNkZGQzMzNjNTZhYTEyY2MxZTkxMGUxMDI5MDNmIiwic2NlbmVfaWQiOiJmaXh0dXJlIiwic2NoZW1hX3ZlcnNpb24iOiIxLjAiLCJzb3VyY2UiOnsia2luZCI6Im1hbnVhbCIsInNvdXJjZV9pZCI6ImZpeHR1cmUifX0=", + "valid": false + }, + { + "expected": { + "code": "noncanonical_json", + "path": "" + }, + "name": "noncanonical_json", + "payload_base64": "eyJhbm5vdGF0aW9ucyI6W10sImFwcGVhcmFuY2VzIjpbeyJhbHBoYV9tb2RlIjoib3BhcXVlIiwiYXBwZWFyYW5jZV9pZCI6ImFwcGVhcmFuY2UvZXZhbHVhdGVkL2JmMWNhNmQ2MGFmMjE3NjdhY2UxNDE5YjYwNTUyMTdjODcxNDU4OWI3OTA5MGQyNzBhYWJlNTEyMjMwZWZhM2EiLCJiYXNlX2NvbG9yIjpbMC43MiwwLjc1LDAuNzgsMV0sImRvdWJsZV9zaWRlZCI6ZmFsc2UsImVkZ2VfY29sb3IiOlswLjA4LDAuMDksMC4xLDFdLCJtZXRhbGxpYyI6MCwibmFtZSI6bnVsbCwicm91Z2huZXNzIjowLjU1LCJzZGtfbWV0YWRhdGEiOnt9LCJzb3VyY2UiOm51bGx9XSwiY2FtZXJhcyI6W10sImNvbXBpbGVfb3B0aW9ucyI6eyJhbmd1bGFyX3RvbGVyYW5jZSI6MC4yMiwiZW1iZWRfcHJlc2VudGF0aW9uIjpmYWxzZSwiZW1iZWRfc291cmNlIjpmYWxzZSwibGluZWFyX3RvbGVyYW5jZSI6MC4zNX0sImNvbm5lY3RvcnMiOltdLCJjb29yZGluYXRlX3N5c3RlbSI6eyJoYW5kZWRuZXNzIjoicmlnaHQiLCJsZW5ndGhfdW5pdCI6Im1tIiwidXBfYXhpcyI6IitaIn0sImRlZmluaXRpb25zIjpbeyJhcHBlYXJhbmNlX2lkIjoiYXBwZWFyYW5jZS9ldmFsdWF0ZWQvYmYxY2E2ZDYwYWYyMTc2N2FjZTE0MTliNjA1NTIxN2M4NzE0NTg5Yjc5MDkwZDI3MGFhYmU1MTIyMzBlZmEzYSIsImRlZmluaXRpb25faWQiOiJkZWZpbml0aW9uL3Jvb3Qvc2hhcGUvbWFudWFsL2ZpeHR1cmUiLCJlZGdlX2Fzc2V0X2lkIjoic2hhMjU2OjZjZTQ4MWRiMWZlMTNiY2ZlN2EyMzc0NDIzZTZjNzJkNzU3ZDUxYzk5MTY4YjI4N2M1MmY1M2FhNjI1Y2NkNjciLCJlbnRpdHlfYXNzZXRfaWQiOiJzaGEyNTY6Mzg5N2NjOWViZGIyOTQxODM1OTY0YTQzNTgyMWI1NTZlMDFiMTc1NTM1Y2ZmOGZjMGVhYWFmYjY4NGM0ZGRmNSIsImdlb21ldHJ5X2Fzc2V0X2lkIjoic2hhMjU2OjIwMDUyMzQxNGRlMjgxYThhMTU1MDg2YTU0Y2QwZjg4MGY2NDA0ODU3NjVhNDhjY2RkOGVjMjMwZjY3ZGQxN2MiLCJraW5kIjoic2hhcGUiLCJuYW1lIjpudWxsLCJzZGtfbWV0YWRhdGEiOnt9LCJzb3VyY2UiOnsia2luZCI6Im1hbnVhbCIsInJvb3RfaWQiOiJyb290Iiwic291cmNlX2lkIjoiZml4dHVyZSJ9fV0sImRpYWdub3N0aWNzIjpbXSwiZWRnZV9hc3NldHMiOlt7ImFzc2V0X2lkIjoic2hhMjU2OjZjZTQ4MWRiMWZlMTNiY2ZlN2EyMzc0NDIzZTZjNzJkNzU3ZDUxYzk5MTY4YjI4N2M1MmY1M2FhNjI1Y2NkNjciLCJhc3NldF90b19zY2VuZSI6WzEwMDAsMCwwLDAsMCwwLC0xMDAwLDAsMCwxMDAwLDAsMCwwLDAsMCwxXSwiYnl0ZV9sZW5ndGgiOjc0MCwiY29udGVudF9oYXNoIjoic2hhMjU2OjZjZTQ4MWRiMWZlMTNiY2ZlN2EyMzc0NDIzZTZjNzJkNzU3ZDUxYzk5MTY4YjI4N2M1MmY1M2FhNjI1Y2NkNjciLCJtZWRpYV90eXBlIjoibW9kZWwvZ2x0Zi1iaW5hcnkiLCJzY2VuZV9sb2NhbF9ib3VuZHMiOnsibWF4IjpbMTAwMCwwLDEwMDBdLCJtaW4iOlswLC0xMDAwLDBdfSwidGVzc2VsbGF0aW9uIjp7ImxpbmVhcl90b2xlcmFuY2UiOjAuMzV9LCJ1cmkiOiJlZGdlcy9zaGEyNTYtNmNlNDgxZGIxZmUxM2JjZmU3YTIzNzQ0MjNlNmM3MmQ3NTdkNTFjOTkxNjhiMjg3YzUyZjUzYWE2MjVjY2Q2Ny5nbGIifV0sImVudGl0eV9hc3NldHMiOlt7ImJ5dGVfbGVuZ3RoIjoxMTUxNywiY29udGVudF9oYXNoIjoic2hhMjU2OjM4OTdjYzllYmRiMjk0MTgzNTk2NGE0MzU4MjFiNTU2ZTAxYjE3NTUzNWNmZjhmYzBlYWFhZmI2ODRjNGRkZjUiLCJlbnRpdHlfYXNzZXRfaWQiOiJzaGEyNTY6Mzg5N2NjOWViZGIyOTQxODM1OTY0YTQzNTgyMWI1NTZlMDFiMTc1NTM1Y2ZmOGZjMGVhYWFmYjY4NGM0ZGRmNSIsIm1lZGlhX3R5cGUiOiJhcHBsaWNhdGlvbi92bmQuc2ltcGxlY2FkLmVudGl0aWVzK2pzb24iLCJ1cmkiOiJlbnRpdGllcy9zaGEyNTYtMzg5N2NjOWViZGIyOTQxODM1OTY0YTQzNTgyMWI1NTZlMDFiMTc1NTM1Y2ZmOGZjMGVhYWFmYjY4NGM0ZGRmNS5qc29uIn1dLCJleHRlbnNpb25zIjp7fSwiZXh0ZW5zaW9uc19yZXF1aXJlZCI6W10sImV4dGVuc2lvbnNfdXNlZCI6W10sImdlbmVyYXRvciI6eyJuYW1lIjoic2ltcGxlY2FkYXBpIiwib2NwX2JpbmRpbmdzX3ZlcnNpb24iOiJmaXh0dXJlIiwib2NwX3ZlcnNpb24iOiJmaXh0dXJlIiwicGxhdGZvcm1fdGFnIjoiZml4dHVyZSIsInByb2ZpbGUiOiJzY2VuZS0xLjAtb2NwLWdsYi0yIiwicHl0aG9uX2FiaSI6ImZpeHR1cmUiLCJzaW1wbGVjYWRhcGlfdmVyc2lvbiI6ImZpeHR1cmUiLCJ0b29sY2hhaW5faGFzaCI6InNoYTI1NjowMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwIn0sImdlb21ldHJ5X2Fzc2V0cyI6W3siYXNzZXRfaWQiOiJzaGEyNTY6MjAwNTIzNDE0ZGUyODFhOGExNTUwODZhNTRjZDBmODgwZjY0MDQ4NTc2NWE0OGNjZGQ4ZWMyMzBmNjdkZDE3YyIsImFzc2V0X3RvX3NjZW5lIjpbMTAwMCwwLDAsMCwwLDAsLTEwMDAsMCwwLDEwMDAsMCwwLDAsMCwwLDFdLCJieXRlX2xlbmd0aCI6MTAyMCwiY29udGVudF9oYXNoIjoic2hhMjU2OjIwMDUyMzQxNGRlMjgxYThhMTU1MDg2YTU0Y2QwZjg4MGY2NDA0ODU3NjVhNDhjY2RkOGVjMjMwZjY3ZGQxN2MiLCJtZWRpYV90eXBlIjoibW9kZWwvZ2x0Zi1iaW5hcnkiLCJzY2VuZV9sb2NhbF9ib3VuZHMiOnsibWF4IjpbMTAwMCwwLDEwMDBdLCJtaW4iOlswLC0xMDAwLDBdfSwidGVzc2VsbGF0aW9uIjp7ImFuZ3VsYXJfdG9sZXJhbmNlIjowLjIyLCJsaW5lYXJfdG9sZXJhbmNlIjowLjM1fSwidXJpIjoiZ2VvbWV0cnkvc2hhMjU2LTIwMDUyMzQxNGRlMjgxYThhMTU1MDg2YTU0Y2QwZjg4MGY2NDA0ODU3NjVhNDhjY2RkOGVjMjMwZjY3ZGQxN2MuZ2xiIn1dLCJsaWdodHMiOltdLCJub2RlcyI6W3siYXBwZWFyYW5jZV9vdmVycmlkZV9pZCI6bnVsbCwiZGVmaW5pdGlvbl9pZCI6ImRlZmluaXRpb24vcm9vdC9zaGFwZS9tYW51YWwvZml4dHVyZSIsIm5hbWUiOm51bGwsIm5vZGVfaWQiOiJpbnN0YW5jZS9yb290Iiwib3JkZXIiOjAsInBhcmVudF9ub2RlX2lkIjpudWxsLCJzZGtfbWV0YWRhdGEiOnt9LCJzZWxlY3RhYmxlIjp0cnVlLCJzb3VyY2UiOnsia2luZCI6InNoYXBlX3Jvb3QiLCJyb290X2lkIjoicm9vdCJ9LCJ0cmFuc2Zvcm0iOnsib3JpZ2luIjpbMCwwLDBdLCJ4X2F4aXMiOlsxLDAsMF0sInlfYXhpcyI6WzAsMSwwXSwiel9heGlzIjpbMCwwLDFdfSwidmlzaWJsZSI6dHJ1ZX1dLCJyZXZpc2lvbiI6InNoYTI1NjpmYWUxNWY0YWVlZjEyNzdjZDgzMWZmOGJmODNmOWIyNDRkNWM0MjI1ZTcyZWI3YmMyZTBlMzVkZDZjZjQzNjE0Iiwic2NlbmVfaWQiOiJmaXh0dXJlIiwic2NoZW1hX3ZlcnNpb24iOiIxLjAiLCJzb3VyY2UiOnsia2luZCI6Im1hbnVhbCIsInNvdXJjZV9pZCI6ImZpeHR1cmUifX0K", + "valid": false + }, + { + "expected": { + "code": "duplicate_json_key", + "path": "" + }, + "name": "duplicate_json_key", + "payload_base64": "eyJzY2hlbWFfdmVyc2lvbiI6IjEuMCIsInNjaGVtYV92ZXJzaW9uIjoiMS4wIn0=", + "valid": false + }, + { + "expected": { + "code": "bom_forbidden", + "path": "" + }, + "name": "bom", + "payload_base64": "77u/eyJhbm5vdGF0aW9ucyI6W10sImFwcGVhcmFuY2VzIjpbeyJhbHBoYV9tb2RlIjoib3BhcXVlIiwiYXBwZWFyYW5jZV9pZCI6ImFwcGVhcmFuY2UvZXZhbHVhdGVkL2JmMWNhNmQ2MGFmMjE3NjdhY2UxNDE5YjYwNTUyMTdjODcxNDU4OWI3OTA5MGQyNzBhYWJlNTEyMjMwZWZhM2EiLCJiYXNlX2NvbG9yIjpbMC43MiwwLjc1LDAuNzgsMV0sImRvdWJsZV9zaWRlZCI6ZmFsc2UsImVkZ2VfY29sb3IiOlswLjA4LDAuMDksMC4xLDFdLCJtZXRhbGxpYyI6MCwibmFtZSI6bnVsbCwicm91Z2huZXNzIjowLjU1LCJzZGtfbWV0YWRhdGEiOnt9LCJzb3VyY2UiOm51bGx9XSwiY2FtZXJhcyI6W10sImNvbXBpbGVfb3B0aW9ucyI6eyJhbmd1bGFyX3RvbGVyYW5jZSI6MC4yMiwiZW1iZWRfcHJlc2VudGF0aW9uIjpmYWxzZSwiZW1iZWRfc291cmNlIjpmYWxzZSwibGluZWFyX3RvbGVyYW5jZSI6MC4zNX0sImNvbm5lY3RvcnMiOltdLCJjb29yZGluYXRlX3N5c3RlbSI6eyJoYW5kZWRuZXNzIjoicmlnaHQiLCJsZW5ndGhfdW5pdCI6Im1tIiwidXBfYXhpcyI6IitaIn0sImRlZmluaXRpb25zIjpbeyJhcHBlYXJhbmNlX2lkIjoiYXBwZWFyYW5jZS9ldmFsdWF0ZWQvYmYxY2E2ZDYwYWYyMTc2N2FjZTE0MTliNjA1NTIxN2M4NzE0NTg5Yjc5MDkwZDI3MGFhYmU1MTIyMzBlZmEzYSIsImRlZmluaXRpb25faWQiOiJkZWZpbml0aW9uL3Jvb3Qvc2hhcGUvbWFudWFsL2ZpeHR1cmUiLCJlZGdlX2Fzc2V0X2lkIjoic2hhMjU2OjZjZTQ4MWRiMWZlMTNiY2ZlN2EyMzc0NDIzZTZjNzJkNzU3ZDUxYzk5MTY4YjI4N2M1MmY1M2FhNjI1Y2NkNjciLCJlbnRpdHlfYXNzZXRfaWQiOiJzaGEyNTY6Mzg5N2NjOWViZGIyOTQxODM1OTY0YTQzNTgyMWI1NTZlMDFiMTc1NTM1Y2ZmOGZjMGVhYWFmYjY4NGM0ZGRmNSIsImdlb21ldHJ5X2Fzc2V0X2lkIjoic2hhMjU2OjIwMDUyMzQxNGRlMjgxYThhMTU1MDg2YTU0Y2QwZjg4MGY2NDA0ODU3NjVhNDhjY2RkOGVjMjMwZjY3ZGQxN2MiLCJraW5kIjoic2hhcGUiLCJuYW1lIjpudWxsLCJzZGtfbWV0YWRhdGEiOnt9LCJzb3VyY2UiOnsia2luZCI6Im1hbnVhbCIsInJvb3RfaWQiOiJyb290Iiwic291cmNlX2lkIjoiZml4dHVyZSJ9fV0sImRpYWdub3N0aWNzIjpbXSwiZWRnZV9hc3NldHMiOlt7ImFzc2V0X2lkIjoic2hhMjU2OjZjZTQ4MWRiMWZlMTNiY2ZlN2EyMzc0NDIzZTZjNzJkNzU3ZDUxYzk5MTY4YjI4N2M1MmY1M2FhNjI1Y2NkNjciLCJhc3NldF90b19zY2VuZSI6WzEwMDAsMCwwLDAsMCwwLC0xMDAwLDAsMCwxMDAwLDAsMCwwLDAsMCwxXSwiYnl0ZV9sZW5ndGgiOjc0MCwiY29udGVudF9oYXNoIjoic2hhMjU2OjZjZTQ4MWRiMWZlMTNiY2ZlN2EyMzc0NDIzZTZjNzJkNzU3ZDUxYzk5MTY4YjI4N2M1MmY1M2FhNjI1Y2NkNjciLCJtZWRpYV90eXBlIjoibW9kZWwvZ2x0Zi1iaW5hcnkiLCJzY2VuZV9sb2NhbF9ib3VuZHMiOnsibWF4IjpbMTAwMCwwLDEwMDBdLCJtaW4iOlswLC0xMDAwLDBdfSwidGVzc2VsbGF0aW9uIjp7ImxpbmVhcl90b2xlcmFuY2UiOjAuMzV9LCJ1cmkiOiJlZGdlcy9zaGEyNTYtNmNlNDgxZGIxZmUxM2JjZmU3YTIzNzQ0MjNlNmM3MmQ3NTdkNTFjOTkxNjhiMjg3YzUyZjUzYWE2MjVjY2Q2Ny5nbGIifV0sImVudGl0eV9hc3NldHMiOlt7ImJ5dGVfbGVuZ3RoIjoxMTUxNywiY29udGVudF9oYXNoIjoic2hhMjU2OjM4OTdjYzllYmRiMjk0MTgzNTk2NGE0MzU4MjFiNTU2ZTAxYjE3NTUzNWNmZjhmYzBlYWFhZmI2ODRjNGRkZjUiLCJlbnRpdHlfYXNzZXRfaWQiOiJzaGEyNTY6Mzg5N2NjOWViZGIyOTQxODM1OTY0YTQzNTgyMWI1NTZlMDFiMTc1NTM1Y2ZmOGZjMGVhYWFmYjY4NGM0ZGRmNSIsIm1lZGlhX3R5cGUiOiJhcHBsaWNhdGlvbi92bmQuc2ltcGxlY2FkLmVudGl0aWVzK2pzb24iLCJ1cmkiOiJlbnRpdGllcy9zaGEyNTYtMzg5N2NjOWViZGIyOTQxODM1OTY0YTQzNTgyMWI1NTZlMDFiMTc1NTM1Y2ZmOGZjMGVhYWFmYjY4NGM0ZGRmNS5qc29uIn1dLCJleHRlbnNpb25zIjp7fSwiZXh0ZW5zaW9uc19yZXF1aXJlZCI6W10sImV4dGVuc2lvbnNfdXNlZCI6W10sImdlbmVyYXRvciI6eyJuYW1lIjoic2ltcGxlY2FkYXBpIiwib2NwX2JpbmRpbmdzX3ZlcnNpb24iOiJmaXh0dXJlIiwib2NwX3ZlcnNpb24iOiJmaXh0dXJlIiwicGxhdGZvcm1fdGFnIjoiZml4dHVyZSIsInByb2ZpbGUiOiJzY2VuZS0xLjAtb2NwLWdsYi0yIiwicHl0aG9uX2FiaSI6ImZpeHR1cmUiLCJzaW1wbGVjYWRhcGlfdmVyc2lvbiI6ImZpeHR1cmUiLCJ0b29sY2hhaW5faGFzaCI6InNoYTI1NjowMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwIn0sImdlb21ldHJ5X2Fzc2V0cyI6W3siYXNzZXRfaWQiOiJzaGEyNTY6MjAwNTIzNDE0ZGUyODFhOGExNTUwODZhNTRjZDBmODgwZjY0MDQ4NTc2NWE0OGNjZGQ4ZWMyMzBmNjdkZDE3YyIsImFzc2V0X3RvX3NjZW5lIjpbMTAwMCwwLDAsMCwwLDAsLTEwMDAsMCwwLDEwMDAsMCwwLDAsMCwwLDFdLCJieXRlX2xlbmd0aCI6MTAyMCwiY29udGVudF9oYXNoIjoic2hhMjU2OjIwMDUyMzQxNGRlMjgxYThhMTU1MDg2YTU0Y2QwZjg4MGY2NDA0ODU3NjVhNDhjY2RkOGVjMjMwZjY3ZGQxN2MiLCJtZWRpYV90eXBlIjoibW9kZWwvZ2x0Zi1iaW5hcnkiLCJzY2VuZV9sb2NhbF9ib3VuZHMiOnsibWF4IjpbMTAwMCwwLDEwMDBdLCJtaW4iOlswLC0xMDAwLDBdfSwidGVzc2VsbGF0aW9uIjp7ImFuZ3VsYXJfdG9sZXJhbmNlIjowLjIyLCJsaW5lYXJfdG9sZXJhbmNlIjowLjM1fSwidXJpIjoiZ2VvbWV0cnkvc2hhMjU2LTIwMDUyMzQxNGRlMjgxYThhMTU1MDg2YTU0Y2QwZjg4MGY2NDA0ODU3NjVhNDhjY2RkOGVjMjMwZjY3ZGQxN2MuZ2xiIn1dLCJsaWdodHMiOltdLCJub2RlcyI6W3siYXBwZWFyYW5jZV9vdmVycmlkZV9pZCI6bnVsbCwiZGVmaW5pdGlvbl9pZCI6ImRlZmluaXRpb24vcm9vdC9zaGFwZS9tYW51YWwvZml4dHVyZSIsIm5hbWUiOm51bGwsIm5vZGVfaWQiOiJpbnN0YW5jZS9yb290Iiwib3JkZXIiOjAsInBhcmVudF9ub2RlX2lkIjpudWxsLCJzZGtfbWV0YWRhdGEiOnt9LCJzZWxlY3RhYmxlIjp0cnVlLCJzb3VyY2UiOnsia2luZCI6InNoYXBlX3Jvb3QiLCJyb290X2lkIjoicm9vdCJ9LCJ0cmFuc2Zvcm0iOnsib3JpZ2luIjpbMCwwLDBdLCJ4X2F4aXMiOlsxLDAsMF0sInlfYXhpcyI6WzAsMSwwXSwiel9heGlzIjpbMCwwLDFdfSwidmlzaWJsZSI6dHJ1ZX1dLCJyZXZpc2lvbiI6InNoYTI1NjpmYWUxNWY0YWVlZjEyNzdjZDgzMWZmOGJmODNmOWIyNDRkNWM0MjI1ZTcyZWI3YmMyZTBlMzVkZDZjZjQzNjE0Iiwic2NlbmVfaWQiOiJmaXh0dXJlIiwic2NoZW1hX3ZlcnNpb24iOiIxLjAiLCJzb3VyY2UiOnsia2luZCI6Im1hbnVhbCIsInNvdXJjZV9pZCI6ImZpeHR1cmUifX0=", + "valid": false + } + ], + "normalized_product_cases": [ + { + "expected": null, + "name": "valid_normalized_part", + "payload_base64": "eyJib2R5X3NvdXJjZSI6eyJraW5kIjoibWFudWFsIiwic291cmNlX2lkIjoiZml4dHVyZV9ib2R5In0sImNvbm5lY3RvcnMiOltdLCJraW5kIjoicGFydCIsIm1hdGVyaWFsIjpudWxsLCJtZXRhZGF0YSI6e30sIm5hbWUiOm51bGwsInBhcnRfaWQiOiJmaXh0dXJlX3BhcnQifQ==", + "valid": true + }, + { + "expected": null, + "name": "valid_normalized_assembly", + "payload_base64": "eyJhc3NlbWJseV9pZCI6ImZpeHR1cmVfYXNzZW1ibHkiLCJjb21wb25lbnRzIjpbeyJjb21wb25lbnRfaWQiOiJjb21wb25lbnRfYSIsImRlZmluaXRpb25fcmVmIjoiZGVmaW5pdGlvbi9yb290L3BhcnQvZml4dHVyZSIsImxvY2FsX3BsYWNlbWVudCI6eyJvcmlnaW4iOlswLDAsMF0sInhfYXhpcyI6WzEsMCwwXSwieV9heGlzIjpbMCwxLDBdLCJ6X2F4aXMiOlswLDAsMV19LCJuYW1lIjpudWxsfV0sImNvbm5lY3RvcnMiOltdLCJjb25zdHJhaW50cyI6W10sImdyb3VuZGVkX2NvbXBvbmVudF9pZHMiOlsiY29tcG9uZW50X2EiXSwia2luZCI6ImFzc2VtYmx5IiwibWV0YWRhdGEiOnt9LCJuYW1lIjpudWxsfQ==", + "valid": true + }, + { + "expected": { + "code": "reference_missing", + "path": "/grounded_component_ids" + }, + "name": "missing_grounded_component", + "payload_base64": "eyJhc3NlbWJseV9pZCI6ImZpeHR1cmVfYXNzZW1ibHkiLCJjb21wb25lbnRzIjpbeyJjb21wb25lbnRfaWQiOiJjb21wb25lbnRfYSIsImRlZmluaXRpb25fcmVmIjoiZGVmaW5pdGlvbi9yb290L3BhcnQvZml4dHVyZSIsImxvY2FsX3BsYWNlbWVudCI6eyJvcmlnaW4iOlswLDAsMF0sInhfYXhpcyI6WzEsMCwwXSwieV9heGlzIjpbMCwxLDBdLCJ6X2F4aXMiOlswLDAsMV19LCJuYW1lIjpudWxsfV0sImNvbm5lY3RvcnMiOltdLCJjb25zdHJhaW50cyI6W10sImdyb3VuZGVkX2NvbXBvbmVudF9pZHMiOlsibWlzc2luZyJdLCJraW5kIjoiYXNzZW1ibHkiLCJtZXRhZGF0YSI6e30sIm5hbWUiOm51bGx9", + "valid": false + }, + { + "expected": { + "code": "id_duplicate", + "path": "/components" + }, + "name": "duplicate_components", + "payload_base64": "eyJhc3NlbWJseV9pZCI6ImZpeHR1cmVfYXNzZW1ibHkiLCJjb21wb25lbnRzIjpbeyJjb21wb25lbnRfaWQiOiJjb21wb25lbnRfYSIsImRlZmluaXRpb25fcmVmIjoiZGVmaW5pdGlvbi9yb290L3BhcnQvZml4dHVyZSIsImxvY2FsX3BsYWNlbWVudCI6eyJvcmlnaW4iOlswLDAsMF0sInhfYXhpcyI6WzEsMCwwXSwieV9heGlzIjpbMCwxLDBdLCJ6X2F4aXMiOlswLDAsMV19LCJuYW1lIjpudWxsfSx7ImNvbXBvbmVudF9pZCI6ImNvbXBvbmVudF9hIiwiZGVmaW5pdGlvbl9yZWYiOiJkZWZpbml0aW9uL3Jvb3QvcGFydC9maXh0dXJlIiwibG9jYWxfcGxhY2VtZW50Ijp7Im9yaWdpbiI6WzAsMCwwXSwieF9heGlzIjpbMSwwLDBdLCJ5X2F4aXMiOlswLDEsMF0sInpfYXhpcyI6WzAsMCwxXX0sIm5hbWUiOm51bGx9XSwiY29ubmVjdG9ycyI6W10sImNvbnN0cmFpbnRzIjpbXSwiZ3JvdW5kZWRfY29tcG9uZW50X2lkcyI6WyJjb21wb25lbnRfYSJdLCJraW5kIjoiYXNzZW1ibHkiLCJtZXRhZGF0YSI6e30sIm5hbWUiOm51bGx9", + "valid": false + }, + { + "expected": null, + "name": "valid_complete_material", + "payload_base64": "eyJib2R5X3NvdXJjZSI6eyJraW5kIjoibWFudWFsIiwic291cmNlX2lkIjoiZml4dHVyZV9ib2R5In0sImNvbm5lY3RvcnMiOltdLCJraW5kIjoicGFydCIsIm1hdGVyaWFsIjp7ImNvbG9yIjpbMC4yLDAuMywwLjRdLCJkZW5zaXR5Ijo3Ljg1LCJkZW5zaXR5X3VuaXQiOiJnL2NtMyIsIm1hdGVyaWFsX2lkIjoic3RlZWwiLCJtZXRhZGF0YSI6e30sIm5hbWUiOiJTdGVlbCJ9LCJtZXRhZGF0YSI6e30sIm5hbWUiOm51bGwsInBhcnRfaWQiOiJmaXh0dXJlX3BhcnQifQ==", + "valid": true + }, + { + "expected": null, + "name": "valid_model_body_source", + "payload_base64": "eyJib2R5X3NvdXJjZSI6eyJncmFwaF9pZCI6ImdyYXBoIiwia2luZCI6Im1vZGVsX291dHB1dCIsIm5vZGVfaWQiOiJib2R5Iiwib3V0cHV0X3Nsb3QiOjB9LCJjb25uZWN0b3JzIjpbXSwia2luZCI6InBhcnQiLCJtYXRlcmlhbCI6bnVsbCwibWV0YWRhdGEiOnt9LCJuYW1lIjpudWxsLCJwYXJ0X2lkIjoiZml4dHVyZV9wYXJ0In0=", + "valid": true + }, + { + "expected": null, + "name": "valid_placement_connector", + "payload_base64": "eyJib2R5X3NvdXJjZSI6eyJraW5kIjoibWFudWFsIiwic291cmNlX2lkIjoiZml4dHVyZV9ib2R5In0sImNvbm5lY3RvcnMiOlt7ImFuY2hvciI6eyJhbmNob3Jfa2luZCI6InBsYWNlbWVudCIsInBsYWNlbWVudCI6eyJvcmlnaW4iOlswLDAsMF0sInhfYXhpcyI6WzEsMCwwXSwieV9heGlzIjpbMCwxLDBdLCJ6X2F4aXMiOlswLDAsMV19fSwiY29ubmVjdG9yX2lkIjoicGxhY2VtZW50IiwibmFtZSI6bnVsbH1dLCJraW5kIjoicGFydCIsIm1hdGVyaWFsIjpudWxsLCJtZXRhZGF0YSI6e30sIm5hbWUiOm51bGwsInBhcnRfaWQiOiJmaXh0dXJlX3BhcnQifQ==", + "valid": true + }, + { + "expected": null, + "name": "valid_forwarded_connector_null_offset", + "payload_base64": "eyJhc3NlbWJseV9pZCI6ImZpeHR1cmVfYXNzZW1ibHkiLCJjb21wb25lbnRzIjpbeyJjb21wb25lbnRfaWQiOiJjb21wb25lbnRfYSIsImRlZmluaXRpb25fcmVmIjoiZGVmaW5pdGlvbi9yb290L3BhcnQvZml4dHVyZSIsImxvY2FsX3BsYWNlbWVudCI6eyJvcmlnaW4iOlswLDAsMF0sInhfYXhpcyI6WzEsMCwwXSwieV9heGlzIjpbMCwxLDBdLCJ6X2F4aXMiOlswLDAsMV19LCJuYW1lIjpudWxsfV0sImNvbm5lY3RvcnMiOlt7ImFuY2hvciI6eyJhbmNob3Jfa2luZCI6ImZvcndhcmRlZCIsIm9mZnNldCI6bnVsbCwic291cmNlX2NvbXBvbmVudF9pZCI6ImNvbXBvbmVudF9hIiwic291cmNlX2Nvbm5lY3Rvcl9pZCI6InBsYWNlbWVudCJ9LCJjb25uZWN0b3JfaWQiOiJmb3J3YXJkZWQiLCJuYW1lIjpudWxsfV0sImNvbnN0cmFpbnRzIjpbXSwiZ3JvdW5kZWRfY29tcG9uZW50X2lkcyI6WyJjb21wb25lbnRfYSJdLCJraW5kIjoiYXNzZW1ibHkiLCJtZXRhZGF0YSI6e30sIm5hbWUiOm51bGx9", + "valid": true + }, + { + "expected": null, + "name": "valid_forwarded_connector_offset", + "payload_base64": "eyJhc3NlbWJseV9pZCI6ImZpeHR1cmVfYXNzZW1ibHkiLCJjb21wb25lbnRzIjpbeyJjb21wb25lbnRfaWQiOiJjb21wb25lbnRfYSIsImRlZmluaXRpb25fcmVmIjoiZGVmaW5pdGlvbi9yb290L3BhcnQvZml4dHVyZSIsImxvY2FsX3BsYWNlbWVudCI6eyJvcmlnaW4iOlswLDAsMF0sInhfYXhpcyI6WzEsMCwwXSwieV9heGlzIjpbMCwxLDBdLCJ6X2F4aXMiOlswLDAsMV19LCJuYW1lIjpudWxsfV0sImNvbm5lY3RvcnMiOlt7ImFuY2hvciI6eyJhbmNob3Jfa2luZCI6ImZvcndhcmRlZCIsIm9mZnNldCI6eyJvcmlnaW4iOlswLDAsNV0sInhfYXhpcyI6WzEsMCwwXSwieV9heGlzIjpbMCwxLDBdLCJ6X2F4aXMiOlswLDAsMV19LCJzb3VyY2VfY29tcG9uZW50X2lkIjoiY29tcG9uZW50X2EiLCJzb3VyY2VfY29ubmVjdG9yX2lkIjoicGxhY2VtZW50In0sImNvbm5lY3Rvcl9pZCI6ImZvcndhcmRlZCIsIm5hbWUiOm51bGx9XSwiY29uc3RyYWludHMiOltdLCJncm91bmRlZF9jb21wb25lbnRfaWRzIjpbImNvbXBvbmVudF9hIl0sImtpbmQiOiJhc3NlbWJseSIsIm1ldGFkYXRhIjp7fSwibmFtZSI6bnVsbH0=", + "valid": true + } + ], + "numeric_vectors": [ + { + "expected_bits": "00000000", + "input_bits": [ + "0000000000000000" + ], + "name": "positive_zero", + "operation": "f32", + "valid": true + }, + { + "expected_bits": "00000000", + "input_bits": [ + "8000000000000000" + ], + "name": "negative_zero", + "operation": "f32", + "valid": true + }, + { + "expected_bits": "00000000", + "input_bits": [ + "0000000000000001" + ], + "name": "smallest_binary64_subnormal", + "operation": "f32", + "valid": true + }, + { + "expected_bits": "00000000", + "input_bits": [ + "8000000000000001" + ], + "name": "negative_smallest_binary64_subnormal", + "operation": "f32", + "valid": true + }, + { + "expected_bits": "00000001", + "input_bits": [ + "36a0000000000000" + ], + "name": "binary32_smallest_subnormal", + "operation": "f32", + "valid": true + }, + { + "expected_bits": "3f800000", + "input_bits": [ + "3ff0000010000000" + ], + "name": "halfway_tie_to_even", + "operation": "f32", + "valid": true + }, + { + "expected_bits": "3f800001", + "input_bits": [ + "3ff0000010000001" + ], + "name": "just_above_halfway", + "operation": "f32", + "valid": true + }, + { + "expected_bits": "7f7fffff", + "input_bits": [ + "47efffffe0000000" + ], + "name": "binary32_largest_finite", + "operation": "f32", + "valid": true + }, + { + "error": "f32 conversion overflowed", + "input_bits": [ + "7fefffffffffffff" + ], + "name": "positive_overflow", + "operation": "f32", + "valid": false + }, + { + "error": "f32 conversion overflowed", + "input_bits": [ + "ffefffffffffffff" + ], + "name": "negative_overflow", + "operation": "f32", + "valid": false + }, + { + "error": "f32 input must be a finite number", + "input_bits": [ + "7ff0000000000000" + ], + "name": "positive_infinity", + "operation": "f32", + "valid": false + }, + { + "error": "f32 input must be a finite number", + "input_bits": [ + "7ff8000000000000" + ], + "name": "quiet_nan", + "operation": "f32", + "valid": false + }, + { + "expected_bits": [ + "0000000000000000", + "0000000000000000", + "3ff0000000000000" + ], + "input_bits": [ + [ + "3ff0000000000000", + "0000000000000000", + "0000000000000000" + ], + [ + "0000000000000000", + "3ff0000000000000", + "0000000000000000" + ] + ], + "name": "basis_cross", + "operation": "cross", + "valid": true + }, + { + "expected_bits": [ + "0000000000000000", + "0000000000000000", + "0000000000000000" + ], + "input_bits": [ + [ + "41a0000002000000", + "41a0000000000000", + "0000000000000000" + ], + [ + "41a0000000000000", + "419ffffffc000000", + "0000000000000000" + ] + ], + "name": "cancellation_cross", + "operation": "cross", + "valid": true + }, + { + "expected_bits": [ + "3f19999a", + "3f4ccccd", + "00000000" + ], + "input_bits": [ + [ + "4008000000000000", + "4010000000000000", + "8000000000000000" + ] + ], + "name": "normalize_3_4_0", + "operation": "normalize", + "valid": true + }, + { + "expected_bits": [ + "3f13cd3a", + "3f13cd3a", + "3f13cd3a" + ], + "input_bits": [ + [ + "3ff0000000000000", + "3ff0000000000000", + "3ff0000000000000" + ] + ], + "name": "normalize_equal_components", + "operation": "normalize", + "valid": true + }, + { + "error": "normalize input has zero or non-finite length", + "input_bits": [ + [ + "0000000000000000", + "0000000000000000", + "0000000000000000" + ] + ], + "name": "normalize_zero", + "operation": "normalize", + "valid": false + }, + { + "error": "normalize input has zero or non-finite length", + "input_bits": [ + [ + "7fefffffffffffff", + "3ff0000000000000", + "0000000000000000" + ] + ], + "name": "normalize_overflow", + "operation": "normalize", + "valid": false + } + ], + "package_cases": [ + { + "blob_uris": [ + "edges/sha256-6ce481db1fe13bcfe7a2374423e6c72d757d51c99168b287c52f53aa625ccd67.glb", + "entities/sha256-3897cc9ebdb2941835964a435821b556e01b175535cff8fc0eaaafb684c4ddf5.json", + "geometry/sha256-200523414de281a8a155086a54cd0f880f640485765a48ccdd8ec230f67dd17c.glb" + ], + "expected": null, + "manifest_base64": "eyJhbm5vdGF0aW9ucyI6W10sImFwcGVhcmFuY2VzIjpbeyJhbHBoYV9tb2RlIjoib3BhcXVlIiwiYXBwZWFyYW5jZV9pZCI6ImFwcGVhcmFuY2UvZXZhbHVhdGVkL2JmMWNhNmQ2MGFmMjE3NjdhY2UxNDE5YjYwNTUyMTdjODcxNDU4OWI3OTA5MGQyNzBhYWJlNTEyMjMwZWZhM2EiLCJiYXNlX2NvbG9yIjpbMC43MiwwLjc1LDAuNzgsMV0sImRvdWJsZV9zaWRlZCI6ZmFsc2UsImVkZ2VfY29sb3IiOlswLjA4LDAuMDksMC4xLDFdLCJtZXRhbGxpYyI6MCwibmFtZSI6bnVsbCwicm91Z2huZXNzIjowLjU1LCJzZGtfbWV0YWRhdGEiOnt9LCJzb3VyY2UiOm51bGx9XSwiY2FtZXJhcyI6W10sImNvbXBpbGVfb3B0aW9ucyI6eyJhbmd1bGFyX3RvbGVyYW5jZSI6MC4yMiwiZW1iZWRfcHJlc2VudGF0aW9uIjpmYWxzZSwiZW1iZWRfc291cmNlIjpmYWxzZSwibGluZWFyX3RvbGVyYW5jZSI6MC4zNX0sImNvbm5lY3RvcnMiOltdLCJjb29yZGluYXRlX3N5c3RlbSI6eyJoYW5kZWRuZXNzIjoicmlnaHQiLCJsZW5ndGhfdW5pdCI6Im1tIiwidXBfYXhpcyI6IitaIn0sImRlZmluaXRpb25zIjpbeyJhcHBlYXJhbmNlX2lkIjoiYXBwZWFyYW5jZS9ldmFsdWF0ZWQvYmYxY2E2ZDYwYWYyMTc2N2FjZTE0MTliNjA1NTIxN2M4NzE0NTg5Yjc5MDkwZDI3MGFhYmU1MTIyMzBlZmEzYSIsImRlZmluaXRpb25faWQiOiJkZWZpbml0aW9uL3Jvb3Qvc2hhcGUvbWFudWFsL2ZpeHR1cmUiLCJlZGdlX2Fzc2V0X2lkIjoic2hhMjU2OjZjZTQ4MWRiMWZlMTNiY2ZlN2EyMzc0NDIzZTZjNzJkNzU3ZDUxYzk5MTY4YjI4N2M1MmY1M2FhNjI1Y2NkNjciLCJlbnRpdHlfYXNzZXRfaWQiOiJzaGEyNTY6Mzg5N2NjOWViZGIyOTQxODM1OTY0YTQzNTgyMWI1NTZlMDFiMTc1NTM1Y2ZmOGZjMGVhYWFmYjY4NGM0ZGRmNSIsImdlb21ldHJ5X2Fzc2V0X2lkIjoic2hhMjU2OjIwMDUyMzQxNGRlMjgxYThhMTU1MDg2YTU0Y2QwZjg4MGY2NDA0ODU3NjVhNDhjY2RkOGVjMjMwZjY3ZGQxN2MiLCJraW5kIjoic2hhcGUiLCJuYW1lIjpudWxsLCJzZGtfbWV0YWRhdGEiOnt9LCJzb3VyY2UiOnsia2luZCI6Im1hbnVhbCIsInJvb3RfaWQiOiJyb290Iiwic291cmNlX2lkIjoiZml4dHVyZSJ9fV0sImRpYWdub3N0aWNzIjpbXSwiZWRnZV9hc3NldHMiOlt7ImFzc2V0X2lkIjoic2hhMjU2OjZjZTQ4MWRiMWZlMTNiY2ZlN2EyMzc0NDIzZTZjNzJkNzU3ZDUxYzk5MTY4YjI4N2M1MmY1M2FhNjI1Y2NkNjciLCJhc3NldF90b19zY2VuZSI6WzEwMDAsMCwwLDAsMCwwLC0xMDAwLDAsMCwxMDAwLDAsMCwwLDAsMCwxXSwiYnl0ZV9sZW5ndGgiOjc0MCwiY29udGVudF9oYXNoIjoic2hhMjU2OjZjZTQ4MWRiMWZlMTNiY2ZlN2EyMzc0NDIzZTZjNzJkNzU3ZDUxYzk5MTY4YjI4N2M1MmY1M2FhNjI1Y2NkNjciLCJtZWRpYV90eXBlIjoibW9kZWwvZ2x0Zi1iaW5hcnkiLCJzY2VuZV9sb2NhbF9ib3VuZHMiOnsibWF4IjpbMTAwMCwwLDEwMDBdLCJtaW4iOlswLC0xMDAwLDBdfSwidGVzc2VsbGF0aW9uIjp7ImxpbmVhcl90b2xlcmFuY2UiOjAuMzV9LCJ1cmkiOiJlZGdlcy9zaGEyNTYtNmNlNDgxZGIxZmUxM2JjZmU3YTIzNzQ0MjNlNmM3MmQ3NTdkNTFjOTkxNjhiMjg3YzUyZjUzYWE2MjVjY2Q2Ny5nbGIifV0sImVudGl0eV9hc3NldHMiOlt7ImJ5dGVfbGVuZ3RoIjoxMTUxNywiY29udGVudF9oYXNoIjoic2hhMjU2OjM4OTdjYzllYmRiMjk0MTgzNTk2NGE0MzU4MjFiNTU2ZTAxYjE3NTUzNWNmZjhmYzBlYWFhZmI2ODRjNGRkZjUiLCJlbnRpdHlfYXNzZXRfaWQiOiJzaGEyNTY6Mzg5N2NjOWViZGIyOTQxODM1OTY0YTQzNTgyMWI1NTZlMDFiMTc1NTM1Y2ZmOGZjMGVhYWFmYjY4NGM0ZGRmNSIsIm1lZGlhX3R5cGUiOiJhcHBsaWNhdGlvbi92bmQuc2ltcGxlY2FkLmVudGl0aWVzK2pzb24iLCJ1cmkiOiJlbnRpdGllcy9zaGEyNTYtMzg5N2NjOWViZGIyOTQxODM1OTY0YTQzNTgyMWI1NTZlMDFiMTc1NTM1Y2ZmOGZjMGVhYWFmYjY4NGM0ZGRmNS5qc29uIn1dLCJleHRlbnNpb25zIjp7fSwiZXh0ZW5zaW9uc19yZXF1aXJlZCI6W10sImV4dGVuc2lvbnNfdXNlZCI6W10sImdlbmVyYXRvciI6eyJuYW1lIjoic2ltcGxlY2FkYXBpIiwib2NwX2JpbmRpbmdzX3ZlcnNpb24iOiJmaXh0dXJlIiwib2NwX3ZlcnNpb24iOiJmaXh0dXJlIiwicGxhdGZvcm1fdGFnIjoiZml4dHVyZSIsInByb2ZpbGUiOiJzY2VuZS0xLjAtb2NwLWdsYi0yIiwicHl0aG9uX2FiaSI6ImZpeHR1cmUiLCJzaW1wbGVjYWRhcGlfdmVyc2lvbiI6ImZpeHR1cmUiLCJ0b29sY2hhaW5faGFzaCI6InNoYTI1NjowMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwIn0sImdlb21ldHJ5X2Fzc2V0cyI6W3siYXNzZXRfaWQiOiJzaGEyNTY6MjAwNTIzNDE0ZGUyODFhOGExNTUwODZhNTRjZDBmODgwZjY0MDQ4NTc2NWE0OGNjZGQ4ZWMyMzBmNjdkZDE3YyIsImFzc2V0X3RvX3NjZW5lIjpbMTAwMCwwLDAsMCwwLDAsLTEwMDAsMCwwLDEwMDAsMCwwLDAsMCwwLDFdLCJieXRlX2xlbmd0aCI6MTAyMCwiY29udGVudF9oYXNoIjoic2hhMjU2OjIwMDUyMzQxNGRlMjgxYThhMTU1MDg2YTU0Y2QwZjg4MGY2NDA0ODU3NjVhNDhjY2RkOGVjMjMwZjY3ZGQxN2MiLCJtZWRpYV90eXBlIjoibW9kZWwvZ2x0Zi1iaW5hcnkiLCJzY2VuZV9sb2NhbF9ib3VuZHMiOnsibWF4IjpbMTAwMCwwLDEwMDBdLCJtaW4iOlswLC0xMDAwLDBdfSwidGVzc2VsbGF0aW9uIjp7ImFuZ3VsYXJfdG9sZXJhbmNlIjowLjIyLCJsaW5lYXJfdG9sZXJhbmNlIjowLjM1fSwidXJpIjoiZ2VvbWV0cnkvc2hhMjU2LTIwMDUyMzQxNGRlMjgxYThhMTU1MDg2YTU0Y2QwZjg4MGY2NDA0ODU3NjVhNDhjY2RkOGVjMjMwZjY3ZGQxN2MuZ2xiIn1dLCJsaWdodHMiOltdLCJub2RlcyI6W3siYXBwZWFyYW5jZV9vdmVycmlkZV9pZCI6bnVsbCwiZGVmaW5pdGlvbl9pZCI6ImRlZmluaXRpb24vcm9vdC9zaGFwZS9tYW51YWwvZml4dHVyZSIsIm5hbWUiOm51bGwsIm5vZGVfaWQiOiJpbnN0YW5jZS9yb290Iiwib3JkZXIiOjAsInBhcmVudF9ub2RlX2lkIjpudWxsLCJzZGtfbWV0YWRhdGEiOnt9LCJzZWxlY3RhYmxlIjp0cnVlLCJzb3VyY2UiOnsia2luZCI6InNoYXBlX3Jvb3QiLCJyb290X2lkIjoicm9vdCJ9LCJ0cmFuc2Zvcm0iOnsib3JpZ2luIjpbMCwwLDBdLCJ4X2F4aXMiOlsxLDAsMF0sInlfYXhpcyI6WzAsMSwwXSwiel9heGlzIjpbMCwwLDFdfSwidmlzaWJsZSI6dHJ1ZX1dLCJyZXZpc2lvbiI6InNoYTI1NjpmYWUxNWY0YWVlZjEyNzdjZDgzMWZmOGJmODNmOWIyNDRkNWM0MjI1ZTcyZWI3YmMyZTBlMzVkZDZjZjQzNjE0Iiwic2NlbmVfaWQiOiJmaXh0dXJlIiwic2NoZW1hX3ZlcnNpb24iOiIxLjAiLCJzb3VyY2UiOnsia2luZCI6Im1hbnVhbCIsInNvdXJjZV9pZCI6ImZpeHR1cmUifX0=", + "name": "valid_package", + "valid": true + }, + { + "blob_uris": [ + "edges/sha256-6ce481db1fe13bcfe7a2374423e6c72d757d51c99168b287c52f53aa625ccd67.glb", + "entities/sha256-3897cc9ebdb2941835964a435821b556e01b175535cff8fc0eaaafb684c4ddf5.json", + "geometry/sha256-200523414de281a8a155086a54cd0f880f640485765a48ccdd8ec230f67dd17c.glb" + ], + "expected": { + "code": "bounds_invalid", + "path": "/definitions/0" + }, + "manifest_base64": "eyJhbm5vdGF0aW9ucyI6W10sImFwcGVhcmFuY2VzIjpbeyJhbHBoYV9tb2RlIjoib3BhcXVlIiwiYXBwZWFyYW5jZV9pZCI6ImFwcGVhcmFuY2UvZXZhbHVhdGVkL2JmMWNhNmQ2MGFmMjE3NjdhY2UxNDE5YjYwNTUyMTdjODcxNDU4OWI3OTA5MGQyNzBhYWJlNTEyMjMwZWZhM2EiLCJiYXNlX2NvbG9yIjpbMC43MiwwLjc1LDAuNzgsMV0sImRvdWJsZV9zaWRlZCI6ZmFsc2UsImVkZ2VfY29sb3IiOlswLjA4LDAuMDksMC4xLDFdLCJtZXRhbGxpYyI6MCwibmFtZSI6bnVsbCwicm91Z2huZXNzIjowLjU1LCJzZGtfbWV0YWRhdGEiOnt9LCJzb3VyY2UiOm51bGx9XSwiY2FtZXJhcyI6W10sImNvbXBpbGVfb3B0aW9ucyI6eyJhbmd1bGFyX3RvbGVyYW5jZSI6MC4yMiwiZW1iZWRfcHJlc2VudGF0aW9uIjpmYWxzZSwiZW1iZWRfc291cmNlIjpmYWxzZSwibGluZWFyX3RvbGVyYW5jZSI6MC4zNX0sImNvbm5lY3RvcnMiOltdLCJjb29yZGluYXRlX3N5c3RlbSI6eyJoYW5kZWRuZXNzIjoicmlnaHQiLCJsZW5ndGhfdW5pdCI6Im1tIiwidXBfYXhpcyI6IitaIn0sImRlZmluaXRpb25zIjpbeyJhcHBlYXJhbmNlX2lkIjoiYXBwZWFyYW5jZS9ldmFsdWF0ZWQvYmYxY2E2ZDYwYWYyMTc2N2FjZTE0MTliNjA1NTIxN2M4NzE0NTg5Yjc5MDkwZDI3MGFhYmU1MTIyMzBlZmEzYSIsImRlZmluaXRpb25faWQiOiJkZWZpbml0aW9uL3Jvb3Qvc2hhcGUvbWFudWFsL2ZpeHR1cmUiLCJlZGdlX2Fzc2V0X2lkIjoic2hhMjU2OjZjZTQ4MWRiMWZlMTNiY2ZlN2EyMzc0NDIzZTZjNzJkNzU3ZDUxYzk5MTY4YjI4N2M1MmY1M2FhNjI1Y2NkNjciLCJlbnRpdHlfYXNzZXRfaWQiOiJzaGEyNTY6Mzg5N2NjOWViZGIyOTQxODM1OTY0YTQzNTgyMWI1NTZlMDFiMTc1NTM1Y2ZmOGZjMGVhYWFmYjY4NGM0ZGRmNSIsImdlb21ldHJ5X2Fzc2V0X2lkIjoic2hhMjU2OjIwMDUyMzQxNGRlMjgxYThhMTU1MDg2YTU0Y2QwZjg4MGY2NDA0ODU3NjVhNDhjY2RkOGVjMjMwZjY3ZGQxN2MiLCJraW5kIjoic2hhcGUiLCJuYW1lIjpudWxsLCJzZGtfbWV0YWRhdGEiOnt9LCJzb3VyY2UiOnsia2luZCI6Im1hbnVhbCIsInJvb3RfaWQiOiJyb290Iiwic291cmNlX2lkIjoiZml4dHVyZSJ9fV0sImRpYWdub3N0aWNzIjpbXSwiZWRnZV9hc3NldHMiOlt7ImFzc2V0X2lkIjoic2hhMjU2OjZjZTQ4MWRiMWZlMTNiY2ZlN2EyMzc0NDIzZTZjNzJkNzU3ZDUxYzk5MTY4YjI4N2M1MmY1M2FhNjI1Y2NkNjciLCJhc3NldF90b19zY2VuZSI6WzEwMDAsMCwwLDAsMCwwLC0xMDAwLDAsMCwxMDAwLDAsMCwwLDAsMCwxXSwiYnl0ZV9sZW5ndGgiOjc0MCwiY29udGVudF9oYXNoIjoic2hhMjU2OjZjZTQ4MWRiMWZlMTNiY2ZlN2EyMzc0NDIzZTZjNzJkNzU3ZDUxYzk5MTY4YjI4N2M1MmY1M2FhNjI1Y2NkNjciLCJtZWRpYV90eXBlIjoibW9kZWwvZ2x0Zi1iaW5hcnkiLCJzY2VuZV9sb2NhbF9ib3VuZHMiOnsibWF4IjpbOTk5LDAsMTAwMF0sIm1pbiI6WzAsLTEwMDAsMF19LCJ0ZXNzZWxsYXRpb24iOnsibGluZWFyX3RvbGVyYW5jZSI6MC4zNX0sInVyaSI6ImVkZ2VzL3NoYTI1Ni02Y2U0ODFkYjFmZTEzYmNmZTdhMjM3NDQyM2U2YzcyZDc1N2Q1MWM5OTE2OGIyODdjNTJmNTNhYTYyNWNjZDY3LmdsYiJ9XSwiZW50aXR5X2Fzc2V0cyI6W3siYnl0ZV9sZW5ndGgiOjExNTE3LCJjb250ZW50X2hhc2giOiJzaGEyNTY6Mzg5N2NjOWViZGIyOTQxODM1OTY0YTQzNTgyMWI1NTZlMDFiMTc1NTM1Y2ZmOGZjMGVhYWFmYjY4NGM0ZGRmNSIsImVudGl0eV9hc3NldF9pZCI6InNoYTI1NjozODk3Y2M5ZWJkYjI5NDE4MzU5NjRhNDM1ODIxYjU1NmUwMWIxNzU1MzVjZmY4ZmMwZWFhYWZiNjg0YzRkZGY1IiwibWVkaWFfdHlwZSI6ImFwcGxpY2F0aW9uL3ZuZC5zaW1wbGVjYWQuZW50aXRpZXMranNvbiIsInVyaSI6ImVudGl0aWVzL3NoYTI1Ni0zODk3Y2M5ZWJkYjI5NDE4MzU5NjRhNDM1ODIxYjU1NmUwMWIxNzU1MzVjZmY4ZmMwZWFhYWZiNjg0YzRkZGY1Lmpzb24ifV0sImV4dGVuc2lvbnMiOnt9LCJleHRlbnNpb25zX3JlcXVpcmVkIjpbXSwiZXh0ZW5zaW9uc191c2VkIjpbXSwiZ2VuZXJhdG9yIjp7Im5hbWUiOiJzaW1wbGVjYWRhcGkiLCJvY3BfYmluZGluZ3NfdmVyc2lvbiI6ImZpeHR1cmUiLCJvY3BfdmVyc2lvbiI6ImZpeHR1cmUiLCJwbGF0Zm9ybV90YWciOiJmaXh0dXJlIiwicHJvZmlsZSI6InNjZW5lLTEuMC1vY3AtZ2xiLTIiLCJweXRob25fYWJpIjoiZml4dHVyZSIsInNpbXBsZWNhZGFwaV92ZXJzaW9uIjoiZml4dHVyZSIsInRvb2xjaGFpbl9oYXNoIjoic2hhMjU2OjAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAifSwiZ2VvbWV0cnlfYXNzZXRzIjpbeyJhc3NldF9pZCI6InNoYTI1NjoyMDA1MjM0MTRkZTI4MWE4YTE1NTA4NmE1NGNkMGY4ODBmNjQwNDg1NzY1YTQ4Y2NkZDhlYzIzMGY2N2RkMTdjIiwiYXNzZXRfdG9fc2NlbmUiOlsxMDAwLDAsMCwwLDAsMCwtMTAwMCwwLDAsMTAwMCwwLDAsMCwwLDAsMV0sImJ5dGVfbGVuZ3RoIjoxMDIwLCJjb250ZW50X2hhc2giOiJzaGEyNTY6MjAwNTIzNDE0ZGUyODFhOGExNTUwODZhNTRjZDBmODgwZjY0MDQ4NTc2NWE0OGNjZGQ4ZWMyMzBmNjdkZDE3YyIsIm1lZGlhX3R5cGUiOiJtb2RlbC9nbHRmLWJpbmFyeSIsInNjZW5lX2xvY2FsX2JvdW5kcyI6eyJtYXgiOls5OTksMCwxMDAwXSwibWluIjpbMCwtMTAwMCwwXX0sInRlc3NlbGxhdGlvbiI6eyJhbmd1bGFyX3RvbGVyYW5jZSI6MC4yMiwibGluZWFyX3RvbGVyYW5jZSI6MC4zNX0sInVyaSI6Imdlb21ldHJ5L3NoYTI1Ni0yMDA1MjM0MTRkZTI4MWE4YTE1NTA4NmE1NGNkMGY4ODBmNjQwNDg1NzY1YTQ4Y2NkZDhlYzIzMGY2N2RkMTdjLmdsYiJ9XSwibGlnaHRzIjpbXSwibm9kZXMiOlt7ImFwcGVhcmFuY2Vfb3ZlcnJpZGVfaWQiOm51bGwsImRlZmluaXRpb25faWQiOiJkZWZpbml0aW9uL3Jvb3Qvc2hhcGUvbWFudWFsL2ZpeHR1cmUiLCJuYW1lIjpudWxsLCJub2RlX2lkIjoiaW5zdGFuY2Uvcm9vdCIsIm9yZGVyIjowLCJwYXJlbnRfbm9kZV9pZCI6bnVsbCwic2RrX21ldGFkYXRhIjp7fSwic2VsZWN0YWJsZSI6dHJ1ZSwic291cmNlIjp7ImtpbmQiOiJzaGFwZV9yb290Iiwicm9vdF9pZCI6InJvb3QifSwidHJhbnNmb3JtIjp7Im9yaWdpbiI6WzAsMCwwXSwieF9heGlzIjpbMSwwLDBdLCJ5X2F4aXMiOlswLDEsMF0sInpfYXhpcyI6WzAsMCwxXX0sInZpc2libGUiOnRydWV9XSwicmV2aXNpb24iOiJzaGEyNTY6NmI3MjFkMWNhYjcxZDcyYzZmYzYyOTY1ZTBmYmQyZTQ1NzY1ZmIxZjRmYTU3MTdlYmRjYzcwYTVjOTNlNjhlNSIsInNjZW5lX2lkIjoiZml4dHVyZSIsInNjaGVtYV92ZXJzaW9uIjoiMS4wIiwic291cmNlIjp7ImtpbmQiOiJtYW51YWwiLCJzb3VyY2VfaWQiOiJmaXh0dXJlIn19", + "name": "glb_bounds_mismatch", + "valid": false + }, + { + "blob_uris": [ + "entities/sha256-3897cc9ebdb2941835964a435821b556e01b175535cff8fc0eaaafb684c4ddf5.json", + "geometry/sha256-200523414de281a8a155086a54cd0f880f640485765a48ccdd8ec230f67dd17c.glb" + ], + "expected": { + "code": "package_member_set_invalid", + "path": "" + }, + "manifest_base64": "eyJhbm5vdGF0aW9ucyI6W10sImFwcGVhcmFuY2VzIjpbeyJhbHBoYV9tb2RlIjoib3BhcXVlIiwiYXBwZWFyYW5jZV9pZCI6ImFwcGVhcmFuY2UvZXZhbHVhdGVkL2JmMWNhNmQ2MGFmMjE3NjdhY2UxNDE5YjYwNTUyMTdjODcxNDU4OWI3OTA5MGQyNzBhYWJlNTEyMjMwZWZhM2EiLCJiYXNlX2NvbG9yIjpbMC43MiwwLjc1LDAuNzgsMV0sImRvdWJsZV9zaWRlZCI6ZmFsc2UsImVkZ2VfY29sb3IiOlswLjA4LDAuMDksMC4xLDFdLCJtZXRhbGxpYyI6MCwibmFtZSI6bnVsbCwicm91Z2huZXNzIjowLjU1LCJzZGtfbWV0YWRhdGEiOnt9LCJzb3VyY2UiOm51bGx9XSwiY2FtZXJhcyI6W10sImNvbXBpbGVfb3B0aW9ucyI6eyJhbmd1bGFyX3RvbGVyYW5jZSI6MC4yMiwiZW1iZWRfcHJlc2VudGF0aW9uIjpmYWxzZSwiZW1iZWRfc291cmNlIjpmYWxzZSwibGluZWFyX3RvbGVyYW5jZSI6MC4zNX0sImNvbm5lY3RvcnMiOltdLCJjb29yZGluYXRlX3N5c3RlbSI6eyJoYW5kZWRuZXNzIjoicmlnaHQiLCJsZW5ndGhfdW5pdCI6Im1tIiwidXBfYXhpcyI6IitaIn0sImRlZmluaXRpb25zIjpbeyJhcHBlYXJhbmNlX2lkIjoiYXBwZWFyYW5jZS9ldmFsdWF0ZWQvYmYxY2E2ZDYwYWYyMTc2N2FjZTE0MTliNjA1NTIxN2M4NzE0NTg5Yjc5MDkwZDI3MGFhYmU1MTIyMzBlZmEzYSIsImRlZmluaXRpb25faWQiOiJkZWZpbml0aW9uL3Jvb3Qvc2hhcGUvbWFudWFsL2ZpeHR1cmUiLCJlZGdlX2Fzc2V0X2lkIjoic2hhMjU2OjZjZTQ4MWRiMWZlMTNiY2ZlN2EyMzc0NDIzZTZjNzJkNzU3ZDUxYzk5MTY4YjI4N2M1MmY1M2FhNjI1Y2NkNjciLCJlbnRpdHlfYXNzZXRfaWQiOiJzaGEyNTY6Mzg5N2NjOWViZGIyOTQxODM1OTY0YTQzNTgyMWI1NTZlMDFiMTc1NTM1Y2ZmOGZjMGVhYWFmYjY4NGM0ZGRmNSIsImdlb21ldHJ5X2Fzc2V0X2lkIjoic2hhMjU2OjIwMDUyMzQxNGRlMjgxYThhMTU1MDg2YTU0Y2QwZjg4MGY2NDA0ODU3NjVhNDhjY2RkOGVjMjMwZjY3ZGQxN2MiLCJraW5kIjoic2hhcGUiLCJuYW1lIjpudWxsLCJzZGtfbWV0YWRhdGEiOnt9LCJzb3VyY2UiOnsia2luZCI6Im1hbnVhbCIsInJvb3RfaWQiOiJyb290Iiwic291cmNlX2lkIjoiZml4dHVyZSJ9fV0sImRpYWdub3N0aWNzIjpbXSwiZWRnZV9hc3NldHMiOlt7ImFzc2V0X2lkIjoic2hhMjU2OjZjZTQ4MWRiMWZlMTNiY2ZlN2EyMzc0NDIzZTZjNzJkNzU3ZDUxYzk5MTY4YjI4N2M1MmY1M2FhNjI1Y2NkNjciLCJhc3NldF90b19zY2VuZSI6WzEwMDAsMCwwLDAsMCwwLC0xMDAwLDAsMCwxMDAwLDAsMCwwLDAsMCwxXSwiYnl0ZV9sZW5ndGgiOjc0MCwiY29udGVudF9oYXNoIjoic2hhMjU2OjZjZTQ4MWRiMWZlMTNiY2ZlN2EyMzc0NDIzZTZjNzJkNzU3ZDUxYzk5MTY4YjI4N2M1MmY1M2FhNjI1Y2NkNjciLCJtZWRpYV90eXBlIjoibW9kZWwvZ2x0Zi1iaW5hcnkiLCJzY2VuZV9sb2NhbF9ib3VuZHMiOnsibWF4IjpbMTAwMCwwLDEwMDBdLCJtaW4iOlswLC0xMDAwLDBdfSwidGVzc2VsbGF0aW9uIjp7ImxpbmVhcl90b2xlcmFuY2UiOjAuMzV9LCJ1cmkiOiJlZGdlcy9zaGEyNTYtNmNlNDgxZGIxZmUxM2JjZmU3YTIzNzQ0MjNlNmM3MmQ3NTdkNTFjOTkxNjhiMjg3YzUyZjUzYWE2MjVjY2Q2Ny5nbGIifV0sImVudGl0eV9hc3NldHMiOlt7ImJ5dGVfbGVuZ3RoIjoxMTUxNywiY29udGVudF9oYXNoIjoic2hhMjU2OjM4OTdjYzllYmRiMjk0MTgzNTk2NGE0MzU4MjFiNTU2ZTAxYjE3NTUzNWNmZjhmYzBlYWFhZmI2ODRjNGRkZjUiLCJlbnRpdHlfYXNzZXRfaWQiOiJzaGEyNTY6Mzg5N2NjOWViZGIyOTQxODM1OTY0YTQzNTgyMWI1NTZlMDFiMTc1NTM1Y2ZmOGZjMGVhYWFmYjY4NGM0ZGRmNSIsIm1lZGlhX3R5cGUiOiJhcHBsaWNhdGlvbi92bmQuc2ltcGxlY2FkLmVudGl0aWVzK2pzb24iLCJ1cmkiOiJlbnRpdGllcy9zaGEyNTYtMzg5N2NjOWViZGIyOTQxODM1OTY0YTQzNTgyMWI1NTZlMDFiMTc1NTM1Y2ZmOGZjMGVhYWFmYjY4NGM0ZGRmNS5qc29uIn1dLCJleHRlbnNpb25zIjp7fSwiZXh0ZW5zaW9uc19yZXF1aXJlZCI6W10sImV4dGVuc2lvbnNfdXNlZCI6W10sImdlbmVyYXRvciI6eyJuYW1lIjoic2ltcGxlY2FkYXBpIiwib2NwX2JpbmRpbmdzX3ZlcnNpb24iOiJmaXh0dXJlIiwib2NwX3ZlcnNpb24iOiJmaXh0dXJlIiwicGxhdGZvcm1fdGFnIjoiZml4dHVyZSIsInByb2ZpbGUiOiJzY2VuZS0xLjAtb2NwLWdsYi0yIiwicHl0aG9uX2FiaSI6ImZpeHR1cmUiLCJzaW1wbGVjYWRhcGlfdmVyc2lvbiI6ImZpeHR1cmUiLCJ0b29sY2hhaW5faGFzaCI6InNoYTI1NjowMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwIn0sImdlb21ldHJ5X2Fzc2V0cyI6W3siYXNzZXRfaWQiOiJzaGEyNTY6MjAwNTIzNDE0ZGUyODFhOGExNTUwODZhNTRjZDBmODgwZjY0MDQ4NTc2NWE0OGNjZGQ4ZWMyMzBmNjdkZDE3YyIsImFzc2V0X3RvX3NjZW5lIjpbMTAwMCwwLDAsMCwwLDAsLTEwMDAsMCwwLDEwMDAsMCwwLDAsMCwwLDFdLCJieXRlX2xlbmd0aCI6MTAyMCwiY29udGVudF9oYXNoIjoic2hhMjU2OjIwMDUyMzQxNGRlMjgxYThhMTU1MDg2YTU0Y2QwZjg4MGY2NDA0ODU3NjVhNDhjY2RkOGVjMjMwZjY3ZGQxN2MiLCJtZWRpYV90eXBlIjoibW9kZWwvZ2x0Zi1iaW5hcnkiLCJzY2VuZV9sb2NhbF9ib3VuZHMiOnsibWF4IjpbMTAwMCwwLDEwMDBdLCJtaW4iOlswLC0xMDAwLDBdfSwidGVzc2VsbGF0aW9uIjp7ImFuZ3VsYXJfdG9sZXJhbmNlIjowLjIyLCJsaW5lYXJfdG9sZXJhbmNlIjowLjM1fSwidXJpIjoiZ2VvbWV0cnkvc2hhMjU2LTIwMDUyMzQxNGRlMjgxYThhMTU1MDg2YTU0Y2QwZjg4MGY2NDA0ODU3NjVhNDhjY2RkOGVjMjMwZjY3ZGQxN2MuZ2xiIn1dLCJsaWdodHMiOltdLCJub2RlcyI6W3siYXBwZWFyYW5jZV9vdmVycmlkZV9pZCI6bnVsbCwiZGVmaW5pdGlvbl9pZCI6ImRlZmluaXRpb24vcm9vdC9zaGFwZS9tYW51YWwvZml4dHVyZSIsIm5hbWUiOm51bGwsIm5vZGVfaWQiOiJpbnN0YW5jZS9yb290Iiwib3JkZXIiOjAsInBhcmVudF9ub2RlX2lkIjpudWxsLCJzZGtfbWV0YWRhdGEiOnt9LCJzZWxlY3RhYmxlIjp0cnVlLCJzb3VyY2UiOnsia2luZCI6InNoYXBlX3Jvb3QiLCJyb290X2lkIjoicm9vdCJ9LCJ0cmFuc2Zvcm0iOnsib3JpZ2luIjpbMCwwLDBdLCJ4X2F4aXMiOlsxLDAsMF0sInlfYXhpcyI6WzAsMSwwXSwiel9heGlzIjpbMCwwLDFdfSwidmlzaWJsZSI6dHJ1ZX1dLCJyZXZpc2lvbiI6InNoYTI1NjpmYWUxNWY0YWVlZjEyNzdjZDgzMWZmOGJmODNmOWIyNDRkNWM0MjI1ZTcyZWI3YmMyZTBlMzVkZDZjZjQzNjE0Iiwic2NlbmVfaWQiOiJmaXh0dXJlIiwic2NoZW1hX3ZlcnNpb24iOiIxLjAiLCJzb3VyY2UiOnsia2luZCI6Im1hbnVhbCIsInNvdXJjZV9pZCI6ImZpeHR1cmUifX0=", + "name": "missing_blob", + "valid": false + }, + { + "blob_mutations": { + "geometry/sha256-200523414de281a8a155086a54cd0f880f640485765a48ccdd8ec230f67dd17c.glb": "ZmxURgIAAAD8AwAAqAIAAEpTT057ImFjY2Vzc29ycyI6W3siYnVmZmVyVmlldyI6MCwiY29tcG9uZW50VHlwZSI6NTEyNiwiY291bnQiOjEyLCJtYXgiOlsxLDEsMV0sIm1pbiI6WzAsMCwwXSwidHlwZSI6IlZFQzMifSx7ImJ1ZmZlclZpZXciOjEsImNvbXBvbmVudFR5cGUiOjUxMjYsImNvdW50IjoxMiwidHlwZSI6IlZFQzMifSx7ImJ1ZmZlclZpZXciOjIsImNvbXBvbmVudFR5cGUiOjUxMjMsImNvdW50IjoxMiwidHlwZSI6IlNDQUxBUiJ9XSwiYXNzZXQiOnsiZ2VuZXJhdG9yIjoiU2ltcGxlQ0FEIFNjZW5lIEdMQiBQcm9maWxlIDEiLCJ2ZXJzaW9uIjoiMi4wIn0sImJ1ZmZlclZpZXdzIjpbeyJidWZmZXIiOjAsImJ5dGVMZW5ndGgiOjE0NCwiYnl0ZU9mZnNldCI6MCwidGFyZ2V0IjozNDk2Mn0seyJidWZmZXIiOjAsImJ5dGVMZW5ndGgiOjE0NCwiYnl0ZU9mZnNldCI6MTQ0LCJ0YXJnZXQiOjM0OTYyfSx7ImJ1ZmZlciI6MCwiYnl0ZUxlbmd0aCI6MjQsImJ5dGVPZmZzZXQiOjI4OCwidGFyZ2V0IjozNDk2M31dLCJidWZmZXJzIjpbeyJieXRlTGVuZ3RoIjozMTJ9XSwibWVzaGVzIjpbeyJwcmltaXRpdmVzIjpbeyJhdHRyaWJ1dGVzIjp7Ik5PUk1BTCI6MSwiUE9TSVRJT04iOjB9LCJpbmRpY2VzIjoyLCJtb2RlIjo0fV19XSwibm9kZXMiOlt7Im1lc2giOjB9XSwic2NlbmUiOjAsInNjZW5lcyI6W3sibm9kZXMiOlswXX1dfTgBAABCSU4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIA/AAAAAAAAgD8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIA/AACAPwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgD8AAAAAAACAPwAAAAAAAAAAAAAAAAAAAAAAAIA/AAAAAAAAgD8AAAAAAACAPwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/Os0TPzrNEz86zRM/Os0TPzrNEz86zRM/Os0TPzrNEz86zRM/AAABAAIAAwAFAAQABgAHAAgACQALAAoA" + }, + "blob_uris": [ + "edges/sha256-6ce481db1fe13bcfe7a2374423e6c72d757d51c99168b287c52f53aa625ccd67.glb", + "entities/sha256-3897cc9ebdb2941835964a435821b556e01b175535cff8fc0eaaafb684c4ddf5.json", + "geometry/sha256-200523414de281a8a155086a54cd0f880f640485765a48ccdd8ec230f67dd17c.glb" + ], + "expected": { + "code": "blob_hash_mismatch", + "path": "/geometry/sha256-200523414de281a8a155086a54cd0f880f640485765a48ccdd8ec230f67dd17c.glb" + }, + "manifest_base64": "eyJhbm5vdGF0aW9ucyI6W10sImFwcGVhcmFuY2VzIjpbeyJhbHBoYV9tb2RlIjoib3BhcXVlIiwiYXBwZWFyYW5jZV9pZCI6ImFwcGVhcmFuY2UvZXZhbHVhdGVkL2JmMWNhNmQ2MGFmMjE3NjdhY2UxNDE5YjYwNTUyMTdjODcxNDU4OWI3OTA5MGQyNzBhYWJlNTEyMjMwZWZhM2EiLCJiYXNlX2NvbG9yIjpbMC43MiwwLjc1LDAuNzgsMV0sImRvdWJsZV9zaWRlZCI6ZmFsc2UsImVkZ2VfY29sb3IiOlswLjA4LDAuMDksMC4xLDFdLCJtZXRhbGxpYyI6MCwibmFtZSI6bnVsbCwicm91Z2huZXNzIjowLjU1LCJzZGtfbWV0YWRhdGEiOnt9LCJzb3VyY2UiOm51bGx9XSwiY2FtZXJhcyI6W10sImNvbXBpbGVfb3B0aW9ucyI6eyJhbmd1bGFyX3RvbGVyYW5jZSI6MC4yMiwiZW1iZWRfcHJlc2VudGF0aW9uIjpmYWxzZSwiZW1iZWRfc291cmNlIjpmYWxzZSwibGluZWFyX3RvbGVyYW5jZSI6MC4zNX0sImNvbm5lY3RvcnMiOltdLCJjb29yZGluYXRlX3N5c3RlbSI6eyJoYW5kZWRuZXNzIjoicmlnaHQiLCJsZW5ndGhfdW5pdCI6Im1tIiwidXBfYXhpcyI6IitaIn0sImRlZmluaXRpb25zIjpbeyJhcHBlYXJhbmNlX2lkIjoiYXBwZWFyYW5jZS9ldmFsdWF0ZWQvYmYxY2E2ZDYwYWYyMTc2N2FjZTE0MTliNjA1NTIxN2M4NzE0NTg5Yjc5MDkwZDI3MGFhYmU1MTIyMzBlZmEzYSIsImRlZmluaXRpb25faWQiOiJkZWZpbml0aW9uL3Jvb3Qvc2hhcGUvbWFudWFsL2ZpeHR1cmUiLCJlZGdlX2Fzc2V0X2lkIjoic2hhMjU2OjZjZTQ4MWRiMWZlMTNiY2ZlN2EyMzc0NDIzZTZjNzJkNzU3ZDUxYzk5MTY4YjI4N2M1MmY1M2FhNjI1Y2NkNjciLCJlbnRpdHlfYXNzZXRfaWQiOiJzaGEyNTY6Mzg5N2NjOWViZGIyOTQxODM1OTY0YTQzNTgyMWI1NTZlMDFiMTc1NTM1Y2ZmOGZjMGVhYWFmYjY4NGM0ZGRmNSIsImdlb21ldHJ5X2Fzc2V0X2lkIjoic2hhMjU2OjIwMDUyMzQxNGRlMjgxYThhMTU1MDg2YTU0Y2QwZjg4MGY2NDA0ODU3NjVhNDhjY2RkOGVjMjMwZjY3ZGQxN2MiLCJraW5kIjoic2hhcGUiLCJuYW1lIjpudWxsLCJzZGtfbWV0YWRhdGEiOnt9LCJzb3VyY2UiOnsia2luZCI6Im1hbnVhbCIsInJvb3RfaWQiOiJyb290Iiwic291cmNlX2lkIjoiZml4dHVyZSJ9fV0sImRpYWdub3N0aWNzIjpbXSwiZWRnZV9hc3NldHMiOlt7ImFzc2V0X2lkIjoic2hhMjU2OjZjZTQ4MWRiMWZlMTNiY2ZlN2EyMzc0NDIzZTZjNzJkNzU3ZDUxYzk5MTY4YjI4N2M1MmY1M2FhNjI1Y2NkNjciLCJhc3NldF90b19zY2VuZSI6WzEwMDAsMCwwLDAsMCwwLC0xMDAwLDAsMCwxMDAwLDAsMCwwLDAsMCwxXSwiYnl0ZV9sZW5ndGgiOjc0MCwiY29udGVudF9oYXNoIjoic2hhMjU2OjZjZTQ4MWRiMWZlMTNiY2ZlN2EyMzc0NDIzZTZjNzJkNzU3ZDUxYzk5MTY4YjI4N2M1MmY1M2FhNjI1Y2NkNjciLCJtZWRpYV90eXBlIjoibW9kZWwvZ2x0Zi1iaW5hcnkiLCJzY2VuZV9sb2NhbF9ib3VuZHMiOnsibWF4IjpbMTAwMCwwLDEwMDBdLCJtaW4iOlswLC0xMDAwLDBdfSwidGVzc2VsbGF0aW9uIjp7ImxpbmVhcl90b2xlcmFuY2UiOjAuMzV9LCJ1cmkiOiJlZGdlcy9zaGEyNTYtNmNlNDgxZGIxZmUxM2JjZmU3YTIzNzQ0MjNlNmM3MmQ3NTdkNTFjOTkxNjhiMjg3YzUyZjUzYWE2MjVjY2Q2Ny5nbGIifV0sImVudGl0eV9hc3NldHMiOlt7ImJ5dGVfbGVuZ3RoIjoxMTUxNywiY29udGVudF9oYXNoIjoic2hhMjU2OjM4OTdjYzllYmRiMjk0MTgzNTk2NGE0MzU4MjFiNTU2ZTAxYjE3NTUzNWNmZjhmYzBlYWFhZmI2ODRjNGRkZjUiLCJlbnRpdHlfYXNzZXRfaWQiOiJzaGEyNTY6Mzg5N2NjOWViZGIyOTQxODM1OTY0YTQzNTgyMWI1NTZlMDFiMTc1NTM1Y2ZmOGZjMGVhYWFmYjY4NGM0ZGRmNSIsIm1lZGlhX3R5cGUiOiJhcHBsaWNhdGlvbi92bmQuc2ltcGxlY2FkLmVudGl0aWVzK2pzb24iLCJ1cmkiOiJlbnRpdGllcy9zaGEyNTYtMzg5N2NjOWViZGIyOTQxODM1OTY0YTQzNTgyMWI1NTZlMDFiMTc1NTM1Y2ZmOGZjMGVhYWFmYjY4NGM0ZGRmNS5qc29uIn1dLCJleHRlbnNpb25zIjp7fSwiZXh0ZW5zaW9uc19yZXF1aXJlZCI6W10sImV4dGVuc2lvbnNfdXNlZCI6W10sImdlbmVyYXRvciI6eyJuYW1lIjoic2ltcGxlY2FkYXBpIiwib2NwX2JpbmRpbmdzX3ZlcnNpb24iOiJmaXh0dXJlIiwib2NwX3ZlcnNpb24iOiJmaXh0dXJlIiwicGxhdGZvcm1fdGFnIjoiZml4dHVyZSIsInByb2ZpbGUiOiJzY2VuZS0xLjAtb2NwLWdsYi0yIiwicHl0aG9uX2FiaSI6ImZpeHR1cmUiLCJzaW1wbGVjYWRhcGlfdmVyc2lvbiI6ImZpeHR1cmUiLCJ0b29sY2hhaW5faGFzaCI6InNoYTI1NjowMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwIn0sImdlb21ldHJ5X2Fzc2V0cyI6W3siYXNzZXRfaWQiOiJzaGEyNTY6MjAwNTIzNDE0ZGUyODFhOGExNTUwODZhNTRjZDBmODgwZjY0MDQ4NTc2NWE0OGNjZGQ4ZWMyMzBmNjdkZDE3YyIsImFzc2V0X3RvX3NjZW5lIjpbMTAwMCwwLDAsMCwwLDAsLTEwMDAsMCwwLDEwMDAsMCwwLDAsMCwwLDFdLCJieXRlX2xlbmd0aCI6MTAyMCwiY29udGVudF9oYXNoIjoic2hhMjU2OjIwMDUyMzQxNGRlMjgxYThhMTU1MDg2YTU0Y2QwZjg4MGY2NDA0ODU3NjVhNDhjY2RkOGVjMjMwZjY3ZGQxN2MiLCJtZWRpYV90eXBlIjoibW9kZWwvZ2x0Zi1iaW5hcnkiLCJzY2VuZV9sb2NhbF9ib3VuZHMiOnsibWF4IjpbMTAwMCwwLDEwMDBdLCJtaW4iOlswLC0xMDAwLDBdfSwidGVzc2VsbGF0aW9uIjp7ImFuZ3VsYXJfdG9sZXJhbmNlIjowLjIyLCJsaW5lYXJfdG9sZXJhbmNlIjowLjM1fSwidXJpIjoiZ2VvbWV0cnkvc2hhMjU2LTIwMDUyMzQxNGRlMjgxYThhMTU1MDg2YTU0Y2QwZjg4MGY2NDA0ODU3NjVhNDhjY2RkOGVjMjMwZjY3ZGQxN2MuZ2xiIn1dLCJsaWdodHMiOltdLCJub2RlcyI6W3siYXBwZWFyYW5jZV9vdmVycmlkZV9pZCI6bnVsbCwiZGVmaW5pdGlvbl9pZCI6ImRlZmluaXRpb24vcm9vdC9zaGFwZS9tYW51YWwvZml4dHVyZSIsIm5hbWUiOm51bGwsIm5vZGVfaWQiOiJpbnN0YW5jZS9yb290Iiwib3JkZXIiOjAsInBhcmVudF9ub2RlX2lkIjpudWxsLCJzZGtfbWV0YWRhdGEiOnt9LCJzZWxlY3RhYmxlIjp0cnVlLCJzb3VyY2UiOnsia2luZCI6InNoYXBlX3Jvb3QiLCJyb290X2lkIjoicm9vdCJ9LCJ0cmFuc2Zvcm0iOnsib3JpZ2luIjpbMCwwLDBdLCJ4X2F4aXMiOlsxLDAsMF0sInlfYXhpcyI6WzAsMSwwXSwiel9heGlzIjpbMCwwLDFdfSwidmlzaWJsZSI6dHJ1ZX1dLCJyZXZpc2lvbiI6InNoYTI1NjpmYWUxNWY0YWVlZjEyNzdjZDgzMWZmOGJmODNmOWIyNDRkNWM0MjI1ZTcyZWI3YmMyZTBlMzVkZDZjZjQzNjE0Iiwic2NlbmVfaWQiOiJmaXh0dXJlIiwic2NoZW1hX3ZlcnNpb24iOiIxLjAiLCJzb3VyY2UiOnsia2luZCI6Im1hbnVhbCIsInNvdXJjZV9pZCI6ImZpeHR1cmUifX0=", + "name": "blob_hash_mismatch", + "valid": false + }, + { + "blob_uris": [ + "edges/sha256-6ce481db1fe13bcfe7a2374423e6c72d757d51c99168b287c52f53aa625ccd67.glb", + "entities/sha256-3596be6227d88f9e2ed5d17168162bdcfc6443f2cc31279821b84816c7af8f88.json", + "geometry/sha256-200523414de281a8a155086a54cd0f880f640485765a48ccdd8ec230f67dd17c.glb" + ], + "expected": null, + "manifest_base64": "eyJhbm5vdGF0aW9ucyI6W10sImFwcGVhcmFuY2VzIjpbeyJhbHBoYV9tb2RlIjoib3BhcXVlIiwiYXBwZWFyYW5jZV9pZCI6ImFwcGVhcmFuY2UvZXZhbHVhdGVkL2JmMWNhNmQ2MGFmMjE3NjdhY2UxNDE5YjYwNTUyMTdjODcxNDU4OWI3OTA5MGQyNzBhYWJlNTEyMjMwZWZhM2EiLCJiYXNlX2NvbG9yIjpbMC43MiwwLjc1LDAuNzgsMV0sImRvdWJsZV9zaWRlZCI6ZmFsc2UsImVkZ2VfY29sb3IiOlswLjA4LDAuMDksMC4xLDFdLCJtZXRhbGxpYyI6MCwibmFtZSI6bnVsbCwicm91Z2huZXNzIjowLjU1LCJzZGtfbWV0YWRhdGEiOnt9LCJzb3VyY2UiOm51bGx9XSwiY2FtZXJhcyI6W10sImNvbXBpbGVfb3B0aW9ucyI6eyJhbmd1bGFyX3RvbGVyYW5jZSI6MC4yMiwiZW1iZWRfcHJlc2VudGF0aW9uIjpmYWxzZSwiZW1iZWRfc291cmNlIjpmYWxzZSwibGluZWFyX3RvbGVyYW5jZSI6MC4zNX0sImNvbm5lY3RvcnMiOltdLCJjb29yZGluYXRlX3N5c3RlbSI6eyJoYW5kZWRuZXNzIjoicmlnaHQiLCJsZW5ndGhfdW5pdCI6Im1tIiwidXBfYXhpcyI6IitaIn0sImRlZmluaXRpb25zIjpbeyJhcHBlYXJhbmNlX2lkIjoiYXBwZWFyYW5jZS9ldmFsdWF0ZWQvYmYxY2E2ZDYwYWYyMTc2N2FjZTE0MTliNjA1NTIxN2M4NzE0NTg5Yjc5MDkwZDI3MGFhYmU1MTIyMzBlZmEzYSIsImRlZmluaXRpb25faWQiOiJkZWZpbml0aW9uL3Jvb3Qvc2hhcGUvaW1wb3J0ZWQvZWxlbWVudCIsImVkZ2VfYXNzZXRfaWQiOiJzaGEyNTY6NmNlNDgxZGIxZmUxM2JjZmU3YTIzNzQ0MjNlNmM3MmQ3NTdkNTFjOTkxNjhiMjg3YzUyZjUzYWE2MjVjY2Q2NyIsImVudGl0eV9hc3NldF9pZCI6InNoYTI1NjozNTk2YmU2MjI3ZDg4ZjllMmVkNWQxNzE2ODE2MmJkY2ZjNjQ0M2YyY2MzMTI3OTgyMWI4NDgxNmM3YWY4Zjg4IiwiZ2VvbWV0cnlfYXNzZXRfaWQiOiJzaGEyNTY6MjAwNTIzNDE0ZGUyODFhOGExNTUwODZhNTRjZDBmODgwZjY0MDQ4NTc2NWE0OGNjZGQ4ZWMyMzBmNjdkZDE3YyIsImtpbmQiOiJzaGFwZSIsIm5hbWUiOm51bGwsInNka19tZXRhZGF0YSI6e30sInNvdXJjZSI6eyJraW5kIjoiaW1wb3J0ZWQiLCJyb290X2lkIjoicm9vdCIsInNvdXJjZV9lbGVtZW50X2lkIjoiZWxlbWVudCJ9fV0sImRpYWdub3N0aWNzIjpbXSwiZWRnZV9hc3NldHMiOlt7ImFzc2V0X2lkIjoic2hhMjU2OjZjZTQ4MWRiMWZlMTNiY2ZlN2EyMzc0NDIzZTZjNzJkNzU3ZDUxYzk5MTY4YjI4N2M1MmY1M2FhNjI1Y2NkNjciLCJhc3NldF90b19zY2VuZSI6WzEwMDAsMCwwLDAsMCwwLC0xMDAwLDAsMCwxMDAwLDAsMCwwLDAsMCwxXSwiYnl0ZV9sZW5ndGgiOjc0MCwiY29udGVudF9oYXNoIjoic2hhMjU2OjZjZTQ4MWRiMWZlMTNiY2ZlN2EyMzc0NDIzZTZjNzJkNzU3ZDUxYzk5MTY4YjI4N2M1MmY1M2FhNjI1Y2NkNjciLCJtZWRpYV90eXBlIjoibW9kZWwvZ2x0Zi1iaW5hcnkiLCJzY2VuZV9sb2NhbF9ib3VuZHMiOnsibWF4IjpbMTAwMCwwLDEwMDBdLCJtaW4iOlswLC0xMDAwLDBdfSwidGVzc2VsbGF0aW9uIjp7ImxpbmVhcl90b2xlcmFuY2UiOjAuMzV9LCJ1cmkiOiJlZGdlcy9zaGEyNTYtNmNlNDgxZGIxZmUxM2JjZmU3YTIzNzQ0MjNlNmM3MmQ3NTdkNTFjOTkxNjhiMjg3YzUyZjUzYWE2MjVjY2Q2Ny5nbGIifV0sImVudGl0eV9hc3NldHMiOlt7ImJ5dGVfbGVuZ3RoIjoxMjIzMywiY29udGVudF9oYXNoIjoic2hhMjU2OjM1OTZiZTYyMjdkODhmOWUyZWQ1ZDE3MTY4MTYyYmRjZmM2NDQzZjJjYzMxMjc5ODIxYjg0ODE2YzdhZjhmODgiLCJlbnRpdHlfYXNzZXRfaWQiOiJzaGEyNTY6MzU5NmJlNjIyN2Q4OGY5ZTJlZDVkMTcxNjgxNjJiZGNmYzY0NDNmMmNjMzEyNzk4MjFiODQ4MTZjN2FmOGY4OCIsIm1lZGlhX3R5cGUiOiJhcHBsaWNhdGlvbi92bmQuc2ltcGxlY2FkLmVudGl0aWVzK2pzb24iLCJ1cmkiOiJlbnRpdGllcy9zaGEyNTYtMzU5NmJlNjIyN2Q4OGY5ZTJlZDVkMTcxNjgxNjJiZGNmYzY0NDNmMmNjMzEyNzk4MjFiODQ4MTZjN2FmOGY4OC5qc29uIn1dLCJleHRlbnNpb25zIjp7fSwiZXh0ZW5zaW9uc19yZXF1aXJlZCI6W10sImV4dGVuc2lvbnNfdXNlZCI6W10sImdlbmVyYXRvciI6eyJuYW1lIjoic2ltcGxlY2FkYXBpIiwib2NwX2JpbmRpbmdzX3ZlcnNpb24iOiJmaXh0dXJlIiwib2NwX3ZlcnNpb24iOiJmaXh0dXJlIiwicGxhdGZvcm1fdGFnIjoiZml4dHVyZSIsInByb2ZpbGUiOiJzY2VuZS0xLjAtb2NwLWdsYi0yIiwicHl0aG9uX2FiaSI6ImZpeHR1cmUiLCJzaW1wbGVjYWRhcGlfdmVyc2lvbiI6ImZpeHR1cmUiLCJ0b29sY2hhaW5faGFzaCI6InNoYTI1NjowMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwIn0sImdlb21ldHJ5X2Fzc2V0cyI6W3siYXNzZXRfaWQiOiJzaGEyNTY6MjAwNTIzNDE0ZGUyODFhOGExNTUwODZhNTRjZDBmODgwZjY0MDQ4NTc2NWE0OGNjZGQ4ZWMyMzBmNjdkZDE3YyIsImFzc2V0X3RvX3NjZW5lIjpbMTAwMCwwLDAsMCwwLDAsLTEwMDAsMCwwLDEwMDAsMCwwLDAsMCwwLDFdLCJieXRlX2xlbmd0aCI6MTAyMCwiY29udGVudF9oYXNoIjoic2hhMjU2OjIwMDUyMzQxNGRlMjgxYThhMTU1MDg2YTU0Y2QwZjg4MGY2NDA0ODU3NjVhNDhjY2RkOGVjMjMwZjY3ZGQxN2MiLCJtZWRpYV90eXBlIjoibW9kZWwvZ2x0Zi1iaW5hcnkiLCJzY2VuZV9sb2NhbF9ib3VuZHMiOnsibWF4IjpbMTAwMCwwLDEwMDBdLCJtaW4iOlswLC0xMDAwLDBdfSwidGVzc2VsbGF0aW9uIjp7ImFuZ3VsYXJfdG9sZXJhbmNlIjowLjIyLCJsaW5lYXJfdG9sZXJhbmNlIjowLjM1fSwidXJpIjoiZ2VvbWV0cnkvc2hhMjU2LTIwMDUyMzQxNGRlMjgxYThhMTU1MDg2YTU0Y2QwZjg4MGY2NDA0ODU3NjVhNDhjY2RkOGVjMjMwZjY3ZGQxN2MuZ2xiIn1dLCJsaWdodHMiOltdLCJub2RlcyI6W3siYXBwZWFyYW5jZV9vdmVycmlkZV9pZCI6bnVsbCwiZGVmaW5pdGlvbl9pZCI6ImRlZmluaXRpb24vcm9vdC9zaGFwZS9pbXBvcnRlZC9lbGVtZW50IiwibmFtZSI6bnVsbCwibm9kZV9pZCI6Imluc3RhbmNlL3Jvb3QiLCJvcmRlciI6MCwicGFyZW50X25vZGVfaWQiOm51bGwsInNka19tZXRhZGF0YSI6e30sInNlbGVjdGFibGUiOnRydWUsInNvdXJjZSI6eyJraW5kIjoic2hhcGVfcm9vdCIsInJvb3RfaWQiOiJyb290In0sInRyYW5zZm9ybSI6eyJvcmlnaW4iOlswLDAsMF0sInhfYXhpcyI6WzEsMCwwXSwieV9heGlzIjpbMCwxLDBdLCJ6X2F4aXMiOlswLDAsMV19LCJ2aXNpYmxlIjp0cnVlfV0sInJldmlzaW9uIjoic2hhMjU2Ojk4ODc1MjI3ZDM3MzFjM2I0ZTQyNDIxYjc4YzM4OGIwMzc2N2Q1Y2Q1NGM1OWYwMGQ0MGRjYzUwNjhmM2JlMTAiLCJzY2VuZV9pZCI6ImltcG9ydGVkX2ZpeHR1cmUiLCJzY2hlbWFfdmVyc2lvbiI6IjEuMCIsInNvdXJjZSI6eyJhcnRpZmFjdF9oYXNoIjoic2hhMjU2OjExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTEiLCJmb3JtYXQiOiJzdGVwIiwia2luZCI6ImltcG9ydGVkIn19", + "name": "valid_imported_shape", + "valid": true + }, + { + "blob_uris": [ + "edges/sha256-6ce481db1fe13bcfe7a2374423e6c72d757d51c99168b287c52f53aa625ccd67.glb", + "entities/sha256-997dcfd838049df11e508909f4f98dd1881b7b5eaedd6399ee0fcc9ebfab791b.json", + "geometry/sha256-200523414de281a8a155086a54cd0f880f640485765a48ccdd8ec230f67dd17c.glb" + ], + "expected": null, + "manifest_base64": "eyJhbm5vdGF0aW9ucyI6W10sImFwcGVhcmFuY2VzIjpbeyJhbHBoYV9tb2RlIjoib3BhcXVlIiwiYXBwZWFyYW5jZV9pZCI6ImFwcGVhcmFuY2UvZXZhbHVhdGVkL2JmMWNhNmQ2MGFmMjE3NjdhY2UxNDE5YjYwNTUyMTdjODcxNDU4OWI3OTA5MGQyNzBhYWJlNTEyMjMwZWZhM2EiLCJiYXNlX2NvbG9yIjpbMC43MiwwLjc1LDAuNzgsMV0sImRvdWJsZV9zaWRlZCI6ZmFsc2UsImVkZ2VfY29sb3IiOlswLjA4LDAuMDksMC4xLDFdLCJtZXRhbGxpYyI6MCwibmFtZSI6bnVsbCwicm91Z2huZXNzIjowLjU1LCJzZGtfbWV0YWRhdGEiOnt9LCJzb3VyY2UiOm51bGx9XSwiY2FtZXJhcyI6W10sImNvbXBpbGVfb3B0aW9ucyI6eyJhbmd1bGFyX3RvbGVyYW5jZSI6MC4yMiwiZW1iZWRfcHJlc2VudGF0aW9uIjpmYWxzZSwiZW1iZWRfc291cmNlIjpmYWxzZSwibGluZWFyX3RvbGVyYW5jZSI6MC4zNX0sImNvbm5lY3RvcnMiOltdLCJjb29yZGluYXRlX3N5c3RlbSI6eyJoYW5kZWRuZXNzIjoicmlnaHQiLCJsZW5ndGhfdW5pdCI6Im1tIiwidXBfYXhpcyI6IitaIn0sImRlZmluaXRpb25zIjpbeyJhcHBlYXJhbmNlX2lkIjoiYXBwZWFyYW5jZS9ldmFsdWF0ZWQvYmYxY2E2ZDYwYWYyMTc2N2FjZTE0MTliNjA1NTIxN2M4NzE0NTg5Yjc5MDkwZDI3MGFhYmU1MTIyMzBlZmEzYSIsImRlZmluaXRpb25faWQiOiJkZWZpbml0aW9uL3Jvb3Qvc2hhcGUvbW9kZWwvZ3JhcGgvYm9keS8wIiwiZWRnZV9hc3NldF9pZCI6InNoYTI1Njo2Y2U0ODFkYjFmZTEzYmNmZTdhMjM3NDQyM2U2YzcyZDc1N2Q1MWM5OTE2OGIyODdjNTJmNTNhYTYyNWNjZDY3IiwiZW50aXR5X2Fzc2V0X2lkIjoic2hhMjU2Ojk5N2RjZmQ4MzgwNDlkZjExZTUwODkwOWY0Zjk4ZGQxODgxYjdiNWVhZWRkNjM5OWVlMGZjYzllYmZhYjc5MWIiLCJnZW9tZXRyeV9hc3NldF9pZCI6InNoYTI1NjoyMDA1MjM0MTRkZTI4MWE4YTE1NTA4NmE1NGNkMGY4ODBmNjQwNDg1NzY1YTQ4Y2NkZDhlYzIzMGY2N2RkMTdjIiwia2luZCI6InNoYXBlIiwibmFtZSI6bnVsbCwic2RrX21ldGFkYXRhIjp7fSwic291cmNlIjp7ImdyYXBoX2lkIjoiZ3JhcGgiLCJraW5kIjoibW9kZWxfb3V0cHV0Iiwibm9kZV9pZCI6ImJvZHkiLCJvdXRwdXRfc2xvdCI6MCwicm9vdF9pZCI6InJvb3QifX1dLCJkaWFnbm9zdGljcyI6W10sImVkZ2VfYXNzZXRzIjpbeyJhc3NldF9pZCI6InNoYTI1Njo2Y2U0ODFkYjFmZTEzYmNmZTdhMjM3NDQyM2U2YzcyZDc1N2Q1MWM5OTE2OGIyODdjNTJmNTNhYTYyNWNjZDY3IiwiYXNzZXRfdG9fc2NlbmUiOlsxMDAwLDAsMCwwLDAsMCwtMTAwMCwwLDAsMTAwMCwwLDAsMCwwLDAsMV0sImJ5dGVfbGVuZ3RoIjo3NDAsImNvbnRlbnRfaGFzaCI6InNoYTI1Njo2Y2U0ODFkYjFmZTEzYmNmZTdhMjM3NDQyM2U2YzcyZDc1N2Q1MWM5OTE2OGIyODdjNTJmNTNhYTYyNWNjZDY3IiwibWVkaWFfdHlwZSI6Im1vZGVsL2dsdGYtYmluYXJ5Iiwic2NlbmVfbG9jYWxfYm91bmRzIjp7Im1heCI6WzEwMDAsMCwxMDAwXSwibWluIjpbMCwtMTAwMCwwXX0sInRlc3NlbGxhdGlvbiI6eyJsaW5lYXJfdG9sZXJhbmNlIjowLjM1fSwidXJpIjoiZWRnZXMvc2hhMjU2LTZjZTQ4MWRiMWZlMTNiY2ZlN2EyMzc0NDIzZTZjNzJkNzU3ZDUxYzk5MTY4YjI4N2M1MmY1M2FhNjI1Y2NkNjcuZ2xiIn1dLCJlbnRpdHlfYXNzZXRzIjpbeyJieXRlX2xlbmd0aCI6MTIzNzYsImNvbnRlbnRfaGFzaCI6InNoYTI1Njo5OTdkY2ZkODM4MDQ5ZGYxMWU1MDg5MDlmNGY5OGRkMTg4MWI3YjVlYWVkZDYzOTllZTBmY2M5ZWJmYWI3OTFiIiwiZW50aXR5X2Fzc2V0X2lkIjoic2hhMjU2Ojk5N2RjZmQ4MzgwNDlkZjExZTUwODkwOWY0Zjk4ZGQxODgxYjdiNWVhZWRkNjM5OWVlMGZjYzllYmZhYjc5MWIiLCJtZWRpYV90eXBlIjoiYXBwbGljYXRpb24vdm5kLnNpbXBsZWNhZC5lbnRpdGllcytqc29uIiwidXJpIjoiZW50aXRpZXMvc2hhMjU2LTk5N2RjZmQ4MzgwNDlkZjExZTUwODkwOWY0Zjk4ZGQxODgxYjdiNWVhZWRkNjM5OWVlMGZjYzllYmZhYjc5MWIuanNvbiJ9XSwiZXh0ZW5zaW9ucyI6e30sImV4dGVuc2lvbnNfcmVxdWlyZWQiOltdLCJleHRlbnNpb25zX3VzZWQiOltdLCJnZW5lcmF0b3IiOnsibmFtZSI6InNpbXBsZWNhZGFwaSIsIm9jcF9iaW5kaW5nc192ZXJzaW9uIjoiZml4dHVyZSIsIm9jcF92ZXJzaW9uIjoiZml4dHVyZSIsInBsYXRmb3JtX3RhZyI6ImZpeHR1cmUiLCJwcm9maWxlIjoic2NlbmUtMS4wLW9jcC1nbGItMiIsInB5dGhvbl9hYmkiOiJmaXh0dXJlIiwic2ltcGxlY2FkYXBpX3ZlcnNpb24iOiJmaXh0dXJlIiwidG9vbGNoYWluX2hhc2giOiJzaGEyNTY6MDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMCJ9LCJnZW9tZXRyeV9hc3NldHMiOlt7ImFzc2V0X2lkIjoic2hhMjU2OjIwMDUyMzQxNGRlMjgxYThhMTU1MDg2YTU0Y2QwZjg4MGY2NDA0ODU3NjVhNDhjY2RkOGVjMjMwZjY3ZGQxN2MiLCJhc3NldF90b19zY2VuZSI6WzEwMDAsMCwwLDAsMCwwLC0xMDAwLDAsMCwxMDAwLDAsMCwwLDAsMCwxXSwiYnl0ZV9sZW5ndGgiOjEwMjAsImNvbnRlbnRfaGFzaCI6InNoYTI1NjoyMDA1MjM0MTRkZTI4MWE4YTE1NTA4NmE1NGNkMGY4ODBmNjQwNDg1NzY1YTQ4Y2NkZDhlYzIzMGY2N2RkMTdjIiwibWVkaWFfdHlwZSI6Im1vZGVsL2dsdGYtYmluYXJ5Iiwic2NlbmVfbG9jYWxfYm91bmRzIjp7Im1heCI6WzEwMDAsMCwxMDAwXSwibWluIjpbMCwtMTAwMCwwXX0sInRlc3NlbGxhdGlvbiI6eyJhbmd1bGFyX3RvbGVyYW5jZSI6MC4yMiwibGluZWFyX3RvbGVyYW5jZSI6MC4zNX0sInVyaSI6Imdlb21ldHJ5L3NoYTI1Ni0yMDA1MjM0MTRkZTI4MWE4YTE1NTA4NmE1NGNkMGY4ODBmNjQwNDg1NzY1YTQ4Y2NkZDhlYzIzMGY2N2RkMTdjLmdsYiJ9XSwibGlnaHRzIjpbXSwibm9kZXMiOlt7ImFwcGVhcmFuY2Vfb3ZlcnJpZGVfaWQiOm51bGwsImRlZmluaXRpb25faWQiOiJkZWZpbml0aW9uL3Jvb3Qvc2hhcGUvbW9kZWwvZ3JhcGgvYm9keS8wIiwibmFtZSI6bnVsbCwibm9kZV9pZCI6Imluc3RhbmNlL3Jvb3QiLCJvcmRlciI6MCwicGFyZW50X25vZGVfaWQiOm51bGwsInNka19tZXRhZGF0YSI6e30sInNlbGVjdGFibGUiOnRydWUsInNvdXJjZSI6eyJraW5kIjoic2hhcGVfcm9vdCIsInJvb3RfaWQiOiJyb290In0sInRyYW5zZm9ybSI6eyJvcmlnaW4iOlswLDAsMF0sInhfYXhpcyI6WzEsMCwwXSwieV9heGlzIjpbMCwxLDBdLCJ6X2F4aXMiOlswLDAsMV19LCJ2aXNpYmxlIjp0cnVlfV0sInJldmlzaW9uIjoic2hhMjU2OmFjM2JjMmFkNjBmZmM3MWU5YzBlZGY1OTNjZGJmNzQxY2U3ZDcwOWE1ODBlMzViMzc4YWY3NTc5ZjY4M2YwYzAiLCJzY2VuZV9pZCI6Im1vZGVsX3NoYXBlX2ZpeHR1cmUiLCJzY2hlbWFfdmVyc2lvbiI6IjEuMCIsInNvdXJjZSI6eyJhcnRpZmFjdF9oYXNoIjoic2hhMjU2OjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIiLCJncmFwaF9pZCI6ImdyYXBoIiwia2luZCI6Im1vZGVsIiwibW9kZWxfc2NoZW1hX3ZlcnNpb24iOiIyLjAifX0=", + "name": "valid_model_shape", + "valid": true + }, + { + "blob_uris": [ + "edges/sha256-6ce481db1fe13bcfe7a2374423e6c72d757d51c99168b287c52f53aa625ccd67.glb", + "entities/sha256-3bb479fbdff1181e672905eaa8510b5aab3786e272c0940c27681f32f1db501c.json", + "geometry/sha256-200523414de281a8a155086a54cd0f880f640485765a48ccdd8ec230f67dd17c.glb" + ], + "expected": null, + "manifest_base64": "eyJhbm5vdGF0aW9ucyI6W10sImFwcGVhcmFuY2VzIjpbeyJhbHBoYV9tb2RlIjoib3BhcXVlIiwiYXBwZWFyYW5jZV9pZCI6ImFwcGVhcmFuY2UvZXZhbHVhdGVkLzgxZjM4OWUyZThlMjcwYzllNGYwNzRlNDIxZTdiZTRhMGUxMDI2MTk2ZjVmZGVkODczMTkxZDlmOTFkYjA4NTIiLCJiYXNlX2NvbG9yIjpbMC43MiwwLjc1LDAuNzgsMV0sImRvdWJsZV9zaWRlZCI6ZmFsc2UsImVkZ2VfY29sb3IiOlswLjA4LDAuMDksMC4xLDFdLCJtZXRhbGxpYyI6MCwibmFtZSI6IkZpeHR1cmUgbWF0ZXJpYWwiLCJyb3VnaG5lc3MiOjAuNTUsInNka19tZXRhZGF0YSI6e30sInNvdXJjZSI6eyJraW5kIjoicHJvZHVjdF9tYXRlcmlhbCIsIm1hdGVyaWFsX2lkIjoiZml4dHVyZV9tYXRlcmlhbCIsInJvb3RfaWQiOiJyb290In19XSwiY2FtZXJhcyI6W10sImNvbXBpbGVfb3B0aW9ucyI6eyJhbmd1bGFyX3RvbGVyYW5jZSI6MC4yMiwiZW1iZWRfcHJlc2VudGF0aW9uIjpmYWxzZSwiZW1iZWRfc291cmNlIjpmYWxzZSwibGluZWFyX3RvbGVyYW5jZSI6MC4zNX0sImNvbm5lY3RvcnMiOltdLCJjb29yZGluYXRlX3N5c3RlbSI6eyJoYW5kZWRuZXNzIjoicmlnaHQiLCJsZW5ndGhfdW5pdCI6Im1tIiwidXBfYXhpcyI6IitaIn0sImRlZmluaXRpb25zIjpbeyJhcHBlYXJhbmNlX2lkIjoiYXBwZWFyYW5jZS9ldmFsdWF0ZWQvODFmMzg5ZTJlOGUyNzBjOWU0ZjA3NGU0MjFlN2JlNGEwZTEwMjYxOTZmNWZkZWQ4NzMxOTFkOWY5MWRiMDg1MiIsImRlZmluaXRpb25faWQiOiJkZWZpbml0aW9uL3Jvb3QvcGFydC9tb2RlbF9wYXJ0IiwiZWRnZV9hc3NldF9pZCI6InNoYTI1Njo2Y2U0ODFkYjFmZTEzYmNmZTdhMjM3NDQyM2U2YzcyZDc1N2Q1MWM5OTE2OGIyODdjNTJmNTNhYTYyNWNjZDY3IiwiZW50aXR5X2Fzc2V0X2lkIjoic2hhMjU2OjNiYjQ3OWZiZGZmMTE4MWU2NzI5MDVlYWE4NTEwYjVhYWIzNzg2ZTI3MmMwOTQwYzI3NjgxZjMyZjFkYjUwMWMiLCJnZW9tZXRyeV9hc3NldF9pZCI6InNoYTI1NjoyMDA1MjM0MTRkZTI4MWE4YTE1NTA4NmE1NGNkMGY4ODBmNjQwNDg1NzY1YTQ4Y2NkZDhlYzIzMGY2N2RkMTdjIiwia2luZCI6InBhcnQiLCJuYW1lIjpudWxsLCJzZGtfbWV0YWRhdGEiOnt9LCJzb3VyY2UiOnsiZ3JhcGhfaWQiOiJncmFwaCIsImtpbmQiOiJwcm9kdWN0X21vZGVsIiwibm9kZV9pZCI6ImJvZHkiLCJvdXRwdXRfc2xvdCI6MCwicm9vdF9pZCI6InJvb3QiLCJzZW1hbnRpY19pZCI6Im1vZGVsX3BhcnQiLCJzZW1hbnRpY190eXBlIjoiUGFydCJ9fV0sImRpYWdub3N0aWNzIjpbXSwiZWRnZV9hc3NldHMiOlt7ImFzc2V0X2lkIjoic2hhMjU2OjZjZTQ4MWRiMWZlMTNiY2ZlN2EyMzc0NDIzZTZjNzJkNzU3ZDUxYzk5MTY4YjI4N2M1MmY1M2FhNjI1Y2NkNjciLCJhc3NldF90b19zY2VuZSI6WzEwMDAsMCwwLDAsMCwwLC0xMDAwLDAsMCwxMDAwLDAsMCwwLDAsMCwxXSwiYnl0ZV9sZW5ndGgiOjc0MCwiY29udGVudF9oYXNoIjoic2hhMjU2OjZjZTQ4MWRiMWZlMTNiY2ZlN2EyMzc0NDIzZTZjNzJkNzU3ZDUxYzk5MTY4YjI4N2M1MmY1M2FhNjI1Y2NkNjciLCJtZWRpYV90eXBlIjoibW9kZWwvZ2x0Zi1iaW5hcnkiLCJzY2VuZV9sb2NhbF9ib3VuZHMiOnsibWF4IjpbMTAwMCwwLDEwMDBdLCJtaW4iOlswLC0xMDAwLDBdfSwidGVzc2VsbGF0aW9uIjp7ImxpbmVhcl90b2xlcmFuY2UiOjAuMzV9LCJ1cmkiOiJlZGdlcy9zaGEyNTYtNmNlNDgxZGIxZmUxM2JjZmU3YTIzNzQ0MjNlNmM3MmQ3NTdkNTFjOTkxNjhiMjg3YzUyZjUzYWE2MjVjY2Q2Ny5nbGIifV0sImVudGl0eV9hc3NldHMiOlt7ImJ5dGVfbGVuZ3RoIjoxMjk2MSwiY29udGVudF9oYXNoIjoic2hhMjU2OjNiYjQ3OWZiZGZmMTE4MWU2NzI5MDVlYWE4NTEwYjVhYWIzNzg2ZTI3MmMwOTQwYzI3NjgxZjMyZjFkYjUwMWMiLCJlbnRpdHlfYXNzZXRfaWQiOiJzaGEyNTY6M2JiNDc5ZmJkZmYxMTgxZTY3MjkwNWVhYTg1MTBiNWFhYjM3ODZlMjcyYzA5NDBjMjc2ODFmMzJmMWRiNTAxYyIsIm1lZGlhX3R5cGUiOiJhcHBsaWNhdGlvbi92bmQuc2ltcGxlY2FkLmVudGl0aWVzK2pzb24iLCJ1cmkiOiJlbnRpdGllcy9zaGEyNTYtM2JiNDc5ZmJkZmYxMTgxZTY3MjkwNWVhYTg1MTBiNWFhYjM3ODZlMjcyYzA5NDBjMjc2ODFmMzJmMWRiNTAxYy5qc29uIn1dLCJleHRlbnNpb25zIjp7fSwiZXh0ZW5zaW9uc19yZXF1aXJlZCI6W10sImV4dGVuc2lvbnNfdXNlZCI6W10sImdlbmVyYXRvciI6eyJuYW1lIjoic2ltcGxlY2FkYXBpIiwib2NwX2JpbmRpbmdzX3ZlcnNpb24iOiJmaXh0dXJlIiwib2NwX3ZlcnNpb24iOiJmaXh0dXJlIiwicGxhdGZvcm1fdGFnIjoiZml4dHVyZSIsInByb2ZpbGUiOiJzY2VuZS0xLjAtb2NwLWdsYi0yIiwicHl0aG9uX2FiaSI6ImZpeHR1cmUiLCJzaW1wbGVjYWRhcGlfdmVyc2lvbiI6ImZpeHR1cmUiLCJ0b29sY2hhaW5faGFzaCI6InNoYTI1NjowMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwIn0sImdlb21ldHJ5X2Fzc2V0cyI6W3siYXNzZXRfaWQiOiJzaGEyNTY6MjAwNTIzNDE0ZGUyODFhOGExNTUwODZhNTRjZDBmODgwZjY0MDQ4NTc2NWE0OGNjZGQ4ZWMyMzBmNjdkZDE3YyIsImFzc2V0X3RvX3NjZW5lIjpbMTAwMCwwLDAsMCwwLDAsLTEwMDAsMCwwLDEwMDAsMCwwLDAsMCwwLDFdLCJieXRlX2xlbmd0aCI6MTAyMCwiY29udGVudF9oYXNoIjoic2hhMjU2OjIwMDUyMzQxNGRlMjgxYThhMTU1MDg2YTU0Y2QwZjg4MGY2NDA0ODU3NjVhNDhjY2RkOGVjMjMwZjY3ZGQxN2MiLCJtZWRpYV90eXBlIjoibW9kZWwvZ2x0Zi1iaW5hcnkiLCJzY2VuZV9sb2NhbF9ib3VuZHMiOnsibWF4IjpbMTAwMCwwLDEwMDBdLCJtaW4iOlswLC0xMDAwLDBdfSwidGVzc2VsbGF0aW9uIjp7ImFuZ3VsYXJfdG9sZXJhbmNlIjowLjIyLCJsaW5lYXJfdG9sZXJhbmNlIjowLjM1fSwidXJpIjoiZ2VvbWV0cnkvc2hhMjU2LTIwMDUyMzQxNGRlMjgxYThhMTU1MDg2YTU0Y2QwZjg4MGY2NDA0ODU3NjVhNDhjY2RkOGVjMjMwZjY3ZGQxN2MuZ2xiIn1dLCJsaWdodHMiOltdLCJub2RlcyI6W3siYXBwZWFyYW5jZV9vdmVycmlkZV9pZCI6bnVsbCwiZGVmaW5pdGlvbl9pZCI6ImRlZmluaXRpb24vcm9vdC9wYXJ0L21vZGVsX3BhcnQiLCJuYW1lIjpudWxsLCJub2RlX2lkIjoiaW5zdGFuY2Uvcm9vdCIsIm9yZGVyIjowLCJwYXJlbnRfbm9kZV9pZCI6bnVsbCwic2RrX21ldGFkYXRhIjp7fSwic2VsZWN0YWJsZSI6dHJ1ZSwic291cmNlIjp7ImNvbXBvbmVudF9wYXRoIjpbXSwia2luZCI6InByb2R1Y3Rfb2NjdXJyZW5jZSIsInJvb3RfaWQiOiJyb290In0sInRyYW5zZm9ybSI6eyJvcmlnaW4iOlswLDAsMF0sInhfYXhpcyI6WzEsMCwwXSwieV9heGlzIjpbMCwxLDBdLCJ6X2F4aXMiOlswLDAsMV19LCJ2aXNpYmxlIjp0cnVlfV0sInJldmlzaW9uIjoic2hhMjU2OjAyMTM0MzIxMmI2NjFkNTc1ZDFhYjVjZDZiY2EzM2EwOTkzZGE5MjI2OWIxYmZkNWNiYzBlYTZmMmY4MzUxMzAiLCJzY2VuZV9pZCI6Im1vZGVsX3BhcnRfZml4dHVyZSIsInNjaGVtYV92ZXJzaW9uIjoiMS4wIiwic291cmNlIjp7ImFydGlmYWN0X2hhc2giOiJzaGEyNTY6MjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMiIsImdyYXBoX2lkIjoiZ3JhcGgiLCJraW5kIjoibW9kZWwiLCJtb2RlbF9zY2hlbWFfdmVyc2lvbiI6IjIuMCJ9fQ==", + "name": "valid_model_part", + "valid": true + }, + { + "blob_uris": [ + "edges/sha256-6ce481db1fe13bcfe7a2374423e6c72d757d51c99168b287c52f53aa625ccd67.glb", + "entities/sha256-d8e42ece57a00e05d644e9a2b35c0525fa2bde29cc44382720d7fb0a56d45aa4.json", + "geometry/sha256-200523414de281a8a155086a54cd0f880f640485765a48ccdd8ec230f67dd17c.glb" + ], + "expected": { + "code": "connector_invalid", + "path": "/entities/sha256-d8e42ece57a00e05d644e9a2b35c0525fa2bde29cc44382720d7fb0a56d45aa4.json/entities/10/connector_binding_status" + }, + "manifest_base64": "eyJhbm5vdGF0aW9ucyI6W10sImFwcGVhcmFuY2VzIjpbeyJhbHBoYV9tb2RlIjoib3BhcXVlIiwiYXBwZWFyYW5jZV9pZCI6ImFwcGVhcmFuY2UvZXZhbHVhdGVkL2JmMWNhNmQ2MGFmMjE3NjdhY2UxNDE5YjYwNTUyMTdjODcxNDU4OWI3OTA5MGQyNzBhYWJlNTEyMjMwZWZhM2EiLCJiYXNlX2NvbG9yIjpbMC43MiwwLjc1LDAuNzgsMV0sImRvdWJsZV9zaWRlZCI6ZmFsc2UsImVkZ2VfY29sb3IiOlswLjA4LDAuMDksMC4xLDFdLCJtZXRhbGxpYyI6MCwibmFtZSI6bnVsbCwicm91Z2huZXNzIjowLjU1LCJzZGtfbWV0YWRhdGEiOnt9LCJzb3VyY2UiOm51bGx9XSwiY2FtZXJhcyI6W10sImNvbXBpbGVfb3B0aW9ucyI6eyJhbmd1bGFyX3RvbGVyYW5jZSI6MC4yMiwiZW1iZWRfcHJlc2VudGF0aW9uIjpmYWxzZSwiZW1iZWRfc291cmNlIjpmYWxzZSwibGluZWFyX3RvbGVyYW5jZSI6MC4zNX0sImNvbm5lY3RvcnMiOltdLCJjb29yZGluYXRlX3N5c3RlbSI6eyJoYW5kZWRuZXNzIjoicmlnaHQiLCJsZW5ndGhfdW5pdCI6Im1tIiwidXBfYXhpcyI6IitaIn0sImRlZmluaXRpb25zIjpbeyJhcHBlYXJhbmNlX2lkIjoiYXBwZWFyYW5jZS9ldmFsdWF0ZWQvYmYxY2E2ZDYwYWYyMTc2N2FjZTE0MTliNjA1NTIxN2M4NzE0NTg5Yjc5MDkwZDI3MGFhYmU1MTIyMzBlZmEzYSIsImRlZmluaXRpb25faWQiOiJkZWZpbml0aW9uL3Jvb3Qvc2hhcGUvbWFudWFsL2ZpeHR1cmUiLCJlZGdlX2Fzc2V0X2lkIjoic2hhMjU2OjZjZTQ4MWRiMWZlMTNiY2ZlN2EyMzc0NDIzZTZjNzJkNzU3ZDUxYzk5MTY4YjI4N2M1MmY1M2FhNjI1Y2NkNjciLCJlbnRpdHlfYXNzZXRfaWQiOiJzaGEyNTY6ZDhlNDJlY2U1N2EwMGUwNWQ2NDRlOWEyYjM1YzA1MjVmYTJiZGUyOWNjNDQzODI3MjBkN2ZiMGE1NmQ0NWFhNCIsImdlb21ldHJ5X2Fzc2V0X2lkIjoic2hhMjU2OjIwMDUyMzQxNGRlMjgxYThhMTU1MDg2YTU0Y2QwZjg4MGY2NDA0ODU3NjVhNDhjY2RkOGVjMjMwZjY3ZGQxN2MiLCJraW5kIjoic2hhcGUiLCJuYW1lIjpudWxsLCJzZGtfbWV0YWRhdGEiOnt9LCJzb3VyY2UiOnsia2luZCI6Im1hbnVhbCIsInJvb3RfaWQiOiJyb290Iiwic291cmNlX2lkIjoiZml4dHVyZSJ9fV0sImRpYWdub3N0aWNzIjpbXSwiZWRnZV9hc3NldHMiOlt7ImFzc2V0X2lkIjoic2hhMjU2OjZjZTQ4MWRiMWZlMTNiY2ZlN2EyMzc0NDIzZTZjNzJkNzU3ZDUxYzk5MTY4YjI4N2M1MmY1M2FhNjI1Y2NkNjciLCJhc3NldF90b19zY2VuZSI6WzEwMDAsMCwwLDAsMCwwLC0xMDAwLDAsMCwxMDAwLDAsMCwwLDAsMCwxXSwiYnl0ZV9sZW5ndGgiOjc0MCwiY29udGVudF9oYXNoIjoic2hhMjU2OjZjZTQ4MWRiMWZlMTNiY2ZlN2EyMzc0NDIzZTZjNzJkNzU3ZDUxYzk5MTY4YjI4N2M1MmY1M2FhNjI1Y2NkNjciLCJtZWRpYV90eXBlIjoibW9kZWwvZ2x0Zi1iaW5hcnkiLCJzY2VuZV9sb2NhbF9ib3VuZHMiOnsibWF4IjpbMTAwMCwwLDEwMDBdLCJtaW4iOlswLC0xMDAwLDBdfSwidGVzc2VsbGF0aW9uIjp7ImxpbmVhcl90b2xlcmFuY2UiOjAuMzV9LCJ1cmkiOiJlZGdlcy9zaGEyNTYtNmNlNDgxZGIxZmUxM2JjZmU3YTIzNzQ0MjNlNmM3MmQ3NTdkNTFjOTkxNjhiMjg3YzUyZjUzYWE2MjVjY2Q2Ny5nbGIifV0sImVudGl0eV9hc3NldHMiOlt7ImJ5dGVfbGVuZ3RoIjoxMTUxMiwiY29udGVudF9oYXNoIjoic2hhMjU2OmQ4ZTQyZWNlNTdhMDBlMDVkNjQ0ZTlhMmIzNWMwNTI1ZmEyYmRlMjljYzQ0MzgyNzIwZDdmYjBhNTZkNDVhYTQiLCJlbnRpdHlfYXNzZXRfaWQiOiJzaGEyNTY6ZDhlNDJlY2U1N2EwMGUwNWQ2NDRlOWEyYjM1YzA1MjVmYTJiZGUyOWNjNDQzODI3MjBkN2ZiMGE1NmQ0NWFhNCIsIm1lZGlhX3R5cGUiOiJhcHBsaWNhdGlvbi92bmQuc2ltcGxlY2FkLmVudGl0aWVzK2pzb24iLCJ1cmkiOiJlbnRpdGllcy9zaGEyNTYtZDhlNDJlY2U1N2EwMGUwNWQ2NDRlOWEyYjM1YzA1MjVmYTJiZGUyOWNjNDQzODI3MjBkN2ZiMGE1NmQ0NWFhNC5qc29uIn1dLCJleHRlbnNpb25zIjp7fSwiZXh0ZW5zaW9uc19yZXF1aXJlZCI6W10sImV4dGVuc2lvbnNfdXNlZCI6W10sImdlbmVyYXRvciI6eyJuYW1lIjoic2ltcGxlY2FkYXBpIiwib2NwX2JpbmRpbmdzX3ZlcnNpb24iOiJmaXh0dXJlIiwib2NwX3ZlcnNpb24iOiJmaXh0dXJlIiwicGxhdGZvcm1fdGFnIjoiZml4dHVyZSIsInByb2ZpbGUiOiJzY2VuZS0xLjAtb2NwLWdsYi0yIiwicHl0aG9uX2FiaSI6ImZpeHR1cmUiLCJzaW1wbGVjYWRhcGlfdmVyc2lvbiI6ImZpeHR1cmUiLCJ0b29sY2hhaW5faGFzaCI6InNoYTI1NjowMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwIn0sImdlb21ldHJ5X2Fzc2V0cyI6W3siYXNzZXRfaWQiOiJzaGEyNTY6MjAwNTIzNDE0ZGUyODFhOGExNTUwODZhNTRjZDBmODgwZjY0MDQ4NTc2NWE0OGNjZGQ4ZWMyMzBmNjdkZDE3YyIsImFzc2V0X3RvX3NjZW5lIjpbMTAwMCwwLDAsMCwwLDAsLTEwMDAsMCwwLDEwMDAsMCwwLDAsMCwwLDFdLCJieXRlX2xlbmd0aCI6MTAyMCwiY29udGVudF9oYXNoIjoic2hhMjU2OjIwMDUyMzQxNGRlMjgxYThhMTU1MDg2YTU0Y2QwZjg4MGY2NDA0ODU3NjVhNDhjY2RkOGVjMjMwZjY3ZGQxN2MiLCJtZWRpYV90eXBlIjoibW9kZWwvZ2x0Zi1iaW5hcnkiLCJzY2VuZV9sb2NhbF9ib3VuZHMiOnsibWF4IjpbMTAwMCwwLDEwMDBdLCJtaW4iOlswLC0xMDAwLDBdfSwidGVzc2VsbGF0aW9uIjp7ImFuZ3VsYXJfdG9sZXJhbmNlIjowLjIyLCJsaW5lYXJfdG9sZXJhbmNlIjowLjM1fSwidXJpIjoiZ2VvbWV0cnkvc2hhMjU2LTIwMDUyMzQxNGRlMjgxYThhMTU1MDg2YTU0Y2QwZjg4MGY2NDA0ODU3NjVhNDhjY2RkOGVjMjMwZjY3ZGQxN2MuZ2xiIn1dLCJsaWdodHMiOltdLCJub2RlcyI6W3siYXBwZWFyYW5jZV9vdmVycmlkZV9pZCI6bnVsbCwiZGVmaW5pdGlvbl9pZCI6ImRlZmluaXRpb24vcm9vdC9zaGFwZS9tYW51YWwvZml4dHVyZSIsIm5hbWUiOm51bGwsIm5vZGVfaWQiOiJpbnN0YW5jZS9yb290Iiwib3JkZXIiOjAsInBhcmVudF9ub2RlX2lkIjpudWxsLCJzZGtfbWV0YWRhdGEiOnt9LCJzZWxlY3RhYmxlIjp0cnVlLCJzb3VyY2UiOnsia2luZCI6InNoYXBlX3Jvb3QiLCJyb290X2lkIjoicm9vdCJ9LCJ0cmFuc2Zvcm0iOnsib3JpZ2luIjpbMCwwLDBdLCJ4X2F4aXMiOlsxLDAsMF0sInlfYXhpcyI6WzAsMSwwXSwiel9heGlzIjpbMCwwLDFdfSwidmlzaWJsZSI6dHJ1ZX1dLCJyZXZpc2lvbiI6InNoYTI1Njo5NjU3MGNmODBiM2FiYmUxOGUxZGVmZDEzNDk0MGQ0ZjlkMmU3MGJiZjNiMWRiNzA0Y2E3NjhiZGJhNDRiMDFiIiwic2NlbmVfaWQiOiJmaXh0dXJlIiwic2NoZW1hX3ZlcnNpb24iOiIxLjAiLCJzb3VyY2UiOnsia2luZCI6Im1hbnVhbCIsInNvdXJjZV9pZCI6ImZpeHR1cmUifX0=", + "name": "solid_status_precedence", + "valid": false + }, + { + "blob_uris": [ + "edges/sha256-6ce481db1fe13bcfe7a2374423e6c72d757d51c99168b287c52f53aa625ccd67.glb", + "entities/sha256-3cc21a0ee57307d35c86046a2b9c0c158834d0ca41043aa292b48232e6ea63d5.json", + "geometry/sha256-200523414de281a8a155086a54cd0f880f640485765a48ccdd8ec230f67dd17c.glb" + ], + "expected": { + "code": "connector_invalid", + "path": "/entities/sha256-3cc21a0ee57307d35c86046a2b9c0c158834d0ca41043aa292b48232e6ea63d5.json/entities/6/connector_binding_status" + }, + "manifest_base64": "eyJhbm5vdGF0aW9ucyI6W10sImFwcGVhcmFuY2VzIjpbeyJhbHBoYV9tb2RlIjoib3BhcXVlIiwiYXBwZWFyYW5jZV9pZCI6ImFwcGVhcmFuY2UvZXZhbHVhdGVkL2JmMWNhNmQ2MGFmMjE3NjdhY2UxNDE5YjYwNTUyMTdjODcxNDU4OWI3OTA5MGQyNzBhYWJlNTEyMjMwZWZhM2EiLCJiYXNlX2NvbG9yIjpbMC43MiwwLjc1LDAuNzgsMV0sImRvdWJsZV9zaWRlZCI6ZmFsc2UsImVkZ2VfY29sb3IiOlswLjA4LDAuMDksMC4xLDFdLCJtZXRhbGxpYyI6MCwibmFtZSI6bnVsbCwicm91Z2huZXNzIjowLjU1LCJzZGtfbWV0YWRhdGEiOnt9LCJzb3VyY2UiOm51bGx9XSwiY2FtZXJhcyI6W10sImNvbXBpbGVfb3B0aW9ucyI6eyJhbmd1bGFyX3RvbGVyYW5jZSI6MC4yMiwiZW1iZWRfcHJlc2VudGF0aW9uIjpmYWxzZSwiZW1iZWRfc291cmNlIjpmYWxzZSwibGluZWFyX3RvbGVyYW5jZSI6MC4zNX0sImNvbm5lY3RvcnMiOltdLCJjb29yZGluYXRlX3N5c3RlbSI6eyJoYW5kZWRuZXNzIjoicmlnaHQiLCJsZW5ndGhfdW5pdCI6Im1tIiwidXBfYXhpcyI6IitaIn0sImRlZmluaXRpb25zIjpbeyJhcHBlYXJhbmNlX2lkIjoiYXBwZWFyYW5jZS9ldmFsdWF0ZWQvYmYxY2E2ZDYwYWYyMTc2N2FjZTE0MTliNjA1NTIxN2M4NzE0NTg5Yjc5MDkwZDI3MGFhYmU1MTIyMzBlZmEzYSIsImRlZmluaXRpb25faWQiOiJkZWZpbml0aW9uL3Jvb3Qvc2hhcGUvbWFudWFsL2ZpeHR1cmUiLCJlZGdlX2Fzc2V0X2lkIjoic2hhMjU2OjZjZTQ4MWRiMWZlMTNiY2ZlN2EyMzc0NDIzZTZjNzJkNzU3ZDUxYzk5MTY4YjI4N2M1MmY1M2FhNjI1Y2NkNjciLCJlbnRpdHlfYXNzZXRfaWQiOiJzaGEyNTY6M2NjMjFhMGVlNTczMDdkMzVjODYwNDZhMmI5YzBjMTU4ODM0ZDBjYTQxMDQzYWEyOTJiNDgyMzJlNmVhNjNkNSIsImdlb21ldHJ5X2Fzc2V0X2lkIjoic2hhMjU2OjIwMDUyMzQxNGRlMjgxYThhMTU1MDg2YTU0Y2QwZjg4MGY2NDA0ODU3NjVhNDhjY2RkOGVjMjMwZjY3ZGQxN2MiLCJraW5kIjoic2hhcGUiLCJuYW1lIjpudWxsLCJzZGtfbWV0YWRhdGEiOnt9LCJzb3VyY2UiOnsia2luZCI6Im1hbnVhbCIsInJvb3RfaWQiOiJyb290Iiwic291cmNlX2lkIjoiZml4dHVyZSJ9fV0sImRpYWdub3N0aWNzIjpbXSwiZWRnZV9hc3NldHMiOlt7ImFzc2V0X2lkIjoic2hhMjU2OjZjZTQ4MWRiMWZlMTNiY2ZlN2EyMzc0NDIzZTZjNzJkNzU3ZDUxYzk5MTY4YjI4N2M1MmY1M2FhNjI1Y2NkNjciLCJhc3NldF90b19zY2VuZSI6WzEwMDAsMCwwLDAsMCwwLC0xMDAwLDAsMCwxMDAwLDAsMCwwLDAsMCwxXSwiYnl0ZV9sZW5ndGgiOjc0MCwiY29udGVudF9oYXNoIjoic2hhMjU2OjZjZTQ4MWRiMWZlMTNiY2ZlN2EyMzc0NDIzZTZjNzJkNzU3ZDUxYzk5MTY4YjI4N2M1MmY1M2FhNjI1Y2NkNjciLCJtZWRpYV90eXBlIjoibW9kZWwvZ2x0Zi1iaW5hcnkiLCJzY2VuZV9sb2NhbF9ib3VuZHMiOnsibWF4IjpbMTAwMCwwLDEwMDBdLCJtaW4iOlswLC0xMDAwLDBdfSwidGVzc2VsbGF0aW9uIjp7ImxpbmVhcl90b2xlcmFuY2UiOjAuMzV9LCJ1cmkiOiJlZGdlcy9zaGEyNTYtNmNlNDgxZGIxZmUxM2JjZmU3YTIzNzQ0MjNlNmM3MmQ3NTdkNTFjOTkxNjhiMjg3YzUyZjUzYWE2MjVjY2Q2Ny5nbGIifV0sImVudGl0eV9hc3NldHMiOlt7ImJ5dGVfbGVuZ3RoIjoxMTUxOSwiY29udGVudF9oYXNoIjoic2hhMjU2OjNjYzIxYTBlZTU3MzA3ZDM1Yzg2MDQ2YTJiOWMwYzE1ODgzNGQwY2E0MTA0M2FhMjkyYjQ4MjMyZTZlYTYzZDUiLCJlbnRpdHlfYXNzZXRfaWQiOiJzaGEyNTY6M2NjMjFhMGVlNTczMDdkMzVjODYwNDZhMmI5YzBjMTU4ODM0ZDBjYTQxMDQzYWEyOTJiNDgyMzJlNmVhNjNkNSIsIm1lZGlhX3R5cGUiOiJhcHBsaWNhdGlvbi92bmQuc2ltcGxlY2FkLmVudGl0aWVzK2pzb24iLCJ1cmkiOiJlbnRpdGllcy9zaGEyNTYtM2NjMjFhMGVlNTczMDdkMzVjODYwNDZhMmI5YzBjMTU4ODM0ZDBjYTQxMDQzYWEyOTJiNDgyMzJlNmVhNjNkNS5qc29uIn1dLCJleHRlbnNpb25zIjp7fSwiZXh0ZW5zaW9uc19yZXF1aXJlZCI6W10sImV4dGVuc2lvbnNfdXNlZCI6W10sImdlbmVyYXRvciI6eyJuYW1lIjoic2ltcGxlY2FkYXBpIiwib2NwX2JpbmRpbmdzX3ZlcnNpb24iOiJmaXh0dXJlIiwib2NwX3ZlcnNpb24iOiJmaXh0dXJlIiwicGxhdGZvcm1fdGFnIjoiZml4dHVyZSIsInByb2ZpbGUiOiJzY2VuZS0xLjAtb2NwLWdsYi0yIiwicHl0aG9uX2FiaSI6ImZpeHR1cmUiLCJzaW1wbGVjYWRhcGlfdmVyc2lvbiI6ImZpeHR1cmUiLCJ0b29sY2hhaW5faGFzaCI6InNoYTI1NjowMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwIn0sImdlb21ldHJ5X2Fzc2V0cyI6W3siYXNzZXRfaWQiOiJzaGEyNTY6MjAwNTIzNDE0ZGUyODFhOGExNTUwODZhNTRjZDBmODgwZjY0MDQ4NTc2NWE0OGNjZGQ4ZWMyMzBmNjdkZDE3YyIsImFzc2V0X3RvX3NjZW5lIjpbMTAwMCwwLDAsMCwwLDAsLTEwMDAsMCwwLDEwMDAsMCwwLDAsMCwwLDFdLCJieXRlX2xlbmd0aCI6MTAyMCwiY29udGVudF9oYXNoIjoic2hhMjU2OjIwMDUyMzQxNGRlMjgxYThhMTU1MDg2YTU0Y2QwZjg4MGY2NDA0ODU3NjVhNDhjY2RkOGVjMjMwZjY3ZGQxN2MiLCJtZWRpYV90eXBlIjoibW9kZWwvZ2x0Zi1iaW5hcnkiLCJzY2VuZV9sb2NhbF9ib3VuZHMiOnsibWF4IjpbMTAwMCwwLDEwMDBdLCJtaW4iOlswLC0xMDAwLDBdfSwidGVzc2VsbGF0aW9uIjp7ImFuZ3VsYXJfdG9sZXJhbmNlIjowLjIyLCJsaW5lYXJfdG9sZXJhbmNlIjowLjM1fSwidXJpIjoiZ2VvbWV0cnkvc2hhMjU2LTIwMDUyMzQxNGRlMjgxYThhMTU1MDg2YTU0Y2QwZjg4MGY2NDA0ODU3NjVhNDhjY2RkOGVjMjMwZjY3ZGQxN2MuZ2xiIn1dLCJsaWdodHMiOltdLCJub2RlcyI6W3siYXBwZWFyYW5jZV9vdmVycmlkZV9pZCI6bnVsbCwiZGVmaW5pdGlvbl9pZCI6ImRlZmluaXRpb24vcm9vdC9zaGFwZS9tYW51YWwvZml4dHVyZSIsIm5hbWUiOm51bGwsIm5vZGVfaWQiOiJpbnN0YW5jZS9yb290Iiwib3JkZXIiOjAsInBhcmVudF9ub2RlX2lkIjpudWxsLCJzZGtfbWV0YWRhdGEiOnt9LCJzZWxlY3RhYmxlIjp0cnVlLCJzb3VyY2UiOnsia2luZCI6InNoYXBlX3Jvb3QiLCJyb290X2lkIjoicm9vdCJ9LCJ0cmFuc2Zvcm0iOnsib3JpZ2luIjpbMCwwLDBdLCJ4X2F4aXMiOlsxLDAsMF0sInlfYXhpcyI6WzAsMSwwXSwiel9heGlzIjpbMCwwLDFdfSwidmlzaWJsZSI6dHJ1ZX1dLCJyZXZpc2lvbiI6InNoYTI1NjpmMjdlZjI1MzY3Yzg1ZWY3YjFhY2JmYzBlZTUyZTY3YTdmYjJkY2Q1MjMwOGMyZjQyYzY1OWM4NGUzNWIxM2UwIiwic2NlbmVfaWQiOiJmaXh0dXJlIiwic2NoZW1hX3ZlcnNpb24iOiIxLjAiLCJzb3VyY2UiOnsia2luZCI6Im1hbnVhbCIsInNvdXJjZV9pZCI6ImZpeHR1cmUifX0=", + "name": "owner_not_part_status_precedence", + "valid": false + }, + { + "blob_uris": [ + "edges/sha256-6ce481db1fe13bcfe7a2374423e6c72d757d51c99168b287c52f53aa625ccd67.glb", + "entities/sha256-3ea2b1921b0c3ffc9684ba2ddcaa91f24a31ca1c6f037a09d31a0ea56d6fdb71.json", + "geometry/sha256-200523414de281a8a155086a54cd0f880f640485765a48ccdd8ec230f67dd17c.glb" + ], + "expected": { + "code": "connector_invalid", + "path": "/entities/sha256-3ea2b1921b0c3ffc9684ba2ddcaa91f24a31ca1c6f037a09d31a0ea56d6fdb71.json/entities/6/connector_binding_status" + }, + "manifest_base64": "eyJhbm5vdGF0aW9ucyI6W10sImFwcGVhcmFuY2VzIjpbeyJhbHBoYV9tb2RlIjoib3BhcXVlIiwiYXBwZWFyYW5jZV9pZCI6ImFwcGVhcmFuY2UvZXZhbHVhdGVkLzgxZjM4OWUyZThlMjcwYzllNGYwNzRlNDIxZTdiZTRhMGUxMDI2MTk2ZjVmZGVkODczMTkxZDlmOTFkYjA4NTIiLCJiYXNlX2NvbG9yIjpbMC43MiwwLjc1LDAuNzgsMV0sImRvdWJsZV9zaWRlZCI6ZmFsc2UsImVkZ2VfY29sb3IiOlswLjA4LDAuMDksMC4xLDFdLCJtZXRhbGxpYyI6MCwibmFtZSI6IkZpeHR1cmUgbWF0ZXJpYWwiLCJyb3VnaG5lc3MiOjAuNTUsInNka19tZXRhZGF0YSI6e30sInNvdXJjZSI6eyJraW5kIjoicHJvZHVjdF9tYXRlcmlhbCIsIm1hdGVyaWFsX2lkIjoiZml4dHVyZV9tYXRlcmlhbCIsInJvb3RfaWQiOiJyb290In19XSwiY2FtZXJhcyI6W10sImNvbXBpbGVfb3B0aW9ucyI6eyJhbmd1bGFyX3RvbGVyYW5jZSI6MC4yMiwiZW1iZWRfcHJlc2VudGF0aW9uIjpmYWxzZSwiZW1iZWRfc291cmNlIjpmYWxzZSwibGluZWFyX3RvbGVyYW5jZSI6MC4zNX0sImNvbm5lY3RvcnMiOlt7ImFuY2hvcl9raW5kIjoiZm9yd2FyZGVkIiwiY29ubmVjdG9yX2lkIjoibmVzdGVkX21vdW50IiwiY29ubmVjdG9yX3NuYXBzaG90X2lkIjoiY29ubmVjdG9yL3Jvb3QvYXNzZW1ibHkvbmVzdGVkX2Fzc2VtYmx5L25lc3RlZF9tb3VudCIsImZvcndhcmRlZF9mcm9tIjp7Im9mZnNldCI6bnVsbCwic291cmNlX2NvbXBvbmVudF9pZCI6InBhcnQiLCJzb3VyY2VfY29ubmVjdG9yX2lkIjoibW91bnQiLCJzb3VyY2VfY29ubmVjdG9yX3NuYXBzaG90X2lkIjoiY29ubmVjdG9yL3Jvb3QvcGFydC9zaGFyZWRfcGFydC9tb3VudCIsInNvdXJjZV9kZWZpbml0aW9uX2lkIjoiZGVmaW5pdGlvbi9yb290L3BhcnQvc2hhcmVkX3BhcnQifSwibG9jYWxfdHJhbnNmb3JtIjp7Im9yaWdpbiI6WzAsMCwwXSwieF9heGlzIjpbMSwwLDBdLCJ5X2F4aXMiOlswLDEsMF0sInpfYXhpcyI6WzAsMCwxXX0sIm5hbWUiOiJOZXN0ZWQgTW91bnQiLCJvd25lcl9kZWZpbml0aW9uX2lkIjoiZGVmaW5pdGlvbi9yb290L2Fzc2VtYmx5L25lc3RlZF9hc3NlbWJseSIsInNka19tZXRhZGF0YSI6e30sInNvdXJjZSI6eyJraW5kIjoibWFudWFsIiwic291cmNlX2lkIjoiZml4dHVyZSJ9fSx7ImFuY2hvcl9raW5kIjoiZm9yd2FyZGVkIiwiY29ubmVjdG9yX2lkIjoicm9vdF9tb3VudCIsImNvbm5lY3Rvcl9zbmFwc2hvdF9pZCI6ImNvbm5lY3Rvci9yb290L2Fzc2VtYmx5L3Jvb3RfYXNzZW1ibHkvcm9vdF9tb3VudCIsImZvcndhcmRlZF9mcm9tIjp7Im9mZnNldCI6eyJvcmlnaW4iOlswLDAsNV0sInhfYXhpcyI6WzEsMCwwXSwieV9heGlzIjpbMCwxLDBdLCJ6X2F4aXMiOlswLDAsMV19LCJzb3VyY2VfY29tcG9uZW50X2lkIjoic3ViYXNzZW1ibHkiLCJzb3VyY2VfY29ubmVjdG9yX2lkIjoibmVzdGVkX21vdW50Iiwic291cmNlX2Nvbm5lY3Rvcl9zbmFwc2hvdF9pZCI6ImNvbm5lY3Rvci9yb290L2Fzc2VtYmx5L25lc3RlZF9hc3NlbWJseS9uZXN0ZWRfbW91bnQiLCJzb3VyY2VfZGVmaW5pdGlvbl9pZCI6ImRlZmluaXRpb24vcm9vdC9hc3NlbWJseS9uZXN0ZWRfYXNzZW1ibHkifSwibG9jYWxfdHJhbnNmb3JtIjp7Im9yaWdpbiI6WzAsMCw1XSwieF9heGlzIjpbMSwwLDBdLCJ5X2F4aXMiOlswLDEsMF0sInpfYXhpcyI6WzAsMCwxXX0sIm5hbWUiOiJSb290IE1vdW50Iiwib3duZXJfZGVmaW5pdGlvbl9pZCI6ImRlZmluaXRpb24vcm9vdC9hc3NlbWJseS9yb290X2Fzc2VtYmx5Iiwic2RrX21ldGFkYXRhIjp7fSwic291cmNlIjp7ImtpbmQiOiJtYW51YWwiLCJzb3VyY2VfaWQiOiJmaXh0dXJlIn19LHsiYW5jaG9yX2tpbmQiOiJwbGFjZW1lbnQiLCJjb25uZWN0b3JfaWQiOiJtb3VudCIsImNvbm5lY3Rvcl9zbmFwc2hvdF9pZCI6ImNvbm5lY3Rvci9yb290L3BhcnQvc2hhcmVkX3BhcnQvbW91bnQiLCJsb2NhbF90cmFuc2Zvcm0iOnsib3JpZ2luIjpbMCwwLDBdLCJ4X2F4aXMiOlsxLDAsMF0sInlfYXhpcyI6WzAsMSwwXSwiel9heGlzIjpbMCwwLDFdfSwibmFtZSI6Ik1vdW50Iiwib3duZXJfZGVmaW5pdGlvbl9pZCI6ImRlZmluaXRpb24vcm9vdC9wYXJ0L3NoYXJlZF9wYXJ0Iiwic2RrX21ldGFkYXRhIjp7fSwic291cmNlIjp7ImtpbmQiOiJtYW51YWwiLCJzb3VyY2VfaWQiOiJmaXh0dXJlIn19XSwiY29vcmRpbmF0ZV9zeXN0ZW0iOnsiaGFuZGVkbmVzcyI6InJpZ2h0IiwibGVuZ3RoX3VuaXQiOiJtbSIsInVwX2F4aXMiOiIrWiJ9LCJkZWZpbml0aW9ucyI6W3siZGVmaW5pdGlvbl9pZCI6ImRlZmluaXRpb24vcm9vdC9hc3NlbWJseS9uZXN0ZWRfYXNzZW1ibHkiLCJraW5kIjoiYXNzZW1ibHkiLCJuYW1lIjoiTmVzdGVkIEFzc2VtYmx5Iiwic2RrX21ldGFkYXRhIjp7fSwic291cmNlIjp7ImtpbmQiOiJwcm9kdWN0X21hbnVhbCIsInJvb3RfaWQiOiJyb290Iiwic2VtYW50aWNfaWQiOiJuZXN0ZWRfYXNzZW1ibHkiLCJzZW1hbnRpY190eXBlIjoiQXNzZW1ibHkifX0seyJkZWZpbml0aW9uX2lkIjoiZGVmaW5pdGlvbi9yb290L2Fzc2VtYmx5L3Jvb3RfYXNzZW1ibHkiLCJraW5kIjoiYXNzZW1ibHkiLCJuYW1lIjoiUm9vdCBBc3NlbWJseSIsInNka19tZXRhZGF0YSI6e30sInNvdXJjZSI6eyJraW5kIjoicHJvZHVjdF9tYW51YWwiLCJyb290X2lkIjoicm9vdCIsInNlbWFudGljX2lkIjoicm9vdF9hc3NlbWJseSIsInNlbWFudGljX3R5cGUiOiJBc3NlbWJseSJ9fSx7ImFwcGVhcmFuY2VfaWQiOiJhcHBlYXJhbmNlL2V2YWx1YXRlZC84MWYzODllMmU4ZTI3MGM5ZTRmMDc0ZTQyMWU3YmU0YTBlMTAyNjE5NmY1ZmRlZDg3MzE5MWQ5ZjkxZGIwODUyIiwiZGVmaW5pdGlvbl9pZCI6ImRlZmluaXRpb24vcm9vdC9wYXJ0L3NoYXJlZF9wYXJ0IiwiZWRnZV9hc3NldF9pZCI6InNoYTI1Njo2Y2U0ODFkYjFmZTEzYmNmZTdhMjM3NDQyM2U2YzcyZDc1N2Q1MWM5OTE2OGIyODdjNTJmNTNhYTYyNWNjZDY3IiwiZW50aXR5X2Fzc2V0X2lkIjoic2hhMjU2OjNlYTJiMTkyMWIwYzNmZmM5Njg0YmEyZGRjYWE5MWYyNGEzMWNhMWM2ZjAzN2EwOWQzMWEwZWE1NmQ2ZmRiNzEiLCJnZW9tZXRyeV9hc3NldF9pZCI6InNoYTI1NjoyMDA1MjM0MTRkZTI4MWE4YTE1NTA4NmE1NGNkMGY4ODBmNjQwNDg1NzY1YTQ4Y2NkZDhlYzIzMGY2N2RkMTdjIiwia2luZCI6InBhcnQiLCJuYW1lIjoiU2hhcmVkIFBhcnQiLCJzZGtfbWV0YWRhdGEiOnt9LCJzb3VyY2UiOnsia2luZCI6InByb2R1Y3RfbWFudWFsIiwicm9vdF9pZCI6InJvb3QiLCJzZW1hbnRpY19pZCI6InNoYXJlZF9wYXJ0Iiwic2VtYW50aWNfdHlwZSI6IlBhcnQifX1dLCJkaWFnbm9zdGljcyI6W10sImVkZ2VfYXNzZXRzIjpbeyJhc3NldF9pZCI6InNoYTI1Njo2Y2U0ODFkYjFmZTEzYmNmZTdhMjM3NDQyM2U2YzcyZDc1N2Q1MWM5OTE2OGIyODdjNTJmNTNhYTYyNWNjZDY3IiwiYXNzZXRfdG9fc2NlbmUiOlsxMDAwLDAsMCwwLDAsMCwtMTAwMCwwLDAsMTAwMCwwLDAsMCwwLDAsMV0sImJ5dGVfbGVuZ3RoIjo3NDAsImNvbnRlbnRfaGFzaCI6InNoYTI1Njo2Y2U0ODFkYjFmZTEzYmNmZTdhMjM3NDQyM2U2YzcyZDc1N2Q1MWM5OTE2OGIyODdjNTJmNTNhYTYyNWNjZDY3IiwibWVkaWFfdHlwZSI6Im1vZGVsL2dsdGYtYmluYXJ5Iiwic2NlbmVfbG9jYWxfYm91bmRzIjp7Im1heCI6WzEwMDAsMCwxMDAwXSwibWluIjpbMCwtMTAwMCwwXX0sInRlc3NlbGxhdGlvbiI6eyJsaW5lYXJfdG9sZXJhbmNlIjowLjM1fSwidXJpIjoiZWRnZXMvc2hhMjU2LTZjZTQ4MWRiMWZlMTNiY2ZlN2EyMzc0NDIzZTZjNzJkNzU3ZDUxYzk5MTY4YjI4N2M1MmY1M2FhNjI1Y2NkNjcuZ2xiIn1dLCJlbnRpdHlfYXNzZXRzIjpbeyJieXRlX2xlbmd0aCI6MTE1MzQsImNvbnRlbnRfaGFzaCI6InNoYTI1NjozZWEyYjE5MjFiMGMzZmZjOTY4NGJhMmRkY2FhOTFmMjRhMzFjYTFjNmYwMzdhMDlkMzFhMGVhNTZkNmZkYjcxIiwiZW50aXR5X2Fzc2V0X2lkIjoic2hhMjU2OjNlYTJiMTkyMWIwYzNmZmM5Njg0YmEyZGRjYWE5MWYyNGEzMWNhMWM2ZjAzN2EwOWQzMWEwZWE1NmQ2ZmRiNzEiLCJtZWRpYV90eXBlIjoiYXBwbGljYXRpb24vdm5kLnNpbXBsZWNhZC5lbnRpdGllcytqc29uIiwidXJpIjoiZW50aXRpZXMvc2hhMjU2LTNlYTJiMTkyMWIwYzNmZmM5Njg0YmEyZGRjYWE5MWYyNGEzMWNhMWM2ZjAzN2EwOWQzMWEwZWE1NmQ2ZmRiNzEuanNvbiJ9XSwiZXh0ZW5zaW9ucyI6e30sImV4dGVuc2lvbnNfcmVxdWlyZWQiOltdLCJleHRlbnNpb25zX3VzZWQiOltdLCJnZW5lcmF0b3IiOnsibmFtZSI6InNpbXBsZWNhZGFwaSIsIm9jcF9iaW5kaW5nc192ZXJzaW9uIjoiZml4dHVyZSIsIm9jcF92ZXJzaW9uIjoiZml4dHVyZSIsInBsYXRmb3JtX3RhZyI6ImZpeHR1cmUiLCJwcm9maWxlIjoic2NlbmUtMS4wLW9jcC1nbGItMiIsInB5dGhvbl9hYmkiOiJmaXh0dXJlIiwic2ltcGxlY2FkYXBpX3ZlcnNpb24iOiJmaXh0dXJlIiwidG9vbGNoYWluX2hhc2giOiJzaGEyNTY6MDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMCJ9LCJnZW9tZXRyeV9hc3NldHMiOlt7ImFzc2V0X2lkIjoic2hhMjU2OjIwMDUyMzQxNGRlMjgxYThhMTU1MDg2YTU0Y2QwZjg4MGY2NDA0ODU3NjVhNDhjY2RkOGVjMjMwZjY3ZGQxN2MiLCJhc3NldF90b19zY2VuZSI6WzEwMDAsMCwwLDAsMCwwLC0xMDAwLDAsMCwxMDAwLDAsMCwwLDAsMCwxXSwiYnl0ZV9sZW5ndGgiOjEwMjAsImNvbnRlbnRfaGFzaCI6InNoYTI1NjoyMDA1MjM0MTRkZTI4MWE4YTE1NTA4NmE1NGNkMGY4ODBmNjQwNDg1NzY1YTQ4Y2NkZDhlYzIzMGY2N2RkMTdjIiwibWVkaWFfdHlwZSI6Im1vZGVsL2dsdGYtYmluYXJ5Iiwic2NlbmVfbG9jYWxfYm91bmRzIjp7Im1heCI6WzEwMDAsMCwxMDAwXSwibWluIjpbMCwtMTAwMCwwXX0sInRlc3NlbGxhdGlvbiI6eyJhbmd1bGFyX3RvbGVyYW5jZSI6MC4yMiwibGluZWFyX3RvbGVyYW5jZSI6MC4zNX0sInVyaSI6Imdlb21ldHJ5L3NoYTI1Ni0yMDA1MjM0MTRkZTI4MWE4YTE1NTA4NmE1NGNkMGY4ODBmNjQwNDg1NzY1YTQ4Y2NkZDhlYzIzMGY2N2RkMTdjLmdsYiJ9XSwibGlnaHRzIjpbXSwibm9kZXMiOlt7ImFwcGVhcmFuY2Vfb3ZlcnJpZGVfaWQiOm51bGwsImRlZmluaXRpb25faWQiOiJkZWZpbml0aW9uL3Jvb3QvYXNzZW1ibHkvcm9vdF9hc3NlbWJseSIsIm5hbWUiOiJyb290Iiwibm9kZV9pZCI6Imluc3RhbmNlL3Jvb3QiLCJvcmRlciI6MCwicGFyZW50X25vZGVfaWQiOm51bGwsInNka19tZXRhZGF0YSI6e30sInNlbGVjdGFibGUiOnRydWUsInNvdXJjZSI6eyJjb21wb25lbnRfcGF0aCI6W10sImtpbmQiOiJwcm9kdWN0X29jY3VycmVuY2UiLCJyb290X2lkIjoicm9vdCJ9LCJ0cmFuc2Zvcm0iOnsib3JpZ2luIjpbMCwwLDBdLCJ4X2F4aXMiOlsxLDAsMF0sInlfYXhpcyI6WzAsMSwwXSwiel9heGlzIjpbMCwwLDFdfSwidmlzaWJsZSI6dHJ1ZX0seyJhcHBlYXJhbmNlX292ZXJyaWRlX2lkIjpudWxsLCJkZWZpbml0aW9uX2lkIjoiZGVmaW5pdGlvbi9yb290L2Fzc2VtYmx5L25lc3RlZF9hc3NlbWJseSIsIm5hbWUiOiJzdWJhc3NlbWJseSIsIm5vZGVfaWQiOiJpbnN0YW5jZS9yb290L3N1YmFzc2VtYmx5Iiwib3JkZXIiOjAsInBhcmVudF9ub2RlX2lkIjoiaW5zdGFuY2Uvcm9vdCIsInNka19tZXRhZGF0YSI6e30sInNlbGVjdGFibGUiOnRydWUsInNvdXJjZSI6eyJjb21wb25lbnRfcGF0aCI6WyJzdWJhc3NlbWJseSJdLCJraW5kIjoicHJvZHVjdF9vY2N1cnJlbmNlIiwicm9vdF9pZCI6InJvb3QifSwidHJhbnNmb3JtIjp7Im9yaWdpbiI6WzAsMCwwXSwieF9heGlzIjpbMSwwLDBdLCJ5X2F4aXMiOlswLDEsMF0sInpfYXhpcyI6WzAsMCwxXX0sInZpc2libGUiOnRydWV9LHsiYXBwZWFyYW5jZV9vdmVycmlkZV9pZCI6bnVsbCwiZGVmaW5pdGlvbl9pZCI6ImRlZmluaXRpb24vcm9vdC9wYXJ0L3NoYXJlZF9wYXJ0IiwibmFtZSI6InBhcnQiLCJub2RlX2lkIjoiaW5zdGFuY2Uvcm9vdC9zdWJhc3NlbWJseS9wYXJ0Iiwib3JkZXIiOjAsInBhcmVudF9ub2RlX2lkIjoiaW5zdGFuY2Uvcm9vdC9zdWJhc3NlbWJseSIsInNka19tZXRhZGF0YSI6e30sInNlbGVjdGFibGUiOnRydWUsInNvdXJjZSI6eyJjb21wb25lbnRfcGF0aCI6WyJzdWJhc3NlbWJseSIsInBhcnQiXSwia2luZCI6InByb2R1Y3Rfb2NjdXJyZW5jZSIsInJvb3RfaWQiOiJyb290In0sInRyYW5zZm9ybSI6eyJvcmlnaW4iOlswLDAsMF0sInhfYXhpcyI6WzEsMCwwXSwieV9heGlzIjpbMCwxLDBdLCJ6X2F4aXMiOlswLDAsMV19LCJ2aXNpYmxlIjp0cnVlfV0sInJldmlzaW9uIjoic2hhMjU2OmI0NmU1M2EzMzg1NjFlZjA0ZDExZTFjNTNhM2YwYTJjZmVlYjcxNGIxN2I5ODNlZjhkMjk0ZGRlMTcyOGFkNGQiLCJzY2VuZV9pZCI6Im5lc3RlZF9maXh0dXJlIiwic2NoZW1hX3ZlcnNpb24iOiIxLjAiLCJzb3VyY2UiOnsia2luZCI6Im1hbnVhbCIsInNvdXJjZV9pZCI6ImZpeHR1cmUifX0=", + "name": "source_not_model_status_precedence", + "valid": false + }, + { + "blob_uris": [ + "edges/sha256-6ce481db1fe13bcfe7a2374423e6c72d757d51c99168b287c52f53aa625ccd67.glb", + "entities/sha256-6330bb77b40d4f53d5f3face884028b74d4c2e492cde2fc303ad367ccd286f08.json", + "geometry/sha256-200523414de281a8a155086a54cd0f880f640485765a48ccdd8ec230f67dd17c.glb" + ], + "expected": { + "code": "connector_invalid", + "path": "/entities/sha256-6330bb77b40d4f53d5f3face884028b74d4c2e492cde2fc303ad367ccd286f08.json/entities/0/connector_binding_status" + }, + "manifest_base64": "eyJhbm5vdGF0aW9ucyI6W10sImFwcGVhcmFuY2VzIjpbeyJhbHBoYV9tb2RlIjoib3BhcXVlIiwiYXBwZWFyYW5jZV9pZCI6ImFwcGVhcmFuY2UvZXZhbHVhdGVkLzgxZjM4OWUyZThlMjcwYzllNGYwNzRlNDIxZTdiZTRhMGUxMDI2MTk2ZjVmZGVkODczMTkxZDlmOTFkYjA4NTIiLCJiYXNlX2NvbG9yIjpbMC43MiwwLjc1LDAuNzgsMV0sImRvdWJsZV9zaWRlZCI6ZmFsc2UsImVkZ2VfY29sb3IiOlswLjA4LDAuMDksMC4xLDFdLCJtZXRhbGxpYyI6MCwibmFtZSI6IkZpeHR1cmUgbWF0ZXJpYWwiLCJyb3VnaG5lc3MiOjAuNTUsInNka19tZXRhZGF0YSI6e30sInNvdXJjZSI6eyJraW5kIjoicHJvZHVjdF9tYXRlcmlhbCIsIm1hdGVyaWFsX2lkIjoiZml4dHVyZV9tYXRlcmlhbCIsInJvb3RfaWQiOiJyb290In19XSwiY2FtZXJhcyI6W10sImNvbXBpbGVfb3B0aW9ucyI6eyJhbmd1bGFyX3RvbGVyYW5jZSI6MC4yMiwiZW1iZWRfcHJlc2VudGF0aW9uIjpmYWxzZSwiZW1iZWRfc291cmNlIjpmYWxzZSwibGluZWFyX3RvbGVyYW5jZSI6MC4zNX0sImNvbm5lY3RvcnMiOltdLCJjb29yZGluYXRlX3N5c3RlbSI6eyJoYW5kZWRuZXNzIjoicmlnaHQiLCJsZW5ndGhfdW5pdCI6Im1tIiwidXBfYXhpcyI6IitaIn0sImRlZmluaXRpb25zIjpbeyJhcHBlYXJhbmNlX2lkIjoiYXBwZWFyYW5jZS9ldmFsdWF0ZWQvODFmMzg5ZTJlOGUyNzBjOWU0ZjA3NGU0MjFlN2JlNGEwZTEwMjYxOTZmNWZkZWQ4NzMxOTFkOWY5MWRiMDg1MiIsImRlZmluaXRpb25faWQiOiJkZWZpbml0aW9uL3Jvb3QvcGFydC9tb2RlbF9wYXJ0IiwiZWRnZV9hc3NldF9pZCI6InNoYTI1Njo2Y2U0ODFkYjFmZTEzYmNmZTdhMjM3NDQyM2U2YzcyZDc1N2Q1MWM5OTE2OGIyODdjNTJmNTNhYTYyNWNjZDY3IiwiZW50aXR5X2Fzc2V0X2lkIjoic2hhMjU2OjYzMzBiYjc3YjQwZDRmNTNkNWYzZmFjZTg4NDAyOGI3NGQ0YzJlNDkyY2RlMmZjMzAzYWQzNjdjY2QyODZmMDgiLCJnZW9tZXRyeV9hc3NldF9pZCI6InNoYTI1NjoyMDA1MjM0MTRkZTI4MWE4YTE1NTA4NmE1NGNkMGY4ODBmNjQwNDg1NzY1YTQ4Y2NkZDhlYzIzMGY2N2RkMTdjIiwia2luZCI6InBhcnQiLCJuYW1lIjpudWxsLCJzZGtfbWV0YWRhdGEiOnt9LCJzb3VyY2UiOnsiZ3JhcGhfaWQiOiJncmFwaCIsImtpbmQiOiJwcm9kdWN0X21vZGVsIiwibm9kZV9pZCI6ImJvZHkiLCJvdXRwdXRfc2xvdCI6MCwicm9vdF9pZCI6InJvb3QiLCJzZW1hbnRpY19pZCI6Im1vZGVsX3BhcnQiLCJzZW1hbnRpY190eXBlIjoiUGFydCJ9fV0sImRpYWdub3N0aWNzIjpbXSwiZWRnZV9hc3NldHMiOlt7ImFzc2V0X2lkIjoic2hhMjU2OjZjZTQ4MWRiMWZlMTNiY2ZlN2EyMzc0NDIzZTZjNzJkNzU3ZDUxYzk5MTY4YjI4N2M1MmY1M2FhNjI1Y2NkNjciLCJhc3NldF90b19zY2VuZSI6WzEwMDAsMCwwLDAsMCwwLC0xMDAwLDAsMCwxMDAwLDAsMCwwLDAsMCwxXSwiYnl0ZV9sZW5ndGgiOjc0MCwiY29udGVudF9oYXNoIjoic2hhMjU2OjZjZTQ4MWRiMWZlMTNiY2ZlN2EyMzc0NDIzZTZjNzJkNzU3ZDUxYzk5MTY4YjI4N2M1MmY1M2FhNjI1Y2NkNjciLCJtZWRpYV90eXBlIjoibW9kZWwvZ2x0Zi1iaW5hcnkiLCJzY2VuZV9sb2NhbF9ib3VuZHMiOnsibWF4IjpbMTAwMCwwLDEwMDBdLCJtaW4iOlswLC0xMDAwLDBdfSwidGVzc2VsbGF0aW9uIjp7ImxpbmVhcl90b2xlcmFuY2UiOjAuMzV9LCJ1cmkiOiJlZGdlcy9zaGEyNTYtNmNlNDgxZGIxZmUxM2JjZmU3YTIzNzQ0MjNlNmM3MmQ3NTdkNTFjOTkxNjhiMjg3YzUyZjUzYWE2MjVjY2Q2Ny5nbGIifV0sImVudGl0eV9hc3NldHMiOlt7ImJ5dGVfbGVuZ3RoIjoxMjk2MCwiY29udGVudF9oYXNoIjoic2hhMjU2OjYzMzBiYjc3YjQwZDRmNTNkNWYzZmFjZTg4NDAyOGI3NGQ0YzJlNDkyY2RlMmZjMzAzYWQzNjdjY2QyODZmMDgiLCJlbnRpdHlfYXNzZXRfaWQiOiJzaGEyNTY6NjMzMGJiNzdiNDBkNGY1M2Q1ZjNmYWNlODg0MDI4Yjc0ZDRjMmU0OTJjZGUyZmMzMDNhZDM2N2NjZDI4NmYwOCIsIm1lZGlhX3R5cGUiOiJhcHBsaWNhdGlvbi92bmQuc2ltcGxlY2FkLmVudGl0aWVzK2pzb24iLCJ1cmkiOiJlbnRpdGllcy9zaGEyNTYtNjMzMGJiNzdiNDBkNGY1M2Q1ZjNmYWNlODg0MDI4Yjc0ZDRjMmU0OTJjZGUyZmMzMDNhZDM2N2NjZDI4NmYwOC5qc29uIn1dLCJleHRlbnNpb25zIjp7fSwiZXh0ZW5zaW9uc19yZXF1aXJlZCI6W10sImV4dGVuc2lvbnNfdXNlZCI6W10sImdlbmVyYXRvciI6eyJuYW1lIjoic2ltcGxlY2FkYXBpIiwib2NwX2JpbmRpbmdzX3ZlcnNpb24iOiJmaXh0dXJlIiwib2NwX3ZlcnNpb24iOiJmaXh0dXJlIiwicGxhdGZvcm1fdGFnIjoiZml4dHVyZSIsInByb2ZpbGUiOiJzY2VuZS0xLjAtb2NwLWdsYi0yIiwicHl0aG9uX2FiaSI6ImZpeHR1cmUiLCJzaW1wbGVjYWRhcGlfdmVyc2lvbiI6ImZpeHR1cmUiLCJ0b29sY2hhaW5faGFzaCI6InNoYTI1NjowMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwIn0sImdlb21ldHJ5X2Fzc2V0cyI6W3siYXNzZXRfaWQiOiJzaGEyNTY6MjAwNTIzNDE0ZGUyODFhOGExNTUwODZhNTRjZDBmODgwZjY0MDQ4NTc2NWE0OGNjZGQ4ZWMyMzBmNjdkZDE3YyIsImFzc2V0X3RvX3NjZW5lIjpbMTAwMCwwLDAsMCwwLDAsLTEwMDAsMCwwLDEwMDAsMCwwLDAsMCwwLDFdLCJieXRlX2xlbmd0aCI6MTAyMCwiY29udGVudF9oYXNoIjoic2hhMjU2OjIwMDUyMzQxNGRlMjgxYThhMTU1MDg2YTU0Y2QwZjg4MGY2NDA0ODU3NjVhNDhjY2RkOGVjMjMwZjY3ZGQxN2MiLCJtZWRpYV90eXBlIjoibW9kZWwvZ2x0Zi1iaW5hcnkiLCJzY2VuZV9sb2NhbF9ib3VuZHMiOnsibWF4IjpbMTAwMCwwLDEwMDBdLCJtaW4iOlswLC0xMDAwLDBdfSwidGVzc2VsbGF0aW9uIjp7ImFuZ3VsYXJfdG9sZXJhbmNlIjowLjIyLCJsaW5lYXJfdG9sZXJhbmNlIjowLjM1fSwidXJpIjoiZ2VvbWV0cnkvc2hhMjU2LTIwMDUyMzQxNGRlMjgxYThhMTU1MDg2YTU0Y2QwZjg4MGY2NDA0ODU3NjVhNDhjY2RkOGVjMjMwZjY3ZGQxN2MuZ2xiIn1dLCJsaWdodHMiOltdLCJub2RlcyI6W3siYXBwZWFyYW5jZV9vdmVycmlkZV9pZCI6bnVsbCwiZGVmaW5pdGlvbl9pZCI6ImRlZmluaXRpb24vcm9vdC9wYXJ0L21vZGVsX3BhcnQiLCJuYW1lIjpudWxsLCJub2RlX2lkIjoiaW5zdGFuY2Uvcm9vdCIsIm9yZGVyIjowLCJwYXJlbnRfbm9kZV9pZCI6bnVsbCwic2RrX21ldGFkYXRhIjp7fSwic2VsZWN0YWJsZSI6dHJ1ZSwic291cmNlIjp7ImNvbXBvbmVudF9wYXRoIjpbXSwia2luZCI6InByb2R1Y3Rfb2NjdXJyZW5jZSIsInJvb3RfaWQiOiJyb290In0sInRyYW5zZm9ybSI6eyJvcmlnaW4iOlswLDAsMF0sInhfYXhpcyI6WzEsMCwwXSwieV9heGlzIjpbMCwxLDBdLCJ6X2F4aXMiOlswLDAsMV19LCJ2aXNpYmxlIjp0cnVlfV0sInJldmlzaW9uIjoic2hhMjU2OmFlNWExYzBhZWYxN2NmZmE4OWZjYjgxNWU3NGI1YzllMmM4NTQ1NDZhYjY2NDRhMGQ5ODE0MTVjMzQ1N2EyZDQiLCJzY2VuZV9pZCI6Im1vZGVsX3BhcnRfZml4dHVyZSIsInNjaGVtYV92ZXJzaW9uIjoiMS4wIiwic291cmNlIjp7ImFydGlmYWN0X2hhc2giOiJzaGEyNTY6MjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMiIsImdyYXBoX2lkIjoiZ3JhcGgiLCJraW5kIjoibW9kZWwiLCJtb2RlbF9zY2hlbWFfdmVyc2lvbiI6IjIuMCJ9fQ==", + "name": "frame_undefined_status_precedence", + "valid": false + }, + { + "blob_uris": [ + "edges/sha256-6ce481db1fe13bcfe7a2374423e6c72d757d51c99168b287c52f53aa625ccd67.glb", + "entities/sha256-ec65dbf3f594403b3969be03fb0047737db645cc3b473304b1403812bec5c2cb.json", + "geometry/sha256-200523414de281a8a155086a54cd0f880f640485765a48ccdd8ec230f67dd17c.glb" + ], + "expected": { + "code": "connector_invalid", + "path": "/connectors/1/local_transform" + }, + "manifest_base64": "eyJhbm5vdGF0aW9ucyI6W10sImFwcGVhcmFuY2VzIjpbeyJhbHBoYV9tb2RlIjoib3BhcXVlIiwiYXBwZWFyYW5jZV9pZCI6ImFwcGVhcmFuY2UvZXZhbHVhdGVkLzgxZjM4OWUyZThlMjcwYzllNGYwNzRlNDIxZTdiZTRhMGUxMDI2MTk2ZjVmZGVkODczMTkxZDlmOTFkYjA4NTIiLCJiYXNlX2NvbG9yIjpbMC43MiwwLjc1LDAuNzgsMV0sImRvdWJsZV9zaWRlZCI6ZmFsc2UsImVkZ2VfY29sb3IiOlswLjA4LDAuMDksMC4xLDFdLCJtZXRhbGxpYyI6MCwibmFtZSI6IkZpeHR1cmUgbWF0ZXJpYWwiLCJyb3VnaG5lc3MiOjAuNTUsInNka19tZXRhZGF0YSI6e30sInNvdXJjZSI6eyJraW5kIjoicHJvZHVjdF9tYXRlcmlhbCIsIm1hdGVyaWFsX2lkIjoiZml4dHVyZV9tYXRlcmlhbCIsInJvb3RfaWQiOiJyb290In19XSwiY2FtZXJhcyI6W10sImNvbXBpbGVfb3B0aW9ucyI6eyJhbmd1bGFyX3RvbGVyYW5jZSI6MC4yMiwiZW1iZWRfcHJlc2VudGF0aW9uIjpmYWxzZSwiZW1iZWRfc291cmNlIjpmYWxzZSwibGluZWFyX3RvbGVyYW5jZSI6MC4zNX0sImNvbm5lY3RvcnMiOlt7ImFuY2hvcl9raW5kIjoiZm9yd2FyZGVkIiwiY29ubmVjdG9yX2lkIjoibmVzdGVkX21vdW50IiwiY29ubmVjdG9yX3NuYXBzaG90X2lkIjoiY29ubmVjdG9yL3Jvb3QvYXNzZW1ibHkvbmVzdGVkX2Fzc2VtYmx5L25lc3RlZF9tb3VudCIsImZvcndhcmRlZF9mcm9tIjp7Im9mZnNldCI6bnVsbCwic291cmNlX2NvbXBvbmVudF9pZCI6InBhcnQiLCJzb3VyY2VfY29ubmVjdG9yX2lkIjoibW91bnQiLCJzb3VyY2VfY29ubmVjdG9yX3NuYXBzaG90X2lkIjoiY29ubmVjdG9yL3Jvb3QvcGFydC9zaGFyZWRfcGFydC9tb3VudCIsInNvdXJjZV9kZWZpbml0aW9uX2lkIjoiZGVmaW5pdGlvbi9yb290L3BhcnQvc2hhcmVkX3BhcnQifSwibG9jYWxfdHJhbnNmb3JtIjp7Im9yaWdpbiI6WzAsMCwwXSwieF9heGlzIjpbMSwwLDBdLCJ5X2F4aXMiOlswLDEsMF0sInpfYXhpcyI6WzAsMCwxXX0sIm5hbWUiOiJOZXN0ZWQgTW91bnQiLCJvd25lcl9kZWZpbml0aW9uX2lkIjoiZGVmaW5pdGlvbi9yb290L2Fzc2VtYmx5L25lc3RlZF9hc3NlbWJseSIsInNka19tZXRhZGF0YSI6e30sInNvdXJjZSI6eyJraW5kIjoibWFudWFsIiwic291cmNlX2lkIjoiZml4dHVyZSJ9fSx7ImFuY2hvcl9raW5kIjoiZm9yd2FyZGVkIiwiY29ubmVjdG9yX2lkIjoicm9vdF9tb3VudCIsImNvbm5lY3Rvcl9zbmFwc2hvdF9pZCI6ImNvbm5lY3Rvci9yb290L2Fzc2VtYmx5L3Jvb3RfYXNzZW1ibHkvcm9vdF9tb3VudCIsImZvcndhcmRlZF9mcm9tIjp7Im9mZnNldCI6eyJvcmlnaW4iOlswLDAsNV0sInhfYXhpcyI6WzEsMCwwXSwieV9heGlzIjpbMCwxLDBdLCJ6X2F4aXMiOlswLDAsMV19LCJzb3VyY2VfY29tcG9uZW50X2lkIjoic3ViYXNzZW1ibHkiLCJzb3VyY2VfY29ubmVjdG9yX2lkIjoibmVzdGVkX21vdW50Iiwic291cmNlX2Nvbm5lY3Rvcl9zbmFwc2hvdF9pZCI6ImNvbm5lY3Rvci9yb290L2Fzc2VtYmx5L25lc3RlZF9hc3NlbWJseS9uZXN0ZWRfbW91bnQiLCJzb3VyY2VfZGVmaW5pdGlvbl9pZCI6ImRlZmluaXRpb24vcm9vdC9hc3NlbWJseS9uZXN0ZWRfYXNzZW1ibHkifSwibG9jYWxfdHJhbnNmb3JtIjp7Im9yaWdpbiI6WzAsMCw2XSwieF9heGlzIjpbMSwwLDBdLCJ5X2F4aXMiOlswLDEsMF0sInpfYXhpcyI6WzAsMCwxXX0sIm5hbWUiOiJSb290IE1vdW50Iiwib3duZXJfZGVmaW5pdGlvbl9pZCI6ImRlZmluaXRpb24vcm9vdC9hc3NlbWJseS9yb290X2Fzc2VtYmx5Iiwic2RrX21ldGFkYXRhIjp7fSwic291cmNlIjp7ImtpbmQiOiJtYW51YWwiLCJzb3VyY2VfaWQiOiJmaXh0dXJlIn19LHsiYW5jaG9yX2tpbmQiOiJwbGFjZW1lbnQiLCJjb25uZWN0b3JfaWQiOiJtb3VudCIsImNvbm5lY3Rvcl9zbmFwc2hvdF9pZCI6ImNvbm5lY3Rvci9yb290L3BhcnQvc2hhcmVkX3BhcnQvbW91bnQiLCJsb2NhbF90cmFuc2Zvcm0iOnsib3JpZ2luIjpbMCwwLDBdLCJ4X2F4aXMiOlsxLDAsMF0sInlfYXhpcyI6WzAsMSwwXSwiel9heGlzIjpbMCwwLDFdfSwibmFtZSI6Ik1vdW50Iiwib3duZXJfZGVmaW5pdGlvbl9pZCI6ImRlZmluaXRpb24vcm9vdC9wYXJ0L3NoYXJlZF9wYXJ0Iiwic2RrX21ldGFkYXRhIjp7fSwic291cmNlIjp7ImtpbmQiOiJtYW51YWwiLCJzb3VyY2VfaWQiOiJmaXh0dXJlIn19XSwiY29vcmRpbmF0ZV9zeXN0ZW0iOnsiaGFuZGVkbmVzcyI6InJpZ2h0IiwibGVuZ3RoX3VuaXQiOiJtbSIsInVwX2F4aXMiOiIrWiJ9LCJkZWZpbml0aW9ucyI6W3siZGVmaW5pdGlvbl9pZCI6ImRlZmluaXRpb24vcm9vdC9hc3NlbWJseS9uZXN0ZWRfYXNzZW1ibHkiLCJraW5kIjoiYXNzZW1ibHkiLCJuYW1lIjoiTmVzdGVkIEFzc2VtYmx5Iiwic2RrX21ldGFkYXRhIjp7fSwic291cmNlIjp7ImtpbmQiOiJwcm9kdWN0X21hbnVhbCIsInJvb3RfaWQiOiJyb290Iiwic2VtYW50aWNfaWQiOiJuZXN0ZWRfYXNzZW1ibHkiLCJzZW1hbnRpY190eXBlIjoiQXNzZW1ibHkifX0seyJkZWZpbml0aW9uX2lkIjoiZGVmaW5pdGlvbi9yb290L2Fzc2VtYmx5L3Jvb3RfYXNzZW1ibHkiLCJraW5kIjoiYXNzZW1ibHkiLCJuYW1lIjoiUm9vdCBBc3NlbWJseSIsInNka19tZXRhZGF0YSI6e30sInNvdXJjZSI6eyJraW5kIjoicHJvZHVjdF9tYW51YWwiLCJyb290X2lkIjoicm9vdCIsInNlbWFudGljX2lkIjoicm9vdF9hc3NlbWJseSIsInNlbWFudGljX3R5cGUiOiJBc3NlbWJseSJ9fSx7ImFwcGVhcmFuY2VfaWQiOiJhcHBlYXJhbmNlL2V2YWx1YXRlZC84MWYzODllMmU4ZTI3MGM5ZTRmMDc0ZTQyMWU3YmU0YTBlMTAyNjE5NmY1ZmRlZDg3MzE5MWQ5ZjkxZGIwODUyIiwiZGVmaW5pdGlvbl9pZCI6ImRlZmluaXRpb24vcm9vdC9wYXJ0L3NoYXJlZF9wYXJ0IiwiZWRnZV9hc3NldF9pZCI6InNoYTI1Njo2Y2U0ODFkYjFmZTEzYmNmZTdhMjM3NDQyM2U2YzcyZDc1N2Q1MWM5OTE2OGIyODdjNTJmNTNhYTYyNWNjZDY3IiwiZW50aXR5X2Fzc2V0X2lkIjoic2hhMjU2OmVjNjVkYmYzZjU5NDQwM2IzOTY5YmUwM2ZiMDA0NzczN2RiNjQ1Y2MzYjQ3MzMwNGIxNDAzODEyYmVjNWMyY2IiLCJnZW9tZXRyeV9hc3NldF9pZCI6InNoYTI1NjoyMDA1MjM0MTRkZTI4MWE4YTE1NTA4NmE1NGNkMGY4ODBmNjQwNDg1NzY1YTQ4Y2NkZDhlYzIzMGY2N2RkMTdjIiwia2luZCI6InBhcnQiLCJuYW1lIjoiU2hhcmVkIFBhcnQiLCJzZGtfbWV0YWRhdGEiOnt9LCJzb3VyY2UiOnsia2luZCI6InByb2R1Y3RfbWFudWFsIiwicm9vdF9pZCI6InJvb3QiLCJzZW1hbnRpY19pZCI6InNoYXJlZF9wYXJ0Iiwic2VtYW50aWNfdHlwZSI6IlBhcnQifX1dLCJkaWFnbm9zdGljcyI6W10sImVkZ2VfYXNzZXRzIjpbeyJhc3NldF9pZCI6InNoYTI1Njo2Y2U0ODFkYjFmZTEzYmNmZTdhMjM3NDQyM2U2YzcyZDc1N2Q1MWM5OTE2OGIyODdjNTJmNTNhYTYyNWNjZDY3IiwiYXNzZXRfdG9fc2NlbmUiOlsxMDAwLDAsMCwwLDAsMCwtMTAwMCwwLDAsMTAwMCwwLDAsMCwwLDAsMV0sImJ5dGVfbGVuZ3RoIjo3NDAsImNvbnRlbnRfaGFzaCI6InNoYTI1Njo2Y2U0ODFkYjFmZTEzYmNmZTdhMjM3NDQyM2U2YzcyZDc1N2Q1MWM5OTE2OGIyODdjNTJmNTNhYTYyNWNjZDY3IiwibWVkaWFfdHlwZSI6Im1vZGVsL2dsdGYtYmluYXJ5Iiwic2NlbmVfbG9jYWxfYm91bmRzIjp7Im1heCI6WzEwMDAsMCwxMDAwXSwibWluIjpbMCwtMTAwMCwwXX0sInRlc3NlbGxhdGlvbiI6eyJsaW5lYXJfdG9sZXJhbmNlIjowLjM1fSwidXJpIjoiZWRnZXMvc2hhMjU2LTZjZTQ4MWRiMWZlMTNiY2ZlN2EyMzc0NDIzZTZjNzJkNzU3ZDUxYzk5MTY4YjI4N2M1MmY1M2FhNjI1Y2NkNjcuZ2xiIn1dLCJlbnRpdHlfYXNzZXRzIjpbeyJieXRlX2xlbmd0aCI6MTE1NDEsImNvbnRlbnRfaGFzaCI6InNoYTI1NjplYzY1ZGJmM2Y1OTQ0MDNiMzk2OWJlMDNmYjAwNDc3MzdkYjY0NWNjM2I0NzMzMDRiMTQwMzgxMmJlYzVjMmNiIiwiZW50aXR5X2Fzc2V0X2lkIjoic2hhMjU2OmVjNjVkYmYzZjU5NDQwM2IzOTY5YmUwM2ZiMDA0NzczN2RiNjQ1Y2MzYjQ3MzMwNGIxNDAzODEyYmVjNWMyY2IiLCJtZWRpYV90eXBlIjoiYXBwbGljYXRpb24vdm5kLnNpbXBsZWNhZC5lbnRpdGllcytqc29uIiwidXJpIjoiZW50aXRpZXMvc2hhMjU2LWVjNjVkYmYzZjU5NDQwM2IzOTY5YmUwM2ZiMDA0NzczN2RiNjQ1Y2MzYjQ3MzMwNGIxNDAzODEyYmVjNWMyY2IuanNvbiJ9XSwiZXh0ZW5zaW9ucyI6e30sImV4dGVuc2lvbnNfcmVxdWlyZWQiOltdLCJleHRlbnNpb25zX3VzZWQiOltdLCJnZW5lcmF0b3IiOnsibmFtZSI6InNpbXBsZWNhZGFwaSIsIm9jcF9iaW5kaW5nc192ZXJzaW9uIjoiZml4dHVyZSIsIm9jcF92ZXJzaW9uIjoiZml4dHVyZSIsInBsYXRmb3JtX3RhZyI6ImZpeHR1cmUiLCJwcm9maWxlIjoic2NlbmUtMS4wLW9jcC1nbGItMiIsInB5dGhvbl9hYmkiOiJmaXh0dXJlIiwic2ltcGxlY2FkYXBpX3ZlcnNpb24iOiJmaXh0dXJlIiwidG9vbGNoYWluX2hhc2giOiJzaGEyNTY6MDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMCJ9LCJnZW9tZXRyeV9hc3NldHMiOlt7ImFzc2V0X2lkIjoic2hhMjU2OjIwMDUyMzQxNGRlMjgxYThhMTU1MDg2YTU0Y2QwZjg4MGY2NDA0ODU3NjVhNDhjY2RkOGVjMjMwZjY3ZGQxN2MiLCJhc3NldF90b19zY2VuZSI6WzEwMDAsMCwwLDAsMCwwLC0xMDAwLDAsMCwxMDAwLDAsMCwwLDAsMCwxXSwiYnl0ZV9sZW5ndGgiOjEwMjAsImNvbnRlbnRfaGFzaCI6InNoYTI1NjoyMDA1MjM0MTRkZTI4MWE4YTE1NTA4NmE1NGNkMGY4ODBmNjQwNDg1NzY1YTQ4Y2NkZDhlYzIzMGY2N2RkMTdjIiwibWVkaWFfdHlwZSI6Im1vZGVsL2dsdGYtYmluYXJ5Iiwic2NlbmVfbG9jYWxfYm91bmRzIjp7Im1heCI6WzEwMDAsMCwxMDAwXSwibWluIjpbMCwtMTAwMCwwXX0sInRlc3NlbGxhdGlvbiI6eyJhbmd1bGFyX3RvbGVyYW5jZSI6MC4yMiwibGluZWFyX3RvbGVyYW5jZSI6MC4zNX0sInVyaSI6Imdlb21ldHJ5L3NoYTI1Ni0yMDA1MjM0MTRkZTI4MWE4YTE1NTA4NmE1NGNkMGY4ODBmNjQwNDg1NzY1YTQ4Y2NkZDhlYzIzMGY2N2RkMTdjLmdsYiJ9XSwibGlnaHRzIjpbXSwibm9kZXMiOlt7ImFwcGVhcmFuY2Vfb3ZlcnJpZGVfaWQiOm51bGwsImRlZmluaXRpb25faWQiOiJkZWZpbml0aW9uL3Jvb3QvYXNzZW1ibHkvcm9vdF9hc3NlbWJseSIsIm5hbWUiOiJyb290Iiwibm9kZV9pZCI6Imluc3RhbmNlL3Jvb3QiLCJvcmRlciI6MCwicGFyZW50X25vZGVfaWQiOm51bGwsInNka19tZXRhZGF0YSI6e30sInNlbGVjdGFibGUiOnRydWUsInNvdXJjZSI6eyJjb21wb25lbnRfcGF0aCI6W10sImtpbmQiOiJwcm9kdWN0X29jY3VycmVuY2UiLCJyb290X2lkIjoicm9vdCJ9LCJ0cmFuc2Zvcm0iOnsib3JpZ2luIjpbMCwwLDBdLCJ4X2F4aXMiOlsxLDAsMF0sInlfYXhpcyI6WzAsMSwwXSwiel9heGlzIjpbMCwwLDFdfSwidmlzaWJsZSI6dHJ1ZX0seyJhcHBlYXJhbmNlX292ZXJyaWRlX2lkIjpudWxsLCJkZWZpbml0aW9uX2lkIjoiZGVmaW5pdGlvbi9yb290L2Fzc2VtYmx5L25lc3RlZF9hc3NlbWJseSIsIm5hbWUiOiJzdWJhc3NlbWJseSIsIm5vZGVfaWQiOiJpbnN0YW5jZS9yb290L3N1YmFzc2VtYmx5Iiwib3JkZXIiOjAsInBhcmVudF9ub2RlX2lkIjoiaW5zdGFuY2Uvcm9vdCIsInNka19tZXRhZGF0YSI6e30sInNlbGVjdGFibGUiOnRydWUsInNvdXJjZSI6eyJjb21wb25lbnRfcGF0aCI6WyJzdWJhc3NlbWJseSJdLCJraW5kIjoicHJvZHVjdF9vY2N1cnJlbmNlIiwicm9vdF9pZCI6InJvb3QifSwidHJhbnNmb3JtIjp7Im9yaWdpbiI6WzAsMCwwXSwieF9heGlzIjpbMSwwLDBdLCJ5X2F4aXMiOlswLDEsMF0sInpfYXhpcyI6WzAsMCwxXX0sInZpc2libGUiOnRydWV9LHsiYXBwZWFyYW5jZV9vdmVycmlkZV9pZCI6bnVsbCwiZGVmaW5pdGlvbl9pZCI6ImRlZmluaXRpb24vcm9vdC9wYXJ0L3NoYXJlZF9wYXJ0IiwibmFtZSI6InBhcnQiLCJub2RlX2lkIjoiaW5zdGFuY2Uvcm9vdC9zdWJhc3NlbWJseS9wYXJ0Iiwib3JkZXIiOjAsInBhcmVudF9ub2RlX2lkIjoiaW5zdGFuY2Uvcm9vdC9zdWJhc3NlbWJseSIsInNka19tZXRhZGF0YSI6e30sInNlbGVjdGFibGUiOnRydWUsInNvdXJjZSI6eyJjb21wb25lbnRfcGF0aCI6WyJzdWJhc3NlbWJseSIsInBhcnQiXSwia2luZCI6InByb2R1Y3Rfb2NjdXJyZW5jZSIsInJvb3RfaWQiOiJyb290In0sInRyYW5zZm9ybSI6eyJvcmlnaW4iOlswLDAsMF0sInhfYXhpcyI6WzEsMCwwXSwieV9heGlzIjpbMCwxLDBdLCJ6X2F4aXMiOlswLDAsMV19LCJ2aXNpYmxlIjp0cnVlfV0sInJldmlzaW9uIjoic2hhMjU2OmU1ZmFjZTQ0MTQyZDQxNzdiZjFmZGNiYzA4ZDJiZjE1OGQwMWI5ZDBkYjk3OWViNDMzNThkMzEwMjJhZjUyOWIiLCJzY2VuZV9pZCI6Im5lc3RlZF9maXh0dXJlIiwic2NoZW1hX3ZlcnNpb24iOiIxLjAiLCJzb3VyY2UiOnsia2luZCI6Im1hbnVhbCIsInNvdXJjZV9pZCI6ImZpeHR1cmUifX0=", + "name": "forwarded_transform_mismatch", + "valid": false + }, + { + "blob_uris": [ + "edges/sha256-6ce481db1fe13bcfe7a2374423e6c72d757d51c99168b287c52f53aa625ccd67.glb", + "entities/sha256-ec65dbf3f594403b3969be03fb0047737db645cc3b473304b1403812bec5c2cb.json", + "geometry/sha256-200523414de281a8a155086a54cd0f880f640485765a48ccdd8ec230f67dd17c.glb" + ], + "expected": { + "code": "connector_invalid", + "path": "/connectors/1/forwarded_from" + }, + "manifest_base64": "eyJhbm5vdGF0aW9ucyI6W10sImFwcGVhcmFuY2VzIjpbeyJhbHBoYV9tb2RlIjoib3BhcXVlIiwiYXBwZWFyYW5jZV9pZCI6ImFwcGVhcmFuY2UvZXZhbHVhdGVkLzgxZjM4OWUyZThlMjcwYzllNGYwNzRlNDIxZTdiZTRhMGUxMDI2MTk2ZjVmZGVkODczMTkxZDlmOTFkYjA4NTIiLCJiYXNlX2NvbG9yIjpbMC43MiwwLjc1LDAuNzgsMV0sImRvdWJsZV9zaWRlZCI6ZmFsc2UsImVkZ2VfY29sb3IiOlswLjA4LDAuMDksMC4xLDFdLCJtZXRhbGxpYyI6MCwibmFtZSI6IkZpeHR1cmUgbWF0ZXJpYWwiLCJyb3VnaG5lc3MiOjAuNTUsInNka19tZXRhZGF0YSI6e30sInNvdXJjZSI6eyJraW5kIjoicHJvZHVjdF9tYXRlcmlhbCIsIm1hdGVyaWFsX2lkIjoiZml4dHVyZV9tYXRlcmlhbCIsInJvb3RfaWQiOiJyb290In19XSwiY2FtZXJhcyI6W10sImNvbXBpbGVfb3B0aW9ucyI6eyJhbmd1bGFyX3RvbGVyYW5jZSI6MC4yMiwiZW1iZWRfcHJlc2VudGF0aW9uIjpmYWxzZSwiZW1iZWRfc291cmNlIjpmYWxzZSwibGluZWFyX3RvbGVyYW5jZSI6MC4zNX0sImNvbm5lY3RvcnMiOlt7ImFuY2hvcl9raW5kIjoiZm9yd2FyZGVkIiwiY29ubmVjdG9yX2lkIjoibmVzdGVkX21vdW50IiwiY29ubmVjdG9yX3NuYXBzaG90X2lkIjoiY29ubmVjdG9yL3Jvb3QvYXNzZW1ibHkvbmVzdGVkX2Fzc2VtYmx5L25lc3RlZF9tb3VudCIsImZvcndhcmRlZF9mcm9tIjp7Im9mZnNldCI6bnVsbCwic291cmNlX2NvbXBvbmVudF9pZCI6InBhcnQiLCJzb3VyY2VfY29ubmVjdG9yX2lkIjoibW91bnQiLCJzb3VyY2VfY29ubmVjdG9yX3NuYXBzaG90X2lkIjoiY29ubmVjdG9yL3Jvb3QvcGFydC9zaGFyZWRfcGFydC9tb3VudCIsInNvdXJjZV9kZWZpbml0aW9uX2lkIjoiZGVmaW5pdGlvbi9yb290L3BhcnQvc2hhcmVkX3BhcnQifSwibG9jYWxfdHJhbnNmb3JtIjp7Im9yaWdpbiI6WzAsMCwwXSwieF9heGlzIjpbMSwwLDBdLCJ5X2F4aXMiOlswLDEsMF0sInpfYXhpcyI6WzAsMCwxXX0sIm5hbWUiOiJOZXN0ZWQgTW91bnQiLCJvd25lcl9kZWZpbml0aW9uX2lkIjoiZGVmaW5pdGlvbi9yb290L2Fzc2VtYmx5L25lc3RlZF9hc3NlbWJseSIsInNka19tZXRhZGF0YSI6e30sInNvdXJjZSI6eyJraW5kIjoibWFudWFsIiwic291cmNlX2lkIjoiZml4dHVyZSJ9fSx7ImFuY2hvcl9raW5kIjoiZm9yd2FyZGVkIiwiY29ubmVjdG9yX2lkIjoicm9vdF9tb3VudCIsImNvbm5lY3Rvcl9zbmFwc2hvdF9pZCI6ImNvbm5lY3Rvci9yb290L2Fzc2VtYmx5L3Jvb3RfYXNzZW1ibHkvcm9vdF9tb3VudCIsImZvcndhcmRlZF9mcm9tIjp7Im9mZnNldCI6eyJvcmlnaW4iOlswLDAsNV0sInhfYXhpcyI6WzEsMCwwXSwieV9heGlzIjpbMCwxLDBdLCJ6X2F4aXMiOlswLDAsMV19LCJzb3VyY2VfY29tcG9uZW50X2lkIjoibWlzc2luZyIsInNvdXJjZV9jb25uZWN0b3JfaWQiOiJuZXN0ZWRfbW91bnQiLCJzb3VyY2VfY29ubmVjdG9yX3NuYXBzaG90X2lkIjoiY29ubmVjdG9yL3Jvb3QvYXNzZW1ibHkvbmVzdGVkX2Fzc2VtYmx5L25lc3RlZF9tb3VudCIsInNvdXJjZV9kZWZpbml0aW9uX2lkIjoiZGVmaW5pdGlvbi9yb290L2Fzc2VtYmx5L25lc3RlZF9hc3NlbWJseSJ9LCJsb2NhbF90cmFuc2Zvcm0iOnsib3JpZ2luIjpbMCwwLDVdLCJ4X2F4aXMiOlsxLDAsMF0sInlfYXhpcyI6WzAsMSwwXSwiel9heGlzIjpbMCwwLDFdfSwibmFtZSI6IlJvb3QgTW91bnQiLCJvd25lcl9kZWZpbml0aW9uX2lkIjoiZGVmaW5pdGlvbi9yb290L2Fzc2VtYmx5L3Jvb3RfYXNzZW1ibHkiLCJzZGtfbWV0YWRhdGEiOnt9LCJzb3VyY2UiOnsia2luZCI6Im1hbnVhbCIsInNvdXJjZV9pZCI6ImZpeHR1cmUifX0seyJhbmNob3Jfa2luZCI6InBsYWNlbWVudCIsImNvbm5lY3Rvcl9pZCI6Im1vdW50IiwiY29ubmVjdG9yX3NuYXBzaG90X2lkIjoiY29ubmVjdG9yL3Jvb3QvcGFydC9zaGFyZWRfcGFydC9tb3VudCIsImxvY2FsX3RyYW5zZm9ybSI6eyJvcmlnaW4iOlswLDAsMF0sInhfYXhpcyI6WzEsMCwwXSwieV9heGlzIjpbMCwxLDBdLCJ6X2F4aXMiOlswLDAsMV19LCJuYW1lIjoiTW91bnQiLCJvd25lcl9kZWZpbml0aW9uX2lkIjoiZGVmaW5pdGlvbi9yb290L3BhcnQvc2hhcmVkX3BhcnQiLCJzZGtfbWV0YWRhdGEiOnt9LCJzb3VyY2UiOnsia2luZCI6Im1hbnVhbCIsInNvdXJjZV9pZCI6ImZpeHR1cmUifX1dLCJjb29yZGluYXRlX3N5c3RlbSI6eyJoYW5kZWRuZXNzIjoicmlnaHQiLCJsZW5ndGhfdW5pdCI6Im1tIiwidXBfYXhpcyI6IitaIn0sImRlZmluaXRpb25zIjpbeyJkZWZpbml0aW9uX2lkIjoiZGVmaW5pdGlvbi9yb290L2Fzc2VtYmx5L25lc3RlZF9hc3NlbWJseSIsImtpbmQiOiJhc3NlbWJseSIsIm5hbWUiOiJOZXN0ZWQgQXNzZW1ibHkiLCJzZGtfbWV0YWRhdGEiOnt9LCJzb3VyY2UiOnsia2luZCI6InByb2R1Y3RfbWFudWFsIiwicm9vdF9pZCI6InJvb3QiLCJzZW1hbnRpY19pZCI6Im5lc3RlZF9hc3NlbWJseSIsInNlbWFudGljX3R5cGUiOiJBc3NlbWJseSJ9fSx7ImRlZmluaXRpb25faWQiOiJkZWZpbml0aW9uL3Jvb3QvYXNzZW1ibHkvcm9vdF9hc3NlbWJseSIsImtpbmQiOiJhc3NlbWJseSIsIm5hbWUiOiJSb290IEFzc2VtYmx5Iiwic2RrX21ldGFkYXRhIjp7fSwic291cmNlIjp7ImtpbmQiOiJwcm9kdWN0X21hbnVhbCIsInJvb3RfaWQiOiJyb290Iiwic2VtYW50aWNfaWQiOiJyb290X2Fzc2VtYmx5Iiwic2VtYW50aWNfdHlwZSI6IkFzc2VtYmx5In19LHsiYXBwZWFyYW5jZV9pZCI6ImFwcGVhcmFuY2UvZXZhbHVhdGVkLzgxZjM4OWUyZThlMjcwYzllNGYwNzRlNDIxZTdiZTRhMGUxMDI2MTk2ZjVmZGVkODczMTkxZDlmOTFkYjA4NTIiLCJkZWZpbml0aW9uX2lkIjoiZGVmaW5pdGlvbi9yb290L3BhcnQvc2hhcmVkX3BhcnQiLCJlZGdlX2Fzc2V0X2lkIjoic2hhMjU2OjZjZTQ4MWRiMWZlMTNiY2ZlN2EyMzc0NDIzZTZjNzJkNzU3ZDUxYzk5MTY4YjI4N2M1MmY1M2FhNjI1Y2NkNjciLCJlbnRpdHlfYXNzZXRfaWQiOiJzaGEyNTY6ZWM2NWRiZjNmNTk0NDAzYjM5NjliZTAzZmIwMDQ3NzM3ZGI2NDVjYzNiNDczMzA0YjE0MDM4MTJiZWM1YzJjYiIsImdlb21ldHJ5X2Fzc2V0X2lkIjoic2hhMjU2OjIwMDUyMzQxNGRlMjgxYThhMTU1MDg2YTU0Y2QwZjg4MGY2NDA0ODU3NjVhNDhjY2RkOGVjMjMwZjY3ZGQxN2MiLCJraW5kIjoicGFydCIsIm5hbWUiOiJTaGFyZWQgUGFydCIsInNka19tZXRhZGF0YSI6e30sInNvdXJjZSI6eyJraW5kIjoicHJvZHVjdF9tYW51YWwiLCJyb290X2lkIjoicm9vdCIsInNlbWFudGljX2lkIjoic2hhcmVkX3BhcnQiLCJzZW1hbnRpY190eXBlIjoiUGFydCJ9fV0sImRpYWdub3N0aWNzIjpbXSwiZWRnZV9hc3NldHMiOlt7ImFzc2V0X2lkIjoic2hhMjU2OjZjZTQ4MWRiMWZlMTNiY2ZlN2EyMzc0NDIzZTZjNzJkNzU3ZDUxYzk5MTY4YjI4N2M1MmY1M2FhNjI1Y2NkNjciLCJhc3NldF90b19zY2VuZSI6WzEwMDAsMCwwLDAsMCwwLC0xMDAwLDAsMCwxMDAwLDAsMCwwLDAsMCwxXSwiYnl0ZV9sZW5ndGgiOjc0MCwiY29udGVudF9oYXNoIjoic2hhMjU2OjZjZTQ4MWRiMWZlMTNiY2ZlN2EyMzc0NDIzZTZjNzJkNzU3ZDUxYzk5MTY4YjI4N2M1MmY1M2FhNjI1Y2NkNjciLCJtZWRpYV90eXBlIjoibW9kZWwvZ2x0Zi1iaW5hcnkiLCJzY2VuZV9sb2NhbF9ib3VuZHMiOnsibWF4IjpbMTAwMCwwLDEwMDBdLCJtaW4iOlswLC0xMDAwLDBdfSwidGVzc2VsbGF0aW9uIjp7ImxpbmVhcl90b2xlcmFuY2UiOjAuMzV9LCJ1cmkiOiJlZGdlcy9zaGEyNTYtNmNlNDgxZGIxZmUxM2JjZmU3YTIzNzQ0MjNlNmM3MmQ3NTdkNTFjOTkxNjhiMjg3YzUyZjUzYWE2MjVjY2Q2Ny5nbGIifV0sImVudGl0eV9hc3NldHMiOlt7ImJ5dGVfbGVuZ3RoIjoxMTU0MSwiY29udGVudF9oYXNoIjoic2hhMjU2OmVjNjVkYmYzZjU5NDQwM2IzOTY5YmUwM2ZiMDA0NzczN2RiNjQ1Y2MzYjQ3MzMwNGIxNDAzODEyYmVjNWMyY2IiLCJlbnRpdHlfYXNzZXRfaWQiOiJzaGEyNTY6ZWM2NWRiZjNmNTk0NDAzYjM5NjliZTAzZmIwMDQ3NzM3ZGI2NDVjYzNiNDczMzA0YjE0MDM4MTJiZWM1YzJjYiIsIm1lZGlhX3R5cGUiOiJhcHBsaWNhdGlvbi92bmQuc2ltcGxlY2FkLmVudGl0aWVzK2pzb24iLCJ1cmkiOiJlbnRpdGllcy9zaGEyNTYtZWM2NWRiZjNmNTk0NDAzYjM5NjliZTAzZmIwMDQ3NzM3ZGI2NDVjYzNiNDczMzA0YjE0MDM4MTJiZWM1YzJjYi5qc29uIn1dLCJleHRlbnNpb25zIjp7fSwiZXh0ZW5zaW9uc19yZXF1aXJlZCI6W10sImV4dGVuc2lvbnNfdXNlZCI6W10sImdlbmVyYXRvciI6eyJuYW1lIjoic2ltcGxlY2FkYXBpIiwib2NwX2JpbmRpbmdzX3ZlcnNpb24iOiJmaXh0dXJlIiwib2NwX3ZlcnNpb24iOiJmaXh0dXJlIiwicGxhdGZvcm1fdGFnIjoiZml4dHVyZSIsInByb2ZpbGUiOiJzY2VuZS0xLjAtb2NwLWdsYi0yIiwicHl0aG9uX2FiaSI6ImZpeHR1cmUiLCJzaW1wbGVjYWRhcGlfdmVyc2lvbiI6ImZpeHR1cmUiLCJ0b29sY2hhaW5faGFzaCI6InNoYTI1NjowMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwIn0sImdlb21ldHJ5X2Fzc2V0cyI6W3siYXNzZXRfaWQiOiJzaGEyNTY6MjAwNTIzNDE0ZGUyODFhOGExNTUwODZhNTRjZDBmODgwZjY0MDQ4NTc2NWE0OGNjZGQ4ZWMyMzBmNjdkZDE3YyIsImFzc2V0X3RvX3NjZW5lIjpbMTAwMCwwLDAsMCwwLDAsLTEwMDAsMCwwLDEwMDAsMCwwLDAsMCwwLDFdLCJieXRlX2xlbmd0aCI6MTAyMCwiY29udGVudF9oYXNoIjoic2hhMjU2OjIwMDUyMzQxNGRlMjgxYThhMTU1MDg2YTU0Y2QwZjg4MGY2NDA0ODU3NjVhNDhjY2RkOGVjMjMwZjY3ZGQxN2MiLCJtZWRpYV90eXBlIjoibW9kZWwvZ2x0Zi1iaW5hcnkiLCJzY2VuZV9sb2NhbF9ib3VuZHMiOnsibWF4IjpbMTAwMCwwLDEwMDBdLCJtaW4iOlswLC0xMDAwLDBdfSwidGVzc2VsbGF0aW9uIjp7ImFuZ3VsYXJfdG9sZXJhbmNlIjowLjIyLCJsaW5lYXJfdG9sZXJhbmNlIjowLjM1fSwidXJpIjoiZ2VvbWV0cnkvc2hhMjU2LTIwMDUyMzQxNGRlMjgxYThhMTU1MDg2YTU0Y2QwZjg4MGY2NDA0ODU3NjVhNDhjY2RkOGVjMjMwZjY3ZGQxN2MuZ2xiIn1dLCJsaWdodHMiOltdLCJub2RlcyI6W3siYXBwZWFyYW5jZV9vdmVycmlkZV9pZCI6bnVsbCwiZGVmaW5pdGlvbl9pZCI6ImRlZmluaXRpb24vcm9vdC9hc3NlbWJseS9yb290X2Fzc2VtYmx5IiwibmFtZSI6InJvb3QiLCJub2RlX2lkIjoiaW5zdGFuY2Uvcm9vdCIsIm9yZGVyIjowLCJwYXJlbnRfbm9kZV9pZCI6bnVsbCwic2RrX21ldGFkYXRhIjp7fSwic2VsZWN0YWJsZSI6dHJ1ZSwic291cmNlIjp7ImNvbXBvbmVudF9wYXRoIjpbXSwia2luZCI6InByb2R1Y3Rfb2NjdXJyZW5jZSIsInJvb3RfaWQiOiJyb290In0sInRyYW5zZm9ybSI6eyJvcmlnaW4iOlswLDAsMF0sInhfYXhpcyI6WzEsMCwwXSwieV9heGlzIjpbMCwxLDBdLCJ6X2F4aXMiOlswLDAsMV19LCJ2aXNpYmxlIjp0cnVlfSx7ImFwcGVhcmFuY2Vfb3ZlcnJpZGVfaWQiOm51bGwsImRlZmluaXRpb25faWQiOiJkZWZpbml0aW9uL3Jvb3QvYXNzZW1ibHkvbmVzdGVkX2Fzc2VtYmx5IiwibmFtZSI6InN1YmFzc2VtYmx5Iiwibm9kZV9pZCI6Imluc3RhbmNlL3Jvb3Qvc3ViYXNzZW1ibHkiLCJvcmRlciI6MCwicGFyZW50X25vZGVfaWQiOiJpbnN0YW5jZS9yb290Iiwic2RrX21ldGFkYXRhIjp7fSwic2VsZWN0YWJsZSI6dHJ1ZSwic291cmNlIjp7ImNvbXBvbmVudF9wYXRoIjpbInN1YmFzc2VtYmx5Il0sImtpbmQiOiJwcm9kdWN0X29jY3VycmVuY2UiLCJyb290X2lkIjoicm9vdCJ9LCJ0cmFuc2Zvcm0iOnsib3JpZ2luIjpbMCwwLDBdLCJ4X2F4aXMiOlsxLDAsMF0sInlfYXhpcyI6WzAsMSwwXSwiel9heGlzIjpbMCwwLDFdfSwidmlzaWJsZSI6dHJ1ZX0seyJhcHBlYXJhbmNlX292ZXJyaWRlX2lkIjpudWxsLCJkZWZpbml0aW9uX2lkIjoiZGVmaW5pdGlvbi9yb290L3BhcnQvc2hhcmVkX3BhcnQiLCJuYW1lIjoicGFydCIsIm5vZGVfaWQiOiJpbnN0YW5jZS9yb290L3N1YmFzc2VtYmx5L3BhcnQiLCJvcmRlciI6MCwicGFyZW50X25vZGVfaWQiOiJpbnN0YW5jZS9yb290L3N1YmFzc2VtYmx5Iiwic2RrX21ldGFkYXRhIjp7fSwic2VsZWN0YWJsZSI6dHJ1ZSwic291cmNlIjp7ImNvbXBvbmVudF9wYXRoIjpbInN1YmFzc2VtYmx5IiwicGFydCJdLCJraW5kIjoicHJvZHVjdF9vY2N1cnJlbmNlIiwicm9vdF9pZCI6InJvb3QifSwidHJhbnNmb3JtIjp7Im9yaWdpbiI6WzAsMCwwXSwieF9heGlzIjpbMSwwLDBdLCJ5X2F4aXMiOlswLDEsMF0sInpfYXhpcyI6WzAsMCwxXX0sInZpc2libGUiOnRydWV9XSwicmV2aXNpb24iOiJzaGEyNTY6NzNjZWQ5OTU5MzYxZDI4M2VjNGMxZDJiNzJlYjRiNjM1YjkwYzFmOTFhZjBmOTg3MjFlMzVmMDkyYmM5NjEzMCIsInNjZW5lX2lkIjoibmVzdGVkX2ZpeHR1cmUiLCJzY2hlbWFfdmVyc2lvbiI6IjEuMCIsInNvdXJjZSI6eyJraW5kIjoibWFudWFsIiwic291cmNlX2lkIjoiZml4dHVyZSJ9fQ==", + "name": "forwarded_direct_child_missing", + "valid": false + }, + { + "blob_uris": [ + "edges/sha256-6ce481db1fe13bcfe7a2374423e6c72d757d51c99168b287c52f53aa625ccd67.glb", + "entities/sha256-ec65dbf3f594403b3969be03fb0047737db645cc3b473304b1403812bec5c2cb.json", + "geometry/sha256-200523414de281a8a155086a54cd0f880f640485765a48ccdd8ec230f67dd17c.glb" + ], + "expected": { + "code": "connector_invalid", + "path": "/connectors/2/owner_definition_id" + }, + "manifest_base64": "eyJhbm5vdGF0aW9ucyI6W10sImFwcGVhcmFuY2VzIjpbeyJhbHBoYV9tb2RlIjoib3BhcXVlIiwiYXBwZWFyYW5jZV9pZCI6ImFwcGVhcmFuY2UvZXZhbHVhdGVkLzgxZjM4OWUyZThlMjcwYzllNGYwNzRlNDIxZTdiZTRhMGUxMDI2MTk2ZjVmZGVkODczMTkxZDlmOTFkYjA4NTIiLCJiYXNlX2NvbG9yIjpbMC43MiwwLjc1LDAuNzgsMV0sImRvdWJsZV9zaWRlZCI6ZmFsc2UsImVkZ2VfY29sb3IiOlswLjA4LDAuMDksMC4xLDFdLCJtZXRhbGxpYyI6MCwibmFtZSI6IkZpeHR1cmUgbWF0ZXJpYWwiLCJyb3VnaG5lc3MiOjAuNTUsInNka19tZXRhZGF0YSI6e30sInNvdXJjZSI6eyJraW5kIjoicHJvZHVjdF9tYXRlcmlhbCIsIm1hdGVyaWFsX2lkIjoiZml4dHVyZV9tYXRlcmlhbCIsInJvb3RfaWQiOiJyb290In19XSwiY2FtZXJhcyI6W10sImNvbXBpbGVfb3B0aW9ucyI6eyJhbmd1bGFyX3RvbGVyYW5jZSI6MC4yMiwiZW1iZWRfcHJlc2VudGF0aW9uIjpmYWxzZSwiZW1iZWRfc291cmNlIjpmYWxzZSwibGluZWFyX3RvbGVyYW5jZSI6MC4zNX0sImNvbm5lY3RvcnMiOlt7ImFuY2hvcl9raW5kIjoiZm9yd2FyZGVkIiwiY29ubmVjdG9yX2lkIjoibmVzdGVkX21vdW50IiwiY29ubmVjdG9yX3NuYXBzaG90X2lkIjoiY29ubmVjdG9yL3Jvb3QvYXNzZW1ibHkvbmVzdGVkX2Fzc2VtYmx5L25lc3RlZF9tb3VudCIsImZvcndhcmRlZF9mcm9tIjp7Im9mZnNldCI6bnVsbCwic291cmNlX2NvbXBvbmVudF9pZCI6InBhcnQiLCJzb3VyY2VfY29ubmVjdG9yX2lkIjoibW91bnQiLCJzb3VyY2VfY29ubmVjdG9yX3NuYXBzaG90X2lkIjoiY29ubmVjdG9yL3Jvb3QvcGFydC9zaGFyZWRfcGFydC9tb3VudCIsInNvdXJjZV9kZWZpbml0aW9uX2lkIjoiZGVmaW5pdGlvbi9yb290L3BhcnQvc2hhcmVkX3BhcnQifSwibG9jYWxfdHJhbnNmb3JtIjp7Im9yaWdpbiI6WzAsMCwwXSwieF9heGlzIjpbMSwwLDBdLCJ5X2F4aXMiOlswLDEsMF0sInpfYXhpcyI6WzAsMCwxXX0sIm5hbWUiOiJOZXN0ZWQgTW91bnQiLCJvd25lcl9kZWZpbml0aW9uX2lkIjoiZGVmaW5pdGlvbi9yb290L2Fzc2VtYmx5L25lc3RlZF9hc3NlbWJseSIsInNka19tZXRhZGF0YSI6e30sInNvdXJjZSI6eyJraW5kIjoibWFudWFsIiwic291cmNlX2lkIjoiZml4dHVyZSJ9fSx7ImFuY2hvcl9raW5kIjoicGxhY2VtZW50IiwiY29ubmVjdG9yX2lkIjoibW91bnQiLCJjb25uZWN0b3Jfc25hcHNob3RfaWQiOiJjb25uZWN0b3Ivcm9vdC9wYXJ0L3NoYXJlZF9wYXJ0L21vdW50IiwibG9jYWxfdHJhbnNmb3JtIjp7Im9yaWdpbiI6WzAsMCwwXSwieF9heGlzIjpbMSwwLDBdLCJ5X2F4aXMiOlswLDEsMF0sInpfYXhpcyI6WzAsMCwxXX0sIm5hbWUiOiJNb3VudCIsIm93bmVyX2RlZmluaXRpb25faWQiOiJkZWZpbml0aW9uL3Jvb3QvcGFydC9zaGFyZWRfcGFydCIsInNka19tZXRhZGF0YSI6e30sInNvdXJjZSI6eyJraW5kIjoibWFudWFsIiwic291cmNlX2lkIjoiZml4dHVyZSJ9fSx7ImFuY2hvcl9raW5kIjoiZm9yd2FyZGVkIiwiY29ubmVjdG9yX2lkIjoicm9vdF9tb3VudCIsImNvbm5lY3Rvcl9zbmFwc2hvdF9pZCI6ImNvbm5lY3Rvci9yb290L3BhcnQvc2hhcmVkX3BhcnQvcm9vdF9tb3VudCIsImZvcndhcmRlZF9mcm9tIjp7Im9mZnNldCI6eyJvcmlnaW4iOlswLDAsNV0sInhfYXhpcyI6WzEsMCwwXSwieV9heGlzIjpbMCwxLDBdLCJ6X2F4aXMiOlswLDAsMV19LCJzb3VyY2VfY29tcG9uZW50X2lkIjoic3ViYXNzZW1ibHkiLCJzb3VyY2VfY29ubmVjdG9yX2lkIjoibmVzdGVkX21vdW50Iiwic291cmNlX2Nvbm5lY3Rvcl9zbmFwc2hvdF9pZCI6ImNvbm5lY3Rvci9yb290L2Fzc2VtYmx5L25lc3RlZF9hc3NlbWJseS9uZXN0ZWRfbW91bnQiLCJzb3VyY2VfZGVmaW5pdGlvbl9pZCI6ImRlZmluaXRpb24vcm9vdC9hc3NlbWJseS9uZXN0ZWRfYXNzZW1ibHkifSwibG9jYWxfdHJhbnNmb3JtIjp7Im9yaWdpbiI6WzAsMCw1XSwieF9heGlzIjpbMSwwLDBdLCJ5X2F4aXMiOlswLDEsMF0sInpfYXhpcyI6WzAsMCwxXX0sIm5hbWUiOiJSb290IE1vdW50Iiwib3duZXJfZGVmaW5pdGlvbl9pZCI6ImRlZmluaXRpb24vcm9vdC9wYXJ0L3NoYXJlZF9wYXJ0Iiwic2RrX21ldGFkYXRhIjp7fSwic291cmNlIjp7ImtpbmQiOiJtYW51YWwiLCJzb3VyY2VfaWQiOiJmaXh0dXJlIn19XSwiY29vcmRpbmF0ZV9zeXN0ZW0iOnsiaGFuZGVkbmVzcyI6InJpZ2h0IiwibGVuZ3RoX3VuaXQiOiJtbSIsInVwX2F4aXMiOiIrWiJ9LCJkZWZpbml0aW9ucyI6W3siZGVmaW5pdGlvbl9pZCI6ImRlZmluaXRpb24vcm9vdC9hc3NlbWJseS9uZXN0ZWRfYXNzZW1ibHkiLCJraW5kIjoiYXNzZW1ibHkiLCJuYW1lIjoiTmVzdGVkIEFzc2VtYmx5Iiwic2RrX21ldGFkYXRhIjp7fSwic291cmNlIjp7ImtpbmQiOiJwcm9kdWN0X21hbnVhbCIsInJvb3RfaWQiOiJyb290Iiwic2VtYW50aWNfaWQiOiJuZXN0ZWRfYXNzZW1ibHkiLCJzZW1hbnRpY190eXBlIjoiQXNzZW1ibHkifX0seyJkZWZpbml0aW9uX2lkIjoiZGVmaW5pdGlvbi9yb290L2Fzc2VtYmx5L3Jvb3RfYXNzZW1ibHkiLCJraW5kIjoiYXNzZW1ibHkiLCJuYW1lIjoiUm9vdCBBc3NlbWJseSIsInNka19tZXRhZGF0YSI6e30sInNvdXJjZSI6eyJraW5kIjoicHJvZHVjdF9tYW51YWwiLCJyb290X2lkIjoicm9vdCIsInNlbWFudGljX2lkIjoicm9vdF9hc3NlbWJseSIsInNlbWFudGljX3R5cGUiOiJBc3NlbWJseSJ9fSx7ImFwcGVhcmFuY2VfaWQiOiJhcHBlYXJhbmNlL2V2YWx1YXRlZC84MWYzODllMmU4ZTI3MGM5ZTRmMDc0ZTQyMWU3YmU0YTBlMTAyNjE5NmY1ZmRlZDg3MzE5MWQ5ZjkxZGIwODUyIiwiZGVmaW5pdGlvbl9pZCI6ImRlZmluaXRpb24vcm9vdC9wYXJ0L3NoYXJlZF9wYXJ0IiwiZWRnZV9hc3NldF9pZCI6InNoYTI1Njo2Y2U0ODFkYjFmZTEzYmNmZTdhMjM3NDQyM2U2YzcyZDc1N2Q1MWM5OTE2OGIyODdjNTJmNTNhYTYyNWNjZDY3IiwiZW50aXR5X2Fzc2V0X2lkIjoic2hhMjU2OmVjNjVkYmYzZjU5NDQwM2IzOTY5YmUwM2ZiMDA0NzczN2RiNjQ1Y2MzYjQ3MzMwNGIxNDAzODEyYmVjNWMyY2IiLCJnZW9tZXRyeV9hc3NldF9pZCI6InNoYTI1NjoyMDA1MjM0MTRkZTI4MWE4YTE1NTA4NmE1NGNkMGY4ODBmNjQwNDg1NzY1YTQ4Y2NkZDhlYzIzMGY2N2RkMTdjIiwia2luZCI6InBhcnQiLCJuYW1lIjoiU2hhcmVkIFBhcnQiLCJzZGtfbWV0YWRhdGEiOnt9LCJzb3VyY2UiOnsia2luZCI6InByb2R1Y3RfbWFudWFsIiwicm9vdF9pZCI6InJvb3QiLCJzZW1hbnRpY19pZCI6InNoYXJlZF9wYXJ0Iiwic2VtYW50aWNfdHlwZSI6IlBhcnQifX1dLCJkaWFnbm9zdGljcyI6W10sImVkZ2VfYXNzZXRzIjpbeyJhc3NldF9pZCI6InNoYTI1Njo2Y2U0ODFkYjFmZTEzYmNmZTdhMjM3NDQyM2U2YzcyZDc1N2Q1MWM5OTE2OGIyODdjNTJmNTNhYTYyNWNjZDY3IiwiYXNzZXRfdG9fc2NlbmUiOlsxMDAwLDAsMCwwLDAsMCwtMTAwMCwwLDAsMTAwMCwwLDAsMCwwLDAsMV0sImJ5dGVfbGVuZ3RoIjo3NDAsImNvbnRlbnRfaGFzaCI6InNoYTI1Njo2Y2U0ODFkYjFmZTEzYmNmZTdhMjM3NDQyM2U2YzcyZDc1N2Q1MWM5OTE2OGIyODdjNTJmNTNhYTYyNWNjZDY3IiwibWVkaWFfdHlwZSI6Im1vZGVsL2dsdGYtYmluYXJ5Iiwic2NlbmVfbG9jYWxfYm91bmRzIjp7Im1heCI6WzEwMDAsMCwxMDAwXSwibWluIjpbMCwtMTAwMCwwXX0sInRlc3NlbGxhdGlvbiI6eyJsaW5lYXJfdG9sZXJhbmNlIjowLjM1fSwidXJpIjoiZWRnZXMvc2hhMjU2LTZjZTQ4MWRiMWZlMTNiY2ZlN2EyMzc0NDIzZTZjNzJkNzU3ZDUxYzk5MTY4YjI4N2M1MmY1M2FhNjI1Y2NkNjcuZ2xiIn1dLCJlbnRpdHlfYXNzZXRzIjpbeyJieXRlX2xlbmd0aCI6MTE1NDEsImNvbnRlbnRfaGFzaCI6InNoYTI1NjplYzY1ZGJmM2Y1OTQ0MDNiMzk2OWJlMDNmYjAwNDc3MzdkYjY0NWNjM2I0NzMzMDRiMTQwMzgxMmJlYzVjMmNiIiwiZW50aXR5X2Fzc2V0X2lkIjoic2hhMjU2OmVjNjVkYmYzZjU5NDQwM2IzOTY5YmUwM2ZiMDA0NzczN2RiNjQ1Y2MzYjQ3MzMwNGIxNDAzODEyYmVjNWMyY2IiLCJtZWRpYV90eXBlIjoiYXBwbGljYXRpb24vdm5kLnNpbXBsZWNhZC5lbnRpdGllcytqc29uIiwidXJpIjoiZW50aXRpZXMvc2hhMjU2LWVjNjVkYmYzZjU5NDQwM2IzOTY5YmUwM2ZiMDA0NzczN2RiNjQ1Y2MzYjQ3MzMwNGIxNDAzODEyYmVjNWMyY2IuanNvbiJ9XSwiZXh0ZW5zaW9ucyI6e30sImV4dGVuc2lvbnNfcmVxdWlyZWQiOltdLCJleHRlbnNpb25zX3VzZWQiOltdLCJnZW5lcmF0b3IiOnsibmFtZSI6InNpbXBsZWNhZGFwaSIsIm9jcF9iaW5kaW5nc192ZXJzaW9uIjoiZml4dHVyZSIsIm9jcF92ZXJzaW9uIjoiZml4dHVyZSIsInBsYXRmb3JtX3RhZyI6ImZpeHR1cmUiLCJwcm9maWxlIjoic2NlbmUtMS4wLW9jcC1nbGItMiIsInB5dGhvbl9hYmkiOiJmaXh0dXJlIiwic2ltcGxlY2FkYXBpX3ZlcnNpb24iOiJmaXh0dXJlIiwidG9vbGNoYWluX2hhc2giOiJzaGEyNTY6MDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMCJ9LCJnZW9tZXRyeV9hc3NldHMiOlt7ImFzc2V0X2lkIjoic2hhMjU2OjIwMDUyMzQxNGRlMjgxYThhMTU1MDg2YTU0Y2QwZjg4MGY2NDA0ODU3NjVhNDhjY2RkOGVjMjMwZjY3ZGQxN2MiLCJhc3NldF90b19zY2VuZSI6WzEwMDAsMCwwLDAsMCwwLC0xMDAwLDAsMCwxMDAwLDAsMCwwLDAsMCwxXSwiYnl0ZV9sZW5ndGgiOjEwMjAsImNvbnRlbnRfaGFzaCI6InNoYTI1NjoyMDA1MjM0MTRkZTI4MWE4YTE1NTA4NmE1NGNkMGY4ODBmNjQwNDg1NzY1YTQ4Y2NkZDhlYzIzMGY2N2RkMTdjIiwibWVkaWFfdHlwZSI6Im1vZGVsL2dsdGYtYmluYXJ5Iiwic2NlbmVfbG9jYWxfYm91bmRzIjp7Im1heCI6WzEwMDAsMCwxMDAwXSwibWluIjpbMCwtMTAwMCwwXX0sInRlc3NlbGxhdGlvbiI6eyJhbmd1bGFyX3RvbGVyYW5jZSI6MC4yMiwibGluZWFyX3RvbGVyYW5jZSI6MC4zNX0sInVyaSI6Imdlb21ldHJ5L3NoYTI1Ni0yMDA1MjM0MTRkZTI4MWE4YTE1NTA4NmE1NGNkMGY4ODBmNjQwNDg1NzY1YTQ4Y2NkZDhlYzIzMGY2N2RkMTdjLmdsYiJ9XSwibGlnaHRzIjpbXSwibm9kZXMiOlt7ImFwcGVhcmFuY2Vfb3ZlcnJpZGVfaWQiOm51bGwsImRlZmluaXRpb25faWQiOiJkZWZpbml0aW9uL3Jvb3QvYXNzZW1ibHkvcm9vdF9hc3NlbWJseSIsIm5hbWUiOiJyb290Iiwibm9kZV9pZCI6Imluc3RhbmNlL3Jvb3QiLCJvcmRlciI6MCwicGFyZW50X25vZGVfaWQiOm51bGwsInNka19tZXRhZGF0YSI6e30sInNlbGVjdGFibGUiOnRydWUsInNvdXJjZSI6eyJjb21wb25lbnRfcGF0aCI6W10sImtpbmQiOiJwcm9kdWN0X29jY3VycmVuY2UiLCJyb290X2lkIjoicm9vdCJ9LCJ0cmFuc2Zvcm0iOnsib3JpZ2luIjpbMCwwLDBdLCJ4X2F4aXMiOlsxLDAsMF0sInlfYXhpcyI6WzAsMSwwXSwiel9heGlzIjpbMCwwLDFdfSwidmlzaWJsZSI6dHJ1ZX0seyJhcHBlYXJhbmNlX292ZXJyaWRlX2lkIjpudWxsLCJkZWZpbml0aW9uX2lkIjoiZGVmaW5pdGlvbi9yb290L2Fzc2VtYmx5L25lc3RlZF9hc3NlbWJseSIsIm5hbWUiOiJzdWJhc3NlbWJseSIsIm5vZGVfaWQiOiJpbnN0YW5jZS9yb290L3N1YmFzc2VtYmx5Iiwib3JkZXIiOjAsInBhcmVudF9ub2RlX2lkIjoiaW5zdGFuY2Uvcm9vdCIsInNka19tZXRhZGF0YSI6e30sInNlbGVjdGFibGUiOnRydWUsInNvdXJjZSI6eyJjb21wb25lbnRfcGF0aCI6WyJzdWJhc3NlbWJseSJdLCJraW5kIjoicHJvZHVjdF9vY2N1cnJlbmNlIiwicm9vdF9pZCI6InJvb3QifSwidHJhbnNmb3JtIjp7Im9yaWdpbiI6WzAsMCwwXSwieF9heGlzIjpbMSwwLDBdLCJ5X2F4aXMiOlswLDEsMF0sInpfYXhpcyI6WzAsMCwxXX0sInZpc2libGUiOnRydWV9LHsiYXBwZWFyYW5jZV9vdmVycmlkZV9pZCI6bnVsbCwiZGVmaW5pdGlvbl9pZCI6ImRlZmluaXRpb24vcm9vdC9wYXJ0L3NoYXJlZF9wYXJ0IiwibmFtZSI6InBhcnQiLCJub2RlX2lkIjoiaW5zdGFuY2Uvcm9vdC9zdWJhc3NlbWJseS9wYXJ0Iiwib3JkZXIiOjAsInBhcmVudF9ub2RlX2lkIjoiaW5zdGFuY2Uvcm9vdC9zdWJhc3NlbWJseSIsInNka19tZXRhZGF0YSI6e30sInNlbGVjdGFibGUiOnRydWUsInNvdXJjZSI6eyJjb21wb25lbnRfcGF0aCI6WyJzdWJhc3NlbWJseSIsInBhcnQiXSwia2luZCI6InByb2R1Y3Rfb2NjdXJyZW5jZSIsInJvb3RfaWQiOiJyb290In0sInRyYW5zZm9ybSI6eyJvcmlnaW4iOlswLDAsMF0sInhfYXhpcyI6WzEsMCwwXSwieV9heGlzIjpbMCwxLDBdLCJ6X2F4aXMiOlswLDAsMV19LCJ2aXNpYmxlIjp0cnVlfV0sInJldmlzaW9uIjoic2hhMjU2OmNkZTA0ZTI3MTcyZmQxYjZmZjUxNTQyODMyNzhkN2YyZDM4NGEzNzVhYmM4MDIxZmU1ZmU2NmZiZWE3ZTkzMmIiLCJzY2VuZV9pZCI6Im5lc3RlZF9maXh0dXJlIiwic2NoZW1hX3ZlcnNpb24iOiIxLjAiLCJzb3VyY2UiOnsia2luZCI6Im1hbnVhbCIsInNvdXJjZV9pZCI6ImZpeHR1cmUifX0=", + "name": "forwarded_owner_not_assembly", + "valid": false + } + ], + "presentation_cases": [ + { + "expected": null, + "name": "valid_presentation", + "payload_base64": "eyJhcHBlYXJhbmNlcyI6W3siYWxwaGFfbW9kZSI6Im9wYXF1ZSIsImJhc2VfY29sb3IiOlswLjIsMC4zLDAuNCwxXSwiZG91YmxlX3NpZGVkIjpmYWxzZSwiZWRnZV9jb2xvciI6WzAuMDUsMC4wNSwwLjA1LDFdLCJtZXRhbGxpYyI6MC4xLCJuYW1lIjoiZml4dHVyZSIsInJvdWdobmVzcyI6MC43fV0sImNhbWVyYXMiOlt7ImZhciI6MTAwMDAsIm5hbWUiOiJvdmVydmlldyIsIm5lYXIiOjEsInBhcmVudF9ub2RlX2lkIjpudWxsLCJwcm9qZWN0aW9uIjoicGVyc3BlY3RpdmUiLCJ0cmFuc2Zvcm0iOnsib3JpZ2luIjpbMjAwMCwtMjAwMCwyMDAwXSwieF9heGlzIjpbMSwwLDBdLCJ5X2F4aXMiOlswLDEsMF0sInpfYXhpcyI6WzAsMCwxXX0sInZlcnRpY2FsX2Zvdl9kZWdyZWVzIjo0NX1dLCJub2RlX292ZXJyaWRlcyI6W3siYXBwZWFyYW5jZV9uYW1lIjoiZml4dHVyZSIsIm5vZGVfaWQiOiJpbnN0YW5jZS9yb290IiwidmlzaWJsZSI6dHJ1ZX1dLCJwcmVzZW50YXRpb25faWQiOiJmaXh0dXJlIiwic2NoZW1hX3ZlcnNpb24iOiIxLjAiLCJzb3VyY2Vfc2NlbmVfaWQiOiJmaXh0dXJlIn0=", + "valid": true + }, + { + "expected": { + "code": "presentation_reference_invalid", + "path": "/node_overrides/0/appearance_name" + }, + "name": "missing_presentation_appearance", + "payload_base64": "eyJhcHBlYXJhbmNlcyI6W3siYWxwaGFfbW9kZSI6Im9wYXF1ZSIsImJhc2VfY29sb3IiOlswLjIsMC4zLDAuNCwxXSwiZG91YmxlX3NpZGVkIjpmYWxzZSwiZWRnZV9jb2xvciI6WzAuMDUsMC4wNSwwLjA1LDFdLCJtZXRhbGxpYyI6MC4xLCJuYW1lIjoiZml4dHVyZSIsInJvdWdobmVzcyI6MC43fV0sImNhbWVyYXMiOlt7ImZhciI6MTAwMDAsIm5hbWUiOiJvdmVydmlldyIsIm5lYXIiOjEsInBhcmVudF9ub2RlX2lkIjpudWxsLCJwcm9qZWN0aW9uIjoicGVyc3BlY3RpdmUiLCJ0cmFuc2Zvcm0iOnsib3JpZ2luIjpbMjAwMCwtMjAwMCwyMDAwXSwieF9heGlzIjpbMSwwLDBdLCJ5X2F4aXMiOlswLDEsMF0sInpfYXhpcyI6WzAsMCwxXX0sInZlcnRpY2FsX2Zvdl9kZWdyZWVzIjo0NX1dLCJub2RlX292ZXJyaWRlcyI6W3siYXBwZWFyYW5jZV9uYW1lIjoibWlzc2luZyIsIm5vZGVfaWQiOiJpbnN0YW5jZS9yb290IiwidmlzaWJsZSI6dHJ1ZX1dLCJwcmVzZW50YXRpb25faWQiOiJmaXh0dXJlIiwic2NoZW1hX3ZlcnNpb24iOiIxLjAiLCJzb3VyY2Vfc2NlbmVfaWQiOiJmaXh0dXJlIn0=", + "valid": false + }, + { + "expected": { + "code": "bounds_invalid", + "path": "/cameras/0/far" + }, + "name": "invalid_presentation_camera", + "payload_base64": "eyJhcHBlYXJhbmNlcyI6W3siYWxwaGFfbW9kZSI6Im9wYXF1ZSIsImJhc2VfY29sb3IiOlswLjIsMC4zLDAuNCwxXSwiZG91YmxlX3NpZGVkIjpmYWxzZSwiZWRnZV9jb2xvciI6WzAuMDUsMC4wNSwwLjA1LDFdLCJtZXRhbGxpYyI6MC4xLCJuYW1lIjoiZml4dHVyZSIsInJvdWdobmVzcyI6MC43fV0sImNhbWVyYXMiOlt7ImZhciI6MSwibmFtZSI6Im92ZXJ2aWV3IiwibmVhciI6MSwicGFyZW50X25vZGVfaWQiOm51bGwsInByb2plY3Rpb24iOiJwZXJzcGVjdGl2ZSIsInRyYW5zZm9ybSI6eyJvcmlnaW4iOlsyMDAwLC0yMDAwLDIwMDBdLCJ4X2F4aXMiOlsxLDAsMF0sInlfYXhpcyI6WzAsMSwwXSwiel9heGlzIjpbMCwwLDFdfSwidmVydGljYWxfZm92X2RlZ3JlZXMiOjQ1fV0sIm5vZGVfb3ZlcnJpZGVzIjpbeyJhcHBlYXJhbmNlX25hbWUiOiJmaXh0dXJlIiwibm9kZV9pZCI6Imluc3RhbmNlL3Jvb3QiLCJ2aXNpYmxlIjp0cnVlfV0sInByZXNlbnRhdGlvbl9pZCI6ImZpeHR1cmUiLCJzY2hlbWFfdmVyc2lvbiI6IjEuMCIsInNvdXJjZV9zY2VuZV9pZCI6ImZpeHR1cmUifQ==", + "valid": false + }, + { + "expected": null, + "name": "valid_orthographic_camera", + "payload_base64": "eyJhcHBlYXJhbmNlcyI6W3siYWxwaGFfbW9kZSI6Im9wYXF1ZSIsImJhc2VfY29sb3IiOlswLjIsMC4zLDAuNCwxXSwiZG91YmxlX3NpZGVkIjpmYWxzZSwiZWRnZV9jb2xvciI6WzAuMDUsMC4wNSwwLjA1LDFdLCJtZXRhbGxpYyI6MC4xLCJuYW1lIjoiZml4dHVyZSIsInJvdWdobmVzcyI6MC43fV0sImNhbWVyYXMiOlt7ImZhciI6MTAwMDAsIm5hbWUiOiJvdmVydmlldyIsIm5lYXIiOjEsInBhcmVudF9ub2RlX2lkIjpudWxsLCJwcm9qZWN0aW9uIjoib3J0aG9ncmFwaGljIiwidHJhbnNmb3JtIjp7Im9yaWdpbiI6WzIwMDAsLTIwMDAsMjAwMF0sInhfYXhpcyI6WzEsMCwwXSwieV9heGlzIjpbMCwxLDBdLCJ6X2F4aXMiOlswLDAsMV19LCJ2ZXJ0aWNhbF9zcGFuIjoxMDAwfV0sIm5vZGVfb3ZlcnJpZGVzIjpbeyJhcHBlYXJhbmNlX25hbWUiOiJmaXh0dXJlIiwibm9kZV9pZCI6Imluc3RhbmNlL3Jvb3QiLCJ2aXNpYmxlIjp0cnVlfV0sInByZXNlbnRhdGlvbl9pZCI6ImZpeHR1cmUiLCJzY2hlbWFfdmVyc2lvbiI6IjEuMCIsInNvdXJjZV9zY2VuZV9pZCI6ImZpeHR1cmUifQ==", + "valid": true + }, + { + "expected": null, + "name": "valid_visible_only_override", + "payload_base64": "eyJhcHBlYXJhbmNlcyI6W3siYWxwaGFfbW9kZSI6Im9wYXF1ZSIsImJhc2VfY29sb3IiOlswLjIsMC4zLDAuNCwxXSwiZG91YmxlX3NpZGVkIjpmYWxzZSwiZWRnZV9jb2xvciI6WzAuMDUsMC4wNSwwLjA1LDFdLCJtZXRhbGxpYyI6MC4xLCJuYW1lIjoiZml4dHVyZSIsInJvdWdobmVzcyI6MC43fV0sImNhbWVyYXMiOlt7ImZhciI6MTAwMDAsIm5hbWUiOiJvdmVydmlldyIsIm5lYXIiOjEsInBhcmVudF9ub2RlX2lkIjpudWxsLCJwcm9qZWN0aW9uIjoicGVyc3BlY3RpdmUiLCJ0cmFuc2Zvcm0iOnsib3JpZ2luIjpbMjAwMCwtMjAwMCwyMDAwXSwieF9heGlzIjpbMSwwLDBdLCJ5X2F4aXMiOlswLDEsMF0sInpfYXhpcyI6WzAsMCwxXX0sInZlcnRpY2FsX2Zvdl9kZWdyZWVzIjo0NX1dLCJub2RlX292ZXJyaWRlcyI6W3sibm9kZV9pZCI6Imluc3RhbmNlL3Jvb3QiLCJ2aXNpYmxlIjp0cnVlfV0sInByZXNlbnRhdGlvbl9pZCI6ImZpeHR1cmUiLCJzY2hlbWFfdmVyc2lvbiI6IjEuMCIsInNvdXJjZV9zY2VuZV9pZCI6ImZpeHR1cmUifQ==", + "valid": true + }, + { + "expected": null, + "name": "valid_appearance_only_override", + "payload_base64": "eyJhcHBlYXJhbmNlcyI6W3siYWxwaGFfbW9kZSI6Im9wYXF1ZSIsImJhc2VfY29sb3IiOlswLjIsMC4zLDAuNCwxXSwiZG91YmxlX3NpZGVkIjpmYWxzZSwiZWRnZV9jb2xvciI6WzAuMDUsMC4wNSwwLjA1LDFdLCJtZXRhbGxpYyI6MC4xLCJuYW1lIjoiZml4dHVyZSIsInJvdWdobmVzcyI6MC43fV0sImNhbWVyYXMiOlt7ImZhciI6MTAwMDAsIm5hbWUiOiJvdmVydmlldyIsIm5lYXIiOjEsInBhcmVudF9ub2RlX2lkIjpudWxsLCJwcm9qZWN0aW9uIjoicGVyc3BlY3RpdmUiLCJ0cmFuc2Zvcm0iOnsib3JpZ2luIjpbMjAwMCwtMjAwMCwyMDAwXSwieF9heGlzIjpbMSwwLDBdLCJ5X2F4aXMiOlswLDEsMF0sInpfYXhpcyI6WzAsMCwxXX0sInZlcnRpY2FsX2Zvdl9kZWdyZWVzIjo0NX1dLCJub2RlX292ZXJyaWRlcyI6W3siYXBwZWFyYW5jZV9uYW1lIjoiZml4dHVyZSIsIm5vZGVfaWQiOiJpbnN0YW5jZS9yb290In1dLCJwcmVzZW50YXRpb25faWQiOiJmaXh0dXJlIiwic2NoZW1hX3ZlcnNpb24iOiIxLjAiLCJzb3VyY2Vfc2NlbmVfaWQiOiJmaXh0dXJlIn0=", + "valid": true + }, + { + "expected": { + "code": "schema_invalid", + "path": "/node_overrides/0" + }, + "name": "empty_node_override", + "payload_base64": "eyJhcHBlYXJhbmNlcyI6W3siYWxwaGFfbW9kZSI6Im9wYXF1ZSIsImJhc2VfY29sb3IiOlswLjIsMC4zLDAuNCwxXSwiZG91YmxlX3NpZGVkIjpmYWxzZSwiZWRnZV9jb2xvciI6WzAuMDUsMC4wNSwwLjA1LDFdLCJtZXRhbGxpYyI6MC4xLCJuYW1lIjoiZml4dHVyZSIsInJvdWdobmVzcyI6MC43fV0sImNhbWVyYXMiOlt7ImZhciI6MTAwMDAsIm5hbWUiOiJvdmVydmlldyIsIm5lYXIiOjEsInBhcmVudF9ub2RlX2lkIjpudWxsLCJwcm9qZWN0aW9uIjoicGVyc3BlY3RpdmUiLCJ0cmFuc2Zvcm0iOnsib3JpZ2luIjpbMjAwMCwtMjAwMCwyMDAwXSwieF9heGlzIjpbMSwwLDBdLCJ5X2F4aXMiOlswLDEsMF0sInpfYXhpcyI6WzAsMCwxXX0sInZlcnRpY2FsX2Zvdl9kZWdyZWVzIjo0NX1dLCJub2RlX292ZXJyaWRlcyI6W3sibm9kZV9pZCI6Imluc3RhbmNlL3Jvb3QifV0sInByZXNlbnRhdGlvbl9pZCI6ImZpeHR1cmUiLCJzY2hlbWFfdmVyc2lvbiI6IjEuMCIsInNvdXJjZV9zY2VuZV9pZCI6ImZpeHR1cmUifQ==", + "valid": false + }, + { + "expected": { + "code": "schema_invalid", + "path": "/cameras/0" + }, + "name": "unknown_camera_projection", + "payload_base64": "eyJhcHBlYXJhbmNlcyI6W3siYWxwaGFfbW9kZSI6Im9wYXF1ZSIsImJhc2VfY29sb3IiOlswLjIsMC4zLDAuNCwxXSwiZG91YmxlX3NpZGVkIjpmYWxzZSwiZWRnZV9jb2xvciI6WzAuMDUsMC4wNSwwLjA1LDFdLCJtZXRhbGxpYyI6MC4xLCJuYW1lIjoiZml4dHVyZSIsInJvdWdobmVzcyI6MC43fV0sImNhbWVyYXMiOlt7ImZhciI6MTAwMDAsIm5hbWUiOiJvdmVydmlldyIsIm5lYXIiOjEsInBhcmVudF9ub2RlX2lkIjpudWxsLCJwcm9qZWN0aW9uIjoidW5rbm93biIsInRyYW5zZm9ybSI6eyJvcmlnaW4iOlsyMDAwLC0yMDAwLDIwMDBdLCJ4X2F4aXMiOlsxLDAsMF0sInlfYXhpcyI6WzAsMSwwXSwiel9heGlzIjpbMCwwLDFdfSwidmVydGljYWxfZm92X2RlZ3JlZXMiOjQ1fV0sIm5vZGVfb3ZlcnJpZGVzIjpbeyJhcHBlYXJhbmNlX25hbWUiOiJmaXh0dXJlIiwibm9kZV9pZCI6Imluc3RhbmNlL3Jvb3QiLCJ2aXNpYmxlIjp0cnVlfV0sInByZXNlbnRhdGlvbl9pZCI6ImZpeHR1cmUiLCJzY2hlbWFfdmVyc2lvbiI6IjEuMCIsInNvdXJjZV9zY2VuZV9pZCI6ImZpeHR1cmUifQ==", + "valid": false + } + ], + "resource_cases": [ + { + "error": null, + "name": "input_archive_exact_limit", + "operation": "input_archive_size", + "parameters": { + "limits": { + "input_archive_bytes": 8 + }, + "size": 8 + }, + "valid": true + }, + { + "error": "input archive exceeds resource limit", + "name": "input_archive_over_limit", + "operation": "input_archive_size", + "parameters": { + "limits": { + "input_archive_bytes": 8 + }, + "size": 9 + }, + "valid": false + }, + { + "error": null, + "name": "scene_json_exact_limit", + "operation": "archive_member_sizes", + "parameters": { + "limits": { + "canonical_archive_bytes": 1000, + "one_member_bytes": 100, + "scene_json_bytes": 8, + "total_uncompressed_bytes": 100 + }, + "sizes": { + "scene.json": 8 + } + }, + "valid": true + }, + { + "error": "scene.json exceeds resource limit", + "name": "scene_json_over_limit", + "operation": "archive_member_sizes", + "parameters": { + "limits": { + "canonical_archive_bytes": 1000, + "one_member_bytes": 100, + "scene_json_bytes": 8, + "total_uncompressed_bytes": 100 + }, + "sizes": { + "scene.json": 9 + } + }, + "valid": false + }, + { + "error": null, + "name": "entity_json_exact_limit", + "operation": "archive_member_sizes", + "parameters": { + "limits": { + "canonical_archive_bytes": 1000, + "entity_json_bytes": 8, + "one_member_bytes": 100, + "total_uncompressed_bytes": 100 + }, + "sizes": { + "entities/a.json": 8, + "scene.json": 0 + } + }, + "valid": true + }, + { + "error": "entity sidecar exceeds resource limit: \"entities/a.json\"", + "name": "entity_json_over_limit", + "operation": "archive_member_sizes", + "parameters": { + "limits": { + "canonical_archive_bytes": 1000, + "entity_json_bytes": 8, + "one_member_bytes": 100, + "total_uncompressed_bytes": 100 + }, + "sizes": { + "entities/a.json": 9, + "scene.json": 0 + } + }, + "valid": false + }, + { + "error": null, + "name": "model_json_exact_limit", + "operation": "archive_member_sizes", + "parameters": { + "limits": { + "canonical_archive_bytes": 1000, + "model_json_bytes": 8, + "one_member_bytes": 100, + "total_uncompressed_bytes": 100 + }, + "sizes": { + "model/model.json": 8, + "scene.json": 0 + } + }, + "valid": true + }, + { + "error": "embedded model.json exceeds resource limit", + "name": "model_json_over_limit", + "operation": "archive_member_sizes", + "parameters": { + "limits": { + "canonical_archive_bytes": 1000, + "model_json_bytes": 8, + "one_member_bytes": 100, + "total_uncompressed_bytes": 100 + }, + "sizes": { + "model/model.json": 9, + "scene.json": 0 + } + }, + "valid": false + }, + { + "error": null, + "name": "presentation_json_exact_limit", + "operation": "archive_member_sizes", + "parameters": { + "limits": { + "canonical_archive_bytes": 1000, + "one_member_bytes": 100, + "presentation_json_bytes": 8, + "total_uncompressed_bytes": 100 + }, + "sizes": { + "presentation/presentation.json": 8, + "scene.json": 0 + } + }, + "valid": true + }, + { + "error": "presentation JSON exceeds resource limit", + "name": "presentation_json_over_limit", + "operation": "archive_member_sizes", + "parameters": { + "limits": { + "canonical_archive_bytes": 1000, + "one_member_bytes": 100, + "presentation_json_bytes": 8, + "total_uncompressed_bytes": 100 + }, + "sizes": { + "presentation/presentation.json": 9, + "scene.json": 0 + } + }, + "valid": false + }, + { + "error": null, + "name": "canonical_archive_exact_limit", + "operation": "archive_member_sizes", + "parameters": { + "limits": { + "canonical_archive_bytes": 256, + "one_member_bytes": 100, + "total_uncompressed_bytes": 100 + }, + "sizes": { + "geometry/a.glb": 34, + "scene.json": 0 + } + }, + "valid": true + }, + { + "error": "canonical stored archive exceeds resource limit", + "name": "canonical_archive_over_limit", + "operation": "archive_member_sizes", + "parameters": { + "limits": { + "canonical_archive_bytes": 256, + "one_member_bytes": 100, + "total_uncompressed_bytes": 100 + }, + "sizes": { + "geometry/a.glb": 35, + "scene.json": 0 + } + }, + "valid": false + }, + { + "error": null, + "name": "one_member_exact_limit", + "operation": "archive_member_sizes", + "parameters": { + "limits": { + "canonical_archive_bytes": 1000, + "one_member_bytes": 8, + "total_uncompressed_bytes": 100 + }, + "sizes": { + "geometry/a.glb": 8, + "scene.json": 0 + } + }, + "valid": true + }, + { + "error": "archive member exceeds resource limit: \"geometry/a.glb\"", + "name": "one_member_over_limit", + "operation": "archive_member_sizes", + "parameters": { + "limits": { + "canonical_archive_bytes": 1000, + "one_member_bytes": 8, + "total_uncompressed_bytes": 100 + }, + "sizes": { + "geometry/a.glb": 9, + "scene.json": 0 + } + }, + "valid": false + }, + { + "error": null, + "name": "total_uncompressed_exact_limit", + "operation": "archive_member_sizes", + "parameters": { + "limits": { + "canonical_archive_bytes": 1000, + "one_member_bytes": 8, + "total_uncompressed_bytes": 16 + }, + "sizes": { + "geometry/a.glb": 8, + "geometry/b.glb": 8, + "scene.json": 0 + } + }, + "valid": true + }, + { + "error": "total uncompressed bytes exceed resource limit", + "name": "total_uncompressed_over_limit", + "operation": "archive_member_sizes", + "parameters": { + "limits": { + "canonical_archive_bytes": 1000, + "one_member_bytes": 8, + "total_uncompressed_bytes": 16 + }, + "sizes": { + "geometry/a.glb": 8, + "geometry/b.glb": 8, + "geometry/c.glb": 1, + "scene.json": 0 + } + }, + "valid": false + }, + { + "error": null, + "name": "archive_member_count_exact_limit", + "operation": "archive_member_count", + "parameters": { + "count": 3, + "limits": { + "canonical_archive_bytes": 10000, + "zip_members": 3 + } + }, + "valid": true + }, + { + "error": "archive member count exceeds resource limit", + "name": "archive_member_count_over_limit", + "operation": "archive_member_count", + "parameters": { + "count": 4, + "limits": { + "canonical_archive_bytes": 10000, + "zip_members": 3 + } + }, + "valid": false + }, + { + "error": null, + "name": "aggregate_compression_ratio_exact_limit", + "operation": "aggregate_compression_ratio", + "parameters": { + "compressed_size": 2, + "limits": { + "compression_ratio": 2 + }, + "uncompressed_size": 4 + }, + "valid": true + }, + { + "error": "aggregate compression ratio exceeds limit", + "name": "aggregate_compression_ratio_over_limit", + "operation": "aggregate_compression_ratio", + "parameters": { + "compressed_size": 2, + "limits": { + "compression_ratio": 2 + }, + "uncompressed_size": 5 + }, + "valid": false + }, + { + "error": null, + "name": "member_compression_ratio_exact_limit", + "operation": "member_compression_ratio", + "parameters": { + "compressed_size": 2, + "limits": { + "compression_ratio": 2 + }, + "uncompressed_size": 4 + }, + "valid": true + }, + { + "error": "member compression ratio exceeds limit", + "name": "member_compression_ratio_over_limit", + "operation": "member_compression_ratio", + "parameters": { + "compressed_size": 2, + "limits": { + "compression_ratio": 2 + }, + "uncompressed_size": 5 + }, + "valid": false + }, + { + "expected": null, + "name": "json_depth_exact_limit", + "operation": "json_depth", + "parameters": { + "depth": 2, + "limits": { + "json_depth": 2 + } + }, + "valid": true + }, + { + "expected": { + "code": "resource_limit_exceeded", + "path": "/x/x/x" + }, + "name": "json_depth_over_limit", + "operation": "json_depth", + "parameters": { + "depth": 3, + "limits": { + "json_depth": 2 + } + }, + "valid": false + }, + { + "expected": { + "code": "resource_limit_exceeded", + "path": "/value" + }, + "name": "utf8_value_exact_limit", + "operation": "json_domain", + "parameters": { + "field": null, + "kind": "value", + "limits": { + "json_string_bytes": 4 + }, + "text": "éé" + }, + "valid": false + }, + { + "expected": { + "code": "resource_limit_exceeded", + "path": "/value" + }, + "name": "utf8_value_over_limit", + "operation": "json_domain", + "parameters": { + "field": null, + "kind": "value", + "limits": { + "json_string_bytes": 4 + }, + "text": "ééa" + }, + "valid": false + }, + { + "expected": null, + "name": "utf8_object_key_exact_limit", + "operation": "json_domain", + "parameters": { + "field": null, + "kind": "object_key", + "limits": { + "json_string_bytes": 4 + }, + "text": "éé" + }, + "valid": true + }, + { + "expected": { + "code": "resource_limit_exceeded", + "path": "/ééa" + }, + "name": "utf8_object_key_over_limit", + "operation": "json_domain", + "parameters": { + "field": null, + "kind": "object_key", + "limits": { + "json_string_bytes": 4 + }, + "text": "ééa" + }, + "valid": false + }, + { + "expected": null, + "name": "utf8_metadata_key_exact_limit", + "operation": "json_domain", + "parameters": { + "field": null, + "kind": "metadata_key", + "limits": { + "json_string_bytes": 100, + "structural_id_bytes": 4 + }, + "text": "éé" + }, + "valid": true + }, + { + "expected": { + "code": "resource_limit_exceeded", + "path": "/metadata/ééa" + }, + "name": "utf8_metadata_key_over_limit", + "operation": "json_domain", + "parameters": { + "field": null, + "kind": "metadata_key", + "limits": { + "json_string_bytes": 100, + "structural_id_bytes": 4 + }, + "text": "ééa" + }, + "valid": false + }, + { + "expected": null, + "name": "utf8_sdk_metadata_key_exact_limit", + "operation": "json_domain", + "parameters": { + "field": null, + "kind": "sdk_metadata_key", + "limits": { + "json_string_bytes": 100, + "structural_id_bytes": 4 + }, + "text": "éé" + }, + "valid": true + }, + { + "expected": { + "code": "resource_limit_exceeded", + "path": "/sdk_metadata/ééa" + }, + "name": "utf8_sdk_metadata_key_over_limit", + "operation": "json_domain", + "parameters": { + "field": null, + "kind": "sdk_metadata_key", + "limits": { + "json_string_bytes": 100, + "structural_id_bytes": 4 + }, + "text": "ééa" + }, + "valid": false + }, + { + "expected": null, + "name": "utf8_uri_exact_limit", + "operation": "json_domain", + "parameters": { + "field": null, + "kind": "uri", + "limits": { + "json_string_bytes": 100, + "uri_bytes": 4 + }, + "text": "éé" + }, + "valid": true + }, + { + "expected": { + "code": "resource_limit_exceeded", + "path": "/uri" + }, + "name": "utf8_uri_over_limit", + "operation": "json_domain", + "parameters": { + "field": null, + "kind": "uri", + "limits": { + "json_string_bytes": 100, + "uri_bytes": 4 + }, + "text": "ééa" + }, + "valid": false + }, + { + "expected": null, + "name": "utf8_definition_ref_exact_limit", + "operation": "json_domain", + "parameters": { + "field": "definition_ref", + "kind": "identifier", + "limits": { + "json_string_bytes": 100, + "structural_id_bytes": 4 + }, + "text": "éé" + }, + "valid": true + }, + { + "expected": { + "code": "resource_limit_exceeded", + "path": "/definition_ref" + }, + "name": "utf8_definition_ref_over_limit", + "operation": "json_domain", + "parameters": { + "field": "definition_ref", + "kind": "identifier", + "limits": { + "json_string_bytes": 100, + "structural_id_bytes": 4 + }, + "text": "ééa" + }, + "valid": false + }, + { + "expected": null, + "name": "utf8_node_id_exact_limit", + "operation": "json_domain", + "parameters": { + "field": "node_id", + "kind": "identifier", + "limits": { + "json_string_bytes": 100, + "structural_id_bytes": 4 + }, + "text": "éé" + }, + "valid": true + }, + { + "expected": { + "code": "resource_limit_exceeded", + "path": "/node_id" + }, + "name": "utf8_node_id_over_limit", + "operation": "json_domain", + "parameters": { + "field": "node_id", + "kind": "identifier", + "limits": { + "json_string_bytes": 100, + "structural_id_bytes": 4 + }, + "text": "ééa" + }, + "valid": false + }, + { + "expected": null, + "name": "utf8_source_element_id_exact_limit", + "operation": "json_domain", + "parameters": { + "field": "source_element_id", + "kind": "identifier", + "limits": { + "json_string_bytes": 100, + "structural_id_bytes": 4 + }, + "text": "éé" + }, + "valid": true + }, + { + "expected": { + "code": "resource_limit_exceeded", + "path": "/source_element_id" + }, + "name": "utf8_source_element_id_over_limit", + "operation": "json_domain", + "parameters": { + "field": "source_element_id", + "kind": "identifier", + "limits": { + "json_string_bytes": 100, + "structural_id_bytes": 4 + }, + "text": "ééa" + }, + "valid": false + }, + { + "expected": null, + "name": "utf8_topo_id_exact_limit", + "operation": "json_domain", + "parameters": { + "field": "topo_id", + "kind": "identifier", + "limits": { + "json_string_bytes": 100, + "structural_id_bytes": 4 + }, + "text": "éé" + }, + "valid": true + }, + { + "expected": { + "code": "resource_limit_exceeded", + "path": "/topo_id" + }, + "name": "utf8_topo_id_over_limit", + "operation": "json_domain", + "parameters": { + "field": "topo_id", + "kind": "identifier", + "limits": { + "json_string_bytes": 100, + "structural_id_bytes": 4 + }, + "text": "ééa" + }, + "valid": false + }, + { + "expected": null, + "name": "utf8_child_entity_ids_item_exact_limit", + "operation": "json_domain", + "parameters": { + "field": "child_entity_ids", + "kind": "identifier_array", + "limits": { + "json_string_bytes": 100, + "structural_id_bytes": 4 + }, + "text": "éé" + }, + "valid": true + }, + { + "expected": { + "code": "resource_limit_exceeded", + "path": "/child_entity_ids/0" + }, + "name": "utf8_child_entity_ids_item_over_limit", + "operation": "json_domain", + "parameters": { + "field": "child_entity_ids", + "kind": "identifier_array", + "limits": { + "json_string_bytes": 100, + "structural_id_bytes": 4 + }, + "text": "ééa" + }, + "valid": false + }, + { + "expected": null, + "name": "utf8_component_path_item_exact_limit", + "operation": "json_domain", + "parameters": { + "field": "component_path", + "kind": "identifier_array", + "limits": { + "json_string_bytes": 100, + "structural_id_bytes": 4 + }, + "text": "éé" + }, + "valid": true + }, + { + "expected": { + "code": "resource_limit_exceeded", + "path": "/component_path/0" + }, + "name": "utf8_component_path_item_over_limit", + "operation": "json_domain", + "parameters": { + "field": "component_path", + "kind": "identifier_array", + "limits": { + "json_string_bytes": 100, + "structural_id_bytes": 4 + }, + "text": "ééa" + }, + "valid": false + }, + { + "expected": null, + "name": "utf8_evaluated_tags_item_exact_limit", + "operation": "json_domain", + "parameters": { + "field": "evaluated_tags", + "kind": "identifier_array", + "limits": { + "json_string_bytes": 100, + "structural_id_bytes": 4 + }, + "text": "éé" + }, + "valid": true + }, + { + "expected": { + "code": "resource_limit_exceeded", + "path": "/evaluated_tags/0" + }, + "name": "utf8_evaluated_tags_item_over_limit", + "operation": "json_domain", + "parameters": { + "field": "evaluated_tags", + "kind": "identifier_array", + "limits": { + "json_string_bytes": 100, + "structural_id_bytes": 4 + }, + "text": "ééa" + }, + "valid": false + }, + { + "expected": null, + "name": "utf8_grounded_component_ids_item_exact_limit", + "operation": "json_domain", + "parameters": { + "field": "grounded_component_ids", + "kind": "identifier_array", + "limits": { + "json_string_bytes": 100, + "structural_id_bytes": 4 + }, + "text": "éé" + }, + "valid": true + }, + { + "expected": { + "code": "resource_limit_exceeded", + "path": "/grounded_component_ids/0" + }, + "name": "utf8_grounded_component_ids_item_over_limit", + "operation": "json_domain", + "parameters": { + "field": "grounded_component_ids", + "kind": "identifier_array", + "limits": { + "json_string_bytes": 100, + "structural_id_bytes": 4 + }, + "text": "ééa" + }, + "valid": false + }, + { + "expected": null, + "name": "utf8_parent_entity_ids_item_exact_limit", + "operation": "json_domain", + "parameters": { + "field": "parent_entity_ids", + "kind": "identifier_array", + "limits": { + "json_string_bytes": 100, + "structural_id_bytes": 4 + }, + "text": "éé" + }, + "valid": true + }, + { + "expected": { + "code": "resource_limit_exceeded", + "path": "/parent_entity_ids/0" + }, + "name": "utf8_parent_entity_ids_item_over_limit", + "operation": "json_domain", + "parameters": { + "field": "parent_entity_ids", + "kind": "identifier_array", + "limits": { + "json_string_bytes": 100, + "structural_id_bytes": 4 + }, + "text": "ééa" + }, + "valid": false + }, + { + "expected": null, + "name": "utf8_semantic_binding_ids_item_exact_limit", + "operation": "json_domain", + "parameters": { + "field": "semantic_binding_ids", + "kind": "identifier_array", + "limits": { + "json_string_bytes": 100, + "structural_id_bytes": 4 + }, + "text": "éé" + }, + "valid": true + }, + { + "expected": { + "code": "resource_limit_exceeded", + "path": "/semantic_binding_ids/0" + }, + "name": "utf8_semantic_binding_ids_item_over_limit", + "operation": "json_domain", + "parameters": { + "field": "semantic_binding_ids", + "kind": "identifier_array", + "limits": { + "json_string_bytes": 100, + "structural_id_bytes": 4 + }, + "text": "ééa" + }, + "valid": false + }, + { + "expected": null, + "name": "scene_definitions_exact_limit", + "operation": "resource_count", + "parameters": { + "artifact": "scene", + "count": 2, + "field": "definitions", + "kind": "collection", + "limits": { + "definitions": 2 + } + }, + "valid": true + }, + { + "expected": { + "code": "resource_limit_exceeded", + "path": "/definitions" + }, + "name": "scene_definitions_over_limit", + "operation": "resource_count", + "parameters": { + "artifact": "scene", + "count": 3, + "field": "definitions", + "kind": "collection", + "limits": { + "definitions": 2 + } + }, + "valid": false + }, + { + "expected": null, + "name": "scene_nodes_exact_limit", + "operation": "resource_count", + "parameters": { + "artifact": "scene", + "count": 2, + "field": "nodes", + "kind": "collection", + "limits": { + "nodes": 2 + } + }, + "valid": true + }, + { + "expected": { + "code": "resource_limit_exceeded", + "path": "/nodes" + }, + "name": "scene_nodes_over_limit", + "operation": "resource_count", + "parameters": { + "artifact": "scene", + "count": 3, + "field": "nodes", + "kind": "collection", + "limits": { + "nodes": 2 + } + }, + "valid": false + }, + { + "expected": null, + "name": "scene_geometry_assets_exact_limit", + "operation": "resource_count", + "parameters": { + "artifact": "scene", + "count": 2, + "field": "geometry_assets", + "kind": "collection", + "limits": { + "assets_per_kind": 2 + } + }, + "valid": true + }, + { + "expected": { + "code": "resource_limit_exceeded", + "path": "/geometry_assets" + }, + "name": "scene_geometry_assets_over_limit", + "operation": "resource_count", + "parameters": { + "artifact": "scene", + "count": 3, + "field": "geometry_assets", + "kind": "collection", + "limits": { + "assets_per_kind": 2 + } + }, + "valid": false + }, + { + "expected": null, + "name": "scene_edge_assets_exact_limit", + "operation": "resource_count", + "parameters": { + "artifact": "scene", + "count": 2, + "field": "edge_assets", + "kind": "collection", + "limits": { + "assets_per_kind": 2 + } + }, + "valid": true + }, + { + "expected": { + "code": "resource_limit_exceeded", + "path": "/edge_assets" + }, + "name": "scene_edge_assets_over_limit", + "operation": "resource_count", + "parameters": { + "artifact": "scene", + "count": 3, + "field": "edge_assets", + "kind": "collection", + "limits": { + "assets_per_kind": 2 + } + }, + "valid": false + }, + { + "expected": null, + "name": "scene_entity_assets_exact_limit", + "operation": "resource_count", + "parameters": { + "artifact": "scene", + "count": 2, + "field": "entity_assets", + "kind": "collection", + "limits": { + "assets_per_kind": 2 + } + }, + "valid": true + }, + { + "expected": { + "code": "resource_limit_exceeded", + "path": "/entity_assets" + }, + "name": "scene_entity_assets_over_limit", + "operation": "resource_count", + "parameters": { + "artifact": "scene", + "count": 3, + "field": "entity_assets", + "kind": "collection", + "limits": { + "assets_per_kind": 2 + } + }, + "valid": false + }, + { + "expected": null, + "name": "scene_appearances_exact_limit", + "operation": "resource_count", + "parameters": { + "artifact": "scene", + "count": 2, + "field": "appearances", + "kind": "collection", + "limits": { + "appearances": 2 + } + }, + "valid": true + }, + { + "expected": { + "code": "resource_limit_exceeded", + "path": "/appearances" + }, + "name": "scene_appearances_over_limit", + "operation": "resource_count", + "parameters": { + "artifact": "scene", + "count": 3, + "field": "appearances", + "kind": "collection", + "limits": { + "appearances": 2 + } + }, + "valid": false + }, + { + "expected": null, + "name": "scene_connectors_exact_limit", + "operation": "resource_count", + "parameters": { + "artifact": "scene", + "count": 2, + "field": "connectors", + "kind": "collection", + "limits": { + "connectors": 2 + } + }, + "valid": true + }, + { + "expected": { + "code": "resource_limit_exceeded", + "path": "/connectors" + }, + "name": "scene_connectors_over_limit", + "operation": "resource_count", + "parameters": { + "artifact": "scene", + "count": 3, + "field": "connectors", + "kind": "collection", + "limits": { + "connectors": 2 + } + }, + "valid": false + }, + { + "expected": null, + "name": "scene_cameras_exact_limit", + "operation": "resource_count", + "parameters": { + "artifact": "scene", + "count": 2, + "field": "cameras", + "kind": "collection", + "limits": { + "cameras": 2 + } + }, + "valid": true + }, + { + "expected": { + "code": "resource_limit_exceeded", + "path": "/cameras" + }, + "name": "scene_cameras_over_limit", + "operation": "resource_count", + "parameters": { + "artifact": "scene", + "count": 3, + "field": "cameras", + "kind": "collection", + "limits": { + "cameras": 2 + } + }, + "valid": false + }, + { + "expected": null, + "name": "entities_entities_exact_limit", + "operation": "resource_count", + "parameters": { + "artifact": "entities", + "count": 2, + "field": "entities", + "kind": "collection", + "limits": { + "entities_per_sidecar": 2 + } + }, + "valid": true + }, + { + "expected": { + "code": "resource_limit_exceeded", + "path": "/entities" + }, + "name": "entities_entities_over_limit", + "operation": "resource_count", + "parameters": { + "artifact": "entities", + "count": 3, + "field": "entities", + "kind": "collection", + "limits": { + "entities_per_sidecar": 2 + } + }, + "valid": false + }, + { + "expected": null, + "name": "entities_face_groups_exact_limit", + "operation": "resource_count", + "parameters": { + "artifact": "entities", + "count": 2, + "field": "face_groups", + "kind": "collection", + "limits": { + "entities_per_sidecar": 2 + } + }, + "valid": true + }, + { + "expected": { + "code": "resource_limit_exceeded", + "path": "/face_groups" + }, + "name": "entities_face_groups_over_limit", + "operation": "resource_count", + "parameters": { + "artifact": "entities", + "count": 3, + "field": "face_groups", + "kind": "collection", + "limits": { + "entities_per_sidecar": 2 + } + }, + "valid": false + }, + { + "expected": null, + "name": "entities_edge_groups_exact_limit", + "operation": "resource_count", + "parameters": { + "artifact": "entities", + "count": 2, + "field": "edge_groups", + "kind": "collection", + "limits": { + "entities_per_sidecar": 2 + } + }, + "valid": true + }, + { + "expected": { + "code": "resource_limit_exceeded", + "path": "/edge_groups" + }, + "name": "entities_edge_groups_over_limit", + "operation": "resource_count", + "parameters": { + "artifact": "entities", + "count": 3, + "field": "edge_groups", + "kind": "collection", + "limits": { + "entities_per_sidecar": 2 + } + }, + "valid": false + }, + { + "expected": null, + "name": "presentation_node_overrides_exact_limit", + "operation": "resource_count", + "parameters": { + "artifact": "presentation", + "count": 2, + "field": "node_overrides", + "kind": "collection", + "limits": { + "nodes": 2 + } + }, + "valid": true + }, + { + "expected": { + "code": "resource_limit_exceeded", + "path": "/node_overrides" + }, + "name": "presentation_node_overrides_over_limit", + "operation": "resource_count", + "parameters": { + "artifact": "presentation", + "count": 3, + "field": "node_overrides", + "kind": "collection", + "limits": { + "nodes": 2 + } + }, + "valid": false + }, + { + "expected": null, + "name": "presentation_appearances_exact_limit", + "operation": "resource_count", + "parameters": { + "artifact": "presentation", + "count": 2, + "field": "appearances", + "kind": "collection", + "limits": { + "appearances": 2 + } + }, + "valid": true + }, + { + "expected": { + "code": "resource_limit_exceeded", + "path": "/appearances" + }, + "name": "presentation_appearances_over_limit", + "operation": "resource_count", + "parameters": { + "artifact": "presentation", + "count": 3, + "field": "appearances", + "kind": "collection", + "limits": { + "appearances": 2 + } + }, + "valid": false + }, + { + "expected": null, + "name": "presentation_cameras_exact_limit", + "operation": "resource_count", + "parameters": { + "artifact": "presentation", + "count": 2, + "field": "cameras", + "kind": "collection", + "limits": { + "cameras": 2 + } + }, + "valid": true + }, + { + "expected": { + "code": "resource_limit_exceeded", + "path": "/cameras" + }, + "name": "presentation_cameras_over_limit", + "operation": "resource_count", + "parameters": { + "artifact": "presentation", + "count": 3, + "field": "cameras", + "kind": "collection", + "limits": { + "cameras": 2 + } + }, + "valid": false + }, + { + "expected": null, + "name": "hierarchy_depth_exact_limit", + "operation": "resource_count", + "parameters": { + "artifact": "scene", + "count": 2, + "field": null, + "kind": "hierarchy", + "limits": { + "hierarchy_depth": 2, + "nodes": 10 + } + }, + "valid": true + }, + { + "expected": { + "code": "resource_limit_exceeded", + "path": "/nodes/0/source/component_path" + }, + "name": "hierarchy_depth_over_limit", + "operation": "resource_count", + "parameters": { + "artifact": "scene", + "count": 3, + "field": null, + "kind": "hierarchy", + "limits": { + "hierarchy_depth": 2, + "nodes": 10 + } + }, + "valid": false + }, + { + "expected": null, + "name": "forwarded_connector_depth_exact_limit", + "operation": "resource_count", + "parameters": { + "artifact": "scene", + "count": 2, + "field": null, + "kind": "forwarded", + "limits": { + "connectors": 10, + "forwarded_connector_depth": 2 + } + }, + "valid": true + }, + { + "expected": { + "code": "resource_limit_exceeded", + "path": "/connectors" + }, + "name": "forwarded_connector_depth_over_limit", + "operation": "resource_count", + "parameters": { + "artifact": "scene", + "count": 3, + "field": null, + "kind": "forwarded", + "limits": { + "connectors": 10, + "forwarded_connector_depth": 2 + } + }, + "valid": false + }, + { + "error": null, + "name": "triangle_vertices_per_asset_exact_limit", + "operation": "glb_counts", + "parameters": { + "index_count": 3, + "kind": "triangle", + "limits": { + "triangle_vertices_per_asset": 2 + }, + "vertex_count": 2 + }, + "valid": true + }, + { + "error": "GLB vertex count is empty or exceeds resource limit", + "name": "triangle_vertices_per_asset_over_limit", + "operation": "glb_counts", + "parameters": { + "index_count": 3, + "kind": "triangle", + "limits": { + "triangle_vertices_per_asset": 2 + }, + "vertex_count": 3 + }, + "valid": false + }, + { + "error": null, + "name": "triangles_per_asset_exact_limit", + "operation": "glb_counts", + "parameters": { + "index_count": 6, + "kind": "triangle", + "limits": { + "triangles_per_asset": 2 + }, + "vertex_count": 3 + }, + "valid": true + }, + { + "error": "GLB index count is invalid or exceeds resource limit", + "name": "triangles_per_asset_over_limit", + "operation": "glb_counts", + "parameters": { + "index_count": 9, + "kind": "triangle", + "limits": { + "triangles_per_asset": 2 + }, + "vertex_count": 3 + }, + "valid": false + }, + { + "error": null, + "name": "line_vertices_per_asset_exact_limit", + "operation": "glb_counts", + "parameters": { + "index_count": 2, + "kind": "line", + "limits": { + "line_vertices_per_asset": 2 + }, + "vertex_count": 2 + }, + "valid": true + }, + { + "error": "GLB vertex count is empty or exceeds resource limit", + "name": "line_vertices_per_asset_over_limit", + "operation": "glb_counts", + "parameters": { + "index_count": 2, + "kind": "line", + "limits": { + "line_vertices_per_asset": 2 + }, + "vertex_count": 3 + }, + "valid": false + }, + { + "error": null, + "name": "line_segments_per_asset_exact_limit", + "operation": "glb_counts", + "parameters": { + "index_count": 4, + "kind": "line", + "limits": { + "line_segments_per_asset": 2 + }, + "vertex_count": 2 + }, + "valid": true + }, + { + "error": "GLB index count is invalid or exceeds resource limit", + "name": "line_segments_per_asset_over_limit", + "operation": "glb_counts", + "parameters": { + "index_count": 6, + "kind": "line", + "limits": { + "line_segments_per_asset": 2 + }, + "vertex_count": 2 + }, + "valid": false + }, + { + "expected": null, + "name": "entities_total_exact_limit", + "operation": "package_budget", + "parameters": { + "contributions": { + "entity_count": 2, + "entity_json_bytes": 0, + "entity_vertex_count": 0, + "glb_decoded_buffer_bytes": 0, + "line_segment_count": 0, + "line_vertex_count": 0, + "other_immutable_json_bytes": 0, + "scene_json_bytes": 0, + "triangle_count": 0, + "triangle_vertex_count": 0 + }, + "limits": { + "entities_total": 2 + } + }, + "valid": true + }, + { + "expected": { + "code": "resource_limit_exceeded", + "path": "/entity_assets" + }, + "name": "entities_total_over_limit", + "operation": "package_budget", + "parameters": { + "contributions": { + "entity_count": 3, + "entity_json_bytes": 0, + "entity_vertex_count": 0, + "glb_decoded_buffer_bytes": 0, + "line_segment_count": 0, + "line_vertex_count": 0, + "other_immutable_json_bytes": 0, + "scene_json_bytes": 0, + "triangle_count": 0, + "triangle_vertex_count": 0 + }, + "limits": { + "entities_total": 2 + } + }, + "valid": false + }, + { + "expected": null, + "name": "line_segments_total_exact_limit", + "operation": "package_budget", + "parameters": { + "contributions": { + "entity_count": 0, + "entity_json_bytes": 0, + "entity_vertex_count": 0, + "glb_decoded_buffer_bytes": 0, + "line_segment_count": 2, + "line_vertex_count": 0, + "other_immutable_json_bytes": 0, + "scene_json_bytes": 0, + "triangle_count": 0, + "triangle_vertex_count": 0 + }, + "limits": { + "line_segments_total": 2 + } + }, + "valid": true + }, + { + "expected": { + "code": "resource_limit_exceeded", + "path": "" + }, + "name": "line_segments_total_over_limit", + "operation": "package_budget", + "parameters": { + "contributions": { + "entity_count": 0, + "entity_json_bytes": 0, + "entity_vertex_count": 0, + "glb_decoded_buffer_bytes": 0, + "line_segment_count": 3, + "line_vertex_count": 0, + "other_immutable_json_bytes": 0, + "scene_json_bytes": 0, + "triangle_count": 0, + "triangle_vertex_count": 0 + }, + "limits": { + "line_segments_total": 2 + } + }, + "valid": false + }, + { + "expected": null, + "name": "line_vertices_total_exact_limit", + "operation": "package_budget", + "parameters": { + "contributions": { + "entity_count": 0, + "entity_json_bytes": 0, + "entity_vertex_count": 0, + "glb_decoded_buffer_bytes": 0, + "line_segment_count": 0, + "line_vertex_count": 2, + "other_immutable_json_bytes": 0, + "scene_json_bytes": 0, + "triangle_count": 0, + "triangle_vertex_count": 0 + }, + "limits": { + "line_vertices_total": 2 + } + }, + "valid": true + }, + { + "expected": { + "code": "resource_limit_exceeded", + "path": "" + }, + "name": "line_vertices_total_over_limit", + "operation": "package_budget", + "parameters": { + "contributions": { + "entity_count": 0, + "entity_json_bytes": 0, + "entity_vertex_count": 0, + "glb_decoded_buffer_bytes": 0, + "line_segment_count": 0, + "line_vertex_count": 3, + "other_immutable_json_bytes": 0, + "scene_json_bytes": 0, + "triangle_count": 0, + "triangle_vertex_count": 0 + }, + "limits": { + "line_vertices_total": 2 + } + }, + "valid": false + }, + { + "expected": null, + "name": "static_decoded_buffer_bytes_exact_limit", + "operation": "package_budget", + "parameters": { + "contributions": { + "entity_count": 0, + "entity_json_bytes": 0, + "entity_vertex_count": 0, + "glb_decoded_buffer_bytes": 0, + "line_segment_count": 0, + "line_vertex_count": 0, + "other_immutable_json_bytes": 0, + "scene_json_bytes": 2, + "triangle_count": 0, + "triangle_vertex_count": 0 + }, + "limits": { + "static_decoded_buffer_bytes": 2 + } + }, + "valid": true + }, + { + "expected": { + "code": "static_buffer_limit_exceeded", + "path": "" + }, + "name": "static_decoded_buffer_bytes_over_limit", + "operation": "package_budget", + "parameters": { + "contributions": { + "entity_count": 0, + "entity_json_bytes": 0, + "entity_vertex_count": 0, + "glb_decoded_buffer_bytes": 0, + "line_segment_count": 0, + "line_vertex_count": 0, + "other_immutable_json_bytes": 0, + "scene_json_bytes": 3, + "triangle_count": 0, + "triangle_vertex_count": 0 + }, + "limits": { + "static_decoded_buffer_bytes": 2 + } + }, + "valid": false + }, + { + "expected": null, + "name": "triangle_vertices_total_exact_limit", + "operation": "package_budget", + "parameters": { + "contributions": { + "entity_count": 0, + "entity_json_bytes": 0, + "entity_vertex_count": 0, + "glb_decoded_buffer_bytes": 0, + "line_segment_count": 0, + "line_vertex_count": 0, + "other_immutable_json_bytes": 0, + "scene_json_bytes": 0, + "triangle_count": 0, + "triangle_vertex_count": 2 + }, + "limits": { + "triangle_vertices_total": 2 + } + }, + "valid": true + }, + { + "expected": { + "code": "resource_limit_exceeded", + "path": "" + }, + "name": "triangle_vertices_total_over_limit", + "operation": "package_budget", + "parameters": { + "contributions": { + "entity_count": 0, + "entity_json_bytes": 0, + "entity_vertex_count": 0, + "glb_decoded_buffer_bytes": 0, + "line_segment_count": 0, + "line_vertex_count": 0, + "other_immutable_json_bytes": 0, + "scene_json_bytes": 0, + "triangle_count": 0, + "triangle_vertex_count": 3 + }, + "limits": { + "triangle_vertices_total": 2 + } + }, + "valid": false + }, + { + "expected": null, + "name": "triangles_total_exact_limit", + "operation": "package_budget", + "parameters": { + "contributions": { + "entity_count": 0, + "entity_json_bytes": 0, + "entity_vertex_count": 0, + "glb_decoded_buffer_bytes": 0, + "line_segment_count": 0, + "line_vertex_count": 0, + "other_immutable_json_bytes": 0, + "scene_json_bytes": 0, + "triangle_count": 2, + "triangle_vertex_count": 0 + }, + "limits": { + "triangles_total": 2 + } + }, + "valid": true + }, + { + "expected": { + "code": "resource_limit_exceeded", + "path": "" + }, + "name": "triangles_total_over_limit", + "operation": "package_budget", + "parameters": { + "contributions": { + "entity_count": 0, + "entity_json_bytes": 0, + "entity_vertex_count": 0, + "glb_decoded_buffer_bytes": 0, + "line_segment_count": 0, + "line_vertex_count": 0, + "other_immutable_json_bytes": 0, + "scene_json_bytes": 0, + "triangle_count": 3, + "triangle_vertex_count": 0 + }, + "limits": { + "triangles_total": 2 + } + }, + "valid": false + }, + { + "expected": null, + "name": "safe_integer_exact_limit", + "operation": "scene_geometry_byte_length", + "parameters": { + "value": "9007199254740991" + }, + "valid": true + }, + { + "expected": { + "code": "schema_invalid", + "path": "/geometry_assets/0/byte_length" + }, + "name": "safe_integer_over_limit", + "operation": "scene_geometry_byte_length", + "parameters": { + "value": "9007199254740992" + }, + "valid": false + }, + { + "expected": null, + "name": "linear_tolerance_exact_max", + "operation": "scene_compile_option", + "parameters": { + "field": "linear_tolerance", + "value": 1000000 + }, + "valid": true + }, + { + "expected": { + "code": "schema_invalid", + "path": "/compile_options/linear_tolerance" + }, + "name": "linear_tolerance_over_max", + "operation": "scene_compile_option", + "parameters": { + "field": "linear_tolerance", + "value": 1000001 + }, + "valid": false + }, + { + "expected": null, + "name": "angular_tolerance_exact_max", + "operation": "scene_compile_option", + "parameters": { + "field": "angular_tolerance", + "value": 3.141592653589793 + }, + "valid": true + }, + { + "expected": { + "code": "schema_invalid", + "path": "/compile_options/angular_tolerance" + }, + "name": "angular_tolerance_over_max", + "operation": "scene_compile_option", + "parameters": { + "field": "angular_tolerance", + "value": 3.1415926535897936 + }, + "valid": false + } + ], + "resource_limits": { + "appearances": 25000, + "assets_per_kind": 25000, + "cameras": 1000, + "canonical_archive_bytes": 268435456, + "compression_ratio": 100, + "connectors": 100000, + "definitions": 25000, + "entities_per_sidecar": 500000, + "entities_total": 2000000, + "entity_json_bytes": 67108864, + "forwarded_connector_depth": 64, + "hierarchy_depth": 256, + "input_archive_bytes": 268435456, + "json_depth": 64, + "json_string_bytes": 1048576, + "line_segments_per_asset": 2000000, + "line_segments_total": 10000000, + "line_vertices_per_asset": 2000000, + "line_vertices_total": 10000000, + "model_json_bytes": 67108864, + "nodes": 100000, + "one_member_bytes": 268435456, + "presentation_json_bytes": 8388608, + "scene_json_bytes": 33554432, + "static_decoded_buffer_bytes": 536870912, + "structural_id_bytes": 4096, + "total_uncompressed_bytes": 1073741824, + "triangle_vertices_per_asset": 2000000, + "triangle_vertices_total": 10000000, + "triangles_per_asset": 2000000, + "triangles_total": 10000000, + "uri_bytes": 1024, + "zip_members": 50000 + }, + "revision_vectors": [ + { + "canonical_base64": "eyJhbm5vdGF0aW9ucyI6W10sImFwcGVhcmFuY2VzIjpbeyJhbHBoYV9tb2RlIjoib3BhcXVlIiwiYXBwZWFyYW5jZV9pZCI6ImFwcGVhcmFuY2UvZXZhbHVhdGVkL2JmMWNhNmQ2MGFmMjE3NjdhY2UxNDE5YjYwNTUyMTdjODcxNDU4OWI3OTA5MGQyNzBhYWJlNTEyMjMwZWZhM2EiLCJiYXNlX2NvbG9yIjpbMC43MiwwLjc1LDAuNzgsMV0sImRvdWJsZV9zaWRlZCI6ZmFsc2UsImVkZ2VfY29sb3IiOlswLjA4LDAuMDksMC4xLDFdLCJtZXRhbGxpYyI6MCwibmFtZSI6bnVsbCwicm91Z2huZXNzIjowLjU1LCJzZGtfbWV0YWRhdGEiOnt9LCJzb3VyY2UiOm51bGx9XSwiY2FtZXJhcyI6W10sImNvbXBpbGVfb3B0aW9ucyI6eyJhbmd1bGFyX3RvbGVyYW5jZSI6MC4yMiwiZW1iZWRfcHJlc2VudGF0aW9uIjpmYWxzZSwiZW1iZWRfc291cmNlIjpmYWxzZSwibGluZWFyX3RvbGVyYW5jZSI6MC4zNX0sImNvbm5lY3RvcnMiOltdLCJjb29yZGluYXRlX3N5c3RlbSI6eyJoYW5kZWRuZXNzIjoicmlnaHQiLCJsZW5ndGhfdW5pdCI6Im1tIiwidXBfYXhpcyI6IitaIn0sImRlZmluaXRpb25zIjpbeyJhcHBlYXJhbmNlX2lkIjoiYXBwZWFyYW5jZS9ldmFsdWF0ZWQvYmYxY2E2ZDYwYWYyMTc2N2FjZTE0MTliNjA1NTIxN2M4NzE0NTg5Yjc5MDkwZDI3MGFhYmU1MTIyMzBlZmEzYSIsImRlZmluaXRpb25faWQiOiJkZWZpbml0aW9uL3Jvb3Qvc2hhcGUvbWFudWFsL2ZpeHR1cmUiLCJlZGdlX2Fzc2V0X2lkIjoic2hhMjU2OjZjZTQ4MWRiMWZlMTNiY2ZlN2EyMzc0NDIzZTZjNzJkNzU3ZDUxYzk5MTY4YjI4N2M1MmY1M2FhNjI1Y2NkNjciLCJlbnRpdHlfYXNzZXRfaWQiOiJzaGEyNTY6Mzg5N2NjOWViZGIyOTQxODM1OTY0YTQzNTgyMWI1NTZlMDFiMTc1NTM1Y2ZmOGZjMGVhYWFmYjY4NGM0ZGRmNSIsImdlb21ldHJ5X2Fzc2V0X2lkIjoic2hhMjU2OjIwMDUyMzQxNGRlMjgxYThhMTU1MDg2YTU0Y2QwZjg4MGY2NDA0ODU3NjVhNDhjY2RkOGVjMjMwZjY3ZGQxN2MiLCJraW5kIjoic2hhcGUiLCJuYW1lIjpudWxsLCJzZGtfbWV0YWRhdGEiOnt9LCJzb3VyY2UiOnsia2luZCI6Im1hbnVhbCIsInJvb3RfaWQiOiJyb290Iiwic291cmNlX2lkIjoiZml4dHVyZSJ9fV0sImRpYWdub3N0aWNzIjpbXSwiZWRnZV9hc3NldHMiOlt7ImFzc2V0X2lkIjoic2hhMjU2OjZjZTQ4MWRiMWZlMTNiY2ZlN2EyMzc0NDIzZTZjNzJkNzU3ZDUxYzk5MTY4YjI4N2M1MmY1M2FhNjI1Y2NkNjciLCJhc3NldF90b19zY2VuZSI6WzEwMDAsMCwwLDAsMCwwLC0xMDAwLDAsMCwxMDAwLDAsMCwwLDAsMCwxXSwiYnl0ZV9sZW5ndGgiOjc0MCwiY29udGVudF9oYXNoIjoic2hhMjU2OjZjZTQ4MWRiMWZlMTNiY2ZlN2EyMzc0NDIzZTZjNzJkNzU3ZDUxYzk5MTY4YjI4N2M1MmY1M2FhNjI1Y2NkNjciLCJtZWRpYV90eXBlIjoibW9kZWwvZ2x0Zi1iaW5hcnkiLCJzY2VuZV9sb2NhbF9ib3VuZHMiOnsibWF4IjpbMTAwMCwwLDEwMDBdLCJtaW4iOlswLC0xMDAwLDBdfSwidGVzc2VsbGF0aW9uIjp7ImxpbmVhcl90b2xlcmFuY2UiOjAuMzV9LCJ1cmkiOiJlZGdlcy9zaGEyNTYtNmNlNDgxZGIxZmUxM2JjZmU3YTIzNzQ0MjNlNmM3MmQ3NTdkNTFjOTkxNjhiMjg3YzUyZjUzYWE2MjVjY2Q2Ny5nbGIifV0sImVudGl0eV9hc3NldHMiOlt7ImJ5dGVfbGVuZ3RoIjoxMTUxNywiY29udGVudF9oYXNoIjoic2hhMjU2OjM4OTdjYzllYmRiMjk0MTgzNTk2NGE0MzU4MjFiNTU2ZTAxYjE3NTUzNWNmZjhmYzBlYWFhZmI2ODRjNGRkZjUiLCJlbnRpdHlfYXNzZXRfaWQiOiJzaGEyNTY6Mzg5N2NjOWViZGIyOTQxODM1OTY0YTQzNTgyMWI1NTZlMDFiMTc1NTM1Y2ZmOGZjMGVhYWFmYjY4NGM0ZGRmNSIsIm1lZGlhX3R5cGUiOiJhcHBsaWNhdGlvbi92bmQuc2ltcGxlY2FkLmVudGl0aWVzK2pzb24iLCJ1cmkiOiJlbnRpdGllcy9zaGEyNTYtMzg5N2NjOWViZGIyOTQxODM1OTY0YTQzNTgyMWI1NTZlMDFiMTc1NTM1Y2ZmOGZjMGVhYWFmYjY4NGM0ZGRmNS5qc29uIn1dLCJleHRlbnNpb25zIjp7fSwiZXh0ZW5zaW9uc19yZXF1aXJlZCI6W10sImV4dGVuc2lvbnNfdXNlZCI6W10sImdlbmVyYXRvciI6eyJuYW1lIjoic2ltcGxlY2FkYXBpIiwib2NwX2JpbmRpbmdzX3ZlcnNpb24iOiJmaXh0dXJlIiwib2NwX3ZlcnNpb24iOiJmaXh0dXJlIiwicGxhdGZvcm1fdGFnIjoiZml4dHVyZSIsInByb2ZpbGUiOiJzY2VuZS0xLjAtb2NwLWdsYi0yIiwicHl0aG9uX2FiaSI6ImZpeHR1cmUiLCJzaW1wbGVjYWRhcGlfdmVyc2lvbiI6ImZpeHR1cmUiLCJ0b29sY2hhaW5faGFzaCI6InNoYTI1NjowMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwIn0sImdlb21ldHJ5X2Fzc2V0cyI6W3siYXNzZXRfaWQiOiJzaGEyNTY6MjAwNTIzNDE0ZGUyODFhOGExNTUwODZhNTRjZDBmODgwZjY0MDQ4NTc2NWE0OGNjZGQ4ZWMyMzBmNjdkZDE3YyIsImFzc2V0X3RvX3NjZW5lIjpbMTAwMCwwLDAsMCwwLDAsLTEwMDAsMCwwLDEwMDAsMCwwLDAsMCwwLDFdLCJieXRlX2xlbmd0aCI6MTAyMCwiY29udGVudF9oYXNoIjoic2hhMjU2OjIwMDUyMzQxNGRlMjgxYThhMTU1MDg2YTU0Y2QwZjg4MGY2NDA0ODU3NjVhNDhjY2RkOGVjMjMwZjY3ZGQxN2MiLCJtZWRpYV90eXBlIjoibW9kZWwvZ2x0Zi1iaW5hcnkiLCJzY2VuZV9sb2NhbF9ib3VuZHMiOnsibWF4IjpbMTAwMCwwLDEwMDBdLCJtaW4iOlswLC0xMDAwLDBdfSwidGVzc2VsbGF0aW9uIjp7ImFuZ3VsYXJfdG9sZXJhbmNlIjowLjIyLCJsaW5lYXJfdG9sZXJhbmNlIjowLjM1fSwidXJpIjoiZ2VvbWV0cnkvc2hhMjU2LTIwMDUyMzQxNGRlMjgxYThhMTU1MDg2YTU0Y2QwZjg4MGY2NDA0ODU3NjVhNDhjY2RkOGVjMjMwZjY3ZGQxN2MuZ2xiIn1dLCJsaWdodHMiOltdLCJub2RlcyI6W3siYXBwZWFyYW5jZV9vdmVycmlkZV9pZCI6bnVsbCwiZGVmaW5pdGlvbl9pZCI6ImRlZmluaXRpb24vcm9vdC9zaGFwZS9tYW51YWwvZml4dHVyZSIsIm5hbWUiOm51bGwsIm5vZGVfaWQiOiJpbnN0YW5jZS9yb290Iiwib3JkZXIiOjAsInBhcmVudF9ub2RlX2lkIjpudWxsLCJzZGtfbWV0YWRhdGEiOnt9LCJzZWxlY3RhYmxlIjp0cnVlLCJzb3VyY2UiOnsia2luZCI6InNoYXBlX3Jvb3QiLCJyb290X2lkIjoicm9vdCJ9LCJ0cmFuc2Zvcm0iOnsib3JpZ2luIjpbMCwwLDBdLCJ4X2F4aXMiOlsxLDAsMF0sInlfYXhpcyI6WzAsMSwwXSwiel9heGlzIjpbMCwwLDFdfSwidmlzaWJsZSI6dHJ1ZX1dLCJyZXZpc2lvbiI6InNoYTI1NjpmYWUxNWY0YWVlZjEyNzdjZDgzMWZmOGJmODNmOWIyNDRkNWM0MjI1ZTcyZWI3YmMyZTBlMzVkZDZjZjQzNjE0Iiwic2NlbmVfaWQiOiJmaXh0dXJlIiwic2NoZW1hX3ZlcnNpb24iOiIxLjAiLCJzb3VyY2UiOnsia2luZCI6Im1hbnVhbCIsInNvdXJjZV9pZCI6ImZpeHR1cmUifX0=", + "draft_base64": "eyJhbm5vdGF0aW9ucyI6W10sImFwcGVhcmFuY2VzIjpbeyJhbHBoYV9tb2RlIjoib3BhcXVlIiwiYXBwZWFyYW5jZV9pZCI6ImFwcGVhcmFuY2UvZXZhbHVhdGVkL2JmMWNhNmQ2MGFmMjE3NjdhY2UxNDE5YjYwNTUyMTdjODcxNDU4OWI3OTA5MGQyNzBhYWJlNTEyMjMwZWZhM2EiLCJiYXNlX2NvbG9yIjpbMC43MiwwLjc1LDAuNzgsMV0sImRvdWJsZV9zaWRlZCI6ZmFsc2UsImVkZ2VfY29sb3IiOlswLjA4LDAuMDksMC4xLDFdLCJtZXRhbGxpYyI6MCwibmFtZSI6bnVsbCwicm91Z2huZXNzIjowLjU1LCJzZGtfbWV0YWRhdGEiOnt9LCJzb3VyY2UiOm51bGx9XSwiY2FtZXJhcyI6W10sImNvbXBpbGVfb3B0aW9ucyI6eyJhbmd1bGFyX3RvbGVyYW5jZSI6MC4yMiwiZW1iZWRfcHJlc2VudGF0aW9uIjpmYWxzZSwiZW1iZWRfc291cmNlIjpmYWxzZSwibGluZWFyX3RvbGVyYW5jZSI6MC4zNX0sImNvbm5lY3RvcnMiOltdLCJjb29yZGluYXRlX3N5c3RlbSI6eyJoYW5kZWRuZXNzIjoicmlnaHQiLCJsZW5ndGhfdW5pdCI6Im1tIiwidXBfYXhpcyI6IitaIn0sImRlZmluaXRpb25zIjpbeyJhcHBlYXJhbmNlX2lkIjoiYXBwZWFyYW5jZS9ldmFsdWF0ZWQvYmYxY2E2ZDYwYWYyMTc2N2FjZTE0MTliNjA1NTIxN2M4NzE0NTg5Yjc5MDkwZDI3MGFhYmU1MTIyMzBlZmEzYSIsImRlZmluaXRpb25faWQiOiJkZWZpbml0aW9uL3Jvb3Qvc2hhcGUvbWFudWFsL2ZpeHR1cmUiLCJlZGdlX2Fzc2V0X2lkIjoic2hhMjU2OjZjZTQ4MWRiMWZlMTNiY2ZlN2EyMzc0NDIzZTZjNzJkNzU3ZDUxYzk5MTY4YjI4N2M1MmY1M2FhNjI1Y2NkNjciLCJlbnRpdHlfYXNzZXRfaWQiOiJzaGEyNTY6Mzg5N2NjOWViZGIyOTQxODM1OTY0YTQzNTgyMWI1NTZlMDFiMTc1NTM1Y2ZmOGZjMGVhYWFmYjY4NGM0ZGRmNSIsImdlb21ldHJ5X2Fzc2V0X2lkIjoic2hhMjU2OjIwMDUyMzQxNGRlMjgxYThhMTU1MDg2YTU0Y2QwZjg4MGY2NDA0ODU3NjVhNDhjY2RkOGVjMjMwZjY3ZGQxN2MiLCJraW5kIjoic2hhcGUiLCJuYW1lIjpudWxsLCJzZGtfbWV0YWRhdGEiOnt9LCJzb3VyY2UiOnsia2luZCI6Im1hbnVhbCIsInJvb3RfaWQiOiJyb290Iiwic291cmNlX2lkIjoiZml4dHVyZSJ9fV0sImRpYWdub3N0aWNzIjpbXSwiZWRnZV9hc3NldHMiOlt7ImFzc2V0X2lkIjoic2hhMjU2OjZjZTQ4MWRiMWZlMTNiY2ZlN2EyMzc0NDIzZTZjNzJkNzU3ZDUxYzk5MTY4YjI4N2M1MmY1M2FhNjI1Y2NkNjciLCJhc3NldF90b19zY2VuZSI6WzEwMDAsMCwwLDAsMCwwLC0xMDAwLDAsMCwxMDAwLDAsMCwwLDAsMCwxXSwiYnl0ZV9sZW5ndGgiOjc0MCwiY29udGVudF9oYXNoIjoic2hhMjU2OjZjZTQ4MWRiMWZlMTNiY2ZlN2EyMzc0NDIzZTZjNzJkNzU3ZDUxYzk5MTY4YjI4N2M1MmY1M2FhNjI1Y2NkNjciLCJtZWRpYV90eXBlIjoibW9kZWwvZ2x0Zi1iaW5hcnkiLCJzY2VuZV9sb2NhbF9ib3VuZHMiOnsibWF4IjpbMTAwMCwwLDEwMDBdLCJtaW4iOlswLC0xMDAwLDBdfSwidGVzc2VsbGF0aW9uIjp7ImxpbmVhcl90b2xlcmFuY2UiOjAuMzV9LCJ1cmkiOiJlZGdlcy9zaGEyNTYtNmNlNDgxZGIxZmUxM2JjZmU3YTIzNzQ0MjNlNmM3MmQ3NTdkNTFjOTkxNjhiMjg3YzUyZjUzYWE2MjVjY2Q2Ny5nbGIifV0sImVudGl0eV9hc3NldHMiOlt7ImJ5dGVfbGVuZ3RoIjoxMTUxNywiY29udGVudF9oYXNoIjoic2hhMjU2OjM4OTdjYzllYmRiMjk0MTgzNTk2NGE0MzU4MjFiNTU2ZTAxYjE3NTUzNWNmZjhmYzBlYWFhZmI2ODRjNGRkZjUiLCJlbnRpdHlfYXNzZXRfaWQiOiJzaGEyNTY6Mzg5N2NjOWViZGIyOTQxODM1OTY0YTQzNTgyMWI1NTZlMDFiMTc1NTM1Y2ZmOGZjMGVhYWFmYjY4NGM0ZGRmNSIsIm1lZGlhX3R5cGUiOiJhcHBsaWNhdGlvbi92bmQuc2ltcGxlY2FkLmVudGl0aWVzK2pzb24iLCJ1cmkiOiJlbnRpdGllcy9zaGEyNTYtMzg5N2NjOWViZGIyOTQxODM1OTY0YTQzNTgyMWI1NTZlMDFiMTc1NTM1Y2ZmOGZjMGVhYWFmYjY4NGM0ZGRmNS5qc29uIn1dLCJleHRlbnNpb25zIjp7fSwiZXh0ZW5zaW9uc19yZXF1aXJlZCI6W10sImV4dGVuc2lvbnNfdXNlZCI6W10sImdlbmVyYXRvciI6eyJuYW1lIjoic2ltcGxlY2FkYXBpIiwib2NwX2JpbmRpbmdzX3ZlcnNpb24iOiJmaXh0dXJlIiwib2NwX3ZlcnNpb24iOiJmaXh0dXJlIiwicGxhdGZvcm1fdGFnIjoiZml4dHVyZSIsInByb2ZpbGUiOiJzY2VuZS0xLjAtb2NwLWdsYi0yIiwicHl0aG9uX2FiaSI6ImZpeHR1cmUiLCJzaW1wbGVjYWRhcGlfdmVyc2lvbiI6ImZpeHR1cmUiLCJ0b29sY2hhaW5faGFzaCI6InNoYTI1NjowMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwIn0sImdlb21ldHJ5X2Fzc2V0cyI6W3siYXNzZXRfaWQiOiJzaGEyNTY6MjAwNTIzNDE0ZGUyODFhOGExNTUwODZhNTRjZDBmODgwZjY0MDQ4NTc2NWE0OGNjZGQ4ZWMyMzBmNjdkZDE3YyIsImFzc2V0X3RvX3NjZW5lIjpbMTAwMCwwLDAsMCwwLDAsLTEwMDAsMCwwLDEwMDAsMCwwLDAsMCwwLDFdLCJieXRlX2xlbmd0aCI6MTAyMCwiY29udGVudF9oYXNoIjoic2hhMjU2OjIwMDUyMzQxNGRlMjgxYThhMTU1MDg2YTU0Y2QwZjg4MGY2NDA0ODU3NjVhNDhjY2RkOGVjMjMwZjY3ZGQxN2MiLCJtZWRpYV90eXBlIjoibW9kZWwvZ2x0Zi1iaW5hcnkiLCJzY2VuZV9sb2NhbF9ib3VuZHMiOnsibWF4IjpbMTAwMCwwLDEwMDBdLCJtaW4iOlswLC0xMDAwLDBdfSwidGVzc2VsbGF0aW9uIjp7ImFuZ3VsYXJfdG9sZXJhbmNlIjowLjIyLCJsaW5lYXJfdG9sZXJhbmNlIjowLjM1fSwidXJpIjoiZ2VvbWV0cnkvc2hhMjU2LTIwMDUyMzQxNGRlMjgxYThhMTU1MDg2YTU0Y2QwZjg4MGY2NDA0ODU3NjVhNDhjY2RkOGVjMjMwZjY3ZGQxN2MuZ2xiIn1dLCJsaWdodHMiOltdLCJub2RlcyI6W3siYXBwZWFyYW5jZV9vdmVycmlkZV9pZCI6bnVsbCwiZGVmaW5pdGlvbl9pZCI6ImRlZmluaXRpb24vcm9vdC9zaGFwZS9tYW51YWwvZml4dHVyZSIsIm5hbWUiOm51bGwsIm5vZGVfaWQiOiJpbnN0YW5jZS9yb290Iiwib3JkZXIiOjAsInBhcmVudF9ub2RlX2lkIjpudWxsLCJzZGtfbWV0YWRhdGEiOnt9LCJzZWxlY3RhYmxlIjp0cnVlLCJzb3VyY2UiOnsia2luZCI6InNoYXBlX3Jvb3QiLCJyb290X2lkIjoicm9vdCJ9LCJ0cmFuc2Zvcm0iOnsib3JpZ2luIjpbMCwwLDBdLCJ4X2F4aXMiOlsxLDAsMF0sInlfYXhpcyI6WzAsMSwwXSwiel9heGlzIjpbMCwwLDFdfSwidmlzaWJsZSI6dHJ1ZX1dLCJzY2VuZV9pZCI6ImZpeHR1cmUiLCJzY2hlbWFfdmVyc2lvbiI6IjEuMCIsInNvdXJjZSI6eyJraW5kIjoibWFudWFsIiwic291cmNlX2lkIjoiZml4dHVyZSJ9fQ==", + "name": "minimal_standalone_shape", + "revision": "sha256:fae15f4aeef1277cd831ff8bf83f9b244d5c4225e72eb7bc2e0e35dd6cf43614", + "sha256": "sha256:8637a4721af859fcd68ceff8522d79a6f101e29e6c8eab39fa74c9037ab15001" + }, + { + "canonical_base64": "eyJhbm5vdGF0aW9ucyI6W10sImFwcGVhcmFuY2VzIjpbeyJhbHBoYV9tb2RlIjoib3BhcXVlIiwiYXBwZWFyYW5jZV9pZCI6ImFwcGVhcmFuY2UvZXZhbHVhdGVkLzgxZjM4OWUyZThlMjcwYzllNGYwNzRlNDIxZTdiZTRhMGUxMDI2MTk2ZjVmZGVkODczMTkxZDlmOTFkYjA4NTIiLCJiYXNlX2NvbG9yIjpbMC43MiwwLjc1LDAuNzgsMV0sImRvdWJsZV9zaWRlZCI6ZmFsc2UsImVkZ2VfY29sb3IiOlswLjA4LDAuMDksMC4xLDFdLCJtZXRhbGxpYyI6MCwibmFtZSI6IkZpeHR1cmUgbWF0ZXJpYWwiLCJyb3VnaG5lc3MiOjAuNTUsInNka19tZXRhZGF0YSI6e30sInNvdXJjZSI6eyJraW5kIjoicHJvZHVjdF9tYXRlcmlhbCIsIm1hdGVyaWFsX2lkIjoiZml4dHVyZV9tYXRlcmlhbCIsInJvb3RfaWQiOiJyb290In19XSwiY2FtZXJhcyI6W10sImNvbXBpbGVfb3B0aW9ucyI6eyJhbmd1bGFyX3RvbGVyYW5jZSI6MC4yMiwiZW1iZWRfcHJlc2VudGF0aW9uIjpmYWxzZSwiZW1iZWRfc291cmNlIjpmYWxzZSwibGluZWFyX3RvbGVyYW5jZSI6MC4zNX0sImNvbm5lY3RvcnMiOlt7ImFuY2hvcl9raW5kIjoiZm9yd2FyZGVkIiwiY29ubmVjdG9yX2lkIjoibmVzdGVkX21vdW50IiwiY29ubmVjdG9yX3NuYXBzaG90X2lkIjoiY29ubmVjdG9yL3Jvb3QvYXNzZW1ibHkvbmVzdGVkX2Fzc2VtYmx5L25lc3RlZF9tb3VudCIsImZvcndhcmRlZF9mcm9tIjp7Im9mZnNldCI6bnVsbCwic291cmNlX2NvbXBvbmVudF9pZCI6InBhcnQiLCJzb3VyY2VfY29ubmVjdG9yX2lkIjoibW91bnQiLCJzb3VyY2VfY29ubmVjdG9yX3NuYXBzaG90X2lkIjoiY29ubmVjdG9yL3Jvb3QvcGFydC9zaGFyZWRfcGFydC9tb3VudCIsInNvdXJjZV9kZWZpbml0aW9uX2lkIjoiZGVmaW5pdGlvbi9yb290L3BhcnQvc2hhcmVkX3BhcnQifSwibG9jYWxfdHJhbnNmb3JtIjp7Im9yaWdpbiI6WzAsMCwwXSwieF9heGlzIjpbMSwwLDBdLCJ5X2F4aXMiOlswLDEsMF0sInpfYXhpcyI6WzAsMCwxXX0sIm5hbWUiOiJOZXN0ZWQgTW91bnQiLCJvd25lcl9kZWZpbml0aW9uX2lkIjoiZGVmaW5pdGlvbi9yb290L2Fzc2VtYmx5L25lc3RlZF9hc3NlbWJseSIsInNka19tZXRhZGF0YSI6e30sInNvdXJjZSI6eyJraW5kIjoibWFudWFsIiwic291cmNlX2lkIjoiZml4dHVyZSJ9fSx7ImFuY2hvcl9raW5kIjoiZm9yd2FyZGVkIiwiY29ubmVjdG9yX2lkIjoicm9vdF9tb3VudCIsImNvbm5lY3Rvcl9zbmFwc2hvdF9pZCI6ImNvbm5lY3Rvci9yb290L2Fzc2VtYmx5L3Jvb3RfYXNzZW1ibHkvcm9vdF9tb3VudCIsImZvcndhcmRlZF9mcm9tIjp7Im9mZnNldCI6eyJvcmlnaW4iOlswLDAsNV0sInhfYXhpcyI6WzEsMCwwXSwieV9heGlzIjpbMCwxLDBdLCJ6X2F4aXMiOlswLDAsMV19LCJzb3VyY2VfY29tcG9uZW50X2lkIjoic3ViYXNzZW1ibHkiLCJzb3VyY2VfY29ubmVjdG9yX2lkIjoibmVzdGVkX21vdW50Iiwic291cmNlX2Nvbm5lY3Rvcl9zbmFwc2hvdF9pZCI6ImNvbm5lY3Rvci9yb290L2Fzc2VtYmx5L25lc3RlZF9hc3NlbWJseS9uZXN0ZWRfbW91bnQiLCJzb3VyY2VfZGVmaW5pdGlvbl9pZCI6ImRlZmluaXRpb24vcm9vdC9hc3NlbWJseS9uZXN0ZWRfYXNzZW1ibHkifSwibG9jYWxfdHJhbnNmb3JtIjp7Im9yaWdpbiI6WzAsMCw1XSwieF9heGlzIjpbMSwwLDBdLCJ5X2F4aXMiOlswLDEsMF0sInpfYXhpcyI6WzAsMCwxXX0sIm5hbWUiOiJSb290IE1vdW50Iiwib3duZXJfZGVmaW5pdGlvbl9pZCI6ImRlZmluaXRpb24vcm9vdC9hc3NlbWJseS9yb290X2Fzc2VtYmx5Iiwic2RrX21ldGFkYXRhIjp7fSwic291cmNlIjp7ImtpbmQiOiJtYW51YWwiLCJzb3VyY2VfaWQiOiJmaXh0dXJlIn19LHsiYW5jaG9yX2tpbmQiOiJwbGFjZW1lbnQiLCJjb25uZWN0b3JfaWQiOiJtb3VudCIsImNvbm5lY3Rvcl9zbmFwc2hvdF9pZCI6ImNvbm5lY3Rvci9yb290L3BhcnQvc2hhcmVkX3BhcnQvbW91bnQiLCJsb2NhbF90cmFuc2Zvcm0iOnsib3JpZ2luIjpbMCwwLDBdLCJ4X2F4aXMiOlsxLDAsMF0sInlfYXhpcyI6WzAsMSwwXSwiel9heGlzIjpbMCwwLDFdfSwibmFtZSI6Ik1vdW50Iiwib3duZXJfZGVmaW5pdGlvbl9pZCI6ImRlZmluaXRpb24vcm9vdC9wYXJ0L3NoYXJlZF9wYXJ0Iiwic2RrX21ldGFkYXRhIjp7fSwic291cmNlIjp7ImtpbmQiOiJtYW51YWwiLCJzb3VyY2VfaWQiOiJmaXh0dXJlIn19XSwiY29vcmRpbmF0ZV9zeXN0ZW0iOnsiaGFuZGVkbmVzcyI6InJpZ2h0IiwibGVuZ3RoX3VuaXQiOiJtbSIsInVwX2F4aXMiOiIrWiJ9LCJkZWZpbml0aW9ucyI6W3siZGVmaW5pdGlvbl9pZCI6ImRlZmluaXRpb24vcm9vdC9hc3NlbWJseS9uZXN0ZWRfYXNzZW1ibHkiLCJraW5kIjoiYXNzZW1ibHkiLCJuYW1lIjoiTmVzdGVkIEFzc2VtYmx5Iiwic2RrX21ldGFkYXRhIjp7fSwic291cmNlIjp7ImtpbmQiOiJwcm9kdWN0X21hbnVhbCIsInJvb3RfaWQiOiJyb290Iiwic2VtYW50aWNfaWQiOiJuZXN0ZWRfYXNzZW1ibHkiLCJzZW1hbnRpY190eXBlIjoiQXNzZW1ibHkifX0seyJkZWZpbml0aW9uX2lkIjoiZGVmaW5pdGlvbi9yb290L2Fzc2VtYmx5L3Jvb3RfYXNzZW1ibHkiLCJraW5kIjoiYXNzZW1ibHkiLCJuYW1lIjoiUm9vdCBBc3NlbWJseSIsInNka19tZXRhZGF0YSI6e30sInNvdXJjZSI6eyJraW5kIjoicHJvZHVjdF9tYW51YWwiLCJyb290X2lkIjoicm9vdCIsInNlbWFudGljX2lkIjoicm9vdF9hc3NlbWJseSIsInNlbWFudGljX3R5cGUiOiJBc3NlbWJseSJ9fSx7ImFwcGVhcmFuY2VfaWQiOiJhcHBlYXJhbmNlL2V2YWx1YXRlZC84MWYzODllMmU4ZTI3MGM5ZTRmMDc0ZTQyMWU3YmU0YTBlMTAyNjE5NmY1ZmRlZDg3MzE5MWQ5ZjkxZGIwODUyIiwiZGVmaW5pdGlvbl9pZCI6ImRlZmluaXRpb24vcm9vdC9wYXJ0L3NoYXJlZF9wYXJ0IiwiZWRnZV9hc3NldF9pZCI6InNoYTI1Njo2Y2U0ODFkYjFmZTEzYmNmZTdhMjM3NDQyM2U2YzcyZDc1N2Q1MWM5OTE2OGIyODdjNTJmNTNhYTYyNWNjZDY3IiwiZW50aXR5X2Fzc2V0X2lkIjoic2hhMjU2OmVjNjVkYmYzZjU5NDQwM2IzOTY5YmUwM2ZiMDA0NzczN2RiNjQ1Y2MzYjQ3MzMwNGIxNDAzODEyYmVjNWMyY2IiLCJnZW9tZXRyeV9hc3NldF9pZCI6InNoYTI1NjoyMDA1MjM0MTRkZTI4MWE4YTE1NTA4NmE1NGNkMGY4ODBmNjQwNDg1NzY1YTQ4Y2NkZDhlYzIzMGY2N2RkMTdjIiwia2luZCI6InBhcnQiLCJuYW1lIjoiU2hhcmVkIFBhcnQiLCJzZGtfbWV0YWRhdGEiOnt9LCJzb3VyY2UiOnsia2luZCI6InByb2R1Y3RfbWFudWFsIiwicm9vdF9pZCI6InJvb3QiLCJzZW1hbnRpY19pZCI6InNoYXJlZF9wYXJ0Iiwic2VtYW50aWNfdHlwZSI6IlBhcnQifX1dLCJkaWFnbm9zdGljcyI6W10sImVkZ2VfYXNzZXRzIjpbeyJhc3NldF9pZCI6InNoYTI1Njo2Y2U0ODFkYjFmZTEzYmNmZTdhMjM3NDQyM2U2YzcyZDc1N2Q1MWM5OTE2OGIyODdjNTJmNTNhYTYyNWNjZDY3IiwiYXNzZXRfdG9fc2NlbmUiOlsxMDAwLDAsMCwwLDAsMCwtMTAwMCwwLDAsMTAwMCwwLDAsMCwwLDAsMV0sImJ5dGVfbGVuZ3RoIjo3NDAsImNvbnRlbnRfaGFzaCI6InNoYTI1Njo2Y2U0ODFkYjFmZTEzYmNmZTdhMjM3NDQyM2U2YzcyZDc1N2Q1MWM5OTE2OGIyODdjNTJmNTNhYTYyNWNjZDY3IiwibWVkaWFfdHlwZSI6Im1vZGVsL2dsdGYtYmluYXJ5Iiwic2NlbmVfbG9jYWxfYm91bmRzIjp7Im1heCI6WzEwMDAsMCwxMDAwXSwibWluIjpbMCwtMTAwMCwwXX0sInRlc3NlbGxhdGlvbiI6eyJsaW5lYXJfdG9sZXJhbmNlIjowLjM1fSwidXJpIjoiZWRnZXMvc2hhMjU2LTZjZTQ4MWRiMWZlMTNiY2ZlN2EyMzc0NDIzZTZjNzJkNzU3ZDUxYzk5MTY4YjI4N2M1MmY1M2FhNjI1Y2NkNjcuZ2xiIn1dLCJlbnRpdHlfYXNzZXRzIjpbeyJieXRlX2xlbmd0aCI6MTE1NDEsImNvbnRlbnRfaGFzaCI6InNoYTI1NjplYzY1ZGJmM2Y1OTQ0MDNiMzk2OWJlMDNmYjAwNDc3MzdkYjY0NWNjM2I0NzMzMDRiMTQwMzgxMmJlYzVjMmNiIiwiZW50aXR5X2Fzc2V0X2lkIjoic2hhMjU2OmVjNjVkYmYzZjU5NDQwM2IzOTY5YmUwM2ZiMDA0NzczN2RiNjQ1Y2MzYjQ3MzMwNGIxNDAzODEyYmVjNWMyY2IiLCJtZWRpYV90eXBlIjoiYXBwbGljYXRpb24vdm5kLnNpbXBsZWNhZC5lbnRpdGllcytqc29uIiwidXJpIjoiZW50aXRpZXMvc2hhMjU2LWVjNjVkYmYzZjU5NDQwM2IzOTY5YmUwM2ZiMDA0NzczN2RiNjQ1Y2MzYjQ3MzMwNGIxNDAzODEyYmVjNWMyY2IuanNvbiJ9XSwiZXh0ZW5zaW9ucyI6e30sImV4dGVuc2lvbnNfcmVxdWlyZWQiOltdLCJleHRlbnNpb25zX3VzZWQiOltdLCJnZW5lcmF0b3IiOnsibmFtZSI6InNpbXBsZWNhZGFwaSIsIm9jcF9iaW5kaW5nc192ZXJzaW9uIjoiZml4dHVyZSIsIm9jcF92ZXJzaW9uIjoiZml4dHVyZSIsInBsYXRmb3JtX3RhZyI6ImZpeHR1cmUiLCJwcm9maWxlIjoic2NlbmUtMS4wLW9jcC1nbGItMiIsInB5dGhvbl9hYmkiOiJmaXh0dXJlIiwic2ltcGxlY2FkYXBpX3ZlcnNpb24iOiJmaXh0dXJlIiwidG9vbGNoYWluX2hhc2giOiJzaGEyNTY6MDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMCJ9LCJnZW9tZXRyeV9hc3NldHMiOlt7ImFzc2V0X2lkIjoic2hhMjU2OjIwMDUyMzQxNGRlMjgxYThhMTU1MDg2YTU0Y2QwZjg4MGY2NDA0ODU3NjVhNDhjY2RkOGVjMjMwZjY3ZGQxN2MiLCJhc3NldF90b19zY2VuZSI6WzEwMDAsMCwwLDAsMCwwLC0xMDAwLDAsMCwxMDAwLDAsMCwwLDAsMCwxXSwiYnl0ZV9sZW5ndGgiOjEwMjAsImNvbnRlbnRfaGFzaCI6InNoYTI1NjoyMDA1MjM0MTRkZTI4MWE4YTE1NTA4NmE1NGNkMGY4ODBmNjQwNDg1NzY1YTQ4Y2NkZDhlYzIzMGY2N2RkMTdjIiwibWVkaWFfdHlwZSI6Im1vZGVsL2dsdGYtYmluYXJ5Iiwic2NlbmVfbG9jYWxfYm91bmRzIjp7Im1heCI6WzEwMDAsMCwxMDAwXSwibWluIjpbMCwtMTAwMCwwXX0sInRlc3NlbGxhdGlvbiI6eyJhbmd1bGFyX3RvbGVyYW5jZSI6MC4yMiwibGluZWFyX3RvbGVyYW5jZSI6MC4zNX0sInVyaSI6Imdlb21ldHJ5L3NoYTI1Ni0yMDA1MjM0MTRkZTI4MWE4YTE1NTA4NmE1NGNkMGY4ODBmNjQwNDg1NzY1YTQ4Y2NkZDhlYzIzMGY2N2RkMTdjLmdsYiJ9XSwibGlnaHRzIjpbXSwibm9kZXMiOlt7ImFwcGVhcmFuY2Vfb3ZlcnJpZGVfaWQiOm51bGwsImRlZmluaXRpb25faWQiOiJkZWZpbml0aW9uL3Jvb3QvYXNzZW1ibHkvcm9vdF9hc3NlbWJseSIsIm5hbWUiOiJyb290Iiwibm9kZV9pZCI6Imluc3RhbmNlL3Jvb3QiLCJvcmRlciI6MCwicGFyZW50X25vZGVfaWQiOm51bGwsInNka19tZXRhZGF0YSI6e30sInNlbGVjdGFibGUiOnRydWUsInNvdXJjZSI6eyJjb21wb25lbnRfcGF0aCI6W10sImtpbmQiOiJwcm9kdWN0X29jY3VycmVuY2UiLCJyb290X2lkIjoicm9vdCJ9LCJ0cmFuc2Zvcm0iOnsib3JpZ2luIjpbMCwwLDBdLCJ4X2F4aXMiOlsxLDAsMF0sInlfYXhpcyI6WzAsMSwwXSwiel9heGlzIjpbMCwwLDFdfSwidmlzaWJsZSI6dHJ1ZX0seyJhcHBlYXJhbmNlX292ZXJyaWRlX2lkIjpudWxsLCJkZWZpbml0aW9uX2lkIjoiZGVmaW5pdGlvbi9yb290L2Fzc2VtYmx5L25lc3RlZF9hc3NlbWJseSIsIm5hbWUiOiJzdWJhc3NlbWJseSIsIm5vZGVfaWQiOiJpbnN0YW5jZS9yb290L3N1YmFzc2VtYmx5Iiwib3JkZXIiOjAsInBhcmVudF9ub2RlX2lkIjoiaW5zdGFuY2Uvcm9vdCIsInNka19tZXRhZGF0YSI6e30sInNlbGVjdGFibGUiOnRydWUsInNvdXJjZSI6eyJjb21wb25lbnRfcGF0aCI6WyJzdWJhc3NlbWJseSJdLCJraW5kIjoicHJvZHVjdF9vY2N1cnJlbmNlIiwicm9vdF9pZCI6InJvb3QifSwidHJhbnNmb3JtIjp7Im9yaWdpbiI6WzAsMCwwXSwieF9heGlzIjpbMSwwLDBdLCJ5X2F4aXMiOlswLDEsMF0sInpfYXhpcyI6WzAsMCwxXX0sInZpc2libGUiOnRydWV9LHsiYXBwZWFyYW5jZV9vdmVycmlkZV9pZCI6bnVsbCwiZGVmaW5pdGlvbl9pZCI6ImRlZmluaXRpb24vcm9vdC9wYXJ0L3NoYXJlZF9wYXJ0IiwibmFtZSI6InBhcnQiLCJub2RlX2lkIjoiaW5zdGFuY2Uvcm9vdC9zdWJhc3NlbWJseS9wYXJ0Iiwib3JkZXIiOjAsInBhcmVudF9ub2RlX2lkIjoiaW5zdGFuY2Uvcm9vdC9zdWJhc3NlbWJseSIsInNka19tZXRhZGF0YSI6e30sInNlbGVjdGFibGUiOnRydWUsInNvdXJjZSI6eyJjb21wb25lbnRfcGF0aCI6WyJzdWJhc3NlbWJseSIsInBhcnQiXSwia2luZCI6InByb2R1Y3Rfb2NjdXJyZW5jZSIsInJvb3RfaWQiOiJyb290In0sInRyYW5zZm9ybSI6eyJvcmlnaW4iOlswLDAsMF0sInhfYXhpcyI6WzEsMCwwXSwieV9heGlzIjpbMCwxLDBdLCJ6X2F4aXMiOlswLDAsMV19LCJ2aXNpYmxlIjp0cnVlfV0sInJldmlzaW9uIjoic2hhMjU2OmU2NjhhZmMxMjZhM2M3NDc5ZjBmM2JlYzBkMWFmMWY4ODNlMjNlZTlhNzVkNTBhMTA0ZGRiMmQxYWJmNzBmMmQiLCJzY2VuZV9pZCI6Im5lc3RlZF9maXh0dXJlIiwic2NoZW1hX3ZlcnNpb24iOiIxLjAiLCJzb3VyY2UiOnsia2luZCI6Im1hbnVhbCIsInNvdXJjZV9pZCI6ImZpeHR1cmUifX0=", + "draft_base64": "eyJhbm5vdGF0aW9ucyI6W10sImFwcGVhcmFuY2VzIjpbeyJhbHBoYV9tb2RlIjoib3BhcXVlIiwiYXBwZWFyYW5jZV9pZCI6ImFwcGVhcmFuY2UvZXZhbHVhdGVkLzgxZjM4OWUyZThlMjcwYzllNGYwNzRlNDIxZTdiZTRhMGUxMDI2MTk2ZjVmZGVkODczMTkxZDlmOTFkYjA4NTIiLCJiYXNlX2NvbG9yIjpbMC43MiwwLjc1LDAuNzgsMV0sImRvdWJsZV9zaWRlZCI6ZmFsc2UsImVkZ2VfY29sb3IiOlswLjA4LDAuMDksMC4xLDFdLCJtZXRhbGxpYyI6MCwibmFtZSI6IkZpeHR1cmUgbWF0ZXJpYWwiLCJyb3VnaG5lc3MiOjAuNTUsInNka19tZXRhZGF0YSI6e30sInNvdXJjZSI6eyJraW5kIjoicHJvZHVjdF9tYXRlcmlhbCIsIm1hdGVyaWFsX2lkIjoiZml4dHVyZV9tYXRlcmlhbCIsInJvb3RfaWQiOiJyb290In19XSwiY2FtZXJhcyI6W10sImNvbXBpbGVfb3B0aW9ucyI6eyJhbmd1bGFyX3RvbGVyYW5jZSI6MC4yMiwiZW1iZWRfcHJlc2VudGF0aW9uIjpmYWxzZSwiZW1iZWRfc291cmNlIjpmYWxzZSwibGluZWFyX3RvbGVyYW5jZSI6MC4zNX0sImNvbm5lY3RvcnMiOlt7ImFuY2hvcl9raW5kIjoiZm9yd2FyZGVkIiwiY29ubmVjdG9yX2lkIjoibmVzdGVkX21vdW50IiwiY29ubmVjdG9yX3NuYXBzaG90X2lkIjoiY29ubmVjdG9yL3Jvb3QvYXNzZW1ibHkvbmVzdGVkX2Fzc2VtYmx5L25lc3RlZF9tb3VudCIsImZvcndhcmRlZF9mcm9tIjp7Im9mZnNldCI6bnVsbCwic291cmNlX2NvbXBvbmVudF9pZCI6InBhcnQiLCJzb3VyY2VfY29ubmVjdG9yX2lkIjoibW91bnQiLCJzb3VyY2VfY29ubmVjdG9yX3NuYXBzaG90X2lkIjoiY29ubmVjdG9yL3Jvb3QvcGFydC9zaGFyZWRfcGFydC9tb3VudCIsInNvdXJjZV9kZWZpbml0aW9uX2lkIjoiZGVmaW5pdGlvbi9yb290L3BhcnQvc2hhcmVkX3BhcnQifSwibG9jYWxfdHJhbnNmb3JtIjp7Im9yaWdpbiI6WzAsMCwwXSwieF9heGlzIjpbMSwwLDBdLCJ5X2F4aXMiOlswLDEsMF0sInpfYXhpcyI6WzAsMCwxXX0sIm5hbWUiOiJOZXN0ZWQgTW91bnQiLCJvd25lcl9kZWZpbml0aW9uX2lkIjoiZGVmaW5pdGlvbi9yb290L2Fzc2VtYmx5L25lc3RlZF9hc3NlbWJseSIsInNka19tZXRhZGF0YSI6e30sInNvdXJjZSI6eyJraW5kIjoibWFudWFsIiwic291cmNlX2lkIjoiZml4dHVyZSJ9fSx7ImFuY2hvcl9raW5kIjoiZm9yd2FyZGVkIiwiY29ubmVjdG9yX2lkIjoicm9vdF9tb3VudCIsImNvbm5lY3Rvcl9zbmFwc2hvdF9pZCI6ImNvbm5lY3Rvci9yb290L2Fzc2VtYmx5L3Jvb3RfYXNzZW1ibHkvcm9vdF9tb3VudCIsImZvcndhcmRlZF9mcm9tIjp7Im9mZnNldCI6eyJvcmlnaW4iOlswLDAsNV0sInhfYXhpcyI6WzEsMCwwXSwieV9heGlzIjpbMCwxLDBdLCJ6X2F4aXMiOlswLDAsMV19LCJzb3VyY2VfY29tcG9uZW50X2lkIjoic3ViYXNzZW1ibHkiLCJzb3VyY2VfY29ubmVjdG9yX2lkIjoibmVzdGVkX21vdW50Iiwic291cmNlX2Nvbm5lY3Rvcl9zbmFwc2hvdF9pZCI6ImNvbm5lY3Rvci9yb290L2Fzc2VtYmx5L25lc3RlZF9hc3NlbWJseS9uZXN0ZWRfbW91bnQiLCJzb3VyY2VfZGVmaW5pdGlvbl9pZCI6ImRlZmluaXRpb24vcm9vdC9hc3NlbWJseS9uZXN0ZWRfYXNzZW1ibHkifSwibG9jYWxfdHJhbnNmb3JtIjp7Im9yaWdpbiI6WzAsMCw1XSwieF9heGlzIjpbMSwwLDBdLCJ5X2F4aXMiOlswLDEsMF0sInpfYXhpcyI6WzAsMCwxXX0sIm5hbWUiOiJSb290IE1vdW50Iiwib3duZXJfZGVmaW5pdGlvbl9pZCI6ImRlZmluaXRpb24vcm9vdC9hc3NlbWJseS9yb290X2Fzc2VtYmx5Iiwic2RrX21ldGFkYXRhIjp7fSwic291cmNlIjp7ImtpbmQiOiJtYW51YWwiLCJzb3VyY2VfaWQiOiJmaXh0dXJlIn19LHsiYW5jaG9yX2tpbmQiOiJwbGFjZW1lbnQiLCJjb25uZWN0b3JfaWQiOiJtb3VudCIsImNvbm5lY3Rvcl9zbmFwc2hvdF9pZCI6ImNvbm5lY3Rvci9yb290L3BhcnQvc2hhcmVkX3BhcnQvbW91bnQiLCJsb2NhbF90cmFuc2Zvcm0iOnsib3JpZ2luIjpbMCwwLDBdLCJ4X2F4aXMiOlsxLDAsMF0sInlfYXhpcyI6WzAsMSwwXSwiel9heGlzIjpbMCwwLDFdfSwibmFtZSI6Ik1vdW50Iiwib3duZXJfZGVmaW5pdGlvbl9pZCI6ImRlZmluaXRpb24vcm9vdC9wYXJ0L3NoYXJlZF9wYXJ0Iiwic2RrX21ldGFkYXRhIjp7fSwic291cmNlIjp7ImtpbmQiOiJtYW51YWwiLCJzb3VyY2VfaWQiOiJmaXh0dXJlIn19XSwiY29vcmRpbmF0ZV9zeXN0ZW0iOnsiaGFuZGVkbmVzcyI6InJpZ2h0IiwibGVuZ3RoX3VuaXQiOiJtbSIsInVwX2F4aXMiOiIrWiJ9LCJkZWZpbml0aW9ucyI6W3siZGVmaW5pdGlvbl9pZCI6ImRlZmluaXRpb24vcm9vdC9hc3NlbWJseS9uZXN0ZWRfYXNzZW1ibHkiLCJraW5kIjoiYXNzZW1ibHkiLCJuYW1lIjoiTmVzdGVkIEFzc2VtYmx5Iiwic2RrX21ldGFkYXRhIjp7fSwic291cmNlIjp7ImtpbmQiOiJwcm9kdWN0X21hbnVhbCIsInJvb3RfaWQiOiJyb290Iiwic2VtYW50aWNfaWQiOiJuZXN0ZWRfYXNzZW1ibHkiLCJzZW1hbnRpY190eXBlIjoiQXNzZW1ibHkifX0seyJkZWZpbml0aW9uX2lkIjoiZGVmaW5pdGlvbi9yb290L2Fzc2VtYmx5L3Jvb3RfYXNzZW1ibHkiLCJraW5kIjoiYXNzZW1ibHkiLCJuYW1lIjoiUm9vdCBBc3NlbWJseSIsInNka19tZXRhZGF0YSI6e30sInNvdXJjZSI6eyJraW5kIjoicHJvZHVjdF9tYW51YWwiLCJyb290X2lkIjoicm9vdCIsInNlbWFudGljX2lkIjoicm9vdF9hc3NlbWJseSIsInNlbWFudGljX3R5cGUiOiJBc3NlbWJseSJ9fSx7ImFwcGVhcmFuY2VfaWQiOiJhcHBlYXJhbmNlL2V2YWx1YXRlZC84MWYzODllMmU4ZTI3MGM5ZTRmMDc0ZTQyMWU3YmU0YTBlMTAyNjE5NmY1ZmRlZDg3MzE5MWQ5ZjkxZGIwODUyIiwiZGVmaW5pdGlvbl9pZCI6ImRlZmluaXRpb24vcm9vdC9wYXJ0L3NoYXJlZF9wYXJ0IiwiZWRnZV9hc3NldF9pZCI6InNoYTI1Njo2Y2U0ODFkYjFmZTEzYmNmZTdhMjM3NDQyM2U2YzcyZDc1N2Q1MWM5OTE2OGIyODdjNTJmNTNhYTYyNWNjZDY3IiwiZW50aXR5X2Fzc2V0X2lkIjoic2hhMjU2OmVjNjVkYmYzZjU5NDQwM2IzOTY5YmUwM2ZiMDA0NzczN2RiNjQ1Y2MzYjQ3MzMwNGIxNDAzODEyYmVjNWMyY2IiLCJnZW9tZXRyeV9hc3NldF9pZCI6InNoYTI1NjoyMDA1MjM0MTRkZTI4MWE4YTE1NTA4NmE1NGNkMGY4ODBmNjQwNDg1NzY1YTQ4Y2NkZDhlYzIzMGY2N2RkMTdjIiwia2luZCI6InBhcnQiLCJuYW1lIjoiU2hhcmVkIFBhcnQiLCJzZGtfbWV0YWRhdGEiOnt9LCJzb3VyY2UiOnsia2luZCI6InByb2R1Y3RfbWFudWFsIiwicm9vdF9pZCI6InJvb3QiLCJzZW1hbnRpY19pZCI6InNoYXJlZF9wYXJ0Iiwic2VtYW50aWNfdHlwZSI6IlBhcnQifX1dLCJkaWFnbm9zdGljcyI6W10sImVkZ2VfYXNzZXRzIjpbeyJhc3NldF9pZCI6InNoYTI1Njo2Y2U0ODFkYjFmZTEzYmNmZTdhMjM3NDQyM2U2YzcyZDc1N2Q1MWM5OTE2OGIyODdjNTJmNTNhYTYyNWNjZDY3IiwiYXNzZXRfdG9fc2NlbmUiOlsxMDAwLDAsMCwwLDAsMCwtMTAwMCwwLDAsMTAwMCwwLDAsMCwwLDAsMV0sImJ5dGVfbGVuZ3RoIjo3NDAsImNvbnRlbnRfaGFzaCI6InNoYTI1Njo2Y2U0ODFkYjFmZTEzYmNmZTdhMjM3NDQyM2U2YzcyZDc1N2Q1MWM5OTE2OGIyODdjNTJmNTNhYTYyNWNjZDY3IiwibWVkaWFfdHlwZSI6Im1vZGVsL2dsdGYtYmluYXJ5Iiwic2NlbmVfbG9jYWxfYm91bmRzIjp7Im1heCI6WzEwMDAsMCwxMDAwXSwibWluIjpbMCwtMTAwMCwwXX0sInRlc3NlbGxhdGlvbiI6eyJsaW5lYXJfdG9sZXJhbmNlIjowLjM1fSwidXJpIjoiZWRnZXMvc2hhMjU2LTZjZTQ4MWRiMWZlMTNiY2ZlN2EyMzc0NDIzZTZjNzJkNzU3ZDUxYzk5MTY4YjI4N2M1MmY1M2FhNjI1Y2NkNjcuZ2xiIn1dLCJlbnRpdHlfYXNzZXRzIjpbeyJieXRlX2xlbmd0aCI6MTE1NDEsImNvbnRlbnRfaGFzaCI6InNoYTI1NjplYzY1ZGJmM2Y1OTQ0MDNiMzk2OWJlMDNmYjAwNDc3MzdkYjY0NWNjM2I0NzMzMDRiMTQwMzgxMmJlYzVjMmNiIiwiZW50aXR5X2Fzc2V0X2lkIjoic2hhMjU2OmVjNjVkYmYzZjU5NDQwM2IzOTY5YmUwM2ZiMDA0NzczN2RiNjQ1Y2MzYjQ3MzMwNGIxNDAzODEyYmVjNWMyY2IiLCJtZWRpYV90eXBlIjoiYXBwbGljYXRpb24vdm5kLnNpbXBsZWNhZC5lbnRpdGllcytqc29uIiwidXJpIjoiZW50aXRpZXMvc2hhMjU2LWVjNjVkYmYzZjU5NDQwM2IzOTY5YmUwM2ZiMDA0NzczN2RiNjQ1Y2MzYjQ3MzMwNGIxNDAzODEyYmVjNWMyY2IuanNvbiJ9XSwiZXh0ZW5zaW9ucyI6e30sImV4dGVuc2lvbnNfcmVxdWlyZWQiOltdLCJleHRlbnNpb25zX3VzZWQiOltdLCJnZW5lcmF0b3IiOnsibmFtZSI6InNpbXBsZWNhZGFwaSIsIm9jcF9iaW5kaW5nc192ZXJzaW9uIjoiZml4dHVyZSIsIm9jcF92ZXJzaW9uIjoiZml4dHVyZSIsInBsYXRmb3JtX3RhZyI6ImZpeHR1cmUiLCJwcm9maWxlIjoic2NlbmUtMS4wLW9jcC1nbGItMiIsInB5dGhvbl9hYmkiOiJmaXh0dXJlIiwic2ltcGxlY2FkYXBpX3ZlcnNpb24iOiJmaXh0dXJlIiwidG9vbGNoYWluX2hhc2giOiJzaGEyNTY6MDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMCJ9LCJnZW9tZXRyeV9hc3NldHMiOlt7ImFzc2V0X2lkIjoic2hhMjU2OjIwMDUyMzQxNGRlMjgxYThhMTU1MDg2YTU0Y2QwZjg4MGY2NDA0ODU3NjVhNDhjY2RkOGVjMjMwZjY3ZGQxN2MiLCJhc3NldF90b19zY2VuZSI6WzEwMDAsMCwwLDAsMCwwLC0xMDAwLDAsMCwxMDAwLDAsMCwwLDAsMCwxXSwiYnl0ZV9sZW5ndGgiOjEwMjAsImNvbnRlbnRfaGFzaCI6InNoYTI1NjoyMDA1MjM0MTRkZTI4MWE4YTE1NTA4NmE1NGNkMGY4ODBmNjQwNDg1NzY1YTQ4Y2NkZDhlYzIzMGY2N2RkMTdjIiwibWVkaWFfdHlwZSI6Im1vZGVsL2dsdGYtYmluYXJ5Iiwic2NlbmVfbG9jYWxfYm91bmRzIjp7Im1heCI6WzEwMDAsMCwxMDAwXSwibWluIjpbMCwtMTAwMCwwXX0sInRlc3NlbGxhdGlvbiI6eyJhbmd1bGFyX3RvbGVyYW5jZSI6MC4yMiwibGluZWFyX3RvbGVyYW5jZSI6MC4zNX0sInVyaSI6Imdlb21ldHJ5L3NoYTI1Ni0yMDA1MjM0MTRkZTI4MWE4YTE1NTA4NmE1NGNkMGY4ODBmNjQwNDg1NzY1YTQ4Y2NkZDhlYzIzMGY2N2RkMTdjLmdsYiJ9XSwibGlnaHRzIjpbXSwibm9kZXMiOlt7ImFwcGVhcmFuY2Vfb3ZlcnJpZGVfaWQiOm51bGwsImRlZmluaXRpb25faWQiOiJkZWZpbml0aW9uL3Jvb3QvYXNzZW1ibHkvcm9vdF9hc3NlbWJseSIsIm5hbWUiOiJyb290Iiwibm9kZV9pZCI6Imluc3RhbmNlL3Jvb3QiLCJvcmRlciI6MCwicGFyZW50X25vZGVfaWQiOm51bGwsInNka19tZXRhZGF0YSI6e30sInNlbGVjdGFibGUiOnRydWUsInNvdXJjZSI6eyJjb21wb25lbnRfcGF0aCI6W10sImtpbmQiOiJwcm9kdWN0X29jY3VycmVuY2UiLCJyb290X2lkIjoicm9vdCJ9LCJ0cmFuc2Zvcm0iOnsib3JpZ2luIjpbMCwwLDBdLCJ4X2F4aXMiOlsxLDAsMF0sInlfYXhpcyI6WzAsMSwwXSwiel9heGlzIjpbMCwwLDFdfSwidmlzaWJsZSI6dHJ1ZX0seyJhcHBlYXJhbmNlX292ZXJyaWRlX2lkIjpudWxsLCJkZWZpbml0aW9uX2lkIjoiZGVmaW5pdGlvbi9yb290L2Fzc2VtYmx5L25lc3RlZF9hc3NlbWJseSIsIm5hbWUiOiJzdWJhc3NlbWJseSIsIm5vZGVfaWQiOiJpbnN0YW5jZS9yb290L3N1YmFzc2VtYmx5Iiwib3JkZXIiOjAsInBhcmVudF9ub2RlX2lkIjoiaW5zdGFuY2Uvcm9vdCIsInNka19tZXRhZGF0YSI6e30sInNlbGVjdGFibGUiOnRydWUsInNvdXJjZSI6eyJjb21wb25lbnRfcGF0aCI6WyJzdWJhc3NlbWJseSJdLCJraW5kIjoicHJvZHVjdF9vY2N1cnJlbmNlIiwicm9vdF9pZCI6InJvb3QifSwidHJhbnNmb3JtIjp7Im9yaWdpbiI6WzAsMCwwXSwieF9heGlzIjpbMSwwLDBdLCJ5X2F4aXMiOlswLDEsMF0sInpfYXhpcyI6WzAsMCwxXX0sInZpc2libGUiOnRydWV9LHsiYXBwZWFyYW5jZV9vdmVycmlkZV9pZCI6bnVsbCwiZGVmaW5pdGlvbl9pZCI6ImRlZmluaXRpb24vcm9vdC9wYXJ0L3NoYXJlZF9wYXJ0IiwibmFtZSI6InBhcnQiLCJub2RlX2lkIjoiaW5zdGFuY2Uvcm9vdC9zdWJhc3NlbWJseS9wYXJ0Iiwib3JkZXIiOjAsInBhcmVudF9ub2RlX2lkIjoiaW5zdGFuY2Uvcm9vdC9zdWJhc3NlbWJseSIsInNka19tZXRhZGF0YSI6e30sInNlbGVjdGFibGUiOnRydWUsInNvdXJjZSI6eyJjb21wb25lbnRfcGF0aCI6WyJzdWJhc3NlbWJseSIsInBhcnQiXSwia2luZCI6InByb2R1Y3Rfb2NjdXJyZW5jZSIsInJvb3RfaWQiOiJyb290In0sInRyYW5zZm9ybSI6eyJvcmlnaW4iOlswLDAsMF0sInhfYXhpcyI6WzEsMCwwXSwieV9heGlzIjpbMCwxLDBdLCJ6X2F4aXMiOlswLDAsMV19LCJ2aXNpYmxlIjp0cnVlfV0sInNjZW5lX2lkIjoibmVzdGVkX2ZpeHR1cmUiLCJzY2hlbWFfdmVyc2lvbiI6IjEuMCIsInNvdXJjZSI6eyJraW5kIjoibWFudWFsIiwic291cmNlX2lkIjoiZml4dHVyZSJ9fQ==", + "name": "nested_assembly", + "revision": "sha256:e668afc126a3c7479f0f3bec0d1af1f883e23ee9a75d50a104ddb2d1abf70f2d", + "sha256": "sha256:a7ee380aead0a3b2e00e5d1c008af4ee1c62a93a5503fdc49d5a5a5b85a127ef" + }, + { + "canonical_base64": "eyJhbm5vdGF0aW9ucyI6W10sImFwcGVhcmFuY2VzIjpbeyJhbHBoYV9tb2RlIjoib3BhcXVlIiwiYXBwZWFyYW5jZV9pZCI6ImFwcGVhcmFuY2UvZXZhbHVhdGVkLzgxZjM4OWUyZThlMjcwYzllNGYwNzRlNDIxZTdiZTRhMGUxMDI2MTk2ZjVmZGVkODczMTkxZDlmOTFkYjA4NTIiLCJiYXNlX2NvbG9yIjpbMC43MiwwLjc1LDAuNzgsMV0sImRvdWJsZV9zaWRlZCI6ZmFsc2UsImVkZ2VfY29sb3IiOlswLjA4LDAuMDksMC4xLDFdLCJtZXRhbGxpYyI6MCwibmFtZSI6IkZpeHR1cmUgbWF0ZXJpYWwiLCJyb3VnaG5lc3MiOjAuNTUsInNka19tZXRhZGF0YSI6e30sInNvdXJjZSI6eyJraW5kIjoicHJvZHVjdF9tYXRlcmlhbCIsIm1hdGVyaWFsX2lkIjoiZml4dHVyZV9tYXRlcmlhbCIsInJvb3RfaWQiOiJyb290In19XSwiY2FtZXJhcyI6W10sImNvbXBpbGVfb3B0aW9ucyI6eyJhbmd1bGFyX3RvbGVyYW5jZSI6MC4yMiwiZW1iZWRfcHJlc2VudGF0aW9uIjpmYWxzZSwiZW1iZWRfc291cmNlIjpmYWxzZSwibGluZWFyX3RvbGVyYW5jZSI6MC4zNX0sImNvbm5lY3RvcnMiOltdLCJjb29yZGluYXRlX3N5c3RlbSI6eyJoYW5kZWRuZXNzIjoicmlnaHQiLCJsZW5ndGhfdW5pdCI6Im1tIiwidXBfYXhpcyI6IitaIn0sImRlZmluaXRpb25zIjpbeyJkZWZpbml0aW9uX2lkIjoiZGVmaW5pdGlvbi9yb290L2Fzc2VtYmx5L3Jvb3RfYXNzZW1ibHkiLCJraW5kIjoiYXNzZW1ibHkiLCJuYW1lIjoiUm9vdCBBc3NlbWJseSIsInNka19tZXRhZGF0YSI6e30sInNvdXJjZSI6eyJraW5kIjoicHJvZHVjdF9tYW51YWwiLCJyb290X2lkIjoicm9vdCIsInNlbWFudGljX2lkIjoicm9vdF9hc3NlbWJseSIsInNlbWFudGljX3R5cGUiOiJBc3NlbWJseSJ9fSx7ImFwcGVhcmFuY2VfaWQiOiJhcHBlYXJhbmNlL2V2YWx1YXRlZC84MWYzODllMmU4ZTI3MGM5ZTRmMDc0ZTQyMWU3YmU0YTBlMTAyNjE5NmY1ZmRlZDg3MzE5MWQ5ZjkxZGIwODUyIiwiZGVmaW5pdGlvbl9pZCI6ImRlZmluaXRpb24vcm9vdC9wYXJ0L3NoYXJlZF9wYXJ0IiwiZWRnZV9hc3NldF9pZCI6InNoYTI1Njo2Y2U0ODFkYjFmZTEzYmNmZTdhMjM3NDQyM2U2YzcyZDc1N2Q1MWM5OTE2OGIyODdjNTJmNTNhYTYyNWNjZDY3IiwiZW50aXR5X2Fzc2V0X2lkIjoic2hhMjU2OmVjNjVkYmYzZjU5NDQwM2IzOTY5YmUwM2ZiMDA0NzczN2RiNjQ1Y2MzYjQ3MzMwNGIxNDAzODEyYmVjNWMyY2IiLCJnZW9tZXRyeV9hc3NldF9pZCI6InNoYTI1NjoyMDA1MjM0MTRkZTI4MWE4YTE1NTA4NmE1NGNkMGY4ODBmNjQwNDg1NzY1YTQ4Y2NkZDhlYzIzMGY2N2RkMTdjIiwia2luZCI6InBhcnQiLCJuYW1lIjoiU2hhcmVkIFBhcnQiLCJzZGtfbWV0YWRhdGEiOnt9LCJzb3VyY2UiOnsia2luZCI6InByb2R1Y3RfbWFudWFsIiwicm9vdF9pZCI6InJvb3QiLCJzZW1hbnRpY19pZCI6InNoYXJlZF9wYXJ0Iiwic2VtYW50aWNfdHlwZSI6IlBhcnQifX1dLCJkaWFnbm9zdGljcyI6W10sImVkZ2VfYXNzZXRzIjpbeyJhc3NldF9pZCI6InNoYTI1Njo2Y2U0ODFkYjFmZTEzYmNmZTdhMjM3NDQyM2U2YzcyZDc1N2Q1MWM5OTE2OGIyODdjNTJmNTNhYTYyNWNjZDY3IiwiYXNzZXRfdG9fc2NlbmUiOlsxMDAwLDAsMCwwLDAsMCwtMTAwMCwwLDAsMTAwMCwwLDAsMCwwLDAsMV0sImJ5dGVfbGVuZ3RoIjo3NDAsImNvbnRlbnRfaGFzaCI6InNoYTI1Njo2Y2U0ODFkYjFmZTEzYmNmZTdhMjM3NDQyM2U2YzcyZDc1N2Q1MWM5OTE2OGIyODdjNTJmNTNhYTYyNWNjZDY3IiwibWVkaWFfdHlwZSI6Im1vZGVsL2dsdGYtYmluYXJ5Iiwic2NlbmVfbG9jYWxfYm91bmRzIjp7Im1heCI6WzEwMDAsMCwxMDAwXSwibWluIjpbMCwtMTAwMCwwXX0sInRlc3NlbGxhdGlvbiI6eyJsaW5lYXJfdG9sZXJhbmNlIjowLjM1fSwidXJpIjoiZWRnZXMvc2hhMjU2LTZjZTQ4MWRiMWZlMTNiY2ZlN2EyMzc0NDIzZTZjNzJkNzU3ZDUxYzk5MTY4YjI4N2M1MmY1M2FhNjI1Y2NkNjcuZ2xiIn1dLCJlbnRpdHlfYXNzZXRzIjpbeyJieXRlX2xlbmd0aCI6MTE1NDEsImNvbnRlbnRfaGFzaCI6InNoYTI1NjplYzY1ZGJmM2Y1OTQ0MDNiMzk2OWJlMDNmYjAwNDc3MzdkYjY0NWNjM2I0NzMzMDRiMTQwMzgxMmJlYzVjMmNiIiwiZW50aXR5X2Fzc2V0X2lkIjoic2hhMjU2OmVjNjVkYmYzZjU5NDQwM2IzOTY5YmUwM2ZiMDA0NzczN2RiNjQ1Y2MzYjQ3MzMwNGIxNDAzODEyYmVjNWMyY2IiLCJtZWRpYV90eXBlIjoiYXBwbGljYXRpb24vdm5kLnNpbXBsZWNhZC5lbnRpdGllcytqc29uIiwidXJpIjoiZW50aXRpZXMvc2hhMjU2LWVjNjVkYmYzZjU5NDQwM2IzOTY5YmUwM2ZiMDA0NzczN2RiNjQ1Y2MzYjQ3MzMwNGIxNDAzODEyYmVjNWMyY2IuanNvbiJ9XSwiZXh0ZW5zaW9ucyI6e30sImV4dGVuc2lvbnNfcmVxdWlyZWQiOltdLCJleHRlbnNpb25zX3VzZWQiOltdLCJnZW5lcmF0b3IiOnsibmFtZSI6InNpbXBsZWNhZGFwaSIsIm9jcF9iaW5kaW5nc192ZXJzaW9uIjoiZml4dHVyZSIsIm9jcF92ZXJzaW9uIjoiZml4dHVyZSIsInBsYXRmb3JtX3RhZyI6ImZpeHR1cmUiLCJwcm9maWxlIjoic2NlbmUtMS4wLW9jcC1nbGItMiIsInB5dGhvbl9hYmkiOiJmaXh0dXJlIiwic2ltcGxlY2FkYXBpX3ZlcnNpb24iOiJmaXh0dXJlIiwidG9vbGNoYWluX2hhc2giOiJzaGEyNTY6MDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMCJ9LCJnZW9tZXRyeV9hc3NldHMiOlt7ImFzc2V0X2lkIjoic2hhMjU2OjIwMDUyMzQxNGRlMjgxYThhMTU1MDg2YTU0Y2QwZjg4MGY2NDA0ODU3NjVhNDhjY2RkOGVjMjMwZjY3ZGQxN2MiLCJhc3NldF90b19zY2VuZSI6WzEwMDAsMCwwLDAsMCwwLC0xMDAwLDAsMCwxMDAwLDAsMCwwLDAsMCwxXSwiYnl0ZV9sZW5ndGgiOjEwMjAsImNvbnRlbnRfaGFzaCI6InNoYTI1NjoyMDA1MjM0MTRkZTI4MWE4YTE1NTA4NmE1NGNkMGY4ODBmNjQwNDg1NzY1YTQ4Y2NkZDhlYzIzMGY2N2RkMTdjIiwibWVkaWFfdHlwZSI6Im1vZGVsL2dsdGYtYmluYXJ5Iiwic2NlbmVfbG9jYWxfYm91bmRzIjp7Im1heCI6WzEwMDAsMCwxMDAwXSwibWluIjpbMCwtMTAwMCwwXX0sInRlc3NlbGxhdGlvbiI6eyJhbmd1bGFyX3RvbGVyYW5jZSI6MC4yMiwibGluZWFyX3RvbGVyYW5jZSI6MC4zNX0sInVyaSI6Imdlb21ldHJ5L3NoYTI1Ni0yMDA1MjM0MTRkZTI4MWE4YTE1NTA4NmE1NGNkMGY4ODBmNjQwNDg1NzY1YTQ4Y2NkZDhlYzIzMGY2N2RkMTdjLmdsYiJ9XSwibGlnaHRzIjpbXSwibm9kZXMiOlt7ImFwcGVhcmFuY2Vfb3ZlcnJpZGVfaWQiOm51bGwsImRlZmluaXRpb25faWQiOiJkZWZpbml0aW9uL3Jvb3QvYXNzZW1ibHkvcm9vdF9hc3NlbWJseSIsIm5hbWUiOiJyb290Iiwibm9kZV9pZCI6Imluc3RhbmNlL3Jvb3QiLCJvcmRlciI6MCwicGFyZW50X25vZGVfaWQiOm51bGwsInNka19tZXRhZGF0YSI6e30sInNlbGVjdGFibGUiOnRydWUsInNvdXJjZSI6eyJjb21wb25lbnRfcGF0aCI6W10sImtpbmQiOiJwcm9kdWN0X29jY3VycmVuY2UiLCJyb290X2lkIjoicm9vdCJ9LCJ0cmFuc2Zvcm0iOnsib3JpZ2luIjpbMCwwLDBdLCJ4X2F4aXMiOlsxLDAsMF0sInlfYXhpcyI6WzAsMSwwXSwiel9heGlzIjpbMCwwLDFdfSwidmlzaWJsZSI6dHJ1ZX0seyJhcHBlYXJhbmNlX292ZXJyaWRlX2lkIjpudWxsLCJkZWZpbml0aW9uX2lkIjoiZGVmaW5pdGlvbi9yb290L3BhcnQvc2hhcmVkX3BhcnQiLCJuYW1lIjoibGVmdCIsIm5vZGVfaWQiOiJpbnN0YW5jZS9yb290L2xlZnQiLCJvcmRlciI6MCwicGFyZW50X25vZGVfaWQiOiJpbnN0YW5jZS9yb290Iiwic2RrX21ldGFkYXRhIjp7fSwic2VsZWN0YWJsZSI6dHJ1ZSwic291cmNlIjp7ImNvbXBvbmVudF9wYXRoIjpbImxlZnQiXSwia2luZCI6InByb2R1Y3Rfb2NjdXJyZW5jZSIsInJvb3RfaWQiOiJyb290In0sInRyYW5zZm9ybSI6eyJvcmlnaW4iOlstMTAwMCwwLDBdLCJ4X2F4aXMiOlsxLDAsMF0sInlfYXhpcyI6WzAsMSwwXSwiel9heGlzIjpbMCwwLDFdfSwidmlzaWJsZSI6dHJ1ZX0seyJhcHBlYXJhbmNlX292ZXJyaWRlX2lkIjpudWxsLCJkZWZpbml0aW9uX2lkIjoiZGVmaW5pdGlvbi9yb290L3BhcnQvc2hhcmVkX3BhcnQiLCJuYW1lIjoicmlnaHQiLCJub2RlX2lkIjoiaW5zdGFuY2Uvcm9vdC9yaWdodCIsIm9yZGVyIjoxLCJwYXJlbnRfbm9kZV9pZCI6Imluc3RhbmNlL3Jvb3QiLCJzZGtfbWV0YWRhdGEiOnt9LCJzZWxlY3RhYmxlIjp0cnVlLCJzb3VyY2UiOnsiY29tcG9uZW50X3BhdGgiOlsicmlnaHQiXSwia2luZCI6InByb2R1Y3Rfb2NjdXJyZW5jZSIsInJvb3RfaWQiOiJyb290In0sInRyYW5zZm9ybSI6eyJvcmlnaW4iOlsxMDAwLDAsMF0sInhfYXhpcyI6WzEsMCwwXSwieV9heGlzIjpbMCwxLDBdLCJ6X2F4aXMiOlswLDAsMV19LCJ2aXNpYmxlIjp0cnVlfV0sInJldmlzaW9uIjoic2hhMjU2OjVlOGRlZGUwNGExNjE5YTJiYTQ1MTQ3ZDk0ZDlmOTc5YWU5YmFjNDRhNTEwYWUzN2VlMDBkMGU1NjQzYjcyMDMiLCJzY2VuZV9pZCI6InJlcGVhdGVkX2ZpeHR1cmUiLCJzY2hlbWFfdmVyc2lvbiI6IjEuMCIsInNvdXJjZSI6eyJraW5kIjoibWFudWFsIiwic291cmNlX2lkIjoiZml4dHVyZSJ9fQ==", + "draft_base64": "eyJhbm5vdGF0aW9ucyI6W10sImFwcGVhcmFuY2VzIjpbeyJhbHBoYV9tb2RlIjoib3BhcXVlIiwiYXBwZWFyYW5jZV9pZCI6ImFwcGVhcmFuY2UvZXZhbHVhdGVkLzgxZjM4OWUyZThlMjcwYzllNGYwNzRlNDIxZTdiZTRhMGUxMDI2MTk2ZjVmZGVkODczMTkxZDlmOTFkYjA4NTIiLCJiYXNlX2NvbG9yIjpbMC43MiwwLjc1LDAuNzgsMV0sImRvdWJsZV9zaWRlZCI6ZmFsc2UsImVkZ2VfY29sb3IiOlswLjA4LDAuMDksMC4xLDFdLCJtZXRhbGxpYyI6MCwibmFtZSI6IkZpeHR1cmUgbWF0ZXJpYWwiLCJyb3VnaG5lc3MiOjAuNTUsInNka19tZXRhZGF0YSI6e30sInNvdXJjZSI6eyJraW5kIjoicHJvZHVjdF9tYXRlcmlhbCIsIm1hdGVyaWFsX2lkIjoiZml4dHVyZV9tYXRlcmlhbCIsInJvb3RfaWQiOiJyb290In19XSwiY2FtZXJhcyI6W10sImNvbXBpbGVfb3B0aW9ucyI6eyJhbmd1bGFyX3RvbGVyYW5jZSI6MC4yMiwiZW1iZWRfcHJlc2VudGF0aW9uIjpmYWxzZSwiZW1iZWRfc291cmNlIjpmYWxzZSwibGluZWFyX3RvbGVyYW5jZSI6MC4zNX0sImNvbm5lY3RvcnMiOltdLCJjb29yZGluYXRlX3N5c3RlbSI6eyJoYW5kZWRuZXNzIjoicmlnaHQiLCJsZW5ndGhfdW5pdCI6Im1tIiwidXBfYXhpcyI6IitaIn0sImRlZmluaXRpb25zIjpbeyJkZWZpbml0aW9uX2lkIjoiZGVmaW5pdGlvbi9yb290L2Fzc2VtYmx5L3Jvb3RfYXNzZW1ibHkiLCJraW5kIjoiYXNzZW1ibHkiLCJuYW1lIjoiUm9vdCBBc3NlbWJseSIsInNka19tZXRhZGF0YSI6e30sInNvdXJjZSI6eyJraW5kIjoicHJvZHVjdF9tYW51YWwiLCJyb290X2lkIjoicm9vdCIsInNlbWFudGljX2lkIjoicm9vdF9hc3NlbWJseSIsInNlbWFudGljX3R5cGUiOiJBc3NlbWJseSJ9fSx7ImFwcGVhcmFuY2VfaWQiOiJhcHBlYXJhbmNlL2V2YWx1YXRlZC84MWYzODllMmU4ZTI3MGM5ZTRmMDc0ZTQyMWU3YmU0YTBlMTAyNjE5NmY1ZmRlZDg3MzE5MWQ5ZjkxZGIwODUyIiwiZGVmaW5pdGlvbl9pZCI6ImRlZmluaXRpb24vcm9vdC9wYXJ0L3NoYXJlZF9wYXJ0IiwiZWRnZV9hc3NldF9pZCI6InNoYTI1Njo2Y2U0ODFkYjFmZTEzYmNmZTdhMjM3NDQyM2U2YzcyZDc1N2Q1MWM5OTE2OGIyODdjNTJmNTNhYTYyNWNjZDY3IiwiZW50aXR5X2Fzc2V0X2lkIjoic2hhMjU2OmVjNjVkYmYzZjU5NDQwM2IzOTY5YmUwM2ZiMDA0NzczN2RiNjQ1Y2MzYjQ3MzMwNGIxNDAzODEyYmVjNWMyY2IiLCJnZW9tZXRyeV9hc3NldF9pZCI6InNoYTI1NjoyMDA1MjM0MTRkZTI4MWE4YTE1NTA4NmE1NGNkMGY4ODBmNjQwNDg1NzY1YTQ4Y2NkZDhlYzIzMGY2N2RkMTdjIiwia2luZCI6InBhcnQiLCJuYW1lIjoiU2hhcmVkIFBhcnQiLCJzZGtfbWV0YWRhdGEiOnt9LCJzb3VyY2UiOnsia2luZCI6InByb2R1Y3RfbWFudWFsIiwicm9vdF9pZCI6InJvb3QiLCJzZW1hbnRpY19pZCI6InNoYXJlZF9wYXJ0Iiwic2VtYW50aWNfdHlwZSI6IlBhcnQifX1dLCJkaWFnbm9zdGljcyI6W10sImVkZ2VfYXNzZXRzIjpbeyJhc3NldF9pZCI6InNoYTI1Njo2Y2U0ODFkYjFmZTEzYmNmZTdhMjM3NDQyM2U2YzcyZDc1N2Q1MWM5OTE2OGIyODdjNTJmNTNhYTYyNWNjZDY3IiwiYXNzZXRfdG9fc2NlbmUiOlsxMDAwLDAsMCwwLDAsMCwtMTAwMCwwLDAsMTAwMCwwLDAsMCwwLDAsMV0sImJ5dGVfbGVuZ3RoIjo3NDAsImNvbnRlbnRfaGFzaCI6InNoYTI1Njo2Y2U0ODFkYjFmZTEzYmNmZTdhMjM3NDQyM2U2YzcyZDc1N2Q1MWM5OTE2OGIyODdjNTJmNTNhYTYyNWNjZDY3IiwibWVkaWFfdHlwZSI6Im1vZGVsL2dsdGYtYmluYXJ5Iiwic2NlbmVfbG9jYWxfYm91bmRzIjp7Im1heCI6WzEwMDAsMCwxMDAwXSwibWluIjpbMCwtMTAwMCwwXX0sInRlc3NlbGxhdGlvbiI6eyJsaW5lYXJfdG9sZXJhbmNlIjowLjM1fSwidXJpIjoiZWRnZXMvc2hhMjU2LTZjZTQ4MWRiMWZlMTNiY2ZlN2EyMzc0NDIzZTZjNzJkNzU3ZDUxYzk5MTY4YjI4N2M1MmY1M2FhNjI1Y2NkNjcuZ2xiIn1dLCJlbnRpdHlfYXNzZXRzIjpbeyJieXRlX2xlbmd0aCI6MTE1NDEsImNvbnRlbnRfaGFzaCI6InNoYTI1NjplYzY1ZGJmM2Y1OTQ0MDNiMzk2OWJlMDNmYjAwNDc3MzdkYjY0NWNjM2I0NzMzMDRiMTQwMzgxMmJlYzVjMmNiIiwiZW50aXR5X2Fzc2V0X2lkIjoic2hhMjU2OmVjNjVkYmYzZjU5NDQwM2IzOTY5YmUwM2ZiMDA0NzczN2RiNjQ1Y2MzYjQ3MzMwNGIxNDAzODEyYmVjNWMyY2IiLCJtZWRpYV90eXBlIjoiYXBwbGljYXRpb24vdm5kLnNpbXBsZWNhZC5lbnRpdGllcytqc29uIiwidXJpIjoiZW50aXRpZXMvc2hhMjU2LWVjNjVkYmYzZjU5NDQwM2IzOTY5YmUwM2ZiMDA0NzczN2RiNjQ1Y2MzYjQ3MzMwNGIxNDAzODEyYmVjNWMyY2IuanNvbiJ9XSwiZXh0ZW5zaW9ucyI6e30sImV4dGVuc2lvbnNfcmVxdWlyZWQiOltdLCJleHRlbnNpb25zX3VzZWQiOltdLCJnZW5lcmF0b3IiOnsibmFtZSI6InNpbXBsZWNhZGFwaSIsIm9jcF9iaW5kaW5nc192ZXJzaW9uIjoiZml4dHVyZSIsIm9jcF92ZXJzaW9uIjoiZml4dHVyZSIsInBsYXRmb3JtX3RhZyI6ImZpeHR1cmUiLCJwcm9maWxlIjoic2NlbmUtMS4wLW9jcC1nbGItMiIsInB5dGhvbl9hYmkiOiJmaXh0dXJlIiwic2ltcGxlY2FkYXBpX3ZlcnNpb24iOiJmaXh0dXJlIiwidG9vbGNoYWluX2hhc2giOiJzaGEyNTY6MDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMCJ9LCJnZW9tZXRyeV9hc3NldHMiOlt7ImFzc2V0X2lkIjoic2hhMjU2OjIwMDUyMzQxNGRlMjgxYThhMTU1MDg2YTU0Y2QwZjg4MGY2NDA0ODU3NjVhNDhjY2RkOGVjMjMwZjY3ZGQxN2MiLCJhc3NldF90b19zY2VuZSI6WzEwMDAsMCwwLDAsMCwwLC0xMDAwLDAsMCwxMDAwLDAsMCwwLDAsMCwxXSwiYnl0ZV9sZW5ndGgiOjEwMjAsImNvbnRlbnRfaGFzaCI6InNoYTI1NjoyMDA1MjM0MTRkZTI4MWE4YTE1NTA4NmE1NGNkMGY4ODBmNjQwNDg1NzY1YTQ4Y2NkZDhlYzIzMGY2N2RkMTdjIiwibWVkaWFfdHlwZSI6Im1vZGVsL2dsdGYtYmluYXJ5Iiwic2NlbmVfbG9jYWxfYm91bmRzIjp7Im1heCI6WzEwMDAsMCwxMDAwXSwibWluIjpbMCwtMTAwMCwwXX0sInRlc3NlbGxhdGlvbiI6eyJhbmd1bGFyX3RvbGVyYW5jZSI6MC4yMiwibGluZWFyX3RvbGVyYW5jZSI6MC4zNX0sInVyaSI6Imdlb21ldHJ5L3NoYTI1Ni0yMDA1MjM0MTRkZTI4MWE4YTE1NTA4NmE1NGNkMGY4ODBmNjQwNDg1NzY1YTQ4Y2NkZDhlYzIzMGY2N2RkMTdjLmdsYiJ9XSwibGlnaHRzIjpbXSwibm9kZXMiOlt7ImFwcGVhcmFuY2Vfb3ZlcnJpZGVfaWQiOm51bGwsImRlZmluaXRpb25faWQiOiJkZWZpbml0aW9uL3Jvb3QvYXNzZW1ibHkvcm9vdF9hc3NlbWJseSIsIm5hbWUiOiJyb290Iiwibm9kZV9pZCI6Imluc3RhbmNlL3Jvb3QiLCJvcmRlciI6MCwicGFyZW50X25vZGVfaWQiOm51bGwsInNka19tZXRhZGF0YSI6e30sInNlbGVjdGFibGUiOnRydWUsInNvdXJjZSI6eyJjb21wb25lbnRfcGF0aCI6W10sImtpbmQiOiJwcm9kdWN0X29jY3VycmVuY2UiLCJyb290X2lkIjoicm9vdCJ9LCJ0cmFuc2Zvcm0iOnsib3JpZ2luIjpbMCwwLDBdLCJ4X2F4aXMiOlsxLDAsMF0sInlfYXhpcyI6WzAsMSwwXSwiel9heGlzIjpbMCwwLDFdfSwidmlzaWJsZSI6dHJ1ZX0seyJhcHBlYXJhbmNlX292ZXJyaWRlX2lkIjpudWxsLCJkZWZpbml0aW9uX2lkIjoiZGVmaW5pdGlvbi9yb290L3BhcnQvc2hhcmVkX3BhcnQiLCJuYW1lIjoibGVmdCIsIm5vZGVfaWQiOiJpbnN0YW5jZS9yb290L2xlZnQiLCJvcmRlciI6MCwicGFyZW50X25vZGVfaWQiOiJpbnN0YW5jZS9yb290Iiwic2RrX21ldGFkYXRhIjp7fSwic2VsZWN0YWJsZSI6dHJ1ZSwic291cmNlIjp7ImNvbXBvbmVudF9wYXRoIjpbImxlZnQiXSwia2luZCI6InByb2R1Y3Rfb2NjdXJyZW5jZSIsInJvb3RfaWQiOiJyb290In0sInRyYW5zZm9ybSI6eyJvcmlnaW4iOlstMTAwMCwwLDBdLCJ4X2F4aXMiOlsxLDAsMF0sInlfYXhpcyI6WzAsMSwwXSwiel9heGlzIjpbMCwwLDFdfSwidmlzaWJsZSI6dHJ1ZX0seyJhcHBlYXJhbmNlX292ZXJyaWRlX2lkIjpudWxsLCJkZWZpbml0aW9uX2lkIjoiZGVmaW5pdGlvbi9yb290L3BhcnQvc2hhcmVkX3BhcnQiLCJuYW1lIjoicmlnaHQiLCJub2RlX2lkIjoiaW5zdGFuY2Uvcm9vdC9yaWdodCIsIm9yZGVyIjoxLCJwYXJlbnRfbm9kZV9pZCI6Imluc3RhbmNlL3Jvb3QiLCJzZGtfbWV0YWRhdGEiOnt9LCJzZWxlY3RhYmxlIjp0cnVlLCJzb3VyY2UiOnsiY29tcG9uZW50X3BhdGgiOlsicmlnaHQiXSwia2luZCI6InByb2R1Y3Rfb2NjdXJyZW5jZSIsInJvb3RfaWQiOiJyb290In0sInRyYW5zZm9ybSI6eyJvcmlnaW4iOlsxMDAwLDAsMF0sInhfYXhpcyI6WzEsMCwwXSwieV9heGlzIjpbMCwxLDBdLCJ6X2F4aXMiOlswLDAsMV19LCJ2aXNpYmxlIjp0cnVlfV0sInNjZW5lX2lkIjoicmVwZWF0ZWRfZml4dHVyZSIsInNjaGVtYV92ZXJzaW9uIjoiMS4wIiwic291cmNlIjp7ImtpbmQiOiJtYW51YWwiLCJzb3VyY2VfaWQiOiJmaXh0dXJlIn19", + "name": "repeated_part_instance", + "revision": "sha256:5e8dede04a1619a2ba45147d94d9f979ae9bac44a510ae37ee00d0e5643b7203", + "sha256": "sha256:1b126b4a74a487c496c97b0410ceffa1ee7d96cee36045461ef748c48e2e0abb" + } + ], + "scene_field_cases": [ + { + "expected": { + "code": "schema_invalid", + "path": "" + }, + "mutations": [ + { + "operation": "delete", + "path": [ + "schema_version" + ] + } + ], + "name": "missing_schema_version", + "recompute_revision": false, + "valid": false + }, + { + "expected": { + "code": "schema_invalid", + "path": "" + }, + "mutations": [ + { + "operation": "delete", + "path": [ + "extensions_used" + ] + } + ], + "name": "missing_extensions_used", + "recompute_revision": false, + "valid": false + }, + { + "expected": { + "code": "schema_invalid", + "path": "" + }, + "mutations": [ + { + "operation": "delete", + "path": [ + "extensions_required" + ] + } + ], + "name": "missing_extensions_required", + "recompute_revision": false, + "valid": false + }, + { + "expected": { + "code": "schema_invalid", + "path": "" + }, + "mutations": [ + { + "operation": "delete", + "path": [ + "extensions" + ] + } + ], + "name": "missing_extensions", + "recompute_revision": false, + "valid": false + }, + { + "expected": { + "code": "schema_invalid", + "path": "" + }, + "mutations": [ + { + "operation": "delete", + "path": [ + "scene_id" + ] + } + ], + "name": "missing_scene_id", + "recompute_revision": false, + "valid": false + }, + { + "expected": { + "code": "schema_invalid", + "path": "" + }, + "mutations": [ + { + "operation": "delete", + "path": [ + "revision" + ] + } + ], + "name": "missing_revision", + "recompute_revision": false, + "valid": false + }, + { + "expected": { + "code": "schema_invalid", + "path": "" + }, + "mutations": [ + { + "operation": "delete", + "path": [ + "generator" + ] + } + ], + "name": "missing_generator", + "recompute_revision": false, + "valid": false + }, + { + "expected": { + "code": "schema_invalid", + "path": "" + }, + "mutations": [ + { + "operation": "delete", + "path": [ + "source" + ] + } + ], + "name": "missing_source", + "recompute_revision": false, + "valid": false + }, + { + "expected": { + "code": "schema_invalid", + "path": "" + }, + "mutations": [ + { + "operation": "delete", + "path": [ + "coordinate_system" + ] + } + ], + "name": "missing_coordinate_system", + "recompute_revision": false, + "valid": false + }, + { + "expected": { + "code": "schema_invalid", + "path": "" + }, + "mutations": [ + { + "operation": "delete", + "path": [ + "compile_options" + ] + } + ], + "name": "missing_compile_options", + "recompute_revision": false, + "valid": false + }, + { + "expected": { + "code": "schema_invalid", + "path": "" + }, + "mutations": [ + { + "operation": "delete", + "path": [ + "definitions" + ] + } + ], + "name": "missing_definitions", + "recompute_revision": false, + "valid": false + }, + { + "expected": { + "code": "schema_invalid", + "path": "" + }, + "mutations": [ + { + "operation": "delete", + "path": [ + "nodes" + ] + } + ], + "name": "missing_nodes", + "recompute_revision": false, + "valid": false + }, + { + "expected": { + "code": "schema_invalid", + "path": "" + }, + "mutations": [ + { + "operation": "delete", + "path": [ + "geometry_assets" + ] + } + ], + "name": "missing_geometry_assets", + "recompute_revision": false, + "valid": false + }, + { + "expected": { + "code": "schema_invalid", + "path": "" + }, + "mutations": [ + { + "operation": "delete", + "path": [ + "edge_assets" + ] + } + ], + "name": "missing_edge_assets", + "recompute_revision": false, + "valid": false + }, + { + "expected": { + "code": "schema_invalid", + "path": "" + }, + "mutations": [ + { + "operation": "delete", + "path": [ + "appearances" + ] + } + ], + "name": "missing_appearances", + "recompute_revision": false, + "valid": false + }, + { + "expected": { + "code": "schema_invalid", + "path": "" + }, + "mutations": [ + { + "operation": "delete", + "path": [ + "entity_assets" + ] + } + ], + "name": "missing_entity_assets", + "recompute_revision": false, + "valid": false + }, + { + "expected": { + "code": "schema_invalid", + "path": "" + }, + "mutations": [ + { + "operation": "delete", + "path": [ + "connectors" + ] + } + ], + "name": "missing_connectors", + "recompute_revision": false, + "valid": false + }, + { + "expected": { + "code": "schema_invalid", + "path": "" + }, + "mutations": [ + { + "operation": "delete", + "path": [ + "cameras" + ] + } + ], + "name": "missing_cameras", + "recompute_revision": false, + "valid": false + }, + { + "expected": { + "code": "schema_invalid", + "path": "" + }, + "mutations": [ + { + "operation": "delete", + "path": [ + "lights" + ] + } + ], + "name": "missing_lights", + "recompute_revision": false, + "valid": false + }, + { + "expected": { + "code": "schema_invalid", + "path": "" + }, + "mutations": [ + { + "operation": "delete", + "path": [ + "annotations" + ] + } + ], + "name": "missing_annotations", + "recompute_revision": false, + "valid": false + }, + { + "expected": { + "code": "schema_invalid", + "path": "" + }, + "mutations": [ + { + "operation": "delete", + "path": [ + "diagnostics" + ] + } + ], + "name": "missing_diagnostics", + "recompute_revision": false, + "valid": false + }, + { + "expected": { + "code": "schema_invalid", + "path": "/compile_options" + }, + "mutations": [ + { + "operation": "delete", + "path": [ + "compile_options", + "linear_tolerance" + ] + } + ], + "name": "missing_compile_options_linear_tolerance", + "recompute_revision": false, + "valid": false + }, + { + "expected": { + "code": "schema_invalid", + "path": "/compile_options" + }, + "mutations": [ + { + "operation": "delete", + "path": [ + "compile_options", + "angular_tolerance" + ] + } + ], + "name": "missing_compile_options_angular_tolerance", + "recompute_revision": false, + "valid": false + }, + { + "expected": { + "code": "schema_invalid", + "path": "/compile_options" + }, + "mutations": [ + { + "operation": "delete", + "path": [ + "compile_options", + "embed_source" + ] + } + ], + "name": "missing_compile_options_embed_source", + "recompute_revision": false, + "valid": false + }, + { + "expected": { + "code": "schema_invalid", + "path": "/compile_options" + }, + "mutations": [ + { + "operation": "delete", + "path": [ + "compile_options", + "embed_presentation" + ] + } + ], + "name": "missing_compile_options_embed_presentation", + "recompute_revision": false, + "valid": false + }, + { + "expected": { + "code": "schema_invalid", + "path": "/definitions/0" + }, + "mutations": [ + { + "operation": "delete", + "path": [ + "definitions", + 0, + "definition_id" + ] + } + ], + "name": "missing_definitions_0_definition_id", + "recompute_revision": false, + "valid": false + }, + { + "expected": { + "code": "schema_invalid", + "path": "/definitions/0" + }, + "mutations": [ + { + "operation": "delete", + "path": [ + "definitions", + 0, + "kind" + ] + } + ], + "name": "missing_definitions_0_kind", + "recompute_revision": false, + "valid": false + }, + { + "expected": { + "code": "schema_invalid", + "path": "/definitions/0" + }, + "mutations": [ + { + "operation": "delete", + "path": [ + "definitions", + 0, + "name" + ] + } + ], + "name": "missing_definitions_0_name", + "recompute_revision": false, + "valid": false + }, + { + "expected": { + "code": "schema_invalid", + "path": "/definitions/0" + }, + "mutations": [ + { + "operation": "delete", + "path": [ + "definitions", + 0, + "source" + ] + } + ], + "name": "missing_definitions_0_source", + "recompute_revision": false, + "valid": false + }, + { + "expected": { + "code": "schema_invalid", + "path": "/definitions/0" + }, + "mutations": [ + { + "operation": "delete", + "path": [ + "definitions", + 0, + "sdk_metadata" + ] + } + ], + "name": "missing_definitions_0_sdk_metadata", + "recompute_revision": false, + "valid": false + }, + { + "expected": { + "code": "schema_invalid", + "path": "/nodes/0" + }, + "mutations": [ + { + "operation": "delete", + "path": [ + "nodes", + 0, + "node_id" + ] + } + ], + "name": "missing_nodes_0_node_id", + "recompute_revision": false, + "valid": false + }, + { + "expected": { + "code": "schema_invalid", + "path": "/nodes/0" + }, + "mutations": [ + { + "operation": "delete", + "path": [ + "nodes", + 0, + "parent_node_id" + ] + } + ], + "name": "missing_nodes_0_parent_node_id", + "recompute_revision": false, + "valid": false + }, + { + "expected": { + "code": "schema_invalid", + "path": "/nodes/0" + }, + "mutations": [ + { + "operation": "delete", + "path": [ + "nodes", + 0, + "order" + ] + } + ], + "name": "missing_nodes_0_order", + "recompute_revision": false, + "valid": false + }, + { + "expected": { + "code": "schema_invalid", + "path": "/nodes/0" + }, + "mutations": [ + { + "operation": "delete", + "path": [ + "nodes", + 0, + "definition_id" + ] + } + ], + "name": "missing_nodes_0_definition_id", + "recompute_revision": false, + "valid": false + }, + { + "expected": { + "code": "schema_invalid", + "path": "/nodes/0" + }, + "mutations": [ + { + "operation": "delete", + "path": [ + "nodes", + 0, + "name" + ] + } + ], + "name": "missing_nodes_0_name", + "recompute_revision": false, + "valid": false + }, + { + "expected": { + "code": "schema_invalid", + "path": "/nodes/0" + }, + "mutations": [ + { + "operation": "delete", + "path": [ + "nodes", + 0, + "transform" + ] + } + ], + "name": "missing_nodes_0_transform", + "recompute_revision": false, + "valid": false + }, + { + "expected": { + "code": "schema_invalid", + "path": "/nodes/0" + }, + "mutations": [ + { + "operation": "delete", + "path": [ + "nodes", + 0, + "visible" + ] + } + ], + "name": "missing_nodes_0_visible", + "recompute_revision": false, + "valid": false + }, + { + "expected": { + "code": "schema_invalid", + "path": "/nodes/0" + }, + "mutations": [ + { + "operation": "delete", + "path": [ + "nodes", + 0, + "selectable" + ] + } + ], + "name": "missing_nodes_0_selectable", + "recompute_revision": false, + "valid": false + }, + { + "expected": { + "code": "schema_invalid", + "path": "/nodes/0" + }, + "mutations": [ + { + "operation": "delete", + "path": [ + "nodes", + 0, + "appearance_override_id" + ] + } + ], + "name": "missing_nodes_0_appearance_override_id", + "recompute_revision": false, + "valid": false + }, + { + "expected": { + "code": "schema_invalid", + "path": "/nodes/0" + }, + "mutations": [ + { + "operation": "delete", + "path": [ + "nodes", + 0, + "source" + ] + } + ], + "name": "missing_nodes_0_source", + "recompute_revision": false, + "valid": false + }, + { + "expected": { + "code": "schema_invalid", + "path": "/nodes/0" + }, + "mutations": [ + { + "operation": "delete", + "path": [ + "nodes", + 0, + "sdk_metadata" + ] + } + ], + "name": "missing_nodes_0_sdk_metadata", + "recompute_revision": false, + "valid": false + }, + { + "expected": { + "code": "schema_invalid", + "path": "/appearances/0" + }, + "mutations": [ + { + "operation": "delete", + "path": [ + "appearances", + 0, + "appearance_id" + ] + } + ], + "name": "missing_appearances_0_appearance_id", + "recompute_revision": false, + "valid": false + }, + { + "expected": { + "code": "schema_invalid", + "path": "/appearances/0" + }, + "mutations": [ + { + "operation": "delete", + "path": [ + "appearances", + 0, + "name" + ] + } + ], + "name": "missing_appearances_0_name", + "recompute_revision": false, + "valid": false + }, + { + "expected": { + "code": "schema_invalid", + "path": "/appearances/0" + }, + "mutations": [ + { + "operation": "delete", + "path": [ + "appearances", + 0, + "source" + ] + } + ], + "name": "missing_appearances_0_source", + "recompute_revision": false, + "valid": false + }, + { + "expected": { + "code": "schema_invalid", + "path": "/appearances/0" + }, + "mutations": [ + { + "operation": "delete", + "path": [ + "appearances", + 0, + "base_color" + ] + } + ], + "name": "missing_appearances_0_base_color", + "recompute_revision": false, + "valid": false + }, + { + "expected": { + "code": "schema_invalid", + "path": "/appearances/0" + }, + "mutations": [ + { + "operation": "delete", + "path": [ + "appearances", + 0, + "metallic" + ] + } + ], + "name": "missing_appearances_0_metallic", + "recompute_revision": false, + "valid": false + }, + { + "expected": { + "code": "schema_invalid", + "path": "/appearances/0" + }, + "mutations": [ + { + "operation": "delete", + "path": [ + "appearances", + 0, + "roughness" + ] + } + ], + "name": "missing_appearances_0_roughness", + "recompute_revision": false, + "valid": false + }, + { + "expected": { + "code": "schema_invalid", + "path": "/appearances/0" + }, + "mutations": [ + { + "operation": "delete", + "path": [ + "appearances", + 0, + "alpha_mode" + ] + } + ], + "name": "missing_appearances_0_alpha_mode", + "recompute_revision": false, + "valid": false + }, + { + "expected": { + "code": "schema_invalid", + "path": "/appearances/0" + }, + "mutations": [ + { + "operation": "delete", + "path": [ + "appearances", + 0, + "double_sided" + ] + } + ], + "name": "missing_appearances_0_double_sided", + "recompute_revision": false, + "valid": false + }, + { + "expected": { + "code": "schema_invalid", + "path": "/appearances/0" + }, + "mutations": [ + { + "operation": "delete", + "path": [ + "appearances", + 0, + "edge_color" + ] + } + ], + "name": "missing_appearances_0_edge_color", + "recompute_revision": false, + "valid": false + }, + { + "expected": { + "code": "schema_invalid", + "path": "/appearances/0" + }, + "mutations": [ + { + "operation": "delete", + "path": [ + "appearances", + 0, + "sdk_metadata" + ] + } + ], + "name": "missing_appearances_0_sdk_metadata", + "recompute_revision": false, + "valid": false + }, + { + "expected": { + "code": "schema_invalid", + "path": "/geometry_assets/0" + }, + "mutations": [ + { + "operation": "delete", + "path": [ + "geometry_assets", + 0, + "asset_id" + ] + } + ], + "name": "missing_geometry_assets_0_asset_id", + "recompute_revision": false, + "valid": false + }, + { + "expected": { + "code": "schema_invalid", + "path": "/geometry_assets/0" + }, + "mutations": [ + { + "operation": "delete", + "path": [ + "geometry_assets", + 0, + "uri" + ] + } + ], + "name": "missing_geometry_assets_0_uri", + "recompute_revision": false, + "valid": false + }, + { + "expected": { + "code": "schema_invalid", + "path": "/geometry_assets/0" + }, + "mutations": [ + { + "operation": "delete", + "path": [ + "geometry_assets", + 0, + "media_type" + ] + } + ], + "name": "missing_geometry_assets_0_media_type", + "recompute_revision": false, + "valid": false + }, + { + "expected": { + "code": "schema_invalid", + "path": "/geometry_assets/0" + }, + "mutations": [ + { + "operation": "delete", + "path": [ + "geometry_assets", + 0, + "byte_length" + ] + } + ], + "name": "missing_geometry_assets_0_byte_length", + "recompute_revision": false, + "valid": false + }, + { + "expected": { + "code": "schema_invalid", + "path": "/geometry_assets/0" + }, + "mutations": [ + { + "operation": "delete", + "path": [ + "geometry_assets", + 0, + "content_hash" + ] + } + ], + "name": "missing_geometry_assets_0_content_hash", + "recompute_revision": false, + "valid": false + }, + { + "expected": { + "code": "schema_invalid", + "path": "/geometry_assets/0" + }, + "mutations": [ + { + "operation": "delete", + "path": [ + "geometry_assets", + 0, + "scene_local_bounds" + ] + } + ], + "name": "missing_geometry_assets_0_scene_local_bounds", + "recompute_revision": false, + "valid": false + }, + { + "expected": { + "code": "schema_invalid", + "path": "/geometry_assets/0" + }, + "mutations": [ + { + "operation": "delete", + "path": [ + "geometry_assets", + 0, + "asset_to_scene" + ] + } + ], + "name": "missing_geometry_assets_0_asset_to_scene", + "recompute_revision": false, + "valid": false + }, + { + "expected": { + "code": "schema_invalid", + "path": "/geometry_assets/0" + }, + "mutations": [ + { + "operation": "delete", + "path": [ + "geometry_assets", + 0, + "tessellation" + ] + } + ], + "name": "missing_geometry_assets_0_tessellation", + "recompute_revision": false, + "valid": false + }, + { + "expected": { + "code": "schema_invalid", + "path": "/entity_assets/0" + }, + "mutations": [ + { + "operation": "delete", + "path": [ + "entity_assets", + 0, + "entity_asset_id" + ] + } + ], + "name": "missing_entity_assets_0_entity_asset_id", + "recompute_revision": false, + "valid": false + }, + { + "expected": { + "code": "schema_invalid", + "path": "/entity_assets/0" + }, + "mutations": [ + { + "operation": "delete", + "path": [ + "entity_assets", + 0, + "uri" + ] + } + ], + "name": "missing_entity_assets_0_uri", + "recompute_revision": false, + "valid": false + }, + { + "expected": { + "code": "schema_invalid", + "path": "/entity_assets/0" + }, + "mutations": [ + { + "operation": "delete", + "path": [ + "entity_assets", + 0, + "media_type" + ] + } + ], + "name": "missing_entity_assets_0_media_type", + "recompute_revision": false, + "valid": false + }, + { + "expected": { + "code": "schema_invalid", + "path": "/entity_assets/0" + }, + "mutations": [ + { + "operation": "delete", + "path": [ + "entity_assets", + 0, + "byte_length" + ] + } + ], + "name": "missing_entity_assets_0_byte_length", + "recompute_revision": false, + "valid": false + }, + { + "expected": { + "code": "schema_invalid", + "path": "/entity_assets/0" + }, + "mutations": [ + { + "operation": "delete", + "path": [ + "entity_assets", + 0, + "content_hash" + ] + } + ], + "name": "missing_entity_assets_0_content_hash", + "recompute_revision": false, + "valid": false + }, + { + "expected": { + "code": "schema_invalid", + "path": "" + }, + "mutations": [ + { + "operation": "add", + "path": [ + "unknown" + ], + "value": true + } + ], + "name": "unknown_scene_field", + "recompute_revision": false, + "valid": false + }, + { + "expected": { + "code": "schema_invalid", + "path": "/generator" + }, + "mutations": [ + { + "operation": "add", + "path": [ + "generator", + "unknown" + ], + "value": true + } + ], + "name": "unknown_generator_field", + "recompute_revision": false, + "valid": false + }, + { + "expected": { + "code": "schema_invalid", + "path": "/compile_options" + }, + "mutations": [ + { + "operation": "add", + "path": [ + "compile_options", + "unknown" + ], + "value": true + } + ], + "name": "unknown_compile_options_field", + "recompute_revision": false, + "valid": false + }, + { + "expected": { + "code": "schema_invalid", + "path": "/definitions/0" + }, + "mutations": [ + { + "operation": "add", + "path": [ + "definitions", + 0, + "unknown" + ], + "value": true + } + ], + "name": "unknown_definition_field", + "recompute_revision": false, + "valid": false + }, + { + "expected": { + "code": "schema_invalid", + "path": "/nodes/0" + }, + "mutations": [ + { + "operation": "add", + "path": [ + "nodes", + 0, + "unknown" + ], + "value": true + } + ], + "name": "unknown_node_field", + "recompute_revision": false, + "valid": false + }, + { + "expected": { + "code": "schema_invalid", + "path": "/appearances/0" + }, + "mutations": [ + { + "operation": "add", + "path": [ + "appearances", + 0, + "unknown" + ], + "value": true + } + ], + "name": "unknown_appearance_field", + "recompute_revision": false, + "valid": false + }, + { + "expected": { + "code": "schema_invalid", + "path": "/presentation_source" + }, + "mutations": [ + { + "operation": "add", + "path": [ + "presentation_source" + ], + "value": null + } + ], + "name": "optional_presentation_source_null", + "recompute_revision": false, + "valid": false + }, + { + "expected": { + "code": "schema_invalid", + "path": "/source" + }, + "mutations": [ + { + "operation": "set", + "path": [ + "source", + "kind" + ], + "value": "unknown" + } + ], + "name": "unknown_scene_source_kind", + "recompute_revision": false, + "valid": false + }, + { + "expected": { + "code": "schema_invalid", + "path": "/definitions/0/source" + }, + "mutations": [ + { + "operation": "set", + "path": [ + "definitions", + 0, + "source", + "kind" + ], + "value": "unknown" + } + ], + "name": "unknown_definition_source_kind", + "recompute_revision": false, + "valid": false + }, + { + "expected": { + "code": "schema_invalid", + "path": "/nodes/0/source" + }, + "mutations": [ + { + "operation": "delete", + "path": [ + "nodes", + 0, + "source", + "kind" + ] + } + ], + "name": "missing_node_source_kind", + "recompute_revision": false, + "valid": false + }, + { + "expected": { + "code": "schema_invalid", + "path": "/schema_version" + }, + "mutations": [ + { + "operation": "set", + "path": [ + "schema_version" + ], + "value": "2.0" + } + ], + "name": "unsupported_scene_schema_version", + "recompute_revision": false, + "valid": false + }, + { + "expected": { + "code": "schema_invalid", + "path": "/generator/profile" + }, + "mutations": [ + { + "operation": "set", + "path": [ + "generator", + "profile" + ], + "value": "scene-2.0" + } + ], + "name": "unsupported_generator_profile", + "recompute_revision": false, + "valid": false + }, + { + "expected": { + "code": "source_matrix_invalid", + "path": "/nodes/0/visible" + }, + "mutations": [ + { + "operation": "set", + "path": [ + "nodes", + 0, + "visible" + ], + "value": false + } + ], + "name": "no_presentation_visibility_override", + "recompute_revision": true, + "valid": false + }, + { + "expected": { + "code": "source_matrix_invalid", + "path": "/nodes/0/selectable" + }, + "mutations": [ + { + "operation": "set", + "path": [ + "nodes", + 0, + "selectable" + ], + "value": false + } + ], + "name": "no_presentation_selectability_override", + "recompute_revision": true, + "valid": false + }, + { + "expected": { + "code": "source_matrix_invalid", + "path": "/nodes/0/appearance_override_id" + }, + "mutations": [ + { + "operation": "set", + "path": [ + "nodes", + 0, + "appearance_override_id" + ], + "value": "appearance/evaluated/bf1ca6d60af21767ace1419b6055217c8714589b79090d270aabe512230efa3a" + } + ], + "name": "no_presentation_appearance_override", + "recompute_revision": true, + "valid": false + }, + { + "expected": { + "code": "source_matrix_invalid", + "path": "/cameras/0" + }, + "mutations": [ + { + "operation": "set", + "path": [ + "cameras" + ], + "value": [ + { + "camera_id": "camera/fixture/overview", + "far": 1000, + "name": "overview", + "near": 1, + "parent_node_id": null, + "projection": "perspective", + "transform": { + "origin": [ + 10, + -10, + 10 + ], + "x_axis": [ + 1, + 0, + 0 + ], + "y_axis": [ + 0, + 1, + 0 + ], + "z_axis": [ + 0, + 0, + 1 + ] + }, + "vertical_fov_degrees": 45 + } + ] + } + ], + "name": "no_presentation_camera", + "recompute_revision": true, + "valid": false + }, + { + "expected": null, + "mutations": [ + { + "operation": "set", + "path": [ + "nodes", + 0, + "name" + ], + "value": "Named occurrence" + }, + { + "operation": "set", + "path": [ + "appearances", + 0, + "name" + ], + "value": "Named appearance" + }, + { + "operation": "set", + "path": [ + "appearances", + 0, + "appearance_id" + ], + "value": "appearance/evaluated/b1da40b9e3e5aa806d7dc04390198809bcbb453889d3f43979ad9de191456b37" + }, + { + "operation": "set", + "path": [ + "definitions", + 0, + "appearance_id" + ], + "value": "appearance/evaluated/b1da40b9e3e5aa806d7dc04390198809bcbb453889d3f43979ad9de191456b37" + } + ], + "name": "nullable_fields_nonnull", + "recompute_revision": true, + "valid": true + } + ], + "scene_shape_cases": [ + { + "blob_uris": [ + "edges/sha256-6ce481db1fe13bcfe7a2374423e6c72d757d51c99168b287c52f53aa625ccd67.glb", + "entities/sha256-3897cc9ebdb2941835964a435821b556e01b175535cff8fc0eaaafb684c4ddf5.json", + "geometry/sha256-200523414de281a8a155086a54cd0f880f640485765a48ccdd8ec230f67dd17c.glb" + ], + "expected": null, + "expected_shape": { + "definition_count": 1, + "definition_occurrence_counts": { + "definition/root/shape/manual/fixture": 1 + }, + "edge_asset_count": 1, + "entity_asset_count": 1, + "geometry_asset_count": 1, + "maximum_depth": 0, + "node_count": 1, + "root_node_ids": [ + "instance/root" + ] + }, + "manifest_base64": "eyJhbm5vdGF0aW9ucyI6W10sImFwcGVhcmFuY2VzIjpbeyJhbHBoYV9tb2RlIjoib3BhcXVlIiwiYXBwZWFyYW5jZV9pZCI6ImFwcGVhcmFuY2UvZXZhbHVhdGVkL2JmMWNhNmQ2MGFmMjE3NjdhY2UxNDE5YjYwNTUyMTdjODcxNDU4OWI3OTA5MGQyNzBhYWJlNTEyMjMwZWZhM2EiLCJiYXNlX2NvbG9yIjpbMC43MiwwLjc1LDAuNzgsMV0sImRvdWJsZV9zaWRlZCI6ZmFsc2UsImVkZ2VfY29sb3IiOlswLjA4LDAuMDksMC4xLDFdLCJtZXRhbGxpYyI6MCwibmFtZSI6bnVsbCwicm91Z2huZXNzIjowLjU1LCJzZGtfbWV0YWRhdGEiOnt9LCJzb3VyY2UiOm51bGx9XSwiY2FtZXJhcyI6W10sImNvbXBpbGVfb3B0aW9ucyI6eyJhbmd1bGFyX3RvbGVyYW5jZSI6MC4yMiwiZW1iZWRfcHJlc2VudGF0aW9uIjpmYWxzZSwiZW1iZWRfc291cmNlIjpmYWxzZSwibGluZWFyX3RvbGVyYW5jZSI6MC4zNX0sImNvbm5lY3RvcnMiOltdLCJjb29yZGluYXRlX3N5c3RlbSI6eyJoYW5kZWRuZXNzIjoicmlnaHQiLCJsZW5ndGhfdW5pdCI6Im1tIiwidXBfYXhpcyI6IitaIn0sImRlZmluaXRpb25zIjpbeyJhcHBlYXJhbmNlX2lkIjoiYXBwZWFyYW5jZS9ldmFsdWF0ZWQvYmYxY2E2ZDYwYWYyMTc2N2FjZTE0MTliNjA1NTIxN2M4NzE0NTg5Yjc5MDkwZDI3MGFhYmU1MTIyMzBlZmEzYSIsImRlZmluaXRpb25faWQiOiJkZWZpbml0aW9uL3Jvb3Qvc2hhcGUvbWFudWFsL2ZpeHR1cmUiLCJlZGdlX2Fzc2V0X2lkIjoic2hhMjU2OjZjZTQ4MWRiMWZlMTNiY2ZlN2EyMzc0NDIzZTZjNzJkNzU3ZDUxYzk5MTY4YjI4N2M1MmY1M2FhNjI1Y2NkNjciLCJlbnRpdHlfYXNzZXRfaWQiOiJzaGEyNTY6Mzg5N2NjOWViZGIyOTQxODM1OTY0YTQzNTgyMWI1NTZlMDFiMTc1NTM1Y2ZmOGZjMGVhYWFmYjY4NGM0ZGRmNSIsImdlb21ldHJ5X2Fzc2V0X2lkIjoic2hhMjU2OjIwMDUyMzQxNGRlMjgxYThhMTU1MDg2YTU0Y2QwZjg4MGY2NDA0ODU3NjVhNDhjY2RkOGVjMjMwZjY3ZGQxN2MiLCJraW5kIjoic2hhcGUiLCJuYW1lIjpudWxsLCJzZGtfbWV0YWRhdGEiOnt9LCJzb3VyY2UiOnsia2luZCI6Im1hbnVhbCIsInJvb3RfaWQiOiJyb290Iiwic291cmNlX2lkIjoiZml4dHVyZSJ9fV0sImRpYWdub3N0aWNzIjpbXSwiZWRnZV9hc3NldHMiOlt7ImFzc2V0X2lkIjoic2hhMjU2OjZjZTQ4MWRiMWZlMTNiY2ZlN2EyMzc0NDIzZTZjNzJkNzU3ZDUxYzk5MTY4YjI4N2M1MmY1M2FhNjI1Y2NkNjciLCJhc3NldF90b19zY2VuZSI6WzEwMDAsMCwwLDAsMCwwLC0xMDAwLDAsMCwxMDAwLDAsMCwwLDAsMCwxXSwiYnl0ZV9sZW5ndGgiOjc0MCwiY29udGVudF9oYXNoIjoic2hhMjU2OjZjZTQ4MWRiMWZlMTNiY2ZlN2EyMzc0NDIzZTZjNzJkNzU3ZDUxYzk5MTY4YjI4N2M1MmY1M2FhNjI1Y2NkNjciLCJtZWRpYV90eXBlIjoibW9kZWwvZ2x0Zi1iaW5hcnkiLCJzY2VuZV9sb2NhbF9ib3VuZHMiOnsibWF4IjpbMTAwMCwwLDEwMDBdLCJtaW4iOlswLC0xMDAwLDBdfSwidGVzc2VsbGF0aW9uIjp7ImxpbmVhcl90b2xlcmFuY2UiOjAuMzV9LCJ1cmkiOiJlZGdlcy9zaGEyNTYtNmNlNDgxZGIxZmUxM2JjZmU3YTIzNzQ0MjNlNmM3MmQ3NTdkNTFjOTkxNjhiMjg3YzUyZjUzYWE2MjVjY2Q2Ny5nbGIifV0sImVudGl0eV9hc3NldHMiOlt7ImJ5dGVfbGVuZ3RoIjoxMTUxNywiY29udGVudF9oYXNoIjoic2hhMjU2OjM4OTdjYzllYmRiMjk0MTgzNTk2NGE0MzU4MjFiNTU2ZTAxYjE3NTUzNWNmZjhmYzBlYWFhZmI2ODRjNGRkZjUiLCJlbnRpdHlfYXNzZXRfaWQiOiJzaGEyNTY6Mzg5N2NjOWViZGIyOTQxODM1OTY0YTQzNTgyMWI1NTZlMDFiMTc1NTM1Y2ZmOGZjMGVhYWFmYjY4NGM0ZGRmNSIsIm1lZGlhX3R5cGUiOiJhcHBsaWNhdGlvbi92bmQuc2ltcGxlY2FkLmVudGl0aWVzK2pzb24iLCJ1cmkiOiJlbnRpdGllcy9zaGEyNTYtMzg5N2NjOWViZGIyOTQxODM1OTY0YTQzNTgyMWI1NTZlMDFiMTc1NTM1Y2ZmOGZjMGVhYWFmYjY4NGM0ZGRmNS5qc29uIn1dLCJleHRlbnNpb25zIjp7fSwiZXh0ZW5zaW9uc19yZXF1aXJlZCI6W10sImV4dGVuc2lvbnNfdXNlZCI6W10sImdlbmVyYXRvciI6eyJuYW1lIjoic2ltcGxlY2FkYXBpIiwib2NwX2JpbmRpbmdzX3ZlcnNpb24iOiJmaXh0dXJlIiwib2NwX3ZlcnNpb24iOiJmaXh0dXJlIiwicGxhdGZvcm1fdGFnIjoiZml4dHVyZSIsInByb2ZpbGUiOiJzY2VuZS0xLjAtb2NwLWdsYi0yIiwicHl0aG9uX2FiaSI6ImZpeHR1cmUiLCJzaW1wbGVjYWRhcGlfdmVyc2lvbiI6ImZpeHR1cmUiLCJ0b29sY2hhaW5faGFzaCI6InNoYTI1NjowMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwIn0sImdlb21ldHJ5X2Fzc2V0cyI6W3siYXNzZXRfaWQiOiJzaGEyNTY6MjAwNTIzNDE0ZGUyODFhOGExNTUwODZhNTRjZDBmODgwZjY0MDQ4NTc2NWE0OGNjZGQ4ZWMyMzBmNjdkZDE3YyIsImFzc2V0X3RvX3NjZW5lIjpbMTAwMCwwLDAsMCwwLDAsLTEwMDAsMCwwLDEwMDAsMCwwLDAsMCwwLDFdLCJieXRlX2xlbmd0aCI6MTAyMCwiY29udGVudF9oYXNoIjoic2hhMjU2OjIwMDUyMzQxNGRlMjgxYThhMTU1MDg2YTU0Y2QwZjg4MGY2NDA0ODU3NjVhNDhjY2RkOGVjMjMwZjY3ZGQxN2MiLCJtZWRpYV90eXBlIjoibW9kZWwvZ2x0Zi1iaW5hcnkiLCJzY2VuZV9sb2NhbF9ib3VuZHMiOnsibWF4IjpbMTAwMCwwLDEwMDBdLCJtaW4iOlswLC0xMDAwLDBdfSwidGVzc2VsbGF0aW9uIjp7ImFuZ3VsYXJfdG9sZXJhbmNlIjowLjIyLCJsaW5lYXJfdG9sZXJhbmNlIjowLjM1fSwidXJpIjoiZ2VvbWV0cnkvc2hhMjU2LTIwMDUyMzQxNGRlMjgxYThhMTU1MDg2YTU0Y2QwZjg4MGY2NDA0ODU3NjVhNDhjY2RkOGVjMjMwZjY3ZGQxN2MuZ2xiIn1dLCJsaWdodHMiOltdLCJub2RlcyI6W3siYXBwZWFyYW5jZV9vdmVycmlkZV9pZCI6bnVsbCwiZGVmaW5pdGlvbl9pZCI6ImRlZmluaXRpb24vcm9vdC9zaGFwZS9tYW51YWwvZml4dHVyZSIsIm5hbWUiOm51bGwsIm5vZGVfaWQiOiJpbnN0YW5jZS9yb290Iiwib3JkZXIiOjAsInBhcmVudF9ub2RlX2lkIjpudWxsLCJzZGtfbWV0YWRhdGEiOnt9LCJzZWxlY3RhYmxlIjp0cnVlLCJzb3VyY2UiOnsia2luZCI6InNoYXBlX3Jvb3QiLCJyb290X2lkIjoicm9vdCJ9LCJ0cmFuc2Zvcm0iOnsib3JpZ2luIjpbMCwwLDBdLCJ4X2F4aXMiOlsxLDAsMF0sInlfYXhpcyI6WzAsMSwwXSwiel9heGlzIjpbMCwwLDFdfSwidmlzaWJsZSI6dHJ1ZX1dLCJyZXZpc2lvbiI6InNoYTI1NjpmYWUxNWY0YWVlZjEyNzdjZDgzMWZmOGJmODNmOWIyNDRkNWM0MjI1ZTcyZWI3YmMyZTBlMzVkZDZjZjQzNjE0Iiwic2NlbmVfaWQiOiJmaXh0dXJlIiwic2NoZW1hX3ZlcnNpb24iOiIxLjAiLCJzb3VyY2UiOnsia2luZCI6Im1hbnVhbCIsInNvdXJjZV9pZCI6ImZpeHR1cmUifX0=", + "name": "minimal_standalone_shape", + "valid": true + }, + { + "blob_uris": [ + "edges/sha256-6ce481db1fe13bcfe7a2374423e6c72d757d51c99168b287c52f53aa625ccd67.glb", + "entities/sha256-ec65dbf3f594403b3969be03fb0047737db645cc3b473304b1403812bec5c2cb.json", + "geometry/sha256-200523414de281a8a155086a54cd0f880f640485765a48ccdd8ec230f67dd17c.glb" + ], + "expected": null, + "expected_shape": { + "definition_count": 3, + "definition_occurrence_counts": { + "definition/root/assembly/nested_assembly": 1, + "definition/root/assembly/root_assembly": 1, + "definition/root/part/shared_part": 1 + }, + "edge_asset_count": 1, + "entity_asset_count": 1, + "geometry_asset_count": 1, + "maximum_depth": 2, + "node_count": 3, + "root_node_ids": [ + "instance/root" + ] + }, + "manifest_base64": "eyJhbm5vdGF0aW9ucyI6W10sImFwcGVhcmFuY2VzIjpbeyJhbHBoYV9tb2RlIjoib3BhcXVlIiwiYXBwZWFyYW5jZV9pZCI6ImFwcGVhcmFuY2UvZXZhbHVhdGVkLzgxZjM4OWUyZThlMjcwYzllNGYwNzRlNDIxZTdiZTRhMGUxMDI2MTk2ZjVmZGVkODczMTkxZDlmOTFkYjA4NTIiLCJiYXNlX2NvbG9yIjpbMC43MiwwLjc1LDAuNzgsMV0sImRvdWJsZV9zaWRlZCI6ZmFsc2UsImVkZ2VfY29sb3IiOlswLjA4LDAuMDksMC4xLDFdLCJtZXRhbGxpYyI6MCwibmFtZSI6IkZpeHR1cmUgbWF0ZXJpYWwiLCJyb3VnaG5lc3MiOjAuNTUsInNka19tZXRhZGF0YSI6e30sInNvdXJjZSI6eyJraW5kIjoicHJvZHVjdF9tYXRlcmlhbCIsIm1hdGVyaWFsX2lkIjoiZml4dHVyZV9tYXRlcmlhbCIsInJvb3RfaWQiOiJyb290In19XSwiY2FtZXJhcyI6W10sImNvbXBpbGVfb3B0aW9ucyI6eyJhbmd1bGFyX3RvbGVyYW5jZSI6MC4yMiwiZW1iZWRfcHJlc2VudGF0aW9uIjpmYWxzZSwiZW1iZWRfc291cmNlIjpmYWxzZSwibGluZWFyX3RvbGVyYW5jZSI6MC4zNX0sImNvbm5lY3RvcnMiOlt7ImFuY2hvcl9raW5kIjoiZm9yd2FyZGVkIiwiY29ubmVjdG9yX2lkIjoibmVzdGVkX21vdW50IiwiY29ubmVjdG9yX3NuYXBzaG90X2lkIjoiY29ubmVjdG9yL3Jvb3QvYXNzZW1ibHkvbmVzdGVkX2Fzc2VtYmx5L25lc3RlZF9tb3VudCIsImZvcndhcmRlZF9mcm9tIjp7Im9mZnNldCI6bnVsbCwic291cmNlX2NvbXBvbmVudF9pZCI6InBhcnQiLCJzb3VyY2VfY29ubmVjdG9yX2lkIjoibW91bnQiLCJzb3VyY2VfY29ubmVjdG9yX3NuYXBzaG90X2lkIjoiY29ubmVjdG9yL3Jvb3QvcGFydC9zaGFyZWRfcGFydC9tb3VudCIsInNvdXJjZV9kZWZpbml0aW9uX2lkIjoiZGVmaW5pdGlvbi9yb290L3BhcnQvc2hhcmVkX3BhcnQifSwibG9jYWxfdHJhbnNmb3JtIjp7Im9yaWdpbiI6WzAsMCwwXSwieF9heGlzIjpbMSwwLDBdLCJ5X2F4aXMiOlswLDEsMF0sInpfYXhpcyI6WzAsMCwxXX0sIm5hbWUiOiJOZXN0ZWQgTW91bnQiLCJvd25lcl9kZWZpbml0aW9uX2lkIjoiZGVmaW5pdGlvbi9yb290L2Fzc2VtYmx5L25lc3RlZF9hc3NlbWJseSIsInNka19tZXRhZGF0YSI6e30sInNvdXJjZSI6eyJraW5kIjoibWFudWFsIiwic291cmNlX2lkIjoiZml4dHVyZSJ9fSx7ImFuY2hvcl9raW5kIjoiZm9yd2FyZGVkIiwiY29ubmVjdG9yX2lkIjoicm9vdF9tb3VudCIsImNvbm5lY3Rvcl9zbmFwc2hvdF9pZCI6ImNvbm5lY3Rvci9yb290L2Fzc2VtYmx5L3Jvb3RfYXNzZW1ibHkvcm9vdF9tb3VudCIsImZvcndhcmRlZF9mcm9tIjp7Im9mZnNldCI6eyJvcmlnaW4iOlswLDAsNV0sInhfYXhpcyI6WzEsMCwwXSwieV9heGlzIjpbMCwxLDBdLCJ6X2F4aXMiOlswLDAsMV19LCJzb3VyY2VfY29tcG9uZW50X2lkIjoic3ViYXNzZW1ibHkiLCJzb3VyY2VfY29ubmVjdG9yX2lkIjoibmVzdGVkX21vdW50Iiwic291cmNlX2Nvbm5lY3Rvcl9zbmFwc2hvdF9pZCI6ImNvbm5lY3Rvci9yb290L2Fzc2VtYmx5L25lc3RlZF9hc3NlbWJseS9uZXN0ZWRfbW91bnQiLCJzb3VyY2VfZGVmaW5pdGlvbl9pZCI6ImRlZmluaXRpb24vcm9vdC9hc3NlbWJseS9uZXN0ZWRfYXNzZW1ibHkifSwibG9jYWxfdHJhbnNmb3JtIjp7Im9yaWdpbiI6WzAsMCw1XSwieF9heGlzIjpbMSwwLDBdLCJ5X2F4aXMiOlswLDEsMF0sInpfYXhpcyI6WzAsMCwxXX0sIm5hbWUiOiJSb290IE1vdW50Iiwib3duZXJfZGVmaW5pdGlvbl9pZCI6ImRlZmluaXRpb24vcm9vdC9hc3NlbWJseS9yb290X2Fzc2VtYmx5Iiwic2RrX21ldGFkYXRhIjp7fSwic291cmNlIjp7ImtpbmQiOiJtYW51YWwiLCJzb3VyY2VfaWQiOiJmaXh0dXJlIn19LHsiYW5jaG9yX2tpbmQiOiJwbGFjZW1lbnQiLCJjb25uZWN0b3JfaWQiOiJtb3VudCIsImNvbm5lY3Rvcl9zbmFwc2hvdF9pZCI6ImNvbm5lY3Rvci9yb290L3BhcnQvc2hhcmVkX3BhcnQvbW91bnQiLCJsb2NhbF90cmFuc2Zvcm0iOnsib3JpZ2luIjpbMCwwLDBdLCJ4X2F4aXMiOlsxLDAsMF0sInlfYXhpcyI6WzAsMSwwXSwiel9heGlzIjpbMCwwLDFdfSwibmFtZSI6Ik1vdW50Iiwib3duZXJfZGVmaW5pdGlvbl9pZCI6ImRlZmluaXRpb24vcm9vdC9wYXJ0L3NoYXJlZF9wYXJ0Iiwic2RrX21ldGFkYXRhIjp7fSwic291cmNlIjp7ImtpbmQiOiJtYW51YWwiLCJzb3VyY2VfaWQiOiJmaXh0dXJlIn19XSwiY29vcmRpbmF0ZV9zeXN0ZW0iOnsiaGFuZGVkbmVzcyI6InJpZ2h0IiwibGVuZ3RoX3VuaXQiOiJtbSIsInVwX2F4aXMiOiIrWiJ9LCJkZWZpbml0aW9ucyI6W3siZGVmaW5pdGlvbl9pZCI6ImRlZmluaXRpb24vcm9vdC9hc3NlbWJseS9uZXN0ZWRfYXNzZW1ibHkiLCJraW5kIjoiYXNzZW1ibHkiLCJuYW1lIjoiTmVzdGVkIEFzc2VtYmx5Iiwic2RrX21ldGFkYXRhIjp7fSwic291cmNlIjp7ImtpbmQiOiJwcm9kdWN0X21hbnVhbCIsInJvb3RfaWQiOiJyb290Iiwic2VtYW50aWNfaWQiOiJuZXN0ZWRfYXNzZW1ibHkiLCJzZW1hbnRpY190eXBlIjoiQXNzZW1ibHkifX0seyJkZWZpbml0aW9uX2lkIjoiZGVmaW5pdGlvbi9yb290L2Fzc2VtYmx5L3Jvb3RfYXNzZW1ibHkiLCJraW5kIjoiYXNzZW1ibHkiLCJuYW1lIjoiUm9vdCBBc3NlbWJseSIsInNka19tZXRhZGF0YSI6e30sInNvdXJjZSI6eyJraW5kIjoicHJvZHVjdF9tYW51YWwiLCJyb290X2lkIjoicm9vdCIsInNlbWFudGljX2lkIjoicm9vdF9hc3NlbWJseSIsInNlbWFudGljX3R5cGUiOiJBc3NlbWJseSJ9fSx7ImFwcGVhcmFuY2VfaWQiOiJhcHBlYXJhbmNlL2V2YWx1YXRlZC84MWYzODllMmU4ZTI3MGM5ZTRmMDc0ZTQyMWU3YmU0YTBlMTAyNjE5NmY1ZmRlZDg3MzE5MWQ5ZjkxZGIwODUyIiwiZGVmaW5pdGlvbl9pZCI6ImRlZmluaXRpb24vcm9vdC9wYXJ0L3NoYXJlZF9wYXJ0IiwiZWRnZV9hc3NldF9pZCI6InNoYTI1Njo2Y2U0ODFkYjFmZTEzYmNmZTdhMjM3NDQyM2U2YzcyZDc1N2Q1MWM5OTE2OGIyODdjNTJmNTNhYTYyNWNjZDY3IiwiZW50aXR5X2Fzc2V0X2lkIjoic2hhMjU2OmVjNjVkYmYzZjU5NDQwM2IzOTY5YmUwM2ZiMDA0NzczN2RiNjQ1Y2MzYjQ3MzMwNGIxNDAzODEyYmVjNWMyY2IiLCJnZW9tZXRyeV9hc3NldF9pZCI6InNoYTI1NjoyMDA1MjM0MTRkZTI4MWE4YTE1NTA4NmE1NGNkMGY4ODBmNjQwNDg1NzY1YTQ4Y2NkZDhlYzIzMGY2N2RkMTdjIiwia2luZCI6InBhcnQiLCJuYW1lIjoiU2hhcmVkIFBhcnQiLCJzZGtfbWV0YWRhdGEiOnt9LCJzb3VyY2UiOnsia2luZCI6InByb2R1Y3RfbWFudWFsIiwicm9vdF9pZCI6InJvb3QiLCJzZW1hbnRpY19pZCI6InNoYXJlZF9wYXJ0Iiwic2VtYW50aWNfdHlwZSI6IlBhcnQifX1dLCJkaWFnbm9zdGljcyI6W10sImVkZ2VfYXNzZXRzIjpbeyJhc3NldF9pZCI6InNoYTI1Njo2Y2U0ODFkYjFmZTEzYmNmZTdhMjM3NDQyM2U2YzcyZDc1N2Q1MWM5OTE2OGIyODdjNTJmNTNhYTYyNWNjZDY3IiwiYXNzZXRfdG9fc2NlbmUiOlsxMDAwLDAsMCwwLDAsMCwtMTAwMCwwLDAsMTAwMCwwLDAsMCwwLDAsMV0sImJ5dGVfbGVuZ3RoIjo3NDAsImNvbnRlbnRfaGFzaCI6InNoYTI1Njo2Y2U0ODFkYjFmZTEzYmNmZTdhMjM3NDQyM2U2YzcyZDc1N2Q1MWM5OTE2OGIyODdjNTJmNTNhYTYyNWNjZDY3IiwibWVkaWFfdHlwZSI6Im1vZGVsL2dsdGYtYmluYXJ5Iiwic2NlbmVfbG9jYWxfYm91bmRzIjp7Im1heCI6WzEwMDAsMCwxMDAwXSwibWluIjpbMCwtMTAwMCwwXX0sInRlc3NlbGxhdGlvbiI6eyJsaW5lYXJfdG9sZXJhbmNlIjowLjM1fSwidXJpIjoiZWRnZXMvc2hhMjU2LTZjZTQ4MWRiMWZlMTNiY2ZlN2EyMzc0NDIzZTZjNzJkNzU3ZDUxYzk5MTY4YjI4N2M1MmY1M2FhNjI1Y2NkNjcuZ2xiIn1dLCJlbnRpdHlfYXNzZXRzIjpbeyJieXRlX2xlbmd0aCI6MTE1NDEsImNvbnRlbnRfaGFzaCI6InNoYTI1NjplYzY1ZGJmM2Y1OTQ0MDNiMzk2OWJlMDNmYjAwNDc3MzdkYjY0NWNjM2I0NzMzMDRiMTQwMzgxMmJlYzVjMmNiIiwiZW50aXR5X2Fzc2V0X2lkIjoic2hhMjU2OmVjNjVkYmYzZjU5NDQwM2IzOTY5YmUwM2ZiMDA0NzczN2RiNjQ1Y2MzYjQ3MzMwNGIxNDAzODEyYmVjNWMyY2IiLCJtZWRpYV90eXBlIjoiYXBwbGljYXRpb24vdm5kLnNpbXBsZWNhZC5lbnRpdGllcytqc29uIiwidXJpIjoiZW50aXRpZXMvc2hhMjU2LWVjNjVkYmYzZjU5NDQwM2IzOTY5YmUwM2ZiMDA0NzczN2RiNjQ1Y2MzYjQ3MzMwNGIxNDAzODEyYmVjNWMyY2IuanNvbiJ9XSwiZXh0ZW5zaW9ucyI6e30sImV4dGVuc2lvbnNfcmVxdWlyZWQiOltdLCJleHRlbnNpb25zX3VzZWQiOltdLCJnZW5lcmF0b3IiOnsibmFtZSI6InNpbXBsZWNhZGFwaSIsIm9jcF9iaW5kaW5nc192ZXJzaW9uIjoiZml4dHVyZSIsIm9jcF92ZXJzaW9uIjoiZml4dHVyZSIsInBsYXRmb3JtX3RhZyI6ImZpeHR1cmUiLCJwcm9maWxlIjoic2NlbmUtMS4wLW9jcC1nbGItMiIsInB5dGhvbl9hYmkiOiJmaXh0dXJlIiwic2ltcGxlY2FkYXBpX3ZlcnNpb24iOiJmaXh0dXJlIiwidG9vbGNoYWluX2hhc2giOiJzaGEyNTY6MDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMCJ9LCJnZW9tZXRyeV9hc3NldHMiOlt7ImFzc2V0X2lkIjoic2hhMjU2OjIwMDUyMzQxNGRlMjgxYThhMTU1MDg2YTU0Y2QwZjg4MGY2NDA0ODU3NjVhNDhjY2RkOGVjMjMwZjY3ZGQxN2MiLCJhc3NldF90b19zY2VuZSI6WzEwMDAsMCwwLDAsMCwwLC0xMDAwLDAsMCwxMDAwLDAsMCwwLDAsMCwxXSwiYnl0ZV9sZW5ndGgiOjEwMjAsImNvbnRlbnRfaGFzaCI6InNoYTI1NjoyMDA1MjM0MTRkZTI4MWE4YTE1NTA4NmE1NGNkMGY4ODBmNjQwNDg1NzY1YTQ4Y2NkZDhlYzIzMGY2N2RkMTdjIiwibWVkaWFfdHlwZSI6Im1vZGVsL2dsdGYtYmluYXJ5Iiwic2NlbmVfbG9jYWxfYm91bmRzIjp7Im1heCI6WzEwMDAsMCwxMDAwXSwibWluIjpbMCwtMTAwMCwwXX0sInRlc3NlbGxhdGlvbiI6eyJhbmd1bGFyX3RvbGVyYW5jZSI6MC4yMiwibGluZWFyX3RvbGVyYW5jZSI6MC4zNX0sInVyaSI6Imdlb21ldHJ5L3NoYTI1Ni0yMDA1MjM0MTRkZTI4MWE4YTE1NTA4NmE1NGNkMGY4ODBmNjQwNDg1NzY1YTQ4Y2NkZDhlYzIzMGY2N2RkMTdjLmdsYiJ9XSwibGlnaHRzIjpbXSwibm9kZXMiOlt7ImFwcGVhcmFuY2Vfb3ZlcnJpZGVfaWQiOm51bGwsImRlZmluaXRpb25faWQiOiJkZWZpbml0aW9uL3Jvb3QvYXNzZW1ibHkvcm9vdF9hc3NlbWJseSIsIm5hbWUiOiJyb290Iiwibm9kZV9pZCI6Imluc3RhbmNlL3Jvb3QiLCJvcmRlciI6MCwicGFyZW50X25vZGVfaWQiOm51bGwsInNka19tZXRhZGF0YSI6e30sInNlbGVjdGFibGUiOnRydWUsInNvdXJjZSI6eyJjb21wb25lbnRfcGF0aCI6W10sImtpbmQiOiJwcm9kdWN0X29jY3VycmVuY2UiLCJyb290X2lkIjoicm9vdCJ9LCJ0cmFuc2Zvcm0iOnsib3JpZ2luIjpbMCwwLDBdLCJ4X2F4aXMiOlsxLDAsMF0sInlfYXhpcyI6WzAsMSwwXSwiel9heGlzIjpbMCwwLDFdfSwidmlzaWJsZSI6dHJ1ZX0seyJhcHBlYXJhbmNlX292ZXJyaWRlX2lkIjpudWxsLCJkZWZpbml0aW9uX2lkIjoiZGVmaW5pdGlvbi9yb290L2Fzc2VtYmx5L25lc3RlZF9hc3NlbWJseSIsIm5hbWUiOiJzdWJhc3NlbWJseSIsIm5vZGVfaWQiOiJpbnN0YW5jZS9yb290L3N1YmFzc2VtYmx5Iiwib3JkZXIiOjAsInBhcmVudF9ub2RlX2lkIjoiaW5zdGFuY2Uvcm9vdCIsInNka19tZXRhZGF0YSI6e30sInNlbGVjdGFibGUiOnRydWUsInNvdXJjZSI6eyJjb21wb25lbnRfcGF0aCI6WyJzdWJhc3NlbWJseSJdLCJraW5kIjoicHJvZHVjdF9vY2N1cnJlbmNlIiwicm9vdF9pZCI6InJvb3QifSwidHJhbnNmb3JtIjp7Im9yaWdpbiI6WzAsMCwwXSwieF9heGlzIjpbMSwwLDBdLCJ5X2F4aXMiOlswLDEsMF0sInpfYXhpcyI6WzAsMCwxXX0sInZpc2libGUiOnRydWV9LHsiYXBwZWFyYW5jZV9vdmVycmlkZV9pZCI6bnVsbCwiZGVmaW5pdGlvbl9pZCI6ImRlZmluaXRpb24vcm9vdC9wYXJ0L3NoYXJlZF9wYXJ0IiwibmFtZSI6InBhcnQiLCJub2RlX2lkIjoiaW5zdGFuY2Uvcm9vdC9zdWJhc3NlbWJseS9wYXJ0Iiwib3JkZXIiOjAsInBhcmVudF9ub2RlX2lkIjoiaW5zdGFuY2Uvcm9vdC9zdWJhc3NlbWJseSIsInNka19tZXRhZGF0YSI6e30sInNlbGVjdGFibGUiOnRydWUsInNvdXJjZSI6eyJjb21wb25lbnRfcGF0aCI6WyJzdWJhc3NlbWJseSIsInBhcnQiXSwia2luZCI6InByb2R1Y3Rfb2NjdXJyZW5jZSIsInJvb3RfaWQiOiJyb290In0sInRyYW5zZm9ybSI6eyJvcmlnaW4iOlswLDAsMF0sInhfYXhpcyI6WzEsMCwwXSwieV9heGlzIjpbMCwxLDBdLCJ6X2F4aXMiOlswLDAsMV19LCJ2aXNpYmxlIjp0cnVlfV0sInJldmlzaW9uIjoic2hhMjU2OmU2NjhhZmMxMjZhM2M3NDc5ZjBmM2JlYzBkMWFmMWY4ODNlMjNlZTlhNzVkNTBhMTA0ZGRiMmQxYWJmNzBmMmQiLCJzY2VuZV9pZCI6Im5lc3RlZF9maXh0dXJlIiwic2NoZW1hX3ZlcnNpb24iOiIxLjAiLCJzb3VyY2UiOnsia2luZCI6Im1hbnVhbCIsInNvdXJjZV9pZCI6ImZpeHR1cmUifX0=", + "name": "nested_assembly", + "valid": true + }, + { + "blob_uris": [ + "edges/sha256-6ce481db1fe13bcfe7a2374423e6c72d757d51c99168b287c52f53aa625ccd67.glb", + "entities/sha256-ec65dbf3f594403b3969be03fb0047737db645cc3b473304b1403812bec5c2cb.json", + "geometry/sha256-200523414de281a8a155086a54cd0f880f640485765a48ccdd8ec230f67dd17c.glb" + ], + "expected": null, + "expected_shape": { + "definition_count": 2, + "definition_occurrence_counts": { + "definition/root/assembly/root_assembly": 1, + "definition/root/part/shared_part": 2 + }, + "edge_asset_count": 1, + "entity_asset_count": 1, + "geometry_asset_count": 1, + "maximum_depth": 1, + "node_count": 3, + "root_node_ids": [ + "instance/root" + ] + }, + "manifest_base64": "eyJhbm5vdGF0aW9ucyI6W10sImFwcGVhcmFuY2VzIjpbeyJhbHBoYV9tb2RlIjoib3BhcXVlIiwiYXBwZWFyYW5jZV9pZCI6ImFwcGVhcmFuY2UvZXZhbHVhdGVkLzgxZjM4OWUyZThlMjcwYzllNGYwNzRlNDIxZTdiZTRhMGUxMDI2MTk2ZjVmZGVkODczMTkxZDlmOTFkYjA4NTIiLCJiYXNlX2NvbG9yIjpbMC43MiwwLjc1LDAuNzgsMV0sImRvdWJsZV9zaWRlZCI6ZmFsc2UsImVkZ2VfY29sb3IiOlswLjA4LDAuMDksMC4xLDFdLCJtZXRhbGxpYyI6MCwibmFtZSI6IkZpeHR1cmUgbWF0ZXJpYWwiLCJyb3VnaG5lc3MiOjAuNTUsInNka19tZXRhZGF0YSI6e30sInNvdXJjZSI6eyJraW5kIjoicHJvZHVjdF9tYXRlcmlhbCIsIm1hdGVyaWFsX2lkIjoiZml4dHVyZV9tYXRlcmlhbCIsInJvb3RfaWQiOiJyb290In19XSwiY2FtZXJhcyI6W10sImNvbXBpbGVfb3B0aW9ucyI6eyJhbmd1bGFyX3RvbGVyYW5jZSI6MC4yMiwiZW1iZWRfcHJlc2VudGF0aW9uIjpmYWxzZSwiZW1iZWRfc291cmNlIjpmYWxzZSwibGluZWFyX3RvbGVyYW5jZSI6MC4zNX0sImNvbm5lY3RvcnMiOltdLCJjb29yZGluYXRlX3N5c3RlbSI6eyJoYW5kZWRuZXNzIjoicmlnaHQiLCJsZW5ndGhfdW5pdCI6Im1tIiwidXBfYXhpcyI6IitaIn0sImRlZmluaXRpb25zIjpbeyJkZWZpbml0aW9uX2lkIjoiZGVmaW5pdGlvbi9yb290L2Fzc2VtYmx5L3Jvb3RfYXNzZW1ibHkiLCJraW5kIjoiYXNzZW1ibHkiLCJuYW1lIjoiUm9vdCBBc3NlbWJseSIsInNka19tZXRhZGF0YSI6e30sInNvdXJjZSI6eyJraW5kIjoicHJvZHVjdF9tYW51YWwiLCJyb290X2lkIjoicm9vdCIsInNlbWFudGljX2lkIjoicm9vdF9hc3NlbWJseSIsInNlbWFudGljX3R5cGUiOiJBc3NlbWJseSJ9fSx7ImFwcGVhcmFuY2VfaWQiOiJhcHBlYXJhbmNlL2V2YWx1YXRlZC84MWYzODllMmU4ZTI3MGM5ZTRmMDc0ZTQyMWU3YmU0YTBlMTAyNjE5NmY1ZmRlZDg3MzE5MWQ5ZjkxZGIwODUyIiwiZGVmaW5pdGlvbl9pZCI6ImRlZmluaXRpb24vcm9vdC9wYXJ0L3NoYXJlZF9wYXJ0IiwiZWRnZV9hc3NldF9pZCI6InNoYTI1Njo2Y2U0ODFkYjFmZTEzYmNmZTdhMjM3NDQyM2U2YzcyZDc1N2Q1MWM5OTE2OGIyODdjNTJmNTNhYTYyNWNjZDY3IiwiZW50aXR5X2Fzc2V0X2lkIjoic2hhMjU2OmVjNjVkYmYzZjU5NDQwM2IzOTY5YmUwM2ZiMDA0NzczN2RiNjQ1Y2MzYjQ3MzMwNGIxNDAzODEyYmVjNWMyY2IiLCJnZW9tZXRyeV9hc3NldF9pZCI6InNoYTI1NjoyMDA1MjM0MTRkZTI4MWE4YTE1NTA4NmE1NGNkMGY4ODBmNjQwNDg1NzY1YTQ4Y2NkZDhlYzIzMGY2N2RkMTdjIiwia2luZCI6InBhcnQiLCJuYW1lIjoiU2hhcmVkIFBhcnQiLCJzZGtfbWV0YWRhdGEiOnt9LCJzb3VyY2UiOnsia2luZCI6InByb2R1Y3RfbWFudWFsIiwicm9vdF9pZCI6InJvb3QiLCJzZW1hbnRpY19pZCI6InNoYXJlZF9wYXJ0Iiwic2VtYW50aWNfdHlwZSI6IlBhcnQifX1dLCJkaWFnbm9zdGljcyI6W10sImVkZ2VfYXNzZXRzIjpbeyJhc3NldF9pZCI6InNoYTI1Njo2Y2U0ODFkYjFmZTEzYmNmZTdhMjM3NDQyM2U2YzcyZDc1N2Q1MWM5OTE2OGIyODdjNTJmNTNhYTYyNWNjZDY3IiwiYXNzZXRfdG9fc2NlbmUiOlsxMDAwLDAsMCwwLDAsMCwtMTAwMCwwLDAsMTAwMCwwLDAsMCwwLDAsMV0sImJ5dGVfbGVuZ3RoIjo3NDAsImNvbnRlbnRfaGFzaCI6InNoYTI1Njo2Y2U0ODFkYjFmZTEzYmNmZTdhMjM3NDQyM2U2YzcyZDc1N2Q1MWM5OTE2OGIyODdjNTJmNTNhYTYyNWNjZDY3IiwibWVkaWFfdHlwZSI6Im1vZGVsL2dsdGYtYmluYXJ5Iiwic2NlbmVfbG9jYWxfYm91bmRzIjp7Im1heCI6WzEwMDAsMCwxMDAwXSwibWluIjpbMCwtMTAwMCwwXX0sInRlc3NlbGxhdGlvbiI6eyJsaW5lYXJfdG9sZXJhbmNlIjowLjM1fSwidXJpIjoiZWRnZXMvc2hhMjU2LTZjZTQ4MWRiMWZlMTNiY2ZlN2EyMzc0NDIzZTZjNzJkNzU3ZDUxYzk5MTY4YjI4N2M1MmY1M2FhNjI1Y2NkNjcuZ2xiIn1dLCJlbnRpdHlfYXNzZXRzIjpbeyJieXRlX2xlbmd0aCI6MTE1NDEsImNvbnRlbnRfaGFzaCI6InNoYTI1NjplYzY1ZGJmM2Y1OTQ0MDNiMzk2OWJlMDNmYjAwNDc3MzdkYjY0NWNjM2I0NzMzMDRiMTQwMzgxMmJlYzVjMmNiIiwiZW50aXR5X2Fzc2V0X2lkIjoic2hhMjU2OmVjNjVkYmYzZjU5NDQwM2IzOTY5YmUwM2ZiMDA0NzczN2RiNjQ1Y2MzYjQ3MzMwNGIxNDAzODEyYmVjNWMyY2IiLCJtZWRpYV90eXBlIjoiYXBwbGljYXRpb24vdm5kLnNpbXBsZWNhZC5lbnRpdGllcytqc29uIiwidXJpIjoiZW50aXRpZXMvc2hhMjU2LWVjNjVkYmYzZjU5NDQwM2IzOTY5YmUwM2ZiMDA0NzczN2RiNjQ1Y2MzYjQ3MzMwNGIxNDAzODEyYmVjNWMyY2IuanNvbiJ9XSwiZXh0ZW5zaW9ucyI6e30sImV4dGVuc2lvbnNfcmVxdWlyZWQiOltdLCJleHRlbnNpb25zX3VzZWQiOltdLCJnZW5lcmF0b3IiOnsibmFtZSI6InNpbXBsZWNhZGFwaSIsIm9jcF9iaW5kaW5nc192ZXJzaW9uIjoiZml4dHVyZSIsIm9jcF92ZXJzaW9uIjoiZml4dHVyZSIsInBsYXRmb3JtX3RhZyI6ImZpeHR1cmUiLCJwcm9maWxlIjoic2NlbmUtMS4wLW9jcC1nbGItMiIsInB5dGhvbl9hYmkiOiJmaXh0dXJlIiwic2ltcGxlY2FkYXBpX3ZlcnNpb24iOiJmaXh0dXJlIiwidG9vbGNoYWluX2hhc2giOiJzaGEyNTY6MDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMCJ9LCJnZW9tZXRyeV9hc3NldHMiOlt7ImFzc2V0X2lkIjoic2hhMjU2OjIwMDUyMzQxNGRlMjgxYThhMTU1MDg2YTU0Y2QwZjg4MGY2NDA0ODU3NjVhNDhjY2RkOGVjMjMwZjY3ZGQxN2MiLCJhc3NldF90b19zY2VuZSI6WzEwMDAsMCwwLDAsMCwwLC0xMDAwLDAsMCwxMDAwLDAsMCwwLDAsMCwxXSwiYnl0ZV9sZW5ndGgiOjEwMjAsImNvbnRlbnRfaGFzaCI6InNoYTI1NjoyMDA1MjM0MTRkZTI4MWE4YTE1NTA4NmE1NGNkMGY4ODBmNjQwNDg1NzY1YTQ4Y2NkZDhlYzIzMGY2N2RkMTdjIiwibWVkaWFfdHlwZSI6Im1vZGVsL2dsdGYtYmluYXJ5Iiwic2NlbmVfbG9jYWxfYm91bmRzIjp7Im1heCI6WzEwMDAsMCwxMDAwXSwibWluIjpbMCwtMTAwMCwwXX0sInRlc3NlbGxhdGlvbiI6eyJhbmd1bGFyX3RvbGVyYW5jZSI6MC4yMiwibGluZWFyX3RvbGVyYW5jZSI6MC4zNX0sInVyaSI6Imdlb21ldHJ5L3NoYTI1Ni0yMDA1MjM0MTRkZTI4MWE4YTE1NTA4NmE1NGNkMGY4ODBmNjQwNDg1NzY1YTQ4Y2NkZDhlYzIzMGY2N2RkMTdjLmdsYiJ9XSwibGlnaHRzIjpbXSwibm9kZXMiOlt7ImFwcGVhcmFuY2Vfb3ZlcnJpZGVfaWQiOm51bGwsImRlZmluaXRpb25faWQiOiJkZWZpbml0aW9uL3Jvb3QvYXNzZW1ibHkvcm9vdF9hc3NlbWJseSIsIm5hbWUiOiJyb290Iiwibm9kZV9pZCI6Imluc3RhbmNlL3Jvb3QiLCJvcmRlciI6MCwicGFyZW50X25vZGVfaWQiOm51bGwsInNka19tZXRhZGF0YSI6e30sInNlbGVjdGFibGUiOnRydWUsInNvdXJjZSI6eyJjb21wb25lbnRfcGF0aCI6W10sImtpbmQiOiJwcm9kdWN0X29jY3VycmVuY2UiLCJyb290X2lkIjoicm9vdCJ9LCJ0cmFuc2Zvcm0iOnsib3JpZ2luIjpbMCwwLDBdLCJ4X2F4aXMiOlsxLDAsMF0sInlfYXhpcyI6WzAsMSwwXSwiel9heGlzIjpbMCwwLDFdfSwidmlzaWJsZSI6dHJ1ZX0seyJhcHBlYXJhbmNlX292ZXJyaWRlX2lkIjpudWxsLCJkZWZpbml0aW9uX2lkIjoiZGVmaW5pdGlvbi9yb290L3BhcnQvc2hhcmVkX3BhcnQiLCJuYW1lIjoibGVmdCIsIm5vZGVfaWQiOiJpbnN0YW5jZS9yb290L2xlZnQiLCJvcmRlciI6MCwicGFyZW50X25vZGVfaWQiOiJpbnN0YW5jZS9yb290Iiwic2RrX21ldGFkYXRhIjp7fSwic2VsZWN0YWJsZSI6dHJ1ZSwic291cmNlIjp7ImNvbXBvbmVudF9wYXRoIjpbImxlZnQiXSwia2luZCI6InByb2R1Y3Rfb2NjdXJyZW5jZSIsInJvb3RfaWQiOiJyb290In0sInRyYW5zZm9ybSI6eyJvcmlnaW4iOlstMTAwMCwwLDBdLCJ4X2F4aXMiOlsxLDAsMF0sInlfYXhpcyI6WzAsMSwwXSwiel9heGlzIjpbMCwwLDFdfSwidmlzaWJsZSI6dHJ1ZX0seyJhcHBlYXJhbmNlX292ZXJyaWRlX2lkIjpudWxsLCJkZWZpbml0aW9uX2lkIjoiZGVmaW5pdGlvbi9yb290L3BhcnQvc2hhcmVkX3BhcnQiLCJuYW1lIjoicmlnaHQiLCJub2RlX2lkIjoiaW5zdGFuY2Uvcm9vdC9yaWdodCIsIm9yZGVyIjoxLCJwYXJlbnRfbm9kZV9pZCI6Imluc3RhbmNlL3Jvb3QiLCJzZGtfbWV0YWRhdGEiOnt9LCJzZWxlY3RhYmxlIjp0cnVlLCJzb3VyY2UiOnsiY29tcG9uZW50X3BhdGgiOlsicmlnaHQiXSwia2luZCI6InByb2R1Y3Rfb2NjdXJyZW5jZSIsInJvb3RfaWQiOiJyb290In0sInRyYW5zZm9ybSI6eyJvcmlnaW4iOlsxMDAwLDAsMF0sInhfYXhpcyI6WzEsMCwwXSwieV9heGlzIjpbMCwxLDBdLCJ6X2F4aXMiOlswLDAsMV19LCJ2aXNpYmxlIjp0cnVlfV0sInJldmlzaW9uIjoic2hhMjU2OjVlOGRlZGUwNGExNjE5YTJiYTQ1MTQ3ZDk0ZDlmOTc5YWU5YmFjNDRhNTEwYWUzN2VlMDBkMGU1NjQzYjcyMDMiLCJzY2VuZV9pZCI6InJlcGVhdGVkX2ZpeHR1cmUiLCJzY2hlbWFfdmVyc2lvbiI6IjEuMCIsInNvdXJjZSI6eyJraW5kIjoibWFudWFsIiwic291cmNlX2lkIjoiZml4dHVyZSJ9fQ==", + "name": "repeated_part_instance", + "valid": true + } + ], + "schema_field_matrices": [ + { + "artifact": "scene", + "base": { + "annotations": [], + "appearances": [], + "cameras": [], + "compile_options": { + "angular_tolerance": 1, + "embed_presentation": false, + "embed_source": false, + "linear_tolerance": 1 + }, + "connectors": [], + "coordinate_system": { + "handedness": "right", + "length_unit": "mm", + "up_axis": "+Z" + }, + "definitions": [], + "diagnostics": [], + "edge_assets": [], + "entity_assets": [], + "extensions": {}, + "extensions_required": [], + "extensions_used": [], + "generator": { + "name": "simplecadapi", + "ocp_bindings_version": "a", + "ocp_version": "a", + "platform_tag": "a", + "profile": "scene-1.0-ocp-glb-1", + "python_abi": "a", + "simplecadapi_version": "a", + "toolchain_hash": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "geometry_assets": [], + "lights": [], + "nodes": [], + "revision": "sha256:0000000000000000000000000000000000000000000000000000000000000000", + "scene_id": "a", + "schema_version": "1.0", + "source": { + "artifact_hash": "sha256:0000000000000000000000000000000000000000000000000000000000000000", + "graph_id": "a", + "kind": "model", + "model_schema_version": "2.0" + } + }, + "cases": [ + { + "mutations": [], + "name": "base_valid", + "valid": true + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "schema_version" + ] + } + ], + "name": "missing_schema_version", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "extensions_used" + ] + } + ], + "name": "missing_extensions_used", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "extensions_required" + ] + } + ], + "name": "missing_extensions_required", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "extensions" + ] + } + ], + "name": "missing_extensions", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "scene_id" + ] + } + ], + "name": "missing_scene_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "revision" + ] + } + ], + "name": "missing_revision", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "generator" + ] + } + ], + "name": "missing_generator", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "source" + ] + } + ], + "name": "missing_source", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "coordinate_system" + ] + } + ], + "name": "missing_coordinate_system", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "compile_options" + ] + } + ], + "name": "missing_compile_options", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "definitions" + ] + } + ], + "name": "missing_definitions", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "nodes" + ] + } + ], + "name": "missing_nodes", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "geometry_assets" + ] + } + ], + "name": "missing_geometry_assets", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "edge_assets" + ] + } + ], + "name": "missing_edge_assets", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "appearances" + ] + } + ], + "name": "missing_appearances", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "entity_assets" + ] + } + ], + "name": "missing_entity_assets", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "connectors" + ] + } + ], + "name": "missing_connectors", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "cameras" + ] + } + ], + "name": "missing_cameras", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "lights" + ] + } + ], + "name": "missing_lights", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "annotations" + ] + } + ], + "name": "missing_annotations", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "diagnostics" + ] + } + ], + "name": "missing_diagnostics", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "unknown" + ], + "value": true + } + ], + "name": "unknown_field", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "presentation_source" + ], + "value": { + "artifact_hash": "sha256:0000000000000000000000000000000000000000000000000000000000000000", + "presentation_id": "a", + "schema_version": "1.0" + } + } + ], + "name": "optional_presentation_source_present", + "valid": true + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "presentation_source" + ], + "value": { + "artifact_hash": "sha256:0000000000000000000000000000000000000000000000000000000000000000", + "presentation_id": "a", + "schema_version": "1.0" + } + } + ], + "name": "optional_fields_all_present", + "valid": true + } + ], + "schema_pointer": "#", + "variant": "base" + }, + { + "artifact": "scene", + "base": { + "origin": [ + 0, + 0, + 0 + ], + "x_axis": [ + 0, + 0, + 0 + ], + "y_axis": [ + 0, + 0, + 0 + ], + "z_axis": [ + 0, + 0, + 0 + ] + }, + "cases": [ + { + "mutations": [], + "name": "base_valid", + "valid": true + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "origin" + ] + } + ], + "name": "missing_origin", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "x_axis" + ] + } + ], + "name": "missing_x_axis", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "y_axis" + ] + } + ], + "name": "missing_y_axis", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "z_axis" + ] + } + ], + "name": "missing_z_axis", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "unknown" + ], + "value": true + } + ], + "name": "unknown_field", + "valid": false + } + ], + "schema_pointer": "#/$defs/transform", + "variant": "base" + }, + { + "artifact": "scene", + "base": { + "max": [ + 0, + 0, + 0 + ], + "min": [ + 0, + 0, + 0 + ] + }, + "cases": [ + { + "mutations": [], + "name": "base_valid", + "valid": true + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "min" + ] + } + ], + "name": "missing_min", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "max" + ] + } + ], + "name": "missing_max", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "unknown" + ], + "value": true + } + ], + "name": "unknown_field", + "valid": false + } + ], + "schema_pointer": "#/$defs/bounds", + "variant": "base" + }, + { + "artifact": "scene", + "base": { + "name": "simplecadapi", + "ocp_bindings_version": "a", + "ocp_version": "a", + "platform_tag": "a", + "profile": "scene-1.0-ocp-glb-1", + "python_abi": "a", + "simplecadapi_version": "a", + "toolchain_hash": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "cases": [ + { + "mutations": [], + "name": "base_valid", + "valid": true + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "name" + ] + } + ], + "name": "missing_name", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "simplecadapi_version" + ] + } + ], + "name": "missing_simplecadapi_version", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "ocp_version" + ] + } + ], + "name": "missing_ocp_version", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "ocp_bindings_version" + ] + } + ], + "name": "missing_ocp_bindings_version", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "python_abi" + ] + } + ], + "name": "missing_python_abi", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "platform_tag" + ] + } + ], + "name": "missing_platform_tag", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "toolchain_hash" + ] + } + ], + "name": "missing_toolchain_hash", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "profile" + ] + } + ], + "name": "missing_profile", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "unknown" + ], + "value": true + } + ], + "name": "unknown_field", + "valid": false + } + ], + "schema_pointer": "#/$defs/generator", + "variant": "profile=scene-1.0-ocp-glb-1" + }, + { + "artifact": "scene", + "base": { + "name": "simplecadapi", + "ocp_bindings_version": "a", + "ocp_version": "a", + "platform_tag": "a", + "profile": "scene-1.0-ocp-glb-2", + "python_abi": "a", + "simplecadapi_version": "a", + "toolchain_hash": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "cases": [ + { + "mutations": [], + "name": "base_valid", + "valid": true + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "name" + ] + } + ], + "name": "missing_name", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "simplecadapi_version" + ] + } + ], + "name": "missing_simplecadapi_version", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "ocp_version" + ] + } + ], + "name": "missing_ocp_version", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "ocp_bindings_version" + ] + } + ], + "name": "missing_ocp_bindings_version", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "python_abi" + ] + } + ], + "name": "missing_python_abi", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "platform_tag" + ] + } + ], + "name": "missing_platform_tag", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "toolchain_hash" + ] + } + ], + "name": "missing_toolchain_hash", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "profile" + ] + } + ], + "name": "missing_profile", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "unknown" + ], + "value": true + } + ], + "name": "unknown_field", + "valid": false + } + ], + "schema_pointer": "#/$defs/generator", + "variant": "profile=scene-1.0-ocp-glb-2" + }, + { + "artifact": "scene", + "base": { + "artifact_hash": "sha256:0000000000000000000000000000000000000000000000000000000000000000", + "graph_id": "a", + "kind": "model", + "model_schema_version": "2.0" + }, + "cases": [ + { + "mutations": [], + "name": "base_valid", + "valid": true + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "kind" + ] + } + ], + "name": "missing_kind", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "graph_id" + ] + } + ], + "name": "missing_graph_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "model_schema_version" + ] + } + ], + "name": "missing_model_schema_version", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "artifact_hash" + ] + } + ], + "name": "missing_artifact_hash", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "unknown" + ], + "value": true + } + ], + "name": "unknown_field", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "embedded_artifact_uri" + ], + "value": "model/model.json" + } + ], + "name": "optional_embedded_artifact_uri_present", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "embedded_artifact_byte_length" + ], + "value": 0 + } + ], + "name": "optional_embedded_artifact_byte_length_present", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "embedded_artifact_uri" + ], + "value": "model/model.json" + }, + { + "operation": "set", + "path": [ + "embedded_artifact_byte_length" + ], + "value": 0 + } + ], + "name": "optional_fields_all_present", + "valid": true + } + ], + "schema_pointer": "#/$defs/modelSource", + "variant": "base" + }, + { + "artifact": "scene", + "base": { + "artifact_hash": "sha256:0000000000000000000000000000000000000000000000000000000000000000", + "format": "a", + "kind": "imported" + }, + "cases": [ + { + "mutations": [], + "name": "base_valid", + "valid": true + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "kind" + ] + } + ], + "name": "missing_kind", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "format" + ] + } + ], + "name": "missing_format", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "artifact_hash" + ] + } + ], + "name": "missing_artifact_hash", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "unknown" + ], + "value": true + } + ], + "name": "unknown_field", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "embedded_artifact_uri" + ], + "value": "source/source.bin" + } + ], + "name": "optional_embedded_artifact_uri_present", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "embedded_artifact_byte_length" + ], + "value": 0 + } + ], + "name": "optional_embedded_artifact_byte_length_present", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "embedded_artifact_uri" + ], + "value": "source/source.bin" + }, + { + "operation": "set", + "path": [ + "embedded_artifact_byte_length" + ], + "value": 0 + } + ], + "name": "optional_fields_all_present", + "valid": true + } + ], + "schema_pointer": "#/$defs/importedSource", + "variant": "base" + }, + { + "artifact": "scene", + "base": { + "kind": "manual", + "source_id": "a" + }, + "cases": [ + { + "mutations": [], + "name": "base_valid", + "valid": true + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "kind" + ] + } + ], + "name": "missing_kind", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "source_id" + ] + } + ], + "name": "missing_source_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "unknown" + ], + "value": true + } + ], + "name": "unknown_field", + "valid": false + } + ], + "schema_pointer": "#/$defs/manualSource", + "variant": "base" + }, + { + "artifact": "scene", + "base": { + "artifact_hash": "sha256:0000000000000000000000000000000000000000000000000000000000000000", + "presentation_id": "a", + "schema_version": "1.0" + }, + "cases": [ + { + "mutations": [], + "name": "base_valid", + "valid": true + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "presentation_id" + ] + } + ], + "name": "missing_presentation_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "schema_version" + ] + } + ], + "name": "missing_schema_version", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "artifact_hash" + ] + } + ], + "name": "missing_artifact_hash", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "unknown" + ], + "value": true + } + ], + "name": "unknown_field", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "embedded_artifact_uri" + ], + "value": "presentation/presentation.json" + } + ], + "name": "optional_embedded_artifact_uri_present", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "embedded_artifact_byte_length" + ], + "value": 0 + } + ], + "name": "optional_embedded_artifact_byte_length_present", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "embedded_artifact_uri" + ], + "value": "presentation/presentation.json" + }, + { + "operation": "set", + "path": [ + "embedded_artifact_byte_length" + ], + "value": 0 + } + ], + "name": "optional_fields_all_present", + "valid": true + } + ], + "schema_pointer": "#/$defs/presentationSource", + "variant": "base" + }, + { + "artifact": "scene", + "base": { + "handedness": "right", + "length_unit": "mm", + "up_axis": "+Z" + }, + "cases": [ + { + "mutations": [], + "name": "base_valid", + "valid": true + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "length_unit" + ] + } + ], + "name": "missing_length_unit", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "handedness" + ] + } + ], + "name": "missing_handedness", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "up_axis" + ] + } + ], + "name": "missing_up_axis", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "unknown" + ], + "value": true + } + ], + "name": "unknown_field", + "valid": false + } + ], + "schema_pointer": "#/$defs/coordinateSystem", + "variant": "base" + }, + { + "artifact": "scene", + "base": { + "angular_tolerance": 1, + "embed_presentation": false, + "embed_source": false, + "linear_tolerance": 1 + }, + "cases": [ + { + "mutations": [], + "name": "base_valid", + "valid": true + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "linear_tolerance" + ] + } + ], + "name": "missing_linear_tolerance", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "angular_tolerance" + ] + } + ], + "name": "missing_angular_tolerance", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "embed_source" + ] + } + ], + "name": "missing_embed_source", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "embed_presentation" + ] + } + ], + "name": "missing_embed_presentation", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "unknown" + ], + "value": true + } + ], + "name": "unknown_field", + "valid": false + } + ], + "schema_pointer": "#/$defs/compileOptions", + "variant": "base" + }, + { + "artifact": "scene", + "base": { + "graph_id": "a", + "kind": "product_model", + "node_id": "a", + "output_slot": 0, + "root_id": "a", + "semantic_id": "a", + "semantic_type": "Part" + }, + "cases": [ + { + "mutations": [], + "name": "base_valid", + "valid": true + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "kind" + ] + } + ], + "name": "missing_kind", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "root_id" + ] + } + ], + "name": "missing_root_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "semantic_type" + ] + } + ], + "name": "missing_semantic_type", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "semantic_id" + ] + } + ], + "name": "missing_semantic_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "graph_id" + ] + } + ], + "name": "missing_graph_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "node_id" + ] + } + ], + "name": "missing_node_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "output_slot" + ] + } + ], + "name": "missing_output_slot", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "unknown" + ], + "value": true + } + ], + "name": "unknown_field", + "valid": false + } + ], + "schema_pointer": "#/$defs/productModelSource", + "variant": "semantic_type=Part" + }, + { + "artifact": "scene", + "base": { + "graph_id": "a", + "kind": "product_model", + "node_id": "a", + "output_slot": 0, + "root_id": "a", + "semantic_id": "a", + "semantic_type": "Assembly" + }, + "cases": [ + { + "mutations": [], + "name": "base_valid", + "valid": true + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "kind" + ] + } + ], + "name": "missing_kind", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "root_id" + ] + } + ], + "name": "missing_root_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "semantic_type" + ] + } + ], + "name": "missing_semantic_type", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "semantic_id" + ] + } + ], + "name": "missing_semantic_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "graph_id" + ] + } + ], + "name": "missing_graph_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "node_id" + ] + } + ], + "name": "missing_node_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "output_slot" + ] + } + ], + "name": "missing_output_slot", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "unknown" + ], + "value": true + } + ], + "name": "unknown_field", + "valid": false + } + ], + "schema_pointer": "#/$defs/productModelSource", + "variant": "semantic_type=Assembly" + }, + { + "artifact": "scene", + "base": { + "kind": "product_manual", + "root_id": "a", + "semantic_id": "a", + "semantic_type": "Part" + }, + "cases": [ + { + "mutations": [], + "name": "base_valid", + "valid": true + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "kind" + ] + } + ], + "name": "missing_kind", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "root_id" + ] + } + ], + "name": "missing_root_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "semantic_type" + ] + } + ], + "name": "missing_semantic_type", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "semantic_id" + ] + } + ], + "name": "missing_semantic_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "unknown" + ], + "value": true + } + ], + "name": "unknown_field", + "valid": false + } + ], + "schema_pointer": "#/$defs/productManualSource", + "variant": "semantic_type=Part" + }, + { + "artifact": "scene", + "base": { + "kind": "product_manual", + "root_id": "a", + "semantic_id": "a", + "semantic_type": "Assembly" + }, + "cases": [ + { + "mutations": [], + "name": "base_valid", + "valid": true + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "kind" + ] + } + ], + "name": "missing_kind", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "root_id" + ] + } + ], + "name": "missing_root_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "semantic_type" + ] + } + ], + "name": "missing_semantic_type", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "semantic_id" + ] + } + ], + "name": "missing_semantic_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "unknown" + ], + "value": true + } + ], + "name": "unknown_field", + "valid": false + } + ], + "schema_pointer": "#/$defs/productManualSource", + "variant": "semantic_type=Assembly" + }, + { + "artifact": "scene", + "base": { + "graph_id": "a", + "kind": "model_output", + "node_id": "a", + "output_slot": 0, + "root_id": "a" + }, + "cases": [ + { + "mutations": [], + "name": "base_valid", + "valid": true + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "kind" + ] + } + ], + "name": "missing_kind", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "root_id" + ] + } + ], + "name": "missing_root_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "graph_id" + ] + } + ], + "name": "missing_graph_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "node_id" + ] + } + ], + "name": "missing_node_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "output_slot" + ] + } + ], + "name": "missing_output_slot", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "unknown" + ], + "value": true + } + ], + "name": "unknown_field", + "valid": false + } + ], + "schema_pointer": "#/$defs/modelOutputSource", + "variant": "base" + }, + { + "artifact": "scene", + "base": { + "kind": "imported", + "root_id": "a", + "source_element_id": "a" + }, + "cases": [ + { + "mutations": [], + "name": "base_valid", + "valid": true + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "kind" + ] + } + ], + "name": "missing_kind", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "root_id" + ] + } + ], + "name": "missing_root_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "source_element_id" + ] + } + ], + "name": "missing_source_element_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "unknown" + ], + "value": true + } + ], + "name": "unknown_field", + "valid": false + } + ], + "schema_pointer": "#/$defs/importedDefinitionSource", + "variant": "base" + }, + { + "artifact": "scene", + "base": { + "kind": "manual", + "root_id": "a", + "source_id": "a" + }, + "cases": [ + { + "mutations": [], + "name": "base_valid", + "valid": true + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "kind" + ] + } + ], + "name": "missing_kind", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "root_id" + ] + } + ], + "name": "missing_root_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "source_id" + ] + } + ], + "name": "missing_source_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "unknown" + ], + "value": true + } + ], + "name": "unknown_field", + "valid": false + } + ], + "schema_pointer": "#/$defs/manualDefinitionSource", + "variant": "base" + }, + { + "artifact": "scene", + "base": { + "appearance_id": "a", + "definition_id": "a", + "edge_asset_id": "sha256:0000000000000000000000000000000000000000000000000000000000000000", + "entity_asset_id": "sha256:0000000000000000000000000000000000000000000000000000000000000000", + "geometry_asset_id": "sha256:0000000000000000000000000000000000000000000000000000000000000000", + "kind": "part", + "name": null, + "sdk_metadata": {}, + "source": { + "graph_id": "a", + "kind": "product_model", + "node_id": "a", + "output_slot": 0, + "root_id": "a", + "semantic_id": "a", + "semantic_type": "Part" + } + }, + "cases": [ + { + "mutations": [], + "name": "base_valid", + "valid": true + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "definition_id" + ] + } + ], + "name": "missing_definition_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "kind" + ] + } + ], + "name": "missing_kind", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "name" + ] + } + ], + "name": "missing_name", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "source" + ] + } + ], + "name": "missing_source", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "sdk_metadata" + ] + } + ], + "name": "missing_sdk_metadata", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "geometry_asset_id" + ] + } + ], + "name": "missing_geometry_asset_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "edge_asset_id" + ] + } + ], + "name": "missing_edge_asset_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "entity_asset_id" + ] + } + ], + "name": "missing_entity_asset_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "appearance_id" + ] + } + ], + "name": "missing_appearance_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "unknown" + ], + "value": true + } + ], + "name": "unknown_field", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "name" + ], + "value": "a" + } + ], + "name": "nullable_name_nonnull", + "valid": true + } + ], + "schema_pointer": "#/$defs/definition", + "variant": "kind=part" + }, + { + "artifact": "scene", + "base": { + "definition_id": "a", + "kind": "assembly", + "name": null, + "sdk_metadata": {}, + "source": { + "graph_id": "a", + "kind": "product_model", + "node_id": "a", + "output_slot": 0, + "root_id": "a", + "semantic_id": "a", + "semantic_type": "Part" + } + }, + "cases": [ + { + "mutations": [], + "name": "base_valid", + "valid": true + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "definition_id" + ] + } + ], + "name": "missing_definition_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "kind" + ] + } + ], + "name": "missing_kind", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "name" + ] + } + ], + "name": "missing_name", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "source" + ] + } + ], + "name": "missing_source", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "sdk_metadata" + ] + } + ], + "name": "missing_sdk_metadata", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "unknown" + ], + "value": true + } + ], + "name": "unknown_field", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "geometry_asset_id" + ], + "value": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + } + ], + "name": "optional_geometry_asset_id_present", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "edge_asset_id" + ], + "value": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + } + ], + "name": "optional_edge_asset_id_present", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "entity_asset_id" + ], + "value": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + } + ], + "name": "optional_entity_asset_id_present", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "appearance_id" + ], + "value": "a" + } + ], + "name": "optional_appearance_id_present", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "geometry_asset_id" + ], + "value": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + { + "operation": "set", + "path": [ + "edge_asset_id" + ], + "value": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + { + "operation": "set", + "path": [ + "entity_asset_id" + ], + "value": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + { + "operation": "set", + "path": [ + "appearance_id" + ], + "value": "a" + } + ], + "name": "optional_fields_all_present", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "name" + ], + "value": "a" + } + ], + "name": "nullable_name_nonnull", + "valid": true + } + ], + "schema_pointer": "#/$defs/definition", + "variant": "kind=assembly" + }, + { + "artifact": "scene", + "base": { + "appearance_id": "a", + "definition_id": "a", + "edge_asset_id": "sha256:0000000000000000000000000000000000000000000000000000000000000000", + "entity_asset_id": "sha256:0000000000000000000000000000000000000000000000000000000000000000", + "geometry_asset_id": "sha256:0000000000000000000000000000000000000000000000000000000000000000", + "kind": "shape", + "name": null, + "sdk_metadata": {}, + "source": { + "graph_id": "a", + "kind": "product_model", + "node_id": "a", + "output_slot": 0, + "root_id": "a", + "semantic_id": "a", + "semantic_type": "Part" + } + }, + "cases": [ + { + "mutations": [], + "name": "base_valid", + "valid": true + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "definition_id" + ] + } + ], + "name": "missing_definition_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "kind" + ] + } + ], + "name": "missing_kind", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "name" + ] + } + ], + "name": "missing_name", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "source" + ] + } + ], + "name": "missing_source", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "sdk_metadata" + ] + } + ], + "name": "missing_sdk_metadata", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "geometry_asset_id" + ] + } + ], + "name": "missing_geometry_asset_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "edge_asset_id" + ] + } + ], + "name": "missing_edge_asset_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "entity_asset_id" + ] + } + ], + "name": "missing_entity_asset_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "appearance_id" + ] + } + ], + "name": "missing_appearance_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "unknown" + ], + "value": true + } + ], + "name": "unknown_field", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "name" + ], + "value": "a" + } + ], + "name": "nullable_name_nonnull", + "valid": true + } + ], + "schema_pointer": "#/$defs/definition", + "variant": "kind=shape" + }, + { + "artifact": "scene", + "base": { + "component_path": [], + "kind": "product_occurrence", + "root_id": "a" + }, + "cases": [ + { + "mutations": [], + "name": "base_valid", + "valid": true + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "kind" + ] + } + ], + "name": "missing_kind", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "root_id" + ] + } + ], + "name": "missing_root_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "component_path" + ] + } + ], + "name": "missing_component_path", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "unknown" + ], + "value": true + } + ], + "name": "unknown_field", + "valid": false + } + ], + "schema_pointer": "#/$defs/productOccurrenceSource", + "variant": "base" + }, + { + "artifact": "scene", + "base": { + "kind": "shape_root", + "root_id": "a" + }, + "cases": [ + { + "mutations": [], + "name": "base_valid", + "valid": true + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "kind" + ] + } + ], + "name": "missing_kind", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "root_id" + ] + } + ], + "name": "missing_root_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "unknown" + ], + "value": true + } + ], + "name": "unknown_field", + "valid": false + } + ], + "schema_pointer": "#/$defs/shapeRootSource", + "variant": "base" + }, + { + "artifact": "scene", + "base": { + "appearance_override_id": "a", + "definition_id": "a", + "name": null, + "node_id": "a", + "order": 0, + "parent_node_id": "a", + "sdk_metadata": {}, + "selectable": false, + "source": { + "component_path": [], + "kind": "product_occurrence", + "root_id": "a" + }, + "transform": { + "origin": [ + 0, + 0, + 0 + ], + "x_axis": [ + 0, + 0, + 0 + ], + "y_axis": [ + 0, + 0, + 0 + ], + "z_axis": [ + 0, + 0, + 0 + ] + }, + "visible": false + }, + "cases": [ + { + "mutations": [], + "name": "base_valid", + "valid": true + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "node_id" + ] + } + ], + "name": "missing_node_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "parent_node_id" + ] + } + ], + "name": "missing_parent_node_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "order" + ] + } + ], + "name": "missing_order", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "definition_id" + ] + } + ], + "name": "missing_definition_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "name" + ] + } + ], + "name": "missing_name", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "transform" + ] + } + ], + "name": "missing_transform", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "visible" + ] + } + ], + "name": "missing_visible", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "selectable" + ] + } + ], + "name": "missing_selectable", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "appearance_override_id" + ] + } + ], + "name": "missing_appearance_override_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "source" + ] + } + ], + "name": "missing_source", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "sdk_metadata" + ] + } + ], + "name": "missing_sdk_metadata", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "unknown" + ], + "value": true + } + ], + "name": "unknown_field", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "name" + ], + "value": "a" + } + ], + "name": "nullable_name_nonnull", + "valid": true + } + ], + "schema_pointer": "#/$defs/node", + "variant": "base" + }, + { + "artifact": "scene", + "base": { + "asset_id": "sha256:0000000000000000000000000000000000000000000000000000000000000000", + "asset_to_scene": [ + 1000, + 0, + 0, + 0, + 0, + 0, + -1000, + 0, + 0, + 1000, + 0, + 0, + 0, + 0, + 0, + 1 + ], + "byte_length": 0, + "content_hash": "sha256:0000000000000000000000000000000000000000000000000000000000000000", + "media_type": "model/gltf-binary", + "scene_local_bounds": { + "max": [ + 0, + 0, + 0 + ], + "min": [ + 0, + 0, + 0 + ] + }, + "tessellation": { + "angular_tolerance": 1, + "linear_tolerance": 1 + }, + "uri": "a" + }, + "cases": [ + { + "mutations": [], + "name": "base_valid", + "valid": true + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "asset_id" + ] + } + ], + "name": "missing_asset_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "uri" + ] + } + ], + "name": "missing_uri", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "media_type" + ] + } + ], + "name": "missing_media_type", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "byte_length" + ] + } + ], + "name": "missing_byte_length", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "content_hash" + ] + } + ], + "name": "missing_content_hash", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "scene_local_bounds" + ] + } + ], + "name": "missing_scene_local_bounds", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "asset_to_scene" + ] + } + ], + "name": "missing_asset_to_scene", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "tessellation" + ] + } + ], + "name": "missing_tessellation", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "unknown" + ], + "value": true + } + ], + "name": "unknown_field", + "valid": false + } + ], + "schema_pointer": "#/$defs/geometryAsset", + "variant": "base" + }, + { + "artifact": "scene", + "base": { + "angular_tolerance": 1, + "linear_tolerance": 1 + }, + "cases": [ + { + "mutations": [], + "name": "base_valid", + "valid": true + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "linear_tolerance" + ] + } + ], + "name": "missing_linear_tolerance", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "angular_tolerance" + ] + } + ], + "name": "missing_angular_tolerance", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "unknown" + ], + "value": true + } + ], + "name": "unknown_field", + "valid": false + } + ], + "schema_pointer": "#/$defs/geometryAsset/properties/tessellation", + "variant": "base" + }, + { + "artifact": "scene", + "base": { + "asset_id": "sha256:0000000000000000000000000000000000000000000000000000000000000000", + "asset_to_scene": [ + 1000, + 0, + 0, + 0, + 0, + 0, + -1000, + 0, + 0, + 1000, + 0, + 0, + 0, + 0, + 0, + 1 + ], + "byte_length": 0, + "content_hash": "sha256:0000000000000000000000000000000000000000000000000000000000000000", + "media_type": "model/gltf-binary", + "scene_local_bounds": { + "max": [ + 0, + 0, + 0 + ], + "min": [ + 0, + 0, + 0 + ] + }, + "tessellation": { + "linear_tolerance": 1 + }, + "uri": "a" + }, + "cases": [ + { + "mutations": [], + "name": "base_valid", + "valid": true + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "asset_id" + ] + } + ], + "name": "missing_asset_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "uri" + ] + } + ], + "name": "missing_uri", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "media_type" + ] + } + ], + "name": "missing_media_type", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "byte_length" + ] + } + ], + "name": "missing_byte_length", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "content_hash" + ] + } + ], + "name": "missing_content_hash", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "scene_local_bounds" + ] + } + ], + "name": "missing_scene_local_bounds", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "asset_to_scene" + ] + } + ], + "name": "missing_asset_to_scene", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "tessellation" + ] + } + ], + "name": "missing_tessellation", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "unknown" + ], + "value": true + } + ], + "name": "unknown_field", + "valid": false + } + ], + "schema_pointer": "#/$defs/edgeAsset", + "variant": "base" + }, + { + "artifact": "scene", + "base": { + "linear_tolerance": 1 + }, + "cases": [ + { + "mutations": [], + "name": "base_valid", + "valid": true + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "linear_tolerance" + ] + } + ], + "name": "missing_linear_tolerance", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "unknown" + ], + "value": true + } + ], + "name": "unknown_field", + "valid": false + } + ], + "schema_pointer": "#/$defs/edgeAsset/properties/tessellation", + "variant": "base" + }, + { + "artifact": "scene", + "base": { + "byte_length": 0, + "content_hash": "sha256:0000000000000000000000000000000000000000000000000000000000000000", + "entity_asset_id": "sha256:0000000000000000000000000000000000000000000000000000000000000000", + "media_type": "application/vnd.simplecad.entities+json", + "uri": "a" + }, + "cases": [ + { + "mutations": [], + "name": "base_valid", + "valid": true + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "entity_asset_id" + ] + } + ], + "name": "missing_entity_asset_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "uri" + ] + } + ], + "name": "missing_uri", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "media_type" + ] + } + ], + "name": "missing_media_type", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "byte_length" + ] + } + ], + "name": "missing_byte_length", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "content_hash" + ] + } + ], + "name": "missing_content_hash", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "unknown" + ], + "value": true + } + ], + "name": "unknown_field", + "valid": false + } + ], + "schema_pointer": "#/$defs/entityAsset", + "variant": "base" + }, + { + "artifact": "scene", + "base": { + "kind": "product_material", + "material_id": "a", + "root_id": "a" + }, + "cases": [ + { + "mutations": [], + "name": "base_valid", + "valid": true + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "kind" + ] + } + ], + "name": "missing_kind", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "root_id" + ] + } + ], + "name": "missing_root_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "material_id" + ] + } + ], + "name": "missing_material_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "unknown" + ], + "value": true + } + ], + "name": "unknown_field", + "valid": false + } + ], + "schema_pointer": "#/$defs/productMaterialSource", + "variant": "base" + }, + { + "artifact": "scene", + "base": { + "appearance_name": "a", + "kind": "presentation", + "presentation_id": "a" + }, + "cases": [ + { + "mutations": [], + "name": "base_valid", + "valid": true + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "kind" + ] + } + ], + "name": "missing_kind", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "presentation_id" + ] + } + ], + "name": "missing_presentation_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "appearance_name" + ] + } + ], + "name": "missing_appearance_name", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "unknown" + ], + "value": true + } + ], + "name": "unknown_field", + "valid": false + } + ], + "schema_pointer": "#/$defs/presentationAppearanceSource", + "variant": "base" + }, + { + "artifact": "scene", + "base": { + "alpha_mode": "opaque", + "appearance_id": "appearance/evaluated/0000000000000000000000000000000000000000000000000000000000000000", + "base_color": [ + 0, + 0, + 0, + 1 + ], + "double_sided": false, + "edge_color": [ + 0, + 0, + 0, + 1 + ], + "metallic": 0, + "name": null, + "roughness": 0, + "sdk_metadata": {}, + "source": null + }, + "cases": [ + { + "mutations": [], + "name": "base_valid", + "valid": true + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "appearance_id" + ] + } + ], + "name": "missing_appearance_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "name" + ] + } + ], + "name": "missing_name", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "source" + ] + } + ], + "name": "missing_source", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "base_color" + ] + } + ], + "name": "missing_base_color", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "metallic" + ] + } + ], + "name": "missing_metallic", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "roughness" + ] + } + ], + "name": "missing_roughness", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "alpha_mode" + ] + } + ], + "name": "missing_alpha_mode", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "double_sided" + ] + } + ], + "name": "missing_double_sided", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "edge_color" + ] + } + ], + "name": "missing_edge_color", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "sdk_metadata" + ] + } + ], + "name": "missing_sdk_metadata", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "unknown" + ], + "value": true + } + ], + "name": "unknown_field", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "name" + ], + "value": "a" + } + ], + "name": "nullable_name_nonnull", + "valid": true + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "source" + ], + "value": { + "kind": "product_material", + "material_id": "a", + "root_id": "a" + } + } + ], + "name": "nullable_source_nonnull", + "valid": true + } + ], + "schema_pointer": "#/$defs/appearance", + "variant": "base" + }, + { + "artifact": "scene", + "base": { + "graph_id": "a", + "kind": "model_operation", + "node_id": "a", + "output_slot": 0 + }, + "cases": [ + { + "mutations": [], + "name": "base_valid", + "valid": true + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "kind" + ] + } + ], + "name": "missing_kind", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "graph_id" + ] + } + ], + "name": "missing_graph_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "node_id" + ] + } + ], + "name": "missing_node_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "output_slot" + ] + } + ], + "name": "missing_output_slot", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "unknown" + ], + "value": true + } + ], + "name": "unknown_field", + "valid": false + } + ], + "schema_pointer": "#/$defs/modelOperationSource", + "variant": "base" + }, + { + "artifact": "scene", + "base": { + "kind": "manual", + "source_id": "a" + }, + "cases": [ + { + "mutations": [], + "name": "base_valid", + "valid": true + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "kind" + ] + } + ], + "name": "missing_kind", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "source_id" + ] + } + ], + "name": "missing_source_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "unknown" + ], + "value": true + } + ], + "name": "unknown_field", + "valid": false + } + ], + "schema_pointer": "#/$defs/manualConnectorSource", + "variant": "base" + }, + { + "artifact": "scene", + "base": { + "entity_asset_id": "sha256:0000000000000000000000000000000000000000000000000000000000000000", + "entity_id": "entity/face/0" + }, + "cases": [ + { + "mutations": [], + "name": "base_valid", + "valid": true + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "entity_asset_id" + ] + } + ], + "name": "missing_entity_asset_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "entity_id" + ] + } + ], + "name": "missing_entity_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "unknown" + ], + "value": true + } + ], + "name": "unknown_field", + "valid": false + } + ], + "schema_pointer": "#/$defs/connectorTarget", + "variant": "base" + }, + { + "artifact": "scene", + "base": { + "offset": null, + "source_component_id": "a", + "source_connector_id": "a", + "source_connector_snapshot_id": "a", + "source_definition_id": "a" + }, + "cases": [ + { + "mutations": [], + "name": "base_valid", + "valid": true + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "source_component_id" + ] + } + ], + "name": "missing_source_component_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "source_definition_id" + ] + } + ], + "name": "missing_source_definition_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "source_connector_id" + ] + } + ], + "name": "missing_source_connector_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "source_connector_snapshot_id" + ] + } + ], + "name": "missing_source_connector_snapshot_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "offset" + ] + } + ], + "name": "missing_offset", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "unknown" + ], + "value": true + } + ], + "name": "unknown_field", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "offset" + ], + "value": { + "origin": [ + 0, + 0, + 0 + ], + "x_axis": [ + 0, + 0, + 0 + ], + "y_axis": [ + 0, + 0, + 0 + ], + "z_axis": [ + 0, + 0, + 0 + ] + } + } + ], + "name": "nullable_offset_nonnull", + "valid": true + } + ], + "schema_pointer": "#/$defs/forwardedFrom", + "variant": "base" + }, + { + "artifact": "scene", + "base": { + "anchor_kind": "geometry", + "connector_id": "a", + "connector_snapshot_id": "a", + "local_transform": { + "origin": [ + 0, + 0, + 0 + ], + "x_axis": [ + 0, + 0, + 0 + ], + "y_axis": [ + 0, + 0, + 0 + ], + "z_axis": [ + 0, + 0, + 0 + ] + }, + "name": null, + "owner_definition_id": "a", + "sdk_metadata": {}, + "source": null, + "target": { + "entity_asset_id": "sha256:0000000000000000000000000000000000000000000000000000000000000000", + "entity_id": "entity/face/0" + } + }, + "cases": [ + { + "mutations": [], + "name": "base_valid", + "valid": true + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "connector_snapshot_id" + ] + } + ], + "name": "missing_connector_snapshot_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "owner_definition_id" + ] + } + ], + "name": "missing_owner_definition_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "connector_id" + ] + } + ], + "name": "missing_connector_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "name" + ] + } + ], + "name": "missing_name", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "anchor_kind" + ] + } + ], + "name": "missing_anchor_kind", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "local_transform" + ] + } + ], + "name": "missing_local_transform", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "source" + ] + } + ], + "name": "missing_source", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "sdk_metadata" + ] + } + ], + "name": "missing_sdk_metadata", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "target" + ] + } + ], + "name": "missing_target", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "unknown" + ], + "value": true + } + ], + "name": "unknown_field", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "forwarded_from" + ], + "value": { + "offset": null, + "source_component_id": "a", + "source_connector_id": "a", + "source_connector_snapshot_id": "a", + "source_definition_id": "a" + } + } + ], + "name": "optional_forwarded_from_present", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "forwarded_from" + ], + "value": { + "offset": null, + "source_component_id": "a", + "source_connector_id": "a", + "source_connector_snapshot_id": "a", + "source_definition_id": "a" + } + } + ], + "name": "optional_fields_all_present", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "name" + ], + "value": "a" + } + ], + "name": "nullable_name_nonnull", + "valid": true + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "source" + ], + "value": { + "graph_id": "a", + "kind": "model_operation", + "node_id": "a", + "output_slot": 0 + } + } + ], + "name": "nullable_source_nonnull", + "valid": true + } + ], + "schema_pointer": "#/$defs/connector", + "variant": "anchor_kind=geometry" + }, + { + "artifact": "scene", + "base": { + "anchor_kind": "placement", + "connector_id": "a", + "connector_snapshot_id": "a", + "local_transform": { + "origin": [ + 0, + 0, + 0 + ], + "x_axis": [ + 0, + 0, + 0 + ], + "y_axis": [ + 0, + 0, + 0 + ], + "z_axis": [ + 0, + 0, + 0 + ] + }, + "name": null, + "owner_definition_id": "a", + "sdk_metadata": {}, + "source": null + }, + "cases": [ + { + "mutations": [], + "name": "base_valid", + "valid": true + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "connector_snapshot_id" + ] + } + ], + "name": "missing_connector_snapshot_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "owner_definition_id" + ] + } + ], + "name": "missing_owner_definition_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "connector_id" + ] + } + ], + "name": "missing_connector_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "name" + ] + } + ], + "name": "missing_name", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "anchor_kind" + ] + } + ], + "name": "missing_anchor_kind", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "local_transform" + ] + } + ], + "name": "missing_local_transform", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "source" + ] + } + ], + "name": "missing_source", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "sdk_metadata" + ] + } + ], + "name": "missing_sdk_metadata", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "unknown" + ], + "value": true + } + ], + "name": "unknown_field", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "target" + ], + "value": { + "entity_asset_id": "sha256:0000000000000000000000000000000000000000000000000000000000000000", + "entity_id": "entity/face/0" + } + } + ], + "name": "optional_target_present", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "forwarded_from" + ], + "value": { + "offset": null, + "source_component_id": "a", + "source_connector_id": "a", + "source_connector_snapshot_id": "a", + "source_definition_id": "a" + } + } + ], + "name": "optional_forwarded_from_present", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "target" + ], + "value": { + "entity_asset_id": "sha256:0000000000000000000000000000000000000000000000000000000000000000", + "entity_id": "entity/face/0" + } + }, + { + "operation": "set", + "path": [ + "forwarded_from" + ], + "value": { + "offset": null, + "source_component_id": "a", + "source_connector_id": "a", + "source_connector_snapshot_id": "a", + "source_definition_id": "a" + } + } + ], + "name": "optional_fields_all_present", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "name" + ], + "value": "a" + } + ], + "name": "nullable_name_nonnull", + "valid": true + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "source" + ], + "value": { + "graph_id": "a", + "kind": "model_operation", + "node_id": "a", + "output_slot": 0 + } + } + ], + "name": "nullable_source_nonnull", + "valid": true + } + ], + "schema_pointer": "#/$defs/connector", + "variant": "anchor_kind=placement" + }, + { + "artifact": "scene", + "base": { + "anchor_kind": "forwarded", + "connector_id": "a", + "connector_snapshot_id": "a", + "forwarded_from": { + "offset": null, + "source_component_id": "a", + "source_connector_id": "a", + "source_connector_snapshot_id": "a", + "source_definition_id": "a" + }, + "local_transform": { + "origin": [ + 0, + 0, + 0 + ], + "x_axis": [ + 0, + 0, + 0 + ], + "y_axis": [ + 0, + 0, + 0 + ], + "z_axis": [ + 0, + 0, + 0 + ] + }, + "name": null, + "owner_definition_id": "a", + "sdk_metadata": {}, + "source": null + }, + "cases": [ + { + "mutations": [], + "name": "base_valid", + "valid": true + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "connector_snapshot_id" + ] + } + ], + "name": "missing_connector_snapshot_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "owner_definition_id" + ] + } + ], + "name": "missing_owner_definition_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "connector_id" + ] + } + ], + "name": "missing_connector_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "name" + ] + } + ], + "name": "missing_name", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "anchor_kind" + ] + } + ], + "name": "missing_anchor_kind", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "local_transform" + ] + } + ], + "name": "missing_local_transform", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "source" + ] + } + ], + "name": "missing_source", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "sdk_metadata" + ] + } + ], + "name": "missing_sdk_metadata", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "forwarded_from" + ] + } + ], + "name": "missing_forwarded_from", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "unknown" + ], + "value": true + } + ], + "name": "unknown_field", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "target" + ], + "value": { + "entity_asset_id": "sha256:0000000000000000000000000000000000000000000000000000000000000000", + "entity_id": "entity/face/0" + } + } + ], + "name": "optional_target_present", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "target" + ], + "value": { + "entity_asset_id": "sha256:0000000000000000000000000000000000000000000000000000000000000000", + "entity_id": "entity/face/0" + } + } + ], + "name": "optional_fields_all_present", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "name" + ], + "value": "a" + } + ], + "name": "nullable_name_nonnull", + "valid": true + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "source" + ], + "value": { + "graph_id": "a", + "kind": "model_operation", + "node_id": "a", + "output_slot": 0 + } + } + ], + "name": "nullable_source_nonnull", + "valid": true + } + ], + "schema_pointer": "#/$defs/connector", + "variant": "anchor_kind=forwarded" + }, + { + "artifact": "scene", + "base": { + "camera_id": "a", + "far": 1, + "name": "a", + "near": 1, + "parent_node_id": null, + "projection": "perspective", + "transform": { + "origin": [ + 0, + 0, + 0 + ], + "x_axis": [ + 0, + 0, + 0 + ], + "y_axis": [ + 0, + 0, + 0 + ], + "z_axis": [ + 0, + 0, + 0 + ] + }, + "vertical_fov_degrees": 1 + }, + "cases": [ + { + "mutations": [], + "name": "base_valid", + "valid": true + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "camera_id" + ] + } + ], + "name": "missing_camera_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "name" + ] + } + ], + "name": "missing_name", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "projection" + ] + } + ], + "name": "missing_projection", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "parent_node_id" + ] + } + ], + "name": "missing_parent_node_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "transform" + ] + } + ], + "name": "missing_transform", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "near" + ] + } + ], + "name": "missing_near", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "far" + ] + } + ], + "name": "missing_far", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "vertical_fov_degrees" + ] + } + ], + "name": "missing_vertical_fov_degrees", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "unknown" + ], + "value": true + } + ], + "name": "unknown_field", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "parent_node_id" + ], + "value": "a" + } + ], + "name": "nullable_parent_node_id_nonnull", + "valid": true + } + ], + "schema_pointer": "#/$defs/perspectiveCamera", + "variant": "base" + }, + { + "artifact": "scene", + "base": { + "camera_id": "a", + "far": 1, + "name": "a", + "near": 1, + "parent_node_id": null, + "projection": "orthographic", + "transform": { + "origin": [ + 0, + 0, + 0 + ], + "x_axis": [ + 0, + 0, + 0 + ], + "y_axis": [ + 0, + 0, + 0 + ], + "z_axis": [ + 0, + 0, + 0 + ] + }, + "vertical_span": 1 + }, + "cases": [ + { + "mutations": [], + "name": "base_valid", + "valid": true + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "camera_id" + ] + } + ], + "name": "missing_camera_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "name" + ] + } + ], + "name": "missing_name", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "projection" + ] + } + ], + "name": "missing_projection", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "parent_node_id" + ] + } + ], + "name": "missing_parent_node_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "transform" + ] + } + ], + "name": "missing_transform", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "near" + ] + } + ], + "name": "missing_near", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "far" + ] + } + ], + "name": "missing_far", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "vertical_span" + ] + } + ], + "name": "missing_vertical_span", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "unknown" + ], + "value": true + } + ], + "name": "unknown_field", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "parent_node_id" + ], + "value": "a" + } + ], + "name": "nullable_parent_node_id_nonnull", + "valid": true + } + ], + "schema_pointer": "#/$defs/orthographicCamera", + "variant": "base" + }, + { + "artifact": "entities", + "base": { + "definition_id": "a", + "edge_asset_id": "sha256:0000000000000000000000000000000000000000000000000000000000000000", + "edge_groups": [], + "entities": [ + { + "child_entity_ids": [], + "connector_binding_status": "not_applicable", + "entity_id": "entity/face/0", + "evaluated_tags": [], + "geometry": { + "type": "brep_solid" + }, + "kind": "solid", + "parent_entity_ids": [], + "properties": { + "bounds": { + "max": [ + 0, + 0, + 0 + ], + "min": [ + 0, + 0, + 0 + ] + }, + "centroid": [ + 0, + 0, + 0 + ], + "quality": "kernel_evaluated", + "surface_area": 0, + "volume": 0 + }, + "render_status": "rendered", + "sdk_connector_frame": null, + "sdk_metadata": {}, + "semantic_binding_ids": [], + "source": { + "graph_id": "a", + "kind": "model_output", + "node_id": "a", + "output_slot": 0 + } + }, + { + "child_entity_ids": [], + "connector_binding_status": "not_applicable", + "entity_id": "entity/face/0", + "evaluated_tags": [], + "geometry": { + "type": "brep_solid" + }, + "kind": "solid", + "parent_entity_ids": [], + "properties": { + "bounds": { + "max": [ + 0, + 0, + 0 + ], + "min": [ + 0, + 0, + 0 + ] + }, + "centroid": [ + 0, + 0, + 0 + ], + "quality": "kernel_evaluated", + "surface_area": 0, + "volume": 0 + }, + "render_status": "rendered", + "sdk_connector_frame": null, + "sdk_metadata": {}, + "semantic_binding_ids": [], + "source": { + "graph_id": "a", + "kind": "model_output", + "node_id": "a", + "output_slot": 0 + } + }, + { + "child_entity_ids": [], + "connector_binding_status": "not_applicable", + "entity_id": "entity/face/0", + "evaluated_tags": [], + "geometry": { + "type": "brep_solid" + }, + "kind": "solid", + "parent_entity_ids": [], + "properties": { + "bounds": { + "max": [ + 0, + 0, + 0 + ], + "min": [ + 0, + 0, + 0 + ] + }, + "centroid": [ + 0, + 0, + 0 + ], + "quality": "kernel_evaluated", + "surface_area": 0, + "volume": 0 + }, + "render_status": "rendered", + "sdk_connector_frame": null, + "sdk_metadata": {}, + "semantic_binding_ids": [], + "source": { + "graph_id": "a", + "kind": "model_output", + "node_id": "a", + "output_slot": 0 + } + }, + { + "child_entity_ids": [], + "connector_binding_status": "not_applicable", + "entity_id": "entity/face/0", + "evaluated_tags": [], + "geometry": { + "type": "brep_solid" + }, + "kind": "solid", + "parent_entity_ids": [], + "properties": { + "bounds": { + "max": [ + 0, + 0, + 0 + ], + "min": [ + 0, + 0, + 0 + ] + }, + "centroid": [ + 0, + 0, + 0 + ], + "quality": "kernel_evaluated", + "surface_area": 0, + "volume": 0 + }, + "render_status": "rendered", + "sdk_connector_frame": null, + "sdk_metadata": {}, + "semantic_binding_ids": [], + "source": { + "graph_id": "a", + "kind": "model_output", + "node_id": "a", + "output_slot": 0 + } + } + ], + "face_groups": [ + { + "entity_id": "entity/face/0", + "first_index": 0, + "group_id": 0, + "index_count": 1, + "mesh_index": 0, + "primitive_index": 0 + } + ], + "geometry_asset_id": "sha256:0000000000000000000000000000000000000000000000000000000000000000", + "geometry_engine": { + "name": "OpenCascade", + "profile": "ocp-evaluated-properties-1", + "version": "a" + }, + "schema_version": "1.0" + }, + "cases": [ + { + "mutations": [], + "name": "base_valid", + "valid": true + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "schema_version" + ] + } + ], + "name": "missing_schema_version", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "definition_id" + ] + } + ], + "name": "missing_definition_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "geometry_asset_id" + ] + } + ], + "name": "missing_geometry_asset_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "edge_asset_id" + ] + } + ], + "name": "missing_edge_asset_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "geometry_engine" + ] + } + ], + "name": "missing_geometry_engine", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "entities" + ] + } + ], + "name": "missing_entities", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "face_groups" + ] + } + ], + "name": "missing_face_groups", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "edge_groups" + ] + } + ], + "name": "missing_edge_groups", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "unknown" + ], + "value": true + } + ], + "name": "unknown_field", + "valid": false + } + ], + "schema_pointer": "#", + "variant": "base" + }, + { + "artifact": "entities", + "base": { + "origin": [ + 0, + 0, + 0 + ], + "x_axis": [ + 0, + 0, + 0 + ], + "y_axis": [ + 0, + 0, + 0 + ], + "z_axis": [ + 0, + 0, + 0 + ] + }, + "cases": [ + { + "mutations": [], + "name": "base_valid", + "valid": true + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "origin" + ] + } + ], + "name": "missing_origin", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "x_axis" + ] + } + ], + "name": "missing_x_axis", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "y_axis" + ] + } + ], + "name": "missing_y_axis", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "z_axis" + ] + } + ], + "name": "missing_z_axis", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "unknown" + ], + "value": true + } + ], + "name": "unknown_field", + "valid": false + } + ], + "schema_pointer": "#/$defs/transform", + "variant": "base" + }, + { + "artifact": "entities", + "base": { + "max": [ + 0, + 0, + 0 + ], + "min": [ + 0, + 0, + 0 + ] + }, + "cases": [ + { + "mutations": [], + "name": "base_valid", + "valid": true + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "min" + ] + } + ], + "name": "missing_min", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "max" + ] + } + ], + "name": "missing_max", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "unknown" + ], + "value": true + } + ], + "name": "unknown_field", + "valid": false + } + ], + "schema_pointer": "#/$defs/bounds", + "variant": "base" + }, + { + "artifact": "entities", + "base": { + "name": "OpenCascade", + "profile": "ocp-evaluated-properties-1", + "version": "a" + }, + "cases": [ + { + "mutations": [], + "name": "base_valid", + "valid": true + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "name" + ] + } + ], + "name": "missing_name", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "version" + ] + } + ], + "name": "missing_version", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "profile" + ] + } + ], + "name": "missing_profile", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "unknown" + ], + "value": true + } + ], + "name": "unknown_field", + "valid": false + } + ], + "schema_pointer": "#/$defs/geometryEngine", + "variant": "base" + }, + { + "artifact": "entities", + "base": { + "graph_id": "a", + "kind": "model_output", + "node_id": "a", + "output_slot": 0 + }, + "cases": [ + { + "mutations": [], + "name": "base_valid", + "valid": true + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "kind" + ] + } + ], + "name": "missing_kind", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "graph_id" + ] + } + ], + "name": "missing_graph_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "node_id" + ] + } + ], + "name": "missing_node_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "output_slot" + ] + } + ], + "name": "missing_output_slot", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "unknown" + ], + "value": true + } + ], + "name": "unknown_field", + "valid": false + } + ], + "schema_pointer": "#/$defs/modelOutput", + "variant": "base" + }, + { + "artifact": "entities", + "base": { + "graph_id": "a", + "kind": "model_topology", + "node_id": "a", + "output_slot": 0, + "topo_id": "a", + "topology_kind": "SOLID" + }, + "cases": [ + { + "mutations": [], + "name": "base_valid", + "valid": true + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "kind" + ] + } + ], + "name": "missing_kind", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "graph_id" + ] + } + ], + "name": "missing_graph_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "node_id" + ] + } + ], + "name": "missing_node_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "output_slot" + ] + } + ], + "name": "missing_output_slot", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "topology_kind" + ] + } + ], + "name": "missing_topology_kind", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "topo_id" + ] + } + ], + "name": "missing_topo_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "unknown" + ], + "value": true + } + ], + "name": "unknown_field", + "valid": false + } + ], + "schema_pointer": "#/$defs/modelTopology", + "variant": "topology_kind=SOLID" + }, + { + "artifact": "entities", + "base": { + "graph_id": "a", + "kind": "model_topology", + "node_id": "a", + "output_slot": 0, + "topo_id": "a", + "topology_kind": "FACE" + }, + "cases": [ + { + "mutations": [], + "name": "base_valid", + "valid": true + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "kind" + ] + } + ], + "name": "missing_kind", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "graph_id" + ] + } + ], + "name": "missing_graph_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "node_id" + ] + } + ], + "name": "missing_node_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "output_slot" + ] + } + ], + "name": "missing_output_slot", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "topology_kind" + ] + } + ], + "name": "missing_topology_kind", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "topo_id" + ] + } + ], + "name": "missing_topo_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "unknown" + ], + "value": true + } + ], + "name": "unknown_field", + "valid": false + } + ], + "schema_pointer": "#/$defs/modelTopology", + "variant": "topology_kind=FACE" + }, + { + "artifact": "entities", + "base": { + "graph_id": "a", + "kind": "model_topology", + "node_id": "a", + "output_slot": 0, + "topo_id": "a", + "topology_kind": "EDGE" + }, + "cases": [ + { + "mutations": [], + "name": "base_valid", + "valid": true + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "kind" + ] + } + ], + "name": "missing_kind", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "graph_id" + ] + } + ], + "name": "missing_graph_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "node_id" + ] + } + ], + "name": "missing_node_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "output_slot" + ] + } + ], + "name": "missing_output_slot", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "topology_kind" + ] + } + ], + "name": "missing_topology_kind", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "topo_id" + ] + } + ], + "name": "missing_topo_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "unknown" + ], + "value": true + } + ], + "name": "unknown_field", + "valid": false + } + ], + "schema_pointer": "#/$defs/modelTopology", + "variant": "topology_kind=EDGE" + }, + { + "artifact": "entities", + "base": { + "graph_id": "a", + "kind": "model_topology", + "node_id": "a", + "output_slot": 0, + "topo_id": "a", + "topology_kind": "VERTEX" + }, + "cases": [ + { + "mutations": [], + "name": "base_valid", + "valid": true + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "kind" + ] + } + ], + "name": "missing_kind", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "graph_id" + ] + } + ], + "name": "missing_graph_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "node_id" + ] + } + ], + "name": "missing_node_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "output_slot" + ] + } + ], + "name": "missing_output_slot", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "topology_kind" + ] + } + ], + "name": "missing_topology_kind", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "topo_id" + ] + } + ], + "name": "missing_topo_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "unknown" + ], + "value": true + } + ], + "name": "unknown_field", + "valid": false + } + ], + "schema_pointer": "#/$defs/modelTopology", + "variant": "topology_kind=VERTEX" + }, + { + "artifact": "entities", + "base": { + "kind": "imported_primitive", + "source_element_id": "a" + }, + "cases": [ + { + "mutations": [], + "name": "base_valid", + "valid": true + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "kind" + ] + } + ], + "name": "missing_kind", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "source_element_id" + ] + } + ], + "name": "missing_source_element_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "unknown" + ], + "value": true + } + ], + "name": "unknown_field", + "valid": false + } + ], + "schema_pointer": "#/$defs/importedPrimitive", + "variant": "base" + }, + { + "artifact": "entities", + "base": { + "kind": "unbound" + }, + "cases": [ + { + "mutations": [], + "name": "base_valid", + "valid": true + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "kind" + ] + } + ], + "name": "missing_kind", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "unknown" + ], + "value": true + } + ], + "name": "unknown_field", + "valid": false + } + ], + "schema_pointer": "#/$defs/unbound", + "variant": "base" + }, + { + "artifact": "entities", + "base": { + "type": "brep_solid" + }, + "cases": [ + { + "mutations": [], + "name": "base_valid", + "valid": true + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "type" + ] + } + ], + "name": "missing_type", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "unknown" + ], + "value": true + } + ], + "name": "unknown_field", + "valid": false + } + ], + "schema_pointer": "#/$defs/brepSolid", + "variant": "base" + }, + { + "artifact": "entities", + "base": { + "position": [ + 0, + 0, + 0 + ], + "type": "point" + }, + "cases": [ + { + "mutations": [], + "name": "base_valid", + "valid": true + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "type" + ] + } + ], + "name": "missing_type", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "position" + ] + } + ], + "name": "missing_position", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "unknown" + ], + "value": true + } + ], + "name": "unknown_field", + "valid": false + } + ], + "schema_pointer": "#/$defs/point", + "variant": "base" + }, + { + "artifact": "entities", + "base": { + "direction": [ + 0, + 0, + 0 + ], + "origin": [ + 0, + 0, + 0 + ], + "type": "line" + }, + "cases": [ + { + "mutations": [], + "name": "base_valid", + "valid": true + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "type" + ] + } + ], + "name": "missing_type", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "origin" + ] + } + ], + "name": "missing_origin", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "direction" + ] + } + ], + "name": "missing_direction", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "unknown" + ], + "value": true + } + ], + "name": "unknown_field", + "valid": false + } + ], + "schema_pointer": "#/$defs/line", + "variant": "base" + }, + { + "artifact": "entities", + "base": { + "center": [ + 0, + 0, + 0 + ], + "normal": [ + 0, + 0, + 0 + ], + "radius": 1, + "type": "circle", + "x_direction": [ + 0, + 0, + 0 + ] + }, + "cases": [ + { + "mutations": [], + "name": "base_valid", + "valid": true + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "type" + ] + } + ], + "name": "missing_type", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "center" + ] + } + ], + "name": "missing_center", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "normal" + ] + } + ], + "name": "missing_normal", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "x_direction" + ] + } + ], + "name": "missing_x_direction", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "radius" + ] + } + ], + "name": "missing_radius", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "unknown" + ], + "value": true + } + ], + "name": "unknown_field", + "valid": false + } + ], + "schema_pointer": "#/$defs/circle", + "variant": "base" + }, + { + "artifact": "entities", + "base": { + "center": [ + 0, + 0, + 0 + ], + "major_radius": 1, + "minor_radius": 1, + "normal": [ + 0, + 0, + 0 + ], + "type": "ellipse", + "x_direction": [ + 0, + 0, + 0 + ] + }, + "cases": [ + { + "mutations": [], + "name": "base_valid", + "valid": true + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "type" + ] + } + ], + "name": "missing_type", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "center" + ] + } + ], + "name": "missing_center", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "normal" + ] + } + ], + "name": "missing_normal", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "x_direction" + ] + } + ], + "name": "missing_x_direction", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "major_radius" + ] + } + ], + "name": "missing_major_radius", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "minor_radius" + ] + } + ], + "name": "missing_minor_radius", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "unknown" + ], + "value": true + } + ], + "name": "unknown_field", + "valid": false + } + ], + "schema_pointer": "#/$defs/ellipse", + "variant": "base" + }, + { + "artifact": "entities", + "base": { + "degree": 0, + "knots_count": 0, + "periodic": false, + "poles_count": 0, + "rational": false, + "type": "bspline_curve" + }, + "cases": [ + { + "mutations": [], + "name": "base_valid", + "valid": true + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "type" + ] + } + ], + "name": "missing_type", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "degree" + ] + } + ], + "name": "missing_degree", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "rational" + ] + } + ], + "name": "missing_rational", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "periodic" + ] + } + ], + "name": "missing_periodic", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "poles_count" + ] + } + ], + "name": "missing_poles_count", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "knots_count" + ] + } + ], + "name": "missing_knots_count", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "unknown" + ], + "value": true + } + ], + "name": "unknown_field", + "valid": false + } + ], + "schema_pointer": "#/$defs/bsplineCurve", + "variant": "base" + }, + { + "artifact": "entities", + "base": { + "engine_type": "a", + "type": "other_curve" + }, + "cases": [ + { + "mutations": [], + "name": "base_valid", + "valid": true + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "type" + ] + } + ], + "name": "missing_type", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "engine_type" + ] + } + ], + "name": "missing_engine_type", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "unknown" + ], + "value": true + } + ], + "name": "unknown_field", + "valid": false + } + ], + "schema_pointer": "#/$defs/otherCurve", + "variant": "base" + }, + { + "artifact": "entities", + "base": { + "normal": [ + 0, + 0, + 0 + ], + "origin": [ + 0, + 0, + 0 + ], + "type": "plane", + "x_direction": [ + 0, + 0, + 0 + ] + }, + "cases": [ + { + "mutations": [], + "name": "base_valid", + "valid": true + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "type" + ] + } + ], + "name": "missing_type", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "origin" + ] + } + ], + "name": "missing_origin", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "normal" + ] + } + ], + "name": "missing_normal", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "x_direction" + ] + } + ], + "name": "missing_x_direction", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "unknown" + ], + "value": true + } + ], + "name": "unknown_field", + "valid": false + } + ], + "schema_pointer": "#/$defs/plane", + "variant": "base" + }, + { + "artifact": "entities", + "base": { + "axis": [ + 0, + 0, + 0 + ], + "origin": [ + 0, + 0, + 0 + ], + "radius": 1, + "type": "cylinder", + "x_direction": [ + 0, + 0, + 0 + ] + }, + "cases": [ + { + "mutations": [], + "name": "base_valid", + "valid": true + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "type" + ] + } + ], + "name": "missing_type", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "origin" + ] + } + ], + "name": "missing_origin", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "axis" + ] + } + ], + "name": "missing_axis", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "x_direction" + ] + } + ], + "name": "missing_x_direction", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "radius" + ] + } + ], + "name": "missing_radius", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "unknown" + ], + "value": true + } + ], + "name": "unknown_field", + "valid": false + } + ], + "schema_pointer": "#/$defs/cylinder", + "variant": "base" + }, + { + "artifact": "entities", + "base": { + "axis": [ + 0, + 0, + 0 + ], + "origin": [ + 0, + 0, + 0 + ], + "reference_radius": 0, + "semi_angle_degrees": 1, + "type": "cone", + "x_direction": [ + 0, + 0, + 0 + ] + }, + "cases": [ + { + "mutations": [], + "name": "base_valid", + "valid": true + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "type" + ] + } + ], + "name": "missing_type", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "origin" + ] + } + ], + "name": "missing_origin", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "axis" + ] + } + ], + "name": "missing_axis", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "x_direction" + ] + } + ], + "name": "missing_x_direction", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "reference_radius" + ] + } + ], + "name": "missing_reference_radius", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "semi_angle_degrees" + ] + } + ], + "name": "missing_semi_angle_degrees", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "unknown" + ], + "value": true + } + ], + "name": "unknown_field", + "valid": false + } + ], + "schema_pointer": "#/$defs/cone", + "variant": "base" + }, + { + "artifact": "entities", + "base": { + "axis": [ + 0, + 0, + 0 + ], + "center": [ + 0, + 0, + 0 + ], + "radius": 1, + "type": "sphere", + "x_direction": [ + 0, + 0, + 0 + ] + }, + "cases": [ + { + "mutations": [], + "name": "base_valid", + "valid": true + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "type" + ] + } + ], + "name": "missing_type", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "center" + ] + } + ], + "name": "missing_center", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "axis" + ] + } + ], + "name": "missing_axis", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "x_direction" + ] + } + ], + "name": "missing_x_direction", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "radius" + ] + } + ], + "name": "missing_radius", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "unknown" + ], + "value": true + } + ], + "name": "unknown_field", + "valid": false + } + ], + "schema_pointer": "#/$defs/sphere", + "variant": "base" + }, + { + "artifact": "entities", + "base": { + "axis": [ + 0, + 0, + 0 + ], + "center": [ + 0, + 0, + 0 + ], + "major_radius": 1, + "minor_radius": 1, + "type": "torus", + "x_direction": [ + 0, + 0, + 0 + ] + }, + "cases": [ + { + "mutations": [], + "name": "base_valid", + "valid": true + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "type" + ] + } + ], + "name": "missing_type", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "center" + ] + } + ], + "name": "missing_center", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "axis" + ] + } + ], + "name": "missing_axis", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "x_direction" + ] + } + ], + "name": "missing_x_direction", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "major_radius" + ] + } + ], + "name": "missing_major_radius", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "minor_radius" + ] + } + ], + "name": "missing_minor_radius", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "unknown" + ], + "value": true + } + ], + "name": "unknown_field", + "valid": false + } + ], + "schema_pointer": "#/$defs/torus", + "variant": "base" + }, + { + "artifact": "entities", + "base": { + "type": "bspline_surface", + "u_degree": 0, + "u_knots_count": 0, + "u_periodic": false, + "u_poles_count": 0, + "u_rational": false, + "v_degree": 0, + "v_knots_count": 0, + "v_periodic": false, + "v_poles_count": 0, + "v_rational": false + }, + "cases": [ + { + "mutations": [], + "name": "base_valid", + "valid": true + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "type" + ] + } + ], + "name": "missing_type", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "u_degree" + ] + } + ], + "name": "missing_u_degree", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "v_degree" + ] + } + ], + "name": "missing_v_degree", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "u_rational" + ] + } + ], + "name": "missing_u_rational", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "v_rational" + ] + } + ], + "name": "missing_v_rational", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "u_periodic" + ] + } + ], + "name": "missing_u_periodic", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "v_periodic" + ] + } + ], + "name": "missing_v_periodic", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "u_poles_count" + ] + } + ], + "name": "missing_u_poles_count", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "v_poles_count" + ] + } + ], + "name": "missing_v_poles_count", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "u_knots_count" + ] + } + ], + "name": "missing_u_knots_count", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "v_knots_count" + ] + } + ], + "name": "missing_v_knots_count", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "unknown" + ], + "value": true + } + ], + "name": "unknown_field", + "valid": false + } + ], + "schema_pointer": "#/$defs/bsplineSurface", + "variant": "base" + }, + { + "artifact": "entities", + "base": { + "engine_type": "a", + "type": "other_surface" + }, + "cases": [ + { + "mutations": [], + "name": "base_valid", + "valid": true + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "type" + ] + } + ], + "name": "missing_type", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "engine_type" + ] + } + ], + "name": "missing_engine_type", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "unknown" + ], + "value": true + } + ], + "name": "unknown_field", + "valid": false + } + ], + "schema_pointer": "#/$defs/otherSurface", + "variant": "base" + }, + { + "artifact": "entities", + "base": { + "bounds": { + "max": [ + 0, + 0, + 0 + ], + "min": [ + 0, + 0, + 0 + ] + }, + "centroid": [ + 0, + 0, + 0 + ], + "quality": "kernel_evaluated", + "surface_area": 0, + "volume": 0 + }, + "cases": [ + { + "mutations": [], + "name": "base_valid", + "valid": true + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "quality" + ] + } + ], + "name": "missing_quality", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "bounds" + ] + } + ], + "name": "missing_bounds", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "volume" + ] + } + ], + "name": "missing_volume", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "surface_area" + ] + } + ], + "name": "missing_surface_area", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "centroid" + ] + } + ], + "name": "missing_centroid", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "unknown" + ], + "value": true + } + ], + "name": "unknown_field", + "valid": false + } + ], + "schema_pointer": "#/$defs/solidProperties", + "variant": "base" + }, + { + "artifact": "entities", + "base": { + "area": 0, + "bounds": { + "max": [ + 0, + 0, + 0 + ], + "min": [ + 0, + 0, + 0 + ] + }, + "centroid": [ + 0, + 0, + 0 + ], + "orientation": "forward", + "quality": "kernel_evaluated" + }, + "cases": [ + { + "mutations": [], + "name": "base_valid", + "valid": true + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "quality" + ] + } + ], + "name": "missing_quality", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "bounds" + ] + } + ], + "name": "missing_bounds", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "area" + ] + } + ], + "name": "missing_area", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "centroid" + ] + } + ], + "name": "missing_centroid", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "orientation" + ] + } + ], + "name": "missing_orientation", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "unknown" + ], + "value": true + } + ], + "name": "unknown_field", + "valid": false + } + ], + "schema_pointer": "#/$defs/faceProperties", + "variant": "orientation=forward" + }, + { + "artifact": "entities", + "base": { + "area": 0, + "bounds": { + "max": [ + 0, + 0, + 0 + ], + "min": [ + 0, + 0, + 0 + ] + }, + "centroid": [ + 0, + 0, + 0 + ], + "orientation": "reversed", + "quality": "kernel_evaluated" + }, + "cases": [ + { + "mutations": [], + "name": "base_valid", + "valid": true + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "quality" + ] + } + ], + "name": "missing_quality", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "bounds" + ] + } + ], + "name": "missing_bounds", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "area" + ] + } + ], + "name": "missing_area", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "centroid" + ] + } + ], + "name": "missing_centroid", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "orientation" + ] + } + ], + "name": "missing_orientation", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "unknown" + ], + "value": true + } + ], + "name": "unknown_field", + "valid": false + } + ], + "schema_pointer": "#/$defs/faceProperties", + "variant": "orientation=reversed" + }, + { + "artifact": "entities", + "base": { + "area": 0, + "bounds": { + "max": [ + 0, + 0, + 0 + ], + "min": [ + 0, + 0, + 0 + ] + }, + "centroid": [ + 0, + 0, + 0 + ], + "orientation": "internal", + "quality": "kernel_evaluated" + }, + "cases": [ + { + "mutations": [], + "name": "base_valid", + "valid": true + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "quality" + ] + } + ], + "name": "missing_quality", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "bounds" + ] + } + ], + "name": "missing_bounds", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "area" + ] + } + ], + "name": "missing_area", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "centroid" + ] + } + ], + "name": "missing_centroid", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "orientation" + ] + } + ], + "name": "missing_orientation", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "unknown" + ], + "value": true + } + ], + "name": "unknown_field", + "valid": false + } + ], + "schema_pointer": "#/$defs/faceProperties", + "variant": "orientation=internal" + }, + { + "artifact": "entities", + "base": { + "area": 0, + "bounds": { + "max": [ + 0, + 0, + 0 + ], + "min": [ + 0, + 0, + 0 + ] + }, + "centroid": [ + 0, + 0, + 0 + ], + "orientation": "external", + "quality": "kernel_evaluated" + }, + "cases": [ + { + "mutations": [], + "name": "base_valid", + "valid": true + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "quality" + ] + } + ], + "name": "missing_quality", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "bounds" + ] + } + ], + "name": "missing_bounds", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "area" + ] + } + ], + "name": "missing_area", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "centroid" + ] + } + ], + "name": "missing_centroid", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "orientation" + ] + } + ], + "name": "missing_orientation", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "unknown" + ], + "value": true + } + ], + "name": "unknown_field", + "valid": false + } + ], + "schema_pointer": "#/$defs/faceProperties", + "variant": "orientation=external" + }, + { + "artifact": "entities", + "base": { + "bounds": { + "max": [ + 0, + 0, + 0 + ], + "min": [ + 0, + 0, + 0 + ] + }, + "centroid": [ + 0, + 0, + 0 + ], + "length": 0, + "quality": "kernel_evaluated" + }, + "cases": [ + { + "mutations": [], + "name": "base_valid", + "valid": true + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "quality" + ] + } + ], + "name": "missing_quality", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "bounds" + ] + } + ], + "name": "missing_bounds", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "length" + ] + } + ], + "name": "missing_length", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "centroid" + ] + } + ], + "name": "missing_centroid", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "unknown" + ], + "value": true + } + ], + "name": "unknown_field", + "valid": false + } + ], + "schema_pointer": "#/$defs/edgeProperties", + "variant": "base" + }, + { + "artifact": "entities", + "base": { + "bounds": { + "max": [ + 0, + 0, + 0 + ], + "min": [ + 0, + 0, + 0 + ] + }, + "position": [ + 0, + 0, + 0 + ], + "quality": "kernel_evaluated" + }, + "cases": [ + { + "mutations": [], + "name": "base_valid", + "valid": true + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "quality" + ] + } + ], + "name": "missing_quality", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "bounds" + ] + } + ], + "name": "missing_bounds", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "position" + ] + } + ], + "name": "missing_position", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "unknown" + ], + "value": true + } + ], + "name": "unknown_field", + "valid": false + } + ], + "schema_pointer": "#/$defs/vertexProperties", + "variant": "base" + }, + { + "artifact": "entities", + "base": { + "child_entity_ids": [], + "connector_binding_status": "not_applicable", + "entity_id": "entity/face/0", + "evaluated_tags": [], + "geometry": { + "type": "brep_solid" + }, + "kind": "solid", + "parent_entity_ids": [], + "properties": { + "bounds": { + "max": [ + 0, + 0, + 0 + ], + "min": [ + 0, + 0, + 0 + ] + }, + "centroid": [ + 0, + 0, + 0 + ], + "quality": "kernel_evaluated", + "surface_area": 0, + "volume": 0 + }, + "render_status": "rendered", + "sdk_connector_frame": null, + "sdk_metadata": {}, + "semantic_binding_ids": [], + "source": { + "graph_id": "a", + "kind": "model_output", + "node_id": "a", + "output_slot": 0 + } + }, + "cases": [ + { + "mutations": [], + "name": "base_valid", + "valid": true + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "entity_id" + ] + } + ], + "name": "missing_entity_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "kind" + ] + } + ], + "name": "missing_kind", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "parent_entity_ids" + ] + } + ], + "name": "missing_parent_entity_ids", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "child_entity_ids" + ] + } + ], + "name": "missing_child_entity_ids", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "source" + ] + } + ], + "name": "missing_source", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "geometry" + ] + } + ], + "name": "missing_geometry", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "properties" + ] + } + ], + "name": "missing_properties", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "sdk_connector_frame" + ] + } + ], + "name": "missing_sdk_connector_frame", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "render_status" + ] + } + ], + "name": "missing_render_status", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "connector_binding_status" + ] + } + ], + "name": "missing_connector_binding_status", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "semantic_binding_ids" + ] + } + ], + "name": "missing_semantic_binding_ids", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "evaluated_tags" + ] + } + ], + "name": "missing_evaluated_tags", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "sdk_metadata" + ] + } + ], + "name": "missing_sdk_metadata", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "unknown" + ], + "value": true + } + ], + "name": "unknown_field", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "sdk_connector_frame" + ], + "value": { + "origin": [ + 0, + 0, + 0 + ], + "x_axis": [ + 0, + 0, + 0 + ], + "y_axis": [ + 0, + 0, + 0 + ], + "z_axis": [ + 0, + 0, + 0 + ] + } + } + ], + "name": "nullable_sdk_connector_frame_nonnull", + "valid": true + } + ], + "schema_pointer": "#/$defs/entity", + "variant": "kind=solid" + }, + { + "artifact": "entities", + "base": { + "child_entity_ids": [], + "connector_binding_status": "not_applicable", + "entity_id": "entity/face/0", + "evaluated_tags": [], + "geometry": { + "type": "brep_solid" + }, + "kind": "face", + "parent_entity_ids": [], + "properties": { + "bounds": { + "max": [ + 0, + 0, + 0 + ], + "min": [ + 0, + 0, + 0 + ] + }, + "centroid": [ + 0, + 0, + 0 + ], + "quality": "kernel_evaluated", + "surface_area": 0, + "volume": 0 + }, + "render_status": "rendered", + "sdk_connector_frame": null, + "sdk_metadata": {}, + "semantic_binding_ids": [], + "source": { + "graph_id": "a", + "kind": "model_output", + "node_id": "a", + "output_slot": 0 + } + }, + "cases": [ + { + "mutations": [], + "name": "base_valid", + "valid": true + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "entity_id" + ] + } + ], + "name": "missing_entity_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "kind" + ] + } + ], + "name": "missing_kind", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "parent_entity_ids" + ] + } + ], + "name": "missing_parent_entity_ids", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "child_entity_ids" + ] + } + ], + "name": "missing_child_entity_ids", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "source" + ] + } + ], + "name": "missing_source", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "geometry" + ] + } + ], + "name": "missing_geometry", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "properties" + ] + } + ], + "name": "missing_properties", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "sdk_connector_frame" + ] + } + ], + "name": "missing_sdk_connector_frame", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "render_status" + ] + } + ], + "name": "missing_render_status", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "connector_binding_status" + ] + } + ], + "name": "missing_connector_binding_status", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "semantic_binding_ids" + ] + } + ], + "name": "missing_semantic_binding_ids", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "evaluated_tags" + ] + } + ], + "name": "missing_evaluated_tags", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "sdk_metadata" + ] + } + ], + "name": "missing_sdk_metadata", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "unknown" + ], + "value": true + } + ], + "name": "unknown_field", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "sdk_connector_frame" + ], + "value": { + "origin": [ + 0, + 0, + 0 + ], + "x_axis": [ + 0, + 0, + 0 + ], + "y_axis": [ + 0, + 0, + 0 + ], + "z_axis": [ + 0, + 0, + 0 + ] + } + } + ], + "name": "nullable_sdk_connector_frame_nonnull", + "valid": true + } + ], + "schema_pointer": "#/$defs/entity", + "variant": "kind=face" + }, + { + "artifact": "entities", + "base": { + "child_entity_ids": [], + "connector_binding_status": "not_applicable", + "entity_id": "entity/face/0", + "evaluated_tags": [], + "geometry": { + "type": "brep_solid" + }, + "kind": "edge", + "parent_entity_ids": [], + "properties": { + "bounds": { + "max": [ + 0, + 0, + 0 + ], + "min": [ + 0, + 0, + 0 + ] + }, + "centroid": [ + 0, + 0, + 0 + ], + "quality": "kernel_evaluated", + "surface_area": 0, + "volume": 0 + }, + "render_status": "rendered", + "sdk_connector_frame": null, + "sdk_metadata": {}, + "semantic_binding_ids": [], + "source": { + "graph_id": "a", + "kind": "model_output", + "node_id": "a", + "output_slot": 0 + } + }, + "cases": [ + { + "mutations": [], + "name": "base_valid", + "valid": true + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "entity_id" + ] + } + ], + "name": "missing_entity_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "kind" + ] + } + ], + "name": "missing_kind", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "parent_entity_ids" + ] + } + ], + "name": "missing_parent_entity_ids", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "child_entity_ids" + ] + } + ], + "name": "missing_child_entity_ids", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "source" + ] + } + ], + "name": "missing_source", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "geometry" + ] + } + ], + "name": "missing_geometry", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "properties" + ] + } + ], + "name": "missing_properties", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "sdk_connector_frame" + ] + } + ], + "name": "missing_sdk_connector_frame", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "render_status" + ] + } + ], + "name": "missing_render_status", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "connector_binding_status" + ] + } + ], + "name": "missing_connector_binding_status", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "semantic_binding_ids" + ] + } + ], + "name": "missing_semantic_binding_ids", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "evaluated_tags" + ] + } + ], + "name": "missing_evaluated_tags", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "sdk_metadata" + ] + } + ], + "name": "missing_sdk_metadata", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "unknown" + ], + "value": true + } + ], + "name": "unknown_field", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "sdk_connector_frame" + ], + "value": { + "origin": [ + 0, + 0, + 0 + ], + "x_axis": [ + 0, + 0, + 0 + ], + "y_axis": [ + 0, + 0, + 0 + ], + "z_axis": [ + 0, + 0, + 0 + ] + } + } + ], + "name": "nullable_sdk_connector_frame_nonnull", + "valid": true + } + ], + "schema_pointer": "#/$defs/entity", + "variant": "kind=edge" + }, + { + "artifact": "entities", + "base": { + "child_entity_ids": [], + "connector_binding_status": "not_applicable", + "entity_id": "entity/face/0", + "evaluated_tags": [], + "geometry": { + "type": "brep_solid" + }, + "kind": "vertex", + "parent_entity_ids": [], + "properties": { + "bounds": { + "max": [ + 0, + 0, + 0 + ], + "min": [ + 0, + 0, + 0 + ] + }, + "centroid": [ + 0, + 0, + 0 + ], + "quality": "kernel_evaluated", + "surface_area": 0, + "volume": 0 + }, + "render_status": "rendered", + "sdk_connector_frame": null, + "sdk_metadata": {}, + "semantic_binding_ids": [], + "source": { + "graph_id": "a", + "kind": "model_output", + "node_id": "a", + "output_slot": 0 + } + }, + "cases": [ + { + "mutations": [], + "name": "base_valid", + "valid": true + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "entity_id" + ] + } + ], + "name": "missing_entity_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "kind" + ] + } + ], + "name": "missing_kind", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "parent_entity_ids" + ] + } + ], + "name": "missing_parent_entity_ids", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "child_entity_ids" + ] + } + ], + "name": "missing_child_entity_ids", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "source" + ] + } + ], + "name": "missing_source", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "geometry" + ] + } + ], + "name": "missing_geometry", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "properties" + ] + } + ], + "name": "missing_properties", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "sdk_connector_frame" + ] + } + ], + "name": "missing_sdk_connector_frame", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "render_status" + ] + } + ], + "name": "missing_render_status", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "connector_binding_status" + ] + } + ], + "name": "missing_connector_binding_status", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "semantic_binding_ids" + ] + } + ], + "name": "missing_semantic_binding_ids", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "evaluated_tags" + ] + } + ], + "name": "missing_evaluated_tags", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "sdk_metadata" + ] + } + ], + "name": "missing_sdk_metadata", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "unknown" + ], + "value": true + } + ], + "name": "unknown_field", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "sdk_connector_frame" + ], + "value": { + "origin": [ + 0, + 0, + 0 + ], + "x_axis": [ + 0, + 0, + 0 + ], + "y_axis": [ + 0, + 0, + 0 + ], + "z_axis": [ + 0, + 0, + 0 + ] + } + } + ], + "name": "nullable_sdk_connector_frame_nonnull", + "valid": true + } + ], + "schema_pointer": "#/$defs/entity", + "variant": "kind=vertex" + }, + { + "artifact": "entities", + "base": { + "entity_id": "entity/face/0", + "first_index": 0, + "group_id": 0, + "index_count": 1, + "mesh_index": 0, + "primitive_index": 0 + }, + "cases": [ + { + "mutations": [], + "name": "base_valid", + "valid": true + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "group_id" + ] + } + ], + "name": "missing_group_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "entity_id" + ] + } + ], + "name": "missing_entity_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "mesh_index" + ] + } + ], + "name": "missing_mesh_index", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "primitive_index" + ] + } + ], + "name": "missing_primitive_index", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "first_index" + ] + } + ], + "name": "missing_first_index", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "index_count" + ] + } + ], + "name": "missing_index_count", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "unknown" + ], + "value": true + } + ], + "name": "unknown_field", + "valid": false + } + ], + "schema_pointer": "#/$defs/group", + "variant": "base" + }, + { + "artifact": "presentation", + "base": { + "appearances": [], + "cameras": [], + "node_overrides": [], + "presentation_id": "a", + "schema_version": "1.0", + "source_scene_id": "a" + }, + "cases": [ + { + "mutations": [], + "name": "base_valid", + "valid": true + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "schema_version" + ] + } + ], + "name": "missing_schema_version", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "presentation_id" + ] + } + ], + "name": "missing_presentation_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "source_scene_id" + ] + } + ], + "name": "missing_source_scene_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "appearances" + ] + } + ], + "name": "missing_appearances", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "node_overrides" + ] + } + ], + "name": "missing_node_overrides", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "cameras" + ] + } + ], + "name": "missing_cameras", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "unknown" + ], + "value": true + } + ], + "name": "unknown_field", + "valid": false + } + ], + "schema_pointer": "#", + "variant": "base" + }, + { + "artifact": "presentation", + "base": { + "origin": [ + 0, + 0, + 0 + ], + "x_axis": [ + 0, + 0, + 0 + ], + "y_axis": [ + 0, + 0, + 0 + ], + "z_axis": [ + 0, + 0, + 0 + ] + }, + "cases": [ + { + "mutations": [], + "name": "base_valid", + "valid": true + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "origin" + ] + } + ], + "name": "missing_origin", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "x_axis" + ] + } + ], + "name": "missing_x_axis", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "y_axis" + ] + } + ], + "name": "missing_y_axis", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "z_axis" + ] + } + ], + "name": "missing_z_axis", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "unknown" + ], + "value": true + } + ], + "name": "unknown_field", + "valid": false + } + ], + "schema_pointer": "#/$defs/transform", + "variant": "base" + }, + { + "artifact": "presentation", + "base": { + "alpha_mode": "opaque", + "base_color": [ + 0, + 0, + 0, + 1 + ], + "double_sided": false, + "edge_color": [ + 0, + 0, + 0, + 1 + ], + "metallic": 0, + "name": "a", + "roughness": 0 + }, + "cases": [ + { + "mutations": [], + "name": "base_valid", + "valid": true + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "name" + ] + } + ], + "name": "missing_name", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "base_color" + ] + } + ], + "name": "missing_base_color", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "metallic" + ] + } + ], + "name": "missing_metallic", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "roughness" + ] + } + ], + "name": "missing_roughness", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "alpha_mode" + ] + } + ], + "name": "missing_alpha_mode", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "double_sided" + ] + } + ], + "name": "missing_double_sided", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "edge_color" + ] + } + ], + "name": "missing_edge_color", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "unknown" + ], + "value": true + } + ], + "name": "unknown_field", + "valid": false + } + ], + "schema_pointer": "#/$defs/appearance", + "variant": "base" + }, + { + "artifact": "presentation", + "base": { + "node_id": "a", + "visible": false + }, + "cases": [ + { + "mutations": [], + "name": "base_valid", + "valid": true + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "node_id" + ] + } + ], + "name": "missing_node_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "visible" + ] + } + ], + "name": "missing_visible", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "unknown" + ], + "value": true + } + ], + "name": "unknown_field", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "appearance_name" + ], + "value": "a" + } + ], + "name": "optional_appearance_name_present", + "valid": true + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "appearance_name" + ], + "value": "a" + } + ], + "name": "optional_fields_all_present", + "valid": true + } + ], + "schema_pointer": "#/$defs/nodeOverride", + "variant": "base" + }, + { + "artifact": "presentation", + "base": { + "far": 1, + "name": "a", + "near": 1, + "parent_node_id": null, + "projection": "perspective", + "transform": { + "origin": [ + 0, + 0, + 0 + ], + "x_axis": [ + 0, + 0, + 0 + ], + "y_axis": [ + 0, + 0, + 0 + ], + "z_axis": [ + 0, + 0, + 0 + ] + }, + "vertical_fov_degrees": 1 + }, + "cases": [ + { + "mutations": [], + "name": "base_valid", + "valid": true + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "name" + ] + } + ], + "name": "missing_name", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "projection" + ] + } + ], + "name": "missing_projection", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "parent_node_id" + ] + } + ], + "name": "missing_parent_node_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "transform" + ] + } + ], + "name": "missing_transform", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "near" + ] + } + ], + "name": "missing_near", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "far" + ] + } + ], + "name": "missing_far", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "vertical_fov_degrees" + ] + } + ], + "name": "missing_vertical_fov_degrees", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "unknown" + ], + "value": true + } + ], + "name": "unknown_field", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "parent_node_id" + ], + "value": "a" + } + ], + "name": "nullable_parent_node_id_nonnull", + "valid": true + } + ], + "schema_pointer": "#/$defs/perspective", + "variant": "base" + }, + { + "artifact": "presentation", + "base": { + "far": 1, + "name": "a", + "near": 1, + "parent_node_id": null, + "projection": "orthographic", + "transform": { + "origin": [ + 0, + 0, + 0 + ], + "x_axis": [ + 0, + 0, + 0 + ], + "y_axis": [ + 0, + 0, + 0 + ], + "z_axis": [ + 0, + 0, + 0 + ] + }, + "vertical_span": 1 + }, + "cases": [ + { + "mutations": [], + "name": "base_valid", + "valid": true + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "name" + ] + } + ], + "name": "missing_name", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "projection" + ] + } + ], + "name": "missing_projection", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "parent_node_id" + ] + } + ], + "name": "missing_parent_node_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "transform" + ] + } + ], + "name": "missing_transform", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "near" + ] + } + ], + "name": "missing_near", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "far" + ] + } + ], + "name": "missing_far", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "vertical_span" + ] + } + ], + "name": "missing_vertical_span", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "unknown" + ], + "value": true + } + ], + "name": "unknown_field", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "parent_node_id" + ], + "value": "a" + } + ], + "name": "nullable_parent_node_id_nonnull", + "valid": true + } + ], + "schema_pointer": "#/$defs/orthographic", + "variant": "base" + }, + { + "artifact": "connector_binding", + "base": { + "binding_id": "a", + "connector_id": "a", + "name": null, + "owner_definition_id": "a", + "schema_version": "1.0", + "selected_occurrence_node_id": "a", + "source_model": { + "artifact_hash": "sha256:0000000000000000000000000000000000000000000000000000000000000000", + "graph_id": "a", + "model_schema_version": "2.0" + }, + "source_scene": { + "revision": "sha256:0000000000000000000000000000000000000000000000000000000000000000", + "scene_id": "a" + }, + "target": { + "entity_asset_id": "sha256:0000000000000000000000000000000000000000000000000000000000000000", + "entity_id": "entity/face/0", + "expected_source": { + "graph_id": "a", + "kind": "model_output", + "node_id": "a", + "output_slot": 0 + }, + "flip": false, + "kind": "topology_entity" + } + }, + "cases": [ + { + "mutations": [], + "name": "base_valid", + "valid": true + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "schema_version" + ] + } + ], + "name": "missing_schema_version", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "binding_id" + ] + } + ], + "name": "missing_binding_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "source_model" + ] + } + ], + "name": "missing_source_model", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "source_scene" + ] + } + ], + "name": "missing_source_scene", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "owner_definition_id" + ] + } + ], + "name": "missing_owner_definition_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "selected_occurrence_node_id" + ] + } + ], + "name": "missing_selected_occurrence_node_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "connector_id" + ] + } + ], + "name": "missing_connector_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "name" + ] + } + ], + "name": "missing_name", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "target" + ] + } + ], + "name": "missing_target", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "unknown" + ], + "value": true + } + ], + "name": "unknown_field", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "name" + ], + "value": "a" + } + ], + "name": "nullable_name_nonnull", + "valid": true + } + ], + "schema_pointer": "#", + "variant": "base" + }, + { + "artifact": "connector_binding", + "base": { + "artifact_hash": "sha256:0000000000000000000000000000000000000000000000000000000000000000", + "graph_id": "a", + "model_schema_version": "2.0" + }, + "cases": [ + { + "mutations": [], + "name": "base_valid", + "valid": true + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "graph_id" + ] + } + ], + "name": "missing_graph_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "model_schema_version" + ] + } + ], + "name": "missing_model_schema_version", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "artifact_hash" + ] + } + ], + "name": "missing_artifact_hash", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "unknown" + ], + "value": true + } + ], + "name": "unknown_field", + "valid": false + } + ], + "schema_pointer": "#/$defs/sourceModel", + "variant": "base" + }, + { + "artifact": "connector_binding", + "base": { + "revision": "sha256:0000000000000000000000000000000000000000000000000000000000000000", + "scene_id": "a" + }, + "cases": [ + { + "mutations": [], + "name": "base_valid", + "valid": true + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "scene_id" + ] + } + ], + "name": "missing_scene_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "revision" + ] + } + ], + "name": "missing_revision", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "unknown" + ], + "value": true + } + ], + "name": "unknown_field", + "valid": false + } + ], + "schema_pointer": "#/$defs/sourceScene", + "variant": "base" + }, + { + "artifact": "connector_binding", + "base": { + "graph_id": "a", + "kind": "model_output", + "node_id": "a", + "output_slot": 0 + }, + "cases": [ + { + "mutations": [], + "name": "base_valid", + "valid": true + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "kind" + ] + } + ], + "name": "missing_kind", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "graph_id" + ] + } + ], + "name": "missing_graph_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "node_id" + ] + } + ], + "name": "missing_node_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "output_slot" + ] + } + ], + "name": "missing_output_slot", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "unknown" + ], + "value": true + } + ], + "name": "unknown_field", + "valid": false + } + ], + "schema_pointer": "#/$defs/modelOutput", + "variant": "base" + }, + { + "artifact": "connector_binding", + "base": { + "graph_id": "a", + "kind": "model_topology", + "node_id": "a", + "output_slot": 0, + "topo_id": "a", + "topology_kind": "FACE" + }, + "cases": [ + { + "mutations": [], + "name": "base_valid", + "valid": true + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "kind" + ] + } + ], + "name": "missing_kind", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "graph_id" + ] + } + ], + "name": "missing_graph_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "node_id" + ] + } + ], + "name": "missing_node_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "output_slot" + ] + } + ], + "name": "missing_output_slot", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "topology_kind" + ] + } + ], + "name": "missing_topology_kind", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "topo_id" + ] + } + ], + "name": "missing_topo_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "unknown" + ], + "value": true + } + ], + "name": "unknown_field", + "valid": false + } + ], + "schema_pointer": "#/$defs/modelTopology", + "variant": "topology_kind=FACE" + }, + { + "artifact": "connector_binding", + "base": { + "graph_id": "a", + "kind": "model_topology", + "node_id": "a", + "output_slot": 0, + "topo_id": "a", + "topology_kind": "EDGE" + }, + "cases": [ + { + "mutations": [], + "name": "base_valid", + "valid": true + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "kind" + ] + } + ], + "name": "missing_kind", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "graph_id" + ] + } + ], + "name": "missing_graph_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "node_id" + ] + } + ], + "name": "missing_node_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "output_slot" + ] + } + ], + "name": "missing_output_slot", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "topology_kind" + ] + } + ], + "name": "missing_topology_kind", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "topo_id" + ] + } + ], + "name": "missing_topo_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "unknown" + ], + "value": true + } + ], + "name": "unknown_field", + "valid": false + } + ], + "schema_pointer": "#/$defs/modelTopology", + "variant": "topology_kind=EDGE" + }, + { + "artifact": "connector_binding", + "base": { + "graph_id": "a", + "kind": "model_topology", + "node_id": "a", + "output_slot": 0, + "topo_id": "a", + "topology_kind": "VERTEX" + }, + "cases": [ + { + "mutations": [], + "name": "base_valid", + "valid": true + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "kind" + ] + } + ], + "name": "missing_kind", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "graph_id" + ] + } + ], + "name": "missing_graph_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "node_id" + ] + } + ], + "name": "missing_node_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "output_slot" + ] + } + ], + "name": "missing_output_slot", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "topology_kind" + ] + } + ], + "name": "missing_topology_kind", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "topo_id" + ] + } + ], + "name": "missing_topo_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "unknown" + ], + "value": true + } + ], + "name": "unknown_field", + "valid": false + } + ], + "schema_pointer": "#/$defs/modelTopology", + "variant": "topology_kind=VERTEX" + }, + { + "artifact": "connector_binding", + "base": { + "entity_asset_id": "sha256:0000000000000000000000000000000000000000000000000000000000000000", + "entity_id": "entity/face/0", + "expected_source": { + "graph_id": "a", + "kind": "model_output", + "node_id": "a", + "output_slot": 0 + }, + "flip": false, + "kind": "topology_entity" + }, + "cases": [ + { + "mutations": [], + "name": "base_valid", + "valid": true + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "kind" + ] + } + ], + "name": "missing_kind", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "entity_asset_id" + ] + } + ], + "name": "missing_entity_asset_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "entity_id" + ] + } + ], + "name": "missing_entity_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "expected_source" + ] + } + ], + "name": "missing_expected_source", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "flip" + ] + } + ], + "name": "missing_flip", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "unknown" + ], + "value": true + } + ], + "name": "unknown_field", + "valid": false + } + ], + "schema_pointer": "#/$defs/target", + "variant": "base" + }, + { + "artifact": "normalized_product", + "base": { + "origin": [ + 0, + 0, + 0 + ], + "x_axis": [ + 0, + 0, + 0 + ], + "y_axis": [ + 0, + 0, + 0 + ], + "z_axis": [ + 0, + 0, + 0 + ] + }, + "cases": [ + { + "mutations": [], + "name": "base_valid", + "valid": true + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "origin" + ] + } + ], + "name": "missing_origin", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "x_axis" + ] + } + ], + "name": "missing_x_axis", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "y_axis" + ] + } + ], + "name": "missing_y_axis", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "z_axis" + ] + } + ], + "name": "missing_z_axis", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "unknown" + ], + "value": true + } + ], + "name": "unknown_field", + "valid": false + } + ], + "schema_pointer": "#/$defs/transform", + "variant": "base" + }, + { + "artifact": "normalized_product", + "base": { + "graph_id": "a", + "kind": "model_output", + "node_id": "a", + "output_slot": 0 + }, + "cases": [ + { + "mutations": [], + "name": "base_valid", + "valid": true + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "kind" + ] + } + ], + "name": "missing_kind", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "graph_id" + ] + } + ], + "name": "missing_graph_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "node_id" + ] + } + ], + "name": "missing_node_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "output_slot" + ] + } + ], + "name": "missing_output_slot", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "unknown" + ], + "value": true + } + ], + "name": "unknown_field", + "valid": false + } + ], + "schema_pointer": "#/$defs/modelBodySource", + "variant": "base" + }, + { + "artifact": "normalized_product", + "base": { + "kind": "manual", + "source_id": "a" + }, + "cases": [ + { + "mutations": [], + "name": "base_valid", + "valid": true + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "kind" + ] + } + ], + "name": "missing_kind", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "source_id" + ] + } + ], + "name": "missing_source_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "unknown" + ], + "value": true + } + ], + "name": "unknown_field", + "valid": false + } + ], + "schema_pointer": "#/$defs/manualBodySource", + "variant": "base" + }, + { + "artifact": "normalized_product", + "base": { + "color": null, + "density": null, + "density_unit": null, + "material_id": "a", + "metadata": {}, + "name": null + }, + "cases": [ + { + "mutations": [], + "name": "base_valid", + "valid": true + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "material_id" + ] + } + ], + "name": "missing_material_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "name" + ] + } + ], + "name": "missing_name", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "density" + ] + } + ], + "name": "missing_density", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "density_unit" + ] + } + ], + "name": "missing_density_unit", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "color" + ] + } + ], + "name": "missing_color", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "metadata" + ] + } + ], + "name": "missing_metadata", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "unknown" + ], + "value": true + } + ], + "name": "unknown_field", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "name" + ], + "value": "a" + } + ], + "name": "nullable_name_nonnull", + "valid": true + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "density" + ], + "value": 0 + } + ], + "name": "nullable_density_nonnull", + "valid": true + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "density_unit" + ], + "value": "a" + } + ], + "name": "nullable_density_unit_nonnull", + "valid": true + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "color" + ], + "value": [ + 0, + 0, + 0 + ] + } + ], + "name": "nullable_color_nonnull", + "valid": true + } + ], + "schema_pointer": "#/$defs/material", + "variant": "base" + }, + { + "artifact": "normalized_product", + "base": { + "flip": false, + "geo_selector": {}, + "kind": "face", + "source_node_id": null + }, + "cases": [ + { + "mutations": [], + "name": "base_valid", + "valid": true + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "kind" + ] + } + ], + "name": "missing_kind", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "source_node_id" + ] + } + ], + "name": "missing_source_node_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "geo_selector" + ] + } + ], + "name": "missing_geo_selector", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "flip" + ] + } + ], + "name": "missing_flip", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "unknown" + ], + "value": true + } + ], + "name": "unknown_field", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "source_node_id" + ], + "value": "a" + } + ], + "name": "nullable_source_node_id_nonnull", + "valid": true + } + ], + "schema_pointer": "#/$defs/geometryRef", + "variant": "kind=face" + }, + { + "artifact": "normalized_product", + "base": { + "flip": false, + "geo_selector": {}, + "kind": "edge", + "source_node_id": null + }, + "cases": [ + { + "mutations": [], + "name": "base_valid", + "valid": true + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "kind" + ] + } + ], + "name": "missing_kind", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "source_node_id" + ] + } + ], + "name": "missing_source_node_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "geo_selector" + ] + } + ], + "name": "missing_geo_selector", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "flip" + ] + } + ], + "name": "missing_flip", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "unknown" + ], + "value": true + } + ], + "name": "unknown_field", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "source_node_id" + ], + "value": "a" + } + ], + "name": "nullable_source_node_id_nonnull", + "valid": true + } + ], + "schema_pointer": "#/$defs/geometryRef", + "variant": "kind=edge" + }, + { + "artifact": "normalized_product", + "base": { + "flip": false, + "geo_selector": {}, + "kind": "vertex", + "source_node_id": null + }, + "cases": [ + { + "mutations": [], + "name": "base_valid", + "valid": true + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "kind" + ] + } + ], + "name": "missing_kind", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "source_node_id" + ] + } + ], + "name": "missing_source_node_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "geo_selector" + ] + } + ], + "name": "missing_geo_selector", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "flip" + ] + } + ], + "name": "missing_flip", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "unknown" + ], + "value": true + } + ], + "name": "unknown_field", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "source_node_id" + ], + "value": "a" + } + ], + "name": "nullable_source_node_id_nonnull", + "valid": true + } + ], + "schema_pointer": "#/$defs/geometryRef", + "variant": "kind=vertex" + }, + { + "artifact": "normalized_product", + "base": { + "anchor_kind": "geometry", + "geometry_ref": { + "flip": false, + "geo_selector": {}, + "kind": "face", + "source_node_id": null + } + }, + "cases": [ + { + "mutations": [], + "name": "base_valid", + "valid": true + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "anchor_kind" + ] + } + ], + "name": "missing_anchor_kind", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "geometry_ref" + ] + } + ], + "name": "missing_geometry_ref", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "unknown" + ], + "value": true + } + ], + "name": "unknown_field", + "valid": false + } + ], + "schema_pointer": "#/$defs/geometryAnchor", + "variant": "base" + }, + { + "artifact": "normalized_product", + "base": { + "anchor_kind": "placement", + "placement": { + "origin": [ + 0, + 0, + 0 + ], + "x_axis": [ + 0, + 0, + 0 + ], + "y_axis": [ + 0, + 0, + 0 + ], + "z_axis": [ + 0, + 0, + 0 + ] + } + }, + "cases": [ + { + "mutations": [], + "name": "base_valid", + "valid": true + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "anchor_kind" + ] + } + ], + "name": "missing_anchor_kind", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "placement" + ] + } + ], + "name": "missing_placement", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "unknown" + ], + "value": true + } + ], + "name": "unknown_field", + "valid": false + } + ], + "schema_pointer": "#/$defs/placementAnchor", + "variant": "base" + }, + { + "artifact": "normalized_product", + "base": { + "anchor_kind": "forwarded", + "offset": null, + "source_component_id": "a", + "source_connector_id": "a" + }, + "cases": [ + { + "mutations": [], + "name": "base_valid", + "valid": true + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "anchor_kind" + ] + } + ], + "name": "missing_anchor_kind", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "source_component_id" + ] + } + ], + "name": "missing_source_component_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "source_connector_id" + ] + } + ], + "name": "missing_source_connector_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "offset" + ] + } + ], + "name": "missing_offset", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "unknown" + ], + "value": true + } + ], + "name": "unknown_field", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "offset" + ], + "value": { + "origin": [ + 0, + 0, + 0 + ], + "x_axis": [ + 0, + 0, + 0 + ], + "y_axis": [ + 0, + 0, + 0 + ], + "z_axis": [ + 0, + 0, + 0 + ] + } + } + ], + "name": "nullable_offset_nonnull", + "valid": true + } + ], + "schema_pointer": "#/$defs/forwardedAnchor", + "variant": "base" + }, + { + "artifact": "normalized_product", + "base": { + "anchor": { + "anchor_kind": "geometry", + "geometry_ref": { + "flip": false, + "geo_selector": {}, + "kind": "face", + "source_node_id": null + } + }, + "connector_id": "a", + "geometry_ref": null, + "name": null + }, + "cases": [ + { + "mutations": [], + "name": "base_valid", + "valid": true + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "connector_id" + ] + } + ], + "name": "missing_connector_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "name" + ] + } + ], + "name": "missing_name", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "anchor" + ] + } + ], + "name": "missing_anchor", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "geometry_ref" + ] + } + ], + "name": "missing_geometry_ref", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "unknown" + ], + "value": true + } + ], + "name": "unknown_field", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "name" + ], + "value": "a" + } + ], + "name": "nullable_name_nonnull", + "valid": true + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "geometry_ref" + ], + "value": { + "flip": false, + "geo_selector": {}, + "kind": "face", + "source_node_id": null + } + } + ], + "name": "nullable_geometry_ref_nonnull", + "valid": true + } + ], + "schema_pointer": "#/$defs/connector", + "variant": "anchor=geometry" + }, + { + "artifact": "normalized_product", + "base": { + "anchor": { + "anchor_kind": "placement", + "placement": { + "origin": [ + 0, + 0, + 0 + ], + "x_axis": [ + 0, + 0, + 0 + ], + "y_axis": [ + 0, + 0, + 0 + ], + "z_axis": [ + 0, + 0, + 0 + ] + } + }, + "connector_id": "a", + "name": null + }, + "cases": [ + { + "mutations": [], + "name": "base_valid", + "valid": true + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "connector_id" + ] + } + ], + "name": "missing_connector_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "name" + ] + } + ], + "name": "missing_name", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "anchor" + ] + } + ], + "name": "missing_anchor", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "unknown" + ], + "value": true + } + ], + "name": "unknown_field", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "geometry_ref" + ], + "value": null + } + ], + "name": "optional_geometry_ref_present", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "geometry_ref" + ], + "value": null + } + ], + "name": "optional_fields_all_present", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "name" + ], + "value": "a" + } + ], + "name": "nullable_name_nonnull", + "valid": true + } + ], + "schema_pointer": "#/$defs/connector", + "variant": "anchor=placement" + }, + { + "artifact": "normalized_product", + "base": { + "anchor": { + "anchor_kind": "forwarded", + "offset": null, + "source_component_id": "a", + "source_connector_id": "a" + }, + "connector_id": "a", + "name": null + }, + "cases": [ + { + "mutations": [], + "name": "base_valid", + "valid": true + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "connector_id" + ] + } + ], + "name": "missing_connector_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "name" + ] + } + ], + "name": "missing_name", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "anchor" + ] + } + ], + "name": "missing_anchor", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "unknown" + ], + "value": true + } + ], + "name": "unknown_field", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "geometry_ref" + ], + "value": null + } + ], + "name": "optional_geometry_ref_present", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "geometry_ref" + ], + "value": null + } + ], + "name": "optional_fields_all_present", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "name" + ], + "value": "a" + } + ], + "name": "nullable_name_nonnull", + "valid": true + } + ], + "schema_pointer": "#/$defs/connector", + "variant": "anchor=forwarded" + }, + { + "artifact": "normalized_product", + "base": { + "component_id": "a", + "definition_ref": "a", + "local_placement": { + "origin": [ + 0, + 0, + 0 + ], + "x_axis": [ + 0, + 0, + 0 + ], + "y_axis": [ + 0, + 0, + 0 + ], + "z_axis": [ + 0, + 0, + 0 + ] + }, + "name": null + }, + "cases": [ + { + "mutations": [], + "name": "base_valid", + "valid": true + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "component_id" + ] + } + ], + "name": "missing_component_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "name" + ] + } + ], + "name": "missing_name", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "definition_ref" + ] + } + ], + "name": "missing_definition_ref", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "local_placement" + ] + } + ], + "name": "missing_local_placement", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "unknown" + ], + "value": true + } + ], + "name": "unknown_field", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "name" + ], + "value": "a" + } + ], + "name": "nullable_name_nonnull", + "valid": true + } + ], + "schema_pointer": "#/$defs/component", + "variant": "base" + }, + { + "artifact": "normalized_product", + "base": { + "component_id": "a", + "connector_id": "a" + }, + "cases": [ + { + "mutations": [], + "name": "base_valid", + "valid": true + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "component_id" + ] + } + ], + "name": "missing_component_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "connector_id" + ] + } + ], + "name": "missing_connector_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "unknown" + ], + "value": true + } + ], + "name": "unknown_field", + "valid": false + } + ], + "schema_pointer": "#/$defs/connectorRef", + "variant": "base" + }, + { + "artifact": "normalized_product", + "base": { + "lower_value": 0, + "upper_value": 0 + }, + "cases": [ + { + "mutations": [], + "name": "base_valid", + "valid": true + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "lower_value" + ] + } + ], + "name": "missing_lower_value", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "upper_value" + ] + } + ], + "name": "missing_upper_value", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "unknown" + ], + "value": true + } + ], + "name": "unknown_field", + "valid": false + } + ], + "schema_pointer": "#/$defs/limit", + "variant": "base" + }, + { + "artifact": "normalized_product", + "base": { + "angle_limit": null, + "connector_a": { + "component_id": "a", + "connector_id": "a" + }, + "connector_b": { + "component_id": "a", + "connector_id": "a" + }, + "constraint_id": "a", + "constraint_kind": "fixed", + "distance_limit": null, + "drive_angle_degrees": null, + "drive_distance": null, + "name": null, + "phase_offset": null, + "pitch_radius": null, + "pitch_radius_a": null, + "pitch_radius_b": null, + "pulley_radius_a": null, + "pulley_radius_b": null + }, + "cases": [ + { + "mutations": [], + "name": "base_valid", + "valid": true + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "constraint_id" + ] + } + ], + "name": "missing_constraint_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "constraint_kind" + ] + } + ], + "name": "missing_constraint_kind", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "connector_a" + ] + } + ], + "name": "missing_connector_a", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "connector_b" + ] + } + ], + "name": "missing_connector_b", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "drive_distance" + ] + } + ], + "name": "missing_drive_distance", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "distance_limit" + ] + } + ], + "name": "missing_distance_limit", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "drive_angle_degrees" + ] + } + ], + "name": "missing_drive_angle_degrees", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "angle_limit" + ] + } + ], + "name": "missing_angle_limit", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "pitch_radius_a" + ] + } + ], + "name": "missing_pitch_radius_a", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "pitch_radius_b" + ] + } + ], + "name": "missing_pitch_radius_b", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "pulley_radius_a" + ] + } + ], + "name": "missing_pulley_radius_a", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "pulley_radius_b" + ] + } + ], + "name": "missing_pulley_radius_b", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "pitch_radius" + ] + } + ], + "name": "missing_pitch_radius", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "phase_offset" + ] + } + ], + "name": "missing_phase_offset", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "name" + ] + } + ], + "name": "missing_name", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "unknown" + ], + "value": true + } + ], + "name": "unknown_field", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "drive_distance" + ], + "value": 0 + } + ], + "name": "nullable_drive_distance_nonnull", + "valid": true + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "distance_limit" + ], + "value": { + "lower_value": 0, + "upper_value": 0 + } + } + ], + "name": "nullable_distance_limit_nonnull", + "valid": true + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "drive_angle_degrees" + ], + "value": 0 + } + ], + "name": "nullable_drive_angle_degrees_nonnull", + "valid": true + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "angle_limit" + ], + "value": { + "lower_value": 0, + "upper_value": 0 + } + } + ], + "name": "nullable_angle_limit_nonnull", + "valid": true + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "pitch_radius_a" + ], + "value": 0 + } + ], + "name": "nullable_pitch_radius_a_nonnull", + "valid": true + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "pitch_radius_b" + ], + "value": 0 + } + ], + "name": "nullable_pitch_radius_b_nonnull", + "valid": true + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "pulley_radius_a" + ], + "value": 0 + } + ], + "name": "nullable_pulley_radius_a_nonnull", + "valid": true + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "pulley_radius_b" + ], + "value": 0 + } + ], + "name": "nullable_pulley_radius_b_nonnull", + "valid": true + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "pitch_radius" + ], + "value": 0 + } + ], + "name": "nullable_pitch_radius_nonnull", + "valid": true + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "phase_offset" + ], + "value": 0 + } + ], + "name": "nullable_phase_offset_nonnull", + "valid": true + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "name" + ], + "value": "a" + } + ], + "name": "nullable_name_nonnull", + "valid": true + } + ], + "schema_pointer": "#/$defs/constraint", + "variant": "constraint_kind=fixed" + }, + { + "artifact": "normalized_product", + "base": { + "angle_limit": null, + "connector_a": { + "component_id": "a", + "connector_id": "a" + }, + "connector_b": { + "component_id": "a", + "connector_id": "a" + }, + "constraint_id": "a", + "constraint_kind": "revolute", + "distance_limit": null, + "drive_angle_degrees": null, + "drive_distance": null, + "name": null, + "phase_offset": null, + "pitch_radius": null, + "pitch_radius_a": null, + "pitch_radius_b": null, + "pulley_radius_a": null, + "pulley_radius_b": null + }, + "cases": [ + { + "mutations": [], + "name": "base_valid", + "valid": true + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "constraint_id" + ] + } + ], + "name": "missing_constraint_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "constraint_kind" + ] + } + ], + "name": "missing_constraint_kind", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "connector_a" + ] + } + ], + "name": "missing_connector_a", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "connector_b" + ] + } + ], + "name": "missing_connector_b", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "drive_distance" + ] + } + ], + "name": "missing_drive_distance", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "distance_limit" + ] + } + ], + "name": "missing_distance_limit", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "drive_angle_degrees" + ] + } + ], + "name": "missing_drive_angle_degrees", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "angle_limit" + ] + } + ], + "name": "missing_angle_limit", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "pitch_radius_a" + ] + } + ], + "name": "missing_pitch_radius_a", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "pitch_radius_b" + ] + } + ], + "name": "missing_pitch_radius_b", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "pulley_radius_a" + ] + } + ], + "name": "missing_pulley_radius_a", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "pulley_radius_b" + ] + } + ], + "name": "missing_pulley_radius_b", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "pitch_radius" + ] + } + ], + "name": "missing_pitch_radius", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "phase_offset" + ] + } + ], + "name": "missing_phase_offset", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "name" + ] + } + ], + "name": "missing_name", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "unknown" + ], + "value": true + } + ], + "name": "unknown_field", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "drive_distance" + ], + "value": 0 + } + ], + "name": "nullable_drive_distance_nonnull", + "valid": true + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "distance_limit" + ], + "value": { + "lower_value": 0, + "upper_value": 0 + } + } + ], + "name": "nullable_distance_limit_nonnull", + "valid": true + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "drive_angle_degrees" + ], + "value": 0 + } + ], + "name": "nullable_drive_angle_degrees_nonnull", + "valid": true + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "angle_limit" + ], + "value": { + "lower_value": 0, + "upper_value": 0 + } + } + ], + "name": "nullable_angle_limit_nonnull", + "valid": true + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "pitch_radius_a" + ], + "value": 0 + } + ], + "name": "nullable_pitch_radius_a_nonnull", + "valid": true + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "pitch_radius_b" + ], + "value": 0 + } + ], + "name": "nullable_pitch_radius_b_nonnull", + "valid": true + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "pulley_radius_a" + ], + "value": 0 + } + ], + "name": "nullable_pulley_radius_a_nonnull", + "valid": true + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "pulley_radius_b" + ], + "value": 0 + } + ], + "name": "nullable_pulley_radius_b_nonnull", + "valid": true + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "pitch_radius" + ], + "value": 0 + } + ], + "name": "nullable_pitch_radius_nonnull", + "valid": true + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "phase_offset" + ], + "value": 0 + } + ], + "name": "nullable_phase_offset_nonnull", + "valid": true + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "name" + ], + "value": "a" + } + ], + "name": "nullable_name_nonnull", + "valid": true + } + ], + "schema_pointer": "#/$defs/constraint", + "variant": "constraint_kind=revolute" + }, + { + "artifact": "normalized_product", + "base": { + "angle_limit": null, + "connector_a": { + "component_id": "a", + "connector_id": "a" + }, + "connector_b": { + "component_id": "a", + "connector_id": "a" + }, + "constraint_id": "a", + "constraint_kind": "prismatic", + "distance_limit": null, + "drive_angle_degrees": null, + "drive_distance": null, + "name": null, + "phase_offset": null, + "pitch_radius": null, + "pitch_radius_a": null, + "pitch_radius_b": null, + "pulley_radius_a": null, + "pulley_radius_b": null + }, + "cases": [ + { + "mutations": [], + "name": "base_valid", + "valid": true + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "constraint_id" + ] + } + ], + "name": "missing_constraint_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "constraint_kind" + ] + } + ], + "name": "missing_constraint_kind", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "connector_a" + ] + } + ], + "name": "missing_connector_a", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "connector_b" + ] + } + ], + "name": "missing_connector_b", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "drive_distance" + ] + } + ], + "name": "missing_drive_distance", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "distance_limit" + ] + } + ], + "name": "missing_distance_limit", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "drive_angle_degrees" + ] + } + ], + "name": "missing_drive_angle_degrees", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "angle_limit" + ] + } + ], + "name": "missing_angle_limit", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "pitch_radius_a" + ] + } + ], + "name": "missing_pitch_radius_a", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "pitch_radius_b" + ] + } + ], + "name": "missing_pitch_radius_b", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "pulley_radius_a" + ] + } + ], + "name": "missing_pulley_radius_a", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "pulley_radius_b" + ] + } + ], + "name": "missing_pulley_radius_b", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "pitch_radius" + ] + } + ], + "name": "missing_pitch_radius", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "phase_offset" + ] + } + ], + "name": "missing_phase_offset", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "name" + ] + } + ], + "name": "missing_name", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "unknown" + ], + "value": true + } + ], + "name": "unknown_field", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "drive_distance" + ], + "value": 0 + } + ], + "name": "nullable_drive_distance_nonnull", + "valid": true + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "distance_limit" + ], + "value": { + "lower_value": 0, + "upper_value": 0 + } + } + ], + "name": "nullable_distance_limit_nonnull", + "valid": true + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "drive_angle_degrees" + ], + "value": 0 + } + ], + "name": "nullable_drive_angle_degrees_nonnull", + "valid": true + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "angle_limit" + ], + "value": { + "lower_value": 0, + "upper_value": 0 + } + } + ], + "name": "nullable_angle_limit_nonnull", + "valid": true + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "pitch_radius_a" + ], + "value": 0 + } + ], + "name": "nullable_pitch_radius_a_nonnull", + "valid": true + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "pitch_radius_b" + ], + "value": 0 + } + ], + "name": "nullable_pitch_radius_b_nonnull", + "valid": true + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "pulley_radius_a" + ], + "value": 0 + } + ], + "name": "nullable_pulley_radius_a_nonnull", + "valid": true + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "pulley_radius_b" + ], + "value": 0 + } + ], + "name": "nullable_pulley_radius_b_nonnull", + "valid": true + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "pitch_radius" + ], + "value": 0 + } + ], + "name": "nullable_pitch_radius_nonnull", + "valid": true + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "phase_offset" + ], + "value": 0 + } + ], + "name": "nullable_phase_offset_nonnull", + "valid": true + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "name" + ], + "value": "a" + } + ], + "name": "nullable_name_nonnull", + "valid": true + } + ], + "schema_pointer": "#/$defs/constraint", + "variant": "constraint_kind=prismatic" + }, + { + "artifact": "normalized_product", + "base": { + "angle_limit": null, + "connector_a": { + "component_id": "a", + "connector_id": "a" + }, + "connector_b": { + "component_id": "a", + "connector_id": "a" + }, + "constraint_id": "a", + "constraint_kind": "gear", + "distance_limit": null, + "drive_angle_degrees": null, + "drive_distance": null, + "name": null, + "phase_offset": null, + "pitch_radius": null, + "pitch_radius_a": null, + "pitch_radius_b": null, + "pulley_radius_a": null, + "pulley_radius_b": null + }, + "cases": [ + { + "mutations": [], + "name": "base_valid", + "valid": true + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "constraint_id" + ] + } + ], + "name": "missing_constraint_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "constraint_kind" + ] + } + ], + "name": "missing_constraint_kind", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "connector_a" + ] + } + ], + "name": "missing_connector_a", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "connector_b" + ] + } + ], + "name": "missing_connector_b", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "drive_distance" + ] + } + ], + "name": "missing_drive_distance", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "distance_limit" + ] + } + ], + "name": "missing_distance_limit", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "drive_angle_degrees" + ] + } + ], + "name": "missing_drive_angle_degrees", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "angle_limit" + ] + } + ], + "name": "missing_angle_limit", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "pitch_radius_a" + ] + } + ], + "name": "missing_pitch_radius_a", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "pitch_radius_b" + ] + } + ], + "name": "missing_pitch_radius_b", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "pulley_radius_a" + ] + } + ], + "name": "missing_pulley_radius_a", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "pulley_radius_b" + ] + } + ], + "name": "missing_pulley_radius_b", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "pitch_radius" + ] + } + ], + "name": "missing_pitch_radius", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "phase_offset" + ] + } + ], + "name": "missing_phase_offset", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "name" + ] + } + ], + "name": "missing_name", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "unknown" + ], + "value": true + } + ], + "name": "unknown_field", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "drive_distance" + ], + "value": 0 + } + ], + "name": "nullable_drive_distance_nonnull", + "valid": true + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "distance_limit" + ], + "value": { + "lower_value": 0, + "upper_value": 0 + } + } + ], + "name": "nullable_distance_limit_nonnull", + "valid": true + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "drive_angle_degrees" + ], + "value": 0 + } + ], + "name": "nullable_drive_angle_degrees_nonnull", + "valid": true + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "angle_limit" + ], + "value": { + "lower_value": 0, + "upper_value": 0 + } + } + ], + "name": "nullable_angle_limit_nonnull", + "valid": true + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "pitch_radius_a" + ], + "value": 0 + } + ], + "name": "nullable_pitch_radius_a_nonnull", + "valid": true + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "pitch_radius_b" + ], + "value": 0 + } + ], + "name": "nullable_pitch_radius_b_nonnull", + "valid": true + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "pulley_radius_a" + ], + "value": 0 + } + ], + "name": "nullable_pulley_radius_a_nonnull", + "valid": true + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "pulley_radius_b" + ], + "value": 0 + } + ], + "name": "nullable_pulley_radius_b_nonnull", + "valid": true + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "pitch_radius" + ], + "value": 0 + } + ], + "name": "nullable_pitch_radius_nonnull", + "valid": true + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "phase_offset" + ], + "value": 0 + } + ], + "name": "nullable_phase_offset_nonnull", + "valid": true + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "name" + ], + "value": "a" + } + ], + "name": "nullable_name_nonnull", + "valid": true + } + ], + "schema_pointer": "#/$defs/constraint", + "variant": "constraint_kind=gear" + }, + { + "artifact": "normalized_product", + "base": { + "angle_limit": null, + "connector_a": { + "component_id": "a", + "connector_id": "a" + }, + "connector_b": { + "component_id": "a", + "connector_id": "a" + }, + "constraint_id": "a", + "constraint_kind": "belt", + "distance_limit": null, + "drive_angle_degrees": null, + "drive_distance": null, + "name": null, + "phase_offset": null, + "pitch_radius": null, + "pitch_radius_a": null, + "pitch_radius_b": null, + "pulley_radius_a": null, + "pulley_radius_b": null + }, + "cases": [ + { + "mutations": [], + "name": "base_valid", + "valid": true + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "constraint_id" + ] + } + ], + "name": "missing_constraint_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "constraint_kind" + ] + } + ], + "name": "missing_constraint_kind", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "connector_a" + ] + } + ], + "name": "missing_connector_a", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "connector_b" + ] + } + ], + "name": "missing_connector_b", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "drive_distance" + ] + } + ], + "name": "missing_drive_distance", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "distance_limit" + ] + } + ], + "name": "missing_distance_limit", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "drive_angle_degrees" + ] + } + ], + "name": "missing_drive_angle_degrees", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "angle_limit" + ] + } + ], + "name": "missing_angle_limit", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "pitch_radius_a" + ] + } + ], + "name": "missing_pitch_radius_a", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "pitch_radius_b" + ] + } + ], + "name": "missing_pitch_radius_b", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "pulley_radius_a" + ] + } + ], + "name": "missing_pulley_radius_a", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "pulley_radius_b" + ] + } + ], + "name": "missing_pulley_radius_b", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "pitch_radius" + ] + } + ], + "name": "missing_pitch_radius", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "phase_offset" + ] + } + ], + "name": "missing_phase_offset", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "name" + ] + } + ], + "name": "missing_name", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "unknown" + ], + "value": true + } + ], + "name": "unknown_field", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "drive_distance" + ], + "value": 0 + } + ], + "name": "nullable_drive_distance_nonnull", + "valid": true + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "distance_limit" + ], + "value": { + "lower_value": 0, + "upper_value": 0 + } + } + ], + "name": "nullable_distance_limit_nonnull", + "valid": true + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "drive_angle_degrees" + ], + "value": 0 + } + ], + "name": "nullable_drive_angle_degrees_nonnull", + "valid": true + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "angle_limit" + ], + "value": { + "lower_value": 0, + "upper_value": 0 + } + } + ], + "name": "nullable_angle_limit_nonnull", + "valid": true + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "pitch_radius_a" + ], + "value": 0 + } + ], + "name": "nullable_pitch_radius_a_nonnull", + "valid": true + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "pitch_radius_b" + ], + "value": 0 + } + ], + "name": "nullable_pitch_radius_b_nonnull", + "valid": true + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "pulley_radius_a" + ], + "value": 0 + } + ], + "name": "nullable_pulley_radius_a_nonnull", + "valid": true + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "pulley_radius_b" + ], + "value": 0 + } + ], + "name": "nullable_pulley_radius_b_nonnull", + "valid": true + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "pitch_radius" + ], + "value": 0 + } + ], + "name": "nullable_pitch_radius_nonnull", + "valid": true + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "phase_offset" + ], + "value": 0 + } + ], + "name": "nullable_phase_offset_nonnull", + "valid": true + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "name" + ], + "value": "a" + } + ], + "name": "nullable_name_nonnull", + "valid": true + } + ], + "schema_pointer": "#/$defs/constraint", + "variant": "constraint_kind=belt" + }, + { + "artifact": "normalized_product", + "base": { + "angle_limit": null, + "connector_a": { + "component_id": "a", + "connector_id": "a" + }, + "connector_b": { + "component_id": "a", + "connector_id": "a" + }, + "constraint_id": "a", + "constraint_kind": "rack_pinion", + "distance_limit": null, + "drive_angle_degrees": null, + "drive_distance": null, + "name": null, + "phase_offset": null, + "pitch_radius": null, + "pitch_radius_a": null, + "pitch_radius_b": null, + "pulley_radius_a": null, + "pulley_radius_b": null + }, + "cases": [ + { + "mutations": [], + "name": "base_valid", + "valid": true + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "constraint_id" + ] + } + ], + "name": "missing_constraint_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "constraint_kind" + ] + } + ], + "name": "missing_constraint_kind", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "connector_a" + ] + } + ], + "name": "missing_connector_a", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "connector_b" + ] + } + ], + "name": "missing_connector_b", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "drive_distance" + ] + } + ], + "name": "missing_drive_distance", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "distance_limit" + ] + } + ], + "name": "missing_distance_limit", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "drive_angle_degrees" + ] + } + ], + "name": "missing_drive_angle_degrees", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "angle_limit" + ] + } + ], + "name": "missing_angle_limit", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "pitch_radius_a" + ] + } + ], + "name": "missing_pitch_radius_a", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "pitch_radius_b" + ] + } + ], + "name": "missing_pitch_radius_b", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "pulley_radius_a" + ] + } + ], + "name": "missing_pulley_radius_a", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "pulley_radius_b" + ] + } + ], + "name": "missing_pulley_radius_b", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "pitch_radius" + ] + } + ], + "name": "missing_pitch_radius", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "phase_offset" + ] + } + ], + "name": "missing_phase_offset", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "name" + ] + } + ], + "name": "missing_name", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "unknown" + ], + "value": true + } + ], + "name": "unknown_field", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "drive_distance" + ], + "value": 0 + } + ], + "name": "nullable_drive_distance_nonnull", + "valid": true + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "distance_limit" + ], + "value": { + "lower_value": 0, + "upper_value": 0 + } + } + ], + "name": "nullable_distance_limit_nonnull", + "valid": true + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "drive_angle_degrees" + ], + "value": 0 + } + ], + "name": "nullable_drive_angle_degrees_nonnull", + "valid": true + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "angle_limit" + ], + "value": { + "lower_value": 0, + "upper_value": 0 + } + } + ], + "name": "nullable_angle_limit_nonnull", + "valid": true + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "pitch_radius_a" + ], + "value": 0 + } + ], + "name": "nullable_pitch_radius_a_nonnull", + "valid": true + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "pitch_radius_b" + ], + "value": 0 + } + ], + "name": "nullable_pitch_radius_b_nonnull", + "valid": true + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "pulley_radius_a" + ], + "value": 0 + } + ], + "name": "nullable_pulley_radius_a_nonnull", + "valid": true + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "pulley_radius_b" + ], + "value": 0 + } + ], + "name": "nullable_pulley_radius_b_nonnull", + "valid": true + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "pitch_radius" + ], + "value": 0 + } + ], + "name": "nullable_pitch_radius_nonnull", + "valid": true + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "phase_offset" + ], + "value": 0 + } + ], + "name": "nullable_phase_offset_nonnull", + "valid": true + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "name" + ], + "value": "a" + } + ], + "name": "nullable_name_nonnull", + "valid": true + } + ], + "schema_pointer": "#/$defs/constraint", + "variant": "constraint_kind=rack_pinion" + }, + { + "artifact": "normalized_product", + "base": { + "body_source": { + "graph_id": "a", + "kind": "model_output", + "node_id": "a", + "output_slot": 0 + }, + "connectors": [], + "kind": "part", + "material": null, + "metadata": {}, + "name": null, + "part_id": "a" + }, + "cases": [ + { + "mutations": [], + "name": "base_valid", + "valid": true + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "kind" + ] + } + ], + "name": "missing_kind", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "part_id" + ] + } + ], + "name": "missing_part_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "body_source" + ] + } + ], + "name": "missing_body_source", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "name" + ] + } + ], + "name": "missing_name", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "material" + ] + } + ], + "name": "missing_material", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "connectors" + ] + } + ], + "name": "missing_connectors", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "metadata" + ] + } + ], + "name": "missing_metadata", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "unknown" + ], + "value": true + } + ], + "name": "unknown_field", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "name" + ], + "value": "a" + } + ], + "name": "nullable_name_nonnull", + "valid": true + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "material" + ], + "value": { + "color": null, + "density": null, + "density_unit": null, + "material_id": "a", + "metadata": {}, + "name": null + } + } + ], + "name": "nullable_material_nonnull", + "valid": true + } + ], + "schema_pointer": "#/$defs/part", + "variant": "base" + }, + { + "artifact": "normalized_product", + "base": { + "assembly_id": "a", + "components": [], + "connectors": [], + "constraints": [], + "grounded_component_ids": [], + "kind": "assembly", + "metadata": {}, + "name": null + }, + "cases": [ + { + "mutations": [], + "name": "base_valid", + "valid": true + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "kind" + ] + } + ], + "name": "missing_kind", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "assembly_id" + ] + } + ], + "name": "missing_assembly_id", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "name" + ] + } + ], + "name": "missing_name", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "components" + ] + } + ], + "name": "missing_components", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "connectors" + ] + } + ], + "name": "missing_connectors", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "constraints" + ] + } + ], + "name": "missing_constraints", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "grounded_component_ids" + ] + } + ], + "name": "missing_grounded_component_ids", + "valid": false + }, + { + "mutations": [ + { + "operation": "delete", + "path": [ + "metadata" + ] + } + ], + "name": "missing_metadata", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "unknown" + ], + "value": true + } + ], + "name": "unknown_field", + "valid": false + }, + { + "mutations": [ + { + "operation": "set", + "path": [ + "name" + ], + "value": "a" + } + ], + "name": "nullable_name_nonnull", + "valid": true + } + ], + "schema_pointer": "#/$defs/assembly", + "variant": "base" + } + ], + "zip_cases": [ + { + "error": null, + "name": "canonical_stored", + "payload_base64": "UEsDBBQAAAgAAAAAIQAdRZYV5AIAAOQCAABRAAAAZWRnZXMvc2hhMjU2LTZjZTQ4MWRiMWZlMTNiY2ZlN2EyMzc0NDIzZTZjNzJkNzU3ZDUxYzk5MTY4YjI4N2M1MmY1M2FhNjI1Y2NkNjcuZ2xiZ2xURgIAAADkAgAAIAIAAEpTT057ImFjY2Vzc29ycyI6W3siYnVmZmVyVmlldyI6MCwiY29tcG9uZW50VHlwZSI6NTEyNiwiY291bnQiOjEyLCJtYXgiOlsxLDEsMV0sIm1pbiI6WzAsMCwwXSwidHlwZSI6IlZFQzMifSx7ImJ1ZmZlclZpZXciOjEsImNvbXBvbmVudFR5cGUiOjUxMjMsImNvdW50IjoxMiwidHlwZSI6IlNDQUxBUiJ9XSwiYXNzZXQiOnsiZ2VuZXJhdG9yIjoiU2ltcGxlQ0FEIFNjZW5lIEdMQiBQcm9maWxlIDEiLCJ2ZXJzaW9uIjoiMi4wIn0sImJ1ZmZlclZpZXdzIjpbeyJidWZmZXIiOjAsImJ5dGVMZW5ndGgiOjE0NCwiYnl0ZU9mZnNldCI6MCwidGFyZ2V0IjozNDk2Mn0seyJidWZmZXIiOjAsImJ5dGVMZW5ndGgiOjI0LCJieXRlT2Zmc2V0IjoxNDQsInRhcmdldCI6MzQ5NjN9XSwiYnVmZmVycyI6W3siYnl0ZUxlbmd0aCI6MTY4fV0sIm1lc2hlcyI6W3sicHJpbWl0aXZlcyI6W3siYXR0cmlidXRlcyI6eyJQT1NJVElPTiI6MH0sImluZGljZXMiOjEsIm1vZGUiOjF9XX1dLCJub2RlcyI6W3sibWVzaCI6MH1dLCJzY2VuZSI6MCwic2NlbmVzIjpbeyJub2RlcyI6WzBdfV19qAAAAEJJTgAAAAAAAAAAAAAAgD8AAAAAAACAPwAAAAAAAAAAAAAAAAAAgD8AAIA/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgD8AAAAAAAAAAAAAAAAAAIA/AAAAAAAAAAAAAAAAAACAPwAAAAAAAIA/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAPwAAAAAAAAEAAgADAAQABQAGAAcACAAJAAoACwBQSwMEFAAACAAAAAAhAGAzoCP9LAAA/SwAAFUAAABlbnRpdGllcy9zaGEyNTYtMzg5N2NjOWViZGIyOTQxODM1OTY0YTQzNTgyMWI1NTZlMDFiMTc1NTM1Y2ZmOGZjMGVhYWFmYjY4NGM0ZGRmNS5qc29ueyJkZWZpbml0aW9uX2lkIjoiZGVmaW5pdGlvbi9yb290L3NoYXBlL21hbnVhbC9maXh0dXJlIiwiZWRnZV9hc3NldF9pZCI6InNoYTI1Njo2Y2U0ODFkYjFmZTEzYmNmZTdhMjM3NDQyM2U2YzcyZDc1N2Q1MWM5OTE2OGIyODdjNTJmNTNhYTYyNWNjZDY3IiwiZWRnZV9ncm91cHMiOlt7ImVudGl0eV9pZCI6ImVudGl0eS9lZGdlLzAiLCJmaXJzdF9pbmRleCI6MCwiZ3JvdXBfaWQiOjAsImluZGV4X2NvdW50IjoyLCJtZXNoX2luZGV4IjowLCJwcmltaXRpdmVfaW5kZXgiOjB9LHsiZW50aXR5X2lkIjoiZW50aXR5L2VkZ2UvMSIsImZpcnN0X2luZGV4IjoyLCJncm91cF9pZCI6MSwiaW5kZXhfY291bnQiOjIsIm1lc2hfaW5kZXgiOjAsInByaW1pdGl2ZV9pbmRleCI6MH0seyJlbnRpdHlfaWQiOiJlbnRpdHkvZWRnZS8yIiwiZmlyc3RfaW5kZXgiOjQsImdyb3VwX2lkIjoyLCJpbmRleF9jb3VudCI6MiwibWVzaF9pbmRleCI6MCwicHJpbWl0aXZlX2luZGV4IjowfSx7ImVudGl0eV9pZCI6ImVudGl0eS9lZGdlLzMiLCJmaXJzdF9pbmRleCI6NiwiZ3JvdXBfaWQiOjMsImluZGV4X2NvdW50IjoyLCJtZXNoX2luZGV4IjowLCJwcmltaXRpdmVfaW5kZXgiOjB9LHsiZW50aXR5X2lkIjoiZW50aXR5L2VkZ2UvNCIsImZpcnN0X2luZGV4Ijo4LCJncm91cF9pZCI6NCwiaW5kZXhfY291bnQiOjIsIm1lc2hfaW5kZXgiOjAsInByaW1pdGl2ZV9pbmRleCI6MH0seyJlbnRpdHlfaWQiOiJlbnRpdHkvZWRnZS81IiwiZmlyc3RfaW5kZXgiOjEwLCJncm91cF9pZCI6NSwiaW5kZXhfY291bnQiOjIsIm1lc2hfaW5kZXgiOjAsInByaW1pdGl2ZV9pbmRleCI6MH1dLCJlbnRpdGllcyI6W3siY2hpbGRfZW50aXR5X2lkcyI6WyJlbnRpdHkvdmVydGV4LzIiLCJlbnRpdHkvdmVydGV4LzMiXSwiY29ubmVjdG9yX2JpbmRpbmdfc3RhdHVzIjoib3duZXJfbm90X3BhcnQiLCJlbnRpdHlfaWQiOiJlbnRpdHkvZWRnZS8wIiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJkaXJlY3Rpb24iOlswLC0wLjcwNzEwNjc4MTE4NjU0NzUsLTAuNzA3MTA2NzgxMTg2NTQ3NV0sIm9yaWdpbiI6WzAsMCwxMDAwXSwidHlwZSI6ImxpbmUifSwia2luZCI6ImVkZ2UiLCJwYXJlbnRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZmFjZS8wIiwiZW50aXR5L2ZhY2UvMyJdLCJwcm9wZXJ0aWVzIjp7ImJvdW5kcyI6eyJtYXgiOlswLDAsMTAwMF0sIm1pbiI6WzAsLTEwMDAsMF19LCJjZW50cm9pZCI6WzAsLTUwMCw1MDBdLCJsZW5ndGgiOjE0MTQuMjEzNTYyMzczMDk1LCJxdWFsaXR5Ijoia2VybmVsX2V2YWx1YXRlZCJ9LCJyZW5kZXJfc3RhdHVzIjoicmVuZGVyZWQiLCJzZGtfY29ubmVjdG9yX2ZyYW1lIjp7Im9yaWdpbiI6WzAsLTUwMCw1MDBdLCJ4X2F4aXMiOlswLC0wLjcwNzEwNjc4MTE4NjU0NzUsLTAuNzA3MTA2NzgxMTg2NTQ3NV0sInlfYXhpcyI6WzAsLTAuNzA3MTA2NzgxMTg2NTQ3NCwwLjcwNzEwNjc4MTE4NjU0NzddLCJ6X2F4aXMiOlstMSwwLDBdfSwic2RrX21ldGFkYXRhIjp7fSwic2VtYW50aWNfYmluZGluZ19pZHMiOltdLCJzb3VyY2UiOnsia2luZCI6InVuYm91bmQifX0seyJjaGlsZF9lbnRpdHlfaWRzIjpbImVudGl0eS92ZXJ0ZXgvMSIsImVudGl0eS92ZXJ0ZXgvMyJdLCJjb25uZWN0b3JfYmluZGluZ19zdGF0dXMiOiJvd25lcl9ub3RfcGFydCIsImVudGl0eV9pZCI6ImVudGl0eS9lZGdlLzEiLCJldmFsdWF0ZWRfdGFncyI6W10sImdlb21ldHJ5Ijp7ImRpcmVjdGlvbiI6Wy0wLjcwNzEwNjc4MTE4NjU0NzUsLTAuNzA3MTA2NzgxMTg2NTQ3NSwwXSwib3JpZ2luIjpbMTAwMCwwLDBdLCJ0eXBlIjoibGluZSJ9LCJraW5kIjoiZWRnZSIsInBhcmVudF9lbnRpdHlfaWRzIjpbImVudGl0eS9mYWNlLzEiLCJlbnRpdHkvZmFjZS8zIl0sInByb3BlcnRpZXMiOnsiYm91bmRzIjp7Im1heCI6WzEwMDAsMCwwXSwibWluIjpbMCwtMTAwMCwwXX0sImNlbnRyb2lkIjpbNTAwLC01MDAsMF0sImxlbmd0aCI6MTQxNC4yMTM1NjIzNzMwOTUsInF1YWxpdHkiOiJrZXJuZWxfZXZhbHVhdGVkIn0sInJlbmRlcl9zdGF0dXMiOiJyZW5kZXJlZCIsInNka19jb25uZWN0b3JfZnJhbWUiOnsib3JpZ2luIjpbNTAwLC01MDAsMF0sInhfYXhpcyI6Wy0wLjcwNzEwNjc4MTE4NjU0NzUsLTAuNzA3MTA2NzgxMTg2NTQ3NSwwXSwieV9heGlzIjpbMCwwLDFdLCJ6X2F4aXMiOlstMC43MDcxMDY3ODExODY1NDc2LDAuNzA3MTA2NzgxMTg2NTQ3NiwwXX0sInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImtpbmQiOiJ1bmJvdW5kIn19LHsiY2hpbGRfZW50aXR5X2lkcyI6WyJlbnRpdHkvdmVydGV4LzAiLCJlbnRpdHkvdmVydGV4LzMiXSwiY29ubmVjdG9yX2JpbmRpbmdfc3RhdHVzIjoib3duZXJfbm90X3BhcnQiLCJlbnRpdHlfaWQiOiJlbnRpdHkvZWRnZS8yIiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJkaXJlY3Rpb24iOlswLC0xLDBdLCJvcmlnaW4iOlswLDAsMF0sInR5cGUiOiJsaW5lIn0sImtpbmQiOiJlZGdlIiwicGFyZW50X2VudGl0eV9pZHMiOlsiZW50aXR5L2ZhY2UvMCIsImVudGl0eS9mYWNlLzEiXSwicHJvcGVydGllcyI6eyJib3VuZHMiOnsibWF4IjpbMCwwLDBdLCJtaW4iOlswLC0xMDAwLDBdfSwiY2VudHJvaWQiOlswLC01MDAsMF0sImxlbmd0aCI6MTAwMCwicXVhbGl0eSI6Imtlcm5lbF9ldmFsdWF0ZWQifSwicmVuZGVyX3N0YXR1cyI6InJlbmRlcmVkIiwic2RrX2Nvbm5lY3Rvcl9mcmFtZSI6eyJvcmlnaW4iOlswLC01MDAsMF0sInhfYXhpcyI6WzAsLTEsMF0sInlfYXhpcyI6WzAsMCwxXSwiel9heGlzIjpbLTEsMCwwXX0sInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImtpbmQiOiJ1bmJvdW5kIn19LHsiY2hpbGRfZW50aXR5X2lkcyI6WyJlbnRpdHkvdmVydGV4LzAiLCJlbnRpdHkvdmVydGV4LzEiXSwiY29ubmVjdG9yX2JpbmRpbmdfc3RhdHVzIjoib3duZXJfbm90X3BhcnQiLCJlbnRpdHlfaWQiOiJlbnRpdHkvZWRnZS8zIiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJkaXJlY3Rpb24iOlsxLDAsMF0sIm9yaWdpbiI6WzAsMCwwXSwidHlwZSI6ImxpbmUifSwia2luZCI6ImVkZ2UiLCJwYXJlbnRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZmFjZS8xIiwiZW50aXR5L2ZhY2UvMiJdLCJwcm9wZXJ0aWVzIjp7ImJvdW5kcyI6eyJtYXgiOlsxMDAwLDAsMF0sIm1pbiI6WzAsMCwwXX0sImNlbnRyb2lkIjpbNTAwLDAsMF0sImxlbmd0aCI6MTAwMCwicXVhbGl0eSI6Imtlcm5lbF9ldmFsdWF0ZWQifSwicmVuZGVyX3N0YXR1cyI6InJlbmRlcmVkIiwic2RrX2Nvbm5lY3Rvcl9mcmFtZSI6eyJvcmlnaW4iOls1MDAsMCwwXSwieF9heGlzIjpbMSwwLDBdLCJ5X2F4aXMiOlswLDAsMV0sInpfYXhpcyI6WzAsLTEsMF19LCJzZGtfbWV0YWRhdGEiOnt9LCJzZW1hbnRpY19iaW5kaW5nX2lkcyI6W10sInNvdXJjZSI6eyJraW5kIjoidW5ib3VuZCJ9fSx7ImNoaWxkX2VudGl0eV9pZHMiOlsiZW50aXR5L3ZlcnRleC8xIiwiZW50aXR5L3ZlcnRleC8yIl0sImNvbm5lY3Rvcl9iaW5kaW5nX3N0YXR1cyI6Im93bmVyX25vdF9wYXJ0IiwiZW50aXR5X2lkIjoiZW50aXR5L2VkZ2UvNCIsImV2YWx1YXRlZF90YWdzIjpbXSwiZ2VvbWV0cnkiOnsiZGlyZWN0aW9uIjpbLTAuNzA3MTA2NzgxMTg2NTQ3NSwwLDAuNzA3MTA2NzgxMTg2NTQ3NV0sIm9yaWdpbiI6WzEwMDAsMCwwXSwidHlwZSI6ImxpbmUifSwia2luZCI6ImVkZ2UiLCJwYXJlbnRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZmFjZS8yIiwiZW50aXR5L2ZhY2UvMyJdLCJwcm9wZXJ0aWVzIjp7ImJvdW5kcyI6eyJtYXgiOlsxMDAwLDAsMTAwMF0sIm1pbiI6WzAsMCwwXX0sImNlbnRyb2lkIjpbNTAwLDAsNTAwXSwibGVuZ3RoIjoxNDE0LjIxMzU2MjM3MzA5NSwicXVhbGl0eSI6Imtlcm5lbF9ldmFsdWF0ZWQifSwicmVuZGVyX3N0YXR1cyI6InJlbmRlcmVkIiwic2RrX2Nvbm5lY3Rvcl9mcmFtZSI6eyJvcmlnaW4iOls1MDAsMCw1MDBdLCJ4X2F4aXMiOlstMC43MDcxMDY3ODExODY1NDc1LDAsMC43MDcxMDY3ODExODY1NDc1XSwieV9heGlzIjpbMC43MDcxMDY3ODExODY1NDc0LDAsMC43MDcxMDY3ODExODY1NDc3XSwiel9heGlzIjpbMCwxLDBdfSwic2RrX21ldGFkYXRhIjp7fSwic2VtYW50aWNfYmluZGluZ19pZHMiOltdLCJzb3VyY2UiOnsia2luZCI6InVuYm91bmQifX0seyJjaGlsZF9lbnRpdHlfaWRzIjpbImVudGl0eS92ZXJ0ZXgvMCIsImVudGl0eS92ZXJ0ZXgvMiJdLCJjb25uZWN0b3JfYmluZGluZ19zdGF0dXMiOiJvd25lcl9ub3RfcGFydCIsImVudGl0eV9pZCI6ImVudGl0eS9lZGdlLzUiLCJldmFsdWF0ZWRfdGFncyI6W10sImdlb21ldHJ5Ijp7ImRpcmVjdGlvbiI6WzAsMCwxXSwib3JpZ2luIjpbMCwwLDBdLCJ0eXBlIjoibGluZSJ9LCJraW5kIjoiZWRnZSIsInBhcmVudF9lbnRpdHlfaWRzIjpbImVudGl0eS9mYWNlLzAiLCJlbnRpdHkvZmFjZS8yIl0sInByb3BlcnRpZXMiOnsiYm91bmRzIjp7Im1heCI6WzAsMCwxMDAwXSwibWluIjpbMCwwLDBdfSwiY2VudHJvaWQiOlswLDAsNTAwXSwibGVuZ3RoIjoxMDAwLCJxdWFsaXR5Ijoia2VybmVsX2V2YWx1YXRlZCJ9LCJyZW5kZXJfc3RhdHVzIjoicmVuZGVyZWQiLCJzZGtfY29ubmVjdG9yX2ZyYW1lIjp7Im9yaWdpbiI6WzAsMCw1MDBdLCJ4X2F4aXMiOlswLDAsMV0sInlfYXhpcyI6WzAsMSwwXSwiel9heGlzIjpbLTEsMCwwXX0sInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImtpbmQiOiJ1bmJvdW5kIn19LHsiY2hpbGRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZWRnZS8wIiwiZW50aXR5L2VkZ2UvMiIsImVudGl0eS9lZGdlLzUiXSwiY29ubmVjdG9yX2JpbmRpbmdfc3RhdHVzIjoib3duZXJfbm90X3BhcnQiLCJlbnRpdHlfaWQiOiJlbnRpdHkvZmFjZS8wIiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJub3JtYWwiOlstMSwwLDBdLCJvcmlnaW4iOlswLC0zMzMuMzMzMzMzMzMzMzMzMywzMzMuMzMzMzMzMzMzMzMzM10sInR5cGUiOiJwbGFuZSIsInhfZGlyZWN0aW9uIjpbMCwwLDFdfSwia2luZCI6ImZhY2UiLCJwYXJlbnRfZW50aXR5X2lkcyI6WyJlbnRpdHkvc29saWQvMCJdLCJwcm9wZXJ0aWVzIjp7ImFyZWEiOjUwMDAwMCwiYm91bmRzIjp7Im1heCI6WzAsMCwxMDAwXSwibWluIjpbMCwtMTAwMCwwXX0sImNlbnRyb2lkIjpbMCwtMzMzLjMzMzMzMzMzMzMzMzMsMzMzLjMzMzMzMzMzMzMzMzNdLCJvcmllbnRhdGlvbiI6ImZvcndhcmQiLCJxdWFsaXR5Ijoia2VybmVsX2V2YWx1YXRlZCJ9LCJyZW5kZXJfc3RhdHVzIjoicmVuZGVyZWQiLCJzZGtfY29ubmVjdG9yX2ZyYW1lIjp7Im9yaWdpbiI6WzAsLTMzMy4zMzMzMzMzMzMzMzMzLDMzMy4zMzMzMzMzMzMzMzMzXSwieF9heGlzIjpbMCwwLDFdLCJ5X2F4aXMiOlswLDEsMF0sInpfYXhpcyI6Wy0xLDAsMF19LCJzZGtfbWV0YWRhdGEiOnt9LCJzZW1hbnRpY19iaW5kaW5nX2lkcyI6W10sInNvdXJjZSI6eyJraW5kIjoidW5ib3VuZCJ9fSx7ImNoaWxkX2VudGl0eV9pZHMiOlsiZW50aXR5L2VkZ2UvMSIsImVudGl0eS9lZGdlLzIiLCJlbnRpdHkvZWRnZS8zIl0sImNvbm5lY3Rvcl9iaW5kaW5nX3N0YXR1cyI6Im93bmVyX25vdF9wYXJ0IiwiZW50aXR5X2lkIjoiZW50aXR5L2ZhY2UvMSIsImV2YWx1YXRlZF90YWdzIjpbXSwiZ2VvbWV0cnkiOnsibm9ybWFsIjpbMCwwLC0xXSwib3JpZ2luIjpbMzMzLjMzMzMzMzMzMzMzMzMsLTMzMy4zMzMzMzMzMzMzMzMzLDBdLCJ0eXBlIjoicGxhbmUiLCJ4X2RpcmVjdGlvbiI6WzEsMCwwXX0sImtpbmQiOiJmYWNlIiwicGFyZW50X2VudGl0eV9pZHMiOlsiZW50aXR5L3NvbGlkLzAiXSwicHJvcGVydGllcyI6eyJhcmVhIjo1MDAwMDAsImJvdW5kcyI6eyJtYXgiOlsxMDAwLDAsMF0sIm1pbiI6WzAsLTEwMDAsMF19LCJjZW50cm9pZCI6WzMzMy4zMzMzMzMzMzMzMzMzLC0zMzMuMzMzMzMzMzMzMzMzMywwXSwib3JpZW50YXRpb24iOiJmb3J3YXJkIiwicXVhbGl0eSI6Imtlcm5lbF9ldmFsdWF0ZWQifSwicmVuZGVyX3N0YXR1cyI6InJlbmRlcmVkIiwic2RrX2Nvbm5lY3Rvcl9mcmFtZSI6eyJvcmlnaW4iOlszMzMuMzMzMzMzMzMzMzMzMywtMzMzLjMzMzMzMzMzMzMzMzMsMF0sInhfYXhpcyI6WzEsMCwwXSwieV9heGlzIjpbMCwtMSwwXSwiel9heGlzIjpbMCwwLC0xXX0sInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImtpbmQiOiJ1bmJvdW5kIn19LHsiY2hpbGRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZWRnZS8zIiwiZW50aXR5L2VkZ2UvNCIsImVudGl0eS9lZGdlLzUiXSwiY29ubmVjdG9yX2JpbmRpbmdfc3RhdHVzIjoib3duZXJfbm90X3BhcnQiLCJlbnRpdHlfaWQiOiJlbnRpdHkvZmFjZS8yIiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJub3JtYWwiOlswLDEsMF0sIm9yaWdpbiI6WzMzMy4zMzMzMzMzMzMzMzMzLDAsMzMzLjMzMzMzMzMzMzMzMzNdLCJ0eXBlIjoicGxhbmUiLCJ4X2RpcmVjdGlvbiI6WzEsMCwwXX0sImtpbmQiOiJmYWNlIiwicGFyZW50X2VudGl0eV9pZHMiOlsiZW50aXR5L3NvbGlkLzAiXSwicHJvcGVydGllcyI6eyJhcmVhIjo1MDAwMDAsImJvdW5kcyI6eyJtYXgiOlsxMDAwLDAsMTAwMF0sIm1pbiI6WzAsMCwwXX0sImNlbnRyb2lkIjpbMzMzLjMzMzMzMzMzMzMzMzMsMCwzMzMuMzMzMzMzMzMzMzMzM10sIm9yaWVudGF0aW9uIjoiZm9yd2FyZCIsInF1YWxpdHkiOiJrZXJuZWxfZXZhbHVhdGVkIn0sInJlbmRlcl9zdGF0dXMiOiJyZW5kZXJlZCIsInNka19jb25uZWN0b3JfZnJhbWUiOnsib3JpZ2luIjpbMzMzLjMzMzMzMzMzMzMzMzMsMCwzMzMuMzMzMzMzMzMzMzMzM10sInhfYXhpcyI6WzEsMCwwXSwieV9heGlzIjpbMCwwLC0xXSwiel9heGlzIjpbMCwxLDBdfSwic2RrX21ldGFkYXRhIjp7fSwic2VtYW50aWNfYmluZGluZ19pZHMiOltdLCJzb3VyY2UiOnsia2luZCI6InVuYm91bmQifX0seyJjaGlsZF9lbnRpdHlfaWRzIjpbImVudGl0eS9lZGdlLzAiLCJlbnRpdHkvZWRnZS8xIiwiZW50aXR5L2VkZ2UvNCJdLCJjb25uZWN0b3JfYmluZGluZ19zdGF0dXMiOiJvd25lcl9ub3RfcGFydCIsImVudGl0eV9pZCI6ImVudGl0eS9mYWNlLzMiLCJldmFsdWF0ZWRfdGFncyI6W10sImdlb21ldHJ5Ijp7Im5vcm1hbCI6WzAuNTc3MzUwMjY5MTg5NjI1OCwtMC41NzczNTAyNjkxODk2MjU4LDAuNTc3MzUwMjY5MTg5NjI1OF0sIm9yaWdpbiI6WzMzMy4zMzMzMzMzMzMzMzMzLC0zMzMuMzMzMzMzMzMzMzMzMywzMzMuMzMzMzMzMzMzMzMzM10sInR5cGUiOiJwbGFuZSIsInhfZGlyZWN0aW9uIjpbLTAuNzA3MTA2NzgxMTg2NTQ3NSwwLDAuNzA3MTA2NzgxMTg2NTQ3NV19LCJraW5kIjoiZmFjZSIsInBhcmVudF9lbnRpdHlfaWRzIjpbImVudGl0eS9zb2xpZC8wIl0sInByb3BlcnRpZXMiOnsiYXJlYSI6ODY2MDI1LjQwMzc4NDQzODYsImJvdW5kcyI6eyJtYXgiOlsxMDAwLDAsMTAwMF0sIm1pbiI6WzAsLTEwMDAsMF19LCJjZW50cm9pZCI6WzMzMy4zMzMzMzMzMzMzMzMzLC0zMzMuMzMzMzMzMzMzMzMzMywzMzMuMzMzMzMzMzMzMzMzM10sIm9yaWVudGF0aW9uIjoiZm9yd2FyZCIsInF1YWxpdHkiOiJrZXJuZWxfZXZhbHVhdGVkIn0sInJlbmRlcl9zdGF0dXMiOiJyZW5kZXJlZCIsInNka19jb25uZWN0b3JfZnJhbWUiOnsib3JpZ2luIjpbMzMzLjMzMzMzMzMzMzMzMzMsLTMzMy4zMzMzMzMzMzMzMzMzLDMzMy4zMzMzMzMzMzMzMzMzXSwieF9heGlzIjpbLTAuNzA3MTA2NzgxMTg2NTQ3NSwwLDAuNzA3MTA2NzgxMTg2NTQ3NV0sInlfYXhpcyI6Wy0wLjQwODI0ODI5MDQ2Mzg2MzEsLTAuODE2NDk2NTgwOTI3NzI2MSwtMC40MDgyNDgyOTA0NjM4NjMxXSwiel9heGlzIjpbMC41NzczNTAyNjkxODk2MjU4LC0wLjU3NzM1MDI2OTE4OTYyNTgsMC41NzczNTAyNjkxODk2MjU4XX0sInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImtpbmQiOiJ1bmJvdW5kIn19LHsiY2hpbGRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZmFjZS8wIiwiZW50aXR5L2ZhY2UvMSIsImVudGl0eS9mYWNlLzIiLCJlbnRpdHkvZmFjZS8zIl0sImNvbm5lY3Rvcl9iaW5kaW5nX3N0YXR1cyI6Im5vdF9hcHBsaWNhYmxlIiwiZW50aXR5X2lkIjoiZW50aXR5L3NvbGlkLzAiLCJldmFsdWF0ZWRfdGFncyI6W10sImdlb21ldHJ5Ijp7InR5cGUiOiJicmVwX3NvbGlkIn0sImtpbmQiOiJzb2xpZCIsInBhcmVudF9lbnRpdHlfaWRzIjpbXSwicHJvcGVydGllcyI6eyJib3VuZHMiOnsibWF4IjpbMTAwMCwwLDEwMDBdLCJtaW4iOlswLC0xMDAwLDBdfSwiY2VudHJvaWQiOlsyNTAsLTI1MCwyNTBdLCJxdWFsaXR5Ijoia2VybmVsX2V2YWx1YXRlZCIsInN1cmZhY2VfYXJlYSI6MjM2NjAyNS40MDM3ODQ0Mzg1LCJ2b2x1bWUiOjE2NjY2NjY2Ni42NjY2NjY2Nn0sInJlbmRlcl9zdGF0dXMiOiJyZW5kZXJlZCIsInNka19jb25uZWN0b3JfZnJhbWUiOm51bGwsInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImtpbmQiOiJ1bmJvdW5kIn19LHsiY2hpbGRfZW50aXR5X2lkcyI6W10sImNvbm5lY3Rvcl9iaW5kaW5nX3N0YXR1cyI6Im93bmVyX25vdF9wYXJ0IiwiZW50aXR5X2lkIjoiZW50aXR5L3ZlcnRleC8wIiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJwb3NpdGlvbiI6WzAsMCwwXSwidHlwZSI6InBvaW50In0sImtpbmQiOiJ2ZXJ0ZXgiLCJwYXJlbnRfZW50aXR5X2lkcyI6WyJlbnRpdHkvZWRnZS8yIiwiZW50aXR5L2VkZ2UvMyIsImVudGl0eS9lZGdlLzUiXSwicHJvcGVydGllcyI6eyJib3VuZHMiOnsibWF4IjpbMCwwLDBdLCJtaW4iOlswLDAsMF19LCJwb3NpdGlvbiI6WzAsMCwwXSwicXVhbGl0eSI6Imtlcm5lbF9ldmFsdWF0ZWQifSwicmVuZGVyX3N0YXR1cyI6InJlbmRlcmVkIiwic2RrX2Nvbm5lY3Rvcl9mcmFtZSI6eyJvcmlnaW4iOlswLDAsMF0sInhfYXhpcyI6WzEsMCwwXSwieV9heGlzIjpbMCwxLDBdLCJ6X2F4aXMiOlswLDAsMV19LCJzZGtfbWV0YWRhdGEiOnt9LCJzZW1hbnRpY19iaW5kaW5nX2lkcyI6W10sInNvdXJjZSI6eyJraW5kIjoidW5ib3VuZCJ9fSx7ImNoaWxkX2VudGl0eV9pZHMiOltdLCJjb25uZWN0b3JfYmluZGluZ19zdGF0dXMiOiJvd25lcl9ub3RfcGFydCIsImVudGl0eV9pZCI6ImVudGl0eS92ZXJ0ZXgvMSIsImV2YWx1YXRlZF90YWdzIjpbXSwiZ2VvbWV0cnkiOnsicG9zaXRpb24iOlsxMDAwLDAsMF0sInR5cGUiOiJwb2ludCJ9LCJraW5kIjoidmVydGV4IiwicGFyZW50X2VudGl0eV9pZHMiOlsiZW50aXR5L2VkZ2UvMSIsImVudGl0eS9lZGdlLzMiLCJlbnRpdHkvZWRnZS80Il0sInByb3BlcnRpZXMiOnsiYm91bmRzIjp7Im1heCI6WzEwMDAsMCwwXSwibWluIjpbMTAwMCwwLDBdfSwicG9zaXRpb24iOlsxMDAwLDAsMF0sInF1YWxpdHkiOiJrZXJuZWxfZXZhbHVhdGVkIn0sInJlbmRlcl9zdGF0dXMiOiJyZW5kZXJlZCIsInNka19jb25uZWN0b3JfZnJhbWUiOnsib3JpZ2luIjpbMTAwMCwwLDBdLCJ4X2F4aXMiOlsxLDAsMF0sInlfYXhpcyI6WzAsMSwwXSwiel9heGlzIjpbMCwwLDFdfSwic2RrX21ldGFkYXRhIjp7fSwic2VtYW50aWNfYmluZGluZ19pZHMiOltdLCJzb3VyY2UiOnsia2luZCI6InVuYm91bmQifX0seyJjaGlsZF9lbnRpdHlfaWRzIjpbXSwiY29ubmVjdG9yX2JpbmRpbmdfc3RhdHVzIjoib3duZXJfbm90X3BhcnQiLCJlbnRpdHlfaWQiOiJlbnRpdHkvdmVydGV4LzIiLCJldmFsdWF0ZWRfdGFncyI6W10sImdlb21ldHJ5Ijp7InBvc2l0aW9uIjpbMCwwLDEwMDBdLCJ0eXBlIjoicG9pbnQifSwia2luZCI6InZlcnRleCIsInBhcmVudF9lbnRpdHlfaWRzIjpbImVudGl0eS9lZGdlLzAiLCJlbnRpdHkvZWRnZS80IiwiZW50aXR5L2VkZ2UvNSJdLCJwcm9wZXJ0aWVzIjp7ImJvdW5kcyI6eyJtYXgiOlswLDAsMTAwMF0sIm1pbiI6WzAsMCwxMDAwXX0sInBvc2l0aW9uIjpbMCwwLDEwMDBdLCJxdWFsaXR5Ijoia2VybmVsX2V2YWx1YXRlZCJ9LCJyZW5kZXJfc3RhdHVzIjoicmVuZGVyZWQiLCJzZGtfY29ubmVjdG9yX2ZyYW1lIjp7Im9yaWdpbiI6WzAsMCwxMDAwXSwieF9heGlzIjpbMSwwLDBdLCJ5X2F4aXMiOlswLDEsMF0sInpfYXhpcyI6WzAsMCwxXX0sInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImtpbmQiOiJ1bmJvdW5kIn19LHsiY2hpbGRfZW50aXR5X2lkcyI6W10sImNvbm5lY3Rvcl9iaW5kaW5nX3N0YXR1cyI6Im93bmVyX25vdF9wYXJ0IiwiZW50aXR5X2lkIjoiZW50aXR5L3ZlcnRleC8zIiwiZXZhbHVhdGVkX3RhZ3MiOltdLCJnZW9tZXRyeSI6eyJwb3NpdGlvbiI6WzAsLTEwMDAsMF0sInR5cGUiOiJwb2ludCJ9LCJraW5kIjoidmVydGV4IiwicGFyZW50X2VudGl0eV9pZHMiOlsiZW50aXR5L2VkZ2UvMCIsImVudGl0eS9lZGdlLzEiLCJlbnRpdHkvZWRnZS8yIl0sInByb3BlcnRpZXMiOnsiYm91bmRzIjp7Im1heCI6WzAsLTEwMDAsMF0sIm1pbiI6WzAsLTEwMDAsMF19LCJwb3NpdGlvbiI6WzAsLTEwMDAsMF0sInF1YWxpdHkiOiJrZXJuZWxfZXZhbHVhdGVkIn0sInJlbmRlcl9zdGF0dXMiOiJyZW5kZXJlZCIsInNka19jb25uZWN0b3JfZnJhbWUiOnsib3JpZ2luIjpbMCwtMTAwMCwwXSwieF9heGlzIjpbMSwwLDBdLCJ5X2F4aXMiOlswLDEsMF0sInpfYXhpcyI6WzAsMCwxXX0sInNka19tZXRhZGF0YSI6e30sInNlbWFudGljX2JpbmRpbmdfaWRzIjpbXSwic291cmNlIjp7ImtpbmQiOiJ1bmJvdW5kIn19XSwiZmFjZV9ncm91cHMiOlt7ImVudGl0eV9pZCI6ImVudGl0eS9mYWNlLzAiLCJmaXJzdF9pbmRleCI6MCwiZ3JvdXBfaWQiOjAsImluZGV4X2NvdW50IjozLCJtZXNoX2luZGV4IjowLCJwcmltaXRpdmVfaW5kZXgiOjB9LHsiZW50aXR5X2lkIjoiZW50aXR5L2ZhY2UvMSIsImZpcnN0X2luZGV4IjozLCJncm91cF9pZCI6MSwiaW5kZXhfY291bnQiOjMsIm1lc2hfaW5kZXgiOjAsInByaW1pdGl2ZV9pbmRleCI6MH0seyJlbnRpdHlfaWQiOiJlbnRpdHkvZmFjZS8yIiwiZmlyc3RfaW5kZXgiOjYsImdyb3VwX2lkIjoyLCJpbmRleF9jb3VudCI6MywibWVzaF9pbmRleCI6MCwicHJpbWl0aXZlX2luZGV4IjowfSx7ImVudGl0eV9pZCI6ImVudGl0eS9mYWNlLzMiLCJmaXJzdF9pbmRleCI6OSwiZ3JvdXBfaWQiOjMsImluZGV4X2NvdW50IjozLCJtZXNoX2luZGV4IjowLCJwcmltaXRpdmVfaW5kZXgiOjB9XSwiZ2VvbWV0cnlfYXNzZXRfaWQiOiJzaGEyNTY6MjAwNTIzNDE0ZGUyODFhOGExNTUwODZhNTRjZDBmODgwZjY0MDQ4NTc2NWE0OGNjZGQ4ZWMyMzBmNjdkZDE3YyIsImdlb21ldHJ5X2VuZ2luZSI6eyJuYW1lIjoiT3BlbkNhc2NhZGUiLCJwcm9maWxlIjoib2NwLWV2YWx1YXRlZC1wcm9wZXJ0aWVzLTEiLCJ2ZXJzaW9uIjoiZml4dHVyZSJ9LCJzY2hlbWFfdmVyc2lvbiI6IjEuMCJ9UEsDBBQAAAgAAAAAIQBJVs2b/AMAAPwDAABUAAAAZ2VvbWV0cnkvc2hhMjU2LTIwMDUyMzQxNGRlMjgxYThhMTU1MDg2YTU0Y2QwZjg4MGY2NDA0ODU3NjVhNDhjY2RkOGVjMjMwZjY3ZGQxN2MuZ2xiZ2xURgIAAAD8AwAAqAIAAEpTT057ImFjY2Vzc29ycyI6W3siYnVmZmVyVmlldyI6MCwiY29tcG9uZW50VHlwZSI6NTEyNiwiY291bnQiOjEyLCJtYXgiOlsxLDEsMV0sIm1pbiI6WzAsMCwwXSwidHlwZSI6IlZFQzMifSx7ImJ1ZmZlclZpZXciOjEsImNvbXBvbmVudFR5cGUiOjUxMjYsImNvdW50IjoxMiwidHlwZSI6IlZFQzMifSx7ImJ1ZmZlclZpZXciOjIsImNvbXBvbmVudFR5cGUiOjUxMjMsImNvdW50IjoxMiwidHlwZSI6IlNDQUxBUiJ9XSwiYXNzZXQiOnsiZ2VuZXJhdG9yIjoiU2ltcGxlQ0FEIFNjZW5lIEdMQiBQcm9maWxlIDEiLCJ2ZXJzaW9uIjoiMi4wIn0sImJ1ZmZlclZpZXdzIjpbeyJidWZmZXIiOjAsImJ5dGVMZW5ndGgiOjE0NCwiYnl0ZU9mZnNldCI6MCwidGFyZ2V0IjozNDk2Mn0seyJidWZmZXIiOjAsImJ5dGVMZW5ndGgiOjE0NCwiYnl0ZU9mZnNldCI6MTQ0LCJ0YXJnZXQiOjM0OTYyfSx7ImJ1ZmZlciI6MCwiYnl0ZUxlbmd0aCI6MjQsImJ5dGVPZmZzZXQiOjI4OCwidGFyZ2V0IjozNDk2M31dLCJidWZmZXJzIjpbeyJieXRlTGVuZ3RoIjozMTJ9XSwibWVzaGVzIjpbeyJwcmltaXRpdmVzIjpbeyJhdHRyaWJ1dGVzIjp7Ik5PUk1BTCI6MSwiUE9TSVRJT04iOjB9LCJpbmRpY2VzIjoyLCJtb2RlIjo0fV19XSwibm9kZXMiOlt7Im1lc2giOjB9XSwic2NlbmUiOjAsInNjZW5lcyI6W3sibm9kZXMiOlswXX1dfTgBAABCSU4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIA/AAAAAAAAgD8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIA/AACAPwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgD8AAAAAAACAPwAAAAAAAAAAAAAAAAAAAAAAAIA/AAAAAAAAgD8AAAAAAACAPwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/Os0TPzrNEz86zRM/Os0TPzrNEz86zRM/Os0TPzrNEz86zRM/AAABAAIAAwAFAAQABgAHAAgACQALAAoAUEsDBBQAAAgAAAAAIQA4DSgxWA0AAFgNAAAKAAAAc2NlbmUuanNvbnsiYW5ub3RhdGlvbnMiOltdLCJhcHBlYXJhbmNlcyI6W3siYWxwaGFfbW9kZSI6Im9wYXF1ZSIsImFwcGVhcmFuY2VfaWQiOiJhcHBlYXJhbmNlL2V2YWx1YXRlZC9iZjFjYTZkNjBhZjIxNzY3YWNlMTQxOWI2MDU1MjE3Yzg3MTQ1ODliNzkwOTBkMjcwYWFiZTUxMjIzMGVmYTNhIiwiYmFzZV9jb2xvciI6WzAuNzIsMC43NSwwLjc4LDFdLCJkb3VibGVfc2lkZWQiOmZhbHNlLCJlZGdlX2NvbG9yIjpbMC4wOCwwLjA5LDAuMSwxXSwibWV0YWxsaWMiOjAsIm5hbWUiOm51bGwsInJvdWdobmVzcyI6MC41NSwic2RrX21ldGFkYXRhIjp7fSwic291cmNlIjpudWxsfV0sImNhbWVyYXMiOltdLCJjb21waWxlX29wdGlvbnMiOnsiYW5ndWxhcl90b2xlcmFuY2UiOjAuMjIsImVtYmVkX3ByZXNlbnRhdGlvbiI6ZmFsc2UsImVtYmVkX3NvdXJjZSI6ZmFsc2UsImxpbmVhcl90b2xlcmFuY2UiOjAuMzV9LCJjb25uZWN0b3JzIjpbXSwiY29vcmRpbmF0ZV9zeXN0ZW0iOnsiaGFuZGVkbmVzcyI6InJpZ2h0IiwibGVuZ3RoX3VuaXQiOiJtbSIsInVwX2F4aXMiOiIrWiJ9LCJkZWZpbml0aW9ucyI6W3siYXBwZWFyYW5jZV9pZCI6ImFwcGVhcmFuY2UvZXZhbHVhdGVkL2JmMWNhNmQ2MGFmMjE3NjdhY2UxNDE5YjYwNTUyMTdjODcxNDU4OWI3OTA5MGQyNzBhYWJlNTEyMjMwZWZhM2EiLCJkZWZpbml0aW9uX2lkIjoiZGVmaW5pdGlvbi9yb290L3NoYXBlL21hbnVhbC9maXh0dXJlIiwiZWRnZV9hc3NldF9pZCI6InNoYTI1Njo2Y2U0ODFkYjFmZTEzYmNmZTdhMjM3NDQyM2U2YzcyZDc1N2Q1MWM5OTE2OGIyODdjNTJmNTNhYTYyNWNjZDY3IiwiZW50aXR5X2Fzc2V0X2lkIjoic2hhMjU2OjM4OTdjYzllYmRiMjk0MTgzNTk2NGE0MzU4MjFiNTU2ZTAxYjE3NTUzNWNmZjhmYzBlYWFhZmI2ODRjNGRkZjUiLCJnZW9tZXRyeV9hc3NldF9pZCI6InNoYTI1NjoyMDA1MjM0MTRkZTI4MWE4YTE1NTA4NmE1NGNkMGY4ODBmNjQwNDg1NzY1YTQ4Y2NkZDhlYzIzMGY2N2RkMTdjIiwia2luZCI6InNoYXBlIiwibmFtZSI6bnVsbCwic2RrX21ldGFkYXRhIjp7fSwic291cmNlIjp7ImtpbmQiOiJtYW51YWwiLCJyb290X2lkIjoicm9vdCIsInNvdXJjZV9pZCI6ImZpeHR1cmUifX1dLCJkaWFnbm9zdGljcyI6W10sImVkZ2VfYXNzZXRzIjpbeyJhc3NldF9pZCI6InNoYTI1Njo2Y2U0ODFkYjFmZTEzYmNmZTdhMjM3NDQyM2U2YzcyZDc1N2Q1MWM5OTE2OGIyODdjNTJmNTNhYTYyNWNjZDY3IiwiYXNzZXRfdG9fc2NlbmUiOlsxMDAwLDAsMCwwLDAsMCwtMTAwMCwwLDAsMTAwMCwwLDAsMCwwLDAsMV0sImJ5dGVfbGVuZ3RoIjo3NDAsImNvbnRlbnRfaGFzaCI6InNoYTI1Njo2Y2U0ODFkYjFmZTEzYmNmZTdhMjM3NDQyM2U2YzcyZDc1N2Q1MWM5OTE2OGIyODdjNTJmNTNhYTYyNWNjZDY3IiwibWVkaWFfdHlwZSI6Im1vZGVsL2dsdGYtYmluYXJ5Iiwic2NlbmVfbG9jYWxfYm91bmRzIjp7Im1heCI6WzEwMDAsMCwxMDAwXSwibWluIjpbMCwtMTAwMCwwXX0sInRlc3NlbGxhdGlvbiI6eyJsaW5lYXJfdG9sZXJhbmNlIjowLjM1fSwidXJpIjoiZWRnZXMvc2hhMjU2LTZjZTQ4MWRiMWZlMTNiY2ZlN2EyMzc0NDIzZTZjNzJkNzU3ZDUxYzk5MTY4YjI4N2M1MmY1M2FhNjI1Y2NkNjcuZ2xiIn1dLCJlbnRpdHlfYXNzZXRzIjpbeyJieXRlX2xlbmd0aCI6MTE1MTcsImNvbnRlbnRfaGFzaCI6InNoYTI1NjozODk3Y2M5ZWJkYjI5NDE4MzU5NjRhNDM1ODIxYjU1NmUwMWIxNzU1MzVjZmY4ZmMwZWFhYWZiNjg0YzRkZGY1IiwiZW50aXR5X2Fzc2V0X2lkIjoic2hhMjU2OjM4OTdjYzllYmRiMjk0MTgzNTk2NGE0MzU4MjFiNTU2ZTAxYjE3NTUzNWNmZjhmYzBlYWFhZmI2ODRjNGRkZjUiLCJtZWRpYV90eXBlIjoiYXBwbGljYXRpb24vdm5kLnNpbXBsZWNhZC5lbnRpdGllcytqc29uIiwidXJpIjoiZW50aXRpZXMvc2hhMjU2LTM4OTdjYzllYmRiMjk0MTgzNTk2NGE0MzU4MjFiNTU2ZTAxYjE3NTUzNWNmZjhmYzBlYWFhZmI2ODRjNGRkZjUuanNvbiJ9XSwiZXh0ZW5zaW9ucyI6e30sImV4dGVuc2lvbnNfcmVxdWlyZWQiOltdLCJleHRlbnNpb25zX3VzZWQiOltdLCJnZW5lcmF0b3IiOnsibmFtZSI6InNpbXBsZWNhZGFwaSIsIm9jcF9iaW5kaW5nc192ZXJzaW9uIjoiZml4dHVyZSIsIm9jcF92ZXJzaW9uIjoiZml4dHVyZSIsInBsYXRmb3JtX3RhZyI6ImZpeHR1cmUiLCJwcm9maWxlIjoic2NlbmUtMS4wLW9jcC1nbGItMiIsInB5dGhvbl9hYmkiOiJmaXh0dXJlIiwic2ltcGxlY2FkYXBpX3ZlcnNpb24iOiJmaXh0dXJlIiwidG9vbGNoYWluX2hhc2giOiJzaGEyNTY6MDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMCJ9LCJnZW9tZXRyeV9hc3NldHMiOlt7ImFzc2V0X2lkIjoic2hhMjU2OjIwMDUyMzQxNGRlMjgxYThhMTU1MDg2YTU0Y2QwZjg4MGY2NDA0ODU3NjVhNDhjY2RkOGVjMjMwZjY3ZGQxN2MiLCJhc3NldF90b19zY2VuZSI6WzEwMDAsMCwwLDAsMCwwLC0xMDAwLDAsMCwxMDAwLDAsMCwwLDAsMCwxXSwiYnl0ZV9sZW5ndGgiOjEwMjAsImNvbnRlbnRfaGFzaCI6InNoYTI1NjoyMDA1MjM0MTRkZTI4MWE4YTE1NTA4NmE1NGNkMGY4ODBmNjQwNDg1NzY1YTQ4Y2NkZDhlYzIzMGY2N2RkMTdjIiwibWVkaWFfdHlwZSI6Im1vZGVsL2dsdGYtYmluYXJ5Iiwic2NlbmVfbG9jYWxfYm91bmRzIjp7Im1heCI6WzEwMDAsMCwxMDAwXSwibWluIjpbMCwtMTAwMCwwXX0sInRlc3NlbGxhdGlvbiI6eyJhbmd1bGFyX3RvbGVyYW5jZSI6MC4yMiwibGluZWFyX3RvbGVyYW5jZSI6MC4zNX0sInVyaSI6Imdlb21ldHJ5L3NoYTI1Ni0yMDA1MjM0MTRkZTI4MWE4YTE1NTA4NmE1NGNkMGY4ODBmNjQwNDg1NzY1YTQ4Y2NkZDhlYzIzMGY2N2RkMTdjLmdsYiJ9XSwibGlnaHRzIjpbXSwibm9kZXMiOlt7ImFwcGVhcmFuY2Vfb3ZlcnJpZGVfaWQiOm51bGwsImRlZmluaXRpb25faWQiOiJkZWZpbml0aW9uL3Jvb3Qvc2hhcGUvbWFudWFsL2ZpeHR1cmUiLCJuYW1lIjpudWxsLCJub2RlX2lkIjoiaW5zdGFuY2Uvcm9vdCIsIm9yZGVyIjowLCJwYXJlbnRfbm9kZV9pZCI6bnVsbCwic2RrX21ldGFkYXRhIjp7fSwic2VsZWN0YWJsZSI6dHJ1ZSwic291cmNlIjp7ImtpbmQiOiJzaGFwZV9yb290Iiwicm9vdF9pZCI6InJvb3QifSwidHJhbnNmb3JtIjp7Im9yaWdpbiI6WzAsMCwwXSwieF9heGlzIjpbMSwwLDBdLCJ5X2F4aXMiOlswLDEsMF0sInpfYXhpcyI6WzAsMCwxXX0sInZpc2libGUiOnRydWV9XSwicmV2aXNpb24iOiJzaGEyNTY6ZmFlMTVmNGFlZWYxMjc3Y2Q4MzFmZjhiZjgzZjliMjQ0ZDVjNDIyNWU3MmViN2JjMmUwZTM1ZGQ2Y2Y0MzYxNCIsInNjZW5lX2lkIjoiZml4dHVyZSIsInNjaGVtYV92ZXJzaW9uIjoiMS4wIiwic291cmNlIjp7ImtpbmQiOiJtYW51YWwiLCJzb3VyY2VfaWQiOiJmaXh0dXJlIn19UEsBAhQDFAAACAAAAAAhAB1FlhXkAgAA5AIAAFEAAAAAAAAAAAAAAKSBAAAAAGVkZ2VzL3NoYTI1Ni02Y2U0ODFkYjFmZTEzYmNmZTdhMjM3NDQyM2U2YzcyZDc1N2Q1MWM5OTE2OGIyODdjNTJmNTNhYTYyNWNjZDY3LmdsYlBLAQIUAxQAAAgAAAAAIQBgM6Aj/SwAAP0sAABVAAAAAAAAAAAAAACkgVMDAABlbnRpdGllcy9zaGEyNTYtMzg5N2NjOWViZGIyOTQxODM1OTY0YTQzNTgyMWI1NTZlMDFiMTc1NTM1Y2ZmOGZjMGVhYWFmYjY4NGM0ZGRmNS5qc29uUEsBAhQDFAAACAAAAAAhAElWzZv8AwAA/AMAAFQAAAAAAAAAAAAAAKSBwzAAAGdlb21ldHJ5L3NoYTI1Ni0yMDA1MjM0MTRkZTI4MWE4YTE1NTA4NmE1NGNkMGY4ODBmNjQwNDg1NzY1YTQ4Y2NkZDhlYzIzMGY2N2RkMTdjLmdsYlBLAQIUAxQAAAgAAAAAIQA4DSgxWA0AAFgNAAAKAAAAAAAAAAAAAACkgTE1AABzY2VuZS5qc29uUEsFBgAAAAAEAAQAvAEAALFCAAAAAA==", + "used_deflate": false, + "valid": true + }, + { + "error": null, + "name": "allowlisted_deflate", + "payload_base64": "UEsDBBQAAAgIAAAAIQAdRZYVcQEAAOQCAABRAAAAZWRnZXMvc2hhMjU2LTZjZTQ4MWRiMWZlMTNiY2ZlN2EyMzc0NDIzZTZjNzJkNzU3ZDUxYzk5MTY4YjI4N2M1MmY1M2FhNjI1Y2NkNjcuZ2xijVLJbsIwEJ3u209EPkdVForaXCqgi6gQQQ3iUnEIwQmWyCLb0KIoUj+nH9YP6TihIlAOtZXEM2/e85tRovnw6RAAvvGl4fPiuf2c+EFAhUi5IM5bTiaLMKR8xOg7cQydBGmcpQlN5HCVUeLcmFZTJReJJI5p6ST2P5Bm6rjHGLEEI0PHjZEsKWT02LFJoW9Lm/uk7S3pNd3rtHqtV1KgoC8ERTAnEU0o92XKFc7ibE47rQfNCzCtPffa2oCnIZtTzSQ6WVIuWIq+iHVtoI+ajXrHZbeTlaQ9mkRyhhYajSrhhmF5LeLS55E62o27prVp6Q/X2qGWWnWyrdqpyGsT9ZubtwqOqZjRCs04i5lky3XoS8nZZCFVmJOB63WHXbePLrA7lkxZoACccJxOcYJmMVZyCQYVXQmrYkwKNbLSfnmq8N9KA3nFF/4v7W4fNuvzfvtbz+/mdtc+DvxDe1/NARzCERzDCZzCGZzDBVzCFfwAUEsDBBQAAAgIAAAAIQBgM6AjtwUAAP0sAABVAAAAZW50aXRpZXMvc2hhMjU2LTM4OTdjYzllYmRiMjk0MTgzNTk2NGE0MzU4MjFiNTU2ZTAxYjE3NTUzNWNmZjhmYzBlYWFhZmI2ODRjNGRkZjUuanNvbt2a626rOBCA34XfJPEVSP/uA+wDrCrkgJOiQ4AF0pNu1XffMRDCLeFSSKoTqVUYbGYYfzMeO/7UXLn3Ai/1wsD2XO2lcr2JwzDdJG8ikpujCE7C3+y9c3qKpaZr0j1IWySJTPNu0Ixw48VwJLOwu8N7ienO2UtTEGoyRqg0HJO4Jjddjp3tFhvWjlimw8meUyEMwh3HNczLkw9xeIoS7eWfT00GqZd+5Fry7xvVZIOg7d6LEzAgcOVZe0G6lnXLmsJFJrad8BSk2gvRtaNM3ipto9g7wnu+y1L2pd/WhpvaSFUbnlkbaWpjVW1kZm20qc2oaqMza2NNbVZVG5tZG29qwzVM+BR1r3quwpM5oc6b57t2aYESXmx4l3Eqz9l41iVUg6c4YRBIJw1jewcP94KDnaQiPcEDtPB3IGM7CFM7EnFadu8OA/ku/JNIpWun4qDUw7MPMjzKNP7QXj4114tBDYQ03EL6Cq1NZGJkmBbGlsGZybtk8Iww9g5e3gnpGCEEsvQjkmCC7wVS+9K1X2C4sghMAUPAVrCt2xV74RTWVq4zN0RxGIFXMnd+ajsYCTf7dhTnuu5jYcxKXevoFfQ78LQ4VF5Rcg5injX1ZXBI32C4GWZrgik3IA9RtIUB/xcyGRgAVv+ScSB9u3SfeiF4ARc8Xw5Efg33dC1xf9nXIdvH4iiVmRUvVQw42+LsJSMd/nGvE9NbIhP6/Hfps8J64RRlKAy+cEUqwEIlkZDBU88pQcvHBron4Sl2svcohvIUZCOgfamw6kcbL4c2Hon2QD/rqIp2jpL+fbTxeLQruu+jrbDK2HoW2jUDSrTHOLyCNsRzjdtWD0PvEj2abLQc2WR80sZ1bOdhtpmO8bB0PDgX13BV7RZPvs3Ui/vge0rSbKOF50KLjkQLF+M5M1nNbEgmZcPuTPhQrq4KS6wuLrtNVQHe06diMhdVbIapGOn3i8zZZmIyeSZu1Jm38HteiVnVf3cavuHsK7Id1WVPfQnu+QmZcjam+ehJOI/yhedgMmlJ1DUFt0BdegbuWPvkLvuoM/S8+fe6ZG5WYzUwvk/YdWh7CAvC+Cj8qydqfK0opWta/egtyRXAyBeBQu5st7G9cqks6+EyCX3PBetbIEIfGBsY5Iyk76zUh70YuAJ6ifxNtH0Y/xaxQnHJKnKYZT8ccdyDOJ0LcTwGceWrVS2Ftp3d4X/Uh3jp4MURH7xiH/xiD0d8sGX36t1Vg/FiZB/KOG0wzZZK42Qc442VeodzxyfxhxPeW14Mfa0fwDe6n8E7l3Mr/LzSt7NIwS3aZ6KbjqJ7zU2TckSMLba2BuGW2vBrydqi0Ul/dIgMXgXNGUWWYSDC1wxR02KMWsbQgPrGrPFDo2xs4TRl1Qp9GLIIs8gWMQPcTbHiz8IG2xrcQltimsTIZM12tXCeTPGjUsCN3Vm9f5/jTkpQyUBEke85YufL7pRw4b0/JxTBuItlZGe9Kmvf/Lo7sibuxXQHDOFwR/2Dv9e7uMNgnGLlJjuPXEIboct17T30T4pzbBSf9eXLhGAJTr6/MC3fnwGquyw94x2FiVdZUlaL8tAL0sro5w/tSaw3liUdJdy43yMKRjqsXXTjo69g7qiXly+XZ+MDj+OjvZM7HRHcgwib8MNCKaiDUmm3GCsVHX8uLmR8OqmfLpmOCxqyKJywu5oJ2nmlaLdoail0/Lm40LG4XGqBBXhpb5wN4OVqT0e1csPyJbcwSyVPZAbuZtXW/ZOcZZ07/CQnnX4isKyia9rovZOc39VG7p6tJDNra53k3N47yUkHHna8xGHHeV+CECeUYeZKYmFhCcw5sgzBmeOivWWhvcEQs7hpcMEsx3FdSzqEgth0XWw6WuXpMjioX9DUdkPGtPZ3JIO/ROIIN1ulx+He85U8dKJVGS2ra3Cu1NBC6CfFKrg4rKwIdt6AYft6D6+R9vU/UEsDBBQAAAgIAAAAIQBJVs2blQEAAPwDAABUAAAAZ2VvbWV0cnkvc2hhMjU2LTIwMDUyMzQxNGRlMjgxYThhMTU1MDg2YTU0Y2QwZjg4MGY2NDA0ODU3NjVhNDhjY2RkOGVjMjMwZjY3ZGQxN2MuZ2xirVLNTsJAEB7Ef4++ANlzY2iLBHshgD/BICWUcDEcStnCJrQl2wUlhITH8S18At/Ig7MtxkLAenA3m87M932zM7Mdjjv3BwDwmQV4Q+PRMpsLYjsODcOAh8R4XpD+1HUp7zL6Qoy8QpzAmwQ+9UVnPqHEuFa1ogxOfUEMVVOIZ7+iTFVw99BjPnp5BTd6IpKQ7l1NJ0tlM7Wanvo3ubZLru+SW7VKo9ImS6zHDkOK4IIMqU+5LQIuceZNxrRWuc1ZDoZzD41qrsUDl41pTiUKmVEesgDbItpVHutIlJEcWDSs/lzQBvWHYoQlFApxwHTd6FrEhc2H0tQLN0Xtp6V0bRT4q1rbEmul0qZYl8OIxesWEmpd1STs0XBEY3TCmccEm61dWwjO+lMh3QVpmu2nSiN6z5Zp1Tt1s4kF4ZiYP2CO5MifJBjgUxSWPZnZRyfOJO+QZAyGcvZRJ5EV49/MPOqWpQxAtd6E3WtV3vzu46Th+3JsY0nO6j3d/h+e8XFZTjsAGTiALBzBIRzDCZzCGVzAOXwBUEsDBBQAAAgIAAAAIQA4DSgxYAQAAFgNAAAKAAAAc2NlbmUuanNvbr1WbY+jOAz+L3zdviSBAO1PudEKmcS0uYWEDWE0vWr++zmUvs21d9LN7LYSLU7i2I/txz4mYK0LEIyzQ7J9+b5IoO8RPFiFUXBMoO33UHVOY7JNXA8/R0xud1VG08L1fY2v0I4QUK/rhivIdc6gEbzIC1DIM76pcyYlCVRZ8EyWm7rYsA3TomAANUouRMqwgRTonhoGrJRrnSdj2KoQC3rI+CgXnKzVbqxbrAajkcxooB1wkaDe3R5iJe1nG3rw6UyHAdrWqGTLFomFjhyzY9suEu/G3d7iQI6zlZSLZNA/qrhbQ4Bke3wniRu9mg+8ky5Fxz3M0CnX9Yascf2MJ6Fnd2MLvgquxQmeqFoIsrGrUVe9xwHtCf+r+dPS+aZZ2BqLH/Wk8j1eai2q4PzFCOe1sRSAajgMAbtoxh4sAXRyLfFmtw+EbYt2F/bVaE0gadeRaOwreDNx07c/ElKusTG0PGfH8beF/Xrv6Z7r+9o7F9bDHnpcd2BHaNeNeQujj1k5BR6GAcPpGG0TMt/mCrOS65o3yNNaNViASIssEynmqhC6kIWWXG02PC9rURZKikamALmQSum8iJptMOHwQHdabgqlNljrWmwyXqZyk2eQpbIUvJYyR8ZrXkiZStU0ZaMYAkBT52WmMq0bSbp36CjJ/CPtgjEp0oxnGkXJoQQuJStzkJnSrClL1uQZy0pZ5BKykozVJSrCsckLrQlq0v7D2FlhHyG6zffn6X08HztBnMTacLNp8V9y3noSnSPwHktCG9hZNwSj5oy8RmVOol8RoJPS4KpBoSUPXjhjbHH9Li/vHxYiI9QHqpZTOSTbImNTVQWKebWHYf+ldnZI+FTh0Ec6jazarndtaJY1law/RFyj/VXrFLRV7UarJx7p4O3qU/yJPGZs5Leza98pfoEqHNt25pPjc9YYvaH7Y2SG9cm55WedW+3aOokJcFsrp4jf4cu55MUzhL+gnH5lqd5Fj8iPusiE9frV6tVgur5FBXo1mWBw+PbnQHG4oD1Lz4B/1p7VpH1C/I2gHOae8377Xnn8ORofm+PL3cZqHM7CHSWchxC75XHmh+TiC/SGHHCqryhDqa/shuoV/TDlV3Jl3rjhkbynXGyc76oAu/sF7xrqlfGqmPBLvmJLUrKkJFqKuH4Ie2J/qM3dsVu7Hl4YnGvVHoz9kFjsk5/YDe+J+hmZfQFnfx2ZcSaestkXGPp72ezpMPUfNHeO27nwPuv4henaOEnNXc6S9/8YkhylqKfZdEqQU9P9n6PNbd+OV50OGzuEafqamzKNfuinubYHHyN+2fq04yOVU4A6lmLwIz6YASaLqvmGD3NAjBK5OsQaj0ccDZdzIGMQF8nbPFC+8FlwOAso8pPgr6sgJjBpfDWDuRgUcfYYRVOlz7nbAHLZZIDYcFEUSpcpJ5KsmzJtNrXIMi1VJoTEQmBd1Eogw1RqnasmS3OeXVLzbn6Jwj12cMMsxEvJv81Fj8egvwFQSwECFAMUAAAICAAAACEAHUWWFXEBAADkAgAAUQAAAAAAAAAAAAAApIEAAAAAZWRnZXMvc2hhMjU2LTZjZTQ4MWRiMWZlMTNiY2ZlN2EyMzc0NDIzZTZjNzJkNzU3ZDUxYzk5MTY4YjI4N2M1MmY1M2FhNjI1Y2NkNjcuZ2xiUEsBAhQDFAAACAgAAAAhAGAzoCO3BQAA/SwAAFUAAAAAAAAAAAAAAKSB4AEAAGVudGl0aWVzL3NoYTI1Ni0zODk3Y2M5ZWJkYjI5NDE4MzU5NjRhNDM1ODIxYjU1NmUwMWIxNzU1MzVjZmY4ZmMwZWFhYWZiNjg0YzRkZGY1Lmpzb25QSwECFAMUAAAICAAAACEASVbNm5UBAAD8AwAAVAAAAAAAAAAAAAAApIEKCAAAZ2VvbWV0cnkvc2hhMjU2LTIwMDUyMzQxNGRlMjgxYThhMTU1MDg2YTU0Y2QwZjg4MGY2NDA0ODU3NjVhNDhjY2RkOGVjMjMwZjY3ZGQxN2MuZ2xiUEsBAhQDFAAACAgAAAAhADgNKDFgBAAAWA0AAAoAAAAAAAAAAAAAAKSBEQoAAHNjZW5lLmpzb25QSwUGAAAAAAQABAC8AQAAmQ4AAAAA", + "used_deflate": true, + "valid": true + }, + { + "error": null, + "name": "exact_member_compression_ratio", + "payload_base64": "UEsDBBQAAAgIAAAAIQArOj37DAAAALAEAAAKAAAAc2NlbmUuanNvbktMHAWjYBSMgqEBAFBLAQIUAxQAAAgIAAAAIQArOj37DAAAALAEAAAKAAAAAAAAAAAAAACkgQAAAABzY2VuZS5qc29uUEsFBgAAAAABAAEAOAAAADQAAAAAAA==", + "used_deflate": true, + "valid": true + }, + { + "error": "member compression ratio exceeds limit: \"scene.json\"", + "name": "over_member_compression_ratio", + "payload_base64": "UEsDBBQAAAgIAAAAIQA5evBEDAAAALEEAAAKAAAAc2NlbmUuanNvbktMHAWjYBSMgiECAFBLAQIUAxQAAAgIAAAAIQA5evBEDAAAALEEAAAKAAAAAAAAAAAAAACkgQAAAABzY2VuZS5qc29uUEsFBgAAAAABAAEAOAAAADQAAAAAAA==", + "used_deflate": null, + "valid": false + }, + { + "error": "ZIP member CRC mismatch: \"other.json\"", + "name": "crc_mismatch", + "payload_base64": "UEsDBBQAAAgAAAAAIQCDFtyMAQAAAAEAAAAKAAAAb3RoZXIuanNvbnlQSwMEFAAACAAAAAAhAEO/pqMCAAAAAgAAAAoAAABzY2VuZS5qc29ue31QSwECFAMUAAAIAAAAACEAgxbcjAEAAAABAAAACgAAAAAAAAAAAAAApIEAAAAAb3RoZXIuanNvblBLAQIUAxQAAAgAAAAAIQBDv6ajAgAAAAIAAAAKAAAAAAAAAAAAAACkgSkAAABzY2VuZS5qc29uUEsFBgAAAAACAAIAcAAAAFMAAAAAAA==", + "used_deflate": null, + "valid": false + }, + { + "error": "ZIP EOCD must be final and have no comment", + "name": "trailing_data", + "payload_base64": "UEsDBBQAAAgAAAAAIQCDFtyMAQAAAAEAAAAKAAAAb3RoZXIuanNvbnhQSwMEFAAACAAAAAAhAEO/pqMCAAAAAgAAAAoAAABzY2VuZS5qc29ue31QSwECFAMUAAAIAAAAACEAgxbcjAEAAAABAAAACgAAAAAAAAAAAAAApIEAAAAAb3RoZXIuanNvblBLAQIUAxQAAAgAAAAAIQBDv6ajAgAAAAIAAAAKAAAAAAAAAAAAAACkgSkAAABzY2VuZS5qc29uUEsFBgAAAAACAAIAcAAAAFMAAAAAAHg=", + "used_deflate": null, + "valid": false + }, + { + "error": "ZIP archive has a leading prefix", + "name": "leading_prefix", + "payload_base64": "eFBLAwQUAAAIAAAAACEAgxbcjAEAAAABAAAACgAAAG90aGVyLmpzb254UEsDBBQAAAgAAAAAIQBDv6ajAgAAAAIAAAAKAAAAc2NlbmUuanNvbnt9UEsBAhQDFAAACAAAAAAhAIMW3IwBAAAAAQAAAAoAAAAAAAAAAAAAAKSBAQAAAG90aGVyLmpzb25QSwECFAMUAAAIAAAAACEAQ7+mowIAAAACAAAACgAAAAAAAAAAAAAApIEqAAAAc2NlbmUuanNvblBLBQYAAAAAAgACAHAAAABUAAAAAAA=", + "used_deflate": null, + "valid": false + }, + { + "error": "ZIP bytes exist between local records and central directory", + "name": "local_record_gap", + "payload_base64": "UEsDBBQAAAgAAAAAIQCDFtyMAQAAAAEAAAAKAAAAb3RoZXIuanNvbnhQSwMEFAAACAAAAAAhAEO/pqMCAAAAAgAAAAoAAABzY2VuZS5qc29ue314UEsBAhQDFAAACAAAAAAhAIMW3IwBAAAAAQAAAAoAAAAAAAAAAAAAAKSBAAAAAG90aGVyLmpzb25QSwECFAMUAAAIAAAAACEAQ7+mowIAAAACAAAACgAAAAAAAAAAAAAApIEpAAAAc2NlbmUuanNvblBLBQYAAAAAAgACAHAAAABUAAAAAAA=", + "used_deflate": null, + "valid": false + }, + { + "error": "duplicate or case-colliding archive member: \"scene.json\"", + "name": "case_collision", + "payload_base64": "UEsDBBQAAAgAAAAAIQCDFtyMAQAAAAEAAAAKAAAAU0NFTkUuSlNPTnhQSwMEFAAACAAAAAAhAEO/pqMCAAAAAgAAAAoAAABzY2VuZS5qc29ue31QSwECFAMUAAAIAAAAACEAgxbcjAEAAAABAAAACgAAAAAAAAAAAAAApIEAAAAAU0NFTkUuSlNPTlBLAQIUAxQAAAgAAAAAIQBDv6ajAgAAAAIAAAAKAAAAAAAAAAAAAACkgSkAAABzY2VuZS5qc29uUEsFBgAAAAACAAIAcAAAAFMAAAAAAA==", + "used_deflate": null, + "valid": false + }, + { + "error": "invalid archive member path segment: \"a/../b.txt\"", + "name": "path_traversal", + "payload_base64": "UEsDBBQAAAgAAAAAIQCDFtyMAQAAAAEAAAAKAAAAYS8uLi9iLnR4dHhQSwMEFAAACAAAAAAhAEO/pqMCAAAAAgAAAAoAAABzY2VuZS5qc29ue31QSwECFAMUAAAIAAAAACEAgxbcjAEAAAABAAAACgAAAAAAAAAAAAAApIEAAAAAYS8uLi9iLnR4dFBLAQIUAxQAAAgAAAAAIQBDv6ajAgAAAAIAAAAKAAAAAAAAAAAAAACkgSkAAABzY2VuZS5qc29uUEsFBgAAAAACAAIAcAAAAFMAAAAAAA==", + "used_deflate": null, + "valid": false + }, + { + "error": "invalid archive member name: \"other\\\\json\"", + "name": "backslash_name", + "payload_base64": "UEsDBBQAAAgAAAAAIQCDFtyMAQAAAAEAAAAKAAAAb3RoZXJcanNvbnhQSwMEFAAACAAAAAAhAEO/pqMCAAAAAgAAAAoAAABzY2VuZS5qc29ue31QSwECFAMUAAAIAAAAACEAgxbcjAEAAAABAAAACgAAAAAAAAAAAAAApIEAAAAAb3RoZXJcanNvblBLAQIUAxQAAAgAAAAAIQBDv6ajAgAAAAIAAAAKAAAAAAAAAAAAAACkgSkAAABzY2VuZS5qc29uUEsFBgAAAAACAAIAcAAAAFMAAAAAAA==", + "used_deflate": null, + "valid": false + }, + { + "error": "invalid archive member name: \"other.\\u0000son\"", + "name": "nul_name", + "payload_base64": "UEsDBBQAAAgAAAAAIQCDFtyMAQAAAAEAAAAKAAAAb3RoZXIuAHNvbnhQSwMEFAAACAAAAAAhAEO/pqMCAAAAAgAAAAoAAABzY2VuZS5qc29ue31QSwECFAMUAAAIAAAAACEAgxbcjAEAAAABAAAACgAAAAAAAAAAAAAApIEAAAAAb3RoZXIuAHNvblBLAQIUAxQAAAgAAAAAIQBDv6ajAgAAAAIAAAAKAAAAAAAAAAAAAACkgSkAAABzY2VuZS5qc29uUEsFBgAAAAACAAIAcAAAAFMAAAAAAA==", + "used_deflate": null, + "valid": false + }, + { + "error": "ZIP EOCD must be final and have no comment", + "name": "bad_eocd_signature", + "payload_base64": "UEsDBBQAAAgAAAAAIQCDFtyMAQAAAAEAAAAKAAAAb3RoZXIuanNvbnhQSwMEFAAACAAAAAAhAEO/pqMCAAAAAgAAAAoAAABzY2VuZS5qc29ue31QSwECFAMUAAAIAAAAACEAgxbcjAEAAAABAAAACgAAAAAAAAAAAAAApIEAAAAAb3RoZXIuanNvblBLAQIUAxQAAAgAAAAAIQBDv6ajAgAAAAIAAAAKAAAAAAAAAAAAAACkgSkAAABzY2VuZS5qc29uAAAAAAAAAAACAAIAcAAAAFMAAAAAAA==", + "used_deflate": null, + "valid": false + }, + { + "error": "ZIP EOCD must be final and have no comment", + "name": "eocd_comment", + "payload_base64": "UEsDBBQAAAgAAAAAIQCDFtyMAQAAAAEAAAAKAAAAb3RoZXIuanNvbnhQSwMEFAAACAAAAAAhAEO/pqMCAAAAAgAAAAoAAABzY2VuZS5qc29ue31QSwECFAMUAAAIAAAAACEAgxbcjAEAAAABAAAACgAAAAAAAAAAAAAApIEAAAAAb3RoZXIuanNvblBLAQIUAxQAAAgAAAAAIQBDv6ajAgAAAAIAAAAKAAAAAAAAAAAAAACkgSkAAABzY2VuZS5qc29uUEsFBgAAAAACAAIAcAAAAFMAAAABAA==", + "used_deflate": null, + "valid": false + }, + { + "error": "multi-disk ZIP archives are forbidden", + "name": "multi_disk", + "payload_base64": "UEsDBBQAAAgAAAAAIQCDFtyMAQAAAAEAAAAKAAAAb3RoZXIuanNvbnhQSwMEFAAACAAAAAAhAEO/pqMCAAAAAgAAAAoAAABzY2VuZS5qc29ue31QSwECFAMUAAAIAAAAACEAgxbcjAEAAAABAAAACgAAAAAAAAAAAAAApIEAAAAAb3RoZXIuanNvblBLAQIUAxQAAAgAAAAAIQBDv6ajAgAAAAIAAAAKAAAAAAAAAAAAAACkgSkAAABzY2VuZS5qc29uUEsFBgEAAAACAAIAcAAAAFMAAAAAAA==", + "used_deflate": null, + "valid": false + }, + { + "error": "archive member count exceeds resource limit", + "name": "member_count_over_limit", + "payload_base64": "UEsDBBQAAAgAAAAAIQCDFtyMAQAAAAEAAAAKAAAAb3RoZXIuanNvbnhQSwMEFAAACAAAAAAhAEO/pqMCAAAAAgAAAAoAAABzY2VuZS5qc29ue31QSwECFAMUAAAIAAAAACEAgxbcjAEAAAABAAAACgAAAAAAAAAAAAAApIEAAAAAb3RoZXIuanNvblBLAQIUAxQAAAgAAAAAIQBDv6ajAgAAAAIAAAAKAAAAAAAAAAAAAACkgSkAAABzY2VuZS5qc29uUEsFBgAAAABRw1HDcAAAAFMAAAAAAA==", + "used_deflate": null, + "valid": false + }, + { + "error": "ZIP central directory offset/size mismatch", + "name": "central_offset_mismatch", + "payload_base64": "UEsDBBQAAAgAAAAAIQCDFtyMAQAAAAEAAAAKAAAAb3RoZXIuanNvbnhQSwMEFAAACAAAAAAhAEO/pqMCAAAAAgAAAAoAAABzY2VuZS5qc29ue31QSwECFAMUAAAIAAAAACEAgxbcjAEAAAABAAAACgAAAAAAAAAAAAAApIEAAAAAb3RoZXIuanNvblBLAQIUAxQAAAgAAAAAIQBDv6ajAgAAAAIAAAAKAAAAAAAAAAAAAACkgSkAAABzY2VuZS5qc29uUEsFBgAAAAACAAIAcAAAAFQAAAAAAA==", + "used_deflate": null, + "valid": false + }, + { + "error": "invalid ZIP central header signature", + "name": "bad_central_signature", + "payload_base64": "UEsDBBQAAAgAAAAAIQCDFtyMAQAAAAEAAAAKAAAAb3RoZXIuanNvbnhQSwMEFAAACAAAAAAhAEO/pqMCAAAAAgAAAAoAAABzY2VuZS5qc29ue30AAAAAFAMUAAAIAAAAACEAgxbcjAEAAAABAAAACgAAAAAAAAAAAAAApIEAAAAAb3RoZXIuanNvblBLAQIUAxQAAAgAAAAAIQBDv6ajAgAAAAIAAAAKAAAAAAAAAAAAAACkgSkAAABzY2VuZS5qc29uUEsFBgAAAAACAAIAcAAAAFMAAAAAAA==", + "used_deflate": null, + "valid": false + }, + { + "error": "ZIP creator/version profile mismatch", + "name": "central_made_by", + "payload_base64": "UEsDBBQAAAgAAAAAIQCDFtyMAQAAAAEAAAAKAAAAb3RoZXIuanNvbnhQSwMEFAAACAAAAAAhAEO/pqMCAAAAAgAAAAoAAABzY2VuZS5qc29ue31QSwECFAAUAAAIAAAAACEAgxbcjAEAAAABAAAACgAAAAAAAAAAAAAApIEAAAAAb3RoZXIuanNvblBLAQIUAxQAAAgAAAAAIQBDv6ajAgAAAAIAAAAKAAAAAAAAAAAAAACkgSkAAABzY2VuZS5qc29uUEsFBgAAAAACAAIAcAAAAFMAAAAAAA==", + "used_deflate": null, + "valid": false + }, + { + "error": "ZIP creator/version profile mismatch", + "name": "central_needed", + "payload_base64": "UEsDBBQAAAgAAAAAIQCDFtyMAQAAAAEAAAAKAAAAb3RoZXIuanNvbnhQSwMEFAAACAAAAAAhAEO/pqMCAAAAAgAAAAoAAABzY2VuZS5qc29ue31QSwECFAMtAAAIAAAAACEAgxbcjAEAAAABAAAACgAAAAAAAAAAAAAApIEAAAAAb3RoZXIuanNvblBLAQIUAxQAAAgAAAAAIQBDv6ajAgAAAAIAAAAKAAAAAAAAAAAAAACkgSkAAABzY2VuZS5qc29uUEsFBgAAAAACAAIAcAAAAFMAAAAAAA==", + "used_deflate": null, + "valid": false + }, + { + "error": "unsupported ZIP flags or compression method", + "name": "central_flags", + "payload_base64": "UEsDBBQAAAgAAAAAIQCDFtyMAQAAAAEAAAAKAAAAb3RoZXIuanNvbnhQSwMEFAAACAAAAAAhAEO/pqMCAAAAAgAAAAoAAABzY2VuZS5qc29ue31QSwECFAMUAAgIAAAAACEAgxbcjAEAAAABAAAACgAAAAAAAAAAAAAApIEAAAAAb3RoZXIuanNvblBLAQIUAxQAAAgAAAAAIQBDv6ajAgAAAAIAAAAKAAAAAAAAAAAAAACkgSkAAABzY2VuZS5qc29uUEsFBgAAAAACAAIAcAAAAFMAAAAAAA==", + "used_deflate": null, + "valid": false + }, + { + "error": "unsupported ZIP flags or compression method", + "name": "central_method", + "payload_base64": "UEsDBBQAAAgAAAAAIQCDFtyMAQAAAAEAAAAKAAAAb3RoZXIuanNvbnhQSwMEFAAACAAAAAAhAEO/pqMCAAAAAgAAAAoAAABzY2VuZS5qc29ue31QSwECFAMUAAAIYwAAACEAgxbcjAEAAAABAAAACgAAAAAAAAAAAAAApIEAAAAAb3RoZXIuanNvblBLAQIUAxQAAAgAAAAAIQBDv6ajAgAAAAIAAAAKAAAAAAAAAAAAAACkgSkAAABzY2VuZS5qc29uUEsFBgAAAAACAAIAcAAAAFMAAAAAAA==", + "used_deflate": null, + "valid": false + }, + { + "error": "ZIP timestamp profile mismatch", + "name": "central_time", + "payload_base64": "UEsDBBQAAAgAAAAAIQCDFtyMAQAAAAEAAAAKAAAAb3RoZXIuanNvbnhQSwMEFAAACAAAAAAhAEO/pqMCAAAAAgAAAAoAAABzY2VuZS5qc29ue31QSwECFAMUAAAIAAABACEAgxbcjAEAAAABAAAACgAAAAAAAAAAAAAApIEAAAAAb3RoZXIuanNvblBLAQIUAxQAAAgAAAAAIQBDv6ajAgAAAAIAAAAKAAAAAAAAAAAAAACkgSkAAABzY2VuZS5qc29uUEsFBgAAAAACAAIAcAAAAFMAAAAAAA==", + "used_deflate": null, + "valid": false + }, + { + "error": "ZIP timestamp profile mismatch", + "name": "central_date", + "payload_base64": "UEsDBBQAAAgAAAAAIQCDFtyMAQAAAAEAAAAKAAAAb3RoZXIuanNvbnhQSwMEFAAACAAAAAAhAEO/pqMCAAAAAgAAAAoAAABzY2VuZS5qc29ue31QSwECFAMUAAAIAAAAAAAAgxbcjAEAAAABAAAACgAAAAAAAAAAAAAApIEAAAAAb3RoZXIuanNvblBLAQIUAxQAAAgAAAAAIQBDv6ajAgAAAAIAAAAKAAAAAAAAAAAAAACkgSkAAABzY2VuZS5qc29uUEsFBgAAAAACAAIAcAAAAFMAAAAAAA==", + "used_deflate": null, + "valid": false + }, + { + "error": "ZIP extra/comment/disk/attributes are forbidden", + "name": "central_extra", + "payload_base64": "UEsDBBQAAAgAAAAAIQCDFtyMAQAAAAEAAAAKAAAAb3RoZXIuanNvbnhQSwMEFAAACAAAAAAhAEO/pqMCAAAAAgAAAAoAAABzY2VuZS5qc29ue31QSwECFAMUAAAIAAAAACEAgxbcjAEAAAABAAAACgABAAAAAAAAAAAApIEAAAAAb3RoZXIuanNvblBLAQIUAxQAAAgAAAAAIQBDv6ajAgAAAAIAAAAKAAAAAAAAAAAAAACkgSkAAABzY2VuZS5qc29uUEsFBgAAAAACAAIAcAAAAFMAAAAAAA==", + "used_deflate": null, + "valid": false + }, + { + "error": "ZIP extra/comment/disk/attributes are forbidden", + "name": "central_comment", + "payload_base64": "UEsDBBQAAAgAAAAAIQCDFtyMAQAAAAEAAAAKAAAAb3RoZXIuanNvbnhQSwMEFAAACAAAAAAhAEO/pqMCAAAAAgAAAAoAAABzY2VuZS5qc29ue31QSwECFAMUAAAIAAAAACEAgxbcjAEAAAABAAAACgAAAAEAAAAAAAAApIEAAAAAb3RoZXIuanNvblBLAQIUAxQAAAgAAAAAIQBDv6ajAgAAAAIAAAAKAAAAAAAAAAAAAACkgSkAAABzY2VuZS5qc29uUEsFBgAAAAACAAIAcAAAAFMAAAAAAA==", + "used_deflate": null, + "valid": false + }, + { + "error": "ZIP extra/comment/disk/attributes are forbidden", + "name": "central_disk_start", + "payload_base64": "UEsDBBQAAAgAAAAAIQCDFtyMAQAAAAEAAAAKAAAAb3RoZXIuanNvbnhQSwMEFAAACAAAAAAhAEO/pqMCAAAAAgAAAAoAAABzY2VuZS5qc29ue31QSwECFAMUAAAIAAAAACEAgxbcjAEAAAABAAAACgAAAAAAAQAAAAAApIEAAAAAb3RoZXIuanNvblBLAQIUAxQAAAgAAAAAIQBDv6ajAgAAAAIAAAAKAAAAAAAAAAAAAACkgSkAAABzY2VuZS5qc29uUEsFBgAAAAACAAIAcAAAAFMAAAAAAA==", + "used_deflate": null, + "valid": false + }, + { + "error": "ZIP extra/comment/disk/attributes are forbidden", + "name": "central_internal_attr", + "payload_base64": "UEsDBBQAAAgAAAAAIQCDFtyMAQAAAAEAAAAKAAAAb3RoZXIuanNvbnhQSwMEFAAACAAAAAAhAEO/pqMCAAAAAgAAAAoAAABzY2VuZS5qc29ue31QSwECFAMUAAAIAAAAACEAgxbcjAEAAAABAAAACgAAAAAAAAABAAAApIEAAAAAb3RoZXIuanNvblBLAQIUAxQAAAgAAAAAIQBDv6ajAgAAAAIAAAAKAAAAAAAAAAAAAACkgSkAAABzY2VuZS5qc29uUEsFBgAAAAACAAIAcAAAAFMAAAAAAA==", + "used_deflate": null, + "valid": false + }, + { + "error": "ZIP member must be Unix regular mode 0100644", + "name": "central_external_attr", + "payload_base64": "UEsDBBQAAAgAAAAAIQCDFtyMAQAAAAEAAAAKAAAAb3RoZXIuanNvbnhQSwMEFAAACAAAAAAhAEO/pqMCAAAAAgAAAAoAAABzY2VuZS5qc29ue31QSwECFAMUAAAIAAAAACEAgxbcjAEAAAABAAAACgAAAAAAAAAAAAAAAAAAAAAAb3RoZXIuanNvblBLAQIUAxQAAAgAAAAAIQBDv6ajAgAAAAIAAAAKAAAAAAAAAAAAAACkgSkAAABzY2VuZS5qc29uUEsFBgAAAAACAAIAcAAAAFMAAAAAAA==", + "used_deflate": null, + "valid": false + }, + { + "error": "ZIP central/local header mismatch", + "name": "central_crc", + "payload_base64": "UEsDBBQAAAgAAAAAIQCDFtyMAQAAAAEAAAAKAAAAb3RoZXIuanNvbnhQSwMEFAAACAAAAAAhAEO/pqMCAAAAAgAAAAoAAABzY2VuZS5qc29ue31QSwECFAMUAAAIAAAAACEAAAAAAAEAAAABAAAACgAAAAAAAAAAAAAApIEAAAAAb3RoZXIuanNvblBLAQIUAxQAAAgAAAAAIQBDv6ajAgAAAAIAAAAKAAAAAAAAAAAAAACkgSkAAABzY2VuZS5qc29uUEsFBgAAAAACAAIAcAAAAFMAAAAAAA==", + "used_deflate": null, + "valid": false + }, + { + "error": "ZIP central/local header mismatch", + "name": "central_compressed_size", + "payload_base64": "UEsDBBQAAAgAAAAAIQCDFtyMAQAAAAEAAAAKAAAAb3RoZXIuanNvbnhQSwMEFAAACAAAAAAhAEO/pqMCAAAAAgAAAAoAAABzY2VuZS5qc29ue31QSwECFAMUAAAIAAAAACEAgxbcjAIAAAABAAAACgAAAAAAAAAAAAAApIEAAAAAb3RoZXIuanNvblBLAQIUAxQAAAgAAAAAIQBDv6ajAgAAAAIAAAAKAAAAAAAAAAAAAACkgSkAAABzY2VuZS5qc29uUEsFBgAAAAACAAIAcAAAAFMAAAAAAA==", + "used_deflate": null, + "valid": false + }, + { + "error": "ZIP central/local header mismatch", + "name": "central_uncompressed_size", + "payload_base64": "UEsDBBQAAAgAAAAAIQCDFtyMAQAAAAEAAAAKAAAAb3RoZXIuanNvbnhQSwMEFAAACAAAAAAhAEO/pqMCAAAAAgAAAAoAAABzY2VuZS5qc29ue31QSwECFAMUAAAIAAAAACEAgxbcjAEAAAACAAAACgAAAAAAAAAAAAAApIEAAAAAb3RoZXIuanNvblBLAQIUAxQAAAgAAAAAIQBDv6ajAgAAAAIAAAAKAAAAAAAAAAAAAACkgSkAAABzY2VuZS5qc29uUEsFBgAAAAACAAIAcAAAAFMAAAAAAA==", + "used_deflate": null, + "valid": false + }, + { + "error": "invalid ZIP local header signature", + "name": "central_local_offset", + "payload_base64": "UEsDBBQAAAgAAAAAIQCDFtyMAQAAAAEAAAAKAAAAb3RoZXIuanNvbnhQSwMEFAAACAAAAAAhAEO/pqMCAAAAAgAAAAoAAABzY2VuZS5qc29ue31QSwECFAMUAAAIAAAAACEAgxbcjAEAAAABAAAACgAAAAAAAAAAAAAApIEBAAAAb3RoZXIuanNvblBLAQIUAxQAAAgAAAAAIQBDv6ajAgAAAAIAAAAKAAAAAAAAAAAAAACkgSkAAABzY2VuZS5qc29uUEsFBgAAAAACAAIAcAAAAFMAAAAAAA==", + "used_deflate": null, + "valid": false + }, + { + "error": "invalid ZIP local header signature", + "name": "local_signature", + "payload_base64": "AAAAABQAAAgAAAAAIQCDFtyMAQAAAAEAAAAKAAAAb3RoZXIuanNvbnhQSwMEFAAACAAAAAAhAEO/pqMCAAAAAgAAAAoAAABzY2VuZS5qc29ue31QSwECFAMUAAAIAAAAACEAgxbcjAEAAAABAAAACgAAAAAAAAAAAAAApIEAAAAAb3RoZXIuanNvblBLAQIUAxQAAAgAAAAAIQBDv6ajAgAAAAIAAAAKAAAAAAAAAAAAAACkgSkAAABzY2VuZS5qc29uUEsFBgAAAAACAAIAcAAAAFMAAAAAAA==", + "used_deflate": null, + "valid": false + }, + { + "error": "ZIP central/local header mismatch", + "name": "local_needed", + "payload_base64": "UEsDBC0AAAgAAAAAIQCDFtyMAQAAAAEAAAAKAAAAb3RoZXIuanNvbnhQSwMEFAAACAAAAAAhAEO/pqMCAAAAAgAAAAoAAABzY2VuZS5qc29ue31QSwECFAMUAAAIAAAAACEAgxbcjAEAAAABAAAACgAAAAAAAAAAAAAApIEAAAAAb3RoZXIuanNvblBLAQIUAxQAAAgAAAAAIQBDv6ajAgAAAAIAAAAKAAAAAAAAAAAAAACkgSkAAABzY2VuZS5qc29uUEsFBgAAAAACAAIAcAAAAFMAAAAAAA==", + "used_deflate": null, + "valid": false + }, + { + "error": "ZIP central/local header mismatch", + "name": "local_flags", + "payload_base64": "UEsDBBQAAAAAAAAAIQCDFtyMAQAAAAEAAAAKAAAAb3RoZXIuanNvbnhQSwMEFAAACAAAAAAhAEO/pqMCAAAAAgAAAAoAAABzY2VuZS5qc29ue31QSwECFAMUAAAIAAAAACEAgxbcjAEAAAABAAAACgAAAAAAAAAAAAAApIEAAAAAb3RoZXIuanNvblBLAQIUAxQAAAgAAAAAIQBDv6ajAgAAAAIAAAAKAAAAAAAAAAAAAACkgSkAAABzY2VuZS5qc29uUEsFBgAAAAACAAIAcAAAAFMAAAAAAA==", + "used_deflate": null, + "valid": false + }, + { + "error": "ZIP central/local header mismatch", + "name": "local_method", + "payload_base64": "UEsDBBQAAAgIAAAAIQCDFtyMAQAAAAEAAAAKAAAAb3RoZXIuanNvbnhQSwMEFAAACAAAAAAhAEO/pqMCAAAAAgAAAAoAAABzY2VuZS5qc29ue31QSwECFAMUAAAIAAAAACEAgxbcjAEAAAABAAAACgAAAAAAAAAAAAAApIEAAAAAb3RoZXIuanNvblBLAQIUAxQAAAgAAAAAIQBDv6ajAgAAAAIAAAAKAAAAAAAAAAAAAACkgSkAAABzY2VuZS5qc29uUEsFBgAAAAACAAIAcAAAAFMAAAAAAA==", + "used_deflate": null, + "valid": false + }, + { + "error": "ZIP central/local header mismatch", + "name": "local_time", + "payload_base64": "UEsDBBQAAAgAAAEAIQCDFtyMAQAAAAEAAAAKAAAAb3RoZXIuanNvbnhQSwMEFAAACAAAAAAhAEO/pqMCAAAAAgAAAAoAAABzY2VuZS5qc29ue31QSwECFAMUAAAIAAAAACEAgxbcjAEAAAABAAAACgAAAAAAAAAAAAAApIEAAAAAb3RoZXIuanNvblBLAQIUAxQAAAgAAAAAIQBDv6ajAgAAAAIAAAAKAAAAAAAAAAAAAACkgSkAAABzY2VuZS5qc29uUEsFBgAAAAACAAIAcAAAAFMAAAAAAA==", + "used_deflate": null, + "valid": false + }, + { + "error": "ZIP central/local header mismatch", + "name": "local_date", + "payload_base64": "UEsDBBQAAAgAAAAAAACDFtyMAQAAAAEAAAAKAAAAb3RoZXIuanNvbnhQSwMEFAAACAAAAAAhAEO/pqMCAAAAAgAAAAoAAABzY2VuZS5qc29ue31QSwECFAMUAAAIAAAAACEAgxbcjAEAAAABAAAACgAAAAAAAAAAAAAApIEAAAAAb3RoZXIuanNvblBLAQIUAxQAAAgAAAAAIQBDv6ajAgAAAAIAAAAKAAAAAAAAAAAAAACkgSkAAABzY2VuZS5qc29uUEsFBgAAAAACAAIAcAAAAFMAAAAAAA==", + "used_deflate": null, + "valid": false + }, + { + "error": "ZIP central/local header mismatch", + "name": "local_crc", + "payload_base64": "UEsDBBQAAAgAAAAAIQAAAAAAAQAAAAEAAAAKAAAAb3RoZXIuanNvbnhQSwMEFAAACAAAAAAhAEO/pqMCAAAAAgAAAAoAAABzY2VuZS5qc29ue31QSwECFAMUAAAIAAAAACEAgxbcjAEAAAABAAAACgAAAAAAAAAAAAAApIEAAAAAb3RoZXIuanNvblBLAQIUAxQAAAgAAAAAIQBDv6ajAgAAAAIAAAAKAAAAAAAAAAAAAACkgSkAAABzY2VuZS5qc29uUEsFBgAAAAACAAIAcAAAAFMAAAAAAA==", + "used_deflate": null, + "valid": false + }, + { + "error": "ZIP central/local header mismatch", + "name": "local_compressed_size", + "payload_base64": "UEsDBBQAAAgAAAAAIQCDFtyMAgAAAAEAAAAKAAAAb3RoZXIuanNvbnhQSwMEFAAACAAAAAAhAEO/pqMCAAAAAgAAAAoAAABzY2VuZS5qc29ue31QSwECFAMUAAAIAAAAACEAgxbcjAEAAAABAAAACgAAAAAAAAAAAAAApIEAAAAAb3RoZXIuanNvblBLAQIUAxQAAAgAAAAAIQBDv6ajAgAAAAIAAAAKAAAAAAAAAAAAAACkgSkAAABzY2VuZS5qc29uUEsFBgAAAAACAAIAcAAAAFMAAAAAAA==", + "used_deflate": null, + "valid": false + }, + { + "error": "ZIP central/local header mismatch", + "name": "local_uncompressed_size", + "payload_base64": "UEsDBBQAAAgAAAAAIQCDFtyMAQAAAAIAAAAKAAAAb3RoZXIuanNvbnhQSwMEFAAACAAAAAAhAEO/pqMCAAAAAgAAAAoAAABzY2VuZS5qc29ue31QSwECFAMUAAAIAAAAACEAgxbcjAEAAAABAAAACgAAAAAAAAAAAAAApIEAAAAAb3RoZXIuanNvblBLAQIUAxQAAAgAAAAAIQBDv6ajAgAAAAIAAAAKAAAAAAAAAAAAAACkgSkAAABzY2VuZS5qc29uUEsFBgAAAAACAAIAcAAAAFMAAAAAAA==", + "used_deflate": null, + "valid": false + }, + { + "error": "ZIP central/local header mismatch", + "name": "local_extra", + "payload_base64": "UEsDBBQAAAgAAAAAIQCDFtyMAQAAAAEAAAAKAAEAb3RoZXIuanNvbnhQSwMEFAAACAAAAAAhAEO/pqMCAAAAAgAAAAoAAABzY2VuZS5qc29ue31QSwECFAMUAAAIAAAAACEAgxbcjAEAAAABAAAACgAAAAAAAAAAAAAApIEAAAAAb3RoZXIuanNvblBLAQIUAxQAAAgAAAAAIQBDv6ajAgAAAAIAAAAKAAAAAAAAAAAAAACkgSkAAABzY2VuZS5qc29uUEsFBgAAAAACAAIAcAAAAFMAAAAAAA==", + "used_deflate": null, + "valid": false + } + ] +} diff --git a/SimpleCADAPI/test/scene_contract_corpus_support.py b/SimpleCADAPI/test/scene_contract_corpus_support.py new file mode 100644 index 0000000..21dcb03 --- /dev/null +++ b/SimpleCADAPI/test/scene_contract_corpus_support.py @@ -0,0 +1,65 @@ +"""Shared-corpus replay and characterization helpers for Python tests.""" + +from __future__ import annotations + +import base64 +from collections import Counter +from copy import deepcopy +from typing import Any, Mapping + +from simplecadapi.scene import with_scene_revision + + +def decode_base64(value: str) -> bytes: + return base64.b64decode(value, validate=True) + + +def corpus_blobs( + corpus: Mapping[str, Any], case: Mapping[str, Any] +) -> dict[str, bytes]: + pool = { + uri: decode_base64(payload) for uri, payload in corpus["blobs"].items() + } + blobs = {uri: pool[uri] for uri in case["blob_uris"]} + blobs.update( + { + uri: decode_base64(payload) + for uri, payload in case.get("blob_mutations", {}).items() + } + ) + return blobs + + +def apply_scene_field_case( + scene: Mapping[str, Any], case: Mapping[str, Any] +) -> dict[str, Any]: + result = deepcopy(dict(scene)) + for mutation in case["mutations"]: + current: Any = result + path = mutation["path"] + for part in path[:-1]: + current = current[part] + if mutation["operation"] == "delete": + del current[path[-1]] + else: + current[path[-1]] = deepcopy(mutation.get("value")) + return with_scene_revision(result) if case["recompute_revision"] else result + + +def scene_shape_facts(scene: Mapping[str, Any]) -> dict[str, Any]: + nodes = scene["nodes"] + occurrence_counts = Counter(node["definition_id"] for node in nodes) + return { + "definition_count": len(scene["definitions"]), + "definition_occurrence_counts": dict(sorted(occurrence_counts.items())), + "edge_asset_count": len(scene["edge_assets"]), + "entity_asset_count": len(scene["entity_assets"]), + "geometry_asset_count": len(scene["geometry_assets"]), + "maximum_depth": max( + len(node["source"].get("component_path", [])) for node in nodes + ), + "node_count": len(nodes), + "root_node_ids": [ + node["node_id"] for node in nodes if node["parent_node_id"] is None + ], + } diff --git a/SimpleCADAPI/test/test_all_features.py b/SimpleCADAPI/test/test_all_features.py index b2df052..a048d25 100644 --- a/SimpleCADAPI/test/test_all_features.py +++ b/SimpleCADAPI/test/test_all_features.py @@ -285,9 +285,14 @@ class TestBooleanOperations(unittest.TestCase): with self.assertRaises(scad.SimpleCADError) as ctx: scad.union_rsolid(solids) - message = str(ctx.exception) - self.assertIn("union_rsolid", message) - self.assertIn("单个Solid结果", message) + error = ctx.exception + self.assertEqual(error.operation, "union_rsolid") + self.assertEqual( + error.guidance.what_happened, "Failed to compute the boolean union." + ) + self.assertTrue( + any("exactly one solid" in cause for cause in error.guidance.possible_causes) + ) def test_union_touching_boxes_cleans_splitter_faces(self): """Test union of face-touching boxes follows CadQuery-style clean behavior.""" @@ -642,7 +647,7 @@ class TestComplexExamples(unittest.TestCase): tooth = scad.extrude_rsolid(tooth_profile, (0, 0, 1), 1.2) # 合并一个齿到基础上 - gear = scad.union_rsolid([gear_base, tooth]) + gear = scad.union_rsolid([gear_base, tooth], glue=False) # 验证 self.assertIsInstance(gear, scad.Solid) diff --git a/SimpleCADAPI/test/test_auto_docs_gen.py b/SimpleCADAPI/test/test_auto_docs_gen.py index a38f034..3cd0c96 100644 --- a/SimpleCADAPI/test/test_auto_docs_gen.py +++ b/SimpleCADAPI/test/test_auto_docs_gen.py @@ -101,6 +101,10 @@ class TestAutoDocsGenPathResolution(unittest.TestCase): with tempfile.TemporaryDirectory() as tmp_dir: package_root = Path(tmp_dir) / "src/simplecadapi" package_root.mkdir(parents=True, exist_ok=True) + backend_root = package_root / "translator/freecad_translator" + backend_root.mkdir(parents=True) + (backend_root / "api.py").write_text("", encoding="utf-8") + (backend_root / "translator.py").write_text("", encoding="utf-8") resolved = auto_docs_gen._default_source_files(package_root) @@ -108,11 +112,12 @@ class TestAutoDocsGenPathResolution(unittest.TestCase): self.assertIn("serializer.py", resolved_names) self.assertIn("graph.py", resolved_names) self.assertIn("expr.py", resolved_names) + self.assertIn("tolerance.py", resolved_names) self.assertIn("sketch.py", resolved_names) self.assertIn("math.py", resolved_names) self.assertIn("translator/freecad_translator/api.py", resolved_names) self.assertIn( - "translator/freecad_translator/script_translator.py", + "translator/freecad_translator/translator.py", resolved_names, ) @@ -295,6 +300,41 @@ def fit_cubic_bspline_control_points(sample_points, *, tolerance=1e-3): readme, ) + def test_generate_markdown_includes_physical_units_category(self): + with tempfile.TemporaryDirectory() as tmp_dir: + tmp_path = Path(tmp_dir) + init_file = tmp_path / "__init__.py" + init_file.write_text( + "__all__ = ['Dimension', 'convert_value']\n", encoding="utf-8" + ) + source_file = tmp_path / "units.py" + source_file.write_text( + ''' +class Dimension: + """Physical dimension.""" + + +def convert_value(value, from_unit, to_unit): + """Convert compatible units.""" + return value +'''.strip() + + "\n", + encoding="utf-8", + ) + output_dir = tmp_path / "docs/api" + + generator = auto_docs_gen.APIDocumentGenerator( + source_files=[source_file], output_dirs=[output_dir], quiet=True + ) + generator.extract_apis() + generator.generate_markdown_docs() + + readme = (output_dir / "README.md").read_text(encoding="utf-8") + + self.assertIn("## Physical Units", readme) + self.assertIn("[Dimension](Dimension.md)", readme) + self.assertIn("[convert_value](convert_value.md)", readme) + def test_generate_stdlib_markdown_uses_stdlib_index_and_import_surface(self): with tempfile.TemporaryDirectory() as tmp_dir: tmp_path = Path(tmp_dir) diff --git a/SimpleCADAPI/test/test_example_model_contract.py b/SimpleCADAPI/test/test_example_model_contract.py new file mode 100644 index 0000000..bfdb2cc --- /dev/null +++ b/SimpleCADAPI/test/test_example_model_contract.py @@ -0,0 +1,108 @@ +"""Structural checks for the example model/session contract.""" + +import ast +import importlib.util +from pathlib import Path +import unittest + + +ROOT = Path(__file__).resolve().parents[1] +EXAMPLES = ROOT / "examples" + + +def _model_files() -> tuple[Path, ...]: + return ( + EXAMPLES / "04_dimension_tolerance_chain.py", + EXAMPLES / "08_constrained_sketch.py", + EXAMPLES / "09_naca0016_blade_freecad.py", + EXAMPLES / "10_part_assembly.py", + EXAMPLES / "16_compact_two_stage_planetary_reducer" / "main.py", + EXAMPLES / "20_integrated_bldc_joint_actuator" / "main.py", + ) + + +def _source_files() -> tuple[Path, ...]: + return tuple( + path + for path in EXAMPLES.rglob("*.py") + if "out" not in path.relative_to(EXAMPLES).parts + ) + + +def _is_decorator(node: ast.expr, name: str) -> bool: + if isinstance(node, ast.Call): + node = node.func + return ( + isinstance(node, ast.Attribute) + and isinstance(node.value, ast.Name) + and node.value.id == "scad" + and node.attr == name + ) or (isinstance(node, ast.Name) and node.id == name) + + +class TestExampleModelContract(unittest.TestCase): + def test_example_10_output_dir_is_anchored_to_the_example_file(self): + path = EXAMPLES / "10_part_assembly.py" + spec = importlib.util.spec_from_file_location("example_10_path_contract", path) + self.assertIsNotNone(spec) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + + self.assertTrue(module.OUT_DIR.is_absolute()) + self.assertEqual( + module.OUT_DIR, + EXAMPLES / "out" / "hydraulic_rod_assembly", + ) + + def test_each_current_model_entry_has_one_explicit_result(self): + for path in _model_files(): + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + model_functions = [ + node + for node in tree.body + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) + and any(_is_decorator(decorator, "model") for decorator in node.decorator_list) + ] + self.assertEqual(len(model_functions), 1, path) + self.assertFalse( + isinstance(model_functions[0], ast.AsyncFunctionDef), + f"async model entry is unsupported: {path}", + ) + capture_calls = [ + node + for node in ast.walk(tree) + if isinstance(node, ast.Call) + and isinstance(node.func, ast.Attribute) + and isinstance(node.func.value, ast.Name) + and node.func.value.id == "scad" + and node.func.attr == "capture_result" + ] + self.assertEqual(len(capture_calls), 1, path) + + def test_examples_do_not_own_manual_sessions_or_serialization(self): + for path in _source_files(): + source = path.read_text(encoding="utf-8") + self.assertNotIn("GraphSession(", source, path) + self.assertNotIn("export_model_json(", source, path) + self.assertNotIn("export_session_json(", source, path) + + def test_make_builders_require_the_active_session(self): + for path in _source_files(): + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + for node in ast.walk(tree): + if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + continue + if not (node.name.startswith("make_") or node.name.startswith("_make_")): + continue + decorators = node.decorator_list + has_session_contract = any( + _is_decorator(decorator, "requires_session") + or _is_decorator(decorator, "model") + for decorator in decorators + ) + self.assertTrue(has_session_contract, f"{path}:{node.lineno}:{node.name}") + + +if __name__ == "__main__": + unittest.main() diff --git a/SimpleCADAPI/test/test_freecad_translator.py b/SimpleCADAPI/test/test_freecad_translator.py index 07481b9..3cc407d 100644 --- a/SimpleCADAPI/test/test_freecad_translator.py +++ b/SimpleCADAPI/test/test_freecad_translator.py @@ -2,6 +2,7 @@ from __future__ import annotations +import hashlib import json import importlib import math @@ -18,6 +19,7 @@ import xml.etree.ElementTree as ET import simplecadapi as scad from simplecadapi import ql from simplecadapi.graph import GraphSession +from simplecadapi.kernel.ocp_properties import bounding_box from simplecadapi.topology import OperationGraph from simplecadapi.translator import freecad_translator @@ -202,14 +204,266 @@ class TestFreeCADTranslator(unittest.TestCase): ) self.assertIn("import FreeCAD as App", script) - self.assertIn("Sketcher::SketchObject", script) - self.assertIn("Part::Extrusion", script) + self.assertIn("Part::Box", script) self.assertIn("_register_graph_folded_alias", script) self.assertNotIn("doc.addObject('App::Link'", script) self.assertIn("SimpleCADNodeId", script) self.assertIn("EXPRESSION_GRAPH_META", script) self.assertIn("# Step", script) + def test_translate_model_json_emits_tag_selection_link_with_binding_metadata(self): + with GraphSession() as session: + box = scad.make_box_rsolid(width=2.0, height=3.0, depth=4.0) + scad.apply_tag_rselection( + scope=box, + targets=[box], + tag="role.semantic_view", + ) + + script = freecad_translator.translate_model_json_to_freecad_script( + scad.export_model_json(session) + ) + + self.assertIn("# Step", script) + self.assertIn("apply_tag_rselection", script) + self.assertIn("_register_tag_selection_node", script) + self.assertIn("SimpleCADTagBinding", script) + + def test_translate_tagged_cylinder_preserves_topology_identity_bindings(self): + with GraphSession() as session: + scad.make_cylinder_rsolid( + radius=2.0, + height=5.0, + tag_prefix="shaft", + ) + + script = freecad_translator.translate_model_json_to_freecad_script( + scad.export_model_json(session) + ) + + self.assertIn("Part::Cylinder", script) + self.assertIn("_register_tag_selection_node", script) + self.assertIn("shaft.face.start", script) + self.assertIn("topology_name", script) + + def test_translate_tagged_box_preserves_topology_identity_bindings(self): + with GraphSession() as session: + scad.make_box_rsolid( + width=2.0, + height=3.0, + depth=4.0, + tag_prefix="housing", + top_face_tag="role.lid_mount", + ) + + script = freecad_translator.translate_model_json_to_freecad_script( + scad.export_model_json(session) + ) + + self.assertIn("Part::Box", script) + self.assertIn("_register_tag_selection_node", script) + self.assertIn("housing.face.top", script) + self.assertIn("role.lid_mount", script) + self.assertIn("topology_name", script) + self.assertIn("operation_output_role", script) + + def test_translate_tagged_cone_preserves_topology_identity_bindings(self): + with GraphSession() as session: + scad.make_cone_rsolid( + bottom_radius=2.0, + height=4.0, + top_radius=1.0, + tag_prefix="adapter", + end_face_tag="role.outlet", + ) + + script = freecad_translator.translate_model_json_to_freecad_script( + scad.export_model_json(session) + ) + + self.assertIn("Part::Cone", script) + self.assertIn("_register_tag_selection_node", script) + self.assertIn("adapter.face.end", script) + self.assertIn("adapter.edge.seam", script) + self.assertIn("role.outlet", script) + self.assertIn("topology_name", script) + self.assertIn("operation_output_role", script) + + def test_translate_constrained_sketch_preserves_topology_tag_contract(self): + with GraphSession() as session: + sketch = scad.make_sketch_rsketch(name="rect") + for point_id, x, y in ( + ("p0", 0.0, 0.0), + ("p1", 2.0, 0.0), + ("p2", 2.0, 1.0), + ("p3", 0.0, 1.0), + ): + sketch = scad.add_point_rsketch(sketch, point_id, x, y) + for entity_id, start, end in ( + ("bottom", "p0", "p1"), + ("right", "p1", "p2"), + ("top", "p2", "p3"), + ("left", "p3", "p0"), + ): + sketch = scad.add_line_rsketch(sketch, entity_id, start, end) + scad.make_face_from_sketch_rface(sketch) + + model_json = scad.export_model_json(session) + script = freecad_translator.translate_model_json_to_freecad_script( + model_json + ) + + self.assertIn("SimpleCADSketchPromotion", script) + self.assertIn("sketch_entity.bottom", script) + self.assertIn("topology_name", script) + self.assertIn("profile_id", script) + + def test_translate_model_json_tag_branches_are_distinct_fcstd_links(self): + with GraphSession() as session: + box = scad.make_box_rsolid(width=2.0, height=3.0, depth=4.0) + scad.apply_tag_rselection( + scope=box, + targets=[box], + tag="role.left_branch", + ) + scad.apply_tag_rselection( + scope=box, + targets=[box], + tag="role.right_branch", + ) + + model_json = scad.export_model_json(session) + expected_bindings = sorted( + json.loads(model_json)["semantic_bindings"], + key=lambda binding: binding["tag"], + ) + probe = """ +import json +import FreeCAD as App + +doc = App.openDocument(FCSTD_PATH) +links = sorted( + [ + obj + for obj in doc.Objects + if getattr(obj, 'SimpleCADOp', '') == 'apply_tag_rselection' + ], + key=lambda obj: json.loads(obj.SimpleCADTagBinding)['tag'], +) +with open(OUT_PATH, 'w', encoding='utf-8') as fh: + json.dump({ + 'names': [obj.Name for obj in links], + 'type_ids': [obj.TypeId for obj in links], + 'linked_names': [obj.LinkedObject.Name for obj in links], + 'volumes': [round(float(obj.Shape.Volume), 6) for obj in links], + 'bindings': [json.loads(obj.SimpleCADTagBinding) for obj in links], + 'node_ids': [obj.SimpleCADNodeId for obj in links], + }, fh) +""" + result = self._inspect_fcstd_json(model_json, probe) + + self.assertEqual(result["type_ids"], ["App::Link", "App::Link"]) + self.assertEqual(len(set(result["names"])), 2) + self.assertEqual(len(set(result["linked_names"])), 1) + self.assertTrue(set(result["names"]).isdisjoint(result["linked_names"])) + self.assertEqual(result["volumes"], [24.0, 24.0]) + self.assertEqual(result["bindings"], expected_bindings) + self.assertEqual(len(set(result["node_ids"])), 2) + + def test_translate_model_json_tagged_parts_preserve_nested_placements(self): + with GraphSession() as session: + lower_body = scad.make_box_rsolid( + width=2.0, + height=4.0, + depth=1.0, + bottom_face_center=(0.0, 0.0, 0.0), + ) + upper_body = scad.make_box_rsolid( + width=2.0, + height=4.0, + depth=1.0, + bottom_face_center=(0.0, 0.0, 10.0), + ) + lower_body = scad.apply_tag(shape=lower_body, tag="role.structure") + upper_body = scad.apply_tag(shape=upper_body, tag="role.structure") + upper_body = scad.apply_tag(shape=upper_body, tag="role.upper") + lower = scad.make_part_rpart(part_id="lower", body=lower_body) + upper = scad.make_part_rpart(part_id="upper", body=upper_body) + child = scad.make_assembly_rassembly(assembly_id="child") + child = scad.add_component_rassembly( + assembly=child, + item=lower, + component_id="lower", + placement=scad.identity_placement_rplacement(), + ) + child = scad.add_component_rassembly( + assembly=child, + item=upper, + component_id="upper", + placement=scad.identity_placement_rplacement(), + ) + root = scad.make_assembly_rassembly(assembly_id="root") + root = scad.add_component_rassembly( + assembly=root, + item=child, + component_id="child", + placement=scad.make_placement_rplacement( + origin=(20.0, 30.0, 5.0), + x_axis=(0.0, 1.0, 0.0), + y_axis=(-1.0, 0.0, 0.0), + ), + ) + scad.make_compound_from_assembly_rcompound(assembly=root) + + probe = """ +import json +import FreeCAD as App + +doc = App.openDocument(FCSTD_PATH) +tag_links = [obj for obj in doc.Objects if getattr(obj, 'SimpleCADOp', '') == 'apply_tag_rselection'] +upper_part = next(obj for obj in doc.Objects if getattr(obj, 'SimpleCADPartId', '') == 'upper') +upper_body = next(obj for obj in upper_part.Group if hasattr(obj, 'SimpleCADSourceBodyNodeId')) +child_link = next(obj for obj in doc.Objects if getattr(obj, 'SimpleCADComponentId', '') == 'child') +compound = next(obj for obj in doc.Objects if getattr(obj, 'SimpleCADOp', '') == 'make_compound_from_assembly_rcompound') + +def bbox(shape): + bounds = shape.BoundBox + return [ + round(float(value), 3) + for value in ( + bounds.XMin, + bounds.YMin, + bounds.ZMin, + bounds.XMax, + bounds.YMax, + bounds.ZMax, + ) + ] + +with open(OUT_PATH, 'w', encoding='utf-8') as fh: + json.dump({ + 'tag_link_transforms': [bool(obj.LinkTransform) for obj in tag_links], + 'upper_body_bbox': bbox(upper_body.Shape), + 'child_bbox': bbox(child_link.Shape), + 'child_solids': len(child_link.Shape.Solids), + 'child_volume': round(float(child_link.Shape.Volume), 3), + 'compound_bbox': bbox(compound.Shape), + 'compound_solids': len(compound.Shape.Solids), + 'compound_volume': round(float(compound.Shape.Volume), 3), + }, fh) +""" + result = self._inspect_fcstd_json(scad.export_model_json(session=session), probe) + + self.assertTrue(result["tag_link_transforms"]) + self.assertTrue(all(result["tag_link_transforms"])) + self.assertEqual(result["upper_body_bbox"], [-1.0, -2.0, 10.0, 1.0, 2.0, 11.0]) + self.assertEqual(result["child_bbox"], [18.0, 29.0, 5.0, 22.0, 31.0, 16.0]) + self.assertEqual(result["child_solids"], 2) + self.assertEqual(result["child_volume"], 16.0) + self.assertEqual(result["compound_bbox"], result["child_bbox"]) + self.assertEqual(result["compound_solids"], 2) + self.assertEqual(result["compound_volume"], 16.0) + def test_translate_model_json_preserves_part_assembly_semantics_in_script(self): with GraphSession() as session: body = scad.make_box_rsolid(2.0, 3.0, 1.0) @@ -1002,6 +1256,10 @@ subassembly_links = [obj for obj in doc.Objects if obj.TypeId == 'Assembly::Asse with open(OUT_PATH, 'w', encoding='utf-8') as fh: json.dump({ 'assembly_ids': sorted(obj.SimpleCADAssemblyId for obj in assembly_objs), + 'assembly_visibility': { + obj.SimpleCADAssemblyId: bool(obj.Visibility) + for obj in assembly_objs + }, 'subassembly_component_ids': [obj.SimpleCADComponentId for obj in subassembly_links], 'subassembly_linked_type_ids': [obj.LinkedObject.TypeId for obj in subassembly_links], 'subassembly_x': [round(obj.Placement.Base.x, 3) for obj in subassembly_links], @@ -1012,6 +1270,10 @@ with open(OUT_PATH, 'w', encoding='utf-8') as fh: result = self._inspect_fcstd_json(payload, probe) self.assertEqual(result["assembly_ids"], ["child", "root"]) + self.assertEqual( + result["assembly_visibility"], + {"child": False, "root": True}, + ) self.assertEqual(result["subassembly_component_ids"], ["child_1"]) self.assertEqual(result["subassembly_linked_type_ids"], ["Assembly::AssemblyObject"]) self.assertEqual(result["subassembly_x"], [4.0]) @@ -1080,18 +1342,50 @@ links = [ if obj.TypeId == 'Assembly::AssemblyLink' and getattr(obj, 'SimpleCADComponentId', '') == 'child_1' ] -joints = [ - obj for obj in doc.Objects - if hasattr(obj, 'SimpleCADConstraint') - and json.loads(obj.SimpleCADConstraint).get('constraint_id') == 'mount_output' -] all_joints = [obj for obj in doc.Objects if hasattr(obj, 'SimpleCADConstraint')] grounds = [obj for obj in doc.Objects if hasattr(obj, 'SimpleCADGroundedComponent')] +assembly_by_id = { + str(obj.SimpleCADAssemblyId): obj + for obj in doc.Objects + if obj.TypeId == 'Assembly::AssemblyObject' + and hasattr(obj, 'SimpleCADAssemblyId') +} + +def constraint_payload(obj): + return json.loads(obj.SimpleCADConstraint) + +def reference_name(reference): + if not reference: + return '' + target = reference[0] + return str(getattr(target, 'SimpleCADComponentId', '') or getattr(target, 'Name', '')) + +child_assembly = assembly_by_id['child'] +child_joint_group = next( + obj for obj in list(getattr(child_assembly, 'OutList', []) or []) + if getattr(obj, 'TypeId', '') == 'Assembly::JointGroup' +) +child_joints = [ + obj for obj in list(getattr(child_joint_group, 'Group', []) or []) + if hasattr(obj, 'SimpleCADConstraint') +] +pivot = next(obj for obj in child_joints if constraint_payload(obj).get('constraint_id') == 'pivot') +mount = next(obj for obj in all_joints if constraint_payload(obj).get('constraint_id') == 'mount_output') +child_joint_ids = {constraint_payload(obj).get('constraint_id') for obj in child_joints} with open(OUT_PATH, 'w', encoding='utf-8') as fh: json.dump({ 'count': len(links), 'rigid': [bool(obj.Rigid) for obj in links], - 'output_reference_type': str(joints[0].Reference2[0].TypeId), + 'output_reference_type': str(mount.Reference2[0].TypeId), + 'child_joint_ids': [constraint_payload(obj).get('constraint_id') for obj in child_joints], + 'child_joint_statuses': [str(getattr(obj, 'SimpleCADConstraintTranslationStatus', '')) for obj in child_joints], + 'pivot_reference_names': [reference_name(pivot.Reference1), reference_name(pivot.Reference2)], + 'pivot_status': str(getattr(pivot, 'SimpleCADConstraintTranslationStatus', '')), + 'root_joint_ids': [ + constraint_payload(obj).get('constraint_id') + for obj in all_joints + if constraint_payload(obj).get('constraint_id') not in child_joint_ids + ], 'joint_visibility': [bool(obj.Visibility) for obj in all_joints], 'ground_visibility': [bool(obj.Visibility) for obj in grounds], }, fh) @@ -1101,6 +1395,11 @@ with open(OUT_PATH, 'w', encoding='utf-8') as fh: self.assertEqual(result["count"], 1) self.assertEqual(result["rigid"], [False]) self.assertEqual(result["output_reference_type"], "App::Link") + self.assertEqual(result["child_joint_ids"], ["pivot"]) + self.assertEqual(result["child_joint_statuses"], ["native_equivalent"]) + self.assertEqual(result["pivot_reference_names"], ["base", "arm"]) + self.assertEqual(result["pivot_status"], "native_equivalent") + self.assertEqual(result["root_joint_ids"], ["mount_output"]) self.assertTrue(result["joint_visibility"]) self.assertTrue(all(visible is False for visible in result["joint_visibility"])) self.assertTrue(result["ground_visibility"]) @@ -1416,6 +1715,139 @@ with open(OUT_PATH, 'w', encoding='utf-8') as fh: self.assertIn("var_fold_ty", normalized_expr_map["Placement.Base.y"]) self.assertIn("var_fold_tz", normalized_expr_map["Placement.Base.z"]) + def test_expression_circle_normal_uses_extrusion_profile_fcstd(self): + normal_y = scad.var("circle_normal_y", 1.0) + with GraphSession() as session: + profile = scad.make_circle_rface( + (0.0, 0.0, 0.0), + 1.0, + normal=(0.0, normal_y, 0.0), + ) + scad.extrude_rsolid(profile, (0.0, 1.0, 0.0), 2.0) + payload = scad.export_model_json(session) + script = freecad_translator.translate_model_json_to_freecad_script(payload) + + self.assertNotIn("doc.addObject('Part::Cylinder'", script) + self.assertIn("doc.addObject('Sketcher::SketchObject'", script) + self.assertIn("doc.addObject('Part::Extrusion'", script) + + probe = """ +import json +import FreeCAD as App + +doc = App.openDocument(FCSTD_PATH) +extrusions = [obj for obj in doc.Objects if getattr(obj, 'SimpleCADOp', '') == 'make_extrude_rsolid'] +cylinders = [obj for obj in doc.Objects if obj.TypeId == 'Part::Cylinder'] +extrusion = extrusions[0] +shape = extrusion.Shape +sketches = [obj for obj in doc.Objects if obj.TypeId == 'Sketcher::SketchObject'] +with open(OUT_PATH, 'w', encoding='utf-8') as fh: + json.dump({ + 'extrusion_count': len(extrusions), + 'cylinder_count': len(cylinders), + 'solid_count': 0 if shape.isNull() else len(shape.Solids), + 'volume': 0.0 if shape.isNull() else float(shape.Volume), + 'expr_support': [getattr(obj, 'SimpleCADExprSupport', '') for obj in sketches], + 'expr_limitations': [getattr(obj, 'SimpleCADExprLimitation', '') for obj in sketches], + }, fh) +""" + result = self._inspect_fcstd_json(payload, probe) + + self.assertEqual(result["extrusion_count"], 1) + self.assertEqual(result["cylinder_count"], 0) + self.assertEqual(result["solid_count"], 1) + self.assertAlmostEqual(result["volume"], 2.0 * 3.141592653589793, places=5) + self.assertIn("limited", result["expr_support"]) + self.assertTrue( + any("orientation" in value.lower() for value in result["expr_limitations"]) + ) + + def test_oblique_circle_extrusion_preserves_source_geometry_fcstd(self): + with GraphSession() as session: + profile = scad.make_circle_rface((0.0, 0.0, 0.0), 1.0) + source = scad.extrude_rsolid(profile, (1.0, 0.0, 1.0), 2.0) + payload = scad.export_model_json(session) + script = freecad_translator.translate_model_json_to_freecad_script(payload) + + self.assertNotIn("doc.addObject('Part::Cylinder'", script) + self.assertIn("doc.addObject('Part::Extrusion'", script) + + source_box = bounding_box(source.wrapped) + source_bounds = [ + source_box.xmin, + source_box.ymin, + source_box.zmin, + source_box.xmax, + source_box.ymax, + source_box.zmax, + ] + probe = """ +import json +import FreeCAD as App + +doc = App.openDocument(FCSTD_PATH) +extrusion = next(obj for obj in doc.Objects if getattr(obj, 'SimpleCADOp', '') == 'make_extrude_rsolid') +shape = extrusion.Shape +box = shape.BoundBox +with open(OUT_PATH, 'w', encoding='utf-8') as fh: + json.dump({ + 'type_id': extrusion.TypeId, + 'volume': float(shape.Volume), + 'bbox': [ + float(box.XMin), float(box.YMin), float(box.ZMin), + float(box.XMax), float(box.YMax), float(box.ZMax), + ], + }, fh) +""" + result = self._inspect_fcstd_json(payload, probe) + + self.assertEqual(result["type_id"], "Part::Extrusion") + self.assertAlmostEqual(result["volume"], source.get_volume(), places=5) + for actual, expected in zip(result["bbox"], source_bounds): + self.assertAlmostEqual(actual, expected, places=5) + + def test_transform_links_compose_non_identity_source_placement_fcstd(self): + with GraphSession() as session: + source = scad.make_box_rsolid( + 1.0, + 1.0, + 1.0, + bottom_face_center=(2.0, 0.0, 0.0), + ) + scad.translate_shape(source, (3.0, 0.0, 0.0)) + scad.translate_shape(source, (0.0, 3.0, 0.0)) + payload = scad.export_model_json(session) + probe = """ +import json +import FreeCAD as App + +doc = App.openDocument(FCSTD_PATH) +links = [obj for obj in doc.Objects if getattr(obj, 'SimpleCADOp', '') == 'make_translate_rshape'] + +def bbox(shape): + box = shape.BoundBox + return [ + float(box.XMin), float(box.YMin), float(box.ZMin), + float(box.XMax), float(box.YMax), float(box.ZMax), + ] + +with open(OUT_PATH, 'w', encoding='utf-8') as fh: + json.dump({ + 'link_transforms': [bool(link.LinkTransform) for link in links], + 'bounds': [bbox(link.Shape) for link in links], + }, fh) +""" + result = self._inspect_fcstd_json(payload, probe) + + self.assertEqual(result["link_transforms"], [True, True]) + self.assertEqual( + result["bounds"], + [ + [4.5, -0.5, 0.0, 5.5, 0.5, 1.0], + [1.5, 2.5, 0.0, 2.5, 3.5, 1.0], + ], + ) + def test_translate_model_json_keeps_link_when_translate_input_is_shared(self): with GraphSession() as session: profile = scad.make_circle_rface((0.0, 0.0, 0.0), 1.0) @@ -1456,8 +1888,9 @@ with open(OUT_PATH, 'w', encoding='utf-8') as fh: scad.export_model_json(session) ) - self.assertIn("Sketcher::SketchObject", script) - self.assertIn("Part::Extrusion", script) + self.assertIn("Part::Box", script) + self.assertIn("RESULT_NODE_IDS", script) + self.assertNotIn("evaluated_graph", script) def test_translate_model_json_requires_graph(self): payload = { @@ -1497,6 +1930,93 @@ with open(OUT_PATH, 'w', encoding='utf-8') as fh: "=<>.var_r * <>.const_", script ) + def test_translate_model_json_preserves_dimension_tolerances(self): + width = scad.var("width", 10.0, tolerance=(-0.1, 0.2)) + with GraphSession() as session: + scad.make_box_rsolid(width, 2.0, 1.0) + session.require_tolerance(width * 2.0, (-0.2, 0.4), name="overall") + + script = freecad_translator.translate_model_json_to_freecad_script( + scad.export_model_json(session) + ) + + self.assertIn("TOLERANCE_GRAPH =", script) + self.assertIn("simplecad_tolerance_graph", script) + self.assertIn("overall", script) + self.assertIn("expr_sheet.set('E1', \"-0.1\")", script) + self.assertIn("expr_sheet.set('F1', \"0.2\")", script) + + def test_translate_model_json_preserves_units_and_uses_canonical_values(self): + width = scad.var( + "width", 1.0, unit="in", tolerance=0.1, tolerance_unit="mm" + ) + angle = scad.var("angle", math.pi / 2.0, unit="rad", tolerance=0.5) + with GraphSession() as session: + scad.make_box_rsolid(width, 2.0, 1.0) + scad.rotate_shape( + scad.make_box_rsolid(1.0, 1.0, 1.0), + angle, + axis=(0.0, 0.0, 1.0), + origin=(0.0, 0.0, 0.0), + ) + session.require_tolerance(width, 0.004, tolerance_unit="in") + + script = freecad_translator.translate_model_json_to_freecad_script( + scad.export_model_json(session) + ) + + self.assertIn('expr_sheet.set("B1", "25.4")', script) + self.assertIn("expr_sheet.set('G1', \"in\")", script) + self.assertIn("expr_sheet.set('H1', \"mm\")", script) + self.assertIn("expr_sheet.set('I1', \"Length\")", script) + self.assertIn("expr_sheet.set('G2', \"rad\")", script) + self.assertIn("expr_sheet.set('H2', \"rad\")", script) + self.assertIn("expr_sheet.set('I2', \"Angle\")", script) + self.assertIn("'tolerance_unit': 'in'", script) + self.assertIn("'target_dimension': {'angle': 0, 'length': 1}", script) + + def test_translate_model_json_disambiguates_same_name_variable_aliases(self): + first = scad.var("width", 10.0) + second = scad.var("width", 20.0) + expression = first + second + with GraphSession() as session: + scad.make_box_rsolid(expression, 2.0, 1.0) + + script = freecad_translator.translate_model_json_to_freecad_script( + scad.export_model_json(session) + ) + suffix = hashlib.sha256(second.expr_id.encode("utf-8")).hexdigest()[:8] + second_alias = f"var_width_{suffix}" + + self.assertIn('setAlias("B1", "var_width")', script) + self.assertIn(f'setAlias("B2", "{second_alias}")', script) + self.assertIn(f"<>.{second_alias}", script) + + def test_translate_model_json_uses_ascii_bounded_unique_aliases(self): + variables = [ + scad.var("宽度" + "x" * 80, float(index + 1)) + for index in range(3) + ] + with GraphSession() as session: + scad.make_box_rsolid( + variables[0] + variables[1] + variables[2], 2.0, 1.0 + ) + + script = freecad_translator.translate_model_json_to_freecad_script( + scad.export_model_json(session) + ) + aliases = [] + for line in script.splitlines(): + marker = "expr_sheet.setAlias(" + if marker not in line: + continue + arguments = line.split("(", 1)[1].rsplit(")", 1)[0] + aliases.append(json.loads("[" + arguments + "]")[1]) + + self.assertEqual(len(aliases), len(set(aliases))) + self.assertTrue(all(alias.isascii() for alias in aliases)) + self.assertTrue(all(len(alias) <= 64 for alias in aliases)) + def test_translate_model_json_uses_semantic_spreadsheet_aliases_and_formulas(self): x = scad.var("hub_radius", 6.5, comment="Hub outer radius") expr = x + 2.0 @@ -1660,6 +2180,38 @@ with open(OUT_PATH, 'w', encoding='utf-8') as fh: self.assertIn(".Angle = float(", script) self.assertIn("'Angle'", script) + def test_wire_revolve_keeps_native_source_dependency_fcstd(self): + with GraphSession() as session: + profile = scad.make_rectangle_rwire( + 1.0, + 1.0, + center=(1.5, 0.0, 0.0), + normal=(0.0, 1.0, 0.0), + ) + scad.revolve_rsolid(profile) + payload = scad.export_model_json(session) + script = freecad_translator.translate_model_json_to_freecad_script(payload) + + self.assertNotIn("make_revolve_rsolid_profile", script) + probe = """ +import json +import FreeCAD as App + +doc = App.openDocument(FCSTD_PATH) +revolution = next(obj for obj in doc.Objects if getattr(obj, 'SimpleCADOp', '') == 'make_revolve_rsolid') +with open(OUT_PATH, 'w', encoding='utf-8') as fh: + json.dump({ + 'source_op': getattr(revolution.Source, 'SimpleCADOp', ''), + 'solid_count': len(revolution.Shape.Solids), + 'volume': float(revolution.Shape.Volume), + }, fh) +""" + result = self._inspect_fcstd_json(payload, probe) + + self.assertEqual(result["source_op"], "make_wire_from_edges_rwire") + self.assertEqual(result["solid_count"], 1) + self.assertGreater(result["volume"], 0.0) + def test_translate_model_json_uses_single_graph_sweep_helper(self): with GraphSession() as session: profile = scad.make_circle_rface((0.0, 0.0, 0.0), 0.5) @@ -1680,6 +2232,72 @@ with open(OUT_PATH, 'w', encoding='utf-8') as fh: self.assertIn(".Frenet = bool(", script) self.assertIn("'Frenet'", script) + def test_translate_model_json_emulates_twisted_sweep_with_solid_loft(self): + with GraphSession() as session: + profile = scad.make_rectangle_rface( + width=2.0, + height=1.0, + center=(0.0, 0.0, 0.0), + ) + scad.twisted_sweep_rsolid( + profile=profile, + distance=4.0, + twist_angle=45.0, + ) + + script = freecad_translator.translate_model_json_to_freecad_script( + scad.export_model_json(session) + ) + + self.assertIn("_twisted_sweep_loft_shape", script) + self.assertIn("Part.makeLoft(sections, True, False, False, 5)", script) + self.assertIn("SimpleCADTranslationSupport", script) + self.assertIn("'emulated'", script) + self.assertIn("approximates the continuous SimpleCAD sweep", script) + + def test_translate_model_json_twisted_sweep_emulation_fcstd_valid(self): + with GraphSession() as session: + profile = scad.make_rectangle_rface( + width=2.0, + height=1.0, + center=(0.0, 0.0, 0.0), + ) + scad.twisted_sweep_rsolid( + profile=profile, + distance=4.0, + twist_angle=45.0, + ) + + probe = """ +import json +import FreeCAD as App + +doc = App.openDocument(FCSTD_PATH) +objects = [obj for obj in doc.Objects if getattr(obj, 'SimpleCADOp', '') == 'make_twisted_sweep_rsolid'] +obj = objects[-1] +shape = obj.Shape +note = doc.getObject('simplecad_translation_limitations') +with open(OUT_PATH, 'w', encoding='utf-8') as fh: + json.dump({ + 'count': len(objects), + 'support': getattr(obj, 'SimpleCADTranslationSupport', ''), + 'limitation': getattr(obj, 'SimpleCADTranslationLimitation', ''), + 'valid': shape.isValid(), + 'solid_count': len(shape.Solids), + 'volume': float(shape.Volume), + 'note_payload': getattr(note, 'Payload', '') if note is not None else '', + }, fh) +""" + result = self._inspect_fcstd_json(scad.export_model_json(session), probe) + + self.assertEqual(result["count"], 1) + self.assertEqual(result["support"], "emulated") + self.assertIn("smooth solid loft", result["limitation"]) + self.assertTrue(result["valid"]) + self.assertEqual(result["solid_count"], 1) + self.assertGreater(result["volume"], 0.0) + self.assertIn("make_twisted_sweep_rsolid", result["note_payload"]) + def test_translate_model_json_materializes_ql_selected_face_profile_for_sweep(self): with GraphSession() as session: base = scad.make_circle_rface((0.0, 0.0, 0.0), 0.25) @@ -1887,9 +2505,9 @@ import FreeCAD as App import Part doc = App.openDocument(FCSTD_PATH) -target = max( - (obj for obj in doc.Objects if getattr(obj, 'TypeId', '') == 'Sketcher::SketchObject'), - key=lambda obj: len(list(getattr(obj, 'Geometry', []))), +target = next( + obj for obj in doc.Objects + if getattr(obj, 'SimpleCADOp', '') == 'make_wire_from_edges_rwire' ) shape = target.Shape wire = shape.Wires[0] @@ -2102,12 +2720,17 @@ with open(OUT_PATH, 'w', encoding='utf-8') as fh: def test_translate_model_json_binds_feature_properties_to_freecad_expressions(self): with GraphSession() as session: helix = scad.make_helix_rwire(1.0, 3.0, 2.0) - face = scad.make_circle_rface((0.0, 0.0, 0.0), 1.0) - scad.extrude_rsolid(face, (0.0, 0.0, 1.0), 2.0) + extrude_face = scad.make_rectangle_rface(width=2.0, height=1.0) + scad.extrude_rsolid( + profile=extrude_face, + direction=(0.0, 0.0, 1.0), + distance=2.0, + ) rev_wire = scad.make_rectangle_rwire(1.0, 2.0, center=(2.0, 0.0, 0.0)) rev_face = scad.make_face_from_wire_rface(rev_wire) scad.revolve_rsolid(rev_face, axis=(0.0, 0.0, 1.0), angle=180.0) - scad.sweep_rsolid(face, helix, is_frenet=True) + sweep_face = scad.make_circle_rface((0.0, 0.0, 0.0), 1.0) + scad.sweep_rsolid(sweep_face, helix, is_frenet=True) box = scad.make_box_rsolid(2.0, 2.0, 2.0) scad.shell_rsolid(box, [box.get_faces(0)], 0.25) @@ -2828,35 +3451,6 @@ with open(OUT_PATH, 'w', encoding='utf-8') as fh: payload_obj["spline_expr"]["reason"], ) - def test_single_gear_model_has_single_leaf_after_parametric_build(self): - with tempfile.TemporaryDirectory() as tmpdir: - output_dir = Path(tmpdir) - subprocess.run( - [ - "uv", - "run", - "python", - "examples/06_parametric_gear_model.py", - "--output-dir", - str(output_dir), - ], - check=True, - text=True, - capture_output=True, - ) - payload = json.loads( - (output_dir / "parametric_gear.model.json").read_text( - encoding="utf-8" - ) - ) - self.assertEqual(len(payload["leaf_ids"]), 1) - var_names = [ - node.get("name") - for node in payload["expression_graph"]["nodes"] - if node.get("kind") == "var" - ] - self.assertNotIn("pitch_radius", var_names) - def test_naca0016_blade_example_translates_bspline_sections_to_fcstd(self): freecad_cmd = self._discover_freecadcmd() if not freecad_cmd: @@ -2892,25 +3486,34 @@ import json import FreeCAD as App doc = App.openDocument(FCSTD_PATH) -sketches = [obj for obj in doc.Objects if getattr(obj, 'TypeId', '') == 'Sketcher::SketchObject'] +sections = [ + obj for obj in doc.Objects + if getattr(obj, 'SimpleCADOp', '') == 'make_wire_from_edges_rwire' +] bspline_counts = [] placements = [] -for sketch in sketches: - bspline_counts.append(sum(1 for geom in getattr(sketch, 'Geometry', []) if type(geom).__name__ == 'BSplineCurve')) +for section in sections: + bspline_counts.append( + sum(1 for edge in section.Shape.Edges if type(edge.Curve).__name__ == 'BSplineCurve') + ) placements.append({ - 'z': float(sketch.Placement.Base.z), - 'angle': float(sketch.Placement.Rotation.Angle), - 'axis': [float(sketch.Placement.Rotation.Axis.x), float(sketch.Placement.Rotation.Axis.y), float(sketch.Placement.Rotation.Axis.z)], + 'z': float(section.Placement.Base.z), + 'angle': float(section.Placement.Rotation.Angle), + 'axis': [float(section.Placement.Rotation.Axis.x), float(section.Placement.Rotation.Axis.y), float(section.Placement.Rotation.Axis.z)], }) lofts = [obj for obj in doc.Objects if getattr(obj, 'SimpleCADOp', '') == 'make_loft_rsolid'] -links = [obj for obj in doc.Objects if getattr(obj, 'TypeId', '') == 'App::Link'] +transform_links = [ + obj for obj in doc.Objects + if getattr(obj, 'TypeId', '') == 'App::Link' + and getattr(obj, 'SimpleCADOp', '') in {'make_translate_rshape', 'make_rotate_rshape'} +] with open(OUT_PATH, 'w', encoding='utf-8') as fh: json.dump({ - 'sketch_count': len(sketches), + 'section_count': len(sections), 'bspline_counts': bspline_counts, 'total_bspline_geometry': sum(bspline_counts), 'placements': placements, - 'link_count': len(links), + 'transform_link_count': len(transform_links), 'loft_count': len(lofts), 'loft_solid_count': 0 if not lofts else len(lofts[-1].Shape.Solids), 'loft_volume': 0.0 if not lofts else float(lofts[-1].Shape.Volume), @@ -2930,10 +3533,10 @@ with open(OUT_PATH, 'w', encoding='utf-8') as fh: node for node in payload["graph"]["nodes"] if node.get("op") == "make_spline_redge" ] self.assertEqual(len(bspline_nodes), 6) - self.assertEqual(result["sketch_count"], 6) + self.assertEqual(result["section_count"], 6) self.assertEqual(result["total_bspline_geometry"], 6) self.assertTrue(all(count == 1 for count in result["bspline_counts"])) - self.assertEqual(result["link_count"], 0) + self.assertEqual(result["transform_link_count"], 0) self.assertEqual( [round(item["z"], 3) for item in result["placements"]], [0.0, 0.8, 1.6, 2.4, 3.2, 4.0], @@ -2952,6 +3555,7 @@ with open(OUT_PATH, 'w', encoding='utf-8') as fh: op="make_line_redge", node_id="e1", params={"start": [0.0, 0.0, 0.0], "end": [1.0, 0.0, 0.0]}, + param_exprs={"end": [{"expr_id": "var_x"}, None, None]}, ) e2 = graph.add_node( op="make_line_redge", @@ -2974,7 +3578,16 @@ with open(OUT_PATH, 'w', encoding='utf-8') as fh: "canonical_contract": {"contract_version": "2.0"}, "graph": graph.to_dict(), "leaf_ids": [wire.node_id], - "expression_graph": {"nodes": []}, + "expression_graph": { + "nodes": [ + { + "expr_id": "var_x", + "kind": "var", + "name": "x", + "default": 1.0, + } + ] + }, "frame_graph": {"nodes": []}, "geometry_registry": [], "semantic_entity_registry": [], diff --git a/SimpleCADAPI/test/test_freecad_translator_gsm.py b/SimpleCADAPI/test/test_freecad_translator_gsm.py new file mode 100644 index 0000000..54d6be1 --- /dev/null +++ b/SimpleCADAPI/test/test_freecad_translator_gsm.py @@ -0,0 +1,241 @@ +"""FreeCAD runtime tests for geometric signature topology matching.""" + +from __future__ import annotations + +import json +import os +import shutil +import subprocess +import tempfile +import unittest + +from simplecadapi.translator.freecad_translator.runtime import ( + assemble_runtime_source, +) + + +class TestFreeCADTranslatorGSM(unittest.TestCase): + def _discover_freecadcmd(self) -> str | None: + return ( + shutil.which("FreeCADCmd") + or shutil.which("freecadcmd") + or ( + "/Applications/FreeCAD.app/Contents/Resources/bin/freecadcmd" + if os.path.exists( + "/Applications/FreeCAD.app/Contents/Resources/bin/freecadcmd" + ) + else None + ) + ) + + def _run_runtime_probe(self, probe: str) -> dict: + freecad_cmd = self._discover_freecadcmd() + if not freecad_cmd: + self.skipTest("freecadcmd not available") + + with tempfile.TemporaryDirectory() as tmp_dir: + script_path = os.path.join(tmp_dir, "probe.py") + result_path = os.path.join(tmp_dir, "result.json") + script = "\n".join( + [ + "import json", + "import math", + "import FreeCAD as App", + "import Part", + "doc = App.newDocument('SimpleCADGSMProbe')", + "GRAPH_NODES = {}", + "GRAPH_OUTPUTS = {}", + "GRAPH_METADATA = {}", + "GRAPH_SELECTIONS = {}", + "GRAPH_SPINE_OBJECTS = {}", + "GRAPH_LIMITATIONS = {}", + "GRAPH_TRANSLATION_LIMITATIONS = {}", + "PRODUCT_VALUES = {}", + "ASSEMBLY_PROJECTION_INPUTS = {}", + "GUI_VISIBILITY_BY_NAME = {}", + "GUI_EXPANDED_BY_NAME = {}", + "GUI_SHAPE_COLOR_BY_NAME = {}", + "GUI_MATERIAL_OVERRIDE_BY_NAME = {}", + "MATERIAL_OBJECTS_BY_ID = {}", + "SIMPLECAD_JOINT_OBJECTS = {}", + "SKETCH_REGISTRY = []", + "OP_EXPRESSION_BINDINGS = {}", + "OP_EXPRESSION_LIMITATIONS = {}", + "EXPRESSION_GRAPH = {}", + f"RESULT_PATH = {json.dumps(result_path)}", + assemble_runtime_source(), + probe, + ] + ) + with open(script_path, "w", encoding="utf-8") as handle: + handle.write(script) + completed = subprocess.run( + [freecad_cmd, script_path], + check=False, + text=True, + capture_output=True, + ) + self.assertEqual( + completed.returncode, + 0, + msg=f"stdout={completed.stdout!r}\nstderr={completed.stderr!r}", + ) + with open(result_path, "r", encoding="utf-8") as handle: + return json.load(handle) + + def test_split_line_selector_resolves_unique_fragment_chain(self): + result = self._run_runtime_probe( + """ +shape = Part.makeCompound([ + Part.makeLine(App.Vector(0, 0, 0), App.Vector(1, 0, 0)), + Part.makeLine(App.Vector(1, 0, 0), App.Vector(2, 0, 0)), +]) +selector = { + 'kind': 'edge', + 'geom_type': 'LINE', + 'start': [0, 0, 0], + 'end': [2, 0, 0], + 'center': [1, 0, 0], + 'length': 2, + 'bbox': {'min': [0, 0, 0], 'max': [2, 0, 0]}, +} +indices = _selection_indices_for_selector(shape, selector, context='split test') +with open(RESULT_PATH, 'w', encoding='utf-8') as handle: + json.dump({'indices': indices}, handle) +""" + ) + + self.assertEqual(result["indices"], [0, 1]) + + def test_two_source_selectors_resolve_one_merged_edge(self): + result = self._run_runtime_probe( + """ +shape = Part.makeCompound([ + Part.makeLine(App.Vector(0, 0, 0), App.Vector(2, 0, 0)), +]) +selectors = [ + { + 'kind': 'edge', 'geom_type': 'LINE', + 'start': [0, 0, 0], 'end': [1, 0, 0], + 'center': [0.5, 0, 0], 'length': 1, + 'bbox': {'min': [0, 0, 0], 'max': [1, 0, 0]}, + }, + { + 'kind': 'edge', 'geom_type': 'LINE', + 'start': [1, 0, 0], 'end': [2, 0, 0], + 'center': [1.5, 0, 0], 'length': 1, + 'bbox': {'min': [1, 0, 0], 'max': [2, 0, 0]}, + }, +] +indices = _selection_indices_for_selectors(shape, selectors, context='merge test') +with open(RESULT_PATH, 'w', encoding='utf-8') as handle: + json.dump({'indices': indices}, handle) +""" + ) + + self.assertEqual(result["indices"], [0]) + + def test_identical_candidates_are_rejected_as_ambiguous(self): + result = self._run_runtime_probe( + """ +shape = Part.makeCompound([ + Part.makeLine(App.Vector(0, 0, 0), App.Vector(2, 0, 0)), + Part.makeLine(App.Vector(0, 0, 0), App.Vector(2, 0, 0)), +]) +selector = { + 'kind': 'edge', 'geom_type': 'LINE', + 'start': [0, 0, 0], 'end': [2, 0, 0], + 'center': [1, 0, 0], 'length': 2, + 'bbox': {'min': [0, 0, 0], 'max': [2, 0, 0]}, +} +try: + _selection_indices_for_selector(shape, selector, context='ambiguity test') +except RuntimeError as error: + message = str(error) +else: + message = '' +with open(RESULT_PATH, 'w', encoding='utf-8') as handle: + json.dump({'message': message}, handle) +""" + ) + + self.assertIn("ambiguous", result["message"].lower()) + + def test_second_candidate_inside_match_threshold_is_ambiguous(self): + result = self._run_runtime_probe( + """ +shape = Part.makeCompound([ + Part.makeLine(App.Vector(0, 0, 0), App.Vector(2, 0, 0)), + Part.makeLine(App.Vector(0, 0.000005, 0), App.Vector(2, 0.000005, 0)), +]) +selector = { + 'kind': 'edge', 'geom_type': 'LINE', + 'start': [0, 0, 0], 'end': [2, 0, 0], + 'center': [1, 0, 0], 'length': 2, + 'bbox': {'min': [0, 0, 0], 'max': [2, 0, 0]}, +} +scores = [ + _geo_selector_score(candidate, selector, index) + for index, candidate in enumerate(shape.Edges) +] +try: + _selection_indices_for_selector(shape, selector, context='near ambiguity test') +except RuntimeError as error: + message = str(error) +else: + message = '' +with open(RESULT_PATH, 'w', encoding='utf-8') as handle: + json.dump({'message': message, 'scores': scores}, handle) +""" + ) + + self.assertLessEqual(result["scores"][1], 1e-4) + self.assertIn("ambiguous", result["message"].lower()) + + def test_split_full_circle_selector_resolves_unique_closed_chain(self): + result = self._run_runtime_probe( + """ +shape = Part.makeCompound([ + Part.Arc(App.Vector(1, 0, 0), App.Vector(0, 1, 0), App.Vector(-1, 0, 0)).toShape(), + Part.Arc(App.Vector(-1, 0, 0), App.Vector(0, -1, 0), App.Vector(1, 0, 0)).toShape(), +]) +selector = { + 'kind': 'edge', 'geom_type': 'CIRCLE', + 'start': [1, 0, 0], 'end': [1, 0, 0], + 'center': [0, 0, 0], 'length': 2 * math.pi, + 'bbox': {'min': [-1, -1, 0], 'max': [1, 1, 0]}, +} +indices = _selection_indices_for_selector(shape, selector, context='circle split test') +with open(RESULT_PATH, 'w', encoding='utf-8') as handle: + json.dump({'indices': indices}, handle) +""" + ) + + self.assertEqual(result["indices"], [0, 1]) + + def test_three_source_selectors_resolve_one_merged_edge(self): + result = self._run_runtime_probe( + """ +shape = Part.makeCompound([ + Part.makeLine(App.Vector(0, 0, 0), App.Vector(3, 0, 0)), +]) +selectors = [ + { + 'kind': 'edge', 'geom_type': 'LINE', + 'start': [index, 0, 0], 'end': [index + 1, 0, 0], + 'center': [index + 0.5, 0, 0], 'length': 1, + 'bbox': {'min': [index, 0, 0], 'max': [index + 1, 0, 0]}, + } + for index in range(3) +] +indices = _selection_indices_for_selectors(shape, selectors, context='three-way merge test') +with open(RESULT_PATH, 'w', encoding='utf-8') as handle: + json.dump({'indices': indices}, handle) +""" + ) + + self.assertEqual(result["indices"], [0]) + + +if __name__ == "__main__": + unittest.main() diff --git a/SimpleCADAPI/test/test_fusion360_translator.py b/SimpleCADAPI/test/test_fusion360_translator.py new file mode 100644 index 0000000..02eae0e --- /dev/null +++ b/SimpleCADAPI/test/test_fusion360_translator.py @@ -0,0 +1,264 @@ +"""Host-independent tests for the Fusion 360 translator backend.""" + +from __future__ import annotations + +import ast +import copy +import importlib +import math +import sys +import unittest + +import simplecadapi as scad +from simplecadapi import GraphSession +from simplecadapi.translator.errors import TranslationRequestError + + +def _rectangle_extrusion_model() -> str: + with GraphSession() as session: + profile = scad.make_rectangle_rface(width=2.0, height=1.0) + scad.extrude_rsolid( + profile=profile, + direction=(0.0, 0.0, 1.0), + distance=3.0, + ) + return scad.export_model_json(session=session) + + +class TestFusion360Translator(unittest.TestCase): + def test_backend_imports_without_target_runtime_modules(self): + before = set(sys.modules) + + fusion = importlib.import_module("simplecadapi.translator.fusion360_translator") + + self.assertNotIn("adsk.core", set(sys.modules) - before) + self.assertNotIn("adsk.fusion", set(sys.modules) - before) + self.assertEqual(fusion.CAPABILITIES.backend_id, "fusion360") + + def test_supported_graph_emits_deterministic_compilable_script(self): + model_json = _rectangle_extrusion_model() + from simplecadapi.translator.fusion360_translator import Fusion360Translator + + translator = Fusion360Translator(document_name="ContractFusion") + first = translator.translate_model_json(model_json) + second = translator.translate_model_json(model_json) + + self.assertEqual(first.content, second.content) + compile(first.content, "", "exec") + self.assertFalse(first.metadata["target_runtime_validated"]) + self.assertTrue(translator.capabilities.targets[0].requires_external_runtime) + + def test_payload_uses_canonical_periodic_curve_frames(self): + with GraphSession() as session: + scad.make_angle_arc_rwire( + (0.0, 0.0, 0.0), + 2.0, + 0.0, + math.pi / 2.0, + normal=(1.0, 0.0, 0.0), + ) + scad.make_helix_rwire( + 1.0, + 3.0, + 2.0, + dir=(1.0, 0.0, 0.0), + ) + + from simplecadapi.translator.fusion360_translator import Fusion360Translator + + script = ( + Fusion360Translator() + .translate_model_json(scad.export_model_json(session)) + .content + ) + module = ast.parse(script) + model_payload = next( + ast.literal_eval(node.value) + for node in module.body + if isinstance(node, ast.Assign) + and any( + isinstance(target, ast.Name) and target.id == "MODEL_PAYLOAD" + for target in node.targets + ) + ) + curve_nodes = { + node["op"]: node + for node in model_payload["graph"]["nodes"] + if node["op"] in {"make_angle_arc_redge", "make_helix_redge"} + } + + for node in curve_nodes.values(): + self.assertEqual(node["params"]["_kernel_x_axis"], [-0.0, 0.0, 1.0]) + self.assertEqual(node["params"]["_kernel_y_axis"], [0.0, -1.0, 0.0]) + self.assertIn("Circle3D.createByCenter", script) + self.assertIn("SweepOrientationTypes.ParallelOrientationType", script) + + def test_fallback_scripts_are_deterministic_and_result_scoped(self): + with GraphSession() as session: + profile = scad.make_rectangle_rface( + 1.0, + 2.0, + center=(2.0, 0.0, 0.0), + normal=(0.0, 1.0, 0.0), + ) + scad.revolve_rsolid(profile, axis=(0.0, 1.0, 0.0)) + scad.make_box_rsolid(width=4.0, height=5.0, depth=6.0) + model_json = scad.export_model_json(session) + payload = scad.import_model_json(model_json) + graph = payload["graph"] + result_id = next( + node.node_id + for node in graph.topological_order() + if node.op == "make_revolve_rsolid" + ) + box_id = next( + node.node_id + for node in graph.topological_order() + if node.op == "make_box_rsolid" + ) + + from simplecadapi.translator.fusion360_translator import Fusion360Translator + + translator = Fusion360Translator( + result_node_ids=[result_id], source_kernel_fallback=True + ) + first = translator.translate_model_json(model_json).content + second = translator.translate_model_json(model_json).content + + self.assertEqual(first, second) + module = ast.parse(first) + fallback_steps = next( + ast.literal_eval(node.value) + for node in module.body + if isinstance(node, ast.Assign) + and any( + isinstance(target, ast.Name) and target.id == "SOURCE_KERNEL_STEPS" + for target in node.targets + ) + ) + self.assertIn(result_id, fallback_steps) + self.assertNotIn(box_id, fallback_steps) + + def test_implicit_results_prefer_active_assembly_state(self): + from simplecadapi.topology import OperationGraph + from simplecadapi.translator.fusion360_translator import Fusion360Translator + + graph = OperationGraph("assembly_states") + locked = graph.add_node( + "make_compound_from_assembly_rcompound", + {"assembly_id": "fixture_locked", "component_count": 1}, + node_id="locked_result", + ) + operating = graph.add_node( + "make_compound_from_assembly_rcompound", + {"assembly_id": "fixture_operating", "component_count": 1}, + node_id="operating_result", + ) + payload = { + "schema_version": "2.0", + "graph": graph, + "leaf_ids": [locked.node_id, operating.node_id], + } + + script = Fusion360Translator().translate_model_payload_to_script( + payload, graph=graph + ) + + self.assertIn("ACTIVE_RESULT_STATE = 'operating'", script) + self.assertIn("RESULT_NODE_IDS = ['operating_result']", script) + + def test_fallback_accepts_legacy_metadata_only_selector_nodes(self): + with GraphSession() as session: + profile = scad.make_rectangle_rface(3.0, 3.0) + solid = scad.extrude_rsolid(profile, (0.0, 0.0, 1.0), 3.0) + scad.fillet_rsolid(solid, [solid.get_edges(0)], 0.2) + payload = scad.import_model_json(scad.export_model_json(session)) + graph_data = copy.deepcopy(payload["graph"].to_dict()) + selector_ids = { + node["node_id"] + for node in graph_data["nodes"] + if node["op"] == "make_select_redge" + } + for node in graph_data["nodes"]: + if node["node_id"] in selector_ids: + node["inputs"] = [] + else: + node["inputs"] = [ + input_ref + for input_ref in node.get("inputs", []) + if input_ref not in selector_ids + ] + graph_data["edges"] = [ + edge + for edge in graph_data.get("edges", []) + if edge[0] not in selector_ids and edge[1] not in selector_ids + ] + + from simplecadapi.topology import OperationGraph + from simplecadapi.translator.fusion360_translator import Fusion360Translator + + legacy_graph = OperationGraph.from_dict(graph_data) + result_id = next( + node.node_id + for node in legacy_graph.nodes + if node.op == "make_fillet_rsolid" + ) + legacy_payload = { + "schema_version": "2.0", + "graph": legacy_graph, + "leaf_ids": [result_id], + } + script = Fusion360Translator( + source_kernel_fallback=True + ).translate_model_payload_to_script(legacy_payload, graph=legacy_graph) + module = ast.parse(script) + fallback_steps = next( + ast.literal_eval(node.value) + for node in module.body + if isinstance(node, ast.Assign) + and any( + isinstance(target, ast.Name) and target.id == "SOURCE_KERNEL_STEPS" + for target in node.targets + ) + ) + + self.assertIn(result_id, fallback_steps) + + def test_explicit_result_only_executes_its_dependency_closure(self): + with GraphSession() as session: + profile = scad.make_rectangle_rface(width=2.0, height=1.0) + scad.extrude_rsolid(profile, (0.0, 0.0, 1.0), 3.0) + scad.make_box_rsolid(width=1.0, height=2.0, depth=3.0) + model_json = scad.export_model_json(session=session) + payload = scad.import_model_json(model_json) + graph = payload["graph"] + extrusion_id = next( + node.node_id + for node in graph.topological_order() + if node.op == "make_extrude_rsolid" + ) + + from simplecadapi.translator.fusion360_translator import Fusion360Translator + + script = ( + Fusion360Translator(result_node_ids=[extrusion_id]) + .translate_model_json(model_json) + .content + ) + + self.assertIn(f"RESULT_NODE_IDS = ['{extrusion_id}']", script) + self.assertIn("active_node_ids = self._result_dependency_ids", script) + self.assertIn("not in active_node_ids", script) + + def test_unsupported_result_operation_is_rejected_before_host_execution(self): + with GraphSession() as session: + scad.make_box_rsolid(width=1.0, height=2.0, depth=3.0) + + from simplecadapi.translator.fusion360_translator import Fusion360Translator + + with self.assertRaises(TranslationRequestError): + Fusion360Translator().translate_model_json(scad.export_model_json(session)) + + +if __name__ == "__main__": + unittest.main() diff --git a/SimpleCADAPI/test/test_graph_model_api.py b/SimpleCADAPI/test/test_graph_model_api.py new file mode 100644 index 0000000..9f12d2d --- /dev/null +++ b/SimpleCADAPI/test/test_graph_model_api.py @@ -0,0 +1,289 @@ +import json +from dataclasses import dataclass +from pathlib import Path +from tempfile import TemporaryDirectory +import unittest + +import simplecadapi as scad +from simplecadapi.scene import ( + parse_canonical_json, + preflight_zip_bytes, + validate_scene_package, +) +from simplecadapi.topology import TopoKind, TopoRef + + +class TestGraphModelApi(unittest.TestCase): + def test_model_export_dir_writes_one_self_contained_scene_package(self): + with TemporaryDirectory() as directory: + @scad.model(graph_id="auto_export", export_dir=directory) + def build_model(): + body = scad.make_box_rsolid(width=1.0, height=2.0, depth=3.0) + scad.capture_result(value=body) + return body + + result = build_model() + self.assertEqual(set(result.artifact_paths), {"scene"}) + self.assertTrue( + all(Path(path).is_file() for path in result.artifact_paths.values()) + ) + self.assertEqual( + sorted(Path(directory).iterdir()), + [result.artifact_paths["scene"]], + ) + + def test_model_export_dir_writes_assembly_artifacts_with_model_provenance(self): + with TemporaryDirectory() as directory: + @scad.model(graph_id="assembly_export", export_dir=directory) + def build_model(): + body = scad.make_box_rsolid(width=1.0, height=2.0, depth=3.0) + part = scad.make_part_rpart(part_id="body", body=body) + assembly = scad.make_assembly_rassembly(assembly_id="root") + assembly = scad.add_component_rassembly( + assembly=assembly, + item=part, + component_id="body", + placement=scad.identity_placement_rplacement(), + ) + scad.capture_result(value=assembly) + return assembly + + result = build_model() + self.assertEqual(set(result.artifact_paths), {"scene"}) + self.assertTrue( + all(Path(path).is_file() for path in result.artifact_paths.values()) + ) + + archive = preflight_zip_bytes( + result.artifact_paths["scene"].read_bytes() + ) + manifest = parse_canonical_json(archive.members["scene.json"]) + blobs = { + name: payload + for name, payload in archive.members.items() + if name != "scene.json" + } + report = validate_scene_package(manifest, blobs) + self.assertTrue(report.valid, report.issues) + self.assertEqual(manifest["source"]["kind"], "model") + self.assertIn("model/model.json", archive.members) + self.assertTrue(manifest["compile_options"]["embed_source"]) + self.assertEqual( + manifest["source"]["embedded_artifact_uri"], + "model/model.json", + ) + source_files = manifest["source"]["source_files"] + self.assertEqual( + [record["path"] for record in source_files], + ["test/test_graph_model_api.py"], + ) + self.assertEqual( + archive.members[source_files[0]["uri"]], + Path(__file__).read_bytes(), + ) + self.assertFalse( + any( + name.lower().endswith((".step", ".stl", ".fcstd")) + for name in archive.members + ) + ) + self.assertTrue( + manifest["definitions"] + and all( + item["source"]["kind"] == "product_model" + for item in manifest["definitions"] + ) + ) + + def test_model_source_embedding_is_explicit(self): + @scad.model(graph_id="embedded_source") + def build_model(): + body = scad.make_box_rsolid(width=1.0, height=2.0, depth=3.0) + scad.capture_result(value=body) + return body + + result = build_model() + package = scad.compile_scene( + scene_id="embedded_source", + roots=(scad.SceneRoot(root_id="main", value=result.value),), + source=result, + options=scad.SceneCompileOptions(embed_source=True), + ) + + self.assertEqual( + package.blobs["model/model.json"], + result.model_json.encode("utf-8"), + ) + self.assertTrue(package.manifest["compile_options"]["embed_source"]) + self.assertEqual( + package.manifest["source"]["embedded_artifact_uri"], + "model/model.json", + ) + source_files = package.manifest["source"]["source_files"] + self.assertEqual( + [record["path"] for record in source_files], + ["test/test_graph_model_api.py"], + ) + self.assertEqual( + package.blobs[source_files[0]["uri"]], + Path(__file__).read_bytes(), + ) + + def test_model_owns_one_session_and_replays_explicit_result(self): + @scad.model(graph_id="decorator_model") + def build_model(): + scad.make_box_rsolid(width=0.5, height=0.5, depth=0.5) + return scad.make_box_rsolid(width=1.0, height=2.0, depth=3.0) + + result = build_model() + + self.assertIsInstance(result, scad.ModelResult) + self.assertEqual(result.session.graph.graph_id, "decorator_model") + self.assertEqual(len(result.result_node_ids), 1) + self.assertEqual(result.session.graph.node_count, 2) + self.assertEqual(len(result.replay()), 1) + payload = json.loads(result.model_json) + self.assertEqual(payload["leaf_ids"], list(result.result_node_ids)) + + def test_requires_session_reuses_active_session(self): + @scad.requires_session + def build_box(): + return scad.make_box_rsolid(width=1.0, height=1.0, depth=1.0) + + with self.assertRaisesRegex(RuntimeError, "requires an active GraphSession"): + build_box() + + with scad.GraphSession(graph_id="shared") as session: + box = build_box() + + self.assertEqual(box.get_metadata("graph")["graph_id"], "shared") + self.assertEqual(session.graph.node_count, 1) + + def test_model_rejects_nested_model_sessions(self): + @scad.model + def child_model(): + return scad.make_box_rsolid(width=1.0, height=1.0, depth=1.0) + + @scad.model + def parent_model(): + return child_model() + + with self.assertRaisesRegex(RuntimeError, "cannot be nested"): + parent_model() + + def test_capture_result_can_exclude_unrelated_leaf(self): + @scad.model + def build_model(): + debug = scad.make_box_rsolid(width=0.25, height=0.25, depth=0.25) + final = scad.make_box_rsolid(width=2.0, height=2.0, depth=2.0) + scad.capture_result(value=final) + return debug, final + + result = build_model() + payload = json.loads(result.model_json) + nodes = {node["node_id"]: node for node in payload["graph"]["nodes"]} + + self.assertEqual(payload["leaf_ids"], list(result.result_node_ids)) + self.assertEqual(len(payload["leaf_ids"]), 1) + self.assertEqual(nodes[payload["leaf_ids"][0]]["params"]["width"], 2.0) + + def test_cross_session_shape_input_is_rejected_at_operation_boundary(self): + with scad.GraphSession(graph_id="source"): + foreign = scad.make_box_rsolid(width=1.0, height=1.0, depth=1.0) + + with scad.GraphSession(graph_id="target"): + with self.assertRaisesRegex(ValueError, "source.*target"): + scad.translate_shape(shape=foreign, vector=(1.0, 0.0, 0.0)) + + def test_cross_session_child_assembly_is_rejected(self): + with scad.GraphSession(graph_id="child_graph"): + child = scad.make_assembly_rassembly(assembly_id="child") + + with scad.GraphSession(graph_id="parent_graph"): + parent = scad.make_assembly_rassembly(assembly_id="parent") + placement = scad.identity_placement_rplacement() + with self.assertRaisesRegex(ValueError, "child_graph.*parent_graph"): + scad.add_component_rassembly( + assembly=parent, + item=child, + component_id="child_1", + placement=placement, + ) + + def test_unrecorded_child_assembly_is_rejected(self): + child = scad.make_assembly_rassembly(assembly_id="unrecorded_child") + + with scad.GraphSession(graph_id="parent_graph"): + parent = scad.make_assembly_rassembly(assembly_id="parent") + placement = scad.identity_placement_rplacement() + with self.assertRaisesRegex(ValueError, "unrecorded Assembly"): + scad.add_component_rassembly( + assembly=parent, + item=child, + component_id="child_1", + placement=placement, + ) + + def test_capture_result_walks_dataclass_values_and_is_atomic_on_failure(self): + @dataclass + class ResultValue: + body: scad.Solid + + @scad.model(graph_id="dataclass_result") + def build_model(): + body = scad.make_box_rsolid(width=1.0, height=2.0, depth=3.0) + scad.capture_result(value=ResultValue(body=body)) + return ResultValue(body=body) + + result = build_model() + self.assertEqual(len(result.result_node_ids), 1) + self.assertEqual(len(result.replay()), 1) + + with scad.GraphSession(graph_id="atomic_capture") as session: + body = scad.make_box_rsolid(width=1.0, height=1.0, depth=1.0) + with scad.GraphSession(graph_id="foreign_capture"): + foreign = scad.make_box_rsolid( + width=2.0, height=2.0, depth=2.0 + ) + with self.assertRaisesRegex(ValueError, "foreign_capture.*atomic_capture"): + session.capture_result(value=(body, foreign)) + self.assertFalse(session.has_explicit_results) + self.assertEqual(session.result_node_ids, ()) + + with scad.GraphSession(graph_id="topology_capture") as session: + body = scad.make_box_rsolid(width=1.0, height=1.0, depth=1.0) + node = body._get_runtime("graph.node") + body._set_runtime( + "topo.ref", + TopoRef( + graph_id="foreign_topology", + node_id=node.node_id, + output_slot=0, + kind=TopoKind.SOLID, + topo_id="solid_0", + ), + ) + with self.assertRaisesRegex( + ValueError, "foreign_topology.*topology_capture" + ): + session.capture_result(value=body) + self.assertFalse(session.has_explicit_results) + self.assertEqual(session.result_node_ids, ()) + + def test_model_and_requires_session_reject_async_functions(self): + async def async_model(): + return scad.make_box_rsolid(width=1.0, height=1.0, depth=1.0) + + async def async_builder(): + return scad.make_box_rsolid(width=1.0, height=1.0, depth=1.0) + + with self.assertRaisesRegex(TypeError, "@model does not support async"): + scad.model(async_model) + with self.assertRaisesRegex( + TypeError, "@requires_session does not support async" + ): + scad.requires_session(async_builder) + + +if __name__ == "__main__": + unittest.main() diff --git a/SimpleCADAPI/test/test_graph_ql.py b/SimpleCADAPI/test/test_graph_ql.py index f3987e3..2dc07cb 100644 --- a/SimpleCADAPI/test/test_graph_ql.py +++ b/SimpleCADAPI/test/test_graph_ql.py @@ -15,24 +15,75 @@ class TestQLSugar(unittest.TestCase): def test_op_predicate(self): pred = Q.op("cut", "generated") - obj = type("Obj", (), {"_tags": {"op.cut.generated"}})() + obj = type( + "Obj", + (), + {"_metadata": {"track": {"op": "make_cut_rsolid", "event": "generated"}}}, + )() self.assertTrue(pred(obj)) def test_op_predicate_wildcard(self): pred = Q.op("cut") - obj = type("Obj", (), {"_tags": {"op.cut.modified"}})() + obj = type( + "Obj", + (), + {"_metadata": {"track": {"op": "make_cut_rsolid", "events": ["modified"]}}}, + )() self.assertTrue(pred(obj)) + def test_op_predicate_matches_generic_operation_alias(self): + obj = type( + "Obj", + (), + { + "_metadata": { + "track": { + "op": "make_translate_rsolid", + "operation": "translate", + "event": "modified", + } + } + }, + )() + + self.assertTrue(Q.op("translate", "modified")(obj)) + self.assertTrue(Q.op("make_translate_rsolid", "modified")(obj)) + def test_origin_predicate(self): pred = Q.origin("tool") - obj = type("Obj", (), {"_tags": {"origin.tool"}})() + obj = type( + "Obj", (), {"_metadata": {"track": {"origin_roles": ["body", "tool"]}}} + )() self.assertTrue(pred(obj)) def test_origin_predicate_no_match(self): pred = Q.origin("tool") - obj = type("Obj", (), {"_tags": {"origin.body"}})() + obj = type("Obj", (), {"_metadata": {"track": {"origin_role": "body"}}})() self.assertFalse(pred(obj)) + def test_op_and_origin_do_not_fall_back_to_flat_tags(self): + obj = type( + "Obj", + (), + {"_metadata": {}, "_tags": {"op.cut.generated", "origin.tool"}}, + )() + + self.assertFalse(Q.op("cut", "generated")(obj)) + self.assertFalse(Q.origin("tool")(obj)) + + def test_typed_tracking_predicates_roundtrip(self): + operation = Q.operation_event("cut", "modified") + origin = Q.origin_role("tool") + + self.assertEqual( + Q.SerializablePredicate.from_dict(operation.to_dict()).to_dict(), + operation.to_dict(), + ) + self.assertEqual( + Q.SerializablePredicate.from_dict(origin.to_dict()).to_dict(), + origin.to_dict(), + ) + def test_role_predicate(self): pred = Q.role("section") obj = type("Obj", (), {"_tags": {"role.section.face"}})() diff --git a/SimpleCADAPI/test/test_inverse_engineer_brep.py b/SimpleCADAPI/test/test_inverse_engineer_brep.py new file mode 100644 index 0000000..d4e8311 --- /dev/null +++ b/SimpleCADAPI/test/test_inverse_engineer_brep.py @@ -0,0 +1,150 @@ +from __future__ import annotations + +import math +from pathlib import Path + +import pytest +import simplecadapi as scad +from simplecadapi.inverse_engineer import brep +from simplecadapi.inverse_engineer.brep.cli import main as brep_cli_main + + +def _box(): + return scad.make_box_rsolid(width=4.0, height=3.0, depth=2.0) + + +def _two_arc_cylinder(): + first = scad.make_angle_arc_redge( + center=(0.0, 0.0, 0.0), + radius=1.0, + start_angle=0.0, + end_angle=math.pi, + normal=(0.0, 0.0, 1.0), + ) + second = scad.make_angle_arc_redge( + center=(0.0, 0.0, 0.0), + radius=1.0, + start_angle=math.pi, + end_angle=2.0 * math.pi, + normal=(0.0, 0.0, 1.0), + ) + wire = scad.make_wire_from_edges_rwire(edges=[first, second]) + face = scad.make_face_from_wire_rface(wire=wire, normal=(0.0, 0.0, 1.0)) + return scad.extrude_rsolid(profile=face, direction=(0.0, 0.0, 1.0), distance=2.0) + + +def test_inspect_shape_reports_unique_and_occurrence_counts(): + report = brep.inspect_shape(_box().wrapped) + + assert report.valid is True + assert report.counts["unique_faces"] == 6 + assert report.counts["unique_edges"] == 12 + assert report.counts["unique_vertices"] == 8 + assert report.counts["edge_occurrences"] == 24 + assert report.surface_type_counts == {"Plane": 6} + assert report.edge_type_counts == {"Line": 12} + assert report.volume == pytest.approx(24.0) + + +def test_inspect_bspline_edge_includes_reconstruction_parameters(): + wire = scad.make_spline_rwire( + control_points=[ + (0.0, 0.0, 0.0), + (1.0, 1.0, 0.0), + (2.0, 1.0, 0.0), + (3.0, 0.0, 0.0), + ], + degree=3, + ) + + report = brep.inspect_shape(wire.wrapped) + spline = report.edges[0] + + assert spline["type"] == "BSplineCurve" + assert spline["degree"] == 3 + assert spline["poles"] == 4 + assert len(spline["control_points"]) == 4 + assert len(spline["knot_values"]) == spline["knots"] + assert len(spline["multiplicities"]) == spline["knots"] + + +def test_compare_same_shape_passes_geometry_and_topology(): + shape = _box().wrapped + comparison = brep.compare_shapes(shape, shape) + + assert comparison.same_geometric_point_set is True + assert comparison.geometry_labelled_incidence_graph_isomorphic is True + assert comparison.hard_gate_passed is True + assert comparison.target_minus_candidate_volume == 0.0 + assert comparison.candidate_minus_target_volume == 0.0 + + +def test_compare_closed_shape_is_invariant_to_boundary_orientation(): + shape = _box().wrapped + comparison = brep.compare_shapes(shape, shape.Reversed()) + + assert comparison.same_geometric_point_set is True + assert comparison.geometry_labelled_incidence_graph_isomorphic is True + assert comparison.hard_gate_passed is True + assert comparison.target_minus_candidate_volume == 0.0 + assert comparison.candidate_minus_target_volume == 0.0 + + +def test_compare_detects_same_geometry_with_different_topology(): + full_circle = scad.make_cylinder_rsolid(radius=1.0, height=2.0) + two_arcs = _two_arc_cylinder() + + comparison = brep.compare_shapes(full_circle.wrapped, two_arcs.wrapped) + + assert comparison.same_geometric_point_set is True + assert comparison.geometry_labelled_incidence_graph_isomorphic is False + assert comparison.hard_gate_passed is False + + +def test_center_slice_specs_follow_shape_bounds(): + specs = brep.center_slice_specs( + minimum=(-2.0, 4.0, 10.0), + maximum=(6.0, 8.0, 14.0), + ) + + assert [(spec.plane, spec.value) for spec in specs] == [ + ("yz", 2.0), + ("xz", 6.0), + ("xy", 12.0), + ] + + +def test_compare_shape_slices_has_zero_xor_for_same_shape(): + shape = _box().wrapped + comparison = brep.compare_shape_slices( + shape, + shape, + slices=(brep.SliceSpec("xy", 1.0), brep.SliceSpec("xz", 1.5)), + samples=(11, 9), + ) + + assert comparison.total_samples == 198 + assert comparison.xor_samples == 0 + assert comparison.sampled_slices_identical is True + + +def test_step_round_trip_and_cli(tmp_path: Path): + step = tmp_path / "box.step" + report_path = tmp_path / "box-report.json" + comparison_path = tmp_path / "comparison.json" + scad.export_step(shapes=_box(), filename=str(step)) + + report = brep.inspect_step(step) + comparison = brep.compare_steps(step, step) + + assert report.counts["unique_faces"] == 6 + assert comparison.hard_gate_passed is True + assert brep_cli_main(["inspect", str(step), "--output", str(report_path)]) == 0 + assert ( + brep_cli_main( + ["compare", str(step), str(step), "--output", str(comparison_path)] + ) + == 0 + ) + assert report_path.exists() + assert comparison_path.exists() diff --git a/SimpleCADAPI/test/test_model_json.py b/SimpleCADAPI/test/test_model_json.py index 0b92bc6..d33acfb 100644 --- a/SimpleCADAPI/test/test_model_json.py +++ b/SimpleCADAPI/test/test_model_json.py @@ -4,6 +4,7 @@ from __future__ import annotations import json import unittest +from copy import deepcopy import simplecadapi as scad from simplecadapi.graph import GraphSession @@ -23,6 +24,38 @@ class TestModelJson(unittest.TestCase): self.assertGreaterEqual(payload["graph"].node_count, 1) self.assertGreaterEqual(payload["expression_graph"].node_count, 1) + def test_model_json_tag_binding_registry_roundtrip_and_tamper_rejection(self): + selector = ( + scad.ql.faces() + .order_by(scad.ql.key("geom.center.z"), desc=True) + .take(1) + .exactly(1) + ) + with GraphSession() as session: + box = scad.make_box_rsolid(2.0, 3.0, 4.0) + scad.apply_tag_rselection(box, selector, "role.target") + + raw = json.loads(scad.export_model_json(session)) + node = next( + item + for item in raw["graph"]["nodes"] + if item["op"] == "apply_tag_rselection" + ) + binding = node["params"]["tag_binding"] + self.assertEqual(raw["semantic_bindings"], [binding]) + self.assertEqual( + raw["canonical_contract"]["semantic_op_set"], + ["apply_tag_rselection"], + ) + + parsed = scad.import_model_json(json.dumps(raw)) + self.assertEqual(parsed["semantic_bindings"], [binding]) + + damaged = deepcopy(raw) + damaged["semantic_bindings"][0]["tag"] = "role.tampered" + with self.assertRaisesRegex(ValueError, "semantic_bindings do not match"): + scad.import_model_json(json.dumps(damaged)) + def test_model_json_declares_canonical_contract_and_graph_roles(self): with GraphSession() as session: scad.make_box_rsolid(1.0, 2.0, 3.0) diff --git a/SimpleCADAPI/test/test_operation_output_roles.py b/SimpleCADAPI/test/test_operation_output_roles.py new file mode 100644 index 0000000..5a3f7d4 --- /dev/null +++ b/SimpleCADAPI/test/test_operation_output_roles.py @@ -0,0 +1,869 @@ +"""Kernel-backed feature output roles, tagging, projection, and replay.""" + +import json +import unittest +from copy import deepcopy + +import simplecadapi as scad +from simplecadapi import ql as Q + + +def _role_count(shape, role): + candidates = shape.get_edges() if role == "shell.wall" else shape.get_faces() + return sum(Q.output_role(role)(item) for item in candidates) + + +class TestOperationOutputRoles(unittest.TestCase): + def test_feature_role_matrix_is_kernel_backed(self): + profile = scad.make_rectangle_rface(2.0, 1.0) + extruded = scad.extrude_rsolid(profile, (0, 0, 1), 2.0) + self.assertEqual(_role_count(extruded, "extrusion.start"), 1) + self.assertEqual(_role_count(extruded, "extrusion.end"), 1) + self.assertEqual(_role_count(extruded, "extrusion.side"), 4) + + revolve_profile = scad.make_rectangle_rface(2.0, 1.0, center=(3, 0, 0)) + revolved = scad.revolve_rsolid( + revolve_profile, (0, 1, 0), 180.0, (0, 0, 0) + ) + self.assertEqual(_role_count(revolved, "revolution.start"), 1) + self.assertEqual(_role_count(revolved, "revolution.end"), 1) + self.assertEqual(_role_count(revolved, "revolution.side"), 4) + + first = scad.make_rectangle_rwire(2.0, 2.0) + last = scad.make_rectangle_rwire(1.0, 1.0, center=(0, 0, 2)) + lofted = scad.loft_rsolid([first, last]) + self.assertEqual(_role_count(lofted, "loft.start"), 1) + self.assertEqual(_role_count(lofted, "loft.end"), 1) + self.assertEqual(_role_count(lofted, "loft.side"), 4) + + sweep_profile = scad.make_rectangle_rface(1.0, 1.0) + path = scad.make_segment_rwire((0, 0, 0), (0, 0, 3)) + swept = scad.sweep_rsolid(sweep_profile, path) + self.assertEqual(_role_count(swept, "sweep.start"), 1) + self.assertEqual(_role_count(swept, "sweep.end"), 1) + self.assertEqual(_role_count(swept, "sweep.side"), 4) + + twisted = scad.twisted_sweep_rsolid( + profile=sweep_profile, + distance=3.0, + twist_angle=45.0, + ) + self.assertEqual(_role_count(twisted, "twisted_sweep.start"), 1) + self.assertEqual(_role_count(twisted, "twisted_sweep.end"), 1) + self.assertEqual(_role_count(twisted, "twisted_sweep.side"), 4) + + def test_revolve_and_sweep_topology_tags_require_exact_side_correspondence(self): + revolve_profile = scad.make_rectangle_rface( + 2.0, + 1.0, + center=(3, 0, 0), + edge_tags=("a", "b", "c", "d"), + ) + revolved = scad.revolve_rsolid( + revolve_profile, + (0, 1, 0), + 180.0, + (0, 0, 0), + tag_prefix="revolved", + ) + self.assertEqual( + { + tag + for face in revolved.get_faces() + for tag in scad.list_tags(face, scope="local") + if tag.startswith("revolved.face.side.") + }, + { + "revolved.face.side.a", + "revolved.face.side.b", + "revolved.face.side.c", + "revolved.face.side.d", + }, + ) + + profile = scad.make_rectangle_rface( + 1.0, 1.0, edge_tags=("a", "b", "c", "d") + ) + path = scad.make_segment_rwire((0, 0, 0), (0, 0, 3)) + swept = scad.sweep_rsolid(profile, path, tag_prefix="swept") + self.assertEqual( + { + tag + for face in swept.get_faces() + for tag in scad.list_tags(face, scope="local") + if tag.startswith("swept.face.side.") + }, + { + "swept.face.side.a", + "swept.face.side.b", + "swept.face.side.c", + "swept.face.side.d", + }, + ) + + def test_loft_does_not_guess_topology_tags_for_merged_side_faces(self): + first = scad.make_rectangle_rwire( + 2.0, + 2.0, + edge_tags=("first_a", "first_b", "first_c", "first_d"), + ) + last = scad.make_rectangle_rwire( + 1.0, + 1.0, + center=(0, 0, 2), + edge_tags=("last_a", "last_b", "last_c", "last_d"), + ) + lofted = scad.loft_rsolid([first, last], tag_prefix="lofted") + + side_tags = { + tag + for face in lofted.get_faces() + for tag in scad.list_tags(face, scope="local") + if tag == "lofted.face.side" or tag.startswith("lofted.face.side.") + } + self.assertEqual(side_tags, {"lofted.face.side"}) + + def test_native_cylinder_has_kernel_face_and_edge_roles(self): + cylinder = scad.make_cylinder_rsolid(2.0, 5.0, tag_prefix="shaft") + for role in ("cylinder.start", "cylinder.end", "cylinder.side"): + self.assertEqual( + sum(Q.output_role(role)(face) for face in cylinder.get_faces()), 1 + ) + for role in ( + "cylinder.start_boundary", + "cylinder.end_boundary", + "cylinder.seam", + ): + self.assertEqual( + sum(Q.output_role(role)(edge) for edge in cylinder.get_edges()), 1 + ) + self.assertEqual( + len(Q.faces().where(Q.tag("shaft.face.start")).resolve(cylinder)), 1 + ) + self.assertEqual( + len(Q.edges().where(Q.tag("shaft.face.start")).resolve(cylinder)), 1 + ) + + def test_native_box_has_kernel_face_roles_and_exact_topology_tags(self): + box = scad.make_box_rsolid(2.0, 3.0, 4.0, tag_prefix="housing") + for role in ( + "box.bottom", + "box.top", + "box.front", + "box.back", + "box.left", + "box.right", + ): + self.assertEqual( + sum(Q.output_role(role)(face) for face in box.get_faces()), 1 + ) + + top = Q.faces().where(Q.tag("housing.face.top")) + front = Q.faces().where(Q.tag("housing.face.front")) + self.assertEqual(len(top.resolve(box)), 1) + self.assertEqual(len(front.resolve(box)), 1) + self.assertEqual( + len(Q.edges().incident_to(top, front, distinct=True).resolve(box)), 1 + ) + + def test_native_box_roles_and_tags_replay(self): + with scad.GraphSession() as session: + box = scad.make_box_rsolid( + 2.0, + 3.0, + 4.0, + tag_prefix="housing", + bottom_face_tag="anchor.base", + right_face_tag="role.mounting_surface", + result_tag="part.housing", + ) + + payload = json.loads(scad.export_model_json(session)) + primitive = next( + node + for node in payload["graph"]["nodes"] + if node["op"] == "make_box_rsolid" + ) + self.assertNotIn("name", primitive["params"]) + self.assertEqual(len(primitive["topo_delta"]["roles"]), 6) + + replayed = scad.replay_model_json(json.dumps(payload))[0] + for shape in (box, replayed): + self.assertEqual( + len(Q.faces().where(Q.tag("housing.face.bottom")).resolve(shape)), + 1, + ) + self.assertEqual( + len(Q.faces().where(Q.tag("anchor.base")).resolve(shape)), 1 + ) + self.assertEqual( + len( + Q.faces() + .where(Q.tag("role.mounting_surface")) + .resolve(shape) + ), + 1, + ) + self.assertIn("part.housing", scad.list_tags(shape, scope="local")) + self.assertEqual( + sum( + Q.output_role("box.right")(face) + for face in shape.get_faces() + ), + 1, + ) + + def test_generic_role_tag_mapping_is_not_a_public_entrypoint(self): + with self.assertRaises(TypeError): + scad.make_box_rsolid( + 2.0, + 3.0, + 4.0, + **{"output_tags": {"box.top_front_edge": "role.edge"}}, + ) + + def test_removed_topology_name_keywords_are_not_public_entrypoints(self): + with self.assertRaises(TypeError): + scad.make_box_rsolid(2.0, 3.0, 4.0, **{"name": "housing"}) + with self.assertRaises(TypeError): + scad.make_rectangle_rface( + 2.0, + 1.0, + **{"edge_names": ("bottom", "right", "top", "left")}, + ) + with self.assertRaises(TypeError): + scad.make_circle_rface( + (0.0, 0.0, 0.0), + 1.0, + **{"edge_name": "outer"}, + ) + + def test_native_cone_and_frustum_have_kernel_roles(self): + cone = scad.make_cone_rsolid(2.0, 4.0, tag_prefix="tip") + frustum = scad.make_cone_rsolid( + 2.0, 4.0, top_radius=1.0, tag_prefix="adapter" + ) + + for shape, end_count in ((cone, 0), (frustum, 1)): + self.assertEqual(_role_count(shape, "cone.start"), 1) + self.assertEqual(_role_count(shape, "cone.end"), end_count) + self.assertEqual(_role_count(shape, "cone.side"), 1) + for role in ( + "cone.start_boundary", + "cone.end_boundary", + "cone.seam", + ): + self.assertEqual( + sum(Q.output_role(role)(edge) for edge in shape.get_edges()), 1 + ) + + self.assertEqual( + len(Q.faces().where(Q.tag("tip.face.start")).resolve(cone)), 1 + ) + self.assertEqual( + len(Q.faces().where(Q.tag("tip.face.end")).resolve(cone)), 0 + ) + self.assertEqual( + len(Q.edges().where(Q.tag("tip.edge.end")).resolve(cone)), 1 + ) + self.assertEqual( + len(Q.faces().where(Q.tag("adapter.face.end")).resolve(frustum)), 1 + ) + + def test_native_cone_roles_and_tags_replay(self): + with scad.GraphSession() as session: + cone = scad.make_cone_rsolid( + 2.0, + 4.0, + top_radius=1.0, + tag_prefix="adapter", + start_face_tag="anchor.base", + end_face_tag="role.outlet", + seam_edge_tag="role.seam", + result_tag="part.adapter", + ) + + payload = json.loads(scad.export_model_json(session)) + primitive = next( + node + for node in payload["graph"]["nodes"] + if node["op"] == "make_cone_rsolid" + ) + self.assertNotIn("name", primitive["params"]) + self.assertEqual(len(primitive["topo_delta"]["roles"]), 6) + + replayed = scad.replay_model_json(json.dumps(payload))[0] + for shape in (cone, replayed): + self.assertEqual( + len(Q.faces().where(Q.tag("adapter.face.end")).resolve(shape)), 1 + ) + self.assertEqual( + len(Q.faces().where(Q.tag("role.outlet")).resolve(shape)), 1 + ) + self.assertEqual( + len(Q.edges().where(Q.tag("role.seam")).resolve(shape)), 1 + ) + self.assertIn("part.adapter", scad.list_tags(shape, scope="local")) + self.assertEqual( + sum( + Q.output_role("cone.end_boundary")(edge) + for edge in shape.get_edges() + ), + 1, + ) + + def test_pointed_cone_rejects_absent_end_face_tag(self): + with self.assertRaisesRegex( + scad.SimpleCADError, "requires exactly one kernel-proven result, got 0" + ): + scad.make_cone_rsolid(2.0, 4.0, end_face_tag="role.outlet") + + def test_native_cylinder_roles_and_tags_replay(self): + with scad.GraphSession() as session: + cylinder = scad.make_cylinder_rsolid( + 2.0, + 5.0, + tag_prefix="shaft", + start_face_tag="role.base", + seam_edge_tag="role.seam", + result_tag="part.shaft", + ) + + payload = json.loads(scad.export_model_json(session)) + primitive = next( + node + for node in payload["graph"]["nodes"] + if node["op"] == "make_cylinder_rsolid" + ) + self.assertNotIn("name", primitive["params"]) + self.assertTrue( + any( + node["params"]["tag_binding"]["evidence"].get("topology_name") + for node in payload["graph"]["nodes"] + if node["op"] == "apply_tag_rselection" + ) + ) + + replayed = scad.replay_model_json(json.dumps(payload))[0] + for shape in (cylinder, replayed): + self.assertEqual( + len(Q.faces().where(Q.tag("shaft.face.start")).resolve(shape)), 1 + ) + self.assertEqual( + len(Q.faces().where(Q.tag("role.base")).resolve(shape)), 1 + ) + self.assertEqual( + len(Q.edges().where(Q.tag("role.seam")).resolve(shape)), 1 + ) + self.assertEqual( + len(Q.edges().where(Q.tag("shaft.edge.seam")).resolve(shape)), 1 + ) + self.assertEqual( + sum( + Q.output_role("cylinder.seam")(edge) + for edge in shape.get_edges() + ), + 1, + ) + + def test_detail_feature_role_matrix_is_kernel_backed(self): + box = scad.make_box_rsolid(4.0, 4.0, 4.0) + edge = box.get_edges(0) + filleted = scad.fillet_rsolid(box, [edge], 0.2) + chamfered = scad.chamfer_rsolid(box, [edge], 0.2) + self.assertGreaterEqual(_role_count(filleted, "fillet.patch"), 1) + self.assertGreaterEqual(_role_count(chamfered, "chamfer.patch"), 1) + + shelled = scad.shell_rsolid(box, [box.get_faces(0)], 0.2) + self.assertEqual(_role_count(shelled, "shell.offset_face"), 5) + self.assertEqual(_role_count(shelled, "shell.closing_descendant"), 1) + self.assertEqual(_role_count(shelled, "shell.wall"), 4) + + def test_full_revolve_has_no_cap_roles_and_rejects_cap_tag(self): + profile = scad.make_rectangle_rface(2.0, 1.0, center=(3, 0, 0)) + revolved = scad.revolve_rsolid( + profile, (0, 1, 0), 360.0, (0, 0, 0) + ) + self.assertEqual(_role_count(revolved, "revolution.start"), 0) + self.assertEqual(_role_count(revolved, "revolution.end"), 0) + + with self.assertRaisesRegex( + scad.SimpleCADError, "requires exactly one kernel-proven result, got 0" + ): + scad.revolve_rsolid( + profile, + (0, 1, 0), + 360.0, + (0, 0, 0), + start_face_tag="role.start", + ) + + def test_unavailable_shell_role_fails_instead_of_guessing(self): + box = scad.make_box_rsolid(4.0, 4.0, 4.0) + with self.assertRaisesRegex( + scad.SimpleCADError, "shell.body_face.*kernel-proven" + ): + scad.shell_rsolid( + box, + [box.get_faces(0)], + 0.2, + body_faces_tag="group.body", + ) + + def test_sweep_rejects_profiles_with_inner_wires(self): + outer = scad.make_rectangle_rwire(3.0, 3.0) + inner = scad.make_rectangle_rwire(1.0, 1.0, center=(1.0, 1.0, 0.0)) + profile = scad.make_face_from_wires_rface(outer, [inner]) + path = scad.make_segment_rwire((0, 0, 0), (0, 0, 3)) + + with self.assertRaisesRegex(scad.SimpleCADError, "inner wires are unsupported"): + scad.sweep_rsolid(profile, path) + + +class TestOperationRoleTags(unittest.TestCase): + @staticmethod + def _binding_nodes(payload): + return [ + node + for node in payload["graph"]["nodes"] + if node["op"] == "apply_tag_rselection" + ] + + def test_named_role_tags_lower_to_role_aware_semantic_nodes(self): + with scad.GraphSession() as session: + profile = scad.make_rectangle_rface(5.0, 3.0) + result = scad.extrude_rsolid( + profile, + (0, 0, 1), + 2.0, + start_face_tag="anchor.base", + end_face_tag="role.mounting_surface", + side_faces_tag="group.outer_walls", + result_tag="part.body", + ) + + self.assertEqual( + len(scad.select_faces_by_tag(result, "anchor.base", scope="local")), 1 + ) + self.assertEqual( + len( + scad.select_faces_by_tag( + result, "role.mounting_surface", scope="local" + ) + ), + 1, + ) + self.assertEqual( + len( + scad.select_faces_by_tag( + result, "group.outer_walls", scope="local" + ) + ), + 4, + ) + self.assertIn("part.body", scad.list_tags(result, scope="local")) + + payload = json.loads(scad.export_model_json(session)) + feature = next( + node + for node in payload["graph"]["nodes"] + if node["op"] == "make_extrude_rsolid" + ) + self.assertNotIn("result_tag", feature["params"]) + + binding_nodes = self._binding_nodes(payload) + self.assertEqual(len(binding_nodes), 4) + role_evidence = [ + node["params"]["tag_binding"]["evidence"].get( + "operation_output_role" + ) + for node in binding_nodes + ] + self.assertEqual( + [item["role"] for item in role_evidence if item is not None], + ["extrusion.start", "extrusion.end", "extrusion.side"], + ) + self.assertTrue( + all( + node["params"]["tag_binding"]["producer"]["kind"] + == "user_operation" + for node in binding_nodes + ) + ) + expected_binding_ids = { + node["params"]["tag_binding"]["binding_id"] for node in binding_nodes + } + + replayed = scad.replay_model_json(json.dumps(payload))[0] + self.assertEqual( + len( + scad.select_faces_by_tag( + replayed, "group.outer_walls", scope="local" + ) + ), + 4, + ) + actual_binding_ids = { + explanation["binding_id"] + for shape, tag in [ + (replayed, "part.body"), + *[ + (face, tag) + for tag in ( + "anchor.base", + "role.mounting_surface", + "group.outer_walls", + ) + for face in scad.select_faces_by_tag(replayed, tag, scope="local") + ], + ] + for explanation in scad.explain_tag(shape, tag, scope="local") + } + self.assertEqual(actual_binding_ids, expected_binding_ids) + + def test_shell_tags_face_and_edge_roles_and_replays(self): + with scad.GraphSession() as session: + box = scad.make_box_rsolid(4.0, 4.0, 4.0) + result = scad.shell_rsolid( + box, + [box.get_faces(0)], + 0.2, + offset_faces_tag="group.offset", + closing_faces_tag="group.closing", + wall_edges_tag="group.wall", + ) + + self.assertEqual( + len(scad.select_faces_by_tag(result, "group.offset", scope="local")), 5 + ) + self.assertEqual( + len(scad.select_faces_by_tag(result, "group.closing", scope="local")), + 1, + ) + self.assertEqual( + len(Q.edges().where(Q.tag("group.wall", scope="local")).resolve(result)), + 4, + ) + + replayed = scad.replay_model_json(scad.export_model_json(session))[0] + self.assertEqual( + len(Q.edges().where(Q.tag("group.wall", scope="local")).resolve(replayed)), + 4, + ) + + def test_remaining_feature_output_tag_surfaces_replay(self): + cases = [] + + with scad.GraphSession() as session: + profile = scad.make_rectangle_rface(2.0, 1.0, center=(3, 0, 0)) + result = scad.revolve_rsolid( + profile, + (0, 1, 0), + 180.0, + (0, 0, 0), + start_face_tag="test.revolve.start", + end_face_tag="test.revolve.end", + side_faces_tag="test.revolve.side", + ) + cases.append( + ( + "revolve", + result, + scad.export_model_json(session), + { + "test.revolve.start": 1, + "test.revolve.end": 1, + "test.revolve.side": 4, + }, + ) + ) + + with scad.GraphSession() as session: + profile = scad.make_rectangle_rface(1.0, 1.0) + result = scad.twisted_sweep_rsolid( + profile=profile, + distance=3.0, + twist_angle=30.0, + start_face_tag="test.twisted.start", + end_face_tag="test.twisted.end", + side_faces_tag="test.twisted.side", + ) + cases.append( + ( + "twisted_sweep", + result, + scad.export_model_json(session), + { + "test.twisted.start": 1, + "test.twisted.end": 1, + "test.twisted.side": 4, + }, + ) + ) + + with scad.GraphSession() as session: + first = scad.make_rectangle_rwire(2.0, 2.0) + last = scad.make_rectangle_rwire(1.0, 1.0, center=(0, 0, 2)) + result = scad.loft_rsolid( + [first, last], + start_face_tag="test.loft.start", + end_face_tag="test.loft.end", + side_faces_tag="test.loft.side", + ) + cases.append( + ( + "loft", + result, + scad.export_model_json(session), + { + "test.loft.start": 1, + "test.loft.end": 1, + "test.loft.side": 4, + }, + ) + ) + + with scad.GraphSession() as session: + profile = scad.make_rectangle_rface(1.0, 1.0) + path = scad.make_segment_rwire((0, 0, 0), (0, 0, 3)) + result = scad.sweep_rsolid( + profile, + path, + start_face_tag="test.sweep.start", + end_face_tag="test.sweep.end", + side_faces_tag="test.sweep.side", + ) + cases.append( + ( + "sweep", + result, + scad.export_model_json(session), + { + "test.sweep.start": 1, + "test.sweep.end": 1, + "test.sweep.side": 4, + }, + ) + ) + + for operation, feature, payload, expected in cases: + replayed = scad.replay_model_json(payload)[0] + for tag, count in expected.items(): + with self.subTest(operation=operation, tag=tag): + authored = scad.select_faces_by_tag(feature, tag, scope="local") + rebuilt = scad.select_faces_by_tag(replayed, tag, scope="local") + self.assertEqual(len(authored), count) + self.assertEqual(len(rebuilt), count) + self.assertEqual( + { + item["binding_id"] + for face in authored + for item in scad.explain_tag(face, tag, scope="local") + }, + { + item["binding_id"] + for face in rebuilt + for item in scad.explain_tag(face, tag, scope="local") + }, + ) + + def test_fillet_and_chamfer_patch_tags_replay(self): + for operation in ("fillet", "chamfer"): + with self.subTest(operation=operation): + with scad.GraphSession() as session: + box = scad.make_box_rsolid(4.0, 4.0, 4.0) + edge = box.get_edges(0) + if operation == "fillet": + result = scad.fillet_rsolid( + box, + [edge], + 0.2, + generated_faces_tag="test.fillet.patch", + ) + tag = "test.fillet.patch" + else: + result = scad.chamfer_rsolid( + box, + [edge], + 0.2, + generated_faces_tag="test.chamfer.patch", + ) + tag = "test.chamfer.patch" + + authored = scad.select_faces_by_tag(result, tag, scope="local") + replayed = scad.replay_model_json(scad.export_model_json(session))[0] + rebuilt = scad.select_faces_by_tag(replayed, tag, scope="local") + self.assertGreaterEqual(len(authored), 1) + self.assertEqual(len(rebuilt), len(authored)) + self.assertEqual( + { + item["binding_id"] + for face in authored + for item in scad.explain_tag(face, tag, scope="local") + }, + { + item["binding_id"] + for face in rebuilt + for item in scad.explain_tag(face, tag, scope="local") + }, + ) + + def test_generic_role_tag_mapping_is_not_a_public_entrypoint(self): + profile = scad.make_rectangle_rface(2.0, 1.0) + with self.assertRaises(TypeError): + scad.extrude_rsolid( + profile, + (0, 0, 1), + 1.0, + **{"output_tags": {"extrusion.end": "role.end"}}, + ) + with self.assertRaisesRegex(scad.SimpleCADError, "is not normalized"): + scad.extrude_rsolid( + profile, + (0, 0, 1), + 1.0, + end_face_tag="Not Normalized", + ) + + def test_topology_and_role_tags_share_surface_with_distinct_evidence(self): + with scad.GraphSession(): + box = scad.make_box_rsolid( + 2.0, + 3.0, + 4.0, + tag_prefix="housing", + top_face_tag="role.cover", + result_tag="part.housing", + ) + + top = Q.faces().where(Q.tag("housing.face.top")).exactly(1).resolve(box)[0] + topology_binding = scad.explain_tag( + top, "housing.face.top", scope="local" + )[0]["binding"] + role_binding = scad.explain_tag( + top, "role.cover", scope="local" + )[0]["binding"] + result_binding = scad.explain_tag( + box, "part.housing", scope="local" + )[0]["binding"] + + self.assertIn("topology_name", topology_binding["evidence"]) + self.assertNotIn("operation_output_role", topology_binding["evidence"]) + self.assertIn("operation_output_role", role_binding["evidence"]) + self.assertNotIn("topology_name", role_binding["evidence"]) + self.assertIn("operation_result", result_binding["evidence"]) + self.assertNotIn("topology_name", result_binding["evidence"]) + + def test_profile_edge_binding_projects_with_exact_source_identity(self): + with scad.GraphSession() as session: + profile = scad.make_rectangle_rface(5.0, 3.0) + source_edge = profile.get_edges(0) + profile = scad.apply_tag_rselection( + profile, [source_edge], "role.source_edge" + ) + tagged_source = scad.select_edges_by_tag( + profile, "role.source_edge", scope="local" + )[0] + source_explanation = scad.explain_tag( + tagged_source, "role.source_edge", scope="local" + )[0] + source_binding_id = source_explanation["binding_id"] + source_topo_id = tagged_source.topo_id + result = scad.extrude_rsolid(profile, (0, 0, 1), 2.0) + + projected = ( + Q.faces() + .where(Q.source_binding(source_binding_id)) + .exactly(1) + .resolve(result) + ) + self.assertTrue(Q.source_topology(source_topo_id)(projected[0])) + projected_explanation = scad.explain_tag( + projected[0], "role.source_edge", scope="local" + )[0] + self.assertEqual( + projected_explanation["binding"]["evidence"]["source_binding_id"], + source_binding_id, + ) + + replayed = next( + shape + for shape in scad.replay_model_json(scad.export_model_json(session)) + if isinstance(shape, scad.Solid) + ) + replayed_projected = ( + Q.faces() + .where(Q.source_binding(source_binding_id)) + .exactly(1) + .resolve(replayed) + ) + self.assertTrue(Q.source_topology(source_topo_id)(replayed_projected[0])) + + def test_replay_rejects_role_binding_and_topology_role_tampering(self): + with scad.GraphSession() as session: + profile = scad.make_rectangle_rface(5.0, 3.0) + scad.extrude_rsolid( + profile, (0, 0, 1), 2.0, start_face_tag="anchor.base" + ) + + raw = json.loads(scad.export_model_json(session)) + binding_node = self._binding_nodes(raw)[0] + + damaged = deepcopy(raw) + damaged_binding_node = next( + node + for node in damaged["graph"]["nodes"] + if node["node_id"] == binding_node["node_id"] + ) + binding = damaged_binding_node["params"]["tag_binding"] + binding["evidence"]["operation_output_role"]["role"] = "extrusion.end" + damaged["semantic_bindings"] = [binding] + with self.assertRaisesRegex( + scad.SimpleCADError, "binding targets do not match" + ): + scad.replay_model_json(json.dumps(damaged)) + + damaged = deepcopy(raw) + feature = next( + node + for node in damaged["graph"]["nodes"] + if node["op"] == "make_extrude_rsolid" + ) + feature["topo_delta"]["roles"].pop() + with self.assertRaisesRegex(scad.SimpleCADError, "output-role evidence drifted"): + scad.replay_model_json(json.dumps(damaged)) + + +class TestOperationRoleQL(unittest.TestCase): + def test_role_and_source_predicates_roundtrip(self): + predicates = ( + Q.output_role("Extrusion.End"), + Q.source_binding("tag_binding_source"), + Q.source_topology("edge_0"), + ) + for predicate in predicates: + with self.subTest(kind=predicate.kind): + self.assertEqual( + Q.SerializablePredicate.from_dict(predicate.to_dict()).to_dict(), + predicate.to_dict(), + ) + + def test_predicate_deserialization_rejects_invalid_payloads(self): + invalid = ( + {"kind": "unknown", "data": {}, "children": []}, + {"kind": "output_role", "data": {}, "children": []}, + { + "kind": "source_binding", + "data": {"source_binding_id": "x", "extra": True}, + "children": [], + }, + {"kind": "not", "data": {}, "children": []}, + ) + for payload in invalid: + with self.subTest(payload=payload): + with self.assertRaises(ValueError): + Q.SerializablePredicate.from_dict(payload) + + +if __name__ == "__main__": + unittest.main() diff --git a/SimpleCADAPI/test/test_original_api_integration.py b/SimpleCADAPI/test/test_original_api_integration.py index 104f5f2..0e525ab 100644 --- a/SimpleCADAPI/test/test_original_api_integration.py +++ b/SimpleCADAPI/test/test_original_api_integration.py @@ -61,7 +61,7 @@ class TestOriginalTransformApiIntegration(unittest.TestCase): faces = moved.get_faces() self.assertGreater( len( - Q.select(faces).where(Q.op("make_translate_rshape", "preserved")).all() + Q.select(faces).where(Q.op("make_translate_rshape", "modified")).all() ), 0, ) @@ -74,20 +74,27 @@ class TestOriginalTransformApiIntegration(unittest.TestCase): self.assertEqual(moved.get_metadata("track")["op"], "make_rotate_rshape") faces = moved.get_faces() self.assertGreater( - len(Q.select(faces).where(Q.op("make_rotate_rshape", "preserved")).all()), + len(Q.select(faces).where(Q.op("make_rotate_rshape", "modified")).all()), 0, ) class TestOriginalFeatureApiIntegration(unittest.TestCase): - def test_extrude_rsolid_auto_applies_semantic_tags(self): + def test_extrude_rsolid_exposes_kernel_proven_output_roles(self): profile = scad.make_rectangle_rface(2.0, 1.0) extruded = scad.extrude_rsolid(profile, (0, 0, 1), 2.0) self.assertEqual(extruded.get_metadata("track")["op"], "make_extrude_rsolid") faces = extruded.get_faces() - tagged = Q.select(faces).where(Q.op("make_extrude_rsolid")).all() - self.assertGreater(len(tagged), 0) + self.assertEqual( + len(Q.select(faces).where(Q.output_role("extrusion.start")).all()), 1 + ) + self.assertEqual( + len(Q.select(faces).where(Q.output_role("extrusion.end")).all()), 1 + ) + self.assertEqual( + len(Q.select(faces).where(Q.output_role("extrusion.side")).all()), 4 + ) def test_fillet_rsolid_auto_applies_semantic_tags(self): box = scad.make_box_rsolid(4.0, 4.0, 4.0) diff --git a/SimpleCADAPI/test/test_product_assembly.py b/SimpleCADAPI/test/test_product_assembly.py index 1d54c7f..29397c2 100644 --- a/SimpleCADAPI/test/test_product_assembly.py +++ b/SimpleCADAPI/test/test_product_assembly.py @@ -514,6 +514,152 @@ def test_constraint_graph_model_json_and_replay_roundtrip(): assert solved.get_component("slider").placement.origin == (0.0, 0.0, 4.0) +def test_boolean_named_face_connector_resolves_after_replay(): + with scad.GraphSession() as session: + flange = scad.extrude_rsolid( + scad.make_circle_rface( + center=(0.0, 0.0, 0.0), + radius=2.0, + normal=(1.0, 0.0, 0.0), + ), + direction=(1.0, 0.0, 0.0), + distance=2.0, + end_face_tag="cap.face.end", + ) + body = scad.make_cylinder_rsolid( + radius=1.5, + height=4.0, + bottom_face_center=(-1.0, 0.0, 0.0), + axis=(1.0, 0.0, 0.0), + ) + bridge = scad.make_box_rsolid( + width=1.0, + height=1.0, + depth=1.0, + bottom_face_center=(-0.5, -0.5, -0.5), + ) + fused = scad.union_rsolid(body, flange, bridge, glue=False) + result = scad.cut_rsolid( + fused, + scad.make_cylinder_rsolid( + radius=0.25, + height=4.0, + bottom_face_center=(-1.0, 1.0, 0.0), + axis=(1.0, 0.0, 0.0), + ), + ) + named_face = ( + ql.faces() + .where(ql.tag("cap.face.end")) + .exactly(1) + .resolve(result)[0] + ) + connector = scad.make_face_connector_rconnector("mount", named_face) + part = scad.add_connector_rpart( + scad.make_part_rpart("boolean_named_part", result), + connector, + ) + session.capture_result(value=part) + + replayed = scad.replay_model_json(scad.export_model_json(session))[0] + assert isinstance(replayed, scad.Part) + replayed_face = ( + ql.faces() + .where(ql.tag("cap.face.end")) + .exactly(1) + .resolve(replayed.body)[0] + ) + assert replayed.get_connector("mount").placement.origin == pytest.approx( + tuple(replayed_face.get_center()) + ) + + package = scad.compile_scene( + scene_id="boolean-named-connector", + roots=(scad.SceneRoot(root_id="main", value=replayed),), + ) + snapshot = package.manifest["connectors"][0] + asset = next( + item + for item in package.manifest["entity_assets"] + if item["entity_asset_id"] == snapshot["target"]["entity_asset_id"] + ) + entity_document = json.loads(package.blobs[asset["uri"]]) + target = next( + item + for item in entity_document["entities"] + if item["entity_id"] == snapshot["target"]["entity_id"] + ) + assert "cap.face.end" in target["evaluated_tags"] + + +def test_nested_constraint_graph_replay_preserves_child_assembly_constraints(): + with scad.GraphSession() as session: + part = _part_with_placement_connector("nested_replay_part") + child = scad.make_assembly_rassembly(assembly_id="nested_replay_child") + child = scad.add_component_rassembly( + assembly=child, + item=part, + component_id="base", + placement=scad.identity_placement_rplacement(), + ) + child = scad.add_component_rassembly( + assembly=child, + item=part, + component_id="arm", + placement=scad.identity_placement_rplacement(), + ) + child = scad.ground_component_rassembly( + assembly=child, + component_id="base", + ) + child = scad.add_revolute_constraint_rassembly( + assembly=child, + constraint_id="pivot", + connector_a=scad.make_connector_ref_rconnectorref( + component_id="base", + connector_id="axis", + ), + connector_b=scad.make_connector_ref_rconnectorref( + component_id="arm", + connector_id="axis", + ), + drive_angle_degrees=30.0, + ) + child = scad.solve_assembly_constraints_rassembly(assembly=child) + + root = scad.make_assembly_rassembly(assembly_id="nested_replay_root") + root = scad.add_component_rassembly( + assembly=root, + item=child, + component_id="child", + placement=scad.make_placement_rplacement(origin=(10.0, 20.0, 30.0)), + ) + + payload = json.loads(scad.export_model_json(session)) + ops = [node["op"] for node in payload["graph"]["nodes"]] + assert ops.count("make_revolute_constraint_rassembly") == 1 + assert ops.count("make_solve_assembly_constraints_rassembly") == 1 + assert ops.count("make_add_component_rassembly") == 3 + + replayed = scad.replay_model_json(json.dumps(payload)) + assert len(replayed) == 1 + replayed_root = replayed[0] + assert isinstance(replayed_root, scad.Assembly) + replayed_child = replayed_root.get_component("child").item + assert isinstance(replayed_child, scad.Assembly) + assert replayed_child.assembly_id == "nested_replay_child" + assert replayed_child.component_ids() == ("base", "arm") + assert replayed_child.constraint_ids() == ("pivot",) + assert replayed_child.grounded_component_ids == ("base",) + assert replayed_child.get_constraint("pivot").drive_angle_degrees == 30.0 + assert replayed_child.get_component("arm").placement.x_axis == ( + math.cos(math.radians(30.0)), + math.sin(math.radians(30.0)), + 0.0, + ) + assert replayed_root.get_component("child").placement.origin == (10.0, 20.0, 30.0) + + def test_graph_session_rejects_duplicate_product_ids(): with pytest.raises(Exception, match="duplicate part"): with scad.GraphSession(): diff --git a/SimpleCADAPI/test/test_public_api_surface.py b/SimpleCADAPI/test/test_public_api_surface.py index 2f7e3ee..e20300a 100644 --- a/SimpleCADAPI/test/test_public_api_surface.py +++ b/SimpleCADAPI/test/test_public_api_surface.py @@ -60,3 +60,41 @@ print(json.dumps({ self.assertTrue(payload["has_apply_tag"]) self.assertTrue(payload["has_list_tags"]) self.assertFalse(payload["has_set_tag"]) + + def test_canonical_tagging_exports(self): + import simplecadapi as scad + from simplecadapi import operations, tagging + + expected = { + "apply_tag_rselection": operations.apply_tag_rselection, + "explain_tag": operations.explain_tag, + "LineageDerivation": tagging.LineageDerivation, + "LineagePolicy": tagging.LineagePolicy, + "TagAttachment": tagging.TagAttachment, + "TagBinding": tagging.TagBinding, + "TagBindingScope": tagging.TagBindingScope, + "TagCertainty": tagging.TagCertainty, + "TagEvidence": tagging.TagEvidence, + "TagEvidenceKind": tagging.TagEvidenceKind, + "TagLifecycle": tagging.TagLifecycle, + "TagLineageWitness": tagging.TagLineageWitness, + "TagProducer": tagging.TagProducer, + "TagProducerKind": tagging.TagProducerKind, + "TagPropagation": tagging.TagPropagation, + "TagScope": tagging.TagScope, + "TagTarget": tagging.TagTarget, + "TagTargetKind": tagging.TagTargetKind, + "TopologyPropagation": tagging.TopologyPropagation, + } + + for name, implementation in expected.items(): + with self.subTest(name=name): + self.assertIn(name, scad.__all__) + self.assertIs(getattr(scad, name), implementation) + + def test_tracking_policy_is_public(self): + import simplecadapi as scad + from simplecadapi.tracking import TrackingPolicy + + self.assertIn("TrackingPolicy", scad.__all__) + self.assertIs(scad.TrackingPolicy, TrackingPolicy) diff --git a/SimpleCADAPI/test/test_ql.py b/SimpleCADAPI/test/test_ql.py index e1a05a0..5b65e1e 100644 --- a/SimpleCADAPI/test/test_ql.py +++ b/SimpleCADAPI/test/test_ql.py @@ -2,6 +2,7 @@ import unittest import simplecadapi as scad from simplecadapi import ql as Q +from simplecadapi import tagging class TestQLTagPredicates(unittest.TestCase): @@ -23,6 +24,30 @@ class TestQLTagPredicates(unittest.TestCase): self.assertTrue(Q.tag("face.top")(top_face)) self.assertTrue(Q.tag("face.*")(top_face)) + def test_tag_scope_serializes_and_roundtrips(self): + predicate = Q.tag("role.mounting_surface", scope="inherited") + payload = predicate.to_dict() + restored = Q.SerializablePredicate.from_dict(payload) + + self.assertEqual(payload["kind"], "tag") + self.assertEqual(payload["data"]["scope"], "inherited") + self.assertEqual(restored.to_dict(), payload) + + def test_tag_scope_evaluates_explicit_downward_inheritance(self): + box = scad.make_box_rsolid(1.0, 1.0, 1.0) + box._apply_user_tag("role.body", topology_propagation="downward") + face = box.get_faces(0) + + self.assertFalse(Q.tag("role.body", scope="local")(face)) + self.assertTrue(Q.tag("role.body", scope="inherited")(face)) + self.assertTrue(Q.tag("role.body", scope="effective")(face)) + + def test_lineage_capability_error_is_not_swallowed(self): + vertex = scad.make_point_rvertex(0.0, 0.0, 0.0) + + with self.assertRaises(tagging.UnsupportedQueryCapabilityError): + Q.tag("role.datum", scope="lineage")(vertex) + class TestQLMetadataPredicates(unittest.TestCase): def test_meta_eq_and_compare(self): @@ -229,6 +254,63 @@ class TestSerializableGeometrySelectors(unittest.TestCase): edges = selector.resolve(result) self.assertGreater(len(edges), 0) + def test_incident_face_query_selects_one_named_edge(self): + profile = scad.make_rectangle_rface( + 4.0, + 2.0, + tag_prefix="profile", + edge_tags=["bottom", "right", "top", "left"], + ) + solid = scad.extrude_rsolid( + profile, (0, 0, 1), 3.0, tag_prefix="boss" + ) + selector = ( + Q.faces() + .where(Q.tag("boss.face.start")) + .boundary("edge") + .incident_to( + Q.faces().where(Q.tag("boss.face.start")), + Q.faces().where(Q.tag("boss.face.side.right")), + distinct=True, + ) + .incident_face_count(exactly=2) + .exactly(1) + ) + + edges = selector.resolve(solid) + self.assertEqual(len(edges), 1) + restored = Q.selector_from_dict(selector.to_dict()) + self.assertEqual(len(restored.resolve(solid)), 1) + + def test_shared_boundary_intersection_uses_topology_identity(self): + solid = scad.make_cylinder_rsolid(2.0, 3.0, tag_prefix="shaft") + start = Q.faces().where(Q.tag("shaft.face.start")) + side = Q.faces().where(Q.tag("shaft.face.side")) + shared = start.shared_boundary(side).exactly(1) + self.assertEqual(len(shared.resolve(solid)), 1) + self.assertEqual( + len(Q.selector_from_dict(shared.to_dict()).resolve(solid)), 1 + ) + + first = scad.make_cylinder_rsolid(2.0, 3.0, tag_prefix="first") + second = scad.make_cylinder_rsolid(2.0, 3.0, tag_prefix="second") + self.assertEqual( + Q.solids() + .where(Q.tag("first.solid")) + .shared_boundary(Q.solids().where(Q.tag("first.solid"))) + .resolve(first), + first.get_edges(), + ) + self.assertEqual( + len( + Q.solids() + .where(Q.tag("first.solid")) + .shared_boundary(Q.solids().where(Q.tag("second.solid"))) + .resolve([first, second]) + ), + 0, + ) + def test_selector_exactly_enforces_cardinality(self): box = scad.make_box_rsolid(1.0, 1.0, 1.0) selector = ( diff --git a/SimpleCADAPI/test/test_rearchitecture_2_0_contract.py b/SimpleCADAPI/test/test_rearchitecture_2_0_contract.py index 89b864c..7ae55c6 100644 --- a/SimpleCADAPI/test/test_rearchitecture_2_0_contract.py +++ b/SimpleCADAPI/test/test_rearchitecture_2_0_contract.py @@ -96,9 +96,12 @@ class TestRearchitecture20ExpressionContracts(unittest.TestCase): self.assertIsInstance(expr, scad.Expr) self.assertAlmostEqual(float(expr), 16.0) - def test_expression_system_is_unitless_phase_one(self): + def test_expression_system_preserves_legacy_unitless_variables(self): r = scad.var("r", 10.0) - self.assertFalse(hasattr(r, "unit")) + self.assertIsNone(r.unit) + self.assertIsNone(r.tolerance_unit) + self.assertIsNone(scad.infer_dimension(r)) + self.assertEqual(r.evaluate(), 10.0) def test_expression_values_can_flow_into_public_modeling_apis(self): r = scad.var("r", 10.0) diff --git a/SimpleCADAPI/test/test_rearchitecture_2_0_ops.py b/SimpleCADAPI/test/test_rearchitecture_2_0_ops.py index 32c3a6e..3b41ff7 100644 --- a/SimpleCADAPI/test/test_rearchitecture_2_0_ops.py +++ b/SimpleCADAPI/test/test_rearchitecture_2_0_ops.py @@ -3,8 +3,10 @@ from __future__ import annotations import unittest +from unittest import mock import simplecadapi as scad +from simplecadapi import operations from simplecadapi.graph import GraphSession @@ -92,6 +94,143 @@ class TestRearchitecture20CoreOps(unittest.TestCase): 1, ) + def test_loft_graph_tracking_records_replayable_node_without_topology_delta(self): + with GraphSession() as session: + a = scad.make_rectangle_rwire(2.0, 2.0, center=(0.0, 0.0, 0.0)) + b = scad.make_rectangle_rwire(1.0, 1.0, center=(0.0, 0.0, 2.0)) + solid = scad.loft_rsolid( + [a, b], tracking_policy=scad.TrackingPolicy.GRAPH + ) + session.capture_result(value=solid) + + loft = next( + node for node in session.graph.nodes if node.op == "make_loft_rsolid" + ) + self.assertIsNone(loft.topo_delta) + self.assertEqual(loft.params["tracking_policy"], "graph") + self.assertEqual(len(loft.inputs), 2) + + replayed = scad.replay_model_json(scad.export_model_json(session))[0] + self.assertIsInstance(replayed, scad.Solid) + self.assertAlmostEqual(replayed.get_volume(), solid.get_volume(), places=6) + + def test_loft_graph_tracking_rejects_topology_role_tags(self): + a = scad.make_rectangle_rwire(2.0, 2.0, center=(0.0, 0.0, 0.0)) + b = scad.make_rectangle_rwire(1.0, 1.0, center=(0.0, 0.0, 2.0)) + + with self.assertRaisesRegex( + scad.SimpleCADError, "GRAPH tracking does not provide loft face-role evidence" + ): + scad.loft_rsolid( + [a, b], + tracking_policy=scad.TrackingPolicy.GRAPH, + side_faces_tag="face.loft.side", + ) + + def test_union_graph_tracking_records_replayable_node_without_topology_delta(self): + with GraphSession() as session: + a = scad.make_box_rsolid(2.0, 2.0, 2.0) + b = scad.make_box_rsolid( + 2.0, + 2.0, + 2.0, + bottom_face_center=(1.0, 0.0, 0.0), + ) + solid = scad.union_rsolid( + a, + b, + glue=False, + tracking_policy=scad.TrackingPolicy.GRAPH, + ) + session.capture_result(value=solid) + + union = next( + node for node in session.graph.nodes if node.op == "make_union_rsolid" + ) + self.assertIsNone(union.topo_delta) + self.assertIsNotNone(union.semantic_delta) + self.assertEqual(union.params["tracking_policy"], "graph") + self.assertEqual(len(union.inputs), 2) + self.assertNotIn("has_delta", solid.get_metadata("track")) + + replayed = scad.replay_model_json(scad.export_model_json(session))[0] + self.assertIsInstance(replayed, scad.Solid) + self.assertAlmostEqual(replayed.get_volume(), solid.get_volume(), places=6) + + def test_cut_graph_tracking_records_replayable_node_without_topology_delta(self): + with GraphSession() as session: + body = scad.make_box_rsolid(4.0, 4.0, 4.0) + tool = scad.make_cylinder_rsolid( + 0.75, + 6.0, + bottom_face_center=(0.0, 0.0, -1.0), + ) + solid = scad.cut_rsolid( + body, + tool, + skip_non_intersecting=False, + tracking_policy=scad.TrackingPolicy.GRAPH, + ) + session.capture_result(value=solid) + + cut = next( + node for node in session.graph.nodes if node.op == "make_cut_rsolid" + ) + self.assertIsNone(cut.topo_delta) + self.assertIsNotNone(cut.semantic_delta) + self.assertEqual(cut.params["tracking_policy"], "graph") + self.assertEqual(len(cut.inputs), 2) + self.assertNotIn("has_delta", solid.get_metadata("track")) + self.assertIn("solid.boolean.cut", scad.list_tags(solid, scope="local")) + + replayed = scad.replay_model_json(scad.export_model_json(session))[0] + self.assertIsInstance(replayed, scad.Solid) + self.assertAlmostEqual(replayed.get_volume(), solid.get_volume(), places=6) + + def test_graph_booleans_do_not_call_topology_history_helpers(self): + body = scad.make_box_rsolid(4.0, 4.0, 4.0) + cutter = scad.make_cylinder_rsolid( + 0.75, + 6.0, + bottom_face_center=(0.0, 0.0, -1.0), + ) + rib = scad.make_box_rsolid( + 2.0, + 2.0, + 2.0, + bottom_face_center=(1.5, 0.0, 0.0), + ) + + with mock.patch.object( + operations, + "tracked_cut", + side_effect=AssertionError("GRAPH cut queried topology history"), + ): + cut = scad.cut_rsolid( + body, + cutter, + tracking_policy=scad.TrackingPolicy.GRAPH, + ) + + with mock.patch.object( + operations, + "fuse_shapes_with_history", + side_effect=AssertionError("GRAPH union requested topology history"), + ), mock.patch.object( + operations, + "track_union_history", + side_effect=AssertionError("GRAPH union queried topology history"), + ): + fused = scad.union_rsolid( + body, + rib, + glue=False, + tracking_policy=scad.TrackingPolicy.GRAPH, + ) + + self.assertLess(cut.get_volume(), body.get_volume()) + self.assertGreater(fused.get_volume(), body.get_volume()) + def test_sweep_produces_topology_delta_at_runtime(self): profile = scad.make_circle_rface((0.0, 0.0, 0.0), 0.5) path = scad.make_segment_rwire((0.0, 0.0, 0.0), (0.0, 0.0, 3.0)) @@ -127,6 +266,93 @@ class TestRearchitecture20CoreOps(unittest.TestCase): 1, ) + def test_twisted_sweep_produces_replayable_topology_delta(self): + with GraphSession() as session: + profile = scad.make_rectangle_rface(width=2.0, height=1.0) + solid = scad.twisted_sweep_rsolid( + profile=profile, + distance=5.0, + twist_angle=-45.0, + ) + session.capture_result(value=solid) + + node = next( + item + for item in session.graph.nodes + if item.op == "make_twisted_sweep_rsolid" + ) + self.assertIsNotNone(node.topo_delta) + self.assertEqual(len(solid.get_faces()), 6) + + replayed = scad.replay_model_json( + scad.export_model_json(session), strict=True + )[0] + self.assertEqual(len(replayed.get_faces()), 6) + self.assertAlmostEqual(replayed.get_volume(), solid.get_volume(), places=8) + + def test_twisted_sweep_supports_expression_parameters_and_arbitrary_axis(self): + distance = scad.var("twisted_distance", 5.0) + angle = scad.var("twisted_angle", 45.0) + with GraphSession() as session: + profile = scad.make_rectangle_rface( + width=2.0, + height=1.0, + normal=(1.0, 1.0, 1.0), + ) + solid = scad.twisted_sweep_rsolid( + profile=profile, + distance=distance, + twist_angle=angle, + axis=(1.0, 1.0, 1.0), + ) + session.capture_result(value=solid) + + node = next( + item + for item in session.graph.nodes + if item.op == "make_twisted_sweep_rsolid" + ) + self.assertEqual(set(node.param_exprs), {"distance", "twist_angle"}) + self.assertEqual(len(solid.get_faces()), 6) + + replayed = scad.replay_model_json( + scad.export_model_json(session), strict=True + )[0] + self.assertAlmostEqual(replayed.get_volume(), solid.get_volume(), places=8) + + def test_twisted_sweep_rejects_inner_wires_and_invalid_axis(self): + outer = scad.make_circle_rface(center=(0.0, 0.0, 0.0), radius=2.0) + inner = scad.make_circle_rface(center=(0.0, 0.0, 0.0), radius=1.0) + ring = scad.make_2d_cut_rface(body=outer, tool=inner) + + with self.assertRaises(scad.SimpleCADError): + scad.twisted_sweep_rsolid( + profile=ring, + distance=2.0, + twist_angle=20.0, + ) + with self.assertRaises(scad.SimpleCADError): + scad.twisted_sweep_rsolid( + profile=outer, + distance=2.0, + twist_angle=20.0, + axis=(0.0, 0.0, 0.0), + ) + with self.assertRaises(scad.SimpleCADError): + scad.twisted_sweep_rsolid( + profile=outer, + distance=2.0, + twist_angle=20.0, + axis=(1.0, 0.0, 0.0), + ) + offset = scad.translate_shape(outer, vector=(0.0, 0.0, 1.0)) + with self.assertRaises(scad.SimpleCADError): + scad.twisted_sweep_rsolid( + profile=offset, + distance=2.0, + twist_angle=20.0, + ) + def test_fillet_accepts_expression_radius_and_records_param_expr(self): radius = scad.var("fillet_r", 0.2) with GraphSession() as session: diff --git a/SimpleCADAPI/test/test_scene_compiler.py b/SimpleCADAPI/test/test_scene_compiler.py new file mode 100644 index 0000000..c4313c0 --- /dev/null +++ b/SimpleCADAPI/test/test_scene_compiler.py @@ -0,0 +1,534 @@ +from __future__ import annotations + +import hashlib +import json +from pathlib import Path + +import pytest + +import simplecadapi as scad +from simplecadapi import _mesh +from simplecadapi.scene import ( + export_scene, + parse_canonical_json, + preflight_zip_bytes, + validate_scene_manifest, + validate_scene_package, + with_scene_revision, +) + + +def _manual_source() -> scad.SceneSource: + return scad.SceneSource(kind="manual", source_id="main") + + +def _mutable_package(package, tmp_path, name): + archive_path = tmp_path / f"{name}.scene.zip" + export_scene(package=package, path=archive_path) + archive = preflight_zip_bytes(archive_path.read_bytes()) + manifest = parse_canonical_json(archive.members["scene.json"]) + blobs = { + uri: payload + for uri, payload in archive.members.items() + if uri != "scene.json" + } + return manifest, blobs + + +def _replace_embedded_model(manifest, blobs, model): + payload = json.dumps(model, sort_keys=True, separators=(",", ":")).encode( + "utf-8" + ) + blobs["model/model.json"] = payload + manifest["source"]["artifact_hash"] = ( + "sha256:" + hashlib.sha256(payload).hexdigest() + ) + manifest["source"]["embedded_artifact_byte_length"] = len(payload) + return with_scene_revision(manifest) + + +@pytest.fixture +def embedded_source_package(): + @scad.model(graph_id="source-boundaries") + def build_model(): + return scad.make_box_rsolid(width=2.0, height=3.0, depth=4.0) + + result = build_model() + return scad.compile_scene( + scene_id="source-boundaries", + roots=(scad.SceneRoot(root_id="main", value=result.value),), + source=result, + options=scad.SceneCompileOptions(embed_source=True), + ) + + +def test_compile_scene_is_deterministic_and_self_validating(): + solid = scad.make_box_rsolid(width=10.0, height=20.0, depth=30.0) + roots = (scad.SceneRoot(root_id="main", value=solid),) + + first = scad.compile_scene(scene_id="box", roots=roots, source=_manual_source()) + second = scad.compile_scene(scene_id="box", roots=roots, source=_manual_source()) + + assert first.manifest == second.manifest + assert dict(first.blobs) == dict(second.blobs) + assert validate_scene_package(first.manifest, first.blobs).valid + assert first.manifest["revision"].startswith("sha256:") + + +def test_embedded_python_source_is_integrity_checked(tmp_path): + @scad.model(graph_id="source-integrity") + def build_model(): + return scad.make_box_rsolid(width=2.0, height=3.0, depth=4.0) + + result = build_model() + package = scad.compile_scene( + scene_id="source-integrity", + roots=(scad.SceneRoot(root_id="main", value=result.value),), + source=result, + options=scad.SceneCompileOptions(embed_source=True), + ) + source_record = package.manifest["source"]["source_files"][0] + + assert source_record["path"] == "test/test_scene_compiler.py" + assert package.blobs[source_record["uri"]] == Path(__file__).read_bytes() + assert validate_scene_package(package.manifest, package.blobs).valid + + tampered = dict(package.blobs) + tampered[source_record["uri"]] += b"\n# tampered\n" + report = validate_scene_package(package.manifest, tampered) + assert not report.valid + assert {issue.code for issue in report.issues} >= { + "blob_hash_mismatch", + "blob_length_mismatch", + } + + archive_path = tmp_path / "source-integrity.scene.zip" + export_scene(package=package, path=archive_path) + archive = preflight_zip_bytes(archive_path.read_bytes()) + malformed_manifest = parse_canonical_json(archive.members["scene.json"]) + malformed_manifest["source"]["source_files"][0]["uri"] = "sources/wrong.py" + malformed_manifest = with_scene_revision(malformed_manifest) + report = validate_scene_package(malformed_manifest, package.blobs) + assert not report.valid + assert "source_matrix_invalid" in {issue.code for issue in report.issues} + + +def test_model_scene_rejects_root_from_different_graph_with_same_graph_id(): + @scad.model(graph_id="shared-provenance-id") + def first_model(): + return scad.make_box_rsolid(width=1.0, height=2.0, depth=3.0) + + @scad.model(graph_id="shared-provenance-id") + def second_model(): + return scad.make_box_rsolid(width=4.0, height=5.0, depth=6.0) + + first = first_model() + second = second_model() + + with pytest.raises(ValueError, match="not owned by the source model graph"): + scad.compile_scene( + scene_id="cross-graph-root", + roots=(scad.SceneRoot(root_id="main", value=first.value),), + source=second, + ) + + +def test_model_scene_rejects_stale_model_result_snapshot(): + @scad.model(graph_id="stale-model-snapshot") + def build_model(): + return scad.make_box_rsolid(width=1.0, height=2.0, depth=3.0) + + result = build_model() + result.session.graph.add_node("late_mutation", node_id="late_mutation") + + with pytest.raises(ValueError, match="no longer matches its model JSON snapshot"): + scad.compile_scene( + scene_id="stale-model-snapshot", + roots=(scad.SceneRoot(root_id="main", value=result.value),), + source=result, + ) + + +def test_manifest_rejects_unsafe_embedded_python_path( + embedded_source_package, tmp_path +): + manifest, _blobs = _mutable_package( + embedded_source_package, tmp_path, "unsafe-source-path" + ) + source_file = manifest["source"]["source_files"][0] + source_file["path"] = "test/../test_scene_compiler.py" + source_file["uri"] = "sources/test/../test_scene_compiler.py" + report = validate_scene_manifest(with_scene_revision(manifest)) + + assert any( + issue.code == "source_matrix_invalid" + and issue.path == "/source/source_files/0/path" + for issue in report.issues + ) + + +def test_package_rejects_malformed_operation_source_mapping( + embedded_source_package, tmp_path +): + manifest, blobs = _mutable_package( + embedded_source_package, tmp_path, "malformed-operation-source" + ) + model = json.loads(blobs["model/model.json"]) + node = next(node for node in model["graph"]["nodes"] if "source" in node) + node["source"].pop("callsite_id") + manifest = _replace_embedded_model(manifest, blobs, model) + report = validate_scene_package(manifest, blobs) + + assert any( + issue.code == "source_matrix_invalid" + and issue.path.endswith("/graph/nodes/0/source") + for issue in report.issues + ) + + +def test_package_rejects_invalid_utf8_python_source( + embedded_source_package, tmp_path +): + manifest, blobs = _mutable_package( + embedded_source_package, tmp_path, "invalid-source-utf8" + ) + source_file = manifest["source"]["source_files"][0] + payload = b"\xff" + blobs[source_file["uri"]] = payload + source_file["byte_length"] = len(payload) + source_file["content_hash"] = "sha256:" + hashlib.sha256(payload).hexdigest() + report = validate_scene_package(with_scene_revision(manifest), blobs) + + assert any( + issue.code == "invalid_utf8" and issue.path == f"/{source_file['uri']}" + for issue in report.issues + ) + + +def test_sphere_entity_uses_kernel_axis_directions(): + solid = scad.make_sphere_rsolid(radius=2.5, center=(1.0, 2.0, 3.0)) + package = scad.compile_scene( + scene_id="sphere", + roots=(scad.SceneRoot(root_id="main", value=solid),), + source=_manual_source(), + ) + entity_uri = package.manifest["entity_assets"][0]["uri"] + entity_document = parse_canonical_json(package.blobs[entity_uri]) + sphere = next( + entity + for entity in entity_document["entities"] + if entity["kind"] == "face" + ) + + assert sphere["geometry"] == { + "type": "sphere", + "center": [1.0, 2.0, 3.0], + "axis": [0.0, 0.0, 1.0], + "x_direction": [1.0, 0.0, 0.0], + "radius": 2.5, + } + + +def test_repeated_part_occurrences_reuse_definition_and_assets(): + part = scad.make_part_rpart( + part_id="block", + body=scad.make_box_rsolid(width=4.0, height=5.0, depth=6.0), + ) + assembly = scad.make_assembly_rassembly(assembly_id="root") + assembly = scad.add_component_rassembly( + assembly=assembly, + item=part, + component_id="first", + placement=scad.identity_placement_rplacement(), + ) + assembly = scad.add_component_rassembly( + assembly=assembly, + item=part, + component_id="second", + placement=scad.make_placement_rplacement(origin=(10.0, 0.0, 0.0)), + ) + + package = scad.compile_scene( + scene_id="repeated", + roots=(scad.SceneRoot(root_id="main", value=assembly),), + source=_manual_source(), + ) + definitions = package.manifest["definitions"] + nodes = package.manifest["nodes"] + part_definitions = [item for item in definitions if item["kind"] == "part"] + + assert len(part_definitions) == 1 + assert [item["node_id"] for item in nodes] == [ + "instance/main", + "instance/main/first", + "instance/main/second", + ] + assert len(package.manifest["geometry_assets"]) == 1 + assert len(package.manifest["edge_assets"]) == 1 + assert len(package.manifest["entity_assets"]) == 1 + + +def test_same_part_id_with_different_body_geometry_is_rejected(): + first = scad.make_part_rpart( + part_id="shared", + body=scad.make_box_rsolid(width=4.0, height=5.0, depth=6.0), + ) + second = scad.make_part_rpart( + part_id="shared", + body=scad.make_box_rsolid(width=7.0, height=5.0, depth=6.0), + ) + assembly = scad.make_assembly_rassembly(assembly_id="root") + assembly = scad.add_component_rassembly( + assembly=assembly, + item=first, + component_id="first", + placement=scad.identity_placement_rplacement(), + ) + assembly = scad.add_component_rassembly( + assembly=assembly, + item=second, + component_id="second", + placement=scad.identity_placement_rplacement(), + ) + + with pytest.raises(ValueError, match="conflicting Part body geometry"): + scad.compile_scene( + scene_id="conflict", + roots=(scad.SceneRoot(root_id="main", value=assembly),), + source=_manual_source(), + ) + + +def test_manual_face_connector_is_exported_with_entity_target(): + solid = scad.make_box_rsolid(width=4.0, height=5.0, depth=6.0) + connector = scad.make_face_connector_rconnector( + connector_id="mount", + face=solid.get_faces()[0], + ) + part = scad.make_part_rpart(part_id="block", body=solid) + part = scad.add_connector_rpart(part=part, connector=connector) + + package = scad.compile_scene( + scene_id="connector", + roots=(scad.SceneRoot(root_id="main", value=part),), + source=_manual_source(), + ) + snapshot = package.manifest["connectors"][0] + + assert snapshot["anchor_kind"] == "geometry" + assert snapshot["target"]["entity_id"].startswith("entity/face/") + assert snapshot["target"]["entity_asset_id"] == package.manifest["definitions"][0]["entity_asset_id"] + assert snapshot["source"] == {"kind": "manual", "source_id": "main"} + + +def test_render_and_collision_use_the_same_default_mesh_object(): + solid = scad.make_box_rsolid(width=10.0, height=20.0, depth=30.0) + cached = _mesh.cached_mesh(solid) + assert cached is not None + + render = scad.build_render_mesh( + solid, + face_entity_ids=[f"entity/face/{index}" for index in range(len(solid.get_faces()))], + linear_tolerance=0.35, + angular_tolerance=0.22, + ) + + assert _mesh.cached_mesh(solid) is cached + assert len(render.indices) == cached.triangle_count * 3 + + +def test_edge_mesh_uses_angular_tolerance_for_curved_edges(): + solid = scad.make_cylinder_rsolid(radius=2.0, height=5.0) + edge_ids = [f"edge-{index}" for index, _edge in enumerate(solid.get_edges())] + + default = scad.build_edge_mesh( + solid, + edge_entity_ids=edge_ids, + linear_tolerance=0.35, + angular_tolerance=0.22, + ) + finer = scad.build_edge_mesh( + solid, + edge_entity_ids=edge_ids, + linear_tolerance=0.35, + angular_tolerance=0.1, + ) + + default_counts = sorted(len(block.segments) for block in default.blocks) + finer_counts = sorted(len(block.segments) for block in finer.blocks) + assert default_counts == [1, 29, 29] + assert finer_counts == [1, 63, 63] + + +def test_edge_mesh_default_angular_tolerance_preserves_public_call_shape(): + solid = scad.make_cylinder_rsolid(radius=2.0, height=5.0) + edge_ids = [f"edge-{index}" for index, _edge in enumerate(solid.get_edges())] + + mesh = scad.build_edge_mesh( + solid, + edge_entity_ids=edge_ids, + linear_tolerance=0.35, + ) + + assert sorted(len(block.segments) for block in mesh.blocks) == [1, 29, 29] + + +def test_compiler_declares_angular_edge_render_profile(): + package = scad.compile_scene( + scene_id="edge-profile", + roots=( + scad.SceneRoot( + root_id="main", + value=scad.make_cylinder_rsolid(radius=2.0, height=5.0), + ), + ), + source=_manual_source(), + ) + + assert package.manifest["generator"]["profile"] == "scene-1.0-ocp-glb-2" + assert package.manifest["edge_assets"][0]["tessellation"] == { + "linear_tolerance": 0.35 + } + assert validate_scene_package(package.manifest, package.blobs).valid + + +def test_validator_keeps_profile_1_scene_packages_readable(tmp_path): + package = scad.compile_scene( + scene_id="legacy-profile", + roots=( + scad.SceneRoot( + root_id="main", + value=scad.make_box_rsolid(width=2.0, height=3.0, depth=4.0), + ), + ), + source=_manual_source(), + ) + path = tmp_path / "legacy-profile.scene.zip" + export_scene(package=package, path=path) + archive = preflight_zip_bytes(path.read_bytes()) + manifest = parse_canonical_json(archive.members["scene.json"]) + manifest["generator"]["profile"] = "scene-1.0-ocp-glb-1" + manifest = with_scene_revision(manifest) + + assert validate_scene_package(manifest, package.blobs).valid + + +def test_export_scene_is_canonical_and_round_trips_through_archive_preflight(tmp_path): + package = scad.compile_scene( + scene_id="archive", + roots=(scad.SceneRoot(root_id="main", value=scad.make_box_rsolid(width=2.0, height=3.0, depth=4.0)),), + source=_manual_source(), + ) + first_path = tmp_path / "first.scene.zip" + second_path = tmp_path / "second.scene.zip" + export_scene(package=package, path=first_path) + export_scene(package=package, path=second_path) + + first_bytes = first_path.read_bytes() + assert first_bytes == second_path.read_bytes() + archive = preflight_zip_bytes(first_bytes) + manifest = parse_canonical_json(archive.members["scene.json"]) + blobs = {name: payload for name, payload in archive.members.items() if name != "scene.json"} + report = validate_scene_package(manifest, blobs) + assert report.valid, report.issues + + +def test_forwarded_connector_is_finalized_after_its_source_connector(): + part = scad.make_part_rpart( + part_id="inner", + body=scad.make_box_rsolid(width=2.0, height=2.0, depth=2.0), + ) + part = scad.add_connector_rpart( + part=part, + connector=scad.make_placement_connector_rconnector( + connector_id="axis", + placement=scad.make_placement_rplacement(origin=(1.0, 0.0, 0.0)), + ), + ) + child = scad.make_assembly_rassembly(assembly_id="child") + child = scad.add_component_rassembly( + assembly=child, + item=part, + component_id="inner", + placement=scad.make_placement_rplacement(origin=(5.0, 0.0, 0.0)), + ) + child = scad.forward_connector_rassembly( + assembly=child, + connector_id="public", + source_component_id="inner", + source_connector_id="axis", + ) + root = scad.make_assembly_rassembly(assembly_id="root") + root = scad.add_component_rassembly( + assembly=root, + item=child, + component_id="child", + placement=scad.identity_placement_rplacement(), + ) + + package = scad.compile_scene( + scene_id="forwarded", + roots=(scad.SceneRoot(root_id="main", value=root),), + source=_manual_source(), + ) + + forwarded = next(item for item in package.manifest["connectors"] if item["connector_id"] == "public") + source = next(item for item in package.manifest["connectors"] if item["connector_id"] == "axis") + assert forwarded["forwarded_from"]["source_connector_snapshot_id"] == source["connector_snapshot_id"] + assert validate_scene_package(package.manifest, package.blobs).valid + + +def test_model_connector_sources_use_product_attachment_operations(): + @scad.model(graph_id="connector_provenance") + def build_model(): + body = scad.make_box_rsolid(width=2.0, height=2.0, depth=2.0) + part = scad.make_part_rpart(part_id="block", body=body) + part = scad.add_connector_rpart( + part=part, + connector=scad.make_placement_connector_rconnector( + connector_id="axis", + placement=scad.identity_placement_rplacement(), + ), + ) + assembly = scad.make_assembly_rassembly(assembly_id="root") + assembly = scad.add_component_rassembly( + assembly=assembly, + item=part, + component_id="block", + placement=scad.identity_placement_rplacement(), + ) + assembly = scad.forward_connector_rassembly( + assembly=assembly, + connector_id="public_axis", + source_component_id="block", + source_connector_id="axis", + ) + assembly = scad.place_component_rassembly( + assembly=assembly, + component_id="block", + placement=scad.make_placement_rplacement(origin=(1.0, 0.0, 0.0)), + ) + scad.capture_result(value=assembly) + return assembly + + result = build_model() + package = scad.compile_scene( + scene_id="connector-provenance", + roots=(scad.SceneRoot(root_id="main", value=result.value),), + source=result, + ) + graph_nodes = { + node.node_id: node for node in result.session.graph.nodes + } + sources = { + connector["connector_id"]: connector["source"] + for connector in package.manifest["connectors"] + } + + assert graph_nodes[sources["axis"]["node_id"]].op == "make_add_connector_rpart" + assert ( + graph_nodes[sources["public_axis"]["node_id"]].op + == "make_forward_connector_rassembly" + ) + assert sources["axis"]["node_id"] != sources["public_axis"]["node_id"] + assert validate_scene_package(package.manifest, package.blobs).valid diff --git a/SimpleCADAPI/test/test_scene_contract.py b/SimpleCADAPI/test/test_scene_contract.py new file mode 100644 index 0000000..9b57f85 --- /dev/null +++ b/SimpleCADAPI/test/test_scene_contract.py @@ -0,0 +1,1859 @@ +import base64 +import hashlib +import json +import math +import re +import struct +import subprocess +import sys +from collections.abc import Mapping +from copy import deepcopy +from dataclasses import FrozenInstanceError, asdict, is_dataclass, replace +from pathlib import Path +from types import MappingProxyType +from types import SimpleNamespace + +import pytest +from jsonschema import Draft202012Validator +from referencing import Registry, Resource +from referencing.jsonschema import DRAFT202012 +import simplecadapi.scene.archive as scene_archive + +from simplecadapi.scene import ( + BASE_LIMITS, + ConnectorBindingDocument, + EntityDocument, + NormalizedProductDocument, + PresentationDocument, + SCHEMA_FILES, + SceneContractError, + SceneDocument, + SceneResourceLimits, + canonical_json_bytes, + canonical_zip_bytes, + parse_canonical_json, + parse_strict_json, + preflight_aggregate_compression_ratio, + preflight_archive_member_sizes, + preflight_glb, + preflight_glb_counts, + preflight_input_archive_size, + preflight_member_compression_ratio, + preflight_zip_bytes, + profile_cross, + profile_f32_bits, + profile_normalize, + validate_entity_asset, + validate_connector_binding, + validate_normalized_product, + validate_presentation, + validate_scene_manifest, + validate_scene_package, + json_resource_issues, + resource_count_issues, + with_scene_revision, +) +from simplecadapi.scene.validation import ( + _compute_package_budget_totals, + _issue, + _package_budget_issues, + _report, + _validate_rule_registry, + load_contract_artifact, +) +from scene_contract_corpus_support import ( + apply_scene_field_case, + corpus_blobs, + scene_shape_facts, +) + + +IDENTITY = { + "origin": [0, 0, 0], + "x_axis": [1, 0, 0], + "y_axis": [0, 1, 0], + "z_axis": [0, 0, 1], +} +ASSET_TO_SCENE = [1000, 0, 0, 0, 0, 0, -1000, 0, 0, 1000, 0, 0, 0, 0, 0, 1] + + +def _f32(value): + result = struct.unpack("d", bytes.fromhex(value))[0] + + +def _f64_hex(value): + return struct.pack(">d", value).hex() + + +def _first_report_issue(report): + issue = report.first_error + return None if issue is None else {"code": issue.code, "path": issue.path} + + +def _replace_glb_json(glb, mutate): + json_length = struct.unpack_from(" 1: + occurrences = [ + node for node in scene["nodes"] if node["definition_id"] == definition_id + ] + assert len(occurrences) == count + assert len({node["definition_id"] for node in occurrences}) == 1 + assert definitions[definition_id]["geometry_asset_id"] is not None + + +def test_python_matches_shared_two_pass_revision_vectors(): + for vector in _load_shared_corpus()["revision_vectors"]: + draft_bytes = _decode_base64(vector["draft_base64"]) + draft = parse_canonical_json(draft_bytes) + scene_bytes = _decode_base64(vector["canonical_base64"]) + scene = with_scene_revision(draft) + + assert canonical_json_bytes(draft) == draft_bytes, vector["name"] + assert scene["revision"] == vector["revision"], vector["name"] + assert canonical_json_bytes(scene) == scene_bytes, vector["name"] + assert "sha256:" + hashlib.sha256(scene_bytes).hexdigest() == vector["sha256"] + + +def test_python_matches_all_shared_glb_and_zip_cases(): + corpus = _load_shared_corpus() + for case in corpus["glb_cases"]: + payload = _decode_base64(case["payload_base64"]) + if case["valid"]: + assert preflight_glb(payload, expected_kind=case["expected_kind"]).kind == case["kind"] + else: + with pytest.raises(ValueError, match="^" + re.escape(case["error"]) + "$"): + preflight_glb(payload, expected_kind=case["expected_kind"]) + for case in corpus["zip_cases"]: + payload = _decode_base64(case["payload_base64"]) + if case["valid"]: + assert preflight_zip_bytes(payload).used_deflate is case["used_deflate"] + else: + with pytest.raises(ValueError, match="^" + re.escape(case["error"]) + "$"): + preflight_zip_bytes(payload) + + +def test_python_matches_shared_numeric_profile_vectors(): + for vector in _load_shared_corpus()["numeric_vectors"]: + try: + if vector["operation"] == "f32": + actual = f"{profile_f32_bits(_f64_from_hex(vector['input_bits'][0])):08x}" + elif vector["operation"] == "cross": + left, right = ( + tuple(_f64_from_hex(component) for component in value) + for value in vector["input_bits"] + ) + actual = [_f64_hex(component) for component in profile_cross(left, right)] + elif vector["operation"] == "normalize": + value = tuple( + _f64_from_hex(component) for component in vector["input_bits"][0] + ) + actual = [ + f"{profile_f32_bits(component):08x}" + for component in profile_normalize(value) + ] + else: + raise AssertionError(vector["operation"]) + except ValueError as exc: + assert not vector["valid"], vector["name"] + assert str(exc) == vector["error"], vector["name"] + else: + assert vector["valid"], vector["name"] + assert actual == vector["expected_bits"], vector["name"] + + +def test_python_matches_shared_resource_boundaries(): + corpus = _load_shared_corpus() + assert asdict(BASE_LIMITS) == corpus["resource_limits"] + valid_scene = parse_canonical_json( + _decode_base64(corpus["artifacts"]["scene"]["base64"]) + ) + + for case in corpus["resource_cases"]: + operation = case["operation"] + parameters = case["parameters"] + limits = replace(BASE_LIMITS, **parameters.get("limits", {})) + try: + if operation == "input_archive_size": + preflight_input_archive_size(parameters["size"], limits=limits) + valid, error, first = True, None, None + elif operation in {"archive_member_sizes", "archive_member_count"}: + if operation == "archive_member_sizes": + sizes = parameters["sizes"] + else: + sizes = {"scene.json": 0} + sizes.update( + { + f"x/{index:05d}": 0 + for index in range(parameters["count"] - 1) + } + ) + preflight_archive_member_sizes(sizes, limits=limits) + valid, error, first = True, None, None + elif operation in { + "aggregate_compression_ratio", + "member_compression_ratio", + }: + callback = ( + preflight_aggregate_compression_ratio + if operation == "aggregate_compression_ratio" + else preflight_member_compression_ratio + ) + callback( + parameters["uncompressed_size"], + parameters["compressed_size"], + limits=limits, + ) + valid, error, first = True, None, None + elif operation == "json_depth": + value = {} + current = value + for _index in range(parameters["depth"]): + current["x"] = {} + current = current["x"] + issues = json_resource_issues(value, limits=limits) + valid = not issues + error = None + first = ( + None + if not issues + else {"code": issues[0].code, "path": issues[0].path} + ) + elif operation == "json_domain": + text = parameters["text"] + kind = parameters["kind"] + field = parameters.get("field") + if kind == "value": + value = {"value": text} + elif kind == "object_key": + value = {text: 0} + elif kind == "metadata_key": + value = {"metadata": {text: 0}} + elif kind == "sdk_metadata_key": + value = {"sdk_metadata": {text: 0}} + elif kind == "identifier": + value = {field or "node_id": text} + elif kind == "identifier_array": + value = {field or "component_path": [text]} + elif kind == "uri": + value = {"uri": text} + else: + raise AssertionError(kind) + issues = json_resource_issues(value, limits=limits) + valid = not issues + error = None + first = ( + None + if not issues + else {"code": issues[0].code, "path": issues[0].path} + ) + elif operation == "resource_count": + count = parameters["count"] + kind = parameters["kind"] + field = parameters.get("field") + if kind == "collection": + value = {field: [None] * count} + elif kind == "hierarchy": + value = { + "nodes": [ + {"source": {"component_path": ["x"] * count}} + ] + } + elif kind == "forwarded": + value = { + "connectors": [ + { + "anchor_kind": "forwarded", + "connector_snapshot_id": f"c{index}", + "forwarded_from": { + "source_connector_snapshot_id": f"c{index + 1}" + }, + } + for index in range(count) + ] + } + else: + raise AssertionError(kind) + issues = resource_count_issues( + value, parameters["artifact"], limits=limits + ) + valid = not issues + error = None + first = ( + None + if not issues + else {"code": issues[0].code, "path": issues[0].path} + ) + elif operation == "glb_counts": + preflight_glb_counts( + parameters["kind"], + parameters["vertex_count"], + parameters["index_count"], + limits=limits, + ) + valid, error, first = True, None, None + elif operation == "package_budget": + contributions = parameters["contributions"] + totals = _compute_package_budget_totals(**contributions) + result = _report( + _package_budget_issues(totals, limits=limits), + artifact="package", + ) + valid, error, first = result.valid, None, _first_report_issue(result) + elif operation == "scene_geometry_byte_length": + value = deepcopy(valid_scene) + byte_length = int(parameters["value"]) + value["geometry_assets"][0]["byte_length"] = byte_length + if byte_length <= 9_007_199_254_740_991: + value = with_scene_revision(value) + result = validate_scene_manifest(value) + valid, error, first = result.valid, None, _first_report_issue(result) + elif operation == "scene_compile_option": + value = deepcopy(valid_scene) + field, number = parameters["field"], parameters["value"] + value["compile_options"][field] = number + value["geometry_assets"][0]["tessellation"][field] = number + if field == "linear_tolerance": + value["edge_assets"][0]["tessellation"][field] = number + result = validate_scene_manifest(with_scene_revision(value)) + valid, error, first = result.valid, None, _first_report_issue(result) + else: + raise AssertionError(operation) + except ValueError as exc: + valid, error, first = False, str(exc), None + + assert valid == case["valid"], case["name"] + if "error" in case: + assert error == case["error"], case["name"] + if case.get("expected") is not None: + assert first == case["expected"], case["name"] + + +def test_shared_exact_byte_artifacts_and_jcs_vectors(): + corpus = _load_shared_corpus() + for artifact in corpus["artifacts"].values(): + payload = _decode_base64(artifact["base64"]) + assert "sha256:" + hashlib.sha256(payload).hexdigest() == artifact["sha256"] + for vector in corpus["jcs_vectors"]: + payload = canonical_json_bytes(vector["input"]) + assert payload == _decode_base64(vector["canonical_base64"]) + assert "sha256:" + hashlib.sha256(payload).hexdigest() == vector["sha256"] + archive = preflight_zip_bytes(_decode_base64(corpus["artifacts"]["canonical_zip"]["base64"])) + assert canonical_zip_bytes(archive.members) == _decode_base64( + corpus["artifacts"]["canonical_zip"]["base64"] + ) + + +def _apply_schema_mutations(base, mutations): + value = deepcopy(base) + for mutation in mutations: + current = value + for part in mutation["path"][:-1]: + current = current[part] + field = mutation["path"][-1] + if mutation["operation"] == "delete": + del current[field] + else: + current[field] = deepcopy(mutation["value"]) + return value + + +def test_python_replays_all_closed_schema_record_field_matrices(): + corpus = _load_shared_corpus() + schemas = { + artifact: parse_strict_json(load_contract_artifact(path)) + for artifact, path in SCHEMA_FILES.items() + } + for matrix in corpus["schema_field_matrices"]: + root = schemas[matrix["artifact"]] + pointer = matrix["schema_pointer"] + schema = ( + root + if pointer == "#" + else { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$ref": pointer, + "$defs": root["$defs"], + } + ) + validator = Draft202012Validator(schema) + for case in matrix["cases"]: + value = _apply_schema_mutations(matrix["base"], case["mutations"]) + assert validator.is_valid(value) is case["valid"], ( + matrix["artifact"], + pointer, + matrix["variant"], + case["name"], + ) + + +def _valid_document_payloads(): + corpus = _load_shared_corpus() + return ( + ("scene", SceneDocument, _decode_base64(corpus["artifacts"]["scene"]["base64"])), + ("entities", EntityDocument, _decode_base64(corpus["artifacts"]["entities"]["base64"])), + ( + "presentation", + PresentationDocument, + _decode_base64(next(case for case in corpus["presentation_cases"] if case["valid"])["payload_base64"]), + ), + ( + "connector_binding", + ConnectorBindingDocument, + _decode_base64(next(case for case in corpus["connector_binding_cases"] if case["valid"])["payload_base64"]), + ), + ( + "normalized_product", + NormalizedProductDocument, + _decode_base64(next(case for case in corpus["normalized_product_cases"] if case["valid"])["payload_base64"]), + ), + ) + + +def _assert_deeply_immutable(value): + if isinstance(value, Mapping): + assert isinstance(value, MappingProxyType) + for child in value.values(): + _assert_deeply_immutable(child) + elif isinstance(value, tuple): + for child in value: + _assert_deeply_immutable(child) + else: + assert not isinstance(value, (dict, list)) + + +def _nested_container(value): + for child in value.values(): + if isinstance(child, (Mapping, tuple, dict, list)): + return child + raise AssertionError("fixture has no nested container") + + +def test_immutable_documents_construct_parse_validate_and_round_trip_canonically(): + for name, document_type, payload in _valid_document_payloads(): + mutable = parse_canonical_json(payload) + constructed = document_type.from_value(mutable) + parsed = document_type.parse(payload) + + assert is_dataclass(constructed), name + assert constructed.canonical_bytes == payload, name + assert parsed.canonical_bytes == payload, name + assert constructed.canonical_hash == "sha256:" + hashlib.sha256(payload).hexdigest(), name + assert constructed == parsed, name + assert bytes(constructed) == payload, name + assert document_type.from_value(constructed.value) == constructed, name + assert document_type.from_value(constructed.to_mutable()) == constructed, name + + +def test_immutable_documents_resist_nested_mutation_and_isolate_mutable_exports(): + for name, document_type, payload in _valid_document_payloads(): + source = parse_canonical_json(payload) + document = document_type.from_value(source) + expected_bytes = document.canonical_bytes + + _assert_deeply_immutable(document.value) + with pytest.raises(TypeError): + document.value["mutation"] = True + frozen_nested = _nested_container(document.value) + with pytest.raises((TypeError, AttributeError)): + if isinstance(frozen_nested, Mapping): + frozen_nested["mutation"] = True + else: + frozen_nested.append(None) + with pytest.raises(FrozenInstanceError): + document._canonical_bytes = b"mutated" + + source["mutation"] = True + first_copy = document.to_mutable() + second_copy = document.to_mutable() + mutable_nested = _nested_container(first_copy) + if isinstance(mutable_nested, dict): + mutable_nested["mutation"] = True + else: + mutable_nested.append(None) + first_copy["mutation"] = True + + assert "mutation" not in document.value, name + assert "mutation" not in second_copy, name + assert first_copy != second_copy, name + assert document.canonical_bytes == expected_bytes, name + + +def test_immutable_document_constructors_reject_invalid_values_and_stale_revisions(): + for _name, document_type, _payload in _valid_document_payloads(): + with pytest.raises(SceneContractError): + document_type.from_value({}) + + with pytest.raises(ValueError, match="canonical"): + SceneDocument.parse(b'{ "schema_version": "1.0" }') + + corpus = _load_shared_corpus() + scene = parse_canonical_json(_decode_base64(corpus["artifacts"]["scene"]["base64"])) + scene["revision"] = "sha256:" + "0" * 64 + with pytest.raises(SceneContractError) as error: + SceneDocument.from_value(scene) + assert error.value.report.first_error.code == "revision_mismatch" diff --git a/SimpleCADAPI/test/test_scene_phase_a_characterization.py b/SimpleCADAPI/test/test_scene_phase_a_characterization.py new file mode 100644 index 0000000..f8e9b1c --- /dev/null +++ b/SimpleCADAPI/test/test_scene_phase_a_characterization.py @@ -0,0 +1,345 @@ +"""Phase A characterization of the current Model 2.0 and OCP boundaries.""" + +from __future__ import annotations + +import json + +import pytest +from OCP.BRep import BRep_Tool +from OCP.BRepMesh import BRepMesh_IncrementalMesh +from OCP.BRepTools import BRepTools +from OCP.TopAbs import TopAbs_FORWARD, TopAbs_REVERSED +from OCP.TopLoc import TopLoc_Location + +import simplecadapi as scad +from simplecadapi.core import Compound, Edge, Face +from simplecadapi.kernel.ocp_export import make_compound_always +from simplecadapi.operations import _make_geo_selector +from simplecadapi.scene import ( + canonical_json_bytes, + load_contract_artifact, + profile_cross, + profile_f32_bits, + profile_normalize, +) +from simplecadapi.serializer import ( + _candidate_shapes_for_geo_selection, + _geo_selector_score, + _resolve_shape_from_geo_selector, +) + + +CHARACTERIZED_RENDER_PROFILE_CASES = { + "bounded_canonicalization_failure", + "closed_edge", + "degenerate_edge", + "duplicate_geometry_with_different_metadata", + "duplicate_geometry_with_different_provenance", + "negative_zero", + "normal_fallback", + "reversed_kernel_traversal", + "shared_face_rejection", + "symmetric_entity", +} + + +def _evaluated_profile() -> dict[str, object]: + return json.loads( + load_contract_artifact( + "profiles/ocp-evaluated-properties-1.profile.json" + ) + ) + + +def _render_profile() -> dict[str, object]: + return json.loads( + load_contract_artifact("profiles/scene-1.0-ocp-glb-2.profile.json") + ) + + +def _fallback_normal(face: Face) -> tuple[float, float, float]: + BRepTools.Clean_s(face.wrapped, False) + mesher = BRepMesh_IncrementalMesh(face.wrapped, 0.35, False, 0.22, False) + mesher.Perform() + location = TopLoc_Location() + triangulation = BRep_Tool.Triangulation_s(face.wrapped, location, 0) + assert triangulation is not None + assert triangulation.HasNormals() is False + indices = list(triangulation.Triangle(1).Get()) + if face.wrapped.Orientation() == TopAbs_REVERSED: + indices = [indices[0], indices[2], indices[1]] + points = [] + transform = location.Transformation() + for index in indices: + point = triangulation.Node(index).Transformed(transform) + points.append((point.X() / 1000, point.Z() / 1000, -point.Y() / 1000)) + first = tuple(points[1][index] - points[0][index] for index in range(3)) + second = tuple(points[2][index] - points[0][index] for index in range(3)) + return profile_normalize(profile_cross(first, second)) + + +def test_all_render_profile_characterization_cases_have_targeted_evidence(): + profile = _render_profile() + assert set(profile["rules"]["required_characterization_cases"]) == ( + CHARACTERIZED_RENDER_PROFILE_CASES + ) + + +def test_model_schema_2_topology_witnesses_and_roles_survive_clean_replay(): + with scad.GraphSession(graph_id="scene-characterization") as session: + profile = scad.make_rectangle_rface(width=5, height=3) + scad.extrude_rsolid( + profile=profile, + direction=(0, 0, 1), + distance=2, + start_face_tag="role.start", + end_face_tag="role.end", + ) + + model_json = scad.export_model_json(session=session) + payload = json.loads(model_json) + assert payload["schema_version"] == "2.0" + assert payload["graph"]["schema_version"] == "2.0" + assert { + key: payload["graph"]["capabilities"][key] + for key in ( + "topology_delta_entries", + "durable_topology_parent_refs", + "operation_output_roles", + ) + } == { + "topology_delta_entries": True, + "durable_topology_parent_refs": True, + "operation_output_roles": True, + } + + assert len(payload["topology_delta_log"]) == 1 + witness = payload["topology_delta_log"][0] + delta = witness["delta"] + assert sorted({entry["role"] for entry in delta["roles"]}) == [ + "extrusion.end", + "extrusion.side", + "extrusion.start", + ] + assert all( + entry["ref"]["graph_id"] == "scene-characterization" + and entry["ref"]["node_id"] == witness["node_id"] + for entry in delta["entries"] + ) + + replayed = scad.replay_model_json(json_str=model_json) + assert len(replayed) == 1 + solid = replayed[0] + assert { + role: sum(scad.ql.output_role(role)(face) for face in solid.get_faces()) + for role in ("extrusion.start", "extrusion.end", "extrusion.side") + } == { + "extrusion.start": 1, + "extrusion.end": 1, + "extrusion.side": 4, + } + + +def test_profile_forces_reversed_edges_forward_before_selector_evaluation(): + profile = _evaluated_profile() + preparation = profile["rules"]["shape_preparation"] + assert preparation["edge_orientation_call"] == "edge.Oriented(TopAbs_FORWARD)" + assert preparation["edge_orientation_order"] == "force_forward_before_location_bake" + + edge = scad.make_line_redge(start=(0, 0, 0), end=(3, 4, 0)) + reversed_edge = Edge(edge.wrapped.Oriented(TopAbs_REVERSED)) + forward_edge = Edge(reversed_edge.wrapped.Oriented(TopAbs_FORWARD)) + + assert edge.wrapped.Orientation() == TopAbs_FORWARD + assert reversed_edge.wrapped.Orientation() == TopAbs_REVERSED + assert forward_edge.wrapped.Orientation() == TopAbs_FORWARD + assert _make_geo_selector(forward_edge) == _make_geo_selector(edge) + assert _geo_selector_score( + forward_edge, _make_geo_selector(edge) + ) == pytest.approx(0.0) + + +def test_reversed_kernel_traversal_preserves_normalized_evaluated_properties(): + first = scad.make_box_rsolid( + width=1, + height=2, + depth=3, + bottom_face_center=(-5, 0, 0), + ) + second = scad.make_box_rsolid( + width=2, + height=3, + depth=4, + bottom_face_center=(5, 0, 0), + ) + forward_root = Compound( + make_compound_always([first.wrapped, second.wrapped]) + ) + reversed_root = Compound( + make_compound_always([second.wrapped, first.wrapped]) + ) + forward_solids = forward_root.get_solids() + reversed_solids = reversed_root.get_solids() + + assert len(forward_solids) == len(reversed_solids) == 2 + assert forward_solids[0].wrapped.IsSame(first.wrapped) + assert forward_solids[1].wrapped.IsSame(second.wrapped) + assert reversed_solids[0].wrapped.IsSame(second.wrapped) + assert reversed_solids[1].wrapped.IsSame(first.wrapped) + + def evaluated_property_records(root: Compound) -> list[bytes]: + records = [] + for solid in root.get_solids(): + selector = _make_geo_selector(solid) + records.append( + canonical_json_bytes( + { + "bounds": selector["bbox"], + "kind": selector["kind"], + "volume": selector["volume"], + } + ) + ) + return records + + forward_records = evaluated_property_records(forward_root) + reversed_records = evaluated_property_records(reversed_root) + assert forward_records != reversed_records + assert tuple(sorted(forward_records)) == tuple(sorted(reversed_records)) + + +def test_symmetric_entities_are_selector_ambiguous_even_if_legacy_resolver_picks_one(): + profile = _evaluated_profile() + selector_rules = profile["rules"]["geo_exact_selector"] + threshold = selector_rules["threshold"] + assert selector_rules["multiple_match_status"] == "selector_ambiguous" + + first = scad.make_box_rsolid(width=2, height=2, depth=2) + second = scad.make_box_rsolid(width=2, height=2, depth=2) + compound = Compound(make_compound_always([first.wrapped, second.wrapped])) + selector = _make_geo_selector(compound.get_edges()[0]) + candidates = _candidate_shapes_for_geo_selection(compound, "edge") + passing = [ + candidate + for candidate in candidates + if _geo_selector_score(candidate, selector) <= threshold + ] + + assert len(passing) == 2 + legacy_result = _resolve_shape_from_geo_selector(compound, selector) + assert any(legacy_result.same_topology(candidate) for candidate in passing) + + +def test_serialized_closed_edge_connector_replays_but_has_no_current_frame(): + profile = _evaluated_profile() + assert ( + profile["rules"]["connector_frame"]["edge_undefined"] + == "missing_endpoint_or_coincident_endpoints" + ) + + with scad.GraphSession(graph_id="closed-edge-characterization") as session: + edge = scad.make_circle_redge(center=(0, 0, 0), radius=2) + scad.make_edge_connector_rconnector( + connector_id="closed_edge", + edge=edge, + ) + + replayed = scad.replay_model_json( + json_str=scad.export_model_json(session=session) + ) + assert len(replayed) == 1 + connector = replayed[0] + selector = connector.geometry_ref.geo_selector + assert selector["start"] == selector["end"] + with pytest.raises(ValueError, match="direction must be a non-zero vector"): + _ = connector.placement + + +def test_profile_canonicalizes_negative_zero_before_render_keys(): + profile = _render_profile() + assert profile["rules"]["numeric"]["negative_zero"] == ( + "canonicalize_to_positive_zero" + ) + assert profile_f32_bits(-0.0) == profile_f32_bits(0.0) == 0 + + +def test_short_valid_ocp_edge_collapses_after_gltf_float32_conversion(): + profile = _render_profile() + assert profile["rules"]["canonical_blocks"]["edge_empty_policy"] == ( + "retain_degenerate_entity_without_render_block" + ) + edge = scad.make_line_redge( + start=(1000, 0, 0), + end=(1000.00001, 0, 0), + ) + start = edge.get_start_vertex().get_coordinates() + end = edge.get_end_vertex().get_coordinates() + + assert edge.get_length() > 0 + assert tuple(profile_f32_bits(component / 1000) for component in start) == tuple( + profile_f32_bits(component / 1000) for component in end + ) + + +def test_missing_kernel_normals_use_oriented_triangle_fallback(): + profile = _render_profile() + assert profile["rules"]["normal"]["fallback"] == ( + "oriented_triangle_cross_product_after_coordinate_conversion" + ) + face = scad.make_rectangle_rface(width=2, height=3) + reversed_face = Face(face.wrapped.Oriented(TopAbs_REVERSED)) + + assert _fallback_normal(face) == pytest.approx((0, 1, 0)) + assert _fallback_normal(reversed_face) == pytest.approx((0, -1, 0)) + + +def test_duplicate_geometry_metadata_changes_selector_bytes_but_not_score(): + first = scad.make_line_redge(start=(0, 0, 0), end=(1, 0, 0)) + second = scad.make_line_redge(start=(0, 0, 0), end=(1, 0, 0)) + first.set_metadata("geo", {"label": "first"}) + second.set_metadata("geo", {"label": "second"}) + first_selector = _make_geo_selector(first) + second_selector = _make_geo_selector(second) + + assert first_selector["metadata_geo"] != second_selector["metadata_geo"] + assert _geo_selector_score(first, second_selector) == pytest.approx(0.0) + assert _geo_selector_score(second, first_selector) == pytest.approx(0.0) + + +def test_duplicate_geometry_provenance_is_distinct_but_not_selector_scored(): + with scad.GraphSession(graph_id="duplicate-provenance"): + first = scad.make_line_redge(start=(0, 0, 0), end=(1, 0, 0)) + second = scad.make_line_redge(start=(0, 0, 0), end=(1, 0, 0)) + + first_node = first._get_runtime("graph.node") + second_node = second._get_runtime("graph.node") + assert first_node.node_id != second_node.node_id + assert _make_geo_selector(first) == _make_geo_selector(second) + assert _geo_selector_score(first, _make_geo_selector(second)) == pytest.approx(0.0) + + +def test_shared_shape_compound_violates_disjoint_solid_ownership_target(): + profile = _evaluated_profile() + topology = profile["rules"]["topology"] + assert topology["accepted_root"] == ( + "single_manifold_solid_or_compound_of_disjoint_manifold_solids" + ) + assert "shared_face_between_solids" in topology["rejected_roots"] + + solid = scad.make_box_rsolid(width=2, height=2, depth=2) + compound = Compound(make_compound_always([solid.wrapped, solid.wrapped])) + solids = compound.get_solids() + assert len(solids) == 2 + assert solids[0].wrapped.IsSame(solids[1].wrapped) + assert len(compound.get_faces()) == 12 + assert len({face.topo_id for face in compound.get_faces()}) == 6 + + +def test_symmetric_graph_canonicalization_has_a_hard_failure_budget(): + profile = _evaluated_profile() + labeling = profile["rules"]["canonical_labeling"] + assert labeling["maximum_states"] == 1_000_000 + assert labeling["budget_error"] == "entity_canonicalization_budget_exceeded" + assert labeling["exact_candidate_ties"] == ( + "arbitrary_order_but_evaluate_every_branch" + ) diff --git a/SimpleCADAPI/test/test_scene_phase_a_examples.py b/SimpleCADAPI/test/test_scene_phase_a_examples.py new file mode 100644 index 0000000..9ae87b1 --- /dev/null +++ b/SimpleCADAPI/test/test_scene_phase_a_examples.py @@ -0,0 +1,238 @@ +"""Opt-in Phase A hierarchy characterization for selected large examples.""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +import subprocess +import sys +import threading +import time + +import pytest + + +ROOT = Path(__file__).resolve().parents[1] +RUN_SLOW_EXAMPLES = os.environ.get("SIMPLECADAPI_RUN_SLOW_SCENE_EXAMPLES") == "1" + +EXAMPLE_CASES = ( + ( + "10_part_assembly.py", + "build_hydraulic_rod_assembly", + 3, + 3, + 2, + ), + ( + "16_compact_two_stage_planetary_reducer/main.py", + "_build_compact_two_stage_planetary_reducer", + 116, + 17, + 15, + ), + ( + "20_integrated_bldc_joint_actuator/main.py", + "build_integrated_bldc_joint_actuator", + 186, + 43, + 34, + ), +) + +_PROBE = r""" +import importlib.util +import json +from pathlib import Path +import sys + +import simplecadapi as scad +from simplecadapi.product import Assembly + +path = Path(sys.argv[1]).resolve() +builder_name = sys.argv[2] +print( + f"SCENE_PHASE_A_START={path.name}:{builder_name}", + flush=True, +) +sys.path.insert(0, str(path.parent)) +spec = importlib.util.spec_from_file_location("phase_a_example_probe", path) +if spec is None or spec.loader is None: + raise RuntimeError(f"could not load example: {path}") +module = importlib.util.module_from_spec(spec) +spec.loader.exec_module(module) +result = getattr(module, builder_name)() +assembly = result.value[0] +if not isinstance(assembly, Assembly): + raise TypeError(f"{builder_name} did not return an Assembly first") + +nodes = [] +def walk(item): + nodes.append(item) + if isinstance(item, Assembly): + for component in item.components: + walk(component.item) + +walk(assembly) +definitions = { + ("assembly", item.assembly_id) + if isinstance(item, Assembly) + else ("part", item.part_id) + for item in nodes +} +part_definitions = { + item.part_id + for item in nodes + if not isinstance(item, Assembly) +} +face_naming = {} +if path.name == "10_part_assembly.py": + naming_contract = { + "outer_sleeve": ("sleeve.", "sleeve.gland.face.mount"), + "piston_rod": ("rod.", "rod.piston.land.left.face.rear"), + } + for part_id, (prefix, connector_face_tag) in naming_contract.items(): + part = next( + item + for item in nodes + if not isinstance(item, Assembly) and item.part_id == part_id + ) + faces = part.body.get_faces() + face_naming[part_id] = { + "face_count": len(faces), + "unnamed_indices": [ + index + for index, face in enumerate(faces) + if not any( + tag.startswith(prefix) + for tag in scad.list_tags(face, scope="local") + ) + ], + "connector_face_count": len( + scad.select_faces_by_tag( + part.body, + connector_face_tag, + scope="local", + ) + ), + } +print("SCENE_PHASE_A_FACTS=" + json.dumps({ + "expected_mesh_count": len(part_definitions), + "face_naming": face_naming, + "product_node_count": len(nodes), + "unique_definition_count": len(definitions), +}, sort_keys=True), flush=True) +""" + + +@pytest.mark.skipif( + not RUN_SLOW_EXAMPLES, + reason="set SIMPLECADAPI_RUN_SLOW_SCENE_EXAMPLES=1 to run large examples", +) +@pytest.mark.parametrize( + ( + "relative_path", + "builder_name", + "expected_nodes", + "expected_definitions", + "expected_meshes", + ), + EXAMPLE_CASES, + ids=("example_10", "example_16", "example_20"), +) +def test_allowlisted_example_product_hierarchy_in_fresh_process( + relative_path: str, + builder_name: str, + expected_nodes: int, + expected_definitions: int, + expected_meshes: int, +): + path = ROOT / "examples" / relative_path + environment = dict(os.environ) + environment["PYTHONHASHSEED"] = "0" + label = f"{relative_path}:{builder_name}" + print( + f"\n[scene-example] starting {label}; " + f"expected nodes={expected_nodes}, definitions={expected_definitions}, " + f"meshes={expected_meshes}", + flush=True, + ) + started = time.monotonic() + process = subprocess.Popen( + [sys.executable, "-c", _PROBE, str(path), builder_name], + cwd=ROOT, + env=environment, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1, + ) + assert process.stdout is not None + output: list[str] = [] + + def relay_output() -> None: + assert process.stdout is not None + for line in process.stdout: + output.append(line) + print(f"[{relative_path}] {line}", end="", flush=True) + + relay = threading.Thread(target=relay_output, daemon=True) + relay.start() + try: + next_heartbeat = 15.0 + while process.poll() is None: + elapsed = time.monotonic() - started + if elapsed >= 600: + process.kill() + process.wait() + pytest.fail(f"{label} timed out after {elapsed:.1f}s") + if elapsed >= next_heartbeat: + print( + f"[scene-example] still building {label}; elapsed={elapsed:.1f}s", + flush=True, + ) + next_heartbeat += 15.0 + time.sleep(0.5) + except BaseException: + if process.poll() is None: + process.kill() + process.wait() + raise + finally: + relay.join(timeout=5) + + elapsed = time.monotonic() - started + stdout = "".join(output) + print( + f"[scene-example] finished {label}; " + f"returncode={process.returncode}, elapsed={elapsed:.1f}s", + flush=True, + ) + assert process.returncode == 0, stdout + fact_lines = [ + line.removeprefix("SCENE_PHASE_A_FACTS=") + for line in stdout.splitlines() + if line.startswith("SCENE_PHASE_A_FACTS=") + ] + assert len(fact_lines) == 1, stdout + assert json.loads(fact_lines[0]) == { + "expected_mesh_count": expected_meshes, + "face_naming": ( + { + "outer_sleeve": { + "connector_face_count": 1, + "face_count": 31, + "unnamed_indices": [], + }, + "piston_rod": { + "connector_face_count": 1, + "face_count": 24, + "unnamed_indices": [], + }, + } + if relative_path == "10_part_assembly.py" + else {} + ), + "product_node_count": expected_nodes, + "unique_definition_count": expected_definitions, + } diff --git a/SimpleCADAPI/test/test_serialization.py b/SimpleCADAPI/test/test_serialization.py index 8ffd079..fbbadf2 100644 --- a/SimpleCADAPI/test/test_serialization.py +++ b/SimpleCADAPI/test/test_serialization.py @@ -163,6 +163,7 @@ class TestCoverageMatrix(unittest.TestCase): "revolve_rsolid", "loft_rsolid", "sweep_rsolid", + "twisted_sweep_rsolid", "helical_sweep_rsolid", "union_rsolid", "cut_rsolid", @@ -302,6 +303,7 @@ class TestCoverageMatrix(unittest.TestCase): "make_revolve_rsolid", "make_loft_rsolid", "make_sweep_rsolid", + "make_twisted_sweep_rsolid", "make_translate_rshape", "make_rotate_rshape", "make_mirror_rshape", @@ -352,6 +354,31 @@ class TestCoverageMatrix(unittest.TestCase): class TestReplay(unittest.TestCase): + @staticmethod + def _tag_node(payload, tag): + return next( + node + for node in payload["graph"]["nodes"] + if node["op"] == "apply_tag_rselection" + and node["params"]["tag_binding"]["tag"] == tag + ) + + @staticmethod + def _topology_ref_map(shape): + refs = {} + for entity in shape._topology_cache.entities(): + wrapper = entity.wrappers[0] + ref = wrapper.get_metadata("topo_ref") + if isinstance(ref, dict): + refs[(entity.kind, entity.topo_id)] = ( + ref["graph_id"], + ref["node_id"], + ref["output_slot"], + ref["kind"], + ref["topo_id"], + ) + return refs + def test_replay_builds_graph(self): """A simple low-level graph can be replayed.""" with GraphSession() as session: @@ -369,6 +396,222 @@ class TestReplay(unittest.TestCase): self.assertIsNotNone(results) self.assertGreater(len(results), 0) + def test_terminal_tag_selector_roundtrip_preserves_binding_and_topology_refs(self): + tag = "role.selector_target" + selector = ( + scad.ql.faces() + .order_by(scad.ql.key("geom.center.z"), desc=True) + .take(1) + .exactly(1) + ) + with scad.GraphSession() as session: + source = scad.make_box_rsolid(2.0, 3.0, 4.0) + source_refs = self._topology_ref_map(source) + geometry_node_id = source.get_metadata("graph")["node_id"] + tagged = scad.apply_tag_rselection(source, selector, tag) + + self.assertEqual(self._topology_ref_map(tagged), source_refs) + self.assertEqual(scad.select_faces_by_tag(source, tag, scope="local"), []) + self.assertEqual(len(scad.select_faces_by_tag(tagged, tag, scope="local")), 1) + + payload = json.loads(scad.export_model_json(session)) + node = self._tag_node(payload, tag) + binding = node["params"]["tag_binding"] + self.assertEqual(payload["leaf_ids"], [node["node_id"]]) + self.assertEqual(node["inputs"], [geometry_node_id]) + self.assertEqual(binding["producer"]["node_id"], node["node_id"]) + self.assertEqual(binding["scope"]["node_id"], geometry_node_id) + self.assertEqual(binding["target"]["kind"], "selection_query") + self.assertEqual(binding["evidence"]["selected_count"], 1) + self.assertEqual(payload["semantic_bindings"], [binding]) + + replayed = scad.replay_model_json(json.dumps(payload)) + self.assertEqual(len(replayed), 1) + result = replayed[0] + self.assertEqual(result.get_metadata("graph")["node_id"], node["node_id"]) + self.assertEqual( + result.get_metadata("topo_ref")["node_id"], geometry_node_id + ) + selected = scad.select_faces_by_tag(result, tag, scope="local") + self.assertEqual(len(selected), 1) + explanation = scad.explain_tag(selected[0], tag, scope="local") + self.assertEqual(explanation[0]["binding_id"], binding["binding_id"]) + + def test_terminal_tag_explicit_refs_roundtrip(self): + tag = "role.explicit_target" + with scad.GraphSession() as session: + box = scad.make_box_rsolid(2.0, 3.0, 4.0) + top_face = max(box.get_faces(), key=lambda face: face.get_center().z) + expected_ref = top_face.get_metadata("topo_ref") + scad.apply_tag_rselection(box, [top_face], tag) + + payload = json.loads(scad.export_model_json(session)) + node = self._tag_node(payload, tag) + binding = node["params"]["tag_binding"] + self.assertEqual(binding["target"]["kind"], "explicit_refs") + self.assertEqual( + binding["target"]["refs"][0]["topo_id"], expected_ref["topo_id"] + ) + self.assertFalse( + any( + item["op"].startswith("make_select_") + for item in payload["graph"]["nodes"] + ) + ) + + result = scad.replay_model_json(json.dumps(payload))[0] + selected = scad.select_faces_by_tag(result, tag, scope="local") + self.assertEqual(len(selected), 1) + self.assertEqual( + selected[0].get_metadata("topo_ref")["topo_id"], expected_ref["topo_id"] + ) + + def test_apply_tag_chain_roundtrip_preserves_nodes_bindings_and_topology_refs(self): + tags = ("role.alpha", "role.beta", "role.alpha") + with scad.GraphSession() as session: + box = scad.make_box_rsolid(2.0, 3.0, 4.0) + geometry_node_id = box.get_metadata("graph")["node_id"] + source_refs = self._topology_ref_map(box) + for tag in tags: + self.assertIs(scad.apply_tag(box, tag), box) + + self.assertEqual(self._topology_ref_map(box), source_refs) + payload = json.loads(scad.export_model_json(session)) + tag_nodes = [ + node + for node in payload["graph"]["nodes"] + if node["op"] == "apply_tag_rselection" + ] + self.assertEqual( + [node["params"]["tag_binding"]["tag"] for node in tag_nodes], + list(tags), + ) + + previous_node_id = geometry_node_id + binding_ids = [] + for node in tag_nodes: + binding = node["params"]["tag_binding"] + self.assertEqual(node["inputs"], [previous_node_id]) + self.assertEqual(binding["scope"]["node_id"], previous_node_id) + self.assertEqual(binding["producer"]["node_id"], node["node_id"]) + binding_ids.append(binding["binding_id"]) + previous_node_id = node["node_id"] + + self.assertEqual(len(binding_ids), len(set(binding_ids))) + self.assertEqual(payload["leaf_ids"], [previous_node_id]) + self.assertEqual(box.get_metadata("graph")["node_id"], previous_node_id) + self.assertEqual( + [binding["binding_id"] for binding in payload["semantic_bindings"]], + binding_ids, + ) + + replayed = scad.replay_model_json(json.dumps(payload)) + self.assertEqual(len(replayed), 1) + result = replayed[0] + self.assertTrue( + {"role.alpha", "role.beta"}.issubset( + scad.list_tags(result, scope="local") + ) + ) + self.assertEqual( + len(scad.explain_tag(result, "role.alpha", scope="local")), + 2, + ) + self.assertEqual(self._topology_ref_map(result), source_refs) + + def test_tag_semantic_branches_are_isolated_after_replay(self): + selector = ( + scad.ql.faces() + .order_by(scad.ql.key("geom.center.z"), desc=True) + .take(1) + .exactly(1) + ) + with scad.GraphSession() as session: + source = scad.make_box_rsolid(2.0, 3.0, 4.0) + left = scad.apply_tag_rselection(source, selector, "role.left_branch") + right = scad.apply_tag_rselection(source, selector, "role.right_branch") + + self.assertEqual( + len(scad.select_faces_by_tag(left, "role.left_branch", scope="local")), 1 + ) + self.assertEqual( + scad.select_faces_by_tag(left, "role.right_branch", scope="local"), [] + ) + self.assertEqual( + scad.select_faces_by_tag(right, "role.left_branch", scope="local"), [] + ) + self.assertEqual( + len(scad.select_faces_by_tag(right, "role.right_branch", scope="local")), + 1, + ) + + payload = json.loads(scad.export_model_json(session)) + left_node = self._tag_node(payload, "role.left_branch") + right_node = self._tag_node(payload, "role.right_branch") + replayed = { + shape.get_metadata("graph")["node_id"]: shape + for shape in scad.replay_model_json(json.dumps(payload)) + } + replayed_left = replayed[left_node["node_id"]] + replayed_right = replayed[right_node["node_id"]] + self.assertEqual( + len( + scad.select_faces_by_tag( + replayed_left, "role.left_branch", scope="local" + ) + ), + 1, + ) + self.assertEqual( + scad.select_faces_by_tag( + replayed_left, "role.right_branch", scope="local" + ), + [], + ) + self.assertEqual( + scad.select_faces_by_tag( + replayed_right, "role.left_branch", scope="local" + ), + [], + ) + self.assertEqual( + len( + scad.select_faces_by_tag( + replayed_right, "role.right_branch", scope="local" + ) + ), + 1, + ) + + def test_strict_tag_replay_rejects_binding_tampering(self): + selector = ( + scad.ql.faces() + .order_by(scad.ql.key("geom.center.z"), desc=True) + .take(1) + .exactly(1) + ) + with scad.GraphSession() as session: + box = scad.make_box_rsolid(2.0, 3.0, 4.0) + scad.apply_tag_rselection(box, selector, "role.target") + + raw = json.loads(scad.export_model_json(session)) + + damaged = deepcopy(raw) + node = self._tag_node(damaged, "role.target") + node["params"]["tag_binding"]["producer"]["node_id"] = node["inputs"][0] + damaged["semantic_bindings"] = [node["params"]["tag_binding"]] + with self.assertRaisesRegex(ValueError, "does not match binding producer"): + scad.replay_model_json(json.dumps(damaged)) + + damaged = deepcopy(raw) + node = self._tag_node(damaged, "role.target") + node["params"]["tag_binding"]["target"]["selector"]["order_keys"][0][ + "desc" + ] = False + damaged["semantic_bindings"] = [node["params"]["tag_binding"]] + with self.assertRaisesRegex(ValueError, "target evidence drifted"): + scad.replay_model_json(json.dumps(damaged)) + def test_replay_preserves_volume(self): with GraphSession() as session: record_operation( @@ -846,6 +1089,38 @@ class TestReplay(unittest.TestCase): self.assertIsInstance(results[0], scad.Solid) self.assertAlmostEqual(results[0].get_volume(), swept.get_volume(), places=5) + def test_replay_twisted_sweep_roundtrip(self): + with scad.GraphSession() as session: + profile = scad.make_rectangle_rface(width=2.0, height=1.0) + swept = scad.twisted_sweep_rsolid( + profile=profile, + distance=5.0, + twist_angle=45.0, + ) + + payload = json.loads(export_graph_json(session.graph)) + node = next( + item + for item in payload["nodes"] + if item["op"] == "make_twisted_sweep_rsolid" + ) + self.assertEqual(len(node["inputs"]), 1) + self.assertEqual( + node["params"], + { + "axis": [0.0, 0.0, 1.0], + "origin": [0.0, 0.0, 0.0], + "distance": 5.0, + "twist_angle": 45.0, + "guide_radius": 1.0, + }, + ) + + results = replay_graph(import_graph_json(json.dumps(payload)), strict=True) + self.assertEqual(len(results), 1) + self.assertEqual(len(results[0].get_faces()), 6) + self.assertAlmostEqual(results[0].get_volume(), swept.get_volume(), places=8) + def test_replay_sweep_records_ql_selected_extrude_end_face_profile(self): with scad.GraphSession() as session: base = scad.make_circle_rface((0, 0, 0), 0.25) @@ -1022,6 +1297,25 @@ class TestReplay(unittest.TestCase): replayed = scad.replay_model_json(json.dumps(payload)) self.assertAlmostEqual(replayed[0].get_volume(), original.get_volume(), places=5) + def test_boolean_replay_defaults_missing_tracking_policy_to_full(self): + with scad.GraphSession() as session: + body = scad.make_box_rsolid(4.0, 4.0, 4.0) + tool = scad.make_cylinder_rsolid( + 0.75, + 6.0, + bottom_face_center=(0.0, 0.0, -1.0), + ) + original = scad.cut_rsolid(body, tool) + + payload = json.loads(scad.export_model_json(session)) + cut_node = next( + node for node in payload["graph"]["nodes"] if node["op"] == "make_cut_rsolid" + ) + self.assertEqual(cut_node["params"].pop("tracking_policy"), "full") + + replayed = scad.replay_model_json(json.dumps(payload)) + self.assertAlmostEqual(replayed[0].get_volume(), original.get_volume(), places=6) + def test_replay_selection_cardinality_mismatch_raises_by_default(self): with scad.GraphSession() as session: box = scad.make_box_rsolid(4.0, 4.0, 4.0) diff --git a/SimpleCADAPI/test/test_sketch_api.py b/SimpleCADAPI/test/test_sketch_api.py index 1edbf5f..53b3edb 100644 --- a/SimpleCADAPI/test/test_sketch_api.py +++ b/SimpleCADAPI/test/test_sketch_api.py @@ -6,6 +6,7 @@ import json import unittest import simplecadapi as scad +from simplecadapi import ql as Q class TestSketchApi(unittest.TestCase): @@ -92,6 +93,108 @@ class TestSketchApi(unittest.TestCase): self.assertAlmostEqual(face.get_area(), 3.141592653589793 * 2.25, places=5) self.assertIn("sketch_entity.outer", scad.list_tags(face.get_edges(0))) + def test_constrained_sketch_promotion_has_topology_identity_tags(self): + sketch = self._make_constrained_rectangle() + face = scad.make_face_from_sketch_rface( + sketch, require_fully_constrained=True + ) + + self.assertEqual( + len(Q.faces().where(Q.tag("sketch.rect.profile.bottom")).resolve(face)), + 1, + ) + for entity_id in ("bottom", "right", "top", "left"): + edges = Q.edges().where( + Q.tag(f"sketch.rect.entity.{entity_id}") + ).resolve(face) + self.assertEqual(len(edges), 1) + evidence = scad.explain_tag( + edges[0], f"sketch.rect.entity.{entity_id}", scope="local" + )[0]["binding"]["evidence"] + self.assertEqual(evidence["evidence_method"], "SketchPromotionMap") + self.assertEqual( + evidence["sketch_promotion"]["entity_id"], entity_id + ) + self.assertEqual(evidence["topology_name"]["kind"], "edge") + + def test_constrained_sketch_wire_promotion_has_topology_identity_tags(self): + sketch = self._make_constrained_rectangle() + wire = scad.make_wire_from_sketch_rwire( + sketch, require_fully_constrained=True + ) + + self.assertEqual( + len(Q.wires().where(Q.tag("sketch.rect.profile.bottom")).resolve(wire)), + 1, + ) + for entity_id in ("bottom", "right", "top", "left"): + edges = Q.edges().where( + Q.tag(f"sketch.rect.entity.{entity_id}") + ).resolve(wire) + self.assertEqual(len(edges), 1) + evidence = scad.explain_tag( + edges[0], f"sketch.rect.entity.{entity_id}", scope="local" + )[0]["binding"]["evidence"] + self.assertEqual(evidence["evidence_method"], "SketchPromotionMap") + self.assertEqual( + evidence["sketch_promotion"]["entity_id"], entity_id + ) + self.assertEqual(evidence["topology_name"]["kind"], "edge") + + def test_constrained_sketch_topology_tags_project_and_replay(self): + with scad.GraphSession() as session: + sketch = self._make_constrained_rectangle() + profile = scad.make_face_from_sketch_rface(sketch) + body = scad.extrude_rsolid( + profile, (0, 0, 1), 2.0, tag_prefix="body" + ) + + expected_tags = { + "body.face.side.bottom", + "body.face.side.right", + "body.face.side.top", + "body.face.side.left", + } + self.assertEqual( + { + tag + for face in body.get_faces() + for tag in scad.list_tags(face, scope="local") + if tag.startswith("body.face.side.") + }, + expected_tags, + ) + + payload = json.loads(scad.export_model_json(session)) + promotion = next( + node + for node in payload["graph"]["nodes"] + if node["op"] == "make_face_from_sketch_rface" + ) + self.assertEqual( + promotion["params"]["promotion_map"]["topology_name"]["kind"], + "face", + ) + self.assertEqual( + [ + edge["topology_name"]["local_name"] + for edge in promotion["params"]["promotion_map"]["edges"] + ], + ["bottom", "right", "top", "left"], + ) + + replayed = scad.replay_model_json(json.dumps(payload)) + rebuilt = next(shape for shape in replayed if isinstance(shape, scad.Solid)) + self.assertEqual( + { + tag + for face in rebuilt.get_faces() + for tag in scad.list_tags(face, scope="local") + if tag.startswith("body.face.side.") + }, + expected_tags, + ) + def test_underconstrained_and_conflicting_sketches_report_diagnostics(self): sketch = scad.make_sketch_rsketch("open") sketch = scad.add_point_rsketch(sketch, "p0", 0.0, 0.0) diff --git a/SimpleCADAPI/test/test_skill_pack.py b/SimpleCADAPI/test/test_skill_pack.py index 08f5b8f..4efb820 100644 --- a/SimpleCADAPI/test/test_skill_pack.py +++ b/SimpleCADAPI/test/test_skill_pack.py @@ -181,6 +181,9 @@ class TestSkillPackPathResolution(unittest.TestCase): content = packager._build_skill_markdown() self.assertIn("GraphSession", content) + self.assertIn("ModelResult", content) + self.assertIn("@model", content) + self.assertIn("capture_result", content) self.assertIn("export_model_json", content) self.assertIn("replay_model_json", content) self.assertIn("Use the graph/model JSON workflow", content) diff --git a/SimpleCADAPI/test/test_solidworks_translator.py b/SimpleCADAPI/test/test_solidworks_translator.py new file mode 100644 index 0000000..6044742 --- /dev/null +++ b/SimpleCADAPI/test/test_solidworks_translator.py @@ -0,0 +1,97 @@ +"""Host-independent tests for the SolidWorks translator backend.""" + +from __future__ import annotations + +import importlib +import sys +import unittest + +import simplecadapi as scad +from simplecadapi import GraphSession +from simplecadapi.translator.errors import TranslationRequestError + + +def _rectangle_extrusion_model() -> str: + with GraphSession() as session: + profile = scad.make_rectangle_rface(width=2.0, height=1.0) + scad.extrude_rsolid( + profile=profile, + direction=(0.0, 0.0, 1.0), + distance=3.0, + ) + return scad.export_model_json(session=session) + + +class TestSolidWorksTranslator(unittest.TestCase): + def test_backend_imports_without_target_runtime_modules(self): + before = set(sys.modules) + + solidworks = importlib.import_module( + "simplecadapi.translator.solidworks_translator" + ) + + self.assertNotIn("pythoncom", set(sys.modules) - before) + self.assertNotIn("win32com.client", set(sys.modules) - before) + self.assertEqual(solidworks.CAPABILITIES.backend_id, "solidworks") + + def test_supported_graph_emits_deterministic_compilable_script(self): + model_json = _rectangle_extrusion_model() + from simplecadapi.translator.solidworks_translator import SolidWorksTranslator + + translator = SolidWorksTranslator(document_name="ContractSolidWorks") + first = translator.translate_model_json(model_json) + second = translator.translate_model_json(model_json) + + self.assertEqual(first.content, second.content) + compile(first.content, "", "exec") + self.assertFalse(first.metadata["target_runtime_validated"]) + self.assertTrue(translator.capabilities.targets[0].requires_external_runtime) + + def test_fallback_scripts_are_deterministic(self): + with GraphSession() as session: + profile = scad.make_rectangle_rface(3.0, 3.0) + solid = scad.extrude_rsolid(profile, (0.0, 0.0, 1.0), 3.0) + scad.fillet_rsolid(solid, [solid.get_edges(0)], 0.2) + model_json = scad.export_model_json(session) + + from simplecadapi.translator.solidworks_translator import SolidWorksTranslator + + translator = SolidWorksTranslator(source_kernel_fallback=True) + first = translator.translate_model_json(model_json).content + second = translator.translate_model_json(model_json).content + + self.assertEqual(first, second) + compile(first, "", "exec") + + def test_script_owns_com_and_only_its_created_document(self): + from simplecadapi.translator.solidworks_translator import SolidWorksTranslator + + script = ( + SolidWorksTranslator(visible=True) + .translate_model_json(_rectangle_extrusion_model()) + .content + ) + + main_offset = script.index("def main():") + coinit_offset = script.index("pythoncom.CoInitialize()", main_offset) + runtime_offset = script.index( + "runtime = SimpleCADSolidWorksRuntime", main_offset + ) + self.assertLess(coinit_offset, runtime_offset) + self.assertEqual(script.count("pythoncom.CoInitialize()"), 1) + self.assertIn("self.sw.CloseDoc(str(_maybe_call(self.model.GetTitle)))", script) + self.assertIn("if not self.visible:", script) + self.assertIn("self.sw.ExitApp()", script) + + def test_unsupported_result_operation_is_rejected_before_host_execution(self): + with GraphSession() as session: + scad.make_box_rsolid(width=1.0, height=2.0, depth=3.0) + + from simplecadapi.translator.solidworks_translator import SolidWorksTranslator + + with self.assertRaises(TranslationRequestError): + SolidWorksTranslator().translate_model_json(scad.export_model_json(session)) + + +if __name__ == "__main__": + unittest.main() diff --git a/SimpleCADAPI/test/test_source_mapping.py b/SimpleCADAPI/test/test_source_mapping.py new file mode 100644 index 0000000..1af5dd2 --- /dev/null +++ b/SimpleCADAPI/test/test_source_mapping.py @@ -0,0 +1,116 @@ +"""Regression tests for operation source provenance.""" + +import json + +import simplecadapi as scad +from simplecadapi.serializer import export_graph_json +from simplecadapi.source_mapping import canonical_source_payload +from simplecadapi.topology import OperationGraph + + +def test_source_mapping_records_call_span_and_assignment_target(): + with scad.GraphSession(graph_id="source_mapping") as session: + width = 10.0 + body = scad.make_box_rsolid( + width=width, + height=2.0, + depth=3.0, + ) + + node = session.graph.nodes[0] + assert node.source is not None + assert node.source["path"].endswith("test_source_mapping.py") + assert node.source["path_kind"] == "project_relative" + assert node.source["line"] <= node.source["end_line"] + assert node.source["call_text"].startswith("scad.make_box_rsolid") + assert node.source["assignment_targets"] == ["body"] + assert "input_bindings" not in node.source + + +def test_source_mapping_disambiguates_same_line_calls_and_round_trips(): + with scad.GraphSession(graph_id="source_mapping_same_line") as session: + first = scad.make_box_rsolid(1.0, 1.0, 1.0); second = scad.make_box_rsolid(2.0, 2.0, 2.0) + + nodes = session.graph.topological_order() + assert len(nodes) == 2 + assert nodes[0].source is not None + assert nodes[1].source is not None + assert nodes[0].source["assignment_targets"] == ["first"] + assert nodes[1].source["assignment_targets"] == ["second"] + assert nodes[0].source["column"] < nodes[1].source["column"] + + payload = json.loads(export_graph_json(session.graph)) + assert payload["capabilities"]["source_mapping"] is True + assert "local_path" not in payload["nodes"][0]["source"] + restored = OperationGraph.from_dict(payload) + assert restored.nodes[0].source == canonical_source_payload(nodes[0].source) + assert restored.nodes[1].source == canonical_source_payload(nodes[1].source) + + +def test_source_mapping_does_not_claim_nested_call_as_assignment_output(): + with scad.GraphSession(graph_id="source_mapping_nested") as session: + body = scad.make_box_rsolid(1.0, 1.0, 1.0) + moved = scad.translate_shape( + shape=body, + vector=(1.0, 0.0, 0.0), + ) + + translate_node = session.graph.topological_order()[1] + assert translate_node.source is not None + assert translate_node.source["assignment_targets"] == ["moved"] + + +def test_source_mapping_records_complex_targets_and_return(): + class Holder: + pass + + holder = Holder() + with scad.GraphSession(graph_id="source_mapping_targets") as session: + holder.body = scad.make_box_rsolid(1.0, 1.0, 1.0) + items = [None] + index = 0 + items[index] = scad.make_box_rsolid(2.0, 2.0, 2.0) + + first, second = session.graph.topological_order() + assert first.source is not None + assert first.source["assignment_targets"] == ["holder.body"] + assert second.source is not None + assert second.source["assignment_targets"] == ["items[index]"] + + +def test_source_mapping_records_return_and_requires_session_builder(): + @scad.requires_session + def build_box(): + return scad.make_box_rsolid(1.0, 1.0, 1.0) + + with scad.GraphSession(graph_id="source_mapping_return") as session: + result = build_box() + + node = session.graph.nodes[0] + assert result.get_metadata("graph")["node_id"] == node.node_id + assert node.source is not None + assert node.source["call_text"] == "scad.make_box_rsolid(1.0, 1.0, 1.0)" + assert node.source["assignment_targets"] == [] + + +def test_macro_nodes_share_the_user_callsite_and_assignment_target(): + with scad.GraphSession(graph_id="source_mapping_macro") as session: + profile = scad.make_rectangle_rface(width=2.0, height=1.0) + + nodes = session.graph.topological_order() + assert len(nodes) > 1 + assert profile.get_metadata("graph")["node_id"] == nodes[-1].node_id + assert all(node.source is not None for node in nodes) + + callsite_ids = {node.source["callsite_id"] for node in nodes if node.source} + call_texts = {node.source["call_text"] for node in nodes if node.source} + targets = { + tuple(node.source["assignment_targets"]) + for node in nodes + if node.source + } + assert len(callsite_ids) == 1 + assert call_texts == { + "scad.make_rectangle_rface(width=2.0, height=1.0)" + } + assert targets == {("profile",)} diff --git a/SimpleCADAPI/test/test_stdlib_bearing.py b/SimpleCADAPI/test/test_stdlib_bearing.py index 6b64cea..c0e295f 100644 --- a/SimpleCADAPI/test/test_stdlib_bearing.py +++ b/SimpleCADAPI/test/test_stdlib_bearing.py @@ -151,6 +151,12 @@ class TestBallBearingAssembly(unittest.TestCase): self.assertIn("make_revolute_constraint_rassembly", ops) self.assertEqual(ops.count("make_forward_connector_rassembly"), 2) self.assertIn("make_compound_from_assembly_rcompound", ops) + stdlib_nodes = [ + node + for node in payload["graph"]["nodes"] + if node["op"] != "make_compound_from_assembly_rcompound" + ] + self.assertTrue(all("topo_delta" not in node for node in stdlib_nodes)) def test_inferred_ball_count_is_recorded(self): bearing = scad.std.bearing.make_ball_bearing_rassembly( diff --git a/SimpleCADAPI/test/test_stdlib_chain.py b/SimpleCADAPI/test/test_stdlib_chain.py new file mode 100644 index 0000000..5fd54e1 --- /dev/null +++ b/SimpleCADAPI/test/test_stdlib_chain.py @@ -0,0 +1,106 @@ +"""Tests for roller-chain standard parts.""" + +import json +import math +import unittest + +import simplecadapi as scad + + +class TestRollerChainSprocket(unittest.TestCase): + def test_iso_606_12b_candidate_geometry(self): + sprocket = scad.std.chain.make_roller_chain_sprocket_rsolid( + n_teeth=18, + chain_pitch=19.05, + roller_diameter=12.07, + sprocket_thickness=11.1, + bore_radius=10.2, + ) + + self.assertGreater(sprocket.get_volume(), 0.0) + meta = sprocket.get_metadata("std.chain.roller_sprocket") + self.assertEqual(meta["n_teeth"], 18) + self.assertAlmostEqual( + meta["pitch_radius"], + 19.05 / (2.0 * math.sin(math.pi / 18)), + ) + self.assertLess(meta["root_radius"], meta["pitch_radius"]) + self.assertGreater(meta["outside_radius"], meta["pitch_radius"]) + print( + "roller_chain_sprocket: " + f"volume={sprocket.get_volume():.3f} " + f"pitch_radius={meta['pitch_radius']:.6f}" + ) + + def test_roller_seats_are_recorded_as_strict_cuts(self): + with scad.GraphSession() as session: + sprocket = scad.std.chain.make_roller_chain_sprocket_rsolid( + n_teeth=18, + chain_pitch=19.05, + roller_diameter=12.07, + sprocket_thickness=11.1, + bore_radius=10.2, + ) + model_json = scad.export_model_json(session=session) + + payload = json.loads(model_json) + cut_nodes = [ + node + for node in payload["graph"]["nodes"] + if node["op"] == "make_cut_rsolid" + ] + self.assertEqual(len(cut_nodes), 1) + self.assertEqual(cut_nodes[0]["params"]["tool_count"], 19) + self.assertFalse(cut_nodes[0]["params"]["skip_non_intersecting"]) + self.assertEqual(cut_nodes[0]["params"]["tracking_policy"], "graph") + self.assertTrue( + all("topo_delta" not in node for node in payload["graph"]["nodes"]) + ) + + replayed = scad.replay_model_json(json_str=model_json, strict=True) + self.assertEqual(len(replayed), 1) + self.assertAlmostEqual( + replayed[0].get_volume(), sprocket.get_volume(), places=5 + ) + + def test_invalid_sprocket_parameters(self): + invalid_kwargs = ( + { + "n_teeth": 5, + "chain_pitch": 19.05, + "roller_diameter": 12.07, + "sprocket_thickness": 11.1, + }, + { + "n_teeth": 18, + "chain_pitch": 0.0, + "roller_diameter": 12.07, + "sprocket_thickness": 11.1, + }, + { + "n_teeth": 18, + "chain_pitch": 19.05, + "roller_diameter": 19.05, + "sprocket_thickness": 11.1, + }, + { + "n_teeth": 18, + "chain_pitch": 19.05, + "roller_diameter": 12.07, + "sprocket_thickness": 0.0, + }, + { + "n_teeth": 18, + "chain_pitch": 19.05, + "roller_diameter": 12.07, + "sprocket_thickness": 11.1, + "bore_radius": 60.0, + }, + ) + for kwargs in invalid_kwargs: + with self.subTest(kwargs=kwargs), self.assertRaises(ValueError): + scad.std.chain.make_roller_chain_sprocket_rsolid(**kwargs) + + +if __name__ == "__main__": + unittest.main() diff --git a/SimpleCADAPI/test/test_stdlib_fastener.py b/SimpleCADAPI/test/test_stdlib_fastener.py new file mode 100644 index 0000000..b25df65 --- /dev/null +++ b/SimpleCADAPI/test/test_stdlib_fastener.py @@ -0,0 +1,359 @@ +"""Tests for parameterized bolt and nut standard parts.""" + +import json +import unittest + +import simplecadapi as scad + + +class TestFastenerSurface(unittest.TestCase): + def test_preferred_nested_std_export_surface(self): + bolt = scad.std.fastener.make_bolt_rsolid(diameter=8.0, length=24.0) + nut = scad.std.fastener.make_nut_rsolid( + diameter=8.0, + width=13.0, + height=6.5, + ) + + self.assertIsInstance(bolt, scad.Solid) + self.assertIsInstance(nut, scad.Solid) + + def test_public_factories_follow_make_rtype_naming(self): + factory_names = [ + name + for name in scad.std.fastener.__all__ + if callable(getattr(scad.std.fastener, name, None)) + ] + + self.assertEqual(factory_names, ["make_bolt_rsolid", "make_nut_rsolid"]) + for name in factory_names: + self.assertTrue(name.startswith("make_"), name) + self.assertIn("_r", name, name) + + +class TestBoltSolid(unittest.TestCase): + def test_supported_head_and_drive_styles(self): + styles = ( + ("hex", "none"), + ("square", "slot"), + ("cylindrical", "hex_socket"), + ("button", "cross"), + ("countersunk", "slot"), + ) + for head_style, drive_style in styles: + with self.subTest(head_style=head_style, drive_style=drive_style): + bolt = scad.std.fastener.make_bolt_rsolid( + diameter=8.0, + length=24.0, + head_style=head_style, + thread_style="partial", + drive_style=drive_style, + ) + meta = bolt.get_metadata("std.fastener.bolt") + + self.assertGreater(bolt.get_volume(), 0.0) + self.assertEqual(meta["head_style"], head_style) + self.assertEqual(meta["drive_style"], drive_style) + self.assertEqual(meta["thread_start"], 24.0 - meta["thread_length"]) + + def test_thread_styles_record_expected_interval(self): + none = scad.std.fastener.make_bolt_rsolid( + diameter=8.0, + length=24.0, + thread_style="none", + ) + full = scad.std.fastener.make_bolt_rsolid( + diameter=8.0, + length=24.0, + thread_style="full", + ) + partial = scad.std.fastener.make_bolt_rsolid( + diameter=8.0, + length=24.0, + thread_style="partial", + thread_length=15.0, + ) + + none_meta = none.get_metadata("std.fastener.bolt") + full_meta = full.get_metadata("std.fastener.bolt") + partial_meta = partial.get_metadata("std.fastener.bolt") + self.assertEqual(none_meta["thread_length"], 0.0) + self.assertEqual(full_meta["thread_start"], 0.0) + self.assertEqual(full_meta["thread_length"], 24.0) + self.assertEqual(partial_meta["thread_start"], 9.0) + self.assertEqual(partial_meta["thread_length"], 15.0) + + def test_metric_coarse_series_and_basic_thread_dimensions(self): + bolt = scad.std.fastener.make_bolt_rsolid( + diameter=10.0, + length=40.0, + ) + meta = bolt.get_metadata("std.fastener.bolt") + + self.assertEqual(meta["thread_pitch"], 1.5) + self.assertEqual(meta["thread_pitch_source"], "metric_coarse") + self.assertAlmostEqual(meta["pitch_diameter"], 10.0 - 0.6495 * 1.5, places=5) + self.assertAlmostEqual( + meta["basic_minor_diameter"], + 10.0 - 1.0825 * 1.5, + places=5, + ) + self.assertEqual(meta["thread_style"], "partial") + self.assertEqual(meta["thread_start"], 14.0) + self.assertEqual(meta["thread_length"], 26.0) + self.assertAlmostEqual(meta["head_width"], 16.0) + self.assertAlmostEqual(meta["head_height"], 6.4) + self.assertAlmostEqual(meta["head_across_corners"], 16.0 / 0.8660254038) + self.assertAlmostEqual(meta["underhead_fillet_radius"], 0.6) + self.assertEqual(meta["recommended_mating_hole_chamfer_min"], 0.6) + + def test_explicit_thread_pitch_is_required_outside_coarse_series(self): + with self.assertRaises(ValueError): + scad.std.fastener.make_bolt_rsolid(diameter=9.0, length=24.0) + + bolt = scad.std.fastener.make_bolt_rsolid( + diameter=9.0, + length=24.0, + thread_pitch=1.25, + ) + self.assertEqual( + bolt.get_metadata("std.fastener.bolt")["thread_pitch_source"], + "explicit", + ) + + def test_auto_uses_full_thread_when_standard_partial_length_exceeds_length(self): + bolt = scad.std.fastener.make_bolt_rsolid( + diameter=4.0, + length=13.0, + thread_detail="cosmetic", + ) + meta = bolt.get_metadata("std.fastener.bolt") + + self.assertEqual(meta["requested_thread_style"], "auto") + self.assertEqual(meta["thread_style"], "full") + self.assertEqual(meta["thread_length"], 13.0) + + def test_modeled_external_thread_strict_replay(self): + with scad.GraphSession() as session: + bolt = scad.std.fastener.make_bolt_rsolid( + diameter=8.0, + length=24.0, + head_style="button", + thread_style="partial", + thread_detail="modeled", + thread_form="v", + thread_pitch=1.25, + thread_depth=0.65, + thread_length=18.0, + drive_style="cross", + ) + model_json = scad.export_model_json(session=session) + + payload = json.loads(model_json) + ops = [node["op"] for node in payload["graph"]["nodes"]] + self.assertIn("make_helix_redge", ops) + self.assertIn("make_sweep_rsolid", ops) + self.assertIn("make_cut_rsolid", ops) + self.assertTrue( + all("topo_delta" not in node for node in payload["graph"]["nodes"]) + ) + + replayed = scad.replay_model_json(json_str=model_json, strict=True) + self.assertEqual(len(replayed), 1) + self.assertAlmostEqual(replayed[0].get_volume(), bolt.get_volume(), places=5) + print( + "modeled_bolt: " + f"volume={bolt.get_volume():.3f} faces={len(bolt.get_faces())}" + ) + + def test_external_thread_stabilization_records_only_selected_phase(self): + with scad.GraphSession() as session: + bolt = scad.std.fastener.make_bolt_rsolid( + diameter=6.0, + length=18.0, + thread_style="full", + thread_detail="modeled", + thread_form="v", + thread_pitch=1.0, + thread_depth=0.45, + ) + model_json = scad.export_model_json(session=session) + + meta = bolt.get_metadata("std.fastener.bolt") + payload = json.loads(model_json) + ops = [node["op"] for node in payload["graph"]["nodes"]] + self.assertNotEqual(meta["thread_phase_degrees"], 0.0) + self.assertEqual(ops.count("make_rotate_rshape"), 1) + replayed = scad.replay_model_json(json_str=model_json, strict=True) + self.assertAlmostEqual(replayed[0].get_volume(), bolt.get_volume(), places=5) + + def test_invalid_bolt_parameters(self): + invalid_kwargs = ( + {"diameter": 0.0, "length": 24.0}, + {"diameter": 8.0, "length": 24.0, "head_style": "wing"}, + {"diameter": 8.0, "length": 24.0, "head_width": 7.0}, + { + "diameter": 8.0, + "length": 24.0, + "thread_style": "partial", + "thread_length": 24.0, + }, + { + "diameter": 8.0, + "length": 24.0, + "thread_style": "full", + "thread_length": 12.0, + }, + { + "diameter": 8.0, + "length": 24.0, + "drive_style": "none", + "drive_size": 4.0, + }, + ) + for kwargs in invalid_kwargs: + with self.subTest(kwargs=kwargs), self.assertRaises(ValueError): + scad.std.fastener.make_bolt_rsolid(**kwargs) + + +class TestNutSolid(unittest.TestCase): + def test_supported_nut_and_hole_styles(self): + for nut_style in ("hex", "square", "round", "knurled"): + for hole_style in ("through", "blind"): + with self.subTest(nut_style=nut_style, hole_style=hole_style): + kwargs = {"hole_depth": 5.0} if hole_style == "blind" else {} + nut = scad.std.fastener.make_nut_rsolid( + diameter=8.0, + width=13.0, + height=6.5, + nut_style=nut_style, + hole_style=hole_style, + **kwargs, + ) + meta = nut.get_metadata("std.fastener.nut") + + self.assertGreater(nut.get_volume(), 0.0) + self.assertEqual(meta["nut_style"], nut_style) + self.assertEqual(meta["hole_style"], hole_style) + + def test_blind_hole_retains_more_material_than_through_hole(self): + through = scad.std.fastener.make_nut_rsolid( + diameter=8.0, + width=13.0, + height=7.0, + hole_style="through", + ) + blind = scad.std.fastener.make_nut_rsolid( + diameter=8.0, + width=13.0, + height=7.0, + hole_style="blind", + hole_depth=5.0, + ) + + self.assertGreater(blind.get_volume(), through.get_volume()) + + def test_modeled_internal_thread_strict_replay(self): + cosmetic = scad.std.fastener.make_nut_rsolid( + diameter=8.0, + width=13.0, + height=7.0, + nut_style="knurled", + hole_style="blind", + hole_depth=5.5, + thread_detail="cosmetic", + knurl_count=18, + ) + with scad.GraphSession() as session: + modeled = scad.std.fastener.make_nut_rsolid( + diameter=8.0, + width=13.0, + height=7.0, + nut_style="knurled", + hole_style="blind", + hole_depth=5.5, + thread_detail="modeled", + thread_form="trapezoidal", + thread_pitch=1.25, + thread_depth=0.65, + knurl_count=18, + ) + model_json = scad.export_model_json(session=session) + + self.assertGreater(modeled.get_volume(), cosmetic.get_volume()) + payload = json.loads(model_json) + ops = [node["op"] for node in payload["graph"]["nodes"]] + self.assertIn("make_helix_redge", ops) + self.assertIn("make_sweep_rsolid", ops) + self.assertIn("make_union_rsolid", ops) + self.assertTrue( + all("topo_delta" not in node for node in payload["graph"]["nodes"]) + ) + + replayed = scad.replay_model_json(json_str=model_json, strict=True) + self.assertEqual(len(replayed), 1) + self.assertAlmostEqual(replayed[0].get_volume(), modeled.get_volume(), places=5) + print( + "modeled_nut: " + f"volume={modeled.get_volume():.3f} faces={len(modeled.get_faces())}" + ) + + def test_internal_thread_stabilization_records_only_selected_phase(self): + with scad.GraphSession() as session: + nut = scad.std.fastener.make_nut_rsolid( + diameter=6.0, + width=10.0, + height=5.0, + thread_detail="modeled", + thread_form="trapezoidal", + thread_pitch=1.0, + thread_depth=0.45, + ) + model_json = scad.export_model_json(session=session) + + meta = nut.get_metadata("std.fastener.nut") + payload = json.loads(model_json) + ops = [node["op"] for node in payload["graph"]["nodes"]] + self.assertNotEqual(meta["thread_phase_degrees"], 0.0) + self.assertEqual(ops.count("make_rotate_rshape"), 1) + replayed = scad.replay_model_json(json_str=model_json, strict=True) + self.assertAlmostEqual(replayed[0].get_volume(), nut.get_volume(), places=5) + + def test_invalid_nut_parameters(self): + invalid_kwargs = ( + {"diameter": 8.0, "width": 8.2, "height": 6.5}, + { + "diameter": 8.0, + "width": 13.0, + "height": 6.5, + "nut_style": "castle", + }, + { + "diameter": 8.0, + "width": 13.0, + "height": 6.5, + "hole_style": "through", + "hole_depth": 5.0, + }, + { + "diameter": 8.0, + "width": 13.0, + "height": 6.5, + "hole_style": "blind", + "hole_depth": 6.5, + }, + { + "diameter": 8.0, + "width": 13.0, + "height": 6.5, + "nut_style": "knurled", + "knurl_count": 6, + }, + ) + for kwargs in invalid_kwargs: + with self.subTest(kwargs=kwargs), self.assertRaises(ValueError): + scad.std.fastener.make_nut_rsolid(**kwargs) + + +if __name__ == "__main__": + unittest.main() diff --git a/SimpleCADAPI/test/test_stdlib_gear.py b/SimpleCADAPI/test/test_stdlib_gear.py index be886d3..ea6a835 100644 --- a/SimpleCADAPI/test/test_stdlib_gear.py +++ b/SimpleCADAPI/test/test_stdlib_gear.py @@ -3,6 +3,7 @@ import json import math import unittest +from unittest import mock import simplecadapi as scad @@ -12,21 +13,26 @@ def _loft_nodes_for(factory): factory() payload = json.loads(scad.export_model_json(session=session)) - return [node for node in payload["graph"]["nodes"] if node["op"] == "make_loft_rsolid"] + return [ + node for node in payload["graph"]["nodes"] if node["op"] == "make_loft_rsolid" + ] class TestStdGearSurface(unittest.TestCase): def test_preferred_nested_std_export_surface(self): self.assertFalse(hasattr(scad, "std" + "_gear")) solid = scad.std.gear.make_spur_gear_rsolid( - n_teeth=8, module=1.0, gear_height=2.0, + n_teeth=8, + module=1.0, + gear_height=2.0, ) self.assertGreater(solid.get_volume(), 0.0) def test_public_gear_factories_follow_make_rtype_naming(self): factory_names = [ - name for name in scad.std.gear.__all__ + name + for name in scad.std.gear.__all__ if callable(getattr(scad.std.gear, name, None)) ] @@ -42,24 +48,37 @@ class TestSpurGear(unittest.TestCase): def test_basic_spur_gear(self): solid = scad.std.gear.make_spur_gear_rsolid( - n_teeth=10, module=2.0, pressure_angle=20.0, gear_height=5.0, + n_teeth=10, + module=2.0, + pressure_angle=20.0, + gear_height=5.0, ) self.assertGreater(solid.get_volume(), 0.0) def test_default_pressure_angle(self): solid = scad.std.gear.make_spur_gear_rsolid( - n_teeth=20, module=1.5, gear_height=4.0, + n_teeth=20, + module=1.5, + gear_height=4.0, ) self.assertGreater(solid.get_volume(), 0.0) def test_more_teeth_larger_volume(self): - small = scad.std.gear.make_spur_gear_rsolid(n_teeth=10, module=2.0, gear_height=5.0) - large = scad.std.gear.make_spur_gear_rsolid(n_teeth=24, module=2.0, gear_height=5.0) + small = scad.std.gear.make_spur_gear_rsolid( + n_teeth=10, module=2.0, gear_height=5.0 + ) + large = scad.std.gear.make_spur_gear_rsolid( + n_teeth=24, module=2.0, gear_height=5.0 + ) self.assertGreater(large.get_volume(), small.get_volume()) def test_height_scales_volume(self): - short = scad.std.gear.make_spur_gear_rsolid(n_teeth=12, module=2.0, gear_height=4.0) - tall = scad.std.gear.make_spur_gear_rsolid(n_teeth=12, module=2.0, gear_height=8.0) + short = scad.std.gear.make_spur_gear_rsolid( + n_teeth=12, module=2.0, gear_height=4.0 + ) + tall = scad.std.gear.make_spur_gear_rsolid( + n_teeth=12, module=2.0, gear_height=8.0 + ) self.assertAlmostEqual(tall.get_volume(), 2.0 * short.get_volume(), places=0) def test_invalid_params(self): @@ -72,12 +91,16 @@ class TestSpurGear(unittest.TestCase): with self.assertRaises(Exception): scad.std.gear.make_spur_gear_rsolid(n_teeth=10, module=2.0, backlash=-0.1) with self.assertRaises(Exception): - scad.std.gear.make_spur_gear_rsolid(n_teeth=10, module=2.0, addendum_factor=0.0) + scad.std.gear.make_spur_gear_rsolid( + n_teeth=10, module=2.0, addendum_factor=0.0 + ) def test_tip_radius_bounds(self): n_teeth = 20 module = 2.0 - solid = scad.std.gear.make_spur_gear_rsolid(n_teeth=n_teeth, module=module, gear_height=4.0) + solid = scad.std.gear.make_spur_gear_rsolid( + n_teeth=n_teeth, module=module, gear_height=4.0 + ) expected_tip = module * n_teeth / 2.0 + module max_r = 0.0 for face in solid.get_faces(): @@ -126,7 +149,9 @@ class TestSpurGear(unittest.TestCase): pressure_angle=math.radians(20.0), return_sketch=True, ) - fix_constraints = [constraint for constraint in sketch.constraints if constraint.kind == "fix"] + fix_constraints = [ + constraint for constraint in sketch.constraints if constraint.kind == "fix" + ] self.assertEqual(len(fix_constraints), 1) self.assertEqual(fix_constraints[0].targets[0]["entity_id"], "center") @@ -137,10 +162,16 @@ class TestSpurGear(unittest.TestCase): pressure_angle = math.radians(20.0) backlash = 0.12 no_backlash = scad.std.gear._compute_tooth_geometry( - n_teeth, module, pressure_angle, backlash=0.0, + n_teeth, + module, + pressure_angle, + backlash=0.0, ) with_backlash = scad.std.gear._compute_tooth_geometry( - n_teeth, module, pressure_angle, backlash=backlash, + n_teeth, + module, + pressure_angle, + backlash=backlash, ) no_backlash_width = no_backlash["right_start"] - no_backlash["left_start"] @@ -187,9 +218,150 @@ class TestSpurGear(unittest.TestCase): finally: scad.std.gear.fit_cubic_bspline_control_points = original - self.assertGreater(len(calls), 0) + self.assertEqual(len(calls), 1) self.assertTrue(all(call[1]["tolerance"] == 1e-4 for call in calls)) + def test_external_gear_reuses_rotated_canonical_involute(self): + n_teeth = 12 + _face, sketch = scad.std.gear._build_gear_profile_face( + n_teeth=n_teeth, + module=1.5, + pressure_angle=math.radians(20.0), + return_sketch=True, + ) + tooth_angle = 2.0 * math.pi / n_teeth + first = sketch.entities["bspline_left_0"].data["control_points"] + second = sketch.entities["bspline_left_1"].data["control_points"] + + for source, actual in zip(first, second): + expected = scad.std.gear._rotate_xy(tuple(source), tooth_angle) + self.assertAlmostEqual(actual[0], expected[0], places=10) + self.assertAlmostEqual(actual[1], expected[1], places=10) + + def test_external_gear_builds_only_two_canonical_root_fillets(self): + original = scad.std.gear._root_fillet_control_points + calls = [] + + def wrapped(*args, **kwargs): + calls.append((args, kwargs)) + return original(*args, **kwargs) + + scad.std.gear._root_fillet_control_points = wrapped + try: + scad.std.gear._build_gear_profile_face( + n_teeth=18, + module=1.5, + pressure_angle=math.radians(20.0), + ) + finally: + scad.std.gear._root_fillet_control_points = original + + self.assertEqual(len(calls), 2) + + def test_external_gear_builds_sketch_with_bounded_clone_count(self): + from simplecadapi.sketch import Sketch + + original = Sketch.clone + calls = [] + + def wrapped(sketch, *args, **kwargs): + calls.append(sketch) + return original(sketch, *args, **kwargs) + + with mock.patch.object(Sketch, "clone", wrapped): + scad.std.gear._build_gear_profile_face( + n_teeth=18, + module=1.5, + pressure_angle=math.radians(20.0), + ) + + self.assertLessEqual(len(calls), 2) + + def test_external_gear_linear_sketch_edits_strict_replay(self): + with scad.GraphSession() as session: + original = scad.std.gear.make_spur_gear_rsolid( + n_teeth=12, + module=2.0, + gear_height=4.0, + ) + + replayed = scad.replay_model_json( + json_str=scad.export_model_json(session=session), strict=True + )[0] + self.assertAlmostEqual(replayed.get_volume(), original.get_volume(), places=6) + + +class TestStraightBevelGear(unittest.TestCase): + def setUp(self): + scad.GraphSession() + + def test_basic_straight_bevel_gear(self): + solid = scad.std.gear.make_straight_bevel_gear_rsolid( + n_teeth=18, + module=4.0, + pitch_angle=45.0, + pressure_angle=20.0, + face_width=18.0, + ) + + self.assertGreater(solid.get_volume(), 0.0) + meta = solid.get_metadata("std.gear.straight_bevel") + self.assertEqual(meta["n_teeth"], 18) + self.assertEqual(meta["module"], 4.0) + self.assertEqual(meta["pitch_angle"], 45.0) + self.assertAlmostEqual(meta["outer_pitch_radius"], 36.0) + self.assertAlmostEqual( + meta["outer_cone_distance"], + 36.0 / math.sin(math.radians(45.0)), + ) + print( + "straight_bevel_gear: " + f"volume={solid.get_volume():.3f} " + f"inner_scale={meta['inner_scale']:.6f}" + ) + + def test_straight_bevel_gear_uses_ruled_similar_sections(self): + loft_nodes = _loft_nodes_for( + lambda: scad.std.gear.make_straight_bevel_gear_rsolid( + n_teeth=18, + module=4.0, + pitch_angle=45.0, + face_width=18.0, + ) + ) + + self.assertEqual(len(loft_nodes), 1) + self.assertEqual(loft_nodes[0]["params"]["profile_count"], 2) + self.assertTrue(loft_nodes[0]["params"]["ruled"]) + + def test_straight_bevel_gear_strict_replay(self): + with scad.GraphSession() as session: + original = scad.std.gear.make_straight_bevel_gear_rsolid( + n_teeth=18, + module=4.0, + pitch_angle=45.0, + face_width=18.0, + ) + model_json = scad.export_model_json(session=session) + + replayed = scad.replay_model_json(json_str=model_json, strict=True) + self.assertEqual(len(replayed), 1) + self.assertAlmostEqual( + replayed[0].get_volume(), original.get_volume(), places=5 + ) + + def test_invalid_straight_bevel_gear_params(self): + invalid_kwargs = ( + {"n_teeth": 2, "module": 4.0}, + {"n_teeth": 18, "module": 0.0}, + {"n_teeth": 18, "module": 4.0, "pitch_angle": 0.0}, + {"n_teeth": 18, "module": 4.0, "pitch_angle": 90.0}, + {"n_teeth": 18, "module": 4.0, "face_width": 60.0}, + ) + for kwargs in invalid_kwargs: + with self.subTest(kwargs=kwargs), self.assertRaises(ValueError): + scad.std.gear.make_straight_bevel_gear_rsolid(**kwargs) + class TestHelicalGear(unittest.TestCase): def setUp(self): @@ -197,14 +369,22 @@ class TestHelicalGear(unittest.TestCase): def test_basic_helical_gear(self): solid = scad.std.gear.make_helical_gear_rsolid( - n_teeth=12, module=2.0, helix_angle=25.0, gear_height=8.0, + n_teeth=12, + module=2.0, + helix_angle=25.0, + gear_height=8.0, ) self.assertGreater(solid.get_volume(), 0.0) def test_zero_helix_falls_back_to_spur(self): - spur = scad.std.gear.make_spur_gear_rsolid(n_teeth=12, module=2.0, gear_height=6.0) + spur = scad.std.gear.make_spur_gear_rsolid( + n_teeth=12, module=2.0, gear_height=6.0 + ) helical = scad.std.gear.make_helical_gear_rsolid( - n_teeth=12, module=2.0, gear_height=6.0, helix_angle=0.0, + n_teeth=12, + module=2.0, + gear_height=6.0, + helix_angle=0.0, ) self.assertAlmostEqual(spur.get_volume(), helical.get_volume(), places=0) @@ -212,19 +392,33 @@ class TestHelicalGear(unittest.TestCase): with self.assertRaises(Exception): scad.std.gear.make_helical_gear_rsolid(n_teeth=2, module=2.0) - def test_helical_gear_uses_small_step_ruled_loft(self): - loft_nodes = _loft_nodes_for( - lambda: scad.std.gear.make_helical_gear_rsolid( + def test_helical_gear_uses_one_continuous_twisted_sweep(self): + with scad.GraphSession() as session: + solid = scad.std.gear.make_helical_gear_rsolid( n_teeth=12, module=2.0, helix_angle=25.0, gear_height=8.0, ) - ) - self.assertEqual(len(loft_nodes), 1) - self.assertEqual(loft_nodes[0]["params"]["profile_count"], 7) - self.assertTrue(loft_nodes[0]["params"]["ruled"]) + payload = json.loads(scad.export_model_json(session=session)) + twisted_nodes = [ + node + for node in payload["graph"]["nodes"] + if node["op"] == "make_twisted_sweep_rsolid" + ] + self.assertEqual(len(twisted_nodes), 1) + self.assertNotIn("topo_delta", twisted_nodes[0]) + self.assertFalse( + any(node["op"] == "make_loft_rsolid" for node in payload["graph"]["nodes"]) + ) + self.assertEqual(len(solid.get_faces()), 74) + + replayed = scad.replay_model_json( + json_str=json.dumps(payload), strict=True + )[0] + self.assertEqual(len(replayed.get_faces()), 74) + self.assertAlmostEqual(replayed.get_volume(), solid.get_volume(), places=6) class TestHerringboneGear(unittest.TestCase): @@ -233,30 +427,84 @@ class TestHerringboneGear(unittest.TestCase): def test_basic_herringbone_gear(self): solid = scad.std.gear.make_herringbone_gear_rsolid( - n_teeth=12, module=2.0, helix_angle=25.0, gear_height=10.0, + n_teeth=12, + module=2.0, + helix_angle=25.0, + gear_height=10.0, ) self.assertGreater(solid.get_volume(), 0.0) def test_zero_helix_falls_back_to_spur(self): - spur = scad.std.gear.make_spur_gear_rsolid(n_teeth=12, module=2.0, gear_height=8.0) + spur = scad.std.gear.make_spur_gear_rsolid( + n_teeth=12, module=2.0, gear_height=8.0 + ) herringbone = scad.std.gear.make_herringbone_gear_rsolid( - n_teeth=12, module=2.0, gear_height=8.0, helix_angle=0.0, + n_teeth=12, + module=2.0, + gear_height=8.0, + helix_angle=0.0, ) self.assertAlmostEqual(spur.get_volume(), herringbone.get_volume(), places=0) - def test_herringbone_gear_uses_small_step_ruled_loft(self): - loft_nodes = _loft_nodes_for( - lambda: scad.std.gear.make_herringbone_gear_rsolid( + def test_herringbone_gear_unions_two_replayable_twisted_halves(self): + with scad.GraphSession() as session: + solid = scad.std.gear.make_herringbone_gear_rsolid( n_teeth=12, module=2.0, helix_angle=25.0, gear_height=10.0, ) - ) - self.assertEqual(len(loft_nodes), 1) - self.assertEqual(loft_nodes[0]["params"]["profile_count"], 9) - self.assertTrue(loft_nodes[0]["params"]["ruled"]) + payload = json.loads(scad.export_model_json(session=session)) + operations = [node["op"] for node in payload["graph"]["nodes"]] + self.assertEqual(operations.count("make_twisted_sweep_rsolid"), 2) + self.assertEqual(operations.count("make_union_rsolid"), 1) + self.assertNotIn("make_loft_rsolid", operations) + self.assertTrue( + all( + "topo_delta" not in node + for node in payload["graph"]["nodes"] + if node["op"] == "make_twisted_sweep_rsolid" + ) + ) + self.assertEqual(len(solid.get_faces()), 146) + + replayed = scad.replay_model_json( + json_str=json.dumps(payload), strict=True + )[0] + self.assertEqual(len(replayed.get_faces()), 146) + self.assertAlmostEqual(replayed.get_volume(), solid.get_volume(), places=6) + + def test_stdlib_graph_tracking_scope_does_not_leak(self): + with scad.GraphSession() as session: + scad.std.gear.make_herringbone_gear_rsolid( + n_teeth=8, + module=1.0, + helix_angle=20.0, + gear_height=4.0, + ) + profile = scad.make_rectangle_rface( + width=1.0, + height=1.0, + center=(0.0, 0.0, 0.0), + ) + scad.twisted_sweep_rsolid( + profile=profile, + distance=2.0, + twist_angle=15.0, + ) + + payload = json.loads(scad.export_model_json(session=session)) + twisted_nodes = [ + node + for node in payload["graph"]["nodes"] + if node["op"] == "make_twisted_sweep_rsolid" + ] + self.assertEqual(len(twisted_nodes), 3) + self.assertEqual( + sum("topo_delta" in node for node in twisted_nodes), + 1, + ) class TestSpurRingGear(unittest.TestCase): @@ -265,7 +513,10 @@ class TestSpurRingGear(unittest.TestCase): def test_basic_ring_gear(self): solid = scad.std.gear.make_spur_ring_gear_rsolid( - n_teeth=20, module=2.0, gear_height=5.0, rim_thickness=4.0, + n_teeth=20, + module=2.0, + gear_height=5.0, + rim_thickness=4.0, ) self.assertGreater(solid.get_volume(), 0.0) @@ -274,7 +525,10 @@ class TestSpurRingGear(unittest.TestCase): module = 2.0 rim_thickness = 4.0 ring = scad.std.gear.make_spur_ring_gear_rsolid( - n_teeth=n_teeth, module=module, gear_height=5.0, rim_thickness=rim_thickness, + n_teeth=n_teeth, + module=module, + gear_height=5.0, + rim_thickness=rim_thickness, ) pitch_radius = module * n_teeth / 2.0 outer_r = pitch_radius + 1.25 * module + rim_thickness @@ -302,13 +556,18 @@ class TestSpurRingGear(unittest.TestCase): pitch_radius = module * n_teeth / 2.0 base_radius = pitch_radius * math.cos(pressure_angle) self.assertAlmostEqual(min(vertex_radii), pitch_radius - module, places=5) - self.assertAlmostEqual(max(vertex_radii), pitch_radius + 1.25 * module, places=5) + self.assertAlmostEqual( + max(vertex_radii), pitch_radius + 1.25 * module, places=5 + ) self.assertGreater(min(vertex_radii), base_radius + 0.5 * module) def test_spur_ring_gear_uses_direct_multi_loop_face_not_2d_cut(self): with scad.GraphSession() as session: scad.std.gear.make_spur_ring_gear_rsolid( - n_teeth=20, module=2.0, gear_height=5.0, rim_thickness=4.0, + n_teeth=20, + module=2.0, + gear_height=5.0, + rim_thickness=4.0, ) payload = json.loads(scad.export_model_json(session)) @@ -337,7 +596,9 @@ class TestSpurRingGear(unittest.TestCase): pressure_angle=math.radians(20.0), return_sketch=True, ) - fix_constraints = [constraint for constraint in sketch.constraints if constraint.kind == "fix"] + fix_constraints = [ + constraint for constraint in sketch.constraints if constraint.kind == "fix" + ] self.assertEqual(len(fix_constraints), 1) self.assertEqual(fix_constraints[0].targets[0]["entity_id"], "center") @@ -348,10 +609,16 @@ class TestSpurRingGear(unittest.TestCase): pressure_angle = math.radians(20.0) backlash = 0.12 no_backlash = scad.std.gear._compute_internal_tooth_geometry( - n_teeth, module, pressure_angle, backlash=0.0, + n_teeth, + module, + pressure_angle, + backlash=0.0, ) with_backlash = scad.std.gear._compute_internal_tooth_geometry( - n_teeth, module, pressure_angle, backlash=backlash, + n_teeth, + module, + pressure_angle, + backlash=backlash, ) no_backlash_space = no_backlash["tooth_angle"] - ( @@ -387,9 +654,13 @@ class TestSpurRingGear(unittest.TestCase): with self.assertRaises(Exception): scad.std.gear.make_spur_ring_gear_rsolid(n_teeth=2, module=2.0) with self.assertRaises(Exception): - scad.std.gear.make_spur_ring_gear_rsolid(n_teeth=10, module=2.0, rim_thickness=0.0) + scad.std.gear.make_spur_ring_gear_rsolid( + n_teeth=10, module=2.0, rim_thickness=0.0 + ) with self.assertRaises(Exception): - scad.std.gear.make_spur_ring_gear_rsolid(n_teeth=10, module=2.0, backlash=-0.1) + scad.std.gear.make_spur_ring_gear_rsolid( + n_teeth=10, module=2.0, backlash=-0.1 + ) class TestHelicalRingGear(unittest.TestCase): @@ -398,7 +669,10 @@ class TestHelicalRingGear(unittest.TestCase): def test_basic_helical_ring(self): solid = scad.std.gear.make_helical_ring_gear_rsolid( - n_teeth=20, module=2.0, helix_angle=20.0, gear_height=8.0, + n_teeth=20, + module=2.0, + helix_angle=20.0, + gear_height=8.0, ) self.assertGreater(solid.get_volume(), 0.0) @@ -423,23 +697,41 @@ class TestHerringboneRingGear(unittest.TestCase): def test_basic_herringbone_ring(self): solid = scad.std.gear.make_herringbone_ring_gear_rsolid( - n_teeth=20, module=2.0, helix_angle=20.0, gear_height=10.0, + n_teeth=20, + module=2.0, + helix_angle=20.0, + gear_height=10.0, ) self.assertGreater(solid.get_volume(), 0.0) def test_herringbone_ring_uses_small_step_ruled_inner_loft(self): - loft_nodes = _loft_nodes_for( - lambda: scad.std.gear.make_herringbone_ring_gear_rsolid( + with scad.GraphSession() as session: + scad.std.gear.make_herringbone_ring_gear_rsolid( n_teeth=20, module=2.0, helix_angle=20.0, gear_height=10.0, ) - ) + + payload = json.loads(scad.export_model_json(session=session)) + loft_nodes = [ + node + for node in payload["graph"]["nodes"] + if node["op"] == "make_loft_rsolid" + ] + cut_nodes = [ + node + for node in payload["graph"]["nodes"] + if node["op"] == "make_cut_rsolid" + ] self.assertEqual(len(loft_nodes), 1) self.assertEqual(loft_nodes[0]["params"]["profile_count"], 9) self.assertTrue(loft_nodes[0]["params"]["ruled"]) + self.assertEqual(loft_nodes[0]["params"]["tracking_policy"], "graph") + self.assertEqual(len(cut_nodes), 1) + self.assertEqual(cut_nodes[0]["params"]["tracking_policy"], "graph") + self.assertNotIn("topo_delta", cut_nodes[0]) class TestCycloidalDisc(unittest.TestCase): @@ -580,12 +872,18 @@ class TestSpurRack(unittest.TestCase): scad.GraphSession() def test_basic_rack(self): - solid = scad.std.gear.make_spur_rack_rsolid(module=2.0, n_teeth=8, rack_height=5.0) + solid = scad.std.gear.make_spur_rack_rsolid( + module=2.0, n_teeth=8, rack_height=5.0 + ) self.assertGreater(solid.get_volume(), 0.0) def test_more_teeth_larger_volume(self): - short = scad.std.gear.make_spur_rack_rsolid(module=2.0, n_teeth=5, rack_height=5.0) - long = scad.std.gear.make_spur_rack_rsolid(module=2.0, n_teeth=10, rack_height=5.0) + short = scad.std.gear.make_spur_rack_rsolid( + module=2.0, n_teeth=5, rack_height=5.0 + ) + long = scad.std.gear.make_spur_rack_rsolid( + module=2.0, n_teeth=10, rack_height=5.0 + ) self.assertGreater(long.get_volume(), short.get_volume()) def test_invalid_params(self): @@ -609,7 +907,10 @@ class TestHelicalRack(unittest.TestCase): def test_basic_helical_rack(self): solid = scad.std.gear.make_helical_rack_rsolid( - module=2.0, n_teeth=8, helix_angle=25.0, rack_height=8.0, + module=2.0, + n_teeth=8, + helix_angle=25.0, + rack_height=8.0, ) self.assertGreater(solid.get_volume(), 0.0) @@ -620,7 +921,10 @@ class TestHerringboneRack(unittest.TestCase): def test_basic_herringbone_rack(self): solid = scad.std.gear.make_herringbone_rack_rsolid( - module=2.0, n_teeth=8, helix_angle=30.0, rack_height=10.0, + module=2.0, + n_teeth=8, + helix_angle=30.0, + rack_height=10.0, ) self.assertGreater(solid.get_volume(), 0.0) @@ -634,7 +938,9 @@ class Test2DFaceBoolean(unittest.TestCase): inner = scad.make_circle_rface(center=(0, 0, 0), radius=4.0) ring = scad.make_2d_cut_rface(outer, inner) self.assertAlmostEqual( - ring.get_area(), math.pi * (100 - 16), places=1, + ring.get_area(), + math.pi * (100 - 16), + places=1, ) self.assertEqual(len(ring.get_inner_wires()), 1) @@ -643,7 +949,9 @@ class Test2DFaceBoolean(unittest.TestCase): inner = scad.make_circle_rwire(center=(0, 0, 0), radius=4.0) ring = scad.make_face_from_wires_rface(outer, [inner]) self.assertAlmostEqual( - ring.get_area(), math.pi * (100 - 16), places=1, + ring.get_area(), + math.pi * (100 - 16), + places=1, ) self.assertEqual(len(ring.get_inner_wires()), 1) diff --git a/SimpleCADAPI/test/test_tagging_refactor.py b/SimpleCADAPI/test/test_tagging_refactor.py index 42a8bdd..5af8669 100644 --- a/SimpleCADAPI/test/test_tagging_refactor.py +++ b/SimpleCADAPI/test/test_tagging_refactor.py @@ -1,7 +1,8 @@ import unittest +from unittest import mock import simplecadapi as scad -from simplecadapi import tagging +from simplecadapi import operations, tagging class TestTaggingRefactor(unittest.TestCase): @@ -11,25 +12,215 @@ class TestTaggingRefactor(unittest.TestCase): self.assertFalse(tagging.is_normalized_tag("Face.Top")) self.assertFalse(tagging.is_normalized_tag("size: 2x3x4")) - def test_tag_policy_propagation(self): - policy = tagging.DEFAULT_TAG_POLICY - self.assertTrue(policy.should_propagate("role.mounting_surface")) - self.assertTrue(policy.should_propagate("anchor.datum.primary")) - self.assertTrue(policy.should_propagate("group.fasteners")) - self.assertFalse(policy.should_propagate("feature.extrude.start_face")) - self.assertFalse(policy.should_propagate("state.debug")) - self.assertFalse(policy.should_propagate("face.top")) - self.assertFalse(policy.should_propagate("legacy.top")) - - def test_apply_tag_propagates_role(self): + def test_apply_tag_does_not_infer_propagation_from_prefix(self): box = scad.make_box_rsolid(1.0, 1.0, 1.0) scad.apply_tag(box, "role.mounting_surface") - faces = box.get_faces() - self.assertTrue(any("role.mounting_surface" in scad.list_tags(face) for face in faces)) + self.assertIn("role.mounting_surface", box._list_tags("local")) + self.assertFalse( + any( + "role.mounting_surface" in face._list_tags("effective") + for face in box.get_faces() + ) + ) - edges = box.get_edges() - self.assertTrue(any("role.mounting_surface" in scad.list_tags(edge) for edge in edges)) + def test_explicit_downward_propagation_is_computed_not_copied(self): + box = scad.make_box_rsolid(1.0, 1.0, 1.0) + binding = box._apply_user_tag( + "role.mounting_surface", topology_propagation="downward" + ) + face = box.get_faces(0) + edge = face.get_edges(0) + + self.assertEqual( + face._list_tags("local"), ["face.box.back", "face.left"] + ) + self.assertNotIn("role.mounting_surface", face._list_tags("local")) + self.assertIn("role.mounting_surface", face._list_tags("inherited")) + self.assertIn("role.mounting_surface", edge._list_tags("inherited")) + self.assertNotIn(binding, face._tag_bindings) + self.assertIn("role.mounting_surface", face._tags) + + def test_effective_excludes_lineage(self): + source = scad.make_point_rvertex(0.0, 0.0, 0.0) + result = scad.make_point_rvertex(1.0, 0.0, 0.0) + binding = source._apply_user_tag("role.datum") + result._add_tag_lineage( + binding, + derivation="continuation", + source_topo_id=source.topo_id, + evidence=tagging.TagEvidence( + "topology_change", {"change_ids": ["change_1"]} + ), + ) + result._set_runtime("semantic.lineage.coverage", "complete") + + self.assertNotIn("role.datum", result._list_tags("effective")) + self.assertEqual(result._list_tags("lineage"), ["role.datum"]) + + def test_lineage_requires_complete_history(self): + vertex = scad.make_point_rvertex(0.0, 0.0, 0.0) + + with self.assertRaises(tagging.UnsupportedQueryCapabilityError): + vertex._list_tags("lineage") + + vertex._set_runtime("semantic.lineage.coverage", "partial") + with self.assertRaises(tagging.UnsupportedQueryCapabilityError): + vertex._list_tags("lineage") + + def test_default_lineage_policy_rejects_boundary(self): + source = scad.make_point_rvertex(0.0, 0.0, 0.0) + result = scad.make_point_rvertex(1.0, 0.0, 0.0) + binding = source._apply_user_tag("role.datum") + result._add_tag_lineage( + binding, + derivation="boundary", + source_topo_id=source.topo_id, + evidence=tagging.TagEvidence("topology_change"), + ) + result._set_runtime("semantic.lineage.coverage", "complete") + + self.assertEqual(result._list_tags("lineage"), []) + + def test_lineage_continues_across_multiple_proven_hops(self): + source = scad.make_point_rvertex(0.0, 0.0, 0.0) + scad.apply_tag(source, "role.datum") + + first = scad.translate_shape(source, (1.0, 0.0, 0.0)) + second = scad.translate_shape(first, (1.0, 0.0, 0.0)) + + self.assertNotIn("role.datum", scad.list_tags(second, "effective")) + self.assertEqual(scad.list_tags(second, "lineage"), ["role.datum"]) + + def test_disallowed_lineage_does_not_seed_later_continuation(self): + source = scad.make_point_rvertex(0.0, 0.0, 0.0) + intermediate = scad.make_point_rvertex(1.0, 0.0, 0.0) + binding = source._apply_user_tag("role.datum") + intermediate._add_tag_lineage( + binding, + derivation="boundary", + source_topo_id=source.topo_id, + evidence=tagging.TagEvidence("topology_change"), + ) + intermediate._set_runtime("semantic.lineage.coverage", "complete") + + result = scad.translate_shape(intermediate, (1.0, 0.0, 0.0)) + + self.assertEqual(scad.list_tags(result, "lineage"), []) + + def test_lineage_explanation_filters_disallowed_witnesses(self): + source = scad.make_point_rvertex(0.0, 0.0, 0.0) + result = scad.make_point_rvertex(1.0, 0.0, 0.0) + binding = source._apply_user_tag("role.datum") + result._add_tag_lineage( + binding, + derivation="boundary", + source_topo_id=source.topo_id, + evidence=tagging.TagEvidence("topology_change"), + ) + result._set_runtime("semantic.lineage.coverage", "complete") + + self.assertEqual(scad.explain_tag(result, "role.datum", "lineage"), []) + + def test_lineage_witness_roundtrip_preserves_source_binding(self): + binding = tagging.user_tag_binding("role.datum", node_id="node_source") + witness = tagging.TagLineageWitness( + binding=binding, + derivation="fragment", + source_topo_id="face_source", + target_topo_id="face_target", + evidence=tagging.TagEvidence( + "topology_change", {"change_ids": ["change_1"]} + ), + ) + + self.assertEqual( + tagging.TagLineageWitness.from_dict(witness.to_dict()), witness + ) + + def test_binding_schema_roundtrip_is_strict(self): + binding = tagging.TagBinding( + binding_id="tag_binding_mounting_surface", + tag="role.mounting_surface", + producer=tagging.TagProducer("user_operation", node_id="node_tag"), + scope=tagging.TagBindingScope("node_body", 0), + target=tagging.TagTarget( + "selection_query", + query_hash="sha256:query", + binding_hash="sha256:binding", + ), + propagation=tagging.TagPropagation( + topology="local", lineage="continuation_fragment" + ), + evidence=tagging.TagEvidence( + "query_execution", + { + "execution_hash": "sha256:execution", + "selected_refs": [], + }, + ), + ) + + payload = binding.to_dict() + self.assertEqual(payload["schema_version"], "1.0") + self.assertEqual(tagging.TagBinding.from_dict(payload), binding) + + malformed = dict(payload) + malformed["unknown"] = True + with self.assertRaises(tagging.TagValidationError): + tagging.TagBinding.from_dict(malformed) + + missing_scope = dict(payload) + missing_scope.pop("scope") + with self.assertRaises(tagging.TagValidationError): + tagging.TagBinding.from_dict(missing_scope) + + def test_same_token_dedupes_but_explanation_preserves_producers(self): + vertex = scad.make_point_rvertex(0.0, 0.0, 0.0) + user_binding = tagging.user_tag_binding( + "role.datum", node_id="node_user" + ) + auto_binding = tagging.TagBinding( + tag="role.datum", + producer=tagging.TagProducer( + "auto_rule", + rule_id="simplecad.test.datum", + rule_version="1.0", + ), + evidence=tagging.TagEvidence("geometry_classification"), + certainty="proven", + lifecycle="recompute", + ) + vertex._add_tag_binding(user_binding) + vertex._add_tag_binding(auto_binding) + + self.assertEqual(vertex._list_tags("local"), ["role.datum"]) + explanations = vertex._explain_tag("role.datum", "local") + self.assertEqual(len(explanations), 2) + self.assertEqual( + {item["producer"]["kind"] for item in explanations}, + {"user_operation", "auto_rule"}, + ) + + self.assertEqual(vertex._remove_tag("role.datum"), 1) + self.assertEqual(vertex._list_tags("local"), ["role.datum"]) + self.assertEqual( + vertex._explain_tag("role.datum", "local")[0]["producer"]["kind"], + "auto_rule", + ) + + def test_internal_and_legacy_bindings_are_not_user_assertions(self): + vertex = scad.make_point_rvertex(0.0, 0.0, 0.0) + vertex._add_tag("internal.marker") + internal = vertex._explain_tag("internal.marker", "local")[0] + self.assertEqual(internal["producer"]["kind"], "auto_rule") + + vertex._tags = {"legacy.marker"} + legacy = vertex._explain_tag("legacy.marker", "effective")[0] + self.assertEqual(legacy["producer"]["kind"], "legacy_import") + self.assertEqual(legacy["attachment"], "effective_legacy") + with self.assertRaises(tagging.UnsupportedQueryCapabilityError): + vertex._list_tags("local") def test_tagging_public_surface_is_functional_and_sorted(self): vertex = scad.make_point_rvertex(0.0, 0.0, 0.0) @@ -42,6 +233,64 @@ class TestTaggingRefactor(unittest.TestCase): self.assertFalse(hasattr(vertex, member_name)) self.assertFalse(hasattr(scad, "set_tag")) + def test_apply_tag_mutates_without_cloning_semantic_topology(self): + box = scad.make_box_rsolid(1.0, 1.0, 1.0) + + with mock.patch.object( + operations, + "clone_semantic_shape_view", + wraps=operations.clone_semantic_shape_view, + ) as clone: + tagged = scad.apply_tag(box, "role.structure") + + self.assertIs(tagged, box) + self.assertEqual(clone.call_count, 0) + self.assertIn("role.structure", scad.list_tags(box, scope="local")) + + def test_apply_tag_rselection_keeps_independent_clone(self): + box = scad.make_box_rsolid(1.0, 1.0, 1.0) + + with mock.patch.object( + operations, + "clone_semantic_shape_view", + wraps=operations.clone_semantic_shape_view, + ) as clone: + tagged = scad.apply_tag_rselection( + box, + scad.ql.solids().exactly(1), + "role.structure", + ) + + self.assertIsNot(tagged, box) + self.assertEqual(clone.call_count, 1) + self.assertNotIn("role.structure", scad.list_tags(box, scope="local")) + self.assertIn("role.structure", scad.list_tags(tagged, scope="local")) + + def test_apply_tag_rselection_and_explain_tag_public_surface(self): + box = scad.make_box_rsolid(1.0, 1.0, 1.0) + source_face = box.get_faces(0) + + tagged = scad.apply_tag_rselection( + box, + [source_face], + "role.mounting_surface", + topology_propagation=scad.TopologyPropagation.LOCAL, + lineage_policy=scad.LineagePolicy.CONTINUATION_FRAGMENT, + ) + tagged_face = next( + face for face in tagged.get_faces() if face.topo_id == source_face.topo_id + ) + + self.assertIn( + "role.mounting_surface", + scad.list_tags(tagged_face, scad.TagScope.LOCAL), + ) + explanation = scad.explain_tag( + tagged_face, "role.mounting_surface", scad.TagScope.LOCAL + ) + self.assertEqual(len(explanation), 1) + self.assertEqual(explanation[0]["producer"]["kind"], "user_operation") + def test_anchor_resolution_candidate_priority(self): candidates = tagging.resolve_anchor_tag_candidates("mounting_surface") diff --git a/SimpleCADAPI/test/test_tolerance.py b/SimpleCADAPI/test/test_tolerance.py new file mode 100644 index 0000000..7a1d997 --- /dev/null +++ b/SimpleCADAPI/test/test_tolerance.py @@ -0,0 +1,722 @@ +"""Complete coverage for expression-driven dimension tolerance chains.""" + +from __future__ import annotations + +import json +import math +import unittest + +import simplecadapi as scad + + +class TestDimensionToleranceDeclaration(unittest.TestCase): + def test_symmetric_and_asymmetric_tolerances(self): + symmetric = scad.var("width", 10.0, tolerance=0.2) + asymmetric = scad.var("shaft", 8.0, tolerance=(-0.05, 0.0)) + + self.assertEqual(symmetric.tolerance.lower_deviation, -0.2) + self.assertEqual(symmetric.tolerance.upper_deviation, 0.2) + self.assertEqual(asymmetric.tolerance.lower_deviation, -0.05) + self.assertEqual(asymmetric.tolerance.upper_deviation, 0.0) + + def test_dimension_tolerance_roundtrip(self): + tolerance = scad.DimensionTolerance(-0.1, 0.3) + self.assertEqual( + scad.DimensionTolerance.from_dict(tolerance.to_dict()), tolerance + ) + self.assertAlmostEqual(tolerance.width, 0.4) + + def test_invalid_variable_and_tolerance_values_are_rejected(self): + invalid_calls = [ + lambda: scad.var("x", True), + lambda: scad.var("x", math.inf), + lambda: scad.var("x", 1.0, tolerance=True), + lambda: scad.var("x", 1.0, tolerance=-0.1), + lambda: scad.var("x", 1.0, tolerance=(0.1, 0.2)), + lambda: scad.var("x", 1.0, tolerance=(-0.2, -0.1)), + lambda: scad.var("x", 1.0, tolerance=(-0.1, 0.1, 0.2)), + lambda: scad.DimensionTolerance(float("nan"), 0.1), + ] + + for call in invalid_calls: + with self.subTest(call=call), self.assertRaises((TypeError, ValueError)): + call() + + def test_expression_graph_preserves_variable_tolerances_and_forward_refs(self): + width = scad.var("width", 10.0, tolerance=(-0.1, 0.2)) + graph = scad.ExpressionGraph() + expression = width * 2.0 + graph.register(expression) + payload = graph.to_dict() + payload["nodes"].reverse() + + rebuilt = scad.ExpressionGraph.from_dict(payload) + rebuilt_width = rebuilt.get(width.expr_id) + + self.assertIsInstance(rebuilt_width, scad.Var) + self.assertEqual(rebuilt_width.tolerance, width.tolerance) + self.assertAlmostEqual(rebuilt.get(expression.expr_id).evaluate(), 20.0) + + def test_var_positional_expr_id_remains_compatible(self): + variable = scad.Var("width", 10.0, "plate width", "var_width") + + self.assertEqual(variable.expr_id, "var_width") + self.assertIsNone(variable.tolerance) + + def test_malformed_expression_graphs_are_rejected(self): + valid_var = { + "expr_id": "var_x", + "kind": "var", + "name": "x", + "default": 1.0, + "tolerance": {"lower_deviation": -0.1, "upper_deviation": 0.1}, + } + payloads = [ + {"nodes": [valid_var, dict(valid_var)]}, + { + "nodes": [ + { + "expr_id": "expr_a", + "kind": "expr", + "op": "neg", + "args": ["missing"], + } + ] + }, + { + "nodes": [ + { + "expr_id": "expr_a", + "kind": "expr", + "op": "neg", + "args": ["expr_b"], + }, + { + "expr_id": "expr_b", + "kind": "expr", + "op": "neg", + "args": ["expr_a"], + }, + ] + }, + { + "nodes": [ + valid_var, + { + "expr_id": "expr_bad", + "kind": "expr", + "op": "add", + "args": ["var_x"], + }, + ] + }, + ] + + for payload in payloads: + with self.subTest(payload=payload), self.assertRaises(ValueError): + scad.ExpressionGraph.from_dict(payload) + + def test_expression_registration_is_atomic_for_conflicting_ids(self): + left = scad.Var( + "left", + 1.0, + expr_id="var_duplicate", + tolerance=scad.DimensionTolerance.symmetric(0.1), + ) + right = scad.Var( + "right", + 2.0, + expr_id="var_duplicate", + tolerance=scad.DimensionTolerance.symmetric(0.1), + ) + expression = scad.Expr("add", (left, right)) + graph = scad.ExpressionGraph() + + with self.assertRaisesRegex(ValueError, "already registered"): + graph.register(expression) + + self.assertEqual(graph.node_count, 0) + + def test_expression_registration_rejects_cycles_without_mutation(self): + expression = scad.Expr("neg", (scad.const(1.0),), expr_id="expr_cycle") + object.__setattr__(expression, "args", (expression,)) + graph = scad.ExpressionGraph() + + with self.assertRaisesRegex(ValueError, "cycle"): + graph.register(expression) + + self.assertEqual(graph.node_count, 0) + + def test_expression_arguments_are_normalized_to_an_immutable_tuple(self): + arguments = [scad.const(1.0)] + expression = scad.Expr("neg", arguments) + arguments[0] = expression + + self.assertIsInstance(expression.args, tuple) + self.assertIsNot(expression.args[0], expression) + + def test_signed_zero_nodes_with_the_same_id_are_distinct(self): + positive = scad.Const(0.0, expr_id="const_zero") + negative = scad.Const(-0.0, expr_id="const_zero") + graph = scad.ExpressionGraph() + graph.register(positive) + + with self.assertRaisesRegex(ValueError, "different node"): + graph.register(negative) + + def test_expression_payload_missing_required_fields_is_rejected_cleanly(self): + payloads = [ + {"nodes": [{"expr_id": "const_x", "kind": "const"}]}, + { + "nodes": [ + {"expr_id": "var_x", "kind": "var", "default": 1.0} + ] + }, + { + "nodes": [ + {"expr_id": "var_x", "kind": "var", "name": "x"} + ] + }, + { + "nodes": [ + {"expr_id": "expr_x", "kind": "expr", "args": []} + ] + }, + ] + + for payload in payloads: + with self.subTest(payload=payload), self.assertRaises(ValueError): + scad.ExpressionGraph.from_dict(payload) + + def test_huge_numeric_inputs_are_rejected_as_non_finite(self): + with self.assertRaisesRegex(ValueError, "finite"): + scad.const(10**10000) + + +class TestWorstCaseTolerancePropagation(unittest.TestCase): + def test_addition_and_subtraction_preserve_asymmetric_deviations(self): + a = scad.var("a", 10.0, tolerance=(-0.1, 0.2)) + b = scad.var("b", 5.0, tolerance=(-0.3, 0.4)) + + result = scad.analyze_tolerance(a - b) + + self.assertAlmostEqual(result.nominal, 5.0) + self.assertAlmostEqual(result.lower_deviation, -0.5) + self.assertAlmostEqual(result.upper_deviation, 0.5) + self.assertEqual(len(result.contributions), 2) + + def test_linear_scaling_and_repeated_variable_dependency_are_exact(self): + x = scad.var("x", 4.0, tolerance=(-0.2, 0.3)) + + scaled = scad.analyze_tolerance(-2.0 * x + 1.0) + cancelled = scad.analyze_tolerance(x - x) + + self.assertAlmostEqual(scaled.lower_deviation, -0.6) + self.assertAlmostEqual(scaled.upper_deviation, 0.4) + self.assertAlmostEqual(cancelled.lower_deviation, 0.0) + self.assertAlmostEqual(cancelled.upper_deviation, 0.0) + + def test_multiplication_handles_all_interval_corner_signs(self): + a = scad.var("a", -1.0, tolerance=(-1.0, 0.5)) + b = scad.var("b", 3.0, tolerance=(-1.0, 1.0)) + + result = scad.analyze_tolerance(a * b) + + self.assertAlmostEqual(result.nominal, -3.0) + self.assertAlmostEqual(result.lower_bound, -8.0) + self.assertAlmostEqual(result.upper_bound, -1.0) + + def test_division_rejects_denominator_interval_containing_zero(self): + numerator = scad.var("numerator", 2.0, tolerance=0.1) + denominator = scad.var("denominator", 1.0, tolerance=1.0) + + with self.assertRaisesRegex( + scad.ToleranceAnalysisError, "denominator.*contains zero" + ): + scad.analyze_tolerance(numerator / denominator) + + def test_integer_and_fractional_power_intervals(self): + signed = scad.var("signed", 0.0, tolerance=2.0) + positive = scad.var("positive", 4.0, tolerance=(-3.0, 5.0)) + + squared = scad.analyze_tolerance(signed**2) + rooted = scad.analyze_tolerance(scad.sqrt(positive)) + + self.assertLessEqual(squared.lower_bound, 0.0) + self.assertGreaterEqual(squared.upper_bound, 4.0) + self.assertAlmostEqual(squared.upper_bound, 4.0) + self.assertLessEqual(rooted.lower_bound, 1.0) + self.assertGreaterEqual(rooted.upper_bound, 3.0) + self.assertAlmostEqual(rooted.lower_bound, 1.0) + self.assertAlmostEqual(rooted.upper_bound, 3.0) + + def test_power_domain_errors_are_explicit(self): + negative = scad.var("negative", -2.0, tolerance=0.1) + crosses_zero = scad.var("crosses_zero", 0.0, tolerance=0.1) + + with self.assertRaisesRegex(scad.ToleranceAnalysisError, "negative base"): + scad.analyze_tolerance(negative**0.5) + with self.assertRaisesRegex(scad.ToleranceAnalysisError, "zero"): + scad.analyze_tolerance(crosses_zero ** -1) + + def test_invalid_constant_subexpressions_use_analysis_errors(self): + expression = scad.sqrt(-1.0) + + with self.assertRaisesRegex( + scad.ToleranceAnalysisError, "declared tolerance interval" + ): + scad.analyze_tolerance(expression) + + def test_trigonometric_extrema_and_discontinuities(self): + angle = scad.var("angle", 0.0, tolerance=(-math.pi, math.pi)) + tangent_angle = scad.var( + "tangent_angle", math.pi / 2.0, tolerance=0.1 + ) + + sine = scad.analyze_tolerance(scad.sin(angle)) + cosine = scad.analyze_tolerance(scad.cos(angle)) + + self.assertEqual((sine.lower_bound, sine.upper_bound), (-1.0, 1.0)) + self.assertEqual((cosine.lower_bound, cosine.upper_bound), (-1.0, 1.0)) + with self.assertRaisesRegex(scad.ToleranceAnalysisError, "discontinuity"): + scad.analyze_tolerance(scad.tan(tangent_angle)) + + def test_inverse_function_domains_are_checked_over_full_interval(self): + below_sqrt = scad.var("below_sqrt", 0.1, tolerance=0.2) + outside_unit = scad.var("outside_unit", 0.9, tolerance=0.2) + + with self.assertRaisesRegex(scad.ToleranceAnalysisError, "below zero"): + scad.analyze_tolerance(scad.sqrt(below_sqrt)) + with self.assertRaisesRegex(scad.ToleranceAnalysisError, "outside"): + scad.analyze_tolerance(scad.asin(outside_unit)) + with self.assertRaisesRegex(scad.ToleranceAnalysisError, "outside"): + scad.analyze_tolerance(scad.acos(outside_unit)) + + def test_atan_and_atan2_propagation(self): + y = scad.var("y", 2.0, tolerance=0.1) + x = scad.var("x", 3.0, tolerance=0.2) + origin_y = scad.var("origin_y", 0.0, tolerance=0.1) + origin_x = scad.var("origin_x", 0.0, tolerance=0.1) + + atan_result = scad.analyze_tolerance(scad.atan(y)) + atan2_result = scad.analyze_tolerance(scad.atan2(y, x)) + + self.assertLess(atan_result.lower_bound, atan_result.upper_bound) + self.assertLess(atan2_result.lower_bound, atan2_result.upper_bound) + with self.assertRaisesRegex(scad.ToleranceAnalysisError, "undefined origin"): + scad.analyze_tolerance(scad.atan2(origin_y, origin_x)) + + def test_atan2_negative_x_branch_cut_is_not_underestimated(self): + y = scad.var("y", 0.0, tolerance=0.1) + x = scad.var("x", -2.0, tolerance=0.1) + expression = scad.atan2(y, x) + + result = scad.analyze_tolerance(expression) + + self.assertEqual(result.lower_bound, -math.pi) + self.assertEqual(result.upper_bound, math.pi) + with self.assertRaisesRegex(scad.ToleranceAnalysisError, "branch cut"): + scad.analyze_tolerance(expression, method="rss") + + def test_atan2_signed_zero_branch_cut_is_rejected_for_rss(self): + y = scad.var("y", -0.0, tolerance=(0.0, 0.1)) + x = scad.var("x", -2.0, tolerance=0.0) + expression = scad.atan2(y, x) + + worst_case = scad.analyze_tolerance(expression) + + self.assertEqual(worst_case.lower_bound, -math.pi) + self.assertEqual(worst_case.upper_bound, math.pi) + with self.assertRaisesRegex(scad.ToleranceAnalysisError, "branch cut"): + scad.analyze_tolerance(expression, method="rss") + + def test_abs_crossing_zero_and_negation(self): + value = scad.var("value", 0.25, tolerance=0.5) + + absolute = scad.analyze_tolerance(abs(value)) + negated = scad.analyze_tolerance(-value) + + self.assertEqual(absolute.lower_bound, 0.0) + self.assertAlmostEqual(absolute.upper_bound, 0.75) + self.assertAlmostEqual(negated.lower_deviation, -0.5) + self.assertAlmostEqual(negated.upper_deviation, 0.5) + + def test_nonlinear_contributions_are_reported_in_target_units(self): + value = scad.var("value", 2.0, tolerance=0.1) + + result = scad.analyze_tolerance(value**2) + + self.assertIsNone(result.contributions[0].sensitivity) + self.assertAlmostEqual(result.contributions[0].lower_deviation, -0.39) + self.assertAlmostEqual(result.contributions[0].upper_deviation, 0.41) + + def test_large_nominal_value_does_not_erase_small_declared_deviations(self): + value = scad.var("large", 1e16, tolerance=0.1) + + result = scad.analyze_tolerance(value) + + self.assertEqual(result.lower_deviation, -0.1) + self.assertEqual(result.upper_deviation, 0.1) + self.assertLess(result.lower_bound, result.nominal) + self.assertGreater(result.upper_bound, result.nominal) + + def test_underflowed_nonlinear_range_remains_conservative(self): + value = scad.var("tiny", 0.0, tolerance=1e-200) + + result = scad.analyze_tolerance(value**2) + check = scad.check_tolerance(value**2, 0.0) + + self.assertEqual(result.lower_deviation, 0.0) + self.assertGreater(result.upper_deviation, 0.0) + self.assertFalse(check.passed) + + def test_large_atan_input_keeps_nonzero_worst_case_range(self): + value = scad.var("large", 1e160, tolerance=1e100) + + result = scad.analyze_tolerance(scad.atan(value)) + + self.assertLess(result.lower_deviation, 0.0) + self.assertGreater(result.upper_deviation, 0.0) + + def test_zero_tolerance_large_periodic_input_remains_a_point(self): + angle = scad.var("angle", 1e16, tolerance=0.0) + + tangent = scad.analyze_tolerance(scad.tan(angle)) + sine = scad.analyze_tolerance(scad.sin(angle)) + + self.assertEqual(tangent.lower_deviation, 0.0) + self.assertEqual(tangent.upper_deviation, 0.0) + self.assertEqual(sine.lower_deviation, 0.0) + self.assertEqual(sine.upper_deviation, 0.0) + + def test_exact_zero_absolute_value_is_not_spuriously_widened(self): + value = scad.var("zero", 0.0, tolerance=0.0) + + result = scad.analyze_tolerance(abs(value)) + + self.assertEqual(result.lower_deviation, 0.0) + self.assertEqual(result.upper_deviation, 0.0) + + +class TestRssTolerancePropagation(unittest.TestCase): + def test_independent_sources_are_combined_by_root_sum_square(self): + a = scad.var("a", 10.0, tolerance=0.3) + b = scad.var("b", 4.0, tolerance=0.4) + + result = scad.analyze_tolerance(a + b, method="rss") + + self.assertAlmostEqual(result.lower_deviation, -0.5) + self.assertAlmostEqual(result.upper_deviation, 0.5) + + def test_repeated_source_is_not_treated_as_independent(self): + x = scad.var("x", 2.0, tolerance=0.2) + + cancelled = scad.analyze_tolerance(x - x, method="rss") + doubled = scad.analyze_tolerance(x + x, method="rss") + + self.assertAlmostEqual(cancelled.lower_deviation, 0.0) + self.assertAlmostEqual(cancelled.upper_deviation, 0.0) + self.assertAlmostEqual(doubled.lower_deviation, -0.4) + self.assertAlmostEqual(doubled.upper_deviation, 0.4) + + def test_nonlinear_sensitivity_is_analytic(self): + x = scad.var("x", 2.0, tolerance=0.1) + + squared = scad.analyze_tolerance(x**2, method="rss") + sine = scad.analyze_tolerance(scad.sin(x), method="rss") + + self.assertAlmostEqual(squared.lower_deviation, -0.4) + self.assertAlmostEqual(squared.upper_deviation, 0.4) + self.assertAlmostEqual( + sine.upper_deviation, abs(math.cos(2.0)) * 0.1 + ) + + def test_rss_rejects_non_differentiable_and_invalid_intervals(self): + at_zero = scad.var("at_zero", 0.0, tolerance=0.1) + denominator = scad.var("denominator", 1.0, tolerance=1.0) + numerator = scad.var("numerator", 2.0, tolerance=0.1) + + with self.assertRaisesRegex(scad.ToleranceAnalysisError, "abs"): + scad.analyze_tolerance(abs(at_zero), method="rss") + with self.assertRaisesRegex(scad.ToleranceAnalysisError, "denominator"): + scad.analyze_tolerance(numerator / denominator, method="rss") + + def test_all_unary_and_binary_derivative_paths_are_supported(self): + a = scad.var("a", 0.5, tolerance=0.01) + b = scad.var("b", 2.0, tolerance=0.02) + expressions = [ + a + b, + a - b, + a * b, + a / b, + b**a, + -a, + abs(a), + scad.sin(a), + scad.cos(a), + scad.tan(a), + scad.sqrt(b), + scad.acos(a), + scad.asin(a), + scad.atan(a), + scad.atan2(a, b), + ] + + for expression in expressions: + with self.subTest(op=getattr(expression, "op", None)): + result = scad.analyze_tolerance(expression, method="rss") + self.assertTrue(math.isfinite(result.lower_bound)) + self.assertTrue(math.isfinite(result.upper_bound)) + + def test_constant_boundary_functions_have_zero_rss_tolerance(self): + expressions = [scad.sqrt(0.0), scad.acos(1.0), scad.asin(-1.0)] + + for expression in expressions: + with self.subTest(op=expression.op): + result = scad.analyze_tolerance(expression, method="rss") + self.assertEqual(result.lower_deviation, 0.0) + self.assertEqual(result.upper_deviation, 0.0) + + def test_rss_uses_overflow_safe_root_sum_square(self): + a = scad.var("a", 0.0, tolerance=1e200) + b = scad.var("b", 0.0, tolerance=1e200) + + result = scad.analyze_tolerance(a + b, method="rss") + + expected = math.hypot(1e200, 1e200) + self.assertTrue(math.isfinite(result.upper_deviation)) + self.assertLessEqual(result.lower_deviation, -expected) + self.assertGreaterEqual(result.upper_deviation, expected) + self.assertAlmostEqual(result.upper_deviation / expected, 1.0) + + def test_rss_atan2_derivative_is_stable_for_large_coordinates(self): + y = scad.var("y", 1e200, tolerance=1e190) + x = scad.var("x", 1e200, tolerance=1e190) + + result = scad.analyze_tolerance(scad.atan2(y, x), method="rss") + + self.assertTrue(math.isfinite(result.upper_deviation)) + self.assertGreater(result.upper_deviation, 0.0) + + def test_rss_atan_derivative_does_not_underflow_before_contribution(self): + value = scad.var("large", 1e160, tolerance=1e100) + + result = scad.analyze_tolerance(scad.atan(value), method="rss") + + self.assertGreaterEqual(result.upper_deviation, 1e-220) + + def test_ill_conditioned_affine_coefficients_preserve_source_effect(self): + value = scad.var("value", 1.0, tolerance=0.1) + expression = (1e16 * value + value) - 1e16 * value + + worst_case = scad.analyze_tolerance(expression) + rss = scad.analyze_tolerance(expression, method="rss") + + self.assertLessEqual(worst_case.lower_deviation, -0.1) + self.assertGreaterEqual(worst_case.upper_deviation, 0.1) + self.assertLessEqual(rss.lower_deviation, -0.1) + self.assertGreaterEqual(rss.upper_deviation, 0.1) + + +class TestToleranceRequirementsAndPersistence(unittest.TestCase): + def test_check_tolerance_returns_margins_without_raising(self): + a = scad.var("a", 10.0, tolerance=0.2) + b = scad.var("b", 5.0, tolerance=0.1) + + passing = scad.check_tolerance(a - b, 0.3, name="clearance") + failing = scad.check_tolerance(a - b, 0.2, name="clearance") + + self.assertTrue(passing.passed) + self.assertAlmostEqual(passing.lower_margin, 0.0) + self.assertAlmostEqual(passing.upper_margin, 0.0) + self.assertFalse(failing.passed) + + def test_requirement_comparison_does_not_scale_epsilon_by_nominal(self): + value = scad.var("large", 1e16, tolerance=0.1) + + check = scad.check_tolerance(value, 0.0) + + self.assertFalse(check.passed) + self.assertLess(check.lower_margin, 0.0) + self.assertLess(check.upper_margin, 0.0) + + def test_analysis_rejects_conflicting_expression_ids_before_caching(self): + left = scad.Var( + "left", + 1.0, + expr_id="var_duplicate", + tolerance=scad.DimensionTolerance.symmetric(0.1), + ) + right = scad.Var( + "right", + 2.0, + expr_id="var_duplicate", + tolerance=scad.DimensionTolerance.symmetric(0.2), + ) + + with self.assertRaisesRegex(scad.ToleranceAnalysisError, "multiple"): + scad.analyze_tolerance(scad.Expr("add", (left, right))) + + def test_every_variable_requires_an_explicit_source_tolerance(self): + declared = scad.var("declared", 2.0, tolerance=0.1) + missing = scad.var("missing", 1.0) + + with self.assertRaisesRegex(scad.ToleranceAnalysisError, "missing"): + scad.analyze_tolerance(declared + missing) + + def test_tolerance_graph_validates_all_requirements(self): + a = scad.var("a", 10.0, tolerance=0.2) + b = scad.var("b", 5.0, tolerance=0.1) + expression_graph = scad.ExpressionGraph() + tolerance_graph = scad.ToleranceGraph(expression_graph) + tolerance_graph.require(a + b, 0.3, name="overall") + tolerance_graph.require(a - b, 0.2, name="clearance") + + report = tolerance_graph.validate() + + self.assertFalse(report.passed) + self.assertEqual(report.checks[0].requirement.name, "overall") + with self.assertRaises(scad.ToleranceValidationError) as context: + tolerance_graph.validate(raise_on_failure=True) + self.assertIsNotNone(context.exception.report) + + def test_tolerance_graph_roundtrip_and_dangling_refs(self): + width = scad.var("width", 10.0, tolerance=0.1) + expression_graph = scad.ExpressionGraph() + tolerance_graph = scad.ToleranceGraph(expression_graph) + requirement = tolerance_graph.require( + width * 2.0, 0.2, name="overall_width", requirement_id="req_width" + ) + + expression_payload = expression_graph.to_dict() + rebuilt_expression_graph = scad.ExpressionGraph.from_dict(expression_payload) + rebuilt = scad.ToleranceGraph.from_dict( + tolerance_graph.to_dict(), rebuilt_expression_graph + ) + + self.assertEqual(rebuilt.requirement_count, 1) + self.assertEqual(rebuilt.requirements[0], requirement) + broken = tolerance_graph.to_dict() + broken["requirements"][0]["target_expr_id"] = "missing" + with self.assertRaisesRegex(ValueError, "Unknown tolerance target"): + scad.ToleranceGraph.from_dict(broken, rebuilt_expression_graph) + + def test_tolerance_graph_rejects_malformed_requirement_types(self): + width = scad.var("width", 10.0, tolerance=0.1) + expression_graph = scad.ExpressionGraph() + expression_graph.register(width) + base = { + "requirements": [ + { + "requirement_id": "req_width", + "target_expr_id": width.expr_id, + "tolerance": { + "lower_deviation": -0.1, + "upper_deviation": 0.1, + }, + "method": "worst_case", + "name": "width", + } + ] + } + + for field, invalid in ( + ("requirement_id", 1), + ("target_expr_id", 1), + ("method", []), + ("name", 1), + ("name", ""), + ): + payload = json.loads(json.dumps(base)) + payload["requirements"][0][field] = invalid + with self.subTest(field=field), self.assertRaises((TypeError, ValueError)): + scad.ToleranceGraph.from_dict(payload, expression_graph) + + def test_duplicate_requirement_ids_are_rejected(self): + width = scad.var("width", 10.0, tolerance=0.1) + graph = scad.ToleranceGraph(scad.ExpressionGraph()) + graph.require(width, 0.1, requirement_id="same") + + with self.assertRaisesRegex(ValueError, "Duplicate"): + graph.require(width, 0.1, requirement_id="same") + + def test_explicit_invalid_requirement_ids_and_names_are_rejected(self): + width = scad.var("width", 10.0, tolerance=0.1) + graph = scad.ToleranceGraph(scad.ExpressionGraph()) + + for requirement_id in ("", 0, []): + with self.subTest(requirement_id=requirement_id), self.assertRaises( + (TypeError, ValueError) + ): + graph.require(width, 0.1, requirement_id=requirement_id) + with self.assertRaises(ValueError): + graph.require(width, 0.1, name="") + + def test_session_and_model_json_include_tolerance_graph(self): + width = scad.var("width", 10.0, tolerance=(-0.1, 0.2)) + with scad.GraphSession() as session: + session.require_tolerance(width * 2.0, (-0.2, 0.4), name="overall") + + session_payload = scad.import_session_json(scad.export_session_json(session)) + model_payload = scad.import_model_json(scad.export_model_json(session)) + + self.assertEqual(session_payload["tolerance_graph"].requirement_count, 1) + self.assertTrue(session_payload["tolerance_graph"].validate().passed) + self.assertEqual(model_payload["tolerance_graph"].requirement_count, 1) + + def test_legacy_session_and_model_payloads_default_to_empty_tolerance_graph(self): + with scad.GraphSession() as session: + pass + raw_session = json.loads(scad.export_session_json(session)) + raw_model = json.loads(scad.export_model_json(session)) + raw_session.pop("tolerance_graph") + raw_model.pop("tolerance_graph") + + imported_session = scad.import_session_json(json.dumps(raw_session)) + imported_model = scad.import_model_json(json.dumps(raw_model)) + + self.assertEqual(imported_session["tolerance_graph"].requirement_count, 0) + self.assertEqual(imported_model["tolerance_graph"].requirement_count, 0) + + def test_failed_requirement_blocks_session_and_model_export(self): + width = scad.var("width", 10.0, tolerance=0.2) + with scad.GraphSession() as session: + session.require_tolerance(width, 0.1, name="width") + + with self.assertRaises(scad.ToleranceValidationError): + scad.export_session_json(session) + with self.assertRaises(scad.SimpleCADError): + scad.export_model_json(session) + + def test_failed_requirement_in_hand_edited_model_blocks_replay(self): + width = scad.var("width", 10.0, tolerance=0.2) + with scad.GraphSession() as session: + scad.make_box_rsolid(width, 1.0, 1.0) + session.require_tolerance(width, 0.2, name="width") + payload = json.loads(scad.export_model_json(session)) + payload["tolerance_graph"]["requirements"][0]["tolerance"] = { + "lower_deviation": -0.1, + "upper_deviation": 0.1, + } + + with self.assertRaises(scad.SimpleCADError): + scad.replay_model_json(json.dumps(payload)) + + def test_analysis_and_report_payloads_are_json_serializable(self): + width = scad.var("width", 10.0, tolerance=0.1) + check = scad.check_tolerance(width * 2.0, 0.2, name="overall") + + serialized = json.dumps(check.to_dict()) + + self.assertIn('"passed": true', serialized) + self.assertIn('"variable_name": "width"', serialized) + + def test_unknown_method_is_rejected(self): + width = scad.var("width", 10.0, tolerance=0.1) + + with self.assertRaisesRegex(ValueError, "Unsupported"): + scad.analyze_tolerance(width, method="monte_carlo") + + +if __name__ == "__main__": + unittest.main() diff --git a/SimpleCADAPI/test/test_tracking_boolean.py b/SimpleCADAPI/test/test_tracking_boolean.py index 33bfa29..0d69a73 100644 --- a/SimpleCADAPI/test/test_tracking_boolean.py +++ b/SimpleCADAPI/test/test_tracking_boolean.py @@ -43,13 +43,16 @@ class TestTrackedCut(unittest.TestCase): # either modified or preserved; some should survive self.assertGreater(len(preserved_face_refs), 0) - def test_tracked_cut_has_generated_faces(self): + def test_tracked_cut_has_proven_changed_faces(self): result = tracked_cut(self.body, self.tool) - generated_face_refs = [ - r for r in result.delta.generated if r.kind == TopoKind.FACE + changed_faces = [ + entry + for entry in result.delta.entries + if entry.ref.kind == TopoKind.FACE + and entry.event in {TopoEvent.MODIFIED, TopoEvent.GENERATED} + and entry.metadata["status"] == "proven" ] - # The cylindrical hole creates new faces - self.assertGreater(len(generated_face_refs), 0) + self.assertGreater(len(changed_faces), 0) def test_tracked_cut_volume_decreased(self): result = tracked_cut(self.body, self.tool) @@ -68,14 +71,15 @@ class TestTrackedCut(unittest.TestCase): def test_tracked_cut_tool_with_tool_face_labels(self): """Faces from the tool should be labeled with origin_role='tool'.""" result = tracked_cut(self.body, self.tool) - tool_generated = [ - r - for r in result.delta.generated - if r.kind == TopoKind.FACE - and result.delta_entries.get(r.topo_id, {}).get("origin_role") == "tool" + tool_outputs = [ + entry + for entry in result.delta.entries + if entry.ref.kind == TopoKind.FACE + and entry.origin_role == "tool" + and entry.event in {TopoEvent.MODIFIED, TopoEvent.GENERATED} ] - # At least the cylindrical face of the hole should be tool-origin - self.assertGreater(len(tool_generated), 0) + self.assertGreater(len(tool_outputs), 0) + self.assertTrue(all(entry.parent_refs for entry in tool_outputs)) def test_tracked_cut_preserves_volume_accuracy(self): result = tracked_cut(self.body, self.tool) @@ -108,6 +112,180 @@ class TestTrackedUnion(unittest.TestCase): section_edges = result.delta.section_edges self.assertGreater(len(section_edges), 0) + for section_ref in section_edges: + entries = [ + entry + for entry in result.delta.entries + if entry.ref == section_ref + ] + self.assertGreaterEqual(len(entries), 2) + self.assertEqual( + {entry.origin_role for entry in entries}, {"body", "tool"} + ) + self.assertTrue(all(entry.parent_refs for entry in entries)) + + def test_nary_union_tracks_every_input_through_clean_history(self): + solids = [ + scad.make_box_rsolid( + 1.0, + 1.0, + 1.0, + bottom_face_center=(float(index), 0.0, 0.0), + ) + for index in range(3) + ] + + result = scad.union_rsolid(*solids, glue=False) + tracks = [face.get_metadata("track") for face in result.get_faces()] + + self.assertEqual(len(result.get_faces()), 6) + self.assertTrue(all(track["status"] == "proven" for track in tracks)) + self.assertEqual( + {role for track in tracks for role in track["origin_roles"]}, + {"body", "tool", "tool_2"}, + ) + self.assertTrue(result.get_metadata("track")["has_delta"]) + + def test_nary_union_keeps_modified_face_lineage_after_clean(self): + barrel = scad.make_cylinder_rsolid( + 16.0, + 120.0, + bottom_face_center=(-60.0, 0.0, 0.0), + axis=(1.0, 0.0, 0.0), + ) + flange = scad.make_cylinder_rsolid( + 22.0, + 12.0, + bottom_face_center=(50.0, 0.0, 0.0), + axis=(1.0, 0.0, 0.0), + ) + nose = scad.make_cylinder_rsolid( + 13.0, + 10.0, + bottom_face_center=(58.0, 0.0, 0.0), + axis=(1.0, 0.0, 0.0), + ) + source_face = max(flange.get_faces(), key=lambda face: face.get_center().x) + scad.apply_tag(source_face, "cap.face.end") + + result = scad.union_rsolid(barrel, flange, nose, glue=False) + descendants = [ + face + for face in result.get_faces() + if "cap.face.end" in scad.list_tags(face, scope="lineage") + ] + + self.assertEqual(len(descendants), 1) + self.assertAlmostEqual(descendants[0].get_center().x, 62.0, places=6) + self.assertAlmostEqual(descendants[0].get_area(), 989.6016859, places=5) + self.assertEqual(descendants[0].get_metadata("track")["event"], "modified") + self.assertNotIn("cap.face.end", scad.list_tags(descendants[0])) + + def test_nary_union_respects_face_binding_lineage_policy(self): + barrel = scad.make_cylinder_rsolid( + 16.0, + 120.0, + bottom_face_center=(-60.0, 0.0, 0.0), + axis=(1.0, 0.0, 0.0), + ) + flange = scad.make_cylinder_rsolid( + 22.0, + 12.0, + bottom_face_center=(50.0, 0.0, 0.0), + axis=(1.0, 0.0, 0.0), + ) + nose = scad.make_cylinder_rsolid( + 13.0, + 10.0, + bottom_face_center=(58.0, 0.0, 0.0), + axis=(1.0, 0.0, 0.0), + ) + source_face = max(flange.get_faces(), key=lambda face: face.get_center().x) + flange = scad.apply_tag_rselection( + flange, + [source_face], + "cap.face.end", + lineage_policy=scad.LineagePolicy.NONE, + ) + + result = scad.union_rsolid(barrel, flange, nose, glue=False) + + self.assertFalse( + any("cap.face.end" in scad.list_tags(face) for face in result.get_faces()) + ) + + def test_nary_union_face_binding_stays_directly_queryable_after_cut(self): + flange = scad.extrude_rsolid( + scad.make_circle_rface( + center=(0.0, 0.0, 0.0), + radius=2.0, + normal=(1.0, 0.0, 0.0), + ), + direction=(1.0, 0.0, 0.0), + distance=2.0, + start_face_tag="cap.face.start", + end_face_tag="cap.face.end", + side_faces_tag="cap.face.side", + ) + source_end = scad.select_faces_by_tag(flange, "cap.face.end")[0] + source_explanation = scad.explain_tag( + source_end, "cap.face.end", scope="local" + )[0] + body = scad.make_cylinder_rsolid( + 1.5, + 4.0, + bottom_face_center=(-1.0, 0.0, 0.0), + axis=(1.0, 0.0, 0.0), + ) + bridge = scad.make_box_rsolid( + 1.0, + 1.0, + 1.0, + bottom_face_center=(-0.5, -0.5, -0.5), + ) + fused = scad.union_rsolid(body, flange, bridge, glue=False) + result = scad.cut_rsolid( + fused, + scad.make_cylinder_rsolid( + 0.25, + 4.0, + bottom_face_center=(-1.0, 1.0, 0.0), + axis=(1.0, 0.0, 0.0), + ), + ) + + end_faces = scad.select_faces_by_tag(result, "cap.face.end") + + self.assertEqual(len(end_faces), 1) + projected = scad.explain_tag( + end_faces[0], "cap.face.end", scope="local" + )[0]["binding"]["evidence"] + self.assertEqual( + projected["source_binding_id"], source_explanation["binding_id"] + ) + self.assertEqual(projected["source_topo_id"], source_end.topo_id) + + def test_nary_union_tracks_faces_without_cleaning(self): + solids = [ + scad.make_box_rsolid( + 1.0, + 1.0, + 1.0, + bottom_face_center=(float(index), 0.0, 0.0), + ) + for index in range(3) + ] + + result = scad.union_rsolid(*solids, clean=False, glue=False) + + self.assertEqual(len(result.get_faces()), 14) + self.assertTrue( + all( + face.get_metadata("track")["status"] == "proven" + for face in result.get_faces() + ) + ) + class TestTrackedIntersect(unittest.TestCase): def setUp(self): diff --git a/SimpleCADAPI/test/test_tracking_data_models.py b/SimpleCADAPI/test/test_tracking_data_models.py index fd97c17..4f481a3 100644 --- a/SimpleCADAPI/test/test_tracking_data_models.py +++ b/SimpleCADAPI/test/test_tracking_data_models.py @@ -7,9 +7,12 @@ from simplecadapi.topology import ( TopoEvent, TopoRef, TopoEntry, + TopoRoleEntry, TopoDelta, OperationNode, OperationGraph, + topo_delta_from_dict, + topo_delta_to_dict, ) @@ -112,6 +115,54 @@ class TestTopoDelta(unittest.TestCase): self.assertEqual(delta.deleted, ()) self.assertEqual(delta.section_edges, ()) + def test_entries_are_canonical_and_preserve_output_and_source_kind(self): + source_ref = TopoRef("g1", "n1", 0, TopoKind.EDGE, "e_source") + output_ref = TopoRef("g1", "n2", 0, TopoKind.FACE, "f_output") + entry = TopoEntry( + ref=output_ref, + event=TopoEvent.GENERATED, + origin_role="profile", + parent_refs=(source_ref,), + metadata={ + "derivation": "boundary", + "coverage": "complete", + "status": "proven", + "evidence_kind": "kernel_history", + "source_kind": "EDGE", + }, + ) + delta = TopoDelta(generated=(output_ref,), entries=(entry,)) + + self.assertEqual(delta.entries, (entry,)) + self.assertEqual(entry.ref.kind, TopoKind.FACE) + self.assertEqual(entry.parent_refs[0].kind, TopoKind.EDGE) + self.assertEqual(entry.metadata["source_kind"], "EDGE") + self.assertEqual( + topo_delta_from_dict(topo_delta_to_dict(delta)).entries, + delta.entries, + ) + + def test_output_roles_roundtrip_independently_from_change_events(self): + source_ref = TopoRef("g1", "n1", 0, TopoKind.EDGE, "e_source") + output_ref = TopoRef("g1", "n2", 0, TopoKind.FACE, "f_output") + role = TopoRoleEntry( + ref=output_ref, + role="Extrusion.Side", + origin_role="profile", + parent_refs=(source_ref,), + metadata={ + "coverage": "complete", + "status": "proven", + "evidence_method": "Generated", + }, + ) + delta = TopoDelta(roles=(role,)) + + restored = topo_delta_from_dict(topo_delta_to_dict(delta)) + self.assertEqual(role.role, "extrusion.side") + self.assertEqual(restored.roles, (role,)) + self.assertEqual(restored.entries, ()) + class TestOperationNode(unittest.TestCase): def test_creation(self): diff --git a/SimpleCADAPI/test/test_tracking_features.py b/SimpleCADAPI/test/test_tracking_features.py index 87f810c..3c0e3cf 100644 --- a/SimpleCADAPI/test/test_tracking_features.py +++ b/SimpleCADAPI/test/test_tracking_features.py @@ -30,11 +30,11 @@ class TestTrackedTranslate(unittest.TestCase): result.shape.get_volume(), self.box.get_volume(), places=6 ) - def test_translate_all_faces_preserved(self): + def test_translate_all_faces_modified(self): result = tracked_translate(self.box, (5, 0, 0)) - preserved = [r for r in result.delta.preserved if r.kind == TopoKind.FACE] + modified = [r for r in result.delta.modified if r.kind == TopoKind.FACE] original_faces = len(self.box.get_faces()) - self.assertEqual(len(preserved), original_faces) + self.assertEqual(len(modified), original_faces) def test_translate_no_generated(self): result = tracked_translate(self.box, (5, 0, 0)) @@ -61,11 +61,11 @@ class TestTrackedRotate(unittest.TestCase): result.shape.get_volume(), self.box.get_volume(), places=4 ) - def test_rotate_all_faces_preserved(self): + def test_rotate_all_faces_modified(self): result = tracked_rotate(self.box, 45.0, (0, 0, 1)) - preserved = [r for r in result.delta.preserved if r.kind == TopoKind.FACE] + modified = [r for r in result.delta.modified if r.kind == TopoKind.FACE] original_faces = len(self.box.get_faces()) - self.assertEqual(len(preserved), original_faces) + self.assertEqual(len(modified), original_faces) def test_rotate_no_generated(self): result = tracked_rotate(self.box, 45.0, (0, 0, 1)) diff --git a/SimpleCADAPI/test/test_tracking_tags.py b/SimpleCADAPI/test/test_tracking_tags.py index d63d45d..7367dea 100644 --- a/SimpleCADAPI/test/test_tracking_tags.py +++ b/SimpleCADAPI/test/test_tracking_tags.py @@ -1,11 +1,19 @@ -"""Tests for auto-tagging based on TopoDelta.""" +"""Tests for evidence-gated semantic projections from TopoDelta.""" import unittest import simplecadapi as scad -from simplecadapi.topology import TopoKind, TopoEvent +from simplecadapi import ql as Q +from simplecadapi.topology import TopoDelta from simplecadapi.tracking import tracked_cut, tracked_union, tracked_extrude -from simplecadapi.autotag import apply_tracking_tags, apply_tracking_tags_to_delta +from simplecadapi.autotag import apply_tracking_tags_to_delta + + +def proven_event(op: str, event: str): + return Q.and_( + Q.operation_event(op, event), + Q.meta("track.status", "==", "proven"), + ) class TestAutoTagCut(unittest.TestCase): @@ -15,96 +23,135 @@ class TestAutoTagCut(unittest.TestCase): 2.0, 15.0, bottom_face_center=(3, 3, -2.5) ) - def test_cut_result_faces_get_operation_tags(self): + def test_cut_result_faces_get_typed_operation_evidence(self): result = tracked_cut(self.body, self.tool) tagged_solid = apply_tracking_tags_to_delta( result.solid, result.delta, result.delta_entries, op="cut" ) faces = tagged_solid.get_faces() - # At least some faces should have operation event tags - has_op_tag = any( - "op.cut.modified" in scad.list_tags(f) or "op.cut.generated" in scad.list_tags(f) for f in faces + has_change = any( + proven_event("cut", "modified")(face) + or proven_event("cut", "generated")(face) + for face in faces ) - self.assertTrue(has_op_tag) + self.assertTrue(has_change) - def test_cut_preserved_faces_tagged(self): + def test_cut_preserved_faces_have_typed_evidence(self): result = tracked_cut(self.body, self.tool) tagged_solid = apply_tracking_tags_to_delta( result.solid, result.delta, result.delta_entries, op="cut" ) faces = tagged_solid.get_faces() - preserved = [f for f in faces if "op.cut.preserved" in scad.list_tags(f)] + preserved = [face for face in faces if proven_event("cut", "preserved")(face)] self.assertGreater(len(preserved), 0) - def test_cut_all_faces_tagged(self): + def test_cut_all_faces_have_tracking_envelopes_not_flat_tracking_tags(self): result = tracked_cut(self.body, self.tool) tagged_solid = apply_tracking_tags_to_delta( result.solid, result.delta, result.delta_entries, op="cut" ) faces = tagged_solid.get_faces() - # All result faces should have some operation event tag - all_tagged = all( - "op.cut.modified" in scad.list_tags(f) - or "op.cut.preserved" in scad.list_tags(f) - or "op.cut.generated" in scad.list_tags(f) - for f in faces - ) - self.assertTrue(all_tagged) + for face in faces: + track = face.get_metadata("track") + self.assertEqual(track["schema_version"], "1.0") + self.assertEqual(track["kind"], "topology_change") + self.assertEqual(track["operation"], "cut") + self.assertEqual(track["topo_kind"], "FACE") + self.assertIn(track["coverage"], {"complete", "partial"}) + self.assertIn(track["status"], {"proven", "unknown"}) + self.assertFalse( + any( + tag.startswith(("op.", "origin.")) + for tag in scad.list_tags(face) + ) + ) - def test_cut_origin_role_tags(self): + def test_cut_origin_roles_are_typed_evidence(self): result = tracked_cut(self.body, self.tool) tagged_solid = apply_tracking_tags_to_delta( result.solid, result.delta, result.delta_entries, op="cut" ) faces = tagged_solid.get_faces() - has_body = any("origin.body" in scad.list_tags(f) for f in faces) - has_tool = any("origin.tool" in scad.list_tags(f) for f in faces) + proven = Q.meta("track.status", "==", "proven") + has_body = any(Q.origin_role("body")(face) and proven(face) for face in faces) + has_tool = any(Q.origin_role("tool")(face) and proven(face) for face in faces) self.assertTrue(has_body) self.assertTrue(has_tool) + def test_unmatched_faces_remain_partial_and_unknown(self): + solid = scad.make_box_rsolid(1.0, 1.0, 1.0) + tagged = apply_tracking_tags_to_delta(solid, TopoDelta(), op="cut") + + for face in tagged.get_faces(): + track = face.get_metadata("track") + self.assertEqual(track["coverage"], "partial") + self.assertEqual(track["status"], "unknown") + self.assertEqual(track["events"], []) + self.assertEqual(track["origin_roles"], []) + self.assertEqual(track["witnesses"], []) + self.assertFalse(Q.operation_event("cut")(face)) + class TestAutoTagUnion(unittest.TestCase): def setUp(self): self.body = scad.make_box_rsolid(10, 10, 10) self.tool = scad.make_cylinder_rsolid(4.0, 10.0, bottom_face_center=(3, 3, 0)) - def test_union_section_faces_tagged(self): - result = tracked_union(self.body, self.tool) + def test_union_faces_have_typed_operation_evidence(self): + result = tracked_union(self.body, self.tool, glue=False) tagged_solid = apply_tracking_tags_to_delta( result.solid, result.delta, result.delta_entries, op="union" ) faces = tagged_solid.get_faces() - # Union creates modified faces at the intersection - has_union_tag = any( - "op.union.modified" in scad.list_tags(f) or "op.union.preserved" in scad.list_tags(f) - for f in faces + has_union_evidence = any( + proven_event("union", "modified")(face) + or proven_event("union", "preserved")(face) + for face in faces + ) + self.assertTrue(has_union_evidence) + + section_tracks = [ + edge.get_metadata("track") + for edge in tagged_solid.get_edges() + if edge.get_metadata("track").get("section") + ] + self.assertTrue(section_tracks) + self.assertTrue( + all( + {"body", "tool"}.issubset(track["origin_roles"]) + and len(track["witnesses"]) >= 2 + for track in section_tracks + ) ) - self.assertTrue(has_union_tag) class TestAutoTagExtrude(unittest.TestCase): def setUp(self): self.profile = scad.make_rectangle_rface(5.0, 3.0) - def test_extrude_faces_tagged(self): + def test_extrude_roles_are_kernel_proven_without_geometry_guesses(self): result = tracked_extrude(self.profile, (0, 0, 1), 10.0) tagged_solid = apply_tracking_tags_to_delta( result.shape, result.delta, result.delta_entries, op="extrude" ) faces = tagged_solid.get_faces() - # Extrude produces new faces; any operation tag is sufficient - has_tag = any( - "op.extrude.generated" in scad.list_tags(f) or "op.extrude.modified" in scad.list_tags(f) - for f in faces - ) - self.assertTrue(has_tag) + self.assertTrue(faces) + roles = [ + role + for face in faces + for role in face.get_metadata("track")["result_roles"] + ] + self.assertEqual(roles.count("extrusion.start"), 1) + self.assertEqual(roles.count("extrusion.end"), 1) + self.assertEqual(roles.count("extrusion.side"), 4) + self.assertTrue(all(Q.output_role(role)(face) for face in faces for role in face.get_metadata("track")["result_roles"])) class TestAutoTagPreservesExisting(unittest.TestCase): - def test_existing_tags_carried_to_result(self): - """Tags from the original body should be carried to preserved/modified faces.""" + def test_user_semantics_are_available_only_through_proven_lineage(self): body = scad.make_box_rsolid(10, 10, 10) - body.auto_tag_faces("box") + for face in body.get_faces(): + scad.apply_tag(face, "role.source_face") tool = scad.make_cylinder_rsolid(2.0, 15.0, bottom_face_center=(3, 3, -2.5)) result = tracked_cut(body, tool) tagged_solid = apply_tracking_tags_to_delta( @@ -114,12 +161,26 @@ class TestAutoTagPreservesExisting(unittest.TestCase): op="cut", source_solid=body, ) - faces = tagged_solid.get_faces() - # Preserved and modified faces from body should carry body's tags - has_original = any( - "face.top" in scad.list_tags(f) or "face.bottom" in scad.list_tags(f) for f in faces + body_descendants = [ + face + for face in tagged_solid.get_faces() + if Q.origin_role("body")(face) + and face.get_metadata("track")["status"] == "proven" + ] + + self.assertTrue(body_descendants) + self.assertTrue( + any( + "role.source_face" in scad.list_tags(face, scope="lineage") + for face in body_descendants + ) + ) + self.assertTrue( + all( + "role.source_face" not in scad.list_tags(face, scope="effective") + for face in body_descendants + ) ) - self.assertTrue(has_original) if __name__ == "__main__": diff --git a/SimpleCADAPI/test/test_translator_backend_contract.py b/SimpleCADAPI/test/test_translator_backend_contract.py new file mode 100644 index 0000000..97b3aef --- /dev/null +++ b/SimpleCADAPI/test/test_translator_backend_contract.py @@ -0,0 +1,110 @@ +"""Contract tests shared by translator backends.""" + +from __future__ import annotations + +import importlib +from pathlib import Path +import unittest + +import simplecadapi as scad +from simplecadapi.graph import GraphSession +from simplecadapi.serializer import CANONICAL_OP_SET +from simplecadapi.translator.base import BaseTranslator +from simplecadapi.translator.freecad_translator import FreeCADTranslator +from simplecadapi.translator.freecad_translator.emitters.registry import ( + EMITTER_METHOD_BY_OP, +) +from simplecadapi.translator.freecad_translator.runtime import ( + assemble_runtime_source, +) +from simplecadapi.translator.types import SupportLevel + + +class TestTranslatorBackendContract(unittest.TestCase): + def test_backend_packages_have_required_modules(self): + translator_root = ( + Path(__file__).resolve().parents[1] + / "src" + / "simplecadapi" + / "translator" + ) + backend_dirs = sorted(translator_root.glob("*_translator")) + self.assertTrue(backend_dirs) + + for backend_dir in backend_dirs: + for filename in ( + "__init__.py", + "api.py", + "translator.py", + "capabilities.py", + ): + self.assertTrue( + (backend_dir / filename).is_file(), + f"{backend_dir.name} is missing required {filename}", + ) + + def test_backend_public_exports_and_capabilities_are_valid(self): + from simplecadapi import translator + + for backend_package_name in translator.__all__: + backend = importlib.import_module( + f"simplecadapi.translator.{backend_package_name}" + ) + for exported_name in backend.__all__: + self.assertTrue(hasattr(backend, exported_name)) + + capabilities = backend.CAPABILITIES + expected_backend_name = backend_package_name.removesuffix("_translator") + self.assertEqual(capabilities.backend_id, expected_backend_name) + self.assertEqual( + set(capabilities.operations), set(CANONICAL_OP_SET) + ) + for op, capability in capabilities.operations.items(): + if capability.level is SupportLevel.UNSUPPORTED: + self.assertTrue(capability.reason, op) + + translator_class = getattr( + backend, f"{capabilities.display_name}Translator" + ) + self.assertTrue(issubclass(translator_class, BaseTranslator)) + + def test_freecad_emitter_registry_matches_declared_support(self): + from simplecadapi.translator.freecad_translator import CAPABILITIES + + self.assertIs( + CAPABILITIES.operations["apply_tag_rselection"].level, + SupportLevel.METADATA_ONLY, + ) + self.assertIs( + CAPABILITIES.operations["make_twisted_sweep_rsolid"].level, + SupportLevel.EMULATED, + ) + supported_ops = { + op + for op, capability in CAPABILITIES.operations.items() + if capability.level is not SupportLevel.UNSUPPORTED + } + self.assertEqual(set(EMITTER_METHOD_BY_OP), supported_ops) + + def test_freecad_runtime_fragments_form_valid_python(self): + runtime_source = assemble_runtime_source() + + self.assertTrue(runtime_source) + compile(runtime_source, "", "exec") + + def test_freecad_translator_is_reusable_and_emits_valid_python(self): + with GraphSession() as session: + scad.make_box_rsolid(1.0, 2.0, 3.0) + + model_json = scad.export_model_json(session) + translator = FreeCADTranslator(document_name="ContractTest") + first = translator.translate_model_json_to_script(model_json) + second = translator.translate_model_json_to_script(model_json) + + self.assertEqual(first, second) + self.assertIn('DOC_NAME = "ContractTest"', first) + compile(first, "", "exec") + + +if __name__ == "__main__": + unittest.main() diff --git a/SimpleCADAPI/test/test_units.py b/SimpleCADAPI/test/test_units.py new file mode 100644 index 0000000..6b2cfb8 --- /dev/null +++ b/SimpleCADAPI/test/test_units.py @@ -0,0 +1,534 @@ +"""Complete coverage for physical units and dimensional expression inference.""" + +from __future__ import annotations + +import json +import math +import unittest + +import simplecadapi as scad +from simplecadapi.units import _infer, unit_to_payload + + +class TestDimensionsAndUnits(unittest.TestCase): + def test_named_dimensions_and_dimension_algebra(self): + self.assertEqual(scad.DIMENSIONLESS.name, "Dimensionless") + self.assertEqual(scad.LENGTH.name, "Length") + self.assertEqual(scad.AREA.name, "Area") + self.assertEqual(scad.VOLUME.name, "Volume") + self.assertEqual(scad.ANGLE.name, "Angle") + self.assertEqual(scad.LENGTH.multiply(scad.LENGTH), scad.AREA) + self.assertEqual(scad.VOLUME.divide(scad.LENGTH), scad.AREA) + self.assertEqual(scad.LENGTH.power(3), scad.VOLUME) + self.assertEqual(scad.AREA.square_root(), scad.LENGTH) + self.assertEqual(scad.Dimension(length=-1, angle=2).symbol, "L^-1 A^2") + self.assertTrue(scad.DIMENSIONLESS.is_dimensionless) + self.assertTrue(scad.LENGTH.is_design_dimension) + self.assertTrue(scad.ANGLE.is_design_dimension) + self.assertFalse(scad.AREA.is_design_dimension) + + def test_dimension_serialization_and_validation(self): + self.assertEqual( + scad.Dimension.from_dict(scad.VOLUME.to_dict()), scad.VOLUME + ) + invalid = [ + None, + {}, + {"length": 1}, + {"length": True, "angle": 0}, + {"length": 1, "angle": 0.5}, + ] + for payload in invalid: + with self.subTest(payload=payload), self.assertRaises( + (TypeError, ValueError) + ): + scad.Dimension.from_dict(payload) + with self.assertRaises(TypeError): + scad.LENGTH.power(0.5) + with self.assertRaises(scad.UnitValidationError): + scad.LENGTH.square_root() + + def test_every_registered_unit_converts_to_canonical_units(self): + conversions = [ + (scad.MM, 1.0), + (scad.CM, 10.0), + (scad.M, 1000.0), + (scad.INCH, 25.4), + (scad.FOOT, 304.8), + (scad.DEGREE, 1.0), + (scad.RADIAN, 180.0 / math.pi), + (scad.ONE, 1.0), + (scad.PERCENT, 0.01), + (scad.SQUARE_MM, 1.0), + (scad.SQUARE_CM, 100.0), + (scad.SQUARE_M, 1_000_000.0), + (scad.SQUARE_INCH, 25.4**2), + (scad.SQUARE_FOOT, 304.8**2), + (scad.CUBIC_MM, 1.0), + (scad.CUBIC_CM, 1000.0), + (scad.CUBIC_M, 1_000_000_000.0), + (scad.CUBIC_INCH, 25.4**3), + (scad.CUBIC_FOOT, 304.8**3), + ] + for unit, expected in conversions: + with self.subTest(unit=unit.symbol): + self.assertAlmostEqual(unit.to_canonical(1.0), expected) + self.assertAlmostEqual(unit.from_canonical(expected), 1.0) + self.assertIs(scad.get_unit(unit), unit) + self.assertEqual(scad.get_unit(unit.symbol), unit) + self.assertEqual( + scad.canonical_unit_for_dimension(unit.dimension).dimension, + unit.dimension, + ) + + def test_unit_aliases_and_conversion(self): + aliases = { + "millimeters": scad.MM, + "centimeter": scad.CM, + "meters": scad.M, + "inches": scad.INCH, + "feet": scad.FOOT, + "degrees": scad.DEGREE, + "radians": scad.RADIAN, + "percent": scad.PERCENT, + "square feet": scad.SQUARE_FOOT, + "cubic inches": scad.CUBIC_INCH, + "ml": scad.CUBIC_CM, + } + for alias, unit in aliases.items(): + with self.subTest(alias=alias): + self.assertEqual(scad.get_unit(alias), unit) + self.assertAlmostEqual(scad.convert_value(1.0, "in", "mm"), 25.4) + self.assertAlmostEqual(scad.convert_value(180.0, "deg", "rad"), math.pi) + self.assertAlmostEqual(scad.convert_value(1.0, "ft^2", "in^2"), 144.0) + with self.assertRaises(scad.UnitValidationError): + scad.convert_value(1.0, "mm", "deg") + with self.assertRaisesRegex(ValueError, "Unknown unit"): + scad.get_unit("parsec") + + def test_unit_validation_and_custom_unit_roundtrip(self): + invalid_calls = [ + lambda: scad.Unit("", scad.LENGTH, 1.0), + lambda: scad.Unit("bad", "Length", 1.0), + lambda: scad.Unit("bad", scad.LENGTH, True), + lambda: scad.Unit("bad", scad.LENGTH, 0.0), + lambda: scad.Unit("bad", scad.LENGTH, math.inf), + lambda: scad.MM.to_canonical(math.inf), + lambda: scad.MM.to_canonical(True), + lambda: scad.M.to_canonical(1e308), + lambda: scad.Unit("tiny", scad.LENGTH, 5e-324).to_canonical(0.5), + lambda: scad.Unit("tiny", scad.LENGTH, 5e-324).from_canonical(1.0), + lambda: scad.Unit("huge", scad.LENGTH, 1e308).from_canonical(5e-324), + lambda: scad.MM.to_canonical(10**10000), + lambda: scad.get_unit(None), + lambda: scad.get_unit(""), + lambda: scad.Unit.from_dict(None), + lambda: unit_to_payload(None), + ] + for call in invalid_calls: + with self.subTest(call=call), self.assertRaises((TypeError, ValueError)): + call() + + thou = scad.Unit("thou", scad.LENGTH, 0.0254) + width = scad.var( + "width", 1000.0, unit=thou, tolerance=2.0, tolerance_unit=thou + ) + graph = scad.ExpressionGraph() + graph.register(width) + payload = graph.to_dict() + self.assertIsInstance(payload["nodes"][0]["unit"], dict) + + rebuilt = scad.ExpressionGraph.from_dict(payload).get(width.expr_id) + self.assertIsInstance(rebuilt, scad.Var) + self.assertEqual(rebuilt.unit, thou) + self.assertEqual(rebuilt.tolerance_unit, thou) + self.assertAlmostEqual(rebuilt.evaluate(), 25.4) + self.assertAlmostEqual(rebuilt.canonical_tolerance.upper_deviation, 0.0508) + + def test_unknown_compound_dimension_uses_a_canonical_scale_one_unit(self): + dimension = scad.Dimension(length=-1, angle=2) + unit = scad.canonical_unit_for_dimension(dimension) + + self.assertEqual(unit.symbol, "L^-1 A^2") + self.assertEqual(unit.dimension, dimension) + self.assertEqual(unit.scale_to_canonical, 1.0) + + def test_malformed_custom_unit_payloads_are_rejected(self): + base = { + "nodes": [ + { + "expr_id": "var_x", + "kind": "var", + "name": "x", + "default": 1.0, + "unit": { + "symbol": "custom", + "dimension": {"length": 1, "angle": 0}, + "scale_to_canonical": 2.0, + }, + } + ] + } + for field in ("symbol", "dimension", "scale_to_canonical"): + payload = json.loads(json.dumps(base)) + del payload["nodes"][0]["unit"][field] + with self.subTest(field=field), self.assertRaises(ValueError): + scad.ExpressionGraph.from_dict(payload) + + +class TestUnitAwareVariables(unittest.TestCase): + def test_nominal_and_tolerance_units_are_canonicalized_independently(self): + width = scad.var( + "width", + 1.0, + unit="in", + tolerance=(-0.1, 0.2), + tolerance_unit="mm", + ) + inherited = scad.var("inherited", 1.0, unit="in", tolerance=0.01) + + self.assertEqual(width.default, 1.0) + self.assertEqual(width.tolerance, scad.DimensionTolerance(-0.1, 0.2)) + self.assertEqual(width.unit, scad.INCH) + self.assertEqual(width.tolerance_unit, scad.MM) + self.assertAlmostEqual(width.canonical_default, 25.4) + self.assertEqual( + width.canonical_tolerance, scad.DimensionTolerance(-0.1, 0.2) + ) + self.assertEqual(inherited.tolerance_unit, scad.INCH) + self.assertAlmostEqual( + inherited.canonical_tolerance.upper_deviation, 0.254 + ) + + def test_bindings_use_the_variable_declaration_unit(self): + width = scad.var("width", 1.0, unit="in") + self.assertAlmostEqual(width.evaluate({"width": 2.0}), 50.8) + self.assertAlmostEqual((width + 1.0).evaluate({"width": 2.0}), 51.8) + + def test_invalid_variable_unit_combinations_are_rejected(self): + invalid_calls = [ + lambda: scad.var("x", 1.0, tolerance=0.1, tolerance_unit="mm"), + lambda: scad.var("x", 1.0, unit="mm", tolerance_unit="mm"), + lambda: scad.var( + "x", 1.0, unit="mm", tolerance=0.1, tolerance_unit="deg" + ), + lambda: scad.var("x", 1.0, unit="unknown"), + lambda: scad.var("x", 1e308, unit="m"), + lambda: scad.var("x", 1.0, unit="mm", tolerance=1e308, tolerance_unit="m"), + ] + for call in invalid_calls: + with self.subTest(call=call), self.assertRaises((TypeError, ValueError)): + call() + + def test_expression_graph_roundtrip_preserves_registered_units(self): + angle = scad.var( + "angle", math.pi / 2.0, unit="rad", tolerance=0.5, tolerance_unit="deg" + ) + expression = scad.sin(angle) + graph = scad.ExpressionGraph() + graph.register(expression) + + payload = graph.to_dict() + rebuilt = scad.ExpressionGraph.from_dict(payload) + rebuilt_angle = rebuilt.get(angle.expr_id) + + self.assertEqual(rebuilt_angle.unit, scad.RADIAN) + self.assertEqual(rebuilt_angle.tolerance_unit, scad.DEGREE) + self.assertAlmostEqual(rebuilt.get(expression.expr_id).evaluate(), 1.0) + + def test_geometry_parameters_use_canonical_cad_values(self): + width = scad.var("width", 1.0, unit="in") + with scad.GraphSession() as session: + scad.make_box_rsolid(width, 2.0, 3.0) + + node = session.graph.topological_order()[0] + self.assertAlmostEqual(node.params["width"], 25.4) + self.assertEqual(node.param_exprs["width"]["expr_id"], width.expr_id) + + +class TestDimensionInference(unittest.TestCase): + def setUp(self): + self.length = scad.var("length", 3.0, unit="mm") + self.other_length = scad.var("other_length", 4.0, unit="cm") + self.angle = scad.var("angle", 90.0, unit="deg") + self.scalar = scad.var("scalar", 50.0, unit="%") + + def test_addition_subtraction_and_contextual_constants(self): + self.assertEqual(scad.infer_dimension(self.length + self.other_length), scad.LENGTH) + self.assertEqual(scad.infer_dimension(self.length - 1.0), scad.LENGTH) + self.assertEqual(scad.infer_dimension(1.0 + self.angle), scad.ANGLE) + self.assertAlmostEqual((self.length + self.other_length).evaluate(), 43.0) + + def test_multiplication_division_and_powers(self): + area = self.length * self.other_length + volume = area * self.length + ratio = self.length / self.other_length + inverse = 1.0 / self.length + + self.assertEqual(scad.infer_dimension(area), scad.AREA) + self.assertEqual(scad.infer_dimension(volume), scad.VOLUME) + self.assertEqual(scad.infer_dimension(ratio), scad.DIMENSIONLESS) + self.assertEqual(scad.infer_dimension(inverse), scad.Dimension(length=-1)) + self.assertEqual(scad.infer_dimension(self.length**3), scad.VOLUME) + self.assertEqual(scad.infer_dimension(self.length**0), scad.DIMENSIONLESS) + self.assertEqual( + scad.infer_dimension(self.scalar ** scad.var("power", 2.0, unit="1")), + scad.DIMENSIONLESS, + ) + + def test_square_root_requires_even_dimension_exponents(self): + diagonal = scad.sqrt(self.length**2 + self.other_length**2) + self.assertEqual(scad.infer_dimension(diagonal), scad.LENGTH) + self.assertAlmostEqual(diagonal.evaluate(), math.sqrt(1609.0)) + self.assertEqual(scad.infer_dimension(scad.sqrt(self.scalar)), scad.DIMENSIONLESS) + with self.assertRaises(scad.UnitValidationError): + scad.infer_dimension(scad.sqrt(self.length)) + with self.assertRaises(scad.UnitValidationError): + scad.infer_dimension(self.length**0.5) + + def test_unary_and_trigonometric_dimensions(self): + self.assertEqual(scad.infer_dimension(-self.length), scad.LENGTH) + self.assertEqual(scad.infer_dimension(abs(self.length)), scad.LENGTH) + for expression in ( + scad.sin(self.angle), + scad.cos(self.angle), + scad.tan(self.angle), + ): + self.assertEqual(scad.infer_dimension(expression), scad.DIMENSIONLESS) + for expression in ( + scad.asin(self.scalar), + scad.acos(self.scalar), + scad.atan(self.scalar), + scad.atan2(self.length, self.other_length), + ): + self.assertEqual(scad.infer_dimension(expression), scad.ANGLE) + + def test_invalid_physical_expressions_are_rejected(self): + legacy = scad.var("legacy", 1.0) + invalid = [ + self.length + self.angle, + self.length + self.scalar, + self.length + legacy, + self.length * legacy, + self.length ** self.scalar, + self.length ** self.angle, + self.length ** legacy, + self.length**0.25, + scad.sin(self.length), + scad.asin(self.length), + scad.atan2(self.length, self.angle), + ] + for expression in invalid: + with self.subTest(op=expression.op), self.assertRaises( + scad.UnitValidationError + ): + scad.infer_dimension(expression) + + with self.assertRaises(scad.UnitValidationError): + (self.length + self.angle).evaluate() + + def test_inference_cache_does_not_hide_conflicting_duplicate_ids(self): + length = scad.Var("length", 1.0, expr_id="same", unit=scad.MM) + angle = scad.Var("angle", 1.0, expr_id="same", unit=scad.DEGREE) + expression = scad.Expr("add", (length, angle)) + + with self.assertRaises(scad.UnitValidationError): + scad.infer_dimension(expression) + with self.assertRaisesRegex(ValueError, "different node"): + scad.ExpressionGraph().register(expression) + + def test_inference_rejects_mutated_cycles_ops_and_node_types(self): + cycle = scad.Expr("neg", (scad.const(1.0),), expr_id="cycle") + object.__setattr__(cycle, "args", (cycle,)) + with self.assertRaisesRegex(scad.UnitValidationError, "cycle"): + scad.infer_dimension(cycle) + + unsupported = scad.Expr("neg", (scad.const(1.0),)) + object.__setattr__(unsupported, "op", "unsupported") + with self.assertRaisesRegex(scad.UnitValidationError, "Unsupported"): + scad.infer_dimension(unsupported) + + with self.assertRaisesRegex(TypeError, "Unsupported expression node"): + _infer(object(), {}, set()) + + def test_legacy_expressions_keep_radian_math_and_unknown_dimension(self): + value = scad.var("legacy", 0.5) + expression = value ** scad.var("legacy_power", 2.0) + + self.assertIsNone(scad.infer_dimension(expression)) + self.assertFalse(scad.expression_uses_units(expression)) + self.assertAlmostEqual(scad.sin(value).evaluate(), math.sin(0.5)) + self.assertAlmostEqual(scad.asin(value).evaluate(), math.asin(0.5)) + self.assertIsNone(scad.infer_dimension(scad.sin(value))) + self.assertIsNone(scad.infer_dimension(value + value)) + self.assertIsNone(scad.infer_dimension(scad.atan2(value, value))) + + +class TestUnitAwareMathAndTolerance(unittest.TestCase): + def test_trigonometric_evaluation_uses_degrees_canonically(self): + degrees = scad.var("degrees", 90.0, unit="deg") + radians = scad.var("radians", math.pi / 2.0, unit="rad") + ratio = scad.var("ratio", 50.0, unit="%") + y = scad.var("y", 1.0, unit="in") + x = scad.var("x", 25.4, unit="mm") + + self.assertAlmostEqual(scad.sin(degrees).evaluate(), 1.0) + self.assertAlmostEqual(scad.sin(radians).evaluate(), 1.0) + self.assertAlmostEqual(scad.asin(ratio).evaluate(), 30.0) + self.assertAlmostEqual(scad.atan2(y, x).evaluate(), 45.0) + + def test_diagonal_tolerance_chain_reports_length_in_mm(self): + width = scad.var( + "width", 3.0, unit="cm", tolerance=0.1, tolerance_unit="mm" + ) + height = scad.var("height", 40.0, unit="mm", tolerance=0.2) + diagonal = scad.sqrt(width**2 + height**2) + + worst_case = scad.analyze_tolerance(diagonal) + rss = scad.analyze_tolerance(diagonal, method="rss") + + self.assertEqual(worst_case.dimension, scad.LENGTH) + self.assertEqual(worst_case.unit, scad.MM) + self.assertAlmostEqual(worst_case.nominal, 50.0) + self.assertLess(worst_case.lower_deviation, 0.0) + self.assertGreater(worst_case.upper_deviation, 0.0) + expected_rss = math.hypot(0.6 * 0.1, 0.8 * 0.2) + self.assertAlmostEqual(rss.upper_deviation, expected_rss, places=12) + self.assertTrue(all(item.source_unit == scad.MM for item in rss.contributions)) + + def test_angle_tolerance_propagation_converts_derivatives(self): + angle = scad.var("angle", 30.0, unit="deg", tolerance=1.0) + result = scad.analyze_tolerance(scad.sin(angle), method="rss") + expected = math.cos(math.radians(30.0)) * math.pi / 180.0 + + self.assertEqual(result.dimension, scad.DIMENSIONLESS) + self.assertEqual(result.unit, scad.ONE) + self.assertAlmostEqual(result.nominal, 0.5) + self.assertAlmostEqual(result.upper_deviation, expected, places=12) + + def test_inverse_trig_and_atan2_tolerances_report_degrees(self): + ratio = scad.var("ratio", 50.0, unit="%", tolerance=1.0) + y = scad.var("y", 10.0, unit="mm", tolerance=0.1) + x = scad.var("x", 10.0, unit="mm", tolerance=0.1) + + asin_result = scad.analyze_tolerance(scad.asin(ratio), method="rss") + atan2_result = scad.analyze_tolerance(scad.atan2(y, x), method="rss") + + self.assertEqual(asin_result.unit, scad.DEGREE) + self.assertAlmostEqual(asin_result.nominal, 30.0) + self.assertEqual(atan2_result.unit, scad.DEGREE) + self.assertAlmostEqual(atan2_result.nominal, 45.0) + + def test_area_and_volume_analysis_are_inferred_but_not_requirements(self): + width = scad.var("width", 2.0, unit="mm", tolerance=0.1) + area = width**2 + volume = width**3 + + self.assertEqual(scad.analyze_tolerance(area).dimension, scad.AREA) + self.assertEqual(scad.analyze_tolerance(volume).dimension, scad.VOLUME) + with self.assertRaisesRegex(ValueError, "Length or Angle"): + scad.check_tolerance(area, 0.5, tolerance_unit="mm^2") + + +class TestUnitAwareRequirementsAndPersistence(unittest.TestCase): + def test_requirement_tolerance_units_are_converted_before_comparison(self): + width = scad.var( + "width", 1.0, unit="in", tolerance=0.1, tolerance_unit="mm" + ) + passing = scad.check_tolerance(width, 0.004, tolerance_unit="in") + failing = scad.check_tolerance(width, 0.003, tolerance_unit="in") + + self.assertTrue(passing.passed) + self.assertFalse(failing.passed) + self.assertEqual(passing.requirement.tolerance_unit, scad.INCH) + self.assertAlmostEqual( + passing.requirement.canonical_tolerance.upper_deviation, 0.1016 + ) + + def test_requirement_unit_validation(self): + length = scad.var("length", 10.0, unit="mm", tolerance=0.1) + legacy = scad.var("legacy", 10.0, tolerance=0.1) + scalar = scad.var("scalar", 1.0, unit="1", tolerance=0.1) + + with self.assertRaisesRegex(ValueError, "incompatible"): + scad.check_tolerance(length, 1.0, tolerance_unit="deg") + with self.assertRaisesRegex(ValueError, "unit-aware"): + scad.check_tolerance(legacy, 1.0, tolerance_unit="mm") + with self.assertRaisesRegex(ValueError, "Length or Angle"): + scad.check_tolerance(scalar, 0.1) + + def test_session_model_and_tolerance_graph_roundtrip_units(self): + width = scad.var( + "width", 1.0, unit="in", tolerance=0.1, tolerance_unit="mm" + ) + with scad.GraphSession() as session: + scad.make_box_rsolid(width, 2.0, 3.0) + requirement = session.require_tolerance( + width, 0.004, tolerance_unit="in", name="width" + ) + + raw = json.loads(scad.export_model_json(session)) + self.assertEqual(raw["expression_graph"]["nodes"][0]["unit"], "in") + self.assertEqual( + raw["expression_graph"]["nodes"][0]["tolerance_unit"], "mm" + ) + self.assertEqual( + raw["tolerance_graph"]["requirements"][0]["tolerance_unit"], "in" + ) + self.assertEqual( + raw["tolerance_graph"]["requirements"][0]["target_dimension"], + scad.LENGTH.to_dict(), + ) + + imported = scad.import_model_json(json.dumps(raw)) + rebuilt = imported["tolerance_graph"].requirements[0] + self.assertEqual(rebuilt, requirement) + self.assertTrue(imported["tolerance_graph"].validate().passed) + self.assertEqual(len(scad.replay_model_json(json.dumps(raw))), 1) + + def test_import_rejects_tampered_requirement_units_and_dimensions(self): + width = scad.var("width", 10.0, unit="mm", tolerance=0.1) + expressions = scad.ExpressionGraph() + tolerances = scad.ToleranceGraph(expressions) + tolerances.require(width, 0.1, tolerance_unit="mm", requirement_id="req") + expression_payload = expressions.to_dict() + + wrong_unit = tolerances.to_dict() + wrong_unit["requirements"][0]["tolerance_unit"] = "deg" + with self.assertRaisesRegex(ValueError, "incompatible"): + scad.ToleranceGraph.from_dict( + wrong_unit, scad.ExpressionGraph.from_dict(expression_payload) + ) + + wrong_dimension = tolerances.to_dict() + wrong_dimension["requirements"][0]["target_dimension"] = scad.ANGLE.to_dict() + with self.assertRaisesRegex(ValueError, "does not match"): + scad.ToleranceGraph.from_dict( + wrong_dimension, scad.ExpressionGraph.from_dict(expression_payload) + ) + + missing_dimension = tolerances.to_dict() + missing_dimension["requirements"][0]["target_dimension"] = None + with self.assertRaisesRegex(ValueError, "does not match"): + scad.ToleranceGraph.from_dict( + missing_dimension, scad.ExpressionGraph.from_dict(expression_payload) + ) + + def test_legacy_payloads_and_requirements_remain_compatible(self): + width = scad.var("width", 10.0, tolerance=0.1) + graph = scad.ExpressionGraph() + tolerance_graph = scad.ToleranceGraph(graph) + tolerance_graph.require(width, 0.1, requirement_id="legacy") + payload = tolerance_graph.to_dict() + payload["requirements"][0].pop("tolerance_unit") + payload["requirements"][0].pop("target_dimension") + + rebuilt = scad.ToleranceGraph.from_dict(payload, graph) + requirement = rebuilt.requirements[0] + self.assertIsNone(requirement.tolerance_unit) + self.assertIsNone(requirement.target_dimension) + self.assertTrue(rebuilt.validate().passed) + + +if __name__ == "__main__": + unittest.main() diff --git a/SimpleCADAPI/tests/test_topology_identity.py b/SimpleCADAPI/tests/test_topology_identity.py index d39a55d..d93ace0 100644 --- a/SimpleCADAPI/tests/test_topology_identity.py +++ b/SimpleCADAPI/tests/test_topology_identity.py @@ -3,6 +3,8 @@ from __future__ import annotations from collections import defaultdict import simplecadapi as scad +from simplecadapi.core import clone_semantic_shape_view +from simplecadapi.sketch import Sketch, SketchRef, SketchSolveResult def _box_edge_occurrences(): @@ -36,6 +38,97 @@ def test_shared_edge_occurrences_share_tags_and_metadata(): assert edge_b.get_metadata("name") == "same-topological-edge" +def test_shared_edge_occurrences_keep_entity_state_after_whole_assignment(): + _, occurrences = _box_edge_occurrences() + groups = defaultdict(list) + for edge in occurrences: + groups[edge.topo_id].append(edge) + + edge_a, edge_b = next(group[:2] for group in groups.values() if len(group) >= 2) + scad.apply_tag(edge_a, "shared.edge") + entity = edge_a._entity + containers = ( + entity.tags, + entity.tag_bindings, + entity.tag_lineage, + entity.metadata, + entity.runtime, + ) + + edge_a._tag_cache = set(edge_a._tag_cache) + edge_a._tag_bindings = list(edge_a._tag_bindings) + edge_a._tag_lineage = list(edge_a._tag_lineage) + edge_a._metadata = {"name": "reassigned"} + edge_a._runtime = {"cached": True} + + assert edge_b._entity is entity + assert containers == ( + edge_b._tag_cache, + edge_b._tag_bindings, + edge_b._tag_lineage, + edge_b._metadata, + edge_b._runtime, + ) + assert all( + before is after + for before, after in zip( + containers, + ( + entity.tags, + entity.tag_bindings, + entity.tag_lineage, + entity.metadata, + entity.runtime, + ), + ) + ) + assert edge_b.get_metadata("name") == "reassigned" + assert edge_b._get_runtime("cached") is True + + +def test_non_topology_tagged_objects_keep_standalone_state(): + objects = [ + Sketch(name="standalone"), + SketchRef("sketch", "point", kind="point"), + SketchSolveResult( + sketch_id="sketch", + status="solved", + dof=0, + residual_norm=0.0, + iterations=0, + solved_points={}, + solved_scalars={}, + ), + ] + + for obj in objects: + containers = (obj._tag_cache, obj._metadata, obj._runtime) + obj._tag_cache = {"standalone.state"} + obj._metadata = {"owner": type(obj).__name__} + obj._runtime = {"ready": True} + + assert obj._entity is None + assert obj._tag_cache is containers[0] + assert obj._metadata is containers[1] + assert obj._runtime is containers[2] + assert obj._tag_cache == {"standalone.state"} + assert obj.get_metadata("owner") == type(obj).__name__ + assert obj._get_runtime("ready") is True + + +def test_semantic_shape_clone_keeps_independent_entity_state(): + box = scad.make_box_rsolid(1.0, 1.0, 1.0) + box.set_metadata("view", "source") + + clone = clone_semantic_shape_view(box) + clone._metadata = {"view": "clone"} + + assert clone.topo_id == box.topo_id + assert clone._entity is not box._entity + assert clone.get_metadata("view") == "clone" + assert box.get_metadata("view") == "source" + + def test_solid_get_edges_returns_unique_topological_edges(): box, _ = _box_edge_occurrences() diff --git a/SimpleCADAPI/tools/generate_scene_contract_fixtures.py b/SimpleCADAPI/tools/generate_scene_contract_fixtures.py new file mode 100644 index 0000000..e837f45 --- /dev/null +++ b/SimpleCADAPI/tools/generate_scene_contract_fixtures.py @@ -0,0 +1,480 @@ +"""Regenerate immutable cross-language Scene 1.0 contract vectors.""" + +from __future__ import annotations + +import argparse +import json +import sys +from copy import deepcopy +from dataclasses import asdict +from pathlib import Path + +from simplecadapi.scene import ( + BASE_LIMITS, + canonical_json_bytes, + validate_connector_binding, + validate_entity_asset, + validate_normalized_product, + validate_presentation, + validate_scene_manifest, + validate_scene_package, + with_scene_revision, +) +from scene_contract_fixtures.authored_cases import ( + build_connector_binding_matrix_cases, + build_normalized_product_matrix_cases, + build_presentation_matrix_cases, +) +from scene_contract_fixtures.base_scene import build_scene_package +from scene_contract_fixtures.binary_cases import build_binary_cases +from scene_contract_fixtures.common import b64, content_hash, first_issue, report_case +from scene_contract_fixtures.entity_cases import build_entity_matrix_cases +from scene_contract_fixtures.numeric_cases import build_numeric_vectors +from scene_contract_fixtures.package_cases import build_package_matrix_cases +from scene_contract_fixtures.resource_cases import build_resource_cases +from scene_contract_fixtures.schema_field_matrix import build_schema_field_matrices +from scene_contract_fixtures.scene_corpus import ( + build_revision_vectors, + build_scene_field_cases, + build_scene_shape_cases, +) + + +ROOT = Path(__file__).resolve().parents[1] +OUTPUT = ROOT / "test" / "fixtures" / "scene-contract" / "corpus.json" + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--check", action="store_true", help="fail if output is stale") + args = parser.parse_args() + + scene, scene_bytes, entity, entity_bytes, blobs = build_scene_package() + blob_pool = {uri: b64(payload) for uri, payload in blobs.items()} + scene_shape_cases, scene_packages = build_scene_shape_cases( + scene, entity, blobs, blob_pool + ) + scene_field_cases = build_scene_field_cases(scene) + geometry_uri = next(uri for uri in blobs if uri.startswith("geometry/")) + edge_uri = next(uri for uri in blobs if uri.startswith("edges/")) + entity_uri = next(uri for uri in blobs if uri.startswith("entities/")) + triangle_glb = blobs[geometry_uri] + line_glb = blobs[edge_uri] + canonical_zip, deflate_zip, glb_cases, zip_cases = build_binary_cases( + triangle_glb, + line_glb, + {"scene.json": scene_bytes, **blobs}, + ) + + missing_nodes = deepcopy(scene) + del missing_nodes["nodes"] + missing_nodes_bytes = canonical_json_bytes(missing_nodes) + empty_nodes = with_scene_revision({**scene, "nodes": []}) + empty_nodes_bytes = canonical_json_bytes(empty_nodes) + revision_mismatch = deepcopy(scene) + revision_mismatch["revision"] = "sha256:" + "0" * 64 + revision_mismatch_bytes = canonical_json_bytes(revision_mismatch) + transform_invalid = deepcopy(scene) + transform_invalid["nodes"][0]["transform"]["x_axis"] = [2, 0, 0] + transform_invalid = with_scene_revision(transform_invalid) + transform_invalid_bytes = canonical_json_bytes(transform_invalid) + + nonunit_entity = deepcopy(entity) + next(item for item in nonunit_entity["entities"] if item["kind"] == "edge")[ + "geometry" + ]["direction"] = [2, 0, 0] + nonunit_entity_bytes = canonical_json_bytes(nonunit_entity) + unordered_entity = deepcopy(entity) + shared_edge = next( + item + for item in unordered_entity["entities"] + if item["kind"] == "edge" and len(item["parent_entity_ids"]) == 2 + ) + shared_edge["parent_entity_ids"].reverse() + unordered_entity_bytes = canonical_json_bytes(unordered_entity) + + presentation = { + "appearances": [ + { + "alpha_mode": "opaque", + "base_color": [0.2, 0.3, 0.4, 1], + "double_sided": False, + "edge_color": [0.05, 0.05, 0.05, 1], + "metallic": 0.1, + "name": "fixture", + "roughness": 0.7, + } + ], + "cameras": [ + { + "far": 10000, + "name": "overview", + "near": 1, + "parent_node_id": None, + "projection": "perspective", + "transform": { + "origin": [2000, -2000, 2000], + "x_axis": [1, 0, 0], + "y_axis": [0, 1, 0], + "z_axis": [0, 0, 1], + }, + "vertical_fov_degrees": 45, + } + ], + "node_overrides": [ + { + "appearance_name": "fixture", + "node_id": "instance/root", + "visible": True, + } + ], + "presentation_id": "fixture", + "schema_version": "1.0", + "source_scene_id": "fixture", + } + presentation_bytes = canonical_json_bytes(presentation) + missing_appearance = deepcopy(presentation) + missing_appearance["node_overrides"][0]["appearance_name"] = "missing" + missing_appearance_bytes = canonical_json_bytes(missing_appearance) + bad_camera = deepcopy(presentation) + bad_camera["cameras"][0]["far"] = 1 + bad_camera_bytes = canonical_json_bytes(bad_camera) + + connector_binding = { + "binding_id": "fixture", + "connector_id": "fixture_connector", + "name": None, + "owner_definition_id": "definition/root/part/fixture", + "schema_version": "1.0", + "selected_occurrence_node_id": "instance/root", + "source_model": { + "artifact_hash": "sha256:" + "1" * 64, + "graph_id": "fixture_graph", + "model_schema_version": "2.0", + }, + "source_scene": {"revision": scene["revision"], "scene_id": "fixture"}, + "target": { + "entity_asset_id": entity["geometry_asset_id"], + "entity_id": "entity/face/0", + "expected_source": { + "graph_id": "fixture_graph", + "kind": "model_output", + "node_id": "fixture_body", + "output_slot": 0, + }, + "flip": True, + "kind": "topology_entity", + }, + } + connector_binding_bytes = canonical_json_bytes(connector_binding) + wrong_binding_graph = deepcopy(connector_binding) + wrong_binding_graph["target"]["expected_source"]["graph_id"] = "other_graph" + wrong_binding_graph_bytes = canonical_json_bytes(wrong_binding_graph) + vertex_flip = deepcopy(connector_binding) + vertex_flip["target"]["entity_id"] = "entity/vertex/0" + vertex_flip_bytes = canonical_json_bytes(vertex_flip) + + normalized_part = { + "body_source": {"kind": "manual", "source_id": "fixture_body"}, + "connectors": [], + "kind": "part", + "material": None, + "metadata": {}, + "name": None, + "part_id": "fixture_part", + } + normalized_part_bytes = canonical_json_bytes(normalized_part) + normalized_assembly = { + "assembly_id": "fixture_assembly", + "components": [ + { + "component_id": "component_a", + "definition_ref": "definition/root/part/fixture", + "local_placement": { + "origin": [0, 0, 0], + "x_axis": [1, 0, 0], + "y_axis": [0, 1, 0], + "z_axis": [0, 0, 1], + }, + "name": None, + } + ], + "connectors": [], + "constraints": [], + "grounded_component_ids": ["component_a"], + "kind": "assembly", + "metadata": {}, + "name": None, + } + normalized_assembly_bytes = canonical_json_bytes(normalized_assembly) + missing_grounded_component = deepcopy(normalized_assembly) + missing_grounded_component["grounded_component_ids"] = ["missing"] + missing_grounded_component_bytes = canonical_json_bytes(missing_grounded_component) + duplicate_components = deepcopy(normalized_assembly) + duplicate_components["components"].append( + deepcopy(duplicate_components["components"][0]) + ) + duplicate_components_bytes = canonical_json_bytes(duplicate_components) + + bad_bounds = deepcopy(scene) + bad_bounds["geometry_assets"][0]["scene_local_bounds"]["max"][0] = 999 + bad_bounds = with_scene_revision(bad_bounds) + bad_bounds_bytes = canonical_json_bytes(bad_bounds) + missing_blob = dict(blobs) + del missing_blob[edge_uri] + mutated_blobs = dict(blobs) + mutated_blobs[geometry_uri] = bytes([triangle_glb[0] ^ 1]) + triangle_glb[1:] + + resource_cases = build_resource_cases( + scene, + normalized_part, + ) + manifest_cases = [ + report_case("valid_scene", scene_bytes, validate_scene_manifest(scene_bytes)), + report_case( + "missing_nodes", + missing_nodes_bytes, + validate_scene_manifest(missing_nodes_bytes), + ), + report_case( + "empty_nodes", + empty_nodes_bytes, + validate_scene_manifest(empty_nodes_bytes), + ), + report_case( + "revision_mismatch", + revision_mismatch_bytes, + validate_scene_manifest(revision_mismatch_bytes), + ), + report_case( + "transform_invalid", + transform_invalid_bytes, + validate_scene_manifest(transform_invalid_bytes), + ), + report_case( + "noncanonical_json", + scene_bytes + b"\n", + validate_scene_manifest(scene_bytes + b"\n"), + ), + report_case( + "duplicate_json_key", + b'{"schema_version":"1.0","schema_version":"1.0"}', + validate_scene_manifest( + b'{"schema_version":"1.0","schema_version":"1.0"}' + ), + ), + report_case( + "bom", + b"\xef\xbb\xbf" + scene_bytes, + validate_scene_manifest(b"\xef\xbb\xbf" + scene_bytes), + ), + ] + entity_cases = [ + report_case( + "valid_entities", entity_bytes, validate_entity_asset(entity_bytes) + ), + report_case( + "nonunit_analytic_direction", + nonunit_entity_bytes, + validate_entity_asset(nonunit_entity_bytes), + ), + report_case( + "unordered_adjacency", + unordered_entity_bytes, + validate_entity_asset(unordered_entity_bytes), + ), + ] + entity_cases.extend(build_entity_matrix_cases(entity)) + presentation_cases = [ + report_case( + "valid_presentation", + presentation_bytes, + validate_presentation(presentation_bytes), + ), + report_case( + "missing_presentation_appearance", + missing_appearance_bytes, + validate_presentation(missing_appearance_bytes), + ), + report_case( + "invalid_presentation_camera", + bad_camera_bytes, + validate_presentation(bad_camera_bytes), + ), + ] + presentation_cases.extend(build_presentation_matrix_cases(presentation)) + connector_binding_cases = [ + report_case( + "valid_connector_binding", + connector_binding_bytes, + validate_connector_binding(connector_binding_bytes), + ), + report_case( + "connector_binding_graph_mismatch", + wrong_binding_graph_bytes, + validate_connector_binding(wrong_binding_graph_bytes), + ), + report_case( + "connector_binding_vertex_flip", + vertex_flip_bytes, + validate_connector_binding(vertex_flip_bytes), + ), + ] + connector_binding_cases.extend( + build_connector_binding_matrix_cases(connector_binding) + ) + normalized_product_cases = [ + report_case( + "valid_normalized_part", + normalized_part_bytes, + validate_normalized_product(normalized_part_bytes), + ), + report_case( + "valid_normalized_assembly", + normalized_assembly_bytes, + validate_normalized_product(normalized_assembly_bytes), + ), + report_case( + "missing_grounded_component", + missing_grounded_component_bytes, + validate_normalized_product(missing_grounded_component_bytes), + ), + report_case( + "duplicate_components", + duplicate_components_bytes, + validate_normalized_product(duplicate_components_bytes), + ), + ] + normalized_product_cases.extend( + build_normalized_product_matrix_cases(normalized_part, normalized_assembly) + ) + package_cases = [ + { + "blob_uris": sorted(blobs), + "expected": first_issue(validate_scene_package(scene_bytes, blobs)), + "manifest_base64": b64(scene_bytes), + "name": "valid_package", + "valid": True, + }, + { + "blob_uris": sorted(blobs), + "expected": first_issue(validate_scene_package(bad_bounds_bytes, blobs)), + "manifest_base64": b64(bad_bounds_bytes), + "name": "glb_bounds_mismatch", + "valid": False, + }, + { + "blob_uris": sorted(missing_blob), + "expected": first_issue(validate_scene_package(scene_bytes, missing_blob)), + "manifest_base64": b64(scene_bytes), + "name": "missing_blob", + "valid": False, + }, + { + "blob_mutations": {geometry_uri: b64(mutated_blobs[geometry_uri])}, + "blob_uris": sorted(mutated_blobs), + "expected": first_issue(validate_scene_package(scene_bytes, mutated_blobs)), + "manifest_base64": b64(scene_bytes), + "name": "blob_hash_mismatch", + "valid": False, + }, + ] + package_cases.extend( + build_package_matrix_cases( + scene, entity, blobs, scene_packages, blob_pool + ) + ) + + jcs_input = { + "literals": [None, True, False], + "numbers": [333333333.33333329, 1e30, 4.50, 2e-3, 1e-27], + "string": "€$\u000f\nA'B\"\\\\\"/", + } + jcs_bytes = canonical_json_bytes(jcs_input) + corpus = { + "artifacts": { + "canonical_zip": { + "base64": b64(canonical_zip), + "sha256": content_hash(canonical_zip), + }, + "deflate_zip": { + "base64": b64(deflate_zip), + "sha256": content_hash(deflate_zip), + }, + "edge_glb": { + "base64": b64(line_glb), + "sha256": content_hash(line_glb), + "uri": edge_uri, + }, + "entities": { + "base64": b64(entity_bytes), + "sha256": content_hash(entity_bytes), + "uri": entity_uri, + }, + "geometry_glb": { + "base64": b64(triangle_glb), + "sha256": content_hash(triangle_glb), + "uri": geometry_uri, + }, + "scene": { + "base64": b64(scene_bytes), + "revision": scene["revision"], + "sha256": content_hash(scene_bytes), + }, + }, + "blobs": dict(sorted(blob_pool.items())), + "entity_cases": entity_cases, + "connector_binding_cases": connector_binding_cases, + "format_version": "1.0", + "glb_cases": glb_cases, + "jcs_vectors": [ + { + "canonical_base64": b64(jcs_bytes), + "input": jcs_input, + "name": "rfc8785_number_and_string_sample", + "sha256": content_hash(jcs_bytes), + } + ], + "manifest_cases": manifest_cases, + "numeric_vectors": build_numeric_vectors(), + "normalized_product_cases": normalized_product_cases, + "package_cases": package_cases, + "presentation_cases": presentation_cases, + "resource_cases": resource_cases, + "resource_limits": asdict(BASE_LIMITS), + "revision_vectors": build_revision_vectors(scene_packages), + "scene_field_cases": scene_field_cases, + "scene_shape_cases": scene_shape_cases, + "schema_field_matrices": build_schema_field_matrices( + ROOT / "src/simplecadapi/scene/contracts/schemas" + ), + "zip_cases": zip_cases, + } + for collection in ( + manifest_cases, + entity_cases, + presentation_cases, + connector_binding_cases, + normalized_product_cases, + package_cases, + ): + for case in collection: + assert case["valid"] == (case["expected"] is None), case["name"] + payload = ( + json.dumps(corpus, ensure_ascii=False, indent=2, sort_keys=True) + "\n" + ).encode("utf-8") + relative_output = OUTPUT.relative_to(ROOT) + if args.check: + if not OUTPUT.exists() or OUTPUT.read_bytes() != payload: + print(f"stale generated fixture: {relative_output}", file=sys.stderr) + return 1 + print(f"{relative_output} is current ({len(payload)} bytes)") + return 0 + + OUTPUT.write_bytes(payload) + print(f"wrote {relative_output} ({len(payload)} bytes)") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/SimpleCADAPI/tools/generate_scene_contract_types.py b/SimpleCADAPI/tools/generate_scene_contract_types.py new file mode 100644 index 0000000..32eba94 --- /dev/null +++ b/SimpleCADAPI/tools/generate_scene_contract_types.py @@ -0,0 +1,267 @@ +#!/usr/bin/env python3 +"""Generate Python and TypeScript types from the five Scene contract schemas.""" + +from __future__ import annotations + +import argparse +import json +import shutil +import subprocess +import sys +import tempfile +from importlib.metadata import version +from pathlib import Path +from typing import Any + + +ROOT = Path(__file__).resolve().parents[1] +SCHEMA_DIR = ROOT / "src/simplecadapi/scene/contracts/schemas" +PYTHON_OUTPUT = ROOT / "src/simplecadapi/scene/generated" +TYPESCRIPT_OUTPUT = ROOT / "scene-contract/src/generated" +PYTHON_GENERATOR_VERSION = "0.32.0" +TYPESCRIPT_GENERATOR_VERSION = "15.0.4" +SCHEMAS = ( + ("scene-1.0.schema.json", "scene", "SceneDocument"), + ("entities-1.0.schema.json", "entities", "EntityDocument"), + ("presentation-1.0.schema.json", "presentation", "PresentationDocument"), + ( + "connector-binding-1.0.schema.json", + "connector_binding", + "ConnectorBindingDocument", + ), + ( + "normalized-product-1.schema.json", + "normalized_product", + "NormalizedProductDocument", + ), +) + + +def _generator_schema(value: Any) -> Any: + """Adapt 2020-12 vocabulary unsupported or weakened by the generators.""" + if isinstance(value, list): + return [_generator_schema(item) for item in value] + if not isinstance(value, dict): + if isinstance(value, str) and value.startswith("#/$defs/"): + return "#/definitions/" + value.removeprefix("#/$defs/") + return value + + result: dict[str, Any] = {} + for key, item in value.items(): + if key in {"const", "prefixItems"}: + continue + if key == "$defs": + key = "definitions" + if key == "$schema": + item = "http://json-schema.org/draft-07/schema#" + if key == "items" and "prefixItems" in value: + continue + result[key] = _generator_schema(item) + + if "const" in value: + constant = _generator_schema(value["const"]) + if isinstance(constant, list): + result.update( + { + "type": "array", + "items": [{"enum": [item]} for item in constant], + "additionalItems": False, + "minItems": len(constant), + "maxItems": len(constant), + } + ) + else: + result["enum"] = [constant] + if "prefixItems" in value: + result["items"] = _generator_schema(value["prefixItems"]) + exact_length = value.get("maxItems") == len(value["prefixItems"]) + result["additionalItems"] = ( + False if exact_length else _generator_schema(value.get("items", True)) + ) + return result + + +def _run(command: list[str]) -> None: + subprocess.run(command, cwd=ROOT, check=True) + + +def _generate_python(schema_path: Path, output: Path, root_name: str) -> None: + _run( + [ + sys.executable, + "-m", + "datamodel_code_generator", + "--input", + str(schema_path), + "--input-file-type", + "jsonschema", + "--output", + str(output), + "--output-model-type", + "typing.TypedDict", + "--target-python-version", + "3.10", + "--use-standard-collections", + "--use-union-operator", + "--enum-field-as-literal", + "all", + "--disable-timestamp", + "--use-double-quotes", + "--class-name", + root_name, + ] + ) + + +def _generate_typescript(schema_path: Path, output: Path) -> None: + executable = ROOT / "scene-contract/node_modules/.bin/json2ts" + if not executable.is_file(): + raise RuntimeError("run `npm install` in scene-contract before generating types") + _run( + [ + str(executable), + "--input", + str(schema_path), + "--output", + str(output), + "--no-enableConstEnums", + "--unreachableDefinitions", + "--maxItems=-1", + ] + ) + + +def _write_python_index(output: Path) -> None: + imports = [] + names = [] + for _, module_name, root_name in SCHEMAS: + imports.append(f"from .{module_name} import {root_name}\n") + names.append(f' "{root_name}",\n') + output.joinpath("__init__.py").write_text( + '"""Schema-generated Scene contract document types."""\n\n' + + "".join(imports) + + "\n__all__ = [\n" + + "".join(names) + + "]\n", + encoding="utf-8", + ) + + +def _write_typescript_index(output: Path) -> None: + exports = [ + f'export type {{ {root_name} }} from "./{module_name}.js";\n' + for _, module_name, root_name in SCHEMAS + ] + output.joinpath("index.ts").write_text( + "// Generated by tools/generate_scene_contract_types.py. Do not edit.\n\n" + + "".join(exports), + encoding="utf-8", + ) + + +def _generate(destination: Path, language: str) -> tuple[Path | None, Path | None]: + schema_dir = destination / "schemas" + python_dir = destination / "python" + typescript_dir = destination / "typescript" + schema_dir.mkdir(parents=True) + if language in {"all", "python"}: + python_dir.mkdir() + if language in {"all", "typescript"}: + typescript_dir.mkdir() + + for filename, module_name, root_name in SCHEMAS: + schema = json.loads(SCHEMA_DIR.joinpath(filename).read_text(encoding="utf-8")) + schema["title"] = root_name + adapted_path = schema_dir / filename + adapted_path.write_text( + json.dumps(_generator_schema(schema), indent=2, ensure_ascii=True) + "\n", + encoding="utf-8", + ) + if language in {"all", "python"}: + _generate_python(adapted_path, python_dir / f"{module_name}.py", root_name) + if language in {"all", "typescript"}: + _generate_typescript(adapted_path, typescript_dir / f"{module_name}.ts") + + if language in {"all", "python"}: + _write_python_index(python_dir) + if language in {"all", "typescript"}: + _write_typescript_index(typescript_dir) + return ( + python_dir if language in {"all", "python"} else None, + typescript_dir if language in {"all", "typescript"} else None, + ) + + +def _files(directory: Path) -> dict[str, bytes]: + if not directory.is_dir(): + return {} + return { + path.relative_to(directory).as_posix(): path.read_bytes() + for path in sorted(directory.rglob("*")) + if path.is_file() and path.suffix != ".pyc" and "__pycache__" not in path.parts + } + + +def _sync(generated: Path, committed: Path, *, check: bool) -> bool: + generated_files = _files(generated) + committed_files = _files(committed) + if generated_files == committed_files: + return True + if check: + changed = sorted(generated_files.keys() ^ committed_files.keys()) + changed.extend( + name + for name in sorted(generated_files.keys() & committed_files.keys()) + if generated_files[name] != committed_files[name] + ) + print(f"stale generated types in {committed.relative_to(ROOT)}:") + for name in changed: + print(f" {name}") + return False + if committed.exists(): + shutil.rmtree(committed) + shutil.copytree(generated, committed) + return True + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--check", action="store_true", help="fail if output is stale") + parser.add_argument( + "--language", + choices=("all", "python", "typescript"), + default="all", + ) + args = parser.parse_args() + + if args.language in {"all", "python"}: + installed = version("datamodel-code-generator") + if installed != PYTHON_GENERATOR_VERSION: + raise RuntimeError( + f"expected datamodel-code-generator {PYTHON_GENERATOR_VERSION}, got {installed}" + ) + if args.language in {"all", "typescript"}: + package_path = ( + ROOT / "scene-contract/node_modules/json-schema-to-typescript/package.json" + ) + if not package_path.is_file(): + raise RuntimeError("run `npm install` in scene-contract before generating types") + installed = json.loads(package_path.read_text(encoding="utf-8"))["version"] + if installed != TYPESCRIPT_GENERATOR_VERSION: + raise RuntimeError( + f"expected json-schema-to-typescript {TYPESCRIPT_GENERATOR_VERSION}, " + f"got {installed}" + ) + + with tempfile.TemporaryDirectory(prefix="scene-contract-types-") as temporary: + python_dir, typescript_dir = _generate(Path(temporary), args.language) + current = True + if python_dir is not None: + current &= _sync(python_dir, PYTHON_OUTPUT, check=args.check) + if typescript_dir is not None: + current &= _sync(typescript_dir, TYPESCRIPT_OUTPUT, check=args.check) + return 0 if current else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/SimpleCADAPI/tools/run_example_scene_exports.py b/SimpleCADAPI/tools/run_example_scene_exports.py new file mode 100644 index 0000000..c0761a0 --- /dev/null +++ b/SimpleCADAPI/tools/run_example_scene_exports.py @@ -0,0 +1,383 @@ +"""Run every current example model in an isolated process and export scenes. + +This is intentionally a disposable harness for the example tagging migration. +It uses the model list from ``test_example_model_contract`` so the execution +set stays aligned with the repository's structural contract. +""" + +from __future__ import annotations + +import argparse +import ast +from concurrent.futures import ThreadPoolExecutor, as_completed +from datetime import datetime, timezone +import importlib.util +import json +import os +from pathlib import Path +import shutil +import signal +import subprocess +import sys +import time +from typing import Any + + +ROOT = Path(__file__).resolve().parents[1] +EXAMPLES = ROOT / "examples" +DEFAULT_TIMEOUT_SECONDS = 900 +DEFAULT_HEAVY_TIMEOUT_SECONDS = 1800 +HEAVY_CASES = { + "examples/16_compact_two_stage_planetary_reducer/main.py", + "examples/20_integrated_bldc_joint_actuator/main.py", +} + + +def _is_model_decorator(node: ast.expr) -> bool: + if isinstance(node, ast.Call): + node = node.func + return ( + isinstance(node, ast.Attribute) + and isinstance(node.value, ast.Name) + and node.value.id == "scad" + and node.attr == "model" + ) or (isinstance(node, ast.Name) and node.id == "model") + + +def _builder_name(path: Path) -> str: + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + model_functions = [ + node.name + for node in tree.body + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) + and any(_is_model_decorator(decorator) for decorator in node.decorator_list) + ] + if len(model_functions) != 1: + raise RuntimeError(f"expected one @model entry in {path}, got {model_functions}") + return model_functions[0] + + +def _model_cases() -> list[tuple[str, str]]: + contract_path = ROOT / "test" / "test_example_model_contract.py" + spec = importlib.util.spec_from_file_location( + "simplecadapi_example_model_contract", contract_path + ) + if spec is None or spec.loader is None: + raise RuntimeError(f"could not load model contract: {contract_path}") + contract = importlib.util.module_from_spec(spec) + spec.loader.exec_module(contract) + + cases = [] + for path in contract._model_files(): + relative_path = path.resolve().relative_to(ROOT).as_posix() + cases.append((relative_path, _builder_name(path))) + return cases + + +def _child_main(relative_path: str, builder_name: str, output_dir: Path) -> None: + path = (ROOT / relative_path).resolve() + sys.path.insert(0, str(path.parent)) + spec = importlib.util.spec_from_file_location( + f"simplecadapi_scene_case_{os.getpid()}", path + ) + if spec is None or spec.loader is None: + raise RuntimeError(f"could not load example module: {path}") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + builder = getattr(module, builder_name) + result = builder() + exported = result.export_artifacts(output_dir=output_dir) + print( + "SCENE_RUN_RESULT=" + + json.dumps( + { + "graph_id": exported.session.graph.graph_id, + "artifact_paths": { + key: str(value) for key, value in exported.artifact_paths.items() + }, + "result_node_count": len(exported.result_node_ids), + }, + sort_keys=True, + ), + flush=True, + ) + + +def _kill_process_group(process: subprocess.Popen[str]) -> None: + try: + os.killpg(process.pid, signal.SIGKILL) + except ProcessLookupError: + pass + + +def _run_case( + *, + relative_path: str, + builder_name: str, + run_dir: Path, + timeout_seconds: int, + heavy_timeout_seconds: int, +) -> dict[str, Any]: + case_name = relative_path.removesuffix(".py").replace("/", "__") + case_dir = run_dir / case_name + case_dir.mkdir(parents=True, exist_ok=True) + attempt = 1 + while (case_dir / f"stdout.attempt-{attempt}.log").exists(): + attempt += 1 + for log_name in ("stdout.log", "stderr.log"): + log_path = case_dir / log_name + if log_path.exists(): + log_path.replace( + case_dir / f"{log_path.stem}.attempt-{attempt}{log_path.suffix}" + ) + timeout = ( + heavy_timeout_seconds + if relative_path in HEAVY_CASES + else timeout_seconds + ) + command = [ + sys.executable, + str(Path(__file__).resolve()), + "--child", + relative_path, + builder_name, + str(case_dir), + ] + environment = dict(os.environ) + environment["PYTHONHASHSEED"] = "0" + started_at = datetime.now(timezone.utc) + started = time.monotonic() + print( + f"[scene-export] starting {relative_path}::{builder_name} " + f"timeout={timeout}s", + flush=True, + ) + stdout = "" + stderr = "" + returncode: int | None = None + timed_out = False + error: str | None = None + try: + process = subprocess.Popen( + command, + cwd=ROOT, + env=environment, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + errors="replace", + start_new_session=True, + ) + try: + stdout, stderr = process.communicate(timeout=timeout) + returncode = process.returncode + except subprocess.TimeoutExpired as exc: + timed_out = True + _kill_process_group(process) + stdout, stderr = process.communicate() + if exc.stdout: + stdout = str(exc.stdout) + if exc.stderr: + stderr = str(exc.stderr) + except Exception as exc: # pragma: no cover - harness failure path + error = f"{exc.__class__.__name__}: {exc}" + + elapsed = time.monotonic() - started + (case_dir / "stdout.log").write_text(stdout, encoding="utf-8") + (case_dir / "stderr.log").write_text(stderr, encoding="utf-8") + scene_paths = sorted(case_dir.glob("*.scene.zip")) + child_result: dict[str, Any] | None = None + for line in stdout.splitlines(): + if line.startswith("SCENE_RUN_RESULT="): + try: + child_result = json.loads(line.removeprefix("SCENE_RUN_RESULT=")) + except json.JSONDecodeError: + error = "invalid SCENE_RUN_RESULT payload" + + status = "passed" + if timed_out: + status = "timeout" + elif error is not None or returncode != 0: + status = "failed" + elif len(scene_paths) != 1: + status = "failed" + error = f"expected one scene zip, found {len(scene_paths)}" + + record = { + "case": relative_path, + "builder": builder_name, + "case_dir": str(case_dir.relative_to(ROOT)), + "status": status, + "returncode": returncode, + "timed_out": timed_out, + "timeout_seconds": timeout, + "elapsed_seconds": round(elapsed, 3), + "started_at": started_at.isoformat(), + "stdout_log": str((case_dir / "stdout.log").relative_to(ROOT)), + "stderr_log": str((case_dir / "stderr.log").relative_to(ROOT)), + "scene_paths": [str(path.relative_to(ROOT)) for path in scene_paths], + "child_result": child_result, + "error": error, + "attempt": attempt, + } + print( + f"[scene-export] {status} {relative_path} elapsed={elapsed:.1f}s", + flush=True, + ) + return record + + +def _publish_scenes( + run_dir: Path, + records: list[dict[str, Any]], +) -> list[dict[str, Any]]: + scene_dir = run_dir / "scenes" + scene_dir.mkdir(parents=True, exist_ok=True) + for existing in scene_dir.glob("*.scene.zip"): + existing.unlink() + published: list[dict[str, Any]] = [] + for record in records: + scene_paths = record.get("scene_paths", []) + if record.get("status") != "passed" or len(scene_paths) != 1: + continue + case_path = Path(record["case"]) + case_name = ( + case_path.parent.name if case_path.name == "main.py" else case_path.stem + ) + source_path = ROOT / scene_paths[0] + destination = scene_dir / f"{case_name}.scene.zip" + shutil.copy2(source_path, destination) + published.append( + { + "case": record["case"], + "path": str(destination.relative_to(ROOT)), + } + ) + return published + + +def _parent_main(args: argparse.Namespace) -> int: + all_cases = _model_cases() + previous_records: dict[str, dict[str, Any]] = {} + if args.resume is not None: + run_dir = args.resume.resolve() + report_path = run_dir / "execution_report.json" + previous_report = json.loads(report_path.read_text(encoding="utf-8")) + run_id = str(previous_report["run_id"]) + started_at = datetime.fromisoformat(previous_report["started_at"]) + previous_records = { + record["case"]: record for record in previous_report["cases"] + } + cases = [ + case + for case in all_cases + if previous_records.get(case[0], {}).get("status") != "passed" + ] + print( + f"[scene-export] resuming {run_id}; retrying {len(cases)} cases", + flush=True, + ) + else: + cases = all_cases + run_id = datetime.now(timezone.utc).strftime("scene_export_%Y%m%dT%H%M%SZ") + run_dir = EXAMPLES / "out" / run_id + run_dir.mkdir(parents=True, exist_ok=False) + started_at = datetime.now(timezone.utc) + if args.case: + requested = set(args.case) + cases = [case for case in cases if case[0] in requested] + unknown = requested - {case[0] for case in all_cases} + if unknown: + raise ValueError(f"unknown example cases: {sorted(unknown)}") + if args.publish_only: + cases = [] + records: list[dict[str, Any]] = [] + with ThreadPoolExecutor(max_workers=args.workers) as executor: + futures = { + executor.submit( + _run_case, + relative_path=relative_path, + builder_name=builder_name, + run_dir=run_dir, + timeout_seconds=args.timeout, + heavy_timeout_seconds=args.heavy_timeout, + ): (relative_path, builder_name) + for relative_path, builder_name in cases + } + for future in as_completed(futures): + relative_path, builder_name = futures[future] + try: + records.append(future.result()) + except Exception as exc: # pragma: no cover - harness failure path + records.append( + { + "case": relative_path, + "builder": builder_name, + "status": "failed", + "error": f"runner worker {exc.__class__.__name__}: {exc}", + } + ) + + records_by_case = dict(previous_records) + records_by_case.update({record["case"]: record for record in records}) + records = sorted(records_by_case.values(), key=lambda record: record["case"]) + report = { + "run_id": run_id, + "started_at": started_at.isoformat(), + "finished_at": datetime.now(timezone.utc).isoformat(), + "root": str(ROOT), + "workers": args.workers, + "timeout_seconds": args.timeout, + "heavy_timeout_seconds": args.heavy_timeout, + "case_count": len(all_cases), + "passed": sum(record.get("status") == "passed" for record in records), + "failed": sum(record.get("status") == "failed" for record in records), + "timed_out": sum(record.get("status") == "timeout" for record in records), + "cases": records, + } + report_path = run_dir / "execution_report.json" + manifest_path = run_dir / "manifest.json" + report_path.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n") + published_scenes = _publish_scenes(run_dir, records) + manifest = { + "run_id": run_id, + "case_count": len(all_cases), + "successful_scene_count": len(published_scenes), + "standalone_scene_packages": published_scenes, + } + manifest_path.write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n") + print(f"[scene-export] report={report_path}", flush=True) + print(f"[scene-export] scenes={run_dir / 'scenes'}", flush=True) + print( + f"[scene-export] passed={report['passed']} failed={report['failed']} " + f"timed_out={report['timed_out']}", + flush=True, + ) + return 0 if report["failed"] == 0 and report["timed_out"] == 0 else 1 + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--child", nargs=3, metavar=("PATH", "BUILDER", "OUTPUT")) + parser.add_argument("--workers", type=int, default=3) + parser.add_argument("--timeout", type=int, default=DEFAULT_TIMEOUT_SECONDS) + parser.add_argument("--resume", type=Path) + parser.add_argument("--case", action="append") + parser.add_argument("--publish-only", action="store_true") + parser.add_argument( + "--heavy-timeout", type=int, default=DEFAULT_HEAVY_TIMEOUT_SECONDS + ) + args = parser.parse_args() + if args.child: + relative_path, builder_name, output = args.child + _child_main(relative_path, builder_name, Path(output).resolve()) + return 0 + if args.workers < 1: + parser.error("--workers must be at least 1") + if args.publish_only and args.resume is None: + parser.error("--publish-only requires --resume") + return _parent_main(args) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/SimpleCADAPI/tools/scene_contract_fixtures/__init__.py b/SimpleCADAPI/tools/scene_contract_fixtures/__init__.py new file mode 100644 index 0000000..353115b --- /dev/null +++ b/SimpleCADAPI/tools/scene_contract_fixtures/__init__.py @@ -0,0 +1 @@ +"""Builders for the immutable cross-language Scene 1.0 fixture corpus.""" diff --git a/SimpleCADAPI/tools/scene_contract_fixtures/authored_cases.py b/SimpleCADAPI/tools/scene_contract_fixtures/authored_cases.py new file mode 100644 index 0000000..4ddf6d0 --- /dev/null +++ b/SimpleCADAPI/tools/scene_contract_fixtures/authored_cases.py @@ -0,0 +1,167 @@ +"""Presentation, connector binding, and normalized product corpus cases.""" + +from __future__ import annotations + +from copy import deepcopy + +from simplecadapi.scene import ( + validate_connector_binding, + validate_normalized_product, + validate_presentation, +) + +from .common import JsonObject, validation_case + + +def build_presentation_matrix_cases( + presentation: JsonObject, +) -> list[dict[str, object]]: + orthographic = deepcopy(presentation) + orthographic["cameras"][0].pop("vertical_fov_degrees") + orthographic["cameras"][0].update( + {"projection": "orthographic", "vertical_span": 1000} + ) + visible_only = deepcopy(presentation) + visible_only["node_overrides"][0].pop("appearance_name") + appearance_only = deepcopy(presentation) + appearance_only["node_overrides"][0].pop("visible") + neither = deepcopy(presentation) + neither["node_overrides"][0] = {"node_id": "instance/root"} + unknown_projection = deepcopy(presentation) + unknown_projection["cameras"][0]["projection"] = "unknown" + return [ + validation_case("valid_orthographic_camera", orthographic, validate_presentation), + validation_case("valid_visible_only_override", visible_only, validate_presentation), + validation_case( + "valid_appearance_only_override", appearance_only, validate_presentation + ), + validation_case("empty_node_override", neither, validate_presentation), + validation_case( + "unknown_camera_projection", unknown_projection, validate_presentation + ), + ] + + +def build_connector_binding_matrix_cases( + binding: JsonObject, +) -> list[dict[str, object]]: + cases: list[dict[str, object]] = [] + for topology_kind, entity_id, flip in ( + ("FACE", "entity/face/0", True), + ("EDGE", "entity/edge/0", True), + ("VERTEX", "entity/vertex/0", False), + ): + value = deepcopy(binding) + value["target"]["entity_id"] = entity_id + value["target"]["flip"] = flip + value["target"]["expected_source"] = { + "graph_id": "fixture_graph", + "kind": "model_topology", + "node_id": "fixture_body", + "output_slot": 0, + "topo_id": f"topo-{topology_kind.lower()}", + "topology_kind": topology_kind, + } + cases.append( + validation_case( + f"valid_binding_model_topology_{topology_kind.lower()}", + value, + validate_connector_binding, + ) + ) + named = deepcopy(binding) + named["name"] = "Fixture connector" + cases.append(validation_case("valid_binding_named", named, validate_connector_binding)) + missing_name = deepcopy(binding) + del missing_name["name"] + cases.append( + validation_case("missing_binding_name", missing_name, validate_connector_binding) + ) + unsupported_version = deepcopy(binding) + unsupported_version["source_model"]["model_schema_version"] = "3.0" + cases.append( + validation_case( + "unsupported_binding_model_schema_version", + unsupported_version, + validate_connector_binding, + ) + ) + return cases + + +def build_normalized_product_matrix_cases( + part: JsonObject, assembly: JsonObject +) -> list[dict[str, object]]: + material_part = deepcopy(part) + material_part["material"] = { + "color": [0.2, 0.3, 0.4], + "density": 7.85, + "density_unit": "g/cm3", + "material_id": "steel", + "metadata": {}, + "name": "Steel", + } + model_part = deepcopy(part) + model_part["body_source"] = { + "graph_id": "graph", + "kind": "model_output", + "node_id": "body", + "output_slot": 0, + } + placement = deepcopy(part) + placement["connectors"] = [ + { + "anchor": { + "anchor_kind": "placement", + "placement": { + "origin": [0, 0, 0], + "x_axis": [1, 0, 0], + "y_axis": [0, 1, 0], + "z_axis": [0, 0, 1], + }, + }, + "connector_id": "placement", + "name": None, + } + ] + forwarded = deepcopy(assembly) + forwarded["connectors"] = [ + { + "anchor": { + "anchor_kind": "forwarded", + "offset": None, + "source_component_id": "component_a", + "source_connector_id": "placement", + }, + "connector_id": "forwarded", + "name": None, + } + ] + forwarded_offset = deepcopy(forwarded) + forwarded_offset["connectors"][0]["anchor"]["offset"] = { + "origin": [0, 0, 5], + "x_axis": [1, 0, 0], + "y_axis": [0, 1, 0], + "z_axis": [0, 0, 1], + } + return [ + validation_case( + "valid_complete_material", material_part, validate_normalized_product + ), + validation_case( + "valid_model_body_source", model_part, validate_normalized_product + ), + validation_case( + "valid_placement_connector", placement, validate_normalized_product + ), + validation_case( + "valid_forwarded_connector_null_offset", + forwarded, + validate_normalized_product, + ), + validation_case( + "valid_forwarded_connector_offset", + forwarded_offset, + validate_normalized_product, + ), + ] diff --git a/SimpleCADAPI/tools/scene_contract_fixtures/base_scene.py b/SimpleCADAPI/tools/scene_contract_fixtures/base_scene.py new file mode 100644 index 0000000..7417f76 --- /dev/null +++ b/SimpleCADAPI/tools/scene_contract_fixtures/base_scene.py @@ -0,0 +1,471 @@ +"""Base Scene package and canonical tetrahedron asset construction.""" + +from __future__ import annotations + +import hashlib +import math +import struct + +from simplecadapi.scene import canonical_json_bytes, with_scene_revision + +from .binary_cases import glb +from .common import content_hash, sha256_hex + + +IDENTITY = { + "origin": [0, 0, 0], + "x_axis": [1, 0, 0], + "y_axis": [0, 1, 0], + "z_axis": [0, 0, 1], +} +ASSET_TO_SCENE = [1000, 0, 0, 0, 0, 0, -1000, 0, 0, 1000, 0, 0, 0, 0, 0, 1] + + +def _normalize(vector): + length = math.sqrt(sum(component * component for component in vector)) + return tuple(component / length for component in vector) + + +def _cross(left, right): + return ( + left[1] * right[2] - left[2] * right[1], + left[2] * right[0] - left[0] * right[2], + left[0] * right[1] - left[1] * right[0], + ) + + +def _frame(origin, x_axis): + x_axis = _normalize(x_axis) + seed = (0.0, 0.0, 1.0) if abs(x_axis[2]) < 0.9 else (0.0, 1.0, 0.0) + projection = sum(left * right for left, right in zip(seed, x_axis)) + y_axis = _normalize( + tuple(seed[index] - projection * x_axis[index] for index in range(3)) + ) + z_axis = _normalize(_cross(x_axis, y_axis)) + return { + "origin": list(origin), + "x_axis": list(x_axis), + "y_axis": list(y_axis), + "z_axis": list(z_axis), + } + + +def _canonical_triangle_asset(): + points = { + "a": (0.0, 0.0, 0.0), + "b": (1.0, 0.0, 0.0), + "c": (0.0, 1.0, 0.0), + "d": (0.0, 0.0, 1.0), + } + oriented_faces = { + "abc": ("a", "c", "b"), + "abd": ("a", "b", "d"), + "acd": ("a", "d", "c"), + "bcd": ("b", "c", "d"), + } + blocks = [] + for name, corners in oriented_faces.items(): + a, b, c = (points[corner] for corner in corners) + normal = _normalize( + _cross( + tuple(b[i] - a[i] for i in range(3)), + tuple(c[i] - a[i] for i in range(3)), + ) + ) + local_positions = sorted( + (points[corner] for corner in corners), + key=lambda point: struct.pack(" float: + result = struct.unpack(" bytes: + positions = [tuple(_f32(component) for component in point) for point in positions] + position_bytes = b"".join(struct.pack(" bytes: + json_length = struct.unpack_from(" bytes: + local_parts: list[bytes] = [] + central_parts: list[bytes] = [] + local_offset = 0 + for name in sorted(members, key=lambda value: value.encode("ascii")): + name_bytes = name.encode("ascii") + payload = members[name] + compressor = zlib.compressobj(level=9, wbits=-zlib.MAX_WBITS) + compressed = compressor.compress(payload) + compressor.flush() + crc = binascii.crc32(payload) & 0xFFFFFFFF + local_header = LOCAL.pack( + 0x04034B50, + 20, + 0x0800, + 8, + 0, + 0x0021, + crc, + len(compressed), + len(payload), + len(name_bytes), + 0, + ) + local_parts.extend((local_header, name_bytes, compressed)) + central_parts.extend( + ( + CENTRAL.pack( + 0x02014B50, + 0x0314, + 20, + 0x0800, + 8, + 0, + 0x0021, + crc, + len(compressed), + len(payload), + len(name_bytes), + 0, + 0, + 0, + 0, + 0x81A40000, + local_offset, + ), + name_bytes, + ) + ) + local_offset += len(local_header) + len(name_bytes) + len(compressed) + central = b"".join(central_parts) + return b"".join(local_parts) + central + EOCD.pack( + 0x06054B50, + 0, + 0, + len(members), + len(members), + len(central), + local_offset, + 0, + ) + + +def _replace_u16(data: bytes, offset: int, value: int) -> bytes: + result = bytearray(data) + struct.pack_into(" bytes: + result = bytearray(data) + struct.pack_into(" int: + return 28 + struct.unpack_from(" bytes: + return _replace_u32(data, _glb_bin_start(data) + relative_offset, value) + + +def _set_path(value: object, path: tuple[object, ...], replacement: object) -> None: + current = value + for part in path[:-1]: + current = current[part] # type: ignore[index] + current[path[-1]] = replacement # type: ignore[index] + + +def _add_path(value: object, path: tuple[object, ...], key: str, item: object) -> None: + current = value + for part in path: + current = current[part] # type: ignore[index] + current[key] = item # type: ignore[index] + + +def _append_path(value: object, path: tuple[object, ...], item: object) -> None: + current = value + for part in path: + current = current[part] # type: ignore[index] + current.append(item) # type: ignore[attr-defined] + + +def _nonminimal_glb_json_padding(data: bytes) -> bytes: + json_length = struct.unpack_from(" bytes: + json_length = struct.unpack_from(" float: + return struct.unpack(" dict[str, object]: + try: + info = preflight_glb(payload, expected_kind=expected_kind) + error = None + actual_valid = True + actual_kind = info.kind + except ValueError as exc: + error = str(exc) + actual_valid = False + actual_kind = None + assert actual_valid == valid, name + return { + "error": error, + "expected_kind": expected_kind, + "kind": actual_kind, + "name": name, + "payload_base64": b64(payload), + "valid": valid, + } + + +def _zip_case( + name: str, + payload: bytes, + valid: bool, + used_deflate: bool | None, +) -> dict[str, object]: + try: + info = preflight_zip_bytes(payload) + error = None + actual_valid = True + actual_deflate = info.used_deflate + except ValueError as exc: + error = str(exc) + actual_valid = False + actual_deflate = None + assert actual_valid == valid, name + assert actual_deflate == used_deflate, name + return { + "error": error, + "name": name, + "payload_base64": b64(payload), + "used_deflate": used_deflate, + "valid": valid, + } + + +def _zip_layout(data: bytes) -> tuple[list[tuple[int, int, int]], int, int]: + eocd = len(data) - EOCD.size + count = struct.unpack_from(" bytes: + entries, _central, _eocd = _zip_layout(data) + central_offset, local_offset, name_length = entries[index] + assert len(name) == name_length + assert struct.unpack_from(" bytes: + entries, central, eocd = _zip_layout(data) + result = bytearray(b"x" + data) + for central_offset, local_offset, _name_length in entries: + struct.pack_into(" bytes: + _entries, central, eocd = _zip_layout(data) + result = bytearray(data[:central] + b"x" + data[central:]) + struct.pack_into(" tuple[bytes, bytes, list[dict[str, object]], list[dict[str, object]]]: + canonical_zip = canonical_zip_bytes(members) + deflate_zip = _deflate_zip(members) + string_bounds_glb = replace_glb_json( + triangle_glb, + lambda document: document["accessors"][0]["min"].__setitem__(0, "0"), + ) + boolean_scene_glb = replace_glb_json( + triangle_glb, lambda document: document.__setitem__("scene", False) + ) + simple_triangle = glb( + "triangle", + [(0, 0, 0), (1, 0, 0), (0, 1, 0)], + [0, 1, 2], + [(0, 0, 1)] * 3, + ) + float_extreme_line = glb( + "line", + [ + ( + _f32_from_bits(0xFF7FFFFF), + _f32_from_bits(0x80000001), + _f32_from_bits(0x007FFFFF), + ), + ( + _f32_from_bits(0x80800000), + 0.0, + _f32_from_bits(0x00000001), + ), + ( + _f32_from_bits(0x00800000), + _f32_from_bits(0x007FFFFF), + _f32_from_bits(0x7F7FFFFF), + ), + ], + [0, 1, 1, 2], + ) + + glb_payloads: list[tuple[str, bytes, str, bool]] = [ + ("valid_triangle", triangle_glb, "triangle", True), + ("valid_line", line_glb, "line", True), + ("valid_float32_finite_extremes", float_extreme_line, "line", True), + ("string_accessor_bounds", string_bounds_glb, "triangle", False), + ("boolean_scene_index", boolean_scene_glb, "triangle", False), + ] + for name, base, path, key, value in ( + ("root_extensions_used", triangle_glb, (), "extensionsUsed", []), + ("asset_extras", triangle_glb, ("asset",), "extras", {}), + ("node_name", triangle_glb, ("nodes", 0), "name", "node"), + ("scene_name", triangle_glb, ("scenes", 0), "name", "scene"), + ("mesh_name", triangle_glb, ("meshes", 0), "name", "mesh"), + ( + "primitive_material", + triangle_glb, + ("meshes", 0, "primitives", 0), + "material", + 0, + ), + ("buffer_uri", triangle_glb, ("buffers", 0), "uri", "buffer.bin"), + ("buffer_view_byte_stride", triangle_glb, ("bufferViews", 0), "byteStride", 12), + ("position_accessor_byte_offset", triangle_glb, ("accessors", 0), "byteOffset", 0), + ("normal_accessor_normalized", triangle_glb, ("accessors", 1), "normalized", False), + ("index_accessor_sparse", triangle_glb, ("accessors", 2), "sparse", {}), + ): + glb_payloads.append( + ( + name, + replace_glb_json( + base, + lambda document, path=path, key=key, value=value: _add_path( + document, path, key, value + ), + ), + "triangle", + False, + ) + ) + for name, base, path, value, kind in ( + ("empty_position_accessor", line_glb, ("accessors", 0, "count"), 0, "line"), + ("empty_index_accessor", line_glb, ("accessors", 1, "count"), 0, "line"), + ( + "wrong_triangle_mode", + triangle_glb, + ("meshes", 0, "primitives", 0, "mode"), + 1, + "triangle", + ), + ( + "wrong_position_component_type", + triangle_glb, + ("accessors", 0, "componentType"), + 5125, + "triangle", + ), + ("wrong_normal_count", triangle_glb, ("accessors", 1, "count"), 1, "triangle"), + ( + "wrong_index_component_type", + triangle_glb, + ("accessors", 2, "componentType"), + 5125, + "triangle", + ), + ("buffer_view_gap", triangle_glb, ("bufferViews", 1, "byteOffset"), 4, "triangle"), + ( + "buffer_view_wrong_target", + triangle_glb, + ("bufferViews", 0, "target"), + 34963, + "triangle", + ), + ( + "buffer_view_wrong_length", + triangle_glb, + ("bufferViews", 0, "byteLength"), + 4, + "triangle", + ), + ): + glb_payloads.append( + ( + name, + replace_glb_json( + base, + lambda document, path=path, value=value: _set_path( + document, path, value + ), + ), + kind, + False, + ) + ) + glb_payloads.extend( + ( + ("bad_glb_magic", _replace_u32(triangle_glb, 0, 0), "triangle", False), + ("bad_glb_version", _replace_u32(triangle_glb, 4, 1), "triangle", False), + ( + "bad_glb_total_length", + _replace_u32(triangle_glb, 8, len(triangle_glb) + 4), + "triangle", + False, + ), + ("bad_json_chunk_type", _replace_u32(triangle_glb, 16, 0), "triangle", False), + ( + "nonminimal_json_padding", + _nonminimal_glb_json_padding(triangle_glb), + "triangle", + False, + ), + ( + "bad_bin_chunk_type", + _replace_u32( + triangle_glb, + 24 + struct.unpack_from(" str: + return base64.b64encode(value).decode("ascii") + + +def sha256_hex(value: bytes) -> str: + return hashlib.sha256(value).hexdigest() + + +def content_hash(value: bytes) -> str: + return "sha256:" + sha256_hex(value) + + +def first_issue(report: Any) -> dict[str, str] | None: + issue = report.first_error + return None if issue is None else {"code": issue.code, "path": issue.path} + + +def report_case(name: str, payload: bytes, report: Any) -> dict[str, object]: + return { + "expected": first_issue(report), + "name": name, + "payload_base64": b64(payload), + "valid": report.valid, + } + + +def transform(origin: list[float] | None = None) -> JsonObject: + value = deepcopy(IDENTITY) + if origin is not None: + value["origin"] = origin + return value + + +def validation_case( + name: str, value: Any, validator: Validator +) -> dict[str, object]: + payload = value if isinstance(value, bytes) else canonical_json_bytes(value) + return report_case(name, payload, validator(payload)) + + +def replace_entity_sidecar( + scene: JsonObject, + entity: JsonObject, + blobs: dict[str, bytes], +) -> ScenePackage: + entity_bytes = canonical_json_bytes(entity) + entity_hash = content_hash(entity_bytes) + entity_uri = f"entities/sha256-{entity_hash.removeprefix('sha256:')}.json" + entity_record = scene["entity_assets"][0] + old_uri = entity_record["uri"] + entity_record.update( + { + "byte_length": len(entity_bytes), + "content_hash": entity_hash, + "entity_asset_id": entity_hash, + "uri": entity_uri, + } + ) + for definition in scene["definitions"]: + if definition["kind"] in {"part", "shape"}: + definition["entity_asset_id"] = entity_hash + updated_blobs = {uri: payload for uri, payload in blobs.items() if uri != old_uri} + updated_blobs[entity_uri] = entity_bytes + return scene, entity, updated_blobs + + +def set_product_material_appearance( + scene: JsonObject, material_id: str = "fixture_material" +) -> None: + appearance = scene["appearances"][0] + appearance["name"] = "Fixture material" + appearance["source"] = { + "kind": "product_material", + "material_id": material_id, + "root_id": "root", + } + draft = dict(appearance) + draft.pop("appearance_id") + appearance_id = "appearance/evaluated/" + sha256_hex(canonical_json_bytes(draft)) + appearance["appearance_id"] = appearance_id + for definition in scene["definitions"]: + if definition["kind"] in {"part", "shape"}: + definition["appearance_id"] = appearance_id + + +def inline_package_case( + name: str, + scene: JsonObject, + blobs: dict[str, bytes], + blob_pool: dict[str, str], +) -> dict[str, object]: + scene_bytes = canonical_json_bytes(scene) + report = validate_scene_package(scene_bytes, blobs) + for uri, payload in blobs.items(): + encoded = b64(payload) + previous = blob_pool.setdefault(uri, encoded) + if previous != encoded: + raise AssertionError(f"one fixture URI resolved to different bytes: {uri}") + return { + "blob_uris": sorted(blobs), + "expected": first_issue(report), + "manifest_base64": b64(scene_bytes), + "name": name, + "valid": report.valid, + } + + +def scene_shape_case( + name: str, + scene: JsonObject, + blobs: dict[str, bytes], + expected: dict[str, object], + blob_pool: dict[str, str], +) -> dict[str, object]: + package = inline_package_case(name, scene, blobs, blob_pool) + if not package["valid"]: + raise AssertionError(f"invalid scene shape fixture {name}: {package['expected']}") + return {**package, "expected_shape": expected} diff --git a/SimpleCADAPI/tools/scene_contract_fixtures/entity_cases.py b/SimpleCADAPI/tools/scene_contract_fixtures/entity_cases.py new file mode 100644 index 0000000..22fae11 --- /dev/null +++ b/SimpleCADAPI/tools/scene_contract_fixtures/entity_cases.py @@ -0,0 +1,103 @@ +"""Entity provenance, topology adjacency, and range corpus cases.""" + +from __future__ import annotations + +from copy import deepcopy + +from simplecadapi.scene import validate_entity_asset + +from .common import JsonObject, validation_case + + +def _entity_with_source(entity: JsonObject, source_kind: str) -> JsonObject: + result = deepcopy(entity) + topology_kind = { + "solid": "SOLID", + "face": "FACE", + "edge": "EDGE", + "vertex": "VERTEX", + } + for record in result["entities"]: + if source_kind == "model_output": + record["source"] = { + "graph_id": "graph", + "kind": "model_output", + "node_id": "body", + "output_slot": 0, + } + elif source_kind == "model_topology": + record["source"] = { + "graph_id": "graph", + "kind": "model_topology", + "node_id": "body", + "output_slot": 0, + "topo_id": record["entity_id"], + "topology_kind": topology_kind[record["kind"]], + } + else: + record["source"] = { + "kind": "imported_primitive", + "source_element_id": record["entity_id"], + } + return result + + +def build_entity_matrix_cases(entity: JsonObject) -> list[dict[str, object]]: + cases = [ + validation_case( + f"valid_entity_source_{source_kind}", + _entity_with_source(entity, source_kind), + validate_entity_asset, + ) + for source_kind in ("model_output", "model_topology", "imported_primitive") + ] + topology_mismatch = _entity_with_source(entity, "model_topology") + face = next( + record for record in topology_mismatch["entities"] if record["kind"] == "face" + ) + face["source"]["topology_kind"] = "EDGE" + cases.append( + validation_case( + "model_topology_kind_mismatch", topology_mismatch, validate_entity_asset + ) + ) + + missing_reciprocal = deepcopy(entity) + face = next( + record for record in missing_reciprocal["entities"] if record["kind"] == "face" + ) + edge_id = face["child_entity_ids"][0] + edge = next( + record + for record in missing_reciprocal["entities"] + if record["entity_id"] == edge_id + ) + edge["parent_entity_ids"].remove(face["entity_id"]) + cases.append( + validation_case( + "missing_reciprocal_adjacency", missing_reciprocal, validate_entity_asset + ) + ) + + range_gap = deepcopy(entity) + range_gap["face_groups"][1]["first_index"] += 3 + cases.append( + validation_case("face_group_range_gap", range_gap, validate_entity_asset) + ) + wrong_cardinality = deepcopy(entity) + wrong_cardinality["edge_groups"][0]["index_count"] = 3 + cases.append( + validation_case( + "edge_group_wrong_cardinality", wrong_cardinality, validate_entity_asset + ) + ) + unsupported_version = deepcopy(entity) + unsupported_version["schema_version"] = "2.0" + cases.append( + validation_case( + "unsupported_entity_schema_version", + unsupported_version, + validate_entity_asset, + ) + ) + return cases diff --git a/SimpleCADAPI/tools/scene_contract_fixtures/field_matrix.py b/SimpleCADAPI/tools/scene_contract_fixtures/field_matrix.py new file mode 100644 index 0000000..824c95a --- /dev/null +++ b/SimpleCADAPI/tools/scene_contract_fixtures/field_matrix.py @@ -0,0 +1,215 @@ +"""Declarative required, nullable, and discriminated-union mutations.""" + +from __future__ import annotations + +from copy import deepcopy +from typing import Any, Iterable + +from simplecadapi.scene import canonical_json_bytes, with_scene_revision + +from .common import sha256_hex + + +Path = tuple[str | int, ...] + + +def _case( + name: str, + operation: str, + path: Path, + value: Any = None, + *, + recompute_revision: bool = False, +) -> dict[str, Any]: + mutation = {"operation": operation, "path": list(path)} + if operation != "delete": + mutation["value"] = value + return { + "mutations": [mutation], + "name": name, + "recompute_revision": recompute_revision, + } + + +def _missing_fields(path: Path, fields: Iterable[str]) -> Iterable[dict[str, Any]]: + for field in fields: + yield _case( + "missing_" + "_".join((*map(str, path), field)), + "delete", + (*path, field), + ) + + +def scene_field_matrix(scene: dict[str, Any]) -> list[dict[str, Any]]: + """Return compact mutations covering the frozen structural field matrix.""" + + records: list[tuple[Path, tuple[str, ...]]] = [ + ( + (), + ( + "schema_version", "extensions_used", "extensions_required", + "extensions", "scene_id", "revision", "generator", "source", + "coordinate_system", "compile_options", "definitions", "nodes", + "geometry_assets", "edge_assets", "appearances", "entity_assets", + "connectors", "cameras", "lights", "annotations", "diagnostics", + ), + ), + ( + ("compile_options",), + ("linear_tolerance", "angular_tolerance", "embed_source", "embed_presentation"), + ), + ( + ("definitions", 0), + ("definition_id", "kind", "name", "source", "sdk_metadata"), + ), + ( + ("nodes", 0), + ( + "node_id", "parent_node_id", "order", "definition_id", "name", + "transform", "visible", "selectable", "appearance_override_id", + "source", "sdk_metadata", + ), + ), + ( + ("appearances", 0), + ( + "appearance_id", "name", "source", "base_color", "metallic", + "roughness", "alpha_mode", "double_sided", "edge_color", "sdk_metadata", + ), + ), + ( + ("geometry_assets", 0), + ( + "asset_id", "uri", "media_type", "byte_length", "content_hash", + "scene_local_bounds", "asset_to_scene", "tessellation", + ), + ), + ( + ("entity_assets", 0), + ("entity_asset_id", "uri", "media_type", "byte_length", "content_hash"), + ), + ] + result = [ + mutation + for path, fields in records + for mutation in _missing_fields(path, fields) + ] + result.extend( + _case(name, "add", path, True) + for name, path in ( + ("unknown_scene_field", ("unknown",)), + ("unknown_generator_field", ("generator", "unknown")), + ("unknown_compile_options_field", ("compile_options", "unknown")), + ("unknown_definition_field", ("definitions", 0, "unknown")), + ("unknown_node_field", ("nodes", 0, "unknown")), + ("unknown_appearance_field", ("appearances", 0, "unknown")), + ) + ) + result.extend( + ( + _case("optional_presentation_source_null", "add", ("presentation_source",), None), + _case("unknown_scene_source_kind", "set", ("source", "kind"), "unknown"), + _case( + "unknown_definition_source_kind", + "set", + ("definitions", 0, "source", "kind"), + "unknown", + ), + _case("missing_node_source_kind", "delete", ("nodes", 0, "source", "kind")), + _case("unsupported_scene_schema_version", "set", ("schema_version",), "2.0"), + _case( + "unsupported_generator_profile", + "set", + ("generator", "profile"), + "scene-2.0", + ), + _case( + "no_presentation_visibility_override", + "set", + ("nodes", 0, "visible"), + False, + recompute_revision=True, + ), + _case( + "no_presentation_selectability_override", + "set", + ("nodes", 0, "selectable"), + False, + recompute_revision=True, + ), + _case( + "no_presentation_appearance_override", + "set", + ("nodes", 0, "appearance_override_id"), + scene["appearances"][0]["appearance_id"], + recompute_revision=True, + ), + _case( + "no_presentation_camera", + "set", + ("cameras",), + [ + { + "camera_id": "camera/fixture/overview", + "far": 1000, + "name": "overview", + "near": 1, + "parent_node_id": None, + "projection": "perspective", + "transform": { + "origin": [10, -10, 10], + "x_axis": [1, 0, 0], + "y_axis": [0, 1, 0], + "z_axis": [0, 0, 1], + }, + "vertical_fov_degrees": 45, + } + ], + recompute_revision=True, + ), + ) + ) + return result + + +def apply_scene_field_case(scene: dict[str, Any], case: dict[str, Any]) -> dict[str, Any]: + """Replay one mutation descriptor to produce its validator input.""" + + result = deepcopy(scene) + for mutation in case["mutations"]: + path = mutation["path"] + current: Any = result + for part in path[:-1]: + current = current[part] + if mutation["operation"] == "delete": + del current[path[-1]] + else: + current[path[-1]] = deepcopy(mutation.get("value")) + if case.get("recompute_revision"): + result = with_scene_revision(result) + return result + + +def nullable_fields_nonnull_case(scene: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any]]: + """Return the multi-field valid nullable vector and its compact descriptor.""" + + value = deepcopy(scene) + value["nodes"][0]["name"] = "Named occurrence" + value["appearances"][0]["name"] = "Named appearance" + appearance = value["appearances"][0] + draft = dict(appearance) + draft.pop("appearance_id") + appearance_id = "appearance/evaluated/" + sha256_hex(canonical_json_bytes(draft)) + appearance["appearance_id"] = appearance_id + value["definitions"][0]["appearance_id"] = appearance_id + descriptor = { + "mutations": [ + {"operation": "set", "path": ["nodes", 0, "name"], "value": "Named occurrence"}, + {"operation": "set", "path": ["appearances", 0, "name"], "value": "Named appearance"}, + {"operation": "set", "path": ["appearances", 0, "appearance_id"], "value": appearance_id}, + {"operation": "set", "path": ["definitions", 0, "appearance_id"], "value": appearance_id}, + ], + "name": "nullable_fields_nonnull", + "recompute_revision": True, + } + return with_scene_revision(value), descriptor diff --git a/SimpleCADAPI/tools/scene_contract_fixtures/numeric_cases.py b/SimpleCADAPI/tools/scene_contract_fixtures/numeric_cases.py new file mode 100644 index 0000000..cc231ac --- /dev/null +++ b/SimpleCADAPI/tools/scene_contract_fixtures/numeric_cases.py @@ -0,0 +1,128 @@ +"""Numeric profile fixture vector construction.""" + +from __future__ import annotations + +import struct + +from simplecadapi.scene import ( + profile_cross, + profile_f32_bits, + profile_normalize, +) + + +def _f64_from_bits(value: str) -> float: + return struct.unpack(">d", bytes.fromhex(value))[0] + + +def _f64_bits(value: float) -> str: + return struct.pack(">d", value).hex() + + +def build_numeric_vectors() -> list[dict[str, object]]: + vectors: list[dict[str, object]] = [] + for name, input_bits in ( + ("positive_zero", "0000000000000000"), + ("negative_zero", "8000000000000000"), + ("smallest_binary64_subnormal", "0000000000000001"), + ("negative_smallest_binary64_subnormal", "8000000000000001"), + ("binary32_smallest_subnormal", "36a0000000000000"), + ("halfway_tie_to_even", "3ff0000010000000"), + ("just_above_halfway", "3ff0000010000001"), + ("binary32_largest_finite", "47efffffe0000000"), + ): + expected = profile_f32_bits(_f64_from_bits(input_bits)) + vectors.append( + { + "expected_bits": f"{expected:08x}", + "input_bits": [input_bits], + "name": name, + "operation": "f32", + "valid": True, + } + ) + for name, input_bits in ( + ("positive_overflow", "7fefffffffffffff"), + ("negative_overflow", "ffefffffffffffff"), + ("positive_infinity", "7ff0000000000000"), + ("quiet_nan", "7ff8000000000000"), + ): + try: + profile_f32_bits(_f64_from_bits(input_bits)) + except ValueError as exc: + error = str(exc) + else: + raise AssertionError(name) + vectors.append( + { + "error": error, + "input_bits": [input_bits], + "name": name, + "operation": "f32", + "valid": False, + } + ) + + cross_inputs = ( + ("basis_cross", (1.0, 0.0, 0.0), (0.0, 1.0, 0.0)), + ( + "cancellation_cross", + (134217729.0, 134217728.0, 0.0), + (134217728.0, 134217727.0, 0.0), + ), + ) + for name, left, right in cross_inputs: + result = profile_cross(left, right) + vectors.append( + { + "expected_bits": [_f64_bits(component) for component in result], + "input_bits": [ + [_f64_bits(component) for component in left], + [_f64_bits(component) for component in right], + ], + "name": name, + "operation": "cross", + "valid": True, + } + ) + + normalize_inputs = ( + ("normalize_3_4_0", (3.0, 4.0, -0.0)), + ("normalize_equal_components", (1.0, 1.0, 1.0)), + ) + for name, value in normalize_inputs: + result = profile_normalize(value) + vectors.append( + { + "expected_bits": [ + f"{profile_f32_bits(component):08x}" for component in result + ], + "input_bits": [[_f64_bits(component) for component in value]], + "name": name, + "operation": "normalize", + "valid": True, + } + ) + for name, value in ( + ("normalize_zero", (0.0, 0.0, 0.0)), + ( + "normalize_overflow", + (_f64_from_bits("7fefffffffffffff"), 1.0, 0.0), + ), + ): + try: + profile_normalize(value) + except ValueError as exc: + error = str(exc) + else: + raise AssertionError(name) + vectors.append( + { + "error": error, + "input_bits": [[_f64_bits(component) for component in value]], + "name": name, + "operation": "normalize", + "valid": False, + } + ) + return vectors diff --git a/SimpleCADAPI/tools/scene_contract_fixtures/package_cases.py b/SimpleCADAPI/tools/scene_contract_fixtures/package_cases.py new file mode 100644 index 0000000..8323408 --- /dev/null +++ b/SimpleCADAPI/tools/scene_contract_fixtures/package_cases.py @@ -0,0 +1,263 @@ +"""Scene package provenance and connector-binding fixture builders.""" + +from __future__ import annotations + +from copy import deepcopy +from typing import Callable + +from simplecadapi.scene import with_scene_revision + +from .common import ( + JsonObject, + ScenePackage, + inline_package_case, + replace_entity_sidecar, + set_product_material_appearance, +) + + +def build_source_scene_package( + base_scene: JsonObject, + base_entity: JsonObject, + base_blobs: dict[str, bytes], + variant: str, +) -> ScenePackage: + scene = deepcopy(base_scene) + entity = deepcopy(base_entity) + definition = scene["definitions"][0] + source_for: Callable[[JsonObject], JsonObject] + status_for: Callable[[JsonObject], str] + + if variant == "imported_shape": + definition_id = "definition/root/shape/imported/element" + scene["scene_id"] = "imported_fixture" + scene["source"] = { + "artifact_hash": "sha256:" + "1" * 64, + "format": "step", + "kind": "imported", + } + definition.update( + { + "definition_id": definition_id, + "kind": "shape", + "source": { + "kind": "imported", + "root_id": "root", + "source_element_id": "element", + }, + } + ) + source_for = lambda record: { + "kind": "imported_primitive", + "source_element_id": record["entity_id"], + } + status_for = lambda record: ( + "not_applicable" if record["kind"] == "solid" else "owner_not_part" + ) + else: + definition_kind = "part" if variant == "model_part" else "shape" + definition_id = ( + "definition/root/part/model_part" + if definition_kind == "part" + else "definition/root/shape/model/graph/body/0" + ) + scene["scene_id"] = f"{variant}_fixture" + scene["source"] = { + "artifact_hash": "sha256:" + "2" * 64, + "graph_id": "graph", + "kind": "model", + "model_schema_version": "2.0", + } + definition_source: JsonObject = { + "graph_id": "graph", + "kind": "product_model" if definition_kind == "part" else "model_output", + "node_id": "body", + "output_slot": 0, + "root_id": "root", + } + if definition_kind == "part": + definition_source.update( + {"semantic_id": "model_part", "semantic_type": "Part"} + ) + definition.update( + { + "definition_id": definition_id, + "kind": definition_kind, + "source": definition_source, + } + ) + topology_kind = { + "solid": "SOLID", + "face": "FACE", + "edge": "EDGE", + "vertex": "VERTEX", + } + if variant == "model_part": + source_for = lambda record: { + "graph_id": "graph", + "kind": "model_topology", + "node_id": "body", + "output_slot": 0, + "topo_id": record["entity_id"], + "topology_kind": topology_kind[record["kind"]], + } + statuses = iter( + ["supported", "selector_ambiguous", "selector_unstable", "supported"] + ) + + def status_for(record: JsonObject) -> str: + return ( + "not_applicable" + if record["kind"] == "solid" + else next(statuses, "supported") + ) + + else: + source_for = lambda _record: { + "graph_id": "graph", + "kind": "model_output", + "node_id": "body", + "output_slot": 0, + } + status_for = lambda record: ( + "not_applicable" + if record["kind"] == "solid" + else "owner_not_part" + ) + + if definition["kind"] == "part": + scene["nodes"][0]["source"] = { + "component_path": [], + "kind": "product_occurrence", + "root_id": "root", + } + scene["nodes"][0]["definition_id"] = definition_id + entity["definition_id"] = definition_id + for record in entity["entities"]: + record["source"] = source_for(record) + record["connector_binding_status"] = status_for(record) + if variant == "model_part": + edge = next(record for record in entity["entities"] if record["kind"] == "edge") + edge["sdk_connector_frame"] = None + edge["connector_binding_status"] = "frame_undefined" + scene, entity, blobs = replace_entity_sidecar( + scene, entity, dict(base_blobs) + ) + if definition["kind"] == "part": + set_product_material_appearance(scene) + return with_scene_revision(scene), entity, blobs + + +def build_package_matrix_cases( + scene: JsonObject, + entity: JsonObject, + blobs: dict[str, bytes], + scene_packages: dict[str, ScenePackage], + blob_pool: dict[str, str], +) -> list[dict[str, object]]: + cases: list[dict[str, object]] = [] + for variant in ("imported_shape", "model_shape", "model_part"): + package = build_source_scene_package(scene, entity, blobs, variant) + cases.append( + inline_package_case( + f"valid_{variant}", package[0], package[2], blob_pool + ) + ) + + nested = scene_packages["nested_assembly"] + model_part = build_source_scene_package(scene, entity, blobs, "model_part") + status_mutations = [ + ( + "solid_status_precedence", + (deepcopy(scene), deepcopy(entity), dict(blobs)), + "solid", + "supported", + ), + ( + "owner_not_part_status_precedence", + (deepcopy(scene), deepcopy(entity), dict(blobs)), + "face", + "source_not_model", + ), + ( + "source_not_model_status_precedence", + deepcopy(nested), + "face", + "supported", + ), + ( + "frame_undefined_status_precedence", + deepcopy(model_part), + "edge", + "owner_not_part", + ), + ] + for name, package, kind, status in status_mutations: + status_scene, status_entity, status_blobs = package + target = next( + record for record in status_entity["entities"] if record["kind"] == kind + ) + target["connector_binding_status"] = status + status_scene, _status_entity, status_blobs = replace_entity_sidecar( + status_scene, status_entity, status_blobs + ) + cases.append( + inline_package_case( + name, with_scene_revision(status_scene), status_blobs, blob_pool + ) + ) + + forwarded_scene, _forwarded_entity, forwarded_blobs = deepcopy(nested) + root_connector = next( + record + for record in forwarded_scene["connectors"] + if record["connector_id"] == "root_mount" + ) + root_connector["local_transform"]["origin"] = [0, 0, 6] + cases.append( + inline_package_case( + "forwarded_transform_mismatch", + with_scene_revision(forwarded_scene), + forwarded_blobs, + blob_pool, + ) + ) + + missing_child_scene, _missing_child_entity, missing_child_blobs = deepcopy(nested) + root_connector = next( + record + for record in missing_child_scene["connectors"] + if record["connector_id"] == "root_mount" + ) + root_connector["forwarded_from"]["source_component_id"] = "missing" + cases.append( + inline_package_case( + "forwarded_direct_child_missing", + with_scene_revision(missing_child_scene), + missing_child_blobs, + blob_pool, + ) + ) + + part_owner_scene, _part_owner_entity, part_owner_blobs = deepcopy(nested) + root_connector = next( + record + for record in part_owner_scene["connectors"] + if record["connector_id"] == "root_mount" + ) + root_connector["owner_definition_id"] = "definition/root/part/shared_part" + root_connector["connector_snapshot_id"] = ( + "connector/root/part/shared_part/root_mount" + ) + part_owner_scene["connectors"].sort( + key=lambda record: record["connector_snapshot_id"] + ) + cases.append( + inline_package_case( + "forwarded_owner_not_assembly", + with_scene_revision(part_owner_scene), + part_owner_blobs, + blob_pool, + ) + ) + return cases diff --git a/SimpleCADAPI/tools/scene_contract_fixtures/resource_cases.py b/SimpleCADAPI/tools/scene_contract_fixtures/resource_cases.py new file mode 100644 index 0000000..98313ca --- /dev/null +++ b/SimpleCADAPI/tools/scene_contract_fixtures/resource_cases.py @@ -0,0 +1,710 @@ +"""Resource-limit fixture case construction.""" + +from __future__ import annotations + +from copy import deepcopy +from dataclasses import replace +from typing import Any, Callable + +from simplecadapi.scene import ( + BASE_LIMITS, + SceneResourceLimits, + canonical_archive_size, + json_resource_issues, + preflight_aggregate_compression_ratio, + preflight_archive_member_sizes, + preflight_glb_counts, + preflight_input_archive_size, + preflight_member_compression_ratio, + resource_count_issues, + validate_scene_manifest, + with_scene_revision, +) +from simplecadapi.scene.validation import ( + _compute_package_budget_totals, + _package_budget_issues, + _report, +) + +from .common import JsonObject, first_issue + + +def _limits(overrides: dict[str, int]) -> SceneResourceLimits: + return replace(BASE_LIMITS, **overrides) + + +def _preflight_case( + name: str, + operation: str, + parameters: dict[str, object], + callback: Callable[[], object], +) -> dict[str, object]: + try: + callback() + except ValueError as exc: + valid, error = False, str(exc) + else: + valid, error = True, None + return { + "error": error, + "name": name, + "operation": operation, + "parameters": parameters, + "valid": valid, + } + + +def _issues_case( + name: str, + operation: str, + parameters: dict[str, object], + issues: tuple[Any, ...] | list[Any], +) -> dict[str, object]: + first = issues[0] if issues else None + return { + "expected": ( + None if first is None else {"code": first.code, "path": first.path} + ), + "name": name, + "operation": operation, + "parameters": parameters, + "valid": not issues, + } + + +def _validation_case( + name: str, + operation: str, + parameters: dict[str, object], + report: Any, +) -> dict[str, object]: + return { + "expected": first_issue(report), + "name": name, + "operation": operation, + "parameters": parameters, + "valid": report.valid, + } + + +def _input_size_case(name: str, size: int, overrides: dict[str, int]) -> dict[str, object]: + parameters: dict[str, object] = {"limits": overrides, "size": size} + return _preflight_case( + name, + "input_archive_size", + parameters, + lambda: preflight_input_archive_size(size, limits=_limits(overrides)), + ) + + +def _archive_sizes_case( + name: str, sizes: dict[str, int], overrides: dict[str, int] +) -> dict[str, object]: + parameters: dict[str, object] = {"limits": overrides, "sizes": sizes} + return _preflight_case( + name, + "archive_member_sizes", + parameters, + lambda: preflight_archive_member_sizes(sizes, limits=_limits(overrides)), + ) + + +def _archive_count_case( + name: str, count: int, overrides: dict[str, int] +) -> dict[str, object]: + sizes = {"scene.json": 0} + sizes.update({f"x/{index}": 0 for index in range(count - 1)}) + parameters: dict[str, object] = {"count": count, "limits": overrides} + return _preflight_case( + name, + "archive_member_count", + parameters, + lambda: preflight_archive_member_sizes(sizes, limits=_limits(overrides)), + ) + + +def _compression_case( + name: str, + operation: str, + uncompressed_size: int, + compressed_size: int, +) -> dict[str, object]: + overrides = {"compression_ratio": 2} + parameters = { + "compressed_size": compressed_size, + "limits": overrides, + "uncompressed_size": uncompressed_size, + } + if operation == "aggregate_compression_ratio": + callback = lambda: preflight_aggregate_compression_ratio( + uncompressed_size, compressed_size, limits=_limits(overrides) + ) + else: + callback = lambda: preflight_member_compression_ratio( + uncompressed_size, compressed_size, limits=_limits(overrides) + ) + return _preflight_case(name, operation, parameters, callback) + + +def _json_value(kind: str, text: str, field: str | None = None) -> object: + if kind == "value": + return {"value": text} + if kind == "object_key": + return {text: 0} + if kind == "metadata_key": + return {"metadata": {text: 0}} + if kind == "sdk_metadata_key": + return {"sdk_metadata": {text: 0}} + if kind == "identifier": + return {field or "node_id": text} + if kind == "identifier_array": + return {field or "component_path": [text]} + if kind == "uri": + return {"uri": text} + raise AssertionError(kind) + + +def _json_domain_case( + name: str, + kind: str, + text: str, + overrides: dict[str, int], + *, + field: str | None = None, +) -> dict[str, object]: + parameters: dict[str, object] = { + "field": field, + "kind": kind, + "limits": overrides, + "text": text, + } + issues = json_resource_issues( + _json_value(kind, text, field), limits=_limits(overrides) + ) + return _issues_case(name, "json_domain", parameters, issues) + + +def _nested_value(depth: int) -> object: + value: dict[str, object] = {} + current = value + for _index in range(depth): + current["x"] = {} + current = current["x"] # type: ignore[assignment] + return value + + +def _json_depth_case(name: str, depth: int) -> dict[str, object]: + overrides = {"json_depth": 2} + parameters: dict[str, object] = {"depth": depth, "limits": overrides} + issues = json_resource_issues(_nested_value(depth), limits=_limits(overrides)) + return _issues_case(name, "json_depth", parameters, issues) + + +def _resource_count_value( + artifact: str, kind: str, count: int, field: str | None +) -> object: + if kind == "collection": + assert field is not None + return {field: [None] * count} + if kind == "hierarchy": + return {"nodes": [{"source": {"component_path": ["x"] * count}}]} + if kind == "forwarded": + return { + "connectors": [ + { + "anchor_kind": "forwarded", + "connector_snapshot_id": f"c{index}", + "forwarded_from": { + "source_connector_snapshot_id": f"c{index + 1}" + }, + } + for index in range(count) + ] + } + raise AssertionError((artifact, kind)) + + +def _resource_count_case( + name: str, + artifact: str, + kind: str, + count: int, + overrides: dict[str, int], + *, + field: str | None = None, +) -> dict[str, object]: + parameters: dict[str, object] = { + "artifact": artifact, + "count": count, + "field": field, + "kind": kind, + "limits": overrides, + } + issues = resource_count_issues( + _resource_count_value(artifact, kind, count, field), + artifact, + limits=_limits(overrides), + ) + return _issues_case(name, "resource_count", parameters, issues) + + +def _glb_count_case( + name: str, + kind: str, + vertex_count: int, + index_count: int, + overrides: dict[str, int], +) -> dict[str, object]: + parameters: dict[str, object] = { + "index_count": index_count, + "kind": kind, + "limits": overrides, + "vertex_count": vertex_count, + } + return _preflight_case( + name, + "glb_counts", + parameters, + lambda: preflight_glb_counts( + kind, # type: ignore[arg-type] + vertex_count, + index_count, + limits=_limits(overrides), + ), + ) + + +_BUDGET_FIELDS = { + "entities_total": "entity_count", + "line_segments_total": "line_segment_count", + "line_vertices_total": "line_vertex_count", + "static_decoded_buffer_bytes": "scene_json_bytes", + "triangle_vertices_total": "triangle_vertex_count", + "triangles_total": "triangle_count", +} + + +def _package_budget_case( + name: str, limit_name: str, value: int +) -> dict[str, object]: + contributions = { + "scene_json_bytes": 0, + "glb_decoded_buffer_bytes": 0, + "entity_json_bytes": 0, + "other_immutable_json_bytes": 0, + "entity_count": 0, + "entity_vertex_count": 0, + "triangle_vertex_count": 0, + "triangle_count": 0, + "line_vertex_count": 0, + "line_segment_count": 0, + } + contributions[_BUDGET_FIELDS[limit_name]] = value + overrides = {limit_name: 2} + totals = _compute_package_budget_totals(**contributions) + budget_report = _report( + _package_budget_issues(totals, limits=_limits(overrides)), artifact="package" + ) + return _validation_case( + name, + "package_budget", + {"contributions": contributions, "limits": overrides}, + budget_report, + ) + + +def build_resource_cases( + scene: JsonObject, + normalized_part: JsonObject, +) -> list[dict[str, object]]: + input_limits = {"input_archive_bytes": 8} + role_limits = { + "canonical_archive_bytes": 1_000, + "one_member_bytes": 100, + "total_uncompressed_bytes": 100, + } + canonical_limits = { + "canonical_archive_bytes": 256, + "one_member_bytes": 100, + "total_uncompressed_bytes": 100, + } + canonical_envelope = canonical_archive_size( + {"geometry/a.glb": 0, "scene.json": 0} + ) + canonical_payload = canonical_limits["canonical_archive_bytes"] - canonical_envelope + one_member_limits = { + "canonical_archive_bytes": 1_000, + "one_member_bytes": 8, + "total_uncompressed_bytes": 100, + } + total_limits = { + "canonical_archive_bytes": 1_000, + "one_member_bytes": 8, + "total_uncompressed_bytes": 16, + } + count_limits = {"canonical_archive_bytes": 10_000, "zip_members": 3} + cases = [ + _input_size_case("input_archive_exact_limit", 8, input_limits), + _input_size_case("input_archive_over_limit", 9, input_limits), + _archive_sizes_case( + "scene_json_exact_limit", + {"scene.json": 8}, + {**role_limits, "scene_json_bytes": 8}, + ), + _archive_sizes_case( + "scene_json_over_limit", + {"scene.json": 9}, + {**role_limits, "scene_json_bytes": 8}, + ), + _archive_sizes_case( + "entity_json_exact_limit", + {"entities/a.json": 8, "scene.json": 0}, + {**role_limits, "entity_json_bytes": 8}, + ), + _archive_sizes_case( + "entity_json_over_limit", + {"entities/a.json": 9, "scene.json": 0}, + {**role_limits, "entity_json_bytes": 8}, + ), + _archive_sizes_case( + "model_json_exact_limit", + {"model/model.json": 8, "scene.json": 0}, + {**role_limits, "model_json_bytes": 8}, + ), + _archive_sizes_case( + "model_json_over_limit", + {"model/model.json": 9, "scene.json": 0}, + {**role_limits, "model_json_bytes": 8}, + ), + _archive_sizes_case( + "presentation_json_exact_limit", + {"presentation/presentation.json": 8, "scene.json": 0}, + {**role_limits, "presentation_json_bytes": 8}, + ), + _archive_sizes_case( + "presentation_json_over_limit", + {"presentation/presentation.json": 9, "scene.json": 0}, + {**role_limits, "presentation_json_bytes": 8}, + ), + _archive_sizes_case( + "canonical_archive_exact_limit", + {"geometry/a.glb": canonical_payload, "scene.json": 0}, + canonical_limits, + ), + _archive_sizes_case( + "canonical_archive_over_limit", + {"geometry/a.glb": canonical_payload + 1, "scene.json": 0}, + canonical_limits, + ), + _archive_sizes_case( + "one_member_exact_limit", + {"geometry/a.glb": 8, "scene.json": 0}, + one_member_limits, + ), + _archive_sizes_case( + "one_member_over_limit", + {"geometry/a.glb": 9, "scene.json": 0}, + one_member_limits, + ), + _archive_sizes_case( + "total_uncompressed_exact_limit", + {"geometry/a.glb": 8, "geometry/b.glb": 8, "scene.json": 0}, + total_limits, + ), + _archive_sizes_case( + "total_uncompressed_over_limit", + { + "geometry/a.glb": 8, + "geometry/b.glb": 8, + "geometry/c.glb": 1, + "scene.json": 0, + }, + total_limits, + ), + _archive_count_case("archive_member_count_exact_limit", 3, count_limits), + _archive_count_case("archive_member_count_over_limit", 4, count_limits), + _compression_case( + "aggregate_compression_ratio_exact_limit", + "aggregate_compression_ratio", + 4, + 2, + ), + _compression_case( + "aggregate_compression_ratio_over_limit", + "aggregate_compression_ratio", + 5, + 2, + ), + _compression_case( + "member_compression_ratio_exact_limit", + "member_compression_ratio", + 4, + 2, + ), + _compression_case( + "member_compression_ratio_over_limit", + "member_compression_ratio", + 5, + 2, + ), + _json_depth_case("json_depth_exact_limit", 2), + _json_depth_case("json_depth_over_limit", 3), + ] + + for kind, overrides in ( + ("value", {"json_string_bytes": 4}), + ("object_key", {"json_string_bytes": 4}), + ( + "metadata_key", + {"json_string_bytes": 100, "structural_id_bytes": 4}, + ), + ( + "sdk_metadata_key", + {"json_string_bytes": 100, "structural_id_bytes": 4}, + ), + ("uri", {"json_string_bytes": 100, "uri_bytes": 4}), + ): + cases.append( + _json_domain_case( + f"utf8_{kind}_exact_limit", kind, "éé", overrides + ) + ) + cases.append( + _json_domain_case( + f"utf8_{kind}_over_limit", kind, "ééa", overrides + ) + ) + + identifier_fields = ( + "definition_ref", + "node_id", + "source_element_id", + "topo_id", + ) + for field in identifier_fields: + overrides = {"json_string_bytes": 100, "structural_id_bytes": 4} + cases.append( + _json_domain_case( + f"utf8_{field}_exact_limit", + "identifier", + "éé", + overrides, + field=field, + ) + ) + cases.append( + _json_domain_case( + f"utf8_{field}_over_limit", + "identifier", + "ééa", + overrides, + field=field, + ) + ) + + for field in ( + "child_entity_ids", + "component_path", + "evaluated_tags", + "grounded_component_ids", + "parent_entity_ids", + "semantic_binding_ids", + ): + overrides = {"json_string_bytes": 100, "structural_id_bytes": 4} + cases.append( + _json_domain_case( + f"utf8_{field}_item_exact_limit", + "identifier_array", + "éé", + overrides, + field=field, + ) + ) + cases.append( + _json_domain_case( + f"utf8_{field}_item_over_limit", + "identifier_array", + "ééa", + overrides, + field=field, + ) + ) + + collection_limits = ( + ("scene", "definitions", "definitions"), + ("scene", "nodes", "nodes"), + ("scene", "geometry_assets", "assets_per_kind"), + ("scene", "edge_assets", "assets_per_kind"), + ("scene", "entity_assets", "assets_per_kind"), + ("scene", "appearances", "appearances"), + ("scene", "connectors", "connectors"), + ("scene", "cameras", "cameras"), + ("entities", "entities", "entities_per_sidecar"), + ("entities", "face_groups", "entities_per_sidecar"), + ("entities", "edge_groups", "entities_per_sidecar"), + ("presentation", "node_overrides", "nodes"), + ("presentation", "appearances", "appearances"), + ("presentation", "cameras", "cameras"), + ) + for artifact, field, limit_name in collection_limits: + overrides = {limit_name: 2} + cases.append( + _resource_count_case( + f"{artifact}_{field}_exact_limit", + artifact, + "collection", + 2, + overrides, + field=field, + ) + ) + cases.append( + _resource_count_case( + f"{artifact}_{field}_over_limit", + artifact, + "collection", + 3, + overrides, + field=field, + ) + ) + + cases.extend( + [ + _resource_count_case( + "hierarchy_depth_exact_limit", + "scene", + "hierarchy", + 2, + {"hierarchy_depth": 2, "nodes": 10}, + ), + _resource_count_case( + "hierarchy_depth_over_limit", + "scene", + "hierarchy", + 3, + {"hierarchy_depth": 2, "nodes": 10}, + ), + _resource_count_case( + "forwarded_connector_depth_exact_limit", + "scene", + "forwarded", + 2, + {"connectors": 10, "forwarded_connector_depth": 2}, + ), + _resource_count_case( + "forwarded_connector_depth_over_limit", + "scene", + "forwarded", + 3, + {"connectors": 10, "forwarded_connector_depth": 2}, + ), + _glb_count_case( + "triangle_vertices_per_asset_exact_limit", + "triangle", + 2, + 3, + {"triangle_vertices_per_asset": 2}, + ), + _glb_count_case( + "triangle_vertices_per_asset_over_limit", + "triangle", + 3, + 3, + {"triangle_vertices_per_asset": 2}, + ), + _glb_count_case( + "triangles_per_asset_exact_limit", + "triangle", + 3, + 6, + {"triangles_per_asset": 2}, + ), + _glb_count_case( + "triangles_per_asset_over_limit", + "triangle", + 3, + 9, + {"triangles_per_asset": 2}, + ), + _glb_count_case( + "line_vertices_per_asset_exact_limit", + "line", + 2, + 2, + {"line_vertices_per_asset": 2}, + ), + _glb_count_case( + "line_vertices_per_asset_over_limit", + "line", + 3, + 2, + {"line_vertices_per_asset": 2}, + ), + _glb_count_case( + "line_segments_per_asset_exact_limit", + "line", + 2, + 4, + {"line_segments_per_asset": 2}, + ), + _glb_count_case( + "line_segments_per_asset_over_limit", + "line", + 2, + 6, + {"line_segments_per_asset": 2}, + ), + ] + ) + + for limit_name in _BUDGET_FIELDS: + cases.append( + _package_budget_case(f"{limit_name}_exact_limit", limit_name, 2) + ) + cases.append( + _package_budget_case(f"{limit_name}_over_limit", limit_name, 3) + ) + + for name, value_number in ( + ("safe_integer_exact_limit", 9_007_199_254_740_991), + ("safe_integer_over_limit", 9_007_199_254_740_992), + ): + value = deepcopy(scene) + value["geometry_assets"][0]["byte_length"] = value_number + if value_number <= 9_007_199_254_740_991: + value = with_scene_revision(value) + cases.append( + _validation_case( + name, + "scene_geometry_byte_length", + {"value": str(value_number)}, + validate_scene_manifest(value), + ) + ) + + for name, field, value_number in ( + ("linear_tolerance_exact_max", "linear_tolerance", 1_000_000), + ("linear_tolerance_over_max", "linear_tolerance", 1_000_001), + ("angular_tolerance_exact_max", "angular_tolerance", 3.141592653589793), + ("angular_tolerance_over_max", "angular_tolerance", 3.1415926535897936), + ): + value = deepcopy(scene) + value["compile_options"][field] = value_number + value["geometry_assets"][0]["tessellation"][field] = value_number + if field == "linear_tolerance": + value["edge_assets"][0]["tessellation"][field] = value_number + value = with_scene_revision(value) + cases.append( + _validation_case( + name, + "scene_compile_option", + {"field": field, "value": value_number}, + validate_scene_manifest(value), + ) + ) + + # Keep the argument in active use so fixture construction catches drift in + # the valid normalized product even though resource probes are synthetic. + assert normalized_part + return cases diff --git a/SimpleCADAPI/tools/scene_contract_fixtures/scene_cases.py b/SimpleCADAPI/tools/scene_contract_fixtures/scene_cases.py new file mode 100644 index 0000000..2cee6d6 --- /dev/null +++ b/SimpleCADAPI/tools/scene_contract_fixtures/scene_cases.py @@ -0,0 +1,218 @@ +"""Minimal, nested, and repeated-instance Scene fixture builders.""" + +from __future__ import annotations + +from copy import deepcopy + +from simplecadapi.scene import with_scene_revision + +from .common import ( + IDENTITY, + JsonObject, + ScenePackage, + replace_entity_sidecar, + set_product_material_appearance, + transform, +) + + +def _product_definition( + definition_id: str, + kind: str, + semantic_id: str, + renderable: JsonObject | None = None, +) -> JsonObject: + definition: JsonObject = { + "definition_id": definition_id, + "kind": kind, + "name": semantic_id.replace("_", " ").title(), + "sdk_metadata": {}, + "source": { + "kind": "product_manual", + "root_id": "root", + "semantic_id": semantic_id, + "semantic_type": "Part" if kind == "part" else "Assembly", + }, + } + if renderable is not None: + definition.update( + { + field: renderable[field] + for field in ( + "appearance_id", + "edge_asset_id", + "entity_asset_id", + "geometry_asset_id", + ) + } + ) + return definition + + +def _product_node( + component_path: list[str], + definition_id: str, + order: int, + local_transform: JsonObject | None = None, +) -> JsonObject: + node_id = "instance/root" + "".join(f"/{part}" for part in component_path) + parent_id = ( + None + if not component_path + else "instance/root" + "".join(f"/{part}" for part in component_path[:-1]) + ) + return { + "appearance_override_id": None, + "definition_id": definition_id, + "name": component_path[-1] if component_path else "root", + "node_id": node_id, + "order": order, + "parent_node_id": parent_id, + "sdk_metadata": {}, + "selectable": True, + "source": { + "component_path": component_path, + "kind": "product_occurrence", + "root_id": "root", + }, + "transform": deepcopy(local_transform or IDENTITY), + "visible": True, + } + + +def _manual_connector( + owner_definition_id: str, + owner_kind: str, + owner_semantic_id: str, + connector_id: str, + anchor_kind: str, + local_transform: JsonObject, + *, + forwarded_from: JsonObject | None = None, +) -> JsonObject: + connector: JsonObject = { + "anchor_kind": anchor_kind, + "connector_id": connector_id, + "connector_snapshot_id": ( + f"connector/root/{owner_kind}/{owner_semantic_id}/{connector_id}" + ), + "local_transform": local_transform, + "name": connector_id.replace("_", " ").title(), + "owner_definition_id": owner_definition_id, + "sdk_metadata": {}, + "source": {"kind": "manual", "source_id": "fixture"}, + } + if forwarded_from is not None: + connector["forwarded_from"] = forwarded_from + return connector + + +def build_product_scene_package( + base_scene: JsonObject, + base_entity: JsonObject, + base_blobs: dict[str, bytes], + *, + nested: bool, +) -> ScenePackage: + scene = deepcopy(base_scene) + entity = deepcopy(base_entity) + root_definition_id = "definition/root/assembly/root_assembly" + part_definition_id = "definition/root/part/shared_part" + renderable = deepcopy(scene["definitions"][0]) + definitions = [ + _product_definition(root_definition_id, "assembly", "root_assembly"), + _product_definition(part_definition_id, "part", "shared_part", renderable), + ] + nodes = [_product_node([], root_definition_id, 0)] + connectors: list[JsonObject] = [] + + if nested: + nested_definition_id = "definition/root/assembly/nested_assembly" + definitions.append( + _product_definition( + nested_definition_id, "assembly", "nested_assembly" + ) + ) + nodes.extend( + [ + _product_node(["subassembly"], nested_definition_id, 0), + _product_node(["subassembly", "part"], part_definition_id, 0), + ] + ) + part_connector = _manual_connector( + part_definition_id, + "part", + "shared_part", + "mount", + "placement", + transform(), + ) + nested_connector = _manual_connector( + nested_definition_id, + "assembly", + "nested_assembly", + "nested_mount", + "forwarded", + transform(), + forwarded_from={ + "offset": None, + "source_component_id": "part", + "source_connector_id": "mount", + "source_connector_snapshot_id": part_connector[ + "connector_snapshot_id" + ], + "source_definition_id": part_definition_id, + }, + ) + root_offset = transform([0, 0, 5]) + root_connector = _manual_connector( + root_definition_id, + "assembly", + "root_assembly", + "root_mount", + "forwarded", + deepcopy(root_offset), + forwarded_from={ + "offset": root_offset, + "source_component_id": "subassembly", + "source_connector_id": "nested_mount", + "source_connector_snapshot_id": nested_connector[ + "connector_snapshot_id" + ], + "source_definition_id": nested_definition_id, + }, + ) + connectors = [part_connector, nested_connector, root_connector] + scene["scene_id"] = "nested_fixture" + else: + nodes.extend( + [ + _product_node( + ["left"], part_definition_id, 0, transform([-1000, 0, 0]) + ), + _product_node( + ["right"], part_definition_id, 1, transform([1000, 0, 0]) + ), + ] + ) + scene["scene_id"] = "repeated_fixture" + + scene["definitions"] = sorted( + definitions, key=lambda value: value["definition_id"].encode("utf-8") + ) + scene["nodes"] = sorted( + nodes, key=lambda value: value["node_id"].encode("utf-8") + ) + scene["connectors"] = sorted( + connectors, + key=lambda value: value["connector_snapshot_id"].encode("utf-8"), + ) + entity["definition_id"] = part_definition_id + for record in entity["entities"]: + if record["kind"] != "solid": + record["connector_binding_status"] = "source_not_model" + scene, entity, blobs = replace_entity_sidecar( + scene, entity, dict(base_blobs) + ) + set_product_material_appearance(scene) + return with_scene_revision(scene), entity, blobs diff --git a/SimpleCADAPI/tools/scene_contract_fixtures/scene_corpus.py b/SimpleCADAPI/tools/scene_contract_fixtures/scene_corpus.py new file mode 100644 index 0000000..ce84979 --- /dev/null +++ b/SimpleCADAPI/tools/scene_contract_fixtures/scene_corpus.py @@ -0,0 +1,140 @@ +"""Scene shape, field-matrix, and two-pass revision corpus sections.""" + +from __future__ import annotations + +from copy import deepcopy + +from simplecadapi.scene import canonical_json_bytes, validate_scene_manifest + +from .common import ( + JsonObject, + ScenePackage, + b64, + content_hash, + first_issue, + scene_shape_case, +) +from .field_matrix import ( + apply_scene_field_case, + nullable_fields_nonnull_case, + scene_field_matrix, +) +from .scene_cases import build_product_scene_package + + +def build_scene_shape_cases( + scene: JsonObject, + entity: JsonObject, + blobs: dict[str, bytes], + blob_pool: dict[str, str], +) -> tuple[list[dict[str, object]], dict[str, ScenePackage]]: + nested = build_product_scene_package(scene, entity, blobs, nested=True) + repeated = build_product_scene_package(scene, entity, blobs, nested=False) + packages = { + "minimal_standalone_shape": (deepcopy(scene), deepcopy(entity), dict(blobs)), + "nested_assembly": nested, + "repeated_part_instance": repeated, + } + cases = [ + scene_shape_case( + "minimal_standalone_shape", + scene, + blobs, + { + "definition_count": 1, + "definition_occurrence_counts": { + "definition/root/shape/manual/fixture": 1 + }, + "edge_asset_count": 1, + "entity_asset_count": 1, + "geometry_asset_count": 1, + "maximum_depth": 0, + "node_count": 1, + "root_node_ids": ["instance/root"], + }, + blob_pool, + ), + scene_shape_case( + "nested_assembly", + nested[0], + nested[2], + { + "definition_count": 3, + "definition_occurrence_counts": { + "definition/root/assembly/nested_assembly": 1, + "definition/root/assembly/root_assembly": 1, + "definition/root/part/shared_part": 1, + }, + "edge_asset_count": 1, + "entity_asset_count": 1, + "geometry_asset_count": 1, + "maximum_depth": 2, + "node_count": 3, + "root_node_ids": ["instance/root"], + }, + blob_pool, + ), + scene_shape_case( + "repeated_part_instance", + repeated[0], + repeated[2], + { + "definition_count": 2, + "definition_occurrence_counts": { + "definition/root/assembly/root_assembly": 1, + "definition/root/part/shared_part": 2, + }, + "edge_asset_count": 1, + "entity_asset_count": 1, + "geometry_asset_count": 1, + "maximum_depth": 1, + "node_count": 3, + "root_node_ids": ["instance/root"], + }, + blob_pool, + ), + ] + return cases, packages + + +def build_scene_field_cases(scene: JsonObject) -> list[dict[str, object]]: + descriptors = scene_field_matrix(scene) + nullable_value, nullable_descriptor = nullable_fields_nonnull_case(scene) + descriptors.append(nullable_descriptor) + result = [] + for descriptor in descriptors: + value = ( + nullable_value + if descriptor["name"] == "nullable_fields_nonnull" + else apply_scene_field_case(scene, descriptor) + ) + report = validate_scene_manifest(canonical_json_bytes(value)) + result.append( + { + **descriptor, + "expected": first_issue(report), + "valid": report.valid, + } + ) + return result + + +def build_revision_vectors( + scene_packages: dict[str, ScenePackage], +) -> list[dict[str, object]]: + vectors = [] + for name, (scene, _entity, _blobs) in scene_packages.items(): + draft = deepcopy(scene) + revision = draft.pop("revision") + draft_bytes = canonical_json_bytes(draft) + scene_bytes = canonical_json_bytes(scene) + vectors.append( + { + "canonical_base64": b64(scene_bytes), + "draft_base64": b64(draft_bytes), + "name": name, + "revision": revision, + "sha256": content_hash(scene_bytes), + } + ) + return vectors diff --git a/SimpleCADAPI/tools/scene_contract_fixtures/schema_field_matrix.py b/SimpleCADAPI/tools/scene_contract_fixtures/schema_field_matrix.py new file mode 100644 index 0000000..7a1ca64 --- /dev/null +++ b/SimpleCADAPI/tools/scene_contract_fixtures/schema_field_matrix.py @@ -0,0 +1,330 @@ +"""Schema-grounded structural field matrices for all contract artifacts.""" + +from __future__ import annotations + +import json +import re +from copy import deepcopy +from pathlib import Path +from typing import Any, Mapping + +from jsonschema import Draft202012Validator + + +Json = Any + + +SCHEMAS = { + "scene": "scene-1.0.schema.json", + "entities": "entities-1.0.schema.json", + "presentation": "presentation-1.0.schema.json", + "connector_binding": "connector-binding-1.0.schema.json", + "normalized_product": "normalized-product-1.schema.json", +} + + +def _resolve(root: Mapping[str, Any], schema: Mapping[str, Any]) -> Mapping[str, Any]: + reference = schema.get("$ref") + if not isinstance(reference, str) or not reference.startswith("#/"): + return schema + current: Any = root + for part in reference[2:].split("/"): + current = current[part.replace("~1", "/").replace("~0", "~")] + return current + + +def _wrapper(root: Mapping[str, Any], pointer: str) -> dict[str, Any]: + if pointer == "#": + return deepcopy(dict(root)) + return { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$ref": pointer, + "$defs": deepcopy(root.get("$defs", {})), + } + + +def _string_example(schema: Mapping[str, Any]) -> str: + pattern = str(schema.get("pattern", "")) + if pattern == "^sha256:[0-9a-f]{64}$": + return "sha256:" + "0" * 64 + if pattern.startswith("^appearance/evaluated/"): + return "appearance/evaluated/" + "0" * 64 + if pattern.startswith("^entity/"): + return "entity/face/0" + for candidate in ("a", "x", "root", "entity/face/0"): + if not pattern or re.fullmatch(pattern, candidate): + return candidate + raise AssertionError(f"no structural string example for pattern {pattern!r}") + + +def _accepts_null(root: Mapping[str, Any], schema: Mapping[str, Any]) -> bool: + wrapper = deepcopy(dict(schema)) + wrapper["$defs"] = deepcopy(root.get("$defs", {})) + return Draft202012Validator(wrapper).is_valid(None) + + +def _nonnull_example(root: Mapping[str, Any], schema: Mapping[str, Any]) -> Json: + resolved = _resolve(root, schema) + if "oneOf" in resolved: + for branch in resolved["oneOf"]: + if not _accepts_null(root, branch): + return _example(root, branch, prefer_nonnull=True) + types = resolved.get("type") + if isinstance(types, list): + draft = dict(resolved) + draft["type"] = next(item for item in types if item != "null") + return _example(root, draft, prefer_nonnull=True) + return _example(root, resolved, prefer_nonnull=True) + + +def _condition_matches( + root: Mapping[str, Any], condition: Mapping[str, Any], value: Json +) -> bool: + schema = deepcopy(dict(condition)) + schema["$defs"] = deepcopy(root.get("$defs", {})) + return Draft202012Validator(schema).is_valid(value) + + +def _example( + root: Mapping[str, Any], + schema: Mapping[str, Any], + *, + overrides: Mapping[str, Json] | None = None, + prefer_nonnull: bool = False, +) -> Json: + resolved = _resolve(root, schema) + if "const" in resolved: + return deepcopy(resolved["const"]) + if "enum" in resolved: + return deepcopy(resolved["enum"][0]) + for union_key in ("oneOf", "anyOf"): + if union_key in resolved and not ( + resolved.get("type") == "object" or "properties" in resolved + ): + branches = resolved[union_key] + if prefer_nonnull: + branches = sorted( + branches, + key=lambda branch: _accepts_null(root, branch), + ) + return _example(root, branches[0], prefer_nonnull=prefer_nonnull) + types = resolved.get("type") + if isinstance(types, list): + if "null" in types and not prefer_nonnull: + return None + types = next(item for item in types if item != "null") + if types == "object" or "properties" in resolved: + properties = resolved.get("properties", {}) + result = { + field: _example(root, properties.get(field, {})) + for field in resolved.get("required", []) + } + if overrides: + result.update(deepcopy(dict(overrides))) + for branch in resolved.get("anyOf", []): + branch_required = branch.get("required", []) + if branch_required: + for field in branch_required: + result.setdefault(field, _example(root, properties.get(field, {}))) + break + for condition in resolved.get("allOf", []): + then = condition.get("then") + if isinstance(then, dict) and _condition_matches( + root, condition.get("if", {}), result + ): + for field in then.get("required", []): + result.setdefault(field, _example(root, properties.get(field, {}))) + return result + if types == "array" or "prefixItems" in resolved: + result = [_example(root, item) for item in resolved.get("prefixItems", [])] + minimum = resolved.get("minItems", 0) + while len(result) < minimum: + result.append(_example(root, resolved.get("items", {}))) + return result + if types == "string": + return _string_example(resolved) + if types == "integer": + return int(resolved.get("minimum", 0)) + if types == "number": + if "exclusiveMinimum" in resolved: + return resolved["exclusiveMinimum"] + 1 + return resolved.get("minimum", 0) + if types == "boolean": + return False + if types == "null": + return None + return {} + + +def _closed_records(schema: Mapping[str, Any]) -> list[tuple[str, Mapping[str, Any]]]: + result: dict[str, Mapping[str, Any]] = {} + if schema.get("type") == "object" and schema.get("additionalProperties") is False: + result["#"] = schema + + def visit(value: Any, pointer: str) -> None: + if not isinstance(value, dict): + return + if value.get("type") == "object" and value.get("additionalProperties") is False: + result[pointer] = value + for key in ("properties", "items", "oneOf", "anyOf", "allOf"): + child = value.get(key) + if isinstance(child, dict): + for name, nested in child.items(): + visit(nested, f"{pointer}/{key}/{name}") + elif isinstance(child, list): + for index, nested in enumerate(child): + visit(nested, f"{pointer}/{key}/{index}") + + for name, definition in schema.get("$defs", {}).items(): + visit(definition, f"#/$defs/{name}") + return list(result.items()) + + +def _mutate(base: Json, operation: str, field: str, value: Json = None) -> Json: + result = deepcopy(base) + if operation == "delete": + del result[field] + else: + result[field] = deepcopy(value) + return result + + +def _record_matrix( + artifact: str, + root: Mapping[str, Any], + pointer: str, + schema: Mapping[str, Any], + *, + overrides: Mapping[str, Json] | None = None, + variant: str = "base", +) -> dict[str, Any]: + validator = Draft202012Validator(_wrapper(root, pointer)) + base = _example(root, schema, overrides=overrides) + if not validator.is_valid(base): + errors = [error.message for error in validator.iter_errors(base)] + raise AssertionError( + f"invalid generated {artifact} {pointer} {variant}: {errors}" + ) + properties = schema.get("properties", {}) + cases: list[dict[str, Any]] = [ + {"mutations": [], "name": "base_valid", "valid": True} + ] + for field in base: + cases.append( + { + "mutations": [{"operation": "delete", "path": [field]}], + "name": f"missing_{field}", + "valid": validator.is_valid(_mutate(base, "delete", field)), + } + ) + cases.append( + { + "mutations": [ + {"operation": "set", "path": ["unknown"], "value": True} + ], + "name": "unknown_field", + "valid": validator.is_valid(_mutate(base, "set", "unknown", True)), + } + ) + optional = [field for field in properties if field not in base] + for field in optional: + present = _example(root, properties[field]) + cases.append( + { + "mutations": [ + {"operation": "set", "path": [field], "value": present} + ], + "name": f"optional_{field}_present", + "valid": validator.is_valid(_mutate(base, "set", field, present)), + } + ) + if optional: + all_present = deepcopy(base) + mutations = [] + for field in optional: + present = _example(root, properties[field]) + all_present[field] = present + mutations.append( + {"operation": "set", "path": [field], "value": present} + ) + cases.append( + { + "mutations": mutations, + "name": "optional_fields_all_present", + "valid": validator.is_valid(all_present), + } + ) + for field, field_schema in properties.items(): + if field in base and base[field] is None and _accepts_null(root, field_schema): + nonnull = _nonnull_example(root, field_schema) + cases.append( + { + "mutations": [ + {"operation": "set", "path": [field], "value": nonnull} + ], + "name": f"nullable_{field}_nonnull", + "valid": validator.is_valid( + _mutate(base, "set", field, nonnull) + ), + } + ) + return { + "artifact": artifact, + "base": base, + "cases": cases, + "schema_pointer": pointer, + "variant": variant, + } + + +def build_schema_field_matrices(schema_dir: Path) -> list[dict[str, Any]]: + """Build declarative matrices for every closed object and discriminator.""" + + matrices: list[dict[str, Any]] = [] + for artifact, filename in SCHEMAS.items(): + root = json.loads(schema_dir.joinpath(filename).read_text(encoding="utf-8")) + for pointer, schema in _closed_records(root): + discriminator = next( + ( + (field, field_schema["enum"]) + for field, field_schema in schema.get("properties", {}).items() + if isinstance(field_schema, dict) + and len(field_schema.get("enum", [])) > 1 + ), + None, + ) + variants: list[tuple[str, Mapping[str, Json] | None]] = [("base", None)] + if discriminator is not None: + field, values = discriminator + variants = [ + (f"{field}={value}", {field: value}) for value in values + ] + if artifact == "normalized_product" and pointer == "#/$defs/connector": + variants = [] + for anchor_name in ( + "geometryAnchor", + "placementAnchor", + "forwardedAnchor", + ): + anchor = _example(root, root["$defs"][anchor_name]) + variants.append( + (f"anchor={anchor['anchor_kind']}", {"anchor": anchor}) + ) + for variant, overrides in variants: + matrices.append( + _record_matrix( + artifact, + root, + pointer, + schema, + overrides=overrides, + variant=variant, + ) + ) + identities = [ + (matrix["artifact"], matrix["schema_pointer"], matrix["variant"]) + for matrix in matrices + ] + if len(identities) != len(set(identities)): + raise AssertionError("duplicate schema field matrix identity") + return matrices diff --git a/SimpleCADAPI/uv.lock b/SimpleCADAPI/uv.lock index 4680418..d443943 100644 --- a/SimpleCADAPI/uv.lock +++ b/SimpleCADAPI/uv.lock @@ -7,6 +7,15 @@ resolution-markers = [ "python_full_version < '3.11'", ] +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + [[package]] name = "anyio" version = "4.12.1" @@ -30,6 +39,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/81/29/5ecc3a15d5a33e31b26c11426c45c501e439cb865d0bff96315d86443b78/appnope-0.1.4-py2.py3-none-any.whl", hash = "sha256:502575ee11cd7a28c0205f379b525beefebab9d161b7c964670864014ed7213c", size = 4321, upload-time = "2024-02-06T09:43:09.663Z" }, ] +[[package]] +name = "argcomplete" +version = "3.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/95/c0/c8e94135e66fabf89a120d9b4b123fe6993506beca6c1938a74c24cfa5fd/argcomplete-3.7.0.tar.gz", hash = "sha256:afde224f753f874807b1dc1414e883ab8fe0cda9c04807b6047dcb8e1ac23913", size = 73284, upload-time = "2026-06-30T22:28:22.249Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/f6/5b8ec087cd9cfa9449491ec83f76fb6b7006b4dff57d2ba8aaab330fe8e4/argcomplete-3.7.0-py3-none-any.whl", hash = "sha256:d8f0f22d2a8a7caa383be1e22b6caf1ecaf0ebd10d8f83cc125e36540c95830c", size = 42575, upload-time = "2026-06-30T22:28:20.547Z" }, +] + [[package]] name = "argon2-cffi" version = "25.1.0" @@ -400,7 +418,7 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/66/54/eb9bfc647b19f2009dd5c7f5ec51c4e6ca831725f1aea7a993034f483147/contourpy-1.3.2.tar.gz", hash = "sha256:b6945942715a034c671b7fc54f9588126b0b8bf23db2696e3ca8328f3ff0ab54", size = 13466130, upload-time = "2025-04-15T17:47:53.79Z" } wheels = [ @@ -471,7 +489,7 @@ resolution-markers = [ "python_full_version == '3.11.*'", ] dependencies = [ - { name = "numpy", version = "2.3.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "numpy", version = "2.3.1", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174, upload-time = "2025-07-26T12:03:12.549Z" } wheels = [ @@ -675,6 +693,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c4/19/31aa63ab719b2e1eea5f200a8a54e2591dc1966a6b75e3de30ef8be9bc2c/cython-3.2.8-py3-none-any.whl", hash = "sha256:f635e113677666de13a2ec2979e9b1d5b90617cdfd1a691d3559be81e2dd6cb9", size = 1258688, upload-time = "2026-06-30T07:41:55.624Z" }, ] +[[package]] +name = "datamodel-code-generator" +version = "0.32.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "argcomplete" }, + { name = "black" }, + { name = "genson" }, + { name = "inflect" }, + { name = "isort" }, + { name = "jinja2" }, + { name = "packaging" }, + { name = "pydantic" }, + { name = "pyyaml" }, + { name = "tomli", marker = "python_full_version < '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3f/66/5ad66a2b5ff34ed67808570f7476261f6f1de3263d0764db9483384878b7/datamodel_code_generator-0.32.0.tar.gz", hash = "sha256:c6f84a6a7683ef9841940b0931aa1ee338b19950ba5b10c920f9c7ad6f5e5b72", size = 457172, upload-time = "2025-07-25T14:12:06.692Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/0a/ef2472343f7b2ec7257a646a21c3c29605939c2ff526959dc6ea2ac4ad7a/datamodel_code_generator-0.32.0-py3-none-any.whl", hash = "sha256:48f3cabbb792398112ee756b23a319e17b001ee534896b324893a98ff10e0a55", size = 120051, upload-time = "2025-07-25T14:12:04.969Z" }, +] + [[package]] name = "debugpy" version = "1.8.14" @@ -732,7 +771,7 @@ name = "exceptiongroup" version = "1.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749, upload-time = "2025-05-10T17:42:51.123Z" } wheels = [ @@ -807,6 +846,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cf/58/8acf1b3e91c58313ce5cb67df61001fc9dcd21be4fadb76c1a2d540e09ed/fqdn-1.5.1-py3-none-any.whl", hash = "sha256:3a179af3761e4df6eb2e026ff9e1a3033d3587bf980a0b1b2e1e5d08d7358014", size = 9121, upload-time = "2021-03-11T07:16:28.351Z" }, ] +[[package]] +name = "genson" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f8/53/de162dc8e03fccd9ebe59d17c7812378fe8bd2b604f6b1b94d00165140ac/genson-1.4.0.tar.gz", hash = "sha256:bc7f1c1bae87a21ca44d81149aec95a3f4468d676de9b8b08caa064f3c50b3da", size = 47908, upload-time = "2026-07-06T08:21:50.331Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/02/767f744ab6d4cb7761e5008acc3d534b7a0481af62563d52e391fbcb2140/genson-1.4.0-py3-none-any.whl", hash = "sha256:03bc71bbe52defde70660cc4dcd1ea1097997da5a1cbb90a9dbd3acc7c9e1b65", size = 24484, upload-time = "2026-07-06T08:21:49.046Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -870,13 +918,26 @@ name = "importlib-metadata" version = "8.7.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "zipp", marker = "python_full_version < '3.12'" }, + { name = "zipp" }, ] sdist = { url = "https://files.pythonhosted.org/packages/76/66/650a33bd90f786193e4de4b3ad86ea60b53c89b669a5c7be931fac31cdb0/importlib_metadata-8.7.0.tar.gz", hash = "sha256:d13b81ad223b890aa16c5471f2ac3056cf76c5f10f82d6f9292f0b415f389000", size = 56641, upload-time = "2025-04-27T15:29:01.736Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/20/b0/36bd937216ec521246249be3bf9855081de4c5e06a0c9b4219dbeda50373/importlib_metadata-8.7.0-py3-none-any.whl", hash = "sha256:e5dd1551894c77868a30651cef00984d50e1002d06942a7101d34870c5f02afd", size = 27656, upload-time = "2025-04-27T15:29:00.214Z" }, ] +[[package]] +name = "inflect" +version = "7.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "more-itertools" }, + { name = "typeguard" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/c6/943357d44a21fd995723d07ccaddd78023eace03c1846049a2645d4324a3/inflect-7.5.0.tar.gz", hash = "sha256:faf19801c3742ed5a05a8ce388e0d8fe1a07f8d095c82201eb904f5d27ad571f", size = 73751, upload-time = "2024-12-28T17:11:18.897Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/eb/427ed2b20a38a4ee29f24dbe4ae2dafab198674fe9a85e3d6adf9e5f5f41/inflect-7.5.0-py3-none-any.whl", hash = "sha256:2aea70e5e70c35d8350b8097396ec155ffd68def678c7ff97f51aa69c1d92344", size = 35197, upload-time = "2024-12-28T17:11:15.931Z" }, +] + [[package]] name = "iniconfig" version = "2.1.0" @@ -1042,7 +1103,7 @@ wheels = [ [[package]] name = "jsonschema" -version = "4.24.0" +version = "4.26.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "attrs" }, @@ -1050,9 +1111,9 @@ dependencies = [ { name = "referencing" }, { name = "rpds-py" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/bf/d3/1cf5326b923a53515d8f3a2cd442e6d7e94fcc444716e879ea70a0ce3177/jsonschema-4.24.0.tar.gz", hash = "sha256:0b4e8069eb12aedfa881333004bccaec24ecef5a8a6a4b6df142b2cc9599d196", size = 353480, upload-time = "2025-05-26T18:48:10.459Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a2/3d/023389198f69c722d039351050738d6755376c8fd343e91dc493ea485905/jsonschema-4.24.0-py3-none-any.whl", hash = "sha256:a462455f19f5faf404a7902952b6f0e3ce868f3ee09a359b05eca6673bd8412d", size = 88709, upload-time = "2025-05-26T18:48:08.417Z" }, + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, ] [package.optional-dependencies] @@ -1063,6 +1124,7 @@ format-nongpl = [ { name = "jsonpointer" }, { name = "rfc3339-validator" }, { name = "rfc3986-validator" }, + { name = "rfc3987-syntax" }, { name = "uri-template" }, { name = "webcolors" }, ] @@ -1335,6 +1397,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/da/e9/0d4add7873a73e462aeb45c036a2dead2562b825aa46ba326727b3f31016/kiwisolver-1.4.9-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:fb940820c63a9590d31d88b815e7a3aa5915cad3ce735ab45f0c730b39547de1", size = 73929, upload-time = "2025-08-10T21:27:48.236Z" }, ] +[[package]] +name = "lark" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/da/34/28fff3ab31ccff1fd4f6c7c7b0ceb2b6968d8ea4950663eadcb5720591a0/lark-1.3.1.tar.gz", hash = "sha256:b426a7a6d6d53189d318f2b6236ab5d6429eaf09259f1ca33eb716eed10d2905", size = 382732, upload-time = "2025-10-27T18:25:56.653Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3d/14ce75ef66813643812f3093ab17e46d3a206942ce7376d31ec2d36229e7/lark-1.3.1-py3-none-any.whl", hash = "sha256:c629b661023a014c37da873b4ff58a817398d12635d3bbb2c5a03be7fe5d1e12", size = 113151, upload-time = "2025-10-27T18:25:54.882Z" }, +] + [[package]] name = "markdown-it-py" version = "3.0.0" @@ -2015,6 +2086,107 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc", size = 117552, upload-time = "2024-03-30T13:22:20.476Z" }, ] +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/08/f1ba952f1c8ae5581c70fa9c6da89f247b83e3dd8c09c035d5d7931fc23d/pydantic_core-2.46.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:a396dcc17e5a0b164dbe026896245a4fa9ff402edca1dff0be3d53a517f74de4", size = 2113146, upload-time = "2026-05-06T13:37:36.537Z" }, + { url = "https://files.pythonhosted.org/packages/56/c6/65f646c7ff09bd257f660434adb45c4dfcbbcebcc030562fecf6f5bf887d/pydantic_core-2.46.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:da4b951fe36dc7c3a1ccb4e3cd1747c3542b8c9ceede8fc86cae054e764485f5", size = 1949769, upload-time = "2026-05-06T13:37:46.365Z" }, + { url = "https://files.pythonhosted.org/packages/64/ba/bfb1d928fd5b49e1258935ff104ae356e9fd89384a55bf9f847e9193ad40/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb63e0198ca18aad131c089b9204c23079c3afa95487e561f4c522d519e55aba", size = 1974958, upload-time = "2026-05-06T13:37:28.611Z" }, + { url = "https://files.pythonhosted.org/packages/4e/74/76223bfb117b64af743c9b6670d1364516f5c0604f96b48f3272f6af6cc6/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f47286a97f0bc9b8859519809077b91b2cefe4ae47fcbf5e466a009c1c5d742b", size = 2042118, upload-time = "2026-05-06T13:36:55.216Z" }, + { url = "https://files.pythonhosted.org/packages/cb/7b/848732968bc8f48f3187542f08358b9d842db564147b256669426ebb1652/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:905a0ed8ea6f2d61c1738835f99b699348d7857379083e5fc497fa0c967a407c", size = 2222876, upload-time = "2026-05-06T13:38:25.455Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2f/e90b63ee2e14bd8d3db8f705a6d75d64e6ee1b7c2c8833747ce706e1e0ce/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea793e075b70290d89d8142074262885d3f7da19634845135751bd6344f73b50", size = 2286703, upload-time = "2026-05-06T13:37:53.304Z" }, + { url = "https://files.pythonhosted.org/packages/ba/1e/acc4d70f88a0a277e4a1fa77ebb985ceabaf900430f875bf9338e11c9420/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:395aebd9183f9d112f569aeb5b2214d1a10a33bec8456447f7fbdfa51d38d4cd", size = 2092042, upload-time = "2026-05-06T13:38:46.981Z" }, + { url = "https://files.pythonhosted.org/packages/a9/da/0a422b57bf8504102bf3c4ccea9c41bab5a5cee6a54650acf8faf67f5a24/pydantic_core-2.46.4-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:b078afbc25f3a1436c7a1d2cd3e322497ee99615ba97c563566fdf46aff1ee01", size = 2117231, upload-time = "2026-05-06T13:39:23.146Z" }, + { url = "https://files.pythonhosted.org/packages/bd/2a/2ac13c3af305843e23c5078c53d135656b3f05a2fd78cb7bbbb12e97b473/pydantic_core-2.46.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f747929cf940cddb5b3668a390056ddd5ba2e5010615ea2dcf4f9c4f3ab8791d", size = 2168388, upload-time = "2026-05-06T13:40:08.06Z" }, + { url = "https://files.pythonhosted.org/packages/72/04/2beacf7e1607e93eefe4aed1b4709f079b905fb77530179d4f7c71745f22/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:daa27d92c36f24388fe3ad306b174781c747627f134452e4f128ea00ce1fe8c4", size = 2184769, upload-time = "2026-05-06T13:38:13.901Z" }, + { url = "https://files.pythonhosted.org/packages/9e/29/d2b9fd9f539133548eaf622c06a4ce176cb46ac59f32d0359c4abc0de047/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:19e51f073cd3df251856a8a4189fbdf1de4012c3ebacfb1884f94f1eb406079f", size = 2319312, upload-time = "2026-05-06T13:39:08.24Z" }, + { url = "https://files.pythonhosted.org/packages/7c/af/0f7a5b85fec6075bea96e3ef9187de38fccced0de92c1e7feda8d5cc7bb9/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c1747f85cee84c26985853c6f3d9bd3e75da5212912443fa111c113b9c246f39", size = 2361817, upload-time = "2026-05-06T13:38:43.2Z" }, + { url = "https://files.pythonhosted.org/packages/25/a4/73363fec545fd3ec025490bdda2743c56d0dd5b6266b1a53bbe9e4265375/pydantic_core-2.46.4-cp310-cp310-win32.whl", hash = "sha256:2f84c03c8607173d16b5a854ec68a2f9079ae03237a54fb506d13af47e1d018d", size = 1987085, upload-time = "2026-05-06T13:39:25.497Z" }, + { url = "https://files.pythonhosted.org/packages/01/aa/62f082da2c91fac1c234bc9ee0066257ce83f0604abd72e4c9d5991f2d84/pydantic_core-2.46.4-cp310-cp310-win_amd64.whl", hash = "sha256:8358a950c8909158e3df31538a7e4edc2d7265a7c54b47f0864d9e5bae9dcebf", size = 2074311, upload-time = "2026-05-06T13:39:59.922Z" }, + { url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" }, + { url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" }, + { url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" }, + { url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" }, + { url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" }, + { url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" }, + { url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" }, + { url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" }, + { url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" }, + { url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" }, + { url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" }, + { url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" }, + { url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" }, + { url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" }, + { url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, + { url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" }, + { url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" }, + { url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" }, + { url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" }, + { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, +] + [[package]] name = "pygments" version = "2.19.2" @@ -2365,6 +2537,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9e/51/17023c0f8f1869d8806b979a2bffa3f861f26a3f1a66b094288323fba52f/rfc3986_validator-0.1.1-py2.py3-none-any.whl", hash = "sha256:2f235c432ef459970b4306369336b9d5dbdda31b510ca1e327636e01f528bfa9", size = 4242, upload-time = "2019-10-28T16:00:13.976Z" }, ] +[[package]] +name = "rfc3987-syntax" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "lark" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2c/06/37c1a5557acf449e8e406a830a05bf885ac47d33270aec454ef78675008d/rfc3987_syntax-1.1.0.tar.gz", hash = "sha256:717a62cbf33cffdd16dfa3a497d81ce48a660ea691b1ddd7be710c22f00b4a0d", size = 14239, upload-time = "2025-07-18T01:05:05.015Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/71/44ce230e1b7fadd372515a97e32a83011f906ddded8d03e3c6aafbdedbb7/rfc3987_syntax-1.1.0-py3-none-any.whl", hash = "sha256:6c3d97604e4c5ce9f714898e05401a0445a641cfa276432b0a648c80856f6a3f", size = 8046, upload-time = "2025-07-18T01:05:03.843Z" }, +] + +[[package]] +name = "rfc8785" +version = "0.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ef/2f/fa1d2e740c490191b572d33dbca5daa180cb423c24396b856f5886371d8b/rfc8785-0.1.4.tar.gz", hash = "sha256:e545841329fe0eee4f6a3b44e7034343100c12b4ec566dc06ca9735681deb4da", size = 14321, upload-time = "2024-09-27T16:33:31.206Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4d/78/119878110660b2ad709888c8a1614fce7e2fab39080ab960656dc8605bf6/rfc8785-0.1.4-py3-none-any.whl", hash = "sha256:520d690b448ecf0703691c76e1a34a24ddcd4fc5bc41d589cb7c58ec651bcd48", size = 9240, upload-time = "2024-09-27T16:33:29.683Z" }, +] + [[package]] name = "rich" version = "14.0.0" @@ -2511,13 +2704,16 @@ wheels = [ [[package]] name = "simplecadapi" -version = "2.0.1b1" +version = "2.0.2" source = { editable = "." } dependencies = [ { name = "cadquery-ocp" }, + { name = "jsonschema" }, { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "numpy", version = "2.3.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "rfc8785" }, { name = "rich" }, + { name = "typing-extensions" }, ] [package.optional-dependencies] @@ -2531,9 +2727,13 @@ dev = [ { name = "pytest" }, { name = "pytest-cov" }, ] +inverse-engineer = [ + { name = "matplotlib" }, +] [package.dev-dependencies] dev = [ + { name = "datamodel-code-generator" }, { name = "ipykernel" }, { name = "jupyterlab" }, { name = "matplotlib" }, @@ -2548,17 +2748,22 @@ requires-dist = [ { name = "black", marker = "extra == 'dev'", specifier = ">=23.0" }, { name = "cadquery-ocp", specifier = ">=7.9.3.1" }, { name = "isort", marker = "extra == 'dev'", specifier = ">=5.0" }, + { name = "jsonschema", specifier = ">=4.25.1,<5.0.0" }, + { name = "matplotlib", marker = "extra == 'inverse-engineer'", specifier = ">=3.7.0" }, { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.0" }, { name = "numpy", specifier = ">=1.21.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0" }, { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.0" }, { name = "python-fcl", marker = "extra == 'collision'", specifier = ">=0.7.0.11" }, + { name = "rfc8785", specifier = ">=0.1.4,<0.2.0" }, { name = "rich", specifier = ">=14.0.0" }, + { name = "typing-extensions", specifier = ">=4.12,<5" }, ] -provides-extras = ["dev", "editor", "collision"] +provides-extras = ["dev", "editor", "collision", "inverse-engineer"] [package.metadata.requires-dev] dev = [ + { name = "datamodel-code-generator", specifier = "==0.32.0" }, { name = "ipykernel", specifier = ">=6.29.5" }, { name = "jupyterlab", specifier = ">=4.5.5" }, { name = "matplotlib", specifier = ">=3.10.8" }, @@ -2726,6 +2931,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7c/b6/74e927715a285743351233f33ea3c684528a0d374d2e43ff9ce9585b73fe/twine-6.1.0-py3-none-any.whl", hash = "sha256:a47f973caf122930bf0fbbf17f80b83bc1602c9ce393c7845f289a3001dc5384", size = 40791, upload-time = "2025-01-21T18:45:24.584Z" }, ] +[[package]] +name = "typeguard" +version = "4.5.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/67/1c/dfba5c4633cafc4c701f237d2ba63b416805047fd6d96aab4cfc40969f98/typeguard-4.5.2.tar.gz", hash = "sha256:5a16dcac23502039299c97c8941651bc33d7ea8cc4b2f7d6bbb1b528f6eea423", size = 80240, upload-time = "2026-05-14T12:59:40.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/29/74eeb4d3f3ae61ca096b018ad486b3b3c74b17bec09ab4edab721cbefec3/typeguard-4.5.2-py3-none-any.whl", hash = "sha256:fcf9de18bd945cdb4c7b996e12b4c51ce83f92f191314a6d7cf1739586ec98cf", size = 36748, upload-time = "2026-05-14T12:59:39.473Z" }, +] + [[package]] name = "types-python-dateutil" version = "2.9.0.20250516" @@ -2744,6 +2961,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b5/00/d631e67a838026495268c2f6884f3711a15a9a2a96cd244fdaea53b823fb/typing_extensions-4.14.1-py3-none-any.whl", hash = "sha256:d1e1e3b58374dc93031d6eda2420a48ea44a36c2b4766a4fdeb3710755731d76", size = 43906, upload-time = "2025-07-04T13:28:32.743Z" }, ] +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + [[package]] name = "uri-template" version = "1.3.0" diff --git a/SimpleCADAPI/viewer/README.md b/SimpleCADAPI/viewer/README.md new file mode 100644 index 0000000..edb9124 --- /dev/null +++ b/SimpleCADAPI/viewer/README.md @@ -0,0 +1,42 @@ +# SimpleCAD Scene Viewer + +This is a browser-only viewer for exported `Scene Schema 1.0` packages. It +does not import SimpleCAD, Python, or OpenCascade. The loader reads the +canonical `.scene.zip`, parses `scene.json`, and loads the referenced GLB and +entity sidecar assets. When present, it also loads `model/model.json` and the +manifest-declared Python files under `sources/`. ZIP resource limits and every +referenced member's exact byte length and SHA-256 are checked before scene +assets are parsed. + +## Run + +```bash +npm install +npm run prepare:example +npm run dev +``` + +Open `http://localhost:5173/`, click **Open .scene.zip**, and select the package +generated at `examples/out/hydraulic_rod_assembly/hydraulic_rod_assembly.scene.zip`. +The same file can be dropped directly into the viewport. + +The file picker accepts any local `.scene.zip` package. The viewer does not +load scenes from URL query parameters or require a built-in case registry. + +## Inspecting A Model + +The left **ASSEMBLY** tab shows the evaluated occurrence hierarchy. Click an +occurrence to inspect its definition, visibility, and evaluated body data. Use +the visibility control on a row to hide or show that occurrence. + +The **FEATURES** tab shows the embedded replayable operation tree from +`model/model.json`. Select an operation to inspect its canonical operation name, +category, inputs, output count, parameters, and summary. For mapped operations, +the inspector shows assignment targets and the complete embedded Python file, +scrolls to the originating call, and highlights its source line range. + +Click a rendered face or CAD edge in the viewport to select the corresponding +evaluated entity. The selected face or edge is highlighted and its measured +properties are shown in the inspector. The scene package must contain the +embedded model artifact for the Features tab; older scene packages still retain +assembly-tree and geometry selection support. diff --git a/SimpleCADAPI/viewer/index.html b/SimpleCADAPI/viewer/index.html new file mode 100644 index 0000000..0ddab4b --- /dev/null +++ b/SimpleCADAPI/viewer/index.html @@ -0,0 +1,13 @@ + + + + + + + SimpleCAD Scene Viewer + + +
+ + + diff --git a/SimpleCADAPI/viewer/package-lock.json b/SimpleCADAPI/viewer/package-lock.json new file mode 100644 index 0000000..71c4dbb --- /dev/null +++ b/SimpleCADAPI/viewer/package-lock.json @@ -0,0 +1,1240 @@ +{ + "name": "simplecad-scene-viewer", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "simplecad-scene-viewer", + "version": "1.0.0", + "dependencies": { + "fflate": "0.8.2", + "lucide": "^0.547.0", + "three": "0.178.0" + }, + "devDependencies": { + "@types/three": "0.178.1", + "typescript": "5.7.2", + "vite": "7.3.6" + } + }, + "node_modules/@dimforge/rapier3d-compat": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz", + "integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tweenjs/tween.js": { + "version": "23.1.3", + "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", + "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/stats.js": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz", + "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/three": { + "version": "0.178.1", + "resolved": "https://registry.npmjs.org/@types/three/-/three-0.178.1.tgz", + "integrity": "sha512-WSabew1mgWgRx2RfLfKY+9h4wyg6U94JfLbZEGU245j/WY2kXqU0MUfghS+3AYMV5ET1VlILAgpy77cB6a3Itw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@dimforge/rapier3d-compat": "~0.12.0", + "@tweenjs/tween.js": "~23.1.3", + "@types/stats.js": "*", + "@types/webxr": "*", + "@webgpu/types": "*", + "fflate": "~0.8.2", + "meshoptimizer": "~0.18.1" + } + }, + "node_modules/@types/webxr": { + "version": "0.5.24", + "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz", + "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webgpu/types": { + "version": "0.1.71", + "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.71.tgz", + "integrity": "sha512-mMy8/ODcKhab808co15eW+yN+HgXoQxRQHTiBV9Mrvl1r0ufnid7YOcI+gi4eUWSWl9ezD6TW2KXccrL8HCh2A==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/lucide": { + "version": "0.547.0", + "resolved": "https://registry.npmjs.org/lucide/-/lucide-0.547.0.tgz", + "integrity": "sha512-cagdLpi+15y9G/Xbby2JLZo4khFKQ1iQ/4s/iwp5Vaz+NV0XX53gvQ81V49Gi8bomq+CJijSV7J2yCovxvcsSA==", + "license": "ISC" + }, + "node_modules/meshoptimizer": { + "version": "0.18.1", + "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-0.18.1.tgz", + "integrity": "sha512-ZhoIoL7TNV4s5B6+rx5mC//fw8/POGyNxS/DZyCJeiZ12ScLfVwRE/GfsxwiTkMYYD5DmK2/JXnEVXqL4rF+Sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.20", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.20.tgz", + "integrity": "sha512-lW616l85ucIQL+FocMmL7pQFPqBmwejrCMg+iPxyImlrANNJG9NHq/RkyCZopDhd8C3LA03PHRJDjkbGu8vvug==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/three": { + "version": "0.178.0", + "resolved": "https://registry.npmjs.org/three/-/three-0.178.0.tgz", + "integrity": "sha512-ybFIB0+x8mz0wnZgSGy2MO/WCO6xZhQSZnmfytSPyNpM0sBafGRVhdaj+erYh5U+RhQOAg/eXqw5uVDiM2BjhQ==", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.2.tgz", + "integrity": "sha512-i5t66RHxDvVN40HfDd1PsEThGNnlMCMT3jMUuoh9/0TaqWevNontacunWyN02LA9/fIbEWlcHZcgTKb9QoaLfg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "7.3.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", + "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0 || ^0.28.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + } + } +} diff --git a/SimpleCADAPI/viewer/package.json b/SimpleCADAPI/viewer/package.json new file mode 100644 index 0000000..d6bdcff --- /dev/null +++ b/SimpleCADAPI/viewer/package.json @@ -0,0 +1,22 @@ +{ + "name": "simplecad-scene-viewer", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc --noEmit && vite build", + "preview": "vite preview", + "prepare:example": "uv run python ../examples/10_part_assembly.py" + }, + "dependencies": { + "fflate": "0.8.2", + "lucide": "^0.547.0", + "three": "0.178.0" + }, + "devDependencies": { + "@types/three": "0.178.1", + "typescript": "5.7.2", + "vite": "7.3.6" + } +} diff --git a/SimpleCADAPI/viewer/src/components/click-selection.ts b/SimpleCADAPI/viewer/src/components/click-selection.ts new file mode 100644 index 0000000..faf590d --- /dev/null +++ b/SimpleCADAPI/viewer/src/components/click-selection.ts @@ -0,0 +1,68 @@ +import * as THREE from 'three'; +import type { OrbitControls } from 'three/addons/controls/OrbitControls.js'; + +type ClickSelectionOptions = { + element: HTMLElement; + camera: THREE.PerspectiveCamera; + controls: OrbitControls; + movementThreshold?: number; + onPointerStateChange?: (active: boolean) => void; + onClick: (event: PointerEvent) => void; +}; + +export function bindClickSelection(options: ClickSelectionOptions): () => void { + const { element, camera, controls, onClick, movementThreshold = 4 } = options; + let pointerDown: { + x: number; + y: number; + button: number; + position: THREE.Vector3; + target: THREE.Vector3; + zoom: number; + } | null = null; + + const onPointerDown = (event: PointerEvent): void => { + pointerDown = { + x: event.clientX, + y: event.clientY, + button: event.button, + position: camera.position.clone(), + target: controls.target.clone(), + zoom: camera.zoom, + }; + options.onPointerStateChange?.(true); + }; + const onPointerUp = (event: PointerEvent): void => { + const down = pointerDown; + pointerDown = null; + options.onPointerStateChange?.(false); + if (!down || down.button !== 0 || event.button !== 0) return; + if (Math.hypot(event.clientX - down.x, event.clientY - down.y) > movementThreshold) return; + + // OrbitControls treats any pointer movement as rotation. Drain that movement, + // then restore the click-time camera before committing selection. + const damping = controls.enableDamping; + controls.enableDamping = false; + controls.update(); + camera.position.copy(down.position); + camera.zoom = down.zoom; + camera.updateProjectionMatrix(); + controls.target.copy(down.target); + controls.update(); + controls.enableDamping = damping; + onClick(event); + }; + const onPointerLeave = (): void => { + pointerDown = null; + options.onPointerStateChange?.(false); + }; + + element.addEventListener('pointerdown', onPointerDown); + element.addEventListener('pointerup', onPointerUp); + element.addEventListener('pointerleave', onPointerLeave); + return () => { + element.removeEventListener('pointerdown', onPointerDown); + element.removeEventListener('pointerup', onPointerUp); + element.removeEventListener('pointerleave', onPointerLeave); + }; +} diff --git a/SimpleCADAPI/viewer/src/components/panel-resizer.ts b/SimpleCADAPI/viewer/src/components/panel-resizer.ts new file mode 100644 index 0000000..3e4570f --- /dev/null +++ b/SimpleCADAPI/viewer/src/components/panel-resizer.ts @@ -0,0 +1,110 @@ +type PanelSide = 'navigator' | 'inspector'; + +type ResizablePanelsOptions = { + workspace: HTMLElement; + navigatorPanel: HTMLElement; + inspectorPanel: HTMLElement; + navigatorResizer: HTMLElement; + inspectorResizer: HTMLElement; + minNavigatorWidth?: number; + maxNavigatorWidth?: number; + minInspectorWidth?: number; + maxInspectorWidth?: number; + minCenterWidth?: number; + gutterWidth?: number; + desktopMedia?: string; +}; + +export function bindResizablePanels(options: ResizablePanelsOptions): () => void { + const { + workspace, + navigatorPanel, + inspectorPanel, + navigatorResizer, + inspectorResizer, + minNavigatorWidth = 180, + maxNavigatorWidth = 520, + minInspectorWidth = 230, + maxInspectorWidth = 620, + minCenterWidth = 320, + gutterWidth = 14, + desktopMedia = '(min-width: 901px)', + } = options; + const media = window.matchMedia(desktopMedia); + const cleanups: Array<() => void> = []; + + const panelFor = (side: PanelSide): HTMLElement => side === 'navigator' ? navigatorPanel : inspectorPanel; + const otherPanelFor = (side: PanelSide): HTMLElement => side === 'navigator' ? inspectorPanel : navigatorPanel; + const resizerFor = (side: PanelSide): HTMLElement => side === 'navigator' ? navigatorResizer : inspectorResizer; + + const setWidth = (side: PanelSide, requestedWidth: number): void => { + if (!media.matches) return; + const minimum = side === 'navigator' ? minNavigatorWidth : minInspectorWidth; + const configuredMaximum = side === 'navigator' ? maxNavigatorWidth : maxInspectorWidth; + const available = workspace.getBoundingClientRect().width + - otherPanelFor(side).getBoundingClientRect().width + - minCenterWidth + - gutterWidth; + const maximum = Math.max(minimum, Math.min(configuredMaximum, available)); + const width = Math.round(Math.max(minimum, Math.min(maximum, requestedWidth))); + workspace.style.setProperty(side === 'navigator' ? '--navigator-width' : '--inspector-width', `${width}px`); + const resizer = resizerFor(side); + resizer.setAttribute('aria-valuemin', String(minimum)); + resizer.setAttribute('aria-valuemax', String(maximum)); + resizer.setAttribute('aria-valuenow', String(width)); + }; + + const bind = (side: PanelSide): void => { + const resizer = resizerFor(side); + const onPointerDown = (event: PointerEvent): void => { + if (event.button !== 0 || !media.matches) return; + event.preventDefault(); + const startX = event.clientX; + const startWidth = panelFor(side).getBoundingClientRect().width; + resizer.setPointerCapture(event.pointerId); + resizer.classList.add('dragging'); + document.body.classList.add('resizing-panels'); + + const onPointerMove = (moveEvent: PointerEvent): void => { + const delta = moveEvent.clientX - startX; + setWidth(side, startWidth + (side === 'navigator' ? delta : -delta)); + }; + const finish = (): void => { + resizer.classList.remove('dragging'); + document.body.classList.remove('resizing-panels'); + resizer.removeEventListener('pointermove', onPointerMove); + resizer.removeEventListener('pointerup', finish); + resizer.removeEventListener('pointercancel', finish); + }; + resizer.addEventListener('pointermove', onPointerMove); + resizer.addEventListener('pointerup', finish); + resizer.addEventListener('pointercancel', finish); + }; + const onKeyDown = (event: KeyboardEvent): void => { + if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return; + event.preventDefault(); + const direction = event.key === 'ArrowRight' ? 1 : -1; + const currentWidth = panelFor(side).getBoundingClientRect().width; + setWidth(side, currentWidth + direction * (side === 'navigator' ? 12 : -12)); + }; + resizer.addEventListener('pointerdown', onPointerDown); + resizer.addEventListener('keydown', onKeyDown); + cleanups.push(() => resizer.removeEventListener('pointerdown', onPointerDown)); + cleanups.push(() => resizer.removeEventListener('keydown', onKeyDown)); + }; + + bind('navigator'); + bind('inspector'); + setWidth('navigator', navigatorPanel.getBoundingClientRect().width); + setWidth('inspector', inspectorPanel.getBoundingClientRect().width); + + const resizeObserver = new ResizeObserver(() => { + if (!media.matches) return; + setWidth('navigator', navigatorPanel.getBoundingClientRect().width); + setWidth('inspector', inspectorPanel.getBoundingClientRect().width); + }); + resizeObserver.observe(workspace); + cleanups.push(() => resizeObserver.disconnect()); + + return () => cleanups.forEach((cleanup) => cleanup()); +} diff --git a/SimpleCADAPI/viewer/src/main.ts b/SimpleCADAPI/viewer/src/main.ts new file mode 100644 index 0000000..545f4a0 --- /dev/null +++ b/SimpleCADAPI/viewer/src/main.ts @@ -0,0 +1,1136 @@ +import './style.css'; + +import { unzipSync, strFromU8 } from 'fflate'; +import { + Box, + Boxes, + Combine, + ChevronDown, + CircleDot, + Copy, + CopyCheck, + Eye, + EyeOff, + GitBranch, + Layers, + LayoutDashboard, + Maximize2, + PackageOpen, + Plus, + Rotate3d, + ScanFace, + Scissors, + Spline, + Workflow, + createIcons, +} from 'lucide'; +import * as THREE from 'three'; +import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js'; +import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; +import { LineSegments2 } from 'three/addons/lines/LineSegments2.js'; +import { LineSegmentsGeometry } from 'three/addons/lines/LineSegmentsGeometry.js'; +import { LineMaterial } from 'three/addons/lines/LineMaterial.js'; +import { bindClickSelection } from './components/click-selection'; +import { bindResizablePanels } from './components/panel-resizer'; + +type Vec3 = [number, number, number]; +type Bounds = { min: Vec3; max: Vec3 }; +type Transform = { origin: Vec3; x_axis: Vec3; y_axis: Vec3; z_axis: Vec3 }; +type Appearance = { appearance_id: string; name: string | null; base_color: [number, number, number, 1]; metallic: number; roughness: number; alpha_mode: 'opaque' | 'mask' | 'blend'; double_sided: boolean; edge_color: [number, number, number, 1] }; +type SourceRecord = Record & { kind: string; root_id?: string; graph_id?: string; node_id?: string; output_slot?: number; component_path?: string[] }; +type Definition = { definition_id: string; kind: string; name: string | null; source: SourceRecord; sdk_metadata: Record; geometry_asset_id?: string; edge_asset_id?: string; entity_asset_id?: string; appearance_id?: string }; +type SceneNode = { node_id: string; parent_node_id: string | null; order: number; definition_id: string; name: string | null; transform: Transform; visible: boolean; selectable: boolean; source: SourceRecord; sdk_metadata: Record }; +type Asset = { asset_id: string; uri: string; byte_length: number; content_hash: string; scene_local_bounds: Bounds; asset_to_scene: number[] }; +type Entity = { entity_id: string; kind: 'solid' | 'face' | 'edge' | 'vertex'; parent_entity_ids: string[]; child_entity_ids: string[]; source: SourceRecord; properties: Record; geometry: Record; sdk_connector_frame: Transform | null; render_status: string; connector_binding_status: string; semantic_binding_ids: string[]; evaluated_tags: string[]; sdk_metadata: Record }; +type EntityAsset = { entity_asset_id: string; uri: string; byte_length: number; content_hash: string }; +type FaceGroup = { entity_id: string; first_index: number; index_count: number }; +type SourceFile = { path: string; uri: string; media_type: string; byte_length: number; content_hash: string }; +type EmbeddedSource = { artifact_hash?: string; embedded_artifact_uri?: string; embedded_artifact_byte_length?: number; source_files?: SourceFile[] }; +type SceneManifest = { schema_version: string; scene_id: string; revision: string; generator: { profile: string; simplecadapi_version: string; ocp_version: string }; source: EmbeddedSource; presentation_source?: EmbeddedSource; coordinate_system: { length_unit: string; up_axis: string }; definitions: Definition[]; nodes: SceneNode[]; geometry_assets: Asset[]; edge_assets: Asset[]; appearances: Appearance[]; entity_assets: EntityAsset[]; connectors: Array<{ connector_snapshot_id: string; owner_definition_id: string; name: string | null; anchor_kind: string; target?: { entity_asset_id: string; entity_id: string } }> }; +type PackageFiles = Record; +type EntitySidecar = { entities: Entity[]; face_groups: FaceGroup[]; edge_groups?: FaceGroup[] }; +type PackageRecord = { uri: string; byte_length: number; content_hash: string }; +type OperationSource = { path?: string | null; line: number; column: number; end_line: number; end_column: number; call_text: string; callsite_id: string; assignment_targets: string[] }; +type ModelNode = { node_id: string; op: string; params: Record; inputs: string[]; output_count?: number; tags?: string[]; display?: { label?: string; category?: string; summary?: string }; source?: OperationSource }; +type ModelDocument = { graph: { graph_id: string; nodes: ModelNode[] }; leaf_ids?: string[] }; +type SelectionMode = 'component' | 'solid' | 'face' | 'edge' | 'vertex'; + +const MAX_PACKAGE_BYTES = 256 * 1024 * 1024; +const MAX_PACKAGE_MEMBERS = 10_000; +const MAX_UNPACKED_BYTES = 512 * 1024 * 1024; +const MAX_MEMBER_BYTES = 256 * 1024 * 1024; +const MAX_SCENE_JSON_BYTES = 8 * 1024 * 1024; +const MAX_COMPRESSION_RATIO = 100; + +const app = document.querySelector('#app'); +if (!app) throw new Error('viewer root is missing'); + +const lucideIcons = { + Box, + Boxes, + Combine, + ChevronDown, + CircleDot, + Copy, + CopyCheck, + Eye, + EyeOff, + GitBranch, + Layers, + LayoutDashboard, + Maximize2, + PackageOpen, + Plus, + Rotate3d, + ScanFace, + Scissors, + Spline, + Workflow, +}; + +function iconMarkup(name: keyof typeof lucideIcons, className = 'ui-icon'): string { + const iconName = name.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase(); + return ``; +} + +function renderIcons(root: Element): void { + createIcons({ + icons: lucideIcons, + attrs: { 'stroke-width': 1.8 }, + root, + }); +} + +app.innerHTML = ` +
+
+
SC
SimpleCADevaluated scene viewer
+
+
+
+ + +
+
Loading evaluated package
Waiting for package
SELECT
+
+ + +
+
No package loadedCAD-local precision retained · GLB transport assets
+
`; + +renderIcons(app); + +const viewport = document.querySelector('#viewport')!; +const loading = document.querySelector('#loading')!; +const sceneTitle = document.querySelector('#scene-title')!; +const tree = document.querySelector('#tree')!; +const details = document.querySelector('#details')!; +const selectionKind = document.querySelector('#selection-kind')!; +const nodeCount = document.querySelector('#node-count')!; +const status = document.querySelector('#status')!; +const statusDot = document.querySelector('#status-dot')!; +const packageMeta = document.querySelector('#package-meta')!; +const workspace = document.querySelector('.workspace')!; +const navigatorPanel = document.querySelector('#navigator-panel')!; +const inspectorPanel = document.querySelector('#inspector-panel')!; +const navigatorResizer = document.querySelector('#navigator-resizer')!; +const inspectorResizer = document.querySelector('#inspector-resizer')!; +const featureTree = document.querySelector('#feature-tree')!; +const featureCount = document.querySelector('#feature-count')!; +const componentsTab = document.querySelector('#components-tab')!; +const featuresTab = document.querySelector('#features-tab')!; +const componentsView = document.querySelector('#components-view')!; +const featuresView = document.querySelector('#features-view')!; + +bindResizablePanels({ workspace, navigatorPanel, inspectorPanel, navigatorResizer, inspectorResizer }); + +const threeScene = new THREE.Scene(); +threeScene.background = new THREE.Color('#0b0e12'); +const camera = new THREE.PerspectiveCamera(42, 1, 0.01, 1000); +camera.up.set(0, 0, 1); +camera.position.set(2.4, 2.1, 3.0); +const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false }); +renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); +renderer.outputColorSpace = THREE.SRGBColorSpace; +renderer.toneMapping = THREE.ACESFilmicToneMapping; +renderer.toneMappingExposure = 1.1; +viewport.append(renderer.domElement); +const controls = new OrbitControls(camera, renderer.domElement); +controls.enableDamping = true; +controls.dampingFactor = 0.08; +controls.minDistance = 0.01; +controls.maxDistance = 1000; + +threeScene.add(new THREE.HemisphereLight('#d9e9ff', '#10141b', 1.7)); +const keyLight = new THREE.DirectionalLight('#fff8e9', 3.1); +keyLight.position.set(4, 7, 5); +threeScene.add(keyLight); +threeScene.add(keyLight.target); +const fillLight = new THREE.DirectionalLight('#8db4ff', 1.1); +fillLight.position.set(-5, -6, 7); +threeScene.add(fillLight); +threeScene.add(fillLight.target); + +const modelRoot = new THREE.Group(); +modelRoot.name = 'scene-package'; +threeScene.add(modelRoot); +const loader = new GLTFLoader(); +const geometryCache = new Map(); +const edgeCache = new Map(); +const entityCache = new Map(); +const nodeObjects = new Map(); +const nodeRows = new Map(); +const manifestByDefinition = new Map(); +const appearanceById = new Map(); +let currentManifest: SceneManifest | null = null; +let currentFiles: PackageFiles | null = null; +let currentModel: ModelDocument | null = null; +const sourceFiles = new Map(); +let selectedNodeId: string | null = null; +let selectedFeatureId: string | null = null; +let selectedOverlays: THREE.Object3D[] = []; +const featureRows = new Map(); +let selectionMode: SelectionMode = 'component'; + +function setStatus(message: string, ready = false): void { + status.textContent = message; + statusDot.classList.toggle('ready', ready); +} + +function bytesFor(files: PackageFiles, uri: string): Uint8Array { + const value = files[uri]; + if (!value) throw new Error(`package member is missing: ${uri}`); + return value; +} + +function validateMemberName(name: string): void { + if (!/^[A-Za-z0-9][A-Za-z0-9._/-]{0,1023}$/.test(name) || name.split('/').some((segment) => !segment || segment === '.' || segment === '..')) { + throw new Error(`invalid scene package member: ${name}`); + } +} + +function packageRecords(manifest: SceneManifest): PackageRecord[] { + const records: PackageRecord[] = []; + const addAsset = (asset: Asset, kind: 'geometry' | 'edges'): void => { + if (asset.asset_id !== asset.content_hash) throw new Error(`${kind} asset ID differs from its content hash`); + const digest = /^sha256:([0-9a-f]{64})$/.exec(asset.content_hash)?.[1]; + if (!digest || asset.uri !== `${kind}/sha256-${digest}.glb`) throw new Error(`invalid content-addressed ${kind} asset URI`); + records.push(asset); + }; + for (const asset of manifest.geometry_assets) addAsset(asset, 'geometry'); + for (const asset of manifest.edge_assets) addAsset(asset, 'edges'); + for (const asset of manifest.entity_assets) { + if (asset.entity_asset_id !== asset.content_hash) throw new Error('entity asset ID differs from its content hash'); + const digest = /^sha256:([0-9a-f]{64})$/.exec(asset.content_hash)?.[1]; + if (!digest || asset.uri !== `entities/sha256-${digest}.json`) throw new Error('invalid content-addressed entity asset URI'); + records.push(asset); + } + for (const source of [manifest.source, manifest.presentation_source]) { + if (!source?.embedded_artifact_uri) continue; + if (typeof source.embedded_artifact_byte_length !== 'number' || typeof source.artifact_hash !== 'string') throw new Error('embedded artifact integrity metadata is missing'); + records.push({ uri: source.embedded_artifact_uri, byte_length: source.embedded_artifact_byte_length, content_hash: source.artifact_hash }); + for (const sourceFile of source.source_files ?? []) records.push(sourceFile); + } + return records; +} + +async function validatePackageMembers(files: PackageFiles, manifest: SceneManifest): Promise { + const records = packageRecords(manifest); + const referenced = new Set(['scene.json']); + for (const record of records) { + validateMemberName(record.uri); + if (referenced.has(record.uri)) throw new Error(`duplicate scene package reference: ${record.uri}`); + referenced.add(record.uri); + } + const names = Object.keys(files); + for (const name of names) { + validateMemberName(name); + } + if (names.length !== referenced.size || names.some((name) => !referenced.has(name))) { + throw new Error('scene package members do not match scene.json references'); + } + for (const record of records) { + const payload = bytesFor(files, record.uri); + if (!Number.isSafeInteger(record.byte_length) || record.byte_length < 0 || payload.byteLength !== record.byte_length) { + throw new Error(`package member length differs from scene.json: ${record.uri}`); + } + const expected = /^sha256:([0-9a-f]{64})$/.exec(record.content_hash)?.[1]; + if (!expected) throw new Error(`invalid package member hash: ${record.uri}`); + const digest = Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256', payload)), (byte) => byte.toString(16).padStart(2, '0')).join(''); + if (digest !== expected) throw new Error(`package member hash differs from scene.json: ${record.uri}`); + } +} + +async function loadGlb(files: PackageFiles, uri: string): Promise { + const bytes = bytesFor(files, uri); + const buffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength); + const gltf = await loader.parseAsync(buffer, ''); + return gltf.scene; +} + +function applyAssetTransform(object: THREE.Object3D, matrix: number[]): void { + if (matrix.length !== 16) throw new Error('asset_to_scene must contain 16 values'); + // The contract stores row-major CAD-to-scene values. Three.js uses metres. + object.applyMatrix4(new THREE.Matrix4().set( + matrix[0] / 1000, matrix[1] / 1000, matrix[2] / 1000, matrix[3] / 1000, + matrix[4] / 1000, matrix[5] / 1000, matrix[6] / 1000, matrix[7] / 1000, + matrix[8] / 1000, matrix[9] / 1000, matrix[10] / 1000, matrix[11] / 1000, + matrix[12], matrix[13], matrix[14], matrix[15], + )); +} + +function placementMatrix(transform: Transform): THREE.Matrix4 { + const { origin, x_axis, y_axis, z_axis } = transform; + return new THREE.Matrix4().set( + x_axis[0], y_axis[0], z_axis[0], origin[0] / 1000, + x_axis[1], y_axis[1], z_axis[1], origin[1] / 1000, + x_axis[2], y_axis[2], z_axis[2], origin[2] / 1000, + 0, 0, 0, 1, + ); +} + +function materialFor(definition: Definition): THREE.MeshStandardMaterial { + const appearance = definition.appearance_id ? appearanceById.get(definition.appearance_id) : undefined; + const color = appearance?.base_color ?? [0.72, 0.75, 0.78, 1]; + return new THREE.MeshStandardMaterial({ color: new THREE.Color(color[0], color[1], color[2]), metalness: appearance?.metallic ?? 0, roughness: appearance?.roughness ?? 0.55, side: appearance?.double_sided ? THREE.DoubleSide : THREE.FrontSide, transparent: appearance?.alpha_mode === 'blend', opacity: color[3] }); +} + +async function instantiateDefinition(definition: Definition): Promise { + if (!currentManifest || !currentFiles) throw new Error('scene package is not loaded'); + const group = new THREE.Group(); + group.name = definition.name || definition.definition_id; + const geometryAsset = currentManifest.geometry_assets.find((asset) => asset.asset_id === definition.geometry_asset_id); + if (geometryAsset) { + let geometry = geometryCache.get(geometryAsset.asset_id); + if (!geometry) { + geometry = await loadGlb(currentFiles, geometryAsset.uri); + applyAssetTransform(geometry, geometryAsset.asset_to_scene); + geometryCache.set(geometryAsset.asset_id, geometry); + } + const renderGeometry = geometry.clone(true); + renderGeometry.traverse((child) => { + if (child instanceof THREE.Mesh) child.material = materialFor(definition); + }); + group.add(renderGeometry); + } + const edgeAsset = currentManifest.edge_assets.find((asset) => asset.asset_id === definition.edge_asset_id); + if (edgeAsset) { + let edge = edgeCache.get(edgeAsset.asset_id); + if (!edge) { + edge = await loadGlb(currentFiles, edgeAsset.uri); + applyAssetTransform(edge, edgeAsset.asset_to_scene); + edge.traverse((child) => { if (child instanceof THREE.LineSegments) child.material = new THREE.LineBasicMaterial({ color: '#6e7e92', transparent: true, opacity: 0.58 }); }); + edgeCache.set(edgeAsset.asset_id, edge); + } + const edgeInstance = edge.clone(true); + edgeInstance.visible = true; + group.add(edgeInstance); + } + const sidecar = sidecarForDefinition(definition); + if (sidecar) { + const vertices = sidecar.entities.filter((entity) => entity.kind === 'vertex'); + if (vertices.length) { + const positions = new Float32Array(vertices.length * 3); + vertices.forEach((entity, index) => { + const position = entity.geometry.position as Vec3; + positions.set([position[0] / 1000, position[1] / 1000, position[2] / 1000], index * 3); + }); + const vertexGeometry = new THREE.BufferGeometry(); + vertexGeometry.setAttribute('position', new THREE.BufferAttribute(positions, 3)); + const vertexPoints = new THREE.Points( + vertexGeometry, + new THREE.PointsMaterial({ color: '#d8ff83', size: 7, sizeAttenuation: false, depthWrite: false, transparent: true, opacity: 0.48 }), + ); + vertexPoints.visible = selectionMode === 'vertex'; + vertexPoints.userData.pickRole = 'vertex'; + vertexPoints.userData.vertexEntityIds = vertices.map((entity) => entity.entity_id); + group.add(vertexPoints); + } + } + return group; +} + +function applySelectionModeVisibility(): void { + for (const object of nodeObjects.values()) { + object.traverse((child) => { + if (child.userData.pickRole === 'vertex') child.visible = selectionMode === 'vertex'; + }); + } +} + +function clearModel(): void { + clearSelectionOverlay(); + const disposedGeometries = new Set(); + const disposedMaterials = new Set(); + const dispose = (root: THREE.Object3D): void => { + root.traverse((object) => { + if (object instanceof THREE.Mesh || object instanceof THREE.LineSegments || object instanceof THREE.Points) { + if (!disposedGeometries.has(object.geometry)) { + object.geometry.dispose(); + disposedGeometries.add(object.geometry); + } + const materials = Array.isArray(object.material) ? object.material : [object.material]; + materials.forEach((material) => { + if (!disposedMaterials.has(material)) { + material.dispose(); + disposedMaterials.add(material); + } + }); + } + }); + }; + for (const object of geometryCache.values()) dispose(object); + for (const object of edgeCache.values()) dispose(object); + while (modelRoot.children.length) { + const child = modelRoot.children[0]; + dispose(child); + modelRoot.remove(child); + } + nodeObjects.clear(); + nodeRows.clear(); + geometryCache.clear(); + edgeCache.clear(); + entityCache.clear(); + currentModel = null; + sourceFiles.clear(); + featureRows.clear(); + featureTree.innerHTML = '
Open a model-backed scene to inspect its operations.
'; + featureCount.textContent = '0'; + selectedNodeId = null; + selectedFeatureId = null; +} + +function renderTree(manifest: SceneManifest): void { + tree.replaceChildren(); + nodeRows.clear(); + const byParent = new Map(); + for (const node of manifest.nodes) byParent.set(node.parent_node_id, [...(byParent.get(node.parent_node_id) ?? []), node]); + for (const nodes of byParent.values()) nodes.sort((a, b) => a.order - b.order); + const append = (parent: HTMLElement, parentId: string | null, depth: number): void => { + for (const node of byParent.get(parentId) ?? []) { + const row = document.createElement('button'); + row.className = 'tree-row'; + row.style.setProperty('--depth', String(depth)); + row.dataset.nodeId = node.node_id; + row.classList.toggle('hidden-node', !node.visible); + const hasChildren = (byParent.get(node.node_id)?.length ?? 0) > 0; + const isAssembly = node.definition_id.includes('/assembly/'); + row.innerHTML = `${iconMarkup(hasChildren ? 'ChevronDown' : 'CircleDot', 'tree-chevron-icon')}${iconMarkup(isAssembly ? 'Boxes' : 'Box', 'tree-type-icon')}${escapeHtml(node.name || node.node_id.split('/').pop() || node.node_id)}${iconMarkup(node.visible ? 'Eye' : 'EyeOff', 'tree-visibility-icon')}`; + renderIcons(row); + row.addEventListener('click', () => { + if (!canSelectNode(node.node_id)) { + setStatus('Occurrence is hidden or not selectable', true); + return; + } + selectNode(node.node_id); + highlightComponent(node.node_id); + }); + row.querySelector('.tree-visibility')!.addEventListener('click', (event) => { + event.stopPropagation(); + toggleNodeVisibility(node.node_id); + }); + parent.append(row); + nodeRows.set(node.node_id, row); + append(parent, node.node_id, depth + 1); + } + }; + append(tree, null, 0); +} + +function featureIconName(op: string): keyof typeof lucideIcons { + if (/^(make_box|make_cylinder|make_sphere|make_cone|make_torus|make_(?:rounded_)?box)/.test(op)) return 'Box'; + if (/^(union|fuse|compound|assemble|combine)/.test(op)) return 'Combine'; + if (/^(cut|subtract|difference)/.test(op)) return 'Scissors'; + if (/^(add|make_|extrude|revolve|loft|sweep|fillet|chamfer|shell|offset|thicken)/.test(op)) return 'Plus'; + if (/^(transform|translate|rotate|scale|mirror)/.test(op)) return 'Rotate3d'; + if (/^(select|query|filter|where|geo_)/.test(op)) return 'GitBranch'; + if (/^(sketch|profile|wire|face)/.test(op)) return 'Layers'; + return 'LayoutDashboard'; +} + +function featureLabel(feature: ModelNode): string { + return feature.display?.label || feature.op.replace(/^make_/, '').replace(/_r(.*)$/, ' $1').replaceAll('_', ' '); +} + +function renderFeatureTree(): void { + featureRows.clear(); + featureTree.replaceChildren(); + const model = currentModel; + if (!model || model.graph.nodes.length === 0) { + featureTree.innerHTML = '
This package has no embedded operation tree.
'; + featureCount.textContent = '0'; + return; + } + + const byId = new Map(model.graph.nodes.map((feature) => [feature.node_id, feature])); + const consumers = new Set(); + for (const feature of model.graph.nodes) for (const input of feature.inputs) if (byId.has(input)) consumers.add(input); + const roots = (model.leaf_ids ?? []).filter((id) => byId.has(id)); + if (!roots.length) roots.push(...model.graph.nodes.filter((feature) => !consumers.has(feature.node_id)).map((feature) => feature.node_id)); + const rendered = new Set(); + + const append = (parent: HTMLElement, featureId: string, depth: number, path: Set): void => { + const feature = byId.get(featureId); + if (!feature || path.has(featureId) || rendered.has(featureId)) return; + rendered.add(featureId); + const children = feature.inputs.filter((input) => byId.has(input)); + const row = document.createElement('button'); + row.className = 'tree-row feature-tree-row'; + row.style.setProperty('--depth', String(depth)); + row.dataset.featureId = feature.node_id; + row.classList.toggle('selected', feature.node_id === selectedFeatureId); + row.innerHTML = `${iconMarkup(children.length ? 'ChevronDown' : 'CircleDot', 'tree-chevron-icon')}${iconMarkup(featureIconName(feature.op), 'tree-type-icon')}${escapeHtml(featureLabel(feature))}${feature.tags?.length ? 'TAG' : ''}`; + renderIcons(row); + row.addEventListener('click', () => selectFeature(feature.node_id)); + parent.append(row); + featureRows.set(feature.node_id, row); + if (children.length) { + const childList = document.createElement('div'); + childList.className = 'feature-tree-children'; + parent.append(childList); + const nextPath = new Set(path).add(featureId); + for (const childId of children) append(childList, childId, depth + 1, nextPath); + } + }; + + for (const rootId of roots) append(featureTree, rootId, 0, new Set()); + for (const feature of model.graph.nodes) append(featureTree, feature.node_id, 0, new Set()); + featureCount.textContent = String(model.graph.nodes.length); +} + +function escapeHtml(value: string): string { return value.replace(/[&<>'"]/g, (character) => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' })[character] || character); } + +function sourceCodeMarkup(source: OperationSource): string { + if (!source.path) return `
Source path is unavailable.
${escapeHtml(source.call_text)}
`; + const content = sourceFiles.get(source.path); + if (content === undefined) return `
Embedded source file is missing.
${escapeHtml(source.call_text)}
`; + const lines = content.split(/\r?\n/); + const start = Math.max(1, source.line); + const end = Math.max(start, source.end_line); + return `
${escapeHtml(source.path)}:${start}${end !== start ? `-${end}` : ''}
${lines.map((line, index) => {
+    const lineNumber = index + 1;
+    const active = lineNumber >= start && lineNumber <= end;
+    return `${lineNumber}${escapeHtml(line) || ' '}`;
+  }).join('\n')}
`; +} + +function pythonLiteral(value: unknown): string { + if (value === null) return 'None'; + if (value === true) return 'True'; + if (value === false) return 'False'; + if (typeof value === 'number') return Number.isFinite(value) ? String(value) : 'None'; + return JSON.stringify(String(value)); +} + +function jsonText(value: unknown): string { + const rendered = JSON.stringify(value, null, 2); + return rendered === undefined ? 'null' : rendered; +} + +function entityCenter(entity: Entity): Vec3 | null { + const value = entity.kind === 'vertex' ? entity.properties.position : entity.properties.centroid; + return Array.isArray(value) && value.length === 3 && value.every((item) => typeof item === 'number') ? value as Vec3 : null; +} + +function entityMeasure(entity: Entity): [string, number] | null { + const path = entity.kind === 'solid' ? 'geom.volume' : entity.kind === 'face' ? 'geom.area' : entity.kind === 'edge' ? 'geom.length' : null; + const value = entity.kind === 'solid' ? entity.properties.volume : entity.kind === 'face' ? entity.properties.area : entity.kind === 'edge' ? entity.properties.length : null; + return path && typeof value === 'number' ? [path, value] : null; +} + +function selectionSourceForEntity(entity: Entity): SourceRecord { + const source = entity.source; + if (!currentModel || typeof source.node_id !== 'string') return source; + const byId = new Map(currentModel.graph.nodes.map((node) => [node.node_id, node])); + const visited = new Set(); + const findSolidProducer = (nodeId: string): ModelNode | null => { + if (visited.has(nodeId)) return null; + visited.add(nodeId); + const node = byId.get(nodeId); + if (!node) return null; + if (/(?:_rsolid|_rshape|_rcompound)$/.test(node.op)) return node; + for (const input of node.inputs) { + const producer = findSolidProducer(input); + if (producer) return producer; + } + return null; + }; + const producer = findSolidProducer(source.node_id); + return producer ? { ...source, node_id: producer.node_id, output_slot: 0 } : source; +} + +function qlSelectorForEntity(entity: Entity, sidecar: EntitySidecar): { expression: string; unique: boolean } { + type Fact = { expression: string; matches: (candidate: Entity) => boolean }; + const candidates = sidecar.entities.filter((candidate) => candidate.kind === entity.kind); + const facts: Fact[] = []; + for (const tag of entity.evaluated_tags) { + facts.push({ expression: `Q.tag(${pythonLiteral(tag)})`, matches: (candidate) => candidate.evaluated_tags.includes(tag) }); + } + const geometryType = entity.geometry.type; + if (typeof geometryType === 'string' && !geometryType.startsWith('other_') && geometryType !== 'brep_solid' && geometryType !== 'point') { + const expected = geometryType.toUpperCase().replace(/^BSPLINE_(CURVE|SURFACE)$/, 'BSPLINE'); + facts.push({ expression: `Q.prop("geom.type", "==", ${pythonLiteral(expected)})`, matches: (candidate) => candidate.geometry.type === geometryType }); + } + const measure = entityMeasure(entity); + if (measure) { + facts.push({ expression: `Q.prop(${pythonLiteral(measure[0])}, "==", ${pythonLiteral(measure[1])})`, matches: (candidate) => entityMeasure(candidate)?.[1] === measure[1] }); + } + const center = entityCenter(entity); + if (center) { + (['x', 'y', 'z'] as const).forEach((axis, index) => facts.push({ + expression: `Q.prop("geom.center.${axis}", "==", ${pythonLiteral(center[index])})`, + matches: (candidate) => entityCenter(candidate)?.[index] === center[index], + })); + } + let matches = candidates; + const selectedFacts: Fact[] = []; + for (const fact of facts) { + const narrowed = matches.filter(fact.matches); + if (narrowed.length < matches.length) { + selectedFacts.push(fact); + matches = narrowed; + } + if (matches.length === 1) break; + } + const unique = matches.length === 1 && matches[0].entity_id === entity.entity_id; + const factory = entity.kind === 'solid' ? 'Q.ShapeSelector(target_kind="solid")' : `Q.${entity.kind}s()`; + const source = selectionSourceForEntity(entity); + const lines = [factory]; + if (typeof source.node_id === 'string') lines.push(`.from_source(${pythonLiteral(source.node_id)}, ${typeof source.output_slot === 'number' ? source.output_slot : 0})`); + if (selectedFacts.length === 1) lines.push(`.where(${selectedFacts[0].expression})`); + if (selectedFacts.length > 1) lines.push(`.where(Q.and_(\n ${selectedFacts.map((fact) => fact.expression).join(',\n ')}\n))`); + lines.push('.exactly(1)'); + return { expression: `(${lines.map((line) => ` ${line}`).join('\n')}\n)`, unique }; +} + +function metadataSection(title: string, value: Record | undefined): string { + if (!value || Object.keys(value).length === 0) return ''; + return `
${escapeHtml(title)}
${escapeHtml(jsonText(value))}
`; +} + +function sourceIdentityRows(source: SourceRecord | undefined): string { + if (!source) return '
Source
unavailable
'; + const rows: Array<[string, unknown]> = [['Kind', source.kind]]; + if (source.graph_id) rows.push(['Graph', source.graph_id]); + if (source.node_id) rows.push(['Operation', source.node_id]); + if (typeof source.output_slot === 'number') rows.push(['Output slot', source.output_slot]); + if (source.semantic_type) rows.push(['Semantic type', source.semantic_type]); + if (source.semantic_id) rows.push(['Semantic ID', source.semantic_id]); + if (source.source_id) rows.push(['Source ID', source.source_id]); + if (source.root_id) rows.push(['Root ID', source.root_id]); + if (source.topo_id) rows.push(['Topology ID', source.topo_id]); + return rows.map(([label, value]) => `
${escapeHtml(label)}
${escapeHtml(String(value))}
`).join(''); +} + +function sidecarForDefinition(definition: Definition | undefined): EntitySidecar | null { + if (!currentManifest || !currentFiles || !definition?.entity_asset_id) return null; + const asset = currentManifest.entity_assets.find((item) => item.entity_asset_id === definition.entity_asset_id); + if (!asset) return null; + const cached = entityCache.get(asset.entity_asset_id); + if (cached) return cached; + const parsed = JSON.parse(strFromU8(bytesFor(currentFiles, asset.uri))) as EntitySidecar; + entityCache.set(asset.entity_asset_id, parsed); + return parsed; +} + +function selectNode(nodeId: string, entityId?: string): void { + if (!currentManifest) return; + if (!canSelectNode(nodeId)) { + setStatus('Occurrence is hidden or not selectable', true); + return; + } + clearSelectionOverlay(); + selectedNodeId = nodeId; + selectedFeatureId = null; + for (const [id, row] of nodeRows) row.classList.toggle('selected', id === nodeId); + for (const row of featureRows.values()) row.classList.remove('selected'); + const node = currentManifest.nodes.find((item) => item.node_id === nodeId); + if (!node) return; + const definition = manifestByDefinition.get(node.definition_id); + selectionKind.textContent = definition?.kind.toUpperCase() ?? 'NODE'; + const sidecar = sidecarForDefinition(definition); + const entity = sidecar?.entities.find((item) => item.entity_id === entityId); + const solid = sidecar?.entities.find((item) => item.kind === 'solid'); + selectionKind.textContent = entity?.kind.toUpperCase() ?? definition?.kind.toUpperCase() ?? 'NODE'; + const evaluated = entity ?? solid; + const inspected = evaluated; + const measure = inspected ? entityMeasure(inspected) : null; + const measureLabel = inspected?.kind === 'face' ? 'Area' : inspected?.kind === 'edge' ? 'Length' : inspected?.kind === 'vertex' ? 'Position' : 'Volume'; + const measureUnit = inspected?.kind === 'face' ? 'mm²' : inspected?.kind === 'edge' ? 'mm' : inspected?.kind === 'vertex' ? 'mm' : 'mm³'; + const measureValue = inspected?.kind === 'vertex' ? (inspected.properties.position as Vec3 | undefined)?.map(formatNumber).join(', ') : formatNumber(measure?.[1]); + const componentPath = Array.isArray(node.source.component_path) && node.source.component_path.length ? node.source.component_path.join(' / ') : 'root'; + const tags = inspected?.evaluated_tags ?? []; + const selector = inspected && sidecar ? qlSelectorForEntity(inspected, sidecar) : null; + const iconName: keyof typeof lucideIcons = inspected?.kind === 'face' + ? 'ScanFace' + : inspected?.kind === 'edge' + ? 'Spline' + : inspected?.kind === 'vertex' + ? 'CircleDot' + : definition?.kind === 'assembly' + ? 'Boxes' + : 'Box'; + const occurrenceObject = nodeObjects.get(nodeId); + const effectiveVisible = occurrenceObject ? isEffectivelyVisible(occurrenceObject) : node.visible; + details.innerHTML = ` +
${iconMarkup(iconName, 'detail-icon-svg')}
${escapeHtml(entity ? `${node.name || node.node_id} / ${entity.entity_id}` : node.name || node.node_id)}${escapeHtml(node.definition_id)}
+
OCCURRENCE
Node
${escapeHtml(node.node_id)}
Component path
${escapeHtml(componentPath)}
Visibility
${effectiveVisible ? 'Visible' : 'Hidden'}
Selectable
${canSelectNode(nodeId) ? 'Yes' : 'No'}
Order
${node.order}
+ ${evaluated ? `
${entity ? 'EVALUATED ENTITY' : 'EVALUATED BODY'}
Entity
${escapeHtml(evaluated.entity_id)}
Topology
${escapeHtml(evaluated.kind)}
Geometry
${escapeHtml(String(evaluated.geometry.type ?? 'unknown'))}
${measureLabel}
${escapeHtml(measureValue || 'n/a')} ${measureUnit}
Quality
${escapeHtml(String(evaluated.properties.quality ?? 'evaluated'))}
Render
${escapeHtml(evaluated.render_status)}
Parents
${evaluated.parent_entity_ids.length}
Children
${evaluated.child_entity_ids.length}
` : ''} +
NAMING & SOURCE
Definition
${escapeHtml(node.definition_id)}
${sourceIdentityRows(definition?.source)}${inspected ? sourceIdentityRows(inspected.source) : ''}
+ ${inspected ? `
TAGS${tags.length ? `
${tags.map((tag) => `${escapeHtml(tag)}`).join('')}
` : '

No evaluated tags.

'}
` : ''} + ${selector ? `
UNIQUE QL SELECTOR${selector.unique ? `` : ''}
${selector.unique ? `
${escapeHtml(selector.expression)}
` : '

The exported tags and geometric facts do not uniquely identify this entity. No selector was fabricated.

'}
` : ''} + ${inspected ? metadataSection('ENTITY METADATA', inspected.sdk_metadata) : ''} + ${metadataSection('DEFINITION METADATA', definition?.sdk_metadata)} + ${metadataSection('OCCURRENCE METADATA', node.sdk_metadata)} +
DEFINITION
Type
${escapeHtml(definition?.kind ?? 'unknown')}
Geometry
${definition?.geometry_asset_id ? 'GLB / cached' : 'none'}
Entity sidecar
${definition?.entity_asset_id ? 'available' : 'none'}
`; + renderIcons(details); + const copyButton = details.querySelector('#copy-ql'); + copyButton?.addEventListener('click', async () => { + if (!selector?.unique) return; + try { + await navigator.clipboard.writeText(selector.expression); + copyButton.innerHTML = `${iconMarkup('CopyCheck', 'button-icon')}COPIED`; + renderIcons(copyButton); + window.setTimeout(() => { + copyButton.innerHTML = `${iconMarkup('Copy', 'button-icon')}COPY`; + renderIcons(copyButton); + }, 1200); + } catch { + setStatus('Clipboard access was denied', currentManifest !== null); + } + }); + if (entityId) highlightEntity(nodeId, entityId); +} + +function selectFeature(featureId: string): void { + if (!currentModel) return; + clearSelectionOverlay(); + selectedFeatureId = featureId; + selectedNodeId = null; + for (const row of nodeRows.values()) row.classList.remove('selected'); + for (const [id, row] of featureRows) row.classList.toggle('selected', id === featureId); + const feature = currentModel.graph.nodes.find((item) => item.node_id === featureId); + if (!feature) return; + selectionKind.textContent = 'FEATURE'; + const inputs = feature.inputs.length ? feature.inputs.join(', ') : 'none'; + const assignment = feature.source?.assignment_targets.length ? feature.source.assignment_targets.join(', ') : 'unassigned'; + details.innerHTML = `
${iconMarkup(featureIconName(feature.op), 'detail-icon-svg')}
${escapeHtml(featureLabel(feature))}${escapeHtml(feature.node_id)}
OPERATION
Function
${escapeHtml(feature.op)}
Category
${escapeHtml(feature.display?.category || 'operation')}
Assigned to
${escapeHtml(assignment)}
Inputs
${escapeHtml(inputs)}
Output count
${feature.output_count ?? 1}
${feature.source ? `
SOURCE${sourceCodeMarkup(feature.source)}
` : ''}${feature.tags?.length ? `
TAGS
${feature.tags.map((tag) => `${escapeHtml(tag)}`).join('')}
` : ''}
PARAMETERS
${escapeHtml(JSON.stringify(feature.params, null, 2))}
${feature.display?.summary ? `
SUMMARY

${escapeHtml(feature.display.summary)}

` : ''}`; + renderIcons(details); + details.querySelector('#active-source-line')?.scrollIntoView({ block: 'center' }); +} + +function clearFeatureSelection(): void { + if (selectedFeatureId === null) return; + selectedFeatureId = null; + selectionKind.textContent = 'SCENE'; + for (const row of featureRows.values()) row.classList.remove('selected'); + details.innerHTML = `
${iconMarkup('LayoutDashboard', 'empty-state-icon')}Feature selection clearedSelect a Blueprint node to inspect its operation and dependencies.
`; + renderIcons(details); +} + +function clearSelectionOverlay(): void { + for (const overlay of selectedOverlays) { + overlay.parent?.remove(overlay); + overlay.traverse((object) => { + if (!(object instanceof THREE.Mesh || object instanceof THREE.LineSegments || object instanceof THREE.Points || object instanceof LineSegments2)) return; + object.geometry.dispose(); + const material = object.material; + (Array.isArray(material) ? material : [material]).forEach((item) => item.dispose()); + }); + } + selectedOverlays = []; +} + +function isEffectivelyVisible(object: THREE.Object3D): boolean { + for (let current: THREE.Object3D | null = object; current; current = current.parent) { + if (!current.visible) return false; + if (current === modelRoot) break; + } + return true; +} + +function canSelectNode(nodeId: string): boolean { + if (!currentManifest) return false; + const byId = new Map(currentManifest.nodes.map((node) => [node.node_id, node])); + for (let currentId: string | null = nodeId; currentId; ) { + const manifestNode = byId.get(currentId); + if (!manifestNode || !manifestNode.visible || !manifestNode.selectable) return false; + currentId = manifestNode.parent_node_id; + } + const object = nodeObjects.get(nodeId); + if (object && !isEffectivelyVisible(object)) return false; + if (object) { + for (let current: THREE.Object3D | null = object; current; current = current.parent) { + const ancestorNodeId = current.userData.nodeId; + if (typeof ancestorNodeId === 'string' && currentManifest.nodes.find((item) => item.node_id === ancestorNodeId)?.selectable === false) return false; + if (current === modelRoot) break; + } + } + return true; +} + +function attachSelectionOverlay(source: THREE.Object3D, overlay: THREE.Object3D): void { + overlay.name = 'selection-overlay'; + overlay.userData.pickable = false; + overlay.renderOrder = 10; + overlay.matrix.copy(source.matrix); + overlay.matrixAutoUpdate = false; + overlay.visible = source.visible; + source.parent?.add(overlay); + selectedOverlays.push(overlay); +} + +function highlightEntity(nodeId: string, entityId?: string): void { + clearSelectionOverlay(); + const node = nodeObjects.get(nodeId); + if (!node) return; + const definition = manifestByDefinition.get(String(node.userData.definitionId)); + const sidecar = sidecarForDefinition(definition); + const entityGroup = entityId ? sidecar?.face_groups.find((group) => group.entity_id === entityId) : undefined; + const edgeGroup = entityId ? sidecar?.edge_groups?.find((group) => group.entity_id === entityId) : undefined; + node.traverse((child) => { + if (selectedOverlays.length || !isEffectivelyVisible(child) || !(child instanceof THREE.Mesh || child instanceof THREE.LineSegments || child instanceof THREE.Points)) return; + const source = child as THREE.Mesh | THREE.LineSegments | THREE.Points; + const range = entityGroup || edgeGroup; + const isVertex = entityId?.startsWith('entity/vertex/') && source instanceof THREE.Points; + if (!range && !isVertex && entityId !== 'entity/solid/0') return; + if (entityGroup && !(source instanceof THREE.Mesh)) return; + if (edgeGroup && !entityGroup && !(source instanceof THREE.LineSegments)) return; + if (isVertex && !source.userData.vertexEntityIds?.includes(entityId)) return; + const material = source instanceof THREE.LineSegments + ? new LineMaterial({ color: '#fff04d', linewidth: 5, worldUnits: false, depthTest: false, transparent: true, opacity: 1 }) + : source instanceof THREE.Points + ? new THREE.PointsMaterial({ color: '#fff04d', size: 16, sizeAttenuation: false, depthTest: false, transparent: true, opacity: 1 }) + : new THREE.MeshBasicMaterial({ color: '#fff04d', transparent: true, opacity: 0.78, depthTest: false, side: THREE.DoubleSide, polygonOffset: true, polygonOffsetFactor: -4, polygonOffsetUnits: -4 }); + let overlay: THREE.Object3D; + if (source instanceof THREE.LineSegments && range) { + const lineGeometry = new LineSegmentsGeometry(); + const positions: number[] = []; + const index = source.geometry.index; + const position = source.geometry.getAttribute('position'); + for (let offset = range.first_index; offset < range.first_index + range.index_count; offset += 2) { + const a = index ? index.getX(offset) : offset; + const b = index ? index.getX(offset + 1) : offset + 1; + positions.push(position.getX(a), position.getY(a), position.getZ(a), position.getX(b), position.getY(b), position.getZ(b)); + } + lineGeometry.setPositions(positions); + const lineMaterial = material as LineMaterial; + lineMaterial.resolution.set(renderer.domElement.clientWidth, renderer.domElement.clientHeight); + overlay = new LineSegments2(lineGeometry, lineMaterial); + } else if (source instanceof THREE.Points) { + const geometry = source.geometry.clone(); + const vertexIndex = source.userData.vertexEntityIds.indexOf(entityId); + geometry.setDrawRange(vertexIndex, 1); + overlay = new THREE.Points(geometry, material as THREE.PointsMaterial); + } else { + const geometry = source.geometry.clone(); + if (range) geometry.setDrawRange(range.first_index, range.index_count); + overlay = new THREE.Mesh(geometry, material as THREE.MeshBasicMaterial); + } + attachSelectionOverlay(source, overlay); + }); +} + +function highlightComponent(nodeId: string): void { + clearSelectionOverlay(); + const node = nodeObjects.get(nodeId); + if (!node) return; + const meshes: THREE.Mesh[] = []; + node.traverse((child) => { + if (child instanceof THREE.Mesh && isEffectivelyVisible(child)) meshes.push(child); + }); + for (const child of meshes) { + const geometry = child.geometry.clone(); + const material = new THREE.MeshBasicMaterial({ color: '#fff04d', transparent: true, opacity: 0.44, depthTest: false, side: THREE.DoubleSide, polygonOffset: true, polygonOffsetFactor: -4, polygonOffsetUnits: -4 }); + const overlay = new THREE.Mesh(geometry, material); + attachSelectionOverlay(child, overlay); + } +} + +function toggleNodeVisibility(nodeId: string): void { + const object = nodeObjects.get(nodeId); + const row = nodeRows.get(nodeId); + if (!object || !row) return; + object.visible = !object.visible; + const manifestNode = currentManifest?.nodes.find((node) => node.node_id === nodeId); + if (manifestNode) manifestNode.visible = object.visible; + row.classList.toggle('hidden-node', !object.visible); + const visibility = row.querySelector('.tree-visibility'); + if (visibility) { + visibility.innerHTML = iconMarkup(object.visible ? 'Eye' : 'EyeOff', 'tree-visibility-icon'); + visibility.setAttribute('aria-label', object.visible ? 'Hide occurrence' : 'Show occurrence'); + visibility.setAttribute('title', object.visible ? 'Hide occurrence' : 'Show occurrence'); + renderIcons(visibility); + } + if (selectedNodeId && !canSelectNode(selectedNodeId)) { + clearSelectionOverlay(); + selectedNodeId = null; + selectedFeatureId = null; + for (const selectedRow of nodeRows.values()) selectedRow.classList.remove('selected'); + for (const row of featureRows.values()) row.classList.remove('selected'); + selectionKind.textContent = 'SCENE'; + details.innerHTML = `
${iconMarkup('EyeOff', 'empty-state-icon')}Selection hiddenThe selected occurrence is hidden or no longer selectable.
`; + renderIcons(details); + } else if (!isEffectivelyVisible(object)) { + clearSelectionOverlay(); + } +} + +function formatNumber(value: unknown): string { return typeof value === 'number' ? new Intl.NumberFormat('en-US', { maximumFractionDigits: 2 }).format(value) : 'n/a'; } + +function frameModel(): void { + const box = new THREE.Box3().setFromObject(modelRoot); + if (box.isEmpty()) return; + const center = box.getCenter(new THREE.Vector3()); + const size = box.getSize(new THREE.Vector3()); + const radius = Math.max(size.length() * 0.5, 0.01); + const direction = new THREE.Vector3(1, 0.78, 1).normalize(); + camera.position.copy(center).add(direction.multiplyScalar(radius * 2.4)); + camera.near = Math.max(radius / 100, 0.001); + camera.far = Math.max(radius * 100, 10); + camera.updateProjectionMatrix(); + controls.target.copy(center); + controls.maxDistance = radius * 12; + controls.update(); +} + +async function loadPackage(files: PackageFiles): Promise { + const manifest = JSON.parse(strFromU8(bytesFor(files, 'scene.json'))) as SceneManifest; + if (manifest.schema_version !== '1.0') throw new Error(`unsupported scene schema: ${manifest.schema_version}`); + await validatePackageMembers(files, manifest); + currentManifest = manifest; + currentFiles = files; + manifestByDefinition.clear(); + appearanceById.clear(); + for (const definition of manifest.definitions) manifestByDefinition.set(definition.definition_id, definition); + for (const appearance of manifest.appearances) appearanceById.set(appearance.appearance_id, appearance); + clearModel(); + if (manifest.source.embedded_artifact_uri === 'model/model.json') { + currentModel = JSON.parse(strFromU8(bytesFor(files, 'model/model.json'))) as ModelDocument; + for (const sourceFile of manifest.source.source_files ?? []) { + sourceFiles.set(sourceFile.path, strFromU8(bytesFor(files, sourceFile.uri))); + } + } + renderTree(manifest); + renderFeatureTree(); + sceneTitle.textContent = manifest.scene_id.replaceAll('-', ' '); + nodeCount.textContent = String(manifest.nodes.length); + packageMeta.textContent = `${manifest.generator.profile} · ${manifest.generator.simplecadapi_version} · ${manifest.coordinate_system.length_unit}`; + const nodesByParent = new Map(); + for (const node of manifest.nodes) nodesByParent.set(node.parent_node_id, [...(nodesByParent.get(node.parent_node_id) ?? []), node]); + const build = async (parent: THREE.Object3D, parentId: string | null): Promise => { + for (const node of (nodesByParent.get(parentId) ?? []).sort((a, b) => a.order - b.order)) { + const definition = manifestByDefinition.get(node.definition_id); + if (!definition) continue; + const object = new THREE.Group(); + object.name = node.node_id; + object.matrixAutoUpdate = false; + object.matrix.copy(placementMatrix(node.transform)); + object.visible = node.visible; + object.userData.nodeId = node.node_id; + object.userData.definitionId = node.definition_id; + nodeObjects.set(node.node_id, object); + parent.add(object); + if (definition.kind !== 'assembly') { + const instance = await instantiateDefinition(definition); + instance.traverse((child) => { child.userData.nodeId = node.node_id; child.userData.definitionId = node.definition_id; }); + object.add(instance); + } + await build(object, node.node_id); + } + }; + await build(modelRoot, null); + frameModel(); + loading.classList.add('hidden'); + setStatus(`${manifest.nodes.length} occurrences · ${manifest.geometry_assets.length} geometry asset${manifest.geometry_assets.length === 1 ? '' : 's'} · ${currentModel?.graph.nodes.length ?? 0} features`, true); +} + +function unzipPackage(raw: Uint8Array): PackageFiles { + if (raw.byteLength > MAX_PACKAGE_BYTES) throw new Error('scene package exceeds browser size limit'); + let memberCount = 0; + let unpackedBytes = 0; + const seenNames = new Set(); + unzipSync(raw, { filter: (entry) => { + validateMemberName(entry.name); + const foldedName = entry.name.toLowerCase(); + if (seenNames.has(foldedName)) throw new Error(`duplicate or case-colliding scene package member: ${entry.name}`); + seenNames.add(foldedName); + memberCount += 1; + unpackedBytes += entry.originalSize; + if (entry.compression !== 0 && entry.compression !== 8) throw new Error(`unsupported ZIP compression method: ${entry.name}`); + if (memberCount > MAX_PACKAGE_MEMBERS) throw new Error('scene package member count is invalid'); + if (entry.originalSize > MAX_MEMBER_BYTES) throw new Error(`scene package member exceeds browser size limit: ${entry.name}`); + if (entry.name === 'scene.json' && entry.originalSize > MAX_SCENE_JSON_BYTES) throw new Error('scene.json is missing or too large'); + if (unpackedBytes > MAX_UNPACKED_BYTES) throw new Error('scene package expands beyond browser size limit'); + if (entry.originalSize > MAX_COMPRESSION_RATIO * Math.max(1, entry.size)) throw new Error(`scene package member compression ratio is too high: ${entry.name}`); + return false; + }}); + if (memberCount === 0 || !seenNames.has('scene.json')) throw new Error('scene.json is missing or too large'); + if (unpackedBytes > MAX_COMPRESSION_RATIO * raw.byteLength) throw new Error('scene package compression ratio is too high'); + const files = unzipSync(raw) as PackageFiles; + const entries = Object.entries(files); + if (entries.length !== memberCount) throw new Error('scene package member count changed during extraction'); + const total = entries.reduce((sum, [, value]) => sum + value.byteLength, 0); + if (total !== unpackedBytes) throw new Error('scene package decoded size differs from ZIP metadata'); + const scene = files['scene.json']; + if (!scene || scene.byteLength > MAX_SCENE_JSON_BYTES) throw new Error('scene.json is missing or too large'); + return files; +} + +document.querySelector('#fit-button')!.addEventListener('click', frameModel); +const selectionModeButtons = Array.from(document.querySelectorAll('.selection-mode')); +for (const button of selectionModeButtons) { + button.addEventListener('click', () => { + selectionMode = button.dataset.selectionMode as SelectionMode; + selectionModeButtons.forEach((item) => item.classList.toggle('active', item === button)); + clearSelectionOverlay(); + applySelectionModeVisibility(); + setStatus(`Selection intent: ${selectionMode.toUpperCase()}`, currentManifest !== null); + }); +} +const fileInput = document.querySelector('#file-input')!; +document.querySelector('#open-button')!.addEventListener('click', () => fileInput.click()); +const setNavigatorTab = (tab: 'components' | 'features'): void => { + const features = tab === 'features'; + componentsTab.classList.toggle('active', !features); + featuresTab.classList.toggle('active', features); + componentsTab.setAttribute('aria-selected', String(!features)); + featuresTab.setAttribute('aria-selected', String(features)); + componentsView.hidden = features; + featuresView.hidden = !features; +}; +componentsTab.addEventListener('click', () => setNavigatorTab('components')); +featuresTab.addEventListener('click', () => setNavigatorTab('features')); +fileInput.addEventListener('change', async () => { + const file = fileInput.files?.[0]; + if (!file) return; + try { + if (file.size > MAX_PACKAGE_BYTES) throw new Error('scene package exceeds browser size limit'); + await loadPackage(unzipPackage(new Uint8Array(await file.arrayBuffer()))); + } catch (error) { setStatus(error instanceof Error ? error.message : 'Unable to open package'); loading.classList.add('hidden'); } +}); + +viewport.addEventListener('dragover', (event) => { + event.preventDefault(); + viewport.classList.add('drop-target'); +}); +viewport.addEventListener('dragleave', () => viewport.classList.remove('drop-target')); +viewport.addEventListener('drop', async (event) => { + event.preventDefault(); + viewport.classList.remove('drop-target'); + const file = event.dataTransfer?.files[0]; + if (!file) return; + try { + if (file.size > MAX_PACKAGE_BYTES) throw new Error('scene package exceeds browser size limit'); + loading.classList.remove('hidden'); + setStatus('Opening scene package'); + await loadPackage(unzipPackage(new Uint8Array(await file.arrayBuffer()))); + } catch (error) { + setStatus(error instanceof Error ? error.message : 'Unable to open package'); + loading.classList.add('hidden'); + } +}); + +function resize(): void { + const width = viewport.clientWidth; + const height = viewport.clientHeight; + renderer.setSize(width, height, false); + camera.aspect = width / Math.max(height, 1); + camera.updateProjectionMatrix(); + for (const overlay of selectedOverlays) { + overlay.traverse((object) => { + if (object instanceof LineSegments2) object.material.resolution.set(width, height); + }); + } +} +new ResizeObserver(resize).observe(viewport); +resize(); +const raycaster = new THREE.Raycaster(); +const pointer = new THREE.Vector2(); +let selectionPointerActive = false; + +function pointerRay(event: PointerEvent): void { + const bounds = renderer.domElement.getBoundingClientRect(); + pointer.x = ((event.clientX - bounds.left) / bounds.width) * 2 - 1; + pointer.y = -((event.clientY - bounds.top) / bounds.height) * 2 + 1; + raycaster.setFromCamera(pointer, camera); + const distance = camera.position.distanceTo(controls.target); + const worldPerPixel = 2 * Math.tan(THREE.MathUtils.degToRad(camera.fov * 0.5)) * distance / Math.max(bounds.height, 1); + raycaster.params.Line.threshold = worldPerPixel * 5; + raycaster.params.Points.threshold = worldPerPixel * 8; +} + +function validSelectionHit(event: PointerEvent): THREE.Intersection | null { + pointerRay(event); + const hits = raycaster.intersectObjects(modelRoot.children, true).filter((hit) => { + if (hit.object.userData.pickable === false || !isEffectivelyVisible(hit.object)) return false; + const nodeId = hit.object.userData.nodeId; + return typeof nodeId === 'string' && canSelectNode(nodeId); + }); + const meshHit = hits.find((hit) => hit.object instanceof THREE.Mesh); + if (selectionMode === 'component' || selectionMode === 'solid' || selectionMode === 'face') { + return meshHit ?? null; + } + const candidate = hits.find((hit) => selectionMode === 'edge' ? hit.object instanceof THREE.LineSegments : hit.object instanceof THREE.Points); + if (!candidate) return null; + if (!meshHit) return candidate; + const occlusionAllowance = selectionMode === 'edge' ? raycaster.params.Line.threshold : raycaster.params.Points.threshold; + return candidate.distance <= meshHit.distance + occlusionAllowance * 1.5 ? candidate : null; +} + +function commitSelection(event: PointerEvent): void { + const hit = validSelectionHit(event); + if (!hit) { + setStatus(`No ${selectionMode} at pointer`, currentManifest !== null); + return; + } + const nodeId = hit?.object.userData.nodeId; + const definitionId = hit?.object.userData.definitionId; + if (typeof nodeId !== 'string' || typeof definitionId !== 'string') return; + const definition = manifestByDefinition.get(definitionId); + const sidecar = sidecarForDefinition(definition); + if (selectionMode === 'component') { + selectNode(nodeId); + highlightComponent(nodeId); + return; + } + if (selectionMode === 'solid') { + selectNode(nodeId, 'entity/solid/0'); + return; + } + let entityId: string | undefined; + if (selectionMode === 'face' && hit.object instanceof THREE.Mesh && typeof hit.faceIndex === 'number') { + const triangleOffset = hit.faceIndex * 3; + entityId = sidecar?.face_groups.find((group) => triangleOffset >= group.first_index && triangleOffset < group.first_index + group.index_count)?.entity_id; + } else if (selectionMode === 'edge' && hit.object instanceof THREE.LineSegments && typeof hit.index === 'number') { + entityId = sidecar?.edge_groups?.find((group) => hit.index! >= group.first_index && hit.index! < group.first_index + group.index_count)?.entity_id; + } else if (selectionMode === 'vertex' && hit.object instanceof THREE.Points && typeof hit.index === 'number') { + entityId = hit.object.userData.vertexEntityIds?.[hit.index]; + } + if (entityId) { + selectNode(nodeId, entityId); + } else { + setStatus(`No ${selectionMode} at pointer`, true); + } +} + +bindClickSelection({ + element: renderer.domElement, + camera, + controls, + onPointerStateChange: (active) => { selectionPointerActive = active; }, + onClick: commitSelection, +}); +renderer.domElement.addEventListener('pointermove', (event) => { + if (selectionPointerActive) return; + renderer.domElement.style.cursor = validSelectionHit(event) ? 'crosshair' : 'default'; +}); +renderer.domElement.addEventListener('pointerleave', () => { + renderer.domElement.style.cursor = 'default'; +}); +renderer.setAnimationLoop(() => { controls.update(); renderer.render(threeScene, camera); }); + +loading.classList.add('hidden'); +details.innerHTML = `
${iconMarkup('PackageOpen', 'empty-state-icon')}Choose a scene packageOpen a .scene.zip file or drop one into the viewport.
`; +renderIcons(details); diff --git a/SimpleCADAPI/viewer/src/style.css b/SimpleCADAPI/viewer/src/style.css new file mode 100644 index 0000000..c117f90 --- /dev/null +++ b/SimpleCADAPI/viewer/src/style.css @@ -0,0 +1,101 @@ +:root { color: #e7edf5; background: #0b0e12; font-family: 'Space Grotesk', system-ui, sans-serif; font-synthesis: none; } +* { box-sizing: border-box; } +* { scrollbar-width: none; -ms-overflow-style: none; } +*::-webkit-scrollbar { display: none; width: 0; height: 0; } +body { margin: 0; min-width: 320px; min-height: 100vh; overflow: hidden; background: #0b0e12; } +body.resizing-panels { cursor: col-resize; user-select: none; } +body.resizing-dock { cursor: row-resize; user-select: none; } +button, select { font: inherit; } +.ui-icon, .button-icon { display: block; flex: 0 0 auto; width: 13px; height: 13px; } +.shell { display: grid; grid-template-rows: 64px minmax(0, 1fr) 32px; height: 100vh; } +.topbar { display: flex; align-items: center; justify-content: space-between; padding: 0 24px; border-bottom: 1px solid #202832; background: #10141a; } +.brand, .top-actions, .brand > div { display: flex; align-items: center; } +.brand { gap: 10px; letter-spacing: -.02em; } +.brand-mark { display: grid; place-items: center; width: 30px; height: 30px; border: 1px solid #b3e36b; color: #b3e36b; font: 500 11px 'DM Mono', monospace; transform: rotate(-7deg); } +.brand strong { display: block; font-size: 14px; line-height: 15px; } +.brand span:not(.brand-mark) { display: block; color: #758397; font-size: 10px; letter-spacing: .08em; text-transform: uppercase; } +.top-actions { gap: 18px; } +.case-picker { display: flex; align-items: center; gap: 9px; color: #738093; font: 500 10px 'DM Mono', monospace; } +.case-picker select { max-width: 230px; padding: 7px 25px 7px 9px; border: 1px solid #2a3440; border-radius: 3px; color: #dce5ee; background: #151b22; font: 12px 'DM Mono', monospace; } +.quiet-button, .viewport-tools button { border: 1px solid #303b48; border-radius: 3px; color: #aebdcd; background: #151b22; cursor: pointer; font: 500 10px 'DM Mono', monospace; letter-spacing: .04em; } +.quiet-button { display: inline-flex; align-items: center; gap: 7px; padding: 8px 11px; } +.quiet-button:hover, .viewport-tools button:hover { border-color: #b3e36b; color: #b3e36b; } +.workspace { --navigator-width: 256px; --inspector-width: 278px; display: grid; grid-template-columns: var(--navigator-width) 7px minmax(0, 1fr) 7px var(--inspector-width); min-width: 0; min-height: 0; overflow: hidden; } +.panel { display: flex; min-width: 0; min-height: 0; flex-direction: column; overflow: hidden; background: #10141a; } +.panel > .panel-heading { flex: 0 0 auto; } +.tree-panel { grid-column: 1; } +.viewport-wrap { grid-column: 3; } +.details-panel { grid-column: 5; } +.panel-resizer { position: relative; z-index: 4; min-width: 0; cursor: col-resize; touch-action: none; background: #10141a; } +.panel-resizer::before { position: absolute; top: 0; bottom: 0; left: 3px; width: 1px; content: ''; background: #202832; transition: background .15s, box-shadow .15s; } +.panel-resizer:hover::before, .panel-resizer.dragging::before, .panel-resizer:focus-visible::before { background: #b3e36b; box-shadow: 0 0 8px rgba(179,227,107,.5); } +#navigator-resizer { grid-column: 2; } +#inspector-resizer { grid-column: 4; } +.panel-heading { display: flex; align-items: flex-start; justify-content: space-between; min-height: 80px; padding: 21px 17px 15px; border-bottom: 1px solid #202832; } +.eyebrow { display: block; color: #718096; font: 500 9px 'DM Mono', monospace; letter-spacing: .15em; } +h1 { margin: 7px 0 0; color: #eef4fb; font-size: 17px; font-weight: 600; letter-spacing: -.04em; text-transform: capitalize; } +.count { min-width: 27px; padding: 4px 6px; border: 1px solid #2b3540; border-radius: 3px; color: #8190a3; text-align: center; font: 10px 'DM Mono', monospace; } +.tree { min-height: 0; flex: 1 1 auto; padding: 12px 0; overflow: auto; overscroll-behavior: contain; } +.tree-empty { padding: 24px 16px; color: #718094; font: 10px/1.6 'DM Mono', monospace; } +.tree-row { display: flex; align-items: center; gap: 7px; width: 100%; padding: 8px 12px 8px calc(12px + var(--depth) * 16px); border: 0; border-left: 2px solid transparent; color: #94a1b2; background: transparent; text-align: left; cursor: pointer; } +.tree-row:hover { color: #edf3f9; background: #171d24; } +.tree-row.selected { border-left-color: #b3e36b; color: #f2f8fb; background: #1a2324; } +.tree-row.hidden-node { color: #4d5a68; }.tree-row.hidden-node .tree-visibility { color: #b3e36b; opacity: .55; } +.tree-chevron { display: grid; flex: 0 0 10px; place-items: center; width: 10px; color: #697889; } +.tree-chevron-icon { display: block; width: 10px; height: 10px; } +.tree-glyph { display: grid; flex: 0 0 14px; place-items: center; width: 14px; color: #b3e36b; } +.tree-type-icon { display: block; width: 14px; height: 14px; } +.tree-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; } +.tree-visibility { display: grid; flex: 0 0 18px; place-items: center; margin-left: auto; color: #43505e; } +.tree-visibility-icon { display: block; width: 14px; height: 14px; } +.navigator-tabs { display: grid; grid-template-columns: 1fr 1fr; border-bottom: 1px solid #202832; background: #0d1218; } +.navigator-tab { display: flex; align-items: center; justify-content: center; gap: 6px; min-width: 0; padding: 10px 8px; border: 0; border-bottom: 2px solid transparent; color: #718094; background: transparent; cursor: pointer; font: 10px 'DM Mono', monospace; text-transform: uppercase; } +.navigator-tab:hover { color: #dce5ee; background: #151b22; } +.navigator-tab.active { border-bottom-color: #b3e36b; color: #b3e36b; background: #151d1b; } +.navigator-tab .count { min-width: 22px; padding: 3px 5px; font-size: 9px; } +.tab-icon { display: flex; flex: 0 0 auto; } +.tab-icon svg { display: block; width: 13px; height: 13px; } +.navigator-view { display: flex; min-width: 0; min-height: 0; flex: 1 1 auto; flex-direction: column; overflow: hidden; } +.navigator-view[hidden] { display: none; } +.feature-tree { padding-top: 12px; } +.feature-tree-row { padding-right: 10px; } +.feature-tree-children { display: block; } +.feature-glyph { color: #91b8ed; } +.feature-tree-row.selected .feature-glyph { color: #b3e36b; } +.feature-tag-mark { margin-left: auto; color: #718094; font: 8px 'DM Mono', monospace; letter-spacing: .06em; } +.viewport-wrap { display: block; position: relative; min-width: 0; min-height: 0; overflow: hidden; background: radial-gradient(circle at 52% 44%, #18222a 0, #0d1218 47%, #0b0e12 100%); } +.viewport-stage { position: absolute; inset: 0; min-width: 0; min-height: 0; overflow: hidden; } +.viewport { position: absolute; inset: 0; overflow: hidden; } +.viewport.drop-target { outline: 2px solid #b3e36b; outline-offset: -8px; } +.viewport canvas { display: block; width: 100%; height: 100%; } +.viewport::after { position: absolute; inset: 0; pointer-events: none; content: ''; background: linear-gradient(90deg, rgba(179,227,107,.04) 1px, transparent 1px), linear-gradient(rgba(179,227,107,.04) 1px, transparent 1px); background-size: 64px 64px; mask-image: linear-gradient(to bottom, transparent 0, rgba(0,0,0,.45) 70%, transparent 100%); } +.hud { position: absolute; z-index: 2; left: 17px; bottom: 16px; display: flex; align-items: center; gap: 7px; padding: 7px 9px; border: 1px solid #27323d; border-radius: 3px; color: #8898aa; background: rgba(14, 19, 24, .8); font: 10px 'DM Mono', monospace; backdrop-filter: blur(8px); } +.status-dot { width: 6px; height: 6px; border-radius: 50%; background: #697583; }.status-dot.ready { background: #b3e36b; box-shadow: 0 0 10px #b3e36b; } +.viewport-tools { position: absolute; z-index: 2; top: 15px; right: 15px; display: flex; align-items: stretch; gap: 7px; } +.viewport-tools button { display: inline-flex; align-items: center; justify-content: center; gap: 5px; padding: 7px 9px; font-size: 9px; } +.viewport-tools button.active { border-color: #526d40; color: #b3e36b; background: #18221c; } +.selection-tools { display: flex; align-items: center; padding-left: 8px; border: 1px solid #303b48; border-radius: 3px; background: rgba(21, 27, 34, .92); } +.selection-tools .tool-label { margin-right: 4px; color: #657386; font: 8px 'DM Mono', monospace; letter-spacing: .08em; } +.selection-tools .selection-mode { border-width: 0 0 0 1px; border-radius: 0; background: transparent; } +.selection-tools .selection-mode:first-of-type { border-left: 0; } +.loading { position: absolute; z-index: 3; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 13px; color: #94a5b7; font: 11px 'DM Mono', monospace; background: rgba(11,14,18,.75); transition: opacity .2s; }.loading.hidden { opacity: 0; pointer-events: none; } +.spinner { width: 18px; height: 18px; border: 1px solid #364352; border-top-color: #b3e36b; border-radius: 50%; animation: spin .7s linear infinite; }@keyframes spin { to { transform: rotate(360deg); } } +.tag { padding: 4px 6px; border: 1px solid #39484d; border-radius: 3px; color: #b3e36b; background: #18221c; font: 9px 'DM Mono', monospace; } +.details { min-width: 0; min-height: 0; flex: 1 1 auto; overflow: auto; overscroll-behavior: contain; padding: 20px 17px; font-size: 13px; }.empty-state { display: flex; flex-direction: column; gap: 8px; padding: 34px 4px; color: #718094; font-size: 13px; line-height: 1.55; }.empty-state strong { color: #bbc7d4; font-size: 15px; }.empty-cross { display: block; width: 28px; height: 28px; color: #536475; }.empty-state-icon { display: block; width: 28px; height: 28px; } +.detail-title { display: flex; gap: 11px; min-width: 0; padding-bottom: 19px; border-bottom: 1px solid #202832; }.detail-icon { display: block; flex: 0 0 auto; width: 24px; color: #b3e36b; }.detail-icon-svg { display: block; width: 24px; height: 24px; }.detail-title > div { min-width: 0; }.detail-title > div > strong, .detail-title > div > span { display: block; }.detail-title strong { margin: 1px 0 6px; overflow-wrap: anywhere; color: #edf3f9; font-size: 16px; line-height: 1.35; }.detail-title > div > span { color: #718096; font: 11px/1.5 'DM Mono', monospace; }.detail-definition-path { overflow-wrap: anywhere; word-break: break-word; } +.detail-section { padding: 19px 0 4px; }.detail-section-heading { display: flex; align-items: center; justify-content: space-between; gap: 10px; }.detail-section dl { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.5fr); gap: 10px 12px; margin: 13px 0 0; font: 11px/1.45 'DM Mono', monospace; }.detail-section dt { color: #657386; }.detail-section dd { min-width: 0; margin: 0; overflow-wrap: anywhere; color: #c5d0dc; text-align: right; word-break: break-word; }.path-value { white-space: normal; }.detail-muted, .selector-warning { color: #718094; font: 11px/1.6 'DM Mono', monospace; }.selector-warning { color: #c19a62; } +.params { max-height: 260px; margin: 12px 0 0; padding: 10px; overflow: auto; border: 1px solid #202832; color: #a9c77b; background: #0b0e12; font: 9px/1.5 'DM Mono', monospace; white-space: pre-wrap; word-break: break-word; } +.source-file-path { margin-top: 12px; overflow-wrap: anywhere; color: #91b8ed; font: 9px/1.5 'DM Mono', monospace; } +.source-code { max-height: 360px; margin: 8px 0 0; padding: 8px 0; overflow: auto; border: 1px solid #202832; color: #b8c4d1; background: #090c10; font: 10px/1.55 'DM Mono', monospace; tab-size: 4; } +.source-code code { display: block; min-width: max-content; } +.source-line { display: grid; grid-template-columns: 42px minmax(max-content, 1fr); min-height: 16px; } +.source-line.active { color: #f2f7ee; background: rgba(179, 227, 107, .13); box-shadow: inset 2px 0 #b3e36b; } +.source-line-number { padding-right: 10px; color: #465363; text-align: right; user-select: none; } +.source-line.active .source-line-number { color: #9abf66; } +.source-line-text { padding-right: 12px; white-space: pre; } +.selector-code { max-height: 240px; margin: 12px 0 0; padding: 10px; overflow: auto; border: 1px solid #39484d; color: #b3e36b; background: #101a16; font: 10px/1.55 'DM Mono', monospace; white-space: pre-wrap; word-break: break-word; }.copy-button { display: inline-flex; align-items: center; gap: 5px; padding: 5px 7px; border: 1px solid #39484d; border-radius: 3px; color: #b3e36b; background: #18221c; cursor: pointer; font: 9px 'DM Mono', monospace; letter-spacing: .08em; }.copy-button:hover { border-color: #b3e36b; } +.tag-list { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 11px; }.tag-list span { padding: 4px 6px; border: 1px solid #39484d; border-radius: 3px; color: #b3e36b; background: #18221c; font: 8px 'DM Mono', monospace; } +.detail-summary { color: #aebdcd; font: 12px/1.6 'DM Mono', monospace; } +.footer { display: flex; justify-content: space-between; align-items: center; padding: 0 17px; border-top: 1px solid #202832; color: #657487; background: #10141a; font: 9px 'DM Mono', monospace; }.footer-note { color: #4d5a68; } +@media (max-width: 900px) { body { overflow: auto; }.shell { height: auto; min-height: 100vh; grid-template-rows: 64px auto 32px; }.topbar { padding: 0 14px; }.case-picker { display: none; }.workspace { grid-template-columns: 210px minmax(0, 1fr); grid-template-rows: minmax(620px, 78vh) auto; overflow: visible; }.panel-resizer { display: none; }.tree-panel { grid-column: 1; }.viewport-wrap { grid-column: 2; min-height: 620px; }.details-panel { grid-column: 1 / -1; border-top: 1px solid #202832; }.details { max-height: 55vh; } } +@media (max-width: 560px) { .top-actions { gap: 0; }.workspace { grid-template-columns: 1fr; grid-template-rows: auto minmax(420px, 62vh) auto; }.tree-panel { grid-column: 1; grid-row: 1; border-bottom: 1px solid #202832; }.tree { max-height: 210px; }.viewport-wrap { grid-column: 1; grid-row: 2; }.details-panel { grid-column: 1; grid-row: 3; }.footer-note { display: none; }.viewport-tools { left: 10px; right: 10px; overflow-x: auto; }.selection-tools { flex: 0 0 auto; }.viewport-tools > #fit-button { flex: 0 0 auto; } } diff --git a/SimpleCADAPI/viewer/tsconfig.json b/SimpleCADAPI/viewer/tsconfig.json new file mode 100644 index 0000000..9b74c1f --- /dev/null +++ b/SimpleCADAPI/viewer/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "isolatedModules": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src/**/*.ts"] +} diff --git a/SimpleCADAPI/viewer/vite.config.ts b/SimpleCADAPI/viewer/vite.config.ts new file mode 100644 index 0000000..c049f46 --- /dev/null +++ b/SimpleCADAPI/viewer/vite.config.ts @@ -0,0 +1,3 @@ +import { defineConfig } from 'vite'; + +export default defineConfig({}); diff --git a/cad-agent-studio/src/app/api/tasks/[taskId]/exports/[format]/route.ts b/cad-agent-studio/src/app/api/tasks/[taskId]/exports/[format]/route.ts new file mode 100644 index 0000000..a741289 --- /dev/null +++ b/cad-agent-studio/src/app/api/tasks/[taskId]/exports/[format]/route.ts @@ -0,0 +1,41 @@ +import { NextRequest, NextResponse } from "next/server"; +import { + exportRobotDescription, + type RobotExportFormat, +} from "@/lib/robot-export"; + +export const runtime = "nodejs"; + +export async function POST( + _request: NextRequest, + context: { + params: Promise<{ taskId: string; format: string }>; + }, +) { + const { taskId, format } = await context.params; + if (format !== "urdf" && format !== "mjcf") { + return NextResponse.json( + { error: "Export format must be urdf or mjcf." }, + { status: 400 }, + ); + } + try { + return NextResponse.json( + await exportRobotDescription(taskId, format as RobotExportFormat), + { + headers: { + "Cache-Control": "no-store", + }, + }, + ); + } catch (error) { + return NextResponse.json( + { + error: error instanceof Error + ? error.message + : "Robot description export failed.", + }, + { status: 500 }, + ); + } +} diff --git a/cad-agent-studio/src/components/agent-studio.tsx b/cad-agent-studio/src/components/agent-studio.tsx index c8d8ee2..212a954 100644 --- a/cad-agent-studio/src/components/agent-studio.tsx +++ b/cad-agent-studio/src/components/agent-studio.tsx @@ -22,6 +22,7 @@ import { Check, CircleAlert, FileImage, + FileJson2, FileUp, Loader2, MessageSquare, @@ -60,6 +61,8 @@ type ViewerContext = { type CadResult = { taskId: string; sourcePath?: string; + sourceUrl?: string; + editableParameters?: Array>; artifactPath?: string; artifactUrl?: string; previewPath?: string; @@ -81,6 +84,19 @@ type CadError = { message: string; }; +type RobotExportFormat = "urdf" | "mjcf"; + +type RobotExportResult = { + status: "generated" | "cached"; + format: RobotExportFormat; + taskId: string; + packagePath: string; + packageUrl: string; + descriptionPath: string; + descriptionUrl: string; + warnings: string[]; +}; + type PublicConfig = { defaultProvider: string; defaultModel: string; @@ -131,10 +147,27 @@ function extractCadResult(text: string) { } } +function taskArtifactUrl(taskId: string, relativePath: string) { + if (!taskId || !relativePath) return ""; + return `/api/tasks/${encodeURIComponent(taskId)}/artifacts/${relativePath + .split(/[\\/]+/) + .map((part) => encodeURIComponent(part)) + .join("/")}`; +} + function setIfChanged(setter: (value: string | ((current: string) => string)) => void, nextValue = "") { setter((current) => (current === nextValue ? current : nextValue)); } +function triggerArtifactDownload(url: string) { + const link = document.createElement("a"); + link.href = url; + link.download = ""; + document.body.appendChild(link); + link.click(); + link.remove(); +} + function UploadedAttachmentCard({ attachment }: { attachment: Attachment }) { return (
@@ -169,6 +202,11 @@ function CadProgressCard({ data }: DataMessagePartProps) { } function CadArtifactCard({ data }: DataMessagePartProps) { + const editableParameters: Array> = Array.isArray(data.editableParameters) + ? data.editableParameters + : []; + const sourceDownloadUrl = data.sourceUrl + || taskArtifactUrl(data.taskId, data.sourcePath || ""); return (
@@ -180,10 +218,46 @@ function CadArtifactCard({ data }: DataMessagePartProps) { {data.sourcePath ?
{data.sourcePath}
: null} {data.artifactPath ?
{data.artifactPath}
: null}
- {data.artifactUrl ? ( - - 下载 STEP - +
+ {data.artifactUrl ? ( + + 下载 STEP + + ) : null} + {sourceDownloadUrl ? ( + + 下载 DesignIR JSON + + ) : null} +
+ {editableParameters.length ? ( +
+
+ 已验证可编辑参数({editableParameters.length}) +
+
+ {editableParameters.map((parameter) => { + const range = ( + parameter.validated_range + && typeof parameter.validated_range === "object" + ? parameter.validated_range + : {} + ) as Record; + const minimum = range.minimum_tested_inclusive; + const maximum = range.maximum_tested_inclusive; + const unit = String(parameter.unit || ""); + return ( +
+ {String(parameter.name)} + {` = ${String(parameter.value)}${unit ? ` ${unit}` : ""}`} + {typeof minimum === "number" && typeof maximum === "number" + ? `(已验证 ${minimum}–${maximum}${unit ? ` ${unit}` : ""})` + : ""} +
+ ); + })} +
+
) : null}
); @@ -201,6 +275,32 @@ function CadErrorCard({ data }: DataMessagePartProps) { ); } +function RobotExportCard({ data }: DataMessagePartProps) { + return ( +
+
+ + {data.format.toUpperCase()} 导出完成 + + {data.status === "cached" ? "已复用" : "新生成"} + +
+ + 下载 {data.format.toUpperCase()} 可移植包 + + {data.warnings?.length ? ( +
+ {data.warnings.join(" ")} +
+ ) : null} +
+ ); +} + function CadToolCallCard(props: ToolCallMessagePartProps) { const status = String(props.status?.type || props.status || ""); return ( @@ -236,6 +336,7 @@ function MessageText() { "cad-progress": CadProgressCard, "cad-result": CadArtifactCard, "cad-error": CadErrorCard, + "robot-export": RobotExportCard, }, Fallback: DataFallback, }, @@ -292,6 +393,9 @@ export function AgentStudio() { const [currentTaskId, setCurrentTaskId] = useState(""); const [previewUrl, setPreviewUrl] = useState(""); const [artifactUrl, setArtifactUrl] = useState(""); + const [sourceUrl, setSourceUrl] = useState(""); + const [editableParameters, setEditableParameters] = useState>>([]); + const [robotExporting, setRobotExporting] = useState(""); const [artifactPath, setArtifactPath] = useState(""); const [viewerAssetUrl, setViewerAssetUrl] = useState(""); const [activeEditToolId, setActiveEditToolId] = useState(""); @@ -307,8 +411,20 @@ export function AgentStudio() { setIfChanged(setCurrentTaskId, result.taskId); setIfChanged(setArtifactPath, result.artifactPath || ""); setIfChanged(setArtifactUrl, result.artifactUrl || ""); + setIfChanged( + setSourceUrl, + result.sourceUrl || taskArtifactUrl( + result.taskId, + result.sourcePath || "", + ), + ); setIfChanged(setPreviewUrl, result.previewUrl || ""); setIfChanged(setViewerAssetUrl, result.viewerAssetUrl || ""); + setEditableParameters( + Array.isArray(result.editableParameters) + ? result.editableParameters + : [], + ); setActiveEditToolId(""); setAiSelectionMode("point"); }, []); @@ -322,9 +438,41 @@ export function AgentStudio() { if (dataPart.type === "data-cad-error") { const data = dataPart.data as CadError; setError(data.message || "CAD 生成失败"); + return; + } + if (dataPart.type === "data-robot-export") { + const data = dataPart.data as RobotExportResult; + if (data.packageUrl) { + triggerArtifactDownload(data.packageUrl); + } } }, [applyCadResult]); + const handleRobotExport = useCallback(async (format: RobotExportFormat) => { + if (!currentTaskId || robotExporting) return; + setError(""); + setRobotExporting(format); + try { + const response = await fetch( + `/api/tasks/${encodeURIComponent(currentTaskId)}/exports/${format}`, + { method: "POST" }, + ); + const result = await response.json() as RobotExportResult & { error?: string }; + if (!response.ok || !result.packageUrl) { + throw new Error(result.error || `${format.toUpperCase()} 转换失败`); + } + triggerArtifactDownload(result.packageUrl); + } catch (nextError) { + setError( + nextError instanceof Error + ? nextError.message + : `${format.toUpperCase()} 转换失败`, + ); + } finally { + setRobotExporting(""); + } + }, [currentTaskId, robotExporting]); + const transport = useMemo(() => new AssistantChatTransport({ api: "/api/chat", body: { @@ -359,6 +507,64 @@ export function AgentStudio() { }); }, []); + useEffect(() => { + const taskId = new URLSearchParams(window.location.search) + .get("taskId") + ?.trim(); + if (taskId && /^[a-zA-Z0-9_-]{4,80}$/.test(taskId)) { + setCurrentTaskId(taskId); + } + }, []); + + useEffect(() => { + if (!currentTaskId) return; + const url = new URL(window.location.href); + if (url.searchParams.get("taskId") !== currentTaskId) { + url.searchParams.set("taskId", currentTaskId); + window.history.replaceState({}, "", url); + } + }, [currentTaskId]); + + useEffect(() => { + if (!currentTaskId) { + setEditableParameters([]); + setSourceUrl(""); + return; + } + void fetch(`/api/tasks/${encodeURIComponent(currentTaskId)}`) + .then((response) => response.json()) + .then((task) => { + const manifest = ( + task?.manifest && typeof task.manifest === "object" + ? task.manifest + : {} + ) as Record; + const sourcePath = String(manifest.source?.path || ""); + if (sourcePath) { + setSourceUrl(taskArtifactUrl(currentTaskId, sourcePath)); + } + const latestArtifact = task?.latestArtifact; + if (latestArtifact?.path && latestArtifact?.url) { + setArtifactPath(String(latestArtifact.path)); + setArtifactUrl(String(latestArtifact.url)); + setPreviewUrl(String(latestArtifact.url)); + } + const latestViewerAsset = task?.latestViewerAsset; + if (latestViewerAsset?.url) { + setViewerAssetUrl(String(latestViewerAsset.url)); + } + setEditableParameters( + Array.isArray(manifest.parameters?.parameters) + ? manifest.parameters.parameters + : [], + ); + }) + .catch(() => { + // The generation result still supplies the artifact links when task + // metadata is temporarily unavailable. + }); + }, [currentTaskId]); + const providerModels = useMemo(() => { const models = config?.providers?.[DEEPSEEK_PROVIDER_ID]?.models || {}; return Object.entries(models); @@ -634,15 +840,87 @@ export function AgentStudio() {
)} - {artifactUrl ? ( - - - STEP - + {artifactUrl || sourceUrl ? ( +
+ {sourceUrl ? ( + + + JSON + + ) : null} + {artifactUrl ? ( + + + STEP + + ) : null} + + +
+ ) : null} + {editableParameters.length ? ( +
+ + 可编辑参数 {editableParameters.length} 个 + +
+ {editableParameters.map((parameter) => { + const range = ( + parameter.validated_range + && typeof parameter.validated_range === "object" + ? parameter.validated_range + : {} + ) as Record; + const minimum = range.minimum_tested_inclusive; + const maximum = range.maximum_tested_inclusive; + const unit = String(parameter.unit || ""); + return ( +
+ {String(parameter.name)} + {` = ${String(parameter.value)}${unit ? ` ${unit}` : ""}`} + {typeof minimum === "number" && typeof maximum === "number" + ? `,范围 ${minimum}–${maximum}${unit ? ` ${unit}` : ""}` + : ""} +
+ ); + })} +
+ 这里只展示通过独立扰动验收的安全参数;其他推断参数仍保留在 DesignIR JSON 中。 +
+
+
) : null} diff --git a/cad-agent-studio/src/lib/cad-generator.ts b/cad-agent-studio/src/lib/cad-generator.ts index b9dd056..20133bf 100644 --- a/cad-agent-studio/src/lib/cad-generator.ts +++ b/cad-agent-studio/src/lib/cad-generator.ts @@ -24,6 +24,8 @@ export type CadGenerationResult = { viewerAssetPath: string; viewerAssetUrl: string; sourcePath: string; + sourceUrl: string; + editableParameters: Array>; summary: string; }; @@ -56,6 +58,59 @@ function surfaceirScript() { return enginePath("designir-pipeline", "scripts", "surfaceir_pipeline.py"); } +function semanticEditableParameters( + designir: Record, +): Array> { + const parameters = designir.semantic_layer?.parameters; + if (!parameters || typeof parameters !== "object") return []; + return Object.entries(parameters) + .filter(([, value]) => ( + value + && typeof value === "object" + && (value as Record).editable === true + )) + .map(([name, value]) => ({ + name, + ...(value as Record), + })); +} + +function catalogEditableParameters( + catalog: Record, +): Array> { + return Array.isArray(catalog.parameters) + ? catalog.parameters.filter( + (value): value is Record => ( + value !== null && typeof value === "object" + ), + ) + : []; +} + +function parameterSummary( + parameters: Array>, +) { + if (!parameters.length) return "当前没有通过验收的可编辑参数。"; + const rows = parameters.map((parameter) => { + const range = ( + parameter.validated_range + && typeof parameter.validated_range === "object" + ? parameter.validated_range + : {} + ) as Record; + const minimum = range.minimum_tested_inclusive; + const maximum = range.maximum_tested_inclusive; + const unit = String(parameter.unit || ""); + const rangeText = ( + typeof minimum === "number" && typeof maximum === "number" + ? `,已验证范围 ${minimum}–${maximum}${unit ? ` ${unit}` : ""}` + : "" + ); + return `${String(parameter.name)}=${String(parameter.value)}${unit ? ` ${unit}` : ""}${rangeText}`; + }); + return `已验证可修改参数:${rows.join(";")}。`; +} + async function runJson( command: string, args: string[], @@ -81,6 +136,16 @@ async function runJson( throw new Error("CAD worker did not return a valid JSON result."); } +function selectedBackendFromRoute(route: Record) { + const selected = String(route.selected_backend || ""); + if (selected !== "build123d" && selected !== "simplecadapi") { + throw new Error( + `CAD Router did not return a supported backend: ${selected || ""}`, + ); + } + return selected; +} + export async function executeDesignIR({ prompt, summary, @@ -118,7 +183,7 @@ export async function executeDesignIR({ [routerScript(), prompt], task.dir, ); - const selectedBackend = String(route.selected_backend || "build123d"); + const selectedBackend = selectedBackendFromRoute(route); const normalizedDesignIR = { ...designir, backend_hint: selectedBackend, @@ -243,6 +308,11 @@ export async function executeDesignIR({ project: String(route.project || "text-to-cad"), fallback_order: Array.isArray(route.fallback_order) ? route.fallback_order : [], workflow_profiles: Array.isArray(route.workflow_profiles) ? route.workflow_profiles : [], + routing_policy: ( + route.routing_policy && typeof route.routing_policy === "object" + ? route.routing_policy + : {} + ), }, source: { path: sourcePath, @@ -263,6 +333,7 @@ export async function executeDesignIR({ }); const stepUrl = artifactUrl(task.taskId, artifactPath, artifactVersion); + const editableParameters = semanticEditableParameters(normalizedDesignIR); return { handled: true, taskId: task.taskId, @@ -273,7 +344,12 @@ export async function executeDesignIR({ viewerAssetPath, viewerAssetUrl: artifactUrl(task.taskId, viewerAssetPath, artifactVersion), sourcePath, - summary: summary || "CAD model generated from semantic DesignIR 3.0.", + sourceUrl: artifactUrl(task.taskId, sourcePath, artifactVersion), + editableParameters, + summary: [ + summary || "CAD model generated from semantic DesignIR 3.0.", + parameterSummary(editableParameters), + ].join(" "), }; } @@ -314,6 +390,7 @@ export async function reconstructUploadedStep({ if ( execution.status !== "accepted" && execution.status !== "accepted_empty_source" + && execution.status !== "review_required" ) { throw new Error( `DesignIR 3.0 reconstruction was not accepted: ${JSON.stringify(execution)}`, @@ -333,6 +410,9 @@ export async function reconstructUploadedStep({ const parametersPath = relativeArtifact(execution.parameters); const geometryReportPath = relativeArtifact(execution.geometry_report); const editReportPath = relativeArtifact(execution.edit_report); + const brepReportPath = execution.brep_report + ? relativeArtifact(execution.brep_report) + : null; const parameterCatalog = JSON.parse( await fs.readFile(path.join(task.dir, parametersPath), "utf8"), ) as Record; @@ -350,10 +430,36 @@ export async function reconstructUploadedStep({ ? execution.experience : {} ) as Record; + const strictGeometryAccepted = execution.geometry_accepted === true; + const usableGeometry = ( + strictGeometryAccepted || execution.geometry_usable === true + ); + if (!usableGeometry) { + throw new Error( + `DesignIR 3.0 reconstruction did not produce usable geometry: ${JSON.stringify(execution)}`, + ); + } + const qualityTier = String(execution.quality_tier || "review_required"); + const geometryValidationSummary = qualityTier === "brep_exact" + ? "Independent geometry and strict B-Rep topology acceptance passed." + : strictGeometryAccepted + ? `Independent geometry acceptance passed; B-Rep quality tier is ${qualityTier}.` + : "Independent reconstruction passed the usable-geometry contract and requires visual review; strict 1:1 acceptance did not pass."; + const reconstructionArtifacts = [ + { path: sourcePath, role: "editable_contract", kind: "designir-3.0" }, + { path: artifactPath, role: "primary", kind: "step" }, + { path: parametersPath, role: "validated_parameters", kind: "json" }, + { path: geometryReportPath, role: "geometry_acceptance", kind: "json" }, + { path: editReportPath, role: "parameter_acceptance", kind: "json" }, + ...(brepReportPath + ? [{ path: brepReportPath, role: "strict_brep_acceptance", kind: "json" }] + : []), + { path: viewerAssetPath, role: "viewer", kind: "glb" }, + ]; await upsertManifest(task.taskId, { request: prompt, route: { - selected_backend: String(route.selected_backend || "build123d"), + selected_backend: selectedBackendFromRoute(route), runner_skill: "cad-router", project: "text-to-cad", execution_adapter: "DesignIR 3.0 / SurfaceIR", @@ -363,6 +469,11 @@ export async function reconstructUploadedStep({ workflow_profiles: Array.isArray(route.workflow_profiles) ? route.workflow_profiles : [], + routing_policy: ( + route.routing_policy && typeof route.routing_policy === "object" + ? route.routing_policy + : {} + ), }, source: { path: sourcePath, @@ -372,14 +483,7 @@ export async function reconstructUploadedStep({ generated_by: "cad-router_uploaded_step_execute", teacher_embedded: false, }, - artifacts: [ - { path: sourcePath, role: "editable_contract", kind: "designir-3.0" }, - { path: artifactPath, role: "primary", kind: "step" }, - { path: parametersPath, role: "validated_parameters", kind: "json" }, - { path: geometryReportPath, role: "geometry_acceptance", kind: "json" }, - { path: editReportPath, role: "parameter_acceptance", kind: "json" }, - { path: viewerAssetPath, role: "viewer", kind: "glb" }, - ], + artifacts: reconstructionArtifacts, parameters: parameterCatalog, experience: { context_kind: "promoted_surfaceir_runtime", @@ -390,7 +494,7 @@ export async function reconstructUploadedStep({ "Uploaded STEP was used only as extraction and acceptance truth.", "DesignIR 3.0 anti-cheat validation passed.", "STEP was independently rebuilt without passing the teacher path to the rebuild worker.", - "Independent geometry acceptance passed.", + geometryValidationSummary, `${Number(execution.validated_parameter_count || 0)} editable parameters passed isolated perturbation acceptance.`, ], studio: { @@ -409,6 +513,7 @@ export async function reconstructUploadedStep({ }); const stepUrl = artifactUrl(task.taskId, artifactPath, artifactVersion); + const editableParameters = catalogEditableParameters(parameterCatalog); return { handled: true, taskId: task.taskId, @@ -423,9 +528,16 @@ export async function reconstructUploadedStep({ artifactVersion, ), sourcePath, + sourceUrl: artifactUrl(task.taskId, sourcePath, artifactVersion), + editableParameters, summary: [ `${teacherName || path.basename(teacherPath)} 已通过 DesignIR 3.0 / SurfaceIR 独立重建。`, - `几何验收通过,已验证可修改参数 ${Number(execution.validated_parameter_count || 0)} 个。`, + strictGeometryAccepted + ? qualityTier === "brep_exact" + ? "严格几何和 B-Rep 拓扑验收通过。" + : `几何验收通过,B-Rep 等级为 ${qualityTier},仍需复核拓扑。` + : "实体几何可用,但未达到严格 1:1,需在预览中复核。", + parameterSummary(editableParameters), ].join(" "), }; } @@ -553,14 +665,24 @@ export async function editDesignIR3Parameter({ stepPath: outputStepPath, }); const artifactVersion = Date.now().toString(36); + const inheritedBackend = String( + sourceManifest.route?.selected_backend || "", + ); + const semanticBackend = ( + inheritedBackend === "simplecadapi" || inheritedBackend === "build123d" + ? inheritedBackend + : "build123d" + ); await upsertManifest(task.taskId, { request: prompt, route: { - selected_backend: "build123d", + selected_backend: semanticBackend, runner_skill: "cad-router", project: "text-to-cad", execution_adapter: "DesignIR 3.0 / SurfaceIR parameter edit", - fallback_order: ["simplecadapi"], + fallback_order: [ + semanticBackend === "simplecadapi" ? "build123d" : "simplecadapi", + ], workflow_profiles: [], }, source: { @@ -603,6 +725,7 @@ export async function editDesignIR3Parameter({ }, }); const stepUrl = artifactUrl(task.taskId, outputStepPath, artifactVersion); + const editableParameters = catalogEditableParameters(parameterCatalog); return { handled: true, taskId: task.taskId, @@ -617,7 +740,16 @@ export async function editDesignIR3Parameter({ artifactVersion, ), sourcePath: outputDesignIRPath, - summary: `${parameter} 已修改为 ${value},并通过独立参数验收。`, + sourceUrl: artifactUrl( + task.taskId, + outputDesignIRPath, + artifactVersion, + ), + editableParameters, + summary: [ + `${parameter} 已修改为 ${value},并通过独立参数验收。`, + parameterSummary(editableParameters), + ].join(" "), }; } diff --git a/cad-agent-studio/src/lib/chat.ts b/cad-agent-studio/src/lib/chat.ts index 6f4ae5e..5e5370e 100644 --- a/cad-agent-studio/src/lib/chat.ts +++ b/cad-agent-studio/src/lib/chat.ts @@ -22,6 +22,7 @@ import { type CadGenerationResult, } from "@/lib/cad-generator"; import { loadLlmConfig, resolveProviderApiKey, selectedModelId, type LlmConfig } from "@/lib/config"; +import { exportRobotDescription } from "@/lib/robot-export"; import { readManifest, taskDir } from "@/lib/task-store"; const execFileAsync = promisify(execFile); @@ -285,6 +286,41 @@ async function buildTaskContext(selectedTaskId?: string) { if (sourcePath && (sourcePath.endsWith(".py") || sourcePath.endsWith(".scad") || sourcePath.endsWith(".json"))) { const absoluteSourcePath = path.join(taskDir(selectedTaskId), sourcePath); sourceText = await fs.readFile(absoluteSourcePath, "utf8").catch(() => ""); + if (sourcePath.endsWith(".json") && sourceText) { + try { + const payload = JSON.parse(sourceText) as Record; + const surfaceLayer = payload.surface_layer; + if ( + surfaceLayer + && typeof surfaceLayer === "object" + && typeof surfaceLayer.data === "string" + ) { + payload.surface_layer = { + format: surfaceLayer.format, + encoding: surfaceLayer.encoding, + uncompressed_bytes: surfaceLayer.uncompressed_bytes, + compressed_bytes: surfaceLayer.compressed_bytes, + sha256: surfaceLayer.sha256, + data: "", + }; + } else if (surfaceLayer && typeof surfaceLayer === "object") { + payload.surface_layer = { + storage: "expanded_surfaceir_omitted_from_llm_context", + solid_count: Array.isArray(surfaceLayer.solids) + ? surfaceLayer.solids.length + : undefined, + free_shell_count: Array.isArray(surfaceLayer.free_shells) + ? surfaceLayer.free_shells.length + : undefined, + surface_vocabulary: surfaceLayer.surface_vocabulary, + curve_vocabulary: surfaceLayer.curve_vocabulary, + }; + } + sourceText = JSON.stringify(payload, null, 2); + } catch { + // Non-DesignIR JSON remains available as a bounded text excerpt. + } + } } return [ `Current task id: ${selectedTaskId}`, @@ -333,6 +369,10 @@ function buildSystemPrompt(viewerContext: unknown[], attachments: unknown[], tas "For selected holes, use the selected reference center/surface/bbox/adjacent selectors to identify the individual feature. If the editable source models a repeated pattern with one shared diameter variable, split out or override the selected instance instead of changing the shared variable.", "Prefer editing the DesignIR recorded in cad-task.json before regenerating STEP and viewer assets.", "If the current task is an uploaded-STEP DesignIR 3.0 surface_parametric reconstruction and the requested change matches a validated parameter, call edit_designir3_parameter.", + "After uploaded-STEP reconstruction, list every perturbation-validated editable parameter by name, current value, unit, and validated range. Never report only the parameter count. Explain that unlisted inferred parameters remain in DesignIR but are not safely exposed until an executable binding and perturbation acceptance exist.", + "When the user asks to download or convert the current model to URDF or MJCF, call export_robot_description. Do not fabricate XML. The tool performs lazy conversion from the current DesignIR and returns a portable ZIP containing the robot description and mesh.", + "After export_robot_description succeeds, the UI starts the download and renders the exact package link. Say that the download has started; do not manually rewrite, shorten, or guess the returned URL.", + "A STEP-derived DesignIR without authoritative link/joint/material evidence exports honestly as one fixed base_link with inertial data omitted. Never invent articulated joints, limits, actuators, density, mass, or inertia.", "If the current task is a text/image DesignIR 3.0 fully_semantic_parametric or hybrid_semantic_surface_parametric model, modify its authoritative semantic_layer and call generate_cad again; preserve unrelated parameters, constraints, and features.", "For every new text/image CAD generation, author complete DesignIR 3.0 with designir_kind=independent_parametric_cad, reconstruction_mode=fully_semantic_parametric, authoring_mode=semantic_feature_program, and semantic_layer containing reconstruction_status, coordinate_system, datums, parameters, expressions, sketches, constraints, features, patterns, attachments, and construction_stages.", "For semantic DesignIR 3.0, edit_interface must contain semantic_parameters, surface_parameter_groups=[], modification_levels=[semantic_feature], and preserved_interfaces. validation_contract must contain source_independence=true, geometry_checks, edit_checks, invariants, perturbations, and thresholds.", @@ -759,6 +799,8 @@ export async function streamAgentResponse({ data: { taskId: cadGeneration.taskId, sourcePath: cadGeneration.sourcePath, + sourceUrl: cadGeneration.sourceUrl, + editableParameters: cadGeneration.editableParameters, artifactPath: cadGeneration.artifactPath, artifactUrl: cadGeneration.artifactUrl, previewPath: cadGeneration.previewPath, @@ -830,6 +872,8 @@ export async function streamAgentResponse({ data: { taskId: cadGeneration.taskId, sourcePath: cadGeneration.sourcePath, + sourceUrl: cadGeneration.sourceUrl, + editableParameters: cadGeneration.editableParameters, artifactPath: cadGeneration.artifactPath, artifactUrl: cadGeneration.artifactUrl, previewPath: cadGeneration.previewPath, @@ -843,6 +887,68 @@ export async function streamAgentResponse({ return cadGeneration; }, }), + export_robot_description: tool({ + description: [ + "Lazily export the current DesignIR 3.0 CAD task as a portable URDF or MJCF ZIP package.", + "The package contains the robot description, a high-resolution STL visual/collision mesh, and an export manifest.", + "Use this when the user asks to download, export, or convert the current model to URDF or MJCF.", + ].join(" "), + inputSchema: z.object({ + format: z.enum(["urdf", "mjcf"]), + taskId: z.string().optional().describe( + "CAD task id. Omit to use the currently selected task.", + ), + }), + execute: async ({ format, taskId }) => { + const targetTaskId = String(taskId || selectedTaskId || "").trim(); + if (!targetTaskId) { + throw new Error("Select or generate a CAD task before robot export."); + } + writeCadProgress({ + step: `export_${format}`, + label: `生成 ${format.toUpperCase()}`, + status: "running", + message: "正在从 DesignIR 独立重建几何并打包机器人描述与网格。", + }); + try { + const exported = await exportRobotDescription( + targetTaskId, + format, + ); + writeCadProgress({ + step: `export_${format}`, + label: `生成 ${format.toUpperCase()}`, + status: "success", + message: exported.status === "cached" + ? "已复用当前 DesignIR 的现有导出包。" + : "机器人描述、网格和导出说明已生成。", + }); + streamWriter?.write({ + type: "data-robot-export", + data: exported, + id: `robot_export_${format}_${targetTaskId}`, + }); + completeAgentStream(`${format.toUpperCase()} 按需导出已完成。`); + return { + ok: true, + ...exported, + downloadStartedByUi: true, + note: "The UI starts the ZIP download and renders the exact package URL; do not rewrite the URL in prose.", + }; + } catch (error) { + const message = error instanceof Error + ? error.message + : `${format.toUpperCase()} export failed.`; + writeCadProgress({ + step: `export_${format}`, + label: `生成 ${format.toUpperCase()}`, + status: "error", + message, + }); + throw error; + } + }, + }), generate_cad: tool({ description: [ "Generate a real CAD model from agent-authored semantic DesignIR 3.0.", @@ -920,6 +1026,8 @@ export async function streamAgentResponse({ data: { taskId: cadGeneration.taskId, sourcePath: cadGeneration.sourcePath, + sourceUrl: cadGeneration.sourceUrl, + editableParameters: cadGeneration.editableParameters, artifactPath: cadGeneration.artifactPath, artifactUrl: cadGeneration.artifactUrl, previewPath: cadGeneration.previewPath, diff --git a/cad-agent-studio/src/lib/preview-artifacts.ts b/cad-agent-studio/src/lib/preview-artifacts.ts index 65ce161..0516f85 100644 --- a/cad-agent-studio/src/lib/preview-artifacts.ts +++ b/cad-agent-studio/src/lib/preview-artifacts.ts @@ -47,6 +47,10 @@ export function contentTypeForArtifact(relativePath: string) { if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg"; if (lower.endsWith(".webp")) return "image/webp"; if (lower.endsWith(".json")) return "application/json"; + if (lower.endsWith(".urdf")) return "application/xml; charset=utf-8"; + if (lower.endsWith(".xml")) return "application/xml; charset=utf-8"; + if (lower.endsWith(".stl")) return "model/stl"; + if (lower.endsWith(".zip")) return "application/zip"; if (lower.endsWith(".py")) return "text/x-python; charset=utf-8"; return "application/octet-stream"; } diff --git a/cad-agent-studio/src/lib/robot-export.ts b/cad-agent-studio/src/lib/robot-export.ts new file mode 100644 index 0000000..18623c2 --- /dev/null +++ b/cad-agent-studio/src/lib/robot-export.ts @@ -0,0 +1,231 @@ +import crypto from "node:crypto"; +import { execFile } from "node:child_process"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { promisify } from "node:util"; +import { artifactUrl, safeArtifactPath } from "@/lib/preview-artifacts"; +import { enginePath } from "@/lib/paths"; +import { + readManifest, + safeTaskId, + taskDir, + upsertManifest, +} from "@/lib/task-store"; + +const execFileAsync = promisify(execFile); + +export type RobotExportFormat = "urdf" | "mjcf"; + +export type RobotExportResult = { + status: "generated" | "cached"; + format: RobotExportFormat; + taskId: string; + packagePath: string; + packageUrl: string; + descriptionPath: string; + descriptionUrl: string; + meshPath: string; + warnings: string[]; + robotStructure: "static_single_link"; +}; + +type ExportState = { + schema_version: "1.0"; + source_sha256: string; + format: RobotExportFormat; + package_path: string; + description_path: string; + mesh_path: string; + warnings: string[]; +}; + +function pythonExecutable() { + return process.env.CAD_PYTHON + || enginePath("text-to-cad", ".venv", "bin", "python"); +} + +function exporterScript() { + return enginePath( + "designir-pipeline", + "scripts", + "robot_description_export.py", + ); +} + +function relativeArtifact(root: string, absolutePath: string) { + const relative = path.relative(root, path.resolve(absolutePath)); + if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) { + throw new Error(`Robot exporter returned an artifact outside the task: ${absolutePath}`); + } + return safeArtifactPath(relative); +} + +async function fileExists(absolutePath: string) { + return fs.access(absolutePath).then(() => true).catch(() => false); +} + +async function readCachedState( + statePath: string, + sourceSha256: string, + format: RobotExportFormat, + root: string, +) { + try { + const state = JSON.parse(await fs.readFile(statePath, "utf8")) as ExportState; + if ( + state.source_sha256 !== sourceSha256 + || state.format !== format + ) { + return null; + } + const required = [ + state.package_path, + state.description_path, + state.mesh_path, + ].map((value) => path.join(root, safeArtifactPath(value))); + return (await Promise.all(required.map(fileExists))).every(Boolean) + ? state + : null; + } catch { + return null; + } +} + +function resultFromState( + taskId: string, + state: ExportState, + status: "generated" | "cached", +): RobotExportResult { + const version = state.source_sha256.slice(0, 16); + return { + status, + format: state.format, + taskId, + packagePath: state.package_path, + packageUrl: artifactUrl(taskId, state.package_path, version), + descriptionPath: state.description_path, + descriptionUrl: artifactUrl(taskId, state.description_path, version), + meshPath: state.mesh_path, + warnings: state.warnings, + robotStructure: "static_single_link", + }; +} + +export async function exportRobotDescription( + requestedTaskId: string, + format: RobotExportFormat, +): Promise { + const taskId = safeTaskId(requestedTaskId); + if (format !== "urdf" && format !== "mjcf") { + throw new Error("Robot export format must be urdf or mjcf."); + } + const manifest = await readManifest(taskId); + if (!manifest) { + throw new Error("The selected CAD task has no manifest."); + } + const sourcePath = safeArtifactPath(String(manifest.source?.path || "")); + if (!sourcePath.toLowerCase().endsWith(".json")) { + throw new Error("Robot export requires a DesignIR 3.0 JSON source."); + } + const root = taskDir(taskId); + const sourceAbsolutePath = path.join(root, sourcePath); + const sourceData = await fs.readFile(sourceAbsolutePath); + const sourceSha256 = crypto + .createHash("sha256") + .update(sourceData) + .digest("hex"); + const exportDir = path.join(root, "exports", format); + const statePath = path.join(exportDir, "export-state.json"); + const cached = await readCachedState( + statePath, + sourceSha256, + format, + root, + ); + if (cached) { + return resultFromState(taskId, cached, "cached"); + } + + await fs.mkdir(exportDir, { recursive: true }); + const { stdout } = await execFileAsync( + pythonExecutable(), + [ + exporterScript(), + "--designir", + sourceAbsolutePath, + "--output-dir", + exportDir, + "--format", + format, + ], + { + cwd: root, + timeout: 900_000, + maxBuffer: 8 * 1024 * 1024, + }, + ); + const jsonLine = stdout + .trim() + .split(/\r?\n/) + .reverse() + .find((line) => line.trim().startsWith("{")); + if (!jsonLine) { + throw new Error("Robot exporter did not return a JSON result."); + } + const worker = JSON.parse(jsonLine) as Record; + const packagePath = relativeArtifact(root, String(worker.package || "")); + const descriptionPath = relativeArtifact( + root, + String(worker.description || ""), + ); + const meshPath = relativeArtifact(root, String(worker.mesh || "")); + const warnings = Array.isArray(worker.warnings) + ? worker.warnings.map(String) + : []; + const state: ExportState = { + schema_version: "1.0", + source_sha256: sourceSha256, + format, + package_path: packagePath, + description_path: descriptionPath, + mesh_path: meshPath, + warnings, + }; + await fs.writeFile( + statePath, + `${JSON.stringify(state, null, 2)}\n`, + "utf8", + ); + + const existingArtifacts = manifest.artifacts || []; + const nextArtifacts = existingArtifacts.filter( + (artifact) => artifact.role !== `${format}_package` + && artifact.role !== `${format}_description` + && artifact.role !== `${format}_mesh`, + ); + nextArtifacts.push( + { path: packagePath, role: `${format}_package`, kind: "zip" }, + { + path: descriptionPath, + role: `${format}_description`, + kind: format, + }, + { path: meshPath, role: `${format}_mesh`, kind: "stl" }, + ); + await upsertManifest(taskId, { + artifacts: nextArtifacts, + studio: { + ...(manifest.studio || {}), + robotExports: { + ...( + manifest.studio?.robotExports + && typeof manifest.studio.robotExports === "object" + ? manifest.studio.robotExports + : {} + ), + [format]: state, + }, + }, + }); + return resultFromState(taskId, state, "generated"); +} diff --git a/designir-pipeline/contracts/assembly-designir-1.0.schema.json b/designir-pipeline/contracts/assembly-designir-1.0.schema.json new file mode 100644 index 0000000..a27243c --- /dev/null +++ b/designir-pipeline/contracts/assembly-designir-1.0.schema.json @@ -0,0 +1,457 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://cadset.local/contracts/assembly-designir-1.0.schema.json", + "title": "Assembly DesignIR 1.0", + "description": "Coordinate-free semantic structure, mating, kinematics and transmission contract.", + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "designir_kind", + "model_id", + "family", + "units", + "parameters", + "modules", + "components", + "relations", + "kinematics", + "edit_interface", + "validation_contract" + ], + "properties": { + "schema_version": { + "const": "assembly-1.0" + }, + "designir_kind": { + "const": "coordinate_free_semantic_assembly" + }, + "model_id": { + "$ref": "#/$defs/identifier" + }, + "family": { + "$ref": "#/$defs/identifier" + }, + "units": { + "enum": ["mm", "inch"] + }, + "design_intent": { + "type": "string", + "minLength": 1 + }, + "parameters": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/parameter" + } + }, + "expressions": { + "type": "object", + "additionalProperties": { + "type": "string", + "minLength": 1 + } + }, + "modules": { + "type": "array", + "items": { + "$ref": "#/$defs/module" + } + }, + "components": { + "type": "array", + "items": { + "$ref": "#/$defs/component" + } + }, + "relations": { + "type": "array", + "items": { + "$ref": "#/$defs/relation" + } + }, + "kinematics": { + "$ref": "#/$defs/kinematics" + }, + "edit_interface": { + "$ref": "#/$defs/editInterface" + }, + "validation_contract": { + "$ref": "#/$defs/validationContract" + } + }, + "$defs": { + "identifier": { + "type": "string", + "pattern": "^[a-z][a-z0-9_]*$" + }, + "parameter": { + "type": "object", + "additionalProperties": false, + "required": ["kind", "value", "editable"], + "properties": { + "kind": { + "enum": ["length", "angle", "ratio", "count", "mass", "torque", "speed", "scalar", "choice", "boolean"] + }, + "value": {}, + "unit": { + "type": "string" + }, + "editable": { + "type": "boolean" + }, + "bounds": { + "type": "array", + "minItems": 2, + "maxItems": 2, + "items": { + "type": "number" + } + }, + "choices": { + "type": "array", + "items": {} + }, + "description": { + "type": "string" + }, + "status": { + "enum": ["specified", "inferred", "estimated", "derived"] + } + } + }, + "module": { + "type": "object", + "additionalProperties": false, + "required": ["id", "purpose", "members"], + "properties": { + "id": { + "$ref": "#/$defs/identifier" + }, + "purpose": { + "type": "string", + "minLength": 1 + }, + "members": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/identifier" + } + }, + "replaceable_as_unit": { + "type": "boolean" + } + } + }, + "component": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "role", + "archetype", + "lifecycle", + "generation", + "interfaces" + ], + "properties": { + "id": { + "$ref": "#/$defs/identifier" + }, + "role": { + "$ref": "#/$defs/identifier" + }, + "archetype": { + "$ref": "#/$defs/identifier" + }, + "lifecycle": { + "enum": ["required", "optional", "replaceable", "generated_pattern"] + }, + "quantity": { + "oneOf": [ + { + "type": "integer", + "minimum": 1 + }, + { + "type": "string", + "minLength": 1 + } + ] + }, + "generation": { + "type": "object", + "additionalProperties": false, + "required": ["strategy", "family", "parameter_bindings"], + "properties": { + "strategy": { + "enum": ["semantic_feature_program", "standard_library", "supplier_catalog", "subassembly_template"] + }, + "family": { + "$ref": "#/$defs/identifier" + }, + "parameter_bindings": { + "type": "object", + "additionalProperties": { + "type": ["string", "number", "integer", "boolean"] + } + } + } + }, + "interfaces": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/interface" + } + }, + "material_class": { + "$ref": "#/$defs/identifier" + }, + "tags": { + "type": "array", + "items": { + "$ref": "#/$defs/identifier" + } + } + } + }, + "interface": { + "type": "object", + "additionalProperties": false, + "required": ["id", "type", "construction"], + "properties": { + "id": { + "$ref": "#/$defs/identifier" + }, + "type": { + "enum": ["axis", "plane", "cylinder", "hole_pattern", "shaft_seat", "bearing_seat", "fastener_seat", "torque_interface", "contact_envelope"] + }, + "construction": { + "enum": ["primary_axis", "secondary_axis", "front_axial_face", "rear_axial_face", "inner_cylindrical_surface", "outer_cylindrical_surface", "pitch_circle_pattern", "shoulder_face", "center_plane", "generated_feature"] + }, + "qualifiers": { + "type": "array", + "items": { + "type": "string" + } + }, + "parameter_refs": { + "type": "array", + "items": { + "$ref": "#/$defs/identifier" + } + } + } + }, + "endpoint": { + "type": "string", + "pattern": "^[a-z][a-z0-9_]*\\.[a-z][a-z0-9_]*$" + }, + "relation": { + "type": "object", + "additionalProperties": false, + "required": ["id", "type", "a", "b", "intent"], + "properties": { + "id": { + "$ref": "#/$defs/identifier" + }, + "type": { + "enum": ["coaxial", "face_mate", "face_offset", "parallel", "perpendicular", "insert", "bearing_support", "axial_capture", "fasten_pattern", "torque_lock", "radial_clearance", "axial_clearance", "tangent"] + }, + "a": { + "$ref": "#/$defs/endpoint" + }, + "b": { + "$ref": "#/$defs/endpoint" + }, + "intent": { + "type": "string", + "minLength": 1 + }, + "value": { + "oneOf": [ + { + "type": "number" + }, + { + "type": "string", + "minLength": 1 + } + ] + }, + "tolerance": { + "oneOf": [ + { + "type": "number", + "minimum": 0 + }, + { + "type": "string", + "minLength": 1 + } + ] + }, + "mobility": { + "enum": ["fixed", "allow_rotation", "allow_translation", "allow_rotation_and_translation"] + }, + "confidence": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "evidence": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "kinematics": { + "type": "object", + "additionalProperties": false, + "required": ["anchor_component", "rigid_groups", "joints", "transmissions"], + "properties": { + "anchor_component": { + "$ref": "#/$defs/identifier" + }, + "rigid_groups": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id", "members"], + "properties": { + "id": { + "$ref": "#/$defs/identifier" + }, + "members": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/identifier" + } + } + } + } + }, + "joints": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id", "type", "parent_group", "child_group", "axis_ref", "station_ref"], + "properties": { + "id": { + "$ref": "#/$defs/identifier" + }, + "type": { + "enum": ["fixed", "revolute", "continuous", "prismatic", "cylindrical"] + }, + "parent_group": { + "$ref": "#/$defs/identifier" + }, + "child_group": { + "$ref": "#/$defs/identifier" + }, + "axis_ref": { + "$ref": "#/$defs/endpoint" + }, + "station_ref": { + "$ref": "#/$defs/endpoint" + }, + "dynamics": { + "type": "object", + "additionalProperties": { + "type": ["number", "string"] + } + } + } + } + }, + "transmissions": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id", "type", "input_joint", "output_joint", "ratio_expression"], + "properties": { + "id": { + "$ref": "#/$defs/identifier" + }, + "type": { + "enum": ["direct", "gear_pair", "planetary", "harmonic", "cycloidal", "belt", "chain", "screw"] + }, + "input_joint": { + "$ref": "#/$defs/identifier" + }, + "output_joint": { + "$ref": "#/$defs/identifier" + }, + "ratio_expression": { + "type": "string", + "minLength": 1 + }, + "fixed_member": { + "$ref": "#/$defs/identifier" + }, + "compliance_model": { + "$ref": "#/$defs/identifier" + } + } + } + } + } + }, + "editInterface": { + "type": "object", + "additionalProperties": false, + "required": ["editable_parameters", "structural_operations", "preserved_interfaces"], + "properties": { + "editable_parameters": { + "type": "array", + "items": { + "$ref": "#/$defs/identifier" + } + }, + "structural_operations": { + "type": "array", + "items": { + "enum": ["add_component", "remove_component", "replace_component", "change_relation", "change_transmission", "add_module"] + } + }, + "preserved_interfaces": { + "type": "array", + "items": { + "$ref": "#/$defs/endpoint" + } + } + } + }, + "validationContract": { + "type": "object", + "additionalProperties": false, + "required": ["coordinate_free_source", "required_checks"], + "properties": { + "coordinate_free_source": { + "const": true + }, + "required_checks": { + "type": "array", + "minItems": 1, + "items": { + "enum": ["schema_valid", "reference_integrity", "constraint_graph_connected", "assembly_solvable_from_scrambled_state", "no_overconstraint", "interface_compatibility", "clearance", "interference", "joint_mobility", "parameter_perturbation", "preserved_interface", "urdf_load", "mjcf_load"] + } + }, + "acceptance_rules": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } +} diff --git a/designir-pipeline/contracts/designir-3.0.schema.json b/designir-pipeline/contracts/designir-3.0.schema.json index 70c7e3c..73888d8 100644 --- a/designir-pipeline/contracts/designir-3.0.schema.json +++ b/designir-pipeline/contracts/designir-3.0.schema.json @@ -56,8 +56,41 @@ }, "surface_layer": { "type": "object", - "required": ["vertices", "solids", "free_shells", "surface_vocabulary", "curve_vocabulary"], + "oneOf": [ + { + "required": [ + "vertices", + "solids", + "free_shells", + "surface_vocabulary", + "curve_vocabulary" + ] + }, + { + "required": [ + "format", + "encoding", + "uncompressed_bytes", + "compressed_bytes", + "sha256", + "data" + ] + } + ], "properties": { + "format": {"const": "surfaceir-3.0"}, + "encoding": {"const": "gzip+base64+json"}, + "uncompressed_bytes": {"type": "integer", "minimum": 2}, + "compressed_bytes": {"type": "integer", "minimum": 2}, + "sha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "data": { + "type": "string", + "minLength": 1, + "contentEncoding": "base64" + }, "vertices": { "type": "array", "items": { @@ -77,6 +110,7 @@ "required": ["id", "shells"], "properties": { "id": {"type": "string"}, + "orientation": {"type": "integer"}, "shells": { "type": "array", "items": { @@ -84,6 +118,7 @@ "required": ["id", "faces"], "properties": { "id": {"type": "string"}, + "orientation": {"type": "integer"}, "faces": { "type": "array", "items": {"$ref": "#/$defs/face"} @@ -286,6 +321,8 @@ "properties": { "id": {"type": "string"}, "orientation": {"type": "integer"}, + "area": {"type": "number", "minimum": 0}, + "volume_contribution": {"type": "number"}, "surface": {"type": "object"}, "uv_bounds": { "type": "array", diff --git a/designir-pipeline/scripts/designir_codec.py b/designir-pipeline/scripts/designir_codec.py new file mode 100644 index 0000000..ac4ddf8 --- /dev/null +++ b/designir-pipeline/scripts/designir_codec.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python3 +"""Lossless storage codec for portable DesignIR 3.0 documents. + +The semantic envelope stays ordinary JSON so an agent can inspect parameters, +features, constraints, and edit bindings without consuming the dense SurfaceIR +coordinate payload. SurfaceIR is compressed losslessly inside the same JSON +document, preserving single-file source-independent rebuilds. +""" + +from __future__ import annotations + +import base64 +import copy +import gzip +import hashlib +import json +from pathlib import Path +from typing import Any + + +SURFACE_LAYER_ENCODING = "gzip+base64+json" +SURFACE_LAYER_FORMAT = "surfaceir-3.0" + + +class DesignIRCodecError(ValueError): + """Raised when a compact DesignIR payload is corrupt or unsupported.""" + + +def is_encoded_surface_layer(value: Any) -> bool: + return ( + isinstance(value, dict) + and value.get("encoding") == SURFACE_LAYER_ENCODING + and value.get("format") == SURFACE_LAYER_FORMAT + and isinstance(value.get("data"), str) + ) + + +def encode_surface_layer(payload: dict[str, Any]) -> dict[str, Any]: + """Return a copy with SurfaceIR losslessly compressed in-place.""" + surface_layer = payload.get("surface_layer") + if surface_layer is None or is_encoded_surface_layer(surface_layer): + return copy.deepcopy(payload) + if not isinstance(surface_layer, dict): + raise DesignIRCodecError("surface_layer must be a JSON object") + + raw = json.dumps( + surface_layer, + ensure_ascii=False, + separators=(",", ":"), + ).encode("utf-8") + compressed = gzip.compress(raw, compresslevel=9, mtime=0) + encoded = copy.copy(payload) + encoded["surface_layer"] = { + "format": SURFACE_LAYER_FORMAT, + "encoding": SURFACE_LAYER_ENCODING, + "uncompressed_bytes": len(raw), + "compressed_bytes": len(compressed), + "sha256": hashlib.sha256(raw).hexdigest(), + "data": base64.b64encode(compressed).decode("ascii"), + } + return encoded + + +def decode_surface_layer(payload: dict[str, Any]) -> dict[str, Any]: + """Return a copy with an encoded SurfaceIR layer expanded and verified.""" + envelope = payload.get("surface_layer") + if not is_encoded_surface_layer(envelope): + return copy.deepcopy(payload) + try: + compressed = base64.b64decode(envelope["data"], validate=True) + raw = gzip.decompress(compressed) + except (ValueError, OSError) as exc: + raise DesignIRCodecError( + f"Cannot decode compact SurfaceIR payload: {exc}" + ) from exc + + expected_compressed = envelope.get("compressed_bytes") + if expected_compressed is not None and len(compressed) != expected_compressed: + raise DesignIRCodecError("Compact SurfaceIR compressed size mismatch") + expected_uncompressed = envelope.get("uncompressed_bytes") + if expected_uncompressed is not None and len(raw) != expected_uncompressed: + raise DesignIRCodecError("Compact SurfaceIR uncompressed size mismatch") + expected_digest = envelope.get("sha256") + if expected_digest and hashlib.sha256(raw).hexdigest() != expected_digest: + raise DesignIRCodecError("Compact SurfaceIR SHA-256 mismatch") + try: + surface_layer = json.loads(raw) + except json.JSONDecodeError as exc: + raise DesignIRCodecError( + f"Decoded SurfaceIR is not valid JSON: {exc}" + ) from exc + if not isinstance(surface_layer, dict): + raise DesignIRCodecError("Decoded SurfaceIR must be a JSON object") + + decoded = copy.copy(payload) + decoded["surface_layer"] = surface_layer + return decoded + + +def read_designir(path: Path) -> dict[str, Any]: + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise DesignIRCodecError(f"Cannot read DesignIR JSON {path}: {exc}") from exc + if not isinstance(payload, dict): + raise DesignIRCodecError("DesignIR must be a JSON object") + return decode_surface_layer(payload) + + +def write_designir( + path: Path, + payload: dict[str, Any], + *, + encode_surface: bool = True, +) -> None: + stored = encode_surface_layer(payload) if encode_surface else payload + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + json.dumps(stored, ensure_ascii=False, separators=(",", ":")) + "\n", + encoding="utf-8", + ) diff --git a/designir-pipeline/scripts/designir_pipeline.py b/designir-pipeline/scripts/designir_pipeline.py index 84bd613..7bca3fd 100644 --- a/designir-pipeline/scripts/designir_pipeline.py +++ b/designir-pipeline/scripts/designir_pipeline.py @@ -21,6 +21,16 @@ import tempfile from pathlib import Path from typing import Any +SCRIPT_DIR = Path(__file__).resolve().parent +if str(SCRIPT_DIR) not in sys.path: + sys.path.insert(0, str(SCRIPT_DIR)) + +from designir_codec import ( + DesignIRCodecError, + read_designir, + write_designir, +) + from build123d import ( Align, Box, @@ -113,8 +123,8 @@ class DesignIRError(ValueError): def read_json(path: Path) -> dict[str, Any]: try: - value = json.loads(path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError) as exc: + value = read_designir(path) + except (OSError, json.JSONDecodeError, DesignIRCodecError) as exc: raise DesignIRError(f"Cannot read JSON {path}: {exc}") from exc if not isinstance(value, dict): raise DesignIRError("DesignIR must be a JSON object") @@ -122,6 +132,9 @@ def read_json(path: Path) -> dict[str, Any]: def write_json(path: Path, value: dict[str, Any]) -> None: + if path.name.endswith(".designir.json") and "surface_layer" in value: + write_designir(path, value) + return path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(value, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") @@ -587,13 +600,26 @@ def build_simplecad_shape(payload: dict[str, Any]) -> tuple[Any, str]: raise DesignIRError("SimpleCADAPI backend is unavailable") from exc values = parameter_values(payload) - shape: Any = None - z_min: float | None = None - z_max: float | None = None - with scad.GraphSession() as session: + + def semantic_tag(value: object, *, prefix: str = "feature") -> str: + token = re.sub(r"[^a-z0-9]+", ".", str(value).lower()).strip(".") + return f"{prefix}.{token or 'unnamed'}" + + graph_id = re.sub( + r"[^a-zA-Z0-9_.-]+", + "_", + str(payload.get("model_id") or payload.get("family") or "designir_model"), + ).strip("._-") or "designir_model" + + @scad.model(graph_id=graph_id) + def compile_model() -> Any: + shape: Any = None + z_min: float | None = None + z_max: float | None = None for feature in payload["features"]: operation = feature["operation"] axis = feature_axis(payload, feature) + feature_tag = semantic_tag(feature["id"]) if operation in {"extrude_circle", "add_cylinder"}: radius = resolve_value( feature["radius"], values, f"{feature['id']}.radius" @@ -612,11 +638,13 @@ def build_simplecad_shape(payload: dict[str, Any]) -> tuple[Any, str]: height=height, bottom_face_center=point, axis=tuple(axis), + tag_prefix=feature_tag, + result_tag=f"{feature_tag}.primitive", ) shape = ( tool if shape is None - else scad.union_rsolid([shape, tool], clean=True) + else scad.union_rsolid(shape, tool, clean=True) ) z_min = point[2] if z_min is None else min(z_min, point[2]) z_max = ( @@ -649,11 +677,13 @@ def build_simplecad_shape(payload: dict[str, Any]) -> tuple[Any, str]: height=depth, depth=height, bottom_face_center=point, + tag_prefix=feature_tag, + result_tag=f"{feature_tag}.primitive", ) shape = ( tool if shape is None - else scad.union_rsolid([shape, tool], clean=True) + else scad.union_rsolid(shape, tool, clean=True) ) z_min = point[2] if z_min is None else min(z_min, point[2]) z_max = ( @@ -709,17 +739,24 @@ def build_simplecad_shape(payload: dict[str, Any]) -> tuple[Any, str]: height=cutter_height, bottom_face_center=(x, y, z_min - 1.0), axis=(0.0, 0.0, 1.0), + tag_prefix=f"{feature_tag}.tool.item{index + 1}", + result_tag=f"{feature_tag}.tool.item{index + 1}.solid", ) - for x, y in centers + for index, (x, y) in enumerate(centers) ] shape = scad.cut_rsolid( - shape, cutters, skip_non_intersecting=False + shape, *cutters, skip_non_intersecting=False ) + shape = scad.apply_tag(shape=shape, tag=f"{feature_tag}.result") print( f"simplecad grounding {feature['id']}: volume={shape.get_volume():.6f}", file=sys.stderr, ) - model_json = scad.export_model_json(session=session, indent=2) + return scad.capture_result(value=shape) + + result = compile_model() + shape = result.value + model_json = result.model_json if shape is None or shape.get_volume() <= 0: raise DesignIRError("DesignIR did not produce a valid SimpleCAD solid") return shape, model_json diff --git a/designir-pipeline/scripts/robot_description_export.py b/designir-pipeline/scripts/robot_description_export.py new file mode 100644 index 0000000..c4eedc3 --- /dev/null +++ b/designir-pipeline/scripts/robot_description_export.py @@ -0,0 +1,282 @@ +#!/usr/bin/env python3 +"""Export a portable single-link URDF or MJCF package from DesignIR 3.0. + +The exporter rebuilds geometry from DesignIR alone, writes an STL mesh in +millimetres, and records the required 0.001 mesh scale in the robot +description. Uploaded teacher STEP files are never read. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import re +import shutil +import sys +import tempfile +from pathlib import Path +from typing import Any +import xml.etree.ElementTree as ET +import zipfile + +SCRIPT_DIR = Path(__file__).resolve().parent +if str(SCRIPT_DIR) not in sys.path: + sys.path.insert(0, str(SCRIPT_DIR)) + +from designir_codec import read_designir +import surfaceir_pipeline + +from build123d import Shape, export_stl + + +EXPORT_SCHEMA_VERSION = "1.0" +MESH_SCALE = "0.001 0.001 0.001" + + +def _safe_name(value: str, fallback: str = "cad_model") -> str: + normalized = re.sub(r"[^a-zA-Z0-9_-]+", "_", value).strip("_") + return normalized or fallback + + +def _xml_bytes(root: ET.Element) -> bytes: + ET.indent(root, space=" ") + return ET.tostring(root, encoding="utf-8", xml_declaration=True) + + +def _urdf(model_name: str, mesh_name: str) -> bytes: + robot = ET.Element("robot", {"name": model_name}) + robot.append( + ET.Comment( + "Generated from source-independent DesignIR 3.0. " + "No joint or material evidence was available, so this is one " + "fixed rigid link and inertial values are intentionally omitted." + ) + ) + link = ET.SubElement(robot, "link", {"name": "base_link"}) + for role in ("visual", "collision"): + node = ET.SubElement(link, role) + ET.SubElement(node, "origin", {"xyz": "0 0 0", "rpy": "0 0 0"}) + geometry = ET.SubElement(node, "geometry") + ET.SubElement( + geometry, + "mesh", + { + "filename": f"meshes/{mesh_name}", + "scale": MESH_SCALE, + }, + ) + return _xml_bytes(robot) + + +def _mjcf(model_name: str, mesh_name: str) -> bytes: + mujoco = ET.Element("mujoco", {"model": model_name}) + mujoco.append( + ET.Comment( + "Generated from source-independent DesignIR 3.0. " + "No joint or material evidence was available, so this is one " + "world-fixed rigid body and density is disabled." + ) + ) + ET.SubElement( + mujoco, + "compiler", + {"angle": "radian", "meshdir": "meshes", "autolimits": "true"}, + ) + asset = ET.SubElement(mujoco, "asset") + ET.SubElement( + asset, + "mesh", + { + "name": "designir_mesh", + "file": mesh_name, + "scale": MESH_SCALE, + }, + ) + worldbody = ET.SubElement(mujoco, "worldbody") + body = ET.SubElement(worldbody, "body", {"name": "base_link"}) + ET.SubElement( + body, + "geom", + { + "name": "base_link_geometry", + "type": "mesh", + "mesh": "designir_mesh", + "density": "0", + "contype": "1", + "conaffinity": "1", + }, + ) + return _xml_bytes(mujoco) + + +def _build_shape(payload: dict[str, Any]) -> tuple[Any, str]: + preferred = ( + payload.get("reconstruction_strategy", {}) + .get("boundary_strategy", "exact_3d_pcurve") + ) + candidates = list( + dict.fromkeys( + [ + preferred, + "exact_3d_pcurve", + "exact_3d", + "first_pcurve", + "analytic_uv_rect", + ] + ) + ) + failures = [] + for strategy in candidates: + try: + return surfaceir_pipeline.build_surfaceir(payload, strategy), strategy + except Exception as exc: # pragma: no cover - fallback diagnostics + failures.append(f"{strategy}: {type(exc).__name__}: {exc}") + raise ValueError("DesignIR geometry could not be rebuilt: " + " | ".join(failures)) + + +def export_robot_package( + designir_path: Path, + output_dir: Path, + output_format: str, + *, + linear_tolerance: float = 0.05, + angular_tolerance: float = 0.1, +) -> dict[str, Any]: + output_format = output_format.lower() + if output_format not in {"urdf", "mjcf"}: + raise ValueError("format must be urdf or mjcf") + + payload = read_designir(designir_path) + if payload.get("document_status") != "geometry_present": + raise ValueError("Cannot export a robot description without geometry") + if "surface_layer" not in payload: + raise ValueError( + "Robot export requires a materialized DesignIR SurfaceIR layer" + ) + + shape, strategy = _build_shape(payload) + model_name = _safe_name(str(payload.get("model_id") or designir_path.stem)) + digest = hashlib.sha256(designir_path.read_bytes()).hexdigest() + package_stem = f"{model_name}.{output_format}" + output_dir.mkdir(parents=True, exist_ok=True) + package_path = output_dir / f"{package_stem}.zip" + description_suffix = ".urdf" if output_format == "urdf" else ".mjcf.xml" + description_name = f"{model_name}{description_suffix}" + mesh_name = f"{model_name}.stl" + + with tempfile.TemporaryDirectory(prefix="designir-robot-export-") as temporary: + staging = Path(temporary) / package_stem + mesh_dir = staging / "meshes" + mesh_dir.mkdir(parents=True) + mesh_path = mesh_dir / mesh_name + export_stl( + Shape(shape), + mesh_path, + tolerance=linear_tolerance, + angular_tolerance=angular_tolerance, + ) + description = ( + _urdf(model_name, mesh_name) + if output_format == "urdf" + else _mjcf(model_name, mesh_name) + ) + description_path = staging / description_name + description_path.write_bytes(description) + manifest = { + "schema_version": EXPORT_SCHEMA_VERSION, + "export_kind": f"designir_to_{output_format}", + "model_name": model_name, + "source": { + "kind": "designir-3.0", + "sha256": digest, + "teacher_step_used": False, + }, + "robot_structure": { + "mode": "static_single_link", + "link_count": 1, + "joint_count": 0, + "root_link": "base_link", + }, + "geometry": { + "mesh": f"meshes/{mesh_name}", + "mesh_units": "mm", + "description_mesh_scale": [0.001, 0.001, 0.001], + "linear_tolerance_mm": linear_tolerance, + "angular_tolerance_rad": angular_tolerance, + "reconstruction_strategy": strategy, + }, + "dynamics": { + "status": "not_authored", + "reason": "DesignIR contains no authoritative material density or joint evidence.", + }, + "assumptions": [ + "All reconstructed solids belong to one rigid base_link.", + "The root link is fixed because no authoritative joint evidence exists.", + "Visual and collision geometry use the same high-resolution mesh.", + ], + } + (staging / "robot-export.json").write_text( + json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + + temporary_zip = package_path.with_suffix(package_path.suffix + ".tmp") + temporary_zip.unlink(missing_ok=True) + with zipfile.ZipFile( + temporary_zip, + "w", + compression=zipfile.ZIP_DEFLATED, + compresslevel=9, + ) as archive: + for source in sorted(staging.rglob("*")): + if source.is_file(): + archive.write(source, source.relative_to(staging)) + temporary_zip.replace(package_path) + + published_description = output_dir / description_name + published_mesh = output_dir / mesh_name + shutil.copyfile(description_path, published_description) + shutil.copyfile(mesh_path, published_mesh) + + return { + "status": "generated", + "format": output_format, + "package": str(package_path.resolve()), + "description": str(published_description.resolve()), + "mesh": str(published_mesh.resolve()), + "source_sha256": digest, + "robot_structure": "static_single_link", + "reconstruction_strategy": strategy, + "warnings": [ + "No authoritative joints were present; exported one fixed base_link.", + "No authoritative material density was present; inertial data was omitted.", + ], + } + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--designir", type=Path, required=True) + parser.add_argument("--output-dir", type=Path, required=True) + parser.add_argument("--format", choices=["urdf", "mjcf"], required=True) + parser.add_argument("--linear-tolerance", type=float, default=0.05) + parser.add_argument("--angular-tolerance", type=float, default=0.1) + return parser + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + result = export_robot_package( + args.designir.resolve(), + args.output_dir.resolve(), + args.format, + linear_tolerance=args.linear_tolerance, + angular_tolerance=args.angular_tolerance, + ) + print(json.dumps(result, ensure_ascii=False)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/designir-pipeline/scripts/surfaceir_pipeline.py b/designir-pipeline/scripts/surfaceir_pipeline.py index 246c3f0..9e91187 100644 --- a/designir-pipeline/scripts/surfaceir_pipeline.py +++ b/designir-pipeline/scripts/surfaceir_pipeline.py @@ -17,6 +17,12 @@ import sys import tempfile from typing import Any +SCRIPT_DIR = Path(__file__).resolve().parent +if str(SCRIPT_DIR) not in sys.path: + sys.path.insert(0, str(SCRIPT_DIR)) + +from designir_codec import read_designir, write_designir + from build123d import Shape, import_step from OCP.BRep import BRep_Builder, BRep_Tool from OCP.BRepBndLib import BRepBndLib @@ -80,10 +86,11 @@ from OCP.TColStd import ( from OCP.IFSelect import IFSelect_RetDone from OCP.GProp import GProp_GProps from OCP.STEPControl import STEPControl_AsIs, STEPControl_Reader, STEPControl_Writer -from OCP.ShapeFix import ShapeFix_Shape +from OCP.ShapeFix import ShapeFix_Face, ShapeFix_Shape, ShapeFix_Shell from OCP.TopAbs import ( TopAbs_EDGE, TopAbs_FACE, + TopAbs_FORWARD, TopAbs_IN, TopAbs_ON, TopAbs_SHELL, @@ -373,12 +380,18 @@ def _edge_records( while explorer.More(): index += 1 edge = explorer.Current() + canonical_edge = TopoDS.Edge_s(edge.Oriented(TopAbs_FORWARD)) topology_index = int(edge_map.FindIndex(edge)) if topology_index <= 0: raise ValueError("Cannot resolve shared topology edge") first_vertex = TopoDS_Vertex() last_vertex = TopoDS_Vertex() - TopExp.Vertices_s(edge, first_vertex, last_vertex) + # Store topology-edge endpoint order independently of this wire + # occurrence. BRepTools_WireExplorer returns a context-oriented edge; + # using that orientation here and then applying it again during replay + # reverses endpoint constraints twice and can create self-intersecting + # wires on periodic faces. + TopExp.Vertices_s(canonical_edge, first_vertex, last_vertex) vertex_ids = [] for vertex in (first_vertex, last_vertex): if vertex.IsNull(): @@ -393,8 +406,9 @@ def _edge_records( "topology_edge_id": f"edge_{topology_index}", "vertex_ids": vertex_ids, "orientation": int(explorer.Orientation()), + "closed": bool(canonical_edge.Closed()), "tolerance": _number(BRep_Tool.Tolerance_s(edge)), - "curve": _curve_record(edge), + "curve": _curve_record(canonical_edge), "pcurve": _pcurve_record(edge, face), } ) @@ -409,12 +423,18 @@ def _wire_records( vertex_map: TopTools_IndexedMapOfShape, ) -> list[dict[str, Any]]: records = [] + # Preserve OCCT's contextual traversal orientation. In particular, a seam + # edge can occur twice on one periodic face and the occurrence orientation + # selects which of its two pcurves is returned by BRep_Tool. outer = BRepTools.OuterWire_s(face) explorer = TopExp_Explorer(face, TopAbs_WIRE) index = 0 while explorer.More(): index += 1 wire = TopoDS.Wire_s(explorer.Current()) + canonical_wire = TopoDS.Wire_s( + wire.Oriented(TopAbs_FORWARD) + ) wire_id = f"{prefix}.wire_{index}" records.append( { @@ -422,7 +442,11 @@ def _wire_records( "orientation": int(wire.Orientation()), "outer": bool(wire.IsSame(outer)), "edges": _edge_records( - wire, face, wire_id, edge_map, vertex_map + canonical_wire, + face, + wire_id, + edge_map, + vertex_map, ), } ) @@ -442,14 +466,50 @@ def _face_records( while explorer.More(): index += 1 face = TopoDS.Face_s(explorer.Current()) + canonical_face = TopoDS.Face_s(face.Oriented(TopAbs_FORWARD)) face_id = f"{prefix}.face_{index}" + properties = GProp_GProps() + BRepGProp.SurfaceProperties_s(canonical_face, properties) + volume_properties = GProp_GProps() + BRepGProp.VolumeProperties_s(face, volume_properties) + bounds = Bnd_Box() + BRepBndLib.AddOptimal_s(canonical_face, bounds) + ( + x_min, + y_min, + z_min, + x_max, + y_max, + z_max, + ) = bounds.Get() records.append( { "id": face_id, "orientation": int(face.Orientation()), - "surface": _surface_record(face), - "uv_bounds": [_number(value) for value in BRepTools.UVBounds_s(face)], - "wires": _wire_records(face, face_id, edge_map, vertex_map), + "area": _number(abs(float(properties.Mass()))), + "centroid": _point(properties.CentreOfMass()), + "bounds": [ + _number(value) + for value in ( + x_min, + y_min, + z_min, + x_max, + y_max, + z_max, + ) + ], + "volume_contribution": _number( + float(volume_properties.Mass()) + ), + "surface": _surface_record(canonical_face), + "uv_bounds": [ + _number(value) + for value in BRepTools.UVBounds_s(canonical_face) + ], + "wires": _wire_records( + canonical_face, face_id, edge_map, vertex_map + ), } ) explorer.Next() @@ -470,6 +530,12 @@ def _vertex_records(vertex_map: TopTools_IndexedMapOfShape) -> list[dict[str, An return records +def _signed_volume(shape: Any) -> float: + properties = GProp_GProps() + BRepGProp.VolumeProperties_s(shape, properties) + return float(properties.Mass()) + + def _topology_records(shape: Any) -> dict[str, Any]: edge_map = TopTools_IndexedMapOfShape() TopExp.MapShapes_s(shape, TopAbs_EDGE, edge_map) @@ -482,12 +548,16 @@ def _topology_records(shape: Any) -> dict[str, Any]: while solid_explorer.More(): solid_index += 1 solid = TopoDS.Solid_s(solid_explorer.Current()) + canonical_solid = TopoDS.Solid_s(solid.Oriented(TopAbs_FORWARD)) shells = [] - shell_explorer = TopExp_Explorer(solid, TopAbs_SHELL) + shell_explorer = TopExp_Explorer(canonical_solid, TopAbs_SHELL) shell_index = 0 while shell_explorer.More(): shell_index += 1 shell = TopoDS.Shell_s(shell_explorer.Current()) + canonical_shell = TopoDS.Shell_s( + shell.Oriented(TopAbs_FORWARD) + ) solid_shell_shapes.append(shell) shell_id = f"solid_{solid_index}.shell_{shell_index}" shells.append( @@ -495,12 +565,21 @@ def _topology_records(shape: Any) -> dict[str, Any]: "id": shell_id, "orientation": int(shell.Orientation()), "faces": _face_records( - shell, shell_id, edge_map, vertex_map + canonical_shell, shell_id, edge_map, vertex_map ), } ) shell_explorer.Next() - solids.append({"id": f"solid_{solid_index}", "shells": shells}) + solids.append( + { + "id": f"solid_{solid_index}", + "orientation": int(solid.Orientation()), + "signed_volume": _number( + _signed_volume(solid) + ), + "shells": shells, + } + ) solid_explorer.Next() free_shells = [] shell_explorer = TopExp_Explorer(shape, TopAbs_SHELL) @@ -510,12 +589,15 @@ def _topology_records(shape: Any) -> dict[str, Any]: if not any(shell.IsSame(candidate) for candidate in solid_shell_shapes): free_shell_index += 1 shell_id = f"free_shell_{free_shell_index}" + canonical_shell = TopoDS.Shell_s( + shell.Oriented(TopAbs_FORWARD) + ) free_shells.append( { "id": shell_id, "orientation": int(shell.Orientation()), "faces": _face_records( - shell, shell_id, edge_map, vertex_map + canonical_shell, shell_id, edge_map, vertex_map ), } ) @@ -1594,7 +1676,16 @@ def _curve_from_record( if len(vertices) == 2: arguments.extend(vertices) arguments.extend([first, last]) - edge = BRepBuilderAPI_MakeEdge(*arguments).Edge() + try: + edge = BRepBuilderAPI_MakeEdge(*arguments).Edge() + except Exception: + # Imported STEP vertex tolerances can be tighter than the + # reconstructed surface/pcurve pair. The pcurve and its parameter + # range are authoritative for this face; let OCCT recover endpoint + # vertices when the source vertices cannot be reused consistently. + edge = BRepBuilderAPI_MakeEdge( + arguments[0], surface, first, last + ).Edge() if kind == "degenerate": BRep_Builder().Degenerated(edge, True) return edge @@ -1728,13 +1819,14 @@ def _wire_from_record( vertex_cache: dict[str, Any], ) -> Any: builder = BRepBuilderAPI_MakeWire() - resolved_edges: list[tuple[dict[str, Any], Any]] = [] + resolved_edges: list[tuple[dict[str, Any], Any, bool]] = [] for edge_payload in payload["edges"]: topology_edge_id = str( edge_payload.get("topology_edge_id", edge_payload["id"]) ) use_shared_edge = boundary_strategy != "first_pcurve" edge = edge_cache.get(topology_edge_id) if use_shared_edge else None + edge_was_created = edge is None if edge is None: vertices = [ vertex_cache[vertex_id] @@ -1742,7 +1834,7 @@ def _wire_from_record( if vertex_id in vertex_cache ] use_parametric_curve = ( - boundary_strategy == "first_pcurve" + boundary_strategy in {"first_pcurve", "shared_pcurve"} or edge_payload["curve"]["type"] == "degenerate" ) try: @@ -1763,14 +1855,15 @@ def _wire_from_record( edge_cache[topology_edge_id] = edge if edge is None: continue + edge.Closed(bool(edge_payload.get("closed", edge.Closed()))) tolerance = max(float(edge_payload.get("tolerance", 1e-7)), 1e-7) BRep_Builder().UpdateEdge(edge, tolerance) - resolved_edges.append((edge_payload, edge)) - if boundary_strategy == "exact_3d_pcurve": + resolved_edges.append((edge_payload, edge, edge_was_created)) + if boundary_strategy in {"exact_3d_pcurve", "shared_pcurve"}: edge_occurrences: dict[ - str, tuple[Any, list[dict[str, Any]]] + str, tuple[Any, list[dict[str, Any]], list[bool]] ] = {} - for edge_payload, edge in resolved_edges: + for edge_payload, edge, edge_was_created in resolved_edges: pcurve = edge_payload.get("pcurve") if pcurve is None: continue @@ -1778,11 +1871,16 @@ def _wire_from_record( edge_payload.get("topology_edge_id", edge_payload["id"]) ) if topology_edge_id not in edge_occurrences: - edge_occurrences[topology_edge_id] = (edge, []) + edge_occurrences[topology_edge_id] = (edge, [], []) edge_occurrences[topology_edge_id][1].append(edge_payload) + edge_occurrences[topology_edge_id][2].append(edge_was_created) edge_builder = BRep_Builder() location = TopLoc_Location() - for topology_edge_id, (edge, occurrences) in edge_occurrences.items(): + for topology_edge_id, ( + edge, + occurrences, + creation_flags, + ) in edge_occurrences.items(): if len(occurrences) > 2: raise ValueError( "Cannot bind more than two pcurves for shared edge " @@ -1795,11 +1893,30 @@ def _wire_from_record( ), 1e-7, ) + if len(occurrences) == 2: + # OCCT binds the first seam pcurve to the FORWARD occurrence + # and the second to REVERSED. Wire traversal order is not + # stable across imported STEP topology. + occurrences = sorted( + occurrences, + key=lambda occurrence: int( + occurrence.get("orientation", 0) + ), + ) pcurves = [ _pcurve_from_record(occurrence["pcurve"]) for occurrence in occurrences ] try: + if ( + boundary_strategy == "shared_pcurve" + and len(occurrences) == 1 + and creation_flags[0] + ): + # BRepBuilderAPI_MakeEdge already attached this pcurve to + # the newly created edge. Reattaching it can replace the + # valid trim with the full periodic surface interval. + continue if len(pcurves) == 2: edge_builder.UpdateEdge( edge, @@ -1822,7 +1939,7 @@ def _wire_from_record( f"Cannot bind pcurve for edge {topology_edge_id}: " f"{type(exc).__name__}: {exc}" ) from exc - for edge_payload, edge in resolved_edges: + for edge_payload, edge, _edge_was_created in resolved_edges: if int(edge_payload["orientation"]) == 1: edge = TopoDS.Edge_s(edge.Reversed()) builder.Add(edge) @@ -1845,7 +1962,6 @@ def _face_from_record( rectangular_parametric_patch = ( ( payload.get("edit_rebuild_strategy") == "uv_rect" - or payload["surface"]["type"] == "torus" or ( boundary_strategy in {"uv_rect_all", "analytic_uv_rect"} and payload["surface"]["type"] != "plane" @@ -1903,17 +2019,113 @@ def _face_from_record( ) from exc def make_trimmed_face(outer_wire: Any, inner_wire_list: list[Any]) -> Any: - face_builder = BRepBuilderAPI_MakeFace(surface, outer_wire, True) - for inner_wire in inner_wire_list: - face_builder.Add(inner_wire) - if not face_builder.IsDone(): - raise ValueError(f"Cannot rebuild face {payload['id']}") - return face_builder.Face() + def build( + selected_outer_wire: Any, + inner_wires: list[Any], + inside: bool, + ) -> Any: + face_builder = BRepBuilderAPI_MakeFace( + surface, selected_outer_wire, inside + ) + for inner_wire in inner_wires: + face_builder.Add(inner_wire) + if not face_builder.IsDone(): + raise ValueError(f"Cannot rebuild face {payload['id']}") + return face_builder.Face() + + selected_outer = outer_wire + selected_wires = list(inner_wire_list) + selected_inside = True + face = build(selected_outer, selected_wires, selected_inside) + expected_area = payload.get("area") + if isinstance(expected_area, (int, float)): + expected = abs(float(expected_area)) + + def area_error(candidate: Any) -> float: + properties = GProp_GProps() + BRepGProp.SurfaceProperties_s(candidate, properties) + return abs(abs(float(properties.Mass())) - expected) + + best_error = area_error(face) + outer_candidates = [ + (outer_wire, True), + (TopoDS.Wire_s(outer_wire.Reversed()), True), + (outer_wire, False), + (TopoDS.Wire_s(outer_wire.Reversed()), False), + ] + for candidate_outer, candidate_inside in outer_candidates[1:]: + try: + trial_face = build( + candidate_outer, + selected_wires, + candidate_inside, + ) + except Exception: + continue + trial_error = area_error(trial_face) + if trial_error + 1e-10 < best_error: + selected_outer = candidate_outer + selected_inside = candidate_inside + face = trial_face + best_error = trial_error + for index, inner_wire in enumerate(list(selected_wires)): + trial_wires = list(selected_wires) + trial_wires[index] = TopoDS.Wire_s( + inner_wire.Reversed() + ) + trial_face = build( + selected_outer, + trial_wires, + selected_inside, + ) + trial_error = area_error(trial_face) + if trial_error + 1e-10 < best_error: + selected_wires = trial_wires + face = trial_face + best_error = trial_error + return face face = make_trimmed_face(outer, inner_wires) BRepLib.BuildCurves3d_s(face) + expected_area = payload.get("area") + if isinstance(expected_area, (int, float)): + expected = abs(float(expected_area)) + + def face_area(candidate: Any) -> float: + properties = GProp_GProps() + BRepGProp.SurfaceProperties_s(candidate, properties) + return abs(float(properties.Mass())) + + original_error = abs(face_area(face) - expected) + area_tolerance = max(1e-8, expected * 1e-7) + if original_error > area_tolerance: + edge_tolerance = max( + [ + float(edge.get("tolerance", 1e-7)) + for wire in payload.get("wires", []) + for edge in wire.get("edges", []) + ] + or [1e-7] + ) + fixer = ShapeFix_Face(face) + fixer.SetPrecision(max(edge_tolerance, 1e-7)) + fixer.SetMaxTolerance(max(edge_tolerance, 1e-3)) + fixer.Perform() + repaired = fixer.Face() + if abs(face_area(repaired) - expected) < original_error: + face = repaired if int(payload["orientation"]) == 1: face = TopoDS.Face_s(face.Reversed()) + expected_contribution = payload.get("volume_contribution") + if isinstance(expected_contribution, (int, float)): + contribution_properties = GProp_GProps() + BRepGProp.VolumeProperties_s(face, contribution_properties) + contribution = float(contribution_properties.Mass()) + expected = float(expected_contribution) + if abs(-contribution - expected) + 1e-10 < abs( + contribution - expected + ): + face = TopoDS.Face_s(face.Reversed()) return face @@ -2035,6 +2247,272 @@ def _apply_edit_operations(shape: Any, payload: dict[str, Any]) -> Any: return result +def _solid_shell_from_record( + shell_payload: dict[str, Any], + boundary_strategy: str, + edge_cache: dict[str, Any], + vertex_cache: dict[str, Any], +) -> TopoDS_Shell: + """Rebuild one closed shell, including one-face periodic shells.""" + fallback_order = [ + boundary_strategy, + "analytic_uv_rect", + "exact_3d", + "exact_3d_pcurve", + "shared_pcurve", + "first_pcurve", + "uv_rect_all", + ] + strategies = list(dict.fromkeys(fallback_order)) + shell_tolerance = max( + [ + float(edge.get("tolerance", 1e-7)) + for face in shell_payload["faces"] + for wire in face["wires"] + for edge in wire["edges"] + ] + or [1e-7] + ) + + # The exact-pcurve representation already carries stable topology edge + # identifiers. Reuse those edges across adjacent faces before resorting to + # geometric sewing. This preserves the original incidence graph and avoids + # turning a shared boundary into two nearly coincident edges when a + # high-order curve is reconstructed independently on both faces. + if boundary_strategy in {"exact_3d_pcurve", "shared_pcurve"}: + shared_edge_cache = dict(edge_cache) + try: + shared_shell_builder = BRep_Builder() + shared_shell = TopoDS_Shell() + shared_shell_builder.MakeShell(shared_shell) + for face_payload in shell_payload["faces"]: + shared_shell_builder.Add( + shared_shell, + _face_from_record( + face_payload, + boundary_strategy, + shared_edge_cache, + vertex_cache, + ), + ) + shared_shell.Closed(True) + if BRepCheck_Analyzer(shared_shell).IsValid(): + edge_cache.clear() + edge_cache.update(shared_edge_cache) + return shared_shell + + # Shared edges can be geometrically exact while their imported + # face orientations are not accepted as a closed shell by OCCT. + # Re-orient that same shared topology before falling back to + # sewing. Accept the repair only when it preserves the exact + # incidence counts encoded by SurfaceIR. + shell_fixer = ShapeFix_Shell(shared_shell) + shell_fixer.SetPrecision(max(shell_tolerance, 1e-7)) + shell_fixer.SetMaxTolerance(max(shell_tolerance, 1e-3)) + shell_fixer.Perform() + oriented_shell = shell_fixer.Shell() + expected_face_count = len(shell_payload["faces"]) + expected_edge_count = len( + { + str(edge["topology_edge_id"]) + for face in shell_payload["faces"] + for wire in face["wires"] + for edge in wire["edges"] + } + ) + expected_vertex_count = len( + { + str(vertex_id) + for face in shell_payload["faces"] + for wire in face["wires"] + for edge in wire["edges"] + for vertex_id in edge.get("vertex_ids", []) + } + ) + actual_counts = {} + for label, shape_type in ( + ("face", TopAbs_FACE), + ("edge", TopAbs_EDGE), + ("vertex", TopAbs_VERTEX), + ): + shape_map = TopTools_IndexedMapOfShape() + TopExp.MapShapes_s(oriented_shell, shape_type, shape_map) + actual_counts[label] = int(shape_map.Extent()) + if ( + BRepCheck_Analyzer(oriented_shell).IsValid() + and actual_counts["face"] == expected_face_count + and actual_counts["edge"] == expected_edge_count + and actual_counts["vertex"] == expected_vertex_count + ): + edge_cache.clear() + edge_cache.update(shared_edge_cache) + return oriented_shell + except Exception: + # The independent-face sewing path below is intentionally retained + # for malformed or non-manifold source topology. + pass + + sewing = BRepBuilderAPI_Sewing( + max(shell_tolerance, 1e-3), True, True, True, False + ) + rebuilt_faces: list[tuple[dict[str, Any], Any]] = [] + for face_payload in shell_payload["faces"]: + candidates = [] + failures = [] + expected_area = abs(float(face_payload.get("area", 0.0))) + expected_centroid = face_payload.get("centroid") + expected_bounds = face_payload.get("bounds") + + def candidate_score( + candidate_face: Any, strategy_index: int + ) -> tuple[Any, ...]: + properties = GProp_GProps() + BRepGProp.SurfaceProperties_s(candidate_face, properties) + area_error = abs(abs(float(properties.Mass())) - expected_area) + area_tolerance = max(1e-8, expected_area * 1e-7) + centroid_error = 0.0 + if ( + isinstance(expected_centroid, list) + and len(expected_centroid) == 3 + ): + center = properties.CentreOfMass() + centroid_error = math.sqrt( + sum( + ( + value + - float(expected_centroid[index]) + ) + ** 2 + for index, value in enumerate( + (center.X(), center.Y(), center.Z()) + ) + ) + ) + bounds_error = 0.0 + if isinstance(expected_bounds, list) and len(expected_bounds) == 6: + bounds = Bnd_Box() + BRepBndLib.AddOptimal_s(candidate_face, bounds) + candidate_bounds = bounds.Get() + bounds_error = max( + abs( + float(candidate_bounds[index]) + - float(expected_bounds[index]) + ) + for index in range(6) + ) + return ( + not BRepCheck_Analyzer(candidate_face).IsValid(), + area_error > area_tolerance, + centroid_error, + bounds_error, + area_error, + strategy_index, + candidate_face, + ) + + for strategy_index, strategy in enumerate(strategies): + try: + face = _face_from_record( + face_payload, strategy, {}, vertex_cache + ) + candidates.append(candidate_score(face, strategy_index)) + except Exception as exc: + failures.append( + f"{strategy}: {type(exc).__name__}: {exc}" + ) + if len(face_payload.get("wires", [])) == 1: + try: + natural_payload = dict(face_payload) + natural_payload["wires"] = [] + natural_face = _face_from_record( + natural_payload, + boundary_strategy, + {}, + vertex_cache, + ) + natural_properties = GProp_GProps() + BRepGProp.SurfaceProperties_s( + natural_face, natural_properties + ) + natural_area_error = abs( + abs(float(natural_properties.Mass())) - expected_area + ) + if natural_area_error <= max( + 1e-8, expected_area * 1e-7 + ): + candidates.append( + candidate_score(natural_face, len(strategies)) + ) + except Exception as exc: + failures.append( + "natural_uv_if_exact: " + f"{type(exc).__name__}: {exc}" + ) + if not candidates: + raise ValueError( + f"Cannot reconstruct face {face_payload['id']}; " + + " | ".join(failures) + ) + candidates.sort(key=lambda item: item[:-1]) + selected = candidates[0][-1] + rebuilt_faces.append((face_payload, selected)) + sewing.Add(selected) + sewing.Perform() + sewed_shape = sewing.SewedShape() + sewed_shells: list[Any] = [] + sewed_shell_explorer = TopExp_Explorer(sewed_shape, TopAbs_SHELL) + while sewed_shell_explorer.More(): + sewed_shells.append( + TopoDS.Shell_s(sewed_shell_explorer.Current()) + ) + sewed_shell_explorer.Next() + if ( + len(sewed_shells) == 1 + and BRepCheck_Analyzer(sewed_shells[0]).IsValid() + ): + # Use the actual sewed topology. In contrast to ShapeFix_Shell, this + # retains the edge sharing established by BRepBuilderAPI_Sewing and + # does not duplicate every boundary edge. + return sewed_shells[0] + + shell_builder = BRep_Builder() + shell = TopoDS_Shell() + shell_builder.MakeShell(shell) + for face_payload, original_face in rebuilt_faces: + modified = ( + sewing.Modified(original_face) + if sewing.IsModified(original_face) + else original_face + ) + face_explorer = TopExp_Explorer(modified, TopAbs_FACE) + face = ( + TopoDS.Face_s(face_explorer.Current()) + if face_explorer.More() + else TopoDS.Face_s(modified) + ) + expected_contribution = face_payload.get("volume_contribution") + if isinstance(expected_contribution, (int, float)): + properties = GProp_GProps() + BRepGProp.VolumeProperties_s(face, properties) + contribution = float(properties.Mass()) + expected = float(expected_contribution) + if abs(-contribution - expected) + 1e-10 < abs( + contribution - expected + ): + face = TopoDS.Face_s(face.Reversed()) + shell_builder.Add(shell, face) + shell.Closed(True) + if not BRepCheck_Analyzer(shell).IsValid(): + shell_fixer = ShapeFix_Shell(shell) + shell_fixer.SetPrecision(max(shell_tolerance, 1e-7)) + shell_fixer.SetMaxTolerance(max(shell_tolerance, 1e-3)) + shell_fixer.Perform() + repaired_shell = shell_fixer.Shell() + if BRepCheck_Analyzer(repaired_shell).IsValid(): + shell = repaired_shell + return shell + + def build_surfaceir( payload: dict[str, Any], boundary_strategy: str | None = None ) -> Any: @@ -2044,6 +2522,7 @@ def build_surfaceir( if boundary_strategy not in { "exact_3d", "exact_3d_pcurve", + "shared_pcurve", "first_pcurve", "analytic_uv_rect", "uv_rect_all", @@ -2065,75 +2544,57 @@ def build_surfaceir( for solid_payload in payload["surface_layer"]["solids"]: rebuilt_shells = [] for shell_payload in solid_payload["shells"]: - shell_tolerance = max( - [ - float(edge.get("tolerance", 1e-7)) - for face in shell_payload["faces"] - for wire in face["wires"] - for edge in wire["edges"] - ] - or [1e-7] + shell = _solid_shell_from_record( + shell_payload, + boundary_strategy, + edge_cache, + vertex_cache, ) - sewing = BRepBuilderAPI_Sewing( - max(shell_tolerance, 1e-7), True, True, True, False - ) - for face_payload in shell_payload["faces"]: - try: - face = _face_from_record( - face_payload, - boundary_strategy, - edge_cache, - vertex_cache, - ) - except Exception as exc: - raise ValueError( - f"Cannot rebuild face {face_payload['id']}: " - f"{type(exc).__name__}: {exc}" - ) from exc - sewing.Add(face) - sewing.Perform() - sewed = sewing.SewedShape() - shell_explorer = TopExp_Explorer(sewed, TopAbs_SHELL) - if not shell_explorer.More(): - raise ValueError(f"Cannot sew shell {shell_payload['id']}") - shell = TopoDS.Shell_s(shell_explorer.Current()) - if boundary_strategy == "exact_3d_pcurve": - rebuilt_shells.append(shell) - else: - solid_builder = BRepBuilderAPI_MakeSolid(shell) - if not solid_builder.IsDone(): - raise ValueError( - f"Cannot rebuild solid {solid_payload['id']}" - ) - rebuilt_solids.append(solid_builder.Solid()) - if boundary_strategy == "exact_3d_pcurve": - solid_builder = BRepBuilderAPI_MakeSolid() - for shell in rebuilt_shells: - solid_builder.Add(shell) - if not solid_builder.IsDone(): - raise ValueError(f"Cannot rebuild solid {solid_payload['id']}") - rebuilt_solids.append(solid_builder.Solid()) + if int(shell_payload.get("orientation", 0)) == 1: + shell = TopoDS.Shell_s(shell.Reversed()) + rebuilt_shells.append(shell) + solid_builder = BRepBuilderAPI_MakeSolid() + for shell in rebuilt_shells: + solid_builder.Add(shell) + if not solid_builder.IsDone(): + raise ValueError(f"Cannot rebuild solid {solid_payload['id']}") + solid = solid_builder.Solid() + if int(solid_payload.get("orientation", 0)) == 1: + solid = TopoDS.Solid_s(solid.Reversed()) + expected_signed_volume = solid_payload.get("signed_volume") + if isinstance(expected_signed_volume, (int, float)): + signed_volume = _signed_volume(solid) + if abs(-signed_volume - float(expected_signed_volume)) < abs( + signed_volume - float(expected_signed_volume) + ): + solid = TopoDS.Solid_s(solid.Reversed()) + rebuilt_solids.append(solid) for shell_payload in payload["surface_layer"].get("free_shells", []): shell_builder = BRep_Builder() shell = TopoDS_Shell() shell_builder.MakeShell(shell) + free_faces = [] for face_payload in shell_payload["faces"]: try: - shell_builder.Add( - shell, - _face_from_record( - face_payload, - boundary_strategy, - edge_cache, - vertex_cache, - ) + face = _face_from_record( + face_payload, + boundary_strategy, + edge_cache, + vertex_cache, ) + free_faces.append(face) + shell_builder.Add(shell, face) except Exception as exc: raise ValueError( f"Cannot rebuild free-shell face {face_payload['id']}: " f"{type(exc).__name__}: {exc}" ) from exc - rebuilt_free_shells.append(shell) + # Keep a one-face open shell as an explicit surface-model component + # instead of pretending it is a closed solid. + free_component = free_faces[0] if len(free_faces) == 1 else shell + if int(shell_payload.get("orientation", 0)) == 1: + free_component = free_component.Reversed() + rebuilt_free_shells.append(free_component) components = rebuilt_solids + rebuilt_free_shells if len(components) == 1: result = components[0] @@ -2308,6 +2769,9 @@ def extract_surfaceir(step_path: Path) -> dict[str, Any]: def write_json(path: Path, payload: dict[str, Any]) -> None: + if path.name.endswith(".designir.json"): + write_designir(path, payload) + return path.parent.mkdir(parents=True, exist_ok=True) path.write_text( json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + "\n", @@ -2387,7 +2851,7 @@ def augment_overall_scale(designir_dir: Path, output: Path) -> dict[str, Any]: if manifest_row["state"] != "extracted": continue designir_path = designir_dir / manifest_row["output_name"] - payload = json.loads(designir_path.read_text(encoding="utf-8")) + payload = read_designir(designir_path) vertices = payload.get("surface_layer", {}).get("vertices", []) if ( payload.get("document_status") != "geometry_present" @@ -2492,7 +2956,7 @@ def augment_axis_affine_bindings( if manifest_row["state"] != "extracted": continue designir_path = designir_dir / manifest_row["output_name"] - payload = json.loads(designir_path.read_text(encoding="utf-8")) + payload = read_designir(designir_path) if payload.get("document_status") != "geometry_present": continue parameters = ( @@ -2565,7 +3029,7 @@ def augment_directional_size_bindings( if manifest_row["state"] != "extracted": continue designir_path = designir_dir / manifest_row["output_name"] - payload = json.loads(designir_path.read_text(encoding="utf-8")) + payload = read_designir(designir_path) if payload.get("document_status") != "geometry_present": continue semantic_layer = payload.get("semantic_layer", {}) @@ -2676,7 +3140,7 @@ def augment_directional_size_bindings( def _facts_from_shape(shape: Any) -> dict[str, Any]: wrapped = shape.wrapped if hasattr(shape, "wrapped") else shape bounds = Bnd_Box() - BRepBndLib.Add_s(wrapped, bounds) + BRepBndLib.AddOptimal_s(wrapped, bounds) if bounds.IsVoid(): x_min = y_min = z_min = x_max = y_max = z_max = 0.0 else: @@ -2707,8 +3171,26 @@ def _facts_from_shape(shape: Any) -> dict[str, Any]: shape_map = TopTools_IndexedMapOfShape() TopExp.MapShapes_s(wrapped, shape_type, shape_map) counts[name] = shape_map.Extent() + solid_subshape_counts = { + "solid_face_count": 0, + "solid_edge_count": 0, + "solid_vertex_count": 0, + } + solid_explorer = TopExp_Explorer(wrapped, TopAbs_SOLID) + while solid_explorer.More(): + solid = TopoDS.Solid_s(solid_explorer.Current()) + for name, shape_type in { + "solid_face_count": TopAbs_FACE, + "solid_edge_count": TopAbs_EDGE, + "solid_vertex_count": TopAbs_VERTEX, + }.items(): + shape_map = TopTools_IndexedMapOfShape() + TopExp.MapShapes_s(solid, shape_type, shape_map) + solid_subshape_counts[name] += shape_map.Extent() + solid_explorer.Next() return { **counts, + **solid_subshape_counts, "volume": float(volume_value), "surface_area": surface_area, "center_of_mass": [float(center.X()), float(center.Y()), float(center.Z())], @@ -2741,6 +3223,52 @@ def _max_error(first: list[float], second: list[float]) -> float: return max(abs(a - b) for a, b in zip(first, second)) +def _solid_geometry(shape: Any) -> Any: + """Return only solid components for solid-reconstruction comparison.""" + wrapped = shape.wrapped if hasattr(shape, "wrapped") else shape + solids = [] + explorer = TopExp_Explorer(wrapped, TopAbs_SOLID) + while explorer.More(): + solids.append(TopoDS.Solid_s(explorer.Current())) + explorer.Next() + if len(solids) == 1: + return solids[0] + builder = BRep_Builder() + compound = TopoDS_Compound() + builder.MakeCompound(compound) + for solid in solids: + builder.Add(compound, solid) + return compound + + +def _robust_cut_volume( + first_shape: Any, second_shape: Any, fuzzy_tolerance: float = 1e-9 +) -> float: + first = ( + first_shape.wrapped + if hasattr(first_shape, "wrapped") + else first_shape + ) + second = ( + second_shape.wrapped + if hasattr(second_shape, "wrapped") + else second_shape + ) + volumes = [] + for tool in (second, second.Reversed()): + operation = BRepAlgoAPI_Cut(first, tool) + operation.SetFuzzyValue(fuzzy_tolerance) + operation.Build() + if not operation.IsDone(): + continue + properties = GProp_GProps() + BRepGProp.VolumeProperties_s(operation.Shape(), properties) + volumes.append(abs(float(properties.Mass()))) + if not volumes: + raise ValueError("Bidirectional B-Rep Boolean difference failed") + return min(volumes) + + def _sample_shape_points(shape: Any) -> list[gp_Pnt]: wrapped = shape.wrapped if hasattr(shape, "wrapped") else shape points: list[gp_Pnt] = [] @@ -2829,7 +3357,24 @@ def validate_folder( source = input_dir / row["source_name"] designir_path = designir_dir / row["output_name"] rebuilt = rebuilt_dir / f"{Path(row['source_name']).stem}.step" - payload = json.loads(designir_path.read_text(encoding="utf-8")) + payload = read_designir(designir_path) + if payload.get("document_status") == "empty_geometry": + results.append( + { + "source_name": row["source_name"], + "state": "empty_source_no_geometry", + "geometry_pass": False, + "usable_reconstruction": False, + "quality_tier": "not_applicable", + "surface_vocabulary": [], + "curve_vocabulary": [], + "reason": ( + "The source STEP document contains no faces or solids; " + "there is no geometry to reconstruct or validate." + ), + } + ) + continue try: shape = build_surfaceir(payload, boundary_strategy) export_step_shape(shape, rebuilt) @@ -2852,7 +3397,11 @@ def validate_folder( ) topology_match = all( teacher[name] == rebuilt_facts[name] - for name in ["solid_count", "face_count", "edge_count"] + for name in [ + "solid_count", + "solid_face_count", + "solid_edge_count", + ] ) vertex_count_delta = ( rebuilt_facts["vertex_count"] - teacher["vertex_count"] @@ -2862,21 +3411,29 @@ def validate_folder( sample_point_count = None if boolean_check and teacher["solid_count"] > 0: try: - symmetric_difference_ratio = float( - (teacher_shape - rebuilt_shape).volume - + (rebuilt_shape - teacher_shape).volume + symmetric_difference_ratio = ( + _robust_cut_volume( + teacher_shape, rebuilt_shape + ) + + _robust_cut_volume( + rebuilt_shape, teacher_shape + ) ) / volume_base except Exception: symmetric_difference_ratio = math.inf if symmetric_difference_ratio > 1e-8: + teacher_distance_shape = _solid_geometry(teacher_shape) + rebuilt_distance_shape = _solid_geometry(rebuilt_shape) forward_distance, forward_count = ( _directed_sample_distance( - teacher_shape, rebuilt_shape + teacher_distance_shape, + rebuilt_distance_shape, ) ) reverse_distance, reverse_count = ( _directed_sample_distance( - rebuilt_shape, teacher_shape + rebuilt_distance_shape, + teacher_distance_shape, ) ) bidirectional_sample_distance = max( @@ -2902,11 +3459,31 @@ def validate_folder( ) ) ) + usable_reconstruction = ( + rebuilt_facts["valid"] + and topology_match + and relative_volume_error <= 2e-3 + and relative_surface_area_error <= 1e-3 + and center_error <= 2e-2 + and bounds_error <= 1e-5 + and ( + bidirectional_sample_distance is None + or bidirectional_sample_distance <= 1e-1 + ) + ) results.append( { "source_name": row["source_name"], "state": "validated" if geometry_pass else "geometry_mismatch", "geometry_pass": geometry_pass, + "usable_reconstruction": usable_reconstruction, + "quality_tier": ( + "surface_geometry_accepted_pending_brep" + if geometry_pass + else "review_required" + if usable_reconstruction + else "rejected" + ), "surface_vocabulary": payload["surface_layer"][ "surface_vocabulary" ], @@ -2932,6 +3509,8 @@ def validate_folder( "source_name": row["source_name"], "state": "rebuild_failed", "geometry_pass": False, + "usable_reconstruction": False, + "quality_tier": "rejected", "surface_vocabulary": payload["surface_layer"][ "surface_vocabulary" ], @@ -2940,14 +3519,26 @@ def validate_folder( } ) state_counts = Counter(row["state"] for row in results) + applicable_results = [ + row for row in results if row["state"] != "empty_source_no_geometry" + ] report = { "schema_version": "1.0", "report_kind": "designir_3_independent_rebuild_validation", "case_count": len(results), + "applicable_case_count": len(applicable_results), + "empty_source_count": len(results) - len(applicable_results), "offset": offset, "boundary_strategy": boundary_strategy, - "geometry_pass_count": sum(row["geometry_pass"] for row in results), - "geometry_fail_count": sum(not row["geometry_pass"] for row in results), + "geometry_pass_count": sum( + row["geometry_pass"] for row in applicable_results + ), + "geometry_fail_count": sum( + not row["geometry_pass"] for row in applicable_results + ), + "usable_reconstruction_count": sum( + row.get("usable_reconstruction", False) for row in results + ), "state_counts": dict(sorted(state_counts.items())), "thresholds": { "relative_volume_error_max": 1e-7, @@ -2958,6 +3549,15 @@ def validate_folder( "bidirectional_sample_distance_mm_max": ( 1e-4 if boolean_check else None ), + "review_required": { + "relative_volume_error_max": 2e-3, + "relative_surface_area_error_max": 1e-3, + "center_of_mass_error_mm_max": 2e-2, + "bounds_error_mm_max": 1e-5, + "bidirectional_solid_sample_distance_mm_max": ( + 1e-1 if boolean_check else None + ), + }, "topology_must_match": True, "vertex_count_normalization_is_diagnostic": True, }, @@ -3082,7 +3682,7 @@ def select_validated_strategies( designir_path = ( designir_dir / f"{Path(primary_row['source_name']).stem}.designir.json" ) - payload = json.loads(designir_path.read_text(encoding="utf-8")) + payload = read_designir(designir_path) payload["reconstruction_strategy"] = { "boundary_strategy": selected, "selection_status": status, @@ -3314,7 +3914,7 @@ def validate_semantic_edit( ) -> dict[str, Any]: """Acceptance-Agent check for one isolated semantic perturbation.""" - payload = json.loads(designir_path.read_text(encoding="utf-8")) + payload = read_designir(designir_path) edited = apply_semantic_parameter(payload, parameter_name, value) output_dir.mkdir(parents=True, exist_ok=True) edited_ir_path = output_dir / f"{designir_path.stem}.edited.json" @@ -3605,7 +4205,7 @@ def validate_semantic_edit( edited["active_edit"]["selected_boundary_strategy"] = edited[ "reconstruction_strategy" ]["boundary_strategy"] - write_json(edited_ir_path, edited) + write_designir(edited_ir_path, edited) expected_parameters = payload["semantic_layer"]["parameters"] expected_value = float(value) checks["source_independence"] = { @@ -3731,7 +4331,7 @@ def validate_semantic_edit( } edited["active_edit"]["acceptance_status"] = state - write_json(edited_ir_path, edited) + write_designir(edited_ir_path, edited) report = { "schema_version": "1.0", "report_kind": "designir_3_semantic_edit_acceptance", @@ -3789,7 +4389,7 @@ def validate_edits_folder( for row in rows: source_name = row["source_name"] designir_path = designir_dir / row["output_name"] - payload = json.loads(designir_path.read_text(encoding="utf-8")) + payload = read_designir(designir_path) if geometry_pass and not geometry_pass.get(source_name, False): case_rows.append( { @@ -4087,7 +4687,7 @@ def apply_edit_validation_to_designir( designir_path = ( designir_dir / f"{Path(source_name).stem}.designir.json" ) - payload = json.loads(designir_path.read_text(encoding="utf-8")) + payload = read_designir(designir_path) parameters = payload.get("semantic_layer", {}).get("parameters", {}) if parameter_name not in parameters: raise ValueError( @@ -4159,6 +4759,7 @@ def main() -> None: choices=[ "exact_3d", "exact_3d_pcurve", + "shared_pcurve", "first_pcurve", "analytic_uv_rect", "uv_rect_all", @@ -4227,6 +4828,7 @@ def main() -> None: choices=[ "exact_3d", "exact_3d_pcurve", + "shared_pcurve", "first_pcurve", "analytic_uv_rect", "uv_rect_all", @@ -4263,16 +4865,16 @@ def main() -> None: ) ) elif args.command == "rebuild": - payload = json.loads(args.designir.read_text(encoding="utf-8")) + payload = read_designir(args.designir) shape = build_surfaceir(payload, args.boundary_strategy) export_step_shape(shape, args.output) print(json.dumps({"output": str(args.output.resolve())})) elif args.command == "edit": - payload = json.loads(args.designir.read_text(encoding="utf-8")) + payload = read_designir(args.designir) edited = apply_semantic_parameter( payload, args.parameter, args.value ) - write_json(args.output_designir.resolve(), edited) + write_designir(args.output_designir.resolve(), edited) output = { "output_designir": str(args.output_designir.resolve()), "parameter": args.parameter, @@ -4465,6 +5067,9 @@ def main() -> None: "case_count": report["case_count"], "geometry_pass_count": report["geometry_pass_count"], "geometry_fail_count": report["geometry_fail_count"], + "usable_reconstruction_count": report[ + "usable_reconstruction_count" + ], "state_counts": report["state_counts"], }, ensure_ascii=False, diff --git a/designir-pipeline/skills/cad-experience-builder/scripts/step_to_case.py b/designir-pipeline/skills/cad-experience-builder/scripts/step_to_case.py index 3903aa0..309d5cb 100644 --- a/designir-pipeline/skills/cad-experience-builder/scripts/step_to_case.py +++ b/designir-pipeline/skills/cad-experience-builder/scripts/step_to_case.py @@ -178,7 +178,62 @@ def _semantic_parameter_candidates( ) for item in repeated } - if len(unique_centers) >= 3: + inferred_gear_kind = semantic_summary.get("inferred_gear_kind") + inferred_tooth_count = int( + semantic_summary.get("inferred_tooth_count", 0) + ) + if inferred_gear_kind and inferred_tooth_count >= 3: + transverse_span = max(bbox_size[index] for index in transverse_axes) + if inferred_gear_kind == "internal_spur_gear" and coaxial: + internal_tip_diameter = 2.0 * min( + float(item["radius"]) for item in coaxial + ) + inferred_module = internal_tip_diameter / ( + inferred_tooth_count - 2.0 + ) + else: + inferred_module = transverse_span / ( + inferred_tooth_count + 2.0 + ) + candidates.extend( + [ + _parameter_candidate( + "tooth_count", + inferred_tooth_count, + "count", + 0.9, + ["equal_angular_tooth_pattern"], + "gear_tooth_count_changes", + "paired_equal_radius_cylindrical_tooth_flanks", + ["gear_teeth"], + ), + _parameter_candidate( + "gear_module", + inferred_module, + "mm", + 0.72, + ["spur_gear_body"], + "gear_pitch_and_tooth_scale_changes", + ( + "internal_tip_diameter_and_tooth_count" + if inferred_gear_kind == "internal_spur_gear" + else "external_envelope_and_tooth_count" + ), + ["module"], + ), + _parameter_candidate( + "gear_width", + bbox_size[{"x": 0, "y": 1, "z": 2}[dominant_axis]], + "mm", + 0.96, + ["spur_gear_body"], + "gear_axial_width_changes", + "dominant_axis_bounding_span", + ["face_width"], + ), + ] + ) + elif len(unique_centers) >= 3: member_radius = float(repeated[0]["radius"]) offsets = [ math.hypot( @@ -419,6 +474,41 @@ def _semantic_summary( and pattern_spread / pattern_mean_offset <= 0.08 ) + # Spur gears exported by common parametric generators often expose two + # equal-radius cylindrical flank faces per tooth. Without this guard the + # generic repeated-cylinder detector labels those flanks as a bolt-hole + # pattern and invents hole_count / bolt_circle_diameter parameters. + # + # This is intentionally a conservative final-B-Rep classifier. It does + # not claim that the source history contained a gear feature. + gear_like_surface_mix = bool( + has_rotational_stack + and has_circular_pattern + and len(largest_repeated_group) >= 12 + and len(largest_repeated_group) % 2 == 0 + and len(planes) <= 3 + and not cones + and not toruses + and not spheres + and not other_surfaces + and dominant_span / max(transverse_span, 1e-9) <= 0.6 + ) + inferred_tooth_count = ( + len(largest_repeated_group) // 2 if gear_like_surface_mix else 0 + ) + centered_radius_ratio = _ratio( + smallest_coaxial_radius, transverse_span + ) + inferred_gear_kind = ( + "internal_spur_gear" + if gear_like_surface_mix and centered_radius_ratio >= 0.35 + else "external_spur_gear" + if gear_like_surface_mix + else None + ) + if gear_like_surface_mix: + has_repeated_axial_holes = False + # Multiple radii sharing a transverse center are a robust final-B-Rep # signal for stepped bores/counterbores, without claiming operation order. transverse_groups: dict[tuple[int, int, int], set[float]] = defaultdict(set) @@ -479,7 +569,22 @@ def _semantic_summary( if other_surfaces: features.append({"type": "freeform_surface_region"}) if has_circular_pattern: - features.append({"type": "circular_equal_radius_pattern"}) + features.append( + { + "type": ( + "equal_angular_tooth_pattern" + if gear_like_surface_mix + else "circular_equal_radius_pattern" + ) + } + ) + if inferred_gear_kind: + features.extend( + [ + {"type": "spur_gear_body"}, + {"type": inferred_gear_kind}, + ] + ) if has_stepped_cylindrical_passage: features.append({"type": "stepped_cylindrical_passage"}) if has_rotational_stack: @@ -515,7 +620,18 @@ def _semantic_summary( ) if has_circular_pattern: constraints.append( - {"id": "common_pattern_radius", "type": "radial_pattern"} + { + "id": ( + "equal_angular_tooth_pattern" + if gear_like_surface_mix + else "common_pattern_radius" + ), + "type": ( + "gear_tooth_pattern" + if gear_like_surface_mix + else "radial_pattern" + ), + } ) if has_multi_axis_passages: constraints.append( @@ -526,7 +642,9 @@ def _semantic_summary( {"id": "shared_passage_axis", "type": "coaxial"} ) - if has_rotational_stack and has_repeated_axial_holes: + if inferred_gear_kind: + family = inferred_gear_kind + elif has_rotational_stack and has_repeated_axial_holes: family = "flanged_rotational_part" elif has_rotational_stack and elongated and len(distinct_coaxial_radii) >= 3: family = "hollow_or_stepped_shaft" @@ -622,6 +740,8 @@ def _semantic_summary( else 0, "parallel_cone_surface_count": len(parallel_cones), "has_multi_axis_passages": has_multi_axis_passages, + "inferred_gear_kind": inferred_gear_kind, + "inferred_tooth_count": inferred_tooth_count, "shape_class": ( "elongated" if elongated else "plate_like" if plate_like else "compact" if compact diff --git a/designir-pipeline/tests/test_assembly_designir.py b/designir-pipeline/tests/test_assembly_designir.py new file mode 100644 index 0000000..cb8de19 --- /dev/null +++ b/designir-pipeline/tests/test_assembly_designir.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +import copy +import importlib.util +import json +from pathlib import Path + + +REPO = Path(__file__).resolve().parents[2] +MODEL_DIR = REPO / "models" / "lcd25-reducer" / "semantic-assembly" +SCHEMA_PATH = ( + REPO + / "designir-pipeline" + / "contracts" + / "assembly-designir-1.0.schema.json" +) +VALIDATOR_PATH = MODEL_DIR / "validate_assembly_design.py" + + +def load_validator(): + spec = importlib.util.spec_from_file_location( + "assembly_design_validator", VALIDATOR_PATH + ) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def load_json(path: Path): + return json.loads(path.read_text(encoding="utf-8")) + + +def test_lcd25_coordinate_free_base_model_is_valid(): + validator = load_validator() + schema = load_json(SCHEMA_PATH) + payload = load_json( + MODEL_DIR / "lcd25-harmonic-reducer.assembly-designir.json" + ) + report = validator.validate(payload, schema) + assert report["status"] == "pass" + assert report["coordinate_free"] is True + assert report["component_count"] == 10 + assert report["relation_count"] == 15 + + +def test_output_shaft_patch_preserves_coordinate_free_contract(): + validator = load_validator() + schema = load_json(SCHEMA_PATH) + payload = load_json( + MODEL_DIR / "lcd25-harmonic-reducer.assembly-designir.json" + ) + patch = load_json(MODEL_DIR / "variants" / "add-output-shaft.patch.json") + resolved = validator.apply_patch(payload, patch) + report = validator.validate(resolved, schema) + assert report["status"] == "pass" + assert report["coordinate_free"] is True + assert report["component_count"] == 11 + assert "output_shaft" in { + component["id"] for component in resolved["components"] + } + assert "output_shaft" in next( + group["members"] + for group in resolved["kinematics"]["rigid_groups"] + if group["id"] == "output_group" + ) + + +def test_authored_transform_is_rejected(): + validator = load_validator() + schema = load_json(SCHEMA_PATH) + payload = load_json( + MODEL_DIR / "lcd25-harmonic-reducer.assembly-designir.json" + ) + invalid = copy.deepcopy(payload) + invalid["components"][0]["transform"] = [ + [1, 0, 0, 0], + [0, 1, 0, 0], + [0, 0, 1, 0], + ] + report = validator.validate(invalid, schema) + assert report["status"] == "fail" + assert report["coordinate_free"] is False + assert any( + "coordinate placement key is forbidden" in failure + for failure in report["failures"] + ) diff --git a/designir-pipeline/tests/test_designir_pipeline.py b/designir-pipeline/tests/test_designir_pipeline.py index 78164a5..dd815a0 100644 --- a/designir-pipeline/tests/test_designir_pipeline.py +++ b/designir-pipeline/tests/test_designir_pipeline.py @@ -81,7 +81,13 @@ class DesignIRPipelineTests(unittest.TestCase): ) self.assertEqual("simplecadapi", result["backend"]) self.assertGreater(result["facts"]["volume_mm3"], 30_000) - self.assertTrue(Path(result["model_json"]).is_file()) + model_json_path = Path(result["model_json"]) + self.assertTrue(model_json_path.is_file()) + model_json = json.loads(model_json_path.read_text(encoding="utf-8")) + serialized = json.dumps(model_json) + self.assertIn("feature.base.flange", serialized) + self.assertIn("feature.central.bore", serialized) + self.assertTrue(model_json.get("leaf_ids")) def test_arbitrary_axis_geometric_center_preserves_pose(self) -> None: payload = self.payload() diff --git a/designir-pipeline/tests/test_experience_library.py b/designir-pipeline/tests/test_experience_library.py index aaa45c7..a59b0e9 100644 --- a/designir-pipeline/tests/test_experience_library.py +++ b/designir-pipeline/tests/test_experience_library.py @@ -2,6 +2,7 @@ from __future__ import annotations import importlib.util import json +import math import tempfile import unittest from pathlib import Path @@ -226,6 +227,63 @@ class CadExperienceTest(unittest.TestCase): self.assertFalse(normalized["reconstruction_eligible"]) self.assertIn("no_closed_solid", normalized["rejection_reasons"]) + def test_spur_gear_flanks_are_not_mislabeled_as_hole_pattern(self): + surfaces = [ + { + "type": "cylinder", + "axis": [1.0, 0.0, 0.0], + "location": [0.0, 0.0, 0.0], + "radius": radius, + "area": 100.0, + } + for radius in (3.0, 8.8) + ] + for index in range(40): + angle = 2.0 * math.pi * index / 40.0 + surfaces.append( + { + "type": "cylinder", + "axis": [1.0, 0.0, 0.0], + "location": [ + 0.0, + 7.5 * math.cos(angle), + 7.5 * math.sin(angle), + ], + "radius": 3.2, + "area": 10.0, + } + ) + surfaces.extend( + {"type": "plane", "area": 250.0} for _ in range(2) + ) + + family, features, constraints, summary = STEP_MODULE._semantic_summary( + surfaces, [4.0, 0.0, 0.0], [8.0, 17.6, 17.6] + ) + feature_roles = {item["type"] for item in features} + self.assertEqual("external_spur_gear", family) + self.assertEqual(20, summary["inferred_tooth_count"]) + self.assertIn("external_spur_gear", feature_roles) + self.assertIn("equal_angular_tooth_pattern", feature_roles) + self.assertNotIn("repeated_axial_hole_pattern", feature_roles) + self.assertIn( + "equal_angular_tooth_pattern", + {item["id"] for item in constraints}, + ) + + parameters = STEP_MODULE._semantic_parameter_candidates( + surfaces, + [4.0, 0.0, 0.0], + [8.0, 17.6, 17.6], + features, + summary, + ) + values = {item["name"]: item["value"] for item in parameters} + self.assertEqual(20, values["tooth_count"]) + self.assertAlmostEqual(0.8, values["gear_module"], places=6) + self.assertEqual(8.0, values["gear_width"]) + self.assertNotIn("repeated_feature_count", values) + def test_sha256_identity_deduplicates_renamed_sources(self): digest = "0" * 64 with tempfile.TemporaryDirectory() as temporary: diff --git a/designir-pipeline/tests/test_robot_description_export.py b/designir-pipeline/tests/test_robot_description_export.py new file mode 100644 index 0000000..7d39981 --- /dev/null +++ b/designir-pipeline/tests/test_robot_description_export.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +import importlib.util +import tempfile +import unittest +from pathlib import Path +import xml.etree.ElementTree as ET +import zipfile + +from build123d import Box, export_step + + +ROOT = Path(__file__).resolve().parents[1] + + +def load_module(name: str, path: Path): + spec = importlib.util.spec_from_file_location(name, path) + module = importlib.util.module_from_spec(spec) + assert spec.loader + spec.loader.exec_module(module) + return module + + +SURFACEIR = load_module( + "surfaceir_pipeline_robot_export_test", + ROOT / "scripts" / "surfaceir_pipeline.py", +) +ROBOT_EXPORT = load_module( + "robot_description_export_test", + ROOT / "scripts" / "robot_description_export.py", +) + + +class RobotDescriptionExportTests(unittest.TestCase): + def test_portable_urdf_and_mjcf_packages_rebuild_from_designir(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + teacher = root / "teacher.step" + designir = root / "box.designir.json" + export_step(Box(10, 20, 30), teacher) + SURFACEIR.write_json( + designir, + SURFACEIR.extract_surfaceir(teacher), + ) + teacher.unlink() + + for output_format, description_suffix, xml_root in ( + ("urdf", ".urdf", "robot"), + ("mjcf", ".mjcf.xml", "mujoco"), + ): + result = ROBOT_EXPORT.export_robot_package( + designir, + root / output_format, + output_format, + ) + package = Path(result["package"]) + self.assertTrue(package.is_file()) + with zipfile.ZipFile(package) as archive: + names = archive.namelist() + description = next( + name + for name in names + if name.endswith(description_suffix) + ) + self.assertTrue( + any(name.startswith("meshes/") and name.endswith(".stl") for name in names) + ) + self.assertIn("robot-export.json", names) + root_element = ET.fromstring(archive.read(description)) + self.assertEqual(xml_root, root_element.tag) + self.assertEqual( + "static_single_link", + result["robot_structure"], + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/designir-pipeline/tests/test_surfaceir_pipeline.py b/designir-pipeline/tests/test_surfaceir_pipeline.py index d658791..4662747 100644 --- a/designir-pipeline/tests/test_surfaceir_pipeline.py +++ b/designir-pipeline/tests/test_surfaceir_pipeline.py @@ -6,7 +6,7 @@ import tempfile import unittest from pathlib import Path -from build123d import Plane, Rectangle +from build123d import Align, Box, Plane, Rectangle, Sphere, export_step ROOT = Path(__file__).resolve().parents[1] @@ -29,6 +29,57 @@ DESIGNIR = load_module( class SurfaceIRPipelineTests(unittest.TestCase): + def test_closed_single_face_sphere_roundtrip(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + directory = Path(temporary) + teacher = directory / "sphere.step" + rebuilt_path = directory / "sphere-rebuilt.step" + expected = Sphere(1) + export_step(expected, teacher) + + payload = SURFACEIR.extract_surfaceir(teacher) + shell = payload["surface_layer"]["solids"][0]["shells"][0] + self.assertEqual(1, len(shell["faces"])) + + rebuilt = SURFACEIR.build_surfaceir( + payload, "exact_3d_pcurve" + ) + SURFACEIR.export_step_shape(rebuilt, rebuilt_path) + actual = DESIGNIR.import_step(rebuilt_path) + self.assertTrue(actual.is_valid) + self.assertEqual(1, len(actual.solids())) + self.assertAlmostEqual(expected.volume, actual.volume, places=9) + + def test_multi_shell_solid_preserves_periodic_inner_shell(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + directory = Path(temporary) + teacher = directory / "cavity.step" + rebuilt_path = directory / "cavity-rebuilt.step" + expected = Box( + 10, + 10, + 10, + align=(Align.CENTER, Align.CENTER, Align.CENTER), + ) - Sphere(1) + export_step(expected, teacher) + + payload = SURFACEIR.extract_surfaceir(teacher) + shells = payload["surface_layer"]["solids"][0]["shells"] + self.assertEqual(2, len(shells)) + self.assertTrue(any(len(shell["faces"]) == 1 for shell in shells)) + + rebuilt = SURFACEIR.build_surfaceir( + payload, "exact_3d_pcurve" + ) + SURFACEIR.export_step_shape(rebuilt, rebuilt_path) + actual = DESIGNIR.import_step(rebuilt_path) + self.assertTrue(actual.is_valid) + self.assertEqual(1, len(actual.solids())) + self.assertEqual(2, len(actual.solids()[0].shells())) + self.assertLess( + abs(expected.volume - actual.volume) / expected.volume, 1e-10 + ) + def test_independent_json_roundtrip_preserves_exact_geometry(self) -> None: with tempfile.TemporaryDirectory() as temporary: directory = Path(temporary) @@ -42,12 +93,27 @@ class SurfaceIRPipelineTests(unittest.TestCase): teacher.unlink() serialized = portable_json.read_text(encoding="utf-8").lower() + stored = json.loads(serialized) + self.assertEqual( + "gzip+base64+json", + stored["surface_layer"]["encoding"], + ) + self.assertEqual( + "surfaceir-3.0", + stored["surface_layer"]["format"], + ) self.assertNotIn("teacher.step", serialized) self.assertNotIn("source_path", serialized) self.assertNotIn("import_step", serialized) self.assertNotIn('"mesh"', serialized) - rebuilt_shape = SURFACEIR.build_surfaceir(surfaceir) + decoded = SURFACEIR.read_designir(portable_json) + self.assertEqual(surfaceir["surface_layer"], decoded["surface_layer"]) + self.assertIn( + "overall_scale", + decoded["edit_interface"]["semantic_parameters"], + ) + rebuilt_shape = SURFACEIR.build_surfaceir(decoded) SURFACEIR.export_step_shape(rebuilt_shape, rebuilt) self.assertTrue(rebuilt.is_file()) diff --git a/models/lcd25-reducer/semantic-assembly/README.md b/models/lcd25-reducer/semantic-assembly/README.md new file mode 100644 index 0000000..c721175 --- /dev/null +++ b/models/lcd25-reducer/semantic-assembly/README.md @@ -0,0 +1,79 @@ +# Coordinate-free semantic assembly + +This directory defines the LCD25 reducer as a reusable mechanical design rather +than a list of STEP filenames and placement matrices. + +## Authority + +`lcd25-harmonic-reducer.assembly-designir.json` is the editable assembly +authority. It contains: + +- product parameters and expressions; +- replaceable component archetypes; +- semantic interfaces constructed from generated geometry; +- mating and support relations; +- rigid groups, joints and harmonic transmission behavior; +- edit operations and interfaces that must remain stable. + +It intentionally contains no XYZ position, rotation, transform matrix, source +STEP path, or occurrence coordinate. A backend generates each component, +discovers its declared interfaces and solves the constraint graph. Numeric +placements are compiler output and may be cached separately, but never become +design authority. + +## Generality + +The contract is not specific to LCD25. Component identities describe functional +roles such as `fixed_tooth_member`, `combined_load_support`, and +`external_output_interface`. Each role selects a replaceable generation family. +The same contract can express harmonic, planetary, cycloidal, belt, chain and +screw transmissions by changing archetypes, relations and the transmission +block. + +## Structural modification + +`variants/add-output-shaft.patch.json` demonstrates a structural edit. It adds: + +- editable shaft diameter and length; +- a generated stepped-shaft component; +- coaxial, shoulder-mate, insert and torque-lock relations; +- output rigid-group membership; +- a new public shaft-end interface. + +No coordinate is supplied. The assembly solver must position the shaft from its +relations to the output flange. + +## Validation + +Validate the base model: + +```bash +python3 models/lcd25-reducer/semantic-assembly/validate_assembly_design.py \ + models/lcd25-reducer/semantic-assembly/lcd25-harmonic-reducer.assembly-designir.json \ + --schema designir-pipeline/contracts/assembly-designir-1.0.schema.json +``` + +Apply and validate the shaft variant: + +```bash +python3 models/lcd25-reducer/semantic-assembly/validate_assembly_design.py \ + models/lcd25-reducer/semantic-assembly/lcd25-harmonic-reducer.assembly-designir.json \ + --schema designir-pipeline/contracts/assembly-designir-1.0.schema.json \ + --patch models/lcd25-reducer/semantic-assembly/variants/add-output-shaft.patch.json \ + --resolved-output models/lcd25-reducer/semantic-assembly/generated/lcd25-with-output-shaft.assembly-designir.json +``` + +## Compiler boundary + +The next compiler stage must: + +1. generate or select geometry for each component archetype; +2. resolve every semantic interface against generated topology; +3. solve relations from a scrambled initial state; +4. emit STEP occurrence placements only as derived output; +5. compile joints and transmissions to URDF/MJCF; +6. reject underconstrained, overconstrained or interfering assemblies. + +The current validator proves schema integrity, coordinate independence, +reference integrity and graph connectivity. It does not yet solve B-Rep +constraints or generate the final reducer STEP. diff --git a/models/lcd25-reducer/semantic-assembly/generated/lcd25-with-output-shaft.assembly-designir.json b/models/lcd25-reducer/semantic-assembly/generated/lcd25-with-output-shaft.assembly-designir.json new file mode 100644 index 0000000..45eb581 --- /dev/null +++ b/models/lcd25-reducer/semantic-assembly/generated/lcd25-with-output-shaft.assembly-designir.json @@ -0,0 +1,967 @@ +{ + "schema_version": "assembly-1.0", + "designir_kind": "coordinate_free_semantic_assembly", + "model_id": "lcd25_harmonic_reducer_with_output_shaft", + "family": "harmonic_reducer_module", + "units": "mm", + "design_intent": "A compact coaxial harmonic reducer whose geometry, assembly and motion are regenerated from functional interfaces rather than stored placements.", + "parameters": { + "nominal_size": { + "kind": "length", + "value": 25.0, + "unit": "mm", + "editable": true, + "bounds": [ + 20.0, + 40.0 + ], + "status": "inferred", + "description": "Nominal transmission size controlling the scalable component family." + }, + "reduction_ratio": { + "kind": "ratio", + "value": 100.0, + "editable": true, + "bounds": [ + 50.0, + 160.0 + ], + "status": "inferred", + "description": "Input rotations per output rotation." + }, + "outer_envelope_diameter": { + "kind": "length", + "value": 110.0, + "unit": "mm", + "editable": true, + "bounds": [ + 90.0, + 140.0 + ], + "status": "specified", + "description": "Maximum housing envelope." + }, + "axial_envelope": { + "kind": "length", + "value": 32.0, + "unit": "mm", + "editable": true, + "bounds": [ + 25.0, + 50.0 + ], + "status": "specified", + "description": "Maximum reducer axial package length." + }, + "output_bore_diameter": { + "kind": "length", + "value": 14.0, + "unit": "mm", + "editable": true, + "bounds": [ + 8.0, + 25.0 + ], + "status": "estimated", + "description": "Central output interface bore." + }, + "housing_wall": { + "kind": "length", + "value": 3.0, + "unit": "mm", + "editable": true, + "bounds": [ + 2.0, + 6.0 + ], + "status": "estimated", + "description": "Minimum structural housing wall." + }, + "output_bolt_count": { + "kind": "count", + "value": 4, + "editable": true, + "bounds": [ + 3, + 8 + ], + "status": "inferred", + "description": "Output flange fastener pattern count." + }, + "housing_bolt_count": { + "kind": "count", + "value": 10, + "editable": true, + "bounds": [ + 6, + 16 + ], + "status": "inferred", + "description": "Housing closure fastener count." + }, + "output_torque_limit": { + "kind": "torque", + "value": 25.0, + "unit": "N*m", + "editable": true, + "bounds": [ + 5.0, + 60.0 + ], + "status": "estimated", + "description": "Initial simulation effort limit." + }, + "output_speed_limit": { + "kind": "speed", + "value": 2.5, + "unit": "rad/s", + "editable": true, + "bounds": [ + 0.5, + 5.0 + ], + "status": "estimated", + "description": "Initial simulation output speed limit." + }, + "output_shaft_diameter": { + "kind": "length", + "value": 14.0, + "unit": "mm", + "editable": true, + "bounds": [ + 10.0, + 20.0 + ], + "status": "specified", + "description": "Output shaft journal diameter." + }, + "output_shaft_length": { + "kind": "length", + "value": 45.0, + "unit": "mm", + "editable": true, + "bounds": [ + 25.0, + 80.0 + ], + "status": "specified", + "description": "Usable shaft extension measured from the output mounting face." + } + }, + "expressions": { + "flexspline_tooth_count": "2 * round(reduction_ratio / 2)", + "circular_spline_tooth_count": "flexspline_tooth_count + 2", + "bearing_nominal_diameter": "0.72 * outer_envelope_diameter", + "output_flange_diameter": "0.48 * outer_envelope_diameter", + "housing_fastener_pitch_diameter": "0.82 * outer_envelope_diameter" + }, + "modules": [ + { + "id": "structure_module", + "purpose": "Carry external loads and provide all mounting seats.", + "members": [ + "housing", + "rear_cover", + "front_cover" + ], + "replaceable_as_unit": true + }, + { + "id": "harmonic_stage", + "purpose": "Generate the harmonic reduction between input and output.", + "members": [ + "circular_spline", + "flexspline", + "wave_generator" + ], + "replaceable_as_unit": true + }, + { + "id": "output_support_module", + "purpose": "Support and locate the rotating output.", + "members": [ + "output_bearing", + "output_flange" + ], + "replaceable_as_unit": true + }, + { + "id": "retention_module", + "purpose": "Close and fasten structural interfaces.", + "members": [ + "housing_fasteners", + "output_fasteners" + ], + "replaceable_as_unit": false + } + ], + "components": [ + { + "id": "housing", + "role": "fixed_structure", + "archetype": "coaxial_reducer_housing", + "lifecycle": "required", + "generation": { + "strategy": "semantic_feature_program", + "family": "annular_housing_with_mounting_flange", + "parameter_bindings": { + "outer_diameter": "outer_envelope_diameter", + "axial_length": "axial_envelope", + "minimum_wall": "housing_wall" + } + }, + "interfaces": [ + { + "id": "main_axis", + "type": "axis", + "construction": "primary_axis" + }, + { + "id": "front_closure_seat", + "type": "plane", + "construction": "front_axial_face" + }, + { + "id": "rear_closure_seat", + "type": "plane", + "construction": "rear_axial_face" + }, + { + "id": "circular_spline_seat", + "type": "cylinder", + "construction": "inner_cylindrical_surface", + "qualifiers": [ + "largest_internal_transmission_seat" + ] + }, + { + "id": "output_bearing_seat", + "type": "bearing_seat", + "construction": "inner_cylindrical_surface", + "qualifiers": [ + "frontmost_bearing_seat" + ] + }, + { + "id": "closure_pattern", + "type": "hole_pattern", + "construction": "pitch_circle_pattern", + "parameter_refs": [ + "housing_bolt_count" + ] + } + ], + "material_class": "aluminum_alloy", + "tags": [ + "fixed", + "structural", + "thermal_path" + ] + }, + { + "id": "circular_spline", + "role": "fixed_tooth_member", + "archetype": "internal_spline_ring", + "lifecycle": "replaceable", + "generation": { + "strategy": "standard_library", + "family": "harmonic_circular_spline", + "parameter_bindings": { + "nominal_size": "nominal_size", + "tooth_count": "circular_spline_tooth_count" + } + }, + "interfaces": [ + { + "id": "tooth_axis", + "type": "axis", + "construction": "primary_axis" + }, + { + "id": "housing_fit", + "type": "cylinder", + "construction": "outer_cylindrical_surface" + }, + { + "id": "anti_rotation", + "type": "torque_interface", + "construction": "generated_feature" + } + ], + "material_class": "hardened_steel", + "tags": [ + "fixed", + "transmission" + ] + }, + { + "id": "flexspline", + "role": "compliant_tooth_member", + "archetype": "cup_flexspline", + "lifecycle": "replaceable", + "generation": { + "strategy": "standard_library", + "family": "harmonic_flexspline", + "parameter_bindings": { + "nominal_size": "nominal_size", + "tooth_count": "flexspline_tooth_count", + "output_bore": "output_bore_diameter" + } + }, + "interfaces": [ + { + "id": "tooth_axis", + "type": "axis", + "construction": "primary_axis" + }, + { + "id": "wave_generator_seat", + "type": "bearing_seat", + "construction": "inner_cylindrical_surface" + }, + { + "id": "output_face", + "type": "plane", + "construction": "front_axial_face" + }, + { + "id": "output_torque_interface", + "type": "torque_interface", + "construction": "generated_feature" + } + ], + "material_class": "spring_steel", + "tags": [ + "rotating", + "compliant", + "transmission" + ] + }, + { + "id": "wave_generator", + "role": "input_deformation_member", + "archetype": "elliptical_wave_generator", + "lifecycle": "replaceable", + "generation": { + "strategy": "subassembly_template", + "family": "elliptical_cam_with_flexible_bearing", + "parameter_bindings": { + "nominal_size": "nominal_size" + } + }, + "interfaces": [ + { + "id": "rotation_axis", + "type": "axis", + "construction": "primary_axis" + }, + { + "id": "input_torque_interface", + "type": "torque_interface", + "construction": "inner_cylindrical_surface" + }, + { + "id": "flexspline_contact", + "type": "contact_envelope", + "construction": "outer_cylindrical_surface", + "qualifiers": [ + "elliptical_compliant_contact" + ] + } + ], + "material_class": "bearing_steel", + "tags": [ + "rotating", + "input", + "transmission" + ] + }, + { + "id": "output_bearing", + "role": "combined_load_support", + "archetype": "cross_roller_bearing", + "lifecycle": "replaceable", + "generation": { + "strategy": "standard_library", + "family": "cross_roller_bearing", + "parameter_bindings": { + "nominal_diameter": "bearing_nominal_diameter" + } + }, + "interfaces": [ + { + "id": "bearing_axis", + "type": "axis", + "construction": "primary_axis" + }, + { + "id": "outer_ring_seat", + "type": "bearing_seat", + "construction": "outer_cylindrical_surface" + }, + { + "id": "inner_ring_seat", + "type": "bearing_seat", + "construction": "inner_cylindrical_surface" + }, + { + "id": "axial_reference", + "type": "plane", + "construction": "center_plane" + } + ], + "material_class": "bearing_steel", + "tags": [ + "support", + "split_kinematic_membership" + ] + }, + { + "id": "output_flange", + "role": "external_output_interface", + "archetype": "bolted_output_flange", + "lifecycle": "replaceable", + "generation": { + "strategy": "semantic_feature_program", + "family": "annular_output_flange", + "parameter_bindings": { + "diameter": "output_flange_diameter", + "bore_diameter": "output_bore_diameter", + "bolt_count": "output_bolt_count" + } + }, + "interfaces": [ + { + "id": "rotation_axis", + "type": "axis", + "construction": "primary_axis" + }, + { + "id": "flexspline_mate", + "type": "plane", + "construction": "rear_axial_face" + }, + { + "id": "bearing_fit", + "type": "bearing_seat", + "construction": "outer_cylindrical_surface" + }, + { + "id": "external_mount_face", + "type": "plane", + "construction": "front_axial_face" + }, + { + "id": "external_bolt_pattern", + "type": "hole_pattern", + "construction": "pitch_circle_pattern", + "parameter_refs": [ + "output_bolt_count" + ] + }, + { + "id": "output_bore", + "type": "shaft_seat", + "construction": "inner_cylindrical_surface", + "parameter_refs": [ + "output_bore_diameter" + ] + } + ], + "material_class": "alloy_steel", + "tags": [ + "rotating", + "output", + "public_interface" + ] + }, + { + "id": "front_cover", + "role": "front_axial_retainer", + "archetype": "annular_cover", + "lifecycle": "required", + "generation": { + "strategy": "semantic_feature_program", + "family": "bolted_annular_cover", + "parameter_bindings": { + "outer_diameter": "outer_envelope_diameter", + "bolt_count": "housing_bolt_count" + } + }, + "interfaces": [ + { + "id": "housing_mate", + "type": "plane", + "construction": "rear_axial_face" + }, + { + "id": "main_axis", + "type": "axis", + "construction": "primary_axis" + }, + { + "id": "fastener_pattern", + "type": "hole_pattern", + "construction": "pitch_circle_pattern", + "parameter_refs": [ + "housing_bolt_count" + ] + } + ], + "material_class": "aluminum_alloy", + "tags": [ + "fixed", + "structural" + ] + }, + { + "id": "rear_cover", + "role": "rear_axial_retainer", + "archetype": "annular_cover", + "lifecycle": "required", + "generation": { + "strategy": "semantic_feature_program", + "family": "bolted_annular_cover", + "parameter_bindings": { + "outer_diameter": "outer_envelope_diameter" + } + }, + "interfaces": [ + { + "id": "housing_mate", + "type": "plane", + "construction": "front_axial_face" + }, + { + "id": "main_axis", + "type": "axis", + "construction": "primary_axis" + } + ], + "material_class": "aluminum_alloy", + "tags": [ + "fixed", + "structural" + ] + }, + { + "id": "housing_fasteners", + "role": "housing_closure_fasteners", + "archetype": "socket_head_screw_pattern", + "lifecycle": "generated_pattern", + "quantity": "housing_bolt_count", + "generation": { + "strategy": "standard_library", + "family": "metric_socket_head_screw", + "parameter_bindings": { + "nominal_thread": "M5", + "count": "housing_bolt_count" + } + }, + "interfaces": [ + { + "id": "fastener_axes", + "type": "hole_pattern", + "construction": "pitch_circle_pattern", + "parameter_refs": [ + "housing_bolt_count" + ] + }, + { + "id": "head_seats", + "type": "fastener_seat", + "construction": "generated_feature" + } + ], + "material_class": "steel", + "tags": [ + "fixed", + "fastener_pattern" + ] + }, + { + "id": "output_fasteners", + "role": "output_interface_fasteners", + "archetype": "countersunk_screw_pattern", + "lifecycle": "generated_pattern", + "quantity": "output_bolt_count", + "generation": { + "strategy": "standard_library", + "family": "metric_countersunk_screw", + "parameter_bindings": { + "nominal_thread": "M2_5", + "count": "output_bolt_count" + } + }, + "interfaces": [ + { + "id": "fastener_axes", + "type": "hole_pattern", + "construction": "pitch_circle_pattern", + "parameter_refs": [ + "output_bolt_count" + ] + }, + { + "id": "head_seats", + "type": "fastener_seat", + "construction": "generated_feature" + } + ], + "material_class": "steel", + "tags": [ + "rotating", + "fastener_pattern" + ] + }, + { + "id": "output_shaft", + "role": "external_torque_shaft", + "archetype": "stepped_keyed_shaft", + "lifecycle": "optional", + "generation": { + "strategy": "semantic_feature_program", + "family": "stepped_output_shaft", + "parameter_bindings": { + "journal_diameter": "output_shaft_diameter", + "extension_length": "output_shaft_length", + "mating_bore": "output_bore_diameter" + } + }, + "interfaces": [ + { + "id": "shaft_axis", + "type": "axis", + "construction": "primary_axis" + }, + { + "id": "mounting_shoulder", + "type": "plane", + "construction": "rear_axial_face" + }, + { + "id": "flange_fit", + "type": "shaft_seat", + "construction": "outer_cylindrical_surface", + "parameter_refs": [ + "output_shaft_diameter" + ] + }, + { + "id": "torque_drive", + "type": "torque_interface", + "construction": "generated_feature", + "qualifiers": [ + "key_or_spline_selected_by_load" + ] + }, + { + "id": "external_shaft_end", + "type": "plane", + "construction": "front_axial_face" + } + ], + "material_class": "alloy_steel", + "tags": [ + "rotating", + "output", + "public_interface" + ] + } + ], + "relations": [ + { + "id": "circular_spline_centering", + "type": "coaxial", + "a": "housing.main_axis", + "b": "circular_spline.tooth_axis", + "intent": "Center the fixed tooth member in the housing.", + "mobility": "fixed" + }, + { + "id": "circular_spline_fixing", + "type": "torque_lock", + "a": "housing.circular_spline_seat", + "b": "circular_spline.anti_rotation", + "intent": "Prevent rotation of the circular spline relative to the housing.", + "mobility": "fixed" + }, + { + "id": "flexspline_centering", + "type": "coaxial", + "a": "circular_spline.tooth_axis", + "b": "flexspline.tooth_axis", + "intent": "Keep both spline members on the reducer axis.", + "mobility": "allow_rotation" + }, + { + "id": "wave_generator_centering", + "type": "coaxial", + "a": "flexspline.tooth_axis", + "b": "wave_generator.rotation_axis", + "intent": "Place the wave generator on the transmission axis.", + "mobility": "allow_rotation" + }, + { + "id": "wave_generator_contact", + "type": "bearing_support", + "a": "flexspline.wave_generator_seat", + "b": "wave_generator.flexspline_contact", + "intent": "Transfer the elliptical deformation while allowing relative rotation.", + "mobility": "allow_rotation", + "confidence": 0.82, + "evidence": [ + "harmonic_stage_topology", + "nested_flexible_bearing" + ] + }, + { + "id": "output_bearing_housing_fit", + "type": "bearing_support", + "a": "housing.output_bearing_seat", + "b": "output_bearing.outer_ring_seat", + "intent": "Fix and support the bearing outer ring in the housing.", + "mobility": "fixed" + }, + { + "id": "output_bearing_flange_fit", + "type": "bearing_support", + "a": "output_flange.bearing_fit", + "b": "output_bearing.inner_ring_seat", + "intent": "Support output flange rotation through the bearing inner ring.", + "mobility": "allow_rotation" + }, + { + "id": "flexspline_output_face", + "type": "face_mate", + "a": "flexspline.output_face", + "b": "output_flange.flexspline_mate", + "intent": "Locate the flexspline axially against the output flange.", + "value": 0.0, + "tolerance": 0.01, + "mobility": "fixed" + }, + { + "id": "flexspline_output_torque", + "type": "torque_lock", + "a": "flexspline.output_torque_interface", + "b": "output_flange.external_bolt_pattern", + "intent": "Transmit flexspline torque to the output flange.", + "mobility": "fixed" + }, + { + "id": "front_cover_centering", + "type": "coaxial", + "a": "housing.main_axis", + "b": "front_cover.main_axis", + "intent": "Center the front closure.", + "mobility": "fixed" + }, + { + "id": "front_cover_seating", + "type": "face_mate", + "a": "housing.front_closure_seat", + "b": "front_cover.housing_mate", + "intent": "Close the front housing interface.", + "value": 0.0, + "tolerance": 0.02, + "mobility": "fixed" + }, + { + "id": "rear_cover_centering", + "type": "coaxial", + "a": "housing.main_axis", + "b": "rear_cover.main_axis", + "intent": "Center the rear closure.", + "mobility": "fixed" + }, + { + "id": "rear_cover_seating", + "type": "face_mate", + "a": "housing.rear_closure_seat", + "b": "rear_cover.housing_mate", + "intent": "Close the rear housing interface.", + "value": 0.0, + "tolerance": 0.02, + "mobility": "fixed" + }, + { + "id": "housing_fastener_alignment", + "type": "fasten_pattern", + "a": "housing.closure_pattern", + "b": "housing_fasteners.fastener_axes", + "intent": "Instantiate and align the housing closure screws.", + "mobility": "fixed" + }, + { + "id": "output_fastener_alignment", + "type": "fasten_pattern", + "a": "output_flange.external_bolt_pattern", + "b": "output_fasteners.fastener_axes", + "intent": "Instantiate and align output flange screws.", + "mobility": "fixed" + }, + { + "id": "output_shaft_centering", + "type": "coaxial", + "a": "output_flange.rotation_axis", + "b": "output_shaft.shaft_axis", + "intent": "Center the new shaft on the solved output axis.", + "mobility": "fixed" + }, + { + "id": "output_shaft_seating", + "type": "face_mate", + "a": "output_flange.external_mount_face", + "b": "output_shaft.mounting_shoulder", + "intent": "Locate the shaft shoulder from the output flange mounting face.", + "value": 0.0, + "tolerance": 0.01, + "mobility": "fixed" + }, + { + "id": "output_shaft_fit", + "type": "insert", + "a": "output_flange.output_bore", + "b": "output_shaft.flange_fit", + "intent": "Fit the shaft journal into the output flange bore.", + "tolerance": 0.01, + "mobility": "fixed" + }, + { + "id": "output_shaft_torque_lock", + "type": "torque_lock", + "a": "output_flange.output_bore", + "b": "output_shaft.torque_drive", + "intent": "Transfer output torque through a keyed or splined interface.", + "mobility": "fixed" + } + ], + "kinematics": { + "anchor_component": "housing", + "rigid_groups": [ + { + "id": "fixed_group", + "members": [ + "housing", + "circular_spline", + "front_cover", + "rear_cover", + "housing_fasteners" + ] + }, + { + "id": "output_group", + "members": [ + "flexspline", + "output_flange", + "output_fasteners", + "output_shaft" + ] + }, + { + "id": "input_group", + "members": [ + "wave_generator" + ] + } + ], + "joints": [ + { + "id": "input_joint", + "type": "continuous", + "parent_group": "fixed_group", + "child_group": "input_group", + "axis_ref": "wave_generator.rotation_axis", + "station_ref": "flexspline.wave_generator_seat", + "dynamics": { + "damping": 0.002, + "friction": 0.003, + "parameter_status": "estimated" + } + }, + { + "id": "output_joint", + "type": "continuous", + "parent_group": "fixed_group", + "child_group": "output_group", + "axis_ref": "output_flange.rotation_axis", + "station_ref": "output_bearing.axial_reference", + "dynamics": { + "damping": 0.08, + "friction": 0.12, + "effort_limit": "output_torque_limit", + "velocity_limit": "output_speed_limit", + "parameter_status": "estimated" + } + } + ], + "transmissions": [ + { + "id": "harmonic_stage_transmission", + "type": "harmonic", + "input_joint": "input_joint", + "output_joint": "output_joint", + "ratio_expression": "output_angle = -input_angle / reduction_ratio", + "fixed_member": "circular_spline", + "compliance_model": "reduced_order_torsional_compliance" + } + ] + }, + "edit_interface": { + "editable_parameters": [ + "nominal_size", + "reduction_ratio", + "outer_envelope_diameter", + "axial_envelope", + "output_bore_diameter", + "housing_wall", + "output_bolt_count", + "housing_bolt_count", + "output_torque_limit", + "output_speed_limit", + "output_shaft_diameter", + "output_shaft_length" + ], + "structural_operations": [ + "add_component", + "remove_component", + "replace_component", + "change_relation", + "change_transmission", + "add_module" + ], + "preserved_interfaces": [ + "housing.main_axis", + "housing.front_closure_seat", + "output_flange.external_mount_face", + "output_flange.external_bolt_pattern", + "output_flange.output_bore", + "output_shaft.external_shaft_end" + ] + }, + "validation_contract": { + "coordinate_free_source": true, + "required_checks": [ + "schema_valid", + "reference_integrity", + "constraint_graph_connected", + "assembly_solvable_from_scrambled_state", + "no_overconstraint", + "interface_compatibility", + "clearance", + "interference", + "joint_mobility", + "parameter_perturbation", + "preserved_interface", + "urdf_load", + "mjcf_load" + ], + "acceptance_rules": [ + "No component placement may be authored as a coordinate or transform.", + "The solver must recover a unique assembly from semantic interfaces and relations.", + "Changing an editable parameter must rebuild affected components and re-solve all relations.", + "Structural edits must preserve declared public interfaces unless the patch explicitly changes the contract." + ] + } +} \ No newline at end of file diff --git a/models/lcd25-reducer/semantic-assembly/lcd25-harmonic-reducer.assembly-designir.json b/models/lcd25-reducer/semantic-assembly/lcd25-harmonic-reducer.assembly-designir.json new file mode 100644 index 0000000..cda55f6 --- /dev/null +++ b/models/lcd25-reducer/semantic-assembly/lcd25-harmonic-reducer.assembly-designir.json @@ -0,0 +1,739 @@ +{ + "schema_version": "assembly-1.0", + "designir_kind": "coordinate_free_semantic_assembly", + "model_id": "lcd25_harmonic_reducer", + "family": "harmonic_reducer_module", + "units": "mm", + "design_intent": "A compact coaxial harmonic reducer whose geometry, assembly and motion are regenerated from functional interfaces rather than stored placements.", + "parameters": { + "nominal_size": { + "kind": "length", + "value": 25.0, + "unit": "mm", + "editable": true, + "bounds": [20.0, 40.0], + "status": "inferred", + "description": "Nominal transmission size controlling the scalable component family." + }, + "reduction_ratio": { + "kind": "ratio", + "value": 100.0, + "editable": true, + "bounds": [50.0, 160.0], + "status": "inferred", + "description": "Input rotations per output rotation." + }, + "outer_envelope_diameter": { + "kind": "length", + "value": 110.0, + "unit": "mm", + "editable": true, + "bounds": [90.0, 140.0], + "status": "specified", + "description": "Maximum housing envelope." + }, + "axial_envelope": { + "kind": "length", + "value": 32.0, + "unit": "mm", + "editable": true, + "bounds": [25.0, 50.0], + "status": "specified", + "description": "Maximum reducer axial package length." + }, + "output_bore_diameter": { + "kind": "length", + "value": 14.0, + "unit": "mm", + "editable": true, + "bounds": [8.0, 25.0], + "status": "estimated", + "description": "Central output interface bore." + }, + "housing_wall": { + "kind": "length", + "value": 3.0, + "unit": "mm", + "editable": true, + "bounds": [2.0, 6.0], + "status": "estimated", + "description": "Minimum structural housing wall." + }, + "output_bolt_count": { + "kind": "count", + "value": 4, + "editable": true, + "bounds": [3, 8], + "status": "inferred", + "description": "Output flange fastener pattern count." + }, + "housing_bolt_count": { + "kind": "count", + "value": 10, + "editable": true, + "bounds": [6, 16], + "status": "inferred", + "description": "Housing closure fastener count." + }, + "output_torque_limit": { + "kind": "torque", + "value": 25.0, + "unit": "N*m", + "editable": true, + "bounds": [5.0, 60.0], + "status": "estimated", + "description": "Initial simulation effort limit." + }, + "output_speed_limit": { + "kind": "speed", + "value": 2.5, + "unit": "rad/s", + "editable": true, + "bounds": [0.5, 5.0], + "status": "estimated", + "description": "Initial simulation output speed limit." + } + }, + "expressions": { + "flexspline_tooth_count": "2 * round(reduction_ratio / 2)", + "circular_spline_tooth_count": "flexspline_tooth_count + 2", + "bearing_nominal_diameter": "0.72 * outer_envelope_diameter", + "output_flange_diameter": "0.48 * outer_envelope_diameter", + "housing_fastener_pitch_diameter": "0.82 * outer_envelope_diameter" + }, + "modules": [ + { + "id": "structure_module", + "purpose": "Carry external loads and provide all mounting seats.", + "members": ["housing", "rear_cover", "front_cover"], + "replaceable_as_unit": true + }, + { + "id": "harmonic_stage", + "purpose": "Generate the harmonic reduction between input and output.", + "members": ["circular_spline", "flexspline", "wave_generator"], + "replaceable_as_unit": true + }, + { + "id": "output_support_module", + "purpose": "Support and locate the rotating output.", + "members": ["output_bearing", "output_flange"], + "replaceable_as_unit": true + }, + { + "id": "retention_module", + "purpose": "Close and fasten structural interfaces.", + "members": ["housing_fasteners", "output_fasteners"], + "replaceable_as_unit": false + } + ], + "components": [ + { + "id": "housing", + "role": "fixed_structure", + "archetype": "coaxial_reducer_housing", + "lifecycle": "required", + "generation": { + "strategy": "semantic_feature_program", + "family": "annular_housing_with_mounting_flange", + "parameter_bindings": { + "outer_diameter": "outer_envelope_diameter", + "axial_length": "axial_envelope", + "minimum_wall": "housing_wall" + } + }, + "interfaces": [ + { + "id": "main_axis", + "type": "axis", + "construction": "primary_axis" + }, + { + "id": "front_closure_seat", + "type": "plane", + "construction": "front_axial_face" + }, + { + "id": "rear_closure_seat", + "type": "plane", + "construction": "rear_axial_face" + }, + { + "id": "circular_spline_seat", + "type": "cylinder", + "construction": "inner_cylindrical_surface", + "qualifiers": ["largest_internal_transmission_seat"] + }, + { + "id": "output_bearing_seat", + "type": "bearing_seat", + "construction": "inner_cylindrical_surface", + "qualifiers": ["frontmost_bearing_seat"] + }, + { + "id": "closure_pattern", + "type": "hole_pattern", + "construction": "pitch_circle_pattern", + "parameter_refs": ["housing_bolt_count"] + } + ], + "material_class": "aluminum_alloy", + "tags": ["fixed", "structural", "thermal_path"] + }, + { + "id": "circular_spline", + "role": "fixed_tooth_member", + "archetype": "internal_spline_ring", + "lifecycle": "replaceable", + "generation": { + "strategy": "standard_library", + "family": "harmonic_circular_spline", + "parameter_bindings": { + "nominal_size": "nominal_size", + "tooth_count": "circular_spline_tooth_count" + } + }, + "interfaces": [ + { + "id": "tooth_axis", + "type": "axis", + "construction": "primary_axis" + }, + { + "id": "housing_fit", + "type": "cylinder", + "construction": "outer_cylindrical_surface" + }, + { + "id": "anti_rotation", + "type": "torque_interface", + "construction": "generated_feature" + } + ], + "material_class": "hardened_steel", + "tags": ["fixed", "transmission"] + }, + { + "id": "flexspline", + "role": "compliant_tooth_member", + "archetype": "cup_flexspline", + "lifecycle": "replaceable", + "generation": { + "strategy": "standard_library", + "family": "harmonic_flexspline", + "parameter_bindings": { + "nominal_size": "nominal_size", + "tooth_count": "flexspline_tooth_count", + "output_bore": "output_bore_diameter" + } + }, + "interfaces": [ + { + "id": "tooth_axis", + "type": "axis", + "construction": "primary_axis" + }, + { + "id": "wave_generator_seat", + "type": "bearing_seat", + "construction": "inner_cylindrical_surface" + }, + { + "id": "output_face", + "type": "plane", + "construction": "front_axial_face" + }, + { + "id": "output_torque_interface", + "type": "torque_interface", + "construction": "generated_feature" + } + ], + "material_class": "spring_steel", + "tags": ["rotating", "compliant", "transmission"] + }, + { + "id": "wave_generator", + "role": "input_deformation_member", + "archetype": "elliptical_wave_generator", + "lifecycle": "replaceable", + "generation": { + "strategy": "subassembly_template", + "family": "elliptical_cam_with_flexible_bearing", + "parameter_bindings": { + "nominal_size": "nominal_size" + } + }, + "interfaces": [ + { + "id": "rotation_axis", + "type": "axis", + "construction": "primary_axis" + }, + { + "id": "input_torque_interface", + "type": "torque_interface", + "construction": "inner_cylindrical_surface" + }, + { + "id": "flexspline_contact", + "type": "contact_envelope", + "construction": "outer_cylindrical_surface", + "qualifiers": ["elliptical_compliant_contact"] + } + ], + "material_class": "bearing_steel", + "tags": ["rotating", "input", "transmission"] + }, + { + "id": "output_bearing", + "role": "combined_load_support", + "archetype": "cross_roller_bearing", + "lifecycle": "replaceable", + "generation": { + "strategy": "standard_library", + "family": "cross_roller_bearing", + "parameter_bindings": { + "nominal_diameter": "bearing_nominal_diameter" + } + }, + "interfaces": [ + { + "id": "bearing_axis", + "type": "axis", + "construction": "primary_axis" + }, + { + "id": "outer_ring_seat", + "type": "bearing_seat", + "construction": "outer_cylindrical_surface" + }, + { + "id": "inner_ring_seat", + "type": "bearing_seat", + "construction": "inner_cylindrical_surface" + }, + { + "id": "axial_reference", + "type": "plane", + "construction": "center_plane" + } + ], + "material_class": "bearing_steel", + "tags": ["support", "split_kinematic_membership"] + }, + { + "id": "output_flange", + "role": "external_output_interface", + "archetype": "bolted_output_flange", + "lifecycle": "replaceable", + "generation": { + "strategy": "semantic_feature_program", + "family": "annular_output_flange", + "parameter_bindings": { + "diameter": "output_flange_diameter", + "bore_diameter": "output_bore_diameter", + "bolt_count": "output_bolt_count" + } + }, + "interfaces": [ + { + "id": "rotation_axis", + "type": "axis", + "construction": "primary_axis" + }, + { + "id": "flexspline_mate", + "type": "plane", + "construction": "rear_axial_face" + }, + { + "id": "bearing_fit", + "type": "bearing_seat", + "construction": "outer_cylindrical_surface" + }, + { + "id": "external_mount_face", + "type": "plane", + "construction": "front_axial_face" + }, + { + "id": "external_bolt_pattern", + "type": "hole_pattern", + "construction": "pitch_circle_pattern", + "parameter_refs": ["output_bolt_count"] + }, + { + "id": "output_bore", + "type": "shaft_seat", + "construction": "inner_cylindrical_surface", + "parameter_refs": ["output_bore_diameter"] + } + ], + "material_class": "alloy_steel", + "tags": ["rotating", "output", "public_interface"] + }, + { + "id": "front_cover", + "role": "front_axial_retainer", + "archetype": "annular_cover", + "lifecycle": "required", + "generation": { + "strategy": "semantic_feature_program", + "family": "bolted_annular_cover", + "parameter_bindings": { + "outer_diameter": "outer_envelope_diameter", + "bolt_count": "housing_bolt_count" + } + }, + "interfaces": [ + { + "id": "housing_mate", + "type": "plane", + "construction": "rear_axial_face" + }, + { + "id": "main_axis", + "type": "axis", + "construction": "primary_axis" + }, + { + "id": "fastener_pattern", + "type": "hole_pattern", + "construction": "pitch_circle_pattern", + "parameter_refs": ["housing_bolt_count"] + } + ], + "material_class": "aluminum_alloy", + "tags": ["fixed", "structural"] + }, + { + "id": "rear_cover", + "role": "rear_axial_retainer", + "archetype": "annular_cover", + "lifecycle": "required", + "generation": { + "strategy": "semantic_feature_program", + "family": "bolted_annular_cover", + "parameter_bindings": { + "outer_diameter": "outer_envelope_diameter" + } + }, + "interfaces": [ + { + "id": "housing_mate", + "type": "plane", + "construction": "front_axial_face" + }, + { + "id": "main_axis", + "type": "axis", + "construction": "primary_axis" + } + ], + "material_class": "aluminum_alloy", + "tags": ["fixed", "structural"] + }, + { + "id": "housing_fasteners", + "role": "housing_closure_fasteners", + "archetype": "socket_head_screw_pattern", + "lifecycle": "generated_pattern", + "quantity": "housing_bolt_count", + "generation": { + "strategy": "standard_library", + "family": "metric_socket_head_screw", + "parameter_bindings": { + "nominal_thread": "M5", + "count": "housing_bolt_count" + } + }, + "interfaces": [ + { + "id": "fastener_axes", + "type": "hole_pattern", + "construction": "pitch_circle_pattern", + "parameter_refs": ["housing_bolt_count"] + }, + { + "id": "head_seats", + "type": "fastener_seat", + "construction": "generated_feature" + } + ], + "material_class": "steel", + "tags": ["fixed", "fastener_pattern"] + }, + { + "id": "output_fasteners", + "role": "output_interface_fasteners", + "archetype": "countersunk_screw_pattern", + "lifecycle": "generated_pattern", + "quantity": "output_bolt_count", + "generation": { + "strategy": "standard_library", + "family": "metric_countersunk_screw", + "parameter_bindings": { + "nominal_thread": "M2_5", + "count": "output_bolt_count" + } + }, + "interfaces": [ + { + "id": "fastener_axes", + "type": "hole_pattern", + "construction": "pitch_circle_pattern", + "parameter_refs": ["output_bolt_count"] + }, + { + "id": "head_seats", + "type": "fastener_seat", + "construction": "generated_feature" + } + ], + "material_class": "steel", + "tags": ["rotating", "fastener_pattern"] + } + ], + "relations": [ + { + "id": "circular_spline_centering", + "type": "coaxial", + "a": "housing.main_axis", + "b": "circular_spline.tooth_axis", + "intent": "Center the fixed tooth member in the housing.", + "mobility": "fixed" + }, + { + "id": "circular_spline_fixing", + "type": "torque_lock", + "a": "housing.circular_spline_seat", + "b": "circular_spline.anti_rotation", + "intent": "Prevent rotation of the circular spline relative to the housing.", + "mobility": "fixed" + }, + { + "id": "flexspline_centering", + "type": "coaxial", + "a": "circular_spline.tooth_axis", + "b": "flexspline.tooth_axis", + "intent": "Keep both spline members on the reducer axis.", + "mobility": "allow_rotation" + }, + { + "id": "wave_generator_centering", + "type": "coaxial", + "a": "flexspline.tooth_axis", + "b": "wave_generator.rotation_axis", + "intent": "Place the wave generator on the transmission axis.", + "mobility": "allow_rotation" + }, + { + "id": "wave_generator_contact", + "type": "bearing_support", + "a": "flexspline.wave_generator_seat", + "b": "wave_generator.flexspline_contact", + "intent": "Transfer the elliptical deformation while allowing relative rotation.", + "mobility": "allow_rotation", + "confidence": 0.82, + "evidence": ["harmonic_stage_topology", "nested_flexible_bearing"] + }, + { + "id": "output_bearing_housing_fit", + "type": "bearing_support", + "a": "housing.output_bearing_seat", + "b": "output_bearing.outer_ring_seat", + "intent": "Fix and support the bearing outer ring in the housing.", + "mobility": "fixed" + }, + { + "id": "output_bearing_flange_fit", + "type": "bearing_support", + "a": "output_flange.bearing_fit", + "b": "output_bearing.inner_ring_seat", + "intent": "Support output flange rotation through the bearing inner ring.", + "mobility": "allow_rotation" + }, + { + "id": "flexspline_output_face", + "type": "face_mate", + "a": "flexspline.output_face", + "b": "output_flange.flexspline_mate", + "intent": "Locate the flexspline axially against the output flange.", + "value": 0.0, + "tolerance": 0.01, + "mobility": "fixed" + }, + { + "id": "flexspline_output_torque", + "type": "torque_lock", + "a": "flexspline.output_torque_interface", + "b": "output_flange.external_bolt_pattern", + "intent": "Transmit flexspline torque to the output flange.", + "mobility": "fixed" + }, + { + "id": "front_cover_centering", + "type": "coaxial", + "a": "housing.main_axis", + "b": "front_cover.main_axis", + "intent": "Center the front closure.", + "mobility": "fixed" + }, + { + "id": "front_cover_seating", + "type": "face_mate", + "a": "housing.front_closure_seat", + "b": "front_cover.housing_mate", + "intent": "Close the front housing interface.", + "value": 0.0, + "tolerance": 0.02, + "mobility": "fixed" + }, + { + "id": "rear_cover_centering", + "type": "coaxial", + "a": "housing.main_axis", + "b": "rear_cover.main_axis", + "intent": "Center the rear closure.", + "mobility": "fixed" + }, + { + "id": "rear_cover_seating", + "type": "face_mate", + "a": "housing.rear_closure_seat", + "b": "rear_cover.housing_mate", + "intent": "Close the rear housing interface.", + "value": 0.0, + "tolerance": 0.02, + "mobility": "fixed" + }, + { + "id": "housing_fastener_alignment", + "type": "fasten_pattern", + "a": "housing.closure_pattern", + "b": "housing_fasteners.fastener_axes", + "intent": "Instantiate and align the housing closure screws.", + "mobility": "fixed" + }, + { + "id": "output_fastener_alignment", + "type": "fasten_pattern", + "a": "output_flange.external_bolt_pattern", + "b": "output_fasteners.fastener_axes", + "intent": "Instantiate and align output flange screws.", + "mobility": "fixed" + } + ], + "kinematics": { + "anchor_component": "housing", + "rigid_groups": [ + { + "id": "fixed_group", + "members": ["housing", "circular_spline", "front_cover", "rear_cover", "housing_fasteners"] + }, + { + "id": "output_group", + "members": ["flexspline", "output_flange", "output_fasteners"] + }, + { + "id": "input_group", + "members": ["wave_generator"] + } + ], + "joints": [ + { + "id": "input_joint", + "type": "continuous", + "parent_group": "fixed_group", + "child_group": "input_group", + "axis_ref": "wave_generator.rotation_axis", + "station_ref": "flexspline.wave_generator_seat", + "dynamics": { + "damping": 0.002, + "friction": 0.003, + "parameter_status": "estimated" + } + }, + { + "id": "output_joint", + "type": "continuous", + "parent_group": "fixed_group", + "child_group": "output_group", + "axis_ref": "output_flange.rotation_axis", + "station_ref": "output_bearing.axial_reference", + "dynamics": { + "damping": 0.08, + "friction": 0.12, + "effort_limit": "output_torque_limit", + "velocity_limit": "output_speed_limit", + "parameter_status": "estimated" + } + } + ], + "transmissions": [ + { + "id": "harmonic_stage_transmission", + "type": "harmonic", + "input_joint": "input_joint", + "output_joint": "output_joint", + "ratio_expression": "output_angle = -input_angle / reduction_ratio", + "fixed_member": "circular_spline", + "compliance_model": "reduced_order_torsional_compliance" + } + ] + }, + "edit_interface": { + "editable_parameters": [ + "nominal_size", + "reduction_ratio", + "outer_envelope_diameter", + "axial_envelope", + "output_bore_diameter", + "housing_wall", + "output_bolt_count", + "housing_bolt_count", + "output_torque_limit", + "output_speed_limit" + ], + "structural_operations": [ + "add_component", + "remove_component", + "replace_component", + "change_relation", + "change_transmission", + "add_module" + ], + "preserved_interfaces": [ + "housing.main_axis", + "housing.front_closure_seat", + "output_flange.external_mount_face", + "output_flange.external_bolt_pattern", + "output_flange.output_bore" + ] + }, + "validation_contract": { + "coordinate_free_source": true, + "required_checks": [ + "schema_valid", + "reference_integrity", + "constraint_graph_connected", + "assembly_solvable_from_scrambled_state", + "no_overconstraint", + "interface_compatibility", + "clearance", + "interference", + "joint_mobility", + "parameter_perturbation", + "preserved_interface", + "urdf_load", + "mjcf_load" + ], + "acceptance_rules": [ + "No component placement may be authored as a coordinate or transform.", + "The solver must recover a unique assembly from semantic interfaces and relations.", + "Changing an editable parameter must rebuild affected components and re-solve all relations.", + "Structural edits must preserve declared public interfaces unless the patch explicitly changes the contract." + ] + } +} diff --git a/models/lcd25-reducer/semantic-assembly/validate_assembly_design.py b/models/lcd25-reducer/semantic-assembly/validate_assembly_design.py new file mode 100644 index 0000000..5c1ec62 --- /dev/null +++ b/models/lcd25-reducer/semantic-assembly/validate_assembly_design.py @@ -0,0 +1,253 @@ +#!/usr/bin/env python3 +"""Validate and semantically patch coordinate-free Assembly DesignIR.""" + +from __future__ import annotations + +import argparse +import copy +import json +from pathlib import Path +from typing import Any + +import jsonschema + + +FORBIDDEN_PLACEMENT_KEYS = { + "transform", + "transforms", + "matrix", + "matrices", + "placement", + "placements", + "position", + "positions", + "location", + "locations", + "xyz", + "rpy", + "quaternion", + "translation", +} + + +def walk_forbidden(value: Any, path: str = "$") -> list[str]: + failures: list[str] = [] + if isinstance(value, dict): + for key, child in value.items(): + child_path = f"{path}.{key}" + if key.lower() in FORBIDDEN_PLACEMENT_KEYS: + failures.append(child_path) + failures.extend(walk_forbidden(child, child_path)) + elif isinstance(value, list): + for index, child in enumerate(value): + failures.extend(walk_forbidden(child, f"{path}[{index}]")) + return failures + + +def component_interfaces(payload: dict[str, Any]) -> dict[str, set[str]]: + return { + component["id"]: {interface["id"] for interface in component["interfaces"]} + for component in payload["components"] + } + + +def validate_references(payload: dict[str, Any]) -> list[str]: + failures: list[str] = [] + interfaces = component_interfaces(payload) + component_ids = set(interfaces) + if len(component_ids) != len(payload["components"]): + failures.append("component ids are not unique") + + relation_ids: set[str] = set() + adjacency = {component_id: set() for component_id in component_ids} + for relation in payload["relations"]: + if relation["id"] in relation_ids: + failures.append(f"duplicate relation id: {relation['id']}") + relation_ids.add(relation["id"]) + endpoints: list[tuple[str, str]] = [] + for side in ("a", "b"): + component_id, interface_id = relation[side].split(".", 1) + endpoints.append((component_id, interface_id)) + if component_id not in interfaces: + failures.append( + f"{relation['id']}.{side}: unknown component {component_id}" + ) + elif interface_id not in interfaces[component_id]: + failures.append( + f"{relation['id']}.{side}: unknown interface " + f"{component_id}.{interface_id}" + ) + if all(component_id in component_ids for component_id, _ in endpoints): + adjacency[endpoints[0][0]].add(endpoints[1][0]) + adjacency[endpoints[1][0]].add(endpoints[0][0]) + + anchor = payload["kinematics"]["anchor_component"] + if anchor not in component_ids: + failures.append(f"unknown anchor component: {anchor}") + elif component_ids: + visited: set[str] = set() + frontier = [anchor] + while frontier: + current = frontier.pop() + if current in visited: + continue + visited.add(current) + frontier.extend(adjacency[current] - visited) + disconnected = sorted(component_ids - visited) + if disconnected: + failures.append( + "constraint graph disconnected: " + ", ".join(disconnected) + ) + + group_ids: set[str] = set() + for group in payload["kinematics"]["rigid_groups"]: + if group["id"] in group_ids: + failures.append(f"duplicate rigid group id: {group['id']}") + group_ids.add(group["id"]) + for member in group["members"]: + if member not in component_ids: + failures.append( + f"rigid group {group['id']} has unknown member {member}" + ) + + joint_ids: set[str] = set() + for joint in payload["kinematics"]["joints"]: + joint_ids.add(joint["id"]) + for side in ("parent_group", "child_group"): + if joint[side] not in group_ids: + failures.append( + f"joint {joint['id']} has unknown {side} {joint[side]}" + ) + for ref_key in ("axis_ref", "station_ref"): + component_id, interface_id = joint[ref_key].split(".", 1) + if ( + component_id not in interfaces + or interface_id not in interfaces[component_id] + ): + failures.append( + f"joint {joint['id']} has unknown {ref_key} " + f"{joint[ref_key]}" + ) + + for transmission in payload["kinematics"]["transmissions"]: + for key in ("input_joint", "output_joint"): + if transmission[key] not in joint_ids: + failures.append( + f"transmission {transmission['id']} has unknown " + f"{key} {transmission[key]}" + ) + fixed_member = transmission.get("fixed_member") + if fixed_member and fixed_member not in component_ids: + failures.append( + f"transmission {transmission['id']} has unknown fixed member " + f"{fixed_member}" + ) + + parameter_ids = set(payload["parameters"]) + for parameter_id in payload["edit_interface"]["editable_parameters"]: + if parameter_id not in parameter_ids: + failures.append(f"unknown editable parameter: {parameter_id}") + elif not payload["parameters"][parameter_id]["editable"]: + failures.append(f"parameter is not editable: {parameter_id}") + + return failures + + +def apply_patch(payload: dict[str, Any], patch: dict[str, Any]) -> dict[str, Any]: + if patch["target_model"] != payload["model_id"]: + raise ValueError("Patch target_model does not match base model") + result = copy.deepcopy(payload) + groups = { + group["id"]: group for group in result["kinematics"]["rigid_groups"] + } + for operation in patch["operations"]: + kind = operation["op"] + if kind == "add_parameter": + result["parameters"][operation["id"]] = operation["value"] + result["edit_interface"]["editable_parameters"].append( + operation["id"] + ) + elif kind == "add_component": + result["components"].append(operation["value"]) + elif kind == "add_relation": + result["relations"].append(operation["value"]) + elif kind == "add_rigid_group_member": + groups[operation["group"]]["members"].append( + operation["component"] + ) + elif kind == "add_preserved_interface": + result["edit_interface"]["preserved_interfaces"].append( + operation["value"] + ) + else: + raise ValueError(f"Unsupported semantic patch operation: {kind}") + result["model_id"] = f"{payload['model_id']}_with_output_shaft" + return result + + +def validate( + payload: dict[str, Any], schema: dict[str, Any] +) -> dict[str, Any]: + schema_errors = sorted( + jsonschema.Draft202012Validator(schema).iter_errors(payload), + key=lambda error: list(error.absolute_path), + ) + failures = [ + f"{'.'.join(map(str, error.absolute_path))}: {error.message}" + for error in schema_errors + ] + forbidden = walk_forbidden(payload) + failures.extend( + f"coordinate placement key is forbidden: {path}" for path in forbidden + ) + if not schema_errors: + failures.extend(validate_references(payload)) + return { + "status": "pass" if not failures else "fail", + "schema_version": payload.get("schema_version"), + "model_id": payload.get("model_id"), + "coordinate_free": not forbidden, + "component_count": len(payload.get("components", [])), + "relation_count": len(payload.get("relations", [])), + "joint_count": len(payload.get("kinematics", {}).get("joints", [])), + "transmission_count": len( + payload.get("kinematics", {}).get("transmissions", []) + ), + "failures": failures, + } + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("model", type=Path) + parser.add_argument("--schema", type=Path, required=True) + parser.add_argument("--patch", type=Path) + parser.add_argument("--resolved-output", type=Path) + parser.add_argument("--report", type=Path) + args = parser.parse_args() + + payload = json.loads(args.model.read_text(encoding="utf-8")) + schema = json.loads(args.schema.read_text(encoding="utf-8")) + if args.patch: + patch = json.loads(args.patch.read_text(encoding="utf-8")) + payload = apply_patch(payload, patch) + if args.resolved_output: + args.resolved_output.parent.mkdir(parents=True, exist_ok=True) + args.resolved_output.write_text( + json.dumps(payload, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + + report = validate(payload, schema) + if args.report: + args.report.parent.mkdir(parents=True, exist_ok=True) + args.report.write_text( + json.dumps(report, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + print(json.dumps(report, ensure_ascii=False)) + raise SystemExit(0 if report["status"] == "pass" else 1) + + +if __name__ == "__main__": + main() diff --git a/models/lcd25-reducer/semantic-assembly/validation/add-output-shaft.json b/models/lcd25-reducer/semantic-assembly/validation/add-output-shaft.json new file mode 100644 index 0000000..cd0a481 --- /dev/null +++ b/models/lcd25-reducer/semantic-assembly/validation/add-output-shaft.json @@ -0,0 +1,11 @@ +{ + "status": "pass", + "schema_version": "assembly-1.0", + "model_id": "lcd25_harmonic_reducer_with_output_shaft", + "coordinate_free": true, + "component_count": 11, + "relation_count": 19, + "joint_count": 2, + "transmission_count": 1, + "failures": [] +} \ No newline at end of file diff --git a/models/lcd25-reducer/semantic-assembly/validation/base.json b/models/lcd25-reducer/semantic-assembly/validation/base.json new file mode 100644 index 0000000..79dcf4a --- /dev/null +++ b/models/lcd25-reducer/semantic-assembly/validation/base.json @@ -0,0 +1,11 @@ +{ + "status": "pass", + "schema_version": "assembly-1.0", + "model_id": "lcd25_harmonic_reducer", + "coordinate_free": true, + "component_count": 10, + "relation_count": 15, + "joint_count": 2, + "transmission_count": 1, + "failures": [] +} \ No newline at end of file diff --git a/models/lcd25-reducer/semantic-assembly/variants/add-output-shaft.patch.json b/models/lcd25-reducer/semantic-assembly/variants/add-output-shaft.patch.json new file mode 100644 index 0000000..bb6ee31 --- /dev/null +++ b/models/lcd25-reducer/semantic-assembly/variants/add-output-shaft.patch.json @@ -0,0 +1,138 @@ +{ + "patch_version": "1.0", + "target_model": "lcd25_harmonic_reducer", + "intent": "Add a coaxial keyed output shaft without authoring a placement transform.", + "operations": [ + { + "op": "add_parameter", + "id": "output_shaft_diameter", + "value": { + "kind": "length", + "value": 14.0, + "unit": "mm", + "editable": true, + "bounds": [10.0, 20.0], + "status": "specified", + "description": "Output shaft journal diameter." + } + }, + { + "op": "add_parameter", + "id": "output_shaft_length", + "value": { + "kind": "length", + "value": 45.0, + "unit": "mm", + "editable": true, + "bounds": [25.0, 80.0], + "status": "specified", + "description": "Usable shaft extension measured from the output mounting face." + } + }, + { + "op": "add_component", + "value": { + "id": "output_shaft", + "role": "external_torque_shaft", + "archetype": "stepped_keyed_shaft", + "lifecycle": "optional", + "generation": { + "strategy": "semantic_feature_program", + "family": "stepped_output_shaft", + "parameter_bindings": { + "journal_diameter": "output_shaft_diameter", + "extension_length": "output_shaft_length", + "mating_bore": "output_bore_diameter" + } + }, + "interfaces": [ + { + "id": "shaft_axis", + "type": "axis", + "construction": "primary_axis" + }, + { + "id": "mounting_shoulder", + "type": "plane", + "construction": "rear_axial_face" + }, + { + "id": "flange_fit", + "type": "shaft_seat", + "construction": "outer_cylindrical_surface", + "parameter_refs": ["output_shaft_diameter"] + }, + { + "id": "torque_drive", + "type": "torque_interface", + "construction": "generated_feature", + "qualifiers": ["key_or_spline_selected_by_load"] + }, + { + "id": "external_shaft_end", + "type": "plane", + "construction": "front_axial_face" + } + ], + "material_class": "alloy_steel", + "tags": ["rotating", "output", "public_interface"] + } + }, + { + "op": "add_relation", + "value": { + "id": "output_shaft_centering", + "type": "coaxial", + "a": "output_flange.rotation_axis", + "b": "output_shaft.shaft_axis", + "intent": "Center the new shaft on the solved output axis.", + "mobility": "fixed" + } + }, + { + "op": "add_relation", + "value": { + "id": "output_shaft_seating", + "type": "face_mate", + "a": "output_flange.external_mount_face", + "b": "output_shaft.mounting_shoulder", + "intent": "Locate the shaft shoulder from the output flange mounting face.", + "value": 0.0, + "tolerance": 0.01, + "mobility": "fixed" + } + }, + { + "op": "add_relation", + "value": { + "id": "output_shaft_fit", + "type": "insert", + "a": "output_flange.output_bore", + "b": "output_shaft.flange_fit", + "intent": "Fit the shaft journal into the output flange bore.", + "tolerance": 0.01, + "mobility": "fixed" + } + }, + { + "op": "add_relation", + "value": { + "id": "output_shaft_torque_lock", + "type": "torque_lock", + "a": "output_flange.output_bore", + "b": "output_shaft.torque_drive", + "intent": "Transfer output torque through a keyed or splined interface.", + "mobility": "fixed" + } + }, + { + "op": "add_rigid_group_member", + "group": "output_group", + "component": "output_shaft" + }, + { + "op": "add_preserved_interface", + "value": "output_shaft.external_shaft_end" + } + ] +} diff --git a/text-to-cad/skills/cad-router/SKILL.md b/text-to-cad/skills/cad-router/SKILL.md index ea2bd55..8403185 100644 --- a/text-to-cad/skills/cad-router/SKILL.md +++ b/text-to-cad/skills/cad-router/SKILL.md @@ -5,7 +5,7 @@ description: Route natural-language or image-guided CAD generation and modificat # CAD Router -Choose a backend before modeling, keep its source as the editable authority, and normalize every result into one handoff contract. Use two projects by role: text-to-cad owns orchestration, general CAD, refinement, validation, repair, and viewing; SimpleCADAPI owns specialized mechanical families. +Choose a backend before modeling, keep its source as the editable authority, and normalize every result into one handoff contract. `text-to-cad` is the backend-neutral orchestrator and DesignIR owner, not a backend that must win by default. `build123d` and SimpleCADAPI 2.0.2 are equal-priority authoring backends selected from evidence. Uploaded STEP reconstruction is executed by SurfaceIR and independently checked by both the existing geometry validator and SimpleCADAPI B-Rep comparison. ## Required workflow @@ -47,8 +47,8 @@ Choose a backend before modeling, keep its source as the editable authority, and publication. A route decision alone is not a completed reconstruction. 3. Follow the selected route unless a hard environmental constraint makes it unavailable. If overriding it, record the reason in the task manifest. 4. Load only the selected backend reference from `references/capabilities.md` and the matching installed Skill: - - `build123d`: use `$cad`; this is the default for general STEP-first mechanical parts, assemblies, and existing STEP modification. - - `simplecadapi`: use an installed SimpleCADAPI Skill/runtime; prefer it for gears, racks, ring gears, bearings, cycloidal parts, reducers, replayable graphs, and semantic tags. + - `build123d`: use `$cad`; prefer it for custom brackets, housings, shafts, flanges, fixtures, source-level assemblies, and freeform/custom surface work. + - `simplecadapi`: use an installed SimpleCADAPI 2.0.2 Skill/runtime; prefer it when a standard factory exists, or when replayable ModelResult graphs, semantic lineage, physical units, tolerance chains, scene packages, or CAD translators materially improve the result. Its standard library includes gears, racks, ring and bevel gears, bearings, bolts, nuts, roller-chain sprockets, cycloidal parts, and reducer families. 5. When the route reports `requirement_refinement` or `visual_repair`, refine the brief, generate, execute, inspect multiple views, and repair no more than three visual mismatch rounds before asking the user. 6. Generate into a task-owned directory and start CAD Viewer with that task directory as `--dir`; do not expose the repository-wide fixture library for a task review. Write one DesignIR 3.0 `*.designir.json` per part and treat it as the editable geometry contract; keep `cad-task.json` as the execution record. Read `references/designir-3.0.md` for both semantic authoring and uploaded STEP reconstruction. `references/designir-2.0.md` is migration-only. Never change backend during an edit unless conversion is explicitly requested; modify the recorded authoritative layer. For a supplied STEP/STP, use `scripts/route.py --execute`; do not manually @@ -60,21 +60,39 @@ Choose a backend before modeling, keep its source as the editable authority, and The DesignIR and generator must not import STEP, embed B-Rep or meshes, retain face references, or use the source model as a base feature. The acceptance agent alone compares the teacher and reconstructed STEP. + Dense SurfaceIR may be stored losslessly in the DesignIR 3.0 + `gzip+base64+json` envelope. CAD runtimes verify and decode it; LLM prompts + receive only the semantic layer, edit interface, and validated parameter + catalog, never the encoded geometry payload. Only expose parameters listed by `parameters.json`; every listed parameter has passed an isolated perturbation test. Other inferred names remain evidence, not a promised edit interface. + The acceptance zone also runs the installed SimpleCADAPI 2.0.2 strict B-Rep + comparator. `brep_exact` requires both the bidirectional geometric-set check + and the geometry-labelled incidence graph check. A geometry-equal but + topology-different result is returned as `review_required`, not silently + described as an exact reconstruction. Failure or absence of the supplemental + comparator must be recorded separately and must not be confused with a pass. 7. Validate against the brief. Run STEP geometry inspection and snapshot review. For experience-informed generation, record which generalized methods were applied, including the reconstruction grammar ID and instantiated symbolic roles. Validate against the user's brief and held-out targets, never against parameters exposed from the same source case. 8. Write a `cad-task.json` manifest using the contract in `references/backend-contract.md`. Hand the STEP artifact to `$cad-viewer` and return its live link. +9. Export URDF or MJCF only on explicit request. Run the deterministic + DesignIR robot-description exporter lazily and return its portable ZIP with + XML, STL visual/collision geometry, and export manifest. When authoritative + assembly/joint/material evidence is absent, export one fixed `base_link`; + never invent joints, limits, actuators, mass, or inertia. ## Routing rules - Prefer an explicit user backend when it can produce the editable STEP contract. -- Prefer SimpleCADAPI when a supported standard mechanical factory or replay/semantic requirement materially reduces custom modeling. -- Prefer build123d for general machined parts such as brackets, connecting rods, housings, shafts, flanges, fixtures, and source-driven assemblies. +- Preserve the backend of an explicitly supplied DesignIR or Python generator unless conversion is requested. +- Do not assign a permanent default backend. Score native-source continuity, factory coverage, semantic/replay needs, geometry vocabulary, assembly needs, and validation needs. +- Prefer SimpleCADAPI when a supported standard mechanical factory or its model graph, semantic topology, unit/tolerance, scene, or translator capabilities materially improve editability and verification. +- Prefer build123d for custom machined parts, source-driven assemblies, and freeform or surface-heavy work not covered by a SimpleCADAPI factory or tested operation vocabulary. +- For uploaded STEP, report SurfaceIR as the effective reconstruction engine. The selected authoring backend describes future semantic repair/edit work; it is not allowed to read the teacher STEP during reconstruction. - Prefer source quality and future modification over the shortest first generation. ## V1 boundaries diff --git a/text-to-cad/skills/cad-router/references/backend-contract.md b/text-to-cad/skills/cad-router/references/backend-contract.md index e62ef50..80a3bc0 100644 --- a/text-to-cad/skills/cad-router/references/backend-contract.md +++ b/text-to-cad/skills/cad-router/references/backend-contract.md @@ -23,6 +23,10 @@ Capture these fields when known: Record: - `selected_backend`: `build123d` or `simplecadapi`. +- `routing_policy.effective_execution_engine`: the actual engine. It is + `surfaceir` for an uploaded STEP even though `selected_backend` records the + preferred semantic authoring backend for later edits. +- `routing_policy.validation_engines`: validators required by the chosen mode. - Origin project and optional executable adapter. - `runner_skill`: installed Skill responsible for generation. - Ranked backend scores and human-readable reasons. @@ -81,6 +85,11 @@ distillation corpus. The task publishes the independent DesignIR, reconstructed STEP, validated parameter catalog, geometry report, edit report, and `cad-task.json`. The reconstruction subprocess receives no teacher STEP path; only extraction and acceptance may read the explicit uploaded source. +The acceptance stage records both SurfaceIR geometry validation and the +SimpleCADAPI B-Rep comparator. A B-Rep report may be `passed`, `mismatch`, or +`unavailable`; only `passed` earns the `brep_exact` quality tier. Geometry +equality with a topology mismatch remains usable for review but is not an exact +reconstruction. ## Backend adapter requirements diff --git a/text-to-cad/skills/cad-router/references/capabilities.md b/text-to-cad/skills/cad-router/references/capabilities.md index 799bb20..6c231e3 100644 --- a/text-to-cad/skills/cad-router/references/capabilities.md +++ b/text-to-cad/skills/cad-router/references/capabilities.md @@ -1,8 +1,25 @@ # Backend capabilities -## build123d through CAD Skill +## Selection principle -Use as the default STEP-first backend. +There is no permanent default authoring backend. `text-to-cad` owns routing, +DesignIR, validation, repair, artifacts, and Viewer handoff. It does not receive +extra score simply for being the orchestrator. + +The router scores: + +1. continuity with an explicitly supplied editable source; +2. deterministic standard-factory coverage; +3. semantic graph, replay, units, tolerances, and translation requirements; +4. custom profile and surface vocabulary; +5. assembly and mechanism requirements; +6. validation requirements. + +Uploaded STEP reconstruction is a separate mode: SurfaceIR is the effective +rebuild engine, while SimpleCADAPI B-Rep comparison supplements the independent +acceptance stage. + +## build123d through CAD Skill Best fit: @@ -11,20 +28,36 @@ Best fit: - Source-level assemblies, named datums, joints, imported components, measurements, alignment checks, and existing STEP inspection. - STEP output compiled from executable semantic DesignIR 3.0. -Weak signals: a specialized mechanical family already implemented by SimpleCADAPI or a browser-only execution requirement. +Weak signals: a standard mechanical family already implemented by +SimpleCADAPI, or a primary requirement for replayable operation graphs, typed +semantic topology, unit checking, tolerance chains, or CAD translation. ## SimpleCADAPI Use for its standard mechanical factories and replayable modeling graph. -Best fit: +Best fit in version 2.0.2: -- Spur, helical, and herringbone gears; internal ring gears; racks; cycloidal discs; ball bearings. +- Spur, helical, herringbone, ring, and straight bevel gears; racks; cycloidal + discs; ball bearings; bolts; nuts; and roller-chain sprockets. - Planetary and cycloidal reducers, joint actuators, and mechanism-heavy assemblies. -- Parameter expressions, operation-graph JSON, replay, semantic tags, and topology queries. +- `@model` / `ModelResult`, parameter expressions, operation-graph JSON, replay, + scene packages, semantic tags, topology lineage, source mapping, and QL. +- Physical units, dimension checking, and manufacturing tolerance chains. +- FreeCAD, Fusion 360, and SolidWorks translation. +- STEP B-Rep inspection, strict target/candidate comparison, multi-view + diagnostics, and slice XOR diagnostics. - Reusable mechanical product families whose formulas should become deterministic library functions. -Before invocation, inspect the installed package and Skill versions together. Do not mix the current single-`Solid` boolean API with older `*rsolidlist` documentation. If the installed runtime and docs disagree, stop that route and use build123d or repair the installation. +Weak signals: freeform or multi-rail surface construction outside the tested SDK +vocabulary, or an existing build123d source whose feature history must remain +authoritative. + +Before invocation, inspect the installed package and Skill versions together. +Require version `2.0.2` or newer for the capabilities listed above. Do not mix +the current single-`Solid` boolean API with older `*rsolidlist` documentation. +If the installed runtime and docs disagree, stop that route and use build123d +or repair the installation. ## Router refinement and repair diff --git a/text-to-cad/skills/cad-router/references/designir-3.0.md b/text-to-cad/skills/cad-router/references/designir-3.0.md index 1c270fc..cc3fe80 100644 --- a/text-to-cad/skills/cad-router/references/designir-3.0.md +++ b/text-to-cad/skills/cad-router/references/designir-3.0.md @@ -113,6 +113,49 @@ The adapter loads only formally promoted experiences whose scope is applied conditionally by the runtime; `cad-task.json` records both the available method count and the exact method IDs used for the task. +## Compact single-file storage + +A DesignIR remains one source-independent JSON file. For surface-parametric and +hybrid documents, the dense `surface_layer` may use the lossless +`gzip+base64+json` storage envelope: + +```json +{ + "surface_layer": { + "format": "surfaceir-3.0", + "encoding": "gzip+base64+json", + "uncompressed_bytes": 10286354, + "compressed_bytes": 2358852, + "sha256": "", + "data": "" + } +} +``` + +The runtime verifies the byte counts and SHA-256 digest before rebuilding. +Decoding restores the same SurfaceIR object; no coordinate, topology, pcurve, +tolerance, parameter binding, or edit information is discarded. Legacy +documents containing an expanded `surface_layer` remain supported. + +Agents must read the ordinary top-level `semantic_layer`, `edit_interface`, and +validated `parameters.json`. They must not place the encoded `surface_layer.data` +string in an LLM prompt. Deterministic CAD runtimes alone decode that field. + +## Lazy URDF and MJCF export + +URDF and MJCF are derived artifacts, not additional DesignIR authorities. +Generate them only after an explicit download/export request. The deterministic +exporter rebuilds from DesignIR alone and packages: + +- the `.urdf` or `.mjcf.xml` description; +- an STL mesh used for visual and collision geometry; +- `robot-export.json` with source hash, scale, reconstruction strategy, + structural assumptions, and dynamics status. + +STEP-derived geometry without authoritative assembly, joint, or material +evidence exports as one fixed `base_link`. Do not infer articulated joints, +limits, actuators, density, mass, or inertia merely from disconnected solids. + ## Parameter boundary STEP does not contain authoritative feature names or modeling history. The diff --git a/text-to-cad/skills/cad-router/scripts/capabilities.json b/text-to-cad/skills/cad-router/scripts/capabilities.json index 42a3b50..03da911 100644 --- a/text-to-cad/skills/cad-router/scripts/capabilities.json +++ b/text-to-cad/skills/cad-router/scripts/capabilities.json @@ -1,5 +1,21 @@ { - "schema_version": "1.0", + "schema_version": "2.0", + "routing_policy": { + "default_backend": null, + "selection_basis": [ + "native source continuity", + "standard factory coverage", + "semantic and replay requirements", + "geometry vocabulary coverage", + "assembly requirements", + "validation requirements" + ], + "uploaded_step_execution_engine": "surfaceir", + "uploaded_step_validation_engines": [ + "surfaceir_geometry_acceptance", + "simplecadapi_brep_compare" + ] + }, "backends": { "build123d": { "project": "text-to-cad", @@ -10,21 +26,43 @@ "general mechanical parts", "machined features", "source-level assemblies", - "existing STEP inspection and modification", + "freeform profiles, lofts, sweeps, and mixed surface workflows", "geometry measurement and validation" ], + "preferred_when": [ + "the part is a custom bracket, housing, shaft, flange, fixture, or enclosure", + "source-level assembly mates or existing build123d Python must be preserved", + "the geometry needs custom profiles or surface-heavy construction not covered by a SimpleCADAPI factory" + ], + "weak_when": [ + "a deterministic SimpleCADAPI standard factory covers the requested part", + "operation-graph replay, typed semantic tags, units, or tolerance chains are primary requirements" + ], "source_of_truth": "build123d Python generator" }, "simplecadapi": { "project": "SimpleCADAPI", "runner_skill": "simplecadapi", + "minimum_version": "2.0.2", "source_formats": ["py", "json"], "primary_formats": ["step", "stp"], "strengths": [ - "gears, ring gears, racks, and cycloidal discs", - "bearings, reducers, and joint actuators", - "replayable operation graphs", - "semantic tags and topology queries" + "gears, ring gears, racks, bevel gears, and cycloidal discs", + "bearings, bolts, nuts, roller-chain sprockets, reducers, and joint actuators", + "replayable ModelResult operation graphs and scene packages", + "typed semantic tags, topology lineage, and source mapping", + "physical units and tolerance-chain validation", + "STEP B-Rep inspection, strict comparison, and slice diagnostics", + "FreeCAD, Fusion 360, and SolidWorks translation" + ], + "preferred_when": [ + "a standard mechanical factory directly covers the requested family", + "replayable model JSON, semantic edit anchors, units, tolerances, or CAD translation are required", + "a reusable mechanical product family should become a deterministic library function" + ], + "weak_when": [ + "the request depends on complex freeform surfaces outside the supported operation vocabulary", + "an existing build123d generator or source-level build123d assembly must be preserved" ], "source_of_truth": "SimpleCADAPI Python generator and model JSON" } diff --git a/text-to-cad/skills/cad-router/scripts/reconstruct_step.py b/text-to-cad/skills/cad-router/scripts/reconstruct_step.py index d6b8341..92315ed 100644 --- a/text-to-cad/skills/cad-router/scripts/reconstruct_step.py +++ b/text-to-cad/skills/cad-router/scripts/reconstruct_step.py @@ -10,6 +10,7 @@ from __future__ import annotations import argparse import json +import math import os import re import shutil @@ -34,6 +35,11 @@ DESIGNIR_SCHEMA = ( / "contracts" / "designir-3.0.schema.json" ) +DESIGNIR_SCRIPTS = WORKSPACE_ROOT / "designir-pipeline" / "scripts" +if str(DESIGNIR_SCRIPTS) not in sys.path: + sys.path.insert(0, str(DESIGNIR_SCRIPTS)) + +from designir_codec import read_designir, write_designir EXECUTABLE_EDIT_STATES = { "executable_enlarge_cut_binding", @@ -80,10 +86,14 @@ def _safe_stem(source: Path) -> str: def _write_json(path: Path, payload: dict[str, Any]) -> None: + if path.name.endswith(".designir.json"): + write_designir(path, payload) + return path.parent.mkdir(parents=True, exist_ok=True) path.write_text( json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8", + errors="replace", ) @@ -117,6 +127,66 @@ def _run_json( ) from exc +def _run_simplecad_brep_compare( + runtime: Path, + source: Path, + rebuilt: Path, + report_path: Path, + *, + timeout_seconds: int, +) -> dict[str, Any]: + """Run the installed SimpleCADAPI comparator in the acceptance zone. + + A topology mismatch is a valid diagnostic result, not a worker failure. + The teacher path is provided only to this acceptance subprocess. + """ + command = [ + str(runtime), + "-m", + "simplecadapi.inverse_engineer.brep.cli", + "compare", + str(source), + str(rebuilt), + "--output", + str(report_path), + ] + try: + completed = subprocess.run( + command, + capture_output=True, + text=True, + timeout=timeout_seconds, + check=False, + ) + except subprocess.TimeoutExpired as exc: + return { + "status": "unavailable", + "reason": "timeout", + "error": str(exc), + } + if report_path.is_file(): + try: + payload = json.loads(report_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + return { + "status": "unavailable", + "reason": "invalid_report", + "error": str(exc), + } + payload["status"] = ( + "passed" if payload.get("hard_gate_passed") else "mismatch" + ) + payload["engine"] = "simplecadapi.inverse_engineer.brep" + return payload + detail = completed.stderr.strip() or completed.stdout.strip() + return { + "status": "unavailable", + "reason": "worker_failed", + "return_code": completed.returncode, + "error": detail[-2000:], + } + + def _validate_designir_contract(payload: dict[str, Any]) -> dict[str, Any]: required = { "schema_version", @@ -261,7 +331,7 @@ def _persist_edit_results( designir_path: Path, attempts: list[dict[str, Any]], ) -> tuple[dict[str, Any], list[dict[str, Any]]]: - payload = json.loads(designir_path.read_text(encoding="utf-8")) + payload = read_designir(designir_path) parameters = payload.get("semantic_layer", {}).get("parameters", {}) accepted_parameters: list[dict[str, Any]] = [] attempted_names = set() @@ -312,6 +382,7 @@ def reconstruct_uploaded_step( request: str, maximum_parameters: int = 8, timeout_seconds: int = 180, + semantic_backend: str = "build123d", ) -> dict[str, Any]: source = source.expanduser().resolve() task_dir = task_dir.expanduser().resolve() @@ -338,6 +409,7 @@ def reconstruct_uploaded_step( rebuilt_path = task_dir / f"{stem}.reconstructed.step" validation_dir = task_dir / "validation" geometry_report_path = validation_dir / "geometry.json" + brep_report_path = validation_dir / "simplecad-brep.json" edit_report_path = validation_dir / "edits.json" parameters_path = task_dir / "parameters.json" task_manifest_path = task_dir / "cad-task.json" @@ -360,7 +432,7 @@ def reconstruct_uploaded_step( runtime = _runtime_python() common = [str(runtime), str(SURFACEIR_PIPELINE)] available_experience_ids = _surfaceir_experience_ids() - boundary_strategy = ( + preferred_boundary_strategy = ( "exact_3d_pcurve" if ( "failure_repair.surface_topology.bind_face_pcurves_and_group_shells" @@ -380,25 +452,9 @@ def reconstruct_uploaded_step( timeout_seconds=timeout_seconds, stage="DesignIR 3.0 extraction", ) - payload = json.loads(designir_path.read_text(encoding="utf-8")) + payload = read_designir(designir_path) contract_checks = _validate_designir_contract(payload) - # The rebuild worker receives no teacher path. It can regenerate the model - # from the DesignIR artifact alone. - _run_json( - common - + [ - "rebuild", - str(designir_path), - "--output", - str(rebuilt_path), - "--boundary-strategy", - boundary_strategy, - ], - timeout_seconds=timeout_seconds, - stage="source-independent SurfaceIR rebuild", - ) - manifest_path = task_dir / "manifest.json" _write_json( manifest_path, @@ -415,35 +471,162 @@ def reconstruct_uploaded_step( }, ) validation_dir.mkdir(parents=True, exist_ok=True) - geometry_work_dir = validation_dir / ".geometry-work" - geometry_result = _run_json( - common - + [ - "validate-folder", - str(source.parent), - "--designir-dir", - str(task_dir), - "--rebuilt-dir", - str(geometry_work_dir), - "--limit", - "1", - "--boolean", - "--boundary-strategy", - boundary_strategy, - ], - timeout_seconds=timeout_seconds, - stage="Acceptance-Agent geometry validation", + strategy_root = validation_dir / ".strategy-work" + strategies = [preferred_boundary_strategy] + if ( + "failure_repair.semantic_edit.boundary_strategy_fallback" + in available_experience_ids + ): + strategies.extend( + strategy + for strategy in ( + "exact_3d", + "first_pcurve", + "analytic_uv_rect", + "uv_rect_all", + ) + if strategy not in strategies + ) + + strategy_attempts: list[dict[str, Any]] = [] + selected: tuple[ + tuple[float, ...], + str, + dict[str, Any], + dict[str, Any], + Path, + ] | None = None + for strategy in strategies: + attempt_dir = strategy_root / strategy + try: + candidate_result = _run_json( + common + + [ + "validate-folder", + str(source.parent), + "--designir-dir", + str(task_dir), + "--rebuilt-dir", + str(attempt_dir), + "--limit", + "1", + "--boolean", + "--boundary-strategy", + strategy, + ], + timeout_seconds=timeout_seconds, + stage=f"Acceptance-Agent geometry validation ({strategy})", + ) + report = json.loads( + (attempt_dir / "validation-report.json").read_text( + encoding="utf-8" + ) + ) + row = (report.get("rows") or [{}])[0] + candidate_steps = sorted(attempt_dir.glob("*.step")) + if not candidate_steps: + raise ReconstructionError( + f"Boundary strategy {strategy} produced no rebuilt STEP" + ) + + def finite_metric(name: str) -> float: + value = row.get(name) + if isinstance(value, (int, float)) and math.isfinite(value): + return float(value) + return math.inf + + score = ( + 0.0 + if row.get("geometry_pass") + else 1.0 + if row.get("usable_reconstruction") + else 2.0, + 0.0 if row.get("topology_match") else 1.0, + finite_metric("relative_volume_error"), + finite_metric("relative_surface_area_error"), + finite_metric("center_of_mass_error"), + finite_metric("bounds_error"), + finite_metric("bidirectional_sample_distance"), + ) + strategy_attempts.append( + { + "strategy": strategy, + "status": "evaluated", + "score": list(score), + "geometry_pass": bool(row.get("geometry_pass")), + "usable_reconstruction": bool( + row.get("usable_reconstruction") + ), + "topology_match": bool(row.get("topology_match")), + "relative_volume_error": row.get("relative_volume_error"), + "relative_surface_area_error": row.get( + "relative_surface_area_error" + ), + "center_of_mass_error": row.get("center_of_mass_error"), + "bounds_error": row.get("bounds_error"), + "bidirectional_sample_distance": row.get( + "bidirectional_sample_distance" + ), + } + ) + candidate = ( + score, + strategy, + report, + candidate_result, + candidate_steps[0], + ) + if selected is None or candidate[0] < selected[0]: + selected = candidate + except (ReconstructionError, subprocess.TimeoutExpired) as exc: + strategy_attempts.append( + { + "strategy": strategy, + "status": "worker_failed", + "error": str(exc), + } + ) + + strategy_report_path = validation_dir / "strategies.json" + if selected is None: + _write_json( + strategy_report_path, + { + "schema_version": "1.0", + "selected_strategy": None, + "attempts": strategy_attempts, + }, + ) + raise ReconstructionError("Every SurfaceIR boundary strategy failed") + + _, boundary_strategy, geometry_report, geometry_result, selected_step = selected + shutil.copy2(selected_step, rebuilt_path) + _write_json( + strategy_report_path, + { + "schema_version": "1.0", + "selected_strategy": boundary_strategy, + "attempts": strategy_attempts, + }, ) - raw_geometry_report = geometry_work_dir / "validation-report.json" - geometry_report = json.loads(raw_geometry_report.read_text(encoding="utf-8")) _write_json(geometry_report_path, geometry_report) geometry_pass = geometry_report.get("geometry_pass_count") == 1 - if not geometry_pass: + usable_reconstruction = ( + geometry_report.get("usable_reconstruction_count") == 1 + ) + if not geometry_pass and not usable_reconstruction: raise ReconstructionError( "Independent reconstruction did not satisfy the geometry contract; " f"see {geometry_report_path}" ) - shutil.rmtree(geometry_work_dir) + brep_comparison = _run_simplecad_brep_compare( + runtime, + source, + rebuilt_path, + brep_report_path, + timeout_seconds=timeout_seconds, + ) + shutil.rmtree(strategy_root) attempts: list[dict[str, Any]] = [] if payload["document_status"] == "geometry_present": @@ -502,7 +685,9 @@ def reconstruct_uploaded_step( ) payload["reconstruction_strategy"] = { "boundary_strategy": boundary_strategy, - "selection_status": "geometry_validated", + "selection_status": ( + "geometry_validated" if geometry_pass else "unresolved" + ), } _write_json(designir_path, payload) applied_experience_ids = _applied_experience_ids( @@ -525,6 +710,41 @@ def reconstruct_uploaded_step( "policy": "Only parameters that passed an isolated perturbation test are exposed.", } _write_json(parameters_path, parameter_catalog) + brep_hard_gate_passed = brep_comparison.get("hard_gate_passed") is True + brep_available = brep_comparison.get("status") in {"passed", "mismatch"} + quality_tier = ( + "brep_exact" + if geometry_pass and brep_hard_gate_passed + else "geometry_summary_accepted_brep_mismatch" + if geometry_pass and brep_available + else "geometry_accepted_brep_unavailable" + if geometry_pass + else "review_required" + ) + published_artifacts = [ + {"path": rebuilt_path.name, "role": "primary_step"}, + {"path": designir_path.name, "role": "editable_designir"}, + {"path": parameters_path.name, "role": "validated_parameters"}, + { + "path": str(geometry_report_path.relative_to(task_dir)), + "role": "geometry_acceptance", + }, + { + "path": str(edit_report_path.relative_to(task_dir)), + "role": "parameter_acceptance", + }, + { + "path": str(strategy_report_path.relative_to(task_dir)), + "role": "boundary_strategy_acceptance", + }, + ] + if brep_report_path.is_file(): + published_artifacts.append( + { + "path": str(brep_report_path.relative_to(task_dir)), + "role": "strict_brep_acceptance", + } + ) task_manifest = { "schema_version": "1.0", @@ -533,8 +753,12 @@ def reconstruct_uploaded_step( "route": { "router": "cad-router", "runtime": "DesignIR 3.0 / SurfaceIR", - "selected_backend": "build123d", + "selected_backend": semantic_backend, "execution_adapter": "surfaceir_occt", + "validation_adapters": [ + "surfaceir_geometry_acceptance", + "simplecadapi_brep_compare", + ], "source_of_truth": designir_path.name, }, "source": { @@ -542,19 +766,7 @@ def reconstruct_uploaded_step( "role": "teacher_and_acceptance_truth_only", "embedded_in_output": False, }, - "artifacts": [ - {"path": rebuilt_path.name, "role": "primary_step"}, - {"path": designir_path.name, "role": "editable_designir"}, - {"path": parameters_path.name, "role": "validated_parameters"}, - { - "path": str(geometry_report_path.relative_to(task_dir)), - "role": "geometry_acceptance", - }, - { - "path": str(edit_report_path.relative_to(task_dir)), - "role": "parameter_acceptance", - }, - ], + "artifacts": published_artifacts, "experience": { "library": ( str(EXPERIENCE_LIBRARY) @@ -566,7 +778,11 @@ def reconstruct_uploaded_step( "methods_applied": applied_experience_ids, }, "validation": { - "geometry_accepted": True, + "geometry_accepted": geometry_pass, + "geometry_usable": usable_reconstruction, + "brep_comparison_status": brep_comparison.get("status"), + "brep_hard_gate_passed": brep_hard_gate_passed, + "quality_tier": quality_tier, "source_independent_rebuild": True, "anti_cheat_pass": True, "validated_parameter_count": len(accepted_parameters), @@ -592,7 +808,15 @@ def reconstruct_uploaded_step( return { "schema_version": "1.0", "status": ( - "accepted" + ( + "accepted" + if geometry_pass + and ( + brep_hard_gate_passed + or brep_comparison.get("status") == "unavailable" + ) + else "review_required" + ) if payload["document_status"] == "geometry_present" else "accepted_empty_source" ), @@ -603,7 +827,13 @@ def reconstruct_uploaded_step( "task_manifest": str(task_manifest_path), "geometry_report": str(geometry_report_path), "edit_report": str(edit_report_path), - "geometry_accepted": True, + "geometry_accepted": geometry_pass, + "geometry_usable": usable_reconstruction, + "brep_comparison": brep_comparison, + "brep_report": ( + str(brep_report_path) if brep_report_path.is_file() else None + ), + "quality_tier": quality_tier, "document_status": payload["document_status"], "validated_parameter_count": len(accepted_parameters), "validated_parameter_names": [ @@ -620,6 +850,10 @@ def reconstruct_uploaded_step( "stages": { "extraction": extraction, "geometry_validation": geometry_result, + "brep_validation": { + "engine": "SimpleCADAPI 2.0.2", + "status": brep_comparison.get("status"), + }, }, } @@ -631,6 +865,12 @@ def build_parser() -> argparse.ArgumentParser: parser.add_argument("--request", default="重建上传的 STEP") parser.add_argument("--maximum-parameters", type=int, default=8) parser.add_argument("--timeout-seconds", type=int, default=180) + parser.add_argument( + "--semantic-backend", + choices=("build123d", "simplecadapi"), + default="build123d", + help="Preferred editable semantic backend after SurfaceIR reconstruction.", + ) return parser @@ -643,6 +883,7 @@ def main(argv: list[str] | None = None) -> int: request=args.request, maximum_parameters=args.maximum_parameters, timeout_seconds=args.timeout_seconds, + semantic_backend=args.semantic_backend, ) except (ReconstructionError, subprocess.TimeoutExpired) as exc: print( diff --git a/text-to-cad/skills/cad-router/scripts/route.py b/text-to-cad/skills/cad-router/scripts/route.py index 69f0857..bda33fc 100755 --- a/text-to-cad/skills/cad-router/scripts/route.py +++ b/text-to-cad/skills/cad-router/scripts/route.py @@ -5,9 +5,11 @@ import argparse import importlib.util import json import os +import re import subprocess import sys from dataclasses import dataclass, field +from importlib.metadata import PackageNotFoundError, version from pathlib import Path @@ -28,19 +30,31 @@ class BackendScore: KEYWORD_GROUPS: dict[str, tuple[str, ...]] = { - "special_mechanical": ( + "standard_factory": ( "gear", "gears", "齿轮", "齿条", "rack", "ring gear", "内齿圈", "cycloidal", "摆线", "bearing", "轴承", "planetary", "行星", "reducer", "gearbox", "减速器", "减速箱", "joint actuator", "关节执行器", + "bevel gear", "锥齿轮", "sprocket", "链轮", "roller chain", "滚子链", + "bolt", "螺栓", "nut", "螺母", "fastener", "紧固件", ), "general_mechanical": ( "bracket", "支架", "connecting rod", "连杆", "crank", "曲柄", "lever", "摇臂", "shaft", "轴", "bushing", "轴套", "flange", "法兰", "housing", "壳体", "enclosure", "外壳", "fixture", "夹具", "plate", "安装板", ), - "replay": ( + "semantic_modeling": ( "replay", "重放", "operation graph", "model json", "模型图", "semantic tag", - "语义标签", + "语义标签", "topology", "拓扑追踪", "source mapping", "来源映射", + "unit", "单位", "tolerance", "公差", "公差链", "scene.zip", + "freecad", "fusion 360", "solidworks", + ), + "custom_surface": ( + "freeform", "自由曲面", "organic", "有机曲面", "nurbs", "复杂曲面", + "surface patch", "曲面补片", "multi rail", "多轨扫掠", + ), + "mechanism": ( + "mechanism", "机构", "motion", "运动关系", "gear constraint", "齿轮约束", + "belt constraint", "皮带约束", "revolute", "转动副", "prismatic", "移动副", ), "visual_complexity": ( "image", "photo", "sketch", "图片", "照片", "草图", "复杂", "complex", @@ -186,18 +200,98 @@ def infer_experience_query(request: str) -> tuple[str | None, list[str]]: def probe_backends() -> dict[str, dict[str, object]]: + def distribution_version(name: str) -> str | None: + try: + return version(name) + except PackageNotFoundError: + return None + + simplecad_version = distribution_version("simplecadapi") return { "build123d": { "available": importlib.util.find_spec("build123d") is not None, + "version": distribution_version("build123d"), "probe": f"python={sys.executable}", }, "simplecadapi": { "available": importlib.util.find_spec("simplecadapi") is not None, + "version": simplecad_version, + "minimum_version": "2.0.2", + "compatible": bool( + simplecad_version + and tuple( + int(part) + for part in re.findall(r"\d+", simplecad_version)[:3] + ) + >= (2, 0, 2) + ), "probe": f"python={sys.executable}", }, } +def _source_backend_hint(edit_source: Path | None) -> str | None: + """Read only an explicitly supplied editable source to preserve its backend.""" + if edit_source is None: + return None + source = edit_source.expanduser() + if not source.is_file(): + return None + if source.name.endswith(".designir.json"): + try: + payload = json.loads(source.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return None + hint = payload.get("backend_hint") + if hint in {"build123d", "simplecadapi"}: + return hint + compiler = payload.get("compiler") + if isinstance(compiler, dict) and compiler.get("backend") in { + "build123d", + "simplecadapi", + }: + return str(compiler["backend"]) + if source.suffix.lower() == ".py": + try: + prefix = source.read_text(encoding="utf-8")[:100_000].lower() + except OSError: + return None + if "import simplecadapi" in prefix or "from simplecadapi" in prefix: + return "simplecadapi" + if "import build123d" in prefix or "from build123d" in prefix: + return "build123d" + return None + + +def _routing_requirements( + text: str, + *, + edit_context: dict[str, object] | None, + assembly: bool, + source_backend: str | None, +) -> list[str]: + requirements = ["step_export", "editable_designir"] + if contains_any(text, KEYWORD_GROUPS["standard_factory"]): + requirements.append("standard_mechanical_factory") + if contains_any(text, KEYWORD_GROUPS["semantic_modeling"]): + requirements.append("semantic_graph_and_replay") + if contains_any(text, KEYWORD_GROUPS["custom_surface"]): + requirements.append("custom_surface_vocabulary") + if contains_any(text, KEYWORD_GROUPS["mechanism"]): + requirements.append("mechanism_constraints") + if assembly: + requirements.append("assembly") + if source_backend: + requirements.append(f"preserve_{source_backend}_source") + if edit_context and edit_context.get("modification_mode") == ( + "private_evidence_to_isolated_designir_reconstruction" + ): + requirements.extend( + ["source_independent_surfaceir_rebuild", "strict_brep_validation"] + ) + return list(dict.fromkeys(requirements)) + + EXPERIENCE_FORBIDDEN_KEYS = { "parameters", "parameter_examples", @@ -458,34 +552,125 @@ def route(args: argparse.Namespace) -> dict[str, object]: "surfaceir" if teacher_step_reconstruction else "router", ) needs_source_classification = bool(edit_context and not experience_family) + source_backend = _source_backend_hint(args.edit_source) + requirements = _routing_requirements( + text, + edit_context=edit_context, + assembly=args.assembly, + source_backend=source_backend, + ) scores = { - "build123d": BackendScore("build123d", 35, ["+35 general STEP-first default"]), - "simplecadapi": BackendScore("simplecadapi", 20, ["+20 specialist mechanical baseline"]), + "build123d": BackendScore( + "build123d", + 30, + ["+30 broad custom mechanical geometry coverage"], + ), + "simplecadapi": BackendScore( + "simplecadapi", + 30, + ["+30 replayable OCP-native parametric coverage"], + ), } + availability = probe_backends() if args.probe else None + if availability: + for backend_name, facts in availability.items(): + if facts.get("available") is not True: + scores[backend_name].add( + -10_000, "backend is unavailable in the probed runtime" + ) + if availability["simplecadapi"].get("compatible") is not True: + scores["simplecadapi"].add( + -10_000, + "installed SimpleCADAPI does not satisfy the 2.0.2 capability contract", + ) - if contains_any(text, KEYWORD_GROUPS["special_mechanical"]): - scores["simplecadapi"].add(65, "supported standard mechanical family") - scores["build123d"].add(8, "can model the family generically") + if contains_any(text, KEYWORD_GROUPS["standard_factory"]): + scores["simplecadapi"].add( + 85, "a deterministic standard mechanical factory is available" + ) + scores["build123d"].add(10, "the family can be modeled generically") if contains_any(text, KEYWORD_GROUPS["general_mechanical"]): - scores["build123d"].add(35, "general mechanical part with direct parametric features") - scores["simplecadapi"].add(10, "general OCP modeling is available") - if contains_any(text, KEYWORD_GROUPS["replay"]): - scores["simplecadapi"].add(60, "replay or semantic graph requirement") + scores["build123d"].add( + 32, "custom mechanical part benefits from broad feature construction" + ) + scores["simplecadapi"].add( + 24, "general OCP feature modeling and semantic tagging are available" + ) + if contains_any(text, KEYWORD_GROUPS["semantic_modeling"]): + scores["simplecadapi"].add( + 70, + "model replay, semantic lineage, units, tolerances, or translation is required", + ) + scores["build123d"].add( + 8, "editable Python can preserve parameters but has no equivalent unified graph" + ) + if contains_any(text, KEYWORD_GROUPS["custom_surface"]): + scores["build123d"].add( + 65, "custom/freeform surface vocabulary favors the general CAD backend" + ) + scores["simplecadapi"].add( + -20, "requested surface vocabulary may exceed deterministic SDK coverage" + ) + if contains_any(text, KEYWORD_GROUPS["mechanism"]): + scores["simplecadapi"].add( + 38, "typed mechanism constraints and replayable products are available" + ) + scores["build123d"].add( + 24, "source-level joints and assemblies are available" + ) - if edit_context: - scores["build123d"].add(55, "existing model inspection or source modification") + if source_backend: + scores[source_backend].add( + 120, f"preserve the explicitly supplied {source_backend} source of truth" + ) + other = "simplecadapi" if source_backend == "build123d" else "build123d" + scores[other].add( + -35, "backend conversion would risk feature-history and edit-contract loss" + ) + elif edit_context and not teacher_step_reconstruction: + scores["build123d"].add( + 20, "general existing-source inspection and local feature repair" + ) + scores["simplecadapi"].add( + 20, "semantic graph editing is available when the source supports it" + ) + if teacher_step_reconstruction: + # Uploaded STEP reconstruction is executed by SurfaceIR. Backend scores + # describe the preferred semantic authoring backend after reconstruction, + # not the source-independent rebuild engine itself. + scores["build123d"].add( + 18, "broad semantic feature vocabulary can author future repairs" + ) + scores["simplecadapi"].add( + 18, "strict B-Rep comparison and semantic lineage strengthen acceptance" + ) if args.assembly: - scores["build123d"].add(15, "source-level assembly workflow") - scores["simplecadapi"].add(18, "mechanism and constraint workflow") - scores["build123d"].add(35, "editable STEP is the fixed artifact contract") + scores["build123d"].add(25, "source-level assembly and datum workflow") + scores["simplecadapi"].add( + 25, "replayable product, connector, and constraint workflow" + ) + scores["build123d"].add(15, "STEP export and editable Python satisfy the contract") scores["simplecadapi"].add( - 30, "OCP STEP export satisfies the fixed artifact contract" + 15, "STEP export plus model JSON satisfy the contract" ) if experience_context and experience_context["experiences"]: - scores["build123d"].add(18, "generalized experience can guide parametric feature composition") - scores["simplecadapi"].add(18, "generalized experience can guide semantic feature composition") + scores["build123d"].add( + 18, "generalized experience can guide parametric feature composition" + ) + scores["simplecadapi"].add( + 24, "generalized experience can bind to semantic roles and operation graphs" + ) forced_backend = args.backend if args.backend != "auto" else None + if ( + forced_backend + and availability + and scores[forced_backend].score < -5_000 + ): + raise ValueError( + f"Explicit backend '{forced_backend}' is unavailable or incompatible " + "in the probed runtime" + ) ranked = sorted(scores.values(), key=lambda item: (-item.score, item.name)) if forced_backend: selected = scores[forced_backend] @@ -500,7 +685,7 @@ def route(args: argparse.Namespace) -> dict[str, object]: profiles.extend(["requirement_refinement", "visual_repair"]) if not any(char.isdigit() for char in request) and contains_any( text, - KEYWORD_GROUPS["general_mechanical"] + KEYWORD_GROUPS["special_mechanical"], + KEYWORD_GROUPS["general_mechanical"] + KEYWORD_GROUPS["standard_factory"], ): profiles.append("requirement_refinement") profiles = list(dict.fromkeys(profiles)) @@ -523,6 +708,29 @@ def route(args: argparse.Namespace) -> dict[str, object]: "project": backend_info["project"], "adapter": backend_info.get("adapter"), "confidence": confidence, + "routing_policy": { + "version": "2.0", + "default_backend": None, + "selection_scope": ( + "post_reconstruction_semantic_backend" + if teacher_step_reconstruction + else "authoring_backend" + ), + "requirements": requirements, + "native_source_backend": source_backend, + "score_margin": gap, + "effective_execution_engine": ( + "surfaceir" if teacher_step_reconstruction else selected.name + ), + "validation_engines": ( + [ + "surfaceir_geometry_acceptance", + "simplecadapi_brep_compare", + ] + if teacher_step_reconstruction + else ["backend_geometry_validation"] + ), + }, "backend_scores": [ {"backend": item.name, "score": item.score, "reasons": item.reasons} for item in sorted(scores.values(), key=lambda item: (-item.score, item.name)) @@ -631,10 +839,10 @@ def route(args: argparse.Namespace) -> dict[str, object]: ), }, "project_contributions": { - "text-to-cad": "core Skill host, build123d CAD backend, refinement, visual repair, validation, artifacts, and CAD Viewer handoff", - "SimpleCADAPI": "specialized mechanical backend with replayable graphs and semantic modeling", + "text-to-cad": "backend-neutral orchestrator, DesignIR owner, SurfaceIR reconstruction, refinement, repair, validation, artifacts, and Viewer handoff", + "SimpleCADAPI": "equal-priority OCP backend for standard factories, replayable graphs, semantic lineage, units, tolerances, translators, and B-Rep diagnostics", }, - "availability": probe_backends() if args.probe else None, + "availability": availability, } if teacher_step_reconstruction: result["execution_adapter"] = { @@ -642,6 +850,7 @@ def route(args: argparse.Namespace) -> dict[str, object]: "runtime": "DesignIR 3.0 / SurfaceIR", "automatic_geometry_acceptance": True, "automatic_parameter_acceptance": True, + "supplemental_brep_acceptance": "SimpleCADAPI 2.0.2", } return result @@ -684,6 +893,8 @@ def execute_reconstruction( str(args.maximum_parameters), "--timeout-seconds", str(args.timeout_seconds), + "--semantic-backend", + str(result["selected_backend"]), ] completed = subprocess.run( command, @@ -703,13 +914,22 @@ def execute_reconstruction( def explain(result: dict[str, object]) -> str: + policy = result.get("routing_policy", {}) lines = [ f"Selected backend: {result['selected_backend']}", f"Runner skill: ${result['runner_skill']}", f"Project: {result['project']}", f"Confidence: {result['confidence']}", + "Effective execution engine: " + f"{policy.get('effective_execution_engine', result['selected_backend'])}", f"Source of truth: {result['source_of_truth']}", ] + requirements = policy.get("requirements", []) + if requirements: + lines.append(f"Requirements: {', '.join(requirements)}") + validators = policy.get("validation_engines", []) + if validators: + lines.append(f"Validation engines: {', '.join(validators)}") profiles = result["workflow_profiles"] if profiles: lines.append(f"Workflow profiles: {', '.join(profiles)}") diff --git a/text-to-cad/tests/python/skills/cad-router/test_reconstruct_step.py b/text-to-cad/tests/python/skills/cad-router/test_reconstruct_step.py index c0b2a25..e081d65 100644 --- a/text-to-cad/tests/python/skills/cad-router/test_reconstruct_step.py +++ b/text-to-cad/tests/python/skills/cad-router/test_reconstruct_step.py @@ -46,6 +46,12 @@ class UploadedStepRuntimeTests(unittest.TestCase): self.assertEqual("accepted", result["status"]) self.assertTrue(result["geometry_accepted"]) + self.assertEqual("passed", result["brep_comparison"]["status"]) + self.assertTrue( + result["brep_comparison"]["hard_gate_passed"] + ) + self.assertEqual("brep_exact", result["quality_tier"]) + self.assertTrue(Path(result["brep_report"]).is_file()) self.assertFalse( result["source_independence"][ "rebuild_command_received_teacher_path" @@ -63,6 +69,18 @@ class UploadedStepRuntimeTests(unittest.TestCase): self.assertFalse( (task_dir / "validation" / ".geometry-work").exists() ) + self.assertFalse( + (task_dir / "validation" / ".strategy-work").exists() + ) + strategy_report = json.loads( + (task_dir / "validation" / "strategies.json").read_text( + encoding="utf-8" + ) + ) + self.assertEqual( + "exact_3d_pcurve", strategy_report["selected_strategy"] + ) + self.assertGreaterEqual(len(strategy_report["attempts"]), 2) parameters = json.loads( Path(result["parameters"]).read_text(encoding="utf-8") @@ -76,6 +94,13 @@ class UploadedStepRuntimeTests(unittest.TestCase): ) self.assertEqual(1, geometry["geometry_pass_count"]) self.assertEqual(0, geometry["geometry_fail_count"]) + task_manifest = json.loads( + Path(result["task_manifest"]).read_text(encoding="utf-8") + ) + self.assertIn( + "simplecadapi_brep_compare", + task_manifest["route"]["validation_adapters"], + ) if __name__ == "__main__": diff --git a/text-to-cad/tests/python/skills/cad-router/test_route.py b/text-to-cad/tests/python/skills/cad-router/test_route.py index e573b0e..e0fcdfb 100644 --- a/text-to-cad/tests/python/skills/cad-router/test_route.py +++ b/text-to-cad/tests/python/skills/cad-router/test_route.py @@ -24,6 +24,32 @@ class CadRouterTests(unittest.TestCase): result = route_module.route(self.parse("生成一个模数 2 的人字齿轮")) self.assertEqual("simplecadapi", result["selected_backend"]) + def test_routes_new_standard_factories_to_simplecadapi(self) -> None: + for request in ( + "生成一个 M8 六角螺栓", + "生成一个 08B 滚子链轮", + "生成一个模数 2 的直齿锥齿轮", + ): + with self.subTest(request=request): + result = route_module.route(self.parse(request)) + self.assertEqual("simplecadapi", result["selected_backend"]) + + def test_routes_semantic_units_and_tolerances_to_simplecadapi(self) -> None: + result = route_module.route( + self.parse("生成参数化法兰,保留语义标签、毫米单位和公差链") + ) + self.assertEqual("simplecadapi", result["selected_backend"]) + self.assertIn( + "semantic_graph_and_replay", + result["routing_policy"]["requirements"], + ) + + def test_routes_custom_freeform_surface_to_build123d(self) -> None: + result = route_module.route( + self.parse("生成带 NURBS 自由曲面的定制外壳") + ) + self.assertEqual("build123d", result["selected_backend"]) + def test_routes_connecting_rod_to_build123d(self) -> None: result = route_module.route(self.parse("生成中心距 120mm 的发动机连杆")) self.assertEqual("build123d", result["selected_backend"]) @@ -32,6 +58,49 @@ class CadRouterTests(unittest.TestCase): result = route_module.route(self.parse("修改现有零件的孔径", "--existing-model")) self.assertEqual("build123d", result["selected_backend"]) + def test_uploaded_step_uses_surfaceir_and_simplecad_brep_validation(self) -> None: + result = route_module.route( + self.parse( + "重建上传的零件", + "--edit-source", + "/tmp/source.step", + ) + ) + policy = result["routing_policy"] + self.assertEqual("surfaceir", policy["effective_execution_engine"]) + self.assertEqual( + [ + "surfaceir_geometry_acceptance", + "simplecadapi_brep_compare", + ], + policy["validation_engines"], + ) + self.assertEqual( + "SimpleCADAPI 2.0.2", + result["execution_adapter"]["supplemental_brep_acceptance"], + ) + + def test_existing_designir_preserves_simplecadapi_backend(self) -> None: + with tempfile.TemporaryDirectory() as directory: + source = Path(directory) / "part.designir.json" + source.write_text( + json.dumps( + { + "schema_version": "3.0", + "backend_hint": "simplecadapi", + } + ), + encoding="utf-8", + ) + result = route_module.route( + self.parse("把孔径增加 1mm", "--edit-source", str(source)) + ) + self.assertEqual("simplecadapi", result["selected_backend"]) + self.assertEqual( + "simplecadapi", + result["routing_policy"]["native_source_backend"], + ) + def test_every_route_has_fixed_step_artifact_contract(self) -> None: result = route_module.route(self.parse("做一个参数化旋钮")) self.assertEqual( @@ -55,6 +124,13 @@ class CadRouterTests(unittest.TestCase): selected = next(item for item in result["backend_scores"] if item["backend"] == "build123d") self.assertIn("explicit backend override", selected["reasons"]) + def test_probe_reports_simplecadapi_202_capability_contract(self) -> None: + result = route_module.route(self.parse("生成一个齿轮", "--probe")) + simplecad = result["availability"]["simplecadapi"] + self.assertTrue(simplecad["available"]) + self.assertTrue(simplecad["compatible"]) + self.assertEqual("2.0.2", simplecad["minimum_version"]) + def test_generalized_experience_is_loaded_without_case_geometry(self) -> None: payload = { "schema_version": "2.0",