diff --git a/backend/.env b/backend/.env index eda918ba..033f5c49 100644 --- a/backend/.env +++ b/backend/.env @@ -1,19 +1,26 @@ # Default provider. Only providers with an API key are exposed to the UI. CDSL_DEFAULT_PROVIDER=deepseek -CDSL_DEFAULT_MODEL=deepseek-chat +CDSL_DEFAULT_MODEL=deepseek-v4-flash +# CDSL_DEFAULT_PROVIDER=openai +# CDSL_DEFAULT_MODEL=gpt-5.5 # DeepSeek. Fill in your own API key below. CDSL_LLM_BASE_URL=https://api.deepseek.com/v1 CDSL_LLM_API_KEY=sk-d3f8fe84bf9a4100a6563559e5d2fefd -CDSL_LLM_MODEL=deepseek-chat +CDSL_LLM_MODEL=deepseek-v4-flash,deepseek-v4-pro,deepseek-v4-flash-vision-exp CDSL_LLM_TIMEOUT_S=90 +CDSL_DEEPSEEK_VISION_MODELS=deepseek-v4-flash-vision-exp + +# Incremental generation uses a separate vision-capable model for checkpoint review. +CDSL_REVIEW_PROVIDER=deepseek +CDSL_REVIEW_MODEL=deepseek-v4-flash-vision-exp # Optional OpenAI provider. Comma-separate enabled models; list vision models # separately so image attachments can be routed safely. -CDSL_OPENAI_BASE_URL=https://api.openai.com/v1 -CDSL_OPENAI_API_KEY= -CDSL_OPENAI_MODELS=gpt-4.1,gpt-4.1-mini -CDSL_OPENAI_VISION_MODELS=gpt-4.1,gpt-4.1-mini +CDSL_OPENAI_BASE_URL=https://api.vip1129.cc/v1 +CDSL_OPENAI_API_KEY=sk-6586c229d77de8c421ba98e7eb0d9c6bb10f08ebc796de946ed17cf8d0d7a229 +CDSL_OPENAI_MODELS=gpt-5.5,gpt-5.6-luna +CDSL_OPENAI_VISION_MODELS=gpt-5.5,gpt-5.6-luna # Optional Kimi provider. CDSL_KIMI_BASE_URL=https://api.moonshot.cn/v1 @@ -24,8 +31,9 @@ CDSL_KIMI_VISION_MODELS= # OpenAI-compatible function schemas. This affects generate_cdsl_model's JSON # arguments only, never ordinary assistant chat text. CDSL_DEEPSEEK_STRICT_TOOL_SCHEMA=false -CDSL_OPENAI_STRICT_TOOL_SCHEMA=true +CDSL_OPENAI_STRICT_TOOL_SCHEMA=false CDSL_KIMI_STRICT_TOOL_SCHEMA=true +CDSL_MAX_REPAIR_ATTEMPTS=4 # To enable individual models instead of every model from a provider, keep the # provider-wide switch false and list exact IDs, for example: # CDSL_OPENAI_STRICT_TOOL_MODELS=gpt-4.1 diff --git a/backend/README.md b/backend/README.md index db9a62ac..e445d238 100644 --- a/backend/README.md +++ b/backend/README.md @@ -9,3 +9,40 @@ The backend owns the application API and CAD generation workflow: - `tests/`: Engine, API, and end-to-end generation tests. Expected development entrypoint: `app.main:app`, served by Uvicorn. + +## Incremental Generation Configuration + +Incremental generation is enabled by default. It requires a separately +configured vision-capable review model and the Python OpenCascade/Pillow +technical renderer; a run +fails instead of skipping visual review when either is unavailable. + +```dotenv +# Authoring provider/model must already be configured as usual. +CDSL_INCREMENTAL_GENERATION=1 + +# Must name one configured provider and one model listed in that provider's +# CDSL__VISION_MODELS setting. It is intentionally not inferred +# from the authoring model. +CDSL_REVIEW_PROVIDER=openai +CDSL_REVIEW_MODEL=gpt-4.1-mini +CDSL_OPENAI_VISION_MODELS=gpt-4.1-mini + +# Install Python rendering dependencies. The renderer reads the revision STEP +# file and creates canonical images without a browser or GPU driver. +pip install -r requirements.txt + +# Optional per-node retry budgets. +CDSL_NODE_AUTHORING_ATTEMPTS=2 +CDSL_NODE_REPAIR_ATTEMPTS=2 +CDSL_NODE_REPLAN_ATTEMPTS=1 +``` + +Every checkpoint is rebuilt from its fully materialized CDSL through the +`cdsl_only` runtime. Checkpoint GLB files are preview-only; STEP, CDSL, and +reports are available only after the task reaches `COMPLETED`. + +The generation plan contains semantic node IDs only. The backend derives the +unique CDSL feature and sketch IDs from each node, then writes them during +fragment materialization. This keeps naming and topology ownership stable +without requiring the authoring model to reproduce internal identifiers. diff --git a/backend/agent/skills/cad-engine/SKILL.md b/backend/agent/skills/cad-engine/SKILL.md index 828b2458..9427d021 100644 --- a/backend/agent/skills/cad-engine/SKILL.md +++ b/backend/agent/skills/cad-engine/SKILL.md @@ -13,15 +13,16 @@ Help an AI agent generate CAD models through the repository's CDSL engine. 1. Use any injected CDSL part-skill bridge to establish the part structure, parameter roles, and feature order. It is planning guidance only. -2. Submit a complete DesignIntent before reading CDSL references or generating - CDSL. It contains semantic structures and mappings, never sketch coordinates - or raw CAD code. The backend records canonical selected part skills. +2. Record a concise natural-language design brief before reading CDSL references + or generating CDSL. The brief captures the intended structures, dependency + order, key dimensions, and explicit assumptions. It is reference context + only, never an executable CAD contract. 3. Read the engine README, `profile_schema.json`, and `cdsl_schema.json`, then search the official CDSL library for schema-valid profiles and feature expressions. `cdsl_schema.json` is the executable input contract. -4. Produce parameterized CDSL in feature dependency order, using the accepted - DesignIntent's feature IDs and mappings plus the part - plan for intent and CDSL references for concrete schema expressions. +4. Produce parameterized CDSL in feature dependency order. CDSL is the sole + authoritative and executable model: choose its feature IDs, profiles, + selectors, and schema-valid expressions directly from the CDSL contract. 5. Call the generation tool only when every chosen feature is executable by the current schema and runtime. Validate through the `cdsl_only` path, then generate STEP and GLB artifacts. @@ -29,7 +30,8 @@ Help an AI agent generate CAD models through the repository's CDSL engine. ## Part-skill boundary - User requirements and the CDSL schema/runtime override bridge guidance; - CDSL examples are lower-priority expression references. + CDSL examples are lower-priority expression references. The textual design + brief is planning context and is never validated against the CDSL. - Do not expose upstream source skills directly or treat them as code. Use only the injected CDSL bridge content. - Never emit build123d source or invent an atomic, profile, selector, or diff --git a/backend/agent/skills/cad-engine/planning-recipe.md b/backend/agent/skills/cad-engine/planning-recipe.md index a05cb327..95c6ff3c 100644 --- a/backend/agent/skills/cad-engine/planning-recipe.md +++ b/backend/agent/skills/cad-engine/planning-recipe.md @@ -1,40 +1,35 @@ -# DesignIntent Planning Recipe +# CDSL Planning Recipe ## Requirement analysis -1. Treat the backend-injected part-family skill as the selected family before - identifying structures. It provides planning guidance only. -2. Decompose the requested part into structures with roles: `base`, - `reference`, `additive`, `subtractive`, `dressup`, or `pattern`. -3. Every structure must state a purpose, structural dependencies, parameter - roles, selector roles, an executable CDSL atomic ID, and a named profile - type when its atomic needs a sketch. -4. Put every structure exactly once in `feature_order`, after its dependencies. - Its `cdsl_feature_id` is the immutable ID that the later CDSL feature must use. -5. Do not put coordinates, raw CDSL, Build123d, or low-level sketch data in a - DesignIntent. -6. Missing essential dimensions are blocking `open_questions`. Unsupported - geometry is a `capability_gaps` item. A blocking item requires - `status: needs_clarification`; do not approximate silently. +1. Treat the backend-injected part-family skill as planning guidance only. +2. Write a concise natural-language brief describing the requested structures, + dependency order, key dimensions, and explicit assumptions. +3. Ask the user one concise clarification question when an essential dimension + or requested outcome is unknown. Do not silently fabricate it. +4. Keep the brief free of feature IDs, selector bindings, profile bindings, + sketch coordinates, raw CDSL, and Build123d source. Those implementation + choices belong only in the final CDSL document. +5. The brief is not stored or validated as a CAD artifact. It is reference + context for the model while it writes the one authoritative CDSL document. ## Part family boundary -- Use only the backend-injected primary and support skills. Never submit - `part_skill_ids`; the backend records the canonical selection for audit. +- Use only the backend-injected primary and support skills. The backend records + the canonical selection for audit. - Explicit user requirements override part skills. The executable CDSL schema/runtime overrides both part skills and library examples. - The current primary family is inherited during revisions unless the user explicitly asks to replace the whole part. A family conflict needs a concise clarification before a ready plan can be submitted. -- CDSL library examples show only expression patterns. They never add or remove - a requested DesignIntent structure. +- CDSL library examples show expression patterns only. They never override the + user's request or the CDSL schema/runtime. ## CDSL generation -- Submit `propose_design_intent` before any library or CDSL-generation call. -- After its ready result includes `intent_id`, inspect references and generate - complete CDSL. Use every planned `cdsl_feature_id` once, in `feature_order`. -- Do not add undeclared CDSL features, delete planned features, change planned - atomics/profiles, or omit declared selector evidence. +- Submit `describe_design_intent` before any library or CDSL-generation call. +- After the text brief is returned, inspect references and generate complete, + schema-valid CDSL. CDSL controls its own feature IDs, atomics, profiles, and + selectors; it is not checked for agreement with the brief. - Keep `cdsl_only` as the only build path. Never use `compiler_context` or a legacy fallback. diff --git a/backend/agent/skills/cad-engine/references/part-skills/bridge/atomic-pattern-holes.md b/backend/agent/skills/cad-engine/references/part-skills/bridge/atomic-pattern-holes.md index 204ad8ed..b3945d63 100644 --- a/backend/agent/skills/cad-engine/references/part-skills/bridge/atomic-pattern-holes.md +++ b/backend/agent/skills/cad-engine/references/part-skills/bridge/atomic-pattern-holes.md @@ -2,4 +2,4 @@ - Build a valid seed hole first and preserve its host frame and functional subtype. - Use `pattern_linear` for one/two-dimensional rows and `pattern_mirror` for symmetry. -- For circular layouts, use an explicit `circles` profile expansion and record the audit status; do not invent a circular-pattern atomic. +- For circular layouts, use an explicit `circles` profile expansion; do not invent a circular-pattern atomic. diff --git a/backend/agent/skills/cad-engine/references/part-skills/bridge/planning-flange.md b/backend/agent/skills/cad-engine/references/part-skills/bridge/planning-flange.md index e4e877fa..6b48bc1e 100644 --- a/backend/agent/skills/cad-engine/references/part-skills/bridge/planning-flange.md +++ b/backend/agent/skills/cad-engine/references/part-skills/bridge/planning-flange.md @@ -3,5 +3,5 @@ - Use for a circular flange, flanged sleeve, or coupling flange with a shared central axis. - Invariants: coaxial outside diameter, bore/hub, sealing land, and fastener holes on a pitch circle. - Plan: `revolve_add` for stepped axisymmetric bodies or circular `extrude_add_blind` -> coaxial bore -> one bolt-hole definition -> explicit circle set or supported repetition -> late finishing. -- No circular-pattern atomic exists. Expand a bolt circle into explicit circles in one cut profile and record `expanded` in the audit. +- No circular-pattern atomic exists. Express a bolt circle with explicit circles in one cut profile when the engine schema supports that profile. - Keep bolt-circle diameter, count, start angle, bore, and hub dimensions distinct. diff --git a/backend/agent/skills/cad-engine/references/part-skills/catalog.json b/backend/agent/skills/cad-engine/references/part-skills/catalog.json index ae537cb1..5eceb8f8 100644 --- a/backend/agent/skills/cad-engine/references/part-skills/catalog.json +++ b/backend/agent/skills/cad-engine/references/part-skills/catalog.json @@ -12,7 +12,6 @@ "exclude": ["flange", "法兰", "3d bracket", "mounting bracket", "轴承座"], "bridge": "bridge/planning-mounting-plate.md", "source": "source/planning/mounting-plate.planning.md", - "capability_translation_rules": ["exact: schema_valid_feature_sequence"], "related": ["functional/mounting-plate-hole-layout", "functional/slotted-adjustment-feature", "functional/standard-hole-wizard", "functional/symmetric-feature-layout", "atomic/extrude-base-profile", "atomic/pattern-holes-from-datum", "atomic/fillet-chamfer-last"] }, { @@ -24,7 +23,6 @@ "exclude": ["flat mounting plate only", "仅底板", "sheet metal"], "bridge": "bridge/planning-mounting-bracket.md", "source": "source/planning/mounting-bracket.planning.md", - "capability_translation_rules": ["exact: schema_valid_feature_sequence"], "related": ["functional/slotted-adjustment-feature", "functional/standard-hole-wizard", "functional/symmetric-feature-layout", "atomic/extrude-base-profile", "atomic/pattern-holes-from-datum", "atomic/fillet-chamfer-last"] }, { @@ -36,7 +34,6 @@ "exclude": ["flange nut", "法兰螺母", "side tab"], "bridge": "bridge/planning-flange.md", "source": "source/planning/flange.planning.md", - "capability_translation_rules": ["expanded: circular_pattern_to_explicit_circles"], "related": ["functional/flange-bolt-circle", "functional/axisymmetric-revolve-strategy", "functional/standard-hole-wizard", "atomic/coaxial-bore-rule", "atomic/revolve-profile-around-axis", "atomic/pattern-holes-from-datum", "atomic/fillet-chamfer-last"] }, { @@ -48,7 +45,6 @@ "exclude": ["spindle assembly", "bearing assembly", "gearbox shaft assembly", "主轴组件", "轴承组件"], "bridge": "bridge/planning-simple-shaft.md", "source": "source/planning/simple-shaft-or-cylindrical-rod.planning.md", - "capability_translation_rules": ["exact: revolve_or_extrude_supported"], "related": ["functional/axisymmetric-revolve-strategy", "atomic/coaxial-bore-rule", "atomic/revolve-profile-around-axis", "atomic/through-hole-cut", "atomic/fillet-chamfer-last"] }, { @@ -60,7 +56,6 @@ "exclude": ["complete rolling bearing", "bearing assembly", "完整轴承", "轴承组件"], "bridge": "bridge/planning-bearing-housing.md", "source": "source/planning/bearing-housing-or-seat.planning.md", - "capability_translation_rules": ["blocked: selector_required_for_dependent_features"], "related": ["functional/bearing-bore-seat", "functional/standard-hole-wizard", "atomic/coaxial-bore-rule", "atomic/extrude-base-profile", "atomic/revolve-profile-around-axis", "atomic/counterbored-hole-creation", "atomic/fillet-chamfer-last"] }, { @@ -72,25 +67,24 @@ "exclude": ["bolt", "screw", "wing nut", "cap nut", "flange nut", "螺栓", "螺钉", "蝶形螺母", "法兰螺母"], "bridge": "bridge/planning-hexagonal-nut.md", "source": "source/planning/hexagonal-nut.planning.md", - "capability_translation_rules": ["approximated: thread_geometry_omitted"], "related": ["functional/axisymmetric-revolve-strategy", "functional/standard-hole-wizard", "atomic/extrude-base-profile", "atomic/coaxial-bore-rule", "atomic/threaded-hole-creation", "atomic/fillet-chamfer-last"] }, - {"id": "functional/axisymmetric-revolve-strategy", "kind": "functional", "title": "Axisymmetric Revolve", "priority": 80, "triggers": ["axisymmetric", "revolve", "revolved body", "rotational part", "turned part", "回转体", "轴对称", "车削件"], "exclude": ["simple extruded cylinder", "rectangular plate"], "bridge": "bridge/functional-axisymmetric-revolve.md", "source": "source/functional/axisymmetric-revolve-strategy.functional.md", "capability_translation_rules": ["exact: explicit_revolve_axis"], "related": []}, - {"id": "functional/bearing-bore-seat", "kind": "functional", "title": "Bearing Bore Seat", "priority": 79, "triggers": ["bearing bore", "bearing pocket", "bearing seat", "bushing bore", "shaft clearance bore", "轴承孔", "轴承座孔", "轴承腔", "轴间隙孔"], "exclude": [], "bridge": "bridge/functional-bearing-bore-seat.md", "source": "source/functional/bearing-bore-seat.functional.md", "capability_translation_rules": ["blocked: selector_required_for_dependent_features"], "related": ["atomic/coaxial-bore-rule", "atomic/counterbored-hole-creation"]}, - {"id": "functional/flange-bolt-circle", "kind": "functional", "title": "Flange Bolt Circle", "priority": 78, "triggers": ["flange bolt circle", "bolt circle", "pitch circle", "pcd", "circular bolt pattern", "螺栓圆", "节圆", "圆周孔阵列"], "exclude": ["rectangular hole grid", "矩形孔阵列"], "bridge": "bridge/functional-flange-bolt-circle.md", "source": "source/functional/flange-bolt-circle.functional.md", "capability_translation_rules": ["expanded: circular_pattern_to_explicit_circles", "blocked: explicit_circle_layout_required"], "related": ["atomic/pattern-holes-from-datum"]}, - {"id": "functional/mounting-plate-hole-layout", "kind": "functional", "title": "Mounting Plate Hole Layout", "priority": 77, "triggers": ["mounting plate hole layout", "base plate hole pattern", "rectangular hole grid", "symmetric mounting holes", "底板孔布局", "矩形孔阵列", "安装孔阵列"], "exclude": ["flange bolt circle", "bearing bore"], "bridge": "bridge/functional-mounting-plate-hole-layout.md", "source": "source/functional/mounting-plate-hole-layout.functional.md", "capability_translation_rules": ["exact: linear_or_mirror_pattern"], "related": ["atomic/pattern-holes-from-datum", "functional/symmetric-feature-layout"]}, - {"id": "functional/slotted-adjustment-feature", "kind": "functional", "title": "Slotted Adjustment Feature", "priority": 76, "triggers": ["slot", "slotted hole", "adjustment slot", "elongated hole", "guide slot", "槽", "长圆槽", "调节槽", "导向槽"], "exclude": ["shaft keyway", "revolved groove", "轴键槽", "回转槽"], "bridge": "bridge/functional-slotted-adjustment.md", "source": "source/functional/slotted-adjustment-feature.functional.md", "capability_translation_rules": ["exact: obround_extrude_cut"], "related": []}, - {"id": "functional/standard-hole-wizard", "kind": "functional", "title": "Standard Hole Wizard", "priority": 75, "triggers": ["hole wizard", "standard holes", "threaded holes", "counterbored holes", "countersunk holes", "tapped holes", "孔向导", "标准孔", "螺纹孔", "沉孔", "沉头孔"], "exclude": [], "bridge": "bridge/functional-standard-hole-wizard.md", "source": "source/functional/standard-hole-wizard.functional.md", "capability_translation_rules": ["approximated: thread_geometry_omitted", "exact: supported_hole_subtype"], "related": ["atomic/counterbored-hole-creation", "atomic/threaded-hole-creation", "atomic/through-hole-cut"]}, - {"id": "functional/symmetric-feature-layout", "kind": "functional", "title": "Symmetric Feature Layout", "priority": 74, "triggers": ["symmetric holes", "mirror pattern", "symmetry", "mirrored features", "centerline layout", "对称孔", "镜像阵列", "对称布局", "中心线布局"], "exclude": [], "bridge": "bridge/functional-symmetric-layout.md", "source": "source/functional/symmetric-feature-layout.functional.md", "capability_translation_rules": ["exact: pattern_mirror_or_pattern_linear"], "related": []}, + {"id": "functional/axisymmetric-revolve-strategy", "kind": "functional", "title": "Axisymmetric Revolve", "priority": 80, "triggers": ["axisymmetric", "revolve", "revolved body", "rotational part", "turned part", "回转体", "轴对称", "车削件"], "exclude": ["simple extruded cylinder", "rectangular plate"], "bridge": "bridge/functional-axisymmetric-revolve.md", "source": "source/functional/axisymmetric-revolve-strategy.functional.md", "related": []}, + {"id": "functional/bearing-bore-seat", "kind": "functional", "title": "Bearing Bore Seat", "priority": 79, "triggers": ["bearing bore", "bearing pocket", "bearing seat", "bushing bore", "shaft clearance bore", "轴承孔", "轴承座孔", "轴承腔", "轴间隙孔"], "exclude": [], "bridge": "bridge/functional-bearing-bore-seat.md", "source": "source/functional/bearing-bore-seat.functional.md", "related": ["atomic/coaxial-bore-rule", "atomic/counterbored-hole-creation"]}, + {"id": "functional/flange-bolt-circle", "kind": "functional", "title": "Flange Bolt Circle", "priority": 78, "triggers": ["flange bolt circle", "bolt circle", "pitch circle", "pcd", "circular bolt pattern", "螺栓圆", "节圆", "圆周孔阵列"], "exclude": ["rectangular hole grid", "矩形孔阵列"], "bridge": "bridge/functional-flange-bolt-circle.md", "source": "source/functional/flange-bolt-circle.functional.md", "related": ["atomic/pattern-holes-from-datum"]}, + {"id": "functional/mounting-plate-hole-layout", "kind": "functional", "title": "Mounting Plate Hole Layout", "priority": 77, "triggers": ["mounting plate hole layout", "base plate hole pattern", "rectangular hole grid", "symmetric mounting holes", "底板孔布局", "矩形孔阵列", "安装孔阵列"], "exclude": ["flange bolt circle", "bearing bore"], "bridge": "bridge/functional-mounting-plate-hole-layout.md", "source": "source/functional/mounting-plate-hole-layout.functional.md", "related": ["atomic/pattern-holes-from-datum", "functional/symmetric-feature-layout"]}, + {"id": "functional/slotted-adjustment-feature", "kind": "functional", "title": "Slotted Adjustment Feature", "priority": 76, "triggers": ["slot", "slotted hole", "adjustment slot", "elongated hole", "guide slot", "槽", "长圆槽", "调节槽", "导向槽"], "exclude": ["shaft keyway", "revolved groove", "轴键槽", "回转槽"], "bridge": "bridge/functional-slotted-adjustment.md", "source": "source/functional/slotted-adjustment-feature.functional.md", "related": []}, + {"id": "functional/standard-hole-wizard", "kind": "functional", "title": "Standard Hole Wizard", "priority": 75, "triggers": ["hole wizard", "standard holes", "threaded holes", "counterbored holes", "countersunk holes", "tapped holes", "孔向导", "标准孔", "螺纹孔", "沉孔", "沉头孔"], "exclude": [], "bridge": "bridge/functional-standard-hole-wizard.md", "source": "source/functional/standard-hole-wizard.functional.md", "related": ["atomic/counterbored-hole-creation", "atomic/threaded-hole-creation", "atomic/through-hole-cut"]}, + {"id": "functional/symmetric-feature-layout", "kind": "functional", "title": "Symmetric Feature Layout", "priority": 74, "triggers": ["symmetric holes", "mirror pattern", "symmetry", "mirrored features", "centerline layout", "对称孔", "镜像阵列", "对称布局", "中心线布局"], "exclude": [], "bridge": "bridge/functional-symmetric-layout.md", "source": "source/functional/symmetric-feature-layout.functional.md", "related": []}, - {"id": "atomic/coaxial-bore-rule", "kind": "atomic", "title": "Coaxial Bore", "priority": 60, "triggers": ["coaxial bore", "central bore", "concentric", "shaft clearance", "同轴孔", "中心孔", "同心", "轴间隙"], "exclude": [], "bridge": "bridge/atomic-coaxial-bore.md", "source": "source/atomic/coaxial-bore-rule.atomic.md", "capability_translation_rules": ["exact: explicit_axis_or_frame"], "related": []}, - {"id": "atomic/counterbored-hole-creation", "kind": "atomic", "title": "Counterbored Hole", "priority": 59, "triggers": ["counterbored hole", "counterbore", "socket head screw", "cap screw", "recessed screw head", "沉孔", "内六角螺钉", "螺钉头沉孔"], "exclude": [], "bridge": "bridge/atomic-counterbore.md", "source": "source/atomic/counterbored-hole-creation.atomic.md", "capability_translation_rules": ["exact: hole_counterbore"], "related": []}, - {"id": "atomic/extrude-base-profile", "kind": "atomic", "title": "Extrude Base Profile", "priority": 58, "triggers": ["extrude base profile", "extrude rectangle", "extrude circle", "base extrusion", "simple cylinder", "rectangular block", "基础拉伸", "矩形拉伸", "圆柱拉伸"], "exclude": [], "bridge": "bridge/atomic-extrude-base.md", "source": "source/atomic/extrude-base-profile.atomic.md", "capability_translation_rules": ["exact: extrude_add_blind"], "related": []}, - {"id": "atomic/fillet-chamfer-last", "kind": "atomic", "title": "Fillet And Chamfer Last", "priority": 57, "triggers": ["fillet chamfer last", "edge treatment", "chamfered edges", "filleted edges", "最后倒角", "最后圆角", "边缘处理"], "exclude": [], "bridge": "bridge/atomic-fillet-chamfer-last.md", "source": "source/atomic/fillet-chamfer-last.atomic.md", "capability_translation_rules": ["omitted: selector_unavailable", "exact: unique_selector"], "related": []}, - {"id": "atomic/pattern-holes-from-datum", "kind": "atomic", "title": "Pattern Holes From Datum", "priority": 56, "triggers": ["hole pattern", "pattern holes from datum", "repeated holes", "symmetric holes", "孔阵列", "重复孔", "基准孔阵列", "对称孔"], "exclude": [], "bridge": "bridge/atomic-pattern-holes.md", "source": "source/atomic/pattern-holes-from-datum.atomic.md", "capability_translation_rules": ["expanded: explicit_circle_layout", "exact: pattern_linear_or_pattern_mirror"], "related": []}, - {"id": "atomic/revolve-profile-around-axis", "kind": "atomic", "title": "Revolve Profile Around Axis", "priority": 55, "triggers": ["revolve profile", "revolve around axis", "revolved cut", "axisymmetric profile", "截面回转", "绕轴回转", "回转切除"], "exclude": [], "bridge": "bridge/atomic-revolve-profile.md", "source": "source/atomic/revolve-profile-around-axis.atomic.md", "capability_translation_rules": ["exact: revolve_add_or_revolve_cut"], "related": []}, - {"id": "atomic/threaded-hole-creation", "kind": "atomic", "title": "Threaded Hole", "priority": 54, "triggers": ["threaded hole", "tapped hole", "internal thread", "m3", "m4", "m5", "m6", "螺纹孔", "攻丝孔", "内螺纹"], "exclude": [], "bridge": "bridge/atomic-threaded-hole.md", "source": "source/atomic/threaded-hole-creation.atomic.md", "capability_translation_rules": ["approximated: thread_geometry_omitted"], "related": []}, - {"id": "atomic/through-hole-cut", "kind": "atomic", "title": "Through Hole Cut", "priority": 53, "triggers": ["through hole", "clearance hole", "central hole", "circular opening", "cut through", "bore through", "通孔", "间隙孔", "贯穿孔", "切穿"], "exclude": [], "bridge": "bridge/atomic-through-hole.md", "source": "source/atomic/through-hole-cut.atomic.md", "capability_translation_rules": ["exact: through_hole_or_explicit_cut"], "related": []} + {"id": "atomic/coaxial-bore-rule", "kind": "atomic", "title": "Coaxial Bore", "priority": 60, "triggers": ["coaxial bore", "central bore", "concentric", "shaft clearance", "同轴孔", "中心孔", "同心", "轴间隙"], "exclude": [], "bridge": "bridge/atomic-coaxial-bore.md", "source": "source/atomic/coaxial-bore-rule.atomic.md", "related": []}, + {"id": "atomic/counterbored-hole-creation", "kind": "atomic", "title": "Counterbored Hole", "priority": 59, "triggers": ["counterbored hole", "counterbore", "socket head screw", "cap screw", "recessed screw head", "沉孔", "内六角螺钉", "螺钉头沉孔"], "exclude": [], "bridge": "bridge/atomic-counterbore.md", "source": "source/atomic/counterbored-hole-creation.atomic.md", "related": []}, + {"id": "atomic/extrude-base-profile", "kind": "atomic", "title": "Extrude Base Profile", "priority": 58, "triggers": ["extrude base profile", "extrude rectangle", "extrude circle", "base extrusion", "simple cylinder", "rectangular block", "基础拉伸", "矩形拉伸", "圆柱拉伸"], "exclude": [], "bridge": "bridge/atomic-extrude-base.md", "source": "source/atomic/extrude-base-profile.atomic.md", "related": []}, + {"id": "atomic/fillet-chamfer-last", "kind": "atomic", "title": "Fillet And Chamfer Last", "priority": 57, "triggers": ["fillet chamfer last", "edge treatment", "chamfered edges", "filleted edges", "最后倒角", "最后圆角", "边缘处理"], "exclude": [], "bridge": "bridge/atomic-fillet-chamfer-last.md", "source": "source/atomic/fillet-chamfer-last.atomic.md", "related": []}, + {"id": "atomic/pattern-holes-from-datum", "kind": "atomic", "title": "Pattern Holes From Datum", "priority": 56, "triggers": ["hole pattern", "pattern holes from datum", "repeated holes", "symmetric holes", "孔阵列", "重复孔", "基准孔阵列", "对称孔"], "exclude": [], "bridge": "bridge/atomic-pattern-holes.md", "source": "source/atomic/pattern-holes-from-datum.atomic.md", "related": []}, + {"id": "atomic/revolve-profile-around-axis", "kind": "atomic", "title": "Revolve Profile Around Axis", "priority": 55, "triggers": ["revolve profile", "revolve around axis", "revolved cut", "axisymmetric profile", "截面回转", "绕轴回转", "回转切除"], "exclude": [], "bridge": "bridge/atomic-revolve-profile.md", "source": "source/atomic/revolve-profile-around-axis.atomic.md", "related": []}, + {"id": "atomic/threaded-hole-creation", "kind": "atomic", "title": "Threaded Hole", "priority": 54, "triggers": ["threaded hole", "tapped hole", "internal thread", "m3", "m4", "m5", "m6", "螺纹孔", "攻丝孔", "内螺纹"], "exclude": [], "bridge": "bridge/atomic-threaded-hole.md", "source": "source/atomic/threaded-hole-creation.atomic.md", "related": []}, + {"id": "atomic/through-hole-cut", "kind": "atomic", "title": "Through Hole Cut", "priority": 53, "triggers": ["through hole", "clearance hole", "central hole", "circular opening", "cut through", "bore through", "通孔", "间隙孔", "贯穿孔", "切穿"], "exclude": [], "bridge": "bridge/atomic-through-hole.md", "source": "source/atomic/through-hole-cut.atomic.md", "related": []} ] } diff --git a/backend/app/main.py b/backend/app/main.py index 09aebdc5..abf6a9ad 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,17 +1,22 @@ from __future__ import annotations +import asyncio import json +import secrets from typing import Any -from fastapi import FastAPI, File, Form, HTTPException, UploadFile +from fastapi import FastAPI, File, HTTPException, UploadFile from fastapi.responses import JSONResponse, StreamingResponse from app.models.contracts import ChatRequest, ConversationPatch, ModifyRequest, ParameterUpdate -from app.services.engine_service import apply_parameter_updates, build_revision +from app.services.engine_service import QualityVerificationError, apply_parameter_updates, build_revision from app.services.agent_service import AgentService from app.services.library import CdslLibrary -from app.services.storage import WorkspaceStore, safe_conversation_id, safe_task_id +from app.services.storage import WorkspaceStore, safe_conversation_id, safe_task_id, write_json from app.services.attachments import attachment_record, classify_upload, extract_document_text +from app.services.image_processing import image_metadata +from app.services.review_renderer import ReviewRenderError, render_checkpoint, renderer_status +from app.services.visual_review import VisualReviewError, review_checkpoint from app.settings import get_settings @@ -22,6 +27,99 @@ agent = AgentService(settings, store, library) app = FastAPI(title="CDSL CAD Agent API", version="0.1.0") +@app.on_event("startup") +async def resume_incremental_generation() -> None: + """Restore durable generation tasks after a backend process restart.""" + await agent.resume_running_tasks() + + +async def _finalize_controlled_revision( + *, + task_id: str, + previous_revision_id: str, + node_id: str, + built: dict[str, Any], +) -> dict[str, Any]: + """Publish a deterministic post-completion edit only after vision review.""" + revision_id = str(built["revision_id"]) + try: + generation_spec = store.read_generation_spec(task_id) or {} + requirements = generation_spec.get("requirements") if isinstance(generation_spec.get("requirements"), list) else [] + render_dir = store.revision_dir(task_id, revision_id) / "review" + manifest = await asyncio.to_thread( + render_checkpoint, + settings, + step_path=store.artifact_path(task_id, str(built["step_path"])), + output_dir=render_dir, + ) + review = await review_checkpoint( + settings, + manifest=manifest, + requirements=requirements, + node_id=node_id, + deterministic_report={ + "quality_status": built.get("quality_status"), + "verification": built.get("verification_summary", {}), + }, + final_checkpoint=True, + ) + manifest_path = (render_dir / "render-manifest.json").relative_to(store.task_dir(task_id)).as_posix() + review_path = (render_dir / "visual-review.json").relative_to(store.task_dir(task_id)).as_posix() + write_json(render_dir / "visual-review.json", review) + store.update_revision_metadata(task_id, revision_id, { + "render_manifest_path": manifest_path, + "visual_review_path": review_path, + }) + if review["verdict"] == "repair" and float(review["confidence"]) >= 0.85: + store.rollback_to_revision(task_id, previous_revision_id, branch_id=f"branch_{secrets.token_hex(4)}") + store.finish_generation(task_id, lifecycle="failed", failure={ + "schema_version": "cad.generation-failure.v1", + "node_id": node_id, + "stage": "visual_review", + "error_code": "HIGH_CONFIDENCE_VISUAL_REPAIR", + "message": "; ".join(review.get("evidence") or ["Visual review rejected the controlled edit"]), + "recommended_rollback_revision": previous_revision_id, + }) + raise ValueError("Visual review rejected this edit; the model was rolled back to its previous revision") + store.finish_generation(task_id, lifecycle="completed") + return {**built, "visibility": "final", "lifecycle": "completed", "checkpoint": False} + except (ReviewRenderError, VisualReviewError, ValueError): + task = store.read_task(task_id) or {} + if str(task.get("lifecycle") or "") == "running": + store.rollback_to_revision(task_id, previous_revision_id, branch_id=f"branch_{secrets.token_hex(4)}") + store.finish_generation(task_id, lifecycle="failed", failure={ + "schema_version": "cad.generation-failure.v1", + "node_id": node_id, + "stage": "visual_review", + "message": "Controlled edit could not complete its required review", + "recommended_rollback_revision": previous_revision_id, + }) + raise + + +def _require_controlled_review_configuration() -> None: + """Fail before a post-completion edit creates an unreviewed checkpoint.""" + settings.resolve_review_model() + ready, detail = renderer_status() + if not ready: + raise ValueError(detail) + + +def _fail_controlled_run(task_id: str, previous_revision_id: str, node_id: str, error: Exception) -> None: + task = store.read_task(task_id) or {} + if str(task.get("lifecycle") or "") != "running": + return + store.rollback_to_revision(task_id, previous_revision_id, branch_id=f"branch_{secrets.token_hex(4)}") + store.finish_generation(task_id, lifecycle="failed", failure={ + "schema_version": "cad.generation-failure.v1", + "node_id": node_id, + "stage": "controlled_build", + "error_code": type(error).__name__.upper(), + "message": str(error), + "recommended_rollback_revision": previous_revision_id, + }) + + @app.get("/health") async def health() -> dict[str, Any]: return { @@ -50,6 +148,12 @@ async def config() -> dict[str, Any]: for model in provider.models ], }) + try: + settings.resolve_review_model() + renderer_ready, renderer_detail = renderer_status() + review_error = "" if renderer_ready else renderer_detail + except ValueError as error: + review_error = str(error) return { "default_provider": settings.default_provider_id, "default_model": settings.llm_model, @@ -57,6 +161,10 @@ async def config() -> dict[str, Any]: "model": settings.llm_model, "configured": settings.llm_configured, "library_samples": library.count(), + "max_repair_attempts": settings.max_repair_attempts, + "incremental_generation": settings.incremental_generation, + "review_configured": not review_error, + "review_error": review_error, } @@ -88,29 +196,38 @@ async def create_conversation() -> JSONResponse: @app.patch("/v1/conversations/{conversation_id}") async def patch_conversation(conversation_id: str, payload: ConversationPatch) -> JSONResponse: try: - record = store.ensure_conversation(safe_conversation_id(conversation_id), payload.current_task_id, payload.attachments) + record = store.ensure_conversation(safe_conversation_id(conversation_id), payload.current_task_id) except ValueError as error: raise HTTPException(status_code=400, detail=str(error)) from error return JSONResponse(record) -@app.post("/v1/uploads") -async def upload_attachment( +@app.post("/v1/conversations/{conversation_id}/attachments") +async def upload_conversation_attachment( + conversation_id: str, file: UploadFile = File(...), - task_id: str | None = Form(default=None), ) -> JSONResponse: data = await file.read() filename = file.filename or "attachment" try: + conversation = safe_conversation_id(conversation_id) + current = store.read_conversation(conversation) + if current is None: + raise HTTPException(status_code=404, detail="Conversation not found") + active_task_id = str(current.get("current_task_id") or "") + active_task = store.read_task(active_task_id) if active_task_id else None + if str((active_task or {}).get("lifecycle") or "") == "running": + raise HTTPException(status_code=409, detail="CAD task is running; attachments are locked until it reaches a terminal state") kind = classify_upload(filename, file.content_type or "", len(data)) - task = store.ensure_task(safe_task_id(task_id) if task_id else None, f"Attachment: {filename}") - relative_path, _ = store.write_upload(task["task_id"], filename, data) + relative_path, _ = store.write_conversation_upload(conversation, filename, data) extracted_path = "" if kind == "document": extracted_path = relative_path + ".txt" extracted = extract_document_text(data) - store.artifact_path(task["task_id"], extracted_path).write_text(extracted, encoding="utf-8") - record = attachment_record(task["task_id"], filename, file.content_type or "", relative_path, data, kind, extracted_path) + store.conversation_attachment_path(conversation, extracted_path).write_text(extracted, encoding="utf-8") + metadata = image_metadata(data) if kind == "image" else {} + record = attachment_record(conversation, filename, file.content_type or "", relative_path, data, kind, extracted_path, metadata) + store.add_conversation_attachment(conversation, record) return JSONResponse(record) except ValueError as error: raise HTTPException(status_code=400, detail=str(error)) from error @@ -124,33 +241,14 @@ async def read_task(task_id: str) -> JSONResponse: raise HTTPException(status_code=400, detail=str(error)) from error if task is None: raise HTTPException(status_code=404, detail="Task not found") + # Keep the task endpoint self-contained for a reconnecting UI. The plan is + # immutable within a run and exposes node status, while previews always use + # the active working revision rather than a downloadable artifact. + task["preview_revision"] = str(task.get("active_revision") or task.get("current_revision") or "") + task["generation_plan"] = store.read_generation_spec(task["task_id"]) return JSONResponse(task) -@app.get("/v1/tasks/{task_id}/design-intent") -async def read_current_design_intent(task_id: str) -> JSONResponse: - try: - safe_id = safe_task_id(task_id) - result = store.read_design_intent(safe_id) - except ValueError as error: - raise HTTPException(status_code=400, detail=str(error)) from error - if result is None: - raise HTTPException(status_code=404, detail="No DesignIntent exists for this task") - return JSONResponse({"task_id": safe_id, "intent_id": result["record"]["intent_id"], **result}) - - -@app.get("/v1/tasks/{task_id}/design-intents/{intent_id}") -async def read_design_intent(task_id: str, intent_id: str) -> JSONResponse: - try: - safe_id = safe_task_id(task_id) - result = store.read_design_intent(safe_id, intent_id) - except ValueError as error: - raise HTTPException(status_code=400, detail=str(error)) from error - if result is None: - raise HTTPException(status_code=404, detail="DesignIntent not found") - return JSONResponse({"task_id": safe_id, "intent_id": result["record"]["intent_id"], **result}) - - @app.get("/v1/tasks/{task_id}/artifacts/{artifact_path:path}") async def read_artifact(task_id: str, artifact_path: str) -> StreamingResponse: from fastapi.responses import FileResponse @@ -162,6 +260,19 @@ async def read_artifact(task_id: str, artifact_path: str) -> StreamingResponse: raise HTTPException(status_code=400, detail=str(error)) from error if not path.is_file(): raise HTTPException(status_code=404, detail="Artifact not found") + task = store.read_task(safe_id) or {} + parts = artifact_path.split("/") + revision_id = parts[1] if len(parts) >= 3 and parts[0] == "revisions" else "" + revision = next((item for item in task.get("revisions") or () if isinstance(item, dict) and item.get("revision_id") == revision_id), None) + published_revision = str(task.get("published_revision") or "") + active_revision = str(task.get("active_revision") or task.get("current_revision") or "") + if isinstance(revision, dict) and revision_id != published_revision: + # Revisions are private until publication. The currently active + # checkpoint exposes only its GLB inline for the review viewer; an old + # or superseded checkpoint has no public artifact surface at all. + if revision_id != active_revision or path.name != "model.glb": + raise HTTPException(status_code=403, detail="Only the published revision is downloadable") + return FileResponse(path, media_type="model/gltf-binary", headers={"Content-Disposition": "inline"}) return FileResponse(path, filename=path.name) @@ -172,6 +283,8 @@ async def read_parameters(task_id: str) -> JSONResponse: except ValueError as error: raise HTTPException(status_code=400, detail=str(error)) from error task = store.read_task(safe_id) + if str((task or {}).get("published_revision") or "") != str((task or {}).get("current_revision") or ""): + raise HTTPException(status_code=403, detail="Checkpoint parameters are not available until publication") revision_id = str((task or {}).get("current_revision") or "") revision = next((item for item in (task or {}).get("revisions", []) if item.get("revision_id") == revision_id), None) relative = str((revision or {}).get("parameters_path") or "") @@ -183,20 +296,55 @@ async def read_parameters(task_id: str) -> JSONResponse: return JSONResponse({"task_id": safe_id, "revision_id": revision_id, **json.loads(path.read_text(encoding="utf-8"))}) +@app.get("/v1/tasks/{task_id}/quality") +async def read_quality(task_id: str) -> JSONResponse: + try: + safe_id = safe_task_id(task_id) + except ValueError as error: + raise HTTPException(status_code=400, detail=str(error)) from error + task = store.read_task(safe_id) + if str((task or {}).get("published_revision") or "") != str((task or {}).get("current_revision") or ""): + raise HTTPException(status_code=403, detail="Checkpoint reports are not available until publication") + revision_id = str((task or {}).get("current_revision") or "") + revisions = (task or {}).get("revisions", []) + revision = next((item for item in revisions if item.get("revision_id") == revision_id), None) + if revisions and revisions[-1].get("revision_id") != revision_id: + revision = revisions[-1] + revision_id = str(revision.get("revision_id") or "") + payload: dict[str, Any] = { + "task_id": safe_id, + "revision_id": revision_id, + "quality_status": (revision or {}).get("quality_status", ""), + "snapshot_status": (revision or {}).get("snapshot_status", "unavailable"), + "snapshot_paths": (revision or {}).get("snapshot_paths", []), + "assumptions": (revision or {}).get("generation_assumptions", []), + "verification_summary": (revision or {}).get("verification_summary", {}), + } + relative = str((revision or {}).get("quality_path") or "") + if relative: + path = store.artifact_path(safe_id, relative) + if path.is_file(): + payload["quality"] = json.loads(path.read_text(encoding="utf-8")) + if not revision: + raise HTTPException(status_code=404, detail="Task has no revision") + return JSONResponse(payload) + + @app.post("/v1/tasks/{task_id}/parameters") async def update_parameters(task_id: str, payload: ParameterUpdate) -> JSONResponse: try: safe_id = safe_task_id(task_id) task = store.read_task(safe_id) + if str((task or {}).get("lifecycle") or "") == "running": + raise ValueError("CAD task is running; parameter changes are locked") current_revision_id = str((task or {}).get("current_revision") or "") - current_revision = next( - (item for item in (task or {}).get("revisions", []) if item.get("revision_id") == current_revision_id), - {}, - ) current_path = store.current_cdsl_path(safe_id) if not task or not current_path or not current_revision_id: raise ValueError("Task has no successful CDSL revision") updated, _ = apply_parameter_updates(json.loads(current_path.read_text(encoding="utf-8")), payload.values) + if settings.incremental_generation: + _require_controlled_review_configuration() + store.start_generation(safe_id, request=f"Parameter update: {', '.join(payload.values)}") result = build_revision( settings=settings, store=store, @@ -209,12 +357,21 @@ async def update_parameters(task_id: str, payload: ParameterUpdate) -> JSONRespo operation={"type": "parameter_update", "values": payload.values}, part_skills=None, generation_assumptions=[], - design_intent_id=str(current_revision.get("design_intent_id") or ""), - design_intent_path=str(current_revision.get("design_intent_path") or ""), - design_intent_status="accepted" if current_revision.get("design_intent_id") else "", + node_id="parameter_update" if settings.incremental_generation else "", + branch_id=f"branch_{secrets.token_hex(4)}" if settings.incremental_generation else "main", + visibility="checkpoint" if settings.incremental_generation else "final", ) + if settings.incremental_generation: + result = await _finalize_controlled_revision( + task_id=safe_id, + previous_revision_id=current_revision_id, + node_id="parameter_update", + built=result, + ) return JSONResponse(result) - except ValueError as error: + except (ValueError, QualityVerificationError, ReviewRenderError, VisualReviewError, RuntimeError) as error: + if settings.incremental_generation and "safe_id" in locals() and "current_revision_id" in locals(): + _fail_controlled_run(safe_id, current_revision_id, "parameter_update", error) raise HTTPException(status_code=400, detail=str(error)) from error @@ -223,7 +380,29 @@ async def modify_task(task_id: str, payload: ModifyRequest) -> JSONResponse: from app.services.editing import apply_direct_edit try: - result = apply_direct_edit(settings, store, safe_task_id(task_id), payload.operation, payload.selection, payload.parameters) + safe_id = safe_task_id(task_id) + task = store.read_task(safe_id) or {} + if str(task.get("lifecycle") or "") == "running": + raise ValueError("CAD task is running; topology edits are locked") + previous_revision_id = str(task.get("current_revision") or "") + if settings.incremental_generation: + _require_controlled_review_configuration() + store.start_generation(safe_id, request=f"Direct CDSL edit: {payload.operation}") + result = apply_direct_edit( + settings, store, safe_id, payload.operation, payload.selection, payload.parameters, + node_id="topology_edit" if settings.incremental_generation else "", + branch_id=f"branch_{secrets.token_hex(4)}" if settings.incremental_generation else "main", + visibility="checkpoint" if settings.incremental_generation else "final", + ) + if settings.incremental_generation: + result = await _finalize_controlled_revision( + task_id=safe_id, + previous_revision_id=previous_revision_id, + node_id="topology_edit", + built=result, + ) return JSONResponse(result) - except ValueError as error: + except (ValueError, QualityVerificationError, ReviewRenderError, VisualReviewError, RuntimeError) as error: + if settings.incremental_generation and "safe_id" in locals() and "previous_revision_id" in locals(): + _fail_controlled_run(safe_id, previous_revision_id, "topology_edit", error) raise HTTPException(status_code=400, detail=str(error)) from error diff --git a/backend/app/models/contracts.py b/backend/app/models/contracts.py index 95470e67..71c4e261 100644 --- a/backend/app/models/contracts.py +++ b/backend/app/models/contracts.py @@ -28,7 +28,6 @@ class ChatRequest(BaseModel): class ConversationPatch(BaseModel): current_task_id: str | None = None - attachments: list[dict[str, Any]] | None = None class ParameterUpdate(BaseModel): @@ -57,8 +56,14 @@ class CadResult(BaseModel): parameters_path: str | None = None selector_path: str | None = None edges_path: str | None = None - design_intent_id: str | None = None - design_intent_path: str | None = None + topology_path: str | None = None summary: str reference_ids: list[str] = Field(default_factory=list) engine: str = "cdsl_only" + quality_status: str = "" + quality_path: str | None = None + assumptions: list[str] = Field(default_factory=list) + warnings: list[str] = Field(default_factory=list) + repair_attempts: int = 0 + snapshot_paths: list[str] = Field(default_factory=list) + snapshot_status: str = "unavailable" diff --git a/backend/app/services/agent_service.py b/backend/app/services/agent_service.py index 7d92db6b..d90515e6 100644 --- a/backend/app/services/agent_service.py +++ b/backend/app/services/agent_service.py @@ -5,7 +5,9 @@ import base64 from copy import deepcopy import json import math +import re import secrets +import sys from collections.abc import AsyncIterator from pathlib import Path from typing import Any @@ -13,11 +15,22 @@ from typing import Any import httpx from app.models.contracts import ChatMessage -from app.services.engine_service import build_revision, load_engine, validate_cdsl +from app.services.engine_service import build_revision, load_engine, normalize_cdsl_for_engine, validate_cdsl +from app.services.cdsl_patch import CdslPatchError, apply_cdsl_patch +from app.services.feature_plan import FeaturePlanError, compute_node_statuses, validate_feature_plan +from app.services.incremental_generation import IncrementalGenerationRunner from app.services.library import CdslLibrary from app.services.part_skills import PartSkillLibrary +from app.services.quality import FEATURE_RULE_TYPES, QUALITY_RULE_TYPES, validate_verification from app.services.sse import event from app.services.storage import WorkspaceStore, now_iso +from app.services.image_observation import ( + merge_image_observations, + normalize_image_observation, + normalize_sketch_candidates, + render_image_observation_context, +) +from app.services.image_processing import cv_hints from app.settings import ProviderConfig, ProviderModel, Settings @@ -37,6 +50,31 @@ class RepeatedToolArgumentsError(RuntimeError): self.diagnostic_paths = diagnostic_paths or [] +class CdslRepairLimitError(RuntimeError): + """The direct CDSL repair budget is exhausted for this request.""" + + def __init__(self, diagnostic_paths: list[str]) -> None: + super().__init__("CDSL repair limit reached") + self.diagnostic_paths = diagnostic_paths + + +def get_repair_step_key(planning_state: dict[str, Any], tool_name: str) -> str: + """Identify one semantic generation step without carrying failures across batches.""" + plan = planning_state.get("feature_plan") if isinstance(planning_state, dict) else None + if isinstance(plan, dict): + active_nodes = sorted( + str(node.get("id")) + for node in plan.get("nodes") or () + if isinstance(node, dict) + and node.get("status") in {"ready", "executing"} + ) + if active_nodes: + return f"plan:{str(plan.get('plan_id') or '')}:{','.join(active_nodes)}" + # ``generate_cdsl_model`` and ``patch_cdsl_model`` are both attempts at + # the same repair phase when no feature plan is available. + return f"phase:{str(planning_state.get('phase') or '')}" + + def user_visible_error_message(error: Exception, user_text: str) -> str: if isinstance(error, StrictToolSchemaError) and any( "\u4e00" <= char <= "\u9fff" for char in str(user_text or "") @@ -56,6 +94,11 @@ def user_visible_error_message(error: Exception, user_text: str) -> str: "请检查所选模型的函数调用兼容性;若仍出现此错误,请关闭该模型的严格工具 schema 开关后再试。" + diagnostics ) + if isinstance(error, CdslRepairLimitError): + diagnostics = "、".join(error.diagnostic_paths) + if any("\u4e00" <= char <= "\u9fff" for char in str(user_text or "")): + return "同一 CDSL 步骤连续重试达到四次上限,未创建新的成功 revision。每次 CDSL 校验失败的诊断已保存到:" + diagnostics + "。" + return "The same CDSL step failed four consecutive times. Diagnostics were saved to: " + diagnostics + "." return str(error) @@ -79,7 +122,7 @@ def _repair_premature_tool_wrapper_close(source: str, parsed_value: Any, parsed_ return None if candidate[candidate_end:].strip() or not isinstance(value, dict): return None - if not set(value).issubset({"cdsl", "summary", "assumptions"}): + if not set(value).issubset({"cdsl", "summary", "assumptions", "verification"}): return None if not isinstance(value.get("cdsl"), dict) or not isinstance(value.get("summary"), str): return None @@ -93,8 +136,44 @@ def _repair_premature_tool_wrapper_close(source: str, parsed_value: Any, parsed_ return value +def _recover_trailing_cdsl_metadata( + source: str, + parsed_value: Any, + parsed_end: int, +) -> dict[str, Any] | None: + """Accept a complete CDSL envelope followed only by duplicate metadata.""" + required = {"cdsl", "summary", "assumptions"} + allowed = required | {"verification"} + if ( + not isinstance(parsed_value, dict) + or not required.issubset(parsed_value) + or not set(parsed_value).issubset(allowed) + ): + return None + + # Some providers continue after a complete root object with a second copy + # of its presentation metadata. Decode that suffix as its own object; + # never use a regex to parse nested JSON. The CDSL payload itself may not + # reappear, so the executable model always comes from the first object. + suffix = source[parsed_end:] + if not suffix.startswith(","): + return None + try: + duplicate, duplicate_end = json.JSONDecoder().raw_decode("{" + suffix[1:]) + except json.JSONDecodeError: + return None + if ( + duplicate_end != len(suffix) + or not isinstance(duplicate, dict) + or not duplicate + or not set(duplicate).issubset({"summary", "assumptions", "verification"}) + ): + return None + return parsed_value + + def parse_tool_arguments(raw_arguments: Any, *, recover_cdsl_wrapper: bool = False) -> dict[str, Any]: - """Decode one function-call argument object, with one guarded CDSL repair.""" + """Decode one function-call argument object, with guarded CDSL repairs.""" if raw_arguments is None or raw_arguments == "": return {} if not isinstance(raw_arguments, str): @@ -112,6 +191,9 @@ def parse_tool_arguments(raw_arguments: Any, *, recover_cdsl_wrapper: bool = Fal repaired = _repair_premature_tool_wrapper_close(source, value, parsed_end) if repaired is not None: return repaired + repaired = _recover_trailing_cdsl_metadata(source, value, parsed_end) + if repaired is not None: + return repaired raise ToolArgumentsError("arguments contain trailing content after the JSON object") if not isinstance(value, dict): raise ToolArgumentsError("arguments must decode to a JSON object") @@ -124,16 +206,73 @@ def invalid_tool_arguments_result(name: str, error: ToolArgumentsError) -> dict[ "code": "INVALID_TOOL_ARGUMENTS", "message": ( f"{name} arguments were rejected: {error}. " - "Call the same tool again with exactly one valid JSON object. " - "Do not append prose, Markdown fences, or another JSON value." + "Regenerate the same tool call with exactly one complete JSON object, " + "from its first `{` through its final `}`. Do not repeat any fields " + "or append prose, Markdown fences, or another JSON value." ), } -def invalid_cdsl_result(error: ValueError) -> dict[str, Any]: +def _cdsl_error_details(error: Exception) -> dict[str, Any]: + message = str(error) + known_codes = ( + "FEATURE_PLAN_INVALID", "FEATURE_PLAN_CYCLE", "TOPOLOGY_REQUIRED", "TOPOLOGY_NOT_AVAILABLE", + "TOPOLOGY_SNAPSHOT_STALE", "SELECTOR_CONTEXT_REQUIRED", "SELECTOR_NOT_FOUND", + "SELECTOR_AMBIGUOUS", "SELECTOR_GEOMETRY_MISMATCH", "FEATURE_NOT_READY", "COMPLETED_FEATURE_MUTATION", + "INVALID_CDSL_PATCH", + ) + explicit_code = next((code for code in known_codes if code in message), "") + if explicit_code: + if explicit_code == "INVALID_CDSL_PATCH": + return { + "code": explicit_code, + "path": "$", + "kind": "patch", + "repair_instruction": "Read the current CDSL and apply a path that exists in base_revision_id; use generate_cdsl_model for structural changes.", + } + if explicit_code == "FEATURE_NOT_READY": + return { + "code": explicit_code, + "path": "$.features", + "kind": "feature_plan", + "repair_instruction": "Keep completed features unchanged and generate only the listed allowed_feature_ids from the current ready plan batch.", + } + return { + "code": explicit_code, + "path": "$", + "kind": "topology" if "SELECTOR" in explicit_code or "TOPOLOGY" in explicit_code else "feature_plan", + "repair_instruction": "Use the current topology snapshot and a valid ready feature batch, then retry.", + } + path_match = re.search(r"(?:at|path) (\$[^: ]*)", message) + quality = getattr(error, "quality_report", None) + if isinstance(quality, dict): + return { + "code": "VERIFICATION_FAILED", + "path": "$.verification.rules", + "kind": "verification", + "repair_instruction": "Correct the CDSL feature geometry or the verification rule, then submit a local patch or complete replacement.", + "quality": quality, + } + if "schema violation" in message: + return { + "code": "CDSL_SCHEMA_INVALID", + "path": path_match.group(1) if path_match else "$", + "kind": "schema", + "repair_instruction": "Correct the field at the reported JSONPath using the authoritative CDSL schema.", + } + return { + "code": "CDSL_RUNTIME_INVALID", + "path": path_match.group(1) if path_match else "$", + "kind": "runtime", + "repair_instruction": "Correct the invalid CDSL structure, dependency, selector, or runtime parameter and retry.", + } + + +def invalid_cdsl_result(error: Exception) -> dict[str, Any]: + details = _cdsl_error_details(error) return { "ok": False, - "code": "INVALID_CDSL", + **details, "message": ( f"The submitted CDSL is incomplete or invalid: {error}. " "Read the authoritative local engine schema, then call generate_cdsl_model " @@ -142,25 +281,12 @@ def invalid_cdsl_result(error: ValueError) -> dict[str, Any]: } -def invalid_design_intent_result(error: Exception) -> dict[str, Any]: - code = str(getattr(error, "code", "INVALID_DESIGN_INTENT")) - return { - "ok": False, - "code": code, - "message": f"The DesignIntent plan was rejected: {error}. Correct the complete plan before generating CDSL.", - } - - def user_visible_tool_message(result: dict[str, Any], user_text: str) -> str: code = str(result.get("code") or "") - if code == "INVALID_CDSL": + if code in {"CDSL_SCHEMA_INVALID", "CDSL_RUNTIME_INVALID", "VERIFICATION_FAILED", "FEATURE_NOT_READY", "INVALID_CDSL_PATCH"}: if any("\u4e00" <= char <= "\u9fff" for char in str(user_text or "")): return "CDSL 不符合 engine 的模型契约,正在请求模型按 schema 修正后重新生成。" return "The CDSL model does not match the engine contract. Asking the model to correct it and retry." - if code in {"INVALID_DESIGN_INTENT", "INTENT_CDSL_MISMATCH", "DESIGN_INTENT_REQUIRED", "DESIGN_INTENT_BLOCKED"}: - if any("\u4e00" <= char <= "\u9fff" for char in str(user_text or "")): - return "设计意图尚未通过校验,未进入 CAD 构建。" - return "The design intent has not passed validation, so CAD construction has not started." return str(result.get("message") or result.get("summary") or "") @@ -193,31 +319,233 @@ def _cdsl_tool_schema() -> dict[str, Any]: schema_name = str(contract.get("cdsl_json_schema_file") or "") if not schema_name or Path(schema_name).name != schema_name: raise RuntimeError("Local engine contract has no valid CDSL JSON Schema path") - return json.loads((engine_dir / schema_name).read_text(encoding="utf-8")) + schema = json.loads((engine_dir / schema_name).read_text(encoding="utf-8")) + supported_atomics = { + str(item) + for item in contract.get("runtime_supported_atomic_ids") or () + if str(item) + } + declared_atomics = set((contract.get("feature_atomic_ids") or {}).keys()) + if not supported_atomics or not supported_atomics.issubset(declared_atomics): + raise RuntimeError("Engine capability contract has invalid runtime atomic ids") + engine_parent = str(engine_dir.parent) + if engine_parent not in sys.path: + sys.path.insert(0, engine_parent) + import cdsl_engine + + registered_atomics = {str(item) for item in getattr(cdsl_engine, "SUPPORTED_ATOMIC_IDS", ())} + supported_atomics &= registered_atomics + if not supported_atomics: + raise RuntimeError("Engine has no registered atomic executors in common with its capability contract") + feature_atomic_definition = schema.get("$defs", {}).get("feature_atomic_ids") + if not isinstance(feature_atomic_definition, dict): + raise RuntimeError("Local CDSL JSON Schema has no feature atomic definition") + feature_atomic_definition["enum"] = sorted(supported_atomics) + profile_definition = schema.get("$defs", {}).get("profile_type") + supported_profiles = { + str(item) + for item in contract.get("runtime_supported_profiles") or () + if str(item) + } + supported_profiles &= {str(item) for item in getattr(cdsl_engine, "SHAPE_GENERATORS", ())} + if isinstance(profile_definition, dict) and supported_profiles: + profile_definition["enum"] = sorted(supported_profiles) + return schema except (OSError, json.JSONDecodeError, AttributeError) as error: raise RuntimeError("Local CDSL JSON Schema is unavailable or invalid") from error CDSL_TOOL_SCHEMA = _cdsl_tool_schema() +GENERATION_TOOL_NAMES = {"generate_cdsl_model", "patch_cdsl_model"} +MAX_AGENT_TOOL_ITERATIONS = 16 + +VERIFICATION_SCHEMA: dict[str, Any] = { + "type": "object", + "properties": { + "rules": { + "type": "array", + "maxItems": 32, + "items": { + "type": "object", + "properties": { + "id": {"type": "string", "minLength": 1}, + "type": {"enum": sorted(QUALITY_RULE_TYPES)}, + "feature": {"type": "string", "minLength": 1}, + "expected": { + "description": ( + "For bbox, use [dx, dy, dz], " + "{min: [x, y, z], max: [x, y, z]}, or " + "{x_min, x_max, y_min, y_max, z_min, z_max}." + ), + }, + "tolerance": {"type": "number", "minimum": 0}, + "severity": {"enum": ["blocking", "warning", "informational"]}, + }, + "required": ["id", "type", "expected"], + "allOf": [ + { + "if": {"properties": {"type": {"enum": sorted(FEATURE_RULE_TYPES)}}}, + "then": {"required": ["feature"]}, + }, + ], + "additionalProperties": False, + }, + }, + }, + "required": ["rules"], + "additionalProperties": False, +} + +JSON_PATCH_OPERATION_SCHEMA: dict[str, Any] = { + "type": "object", + "properties": { + "op": {"enum": ["add", "remove", "replace", "move", "copy", "test"]}, + "path": {"type": "string", "pattern": "^/"}, + "from": {"type": "string", "pattern": "^/"}, + "value": {}, + }, + "required": ["op", "path"], + "additionalProperties": False, +} -def _design_intent_tool_schema() -> dict[str, Any]: - engine_dir = Path(__file__).resolve().parents[2] / "engine" / "cdsl_engine" +def engine_capability_manifest(settings: Settings) -> dict[str, Any]: + """Build the compact planner-facing capability contract from engine files.""" + load_engine(settings) try: - schema = json.loads((engine_dir / "design_intent_schema.json").read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError) as error: - raise RuntimeError("Local DesignIntent JSON Schema is unavailable or invalid") from error - # The model cannot forge storage/audit fields. They are added only after - # backend validation by WorkspaceStore.create_design_intent(). - for field in ("intent_id", "created_at", "part_skill_ids", "part_skill_selection"): - schema.get("properties", {}).pop(field, None) - return schema + profile = json.loads((settings.engine_root / "profile_schema.json").read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return {"supported_profiles": [], "runtime_atomic_ids": [], "required_params": {}, "unsupported_profiles": []} + atomic_contracts = profile.get("feature_atomic_ids") if isinstance(profile.get("feature_atomic_ids"), dict) else {} + declared_atomics = {str(item) for item in profile.get("runtime_supported_atomic_ids") or atomic_contracts} + registered_atomics = {str(item) for item in getattr(load_engine(settings), "SUPPORTED_ATOMIC_IDS", ())} + supported_atomics = sorted(declared_atomics & registered_atomics) + declared_profiles = {str(item) for item in profile.get("runtime_supported_profiles") or ()} + registered_profiles = {str(item) for item in getattr(load_engine(settings), "SHAPE_GENERATORS", ())} + return { + "supported_profiles": sorted(declared_profiles & registered_profiles), + "runtime_atomic_ids": supported_atomics, + "required_params": {str(key): list(value.get("required_params") or []) for key, value in atomic_contracts.items() if isinstance(value, dict)}, + "unsupported_profiles": sorted(str(item) for item in profile.get("unsupported_profiles") or []), + "verification_rule_types": sorted(QUALITY_RULE_TYPES), + } -DESIGN_INTENT_TOOL_SCHEMA = _design_intent_tool_schema() +IMAGE_SEGMENT_SCHEMA: dict[str, Any] = { + "type": "object", + "properties": { + "type": {"enum": ["line", "arc", "circle", "polyline", "unknown_curve"]}, + "start": {"type": "array", "items": {"type": "number"}, "minItems": 2, "maxItems": 2}, + "end": {"type": "array", "items": {"type": "number"}, "minItems": 2, "maxItems": 2}, + "center": {"type": "array", "items": {"type": "number"}, "minItems": 2, "maxItems": 2}, + "radius_mm": {"type": "number", "exclusiveMinimum": 0}, + "clockwise": {"type": "boolean"}, + "points": {"type": "array", "items": {"type": "array", "items": {"type": "number"}, "minItems": 2, "maxItems": 2}, "maxItems": 256}, + "image_start": {"type": "array", "items": {"type": "number"}, "minItems": 2, "maxItems": 2}, + "image_end": {"type": "array", "items": {"type": "number"}, "minItems": 2, "maxItems": 2}, + "confidence": {"type": "number", "minimum": 0, "maximum": 1}, + "notes": {"type": "string", "maxLength": 300}, + }, + "required": ["type"], + "additionalProperties": False, +} +IMAGE_PROFILE_SCHEMA: dict[str, Any] = { + "type": "object", + "properties": { + "id": {"type": "string", "minLength": 1, "maxLength": 80}, + "role": {"type": "string", "maxLength": 40}, + "plane_hint": {"type": "string", "maxLength": 120}, + "closed": {"type": "boolean"}, + "coordinate_space": {"type": "string", "maxLength": 40}, + "segments": {"type": "array", "maxItems": 256, "items": IMAGE_SEGMENT_SCHEMA}, + "source_images": {"type": "array", "maxItems": 12, "items": {"type": "string"}}, + "confidence": {"type": "number", "minimum": 0, "maximum": 1}, + "uncertain": {"type": "array", "maxItems": 16, "items": {"type": "string", "maxLength": 300}}, + "notes": {"type": "string", "maxLength": 300}, + }, + "required": ["id", "segments"], + "additionalProperties": False, +} + +IMAGE_MEASUREMENT_SCHEMA: dict[str, Any] = { + "type": "object", + "properties": { + "name": {"type": "string", "minLength": 1, "maxLength": 120}, + "value_mm": {"type": "number"}, + "min_mm": {"type": "number"}, + "max_mm": {"type": "number"}, + "source": {"enum": ["user", "image", "cv", "assumption"]}, + "confidence": {"type": "number", "minimum": 0, "maximum": 1}, + "evidence": {"type": "string", "maxLength": 300}, + "source_images": {"type": "array", "maxItems": 12, "items": {"type": "string"}}, + }, + "required": ["name"], + "additionalProperties": False, +} + +IMAGE_OBSERVATION_PROPERTIES: dict[str, Any] = { + "attachment_ids": {"type": "array", "maxItems": 12, "items": {"type": "string"}}, + "part_type": {"type": "string", "minLength": 1, "maxLength": 300}, + "visible_features": {"type": "array", "maxItems": 32, "items": {"type": "string", "maxLength": 300}}, + "uncertain_features": {"type": "array", "maxItems": 64, "items": {"type": "string", "maxLength": 300}}, + "views": {"type": "array", "maxItems": 12, "items": {"type": "object", "properties": { + "attachment_id": {"type": "string"}, "view_role": {"type": "string"}, "orientation": {"type": "string"}, + "visible_regions": {"type": "array", "items": {"type": "string"}}, "occluded_regions": {"type": "array", "items": {"type": "string"}}, + "quality": {"type": "string"}, "scale_reference_id": {"type": "string"}, "confidence": {"type": "number", "minimum": 0, "maximum": 1}, + }, "required": ["attachment_id"], "additionalProperties": False}}, + "scale_references": {"type": "array", "maxItems": 12, "items": {"type": "object"}}, + "overall_geometry": {"type": "object"}, + "surfaces": {"type": "array", "maxItems": 24, "items": {"type": "object"}}, + "profiles": {"type": "array", "maxItems": 32, "items": IMAGE_PROFILE_SCHEMA}, + "holes": {"type": "array", "maxItems": 64, "items": {"type": "object"}}, + "bends": {"type": "array", "maxItems": 16, "items": {"type": "object"}}, + "measurements": {"type": "array", "maxItems": 128, "items": IMAGE_MEASUREMENT_SCHEMA}, + "uncertainties": {"type": "array", "maxItems": 64, "items": {"type": "string", "maxLength": 300}}, + "assumptions": {"type": "array", "maxItems": 64, "items": {"type": "string", "maxLength": 300}}, + "cv_hints": {"type": "array", "maxItems": 32, "items": {"type": "object"}}, +} TOOL_SCHEMAS: list[dict[str, Any]] = [ + { + "type": "function", + "function": { + "name": "analyze_image_reference", + "description": ( + "Perform a complete multi-view CAD image survey. Identify every visible plane, bend, " + "outer profile, hole, slot, irregular cutout, scale reference, estimated measurement, " + "occlusion, and uncertainty. Preserve geometry evidence; do not omit an uncertain profile." + ), + "parameters": { + "type": "object", + "properties": IMAGE_OBSERVATION_PROPERTIES, + "required": ["part_type", "visible_features", "uncertain_features", "views", "profiles", "measurements", "uncertainties"], + "additionalProperties": False, + }, + }, + }, + { + "type": "function", + "function": { + "name": "extract_image_sketch_candidates", + "description": "Convert the complete image survey into candidate 2D sketch profiles for outer faces and irregular openings. Keep polyline or unknown curves when line/arc decomposition is uncertain.", + "parameters": { + "type": "object", + "properties": { + "part_type": {"type": "string", "maxLength": 300}, + "profiles": {"type": "array", "maxItems": 32, "items": IMAGE_PROFILE_SCHEMA}, + "measurements": {"type": "array", "maxItems": 128, "items": IMAGE_MEASUREMENT_SCHEMA}, + "visible_features": {"type": "array", "maxItems": 32, "items": {"type": "string", "maxLength": 300}}, + "uncertain_features": {"type": "array", "maxItems": 64, "items": {"type": "string", "maxLength": 300}}, + "uncertainties": {"type": "array", "maxItems": 64, "items": {"type": "string", "maxLength": 300}}, + "assumptions": {"type": "array", "maxItems": 64, "items": {"type": "string", "maxLength": 300}}, + "cv_hints": {"type": "array", "maxItems": 32, "items": {"type": "object"}}, + }, + "required": ["profiles", "measurements", "uncertainties"], + "additionalProperties": False, + }, + }, + }, { "type": "function", "function": { @@ -242,16 +570,15 @@ TOOL_SCHEMAS: list[dict[str, Any]] = [ { "type": "function", "function": { - "name": "propose_design_intent", - "description": "Submit a complete semantic DesignIntent plan before searching CDSL references or generating CDSL.", + "name": "describe_design_intent", + "description": "Record a concise natural-language CAD plan as reference for this turn's CDSL generation. This plan is not a CAD contract; CDSL remains the only authoritative model.", "parameters": { "type": "object", "properties": { - "intent": DESIGN_INTENT_TOOL_SCHEMA, - "summary": {"type": "string", "minLength": 1}, + "plan": {"type": "string", "minLength": 1}, "assumptions": {"type": "array", "items": {"type": "string"}}, }, - "required": ["intent", "summary", "assumptions"], + "required": ["plan", "assumptions"], "additionalProperties": False, }, }, @@ -272,12 +599,102 @@ TOOL_SCHEMAS: list[dict[str, Any]] = [ "parameters": { "type": "object", "properties": { - "design_intent_id": {"type": "string", "pattern": "^intent_[a-z0-9]{12}$"}, "cdsl": CDSL_TOOL_SCHEMA, "summary": {"type": "string", "minLength": 1}, "assumptions": {"type": "array", "items": {"type": "string"}}, + "verification": VERIFICATION_SCHEMA, + }, + "required": ["cdsl", "summary", "assumptions"], + "additionalProperties": False, + }, + }, + }, + { + "type": "function", + "function": { + "name": "patch_cdsl_model", + "description": "Apply RFC 6902 JSON Patch operations to one existing CDSL revision, validate and rebuild it as a new revision.", + "parameters": { + "type": "object", + "properties": { + "base_revision_id": {"type": "string", "minLength": 1}, + "patches": {"type": "array", "minItems": 1, "maxItems": 32, "items": JSON_PATCH_OPERATION_SCHEMA}, + "summary": {"type": "string", "minLength": 1}, + "assumptions": {"type": "array", "items": {"type": "string"}}, + "verification": VERIFICATION_SCHEMA, + }, + "required": ["base_revision_id", "patches", "summary", "assumptions"], + "additionalProperties": False, + }, + }, + }, +] + +PLANNING_TOOL_SCHEMAS: list[dict[str, Any]] = [ + { + "type": "function", + "function": { + "name": "plan_feature_tree", + "description": "Validate and store an acyclic semantic feature plan. This is planning data, not executable CDSL; never invent selectors.", + "parameters": { + "type": "object", + "properties": { + "plan_id": {"type": "string", "minLength": 1}, + "nodes": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "properties": { + "id": {"type": "string", "minLength": 1}, + "intent": {"type": "string"}, + "atomic_id": {"type": "string", "minLength": 1}, + "depends_on": {"type": "array", "items": {"type": "string"}}, + "requires_topology": {"type": "boolean"}, + "topology_query": {"type": "object"}, + "cdsl_feature_ids": {"type": "array", "items": {"type": "string"}}, + }, + "required": ["id", "atomic_id", "depends_on"], + "additionalProperties": False, + }, + }, + "replan": { + "type": "object", + "properties": { + "replace_nodes": {"type": "array", "items": {"type": "string"}, "minItems": 1}, + "reason": {"type": "string", "minLength": 1}, + "alternatives": {"type": "array", "items": {"type": "string"}}, + }, + "required": ["replace_nodes", "reason", "alternatives"], + "additionalProperties": False, + }, + }, + "required": ["plan_id", "nodes"], + "additionalProperties": False, + }, + }, + }, + { + "type": "function", + "function": { + "name": "inspect_current_topology", + "description": "Query real executable face/edge/vertex records from the current successful revision. Returned selectors may be copied into CDSL.", + "parameters": { + "type": "object", + "properties": { + "kind": {"enum": ["face", "edge", "vertex", "body", "plane", "axis"]}, + "feature_id": {"type": "string"}, + "owner_feature_id": {"type": "string"}, + "surface_type": {"type": "string"}, + "curve_type": {"type": "string"}, + "position_hint": {"type": "string"}, + "position": {"type": "string"}, + "bbox_mm": {"type": "array", "items": {"type": "number"}, "minItems": 6, "maxItems": 6}, + "length_range_mm": {"type": "array", "items": {"type": "number"}, "minItems": 2, "maxItems": 2}, + "area_range_mm2": {"type": "array", "items": {"type": "number"}, "minItems": 2, "maxItems": 2}, + "limit": {"type": "integer", "minimum": 1, "maximum": 50}, + "cursor": {"type": "integer", "minimum": 0}, }, - "required": ["design_intent_id", "cdsl", "summary", "assumptions"], "additionalProperties": False, }, }, @@ -285,14 +702,35 @@ TOOL_SCHEMAS: list[dict[str, Any]] = [ ] -def tools_for_model(model: ProviderModel) -> list[dict[str, Any]]: +def tools_for_model( + model: ProviderModel, + *, + include_image_analysis: bool = True, + include_image_sketches: bool = False, + image_stage: str | None = None, +) -> list[dict[str, Any]]: """Return this model's tool contract without mutating the shared schema.""" - tools = deepcopy(TOOL_SCHEMAS) + tools = deepcopy(TOOL_SCHEMAS + PLANNING_TOOL_SCHEMAS) + if not include_image_analysis: + tools = [ + tool + for tool in tools + if tool.get("function", {}).get("name") != "analyze_image_reference" + ] + if not include_image_sketches: + tools = [ + tool + for tool in tools + if tool.get("function", {}).get("name") != "extract_image_sketch_candidates" + ] + if image_stage in {"survey", "sketch"}: + required_name = "analyze_image_reference" if image_stage == "survey" else "extract_image_sketch_candidates" + tools = [tool for tool in tools if tool.get("function", {}).get("name") == required_name] if not model.strict_tool_schema: return tools for tool in tools: - if tool.get("function", {}).get("name") in {"propose_design_intent", "generate_cdsl_model"}: + if tool.get("function", {}).get("name") in GENERATION_TOOL_NAMES: # This flag constrains function arguments only. It has no effect on # normal assistant text, the user's prompt, or the summary. tool["function"]["strict"] = True @@ -312,6 +750,239 @@ def messages_for_model(messages: list[ChatMessage]) -> list[dict[str, Any]]: return result +def image_attachments(conversation: dict[str, Any]) -> list[dict[str, Any]]: + return [ + attachment + for attachment in conversation.get("attachments") or [] + if isinstance(attachment, dict) and attachment.get("kind") == "image" and attachment.get("id") + ] + + +def revision_input_attachments(conversation: dict[str, Any]) -> list[dict[str, str | int]]: + """Snapshot conversation-owned inputs without duplicating their files into a task.""" + conversation_id = str(conversation.get("conversation_id") or "") + snapshots: list[dict[str, str | int]] = [] + for attachment in conversation.get("attachments") or []: + if not isinstance(attachment, dict) or str(attachment.get("conversation_id") or "") != conversation_id: + continue + attachment_id = str(attachment.get("id") or "") + if not attachment_id: + continue + snapshots.append({ + "attachment_id": attachment_id, + "conversation_id": conversation_id, + "name": str(attachment.get("name") or ""), + "kind": str(attachment.get("kind") or ""), + "mime": str(attachment.get("mime") or ""), + "size": int(attachment.get("size") or 0), + "sha256": str(attachment.get("sha256") or ""), + "width": int(attachment.get("width") or 0), + "height": int(attachment.get("height") or 0), + }) + return snapshots + + +def cad_request_instruction(task_id: str, current_task: dict[str, Any] | None) -> str: + revision_id = str((current_task or {}).get("current_revision") or "") + if revision_id: + return f""" +CAD request state: +- Mode: revision. +- Target task: {task_id}; current successful revision: {revision_id}. +- Call read_current_cdsl first, then preserve unrelated features in the complete replacement CDSL. +""" + prior_attempt = f" Task {task_id} has no successful revision and is only a failed/incomplete build attempt." if task_id else "" + return f""" +CAD request state: +- Mode: create. +- There is no current successful CDSL revision.{prior_attempt} +- Do not call read_current_cdsl. Generate a new model after the normal planning workflow. +""" + + +def image_reference_analysis(conversation: dict[str, Any]) -> dict[str, Any] | None: + """Return the latest complete survey for every currently attached image.""" + attachment_ids = {str(attachment["id"]) for attachment in image_attachments(conversation)} + if not attachment_ids: + return None + for message in reversed(conversation.get("messages") or []): + if not isinstance(message, dict): + continue + for part in reversed(message.get("parts") or []): + if not isinstance(part, dict) or part.get("type") != "data-cad-image-analysis": + continue + data = part.get("data") + if not isinstance(data, dict): + continue + analyzed_ids = {str(value) for value in data.get("attachmentIds") or [] if str(value)} + if attachment_ids.issubset(analyzed_ids) and data.get("observationStage", "complete") == "complete": + return data + return None + + +def image_reference_stage(conversation: dict[str, Any]) -> str: + """Return the next image-intake stage while retaining legacy analyses.""" + attachment_ids = {str(attachment["id"]) for attachment in image_attachments(conversation)} + if not attachment_ids: + return "complete" + for message in reversed(conversation.get("messages") or []): + if not isinstance(message, dict): + continue + for part in reversed(message.get("parts") or []): + if not isinstance(part, dict) or part.get("type") != "data-cad-image-analysis": + continue + data = part.get("data") + if not isinstance(data, dict): + continue + analyzed_ids = {str(value) for value in data.get("attachmentIds") or [] if str(value)} + if not attachment_ids.issubset(analyzed_ids): + continue + stage = str(data.get("observationStage") or "complete") + if stage == "survey": + return "sketch" + if stage in {"sketch", "complete"}: + return "complete" + return "survey" + + +def image_reference_observation_part(conversation: dict[str, Any], stage: str) -> dict[str, Any] | None: + attachment_ids = {str(attachment["id"]) for attachment in image_attachments(conversation)} + for message in reversed(conversation.get("messages") or []): + if not isinstance(message, dict): + continue + for part in reversed(message.get("parts") or []): + if not isinstance(part, dict) or part.get("type") != "data-cad-image-analysis": + continue + data = part.get("data") + if not isinstance(data, dict): + continue + analyzed_ids = {str(value) for value in data.get("attachmentIds") or [] if str(value)} + if attachment_ids.issubset(analyzed_ids) and str(data.get("observationStage") or "complete") == stage: + return data + return None + + +def image_reference_instruction( + attachments: list[dict[str, Any]], + analysis: dict[str, Any] | None, + stage: str = "complete", +) -> str: + if not attachments: + return "" + if stage == "survey": + return """ +Image-reference intake gate (highest priority for this turn): +- The user has uploaded image references that have not yet been analyzed. +- Your only tool call in this turn must be analyze_image_reference. +- Do not call describe_design_intent, search_cdsl_library, read_cdsl_reference, + read_current_cdsl, or generate_cdsl_model in this turn. +- Build a complete structured multi-view survey: views, surfaces, bends, outer profiles, + holes, irregular openings, scale references, measurements, CV hints, and uncertainties. +- Preserve uncertain profiles as polyline or unknown_curve evidence instead of dropping them. +- Describe only what is visible. Do not present image estimates as user-verified dimensions. +- The backend will present the structured result and provide it back as + visual-reference context for you to continue this same task. +""" + if stage == "sketch": + return """ +Image survey is recorded for the current attachments. Your only tool call in this turn +must be extract_image_sketch_candidates. Use the survey and all attached images to +produce outer-face and irregular-opening profiles. Prefer line, arc, and circle segments; +retain polyline or unknown_curve segments when the image does not justify a primitive. +Include source image ids, coordinate evidence, confidence, measurements, and unresolved +parameters. Do not call CAD planning or generation tools yet. +""" + return """ +Image-reference analysis already recorded for the current attachments: +{data} +Use this complete survey and the sketch candidates as visual-reference context. +Do not silently discard visible profiles or openings. User-provided dimensions override +image, CV, or assumption estimates; preserve uncertain values as assumptions. +Interpret the full conversation to decide whether to ask a concise question, +make clearly stated approximate assumptions, or continue the ordinary CDSL +workflow. When the user permits or requests estimates, choose coherent values +yourself and record them as assumptions instead of asking again. Respect the +user's tolerance for estimates. Never present an inferred dimension as an exact +measurement from the image. +""".format(data=render_image_observation_context(analysis)) + + +def normalize_image_analysis(arguments: dict[str, Any]) -> dict[str, Any]: + def text(value: Any, name: str, limit: int = 300) -> str: + normalized = str(value or "").strip() + if not normalized: + raise ValueError(f"analyze_image_reference requires a non-empty {name}") + return normalized[:limit] + + def text_list(value: Any, name: str, maximum: int) -> list[str]: + if not isinstance(value, list) or not value: + raise ValueError(f"analyze_image_reference requires a non-empty {name} array") + return [text(item, name, 240) for item in value[:maximum]] + + raw_dimensions = arguments.get("dimension_candidates") + if raw_dimensions is None: + raw_dimensions = [] + if not isinstance(raw_dimensions, list): + raise ValueError("analyze_image_reference dimension_candidates must be an array") + dimensions: list[dict[str, str]] = [] + used_ids: set[str] = set() + for item in raw_dimensions[:12]: + if not isinstance(item, dict): + raise ValueError("analyze_image_reference dimension_candidates must contain objects") + dimension_id = text(item.get("id"), "dimension_candidates.id", 80) + if dimension_id in used_ids: + continue + used_ids.add(dimension_id) + dimensions.append({ + "id": dimension_id, + "label": text(item.get("label"), "dimension_candidates.label", 160), + "reason": text(item.get("reason"), "dimension_candidates.reason", 240), + }) + uncertain = arguments.get("uncertain_features") + if not isinstance(uncertain, list): + raise ValueError("analyze_image_reference requires an uncertain_features array") + return { + "part_type": text(arguments.get("part_type"), "part_type"), + "visible_features": text_list(arguments.get("visible_features"), "visible_features", 12), + "uncertain_features": [text(item, "uncertain_features", 240) for item in uncertain[:8]], + "dimension_candidates": dimensions, + } + + +def image_observation_payload(observation: dict[str, Any], *, stage: str, artifact_path: str = "") -> dict[str, Any]: + """Map the persisted snake_case observation to the existing UI data part.""" + dimensions = [ + { + "id": str(item.get("name") or f"measurement_{index}"), + "label": str(item.get("name") or "尺寸"), + "reason": str(item.get("evidence") or "图片或模型估算"), + } + for index, item in enumerate(observation.get("measurements") or []) + if isinstance(item, dict) + ] + return { + "observationStage": stage, + "schemaVersion": observation.get("schema_version", "cad.image-observation.v2"), + "attachmentIds": observation.get("attachment_ids") or [], + "partType": observation.get("part_type") or "", + "visibleFeatures": observation.get("visible_features") or [], + "uncertainFeatures": observation.get("uncertain_features") or [], + "dimensionCandidates": dimensions, + "views": observation.get("views") or [], + "scaleReferences": observation.get("scale_references") or [], + "overallGeometry": observation.get("overall_geometry") or {}, + "surfaces": observation.get("surfaces") or [], + "profiles": observation.get("profiles") or [], + "holes": observation.get("holes") or [], + "bends": observation.get("bends") or [], + "measurements": observation.get("measurements") or [], + "uncertainties": observation.get("uncertainties") or [], + "assumptions": observation.get("assumptions") or [], + "cvHints": observation.get("cv_hints") or [], + "artifactPath": artifact_path or None, + } + + def _viewer_selection_text(value: Any, limit: int = 240) -> str: return str(value or "").strip()[:limit] @@ -343,6 +1014,7 @@ def _viewer_selection_entity(value: Any) -> dict[str, Any] | None: return { "referenceId": reference_id, "selector": _viewer_selection_text(value.get("selector")), + "snapshotId": _viewer_selection_text(value.get("snapshotId"), 160), "label": _viewer_selection_text(value.get("label")), "selectorType": _viewer_selection_text(value.get("selectorType"), 80), "surfaceType": _viewer_selection_text(value.get("surfaceType"), 80), @@ -394,22 +1066,320 @@ Use this data to answer questions about the selected geometry. In particular, us """.format(data=json.dumps(selections, ensure_ascii=False, separators=(",", ":"))) +def _selector_values(value: Any) -> list[dict[str, Any]]: + found: list[dict[str, Any]] = [] + if isinstance(value, dict): + if value.get("kind") and value.get("stable_id") and value.get("source"): + found.append(value) + for child in value.values(): + found.extend(_selector_values(child)) + elif isinstance(value, list): + for child in value: + found.extend(_selector_values(child)) + return found + + +def _numeric_range(value: Any, field: str) -> tuple[float, float] | None: + if value is None: + return None + if not isinstance(value, list) or len(value) != 2: + raise ValueError(f"{field} must contain exactly two numbers") + try: + left, right = float(value[0]), float(value[1]) + except (TypeError, ValueError) as error: + raise ValueError(f"{field} must contain numbers") from error + if left > right: + raise ValueError(f"{field} minimum must not exceed maximum") + return left, right + + +def _validate_snapshot_selectors(store: Any, task_id: str, cdsl: dict[str, Any]) -> None: + """Validate runtime/viewer selectors against their own task revision snapshot. + + A completed feature keeps the selector provenance from the revision in which + it was created. Requiring every selector in a later CDSL revision to point + at the newest snapshot incorrectly rejects those immutable historical + selectors before a new feature can be appended. + """ + if not task_id: + return + task = store.read_task(task_id) or {} + current_revision = str(task.get("current_revision") or "") + if not current_revision: + return + topology_path = store.current_topology_path(task_id) + current_snapshot = json.loads(topology_path.read_text(encoding="utf-8")) if topology_path and topology_path.is_file() else None + current_snapshot_id = str((current_snapshot or {}).get("snapshot_id") or f"{task_id}/{current_revision}") + snapshots: dict[str, dict[str, Any]] = {current_snapshot_id: current_snapshot or {}} + + def snapshot_for_selector(snapshot_id: str) -> dict[str, Any] | None: + if snapshot_id in snapshots: + return snapshots[snapshot_id] + prefix = f"{task_id}/" + if not snapshot_id.startswith(prefix): + return None + revision_id = snapshot_id[len(prefix):] + if not revision_id: + return None + revision = next( + (item for item in task.get("revisions") or () + if isinstance(item, dict) and str(item.get("revision_id") or "") == revision_id), + None, + ) + if not isinstance(revision, dict) or revision.get("status") != "success": + return None + historical_path = store.revision_topology_path(task_id, revision_id) + if historical_path is None or not historical_path.is_file(): + return None + try: + historical = json.loads(historical_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return None + snapshots[snapshot_id] = historical + return historical + + for selector in _selector_values(cdsl): + source = str(selector.get("source") or "") + if source not in {"runtime_snapshot", "viewer_selection"}: + continue + snapshot_id = str(selector.get("snapshot_id") or "") + snapshot = snapshot_for_selector(snapshot_id) + if snapshot is None: + raise ValueError("TOPOLOGY_SNAPSHOT_STALE: selector does not belong to a known task revision") + records = {str(item.get("record_id")): item for item in snapshot.get("records") or () if isinstance(item, dict)} + record = records.get(str(selector.get("stable_id") or "")) + if not record or record.get("executable") is False: + raise ValueError("SELECTOR_NOT_FOUND: selector record is not present in the referenced topology snapshot") + if str(selector.get("kind")) != str(record.get("kind")): + raise ValueError("SELECTOR_GEOMETRY_MISMATCH: selector kind differs from topology record") + owner = selector.get("owner_feature_id") + owners = {str(item) for item in record.get("owner_feature_ids") or ()} + if owner and str(owner) not in owners: + raise ValueError("SELECTOR_GEOMETRY_MISMATCH: selector owner differs from topology record") + expected = selector.get("geometry") if isinstance(selector.get("geometry"), dict) else {} + actual = record.get("geometry") if isinstance(record.get("geometry"), dict) else {} + for key in ("curve_type", "surface_type"): + if key in expected and expected.get(key) != actual.get(key): + raise ValueError(f"SELECTOR_GEOMETRY_MISMATCH: selector {key} differs from topology record") + for key in ("center_mm", "normal", "plane_normal", "start_mm", "end_mm"): + if key not in expected: + continue + left, right = expected.get(key), actual.get(key) + if not isinstance(left, (list, tuple)) or not isinstance(right, (list, tuple)) or len(left) != len(right): + raise ValueError(f"SELECTOR_GEOMETRY_MISMATCH: selector {key} differs from topology record") + try: + mismatch = any(abs(float(a) - float(b)) > 1e-5 for a, b in zip(left, right)) + except (TypeError, ValueError) as error: + raise ValueError(f"SELECTOR_GEOMETRY_MISMATCH: selector {key} is not numeric") from error + if mismatch: + raise ValueError(f"SELECTOR_GEOMETRY_MISMATCH: selector {key} differs from topology record") + if "bbox_mm" in expected: + left, right = expected.get("bbox_mm"), actual.get("bbox_mm") + if not isinstance(left, (list, tuple)) or not isinstance(right, (list, tuple)) or len(left) != len(right): + raise ValueError("SELECTOR_GEOMETRY_MISMATCH: selector bbox_mm differs from topology record") + try: + mismatch = any(abs(float(a) - float(b)) > 1e-5 for a, b in zip(left, right)) + except (TypeError, ValueError) as error: + raise ValueError("SELECTOR_GEOMETRY_MISMATCH: selector bbox_mm is not numeric") from error + if mismatch: + raise ValueError("SELECTOR_GEOMETRY_MISMATCH: selector bbox_mm differs from topology record") + for key in ("length_mm", "area_mm2"): + if key in expected and key in actual: + try: + expected_value = float(expected[key]) + actual_value = float(actual[key]) + except (TypeError, ValueError) as error: + raise ValueError(f"SELECTOR_GEOMETRY_MISMATCH: selector {key} is not numeric") from error + if abs(expected_value - actual_value) > max(1e-5, abs(actual_value) * 1e-5): + raise ValueError(f"SELECTOR_GEOMETRY_MISMATCH: selector {key} differs from topology record") + + +def _topology_query_result(store: Any, task_id: str, arguments: dict[str, Any]) -> dict[str, Any]: + if not task_id: + return {"ok": False, "code": "TOPOLOGY_NOT_AVAILABLE", "message": "No current CAD task exists."} + task = store.read_task(task_id) or {} + revision_id = str(task.get("current_revision") or "") + path = store.current_topology_path(task_id) + if not revision_id or path is None or not path.is_file(): + return {"ok": False, "code": "TOPOLOGY_NOT_AVAILABLE", "message": "The current task has no successful topology snapshot."} + snapshot = json.loads(path.read_text(encoding="utf-8")) + records = [record for record in snapshot.get("records") or () if isinstance(record, dict) and record.get("executable", True) is not False] + kind = str(arguments.get("kind") or "") + if kind: + records = [record for record in records if record.get("kind") == kind] + for key in ("feature_id", "owner_feature_id"): + value = str(arguments.get(key) or "") + if value: + records = [record for record in records if record.get("feature_id") == value or value in (record.get("owner_feature_ids") or [])] + for key in ("surface_type", "curve_type"): + value = str(arguments.get(key) or "") + if value: + records = [record for record in records if (record.get("geometry") or {}).get(key) == value] + position_hint = str(arguments.get("position_hint") or arguments.get("position") or "").strip().casefold() + if position_hint: + position_axis = {"top": 2, "upper": 2, "highest": 2, "bottom": 2, "lower": 2, "lowest": 2, + "left": 0, "right": 0, "front": 1, "back": 1}.get(position_hint) + position_values: list[float] = [] + if position_axis is not None: + for candidate in records: + candidate_center = (candidate.get("geometry") or {}).get("center_mm") + if isinstance(candidate_center, (list, tuple)) and len(candidate_center) > position_axis: + try: + position_values.append(float(candidate_center[position_axis])) + except (TypeError, ValueError): + pass + target = None + if position_values and position_axis is not None: + target = min(position_values) if position_hint in {"bottom", "lower", "lowest", "left", "front"} else max(position_values) + def matches_position(record: dict[str, Any]) -> bool: + geometry = record.get("geometry") or {} + center = geometry.get("center_mm") + bbox = geometry.get("bbox_mm") + if not isinstance(center, (list, tuple)) or len(center) < 3: + return False + try: + x, y, z = (float(center[index]) for index in range(3)) + except (TypeError, ValueError): + return False + if position_hint in {"top", "upper", "highest", "bottom", "lower", "lowest"}: + return target is not None and abs(z - target) <= 1e-5 + if position_hint in {"left", "right", "front", "back"}: + axis = {"left": 0, "right": 0, "front": 1, "back": 1}[position_hint] + sign = {"left": -1, "right": 1, "front": 1, "back": -1}[position_hint] + value = x if axis == 0 else y + return target is not None and abs(value - target) <= 1e-5 + return str(geometry.get("position_hint") or "").casefold() == position_hint + records = [record for record in records if matches_position(record)] + bbox_filter = arguments.get("bbox_mm") + if bbox_filter is not None: + if isinstance(bbox_filter, dict) and isinstance(bbox_filter.get("min"), list) and isinstance(bbox_filter.get("max"), list): + bbox_filter = [*bbox_filter["min"], *bbox_filter["max"]] + if not isinstance(bbox_filter, list) or len(bbox_filter) != 6: + raise ValueError("bbox_mm must contain exactly six numbers") + try: + query_bbox = tuple(float(value) for value in bbox_filter) + except (TypeError, ValueError) as error: + raise ValueError("bbox_mm must contain numbers") from error + if query_bbox[0] > query_bbox[3] or query_bbox[1] > query_bbox[4] or query_bbox[2] > query_bbox[5]: + raise ValueError("bbox_mm minimums must not exceed maximums") + def intersects(record: dict[str, Any]) -> bool: + actual = (record.get("geometry") or {}).get("bbox_mm") + if not isinstance(actual, (list, tuple)) or len(actual) != 6: + return False + try: + values = tuple(float(value) for value in actual) + except (TypeError, ValueError): + return False + return all(values[index] <= query_bbox[index + 3] and query_bbox[index] <= values[index + 3] for index in range(3)) + records = [record for record in records if intersects(record)] + length_range = _numeric_range(arguments.get("length_range_mm"), "length_range_mm") + area_range = _numeric_range(arguments.get("area_range_mm2"), "area_range_mm2") + if length_range: + records = [record for record in records if length_range[0] <= float((record.get("geometry") or {}).get("length_mm", -1)) <= length_range[1]] + if area_range: + records = [record for record in records if area_range[0] <= float((record.get("geometry") or {}).get("area_mm2", -1)) <= area_range[1]] + offset = max(0, int(arguments.get("cursor") or 0)) + limit = min(50, max(1, int(arguments.get("limit") or 20))) + selected = records[offset:offset + limit] + output_records = [] + for record in selected: + selector = { + "kind": record["kind"], + "stable_id": record["record_id"], + "source": "runtime_snapshot", + "confidence": 1.0, + "snapshot_id": snapshot.get("snapshot_id") or f"{task_id}/{revision_id}", + "geometry": record.get("geometry") or {}, + } + owners = record.get("owner_feature_ids") or [] + if owners: + selector["owner_feature_id"] = owners[0] + output_records.append({**record, "selector": selector}) + return { + "ok": True, + "task_id": task_id, + "revision_id": revision_id, + "snapshot_id": snapshot.get("snapshot_id") or f"{task_id}/{revision_id}", + "records": output_records, + "next_cursor": offset + len(output_records) if offset + len(output_records) < len(records) else None, + } + + +def _validate_plan_cdsl_transition(store: Any, task_id: str, plan: dict[str, Any] | None, cdsl: dict[str, Any]) -> None: + if not isinstance(plan, dict): + return + current_path = store.current_cdsl_path(task_id) if task_id else None + current = json.loads(current_path.read_text(encoding="utf-8")) if current_path and current_path.is_file() else {} + current_features = {str(item.get("id")): item for item in current.get("features") or () if isinstance(item, dict)} + next_features = {str(item.get("id")): item for item in cdsl.get("features") or () if isinstance(item, dict)} + completed_ids = { + str(feature_id) + for node in plan.get("nodes") or () + if isinstance(node, dict) and node.get("status") in {"completed", "executed"} + for feature_id in node.get("cdsl_feature_ids") or () + } + for feature_id in completed_ids: + if feature_id not in next_features or next_features[feature_id] != current_features.get(feature_id): + raise ValueError(f"COMPLETED_FEATURE_MUTATION: completed feature {feature_id} cannot be removed or rewritten") + allowed_ids = { + str(feature_id) + for node in plan.get("nodes") or () + if isinstance(node, dict) and node.get("status") in {"ready", "executing"} + for feature_id in node.get("cdsl_feature_ids") or () + } + for feature_id in next_features: + if feature_id not in current_features and feature_id not in allowed_ids: + ready_nodes = [ + str(node.get("id")) + for node in plan.get("nodes") or () + if isinstance(node, dict) and node.get("status") == "ready" + ] + allowed = sorted(allowed_ids) + raise ValueError( + f"FEATURE_NOT_READY: feature {feature_id} is not in the current ready plan batch; " + f"allowed_feature_ids={allowed}; ready_nodes={ready_nodes}" + ) + + def system_prompt( settings: Settings, user_text: str, viewer_context: list[dict[str, Any]] | None = None, task_id: str = "", part_skill_context: str = "", + image_reference_context: str = "", + cad_request_context: str = "", ) -> str: + capability_manifest = json.dumps(engine_capability_manifest(settings), ensure_ascii=False, separators=(",", ":")) skill_path = settings.engine_root.parent.parent / "agent" / "skills" / "cad-engine" / "SKILL.md" skill = skill_path.read_text(encoding="utf-8") if skill_path.is_file() else "" - planning_recipe_path = skill_path.with_name("planning-recipe.md") - planning_recipe = planning_recipe_path.read_text(encoding="utf-8") if planning_recipe_path.is_file() else "" - readme_path = settings.engine_root / "README.md" - engine_readme = readme_path.read_text(encoding="utf-8") if readme_path.is_file() else "" profile_schema_path = settings.engine_root / "profile_schema.json" profile_schema = profile_schema_path.read_text(encoding="utf-8") if profile_schema_path.is_file() else "" - supported_profiles = ", ".join(sorted(load_engine(settings).SHAPE_GENERATORS)) + generation_contract = """- For generate_cdsl_model, pass the complete CDSL as the cdsl object directly, not as Markdown or a JSON string. +- For a multi-stage CAD request, call plan_feature_tree after describe_design_intent. The plan is a DAG of semantic features; never put invented edge/face IDs in it. +- Generate only the current ready feature batch. The server automatically binds a successful build's topology snapshot; call inspect_current_topology to retrieve exact selector candidates before adding topology-dependent features. +- Copy selectors only from inspect_current_topology or a trusted viewer selection. A screenshot is not an exact topology selector source, but its measured image survey and sketch candidates may guide profile geometry. +- Keep completed CDSL features unchanged and use patch_cdsl_model for later feature batches. +- If a selector or kernel failure blocks a node, use plan_feature_tree with `replan` to replace only that node and its downstream subtree. +- For a revision, call read_current_cdsl before generate_cdsl_model. Preserve unrelated CDSL features unless the user requests whole-part replacement. +- Use patch_cdsl_model only for a local RFC 6902 repair of a known base_revision_id. For structural changes, submit a complete replacement CDSL with generate_cdsl_model. +- For every feature.atomic_id, use only an ID listed in the compact capability manifest's runtime_atomic_ids. Other semantic contracts are not executable in this runtime. +- The backend normalizes only these unambiguous aliases: sketch_id -> id on sketches, sketch -> sketch_id on features, legacy plane/offset_mm -> workplane, and axis.point_mm -> axis.origin_mm. +- Every explicit user dimension, feature count, hole size, or hole position that can be measured must have a generic verification rule. Rule feature values must be IDs from the submitted CDSL. +- Verification types include bbox, overall_length, overall_width, overall_height, overall_diameter, hole_count, hole_diameter, hole_center, through_condition, solid_count, and feature_count. Overall width and height measure the runtime Y and Z bbox dimensions. Feature-scoped rules (hole_count, hole_diameter, hole_center, through_condition) must include the exact CDSL feature ID. +- Do not output build123d source, compiler_context, unknown_shape, complex_arc_shape, entities, contour_edges_mm, or contour_regions_mm.""" + workflow = """3. Search the local official CDSL library after the design brief. Read a relevant reference when a match exists; samples are expression guidance, not templates or higher-priority requirements. +4. For a multi-feature request, call plan_feature_tree and then generate only its ready batch. +5. After each successful build, inspect topology when the plan has waiting topology-dependent nodes, then patch the existing CDSL. +6. On a schema, runtime, selector, or verification failure, repair only the affected feature/subtree. +7. Never claim success unless the final plan is complete and the tool returns a successful CDSL-only STEP and GLB artifact.""" + authoring_context = f"""You own the complete CDSL: profiles, workplanes, sketch IDs, feature IDs, dependencies, selectors, and atomic IDs must be valid under the engine schema. Do not invent unsupported capabilities. + +Local skill: +{skill} + +Authoritative engine schema: +{profile_schema}""" return f"""You are the CDSL CAD Agent for CDSL CAD Studio. Language policy: @@ -427,46 +1397,40 @@ Language policy: Tool call contract: - Every function call arguments field must contain exactly one valid JSON object. - Do not append prose, Markdown code fences, comments, or a second JSON value. -- For generate_cdsl_model, pass the complete CDSL as the cdsl object directly, - not as Markdown and not as a concatenated JSON string. -- Call propose_design_intent first. Its `intent` is the complete semantic - planning JSON, without sketch coordinates, raw CAD code, storage IDs, or - part-skill IDs. The backend chooses and persists part skills itself. -- Do not call search_cdsl_library, read_cdsl_reference, or - generate_cdsl_model until propose_design_intent returns an accepted - `intent_id`. Pass that exact ID to generate_cdsl_model. -- The generate_cdsl_model `cdsl` parameter is the complete machine-enforced - schema. Satisfy its nested object and array types exactly; do not substitute - a shorthand array for an object. For example, every hole position is - `{{"mm": [u_mm, v_mm, w_mm]}}`, never `[u_mm, v_mm]`. +- Call describe_design_intent first with a concise natural-language plan. - If a tool reports INVALID_TOOL_ARGUMENTS, correct the arguments and call that tool again. Do not claim that the CAD model was generated. -- If generate_cdsl_model reports INVALID_CDSL, correct the full CDSL object and - call it again. Do not submit a partial object or claim success. +- If a generation tool reports INVALID_CDSL, correct its plan or complete CDSL + as applicable and call that same tool again. Do not claim success. +{generation_contract} Workflow limits: - Do not expose internal planning or "let me" commentary to the user while using tools. The application shows tool progress separately. +- Keep final user-facing responses operational and concise. When a structured + CAD result has been produced, do not restate its name, files, revision, or + tool progress; reply only when an assumption, limitation, or next decision + needs the user's attention. Otherwise finish without a prose postscript. +- When clarification is essential, ask exactly one direct question that names + the missing dimension or decision. Do not combine it with a tool trace or a + generic progress update. - Use at most two CDSL-library searches per user request. If neither finds a - useful reference, stop searching and use the engine guide to either generate + useful reference, stop searching and use the available capability contract to either generate the model or ask one concise clarification question. - Do not repeatedly search for the same unavailable feature or profile. {response_language_instruction(user_text)} -You generate parameterized CDSL, never raw CAD source code. For new CAD requests: + {image_reference_context} + + {cad_request_context} + +You generate executable CAD through complete parameterized CDSL, never raw CAD source code. For new CAD requests: 1. Use the injected part-skill guidance, when present, only to establish the - structural plan, feature dependency order, and parameter roles. -2. Call propose_design_intent. A blocking question or capability gap must make - the plan `needs_clarification`; then ask one concise user-facing question - and do not call CDSL tools. -3. Only after an accepted ready intent, search the local official CDSL library. - Read at least one relevant reference when a match exists; samples provide - schema-valid expressions, not higher-priority part intent. -4. Generate a complete CDSL whose feature IDs, atomics, dependencies, profiles, - and selector evidence exactly realize the accepted DesignIntent, then call - generate_cdsl_model with its intent ID. -5. Never claim success unless the tool returns a successful CDSL-only STEP and GLB artifact. + structural plan, feature dependency order, parameter roles, and reference queries. +2. Call describe_design_intent with a concise textual plan. Ask one concise + user-facing question before CAD generation if essential dimensions are missing. +{workflow} Precedence is strict: explicit user request, then CDSL schema/runtime, then part-skill guidance, then CDSL-library examples. Part skills never authorize @@ -476,34 +1440,13 @@ unsupported requested structure, ask one concise clarification question or state the blocker rather than fabricating geometry. If a part-family conflict is injected, preserve the current part unless the user explicitly requests a whole-part replacement. A primary-family conflict is a hard clarification -stop: ask one concise question and do not call generate_cdsl_model until the +stop: ask one concise question and do not call a generation tool until the user resolves it. -For a revision, call read_current_cdsl first and preserve unrelated features, -then propose a revise DesignIntent with base_revision_id set to the current -successful revision. Do not search references or generate CDSL before that plan -is accepted. -Do not output compiler_context, unknown_shape, complex_arc_shape, entities, -contour_edges_mm, or contour_regions_mm. Use only self-contained named profiles -and feature atomic IDs defined in the engine schema below. Read the engine -schema before selecting an atomic ID, a profile, or their parameter names. Do -not invent an atomic ID, profile, or their fields. Ask a concise clarification question when essential -dimensions or intent are missing. Ordinary explanations must not create CAD. +{authoring_context} -Local skill: -{skill} - -DesignIntent planning recipe: -{planning_recipe} - -Local engine guide: -{engine_readme} - -Authoritative engine schema: -{profile_schema} - -Supported named profile types: -{supported_profiles} +Compact Engine Capability Manifest (planner-facing; backend validator remains authoritative): +{capability_manifest} Injected part-skill context: {part_skill_context or "No part-family skill guidance was selected for this request."} @@ -527,6 +1470,92 @@ class AgentService: self.store = store self.library = library self.part_skill_library = part_skill_library or PartSkillLibrary(part_skill_root(settings)) + # The generation worker outlives an individual SSE response. The + # durable task lifecycle remains the cross-process source of truth; + # this map only owns live event delivery in the current process. + self._incremental_runs: dict[str, asyncio.Task[None]] = {} + + async def resume_running_tasks(self) -> None: + """Reattach process-local workers to persisted incremental runs. + + A browser disconnect is already independent from the worker. This + recovery path additionally prevents an application restart from + stranding a durable task in ``running``. The original frozen provider, + model, messages, and selected part skills are read from the task, not + from mutable conversation state. + """ + if not self.settings.incremental_generation: + return + for task in self.store.running_tasks(): + task_id = str(task.get("task_id") or "") + if not task_id or task_id in self._incremental_runs: + continue + context = self.store.read_generation_run_context(task_id) + if not isinstance(context, dict): + self.store.finish_generation(task_id, lifecycle="failed", failure={ + "schema_version": "cad.generation-failure.v1", + "stage": "recovery", + "message": "The frozen generation context is unavailable after restart", + }) + continue + conversation_id = str(context.get("conversation_id") or "") + conversation = self.store.read_conversation(conversation_id) if conversation_id else None + author_messages = context.get("author_messages") + part_skills = context.get("part_skills") + if not isinstance(conversation, dict) or not isinstance(author_messages, list) or not isinstance(part_skills, dict): + self.store.finish_generation(task_id, lifecycle="failed", failure={ + "schema_version": "cad.generation-failure.v1", + "stage": "recovery", + "message": "The frozen conversation context is invalid after restart", + }) + continue + try: + provider, model = self.settings.resolve_model( + str(context.get("provider_id") or ""), str(context.get("model_id") or ""), + ) + except ValueError as error: + self.store.finish_generation(task_id, lifecycle="failed", failure={ + "schema_version": "cad.generation-failure.v1", "stage": "recovery", "message": str(error), + }) + continue + assistant_id = str(context.get("assistant_id") or f"assistant_{secrets.token_hex(8)}") + request = str(context.get("request") or task.get("request") or "") + runner = IncrementalGenerationRunner(self.settings, self.store, self._complete) + + async def consume( + *, task_id: str = task_id, request: str = request, conversation: dict[str, Any] = conversation, + provider: ProviderConfig = provider, model: ProviderModel = model, + author_messages: list[dict[str, Any]] = author_messages, part_skills: dict[str, Any] = part_skills, + assistant_id: str = assistant_id, runner: IncrementalGenerationRunner = runner, + ) -> None: + parts: list[dict[str, Any]] = [] + terminal = "" + try: + async for name, payload in runner.run( + task_id=task_id, request=request, conversation=conversation, provider=provider, model=model, + author_messages=author_messages, part_skills=part_skills, already_started=True, + ): + if name == "cad_result": + parts.append({"type": "data-cad-result", "data": payload}) + elif name == "task_terminal": + terminal = str(payload.get("lifecycle") or "") + if terminal == "failed": + parts.append({"type": "data-cad-error", "data": { + "stage": "generation", "message": str(payload.get("message") or "CAD 增量生成失败。"), + }}) + except Exception as error: + self.store.finish_generation(task_id, lifecycle="failed", failure={ + "schema_version": "cad.generation-failure.v1", "stage": "recovery_worker", "message": str(error), + }) + terminal = "failed" + parts.append({"type": "data-cad-error", "data": {"stage": "generation", "message": str(error)}}) + finally: + if not parts and terminal == "completed": + parts.append({"type": "text", "text": "CAD 模型已完成。"}) + self._persist_assistant(conversation["conversation_id"], assistant_id, parts, task_id) + self._incremental_runs.pop(task_id, None) + + self._incremental_runs[task_id] = asyncio.create_task(consume(), name=f"resume-incremental-cdsl-{task_id}") async def stream( self, @@ -543,14 +1572,29 @@ class AgentService: yield event("done", {}) return user_text = text_from_message(latest_user) - conversation = self.store.ensure_conversation(conversation_id, selected_task_id) - self.store.append_conversation_message(conversation["conversation_id"], latest_user.model_dump(), selected_task_id) - task_id = selected_task_id or conversation.get("current_task_id") or "" + conversation = self.store.ensure_conversation(conversation_id) + task_id = str(selected_task_id or conversation.get("current_task_id") or "") + current_task = self.store.read_task(task_id) if task_id else None assistant_parts: list[dict[str, Any]] = [] assistant_id = f"assistant_{secrets.token_hex(8)}" - successful_result: dict[str, Any] | None = None error_payload: dict[str, Any] | None = None + if task_id and current_task is None: + error_payload = {"stage": "request", "message": "The selected CAD task no longer exists. Start a new model or select a valid task."} + assistant_parts.append({"type": "data-cad-error", "data": error_payload}) + yield event("cad_error", error_payload) + self._persist_assistant(conversation["conversation_id"], assistant_id, assistant_parts, "") + yield event("done", {}) + return + if task_id and current_task and str(current_task.get("lifecycle") or "") == "running": + # Do not append the attempted turn: the run's request and + # attachments are immutable until a terminal lifecycle state. + yield event("cad_error", {"stage": "request", "message": "该 CAD 任务正在生成,完成或失败前不能继续对话。"}) + yield event("done", {}) + return + + self.store.append_conversation_message(conversation["conversation_id"], latest_user.model_dump(), task_id or None) + try: provider, model = self.settings.resolve_model(provider_id, model_id) except ValueError as error: @@ -581,21 +1625,114 @@ class AgentService: yield event("done", {}) return + # The persistent node-by-node orchestrator is opt-in while existing + # installations migrate their review-model and Chromium configuration. + # Once enabled, every new request is frozen and legacy authoring tools + # are not exposed for that run. + if self.settings.incremental_generation: + if not task_id: + created = self.store.ensure_task(None, user_text) + task_id = str(created["task_id"]) + current_task = created + self.store.append_conversation_message(conversation["conversation_id"], latest_user.model_dump(), task_id) + author_messages: list[dict[str, Any]] = [{ + "role": "system", + "content": ( + "You are an incremental CDSL CAD author. The user request is frozen for this run. " + "Missing dimensions must become explicit assumptions. Use only the requested function tool; do not emit prose or raw CAD source." + ), + }] + author_messages.extend(messages_for_model(messages)) + if attachment_message: + author_messages.append({"role": "user", "content": attachment_message}) + runner = IncrementalGenerationRunner(self.settings, self.store, self._complete) + part_skills = self.part_skill_library.audit(self.part_skill_library.select(user_text), {}, []) + # Acquire the durable run lock before scheduling work so a second + # request cannot slip in during the first model call. + self.store.start_generation(task_id, request=user_text) + self.store.write_generation_run_context(task_id, { + "schema_version": "cad.generation-run-context.v1", + "request": user_text, + "conversation_id": conversation["conversation_id"], + "provider_id": provider.id, + "model_id": model.id, + "assistant_id": assistant_id, + "author_messages": author_messages, + "part_skills": part_skills, + }) + queue: asyncio.Queue[tuple[str, dict[str, Any]] | None] = asyncio.Queue() + + async def consume_incremental_run() -> None: + run_parts: list[dict[str, Any]] = [] + terminal = "" + try: + async for name, event_payload in runner.run( + task_id=task_id, + request=user_text, + conversation=conversation, + provider=provider, + model=model, + author_messages=author_messages, + part_skills=part_skills, + already_started=True, + ): + if name == "cad_result": + run_parts.append({"type": "data-cad-result", "data": event_payload}) + elif name == "task_terminal": + terminal = str(event_payload.get("lifecycle") or "") + if terminal == "failed": + run_parts.append({ + "type": "data-cad-error", + "data": {"stage": "generation", "message": str(event_payload.get("message") or "CAD 增量生成失败。")}, + }) + await queue.put((name, event_payload)) + except Exception as error: # runner normally converts failures to a terminal event + self.store.finish_generation(task_id, lifecycle="failed", failure={ + "schema_version": "cad.generation-failure.v1", "message": str(error), "stage": "worker", + }) + terminal = "failed" + payload = {"taskId": task_id, "lifecycle": "failed", "message": str(error)} + run_parts.append({"type": "data-cad-error", "data": {"stage": "generation", "message": str(error)}}) + await queue.put(("task_terminal", payload)) + finally: + if not run_parts and terminal == "completed": + run_parts.append({"type": "text", "text": "CAD 模型已完成。"}) + self._persist_assistant(conversation["conversation_id"], assistant_id, run_parts, task_id) + self._incremental_runs.pop(task_id, None) + await queue.put(None) + + worker = asyncio.create_task(consume_incremental_run(), name=f"incremental-cdsl-{task_id}") + self._incremental_runs[task_id] = worker + while True: + queued = await queue.get() + if queued is None: + break + name, event_payload = queued + if name == "task_terminal" and str(event_payload.get("lifecycle") or "") == "failed": + yield event("cad_error", {"stage": "generation", "message": str(event_payload.get("message") or "CAD 增量生成失败。")}) + yield event(name, event_payload) + yield event("done", {}) + return + + image_inputs = image_attachments(conversation) + intake_stage = image_reference_stage(conversation) + recorded_image_analysis = image_reference_analysis(conversation) + partial_observation = image_reference_observation_part(conversation, "survey") yield event("progress", {"step": "analyze_request", "label": "分析需求", "status": "running", "message": "正在整理当前会话和 CAD 需求。"}) references: list[str] = [] library_searches = 0 - current_task = self.store.read_task(task_id) if task_id else None inherited_skill_ids = self.part_skill_library.inherited_from_task(current_task) part_skill_selection = self.part_skill_library.select(user_text, inherited_skill_ids) - intent_state: dict[str, Any] = { - "phase": "WAITING_FOR_INTENT", - "design_intent_id": "", - } + planning_state: dict[str, Any] = {"phase": "INTAKE", "design_brief": "", "current_model_read": False} + if task_id: + persisted_plan = self.store.read_feature_plan(task_id) + if isinstance(persisted_plan, dict): + planning_state["feature_plan"] = persisted_plan yield event("progress", { "step": "select_part_skill", - "label": "识别零件族", + "label": "选择建模 Skill", "status": "success", - "message": "已完成零件族与辅助建模规则识别。", + "message": "已完成建模 Skill 与辅助规则选择。", }) model_messages: list[dict[str, Any]] = [{ "role": "system", @@ -605,18 +1742,47 @@ class AgentService: viewer_context, task_id, self.part_skill_library.render_context(part_skill_selection), + image_reference_instruction( + image_inputs, + recorded_image_analysis or partial_observation, + intake_stage, + ), + cad_request_instruction(task_id, current_task), ), }] model_messages.extend(messages_for_model(messages)) if attachment_message: model_messages.append({"role": "user", "content": attachment_message}) - tools = tools_for_model(model) - required_tool_name: str | None = None + tools = tools_for_model( + model, + include_image_analysis=intake_stage == "survey", + include_image_sketches=intake_stage == "sketch", + image_stage=intake_stage if intake_stage in {"survey", "sketch"} else None, + ) + required_tool_name: str | None = ( + "analyze_image_reference" if intake_stage == "survey" + else "extract_image_sketch_candidates" if intake_stage == "sketch" + else None + ) generate_argument_failures = 0 tool_argument_diagnostics: list[str] = [] + cdsl_validation_diagnostics: list[str] = [] + generation_completed = False + repair_attempts = 0 + repair_step_key: str | None = None + last_plan_status: dict[str, Any] | None = None try: - for iteration in range(8): + for iteration in range(MAX_AGENT_TOOL_ITERATIONS): + # Once the model has been told to repair a CDSL step, stop + # before requesting another completion when that same step has + # exhausted its consecutive repair budget. + if ( + planning_state.get("phase") == "CDSL_REPAIR" + and required_tool_name in GENERATION_TOOL_NAMES + and repair_attempts >= self.settings.max_repair_attempts + ): + raise CdslRepairLimitError(cdsl_validation_diagnostics) response = await self._complete(model_messages, tools, provider, model, required_tool_name) response_choice = response["choices"][0] choice = response_choice["message"] @@ -640,6 +1806,53 @@ class AgentService: model_messages.append(choice) for call in tool_calls: name = str(call.get("function", {}).get("name") or "") + if intake_stage == "survey" and name != "analyze_image_reference": + result = { + "ok": False, + "code": "IMAGE_ANALYSIS_REQUIRED", + "message": "Analyze all current image attachments before using planning, library, or generation tools.", + } + model_messages.append({"role": "tool", "tool_call_id": call.get("id", ""), "content": json.dumps(result, ensure_ascii=False)}) + yield event("progress", {"step": name or "tool", "label": self._tool_label(name), "status": "error", "message": "请先完成图片参考分析。"}) + continue + if intake_stage == "sketch" and name != "extract_image_sketch_candidates": + result = { + "ok": False, + "code": "IMAGE_SKETCH_REQUIRED", + "message": "Extract image sketch candidates before using planning or generation tools.", + } + model_messages.append({"role": "tool", "tool_call_id": call.get("id", ""), "content": json.dumps(result, ensure_ascii=False)}) + yield event("progress", {"step": name or "tool", "label": self._tool_label(name), "status": "error", "message": "请先提取图片草图候选。"}) + continue + if name == "analyze_image_reference" and intake_stage != "survey": + result = { + "ok": False, + "code": "IMAGE_ANALYSIS_ALREADY_RECORDED", + "message": ( + "Image analysis is already recorded for the current attachments. " + "Use that result and continue the CAD workflow; do not analyze the image again." + ), + } + model_messages.append({ + "role": "tool", + "tool_call_id": call.get("id", ""), + "content": json.dumps(result, ensure_ascii=False), + }) + yield event("progress", { + "step": name, + "label": self._tool_label(name), + "status": "error", + "message": "图片识别结果已存在,正在继续后续建模流程。", + }) + continue + if name == "extract_image_sketch_candidates" and intake_stage != "sketch": + result = { + "ok": False, + "code": "IMAGE_SKETCH_ALREADY_RECORDED", + "message": "Image sketch candidates are already recorded for the current attachments.", + } + model_messages.append({"role": "tool", "tool_call_id": call.get("id", ""), "content": json.dumps(result, ensure_ascii=False)}) + continue if name == "search_cdsl_library": library_searches += 1 if library_searches > 2: @@ -684,7 +1897,7 @@ class AgentService: if diagnostic_path: tool_argument_diagnostics.append(diagnostic_path) result = invalid_tool_arguments_result(name or "tool", error) - if name == "generate_cdsl_model": + if name in GENERATION_TOOL_NAMES: generate_argument_failures += 1 if generate_argument_failures >= 2: raise RepeatedToolArgumentsError(str(error), tool_argument_diagnostics) @@ -701,6 +1914,21 @@ class AgentService: "message": "CAD 工具参数格式无效,正在请求模型修正。", }) continue + if name in GENERATION_TOOL_NAMES and planning_state.get("phase") == "CDSL_REPAIR": + current_step_key = get_repair_step_key(planning_state, name) + if repair_step_key != current_step_key: + repair_step_key = current_step_key + repair_attempts = 0 + if repair_attempts >= self.settings.max_repair_attempts: + raise CdslRepairLimitError(cdsl_validation_diagnostics) + repair_attempts += 1 + cdsl_attempt_path = "" + if name in GENERATION_TOOL_NAMES: + cdsl_attempt_path = self._record_cdsl_attempt( + conversation_id=conversation["conversation_id"], + arguments=arguments, + iteration=iteration + 1, + ) yield event("progress", { "step": name, "label": self._tool_label(name), @@ -715,19 +1943,47 @@ class AgentService: user_text, references, part_skill_selection=part_skill_selection, - intent_state=intent_state, + planning_state=planning_state, + image_attachment_ids=[str(attachment["id"]) for attachment in image_inputs], + input_attachments=revision_input_attachments(conversation), + conversation_id=conversation["conversation_id"], + repair_attempts=repair_attempts, ) except (ValueError, RuntimeError) as error: - code = str(getattr(error, "code", "")) - if code in {"INVALID_DESIGN_INTENT", "INTENT_CDSL_MISMATCH", "DESIGN_INTENT_REQUIRED", "DESIGN_INTENT_BLOCKED"}: - result = invalid_design_intent_result(error) - generated = None - if name in {"propose_design_intent", "generate_cdsl_model"} and code != "DESIGN_INTENT_BLOCKED": - required_tool_name = name - elif name == "generate_cdsl_model": + if name in GENERATION_TOOL_NAMES: + diagnostic_path = self._record_cdsl_validation_diagnostic( + conversation_id=conversation["conversation_id"], + task_id=task_id, + provider=provider, + model=model, + response=response, + finish_reason=response_choice.get("finish_reason"), + iteration=iteration + 1, + call=call, + arguments=arguments, + cdsl_attempt_path=cdsl_attempt_path, + error=error, + ) + cdsl_validation_diagnostics.append(diagnostic_path) result = invalid_cdsl_result(error) + result["diagnostic_path"] = diagnostic_path + repair_task_id = str(getattr(error, "task_id", "") or "") + repair_revision_id = str(getattr(error, "revision_id", "") or "") + if repair_task_id and repair_revision_id: + result.update({ + "task_id": repair_task_id, + "base_revision_id": repair_revision_id, + "repair_instruction": ( + "Patch this explicit base_revision_id for a local correction, or call read_current_cdsl " + "before a complete CDSL replacement." + ), + }) + if name == "patch_cdsl_model": + result["code"] = "INVALID_CDSL_PATCH" + result["repair_instruction"] = "Correct the RFC 6902 patch or call generate_cdsl_model with a complete replacement CDSL." generated = None required_tool_name = name + planning_state["phase"] = "CDSL_REPAIR" else: raise if result.get("task_id"): @@ -745,15 +2001,98 @@ class AgentService: "status": "success" if result.get("ok", True) else "error", "message": user_visible_tool_message(result, user_text), }) - if name == "propose_design_intent" and result.get("ok"): + if name == "describe_design_intent" and result.get("ok"): yield event("progress", { - "step": "validate_design_intent", - "label": "校验设计意图", + "step": "record_design_brief", + "label": "记录设计说明", "status": "success", - "message": "设计意图已通过结构、依赖和能力边界校验。", + "message": "设计说明已记录,将作为 CDSL 生成参考。", }) - if name in {"propose_design_intent", "generate_cdsl_model"} and result.get("ok"): + if name == "analyze_image_reference" and result.get("ok"): + survey = result["observation"] + if result.get("legacy"): + artifact_path = result.get("artifact_path", "") + image_payload = image_observation_payload(survey, stage="complete", artifact_path=artifact_path) + assistant_parts.append({"type": "data-cad-image-analysis", "data": image_payload}) + yield event("image_analysis", image_payload) + recorded_image_analysis = image_payload + intake_stage = "complete" + required_tool_name = None + tools = tools_for_model(model, include_image_analysis=False, include_image_sketches=False) + model_messages.append({"role": "system", "content": image_reference_instruction(image_inputs, survey, "complete")}) + yield event("progress", {"step": "analyze_image_reference", "label": "识别图片参考", "status": "success", "message": "已识别图片参考,正在继续 CAD 建模流程。"}) + continue + image_payload = image_observation_payload(survey, stage="survey", artifact_path=result.get("artifact_path", "")) + assistant_parts.append({"type": "data-cad-image-analysis", "data": image_payload}) + yield event("image_analysis", image_payload) + partial_observation = image_payload + intake_stage = "sketch" + required_tool_name = "extract_image_sketch_candidates" + tools = tools_for_model(model, include_image_analysis=False, include_image_sketches=True, image_stage="sketch") + model_messages.append({ + "role": "system", + "content": image_reference_instruction(image_inputs, survey, "sketch"), + }) + yield event("progress", { + "step": "analyze_image_reference", + "label": "整理图片勘测", + "status": "success", + "message": "已完成多视角图片勘测,正在提取草图候选。", + }) + continue + if name == "extract_image_sketch_candidates" and result.get("ok"): + survey = partial_observation or {} + if "observationStage" in survey: + survey = { + "schema_version": survey.get("schemaVersion", "cad.image-observation.v2"), + "attachment_ids": survey.get("attachmentIds") or [], + "part_type": survey.get("partType") or "", + "visible_features": survey.get("visibleFeatures") or [], + "uncertain_features": survey.get("uncertainFeatures") or [], + "views": survey.get("views") or [], + "scale_references": survey.get("scaleReferences") or [], + "overall_geometry": survey.get("overallGeometry") or {}, + "surfaces": survey.get("surfaces") or [], + "profiles": survey.get("profiles") or [], + "holes": survey.get("holes") or [], + "bends": survey.get("bends") or [], + "measurements": survey.get("measurements") or [], + "uncertainties": survey.get("uncertainties") or [], + "assumptions": survey.get("assumptions") or [], + "cv_hints": survey.get("cvHints") or [], + } + observation = merge_image_observations(survey, result["sketches"]) + artifact_path = self.store.write_conversation_planning( + conversation["conversation_id"], + "image-observation-v2", + observation, + ) + image_payload = image_observation_payload(observation, stage="complete", artifact_path=artifact_path) + assistant_parts.append({"type": "data-cad-image-analysis", "data": image_payload}) + yield event("image_analysis", image_payload) + recorded_image_analysis = image_payload + intake_stage = "complete" required_tool_name = None + tools = tools_for_model(model, include_image_analysis=False, include_image_sketches=False) + model_messages.append({ + "role": "system", + "content": image_reference_instruction(image_inputs, observation, "complete"), + }) + yield event("progress", { + "step": "extract_image_sketch_candidates", + "label": "提取图片草图", + "status": "success", + "message": "已提取图片轮廓和草图候选,正在继续 CAD 建模流程。", + }) + continue + if name in GENERATION_TOOL_NAMES and result.get("ok"): + required_tool_name = None + if result.get("ok", True): + # A successful tool call is forward progress. The next + # generation batch must receive a fresh consecutive-failure budget. + repair_attempts = 0 + repair_step_key = None + generate_argument_failures = 0 if generated: result_payload = { "taskId": generated["task_id"], @@ -765,13 +2104,20 @@ class AgentService: "parametersPath": generated.get("parameters_path"), "selectorPath": generated.get("selector_path"), "edgesPath": generated.get("edges_path"), - "designIntentId": generated.get("design_intent_id"), - "designIntentPath": generated.get("design_intent_path"), "summary": generated["summary"], "referenceIds": generated["reference_ids"], "engine": generated["engine"], + "qualityStatus": generated.get("quality_status", ""), + "qualityPath": generated.get("quality_path") or None, + "assumptions": generated.get("generation_assumptions", []), + "repairAttempts": generated.get("repair_attempts", 0), + "snapshotPaths": generated.get("snapshot_paths", []), + "snapshotStatus": generated.get("snapshot_status", "unavailable"), + "topologyPath": generated.get("topology_path"), + "planComplete": generated.get("plan_complete", True), + "planStatus": generated.get("plan_status"), + "requiredAction": generated.get("required_action", "complete"), } - successful_result = result_payload assistant_parts.append({"type": "data-cad-result", "data": result_payload}) yield event("cad_result", result_payload) yield event("progress", { @@ -780,8 +2126,39 @@ class AgentService: "status": "success", "message": "已通过 cdsl_only runtime 构建 STEP 和 GLB。", }) - if iteration == 7: - error_payload = {"stage": "agent", "message": "Agent tool loop reached its safety limit."} + generation_completed = bool(generated.get("plan_complete", True)) + if isinstance(generated.get("plan_status"), dict): + last_plan_status = generated["plan_status"] + if generation_completed: + break + if generation_completed: + break + if iteration == MAX_AGENT_TOOL_ITERATIONS - 1: + validation_diagnostics = cdsl_validation_diagnostics + waiting_nodes = [ + str(item) for item in (last_plan_status or {}).get("waiting_nodes") or [] + ] + if waiting_nodes: + message = ( + "CAD 基础模型已生成,但特征计划尚未完成。等待拓扑选择的特征:" + + "、".join(waiting_nodes) + + "。请先读取当前拓扑,再继续生成这些特征。" + ) + elif validation_diagnostics: + diagnostic_paths = "、".join(validation_diagnostics) + if any("\u4e00" <= char <= "\u9fff" for char in user_text): + message = ( + "模型重试达到安全上限。每次 CDSL 校验失败的诊断已保存到:" + f"{diagnostic_paths}。" + ) + else: + message = ( + "Agent tool loop reached its safety limit. " + f"CDSL validation diagnostics were saved to: {diagnostic_paths}." + ) + else: + message = "Agent tool loop reached its safety limit." + error_payload = {"stage": "agent", "message": message} assistant_parts.append({"type": "data-cad-error", "data": error_payload}) yield event("cad_error", error_payload) except Exception as error: @@ -795,8 +2172,6 @@ class AgentService: "type": "text", "text": "我暂时没有生成可执行的 CAD 结果。请补充尺寸、形状或修改目标。", }) - if successful_result and not any(part.get("type") == "text" for part in assistant_parts): - assistant_parts.insert(0, {"type": "text", "text": f"已生成:{successful_result['summary']}。"}) self._persist_assistant(conversation["conversation_id"], assistant_id, assistant_parts, task_id) yield event("progress", {"step": "agent_stream", "label": "调用模型和工具", "status": "success", "message": "Agent 请求已完成。"}) yield event("done", {}) @@ -863,6 +2238,74 @@ class AgentService: } return self.store.write_tool_call_diagnostic(conversation_id, payload) + def _record_cdsl_attempt( + self, + *, + conversation_id: str, + arguments: dict[str, Any], + iteration: int, + ) -> str: + candidate = arguments.get("cdsl") + if isinstance(candidate, str): + try: + candidate = json.loads(candidate) + except json.JSONDecodeError: + pass + # Patch calls do not contain a complete CDSL. Preserve their payload + # so an out-of-range path can be diagnosed against the exact request. + if candidate is None and "patches" in arguments: + candidate = { + "kind": "cdsl_patch_attempt", + "base_revision_id": str(arguments.get("base_revision_id") or ""), + "patches": deepcopy(arguments.get("patches") or []), + } + return self.store.write_cdsl_attempt(conversation_id, candidate, iteration) + + def _record_cdsl_validation_diagnostic( + self, + *, + conversation_id: str, + task_id: str, + provider: ProviderConfig, + model: ProviderModel, + response: dict[str, Any], + finish_reason: Any, + iteration: int, + call: dict[str, Any], + arguments: dict[str, Any], + cdsl_attempt_path: str, + error: Exception, + ) -> str: + function = call.get("function") if isinstance(call.get("function"), dict) else {} + details = _cdsl_error_details(error) + payload = { + "schema_version": "1.0", + "recorded_at": now_iso(), + "kind": "cdsl_validation_failure", + "conversation_id": conversation_id, + "task_id": task_id, + "provider_id": provider.id, + "model_id": model.id, + "strict_tool_schema": model.strict_tool_schema, + "completion_id": response.get("id"), + "response_model": response.get("model"), + "finish_reason": finish_reason, + "usage": response.get("usage"), + "iteration": iteration, + "tool_call_id": call.get("id"), + "tool_name": function.get("name"), + "cdsl_attempt_path": cdsl_attempt_path, + "base_revision_id": arguments.get("base_revision_id"), + "patches": deepcopy(arguments.get("patches")) if "patches" in arguments else None, + "summary": str(arguments.get("summary") or ""), + "assumptions": arguments.get("assumptions") or [], + "verification": arguments.get("verification"), + "diagnostic": details, + "validation_error_type": type(error).__name__, + "validation_error": str(error), + } + return self.store.write_cdsl_validation_diagnostic(conversation_id, payload) + async def _complete( self, messages: list[dict[str, Any]], @@ -885,6 +2328,14 @@ class AgentService: } async with httpx.AsyncClient(timeout=self.settings.llm_timeout_s) as client: response = await client.post(url, headers=headers, json=payload) + # Some reasoning-enabled, OpenAI-compatible models accept tools but + # reject an explicit tool_choice. Retry once without that constraint. + if ( + response.status_code == 400 + and "thinking mode does not support this tool_choice" in response.text.lower() + ): + payload.pop("tool_choice") + response = await client.post(url, headers=headers, json=payload) if response.status_code >= 400: if model.strict_tool_schema: raise StrictToolSchemaError( @@ -906,117 +2357,279 @@ class AgentService: references: list[str], *, part_skill_selection: dict[str, Any] | None = None, - intent_state: dict[str, Any] | None = None, + planning_state: dict[str, Any] | None = None, + image_attachment_ids: list[str] | None = None, + input_attachments: list[dict[str, str | int]] | None = None, + conversation_id: str | None = None, + repair_attempts: int = 0, ) -> tuple[dict[str, Any], dict[str, Any] | None]: - state = intent_state if intent_state is not None else {"phase": "WAITING_FOR_INTENT", "design_intent_id": ""} - phase = str(state.get("phase") or "WAITING_FOR_INTENT") - if name == "propose_design_intent": - intent = arguments.get("intent") - if not isinstance(intent, dict): - raise ValueError("propose_design_intent requires an intent JSON object") - if any(field in intent for field in ("intent_id", "created_at", "part_skill_ids", "part_skill_selection")): - raise ValueError("DesignIntent audit fields are assigned only by the backend") - summary = str(arguments.get("summary") or "").strip() + state = planning_state if planning_state is not None else {"phase": "INTAKE", "design_brief": "", "current_model_read": False} + phase = str(state.get("phase") or "INTAKE") + if name == "analyze_image_reference": + if not image_attachment_ids: + raise ValueError("analyze_image_reference requires at least one image attachment") + legacy_arguments = "views" not in arguments and "profiles" not in arguments + if not legacy_arguments: + observation = normalize_image_observation(arguments, attachment_ids=image_attachment_ids) + else: + legacy = normalize_image_analysis(arguments) + observation = normalize_image_observation({ + "attachment_ids": image_attachment_ids, + "part_type": legacy["part_type"], + "visible_features": legacy["visible_features"], + "uncertain_features": legacy["uncertain_features"], + "measurements": [ + {"name": item["label"], "source": "image", "evidence": item["reason"]} + for item in legacy["dimension_candidates"] + ], + "views": [{"attachment_id": attachment_id} for attachment_id in image_attachment_ids], + }, attachment_ids=image_attachment_ids) + analysis = {"ok": True, "legacy": legacy_arguments, "attachment_ids": list(dict.fromkeys(image_attachment_ids)), "observation": observation, **observation} + if conversation_id: + analysis["artifact_path"] = self.store.write_conversation_planning(conversation_id, "image-survey", analysis["observation"]) + state["phase"] = "REFERENCE_ANALYZED" + return analysis, None + if name == "extract_image_sketch_candidates": + if not image_attachment_ids: + raise ValueError("extract_image_sketch_candidates requires at least one image attachment") + sketches = normalize_sketch_candidates(arguments, attachment_ids=image_attachment_ids) + state["phase"] = "REFERENCE_SKETCHED" + return {"ok": True, "sketches": sketches, **sketches}, None + if name == "describe_design_intent": + plan = str(arguments.get("plan") or "").strip() assumptions = arguments.get("assumptions") - if not summary or not isinstance(assumptions, list) or not all(isinstance(item, str) for item in assumptions): - raise ValueError("propose_design_intent requires a summary and an array of string assumptions") - selection = part_skill_selection or self.part_skill_library.select(request) - if selection.get("conflict"): - return { - "ok": False, - "code": "DESIGN_INTENT_BLOCKED", - "message": str(selection["conflict"].get("message") or "The current part family must be clarified before planning."), - }, None - engine = load_engine(self.settings) - current_task = self.store.read_task(task_id) if task_id else None - current_revision_id = str((current_task or {}).get("current_revision") or "") - if current_revision_id and intent.get("mode") != "revise": - raise engine.DesignIntentError("INVALID_DESIGN_INTENT", "A task with a successful revision requires a revise DesignIntent") - if intent.get("mode") == "revise" and not current_revision_id: - raise engine.DesignIntentError("INVALID_DESIGN_INTENT", "A revise DesignIntent requires a current successful revision") - normalized = deepcopy(intent) - if assumptions: - normalized["assumptions"] = list(dict.fromkeys([ - *normalized.get("assumptions", []), - *(item.strip() for item in assumptions if item.strip()), - ])) - normalized = engine.validate_design_intent(normalized, engine, current_revision_id=current_revision_id) - persisted = self.store.create_design_intent(task_id or None, request, normalized, selection) - state["design_intent_id"] = persisted["intent_id"] - if normalized["status"] != "ready": - state["phase"] = "WAITING_FOR_INTENT" - return { - "ok": False, - "code": "DESIGN_INTENT_BLOCKED", - "task_id": persisted["task_id"], - "design_intent_id": persisted["intent_id"], - "status": normalized["status"], - "intent": persisted["intent"], - "message": "The DesignIntent is saved but blocked. Ask the user only about its blocking question or capability gap.", - }, None - state["phase"] = "INTENT_ACCEPTED" + if not plan or not isinstance(assumptions, list) or not all(isinstance(item, str) for item in assumptions): + raise ValueError("describe_design_intent requires a non-empty plan and an array of string assumptions") + state["design_brief"] = plan + state["phase"] = "PLANNED" return { "ok": True, - "task_id": persisted["task_id"], - "design_intent_id": persisted["intent_id"], - "design_intent_path": persisted["path"], - "status": "accepted", - "intent": persisted["intent"], - "summary": summary, + "plan": plan, + "assumptions": [item.strip() for item in assumptions if item.strip()], + "message": "The design brief is recorded as reference only. CDSL remains the sole authoritative CAD model.", + }, None + if name == "plan_feature_tree": + if phase not in {"PLANNED", "TOPOLOGY_READY", "LIBRARY_SEARCHING", "LIBRARY_REFERENCE_READY", "CDSL_REPAIR", "PLAN_READY"}: + return {"ok": False, "code": "DESIGN_BRIEF_REQUIRED", "message": "Call describe_design_intent before planning the feature tree."}, None + try: + engine = load_engine(self.settings) + plan = validate_feature_plan({ + "schema_version": "cad.feature-plan.v1", + "plan_id": arguments.get("plan_id"), + "task_id": task_id, + "nodes": arguments.get("nodes"), + }, supported_atomic_ids=getattr(engine, "SUPPORTED_ATOMIC_IDS", ())) + replan = arguments.get("replan") + if replan is not None: + if not isinstance(replan, dict): + raise FeaturePlanError("replan must be an object") + replace_nodes = replan.get("replace_nodes") + reason = str(replan.get("reason") or "").strip() + alternatives = replan.get("alternatives") + if not isinstance(replace_nodes, list) or not replace_nodes or not all(str(item).strip() for item in replace_nodes): + raise FeaturePlanError("replan.replace_nodes must be a non-empty string array") + if not reason or not isinstance(alternatives, list) or not all(isinstance(item, str) for item in alternatives): + raise FeaturePlanError("replan requires reason and alternatives") + prior = state.get("feature_plan") or (self.store.read_feature_plan(task_id) if task_id else None) + if isinstance(prior, dict): + prior_nodes = {str(item.get("id")): item for item in prior.get("nodes") or () if isinstance(item, dict)} + next_nodes = {str(item.get("id")): item for item in plan.get("nodes") or () if isinstance(item, dict)} + replace_set = {str(item) for item in replace_nodes} + if not replace_set.issubset(prior_nodes): + raise FeaturePlanError("replan.replace_nodes must identify existing plan nodes") + completed_replacements = { + node_id for node_id in replace_set + if prior_nodes[node_id].get("status") in {"completed", "executed"} + } + if completed_replacements: + raise FeaturePlanError( + "Replan cannot replace completed nodes: " + + ", ".join(sorted(completed_replacements)) + ) + missing_unchanged = set(prior_nodes) - replace_set - set(next_nodes) + if missing_unchanged: + raise FeaturePlanError( + "Replan cannot remove nodes outside replace_nodes: " + + ", ".join(sorted(missing_unchanged)) + ) + for node_id in prior_nodes.keys() & next_nodes.keys(): + if node_id not in replace_set: + old = {key: value for key, value in prior_nodes[node_id].items() if key not in {"status", "failure"}} + new = {key: value for key, value in next_nodes[node_id].items() if key not in {"status", "failure"}} + if old != new: + raise FeaturePlanError(f"Replan may only change replace_nodes; unchanged node mutated: {node_id}") + plan["replan"] = { + "replace_nodes": [str(item) for item in replace_nodes], + "reason": reason, + "alternatives": [item.strip() for item in alternatives if item.strip()], + } + except (FeaturePlanError, ValueError) as error: + state["phase"] = "BLOCKED" + message = str(error) + code = "FEATURE_PLAN_CYCLE" if "cycle" in message.casefold() else "FEATURE_PLAN_INVALID" + return {"ok": False, "code": code, "message": message}, None + state["feature_plan"] = plan + state["phase"] = "PLAN_READY" + current_cdsl = None + topology = None + if task_id: + cdsl_path = self.store.current_cdsl_path(task_id) + if cdsl_path and cdsl_path.is_file(): + current_cdsl = json.loads(cdsl_path.read_text(encoding="utf-8")) + topology_path = self.store.current_topology_path(task_id) + if topology_path and topology_path.is_file(): + topology = json.loads(topology_path.read_text(encoding="utf-8")) + self.store.write_feature_plan(task_id, plan) + status = compute_node_statuses(plan, cdsl=current_cdsl, topology=topology) + plan.update({key: status[key] for key in ("nodes", "ready_nodes", "waiting_nodes", "blocked_nodes", "completed_nodes", "complete")}) + state["feature_plan"] = plan + if task_id: + self.store.write_feature_plan(task_id, plan) + return { + "ok": True, + "plan_id": plan["plan_id"], + "ready_nodes": plan["ready_nodes"], + "waiting_nodes": plan["waiting_nodes"], + "blocked_nodes": plan["blocked_nodes"], + "completed_nodes": plan["completed_nodes"], + "required_action": "generate_cdsl_model" if plan["ready_nodes"] else "inspect_current_topology" if plan["waiting_nodes"] else "none", + "message": "Feature plan validated. Generate only ready nodes; never invent topology selectors.", }, None if name == "search_cdsl_library": - if phase not in {"INTENT_ACCEPTED", "LIBRARY_REFERENCE", "WAITING_FOR_CDSL"}: - return {"ok": False, "code": "DESIGN_INTENT_REQUIRED", "message": "Submit and receive an accepted DesignIntent before searching CDSL references."}, None - results = self.library.search(str(arguments.get("query") or request), int(arguments.get("limit") or 5)) - state["phase"] = "LIBRARY_REFERENCE" + if phase not in {"PLANNED", "LIBRARY_SEARCHING", "LIBRARY_REFERENCE_READY", "CDSL_REPAIR"}: + return {"ok": False, "code": "DESIGN_BRIEF_REQUIRED", "message": "Call describe_design_intent before searching CDSL references."}, None + query = str(arguments.get("query") or request) + normalized_query = " ".join(query.casefold().split()) + seen_queries = state.setdefault("library_queries", []) + if len(seen_queries) >= 2: + return {"ok": False, "code": "LIBRARY_SEARCH_LIMIT_REACHED", "message": "The CDSL library search limit for this request has been reached; continue with the available references."}, None + if normalized_query in seen_queries: + return {"ok": False, "code": "LIBRARY_QUERY_DUPLICATE", "message": "Do not repeat an identical unavailable library query; use the existing results or compile with the available capability."}, None + seen_queries.append(normalized_query) + results = self.library.search(query, min(8, int(arguments.get("limit") or 5))) + state["phase"] = "LIBRARY_SEARCHING" return {"ok": True, "results": results}, None if name == "read_cdsl_reference": - if phase not in {"INTENT_ACCEPTED", "LIBRARY_REFERENCE", "WAITING_FOR_CDSL"}: - return {"ok": False, "code": "DESIGN_INTENT_REQUIRED", "message": "Submit and receive an accepted DesignIntent before reading CDSL references."}, None + if phase not in {"PLANNED", "LIBRARY_SEARCHING", "LIBRARY_REFERENCE_READY", "CDSL_REPAIR"}: + return {"ok": False, "code": "DESIGN_BRIEF_REQUIRED", "message": "Call describe_design_intent before reading CDSL references."}, None part_id = str(arguments.get("part_id") or "") - sample = self.library.read_sample(part_id) + if part_id in references: + return {"ok": False, "code": "LIBRARY_REFERENCE_DUPLICATE", "message": "This CDSL reference is already loaded; choose another reference or continue to compilation."}, None + if len(state.get("reference_records") or []) >= 2: + return {"ok": False, "code": "LIBRARY_REFERENCE_LIMIT_REACHED", "message": "At most two complete CDSL library references may be read for one request."}, None + try: + sample = self.library.read_sample(part_id) + except ValueError as error: + return {"ok": False, "code": "LIBRARY_REFERENCE_NOT_FOUND", "message": str(error)}, None if part_id not in references: references.append(part_id) - state["phase"] = "WAITING_FOR_CDSL" + state.setdefault("reference_records", []).append({"part_id": part_id, "source": "cdsl_library", "summary": "official CDSL reference"}) + state["phase"] = "LIBRARY_REFERENCE_READY" return {"ok": True, "part_id": part_id, "cdsl": sample}, None if name == "read_current_cdsl": if not task_id: return {"ok": False, "message": "No current task exists. This is a new model request."}, None path = self.store.current_cdsl_path(task_id) + revision_id = str((self.store.read_task(task_id) or {}).get("current_revision") or "") if path is None: - return {"ok": False, "message": "The current task has no successful CDSL revision."}, None - return {"ok": True, "task_id": task_id, "cdsl": json.loads(path.read_text(encoding="utf-8"))}, None - if name == "generate_cdsl_model": - if phase not in {"INTENT_ACCEPTED", "LIBRARY_REFERENCE", "WAITING_FOR_CDSL"}: - return {"ok": False, "code": "DESIGN_INTENT_REQUIRED", "message": "Submit and receive an accepted DesignIntent before generating CDSL."}, None - design_intent_id = str(arguments.get("design_intent_id") or "") - if not design_intent_id or design_intent_id != str(state.get("design_intent_id") or "") or not task_id: - return {"ok": False, "code": "DESIGN_INTENT_REQUIRED", "message": "generate_cdsl_model must use the accepted DesignIntent ID for this task."}, None - intent_record = self.store.read_design_intent(task_id, design_intent_id) - if not intent_record or intent_record["record"].get("status") != "accepted": - return {"ok": False, "code": "DESIGN_INTENT_REQUIRED", "message": "The requested DesignIntent is not accepted for this task."}, None - intent = intent_record["intent"] - if intent.get("status") != "ready": - return {"ok": False, "code": "DESIGN_INTENT_BLOCKED", "message": "The DesignIntent is blocked and cannot be built."}, None - cdsl = arguments.get("cdsl") - if isinstance(cdsl, str): - cdsl = json.loads(cdsl) - if not isinstance(cdsl, dict): - raise ValueError("generate_cdsl_model requires a CDSL JSON object") + repairable = self.store.latest_repairable_cdsl(task_id) + if repairable is None: + return {"ok": False, "message": "The current task has no successful or repairable CDSL revision."}, None + revision_id, path = repairable + payload: dict[str, Any] = { + "ok": True, + "task_id": task_id, + "revision_id": revision_id, + "cdsl": json.loads(path.read_text(encoding="utf-8")), + } + state["current_model_read"] = True + state["read_revision_id"] = revision_id + state["phase"] = "PLANNED" + return payload, None + if name == "inspect_current_topology": + if phase not in {"PLAN_READY", "TOPOLOGY_READY", "CDSL_AUTHORING", "COMPLETED", "CDSL_REPAIR", "PLANNED"}: + return {"ok": False, "code": "TOPOLOGY_NOT_AVAILABLE", "message": "Build a successful CDSL revision before inspecting topology."}, None + result = _topology_query_result(self.store, task_id, arguments) + if result.get("ok") and result.get("records") and isinstance(state.get("feature_plan"), dict): + plan = deepcopy(state["feature_plan"]) + plan["topology_snapshot_id"] = str(result.get("snapshot_id") or "") + cdsl_path = self.store.current_cdsl_path(task_id) + topology_path = self.store.current_topology_path(task_id) + cdsl = json.loads(cdsl_path.read_text(encoding="utf-8")) if cdsl_path and cdsl_path.is_file() else None + topology = json.loads(topology_path.read_text(encoding="utf-8")) if topology_path and topology_path.is_file() else None + state["feature_plan"] = compute_node_statuses(plan, cdsl=cdsl, topology=topology) + self.store.write_feature_plan(task_id, state["feature_plan"]) + return result, None + if name in GENERATION_TOOL_NAMES: + if phase not in {"PLANNED", "PLAN_READY", "TOPOLOGY_READY", "LIBRARY_SEARCHING", "LIBRARY_REFERENCE_READY", "CDSL_REPAIR", "COMPLETED"}: + return {"ok": False, "code": "DESIGN_BRIEF_REQUIRED", "message": "Call describe_design_intent before generating CAD."}, None + selection = part_skill_selection or self.part_skill_library.select(request) + if selection.get("conflict"): + state["phase"] = "BLOCKED" + return { + "ok": False, + "code": "PART_SKILL_CONFLICT", + "message": str(selection["conflict"].get("message") or "Resolve the primary part-family conflict before generating CAD."), + }, None + state["phase"] = "CDSL_AUTHORING" + if name == "generate_cdsl_model": + if task_id and not state.get("current_model_read"): + return {"ok": False, "code": "CURRENT_CDSL_REQUIRED", "message": "Call read_current_cdsl before replacing an existing task revision."}, None + cdsl = arguments.get("cdsl") + if isinstance(cdsl, str): + cdsl = json.loads(cdsl) + if not isinstance(cdsl, dict): + raise ValueError("generate_cdsl_model requires a CDSL JSON object") + parent_revision_id = str(state.get("read_revision_id") or "") if task_id else "" + if task_id and not parent_revision_id: + parent_revision_id = str((self.store.read_task(task_id) or {}).get("current_revision") or "") + operation: dict[str, Any] = {"type": "cdsl_replacement" if parent_revision_id else "cdsl_create"} + if phase == "CDSL_REPAIR": + operation["type"] = "cdsl_repair" + else: + if not task_id: + return {"ok": False, "code": "PATCH_TASK_REQUIRED", "message": "patch_cdsl_model requires an existing task."}, None + base_revision_id = str(arguments.get("base_revision_id") or "") + current_revision_id = str((self.store.read_task(task_id) or {}).get("current_revision") or "") + if not current_revision_id or base_revision_id != current_revision_id: + raise ValueError("TOPOLOGY_SNAPSHOT_STALE: base_revision_id must be the current successful revision") + base_path = self.store.revision_cdsl_path(task_id, base_revision_id) + if base_path is None: + raise ValueError("PATCH_BASE_REVISION_NOT_FOUND: base_revision_id does not identify a readable CDSL revision") + try: + base_cdsl = json.loads(base_path.read_text(encoding="utf-8")) + cdsl = apply_cdsl_patch(base_cdsl, arguments.get("patches")) + except (CdslPatchError, json.JSONDecodeError) as error: + raise ValueError(f"INVALID_CDSL_PATCH: {error}") from error + parent_revision_id = base_revision_id + operation = {"type": "cdsl_patch", "base_revision_id": base_revision_id, "patches": deepcopy(arguments.get("patches") or [])} + base_selectors = {(item.get("source"), item.get("stable_id"), item.get("snapshot_id")) for item in _selector_values(base_cdsl)} + next_selectors = {(item.get("source"), item.get("stable_id"), item.get("snapshot_id")) for item in _selector_values(cdsl)} + if any(source in {"runtime_snapshot", "viewer_selection"} for source, _stable_id, _snapshot_id in next_selectors - base_selectors): + operation["type"] = "cdsl_selector_patch" + if state.get("feature_plan"): + operation["type"] = "cdsl_plan_batch" if operation.get("type") in {"cdsl_create", "cdsl_replacement"} else operation.get("type") + operation["plan_id"] = str(state["feature_plan"].get("plan_id") or "") + operation["plan_nodes"] = [ + str(node.get("id")) for node in state["feature_plan"].get("nodes") or () + if isinstance(node, dict) and node.get("status") in {"ready", "executing"} + ] + cdsl, normalization_repairs = normalize_cdsl_for_engine(cdsl) + _validate_plan_cdsl_transition(self.store, task_id, state.get("feature_plan"), cdsl) + _validate_snapshot_selectors(self.store, task_id, cdsl) # Reject malformed model output before build_revision allocates a task # directory or revision. build_revision will assign the real task ID. preflight_cdsl = {**cdsl, "part_id": str(cdsl.get("part_id") or "agent_preflight")} engine = load_engine(self.settings) - current_path = self.store.current_cdsl_path(task_id) - current_cdsl = json.loads(current_path.read_text(encoding="utf-8")) if current_path else None - engine.validate_intent_cdsl(intent, preflight_cdsl, engine, current_cdsl=current_cdsl) + state["phase"] = "PREFLIGHT" validate_cdsl(preflight_cdsl, engine) - summary = str(arguments.get("summary") or "CDSL CAD model") + summary = str(arguments.get("summary") or "Parameterized CAD model") raw_assumptions = arguments.get("assumptions") or [] if not isinstance(raw_assumptions, list) or not all(isinstance(item, str) for item in raw_assumptions): - raise ValueError("generate_cdsl_model assumptions must be an array of strings") + raise ValueError(f"{name} assumptions must be an array of strings") assumptions = [item.strip() for item in raw_assumptions if item.strip()] - selection = part_skill_selection or self.part_skill_library.select(request) + verification = arguments.get("verification") + validate_verification(verification, cdsl) part_skill_audit = self.part_skill_library.audit(selection, cdsl, assumptions) state["phase"] = "BUILDING" try: @@ -1029,23 +2642,55 @@ class AgentService: cdsl=cdsl, reference_ids=list(references), summary=summary, + parent_revision_id=parent_revision_id, + operation=operation, part_skills=part_skill_audit, generation_assumptions=assumptions, - design_intent=intent, - design_intent_path=str(intent_record["record"].get("path") or ""), + repair_attempts=repair_attempts, + input_attachments=input_attachments, + verification=verification, + reference_records=state.get("reference_records"), + feature_plan=state.get("feature_plan"), ) except Exception: - # The accepted plan stays current so the model can submit a - # corrected implementation without silently replanning. - state["phase"] = "INTENT_ACCEPTED" + state["phase"] = "CDSL_REPAIR" raise - state["phase"] = "COMPLETED" + plan_status: dict[str, Any] | None = None + if state.get("feature_plan"): + topology_path = self.store.current_topology_path(yieldable["task_id"]) + topology = json.loads(topology_path.read_text(encoding="utf-8")) if topology_path and topology_path.is_file() else None + # A successful build already produced the authoritative topology + # snapshot. Bind it here so advancing a plan never depends on the + # model remembering a bookkeeping-only topology inspection call. + plan = deepcopy(state["feature_plan"]) + snapshot_id = str((topology or {}).get("snapshot_id") or "") + if snapshot_id: + plan["topology_snapshot_id"] = snapshot_id + plan_status = compute_node_statuses(plan, cdsl=cdsl, topology=topology) + state["feature_plan"] = plan_status + self.store.write_feature_plan(yieldable["task_id"], plan_status) + state["phase"] = "COMPLETED" if plan_status["complete"] else "TOPOLOGY_READY" + else: + state["phase"] = "COMPLETED" + yieldable["plan_complete"] = bool(plan_status["complete"]) if plan_status else True + yieldable["plan_status"] = plan_status return { "ok": True, "summary": summary, "task_id": yieldable["task_id"], "revision_id": yieldable["revision_id"], - "design_intent_id": design_intent_id, + "normalization_repairs": normalization_repairs, + "operation": operation, + "plan_complete": bool(plan_status["complete"]) if plan_status else True, + "plan_status": { + key: plan_status[key] + for key in ("ready_nodes", "waiting_nodes", "blocked_nodes", "completed_nodes", "complete") + } if plan_status else None, + "required_action": ( + "inspect_current_topology" if plan_status and plan_status["waiting_nodes"] + else "patch_cdsl_model" if plan_status and plan_status["ready_nodes"] + else "complete" + ) if plan_status else "complete", }, yieldable raise ValueError(f"Unknown agent tool: {name}") @@ -1056,37 +2701,66 @@ class AgentService: @staticmethod def _tool_label(name: str) -> str: return { + "analyze_image_reference": "整理图片勘测", + "extract_image_sketch_candidates": "提取图片草图", "search_cdsl_library": "检索 CDSL 模型库", "read_cdsl_reference": "读取 CDSL 参考模型", "read_current_cdsl": "读取当前 CDSL", - "propose_design_intent": "生成设计意图", + "describe_design_intent": "整理设计说明", + "plan_feature_tree": "规划特征树", + "inspect_current_topology": "查询当前拓扑", "generate_cdsl_model": "生成 CDSL", + "patch_cdsl_model": "修复 CDSL", }.get(name, "调用 CAD 工具") def _attachment_message(self, conversation: dict[str, Any], model: ProviderModel) -> list[dict[str, Any]] | str: attachments = conversation.get("attachments") or [] if not attachments: return "" - content: list[dict[str, Any]] = [{"type": "text", "text": "The following local attachments are part of the CAD request."}] + conversation_id = str(conversation.get("conversation_id") or "") + if not conversation_id: + raise ValueError("Conversation attachment has no conversation id") + content: list[dict[str, Any]] = [{"type": "text", "text": "The following local attachments are part of the CAD request. Image ids are stable references for the visual survey."}] for attachment in attachments: if not isinstance(attachment, dict): continue kind = str(attachment.get("kind") or "") - task_id = str(attachment.get("task_id") or "") + attachment_conversation = str(attachment.get("conversation_id") or "") relative = str(attachment.get("path") or "") - if not task_id or not relative: - continue - path = self.store.artifact_path(task_id, relative) + if attachment_conversation != conversation_id or not relative: + raise ValueError("Conversation attachment metadata is invalid") + path = self.store.conversation_attachment_path(conversation_id, relative) + if not path.is_file(): + raise ValueError(f"Conversation attachment is missing: {attachment.get('name') or attachment.get('id')}") if kind == "image": if not model.vision: - raise ValueError("The selected model does not support images. Choose a vision-capable OpenAI or Kimi model.") + raise ValueError("The selected model does not support images. Choose a vision-capable model enabled in backend/.env.") mime = str(attachment.get("mime") or "image/png") + metadata = { + "width": attachment.get("width"), + "height": attachment.get("height"), + "orientation": attachment.get("orientation"), + } + try: + hints = cv_hints(path.read_bytes()) + except OSError: + hints = {"available": False, "hints": []} + content.append({ + "type": "text", + "text": ( + f"IMAGE_ID: {attachment.get('id')}\n" + f"FILE_NAME: {attachment.get('name')}\n" + f"MIME: {mime}\n" + f"METADATA: {json.dumps(metadata, ensure_ascii=False, separators=(',', ':'))}\n" + f"CV_HINTS: {json.dumps(hints, ensure_ascii=False, separators=(',', ':'))}" + ), + }) encoded = base64.b64encode(path.read_bytes()).decode("ascii") content.append({"type": "image_url", "image_url": {"url": f"data:{mime};base64,{encoded}"}}) elif kind == "document": extracted = str(attachment.get("extracted_path") or "") if extracted: - text_path = self.store.artifact_path(task_id, extracted) + text_path = self.store.conversation_attachment_path(conversation_id, extracted) text = text_path.read_text(encoding="utf-8")[:30_000] content.append({"type": "text", "text": f"Document {attachment.get('name')}:\n{text}"}) return content diff --git a/backend/app/services/attachments.py b/backend/app/services/attachments.py index 101e9323..354798f4 100644 --- a/backend/app/services/attachments.py +++ b/backend/app/services/attachments.py @@ -4,8 +4,8 @@ import hashlib from pathlib import Path -IMAGE_SUFFIXES = {".png", ".jpg", ".jpeg", ".webp"} -DOCUMENT_SUFFIXES = {".txt", ".md", ".csv", ".json"} +IMAGE_SUFFIXES = {".png", ".jpg", ".jpeg", ".webp", ".svg"} +DOCUMENT_SUFFIXES = {".txt", ".md", ".csv", ".json", ".pdf", ".dxf", ".step", ".stp"} MAX_IMAGE_BYTES = 10 * 1024 * 1024 MAX_DOCUMENT_BYTES = 2 * 1024 * 1024 MAX_EXTRACTED_CHARS = 30_000 @@ -13,8 +13,6 @@ MAX_EXTRACTED_CHARS = 30_000 def classify_upload(filename: str, mime: str, size: int) -> str: suffix = Path(filename).suffix.lower() - if suffix in {".step", ".stp"}: - raise ValueError("STEP/STP upload is not supported in this version") if suffix in IMAGE_SUFFIXES or mime.startswith("image/"): if size > MAX_IMAGE_BYTES: raise ValueError("Image upload exceeds the 10 MB limit") @@ -23,21 +21,40 @@ def classify_upload(filename: str, mime: str, size: int) -> str: if size > MAX_DOCUMENT_BYTES: raise ValueError("Document upload exceeds the 2 MB limit") return "document" - raise ValueError("Only PNG, JPG, WEBP, TXT, MD, CSV, and JSON uploads are supported") + raise ValueError("Only PNG, JPG, WEBP, SVG, TXT, MD, CSV, JSON, PDF, DXF, and STEP/STP uploads are supported") def extract_document_text(data: bytes) -> str: + # PDF extraction is optional so the API can still accept a reference when + # the local PDF dependency is unavailable; the binary remains available as + # the primary attachment artifact. + if data.startswith(b"%PDF"): + try: + from pypdf import PdfReader + import io + return "\n".join(page.extract_text() or "" for page in PdfReader(io.BytesIO(data)).pages)[:MAX_EXTRACTED_CHARS] + except Exception: + return "[PDF reference uploaded; text extraction unavailable]" try: text = data.decode("utf-8") except UnicodeDecodeError as error: - raise ValueError("Documents must be UTF-8 text") from error + return "[Binary CAD reference uploaded; structured geometry inspection is deferred to the CAD reference analyzer]" return text[:MAX_EXTRACTED_CHARS] -def attachment_record(task_id: str, filename: str, mime: str, relative_path: str, data: bytes, kind: str, extracted_path: str = "") -> dict[str, object]: +def attachment_record( + conversation_id: str, + filename: str, + mime: str, + relative_path: str, + data: bytes, + kind: str, + extracted_path: str = "", + metadata: dict[str, object] | None = None, +) -> dict[str, object]: return { "id": Path(relative_path).stem, - "task_id": task_id, + "conversation_id": conversation_id, "name": filename, "kind": kind, "path": relative_path, @@ -45,4 +62,5 @@ def attachment_record(task_id: str, filename: str, mime: str, relative_path: str "size": len(data), "sha256": hashlib.sha256(data).hexdigest(), "extracted_path": extracted_path, + **(metadata or {}), } diff --git a/backend/app/services/cdsl_fragment.py b/backend/app/services/cdsl_fragment.py new file mode 100644 index 00000000..5d0840cb --- /dev/null +++ b/backend/app/services/cdsl_fragment.py @@ -0,0 +1,272 @@ +"""Controlled CDSL fragments; the backend, never string concatenation, materialises a model.""" + +from __future__ import annotations + +from copy import deepcopy +from hashlib import sha256 +import json +from typing import Any + +from app.services.generation_plan import GenerationPlanError + + +class CdslFragmentError(ValueError): + """A node fragment cannot be applied safely to its declared base revision.""" + + +def cdsl_sha256(cdsl: dict[str, Any] | None) -> str: + value = cdsl or {"geometry": {"sketches": []}, "features": []} + return sha256(json.dumps(value, ensure_ascii=True, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest() + + +def _items(value: Any, field: str) -> list[dict[str, Any]]: + if value is None: + return [] + if not isinstance(value, list) or not all(isinstance(item, dict) for item in value): + raise CdslFragmentError(f"{field} must be an array of objects") + return deepcopy(value) + + +def _ids(items: list[dict[str, Any]], field: str) -> list[str]: + values = [str(item.get("id") or "").strip() for item in items] + if not all(values) or len(values) != len(set(values)): + raise CdslFragmentError(f"{field} must have unique non-empty ids") + return values + + +def _node(plan: dict[str, Any], node_id: str) -> dict[str, Any]: + node = next((item for item in plan.get("nodes") or () if isinstance(item, dict) and item.get("id") == node_id), None) + if node is None: + raise CdslFragmentError(f"Fragment references an unknown node: {node_id}") + return node + + +def _single_output_id(node: dict[str, Any], field: str) -> str: + values = [str(item) for item in node.get(field) or () if str(item)] + if len(values) != 1: + raise CdslFragmentError(f"Node {node.get('id')} must declare exactly one {field} output") + return values[0] + + +def _node_feature_id(plan: dict[str, Any], node_id: str) -> str: + return _single_output_id(_node(plan, node_id), "cdsl_feature_ids") + + +def _materialize_node_references(value: Any, plan: dict[str, Any]) -> Any: + """Translate fragment-only node references into CDSL feature references.""" + if isinstance(value, list): + return [_materialize_node_references(item, plan) for item in value] + if not isinstance(value, dict): + return value + materialized = {key: _materialize_node_references(item, plan) for key, item in value.items()} + owner_node_id = materialized.pop("owner_node_id", None) + if owner_node_id is not None: + if not isinstance(owner_node_id, str) or not owner_node_id.strip(): + raise CdslFragmentError("owner_node_id must be a non-empty plan node id") + materialized["owner_feature_id"] = _node_feature_id(plan, owner_node_id.strip()) + source_node_ids = materialized.pop("source_node_ids", None) + if source_node_ids is not None: + if not isinstance(source_node_ids, list) or not source_node_ids or not all(isinstance(item, str) and item.strip() for item in source_node_ids): + raise CdslFragmentError("source_node_ids must be a non-empty array of plan node ids") + materialized["source_feature_ids"] = [_node_feature_id(plan, item.strip()) for item in source_node_ids] + return materialized + + +def _selector_references(value: Any) -> list[dict[str, Any]]: + """Collect selector-shaped objects from feature selectors and params.""" + found: list[dict[str, Any]] = [] + if isinstance(value, dict): + if "kind" in value and ("stable_id" in value or "snapshot_id" in value or "owner_feature_id" in value or "owner_node_id" in value): + found.append(value) + for child in value.values(): + found.extend(_selector_references(child)) + elif isinstance(value, list): + for child in value: + found.extend(_selector_references(child)) + return found + + +def validate_fragment( + fragment: dict[str, Any], + *, + plan: dict[str, Any], + node_id: str, + base_revision_id: str, + base_cdsl: dict[str, Any] | None, + required_snapshot_id: str = "", +) -> dict[str, Any]: + if not isinstance(fragment, dict): + raise CdslFragmentError("CDSL fragment must be an object") + version = str(fragment.get("schema_version") or "cad.cdsl-fragment.v1") + if version != "cad.cdsl-fragment.v1": + raise CdslFragmentError(f"Unsupported CDSL fragment schema: {version}") + declared_node_id = str(fragment.get("node_id") or "") + if declared_node_id != node_id: + raise CdslFragmentError("Fragment node_id does not match the active node") + if str(fragment.get("base_revision_id") or "") != base_revision_id: + raise CdslFragmentError("Fragment base_revision_id does not match the active revision") + if str(fragment.get("base_cdsl_sha256") or "") != cdsl_sha256(base_cdsl): + raise CdslFragmentError("Fragment base_cdsl_sha256 does not match the active CDSL") + snapshot_id = str(fragment.get("required_snapshot_id") or "") + if required_snapshot_id and snapshot_id != required_snapshot_id: + raise CdslFragmentError("Fragment required_snapshot_id does not match the active topology snapshot") + if not required_snapshot_id and snapshot_id: + raise CdslFragmentError("Fragment cannot use a topology snapshot before one exists") + sketches = _items(fragment.get("add_sketches"), "add_sketches") + features = _items(fragment.get("add_features"), "add_features") + node = _node(plan, node_id) + expected = {str(item) for item in node.get("cdsl_feature_ids") or ()} + expected_sketches = {str(item) for item in node.get("cdsl_sketch_ids") or ()} + if len(expected) != 1: + # Compatibility path for pre-incremental plans which could own more + # than one CDSL feature in a single node. + feature_ids = _ids(features, "add_features") + if set(feature_ids) != expected: + raise CdslFragmentError( + f"Fragment features must exactly match node outputs: expected {sorted(expected)}, got {sorted(feature_ids)}" + ) + elif len(features) != 1: + raise CdslFragmentError("An atomic plan node must generate exactly one feature") + if len(expected_sketches) > 1: + sketch_ids = _ids(sketches, "add_sketches") + if set(sketch_ids) != expected_sketches: + raise CdslFragmentError( + f"Fragment sketches must exactly match node outputs: expected {sorted(expected_sketches)}, got {sorted(sketch_ids)}" + ) + elif len(sketches) != len(expected_sketches): + expected_description = "one" if expected_sketches else "no" + raise CdslFragmentError(f"This node requires {expected_description} new sketch") + + # The planner and fragment author never own CDSL object identities. The + # merger writes node-derived IDs and dependencies after it has validated + # the current immutable plan. + if len(expected) == 1: + features[0]["id"] = next(iter(expected)) + if len(expected_sketches) == 1: + sketch_id = next(iter(expected_sketches)) + sketches[0]["id"] = sketch_id + features[0]["sketch_id"] = sketch_id + elif not expected_sketches and len(features) == 1: + features[0].pop("sketch_id", None) + features = _materialize_node_references(features, plan) + sketches = _materialize_node_references(sketches, plan) + sketch_ids = _ids(sketches, "add_sketches") + feature_ids = _ids(features, "add_features") + atomic_id = str(node.get("atomic_id") or "") + if len(expected) == 1: + features[0]["atomic_id"] = atomic_id + elif any(str(feature.get("atomic_id") or "") != atomic_id for feature in features): + raise CdslFragmentError(f"Fragment features must use plan atomic_id {atomic_id}") + base_sketch_ids = { + str(item.get("id")) for item in ((base_cdsl or {}).get("geometry") or {}).get("sketches") or () + if isinstance(item, dict) + } + base_feature_ids = { + str(item.get("id")) for item in (base_cdsl or {}).get("features") or () if isinstance(item, dict) + } + if base_sketch_ids & set(sketch_ids): + raise CdslFragmentError("Fragment attempts to overwrite an existing sketch") + if base_feature_ids & set(feature_ids): + raise CdslFragmentError("Fragment attempts to overwrite a frozen feature") + predecessor_features = { + feature_id + for dependency in node.get("depends_on") or () + for feature_id in (_node(plan, str(dependency)).get("cdsl_feature_ids") or ()) + } + if len(expected) == 1: + features[0]["depends_on"] = sorted(predecessor_features) + available_features = base_feature_ids | set(feature_ids) + available_sketches = base_sketch_ids | set(sketch_ids) + for feature in features: + feature_id = str(feature["id"]) + dependencies = feature.get("depends_on") or [] + if not isinstance(dependencies, list) or any(str(item) not in available_features for item in dependencies): + raise CdslFragmentError(f"Feature {feature_id} has a missing dependency") + sketch_id = str(feature.get("sketch_id") or "") + if sketch_id and sketch_id not in available_sketches: + raise CdslFragmentError(f"Feature {feature_id} references an unknown sketch") + if required_snapshot_id: + selectors = [selector for feature in features for selector in _selector_references(feature)] + if not selectors: + raise CdslFragmentError("Topology-dependent fragment must provide runtime snapshot selectors") + for selector in selectors: + if str(selector.get("snapshot_id") or "") != required_snapshot_id: + raise CdslFragmentError("Topology selector must reference the active snapshot") + if not str(selector.get("owner_feature_id") or ""): + raise CdslFragmentError("Topology selector must declare owner_feature_id") + if not isinstance(selector.get("geometry"), dict) or not selector["geometry"]: + raise CdslFragmentError("Topology selector must declare a non-empty geometry signature") + declared_dependencies = { + str(dependency) + for feature in features + for dependency in feature.get("depends_on") or () + } + if not predecessor_features.issubset(declared_dependencies): + raise CdslFragmentError("Fragment does not preserve all plan-node dependencies") + rules = fragment.get("verification_rules") or [] + if not isinstance(rules, list) or not all(isinstance(item, dict) for item in rules): + raise CdslFragmentError("verification_rules must be an array of objects") + assumptions = fragment.get("assumptions") or [] + if not isinstance(assumptions, list) or not all(isinstance(item, str) for item in assumptions): + raise CdslFragmentError("assumptions must be an array of strings") + return { + "schema_version": "cad.cdsl-fragment.v1", + "node_id": node_id, + "base_revision_id": base_revision_id, + "base_cdsl_sha256": cdsl_sha256(base_cdsl), + "required_snapshot_id": snapshot_id, + "add_sketches": sketches, + "add_features": features, + "expected_feature_ids": sorted(expected), + "verification_rules": deepcopy(rules), + "assumptions": [item.strip() for item in assumptions if item.strip()], + } + + +def materialize_fragment(base_cdsl: dict[str, Any] | None, fragment: dict[str, Any]) -> dict[str, Any]: + """Return the complete, append-only document that the runtime must rebuild.""" + if base_cdsl is None: + document: dict[str, Any] = { + "schema": "cad.cdsl.llm.v1", + "schema_version": "1.1.0", + "kind": "part", + "part_id": "agent_preflight", + "geometry": {"sketches": []}, + "features": [], + } + else: + document = deepcopy(base_cdsl) + geometry = document.setdefault("geometry", {}) + if not isinstance(geometry, dict): + raise CdslFragmentError("Base CDSL geometry must be an object") + sketches = geometry.setdefault("sketches", []) + features = document.setdefault("features", []) + if not isinstance(sketches, list) or not isinstance(features, list): + raise CdslFragmentError("Base CDSL has invalid collections") + sketches.extend(deepcopy(fragment["add_sketches"])) + features.extend(deepcopy(fragment["add_features"])) + return document + + +def selector_bindings(engine_result: dict[str, Any], *, node_id: str, snapshot_id: str) -> dict[str, Any]: + """Persist the runtime's actual selector choices as auditable node evidence.""" + values = [] + for resolution in engine_result.get("selector_resolution") or (): + if not isinstance(resolution, dict): + continue + selector = resolution.get("selector") if isinstance(resolution.get("selector"), dict) else {} + candidates = resolution.get("candidates") if isinstance(resolution.get("candidates"), (list, tuple)) else [] + values.append({ + "consumer_node_id": node_id, + "consumer_feature_id": str(resolution.get("feature_id") or ""), + "source_snapshot_id": str(selector.get("snapshot_id") or snapshot_id), + "kind": str(selector.get("kind") or ""), + "owner_feature_id": str(selector.get("owner_feature_id") or ""), + "stable_id": str(selector.get("stable_id") or ""), + "geometry": deepcopy(selector.get("geometry") or {}), + "status": str(resolution.get("status") or ""), + "candidates": deepcopy(list(candidates)), + "score": resolution.get("score"), + "selected": deepcopy(resolution.get("selected") or resolution.get("record") or {}), + }) + return {"schema_version": "cad.selector-bindings.v1", "node_id": node_id, "bindings": values} diff --git a/backend/app/services/cdsl_patch.py b/backend/app/services/cdsl_patch.py new file mode 100644 index 00000000..3290ec2d --- /dev/null +++ b/backend/app/services/cdsl_patch.py @@ -0,0 +1,122 @@ +"""Small, dependency-free RFC 6902 JSON Patch implementation for CDSL revisions.""" + +from __future__ import annotations + +from copy import deepcopy +from typing import Any + + +class CdslPatchError(ValueError): + """A JSON Patch operation cannot be applied to the requested CDSL revision.""" + + +def _tokens(path: Any) -> list[str]: + if not isinstance(path, str) or not path.startswith("/"): + raise CdslPatchError("JSON Patch paths must be JSON Pointers beginning with '/'") + return [token.replace("~1", "/").replace("~0", "~") for token in path[1:].split("/")] + + +def _index(token: str, length: int, *, allow_append: bool = False) -> int: + if allow_append and token == "-": + return length + if not token.isdigit() or (len(token) > 1 and token.startswith("0")): + raise CdslPatchError(f"Invalid JSON Patch array index: {token}") + value = int(token) + if value < 0 or value >= length: + raise CdslPatchError(f"JSON Patch array index is out of range: {token}") + return value + + +def _parent(document: Any, path: str) -> tuple[Any, str]: + if path == "": + raise CdslPatchError("Replacing the complete CDSL document is not allowed; call generate_cdsl_model instead") + tokens = _tokens(path) + current = document + for token in tokens[:-1]: + if isinstance(current, dict): + if token not in current: + raise CdslPatchError(f"JSON Patch path does not exist: {path}") + current = current[token] + elif isinstance(current, list): + current = current[_index(token, len(current))] + else: + raise CdslPatchError(f"JSON Patch path does not resolve to a container: {path}") + return current, tokens[-1] + + +def _get(document: Any, path: str) -> Any: + current = document + for token in _tokens(path): + if isinstance(current, dict): + if token not in current: + raise CdslPatchError(f"JSON Patch path does not exist: {path}") + current = current[token] + elif isinstance(current, list): + current = current[_index(token, len(current))] + else: + raise CdslPatchError(f"JSON Patch path does not resolve: {path}") + return current + + +def _add(document: Any, path: str, value: Any) -> None: + parent, token = _parent(document, path) + if isinstance(parent, dict): + parent[token] = deepcopy(value) + elif isinstance(parent, list): + index = _index(token, len(parent), allow_append=True) + parent.insert(index, deepcopy(value)) + else: + raise CdslPatchError(f"JSON Patch add target is not a container: {path}") + + +def _remove(document: Any, path: str) -> Any: + parent, token = _parent(document, path) + if isinstance(parent, dict): + if token not in parent: + raise CdslPatchError(f"JSON Patch path does not exist: {path}") + return parent.pop(token) + if isinstance(parent, list): + return parent.pop(_index(token, len(parent))) + raise CdslPatchError(f"JSON Patch remove target is not a container: {path}") + + +def apply_cdsl_patch(cdsl: dict[str, Any], patches: Any) -> dict[str, Any]: + if not isinstance(cdsl, dict): + raise CdslPatchError("The base CDSL must be an object") + if not isinstance(patches, list) or not patches: + raise CdslPatchError("patches must be a non-empty JSON Patch array") + if len(patches) > 32: + raise CdslPatchError("At most 32 JSON Patch operations are allowed") + result: Any = deepcopy(cdsl) + for index, operation in enumerate(patches): + if not isinstance(operation, dict): + raise CdslPatchError(f"Patch operation {index} must be an object") + kind = str(operation.get("op") or "") + path = operation.get("path") + if kind not in {"add", "remove", "replace", "move", "copy", "test"}: + raise CdslPatchError(f"Unsupported JSON Patch operation: {kind or ''}") + if kind in {"add", "replace", "test"} and "value" not in operation: + raise CdslPatchError(f"JSON Patch {kind} requires value") + if kind in {"move", "copy"} and "from" not in operation: + raise CdslPatchError(f"JSON Patch {kind} requires from") + if path == "": + raise CdslPatchError("Replacing the complete CDSL document is not allowed; call generate_cdsl_model instead") + if kind == "add": + _add(result, path, operation["value"]) + elif kind == "remove": + _remove(result, path) + elif kind == "replace": + _get(result, path) + _remove(result, path) + _add(result, path, operation["value"]) + elif kind == "move": + source = str(operation["from"]) + if path == source or str(path).startswith(source.rstrip("/") + "/"): + raise CdslPatchError("JSON Patch move cannot move a value into itself") + moved = _remove(result, source) + _add(result, path, moved) + elif kind == "copy": + _add(result, path, _get(result, str(operation["from"]))) + elif kind == "test" and _get(result, path) != operation["value"]: + raise CdslPatchError(f"JSON Patch test failed at {path}") + return result diff --git a/backend/app/services/editing.py b/backend/app/services/editing.py index 6de32090..64bb9373 100644 --- a/backend/app/services/editing.py +++ b/backend/app/services/editing.py @@ -5,7 +5,7 @@ import json import math from typing import Any -from app.services.engine_service import build_revision +from app.services.engine_service import build_revision, load_engine from app.services.storage import WorkspaceStore from app.settings import Settings @@ -59,22 +59,35 @@ def _profile_for(operation: str, values: dict[str, Any]) -> tuple[dict[str, Any] if operation == "add_slot": width = _number(values, "slotWidth", values.get("width", 8.0)) length = _number(values, "slotLength", values.get("length", width * 3)) - return {"type": "obround", "center": [0.0, 0.0], "length_mm": max(length, width), "width_mm": width}, depth + length = max(length, width) + radius = width / 2 + left, right = -length / 2 + radius, length / 2 - radius + return {"type": "analytic_contours", "contours": [{"role": "outer", "closed": True, "segments": [ + {"type": "line", "start": [right, radius], "end": [left, radius]}, + {"type": "arc", "start": [left, radius], "end": [left, -radius], "center": [left, 0.0], "radius_mm": radius}, + {"type": "line", "start": [left, -radius], "end": [right, -radius]}, + {"type": "arc", "start": [right, -radius], "end": [right, radius], "center": [right, 0.0], "radius_mm": radius}, + ]}]}, depth if operation == "add_pocket": width = _number(values, "width", 20.0) height = _number(values, "height", 12.0) - return {"type": "rectangle", "center": [0.0, 0.0], "width_mm": width, "height_mm": height}, depth + return {"type": "polygon", "vertices": [ + [-width / 2, -height / 2], [width / 2, -height / 2], + [width / 2, height / 2], [-width / 2, height / 2], + ]}, depth if operation == "add_hole_pattern": diameter = _number(values, "holeDiameter", values.get("diameter", 6.0)) rows = max(1, int(_number(values, "rows", 2, 1))) columns = max(1, int(_number(values, "columns", 2, 1))) - return { - "type": "circle_grid", "radius_mm": diameter / 2, - "count_x": columns, "count_y": rows, - "spacing_x_mm": _number(values, "pitchX", 12.0), - "spacing_y_mm": _number(values, "pitchY", 12.0), - "center_mm": [0.0, 0.0], - }, depth + pitch_x = _number(values, "pitchX", 12.0) + pitch_y = _number(values, "pitchY", 12.0) + return {"type": "analytic_contours", "contours": [ + {"role": "outer", "closed": True, "segments": [{"type": "circle", "center": [ + (column - (columns - 1) / 2) * pitch_x, + (row - (rows - 1) / 2) * pitch_y, + ], "radius_mm": diameter / 2}]} + for row in range(rows) for column in range(columns) + ]}, depth raise ValueError(f"Unsupported direct CDSL edit: {operation}") @@ -137,6 +150,10 @@ def apply_direct_edit( operation: str, selection: dict[str, Any], parameters: dict[str, Any], + *, + node_id: str = "", + branch_id: str = "main", + visibility: str = "final", ) -> dict[str, Any]: if operation in {"add_chamfer", "add_fillet"}: raise ValueError("Chamfer and fillet require a stable CDSL edge anchor and are not available for this model yet") @@ -147,12 +164,17 @@ def apply_direct_edit( revision_id = str((task or {}).get("current_revision") or "") if source is None or not revision_id: raise ValueError("Task has no successful CDSL revision") - current_revision = next( - (item for item in (task or {}).get("revisions", []) if item.get("revision_id") == revision_id), - {}, - ) frame = _selection_frame(selection) cdsl = copy.deepcopy(json.loads(source.read_text(encoding="utf-8"))) + # Historical revisions can contain importer-only profile macros. An edit + # creates a new CDSL-only revision, so lower those profiles explicitly + # before adding the new generic feature. + load_engine(settings) + from cdsl_importer.legacy_profile_adapter import lower_legacy_profiles + + lowered_cdsl = lower_legacy_profiles(cdsl) + legacy_profiles_lowered = lowered_cdsl != cdsl + cdsl = lowered_cdsl features = cdsl.setdefault("features", []) sketches = cdsl.setdefault("geometry", {}).setdefault("sketches", []) picks = selection.get("picks") if isinstance(selection.get("picks"), list) else [] @@ -187,10 +209,15 @@ def apply_direct_edit( reference_ids=[], summary=f"Applied {operation}", parent_revision_id=revision_id, - operation={"type": operation, "selection": selection, "parameters": parameters}, + operation={ + "type": operation, + "selection": selection, + "parameters": parameters, + "legacy_profiles_lowered": legacy_profiles_lowered, + }, part_skills=None, - generation_assumptions=[], - design_intent_id=str(current_revision.get("design_intent_id") or ""), - design_intent_path=str(current_revision.get("design_intent_path") or ""), - design_intent_status="accepted" if current_revision.get("design_intent_id") else "", + generation_assumptions=["Legacy profile macros were lowered to direct analytic contours before this edit."] if legacy_profiles_lowered else [], + node_id=node_id, + branch_id=branch_id, + visibility=visibility, ) diff --git a/backend/app/services/engine_service.py b/backend/app/services/engine_service.py index f2f3d274..698435a5 100644 --- a/backend/app/services/engine_service.py +++ b/backend/app/services/engine_service.py @@ -4,6 +4,7 @@ import copy import json import math import re +import shutil import sys from pathlib import Path from typing import Any @@ -12,10 +13,22 @@ from jsonschema import Draft202012Validator from jsonschema.exceptions import SchemaError from vendor.cdsl_preview_runtime import step_to_glb +from app.services.quality import evaluate_quality, validate_verification from app.services.storage import WorkspaceStore, now_iso, write_json +from app.services.cdsl_fragment import selector_bindings from app.settings import Settings +class QualityVerificationError(RuntimeError): + """A built CDSL document missed a blocking generic verification rule.""" + + def __init__(self, quality_report: dict[str, Any]) -> None: + super().__init__("Blocking CDSL verification checks failed") + self.quality_report = quality_report + self.task_id = "" + self.revision_id = "" + + def load_engine(settings: Settings) -> Any: parent = str(settings.engine_root.parent) if parent not in sys.path: @@ -75,6 +88,76 @@ def _validate_cdsl_json_schema(cdsl: dict[str, Any], engine: Any) -> None: raise ValueError(f"CDSL schema violation at {location}: {error.message}") +def _legacy_workplane(plane: str, offset: Any) -> dict[str, list[float]] | None: + if isinstance(offset, bool) or not isinstance(offset, (int, float)): + return None + distance = float(offset) + definitions = { + "XY": ([0.0, 0.0, distance], [1.0, 0.0, 0.0], [0.0, 0.0, 1.0]), + "XZ": ([0.0, distance, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]), + "YZ": ([distance, 0.0, 0.0], [0.0, 1.0, 0.0], [1.0, 0.0, 0.0]), + } + definition = definitions.get(plane.upper()) + if definition is None: + return None + origin, x_dir, normal = definition + return {"origin_mm": origin, "x_dir": x_dir, "normal": normal} + + +def normalize_cdsl_for_engine(cdsl: dict[str, Any]) -> tuple[dict[str, Any], list[str]]: + """Convert unambiguous legacy LLM aliases into the current CDSL dialect. + + This intentionally does not infer dimensions, selectors, or feature + dependencies. Any non-mechanical error remains visible to the validator. + """ + normalized = copy.deepcopy(cdsl) + repairs: list[str] = [] + geometry = normalized.get("geometry") + sketches = geometry.get("sketches") if isinstance(geometry, dict) else None + if isinstance(sketches, list): + for index, sketch in enumerate(sketches): + if not isinstance(sketch, dict): + continue + if "id" not in sketch and isinstance(sketch.get("sketch_id"), str): + sketch["id"] = sketch.pop("sketch_id") + repairs.append(f"geometry.sketches[{index}]: sketch_id -> id") + + legacy_plane: Any = sketch.get("plane") + legacy_offset: Any = sketch.get("offset_mm", 0) + workplane_value = sketch.get("workplane") + if isinstance(workplane_value, dict) and "origin_mm" not in workplane_value: + legacy_plane = workplane_value.get("plane") + legacy_offset = workplane_value.get("offset_mm", 0) + elif "workplane" in sketch: + continue + + workplane = _legacy_workplane(legacy_plane, legacy_offset) if isinstance(legacy_plane, str) else None + if workplane is None: + continue + sketch["workplane"] = workplane + sketch.pop("plane", None) + sketch.pop("offset_mm", None) + repairs.append(f"geometry.sketches[{index}]: legacy plane/offset_mm -> workplane") + + features = normalized.get("features") + if isinstance(features, list): + for index, feature in enumerate(features): + if not isinstance(feature, dict): + continue + if "sketch_id" not in feature and isinstance(feature.get("sketch"), str): + feature["sketch_id"] = feature.pop("sketch") + repairs.append(f"features[{index}]: sketch -> sketch_id") + if "depends_on" not in feature: + feature["depends_on"] = [] + repairs.append(f"features[{index}]: added empty depends_on") + params = feature.get("params") + axis = params.get("axis") if isinstance(params, dict) else None + if isinstance(axis, dict) and "origin_mm" not in axis and "point_mm" in axis: + axis["origin_mm"] = axis.pop("point_mm") + repairs.append(f"features[{index}].params.axis: point_mm -> origin_mm") + return normalized, repairs + + def validate_cdsl(cdsl: dict[str, Any], engine: Any) -> None: if not isinstance(cdsl, dict): raise ValueError("CDSL must be a JSON object") @@ -95,10 +178,12 @@ def validate_cdsl(cdsl: dict[str, Any], engine: Any) -> None: sketch_ids = {str(sketch.get("id")) for sketch in sketches} semantic_contract = _engine_schema(engine) atomic_contracts = semantic_contract["feature_atomic_ids"] - supported_atomic_ids = sorted( + declared_atomic_ids = { str(atomic_id) for atomic_id in semantic_contract.get("runtime_supported_atomic_ids", atomic_contracts) - ) + } + registered_atomic_ids = {str(atomic_id) for atomic_id in getattr(engine, "SUPPORTED_ATOMIC_IDS", ())} + supported_atomic_ids = sorted(declared_atomic_ids & registered_atomic_ids) feature_ids: set[str] = set() for feature in features: fid = str(feature.get("id") or "") @@ -215,7 +300,140 @@ def parameter_contract(cdsl: dict[str, Any]) -> dict[str, Any]: return {"schema_version": "1.0", "parameters": _derived_parameters(cdsl), "source": "derived"} -def topology_sidecars(engine_result: dict[str, Any], preview: dict[str, Any] | None = None) -> tuple[dict[str, Any], dict[str, Any]]: +def topology_snapshot( + engine_result: dict[str, Any], + *, + task_id: str = "", + revision_id: str = "", + preview: dict[str, Any] | None = None, +) -> dict[str, Any]: + raw_records = [ + raw for raw in engine_result.get("topology_records") or () + if isinstance(raw, dict) and raw.get("record_id") and raw.get("kind") + ] + active_body_id = next( + ( + str(result.get("body_id")) + for result in reversed(engine_result.get("feature_results") or ()) + if isinstance(result, dict) and result.get("body_id") + ), + "", + ) + if not active_body_id: + active_body_id = next( + ( + str(raw.get("body_id")) + for raw in reversed(raw_records) + if raw.get("kind") == "body" and raw.get("body_id") + ), + "", + ) + + # The runtime retains historical B-rep records for provenance, but only + # the final body can resolve face, edge, vertex, and body selectors. + active_records = [ + raw for raw in raw_records + if not active_body_id + or raw.get("kind") in {"plane", "axis"} + or str(raw.get("body_id") or "") == active_body_id + ] + records: list[dict[str, Any]] = [] + for raw in active_records: + kind = str(raw.get("kind")) + records.append({ + "record_id": str(raw["record_id"]), + "kind": kind, + "feature_id": str(raw.get("feature_id") or ""), + "body_id": str(raw.get("body_id") or "") or None, + "owner_feature_ids": [str(item) for item in raw.get("owner_feature_ids") or () if str(item)], + "geometry": copy.deepcopy(raw.get("geometry") or {}), + "executable": kind in {"body", "face", "edge", "vertex", "plane", "axis"}, + "synthetic": False, + }) + # Preview/B-rep fallback faces are useful for visual explanation only. + # Keep them in the unified audit snapshot, but never expose them as + # executable selector candidates. + if not any(item.get("kind") == "face" for item in records): + for index, raw in enumerate((preview or {}).get("topology_faces") or ()): + if not isinstance(raw, dict): + continue + record_id = str(raw.get("id") or f"synthetic:face:{index}") + center = raw.get("center") + normal = raw.get("normal") + raw_bbox = raw.get("bbox") + if isinstance(raw_bbox, dict) and isinstance(raw_bbox.get("min"), list) and isinstance(raw_bbox.get("max"), list): + raw_bbox = [*raw_bbox["min"], *raw_bbox["max"]] + geometry = { + "surface_type": str(raw.get("surface_type") or "unknown"), + "center_mm": copy.deepcopy(center) if isinstance(center, list) else None, + "normal": copy.deepcopy(normal) if isinstance(normal, list) else None, + "bbox_mm": copy.deepcopy(raw_bbox or {}), + } + records.append({ + "record_id": record_id, + "kind": "face", + "feature_id": "", + "body_id": None, + "owner_feature_ids": [], + "geometry": geometry, + "executable": False, + "synthetic": True, + }) + return { + "schema_version": "cad.topology.v1", + "task_id": task_id, + "revision_id": revision_id, + "snapshot_id": f"{task_id}/{revision_id}" if task_id and revision_id else "", + "body_id": active_body_id, + "records": records, + } + + +def topology_sidecars( + engine_result: dict[str, Any], + preview: dict[str, Any] | None = None, + *, + snapshot: dict[str, Any] | None = None, +) -> tuple[dict[str, Any], dict[str, Any]]: + runtime_records = (snapshot or topology_snapshot(engine_result)).get("records") or [] + runtime_faces = [item for item in runtime_records if item.get("kind") == "face" and item.get("executable", True)] + runtime_edges = [item for item in runtime_records if item.get("kind") == "edge" and item.get("executable", True)] + if runtime_faces or runtime_edges: + references = [] + for record in runtime_faces: + geometry = record.get("geometry") or {} + references.append({ + "id": str(record.get("record_id")), + "selectorType": "face", + "label": str(geometry.get("surface_type") or "face"), + "center": geometry.get("center_mm"), + "normal": geometry.get("normal"), + "frame": { + "origin_mm": geometry.get("center_mm"), + "normal": geometry.get("normal"), + "x_dir": [1, 0, 0], + "y_dir": [0, 1, 0], + }, + "bbox": geometry.get("bbox_mm") or {}, + "surface_type": str(geometry.get("surface_type") or "unknown"), + "owner_feature_ids": record.get("owner_feature_ids") or [], + "source": "runtime_snapshot", + "snapshot_id": (snapshot or {}).get("snapshot_id") or "", + "executable": True, + }) + edge_records = [ + { + **record, + "selectorType": "edge", + "source": "runtime_snapshot", + "snapshot_id": (snapshot or {}).get("snapshot_id") or "", + } + for record in runtime_edges + ] + return ( + {"schema_version": "cad.topology.v1", "references": references, "edges": edge_records}, + {"schema_version": "cad.topology.v1", "edges": edge_records}, + ) topology_faces = (preview or {}).get("topology_faces") if isinstance(topology_faces, list) and topology_faces: references = [] @@ -238,6 +456,9 @@ def topology_sidecars(engine_result: dict[str, Any], preview: dict[str, Any] | N "surface_type": str(face.get("surface_type") or "unknown"), "triangle_start": int(face.get("triangle_start") or 0), "triangle_count": int(face.get("triangle_count") or 0), + "source": "preview", + "synthetic": True, + "executable": False, }) if references: return ({"schema_version": "1.1", "references": references}, {"schema_version": "1.0", "edges": []}) @@ -262,6 +483,9 @@ def topology_sidecars(engine_result: dict[str, Any], preview: dict[str, Any] | N "center": point, "normal": normal, "frame": {"origin_mm": point, "normal": normal, "x_dir": x_dir, "y_dir": y_dir}, "bbox": {"min": minimum, "max": maximum}, + "source": "bbox_fallback", + "synthetic": True, + "executable": False, } for name, point, normal, x_dir, y_dir in definitions ] @@ -330,7 +554,6 @@ def _part_skill_audit( skill_ids = [str(item) for item in audit.get("skill_ids") or [] if str(item)] if not skill_ids: skill_ids = [str(item.get("id")) for item in skills if item.get("id")] - evidence = audit.get("evidence") if isinstance(audit.get("evidence"), dict) else {} audit.update({ "schema_version": str(audit.get("schema_version") or "1.0"), "request": str(audit.get("request") or request), @@ -339,10 +562,6 @@ def _part_skill_audit( "skills": skills, "inherited_skill_ids": [str(item) for item in audit.get("inherited_skill_ids") or [] if str(item)], "assumptions": [str(item) for item in generation_assumptions or []], - "evidence": evidence, - "capability_translations": [ - item for item in audit.get("capability_translations") or [] if isinstance(item, dict) - ], }) return audit @@ -350,20 +569,9 @@ def _part_skill_audit( def _generation_context( reference_ids: list[str], part_skill_audit: dict[str, Any], - design_intent: dict[str, Any] | None = None, - design_intent_path: str = "", - design_intent_id: str = "", ) -> dict[str, Any]: skills = [item for item in part_skill_audit.get("skills") or [] if isinstance(item, dict)] - intent = design_intent if isinstance(design_intent, dict) else {} return { - "design_intent_id": str(intent.get("intent_id") or design_intent_id or ""), - "design_intent_path": design_intent_path, - "design_intent_structures": [ - str(item.get("id") or "") - for item in intent.get("structures") or [] - if isinstance(item, dict) and item.get("id") - ], "cdsl_reference_ids": list(reference_ids), "part_skill_ids": list(part_skill_audit.get("skill_ids") or []), "part_skill_paths": [ @@ -375,9 +583,6 @@ def _generation_context( for item in skills ], "generation_assumptions": list(part_skill_audit.get("assumptions") or []), - "design_intent_assumptions": list(part_skill_audit.get("design_intent_assumptions") or []), - "design_intent_capability_gaps": list(part_skill_audit.get("design_intent_capability_gaps") or []), - "capability_translations": list(part_skill_audit.get("capability_translations") or []), } @@ -392,13 +597,17 @@ def build_revision( summary: str, parent_revision_id: str | None = None, operation: dict[str, Any] | None = None, - attachments: list[dict[str, Any]] | None = None, + input_attachments: list[dict[str, Any]] | None = None, part_skills: dict[str, Any] | None = None, generation_assumptions: list[str] | None = None, - design_intent: dict[str, Any] | None = None, - design_intent_path: str = "", - design_intent_id: str = "", - design_intent_status: str = "", + repair_attempts: int = 0, + verification: dict[str, Any] | None = None, + reference_records: list[dict[str, Any]] | None = None, + feature_plan: dict[str, Any] | None = None, + node_id: str = "", + fragment: dict[str, Any] | None = None, + branch_id: str = "main", + visibility: str = "final", ) -> dict[str, Any]: engine = load_engine(settings) task = store.ensure_task(task_id, request) @@ -412,31 +621,25 @@ def build_revision( parameters_path = revision_dir / "parameters.json" selector_path = revision_dir / "model.selector.json" edges_path = revision_dir / "model.edges.json" + topology_path = revision_dir / "model.topology.json" part_skills_path = revision_dir / "part-skills.json" - intent = copy.deepcopy(design_intent) if isinstance(design_intent, dict) else {} - intent_assumptions = [str(item) for item in intent.get("assumptions") or [] if str(item)] - merged_assumptions = list(dict.fromkeys([ - *intent_assumptions, - *(str(item) for item in generation_assumptions or [] if str(item)), - ])) - part_skill_audit = _part_skill_audit(part_skills, request, merged_assumptions) - intent_id = str(intent.get("intent_id") or design_intent_id or "") - if design_intent_path: - design_intent_path = str(design_intent_path) - part_skill_audit.update({ - "design_intent_id": intent_id, - "design_intent_path": design_intent_path, - "intent_status": str(intent.get("status") or design_intent_status or ""), - "design_intent_assumptions": intent_assumptions, - "design_intent_capability_gaps": [ - item for item in intent.get("capability_gaps") or [] if isinstance(item, dict) - ], - }) - generation_context = _generation_context(reference_ids, part_skill_audit, intent, design_intent_path, intent_id) + quality_path = revision_dir / "quality-report.json" + snapshot_manifest_path = revision_dir / "snapshot-manifest.json" + fragment_path = revision_dir / "fragment.json" + selector_bindings_path = revision_dir / "selector-bindings.json" + part_skill_audit = _part_skill_audit(part_skills, request, generation_assumptions) + generation_context = _generation_context(reference_ids, part_skill_audit) write_json(request_path, {"request": request, "created_at": now_iso()}) - write_json(references_path, {"reference_ids": reference_ids}) + if isinstance(feature_plan, dict): + store.write_feature_plan(task["task_id"], feature_plan) + write_json(references_path, {"reference_ids": reference_ids, "records": [item for item in reference_records or [] if isinstance(item, dict)]}) write_json(part_skills_path, part_skill_audit) - + write_json(snapshot_manifest_path, { + "schema_version": "1.0", + "status": "unavailable", + "reason": "Snapshot runner is not attached to this backend build", + "snapshots": [], + }) def revision_record(status: str, *, error: str = "", engine_name: str = "") -> dict[str, Any]: record = { "revision_id": revision_id, @@ -446,16 +649,25 @@ def build_revision( "cdsl_path": cdsl_path.relative_to(store.task_dir(task["task_id"])).as_posix(), "report_path": report_path.relative_to(store.task_dir(task["task_id"])).as_posix(), "parameters_path": parameters_path.relative_to(store.task_dir(task["task_id"])).as_posix(), + "topology_path": topology_path.relative_to(store.task_dir(task["task_id"])).as_posix(), "part_skills_path": part_skills_path.relative_to(store.task_dir(task["task_id"])).as_posix(), - "design_intent_id": intent_id, - "design_intent_path": design_intent_path, + "quality_path": quality_path.relative_to(store.task_dir(task["task_id"])).as_posix(), + "snapshot_manifest_path": snapshot_manifest_path.relative_to(store.task_dir(task["task_id"])).as_posix(), + "snapshot_status": "unavailable", + "snapshot_paths": [snapshot_manifest_path.relative_to(store.task_dir(task["task_id"])).as_posix()], "part_skill_ids": list(part_skill_audit.get("skill_ids") or []), "generation_assumptions": list(part_skill_audit.get("assumptions") or []), "reference_ids": reference_ids, "summary": summary, "parent_revision_id": parent_revision_id or "", "operation": operation or {}, - "attachments": attachments or [], + "input_attachments": input_attachments or [], + "repair_attempts": max(0, int(repair_attempts)), + "branch_id": branch_id or "main", + "visibility": visibility if visibility in {"checkpoint", "final", "superseded"} else "checkpoint", + "node_id": node_id, + "fragment_path": fragment_path.relative_to(store.task_dir(task["task_id"])).as_posix() if fragment else "", + "selector_bindings_path": selector_bindings_path.relative_to(store.task_dir(task["task_id"])).as_posix(), } if status == "success": record.update({ @@ -463,12 +675,15 @@ def build_revision( "glb_path": glb_path.relative_to(store.task_dir(task["task_id"])).as_posix(), "selector_path": selector_path.relative_to(store.task_dir(task["task_id"])).as_posix(), "edges_path": edges_path.relative_to(store.task_dir(task["task_id"])).as_posix(), + "topology_path": topology_path.relative_to(store.task_dir(task["task_id"])).as_posix(), "engine": engine_name, }) else: record["error"] = error return record + quality_report: dict[str, Any] | None = None + quality_status = "" try: cdsl_copy = copy.deepcopy(cdsl) if not isinstance(cdsl_copy, dict): @@ -480,6 +695,8 @@ def build_revision( if not isinstance(meta.get("editable_parameters"), list) or not meta["editable_parameters"]: meta["editable_parameters"] = _derived_parameters(cdsl_copy) write_json(cdsl_path, cdsl_copy) + if fragment is not None: + write_json(fragment_path, fragment) write_json(parameters_path, parameter_contract(cdsl_copy)) validate_cdsl(cdsl_copy, engine) # Product revisions are semantic CDSL artifacts. Do not route them @@ -489,9 +706,27 @@ def build_revision( if engine_result.get("engine") != "cdsl_only" or not step_path.is_file() or step_path.stat().st_size == 0: raise RuntimeError("Engine did not produce a CDSL-only STEP artifact") preview = step_to_glb(step_path, glb_path) - selector, edges = topology_sidecars(engine_result, preview) + snapshot = topology_snapshot(engine_result, task_id=task["task_id"], revision_id=revision_id, preview=preview) + write_json(topology_path, snapshot) + write_json(selector_bindings_path, selector_bindings( + engine_result, + node_id=node_id, + snapshot_id=str(snapshot.get("snapshot_id") or ""), + )) + selector, edges = topology_sidecars(engine_result, preview, snapshot=snapshot) write_json(selector_path, selector) write_json(edges_path, edges) + rules = validate_verification(verification, cdsl_copy) + quality_report = evaluate_quality(rules, cdsl_copy, engine_result) + quality_report["evaluated_at"] = now_iso() + write_json(quality_path, quality_report) + quality_status = ( + "accepted" if rules and quality_report["status"] == "passed" + else "built_with_warnings" if quality_report["status"] == "passed" + else "needs_repair" + ) + if quality_report["status"] != "passed": + raise QualityVerificationError(quality_report) report = { "engine_result": engine_result, "preview": preview, @@ -500,16 +735,40 @@ def build_revision( } write_json(report_path, report) revision = revision_record("success", engine_name=str(engine_result["engine"])) + revision["quality_status"] = quality_status or "accepted" + revision["verification_summary"] = { + "requested": bool(rules), + "blocking_failures": len(quality_report.get("blocking_failures") or []), + "warnings": len(quality_report.get("warnings") or []), + } except Exception as error: + if not isinstance(error, QualityVerificationError): + # The failed candidate is retained in the conversation diagnostics, + # not as a task revision. Runtime/schema failures must not create + # an editable revision that looks like a model version. + shutil.rmtree(revision_dir, ignore_errors=True) + raise if not cdsl_path.is_file() and isinstance(cdsl, dict): write_json(cdsl_path, copy.deepcopy(cdsl)) + if quality_report is not None and not quality_path.is_file(): + write_json(quality_path, quality_report) write_json(report_path, { "error": str(error), "generation_context": generation_context, + "quality": quality_report, "validated_at": now_iso(), }) - revision = revision_record("failed", error=str(error)) + revision = revision_record("needs_repair" if quality_report is not None else "failed", error=str(error)) + revision["quality_status"] = quality_status or ("needs_repair" if quality_report else "failed") + if quality_report is not None: + revision["verification_summary"] = { + "requested": bool(quality_report.get("verification_requested")), + "blocking_failures": len(quality_report.get("blocking_failures") or []), + "warnings": len(quality_report.get("warnings") or []), + } store.update_task(task["task_id"], revision) + error.task_id = task["task_id"] + error.revision_id = revision_id raise store.update_task(task["task_id"], revision) return {"task_id": task["task_id"], **revision} diff --git a/backend/app/services/feature_plan.py b/backend/app/services/feature_plan.py new file mode 100644 index 00000000..a3e3810f --- /dev/null +++ b/backend/app/services/feature_plan.py @@ -0,0 +1,222 @@ +"""Deterministic feature-plan validation and readiness calculations.""" + +from __future__ import annotations + +from collections import defaultdict, deque +from copy import deepcopy +from typing import Any, Iterable + + +PLAN_SCHEMA_VERSION = "cad.feature-plan.v1" +NODE_STATUSES = { + "planned", + "ready", + "waiting_for_topology", + "waiting_for_selection", + "blocked", + "executing", + "executed", + "failed", + "completed", +} + +_TOPOLOGY_REQUIRED_ATOMICS = { + "fillet", + "chamfer", + "hole_blind", + "hole_countersink", + "hole_counterbore", + "pattern_mirror", +} + + +class FeaturePlanError(ValueError): + """A feature plan is not a valid acyclic executable plan.""" + + +def _text(value: Any, field: str, *, required: bool = True) -> str: + result = str(value or "").strip() + if required and not result: + raise FeaturePlanError(f"{field} is required") + return result + + +def _bool(value: Any) -> bool: + return value is True + + +def _normalise_node(raw: Any, index: int) -> dict[str, Any]: + if not isinstance(raw, dict): + raise FeaturePlanError(f"nodes[{index}] must be an object") + node_id = _text(raw.get("id"), f"nodes[{index}].id") + atomic_id = _text(raw.get("atomic_id"), f"nodes[{index}].atomic_id") + depends_on = raw.get("depends_on") or [] + if not isinstance(depends_on, list) or not all(isinstance(item, str) and item.strip() for item in depends_on): + raise FeaturePlanError(f"nodes[{index}].depends_on must be an array of non-empty strings") + feature_ids = raw.get("cdsl_feature_ids") + if feature_ids is None: + feature_ids = [node_id] + if not isinstance(feature_ids, list) or not feature_ids or not all(isinstance(item, str) and item.strip() for item in feature_ids): + raise FeaturePlanError(f"nodes[{index}].cdsl_feature_ids must be a non-empty string array") + query = raw.get("topology_query") + if query is not None and not isinstance(query, dict): + raise FeaturePlanError(f"nodes[{index}].topology_query must be an object") + requires_topology = _bool(raw.get("requires_topology")) or atomic_id in _TOPOLOGY_REQUIRED_ATOMICS + status = str(raw.get("status") or "planned") + if status not in NODE_STATUSES: + raise FeaturePlanError(f"nodes[{index}].status is unsupported: {status}") + node = { + "id": node_id, + "intent": _text(raw.get("intent"), f"nodes[{index}].intent", required=False), + "atomic_id": atomic_id, + "depends_on": list(dict.fromkeys(item.strip() for item in depends_on)), + "requires_topology": requires_topology, + "topology_query": deepcopy(query) if query is not None else None, + "status": status, + "cdsl_feature_ids": list(dict.fromkeys(item.strip() for item in feature_ids)), + } + if raw.get("selector_required") is not None: + node["selector_required"] = _bool(raw.get("selector_required")) + if isinstance(raw.get("failure"), dict): + node["failure"] = deepcopy(raw["failure"]) + return node + + +def validate_feature_plan(plan: dict[str, Any], *, supported_atomic_ids: Iterable[str] = ()) -> dict[str, Any]: + if not isinstance(plan, dict): + raise FeaturePlanError("Feature plan must be an object") + version = str(plan.get("schema_version") or PLAN_SCHEMA_VERSION) + if version != PLAN_SCHEMA_VERSION: + raise FeaturePlanError(f"Unsupported feature plan schema: {version}") + plan_id = _text(plan.get("plan_id"), "plan_id") + task_id = _text(plan.get("task_id"), "task_id", required=False) + topology_snapshot_id = _text(plan.get("topology_snapshot_id"), "topology_snapshot_id", required=False) + raw_nodes = plan.get("nodes") + if not isinstance(raw_nodes, list) or not raw_nodes: + raise FeaturePlanError("Feature plan requires a non-empty nodes array") + nodes = [_normalise_node(item, index) for index, item in enumerate(raw_nodes)] + by_id: dict[str, dict[str, Any]] = {} + node_index: dict[str, int] = {} + feature_owner: dict[str, str] = {} + supported = {str(item) for item in supported_atomic_ids if str(item)} + for index, node in enumerate(nodes): + if node["id"] in by_id: + raise FeaturePlanError(f"Duplicate feature plan node: {node['id']}") + if supported and node["atomic_id"] not in supported: + raise FeaturePlanError(f"Unsupported feature plan atomic_id: {node['atomic_id']}") + by_id[node["id"]] = node + node_index[node["id"]] = index + for feature_id in node["cdsl_feature_ids"]: + if feature_id in feature_owner: + raise FeaturePlanError(f"CDSL feature belongs to multiple plan nodes: {feature_id}") + feature_owner[feature_id] = node["id"] + indegree = {node_id: 0 for node_id in by_id} + children: dict[str, list[str]] = defaultdict(list) + for node in nodes: + for dependency in node["depends_on"]: + if dependency not in by_id: + raise FeaturePlanError(f"Node {node['id']} has missing dependency: {dependency}") + if node_index[dependency] >= node_index[node["id"]]: + raise FeaturePlanError(f"Node {node['id']} must appear after dependency: {dependency}") + indegree[node["id"]] += 1 + children[dependency].append(node["id"]) + queue = deque(node_id for node_id, degree in indegree.items() if degree == 0) + visited: list[str] = [] + while queue: + node_id = queue.popleft() + visited.append(node_id) + for child in children[node_id]: + indegree[child] -= 1 + if indegree[child] == 0: + queue.append(child) + if len(visited) != len(nodes): + raise FeaturePlanError("Feature plan contains a dependency cycle") + return { + "schema_version": PLAN_SCHEMA_VERSION, + "plan_id": plan_id, + "task_id": task_id, + "topology_snapshot_id": topology_snapshot_id, + "nodes": nodes, + "feature_owner": feature_owner, + } + + +def _topology_available(topology: dict[str, Any] | None) -> bool: + if not isinstance(topology, dict): + return False + return any( + isinstance(record, dict) + and record.get("executable", True) is not False + and record.get("kind") in {"face", "edge", "vertex", "body", "plane", "axis"} + for record in topology.get("records") or () + ) + + +def compute_node_statuses( + plan: dict[str, Any], + *, + cdsl: dict[str, Any] | None = None, + topology: dict[str, Any] | None = None, +) -> dict[str, Any]: + """Return a copy with deterministic status and readiness information.""" + checked = validate_feature_plan(plan) + present_features = { + str(feature.get("id")) + for feature in (cdsl or {}).get("features") or () + if isinstance(feature, dict) and feature.get("id") + } + has_topology = _topology_available(topology) + topology_snapshot_id = str((topology or {}).get("snapshot_id") or "") + selection_ready = bool(topology_snapshot_id and checked.get("topology_snapshot_id") == topology_snapshot_id) + by_id = {node["id"]: node for node in checked["nodes"]} + result_nodes: list[dict[str, Any]] = [] + for original in checked["nodes"]: + node = deepcopy(original) + if node["status"] in {"failed", "blocked"}: + result_nodes.append(node) + continue + if all(feature_id in present_features for feature_id in node["cdsl_feature_ids"]): + node["status"] = "completed" + result_nodes.append(node) + continue + dependencies_done = all(by_id[item]["status"] in {"executed", "completed"} or all( + feature_id in present_features for feature_id in by_id[item]["cdsl_feature_ids"] + ) for item in node["depends_on"]) + if not dependencies_done: + node["status"] = "planned" + elif node["requires_topology"] and not has_topology: + node["status"] = "waiting_for_topology" + elif node["requires_topology"] and not selection_ready: + node["status"] = "waiting_for_selection" + else: + node["status"] = "ready" + result_nodes.append(node) + ready = [node["id"] for node in result_nodes if node["status"] == "ready"] + waiting = [node["id"] for node in result_nodes if node["status"] in {"waiting_for_topology", "waiting_for_selection"}] + blocked = [node["id"] for node in result_nodes if node["status"] == "blocked"] + completed = [node["id"] for node in result_nodes if node["status"] == "completed"] + return { + **checked, + "nodes": result_nodes, + "ready_nodes": ready, + "waiting_nodes": waiting, + "blocked_nodes": blocked, + "completed_nodes": completed, + "complete": len(completed) == len(result_nodes) and not blocked, + } + + +def plan_feature_ids(plan: dict[str, Any], node_ids: Iterable[str]) -> set[str]: + checked = validate_feature_plan(plan) + wanted = set(node_ids) + return { + feature_id + for node in checked["nodes"] + if node["id"] in wanted + for feature_id in node["cdsl_feature_ids"] + } + + +def node_for_feature(plan: dict[str, Any], feature_id: str) -> dict[str, Any] | None: + checked = validate_feature_plan(plan) + return next((node for node in checked["nodes"] if feature_id in node["cdsl_feature_ids"]), None) diff --git a/backend/app/services/generation_plan.py b/backend/app/services/generation_plan.py new file mode 100644 index 00000000..14ae27e2 --- /dev/null +++ b/backend/app/services/generation_plan.py @@ -0,0 +1,228 @@ +"""Strict contracts for persistent, node-by-node CAD generation.""" + +from __future__ import annotations + +from collections import defaultdict, deque +from copy import deepcopy +from hashlib import sha256 +import re +from typing import Any, Iterable + +from app.services.feature_plan import FeaturePlanError, validate_feature_plan + + +GENERATION_PLAN_SCHEMA_VERSION = "cad.generation-plan.v2" +BACKEND_ID_STRATEGY = "backend-derived-v1" +REQUIREMENT_SOURCES = {"explicit", "assumption"} +REQUIREMENT_PRIORITIES = {"hard", "soft"} +# Keep this in sync with the engine's profile_schema.json. The plan is +# deliberately atomic: an operation that consumes a profile owns one new +# sketch, while all other operations own none. +SKETCH_REQUIRED_ATOMICS = { + "extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind", + "revolve_add", "revolve_cut", "hole_blind", "hole_countersink", + "hole_counterbore", "sphere_add", +} + + +class GenerationPlanError(ValueError): + """The authoring plan cannot safely drive an incremental build.""" + + +def _text(value: Any, field: str, *, required: bool = True) -> str: + result = str(value or "").strip() + if required and not result: + raise GenerationPlanError(f"{field} is required") + return result + + +def _string_list(value: Any, field: str, *, required: bool = False) -> list[str]: + if value is None: + value = [] + if not isinstance(value, list) or not all(isinstance(item, str) and item.strip() for item in value): + raise GenerationPlanError(f"{field} must be an array of non-empty strings") + result = list(dict.fromkeys(item.strip() for item in value)) + if required and not result: + raise GenerationPlanError(f"{field} must not be empty") + return result + + +def _normalise_requirement(raw: Any, index: int) -> dict[str, Any]: + if not isinstance(raw, dict): + raise GenerationPlanError(f"requirements[{index}] must be an object") + source = _text(raw.get("source") or "assumption", f"requirements[{index}].source") + priority = _text(raw.get("priority") or "hard", f"requirements[{index}].priority") + if source not in REQUIREMENT_SOURCES: + raise GenerationPlanError(f"requirements[{index}].source is unsupported: {source}") + if priority not in REQUIREMENT_PRIORITIES: + raise GenerationPlanError(f"requirements[{index}].priority is unsupported: {priority}") + return { + "id": _text(raw.get("id"), f"requirements[{index}].id"), + "source": source, + "priority": priority, + "description": _text(raw.get("description"), f"requirements[{index}].description"), + "value": deepcopy(raw.get("value")), + "unit": _text(raw.get("unit"), f"requirements[{index}].unit", required=False), + "tolerance": deepcopy(raw.get("tolerance")), + } + + +def _backend_cdsl_id(kind: str, node_id: str) -> str: + """Create a valid, stable CDSL identifier without trusting model naming.""" + slug = re.sub(r"[^A-Za-z0-9_-]+", "_", node_id).strip("_-").lower() or "node" + digest = sha256(node_id.encode("utf-8")).hexdigest()[:8] + return f"{kind}_{slug[:60]}_{digest}" + + +def _backend_node_outputs(node_id: str, atomic_id: str) -> tuple[list[str], list[str]]: + feature_ids = [_backend_cdsl_id("feature", node_id)] + sketch_ids = [_backend_cdsl_id("sketch", node_id)] if atomic_id in SKETCH_REQUIRED_ATOMICS else [] + return feature_ids, sketch_ids + + +def _stored_plan_ids(raw: dict[str, Any]) -> bool: + """Retain IDs of plans already materialised by an earlier backend version.""" + return str(raw.get("id_strategy") or "") == BACKEND_ID_STRATEGY or isinstance(raw.get("feature_owner"), dict) + + +def validate_generation_plan( + document: dict[str, Any], + *, + supported_atomic_ids: Iterable[str] = (), + task_id: str = "", +) -> dict[str, Any]: + """Normalise a planner response and prove every hard requirement is owned.""" + if not isinstance(document, dict): + raise GenerationPlanError("Generation plan must be an object") + version = str(document.get("schema_version") or GENERATION_PLAN_SCHEMA_VERSION) + if version != GENERATION_PLAN_SCHEMA_VERSION: + raise GenerationPlanError(f"Unsupported generation plan schema: {version}") + requirements_raw = document.get("requirements") + if not isinstance(requirements_raw, list) or not requirements_raw: + raise GenerationPlanError("Generation plan requires a non-empty requirements array") + requirements = [_normalise_requirement(item, index) for index, item in enumerate(requirements_raw)] + requirement_ids = [item["id"] for item in requirements] + if len(requirement_ids) != len(set(requirement_ids)): + raise GenerationPlanError("Generation plan has duplicate requirement ids") + + raw_nodes = document.get("nodes") + if not isinstance(raw_nodes, list) or not raw_nodes: + raise GenerationPlanError("Generation plan requires a non-empty nodes array") + preserve_stored_ids = _stored_plan_ids(document) + feature_nodes: list[dict[str, Any]] = [] + node_metadata: dict[str, dict[str, Any]] = {} + sketch_owner: dict[str, str] = {} + for index, raw in enumerate(raw_nodes): + if not isinstance(raw, dict): + raise GenerationPlanError(f"nodes[{index}] must be an object") + node_id = _text(raw.get("id"), f"nodes[{index}].id") + atomic_id = _text(raw.get("atomic_id"), f"nodes[{index}].atomic_id") + if preserve_stored_ids: + feature_ids = _string_list(raw.get("cdsl_feature_ids"), f"nodes[{index}].cdsl_feature_ids", required=True) + sketch_ids = _string_list(raw.get("cdsl_sketch_ids"), f"nodes[{index}].cdsl_sketch_ids") + else: + # New plans own semantic node IDs only. CDSL object IDs are a + # deterministic backend implementation detail, not model output. + feature_ids, sketch_ids = _backend_node_outputs(node_id, atomic_id) + for sketch_id in sketch_ids: + previous = sketch_owner.get(sketch_id) + if previous: + raise GenerationPlanError(f"CDSL sketch belongs to multiple plan nodes: {sketch_id} ({previous}, {node_id})") + sketch_owner[sketch_id] = node_id + coverage = _string_list(raw.get("requirement_ids"), f"nodes[{index}].requirement_ids") + unknown = sorted(set(coverage) - set(requirement_ids)) + if unknown: + raise GenerationPlanError(f"Node {node_id} references unknown requirements: {', '.join(unknown)}") + rules = raw.get("verification_rules") or [] + if not isinstance(rules, list) or not all(isinstance(item, dict) for item in rules): + raise GenerationPlanError(f"nodes[{index}].verification_rules must be an array of objects") + targets = raw.get("review_targets") or [] + if not isinstance(targets, list) or not all(isinstance(item, dict) for item in targets): + raise GenerationPlanError(f"nodes[{index}].review_targets must be an array of objects") + feature_nodes.append({ + "id": node_id, + "intent": _text(raw.get("intent"), f"nodes[{index}].intent", required=False), + "atomic_id": atomic_id, + "depends_on": _string_list(raw.get("depends_on"), f"nodes[{index}].depends_on"), + "requires_topology": raw.get("requires_topology") is True, + "topology_query": deepcopy(raw.get("topology_query")) if raw.get("topology_query") is not None else None, + "cdsl_feature_ids": feature_ids, + }) + node_metadata[node_id] = { + "cdsl_sketch_ids": sketch_ids, + "requires_sketch": bool(sketch_ids), + "requirement_ids": coverage, + "verification_rules": deepcopy(rules), + "review_targets": deepcopy(targets), + "attempts": {"authoring": 0, "repair": 0, "replan": 0}, + } + try: + feature_plan = validate_feature_plan({ + "schema_version": "cad.feature-plan.v1", + "plan_id": document.get("plan_id"), + "task_id": task_id or document.get("task_id"), + "nodes": feature_nodes, + }, supported_atomic_ids=supported_atomic_ids) + except FeaturePlanError as error: + raise GenerationPlanError(str(error)) from error + + covered = { + requirement_id + for metadata in node_metadata.values() + for requirement_id in metadata["requirement_ids"] + } + uncovered = [item["id"] for item in requirements if item["priority"] == "hard" and item["id"] not in covered] + if uncovered: + raise GenerationPlanError("Hard requirements are not covered: " + ", ".join(uncovered)) + nodes = [] + for node in feature_plan["nodes"]: + nodes.append({**node, **node_metadata[node["id"]]}) + return { + "schema_version": GENERATION_PLAN_SCHEMA_VERSION, + "id_strategy": BACKEND_ID_STRATEGY, + "plan_id": feature_plan["plan_id"], + "task_id": task_id or feature_plan["task_id"], + "requirements": requirements, + "assumptions": _string_list(document.get("assumptions"), "assumptions"), + "nodes": nodes, + "feature_owner": feature_plan["feature_owner"], + "sketch_owner": sketch_owner, + } + + +def descendant_closure(plan: dict[str, Any], root_node_id: str) -> set[str]: + """Return one node and every node whose model depends on it.""" + nodes = plan.get("nodes") if isinstance(plan, dict) else None + if not isinstance(nodes, list): + raise GenerationPlanError("Generation plan has no nodes") + children: dict[str, set[str]] = defaultdict(set) + known = {str(node.get("id")) for node in nodes if isinstance(node, dict)} + if root_node_id not in known: + raise GenerationPlanError(f"Unknown generation-plan node: {root_node_id}") + for node in nodes: + if not isinstance(node, dict): + continue + for dependency in node.get("depends_on") or (): + children[str(dependency)].add(str(node.get("id"))) + result: set[str] = set() + queue: deque[str] = deque([root_node_id]) + while queue: + node_id = queue.popleft() + if node_id in result: + continue + result.add(node_id) + queue.extend(sorted(children[node_id] - result)) + return result + + +def mark_nodes_stale(plan: dict[str, Any], root_node_id: str, *, reason: str) -> dict[str, Any]: + """Invalidate a node/subtree after an upstream geometry change or rollback.""" + updated = deepcopy(plan) + stale = descendant_closure(updated, root_node_id) + for node in updated.get("nodes") or (): + if isinstance(node, dict) and str(node.get("id")) in stale: + node["status"] = "planned" + node["stale"] = True + node["stale_reason"] = reason + node.pop("topology_snapshot_id", None) + return updated diff --git a/backend/app/services/image_observation.py b/backend/app/services/image_observation.py new file mode 100644 index 00000000..f6aad48b --- /dev/null +++ b/backend/app/services/image_observation.py @@ -0,0 +1,264 @@ +"""Structured multi-view image observations used by the CAD agent. + +The observation contract intentionally keeps uncertain image evidence separate +from executable CDSL. It can therefore retain free-form/polyline candidates +without pretending that the local CAD runtime supports them directly. +""" + +from __future__ import annotations + +import json +from copy import deepcopy +from typing import Any + + +OBSERVATION_SCHEMA_VERSION = "cad.image-observation.v2" +TEXT_LIMIT = 300 +LIMITS = { + "views": 12, + "surfaces": 24, + "profiles": 32, + "segments": 256, + "holes": 64, + "bends": 16, + "measurements": 128, + "uncertainties": 64, +} + + +def _text(value: Any, name: str, limit: int = TEXT_LIMIT, *, required: bool = False) -> str: + result = str(value or "").strip() + if required and not result: + raise ValueError(f"{name} must be a non-empty string") + return result[:limit] + + +def _text_list(value: Any, name: str, limit: int) -> list[str]: + if value is None: + return [] + if not isinstance(value, list): + raise ValueError(f"{name} must be an array") + return [_text(item, name, required=True) for item in value[:limit]] + + +def _number(value: Any, name: str) -> float | None: + if value is None or value == "": + return None + try: + return float(value) + except (TypeError, ValueError) as error: + raise ValueError(f"{name} must be numeric") from error + + +def _point(value: Any, name: str, dimensions: int = 2) -> list[float] | None: + if value is None: + return None + if not isinstance(value, list) or len(value) < dimensions: + raise ValueError(f"{name} must contain at least {dimensions} numbers") + output: list[float] = [] + for index, component in enumerate(value[:dimensions]): + parsed = _number(component, f"{name}[{index}]") + if parsed is None: + raise ValueError(f"{name}[{index}] must be numeric") + output.append(parsed) + return output + + +def _confidence(value: Any) -> float | None: + parsed = _number(value, "confidence") + if parsed is None: + return None + return max(0.0, min(1.0, parsed)) + + +def _source_images(value: Any) -> list[str]: + return _text_list(value, "source_images", LIMITS["views"]) + + +def _normalize_segment(segment: Any) -> dict[str, Any]: + if not isinstance(segment, dict): + raise ValueError("profile segments must contain objects") + kind = _text(segment.get("type"), "segment.type", 32, required=True) + if kind not in {"line", "arc", "circle", "polyline", "unknown_curve"}: + raise ValueError(f"unsupported image segment type: {kind}") + result: dict[str, Any] = {"type": kind} + if kind in {"line", "arc"}: + result["start"] = _point(segment.get("start"), "segment.start") + result["end"] = _point(segment.get("end"), "segment.end") + if result["start"] is None or result["end"] is None: + raise ValueError(f"{kind} segments require start and end") + if kind == "arc": + result["center"] = _point(segment.get("center"), "segment.center") + result["radius_mm"] = _number(segment.get("radius_mm"), "segment.radius_mm") + result["clockwise"] = bool(segment.get("clockwise")) + if kind == "circle": + result["center"] = _point(segment.get("center"), "segment.center") + result["radius_mm"] = _number(segment.get("radius_mm"), "segment.radius_mm") + if result["center"] is None or result["radius_mm"] is None: + raise ValueError("circle segments require center and radius_mm") + if kind in {"polyline", "unknown_curve"}: + points = segment.get("points") + if not isinstance(points, list) or not points: + raise ValueError(f"{kind} segments require points") + result["points"] = [_point(point, "segment.points") for point in points[:LIMITS["segments"]]] + if any(point is None for point in result["points"]): + raise ValueError(f"{kind} segment contains an invalid point") + result["image_uv"] = { + "start": _point(segment.get("image_start"), "segment.image_start"), + "end": _point(segment.get("image_end"), "segment.image_end"), + } + result["confidence"] = _confidence(segment.get("confidence")) + result["notes"] = _text(segment.get("notes"), "segment.notes") + return result + + +def _normalize_profile(profile: Any) -> dict[str, Any]: + if not isinstance(profile, dict): + raise ValueError("profiles must contain objects") + segments = profile.get("segments") or [] + if not isinstance(segments, list): + raise ValueError("profile.segments must be an array") + return { + "id": _text(profile.get("id"), "profile.id", 80, required=True), + "role": _text(profile.get("role"), "profile.role", 40), + "plane_hint": _text(profile.get("plane_hint"), "profile.plane_hint"), + "closed": bool(profile.get("closed")), + "coordinate_space": _text(profile.get("coordinate_space"), "profile.coordinate_space") or "image_uv", + "segments": [_normalize_segment(item) for item in segments[:LIMITS["segments"]]], + "source_images": _source_images(profile.get("source_images")), + "confidence": _confidence(profile.get("confidence")), + "uncertain": _text_list(profile.get("uncertain"), "profile.uncertain", 16), + "notes": _text(profile.get("notes"), "profile.notes"), + } + + +def _normalize_measurement(measurement: Any) -> dict[str, Any]: + if not isinstance(measurement, dict): + raise ValueError("measurements must contain objects") + source = _text(measurement.get("source"), "measurement.source") or "image" + if source not in {"user", "image", "cv", "assumption"}: + raise ValueError("measurement.source must be user, image, cv, or assumption") + value = _number(measurement.get("value_mm"), "measurement.value_mm") + minimum = _number(measurement.get("min_mm"), "measurement.min_mm") + maximum = _number(measurement.get("max_mm"), "measurement.max_mm") + return { + "name": _text(measurement.get("name"), "measurement.name", 120, required=True), + "value_mm": value, + "min_mm": minimum, + "max_mm": maximum, + "source": source, + "confidence": _confidence(measurement.get("confidence")), + "evidence": _text(measurement.get("evidence"), "measurement.evidence"), + "source_images": _source_images(measurement.get("source_images")), + } + + +def normalize_image_observation(arguments: dict[str, Any], *, attachment_ids: list[str]) -> dict[str, Any]: + """Normalize the survey tool output while preserving uncertain geometry.""" + if not isinstance(arguments, dict): + raise ValueError("image observation arguments must be an object") + raw_ids = [str(item) for item in arguments.get("attachment_ids") or attachment_ids if str(item)] + normalized_ids = list(dict.fromkeys(raw_ids or attachment_ids)) + if not normalized_ids: + raise ValueError("image observation requires at least one attachment") + views = arguments.get("views") or [] + profiles = arguments.get("profiles") or [] + measurements = arguments.get("measurements") or [] + result: dict[str, Any] = { + "schema_version": OBSERVATION_SCHEMA_VERSION, + "attachment_ids": normalized_ids, + "part_type": _text(arguments.get("part_type"), "part_type", required=True), + "visible_features": _text_list(arguments.get("visible_features"), "visible_features", 32), + "uncertain_features": _text_list(arguments.get("uncertain_features"), "uncertain_features", LIMITS["uncertainties"]), + "views": [], + "scale_references": deepcopy(arguments.get("scale_references") or [])[:LIMITS["views"]], + "overall_geometry": arguments.get("overall_geometry") if isinstance(arguments.get("overall_geometry"), dict) else {}, + "surfaces": deepcopy(arguments.get("surfaces") or [])[:LIMITS["surfaces"]], + "profiles": [_normalize_profile(item) for item in profiles[:LIMITS["profiles"]]], + "holes": deepcopy(arguments.get("holes") or [])[:LIMITS["holes"]], + "bends": deepcopy(arguments.get("bends") or [])[:LIMITS["bends"]], + "measurements": [_normalize_measurement(item) for item in measurements[:LIMITS["measurements"]]], + "uncertainties": _text_list(arguments.get("uncertainties"), "uncertainties", LIMITS["uncertainties"]), + "assumptions": _text_list(arguments.get("assumptions"), "assumptions", LIMITS["uncertainties"]), + "cv_hints": deepcopy(arguments.get("cv_hints") or [])[:LIMITS["profiles"]], + } + for item in views[:LIMITS["views"]]: + if not isinstance(item, dict): + raise ValueError("views must contain objects") + result["views"].append({ + "attachment_id": _text(item.get("attachment_id"), "view.attachment_id", 120, required=True), + "view_role": _text(item.get("view_role"), "view.view_role"), + "orientation": _text(item.get("orientation"), "view.orientation"), + "visible_regions": _text_list(item.get("visible_regions"), "view.visible_regions", 24), + "occluded_regions": _text_list(item.get("occluded_regions"), "view.occluded_regions", 24), + "quality": _text(item.get("quality"), "view.quality"), + "scale_reference_id": _text(item.get("scale_reference_id"), "view.scale_reference_id", 120), + "confidence": _confidence(item.get("confidence")), + }) + return result + + +def normalize_sketch_candidates(arguments: dict[str, Any], *, attachment_ids: list[str]) -> dict[str, Any]: + """Normalize the second-stage sketch extraction result.""" + if not isinstance(arguments, dict): + raise ValueError("sketch candidate arguments must be an object") + profiles = arguments.get("profiles") or arguments.get("sketches") or [] + base = normalize_image_observation({ + "attachment_ids": attachment_ids, + "part_type": arguments.get("part_type") or "image reference", + "visible_features": arguments.get("visible_features") or ["profile candidates"], + "uncertain_features": arguments.get("uncertain_features") or [], + "profiles": profiles, + "measurements": arguments.get("measurements") or [], + "uncertainties": arguments.get("uncertainties") or [], + "assumptions": arguments.get("assumptions") or [], + "cv_hints": arguments.get("cv_hints") or [], + }, attachment_ids=attachment_ids) + return { + "profiles": base["profiles"], + "measurements": base["measurements"], + "uncertainties": base["uncertainties"], + "assumptions": base["assumptions"], + "cv_hints": base["cv_hints"], + } + + +def merge_image_observations(survey: dict[str, Any], sketches: dict[str, Any]) -> dict[str, Any]: + """Merge the two stages and keep user-sourced measurements authoritative.""" + result = deepcopy(survey) + result["schema_version"] = OBSERVATION_SCHEMA_VERSION + result["profiles"] = sketches.get("profiles") or result.get("profiles") or [] + existing = {str(item.get("name")): item for item in result.get("measurements") or () if isinstance(item, dict)} + for item in sketches.get("measurements") or (): + if not isinstance(item, dict): + continue + key = str(item.get("name") or "") + prior = existing.get(key) + if prior and prior.get("source") == "user" and item.get("source") != "user": + continue + existing[key] = item + result["measurements"] = list(existing.values()) + result["uncertainties"] = list(dict.fromkeys([ + *(result.get("uncertainties") or []), + *(sketches.get("uncertainties") or []), + ]))[:LIMITS["uncertainties"]] + result["assumptions"] = list(dict.fromkeys([ + *(result.get("assumptions") or []), + *(sketches.get("assumptions") or []), + ]))[:LIMITS["uncertainties"]] + result["cv_hints"] = sketches.get("cv_hints") or result.get("cv_hints") or [] + return result + + +def render_image_observation_context(observation: dict[str, Any] | None) -> str: + if not isinstance(observation, dict): + return "" + compact = { + key: observation.get(key) + for key in ( + "schema_version", "attachment_ids", "part_type", "views", "overall_geometry", + "surfaces", "profiles", "holes", "bends", "measurements", "uncertainties", "assumptions", + ) + if observation.get(key) not in (None, [], {}) + } + return json.dumps(compact, ensure_ascii=False, separators=(",", ":")) diff --git a/backend/app/services/image_processing.py b/backend/app/services/image_processing.py new file mode 100644 index 00000000..b70e67d2 --- /dev/null +++ b/backend/app/services/image_processing.py @@ -0,0 +1,46 @@ +"""Optional image metadata and computer-vision hints for image observations.""" + +from __future__ import annotations + +import io +from typing import Any + + +def image_metadata(data: bytes) -> dict[str, Any]: + """Read safe image metadata without changing the original upload.""" + try: + from PIL import Image, ImageOps + with Image.open(io.BytesIO(data)) as image: + normalized = ImageOps.exif_transpose(image) + return { + "width": int(normalized.width), + "height": int(normalized.height), + "format": str(image.format or "").lower(), + "orientation": "landscape" if normalized.width >= normalized.height else "portrait", + "has_alpha": "A" in normalized.getbands(), + } + except Exception as error: + return {"error": f"image metadata unavailable: {type(error).__name__}"} + + +def cv_hints(data: bytes) -> dict[str, Any]: + """Return conservative CV hints; OpenCV is intentionally optional.""" + try: + import cv2 # type: ignore + import numpy as np # type: ignore + except Exception: + return {"available": False, "hints": []} + try: + image = cv2.imdecode(np.frombuffer(data, dtype=np.uint8), cv2.IMREAD_GRAYSCALE) + if image is None: + return {"available": True, "hints": [], "error": "image decode failed"} + edges = cv2.Canny(image, 50, 150) + lines = cv2.HoughLinesP(edges, 1, 3.141592653589793 / 180, threshold=50, minLineLength=30, maxLineGap=8) + line_hints = [] + for line in (lines[:32] if lines is not None else []): + x1, y1, x2, y2 = [int(value) for value in line[0]] + line_hints.append({"type": "line", "start_px": [x1, y1], "end_px": [x2, y2]}) + return {"available": True, "hints": line_hints, "edge_pixels": int((edges > 0).sum())} + except Exception as error: + return {"available": True, "hints": [], "error": f"cv failed: {type(error).__name__}"} + diff --git a/backend/app/services/incremental_generation.py b/backend/app/services/incremental_generation.py new file mode 100644 index 00000000..020609b4 --- /dev/null +++ b/backend/app/services/incremental_generation.py @@ -0,0 +1,509 @@ +"""Persistent, full-rebuild orchestration for node-by-node CDSL authoring.""" + +from __future__ import annotations + +import asyncio +from collections.abc import AsyncIterator, Awaitable, Callable +from copy import deepcopy +import json +from pathlib import Path +import secrets +from typing import Any + +from app.services.cdsl_fragment import CdslFragmentError, cdsl_sha256, materialize_fragment, validate_fragment +from app.services.engine_service import QualityVerificationError, build_revision, load_engine, normalize_cdsl_for_engine, validate_cdsl +from app.services.generation_plan import GenerationPlanError, descendant_closure, mark_nodes_stale, validate_generation_plan +from app.services.quality import validate_verification +from app.services.review_renderer import ReviewRenderError, render_checkpoint, renderer_status +from app.services.storage import WorkspaceStore, write_json +from app.services.visual_review import VisualReviewError, review_checkpoint +from app.settings import ProviderConfig, ProviderModel, Settings + + +Completion = Callable[[list[dict[str, Any]], list[dict[str, Any]], ProviderConfig, ProviderModel, str | None], Awaitable[dict[str, Any]]] + + +PLAN_TOOL = { + "type": "function", + "function": { + "name": "plan_generation_task", + "description": "Create the complete immutable requirement list and executable feature DAG before authoring any CDSL.", + "parameters": { + "type": "object", + "properties": { + "schema_version": {"type": "string", "const": "cad.generation-plan.v2"}, + "plan_id": {"type": "string", "minLength": 1}, + "requirements": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "properties": { + "id": {"type": "string", "minLength": 1}, + "source": {"enum": ["explicit", "assumption"]}, + "priority": {"enum": ["hard", "soft"]}, + "description": {"type": "string", "minLength": 1}, + "value": {}, + "unit": {"type": "string"}, + "tolerance": {}, + }, + "required": ["id", "source", "priority", "description"], + "additionalProperties": False, + }, + }, + "assumptions": {"type": "array", "items": {"type": "string"}}, + "nodes": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "properties": { + "id": {"type": "string", "minLength": 1}, + "intent": {"type": "string"}, + "atomic_id": {"type": "string", "minLength": 1}, + "depends_on": {"type": "array", "items": {"type": "string"}}, + "requires_topology": {"type": "boolean"}, + "topology_query": {"type": "object"}, + "requirement_ids": {"type": "array", "items": {"type": "string"}}, + "verification_rules": {"type": "array", "items": {"type": "object"}}, + "review_targets": {"type": "array", "items": {"type": "object"}}, + }, + "required": ["id", "intent", "atomic_id", "depends_on", "requirement_ids", "verification_rules", "review_targets"], + "additionalProperties": False, + }, + }, + }, + "required": ["schema_version", "plan_id", "requirements", "assumptions", "nodes"], + "additionalProperties": False, + }, + }, +} + +FRAGMENT_TOOL = { + "type": "function", + "function": { + "name": "generate_cdsl_fragment", + "description": "Generate only the active plan node's additive CDSL fragment. Never replace or mutate existing CDSL.", + "parameters": { + "type": "object", + "properties": { + "schema_version": {"type": "string", "const": "cad.cdsl-fragment.v1"}, + "node_id": {"type": "string", "minLength": 1}, + "base_revision_id": {"type": "string"}, + "base_cdsl_sha256": {"type": "string", "minLength": 64, "maxLength": 64}, + "required_snapshot_id": {"type": "string"}, + "add_sketches": {"type": "array", "items": {"type": "object"}}, + "add_features": {"type": "array", "items": {"type": "object"}}, + "verification_rules": {"type": "array", "items": {"type": "object"}}, + "assumptions": {"type": "array", "items": {"type": "string"}}, + }, + "required": ["schema_version", "node_id", "base_revision_id", "base_cdsl_sha256", "add_sketches", "add_features", "verification_rules", "assumptions"], + "additionalProperties": False, + }, + }, +} + + +class IncrementalGenerationError(RuntimeError): + pass + + +def _tool_response(response: dict[str, Any], expected_name: str) -> dict[str, Any]: + try: + call = response["choices"][0]["message"]["tool_calls"][0] + if call["function"]["name"] != expected_name: + raise KeyError("wrong tool") + result = json.loads(call["function"]["arguments"]) + except (KeyError, IndexError, TypeError, json.JSONDecodeError) as error: + raise IncrementalGenerationError(f"Author did not return a valid {expected_name} call") from error + if not isinstance(result, dict): + raise IncrementalGenerationError(f"{expected_name} arguments must be an object") + return result + + +def _node_by_id(spec: dict[str, Any], node_id: str) -> dict[str, Any]: + node = next((item for item in spec.get("nodes") or () if isinstance(item, dict) and item.get("id") == node_id), None) + if node is None: + raise IncrementalGenerationError(f"Generation plan has no node {node_id}") + return node + + +def _fragment_node_context(node: dict[str, Any]) -> dict[str, Any]: + """Expose semantic node intent, not backend-owned CDSL implementation IDs.""" + fields = ( + "id", "intent", "atomic_id", "depends_on", "requires_topology", + "requires_sketch", "topology_query", "requirement_ids", + "verification_rules", "review_targets", + ) + return {field: deepcopy(node[field]) for field in fields if field in node} + + +def _ready_node(spec: dict[str, Any], completed: set[str], has_topology: bool) -> dict[str, Any] | None: + for node in spec.get("nodes") or (): + if not isinstance(node, dict) or node.get("status") == "completed": + continue + if all(str(item) in completed for item in node.get("depends_on") or ()) and (not node.get("requires_topology") or has_topology): + return node + return None + + +def _error_code(error: Exception) -> str: + message = str(error) + for code in ( + "SELECTOR_AMBIGUOUS", "SELECTOR_NOT_FOUND", "SELECTOR_GEOMETRY_MISMATCH", + "TOPOLOGY_SNAPSHOT_STALE", "TOPOLOGY_REQUIRED", "VERIFICATION_FAILED", "SELECTOR_OWNER_REQUIRED", + ): + if code in message: + return code + return type(error).__name__.upper() + + +def _mark_affected_nodes_stale(plan: dict[str, Any], node_ids: list[str], *, reason: str) -> tuple[dict[str, Any], set[str]]: + """Invalidate the union of every affected node's downstream closure.""" + updated = deepcopy(plan) + stale: set[str] = set() + for node_id in dict.fromkeys(node_ids): + stale.update(descendant_closure(updated, node_id)) + updated = mark_nodes_stale(updated, node_id, reason=reason) + return updated, stale + + +def _source_image_paths(store: WorkspaceStore, conversation: dict[str, Any]) -> list[Path]: + conversation_id = str(conversation.get("conversation_id") or "") + paths: list[Path] = [] + for attachment in conversation.get("attachments") or (): + if not isinstance(attachment, dict) or attachment.get("kind") != "image" or not conversation_id: + continue + try: + paths.append(store.conversation_attachment_path(conversation_id, str(attachment.get("path") or ""))) + except ValueError: + continue + return paths + + +class IncrementalGenerationRunner: + """The agent-facing controller. It is deliberately full-rebuild and restart-safe.""" + + def __init__(self, settings: Settings, store: WorkspaceStore, complete: Completion) -> None: + self.settings = settings + self.store = store + self._complete = complete + + async def _call(self, messages: list[dict[str, Any]], provider: ProviderConfig, model: ProviderModel, tool: dict[str, Any], name: str) -> dict[str, Any]: + response = await self._complete(messages, [tool], provider, model, name) + return _tool_response(response, name) + + async def run( + self, + *, + task_id: str, + request: str, + conversation: dict[str, Any], + provider: ProviderConfig, + model: ProviderModel, + author_messages: list[dict[str, Any]], + part_skills: dict[str, Any] | None = None, + references: list[str] | None = None, + already_started: bool = False, + ) -> AsyncIterator[tuple[str, dict[str, Any]]]: + plan_diagnostic_path = "" + try: + task = self.store.ensure_task(task_id or None, request) + task_id = str(task["task_id"]) + # Configuration is a start gate: visual review is required, never silently skipped. + self.settings.resolve_review_model() + renderer_ready, renderer_error = renderer_status() + if not renderer_ready: + raise IncrementalGenerationError(renderer_error) + task = self.store.read_task(task_id) if already_started else self.store.start_generation(task_id, request=request) + if not isinstance(task, dict): + raise IncrementalGenerationError("Generation task is unavailable") + yield "generation_plan", {"taskId": task_id, "status": "running"} + engine = load_engine(self.settings) + persisted_spec = self.store.read_generation_spec(task_id) + if persisted_spec is not None: + spec = validate_generation_plan( + persisted_spec, + supported_atomic_ids=getattr(engine, "SUPPORTED_ATOMIC_IDS", ()), + task_id=task_id, + ) + yield "generation_plan", { + "taskId": task_id, "status": "success", "planId": spec["plan_id"], "resumed": True, + "requirements": spec["requirements"], + "nodes": [{"id": node["id"], "intent": node["intent"], "status": node.get("status", "planned")} for node in spec["nodes"]], + } + else: + planning_messages = [ + { + "role": "system", + "content": ( + "Create one complete cad.generation-plan.v2 before creating geometry. " + "Turn every user constraint into a requirement with source explicit or assumption; " + "use source assumption for missing dimensions and never ask the user questions. " + "Every hard requirement must belong to at least one node. Use only runtime-supported atomic ids. " + "Do not output expected_feature_ids or expected_sketch_ids: the backend derives all CDSL object ids from node.id." + ), + }, + *author_messages, + ] + raw_spec = await self._call(planning_messages, provider, model, PLAN_TOOL, "plan_generation_task") + try: + spec = validate_generation_plan( + raw_spec, + supported_atomic_ids=getattr(engine, "SUPPORTED_ATOMIC_IDS", ()), + task_id=task_id, + ) + except GenerationPlanError as error: + plan_diagnostic_path = self.store.write_generation_failure(task_id, { + "schema_version": "cad.generation-plan-diagnostic.v1", + "stage": "generation_plan_validation", + "message": str(error), + "raw_plan": raw_spec, + }) + raise + self.store.write_generation_spec(task_id, spec) + yield "generation_plan", { + "taskId": task_id, "status": "success", "planId": spec["plan_id"], + "requirements": spec["requirements"], "nodes": [{"id": node["id"], "intent": node["intent"], "status": "planned"} for node in spec["nodes"]], + } + + completed: set[str] = { + str(node["id"]) for node in spec["nodes"] if node.get("status") == "completed" + } + last_built: dict[str, Any] | None = None + task = self.store.read_task(task_id) or task + active_revision_id = str(task.get("active_revision") or "") + # A process can stop between build and review. That checkpoint is + # not a legal base revision, so recover its parent before resuming. + active_record = next( + (item for item in task.get("revisions") or () if isinstance(item, dict) and item.get("revision_id") == active_revision_id), + None, + ) + if isinstance(active_record, dict) and active_record.get("visibility") == "checkpoint": + current_node = str(active_record.get("node_id") or "") + if current_node and current_node not in completed: + recovered = str(active_record.get("parent_revision_id") or "") + self.store.rollback_to_revision(task_id, recovered, branch_id=f"branch_{secrets.token_hex(4)}") + active_revision_id = recovered + task = self.store.read_task(task_id) or task + base_path = self.store.current_cdsl_path(task_id) + base_cdsl = json.loads(base_path.read_text(encoding="utf-8")) if base_path and base_path.is_file() else None + while True: + topology_path = self.store.current_topology_path(task_id) + topology = json.loads(topology_path.read_text(encoding="utf-8")) if topology_path and topology_path.is_file() else None + node = _ready_node(spec, completed, bool(topology and topology.get("records"))) + if node is None: + if len(completed) == len(spec["nodes"]): + self.store.finish_generation(task_id, lifecycle="completed") + if last_built is not None: + yield "cad_result", self._result_payload(last_built, lifecycle="completed", checkpoint=False) + yield "task_terminal", {"taskId": task_id, "lifecycle": "completed", "revisionId": str((self.store.read_task(task_id) or {}).get("published_revision") or "")} + return + waiting = [item["id"] for item in spec["nodes"] if item.get("id") not in completed] + raise IncrementalGenerationError("No executable plan node is ready: " + ", ".join(waiting)) + + node_id = str(node["id"]) + self.store.set_active_node(task_id, node_id) + yield "checkpoint", {"taskId": task_id, "nodeId": node_id, "status": "authoring"} + attempts = node.setdefault("attempts", {"authoring": 0, "repair": 0, "replan": 0}) + feedback = "" + while True: + attempt_kind = "authoring" if int(attempts.get("authoring") or 0) < self.settings.node_authoring_attempts else "repair" + if attempt_kind == "repair" and int(attempts.get("repair") or 0) >= self.settings.node_repair_attempts: + if int(attempts.get("replan") or 0) >= self.settings.node_replan_attempts: + raise IncrementalGenerationError(f"Node {node_id} exhausted its authoring, repair, and replan budgets: {feedback}") + attempts["replan"] = int(attempts.get("replan") or 0) + 1 + spec = await self._replan(spec, node_id, feedback, provider, model, author_messages, engine) + self.store.write_generation_spec(task_id, spec) + node = _node_by_id(spec, node_id) + node["attempts"] = {"authoring": 0, "repair": 0, "replan": attempts["replan"]} + attempts = node["attempts"] + yield "rollback", {"taskId": task_id, "nodeId": node_id, "reason": "node_replan"} + continue + attempts[attempt_kind] = int(attempts.get(attempt_kind) or 0) + 1 + required_snapshot = str((topology or {}).get("snapshot_id") or "") if node.get("requires_topology") else "" + node_requirement_ids = set(node.get("requirement_ids") or ()) + author_context = { + "active_node": _fragment_node_context(node), + "requirements": [ + requirement for requirement in spec["requirements"] + if requirement.get("id") in node_requirement_ids + ], + "base_revision_id": active_revision_id, + "base_cdsl_sha256": cdsl_sha256(base_cdsl), + "required_snapshot_id": required_snapshot, + "base_cdsl": base_cdsl, + "topology": topology if required_snapshot else None, + "previous_failure": feedback, + } + fragment_messages = [ + { + "role": "system", + "content": ( + "Author exactly one additive CDSL fragment for the active node. Do not change existing CDSL or generate unsupported topology selectors. " + "Do not set output id, sketch_id, or depends_on: the backend assigns them. " + "Use owner_node_id instead of owner_feature_id and source_node_ids instead of source_feature_ids when referring to plan nodes." + ), + }, + *author_messages, + {"role": "user", "content": json.dumps(author_context, ensure_ascii=False)}, + ] + try: + raw_fragment = await self._call(fragment_messages, provider, model, FRAGMENT_TOOL, "generate_cdsl_fragment") + fragment = validate_fragment( + raw_fragment, plan=spec, node_id=node_id, base_revision_id=active_revision_id, + base_cdsl=base_cdsl, required_snapshot_id=required_snapshot, + ) + cdsl = materialize_fragment(base_cdsl, fragment) + cdsl, repairs = normalize_cdsl_for_engine(cdsl) + validate_cdsl(cdsl, engine) + rules = [*node.get("verification_rules", []), *fragment.get("verification_rules", [])] + verification = {"rules": rules} if rules else None + validate_verification(verification, cdsl) + fragment_base_revision = str(fragment["base_revision_id"]) + built = await asyncio.to_thread( + build_revision, + settings=self.settings, store=self.store, task_id=task_id, request=request, cdsl=cdsl, + reference_ids=references or [], summary=node.get("intent") or node_id, + parent_revision_id=active_revision_id or None, + operation={"type": "cdsl_fragment", "node_id": node_id}, + part_skills=part_skills, generation_assumptions=[*spec.get("assumptions", []), *fragment.get("assumptions", [])], + verification=verification, node_id=node_id, fragment=fragment, + branch_id=str((self.store.read_task(task_id) or {}).get("active_branch_id") or "main"), visibility="checkpoint", + ) + candidate_revision_id = str(built["revision_id"]) + try: + render_dir = self.store.revision_dir(task_id, candidate_revision_id) / "review" + manifest = await asyncio.to_thread( + render_checkpoint, + self.settings, + step_path=self.store.artifact_path(task_id, str(built["step_path"])), + output_dir=render_dir, + review_targets=node.get("review_targets"), + ) + final_checkpoint = len(completed) + 1 == len(spec["nodes"]) + review_requirements = spec["requirements"] if final_checkpoint else [ + requirement for requirement in spec["requirements"] + if requirement.get("id") in set(node.get("requirement_ids") or ()) + ] + review = await review_checkpoint( + self.settings, manifest=manifest, requirements=review_requirements, node_id=node_id, + deterministic_report={"quality_status": built.get("quality_status"), "verification": built.get("verification_summary", {})}, + source_images=_source_image_paths(self.store, conversation) if not completed or final_checkpoint else [], + final_checkpoint=final_checkpoint, + ) + except (ReviewRenderError, VisualReviewError) as error: + self.store.rollback_to_revision(task_id, fragment_base_revision, branch_id=f"branch_{secrets.token_hex(4)}") + active_revision_id = fragment_base_revision + rollback_path = self.store.current_cdsl_path(task_id) + base_cdsl = json.loads(rollback_path.read_text(encoding="utf-8")) if rollback_path and rollback_path.is_file() else None + raise error + active_revision_id = candidate_revision_id + base_cdsl = cdsl + manifest_relative = (render_dir / "render-manifest.json").relative_to(self.store.task_dir(task_id)).as_posix() + review_relative = (render_dir / "visual-review.json").relative_to(self.store.task_dir(task_id)).as_posix() + write_json(render_dir / "visual-review.json", review) + self.store.update_revision_metadata(task_id, active_revision_id, {"render_manifest_path": manifest_relative, "visual_review_path": review_relative}) + yield "render_review", {"taskId": task_id, "revisionId": active_revision_id, "nodeId": node_id, "review": review} + if review["verdict"] == "repair" and float(review["confidence"]) >= 0.85: + affected_nodes = [ + str(item) for item in review.get("affected_node_ids") or () + if any(str(candidate.get("id") or "") == str(item) for candidate in spec.get("nodes") or ()) + ] or [node_id] + rollback_base = self.store.rollback_anchor_for_nodes( + task_id, + affected_nodes, + fallback_revision_id=fragment_base_revision, + ) + branch = f"branch_{secrets.token_hex(4)}" + self.store.rollback_to_revision(task_id, rollback_base, branch_id=branch) + spec, stale_nodes = _mark_affected_nodes_stale( + spec, affected_nodes, reason="high_confidence_visual_review", + ) + self.store.write_generation_spec(task_id, spec) + completed.difference_update(stale_nodes) + active_revision_id = rollback_base + rollback_path = self.store.current_cdsl_path(task_id) + base_cdsl = json.loads(rollback_path.read_text(encoding="utf-8")) if rollback_path and rollback_path.is_file() else None + feedback = "High-confidence visual review requires correction: " + "; ".join(review.get("evidence") or []) + yield "rollback", { + "taskId": task_id, "nodeId": node_id, "revisionId": active_revision_id, + "reason": "visual_review", "affectedNodeIds": affected_nodes, + } + continue + completed.add(node_id) + node["status"] = "completed" + node.pop("stale", None) + self.store.write_generation_spec(task_id, spec) + last_built = built + payload = self._result_payload(built, lifecycle="running", checkpoint=True) + yield "checkpoint", {"taskId": task_id, "nodeId": node_id, "status": "success", "revisionId": active_revision_id} + yield "cad_result", payload + break + except (CdslFragmentError, GenerationPlanError, QualityVerificationError, ReviewRenderError, VisualReviewError, ValueError, RuntimeError) as error: + feedback = str(error) + failure = { + "schema_version": "cad.generation-failure.v1", + "node_id": node_id, + "stage": attempt_kind, + "error_code": _error_code(error), + "message": feedback, + "requirement_ids": list(node.get("requirement_ids") or ()), + "selector": {"required_snapshot_id": required_snapshot}, + "geometry_delta": {}, + "recommended_rollback_revision": fragment_base_revision if "fragment_base_revision" in locals() else active_revision_id, + } + failure_path = self.store.write_generation_failure(task_id, failure) + yield "checkpoint", {"taskId": task_id, "nodeId": node_id, "status": "error", "attempt": attempt_kind, "message": feedback} + # A failed build never becomes the active base; retries are safe and deterministic. + continue + except Exception as error: + failure = { + "schema_version": "cad.generation-failure.v1", + "message": str(error), + "active_node_id": str((self.store.read_task(task_id) or {}).get("active_node_id") or ""), + } + if plan_diagnostic_path: + failure["plan_diagnostic_path"] = plan_diagnostic_path + self.store.finish_generation(task_id, lifecycle="failed", failure=failure) + yield "task_terminal", {"taskId": task_id, "lifecycle": "failed", "message": str(error)} + + async def _replan( + self, + spec: dict[str, Any], + node_id: str, + feedback: str, + provider: ProviderConfig, + model: ProviderModel, + author_messages: list[dict[str, Any]], + engine: Any, + ) -> dict[str, Any]: + raw = await self._call([ + {"role": "system", "content": "Replan only the failed node and its downstream nodes. Preserve completed node definitions and all requirements."}, + *author_messages, + {"role": "user", "content": json.dumps({"existing_plan": spec, "failed_node_id": node_id, "failure": feedback}, ensure_ascii=False)}, + ], provider, model, PLAN_TOOL, "plan_generation_task") + next_spec = validate_generation_plan(raw, supported_atomic_ids=getattr(engine, "SUPPORTED_ATOMIC_IDS", ()), task_id=str(spec.get("task_id") or "")) + stale = {item["id"] for item in mark_nodes_stale(spec, node_id, reason="replan").get("nodes") or [] if item.get("stale")} + prior = {item["id"]: item for item in spec.get("nodes") or []} + for node in next_spec["nodes"]: + if node["id"] not in stale and node["id"] in prior: + old = prior[node["id"]] + for key in ("atomic_id", "depends_on", "cdsl_feature_ids"): + if node.get(key) != old.get(key): + raise IncrementalGenerationError(f"Replan changed non-stale node {node['id']}") + return next_spec + + @staticmethod + def _result_payload(built: dict[str, Any], *, lifecycle: str, checkpoint: bool) -> dict[str, Any]: + return { + "taskId": built["task_id"], "revisionId": built["revision_id"], + "cdslPath": built["cdsl_path"], "stepPath": built["step_path"], "glbPath": built["glb_path"], + "reportPath": built["report_path"], "parametersPath": built.get("parameters_path"), + "selectorPath": built.get("selector_path"), "edgesPath": built.get("edges_path"), "topologyPath": built.get("topology_path"), + "summary": built.get("summary") or "CDSL checkpoint", "referenceIds": built.get("reference_ids") or [], + "engine": built.get("engine") or "cdsl_only", "qualityStatus": built.get("quality_status") or "", + "qualityPath": built.get("quality_path"), "assumptions": built.get("generation_assumptions") or [], + "snapshotPaths": built.get("snapshot_paths") or [], "snapshotStatus": built.get("snapshot_status") or "unavailable", + "lifecycle": lifecycle, "checkpoint": checkpoint, + } diff --git a/backend/app/services/library.py b/backend/app/services/library.py index e681eb72..fa50a53f 100644 --- a/backend/app/services/library.py +++ b/backend/app/services/library.py @@ -9,10 +9,28 @@ from app.settings import Settings TOKEN_PATTERN = re.compile(r"[a-zA-Z0-9_]+") +CN_TOKEN_PATTERN = re.compile(r"[\u4e00-\u9fff]") + +TERM_ALIASES = { + "法兰": {"flange", "mounting_plate"}, + "套筒": {"sleeve", "tube", "cylinder"}, + "孔": {"hole", "circle", "circles"}, + "沉头": {"countersunk", "counterbore"}, + "沉孔": {"counterbore", "counterbored"}, + "槽": {"slot", "obround"}, + "底板": {"plate", "rectangle", "mounting_plate"}, + "轴": {"shaft", "cylinder"}, +} def tokens(value: str) -> set[str]: - return {token.lower() for token in TOKEN_PATTERN.findall(value)} + text = str(value or "").lower() + result = {token.lower() for token in TOKEN_PATTERN.findall(text)} + result.update(CN_TOKEN_PATTERN.findall(text)) + for term, aliases in TERM_ALIASES.items(): + if term in text: + result.update(aliases) + return result class CdslLibrary: @@ -38,6 +56,8 @@ class CdslLibrary: "profiles": record.get("profiles", []), "features": record.get("features", []), "summary": record.get("summary", ""), + "family": record.get("family", ""), + "atomics": record.get("features", []), } for record in self._records()[:limit] ] @@ -46,6 +66,8 @@ class CdslLibrary: corpus = " ".join([ record.get("part_id", ""), record.get("source_name", ""), + record.get("summary", ""), + record.get("family", ""), " ".join(record.get("profiles", [])), " ".join(record.get("features", [])), " ".join(record.get("parameters", [])), @@ -60,6 +82,8 @@ class CdslLibrary: "profiles": record.get("profiles", []), "features": record.get("features", []), "summary": record.get("summary", ""), + "family": record.get("family", ""), + "atomics": record.get("features", []), } for _, record in scored[:limit] ] diff --git a/backend/app/services/part_skills.py b/backend/app/services/part_skills.py index 345ef19e..30c13cd3 100644 --- a/backend/app/services/part_skills.py +++ b/backend/app/services/part_skills.py @@ -58,7 +58,6 @@ class PartSkill: bridge: str source: str related: tuple[str, ...] - capability_translation_rules: tuple[str, ...] @classmethod def from_mapping(cls, value: dict[str, Any]) -> "PartSkill": @@ -72,9 +71,6 @@ class PartSkill: bridge=str(value["bridge"]), source=str(value["source"]), related=tuple(str(item) for item in value.get("related") or []), - capability_translation_rules=tuple( - str(item) for item in value.get("capability_translation_rules") or [] - ), ) @@ -84,6 +80,7 @@ class PartSkillLibrary: def __init__(self, root: Path) -> None: self.root = Path(root) payload = json.loads((self.root / "catalog.json").read_text(encoding="utf-8")) + self.catalog_version = str(payload.get("schema_version") or "1.0") self.max_planning = int(payload.get("max_planning") or 1) self.max_support = int(payload.get("max_support") or 3) self.skills = tuple(PartSkill.from_mapping(item) for item in payload.get("skills") or []) @@ -112,9 +109,10 @@ class PartSkillLibrary: "kind": skill.kind, "category": skill.kind, "title": skill.title, + "version": self.catalog_version, + "summary": skill.title, "source": skill.source, "bridge": skill.bridge, - "capability_translation_rules": list(skill.capability_translation_rules), "selection": selection, "matched_triggers": list(matched), } @@ -207,14 +205,7 @@ class PartSkillLibrary: revision_ids = [str(item) for item in (revision or {}).get("part_skill_ids") or [] if str(item) in self.by_id] if revision_ids: return revision_ids - # A valid DesignIntent creates a task before the first runtime build. - # Its canonical backend selection must therefore be inheritable too. - intent_id = str((task or {}).get("current_design_intent_id") or "") - intent = next( - (item for item in (task or {}).get("design_intents") or [] if str(item.get("intent_id") or "") == intent_id), - None, - ) - return [str(item) for item in (intent or {}).get("part_skill_ids") or [] if str(item) in self.by_id] + return [] def render_context(self, selection: dict[str, Any]) -> str: records = selection.get("skills") or [] @@ -233,62 +224,9 @@ class PartSkillLibrary: return "\n".join(sections) def audit(self, selection: dict[str, Any], cdsl: dict[str, Any], assumptions: list[str] | None = None) -> dict[str, Any]: - features = [item for item in cdsl.get("features") or [] if isinstance(item, dict)] - feature_ids = [str(item.get("id")) for item in features if item.get("id")] - atomics = [str(item.get("atomic_id")) for item in features] - sketches = [item for item in (cdsl.get("geometry") or {}).get("sketches") or [] if isinstance(item, dict)] - profiles = [str((item.get("profile") or {}).get("type")) for item in sketches] - evidence = { - "feature_ids": feature_ids, - "atomic_ids": sorted(set(atomics)), - "profile_types": sorted(set(profiles)), - "features": [ - { - "id": str(item.get("id") or ""), - "atomic_id": str(item.get("atomic_id") or ""), - "sketch_id": str(item.get("sketch_id") or ""), - "params": item.get("params") if isinstance(item.get("params"), dict) else {}, - } - for item in features - ], - "profiles": [ - { - "id": str(item.get("id") or ""), - "type": str((item.get("profile") or {}).get("type") or ""), - "profile": item.get("profile") if isinstance(item.get("profile"), dict) else {}, - } - for item in sketches - ], - } - translations: list[dict[str, Any]] = [] - for record in selection.get("skills") or []: - skill_id = str(record.get("id")) - status = "exact" - reason = "The selected structural guidance is represented by the submitted CDSL plan." - if skill_id == "functional/flange-bolt-circle": - if "circles" in profiles and "extrude_cut_blind" in atomics: - status, reason = "expanded", "Circular bolt layout is represented by explicit circle geometry because no circular-pattern atomic exists." - else: - status, reason = "blocked", "No explicit circular bolt layout evidence was found in the CDSL." - elif skill_id == "atomic/threaded-hole-creation": - status, reason = "approximated", "The current runtime preserves a cylindrical bore but does not generate helical thread topology." - elif skill_id == "atomic/fillet-chamfer-last": - finishing = [item for item in features if item.get("atomic_id") in {"fillet", "chamfer"}] - if not finishing: - status, reason = "omitted", "No stable finishing selector was submitted; edge treatment was omitted." - elif skill_id == "atomic/pattern-holes-from-datum": - if "circles" in profiles or "circle_grid" in profiles: - status, reason = "expanded", "The layout is represented by explicit profile circles." - elif not any(item in {"pattern_linear", "pattern_mirror"} for item in atomics): - status, reason = "blocked", "No supported pattern or explicit circle layout was found." - translation: dict[str, Any] = {"skill_id": skill_id, "status": status, "reason": reason, "evidence": evidence} - if skill_id == "functional/flange-bolt-circle" and status == "expanded": - translation["translation"] = "circular_pattern_to_explicit_circles" - elif skill_id == "atomic/threaded-hole-creation": - translation["translation"] = "thread_geometry_omitted" - elif skill_id == "atomic/fillet-chamfer-last" and status == "omitted": - translation["translation"] = "selector_unavailable" - translations.append(translation) + # Skills are prompt knowledge only. Do not interpret a selected skill + # as geometry, capability translation, or an executable model rule. + del cdsl return { "schema_version": "1.0", "request": selection.get("request", ""), @@ -298,6 +236,4 @@ class PartSkillLibrary: "inherited_skill_ids": list(selection.get("inherited_skill_ids") or []), "conflict": selection.get("conflict"), "assumptions": [str(item) for item in assumptions or []], - "evidence": evidence, - "capability_translations": translations, } diff --git a/backend/app/services/quality.py b/backend/app/services/quality.py new file mode 100644 index 00000000..e352e6f3 --- /dev/null +++ b/backend/app/services/quality.py @@ -0,0 +1,329 @@ +"""Generic, model-family-independent verification for direct CDSL revisions.""" + +from __future__ import annotations + +import math +from typing import Any + + +QUALITY_RULE_TYPES = frozenset({ + "bbox", "solid_count", "feature_count", "hole_count", "hole_diameter", + "hole_center", "overall_length", "overall_width", "overall_height", + "overall_diameter", "through_condition", +}) +FEATURE_RULE_TYPES = frozenset({"hole_count", "hole_diameter", "hole_center", "through_condition"}) +_SEVERITIES = {"blocking", "warning", "informational"} + + +def _bbox_bounds(engine_result: dict[str, Any], feature_id: str | None = None) -> tuple[list[float], list[float]] | None: + """Return a whole-model or feature-owned runtime bounding box.""" + candidates: list[Any] = [] + if feature_id: + for record in engine_result.get("topology_records") or []: + if not isinstance(record, dict): + continue + owners = record.get("owner_feature_ids") or [] + # Runtime records may carry the current operation in feature_id + # while owner_feature_ids identify the actual geometry owner. Use + # ownership when present so inherited faces do not pollute a + # feature-local measurement. + matches = feature_id in owners if owners else record.get("feature_id") == feature_id + if matches: + geometry = record.get("geometry") + if isinstance(geometry, dict): + candidates.append(geometry.get("bbox_mm")) + else: + bbox = engine_result.get("bbox_mm") + if isinstance(bbox, dict): + candidates.append([ + *(bbox.get("min") or []), + *(bbox.get("max") or []), + ]) + + boxes = [ + value for value in candidates + if isinstance(value, list) and len(value) == 6 + and all(isinstance(item, (int, float)) and math.isfinite(float(item)) for item in value) + ] + if not boxes: + return None + minimum = [min(box[index] for box in boxes) for index in (0, 1, 2)] + maximum = [max(box[index] for box in boxes) for index in (3, 4, 5)] + return [float(item) for item in minimum], [float(item) for item in maximum] + + +def _bbox_value(engine_result: dict[str, Any], feature_id: str | None = None) -> dict[str, Any] | None: + bounds = _bbox_bounds(engine_result, feature_id) + if bounds is None: + return None + minimum, maximum = bounds + return { + "min": minimum, + "max": maximum, + "dimensions": [maximum[index] - minimum[index] for index in range(3)], + } + + +def _dimensions(engine_result: dict[str, Any]) -> list[float] | None: + value = _bbox_value(engine_result) + return value["dimensions"] if value else None + + +def _feature(cdsl: dict[str, Any], feature_id: str | None) -> dict[str, Any] | None: + return next((item for item in cdsl.get("features") or [] if isinstance(item, dict) and str(item.get("id")) == feature_id), None) + + +def _sketch(cdsl: dict[str, Any], sketch_id: str | None) -> dict[str, Any] | None: + return next((item for item in (cdsl.get("geometry") or {}).get("sketches") or [] if isinstance(item, dict) and str(item.get("id")) == sketch_id), None) + + +def _circles(profile: dict[str, Any]) -> list[dict[str, Any]]: + if profile.get("type") == "circle": + center, radius = profile.get("center"), profile.get("radius_mm") + if isinstance(center, list) and len(center) >= 2 and isinstance(radius, (int, float)): + return [{"center": [float(center[0]), float(center[1])], "radius_mm": float(radius)}] + if profile.get("type") != "analytic_contours": + return [] + return [ + {"center": [float(segment["center"][0]), float(segment["center"][1])], "radius_mm": float(segment["radius_mm"])} + for contour in profile.get("contours") or [] if isinstance(contour, dict) + for segment in contour.get("segments") or [] if isinstance(segment, dict) + and segment.get("type") == "circle" + and isinstance(segment.get("center"), list) and len(segment["center"]) >= 2 + and isinstance(segment.get("radius_mm"), (int, float)) + ] + + +def _circle_feature_bbox(feature: dict[str, Any] | None, sketch: dict[str, Any] | None) -> dict[str, Any] | None: + """Derive a stable world-space bbox for a circular extrude feature.""" + if not isinstance(feature, dict) or not isinstance(sketch, dict): + return None + if str(feature.get("atomic_id") or "") != "extrude_add_blind": + return None + profile = sketch.get("profile") + workplane = sketch.get("workplane") + params = feature.get("params") + if not isinstance(profile, dict) or profile.get("type") != "circle" or not isinstance(workplane, dict): + return None + center_local = profile.get("center") + radius = profile.get("radius_mm") + origin = workplane.get("origin_mm") + x_dir = workplane.get("x_dir") + y_dir = workplane.get("y_dir") + normal = workplane.get("normal") + distance = (params or {}).get("distance_mm") if isinstance(params, dict) else None + if not isinstance(center_local, list) or len(center_local) < 2 or not _finite_number(radius): + return None + if not all(isinstance(value, list) and len(value) >= 3 for value in (origin, x_dir, y_dir, normal)) or not _finite_number(distance): + return None + center = [ + float(origin[index]) + float(center_local[0]) * float(x_dir[index]) + float(center_local[1]) * float(y_dir[index]) + for index in range(3) + ] + points = [] + for axis_x in (-1.0, 1.0): + for axis_y in (-1.0, 1.0): + cross_section = [ + center[index] + + axis_x * float(radius) * float(x_dir[index]) + + axis_y * float(radius) * float(y_dir[index]) + for index in range(3) + ] + points.append(cross_section) + points.append([ + cross_section[index] + float(distance) * float(normal[index]) + for index in range(3) + ]) + minimum = [min(point[index] for point in points) for index in range(3)] + maximum = [max(point[index] for point in points) for index in range(3)] + return { + "min": minimum, + "max": maximum, + "dimensions": [maximum[index] - minimum[index] for index in range(3)], + } + + +def _finite_number(value: Any) -> bool: + return isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(float(value)) + + +def _validate_expected(kind: str, expected: Any, index: int) -> None: + scalar_types = { + "solid_count", "feature_count", "hole_count", "hole_diameter", + "overall_length", "overall_width", "overall_height", "overall_diameter", + } + if kind in scalar_types and not _finite_number(expected): + raise ValueError(f"verification.rules[{index}].expected must be a finite number for {kind}") + if kind == "bbox": + valid_dimensions = isinstance(expected, list) and len(expected) == 3 and all(_finite_number(value) for value in expected) + valid_ranges = isinstance(expected, dict) and set(expected) in ({"min", "max"}, {"x_min", "x_max", "y_min", "y_max", "z_min", "z_max"}) + if valid_ranges and set(expected) == {"min", "max"}: + valid_ranges = all( + isinstance(expected[key], list) + and len(expected[key]) == 3 + and all(_finite_number(value) for value in expected[key]) + for key in ("min", "max") + ) + elif valid_ranges: + valid_ranges = all(_finite_number(expected[key]) for key in expected) + if not valid_dimensions and not valid_ranges: + raise ValueError( + f"verification.rules[{index}].expected for bbox must be a three-number bbox [dx, dy, dz], " + "{min, max}, or {x_min, x_max, y_min, y_max, z_min, z_max}" + ) + if kind == "hole_center" and ( + not isinstance(expected, list) or len(expected) != 2 or not all(_finite_number(value) for value in expected) + ): + raise ValueError(f"verification.rules[{index}].expected must be a two-number hole center") + if kind == "through_condition" and not isinstance(expected, bool): + raise ValueError(f"verification.rules[{index}].expected must be boolean for through_condition") + + +def validate_verification(verification: Any, cdsl: dict[str, Any]) -> list[dict[str, Any]]: + if verification is None: + return [] + if not isinstance(verification, dict) or set(verification) - {"rules"}: + raise ValueError("verification must be an object containing only rules") + rules = verification.get("rules", []) + if not isinstance(rules, list) or len(rules) > 32: + raise ValueError("verification.rules must be an array with at most 32 rules") + feature_ids = {str(item.get("id")) for item in cdsl.get("features") or [] if isinstance(item, dict) and item.get("id")} + normalized: list[dict[str, Any]] = [] + ids: set[str] = set() + for index, raw in enumerate(rules): + if not isinstance(raw, dict): + raise ValueError(f"verification.rules[{index}] must be an object") + allowed = {"id", "type", "feature", "expected", "tolerance", "severity"} + if set(raw) - allowed: + raise ValueError(f"verification.rules[{index}] has unsupported fields") + rule_id = str(raw.get("id") or "").strip() + kind = str(raw.get("type") or "").strip() + if not rule_id or rule_id in ids: + raise ValueError(f"verification.rules[{index}].id must be unique and non-empty") + if kind not in QUALITY_RULE_TYPES: + raise ValueError(f"verification.rules[{index}].type is unsupported: {kind}") + if "expected" not in raw: + raise ValueError(f"verification.rules[{index}].expected is required") + _validate_expected(kind, raw["expected"], index) + severity = str(raw.get("severity") or "blocking") + if severity not in _SEVERITIES: + raise ValueError(f"verification.rules[{index}].severity is unsupported") + try: + tolerance = float(raw.get("tolerance") or 0.0) + except (TypeError, ValueError) as error: + raise ValueError(f"verification.rules[{index}].tolerance must be numeric") from error + if not math.isfinite(tolerance) or tolerance < 0: + raise ValueError(f"verification.rules[{index}].tolerance must be finite and non-negative") + feature = str(raw.get("feature") or "").strip() + if kind in FEATURE_RULE_TYPES and not feature: + raise ValueError(f"verification.rules[{index}].feature is required for {kind}") + if feature and feature not in feature_ids: + raise ValueError(f"verification.rules[{index}].feature must reference a CDSL feature ID") + ids.add(rule_id) + normalized.append({"id": rule_id, "type": kind, "feature": feature, "expected": raw["expected"], "tolerance": tolerance, "severity": severity}) + return normalized + + +def _actual(rule: dict[str, Any], cdsl: dict[str, Any], engine_result: dict[str, Any]) -> tuple[Any, str]: + kind, target = rule["type"], rule.get("feature") or None + feature = _feature(cdsl, target) + params = (feature or {}).get("params") if isinstance((feature or {}).get("params"), dict) else {} + sketch = _sketch(cdsl, str((feature or {}).get("sketch_id") or "")) + profile = (sketch or {}).get("profile") if isinstance(sketch, dict) else {} + circles = _circles(profile) if isinstance(profile, dict) else [] + dimensions = _dimensions(engine_result) + if kind == "bbox": + derived = _circle_feature_bbox(feature, sketch) + if target and derived is not None: + return derived, f"cdsl.features.{target}.sketch + params" + value = _bbox_value(engine_result, target) + source = "runtime.bbox_mm" if not target else f"runtime.topology_records[{target}].bbox_mm" + return value, source + if kind == "solid_count": + return float(engine_result.get("solid_count", 1)), "runtime.solid_count" + if kind == "feature_count": + return float(len(cdsl.get("features") or [])), "cdsl.features" + if kind == "hole_count": + return float(len(circles)), f"cdsl.features.{target}.sketch" + if kind == "hole_diameter": + value = params.get("diameter_mm", params.get("hole_diameter_mm")) + if isinstance(value, (int, float)): + return float(value), f"cdsl.features.{target}.params" + return (circles[0]["radius_mm"] * 2 if circles else None), f"cdsl.features.{target}.sketch" + if kind == "hole_center": + centers = [circle["center"] for circle in circles] + return (centers[0] if len(centers) == 1 else centers), f"cdsl.features.{target}.sketch" + if kind == "overall_length": + return (max(dimensions) if dimensions else None), "runtime.bbox_mm" + if kind == "overall_width": + return (dimensions[1] if dimensions and len(dimensions) > 1 else None), "runtime.bbox_mm[y]" + if kind == "overall_height": + return (dimensions[2] if dimensions and len(dimensions) > 2 else None), "runtime.bbox_mm[z]" + if kind == "overall_diameter": + if target and circles: + return max(circle["radius_mm"] for circle in circles) * 2.0, f"cdsl.features.{target}.sketch" + return (min(dimensions) if dimensions else None), "runtime.bbox_mm" + if kind == "through_condition": + end = params.get("end_condition") if isinstance(params.get("end_condition"), dict) else {} + if end.get("type") in {"through_all", "through_all_both", "through_all_and_blind"}: + return True, f"cdsl.features.{target}.params.end_condition" + distance = params.get("distance_mm") + return bool(isinstance(distance, (int, float)) and dimensions and float(distance) >= min(dimensions) - 1e-6), "cdsl.params + runtime.bbox_mm" + return None, "unsupported verification type" + + +def _matches(expected: Any, actual: Any, tolerance: float) -> bool: + if actual is None: + return False + if isinstance(expected, list): + return isinstance(actual, list) and len(expected) == len(actual) and all( + _matches(expected_item, actual_item, tolerance) for expected_item, actual_item in zip(expected, actual) + ) + if isinstance(expected, bool): + return bool(actual) is expected + if isinstance(expected, (int, float)) and isinstance(actual, (int, float)): + return math.isclose(float(expected), float(actual), abs_tol=tolerance, rel_tol=0.0) + return expected == actual + + +def _matches_bbox(expected: Any, actual: dict[str, Any] | None, tolerance: float) -> bool: + if actual is None: + return False + if isinstance(expected, list): + return _matches(expected, actual["dimensions"], tolerance) + if not isinstance(expected, dict): + return False + if set(expected) == {"min", "max"}: + return _matches(expected["min"], actual["min"], tolerance) and _matches(expected["max"], actual["max"], tolerance) + if set(expected) == {"x_min", "x_max", "y_min", "y_max", "z_min", "z_max"}: + actual_ranges = { + "x_min": actual["min"][0], "x_max": actual["max"][0], + "y_min": actual["min"][1], "y_max": actual["max"][1], + "z_min": actual["min"][2], "z_max": actual["max"][2], + } + return all(_matches(expected[key], actual_ranges[key], tolerance) for key in actual_ranges) + return False + + +def evaluate_quality(rules: list[dict[str, Any]], cdsl: dict[str, Any], engine_result: dict[str, Any]) -> dict[str, Any]: + results = [] + for rule in rules: + actual, source = _actual(rule, cdsl, engine_result) + passed = ( + _matches_bbox(rule["expected"], actual, rule["tolerance"]) + if rule["type"] == "bbox" + else _matches(rule["expected"], actual, rule["tolerance"]) + ) + results.append({**rule, "status": "passed" if passed else ("failed" if actual is not None else "unavailable"), "actual": actual, "source": source}) + blocking = [result for result in results if result["severity"] == "blocking" and result["status"] != "passed"] + warnings = [result for result in results if result["severity"] != "blocking" and result["status"] != "passed"] + return { + "schema": "cad.quality-report.v1", + "schema_version": "1.0", + "status": "passed" if not blocking else "failed", + "verification_requested": bool(rules), + "results": results, + "blocking_failures": blocking, + "warnings": warnings, + "measurements": {"bbox_mm": engine_result.get("bbox_mm"), "solid_count": engine_result.get("solid_count", 1)}, + } diff --git a/backend/app/services/review_renderer.py b/backend/app/services/review_renderer.py new file mode 100644 index 00000000..1c5d79f2 --- /dev/null +++ b/backend/app/services/review_renderer.py @@ -0,0 +1,332 @@ +"""Deterministic, CPU-only CAD technical renders for visual review. + +OpenCascade computes exact visible/hidden edges from the revision STEP file. +Pillow rasterizes the resulting technical drawings. Neither stage needs a web +browser, OpenGL, a desktop session, nor a GPU, which keeps review evidence +consistent on macOS, Linux, and Windows workers. +""" + +from __future__ import annotations + +import importlib +import json +import math +from pathlib import Path +from typing import Any + +from app.settings import Settings + + +CANONICAL_VIEWS = ("top", "bottom", "front", "back", "left", "right", "isometric") +RENDER_SIZE = 2048 +REVIEW_SIZE = 1024 +FRAME_PADDING = 0.14 +BACKGROUND_RGB = (246, 248, 251) +VISIBLE_EDGE_RGB = (34, 54, 69) +HIDDEN_EDGE_RGB = (142, 157, 170) + + +class ReviewRenderError(RuntimeError): + """The fixed-view renderer was unavailable or produced incomplete evidence.""" + + +def renderer_status() -> tuple[bool, str]: + """Verify that the pure-Python/OCC renderer dependencies are importable.""" + try: + _render_modules() + except ReviewRenderError as error: + return False, str(error) + return True, "" + + +def _render_modules() -> tuple[Any, Any, Any]: + try: + pillow_image = importlib.import_module("PIL.Image") + pillow_draw = importlib.import_module("PIL.ImageDraw") + import_step = importlib.import_module("build123d").import_step + except (ImportError, AttributeError) as error: + raise ReviewRenderError( + "Python technical renderer is unavailable; install backend requirements (build123d and Pillow)" + ) from error + return pillow_image, pillow_draw, import_step + + +def _number_list(value: Any, *, size: int) -> list[float] | None: + if not isinstance(value, list) or len(value) < size: + return None + try: + values = [float(item) for item in value[:size]] + except (TypeError, ValueError): + return None + return values if all(math.isfinite(item) for item in values) else None + + +def _bounds_center(bounds: list[float]) -> list[float]: + return [ + (bounds[0] + bounds[1]) / 2, + (bounds[2] + bounds[3]) / 2, + (bounds[4] + bounds[5]) / 2, + ] + + +def _shape_bounds(shape: Any) -> list[float]: + box = shape.bounding_box() + bounds = [float(box.min.X), float(box.max.X), float(box.min.Y), float(box.max.Y), float(box.min.Z), float(box.max.Z)] + if not all(math.isfinite(value) for value in bounds): + raise ReviewRenderError("STEP review source has invalid bounds") + return bounds + + +def _target_frame(target: dict[str, Any] | None, model_bounds: list[float]) -> tuple[list[float], float]: + model_center = _bounds_center(model_bounds) + model_extent = max(model_bounds[1] - model_bounds[0], model_bounds[3] - model_bounds[2], model_bounds[5] - model_bounds[4], 1.0) + if not isinstance(target, dict): + return model_center, 0.0 + bbox = _number_list(target.get("bbox_mm"), size=6) + if bbox and bbox[3] > bbox[0] and bbox[4] > bbox[1] and bbox[5] > bbox[2]: + center = [(bbox[0] + bbox[3]) / 2, (bbox[1] + bbox[4]) / 2, (bbox[2] + bbox[5]) / 2] + extent = max(bbox[3] - bbox[0], bbox[4] - bbox[1], bbox[5] - bbox[2], 1.0) + return center, min(model_extent, extent * 1.6) + center = _number_list(target.get("center_mm"), size=3) + try: + radius = float(target.get("radius_mm")) + except (TypeError, ValueError): + radius = 0.0 + if center and math.isfinite(radius) and radius > 0: + return center, min(model_extent, max(radius * 2, 1.0) * 1.6) + return model_center, 0.0 + + +def _camera_for(view_id: str, center: list[float]) -> dict[str, Any]: + directions = { + "top": ([0.0, 0.0, 1.0], [0.0, 1.0, 0.0]), + "bottom": ([0.0, 0.0, -1.0], [0.0, 1.0, 0.0]), + "front": ([0.0, -1.0, 0.0], [0.0, 0.0, 1.0]), + "back": ([0.0, 1.0, 0.0], [0.0, 0.0, 1.0]), + "left": ([-1.0, 0.0, 0.0], [0.0, 0.0, 1.0]), + "right": ([1.0, 0.0, 0.0], [0.0, 0.0, 1.0]), + "isometric": ([1.0, -1.0, 0.8], [0.0, 0.0, 1.0]), + } + direction, view_up = directions.get(view_id, directions["isometric"]) + length = math.sqrt(sum(item * item for item in direction)) or 1.0 + normal = [item / length for item in direction] + # Orthographic HLR ignores the distance, but a large deterministic value + # makes the intended camera convention explicit in the manifest. + position = [center[index] + normal[index] * 100000.0 for index in range(3)] + return {"projection": "orthographic", "position": position, "focal_point": center, "view_up": view_up} + + +def _edge_points(edge: Any, spacing: float) -> list[tuple[float, float]]: + count = max(2, min(1024, int(math.ceil(float(edge.length) / max(spacing, 0.002))) + 1)) + try: + points = edge.positions([index / (count - 1) for index in range(count)]) + except Exception: + points = [edge.position_at(0), edge.position_at(1)] + return [(float(point.X), float(point.Y)) for point in points] + + +def _projected_bounds(edges: list[Any]) -> tuple[float, float, float, float]: + points = [point for edge in edges for point in _edge_points(edge, 0.5)] + if not points: + raise ReviewRenderError("Hidden-line projection produced no drawable edges") + xs, ys = zip(*points) + return min(xs), max(xs), min(ys), max(ys) + + +def _frame_bounds(edges: list[Any], target_extent: float) -> tuple[float, float, float, float]: + min_x, max_x, min_y, max_y = _projected_bounds(edges) + if target_extent > 0: + # HLR maps the look-at target to the projection origin, making this + # an exact, deterministic local crop without a GPU clipping plane. + half = target_extent / (2 * (1 - 2 * FRAME_PADDING)) + return -half, half, -half, half + center_x, center_y = (min_x + max_x) / 2, (min_y + max_y) / 2 + extent = max(max_x - min_x, max_y - min_y, 1.0) + half = extent / (2 * (1 - 2 * FRAME_PADDING)) + return center_x - half, center_x + half, center_y - half, center_y + half + + +def _pixel(point: tuple[float, float], frame: tuple[float, float, float, float], size: int) -> tuple[int, int]: + min_x, max_x, min_y, max_y = frame + x = round((point[0] - min_x) * (size - 1) / (max_x - min_x)) + y = round((max_y - point[1]) * (size - 1) / (max_y - min_y)) + return int(x), int(y) + + +def _draw_dashed(draw: Any, points: list[tuple[int, int]], *, fill: tuple[int, int, int], width: int) -> None: + dash, gap = 16, 10 + for start, end in zip(points, points[1:]): + dx, dy = end[0] - start[0], end[1] - start[1] + length = math.hypot(dx, dy) + if length <= 0: + continue + distance = 0.0 + while distance < length: + segment_end = min(length, distance + dash) + first = (round(start[0] + dx * distance / length), round(start[1] + dy * distance / length)) + last = (round(start[0] + dx * segment_end / length), round(start[1] + dy * segment_end / length)) + draw.line((first, last), fill=fill, width=width) + distance += dash + gap + + +def _rasterize( + *, + visible: list[Any], + hidden: list[Any], + frame: tuple[float, float, float, float], + output_dir: Path, + view_id: str, + intentional_crop: bool, +) -> dict[str, Any]: + pillow_image, pillow_draw, _ = _render_modules() + image = pillow_image.new("RGB", (RENDER_SIZE, RENDER_SIZE), BACKGROUND_RGB) + mask = pillow_image.new("L", (RENDER_SIZE, RENDER_SIZE), 0) + draw = pillow_draw.Draw(image) + mask_draw = pillow_draw.Draw(mask) + spacing = max((frame[1] - frame[0]) / 1800, 0.01) + for edge in hidden: + points = [_pixel(point, frame, RENDER_SIZE) for point in _edge_points(edge, spacing)] + _draw_dashed(draw, points, fill=HIDDEN_EDGE_RGB, width=3) + _draw_dashed(mask_draw, points, fill=128, width=4) + for edge in visible: + points = [_pixel(point, frame, RENDER_SIZE) for point in _edge_points(edge, spacing)] + if len(points) >= 2: + draw.line(points, fill=VISIBLE_EDGE_RGB, width=4, joint="curve") + mask_draw.line(points, fill=255, width=5, joint="curve") + high_path = output_dir / "internal" / f"{view_id}-2x.png" + high_path.parent.mkdir(parents=True, exist_ok=True) + image.save(high_path, optimize=True) + output_path = output_dir / f"{view_id}.png" + image.resize((REVIEW_SIZE, REVIEW_SIZE), resample=pillow_image.Resampling.LANCZOS).save(output_path, optimize=True) + diagnostic_dir = output_dir / "internal" / view_id + diagnostic_dir.mkdir(parents=True, exist_ok=True) + mask_path = diagnostic_dir / "line-mask.png" + mask.save(mask_path) + edge_path = diagnostic_dir / "edge.png" + mask.save(edge_path) + box = mask.getbbox() + coverage = (RENDER_SIZE * RENDER_SIZE - mask.histogram()[0]) / (RENDER_SIZE * RENDER_SIZE) + pixel_bbox = list(box) if box else [] + touches_border = bool(box and (box[0] <= 1 or box[1] <= 1 or box[2] >= RENDER_SIZE - 1 or box[3] >= RENDER_SIZE - 1)) + valid = bool(box and coverage >= 0.00005 and coverage <= 0.20 and (intentional_crop or not touches_border)) + return { + "path": str(output_path), + "high_resolution_path": str(high_path), + "diagnostics": { + "line_mask_path": str(mask_path), + "edge_path": str(edge_path), + "coverage": coverage, + "pixel_bbox": pixel_bbox, + "touches_border": touches_border, + "intentional_crop": intentional_crop, + "visible_edge_count": len(visible), + "hidden_edge_count": len(hidden), + "valid": valid, + }, + } + + +def _render_view( + *, + shape: Any, + view_id: str, + projection_id: str, + target: dict[str, Any] | None, + model_bounds: list[float], + output_dir: Path, +) -> dict[str, Any]: + center, target_extent = _target_frame(target, model_bounds) + camera = _camera_for(projection_id, center) + try: + visible, hidden = shape.project_to_viewport( + camera["position"], viewport_up=camera["view_up"], look_at=camera["focal_point"] + ) + except Exception as error: + raise ReviewRenderError(f"OpenCascade hidden-line projection failed for {view_id}: {error}") from error + visible_edges, hidden_edges = list(visible), list(hidden) + frame = _frame_bounds([*visible_edges, *hidden_edges], target_extent) + rendered = _rasterize( + visible=visible_edges, + hidden=hidden_edges, + frame=frame, + output_dir=output_dir, + view_id=view_id, + intentional_crop=target_extent > 0, + ) + if not rendered["diagnostics"]["valid"]: + raise ReviewRenderError(f"Review render quality check failed for {view_id}: {json.dumps(rendered['diagnostics'], ensure_ascii=False)}") + return {"id": view_id, "camera": {**camera, "view": projection_id, "frame_mm": list(frame)}, "target": target, **rendered} + + +def _contact_sheet(views: list[dict[str, Any]], output_dir: Path) -> str: + """Create compact whole-model evidence for routine reviewer calls.""" + pillow_image, pillow_draw, _ = _render_modules() + canonical = [item for item in views if item["id"] in CANONICAL_VIEWS] + if not canonical: + return "" + tile = 400 + sheet = pillow_image.new("RGB", (tile * 3, tile * 3), BACKGROUND_RGB) + draw = pillow_draw.Draw(sheet) + for index, item in enumerate(canonical): + image = pillow_image.open(str(item["path"])).convert("RGB").resize((tile, tile), resample=pillow_image.Resampling.LANCZOS) + x, y = (index % 3) * tile, (index // 3) * tile + sheet.paste(image, (x, y)) + draw.rectangle((x + 8, y + 8, x + 96, y + 33), fill=(255, 255, 255)) + draw.text((x + 14, y + 13), str(item["id"]), fill=VISIBLE_EDGE_RGB) + path = output_dir / "contact-sheet.jpg" + sheet.save(path, quality=88, optimize=True, progressive=True) + return str(path) + + +def render_checkpoint( + settings: Settings, + *, + step_path: Path, + output_dir: Path, + review_targets: list[dict[str, Any]] | None = None, + include_canonical: bool = True, +) -> dict[str, Any]: + """Render STEP geometry into stable canonical and bounded node-detail views.""" + del settings + ready, detail = renderer_status() + if not ready: + raise ReviewRenderError(detail) + if not step_path.is_file(): + raise ReviewRenderError(f"STEP review source is missing: {step_path.name}") + _, _, import_step = _render_modules() + try: + shape = import_step(str(step_path)) + except Exception as error: + raise ReviewRenderError(f"Unable to read STEP review source: {error}") from error + bounds = _shape_bounds(shape) + output_dir.mkdir(parents=True, exist_ok=True) + jobs: list[tuple[str, dict[str, Any] | None]] = [] + if include_canonical: + jobs.extend((view_id, None) for view_id in CANONICAL_VIEWS) + jobs.extend((f"detail-{index + 1}", target) for index, target in enumerate((review_targets or [])[:3])) + views = [ + _render_view( + shape=shape, + view_id=view_id, + projection_id="isometric" if view_id.startswith("detail-") else view_id, + target=target, + model_bounds=bounds, + output_dir=output_dir, + ) + for view_id, target in jobs + ] + canonical = {item["id"] for item in views if not str(item["id"]).startswith("detail-")} + if include_canonical and canonical != set(CANONICAL_VIEWS): + raise ReviewRenderError("Python review renderer did not produce every canonical view") + contact_sheet_path = _contact_sheet(views, output_dir) if include_canonical else "" + manifest = { + "schema_version": "cad.render-manifest.v2", + "renderer": "python-occ-hlr-pillow", + "source": {"type": "step", "path": str(step_path), "bounds_mm": bounds}, + "high_resolution": {"width": RENDER_SIZE, "height": RENDER_SIZE, "method": "occ_hidden_line"}, + "review_resolution": {"width": REVIEW_SIZE, "height": REVIEW_SIZE, "resample": "lanczos"}, + "contact_sheet_path": contact_sheet_path, + "views": views, + } + (output_dir / "render-manifest.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8") + return manifest diff --git a/backend/app/services/storage.py b/backend/app/services/storage.py index 8aa1732c..6eea8e9d 100644 --- a/backend/app/services/storage.py +++ b/backend/app/services/storage.py @@ -12,7 +12,6 @@ from app.settings import Settings TASK_ID = re.compile(r"^cad_[a-z0-9]{12}$") CONVERSATION_ID = re.compile(r"^conv_[a-z0-9]{12}$") -DESIGN_INTENT_ID = re.compile(r"^intent_[a-z0-9]{12}$") def now_iso() -> str: @@ -37,13 +36,6 @@ def safe_conversation_id(conversation_id: str) -> str: return value -def safe_design_intent_id(intent_id: str) -> str: - value = str(intent_id or "").strip() - if not DESIGN_INTENT_ID.fullmatch(value): - raise ValueError("Invalid design intent id") - return value - - def safe_relative_path(value: str) -> str: path = Path(str(value or "")) if not value or path.is_absolute() or ".." in path.parts: @@ -88,11 +80,35 @@ class WorkspaceStore: write_json(path, payload) return (Path(conversation) / relative).as_posix() + def write_cdsl_attempt(self, conversation_id: str, cdsl: Any, iteration: int) -> str: + """Retain a parsed model candidate before validation or execution.""" + conversation = safe_conversation_id(conversation_id) + relative = Path("diagnostics") / f"cdsl_attempt_{iteration:02d}_{secrets.token_hex(8)}.json" + path = self.conversation_dir(conversation) / relative + write_json(path, cdsl) + return (Path(conversation) / relative).as_posix() + + def write_cdsl_validation_diagnostic(self, conversation_id: str, payload: dict[str, Any]) -> str: + """Persist the reason a retained CDSL candidate was rejected.""" + conversation = safe_conversation_id(conversation_id) + relative = Path("diagnostics") / f"cdsl_validation_{secrets.token_hex(8)}.json" + path = self.conversation_dir(conversation) / relative + write_json(path, payload) + return (Path(conversation) / relative).as_posix() + + def write_conversation_planning(self, conversation_id: str, prefix: str, payload: dict[str, Any]) -> str: + """Persist structured intake/planning evidence before a task exists.""" + conversation = safe_conversation_id(conversation_id) + safe_prefix = re.sub(r"[^a-zA-Z0-9_-]+", "-", prefix).strip("-") or "planning" + relative = Path("planning") / f"{safe_prefix}-{secrets.token_hex(6)}.json" + path = self.conversation_dir(conversation) / relative + write_json(path, payload) + return (Path(conversation) / relative).as_posix() + def ensure_conversation( self, conversation_id: str | None, current_task_id: str | None = None, - attachments: list[dict[str, Any]] | None = None, ) -> dict[str, Any]: cid = safe_conversation_id(conversation_id) if conversation_id else new_id("conv") path = self.conversation_path(cid) @@ -102,21 +118,18 @@ class WorkspaceStore: if current_task_id: current["current_task_id"] = safe_task_id(current_task_id) changed = True - if attachments is not None: - current["attachments"] = attachments - changed = True if changed: current["updated_at"] = now_iso() write_json(path, current) return current record = { - "schema_version": "1.1", + "schema_version": "1.2", "conversation_id": cid, "created_at": now_iso(), "updated_at": now_iso(), "current_task_id": safe_task_id(current_task_id) if current_task_id else "", "messages": [], - "attachments": attachments or [], + "attachments": [], } write_json(path, record) return record @@ -135,36 +148,95 @@ class WorkspaceStore: write_json(self.conversation_path(record["conversation_id"]), record) return record - def write_upload(self, task_id: str, filename: str, data: bytes) -> tuple[str, Path]: + def write_conversation_upload(self, conversation_id: str, filename: str, data: bytes) -> tuple[str, Path]: + conversation = safe_conversation_id(conversation_id) safe_name = re.sub(r"[^a-zA-Z0-9._-]+", "_", Path(filename).name).strip("._") or "attachment" relative = Path("uploads") / f"upload_{secrets.token_hex(6)}_{safe_name}" - target = self.artifact_path(task_id, relative.as_posix()) + target = self.conversation_attachment_path(conversation, relative.as_posix()) target.parent.mkdir(parents=True, exist_ok=True) target.write_bytes(data) return relative.as_posix(), target + def add_conversation_attachment(self, conversation_id: str, attachment: dict[str, Any]) -> dict[str, Any]: + conversation = safe_conversation_id(conversation_id) + record = self.read_conversation(conversation) + if record is None: + raise ValueError("Conversation not found") + if str(attachment.get("conversation_id") or "") != conversation: + raise ValueError("Attachment does not belong to this conversation") + attachment_id = str(attachment.get("id") or "") + if not attachment_id: + raise ValueError("Attachment id is required") + self.conversation_attachment_path(conversation, str(attachment.get("path") or "")) + attachments = record.setdefault("attachments", []) + if any(str(item.get("id") or "") == attachment_id for item in attachments if isinstance(item, dict)): + raise ValueError("Attachment already exists") + attachments.append(attachment) + record["updated_at"] = now_iso() + write_json(self.conversation_path(conversation), record) + return record + def ensure_task(self, task_id: str | None, request: str) -> dict[str, Any]: tid = safe_task_id(task_id) if task_id else new_id("cad") path = self.task_path(tid) current = read_json(path) if current: - return current + return self._migrate_task(current, path) task_dir = self.task_dir(tid) (task_dir / "revisions").mkdir(parents=True, exist_ok=True) record = { - "schema_version": "1.1", + "schema_version": "1.3", "task_id": tid, "request": request, "created_at": now_iso(), "updated_at": now_iso(), "current_revision": "", - "current_design_intent_id": "", - "design_intents": [], + "active_revision": "", + "published_revision": "", + "lifecycle": "completed", + "run_id": "", + "generation_spec_path": "", + "run_context_path": "", + "active_node_id": "", + "run_failure_path": "", "revisions": [], } write_json(path, record) return record + def _migrate_task(self, task: dict[str, Any], path: Path) -> dict[str, Any]: + """Add run-state fields lazily without rewriting successful history.""" + changed = False + current = str(task.get("current_revision") or "") + defaults = { + "schema_version": "1.3", + "active_revision": current, + "published_revision": current, + "lifecycle": "completed", + "run_id": "", + "generation_spec_path": "", + "run_context_path": "", + "active_node_id": "", + "run_failure_path": "", + } + for key, value in defaults.items(): + if key not in task: + task[key] = value + changed = True + for revision in task.get("revisions") or (): + if not isinstance(revision, dict): + continue + if "visibility" not in revision: + revision["visibility"] = "final" if str(revision.get("revision_id") or "") == str(task["published_revision"] or "") else "checkpoint" + changed = True + if "branch_id" not in revision: + revision["branch_id"] = "main" + changed = True + if changed: + task["updated_at"] = now_iso() + write_json(path, task) + return task + def next_revision(self, task_id: str) -> tuple[str, Path]: task = self.ensure_task(task_id, "") revision_id = f"rev_{len(task['revisions']) + 1:03d}" @@ -177,96 +249,302 @@ class WorkspaceStore: task["revisions"].append(revision) if revision.get("status") == "success": task["current_revision"] = revision["revision_id"] + task["active_revision"] = revision["revision_id"] + if revision.get("visibility") == "final": + task["published_revision"] = revision["revision_id"] task["updated_at"] = now_iso() write_json(self.task_path(task_id), task) return task - def create_design_intent( - self, - task_id: str | None, - request: str, - intent: dict[str, Any], - part_skill_selection: dict[str, Any], - ) -> dict[str, Any]: - """Persist a validated plan and atomically make it the current plan.""" + def read_task(self, task_id: str) -> dict[str, Any] | None: + task = read_json(self.task_path(task_id)) + return self._migrate_task(task, self.task_path(task_id)) if isinstance(task, dict) else None + + def start_generation(self, task_id: str, *, request: str, run_id: str | None = None) -> dict[str, Any]: task = self.ensure_task(task_id, request) - task.setdefault("design_intents", []) - task.setdefault("current_design_intent_id", "") - task["schema_version"] = "1.1" - previous_id = str(task.get("current_design_intent_id") or "") - if previous_id: - for record in task["design_intents"]: - if str(record.get("intent_id") or "") == previous_id and record.get("status") != "superseded": - record["status"] = "superseded" - - intent_id = new_id("intent") - created_at = now_iso() - relative = Path("planning") / f"design-intent-{intent_id}.json" - skill_ids = [str(item) for item in part_skill_selection.get("skill_ids") or [] if str(item)] - persisted = dict(intent) - persisted.update({ - "intent_id": intent_id, - "created_at": created_at, - "part_skill_ids": skill_ids, - "part_skill_selection": part_skill_selection, + if str(task.get("lifecycle") or "") == "running": + raise ValueError("CAD task is already running") + task.update({ + "lifecycle": "running", + "run_id": run_id or new_id("run"), + "active_node_id": "", + "run_failure_path": "", + "request": request or task.get("request") or "", + "active_revision": str(task.get("current_revision") or ""), + "updated_at": now_iso(), }) - write_json(self.artifact_path(task["task_id"], relative.as_posix()), persisted) - record = { - "intent_id": intent_id, - "status": "accepted" if persisted.get("status") == "ready" else "pending", - "created_at": created_at, - "path": relative.as_posix(), - "part_skill_ids": skill_ids, - "base_revision_id": str(persisted.get("base_revision_id") or ""), - } - task["design_intents"].append(record) - task["current_design_intent_id"] = intent_id - task["updated_at"] = now_iso() - write_json(self.task_path(task["task_id"]), task) - return {"task_id": task["task_id"], **record, "intent": persisted} + write_json(self.task_path(task_id), task) + return task - def read_design_intent(self, task_id: str, intent_id: str | None = None) -> dict[str, Any] | None: - task = self.read_task(task_id) - if not task: - return None - chosen = safe_design_intent_id(intent_id) if intent_id else str(task.get("current_design_intent_id") or "") - if not chosen: - return None - record = next( - (item for item in task.get("design_intents") or [] if str(item.get("intent_id") or "") == chosen), - None, - ) - if not isinstance(record, dict): - return None - path = self.artifact_path(task_id, str(record.get("path") or "")) - intent = read_json(path) - if not isinstance(intent, dict): - return None - return {"task_id": task_id, "record": record, "intent": intent} - - def update_design_intent_status(self, task_id: str, intent_id: str, status: str) -> dict[str, Any]: - safe_id = safe_design_intent_id(intent_id) + def finish_generation(self, task_id: str, *, lifecycle: str, failure: dict[str, Any] | None = None) -> dict[str, Any]: + if lifecycle not in {"completed", "failed"}: + raise ValueError("Generation lifecycle must be completed or failed") task = self.ensure_task(task_id, "") - records = task.setdefault("design_intents", []) - record = next((item for item in records if str(item.get("intent_id") or "") == safe_id), None) - if not isinstance(record, dict): - raise ValueError("Design intent not found") - record["status"] = str(status) + failure_path = "" + if failure: + failure_path = "run-failures/" + f"failure_{secrets.token_hex(8)}.json" + write_json(self.task_dir(task_id) / failure_path, failure) + if lifecycle == "completed": + task["published_revision"] = str(task.get("active_revision") or task.get("current_revision") or "") + for revision in task.get("revisions") or (): + if isinstance(revision, dict) and revision.get("revision_id") == task["published_revision"]: + revision["visibility"] = "final" + task.update({ + "lifecycle": lifecycle, + "active_node_id": "", + "run_failure_path": failure_path, + "updated_at": now_iso(), + }) + write_json(self.task_path(task_id), task) + return task + + def set_active_revision(self, task_id: str, revision_id: str, *, branch_id: str | None = None) -> dict[str, Any]: + task = self.ensure_task(task_id, "") + if not revision_id: + task["active_revision"] = "" + task["current_revision"] = "" + task["updated_at"] = now_iso() + write_json(self.task_path(task_id), task) + return task + revision = next((item for item in task.get("revisions") or () if isinstance(item, dict) and item.get("revision_id") == revision_id), None) + if not isinstance(revision, dict) or revision.get("status") != "success": + raise ValueError("Active revision must be a successful revision") + task["active_revision"] = revision_id + task["current_revision"] = revision_id + if branch_id: + task["active_branch_id"] = branch_id task["updated_at"] = now_iso() write_json(self.task_path(task_id), task) - return record + return task - def read_task(self, task_id: str) -> dict[str, Any] | None: - return read_json(self.task_path(task_id)) + def set_active_node(self, task_id: str, node_id: str) -> dict[str, Any]: + task = self.ensure_task(task_id, "") + task["active_node_id"] = node_id + task["updated_at"] = now_iso() + write_json(self.task_path(task_id), task) + return task + + def update_revision_metadata(self, task_id: str, revision_id: str, values: dict[str, Any]) -> dict[str, Any]: + task = self.ensure_task(task_id, "") + revision = next((item for item in task.get("revisions") or () if isinstance(item, dict) and item.get("revision_id") == revision_id), None) + if not isinstance(revision, dict): + raise ValueError("Revision does not exist") + revision.update(values) + task["updated_at"] = now_iso() + write_json(self.task_path(task_id), task) + return task + + def rollback_to_revision(self, task_id: str, revision_id: str, *, branch_id: str) -> dict[str, Any]: + """Move the generation head without deleting immutable checkpoint artifacts.""" + task = self.set_active_revision(task_id, revision_id, branch_id=branch_id) + children: dict[str, set[str]] = {} + for revision in task.get("revisions") or (): + if not isinstance(revision, dict): + continue + parent = str(revision.get("parent_revision_id") or "") + child = str(revision.get("revision_id") or "") + if parent and child: + children.setdefault(parent, set()).add(child) + superseded: set[str] = set() + pending = list(children.get(revision_id, set())) if revision_id else [ + str(item.get("revision_id") or "") + for item in task.get("revisions") or () + if isinstance(item, dict) and not str(item.get("parent_revision_id") or "") + ] + while pending: + child = pending.pop() + if not child or child in superseded: + continue + superseded.add(child) + pending.extend(children.get(child, set())) + for revision in task.get("revisions") or (): + if isinstance(revision, dict) and str(revision.get("revision_id") or "") in superseded and revision.get("visibility") == "checkpoint": + revision["visibility"] = "superseded" + task["updated_at"] = now_iso() + write_json(self.task_path(task_id), task) + return task + + def rollback_anchor_for_nodes( + self, + task_id: str, + node_ids: list[str], + *, + fallback_revision_id: str = "", + ) -> str: + """Return the revision before every affected node's latest checkpoint. + + Returning each affected revision's parent (rather than the revision + itself) ensures the faulty node is regenerated. A common ancestor + keeps unrelated upstream work intact while permitting a single rollback + over any number of affected nodes. + """ + task = self.read_task(task_id) or {} + revisions = [item for item in task.get("revisions") or () if isinstance(item, dict)] + by_id = {str(item.get("revision_id") or ""): item for item in revisions} + parents: list[str] = [] + for node_id in dict.fromkeys(str(item) for item in node_ids if str(item)): + matching = [item for item in revisions if item.get("status") == "success" and str(item.get("node_id") or "") == node_id] + if matching: + parents.append(str(matching[-1].get("parent_revision_id") or "")) + if not parents: + return fallback_revision_id + + def lineage(revision_id: str) -> list[str]: + chain = [revision_id] + seen = {revision_id} + current = revision_id + while current: + parent = str((by_id.get(current) or {}).get("parent_revision_id") or "") + if parent in seen: + break + chain.append(parent) + seen.add(parent) + current = parent + return chain + + common = set(lineage(parents[0])) + for parent in parents[1:]: + common.intersection_update(lineage(parent)) + if not common: + return fallback_revision_id + return next((revision for revision in lineage(parents[0]) if revision in common), fallback_revision_id) + + def write_generation_failure(self, task_id: str, payload: dict[str, Any]) -> str: + """Persist an attempt-level diagnostic without changing lifecycle.""" + relative = Path("generation-failures") / f"failure_{secrets.token_hex(8)}.json" + write_json(self.task_dir(task_id) / relative, payload) + return relative.as_posix() + + def generation_spec_path(self, task_id: str) -> Path: + return self.task_dir(task_id) / "generation-spec.json" + + def write_generation_spec(self, task_id: str, spec: dict[str, Any]) -> Path: + task = self.ensure_task(task_id, "") + path = self.generation_spec_path(task_id) + write_json(path, spec) + task["generation_spec_path"] = path.relative_to(self.task_dir(task_id)).as_posix() + task["updated_at"] = now_iso() + write_json(self.task_path(task_id), task) + return path + + def generation_run_context_path(self, task_id: str) -> Path: + return self.task_dir(task_id) / "generation-run-context.json" + + def write_generation_run_context(self, task_id: str, context: dict[str, Any]) -> Path: + """Persist the frozen authoring inputs needed to resume after restart.""" + task = self.ensure_task(task_id, "") + path = self.generation_run_context_path(task_id) + write_json(path, context) + task["run_context_path"] = path.relative_to(self.task_dir(task_id)).as_posix() + task["updated_at"] = now_iso() + write_json(self.task_path(task_id), task) + return path + + def read_generation_run_context(self, task_id: str) -> dict[str, Any] | None: + task = self.read_task(task_id) or {} + relative = str(task.get("run_context_path") or "") + context = read_json(self.artifact_path(task_id, relative)) if relative else None + return context if isinstance(context, dict) else None + + def running_tasks(self) -> list[dict[str, Any]]: + """Enumerate durable tasks that need a process-local worker.""" + tasks: list[dict[str, Any]] = [] + for candidate in self.settings.task_root.glob("cad_*"): + if not candidate.is_dir() or not TASK_ID.fullmatch(candidate.name): + continue + task = self.read_task(candidate.name) + if isinstance(task, dict) and task.get("lifecycle") == "running": + tasks.append(task) + return tasks + + def read_generation_spec(self, task_id: str) -> dict[str, Any] | None: + task = self.read_task(task_id) or {} + relative = str(task.get("generation_spec_path") or "") + return read_json(self.artifact_path(task_id, relative)) if relative else None + + def revision_dir(self, task_id: str, revision_id: str) -> Path: + return self.task_dir(task_id) / "revisions" / revision_id def current_cdsl_path(self, task_id: str) -> Path | None: task = self.read_task(task_id) - revision_id = str((task or {}).get("current_revision") or "") + revision_id = str((task or {}).get("active_revision") or (task or {}).get("current_revision") or "") if not revision_id: return None candidate = self.task_dir(task_id) / "revisions" / revision_id / "model.cdsl.json" return candidate if candidate.is_file() else None + def latest_repairable_cdsl(self, task_id: str) -> tuple[str, Path] | None: + """Return the latest revision CDSL when a quality failure needs repair.""" + task = self.read_task(task_id) + for revision in reversed((task or {}).get("revisions") or []): + revision_id = str(revision.get("revision_id") or "") + if not revision_id or str(revision.get("quality_status") or "") != "needs_repair": + continue + path = self.revision_cdsl_path(task_id, revision_id) + if path is not None: + return revision_id, path + return None + + def feature_plan_path(self, task_id: str) -> Path: + return self.task_dir(task_id) / "feature-plan.json" + + def read_feature_plan(self, task_id: str) -> dict[str, Any] | None: + safe_task = safe_task_id(task_id) + plan = read_json(self.feature_plan_path(safe_task)) + if not isinstance(plan, dict): + return None + plan_task = str(plan.get("task_id") or "") + if plan_task and plan_task != safe_task: + return None + return plan + + def write_feature_plan(self, task_id: str, plan: dict[str, Any]) -> Path: + safe_task = safe_task_id(task_id) + if not isinstance(plan, dict): + raise ValueError("Feature plan must be an object") + plan_task = str(plan.get("task_id") or "") + if plan_task and plan_task != safe_task: + raise ValueError("Feature plan task_id does not match its task") + plan["task_id"] = safe_task + path = self.feature_plan_path(safe_task) + write_json(path, plan) + return path + + def revision_topology_path(self, task_id: str, revision_id: str) -> Path | None: + task = self.read_task(task_id) + revision = next( + (item for item in (task or {}).get("revisions") or [] if str(item.get("revision_id") or "") == revision_id), + None, + ) + if not isinstance(revision, dict): + return None + relative = str(revision.get("topology_path") or "") + if not relative: + return None + candidate = self.artifact_path(task_id, relative) + return candidate if candidate.is_file() else None + + def current_topology_path(self, task_id: str) -> Path | None: + task = self.read_task(task_id) + revision_id = str((task or {}).get("active_revision") or (task or {}).get("current_revision") or "") + if not revision_id: + return None + return self.revision_topology_path(task_id, revision_id) + + def revision_cdsl_path(self, task_id: str, revision_id: str) -> Path | None: + task = self.read_task(task_id) + revision = next( + (item for item in (task or {}).get("revisions") or [] if str(item.get("revision_id") or "") == revision_id), + None, + ) + if not isinstance(revision, dict): + return None + relative = str(revision.get("cdsl_path") or "") + if not relative: + return None + candidate = self.artifact_path(task_id, relative) + return candidate if candidate.is_file() else None + def artifact_path(self, task_id: str, relative_path: str) -> Path: safe = safe_relative_path(relative_path) root = self.task_dir(task_id).resolve() @@ -274,3 +552,11 @@ class WorkspaceStore: if root != target and root not in target.parents: raise ValueError("Artifact path escapes task directory") return target + + def conversation_attachment_path(self, conversation_id: str, relative_path: str) -> Path: + safe = safe_relative_path(relative_path) + root = self.conversation_dir(conversation_id).resolve() + target = (root / safe).resolve() + if root != target and root not in target.parents: + raise ValueError("Attachment path escapes conversation directory") + return target diff --git a/backend/app/services/visual_review.py b/backend/app/services/visual_review.py new file mode 100644 index 00000000..c2fecf27 --- /dev/null +++ b/backend/app/services/visual_review.py @@ -0,0 +1,141 @@ +"""Independent, structured visual review of generated checkpoint renders.""" + +from __future__ import annotations + +import base64 +import json +from pathlib import Path +from typing import Any + +import httpx + +from app.settings import ProviderConfig, ProviderModel, Settings + + +VISUAL_REVIEW_TOOL = { + "type": "function", + "function": { + "name": "review_rendered_checkpoint", + "description": "Review fixed CAD render views against frozen requirements. Never author or modify CDSL.", + "parameters": { + "type": "object", + "properties": { + "verdict": {"enum": ["pass", "warning", "repair"]}, + "confidence": {"type": "number", "minimum": 0, "maximum": 1}, + "affected_node_ids": {"type": "array", "items": {"type": "string"}, "maxItems": 12}, + "requirement_ids": {"type": "array", "items": {"type": "string"}, "maxItems": 32}, + "evidence": {"type": "array", "items": {"type": "string"}, "maxItems": 12}, + }, + "required": ["verdict", "confidence", "affected_node_ids", "requirement_ids", "evidence"], + "additionalProperties": False, + }, + }, +} + + +class VisualReviewError(RuntimeError): + pass + + +def _image_part(path: Path) -> dict[str, Any]: + encoded = base64.b64encode(path.read_bytes()).decode("ascii") + media = "image/jpeg" if path.suffix.lower() in {".jpg", ".jpeg"} else "image/png" + return {"type": "image_url", "image_url": {"url": f"data:{media};base64,{encoded}"}} + + +def _selected_review_views(manifest: dict[str, Any], *, final_checkpoint: bool) -> list[dict[str, Any]]: + """Keep each reviewer call bounded while canonical evidence stays archived. + + A contact sheet establishes global context. Up to two planned detail views + provide node-specific evidence. The final checkpoint adds full canonical + views because it is the only point where those extra image tokens pay off. + """ + views = [item for item in manifest.get("views") or () if isinstance(item, dict)] + by_id = {str(item.get("id") or ""): item for item in views} + selected: list[dict[str, Any]] = [] + contact = Path(str(manifest.get("contact_sheet_path") or "")) + if contact.is_file(): + selected.append({"id": "contact-sheet", "path": str(contact), "camera": {"projection": "mixed"}}) + for view_id in ("detail-1", "detail-2"): + item = by_id.get(view_id) + if item is not None: + selected.append(item) + if final_checkpoint: + selected.extend(by_id[view_id] for view_id in ("top", "bottom", "front", "back", "left", "right", "isometric") if view_id in by_id) + elif not selected and "isometric" in by_id: + selected.append(by_id["isometric"]) + return selected or views[:1] + + +async def review_checkpoint( + settings: Settings, + *, + manifest: dict[str, Any], + requirements: list[dict[str, Any]], + node_id: str, + deterministic_report: dict[str, Any], + source_images: list[Path] | None = None, + final_checkpoint: bool = False, +) -> dict[str, Any]: + provider, model = settings.resolve_review_model() + views = _selected_review_views(manifest, final_checkpoint=final_checkpoint) + paths = [Path(str(item.get("path") or "")) for item in views] + if not paths or not all(path.is_file() for path in paths): + raise VisualReviewError("Review render manifest references missing image files") + content: list[dict[str, Any]] = [{ + "type": "text", + "text": json.dumps({ + "node_id": node_id, + "requirements": requirements, + "deterministic_report": deterministic_report, + "render_manifest": { + "renderer": manifest.get("renderer"), + "source": manifest.get("source"), + "views": [{"id": item.get("id"), "camera": item.get("camera"), "diagnostics": item.get("diagnostics")} for item in views], + }, + "instruction": "Identify visible missing geometry, wrong silhouette, orientation, or proportion. Do not infer hidden dimensions. Return repair only for an observable issue.", + }, ensure_ascii=False), + }] + content.extend(_image_part(path) for path in paths) + # Reference images are only supplementary evidence. Keep this bounded so + # an attachment-heavy request does not dominate every checkpoint review. + for path in (source_images or [])[:2]: + if path.is_file() and path.suffix.lower() in {".png", ".jpg", ".jpeg", ".webp"}: + content.append(_image_part(path)) + tool = json.loads(json.dumps(VISUAL_REVIEW_TOOL)) + if model.strict_tool_schema: + tool["function"]["strict"] = True + payload = { + "model": model.id, + "messages": [ + {"role": "system", "content": "You are an independent CAD visual reviewer. You may only call review_rendered_checkpoint."}, + {"role": "user", "content": content}, + ], + "tools": [tool], + "tool_choice": {"type": "function", "function": {"name": "review_rendered_checkpoint"}}, + "temperature": 0, + } + headers = {"Authorization": f"Bearer {provider.api_key}", "Content-Type": "application/json"} + async with httpx.AsyncClient(timeout=settings.llm_timeout_s) as client: + response = await client.post(f"{provider.base_url}/chat/completions", headers=headers, json=payload) + if response.status_code >= 400: + raise VisualReviewError(f"Visual review request failed ({response.status_code}): {response.text[:500]}") + try: + call = response.json()["choices"][0]["message"]["tool_calls"][0] + if call["function"]["name"] != "review_rendered_checkpoint": + raise KeyError("wrong tool") + result = json.loads(call["function"]["arguments"]) + except (KeyError, IndexError, TypeError, json.JSONDecodeError) as error: + raise VisualReviewError("Visual reviewer did not return a valid review tool call") from error + if not isinstance(result, dict) or result.get("verdict") not in {"pass", "warning", "repair"}: + raise VisualReviewError("Visual reviewer returned an invalid verdict") + try: + confidence = float(result.get("confidence")) + except (TypeError, ValueError) as error: + raise VisualReviewError("Visual reviewer returned an invalid confidence") from error + if not 0 <= confidence <= 1: + raise VisualReviewError("Visual reviewer confidence is outside [0, 1]") + for key in ("affected_node_ids", "requirement_ids", "evidence"): + if not isinstance(result.get(key), list) or not all(isinstance(item, str) for item in result[key]): + raise VisualReviewError(f"Visual reviewer returned an invalid {key}") + return {"schema_version": "cad.visual-review.v1", "node_id": node_id, "model": model.id, **result} diff --git a/backend/app/settings.py b/backend/app/settings.py index e9320a0a..9f657ade 100644 --- a/backend/app/settings.py +++ b/backend/app/settings.py @@ -50,6 +50,13 @@ class Settings: llm_timeout_s: float default_provider_id: str providers: tuple[ProviderConfig, ...] + max_repair_attempts: int = 4 + review_provider_id: str = "" + review_model_id: str = "" + node_authoring_attempts: int = 2 + node_repair_attempts: int = 2 + node_replan_attempts: int = 1 + incremental_generation: bool = False @property def llm_configured(self) -> bool: @@ -69,6 +76,22 @@ class Settings: raise ValueError("The selected model is not enabled for this provider") return provider, model + def resolve_review_model(self) -> tuple[ProviderConfig, ProviderModel]: + """Return the independently configured visual reviewer, never an author fallback.""" + provider_id = self.review_provider_id + if not provider_id: + raise ValueError("CDSL_REVIEW_PROVIDER must identify a configured vision provider") + provider = self.provider_for(provider_id) + if provider is None: + raise ValueError("The configured visual review provider is unavailable") + model_id = self.review_model_id or "" + if not model_id: + raise ValueError("CDSL_REVIEW_MODEL must identify a configured vision model") + model = provider.model(model_id) + if model is None or not model.vision: + raise ValueError("CDSL_REVIEW_MODEL must identify a configured vision-capable model") + return provider, model + def _enabled_model_ids(value: str) -> set[str]: return {item.strip() for item in value.split(",") if item.strip()} @@ -137,6 +160,13 @@ def get_settings() -> Settings: llm_api_key=default_provider.api_key, llm_model=default_model, llm_timeout_s=float(os.getenv("CDSL_LLM_TIMEOUT_S", "90")), + max_repair_attempts=max(0, int(os.getenv("CDSL_MAX_REPAIR_ATTEMPTS", "4"))), default_provider_id=default_provider_id, providers=providers, + review_provider_id=os.getenv("CDSL_REVIEW_PROVIDER", "").strip().lower(), + review_model_id=os.getenv("CDSL_REVIEW_MODEL", "").strip(), + node_authoring_attempts=max(1, int(os.getenv("CDSL_NODE_AUTHORING_ATTEMPTS", "2"))), + node_repair_attempts=max(0, int(os.getenv("CDSL_NODE_REPAIR_ATTEMPTS", "2"))), + node_replan_attempts=max(0, int(os.getenv("CDSL_NODE_REPLAN_ATTEMPTS", "1"))), + incremental_generation=_as_bool(os.getenv("CDSL_INCREMENTAL_GENERATION", "1")), ) diff --git a/backend/cdsl_importer/__init__.py b/backend/cdsl_importer/__init__.py new file mode 100644 index 00000000..c9ee396e --- /dev/null +++ b/backend/cdsl_importer/__init__.py @@ -0,0 +1,6 @@ +"""Compatibility adapters for imported and historical CDSL documents. + +This package is deliberately outside ``cdsl_engine``. It may lower retired +source syntax into the engine's generic CDSL contract, but is never imported +by the CDSL runtime. +""" diff --git a/backend/engine/cdsl_engine/distill_output3.py b/backend/cdsl_importer/distill_output3.py similarity index 100% rename from backend/engine/cdsl_engine/distill_output3.py rename to backend/cdsl_importer/distill_output3.py diff --git a/backend/cdsl_importer/legacy_profile_adapter.py b/backend/cdsl_importer/legacy_profile_adapter.py new file mode 100644 index 00000000..d90415e9 --- /dev/null +++ b/backend/cdsl_importer/legacy_profile_adapter.py @@ -0,0 +1,117 @@ +"""Explicit import/rebuild compatibility for retired CDSL profile macros. + +The CDSL runtime accepts only direct geometric profiles. This adapter is for +historical artifacts and importers: it expands a legacy shorthand exactly once +into an ``analytic_contours`` profile before generic runtime validation. +""" + +from __future__ import annotations + +import copy +from typing import Any + +from .legacy_profiles import LEGACY_PROFILE_GENERATORS + + +class LegacyProfileError(ValueError): + """A legacy profile could not be lowered to generic CDSL geometry.""" + + +def legacy_profile_types() -> tuple[str, ...]: + return tuple(sorted(LEGACY_PROFILE_GENERATORS)) + + +def _segment_from_edge(edge: dict[str, Any]) -> dict[str, Any]: + kind = str(edge.get("type") or "") + if kind == "line": + return { + "type": "line", + "start": list(edge["start_mm"][:2]), + "end": list(edge["end_mm"][:2]), + } + if kind == "arc": + segment = { + "type": "arc", + "start": list(edge["start_mm"][:2]), + "end": list(edge["end_mm"][:2]), + "center": list(edge["center_mm"][:2]), + "radius_mm": float(edge["radius_mm"]), + } + if "clockwise" in edge: + segment["clockwise"] = bool(edge["clockwise"]) + return segment + raise LegacyProfileError(f"Legacy profile emitted unsupported edge type: {kind}") + + +def _circle_contour(entity: dict[str, Any]) -> dict[str, Any]: + radius = entity.get("radius_mm") + if not isinstance(radius, (int, float)) or float(radius) <= 0: + raise LegacyProfileError("Legacy circle must have radius_mm > 0") + center = entity.get("center") or [0.0, 0.0] + if not isinstance(center, list) or len(center) < 2: + raise LegacyProfileError("Legacy circle must have a two-dimensional center") + return { + "role": "outer", + "closed": True, + "segments": [{"type": "circle", "center": [float(center[0]), float(center[1])], "radius_mm": float(radius)}], + } + + +def _edge_contour(edges: list[dict[str, Any]], *, role: str) -> dict[str, Any]: + return { + "role": role, + "closed": True, + "segments": [_segment_from_edge(edge) for edge in edges], + } + + +def lower_legacy_profile(profile: dict[str, Any], *, sketch_id: str = "") -> dict[str, Any]: + """Expand one named legacy profile to a direct analytic contour profile.""" + kind = str(profile.get("type") or "") + generator = LEGACY_PROFILE_GENERATORS.get(kind) + if generator is None: + raise LegacyProfileError(f"Unsupported legacy profile: {kind!r}") + meta: dict[str, Any] = {"id": sketch_id, "_entities": None, "_contour": None} + try: + entities, contour = generator(copy.deepcopy(profile), meta) + except (KeyError, TypeError, ValueError) as error: + raise LegacyProfileError(f"Could not lower legacy profile {kind!r}: {error}") from error + + contours: list[dict[str, Any]] = [] + if contour: + contours.append(_edge_contour(contour, role="outer")) + for region in meta.get("_regions") or []: + if not isinstance(region, dict): + continue + outer = region.get("outer") or [] + if outer: + contours.append(_edge_contour(outer, role="outer")) + for hole in region.get("holes") or []: + if hole: + contours.append(_edge_contour(hole, role="inner")) + for entity in entities or []: + if entity.get("type") == "circle" and not entity.get("construction"): + contours.append(_circle_contour(entity)) + if not contours: + raise LegacyProfileError(f"Legacy profile {kind!r} did not produce a closed contour") + return {"type": "analytic_contours", "contours": contours} + + +def lower_legacy_profiles(cdsl: dict[str, Any]) -> dict[str, Any]: + """Return a copy with every self-contained legacy sketch lowered. + + This is opt-in. Calling the runtime directly with a legacy profile still + returns an unsupported-profile diagnostic. + """ + lowered = copy.deepcopy(cdsl) + sketches = ((lowered.get("geometry") or {}).get("sketches") or []) + for sketch in sketches: + if not isinstance(sketch, dict): + continue + profile = sketch.get("profile") + if not isinstance(profile, dict): + continue + if str(profile.get("type") or "") not in LEGACY_PROFILE_GENERATORS: + continue + sketch["profile"] = lower_legacy_profile(profile, sketch_id=str(sketch.get("id") or "")) + return lowered diff --git a/backend/cdsl_importer/legacy_profiles.py b/backend/cdsl_importer/legacy_profiles.py new file mode 100644 index 00000000..bc6ec928 --- /dev/null +++ b/backend/cdsl_importer/legacy_profiles.py @@ -0,0 +1,2075 @@ +"""Historical profile macro lowering support. + +This module is an importer-only compatibility adapter. It is not imported by +the CDSL runtime and is never a planner-facing CDSL contract. It preserves +the historical macro definitions long enough to lower old artifacts to direct +``analytic_contours`` before calling the engine. + +架构:注册表模式 —— 每个轮廓类型对应一个生成器函数, + 按 "type" 字符串索引。新增形状或调整既有 profile 参数契约时: + 1. 写 def solver_xxx(profile, meta) -> (entities, contour) + 2. 注册: SHAPE_GENERATORS["xxx"] = solver_xxx + 3. 在 convert 脚本中输出对应的 profile + 4. 同步更新 profile_schema.json(Agent 与后端校验器的公开契约) + +支持的 profile 类型: + - circle: 单个圆 + - annulus: 同心圆环 + - circles: 多圆(引擎自动判断加/切除) + - circle_grid: 矩形圆孔阵列(行列+间距) + - rectangle: 矩形 + - rectangle_with_circles: 矩形 + 内圆孔/岛 + - rectangle_with_fillets: 带圆角的矩形(4角倒圆),可选内圆 + - rectangle_with_symmetric_notches: 对称槽板(矩形+4个U形缺口) + - obround: 槽形 / 键槽(2平行线 + 2半圆) + - polygon: N边多边形(顶点列表) + - ibone: 工字形凸耳(12线+4弧+4孔) + - circle_with_arc_notches: 圆+均匀圆弧凹口 + - circular_sector_slot: 圆弧扇区+中心矩形槽 + - circle_with_radial_tabs: 圆+径向矩形凸耳(带圆角) + - filleted_rect_side_slots: 圆角矩形+两侧中心U形槽 + - d_shape: D形(半圆+弦线) + - partial_ring: 部分圆环(同心弧+径向线) + - partial_ring_with_arc_island: 扇区环 + 弦上偏移弧岛(保留材料岛) + - concentric_arc_profile: 同心圆弧轮廓(多段弧+圆心标记) + - patterned_cutouts: 母形 + 规则布局的多区域切口 + - compound_patterned_cutouts: 多组母形/布局合并为一个切除草图 +""" + +from __future__ import annotations + +import math +from copy import deepcopy +from typing import Any, Iterable + + +# ═══════════════════════════════════════════════════════════════ +# 基础几何原语 +# ═══════════════════════════════════════════════════════════════ + +def _circle(center: list[float], radius_mm: float, construction: bool = False) -> dict[str, Any]: + return { + "type": "circle", + "center": [float(center[0]), float(center[1])], + "radius_mm": float(radius_mm), + "construction": construction, + } + + +def _line(start: list[float], end: list[float], construction: bool = False) -> dict[str, Any]: + return { + "type": "line", + "start": [float(start[0]), float(start[1])], + "end": [float(end[0]), float(end[1])], + "construction": construction, + } + + +def _contour_line(start_mm: list[float], end_mm: list[float]) -> dict[str, Any]: + return { + "type": "line", + "start_mm": [ + float(start_mm[0]), + float(start_mm[1]), + float(start_mm[2]) if len(start_mm) > 2 else 0.0, + ], + "end_mm": [ + float(end_mm[0]), + float(end_mm[1]), + float(end_mm[2]) if len(end_mm) > 2 else 0.0, + ], + } + + +def _contour_arc( + start_mm: list[float], + end_mm: list[float], + center_mm: list[float], + radius_mm: float | None, + clockwise: bool | None = None, +) -> dict[str, Any]: + result = { + "type": "arc", + "start_mm": [ + float(start_mm[0]), + float(start_mm[1]), + float(start_mm[2]) if len(start_mm) > 2 else 0.0, + ], + "end_mm": [ + float(end_mm[0]), + float(end_mm[1]), + float(end_mm[2]) if len(end_mm) > 2 else 0.0, + ], + "center_mm": [ + float(center_mm[0]), + float(center_mm[1]), + float(center_mm[2]) if len(center_mm) > 2 else 0.0, + ], + "radius_mm": float(radius_mm) if radius_mm is not None else None, + } + if clockwise is not None: + result["clockwise"] = bool(clockwise) + return result + + +# ═══════════════════════════════════════════════════════════════ +# 3D 坐标转换 +# ═══════════════════════════════════════════════════════════════ + +def _to_3d(workplane: dict[str, Any], u: float, v: float) -> list[float]: + """将2D局部坐标 (u,v) 映射到3D世界坐标。""" + origin = workplane.get("origin_mm") or [0, 0, 0] + x_dir = workplane.get("x_dir") or [1, 0, 0] + normal = workplane.get("normal") or [0, 0, 1] + y_dir = [ + normal[1] * x_dir[2] - normal[2] * x_dir[1], + normal[2] * x_dir[0] - normal[0] * x_dir[2], + normal[0] * x_dir[1] - normal[1] * x_dir[0], + ] + return [ + origin[0] + u * x_dir[0] + v * y_dir[0], + origin[1] + u * x_dir[1] + v * y_dir[1], + origin[2] + u * x_dir[2] + v * y_dir[2], + ] + + +def _transform_contours(contour: list[dict[str, Any]], wp: dict[str, Any]) -> list[dict[str, Any]]: + """将轮廓边的2D坐标映射为3D世界坐标。""" + result: list[dict[str, Any]] = [] + x_dir = wp.get("x_dir") or [1, 0, 0] + normal = wp.get("normal") or [0, 0, 1] + for e in contour: + e2 = deepcopy(e) + if e["type"] == "line": + e2["start_mm"] = _to_3d(wp, e["start_mm"][0], e["start_mm"][1]) + e2["end_mm"] = _to_3d(wp, e["end_mm"][0], e["end_mm"][1]) + elif e["type"] == "arc": + e2["start_mm"] = _to_3d(wp, e["start_mm"][0], e["start_mm"][1]) + e2["end_mm"] = _to_3d(wp, e["end_mm"][0], e["end_mm"][1]) + e2["center_mm"] = _to_3d(wp, e["center_mm"][0], e["center_mm"][1]) + e2["normal"] = list(normal) + result.append(e2) + return result + + +# ═══════════════════════════════════════════════════════════════ +# 矩形 / 圆辅助 +# ═══════════════════════════════════════════════════════════════ + +def _build_rect_bounds(profile: dict[str, Any]) -> tuple[float, float, float, float]: + """从 profile 中提取矩形的 (x0, y0, x1, y1) 边界。""" + center = profile.get("center") + w = float(profile.get("width_mm") or 0) + h = float(profile.get("height_mm") or 0) + if center and w > 0 and h > 0: + cx, cy = float(center[0]), float(center[1]) + return cx - w / 2, cy - h / 2, cx + w / 2, cy + h / 2 + mn = profile.get("min_mm") + mx = profile.get("max_mm") + if mn and mx: + return float(mn[0]), float(mn[1]), float(mx[0]), float(mx[1]) + raise ValueError("rectangle profile needs (center+width+height) or (min+max)") + + +def _rect_lines_and_contour( + x0: float, y0: float, x1: float, y1: float, +) -> tuple[list[dict], list[dict]]: + p00, p10, p11, p01 = [x0, y0, 0.0], [x1, y0, 0.0], [x1, y1, 0.0], [x0, y1, 0.0] + entities = [ + _line([x0, y0], [x1, y0]), + _line([x1, y0], [x1, y1]), + _line([x1, y1], [x0, y1]), + _line([x0, y1], [x0, y0]), + ] + contour = [ + _contour_line(p00, p10), + _contour_line(p10, p11), + _contour_line(p11, p01), + _contour_line(p01, p00), + ] + return entities, contour + + +def _build_circle_entities(items: list[dict]) -> list[dict]: + entities: list[dict] = [] + for item in items: + center = item.get("center") or [0.0, 0.0] + r = float(item.get("radius_mm") or 0) + if r <= 0: + raise ValueError("circle radius must be > 0") + entities.append(_circle(center, r, construction=False)) + return entities + + +def _filleted_rect_contour( + x0: float, y0: float, x1: float, y1: float, r: float, +) -> tuple[list[dict], list[dict]]: + """生成带圆角矩形的实体线和轮廓边(4直线 + 4圆弧)。""" + if r <= 0: + return _rect_lines_and_contour(x0, y0, x1, y1) + + cx0, cx1 = x0 + r, x1 - r + cy0, cy1 = y0 + r, y1 - r + + entities = [ + _line([cx0, y0], [cx1, y0]), + _line([x0, cy0], [x0, cy1]), + _line([cx0, y1], [cx1, y1]), + _line([x1, cy0], [x1, cy1]), + ] + + contour = [ + _contour_line([x0, cy0, 0.0], [x0, cy1, 0.0]), + _contour_arc([x0, cy1, 0.0], [cx0, y1, 0.0], [cx0, cy1, 0.0], r), + _contour_line([cx0, y1, 0.0], [cx1, y1, 0.0]), + _contour_arc([cx1, y1, 0.0], [x1, cy1, 0.0], [cx1, cy1, 0.0], r), + _contour_line([x1, cy1, 0.0], [x1, cy0, 0.0]), + _contour_arc([x1, cy0, 0.0], [cx1, y0, 0.0], [cx1, cy0, 0.0], r), + _contour_line([cx1, y0, 0.0], [cx0, y0, 0.0]), + _contour_arc([cx0, y0, 0.0], [x0, cy0, 0.0], [cx0, cy0, 0.0], r), + ] + + return entities, contour + + +# ═══════════════════════════════════════════════════════════════ +# 形状生成器(每个是一个独立函数,按 type 注册) +# ═══════════════════════════════════════════════════════════════ + +_Ctx = dict[str, Any] + + +def _gen_circle(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: + center = profile.get("center") or [0.0, 0.0] + r = float(profile.get("radius_mm") or 0) + if r <= 0: + raise ValueError("circle radius must be > 0") + entities = [_circle(center, r)] + cx, cy, c3d = float(center[0]), float(center[1]), [float(center[0]), float(center[1]), 0.0] + contour = [ + _contour_arc([cx + r, cy, 0.0], [cx, cy + r, 0.0], c3d, r), + _contour_arc([cx, cy + r, 0.0], [cx - r, cy, 0.0], c3d, r), + _contour_arc([cx - r, cy, 0.0], [cx, cy - r, 0.0], c3d, r), + _contour_arc([cx, cy - r, 0.0], [cx + r, cy, 0.0], c3d, r), + ] + return entities, contour + + +def _gen_annulus(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: + center = profile.get("center") or [0.0, 0.0] + inner_r = float(profile.get("inner_radius_mm") or 0) + outer_r = float(profile.get("outer_radius_mm") or 0) + if inner_r <= 0 or outer_r <= 0: + raise ValueError("annulus radii must be > 0") + if inner_r >= outer_r: + raise ValueError("inner_radius >= outer_radius") + return [_circle(center, inner_r), _circle(center, outer_r)], [] + + +def _gen_circles(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: + items = profile.get("items") or [] + if not items: + raise ValueError("circles items must be non-empty") + return _build_circle_entities(items), [] + + +def _gen_circle_grid(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: + """矩形圆孔阵列:由起点圆心 + 间距 + 行列数生成。 + + 参数: + radius_mm: 孔半径 + count_x / count_y: 列数、行数 + spacing_x_mm / spacing_y_mm: 圆心间距 + origin_mm: 第一孔圆心 [u,v](默认沿 +u/+v 铺开) + 或 center_mm: 阵列几何中心(与 origin_mm 二选一) + + 覆盖: b006(4×5 通孔阵列) + """ + r = float(profile["radius_mm"]) + nx = int(profile["count_x"]) + ny = int(profile["count_y"]) + sx = float(profile["spacing_x_mm"]) + sy = float(profile["spacing_y_mm"]) + if r <= 0 or nx < 1 or ny < 1: + raise ValueError("circle_grid: invalid radius/counts") + + if profile.get("center_mm") is not None: + cc = profile["center_mm"] + u0 = float(cc[0]) - (nx - 1) * sx / 2.0 + v0 = float(cc[1]) - (ny - 1) * sy / 2.0 + else: + origin = profile.get("origin_mm") or [0.0, 0.0] + u0, v0 = float(origin[0]), float(origin[1]) + + items = [ + {"center": [u0 + i * sx, v0 + j * sy], "radius_mm": r} + for j in range(ny) + for i in range(nx) + ] + return _build_circle_entities(items), [] + + +def _gen_rectangle(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: + x0, y0, x1, y1 = _build_rect_bounds(profile) + return _rect_lines_and_contour(x0, y0, x1, y1) + + +def _gen_rect_with_circles(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: + boundary = profile.get("boundary") or {} + circle_items = profile.get("circles") or [] + btype = boundary.get("type") or "rectangle" + if btype in ("rectangle", "rectangle_with_fillets"): + if btype == "rectangle": + x0, y0, x1, y1 = _build_rect_bounds(boundary) + ent, con = _rect_lines_and_contour(x0, y0, x1, y1) + else: + fr = float(boundary.get("fillet_radius_mm") or 0) + x0, y0, x1, y1 = _build_rect_bounds(boundary) + ent, con = _filleted_rect_contour(x0, y0, x1, y1, fr) + ent.extend(_build_circle_entities(circle_items)) + return ent, con + raise ValueError(f"rectangle_with_circles: unsupported boundary type {btype!r}") + + +def _gen_rect_with_fillets(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: + fr = float(profile.get("fillet_radius_mm") or 0) + x0, y0, x1, y1 = _build_rect_bounds(profile) + ent, con = _filleted_rect_contour(x0, y0, x1, y1, fr) + ent.extend(_build_circle_entities(profile.get("circles") or [])) + return ent, con + + +def _gen_obround(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: + center = profile.get("center") + length = float(profile.get("length_mm") or 0) + width = float(profile.get("width_mm") or 0) + if length <= 0 or width <= 0: + raise ValueError("obround needs positive length/width") + r = width / 2 + cx, cy = (float(center[0]), float(center[1])) if center else (0.0, 0.0) + offset = max(0, (length - width) / 2) + left_cx, right_cx = cx - offset, cx + offset + + if offset < 0.001: + c3d = [cx, cy, 0.0] + contour = [ + _contour_arc([cx + r, cy, 0.0], [cx, cy + r, 0.0], c3d, r), + _contour_arc([cx, cy + r, 0.0], [cx - r, cy, 0.0], c3d, r), + _contour_arc([cx - r, cy, 0.0], [cx, cy - r, 0.0], c3d, r), + _contour_arc([cx, cy - r, 0.0], [cx + r, cy, 0.0], c3d, r), + ] + return [_circle([cx, cy], r)], contour + + top_y, bot_y = cy + r, cy - r + left_c3d, right_c3d = [left_cx, cy, 0.0], [right_cx, cy, 0.0] + contour = [ + _contour_arc([right_cx, top_y, 0.0], [right_cx + r, cy, 0.0], right_c3d, r), + _contour_arc([right_cx + r, cy, 0.0], [right_cx, bot_y, 0.0], right_c3d, r), + _contour_line([right_cx, bot_y, 0.0], [left_cx, bot_y, 0.0]), + _contour_arc([left_cx, bot_y, 0.0], [left_cx - r, cy, 0.0], left_c3d, r), + _contour_arc([left_cx - r, cy, 0.0], [left_cx, top_y, 0.0], left_c3d, r), + _contour_line([left_cx, top_y, 0.0], [right_cx, top_y, 0.0]), + ] + entities = [ + _line([left_cx, bot_y], [right_cx, bot_y]), + _line([left_cx, top_y], [right_cx, top_y]), + _circle([left_cx, cy], r), + _circle([right_cx, cy], r), + ] + return entities, contour + + +def _gen_polygon(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: + vertices = profile.get("vertices") or [] + if len(vertices) >= 3: + pts_2d = [(float(v[0]), float(v[1])) for v in vertices] + entities, contour = [], [] + for i in range(len(pts_2d)): + s, e = pts_2d[i], pts_2d[(i + 1) % len(pts_2d)] + entities.append(_line(list(s), list(e))) + contour.append(_contour_line([s[0], s[1], 0.0], [e[0], e[1], 0.0])) + return entities, contour + # 015133: no vertices in profile -> use entities from compiler_context + ents = meta.get("_entities") or [] + if not ents: + raise ValueError("polygon needs at least 3 vertices or existing entities in sketch") + contour = [] + for e in ents: + t = e.get("type", "") + if t == "line": + s = e.get("start", [0, 0]) + ed = e.get("end", [0, 0]) + contour.append(_contour_line([float(s[0]), float(s[1]), 0.0], [float(ed[0]), float(ed[1]), 0.0])) + elif t == "arc": + contour.append(_contour_line( + [float(e["start"][0]), float(e["start"][1]), 0.0], + [float(e["end"][0]), float(e["end"][1]), 0.0])) + return list(ents), contour + + +def _gen_ibone(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: + """工字形凸耳:12线 + 4弧 + 4孔""" + bw, bh = float(profile["body_width_mm"]), float(profile["body_height_mm"]) + fw, fh = float(profile["flange_width_mm"]), float(profile["flange_height_mm"]) + cr = float(profile["corner_radius_mm"]) + hr = float(profile.get("hole_radius_mm") or 0) + hw, hfw, ar = bw / 2, fw / 2, bw / 2 - cr + av_bot, av_top = fh - cr, bh - fh + cr + + segs = [ + ("L", hfw, 0, -hfw, 0), + ("L", -hfw, 0, -hfw, fh - cr), + ("A", -hfw, fh - cr, -ar, fh, -ar, fh - cr, cr), + ("L", -ar, fh, -hw, fh), + ("L", -hw, fh, -hw, bh - fh), + ("L", -hw, bh - fh, -ar, bh - fh), + ("A", -ar, bh - fh, -hfw, bh - fh + cr, -ar, bh - fh + cr, cr), + ("L", -hfw, bh - fh + cr, -hfw, bh), + ("L", -hfw, bh, hfw, bh), + ("L", hfw, bh, hfw, bh - fh + cr), + ("A", hfw, bh - fh + cr, ar, bh - fh, ar, bh - fh + cr, cr), + ("L", ar, bh - fh, hw, bh - fh), + ("L", hw, bh - fh, hw, fh), + ("L", hw, fh, ar, fh), + ("A", ar, fh, hfw, fh - cr, ar, fh - cr, cr), + ("L", hfw, fh - cr, hfw, 0), + ] + entities, contour = [], [] + for s in segs: + if s[0] == "L": + _, u1, v1, u2, v2 = s + entities.append(_line([u1, v1], [u2, v2])) + contour.append(_contour_line([u1, v1, 0.0], [u2, v2, 0.0])) + else: + _, u1, v1, u2, v2, cu, cv, r = s + contour.append(_contour_arc([u1, v1, 0.0], [u2, v2, 0.0], [cu, cv, 0.0], r)) + + if hr > 0: + for cu, cv in [(-ar, av_bot), (ar, av_bot), (-ar, av_top), (ar, av_top)]: + entities.append(_circle([cu, cv], hr)) + return entities, contour + + +def _gen_rect_symmetric_notches(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: + """对称槽板:矩形+4个U形缺口(简化多边形 或 精确弧边)""" + w, h = float(profile["width_mm"]), float(profile["height_mm"]) + n = profile.get("notch") or {} + n_ys, n_ye = float(n["y_start"]), float(n["y_end"]) + n_depth = float(n["depth_mm"]) + n_ir = float(n.get("inner_radius_mm") or 0) + n_cr = float(n.get("corner_radius_mm") or 0) + hw = w / 2 + inner_u = hw - n_depth + + if n_ir > 0 and n_cr > 0: + icu, icv = hw - n_depth / 2, (n_ys + n_ye) / 2 + entities, contour = [], [] + for u1, v1, u2, v2 in [ + (hw, 0, hw, n_ys), (hw, n_ye, hw, h - n_ye), + (hw, h - n_ys, hw, h), (-hw, h, -hw, h - n_ys), + (-hw, h - n_ye, -hw, n_ye), (-hw, n_ys, -hw, 0), + (-hw, 0, hw, 0), (hw, h, -hw, h), + ]: + entities.append(_line([u1, v1], [u2, v2])) + contour.append(_contour_line([u1, v1, 0.0], [u2, v2, 0.0])) + + def _notch(sign_u, y_bot, y_top): + u = sign_u * hw + ec = sign_u * (hw - n_cr) + icu2 = sign_u * icu + contour.append(_contour_arc( + [u, y_bot, 0.0], [ec, y_bot + n_cr, 0.0], [ec, y_bot, 0.0], n_cr)) + av = y_bot + n_cr + au = icu2 - sign_u * math.sqrt(max(0.0, n_ir ** 2 - (av - icv) ** 2)) + contour.append(_contour_line([ec, av, 0.0], [au, av, 0.0])) + bv = y_top - n_cr + bu = icu2 - sign_u * math.sqrt(max(0.0, n_ir ** 2 - (bv - icv) ** 2)) + contour.append(_contour_arc( + [au, av, 0.0], [bu, bv, 0.0], [icu2, icv, 0.0], n_ir)) + contour.append(_contour_line([bu, bv, 0.0], [ec, bv, 0.0])) + contour.append(_contour_arc( + [ec, bv, 0.0], [u, y_top, 0.0], [ec, y_top, 0.0], n_cr)) + + _notch(+1, n_ys, n_ye) + _notch(+1, h - n_ye, h - n_ys) + _notch(-1, n_ys, n_ye) + _notch(-1, h - n_ye, h - n_ys) + return entities, contour + + # 简化多边形(5 参数) + verts = [ + (hw, 0), (hw, n_ys), (inner_u, n_ys), (inner_u, n_ye), + (hw, n_ye), (hw, h - n_ye), (inner_u, h - n_ye), + (inner_u, h - n_ys), (hw, h - n_ys), (hw, h), + (-hw, h), (-hw, h - n_ys), (-inner_u, h - n_ys), + (-inner_u, h - n_ye), (-hw, h - n_ye), (-hw, n_ye), + (-inner_u, n_ye), (-inner_u, n_ys), (-hw, n_ys), (-hw, 0), + ] + entities, contour = [], [] + pts = [(float(v[0]), float(v[1])) for v in verts] + for i in range(len(pts)): + s, e = pts[i], pts[(i + 1) % len(pts)] + entities.append(_line(list(s), list(e))) + contour.append(_contour_line([s[0], s[1], 0.0], [e[0], e[1], 0.0])) + return entities, contour + + +def _gen_revolve_chamfer(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: + """旋转切除的梯形截面(5顶点,相对轴顶点定义)。""" + ah = float(profile["axis_height_mm"]) + tw = float(profile["top_width_mm"]) + bw = float(profile["bottom_width_mm"]) + wi = float(profile.get("wall_inset_mm") or 0) + si = float(profile.get("step_inset_mm") or 0) + side = profile.get("on_axis_side", "left") + sign = -1 if side == "left" else 1 + v0 = (sign * tw, -wi); v1 = (0.0, 0.0); v2 = (0.0, -ah) + v3 = (sign * bw, -ah); v4 = (sign * tw, -si) + entities, contour = [], [] + for s, e in [(v0, v1), (v1, v2), (v2, v3), (v3, v4), (v4, v0)]: + entities.append(_line(list(s), list(e))) + contour.append(_contour_line([s[0], s[1], 0.0], [e[0], e[1], 0.0])) + return entities, contour + + +def _gen_revolve_chamfer_slanted(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: + """旋转切除的斜底梯形截面(5顶点)。 + + 与 revolve_chamfer 的区别:底部为斜边(轴底→壁底不是水平线)。 + 参数: + axis_height_mm: 轴侧总高度(V1→V2) + top_width_mm: 顶部宽度(轴→外壁) + wall_inset_mm: 顶部台阶深度(V0→V4 的 V 偏移) + wall_height_mm: 壁段高度(V4→V3) + wall_width_mm: 壁距轴的距离 + on_axis_side: "left"(U负) 或 "right"(U正) + """ + ah = float(profile["axis_height_mm"]) + tw = float(profile["top_width_mm"]) + wi = float(profile.get("wall_inset_mm") or 0) + wh = float(profile["wall_height_mm"]) + ww = float(profile["wall_width_mm"]) + side = profile.get("on_axis_side", "left") + sign = -1 if side == "left" else 1 + + v0 = (sign * tw, 0.0) # 顶部外侧 + v1 = (0.0, 0.0) # 轴顶点 + v2 = (0.0, -ah) # 轴底部 + v3 = (sign * ww, -wi - wh) # 壁底部(斜边连接到 V2) + v4 = (sign * ww, -wi) # 壁顶部(台阶) + + entities, contour = [], [] + for s, e in [(v0, v1), (v1, v2), (v2, v3), (v3, v4), (v4, v0)]: + entities.append(_line(list(s), list(e))) + contour.append(_contour_line([s[0], s[1], 0.0], [e[0], e[1], 0.0])) + return entities, contour + + +# ═══════════════════════════════════════════════════════════════ +# 弧边复合轮廓生成器(按"015133 手册"方法注册) +# ═══════════════════════════════════════════════════════════════ + + +def _gen_circle_with_arc_notches(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: + """圆+圆弧凹口:大圆上均匀分布的弧形缺口。 + + 参数: + outer_radius_mm: 大圆半径 + notch_radius_mm: 每个凹口的圆弧半径 + notch_angles_deg: 凹口所在的角度列表(度,从+u顺时针) + 默认 [0, 90, 180, 270](十字槽) + 例: [0,90,180,270] → 十字形,[45,135,225,315] → 斜十字 + + 覆盖文件: 48, 49, 50, 82 + """ + import math + R = float(profile["outer_radius_mm"]) + r = float(profile["notch_radius_mm"]) + angles_deg = profile.get("notch_angles_deg", [0, 90, 180, 270]) + + # 每个凹口在大圆上占据的半角宽度 + delta = math.acos(max(-1.0, min(1.0, 1.0 - r * r / (2.0 * R * R)))) + angles_rad = [math.radians(a) for a in sorted(angles_deg)] + + entities, contour = [], [] + n = len(angles_rad) + + for i in range(n): + prev_end = angles_rad[i - 1] + delta # 上一个凹口离开点 + curr_enter = angles_rad[i] - delta # 当前凹口入口 + + # 大弧:从上一个凹口离开点到当前凹口入口(顺时针) + ps_u, ps_v = R * math.cos(prev_end), R * math.sin(prev_end) + pe_u, pe_v = R * math.cos(curr_enter), R * math.sin(curr_enter) + + contour.append(_contour_arc( + [ps_u, ps_v, 0.0], [pe_u, pe_v, 0.0], + [0.0, 0.0, 0.0], R, + )) + + # 凹口弧:从入口→出口,中心在外圆上 + curr_exit = angles_rad[i] + delta + nc_u = R * math.cos(angles_rad[i]) + nc_v = R * math.sin(angles_rad[i]) + + pn_enter_u = R * math.cos(curr_enter) + pn_enter_v = R * math.sin(curr_enter) + pn_exit_u = R * math.cos(curr_exit) + pn_exit_v = R * math.sin(curr_exit) + + # 凹口弧:从出口回到入口(与大弧方向相反) + contour.append(_contour_arc( + [pn_exit_u, pn_exit_v, 0.0], [pn_enter_u, pn_enter_v, 0.0], + [nc_u, nc_v, 0.0], r, + )) + + entities.extend(_build_circle_entities(profile.get("circles") or [])) + return entities, contour + + +def _gen_circular_sector_slot(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: + """圆弧扇区槽:一段大圆弧 + 两条径向线 + 一个矩形槽口。 + + 形状:一个扇形(大圆弧 + 两侧径向线),中心开矩形槽。 + 由两条大弧(上/下)、两条径向线、一个中央矩形槽组成。 + + 参数: + arc_radius_mm: 大弧半径(圆心在原点) + slot_half_width_mm: 槽口半宽(从圆心起的径向距离) + chord_half_mm: 弧弦线半长(弧的跨距,决定弧幅度) + + 覆盖文件: 87, 88, 89, 90 + """ + import math + R = float(profile["arc_radius_mm"]) + hw = float(profile["slot_half_width_mm"]) + ch = float(profile["chord_half_mm"]) + + # 弧端点:在圆上,到中心轴的垂直距离为 ch + # 弧端点在圆 R 上,距离中心轴 ch,其角度为 asin(ch/R) + half_angle = math.asin(max(-1.0, min(1.0, ch / R))) + + # 弧端点坐标(圆上,2 个象限) + arc_x = R * math.cos(half_angle) + arc_y = R * math.sin(half_angle) if ch >= 0 else -R * math.sin(-half_angle) + + # 4 个关键点(顺时针) + # 下弧: x 从 -arc_x 到 +arc_x, y = -ch (在圆上 y = ±arc_y ≈ ±ch) + # 右上角弧段 + arc_x_neg_angle = R * math.cos(-half_angle) + arc_y_neg = R * math.sin(-half_angle) + + p_bot_right = (arc_x_neg_angle, arc_y_neg) # 右下(圆上,负半角) + p_bot_left = (arc_x, arc_y) # 右下(圆上,正半角)... 等等 + + # 直接按 87 的几何定义:下弧从 (+xs, -ch) 到 (-xs, -ch),上弧从 (-xs, +ch) 到 (+xs, +ch) + # xs 由圆 R 和 ch 确定 + xs = math.sqrt(max(0, R * R - ch * ch)) + + contour = [ + # 下弧:从 (xs, -ch) 到 (-xs, -ch),圆心原点,半径 R(顺时针) + _contour_arc([xs, -ch, 0.0], [-xs, -ch, 0.0], [0.0, 0.0, 0.0], R), + # 左侧线:(-xs, -ch) → (-xs, +ch) ... + # 不对,夹着槽口 + + ] + + # 重新按 87 的实际边序列构建 + # [0] line (-27.5, -13)→(-27.5, +13) → 槽口左竖线 + # [1] line (-27.5, +13)→(-37.83, +13) → 径向连接 + # [2] arc r=40 c=(0,0) s=(+37.83, +13)→(-37.83, +13) → 上弧 + # [3] line (+27.5, +13)→(+37.83, +13) → 径向连接(右侧) + # [4] line (+27.5, -13)→(+27.5, +13) → 槽口右竖线 + # [5] line (+27.5, -13)→(+37.83, -13) → 径向连接 + # [6] arc r=40 c=(0,0) s=(-37.83, -13)→(+37.83, -13) → 下弧 + # [7] line (-27.5, -13)→(-37.83, -13) → 径向连接 + + # 参数化: + # slot_half = 27.5 (槽口半宽) + # chord_half = 13 (弧端点的 w 坐标,确定弧的跨度) + # arc_radius = 40 + # arc_x_end = sqrt(R² - ch²) = sqrt(1600 - 169) ≈ 37.83 + + sh = hw # slot half + axe = math.sqrt(max(0.0, R * R - ch * ch)) # arc x-endpoint + + contour = [ + # 槽口竖线(从左下到左上) + _contour_line([-sh, -ch, 0.0], [-sh, ch, 0.0]), + # 连接到弧(从槽口左上到弧左下) + _contour_line([-sh, ch, 0.0], [-axe, ch, 0.0]), + # 上弧(从弧左下到弧右下,经过原点顶) + _contour_arc([axe, ch, 0.0], [-axe, ch, 0.0], [0.0, 0.0, 0.0], R), + # 连接到槽口(从弧右下到槽口右上) + _contour_line([sh, ch, 0.0], [axe, ch, 0.0]), + # 槽口竖线(从右上到右下) + _contour_line([sh, ch, 0.0], [sh, -ch, 0.0]), + # 连接到弧(从槽口右下到弧右上) + _contour_line([sh, -ch, 0.0], [axe, -ch, 0.0]), + # 下弧(从弧右上到弧左上,经过原点底) + _contour_arc([-axe, -ch, 0.0], [axe, -ch, 0.0], [0.0, 0.0, 0.0], R), + # 连接到槽口(从弧左上到槽口左下) + _contour_line([-sh, -ch, 0.0], [-axe, -ch, 0.0]), + ] + + entities = [ + _line([-sh, -ch], [-sh, ch]), + _line([-sh, ch], [-axe, ch]), + _line([sh, ch], [axe, ch]), + _line([sh, ch], [sh, -ch]), + _line([sh, -ch], [axe, -ch]), + _line([-sh, -ch], [-axe, -ch]), + ] + + entities.extend(_build_circle_entities(profile.get("circles") or [])) + return entities, contour + + +def _gen_circle_with_radial_tabs(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: + """圆+径向凸耳:大圆弧上有矩形凸起。 + + 形状:一个大圆被两侧的矩形凸耳取代部分弧段。 + 简化表示为直边多边形(忽略 r=1 的圆角,体积误差 <1%)。 + + 参数: + outer_radius_mm: 大圆半径 + tab_u_half_mm: 凸耳半宽(弧线方向,从根部到内缘) + tab_v_offset_mm: 凸耳离弧线的垂直距离(即凸耳顶部距弧线的v偏移) + + 覆盖文件: 91, 92, 93, 94, 95 + """ + import math + R = float(profile["outer_radius_mm"]) + tu = float(profile.get("tab_u_half_mm") or 0) + tv = float(profile.get("tab_v_offset_mm") or 0) + + # 凸耳根部在圆上的角度 + angle = math.asin(min(1.0, max(0.0, tv / R))) + + # 弧上根部点(右侧) + root_u = R * math.cos(angle) + root_v = R * math.sin(angle) + + # 凸耳内缘 + inner_u = root_u - tu + inner_v = root_v * 0.9 # 略浅于弧线 + + # 构建轮廓:大弧(上) → 右凸耳 → 大弧(下) → 左凸耳 → 闭合 + + contour = [] + + # 上弧:从左侧根部到右侧根部(经过顶点) + contour.append(_contour_arc( + [root_u, root_v, 0.0], [-root_u, root_v, 0.0], + [0.0, 0.0, 0.0], R)) + + # 右侧凸耳(多边形:根部→内顶→内底→根部) + contour.append(_contour_line([root_u, root_v, 0.0], [inner_u, inner_v, 0.0])) + contour.append(_contour_line([inner_u, inner_v, 0.0], [inner_u, -inner_v, 0.0])) + contour.append(_contour_line([inner_u, -inner_v, 0.0], [root_u, -root_v, 0.0])) + + # 下弧:从右侧底部到左侧底部(经过底点) + contour.append(_contour_arc( + [-root_u, -root_v, 0.0], [root_u, -root_v, 0.0], + [0.0, 0.0, 0.0], R)) + + # 左侧凸耳(镜像) + contour.append(_contour_line([-root_u, -root_v, 0.0], [-inner_u, -inner_v, 0.0])) + contour.append(_contour_line([-inner_u, -inner_v, 0.0], [-inner_u, inner_v, 0.0])) + contour.append(_contour_line([-inner_u, inner_v, 0.0], [-root_u, root_v, 0.0])) + + entities = [ + _line([root_u, root_v], [inner_u, inner_v]), + _line([inner_u, inner_v], [inner_u, -inner_v]), + _line([inner_u, -inner_v], [root_u, -root_v]), + _line([-root_u, -root_v], [-inner_u, -inner_v]), + _line([-inner_u, -inner_v], [-inner_u, inner_v]), + _line([-inner_u, inner_v], [-root_u, root_v]), + ] + + entities.extend(_build_circle_entities(profile.get("circles") or [])) + return entities, contour + + +def _gen_filleted_rect_side_slots(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: + """圆角矩形+两侧中心凹槽。 + + 形状:圆角矩形,左右两侧中心各有一个 U 形凹槽(半圆槽)。 + + 参数: + half_width_mm: 矩形半宽(不含圆角) + half_height_mm: 矩形半高(不含圆角) + corner_radius_mm: 四角圆角半径 + slot_radius_mm: 两侧中心凹槽半径(默认 5.0) + circles: 可选内部圆(孔洞),[{center:[x,y], radius_mm:r}, …] + + 覆盖文件: 58, 63, 64, 65, 66 + """ + hw = float(profile["half_width_mm"]) + hh = float(profile["half_height_mm"]) + cr = float(profile["corner_radius_mm"]) + sr = float(profile.get("slot_radius_mm") or cr * 0.5) + + # 注意: v=-Z, 所以 v 正方向朝 Z 负 + # 矩形范围: u=[-hw,hw], v=[-hh,+hh] 对应 z=[+hh,-hh] + # 上边 (z=+hh): v=-hh, 下边 (z=-hh): v=+hh + + entities, contour = [], [] + top_v, bot_v = -hh, hh # 上边 v=-hh, 下边 v=+hh + + # 上边(直线,从左上角到右上角) + contour.append(_contour_line( + [-(hw - cr), top_v, 0.0], [(hw - cr), top_v, 0.0])) + entities.append(_line([-(hw - cr), top_v], [(hw - cr), top_v])) + + # 右上圆角(逆时针绕 center: 从顶点到右侧) + contour.append(_contour_arc( + [(hw - cr), top_v, 0.0], [hw, top_v + cr, 0.0], + [(hw - cr), top_v + cr, 0.0], cr)) + + # 右边上半(从圆角到凹槽上方) + contour.append(_contour_line( + [hw, top_v + cr, 0.0], [hw, -sr, 0.0])) + entities.append(_line([hw, top_v + cr], [hw, -sr])) + + # 右侧中心凹槽(半圆向内的 U 形凹口) + contour.append(_contour_arc( + [hw, sr, 0.0], [hw, -sr, 0.0], + [hw, 0.0, 0.0], sr)) + + # 右边下半(从凹槽下方到右下角) + contour.append(_contour_line( + [hw, sr, 0.0], [hw, bot_v - cr, 0.0])) + entities.append(_line([hw, sr], [hw, bot_v - cr])) + + # 右下圆角 + contour.append(_contour_arc( + [hw, bot_v - cr, 0.0], [(hw - cr), bot_v, 0.0], + [(hw - cr), bot_v - cr, 0.0], cr)) + + # 下边 + contour.append(_contour_line( + [(hw - cr), bot_v, 0.0], [-(hw - cr), bot_v, 0.0])) + entities.append(_line([(hw - cr), bot_v], [-(hw - cr), bot_v])) + + # 左下圆角 + contour.append(_contour_arc( + [-(hw - cr), bot_v, 0.0], [-hw, bot_v - cr, 0.0], + [-(hw - cr), bot_v - cr, 0.0], cr)) + + # 左边下半 + contour.append(_contour_line( + [-hw, bot_v - cr, 0.0], [-hw, sr, 0.0])) + entities.append(_line([-hw, bot_v - cr], [-hw, sr])) + + # 左侧中心凹槽 + contour.append(_contour_arc( + [-hw, -sr, 0.0], [-hw, sr, 0.0], + [-hw, 0.0, 0.0], sr)) + + # 左边上半 + contour.append(_contour_line( + [-hw, -sr, 0.0], [-hw, top_v + cr, 0.0])) + entities.append(_line([-hw, -sr], [-hw, top_v + cr])) + + # 左上圆角 + contour.append(_contour_arc( + [-hw, top_v + cr, 0.0], [-(hw - cr), top_v, 0.0], + [-(hw - cr), top_v + cr, 0.0], cr)) + + entities.extend(_build_circle_entities(profile.get("circles") or [])) + return entities, contour + + +# ═══════════════════════════════════════════════════════════════ +# 更多弧边复合轮廓生成器 +# ═══════════════════════════════════════════════════════════════ + + +def _gen_d_shape(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: + """D形(半圆+弦线):一条直线 + 一条大圆弧,形如字母 D。 + + 参数: + radius_mm: 大弧半径(圆心在原点) + chord_sign: 弦线方向,"left"=弦在 x>0 侧,"right"=弦在 x<0 侧 + 默认 "left"(弦线在 +x 侧,弧形开口朝 -x) + + 覆盖文件: 144358 + """ + import math + R = float(profile["radius_mm"]) + side = profile.get("chord_sign", "left") + sign = 1 if side == "left" else -1 + + # chord at x=cx (cx^2 + y^2 = R^2) + # For side="left": chord at x = sqrt(R^2 - y_len^2) ... + # Actually, from 144358 data: arc r=41 c=(0,0) from (34,22.9) to (34,-22.9) + # So the chord is at x=34, v ranges from -22.9 to 22.9 + # v_max = sqrt(R^2 - x^2) = sqrt(41^2 - 34^2) = sqrt(1681-1156) = sqrt(525) ≈ 22.91 ✓ + + v_max = math.sqrt(max(0.0, R * R - (R - 7) * (R - 7))) + # 实际上,chord x 可以根据 radius 推导 + # 使用 chord_x 参数如果存在,否则用近似 + chord_x = float(profile.get("chord_x_mm") or R * 0.83) # 默认在半径 83% 处 + + v_half = math.sqrt(max(0.0, R * R - chord_x * chord_x)) + cx = sign * chord_x # 弦线 x 坐标 + + contour = [ + # 弦线(从下到上) + _contour_line([cx, -v_half, 0.0], [cx, v_half, 0.0]), + # 大弧(从右上到左下,即从左到右沿弧线) + _contour_arc([cx, v_half, 0.0], [cx, -v_half, 0.0], [0.0, 0.0, 0.0], R), + ] + + entities = [ + _line([cx, -v_half], [cx, v_half]), + ] + + entities.extend(_build_circle_entities(profile.get("circles") or [])) + return entities, contour + + +def _gen_partial_ring(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: + """部分圆环(同心圆弧+径向直线):两段同心弧 + 两条径向线。 + + 形状像一个扇区环 (sector annulus),由内外两段同心弧和两侧径向线组成。 + + 参数: + inner_radius_mm: 内弧半径 + outer_radius_mm: 外弧半径 + half_angle_deg: 弧的半角度(两侧各 half_angle 度,总张角 2*half_angle) + + 覆盖文件: 177126 + """ + import math + ir = float(profile["inner_radius_mm"]) + oR = float(profile["outer_radius_mm"]) + h_deg = float(profile.get("half_angle_deg") or 45.0) + h_rad = math.radians(h_deg) + + # 内弧端点 + iu_pos = ir * math.cos(h_rad) + iv_pos = ir * math.sin(h_rad) + iu_neg = ir * math.cos(-h_rad) + iv_neg = ir * math.sin(-h_rad) + + # 外弧端点 + ou_pos = oR * math.cos(h_rad) + ov_pos = oR * math.sin(h_rad) + ou_neg = oR * math.cos(-h_rad) + ov_neg = oR * math.sin(-h_rad) + + contour = [ + # 右侧径向线(从内弧到外弧,+h角度) + _contour_line([iu_pos, iv_pos, 0.0], [ou_pos, ov_pos, 0.0]), + # 外弧(从 +h 到 -h) + _contour_arc([ou_neg, ov_neg, 0.0], [ou_pos, ov_pos, 0.0], [0.0, 0.0, 0.0], oR), + # 左侧径向线(从外弧到内弧,-h角度) + _contour_line([ou_neg, ov_neg, 0.0], [iu_neg, iv_neg, 0.0]), + # 内弧(从 -h 到 +h) + _contour_arc([iu_pos, iv_pos, 0.0], [iu_neg, iv_neg, 0.0], [0.0, 0.0, 0.0], ir), + ] + + entities = [ + _line([iu_pos, iv_pos], [ou_pos, ov_pos]), + _line([ou_neg, ov_neg], [iu_neg, iv_neg]), + ] + + entities.extend(_build_circle_entities(profile.get("circles") or [])) + return entities, contour + + +def _gen_partial_ring_with_arc_island(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: + """扇区环 + 外弦上的等宽弧岛(岛不切除,作为区域内孔)。 + + 每个 replica 生成一块「外轮廓=扇区环、内孔=偏移弧岛」的区域。 + 岛外弧端点落在扇区外弧弦上,横坐标取 ±inner·cos(half_angle)(相对角平分线)。 + + 参数: + inner_radius_mm / outer_radius_mm / half_angle_deg: 扇区环 + island_radius_mm: 岛外弧半径 + island_gap_mm: 岛内外弧径向间距(等宽) + center_angles_deg 或 replicas[{center_angle_deg}]: 各扇区角平分线方向(度) + + 覆盖: b005 + """ + ir = float(profile["inner_radius_mm"]) + oR = float(profile["outer_radius_mm"]) + h_deg = float(profile.get("half_angle_deg") or 45.0) + island_r = float(profile["island_radius_mm"]) + gap = float(profile.get("island_gap_mm") or 1.0) + if ir <= 0 or oR <= ir or island_r <= gap: + raise ValueError("partial_ring_with_arc_island: invalid radii") + + replicas = profile.get("replicas") + if replicas: + angles = [float(r["center_angle_deg"]) for r in replicas] + else: + angles = [float(a) for a in (profile.get("center_angles_deg") or [0.0])] + + h = math.radians(h_deg) + entities: list[_Ctx] = [] + regions: list[dict[str, Any]] = [] + + for ca_deg in angles: + ca = math.radians(ca_deg) + a0, a1 = ca - h, ca + h + + def polar(r: float, ang: float) -> list[float]: + return [r * math.cos(ang), r * math.sin(ang), 0.0] + + # 扇区环外轮廓(逆时针:外弧 a0→a1,径向,内弧 a1→a0,径向) + ou0, ou1 = polar(oR, a0), polar(oR, a1) + iu0, iu1 = polar(ir, a0), polar(ir, a1) + outer = [ + _contour_arc(ou0, ou1, [0.0, 0.0, 0.0], oR), + _contour_line(ou1, iu1), + _contour_arc(iu1, iu0, [0.0, 0.0, 0.0], ir), + _contour_line(iu0, ou0), + ] + entities.extend([ + _line(ou0[:2], ou1[:2]), + _line(ou1[:2], iu1[:2]), + _line(iu1[:2], iu0[:2]), + _line(iu0[:2], ou0[:2]), + ]) + + # 外弦中点与弦向单位向量;岛端点 = M ± inner·cos(h)·chord_dir + ux, uy = math.cos(ca), math.sin(ca) + mx = oR * ux * math.cos(h) + my = oR * uy * math.cos(h) + cdx, cdy = -uy, ux + span = ir * math.cos(h) + e1 = [mx + span * cdx, my + span * cdy, 0.0] + e2 = [mx - span * cdx, my - span * cdy, 0.0] + + # 岛心在角平分线上:|E - t·u| = island_r,取距原点较近根 + dot = e1[0] * ux + e1[1] * uy + e2n = e1[0] * e1[0] + e1[1] * e1[1] + disc = max(0.0, dot * dot - (e2n - island_r * island_r)) + t1, t2 = dot - math.sqrt(disc), dot + math.sqrt(disc) + t = t1 if abs(t1) <= abs(t2) else t2 + cx, cy = t * ux, t * uy + c3 = [cx, cy, 0.0] + + def inward(pt: list[float]) -> list[float]: + vx, vy = cx - pt[0], cy - pt[1] + L = math.hypot(vx, vy) or 1.0 + return [pt[0] + vx / L * gap, pt[1] + vy / L * gap, 0.0] + + i1, i2 = inward(e1), inward(e2) + ri = island_r - gap + + # 岛孔:外弧 e1→e2(经外侧鼓包)再经内弧返回;与扇区同向时作孔需反向 + # 外弧走短弧中指向外侧(远离原点)的那条 + hole = [ + _contour_arc(e1, e2, c3, island_r), + _contour_line(e2, i2), + _contour_arc(i2, i1, c3, ri), + _contour_line(i1, e1), + ] + entities.extend([ + _line(e1[:2], e2[:2]), + _line(e2[:2], i2[:2]), + _line(i2[:2], i1[:2]), + _line(i1[:2], e1[:2]), + ]) + regions.append({"outer": outer, "holes": [hole]}) + + meta["_regions"] = regions + return entities, [] + + +def _gen_arc_chain(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: + """弧链轮廓:多段首尾相连的弧形成闭合轮廓(各弧可有不同圆心)。 + + 用于 revolve 特征的截面草图,由多段圆弧端到端连接组成。 + + 参数: + arcs: 弧描述列表 [{radius_mm, center:[u,v], start_angle_deg, end_angle_deg}, ...] + (每个弧从 start_angle 到 end_angle,起点与上一条弧终点重合) + + 覆盖文件: 020543 + """ + import math + arc_list = profile.get("arcs") or [] + + if not arc_list or len(arc_list) < 2: + raise ValueError("arc_chain needs at least 2 arcs") + + entities, contour = [], [] + + for arc_desc in arc_list: + r = float(arc_desc["radius_mm"]) + center = arc_desc.get("center") or [0.0, 0.0] + cu, cv = float(center[0]), float(center[1]) + sa = math.radians(float(arc_desc["start_angle_deg"])) + ea = math.radians(float(arc_desc["end_angle_deg"])) + + su = cu + r * math.cos(sa) + sv = cv + r * math.sin(sa) + eu = cu + r * math.cos(ea) + ev = cv + r * math.sin(ea) + + contour.append(_contour_arc( + [su, sv, 0.0], [eu, ev, 0.0], + [cu, cv, 0.0], r, + )) + + entities.extend(_build_circle_entities(profile.get("circles") or [])) + return entities, contour + + +def _gen_radial_slot(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: + """径向槽:两段同心圆弧 + 两端圆角,形如弧形环段。 + + 用于 extrude_cut 在圆柱壁面上开弧形槽口。 + + 参数: + inner_radius_mm: 内弧半径 + outer_radius_mm: 外弧半径 + start_angle_deg: 槽起始角度(度,从工作平面 x_dir 方向逆时针测量) + end_angle_deg: 槽终止角度 + + 覆盖文件: 020543 (sk_02, sk_03, sk_04) + """ + import math + ir = float(profile["inner_radius_mm"]) + oR = float(profile["outer_radius_mm"]) + sa_deg = float(profile["start_angle_deg"]) + ea_deg = float(profile["end_angle_deg"]) + + fr = (oR - ir) / 2.0 # 端盖圆角半径 + sa = math.radians(sa_deg) + ea = math.radians(ea_deg) + + entities, contour = [], [] + + # 角度从工作平面 x_dir 方向测量 → u = r·cos(θ), v = r·sin(θ) + # 1. 内弧(从 start→end) + isu = ir * math.cos(sa); isv = ir * math.sin(sa) + ieu = ir * math.cos(ea); iev = ir * math.sin(ea) + contour.append(_contour_arc( + [isu, isv, 0.0], [ieu, iev, 0.0], + [0.0, 0.0, 0.0], ir, + )) + + # 2. 终端圆角(半圆,从内弧终点到外弧终点) + fc_u = (ir + oR) / 2.0 + fcu_s = fc_u * math.cos(ea); fcv_s = fc_u * math.sin(ea) + osu = oR * math.cos(sa); osv = oR * math.sin(sa) + oeu = oR * math.cos(ea); oev = oR * math.sin(ea) + + contour.append(_contour_arc( + [ieu, iev, 0.0], [oeu, oev, 0.0], + [fcu_s, fcv_s, 0.0], fr, + )) + + # 3. 外弧(从 end→start,反向) + contour.append(_contour_arc( + [oeu, oev, 0.0], [osu, osv, 0.0], + [0.0, 0.0, 0.0], oR, + )) + + # 4. 起始端圆角(从外弧起点到内弧起点) + fcu_e = fc_u * math.cos(sa); fcv_e = fc_u * math.sin(sa) + contour.append(_contour_arc( + [osu, osv, 0.0], [isu, isv, 0.0], + [fcu_e, fcv_e, 0.0], fr, + )) + + entities.extend(_build_circle_entities(profile.get("circles") or [])) + return entities, contour + + +# ═══════════════════════════════════════════════════════════════ +# 程序化重复切口 +# ═══════════════════════════════════════════════════════════════ + +def _poly_contour(vertices: list[tuple[float, float]]) -> list[_Ctx]: + """把按顺序给出的二维顶点变成闭合直线轮廓。""" + return [ + _contour_line( + [vertices[i][0], vertices[i][1], 0.0], + [vertices[(i + 1) % len(vertices)][0], vertices[(i + 1) % len(vertices)][1], 0.0], + ) + for i in range(len(vertices)) + ] + + +def _transform_pattern_contour( + contour: list[_Ctx], + x_mm: float, + y_mm: float, + angle_deg: float, + scale: float = 1.0, +) -> list[_Ctx]: + """旋转、缩放并平移一个二维轮廓。""" + a = math.radians(angle_deg) + ca, sa = math.cos(a), math.sin(a) + + def point(p: list[float]) -> list[float]: + x, y = float(p[0]) * scale, float(p[1]) * scale + return [x_mm + x * ca - y * sa, y_mm + x * sa + y * ca, 0.0] + + result: list[_Ctx] = [] + for edge in contour: + item = deepcopy(edge) + item["start_mm"] = point(edge["start_mm"]) + item["end_mm"] = point(edge["end_mm"]) + if edge.get("center_mm") is not None: + item["center_mm"] = point(edge["center_mm"]) + if edge.get("radius_mm") is not None: + item["radius_mm"] = float(edge["radius_mm"]) * scale + result.append(item) + return result + + +def _pattern_motif_contour(motif: _Ctx) -> list[_Ctx]: + """从少量命名尺寸生成一个切口母形。""" + kind = str(motif.get("type") or "") + + if kind == "circle": + radius = float(motif["radius_mm"]) + return _gen_circle({"type": "circle", "radius_mm": radius}, {})[1] + + if kind in ("square", "rectangle"): + width = float(motif["width_mm"]) + height = float(motif.get("height_mm") or width) + return _rect_lines_and_contour(-width / 2.0, -height / 2.0, width / 2.0, height / 2.0)[1] + + if kind == "obround": + length = float(motif["length_mm"]) + width = float(motif["width_mm"]) + return _gen_obround( + {"type": "obround", "center": [0.0, 0.0], "length_mm": length, "width_mm": width}, + {}, + )[1] + + if kind == "cross": + size = float(motif["size_mm"]) + arm = float(motif["arm_width_mm"]) + half, arm_half = size / 2.0, arm / 2.0 + vertices = [ + (-arm_half, -half), (arm_half, -half), + (arm_half, -arm_half), (half, -arm_half), + (half, arm_half), (arm_half, arm_half), + (arm_half, half), (-arm_half, half), + (-arm_half, arm_half), (-half, arm_half), + (-half, -arm_half), (-arm_half, -arm_half), + ] + return _poly_contour(vertices) + + if kind == "d_shape_polygon": + stem = float(motif["stem_length_mm"]) + nose = float(motif["nose_depth_mm"]) + half_height = float(motif["half_height_mm"]) + segments = int(motif.get("arc_segments") or 14) + vertices = [(-stem, -half_height), (-stem, half_height), (0.0, half_height)] + # 右半椭圆;首尾端点已由直线给出,内部取样由引擎固化。 + for i in range(1, segments): + angle = math.pi / 2.0 - math.pi * i / segments + vertices.append((nose * math.cos(angle), half_height * math.sin(angle))) + vertices.append((0.0, -half_height)) + return _poly_contour(vertices) + + if kind == "regular_hexagon": + radius = float(motif["radius_mm"]) + return _poly_contour([ + ( + radius * math.cos(math.radians(60.0 * i)), + radius * math.sin(math.radians(60.0 * i)), + ) + for i in range(6) + ]) + + if kind == "skew_hexagon": + # 该族来自六边形母形的非对称离散模板;只保留一个名义半径, + # 其余稳定比例由引擎固化,不把六个顶点写进 CDSL。 + radius = float(motif["nominal_radius_mm"]) + return _poly_contour([ + (radius, 0.0), + (radius * 0.317014, radius * 0.682, ), + (-radius * 0.5, radius * 0.682), + (-radius * 1.183014, 0.0), + (-radius * 0.408494, -radius * 0.774519), + (radius * 0.317014, -radius * 0.774519), + ]) + + if kind == "triangle": + radius = float(motif["radius_mm"]) + return _poly_contour([ + ( + radius * math.cos(math.radians(120.0 * i)), + radius * math.sin(math.radians(120.0 * i)), + ) + for i in range(3) + ]) + + if kind == "teardrop_polygon": + if motif.get("left_width_mm") is not None: + left = float(motif["left_width_mm"]) + right = float(motif["right_width_mm"]) + tip = float(motif["tip_height_mm"]) + bottom = -float(motif["bottom_depth_mm"]) + shoulder = float(motif["shoulder_height_mm"]) + return _poly_contour([ + (0.0, tip), + (right, shoulder), + (right, bottom), + (-left, bottom), + (-left, shoulder), + ]) + width = float(motif["width_mm"]) + height = float(motif["height_mm"]) + shoulder = float(motif.get("shoulder_fraction") or 0.58) + half = width / 2.0 + top = height / 2.0 + bottom = -height / 2.0 + shoulder_y = bottom + height * shoulder + return _poly_contour([ + (0.0, top), + (half, shoulder_y), + (half, bottom), + (-half, bottom), + (-half, shoulder_y), + ]) + + if kind == "trapezoid": + bottom = float(motif["bottom_width_mm"]) + top = float(motif["top_width_mm"]) + height = float(motif["height_mm"]) + hh = height / 2.0 + return _poly_contour([ + (-bottom / 2.0, -hh), + (bottom / 2.0, -hh), + (top / 2.0, hh), + (-top / 2.0, hh), + ]) + + if kind == "annular_sector_polygon": + inner = float(motif["inner_radius_mm"]) + outer = float(motif["outer_radius_mm"]) + half_angle = float(motif["half_angle_deg"]) + segments = int(motif.get("arc_segments") or 8) + outer_pts = [ + ( + outer * math.cos(math.radians(-half_angle + 2.0 * half_angle * i / segments)), + outer * math.sin(math.radians(-half_angle + 2.0 * half_angle * i / segments)), + ) + for i in range(segments + 1) + ] + inner_pts = [ + ( + inner * math.cos(math.radians(half_angle - 2.0 * half_angle * i / segments)), + inner * math.sin(math.radians(half_angle - 2.0 * half_angle * i / segments)), + ) + for i in range(segments + 1) + ] + return _poly_contour(outer_pts + inner_pts) + + raise ValueError(f"patterned_cutouts: unsupported motif type {kind!r}") + + +def _pattern_placements(layout: _Ctx) -> list[tuple[float, float, float, float]]: + """展开语义布局,返回 (x, y, rotation_deg, scale)。""" + kind = str(layout.get("type") or "") + orientation = str(layout.get("orientation") or "fixed") + orientation_offset = float(layout.get("orientation_offset_deg") or 0.0) + + def orient(angle: float) -> float: + if orientation == "radial": + return angle + orientation_offset + if orientation == "tangential": + return angle + 90.0 + orientation_offset + if orientation == "snapped_radial": + snap = float(layout.get("orientation_snap_deg") or 45.0) + return round(angle / snap) * snap + orientation_offset + return orientation_offset + + if kind in ("ring", "angular"): + radius = float(layout.get("radius_mm") or 0.0) + count = int(layout["count"]) + start = float(layout.get("start_angle_deg") or 0.0) + step = float(layout.get("angle_step_deg") or (360.0 / count)) + angular_only = kind == "angular" + return [ + ( + 0.0 if angular_only else radius * math.cos(math.radians(start + i * step)), + 0.0 if angular_only else radius * math.sin(math.radians(start + i * step)), + orient(start + i * step), + 1.0, + ) + for i in range(count) + ] + + if kind == "concentric_rings": + result: list[tuple[float, float, float, float]] = [] + for ring in layout.get("rings") or []: + merged = dict(layout) + merged.update(ring) + merged["type"] = "ring" + result.extend(_pattern_placements(merged)) + return result + + if kind == "disc_grid": + nx, ny = int(layout["count_x"]), int(layout["count_y"]) + sx, sy = float(layout["spacing_x_mm"]), float(layout["spacing_y_mm"]) + center = layout.get("center_mm") or [0.0, 0.0] + x0 = float(center[0]) - (nx - 1) * sx / 2.0 + y0 = float(center[1]) - (ny - 1) * sy / 2.0 + limit = layout.get("max_center_radius_mm") + points = [ + (x0 + i * sx, y0 + j * sy) + for j in range(ny) + for i in range(nx) + ] + if limit is not None: + points = [(x, y) for x, y in points if math.hypot(x, y) <= float(limit) + 1e-9] + return [(x, y, orientation_offset, 1.0) for x, y in points] + + if kind == "open_arc": + radius = float(layout["radius_mm"]) + count = int(layout["count"]) + start, end = float(layout["start_angle_deg"]), float(layout["end_angle_deg"]) + step = 0.0 if count == 1 else (end - start) / (count - 1) + return [ + ( + radius * math.cos(math.radians(start + i * step)), + radius * math.sin(math.radians(start + i * step)), + orient(start + i * step), + 1.0, + ) + for i in range(count) + ] + + if kind == "spiral": + count = int(layout["count"]) + start_radius = float(layout["start_radius_mm"]) + radius_step = float(layout["radius_step_mm"]) + start_angle = float(layout.get("start_angle_deg") or 0.0) + angle_step = float(layout["angle_step_deg"]) + result = [] + for i in range(count): + radius = start_radius + i * radius_step + angle = start_angle + i * angle_step + result.append(( + radius * math.cos(math.radians(angle)), + radius * math.sin(math.radians(angle)), + orient(angle), + 1.0, + )) + return result + + if kind == "cross_lines": + count = int(layout["count_per_axis"]) + spacing = float(layout["spacing_mm"]) + start = -(count - 1) * spacing / 2.0 + result = [] + for i in range(count): + value = start + i * spacing + result.append((value, 0.0, orientation_offset, 1.0)) + result.append((0.0, value, orientation_offset + 90.0, 1.0)) + return result + + if kind == "x_field": + levels = int(layout["levels"]) + spacing = float(layout["spacing_mm"]) + start = -(levels - 1) * spacing / 2.0 + result = [] + for i in range(levels): + value = start + i * spacing + if abs(value) < 1e-9: + rotation = 135.0 + orientation_offset if orientation == "diagonal_axes" else orientation_offset + result.append((0.0, 0.0, rotation, 1.0)) + else: + for y in (value, -value): + if orientation == "diagonal_axes": + rotation = (135.0 if value * y > 0 else 45.0) + orientation_offset + else: + angle = math.degrees(math.atan2(y, value)) + rotation = orient(angle) + result.append((value, y, rotation, 1.0)) + return result + + if kind == "twin_strips": + x_offset = float(layout["x_offset_mm"]) + count = int(layout["count_y"]) + y_start = float(layout["y_start_mm"]) + y_end = float(layout["y_end_mm"]) + step = 0.0 if count == 1 else (y_end - y_start) / (count - 1) + return [ + (x, y_start + j * step, orientation_offset, 1.0) + for j in range(count) + for x in (-x_offset, x_offset) + ] + + if kind == "corner_clusters": + levels = [float(v) for v in (layout.get("levels_mm") or [])] + return [ + (sx * x, sy * y, orientation_offset, 1.0) + for sx in (-1.0, 1.0) + for sy in (-1.0, 1.0) + for y in levels + for x in levels + ] + + if kind == "diamond_field": + radius = int(layout["manhattan_radius"]) + spacing = float(layout["spacing_mm"]) + return [ + (i * spacing, j * spacing, orientation_offset, 1.0) + for distance in range(radius + 1) + for j in range(-radius, radius + 1) + for i in range(-radius, radius + 1) + if abs(i) + abs(j) == distance + ] + + raise ValueError(f"patterned_cutouts: unsupported layout type {kind!r}") + + +def _gen_patterned_cutouts(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: + """一个母形 + 一个语义布局,运行时展开成多个独立切除区域。""" + motif = profile.get("motif") or {} + layout = profile.get("layout") or {} + base = _pattern_motif_contour(motif) + regions = [] + for x, y, angle, scale in _pattern_placements(layout): + regions.append({ + "outer": _transform_pattern_contour(base, x, y, angle, scale), + "holes": [], + }) + if not regions: + raise ValueError("patterned_cutouts: layout produced no regions") + meta["_regions"] = regions + return [], [] + + +def _gen_compound_patterned_cutouts(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: + """把少量不同母形/布局的程序化图案合并到同一草图。""" + regions: list[_Ctx] = [] + for pattern in profile.get("patterns") or []: + motif = pattern.get("motif") or {} + layout = pattern.get("layout") or {} + base = _pattern_motif_contour(motif) + for x, y, angle, scale in _pattern_placements(layout): + regions.append({ + "outer": _transform_pattern_contour(base, x, y, angle, scale), + "holes": [], + }) + if not regions: + raise ValueError("compound_patterned_cutouts: patterns produced no regions") + meta["_regions"] = regions + return [], [] + + +# ═══════════════════════════════════════════════════════════════ +# Evidence v2 analytic contours +# ═══════════════════════════════════════════════════════════════ + +_ANALYTIC_TOLERANCE_MM = 1e-5 + + +def _distance_2d(left: list[float], right: list[float]) -> float: + return math.hypot(float(left[0]) - float(right[0]), float(left[1]) - float(right[1])) + + +def _reverse_analytic_edge(edge: _Ctx) -> _Ctx: + result = deepcopy(edge) + result["start_mm"], result["end_mm"] = result["end_mm"], result["start_mm"] + if result.get("type") == "arc" and "clockwise" in result: + result["clockwise"] = not bool(result["clockwise"]) + return result + + +def _join_analytic_edges(edges: list[_Ctx], *, closed: bool) -> list[_Ctx]: + """Order/reorient a contour without depending on SolidWorks segment order.""" + if not edges: + return [] + pending = [deepcopy(edge) for edge in edges] + ordered = [pending.pop(0)] + while pending: + tail = ordered[-1]["end_mm"] + match_index = None + reverse = False + for index, edge in enumerate(pending): + if _distance_2d(tail, edge["start_mm"]) <= _ANALYTIC_TOLERANCE_MM: + match_index = index + break + if _distance_2d(tail, edge["end_mm"]) <= _ANALYTIC_TOLERANCE_MM: + match_index = index + reverse = True + break + if match_index is None: + raise ValueError("analytic_contours: segments do not form a connected contour") + edge = pending.pop(match_index) + ordered.append(_reverse_analytic_edge(edge) if reverse else edge) + if closed and _distance_2d(ordered[0]["start_mm"], ordered[-1]["end_mm"]) > _ANALYTIC_TOLERANCE_MM: + raise ValueError("analytic_contours: closed contour endpoints do not meet") + return ordered + + +def _analytic_circle_edges(segment: _Ctx) -> list[_Ctx]: + center = segment.get("center") or [0.0, 0.0] + radius = float(segment.get("radius_mm") or 0.0) + if radius <= 0: + raise ValueError("analytic_contours: circle radius_mm must be > 0") + cx, cy = float(center[0]), float(center[1]) + clockwise = bool(segment.get("clockwise", False)) + angles = [0.0, -90.0, -180.0, -270.0, -360.0] if clockwise else [0.0, 90.0, 180.0, 270.0, 360.0] + points = [[cx + radius * math.cos(math.radians(angle)), cy + radius * math.sin(math.radians(angle)), 0.0] for angle in angles] + return [ + _contour_arc(points[index], points[index + 1], [cx, cy, 0.0], radius, clockwise) + for index in range(4) + ] + + +def _analytic_segment_edges(segment: _Ctx) -> list[_Ctx]: + segment_type = segment.get("type") + if segment_type == "line": + return [_contour_line(segment["start"], segment["end"])] + if segment_type == "arc": + return [ + _contour_arc( + segment["start"], segment["end"], segment["center"], + segment.get("radius_mm"), segment.get("clockwise"), + ) + ] + if segment_type == "circle": + return _analytic_circle_edges(segment) + if segment_type == "bspline": + raise ValueError("analytic_contours: bspline requires an explicit approximation capability") + raise ValueError(f"analytic_contours: unsupported segment type {segment_type!r}") + + +def _sample_analytic_loop(edges: list[_Ctx]) -> list[tuple[float, float]]: + """Create a deterministic planar sample only for containment classification.""" + points: list[tuple[float, float]] = [] + for edge in edges: + start = edge["start_mm"] + points.append((float(start[0]), float(start[1]))) + if edge.get("type") != "arc": + continue + center = edge["center_mm"] + end = edge["end_mm"] + sx, sy = float(start[0]) - float(center[0]), float(start[1]) - float(center[1]) + ex, ey = float(end[0]) - float(center[0]), float(end[1]) - float(center[1]) + start_angle = math.atan2(sy, sx) + end_angle = math.atan2(ey, ex) + delta = end_angle - start_angle + if edge.get("clockwise"): + if delta >= 0: + delta -= math.tau + elif delta <= 0: + delta += math.tau + for fraction in (0.25, 0.5, 0.75): + angle = start_angle + delta * fraction + radius = float(edge.get("radius_mm") or math.hypot(sx, sy)) + points.append((float(center[0]) + radius * math.cos(angle), float(center[1]) + radius * math.sin(angle))) + return points + + +def _loop_area(points: list[tuple[float, float]]) -> float: + if len(points) < 3: + return 0.0 + return abs(sum(points[index][0] * points[(index + 1) % len(points)][1] - points[(index + 1) % len(points)][0] * points[index][1] for index in range(len(points))) / 2.0) + + +def _endpoint_signed_area(edges: list[_Ctx]) -> float: + points = [(float(edge["start_mm"][0]), float(edge["start_mm"][1])) for edge in edges] + return sum( + points[index][0] * points[(index + 1) % len(points)][1] + - points[(index + 1) % len(points)][0] * points[index][1] + for index in range(len(points)) + ) / 2.0 + + +def _normalize_quarter_rounding_direction(edges: list[_Ctx]) -> None: + """Repair inconsistent sweep flags on a conventional rounded rectangle. + + Evidence exports occasionally label one or more 90-degree corner arcs + with the opposite direction. Honouring those isolated flags creates + 270-degree loops. This normalizer applies only to the unambiguous shape: + exactly four equal-radius quarter arcs in one closed loop. Other arcs, + including annular sectors and long sweeps, retain their captured flags. + """ + arcs = [edge for edge in edges if edge.get("type") == "arc"] + if len(arcs) != 4: + return + radii = [float(edge.get("radius_mm") or 0.0) for edge in arcs] + if min(radii) <= _ANALYTIC_TOLERANCE_MM or max(radii) - min(radii) > _ANALYTIC_TOLERANCE_MM: + return + for edge in arcs: + center = edge.get("center_mm") + if not isinstance(center, list): + return + start, end = edge["start_mm"], edge["end_mm"] + first = (float(start[0]) - float(center[0]), float(start[1]) - float(center[1])) + second = (float(end[0]) - float(center[0]), float(end[1]) - float(center[1])) + angle = abs(math.atan2(first[0] * second[1] - first[1] * second[0], first[0] * second[0] + first[1] * second[1])) + if abs(angle - math.pi / 2) > 1e-4: + return + # A clockwise endpoint loop needs clockwise short corner arcs; a + # counter-clockwise loop needs their reverse. This preserves the actual + # rounded-rectangle boundary, independent of per-segment export noise. + clockwise = _endpoint_signed_area(edges) < 0.0 + for edge in arcs: + edge["clockwise"] = clockwise + + +def _point_in_loop(point: tuple[float, float], loop: list[tuple[float, float]]) -> bool: + if len(loop) < 3: + return False + inside = False + x, y = point + previous = loop[-1] + for current in loop: + x1, y1 = current + x2, y2 = previous + if (y1 > y) != (y2 > y): + intersect_x = (x2 - x1) * (y - y1) / (y2 - y1) + x1 + if x < intersect_x: + inside = not inside + previous = current + return inside + + +def _gen_analytic_contours(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: + """Resolve Evidence v2 line/arc/circle loops into engine-neutral regions. + + The returned regions preserve holes and islands. The build adapter owns + B-rep creation; this profile generator only reasons about sketch geometry. + """ + loops: list[_Ctx] = [] + entities: list[_Ctx] = [] + for contour_index, contour in enumerate(profile.get("contours") or []): + if not contour.get("closed"): + raise ValueError(f"analytic_contours: contour {contour_index} is open") + segment_edges: list[_Ctx] = [] + for segment in contour.get("segments") or []: + segment_type = segment.get("type") + if segment_type == "line": + entities.append(_line(segment["start"], segment["end"])) + elif segment_type == "circle": + entities.append(_circle(segment.get("center") or [0.0, 0.0], float(segment.get("radius_mm") or 0.0))) + segment_edges.extend(_analytic_segment_edges(segment)) + if not segment_edges: + continue + edges = _join_analytic_edges(segment_edges, closed=True) + _normalize_quarter_rounding_direction(edges) + points = _sample_analytic_loop(edges) + area = _loop_area(points) + if area <= _ANALYTIC_TOLERANCE_MM * _ANALYTIC_TOLERANCE_MM: + raise ValueError(f"analytic_contours: contour {contour_index} is degenerate") + loops.append({"role": contour.get("role", "unknown"), "edges": edges, "points": points, "area": area}) + + for segment in profile.get("construction") or []: + if segment.get("type") == "line": + entities.append(_line(segment["start"], segment["end"], construction=True)) + elif segment.get("type") == "circle": + entities.append(_circle(segment.get("center") or [0.0, 0.0], float(segment.get("radius_mm") or 0.0), construction=True)) + + if not loops: + return entities, [] + for loop in loops: + # Role tags captured from the source sketch are useful provenance but + # not authoritative geometry. A number of exports label separate + # closed contours as ``inner`` although no outer contour contains + # them. The even-odd containment rule is deterministic for the + # supported analytic curves and preserves those independent regions. + contained_by = sum(_point_in_loop(loop["points"][0], other["points"]) for other in loops if other is not loop) + loop["role"] = "inner" if contained_by % 2 else "outer" + outers = [loop for loop in loops if loop["role"] == "outer"] + inners = [loop for loop in loops if loop["role"] == "inner"] + regions = [{"outer": outer["edges"], "holes": []} for outer in outers] + for inner in inners: + containing = [outer for outer in outers if _point_in_loop(inner["points"][0], outer["points"])] + if not containing: + raise ValueError("analytic_contours: inner contour has no containing outer contour") + selected = min(containing, key=lambda outer: outer["area"]) + regions[outers.index(selected)]["holes"].append(inner["edges"]) + meta["_regions"] = regions + return entities, [] + + +# ═══════════════════════════════════════════════════════════════ +# Runtime profile registry +# +# The CDSL-only runtime accepts direct geometric profiles only. Business +# shapes and procedural sketch macros are retained below solely for explicit +# importer/rebuild compatibility lowering and are not runtime capabilities. +# ═══════════════════════════════════════════════════════════════ + +CORE_SHAPE_GENERATORS: dict[str, Any] = { + "circle": _gen_circle, + "polygon": _gen_polygon, + "analytic_contours": _gen_analytic_contours, +} + +# Import/rebuild compatibility only. New CDSL must be lowered before runtime +# execution instead of adding any of these names to the core registry. +LEGACY_PROFILE_GENERATORS: dict[str, Any] = { + "annulus": _gen_annulus, + "circles": _gen_circles, + "circle_grid": _gen_circle_grid, + "rectangle": _gen_rectangle, + "rectangle_with_circles": _gen_rect_with_circles, + "rectangle_with_fillets": _gen_rect_with_fillets, + "obround": _gen_obround, + "ibone": _gen_ibone, + "rectangle_with_symmetric_notches": _gen_rect_symmetric_notches, + "revolve_chamfer": _gen_revolve_chamfer, + "revolve_chamfer_slanted": _gen_revolve_chamfer_slanted, + # 弧边复合轮廓(按 015133 手册方法注册,同形异构通过参数复用) + "circle_with_arc_notches": _gen_circle_with_arc_notches, + "circular_sector_slot": _gen_circular_sector_slot, + "circle_with_radial_tabs": _gen_circle_with_radial_tabs, + "filleted_rect_side_slots": _gen_filleted_rect_side_slots, + # 弧边形状 + "d_shape": _gen_d_shape, + "partial_ring": _gen_partial_ring, + "partial_ring_with_arc_island": _gen_partial_ring_with_arc_island, + "arc_chain": _gen_arc_chain, + "radial_slot": _gen_radial_slot, + "patterned_cutouts": _gen_patterned_cutouts, + "compound_patterned_cutouts": _gen_compound_patterned_cutouts, +} + +# Historical callers query this name to learn runtime capability. Keep the +# name but make it an alias to the core-only registry. +# This registry is intentionally local to the historical importer. It is +# useful for replaying old exports, but must never be treated as engine +# capability. +SHAPE_GENERATORS = {**CORE_SHAPE_GENERATORS, **LEGACY_PROFILE_GENERATORS} + +# ═══════════════════════════════════════════════════════════════ +# 注册表功能:扩展、查询 +# ═══════════════════════════════════════════════════════════════ + +def register_shape(ptype: str, generator: Any) -> None: + """Runtime profiles are a fixed CDSL contract, not plugin hooks.""" + raise RuntimeError("Runtime profile types are fixed; lower custom profiles before CDSL execution") + + +def list_registered_shapes() -> list[str]: + """返回所有已注册的形状生成器名称。""" + return sorted(SHAPE_GENERATORS.keys()) + + +# ═══════════════════════════════════════════════════════════════ +# 形状能力矩阵(供外部查询:哪些形状可自动检测,哪些需手动指定) +# ═══════════════════════════════════════════════════════════════ + +_ShapeInfo = dict[str, Any] + +LEGACY_SHAPE_CAPABILITIES: dict[str, _ShapeInfo] = { + "circle": {"detectable": True, "arity": "circle", "description": "单圆"}, + "annulus": {"detectable": True, "arity": "circles", "description": "同心圆环"}, + "circles": {"detectable": True, "arity": "circles", "description": "多圆(非同心)"}, + "circle_grid": {"detectable": False, "arity": "circles", "description": "矩形圆孔阵列"}, + "rectangle": {"detectable": True, "arity": "polygon", "description": "4线矩形"}, + "rectangle_with_circles": {"detectable": True, "arity": "mixed", "description": "矩形+内圆孔"}, + "rectangle_with_fillets": {"detectable": False, "arity": "mixed", "description": "圆角矩形(4弧+4线)"}, + "obround": {"detectable": True, "arity": "mixed", "description": "槽形/键槽(2线+2半圆弧)"}, + "polygon": {"detectable": True, "arity": "polygon", "description": "N边多边形"}, + "ibone": {"detectable": False, "arity": "mixed", "description": "工字形凸耳(12线+4弧+4孔)"}, + "rectangle_with_symmetric_notches": {"detectable": False,"arity": "mixed", "description": "对称槽板(矩形+4U形缺口)"}, + "revolve_chamfer": {"detectable": True, "arity": "polygon", "description": "旋转梯形截面"}, + "revolve_chamfer_slanted": {"detectable": True, "arity": "polygon", "description": "旋转斜底梯形截面"}, + "circle_with_arc_notches": {"detectable": False, "arity": "mixed", "description": "圆+均匀弧形凹口"}, + "circular_sector_slot": {"detectable": False, "arity": "mixed", "description": "圆弧扇区+中心矩形槽"}, + "circle_with_radial_tabs": {"detectable": False, "arity": "mixed", "description": "圆+径向矩形凸耳"}, + "filleted_rect_side_slots": {"detectable": False, "arity": "mixed", "description": "圆角矩形+两侧中心U形槽"}, + "d_shape": {"detectable": True, "arity": "mixed", "description": "D形(半圆+弦线)"}, + "partial_ring": {"detectable": True, "arity": "mixed", "description": "部分圆环(扇区环)"}, + "partial_ring_with_arc_island": {"detectable": False, "arity": "mixed", "description": "扇区环+弦上偏移弧岛"}, + "radial_slot": {"detectable": False, "arity": "mixed", "description": "径向弧形槽"}, + "arc_chain": {"detectable": False, "arity": "arcs", "description": "多段弧链轮廓"}, + "patterned_cutouts": {"detectable": False, "arity": "regions", "description": "程序化重复切口"}, + "compound_patterned_cutouts": {"detectable": False, "arity": "regions", "description": "复合程序化重复切口"}, +} + +SHAPE_CAPABILITIES: dict[str, _ShapeInfo] = { + "circle": {"detectable": True, "arity": "circle", "description": "单圆"}, + "polygon": {"detectable": True, "arity": "polygon", "description": "闭合直线轮廓"}, + "analytic_contours": {"detectable": True, "arity": "analytic", "description": "闭合线、圆弧和圆轮廓"}, +} + + +# ═══════════════════════════════════════════════════════════════ +# 主入口 +# ═══════════════════════════════════════════════════════════════ + +def resolve_profile(sketch: dict[str, Any]) -> dict[str, Any]: + """按 type 查找生成器,生成 entities + contour_edges_mm。 + + 对于返回非空 contour 的生成器,会额外保留原始 sketch.entities + 中的非 construction circle 实体(孔洞/圆岛),确保不丢失内部特征。 + """ + profile = sketch.get("profile") + if not profile: + return sketch + + ptype = profile.get("type") + generator = SHAPE_GENERATORS.get(ptype) + if generator is None: + raise ValueError(f"sketch {sketch.get('id')}: unsupported profile type {ptype!r}") + + meta = {"id": sketch.get("id"), "name": sketch.get("name"), "_entities": sketch.get("entities"), "_contour": sketch.get("contour_edges_mm")} + entities, contour = generator(profile, meta) + + out = deepcopy(sketch) + + # 保留原始草图中的非 construction circle 实体(这些是内部孔洞/圆岛) + orig_ents = sketch.get("entities") or [] + keep_circles = [ + e for e in orig_ents + if e.get("type") == "circle" and not e.get("construction") + ] + if keep_circles and contour: + # 只对生成器产出 contour 的场合保留 circles(轮廓生成器 + 内部圆孔) + entities = list(entities) + keep_circles + + out["entities"] = entities + wp = sketch.get("workplane") + if contour: + out["contour_edges_mm"] = _transform_contours(contour, wp) if wp else contour + regions = meta.get("_regions") or [] + if regions: + out["contour_regions_mm"] = [ + { + "outer": _transform_contours(reg["outer"], wp) if wp else reg["outer"], + "holes": [ + _transform_contours(hole, wp) if wp else hole + for hole in (reg.get("holes") or []) + ], + } + for reg in regions + ] + return out + + +def resolve_all_sketches(cdsl: dict[str, Any]) -> dict[str, Any]: + """对 CDSL 中所有带 profile 字段的草图进行解析。 + + 支持 profile_from 字段:引用另一个草图的 profile,避免重复。 + 例:sk_05: {"profile_from": "sk_03"} → 使用 sk_03 的 profile。 + """ + geom = cdsl.get("geometry") or {} + sketches = geom.get("sketches") or [] + + # 第一遍: 解析所有有自己 profile 的草图 + resolved: dict[str, dict] = {} + for sk in sketches: + sid = sk.get("id") + if sid is None: + continue + if "profile" in sk: + resolved[sid] = resolve_profile(sk) + + # 第二遍: 解析 profile_from 引用(支持 profile_shift 偏移) + for sk in sketches: + sid = sk.get("id") + pf = sk.get("profile_from") + if pf and sid: + src = resolved.get(pf) + if src is None: + raise ValueError( + f"sketch {sid}: profile_from={pf!r} not found or not yet resolved" + ) + sk2 = deepcopy(sk) + sk2["profile"] = deepcopy(src.get("profile")) + sk2.pop("profile_from", None) + + # profile_shift: 对 polygon 顶点做 2D 偏移(同形异构共享) + shift = sk.get("profile_shift") + if shift and len(shift) == 2 and sk2["profile"].get("type") == "polygon": + du, dv = float(shift[0]), float(shift[1]) + for v in sk2["profile"]["vertices"]: + v[0] = round(v[0] + du, 6) + v[1] = round(v[1] + dv, 6) + sk2.pop("profile_shift", None) + resolved[sid] = resolve_profile(sk2) + + # 按原顺序输出 + result = [] + for sk in sketches: + sid = sk.get("id") + if sid and sid in resolved: + result.append(resolved[sid]) + else: + result.append(deepcopy(sk)) + + out = deepcopy(cdsl) + out.setdefault("geometry", {})["sketches"] = result + return out + + +def resolve_required_sketches( + cdsl: dict[str, Any], + sketch_ids: Iterable[str], + *, + errors: dict[str, str] | None = None, +) -> dict[str, Any]: + """Resolve only profiles that an executable feature actually consumes. + + ``profile_from`` dependencies are resolved recursively. Callers that + pass ``errors`` get feature-addressable failures without losing unrelated + resolved sketches; callers that omit it retain the strict exception + behavior useful to profile tooling. + """ + sketches = list((cdsl.get("geometry") or {}).get("sketches") or []) + by_id = {str(sketch.get("id")): sketch for sketch in sketches if sketch.get("id") is not None} + resolved: dict[str, dict[str, Any]] = {} + resolving: set[str] = set() + + def resolve_one(sketch_id: str) -> dict[str, Any]: + if sketch_id in resolved: + return resolved[sketch_id] + sketch = by_id.get(sketch_id) + if sketch is None: + raise ValueError(f"sketch {sketch_id!r} was not found") + if sketch_id in resolving: + raise ValueError(f"sketch {sketch_id}: profile_from contains a cycle") + resolving.add(sketch_id) + try: + if "profile" in sketch: + output = resolve_profile(sketch) + elif sketch.get("profile_from"): + source_id = str(sketch["profile_from"]) + source = resolve_one(source_id) + if not source.get("profile"): + raise ValueError(f"sketch {sketch_id}: profile_from={source_id!r} has no profile") + output = deepcopy(sketch) + output["profile"] = deepcopy(source["profile"]) + output.pop("profile_from", None) + shift = sketch.get("profile_shift") + if shift and len(shift) == 2 and output["profile"].get("type") == "polygon": + du, dv = float(shift[0]), float(shift[1]) + for vertex in output["profile"]["vertices"]: + vertex[0] = round(vertex[0] + du, 6) + vertex[1] = round(vertex[1] + dv, 6) + output.pop("profile_shift", None) + output = resolve_profile(output) + else: + output = deepcopy(sketch) + resolved[sketch_id] = output + return output + finally: + resolving.discard(sketch_id) + + for sketch_id in {str(item) for item in sketch_ids}: + try: + resolve_one(sketch_id) + except ValueError as error: + if errors is None: + raise + errors[sketch_id] = str(error) + + output = deepcopy(cdsl) + output.setdefault("geometry", {})["sketches"] = [ + resolved.get(str(sketch.get("id")), deepcopy(sketch)) + for sketch in sketches + ] + return output diff --git a/backend/engine/cdsl_engine/convert_to_cdsl.py b/backend/cdsl_importer/solidworks_to_cdsl.py similarity index 98% rename from backend/engine/cdsl_engine/convert_to_cdsl.py rename to backend/cdsl_importer/solidworks_to_cdsl.py index b0161fdc..7c45f9ba 100644 --- a/backend/engine/cdsl_engine/convert_to_cdsl.py +++ b/backend/cdsl_importer/solidworks_to_cdsl.py @@ -26,9 +26,9 @@ from pathlib import Path from typing import Any, Callable try: - from .translator import normalize_to_ir -except ImportError: # 允许直接 python engine/convert_to_cdsl.py - from translator import normalize_to_ir + from engine.cdsl_engine.translator import normalize_to_ir +except ModuleNotFoundError: # Engine root added to sys.path by product runtime. + from cdsl_engine.translator import normalize_to_ir # ═══════════════════════════════════════════════════════════════ @@ -882,8 +882,16 @@ def convert_sw_json_to_cdsl( }, } + # The import classifier may use historical shape labels while recognizing + # a SolidWorks sketch. Lower them before exposing the result as CDSL so + # the runtime contract remains line/arc/circle/contour based. + try: + from cdsl_importer.legacy_profile_adapter import lower_legacy_profiles + except ImportError: # pragma: no cover - direct converter invocation + from cdsl_importer.legacy_profile_adapter import lower_legacy_profiles + cdsl = _clean_cdsl_floats(cdsl) - return cdsl + return lower_legacy_profiles(cdsl) def op_to_raw(feat: dict, op: dict, all_ops: list) -> dict | None: diff --git a/backend/engine/cdsl_engine/__init__.py b/backend/engine/cdsl_engine/__init__.py index 3afc935d..39c490f4 100644 --- a/backend/engine/cdsl_engine/__init__.py +++ b/backend/engine/cdsl_engine/__init__.py @@ -6,22 +6,19 @@ The package exposes the CDSL-only rebuild API used by the backend Agent. from __future__ import annotations -from .convert_to_cdsl import convert_sw_json_to_cdsl, write_cdsl_outputs from .llm_compiler import compile_cdsl from .llm_engine import run_engine_plan from .rebuild import compare_with_gold, compile_cdsl_to_pack, run_cdsl_only, run_engine, run_rebuild from .semantic_validation import validate_semantic_cdsl -from .sketch_solver import SHAPE_GENERATORS, resolve_all_sketches, resolve_required_sketches +from .sketch_solver import CORE_SHAPE_GENERATORS, resolve_all_sketches, resolve_required_sketches from .runtime import ALL_ATOMIC_IDS, EXECUTORS, RuntimeExecutionError, analyze_cdsl, rebuild_cdsl -from .design_intent import DesignIntentError, design_intent_from_cdsl, validate_design_intent, validate_intent_cdsl # The package-level runtime contract is the session executor registry. The # older llm_engine dispatcher remains available only for legacy engine packs. SUPPORTED_ATOMIC_IDS = ALL_ATOMIC_IDS +SHAPE_GENERATORS = CORE_SHAPE_GENERATORS __all__ = [ - "convert_sw_json_to_cdsl", - "write_cdsl_outputs", "compile_cdsl", "compile_cdsl_to_pack", "run_engine_plan", @@ -31,6 +28,7 @@ __all__ = [ "compare_with_gold", "resolve_all_sketches", "resolve_required_sketches", + "CORE_SHAPE_GENERATORS", "SHAPE_GENERATORS", "SUPPORTED_ATOMIC_IDS", "validate_semantic_cdsl", @@ -39,10 +37,6 @@ __all__ = [ "rebuild_cdsl", "analyze_cdsl", "RuntimeExecutionError", - "DesignIntentError", - "validate_design_intent", - "validate_intent_cdsl", - "design_intent_from_cdsl", ] __version__ = "1.0.0" diff --git a/backend/engine/cdsl_engine/batch_rebuild.py b/backend/engine/cdsl_engine/batch_rebuild.py index 7260a356..6b2ea707 100644 --- a/backend/engine/cdsl_engine/batch_rebuild.py +++ b/backend/engine/cdsl_engine/batch_rebuild.py @@ -13,6 +13,7 @@ from collections import Counter from pathlib import Path from typing import Any, Iterable +from cdsl_importer.legacy_profile_adapter import lower_legacy_profiles from .semantic_validation import validate_semantic_cdsl @@ -225,7 +226,7 @@ def analyze_document( "failure_category": "preflight_blocked", } try: - cdsl = json.loads(path.read_text(encoding="utf-8")) + cdsl = lower_legacy_profiles(json.loads(path.read_text(encoding="utf-8"))) report["part_id"] = str(cdsl.get("part_id") or report["part_id"]) semantic = validate_semantic_cdsl(cdsl) report["semantic_valid"] = True diff --git a/backend/engine/cdsl_engine/build123d_adapter.py b/backend/engine/cdsl_engine/build123d_adapter.py index 0e81abcd..6537e2df 100644 --- a/backend/engine/cdsl_engine/build123d_adapter.py +++ b/backend/engine/cdsl_engine/build123d_adapter.py @@ -5,7 +5,7 @@ from __future__ import annotations import math from typing import Any, Iterable -from build123d import Axis, Edge, Face, Plane, Solid, Vector, Wire, export_step +from build123d import Axis, Compound, Edge, Face, Plane, Solid, Vector, Wire, export_step from .runtime_types import AxisSpec, HoleSpec, PlaneSpec, TopologyRecord, Vector3, canonical_plane_signature @@ -201,13 +201,23 @@ class Build123dGeometryAdapter: def revolve(face: Face, angle_deg: float, axis: AxisSpec) -> Solid: return Solid.revolve(face, angle_deg, Build123dGeometryAdapter.axis(axis)) + @staticmethod + def _body_shape(value: Any) -> Any: + """Normalize boolean results, including disconnected ShapeList values.""" + if hasattr(value, "bounding_box"): + return value + shapes = list(value) + if not shapes: + raise ValueError("Boolean operation produced no shapes") + return shapes[0] if len(shapes) == 1 else Compound(shapes) + @staticmethod def fuse(body: Any | None, solid: Solid) -> Any: - return solid if body is None else body.fuse(solid) + return solid if body is None else Build123dGeometryAdapter._body_shape(body.fuse(solid)) @staticmethod def cut(body: Any, tool: Any) -> Any: - return body.cut(tool) + return Build123dGeometryAdapter._body_shape(body.cut(tool)) @staticmethod def sphere(radius_mm: float, center_mm: Vector3) -> Solid: diff --git a/backend/engine/cdsl_engine/cdsl_schema.json b/backend/engine/cdsl_engine/cdsl_schema.json index a8e05d61..305fcdfb 100644 --- a/backend/engine/cdsl_engine/cdsl_schema.json +++ b/backend/engine/cdsl_engine/cdsl_schema.json @@ -249,7 +249,8 @@ "stable_id": {"type": "string", "minLength": 1}, "owner_feature_id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}, "geometry": {"type": "object"}, - "source": {"enum": ["solidworks", "inferred_from_step"]}, + "source": {"enum": ["solidworks", "inferred_from_step", "runtime_snapshot", "viewer_selection"]}, + "snapshot_id": {"type": "string", "minLength": 1}, "confidence": {"type": "number", "minimum": 0, "maximum": 1} }, "required": ["kind", "stable_id", "source", "confidence"], @@ -258,24 +259,18 @@ "analyticSegment": { "type": "object", "properties": { - "type": {"enum": ["line", "arc", "circle", "bspline"]}, + "type": {"enum": ["line", "arc", "circle"]}, "start": {"$ref": "#/$defs/point2"}, "end": {"$ref": "#/$defs/point2"}, "center": {"$ref": "#/$defs/point2"}, "radius_mm": {"$ref": "#/$defs/positive"}, - "clockwise": {"type": "boolean"}, - "degree": {"type": "integer", "minimum": 1}, - "control_points": {"type": "array", "items": {"$ref": "#/$defs/point2"}}, - "knots": {"type": "array", "items": {"$ref": "#/$defs/number"}}, - "weights": {"type": "array", "items": {"$ref": "#/$defs/positive"}}, - "periodic": {"type": "boolean"} + "clockwise": {"type": "boolean"} }, "required": ["type"], "allOf": [ {"if": {"properties": {"type": {"const": "line"}}}, "then": {"required": ["start", "end"]}}, {"if": {"properties": {"type": {"const": "arc"}}}, "then": {"required": ["start", "end", "center", "radius_mm"]}}, - {"if": {"properties": {"type": {"const": "circle"}}}, "then": {"required": ["center", "radius_mm"]}}, - {"if": {"properties": {"type": {"const": "bspline"}}}, "then": {"required": ["degree", "control_points", "knots"]}} + {"if": {"properties": {"type": {"const": "circle"}}}, "then": {"required": ["center", "radius_mm"]}} ], "additionalProperties": false }, @@ -334,87 +329,11 @@ {"if": {"properties": {"atomic_id": {"const": "hole_wizard"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/holeWizardParams"}}}} ] }, - "rectangleBounds": { - "type": "object", - "properties": {"center": {"$ref": "#/$defs/point2"}, "width_mm": {"$ref": "#/$defs/positive"}, "height_mm": {"$ref": "#/$defs/positive"}, "min_mm": {"$ref": "#/$defs/point2"}, "max_mm": {"$ref": "#/$defs/point2"}}, - "allOf": [ - {"oneOf": [ - {"required": ["center", "width_mm", "height_mm"], "not": {"anyOf": [{"required": ["min_mm"]}, {"required": ["max_mm"]}]}}, - {"required": ["min_mm", "max_mm"], "not": {"anyOf": [{"required": ["center"]}, {"required": ["width_mm"]}, {"required": ["height_mm"]}]}} - ]} - ] - }, - "rectangleBoundary": { - "type": "object", - "properties": {"type": {"enum": ["rectangle", "rectangle_with_fillets"]}, "center": {"$ref": "#/$defs/point2"}, "width_mm": {"$ref": "#/$defs/positive"}, "height_mm": {"$ref": "#/$defs/positive"}, "min_mm": {"$ref": "#/$defs/point2"}, "max_mm": {"$ref": "#/$defs/point2"}, "fillet_radius_mm": {"$ref": "#/$defs/positive"}}, - "allOf": [{"$ref": "#/$defs/rectangleBounds"}], - "additionalProperties": false - }, - "profile_type": {"enum": ["circle", "annulus", "circles", "circle_grid", "rectangle", "rectangle_with_circles", "rectangle_with_fillets", "obround", "polygon", "ibone", "rectangle_with_symmetric_notches", "revolve_chamfer", "revolve_chamfer_slanted", "circle_with_arc_notches", "circular_sector_slot", "circle_with_radial_tabs", "filleted_rect_side_slots", "d_shape", "partial_ring", "partial_ring_with_arc_island", "radial_slot", "arc_chain", "patterned_cutouts", "compound_patterned_cutouts", "analytic_contours"]}, - "motif_type": {"enum": ["circle", "square", "rectangle", "obround", "cross", "d_shape_polygon", "regular_hexagon", "skew_hexagon", "triangle", "teardrop_polygon", "trapezoid", "annular_sector_polygon"]}, - "layout_type": {"enum": ["ring", "angular", "concentric_rings", "disc_grid", "open_arc", "spiral", "cross_lines", "x_field", "twin_strips", "corner_clusters", "diamond_field"]}, - "motif": { - "oneOf": [ - {"type": "object", "properties": {"type": {"const": "circle"}, "radius_mm": {"$ref": "#/$defs/positive"}}, "required": ["type", "radius_mm"], "additionalProperties": false}, - {"type": "object", "properties": {"type": {"const": "square"}, "width_mm": {"$ref": "#/$defs/positive"}}, "required": ["type", "width_mm"], "additionalProperties": false}, - {"type": "object", "properties": {"type": {"const": "rectangle"}, "width_mm": {"$ref": "#/$defs/positive"}, "height_mm": {"$ref": "#/$defs/positive"}}, "required": ["type", "width_mm", "height_mm"], "additionalProperties": false}, - {"type": "object", "properties": {"type": {"const": "obround"}, "length_mm": {"$ref": "#/$defs/positive"}, "width_mm": {"$ref": "#/$defs/positive"}}, "required": ["type", "length_mm", "width_mm"], "additionalProperties": false}, - {"type": "object", "properties": {"type": {"const": "cross"}, "size_mm": {"$ref": "#/$defs/positive"}, "arm_width_mm": {"$ref": "#/$defs/positive"}}, "required": ["type", "size_mm", "arm_width_mm"], "additionalProperties": false}, - {"type": "object", "properties": {"type": {"const": "d_shape_polygon"}, "stem_length_mm": {"$ref": "#/$defs/positive"}, "nose_depth_mm": {"$ref": "#/$defs/positive"}, "half_height_mm": {"$ref": "#/$defs/positive"}, "arc_segments": {"$ref": "#/$defs/positiveInteger"}}, "required": ["type", "stem_length_mm", "nose_depth_mm", "half_height_mm"], "additionalProperties": false}, - {"type": "object", "properties": {"type": {"const": "regular_hexagon"}, "radius_mm": {"$ref": "#/$defs/positive"}}, "required": ["type", "radius_mm"], "additionalProperties": false}, - {"type": "object", "properties": {"type": {"const": "skew_hexagon"}, "nominal_radius_mm": {"$ref": "#/$defs/positive"}}, "required": ["type", "nominal_radius_mm"], "additionalProperties": false}, - {"type": "object", "properties": {"type": {"const": "triangle"}, "radius_mm": {"$ref": "#/$defs/positive"}}, "required": ["type", "radius_mm"], "additionalProperties": false}, - {"type": "object", "properties": {"type": {"const": "teardrop_polygon"}, "width_mm": {"$ref": "#/$defs/positive"}, "height_mm": {"$ref": "#/$defs/positive"}, "shoulder_fraction": {"type": "number", "exclusiveMinimum": 0, "exclusiveMaximum": 1}, "left_width_mm": {"$ref": "#/$defs/positive"}, "right_width_mm": {"$ref": "#/$defs/positive"}, "tip_height_mm": {"$ref": "#/$defs/positive"}, "bottom_depth_mm": {"$ref": "#/$defs/positive"}, "shoulder_height_mm": {"$ref": "#/$defs/positive"}}, "required": ["type"], "oneOf": [{"required": ["width_mm", "height_mm"], "not": {"anyOf": [{"required": ["left_width_mm"]}, {"required": ["right_width_mm"]}, {"required": ["tip_height_mm"]}, {"required": ["bottom_depth_mm"]}, {"required": ["shoulder_height_mm"]}]}}, {"required": ["left_width_mm", "right_width_mm", "tip_height_mm", "bottom_depth_mm", "shoulder_height_mm"], "not": {"anyOf": [{"required": ["width_mm"]}, {"required": ["height_mm"]}, {"required": ["shoulder_fraction"]}]}}], "additionalProperties": false}, - {"type": "object", "properties": {"type": {"const": "trapezoid"}, "bottom_width_mm": {"$ref": "#/$defs/positive"}, "top_width_mm": {"$ref": "#/$defs/positive"}, "height_mm": {"$ref": "#/$defs/positive"}}, "required": ["type", "bottom_width_mm", "top_width_mm", "height_mm"], "additionalProperties": false}, - {"type": "object", "properties": {"type": {"const": "annular_sector_polygon"}, "inner_radius_mm": {"$ref": "#/$defs/positive"}, "outer_radius_mm": {"$ref": "#/$defs/positive"}, "half_angle_deg": {"$ref": "#/$defs/positive"}, "arc_segments": {"$ref": "#/$defs/positiveInteger"}}, "required": ["type", "inner_radius_mm", "outer_radius_mm", "half_angle_deg"], "additionalProperties": false} - ] - }, - "layoutCommon": { - "type": "object", - "properties": {"orientation": {"enum": ["fixed", "radial", "tangential", "snapped_radial", "diagonal_axes"]}, "orientation_offset_deg": {"$ref": "#/$defs/number"}, "orientation_snap_deg": {"$ref": "#/$defs/positive"}} - }, - "layout": { - "oneOf": [ - {"type": "object", "properties": {"type": {"const": "ring"}, "radius_mm": {"$ref": "#/$defs/positive"}, "count": {"$ref": "#/$defs/positiveInteger"}, "start_angle_deg": {"$ref": "#/$defs/number"}, "angle_step_deg": {"$ref": "#/$defs/number"}, "orientation": {"enum": ["fixed", "radial", "tangential", "snapped_radial", "diagonal_axes"]}, "orientation_offset_deg": {"$ref": "#/$defs/number"}, "orientation_snap_deg": {"$ref": "#/$defs/positive"}}, "required": ["type", "radius_mm", "count"], "additionalProperties": false}, - {"type": "object", "properties": {"type": {"const": "angular"}, "radius_mm": {"$ref": "#/$defs/number"}, "count": {"$ref": "#/$defs/positiveInteger"}, "start_angle_deg": {"$ref": "#/$defs/number"}, "angle_step_deg": {"$ref": "#/$defs/number"}, "orientation": {"enum": ["fixed", "radial", "tangential", "snapped_radial", "diagonal_axes"]}, "orientation_offset_deg": {"$ref": "#/$defs/number"}, "orientation_snap_deg": {"$ref": "#/$defs/positive"}}, "required": ["type", "count"], "additionalProperties": false}, - {"type": "object", "properties": {"type": {"const": "concentric_rings"}, "rings": {"type": "array", "minItems": 1, "items": {"type": "object", "properties": {"radius_mm": {"$ref": "#/$defs/positive"}, "count": {"$ref": "#/$defs/positiveInteger"}, "start_angle_deg": {"$ref": "#/$defs/number"}, "angle_step_deg": {"$ref": "#/$defs/number"}}, "required": ["radius_mm", "count"], "additionalProperties": false}}, "orientation": {"enum": ["fixed", "radial", "tangential", "snapped_radial", "diagonal_axes"]}, "orientation_offset_deg": {"$ref": "#/$defs/number"}, "orientation_snap_deg": {"$ref": "#/$defs/positive"}}, "required": ["type", "rings"], "additionalProperties": false}, - {"type": "object", "properties": {"type": {"const": "disc_grid"}, "count_x": {"$ref": "#/$defs/positiveInteger"}, "count_y": {"$ref": "#/$defs/positiveInteger"}, "spacing_x_mm": {"$ref": "#/$defs/positive"}, "spacing_y_mm": {"$ref": "#/$defs/positive"}, "center_mm": {"$ref": "#/$defs/point2"}, "max_center_radius_mm": {"$ref": "#/$defs/positive"}, "orientation_offset_deg": {"$ref": "#/$defs/number"}}, "required": ["type", "count_x", "count_y", "spacing_x_mm", "spacing_y_mm"], "additionalProperties": false}, - {"type": "object", "properties": {"type": {"const": "open_arc"}, "radius_mm": {"$ref": "#/$defs/positive"}, "count": {"$ref": "#/$defs/positiveInteger"}, "start_angle_deg": {"$ref": "#/$defs/number"}, "end_angle_deg": {"$ref": "#/$defs/number"}, "orientation": {"enum": ["fixed", "radial", "tangential", "snapped_radial", "diagonal_axes"]}, "orientation_offset_deg": {"$ref": "#/$defs/number"}, "orientation_snap_deg": {"$ref": "#/$defs/positive"}}, "required": ["type", "radius_mm", "count", "start_angle_deg", "end_angle_deg"], "additionalProperties": false}, - {"type": "object", "properties": {"type": {"const": "spiral"}, "count": {"$ref": "#/$defs/positiveInteger"}, "start_radius_mm": {"$ref": "#/$defs/positive"}, "radius_step_mm": {"$ref": "#/$defs/number"}, "start_angle_deg": {"$ref": "#/$defs/number"}, "angle_step_deg": {"$ref": "#/$defs/number"}, "orientation": {"enum": ["fixed", "radial", "tangential", "snapped_radial", "diagonal_axes"]}, "orientation_offset_deg": {"$ref": "#/$defs/number"}, "orientation_snap_deg": {"$ref": "#/$defs/positive"}}, "required": ["type", "count", "start_radius_mm", "radius_step_mm", "angle_step_deg"], "additionalProperties": false}, - {"type": "object", "properties": {"type": {"const": "cross_lines"}, "count_per_axis": {"$ref": "#/$defs/positiveInteger"}, "spacing_mm": {"$ref": "#/$defs/positive"}, "orientation_offset_deg": {"$ref": "#/$defs/number"}}, "required": ["type", "count_per_axis", "spacing_mm"], "additionalProperties": false}, - {"type": "object", "properties": {"type": {"const": "x_field"}, "levels": {"$ref": "#/$defs/positiveInteger"}, "spacing_mm": {"$ref": "#/$defs/positive"}, "orientation": {"enum": ["fixed", "radial", "tangential", "snapped_radial", "diagonal_axes"]}, "orientation_offset_deg": {"$ref": "#/$defs/number"}, "orientation_snap_deg": {"$ref": "#/$defs/positive"}}, "required": ["type", "levels", "spacing_mm"], "additionalProperties": false}, - {"type": "object", "properties": {"type": {"const": "twin_strips"}, "x_offset_mm": {"$ref": "#/$defs/positive"}, "count_y": {"$ref": "#/$defs/positiveInteger"}, "y_start_mm": {"$ref": "#/$defs/number"}, "y_end_mm": {"$ref": "#/$defs/number"}, "orientation_offset_deg": {"$ref": "#/$defs/number"}}, "required": ["type", "x_offset_mm", "count_y", "y_start_mm", "y_end_mm"], "additionalProperties": false}, - {"type": "object", "properties": {"type": {"const": "corner_clusters"}, "levels_mm": {"type": "array", "minItems": 1, "items": {"$ref": "#/$defs/positive"}}, "orientation_offset_deg": {"$ref": "#/$defs/number"}}, "required": ["type", "levels_mm"], "additionalProperties": false}, - {"type": "object", "properties": {"type": {"const": "diamond_field"}, "manhattan_radius": {"type": "integer", "minimum": 0}, "spacing_mm": {"$ref": "#/$defs/positive"}, "orientation_offset_deg": {"$ref": "#/$defs/number"}}, "required": ["type", "manhattan_radius", "spacing_mm"], "additionalProperties": false} - ] - }, - "pattern": {"type": "object", "properties": {"motif": {"$ref": "#/$defs/motif"}, "layout": {"$ref": "#/$defs/layout"}}, "required": ["motif", "layout"], "additionalProperties": false}, + "profile_type": {"enum": ["circle", "polygon", "analytic_contours"]}, "profile": { "oneOf": [ {"type": "object", "properties": {"type": {"const": "circle"}, "center": {"$ref": "#/$defs/point2"}, "radius_mm": {"$ref": "#/$defs/positive"}}, "required": ["type", "radius_mm"], "additionalProperties": false}, - {"type": "object", "properties": {"type": {"const": "annulus"}, "center": {"$ref": "#/$defs/point2"}, "inner_radius_mm": {"$ref": "#/$defs/positive"}, "outer_radius_mm": {"$ref": "#/$defs/positive"}}, "required": ["type", "inner_radius_mm", "outer_radius_mm"], "additionalProperties": false}, - {"type": "object", "properties": {"type": {"const": "circles"}, "items": {"type": "array", "minItems": 1, "items": {"$ref": "#/$defs/circleItem"}}}, "required": ["type", "items"], "additionalProperties": false}, - {"type": "object", "properties": {"type": {"const": "circle_grid"}, "radius_mm": {"$ref": "#/$defs/positive"}, "count_x": {"$ref": "#/$defs/positiveInteger"}, "count_y": {"$ref": "#/$defs/positiveInteger"}, "spacing_x_mm": {"$ref": "#/$defs/positive"}, "spacing_y_mm": {"$ref": "#/$defs/positive"}, "origin_mm": {"$ref": "#/$defs/point2"}, "center_mm": {"$ref": "#/$defs/point2"}}, "required": ["type", "radius_mm", "count_x", "count_y", "spacing_x_mm", "spacing_y_mm"], "additionalProperties": false, "oneOf": [{"required": ["origin_mm"], "not": {"required": ["center_mm"]}}, {"required": ["center_mm"], "not": {"required": ["origin_mm"]}}]}, - {"type": "object", "properties": {"type": {"const": "rectangle"}, "center": {"$ref": "#/$defs/point2"}, "width_mm": {"$ref": "#/$defs/positive"}, "height_mm": {"$ref": "#/$defs/positive"}, "min_mm": {"$ref": "#/$defs/point2"}, "max_mm": {"$ref": "#/$defs/point2"}}, "required": ["type"], "allOf": [{"$ref": "#/$defs/rectangleBounds"}], "additionalProperties": false}, - {"type": "object", "properties": {"type": {"const": "rectangle_with_circles"}, "boundary": {"$ref": "#/$defs/rectangleBoundary"}, "circles": {"type": "array", "items": {"$ref": "#/$defs/circleItem"}}}, "required": ["type", "boundary"], "additionalProperties": false}, - {"type": "object", "properties": {"type": {"const": "rectangle_with_fillets"}, "center": {"$ref": "#/$defs/point2"}, "width_mm": {"$ref": "#/$defs/positive"}, "height_mm": {"$ref": "#/$defs/positive"}, "min_mm": {"$ref": "#/$defs/point2"}, "max_mm": {"$ref": "#/$defs/point2"}, "fillet_radius_mm": {"$ref": "#/$defs/positive"}, "circles": {"type": "array", "items": {"$ref": "#/$defs/circleItem"}}}, "required": ["type"], "allOf": [{"$ref": "#/$defs/rectangleBounds"}], "additionalProperties": false}, - {"type": "object", "properties": {"type": {"const": "obround"}, "center": {"$ref": "#/$defs/point2"}, "length_mm": {"$ref": "#/$defs/positive"}, "width_mm": {"$ref": "#/$defs/positive"}}, "required": ["type", "length_mm", "width_mm"], "additionalProperties": false}, {"type": "object", "properties": {"type": {"const": "polygon"}, "vertices": {"type": "array", "minItems": 3, "items": {"$ref": "#/$defs/point2"}}}, "required": ["type", "vertices"], "additionalProperties": false}, - {"type": "object", "properties": {"type": {"const": "ibone"}, "body_width_mm": {"$ref": "#/$defs/positive"}, "body_height_mm": {"$ref": "#/$defs/positive"}, "flange_width_mm": {"$ref": "#/$defs/positive"}, "flange_height_mm": {"$ref": "#/$defs/positive"}, "corner_radius_mm": {"$ref": "#/$defs/positive"}, "hole_radius_mm": {"$ref": "#/$defs/positive"}}, "required": ["type", "body_width_mm", "body_height_mm", "flange_width_mm", "flange_height_mm", "corner_radius_mm"], "additionalProperties": false}, - {"type": "object", "properties": {"type": {"const": "rectangle_with_symmetric_notches"}, "width_mm": {"$ref": "#/$defs/positive"}, "height_mm": {"$ref": "#/$defs/positive"}, "notch": {"type": "object", "properties": {"y_start": {"$ref": "#/$defs/number"}, "y_end": {"$ref": "#/$defs/number"}, "depth_mm": {"$ref": "#/$defs/positive"}, "inner_radius_mm": {"$ref": "#/$defs/positive"}, "corner_radius_mm": {"$ref": "#/$defs/positive"}}, "required": ["y_start", "y_end", "depth_mm"], "additionalProperties": false}}, "required": ["type", "width_mm", "height_mm", "notch"], "additionalProperties": false}, - {"type": "object", "properties": {"type": {"const": "revolve_chamfer"}, "axis_height_mm": {"$ref": "#/$defs/positive"}, "top_width_mm": {"$ref": "#/$defs/positive"}, "bottom_width_mm": {"$ref": "#/$defs/positive"}, "wall_inset_mm": {"$ref": "#/$defs/number"}, "step_inset_mm": {"$ref": "#/$defs/number"}, "on_axis_side": {"enum": ["left", "right"]}}, "required": ["type", "axis_height_mm", "top_width_mm", "bottom_width_mm"], "additionalProperties": false}, - {"type": "object", "properties": {"type": {"const": "revolve_chamfer_slanted"}, "axis_height_mm": {"$ref": "#/$defs/positive"}, "top_width_mm": {"$ref": "#/$defs/positive"}, "wall_inset_mm": {"$ref": "#/$defs/number"}, "wall_height_mm": {"$ref": "#/$defs/positive"}, "wall_width_mm": {"$ref": "#/$defs/positive"}, "on_axis_side": {"enum": ["left", "right"]}}, "required": ["type", "axis_height_mm", "top_width_mm", "wall_height_mm", "wall_width_mm"], "additionalProperties": false}, - {"type": "object", "properties": {"type": {"const": "circle_with_arc_notches"}, "outer_radius_mm": {"$ref": "#/$defs/positive"}, "notch_radius_mm": {"$ref": "#/$defs/positive"}, "notch_angles_deg": {"type": "array", "minItems": 1, "items": {"$ref": "#/$defs/number"}}, "circles": {"type": "array", "items": {"$ref": "#/$defs/circleItem"}}}, "required": ["type", "outer_radius_mm", "notch_radius_mm"], "additionalProperties": false}, - {"type": "object", "properties": {"type": {"const": "circular_sector_slot"}, "arc_radius_mm": {"$ref": "#/$defs/positive"}, "slot_half_width_mm": {"$ref": "#/$defs/positive"}, "chord_half_mm": {"$ref": "#/$defs/positive"}, "circles": {"type": "array", "items": {"$ref": "#/$defs/circleItem"}}}, "required": ["type", "arc_radius_mm", "slot_half_width_mm", "chord_half_mm"], "additionalProperties": false}, - {"type": "object", "properties": {"type": {"const": "circle_with_radial_tabs"}, "outer_radius_mm": {"$ref": "#/$defs/positive"}, "tab_u_half_mm": {"$ref": "#/$defs/positive"}, "tab_v_offset_mm": {"$ref": "#/$defs/number"}, "circles": {"type": "array", "items": {"$ref": "#/$defs/circleItem"}}}, "required": ["type", "outer_radius_mm"], "additionalProperties": false}, - {"type": "object", "properties": {"type": {"const": "filleted_rect_side_slots"}, "half_width_mm": {"$ref": "#/$defs/positive"}, "half_height_mm": {"$ref": "#/$defs/positive"}, "corner_radius_mm": {"$ref": "#/$defs/positive"}, "slot_radius_mm": {"$ref": "#/$defs/positive"}, "circles": {"type": "array", "items": {"$ref": "#/$defs/circleItem"}}}, "required": ["type", "half_width_mm", "half_height_mm", "corner_radius_mm"], "additionalProperties": false}, - {"type": "object", "properties": {"type": {"const": "d_shape"}, "radius_mm": {"$ref": "#/$defs/positive"}, "chord_sign": {"enum": ["left", "right"]}, "chord_x_mm": {"$ref": "#/$defs/positive"}, "circles": {"type": "array", "items": {"$ref": "#/$defs/circleItem"}}}, "required": ["type", "radius_mm"], "additionalProperties": false}, - {"type": "object", "properties": {"type": {"const": "partial_ring"}, "inner_radius_mm": {"$ref": "#/$defs/positive"}, "outer_radius_mm": {"$ref": "#/$defs/positive"}, "half_angle_deg": {"$ref": "#/$defs/positive"}, "circles": {"type": "array", "items": {"$ref": "#/$defs/circleItem"}}}, "required": ["type", "inner_radius_mm", "outer_radius_mm"], "additionalProperties": false}, - {"type": "object", "properties": {"type": {"const": "partial_ring_with_arc_island"}, "inner_radius_mm": {"$ref": "#/$defs/positive"}, "outer_radius_mm": {"$ref": "#/$defs/positive"}, "half_angle_deg": {"$ref": "#/$defs/positive"}, "island_radius_mm": {"$ref": "#/$defs/positive"}, "island_gap_mm": {"$ref": "#/$defs/positive"}, "center_angles_deg": {"type": "array", "minItems": 1, "items": {"$ref": "#/$defs/number"}}, "replicas": {"type": "array", "minItems": 1, "items": {"type": "object", "properties": {"center_angle_deg": {"$ref": "#/$defs/number"}}, "required": ["center_angle_deg"], "additionalProperties": false}}}, "required": ["type", "inner_radius_mm", "outer_radius_mm", "island_radius_mm"], "additionalProperties": false, "oneOf": [{"required": ["center_angles_deg"], "not": {"required": ["replicas"]}}, {"required": ["replicas"], "not": {"required": ["center_angles_deg"]}}]}, - {"type": "object", "properties": {"type": {"const": "radial_slot"}, "inner_radius_mm": {"$ref": "#/$defs/positive"}, "outer_radius_mm": {"$ref": "#/$defs/positive"}, "start_angle_deg": {"$ref": "#/$defs/number"}, "end_angle_deg": {"$ref": "#/$defs/number"}, "circles": {"type": "array", "items": {"$ref": "#/$defs/circleItem"}}}, "required": ["type", "inner_radius_mm", "outer_radius_mm", "start_angle_deg", "end_angle_deg"], "additionalProperties": false}, - {"type": "object", "properties": {"type": {"const": "arc_chain"}, "arcs": {"type": "array", "minItems": 2, "items": {"type": "object", "properties": {"center": {"$ref": "#/$defs/point2"}, "radius_mm": {"$ref": "#/$defs/positive"}, "start_angle_deg": {"$ref": "#/$defs/number"}, "end_angle_deg": {"$ref": "#/$defs/number"}}, "required": ["radius_mm", "start_angle_deg", "end_angle_deg"], "additionalProperties": false}}, "circles": {"type": "array", "items": {"$ref": "#/$defs/circleItem"}}}, "required": ["type", "arcs"], "additionalProperties": false}, - {"type": "object", "properties": {"type": {"const": "patterned_cutouts"}, "motif": {"$ref": "#/$defs/motif"}, "layout": {"$ref": "#/$defs/layout"}}, "required": ["type", "motif", "layout"], "additionalProperties": false}, - {"type": "object", "properties": {"type": {"const": "compound_patterned_cutouts"}, "patterns": {"type": "array", "minItems": 1, "items": {"$ref": "#/$defs/pattern"}}}, "required": ["type", "patterns"], "additionalProperties": false}, {"$ref": "#/$defs/analyticProfile"} ] }, diff --git a/backend/engine/cdsl_engine/design_intent.py b/backend/engine/cdsl_engine/design_intent.py deleted file mode 100644 index 3abe589e..00000000 --- a/backend/engine/cdsl_engine/design_intent.py +++ /dev/null @@ -1,331 +0,0 @@ -"""Validation for the semantic planning artifact between a request and CDSL.""" - -from __future__ import annotations - -import json -from copy import deepcopy -from pathlib import Path -from typing import Any - -from jsonschema import Draft202012Validator -from jsonschema.exceptions import SchemaError - - -class DesignIntentError(ValueError): - """A plan-contract violation with a stable error code for the Agent.""" - - def __init__(self, code: str, message: str) -> None: - super().__init__(message) - self.code = code - - -def _schema_path(engine: Any) -> Path: - return Path(str(engine.__file__)).with_name("design_intent_schema.json") - - -def load_design_intent_schema(engine: Any) -> dict[str, Any]: - path = _schema_path(engine) - try: - schema = json.loads(path.read_text(encoding="utf-8")) - Draft202012Validator.check_schema(schema) - except (OSError, json.JSONDecodeError, SchemaError) as error: - raise RuntimeError("The local DesignIntent JSON Schema is unavailable or invalid") from error - return schema - - -def _location(error: Any) -> str: - return "$" + "".join( - f"[{item}]" if isinstance(item, int) else f".{item}" - for item in error.absolute_path - ) - - -def _schema_validate(intent: dict[str, Any], engine: Any) -> None: - errors = sorted( - Draft202012Validator(load_design_intent_schema(engine)).iter_errors(intent), - key=lambda error: (list(error.absolute_path), error.message), - ) - if errors: - error = errors[0] - raise DesignIntentError( - "INVALID_DESIGN_INTENT", - f"DesignIntent schema violation at {_location(error)}: {error.message}", - ) - - -def _runtime_capabilities(engine: Any) -> tuple[set[str], set[str], dict[str, dict[str, Any]]]: - try: - profile_schema = json.loads(Path(str(engine.__file__)).with_name("profile_schema.json").read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError) as error: - raise RuntimeError("The local CDSL profile schema is unavailable or invalid") from error - atomic_ids = set(profile_schema.get("runtime_supported_atomic_ids") or getattr(engine, "SUPPORTED_ATOMIC_IDS", ())) - profiles = { - name - for name, contract in (profile_schema.get("profiles") or {}).items() - if isinstance(contract, dict) and contract.get("agent_allowed") is True - } - atomic_contracts = { - str(atomic_id): contract - for atomic_id, contract in (profile_schema.get("feature_atomic_ids") or {}).items() - if isinstance(contract, dict) - } - return {str(item) for item in atomic_ids}, {str(item) for item in profiles}, atomic_contracts - - -def _blocking(intent: dict[str, Any]) -> bool: - questions = intent.get("open_questions") or [] - gaps = intent.get("capability_gaps") or [] - return any(isinstance(item, dict) and item.get("blocking") is True for item in [*questions, *gaps]) - - -def validate_design_intent( - intent: dict[str, Any], - engine: Any, - *, - current_revision_id: str = "", -) -> dict[str, Any]: - """Validate DesignIntent syntax and semantic ordering against runtime capability.""" - if not isinstance(intent, dict): - raise DesignIntentError("INVALID_DESIGN_INTENT", "DesignIntent must be a JSON object") - _schema_validate(intent, engine) - - normalized = deepcopy(intent) - mode = str(normalized["mode"]) - base_revision_id = str(normalized["base_revision_id"] or "") - if mode == "create" and base_revision_id: - raise DesignIntentError("INVALID_DESIGN_INTENT", "create DesignIntent must not set base_revision_id") - if mode == "revise": - if not base_revision_id: - raise DesignIntentError("INVALID_DESIGN_INTENT", "revise DesignIntent requires base_revision_id") - if current_revision_id and base_revision_id != current_revision_id: - raise DesignIntentError( - "INVALID_DESIGN_INTENT", - "revise DesignIntent base_revision_id must be the current successful revision", - ) - - structures = normalized["structures"] - ids = [str(item["id"]) for item in structures] - feature_ids = [str(item["cdsl_feature_id"]) for item in structures] - if len(set(ids)) != len(ids): - raise DesignIntentError("INVALID_DESIGN_INTENT", "DesignIntent structure ids must be unique") - if len(set(feature_ids)) != len(feature_ids): - raise DesignIntentError("INVALID_DESIGN_INTENT", "DesignIntent cdsl_feature_id values must be unique") - - feature_order = [str(item) for item in normalized["feature_order"]] - if len(set(feature_order)) != len(feature_order) or set(feature_order) != set(ids): - raise DesignIntentError( - "INVALID_DESIGN_INTENT", - "feature_order must list every DesignIntent structure exactly once", - ) - order = {structure_id: index for index, structure_id in enumerate(feature_order)} - atomic_ids, profile_types, atomic_contracts = _runtime_capabilities(engine) - for structure in structures: - structure_id = str(structure["id"]) - for dependency in structure["depends_on"]: - dependency_id = str(dependency) - if dependency_id not in order: - raise DesignIntentError( - "INVALID_DESIGN_INTENT", - f"Structure {structure_id} depends on an unknown structure {dependency_id}", - ) - if order[dependency_id] >= order[structure_id]: - raise DesignIntentError( - "INVALID_DESIGN_INTENT", - f"Structure {structure_id} must depend only on earlier feature_order entries", - ) - strategy = structure["cdsl_strategy"] - atomic_id = str(strategy["atomic_id"]) - if atomic_id not in atomic_ids: - raise DesignIntentError( - "INVALID_DESIGN_INTENT", - f"Structure {structure_id} uses unsupported atomic_id {atomic_id}", - ) - profile_type = str(strategy.get("profile_type") or "") - if atomic_contracts.get(atomic_id, {}).get("requires_sketch") and not profile_type: - raise DesignIntentError( - "INVALID_DESIGN_INTENT", - f"Structure {structure_id} uses {atomic_id} and requires a profile_type", - ) - if profile_type and profile_type not in profile_types: - raise DesignIntentError( - "INVALID_DESIGN_INTENT", - f"Structure {structure_id} uses unsupported or agent-disallowed profile_type {profile_type}", - ) - - if normalized["status"] == "ready" and _blocking(normalized): - raise DesignIntentError( - "DESIGN_INTENT_BLOCKED", - "A DesignIntent with blocking questions or capability gaps must need clarification", - ) - if normalized["status"] == "needs_clarification" and not _blocking(normalized): - raise DesignIntentError( - "INVALID_DESIGN_INTENT", - "needs_clarification requires a blocking question or capability gap", - ) - if normalized["status"] == "ready" and not structures: - raise DesignIntentError("INVALID_DESIGN_INTENT", "A ready DesignIntent needs at least one structure") - if normalized["status"] == "ready" and mode == "create" and not any(item["role"] == "base" for item in structures): - raise DesignIntentError("INVALID_DESIGN_INTENT", "A ready create DesignIntent needs a base structure") - nonblocking_gaps = [item for item in normalized["capability_gaps"] if not item["blocking"]] - if nonblocking_gaps and not normalized["assumptions"]: - raise DesignIntentError( - "INVALID_DESIGN_INTENT", - "Non-blocking capability gaps require an explicit approximation assumption", - ) - for gap in normalized["capability_gaps"]: - if str(gap["structure_id"]) not in set(ids): - raise DesignIntentError( - "INVALID_DESIGN_INTENT", - f"Capability gap references an unknown structure {gap['structure_id']}", - ) - valid_expectation_ids = set(ids) | set(feature_ids) - for expectation in normalized["verification_expectations"]: - if expectation["type"] == "feature_count" and str(expectation["feature_id"]) not in valid_expectation_ids: - raise DesignIntentError( - "INVALID_DESIGN_INTENT", - f"feature_count expectation references an unknown structure or CDSL feature {expectation['feature_id']}", - ) - return normalized - - -def _has_selector_evidence(value: Any, role: str) -> bool: - if isinstance(value, dict): - for key, child in value.items(): - if role in {"selector", "selectors"} and key == "selectors" and isinstance(child, list) and child: - return True - if key == role and child not in (None, "", [], {}): - if not (isinstance(child, dict) and set(child) == {"unresolved"}): - return True - if _has_selector_evidence(child, role): - return True - elif isinstance(value, list): - return any(_has_selector_evidence(item, role) for item in value) - return False - - -def validate_intent_cdsl( - intent: dict[str, Any], - cdsl: dict[str, Any], - engine: Any, - *, - current_cdsl: dict[str, Any] | None = None, -) -> None: - """Ensure an executable CDSL document faithfully realizes one accepted plan.""" - normalized = validate_design_intent(intent, engine) - if normalized["status"] != "ready": - raise DesignIntentError("DESIGN_INTENT_BLOCKED", "CDSL generation is blocked until the DesignIntent is ready") - if not isinstance(cdsl, dict): - raise DesignIntentError("INTENT_CDSL_MISMATCH", "CDSL must be a JSON object") - features = cdsl.get("features") - sketches = (cdsl.get("geometry") or {}).get("sketches") - if not isinstance(features, list) or not isinstance(sketches, list): - raise DesignIntentError("INTENT_CDSL_MISMATCH", "CDSL must contain features and sketches") - feature_by_id = {str(feature.get("id") or ""): feature for feature in features if isinstance(feature, dict)} - if len(feature_by_id) != len(features): - raise DesignIntentError("INTENT_CDSL_MISMATCH", "CDSL features must have unique ids") - structure_by_id = {str(item["id"]): item for item in normalized["structures"]} - expected_feature_ids = [str(structure_by_id[item]["cdsl_feature_id"]) for item in normalized["feature_order"]] - if normalized["mode"] == "revise" and isinstance(current_cdsl, dict): - base_feature_ids = { - str(feature.get("id") or "") - for feature in current_cdsl.get("features") or [] - if isinstance(feature, dict) and feature.get("id") - } - if not base_feature_ids.issubset(set(expected_feature_ids)): - missing = ", ".join(sorted(base_feature_ids - set(expected_feature_ids))) - raise DesignIntentError( - "INTENT_CDSL_MISMATCH", - f"revise DesignIntent must explicitly preserve current features: {missing}", - ) - actual_feature_ids = [str(feature.get("id") or "") for feature in features] - if actual_feature_ids != expected_feature_ids: - raise DesignIntentError( - "INTENT_CDSL_MISMATCH", - "CDSL feature ids and order must exactly match DesignIntent feature_order", - ) - sketch_by_id = {str(sketch.get("id") or ""): sketch for sketch in sketches if isinstance(sketch, dict)} - feature_for_structure = { - str(structure["id"]): feature_by_id[str(structure["cdsl_feature_id"])] - for structure in normalized["structures"] - } - for structure in normalized["structures"]: - structure_id = str(structure["id"]) - feature = feature_for_structure[structure_id] - strategy = structure["cdsl_strategy"] - if feature.get("atomic_id") != strategy["atomic_id"]: - raise DesignIntentError( - "INTENT_CDSL_MISMATCH", - f"CDSL feature {feature.get('id')} atomic_id does not match structure {structure_id}", - ) - expected_dependencies = { - str(structure_by_id[dependency]["cdsl_feature_id"]) - for dependency in structure["depends_on"] - } - actual_dependencies = {str(item) for item in feature.get("depends_on") or []} - if not expected_dependencies.issubset(actual_dependencies): - raise DesignIntentError( - "INTENT_CDSL_MISMATCH", - f"CDSL feature {feature.get('id')} is missing planned dependencies", - ) - profile_type = str(strategy.get("profile_type") or "") - if profile_type: - sketch_id = str(feature.get("sketch_id") or "") - sketch = sketch_by_id.get(sketch_id) - actual_profile_type = str(((sketch or {}).get("profile") or {}).get("type") or "") - if actual_profile_type != profile_type: - raise DesignIntentError( - "INTENT_CDSL_MISMATCH", - f"CDSL feature {feature.get('id')} must use profile_type {profile_type}", - ) - for selector_role in strategy["selector_roles"]: - if not _has_selector_evidence(feature, str(selector_role)): - raise DesignIntentError( - "INTENT_CDSL_MISMATCH", - f"CDSL feature {feature.get('id')} is missing selector evidence for {selector_role}", - ) - - -def design_intent_from_cdsl(cdsl: dict[str, Any], *, request: str, mode: str = "create", base_revision_id: str = "") -> dict[str, Any]: - """Create a conservative audit-only plan for legacy revisions when needed.""" - sketches = (cdsl.get("geometry") or {}).get("sketches") or [] - profile_by_sketch_id = { - str(sketch.get("id") or ""): str((sketch.get("profile") or {}).get("type") or "") - for sketch in sketches - if isinstance(sketch, dict) - } - structures: list[dict[str, Any]] = [] - for index, feature in enumerate(cdsl.get("features") or []): - if not isinstance(feature, dict): - continue - feature_id = str(feature.get("id") or f"feature_{index + 1}") - atomic_id = str(feature.get("atomic_id") or "") - profile_type = profile_by_sketch_id.get(str(feature.get("sketch_id") or ""), "") - strategy: dict[str, Any] = { - "atomic_id": atomic_id, - "parameter_roles": sorted(str(key) for key in (feature.get("params") or {}).keys()), - "selector_roles": [], - } - if profile_type: - strategy["profile_type"] = profile_type - structures.append({ - "id": feature_id, - "cdsl_feature_id": feature_id, - "role": "base" if index == 0 else "subtractive" if "cut" in atomic_id or atomic_id.startswith("hole") else "additive", - "purpose": str(feature.get("name") or feature_id), - "depends_on": [str(item) for item in feature.get("depends_on") or []], - "cdsl_strategy": strategy, - }) - return { - "schema": "cad.cdsl.design-intent.v1", - "schema_version": "1.0", - "mode": mode, - "request": request, - "base_revision_id": base_revision_id, - "structures": structures, - "feature_order": [item["id"] for item in structures], - "assumptions": ["Synthesized from a legacy CDSL revision."], - "open_questions": [], - "capability_gaps": [], - "verification_expectations": [], - "status": "ready", - } diff --git a/backend/engine/cdsl_engine/design_intent_schema.json b/backend/engine/cdsl_engine/design_intent_schema.json deleted file mode 100644 index cc1ab344..00000000 --- a/backend/engine/cdsl_engine/design_intent_schema.json +++ /dev/null @@ -1,124 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://cdsl.local/schema/cad.cdsl.design-intent.v1", - "title": "CDSL design intent", - "type": "object", - "properties": { - "schema": {"const": "cad.cdsl.design-intent.v1"}, - "schema_version": {"const": "1.0"}, - "mode": {"enum": ["create", "revise"]}, - "request": {"type": "string", "minLength": 1}, - "base_revision_id": {"type": "string"}, - "structures": {"type": "array", "items": {"$ref": "#/$defs/structure"}}, - "feature_order": {"type": "array", "items": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}}, - "assumptions": {"type": "array", "items": {"type": "string", "minLength": 1}}, - "open_questions": {"type": "array", "items": {"$ref": "#/$defs/openQuestion"}}, - "capability_gaps": {"type": "array", "items": {"$ref": "#/$defs/capabilityGap"}}, - "verification_expectations": {"type": "array", "items": {"$ref": "#/$defs/verificationExpectation"}}, - "status": {"enum": ["ready", "needs_clarification"]}, - "intent_id": {"type": "string", "pattern": "^intent_[a-z0-9]{12}$"}, - "created_at": {"type": "string", "minLength": 1}, - "part_skill_ids": {"type": "array", "items": {"type": "string", "minLength": 1}}, - "part_skill_selection": {"type": "object"} - }, - "required": [ - "schema", - "schema_version", - "mode", - "request", - "base_revision_id", - "structures", - "feature_order", - "assumptions", - "open_questions", - "capability_gaps", - "verification_expectations", - "status" - ], - "additionalProperties": false, - "$defs": { - "structure": { - "type": "object", - "properties": { - "id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}, - "cdsl_feature_id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}, - "role": {"enum": ["base", "reference", "additive", "subtractive", "dressup", "pattern"]}, - "purpose": {"type": "string", "minLength": 1}, - "depends_on": {"type": "array", "items": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}}, - "cdsl_strategy": {"$ref": "#/$defs/cdslStrategy"} - }, - "required": ["id", "cdsl_feature_id", "role", "purpose", "depends_on", "cdsl_strategy"], - "additionalProperties": false - }, - "cdslStrategy": { - "type": "object", - "properties": { - "atomic_id": {"type": "string", "minLength": 1}, - "profile_type": {"type": "string", "minLength": 1}, - "parameter_roles": {"type": "array", "items": {"type": "string", "minLength": 1}}, - "selector_roles": {"type": "array", "items": {"type": "string", "minLength": 1}} - }, - "required": ["atomic_id", "parameter_roles", "selector_roles"], - "additionalProperties": false - }, - "openQuestion": { - "type": "object", - "properties": { - "id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}, - "question": {"type": "string", "minLength": 1}, - "blocking": {"type": "boolean"} - }, - "required": ["id", "question", "blocking"], - "additionalProperties": false - }, - "capabilityGap": { - "type": "object", - "properties": { - "code": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,100}$"}, - "structure_id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}, - "message": {"type": "string", "minLength": 1}, - "blocking": {"type": "boolean"} - }, - "required": ["code", "structure_id", "message", "blocking"], - "additionalProperties": false - }, - "verificationExpectation": { - "oneOf": [ - { - "type": "object", - "properties": { - "type": {"const": "feature_count"}, - "feature_id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}, - "expected": {"type": "integer", "minimum": 0} - }, - "required": ["type", "feature_id", "expected"], - "additionalProperties": false - }, - { - "type": "object", - "properties": {"type": {"const": "symmetry"}, "axis": {"enum": ["x", "y", "z"]}}, - "required": ["type", "axis"], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "type": {"const": "bbox"}, - "expected_mm": { - "type": "object", - "properties": { - "x": {"type": "number", "exclusiveMinimum": 0}, - "y": {"type": "number", "exclusiveMinimum": 0}, - "z": {"type": "number", "exclusiveMinimum": 0} - }, - "required": ["x", "y", "z"], - "additionalProperties": false - } - }, - "required": ["type", "expected_mm"], - "additionalProperties": false - } - ] - } - } -} diff --git a/backend/engine/cdsl_engine/phase_pools.py b/backend/engine/cdsl_engine/phase_pools.py index a2118c32..fd228762 100644 --- a/backend/engine/cdsl_engine/phase_pools.py +++ b/backend/engine/cdsl_engine/phase_pools.py @@ -11,6 +11,7 @@ from pathlib import Path from typing import Any from .capabilities import sketch_ids_required_by_contract +from cdsl_importer.legacy_profile_adapter import lower_legacy_profiles from .semantic_validation import validate_semantic_cdsl from .sketch_solver import resolve_required_sketches @@ -21,7 +22,7 @@ P3_ATOMIC_IDS = frozenset({ }) P4_ATOMIC_IDS = P3_ATOMIC_IDS | frozenset({"hole_wizard"}) P6_ATOMIC_IDS = P4_ATOMIC_IDS | frozenset({"pattern_linear", "pattern_mirror"}) -P3_PROFILE_TYPES = frozenset({"analytic_contours", "circle", "circles", "annulus"}) +P3_PROFILE_TYPES = frozenset({"analytic_contours", "circle", "polygon"}) # Static pool membership asks whether the exported history is an extrude/ # revolve history. Whether a first cut has a preceding active body remains a # runtime preflight question, not a reason to erase it from the input pool. @@ -66,6 +67,9 @@ def is_static_phase_ready(cdsl: dict[str, Any], phase: str) -> bool: allowed = _PHASE_ATOMIC_IDS.get(phase) if allowed is None: raise ValueError(f"Unknown CDSL runtime phase {phase!r}") + # Corpus pools retain historical profiles on disk but evaluate them after + # the importer compatibility lowering used by the batch path. + cdsl = lower_legacy_profiles(cdsl) semantic = validate_semantic_cdsl(cdsl) if semantic["unresolved"]: return False diff --git a/backend/engine/cdsl_engine/profile_schema.json b/backend/engine/cdsl_engine/profile_schema.json index 14ba81d4..74a90623 100644 --- a/backend/engine/cdsl_engine/profile_schema.json +++ b/backend/engine/cdsl_engine/profile_schema.json @@ -2,10 +2,10 @@ "schema": "cdsl.engine.schema.v1", "schema_version": "1.3.0", "cdsl_json_schema_file": "cdsl_schema.json", - "maintenance_rule": "The semantic CDSL contract is a superset of the current runtime. runtime_supported_atomic_ids and runtime_supported_profiles must stay synchronized with runtime.py EXECUTORS, sketch_solver.py SHAPE_GENERATORS, and the package-level capability tests. Legacy llm_compiler.py and llm_engine.py are not the CDSL-only runtime contract.", + "maintenance_rule": "The CDSL-only runtime contract is limited to direct generic profiles and runtime.py EXECUTORS. Legacy macro profiles are importer compatibility syntax and must be lowered by cdsl_importer.legacy_profile_adapter before generic runtime validation.", "coordinate_convention": "All profile dimensions use millimetres. Two-dimensional points are [u, v] in the sketch workplane.", "runtime_supported_atomic_ids": ["extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind", "revolve_add", "revolve_cut", "hole_blind", "hole_countersink", "hole_counterbore", "sphere_add", "reference_plane", "reference_axis", "hole_wizard", "fillet", "chamfer", "pattern_linear", "pattern_mirror"], - "runtime_supported_profiles": ["circle", "annulus", "circles", "circle_grid", "rectangle", "rectangle_with_circles", "rectangle_with_fillets", "obround", "polygon", "ibone", "rectangle_with_symmetric_notches", "revolve_chamfer", "revolve_chamfer_slanted", "circle_with_arc_notches", "circular_sector_slot", "circle_with_radial_tabs", "filleted_rect_side_slots", "d_shape", "partial_ring", "partial_ring_with_arc_island", "arc_chain", "radial_slot", "patterned_cutouts", "compound_patterned_cutouts", "analytic_contours", "complex_arc_shape", "unknown_shape"], + "runtime_supported_profiles": ["circle", "polygon", "analytic_contours"], "feature_atomic_ids": { "extrude_add_blind": {"summary": "Add the closed profile by one signed extrusion distance.", "required_params": ["distance_mm"], "optional_params": ["reverse"], "requires_sketch": true}, "extrude_add_two_sided": {"summary": "Add the closed profile with independently captured forward and reverse terminations.", "required_params": ["distance_mm", "reverse_distance_mm"], "optional_params": ["reverse", "end_condition", "reverse_end_condition"], "requires_sketch": true}, @@ -26,202 +26,18 @@ }, "profiles": { "circle": { - "agent_allowed": true, "summary": "A single circular closed profile.", "required": ["radius_mm"], "optional": ["center"], "constraints": ["radius_mm > 0", "center defaults to [0, 0]"] }, - "annulus": { - "agent_allowed": true, - "summary": "A concentric ring.", - "required": ["inner_radius_mm", "outer_radius_mm"], - "optional": ["center"], - "constraints": ["0 < inner_radius_mm < outer_radius_mm"] - }, - "circles": { - "agent_allowed": true, - "summary": "A non-empty list of circles.", - "required": ["items"], - "item_schema": {"required": ["radius_mm"], "optional": ["center"]} - }, - "circle_grid": { - "agent_allowed": true, - "summary": "A rectangular grid of equal circles.", - "required": ["radius_mm", "count_x", "count_y", "spacing_x_mm", "spacing_y_mm"], - "optional": ["origin_mm", "center_mm"], - "constraints": ["radius_mm > 0", "count_x and count_y are integers >= 1", "use origin_mm or center_mm, not both"] - }, - "rectangle": { - "agent_allowed": true, - "summary": "An axis-aligned rectangle.", - "one_of": [["center", "width_mm", "height_mm"], ["min_mm", "max_mm"]], - "constraints": ["width_mm > 0 and height_mm > 0 when using center"] - }, - "rectangle_with_circles": { - "agent_allowed": true, - "summary": "A rectangle or filleted rectangle with optional internal circles.", - "required": ["boundary"], - "optional": ["circles"], - "nested": {"boundary": "rectangle schema plus optional type=rectangle|rectangle_with_fillets and fillet_radius_mm"} - }, - "rectangle_with_fillets": { - "agent_allowed": true, - "summary": "An axis-aligned rectangle with corner fillets and optional internal circles.", - "one_of": [["center", "width_mm", "height_mm"], ["min_mm", "max_mm"]], - "optional": ["fillet_radius_mm", "circles"], - "constraints": ["fillet_radius_mm defaults to 0"] - }, - "obround": { - "agent_allowed": true, - "summary": "A horizontal capsule/slot.", - "required": ["length_mm", "width_mm"], - "optional": ["center"], - "constraints": ["length_mm > 0", "width_mm > 0", "center defaults to [0, 0]"] - }, "polygon": { - "agent_allowed": true, "summary": "A closed straight-edge polygon.", "required": ["vertices"], "constraints": ["vertices contains at least three [u, v] points"] }, - "ibone": { - "agent_allowed": true, - "summary": "I-shaped lug with optional four holes.", - "required": ["body_width_mm", "body_height_mm", "flange_width_mm", "flange_height_mm", "corner_radius_mm"], - "optional": ["hole_radius_mm"] - }, - "rectangle_with_symmetric_notches": { - "agent_allowed": true, - "summary": "A rectangular plate with four symmetric side notches.", - "required": ["width_mm", "height_mm", "notch"], - "nested": {"notch": {"required": ["y_start", "y_end", "depth_mm"], "optional": ["inner_radius_mm", "corner_radius_mm"]}} - }, - "revolve_chamfer": { - "agent_allowed": true, - "summary": "Five-edge trapezoid profile for a revolve cut.", - "required": ["axis_height_mm", "top_width_mm", "bottom_width_mm"], - "optional": ["wall_inset_mm", "step_inset_mm", "on_axis_side"], - "constraints": ["on_axis_side is left or right"] - }, - "revolve_chamfer_slanted": { - "agent_allowed": true, - "summary": "Five-edge slanted profile for a revolve cut.", - "required": ["axis_height_mm", "top_width_mm", "wall_height_mm", "wall_width_mm"], - "optional": ["wall_inset_mm", "on_axis_side"], - "constraints": ["on_axis_side is left or right"] - }, - "circle_with_arc_notches": { - "agent_allowed": true, - "summary": "A circular boundary with arc-shaped notches.", - "required": ["outer_radius_mm", "notch_radius_mm"], - "optional": ["notch_angles_deg", "circles"], - "constraints": ["notch_angles_deg defaults to [0, 90, 180, 270]"] - }, - "circular_sector_slot": { - "agent_allowed": true, - "summary": "A circular-sector boundary with a central rectangular slot.", - "required": ["arc_radius_mm", "slot_half_width_mm", "chord_half_mm"], - "optional": ["circles"] - }, - "circle_with_radial_tabs": { - "agent_allowed": true, - "summary": "A circle with two mirrored radial tabs.", - "required": ["outer_radius_mm"], - "optional": ["tab_u_half_mm", "tab_v_offset_mm", "circles"] - }, - "filleted_rect_side_slots": { - "agent_allowed": true, - "summary": "A filleted rectangle with one semicircular slot at each side centre.", - "required": ["half_width_mm", "half_height_mm", "corner_radius_mm"], - "optional": ["slot_radius_mm", "circles"] - }, - "d_shape": { - "agent_allowed": true, - "summary": "A D-shaped boundary made from one chord and one arc.", - "required": ["radius_mm"], - "optional": ["chord_sign", "chord_x_mm", "circles"], - "constraints": ["chord_sign is left or right"] - }, - "partial_ring": { - "agent_allowed": true, - "summary": "An annular sector.", - "required": ["inner_radius_mm", "outer_radius_mm"], - "optional": ["half_angle_deg", "circles"], - "constraints": ["0 < inner_radius_mm < outer_radius_mm"] - }, - "partial_ring_with_arc_island": { - "agent_allowed": true, - "summary": "One or more annular sectors with an arc island.", - "required": ["inner_radius_mm", "outer_radius_mm", "island_radius_mm"], - "optional": ["half_angle_deg", "island_gap_mm", "center_angles_deg", "replicas"], - "nested": {"replicas": {"required": ["center_angle_deg"]}} - }, - "arc_chain": { - "agent_allowed": true, - "summary": "A closed chain of two or more arcs, commonly used for revolve sections.", - "required": ["arcs"], - "optional": ["circles"], - "item_schema": {"required": ["radius_mm", "start_angle_deg", "end_angle_deg"], "optional": ["center"]}, - "constraints": ["arcs contains at least two endpoint-connected arcs"] - }, - "radial_slot": { - "agent_allowed": true, - "summary": "A rounded annular sector slot.", - "required": ["inner_radius_mm", "outer_radius_mm", "start_angle_deg", "end_angle_deg"], - "optional": ["circles"], - "constraints": ["0 < inner_radius_mm < outer_radius_mm"] - }, - "patterned_cutouts": { - "agent_allowed": true, - "summary": "One procedural cutout motif repeated by one procedural layout.", - "required": ["motif", "layout"], - "nested": {"motif": "See motif_types", "layout": "See layout_types"} - }, - "compound_patterned_cutouts": { - "agent_allowed": true, - "summary": "Multiple procedural motif/layout pairs merged into one cutout sketch.", - "required": ["patterns"], - "nested": {"patterns": "Non-empty list of {motif, layout}; see patterned_cutouts."} - }, - "complex_arc_shape": { - "agent_allowed": false, - "summary": "Legacy compiler-context fallback only. Never generate it." - }, "analytic_contours": { - "agent_allowed": false, - "summary": "Exact analytic line, arc and circle contours emitted by the Evidence v2 converter. The runtime resolves them into closed regions without compiler_context; B-spline currently produces an explicit unsupported diagnostic." - }, - "unknown_shape": { - "agent_allowed": false, - "summary": "Legacy compiler-context fallback only. Never generate it." + "summary": "Closed direct line, arc and circle contours. B-splines are unsupported." } - }, - "motif_types": { - "circle": ["radius_mm"], - "square": ["width_mm"], - "rectangle": ["width_mm", "height_mm"], - "obround": ["length_mm", "width_mm"], - "cross": ["size_mm", "arm_width_mm"], - "d_shape_polygon": ["stem_length_mm", "nose_depth_mm", "half_height_mm"], - "regular_hexagon": ["radius_mm"], - "skew_hexagon": ["nominal_radius_mm"], - "triangle": ["radius_mm"], - "teardrop_polygon": ["width_mm", "height_mm"], - "trapezoid": ["bottom_width_mm", "top_width_mm", "height_mm"], - "annular_sector_polygon": ["inner_radius_mm", "outer_radius_mm", "half_angle_deg"] - }, - "layout_types": { - "ring": ["radius_mm", "count"], - "angular": ["count"], - "concentric_rings": ["rings"], - "disc_grid": ["count_x", "count_y", "spacing_x_mm", "spacing_y_mm"], - "open_arc": ["radius_mm", "count", "start_angle_deg", "end_angle_deg"], - "spiral": ["count", "start_radius_mm", "radius_step_mm", "angle_step_deg"], - "cross_lines": ["count_per_axis", "spacing_mm"], - "x_field": ["levels", "spacing_mm"], - "twin_strips": ["x_offset_mm", "count_y", "y_start_mm", "y_end_mm"], - "corner_clusters": ["levels_mm"], - "diamond_field": ["manhattan_radius", "spacing_mm"] } } diff --git a/backend/engine/cdsl_engine/rebuild.py b/backend/engine/cdsl_engine/rebuild.py index 57b9e7ee..7f965e73 100644 --- a/backend/engine/cdsl_engine/rebuild.py +++ b/backend/engine/cdsl_engine/rebuild.py @@ -15,25 +15,30 @@ from pathlib import Path from typing import Any try: - from .sketch_solver import SHAPE_GENERATORS, resolve_all_sketches + from cdsl_importer.legacy_profile_adapter import lower_legacy_profiles + from .sketch_solver import SHAPE_GENERATORS, resolve_all_sketches from .llm_compiler import compile_cdsl from .llm_engine import run_engine_plan from .translator import generate_build123d_code, normalize_to_ir except ImportError: # 允许直接 python rebuild.py - from sketch_solver import SHAPE_GENERATORS, resolve_all_sketches + from cdsl_importer.legacy_profile_adapter import lower_legacy_profiles + from sketch_solver import SHAPE_GENERATORS, resolve_all_sketches from llm_compiler import compile_cdsl from llm_engine import run_engine_plan from translator import generate_build123d_code, normalize_to_ir -def run_rebuild(cdsl: dict[str, Any], out_step: Path, ctx_file: Path | None = None, gold_step: Path | None = None, - force_exact: bool = False) -> dict[str, Any]: +def run_rebuild(cdsl: dict[str, Any], out_step: Path, ctx_file: Path | None = None, gold_step: Path | None = None, + force_exact: bool = False) -> dict[str, Any]: """主重建入口。 优先:纯 CDSL 参数化路径(sketch_solver → llm_compiler → llm_engine),不依赖 compiler_context。 回退:CDSL + compiler_context 的 translator 路径。 """ - sketches = cdsl.get("geometry", {}).get("sketches", []) + # Compatibility entry point only: new CDSL-only runtime calls do not use + # macro profiles and therefore never invoke this adapter. + cdsl = lower_legacy_profiles(cdsl) + sketches = cdsl.get("geometry", {}).get("sketches", []) all_drawable = bool(sketches) and all( _sketch_is_cdsl_drawable(s) for s in sketches ) diff --git a/backend/engine/cdsl_engine/runtime.py b/backend/engine/cdsl_engine/runtime.py index 30a342d1..c370ad6f 100644 --- a/backend/engine/cdsl_engine/runtime.py +++ b/backend/engine/cdsl_engine/runtime.py @@ -15,7 +15,7 @@ from .runtime_types import ( RuntimeDiagnostic, SelectorResolution, TopologyRecord, TopologyRegistry, vector_add, vector_cross, vector_dot, vector_scale, vector_subtract, vector_unit, ) -from .sketch_solver import SHAPE_GENERATORS, resolve_required_sketches +from .sketch_solver import CORE_SHAPE_GENERATORS, resolve_required_sketches ALL_ATOMIC_IDS = frozenset({ @@ -89,6 +89,7 @@ class ExecutionSession: results: dict[str, FeatureResult] = field(default_factory=dict) replay_definitions: dict[str, FeaturePlanNode] = field(default_factory=dict) selector_resolutions: list[dict[str, Any]] = field(default_factory=list) + active_feature_id: str = "" def register_body(self, feature_id: str, body: Any, *, replay_node: FeaturePlanNode | None = None) -> None: self.body = body @@ -103,7 +104,9 @@ class ExecutionSession: def resolve(self, selector: dict[str, Any]) -> SelectorResolution: resolution = self.topology.resolve(selector, active_body_id=self.body_id) - self.selector_resolutions.append(resolution.as_dict()) + evidence = resolution.as_dict() + evidence["feature_id"] = self.active_feature_id + self.selector_resolutions.append(evidence) return resolution def result(self, node: FeaturePlanNode, *, context: PlaneSpec | AxisSpec | None = None, diagnostics: list[RuntimeDiagnostic] | None = None) -> FeatureResult: @@ -743,7 +746,12 @@ def _execute_node(node: FeaturePlanNode, session: ExecutionSession, sketch_overr executor = EXECUTORS.get(node.atomic_id) if executor is None: raise ValueError(f"No executor registered for {node.atomic_id!r}") - return executor(node, session, sketch_override) + previous_feature_id = session.active_feature_id + session.active_feature_id = node.feature_id + try: + return executor(node, session, sketch_override) + finally: + session.active_feature_id = previous_feature_id ExecutorFunction = Callable[[FeaturePlanNode, ExecutionSession, dict[str, Any] | None], FeatureResult] @@ -824,7 +832,7 @@ def analyze_cdsl(cdsl: dict[str, Any]): resolved = resolve_required_sketches( deepcopy(cdsl), sketch_ids_required_by_contract(cdsl), errors=sketch_errors, ) - analyzer = CapabilityAnalyzer(atomic_ids=EXECUTORS, profile_types=SHAPE_GENERATORS) + analyzer = CapabilityAnalyzer(atomic_ids=EXECUTORS, profile_types=CORE_SHAPE_GENERATORS) return analyzer.analyze(resolved, sketch_errors=sketch_errors) @@ -834,7 +842,7 @@ def rebuild_cdsl(cdsl: dict[str, Any], out_step: Path, *, strict: bool = True) - resolved = resolve_required_sketches( deepcopy(cdsl), sketch_ids_required_by_contract(cdsl), errors=sketch_errors, ) - analysis = CapabilityAnalyzer(atomic_ids=EXECUTORS, profile_types=SHAPE_GENERATORS).analyze( + analysis = CapabilityAnalyzer(atomic_ids=EXECUTORS, profile_types=CORE_SHAPE_GENERATORS).analyze( resolved, sketch_errors=sketch_errors, ) if strict and not analysis.runtime_eligible: diff --git a/backend/engine/cdsl_engine/runtime_types.py b/backend/engine/cdsl_engine/runtime_types.py index de753079..242c1f39 100644 --- a/backend/engine/cdsl_engine/runtime_types.py +++ b/backend/engine/cdsl_engine/runtime_types.py @@ -381,6 +381,13 @@ class SelectorResolution: } if self.record is not None: output["record"] = self.record.public_dict() + output["selected"] = self.record.public_dict() + score = next( + (candidate.get("score") for candidate in self.candidates if candidate.get("record_id") == self.record.record_id), + None, + ) + if score is not None: + output["score"] = score if self.diagnostic is not None: output["diagnostic"] = self.diagnostic.as_dict() return output @@ -589,6 +596,66 @@ class TopologyRegistry: if owner: candidates = [record for record in candidates if owner in record.owners] geometry = normalize_selector_geometry(selector.get("geometry")) + if selector.get("snapshot_id") and not owner: + return SelectorResolution( + selector=selector, + status="not_found", + candidates=(), + diagnostic=RuntimeDiagnostic( + code="selector_owner_required", + message="A snapshot selector requires owner_feature_id", + detail={"minimum_score": minimum_score}, + ), + ) + stable_id = str(selector.get("stable_id") or "").strip() + if stable_id: + exact = [record for record in candidates if record.record_id == stable_id] + if len(exact) == 1: + record = exact[0] + # A stable ID is only a lookup accelerator for snapshot-aware + # selectors. It cannot revive a B-rep entity whose geometric + # signature changed after an upstream rebuild. + if selector.get("snapshot_id"): + score = self._geometry_score(geometry, record.geometry) if geometry else None + if score is None or score < minimum_score: + return SelectorResolution( + selector=selector, + status="not_found", + candidates=({"score": round(float(score or 0), 6), **record.public_dict()},), + diagnostic=RuntimeDiagnostic( + code="selector_geometry_mismatch", + message="The stable selector record no longer matches its geometry signature", + detail={"stable_id": stable_id, "score": score, "minimum_score": minimum_score}, + ), + ) + return SelectorResolution( + selector=selector, + status="resolved", + record=record, + candidates=({"score": round(float(score), 6) if selector.get("snapshot_id") else 1.0, **record.public_dict()},), + ) + if len(exact) > 1: + return SelectorResolution( + selector=selector, + status="ambiguous", + candidates=tuple({"score": 1.0, **record.public_dict()} for record in exact), + diagnostic=RuntimeDiagnostic( + code="selector_ambiguous", + message="More than one runtime topology record has the requested stable_id", + detail={"stable_id": stable_id, "candidate_count": len(exact)}, + ), + ) + if selector.get("snapshot_id") and not geometry: + return SelectorResolution( + selector=selector, + status="not_found", + candidates=(), + diagnostic=RuntimeDiagnostic( + code="selector_geometry_mismatch", + message="A snapshot selector requires a geometry signature", + detail={"minimum_score": minimum_score}, + ), + ) scored: list[tuple[float, TopologyRecord]] = [] for candidate in candidates: # An owner-qualified context selector is deterministic when it has diff --git a/backend/engine/cdsl_engine/sketch_solver.py b/backend/engine/cdsl_engine/sketch_solver.py index 4311d7dc..c9c5e4a6 100644 --- a/backend/engine/cdsl_engine/sketch_solver.py +++ b/backend/engine/cdsl_engine/sketch_solver.py @@ -1,37 +1,9 @@ -"""轮廓求解器:参数化草图描述 → entities + contour_edges_mm。 +"""Core CDSL sketch resolver. -LLM 只需输出离散决策(type, radius, width …), -求解器负责生成精确的实体和轮廓边坐标。 - -架构:注册表模式 —— 每个轮廓类型对应一个生成器函数, - 按 "type" 字符串索引。新增形状或调整既有 profile 参数契约时: - 1. 写 def solver_xxx(profile, meta) -> (entities, contour) - 2. 注册: SHAPE_GENERATORS["xxx"] = solver_xxx - 3. 在 convert 脚本中输出对应的 profile - 4. 同步更新 profile_schema.json(Agent 与后端校验器的公开契约) - -支持的 profile 类型: - - circle: 单个圆 - - annulus: 同心圆环 - - circles: 多圆(引擎自动判断加/切除) - - circle_grid: 矩形圆孔阵列(行列+间距) - - rectangle: 矩形 - - rectangle_with_circles: 矩形 + 内圆孔/岛 - - rectangle_with_fillets: 带圆角的矩形(4角倒圆),可选内圆 - - rectangle_with_symmetric_notches: 对称槽板(矩形+4个U形缺口) - - obround: 槽形 / 键槽(2平行线 + 2半圆) - - polygon: N边多边形(顶点列表) - - ibone: 工字形凸耳(12线+4弧+4孔) - - circle_with_arc_notches: 圆+均匀圆弧凹口 - - circular_sector_slot: 圆弧扇区+中心矩形槽 - - circle_with_radial_tabs: 圆+径向矩形凸耳(带圆角) - - filleted_rect_side_slots: 圆角矩形+两侧中心U形槽 - - d_shape: D形(半圆+弦线) - - partial_ring: 部分圆环(同心弧+径向线) - - partial_ring_with_arc_island: 扇区环 + 弦上偏移弧岛(保留材料岛) - - concentric_arc_profile: 同心圆弧轮廓(多段弧+圆心标记) - - patterned_cutouts: 母形 + 规则布局的多区域切口 - - compound_patterned_cutouts: 多组母形/布局合并为一个切除草图 +The runtime accepts only direct geometric descriptions: circles, straight-edge +polygons, and closed analytic line/arc/circle contours. Semantic shapes and +historical profile macros belong to the importer compatibility layer and must +be lowered before this module is invoked. """ from __future__ import annotations @@ -41,81 +13,34 @@ from copy import deepcopy from typing import Any, Iterable -# ═══════════════════════════════════════════════════════════════ -# 基础几何原语 -# ═══════════════════════════════════════════════════════════════ - -def _circle(center: list[float], radius_mm: float, construction: bool = False) -> dict[str, Any]: - return { - "type": "circle", - "center": [float(center[0]), float(center[1])], - "radius_mm": float(radius_mm), - "construction": construction, - } +_Ctx = dict[str, Any] +_TOLERANCE_MM = 1e-5 -def _line(start: list[float], end: list[float], construction: bool = False) -> dict[str, Any]: - return { - "type": "line", - "start": [float(start[0]), float(start[1])], - "end": [float(end[0]), float(end[1])], - "construction": construction, - } +def _circle(center: list[float], radius_mm: float, construction: bool = False) -> _Ctx: + return {"type": "circle", "center": [float(center[0]), float(center[1])], "radius_mm": float(radius_mm), "construction": construction} -def _contour_line(start_mm: list[float], end_mm: list[float]) -> dict[str, Any]: - return { - "type": "line", - "start_mm": [ - float(start_mm[0]), - float(start_mm[1]), - float(start_mm[2]) if len(start_mm) > 2 else 0.0, - ], - "end_mm": [ - float(end_mm[0]), - float(end_mm[1]), - float(end_mm[2]) if len(end_mm) > 2 else 0.0, - ], - } +def _line(start: list[float], end: list[float], construction: bool = False) -> _Ctx: + return {"type": "line", "start": [float(start[0]), float(start[1])], "end": [float(end[0]), float(end[1])], "construction": construction} -def _contour_arc( - start_mm: list[float], - end_mm: list[float], - center_mm: list[float], - radius_mm: float | None, - clockwise: bool | None = None, -) -> dict[str, Any]: - result = { - "type": "arc", - "start_mm": [ - float(start_mm[0]), - float(start_mm[1]), - float(start_mm[2]) if len(start_mm) > 2 else 0.0, - ], - "end_mm": [ - float(end_mm[0]), - float(end_mm[1]), - float(end_mm[2]) if len(end_mm) > 2 else 0.0, - ], - "center_mm": [ - float(center_mm[0]), - float(center_mm[1]), - float(center_mm[2]) if len(center_mm) > 2 else 0.0, - ], - "radius_mm": float(radius_mm) if radius_mm is not None else None, - } +def _point(point: list[float]) -> list[float]: + return [float(point[0]), float(point[1]), float(point[2]) if len(point) > 2 else 0.0] + + +def _contour_line(start: list[float], end: list[float]) -> _Ctx: + return {"type": "line", "start_mm": _point(start), "end_mm": _point(end)} + + +def _contour_arc(start: list[float], end: list[float], center: list[float], radius: float | None, clockwise: bool | None = None) -> _Ctx: + edge: _Ctx = {"type": "arc", "start_mm": _point(start), "end_mm": _point(end), "center_mm": _point(center), "radius_mm": float(radius) if radius is not None else None} if clockwise is not None: - result["clockwise"] = bool(clockwise) - return result + edge["clockwise"] = bool(clockwise) + return edge -# ═══════════════════════════════════════════════════════════════ -# 3D 坐标转换 -# ═══════════════════════════════════════════════════════════════ - -def _to_3d(workplane: dict[str, Any], u: float, v: float) -> list[float]: - """将2D局部坐标 (u,v) 映射到3D世界坐标。""" +def _to_3d(workplane: _Ctx, u: float, v: float) -> list[float]: origin = workplane.get("origin_mm") or [0, 0, 0] x_dir = workplane.get("x_dir") or [1, 0, 0] normal = workplane.get("normal") or [0, 0, 1] @@ -124,1491 +49,81 @@ def _to_3d(workplane: dict[str, Any], u: float, v: float) -> list[float]: normal[2] * x_dir[0] - normal[0] * x_dir[2], normal[0] * x_dir[1] - normal[1] * x_dir[0], ] - return [ - origin[0] + u * x_dir[0] + v * y_dir[0], - origin[1] + u * x_dir[1] + v * y_dir[1], - origin[2] + u * x_dir[2] + v * y_dir[2], - ] + return [origin[0] + u * x_dir[0] + v * y_dir[0], origin[1] + u * x_dir[1] + v * y_dir[1], origin[2] + u * x_dir[2] + v * y_dir[2]] -def _transform_contours(contour: list[dict[str, Any]], wp: dict[str, Any]) -> list[dict[str, Any]]: - """将轮廓边的2D坐标映射为3D世界坐标。""" - result: list[dict[str, Any]] = [] - x_dir = wp.get("x_dir") or [1, 0, 0] - normal = wp.get("normal") or [0, 0, 1] - for e in contour: - e2 = deepcopy(e) - if e["type"] == "line": - e2["start_mm"] = _to_3d(wp, e["start_mm"][0], e["start_mm"][1]) - e2["end_mm"] = _to_3d(wp, e["end_mm"][0], e["end_mm"][1]) - elif e["type"] == "arc": - e2["start_mm"] = _to_3d(wp, e["start_mm"][0], e["start_mm"][1]) - e2["end_mm"] = _to_3d(wp, e["end_mm"][0], e["end_mm"][1]) - e2["center_mm"] = _to_3d(wp, e["center_mm"][0], e["center_mm"][1]) - e2["normal"] = list(normal) - result.append(e2) - return result +def _transform_contours(contours: list[_Ctx], workplane: _Ctx) -> list[_Ctx]: + transformed: list[_Ctx] = [] + normal = workplane.get("normal") or [0, 0, 1] + for edge in contours: + output = deepcopy(edge) + output["start_mm"] = _to_3d(workplane, edge["start_mm"][0], edge["start_mm"][1]) + output["end_mm"] = _to_3d(workplane, edge["end_mm"][0], edge["end_mm"][1]) + if edge["type"] == "arc": + output["center_mm"] = _to_3d(workplane, edge["center_mm"][0], edge["center_mm"][1]) + output["normal"] = list(normal) + transformed.append(output) + return transformed -# ═══════════════════════════════════════════════════════════════ -# 矩形 / 圆辅助 -# ═══════════════════════════════════════════════════════════════ - -def _build_rect_bounds(profile: dict[str, Any]) -> tuple[float, float, float, float]: - """从 profile 中提取矩形的 (x0, y0, x1, y1) 边界。""" - center = profile.get("center") - w = float(profile.get("width_mm") or 0) - h = float(profile.get("height_mm") or 0) - if center and w > 0 and h > 0: - cx, cy = float(center[0]), float(center[1]) - return cx - w / 2, cy - h / 2, cx + w / 2, cy + h / 2 - mn = profile.get("min_mm") - mx = profile.get("max_mm") - if mn and mx: - return float(mn[0]), float(mn[1]), float(mx[0]), float(mx[1]) - raise ValueError("rectangle profile needs (center+width+height) or (min+max)") - - -def _rect_lines_and_contour( - x0: float, y0: float, x1: float, y1: float, -) -> tuple[list[dict], list[dict]]: - p00, p10, p11, p01 = [x0, y0, 0.0], [x1, y0, 0.0], [x1, y1, 0.0], [x0, y1, 0.0] - entities = [ - _line([x0, y0], [x1, y0]), - _line([x1, y0], [x1, y1]), - _line([x1, y1], [x0, y1]), - _line([x0, y1], [x0, y0]), - ] - contour = [ - _contour_line(p00, p10), - _contour_line(p10, p11), - _contour_line(p11, p01), - _contour_line(p01, p00), - ] - return entities, contour - - -def _build_circle_entities(items: list[dict]) -> list[dict]: - entities: list[dict] = [] - for item in items: - center = item.get("center") or [0.0, 0.0] - r = float(item.get("radius_mm") or 0) - if r <= 0: - raise ValueError("circle radius must be > 0") - entities.append(_circle(center, r, construction=False)) - return entities - - -def _filleted_rect_contour( - x0: float, y0: float, x1: float, y1: float, r: float, -) -> tuple[list[dict], list[dict]]: - """生成带圆角矩形的实体线和轮廓边(4直线 + 4圆弧)。""" - if r <= 0: - return _rect_lines_and_contour(x0, y0, x1, y1) - - cx0, cx1 = x0 + r, x1 - r - cy0, cy1 = y0 + r, y1 - r - - entities = [ - _line([cx0, y0], [cx1, y0]), - _line([x0, cy0], [x0, cy1]), - _line([cx0, y1], [cx1, y1]), - _line([x1, cy0], [x1, cy1]), - ] - - contour = [ - _contour_line([x0, cy0, 0.0], [x0, cy1, 0.0]), - _contour_arc([x0, cy1, 0.0], [cx0, y1, 0.0], [cx0, cy1, 0.0], r), - _contour_line([cx0, y1, 0.0], [cx1, y1, 0.0]), - _contour_arc([cx1, y1, 0.0], [x1, cy1, 0.0], [cx1, cy1, 0.0], r), - _contour_line([x1, cy1, 0.0], [x1, cy0, 0.0]), - _contour_arc([x1, cy0, 0.0], [cx1, y0, 0.0], [cx1, cy0, 0.0], r), - _contour_line([cx1, y0, 0.0], [cx0, y0, 0.0]), - _contour_arc([cx0, y0, 0.0], [x0, cy0, 0.0], [cx0, cy0, 0.0], r), - ] - - return entities, contour - - -# ═══════════════════════════════════════════════════════════════ -# 形状生成器(每个是一个独立函数,按 type 注册) -# ═══════════════════════════════════════════════════════════════ - -_Ctx = dict[str, Any] - - -def _gen_circle(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: +def _gen_circle(profile: _Ctx, _: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: center = profile.get("center") or [0.0, 0.0] - r = float(profile.get("radius_mm") or 0) - if r <= 0: + radius = float(profile.get("radius_mm") or 0.0) + if radius <= 0: raise ValueError("circle radius must be > 0") - entities = [_circle(center, r)] - cx, cy, c3d = float(center[0]), float(center[1]), [float(center[0]), float(center[1]), 0.0] - contour = [ - _contour_arc([cx + r, cy, 0.0], [cx, cy + r, 0.0], c3d, r), - _contour_arc([cx, cy + r, 0.0], [cx - r, cy, 0.0], c3d, r), - _contour_arc([cx - r, cy, 0.0], [cx, cy - r, 0.0], c3d, r), - _contour_arc([cx, cy - r, 0.0], [cx + r, cy, 0.0], c3d, r), - ] - return entities, contour - - -def _gen_annulus(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: - center = profile.get("center") or [0.0, 0.0] - inner_r = float(profile.get("inner_radius_mm") or 0) - outer_r = float(profile.get("outer_radius_mm") or 0) - if inner_r <= 0 or outer_r <= 0: - raise ValueError("annulus radii must be > 0") - if inner_r >= outer_r: - raise ValueError("inner_radius >= outer_radius") - return [_circle(center, inner_r), _circle(center, outer_r)], [] - - -def _gen_circles(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: - items = profile.get("items") or [] - if not items: - raise ValueError("circles items must be non-empty") - return _build_circle_entities(items), [] - - -def _gen_circle_grid(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: - """矩形圆孔阵列:由起点圆心 + 间距 + 行列数生成。 - - 参数: - radius_mm: 孔半径 - count_x / count_y: 列数、行数 - spacing_x_mm / spacing_y_mm: 圆心间距 - origin_mm: 第一孔圆心 [u,v](默认沿 +u/+v 铺开) - 或 center_mm: 阵列几何中心(与 origin_mm 二选一) - - 覆盖: b006(4×5 通孔阵列) - """ - r = float(profile["radius_mm"]) - nx = int(profile["count_x"]) - ny = int(profile["count_y"]) - sx = float(profile["spacing_x_mm"]) - sy = float(profile["spacing_y_mm"]) - if r <= 0 or nx < 1 or ny < 1: - raise ValueError("circle_grid: invalid radius/counts") - - if profile.get("center_mm") is not None: - cc = profile["center_mm"] - u0 = float(cc[0]) - (nx - 1) * sx / 2.0 - v0 = float(cc[1]) - (ny - 1) * sy / 2.0 - else: - origin = profile.get("origin_mm") or [0.0, 0.0] - u0, v0 = float(origin[0]), float(origin[1]) - - items = [ - {"center": [u0 + i * sx, v0 + j * sy], "radius_mm": r} - for j in range(ny) - for i in range(nx) - ] - return _build_circle_entities(items), [] - - -def _gen_rectangle(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: - x0, y0, x1, y1 = _build_rect_bounds(profile) - return _rect_lines_and_contour(x0, y0, x1, y1) - - -def _gen_rect_with_circles(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: - boundary = profile.get("boundary") or {} - circle_items = profile.get("circles") or [] - btype = boundary.get("type") or "rectangle" - if btype in ("rectangle", "rectangle_with_fillets"): - if btype == "rectangle": - x0, y0, x1, y1 = _build_rect_bounds(boundary) - ent, con = _rect_lines_and_contour(x0, y0, x1, y1) - else: - fr = float(boundary.get("fillet_radius_mm") or 0) - x0, y0, x1, y1 = _build_rect_bounds(boundary) - ent, con = _filleted_rect_contour(x0, y0, x1, y1, fr) - ent.extend(_build_circle_entities(circle_items)) - return ent, con - raise ValueError(f"rectangle_with_circles: unsupported boundary type {btype!r}") - - -def _gen_rect_with_fillets(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: - fr = float(profile.get("fillet_radius_mm") or 0) - x0, y0, x1, y1 = _build_rect_bounds(profile) - ent, con = _filleted_rect_contour(x0, y0, x1, y1, fr) - ent.extend(_build_circle_entities(profile.get("circles") or [])) - return ent, con - - -def _gen_obround(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: - center = profile.get("center") - length = float(profile.get("length_mm") or 0) - width = float(profile.get("width_mm") or 0) - if length <= 0 or width <= 0: - raise ValueError("obround needs positive length/width") - r = width / 2 - cx, cy = (float(center[0]), float(center[1])) if center else (0.0, 0.0) - offset = max(0, (length - width) / 2) - left_cx, right_cx = cx - offset, cx + offset - - if offset < 0.001: - c3d = [cx, cy, 0.0] - contour = [ - _contour_arc([cx + r, cy, 0.0], [cx, cy + r, 0.0], c3d, r), - _contour_arc([cx, cy + r, 0.0], [cx - r, cy, 0.0], c3d, r), - _contour_arc([cx - r, cy, 0.0], [cx, cy - r, 0.0], c3d, r), - _contour_arc([cx, cy - r, 0.0], [cx + r, cy, 0.0], c3d, r), - ] - return [_circle([cx, cy], r)], contour - - top_y, bot_y = cy + r, cy - r - left_c3d, right_c3d = [left_cx, cy, 0.0], [right_cx, cy, 0.0] - contour = [ - _contour_arc([right_cx, top_y, 0.0], [right_cx + r, cy, 0.0], right_c3d, r), - _contour_arc([right_cx + r, cy, 0.0], [right_cx, bot_y, 0.0], right_c3d, r), - _contour_line([right_cx, bot_y, 0.0], [left_cx, bot_y, 0.0]), - _contour_arc([left_cx, bot_y, 0.0], [left_cx - r, cy, 0.0], left_c3d, r), - _contour_arc([left_cx - r, cy, 0.0], [left_cx, top_y, 0.0], left_c3d, r), - _contour_line([left_cx, top_y, 0.0], [right_cx, top_y, 0.0]), - ] - entities = [ - _line([left_cx, bot_y], [right_cx, bot_y]), - _line([left_cx, top_y], [right_cx, top_y]), - _circle([left_cx, cy], r), - _circle([right_cx, cy], r), - ] - return entities, contour + cx, cy = float(center[0]), float(center[1]) + points = [[cx + radius, cy, 0.0], [cx, cy + radius, 0.0], [cx - radius, cy, 0.0], [cx, cy - radius, 0.0], [cx + radius, cy, 0.0]] + return [_circle([cx, cy], radius)], [_contour_arc(points[index], points[index + 1], [cx, cy, 0.0], radius) for index in range(4)] def _gen_polygon(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: vertices = profile.get("vertices") or [] - if len(vertices) >= 3: - pts_2d = [(float(v[0]), float(v[1])) for v in vertices] - entities, contour = [], [] - for i in range(len(pts_2d)): - s, e = pts_2d[i], pts_2d[(i + 1) % len(pts_2d)] - entities.append(_line(list(s), list(e))) - contour.append(_contour_line([s[0], s[1], 0.0], [e[0], e[1], 0.0])) - return entities, contour - # 015133: no vertices in profile -> use entities from compiler_context - ents = meta.get("_entities") or [] - if not ents: - raise ValueError("polygon needs at least 3 vertices or existing entities in sketch") - contour = [] - for e in ents: - t = e.get("type", "") - if t == "line": - s = e.get("start", [0, 0]) - ed = e.get("end", [0, 0]) - contour.append(_contour_line([float(s[0]), float(s[1]), 0.0], [float(ed[0]), float(ed[1]), 0.0])) - elif t == "arc": - contour.append(_contour_line( - [float(e["start"][0]), float(e["start"][1]), 0.0], - [float(e["end"][0]), float(e["end"][1]), 0.0])) - return list(ents), contour - - -def _gen_ibone(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: - """工字形凸耳:12线 + 4弧 + 4孔""" - bw, bh = float(profile["body_width_mm"]), float(profile["body_height_mm"]) - fw, fh = float(profile["flange_width_mm"]), float(profile["flange_height_mm"]) - cr = float(profile["corner_radius_mm"]) - hr = float(profile.get("hole_radius_mm") or 0) - hw, hfw, ar = bw / 2, fw / 2, bw / 2 - cr - av_bot, av_top = fh - cr, bh - fh + cr - - segs = [ - ("L", hfw, 0, -hfw, 0), - ("L", -hfw, 0, -hfw, fh - cr), - ("A", -hfw, fh - cr, -ar, fh, -ar, fh - cr, cr), - ("L", -ar, fh, -hw, fh), - ("L", -hw, fh, -hw, bh - fh), - ("L", -hw, bh - fh, -ar, bh - fh), - ("A", -ar, bh - fh, -hfw, bh - fh + cr, -ar, bh - fh + cr, cr), - ("L", -hfw, bh - fh + cr, -hfw, bh), - ("L", -hfw, bh, hfw, bh), - ("L", hfw, bh, hfw, bh - fh + cr), - ("A", hfw, bh - fh + cr, ar, bh - fh, ar, bh - fh + cr, cr), - ("L", ar, bh - fh, hw, bh - fh), - ("L", hw, bh - fh, hw, fh), - ("L", hw, fh, ar, fh), - ("A", ar, fh, hfw, fh - cr, ar, fh - cr, cr), - ("L", hfw, fh - cr, hfw, 0), - ] - entities, contour = [], [] - for s in segs: - if s[0] == "L": - _, u1, v1, u2, v2 = s - entities.append(_line([u1, v1], [u2, v2])) - contour.append(_contour_line([u1, v1, 0.0], [u2, v2, 0.0])) - else: - _, u1, v1, u2, v2, cu, cv, r = s - contour.append(_contour_arc([u1, v1, 0.0], [u2, v2, 0.0], [cu, cv, 0.0], r)) - - if hr > 0: - for cu, cv in [(-ar, av_bot), (ar, av_bot), (-ar, av_top), (ar, av_top)]: - entities.append(_circle([cu, cv], hr)) - return entities, contour - - -def _gen_rect_symmetric_notches(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: - """对称槽板:矩形+4个U形缺口(简化多边形 或 精确弧边)""" - w, h = float(profile["width_mm"]), float(profile["height_mm"]) - n = profile.get("notch") or {} - n_ys, n_ye = float(n["y_start"]), float(n["y_end"]) - n_depth = float(n["depth_mm"]) - n_ir = float(n.get("inner_radius_mm") or 0) - n_cr = float(n.get("corner_radius_mm") or 0) - hw = w / 2 - inner_u = hw - n_depth - - if n_ir > 0 and n_cr > 0: - icu, icv = hw - n_depth / 2, (n_ys + n_ye) / 2 - entities, contour = [], [] - for u1, v1, u2, v2 in [ - (hw, 0, hw, n_ys), (hw, n_ye, hw, h - n_ye), - (hw, h - n_ys, hw, h), (-hw, h, -hw, h - n_ys), - (-hw, h - n_ye, -hw, n_ye), (-hw, n_ys, -hw, 0), - (-hw, 0, hw, 0), (hw, h, -hw, h), - ]: - entities.append(_line([u1, v1], [u2, v2])) - contour.append(_contour_line([u1, v1, 0.0], [u2, v2, 0.0])) - - def _notch(sign_u, y_bot, y_top): - u = sign_u * hw - ec = sign_u * (hw - n_cr) - icu2 = sign_u * icu - contour.append(_contour_arc( - [u, y_bot, 0.0], [ec, y_bot + n_cr, 0.0], [ec, y_bot, 0.0], n_cr)) - av = y_bot + n_cr - au = icu2 - sign_u * math.sqrt(max(0.0, n_ir ** 2 - (av - icv) ** 2)) - contour.append(_contour_line([ec, av, 0.0], [au, av, 0.0])) - bv = y_top - n_cr - bu = icu2 - sign_u * math.sqrt(max(0.0, n_ir ** 2 - (bv - icv) ** 2)) - contour.append(_contour_arc( - [au, av, 0.0], [bu, bv, 0.0], [icu2, icv, 0.0], n_ir)) - contour.append(_contour_line([bu, bv, 0.0], [ec, bv, 0.0])) - contour.append(_contour_arc( - [ec, bv, 0.0], [u, y_top, 0.0], [ec, y_top, 0.0], n_cr)) - - _notch(+1, n_ys, n_ye) - _notch(+1, h - n_ye, h - n_ys) - _notch(-1, n_ys, n_ye) - _notch(-1, h - n_ye, h - n_ys) - return entities, contour - - # 简化多边形(5 参数) - verts = [ - (hw, 0), (hw, n_ys), (inner_u, n_ys), (inner_u, n_ye), - (hw, n_ye), (hw, h - n_ye), (inner_u, h - n_ye), - (inner_u, h - n_ys), (hw, h - n_ys), (hw, h), - (-hw, h), (-hw, h - n_ys), (-inner_u, h - n_ys), - (-inner_u, h - n_ye), (-hw, h - n_ye), (-hw, n_ye), - (-inner_u, n_ye), (-inner_u, n_ys), (-hw, n_ys), (-hw, 0), - ] - entities, contour = [], [] - pts = [(float(v[0]), float(v[1])) for v in verts] - for i in range(len(pts)): - s, e = pts[i], pts[(i + 1) % len(pts)] - entities.append(_line(list(s), list(e))) - contour.append(_contour_line([s[0], s[1], 0.0], [e[0], e[1], 0.0])) - return entities, contour - - -def _gen_revolve_chamfer(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: - """旋转切除的梯形截面(5顶点,相对轴顶点定义)。""" - ah = float(profile["axis_height_mm"]) - tw = float(profile["top_width_mm"]) - bw = float(profile["bottom_width_mm"]) - wi = float(profile.get("wall_inset_mm") or 0) - si = float(profile.get("step_inset_mm") or 0) - side = profile.get("on_axis_side", "left") - sign = -1 if side == "left" else 1 - v0 = (sign * tw, -wi); v1 = (0.0, 0.0); v2 = (0.0, -ah) - v3 = (sign * bw, -ah); v4 = (sign * tw, -si) - entities, contour = [], [] - for s, e in [(v0, v1), (v1, v2), (v2, v3), (v3, v4), (v4, v0)]: - entities.append(_line(list(s), list(e))) - contour.append(_contour_line([s[0], s[1], 0.0], [e[0], e[1], 0.0])) - return entities, contour - - -def _gen_revolve_chamfer_slanted(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: - """旋转切除的斜底梯形截面(5顶点)。 - - 与 revolve_chamfer 的区别:底部为斜边(轴底→壁底不是水平线)。 - 参数: - axis_height_mm: 轴侧总高度(V1→V2) - top_width_mm: 顶部宽度(轴→外壁) - wall_inset_mm: 顶部台阶深度(V0→V4 的 V 偏移) - wall_height_mm: 壁段高度(V4→V3) - wall_width_mm: 壁距轴的距离 - on_axis_side: "left"(U负) 或 "right"(U正) - """ - ah = float(profile["axis_height_mm"]) - tw = float(profile["top_width_mm"]) - wi = float(profile.get("wall_inset_mm") or 0) - wh = float(profile["wall_height_mm"]) - ww = float(profile["wall_width_mm"]) - side = profile.get("on_axis_side", "left") - sign = -1 if side == "left" else 1 - - v0 = (sign * tw, 0.0) # 顶部外侧 - v1 = (0.0, 0.0) # 轴顶点 - v2 = (0.0, -ah) # 轴底部 - v3 = (sign * ww, -wi - wh) # 壁底部(斜边连接到 V2) - v4 = (sign * ww, -wi) # 壁顶部(台阶) - - entities, contour = [], [] - for s, e in [(v0, v1), (v1, v2), (v2, v3), (v3, v4), (v4, v0)]: - entities.append(_line(list(s), list(e))) - contour.append(_contour_line([s[0], s[1], 0.0], [e[0], e[1], 0.0])) - return entities, contour - - -# ═══════════════════════════════════════════════════════════════ -# 弧边复合轮廓生成器(按"015133 手册"方法注册) -# ═══════════════════════════════════════════════════════════════ - - -def _gen_circle_with_arc_notches(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: - """圆+圆弧凹口:大圆上均匀分布的弧形缺口。 - - 参数: - outer_radius_mm: 大圆半径 - notch_radius_mm: 每个凹口的圆弧半径 - notch_angles_deg: 凹口所在的角度列表(度,从+u顺时针) - 默认 [0, 90, 180, 270](十字槽) - 例: [0,90,180,270] → 十字形,[45,135,225,315] → 斜十字 - - 覆盖文件: 48, 49, 50, 82 - """ - import math - R = float(profile["outer_radius_mm"]) - r = float(profile["notch_radius_mm"]) - angles_deg = profile.get("notch_angles_deg", [0, 90, 180, 270]) - - # 每个凹口在大圆上占据的半角宽度 - delta = math.acos(max(-1.0, min(1.0, 1.0 - r * r / (2.0 * R * R)))) - angles_rad = [math.radians(a) for a in sorted(angles_deg)] - - entities, contour = [], [] - n = len(angles_rad) - - for i in range(n): - prev_end = angles_rad[i - 1] + delta # 上一个凹口离开点 - curr_enter = angles_rad[i] - delta # 当前凹口入口 - - # 大弧:从上一个凹口离开点到当前凹口入口(顺时针) - ps_u, ps_v = R * math.cos(prev_end), R * math.sin(prev_end) - pe_u, pe_v = R * math.cos(curr_enter), R * math.sin(curr_enter) - - contour.append(_contour_arc( - [ps_u, ps_v, 0.0], [pe_u, pe_v, 0.0], - [0.0, 0.0, 0.0], R, - )) - - # 凹口弧:从入口→出口,中心在外圆上 - curr_exit = angles_rad[i] + delta - nc_u = R * math.cos(angles_rad[i]) - nc_v = R * math.sin(angles_rad[i]) - - pn_enter_u = R * math.cos(curr_enter) - pn_enter_v = R * math.sin(curr_enter) - pn_exit_u = R * math.cos(curr_exit) - pn_exit_v = R * math.sin(curr_exit) - - # 凹口弧:从出口回到入口(与大弧方向相反) - contour.append(_contour_arc( - [pn_exit_u, pn_exit_v, 0.0], [pn_enter_u, pn_enter_v, 0.0], - [nc_u, nc_v, 0.0], r, - )) - - entities.extend(_build_circle_entities(profile.get("circles") or [])) - return entities, contour - - -def _gen_circular_sector_slot(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: - """圆弧扇区槽:一段大圆弧 + 两条径向线 + 一个矩形槽口。 - - 形状:一个扇形(大圆弧 + 两侧径向线),中心开矩形槽。 - 由两条大弧(上/下)、两条径向线、一个中央矩形槽组成。 - - 参数: - arc_radius_mm: 大弧半径(圆心在原点) - slot_half_width_mm: 槽口半宽(从圆心起的径向距离) - chord_half_mm: 弧弦线半长(弧的跨距,决定弧幅度) - - 覆盖文件: 87, 88, 89, 90 - """ - import math - R = float(profile["arc_radius_mm"]) - hw = float(profile["slot_half_width_mm"]) - ch = float(profile["chord_half_mm"]) - - # 弧端点:在圆上,到中心轴的垂直距离为 ch - # 弧端点在圆 R 上,距离中心轴 ch,其角度为 asin(ch/R) - half_angle = math.asin(max(-1.0, min(1.0, ch / R))) - - # 弧端点坐标(圆上,2 个象限) - arc_x = R * math.cos(half_angle) - arc_y = R * math.sin(half_angle) if ch >= 0 else -R * math.sin(-half_angle) - - # 4 个关键点(顺时针) - # 下弧: x 从 -arc_x 到 +arc_x, y = -ch (在圆上 y = ±arc_y ≈ ±ch) - # 右上角弧段 - arc_x_neg_angle = R * math.cos(-half_angle) - arc_y_neg = R * math.sin(-half_angle) - - p_bot_right = (arc_x_neg_angle, arc_y_neg) # 右下(圆上,负半角) - p_bot_left = (arc_x, arc_y) # 右下(圆上,正半角)... 等等 - - # 直接按 87 的几何定义:下弧从 (+xs, -ch) 到 (-xs, -ch),上弧从 (-xs, +ch) 到 (+xs, +ch) - # xs 由圆 R 和 ch 确定 - xs = math.sqrt(max(0, R * R - ch * ch)) - - contour = [ - # 下弧:从 (xs, -ch) 到 (-xs, -ch),圆心原点,半径 R(顺时针) - _contour_arc([xs, -ch, 0.0], [-xs, -ch, 0.0], [0.0, 0.0, 0.0], R), - # 左侧线:(-xs, -ch) → (-xs, +ch) ... - # 不对,夹着槽口 - - ] - - # 重新按 87 的实际边序列构建 - # [0] line (-27.5, -13)→(-27.5, +13) → 槽口左竖线 - # [1] line (-27.5, +13)→(-37.83, +13) → 径向连接 - # [2] arc r=40 c=(0,0) s=(+37.83, +13)→(-37.83, +13) → 上弧 - # [3] line (+27.5, +13)→(+37.83, +13) → 径向连接(右侧) - # [4] line (+27.5, -13)→(+27.5, +13) → 槽口右竖线 - # [5] line (+27.5, -13)→(+37.83, -13) → 径向连接 - # [6] arc r=40 c=(0,0) s=(-37.83, -13)→(+37.83, -13) → 下弧 - # [7] line (-27.5, -13)→(-37.83, -13) → 径向连接 - - # 参数化: - # slot_half = 27.5 (槽口半宽) - # chord_half = 13 (弧端点的 w 坐标,确定弧的跨度) - # arc_radius = 40 - # arc_x_end = sqrt(R² - ch²) = sqrt(1600 - 169) ≈ 37.83 - - sh = hw # slot half - axe = math.sqrt(max(0.0, R * R - ch * ch)) # arc x-endpoint - - contour = [ - # 槽口竖线(从左下到左上) - _contour_line([-sh, -ch, 0.0], [-sh, ch, 0.0]), - # 连接到弧(从槽口左上到弧左下) - _contour_line([-sh, ch, 0.0], [-axe, ch, 0.0]), - # 上弧(从弧左下到弧右下,经过原点顶) - _contour_arc([axe, ch, 0.0], [-axe, ch, 0.0], [0.0, 0.0, 0.0], R), - # 连接到槽口(从弧右下到槽口右上) - _contour_line([sh, ch, 0.0], [axe, ch, 0.0]), - # 槽口竖线(从右上到右下) - _contour_line([sh, ch, 0.0], [sh, -ch, 0.0]), - # 连接到弧(从槽口右下到弧右上) - _contour_line([sh, -ch, 0.0], [axe, -ch, 0.0]), - # 下弧(从弧右上到弧左上,经过原点底) - _contour_arc([-axe, -ch, 0.0], [axe, -ch, 0.0], [0.0, 0.0, 0.0], R), - # 连接到槽口(从弧左上到槽口左下) - _contour_line([-sh, -ch, 0.0], [-axe, -ch, 0.0]), - ] - - entities = [ - _line([-sh, -ch], [-sh, ch]), - _line([-sh, ch], [-axe, ch]), - _line([sh, ch], [axe, ch]), - _line([sh, ch], [sh, -ch]), - _line([sh, -ch], [axe, -ch]), - _line([-sh, -ch], [-axe, -ch]), - ] - - entities.extend(_build_circle_entities(profile.get("circles") or [])) - return entities, contour - - -def _gen_circle_with_radial_tabs(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: - """圆+径向凸耳:大圆弧上有矩形凸起。 - - 形状:一个大圆被两侧的矩形凸耳取代部分弧段。 - 简化表示为直边多边形(忽略 r=1 的圆角,体积误差 <1%)。 - - 参数: - outer_radius_mm: 大圆半径 - tab_u_half_mm: 凸耳半宽(弧线方向,从根部到内缘) - tab_v_offset_mm: 凸耳离弧线的垂直距离(即凸耳顶部距弧线的v偏移) - - 覆盖文件: 91, 92, 93, 94, 95 - """ - import math - R = float(profile["outer_radius_mm"]) - tu = float(profile.get("tab_u_half_mm") or 0) - tv = float(profile.get("tab_v_offset_mm") or 0) - - # 凸耳根部在圆上的角度 - angle = math.asin(min(1.0, max(0.0, tv / R))) - - # 弧上根部点(右侧) - root_u = R * math.cos(angle) - root_v = R * math.sin(angle) - - # 凸耳内缘 - inner_u = root_u - tu - inner_v = root_v * 0.9 # 略浅于弧线 - - # 构建轮廓:大弧(上) → 右凸耳 → 大弧(下) → 左凸耳 → 闭合 - - contour = [] - - # 上弧:从左侧根部到右侧根部(经过顶点) - contour.append(_contour_arc( - [root_u, root_v, 0.0], [-root_u, root_v, 0.0], - [0.0, 0.0, 0.0], R)) - - # 右侧凸耳(多边形:根部→内顶→内底→根部) - contour.append(_contour_line([root_u, root_v, 0.0], [inner_u, inner_v, 0.0])) - contour.append(_contour_line([inner_u, inner_v, 0.0], [inner_u, -inner_v, 0.0])) - contour.append(_contour_line([inner_u, -inner_v, 0.0], [root_u, -root_v, 0.0])) - - # 下弧:从右侧底部到左侧底部(经过底点) - contour.append(_contour_arc( - [-root_u, -root_v, 0.0], [root_u, -root_v, 0.0], - [0.0, 0.0, 0.0], R)) - - # 左侧凸耳(镜像) - contour.append(_contour_line([-root_u, -root_v, 0.0], [-inner_u, -inner_v, 0.0])) - contour.append(_contour_line([-inner_u, -inner_v, 0.0], [-inner_u, inner_v, 0.0])) - contour.append(_contour_line([-inner_u, inner_v, 0.0], [-root_u, root_v, 0.0])) - - entities = [ - _line([root_u, root_v], [inner_u, inner_v]), - _line([inner_u, inner_v], [inner_u, -inner_v]), - _line([inner_u, -inner_v], [root_u, -root_v]), - _line([-root_u, -root_v], [-inner_u, -inner_v]), - _line([-inner_u, -inner_v], [-inner_u, inner_v]), - _line([-inner_u, inner_v], [-root_u, root_v]), - ] - - entities.extend(_build_circle_entities(profile.get("circles") or [])) - return entities, contour - - -def _gen_filleted_rect_side_slots(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: - """圆角矩形+两侧中心凹槽。 - - 形状:圆角矩形,左右两侧中心各有一个 U 形凹槽(半圆槽)。 - - 参数: - half_width_mm: 矩形半宽(不含圆角) - half_height_mm: 矩形半高(不含圆角) - corner_radius_mm: 四角圆角半径 - slot_radius_mm: 两侧中心凹槽半径(默认 5.0) - circles: 可选内部圆(孔洞),[{center:[x,y], radius_mm:r}, …] - - 覆盖文件: 58, 63, 64, 65, 66 - """ - hw = float(profile["half_width_mm"]) - hh = float(profile["half_height_mm"]) - cr = float(profile["corner_radius_mm"]) - sr = float(profile.get("slot_radius_mm") or cr * 0.5) - - # 注意: v=-Z, 所以 v 正方向朝 Z 负 - # 矩形范围: u=[-hw,hw], v=[-hh,+hh] 对应 z=[+hh,-hh] - # 上边 (z=+hh): v=-hh, 下边 (z=-hh): v=+hh - - entities, contour = [], [] - top_v, bot_v = -hh, hh # 上边 v=-hh, 下边 v=+hh - - # 上边(直线,从左上角到右上角) - contour.append(_contour_line( - [-(hw - cr), top_v, 0.0], [(hw - cr), top_v, 0.0])) - entities.append(_line([-(hw - cr), top_v], [(hw - cr), top_v])) - - # 右上圆角(逆时针绕 center: 从顶点到右侧) - contour.append(_contour_arc( - [(hw - cr), top_v, 0.0], [hw, top_v + cr, 0.0], - [(hw - cr), top_v + cr, 0.0], cr)) - - # 右边上半(从圆角到凹槽上方) - contour.append(_contour_line( - [hw, top_v + cr, 0.0], [hw, -sr, 0.0])) - entities.append(_line([hw, top_v + cr], [hw, -sr])) - - # 右侧中心凹槽(半圆向内的 U 形凹口) - contour.append(_contour_arc( - [hw, sr, 0.0], [hw, -sr, 0.0], - [hw, 0.0, 0.0], sr)) - - # 右边下半(从凹槽下方到右下角) - contour.append(_contour_line( - [hw, sr, 0.0], [hw, bot_v - cr, 0.0])) - entities.append(_line([hw, sr], [hw, bot_v - cr])) - - # 右下圆角 - contour.append(_contour_arc( - [hw, bot_v - cr, 0.0], [(hw - cr), bot_v, 0.0], - [(hw - cr), bot_v - cr, 0.0], cr)) - - # 下边 - contour.append(_contour_line( - [(hw - cr), bot_v, 0.0], [-(hw - cr), bot_v, 0.0])) - entities.append(_line([(hw - cr), bot_v], [-(hw - cr), bot_v])) - - # 左下圆角 - contour.append(_contour_arc( - [-(hw - cr), bot_v, 0.0], [-hw, bot_v - cr, 0.0], - [-(hw - cr), bot_v - cr, 0.0], cr)) - - # 左边下半 - contour.append(_contour_line( - [-hw, bot_v - cr, 0.0], [-hw, sr, 0.0])) - entities.append(_line([-hw, bot_v - cr], [-hw, sr])) - - # 左侧中心凹槽 - contour.append(_contour_arc( - [-hw, -sr, 0.0], [-hw, sr, 0.0], - [-hw, 0.0, 0.0], sr)) - - # 左边上半 - contour.append(_contour_line( - [-hw, -sr, 0.0], [-hw, top_v + cr, 0.0])) - entities.append(_line([-hw, -sr], [-hw, top_v + cr])) - - # 左上圆角 - contour.append(_contour_arc( - [-hw, top_v + cr, 0.0], [-(hw - cr), top_v, 0.0], - [-(hw - cr), top_v + cr, 0.0], cr)) - - entities.extend(_build_circle_entities(profile.get("circles") or [])) - return entities, contour - - -# ═══════════════════════════════════════════════════════════════ -# 更多弧边复合轮廓生成器 -# ═══════════════════════════════════════════════════════════════ - - -def _gen_d_shape(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: - """D形(半圆+弦线):一条直线 + 一条大圆弧,形如字母 D。 - - 参数: - radius_mm: 大弧半径(圆心在原点) - chord_sign: 弦线方向,"left"=弦在 x>0 侧,"right"=弦在 x<0 侧 - 默认 "left"(弦线在 +x 侧,弧形开口朝 -x) - - 覆盖文件: 144358 - """ - import math - R = float(profile["radius_mm"]) - side = profile.get("chord_sign", "left") - sign = 1 if side == "left" else -1 - - # chord at x=cx (cx^2 + y^2 = R^2) - # For side="left": chord at x = sqrt(R^2 - y_len^2) ... - # Actually, from 144358 data: arc r=41 c=(0,0) from (34,22.9) to (34,-22.9) - # So the chord is at x=34, v ranges from -22.9 to 22.9 - # v_max = sqrt(R^2 - x^2) = sqrt(41^2 - 34^2) = sqrt(1681-1156) = sqrt(525) ≈ 22.91 ✓ - - v_max = math.sqrt(max(0.0, R * R - (R - 7) * (R - 7))) - # 实际上,chord x 可以根据 radius 推导 - # 使用 chord_x 参数如果存在,否则用近似 - chord_x = float(profile.get("chord_x_mm") or R * 0.83) # 默认在半径 83% 处 - - v_half = math.sqrt(max(0.0, R * R - chord_x * chord_x)) - cx = sign * chord_x # 弦线 x 坐标 - - contour = [ - # 弦线(从下到上) - _contour_line([cx, -v_half, 0.0], [cx, v_half, 0.0]), - # 大弧(从右上到左下,即从左到右沿弧线) - _contour_arc([cx, v_half, 0.0], [cx, -v_half, 0.0], [0.0, 0.0, 0.0], R), - ] - - entities = [ - _line([cx, -v_half], [cx, v_half]), - ] - - entities.extend(_build_circle_entities(profile.get("circles") or [])) - return entities, contour - - -def _gen_partial_ring(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: - """部分圆环(同心圆弧+径向直线):两段同心弧 + 两条径向线。 - - 形状像一个扇区环 (sector annulus),由内外两段同心弧和两侧径向线组成。 - - 参数: - inner_radius_mm: 内弧半径 - outer_radius_mm: 外弧半径 - half_angle_deg: 弧的半角度(两侧各 half_angle 度,总张角 2*half_angle) - - 覆盖文件: 177126 - """ - import math - ir = float(profile["inner_radius_mm"]) - oR = float(profile["outer_radius_mm"]) - h_deg = float(profile.get("half_angle_deg") or 45.0) - h_rad = math.radians(h_deg) - - # 内弧端点 - iu_pos = ir * math.cos(h_rad) - iv_pos = ir * math.sin(h_rad) - iu_neg = ir * math.cos(-h_rad) - iv_neg = ir * math.sin(-h_rad) - - # 外弧端点 - ou_pos = oR * math.cos(h_rad) - ov_pos = oR * math.sin(h_rad) - ou_neg = oR * math.cos(-h_rad) - ov_neg = oR * math.sin(-h_rad) - - contour = [ - # 右侧径向线(从内弧到外弧,+h角度) - _contour_line([iu_pos, iv_pos, 0.0], [ou_pos, ov_pos, 0.0]), - # 外弧(从 +h 到 -h) - _contour_arc([ou_neg, ov_neg, 0.0], [ou_pos, ov_pos, 0.0], [0.0, 0.0, 0.0], oR), - # 左侧径向线(从外弧到内弧,-h角度) - _contour_line([ou_neg, ov_neg, 0.0], [iu_neg, iv_neg, 0.0]), - # 内弧(从 -h 到 +h) - _contour_arc([iu_pos, iv_pos, 0.0], [iu_neg, iv_neg, 0.0], [0.0, 0.0, 0.0], ir), - ] - - entities = [ - _line([iu_pos, iv_pos], [ou_pos, ov_pos]), - _line([ou_neg, ov_neg], [iu_neg, iv_neg]), - ] - - entities.extend(_build_circle_entities(profile.get("circles") or [])) - return entities, contour - - -def _gen_partial_ring_with_arc_island(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: - """扇区环 + 外弦上的等宽弧岛(岛不切除,作为区域内孔)。 - - 每个 replica 生成一块「外轮廓=扇区环、内孔=偏移弧岛」的区域。 - 岛外弧端点落在扇区外弧弦上,横坐标取 ±inner·cos(half_angle)(相对角平分线)。 - - 参数: - inner_radius_mm / outer_radius_mm / half_angle_deg: 扇区环 - island_radius_mm: 岛外弧半径 - island_gap_mm: 岛内外弧径向间距(等宽) - center_angles_deg 或 replicas[{center_angle_deg}]: 各扇区角平分线方向(度) - - 覆盖: b005 - """ - ir = float(profile["inner_radius_mm"]) - oR = float(profile["outer_radius_mm"]) - h_deg = float(profile.get("half_angle_deg") or 45.0) - island_r = float(profile["island_radius_mm"]) - gap = float(profile.get("island_gap_mm") or 1.0) - if ir <= 0 or oR <= ir or island_r <= gap: - raise ValueError("partial_ring_with_arc_island: invalid radii") - - replicas = profile.get("replicas") - if replicas: - angles = [float(r["center_angle_deg"]) for r in replicas] - else: - angles = [float(a) for a in (profile.get("center_angles_deg") or [0.0])] - - h = math.radians(h_deg) - entities: list[_Ctx] = [] - regions: list[dict[str, Any]] = [] - - for ca_deg in angles: - ca = math.radians(ca_deg) - a0, a1 = ca - h, ca + h - - def polar(r: float, ang: float) -> list[float]: - return [r * math.cos(ang), r * math.sin(ang), 0.0] - - # 扇区环外轮廓(逆时针:外弧 a0→a1,径向,内弧 a1→a0,径向) - ou0, ou1 = polar(oR, a0), polar(oR, a1) - iu0, iu1 = polar(ir, a0), polar(ir, a1) - outer = [ - _contour_arc(ou0, ou1, [0.0, 0.0, 0.0], oR), - _contour_line(ou1, iu1), - _contour_arc(iu1, iu0, [0.0, 0.0, 0.0], ir), - _contour_line(iu0, ou0), - ] - entities.extend([ - _line(ou0[:2], ou1[:2]), - _line(ou1[:2], iu1[:2]), - _line(iu1[:2], iu0[:2]), - _line(iu0[:2], ou0[:2]), - ]) - - # 外弦中点与弦向单位向量;岛端点 = M ± inner·cos(h)·chord_dir - ux, uy = math.cos(ca), math.sin(ca) - mx = oR * ux * math.cos(h) - my = oR * uy * math.cos(h) - cdx, cdy = -uy, ux - span = ir * math.cos(h) - e1 = [mx + span * cdx, my + span * cdy, 0.0] - e2 = [mx - span * cdx, my - span * cdy, 0.0] - - # 岛心在角平分线上:|E - t·u| = island_r,取距原点较近根 - dot = e1[0] * ux + e1[1] * uy - e2n = e1[0] * e1[0] + e1[1] * e1[1] - disc = max(0.0, dot * dot - (e2n - island_r * island_r)) - t1, t2 = dot - math.sqrt(disc), dot + math.sqrt(disc) - t = t1 if abs(t1) <= abs(t2) else t2 - cx, cy = t * ux, t * uy - c3 = [cx, cy, 0.0] - - def inward(pt: list[float]) -> list[float]: - vx, vy = cx - pt[0], cy - pt[1] - L = math.hypot(vx, vy) or 1.0 - return [pt[0] + vx / L * gap, pt[1] + vy / L * gap, 0.0] - - i1, i2 = inward(e1), inward(e2) - ri = island_r - gap - - # 岛孔:外弧 e1→e2(经外侧鼓包)再经内弧返回;与扇区同向时作孔需反向 - # 外弧走短弧中指向外侧(远离原点)的那条 - hole = [ - _contour_arc(e1, e2, c3, island_r), - _contour_line(e2, i2), - _contour_arc(i2, i1, c3, ri), - _contour_line(i1, e1), - ] - entities.extend([ - _line(e1[:2], e2[:2]), - _line(e2[:2], i2[:2]), - _line(i2[:2], i1[:2]), - _line(i1[:2], e1[:2]), - ]) - regions.append({"outer": outer, "holes": [hole]}) - - meta["_regions"] = regions - return entities, [] - - -def _gen_arc_chain(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: - """弧链轮廓:多段首尾相连的弧形成闭合轮廓(各弧可有不同圆心)。 - - 用于 revolve 特征的截面草图,由多段圆弧端到端连接组成。 - - 参数: - arcs: 弧描述列表 [{radius_mm, center:[u,v], start_angle_deg, end_angle_deg}, ...] - (每个弧从 start_angle 到 end_angle,起点与上一条弧终点重合) - - 覆盖文件: 020543 - """ - import math - arc_list = profile.get("arcs") or [] - - if not arc_list or len(arc_list) < 2: - raise ValueError("arc_chain needs at least 2 arcs") - - entities, contour = [], [] - - for arc_desc in arc_list: - r = float(arc_desc["radius_mm"]) - center = arc_desc.get("center") or [0.0, 0.0] - cu, cv = float(center[0]), float(center[1]) - sa = math.radians(float(arc_desc["start_angle_deg"])) - ea = math.radians(float(arc_desc["end_angle_deg"])) - - su = cu + r * math.cos(sa) - sv = cv + r * math.sin(sa) - eu = cu + r * math.cos(ea) - ev = cv + r * math.sin(ea) - - contour.append(_contour_arc( - [su, sv, 0.0], [eu, ev, 0.0], - [cu, cv, 0.0], r, - )) - - entities.extend(_build_circle_entities(profile.get("circles") or [])) - return entities, contour - - -def _gen_radial_slot(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: - """径向槽:两段同心圆弧 + 两端圆角,形如弧形环段。 - - 用于 extrude_cut 在圆柱壁面上开弧形槽口。 - - 参数: - inner_radius_mm: 内弧半径 - outer_radius_mm: 外弧半径 - start_angle_deg: 槽起始角度(度,从工作平面 x_dir 方向逆时针测量) - end_angle_deg: 槽终止角度 - - 覆盖文件: 020543 (sk_02, sk_03, sk_04) - """ - import math - ir = float(profile["inner_radius_mm"]) - oR = float(profile["outer_radius_mm"]) - sa_deg = float(profile["start_angle_deg"]) - ea_deg = float(profile["end_angle_deg"]) - - fr = (oR - ir) / 2.0 # 端盖圆角半径 - sa = math.radians(sa_deg) - ea = math.radians(ea_deg) - - entities, contour = [], [] - - # 角度从工作平面 x_dir 方向测量 → u = r·cos(θ), v = r·sin(θ) - # 1. 内弧(从 start→end) - isu = ir * math.cos(sa); isv = ir * math.sin(sa) - ieu = ir * math.cos(ea); iev = ir * math.sin(ea) - contour.append(_contour_arc( - [isu, isv, 0.0], [ieu, iev, 0.0], - [0.0, 0.0, 0.0], ir, - )) - - # 2. 终端圆角(半圆,从内弧终点到外弧终点) - fc_u = (ir + oR) / 2.0 - fcu_s = fc_u * math.cos(ea); fcv_s = fc_u * math.sin(ea) - osu = oR * math.cos(sa); osv = oR * math.sin(sa) - oeu = oR * math.cos(ea); oev = oR * math.sin(ea) - - contour.append(_contour_arc( - [ieu, iev, 0.0], [oeu, oev, 0.0], - [fcu_s, fcv_s, 0.0], fr, - )) - - # 3. 外弧(从 end→start,反向) - contour.append(_contour_arc( - [oeu, oev, 0.0], [osu, osv, 0.0], - [0.0, 0.0, 0.0], oR, - )) - - # 4. 起始端圆角(从外弧起点到内弧起点) - fcu_e = fc_u * math.cos(sa); fcv_e = fc_u * math.sin(sa) - contour.append(_contour_arc( - [osu, osv, 0.0], [isu, isv, 0.0], - [fcu_e, fcv_e, 0.0], fr, - )) - - entities.extend(_build_circle_entities(profile.get("circles") or [])) - return entities, contour - - -# ═══════════════════════════════════════════════════════════════ -# 程序化重复切口 -# ═══════════════════════════════════════════════════════════════ - -def _poly_contour(vertices: list[tuple[float, float]]) -> list[_Ctx]: - """把按顺序给出的二维顶点变成闭合直线轮廓。""" - return [ - _contour_line( - [vertices[i][0], vertices[i][1], 0.0], - [vertices[(i + 1) % len(vertices)][0], vertices[(i + 1) % len(vertices)][1], 0.0], - ) - for i in range(len(vertices)) - ] - - -def _transform_pattern_contour( - contour: list[_Ctx], - x_mm: float, - y_mm: float, - angle_deg: float, - scale: float = 1.0, -) -> list[_Ctx]: - """旋转、缩放并平移一个二维轮廓。""" - a = math.radians(angle_deg) - ca, sa = math.cos(a), math.sin(a) - - def point(p: list[float]) -> list[float]: - x, y = float(p[0]) * scale, float(p[1]) * scale - return [x_mm + x * ca - y * sa, y_mm + x * sa + y * ca, 0.0] - - result: list[_Ctx] = [] - for edge in contour: - item = deepcopy(edge) - item["start_mm"] = point(edge["start_mm"]) - item["end_mm"] = point(edge["end_mm"]) - if edge.get("center_mm") is not None: - item["center_mm"] = point(edge["center_mm"]) - if edge.get("radius_mm") is not None: - item["radius_mm"] = float(edge["radius_mm"]) * scale - result.append(item) - return result - - -def _pattern_motif_contour(motif: _Ctx) -> list[_Ctx]: - """从少量命名尺寸生成一个切口母形。""" - kind = str(motif.get("type") or "") - - if kind == "circle": - radius = float(motif["radius_mm"]) - return _gen_circle({"type": "circle", "radius_mm": radius}, {})[1] - - if kind in ("square", "rectangle"): - width = float(motif["width_mm"]) - height = float(motif.get("height_mm") or width) - return _rect_lines_and_contour(-width / 2.0, -height / 2.0, width / 2.0, height / 2.0)[1] - - if kind == "obround": - length = float(motif["length_mm"]) - width = float(motif["width_mm"]) - return _gen_obround( - {"type": "obround", "center": [0.0, 0.0], "length_mm": length, "width_mm": width}, - {}, - )[1] - - if kind == "cross": - size = float(motif["size_mm"]) - arm = float(motif["arm_width_mm"]) - half, arm_half = size / 2.0, arm / 2.0 - vertices = [ - (-arm_half, -half), (arm_half, -half), - (arm_half, -arm_half), (half, -arm_half), - (half, arm_half), (arm_half, arm_half), - (arm_half, half), (-arm_half, half), - (-arm_half, arm_half), (-half, arm_half), - (-half, -arm_half), (-arm_half, -arm_half), - ] - return _poly_contour(vertices) - - if kind == "d_shape_polygon": - stem = float(motif["stem_length_mm"]) - nose = float(motif["nose_depth_mm"]) - half_height = float(motif["half_height_mm"]) - segments = int(motif.get("arc_segments") or 14) - vertices = [(-stem, -half_height), (-stem, half_height), (0.0, half_height)] - # 右半椭圆;首尾端点已由直线给出,内部取样由引擎固化。 - for i in range(1, segments): - angle = math.pi / 2.0 - math.pi * i / segments - vertices.append((nose * math.cos(angle), half_height * math.sin(angle))) - vertices.append((0.0, -half_height)) - return _poly_contour(vertices) - - if kind == "regular_hexagon": - radius = float(motif["radius_mm"]) - return _poly_contour([ - ( - radius * math.cos(math.radians(60.0 * i)), - radius * math.sin(math.radians(60.0 * i)), - ) - for i in range(6) - ]) - - if kind == "skew_hexagon": - # 该族来自六边形母形的非对称离散模板;只保留一个名义半径, - # 其余稳定比例由引擎固化,不把六个顶点写进 CDSL。 - radius = float(motif["nominal_radius_mm"]) - return _poly_contour([ - (radius, 0.0), - (radius * 0.317014, radius * 0.682, ), - (-radius * 0.5, radius * 0.682), - (-radius * 1.183014, 0.0), - (-radius * 0.408494, -radius * 0.774519), - (radius * 0.317014, -radius * 0.774519), - ]) - - if kind == "triangle": - radius = float(motif["radius_mm"]) - return _poly_contour([ - ( - radius * math.cos(math.radians(120.0 * i)), - radius * math.sin(math.radians(120.0 * i)), - ) - for i in range(3) - ]) - - if kind == "teardrop_polygon": - if motif.get("left_width_mm") is not None: - left = float(motif["left_width_mm"]) - right = float(motif["right_width_mm"]) - tip = float(motif["tip_height_mm"]) - bottom = -float(motif["bottom_depth_mm"]) - shoulder = float(motif["shoulder_height_mm"]) - return _poly_contour([ - (0.0, tip), - (right, shoulder), - (right, bottom), - (-left, bottom), - (-left, shoulder), - ]) - width = float(motif["width_mm"]) - height = float(motif["height_mm"]) - shoulder = float(motif.get("shoulder_fraction") or 0.58) - half = width / 2.0 - top = height / 2.0 - bottom = -height / 2.0 - shoulder_y = bottom + height * shoulder - return _poly_contour([ - (0.0, top), - (half, shoulder_y), - (half, bottom), - (-half, bottom), - (-half, shoulder_y), - ]) - - if kind == "trapezoid": - bottom = float(motif["bottom_width_mm"]) - top = float(motif["top_width_mm"]) - height = float(motif["height_mm"]) - hh = height / 2.0 - return _poly_contour([ - (-bottom / 2.0, -hh), - (bottom / 2.0, -hh), - (top / 2.0, hh), - (-top / 2.0, hh), - ]) - - if kind == "annular_sector_polygon": - inner = float(motif["inner_radius_mm"]) - outer = float(motif["outer_radius_mm"]) - half_angle = float(motif["half_angle_deg"]) - segments = int(motif.get("arc_segments") or 8) - outer_pts = [ - ( - outer * math.cos(math.radians(-half_angle + 2.0 * half_angle * i / segments)), - outer * math.sin(math.radians(-half_angle + 2.0 * half_angle * i / segments)), - ) - for i in range(segments + 1) - ] - inner_pts = [ - ( - inner * math.cos(math.radians(half_angle - 2.0 * half_angle * i / segments)), - inner * math.sin(math.radians(half_angle - 2.0 * half_angle * i / segments)), - ) - for i in range(segments + 1) - ] - return _poly_contour(outer_pts + inner_pts) - - raise ValueError(f"patterned_cutouts: unsupported motif type {kind!r}") - - -def _pattern_placements(layout: _Ctx) -> list[tuple[float, float, float, float]]: - """展开语义布局,返回 (x, y, rotation_deg, scale)。""" - kind = str(layout.get("type") or "") - orientation = str(layout.get("orientation") or "fixed") - orientation_offset = float(layout.get("orientation_offset_deg") or 0.0) - - def orient(angle: float) -> float: - if orientation == "radial": - return angle + orientation_offset - if orientation == "tangential": - return angle + 90.0 + orientation_offset - if orientation == "snapped_radial": - snap = float(layout.get("orientation_snap_deg") or 45.0) - return round(angle / snap) * snap + orientation_offset - return orientation_offset - - if kind in ("ring", "angular"): - radius = float(layout.get("radius_mm") or 0.0) - count = int(layout["count"]) - start = float(layout.get("start_angle_deg") or 0.0) - step = float(layout.get("angle_step_deg") or (360.0 / count)) - angular_only = kind == "angular" - return [ - ( - 0.0 if angular_only else radius * math.cos(math.radians(start + i * step)), - 0.0 if angular_only else radius * math.sin(math.radians(start + i * step)), - orient(start + i * step), - 1.0, - ) - for i in range(count) - ] - - if kind == "concentric_rings": - result: list[tuple[float, float, float, float]] = [] - for ring in layout.get("rings") or []: - merged = dict(layout) - merged.update(ring) - merged["type"] = "ring" - result.extend(_pattern_placements(merged)) - return result - - if kind == "disc_grid": - nx, ny = int(layout["count_x"]), int(layout["count_y"]) - sx, sy = float(layout["spacing_x_mm"]), float(layout["spacing_y_mm"]) - center = layout.get("center_mm") or [0.0, 0.0] - x0 = float(center[0]) - (nx - 1) * sx / 2.0 - y0 = float(center[1]) - (ny - 1) * sy / 2.0 - limit = layout.get("max_center_radius_mm") - points = [ - (x0 + i * sx, y0 + j * sy) - for j in range(ny) - for i in range(nx) - ] - if limit is not None: - points = [(x, y) for x, y in points if math.hypot(x, y) <= float(limit) + 1e-9] - return [(x, y, orientation_offset, 1.0) for x, y in points] - - if kind == "open_arc": - radius = float(layout["radius_mm"]) - count = int(layout["count"]) - start, end = float(layout["start_angle_deg"]), float(layout["end_angle_deg"]) - step = 0.0 if count == 1 else (end - start) / (count - 1) - return [ - ( - radius * math.cos(math.radians(start + i * step)), - radius * math.sin(math.radians(start + i * step)), - orient(start + i * step), - 1.0, - ) - for i in range(count) - ] - - if kind == "spiral": - count = int(layout["count"]) - start_radius = float(layout["start_radius_mm"]) - radius_step = float(layout["radius_step_mm"]) - start_angle = float(layout.get("start_angle_deg") or 0.0) - angle_step = float(layout["angle_step_deg"]) - result = [] - for i in range(count): - radius = start_radius + i * radius_step - angle = start_angle + i * angle_step - result.append(( - radius * math.cos(math.radians(angle)), - radius * math.sin(math.radians(angle)), - orient(angle), - 1.0, - )) - return result - - if kind == "cross_lines": - count = int(layout["count_per_axis"]) - spacing = float(layout["spacing_mm"]) - start = -(count - 1) * spacing / 2.0 - result = [] - for i in range(count): - value = start + i * spacing - result.append((value, 0.0, orientation_offset, 1.0)) - result.append((0.0, value, orientation_offset + 90.0, 1.0)) - return result - - if kind == "x_field": - levels = int(layout["levels"]) - spacing = float(layout["spacing_mm"]) - start = -(levels - 1) * spacing / 2.0 - result = [] - for i in range(levels): - value = start + i * spacing - if abs(value) < 1e-9: - rotation = 135.0 + orientation_offset if orientation == "diagonal_axes" else orientation_offset - result.append((0.0, 0.0, rotation, 1.0)) - else: - for y in (value, -value): - if orientation == "diagonal_axes": - rotation = (135.0 if value * y > 0 else 45.0) + orientation_offset - else: - angle = math.degrees(math.atan2(y, value)) - rotation = orient(angle) - result.append((value, y, rotation, 1.0)) - return result - - if kind == "twin_strips": - x_offset = float(layout["x_offset_mm"]) - count = int(layout["count_y"]) - y_start = float(layout["y_start_mm"]) - y_end = float(layout["y_end_mm"]) - step = 0.0 if count == 1 else (y_end - y_start) / (count - 1) - return [ - (x, y_start + j * step, orientation_offset, 1.0) - for j in range(count) - for x in (-x_offset, x_offset) - ] - - if kind == "corner_clusters": - levels = [float(v) for v in (layout.get("levels_mm") or [])] - return [ - (sx * x, sy * y, orientation_offset, 1.0) - for sx in (-1.0, 1.0) - for sy in (-1.0, 1.0) - for y in levels - for x in levels - ] - - if kind == "diamond_field": - radius = int(layout["manhattan_radius"]) - spacing = float(layout["spacing_mm"]) - return [ - (i * spacing, j * spacing, orientation_offset, 1.0) - for distance in range(radius + 1) - for j in range(-radius, radius + 1) - for i in range(-radius, radius + 1) - if abs(i) + abs(j) == distance - ] - - raise ValueError(f"patterned_cutouts: unsupported layout type {kind!r}") - - -def _gen_patterned_cutouts(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: - """一个母形 + 一个语义布局,运行时展开成多个独立切除区域。""" - motif = profile.get("motif") or {} - layout = profile.get("layout") or {} - base = _pattern_motif_contour(motif) - regions = [] - for x, y, angle, scale in _pattern_placements(layout): - regions.append({ - "outer": _transform_pattern_contour(base, x, y, angle, scale), - "holes": [], - }) - if not regions: - raise ValueError("patterned_cutouts: layout produced no regions") - meta["_regions"] = regions - return [], [] - - -def _gen_compound_patterned_cutouts(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: - """把少量不同母形/布局的程序化图案合并到同一草图。""" - regions: list[_Ctx] = [] - for pattern in profile.get("patterns") or []: - motif = pattern.get("motif") or {} - layout = pattern.get("layout") or {} - base = _pattern_motif_contour(motif) - for x, y, angle, scale in _pattern_placements(layout): - regions.append({ - "outer": _transform_pattern_contour(base, x, y, angle, scale), - "holes": [], - }) - if not regions: - raise ValueError("compound_patterned_cutouts: patterns produced no regions") - meta["_regions"] = regions - return [], [] - - -# ═══════════════════════════════════════════════════════════════ -# Evidence v2 analytic contours -# ═══════════════════════════════════════════════════════════════ - -_ANALYTIC_TOLERANCE_MM = 1e-5 - - -def _distance_2d(left: list[float], right: list[float]) -> float: + if len(vertices) < 3: + entities = meta.get("_entities") or [] + if not entities: + raise ValueError("polygon needs at least 3 vertices") + return list(entities), [_contour_line(edge["start"], edge["end"]) for edge in entities if edge.get("type") == "line"] + points = [(float(vertex[0]), float(vertex[1])) for vertex in vertices] + return ( + [_line(list(points[index]), list(points[(index + 1) % len(points)])) for index in range(len(points))], + [_contour_line([*points[index], 0.0], [*points[(index + 1) % len(points)], 0.0]) for index in range(len(points))], + ) + + +def _distance(left: list[float], right: list[float]) -> float: return math.hypot(float(left[0]) - float(right[0]), float(left[1]) - float(right[1])) -def _reverse_analytic_edge(edge: _Ctx) -> _Ctx: - result = deepcopy(edge) - result["start_mm"], result["end_mm"] = result["end_mm"], result["start_mm"] - if result.get("type") == "arc" and "clockwise" in result: - result["clockwise"] = not bool(result["clockwise"]) - return result +def _reverse(edge: _Ctx) -> _Ctx: + output = deepcopy(edge) + output["start_mm"], output["end_mm"] = output["end_mm"], output["start_mm"] + if output.get("type") == "arc" and "clockwise" in output: + output["clockwise"] = not bool(output["clockwise"]) + return output -def _join_analytic_edges(edges: list[_Ctx], *, closed: bool) -> list[_Ctx]: - """Order/reorient a contour without depending on SolidWorks segment order.""" +def _join(edges: list[_Ctx]) -> list[_Ctx]: if not edges: return [] - pending = [deepcopy(edge) for edge in edges] - ordered = [pending.pop(0)] - while pending: + remaining = [deepcopy(edge) for edge in edges] + ordered = [remaining.pop(0)] + while remaining: tail = ordered[-1]["end_mm"] - match_index = None - reverse = False - for index, edge in enumerate(pending): - if _distance_2d(tail, edge["start_mm"]) <= _ANALYTIC_TOLERANCE_MM: - match_index = index + for index, candidate in enumerate(remaining): + if _distance(tail, candidate["start_mm"]) <= _TOLERANCE_MM: + ordered.append(remaining.pop(index)) break - if _distance_2d(tail, edge["end_mm"]) <= _ANALYTIC_TOLERANCE_MM: - match_index = index - reverse = True + if _distance(tail, candidate["end_mm"]) <= _TOLERANCE_MM: + ordered.append(_reverse(remaining.pop(index))) break - if match_index is None: + else: raise ValueError("analytic_contours: segments do not form a connected contour") - edge = pending.pop(match_index) - ordered.append(_reverse_analytic_edge(edge) if reverse else edge) - if closed and _distance_2d(ordered[0]["start_mm"], ordered[-1]["end_mm"]) > _ANALYTIC_TOLERANCE_MM: + if _distance(ordered[0]["start_mm"], ordered[-1]["end_mm"]) > _TOLERANCE_MM: raise ValueError("analytic_contours: closed contour endpoints do not meet") return ordered -def _analytic_circle_edges(segment: _Ctx) -> list[_Ctx]: +def _circle_edges(segment: _Ctx) -> list[_Ctx]: center = segment.get("center") or [0.0, 0.0] radius = float(segment.get("radius_mm") or 0.0) if radius <= 0: @@ -1617,86 +132,56 @@ def _analytic_circle_edges(segment: _Ctx) -> list[_Ctx]: clockwise = bool(segment.get("clockwise", False)) angles = [0.0, -90.0, -180.0, -270.0, -360.0] if clockwise else [0.0, 90.0, 180.0, 270.0, 360.0] points = [[cx + radius * math.cos(math.radians(angle)), cy + radius * math.sin(math.radians(angle)), 0.0] for angle in angles] - return [ - _contour_arc(points[index], points[index + 1], [cx, cy, 0.0], radius, clockwise) - for index in range(4) - ] + return [_contour_arc(points[index], points[index + 1], [cx, cy, 0.0], radius, clockwise) for index in range(4)] -def _analytic_segment_edges(segment: _Ctx) -> list[_Ctx]: - segment_type = segment.get("type") - if segment_type == "line": +def _segment_edges(segment: _Ctx) -> list[_Ctx]: + kind = segment.get("type") + if kind == "line": return [_contour_line(segment["start"], segment["end"])] - if segment_type == "arc": - return [ - _contour_arc( - segment["start"], segment["end"], segment["center"], - segment.get("radius_mm"), segment.get("clockwise"), - ) - ] - if segment_type == "circle": - return _analytic_circle_edges(segment) - if segment_type == "bspline": + if kind == "arc": + return [_contour_arc(segment["start"], segment["end"], segment["center"], segment.get("radius_mm"), segment.get("clockwise"))] + if kind == "circle": + return _circle_edges(segment) + if kind == "bspline": raise ValueError("analytic_contours: bspline requires an explicit approximation capability") - raise ValueError(f"analytic_contours: unsupported segment type {segment_type!r}") + raise ValueError(f"analytic_contours: unsupported segment type {kind!r}") -def _sample_analytic_loop(edges: list[_Ctx]) -> list[tuple[float, float]]: - """Create a deterministic planar sample only for containment classification.""" +def _sample_loop(edges: list[_Ctx]) -> list[tuple[float, float]]: points: list[tuple[float, float]] = [] for edge in edges: start = edge["start_mm"] points.append((float(start[0]), float(start[1]))) if edge.get("type") != "arc": continue - center = edge["center_mm"] - end = edge["end_mm"] - sx, sy = float(start[0]) - float(center[0]), float(start[1]) - float(center[1]) - ex, ey = float(end[0]) - float(center[0]), float(end[1]) - float(center[1]) - start_angle = math.atan2(sy, sx) - end_angle = math.atan2(ey, ex) + center, end = edge["center_mm"], edge["end_mm"] + start_angle = math.atan2(float(start[1]) - float(center[1]), float(start[0]) - float(center[0])) + end_angle = math.atan2(float(end[1]) - float(center[1]), float(end[0]) - float(center[0])) delta = end_angle - start_angle if edge.get("clockwise"): if delta >= 0: delta -= math.tau elif delta <= 0: delta += math.tau + radius = float(edge.get("radius_mm") or _distance(start, center)) for fraction in (0.25, 0.5, 0.75): angle = start_angle + delta * fraction - radius = float(edge.get("radius_mm") or math.hypot(sx, sy)) points.append((float(center[0]) + radius * math.cos(angle), float(center[1]) + radius * math.sin(angle))) return points -def _loop_area(points: list[tuple[float, float]]) -> float: - if len(points) < 3: - return 0.0 - return abs(sum(points[index][0] * points[(index + 1) % len(points)][1] - points[(index + 1) % len(points)][0] * points[index][1] for index in range(len(points))) / 2.0) - - -def _endpoint_signed_area(edges: list[_Ctx]) -> float: - points = [(float(edge["start_mm"][0]), float(edge["start_mm"][1])) for edge in edges] - return sum( - points[index][0] * points[(index + 1) % len(points)][1] - - points[(index + 1) % len(points)][0] * points[index][1] - for index in range(len(points)) - ) / 2.0 - - def _normalize_quarter_rounding_direction(edges: list[_Ctx]) -> None: - """Repair inconsistent sweep flags on a conventional rounded rectangle. + """Repair inconsistent direction flags on a conventional rounded box. - Evidence exports occasionally label one or more 90-degree corner arcs - with the opposite direction. Honouring those isolated flags creates - 270-degree loops. This normalizer applies only to the unambiguous shape: - exactly four equal-radius quarter arcs in one closed loop. Other arcs, - including annular sectors and long sweeps, retain their captured flags. + The rule only applies to the unambiguous case of four equal 90-degree + corner arcs. It is geometry normalization, not a semantic shape macro. """ arcs = [edge for edge in edges if edge.get("type") == "arc"] if len(arcs) != 4: return radii = [float(edge.get("radius_mm") or 0.0) for edge in arcs] - if min(radii) <= _ANALYTIC_TOLERANCE_MM or max(radii) - min(radii) > _ANALYTIC_TOLERANCE_MM: + if min(radii) <= _TOLERANCE_MM or max(radii) - min(radii) > _TOLERANCE_MM: return for edge in arcs: center = edge.get("center_mm") @@ -1708,81 +193,62 @@ def _normalize_quarter_rounding_direction(edges: list[_Ctx]) -> None: angle = abs(math.atan2(first[0] * second[1] - first[1] * second[0], first[0] * second[0] + first[1] * second[1])) if abs(angle - math.pi / 2) > 1e-4: return - # A clockwise endpoint loop needs clockwise short corner arcs; a - # counter-clockwise loop needs their reverse. This preserves the actual - # rounded-rectangle boundary, independent of per-segment export noise. - clockwise = _endpoint_signed_area(edges) < 0.0 + points = [(float(edge["start_mm"][0]), float(edge["start_mm"][1])) for edge in edges] + clockwise = sum(points[index][0] * points[(index + 1) % len(points)][1] - points[(index + 1) % len(points)][0] * points[index][1] for index in range(len(points))) < 0.0 for edge in arcs: edge["clockwise"] = clockwise -def _point_in_loop(point: tuple[float, float], loop: list[tuple[float, float]]) -> bool: - if len(loop) < 3: - return False +def _area(points: list[tuple[float, float]]) -> float: + return abs(sum(points[index][0] * points[(index + 1) % len(points)][1] - points[(index + 1) % len(points)][0] * points[index][1] for index in range(len(points))) / 2.0) if len(points) >= 3 else 0.0 + + +def _contains(point: tuple[float, float], loop: list[tuple[float, float]]) -> bool: inside = False x, y = point previous = loop[-1] for current in loop: - x1, y1 = current - x2, y2 = previous - if (y1 > y) != (y2 > y): - intersect_x = (x2 - x1) * (y - y1) / (y2 - y1) + x1 - if x < intersect_x: + if (current[1] > y) != (previous[1] > y): + crossing = (previous[0] - current[0]) * (y - current[1]) / (previous[1] - current[1]) + current[0] + if x < crossing: inside = not inside previous = current return inside def _gen_analytic_contours(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: - """Resolve Evidence v2 line/arc/circle loops into engine-neutral regions. - - The returned regions preserve holes and islands. The build adapter owns - B-rep creation; this profile generator only reasons about sketch geometry. - """ loops: list[_Ctx] = [] entities: list[_Ctx] = [] - for contour_index, contour in enumerate(profile.get("contours") or []): + for index, contour in enumerate(profile.get("contours") or []): if not contour.get("closed"): - raise ValueError(f"analytic_contours: contour {contour_index} is open") - segment_edges: list[_Ctx] = [] + raise ValueError(f"analytic_contours: contour {index} is open") + raw_edges: list[_Ctx] = [] for segment in contour.get("segments") or []: - segment_type = segment.get("type") - if segment_type == "line": + if segment.get("type") == "line": entities.append(_line(segment["start"], segment["end"])) - elif segment_type == "circle": + elif segment.get("type") == "circle": entities.append(_circle(segment.get("center") or [0.0, 0.0], float(segment.get("radius_mm") or 0.0))) - segment_edges.extend(_analytic_segment_edges(segment)) - if not segment_edges: - continue - edges = _join_analytic_edges(segment_edges, closed=True) - _normalize_quarter_rounding_direction(edges) - points = _sample_analytic_loop(edges) - area = _loop_area(points) - if area <= _ANALYTIC_TOLERANCE_MM * _ANALYTIC_TOLERANCE_MM: - raise ValueError(f"analytic_contours: contour {contour_index} is degenerate") - loops.append({"role": contour.get("role", "unknown"), "edges": edges, "points": points, "area": area}) - + raw_edges.extend(_segment_edges(segment)) + if raw_edges: + edges = _join(raw_edges) + _normalize_quarter_rounding_direction(edges) + sample = _sample_loop(edges) + if _area(sample) <= _TOLERANCE_MM * _TOLERANCE_MM: + raise ValueError(f"analytic_contours: contour {index} is degenerate") + loops.append({"edges": edges, "points": sample, "area": _area(sample)}) for segment in profile.get("construction") or []: if segment.get("type") == "line": entities.append(_line(segment["start"], segment["end"], construction=True)) elif segment.get("type") == "circle": entities.append(_circle(segment.get("center") or [0.0, 0.0], float(segment.get("radius_mm") or 0.0), construction=True)) - if not loops: return entities, [] for loop in loops: - # Role tags captured from the source sketch are useful provenance but - # not authoritative geometry. A number of exports label separate - # closed contours as ``inner`` although no outer contour contains - # them. The even-odd containment rule is deterministic for the - # supported analytic curves and preserves those independent regions. - contained_by = sum(_point_in_loop(loop["points"][0], other["points"]) for other in loops if other is not loop) - loop["role"] = "inner" if contained_by % 2 else "outer" + loop["role"] = "inner" if sum(_contains(loop["points"][0], other["points"]) for other in loops if other is not loop) % 2 else "outer" outers = [loop for loop in loops if loop["role"] == "outer"] - inners = [loop for loop in loops if loop["role"] == "inner"] regions = [{"outer": outer["edges"], "holes": []} for outer in outers] - for inner in inners: - containing = [outer for outer in outers if _point_in_loop(inner["points"][0], outer["points"])] + for inner in (loop for loop in loops if loop["role"] == "inner"): + containing = [outer for outer in outers if _contains(inner["points"][0], outer["points"])] if not containing: raise ValueError("analytic_contours: inner contour has no containing outer contour") selected = min(containing, key=lambda outer: outer["area"]) @@ -1791,218 +257,84 @@ def _gen_analytic_contours(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[ return entities, [] -# ═══════════════════════════════════════════════════════════════ -# 生成器注册表 —— 唯一索引点 -# ═══════════════════════════════════════════════════════════════ - -SHAPE_GENERATORS: dict[str, Any] = { - "circle": _gen_circle, - "annulus": _gen_annulus, - "circles": _gen_circles, - "circle_grid": _gen_circle_grid, - "rectangle": _gen_rectangle, - "rectangle_with_circles": _gen_rect_with_circles, - "rectangle_with_fillets": _gen_rect_with_fillets, - "obround": _gen_obround, - "polygon": _gen_polygon, - "ibone": _gen_ibone, - "rectangle_with_symmetric_notches": _gen_rect_symmetric_notches, - "revolve_chamfer": _gen_revolve_chamfer, - "revolve_chamfer_slanted": _gen_revolve_chamfer_slanted, - # 弧边复合轮廓(按 015133 手册方法注册,同形异构通过参数复用) - "circle_with_arc_notches": _gen_circle_with_arc_notches, - "circular_sector_slot": _gen_circular_sector_slot, - "circle_with_radial_tabs": _gen_circle_with_radial_tabs, - "filleted_rect_side_slots": _gen_filleted_rect_side_slots, - # 弧边形状 - "d_shape": _gen_d_shape, - "partial_ring": _gen_partial_ring, - "partial_ring_with_arc_island": _gen_partial_ring_with_arc_island, - "radial_slot": _gen_radial_slot, - "patterned_cutouts": _gen_patterned_cutouts, - "compound_patterned_cutouts": _gen_compound_patterned_cutouts, - "analytic_contours": _gen_analytic_contours, - "arc_chain": _gen_arc_chain, - "complex_arc_shape": _gen_polygon, # 从 compiler_context entities 重建 - "unknown_shape": _gen_polygon, # 未分类形状也走 compiler_context 回退 +CORE_SHAPE_GENERATORS: dict[str, Any] = {"circle": _gen_circle, "polygon": _gen_polygon, "analytic_contours": _gen_analytic_contours} +SHAPE_GENERATORS = CORE_SHAPE_GENERATORS +SHAPE_CAPABILITIES: dict[str, _Ctx] = { + "circle": {"detectable": True, "arity": "circle", "description": "single circular contour"}, + "polygon": {"detectable": True, "arity": "polygon", "description": "closed straight-edge contour"}, + "analytic_contours": {"detectable": True, "arity": "analytic", "description": "closed line, arc, and circle contours"}, } -# ═══════════════════════════════════════════════════════════════ -# 注册表功能:扩展、查询 -# ═══════════════════════════════════════════════════════════════ -def register_shape(ptype: str, generator: Any) -> None: - """注册一个新的轮廓生成器。扩展用途。""" - SHAPE_GENERATORS[ptype] = generator +def register_shape(_: str, __: Any) -> None: + raise RuntimeError("Runtime profile types are fixed; lower custom profiles before CDSL execution") def list_registered_shapes() -> list[str]: - """返回所有已注册的形状生成器名称。""" - return sorted(SHAPE_GENERATORS.keys()) + return sorted(SHAPE_GENERATORS) -# ═══════════════════════════════════════════════════════════════ -# 形状能力矩阵(供外部查询:哪些形状可自动检测,哪些需手动指定) -# ═══════════════════════════════════════════════════════════════ - -_ShapeInfo = dict[str, Any] - -SHAPE_CAPABILITIES: dict[str, _ShapeInfo] = { - "circle": {"detectable": True, "arity": "circle", "description": "单圆"}, - "annulus": {"detectable": True, "arity": "circles", "description": "同心圆环"}, - "circles": {"detectable": True, "arity": "circles", "description": "多圆(非同心)"}, - "circle_grid": {"detectable": False, "arity": "circles", "description": "矩形圆孔阵列"}, - "rectangle": {"detectable": True, "arity": "polygon", "description": "4线矩形"}, - "rectangle_with_circles": {"detectable": True, "arity": "mixed", "description": "矩形+内圆孔"}, - "rectangle_with_fillets": {"detectable": False, "arity": "mixed", "description": "圆角矩形(4弧+4线)"}, - "obround": {"detectable": True, "arity": "mixed", "description": "槽形/键槽(2线+2半圆弧)"}, - "polygon": {"detectable": True, "arity": "polygon", "description": "N边多边形"}, - "ibone": {"detectable": False, "arity": "mixed", "description": "工字形凸耳(12线+4弧+4孔)"}, - "rectangle_with_symmetric_notches": {"detectable": False,"arity": "mixed", "description": "对称槽板(矩形+4U形缺口)"}, - "revolve_chamfer": {"detectable": True, "arity": "polygon", "description": "旋转梯形截面"}, - "revolve_chamfer_slanted": {"detectable": True, "arity": "polygon", "description": "旋转斜底梯形截面"}, - "circle_with_arc_notches": {"detectable": False, "arity": "mixed", "description": "圆+均匀弧形凹口"}, - "circular_sector_slot": {"detectable": False, "arity": "mixed", "description": "圆弧扇区+中心矩形槽"}, - "circle_with_radial_tabs": {"detectable": False, "arity": "mixed", "description": "圆+径向矩形凸耳"}, - "filleted_rect_side_slots": {"detectable": False, "arity": "mixed", "description": "圆角矩形+两侧中心U形槽"}, - "d_shape": {"detectable": True, "arity": "mixed", "description": "D形(半圆+弦线)"}, - "partial_ring": {"detectable": True, "arity": "mixed", "description": "部分圆环(扇区环)"}, - "partial_ring_with_arc_island": {"detectable": False, "arity": "mixed", "description": "扇区环+弦上偏移弧岛"}, - "radial_slot": {"detectable": False, "arity": "mixed", "description": "径向弧形槽"}, - "arc_chain": {"detectable": False, "arity": "arcs", "description": "多段弧链轮廓"}, - "patterned_cutouts": {"detectable": False, "arity": "regions", "description": "程序化重复切口"}, - "compound_patterned_cutouts": {"detectable": False, "arity": "regions", "description": "复合程序化重复切口"}, -} - - -# ═══════════════════════════════════════════════════════════════ -# 主入口 -# ═══════════════════════════════════════════════════════════════ - -def resolve_profile(sketch: dict[str, Any]) -> dict[str, Any]: - """按 type 查找生成器,生成 entities + contour_edges_mm。 - - 对于返回非空 contour 的生成器,会额外保留原始 sketch.entities - 中的非 construction circle 实体(孔洞/圆岛),确保不丢失内部特征。 - """ +def resolve_profile(sketch: _Ctx) -> _Ctx: profile = sketch.get("profile") if not profile: return sketch - - ptype = profile.get("type") - generator = SHAPE_GENERATORS.get(ptype) + generator = SHAPE_GENERATORS.get(profile.get("type")) if generator is None: - raise ValueError(f"sketch {sketch.get('id')}: unsupported profile type {ptype!r}") - - meta = {"id": sketch.get("id"), "name": sketch.get("name"), "_entities": sketch.get("entities"), "_contour": sketch.get("contour_edges_mm")} + raise ValueError(f"sketch {sketch.get('id')}: unsupported profile type {profile.get('type')!r}") + meta: _Ctx = {"id": sketch.get("id"), "_entities": sketch.get("entities"), "_regions": []} entities, contour = generator(profile, meta) - - out = deepcopy(sketch) - - # 保留原始草图中的非 construction circle 实体(这些是内部孔洞/圆岛) - orig_ents = sketch.get("entities") or [] - keep_circles = [ - e for e in orig_ents - if e.get("type") == "circle" and not e.get("construction") - ] - if keep_circles and contour: - # 只对生成器产出 contour 的场合保留 circles(轮廓生成器 + 内部圆孔) - entities = list(entities) + keep_circles - - out["entities"] = entities - wp = sketch.get("workplane") + output = deepcopy(sketch) + original_circles = [entity for entity in sketch.get("entities") or [] if entity.get("type") == "circle" and not entity.get("construction")] + output["entities"] = list(entities) + (original_circles if contour else []) + workplane = sketch.get("workplane") if contour: - out["contour_edges_mm"] = _transform_contours(contour, wp) if wp else contour - regions = meta.get("_regions") or [] - if regions: - out["contour_regions_mm"] = [ - { - "outer": _transform_contours(reg["outer"], wp) if wp else reg["outer"], - "holes": [ - _transform_contours(hole, wp) if wp else hole - for hole in (reg.get("holes") or []) - ], - } - for reg in regions + output["contour_edges_mm"] = _transform_contours(contour, workplane) if workplane else contour + if meta["_regions"]: + output["contour_regions_mm"] = [ + {"outer": _transform_contours(region["outer"], workplane) if workplane else region["outer"], "holes": [_transform_contours(hole, workplane) if workplane else hole for hole in region.get("holes") or []]} + for region in meta["_regions"] ] - return out + return output -def resolve_all_sketches(cdsl: dict[str, Any]) -> dict[str, Any]: - """对 CDSL 中所有带 profile 字段的草图进行解析。 - - 支持 profile_from 字段:引用另一个草图的 profile,避免重复。 - 例:sk_05: {"profile_from": "sk_03"} → 使用 sk_03 的 profile。 - """ - geom = cdsl.get("geometry") or {} - sketches = geom.get("sketches") or [] - - # 第一遍: 解析所有有自己 profile 的草图 - resolved: dict[str, dict] = {} - for sk in sketches: - sid = sk.get("id") - if sid is None: - continue - if "profile" in sk: - resolved[sid] = resolve_profile(sk) - - # 第二遍: 解析 profile_from 引用(支持 profile_shift 偏移) - for sk in sketches: - sid = sk.get("id") - pf = sk.get("profile_from") - if pf and sid: - src = resolved.get(pf) - if src is None: - raise ValueError( - f"sketch {sid}: profile_from={pf!r} not found or not yet resolved" - ) - sk2 = deepcopy(sk) - sk2["profile"] = deepcopy(src.get("profile")) - sk2.pop("profile_from", None) - - # profile_shift: 对 polygon 顶点做 2D 偏移(同形异构共享) - shift = sk.get("profile_shift") - if shift and len(shift) == 2 and sk2["profile"].get("type") == "polygon": - du, dv = float(shift[0]), float(shift[1]) - for v in sk2["profile"]["vertices"]: - v[0] = round(v[0] + du, 6) - v[1] = round(v[1] + dv, 6) - sk2.pop("profile_shift", None) - resolved[sid] = resolve_profile(sk2) - - # 按原顺序输出 - result = [] - for sk in sketches: - sid = sk.get("id") - if sid and sid in resolved: - result.append(resolved[sid]) - else: - result.append(deepcopy(sk)) - - out = deepcopy(cdsl) - out.setdefault("geometry", {})["sketches"] = result - return out +def _shift_profile(sketch: _Ctx, source: _Ctx) -> _Ctx: + output = deepcopy(sketch) + output["profile"] = deepcopy(source["profile"]) + output.pop("profile_from", None) + shift = sketch.get("profile_shift") + if shift and len(shift) == 2 and output["profile"].get("type") == "polygon": + for vertex in output["profile"]["vertices"]: + vertex[0], vertex[1] = round(float(vertex[0]) + float(shift[0]), 6), round(float(vertex[1]) + float(shift[1]), 6) + output.pop("profile_shift", None) + return output -def resolve_required_sketches( - cdsl: dict[str, Any], - sketch_ids: Iterable[str], - *, - errors: dict[str, str] | None = None, -) -> dict[str, Any]: - """Resolve only profiles that an executable feature actually consumes. +def resolve_all_sketches(cdsl: _Ctx) -> _Ctx: + sketches = list((cdsl.get("geometry") or {}).get("sketches") or []) + resolved: dict[str, _Ctx] = {} + for sketch in sketches: + sketch_id = sketch.get("id") + if sketch_id is not None and "profile" in sketch: + resolved[str(sketch_id)] = resolve_profile(sketch) + for sketch in sketches: + sketch_id, source_id = sketch.get("id"), sketch.get("profile_from") + if sketch_id is not None and source_id: + source = resolved.get(str(source_id)) + if source is None: + raise ValueError(f"sketch {sketch_id}: profile_from={source_id!r} not found or not yet resolved") + resolved[str(sketch_id)] = resolve_profile(_shift_profile(sketch, source)) + output = deepcopy(cdsl) + output.setdefault("geometry", {})["sketches"] = [resolved.get(str(sketch.get("id")), deepcopy(sketch)) for sketch in sketches] + return output - ``profile_from`` dependencies are resolved recursively. Callers that - pass ``errors`` get feature-addressable failures without losing unrelated - resolved sketches; callers that omit it retain the strict exception - behavior useful to profile tooling. - """ + +def resolve_required_sketches(cdsl: _Ctx, sketch_ids: Iterable[str], *, errors: dict[str, str] | None = None) -> _Ctx: sketches = list((cdsl.get("geometry") or {}).get("sketches") or []) by_id = {str(sketch.get("id")): sketch for sketch in sketches if sketch.get("id") is not None} - resolved: dict[str, dict[str, Any]] = {} + resolved: dict[str, _Ctx] = {} resolving: set[str] = set() - def resolve_one(sketch_id: str) -> dict[str, Any]: + def resolve_one(sketch_id: str) -> _Ctx: if sketch_id in resolved: return resolved[sketch_id] sketch = by_id.get(sketch_id) @@ -2015,21 +347,7 @@ def resolve_required_sketches( if "profile" in sketch: output = resolve_profile(sketch) elif sketch.get("profile_from"): - source_id = str(sketch["profile_from"]) - source = resolve_one(source_id) - if not source.get("profile"): - raise ValueError(f"sketch {sketch_id}: profile_from={source_id!r} has no profile") - output = deepcopy(sketch) - output["profile"] = deepcopy(source["profile"]) - output.pop("profile_from", None) - shift = sketch.get("profile_shift") - if shift and len(shift) == 2 and output["profile"].get("type") == "polygon": - du, dv = float(shift[0]), float(shift[1]) - for vertex in output["profile"]["vertices"]: - vertex[0] = round(vertex[0] + du, 6) - vertex[1] = round(vertex[1] + dv, 6) - output.pop("profile_shift", None) - output = resolve_profile(output) + output = resolve_profile(_shift_profile(sketch, resolve_one(str(sketch["profile_from"])))) else: output = deepcopy(sketch) resolved[sketch_id] = output @@ -2044,10 +362,6 @@ def resolve_required_sketches( if errors is None: raise errors[sketch_id] = str(error) - output = deepcopy(cdsl) - output.setdefault("geometry", {})["sketches"] = [ - resolved.get(str(sketch.get("id")), deepcopy(sketch)) - for sketch in sketches - ] + output.setdefault("geometry", {})["sketches"] = [resolved.get(str(sketch.get("id")), deepcopy(sketch)) for sketch in sketches] return output diff --git a/backend/requirements-vision.txt b/backend/requirements-vision.txt new file mode 100644 index 00000000..c7156dd5 --- /dev/null +++ b/backend/requirements-vision.txt @@ -0,0 +1,2 @@ +-r requirements.txt +opencv-python-headless>=4.9,<5 diff --git a/backend/requirements.txt b/backend/requirements.txt index b5249183..85fbc38f 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -5,3 +5,4 @@ uvicorn[standard]>=0.30,<1 build123d python-multipart>=0.0.9,<1 jsonschema>=4.23,<5 +Pillow>=10,<12 diff --git a/backend/scripts/cleanup_legacy_attachments.py b/backend/scripts/cleanup_legacy_attachments.py new file mode 100644 index 00000000..820d7ad4 --- /dev/null +++ b/backend/scripts/cleanup_legacy_attachments.py @@ -0,0 +1,97 @@ +"""Explicitly remove attachments belonging to conversations without v2 image observations. + +The command is dry-run by default. It only mutates data when ``--apply`` is +provided, and it never removes CAD task artifacts. +""" + +from __future__ import annotations + +import argparse +import shutil +from pathlib import Path +import sys + +BACKEND_ROOT = Path(__file__).resolve().parents[1] +if str(BACKEND_ROOT) not in sys.path: + sys.path.insert(0, str(BACKEND_ROOT)) + +from app.services.storage import read_json, write_json +from app.settings import get_settings + + +def _has_v2_observation(record: dict) -> bool: + for message in record.get("messages") or []: + if not isinstance(message, dict): + continue + for part in message.get("parts") or []: + if not isinstance(part, dict) or part.get("type") != "data-cad-image-analysis": + continue + data = part.get("data") + if isinstance(data, dict) and str(data.get("schemaVersion") or data.get("schema_version") or "") == "cad.image-observation.v2": + return True + return False + + +def plan_cleanup(root: Path) -> list[tuple[Path, str]]: + planned: list[tuple[Path, str]] = [] + for conversation_dir in sorted(root.glob("conv_*")): + record_path = conversation_dir / "conversation.json" + record = read_json(record_path) + if not isinstance(record, dict) or _has_v2_observation(record): + continue + uploads = conversation_dir / "uploads" + if uploads.is_dir(): + planned.append((uploads, "legacy upload directory")) + planning = conversation_dir / "planning" + if planning.is_dir(): + planned.append((planning, "legacy planning directory")) + if record.get("attachments"): + planned.append((record_path, "remove legacy attachment metadata and image-analysis parts")) + return planned + + +def apply_cleanup(root: Path) -> int: + changed = 0 + for conversation_dir in sorted(root.glob("conv_*")): + record_path = conversation_dir / "conversation.json" + record = read_json(record_path) + if not isinstance(record, dict) or _has_v2_observation(record): + continue + for relative in ("uploads", "planning"): + target = conversation_dir / relative + if target.is_dir(): + shutil.rmtree(target) + changed += 1 + record["attachments"] = [] + for message in record.get("messages") or []: + if not isinstance(message, dict): + continue + message["parts"] = [ + part for part in message.get("parts") or [] + if not (isinstance(part, dict) and part.get("type") == "data-cad-image-analysis") + ] + write_json(record_path, record) + changed += 1 + return changed + + +def main() -> int: + parser = argparse.ArgumentParser(description="Remove legacy conversation attachments") + parser.add_argument("--apply", action="store_true", help="perform deletion; default is dry-run") + parser.add_argument("--dry-run", action="store_true", help="list deletion targets without changing files") + args = parser.parse_args() + root = get_settings().conversation_root.resolve() + if root.name != "conversations": + raise SystemExit(f"Refusing unexpected conversation root: {root}") + planned = plan_cleanup(root) + for path, reason in planned: + print(f"{'DELETE' if args.apply else 'WOULD DELETE'} {path} ({reason})") + if not args.apply: + print(f"Dry-run: {len(planned)} targets. Re-run with --apply to delete.") + return 0 + print(f"Deleted {apply_cleanup(root)} conversation records/directories.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/backend/scripts/remove_generation_spec_artifacts.py b/backend/scripts/remove_generation_spec_artifacts.py new file mode 100644 index 00000000..ec3977f4 --- /dev/null +++ b/backend/scripts/remove_generation_spec_artifacts.py @@ -0,0 +1,159 @@ +#!/usr/bin/env python3 +"""Remove retired semantic-plan artifacts from the local workspace. + +Run without arguments to list every file and metadata document that would +change. Run again with --apply to make the irreversible cleanup. +""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path +from typing import Any + + +RETIRED_FILENAMES = { + "generation-spec.json", + "generation-provenance.json", + "acceptance-report.json", +} +RETIRED_DIAGNOSTIC_PREFIX = "generation_spec_" +RETIRED_TOOL_NAMES = { + "create_generation_spec", + "author_cdsl_from_generation_spec", + "compile_generation_spec", + "patch_generation_spec", + "generate_flange_sleeve_model", +} +RETIRED_METADATA_KEYS = { + "generation_spec_path", + "generation_provenance_path", + "acceptance_path", + "approximations", + "current_design_intent_id", + "design_intents", + "design_intent_id", + "design_intent_path", + "design_intent_structures", + "design_intent_assumptions", + "design_intent_capability_gaps", + "capability_translations", + "evidence", +} + + +def _strip_metadata(value: Any) -> bool: + changed = False + if isinstance(value, list): + for item in value: + changed = _strip_metadata(item) or changed + return changed + if not isinstance(value, dict): + return False + for key in list(value): + if key in RETIRED_METADATA_KEYS or key.startswith("generation_spec_"): + value.pop(key) + changed = True + operation = value.get("operation") + if isinstance(operation, dict) and "spec" in str(operation.get("type") or "").casefold(): + value["operation"] = {} + changed = True + for key, child in list(value.items()): + if key == "source" and isinstance(child, str) and "generation_spec" in child.casefold(): + value[key] = "legacy_cdsl" + changed = True + elif key in {"message", "text"} and isinstance(child, str) and "generationspec" in child.casefold(): + value[key] = "已清理已弃用的规格流程诊断;当前任务统一使用直接 CDSL 生成。" + changed = True + else: + changed = _strip_metadata(child) or changed + return changed + + +def _retired_diagnostic(path: Path) -> bool: + if path.name.startswith(RETIRED_DIAGNOSTIC_PREFIX): + return True + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return False + if not isinstance(payload, dict): + return False + tool_name = str(payload.get("tool_name") or "") + arguments = str(payload.get("arguments") or "") + return tool_name in RETIRED_TOOL_NAMES or "generation-spec" in arguments or "generation_spec" in arguments + + +def cleanup_plan(data_root: Path) -> tuple[list[Path], list[Path]]: + files: list[Path] = [] + metadata: list[Path] = [] + task_root = data_root / "tasks" + conversation_root = data_root / "conversations" + if task_root.is_dir(): + for path in task_root.rglob("*"): + if path.is_file() and ( + path.name in RETIRED_FILENAMES + or (path.parent.name == "planning" and path.name.startswith("design-intent-")) + ): + files.append(path) + for path in task_root.rglob("*.json"): + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + continue + original = json.dumps(payload, sort_keys=True, ensure_ascii=False) + _strip_metadata(payload) + if json.dumps(payload, sort_keys=True, ensure_ascii=False) != original: + metadata.append(path) + if conversation_root.is_dir(): + for path in conversation_root.rglob("*.json"): + if _retired_diagnostic(path): + files.append(path) + continue + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + continue + original = json.dumps(payload, sort_keys=True, ensure_ascii=False) + _strip_metadata(payload) + if json.dumps(payload, sort_keys=True, ensure_ascii=False) != original: + metadata.append(path) + file_set = set(files) + return sorted(file_set), sorted(set(metadata) - file_set) + + +def apply_cleanup(data_root: Path) -> tuple[list[Path], list[Path]]: + files, metadata = cleanup_plan(data_root) + for path in files: + path.unlink() + for path in metadata: + payload = json.loads(path.read_text(encoding="utf-8")) + if _strip_metadata(payload): + path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + return files, metadata + + +def main() -> int: + parser = argparse.ArgumentParser(description="Remove retired GenerationSpec and semantic-plan artifacts") + parser.add_argument("--data-root", type=Path, default=Path(__file__).resolve().parents[1] / "data") + parser.add_argument("--apply", action="store_true", help="Delete the listed artifacts and rewrite metadata") + args = parser.parse_args() + files, metadata = cleanup_plan(args.data_root) + action = "Will remove" if not args.apply else "Removing" + for path in files: + print(f"{action} artifact: {path}") + for path in metadata: + print(f"{action} retired metadata: {path}") + if not files and not metadata: + print("No retired GenerationSpec or semantic-plan artifacts found.") + if not args.apply: + print("Dry run only. Re-run with --apply to make these changes.") + return 0 + apply_cleanup(args.data_root) + print(f"Removed {len(files)} artifacts and rewrote {len(metadata)} metadata documents.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/backend/tests/test_agent_tool_arguments.py b/backend/tests/test_agent_tool_arguments.py index 52e13bb5..82569fc2 100644 --- a/backend/tests/test_agent_tool_arguments.py +++ b/backend/tests/test_agent_tool_arguments.py @@ -5,12 +5,59 @@ import json import tempfile import unittest from pathlib import Path +from types import SimpleNamespace +from unittest.mock import patch from app.models.contracts import ChatMessage, MessagePart -from app.services.agent_service import AgentService, CDSL_TOOL_SCHEMA, RepeatedToolArgumentsError, StrictToolSchemaError, TOOL_SCHEMAS, ToolArgumentsError, parse_tool_arguments, response_language_instruction, tools_for_model, user_visible_error_message +from app.services.agent_service import AgentService, CDSL_TOOL_SCHEMA, RepeatedToolArgumentsError, StrictToolSchemaError, TOOL_SCHEMAS, ToolArgumentsError, engine_capability_manifest, get_repair_step_key, normalize_image_analysis, parse_tool_arguments, response_language_instruction, system_prompt, tools_for_model, user_visible_error_message +from app.services.engine_service import load_engine +from app.services.quality import QUALITY_RULE_TYPES from app.services.library import CdslLibrary from app.services.storage import WorkspaceStore -from app.settings import ProviderConfig, ProviderModel, Settings +from app.settings import ProviderConfig, ProviderModel, Settings, get_settings + + +class ToolChoiceCompatibilityTests(unittest.TestCase): + def test_retries_without_tool_choice_when_thinking_mode_rejects_it(self) -> None: + class FakeResponse: + def __init__(self, status_code: int, text: str, body: dict[str, object]) -> None: + self.status_code = status_code + self.text = text + self._body = body + + def json(self) -> dict[str, object]: + return self._body + + class FakeClient: + def __init__(self) -> None: + self.requests: list[dict[str, object]] = [] + self.responses = [ + FakeResponse(400, '{"error":{"message":"Thinking mode does not support this tool_choice"}}', {}), + FakeResponse(200, "", {"choices": [{"message": {"role": "assistant", "content": "ok"}}]}), + ] + + async def __aenter__(self) -> "FakeClient": + return self + + async def __aexit__(self, *args: object) -> None: + return None + + async def post(self, _url: str, *, headers: dict[str, str], json: dict[str, object]) -> FakeResponse: + self.requests.append(dict(json)) + return self.responses.pop(0) + + agent = object.__new__(AgentService) + agent.settings = SimpleNamespace(llm_timeout_s=1) + client = FakeClient() + provider = ProviderConfig("deepseek", "DeepSeek", "https://example.invalid/v1", "test-key", (ProviderModel("deepseek-v4-flash-vision-exp", vision=True),)) + model = provider.models[0] + + with patch("app.services.agent_service.httpx.AsyncClient", return_value=client): + response = asyncio.run(agent._complete([], [], provider, model, "analyze_image_reference")) + + self.assertEqual(response["choices"][0]["message"]["content"], "ok") + self.assertEqual(client.requests[0]["tool_choice"], {"type": "function", "function": {"name": "analyze_image_reference"}}) + self.assertNotIn("tool_choice", client.requests[1]) class ParseToolArgumentsTests(unittest.TestCase): @@ -32,6 +79,38 @@ class ParseToolArgumentsTests(unittest.TestCase): with self.assertRaisesRegex(ToolArgumentsError, "JSON object"): parse_tool_arguments('["not", "tool arguments"]') + +class RepairStepKeyTests(unittest.TestCase): + def test_generation_and_patch_share_the_same_feature_step_budget(self) -> None: + state = { + "phase": "CDSL_REPAIR", + "feature_plan": { + "plan_id": "plan_1", + "nodes": [ + {"id": "boss", "status": "ready"}, + {"id": "hole", "status": "waiting_for_selection"}, + ], + }, + } + + self.assertEqual( + get_repair_step_key(state, "generate_cdsl_model"), + get_repair_step_key(state, "patch_cdsl_model"), + ) + + def test_completed_nodes_do_not_change_the_active_step_key(self) -> None: + state = { + "phase": "CDSL_REPAIR", + "feature_plan": { + "plan_id": "plan_1", + "nodes": [ + {"id": "base", "status": "completed"}, + {"id": "boss", "status": "ready"}, + ], + }, + } + self.assertEqual(get_repair_step_key(state, "generate_cdsl_model"), "plan:plan_1:boss") + def test_recovers_only_the_known_premature_cdsl_wrapper_close(self) -> None: payload = parse_tool_arguments( '{"cdsl":{"schema":"cad.cdsl.llm.v1"}}, "summary":"fixed envelope"}', @@ -41,6 +120,28 @@ class ParseToolArgumentsTests(unittest.TestCase): self.assertEqual(payload["summary"], "fixed envelope") self.assertEqual(payload["cdsl"], {"schema": "cad.cdsl.llm.v1"}) + def test_recovers_trailing_cdsl_metadata_after_a_complete_envelope(self) -> None: + payload = parse_tool_arguments( + '{"cdsl":{"schema":"cad.cdsl.llm.v1"},"summary":"fixed envelope",' + '"assumptions":["metric"]}, "summary":"repeated envelope",' + '"assumptions":["metric"]}', + recover_cdsl_wrapper=True, + ) + + self.assertEqual(payload, { + "cdsl": {"schema": "cad.cdsl.llm.v1"}, + "summary": "fixed envelope", + "assumptions": ["metric"], + }) + + def test_rejects_a_second_cdsl_payload_after_a_complete_envelope(self) -> None: + with self.assertRaisesRegex(ToolArgumentsError, "trailing content"): + parse_tool_arguments( + '{"cdsl":{"schema":"cad.cdsl.llm.v1"},"summary":"original",' + '"assumptions":[]}, "cdsl":{"schema":"different"}}', + recover_cdsl_wrapper=True, + ) + def test_does_not_recover_arbitrary_trailing_tool_content(self) -> None: with self.assertRaisesRegex(ToolArgumentsError, "trailing content"): parse_tool_arguments( @@ -62,14 +163,38 @@ class ParseToolArgumentsTests(unittest.TestCase): self.assertNotIn("extrude", cdsl["$defs"]["feature_atomic_ids"]["enum"]) self.assertEqual(cdsl, CDSL_TOOL_SCHEMA) - def test_strict_tool_schema_covers_design_intent_and_cdsl_generation_arguments(self) -> None: + def test_generation_schema_exposes_only_runtime_atomic_ids(self) -> None: + settings = get_settings() + engine = load_engine(settings) + self.assertEqual( + set(CDSL_TOOL_SCHEMA["$defs"]["feature_atomic_ids"]["enum"]), + set(engine.SUPPORTED_ATOMIC_IDS), + ) + + def test_verification_schema_exposes_only_implemented_rule_types(self) -> None: + generate_tool = next(tool for tool in TOOL_SCHEMAS if tool["function"]["name"] == "generate_cdsl_model") + verification = generate_tool["function"]["parameters"]["properties"]["verification"] + rule_type = verification["properties"]["rules"]["items"]["properties"]["type"] + self.assertEqual(set(rule_type["enum"]), set(QUALITY_RULE_TYPES)) + + def test_verification_schema_requires_feature_for_feature_scoped_rules(self) -> None: + generate_tool = next(tool for tool in TOOL_SCHEMAS if tool["function"]["name"] == "generate_cdsl_model") + rule = generate_tool["function"]["parameters"]["properties"]["verification"]["properties"]["rules"]["items"] + self.assertTrue(any("feature" in branch.get("then", {}).get("required", []) for branch in rule["allOf"])) + self.assertIn("overall_width", rule["properties"]["type"]["enum"]) + self.assertIn("overall_height", rule["properties"]["type"]["enum"]) + + def test_strict_tool_schema_covers_generation_arguments(self) -> None: tools = tools_for_model(ProviderModel("strict-model", strict_tool_schema=True)) strict_tools = [tool["function"]["name"] for tool in tools if tool["function"].get("strict")] - self.assertEqual(strict_tools, ["propose_design_intent", "generate_cdsl_model"]) + self.assertEqual(strict_tools, ["generate_cdsl_model", "patch_cdsl_model"]) generate_tool = next(tool for tool in tools if tool["function"]["name"] == "generate_cdsl_model") self.assertEqual(generate_tool["function"]["parameters"]["properties"]["summary"], {"type": "string", "minLength": 1}) self.assertEqual(generate_tool["function"]["parameters"]["properties"]["cdsl"], CDSL_TOOL_SCHEMA) + self.assertIn("verification", generate_tool["function"]["parameters"]["properties"]) + patch_tool = next(tool for tool in tools if tool["function"]["name"] == "patch_cdsl_model") + self.assertIn("base_revision_id", patch_tool["function"]["parameters"]["required"]) self.assertFalse(generate_tool["function"]["parameters"]["additionalProperties"]) def test_default_model_does_not_receive_strict_tool_schema(self) -> None: @@ -77,6 +202,31 @@ class ParseToolArgumentsTests(unittest.TestCase): self.assertFalse(any(tool["function"].get("strict") for tool in tools)) + def test_direct_cdsl_tools_exclude_spec_and_template_generators(self) -> None: + names = [tool["function"]["name"] for tool in tools_for_model(ProviderModel("default-model"))] + self.assertIn("generate_cdsl_model", names) + self.assertIn("patch_cdsl_model", names) + self.assertNotIn("create_generation_spec", names) + self.assertNotIn("author_cdsl_from_generation_spec", names) + self.assertNotIn("patch_generation_spec", names) + self.assertNotIn("generate_flange_sleeve_model", names) + + def test_recorded_image_analysis_is_not_exposed_as_a_tool(self) -> None: + tools = tools_for_model(ProviderModel("vision-model", vision=True), include_image_analysis=False) + + self.assertNotIn("analyze_image_reference", [tool["function"]["name"] for tool in tools]) + + def test_image_analysis_allows_no_dimension_candidates(self) -> None: + analysis_tool = next(tool for tool in TOOL_SCHEMAS if tool["function"]["name"] == "analyze_image_reference") + self.assertNotIn("dimension_candidates", analysis_tool["function"]["parameters"]["required"]) + + result = normalize_image_analysis({ + "part_type": "压铸外壳", + "visible_features": ["圆角矩形外轮廓"], + "uncertain_features": [], + }) + self.assertEqual(result["dimension_candidates"], []) + def test_strict_schema_rejection_is_localized_for_chinese_requests(self) -> None: message = user_visible_error_message( StrictToolSchemaError("provider rejected strict schema"), @@ -243,6 +393,332 @@ class ToolArgumentsRetryTests(unittest.TestCase): self.assertTrue(any("已修正工具参数" in str(event.get("text", "")) for event in events)) self.assertEqual(list(settings.task_root.glob("cad_*")), []) + def test_persists_every_cdsl_attempt_and_validation_failure(self) -> None: + class InvalidCdslAgent(AgentService): + def __init__(self, *args: object, **kwargs: object) -> None: + super().__init__(*args, **kwargs) + plan_call = { + "id": "design_brief", + "type": "function", + "function": { + "name": "describe_design_intent", + "arguments": json.dumps({"plan": "建立一个法兰。", "assumptions": []}), + }, + } + invalid_call = { + "id": "invalid_cdsl", + "type": "function", + "function": { + "name": "generate_cdsl_model", + "arguments": json.dumps({"cdsl": {}, "summary": "无效法兰", "assumptions": []}), + }, + } + self.responses = [ + {"choices": [{"message": {"role": "assistant", "content": "", "tool_calls": [plan_call]}}]}, + *[ + {"choices": [{"message": {"role": "assistant", "content": "", "tool_calls": [invalid_call]}}]} + for _ in range(7) + ], + ] + + async def _complete(self, *args: object, **kwargs: object) -> dict[str, object]: + return self.responses.pop(0) + + backend_root = Path(__file__).resolve().parents[1] + with tempfile.TemporaryDirectory() as temporary_directory: + temporary_root = Path(temporary_directory) + provider = ProviderConfig("test", "Test", "https://example.invalid/v1", "test-key", (ProviderModel("test-model"),)) + settings = Settings( + task_root=temporary_root / "tasks", + conversation_root=temporary_root / "conversations", + library_root=backend_root / "cdsl_library", + engine_root=backend_root / "engine" / "cdsl_engine", + llm_base_url=provider.base_url, + llm_api_key=provider.api_key, + llm_model="test-model", + llm_timeout_s=1, + default_provider_id="test", + providers=(provider,), + ) + store = WorkspaceStore(settings) + agent = InvalidCdslAgent(settings, store, CdslLibrary(settings)) + conversation_id = "conv_000000000004" + message = ChatMessage(id="user_invalid_cdsl", role="user", parts=[MessagePart(type="text", text="生成一个法兰")]) + + async def collect_events() -> list[dict[str, object]]: + events: list[dict[str, object]] = [] + async for chunk in agent.stream([message], conversation_id, None): + events.append(json.loads(chunk.decode("utf-8").split("data: ", 1)[1])) + return events + + events = asyncio.run(collect_events()) + + diagnostics = settings.conversation_root / conversation_id / "diagnostics" + attempts = sorted(diagnostics.glob("cdsl_attempt_*.json")) + failures = sorted(diagnostics.glob("cdsl_validation_*.json")) + self.assertEqual(len(attempts), 5) + self.assertEqual(len(failures), 5) + self.assertTrue(all(json.loads(path.read_text(encoding="utf-8")) == {} for path in attempts)) + + records = sorted( + (json.loads(path.read_text(encoding="utf-8")) for path in failures), + key=lambda record: int(record["iteration"]), + ) + self.assertEqual([record["iteration"] for record in records], list(range(2, 7))) + self.assertTrue(all(record["kind"] == "cdsl_validation_failure" for record in records)) + self.assertTrue(all(record["validation_error_type"] == "ValueError" for record in records)) + self.assertTrue(all(record["validation_error"] for record in records)) + self.assertEqual( + {Path(record["cdsl_attempt_path"]).name for record in records}, + {path.name for path in attempts}, + ) + self.assertTrue(any("每次 CDSL 校验失败的诊断已保存到" in str(event.get("message", "")) for event in events)) + self.assertEqual(len(agent.responses), 2) + self.assertEqual(list(settings.task_root.glob("cad_*")), []) + + +class ImageReferenceIntakeTests(unittest.TestCase): + @staticmethod + def _settings(temporary_root: Path, *, vision: bool = True) -> Settings: + backend_root = Path(__file__).resolve().parents[1] + provider = ProviderConfig( + "test", + "Test", + "https://example.invalid/v1", + "test-key", + (ProviderModel("vision-model", vision=vision),), + ) + return Settings( + task_root=temporary_root / "tasks", + conversation_root=temporary_root / "conversations", + library_root=backend_root / "cdsl_library", + engine_root=backend_root / "engine" / "cdsl_engine", + llm_base_url=provider.base_url, + llm_api_key=provider.api_key, + llm_model="vision-model", + llm_timeout_s=1, + default_provider_id="test", + providers=(provider,), + ) + + @staticmethod + def _add_image_attachment(store: WorkspaceStore, conversation_id: str) -> str: + store.ensure_conversation(conversation_id) + relative_path, _ = store.write_conversation_upload(conversation_id, "flange.png", b"image-bytes") + store.add_conversation_attachment(conversation_id, { + "id": "upload_flange", + "conversation_id": conversation_id, + "name": "flange.png", + "kind": "image", + "path": relative_path, + "mime": "image/png", + }) + return "upload_flange" + + @staticmethod + def _analysis_arguments() -> dict[str, object]: + return { + "part_type": "四孔法兰套筒", + "visible_features": ["中空圆筒", "四孔法兰", "螺栓孔"], + "uncertain_features": ["法兰背面可能有沉孔"], + "dimension_candidates": [ + {"id": "bore_diameter", "label": "中心孔直径", "reason": "图片没有标注内径"}, + {"id": "bolt_circle", "label": "螺栓孔中心距", "reason": "透视图无法确定孔距"}, + ], + } + + def test_image_request_keeps_structured_analysis_when_model_asks_a_question(self) -> None: + class ImageIntakeAgent(AgentService): + def __init__(self, *args: object, **kwargs: object) -> None: + super().__init__(*args, **kwargs) + self.required_tools: list[str | None] = [] + self.responses = [ + {"choices": [{"message": { + "role": "assistant", + "content": "", + "tool_calls": [{ + "id": "image_analysis", + "type": "function", + "function": { + "name": "analyze_image_reference", + "arguments": json.dumps(ImageReferenceIntakeTests._analysis_arguments()), + }, + }], + }}]}, + {"choices": [{"message": { + "role": "assistant", + "content": "中心孔直径会显著影响零件用途,请确认这个尺寸。", + "tool_calls": [], + }}]}, + ] + + async def _complete(self, *args: object, **kwargs: object) -> dict[str, object]: + self.required_tools.append(kwargs.get("required_tool_name") if "required_tool_name" in kwargs else args[4] if len(args) > 4 else None) + return self.responses.pop(0) + + with tempfile.TemporaryDirectory() as temporary_directory: + temporary_root = Path(temporary_directory) + settings = self._settings(temporary_root) + store = WorkspaceStore(settings) + conversation_id = "conv_000000000001" + self._add_image_attachment(store, conversation_id) + agent = ImageIntakeAgent(settings, store, CdslLibrary(settings)) + message = ChatMessage(id="user_image", role="user", parts=[MessagePart(type="text", text="生成图片中的模型")]) + + async def collect_events() -> list[dict[str, object]]: + events: list[dict[str, object]] = [] + async for chunk in agent.stream([message], conversation_id, None): + events.append(json.loads(chunk.decode("utf-8").split("data: ", 1)[1])) + return events + + events = asyncio.run(collect_events()) + conversation = store.read_conversation(conversation_id) + assistant_parts = conversation["messages"][-1]["parts"] + + self.assertEqual(agent.required_tools, ["analyze_image_reference", None]) + self.assertEqual(agent.responses, []) + self.assertTrue(any(event.get("partType") == "四孔法兰套筒" for event in events)) + self.assertTrue(any("中心孔直径" in str(event.get("text", "")) for event in events)) + self.assertEqual([part["type"] for part in assistant_parts], ["data-cad-image-analysis", "text"]) + self.assertEqual(assistant_parts[0]["data"]["attachmentIds"], ["upload_flange"]) + self.assertEqual(list(settings.task_root.glob("cad_*")), []) + + def test_model_can_continue_to_generation_after_initial_analysis(self) -> None: + class EstimateAgent(AgentService): + def __init__(self, *args: object, **kwargs: object) -> None: + super().__init__(*args, **kwargs) + self.tool_sets: list[list[str]] = [] + self.tool_calls: list[str] = [] + self.responses = [ + {"choices": [{"message": {"role": "assistant", "content": "", "tool_calls": [{ + "id": "image_analysis", "type": "function", "function": { + "name": "analyze_image_reference", + "arguments": json.dumps(ImageReferenceIntakeTests._analysis_arguments()), + }, + }]}}]}, + {"choices": [{"message": {"role": "assistant", "content": "", "tool_calls": [{ + "id": "design_brief", "type": "function", "function": { + "name": "describe_design_intent", + "arguments": json.dumps({ + "plan": "按图片比例建立法兰套筒。", + "assumptions": ["所有未标注尺寸按图片比例估算,单位为 mm。"], + }), + }, + }]}}]}, + {"choices": [{"message": {"role": "assistant", "content": "", "tool_calls": [{ + "id": "build_cdsl", "type": "function", "function": { + "name": "generate_cdsl_model", + "arguments": json.dumps({"cdsl": {}, "summary": "估算尺寸的法兰套筒", "assumptions": ["尺寸按比例估算"]}), + }, + }]}}]}, + {"choices": [{"message": {"role": "assistant", "content": "已按图片比例估算尺寸并生成模型。", "tool_calls": []}}]}, + ] + + async def _complete(self, messages: list[dict[str, object]], tools: list[dict[str, object]], *args: object, **kwargs: object) -> dict[str, object]: + self.tool_sets.append([str(tool["function"]["name"]) for tool in tools]) + return self.responses.pop(0) + + async def _run_tool(self, name: str, arguments: dict[str, object], *args: object, **kwargs: object) -> tuple[dict[str, object], dict[str, object] | None]: + self.tool_calls.append(name) + if name == "generate_cdsl_model": + return {"ok": True, "summary": "估算尺寸的法兰套筒"}, None + return await super()._run_tool(name, arguments, *args, **kwargs) + + with tempfile.TemporaryDirectory() as temporary_directory: + settings = self._settings(Path(temporary_directory)) + store = WorkspaceStore(settings) + conversation_id = "conv_000000000002" + self._add_image_attachment(store, conversation_id) + agent = EstimateAgent(settings, store, CdslLibrary(settings)) + message = ChatMessage(id="user_estimate", role="user", parts=[MessagePart(type="text", text="根据图片直接推进建模,比例上的不确定性按合理工程判断处理。")]) + + async def collect_events() -> list[dict[str, object]]: + events: list[dict[str, object]] = [] + async for chunk in agent.stream([message], conversation_id, None): + events.append(json.loads(chunk.decode("utf-8").split("data: ", 1)[1])) + return events + + events = asyncio.run(collect_events()) + assistant_parts = store.read_conversation(conversation_id)["messages"][-1]["parts"] + + self.assertEqual(agent.tool_calls, ["analyze_image_reference", "describe_design_intent", "generate_cdsl_model"]) + self.assertIn("analyze_image_reference", agent.tool_sets[0]) + self.assertTrue(all("analyze_image_reference" not in tool_set for tool_set in agent.tool_sets[1:])) + self.assertEqual([part["type"] for part in assistant_parts], ["data-cad-image-analysis", "text"]) + self.assertFalse(any("请补充以下尺寸" in str(event.get("text", "")) for event in events)) + + def test_recorded_analysis_reuses_context_without_reanalyzing(self) -> None: + class RecordedEstimateAgent(AgentService): + def __init__(self, *args: object, **kwargs: object) -> None: + super().__init__(*args, **kwargs) + self.tool_sets: list[list[str]] = [] + self.tool_calls: list[str] = [] + self.responses = [ + {"choices": [{"message": {"role": "assistant", "content": "", "tool_calls": [{ + "id": "design_brief", "type": "function", "function": { + "name": "describe_design_intent", + "arguments": json.dumps({"plan": "按既有识别结果建立法兰套筒。", "assumptions": ["尺寸按图片比例估算"]}), + }, + }]}}]}, + {"choices": [{"message": {"role": "assistant", "content": "", "tool_calls": [{ + "id": "build_cdsl", "type": "function", "function": { + "name": "generate_cdsl_model", + "arguments": json.dumps({"cdsl": {}, "summary": "估算尺寸的法兰套筒", "assumptions": ["尺寸按比例估算"]}), + }, + }]}}]}, + {"choices": [{"message": {"role": "assistant", "content": "已按已有识别结果继续生成模型。", "tool_calls": []}}]}, + ] + + async def _complete(self, messages: list[dict[str, object]], tools: list[dict[str, object]], *args: object, **kwargs: object) -> dict[str, object]: + self.tool_sets.append([str(tool["function"]["name"]) for tool in tools]) + return self.responses.pop(0) + + async def _run_tool(self, name: str, arguments: dict[str, object], *args: object, **kwargs: object) -> tuple[dict[str, object], dict[str, object] | None]: + self.tool_calls.append(name) + if name == "generate_cdsl_model": + return {"ok": True, "summary": "估算尺寸的法兰套筒"}, None + return await super()._run_tool(name, arguments, *args, **kwargs) + + with tempfile.TemporaryDirectory() as temporary_directory: + settings = self._settings(Path(temporary_directory)) + store = WorkspaceStore(settings) + conversation_id = "conv_000000000003" + attachment_id = self._add_image_attachment(store, conversation_id) + analysis = self._analysis_arguments() + store.append_conversation_message(conversation_id, { + "id": "assistant_previous_analysis", + "role": "assistant", + "parts": [{"type": "data-cad-image-analysis", "data": { + "attachmentIds": [attachment_id], + "partType": analysis["part_type"], + "visibleFeatures": analysis["visible_features"], + "uncertainFeatures": analysis["uncertain_features"], + "dimensionCandidates": analysis["dimension_candidates"], + }}], + }) + agent = RecordedEstimateAgent(settings, store, CdslLibrary(settings)) + message = ChatMessage(id="user_estimate_again", role="user", parts=[MessagePart(type="text", text="请继续,未标注处按你的工程判断处理。")]) + + async def collect_events() -> list[dict[str, object]]: + events: list[dict[str, object]] = [] + async for chunk in agent.stream([message], conversation_id, None): + events.append(json.loads(chunk.decode("utf-8").split("data: ", 1)[1])) + return events + + events = asyncio.run(collect_events()) + conversation = store.read_conversation(conversation_id) + analysis_parts = [ + part + for item in conversation["messages"] + for part in item["parts"] + if part["type"] == "data-cad-image-analysis" + ] + + self.assertEqual(agent.tool_calls, ["describe_design_intent", "generate_cdsl_model"]) + self.assertTrue(all("analyze_image_reference" not in tool_set for tool_set in agent.tool_sets)) + self.assertEqual(len(analysis_parts), 1) + self.assertFalse(any("请补充以下尺寸" in str(event.get("text", "")) for event in events)) + def test_direct_cdsl_generation_is_rejected_without_creating_a_task(self) -> None: class RetryAgent(AgentService): def __init__(self, *args: object, **kwargs: object) -> None: @@ -328,10 +804,95 @@ class ToolArgumentsRetryTests(unittest.TestCase): tool_result = agent.seen_messages[1][-1] self.assertEqual(tool_result["role"], "tool") - self.assertEqual(json.loads(str(tool_result["content"]))["code"], "DESIGN_INTENT_REQUIRED") + self.assertEqual(json.loads(str(tool_result["content"]))["code"], "DESIGN_BRIEF_REQUIRED") self.assertEqual(agent.required_tools, [None, None, None]) self.assertEqual(list(settings.task_root.glob("cad_*")), []) +class StructuredResultResponseTests(unittest.TestCase): + def test_structured_result_does_not_add_a_duplicate_success_message(self) -> None: + class StructuredResultAgent(AgentService): + def __init__(self, *args: object, **kwargs: object) -> None: + super().__init__(*args, **kwargs) + self.responses = [ + {"choices": [{"message": {"role": "assistant", "content": "", "tool_calls": [{ + "id": "design_brief", + "type": "function", + "function": { + "name": "describe_design_intent", + "arguments": json.dumps({"plan": "建立带中心孔的法兰。", "assumptions": []}), + }, + }]}}]}, + {"choices": [{"message": {"role": "assistant", "content": "", "tool_calls": [{ + "id": "build_cdsl", + "type": "function", + "function": { + "name": "generate_cdsl_model", + "arguments": json.dumps({"cdsl": {}, "summary": "带中心孔的法兰", "assumptions": []}), + }, + }]}}]}, + {"choices": [{"message": {"role": "assistant", "content": "", "tool_calls": []}}]}, + ] + + async def _complete(self, *args: object, **kwargs: object) -> dict[str, object]: + return self.responses.pop(0) + + async def _run_tool(self, name: str, *args: object, **kwargs: object) -> tuple[dict[str, object], dict[str, object] | None]: + if name == "describe_design_intent": + return {"ok": True, "summary": "设计说明已记录"}, None + if name == "generate_cdsl_model": + return {"ok": True, "summary": "带中心孔的法兰"}, { + "task_id": "cad_000000000001", + "revision_id": "rev_001", + "cdsl_path": "model.cdsl.json", + "step_path": "model.step", + "glb_path": "model.glb", + "report_path": "report.json", + "summary": "带中心孔的法兰", + "reference_ids": [], + "engine": "cdsl_only", + } + raise AssertionError(f"unexpected tool: {name}") + + backend_root = Path(__file__).resolve().parents[1] + with tempfile.TemporaryDirectory() as temporary_directory: + temporary_root = Path(temporary_directory) + provider = ProviderConfig("test", "Test", "https://example.invalid/v1", "test-key", (ProviderModel("test-model"),)) + settings = Settings( + task_root=temporary_root / "tasks", + conversation_root=temporary_root / "conversations", + library_root=backend_root / "cdsl_library", + engine_root=backend_root / "engine" / "cdsl_engine", + llm_base_url=provider.base_url, + llm_api_key=provider.api_key, + llm_model="test-model", + llm_timeout_s=1, + default_provider_id="test", + providers=(provider,), + ) + store = WorkspaceStore(settings) + agent = StructuredResultAgent(settings, store, CdslLibrary(settings)) + message = ChatMessage(id="user_result", role="user", parts=[MessagePart(type="text", text="生成一个带中心孔的法兰")]) + + async def collect_events() -> list[dict[str, object]]: + events: list[dict[str, object]] = [] + async for chunk in agent.stream([message], None, None): + events.append(json.loads(chunk.decode("utf-8").split("data: ", 1)[1])) + return events + + events = asyncio.run(collect_events()) + conversation_id = store.read_conversation(next(settings.conversation_root.iterdir()).name)["conversation_id"] + assistant_parts = store.read_conversation(conversation_id)["messages"][-1]["parts"] + diagnostics = settings.conversation_root / conversation_id / "diagnostics" + attempts = list(diagnostics.glob("cdsl_attempt_*.json")) + + self.assertEqual([part["type"] for part in assistant_parts], ["data-cad-result"]) + self.assertTrue(any(event.get("taskId") == "cad_000000000001" for event in events)) + self.assertFalse(any("已生成:" in str(event.get("text", "")) for event in events)) + self.assertEqual(len(attempts), 1) + self.assertEqual(json.loads(attempts[0].read_text(encoding="utf-8")), {}) + self.assertEqual(list(diagnostics.glob("cdsl_validation_*.json")), []) + + if __name__ == "__main__": unittest.main() diff --git a/backend/tests/test_conversation_attachments.py b/backend/tests/test_conversation_attachments.py new file mode 100644 index 00000000..2815442a --- /dev/null +++ b/backend/tests/test_conversation_attachments.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +import tempfile +import unittest +from pathlib import Path + +from app.services.attachments import attachment_record +from app.services.storage import WorkspaceStore +from app.settings import ProviderConfig, ProviderModel, Settings + + +class ConversationAttachmentStorageTests(unittest.TestCase): + def setUp(self) -> None: + self.temporary_directory = tempfile.TemporaryDirectory() + temporary_root = Path(self.temporary_directory.name) + backend_root = Path(__file__).resolve().parents[1] + provider = ProviderConfig("test", "Test", "https://example.invalid/v1", "test-key", (ProviderModel("test-model"),)) + self.settings = Settings( + task_root=temporary_root / "tasks", + conversation_root=temporary_root / "conversations", + library_root=backend_root / "cdsl_library", + engine_root=backend_root / "engine" / "cdsl_engine", + llm_base_url=provider.base_url, + llm_api_key=provider.api_key, + llm_model="test-model", + llm_timeout_s=1, + default_provider_id="test", + providers=(provider,), + ) + self.store = WorkspaceStore(self.settings) + self.conversation_id = "conv_000000000001" + self.store.ensure_conversation(self.conversation_id) + + def tearDown(self) -> None: + self.temporary_directory.cleanup() + + def test_upload_is_owned_by_conversation_without_creating_a_task(self) -> None: + relative_path, target = self.store.write_conversation_upload(self.conversation_id, "reference.png", b"reference-bytes") + attachment = attachment_record(self.conversation_id, "reference.png", "image/png", relative_path, b"reference-bytes", "image") + conversation = self.store.add_conversation_attachment(self.conversation_id, attachment) + + self.assertEqual(target, (self.settings.conversation_root / self.conversation_id / relative_path).resolve()) + self.assertTrue(target.is_file()) + self.assertEqual(conversation["attachments"], [attachment]) + self.assertEqual(list(self.settings.task_root.glob("cad_*")), []) + + def test_attachment_cannot_be_linked_to_another_conversation(self) -> None: + relative_path, _ = self.store.write_conversation_upload(self.conversation_id, "reference.png", b"reference-bytes") + attachment = attachment_record("conv_000000000002", "reference.png", "image/png", relative_path, b"reference-bytes", "image") + + with self.assertRaisesRegex(ValueError, "does not belong"): + self.store.add_conversation_attachment(self.conversation_id, attachment) + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_design_intent.py b/backend/tests/test_design_intent.py deleted file mode 100644 index 67e79bd1..00000000 --- a/backend/tests/test_design_intent.py +++ /dev/null @@ -1,202 +0,0 @@ -from __future__ import annotations - -import copy -import sys -import unittest -from pathlib import Path - - -ROOT = Path(__file__).resolve().parents[2] -sys.path.insert(0, str(ROOT / "backend")) - - - -def workplane(z: float = 0.0) -> dict[str, list[float]]: - return { - "origin_mm": [0.0, 0.0, z], - "x_dir": [1.0, 0.0, 0.0], - "y_dir": [0.0, 1.0, 0.0], - "normal": [0.0, 0.0, 1.0], - } - - -def mounting_plate_intent() -> dict: - return { - "schema": "cad.cdsl.design-intent.v1", - "schema_version": "1.0", - "mode": "create", - "request": "Create a mounting plate with four holes and a center slot", - "base_revision_id": "", - "structures": [ - { - "id": "base_plate", - "cdsl_feature_id": "base_add", - "role": "base", - "purpose": "Rectangular mounting plate", - "depends_on": [], - "cdsl_strategy": { - "atomic_id": "extrude_add_blind", - "profile_type": "rectangle", - "parameter_roles": ["width_mm", "height_mm", "thickness_mm"], - "selector_roles": [], - }, - }, - { - "id": "mounting_holes", - "cdsl_feature_id": "hole_cut", - "role": "subtractive", - "purpose": "Four mounting holes", - "depends_on": ["base_plate"], - "cdsl_strategy": { - "atomic_id": "extrude_cut_blind", - "profile_type": "circle_grid", - "parameter_roles": ["diameter_mm", "count_x", "count_y"], - "selector_roles": [], - }, - }, - { - "id": "adjustment_slot", - "cdsl_feature_id": "slot_cut", - "role": "subtractive", - "purpose": "Center adjustment slot", - "depends_on": ["mounting_holes"], - "cdsl_strategy": { - "atomic_id": "extrude_cut_blind", - "profile_type": "obround", - "parameter_roles": ["length_mm", "width_mm", "depth_mm"], - "selector_roles": [], - }, - }, - ], - "feature_order": ["base_plate", "mounting_holes", "adjustment_slot"], - "assumptions": [], - "open_questions": [], - "capability_gaps": [], - "verification_expectations": [ - {"type": "feature_count", "feature_id": "mounting_holes", "expected": 4}, - {"type": "symmetry", "axis": "x"}, - {"type": "bbox", "expected_mm": {"x": 100, "y": 60, "z": 10}}, - ], - "status": "ready", - } - - -def mounting_plate_cdsl() -> dict: - return { - "schema": "cad.cdsl.llm.v1", - "schema_version": "1.0", - "kind": "part", - "part_id": "mounting-plate", - "geometry": { - "sketches": [ - {"id": "base_sketch", "workplane": workplane(), "profile": {"type": "rectangle", "center": [0, 0], "width_mm": 100, "height_mm": 60}}, - {"id": "holes_sketch", "workplane": workplane(10), "profile": {"type": "circle_grid", "radius_mm": 3, "count_x": 2, "count_y": 2, "spacing_x_mm": 80, "spacing_y_mm": 40, "center_mm": [0, 0]}}, - {"id": "slot_sketch", "workplane": workplane(10), "profile": {"type": "obround", "center": [0, 0], "length_mm": 32, "width_mm": 10}}, - ], - }, - "features": [ - {"id": "base_add", "atomic_id": "extrude_add_blind", "depends_on": [], "sketch_id": "base_sketch", "params": {"distance_mm": 10}}, - {"id": "hole_cut", "atomic_id": "extrude_cut_blind", "depends_on": ["base_add"], "sketch_id": "holes_sketch", "params": {"distance_mm": 10, "reverse": True}}, - {"id": "slot_cut", "atomic_id": "extrude_cut_blind", "depends_on": ["hole_cut"], "sketch_id": "slot_sketch", "params": {"distance_mm": 10, "reverse": True}}, - ], - } - - -class DesignIntentValidationTests(unittest.TestCase): - @classmethod - def setUpClass(cls) -> None: - sys.path.insert(0, str(ROOT / "backend" / "engine")) - import cdsl_engine - - cls.engine = cdsl_engine - - def assert_plan_error(self, intent: dict, code: str = "INVALID_DESIGN_INTENT") -> None: - with self.assertRaises(self.engine.DesignIntentError) as context: - self.engine.validate_design_intent(intent, self.engine) - self.assertEqual(context.exception.code, code) - - def test_valid_mounting_plate_plan_and_cdsl_match(self) -> None: - intent = mounting_plate_intent() - validated = self.engine.validate_design_intent(intent, self.engine) - - self.assertEqual(validated["feature_order"], ["base_plate", "mounting_holes", "adjustment_slot"]) - self.engine.validate_intent_cdsl(validated, mounting_plate_cdsl(), self.engine) - - def test_revise_requires_current_base_revision(self) -> None: - intent = mounting_plate_intent() - intent["mode"] = "revise" - intent["base_revision_id"] = "" - self.assert_plan_error(intent) - - intent["base_revision_id"] = "rev_001" - self.engine.validate_design_intent(intent, self.engine, current_revision_id="rev_001") - with self.assertRaises(self.engine.DesignIntentError) as context: - self.engine.validate_design_intent(intent, self.engine, current_revision_id="rev_002") - self.assertEqual(context.exception.code, "INVALID_DESIGN_INTENT") - - def test_rejects_duplicate_ids_bad_order_and_unregistered_capabilities(self) -> None: - duplicate = mounting_plate_intent() - duplicate["structures"][1]["id"] = "base_plate" - self.assert_plan_error(duplicate) - - cyclic = mounting_plate_intent() - cyclic["structures"][0]["depends_on"] = ["mounting_holes"] - self.assert_plan_error(cyclic) - - unsupported = mounting_plate_intent() - unsupported["structures"][0]["cdsl_strategy"]["atomic_id"] = "thread_add" - self.assert_plan_error(unsupported) - - incomplete = mounting_plate_intent() - incomplete["feature_order"].pop() - self.assert_plan_error(incomplete) - - no_profile = mounting_plate_intent() - no_profile["structures"][0]["cdsl_strategy"].pop("profile_type") - self.assert_plan_error(no_profile) - - unknown_expectation = mounting_plate_intent() - unknown_expectation["verification_expectations"][0]["feature_id"] = "missing" - self.assert_plan_error(unknown_expectation) - - def test_blockers_and_nonblocking_approximations_are_explicit(self) -> None: - clarification = mounting_plate_intent() - clarification["open_questions"] = [{"id": "thickness", "question": "What thickness is required?", "blocking": True}] - clarification["status"] = "needs_clarification" - self.engine.validate_design_intent(clarification, self.engine) - with self.assertRaises(self.engine.DesignIntentError) as context: - self.engine.validate_intent_cdsl(clarification, mounting_plate_cdsl(), self.engine) - self.assertEqual(context.exception.code, "DESIGN_INTENT_BLOCKED") - - approximation = mounting_plate_intent() - approximation["capability_gaps"] = [{ - "code": "unsupported_thread_geometry", - "structure_id": "mounting_holes", - "message": "Only a cylindrical bore is available.", - "blocking": False, - }] - self.assert_plan_error(approximation) - approximation["assumptions"] = ["Thread geometry is approximated by cylindrical bores."] - self.engine.validate_design_intent(approximation, self.engine) - - def test_rejects_cdsl_feature_atomic_profile_and_selector_mismatches(self) -> None: - missing_feature = mounting_plate_cdsl() - missing_feature["features"].pop() - with self.assertRaises(self.engine.DesignIntentError) as context: - self.engine.validate_intent_cdsl(mounting_plate_intent(), missing_feature, self.engine) - self.assertEqual(context.exception.code, "INTENT_CDSL_MISMATCH") - - atomic = mounting_plate_cdsl() - atomic["features"][1]["atomic_id"] = "extrude_add_blind" - with self.assertRaises(self.engine.DesignIntentError): - self.engine.validate_intent_cdsl(mounting_plate_intent(), atomic, self.engine) - - profile = mounting_plate_cdsl() - profile["geometry"]["sketches"][1]["profile"]["type"] = "circles" - with self.assertRaises(self.engine.DesignIntentError): - self.engine.validate_intent_cdsl(mounting_plate_intent(), profile, self.engine) - - selector_plan = copy.deepcopy(mounting_plate_intent()) - selector_plan["structures"][2]["cdsl_strategy"]["selector_roles"] = ["host_face"] - with self.assertRaises(self.engine.DesignIntentError): - self.engine.validate_intent_cdsl(selector_plan, mounting_plate_cdsl(), self.engine) diff --git a/backend/tests/test_design_intent_flow.py b/backend/tests/test_design_intent_flow.py index 8244d67a..fae6e704 100644 --- a/backend/tests/test_design_intent_flow.py +++ b/backend/tests/test_design_intent_flow.py @@ -6,25 +6,50 @@ import sys import tempfile import unittest from pathlib import Path +from unittest.mock import patch ROOT = Path(__file__).resolve().parents[2] sys.path.insert(0, str(ROOT / "backend")) +from app.models.contracts import ChatMessage, MessagePart # noqa: E402 from app.services.agent_service import AgentService # noqa: E402 -from app.services.engine_service import load_engine # noqa: E402 from app.services.library import CdslLibrary # noqa: E402 from app.services.part_skills import PartSkillLibrary # noqa: E402 from app.services.storage import WorkspaceStore # noqa: E402 from app.settings import ProviderConfig, ProviderModel, Settings # noqa: E402 -from tests.test_design_intent import mounting_plate_cdsl, mounting_plate_intent # noqa: E402 BACKEND = ROOT / "backend" PART_SKILL_ROOT = BACKEND / "agent" / "skills" / "cad-engine" / "references" / "part-skills" -class DesignIntentFlowTests(unittest.TestCase): +def _workplane(z: float = 0.0) -> dict[str, list[float]]: + return {"origin_mm": [0.0, 0.0, z], "x_dir": [1.0, 0.0, 0.0], "y_dir": [0.0, 1.0, 0.0], "normal": [0.0, 0.0, 1.0]} + + +def mounting_plate_cdsl() -> dict: + holes = [ + {"role": "outer", "closed": True, "segments": [{"type": "circle", "center": center, "radius_mm": 3}]} + for center in [[-40, -20], [40, -20], [-40, 20], [40, 20]] + ] + return { + "schema": "cad.cdsl.llm.v1", + "schema_version": "1.0", + "kind": "part", + "part_id": "mounting-plate", + "geometry": {"sketches": [ + {"id": "base_sketch", "workplane": _workplane(), "profile": {"type": "polygon", "vertices": [[-50, -30], [50, -30], [50, 30], [-50, 30]]}}, + {"id": "holes_sketch", "workplane": _workplane(10), "profile": {"type": "analytic_contours", "contours": holes}}, + ]}, + "features": [ + {"id": "base_add", "atomic_id": "extrude_add_blind", "depends_on": [], "sketch_id": "base_sketch", "params": {"distance_mm": 10}}, + {"id": "hole_cut", "atomic_id": "extrude_cut_blind", "depends_on": ["base_add"], "sketch_id": "holes_sketch", "params": {"distance_mm": 10, "reverse": True}}, + ], + } + + +class DirectCdslFlowTests(unittest.TestCase): def settings(self, root: Path) -> Settings: provider = ProviderConfig("test", "Test", "https://example.invalid/v1", "test-key", (ProviderModel("test-model"),)) return Settings( @@ -40,85 +65,232 @@ class DesignIntentFlowTests(unittest.TestCase): providers=(provider,), ) - def test_design_intent_is_required_before_library_or_cdsl(self) -> None: + def test_design_brief_is_required_before_library_or_cdsl(self) -> None: with tempfile.TemporaryDirectory() as directory: settings = self.settings(Path(directory)) store = WorkspaceStore(settings) agent = AgentService(settings, store, CdslLibrary(settings), PartSkillLibrary(PART_SKILL_ROOT)) - state = {"phase": "WAITING_FOR_INTENT", "design_intent_id": ""} - searched, _ = asyncio.run(agent._run_tool("search_cdsl_library", {"query": "mounting plate"}, "", "mounting plate", [], intent_state=state)) - generated, _ = asyncio.run(agent._run_tool("generate_cdsl_model", {"design_intent_id": "intent_aaaaaaaaaaaa", "cdsl": {}, "summary": "x", "assumptions": []}, "", "mounting plate", [], intent_state=state)) + state = {"phase": "INTAKE", "design_brief": ""} + searched, _ = asyncio.run(agent._run_tool("search_cdsl_library", {"query": "mounting plate"}, "", "mounting plate", [], planning_state=state)) + generated, _ = asyncio.run(agent._run_tool("generate_cdsl_model", {"cdsl": {}, "summary": "x", "assumptions": []}, "", "mounting plate", [], planning_state=state)) - self.assertEqual(searched["code"], "DESIGN_INTENT_REQUIRED") - self.assertEqual(generated["code"], "DESIGN_INTENT_REQUIRED") + self.assertEqual(searched["code"], "DESIGN_BRIEF_REQUIRED") + self.assertEqual(generated["code"], "DESIGN_BRIEF_REQUIRED") self.assertEqual(list(settings.task_root.glob("cad_*")), []) - def test_ready_plan_persists_before_build_and_links_success_revision(self) -> None: + def test_generation_normalizes_legacy_llm_cdsl_before_building(self) -> None: + legacy_cdsl = { + "schema": "cad.cdsl.llm.v1", + "part_id": "legacy-flange-base", + "geometry": {"sketches": [{ + "id": "base_sketch", + "plane": "XY", + "offset_mm": 12, + "profile": {"type": "circle", "radius_mm": 20}, + }]}, + "features": [{ + "id": "base_add", + "atomic_id": "extrude_add_blind", + "sketch": "base_sketch", + "params": {"distance_mm": 8}, + }], + } + with tempfile.TemporaryDirectory() as directory: + settings = self.settings(Path(directory)) + store = WorkspaceStore(settings) + agent = AgentService(settings, store, CdslLibrary(settings), PartSkillLibrary(PART_SKILL_ROOT)) + state = {"phase": "PLANNED", "design_brief": "Create a flange base."} + captured_build: dict[str, object] = {} + + def fake_build_revision(**kwargs: object) -> dict[str, object]: + captured_build.update(kwargs) + return {"task_id": "cad_aaaaaaaaaaaa", "revision_id": "rev_001"} + + with patch("app.services.agent_service.build_revision", side_effect=fake_build_revision): + result, _ = asyncio.run(agent._run_tool( + "generate_cdsl_model", + {"cdsl": legacy_cdsl, "summary": "flange base", "assumptions": []}, + "", "Create a flange base", [], planning_state=state, + )) + + built_cdsl = captured_build["cdsl"] + self.assertTrue(result["ok"]) + self.assertEqual(built_cdsl["features"][0]["sketch_id"], "base_sketch") + self.assertEqual(built_cdsl["features"][0]["depends_on"], []) + self.assertNotIn("sketch", built_cdsl["features"][0]) + self.assertIn("workplane", built_cdsl["geometry"]["sketches"][0]) + self.assertEqual(len(result["normalization_repairs"]), 3) + + def test_successful_generation_ends_the_agent_tool_loop(self) -> None: + class CaptureAgent(AgentService): + def __init__(self, *args: object, **kwargs: object) -> None: + super().__init__(*args, **kwargs) + self.responses = [ + { + "choices": [{"message": { + "role": "assistant", "content": "", "tool_calls": [{ + "id": "brief", "type": "function", "function": { + "name": "describe_design_intent", + "arguments": json.dumps({"plan": "Create a cylindrical part.", "assumptions": []}), + }, + }], + }}], + }, + { + "choices": [{"message": { + "role": "assistant", "content": "", "tool_calls": [{ + "id": "generate", "type": "function", "function": { + "name": "generate_cdsl_model", + "arguments": json.dumps({ + "cdsl": mounting_plate_cdsl(), + "summary": "mounting plate", + "assumptions": [], + }), + }, + }], + }}], + }, + ] + + async def _complete(self, *args: object, **kwargs: object) -> dict[str, object]: + return self.responses.pop(0) + + with tempfile.TemporaryDirectory() as directory: + settings = self.settings(Path(directory)) + store = WorkspaceStore(settings) + agent = CaptureAgent(settings, store, CdslLibrary(settings), PartSkillLibrary(PART_SKILL_ROOT)) + + def fake_build_revision(**kwargs: object) -> dict[str, object]: + return { + "task_id": "cad_aaaaaaaaaaaa", "revision_id": "rev_001", + "cdsl_path": "revisions/rev_001/model.cdsl.json", + "step_path": "revisions/rev_001/model.step", + "glb_path": "revisions/rev_001/model.glb", + "report_path": "revisions/rev_001/rebuild-report.json", + "summary": str(kwargs["summary"]), "reference_ids": [], "engine": "cdsl_only", + } + + with patch("app.services.agent_service.build_revision", side_effect=fake_build_revision): + async def consume() -> None: + message = ChatMessage(id="user_1", role="user", parts=[MessagePart(type="text", text="Create a mounting plate")]) + async for _ in agent.stream([message], None, None): + pass + + asyncio.run(consume()) + + saved = store.read_conversation(next(item.name for item in settings.conversation_root.iterdir())) + parts = saved["messages"][-1]["parts"] + self.assertEqual(agent.responses, []) + self.assertTrue(any(part["type"] == "data-cad-result" for part in parts)) + self.assertFalse(any(part["type"] == "data-cad-error" for part in parts)) + + def test_text_brief_is_returned_to_model_and_cdsl_is_the_only_contract(self) -> None: + class CaptureAgent(AgentService): + def __init__(self, *args: object, **kwargs: object) -> None: + super().__init__(*args, **kwargs) + self.seen_messages: list[list[dict[str, object]]] = [] + self.responses = [ + { + "choices": [{"message": { + "role": "assistant", + "content": "", + "tool_calls": [{ + "id": "brief", + "type": "function", + "function": { + "name": "describe_design_intent", + "arguments": json.dumps({ + "plan": "Create a rectangular mounting plate, then cut four mounting holes and a center slot.", + "assumptions": ["Use millimetres."], + }), + }, + }], + }}], + }, + { + "choices": [{"message": { + "role": "assistant", + "content": "", + "tool_calls": [{ + "id": "generate", + "type": "function", + "function": { + "name": "generate_cdsl_model", + "arguments": json.dumps({ + "cdsl": mounting_plate_cdsl(), + "summary": "mounting plate", + "assumptions": ["Use millimetres."], + }), + }, + }], + }}], + }, + {"choices": [{"message": {"role": "assistant", "content": "已生成。", "tool_calls": []}}]}, + ] + + async def _complete(self, messages: list[dict[str, object]], *args: object, **kwargs: object) -> dict[str, object]: + self.seen_messages.append([dict(message) for message in messages]) + return self.responses.pop(0) + with tempfile.TemporaryDirectory() as directory: settings = self.settings(Path(directory)) store = WorkspaceStore(settings) skills = PartSkillLibrary(PART_SKILL_ROOT) - agent = AgentService(settings, store, CdslLibrary(settings), skills) - request = "Create a mounting plate with four holes and a center slot" - state = {"phase": "WAITING_FOR_INTENT", "design_intent_id": ""} - planned, _ = asyncio.run(agent._run_tool( - "propose_design_intent", - {"intent": mounting_plate_intent(), "summary": "plate plan", "assumptions": []}, - "", request, [], part_skill_selection=skills.select(request), intent_state=state, - )) + agent = CaptureAgent(settings, store, CdslLibrary(settings), skills) + captured_build: dict[str, object] = {} - self.assertTrue(planned["ok"]) - task = store.read_task(planned["task_id"]) - self.assertEqual(task["current_revision"], "") - planning_path = store.artifact_path(planned["task_id"], planned["design_intent_path"]) - self.assertTrue(planning_path.is_file()) - saved = json.loads(planning_path.read_text(encoding="utf-8")) - self.assertEqual(saved["part_skill_ids"], skills.select(request)["skill_ids"]) + def fake_build_revision(**kwargs: object) -> dict[str, object]: + captured_build.update(kwargs) + return { + "task_id": "cad_aaaaaaaaaaaa", + "revision_id": "rev_001", + "cdsl_path": "revisions/rev_001/model.cdsl.json", + "step_path": "revisions/rev_001/model.step", + "glb_path": "revisions/rev_001/model.glb", + "report_path": "revisions/rev_001/rebuild-report.json", + "summary": str(kwargs["summary"]), + "reference_ids": list(kwargs["reference_ids"]), + "engine": "cdsl_only", + } - searched, _ = asyncio.run(agent._run_tool("search_cdsl_library", {"query": "plate"}, planned["task_id"], request, [], intent_state=state)) - self.assertTrue(searched["ok"]) - result, built = asyncio.run(agent._run_tool( - "generate_cdsl_model", - {"design_intent_id": planned["design_intent_id"], "cdsl": mounting_plate_cdsl(), "summary": "plate", "assumptions": []}, - planned["task_id"], request, [], part_skill_selection=skills.select(request), intent_state=state, + with patch("app.services.agent_service.build_revision", side_effect=fake_build_revision): + async def consume() -> None: + message = ChatMessage(id="user_1", role="user", parts=[MessagePart(type="text", text="Create a mounting plate")]) + async for _ in agent.stream([message], None, None): + pass + + asyncio.run(consume()) + + brief_result = json.loads(str(agent.seen_messages[1][-1]["content"])) + self.assertEqual(brief_result["plan"], "Create a rectangular mounting plate, then cut four mounting holes and a center slot.") + self.assertNotIn("structures", brief_result) + self.assertNotIn("design_intent", captured_build) + self.assertEqual(captured_build["parent_revision_id"], "") + + def test_revision_parent_is_taken_from_the_current_successful_cdsl_revision(self) -> None: + with tempfile.TemporaryDirectory() as directory: + settings = self.settings(Path(directory)) + store = WorkspaceStore(settings) + agent = AgentService(settings, store, CdslLibrary(settings), PartSkillLibrary(PART_SKILL_ROOT)) + state = {"phase": "INTAKE", "design_brief": "", "current_model_read": True} + asyncio.run(agent._run_tool( + "describe_design_intent", + {"plan": "Increase the plate thickness and preserve the existing hole layout.", "assumptions": []}, + "cad_aaaaaaaaaaaa", "Revise the plate", [], planning_state=state, )) + store.read_task = lambda _task_id: {"current_revision": "rev_007"} # type: ignore[method-assign] + captured_build: dict[str, object] = {} + + def fake_build_revision(**kwargs: object) -> dict[str, object]: + captured_build.update(kwargs) + return {"task_id": "cad_aaaaaaaaaaaa", "revision_id": "rev_008"} + + with patch("app.services.agent_service.build_revision", side_effect=fake_build_revision): + result, _ = asyncio.run(agent._run_tool( + "generate_cdsl_model", + {"cdsl": mounting_plate_cdsl(), "summary": "revised plate", "assumptions": []}, + "cad_aaaaaaaaaaaa", "Revise the plate", [], planning_state=state, + )) self.assertTrue(result["ok"]) - self.assertIsNotNone(built) - revision = store.read_task(planned["task_id"])["revisions"][-1] - self.assertEqual(revision["design_intent_id"], planned["design_intent_id"]) - self.assertEqual(revision["design_intent_path"], planned["design_intent_path"]) - audit = json.loads(store.artifact_path(planned["task_id"], revision["part_skills_path"]).read_text(encoding="utf-8")) - self.assertEqual(audit["design_intent_id"], planned["design_intent_id"]) - self.assertEqual(audit["design_intent_assumptions"], []) - - def test_blocked_plan_is_persisted_without_a_revision_and_new_plan_supersedes_it(self) -> None: - with tempfile.TemporaryDirectory() as directory: - settings = self.settings(Path(directory)) - store = WorkspaceStore(settings) - skills = PartSkillLibrary(PART_SKILL_ROOT) - agent = AgentService(settings, store, CdslLibrary(settings), skills) - request = "Create a mounting plate" - state = {"phase": "WAITING_FOR_INTENT", "design_intent_id": ""} - blocked_intent = mounting_plate_intent() - blocked_intent["open_questions"] = [{"id": "thickness", "question": "Thickness?", "blocking": True}] - blocked_intent["status"] = "needs_clarification" - blocked, _ = asyncio.run(agent._run_tool( - "propose_design_intent", - {"intent": blocked_intent, "summary": "blocked", "assumptions": []}, - "", request, [], part_skill_selection=skills.select(request), intent_state=state, - )) - self.assertEqual(blocked["code"], "DESIGN_INTENT_BLOCKED") - task = store.read_task(blocked["task_id"]) - self.assertEqual(task["revisions"], []) - self.assertEqual(task["design_intents"][0]["status"], "pending") - - ready, _ = asyncio.run(agent._run_tool( - "propose_design_intent", - {"intent": mounting_plate_intent(), "summary": "ready", "assumptions": []}, - blocked["task_id"], request, [], part_skill_selection=skills.select(request), intent_state=state, - )) - self.assertTrue(ready["ok"]) - task = store.read_task(blocked["task_id"]) - self.assertEqual(task["design_intents"][0]["status"], "superseded") - self.assertEqual(task["design_intents"][-1]["status"], "accepted") + self.assertEqual(captured_build["parent_revision_id"], "rev_007") diff --git a/backend/tests/test_direct_cdsl_pipeline.py b/backend/tests/test_direct_cdsl_pipeline.py new file mode 100644 index 00000000..2c68cf8b --- /dev/null +++ b/backend/tests/test_direct_cdsl_pipeline.py @@ -0,0 +1,589 @@ +from __future__ import annotations + +import asyncio +import json +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "backend")) + +from app.services.agent_service import AgentService, _validate_snapshot_selectors # noqa: E402 +from app.services.cdsl_patch import CdslPatchError, apply_cdsl_patch # noqa: E402 +from app.services.engine_service import QualityVerificationError, build_revision # noqa: E402 +from app.services.library import CdslLibrary # noqa: E402 +from app.services.part_skills import PartSkillLibrary # noqa: E402 +from app.services.quality import evaluate_quality, validate_verification # noqa: E402 +from app.services.storage import WorkspaceStore, write_json # noqa: E402 +from app.settings import ProviderConfig, ProviderModel, Settings # noqa: E402 + + +BACKEND = ROOT / "backend" +PART_SKILL_ROOT = BACKEND / "agent" / "skills" / "cad-engine" / "references" / "part-skills" + + +def workplane(z: float = 0.0) -> dict[str, list[float]]: + return { + "origin_mm": [0.0, 0.0, z], + "x_dir": [1.0, 0.0, 0.0], + "y_dir": [0.0, 1.0, 0.0], + "normal": [0.0, 0.0, 1.0], + } + + +def fixture() -> dict: + return { + "schema": "cad.cdsl.llm.v1", + "schema_version": "1.0", + "kind": "part", + "part_id": "direct-cdsl-fixture", + "geometry": { + "sketches": [{ + "id": "base", + "workplane": workplane(), + "profile": {"type": "polygon", "vertices": [[-10, -5], [10, -5], [10, 5], [-10, 5]]}, + }], + }, + "features": [{ + "id": "base_add", + "atomic_id": "extrude_add_blind", + "depends_on": [], + "sketch_id": "base", + "params": {"distance_mm": 4}, + }], + } + + +def settings(root: Path) -> Settings: + provider = ProviderConfig("test", "Test", "https://example.invalid/v1", "test-key", (ProviderModel("test-model"),)) + return Settings( + task_root=root / "tasks", + conversation_root=root / "conversations", + library_root=BACKEND / "cdsl_library", + engine_root=BACKEND / "engine" / "cdsl_engine", + llm_base_url=provider.base_url, + llm_api_key=provider.api_key, + llm_model="test-model", + llm_timeout_s=1, + default_provider_id="test", + providers=(provider,), + ) + + +class PatchTests(unittest.TestCase): + def test_rfc6902_patch_updates_cdsl_without_replacing_its_root(self) -> None: + document = fixture() + patched = apply_cdsl_patch(document, [{"op": "replace", "path": "/features/0/params/distance_mm", "value": 8}]) + + self.assertEqual(patched["features"][0]["params"]["distance_mm"], 8) + self.assertEqual(document["features"][0]["params"]["distance_mm"], 4) + with self.assertRaisesRegex(CdslPatchError, "complete CDSL"): + apply_cdsl_patch(document, [{"op": "replace", "path": "", "value": {}}]) + + def test_invalid_patch_path_is_rejected(self) -> None: + with self.assertRaisesRegex(CdslPatchError, "out of range"): + apply_cdsl_patch(fixture(), [{"op": "replace", "path": "/features/3/id", "value": "x"}]) + + +class VerificationTests(unittest.TestCase): + def test_bbox_accepts_dimension_vector(self) -> None: + rules = validate_verification({"rules": [ + {"id": "size", "type": "bbox", "expected": [20, 10, 4]}, + ]}, fixture()) + report = evaluate_quality(rules, fixture(), { + "bbox_mm": {"min": [-10, -5, 0], "max": [10, 5, 4]}, + }) + + self.assertEqual(report["status"], "passed") + self.assertEqual(report["results"][0]["actual"]["dimensions"], [20.0, 10.0, 4.0]) + + def test_bbox_accepts_coordinate_ranges(self) -> None: + rules = validate_verification({"rules": [ + {"id": "range", "type": "bbox", "expected": { + "x_min": -10, "x_max": 10, "y_min": -5, "y_max": 5, "z_min": 0, "z_max": 4, + }}, + ]}, fixture()) + report = evaluate_quality(rules, fixture(), { + "bbox_mm": {"min": [-10, -5, 0], "max": [10, 5, 4]}, + }) + + self.assertEqual(report["status"], "passed") + + def test_feature_bbox_uses_owned_topology_records(self) -> None: + rules = validate_verification({"rules": [ + {"id": "base_range", "type": "bbox", "feature": "base_add", "expected": [10, 8, 2]}, + ]}, fixture()) + report = evaluate_quality(rules, fixture(), { + "bbox_mm": {"min": [0, 0, 0], "max": [20, 20, 20]}, + "topology_records": [{ + "feature_id": "base_add", "owner_feature_ids": ["base_add"], + "geometry": {"bbox_mm": [0, 0, 0, 10, 8, 2]}, + }], + }) + + self.assertEqual(report["status"], "passed") + self.assertEqual(report["results"][0]["source"], "runtime.topology_records[base_add].bbox_mm") + + def test_feature_bbox_excludes_inherited_runtime_records(self) -> None: + document = fixture() + document["features"].append({ + "id": "boss", + "atomic_id": "extrude_add_blind", + "depends_on": ["base_add"], + "sketch_id": "base", + "params": {"distance_mm": 8}, + }) + rules = validate_verification({"rules": [ + {"id": "boss_range", "type": "bbox", "feature": "boss", "expected": [8, 58, 58]}, + ]}, document) + report = evaluate_quality(rules, document, { + "bbox_mm": {"min": [0, 0, 0], "max": [128, 70, 90]}, + "topology_records": [ + { + "feature_id": "boss", "owner_feature_ids": ["base"], + "geometry": {"bbox_mm": [0, 0, 0, 128, 70, 90]}, + }, + { + "feature_id": "boss", "owner_feature_ids": ["boss"], + "geometry": {"bbox_mm": [60, -29, 16, 68, 29, 74]}, + }, + ], + }) + + self.assertEqual(report["status"], "passed") + self.assertEqual(report["results"][0]["actual"]["dimensions"], [8.0, 58.0, 58.0]) + + def test_feature_diameter_uses_target_circle(self) -> None: + document = fixture() + document["features"][0]["id"] = "boss" + document["features"][0]["sketch_id"] = "boss_sketch" + document["geometry"]["sketches"][0]["id"] = "boss_sketch" + document["geometry"]["sketches"][0]["profile"] = {"type": "circle", "center": [0, 0], "radius_mm": 29} + rules = validate_verification({"rules": [ + {"id": "boss_dia", "type": "overall_diameter", "feature": "boss", "expected": 58}, + ]}, document) + report = evaluate_quality(rules, document, { + "bbox_mm": {"min": [0, 0, 0], "max": [128, 70, 90]}, + }) + + self.assertEqual(report["status"], "passed") + self.assertEqual(report["results"][0]["actual"], 58.0) + + def test_overall_width_and_height_are_supported(self) -> None: + rules = validate_verification({"rules": [ + {"id": "width", "type": "overall_width", "expected": 70}, + {"id": "height", "type": "overall_height", "expected": 90}, + ]}, fixture()) + report = evaluate_quality(rules, fixture(), { + "bbox_mm": {"min": [-64, -35, 0], "max": [64, 35, 90]}, + }) + + self.assertEqual(report["status"], "passed") + self.assertEqual([item["actual"] for item in report["results"]], [70.0, 90.0]) + + def test_feature_scoped_verification_requires_feature(self) -> None: + with self.assertRaisesRegex(ValueError, "feature is required for hole_count"): + validate_verification({"rules": [ + {"id": "holes", "type": "hole_count", "expected": 1}, + ]}, fixture()) + + def test_bbox_shape_is_rejected_before_execution(self) -> None: + with self.assertRaisesRegex(ValueError, "expected for bbox"): + validate_verification({"rules": [ + {"id": "bad", "type": "bbox", "expected": {"width": 10}}, + ]}, fixture()) + + def test_unknown_feature_reference_is_rejected(self) -> None: + with self.assertRaisesRegex(ValueError, "must reference a CDSL feature ID"): + validate_verification({"rules": [{"id": "holes", "type": "hole_count", "feature": "missing", "expected": 4}]}, fixture()) + + def test_invalid_expected_value_is_rejected(self) -> None: + with self.assertRaisesRegex(ValueError, "expected for bbox"): + validate_verification({"rules": [{"id": "size", "type": "bbox", "expected": [10, 5]}]}, fixture()) + + def test_warning_does_not_block_generic_quality(self) -> None: + rules = validate_verification({"rules": [ + {"id": "solids", "type": "solid_count", "expected": 1, "severity": "blocking"}, + {"id": "length", "type": "overall_length", "expected": 999, "severity": "warning"}, + ]}, fixture()) + report = evaluate_quality(rules, fixture(), { + "solid_count": 1, + "bbox_mm": {"min": [-10, -5, 0], "max": [10, 5, 4]}, + }) + + self.assertEqual(report["status"], "passed") + self.assertEqual(len(report["blocking_failures"]), 0) + self.assertEqual(len(report["warnings"]), 1) + + def test_blocking_failure_requires_repair(self) -> None: + rules = validate_verification({"rules": [{"id": "solids", "type": "solid_count", "expected": 2}]}, fixture()) + report = evaluate_quality(rules, fixture(), {"solid_count": 1, "bbox_mm": {"min": [0, 0, 0], "max": [1, 1, 1]}}) + + self.assertEqual(report["status"], "failed") + self.assertEqual(report["blocking_failures"][0]["id"], "solids") + + +class AgentPatchFlowTests(unittest.TestCase): + def _seed_revision(self, store: WorkspaceStore) -> tuple[str, str]: + task = store.ensure_task(None, "Create a direct CDSL fixture") + revision_id = "rev_001" + revision_dir = store.task_dir(task["task_id"]) / "revisions" / revision_id + revision_dir.mkdir(parents=True) + write_json(revision_dir / "model.cdsl.json", fixture()) + store.update_task(task["task_id"], { + "revision_id": revision_id, + "status": "success", + "cdsl_path": f"revisions/{revision_id}/model.cdsl.json", + }) + return task["task_id"], revision_id + + def test_patch_creates_a_child_revision_from_explicit_parent(self) -> None: + with tempfile.TemporaryDirectory() as directory: + config = settings(Path(directory)) + store = WorkspaceStore(config) + task_id, parent = self._seed_revision(store) + agent = AgentService(config, store, CdslLibrary(config), PartSkillLibrary(PART_SKILL_ROOT)) + captured: dict[str, object] = {} + + def fake_build_revision(**kwargs: object) -> dict[str, object]: + captured.update(kwargs) + return {"task_id": task_id, "revision_id": "rev_002"} + + with patch("app.services.agent_service.build_revision", side_effect=fake_build_revision): + result, _ = asyncio.run(agent._run_tool( + "patch_cdsl_model", + { + "base_revision_id": parent, + "patches": [{"op": "replace", "path": "/features/0/params/distance_mm", "value": 8}], + "summary": "Increase thickness", + "assumptions": [], + }, + task_id, + "Increase thickness", + [], + planning_state={"phase": "PLANNED", "current_model_read": True}, + )) + + self.assertTrue(result["ok"]) + self.assertEqual(captured["parent_revision_id"], parent) + self.assertEqual(captured["operation"]["type"], "cdsl_patch") + self.assertEqual(captured["cdsl"]["features"][0]["params"]["distance_mm"], 8) + + def test_completed_feature_can_keep_selector_from_historical_snapshot(self) -> None: + with tempfile.TemporaryDirectory() as directory: + config = settings(Path(directory)) + store = WorkspaceStore(config) + task_id, parent = self._seed_revision(store) + parent_dir = store.task_dir(task_id) / "revisions" / parent + parent_snapshot_id = f"{task_id}/{parent}" + write_json(parent_dir / "model.topology.json", { + "schema_version": "cad.topology.v1", + "task_id": task_id, + "revision_id": parent, + "snapshot_id": parent_snapshot_id, + "records": [{"record_id": "body:base:edge:0", "kind": "edge", "executable": True, "geometry": {}}], + }) + task = store.read_task(task_id) + task["revisions"][0]["topology_path"] = f"revisions/{parent}/model.topology.json" + write_json(store.task_path(task_id), task) + + child = "rev_002" + child_dir = store.task_dir(task_id) / "revisions" / child + child_dir.mkdir(parents=True) + write_json(child_dir / "model.cdsl.json", fixture()) + write_json(child_dir / "model.topology.json", { + "schema_version": "cad.topology.v1", + "task_id": task_id, + "revision_id": child, + "snapshot_id": f"{task_id}/{child}", + "records": [{"record_id": "body:base:edge:1", "kind": "edge", "executable": True, "geometry": {}}], + }) + store.update_task(task_id, { + "revision_id": child, + "status": "success", + "cdsl_path": f"revisions/{child}/model.cdsl.json", + "topology_path": f"revisions/{child}/model.topology.json", + }) + + document = fixture() + document["features"][0]["selectors"] = [{ + "kind": "edge", + "stable_id": "body:base:edge:0", + "source": "runtime_snapshot", + "snapshot_id": parent_snapshot_id, + "confidence": 1.0, + }] + _validate_snapshot_selectors(store, task_id, document) + + document["features"][0]["selectors"][0]["snapshot_id"] = f"{task_id}/rev_999" + with self.assertRaisesRegex(ValueError, "TOPOLOGY_SNAPSHOT_STALE"): + _validate_snapshot_selectors(store, task_id, document) + + def test_topology_inspection_unlocks_waiting_plan_nodes(self) -> None: + with tempfile.TemporaryDirectory() as directory: + config = settings(Path(directory)) + store = WorkspaceStore(config) + task_id, revision_id = self._seed_revision(store) + revision_dir = store.task_dir(task_id) / "revisions" / revision_id + snapshot_id = f"{task_id}/{revision_id}" + write_json(revision_dir / "model.topology.json", { + "schema_version": "cad.topology.v1", + "task_id": task_id, + "revision_id": revision_id, + "snapshot_id": snapshot_id, + "records": [{ + "record_id": "body:base_add", + "kind": "body", + "body_id": "body:base_add", + "feature_id": "base_add", + "owner_feature_ids": ["base_add"], + "geometry": {}, + "executable": True, + }], + }) + task = store.read_task(task_id) + task["revisions"][0]["topology_path"] = f"revisions/{revision_id}/model.topology.json" + write_json(store.task_path(task_id), task) + state = { + "phase": "TOPOLOGY_READY", + "feature_plan": { + "schema_version": "cad.feature-plan.v1", + "plan_id": "plan_1", + "task_id": task_id, + "nodes": [ + {"id": "base", "atomic_id": "extrude_add_blind", "depends_on": [], "cdsl_feature_ids": ["base_add"]}, + {"id": "round", "atomic_id": "fillet", "depends_on": ["base"], "cdsl_feature_ids": ["round"]}, + ], + }, + } + agent = AgentService(config, store, CdslLibrary(config), PartSkillLibrary(PART_SKILL_ROOT)) + + result, _ = asyncio.run(agent._run_tool( + "inspect_current_topology", {}, task_id, "Round the edges", [], planning_state=state, + )) + + self.assertTrue(result["ok"]) + self.assertEqual(state["feature_plan"]["topology_snapshot_id"], snapshot_id) + self.assertEqual(state["feature_plan"]["ready_nodes"], ["round"]) + + def test_successful_build_automatically_binds_topology_for_waiting_nodes(self) -> None: + with tempfile.TemporaryDirectory() as directory: + config = settings(Path(directory)) + store = WorkspaceStore(config) + agent = AgentService(config, store, CdslLibrary(config), PartSkillLibrary(PART_SKILL_ROOT)) + state = { + "phase": "PLAN_READY", + "feature_plan": { + "schema_version": "cad.feature-plan.v1", + "plan_id": "plan_1", + "task_id": "", + "nodes": [ + { + "id": "base", + "atomic_id": "extrude_add_blind", + "depends_on": [], + "cdsl_feature_ids": ["base_add"], + "status": "ready", + }, + { + "id": "round", + "atomic_id": "fillet", + "depends_on": ["base"], + "cdsl_feature_ids": ["round"], + }, + ], + }, + } + + result, _ = asyncio.run(agent._run_tool( + "generate_cdsl_model", + {"cdsl": fixture(), "summary": "Base feature", "assumptions": []}, + None, + "Create a base with a later fillet", + [], + planning_state=state, + )) + + self.assertTrue(result["ok"]) + self.assertEqual(result["required_action"], "patch_cdsl_model") + self.assertEqual(result["plan_status"]["ready_nodes"], ["round"]) + self.assertEqual(result["plan_status"]["waiting_nodes"], []) + self.assertEqual(state["feature_plan"]["topology_snapshot_id"], "{}/{}".format(result["task_id"], result["revision_id"])) + + def test_bad_patch_creates_no_revision(self) -> None: + with tempfile.TemporaryDirectory() as directory: + config = settings(Path(directory)) + store = WorkspaceStore(config) + task_id, parent = self._seed_revision(store) + agent = AgentService(config, store, CdslLibrary(config), PartSkillLibrary(PART_SKILL_ROOT)) + with self.assertRaisesRegex(ValueError, "INVALID_CDSL_PATCH"): + asyncio.run(agent._run_tool( + "patch_cdsl_model", + {"base_revision_id": parent, "patches": [{"op": "replace", "path": "/missing", "value": 8}], "summary": "bad", "assumptions": []}, + task_id, + "bad patch", + [], + planning_state={"phase": "PLANNED"}, + )) + + task = store.read_task(task_id) + self.assertEqual([item["revision_id"] for item in task["revisions"]], [parent]) + + def test_patch_rejects_non_current_parent_revision(self) -> None: + with tempfile.TemporaryDirectory() as directory: + config = settings(Path(directory)) + store = WorkspaceStore(config) + task_id, parent = self._seed_revision(store) + child = "rev_002" + child_dir = store.task_dir(task_id) / "revisions" / child + child_dir.mkdir(parents=True) + write_json(child_dir / "model.cdsl.json", fixture()) + store.update_task(task_id, { + "revision_id": child, + "status": "success", + "cdsl_path": f"revisions/{child}/model.cdsl.json", + }) + agent = AgentService(config, store, CdslLibrary(config), PartSkillLibrary(PART_SKILL_ROOT)) + with self.assertRaisesRegex(ValueError, "TOPOLOGY_SNAPSHOT_STALE"): + asyncio.run(agent._run_tool( + "patch_cdsl_model", + {"base_revision_id": parent, "patches": [{"op": "replace", "path": "/features/0/params/distance_mm", "value": 8}], "summary": "stale", "assumptions": []}, + task_id, + "stale patch", + [], + planning_state={"phase": "PLANNED"}, + )) + + def test_complete_replacement_uses_current_revision_as_parent(self) -> None: + with tempfile.TemporaryDirectory() as directory: + config = settings(Path(directory)) + store = WorkspaceStore(config) + task_id, parent = self._seed_revision(store) + agent = AgentService(config, store, CdslLibrary(config), PartSkillLibrary(PART_SKILL_ROOT)) + captured: dict[str, object] = {} + + def fake_build_revision(**kwargs: object) -> dict[str, object]: + captured.update(kwargs) + return {"task_id": task_id, "revision_id": "rev_002"} + + with patch("app.services.agent_service.build_revision", side_effect=fake_build_revision): + result, _ = asyncio.run(agent._run_tool( + "generate_cdsl_model", + {"cdsl": fixture(), "summary": "Replacement", "assumptions": []}, + task_id, + "replace geometry", + [], + planning_state={"phase": "PLANNED", "current_model_read": True}, + )) + + self.assertTrue(result["ok"]) + self.assertEqual(captured["parent_revision_id"], parent) + self.assertEqual(captured["operation"]["type"], "cdsl_replacement") + + def test_blocking_quality_failure_is_repairable_and_readable(self) -> None: + with tempfile.TemporaryDirectory() as directory: + config = settings(Path(directory)) + store = WorkspaceStore(config) + with self.assertRaises(QualityVerificationError) as context: + build_revision( + settings=config, + store=store, + task_id=None, + request="Create fixture", + cdsl=fixture(), + reference_ids=[], + summary="fixture", + verification={"rules": [{"id": "wrong-solid-count", "type": "solid_count", "expected": 2}]}, + ) + + error = context.exception + self.assertTrue(error.task_id) + self.assertEqual(error.revision_id, "rev_001") + task = store.read_task(error.task_id) + self.assertEqual(task["revisions"][0]["status"], "needs_repair") + self.assertEqual(task["revisions"][0]["quality_status"], "needs_repair") + + agent = AgentService(config, store, CdslLibrary(config), PartSkillLibrary(PART_SKILL_ROOT)) + read, _ = asyncio.run(agent._run_tool( + "read_current_cdsl", {}, error.task_id, "Repair fixture", [], planning_state={"phase": "INTAKE"} + )) + self.assertTrue(read["ok"]) + self.assertEqual(read["revision_id"], error.revision_id) + + def test_runtime_build_failure_does_not_leave_a_revision(self) -> None: + with tempfile.TemporaryDirectory() as directory: + config = settings(Path(directory)) + store = WorkspaceStore(config) + + class BrokenEngine: + def run_cdsl_only(self, _cdsl: dict, _step: Path) -> dict: + raise RuntimeError("intentional runtime failure") + + with patch("app.services.engine_service.load_engine", return_value=BrokenEngine()), patch( + "app.services.engine_service.validate_cdsl", return_value=None + ), self.assertRaisesRegex(RuntimeError, "intentional runtime failure"): + build_revision( + settings=config, + store=store, + task_id=None, + request="broken fixture", + cdsl=fixture(), + reference_ids=[], + summary="broken", + ) + + tasks = list(config.task_root.glob("cad_*")) + self.assertEqual(len(tasks), 1) + task = store.read_task(tasks[0].name) + self.assertEqual(task["revisions"], []) + + +class CleanupAndBoundaryTests(unittest.TestCase): + def test_cleanup_is_dry_run_then_removes_only_retired_artifacts(self) -> None: + with tempfile.TemporaryDirectory() as directory: + data_root = Path(directory) / "data" + task_dir = data_root / "tasks" / "cad_aaaaaaaaaaaa" / "revisions" / "rev_001" + conversation_dir = data_root / "conversations" / "conv_aaaaaaaaaaaa" / "diagnostics" + task_dir.mkdir(parents=True) + conversation_dir.mkdir(parents=True) + write_json(task_dir / "generation-spec.json", {"schema": "cad.generation-spec.v1"}) + write_json(task_dir / "model.cdsl.json", fixture()) + (task_dir / "model.step").write_bytes(b"step") + (task_dir / "model.glb").write_bytes(b"glb") + write_json(data_root / "tasks" / "cad_aaaaaaaaaaaa" / "task.json", { + "generation_spec_path": "revisions/rev_001/generation-spec.json", + "current_design_intent_id": "intent_aaaaaaaaaaaa", + "revisions": [{"acceptance_path": "revisions/rev_001/acceptance-report.json"}], + }) + write_json(conversation_dir / "generation_spec_validation_deadbeef.json", {"kind": "old"}) + + script = BACKEND / "scripts" / "remove_generation_spec_artifacts.py" + dry = subprocess.run([sys.executable, str(script), "--data-root", str(data_root)], check=True, capture_output=True, text=True) + self.assertIn("Dry run only", dry.stdout) + self.assertTrue((task_dir / "generation-spec.json").is_file()) + + subprocess.run([sys.executable, str(script), "--data-root", str(data_root), "--apply"], check=True, capture_output=True, text=True) + self.assertFalse((task_dir / "generation-spec.json").exists()) + self.assertFalse((conversation_dir / "generation_spec_validation_deadbeef.json").exists()) + self.assertTrue((task_dir / "model.cdsl.json").is_file()) + self.assertTrue((task_dir / "model.step").is_file()) + self.assertTrue((task_dir / "model.glb").is_file()) + cleaned = json.loads((data_root / "tasks" / "cad_aaaaaaaaaaaa" / "task.json").read_text(encoding="utf-8")) + self.assertNotIn("generation_spec_path", cleaned) + self.assertNotIn("current_design_intent_id", cleaned) + self.assertNotIn("acceptance_path", cleaned["revisions"][0]) + + def test_engine_does_not_import_application_or_dispatch_part_families(self) -> None: + engine_root = BACKEND / "engine" / "cdsl_engine" + source = "\n".join(path.read_text(encoding="utf-8") for path in engine_root.rglob("*.py")) + lowered = source.casefold() + + self.assertNotIn("from app", lowered) + self.assertNotIn("import app", lowered) + for business_family in ("mounting_bracket", "mounting_plate", "flange_sleeve", "bearing_housing", "hex_nut"): + self.assertNotIn(business_family, lowered) diff --git a/backend/tests/test_engine_runtime_foundation.py b/backend/tests/test_engine_runtime_foundation.py index 4d68e36c..d061e342 100644 --- a/backend/tests/test_engine_runtime_foundation.py +++ b/backend/tests/test_engine_runtime_foundation.py @@ -21,6 +21,13 @@ def _workplane() -> dict: return {"origin_mm": [0, 0, 0], "x_dir": [1, 0, 0], "normal": [0, 0, 1]} +def _rectangle(minimum: list[float], maximum: list[float]) -> dict: + return {"type": "polygon", "vertices": [ + [minimum[0], minimum[1]], [maximum[0], minimum[1]], + [maximum[0], maximum[1]], [minimum[0], maximum[1]], + ]} + + class EngineRuntimeFoundationTests(unittest.TestCase): def _base_block(self) -> dict: return { @@ -28,7 +35,7 @@ class EngineRuntimeFoundationTests(unittest.TestCase): "meta": {"unit": "mm"}, "geometry": {"sketches": [{ "id": "base", "workplane": _workplane(), - "profile": {"type": "rectangle", "center": [0, 0], "width_mm": 10, "height_mm": 10}, + "profile": _rectangle([-5, -5], [5, 5]), }]}, "features": [{ "id": "base_add", "atomic_id": "extrude_add_blind", "depends_on": [], @@ -186,6 +193,37 @@ class EngineRuntimeFoundationTests(unittest.TestCase): self.assertEqual(resolution.status, "ambiguous") self.assertEqual(resolution.diagnostic.code, "selector_ambiguous") + def test_selector_resolver_prefers_exact_runtime_stable_id(self) -> None: + registry = TopologyRegistry() + geometry = {"curve_type": "circle", "center_mm": [0, 0, 0]} + registry.register(TopologyRecord("body:b:edge:18", "edge", "boss", "body:b", geometry)) + registry.register(TopologyRecord("body:b:edge:20", "edge", "boss", "body:b", geometry)) + + resolution = registry.resolve({ + "kind": "edge", + "stable_id": "body:b:edge:18", + "owner_feature_id": "boss", + "snapshot_id": "cad_test/rev_001", + "geometry": geometry, + }, active_body_id="body:b") + + self.assertEqual(resolution.status, "resolved") + self.assertEqual(resolution.record.record_id, "body:b:edge:18") + + def test_snapshot_stable_id_rejects_geometry_that_changed(self) -> None: + registry = TopologyRegistry() + registry.register(TopologyRecord( + "body:b:edge:18", "edge", "boss", "body:b", + {"curve_type": "circle", "center_mm": [0, 0, 0]}, + )) + resolution = registry.resolve({ + "kind": "edge", "stable_id": "body:b:edge:18", "owner_feature_id": "boss", + "snapshot_id": "cad_test/rev_001", + "geometry": {"curve_type": "circle", "center_mm": [1, 0, 0]}, + }, active_body_id="body:b") + self.assertEqual(resolution.status, "not_found") + self.assertEqual(resolution.diagnostic.code, "selector_geometry_mismatch") + def test_selector_resolver_normalizes_legacy_solidworks_plane_evidence(self) -> None: registry = TopologyRegistry() registry.register(TopologyRecord( @@ -490,7 +528,7 @@ class EngineRuntimeFoundationTests(unittest.TestCase): "schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part", "part_id": "mirror-test", "meta": {"unit": "mm"}, "geometry": {"sketches": [ - {"id": "base", "workplane": _workplane(), "profile": {"type": "rectangle", "center": [0, 0], "width_mm": 10, "height_mm": 10}}, + {"id": "base", "workplane": _workplane(), "profile": _rectangle([-5, -5], [5, 5])}, {"id": "cut", "workplane": _workplane(), "profile": {"type": "circle", "center": [2, 0], "radius_mm": 1}}, ]}, "features": [ @@ -518,7 +556,7 @@ class EngineRuntimeFoundationTests(unittest.TestCase): "schema": "cad.cdsl.llm.v1", "geometry": {"sketches": [{ "id": "circles", "workplane": _workplane(), - "profile": {"type": "circles", "items": [{"center": [2, 3], "radius_mm": 1}]}, + "profile": {"type": "circle", "center": [2, 3], "radius_mm": 1}, }]}, "features": [], } @@ -761,7 +799,7 @@ class EngineRuntimeFoundationTests(unittest.TestCase): "meta": {"unit": "mm"}, "geometry": {"sketches": [{ "id": "profile", "workplane": _workplane(), - "profile": {"type": "rectangle", "min_mm": [2, 1], "max_mm": [4, 2]}, + "profile": _rectangle([2, 1], [4, 2]), }]}, "features": [ { diff --git a/backend/tests/test_feature_plan.py b/backend/tests/test_feature_plan.py new file mode 100644 index 00000000..7a7eef58 --- /dev/null +++ b/backend/tests/test_feature_plan.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +import sys +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "backend")) + +from app.services.feature_plan import FeaturePlanError, compute_node_statuses, validate_feature_plan # noqa: E402 + + +class FeaturePlanTests(unittest.TestCase): + def plan(self) -> dict: + return { + "schema_version": "cad.feature-plan.v1", + "plan_id": "plan_test", + "task_id": "cad_test", + "nodes": [ + {"id": "base", "atomic_id": "extrude_add_blind", "depends_on": []}, + {"id": "round", "atomic_id": "fillet", "depends_on": ["base"], "requires_topology": True}, + ], + } + + def test_rejects_cycles_and_missing_dependencies(self) -> None: + cyclic = self.plan() + cyclic["nodes"][0]["depends_on"] = ["round"] + with self.assertRaises(FeaturePlanError): + validate_feature_plan(cyclic, supported_atomic_ids={"extrude_add_blind", "fillet"}) + missing = self.plan() + missing["nodes"][1]["depends_on"] = ["missing"] + with self.assertRaises(FeaturePlanError): + validate_feature_plan(missing, supported_atomic_ids={"extrude_add_blind", "fillet"}) + + def test_ready_prefix_waits_for_topology(self) -> None: + statuses = compute_node_statuses(self.plan(), cdsl={"features": []}) + self.assertEqual(statuses["ready_nodes"], ["base"]) + self.assertEqual(statuses["waiting_nodes"], []) + + def test_built_prefix_unlocks_topology_dependent_node(self) -> None: + statuses = compute_node_statuses( + self.plan(), + cdsl={"features": [{"id": "base"}]}, + topology={"records": [{"record_id": "body:base", "kind": "body", "executable": True}]}, + ) + self.assertEqual(statuses["completed_nodes"], ["base"]) + self.assertEqual(statuses["waiting_nodes"], ["round"]) + + def test_inspected_snapshot_unlocks_topology_dependent_node(self) -> None: + plan = self.plan() + plan["topology_snapshot_id"] = "cad_test/rev_001" + statuses = compute_node_statuses( + plan, + cdsl={"features": [{"id": "base"}]}, + topology={ + "snapshot_id": "cad_test/rev_001", + "records": [{"record_id": "body:base", "kind": "body", "executable": True}], + }, + ) + + self.assertEqual(statuses["ready_nodes"], ["round"]) + self.assertEqual(statuses["waiting_nodes"], []) + + def test_dependency_must_appear_before_dependent_node(self) -> None: + plan = self.plan() + plan["nodes"] = [plan["nodes"][1], plan["nodes"][0]] + with self.assertRaisesRegex(FeaturePlanError, "appear after dependency"): + validate_feature_plan(plan, supported_atomic_ids={"extrude_add_blind", "fillet"}) diff --git a/backend/tests/test_image_observation.py b/backend/tests/test_image_observation.py new file mode 100644 index 00000000..43318e75 --- /dev/null +++ b/backend/tests/test_image_observation.py @@ -0,0 +1,134 @@ +from __future__ import annotations + +import asyncio +import io +import json +from pathlib import Path +from tempfile import TemporaryDirectory + +from PIL import Image + +from app.services.image_observation import ( + merge_image_observations, + normalize_image_observation, + normalize_sketch_candidates, + render_image_observation_context, +) +from app.services.image_processing import cv_hints, image_metadata +from app.services.agent_service import tools_for_model +from app.services.agent_service import AgentService +from app.services.attachments import attachment_record +from app.services.library import CdslLibrary +from app.services.storage import WorkspaceStore +from app.settings import ProviderConfig, ProviderModel, Settings +from app.models.contracts import ChatMessage, MessagePart + + +def test_observation_keeps_multiview_profiles_and_measurement_sources() -> None: + result = normalize_image_observation({ + "part_type": "bent bracket", + "visible_features": ["plate", "irregular opening"], + "uncertain_features": ["inner bend radius"], + "views": [{"attachment_id": "upload_a", "view_role": "front", "confidence": 0.8}], + "profiles": [{ + "id": "opening_01", + "role": "cutout", + "closed": True, + "source_images": ["upload_a"], + "segments": [ + {"type": "line", "start": [0, 0], "end": [10, 0]}, + {"type": "arc", "start": [10, 0], "end": [10, 4], "center": [8, 2], "radius_mm": 2}, + {"type": "polyline", "points": [[10, 4], [5, 8], [0, 4]]}, + ], + }], + "measurements": [{"name": "plate_thickness", "value_mm": 1.2, "source": "user"}], + }, attachment_ids=["upload_a"]) + + assert result["schema_version"] == "cad.image-observation.v2" + assert result["profiles"][0]["segments"][1]["type"] == "arc" + assert result["measurements"][0]["source"] == "user" + assert "opening_01" in render_image_observation_context(result) + + +def test_sketch_merge_does_not_replace_user_measurement() -> None: + survey = normalize_image_observation({ + "part_type": "bracket", + "visible_features": ["plate"], + "uncertain_features": [], + "views": [{"attachment_id": "upload_a"}], + "measurements": [{"name": "thickness", "value_mm": 1.2, "source": "user"}], + }, attachment_ids=["upload_a"]) + sketches = normalize_sketch_candidates({ + "profiles": [], + "measurements": [{"name": "thickness", "value_mm": 1.6, "source": "image"}], + "uncertainties": ["bend radius"], + }, attachment_ids=["upload_a"]) + merged = merge_image_observations(survey, sketches) + + assert merged["measurements"][0]["value_mm"] == 1.2 + assert merged["uncertainties"] == ["bend radius"] + + +def test_image_metadata_and_cv_degrade_without_required_cv_support() -> None: + output = io.BytesIO() + Image.new("RGB", (32, 16), "white").save(output, format="PNG") + metadata = image_metadata(output.getvalue()) + assert metadata["width"] == 32 + assert metadata["height"] == 16 + assert "available" in cv_hints(output.getvalue()) + + +def test_image_tool_stages_expose_only_the_required_tool() -> None: + model = ProviderModel("vision", vision=True) + assert [tool["function"]["name"] for tool in tools_for_model(model, image_stage="survey")] == ["analyze_image_reference"] + assert [tool["function"]["name"] for tool in tools_for_model(model, image_stage="sketch", include_image_analysis=False, include_image_sketches=True)] == ["extract_image_sketch_candidates"] + + +def test_agent_runs_survey_then_sketch_stage_before_normal_tools() -> None: + class TwoStageAgent(AgentService): + def __init__(self, *args: object, **kwargs: object) -> None: + super().__init__(*args, **kwargs) + self.required: list[str | None] = [] + self.responses = [ + {"choices": [{"message": {"role": "assistant", "content": "", "tool_calls": [{ + "id": "survey", "type": "function", "function": {"name": "analyze_image_reference", "arguments": json.dumps({ + "part_type": "bracket", "visible_features": ["plate"], "uncertain_features": [], + "views": [{"attachment_id": "upload_a", "view_role": "front"}], "profiles": [], + "measurements": [], "uncertainties": [], + })}, + }]}}]}, + {"choices": [{"message": {"role": "assistant", "content": "", "tool_calls": [{ + "id": "sketch", "type": "function", "function": {"name": "extract_image_sketch_candidates", "arguments": json.dumps({ + "profiles": [{"id": "opening", "role": "cutout", "closed": True, "segments": [{"type": "line", "start": [0, 0], "end": [2, 0]}]}], + "measurements": [], "uncertainties": [], + })}, + }]}}]}, + {"choices": [{"message": {"role": "assistant", "content": "继续建模。", "tool_calls": []}}]}, + ] + + async def _complete(self, *args: object, **kwargs: object) -> dict[str, object]: + self.required.append(kwargs.get("required_tool_name") if "required_tool_name" in kwargs else args[4] if len(args) > 4 else None) + return self.responses.pop(0) + + with TemporaryDirectory() as directory: + root = Path(directory) + backend_root = Path(__file__).resolve().parents[1] + provider = ProviderConfig("test", "Test", "https://example.invalid/v1", "key", (ProviderModel("vision", vision=True),)) + settings = Settings(root / "tasks", root / "conversations", backend_root / "cdsl_library", backend_root / "engine" / "cdsl_engine", "", "", "", 5, "test", (provider,)) + store = WorkspaceStore(settings) + conversation_id = "conv_000000000001" + store.ensure_conversation(conversation_id) + relative, _ = store.write_conversation_upload(conversation_id, "part.png", b"png") + store.add_conversation_attachment(conversation_id, attachment_record(conversation_id, "part.png", "image/png", relative, b"png", "image")) + agent = TwoStageAgent(settings, store, CdslLibrary(settings)) + message = ChatMessage(id="user", role="user", parts=[MessagePart(type="text", text="根据图片继续建模")]) + + async def collect() -> list[dict[str, object]]: + events = [] + async for chunk in agent.stream([message], conversation_id, None): + events.append(json.loads(chunk.decode("utf-8").split("data: ", 1)[1])) + return events + + events = asyncio.run(collect()) + assert agent.required[:2] == ["analyze_image_reference", "extract_image_sketch_candidates"] + assert any(event.get("observationStage") == "complete" for event in events) diff --git a/backend/tests/test_incremental_generation.py b/backend/tests/test_incremental_generation.py new file mode 100644 index 00000000..69bd1bda --- /dev/null +++ b/backend/tests/test_incremental_generation.py @@ -0,0 +1,340 @@ +from __future__ import annotations + +import asyncio +from dataclasses import replace +import json +import sys +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "backend")) + +from fastapi import HTTPException # noqa: E402 +from app import main as api # noqa: E402 +from app.services.cdsl_fragment import CdslFragmentError, cdsl_sha256, validate_fragment # noqa: E402 +from app.services.generation_plan import GenerationPlanError, descendant_closure, mark_nodes_stale, validate_generation_plan # noqa: E402 +from app.services.incremental_generation import IncrementalGenerationRunner # noqa: E402 +from app.services.review_renderer import CANONICAL_VIEWS, REVIEW_SIZE, RENDER_SIZE, render_checkpoint # noqa: E402 +from app.services.storage import WorkspaceStore # noqa: E402 +from app.services.visual_review import _selected_review_views # noqa: E402 +from app.settings import ProviderConfig, ProviderModel, Settings # noqa: E402 + + +def plan() -> dict: + return { + "schema_version": "cad.generation-plan.v2", + "plan_id": "incremental_test", + "requirements": [ + {"id": "req_base", "source": "explicit", "priority": "hard", "description": "base solid"}, + {"id": "req_round", "source": "explicit", "priority": "hard", "description": "edge round"}, + ], + "assumptions": [], + "nodes": [ + { + "id": "base", "intent": "base", "atomic_id": "extrude_add_blind", "depends_on": [], + "requirement_ids": ["req_base"], "verification_rules": [], "review_targets": [], + }, + { + "id": "round", "intent": "round", "atomic_id": "fillet", "depends_on": ["base"], + "requires_topology": True, + "requirement_ids": ["req_round"], "verification_rules": [], "review_targets": [], + }, + ], + } + + +class GenerationPlanTests(unittest.TestCase): + def test_hard_requirements_and_backend_outputs_are_owned(self) -> None: + invalid = plan() + invalid["nodes"][1]["requirement_ids"] = [] + with self.assertRaisesRegex(GenerationPlanError, "Hard requirements"): + validate_generation_plan(invalid, supported_atomic_ids={"extrude_add_blind", "fillet"}, task_id="cad_abcdef123456") + + generated = validate_generation_plan(plan(), supported_atomic_ids={"extrude_add_blind", "fillet"}, task_id="cad_abcdef123456") + self.assertEqual(generated["id_strategy"], "backend-derived-v1") + self.assertEqual(len(generated["nodes"][0]["cdsl_feature_ids"]), 1) + self.assertEqual(len(generated["nodes"][0]["cdsl_sketch_ids"]), 1) + self.assertEqual(generated["nodes"][1]["cdsl_sketch_ids"], []) + # Legacy fields from an untrusted model output cannot choose CDSL IDs. + supplied = plan() + supplied["nodes"][0]["expected_feature_ids"] = ["model_chosen_id"] + self.assertEqual( + validate_generation_plan(supplied, supported_atomic_ids={"extrude_add_blind", "fillet"}, task_id="cad_abcdef123456")["nodes"][0]["cdsl_feature_ids"], + generated["nodes"][0]["cdsl_feature_ids"], + ) + + def test_upstream_invalidation_marks_all_descendants_stale(self) -> None: + spec = validate_generation_plan(plan(), supported_atomic_ids={"extrude_add_blind", "fillet"}, task_id="cad_abcdef123456") + spec["nodes"][0]["status"] = "completed" + spec["nodes"][1]["status"] = "completed" + self.assertEqual(descendant_closure(spec, "base"), {"base", "round"}) + stale = mark_nodes_stale(spec, "base", reason="topology_changed") + self.assertTrue(all(node["stale"] for node in stale["nodes"])) + self.assertTrue(all(node["status"] == "planned" for node in stale["nodes"])) + + +class FragmentTests(unittest.TestCase): + def setUp(self) -> None: + self.spec = validate_generation_plan(plan(), supported_atomic_ids={"extrude_add_blind", "fillet"}, task_id="cad_abcdef123456") + + def base_fragment(self) -> dict: + return { + "schema_version": "cad.cdsl-fragment.v1", "node_id": "base", "base_revision_id": "", + "base_cdsl_sha256": cdsl_sha256(None), "add_sketches": [{}], + "add_features": [{}], "verification_rules": [], "assumptions": [], + } + + def test_fragment_assigns_ids_dependencies_and_atomic_from_plan(self) -> None: + accepted = validate_fragment(self.base_fragment(), plan=self.spec, node_id="base", base_revision_id="", base_cdsl=None) + base = self.spec["nodes"][0] + self.assertEqual(accepted["add_sketches"][0]["id"], base["cdsl_sketch_ids"][0]) + self.assertEqual(accepted["add_features"][0]["id"], base["cdsl_feature_ids"][0]) + self.assertEqual(accepted["add_features"][0]["sketch_id"], base["cdsl_sketch_ids"][0]) + self.assertEqual(accepted["add_features"][0]["atomic_id"], "extrude_add_blind") + self.assertEqual(accepted["add_features"][0]["depends_on"], []) + model_ids = self.base_fragment() + model_ids["add_sketches"][0]["id"] = "model_sketch" + model_ids["add_features"][0].update({"id": "model_feature", "atomic_id": "fillet", "sketch_id": "model_sketch"}) + overwritten = validate_fragment(model_ids, plan=self.spec, node_id="base", base_revision_id="", base_cdsl=None) + self.assertEqual(overwritten["add_features"][0]["id"], base["cdsl_feature_ids"][0]) + self.assertEqual(overwritten["add_features"][0]["atomic_id"], "extrude_add_blind") + + def test_fragment_rejects_wrong_hash_and_output_shape(self) -> None: + wrong_hash = self.base_fragment() + wrong_hash["base_cdsl_sha256"] = "0" * 64 + with self.assertRaisesRegex(CdslFragmentError, "sha256"): + validate_fragment(wrong_hash, plan=self.spec, node_id="base", base_revision_id="", base_cdsl=None) + unexpected_sketch = self.base_fragment() + unexpected_sketch["add_sketches"].append({"id": "extra"}) + with self.assertRaisesRegex(CdslFragmentError, "one new sketch"): + validate_fragment(unexpected_sketch, plan=self.spec, node_id="base", base_revision_id="", base_cdsl=None) + + def test_topology_fragment_requires_active_snapshot_owner_and_geometry(self) -> None: + base = self.spec["nodes"][0] + round_node = self.spec["nodes"][1] + base_cdsl = { + "geometry": {"sketches": [{"id": base["cdsl_sketch_ids"][0]}]}, + "features": [{"id": base["cdsl_feature_ids"][0], "atomic_id": "extrude_add_blind"}], + } + fragment = { + "schema_version": "cad.cdsl-fragment.v1", "node_id": "round", "base_revision_id": "rev_001", + "base_cdsl_sha256": cdsl_sha256(base_cdsl), "required_snapshot_id": "cad_test/rev_001", + "add_sketches": [], + "add_features": [{ + "selectors": [{ + "kind": "edge", "stable_id": "body:feature_base:edge:0", "owner_node_id": "base", + "snapshot_id": "cad_test/rev_001", "geometry": {"curve_type": "line", "length_mm": 10}, + }], + }], + "verification_rules": [], "assumptions": [], + } + accepted = validate_fragment( + fragment, plan=self.spec, node_id="round", base_revision_id="rev_001", base_cdsl=base_cdsl, + required_snapshot_id="cad_test/rev_001", + ) + self.assertEqual(accepted["node_id"], "round") + self.assertEqual(accepted["add_features"][0]["id"], round_node["cdsl_feature_ids"][0]) + self.assertEqual(accepted["add_features"][0]["depends_on"], [base["cdsl_feature_ids"][0]]) + self.assertEqual(accepted["add_features"][0]["selectors"][0]["owner_feature_id"], base["cdsl_feature_ids"][0]) + fragment["add_features"][0]["selectors"][0].pop("geometry") + with self.assertRaisesRegex(CdslFragmentError, "geometry signature"): + validate_fragment( + fragment, plan=self.spec, node_id="round", base_revision_id="rev_001", base_cdsl=base_cdsl, + required_snapshot_id="cad_test/rev_001", + ) + + +class StorageAndRunnerTests(unittest.TestCase): + def settings(self, root: Path) -> Settings: + provider = ProviderConfig("author", "Author", "https://example.invalid/v1", "secret", (ProviderModel("author-model"),)) + return Settings( + task_root=root / "tasks", conversation_root=root / "conversations", library_root=root / "library", + engine_root=ROOT / "backend" / "engine" / "cdsl_engine", llm_base_url="", llm_api_key="", llm_model="author-model", + llm_timeout_s=1, default_provider_id="author", providers=(provider,), incremental_generation=True, + ) + + def test_rollback_anchor_rewinds_before_affected_nodes(self) -> None: + with tempfile.TemporaryDirectory() as directory: + store = WorkspaceStore(self.settings(Path(directory))) + task = store.ensure_task(None, "test") + task_id = task["task_id"] + for revision_id, parent, node_id in (("rev_001", "", "base"), ("rev_002", "rev_001", "middle"), ("rev_003", "rev_002", "tip")): + store.update_task(task_id, {"revision_id": revision_id, "status": "success", "parent_revision_id": parent, "node_id": node_id, "visibility": "checkpoint"}) + self.assertEqual(store.rollback_anchor_for_nodes(task_id, ["middle", "tip"], fallback_revision_id="rev_002"), "rev_001") + self.assertEqual(store.rollback_anchor_for_nodes(task_id, ["base"], fallback_revision_id="rev_001"), "") + store.rollback_to_revision(task_id, "rev_002", branch_id="branch_repair") + revisions = {item["revision_id"]: item for item in (store.read_task(task_id) or {})["revisions"]} + self.assertEqual(revisions["rev_001"]["visibility"], "checkpoint") + self.assertEqual(revisions["rev_002"]["visibility"], "checkpoint") + self.assertEqual(revisions["rev_003"]["visibility"], "superseded") + + def test_run_context_is_persisted_for_worker_recovery(self) -> None: + with tempfile.TemporaryDirectory() as directory: + store = WorkspaceStore(self.settings(Path(directory))) + task = store.ensure_task(None, "test") + store.write_generation_run_context(task["task_id"], { + "schema_version": "cad.generation-run-context.v1", + "request": "test", "conversation_id": "conv_abcdef123456", + "provider_id": "author", "model_id": "author-model", "author_messages": [], "part_skills": {}, + }) + recovered = store.read_generation_run_context(task["task_id"]) + self.assertEqual(recovered and recovered["request"], "test") + task = store.start_generation(task["task_id"], request="test") + self.assertEqual([item["task_id"] for item in store.running_tasks()], [task["task_id"]]) + + def test_missing_visual_review_configuration_fails_the_run(self) -> None: + with tempfile.TemporaryDirectory() as directory: + settings = self.settings(Path(directory)) + store = WorkspaceStore(settings) + task = store.ensure_task(None, "test") + + async def complete(*_args: object) -> dict: + raise AssertionError("author must not be called before visual configuration validation") + + runner = IncrementalGenerationRunner(settings, store, complete) + + async def collect() -> list[tuple[str, dict]]: + return [item async for item in runner.run( + task_id=task["task_id"], request="test", conversation={"conversation_id": "", "attachments": []}, + provider=settings.providers[0], model=settings.providers[0].models[0], author_messages=[], + )] + + events = asyncio.run(collect()) + self.assertEqual(events[-1][0], "task_terminal") + self.assertEqual(events[-1][1]["lifecycle"], "failed") + self.assertEqual((store.read_task(task["task_id"]) or {})["lifecycle"], "failed") + + def test_invalid_plan_is_preserved_in_failure_diagnostics(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + provider = ProviderConfig( + "author", "Author", "https://example.invalid/v1", "secret", + (ProviderModel("author-model", vision=True),), + ) + settings = replace( + self.settings(root), providers=(provider,), review_provider_id="author", review_model_id="author-model", + ) + store = WorkspaceStore(settings) + task = store.ensure_task(None, "test") + raw_plan = {"schema_version": "cad.generation-plan.v2", "plan_id": "broken", "requirements": []} + + async def complete(*_args: object) -> dict: + return { + "choices": [{"message": {"tool_calls": [{"function": { + "name": "plan_generation_task", "arguments": json.dumps(raw_plan), + }}]}}], + } + + runner = IncrementalGenerationRunner(settings, store, complete) + engine = type("Engine", (), {"SUPPORTED_ATOMIC_IDS": ("extrude_add_blind",)})() + + async def collect() -> list[tuple[str, dict]]: + return [item async for item in runner.run( + task_id=task["task_id"], request="test", conversation={"conversation_id": "", "attachments": []}, + provider=provider, model=provider.models[0], author_messages=[], + )] + + with patch("app.services.incremental_generation.load_engine", return_value=engine), patch( + "app.services.incremental_generation.renderer_status", return_value=(True, "") + ): + events = asyncio.run(collect()) + + self.assertEqual(events[-1], ("task_terminal", { + "taskId": task["task_id"], "lifecycle": "failed", + "message": "Generation plan requires a non-empty requirements array", + })) + failed_task = store.read_task(task["task_id"]) or {} + failure_path = root / "tasks" / task["task_id"] / str(failed_task["run_failure_path"]) + with failure_path.open(encoding="utf-8") as handle: + failure = json.load(handle) + diagnostic_path = root / "tasks" / task["task_id"] / str(failure["plan_diagnostic_path"]) + with diagnostic_path.open(encoding="utf-8") as handle: + diagnostic = json.load(handle) + self.assertEqual(diagnostic["stage"], "generation_plan_validation") + self.assertEqual(diagnostic["raw_plan"], raw_plan) + + +class ArtifactAccessTests(unittest.TestCase): + def test_checkpoint_allows_only_the_active_glb_until_publication(self) -> None: + with tempfile.TemporaryDirectory() as directory: + store = WorkspaceStore(StorageAndRunnerTests().settings(Path(directory))) + task = store.ensure_task(None, "test") + task_id = task["task_id"] + revision_id, revision_dir = store.next_revision(task_id) + glb = revision_dir / "model.glb" + step = revision_dir / "model.step" + glb.write_bytes(b"glb") + step.write_bytes(b"step") + store.update_task(task_id, { + "revision_id": revision_id, + "status": "success", + "cdsl_path": f"revisions/{revision_id}/model.cdsl.json", + "glb_path": f"revisions/{revision_id}/model.glb", + "step_path": f"revisions/{revision_id}/model.step", + "report_path": f"revisions/{revision_id}/rebuild-report.json", + "visibility": "checkpoint", + }) + previous_store = api.store + api.store = store + try: + response = asyncio.run(api.read_artifact(task_id, f"revisions/{revision_id}/model.glb")) + self.assertEqual(response.media_type, "model/gltf-binary") + with self.assertRaises(HTTPException) as rejected: + asyncio.run(api.read_artifact(task_id, f"revisions/{revision_id}/model.step")) + self.assertEqual(rejected.exception.status_code, 403) + store.finish_generation(task_id, lifecycle="completed") + response = asyncio.run(api.read_artifact(task_id, f"revisions/{revision_id}/model.step")) + self.assertEqual(response.status_code, 200) + finally: + api.store = previous_store + + +class TechnicalRenderTests(unittest.TestCase): + def test_cpu_renderer_emits_fixed_views_and_keeps_detail_separate(self) -> None: + from build123d import Box, export_step + + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + step_path = root / "box.step" + export_step(Box(30, 20, 10), step_path) + manifest = render_checkpoint( + StorageAndRunnerTests().settings(root), + step_path=step_path, + output_dir=root / "review", + review_targets=[{"bbox_mm": [-5, -5, -5, 5, 5, 5]}], + ) + self.assertEqual(manifest["renderer"], "python-occ-hlr-pillow") + views = {item["id"]: item for item in manifest["views"]} + self.assertEqual(set(CANONICAL_VIEWS), set(views) & set(CANONICAL_VIEWS)) + self.assertIn("detail-1", views) + self.assertNotEqual(views["isometric"]["path"], views["detail-1"]["path"]) + self.assertTrue(all(Path(views[view_id]["path"]).is_file() for view_id in CANONICAL_VIEWS)) + self.assertTrue(all(views[view_id]["diagnostics"]["valid"] for view_id in CANONICAL_VIEWS)) + self.assertTrue(views["detail-1"]["diagnostics"]["intentional_crop"]) + from PIL import Image + + with Image.open(views["isometric"]["path"]) as image: + self.assertEqual(image.size, (REVIEW_SIZE, REVIEW_SIZE)) + with Image.open(views["isometric"]["high_resolution_path"]) as image: + self.assertEqual(image.size, (RENDER_SIZE, RENDER_SIZE)) + + def test_routine_visual_review_uses_compact_evidence(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + contact = root / "contact-sheet.jpg" + contact.write_bytes(b"jpg") + manifest = { + "contact_sheet_path": str(contact), + "views": [{"id": view_id, "path": str(root / f"{view_id}.png")} for view_id in (*CANONICAL_VIEWS, "detail-1", "detail-2")], + } + routine = _selected_review_views(manifest, final_checkpoint=False) + final = _selected_review_views(manifest, final_checkpoint=True) + self.assertEqual([item["id"] for item in routine], ["contact-sheet", "detail-1", "detail-2"]) + self.assertEqual({item["id"] for item in final}, {"contact-sheet", "detail-1", "detail-2", *CANONICAL_VIEWS}) + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_part_skills.py b/backend/tests/test_part_skills.py index 221bcf40..5352f6c1 100644 --- a/backend/tests/test_part_skills.py +++ b/backend/tests/test_part_skills.py @@ -1,7 +1,6 @@ from __future__ import annotations import asyncio -import copy import json import sys import tempfile @@ -14,7 +13,6 @@ sys.path.insert(0, str(ROOT / "backend")) from app.models.contracts import ChatMessage, MessagePart # noqa: E402 from app.services.agent_service import AgentService, TOOL_SCHEMAS, system_prompt # noqa: E402 -from app.services.engine_service import build_revision, load_engine, validate_cdsl # noqa: E402 from app.services.library import CdslLibrary # noqa: E402 from app.services.part_skills import PartSkillLibrary # noqa: E402 from app.services.storage import WorkspaceStore # noqa: E402 @@ -46,10 +44,49 @@ def document(part_id: str, sketches: list[dict], features: list[dict]) -> dict: } +def rectangle(center: list[float], width: float, height: float) -> dict: + half_width, half_height = width / 2, height / 2 + return {"type": "polygon", "vertices": [ + [center[0] - half_width, center[1] - half_height], [center[0] + half_width, center[1] - half_height], + [center[0] + half_width, center[1] + half_height], [center[0] - half_width, center[1] + half_height], + ]} + + +def circles(items: list[dict]) -> dict: + return {"type": "analytic_contours", "contours": [ + {"role": "outer", "closed": True, "segments": [{"type": "circle", "center": item["center"], "radius_mm": item["radius_mm"]}]} + for item in items + ]} + + +def circle_grid(radius: float, count_x: int, count_y: int, spacing_x: float, spacing_y: float) -> dict: + return circles([ + {"center": [(column - (count_x - 1) / 2) * spacing_x, (row - (count_y - 1) / 2) * spacing_y], "radius_mm": radius} + for row in range(count_y) for column in range(count_x) + ]) + + +def obround(length: float, width: float) -> dict: + radius, left, right = width / 2, -length / 2 + width / 2, length / 2 - width / 2 + return {"type": "analytic_contours", "contours": [{"role": "outer", "closed": True, "segments": [ + {"type": "line", "start": [right, radius], "end": [left, radius]}, + {"type": "arc", "start": [left, radius], "end": [left, -radius], "center": [left, 0], "radius_mm": radius}, + {"type": "line", "start": [left, -radius], "end": [right, -radius]}, + {"type": "arc", "start": [right, -radius], "end": [right, radius], "center": [right, 0], "radius_mm": radius}, + ]}]} + + +def annulus(inner_radius: float, outer_radius: float) -> dict: + return circles([ + {"center": [0, 0], "radius_mm": outer_radius}, + {"center": [0, 0], "radius_mm": inner_radius}, + ]) + + def mounting_plate_fixture() -> dict: return document("golden-mounting-plate", [ - {"id": "base", "workplane": workplane(), "profile": {"type": "rectangle", "center": [0, 0], "width_mm": 80, "height_mm": 60}}, - {"id": "grid", "workplane": workplane(10), "profile": {"type": "circle_grid", "radius_mm": 3, "count_x": 2, "count_y": 2, "spacing_x_mm": 50, "spacing_y_mm": 30, "center_mm": [0, 0]}}, + {"id": "base", "workplane": workplane(), "profile": rectangle([0, 0], 80, 60)}, + {"id": "grid", "workplane": workplane(10), "profile": circle_grid(3, 2, 2, 50, 30)}, ], [ {"id": "base_add", "atomic_id": "extrude_add_blind", "depends_on": [], "params": {"distance_mm": 10}, "sketch_id": "base"}, {"id": "mount_pattern", "atomic_id": "extrude_cut_blind", "depends_on": ["base_add"], "params": {"distance_mm": 10, "reverse": True}, "sketch_id": "grid"}, @@ -60,10 +97,10 @@ def mounting_plate_fixture() -> dict: def mounting_bracket_fixture() -> dict: web_plane = {"origin_mm": [0, -20, 0], "x_dir": [1, 0, 0], "y_dir": [0, 0, -1], "normal": [0, 1, 0]} return document("golden-mounting-bracket", [ - {"id": "base", "workplane": workplane(), "profile": {"type": "rectangle", "center": [0, 0], "width_mm": 80, "height_mm": 40}}, - {"id": "web", "workplane": web_plane, "profile": {"type": "rectangle", "center": [0, -15], "width_mm": 50, "height_mm": 30}}, - {"id": "slot", "workplane": workplane(6), "profile": {"type": "obround", "center": [0, 0], "length_mm": 24, "width_mm": 8}}, - {"id": "symmetric_holes", "workplane": workplane(6), "profile": {"type": "circles", "items": [{"center": [-25, 0], "radius_mm": 3}, {"center": [25, 0], "radius_mm": 3}]}}, + {"id": "base", "workplane": workplane(), "profile": rectangle([0, 0], 80, 40)}, + {"id": "web", "workplane": web_plane, "profile": rectangle([0, -15], 50, 30)}, + {"id": "slot", "workplane": workplane(6), "profile": obround(24, 8)}, + {"id": "symmetric_holes", "workplane": workplane(6), "profile": circles([{"center": [-25, 0], "radius_mm": 3}, {"center": [25, 0], "radius_mm": 3}])}, ], [ {"id": "base_add", "atomic_id": "extrude_add_blind", "depends_on": [], "params": {"distance_mm": 6}, "sketch_id": "base"}, {"id": "web_add", "atomic_id": "extrude_add_blind", "depends_on": ["base_add"], "params": {"distance_mm": 6}, "sketch_id": "web"}, @@ -74,8 +111,8 @@ def mounting_bracket_fixture() -> dict: def flange_fixture() -> dict: return document("golden-flange", [ - {"id": "base", "workplane": workplane(), "profile": {"type": "annulus", "inner_radius_mm": 10, "outer_radius_mm": 40}}, - {"id": "bolt_circle", "workplane": workplane(12), "profile": {"type": "circles", "items": [{"center": [25, 0], "radius_mm": 3}, {"center": [0, 25], "radius_mm": 3}, {"center": [-25, 0], "radius_mm": 3}, {"center": [0, -25], "radius_mm": 3}]}}, + {"id": "base", "workplane": workplane(), "profile": annulus(10, 40)}, + {"id": "bolt_circle", "workplane": workplane(12), "profile": circles([{"center": [25, 0], "radius_mm": 3}, {"center": [0, 25], "radius_mm": 3}, {"center": [-25, 0], "radius_mm": 3}, {"center": [0, -25], "radius_mm": 3}])}, ], [ {"id": "base_add", "atomic_id": "extrude_add_blind", "depends_on": [], "params": {"distance_mm": 12}, "sketch_id": "base"}, {"id": "bolt_holes", "atomic_id": "extrude_cut_blind", "depends_on": ["base_add"], "params": {"distance_mm": 12, "reverse": True}, "sketch_id": "bolt_circle"}, @@ -94,9 +131,9 @@ def shaft_fixture() -> dict: def bearing_housing_fixture() -> dict: return document("golden-bearing-housing", [ - {"id": "base", "workplane": workplane(), "profile": {"type": "rectangle", "center": [0, 0], "width_mm": 100, "height_mm": 60}}, + {"id": "base", "workplane": workplane(), "profile": rectangle([0, 0], 100, 60)}, {"id": "housing", "workplane": workplane(8), "profile": {"type": "circle", "radius_mm": 28}}, - {"id": "base_holes", "workplane": workplane(8), "profile": {"type": "circle_grid", "radius_mm": 4, "count_x": 2, "count_y": 2, "spacing_x_mm": 80, "spacing_y_mm": 40, "center_mm": [0, 0]}}, + {"id": "base_holes", "workplane": workplane(8), "profile": circle_grid(4, 2, 2, 80, 40)}, ], [ {"id": "base_add", "atomic_id": "extrude_add_blind", "depends_on": [], "params": {"distance_mm": 8}, "sketch_id": "base"}, {"id": "housing_add", "atomic_id": "extrude_add_blind", "depends_on": ["base_add"], "params": {"distance_mm": 25}, "sketch_id": "housing"}, @@ -115,16 +152,6 @@ def hex_nut_fixture() -> dict: ]) -GOLDEN_CASES = ( - ("带沉孔和孔阵列的安装底板", mounting_plate_fixture, {"planning/mounting-plate", "atomic/counterbored-hole-creation", "atomic/pattern-holes-from-datum"}, {"atomic/pattern-holes-from-datum": "expanded"}), - ("带槽和对称孔的安装支架", mounting_bracket_fixture, {"planning/mounting-bracket", "functional/slotted-adjustment-feature", "functional/symmetric-feature-layout"}, {"atomic/pattern-holes-from-datum": "expanded"}), - ("带螺栓圆的法兰", flange_fixture, {"planning/flange", "functional/flange-bolt-circle"}, {"functional/flange-bolt-circle": "expanded"}), - ("带回转体同轴孔的阶梯轴", shaft_fixture, {"planning/simple-shaft", "functional/axisymmetric-revolve-strategy", "atomic/coaxial-bore-rule"}, {}), - ("带轴承座孔和底座孔的轴承座", bearing_housing_fixture, {"planning/bearing-housing", "functional/bearing-bore-seat"}, {}), - ("M6 六角螺母", hex_nut_fixture, {"planning/hexagonal-nut", "atomic/threaded-hole-creation"}, {"atomic/threaded-hole-creation": "approximated"}), -) - - class PartSkillLibraryTests(unittest.TestCase): def setUp(self) -> None: self.library = PartSkillLibrary(PART_SKILL_ROOT) @@ -166,24 +193,19 @@ class PartSkillLibraryTests(unittest.TestCase): self.assertTrue((PART_SKILL_ROOT / skill["bridge"]).is_file()) self.assertTrue((PART_SKILL_ROOT / skill["source"]).is_file()) self.assertTrue(skill["triggers"]) - self.assertTrue(skill["capability_translation_rules"]) + self.assertNotIn("capability_translation_rules", skill) self.assertEqual(categories, {"planning": 6, "functional": 7, "atomic": 8}) self.assertTrue((PART_SKILL_ROOT / "LICENSE").is_file()) self.assertTrue((PART_SKILL_ROOT / "PROVENANCE.md").is_file()) - def test_audit_records_exact_omitted_and_blocked_capability_states(self) -> None: + def test_audit_records_selection_and_assumptions_without_interpreting_geometry(self) -> None: fixture = mounting_plate_fixture() - exact = self.library.audit(self.library.select("安装底板"), fixture) - finishing = self.library.audit(self.library.select("edge treatment"), fixture) - blocked = self.library.audit(self.library.select("flange bolt circle"), mounting_plate_fixture()) - - exact_statuses = {item["skill_id"]: item["status"] for item in exact["capability_translations"]} - finishing_statuses = {item["skill_id"]: item for item in finishing["capability_translations"]} - blocked_statuses = {item["skill_id"]: item["status"] for item in blocked["capability_translations"]} - self.assertEqual(exact_statuses["planning/mounting-plate"], "exact") - self.assertEqual(finishing_statuses["atomic/fillet-chamfer-last"]["status"], "omitted") - self.assertEqual(finishing_statuses["atomic/fillet-chamfer-last"]["translation"], "selector_unavailable") - self.assertEqual(blocked_statuses["functional/flange-bolt-circle"], "blocked") + audit = self.library.audit(self.library.select("安装底板"), fixture, ["孔位置由用户确认"]) + self.assertIn("planning/mounting-plate", audit["skill_ids"]) + self.assertEqual(audit["assumptions"], ["孔位置由用户确认"]) + self.assertTrue(all(item.get("version") and item.get("summary") for item in audit["skills"])) + self.assertNotIn("capability_translations", audit) + self.assertNotIn("evidence", audit) class AgentPartSkillTests(unittest.TestCase): @@ -211,7 +233,7 @@ class AgentPartSkillTests(unittest.TestCase): self.assertIn("[planning/flange]", str(agent.first_messages[0]["content"])) self.assertIn("[functional/flange-bolt-circle]", str(agent.first_messages[0]["content"])) - def test_prompt_contains_bridge_without_adding_tools(self) -> None: + def test_prompt_contains_bridge_and_direct_cdsl_tools(self) -> None: library = PartSkillLibrary(PART_SKILL_ROOT) selection = library.select("Create a flange with a bolt circle") with tempfile.TemporaryDirectory() as directory: @@ -220,7 +242,7 @@ class AgentPartSkillTests(unittest.TestCase): self.assertIn("[planning/flange]", prompt) self.assertIn("circular-pattern atomic", prompt) - self.assertEqual([tool["function"]["name"] for tool in TOOL_SCHEMAS], ["search_cdsl_library", "read_cdsl_reference", "propose_design_intent", "read_current_cdsl", "generate_cdsl_model"]) + self.assertEqual([tool["function"]["name"] for tool in TOOL_SCHEMAS], ["analyze_image_reference", "extract_image_sketch_candidates", "search_cdsl_library", "read_cdsl_reference", "describe_design_intent", "read_current_cdsl", "generate_cdsl_model", "patch_cdsl_model"]) def test_generation_persists_assumptions_and_selected_skills(self) -> None: with tempfile.TemporaryDirectory() as directory: @@ -230,27 +252,25 @@ class AgentPartSkillTests(unittest.TestCase): agent = AgentService(settings, store, CdslLibrary(settings), library) request = "M6 六角螺母" selection = library.select(request) - engine = load_engine(settings) - intent = engine.design_intent_from_cdsl(hex_nut_fixture(), request=request) - intent_state = {"phase": "WAITING_FOR_INTENT", "design_intent_id": ""} + planning_state = {"phase": "INTAKE", "design_brief": ""} planned, _ = asyncio.run(agent._run_tool( - "propose_design_intent", - {"intent": intent, "summary": "M6 nut plan", "assumptions": []}, + "describe_design_intent", + {"plan": "Create an M6 hex nut with a centered cylindrical bore representing the thread.", "assumptions": []}, "", request, [], part_skill_selection=selection, - intent_state=intent_state, + planning_state=planning_state, )) self.assertTrue(planned["ok"]) result, generated = asyncio.run(agent._run_tool( "generate_cdsl_model", - {"design_intent_id": planned["design_intent_id"], "cdsl": hex_nut_fixture(), "summary": "M6 nut", "assumptions": ["M6 thread is represented as a cylindrical bore"]}, - planned["task_id"], + {"cdsl": hex_nut_fixture(), "summary": "M6 nut", "assumptions": ["M6 thread is represented as a cylindrical bore"]}, + "", request, ["reference-fixture"], part_skill_selection=selection, - intent_state=intent_state, + planning_state=planning_state, )) self.assertTrue(result["ok"]) @@ -280,66 +300,5 @@ class AgentPartSkillTests(unittest.TestCase): ) -class PartSkillBuildAndGoldenTests(unittest.TestCase): - @classmethod - def setUpClass(cls) -> None: - cls.settings = AgentPartSkillTests._settings(Path(tempfile.mkdtemp())) - cls.engine = load_engine(cls.settings) - cls.library = PartSkillLibrary(PART_SKILL_ROOT) - - def test_success_and_failure_revisions_always_write_part_skill_audit(self) -> None: - with tempfile.TemporaryDirectory() as directory: - settings = AgentPartSkillTests._settings(Path(directory)) - store = WorkspaceStore(settings) - selection = self.library.select("带螺栓圆的法兰") - audit = self.library.audit(selection, flange_fixture(), ["Explicit circle expansion"]) - success = build_revision( - settings=settings, store=store, task_id=None, request="带螺栓圆的法兰", cdsl=flange_fixture(), - reference_ids=["flange-reference"], summary="flange", part_skills=audit, - generation_assumptions=["Explicit circle expansion"], - ) - success_audit = json.loads(store.artifact_path(success["task_id"], success["part_skills_path"]).read_text(encoding="utf-8")) - success_report = json.loads(store.artifact_path(success["task_id"], success["report_path"]).read_text(encoding="utf-8")) - self.assertEqual(success_audit["skill_ids"], audit["skill_ids"]) - self.assertIn("generation_context", success_report) - - invalid = copy.deepcopy(flange_fixture()) - invalid["features"][0]["atomic_id"] = "not_supported" - failed_audit = self.library.audit(selection, invalid, []) - with self.assertRaisesRegex(ValueError, "CDSL schema violation"): - build_revision( - settings=settings, store=store, task_id=success["task_id"], request="bad flange", cdsl=invalid, - reference_ids=[], summary="bad", part_skills=failed_audit, generation_assumptions=[], - ) - task = store.read_task(success["task_id"]) - failed = task["revisions"][-1] - self.assertEqual(failed["status"], "failed") - self.assertTrue(store.artifact_path(task["task_id"], failed["part_skills_path"]).is_file()) - report = json.loads(store.artifact_path(task["task_id"], failed["report_path"]).read_text(encoding="utf-8")) - self.assertEqual(report["generation_context"]["part_skill_ids"], audit["skill_ids"]) - legacy_report = {"engine_result": {"engine": "cdsl_only"}} - self.assertEqual(legacy_report.get("generation_context", {}), {}) - - def test_golden_part_families_validate_preflight_rebuild_and_audit(self) -> None: - for request, factory, expected_skills, expected_statuses in GOLDEN_CASES: - with self.subTest(request=request): - cdsl = factory() - selection = self.library.select(request) - self.assertTrue(expected_skills.issubset(selection["skill_ids"])) - validate_cdsl(cdsl, self.engine) - with tempfile.TemporaryDirectory() as directory: - step_path = Path(directory) / "model.step" - result = self.engine.run_cdsl_only(copy.deepcopy(cdsl), step_path) - self.assertEqual(result["engine"], "cdsl_only") - self.assertTrue(step_path.is_file()) - self.assertGreater(step_path.stat().st_size, 0) - audit = self.library.audit(selection, cdsl, ["golden fixture"]) - statuses = {item["skill_id"]: item["status"] for item in audit["capability_translations"]} - for skill_id, status in expected_statuses.items(): - self.assertEqual(statuses[skill_id], status) - self.assertTrue(audit["evidence"]["features"]) - self.assertTrue(audit["evidence"]["profiles"]) - - if __name__ == "__main__": unittest.main() diff --git a/backend/tests/test_profile_schema.py b/backend/tests/test_profile_schema.py index 0f33381b..d0509e24 100644 --- a/backend/tests/test_profile_schema.py +++ b/backend/tests/test_profile_schema.py @@ -5,7 +5,7 @@ import tempfile import unittest from pathlib import Path -from app.services.engine_service import load_engine, validate_cdsl +from app.services.engine_service import load_engine, normalize_cdsl_for_engine, validate_cdsl from app.settings import get_settings @@ -22,21 +22,38 @@ class ProfileSchemaTests(unittest.TestCase): def test_schema_and_registered_profiles_stay_in_sync(self) -> None: self.assertEqual(set(self.schema["runtime_supported_profiles"]), set(self.engine.SHAPE_GENERATORS)) + def test_legacy_profile_macros_require_explicit_adapter_lowering(self) -> None: + legacy = { + "schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part", "part_id": "legacy-rectangle", + "meta": {"unit": "mm"}, + "geometry": {"sketches": [{ + "id": "base", "workplane": {"origin_mm": [0, 0, 0], "x_dir": [1, 0, 0], "normal": [0, 0, 1]}, + "profile": {"type": "rectangle", "center": [0, 0], "width_mm": 10, "height_mm": 10}, + }]}, + "features": [{"id": "base_add", "atomic_id": "extrude_add_blind", "depends_on": [], "sketch_id": "base", "params": {"distance_mm": 2}}], + } + with self.assertRaisesRegex(ValueError, "CDSL schema violation"): + validate_cdsl(legacy, self.engine) + from cdsl_importer.legacy_profile_adapter import lower_legacy_profiles + + lowered = lower_legacy_profiles(legacy) + self.assertEqual(lowered["geometry"]["sketches"][0]["profile"]["type"], "analytic_contours") + validate_cdsl(lowered, self.engine) + def test_schema_and_executable_atomic_operations_stay_in_sync(self) -> None: self.assertEqual(set(self.schema["runtime_supported_atomic_ids"]), set(self.engine.SUPPORTED_ATOMIC_IDS)) - self.assertTrue(set(self.engine.SUPPORTED_ATOMIC_IDS).issubset(self.schema["feature_atomic_ids"])) + self.assertEqual(set(self.engine.SUPPORTED_ATOMIC_IDS), set(self.schema["feature_atomic_ids"])) self.assertEqual( set(self.cdsl_schema["$defs"]["feature_atomic_ids"]["enum"]), - set(self.schema["feature_atomic_ids"]), + set(self.engine.SUPPORTED_ATOMIC_IDS), ) def test_machine_schema_and_human_contract_stay_in_sync(self) -> None: self.assertEqual( set(self.cdsl_schema["$defs"]["profile_type"]["enum"]), - set(self.schema["profiles"]) - {"complex_arc_shape", "unknown_shape"}, + set(self.schema["runtime_supported_profiles"]), ) - self.assertEqual(set(self.cdsl_schema["$defs"]["motif_type"]["enum"]), set(self.schema["motif_types"])) - self.assertEqual(set(self.cdsl_schema["$defs"]["layout_type"]["enum"]), set(self.schema["layout_types"])) + self.assertEqual(set(self.schema["profiles"]), set(self.schema["runtime_supported_profiles"])) def test_rejects_unsupported_atomic_operation_before_rebuild(self) -> None: cdsl = { @@ -88,6 +105,52 @@ class ProfileSchemaTests(unittest.TestCase): with self.assertRaisesRegex(ValueError, "positions\\[0\\].*not of type 'object'"): validate_cdsl(cdsl, self.engine) + def test_normalizes_unambiguous_legacy_llm_field_names_before_validation(self) -> None: + legacy_cdsl = { + "schema": "cad.cdsl.llm.v1", + "part_id": "legacy-flange-base", + "geometry": {"sketches": [{ + "id": "base_sketch", + "plane": "XY", + "offset_mm": 12, + "profile": {"type": "circle", "radius_mm": 20}, + }]}, + "features": [{ + "id": "base_add", + "atomic_id": "extrude_add_blind", + "sketch": "base_sketch", + "params": {"distance_mm": 8}, + }], + } + + normalized, repairs = normalize_cdsl_for_engine(legacy_cdsl) + + self.assertEqual(legacy_cdsl["geometry"]["sketches"][0]["plane"], "XY") + self.assertEqual(normalized["features"][0]["sketch_id"], "base_sketch") + self.assertNotIn("sketch", normalized["features"][0]) + self.assertEqual(normalized["features"][0]["depends_on"], []) + self.assertEqual(normalized["geometry"]["sketches"][0]["workplane"], { + "origin_mm": [0.0, 0.0, 12.0], + "x_dir": [1.0, 0.0, 0.0], + "normal": [0.0, 0.0, 1.0], + }) + self.assertEqual(len(repairs), 3) + validate_cdsl(normalized, self.engine) + + def test_normalizer_rewrites_the_legacy_revolve_axis_point_name(self) -> None: + normalized, repairs = normalize_cdsl_for_engine({ + "features": [{ + "id": "turn", + "atomic_id": "revolve_add", + "params": {"axis": {"point_mm": [0, 0, 0], "direction": [0, 0, 1]}}, + }], + }) + + axis = normalized["features"][0]["params"]["axis"] + self.assertEqual(axis["origin_mm"], [0, 0, 0]) + self.assertNotIn("point_mm", axis) + self.assertIn("features[0].params.axis: point_mm -> origin_mm", repairs) + def test_cdsl_only_rebuild_preserves_its_actual_failure(self) -> None: cdsl = { "schema": "cad.cdsl.llm.v1", @@ -112,11 +175,16 @@ class ProfileSchemaTests(unittest.TestCase): self.assertNotIn("engine.run_rebuild(cdsl_copy, step_path)", build_revision_source) def test_all_official_samples_match_the_engine_schema(self) -> None: + from cdsl_importer.legacy_profile_adapter import lower_legacy_profiles + samples = sorted(self.settings.library_root.glob("samples/**/model.cdsl.json")) self.assertGreater(len(samples), 0) for sample_path in samples: with self.subTest(sample=sample_path.parent.name): - validate_cdsl(json.loads(sample_path.read_text(encoding="utf-8")), self.engine) + legacy_sample = json.loads(sample_path.read_text(encoding="utf-8")) + lowered = lower_legacy_profiles(legacy_sample) + validate_cdsl(lowered, self.engine) + self.assertTrue(self.engine.analyze_cdsl(lowered).runtime_eligible) if __name__ == "__main__": unittest.main() diff --git a/backend/tests/test_topology_snapshot.py b/backend/tests/test_topology_snapshot.py new file mode 100644 index 00000000..b311482e --- /dev/null +++ b/backend/tests/test_topology_snapshot.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +import sys +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "backend")) + +from app.services.engine_service import topology_snapshot, topology_sidecars # noqa: E402 + + +class TopologySnapshotTests(unittest.TestCase): + def result(self) -> dict: + return { + "topology_records": [ + { + "record_id": "body:base:edge:0", + "kind": "edge", + "feature_id": "base", + "body_id": "body:base", + "owner_feature_ids": ["base"], + "geometry": {"curve_type": "line", "length_mm": 10}, + }, + { + "record_id": "body:base", + "kind": "body", + "feature_id": "base", + "body_id": "body:base", + "geometry": {}, + }, + ] + } + + def test_snapshot_preserves_runtime_edges(self) -> None: + snapshot = topology_snapshot(self.result(), task_id="cad_test", revision_id="rev_001") + self.assertEqual(snapshot["snapshot_id"], "cad_test/rev_001") + self.assertEqual(snapshot["records"][0]["record_id"], "body:base:edge:0") + self.assertTrue(snapshot["records"][0]["executable"]) + + def test_sidecars_are_derived_from_runtime_records(self) -> None: + snapshot = topology_snapshot(self.result()) + selector, edges = topology_sidecars(self.result(), snapshot=snapshot) + self.assertEqual(edges["edges"][0]["record_id"], "body:base:edge:0") + self.assertEqual(selector["edges"][0]["source"], "runtime_snapshot") + + def test_snapshot_excludes_superseded_body_records(self) -> None: + result = { + "feature_results": [ + {"feature_id": "base", "body_id": "body:base"}, + {"feature_id": "cut", "body_id": "body:cut"}, + ], + "topology_records": [ + {"record_id": "body:base", "kind": "body", "body_id": "body:base", "feature_id": "base"}, + {"record_id": "body:base:edge:0", "kind": "edge", "body_id": "body:base", "feature_id": "base"}, + {"record_id": "body:cut", "kind": "body", "body_id": "body:cut", "feature_id": "cut"}, + {"record_id": "body:cut:edge:0", "kind": "edge", "body_id": "body:cut", "feature_id": "cut"}, + ], + } + + snapshot = topology_snapshot(result) + + self.assertEqual(snapshot["body_id"], "body:cut") + self.assertEqual([record["record_id"] for record in snapshot["records"]], ["body:cut", "body:cut:edge:0"]) + + def test_preview_faces_are_audited_but_not_executable(self) -> None: + snapshot = topology_snapshot( + {"topology_records": []}, + task_id="cad_test", + revision_id="rev_001", + preview={"topology_faces": [{"id": "preview_face", "surface_type": "plane", "center": [0, 0, 1], "normal": [0, 0, 1]}]}, + ) + self.assertEqual(snapshot["records"][0]["record_id"], "preview_face") + self.assertTrue(snapshot["records"][0]["synthetic"]) + self.assertFalse(snapshot["records"][0]["executable"]) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 8db38ace..661ff7b2 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -31,7 +31,6 @@ "@types/react": "^19", "@types/react-dom": "^19", "@types/three": "^0.185.4", - "puppeteer-core": "^25.8.0", "tailwindcss": "^4", "tsx": "^4.20.6", "typescript": "^5" @@ -1491,35 +1490,6 @@ "node": ">= 10" } }, - "node_modules/@puppeteer/browsers": { - "version": "3.2.1", - "resolved": "https://registry.npmmirror.com/@puppeteer/browsers/-/browsers-3.2.1.tgz", - "integrity": "sha512-KDz+3qDRdBAlRlMjmKyj6dEs33YHTk/xRHEENSXq6TNnhgoU15ruSHtEBeVF6OZ9tBDY55Se4P0nFMNsipzU9A==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "modern-tar": "^0.8.0", - "yargs": "^18.0.0" - }, - "bin": { - "browsers": "lib/main-cli.js" - }, - "engines": { - "node": ">=22.12.0" - }, - "peerDependencies": { - "proxy-agent": ">=8.0.1", - "yauzl": "^2.10.0 || ^3.4.0" - }, - "peerDependenciesMeta": { - "proxy-agent": { - "optional": true - }, - "yauzl": { - "optional": true - } - } - }, "node_modules/@radix-ui/number": { "version": "1.1.3", "resolved": "https://registry.npmmirror.com/@radix-ui/number/-/number-1.1.3.tgz", @@ -3455,32 +3425,6 @@ } } }, - "node_modules/ansi-regex": { - "version": "6.3.0", - "resolved": "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-6.3.0.tgz", - "integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/aria-hidden": { "version": "1.2.6", "resolved": "https://registry.npmmirror.com/aria-hidden/-/aria-hidden-1.2.6.tgz", @@ -3557,72 +3501,12 @@ ], "license": "CC-BY-4.0" }, - "node_modules/chromium-bidi": { - "version": "17.0.2", - "resolved": "https://registry.npmmirror.com/chromium-bidi/-/chromium-bidi-17.0.2.tgz", - "integrity": "sha512-5v9GQFhTktFvotn/OFNJBmKLKRAb6n9r0bVCwf7sHgWc3/JryK0bj1nn93L3pHFrfgcsu6Be6EWsDi+1XHTGDg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "mitt": "^3.0.1", - "zod": "^3.24.1" - }, - "engines": { - "node": ">=20.19.0 <22.0.0 || >=22.12.0" - }, - "peerDependencies": { - "devtools-protocol": "*" - } - }, - "node_modules/chromium-bidi/node_modules/zod": { - "version": "3.25.76", - "resolved": "https://registry.npmmirror.com/zod/-/zod-3.25.76.tgz", - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - }, "node_modules/client-only": { "version": "0.0.1", "resolved": "https://registry.npmmirror.com/client-only/-/client-only-0.0.1.tgz", "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", "license": "MIT" }, - "node_modules/cliui": { - "version": "9.0.1", - "resolved": "https://registry.npmmirror.com/cliui/-/cliui-9.0.1.tgz", - "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^7.2.0", - "strip-ansi": "^7.1.0", - "wrap-ansi": "^9.0.0" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/cliui/node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmmirror.com/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmmirror.com/clsx/-/clsx-2.1.1.tgz", @@ -3664,21 +3548,6 @@ "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", "license": "MIT" }, - "node_modules/devtools-protocol": { - "version": "0.0.1666840", - "resolved": "https://registry.npmmirror.com/devtools-protocol/-/devtools-protocol-0.0.1666840.tgz", - "integrity": "sha512-gCcO42XCHKEs7Ag0S7aGYsnJ7hlgrO3qderYqeiY0Eqk+0GFfuvT13IA0hHreJTa2KCdDVyGMeOhdMNmrrTjVg==", - "dev": true, - "license": "BSD-3-Clause", - "peer": true - }, - "node_modules/emoji-regex": { - "version": "10.6.0", - "resolved": "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-10.6.0.tgz", - "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", - "dev": true, - "license": "MIT" - }, "node_modules/enhanced-resolve": { "version": "5.24.5", "resolved": "https://registry.npmmirror.com/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz", @@ -3735,16 +3604,6 @@ "@esbuild/win32-x64": "0.28.2" } }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmmirror.com/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/eventsource-parser": { "version": "3.1.1", "resolved": "https://registry.npmmirror.com/eventsource-parser/-/eventsource-parser-3.1.1.tgz", @@ -3776,29 +3635,6 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/get-caller-file": { - "version": "2.0.5", - "resolved": "https://registry.npmmirror.com/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true, - "license": "ISC", - "engines": { - "node": "6.* || 8.* || >= 10.*" - } - }, - "node_modules/get-east-asian-width": { - "version": "1.6.0", - "resolved": "https://registry.npmmirror.com/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", - "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/get-nonce": { "version": "1.0.1", "resolved": "https://registry.npmmirror.com/get-nonce/-/get-nonce-1.0.1.tgz", @@ -4118,23 +3954,6 @@ "devOptional": true, "license": "MIT" }, - "node_modules/mitt": { - "version": "3.0.1", - "resolved": "https://registry.npmmirror.com/mitt/-/mitt-3.0.1.tgz", - "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", - "dev": true, - "license": "MIT" - }, - "node_modules/modern-tar": { - "version": "0.8.4", - "resolved": "https://registry.npmmirror.com/modern-tar/-/modern-tar-0.8.4.tgz", - "integrity": "sha512-gN54ddmyzEg10orwZ2u4OOv+bjpMWdIl5jIkodK97bMq8QBSL5c0D7YX0lT1Ooz+99S7+PvFbnxzdjgHo1r41g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18.0.0" - } - }, "node_modules/nanoid": { "version": "6.0.1", "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-6.0.1.tgz", @@ -4315,24 +4134,6 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, - "node_modules/puppeteer-core": { - "version": "25.8.0", - "resolved": "https://registry.npmmirror.com/puppeteer-core/-/puppeteer-core-25.8.0.tgz", - "integrity": "sha512-LDOrawV8vfCVk+yLj2ozvajNP4Sv3OV9y3Tpiyy2g2Z+aQlbcozP6KJfI4iSBq7YQER+86ihEtPa5ioiZyWxMQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@puppeteer/browsers": "3.2.1", - "chromium-bidi": "17.0.2", - "devtools-protocol": "0.0.1666840", - "typed-query-selector": "^2.12.2", - "webdriver-bidi-protocol": "0.4.2", - "ws": "^8.21.1" - }, - "engines": { - "node": ">=22.12.0" - } - }, "node_modules/radix-ui": { "version": "1.6.7", "resolved": "https://registry.npmmirror.com/radix-ui/-/radix-ui-1.6.7.tgz", @@ -4614,39 +4415,6 @@ "node": ">=0.10.0" } }, - "node_modules/string-width": { - "version": "8.2.2", - "resolved": "https://registry.npmmirror.com/string-width/-/string-width-8.2.2.tgz", - "integrity": "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==", - "dev": true, - "license": "MIT", - "dependencies": { - "get-east-asian-width": "^1.5.0", - "strip-ansi": "^7.1.2" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/strip-ansi": { - "version": "7.2.0", - "resolved": "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-7.2.0.tgz", - "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.2.2" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, "node_modules/styled-jsx": { "version": "5.1.6", "resolved": "https://registry.npmmirror.com/styled-jsx/-/styled-jsx-5.1.6.tgz", @@ -4767,13 +4535,6 @@ "fsevents": "~2.3.3" } }, - "node_modules/typed-query-selector": { - "version": "2.12.2", - "resolved": "https://registry.npmmirror.com/typed-query-selector/-/typed-query-selector-2.12.2.tgz", - "integrity": "sha512-EOPFbyIub4ngnEdqi2yOcNeDLaX/0jcE1JoAXQDDMIthap7FoN795lc/SHfIq2d416VufXpM8z/lD+WRm2gfOQ==", - "dev": true, - "license": "MIT" - }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz", @@ -4910,109 +4671,6 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, - "node_modules/webdriver-bidi-protocol": { - "version": "0.4.2", - "resolved": "https://registry.npmmirror.com/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.2.tgz", - "integrity": "sha512-VSV+fzfChirL3e7jay2yUC7B4HQCGtEWEg/MSSQbK+qWbqeGlRLlXTzPpYr3XGUvbpDHumWZBJxgesg4N7dbtA==", - "dev": true, - "license": "Apache-2.0" - }, - "node_modules/wrap-ansi": { - "version": "9.0.2", - "resolved": "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-9.0.2.tgz", - "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.1", - "string-width": "^7.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi/node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmmirror.com/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ws": { - "version": "8.21.3", - "resolved": "https://registry.npmmirror.com/ws/-/ws-8.21.3.tgz", - "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, - "node_modules/y18n": { - "version": "5.0.8", - "resolved": "https://registry.npmmirror.com/y18n/-/y18n-5.0.8.tgz", - "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=10" - } - }, - "node_modules/yargs": { - "version": "18.1.0", - "resolved": "https://registry.npmmirror.com/yargs/-/yargs-18.1.0.tgz", - "integrity": "sha512-2rAgRKu54VsHkqI0/tYkmluGXHD4KW7yZoycuqDQ15QOTnc2VVfy0nN/1eMhnQLO00A+dwtK20xuCnc1YGeUyg==", - "dev": true, - "license": "MIT", - "dependencies": { - "cliui": "^9.0.1", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "string-width": "^8.2.1", - "y18n": "^5.0.5", - "yargs-parser": "^22.0.0" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=23" - } - }, - "node_modules/yargs-parser": { - "version": "22.0.0", - "resolved": "https://registry.npmmirror.com/yargs-parser/-/yargs-parser-22.0.0.tgz", - "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", - "dev": true, - "license": "ISC", - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=23" - } - }, "node_modules/zod": { "version": "4.4.3", "resolved": "https://registry.npmmirror.com/zod/-/zod-4.4.3.tgz", diff --git a/frontend/package.json b/frontend/package.json index 4c1c4514..7880825c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -33,7 +33,6 @@ "@types/react": "^19", "@types/react-dom": "^19", "@types/three": "^0.185.4", - "puppeteer-core": "^25.8.0", "tailwindcss": "^4", "tsx": "^4.20.6", "typescript": "^5" diff --git a/frontend/src/app/api/conversations/[conversationId]/attachments/route.ts b/frontend/src/app/api/conversations/[conversationId]/attachments/route.ts new file mode 100644 index 00000000..aacd9727 --- /dev/null +++ b/frontend/src/app/api/conversations/[conversationId]/attachments/route.ts @@ -0,0 +1,12 @@ +import { NextRequest, NextResponse } from "next/server"; +import { backendFetch, readBackendError } from "@/lib/backend"; + +export const runtime = "nodejs"; + +export async function POST(request: NextRequest, context: { params: Promise<{ conversationId: string }> }) { + const { conversationId } = await context.params; + const body = await request.formData(); + const response = await backendFetch(`/v1/conversations/${encodeURIComponent(conversationId)}/attachments`, { method: "POST", body }); + if (!response.ok) return NextResponse.json({ error: await readBackendError(response) }, { status: response.status }); + return NextResponse.json(await response.json()); +} diff --git a/frontend/src/app/api/conversations/[conversationId]/route.ts b/frontend/src/app/api/conversations/[conversationId]/route.ts index df7bc9de..40d3db6a 100644 --- a/frontend/src/app/api/conversations/[conversationId]/route.ts +++ b/frontend/src/app/api/conversations/[conversationId]/route.ts @@ -18,7 +18,6 @@ export async function PATCH(request: NextRequest, context: { params: Promise<{ c headers: { "Content-Type": "application/json" }, body: JSON.stringify({ current_task_id: body.currentTaskId || null, - attachments: Array.isArray(body.attachments) ? body.attachments : undefined, }), }); if (!response.ok) return NextResponse.json({ error: await readBackendError(response) }, { status: response.status }); diff --git a/frontend/src/app/api/uploads/route.ts b/frontend/src/app/api/tasks/[taskId]/quality/route.ts similarity index 59% rename from frontend/src/app/api/uploads/route.ts rename to frontend/src/app/api/tasks/[taskId]/quality/route.ts index 34568d25..29a3ddcf 100644 --- a/frontend/src/app/api/uploads/route.ts +++ b/frontend/src/app/api/tasks/[taskId]/quality/route.ts @@ -3,9 +3,9 @@ import { backendFetch, readBackendError } from "@/lib/backend"; export const runtime = "nodejs"; -export async function POST(request: NextRequest) { - const body = await request.formData(); - const response = await backendFetch("/v1/uploads", { method: "POST", body }); +export async function GET(_: NextRequest, { params }: { params: Promise<{ taskId: string }> }) { + const { taskId } = await params; + const response = await backendFetch(`/v1/tasks/${encodeURIComponent(taskId)}/quality`); if (!response.ok) return NextResponse.json({ error: await readBackendError(response) }, { status: response.status }); return NextResponse.json(await response.json()); } diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index d00cd5e2..29b84cc6 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -75,7 +75,6 @@ --ui-loading-overlay: rgb(233 238 242 / 70%); --ui-loading-overlay-strong: rgb(245 247 248 / 88%); --ui-drag-overlay: rgb(255 255 255 / 88%); - --ui-drag-shadow: 0 0 0 999px rgb(15 25 30 / 18%); --ui-shadow-soft: 0 12px 30px rgb(16 24 32 / 12%); --ui-shadow-panel: 0 22px 60px rgb(16 24 32 / 14%); --ui-shadow-popover: 0 18px 46px rgb(16 24 32 / 16%); @@ -161,7 +160,6 @@ --ui-loading-overlay: rgb(13 15 17 / 35%); --ui-loading-overlay-strong: rgb(13 15 17 / 76%); --ui-drag-overlay: rgb(16 19 21 / 88%); - --ui-drag-shadow: 0 0 0 999px rgb(17 19 21 / 42%); --ui-shadow-soft: 0 10px 24px rgb(0 0 0 / 20%); --ui-shadow-panel: 0 24px 60px rgb(0 0 0 / 26%); --ui-shadow-popover: 0 18px 48px rgb(0 0 0 / 28%); @@ -375,6 +373,7 @@ button:disabled { @keyframes generation-edge-soft-out { to { opacity: 0; } } @media (prefers-reduced-motion: reduce) { + .spin { animation: none; } .generation-edge-glow, .generation-edge-glow *, .generation-edge-glow *::before { @@ -395,30 +394,86 @@ button:disabled { .config-warning { display: flex; flex: 0 0 auto; align-items: center; gap: 8px; border-bottom: 1px solid var(--ui-error-border); background: var(--ui-error-bg); color: var(--ui-error-text); font-size: 12px; padding: 8px 12px; } .studio-main { display: flex; min-height: 0; flex: 1; } .agent-pane { display: flex; width: 420px; min-width: 0; min-height: 0; flex: 0 0 auto; flex-direction: column; border-right: 1px solid var(--ui-border); background: var(--ui-panel); } -.preview-pane { min-width: 0; min-height: 0; flex: 1; background: var(--ui-viewer-bg); } +.preview-pane { position: relative; min-width: 0; min-height: 0; flex: 1; background: var(--ui-viewer-bg); } +.generation-status { position: absolute; z-index: 30; top: 12px; right: 12px; width: min(260px, calc(100% - 24px)); max-height: min(42vh, 360px); overflow: auto; border: 1px solid var(--ui-border); border-radius: 6px; background: var(--ui-glass-popover); box-shadow: var(--ui-shadow-soft); backdrop-filter: blur(12px); color: var(--ui-text); padding: 10px; font-size: 12px; } +.generation-status-heading { display: flex; align-items: center; gap: 6px; color: var(--ui-text-strong); font-weight: 650; } +.generation-status-active { margin: 6px 0 8px; color: var(--ui-accent-text); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; overflow-wrap: anywhere; } +.generation-status ul { display: grid; gap: 4px; margin: 0; padding: 0; list-style: none; } +.generation-status li { display: flex; justify-content: space-between; gap: 8px; border-top: 1px solid var(--ui-border-muted); padding-top: 4px; color: var(--ui-text-muted); } +.generation-status li[data-status="completed"] small { color: var(--ui-success); } +.generation-status li[data-status="planned"] small { color: var(--ui-text-subtle); } +.generation-status li[data-status="failed"] small { color: var(--ui-error); } .agent-thread-shell, .thread-root { display: flex; min-height: 0; flex: 1; flex-direction: column; } +.agent-thread-shell { position: relative; } +.spin { animation: ui-spin 900ms linear infinite; } +@keyframes ui-spin { to { transform: rotate(360deg); } } .agent-pane-title { display: flex; height: 40px; flex: 0 0 auto; align-items: center; gap: 8px; border-bottom: 1px solid var(--ui-border); color: var(--ui-text-strong); font-size: 12px; font-weight: 700; padding: 0 12px; } .agent-pane-title svg { color: var(--ui-accent); } .thread-viewport { min-height: 0; flex: 1; overflow-y: auto; padding: 12px; } .message-list { display: grid; } -.message-row { display: flex; border-bottom: 1px solid var(--ui-border-muted); padding: 10px 2px; } -.user-row { justify-content: flex-end; } -.assistant-row { justify-content: flex-start; } -.message-bubble { max-width: 100%; min-width: 0; } -.user-bubble { max-width: 90%; border-radius: 5px; background: var(--ui-accent); color: var(--ui-accent-contrast); padding: 8px 10px; } -.assistant-bubble { width: 100%; color: var(--ui-text); } +.message-row { display: grid; gap: 6px; border-bottom: 1px solid var(--ui-border-muted); padding: 12px 2px; } +.user-row { padding-left: 32px; } +.message-role { display: flex; align-items: center; gap: 7px; color: var(--ui-text-muted); font-size: 11px; font-weight: 700; } +.message-role svg { color: var(--ui-text-muted); } +.assistant-row .message-role svg { color: var(--ui-accent); } +.message-content { min-width: 0; color: var(--ui-text); } .message-text { margin: 0; font-size: 12px; line-height: 1.65; overflow-wrap: anywhere; white-space: pre-wrap; } -.thread-empty { display: grid; gap: 7px; border: 1px dashed var(--ui-border-strong); border-radius: 5px; color: var(--ui-text-muted); font-size: 12px; line-height: 1.55; padding: 14px; } -.thread-empty strong { color: var(--ui-text-strong); } -.attachment-list { display: grid; gap: 4px; margin-bottom: 10px; border-bottom: 1px solid var(--ui-border-muted); padding-bottom: 10px; } +.message-request-error { display: flex; align-items: flex-start; gap: 7px; margin-top: 8px; color: var(--ui-error-text); font-size: 11px; line-height: 1.45; } +.message-request-error svg { flex: 0 0 auto; margin-top: 1px; } +.thread-empty { color: var(--ui-text-muted); font-size: 12px; line-height: 1.55; padding: 2px 0 14px; } +.attachment-list { display: grid; gap: 4px; margin-bottom: 10px; border-bottom: 1px solid var(--ui-border-muted); padding: 0 1px 10px; } .attachment-card { display: flex; min-width: 0; align-items: center; gap: 7px; color: var(--ui-text-muted); font-size: 11px; } .attachment-card svg { flex: 0 0 auto; color: var(--ui-accent); }.attachment-card span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.attachment-card small { margin-left: auto; color: var(--ui-text-subtle); font-size: 10px; white-space: nowrap; } +.upload-error { display: flex; align-items: flex-start; gap: 7px; margin: 0 1px 10px; border-bottom: 1px solid var(--ui-error-border); color: var(--ui-error-text); font-size: 11px; line-height: 1.45; padding: 0 0 10px; } +.upload-error svg { flex: 0 0 auto; margin-top: 1px; } .composer-shell { flex: 0 0 auto; border-top: 1px solid var(--ui-border); padding: 12px; } +.composer-file-input { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; } .composer-root { display: grid; gap: 8px; } .composer-input { min-height: 96px; max-height: 176px; width: 100%; resize: none; border: 1px solid var(--ui-border); border-radius: 4px; background: var(--ui-control-bg); color: var(--ui-text); font-size: 12px; line-height: 1.55; outline: none; padding: 9px; } -.composer-input:focus { border-color: var(--ui-accent); }.composer-footer { display: flex; align-items: center; justify-content: space-between; gap: 8px; color: var(--ui-text-muted); font-size: 11px; }.composer-footer > span, .composer-footer > div { display: flex; align-items: center; gap: 7px; }.composer-action, .composer-send { display: grid; width: 30px; height: 30px; place-items: center; border: 1px solid var(--ui-border); border-radius: 4px; background: var(--ui-control-bg); color: var(--ui-text-muted); }.composer-send { border-color: var(--ui-accent); background: var(--ui-accent); color: var(--ui-accent-contrast); } -.cad-card { display: flex; gap: 9px; margin: 6px 0; border: 1px solid var(--ui-border); border-radius: 5px; background: var(--ui-panel-muted); padding: 9px; }.cad-card-icon { display: grid; flex: 0 0 auto; width: 25px; height: 25px; place-items: center; border-radius: 4px; background: var(--ui-accent-soft); color: var(--ui-accent); }.cad-card-body { min-width: 0; }.cad-card-title { color: var(--ui-text-strong); font-size: 12px; font-weight: 700; }.cad-card-copy, .cad-result-meta { color: var(--ui-text-muted); font-size: 11px; line-height: 1.45; overflow-wrap: anywhere; }.download-row { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 7px; }.download-link { border-bottom: 1px solid currentColor; color: var(--ui-link); font-size: 11px; text-decoration: none; }.cad-error-card { border-color: var(--ui-error-border); background: var(--ui-error-bg); }.cad-error-card .cad-card-icon { background: transparent; color: var(--ui-error-text); } +.composer-input:focus-visible { border-color: var(--ui-accent); box-shadow: 0 0 0 3px var(--ui-focus-ring); } +.composer-footer { display: flex; align-items: center; justify-content: space-between; gap: 8px; color: var(--ui-text-muted); font-size: 11px; } +.composer-footer > span, .composer-actions { display: flex; align-items: center; gap: 7px; } +.composer-actions { flex: 0 0 auto; } +.composer-command { display: inline-flex; height: 32px; align-items: center; justify-content: center; gap: 5px; border: 1px solid var(--ui-border); border-radius: 4px; background: var(--ui-control-bg); color: var(--ui-text-muted); font-size: 11px; font-weight: 600; padding: 0 9px; } +.composer-command:hover:not(:disabled) { background: var(--ui-control-hover); color: var(--ui-text); } +.composer-command:focus-visible, .download-link:focus-visible { outline: 2px solid var(--ui-focus-ring); outline-offset: 2px; } +.composer-send { border-color: var(--ui-accent); background: var(--ui-accent); color: var(--ui-accent-contrast); } +.composer-send:hover:not(:disabled) { background: var(--ui-accent-hover); color: var(--ui-accent-contrast); } +.composer-cancel { border-color: var(--ui-accent-border); background: var(--ui-accent-soft); color: var(--ui-accent-text); } +.file-drop-overlay { position: absolute; z-index: 20; inset: 8px; display: grid; place-content: center; justify-items: center; gap: 8px; border: 1px dashed var(--ui-accent); border-radius: 5px; background: var(--ui-drag-overlay); color: var(--ui-accent); font-size: 12px; font-weight: 700; pointer-events: none; } +.cad-message { display: grid; gap: 3px; margin: 9px 0 0; min-width: 0; padding-left: 2px; } +.cad-message-heading { display: flex; min-width: 0; align-items: center; gap: 7px; color: var(--ui-text); font-size: 12px; font-weight: 600; } +.cad-message-heading svg { flex: 0 0 auto; color: var(--ui-accent); } +.cad-status-label { color: var(--ui-text-subtle); font-size: 10px; font-weight: 500; text-transform: uppercase; } +.cad-message-copy { margin-left: 21px; color: var(--ui-text-muted); font-size: 11px; line-height: 1.45; overflow-wrap: anywhere; } +.cad-progress.is-error .cad-message-heading, .cad-progress.is-error .cad-message-heading svg, .cad-error .cad-message-heading, .cad-error .cad-message-heading svg { color: var(--ui-error-text); } +.cad-result { margin-top: 12px; } +.cad-result-title { margin-left: 21px; color: var(--ui-accent-text); font-size: 12px; font-weight: 600; line-height: 1.45; overflow-wrap: anywhere; } +.cad-result-meta { display: flex; flex-wrap: wrap; gap: 3px 10px; margin-left: 21px; color: var(--ui-text-muted); font-size: 10px; line-height: 1.4; overflow-wrap: anywhere; } +.cad-quality-status { font-weight: 600; } +.quality-accepted { color: #2f8f5b; } +.quality-built_with_warnings, .quality-historical { color: #a66a1f; } +.quality-needs_repair, .quality-blocked, .quality-failed { color: #b44545; } +.cad-result-notes { display: grid; grid-template-columns: 42px minmax(0, 1fr); gap: 6px; margin: 8px 0 0 21px; color: var(--ui-text-muted); font-size: 11px; line-height: 1.45; } +.cad-result-notes strong { color: var(--ui-text); } +.cad-quality-results { display: grid; grid-template-columns: 42px minmax(0, 1fr); gap: 6px; margin: 8px 0 0 21px; color: var(--ui-text-muted); font-size: 11px; line-height: 1.45; } +.cad-quality-results > strong { color: var(--ui-text); } +.cad-quality-results ul { display: grid; gap: 3px; margin: 0; padding: 0; list-style: none; } +.cad-quality-results li { display: flex; flex-wrap: wrap; gap: 6px; overflow-wrap: anywhere; } +.quality-rule-passed { color: #2f8f5b; } +.quality-rule-failed, .quality-rule-unavailable { color: #b44545; } +.download-row { display: flex; flex-wrap: wrap; gap: 9px; margin: 2px 0 0 21px; } +.download-link { display: inline-flex; align-items: center; gap: 4px; color: var(--ui-link); font-size: 11px; text-decoration: none; text-underline-offset: 2px; } +.download-link:hover { color: var(--ui-link-hover); text-decoration: underline; } +.cad-image-part-type { margin-left: 21px; color: var(--ui-text); font-size: 11px; font-weight: 600; line-height: 1.45; } +.image-analysis-section { display: grid; gap: 2px; margin: 4px 0 0 21px; } +.image-analysis-section > span { color: var(--ui-text-subtle); font-size: 10px; font-weight: 700; } +.image-analysis-section p { margin: 0; color: var(--ui-text-muted); font-size: 11px; line-height: 1.45; overflow-wrap: anywhere; } +.image-dimension-list { display: grid; gap: 5px; margin: 1px 0 0; padding: 0; list-style: none; } +.image-dimension-list li { display: grid; gap: 1px; color: var(--ui-text-muted); font-size: 11px; line-height: 1.4; } +.image-dimension-list strong { color: var(--ui-text); font-weight: 600; } +.image-dimension-list span { overflow-wrap: anywhere; } .viewer-state { display: flex; height: 100%; min-height: 42vh; align-items: center; justify-content: center; gap: 9px; color: var(--ui-text-muted); font-size: 12px; }.viewer-state svg { color: var(--ui-accent); }.viewer-state-error { color: var(--ui-error-text); }.viewer-state-error svg { color: var(--ui-error-text); } .viewer-loading { position: absolute; z-index: 40; left: 50%; top: 50%; display: flex; align-items: center; gap: 8px; transform: translate(-50%, -50%); border: 1px solid var(--ui-border); border-radius: 5px; background: var(--ui-glass-popover); color: var(--ui-text-muted); font-size: 12px; padding: 9px 12px; box-shadow: var(--ui-shadow-soft); } .cad-viewer-dark { background: var(--ui-viewer-bg); } -@media (max-width: 767px) { .studio-app { height: auto; min-height: 100vh; overflow: visible; }.app-header { height: auto; min-height: 48px; flex-wrap: wrap; padding: 8px 12px; }.task-badge { display: none; }.app-controls { width: 100%; }.app-controls select { flex: 1; }.studio-main { min-height: 0; flex-direction: column; }.preview-pane { order: -1; min-height: 46vh; }.agent-pane { width: 100%; min-height: 560px; border-top: 1px solid var(--ui-border); border-right: 0; }.thread-viewport { max-height: 480px; }.composer-footer > span { display: none; } } +@media (max-width: 767px) { .studio-app { height: auto; min-height: 100vh; overflow: visible; }.app-header { height: auto; min-height: 48px; flex-wrap: wrap; padding: 8px 12px; }.task-badge { display: none; }.app-controls { width: 100%; }.app-controls select { flex: 1; }.studio-main { min-height: 0; flex-direction: column; }.preview-pane { min-height: 46vh; }.agent-pane { width: 100%; min-height: 560px; border-right: 0; border-bottom: 1px solid var(--ui-border); }.thread-viewport { max-height: 480px; }.composer-footer > span { display: none; } } diff --git a/frontend/src/components/agent-studio.tsx b/frontend/src/components/agent-studio.tsx index 767c857a..67882645 100644 --- a/frontend/src/components/agent-studio.tsx +++ b/frontend/src/components/agent-studio.tsx @@ -6,13 +6,14 @@ import { useChat } from "@ai-sdk/react"; import { DefaultChatTransport } from "ai"; import { AlertCircle, Box, Loader2, Moon, Sun } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { latestSuccessfulResult } from "@/lib/cad-artifacts"; +import { activeCheckpointPreview, latestSuccessfulResult } from "@/lib/cad-artifacts"; import { normalizeCadMessages } from "@/lib/cad-messages"; import type { ViewerSelectionContext } from "@/lib/viewer-selection"; import type { BackendConfig, CadError, CadAttachment, + CadProgress, CadResult, CadUIMessage, ConversationRecord, @@ -34,10 +35,13 @@ export function AgentStudio() { const [lastError, setLastError] = useState(""); const [attachments, setAttachments] = useState([]); const [uploading, setUploading] = useState(false); + const [uploadError, setUploadError] = useState(""); const [providerId, setProviderId] = useState(""); const [modelId, setModelId] = useState(""); const [theme, setTheme] = useState<"light" | "dark">("light"); const [viewerSelection, setViewerSelection] = useState(null); + const [taskRunning, setTaskRunning] = useState(false); + const [taskRecord, setTaskRecord] = useState(null); const syncUrl = useCallback((conversation: string, task: string) => { const params = new URLSearchParams(window.location.search); @@ -72,10 +76,12 @@ export function AgentStudio() { const taskId = urlTaskId || conversation.current_task_id || ""; let restored: CadResult | null = null; + let restoredTask: TaskRecord | null = null; if (taskId) { const taskResponse = await fetch(`/api/tasks/${encodeURIComponent(taskId)}`, { cache: "no-store" }); if (taskResponse.ok) { - restored = latestSuccessfulResult((await taskResponse.json()) as TaskRecord); + restoredTask = (await taskResponse.json()) as TaskRecord; + restored = activeCheckpointPreview(restoredTask) ?? latestSuccessfulResult(restoredTask); } } @@ -89,6 +95,8 @@ export function AgentStudio() { setProviderId(defaultProvider?.id || ""); setModelId(defaultProvider?.models.find((model) => model.id === nextConfig.default_model)?.id || defaultProvider?.models[0]?.id || ""); setCadResult(restored); + setTaskRunning(restoredTask?.lifecycle === "running"); + setTaskRecord(restoredTask); setLoadState("ready"); syncUrl(nextConversationId, taskId); } catch (error) { @@ -126,6 +134,7 @@ export function AgentStudio() { setViewerSelection(null); setSelectedTaskId(result.taskId); setLastError(""); + if (result.lifecycle) setTaskRunning(result.lifecycle === "running"); if (conversationId) { syncUrl(conversationId, result.taskId); void fetch(`/api/conversations/${encodeURIComponent(conversationId)}`, { @@ -136,48 +145,79 @@ export function AgentStudio() { } }, [conversationId, syncUrl]); + const handleTaskState = useCallback((progress: CadProgress) => { + const taskId = String(progress.taskId || ""); + if (taskId) { + setSelectedTaskId(taskId); + if (conversationId) { + syncUrl(conversationId, taskId); + void fetch(`/api/conversations/${encodeURIComponent(conversationId)}`, { + method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ currentTaskId: taskId }), + }); + } + } + if (progress.step === "generation_plan" && progress.status === "running") setTaskRunning(true); + if (progress.step === "task_terminal") setTaskRunning(progress.lifecycle === "running"); + }, [conversationId, syncUrl]); + const handleError = useCallback((error: CadError) => { setLastError(error.message); + if (error.stage === "generation") setTaskRunning(false); }, []); const handleUpload = useCallback(async (files: FileList | null) => { - if (!files?.length) return; - const selectedModel = config?.providers - .find((provider) => provider.id === providerId) - ?.models.find((model) => model.id === modelId); - const includesImage = Array.from(files).some((file) => file.type.startsWith("image/") || /\.(png|jpe?g|webp)$/i.test(file.name)); - if (includesImage && !selectedModel?.vision) { - setLastError("当前模型不支持图片。请选择标记为 Vision 的 OpenAI 或 Kimi 模型后再上传图片。"); - return; - } + const selectedFiles = Array.from(files || []); + if (!selectedFiles.length) return; + setUploadError(""); setUploading(true); try { const uploaded: CadAttachment[] = []; - for (const file of Array.from(files)) { + for (const file of selectedFiles) { const form = new FormData(); form.set("file", file); - if (selectedTaskId) form.set("task_id", selectedTaskId); - const response = await fetch("/api/uploads", { method: "POST", body: form }); + const response = await fetch(`/api/conversations/${encodeURIComponent(conversationId)}/attachments`, { method: "POST", body: form }); const payload = await response.json() as CadAttachment & { error?: string }; if (!response.ok) throw new Error(payload.error || `${file.name} 上传失败`); uploaded.push(payload); - if (!selectedTaskId) setSelectedTaskId(payload.task_id); } - setAttachments((current) => { - const next = [...current, ...uploaded]; - if (conversationId) void fetch(`/api/conversations/${encodeURIComponent(conversationId)}`, { - method: "PATCH", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ currentTaskId: selectedTaskId || uploaded[0]?.task_id || null, attachments: next }), - }); - return next; - }); + setAttachments((current) => [...current, ...uploaded]); setLastError(""); } catch (error) { - setLastError(error instanceof Error ? error.message : "附件上传失败"); + const message = error instanceof Error ? error.message : "附件上传失败"; + setUploadError(message); + setLastError(message); } finally { setUploading(false); } - }, [config, conversationId, modelId, providerId, selectedTaskId]); + }, [conversationId]); + + useEffect(() => { + if (!taskRunning || !selectedTaskId) return; + let cancelled = false; + const refresh = async () => { + try { + const response = await fetch(`/api/tasks/${encodeURIComponent(selectedTaskId)}`, { cache: "no-store" }); + if (!response.ok) return; + const next = await response.json() as TaskRecord; + if (cancelled) return; + setTaskRecord(next); + const running = next.lifecycle === "running"; + setTaskRunning(running); + if (running) { + const preview = activeCheckpointPreview(next); + if (preview) setCadResult(preview); + } else { + const restored = latestSuccessfulResult(next); + if (restored) setCadResult(restored); + } + } catch { + // Keep the persisted lock until a later poll can prove a terminal state. + } + }; + void refresh(); + const timer = window.setInterval(() => void refresh(), 2000); + return () => { cancelled = true; window.clearInterval(timer); }; + }, [selectedTaskId, taskRunning]); if (loadState === "loading") { return ; @@ -197,6 +237,7 @@ export function AgentStudio() { initialMessages={initialMessages} onCadResult={handleResult} onCadError={handleError} + onCadProgress={handleTaskState} > ); @@ -228,6 +272,7 @@ function AgentRuntime({ initialMessages, onCadResult, onCadError, + onCadProgress, children, }: { conversationId: string; @@ -238,6 +283,7 @@ function AgentRuntime({ initialMessages: CadUIMessage[]; onCadResult: (result: CadResult) => void; onCadError: (error: CadError) => void; + onCadProgress: (progress: CadProgress) => void; children: React.ReactNode; }) { const conversationRef = useRef(conversationId); @@ -247,6 +293,7 @@ function AgentRuntime({ const viewerSelectionRef = useRef(viewerSelection); const onCadResultRef = useRef(onCadResult); const onCadErrorRef = useRef(onCadError); + const onCadProgressRef = useRef(onCadProgress); conversationRef.current = conversationId; taskRef.current = selectedTaskId; providerRef.current = providerId; @@ -254,6 +301,7 @@ function AgentRuntime({ viewerSelectionRef.current = viewerSelection; onCadResultRef.current = onCadResult; onCadErrorRef.current = onCadError; + onCadProgressRef.current = onCadProgress; const transport = useMemo(() => new DefaultChatTransport({ api: "/api/chat", @@ -278,6 +326,9 @@ function AgentRuntime({ messages: initialMessages, transport, onData: (part) => { + if (part.type === "data-cad-progress") { + onCadProgressRef.current(part.data as CadProgress); + } if (part.type === "data-cad-result") { onCadResultRef.current(part.data as CadResult); taskRef.current = (part.data as CadResult).taskId; @@ -425,6 +476,7 @@ function StudioShell({ lastError, attachments, uploading, + uploadError, onUpload, theme, onToggleTheme, @@ -435,12 +487,15 @@ function StudioShell({ onCadResult, onCadError, onSelectionChange, + taskRunning, + taskRecord, }: { config: BackendConfig | null; cadResult: CadResult | null; lastError: string; attachments: CadAttachment[]; uploading: boolean; + uploadError: string; onUpload: (files: FileList | null) => void; theme: "light" | "dark"; onToggleTheme: () => void; @@ -451,8 +506,10 @@ function StudioShell({ onCadResult: (result: CadResult) => void; onCadError: (error: CadError) => void; onSelectionChange: (selection: ViewerSelectionContext | null) => void; + taskRunning: boolean; + taskRecord: TaskRecord | null; }) { - const running = useAuiState((state) => state.thread.isRunning); + const running = useAuiState((state) => state.thread.isRunning) || taskRunning; const provider = config?.providers.find((item) => item.id === providerId); const handleViewerError = useCallback((message: string) => { onCadError({ stage: "viewer", message }); @@ -462,24 +519,43 @@ function StudioShell({
CDSL CAD Studio{cadResult ? {cadResult.taskId} : null}
- { const id = event.target.value; onProviderChange(id); onModelChange(config?.providers.find((item) => item.id === id)?.models[0]?.id || ""); }}> {config?.providers.map((item) => )} - onModelChange(event.target.value)}> {provider?.models.map((model) => )} - +
{!config?.configured ?
未配置模型环境变量,聊天会保留诊断但不会生成虚假模型。
: null} + {config?.incremental_generation && !config.review_configured ?
视觉复核未配置,增量任务会拒绝启动:{config.review_error || "请配置独立视觉模型与 Chromium。"}
: null}
- -
+ +
+ + +
); } +function GenerationStatus({ task }: { task: TaskRecord | null }) { + const plan = task?.generation_plan; + const nodes = Array.isArray(plan?.nodes) ? plan.nodes.filter((node): node is Record => Boolean(node && typeof node === "object")) : []; + if (task?.lifecycle !== "running") return null; + return ( + + ); +} + function StudioLoading() { return (
diff --git a/frontend/src/components/agent-thread.tsx b/frontend/src/components/agent-thread.tsx index 53e71909..37fcef5d 100644 --- a/frontend/src/components/agent-thread.tsx +++ b/frontend/src/components/agent-thread.tsx @@ -1,59 +1,133 @@ "use client"; -import { Check, FileImage, FileText, Loader2, MessageSquare, Paperclip, Send, Square } from "lucide-react"; -import { useRef } from "react"; +import { Bot, Check, CircleAlert, FileImage, FileText, Loader2, MessageSquare, Paperclip, Send, Sparkles, Upload } from "lucide-react"; +import { useRef, useState, type ChangeEvent, type DragEvent } from "react"; import { ComposerPrimitive, MessagePrimitive, ThreadPrimitive, useAuiState } from "@assistant-ui/react"; import type { CadAttachment } from "@/lib/cad-types"; -import { CadErrorPart, CadProgressPart, CadResultPart, TextPart } from "./cad-message-parts"; +import { CadErrorPart, CadImageAnalysisPart, CadProgressPart, CadResultPart, TextPart } from "./cad-message-parts"; -export function AgentThread({ attachments, uploading, onUpload }: { +export function AgentThread({ attachments, uploading, uploadError, taskRunning = false, onUpload }: { attachments: CadAttachment[]; uploading: boolean; + uploadError: string; + taskRunning?: boolean; onUpload: (files: FileList | null) => void; }) { const fileInput = useRef(null); + const dragDepth = useRef(0); + const running = useAuiState((state) => state.thread.isRunning) || taskRunning; + const [isDraggingFiles, setIsDraggingFiles] = useState(false); + const canUpload = !uploading && !running; + + const onDragEnter = (event: DragEvent) => { + event.preventDefault(); + event.stopPropagation(); + dragDepth.current += 1; + if (canUpload && isFileDrag(event)) setIsDraggingFiles(true); + }; + + const onDragOver = (event: DragEvent) => { + event.preventDefault(); + event.stopPropagation(); + event.dataTransfer.dropEffect = canUpload ? "copy" : "none"; + }; + + const onDragLeave = (event: DragEvent) => { + event.preventDefault(); + event.stopPropagation(); + dragDepth.current = Math.max(0, dragDepth.current - 1); + if (dragDepth.current === 0) setIsDraggingFiles(false); + }; + + const onDrop = (event: DragEvent) => { + event.preventDefault(); + event.stopPropagation(); + dragDepth.current = 0; + setIsDraggingFiles(false); + if (canUpload && event.dataTransfer.files.length) onUpload(event.dataTransfer.files); + }; + return ( -
+
Agent
{attachments.length ?
{attachments.map((attachment) => )}
: null} + {uploadError ?
: null} -
描述要生成或修改的 CAD 模型Agent 会检索本地 CDSL 样本并生成可编辑的 CDSL 模型。
+
描述需要生成或修改的 CAD 模型。
- +
+ {isDraggingFiles ?
拖放文件上传
: null}
); } +function isFileDrag(event: DragEvent) { + return event.dataTransfer.files.length > 0 || Array.from(event.dataTransfer.types).includes("Files"); +} + function AttachmentCard({ attachment }: { attachment: CadAttachment }) { const Icon = attachment.kind === "image" ? FileImage : FileText; return
{attachment.name}{attachment.kind === "image" ? "视觉参考" : "文本参考"}
; } function UserMessage() { - return
; + return ( + +
+
+
+ ); } function AssistantMessage() { return ( -
+
+
+ + +
+
+
); } -function Composer({ fileInput, uploading, onUpload }: { fileInput: React.RefObject; uploading: boolean; onUpload: (files: FileList | null) => void }) { - const running = useAuiState((state) => state.thread.isRunning); +function Composer({ fileInput, uploading, taskRunning = false, onUpload }: { fileInput: React.RefObject; uploading: boolean; taskRunning?: boolean; onUpload: (files: FileList | null) => void }) { + const running = useAuiState((state) => state.thread.isRunning) || taskRunning; + const handleFileChange = (event: ChangeEvent) => { + if (event.currentTarget.files?.length) onUpload(event.currentTarget.files); + event.currentTarget.value = ""; + }; return (
- onUpload(event.target.files)} /> + - -
Enter 发送,Shift + Enter 换行
{running ? : }
+ +
+ +
+ {running ? ( + + ) : ( + <> + + + + )} +
+
); diff --git a/frontend/src/components/cad-message-parts.tsx b/frontend/src/components/cad-message-parts.tsx index c51daf42..dfd2471c 100644 --- a/frontend/src/components/cad-message-parts.tsx +++ b/frontend/src/components/cad-message-parts.tsx @@ -1,8 +1,9 @@ "use client"; -import { AlertTriangle, CheckCircle2, Download, Loader2 } from "lucide-react"; +import { AlertTriangle, Box, Check, Download, Loader2, Ruler } from "lucide-react"; +import { useEffect, useState } from "react"; import { encodeArtifactUrl } from "@/lib/cad-artifacts"; -import type { CadError, CadProgress, CadResult } from "@/lib/cad-types"; +import type { CadError, CadImageAnalysis, CadProgress, CadResult } from "@/lib/cad-types"; export function TextPart({ text }: { text: string }) { if (!text.trim()) return null; @@ -10,63 +11,180 @@ export function TextPart({ text }: { text: string }) { } export function CadProgressPart({ data }: { data: CadProgress }) { - const running = data.status === "running"; + if (data.step === "agent_stream") return null; + const status = String(data.status || "").toLowerCase(); + const isRunning = status === "running"; + const isError = status === "error"; + const statusLabel = isRunning ? "进行中" : isError ? "失败" : status === "success" ? "完成" : data.status; return ( -
-
- {running ? : } -
-
-
{data.label || data.step}
- {data.message ?
{data.message}
: null} +
+
+ {isRunning ?
+ {data.message ?
{data.message}
: null}
); } export function CadResultPart({ data }: { data: CadResult }) { - const downloads: Array<[string, string]> = [ + const downloads: Array<[string, string]> = data.checkpoint ? [] : [ ["STEP", data.stepPath], ["CDSL", data.cdslPath], ["GLB", data.glbPath], - ["REPORT", data.reportPath], + ["报告", data.reportPath], ]; - if (data.designIntentPath) downloads.push(["DESIGN INTENT", data.designIntentPath]); + if (!data.checkpoint && data.qualityPath) downloads.push(["质量报告", data.qualityPath]); + if (!data.checkpoint && data.snapshotPaths?.length) downloads.push(["快照清单", data.snapshotPaths[0]]); + const qualityLabels: Record = { + accepted: "验收通过", + built_with_warnings: "构建完成,有警告", + needs_repair: "需要修复", + blocked: "已阻塞", + failed: "失败", + }; + const quality = String(data.qualityStatus || ""); return ( -
-
- +
+
+
-
-
{data.summary || "生成完成"}
-
+
{data.summary || "生成完成"}
+
{data.engine} {data.revisionId} - {data.referenceIds.length} references -
-
- {downloads.map(([label, path]) => ( - - - {label} - - ))} -
+ {data.referenceIds.length} 个参考 + {quality ? {qualityLabels[quality] || quality} : null}
+ {data.assumptions?.length ?
假设{data.assumptions.join(";")}
: null} + {data.referenceIds.length ?
参考{data.referenceIds.join(";")}
: null} + {!data.checkpoint ? : null} + {data.snapshotStatus && data.snapshotStatus !== "unavailable" ?
快照{data.snapshotStatus}
: null} + {downloads.length ?
+ {downloads.map(([label, path]) => ( + + + ))} +
: null} +
+ ); +} + +type QualityResult = { id?: string; status?: string; severity?: string; expected?: unknown; actual?: unknown }; +type QualityPayload = { quality?: { results?: QualityResult[] }; quality_status?: string }; + +function QualitySummary({ data }: { data: CadResult }) { + const [quality, setQuality] = useState(null); + + useEffect(() => { + if (!data.qualityPath) { + setQuality(null); + return; + } + const controller = new AbortController(); + void fetch(`/api/tasks/${encodeURIComponent(data.taskId)}/quality`, { signal: controller.signal }) + .then((response) => response.ok ? response.json() as Promise : null) + .then((payload) => { if (!controller.signal.aborted) setQuality(payload); }) + .catch(() => { if (!controller.signal.aborted) setQuality(null); }); + return () => controller.abort(); + }, [data.qualityPath, data.revisionId, data.taskId]); + + const results = quality?.quality?.results || []; + if (!results.length) return null; + const label: Record = { passed: "通过", failed: "未通过", unavailable: "不可用" }; + return ( +
+ 验证 +
    + {results.map((result, index) => ( +
  • + {result.id || "rule"} + {label[result.status || ""] || result.status || "不可用"} + {result.severity && result.severity !== "blocking" ? {result.severity} : null} +
  • + ))} +
); } -export function CadErrorPart({ data }: { data: CadError }) { +export function CadImageAnalysisPart({ data }: { data: CadImageAnalysis }) { + const dimensionCandidates = data.dimensionCandidates ?? data.requiredDimensions ?? []; + const profileCount = data.profiles?.length || 0; + const holeCount = data.holes?.length || 0; + const viewCount = data.views?.length || data.attachmentIds.length; return ( -
-
- -
-
-
{data.stage || "生成失败"}
-
{data.message}
+
+
+
{data.partType}
+
+ 可见特征 +

{data.visibleFeatures.join(";") || "未识别到可确认特征"}

+ {data.uncertainFeatures.length ?
+ 待确认特征 +

{data.uncertainFeatures.join(";")}

+
: null} + {dimensionCandidates.length ?
+ 可能需要确认的尺寸 +
    + {dimensionCandidates.map((dimension) =>
  • + {dimension.label} + {dimension.reason} +
  • )} +
+
: null} + {(data.views?.length || profileCount || holeCount || data.measurements?.length || data.assumptions?.length) ? ( +
+ 勘测详情 +
+ 视角与几何 +

{viewCount} 个视角,{profileCount} 个轮廓,{holeCount} 个孔/切除特征

+
+ {data.profiles?.length ?
+ 草图候选 +
    + {data.profiles.slice(0, 12).map((profile) =>
  • + {profile.id} + {profile.segments?.length || 0} 段,{profile.closed ? "闭合" : "未确认闭合"}{profile.confidence == null ? "" : `,置信度 ${Math.round(profile.confidence * 100)}%`} +
  • )} +
+
: null} + {data.measurements?.length ?
+ 测量记录 +
    + {data.measurements.slice(0, 16).map((measurement, index) =>
  • + {measurement.name} + {measurement.value_mm == null ? "待确认" : `${measurement.value_mm} mm`} · {measurement.source || "image"} +
  • )} +
+
: null} + {data.uncertainties?.length ?
+ 勘测不确定项 +

{data.uncertainties.slice(0, 16).join(";")}

+
: null} +
+ ) : null} +
+ ); +} + +export function CadErrorPart({ data }: { data: CadError }) { + const stageLabels: Record = { + agent: "Agent", + attachment: "附件", + chat: "对话", + viewer: "预览", + }; + const stage = stageLabels[data.stage] || data.stage || "CAD 操作"; + return ( +
+
+
{data.message}
); } diff --git a/frontend/src/components/cad-viewer-preview.tsx b/frontend/src/components/cad-viewer-preview.tsx index f79f3709..86211d66 100644 --- a/frontend/src/components/cad-viewer-preview.tsx +++ b/frontend/src/components/cad-viewer-preview.tsx @@ -51,11 +51,19 @@ function resultFromBackend(payload: Record): CadResult { parametersPath: typeof payload.parameters_path === "string" ? payload.parameters_path : undefined, selectorPath: typeof payload.selector_path === "string" ? payload.selector_path : undefined, edgesPath: typeof payload.edges_path === "string" ? payload.edges_path : undefined, - designIntentId: typeof payload.design_intent_id === "string" ? payload.design_intent_id : undefined, - designIntentPath: typeof payload.design_intent_path === "string" ? payload.design_intent_path : undefined, + topologyPath: typeof payload.topology_path === "string" ? payload.topology_path : undefined, summary: String(payload.summary || "Updated CDSL model"), referenceIds: Array.isArray(payload.reference_ids) ? payload.reference_ids.map(String) : [], engine: String(payload.engine || "cdsl_only"), + qualityStatus: String(payload.quality_status || ""), + qualityPath: typeof payload.quality_path === "string" ? payload.quality_path : undefined, + assumptions: Array.isArray(payload.generation_assumptions) ? payload.generation_assumptions.map(String) : [], + warnings: Array.isArray(payload.warnings) ? payload.warnings.map(String) : [], + repairAttempts: Number(payload.repair_attempts || 0), + snapshotPaths: Array.isArray(payload.snapshot_paths) ? payload.snapshot_paths.map(String) : [], + snapshotStatus: String(payload.snapshot_status || "unavailable"), + checkpoint: Boolean(payload.checkpoint), + lifecycle: typeof payload.lifecycle === "string" ? payload.lifecycle : undefined, }; } @@ -220,14 +228,32 @@ export function CadViewerPreview({ result, isGenerating, lastError, theme, onRes setLoadState((current) => current.kind === "ready" ? current : { kind: "loading" }); const glbUrl = encodeArtifactUrl(result.taskId, result.glbPath); const selectorUrl = result.selectorPath ? encodeArtifactUrl(result.taskId, result.selectorPath) : ""; + const topologyUrl = result.topologyPath ? encodeArtifactUrl(result.taskId, result.topologyPath) : ""; void Promise.all([ loadRenderGlb(glbUrl), selectorUrl ? loadRenderJson(selectorUrl).catch(() => null) : Promise.resolve(null), + topologyUrl ? loadRenderJson(topologyUrl).catch(() => null) : Promise.resolve(null), ]) - .then(([meshData, selectorSidecar]) => { + .then(([meshData, selectorSidecar, topologySnapshot]) => { if (controller.signal.aborted) return; - const selectorRuntime = selectorSidecar && typeof selectorSidecar === "object" - ? buildCdslSelectorRuntime(selectorSidecar, meshData) + const topologyMatchesRevision = topologySnapshot && typeof topologySnapshot === "object" + && String((topologySnapshot as Record).task_id || "") === result.taskId + && String((topologySnapshot as Record).revision_id || "") === result.revisionId; + const rawTopologyRecords = topologyMatchesRevision ? (topologySnapshot as Record).records : null; + const topologyRecords = Array.isArray(rawTopologyRecords) + ? rawTopologyRecords.filter((record): record is Record => Boolean(record && typeof record === "object")) + : []; + const selectorPayload = selectorSidecar && typeof selectorSidecar === "object" + ? { + ...(selectorSidecar as Record), + edges: Array.isArray((selectorSidecar as Record).edges) + && ((selectorSidecar as Record).edges as unknown[]).length + ? (selectorSidecar as Record).edges + : topologyRecords.filter((record) => record.kind === "edge" && record.executable !== false), + } + : null; + const selectorRuntime = selectorPayload + ? buildCdslSelectorRuntime(selectorPayload as Parameters[0], meshData) : null; setLoadState({ kind: "ready", meshData, selectorRuntime }); setHoveredReferenceId(""); @@ -249,7 +275,7 @@ export function CadViewerPreview({ result, isGenerating, lastError, theme, onRes onError(error instanceof Error ? error.message : "CAD Viewer asset loading failed"); }); return () => controller.abort(); - }, [onError, onSelectionChange, result?.glbPath, result?.revisionId, result?.selectorPath, result?.taskId]); + }, [onError, onSelectionChange, result?.glbPath, result?.revisionId, result?.selectorPath, result?.taskId, result?.topologyPath]); useEffect(() => { if (!reveal) return; @@ -258,7 +284,7 @@ export function CadViewerPreview({ result, isGenerating, lastError, theme, onRes }, [reveal]); useEffect(() => { - if (!result) { + if (!result || result.checkpoint || isGenerating) { setParameters([]); setShowParameters(false); return; @@ -277,7 +303,7 @@ export function CadViewerPreview({ result, isGenerating, lastError, theme, onRes if (!controller.signal.aborted) setParameterError(error instanceof Error ? error.message : "无法读取参数"); }); return () => controller.abort(); - }, [result?.revisionId, result?.taskId]); + }, [isGenerating, result?.checkpoint, result?.revisionId, result?.taskId]); const submitEdit = useCallback(async (operation: string, picks: Record[]) => { if (!result || !operation || !picks.length) return; @@ -375,7 +401,7 @@ export function CadViewerPreview({ result, isGenerating, lastError, theme, onRes }, [loadState, onSelectionChange, result]); const commitParameters = useCallback(async (values: Record) => { - if (!result || !Object.keys(values).length) return; + if (!result || result.checkpoint || isGenerating || !Object.keys(values).length) return; const parameterId = Object.keys(values)[0]; setParameterPending(parameterId); setParameterError(""); @@ -391,13 +417,17 @@ export function CadViewerPreview({ result, isGenerating, lastError, theme, onRes } finally { setParameterPending(""); } - }, [onResult, result]); + }, [isGenerating, onResult, result]); const viewerTheme = useMemo(() => ({ ...VIEWER_THEME, colorMode: theme }), [theme]); const activeToolDefinition = activeTool ? cadEditToolForOperation(activeTool) : null; const pickableFaces = useMemo( - () => loadState.kind === "ready" ? loadState.selectorRuntime?.references.filter((reference) => reference.selectorType === "face") || [] : [], - [loadState] + () => !isGenerating && !result?.checkpoint && loadState.kind === "ready" ? loadState.selectorRuntime?.references.filter((reference) => reference.selectorType === "face") || [] : [], + [isGenerating, loadState, result?.checkpoint] + ); + const pickableEdges = useMemo( + () => !isGenerating && !result?.checkpoint && loadState.kind === "ready" ? loadState.selectorRuntime?.edges || [] : [], + [isGenerating, loadState, result?.checkpoint] ); if (loadState.kind === "empty") return } text="3D 预览等待模型" />; if (loadState.kind === "loading") return } text="加载 CAD Viewer 资产..." />; @@ -424,10 +454,10 @@ export function CadViewerPreview({ result, isGenerating, lastError, theme, onRes selectedReferenceIds={selectedReferenceIds} selectorRuntime={loadState.selectorRuntime} pickableFaces={pickableFaces} - pickableEdges={[]} + pickableEdges={pickableEdges} onHoverReferenceChange={setHoveredReferenceId} onActivateReference={handleActivateReference} - editPointPickEnabled={Boolean(activeTool)} + editPointPickEnabled={Boolean(activeTool) && !isGenerating && !result?.checkpoint} activeEditToolId={activeTool} editToolPickKind={cadEditToolNextPickKind(activeTool, editPicks.length)} editToolPicks={editPicks} @@ -440,16 +470,16 @@ export function CadViewerPreview({ result, isGenerating, lastError, theme, onRes onAiSelectionComplete={onAiSelectionComplete} /> - + /> : null} { setActiveTool(tool); @@ -468,13 +498,13 @@ export function CadViewerPreview({ result, isGenerating, lastError, theme, onRes }} /> viewerRef.current?.zoomToFit?.()} onScreenshot={() => void viewerRef.current?.captureScreenshot?.({ filename: "cdsl-cad.png" })} onParameters={() => setShowParameters(true)} /> {editPending || isGenerating ?
{editPending ? "正在应用 CDSL 编辑..." : "正在生成 CDSL 模型..."}
: null} - {activeToolDefinition ? ( + {activeToolDefinition && !isGenerating && !result?.checkpoint ? (
{activeToolDefinition.label}
@@ -538,7 +568,7 @@ export function CadViewerPreview({ result, isGenerating, lastError, theme, onRes onClose={() => setShowParameters(false)} onCommit={(id, value) => void commitParameters({ [id]: value })} onReset={(values) => void commitParameters(values)} - downloads={[ + downloads={result.checkpoint ? [] : [ { label: "STEP", description: "CAD exchange", url: encodeArtifactUrl(result.taskId, result.stepPath) }, { label: "CDSL", description: "Editable model", url: encodeArtifactUrl(result.taskId, result.cdslPath) }, { label: "GLB", description: "Preview mesh", url: encodeArtifactUrl(result.taskId, result.glbPath) }, diff --git a/frontend/src/lib/cad-artifacts.ts b/frontend/src/lib/cad-artifacts.ts index e1b4d058..1058dc9f 100644 --- a/frontend/src/lib/cad-artifacts.ts +++ b/frontend/src/lib/cad-artifacts.ts @@ -6,11 +6,8 @@ export function encodeArtifactUrl(taskId: string, artifactPath: string) { return `/api/tasks/${encodedTask}/artifacts/${encodedPath}`; } -export function latestSuccessfulResult(task: TaskRecord | null): CadResult | null { - if (!task) return null; - const current = - task.revisions.find((revision) => revision.revision_id === task.current_revision) ?? - [...task.revisions].reverse().find((revision) => revision.status === "success"); +function resultForRevision(task: TaskRecord, revisionId: string, checkpoint: boolean): CadResult | null { + const current = task.revisions.find((revision) => revision.revision_id === revisionId); if ( !current || current.status !== "success" || @@ -31,10 +28,33 @@ export function latestSuccessfulResult(task: TaskRecord | null): CadResult | nul parametersPath: current.parameters_path, selectorPath: current.selector_path, edgesPath: current.edges_path, - designIntentId: current.design_intent_id, - designIntentPath: current.design_intent_path, + topologyPath: current.topology_path, summary: current.summary || "CDSL CAD model", referenceIds: current.reference_ids || [], engine: current.engine || "cdsl_only", + qualityStatus: current.quality_status || "", + qualityPath: current.quality_path, + assumptions: current.generation_assumptions || current.assumptions || [], + warnings: current.warnings || [], + repairAttempts: current.repair_attempts || 0, + snapshotPaths: current.snapshot_manifest_path ? [current.snapshot_manifest_path] : [], + snapshotStatus: current.snapshot_status || "unavailable", + checkpoint, + lifecycle: task.lifecycle || "completed", }; } + +export function activeCheckpointPreview(task: TaskRecord | null): CadResult | null { + if (!task || task.lifecycle !== "running") return null; + const revisionId = task.preview_revision || task.active_revision || task.current_revision; + if (!revisionId) return null; + return resultForRevision(task, revisionId, true); +} + +export function latestSuccessfulResult(task: TaskRecord | null): CadResult | null { + if (!task) return null; + const current = + task.revisions.find((revision) => revision.revision_id === (task.published_revision || task.current_revision)) ?? + [...task.revisions].reverse().find((revision) => revision.status === "success" && revision.visibility !== "checkpoint"); + return current ? resultForRevision(task, current.revision_id, current.visibility === "checkpoint") : null; +} diff --git a/frontend/src/lib/cad-messages.ts b/frontend/src/lib/cad-messages.ts index bf8ce7fb..c03a7c20 100644 --- a/frontend/src/lib/cad-messages.ts +++ b/frontend/src/lib/cad-messages.ts @@ -1,4 +1,4 @@ -import type { CadError, CadProgress, CadResult, CadUIMessage } from "./cad-types"; +import type { CadError, CadImageAnalysis, CadProgress, CadResult, CadUIMessage } from "./cad-types"; type AnyPart = { type?: unknown; text?: unknown; data?: unknown; id?: unknown }; type AnyMessage = { id?: unknown; role?: unknown; parts?: unknown }; @@ -24,6 +24,9 @@ export function normalizeCadMessages(input: unknown): CadUIMessage[] { if (item.type === "data-cad-error") { return [{ type: "data-cad-error", id: stringId(item.id), data: item.data as CadError }]; } + if (item.type === "data-cad-image-analysis") { + return [{ type: "data-cad-image-analysis", id: stringId(item.id), data: item.data as CadImageAnalysis }]; + } return []; }); return [{ diff --git a/frontend/src/lib/cad-stream.test.ts b/frontend/src/lib/cad-stream.test.ts index 695abbf2..f41130e2 100644 --- a/frontend/src/lib/cad-stream.test.ts +++ b/frontend/src/lib/cad-stream.test.ts @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import { backendEventToUiChunk } from "./cad-stream"; -import { latestSuccessfulResult } from "./cad-artifacts"; +import { activeCheckpointPreview, latestSuccessfulResult } from "./cad-artifacts"; import { messagesForBackend } from "./cad-messages"; import type { CadUIMessage } from "./cad-types"; @@ -14,6 +14,37 @@ test("maps backend cad_result SSE into an AI SDK data part", () => { assert.deepEqual("data" in chunk! ? chunk.data : null, { taskId: "cad_abc", revisionId: "rev_001" }); }); +test("keeps progressive revisions as separate data parts", () => { + const first = backendEventToUiChunk({ event: "cad_result", data: { taskId: "cad_abc", revisionId: "rev_001" } }, "text_1"); + const second = backendEventToUiChunk({ event: "cad_result", data: { taskId: "cad_abc", revisionId: "rev_002" } }, "text_1"); + assert.notEqual(first?.id, second?.id); +}); + +test("maps visual repair review into a blocking progress state", () => { + const chunk = backendEventToUiChunk({ + event: "render_review", + data: { taskId: "cad_abc", nodeId: "round", review: { verdict: "repair", confidence: 0.92, evidence: ["missing round"] } }, + }, "text_1"); + assert.equal(chunk?.type, "data-cad-progress"); + assert.deepEqual("data" in chunk! ? chunk.data : null, { + step: "render_review", label: "视觉复核", status: "error", message: "missing round", taskId: "cad_abc", nodeId: "round", + }); +}); + +test("maps structured image analysis SSE into an AI SDK data part", () => { + const data = { + attachmentIds: ["upload_flange"], + partType: "四孔法兰套筒", + visibleFeatures: ["中空圆筒"], + uncertainFeatures: [], + dimensionCandidates: [{ id: "bore_diameter", label: "中心孔直径", reason: "图片未标注" }], + }; + const chunk = backendEventToUiChunk({ event: "image_analysis", data }, "text_1"); + + assert.equal(chunk?.type, "data-cad-image-analysis"); + assert.deepEqual("data" in chunk! ? chunk.data : null, data); +}); + test("restores the latest successful task revision for the viewer", () => { const result = latestSuccessfulResult({ task_id: "cad_abc", @@ -27,6 +58,34 @@ test("restores the latest successful task revision for the viewer", () => { assert.equal(result?.summary, "done"); }); +test("prefers the published revision over an active checkpoint", () => { + const result = latestSuccessfulResult({ + task_id: "cad_abc", + current_revision: "rev_002", + active_revision: "rev_002", + published_revision: "rev_001", + lifecycle: "running", + revisions: [ + { revision_id: "rev_001", status: "success", visibility: "final", cdsl_path: "a", step_path: "b", glb_path: "c", report_path: "d" }, + { revision_id: "rev_002", status: "success", visibility: "checkpoint", cdsl_path: "aa", step_path: "bb", glb_path: "cc", report_path: "dd" }, + ], + }); + assert.equal(result?.revisionId, "rev_001"); + assert.equal(result?.checkpoint, false); +}); + +test("restores an active checkpoint only while the task is running", () => { + const result = activeCheckpointPreview({ + task_id: "cad_abc", current_revision: "rev_002", active_revision: "rev_002", published_revision: "rev_001", lifecycle: "running", + revisions: [ + { revision_id: "rev_001", status: "success", visibility: "final", cdsl_path: "a", step_path: "b", glb_path: "c", report_path: "d" }, + { revision_id: "rev_002", status: "success", visibility: "checkpoint", cdsl_path: "aa", step_path: "bb", glb_path: "cc", report_path: "dd" }, + ], + }); + assert.equal(result?.revisionId, "rev_002"); + assert.equal(result?.checkpoint, true); +}); + test("strips non-text and non-CAD parts before sending to FastAPI", () => { const messages: CadUIMessage[] = [{ id: "m1", diff --git a/frontend/src/lib/cad-stream.ts b/frontend/src/lib/cad-stream.ts index e9bd7e44..127c71bc 100644 --- a/frontend/src/lib/cad-stream.ts +++ b/frontend/src/lib/cad-stream.ts @@ -19,10 +19,37 @@ export function backendEventToUiChunk( data: item.data, }; } + if (["generation_plan", "checkpoint", "render_review", "rollback", "task_terminal"].includes(item.event)) { + const review = item.data.review && typeof item.data.review === "object" + ? item.data.review as Record + : null; + const status = item.event === "task_terminal" + ? (String(item.data.lifecycle || "") === "failed" ? "error" : "success") + : item.event === "render_review" && String(review?.verdict || "") === "repair" && Number(review?.confidence || 0) >= 0.85 + ? "error" + : String(item.data.status || "running"); + return { + type: "data-cad-progress", + id: `${item.event}_${String(item.data.taskId || Date.now())}_${String(item.data.nodeId || "")}`, + data: { step: item.event, label: ({ + generation_plan: "生成计划", checkpoint: "构建检查点", render_review: "视觉复核", rollback: "回滚检查点", task_terminal: "生成任务", + } as Record)[item.event], status, message: String( + item.data.message || item.data.reason || (review?.evidence instanceof Array ? review.evidence.join(";") : ""), + ), + ...(item.data.taskId ? { taskId: String(item.data.taskId) } : {}), + ...(item.data.nodeId ? { nodeId: String(item.data.nodeId) } : {}), + ...(item.data.lifecycle ? { lifecycle: String(item.data.lifecycle) } : {}), + }, + }; + } if (item.event === "cad_result") { + const taskId = String(item.data.taskId || ""); + const revisionId = String(item.data.revisionId || Date.now()); return { type: "data-cad-result", - id: `result_${String(item.data.taskId || Date.now())}`, + // Each revision is a distinct progressive result. Reusing only the task + // id makes the AI SDK reconcile intermediate revisions into one part. + id: `result_${taskId}_${revisionId}`, data: item.data, }; } @@ -33,5 +60,12 @@ export function backendEventToUiChunk( data: item.data, }; } + if (item.event === "image_analysis") { + return { + type: "data-cad-image-analysis", + id: `image_analysis_${Date.now()}`, + data: item.data, + }; + } return null; } diff --git a/frontend/src/lib/cad-types.ts b/frontend/src/lib/cad-types.ts index 07105e64..c5044d2d 100644 --- a/frontend/src/lib/cad-types.ts +++ b/frontend/src/lib/cad-types.ts @@ -5,6 +5,9 @@ export type CadProgress = { label: string; status: "running" | "success" | "error" | string; message?: string; + taskId?: string; + nodeId?: string; + lifecycle?: "running" | "completed" | "failed" | string; }; export type CadResult = { @@ -17,11 +20,19 @@ export type CadResult = { parametersPath?: string; selectorPath?: string; edgesPath?: string; - designIntentId?: string; - designIntentPath?: string; + topologyPath?: string; summary: string; referenceIds: string[]; engine: string; + qualityStatus?: "accepted" | "built_with_warnings" | "needs_repair" | "blocked" | "failed" | string; + qualityPath?: string; + assumptions?: string[]; + warnings?: string[]; + repairAttempts?: number; + snapshotPaths?: string[]; + snapshotStatus?: string; + checkpoint?: boolean; + lifecycle?: "running" | "completed" | "failed" | string; }; export type CadError = { @@ -29,10 +40,63 @@ export type CadError = { message: string; }; +export type CadImageDimension = { + id: string; + label: string; + reason: string; +}; + +export type CadImageSegment = { + type: "line" | "arc" | "circle" | "polyline" | "unknown_curve" | string; + start?: number[]; + end?: number[]; + center?: number[]; + radius_mm?: number | null; + points?: number[][]; + confidence?: number | null; + notes?: string; +}; + +export type CadImageProfile = { + id: string; + role?: string; + plane_hint?: string; + closed?: boolean; + segments?: CadImageSegment[]; + source_images?: string[]; + confidence?: number | null; + uncertain?: string[]; + notes?: string; +}; + +export type CadImageAnalysis = { + observationStage?: "survey" | "sketch" | "complete" | string; + schemaVersion?: string; + attachmentIds: string[]; + partType: string; + visibleFeatures: string[]; + uncertainFeatures: string[]; + dimensionCandidates?: CadImageDimension[]; + // Legacy conversations stored this field before dimensions became optional. + requiredDimensions?: CadImageDimension[]; + views?: Array<{ attachment_id?: string; view_role?: string; orientation?: string; quality?: string; confidence?: number | null }>; + overallGeometry?: Record; + surfaces?: Array>; + profiles?: CadImageProfile[]; + holes?: Array>; + bends?: Array>; + measurements?: Array<{ name: string; value_mm?: number | null; source?: string; confidence?: number | null; evidence?: string }>; + uncertainties?: string[]; + assumptions?: string[]; + cvHints?: Array>; + artifactPath?: string; +}; + export type CadDataParts = { "cad-progress": CadProgress; "cad-result": CadResult; "cad-error": CadError; + "cad-image-analysis": CadImageAnalysis; }; export type CadUIMessage = UIMessage; @@ -46,7 +110,7 @@ export type ConversationRecord = { export type CadAttachment = { id: string; - task_id: string; + conversation_id: string; name: string; kind: "image" | "document"; path: string; @@ -54,6 +118,10 @@ export type CadAttachment = { size: number; sha256: string; extracted_path?: string; + width?: number; + height?: number; + orientation?: string; + format?: string; }; export type TaskRevision = { @@ -66,18 +134,34 @@ export type TaskRevision = { parameters_path?: string; selector_path?: string; edges_path?: string; - design_intent_id?: string; - design_intent_path?: string; + topology_path?: string; summary?: string; reference_ids?: string[]; engine?: string; error?: string; + quality_status?: string; + quality_path?: string; + assumptions?: string[]; + generation_assumptions?: string[]; + warnings?: string[]; + repair_attempts?: number; + snapshot_manifest_path?: string; + snapshot_status?: string; + visibility?: "checkpoint" | "final" | "superseded" | string; + node_id?: string; + render_manifest_path?: string; + visual_review_path?: string; }; export type TaskRecord = { task_id: string; current_revision: string; - current_design_intent_id?: string; + active_revision?: string; + published_revision?: string; + lifecycle?: "running" | "completed" | "failed" | string; + active_node_id?: string; + preview_revision?: string; + generation_plan?: Record | null; revisions: TaskRevision[]; }; @@ -92,4 +176,8 @@ export type BackendConfig = { model: string; configured: boolean; library_samples: number; + max_repair_attempts?: number; + incremental_generation?: boolean; + review_configured?: boolean; + review_error?: string; }; diff --git a/frontend/src/lib/cdsl-selector-runtime.test.ts b/frontend/src/lib/cdsl-selector-runtime.test.ts index b512c1ef..4888829d 100644 --- a/frontend/src/lib/cdsl-selector-runtime.test.ts +++ b/frontend/src/lib/cdsl-selector-runtime.test.ts @@ -41,3 +41,22 @@ test("uses backend face triangle ranges when they are available", () => { assert.deepEqual([...runtime.proxy.faceIds], [0, 1]); assert.deepEqual([...runtime.proxy.faceRuns], [0, 0, 0, 1, 0, 0, 0, 1, 1, 1]); }); + +test("keeps face row lookup separate from edge row lookup", () => { + const runtime = buildCdslSelectorRuntime({ + references: [ + { id: "face_000", selectorType: "face", center: [0, 0, 0], normal: [0, 0, 1], frame: { origin_mm: [0, 0, 0], normal: [0, 0, 1] } }, + ], + edges: [{ + record_id: "edge_000", + geometry: { start_mm: [0, 0, 0], end_mm: [1, 0, 0], center_mm: [0.5, 0, 0], bbox_mm: [0, 0, 0, 1, 0, 0], curve_type: "line" }, + }], + }, { + vertices: new Float32Array(9), + indices: new Uint32Array([0, 1, 2]), + }); + + assert.equal(runtime.faceReferenceByRowIndex.get(0)?.id, "face_000"); + assert.equal(runtime.edgeReferenceByRowIndex.get(0)?.id, "edge_000"); + assert.deepEqual(runtime.faces.map((reference) => reference.id), ["face_000"]); +}); diff --git a/frontend/src/lib/cdsl-selector-runtime.ts b/frontend/src/lib/cdsl-selector-runtime.ts index 59a42ecc..257ffcf7 100644 --- a/frontend/src/lib/cdsl-selector-runtime.ts +++ b/frontend/src/lib/cdsl-selector-runtime.ts @@ -11,9 +11,20 @@ type SidecarReference = { surface_type?: string; triangle_start?: number; triangle_count?: number; + executable?: boolean; + snapshot_id?: string; }; -type Sidecar = { references?: SidecarReference[] }; +type SidecarEdge = { + record_id?: string; + selectorType?: string; + executable?: boolean; + snapshot_id?: string; + geometry?: Record; + owner_feature_ids?: string[]; +}; + +type Sidecar = { references?: SidecarReference[]; edges?: SidecarEdge[] }; const GLB_CAD_UNIT_SCALE = 1000; const FACE_ID_NONE = 0xffffffff; @@ -96,7 +107,7 @@ function triangleRuns(faceIds: Uint32Array, occurrenceRow: number) { export function buildCdslSelectorRuntime(sidecar: Sidecar, meshData: any) { const sourceReferences = Array.isArray(sidecar?.references) ? sidecar.references : []; const faces = sourceReferences - .filter((reference) => String(reference?.selectorType || "").toLowerCase() === "face") + .filter((reference) => String(reference?.selectorType || "").toLowerCase() === "face" && reference?.executable !== false) .map((reference, rowIndex) => { const frame = sourceFrame(reference); if (!frame) return null; @@ -141,6 +152,73 @@ export function buildCdslSelectorRuntime(sidecar: Sidecar, meshData: any) { }) .filter(Boolean) as any[]; + const edgeLines: number[] = []; + const edgeIndices: number[] = []; + const edgeIds: number[] = []; + const edgeReferences = (Array.isArray(sidecar?.edges) ? sidecar.edges : []) + .filter((record) => record?.executable !== false) + .map((record, rowIndex) => { + const geometry = record.geometry || {}; + const bbox = Array.isArray(geometry.bbox_mm) && geometry.bbox_mm.length === 6 + ? geometry.bbox_mm.map(Number) + : null; + const center = Array.isArray(geometry.center_mm) && geometry.center_mm.length >= 3 + ? geometry.center_mm.slice(0, 3).map(Number) + : null; + const start = Array.isArray(geometry.start_mm) && geometry.start_mm.length >= 3 + ? geometry.start_mm.slice(0, 3).map(Number) + : null; + const end = Array.isArray(geometry.end_mm) && geometry.end_mm.length >= 3 + ? geometry.end_mm.slice(0, 3).map(Number) + : null; + let points: number[][] = start && end ? [start, end] : []; + if (!points.length && center && bbox && String(geometry.curve_type || "").toLowerCase() === "circle") { + const extents = [bbox[3] - bbox[0], bbox[4] - bbox[1], bbox[5] - bbox[2]]; + const normalAxis = extents.indexOf(Math.min(...extents)); + const axes = [0, 1, 2].filter((axis) => axis !== normalAxis); + const radius = Math.max(extents[axes[0]], extents[axes[1]]) / 2; + points = Array.from({ length: 33 }, (_, index) => { + const angle = (index / 32) * Math.PI * 2; + const point = [...center]; + point[axes[0]] += Math.cos(angle) * radius; + point[axes[1]] += Math.sin(angle) * radius; + return point; + }); + } + if (points.length < 2 || !points.every((point) => point.every(Number.isFinite))) return null; + const segmentStart = edgeIndices.length / 2; + for (let index = 1; index < points.length; index += 1) { + const startIndex = edgeLines.length / 3; + edgeLines.push(...previewVector(points[index - 1] as Vector3), ...previewVector(points[index] as Vector3)); + edgeIndices.push(startIndex, startIndex + 1); + edgeIds.push(rowIndex); + } + const edgeId = String(record.record_id || `edge_${rowIndex}`); + const previewCenter = center ? previewVector(center as Vector3) : null; + return { + id: edgeId, + selectorType: "edge", + normalizedSelector: edgeId, + displaySelector: edgeId, + label: String(geometry.curve_type || "edge"), + summary: String(geometry.curve_type || edgeId), + shortSummary: String(geometry.curve_type || edgeId), + partId: "glb:0", + rowIndex, + pickData: { + selectorType: "edge", + center: previewCenter, + sourceBoundsMm: bbox, + bbox: bbox ? mappedBBox({ min: bbox.slice(0, 3), max: bbox.slice(3, 6) }) : null, + segmentStart, + segmentCount: points.length - 1, + curveType: String(geometry.curve_type || "edge"), + cdslCoordinateSystem: "build123d_y_up_glb", + }, + }; + }) + .filter(Boolean) as any[]; + const vertices = meshData?.vertices; const indices = meshData?.indices; const triangleCount = Math.floor((indices?.length || 0) / 3); @@ -186,7 +264,7 @@ export function buildCdslSelectorRuntime(sidecar: Sidecar, meshData: any) { } const faceRuns = triangleRuns(faceIds, 0); - const references = faces; + const references = [...faces, ...edgeReferences]; return { schemaVersion: 1, surfaceEdgeRendering: false, @@ -196,19 +274,21 @@ export function buildCdslSelectorRuntime(sidecar: Sidecar, meshData: any) { bbox: meshData?.bounds || null, occurrences: [{ id: "glb:0" }], shapes: [], - faces: references, - edges: [], + faces, + edges: edgeReferences, vertices: [], references, referenceMap: new Map(references.map((reference) => [reference.id, reference])), referenceByNormalizedSelector: new Map(references.map((reference) => [reference.normalizedSelector, reference])), referenceByDisplaySelector: new Map(references.map((reference) => [reference.displaySelector, reference])), - faceReferenceByRowIndex: new Map(references.map((reference) => [reference.rowIndex, reference])), - edgeReferenceByRowIndex: new Map(), + // Face and edge rows are separate namespaces. Mapping all references here + // lets edge rows overwrite face rows and makes face clicks unselectable. + faceReferenceByRowIndex: new Map(faces.map((reference) => [reference.rowIndex, reference])), + edgeReferenceByRowIndex: new Map(edgeReferences.map((reference) => [reference.rowIndex, reference])), vertexReferenceByRowIndex: new Map(), occurrenceIdByRowIndex: new Map([[0, "glb:0"]]), - faceReferenceMap: new Map(references.map((reference) => [reference.id, reference])), - edgeReferenceMap: new Map(), + faceReferenceMap: new Map(faces.map((reference) => [reference.id, reference])), + edgeReferenceMap: new Map(edgeReferences.map((reference) => [reference.id, reference])), vertexReferenceMap: new Map(), singleOccurrenceId: "glb:0", proxy: { @@ -217,9 +297,9 @@ export function buildCdslSelectorRuntime(sidecar: Sidecar, meshData: any) { faceIds, faceRuns, faceRunColumns: ["occurrenceRow", "primitiveIndex", "triangleStart", "triangleCount", "faceRow"], - edgePositions: new Float32Array(0), - edgeIndices: new Uint32Array(0), - edgeIds: new Uint32Array(0), + edgePositions: new Float32Array(edgeLines), + edgeIndices: new Uint32Array(edgeIndices), + edgeIds: new Uint32Array(edgeIds), faceEdgeRows: [], edgeFaceRows: [], }, diff --git a/frontend/src/lib/viewer-selection.ts b/frontend/src/lib/viewer-selection.ts index 36a7cc4b..eaf7648b 100644 --- a/frontend/src/lib/viewer-selection.ts +++ b/frontend/src/lib/viewer-selection.ts @@ -128,6 +128,7 @@ function compactEntity( selector: String(selectedEntity?.selector || reference?.displaySelector || reference?.normalizedSelector || id).trim(), label: String(reference?.label || selectedEntity?.surfaceType || referenceData.surfaceType || "face").trim(), selectorType: String(selectedEntity?.selectorType || reference?.selectorType || "face").trim(), + snapshotId: String(selectedEntity?.snapshotId || reference?.snapshot_id || "").trim(), surfaceType: String(selectedEntity?.surfaceType || referenceData.surfaceType || "unknown").trim(), centerMm: referenceData.centerMm, normal: referenceData.normal,