commit beba8744b4f94c71e8071c55dfd4d39f5a60798c Author: 李康 Date: Wed Aug 19 19:34:30 2026 +0800 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..c602c912 --- /dev/null +++ b/.gitignore @@ -0,0 +1,41 @@ +# macOS +.DS_Store +**/.DS_Store + +# Environment files +.env +.env.* +!.env.example +!.env.*.example + +# Python +__pycache__/ +*.py[cod] +*$py.class +.Python +.venv/ +venv/ +env/ +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.coverage +htmlcov/ + +# Node / Next.js +node_modules/ +.next/ +out/ +dist/ +build/ +*.tsbuildinfo + +# Runtime data and generated local artifacts +backend/data/ + +# Logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* diff --git a/README.md b/README.md new file mode 100644 index 00000000..9c396971 --- /dev/null +++ b/README.md @@ -0,0 +1,20 @@ +# CAD CDSL Workspace + +This repository is organized as a CAD generation workspace with four runtime +programs and one AI skill: + +- `frontend/`: Agent UI and 3D model preview. +- `backend/`: API, generation orchestration, engine, and official CDSL library. +- `solidworks_to_json/`: SolidWorks export plugin. +- `json_to_cdsl/`: SolidWorks JSON to parameterized CDSL converter. +- `backend/agent/skills/cad-engine/`: Instructions for the backend AI agent to use the CAD engine. +- `cdsl 5/`: Existing experimental assets kept unchanged for reference. + +Run both application services from the repository root with: + +```bash +./run.sh +``` + +The startup script expects `backend/app/main.py` and +`frontend/package.json` to be added by the implementation phase. diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 00000000..db9a62ac --- /dev/null +++ b/backend/README.md @@ -0,0 +1,11 @@ +# Backend + +The backend owns the application API and CAD generation workflow: + +- `app/`: HTTP API, jobs, orchestration, and persistence adapters. +- `agent/`: AI prompts, tools, and skills used by the generation agent. +- `engine/`: CDSL compiler, sketch solver, and STEP generation runtime. +- `cdsl_library/`: Official CDSL examples, metadata, and search index. +- `tests/`: Engine, API, and end-to-end generation tests. + +Expected development entrypoint: `app.main:app`, served by Uvicorn. diff --git a/backend/agent/skills/cad-engine/SKILL.md b/backend/agent/skills/cad-engine/SKILL.md new file mode 100644 index 00000000..b1cd5bd0 --- /dev/null +++ b/backend/agent/skills/cad-engine/SKILL.md @@ -0,0 +1,23 @@ +# CAD Engine Skill + +## Purpose + +Help an AI agent generate CAD models through the repository's CDSL engine. + +## Required workflow + +1. Read the engine README and the relevant engine modules. +2. Search the official CDSL library for similar parts, profiles, and feature + sequences. +3. Produce or revise parameterized CDSL. +4. Validate that the CDSL can run through the `cdsl_only` path. +5. Compile the CDSL and generate a STEP file. +6. Return the generated artifact, validation result, and library references. + +## Hard constraints + +- Prefer named profiles and discrete semantic parameters over raw coordinates. +- Do not use `compiler_context` as the final source of missing training data. +- Keep the generated CDSL self-sufficient whenever the supported shape + generators can express the geometry. +- Preserve the original CDSL and write revisions as separate artifacts. diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/backend/app/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/app/api/.gitkeep b/backend/app/api/.gitkeep new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/backend/app/api/.gitkeep @@ -0,0 +1 @@ + diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 00000000..4b3dbf49 --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,189 @@ +from __future__ import annotations + +import json +from typing import Any + +from fastapi import FastAPI, File, Form, 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.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.attachments import attachment_record, classify_upload, extract_document_text +from app.settings import get_settings + + +settings = get_settings() +store = WorkspaceStore(settings) +library = CdslLibrary(settings) +agent = AgentService(settings, store, library) +app = FastAPI(title="CDSL CAD Agent API", version="0.1.0") + + +@app.get("/health") +async def health() -> dict[str, Any]: + return { + "ok": True, + "service": "cdsl-cad-backend", + "llm_configured": settings.llm_configured, + "library_index": (settings.library_root / "index" / "catalog.json").is_file(), + } + + +@app.get("/v1/config") +async def config() -> dict[str, Any]: + providers = [] + for provider in settings.providers: + if not provider.configured: + continue + providers.append({ + "id": provider.id, + "label": provider.label, + "models": [{"id": model.id, "vision": model.vision} for model in provider.models], + }) + return { + "default_provider": settings.default_provider_id, + "default_model": settings.llm_model, + "providers": providers, + "model": settings.llm_model, + "configured": settings.llm_configured, + "library_samples": library.count(), + } + + +@app.post("/v1/chat/stream") +async def chat_stream(payload: ChatRequest) -> StreamingResponse: + return StreamingResponse( + agent.stream(payload.messages, payload.conversation_id, payload.selected_task_id, payload.provider_id, payload.model_id), + media_type="text/event-stream", + headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, + ) + + +@app.get("/v1/conversations/{conversation_id}") +async def read_conversation(conversation_id: str) -> JSONResponse: + try: + record = store.read_conversation(safe_conversation_id(conversation_id)) + except ValueError as error: + raise HTTPException(status_code=400, detail=str(error)) from error + if record is None: + raise HTTPException(status_code=404, detail="Conversation not found") + return JSONResponse(record) + + +@app.post("/v1/conversations") +async def create_conversation() -> JSONResponse: + return JSONResponse(store.ensure_conversation(None)) + + +@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) + 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( + file: UploadFile = File(...), + task_id: str | None = Form(default=None), +) -> JSONResponse: + data = await file.read() + filename = file.filename or "attachment" + try: + 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) + 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) + return JSONResponse(record) + except ValueError as error: + raise HTTPException(status_code=400, detail=str(error)) from error + + +@app.get("/v1/tasks/{task_id}") +async def read_task(task_id: str) -> JSONResponse: + try: + task = store.read_task(safe_task_id(task_id)) + except ValueError as error: + raise HTTPException(status_code=400, detail=str(error)) from error + if task is None: + raise HTTPException(status_code=404, detail="Task not found") + return JSONResponse(task) + + +@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 + + try: + safe_id = safe_task_id(task_id) + path = store.artifact_path(safe_id, artifact_path) + except ValueError as error: + raise HTTPException(status_code=400, detail=str(error)) from error + if not path.is_file(): + raise HTTPException(status_code=404, detail="Artifact not found") + return FileResponse(path, filename=path.name) + + +@app.get("/v1/tasks/{task_id}/parameters") +async def read_parameters(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) + 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 "") + if not relative: + raise HTTPException(status_code=404, detail="No editable parameters exist for this task") + path = store.artifact_path(safe_id, relative) + if not path.is_file(): + raise HTTPException(status_code=404, detail="Parameter contract not found") + return JSONResponse({"task_id": safe_id, "revision_id": revision_id, **json.loads(path.read_text(encoding="utf-8"))}) + + +@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) + current_revision_id = str((task or {}).get("current_revision") or "") + 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) + result = build_revision( + settings=settings, + store=store, + task_id=safe_id, + request=f"Parameter update: {', '.join(payload.values)}", + cdsl=updated, + reference_ids=[], + summary="Updated CDSL parameters", + parent_revision_id=current_revision_id, + operation={"type": "parameter_update", "values": payload.values}, + ) + return JSONResponse(result) + except ValueError as error: + raise HTTPException(status_code=400, detail=str(error)) from error + + +@app.post("/v1/tasks/{task_id}/modify") +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) + return JSONResponse(result) + except ValueError as error: + raise HTTPException(status_code=400, detail=str(error)) from error diff --git a/backend/app/models/.gitkeep b/backend/app/models/.gitkeep new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/backend/app/models/.gitkeep @@ -0,0 +1 @@ + diff --git a/backend/app/models/contracts.py b/backend/app/models/contracts.py new file mode 100644 index 00000000..3a17589a --- /dev/null +++ b/backend/app/models/contracts.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +from typing import Any, Literal + +from pydantic import BaseModel, Field + + +class MessagePart(BaseModel): + type: str + text: str | None = None + data: dict[str, Any] | None = None + + +class ChatMessage(BaseModel): + id: str + role: Literal["user", "assistant"] + parts: list[MessagePart] + + +class ChatRequest(BaseModel): + conversation_id: str | None = None + selected_task_id: str | None = None + messages: list[ChatMessage] = Field(default_factory=list) + provider_id: str | None = None + model_id: str | None = None + + +class ConversationPatch(BaseModel): + current_task_id: str | None = None + attachments: list[dict[str, Any]] | None = None + + +class ParameterUpdate(BaseModel): + values: dict[str, float] = Field(default_factory=dict) + + +class ModifyRequest(BaseModel): + operation: str + selection: dict[str, Any] = Field(default_factory=dict) + parameters: dict[str, Any] = Field(default_factory=dict) + + +class TaskArtifact(BaseModel): + path: str + role: str + kind: str + + +class CadResult(BaseModel): + task_id: str + revision_id: str + cdsl_path: str + step_path: str + glb_path: str + report_path: str + parameters_path: str | None = None + selector_path: str | None = None + edges_path: str | None = None + summary: str + reference_ids: list[str] = Field(default_factory=list) + engine: str = "cdsl_only" diff --git a/backend/app/services/.gitkeep b/backend/app/services/.gitkeep new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/backend/app/services/.gitkeep @@ -0,0 +1 @@ + diff --git a/backend/app/services/agent_service.py b/backend/app/services/agent_service.py new file mode 100644 index 00000000..4385c58d --- /dev/null +++ b/backend/app/services/agent_service.py @@ -0,0 +1,362 @@ +from __future__ import annotations + +import asyncio +import base64 +import json +import secrets +from collections.abc import AsyncIterator +from typing import Any + +import httpx + +from app.models.contracts import ChatMessage +from app.services.engine_service import build_revision, load_engine +from app.services.library import CdslLibrary +from app.services.sse import event +from app.services.storage import WorkspaceStore +from app.settings import ProviderConfig, ProviderModel, Settings + + +TOOL_SCHEMAS: list[dict[str, Any]] = [ + { + "type": "function", + "function": { + "name": "search_cdsl_library", + "description": "Search the official local CDSL library for similar geometry and feature sequences.", + "parameters": { + "type": "object", + "properties": {"query": {"type": "string"}, "limit": {"type": "integer", "minimum": 1, "maximum": 8}}, + "required": ["query"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "read_cdsl_reference", + "description": "Read one official CDSL sample by part_id. Use this before creating geometry based on a reference.", + "parameters": {"type": "object", "properties": {"part_id": {"type": "string"}}, "required": ["part_id"]}, + }, + }, + { + "type": "function", + "function": { + "name": "read_current_cdsl", + "description": "Read the current task's latest CDSL before making a natural-language revision.", + "parameters": {"type": "object", "properties": {}, "additionalProperties": False}, + }, + }, + { + "type": "function", + "function": { + "name": "generate_cdsl_model", + "description": "Validate and execute a complete parameterized CDSL model. Use only for explicit CAD generation or revision.", + "parameters": { + "type": "object", + "properties": { + "cdsl": {"type": "object", "description": "Complete cad.cdsl.llm.v1 JSON object."}, + "summary": {"type": "string"}, + "assumptions": {"type": "array", "items": {"type": "string"}}, + }, + "required": ["cdsl", "summary"], + }, + }, + }, +] + + +def text_from_message(message: ChatMessage) -> str: + return "\n".join(part.text or "" for part in message.parts if part.type == "text").strip() + + +def messages_for_model(messages: list[ChatMessage]) -> list[dict[str, Any]]: + result: list[dict[str, Any]] = [] + for message in messages[-20:]: + text = text_from_message(message) + if text: + result.append({"role": message.role, "content": text}) + return result + + +def system_prompt(settings: Settings) -> str: + 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 "" + readme_path = settings.engine_root / "README.md" + engine_readme = readme_path.read_text(encoding="utf-8") if readme_path.is_file() else "" + supported_profiles = ", ".join(sorted(load_engine(settings).SHAPE_GENERATORS)) + return f"""You are the CDSL CAD Agent for CDSL CAD Studio. + +You generate parameterized CDSL, never raw CAD source code. For new CAD requests: +1. Search the local official CDSL library. +2. Read at least one relevant reference when a match exists. +3. Call generate_cdsl_model only when the request is sufficiently specified. +4. Never claim success unless the tool returns a successful CDSL-only STEP and GLB artifact. + +For a revision, call read_current_cdsl first and preserve unrelated features. +Do not output compiler_context, unknown_shape, complex_arc_shape, entities, +contour_edges_mm, or contour_regions_mm. Use only self-contained named profiles +supported by the engine. Ask a concise clarification question when essential +dimensions or intent are missing. Ordinary explanations must not create CAD. + +Local skill: +{skill} + +Local engine guide: +{engine_readme} + +Supported named profile types: +{supported_profiles} +""" + + +class AgentService: + def __init__(self, settings: Settings, store: WorkspaceStore, library: CdslLibrary) -> None: + self.settings = settings + self.store = store + self.library = library + + async def stream( + self, + messages: list[ChatMessage], + conversation_id: str | None, + selected_task_id: str | None, + provider_id: str | None = None, + model_id: str | None = None, + ) -> AsyncIterator[bytes]: + latest_user = next((message for message in reversed(messages) if message.role == "user"), None) + if latest_user is None: + yield event("cad_error", {"stage": "request", "message": "A user message is required."}) + 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 "" + 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 + + try: + provider, model = self.settings.resolve_model(provider_id, model_id) + except ValueError as error: + provider = None + model = None + configuration_error = str(error) + else: + configuration_error = "" + + if not self.settings.llm_configured or provider is None or model is None: + message = "Agent 尚未配置模型。请设置 CDSL_LLM_BASE_URL、CDSL_LLM_API_KEY 和 CDSL_LLM_MODEL。" + if configuration_error: + message = configuration_error + error_payload = {"stage": "configuration", "message": message} + 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, task_id) + yield event("done", {}) + return + + try: + attachment_message = self._attachment_message(conversation, model) + except ValueError as error: + error_payload = {"stage": "attachment", "message": str(error)} + 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, task_id) + yield event("done", {}) + return + + yield event("progress", {"step": "analyze_request", "label": "分析需求", "status": "running", "message": "正在整理当前会话和 CAD 需求。"}) + references: list[str] = [] + model_messages: list[dict[str, Any]] = [{"role": "system", "content": system_prompt(self.settings)}] + model_messages.extend(messages_for_model(messages)) + if attachment_message: + model_messages.append({"role": "user", "content": attachment_message}) + tools = TOOL_SCHEMAS + + try: + for iteration in range(8): + response = await self._complete(model_messages, tools, provider, model) + choice = response["choices"][0]["message"] + tool_calls = choice.get("tool_calls") or [] + content = str(choice.get("content") or "") + if content: + assistant_parts.append({"type": "text", "text": content}) + for chunk in self._chunks(content): + yield event("text_delta", {"text": chunk}) + if not tool_calls: + break + model_messages.append(choice) + for call in tool_calls: + name = str(call.get("function", {}).get("name") or "") + arguments = json.loads(call.get("function", {}).get("arguments") or "{}") + yield event("progress", { + "step": name, + "label": self._tool_label(name), + "status": "running", + "message": "Agent 正在调用本地 CAD 工具。", + }) + result, generated = await self._run_tool(name, arguments, task_id, user_text, references) + if generated: + task_id = generated["task_id"] + 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": "success" if result.get("ok", True) else "error", + "message": result.get("message") or result.get("summary") or "", + }) + if generated: + result_payload = { + "taskId": generated["task_id"], + "revisionId": generated["revision_id"], + "cdslPath": generated["cdsl_path"], + "stepPath": generated["step_path"], + "glbPath": generated["glb_path"], + "reportPath": generated["report_path"], + "parametersPath": generated.get("parameters_path"), + "selectorPath": generated.get("selector_path"), + "edgesPath": generated.get("edges_path"), + "summary": generated["summary"], + "referenceIds": generated["reference_ids"], + "engine": generated["engine"], + } + successful_result = result_payload + assistant_parts.append({"type": "data-cad-result", "data": result_payload}) + yield event("cad_result", result_payload) + if iteration == 7: + error_payload = {"stage": "agent", "message": "Agent tool loop reached its safety limit."} + assistant_parts.append({"type": "data-cad-error", "data": error_payload}) + yield event("cad_error", error_payload) + except Exception as error: + error_payload = {"stage": "agent", "message": str(error)} + assistant_parts.append({"type": "data-cad-error", "data": error_payload}) + yield event("cad_error", error_payload) + if task_id: + self.store.ensure_conversation(conversation["conversation_id"], task_id) + if not assistant_parts: + assistant_parts.append({ + "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", {}) + + def _persist_assistant( + self, + conversation_id: str, + assistant_id: str, + parts: list[dict[str, Any]], + task_id: str, + ) -> None: + self.store.append_conversation_message( + conversation_id, + { + "id": assistant_id or f"assistant_{conversation_id}_{len(parts)}", + "role": "assistant", + "parts": parts, + }, + task_id or None, + ) + + async def _complete( + self, + messages: list[dict[str, Any]], + tools: list[dict[str, Any]], + provider: ProviderConfig, + model: ProviderModel, + ) -> dict[str, Any]: + url = f"{provider.base_url}/chat/completions" + headers = {"Authorization": f"Bearer {provider.api_key}", "Content-Type": "application/json"} + payload = {"model": model.id, "messages": messages, "tools": tools, "tool_choice": "auto", "temperature": 0.1} + async with httpx.AsyncClient(timeout=self.settings.llm_timeout_s) as client: + response = await client.post(url, headers=headers, json=payload) + if response.status_code >= 400: + raise RuntimeError(f"LLM request failed ({response.status_code}): {response.text[:800]}") + return response.json() + + async def _run_tool(self, name: str, arguments: dict[str, Any], task_id: str, request: str, references: list[str]) -> tuple[dict[str, Any], dict[str, Any] | None]: + if name == "search_cdsl_library": + results = self.library.search(str(arguments.get("query") or request), int(arguments.get("limit") or 5)) + return {"ok": True, "results": results}, None + if name == "read_cdsl_reference": + part_id = str(arguments.get("part_id") or "") + sample = self.library.read_sample(part_id) + if part_id not in references: + references.append(part_id) + 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) + 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": + 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") + summary = str(arguments.get("summary") or "CDSL CAD model") + yieldable = await asyncio.to_thread( + build_revision, + settings=self.settings, + store=self.store, + task_id=task_id or None, + request=request, + cdsl=cdsl, + reference_ids=list(references), + summary=summary, + ) + return {"ok": True, "summary": summary, "task_id": yieldable["task_id"], "revision_id": yieldable["revision_id"]}, yieldable + raise ValueError(f"Unknown agent tool: {name}") + + @staticmethod + def _chunks(text: str) -> list[str]: + return [text[index:index + 96] for index in range(0, len(text), 96)] + + @staticmethod + def _tool_label(name: str) -> str: + return { + "search_cdsl_library": "检索 CDSL 模型库", + "read_cdsl_reference": "读取 CDSL 参考模型", + "read_current_cdsl": "读取当前 CDSL", + "generate_cdsl_model": "生成 CDSL CAD 模型", + }.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."}] + for attachment in attachments: + if not isinstance(attachment, dict): + continue + kind = str(attachment.get("kind") or "") + task_id = str(attachment.get("task_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 kind == "image": + if not model.vision: + raise ValueError("The selected model does not support images. Choose a vision-capable OpenAI or Kimi model.") + mime = str(attachment.get("mime") or "image/png") + 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 = 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 new file mode 100644 index 00000000..101e9323 --- /dev/null +++ b/backend/app/services/attachments.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +import hashlib +from pathlib import Path + + +IMAGE_SUFFIXES = {".png", ".jpg", ".jpeg", ".webp"} +DOCUMENT_SUFFIXES = {".txt", ".md", ".csv", ".json"} +MAX_IMAGE_BYTES = 10 * 1024 * 1024 +MAX_DOCUMENT_BYTES = 2 * 1024 * 1024 +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") + return "image" + if suffix in DOCUMENT_SUFFIXES: + 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") + + +def extract_document_text(data: bytes) -> str: + try: + text = data.decode("utf-8") + except UnicodeDecodeError as error: + raise ValueError("Documents must be UTF-8 text") from error + 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]: + return { + "id": Path(relative_path).stem, + "task_id": task_id, + "name": filename, + "kind": kind, + "path": relative_path, + "mime": mime or "application/octet-stream", + "size": len(data), + "sha256": hashlib.sha256(data).hexdigest(), + "extracted_path": extracted_path, + } diff --git a/backend/app/services/editing.py b/backend/app/services/editing.py new file mode 100644 index 00000000..8d0689db --- /dev/null +++ b/backend/app/services/editing.py @@ -0,0 +1,187 @@ +from __future__ import annotations + +import copy +import json +import math +from typing import Any + +from app.services.engine_service import build_revision +from app.services.storage import WorkspaceStore +from app.settings import Settings + + +SUPPORTED_OPERATIONS = { + "add_hole", "add_counterbore", "add_countersink", "add_slot", + "add_pocket", "add_circular_pocket", "add_hole_pattern", +} + + +def _number(values: dict[str, Any], name: str, fallback: float, minimum: float = 0.01) -> float: + value = float(values.get(name, fallback)) + if not math.isfinite(value) or value < minimum: + raise ValueError(f"{name} must be a finite number >= {minimum}") + return value + + +def _selection_frame(selection: dict[str, Any]) -> dict[str, list[float]]: + pick = selection.get("pick") if isinstance(selection.get("pick"), dict) else selection + surface = pick.get("surface") if isinstance(pick.get("surface"), dict) else {} + surface_type = str(surface.get("type") or surface.get("surfaceType") or "").lower() + if surface_type and "plane" not in surface_type: + raise ValueError("Direct CDSL edits currently require a planar face") + frame = pick.get("frame") if isinstance(pick.get("frame"), dict) else {} + origin = frame.get("origin_mm") or pick.get("center") or pick.get("point") + normal = frame.get("normal") or pick.get("normal") + x_dir = frame.get("x_dir") or frame.get("xDir") or [1.0, 0.0, 0.0] + y_dir = frame.get("y_dir") or frame.get("yDir") or [0.0, 1.0, 0.0] + if not all(isinstance(value, list) and len(value) >= 3 for value in (origin, normal, x_dir, y_dir)): + raise ValueError("Select a planar face before applying a direct CDSL edit") + return { + "origin_mm": [float(item) for item in origin[:3]], + "normal": [float(item) for item in normal[:3]], + "x_dir": [float(item) for item in x_dir[:3]], + "y_dir": [float(item) for item in y_dir[:3]], + } + + +def _next_id(prefix: str, existing: set[str]) -> str: + index = 1 + while f"{prefix}_{index:03d}" in existing: + index += 1 + return f"{prefix}_{index:03d}" + + +def _profile_for(operation: str, values: dict[str, Any]) -> tuple[dict[str, Any], float]: + depth = _through_depth(values) + if operation in {"add_hole", "add_counterbore", "add_countersink", "add_circular_pocket"}: + diameter = _number(values, "holeDiameter", values.get("diameter", 10.0)) + return {"type": "circle", "center": [0.0, 0.0], "radius_mm": diameter / 2}, depth + 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 + 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 + 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 + raise ValueError(f"Unsupported direct CDSL edit: {operation}") + + +def _through_depth(values: dict[str, Any]) -> float: + # A through cut deliberately exceeds the model bounds. build123d clips the + # cutter against the solid, so this remains deterministic for any part size. + return 10000.0 if str(values.get("depth") or "").lower() == "through" else _number(values, "depth", 10.0) + + +def _hole_feature(operation: str, frame: dict[str, list[float]], values: dict[str, Any]) -> dict[str, Any]: + diameter = _number(values, "holeDiameter", values.get("diameter", 10.0)) + params: dict[str, Any] = { + "diameter_mm": diameter, + "depth_mm": _through_depth(values), + "positions": [{"mm": [0.0, 0.0, 0.0]}], + "host_face": {"frame": frame}, + } + atomic = "hole_blind" + if operation == "add_counterbore": + counterbore_diameter = _number(values, "counterboreDiameter", diameter * 2) + if counterbore_diameter <= diameter: + raise ValueError("counterboreDiameter must be larger than holeDiameter") + params["counterbore_diameter_mm"] = counterbore_diameter + params["counterbore_depth_mm"] = _number(values, "counterboreDepth", min(diameter, 2.0)) + atomic = "hole_counterbore" + elif operation == "add_countersink": + countersink_diameter = _number(values, "countersinkDiameter", diameter * 2) + if countersink_diameter <= diameter: + raise ValueError("countersinkDiameter must be larger than holeDiameter") + params["countersink_diameter_mm"] = countersink_diameter + params["countersink_angle_rad"] = math.radians(_number(values, "countersinkAngleDeg", 90.0, 1.0)) + atomic = "hole_countersink" + return {"atomic": atomic, "params": params} + + +def _slot_frame(frame: dict[str, list[float]], picks: list[dict[str, Any]]) -> tuple[dict[str, list[float]], float]: + if len(picks) < 2: + raise ValueError("Select the two endpoints for the slot") + first = _selection_frame({"pick": picks[0]}) + second = _selection_frame({"pick": picks[1]}) + vector = [second["origin_mm"][index] - first["origin_mm"][index] for index in range(3)] + length = math.sqrt(sum(value * value for value in vector)) + if length < 0.01: + raise ValueError("Slot endpoints must be distinct") + x_dir = [value / length for value in vector] + normal = first["normal"] + 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], + ] + midpoint = [(first["origin_mm"][index] + second["origin_mm"][index]) / 2 for index in range(3)] + return {"origin_mm": midpoint, "normal": normal, "x_dir": x_dir, "y_dir": y_dir}, length + + +def apply_direct_edit( + settings: Settings, + store: WorkspaceStore, + task_id: str, + operation: str, + selection: dict[str, Any], + parameters: dict[str, Any], +) -> 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") + if operation not in SUPPORTED_OPERATIONS: + raise ValueError(f"Unsupported direct CDSL edit: {operation}") + source = store.current_cdsl_path(task_id) + task = store.read_task(task_id) + 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") + frame = _selection_frame(selection) + cdsl = copy.deepcopy(json.loads(source.read_text(encoding="utf-8"))) + features = cdsl.setdefault("features", []) + sketches = cdsl.setdefault("geometry", {}).setdefault("sketches", []) + picks = selection.get("picks") if isinstance(selection.get("picks"), list) else [] + if operation == "add_slot": + frame, slot_length = _slot_frame(frame, [pick for pick in picks if isinstance(pick, dict)]) + parameters = {**parameters, "slotLength": slot_length} + profile, depth = _profile_for(operation, parameters) + feature_id = _next_id("edit", {str(item.get("id")) for item in features}) + sketch_id = _next_id("edit_sketch", {str(item.get("id")) for item in sketches}) + dependency = str(features[-1].get("id")) if features else "" + sketches.append({"id": sketch_id, "name": operation, "workplane": frame, "profile": profile}) + feature: dict[str, Any] = { + "id": feature_id, + "depends_on": [dependency] if dependency else [], + "name": operation, + "sketch_id": sketch_id, + } + if operation in {"add_hole", "add_counterbore", "add_countersink"}: + hole = _hole_feature(operation, frame, parameters) + feature["atomic_id"] = hole["atomic"] + feature["params"] = hole["params"] + else: + feature["atomic_id"] = "extrude_cut_blind" + feature["params"] = {"distance_mm": depth} + features.append(feature) + return build_revision( + settings=settings, + store=store, + task_id=task_id, + request=f"Direct CDSL edit: {operation}", + cdsl=cdsl, + reference_ids=[], + summary=f"Applied {operation}", + parent_revision_id=revision_id, + operation={"type": operation, "selection": selection, "parameters": parameters}, + ) diff --git a/backend/app/services/engine_service.py b/backend/app/services/engine_service.py new file mode 100644 index 00000000..3f7a2643 --- /dev/null +++ b/backend/app/services/engine_service.py @@ -0,0 +1,334 @@ +from __future__ import annotations + +import copy +import json +import math +import re +import sys +from pathlib import Path +from typing import Any + +from vendor.cdsl_preview_runtime import step_to_glb +from app.services.storage import WorkspaceStore, now_iso, write_json +from app.settings import Settings + + +def load_engine(settings: Settings) -> Any: + parent = str(settings.engine_root.parent) + if parent not in sys.path: + sys.path.insert(0, parent) + import cdsl_engine + + return cdsl_engine + + +def _walk(value: Any) -> list[tuple[str, Any]]: + result: list[tuple[str, Any]] = [] + if isinstance(value, dict): + for key, child in value.items(): + result.append((str(key), child)) + result.extend(_walk(child)) + elif isinstance(value, list): + for child in value: + result.extend(_walk(child)) + return result + + +def validate_cdsl(cdsl: dict[str, Any], engine: Any) -> None: + if not isinstance(cdsl, dict): + raise ValueError("CDSL must be a JSON object") + if cdsl.get("schema") != "cad.cdsl.llm.v1": + raise ValueError("Unsupported CDSL schema") + part_id = str(cdsl.get("part_id") or "") + if not re.fullmatch(r"[a-zA-Z0-9_-]{3,80}", part_id): + raise ValueError("part_id must use letters, numbers, underscores, or hyphens") + forbidden = {"compiler_context", "unknown_shape", "complex_arc_shape", "contour_edges_mm", "contour_regions_mm", "entities"} + for key, value in _walk(cdsl): + if key in forbidden or (isinstance(value, str) and value in {"unknown_shape", "complex_arc_shape"}): + raise ValueError(f"Training-unsafe CDSL field: {key}") + features = cdsl.get("features") + sketches = cdsl.get("geometry", {}).get("sketches") + if not isinstance(features, list) or not features or not isinstance(sketches, list) or not sketches: + raise ValueError("CDSL requires features and parameterized sketches") + sketch_ids = {str(sketch.get("id")) for sketch in sketches} + feature_ids: set[str] = set() + for feature in features: + fid = str(feature.get("id") or "") + if not fid or fid in feature_ids: + raise ValueError("Feature ids must be unique") + feature_ids.add(fid) + if str(feature.get("sketch_id") or "") not in sketch_ids: + raise ValueError(f"Feature {fid} refers to a missing sketch") + if not str(feature.get("atomic_id") or ""): + raise ValueError(f"Feature {fid} has no atomic_id") + for dependency in feature.get("depends_on") or []: + if dependency not in feature_ids: + raise ValueError(f"Feature {fid} has a forward or missing dependency") + for sketch in sketches: + profile = sketch.get("profile") + if sketch.get("profile_from"): + continue + if not isinstance(profile, dict): + raise ValueError(f"Sketch {sketch.get('id')} has no self-contained profile") + profile_type = str(profile.get("type") or "") + if profile_type == "polygon": + if not profile.get("vertices"): + raise ValueError("Polygon profiles require vertices") + elif profile_type not in engine.SHAPE_GENERATORS: + raise ValueError(f"Unsupported CDSL profile: {profile_type}") + + +def _parameter_id(path: list[str]) -> str: + return "param_" + "_".join(re.sub(r"[^a-zA-Z0-9]+", "_", item).strip("_") for item in path) + + +def _parameter_label(path: list[str]) -> str: + return " / ".join(path[-2:]).replace("_mm", " (mm)").replace("_", " ") + + +def _derived_parameters(cdsl: dict[str, Any]) -> list[dict[str, Any]]: + parameters: list[dict[str, Any]] = [] + + def add(path: list[str], value: Any, group: str) -> None: + if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(float(value)): + return + number = float(value) + magnitude = max(abs(number), 1.0) + parameters.append({ + "id": _parameter_id(path), + "name": ".".join(path), + "display_name": _parameter_label(path), + "path": path, + "value": number, + "default_value": number, + "minimum": 0.01 if number >= 0 else -magnitude * 10, + "maximum": magnitude * 10, + "step": 0.1 if abs(number) < 100 else 1.0, + "precision": 2, + "unit": "mm" if path[-1].endswith("_mm") else "", + "group": group, + "editable": True, + }) + + for feature_index, feature in enumerate(cdsl.get("features") or []): + for key, value in (feature.get("params") or {}).items(): + add(["features", str(feature_index), "params", str(key)], value, "Features") + for sketch_index, sketch in enumerate(cdsl.get("geometry", {}).get("sketches") or []): + profile = sketch.get("profile") or {} + + def walk_profile(value: Any, path: list[str]) -> None: + if isinstance(value, dict): + for key, child in value.items(): + walk_profile(child, [*path, str(key)]) + elif isinstance(value, list): + # Coordinates are topology anchors, not user-facing dimensions. + return + else: + add(path, value, "Sketches") + + walk_profile(profile, ["geometry", "sketches", str(sketch_index), "profile"]) + return parameters + + +def parameter_contract(cdsl: dict[str, Any]) -> dict[str, Any]: + declared = cdsl.get("meta", {}).get("editable_parameters") + if isinstance(declared, list) and declared: + values = [item for item in declared if isinstance(item, dict) and isinstance(item.get("path"), list)] + if values: + return {"schema_version": "1.0", "parameters": values, "source": "declared"} + 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]]: + topology_faces = (preview or {}).get("topology_faces") + if isinstance(topology_faces, list) and topology_faces: + references = [] + for face in topology_faces: + if not isinstance(face, dict): + continue + frame = face.get("frame") + center = face.get("center") + normal = face.get("normal") + if not isinstance(frame, dict) or not isinstance(center, list) or not isinstance(normal, list): + continue + references.append({ + "id": str(face.get("id") or f"face_{len(references):03d}"), + "selectorType": "face", + "label": str(face.get("surface_type") or "face"), + "center": center, + "normal": normal, + "frame": frame, + "bbox": face.get("bbox") or {}, + "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), + }) + if references: + return ({"schema_version": "1.1", "references": references}, {"schema_version": "1.0", "edges": []}) + + bbox = engine_result.get("bbox_mm") or {} + minimum = [float(value) for value in bbox.get("min") or [0, 0, 0]] + maximum = [float(value) for value in bbox.get("max") or [0, 0, 0]] + if len(minimum) != 3 or len(maximum) != 3: + raise ValueError("Engine result is missing a valid bounding box") + center = [(minimum[index] + maximum[index]) / 2 for index in range(3)] + definitions = [ + ("top", [center[0], center[1], maximum[2]], [0, 0, 1], [1, 0, 0], [0, 1, 0]), + ("bottom", [center[0], center[1], minimum[2]], [0, 0, -1], [1, 0, 0], [0, -1, 0]), + ("right", [maximum[0], center[1], center[2]], [1, 0, 0], [0, 1, 0], [0, 0, 1]), + ("left", [minimum[0], center[1], center[2]], [-1, 0, 0], [0, 1, 0], [0, 0, -1]), + ("front", [center[0], maximum[1], center[2]], [0, 1, 0], [1, 0, 0], [0, 0, -1]), + ("back", [center[0], minimum[1], center[2]], [0, -1, 0], [1, 0, 0], [0, 0, 1]), + ] + references = [ + { + "id": f"face_{name}", "selectorType": "face", "label": name, + "center": point, "normal": normal, + "frame": {"origin_mm": point, "normal": normal, "x_dir": x_dir, "y_dir": y_dir}, + "bbox": {"min": minimum, "max": maximum}, + } + for name, point, normal, x_dir, y_dir in definitions + ] + return ({"schema_version": "1.0", "references": references}, {"schema_version": "1.0", "edges": []}) + + +def _set_parameter_value(document: dict[str, Any], path: list[str], value: float) -> None: + target: Any = document + for index, key in enumerate(path): + final = index == len(path) - 1 + if isinstance(target, list): + item_index = int(key) + if item_index < 0 or item_index >= len(target): + raise ValueError("Parameter path is no longer valid") + if final: + target[item_index] = value + else: + target = target[item_index] + elif isinstance(target, dict): + if key not in target: + raise ValueError("Parameter path is no longer valid") + if final: + target[key] = value + else: + target = target[key] + else: + raise ValueError("Parameter path is no longer valid") + + +def apply_parameter_updates(cdsl: dict[str, Any], values: dict[str, float]) -> tuple[dict[str, Any], dict[str, Any]]: + contract = parameter_contract(cdsl) + entries = {str(item.get("id")): item for item in contract["parameters"]} + updated = copy.deepcopy(cdsl) + for parameter_id, raw_value in values.items(): + entry = entries.get(parameter_id) + value = float(raw_value) + if entry is None or not entry.get("editable", False): + raise ValueError(f"Unknown editable parameter: {parameter_id}") + if not math.isfinite(value): + raise ValueError("Parameter values must be finite") + minimum, maximum = entry.get("minimum"), entry.get("maximum") + if isinstance(minimum, (int, float)) and value < float(minimum): + raise ValueError(f"{parameter_id} is below its minimum") + if isinstance(maximum, (int, float)) and value > float(maximum): + raise ValueError(f"{parameter_id} is above its maximum") + path = entry.get("path") + if not isinstance(path, list) or not all(isinstance(item, str) for item in path): + raise ValueError(f"{parameter_id} has an invalid path") + _set_parameter_value(updated, path, value) + declared = updated.get("meta", {}).get("editable_parameters") + if isinstance(declared, list): + for declared_entry in declared: + if isinstance(declared_entry, dict) and str(declared_entry.get("id")) == parameter_id: + declared_entry["value"] = value + return updated, parameter_contract(updated) + + +def build_revision( + *, + settings: Settings, + store: WorkspaceStore, + task_id: str | None, + request: str, + cdsl: dict[str, Any], + reference_ids: list[str], + summary: str, + parent_revision_id: str | None = None, + operation: dict[str, Any] | None = None, + attachments: list[dict[str, Any]] | None = None, +) -> dict[str, Any]: + engine = load_engine(settings) + task = store.ensure_task(task_id, request) + revision_id, revision_dir = store.next_revision(task["task_id"]) + cdsl_copy = copy.deepcopy(cdsl) + cdsl_copy["part_id"] = task["task_id"] + meta = cdsl_copy.setdefault("meta", {}) + if not isinstance(meta, dict): + raise ValueError("CDSL meta must be an object when present") + if not isinstance(meta.get("editable_parameters"), list) or not meta["editable_parameters"]: + meta["editable_parameters"] = _derived_parameters(cdsl_copy) + validate_cdsl(cdsl_copy, engine) + cdsl_path = revision_dir / "model.cdsl.json" + step_path = revision_dir / "model.step" + glb_path = revision_dir / "model.glb" + report_path = revision_dir / "rebuild-report.json" + request_path = revision_dir / "request.json" + references_path = revision_dir / "references.json" + parameters_path = revision_dir / "parameters.json" + selector_path = revision_dir / "model.selector.json" + edges_path = revision_dir / "model.edges.json" + write_json(request_path, {"request": request, "created_at": now_iso()}) + write_json(references_path, {"reference_ids": reference_ids}) + write_json(cdsl_path, cdsl_copy) + contract = parameter_contract(cdsl_copy) + write_json(parameters_path, contract) + + try: + engine_result = engine.run_rebuild(cdsl_copy, step_path) + 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) + write_json(selector_path, selector) + write_json(edges_path, edges) + report = {"engine_result": engine_result, "preview": preview, "validated_at": now_iso()} + write_json(report_path, report) + revision = { + "revision_id": revision_id, + "status": "success", + "created_at": now_iso(), + "request_path": request_path.relative_to(store.task_dir(task["task_id"])).as_posix(), + "cdsl_path": cdsl_path.relative_to(store.task_dir(task["task_id"])).as_posix(), + "step_path": step_path.relative_to(store.task_dir(task["task_id"])).as_posix(), + "glb_path": glb_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(), + "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(), + "reference_ids": reference_ids, + "summary": summary, + "engine": engine_result["engine"], + "parent_revision_id": parent_revision_id or "", + "operation": operation or {}, + "attachments": attachments or [], + } + except Exception as error: + write_json(report_path, {"error": str(error), "validated_at": now_iso()}) + revision = { + "revision_id": revision_id, + "status": "failed", + "created_at": now_iso(), + "request_path": request_path.relative_to(store.task_dir(task["task_id"])).as_posix(), + "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(), + "reference_ids": reference_ids, + "summary": summary, + "error": str(error), + "parent_revision_id": parent_revision_id or "", + "operation": operation or {}, + "attachments": attachments or [], + } + store.update_task(task["task_id"], revision) + raise + store.update_task(task["task_id"], revision) + return {"task_id": task["task_id"], **revision} diff --git a/backend/app/services/library.py b/backend/app/services/library.py new file mode 100644 index 00000000..e681eb72 --- /dev/null +++ b/backend/app/services/library.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +import json +import re +from pathlib import Path +from typing import Any + +from app.settings import Settings + + +TOKEN_PATTERN = re.compile(r"[a-zA-Z0-9_]+") + + +def tokens(value: str) -> set[str]: + return {token.lower() for token in TOKEN_PATTERN.findall(value)} + + +class CdslLibrary: + def __init__(self, settings: Settings) -> None: + self.settings = settings + self.index_path = settings.library_root / "index" / "catalog.json" + + def _records(self) -> list[dict[str, Any]]: + if not self.index_path.is_file(): + return [] + payload = json.loads(self.index_path.read_text(encoding="utf-8")) + return payload.get("samples", []) + + def count(self) -> int: + return len(self._records()) + + def search(self, query: str, limit: int = 5) -> list[dict[str, Any]]: + query_tokens = tokens(query) + if not query_tokens: + return [ + { + "part_id": record["part_id"], + "profiles": record.get("profiles", []), + "features": record.get("features", []), + "summary": record.get("summary", ""), + } + for record in self._records()[:limit] + ] + scored: list[tuple[int, dict[str, Any]]] = [] + for record in self._records(): + corpus = " ".join([ + record.get("part_id", ""), + record.get("source_name", ""), + " ".join(record.get("profiles", [])), + " ".join(record.get("features", [])), + " ".join(record.get("parameters", [])), + ]) + score = len(query_tokens & tokens(corpus)) + if score: + scored.append((score, record)) + scored.sort(key=lambda item: (-item[0], item[1]["part_id"])) + return [ + { + "part_id": record["part_id"], + "profiles": record.get("profiles", []), + "features": record.get("features", []), + "summary": record.get("summary", ""), + } + for _, record in scored[:limit] + ] + + def read_sample(self, part_id: str) -> dict[str, Any]: + for record in self._records(): + if record.get("part_id") == part_id: + source = self.settings.library_root / "samples" / part_id / "model.cdsl.json" + if not source.is_file(): + break + return json.loads(source.read_text(encoding="utf-8")) + raise ValueError(f"CDSL sample not found: {part_id}") diff --git a/backend/app/services/sse.py b/backend/app/services/sse.py new file mode 100644 index 00000000..335b4ade --- /dev/null +++ b/backend/app/services/sse.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +import json +from collections.abc import AsyncIterator +from typing import Any + + +def event(name: str, data: dict[str, Any]) -> bytes: + return f"event: {name}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n".encode("utf-8") + + +async def one_event(name: str, data: dict[str, Any]) -> AsyncIterator[bytes]: + yield event(name, data) diff --git a/backend/app/services/storage.py b/backend/app/services/storage.py new file mode 100644 index 00000000..e4d181ca --- /dev/null +++ b/backend/app/services/storage.py @@ -0,0 +1,183 @@ +from __future__ import annotations + +import json +import re +import secrets +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +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}$") + + +def now_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + +def new_id(prefix: str) -> str: + return f"{prefix}_{secrets.token_hex(6)}" + + +def safe_task_id(task_id: str) -> str: + value = str(task_id or "").strip() + if not TASK_ID.fullmatch(value): + raise ValueError("Invalid task id") + return value + + +def safe_conversation_id(conversation_id: str) -> str: + value = str(conversation_id or "").strip() + if not CONVERSATION_ID.fullmatch(value): + raise ValueError("Invalid conversation 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: + raise ValueError("Invalid artifact path") + return path.as_posix() + + +def write_json(path: Path, payload: Any) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + + +def read_json(path: Path, fallback: Any = None) -> Any: + if not path.is_file(): + return fallback + return json.loads(path.read_text(encoding="utf-8")) + + +class WorkspaceStore: + def __init__(self, settings: Settings) -> None: + self.settings = settings + self.settings.task_root.mkdir(parents=True, exist_ok=True) + self.settings.conversation_root.mkdir(parents=True, exist_ok=True) + + def task_dir(self, task_id: str) -> Path: + return self.settings.task_root / safe_task_id(task_id) + + def task_path(self, task_id: str) -> Path: + return self.task_dir(task_id) / "task.json" + + def conversation_dir(self, conversation_id: str) -> Path: + return self.settings.conversation_root / safe_conversation_id(conversation_id) + + def conversation_path(self, conversation_id: str) -> Path: + return self.conversation_dir(conversation_id) / "conversation.json" + + 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) + current = read_json(path) + if current: + changed = False + 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.0", + "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 [], + } + write_json(path, record) + return record + + def read_conversation(self, conversation_id: str) -> dict[str, Any] | None: + return read_json(self.conversation_path(conversation_id)) + + def append_conversation_message(self, conversation_id: str, message: dict[str, Any], current_task_id: str | None = None) -> dict[str, Any]: + record = self.ensure_conversation(conversation_id, current_task_id) + known = {str(item.get("id")) for item in record["messages"]} + if str(message.get("id")) not in known: + record["messages"].append(message) + if current_task_id: + record["current_task_id"] = safe_task_id(current_task_id) + record["updated_at"] = now_iso() + 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]: + 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.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(data) + return relative.as_posix(), target + + 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 + task_dir = self.task_dir(tid) + (task_dir / "revisions").mkdir(parents=True, exist_ok=True) + record = { + "schema_version": "1.0", + "task_id": tid, + "request": request, + "created_at": now_iso(), + "updated_at": now_iso(), + "current_revision": "", + "revisions": [], + } + write_json(path, record) + return record + + 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}" + revision_dir = self.task_dir(task_id) / "revisions" / revision_id + revision_dir.mkdir(parents=True, exist_ok=False) + return revision_id, revision_dir + + def update_task(self, task_id: str, revision: dict[str, Any]) -> dict[str, Any]: + task = self.ensure_task(task_id, "") + task["revisions"].append(revision) + if revision.get("status") == "success": + task["current_revision"] = revision["revision_id"] + task["updated_at"] = now_iso() + write_json(self.task_path(task_id), task) + return task + + def read_task(self, task_id: str) -> dict[str, Any] | None: + return read_json(self.task_path(task_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 "") + 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 artifact_path(self, task_id: str, relative_path: str) -> Path: + safe = safe_relative_path(relative_path) + root = self.task_dir(task_id).resolve() + target = (root / safe).resolve() + if root != target and root not in target.parents: + raise ValueError("Artifact path escapes task directory") + return target diff --git a/backend/app/settings.py b/backend/app/settings.py new file mode 100644 index 00000000..8a7ca64d --- /dev/null +++ b/backend/app/settings.py @@ -0,0 +1,111 @@ +from __future__ import annotations + +import os +from dataclasses import dataclass +from pathlib import Path + +from dotenv import load_dotenv + + +BACKEND_ROOT = Path(__file__).resolve().parents[1] +PROJECT_ROOT = BACKEND_ROOT.parent + +load_dotenv(BACKEND_ROOT / ".env") + + +@dataclass(frozen=True) +class ProviderModel: + id: str + vision: bool = False + + +@dataclass(frozen=True) +class ProviderConfig: + id: str + label: str + base_url: str + api_key: str + models: tuple[ProviderModel, ...] + + @property + def configured(self) -> bool: + return bool(self.base_url and self.api_key and self.models) + + def model(self, model_id: str) -> ProviderModel | None: + return next((model for model in self.models if model.id == model_id), None) + + +@dataclass(frozen=True) +class Settings: + task_root: Path + conversation_root: Path + library_root: Path + engine_root: Path + llm_base_url: str + llm_api_key: str + llm_model: str + llm_timeout_s: float + default_provider_id: str + providers: tuple[ProviderConfig, ...] + + @property + def llm_configured(self) -> bool: + return self.provider_for(self.default_provider_id) is not None + + def provider_for(self, provider_id: str | None) -> ProviderConfig | None: + requested = str(provider_id or self.default_provider_id).strip().lower() + return next((provider for provider in self.providers if provider.id == requested and provider.configured), None) + + def resolve_model(self, provider_id: str | None, model_id: str | None) -> tuple[ProviderConfig, ProviderModel]: + provider = self.provider_for(provider_id) + if provider is None: + raise ValueError("The selected model provider is not configured") + selected = str(model_id or "").strip() or provider.models[0].id + model = provider.model(selected) + if model is None: + raise ValueError("The selected model is not enabled for this provider") + return provider, model + + +def _models(value: str, vision_value: str = "") -> tuple[ProviderModel, ...]: + vision_ids = {item.strip() for item in vision_value.split(",") if item.strip()} + return tuple( + ProviderModel(id=item, vision=item in vision_ids) + for item in (part.strip() for part in value.split(",")) + if item + ) + + +def _provider(prefix: str, provider_id: str, label: str, default_base_url: str, default_model: str = "") -> ProviderConfig: + # The legacy CDSL_LLM_* variables remain the DeepSeek default so existing + # local installations continue to work without copying secrets. + legacy = provider_id == "deepseek" + base_url = os.getenv(f"CDSL_{prefix}_BASE_URL", os.getenv("CDSL_LLM_BASE_URL", default_base_url) if legacy else default_base_url).rstrip("/") + api_key = os.getenv(f"CDSL_{prefix}_API_KEY", os.getenv("CDSL_LLM_API_KEY", "") if legacy else "") + model_list = os.getenv(f"CDSL_{prefix}_MODELS", os.getenv("CDSL_LLM_MODEL", default_model) if legacy else default_model) + vision_models = os.getenv(f"CDSL_{prefix}_VISION_MODELS", "") + return ProviderConfig(provider_id, label, base_url, api_key, _models(model_list, vision_models)) + + +def get_settings() -> Settings: + data_root = BACKEND_ROOT / "data" + providers = ( + _provider("DEEPSEEK", "deepseek", "DeepSeek", "https://api.deepseek.com/v1", "deepseek-chat"), + _provider("OPENAI", "openai", "OpenAI", "https://api.openai.com/v1"), + _provider("KIMI", "kimi", "Kimi", "https://api.moonshot.cn/v1"), + ) + default_provider_id = os.getenv("CDSL_DEFAULT_PROVIDER", "deepseek").strip().lower() or "deepseek" + default_provider = next((item for item in providers if item.id == default_provider_id), providers[0]) + default_model = os.getenv("CDSL_DEFAULT_MODEL", "").strip() or (default_provider.models[0].id if default_provider.models else "") + return Settings( + task_root=data_root / "tasks", + conversation_root=data_root / "conversations", + library_root=BACKEND_ROOT / "cdsl_library", + engine_root=BACKEND_ROOT / "engine" / "cdsl_engine", + llm_base_url=default_provider.base_url, + llm_api_key=default_provider.api_key, + llm_model=default_model, + llm_timeout_s=float(os.getenv("CDSL_LLM_TIMEOUT_S", "90")), + default_provider_id=default_provider_id, + providers=providers, + ) diff --git a/backend/app/workers/.gitkeep b/backend/app/workers/.gitkeep new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/backend/app/workers/.gitkeep @@ -0,0 +1 @@ + diff --git a/backend/cdsl_library/build_index.py b/backend/cdsl_library/build_index.py new file mode 100644 index 00000000..3234e569 --- /dev/null +++ b/backend/cdsl_library/build_index.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +import json +import re +from pathlib import Path +from typing import Any + + +ROOT = Path(__file__).resolve().parent +SAMPLES = ROOT / "samples" +INDEX = ROOT / "index" / "catalog.json" + + +def flatten_keys(value: Any) -> set[str]: + if isinstance(value, dict): + return set(value) | set().union(*(flatten_keys(item) for item in value.values())) + if isinstance(value, list): + return set().union(*(flatten_keys(item) for item in value)) if value else set() + return set() + + +def build_index() -> dict[str, Any]: + records: list[dict[str, Any]] = [] + for source in sorted(SAMPLES.glob("*/model.cdsl.json")): + cdsl = json.loads(source.read_text(encoding="utf-8")) + profiles = [ + str(sketch.get("profile", {}).get("type")) + for sketch in cdsl.get("geometry", {}).get("sketches", []) + if sketch.get("profile", {}).get("type") + ] + features = [str(feature.get("atomic_id")) for feature in cdsl.get("features", [])] + parameter_names = sorted(flatten_keys(cdsl.get("geometry", {})) | flatten_keys(cdsl.get("features", []))) + part_id = str(cdsl.get("part_id") or source.parent.name) + source_name = str(cdsl.get("meta", {}).get("source") or part_id) + records.append({ + "part_id": part_id, + "source_name": source_name, + "profiles": profiles, + "features": features, + "parameters": parameter_names, + "summary": f"{part_id}: {', '.join(profiles)}; {', '.join(features)}", + }) + payload = {"schema_version": "1.0", "samples": records} + INDEX.parent.mkdir(parents=True, exist_ok=True) + INDEX.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + return payload + + +if __name__ == "__main__": + result = build_index() + print(json.dumps({"samples": len(result["samples"])}, ensure_ascii=False)) diff --git a/backend/cdsl_library/index/.gitkeep b/backend/cdsl_library/index/.gitkeep new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/backend/cdsl_library/index/.gitkeep @@ -0,0 +1 @@ + diff --git a/backend/cdsl_library/index/catalog.json b/backend/cdsl_library/index/catalog.json new file mode 100644 index 00000000..5b175aff --- /dev/null +++ b/backend/cdsl_library/index/catalog.json @@ -0,0 +1,1233 @@ +{ + "schema_version": "1.0", + "samples": [ + { + "part_id": "b005", + "source_name": "b005.solidworks_evidence_v2.json", + "profiles": [ + "circle", + "partial_ring_with_arc_island" + ], + "features": [ + "extrude_add_blind", + "extrude_cut_blind" + ], + "parameters": [ + "atomic_id", + "center", + "center_angles_deg", + "depends_on", + "distance_mm", + "half_angle_deg", + "id", + "inner_radius_mm", + "island_gap_mm", + "island_radius_mm", + "name", + "normal", + "origin_mm", + "outer_radius_mm", + "params", + "profile", + "radius_mm", + "reverse", + "sketch_id", + "sketches", + "type", + "workplane", + "x_dir", + "y_dir" + ], + "summary": "b005: circle, partial_ring_with_arc_island; extrude_add_blind, extrude_cut_blind" + }, + { + "part_id": "b006", + "source_name": "b006.solidworks_evidence_v2.json", + "profiles": [ + "rectangle", + "circle_grid" + ], + "features": [ + "extrude_add_blind", + "extrude_cut_blind" + ], + "parameters": [ + "atomic_id", + "center", + "count_x", + "count_y", + "depends_on", + "distance_mm", + "height_mm", + "id", + "name", + "normal", + "origin_mm", + "params", + "profile", + "radius_mm", + "reverse", + "sketch_id", + "sketches", + "spacing_x_mm", + "spacing_y_mm", + "type", + "width_mm", + "workplane", + "x_dir", + "y_dir" + ], + "summary": "b006: rectangle, circle_grid; extrude_add_blind, extrude_cut_blind" + }, + { + "part_id": "cylinder_001_cylinder_01_sector_footprint_d50_sector12", + "source_name": "cylinder_001_cylinder_01_sector_footprint_d50_sector12.solidworks_evidence_v2.json", + "profiles": [ + "circle", + "patterned_cutouts" + ], + "features": [ + "extrude_add_blind", + "extrude_cut_blind" + ], + "parameters": [ + "atomic_id", + "center", + "count", + "depends_on", + "distance_mm", + "half_angle_deg", + "id", + "inner_radius_mm", + "layout", + "motif", + "name", + "normal", + "orientation", + "origin_mm", + "outer_radius_mm", + "params", + "profile", + "radius_mm", + "reverse", + "sketch_id", + "sketches", + "start_angle_deg", + "type", + "workplane", + "x_dir", + "y_dir" + ], + "summary": "cylinder_001_cylinder_01_sector_footprint_d50_sector12: circle, patterned_cutouts; extrude_add_blind, extrude_cut_blind" + }, + { + "part_id": "cylinder_002_cylinder_02_sector_footprint_d25_scaled_sector12", + "source_name": "cylinder_002_cylinder_02_sector_footprint_d25_scaled_sector12.solidworks_evidence_v2.json", + "profiles": [ + "circle", + "patterned_cutouts" + ], + "features": [ + "extrude_add_blind", + "extrude_cut_blind" + ], + "parameters": [ + "atomic_id", + "center", + "count", + "depends_on", + "distance_mm", + "half_angle_deg", + "id", + "inner_radius_mm", + "layout", + "motif", + "name", + "normal", + "orientation", + "origin_mm", + "outer_radius_mm", + "params", + "profile", + "radius_mm", + "reverse", + "sketch_id", + "sketches", + "start_angle_deg", + "type", + "workplane", + "x_dir", + "y_dir" + ], + "summary": "cylinder_002_cylinder_02_sector_footprint_d25_scaled_sector12: circle, patterned_cutouts; extrude_add_blind, extrude_cut_blind" + }, + { + "part_id": "cylinder_003_cylinder_03_circle_ring_array_circle32", + "source_name": "cylinder_003_cylinder_03_circle_ring_array_circle32.solidworks_evidence_v2.json", + "profiles": [ + "circle", + "patterned_cutouts" + ], + "features": [ + "extrude_add_blind", + "extrude_cut_blind" + ], + "parameters": [ + "atomic_id", + "center", + "count", + "depends_on", + "distance_mm", + "id", + "layout", + "motif", + "name", + "normal", + "origin_mm", + "params", + "profile", + "radius_mm", + "reverse", + "sketch_id", + "sketches", + "type", + "workplane", + "x_dir", + "y_dir" + ], + "summary": "cylinder_003_cylinder_03_circle_ring_array_circle32: circle, patterned_cutouts; extrude_add_blind, extrude_cut_blind" + }, + { + "part_id": "cylinder_004_cylinder_04_square_ring_array_rectangle24", + "source_name": "cylinder_004_cylinder_04_square_ring_array_rectangle24.solidworks_evidence_v2.json", + "profiles": [ + "circle", + "patterned_cutouts" + ], + "features": [ + "extrude_add_blind", + "extrude_cut_blind" + ], + "parameters": [ + "atomic_id", + "center", + "count", + "depends_on", + "distance_mm", + "id", + "layout", + "motif", + "name", + "normal", + "origin_mm", + "params", + "profile", + "radius_mm", + "reverse", + "sketch_id", + "sketches", + "type", + "width_mm", + "workplane", + "x_dir", + "y_dir" + ], + "summary": "cylinder_004_cylinder_04_square_ring_array_rectangle24: circle, patterned_cutouts; extrude_add_blind, extrude_cut_blind" + }, + { + "part_id": "cylinder_005_cylinder_05_diamond_ring_array_polygon24", + "source_name": "cylinder_005_cylinder_05_diamond_ring_array_polygon24.solidworks_evidence_v2.json", + "profiles": [ + "circle", + "patterned_cutouts" + ], + "features": [ + "extrude_add_blind", + "extrude_cut_blind" + ], + "parameters": [ + "atomic_id", + "center", + "count", + "depends_on", + "distance_mm", + "id", + "layout", + "motif", + "name", + "normal", + "origin_mm", + "params", + "profile", + "radius_mm", + "reverse", + "sketch_id", + "sketches", + "type", + "width_mm", + "workplane", + "x_dir", + "y_dir" + ], + "summary": "cylinder_005_cylinder_05_diamond_ring_array_polygon24: circle, patterned_cutouts; extrude_add_blind, extrude_cut_blind" + }, + { + "part_id": "cylinder_006_cylinder_06_circle_square_grid_in_disc_circle45", + "source_name": "cylinder_006_cylinder_06_circle_square_grid_in_disc_circle45.solidworks_evidence_v2.json", + "profiles": [ + "circle", + "patterned_cutouts" + ], + "features": [ + "extrude_add_blind", + "extrude_cut_blind" + ], + "parameters": [ + "atomic_id", + "center", + "center_mm", + "count_x", + "count_y", + "depends_on", + "distance_mm", + "id", + "layout", + "max_center_radius_mm", + "motif", + "name", + "normal", + "origin_mm", + "params", + "profile", + "radius_mm", + "reverse", + "sketch_id", + "sketches", + "spacing_x_mm", + "spacing_y_mm", + "type", + "workplane", + "x_dir", + "y_dir" + ], + "summary": "cylinder_006_cylinder_06_circle_square_grid_in_disc_circle45: circle, patterned_cutouts; extrude_add_blind, extrude_cut_blind" + }, + { + "part_id": "cylinder_007_cylinder_07_sector_double_ring_sector36", + "source_name": "cylinder_007_cylinder_07_sector_double_ring_sector36.solidworks_evidence_v2.json", + "profiles": [ + "circle", + "compound_patterned_cutouts" + ], + "features": [ + "extrude_add_blind", + "extrude_cut_blind" + ], + "parameters": [ + "atomic_id", + "center", + "count", + "depends_on", + "distance_mm", + "half_angle_deg", + "id", + "inner_radius_mm", + "layout", + "motif", + "name", + "normal", + "orientation", + "origin_mm", + "outer_radius_mm", + "params", + "patterns", + "profile", + "radius_mm", + "reverse", + "sketch_id", + "sketches", + "start_angle_deg", + "type", + "workplane", + "x_dir", + "y_dir" + ], + "summary": "cylinder_007_cylinder_07_sector_double_ring_sector36: circle, compound_patterned_cutouts; extrude_add_blind, extrude_cut_blind" + }, + { + "part_id": "cylinder_008_cylinder_08_slot_ring_array_slot24", + "source_name": "cylinder_008_cylinder_08_slot_ring_array_slot24.solidworks_evidence_v2.json", + "profiles": [ + "circle", + "patterned_cutouts" + ], + "features": [ + "extrude_add_blind", + "extrude_cut_blind" + ], + "parameters": [ + "atomic_id", + "center", + "count", + "depends_on", + "distance_mm", + "id", + "layout", + "length_mm", + "motif", + "name", + "normal", + "orientation", + "origin_mm", + "params", + "profile", + "radius_mm", + "reverse", + "sketch_id", + "sketches", + "type", + "width_mm", + "workplane", + "x_dir", + "y_dir" + ], + "summary": "cylinder_008_cylinder_08_slot_ring_array_slot24: circle, patterned_cutouts; extrude_add_blind, extrude_cut_blind" + }, + { + "part_id": "cylinder_009_cylinder_09_sector_radial_fan_sector12", + "source_name": "cylinder_009_cylinder_09_sector_radial_fan_sector12.solidworks_evidence_v2.json", + "profiles": [ + "circle", + "patterned_cutouts" + ], + "features": [ + "extrude_add_blind", + "extrude_cut_blind" + ], + "parameters": [ + "atomic_id", + "center", + "count", + "depends_on", + "distance_mm", + "half_angle_deg", + "id", + "inner_radius_mm", + "layout", + "motif", + "name", + "normal", + "orientation", + "origin_mm", + "outer_radius_mm", + "params", + "profile", + "radius_mm", + "reverse", + "sketch_id", + "sketches", + "start_angle_deg", + "type", + "workplane", + "x_dir", + "y_dir" + ], + "summary": "cylinder_009_cylinder_09_sector_radial_fan_sector12: circle, patterned_cutouts; extrude_add_blind, extrude_cut_blind" + }, + { + "part_id": "cylinder_010_cylinder_10_circle_scaled_double_ring_circle36", + "source_name": "cylinder_010_cylinder_10_circle_scaled_double_ring_circle36.solidworks_evidence_v2.json", + "profiles": [ + "circle", + "patterned_cutouts" + ], + "features": [ + "extrude_add_blind", + "extrude_cut_blind" + ], + "parameters": [ + "atomic_id", + "center", + "count", + "depends_on", + "distance_mm", + "id", + "layout", + "motif", + "name", + "normal", + "origin_mm", + "params", + "profile", + "radius_mm", + "reverse", + "rings", + "sketch_id", + "sketches", + "start_angle_deg", + "type", + "workplane", + "x_dir", + "y_dir" + ], + "summary": "cylinder_010_cylinder_10_circle_scaled_double_ring_circle36: circle, patterned_cutouts; extrude_add_blind, extrude_cut_blind" + }, + { + "part_id": "cylinder_011_cylinder_11_sector_scaled_ring_v01_sector8", + "source_name": "cylinder_011_cylinder_11_sector_scaled_ring_v01_sector8.solidworks_evidence_v2.json", + "profiles": [ + "circle", + "patterned_cutouts" + ], + "features": [ + "extrude_add_blind", + "extrude_cut_blind" + ], + "parameters": [ + "atomic_id", + "center", + "count", + "depends_on", + "distance_mm", + "half_angle_deg", + "id", + "inner_radius_mm", + "layout", + "motif", + "name", + "normal", + "orientation", + "origin_mm", + "outer_radius_mm", + "params", + "profile", + "radius_mm", + "reverse", + "sketch_id", + "sketches", + "start_angle_deg", + "type", + "workplane", + "x_dir", + "y_dir" + ], + "summary": "cylinder_011_cylinder_11_sector_scaled_ring_v01_sector8: circle, patterned_cutouts; extrude_add_blind, extrude_cut_blind" + }, + { + "part_id": "cylinder_012_cylinder_12_circle_ring_array_v01_circle24", + "source_name": "cylinder_012_cylinder_12_circle_ring_array_v01_circle24.solidworks_evidence_v2.json", + "profiles": [ + "circle", + "patterned_cutouts" + ], + "features": [ + "extrude_add_blind", + "extrude_cut_blind" + ], + "parameters": [ + "atomic_id", + "center", + "count", + "depends_on", + "distance_mm", + "id", + "layout", + "motif", + "name", + "normal", + "origin_mm", + "params", + "profile", + "radius_mm", + "reverse", + "sketch_id", + "sketches", + "type", + "workplane", + "x_dir", + "y_dir" + ], + "summary": "cylinder_012_cylinder_12_circle_ring_array_v01_circle24: circle, patterned_cutouts; extrude_add_blind, extrude_cut_blind" + }, + { + "part_id": "cylinder_013_cylinder_13_square_ring_array_v01_rectangle16", + "source_name": "cylinder_013_cylinder_13_square_ring_array_v01_rectangle16.solidworks_evidence_v2.json", + "profiles": [ + "circle", + "patterned_cutouts" + ], + "features": [ + "extrude_add_blind", + "extrude_cut_blind" + ], + "parameters": [ + "atomic_id", + "center", + "count", + "depends_on", + "distance_mm", + "id", + "layout", + "motif", + "name", + "normal", + "origin_mm", + "params", + "profile", + "radius_mm", + "reverse", + "sketch_id", + "sketches", + "type", + "width_mm", + "workplane", + "x_dir", + "y_dir" + ], + "summary": "cylinder_013_cylinder_13_square_ring_array_v01_rectangle16: circle, patterned_cutouts; extrude_add_blind, extrude_cut_blind" + }, + { + "part_id": "cylinder_014_cylinder_14_diamond_ring_array_v01_polygon16", + "source_name": "cylinder_014_cylinder_14_diamond_ring_array_v01_polygon16.solidworks_evidence_v2.json", + "profiles": [ + "circle", + "patterned_cutouts" + ], + "features": [ + "extrude_add_blind", + "extrude_cut_blind" + ], + "parameters": [ + "atomic_id", + "center", + "count", + "depends_on", + "distance_mm", + "id", + "layout", + "motif", + "name", + "normal", + "origin_mm", + "params", + "profile", + "radius_mm", + "reverse", + "sketch_id", + "sketches", + "type", + "width_mm", + "workplane", + "x_dir", + "y_dir" + ], + "summary": "cylinder_014_cylinder_14_diamond_ring_array_v01_polygon16: circle, patterned_cutouts; extrude_add_blind, extrude_cut_blind" + }, + { + "part_id": "cylinder_015_cylinder_15_circle_grid_in_disc_v01_circle30", + "source_name": "cylinder_015_cylinder_15_circle_grid_in_disc_v01_circle30.solidworks_evidence_v2.json", + "profiles": [ + "circle", + "patterned_cutouts" + ], + "features": [ + "extrude_add_blind", + "extrude_cut_blind" + ], + "parameters": [ + "atomic_id", + "center", + "center_mm", + "count_x", + "count_y", + "depends_on", + "distance_mm", + "id", + "layout", + "motif", + "name", + "normal", + "origin_mm", + "params", + "profile", + "radius_mm", + "reverse", + "sketch_id", + "sketches", + "spacing_x_mm", + "spacing_y_mm", + "type", + "workplane", + "x_dir", + "y_dir" + ], + "summary": "cylinder_015_cylinder_15_circle_grid_in_disc_v01_circle30: circle, patterned_cutouts; extrude_add_blind, extrude_cut_blind" + }, + { + "part_id": "cylinder_016_cylinder_16_dshape_open_arc_v01_dshape18", + "source_name": "cylinder_016_cylinder_16_dshape_open_arc_v01_dshape18.solidworks_evidence_v2.json", + "profiles": [ + "circle", + "patterned_cutouts" + ], + "features": [ + "extrude_add_blind", + "extrude_cut_blind" + ], + "parameters": [ + "atomic_id", + "center", + "count", + "depends_on", + "distance_mm", + "end_angle_deg", + "half_height_mm", + "id", + "layout", + "motif", + "name", + "normal", + "nose_depth_mm", + "origin_mm", + "params", + "profile", + "radius_mm", + "reverse", + "sketch_id", + "sketches", + "start_angle_deg", + "stem_length_mm", + "type", + "workplane", + "x_dir", + "y_dir" + ], + "summary": "cylinder_016_cylinder_16_dshape_open_arc_v01_dshape18: circle, patterned_cutouts; extrude_add_blind, extrude_cut_blind" + }, + { + "part_id": "cylinder_017_cylinder_17_cross_open_arc_v01_cross14", + "source_name": "cylinder_017_cylinder_17_cross_open_arc_v01_cross14.solidworks_evidence_v2.json", + "profiles": [ + "circle", + "patterned_cutouts" + ], + "features": [ + "extrude_add_blind", + "extrude_cut_blind" + ], + "parameters": [ + "arm_width_mm", + "atomic_id", + "center", + "count", + "depends_on", + "distance_mm", + "end_angle_deg", + "id", + "layout", + "motif", + "name", + "normal", + "origin_mm", + "params", + "profile", + "radius_mm", + "reverse", + "size_mm", + "sketch_id", + "sketches", + "start_angle_deg", + "type", + "workplane", + "x_dir", + "y_dir" + ], + "summary": "cylinder_017_cylinder_17_cross_open_arc_v01_cross14: circle, patterned_cutouts; extrude_add_blind, extrude_cut_blind" + }, + { + "part_id": "cylinder_018_cylinder_18_slot_ring_array_v01_slot16", + "source_name": "cylinder_018_cylinder_18_slot_ring_array_v01_slot16.solidworks_evidence_v2.json", + "profiles": [ + "circle", + "patterned_cutouts" + ], + "features": [ + "extrude_add_blind", + "extrude_cut_blind" + ], + "parameters": [ + "atomic_id", + "center", + "count", + "depends_on", + "distance_mm", + "id", + "layout", + "length_mm", + "motif", + "name", + "normal", + "orientation", + "origin_mm", + "params", + "profile", + "radius_mm", + "reverse", + "sketch_id", + "sketches", + "type", + "width_mm", + "workplane", + "x_dir", + "y_dir" + ], + "summary": "cylinder_018_cylinder_18_slot_ring_array_v01_slot16: circle, patterned_cutouts; extrude_add_blind, extrude_cut_blind" + }, + { + "part_id": "cylinder_019_cylinder_19_circle_scaled_double_ring_v01_circle24", + "source_name": "cylinder_019_cylinder_19_circle_scaled_double_ring_v01_circle24.solidworks_evidence_v2.json", + "profiles": [ + "circle", + "patterned_cutouts" + ], + "features": [ + "extrude_add_blind", + "extrude_cut_blind" + ], + "parameters": [ + "atomic_id", + "center", + "count", + "depends_on", + "distance_mm", + "id", + "layout", + "motif", + "name", + "normal", + "origin_mm", + "params", + "profile", + "radius_mm", + "reverse", + "rings", + "sketch_id", + "sketches", + "start_angle_deg", + "type", + "workplane", + "x_dir", + "y_dir" + ], + "summary": "cylinder_019_cylinder_19_circle_scaled_double_ring_v01_circle24: circle, patterned_cutouts; extrude_add_blind, extrude_cut_blind" + }, + { + "part_id": "cylinder_020_cylinder_20_sector_radial_fan_v01_sector10", + "source_name": "cylinder_020_cylinder_20_sector_radial_fan_v01_sector10.solidworks_evidence_v2.json", + "profiles": [ + "circle", + "patterned_cutouts" + ], + "features": [ + "extrude_add_blind", + "extrude_cut_blind" + ], + "parameters": [ + "atomic_id", + "center", + "count", + "depends_on", + "distance_mm", + "half_angle_deg", + "id", + "inner_radius_mm", + "layout", + "motif", + "name", + "normal", + "orientation", + "origin_mm", + "outer_radius_mm", + "params", + "profile", + "radius_mm", + "reverse", + "sketch_id", + "sketches", + "start_angle_deg", + "type", + "workplane", + "x_dir", + "y_dir" + ], + "summary": "cylinder_020_cylinder_20_sector_radial_fan_v01_sector10: circle, patterned_cutouts; extrude_add_blind, extrude_cut_blind" + }, + { + "part_id": "cylinder_021_cylinder_21_sector_double_ring_v01_sector24", + "source_name": "cylinder_021_cylinder_21_sector_double_ring_v01_sector24.solidworks_evidence_v2.json", + "profiles": [ + "circle", + "compound_patterned_cutouts" + ], + "features": [ + "extrude_add_blind", + "extrude_cut_blind" + ], + "parameters": [ + "atomic_id", + "center", + "count", + "depends_on", + "distance_mm", + "half_angle_deg", + "id", + "inner_radius_mm", + "layout", + "motif", + "name", + "normal", + "orientation", + "origin_mm", + "outer_radius_mm", + "params", + "patterns", + "profile", + "radius_mm", + "reverse", + "sketch_id", + "sketches", + "start_angle_deg", + "type", + "workplane", + "x_dir", + "y_dir" + ], + "summary": "cylinder_021_cylinder_21_sector_double_ring_v01_sector24: circle, compound_patterned_cutouts; extrude_add_blind, extrude_cut_blind" + }, + { + "part_id": "cylinder_022_cylinder_22_hexagon_spiral_v01_polygon28", + "source_name": "cylinder_022_cylinder_22_hexagon_spiral_v01_polygon28.solidworks_evidence_v2.json", + "profiles": [ + "circle", + "patterned_cutouts" + ], + "features": [ + "extrude_add_blind", + "extrude_cut_blind" + ], + "parameters": [ + "angle_step_deg", + "atomic_id", + "center", + "count", + "depends_on", + "distance_mm", + "id", + "layout", + "motif", + "name", + "nominal_radius_mm", + "normal", + "orientation", + "orientation_offset_deg", + "orientation_snap_deg", + "origin_mm", + "params", + "profile", + "radius_mm", + "radius_step_mm", + "reverse", + "sketch_id", + "sketches", + "start_angle_deg", + "start_radius_mm", + "type", + "workplane", + "x_dir", + "y_dir" + ], + "summary": "cylinder_022_cylinder_22_hexagon_spiral_v01_polygon28: circle, patterned_cutouts; extrude_add_blind, extrude_cut_blind" + }, + { + "part_id": "cylinder_023_cylinder_23_hexagon_cross_v01_polygon12", + "source_name": "cylinder_023_cylinder_23_hexagon_cross_v01_polygon12.solidworks_evidence_v2.json", + "profiles": [ + "circle", + "patterned_cutouts" + ], + "features": [ + "extrude_add_blind", + "extrude_cut_blind" + ], + "parameters": [ + "atomic_id", + "center", + "count_per_axis", + "depends_on", + "distance_mm", + "id", + "layout", + "motif", + "name", + "nominal_radius_mm", + "normal", + "orientation_offset_deg", + "origin_mm", + "params", + "profile", + "radius_mm", + "reverse", + "sketch_id", + "sketches", + "spacing_mm", + "type", + "workplane", + "x_dir", + "y_dir" + ], + "summary": "cylinder_023_cylinder_23_hexagon_cross_v01_polygon12: circle, patterned_cutouts; extrude_add_blind, extrude_cut_blind" + }, + { + "part_id": "cylinder_024_cylinder_24_triangle_x_field_v01_polygon9", + "source_name": "cylinder_024_cylinder_24_triangle_x_field_v01_polygon9.solidworks_evidence_v2.json", + "profiles": [ + "circle", + "patterned_cutouts" + ], + "features": [ + "extrude_add_blind", + "extrude_cut_blind" + ], + "parameters": [ + "atomic_id", + "center", + "depends_on", + "distance_mm", + "id", + "layout", + "levels", + "motif", + "name", + "normal", + "orientation", + "origin_mm", + "params", + "profile", + "radius_mm", + "reverse", + "sketch_id", + "sketches", + "spacing_mm", + "type", + "workplane", + "x_dir", + "y_dir" + ], + "summary": "cylinder_024_cylinder_24_triangle_x_field_v01_polygon9: circle, patterned_cutouts; extrude_add_blind, extrude_cut_blind" + }, + { + "part_id": "cylinder_025_cylinder_25_teardrop_twin_strips_v01_teardrop24", + "source_name": "cylinder_025_cylinder_25_teardrop_twin_strips_v01_teardrop24.solidworks_evidence_v2.json", + "profiles": [ + "circle", + "patterned_cutouts" + ], + "features": [ + "extrude_add_blind", + "extrude_cut_blind" + ], + "parameters": [ + "atomic_id", + "bottom_depth_mm", + "center", + "count_y", + "depends_on", + "distance_mm", + "id", + "layout", + "left_width_mm", + "motif", + "name", + "normal", + "origin_mm", + "params", + "profile", + "radius_mm", + "reverse", + "right_width_mm", + "shoulder_height_mm", + "sketch_id", + "sketches", + "tip_height_mm", + "type", + "workplane", + "x_dir", + "x_offset_mm", + "y_dir", + "y_end_mm", + "y_start_mm" + ], + "summary": "cylinder_025_cylinder_25_teardrop_twin_strips_v01_teardrop24: circle, patterned_cutouts; extrude_add_blind, extrude_cut_blind" + }, + { + "part_id": "cylinder_026_cylinder_26_trapezoid_center_plus_ring_v01_trapezoid24", + "source_name": "cylinder_026_cylinder_26_trapezoid_center_plus_ring_v01_trapezoid24.solidworks_evidence_v2.json", + "profiles": [ + "circle", + "patterned_cutouts" + ], + "features": [ + "extrude_add_blind", + "extrude_cut_blind" + ], + "parameters": [ + "atomic_id", + "bottom_width_mm", + "center", + "count", + "depends_on", + "distance_mm", + "height_mm", + "id", + "layout", + "motif", + "name", + "normal", + "orientation", + "origin_mm", + "params", + "profile", + "radius_mm", + "reverse", + "rings", + "sketch_id", + "sketches", + "start_angle_deg", + "top_width_mm", + "type", + "workplane", + "x_dir", + "y_dir" + ], + "summary": "cylinder_026_cylinder_26_trapezoid_center_plus_ring_v01_trapezoid24: circle, patterned_cutouts; extrude_add_blind, extrude_cut_blind" + }, + { + "part_id": "cylinder_027_cylinder_27_dshape_corner_clusters_v01_dshape36", + "source_name": "cylinder_027_cylinder_27_dshape_corner_clusters_v01_dshape36.solidworks_evidence_v2.json", + "profiles": [ + "circle", + "patterned_cutouts" + ], + "features": [ + "extrude_add_blind", + "extrude_cut_blind" + ], + "parameters": [ + "atomic_id", + "center", + "depends_on", + "distance_mm", + "half_height_mm", + "id", + "layout", + "levels_mm", + "motif", + "name", + "normal", + "nose_depth_mm", + "origin_mm", + "params", + "profile", + "radius_mm", + "reverse", + "sketch_id", + "sketches", + "stem_length_mm", + "type", + "workplane", + "x_dir", + "y_dir" + ], + "summary": "cylinder_027_cylinder_27_dshape_corner_clusters_v01_dshape36: circle, patterned_cutouts; extrude_add_blind, extrude_cut_blind" + }, + { + "part_id": "cylinder_028_cylinder_28_diamond_field_v01_polygon25", + "source_name": "cylinder_028_cylinder_28_diamond_field_v01_polygon25.solidworks_evidence_v2.json", + "profiles": [ + "circle", + "patterned_cutouts" + ], + "features": [ + "extrude_add_blind", + "extrude_cut_blind" + ], + "parameters": [ + "atomic_id", + "center", + "depends_on", + "distance_mm", + "id", + "layout", + "manhattan_radius", + "motif", + "name", + "normal", + "origin_mm", + "params", + "profile", + "radius_mm", + "reverse", + "sketch_id", + "sketches", + "spacing_mm", + "type", + "width_mm", + "workplane", + "x_dir", + "y_dir" + ], + "summary": "cylinder_028_cylinder_28_diamond_field_v01_polygon25: circle, patterned_cutouts; extrude_add_blind, extrude_cut_blind" + }, + { + "part_id": "cylinder_029_cylinder_29_sector_scaled_ring_v02_sector10", + "source_name": "cylinder_029_cylinder_29_sector_scaled_ring_v02_sector10.solidworks_evidence_v2.json", + "profiles": [ + "circle", + "patterned_cutouts" + ], + "features": [ + "extrude_add_blind", + "extrude_cut_blind" + ], + "parameters": [ + "atomic_id", + "center", + "count", + "depends_on", + "distance_mm", + "half_angle_deg", + "id", + "inner_radius_mm", + "layout", + "motif", + "name", + "normal", + "orientation", + "origin_mm", + "outer_radius_mm", + "params", + "profile", + "radius_mm", + "reverse", + "sketch_id", + "sketches", + "start_angle_deg", + "type", + "workplane", + "x_dir", + "y_dir" + ], + "summary": "cylinder_029_cylinder_29_sector_scaled_ring_v02_sector10: circle, patterned_cutouts; extrude_add_blind, extrude_cut_blind" + } + ] +} diff --git a/backend/cdsl_library/samples/.gitkeep b/backend/cdsl_library/samples/.gitkeep new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/backend/cdsl_library/samples/.gitkeep @@ -0,0 +1 @@ + diff --git a/backend/cdsl_library/samples/b005/model.cdsl.json b/backend/cdsl_library/samples/b005/model.cdsl.json new file mode 100644 index 00000000..54cce428 --- /dev/null +++ b/backend/cdsl_library/samples/b005/model.cdsl.json @@ -0,0 +1,76 @@ +{ + "schema": "cad.cdsl.llm.v1", + "schema_version": "1.0.0", + "kind": "part", + "part_id": "b005", + "features": [ + { + "id": "f01", + "atomic_id": "extrude_add_blind", + "depends_on": [], + "name": "凸台-拉伸1", + "params": { + "distance_mm": 20.0, + "reverse": true + }, + "sketch_id": "sketch_001" + }, + { + "id": "f02", + "atomic_id": "extrude_cut_blind", + "depends_on": ["f01"], + "name": "切除-拉伸1", + "params": { + "distance_mm": 8.0 + }, + "sketch_id": "sketch_002" + } + ], + "geometry": { + "sketches": [ + { + "id": "sketch_001", + "name": "草图1", + "workplane": { + "origin_mm": [0.0, 0.0, 0.0], + "x_dir": [1.0, 0.0, 0.0], + "y_dir": [0.0, 0.0, 1.0], + "normal": [0.0, -1.0, 0.0] + }, + "profile": { + "type": "circle", + "center": [0.0, 0.0], + "radius_mm": 25.0 + } + }, + { + "id": "sketch_002", + "name": "草图2", + "workplane": { + "origin_mm": [0.0, 20.0, 0.0], + "x_dir": [1.0, 0.0, 0.0], + "y_dir": [0.0, 0.0, 1.0], + "normal": [0.0, -1.0, 0.0] + }, + "profile": { + "type": "partial_ring_with_arc_island", + "inner_radius_mm": 10.0, + "outer_radius_mm": 20.0, + "half_angle_deg": 45.0, + "island_radius_mm": 12.265, + "island_gap_mm": 1.0, + "center_angles_deg": [90.0, -90.0] + } + } + ] + }, + "meta": { + "source": "b005.solidworks_evidence_v2.json", + "parameterized_sketches": 2, + "profile_from_sketches": 0, + "notes": [ + "CDSL-only drawable: profiles expand via shape generators; no compiler_context required", + "Cut regions = annular sectors minus constant-width arc islands on outer chords" + ] + } +} diff --git a/backend/cdsl_library/samples/b006/model.cdsl.json b/backend/cdsl_library/samples/b006/model.cdsl.json new file mode 100644 index 00000000..c03b4bc1 --- /dev/null +++ b/backend/cdsl_library/samples/b006/model.cdsl.json @@ -0,0 +1,79 @@ +{ + "schema": "cad.cdsl.llm.v1", + "schema_version": "1.0.0", + "kind": "part", + "part_id": "b006", + "features": [ + { + "id": "f01", + "atomic_id": "extrude_add_blind", + "depends_on": [], + "name": "凸台-拉伸1", + "params": { + "distance_mm": 5.0 + }, + "sketch_id": "sketch_001" + }, + { + "id": "f02", + "atomic_id": "extrude_cut_blind", + "depends_on": ["f01"], + "name": "切除-拉伸1", + "params": { + "distance_mm": 5.0, + "reverse": true + }, + "sketch_id": "sketch_002" + } + ], + "geometry": { + "sketches": [ + { + "id": "sketch_001", + "name": "草图1", + "workplane": { + "origin_mm": [0.0, 0.0, 0.0], + "x_dir": [1.0, 0.0, 0.0], + "y_dir": [0.0, 1.0, 0.0], + "normal": [0.0, 0.0, 1.0] + }, + "profile": { + "type": "rectangle", + "center": [0.0, 0.0], + "width_mm": 100.0, + "height_mm": 100.0 + } + }, + { + "id": "sketch_002", + "name": "草图2", + "workplane": { + "origin_mm": [0.0, 0.0, 5.0], + "x_dir": [1.0, 0.0, 0.0], + "y_dir": [0.0, 1.0, 0.0], + "normal": [0.0, 0.0, 1.0] + }, + "profile": { + "type": "circle_grid", + "radius_mm": 6.1655, + "count_x": 4, + "count_y": 5, + "spacing_x_mm": 23.0, + "spacing_y_mm": 18.0, + "origin_mm": [-33.7021, -35.5209] + } + } + ] + }, + "meta": { + "source": "b006.solidworks_evidence_v2.json", + "parameterized_sketches": 2, + "profile_from_sketches": 0, + "notes": [ + "Native part name suggests hole pattern (阵列孔)", + "Cut feature 切除-拉伸1 referenced by sketch2 children but missing from evidence feature list; depth assumed through-all = plate thickness 5mm", + "Sketch dims D1=4 D2=5 interpreted as pattern counts (count_x/count_y)", + "No gold STEP provided" + ] + } +} diff --git a/backend/cdsl_library/samples/cylinder_001_cylinder_01_sector_footprint_d50_sector12/model.cdsl.json b/backend/cdsl_library/samples/cylinder_001_cylinder_01_sector_footprint_d50_sector12/model.cdsl.json new file mode 100644 index 00000000..c34723ec --- /dev/null +++ b/backend/cdsl_library/samples/cylinder_001_cylinder_01_sector_footprint_d50_sector12/model.cdsl.json @@ -0,0 +1,82 @@ +{ + "schema": "cad.cdsl.llm.v1", + "schema_version": "1.0.0", + "kind": "part", + "part_id": "cylinder_001_cylinder_01_sector_footprint_d50_sector12", + "features": [ + { + "id": "f01", + "atomic_id": "extrude_add_blind", + "depends_on": [], + "name": "凸台-拉伸1", + "params": { + "distance_mm": 20.0 + }, + "sketch_id": "sketch_001" + }, + { + "id": "f02", + "atomic_id": "extrude_cut_blind", + "depends_on": ["f01"], + "name": "程序化图案切除", + "params": { + "distance_mm": 8.0, + "reverse": true + }, + "sketch_id": "sketch_002" + } + ], + "geometry": { + "sketches": [ + { + "id": "sketch_001", + "name": "草图1", + "workplane": { + "origin_mm": [0.0, 0.0, 0.0], + "x_dir": [1.0, 0.0, 0.0], + "y_dir": [0.0, 1.0, 0.0], + "normal": [0.0, 0.0, 1.0] + }, + "profile": { + "type": "circle", + "center": [0.0, 0.0], + "radius_mm": 50.0 + } + }, + { + "id": "sketch_002", + "name": "图案草图", + "workplane": { + "origin_mm": [0.0, 0.0, 20.0], + "x_dir": [1.0, 0.0, 0.0], + "y_dir": [0.0, 1.0, 0.0], + "normal": [0.0, 0.0, 1.0] + }, + "profile": { + "type": "patterned_cutouts", + "motif": { + "type": "annular_sector_polygon", + "inner_radius_mm": 16.0, + "outer_radius_mm": 25.0, + "half_angle_deg": 4.583662 + }, + "layout": { + "type": "angular", + "count": 12, + "start_angle_deg": 15.0, + "orientation": "radial" + } + } + } + ] + }, + "meta": { + "source": "cylinder_001_cylinder_01_sector_footprint_d50_sector12.solidworks_evidence_v2.json", + "parameterized_sketches": 2, + "profile_from_sketches": 0, + "notes": [ + "CDSL-only drawable: repeated source sketches are represented by one procedural motif and layout", + "No compiler_context, source entities, per-instance coordinates, or encoded geometry" + ] + } +} diff --git a/backend/cdsl_library/samples/cylinder_002_cylinder_02_sector_footprint_d25_scaled_sector12/model.cdsl.json b/backend/cdsl_library/samples/cylinder_002_cylinder_02_sector_footprint_d25_scaled_sector12/model.cdsl.json new file mode 100644 index 00000000..a0c8a8f6 --- /dev/null +++ b/backend/cdsl_library/samples/cylinder_002_cylinder_02_sector_footprint_d25_scaled_sector12/model.cdsl.json @@ -0,0 +1,82 @@ +{ + "schema": "cad.cdsl.llm.v1", + "schema_version": "1.0.0", + "kind": "part", + "part_id": "cylinder_002_cylinder_02_sector_footprint_d25_scaled_sector12", + "features": [ + { + "id": "f01", + "atomic_id": "extrude_add_blind", + "depends_on": [], + "name": "凸台-拉伸1", + "params": { + "distance_mm": 20.0 + }, + "sketch_id": "sketch_001" + }, + { + "id": "f02", + "atomic_id": "extrude_cut_blind", + "depends_on": ["f01"], + "name": "程序化图案切除", + "params": { + "distance_mm": 8.0, + "reverse": true + }, + "sketch_id": "sketch_002" + } + ], + "geometry": { + "sketches": [ + { + "id": "sketch_001", + "name": "草图1", + "workplane": { + "origin_mm": [0.0, 0.0, 0.0], + "x_dir": [1.0, 0.0, 0.0], + "y_dir": [0.0, 1.0, 0.0], + "normal": [0.0, 0.0, 1.0] + }, + "profile": { + "type": "circle", + "center": [0.0, 0.0], + "radius_mm": 50.0 + } + }, + { + "id": "sketch_002", + "name": "图案草图", + "workplane": { + "origin_mm": [0.0, 0.0, 20.0], + "x_dir": [1.0, 0.0, 0.0], + "y_dir": [0.0, 1.0, 0.0], + "normal": [0.0, 0.0, 1.0] + }, + "profile": { + "type": "patterned_cutouts", + "motif": { + "type": "annular_sector_polygon", + "inner_radius_mm": 8.0, + "outer_radius_mm": 12.5, + "half_angle_deg": 4.583662 + }, + "layout": { + "type": "angular", + "count": 12, + "start_angle_deg": 15.0, + "orientation": "radial" + } + } + } + ] + }, + "meta": { + "source": "cylinder_002_cylinder_02_sector_footprint_d25_scaled_sector12.solidworks_evidence_v2.json", + "parameterized_sketches": 2, + "profile_from_sketches": 0, + "notes": [ + "CDSL-only drawable: repeated source sketches are represented by one procedural motif and layout", + "No compiler_context, source entities, per-instance coordinates, or encoded geometry" + ] + } +} diff --git a/backend/cdsl_library/samples/cylinder_003_cylinder_03_circle_ring_array_circle32/model.cdsl.json b/backend/cdsl_library/samples/cylinder_003_cylinder_03_circle_ring_array_circle32/model.cdsl.json new file mode 100644 index 00000000..322c636c --- /dev/null +++ b/backend/cdsl_library/samples/cylinder_003_cylinder_03_circle_ring_array_circle32/model.cdsl.json @@ -0,0 +1,79 @@ +{ + "schema": "cad.cdsl.llm.v1", + "schema_version": "1.0.0", + "kind": "part", + "part_id": "cylinder_003_cylinder_03_circle_ring_array_circle32", + "features": [ + { + "id": "f01", + "atomic_id": "extrude_add_blind", + "depends_on": [], + "name": "凸台-拉伸1", + "params": { + "distance_mm": 20.0 + }, + "sketch_id": "sketch_001" + }, + { + "id": "f02", + "atomic_id": "extrude_cut_blind", + "depends_on": ["f01"], + "name": "程序化图案切除", + "params": { + "distance_mm": 8.0, + "reverse": true + }, + "sketch_id": "sketch_002" + } + ], + "geometry": { + "sketches": [ + { + "id": "sketch_001", + "name": "草图1", + "workplane": { + "origin_mm": [0.0, 0.0, 0.0], + "x_dir": [1.0, 0.0, 0.0], + "y_dir": [0.0, 1.0, 0.0], + "normal": [0.0, 0.0, 1.0] + }, + "profile": { + "type": "circle", + "center": [0.0, 0.0], + "radius_mm": 50.0 + } + }, + { + "id": "sketch_002", + "name": "图案草图", + "workplane": { + "origin_mm": [0.0, 0.0, 20.0], + "x_dir": [1.0, 0.0, 0.0], + "y_dir": [0.0, 1.0, 0.0], + "normal": [0.0, 0.0, 1.0] + }, + "profile": { + "type": "patterned_cutouts", + "motif": { + "type": "circle", + "radius_mm": 2.4 + }, + "layout": { + "type": "ring", + "radius_mm": 34.0, + "count": 32 + } + } + } + ] + }, + "meta": { + "source": "cylinder_003_cylinder_03_circle_ring_array_circle32.solidworks_evidence_v2.json", + "parameterized_sketches": 2, + "profile_from_sketches": 0, + "notes": [ + "CDSL-only drawable: repeated source sketches are represented by one procedural motif and layout", + "No compiler_context, source entities, per-instance coordinates, or encoded geometry" + ] + } +} diff --git a/backend/cdsl_library/samples/cylinder_004_cylinder_04_square_ring_array_rectangle24/model.cdsl.json b/backend/cdsl_library/samples/cylinder_004_cylinder_04_square_ring_array_rectangle24/model.cdsl.json new file mode 100644 index 00000000..66e2269c --- /dev/null +++ b/backend/cdsl_library/samples/cylinder_004_cylinder_04_square_ring_array_rectangle24/model.cdsl.json @@ -0,0 +1,79 @@ +{ + "schema": "cad.cdsl.llm.v1", + "schema_version": "1.0.0", + "kind": "part", + "part_id": "cylinder_004_cylinder_04_square_ring_array_rectangle24", + "features": [ + { + "id": "f01", + "atomic_id": "extrude_add_blind", + "depends_on": [], + "name": "凸台-拉伸1", + "params": { + "distance_mm": 20.0 + }, + "sketch_id": "sketch_001" + }, + { + "id": "f02", + "atomic_id": "extrude_cut_blind", + "depends_on": ["f01"], + "name": "程序化图案切除", + "params": { + "distance_mm": 8.0, + "reverse": true + }, + "sketch_id": "sketch_002" + } + ], + "geometry": { + "sketches": [ + { + "id": "sketch_001", + "name": "草图1", + "workplane": { + "origin_mm": [0.0, 0.0, 0.0], + "x_dir": [1.0, 0.0, 0.0], + "y_dir": [0.0, 1.0, 0.0], + "normal": [0.0, 0.0, 1.0] + }, + "profile": { + "type": "circle", + "center": [0.0, 0.0], + "radius_mm": 50.0 + } + }, + { + "id": "sketch_002", + "name": "图案草图", + "workplane": { + "origin_mm": [0.0, 0.0, 20.0], + "x_dir": [1.0, 0.0, 0.0], + "y_dir": [0.0, 1.0, 0.0], + "normal": [0.0, 0.0, 1.0] + }, + "profile": { + "type": "patterned_cutouts", + "motif": { + "type": "square", + "width_mm": 3.3 + }, + "layout": { + "type": "ring", + "radius_mm": 32.0, + "count": 24 + } + } + } + ] + }, + "meta": { + "source": "cylinder_004_cylinder_04_square_ring_array_rectangle24.solidworks_evidence_v2.json", + "parameterized_sketches": 2, + "profile_from_sketches": 0, + "notes": [ + "CDSL-only drawable: repeated source sketches are represented by one procedural motif and layout", + "No compiler_context, source entities, per-instance coordinates, or encoded geometry" + ] + } +} diff --git a/backend/cdsl_library/samples/cylinder_005_cylinder_05_diamond_ring_array_polygon24/model.cdsl.json b/backend/cdsl_library/samples/cylinder_005_cylinder_05_diamond_ring_array_polygon24/model.cdsl.json new file mode 100644 index 00000000..58db13c9 --- /dev/null +++ b/backend/cdsl_library/samples/cylinder_005_cylinder_05_diamond_ring_array_polygon24/model.cdsl.json @@ -0,0 +1,79 @@ +{ + "schema": "cad.cdsl.llm.v1", + "schema_version": "1.0.0", + "kind": "part", + "part_id": "cylinder_005_cylinder_05_diamond_ring_array_polygon24", + "features": [ + { + "id": "f01", + "atomic_id": "extrude_add_blind", + "depends_on": [], + "name": "凸台-拉伸1", + "params": { + "distance_mm": 20.0 + }, + "sketch_id": "sketch_001" + }, + { + "id": "f02", + "atomic_id": "extrude_cut_blind", + "depends_on": ["f01"], + "name": "程序化图案切除", + "params": { + "distance_mm": 8.0, + "reverse": true + }, + "sketch_id": "sketch_002" + } + ], + "geometry": { + "sketches": [ + { + "id": "sketch_001", + "name": "草图1", + "workplane": { + "origin_mm": [0.0, 0.0, 0.0], + "x_dir": [1.0, 0.0, 0.0], + "y_dir": [0.0, 1.0, 0.0], + "normal": [0.0, 0.0, 1.0] + }, + "profile": { + "type": "circle", + "center": [0.0, 0.0], + "radius_mm": 50.0 + } + }, + { + "id": "sketch_002", + "name": "图案草图", + "workplane": { + "origin_mm": [0.0, 0.0, 20.0], + "x_dir": [1.0, 0.0, 0.0], + "y_dir": [0.0, 1.0, 0.0], + "normal": [0.0, 0.0, 1.0] + }, + "profile": { + "type": "patterned_cutouts", + "motif": { + "type": "square", + "width_mm": 4.242641 + }, + "layout": { + "type": "ring", + "radius_mm": 32.0, + "count": 24 + } + } + } + ] + }, + "meta": { + "source": "cylinder_005_cylinder_05_diamond_ring_array_polygon24.solidworks_evidence_v2.json", + "parameterized_sketches": 2, + "profile_from_sketches": 0, + "notes": [ + "CDSL-only drawable: repeated source sketches are represented by one procedural motif and layout", + "No compiler_context, source entities, per-instance coordinates, or encoded geometry" + ] + } +} diff --git a/backend/cdsl_library/samples/cylinder_006_cylinder_06_circle_square_grid_in_disc_circle45/model.cdsl.json b/backend/cdsl_library/samples/cylinder_006_cylinder_06_circle_square_grid_in_disc_circle45/model.cdsl.json new file mode 100644 index 00000000..ae9aeef0 --- /dev/null +++ b/backend/cdsl_library/samples/cylinder_006_cylinder_06_circle_square_grid_in_disc_circle45/model.cdsl.json @@ -0,0 +1,83 @@ +{ + "schema": "cad.cdsl.llm.v1", + "schema_version": "1.0.0", + "kind": "part", + "part_id": "cylinder_006_cylinder_06_circle_square_grid_in_disc_circle45", + "features": [ + { + "id": "f01", + "atomic_id": "extrude_add_blind", + "depends_on": [], + "name": "凸台-拉伸1", + "params": { + "distance_mm": 20.0 + }, + "sketch_id": "sketch_001" + }, + { + "id": "f02", + "atomic_id": "extrude_cut_blind", + "depends_on": ["f01"], + "name": "程序化图案切除", + "params": { + "distance_mm": 8.0, + "reverse": true + }, + "sketch_id": "sketch_002" + } + ], + "geometry": { + "sketches": [ + { + "id": "sketch_001", + "name": "草图1", + "workplane": { + "origin_mm": [0.0, 0.0, 0.0], + "x_dir": [1.0, 0.0, 0.0], + "y_dir": [0.0, 1.0, 0.0], + "normal": [0.0, 0.0, 1.0] + }, + "profile": { + "type": "circle", + "center": [0.0, 0.0], + "radius_mm": 50.0 + } + }, + { + "id": "sketch_002", + "name": "图案草图", + "workplane": { + "origin_mm": [0.0, 0.0, 20.0], + "x_dir": [1.0, 0.0, 0.0], + "y_dir": [0.0, 1.0, 0.0], + "normal": [0.0, 0.0, 1.0] + }, + "profile": { + "type": "patterned_cutouts", + "motif": { + "type": "circle", + "radius_mm": 2.2 + }, + "layout": { + "type": "disc_grid", + "count_x": 7, + "count_y": 7, + "spacing_x_mm": 10.0, + "spacing_y_mm": 10.0, + "center_mm": [0.0, 0.0], + "max_center_radius_mm": 36.1 + } + } + } + ] + }, + "meta": { + "source": "cylinder_006_cylinder_06_circle_square_grid_in_disc_circle45.solidworks_evidence_v2.json", + "parameterized_sketches": 2, + "profile_from_sketches": 0, + "notes": [ + "CDSL-only drawable: repeated source sketches are represented by one procedural motif and layout", + "No compiler_context, source entities, per-instance coordinates, or encoded geometry" + ] + } +} diff --git a/backend/cdsl_library/samples/cylinder_007_cylinder_07_sector_double_ring_sector36/model.cdsl.json b/backend/cdsl_library/samples/cylinder_007_cylinder_07_sector_double_ring_sector36/model.cdsl.json new file mode 100644 index 00000000..923b1252 --- /dev/null +++ b/backend/cdsl_library/samples/cylinder_007_cylinder_07_sector_double_ring_sector36/model.cdsl.json @@ -0,0 +1,100 @@ +{ + "schema": "cad.cdsl.llm.v1", + "schema_version": "1.0.0", + "kind": "part", + "part_id": "cylinder_007_cylinder_07_sector_double_ring_sector36", + "features": [ + { + "id": "f01", + "atomic_id": "extrude_add_blind", + "depends_on": [], + "name": "凸台-拉伸1", + "params": { + "distance_mm": 20.0 + }, + "sketch_id": "sketch_001" + }, + { + "id": "f02", + "atomic_id": "extrude_cut_blind", + "depends_on": ["f01"], + "name": "程序化图案切除", + "params": { + "distance_mm": 8.0, + "reverse": true + }, + "sketch_id": "sketch_002" + } + ], + "geometry": { + "sketches": [ + { + "id": "sketch_001", + "name": "草图1", + "workplane": { + "origin_mm": [0.0, 0.0, 0.0], + "x_dir": [1.0, 0.0, 0.0], + "y_dir": [0.0, 1.0, 0.0], + "normal": [0.0, 0.0, 1.0] + }, + "profile": { + "type": "circle", + "center": [0.0, 0.0], + "radius_mm": 50.0 + } + }, + { + "id": "sketch_002", + "name": "双环图案草图", + "workplane": { + "origin_mm": [0.0, 0.0, 20.0], + "x_dir": [1.0, 0.0, 0.0], + "y_dir": [0.0, 1.0, 0.0], + "normal": [0.0, 0.0, 1.0] + }, + "profile": { + "type": "compound_patterned_cutouts", + "patterns": [ + { + "motif": { + "type": "annular_sector_polygon", + "inner_radius_mm": 12.0, + "outer_radius_mm": 20.0, + "half_angle_deg": 4.583662 + }, + "layout": { + "type": "angular", + "count": 12, + "start_angle_deg": 15.0, + "orientation": "radial" + } + }, + { + "motif": { + "type": "annular_sector_polygon", + "inner_radius_mm": 28.0, + "outer_radius_mm": 40.0, + "half_angle_deg": 2.864789 + }, + "layout": { + "type": "angular", + "count": 24, + "start_angle_deg": 7.5, + "orientation": "radial" + } + } + ] + } + } + ] + }, + "meta": { + "source": "cylinder_007_cylinder_07_sector_double_ring_sector36.solidworks_evidence_v2.json", + "parameterized_sketches": 2, + "profile_from_sketches": 0, + "notes": [ + "CDSL-only drawable: repeated source sketches are represented by one procedural motif and layout", + "No compiler_context, source entities, per-instance coordinates, or encoded geometry" + ] + } +} diff --git a/backend/cdsl_library/samples/cylinder_008_cylinder_08_slot_ring_array_slot24/model.cdsl.json b/backend/cdsl_library/samples/cylinder_008_cylinder_08_slot_ring_array_slot24/model.cdsl.json new file mode 100644 index 00000000..ae858396 --- /dev/null +++ b/backend/cdsl_library/samples/cylinder_008_cylinder_08_slot_ring_array_slot24/model.cdsl.json @@ -0,0 +1,81 @@ +{ + "schema": "cad.cdsl.llm.v1", + "schema_version": "1.0.0", + "kind": "part", + "part_id": "cylinder_008_cylinder_08_slot_ring_array_slot24", + "features": [ + { + "id": "f01", + "atomic_id": "extrude_add_blind", + "depends_on": [], + "name": "凸台-拉伸1", + "params": { + "distance_mm": 20.0 + }, + "sketch_id": "sketch_001" + }, + { + "id": "f02", + "atomic_id": "extrude_cut_blind", + "depends_on": ["f01"], + "name": "程序化图案切除", + "params": { + "distance_mm": 8.0, + "reverse": true + }, + "sketch_id": "sketch_002" + } + ], + "geometry": { + "sketches": [ + { + "id": "sketch_001", + "name": "草图1", + "workplane": { + "origin_mm": [0.0, 0.0, 0.0], + "x_dir": [1.0, 0.0, 0.0], + "y_dir": [0.0, 1.0, 0.0], + "normal": [0.0, 0.0, 1.0] + }, + "profile": { + "type": "circle", + "center": [0.0, 0.0], + "radius_mm": 50.0 + } + }, + { + "id": "sketch_002", + "name": "图案草图", + "workplane": { + "origin_mm": [0.0, 0.0, 20.0], + "x_dir": [1.0, 0.0, 0.0], + "y_dir": [0.0, 1.0, 0.0], + "normal": [0.0, 0.0, 1.0] + }, + "profile": { + "type": "patterned_cutouts", + "motif": { + "type": "obround", + "length_mm": 7.0, + "width_mm": 2.8 + }, + "layout": { + "type": "ring", + "radius_mm": 34.0, + "count": 24, + "orientation": "radial" + } + } + } + ] + }, + "meta": { + "source": "cylinder_008_cylinder_08_slot_ring_array_slot24.solidworks_evidence_v2.json", + "parameterized_sketches": 2, + "profile_from_sketches": 0, + "notes": [ + "CDSL-only drawable: repeated source sketches are represented by one procedural motif and layout", + "No compiler_context, source entities, per-instance coordinates, or encoded geometry" + ] + } +} diff --git a/backend/cdsl_library/samples/cylinder_009_cylinder_09_sector_radial_fan_sector12/model.cdsl.json b/backend/cdsl_library/samples/cylinder_009_cylinder_09_sector_radial_fan_sector12/model.cdsl.json new file mode 100644 index 00000000..d231f782 --- /dev/null +++ b/backend/cdsl_library/samples/cylinder_009_cylinder_09_sector_radial_fan_sector12/model.cdsl.json @@ -0,0 +1,82 @@ +{ + "schema": "cad.cdsl.llm.v1", + "schema_version": "1.0.0", + "kind": "part", + "part_id": "cylinder_009_cylinder_09_sector_radial_fan_sector12", + "features": [ + { + "id": "f01", + "atomic_id": "extrude_add_blind", + "depends_on": [], + "name": "凸台-拉伸1", + "params": { + "distance_mm": 20.0 + }, + "sketch_id": "sketch_001" + }, + { + "id": "f02", + "atomic_id": "extrude_cut_blind", + "depends_on": ["f01"], + "name": "程序化图案切除", + "params": { + "distance_mm": 8.0, + "reverse": true + }, + "sketch_id": "sketch_002" + } + ], + "geometry": { + "sketches": [ + { + "id": "sketch_001", + "name": "草图1", + "workplane": { + "origin_mm": [0.0, 0.0, 0.0], + "x_dir": [1.0, 0.0, 0.0], + "y_dir": [0.0, 1.0, 0.0], + "normal": [0.0, 0.0, 1.0] + }, + "profile": { + "type": "circle", + "center": [0.0, 0.0], + "radius_mm": 50.0 + } + }, + { + "id": "sketch_002", + "name": "图案草图", + "workplane": { + "origin_mm": [0.0, 0.0, 20.0], + "x_dir": [1.0, 0.0, 0.0], + "y_dir": [0.0, 1.0, 0.0], + "normal": [0.0, 0.0, 1.0] + }, + "profile": { + "type": "patterned_cutouts", + "motif": { + "type": "annular_sector_polygon", + "inner_radius_mm": 10.0, + "outer_radius_mm": 42.0, + "half_angle_deg": 3.437747 + }, + "layout": { + "type": "angular", + "count": 12, + "start_angle_deg": 15.0, + "orientation": "radial" + } + } + } + ] + }, + "meta": { + "source": "cylinder_009_cylinder_09_sector_radial_fan_sector12.solidworks_evidence_v2.json", + "parameterized_sketches": 2, + "profile_from_sketches": 0, + "notes": [ + "CDSL-only drawable: repeated source sketches are represented by one procedural motif and layout", + "No compiler_context, source entities, per-instance coordinates, or encoded geometry" + ] + } +} diff --git a/backend/cdsl_library/samples/cylinder_010_cylinder_10_circle_scaled_double_ring_circle36/model.cdsl.json b/backend/cdsl_library/samples/cylinder_010_cylinder_10_circle_scaled_double_ring_circle36/model.cdsl.json new file mode 100644 index 00000000..638f1386 --- /dev/null +++ b/backend/cdsl_library/samples/cylinder_010_cylinder_10_circle_scaled_double_ring_circle36/model.cdsl.json @@ -0,0 +1,89 @@ +{ + "schema": "cad.cdsl.llm.v1", + "schema_version": "1.0.0", + "kind": "part", + "part_id": "cylinder_010_cylinder_10_circle_scaled_double_ring_circle36", + "features": [ + { + "id": "f01", + "atomic_id": "extrude_add_blind", + "depends_on": [], + "name": "凸台-拉伸1", + "params": { + "distance_mm": 20.0 + }, + "sketch_id": "sketch_001" + }, + { + "id": "f02", + "atomic_id": "extrude_cut_blind", + "depends_on": ["f01"], + "name": "程序化图案切除", + "params": { + "distance_mm": 8.0, + "reverse": true + }, + "sketch_id": "sketch_002" + } + ], + "geometry": { + "sketches": [ + { + "id": "sketch_001", + "name": "草图1", + "workplane": { + "origin_mm": [0.0, 0.0, 0.0], + "x_dir": [1.0, 0.0, 0.0], + "y_dir": [0.0, 1.0, 0.0], + "normal": [0.0, 0.0, 1.0] + }, + "profile": { + "type": "circle", + "center": [0.0, 0.0], + "radius_mm": 50.0 + } + }, + { + "id": "sketch_002", + "name": "图案草图", + "workplane": { + "origin_mm": [0.0, 0.0, 20.0], + "x_dir": [1.0, 0.0, 0.0], + "y_dir": [0.0, 1.0, 0.0], + "normal": [0.0, 0.0, 1.0] + }, + "profile": { + "type": "patterned_cutouts", + "motif": { + "type": "circle", + "radius_mm": 2.3 + }, + "layout": { + "type": "concentric_rings", + "rings": [ + { + "radius_mm": 18.0, + "count": 12, + "start_angle_deg": 0.0 + }, + { + "radius_mm": 36.0, + "count": 24, + "start_angle_deg": 0.0 + } + ] + } + } + } + ] + }, + "meta": { + "source": "cylinder_010_cylinder_10_circle_scaled_double_ring_circle36.solidworks_evidence_v2.json", + "parameterized_sketches": 2, + "profile_from_sketches": 0, + "notes": [ + "CDSL-only drawable: repeated source sketches are represented by one procedural motif and layout", + "No compiler_context, source entities, per-instance coordinates, or encoded geometry" + ] + } +} diff --git a/backend/cdsl_library/samples/cylinder_011_cylinder_11_sector_scaled_ring_v01_sector8/model.cdsl.json b/backend/cdsl_library/samples/cylinder_011_cylinder_11_sector_scaled_ring_v01_sector8/model.cdsl.json new file mode 100644 index 00000000..59bbd82b --- /dev/null +++ b/backend/cdsl_library/samples/cylinder_011_cylinder_11_sector_scaled_ring_v01_sector8/model.cdsl.json @@ -0,0 +1,82 @@ +{ + "schema": "cad.cdsl.llm.v1", + "schema_version": "1.0.0", + "kind": "part", + "part_id": "cylinder_011_cylinder_11_sector_scaled_ring_v01_sector8", + "features": [ + { + "id": "f01", + "atomic_id": "extrude_add_blind", + "depends_on": [], + "name": "凸台-拉伸1", + "params": { + "distance_mm": 20.0 + }, + "sketch_id": "sketch_001" + }, + { + "id": "f02", + "atomic_id": "extrude_cut_blind", + "depends_on": ["f01"], + "name": "程序化图案切除", + "params": { + "distance_mm": 8.0, + "reverse": true + }, + "sketch_id": "sketch_002" + } + ], + "geometry": { + "sketches": [ + { + "id": "sketch_001", + "name": "草图1", + "workplane": { + "origin_mm": [0.0, 0.0, 0.0], + "x_dir": [1.0, 0.0, 0.0], + "y_dir": [0.0, 1.0, 0.0], + "normal": [0.0, 0.0, 1.0] + }, + "profile": { + "type": "circle", + "center": [0.0, 0.0], + "radius_mm": 50.0 + } + }, + { + "id": "sketch_002", + "name": "图案草图", + "workplane": { + "origin_mm": [0.0, 0.0, 20.0], + "x_dir": [1.0, 0.0, 0.0], + "y_dir": [0.0, 1.0, 0.0], + "normal": [0.0, 0.0, 1.0] + }, + "profile": { + "type": "patterned_cutouts", + "motif": { + "type": "annular_sector_polygon", + "inner_radius_mm": 10.0, + "outer_radius_mm": 20.0, + "half_angle_deg": 4.010705 + }, + "layout": { + "type": "angular", + "count": 8, + "start_angle_deg": 22.5, + "orientation": "radial" + } + } + } + ] + }, + "meta": { + "source": "cylinder_011_cylinder_11_sector_scaled_ring_v01_sector8.solidworks_evidence_v2.json", + "parameterized_sketches": 2, + "profile_from_sketches": 0, + "notes": [ + "CDSL-only drawable: repeated source sketches are represented by one procedural motif and layout", + "No compiler_context, source entities, per-instance coordinates, or encoded geometry" + ] + } +} diff --git a/backend/cdsl_library/samples/cylinder_012_cylinder_12_circle_ring_array_v01_circle24/model.cdsl.json b/backend/cdsl_library/samples/cylinder_012_cylinder_12_circle_ring_array_v01_circle24/model.cdsl.json new file mode 100644 index 00000000..7b4ad7ff --- /dev/null +++ b/backend/cdsl_library/samples/cylinder_012_cylinder_12_circle_ring_array_v01_circle24/model.cdsl.json @@ -0,0 +1,79 @@ +{ + "schema": "cad.cdsl.llm.v1", + "schema_version": "1.0.0", + "kind": "part", + "part_id": "cylinder_012_cylinder_12_circle_ring_array_v01_circle24", + "features": [ + { + "id": "f01", + "atomic_id": "extrude_add_blind", + "depends_on": [], + "name": "凸台-拉伸1", + "params": { + "distance_mm": 20.0 + }, + "sketch_id": "sketch_001" + }, + { + "id": "f02", + "atomic_id": "extrude_cut_blind", + "depends_on": ["f01"], + "name": "程序化图案切除", + "params": { + "distance_mm": 8.0, + "reverse": true + }, + "sketch_id": "sketch_002" + } + ], + "geometry": { + "sketches": [ + { + "id": "sketch_001", + "name": "草图1", + "workplane": { + "origin_mm": [0.0, 0.0, 0.0], + "x_dir": [1.0, 0.0, 0.0], + "y_dir": [0.0, 1.0, 0.0], + "normal": [0.0, 0.0, 1.0] + }, + "profile": { + "type": "circle", + "center": [0.0, 0.0], + "radius_mm": 50.0 + } + }, + { + "id": "sketch_002", + "name": "图案草图", + "workplane": { + "origin_mm": [0.0, 0.0, 20.0], + "x_dir": [1.0, 0.0, 0.0], + "y_dir": [0.0, 1.0, 0.0], + "normal": [0.0, 0.0, 1.0] + }, + "profile": { + "type": "patterned_cutouts", + "motif": { + "type": "circle", + "radius_mm": 1.9 + }, + "layout": { + "type": "ring", + "radius_mm": 26.0, + "count": 24 + } + } + } + ] + }, + "meta": { + "source": "cylinder_012_cylinder_12_circle_ring_array_v01_circle24.solidworks_evidence_v2.json", + "parameterized_sketches": 2, + "profile_from_sketches": 0, + "notes": [ + "CDSL-only drawable: repeated source sketches are represented by one procedural motif and layout", + "No compiler_context, source entities, per-instance coordinates, or encoded geometry" + ] + } +} diff --git a/backend/cdsl_library/samples/cylinder_013_cylinder_13_square_ring_array_v01_rectangle16/model.cdsl.json b/backend/cdsl_library/samples/cylinder_013_cylinder_13_square_ring_array_v01_rectangle16/model.cdsl.json new file mode 100644 index 00000000..cc44e36a --- /dev/null +++ b/backend/cdsl_library/samples/cylinder_013_cylinder_13_square_ring_array_v01_rectangle16/model.cdsl.json @@ -0,0 +1,79 @@ +{ + "schema": "cad.cdsl.llm.v1", + "schema_version": "1.0.0", + "kind": "part", + "part_id": "cylinder_013_cylinder_13_square_ring_array_v01_rectangle16", + "features": [ + { + "id": "f01", + "atomic_id": "extrude_add_blind", + "depends_on": [], + "name": "凸台-拉伸1", + "params": { + "distance_mm": 20.0 + }, + "sketch_id": "sketch_001" + }, + { + "id": "f02", + "atomic_id": "extrude_cut_blind", + "depends_on": ["f01"], + "name": "程序化图案切除", + "params": { + "distance_mm": 8.0, + "reverse": true + }, + "sketch_id": "sketch_002" + } + ], + "geometry": { + "sketches": [ + { + "id": "sketch_001", + "name": "草图1", + "workplane": { + "origin_mm": [0.0, 0.0, 0.0], + "x_dir": [1.0, 0.0, 0.0], + "y_dir": [0.0, 1.0, 0.0], + "normal": [0.0, 0.0, 1.0] + }, + "profile": { + "type": "circle", + "center": [0.0, 0.0], + "radius_mm": 50.0 + } + }, + { + "id": "sketch_002", + "name": "图案草图", + "workplane": { + "origin_mm": [0.0, 0.0, 20.0], + "x_dir": [1.0, 0.0, 0.0], + "y_dir": [0.0, 1.0, 0.0], + "normal": [0.0, 0.0, 1.0] + }, + "profile": { + "type": "patterned_cutouts", + "motif": { + "type": "square", + "width_mm": 2.7 + }, + "layout": { + "type": "ring", + "radius_mm": 25.0, + "count": 16 + } + } + } + ] + }, + "meta": { + "source": "cylinder_013_cylinder_13_square_ring_array_v01_rectangle16.solidworks_evidence_v2.json", + "parameterized_sketches": 2, + "profile_from_sketches": 0, + "notes": [ + "CDSL-only drawable: repeated source sketches are represented by one procedural motif and layout", + "No compiler_context, source entities, per-instance coordinates, or encoded geometry" + ] + } +} diff --git a/backend/cdsl_library/samples/cylinder_014_cylinder_14_diamond_ring_array_v01_polygon16/model.cdsl.json b/backend/cdsl_library/samples/cylinder_014_cylinder_14_diamond_ring_array_v01_polygon16/model.cdsl.json new file mode 100644 index 00000000..88cbcbba --- /dev/null +++ b/backend/cdsl_library/samples/cylinder_014_cylinder_14_diamond_ring_array_v01_polygon16/model.cdsl.json @@ -0,0 +1,79 @@ +{ + "schema": "cad.cdsl.llm.v1", + "schema_version": "1.0.0", + "kind": "part", + "part_id": "cylinder_014_cylinder_14_diamond_ring_array_v01_polygon16", + "features": [ + { + "id": "f01", + "atomic_id": "extrude_add_blind", + "depends_on": [], + "name": "凸台-拉伸1", + "params": { + "distance_mm": 20.0 + }, + "sketch_id": "sketch_001" + }, + { + "id": "f02", + "atomic_id": "extrude_cut_blind", + "depends_on": ["f01"], + "name": "程序化图案切除", + "params": { + "distance_mm": 8.0, + "reverse": true + }, + "sketch_id": "sketch_002" + } + ], + "geometry": { + "sketches": [ + { + "id": "sketch_001", + "name": "草图1", + "workplane": { + "origin_mm": [0.0, 0.0, 0.0], + "x_dir": [1.0, 0.0, 0.0], + "y_dir": [0.0, 1.0, 0.0], + "normal": [0.0, 0.0, 1.0] + }, + "profile": { + "type": "circle", + "center": [0.0, 0.0], + "radius_mm": 50.0 + } + }, + { + "id": "sketch_002", + "name": "图案草图", + "workplane": { + "origin_mm": [0.0, 0.0, 20.0], + "x_dir": [1.0, 0.0, 0.0], + "y_dir": [0.0, 1.0, 0.0], + "normal": [0.0, 0.0, 1.0] + }, + "profile": { + "type": "patterned_cutouts", + "motif": { + "type": "square", + "width_mm": 3.535534 + }, + "layout": { + "type": "ring", + "radius_mm": 25.0, + "count": 16 + } + } + } + ] + }, + "meta": { + "source": "cylinder_014_cylinder_14_diamond_ring_array_v01_polygon16.solidworks_evidence_v2.json", + "parameterized_sketches": 2, + "profile_from_sketches": 0, + "notes": [ + "CDSL-only drawable: repeated source sketches are represented by one procedural motif and layout", + "No compiler_context, source entities, per-instance coordinates, or encoded geometry" + ] + } +} diff --git a/backend/cdsl_library/samples/cylinder_015_cylinder_15_circle_grid_in_disc_v01_circle30/model.cdsl.json b/backend/cdsl_library/samples/cylinder_015_cylinder_15_circle_grid_in_disc_v01_circle30/model.cdsl.json new file mode 100644 index 00000000..421355c4 --- /dev/null +++ b/backend/cdsl_library/samples/cylinder_015_cylinder_15_circle_grid_in_disc_v01_circle30/model.cdsl.json @@ -0,0 +1,82 @@ +{ + "schema": "cad.cdsl.llm.v1", + "schema_version": "1.0.0", + "kind": "part", + "part_id": "cylinder_015_cylinder_15_circle_grid_in_disc_v01_circle30", + "features": [ + { + "id": "f01", + "atomic_id": "extrude_add_blind", + "depends_on": [], + "name": "凸台-拉伸1", + "params": { + "distance_mm": 20.0 + }, + "sketch_id": "sketch_001" + }, + { + "id": "f02", + "atomic_id": "extrude_cut_blind", + "depends_on": ["f01"], + "name": "程序化图案切除", + "params": { + "distance_mm": 8.0, + "reverse": true + }, + "sketch_id": "sketch_002" + } + ], + "geometry": { + "sketches": [ + { + "id": "sketch_001", + "name": "草图1", + "workplane": { + "origin_mm": [0.0, 0.0, 0.0], + "x_dir": [1.0, 0.0, 0.0], + "y_dir": [0.0, 1.0, 0.0], + "normal": [0.0, 0.0, 1.0] + }, + "profile": { + "type": "circle", + "center": [0.0, 0.0], + "radius_mm": 50.0 + } + }, + { + "id": "sketch_002", + "name": "图案草图", + "workplane": { + "origin_mm": [0.0, 0.0, 20.0], + "x_dir": [1.0, 0.0, 0.0], + "y_dir": [0.0, 1.0, 0.0], + "normal": [0.0, 0.0, 1.0] + }, + "profile": { + "type": "patterned_cutouts", + "motif": { + "type": "circle", + "radius_mm": 1.8 + }, + "layout": { + "type": "disc_grid", + "count_x": 6, + "count_y": 5, + "spacing_x_mm": 9.0, + "spacing_y_mm": 9.0, + "center_mm": [0.0, 0.0] + } + } + } + ] + }, + "meta": { + "source": "cylinder_015_cylinder_15_circle_grid_in_disc_v01_circle30.solidworks_evidence_v2.json", + "parameterized_sketches": 2, + "profile_from_sketches": 0, + "notes": [ + "CDSL-only drawable: repeated source sketches are represented by one procedural motif and layout", + "No compiler_context, source entities, per-instance coordinates, or encoded geometry" + ] + } +} diff --git a/backend/cdsl_library/samples/cylinder_016_cylinder_16_dshape_open_arc_v01_dshape18/model.cdsl.json b/backend/cdsl_library/samples/cylinder_016_cylinder_16_dshape_open_arc_v01_dshape18/model.cdsl.json new file mode 100644 index 00000000..ab1ea126 --- /dev/null +++ b/backend/cdsl_library/samples/cylinder_016_cylinder_16_dshape_open_arc_v01_dshape18/model.cdsl.json @@ -0,0 +1,83 @@ +{ + "schema": "cad.cdsl.llm.v1", + "schema_version": "1.0.0", + "kind": "part", + "part_id": "cylinder_016_cylinder_16_dshape_open_arc_v01_dshape18", + "features": [ + { + "id": "f01", + "atomic_id": "extrude_add_blind", + "depends_on": [], + "name": "凸台-拉伸1", + "params": { + "distance_mm": 20.0 + }, + "sketch_id": "sketch_001" + }, + { + "id": "f02", + "atomic_id": "extrude_cut_blind", + "depends_on": ["f01"], + "name": "程序化图案切除", + "params": { + "distance_mm": 8.0, + "reverse": true + }, + "sketch_id": "sketch_002" + } + ], + "geometry": { + "sketches": [ + { + "id": "sketch_001", + "name": "草图1", + "workplane": { + "origin_mm": [0.0, 0.0, 0.0], + "x_dir": [1.0, 0.0, 0.0], + "y_dir": [0.0, 1.0, 0.0], + "normal": [0.0, 0.0, 1.0] + }, + "profile": { + "type": "circle", + "center": [0.0, 0.0], + "radius_mm": 50.0 + } + }, + { + "id": "sketch_002", + "name": "图案草图", + "workplane": { + "origin_mm": [0.0, 0.0, 20.0], + "x_dir": [1.0, 0.0, 0.0], + "y_dir": [0.0, 1.0, 0.0], + "normal": [0.0, 0.0, 1.0] + }, + "profile": { + "type": "patterned_cutouts", + "motif": { + "type": "d_shape_polygon", + "stem_length_mm": 1.9, + "nose_depth_mm": 1.9, + "half_height_mm": 1.3775 + }, + "layout": { + "type": "open_arc", + "radius_mm": 35.0, + "count": 18, + "start_angle_deg": 18.0, + "end_angle_deg": 262.8 + } + } + } + ] + }, + "meta": { + "source": "cylinder_016_cylinder_16_dshape_open_arc_v01_dshape18.solidworks_evidence_v2.json", + "parameterized_sketches": 2, + "profile_from_sketches": 0, + "notes": [ + "CDSL-only drawable: repeated source sketches are represented by one procedural motif and layout", + "No compiler_context, source entities, per-instance coordinates, or encoded geometry" + ] + } +} diff --git a/backend/cdsl_library/samples/cylinder_017_cylinder_17_cross_open_arc_v01_cross14/model.cdsl.json b/backend/cdsl_library/samples/cylinder_017_cylinder_17_cross_open_arc_v01_cross14/model.cdsl.json new file mode 100644 index 00000000..a3d64a85 --- /dev/null +++ b/backend/cdsl_library/samples/cylinder_017_cylinder_17_cross_open_arc_v01_cross14/model.cdsl.json @@ -0,0 +1,82 @@ +{ + "schema": "cad.cdsl.llm.v1", + "schema_version": "1.0.0", + "kind": "part", + "part_id": "cylinder_017_cylinder_17_cross_open_arc_v01_cross14", + "features": [ + { + "id": "f01", + "atomic_id": "extrude_add_blind", + "depends_on": [], + "name": "凸台-拉伸1", + "params": { + "distance_mm": 20.0 + }, + "sketch_id": "sketch_001" + }, + { + "id": "f02", + "atomic_id": "extrude_cut_blind", + "depends_on": ["f01"], + "name": "程序化图案切除", + "params": { + "distance_mm": 8.0, + "reverse": true + }, + "sketch_id": "sketch_002" + } + ], + "geometry": { + "sketches": [ + { + "id": "sketch_001", + "name": "草图1", + "workplane": { + "origin_mm": [0.0, 0.0, 0.0], + "x_dir": [1.0, 0.0, 0.0], + "y_dir": [0.0, 1.0, 0.0], + "normal": [0.0, 0.0, 1.0] + }, + "profile": { + "type": "circle", + "center": [0.0, 0.0], + "radius_mm": 50.0 + } + }, + { + "id": "sketch_002", + "name": "图案草图", + "workplane": { + "origin_mm": [0.0, 0.0, 20.0], + "x_dir": [1.0, 0.0, 0.0], + "y_dir": [0.0, 1.0, 0.0], + "normal": [0.0, 0.0, 1.0] + }, + "profile": { + "type": "patterned_cutouts", + "motif": { + "type": "cross", + "size_mm": 4.62, + "arm_width_mm": 2.9568 + }, + "layout": { + "type": "open_arc", + "radius_mm": 31.0, + "count": 14, + "start_angle_deg": 189.0, + "end_angle_deg": 387.0 + } + } + } + ] + }, + "meta": { + "source": "cylinder_017_cylinder_17_cross_open_arc_v01_cross14.solidworks_evidence_v2.json", + "parameterized_sketches": 2, + "profile_from_sketches": 0, + "notes": [ + "CDSL-only drawable: repeated source sketches are represented by one procedural motif and layout", + "No compiler_context, source entities, per-instance coordinates, or encoded geometry" + ] + } +} diff --git a/backend/cdsl_library/samples/cylinder_018_cylinder_18_slot_ring_array_v01_slot16/model.cdsl.json b/backend/cdsl_library/samples/cylinder_018_cylinder_18_slot_ring_array_v01_slot16/model.cdsl.json new file mode 100644 index 00000000..c0ae05ea --- /dev/null +++ b/backend/cdsl_library/samples/cylinder_018_cylinder_18_slot_ring_array_v01_slot16/model.cdsl.json @@ -0,0 +1,81 @@ +{ + "schema": "cad.cdsl.llm.v1", + "schema_version": "1.0.0", + "kind": "part", + "part_id": "cylinder_018_cylinder_18_slot_ring_array_v01_slot16", + "features": [ + { + "id": "f01", + "atomic_id": "extrude_add_blind", + "depends_on": [], + "name": "凸台-拉伸1", + "params": { + "distance_mm": 20.0 + }, + "sketch_id": "sketch_001" + }, + { + "id": "f02", + "atomic_id": "extrude_cut_blind", + "depends_on": ["f01"], + "name": "程序化图案切除", + "params": { + "distance_mm": 8.0, + "reverse": true + }, + "sketch_id": "sketch_002" + } + ], + "geometry": { + "sketches": [ + { + "id": "sketch_001", + "name": "草图1", + "workplane": { + "origin_mm": [0.0, 0.0, 0.0], + "x_dir": [1.0, 0.0, 0.0], + "y_dir": [0.0, 1.0, 0.0], + "normal": [0.0, 0.0, 1.0] + }, + "profile": { + "type": "circle", + "center": [0.0, 0.0], + "radius_mm": 50.0 + } + }, + { + "id": "sketch_002", + "name": "图案草图", + "workplane": { + "origin_mm": [0.0, 0.0, 20.0], + "x_dir": [1.0, 0.0, 0.0], + "y_dir": [0.0, 1.0, 0.0], + "normal": [0.0, 0.0, 1.0] + }, + "profile": { + "type": "patterned_cutouts", + "motif": { + "type": "obround", + "length_mm": 5.75, + "width_mm": 2.3 + }, + "layout": { + "type": "ring", + "radius_mm": 27.0, + "count": 16, + "orientation": "radial" + } + } + } + ] + }, + "meta": { + "source": "cylinder_018_cylinder_18_slot_ring_array_v01_slot16.solidworks_evidence_v2.json", + "parameterized_sketches": 2, + "profile_from_sketches": 0, + "notes": [ + "CDSL-only drawable: repeated source sketches are represented by one procedural motif and layout", + "No compiler_context, source entities, per-instance coordinates, or encoded geometry" + ] + } +} diff --git a/backend/cdsl_library/samples/cylinder_019_cylinder_19_circle_scaled_double_ring_v01_circle24/model.cdsl.json b/backend/cdsl_library/samples/cylinder_019_cylinder_19_circle_scaled_double_ring_v01_circle24/model.cdsl.json new file mode 100644 index 00000000..855360b3 --- /dev/null +++ b/backend/cdsl_library/samples/cylinder_019_cylinder_19_circle_scaled_double_ring_v01_circle24/model.cdsl.json @@ -0,0 +1,89 @@ +{ + "schema": "cad.cdsl.llm.v1", + "schema_version": "1.0.0", + "kind": "part", + "part_id": "cylinder_019_cylinder_19_circle_scaled_double_ring_v01_circle24", + "features": [ + { + "id": "f01", + "atomic_id": "extrude_add_blind", + "depends_on": [], + "name": "凸台-拉伸1", + "params": { + "distance_mm": 20.0 + }, + "sketch_id": "sketch_001" + }, + { + "id": "f02", + "atomic_id": "extrude_cut_blind", + "depends_on": ["f01"], + "name": "程序化图案切除", + "params": { + "distance_mm": 8.0, + "reverse": true + }, + "sketch_id": "sketch_002" + } + ], + "geometry": { + "sketches": [ + { + "id": "sketch_001", + "name": "草图1", + "workplane": { + "origin_mm": [0.0, 0.0, 0.0], + "x_dir": [1.0, 0.0, 0.0], + "y_dir": [0.0, 1.0, 0.0], + "normal": [0.0, 0.0, 1.0] + }, + "profile": { + "type": "circle", + "center": [0.0, 0.0], + "radius_mm": 50.0 + } + }, + { + "id": "sketch_002", + "name": "图案草图", + "workplane": { + "origin_mm": [0.0, 0.0, 20.0], + "x_dir": [1.0, 0.0, 0.0], + "y_dir": [0.0, 1.0, 0.0], + "normal": [0.0, 0.0, 1.0] + }, + "profile": { + "type": "patterned_cutouts", + "motif": { + "type": "circle", + "radius_mm": 1.8 + }, + "layout": { + "type": "concentric_rings", + "rings": [ + { + "radius_mm": 14.0, + "count": 8, + "start_angle_deg": 0.0 + }, + { + "radius_mm": 30.0, + "count": 16, + "start_angle_deg": 0.0 + } + ] + } + } + } + ] + }, + "meta": { + "source": "cylinder_019_cylinder_19_circle_scaled_double_ring_v01_circle24.solidworks_evidence_v2.json", + "parameterized_sketches": 2, + "profile_from_sketches": 0, + "notes": [ + "CDSL-only drawable: repeated source sketches are represented by one procedural motif and layout", + "No compiler_context, source entities, per-instance coordinates, or encoded geometry" + ] + } +} diff --git a/backend/cdsl_library/samples/cylinder_020_cylinder_20_sector_radial_fan_v01_sector10/model.cdsl.json b/backend/cdsl_library/samples/cylinder_020_cylinder_20_sector_radial_fan_v01_sector10/model.cdsl.json new file mode 100644 index 00000000..591e1682 --- /dev/null +++ b/backend/cdsl_library/samples/cylinder_020_cylinder_20_sector_radial_fan_v01_sector10/model.cdsl.json @@ -0,0 +1,82 @@ +{ + "schema": "cad.cdsl.llm.v1", + "schema_version": "1.0.0", + "kind": "part", + "part_id": "cylinder_020_cylinder_20_sector_radial_fan_v01_sector10", + "features": [ + { + "id": "f01", + "atomic_id": "extrude_add_blind", + "depends_on": [], + "name": "凸台-拉伸1", + "params": { + "distance_mm": 20.0 + }, + "sketch_id": "sketch_001" + }, + { + "id": "f02", + "atomic_id": "extrude_cut_blind", + "depends_on": ["f01"], + "name": "程序化图案切除", + "params": { + "distance_mm": 8.0, + "reverse": true + }, + "sketch_id": "sketch_002" + } + ], + "geometry": { + "sketches": [ + { + "id": "sketch_001", + "name": "草图1", + "workplane": { + "origin_mm": [0.0, 0.0, 0.0], + "x_dir": [1.0, 0.0, 0.0], + "y_dir": [0.0, 1.0, 0.0], + "normal": [0.0, 0.0, 1.0] + }, + "profile": { + "type": "circle", + "center": [0.0, 0.0], + "radius_mm": 50.0 + } + }, + { + "id": "sketch_002", + "name": "图案草图", + "workplane": { + "origin_mm": [0.0, 0.0, 20.0], + "x_dir": [1.0, 0.0, 0.0], + "y_dir": [0.0, 1.0, 0.0], + "normal": [0.0, 0.0, 1.0] + }, + "profile": { + "type": "patterned_cutouts", + "motif": { + "type": "annular_sector_polygon", + "inner_radius_mm": 9.0, + "outer_radius_mm": 36.0, + "half_angle_deg": 3.151268 + }, + "layout": { + "type": "angular", + "count": 10, + "start_angle_deg": 18.0, + "orientation": "radial" + } + } + } + ] + }, + "meta": { + "source": "cylinder_020_cylinder_20_sector_radial_fan_v01_sector10.solidworks_evidence_v2.json", + "parameterized_sketches": 2, + "profile_from_sketches": 0, + "notes": [ + "CDSL-only drawable: repeated source sketches are represented by one procedural motif and layout", + "No compiler_context, source entities, per-instance coordinates, or encoded geometry" + ] + } +} diff --git a/backend/cdsl_library/samples/cylinder_021_cylinder_21_sector_double_ring_v01_sector24/model.cdsl.json b/backend/cdsl_library/samples/cylinder_021_cylinder_21_sector_double_ring_v01_sector24/model.cdsl.json new file mode 100644 index 00000000..281b46a3 --- /dev/null +++ b/backend/cdsl_library/samples/cylinder_021_cylinder_21_sector_double_ring_v01_sector24/model.cdsl.json @@ -0,0 +1,100 @@ +{ + "schema": "cad.cdsl.llm.v1", + "schema_version": "1.0.0", + "kind": "part", + "part_id": "cylinder_021_cylinder_21_sector_double_ring_v01_sector24", + "features": [ + { + "id": "f01", + "atomic_id": "extrude_add_blind", + "depends_on": [], + "name": "凸台-拉伸1", + "params": { + "distance_mm": 20.0 + }, + "sketch_id": "sketch_001" + }, + { + "id": "f02", + "atomic_id": "extrude_cut_blind", + "depends_on": ["f01"], + "name": "程序化图案切除", + "params": { + "distance_mm": 8.0, + "reverse": true + }, + "sketch_id": "sketch_002" + } + ], + "geometry": { + "sketches": [ + { + "id": "sketch_001", + "name": "草图1", + "workplane": { + "origin_mm": [0.0, 0.0, 0.0], + "x_dir": [1.0, 0.0, 0.0], + "y_dir": [0.0, 1.0, 0.0], + "normal": [0.0, 0.0, 1.0] + }, + "profile": { + "type": "circle", + "center": [0.0, 0.0], + "radius_mm": 50.0 + } + }, + { + "id": "sketch_002", + "name": "双环图案草图", + "workplane": { + "origin_mm": [0.0, 0.0, 20.0], + "x_dir": [1.0, 0.0, 0.0], + "y_dir": [0.0, 1.0, 0.0], + "normal": [0.0, 0.0, 1.0] + }, + "profile": { + "type": "compound_patterned_cutouts", + "patterns": [ + { + "motif": { + "type": "annular_sector_polygon", + "inner_radius_mm": 12.0, + "outer_radius_mm": 20.0, + "half_angle_deg": 4.010705 + }, + "layout": { + "type": "angular", + "count": 8, + "start_angle_deg": 22.5, + "orientation": "radial" + } + }, + { + "motif": { + "type": "annular_sector_polygon", + "inner_radius_mm": 28.0, + "outer_radius_mm": 39.0, + "half_angle_deg": 2.57831 + }, + "layout": { + "type": "angular", + "count": 16, + "start_angle_deg": 11.25, + "orientation": "radial" + } + } + ] + } + } + ] + }, + "meta": { + "source": "cylinder_021_cylinder_21_sector_double_ring_v01_sector24.solidworks_evidence_v2.json", + "parameterized_sketches": 2, + "profile_from_sketches": 0, + "notes": [ + "CDSL-only drawable: repeated source sketches are represented by one procedural motif and layout", + "No compiler_context, source entities, per-instance coordinates, or encoded geometry" + ] + } +} diff --git a/backend/cdsl_library/samples/cylinder_022_cylinder_22_hexagon_spiral_v01_polygon28/model.cdsl.json b/backend/cdsl_library/samples/cylinder_022_cylinder_22_hexagon_spiral_v01_polygon28/model.cdsl.json new file mode 100644 index 00000000..e2fb4766 --- /dev/null +++ b/backend/cdsl_library/samples/cylinder_022_cylinder_22_hexagon_spiral_v01_polygon28/model.cdsl.json @@ -0,0 +1,85 @@ +{ + "schema": "cad.cdsl.llm.v1", + "schema_version": "1.0.0", + "kind": "part", + "part_id": "cylinder_022_cylinder_22_hexagon_spiral_v01_polygon28", + "features": [ + { + "id": "f01", + "atomic_id": "extrude_add_blind", + "depends_on": [], + "name": "凸台-拉伸1", + "params": { + "distance_mm": 20.0 + }, + "sketch_id": "sketch_001" + }, + { + "id": "f02", + "atomic_id": "extrude_cut_blind", + "depends_on": ["f01"], + "name": "程序化图案切除", + "params": { + "distance_mm": 8.0, + "reverse": true + }, + "sketch_id": "sketch_002" + } + ], + "geometry": { + "sketches": [ + { + "id": "sketch_001", + "name": "草图1", + "workplane": { + "origin_mm": [0.0, 0.0, 0.0], + "x_dir": [1.0, 0.0, 0.0], + "y_dir": [0.0, 1.0, 0.0], + "normal": [0.0, 0.0, 1.0] + }, + "profile": { + "type": "circle", + "center": [0.0, 0.0], + "radius_mm": 50.0 + } + }, + { + "id": "sketch_002", + "name": "图案草图", + "workplane": { + "origin_mm": [0.0, 0.0, 20.0], + "x_dir": [1.0, 0.0, 0.0], + "y_dir": [0.0, 1.0, 0.0], + "normal": [0.0, 0.0, 1.0] + }, + "profile": { + "type": "patterned_cutouts", + "motif": { + "type": "skew_hexagon", + "nominal_radius_mm": 1.875 + }, + "layout": { + "type": "spiral", + "count": 28, + "start_radius_mm": 8.054, + "radius_step_mm": 0.851, + "start_angle_deg": -0.105, + "angle_step_deg": 33.2394, + "orientation": "snapped_radial", + "orientation_snap_deg": 45.0, + "orientation_offset_deg": 0.0 + } + } + } + ] + }, + "meta": { + "source": "cylinder_022_cylinder_22_hexagon_spiral_v01_polygon28.solidworks_evidence_v2.json", + "parameterized_sketches": 2, + "profile_from_sketches": 0, + "notes": [ + "CDSL-only drawable: repeated source sketches are represented by one procedural motif and layout", + "No compiler_context, source entities, per-instance coordinates, or encoded geometry" + ] + } +} diff --git a/backend/cdsl_library/samples/cylinder_023_cylinder_23_hexagon_cross_v01_polygon12/model.cdsl.json b/backend/cdsl_library/samples/cylinder_023_cylinder_23_hexagon_cross_v01_polygon12/model.cdsl.json new file mode 100644 index 00000000..f9649ed1 --- /dev/null +++ b/backend/cdsl_library/samples/cylinder_023_cylinder_23_hexagon_cross_v01_polygon12/model.cdsl.json @@ -0,0 +1,80 @@ +{ + "schema": "cad.cdsl.llm.v1", + "schema_version": "1.0.0", + "kind": "part", + "part_id": "cylinder_023_cylinder_23_hexagon_cross_v01_polygon12", + "features": [ + { + "id": "f01", + "atomic_id": "extrude_add_blind", + "depends_on": [], + "name": "凸台-拉伸1", + "params": { + "distance_mm": 20.0 + }, + "sketch_id": "sketch_001" + }, + { + "id": "f02", + "atomic_id": "extrude_cut_blind", + "depends_on": ["f01"], + "name": "程序化图案切除", + "params": { + "distance_mm": 8.0, + "reverse": true + }, + "sketch_id": "sketch_002" + } + ], + "geometry": { + "sketches": [ + { + "id": "sketch_001", + "name": "草图1", + "workplane": { + "origin_mm": [0.0, 0.0, 0.0], + "x_dir": [1.0, 0.0, 0.0], + "y_dir": [0.0, 1.0, 0.0], + "normal": [0.0, 0.0, 1.0] + }, + "profile": { + "type": "circle", + "center": [0.0, 0.0], + "radius_mm": 50.0 + } + }, + { + "id": "sketch_002", + "name": "图案草图", + "workplane": { + "origin_mm": [0.0, 0.0, 20.0], + "x_dir": [1.0, 0.0, 0.0], + "y_dir": [0.0, 1.0, 0.0], + "normal": [0.0, 0.0, 1.0] + }, + "profile": { + "type": "patterned_cutouts", + "motif": { + "type": "skew_hexagon", + "nominal_radius_mm": 1.9 + }, + "layout": { + "type": "cross_lines", + "count_per_axis": 6, + "spacing_mm": 13.6, + "orientation_offset_deg": 0.0 + } + } + } + ] + }, + "meta": { + "source": "cylinder_023_cylinder_23_hexagon_cross_v01_polygon12.solidworks_evidence_v2.json", + "parameterized_sketches": 2, + "profile_from_sketches": 0, + "notes": [ + "CDSL-only drawable: repeated source sketches are represented by one procedural motif and layout", + "No compiler_context, source entities, per-instance coordinates, or encoded geometry" + ] + } +} diff --git a/backend/cdsl_library/samples/cylinder_024_cylinder_24_triangle_x_field_v01_polygon9/model.cdsl.json b/backend/cdsl_library/samples/cylinder_024_cylinder_24_triangle_x_field_v01_polygon9/model.cdsl.json new file mode 100644 index 00000000..919ed45d --- /dev/null +++ b/backend/cdsl_library/samples/cylinder_024_cylinder_24_triangle_x_field_v01_polygon9/model.cdsl.json @@ -0,0 +1,80 @@ +{ + "schema": "cad.cdsl.llm.v1", + "schema_version": "1.0.0", + "kind": "part", + "part_id": "cylinder_024_cylinder_24_triangle_x_field_v01_polygon9", + "features": [ + { + "id": "f01", + "atomic_id": "extrude_add_blind", + "depends_on": [], + "name": "凸台-拉伸1", + "params": { + "distance_mm": 20.0 + }, + "sketch_id": "sketch_001" + }, + { + "id": "f02", + "atomic_id": "extrude_cut_blind", + "depends_on": ["f01"], + "name": "程序化图案切除", + "params": { + "distance_mm": 8.0, + "reverse": true + }, + "sketch_id": "sketch_002" + } + ], + "geometry": { + "sketches": [ + { + "id": "sketch_001", + "name": "草图1", + "workplane": { + "origin_mm": [0.0, 0.0, 0.0], + "x_dir": [1.0, 0.0, 0.0], + "y_dir": [0.0, 1.0, 0.0], + "normal": [0.0, 0.0, 1.0] + }, + "profile": { + "type": "circle", + "center": [0.0, 0.0], + "radius_mm": 50.0 + } + }, + { + "id": "sketch_002", + "name": "图案草图", + "workplane": { + "origin_mm": [0.0, 0.0, 20.0], + "x_dir": [1.0, 0.0, 0.0], + "y_dir": [0.0, 1.0, 0.0], + "normal": [0.0, 0.0, 1.0] + }, + "profile": { + "type": "patterned_cutouts", + "motif": { + "type": "triangle", + "radius_mm": 2.3 + }, + "layout": { + "type": "x_field", + "levels": 5, + "spacing_mm": 11.0, + "orientation": "diagonal_axes" + } + } + } + ] + }, + "meta": { + "source": "cylinder_024_cylinder_24_triangle_x_field_v01_polygon9.solidworks_evidence_v2.json", + "parameterized_sketches": 2, + "profile_from_sketches": 0, + "notes": [ + "CDSL-only drawable: repeated source sketches are represented by one procedural motif and layout", + "No compiler_context, source entities, per-instance coordinates, or encoded geometry" + ] + } +} diff --git a/backend/cdsl_library/samples/cylinder_025_cylinder_25_teardrop_twin_strips_v01_teardrop24/model.cdsl.json b/backend/cdsl_library/samples/cylinder_025_cylinder_25_teardrop_twin_strips_v01_teardrop24/model.cdsl.json new file mode 100644 index 00000000..ef5a0d9b --- /dev/null +++ b/backend/cdsl_library/samples/cylinder_025_cylinder_25_teardrop_twin_strips_v01_teardrop24/model.cdsl.json @@ -0,0 +1,85 @@ +{ + "schema": "cad.cdsl.llm.v1", + "schema_version": "1.0.0", + "kind": "part", + "part_id": "cylinder_025_cylinder_25_teardrop_twin_strips_v01_teardrop24", + "features": [ + { + "id": "f01", + "atomic_id": "extrude_add_blind", + "depends_on": [], + "name": "凸台-拉伸1", + "params": { + "distance_mm": 20.0 + }, + "sketch_id": "sketch_001" + }, + { + "id": "f02", + "atomic_id": "extrude_cut_blind", + "depends_on": ["f01"], + "name": "程序化图案切除", + "params": { + "distance_mm": 8.0, + "reverse": true + }, + "sketch_id": "sketch_002" + } + ], + "geometry": { + "sketches": [ + { + "id": "sketch_001", + "name": "草图1", + "workplane": { + "origin_mm": [0.0, 0.0, 0.0], + "x_dir": [1.0, 0.0, 0.0], + "y_dir": [0.0, 1.0, 0.0], + "normal": [0.0, 0.0, 1.0] + }, + "profile": { + "type": "circle", + "center": [0.0, 0.0], + "radius_mm": 50.0 + } + }, + { + "id": "sketch_002", + "name": "图案草图", + "workplane": { + "origin_mm": [0.0, 0.0, 20.0], + "x_dir": [1.0, 0.0, 0.0], + "y_dir": [0.0, 1.0, 0.0], + "normal": [0.0, 0.0, 1.0] + }, + "profile": { + "type": "patterned_cutouts", + "motif": { + "type": "teardrop_polygon", + "left_width_mm": 1.116, + "right_width_mm": 1.548, + "tip_height_mm": 2.79, + "bottom_depth_mm": 1.476, + "shoulder_height_mm": 1.242 + }, + "layout": { + "type": "twin_strips", + "x_offset_mm": 15.0, + "count_y": 12, + "y_start_mm": -34.0, + "y_end_mm": 34.0 + } + } + } + ] + }, + "meta": { + "source": "cylinder_025_cylinder_25_teardrop_twin_strips_v01_teardrop24.solidworks_evidence_v2.json", + "parameterized_sketches": 2, + "profile_from_sketches": 0, + "notes": [ + "CDSL-only drawable: repeated source sketches are represented by one procedural motif and layout", + "No compiler_context, source entities, per-instance coordinates, or encoded geometry" + ] + } +} diff --git a/backend/cdsl_library/samples/cylinder_026_cylinder_26_trapezoid_center_plus_ring_v01_trapezoid24/model.cdsl.json b/backend/cdsl_library/samples/cylinder_026_cylinder_26_trapezoid_center_plus_ring_v01_trapezoid24/model.cdsl.json new file mode 100644 index 00000000..79671af5 --- /dev/null +++ b/backend/cdsl_library/samples/cylinder_026_cylinder_26_trapezoid_center_plus_ring_v01_trapezoid24/model.cdsl.json @@ -0,0 +1,92 @@ +{ + "schema": "cad.cdsl.llm.v1", + "schema_version": "1.0.0", + "kind": "part", + "part_id": "cylinder_026_cylinder_26_trapezoid_center_plus_ring_v01_trapezoid24", + "features": [ + { + "id": "f01", + "atomic_id": "extrude_add_blind", + "depends_on": [], + "name": "凸台-拉伸1", + "params": { + "distance_mm": 20.0 + }, + "sketch_id": "sketch_001" + }, + { + "id": "f02", + "atomic_id": "extrude_cut_blind", + "depends_on": ["f01"], + "name": "程序化图案切除", + "params": { + "distance_mm": 8.0, + "reverse": true + }, + "sketch_id": "sketch_002" + } + ], + "geometry": { + "sketches": [ + { + "id": "sketch_001", + "name": "草图1", + "workplane": { + "origin_mm": [0.0, 0.0, 0.0], + "x_dir": [1.0, 0.0, 0.0], + "y_dir": [0.0, 1.0, 0.0], + "normal": [0.0, 0.0, 1.0] + }, + "profile": { + "type": "circle", + "center": [0.0, 0.0], + "radius_mm": 50.0 + } + }, + { + "id": "sketch_002", + "name": "图案草图", + "workplane": { + "origin_mm": [0.0, 0.0, 20.0], + "x_dir": [1.0, 0.0, 0.0], + "y_dir": [0.0, 1.0, 0.0], + "normal": [0.0, 0.0, 1.0] + }, + "profile": { + "type": "patterned_cutouts", + "motif": { + "type": "trapezoid", + "bottom_width_mm": 4.32, + "top_width_mm": 2.376, + "height_mm": 3.24 + }, + "layout": { + "type": "concentric_rings", + "orientation": "radial", + "rings": [ + { + "radius_mm": 12.0, + "count": 8, + "start_angle_deg": 0.0 + }, + { + "radius_mm": 31.0, + "count": 16, + "start_angle_deg": 0.0 + } + ] + } + } + } + ] + }, + "meta": { + "source": "cylinder_026_cylinder_26_trapezoid_center_plus_ring_v01_trapezoid24.solidworks_evidence_v2.json", + "parameterized_sketches": 2, + "profile_from_sketches": 0, + "notes": [ + "CDSL-only drawable: repeated source sketches are represented by one procedural motif and layout", + "No compiler_context, source entities, per-instance coordinates, or encoded geometry" + ] + } +} diff --git a/backend/cdsl_library/samples/cylinder_027_cylinder_27_dshape_corner_clusters_v01_dshape36/model.cdsl.json b/backend/cdsl_library/samples/cylinder_027_cylinder_27_dshape_corner_clusters_v01_dshape36/model.cdsl.json new file mode 100644 index 00000000..6a506140 --- /dev/null +++ b/backend/cdsl_library/samples/cylinder_027_cylinder_27_dshape_corner_clusters_v01_dshape36/model.cdsl.json @@ -0,0 +1,80 @@ +{ + "schema": "cad.cdsl.llm.v1", + "schema_version": "1.0.0", + "kind": "part", + "part_id": "cylinder_027_cylinder_27_dshape_corner_clusters_v01_dshape36", + "features": [ + { + "id": "f01", + "atomic_id": "extrude_add_blind", + "depends_on": [], + "name": "凸台-拉伸1", + "params": { + "distance_mm": 20.0 + }, + "sketch_id": "sketch_001" + }, + { + "id": "f02", + "atomic_id": "extrude_cut_blind", + "depends_on": ["f01"], + "name": "程序化图案切除", + "params": { + "distance_mm": 8.0, + "reverse": true + }, + "sketch_id": "sketch_002" + } + ], + "geometry": { + "sketches": [ + { + "id": "sketch_001", + "name": "草图1", + "workplane": { + "origin_mm": [0.0, 0.0, 0.0], + "x_dir": [1.0, 0.0, 0.0], + "y_dir": [0.0, 1.0, 0.0], + "normal": [0.0, 0.0, 1.0] + }, + "profile": { + "type": "circle", + "center": [0.0, 0.0], + "radius_mm": 50.0 + } + }, + { + "id": "sketch_002", + "name": "图案草图", + "workplane": { + "origin_mm": [0.0, 0.0, 20.0], + "x_dir": [1.0, 0.0, 0.0], + "y_dir": [0.0, 1.0, 0.0], + "normal": [0.0, 0.0, 1.0] + }, + "profile": { + "type": "patterned_cutouts", + "motif": { + "type": "d_shape_polygon", + "stem_length_mm": 1.8, + "nose_depth_mm": 1.8, + "half_height_mm": 1.305 + }, + "layout": { + "type": "corner_clusters", + "levels_mm": [12.0, 18.5, 25.0] + } + } + } + ] + }, + "meta": { + "source": "cylinder_027_cylinder_27_dshape_corner_clusters_v01_dshape36.solidworks_evidence_v2.json", + "parameterized_sketches": 2, + "profile_from_sketches": 0, + "notes": [ + "CDSL-only drawable: repeated source sketches are represented by one procedural motif and layout", + "No compiler_context, source entities, per-instance coordinates, or encoded geometry" + ] + } +} diff --git a/backend/cdsl_library/samples/cylinder_028_cylinder_28_diamond_field_v01_polygon25/model.cdsl.json b/backend/cdsl_library/samples/cylinder_028_cylinder_28_diamond_field_v01_polygon25/model.cdsl.json new file mode 100644 index 00000000..63deb526 --- /dev/null +++ b/backend/cdsl_library/samples/cylinder_028_cylinder_28_diamond_field_v01_polygon25/model.cdsl.json @@ -0,0 +1,79 @@ +{ + "schema": "cad.cdsl.llm.v1", + "schema_version": "1.0.0", + "kind": "part", + "part_id": "cylinder_028_cylinder_28_diamond_field_v01_polygon25", + "features": [ + { + "id": "f01", + "atomic_id": "extrude_add_blind", + "depends_on": [], + "name": "凸台-拉伸1", + "params": { + "distance_mm": 20.0 + }, + "sketch_id": "sketch_001" + }, + { + "id": "f02", + "atomic_id": "extrude_cut_blind", + "depends_on": ["f01"], + "name": "程序化图案切除", + "params": { + "distance_mm": 8.0, + "reverse": true + }, + "sketch_id": "sketch_002" + } + ], + "geometry": { + "sketches": [ + { + "id": "sketch_001", + "name": "草图1", + "workplane": { + "origin_mm": [0.0, 0.0, 0.0], + "x_dir": [1.0, 0.0, 0.0], + "y_dir": [0.0, 1.0, 0.0], + "normal": [0.0, 0.0, 1.0] + }, + "profile": { + "type": "circle", + "center": [0.0, 0.0], + "radius_mm": 50.0 + } + }, + { + "id": "sketch_002", + "name": "图案草图", + "workplane": { + "origin_mm": [0.0, 0.0, 20.0], + "x_dir": [1.0, 0.0, 0.0], + "y_dir": [0.0, 1.0, 0.0], + "normal": [0.0, 0.0, 1.0] + }, + "profile": { + "type": "patterned_cutouts", + "motif": { + "type": "square", + "width_mm": 2.969848 + }, + "layout": { + "type": "diamond_field", + "manhattan_radius": 3, + "spacing_mm": 8.0 + } + } + } + ] + }, + "meta": { + "source": "cylinder_028_cylinder_28_diamond_field_v01_polygon25.solidworks_evidence_v2.json", + "parameterized_sketches": 2, + "profile_from_sketches": 0, + "notes": [ + "CDSL-only drawable: repeated source sketches are represented by one procedural motif and layout", + "No compiler_context, source entities, per-instance coordinates, or encoded geometry" + ] + } +} diff --git a/backend/cdsl_library/samples/cylinder_029_cylinder_29_sector_scaled_ring_v02_sector10/model.cdsl.json b/backend/cdsl_library/samples/cylinder_029_cylinder_29_sector_scaled_ring_v02_sector10/model.cdsl.json new file mode 100644 index 00000000..173da035 --- /dev/null +++ b/backend/cdsl_library/samples/cylinder_029_cylinder_29_sector_scaled_ring_v02_sector10/model.cdsl.json @@ -0,0 +1,82 @@ +{ + "schema": "cad.cdsl.llm.v1", + "schema_version": "1.0.0", + "kind": "part", + "part_id": "cylinder_029_cylinder_29_sector_scaled_ring_v02_sector10", + "features": [ + { + "id": "f01", + "atomic_id": "extrude_add_blind", + "depends_on": [], + "name": "凸台-拉伸1", + "params": { + "distance_mm": 20.0 + }, + "sketch_id": "sketch_001" + }, + { + "id": "f02", + "atomic_id": "extrude_cut_blind", + "depends_on": ["f01"], + "name": "程序化图案切除", + "params": { + "distance_mm": 8.0, + "reverse": true + }, + "sketch_id": "sketch_002" + } + ], + "geometry": { + "sketches": [ + { + "id": "sketch_001", + "name": "草图1", + "workplane": { + "origin_mm": [0.0, 0.0, 0.0], + "x_dir": [1.0, 0.0, 0.0], + "y_dir": [0.0, 1.0, 0.0], + "normal": [0.0, 0.0, 1.0] + }, + "profile": { + "type": "circle", + "center": [0.0, 0.0], + "radius_mm": 50.0 + } + }, + { + "id": "sketch_002", + "name": "图案草图", + "workplane": { + "origin_mm": [0.0, 0.0, 20.0], + "x_dir": [1.0, 0.0, 0.0], + "y_dir": [0.0, 1.0, 0.0], + "normal": [0.0, 0.0, 1.0] + }, + "profile": { + "type": "patterned_cutouts", + "motif": { + "type": "annular_sector_polygon", + "inner_radius_mm": 11.0, + "outer_radius_mm": 22.0, + "half_angle_deg": 4.010705 + }, + "layout": { + "type": "angular", + "count": 10, + "start_angle_deg": 18.0, + "orientation": "radial" + } + } + } + ] + }, + "meta": { + "source": "cylinder_029_cylinder_29_sector_scaled_ring_v02_sector10.solidworks_evidence_v2.json", + "parameterized_sketches": 2, + "profile_from_sketches": 0, + "notes": [ + "CDSL-only drawable: repeated source sketches are represented by one procedural motif and layout", + "No compiler_context, source entities, per-instance coordinates, or encoded geometry" + ] + } +} diff --git a/backend/engine/.gitkeep b/backend/engine/.gitkeep new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/backend/engine/.gitkeep @@ -0,0 +1 @@ + diff --git a/backend/engine/cdsl_engine/README.md b/backend/engine/cdsl_engine/README.md new file mode 100644 index 00000000..c1f3329f --- /dev/null +++ b/backend/engine/cdsl_engine/README.md @@ -0,0 +1,10 @@ +# Local CDSL Engine + +This package rebuilds `cad.cdsl.llm.v1` models through the CDSL-only path: + +`sketch_solver -> llm_compiler -> llm_engine -> STEP` + +Supported profiles are defined by `SHAPE_GENERATORS` in `sketch_solver.py`. +The Studio only accepts self-contained profile data and requires successful +`engine=cdsl_only` output. It never uses the legacy translator fallback or +`compiler_context`. diff --git a/backend/engine/cdsl_engine/__init__.py b/backend/engine/cdsl_engine/__init__.py new file mode 100644 index 00000000..c029d973 --- /dev/null +++ b/backend/engine/cdsl_engine/__init__.py @@ -0,0 +1,28 @@ +""" +Local CDSL engine copied into the product repository. + +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_engine, run_rebuild +from .sketch_solver import SHAPE_GENERATORS, resolve_all_sketches + +__all__ = [ + "convert_sw_json_to_cdsl", + "write_cdsl_outputs", + "compile_cdsl", + "compile_cdsl_to_pack", + "run_engine_plan", + "run_engine", + "run_rebuild", + "compare_with_gold", + "resolve_all_sketches", + "SHAPE_GENERATORS", +] + +__version__ = "1.0.0" diff --git a/backend/engine/cdsl_engine/convert_to_cdsl.py b/backend/engine/cdsl_engine/convert_to_cdsl.py new file mode 100644 index 00000000..b0161fdc --- /dev/null +++ b/backend/engine/cdsl_engine/convert_to_cdsl.py @@ -0,0 +1,1123 @@ +""" +SW 建模历史 JSON → 参数化 CDSL 转换器 (v4) +============================================ +严格按照 015133 手册要求生成 LLM 训练友好的 CDSL: + +Phase 1 — 草图参数化 (注册表模式): + - @sketch_classifier 装饰器注册形状分类器 + - 通用性: 新增分类器无需修改主逻辑 + - 与 sketch_solver.py 的 SHAPE_GENERATORS 对齐 + +Phase 2 — 同形复用: + - 检测内容相同的草图 → profile_from: "sk_xx" + - profile_shift 处理镜像偏移 + +Phase 3 — 轴原点推导: + - 所有 revolve 特征 axis.origin_mm → from_contour_vertex +""" + +from __future__ import annotations + +import hashlib +import json +import math +from copy import deepcopy +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 + + +# ═══════════════════════════════════════════════════════════════ +# 草图分类器注册表 —— 通用性核心 +# ═══════════════════════════════════════════════════════════════ + +# 分类器签名: 接收 (lines, arcs, circles, const_lines, all_non_const) -> profile_dict | None +ClassifierFunc = Callable[[list[dict], list[dict], list[dict], list[dict], list[dict]], dict[str, Any] | None] + +SKETCH_CLASSIFIERS: list[tuple[str, int, ClassifierFunc]] = [] +"""形状分类器注册表 (name, priority, function)。priority 越小优先级越高。""" + + +def register_classifier(name: str, priority: int = 100) -> Callable[[ClassifierFunc], ClassifierFunc]: + """注册装饰器:将分类函数注册到 SKETCH_CLASSIFIERS。 + + 用法: + @register_classifier("obround", priority=10) + def _classify_obround(lines, arcs, circles, const_lines, all_ents): + ... + return profile_dict or None + """ + def decorator(fn: ClassifierFunc) -> ClassifierFunc: + SKETCH_CLASSIFIERS.append((name, priority, fn)) + return fn + return decorator + + +def _classify_sketch_shape(sketch: dict[str, Any]) -> dict[str, Any]: + """按优先级依次尝试所有注册的分类器,返回第一个非 None 结果。 + + 架构设计: 新增形状只需 @register_classifier 装饰一个函数, + 无需修改此函数或 if/elif 链。—— 015133 手册"注册表模式" + """ + entities = sketch.get("entities") or [] + if not entities: + return None + + non_const = [e for e in entities if not e.get("construction")] + const_lines = [e for e in entities if e.get("type") == "line" and e.get("construction")] + + lines = [e for e in non_const if e.get("type") == "line"] + circles = [e for e in non_const if e.get("type") == "circle"] + arcs = [e for e in non_const if e.get("type") == "arc"] + + if len(lines) == 0 and len(circles) == 0 and len(arcs) == 0: + return None + + # 预计算常用辅助数据(避免每个分类器重复计算) + # 按优先级执行分类器 + sorted_classifiers = sorted(SKETCH_CLASSIFIERS, key=lambda x: x[1]) + for name, priority, fn in sorted_classifiers: + result = fn(lines, arcs, circles, const_lines, non_const) + if result is not None: + return result + + # 无法识别的形状: 015133 坐标仅存 compiler_context + return { + "type": "unknown_shape", + "signature": "L{}_A{}_C{}".format(len(lines), len(arcs), len(circles)), + "_note": "exact rebuild will use compiler_context", + } + + +# ═══════════════════════════════════════════════════════════════ +# 基础几何形状分类器 +# ═══════════════════════════════════════════════════════════════ + +@register_classifier("circle", priority=1) +def _classify_circle(lines, arcs, circles, const_lines, all_ents): + """单圆:1 个 circle 实体,无线段无弧""" + if len(lines) == 0 and len(arcs) == 0 and len(circles) == 1: + c = circles[0] + return { + "type": "circle", + "radius_mm": round(float(c["radius_mm"]), 6), + "center": [round(float(c["center"][0]), 6), round(float(c["center"][1]), 6)], + } + return None + + +@register_classifier("annulus", priority=2) +def _classify_annulus(lines, arcs, circles, const_lines, all_ents): + """同心圆环:2 个同心圆""" + if len(lines) != 0 or len(arcs) != 0 or len(circles) != 2: + return None + c0, c1 = circles[0], circles[1] + if (abs(float(c0["center"][0]) - float(c1["center"][0])) < 1e-6 + and abs(float(c0["center"][1]) - float(c1["center"][1])) < 1e-6): + radii = sorted([float(c0["radius_mm"]), float(c1["radius_mm"])]) + return { + "type": "annulus", + "center": [round(float(c0["center"][0]), 6), round(float(c0["center"][1]), 6)], + "outer_radius_mm": round(radii[1], 6), + "inner_radius_mm": round(radii[0], 6), + } + return None + + +@register_classifier("circles", priority=3) +def _classify_circles(lines, arcs, circles, const_lines, all_ents): + """多圆(非同心):N 个圆,无线段""" + if len(lines) == 0 and len(arcs) == 0 and len(circles) >= 2: + return { + "type": "circles", + "items": [{ + "center": [round(float(c["center"][0]), 6), round(float(c["center"][1]), 6)], + "radius_mm": round(float(c["radius_mm"]), 6), + } for c in circles], + } + return None + + +@register_classifier("rectangle", priority=4) +def _classify_rect(lines, arcs, circles, const_lines, all_ents): + """纯矩形:4 条线,无弧""" + if len(lines) == 4 and len(arcs) == 0 and len(circles) == 0: + return _classify_rectangle(lines) + return None + + +@register_classifier("obround", priority=5) +def _classify_obround(lines, arcs, circles, const_lines, all_ents): + """槽形/键槽:2 条平行直线 + 2 段半圆弧 = 运动场的形状。 + + 检测几何签名: + - 2 条直线(长度相等且平行) + - 2 条圆弧(半径相等,两弧圆心在直线端点处) + - 2 条直线端点分别连接到 2 条弧的端点 + """ + if len(lines) != 2 or len(arcs) != 2 or len(circles) > 0: + return None + + # 获取线条方向 + s1 = [float(lines[0]["start"][0]), float(lines[0]["start"][1])] + e1 = [float(lines[0]["end"][0]), float(lines[0]["end"][1])] + s2 = [float(lines[1]["start"][0]), float(lines[1]["start"][1])] + e2 = [float(lines[1]["end"][0]), float(lines[1]["end"][1])] + + # 线 1 方向 + d1_u, d1_v = e1[0] - s1[0], e1[1] - s1[1] + d2_u, d2_v = e2[0] - s2[0], e2[1] - s2[1] + + len1 = math.hypot(d1_u, d1_v) + len2 = math.hypot(d2_u, d2_v) + + if len1 < 1e-6 or len2 < 1e-6: + return None + + # 平行性检查 (cos 接近 ±1) + dot = (d1_u * d2_u + d1_v * d2_v) / (len1 * len2) + if abs(abs(dot) - 1.0) > 0.01: + return None + + # 长度相等 + if abs(len1 - len2) > 0.1: + return None + + # 弧半径相等 + r1 = float(arcs[0].get("radius_mm", 0)) + r2 = float(arcs[1].get("radius_mm", 0)) + if abs(r1 - r2) > 0.01 or r1 <= 0: + return None + + # 直线平行 → X_dir 与线条方向平行 + # obround 方向: length 沿线条方向, width = 2*r + dir_u, dir_v = d1_u / len1, d1_v / len1 + width = 2 * r1 + length = len1 + width # 总长 = 直线长度 + 2*半径 + + mid_x = (s1[0] + e1[0] + s2[0] + e2[0]) / 4 + mid_y = (s1[1] + e1[1] + s2[1] + e2[1]) / 4 + + return { + "type": "obround", + "center": [round(mid_x, 6), round(mid_y, 6)], + "length_mm": round(length, 6), + "width_mm": round(width, 6), + } + + +@register_classifier("d_shape", priority=6) +def _classify_d_shape(lines, arcs, circles, const_lines, all_ents): + """D 形:1 条直线 + 1 段圆弧(半圆+弦线)。 + + 几何签名: + - 1 条线 + 1 条弧 + - 弧的端点 = 线的端点 + - 弧接近半圆 (sweep ≈ 180°) + """ + if len(lines) != 1 or len(arcs) != 1 or len(circles) > 0: + return None + + ln_start = [float(lines[0]["start"][0]), float(lines[0]["start"][1])] + ln_end = [float(lines[0]["end"][0]), float(lines[0]["end"][1])] + arc_center = [float(arcs[0].get("center", [0, 0])[0]), float(arcs[0].get("center", [0, 0])[1])] + arc_radius = float(arcs[0].get("radius_mm", 0)) + arc_sweep = arcs[0].get("arc_sweep_deg", 0) + + if arc_radius <= 0: + return None + + # 弧端点应与线端点重合 + arc_start = [float(arcs[0].get("start", [0, 0])[0]), float(arcs[0].get("start", [0, 0])[1])] + arc_end = [float(arcs[0].get("end", [0, 0])[0]), float(arcs[0].get("end", [0, 0])[1])] + + # 线/弧端点匹配(任意组合) + eps = 0.01 + dists = [ + (math.hypot(ln_start[0] - arc_start[0], ln_start[1] - arc_start[1]), + math.hypot(ln_end[0] - arc_end[0], ln_end[1] - arc_end[1])), + (math.hypot(ln_start[0] - arc_end[0], ln_start[1] - arc_end[1]), + math.hypot(ln_end[0] - arc_start[0], ln_end[1] - arc_start[1])), + ] + ok = any(d[0] < eps and d[1] < eps for d in dists) + if not ok: + return None + + # 弦线方向: 圆心到弦中点的方向 = 负 chord_sign + chord_mid = [(ln_start[0] + ln_end[0]) / 2, (ln_start[1] + ln_end[1]) / 2] + to_chord = [chord_mid[0] - arc_center[0], chord_mid[1] - arc_center[1]] + + cdx = arc_center[0] - chord_mid[0] + if cdx > 0: + chord_sign = "left" # 圆心在弦右侧,弧开口朝左(D 的圆弧在左侧) + else: + chord_sign = "right" + + chord_len = math.hypot(ln_end[0] - ln_start[0], ln_end[1] - ln_start[1]) + chord_x = math.sqrt(max(0, arc_radius * arc_radius - (chord_len / 2) ** 2)) + + return { + "type": "d_shape", + "radius_mm": round(arc_radius, 6), + "chord_sign": chord_sign, + "chord_x_mm": round(chord_x, 6), + } + + +@register_classifier("partial_ring", priority=7) +def _classify_partial_ring(lines, arcs, circles, const_lines, all_ents): + """部分圆环(扇区环):2 段同心弧 + 2 条径向直线。 + + 几何签名: + - 2 线 + 2 弧 + - 2 弧同心 + - 每条线连接一对外弧端点和内弧端点 + """ + if len(lines) != 2 or len(arcs) != 2 or len(circles) > 0: + return None + + c0 = [float(arcs[0].get("center", [0, 0])[0]), float(arcs[0].get("center", [0, 0])[1])] + c1 = [float(arcs[1].get("center", [0, 0])[0]), float(arcs[1].get("center", [0, 0])[1])] + + if abs(c0[0] - c1[0]) > 0.01 or abs(c0[1] - c1[1]) > 0.01: + return None + + r0 = float(arcs[0].get("radius_mm", 0)) + r1 = float(arcs[1].get("radius_mm", 0)) + if r0 <= 0 or r1 <= 0: + return None + + ir = min(r0, r1) + oR = max(r0, r1) + + # 线的端点应连接到弧端点 + # 径向线应从内弧端点到外弧端点 + # 计算大约的角度范围 + a0_s = [float(arcs[0].get("start", [0, 0])[0]), float(arcs[0].get("start", [0, 0])[1])] + a0_e = [float(arcs[0].get("end", [0, 0])[0]), float(arcs[0].get("end", [0, 0])[1])] + + # 相对于圆心的角度 + def angle_from(u, v): + return math.degrees(math.atan2(v - c0[1], u - c0[0])) + + angles = [angle_from(p[0], p[1]) for p in [a0_s, a0_e]] + half_angle = abs(angles[0] - angles[1]) / 2 + if half_angle > 180: + half_angle = 360 - half_angle + + return { + "type": "partial_ring", + "inner_radius_mm": round(ir, 6), + "outer_radius_mm": round(oR, 6), + "half_angle_deg": round(half_angle, 6), + } + + +@register_classifier("rectangle_with_circles", priority=8) +def _classify_rect_with_circles(lines, arcs, circles, const_lines, all_ents): + """矩形内嵌圆孔:矩形 + 内部圆形孔洞。 + + 几何签名: + - 4 条线构成矩形 + - N 个圆全部在矩形内部 + """ + if len(lines) != 4 or len(arcs) != 0 or len(circles) == 0: + return None + + rect = _classify_rectangle(lines) + if rect is None: + return None + + # 检查所有圆是否在矩形内 + mn = rect.get("min_mm", [0, 0]) + mx = rect.get("max_mm", [0, 0]) + x0, y0 = mn[0], mn[1] + x1, y1 = mx[0], mx[1] + + inner_circles = [] + for c in circles: + cx = float(c["center"][0]) + cy = float(c["center"][1]) + cr = float(c["radius_mm"]) + if x0 - cr < cx < x1 + cr and y0 - cr < cy < y1 + cr: + inner_circles.append({ + "center": [round(cx, 6), round(cy, 6)], + "radius_mm": round(cr, 6), + }) + + if not inner_circles: + return None + + return { + "type": "rectangle_with_circles", + "boundary": { + "type": "rectangle", + "width_mm": rect.get("width_mm"), + "height_mm": rect.get("height_mm"), + "min_mm": mn, + "max_mm": mx, + }, + "circles": inner_circles, + } + + +@register_classifier("polygon", priority=50) +def _classify_polygon(lines, arcs, circles, const_lines, all_ents): + """普通多边形:N 条线段,无弧无圆。顶点坐标仅存 compiler_context。""" + if len(lines) < 3 or len(arcs) > 0 or len(circles) > 0: + return None + + # 4 线优先返回 rectangle + if len(lines) == 4: + rect = _classify_rectangle(lines) + if rect: + rect["type"] = "rectangle" + return rect + + # 015133: polygon 不携带顶点坐标,坐标属于 Execution IR + has_axis = len(const_lines) >= 1 + return { + "type": "polygon", + "n_sides": len(lines), + "_has_construction_axis": has_axis, + } + + +@register_classifier("revolve_straight_profile", priority=55) +def _classify_revolve_polygon(lines, arcs, circles, const_lines, all_ents): + """旋转特征的直边截面(有 construction line 做轴,5 边梯形等)。 + + > polygon 分类器(priority=50) 如果没匹配到,说明有弧/圆。 + > 这里处理有构造线但非纯多边形的情况。 + """ + if len(const_lines) < 1: + return None + if len(lines) < 3: + return None + if len(arcs) > 0 or len(circles) > 0: + return None + + # 尝试 revolve_chamfer / revolve_chamfer_slanted 识别 + if len(lines) == 5: + vertices = _extract_polygon_vertices(lines) + if len(vertices) == 5: + # 5 边梯形截面: revolve_chamfer 或 slanted 变体 + # 分析形状特征 + v = vertices + # 找到到原点最近的点 (轴侧) + dists = [math.hypot(vx[0], vx[1]) for vx in v] + min_idx = dists.index(min(dists)) + axis_v = v[min_idx] + max_idx = dists.index(max(dists)) + wall_v = v[max_idx] + + # 最高点和最低点 + ys = [vx[1] for vx in v] + top_y, bot_y = max(ys), min(ys) + + # 找到轴侧高度 + axis_height = abs(top_y - bot_y) + + # 找到壁侧参数 + wall_width = abs(wall_v[0]) + wall_y = wall_v[1] + wall_inset = abs(top_y - wall_y) + + if wall_inset > 0: + return { + "type": "revolve_chamfer_slanted", + "axis_height_mm": round(axis_height, 6), + "top_width_mm": round(abs(axis_v[0]) if abs(axis_v[0]) > 0.01 else abs(wall_width), 6), + "wall_inset_mm": round(wall_inset, 6), + "wall_height_mm": round(abs(bot_y - wall_y), 6), + "wall_width_mm": round(wall_width, 6), + "on_axis_side": "right" if wall_v[0] > 0 else "left", + } + + return { + "type": "revolve_chamfer", + "axis_height_mm": round(axis_height, 6), + "top_width_mm": round(abs(float(v[max_idx][0])), 6), + "bottom_width_mm": round(abs(float(v[(min_idx + 2) % 5][0])), 6), + "wall_inset_mm": round(wall_inset, 6), + "on_axis_side": "right" if wall_v[0] > 0 else "left", + } + + # 普通 revolve 多边形截面 + vertices = _extract_polygon_vertices(lines) + if len(vertices) >= 3: + return { + "type": "polygon", + "vertices": vertices, + "_has_construction_axis": True, + } + return None + + +@register_classifier("complex_arc_shape", priority=90) +def _classify_complex_arc_shape(lines, arcs, circles, const_lines, all_ents): + """含弧复杂形状:任何无法被更高优先级分类器识别的含弧草图。 + + 015133: 坐标仅存 compiler_context,CDSL 只保留类型签名。 + """ + if len(arcs) == 0: + return None + + return { + "type": "complex_arc_shape", + "n_lines": len(lines), + "n_arcs": len(arcs), + "n_circles": len(circles), + "_signature": "L{}_A{}_C{}".format(len(lines), len(arcs), len(circles)), + } + + + +def _clean_cdsl_floats(obj: Any, decimals: int = 6) -> Any: + """递归清理CDSL中所有浮点数到指定精度。移除IEEE 754噪音 (如31.500000000000007→31.5)。 + + 015133标准: LLM不应学习的浮点精度噪音。 + """ + if isinstance(obj, dict): + return {k: _clean_cdsl_floats(v, decimals) for k, v in obj.items()} + elif isinstance(obj, list): + return [_clean_cdsl_floats(item, decimals) for item in obj] + elif isinstance(obj, float): + # 跳过方向向量 (归一化后的单位向量分量本身就是无理数,如0.707...) + # 跳过编译器上下文中的精确值 + return round(obj, decimals) + return obj + +def _content_hash(sketch: dict[str, Any]) -> str: + """对草图实体内容计算 hash,用于检测同形复用""" + entities = sketch.get("entities") or [] + keys_data = [] + for ent in entities: + if ent.get("construction"): + continue + t = ent.get("type", "?") + if t == "line": + s = ent.get("start", [0, 0]) + e = ent.get("end", [0, 0]) + keys_data.append(f"line:{s[0]:.4f},{s[1]:.4f}:{e[0]:.4f},{e[1]:.4f}") + elif t == "circle": + c = ent.get("center", [0, 0]) + r = ent.get("radius_mm", 0) + keys_data.append(f"circle:{c[0]:.4f},{c[1]:.4f}:r{r:.4f}") + elif t == "arc": + c = ent.get("center", [0, 0]) + r = ent.get("radius_mm", 0) + s = ent.get("start", [0, 0]) + e = ent.get("end", [0, 0]) + keys_data.append(f"arc:{c[0]:.4f},{c[1]:.4f}:r{r:.4f}:{s[0]:.4f},{s[1]:.4f}:{e[0]:.4f},{e[1]:.4f}") + elif t == "point": + p = ent.get("point_mm") or ent.get("point") or [0, 0] + keys_data.append(f"point:{p[0]:.4f},{p[1]:.4f}") + else: + keys_data.append(f"unknown:{json.dumps(ent, sort_keys=True)}") + keys_data.sort() + return hashlib.md5("|".join(keys_data).encode()).hexdigest()[:12] + + +def _classify_rectangle(lines: list[dict[str, Any]]) -> dict[str, Any] | None: + """从 4 条线段识别矩形""" + pts = [] + for l in lines: + s = l.get("start", [0, 0]) + e = l.get("end", [0, 0]) + pts.append((round(float(s[0]), 6), round(float(s[1]), 6))) + pts.append((round(float(e[0]), 6), round(float(e[1]), 6))) + + xs = [p[0] for p in pts] + ys = [p[1] for p in pts] + min_x, max_x = min(xs), max(xs) + min_y, max_y = min(ys), max(ys) + w = round(max_x - min_x, 6) + h = round(max_y - min_y, 6) + + if w < 1e-6 or h < 1e-6: + return None + + # rectangle 只保留 width/height + center,min_mm/max_mm 是坐标污染 + cx = round((min_x + max_x) / 2, 6) + cy = round((min_y + max_y) / 2, 6) + result = { + "type": "rectangle", + "width_mm": w, + "height_mm": h, + "center": [cx, cy], + } + return result + + +def _extract_polygon_vertices(lines: list[dict[str, Any]]) -> list[list[float]]: + """从连续线段提取有序顶点""" + if len(lines) < 2: + return [] + adj = {} + for l in lines: + s = (round(float(l["start"][0]), 6), round(float(l["start"][1]), 6)) + e = (round(float(l["end"][0]), 6), round(float(l["end"][1]), 6)) + adj[s] = e + + if not adj: + return [] + + first = next(iter(adj.keys())) + order = [first] + cur = first + seen = {first} + for _ in range(len(lines) + 1): + nxt = adj.get(cur) + if nxt is None or nxt in seen: + break + order.append(nxt) + seen.add(nxt) + cur = nxt + return [[float(v[0]), float(v[1])] for v in order] + + +def _generate_contour_from_loops(sketch: dict[str, Any]) -> list[dict[str, Any]]: + """从 IR 草图 loops + entities 生成 contour_edges_mm""" + entities = sketch.get("entities") or [] + loops = sketch.get("loops") or [] + + contours = [] + # 如果没有 loops 数据,从 entities 直接生成 + if not loops: + non_const = [e for e in entities if not e.get("construction")] + for ent in non_const: + c = _entity_to_contour(ent) + if c: + contours.append(c) + return contours + + # 有 loops 数据:从 loop 的 entity_indices 提取 + for loop in loops: + indices = loop.get("entity_indices") or [] + for idx in indices: + if idx < len(entities) and not entities[idx].get("construction", False): + c = _entity_to_contour(entities[idx]) + if c: + contours.append(c) + + return contours + + +def _entity_to_contour(ent: dict[str, Any]) -> dict[str, Any] | None: + """将单个草图实体转换为 contour_edge""" + t = ent.get("type", "") + if t == "line": + s = ent.get("start", [0, 0]) + e = ent.get("end", [0, 0]) + return { + "type": "line", + "start_mm": [float(s[0]), float(s[1]), 0.0], + "end_mm": [float(e[0]), float(e[1]), 0.0], + } + elif t == "arc": + s = ent.get("start", [0, 0]) + e = ent.get("end", [0, 0]) + c = ent.get("center", [0, 0]) + return { + "type": "arc", + "start_mm": [float(s[0]), float(s[1]), 0.0], + "end_mm": [float(e[0]), float(e[1]), 0.0], + "center_mm": [float(c[0]), float(c[1]), 0.0], + "radius_mm": float(ent.get("radius_mm", 0)), + } + elif t == "circle": + c = ent.get("center", [0, 0]) + r = ent.get("radius_mm", 0) + # 圆分解为 4 段弧 + cx, cy = float(c[0]), float(c[1]) + return { + "type": "arc", + "start_mm": [cx + r, cy, 0.0], + "end_mm": [cx, cy + r, 0.0], + "center_mm": [cx, cy, 0.0], + "radius_mm": r, + } + return None + + +# ═══════════════════════════════════════════════════════════════ +# 主轴: SW JSON → 参数化 CDSL +# ═══════════════════════════════════════════════════════════════ + +def convert_sw_json_to_cdsl( + sw_json_path: str | Path, + part_id: str | None = None, +) -> dict[str, Any]: + sw_json_path = Path(sw_json_path) + with open(sw_json_path, "r", encoding="utf-8") as f: + sw_data = json.load(f) + + if part_id is None: + part_id = sw_json_path.stem.replace(".solidworks_rebuild_extract", "") + + ir = normalize_to_ir(sw_data) + + # ── Phase 1: 参数化每个草图 ── + ir_sketches = {s["id"]: s for s in ir.get("sketches", [])} + cdsl_sketches: list[dict[str, Any]] = [] + id_to_cdsl_sketch: dict[str, dict[str, Any]] = {} + + for sk_id, sk in sorted(ir_sketches.items()): + name = sk.get("name", "") + wp = sk.get("workplane") or {} + + # 跳过放样轮廓草图(由 loft 操作直接处理) + if sk.get("loft_profile"): + continue + + # 跳过纯参考草图 (如 body reference) + entities = sk.get("entities") or [] + non_const = [e for e in entities if not e.get("construction")] + if len(non_const) == 0: + cdsl_sk = { + "id": sk_id, + "name": name, + "workplane": { + "origin_mm": wp.get("origin_mm", [0, 0, 0]), + "x_dir": wp.get("x_dir", [1, 0, 0]), + "y_dir": wp.get("y_dir", [0, 1, 0]), + "normal": wp.get("normal", [0, 0, 1]), + }, + } + cdsl_sketches.append(cdsl_sk) + id_to_cdsl_sketch[sk_id] = cdsl_sk + continue + + profile = _classify_sketch_shape(sk) + content_h = _content_hash(sk) + + cdsl_sk = { + "id": sk_id, + "name": name, + "workplane": { + "origin_mm": wp.get("origin_mm", [0, 0, 0]), + "x_dir": wp.get("x_dir", [1, 0, 0]), + "y_dir": wp.get("y_dir", [0, 1, 0]), + "normal": wp.get("normal", [0, 0, 1]), + }, + "_content_hash": content_h, + } + if profile: + # 复杂形状回退:保留原始实体和 contour_edges_mm 以保证 exact 路径重建精度 + # 已注册的形状(circle/obround/d_shape 等)直接用 profile + # 015133: 所有形状都用 profile,坐标仅存 compiler_context + cdsl_sk["profile"] = profile + + cdsl_sketches.append(cdsl_sk) + id_to_cdsl_sketch[sk_id] = cdsl_sk + + # ── Phase 2: profile_from for ALL duplicates (015133 standard) ── + hash_to_first: dict[str, str] = {} + dedup_count = 0 + + for cdsl_sk in cdsl_sketches: + ch = cdsl_sk.pop("_content_hash", None) + if not ch: + continue + sk_id = cdsl_sk["id"] + + if ch in hash_to_first: + src_id = hash_to_first[ch] + src_sk = id_to_cdsl_sketch.get(src_id) + if not src_sk: + continue + + src_has_profile = bool(src_sk.get("profile")) + this_has_profile = bool(cdsl_sk.get("profile")) + + src_origin = src_sk.get("workplane", {}).get("origin_mm", [0, 0, 0]) + this_origin = cdsl_sk.get("workplane", {}).get("origin_mm", [0, 0, 0]) + dx = round(this_origin[0] - src_origin[0], 6) + dy = round(this_origin[1] - src_origin[1], 6) + + if src_has_profile and this_has_profile: + # case 1: both have registered profiles -> standard profile_from + cdsl_sk["profile_from"] = src_id + cdsl_sk.pop("profile", None) + cdsl_sk.pop("entities", None) + cdsl_sk.pop("contour_edges_mm", None) + cdsl_sk.pop("workplane", None) # 仅编译器上下文需要,LLM不学 + cdsl_sk.pop("_fallback_reason", None) + if abs(dx) > 1e-6 or abs(dy) > 1e-6: + cdsl_sk["profile_shift"] = [dx, dy] + dedup_count += 1 + + elif not src_has_profile and not this_has_profile: + # case 2: complex shapes - create polygon profile on source + src_ents = src_sk.get("entities", []) + src_contours = src_sk.get("contour_edges_mm", []) + if src_ents or src_contours: + if not src_sk.get("profile"): + vertices = [] + for ce in src_contours: + s = ce.get("start_mm", [0, 0, 0]) + if len(s) >= 2: + vertices.append([float(s[0]), float(s[1])]) + if not vertices: + for e in src_ents: + s = e.get("start", [0, 0]) + if len(s) >= 2: + vertices.append([float(s[0]), float(s[1])]) + if vertices: + src_sk["profile"] = { + "type": "polygon", + "vertices": vertices, + "_from_entities": True, + } + cdsl_sk["profile_from"] = src_id + cdsl_sk.pop("profile", None) + cdsl_sk.pop("entities", None) + cdsl_sk.pop("contour_edges_mm", None) + cdsl_sk.pop("workplane", None) # 仅编译器上下文需要,LLM不学 + cdsl_sk.pop("_fallback_reason", None) + if abs(dx) > 1e-6 or abs(dy) > 1e-6: + cdsl_sk["profile_shift"] = [dx, dy] + dedup_count += 1 + + elif src_has_profile: + # case 3: source has profile, duplicate has only entities + cdsl_sk["profile_from"] = src_id + cdsl_sk.pop("profile", None) + cdsl_sk.pop("entities", None) + cdsl_sk.pop("contour_edges_mm", None) + cdsl_sk.pop("_fallback_reason", None) + if abs(dx) > 1e-6 or abs(dy) > 1e-6: + cdsl_sk["profile_shift"] = [dx, dy] + dedup_count += 1 + else: + hash_to_first[ch] = sk_id + + # ── Phase 3: 构建 CDSL features ── + ops = [op for op in ir.get("operations", []) + if op.get("type") not in ("assembly_compose",)] + + cdsl_features: list[dict[str, Any]] = [] + feature_ids: list[str] = [] + name_to_id: dict[str, str] = {} + + for i, op in enumerate(ops): + fid = f"f{i + 1:02d}" + op_type = op.get("type", "") + sketch_id = op.get("sketch") + params = op.get("parameters", {}) + + # 确定 atomic_id + atomic_id = _map_atomic_id(op_type, params) + + # 构建 CDSL 参数 + cdsl_params = _build_cdsl_params(op, params, op_type, atomic_id, cdsl_features) + + feat: dict[str, Any] = { + "id": fid, + "atomic_id": atomic_id, + "depends_on": list(feature_ids), + "name": op.get("name", fid), + "params": cdsl_params, + } + + if sketch_id: + feat["sketch_id"] = sketch_id + + cdsl_features.append(feat) + feature_ids.append(fid) + name_to_id[op.get("name", "")] = fid + + # 修复 pattern source_features 引用 + for feat in cdsl_features: + if feat["atomic_id"] == "pattern_linear": + raw = op_to_raw(op, feat, ops) + if raw: + src_names = raw.get("source_feature_names", []) + src_ids = [name_to_id.get(n, n) for n in src_names] + feat["params"]["source_feature_ids"] = src_ids + + # ── 清理空草图 (015133: 空参考草图移入compiler_context,不污染Learning IR) ── + empty_ids = set() + for cdsl_sk in cdsl_sketches: + if not cdsl_sk.get("profile") and not cdsl_sk.get("profile_from"): + has_geo = bool(cdsl_sk.get("entities") or cdsl_sk.get("contour_edges_mm")) + if not has_geo: + empty_ids.add(cdsl_sk["id"]) + # 仅移除未被feature引用的空草图 (防止破坏特征引用) + if empty_ids: + ref_ids = set() + for feat in cdsl_features: + sid = feat.get("sketch_id") + if sid and sid in empty_ids: + ref_ids.add(sid) + safe_remove = empty_ids - ref_ids + cdsl_sketches = [s for s in cdsl_sketches if s["id"] not in safe_remove] + + + # 构建 CDSL (不含 compiler_context, LLM 训练可直接加载) + cdsl: dict[str, Any] = { + "schema": "cad.cdsl.llm.v1", + "schema_version": "1.0.0", + "kind": "part", + "part_id": part_id, + "features": cdsl_features, + "geometry": {"sketches": cdsl_sketches}, + "meta": { + "unit": "mm", + "source": "sw_history_distill_llm_v1", + "optimization": "015133-compliant", + "parameterized_sketches": sum(1 for s in cdsl_sketches if s.get("profile")), + "profile_from_sketches": sum(1 for s in cdsl_sketches if s.get("profile_from")), + "from_contour_vertex_count": sum( + 1 for f in cdsl_features + if "revolve" in f["atomic_id"] + and f["params"].get("axis", {}).get("from_contour_vertex") is not None + ), + "raw_sketch_count": sum(1 for s in cdsl_sketches if not s.get("profile") and not s.get("profile_from")), + }, + } + + cdsl = _clean_cdsl_floats(cdsl) + return cdsl + + +def op_to_raw(feat: dict, op: dict, all_ops: list) -> dict | None: + """从 pattern 操作的 raw_parameters 提取数据""" + raw = op.get("raw_parameters", {}) + if not raw: + # 从更早的 feature 中查找 + for o in all_ops: + if o.get("type") == "linear_pattern": + raw = o.get("raw_parameters", {}) + if raw: + break + if not raw: + return None + srcs = raw.get("source_features") or [] + return { + "source_feature_names": [s.get("name", "") for s in srcs if isinstance(s, dict)], + } + + +def _map_atomic_id(op_type: str, params: dict) -> str: + if op_type in ("extrude_add", "extrude_cut"): + rev_dist = params.get("reverse_distance_mm") or 0 + both = params.get("both_directions", False) + if op_type == "extrude_add" and (both or rev_dist > 1e-6): + return "extrude_add_two_sided" + return "extrude_cut_blind" if op_type == "extrude_cut" else "extrude_add_blind" + if op_type in ("revolve_add", "revolve_cut"): + return op_type + if op_type in ("linear_pattern", "pattern_linear"): + return "pattern_linear" + if op_type == "pattern_mirror": + return "pattern_mirror" + if op_type == "shell": + return "shell" + if op_type == "draft": + return "draft" + if op_type == "loft": + return "loft" + if op_type == "dome": + return "dome" + if op_type == "thicken": + return "thicken" + if op_type == "noop": + return "noop" + if op_type == "hole": + if (params.get("countersink_diameter_mm") or 0) > 0: + return "hole_countersink" + if (params.get("counterbore_diameter_mm") or 0) > 0: + return "hole_counterbore" + return "hole_blind" + return op_type + + +def _build_cdsl_params(op: dict, params: dict, op_type: str, atomic_id: str, prev_features: list = None) -> dict: + result = {} + + if "extrude" in op_type: + dist = params.get("distance_mm") or 0 + result["distance_mm"] = float(dist) + rev_dist = params.get("reverse_distance_mm") or 0 + rev = params.get("reverse", False) + both = params.get("both_directions", False) + if both and rev_dist > 1e-6: + result["reverse_distance_mm"] = float(rev_dist) + elif rev: + result["reverse"] = True + + elif "revolve" in op_type: + result["angle_deg"] = float(params.get("angle_deg") or 360) + ax = params.get("axis") or params.get("axis_reference") or {} + direction = ax.get("direction") or [0, 0, 1] + # Phase 3: from_contour_vertex 替代 origin_mm + result["axis"] = {"direction": list(direction)} + origin = ax.get("origin_mm") + if origin: + # 用 from_contour_vertex: 0 替代浮点坐标 + result["axis"]["from_contour_vertex"] = 0 + elif ax.get("from_contour_vertex") is not None: + result["axis"]["from_contour_vertex"] = int(ax["from_contour_vertex"]) + elif ax.get("from_workplane_origin"): + result["axis"]["from_workplane_origin"] = True + else: + result["axis"]["from_contour_vertex"] = 0 + + elif atomic_id == "pattern_linear": + raw = op.get("raw_parameters", {}) + params2 = params + d1_raw = raw.get("direction1") or [1, 0, 0] + d2_raw = raw.get("direction2") or [0, 1, 0] + d1 = d1_raw.get("vector") if isinstance(d1_raw, dict) else d1_raw + d2 = d2_raw.get("vector") if isinstance(d2_raw, dict) else d2_raw + result["pattern_count_1"] = int(raw.get("d1_total_instances") or params2.get("total_instances") or 2) + result["spacing_1_mm"] = float(raw.get("d1_spacing_mm") or params2.get("spacing_mm") or 0) + result["direction_1"] = d1 + result["pattern_count_2"] = int(raw.get("d2_total_instances") or 1) + result["spacing_2_mm"] = float(raw.get("d2_spacing_mm") or 0) + result["direction_2"] = d2 + + elif atomic_id == "pattern_mirror": + raw = op.get("raw_parameters", {}) + src_features = params.get("source_features") or [] + result["source_feature_ids"] = [f.get("name") if isinstance(f, dict) else str(f) for f in src_features] + # 镜像面数据从 C# 插件 raw_parameters 直接获取 + mirror_origin = raw.get("mirror_plane_origin") + mirror_normal = raw.get("mirror_plane_normal") + if mirror_origin and isinstance(mirror_origin, (list, tuple)) and len(mirror_origin) >= 3: + result["mirror_plane_origin_mm"] = [float(v) for v in mirror_origin] + if mirror_normal and isinstance(mirror_normal, (list, tuple)) and len(mirror_normal) >= 3: + result["mirror_plane_normal"] = [float(v) for v in mirror_normal] + + elif atomic_id == "shell": + result["thickness_mm"] = float(params.get("thickness_mm") or 0) + result["faces_to_remove"] = params.get("faces_to_remove") or [] + + elif atomic_id == "draft": + result["angle_deg"] = float(params.get("angle_deg") or 0) + result["draft_type"] = params.get("draft_type", "") + pull_dir = params.get("pull_direction", [0, 0, 1]) + result["pull_direction"] = [float(v) for v in pull_dir] if pull_dir else [0, 0, 1] + + elif atomic_id == "loft": + result["profile_sketches"] = params.get("profile_sketches") or [] + result["is_closed"] = params.get("is_closed", False) + + elif atomic_id == "dome": + result["height_mm"] = float(params.get("height_mm") or 0) + + elif atomic_id == "thicken": + result["thickness_mm"] = float(params.get("thickness_mm") or 0) + + elif atomic_id == "noop": + result["sw_type"] = params.get("sw_type", "") + + elif atomic_id.startswith("hole"): + result["diameter_mm"] = float(params.get("diameter_mm") or 0) + result["depth_mm"] = float(params.get("depth_mm") or 0) + result["positions"] = params.get("positions") or [] + + elif atomic_id == "fillet": + result["radius_mm"] = float(params.get("radius_mm") or 0) + + elif atomic_id == "chamfer": + result["distance_mm"] = float(params.get("distance_mm") or 0) + + return result + + +def _simplified_entities(entities: list[dict]) -> list[dict]: + """简化的实体数据(保留足够的几何信息供 sketch_solver 重建)""" + result = [] + for ent in entities: + s = {"type": ent.get("type", "?")} + if ent.get("type") == "line": + s["start"] = (ent.get("start") or [0, 0])[:2] + s["end"] = (ent.get("end") or [0, 0])[:2] + elif ent.get("type") == "circle": + s["center"] = (ent.get("center") or [0, 0])[:2] + s["radius_mm"] = ent.get("radius_mm") + elif ent.get("type") == "arc": + s["center"] = (ent.get("center") or [0, 0])[:2] + s["start"] = (ent.get("start") or [0, 0])[:2] + s["end"] = (ent.get("end") or [0, 0])[:2] + s["radius_mm"] = ent.get("radius_mm") + s["is_circle"] = ent.get("is_circle", False) + if ent.get("construction"): + s["construction"] = True + result.append(s) + return result + + +# ═══════════════════════════════════════════════════════════════ +# CLI +# ═══════════════════════════════════════════════════════════════ + +def write_cdsl_outputs( + cdsl: dict[str, Any], + out_dir: Path, + *, + sw_data: dict[str, Any] | None = None, + write_compiler_context: bool = False, +) -> dict[str, Path]: + """写入 CDSL;可选写入 compiler_context(仅调试,非 cdsl_only 验收依赖)。""" + out_dir = Path(out_dir) + out_dir.mkdir(parents=True, exist_ok=True) + part_id = str(cdsl.get("part_id") or "part") + cdsl_clean = _clean_cdsl_floats(cdsl) + cdsl_path = out_dir / f"{part_id}.cdsl.json" + cdsl_path.write_text(json.dumps(cdsl_clean, ensure_ascii=False, indent=2, default=str), encoding="utf-8") + paths: dict[str, Path] = {"cdsl": cdsl_path} + if write_compiler_context and sw_data is not None: + ir = normalize_to_ir(sw_data) + ctx_data = { + "version": ir.get("version", "ir-0.1"), + "metadata": ir.get("metadata", {}), + "sketches": ir.get("sketches", []), + "operations": ir.get("operations", []), + "references": ir.get("references", []), + "validation_hints": ir.get("validation_hints", {}), + "rebuild_contract": ir.get("rebuild_contract", {}), + } + ctx_path = out_dir / f"{part_id}.compiler_context.json" + ctx_path.write_text(json.dumps(ctx_data, ensure_ascii=False, indent=2, default=str), encoding="utf-8") + paths["compiler_context"] = ctx_path + return paths + + +def main(): + import argparse + from collections import Counter + ap = argparse.ArgumentParser(description="SW JSON → Parameterized CDSL") + ap.add_argument("sw_json", type=Path) + ap.add_argument("--out", "-o", type=Path, required=True, help="输出目录") + ap.add_argument("--part-id", type=str, default=None) + ap.add_argument("--write-context", action="store_true", help="额外写出 compiler_context(调试用)") + args = ap.parse_args() + + sw_data = json.loads(args.sw_json.read_text(encoding="utf-8")) + cdsl = convert_sw_json_to_cdsl(args.sw_json, args.part_id) + paths = write_cdsl_outputs( + cdsl, args.out, sw_data=sw_data, write_compiler_context=args.write_context + ) + + meta = cdsl["meta"] + print(f"Parameterized CDSL → {paths['cdsl']}") + print(f" features: {len(cdsl['features'])}, sketches: {len(cdsl['geometry']['sketches'])}") + print(f" parameterized: {meta['parameterized_sketches']}, profile_from: {meta['profile_from_sketches']}") + print(f" from_contour_vertex: {meta['from_contour_vertex_count']}") + ac = Counter(f["atomic_id"] for f in cdsl["features"]) + for aid, cnt in sorted(ac.items()): + print(f" {aid}: {cnt}") + if "compiler_context" in paths: + print(f" compiler_context → {paths['compiler_context']} (debug only)") + + +if __name__ == "__main__": + main() diff --git a/backend/engine/cdsl_engine/distill_output3.py b/backend/engine/cdsl_engine/distill_output3.py new file mode 100644 index 00000000..41d2a7cf --- /dev/null +++ b/backend/engine/cdsl_engine/distill_output3.py @@ -0,0 +1,349 @@ +"""将 output3 的程序化圆柱样本蒸馏为自足的短 CDSL。 + +这些样本的 SolidWorks 历史把每个切口存成独立草图/切除特征。本脚本按 +文件名中的设计族选择一个可复用的 motif + layout 语义描述;不会复制 +草图 entities、逐切口坐标、compiler_context 或任何编码后的几何。 +""" + +from __future__ import annotations + +import argparse +import json +import re +from pathlib import Path +from typing import Any + + +SPECS: dict[str, dict[str, Any]] = { + "cylinder_001": { + "motif": {"type": "annular_sector_polygon", "inner_radius_mm": 16.0, "outer_radius_mm": 25.0, "half_angle_deg": 4.583662}, + "layout": {"type": "angular", "count": 12, "start_angle_deg": 15.0, "orientation": "radial"}, + }, + "cylinder_002": { + "motif": {"type": "annular_sector_polygon", "inner_radius_mm": 8.0, "outer_radius_mm": 12.5, "half_angle_deg": 4.583662}, + "layout": {"type": "angular", "count": 12, "start_angle_deg": 15.0, "orientation": "radial"}, + }, + "cylinder_003": { + "motif": {"type": "circle", "radius_mm": 2.4}, + "layout": {"type": "ring", "radius_mm": 34.0, "count": 32}, + }, + "cylinder_004": { + "motif": {"type": "square", "width_mm": 3.3}, + "layout": {"type": "ring", "radius_mm": 32.0, "count": 24}, + }, + "cylinder_005": { + "motif": {"type": "square", "width_mm": 4.242641}, + "layout": {"type": "ring", "radius_mm": 32.0, "count": 24}, + }, + "cylinder_006": { + "motif": {"type": "circle", "radius_mm": 2.2}, + "layout": { + "type": "disc_grid", "count_x": 7, "count_y": 7, + "spacing_x_mm": 10.0, "spacing_y_mm": 10.0, + "center_mm": [0.0, 0.0], "max_center_radius_mm": 36.1, + }, + }, + "cylinder_007": { + "motif": {"type": "annular_sector_polygon", "inner_radius_mm": 12.0, "outer_radius_mm": 20.0, "half_angle_deg": 4.583662}, + "layout": { + "type": "concentric_rings", "orientation": "radial", + "rings": [ + {"type": "angular", "count": 12, "start_angle_deg": 15.0}, + {"type": "angular", "count": 24, "start_angle_deg": 7.5}, + ], + }, + "ring_motifs": [ + {"inner_radius_mm": 12.0, "outer_radius_mm": 20.0, "half_angle_deg": 4.583662, "count": 12, "start_angle_deg": 15.0}, + {"inner_radius_mm": 28.0, "outer_radius_mm": 40.0, "half_angle_deg": 2.864789, "count": 24, "start_angle_deg": 7.5}, + ], + }, + "cylinder_008": { + "motif": {"type": "obround", "length_mm": 7.0, "width_mm": 2.8}, + "layout": {"type": "ring", "radius_mm": 34.0, "count": 24, "orientation": "radial"}, + }, + "cylinder_009": { + "motif": {"type": "annular_sector_polygon", "inner_radius_mm": 10.0, "outer_radius_mm": 42.0, "half_angle_deg": 3.437747}, + "layout": {"type": "angular", "count": 12, "start_angle_deg": 15.0, "orientation": "radial"}, + }, + "cylinder_010": { + "motif": {"type": "circle", "radius_mm": 2.3}, + "layout": { + "type": "concentric_rings", + "rings": [ + {"radius_mm": 18.0, "count": 12, "start_angle_deg": 0.0}, + {"radius_mm": 36.0, "count": 24, "start_angle_deg": 0.0}, + ], + }, + }, + "cylinder_011": { + "motif": {"type": "annular_sector_polygon", "inner_radius_mm": 10.0, "outer_radius_mm": 20.0, "half_angle_deg": 4.010705}, + "layout": {"type": "angular", "count": 8, "start_angle_deg": 22.5, "orientation": "radial"}, + }, + "cylinder_012": { + "motif": {"type": "circle", "radius_mm": 1.9}, + "layout": {"type": "ring", "radius_mm": 26.0, "count": 24}, + }, + "cylinder_013": { + "motif": {"type": "square", "width_mm": 2.7}, + "layout": {"type": "ring", "radius_mm": 25.0, "count": 16}, + }, + "cylinder_014": { + "motif": {"type": "square", "width_mm": 3.535534}, + "layout": {"type": "ring", "radius_mm": 25.0, "count": 16}, + }, + "cylinder_015": { + "motif": {"type": "circle", "radius_mm": 1.8}, + "layout": { + "type": "disc_grid", "count_x": 6, "count_y": 5, + "spacing_x_mm": 9.0, "spacing_y_mm": 9.0, + "center_mm": [0.0, 0.0], + }, + }, + "cylinder_016": { + "motif": {"type": "d_shape_polygon", "stem_length_mm": 1.9, "nose_depth_mm": 1.9, "half_height_mm": 1.3775}, + "layout": { + "type": "open_arc", "radius_mm": 35.0, "count": 18, + "start_angle_deg": 18.0, "end_angle_deg": 262.8, + }, + }, + "cylinder_017": { + "motif": {"type": "cross", "size_mm": 4.62, "arm_width_mm": 2.9568}, + "layout": { + "type": "open_arc", "radius_mm": 31.0, "count": 14, + "start_angle_deg": 189.0, "end_angle_deg": 387.0, + }, + }, + "cylinder_018": { + "motif": {"type": "obround", "length_mm": 5.75, "width_mm": 2.3}, + "layout": {"type": "ring", "radius_mm": 27.0, "count": 16, "orientation": "radial"}, + }, + "cylinder_019": { + "motif": {"type": "circle", "radius_mm": 1.8}, + "layout": { + "type": "concentric_rings", + "rings": [ + {"radius_mm": 14.0, "count": 8, "start_angle_deg": 0.0}, + {"radius_mm": 30.0, "count": 16, "start_angle_deg": 0.0}, + ], + }, + }, + "cylinder_020": { + "motif": {"type": "annular_sector_polygon", "inner_radius_mm": 9.0, "outer_radius_mm": 36.0, "half_angle_deg": 3.151268}, + "layout": {"type": "angular", "count": 10, "start_angle_deg": 18.0, "orientation": "radial"}, + }, + "cylinder_021": { + "motif": {"type": "annular_sector_polygon", "inner_radius_mm": 12.0, "outer_radius_mm": 20.0, "half_angle_deg": 4.010705}, + "layout": {"type": "angular", "count": 8, "start_angle_deg": 22.5, "orientation": "radial"}, + "ring_motifs": [ + {"inner_radius_mm": 12.0, "outer_radius_mm": 20.0, "half_angle_deg": 4.010705, "count": 8, "start_angle_deg": 22.5}, + {"inner_radius_mm": 28.0, "outer_radius_mm": 39.0, "half_angle_deg": 2.57831, "count": 16, "start_angle_deg": 11.25}, + ], + }, + "cylinder_022": { + "motif": {"type": "skew_hexagon", "nominal_radius_mm": 1.875}, + "layout": { + "type": "spiral", "count": 28, "start_radius_mm": 8.054, + "radius_step_mm": 0.851, "start_angle_deg": -0.105, "angle_step_deg": 33.2394, + "orientation": "snapped_radial", "orientation_snap_deg": 45.0, + "orientation_offset_deg": 0.0, + }, + }, + "cylinder_023": { + "motif": {"type": "skew_hexagon", "nominal_radius_mm": 1.9}, + "layout": { + "type": "cross_lines", "count_per_axis": 6, "spacing_mm": 13.6, + "orientation_offset_deg": 0.0, + }, + }, + "cylinder_024": { + "motif": {"type": "triangle", "radius_mm": 2.3}, + "layout": { + "type": "x_field", "levels": 5, "spacing_mm": 11.0, + "orientation": "diagonal_axes", + }, + }, + "cylinder_025": { + "motif": { + "type": "teardrop_polygon", + "left_width_mm": 1.116, "right_width_mm": 1.548, + "tip_height_mm": 2.79, "bottom_depth_mm": 1.476, + "shoulder_height_mm": 1.242, + }, + "layout": { + "type": "twin_strips", "x_offset_mm": 15.0, "count_y": 12, + "y_start_mm": -34.0, "y_end_mm": 34.0, + }, + }, + "cylinder_026": { + "motif": {"type": "trapezoid", "bottom_width_mm": 4.32, "top_width_mm": 2.376, "height_mm": 3.24}, + "layout": { + "type": "concentric_rings", "orientation": "radial", + "rings": [ + {"radius_mm": 12.0, "count": 8, "start_angle_deg": 0.0}, + {"radius_mm": 31.0, "count": 16, "start_angle_deg": 0.0}, + ], + }, + }, + "cylinder_027": { + "motif": {"type": "d_shape_polygon", "stem_length_mm": 1.8, "nose_depth_mm": 1.8, "half_height_mm": 1.305}, + "layout": {"type": "corner_clusters", "levels_mm": [12.0, 18.5, 25.0]}, + }, + "cylinder_028": { + "motif": {"type": "square", "width_mm": 2.969848}, + "layout": {"type": "diamond_field", "manhattan_radius": 3, "spacing_mm": 8.0}, + }, + "cylinder_029": { + "motif": {"type": "annular_sector_polygon", "inner_radius_mm": 11.0, "outer_radius_mm": 22.0, "half_angle_deg": 4.010705}, + "layout": {"type": "angular", "count": 10, "start_angle_deg": 18.0, "orientation": "radial"}, + }, +} + + +def _pattern_sketches(spec: dict[str, Any]) -> list[dict[str, Any]]: + base = { + "id": "sketch_001", + "name": "草图1", + "workplane": { + "origin_mm": [0.0, 0.0, 0.0], + "x_dir": [1.0, 0.0, 0.0], + "y_dir": [0.0, 1.0, 0.0], + "normal": [0.0, 0.0, 1.0], + }, + "profile": {"type": "circle", "center": [0.0, 0.0], "radius_mm": 50.0}, + } + cut_workplane = { + "origin_mm": [0.0, 0.0, 20.0], + "x_dir": [1.0, 0.0, 0.0], + "y_dir": [0.0, 1.0, 0.0], + "normal": [0.0, 0.0, 1.0], + } + + ring_motifs = spec.get("ring_motifs") + if not ring_motifs: + cut = { + "id": "sketch_002", + "name": "图案草图", + "workplane": cut_workplane, + "profile": { + "type": "patterned_cutouts", + "motif": spec["motif"], + "layout": spec["layout"], + }, + } + return [base, cut] + + # 两组扇区的内外半径不同,仍合并成一个切除特征;组合只包含 + # 两个有名字的程序化子图案,不保存任何逐实例坐标。 + cut = { + "id": "sketch_002", + "name": "双环图案草图", + "workplane": cut_workplane, + "profile": { + "type": "compound_patterned_cutouts", + "patterns": [ + { + "motif": { + "type": "annular_sector_polygon", + "inner_radius_mm": item["inner_radius_mm"], + "outer_radius_mm": item["outer_radius_mm"], + "half_angle_deg": item["half_angle_deg"], + }, + "layout": { + "type": "angular", + "count": item["count"], + "start_angle_deg": item["start_angle_deg"], + "orientation": "radial", + }, + } + for item in ring_motifs + ], + }, + } + return [base, cut] + + +def make_cdsl(source: Path, spec: dict[str, Any]) -> dict[str, Any]: + part_id = source.name.removesuffix(".solidworks_evidence_v2.json") + return { + "schema": "cad.cdsl.llm.v1", + "schema_version": "1.0.0", + "kind": "part", + "part_id": part_id, + "features": [ + { + "id": "f01", + "atomic_id": "extrude_add_blind", + "depends_on": [], + "name": "凸台-拉伸1", + "params": {"distance_mm": 20.0}, + "sketch_id": "sketch_001", + }, + { + "id": "f02", + "atomic_id": "extrude_cut_blind", + "depends_on": ["f01"], + "name": "程序化图案切除", + "params": {"distance_mm": 8.0, "reverse": True}, + "sketch_id": "sketch_002", + }, + ], + "geometry": {"sketches": _pattern_sketches(spec)}, + "meta": { + "source": source.name, + "parameterized_sketches": 2, + "profile_from_sketches": 0, + "notes": [ + "CDSL-only drawable: repeated source sketches are represented by one procedural motif and layout", + "No compiler_context, source entities, per-instance coordinates, or encoded geometry", + ], + }, + } + + +def distill(input_dir: Path, output_dir: Path) -> list[Path]: + sources = sorted(input_dir.glob("cylinder_*.solidworks_evidence_v2.json")) + written: list[Path] = [] + missing: list[str] = [] + for source in sources: + key = source.name[:12] + spec = SPECS.get(key) + if spec is None: + missing.append(source.name) + continue + part_id = source.name.removesuffix(".solidworks_evidence_v2.json") + part_dir = output_dir / part_id + part_dir.mkdir(parents=True, exist_ok=True) + out = part_dir / f"{part_id}.cdsl.json" + text = json.dumps(make_cdsl(source, spec), ensure_ascii=False, indent=2) + # 与 b005/b006 一致:短标量数组保持单行,结构数组仍按层级展开。 + scalar_array = re.compile( + r"\[\n(?P(?:[ \t]+(?:-?\d+(?:\.\d+)?|true|false|null|\"[^\"\\n]*\"),?\n)+)[ \t]*\]" + ) + + def compact(match: re.Match[str]) -> str: + values = [line.strip().rstrip(",") for line in match.group("body").splitlines()] + inline = "[" + ", ".join(values) + "]" + return inline if len(inline) <= 100 else match.group(0) + + text = scalar_array.sub(compact, text) + out.write_text( + text + "\n", + encoding="utf-8", + ) + written.append(out) + if missing: + raise RuntimeError("No semantic specification for: " + ", ".join(missing)) + return written + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("input_dir", type=Path) + parser.add_argument("--out", type=Path, required=True) + args = parser.parse_args() + paths = distill(args.input_dir, args.out) + print(f"wrote {len(paths)} CDSL files to {args.out}") + + +if __name__ == "__main__": + main() diff --git a/backend/engine/cdsl_engine/llm_compiler.py b/backend/engine/cdsl_engine/llm_compiler.py new file mode 100644 index 00000000..75d8a7ac --- /dev/null +++ b/backend/engine/cdsl_engine/llm_compiler.py @@ -0,0 +1,286 @@ +"""通用编译器:瘦 CDSL → build_pack;线性阵列在此展开为重复步骤。""" + +from __future__ import annotations + +import json +from copy import deepcopy +from pathlib import Path +from typing import Any + +try: + from .sketch_solver import resolve_all_sketches +except ImportError: + from sketch_solver import resolve_all_sketches + + +REQUIRED = { + "revolve_add": ["angle_deg", "axis"], + "revolve_cut": ["angle_deg", "axis"], + "extrude_add_blind": ["distance_mm"], + "extrude_add_two_sided": ["distance_mm"], + "extrude_cut_blind": ["distance_mm"], + "hole_blind": ["diameter_mm", "depth_mm"], + "hole_countersink": ["diameter_mm", "depth_mm"], + "hole_counterbore": ["diameter_mm", "depth_mm"], +} + + +def _load(path: Path) -> dict[str, Any]: + return json.loads(path.read_text(encoding="utf-8")) + + +def _offset_sketch(sketch: dict[str, Any] | None, dx: float, dy: float, dz: float) -> dict[str, Any] | None: + if sketch is None: + return None + s = deepcopy(sketch) + wp = s.get("workplane") or {} + o = list(wp.get("origin_mm") or [0, 0, 0]) + wp["origin_mm"] = [o[0] + dx, o[1] + dy, o[2] + dz] + s["workplane"] = wp + edges = [] + for e in s.get("contour_edges_mm") or []: + ne = deepcopy(e) + for key in ("start_mm", "end_mm", "center_mm"): + if key in ne: + p = ne[key] + ne[key] = [p[0] + dx, p[1] + dy, p[2] + dz] + edges.append(ne) + if edges: + s["contour_edges_mm"] = edges + # 2D entities: shift in plane if offset has in-plane components only — skip for world offset patterns + return s + + +def _offset_params_positions(params: dict[str, Any], dx: float, dy: float, dz: float) -> dict[str, Any]: + p = deepcopy(params) + if "positions" in p: + for pos in p["positions"]: + mm = pos.get("mm") + if mm: + pos["mm"] = [mm[0] + dx, mm[1] + dy, mm[2] + dz] + if "axis" in p and isinstance(p["axis"], dict): + o = list(p["axis"].get("origin_mm") or [0, 0, 0]) + p["axis"]["origin_mm"] = [o[0] + dx, o[1] + dy, o[2] + dz] + return p + + +def compile_cdsl( + cdsl: dict[str, Any], + atoms_catalog: dict[str, Any] | None = None, + techniques_catalog: dict[str, Any] | None = None, +) -> dict[str, Any]: + allowed = set() + if atoms_catalog: + allowed = {a["atomic_id"] for a in atoms_catalog.get("atoms") or []} + techniques = { + item["technique_id"]: item + for item in (techniques_catalog or {}).get("techniques") or [] + } + + sketches = {s["id"]: s for s in (cdsl.get("geometry") or {}).get("sketches") or []} + + # 参数化轮廓求解:将 profile 字段展开为精确的 entities + contour_edges_mm + cdsl = resolve_all_sketches(cdsl) + sketches = {s["id"]: s for s in (cdsl.get("geometry") or {}).get("sketches") or []} + steps: list[dict[str, Any]] = [] + seen_ids: set[str] = set() + # feature_id -> list of emitted step dicts (for pattern source) + emitted: dict[str, list[dict[str, Any]]] = {} + + def emit(feature: dict[str, Any], params: dict[str, Any], sketch: dict[str, Any] | None, step_id: str) -> dict[str, Any]: + atomic = feature["atomic_id"] + if allowed and atomic not in allowed: + raise ValueError(f"{step_id}: atomic_id {atomic!r} is not admitted by catalog") + for dep in feature.get("depends_on") or []: + if dep not in seen_ids and not any(dep in emitted): + # dependency may be ok if earlier + if dep not in seen_ids: + raise ValueError(f"{step_id}: depends_on {dep} not yet defined") + step = { + "step_id": step_id, + "atomic_id": atomic, + "depends_on": list(feature.get("depends_on") or []), + "params": params, + "sketch": sketch, + "source_name": feature.get("name"), + } + steps.append(step) + seen_ids.add(step_id) + return step + + for feat in cdsl.get("features") or []: + fid = feat["id"] + atomic = feat.get("atomic_id") + technique_id = feat.get("technique_id") + if technique_id: + technique = techniques.get(technique_id) + if technique is None: + raise ValueError(f"{fid}: technique_id {technique_id!r} is not admitted by catalog") + groups = feat.get("params") or {} + expanded: list[dict[str, Any]] = [] + previous_step_id: str | None = None + for index, internal in enumerate(technique.get("internal_steps") or [], start=1): + group_name = internal.get("params_from") + group = deepcopy(groups.get(group_name) or {}) + if not isinstance(group, dict): + raise ValueError(f"{fid}: parameter group {group_name!r} must be an object") + params = deepcopy(group.get("params") if isinstance(group.get("params"), dict) else group) + sketch_id = group.get("sketch_id") or params.pop("sketch_id", None) + sketch = deepcopy(sketches[sketch_id]) if sketch_id and sketch_id in sketches else None + internal_atomic = internal.get("atomic_id") + if not internal_atomic: + raise ValueError(f"{fid}: technique {technique_id!r} has an invalid internal step") + for key in REQUIRED.get(internal_atomic, []): + if params.get(key) is None: + raise ValueError( + f"{fid}: technique {technique_id!r} group {group_name!r} missing {key}" + ) + internal_feature = { + "atomic_id": internal_atomic, + "depends_on": [previous_step_id] if previous_step_id else list(feat.get("depends_on") or []), + "name": f"{feat.get('name') or technique_id}:{group_name or index}", + } + step_id = f"{fid}.t{index}" + expanded.append(emit(internal_feature, params, sketch, step_id)) + previous_step_id = step_id + if len(expanded) < 2: + raise ValueError(f"{fid}: technique {technique_id!r} must expand to at least two steps") + emitted[fid] = expanded + seen_ids.add(fid) + continue + if not atomic: + raise ValueError(f"{fid}: missing atomic_id") + + if atomic == "pattern_linear": + params = feat.get("params") or {} + src_ids = params.get("source_feature_ids") or [] + c1 = int(params.get("pattern_count_1") or 1) + c2 = int(params.get("pattern_count_2") or 1) + s1 = float(params.get("spacing_1_mm") or 0) + s2 = float(params.get("spacing_2_mm") or 0) + d1 = params.get("direction_1") or [1, 0, 0] + d2 = params.get("direction_2") or [0, 1, 0] + if params.get("direction_1_reverse"): + d1 = [-d1[0], -d1[1], -d1[2]] + if params.get("direction_2_reverse"): + d2 = [-d2[0], -d2[1], -d2[2]] + + src_steps: list[dict[str, Any]] = [] + for sid in src_ids: + src_steps.extend(emitted.get(sid) or []) + if not src_steps: + # 无源则跳过并记录 + steps.append( + { + "step_id": fid, + "atomic_id": "noop_pattern", + "depends_on": list(feat.get("depends_on") or []), + "params": params, + "sketch": None, + "note": "pattern source steps missing", + } + ) + seen_ids.add(fid) + continue + + clone_steps = [] + k = 0 + for i in range(c1): + for j in range(c2): + if i == 0 and j == 0: + continue + dx = d1[0] * s1 * i + d2[0] * s2 * j + dy = d1[1] * s1 * i + d2[1] * s2 * j + dz = d1[2] * s1 * i + d2[2] * s2 * j + for src in src_steps: + k += 1 + clone_id = f"{fid}.p{k}" + fake_feat = { + "atomic_id": src["atomic_id"], + "depends_on": [steps[-1]["step_id"]] if steps else [], + "name": f"{src.get('source_name')}_pattern", + } + st = emit( + fake_feat, + _offset_params_positions(src["params"], dx, dy, dz), + _offset_sketch(src.get("sketch"), dx, dy, dz), + clone_id, + ) + clone_steps.append(st) + emitted[fid] = clone_steps + seen_ids.add(fid) + continue + + params = deepcopy(feat.get("params") or {}) + sketch_id = feat.get("sketch_id") or params.get("sketch_id") + sketch = deepcopy(sketches[sketch_id]) if sketch_id and sketch_id in sketches else None + if sketch_id: + params["sketch_id"] = sketch_id + + # Auto-derive revolve axis origin + if "revolve" in atomic and sketch and "axis" in params: + ax = params.get("axis") or {} + # 优先级: from_workplane_origin > from_contour_vertex > origin_mm 裸坐标 + wp = sketch.get("workplane") or {} + wp_origin = wp.get("origin_mm") or [0.0, 0.0, 0.0] + + if ax.get("from_workplane_origin") and "origin_mm" not in ax: + params["axis"] = deepcopy(params["axis"]) + params["axis"]["origin_mm"] = list(wp_origin) + elif "origin_mm" not in ax: + ce = sketch.get("contour_edges_mm") or [] + if ce: + idx = int(ax.get("from_contour_vertex", 0)) + vertex = ce[idx % len(ce)]["start_mm"] + params["axis"] = deepcopy(params["axis"]) + params["axis"]["origin_mm"] = list(vertex) + + for key in REQUIRED.get(atomic, []): + if key == "axis" and "axis" not in params: + raise ValueError(f"{fid}: missing axis") + if key not in ("axis",) and params.get(key) is None and key != "sketch_id": + # positions can be empty temporarily + if key in params: + continue + if key in ("diameter_mm", "depth_mm", "distance_mm", "angle_deg") and params.get(key) is None: + raise ValueError(f"{fid}: missing {key}") + + st = emit(feat, params, sketch, fid) + emitted[fid] = [st] + + # filter noop + steps = [s for s in steps if s.get("atomic_id") != "noop_pattern"] + + return { + "schema": "cad.engine_plan.v1", + "part_id": cdsl.get("part_id"), + "unit": "mm", + "steps": steps, + "compiler_context": deepcopy(cdsl.get("compiler_context")), + "meta": { + "from_cdsl_schema": cdsl.get("schema"), + "compiler": "cad-heard.llm_compiler.v1", + "n_steps": len(steps), + }, + } + + +def main() -> None: + import argparse + + ap = argparse.ArgumentParser() + ap.add_argument("--cdsl", type=Path, required=True) + ap.add_argument("--catalog", type=Path, default=None) + ap.add_argument("--techniques", type=Path, default=None) + ap.add_argument("--out", type=Path, required=True) + args = ap.parse_args() + catalog = _load(args.catalog) if args.catalog else None + techniques = _load(args.techniques) if args.techniques else None + pack = compile_cdsl(_load(args.cdsl), catalog, techniques) + args.out.parent.mkdir(parents=True, exist_ok=True) + args.out.write_text(json.dumps(pack, ensure_ascii=False, indent=2), encoding="utf-8") + print(f"wrote {args.out} steps={len(pack['steps'])}") + + +if __name__ == "__main__": + main() diff --git a/backend/engine/cdsl_engine/llm_engine.py b/backend/engine/cdsl_engine/llm_engine.py new file mode 100644 index 00000000..aa5c3608 --- /dev/null +++ b/backend/engine/cdsl_engine/llm_engine.py @@ -0,0 +1,500 @@ +"""build123d 绘图引擎:执行 build_pack → STEP。""" + +from __future__ import annotations + +import builtins +import json +import math +import subprocess +import sys +from pathlib import Path +from typing import Any + +# 保留内置 float,防止被 build123d 上下文 shadow +_f = builtins.float + +from build123d import ( # noqa: E402 + Align, + Axis, + BuildPart, + BuildSketch, + Circle, + Cone, + Cylinder, + Edge, + Face, + Location, + Locations, + Mode, + Plane, + Polygon, + Vector, + Wire, + export_step, + extrude, + import_step, + revolve, +) + + +def _load(path: Path) -> dict[str, Any]: + return json.loads(path.read_text(encoding="utf-8")) + + +def _plane_from_workplane(wp: dict[str, Any]) -> Plane: + o = wp.get("origin_mm") or [0, 0, 0] + x = wp.get("x_dir") or [1, 0, 0] + n = wp.get("normal") or [0, 0, 1] + return Plane( + origin=Vector(_f(o[0]), _f(o[1]), _f(o[2])), + x_dir=Vector(_f(x[0]), _f(x[1]), _f(x[2])), + z_dir=Vector(_f(n[0]), _f(n[1]), _f(n[2])), + ) + + +def _axis_from_params(axis: dict[str, Any]) -> Axis: + o = axis.get("origin_mm") or [0, 0, 0] + d = axis.get("direction") or [1, 0, 0] + return Axis( + origin=Vector(_f(o[0]), _f(o[1]), _f(o[2])), + direction=Vector(_f(d[0]), _f(d[1]), _f(d[2])), + ) + + +def _ordered_profile_points(sketch: dict[str, Any]) -> list[tuple[float, float]]: + entities = sketch.get("entities") or [] + line_loop = [ + i for i, e in enumerate(entities) if e["type"] == "line" and not e.get("construction") + ] + if not line_loop: + raise ValueError(f"sketch {sketch.get('id')}: no profile lines") + pts: list[tuple[float, float]] = [] + for i in line_loop: + e = entities[i] + s = (_f(e["start"][0]), _f(e["start"][1])) + en = (_f(e["end"][0]), _f(e["end"][1])) + if not pts: + pts.append(s) + if abs(pts[-1][0] - s[0]) + abs(pts[-1][1] - s[1]) > 1e-4: + if abs(pts[-1][0] - en[0]) + abs(pts[-1][1] - en[1]) <= 1e-4: + s, en = en, s + else: + pts.append(s) + pts.append(en) + if abs(pts[0][0] - pts[-1][0]) + abs(pts[0][1] - pts[-1][1]) > 1e-4: + pts.append(pts[0]) + return pts + + +def _face_from_contour_edges(edges_mm: list[dict[str, Any]], *, desired_normal: list[float] | None = None) -> Face: + b123_edges: list[Edge] = [] + for e in edges_mm: + p1 = Vector(*e["start_mm"]) + p2 = Vector(*e["end_mm"]) + if e.get("type") == "arc" and e.get("center_mm") and e.get("radius_mm") is not None: + center = Vector(*e["center_mm"]) + r = _f(e["radius_mm"]) + v1 = p1 - center + v2 = p2 - center + if v1.length < 1e-9 or v2.length < 1e-9: + b123_edges.append(Edge.make_line(p1, p2)) + continue + n = Vector(*(e.get("normal") or [0, 0, 1])) + if n.length < 1e-9: + n = v1.cross(v2) + if n.length < 1e-9: + n = Vector(0, 0, 1) + n = n.normalized() + v1n = v1.normalized() * r + v2n = v2.normalized() * r + bis = v1n + v2n + if bis.length < 1e-9: + bis = n.cross(v1n) + mid = center + bis.normalized() * r + try: + b123_edges.append(Edge.make_three_point_arc(p1, mid, p2)) + except Exception: + b123_edges.append(Edge.make_line(p1, p2)) + else: + b123_edges.append(Edge.make_line(p1, p2)) + face = Face(Wire(b123_edges)) + if desired_normal is not None: + dn = Vector(*desired_normal) + if dn.length > 1e-9: + fn = face.normal_at() + if fn.dot(dn) < 0: + # 重建反转的 Wire:边顺序反转 + 每条边起止点交换 + # 这样法向自然翻转,但每条边的几何方向不变(不同于 Face.Reversed) + rev_edges: list[Edge] = [] + for e in reversed(edges_mm): + p1 = Vector(*e["end_mm"]) + p2 = Vector(*e["start_mm"]) + if e.get("type") == "arc" and e.get("center_mm") and e.get("radius_mm") is not None: + center = Vector(*e["center_mm"]) + r = _f(e["radius_mm"]) + v1 = p1 - center + v2 = p2 - center + if v1.length < 1e-9 or v2.length < 1e-9: + rev_edges.append(Edge.make_line(p1, p2)) + continue + n = Vector(*(e.get("normal") or [0, 0, 1])) + if n.length < 1e-9: + n = v1.cross(v2) + if n.length < 1e-9: + n = Vector(0, 0, 1) + n = n.normalized() + v1n = v1.normalized() * r + v2n = v2.normalized() * r + bis = v1n + v2n + if bis.length < 1e-9: + bis = n.cross(v1n) + mid = center + bis.normalized() * r + try: + rev_edges.append(Edge.make_three_point_arc(p1, mid, p2)) + except Exception: + rev_edges.append(Edge.make_line(p1, p2)) + else: + rev_edges.append(Edge.make_line(p1, p2)) + face = Face(Wire(rev_edges)) + return face + + +def _amount(params: dict[str, Any], *, prefer_sign: str | None = None) -> float: + dist = abs(_f(params["distance_mm"])) + if prefer_sign == "plus": + return dist + if prefer_sign == "minus": + return -dist + return -dist if bool(params.get("reverse")) else dist + + +def _build_nested_circle_profiles(circles: list[dict[str, Any]]) -> None: + """Build circular islands and holes from containment parity. + + A circle contained by one larger circle is a hole; a circle contained by + two larger circles is an island again. This preserves annular profiles + without storing the heavy tessellated sketch regions from the SW export. + """ + ordered = sorted(circles, key=lambda item: _f(item["radius_mm"]), reverse=True) + tolerance = 1e-6 + for index, circle in enumerate(ordered): + center = circle["center"] + radius = _f(circle["radius_mm"]) + containing = 0 + for outer in ordered[:index]: + outer_center = outer["center"] + outer_radius = _f(outer["radius_mm"]) + distance = math.hypot( + _f(center[0]) - _f(outer_center[0]), + _f(center[1]) - _f(outer_center[1]), + ) + if distance + radius <= outer_radius + tolerance: + containing += 1 + mode = Mode.ADD if containing % 2 == 0 else Mode.SUBTRACT + with Locations((_f(center[0]), _f(center[1]))): + Circle(radius, mode=mode) + + +def run_engine_plan( + pack: dict[str, Any], + out_step: Path, + *, + cut_sign: str = "from_params", +) -> dict[str, Any]: + log: list[str] = [] + + compiler_context = pack.get("compiler_context") + if isinstance(compiler_context, dict): + # 回退路径:使用本包 translator(不依赖外部 backend.src) + try: + from .translator import generate_build123d_code, get_part_name + except ImportError: + from translator import generate_build123d_code, get_part_name + + context = dict(compiler_context) + context.setdefault("metadata", {})["part_name"] = str(pack.get("part_id") or out_step.stem) + out_step.parent.mkdir(parents=True, exist_ok=True) + completed = subprocess.run( + [sys.executable, "-c", generate_build123d_code(context)], + cwd=out_step.parent, + capture_output=True, + text=True, + timeout=180, + ) + if completed.returncode != 0: + raise RuntimeError( + f"exact compiler execution failed\nSTDOUT:\n{completed.stdout}\nSTDERR:\n{completed.stderr}" + ) + generated_name = get_part_name({"part_name": context["metadata"]["part_name"]}) + generated = out_step.parent / f"{generated_name}.step" + if generated != out_step and generated.exists(): + generated.replace(out_step) + if not out_step.exists(): + raise RuntimeError(f"exact compiler did not generate {out_step}") + solid = import_step(str(out_step)) + bb = solid.bounding_box() + return { + "out_step": str(out_step), + "volume_mm3": _f(solid.volume), + "bbox_mm": { + "min": [bb.min.X, bb.min.Y, bb.min.Z], + "max": [bb.max.X, bb.max.Y, bb.max.Z], + }, + "engine": "translator_fallback", + } + + with BuildPart() as part: + for step in pack.get("steps") or []: + atomic = step["atomic_id"] + params = step["params"] + sketch = step.get("sketch") + sid = step.get("step_id") + + if atomic in ("extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind"): + if sketch is None: + raise ValueError(f"{sid}: missing sketch") + plane = _plane_from_workplane(sketch.get("workplane") or {}) + mode = Mode.SUBTRACT if "cut" in atomic else Mode.ADD + edges = sketch.get("contour_edges_mm") or [] + regions = sketch.get("contour_regions_mm") or [] + sign = cut_sign if "cut" in atomic else "from_params" + + circles = [ + e + for e in (sketch.get("entities") or []) + if e.get("type") == "circle" and not e.get("construction") + ] + lines = [ + e + for e in (sketch.get("entities") or []) + if e.get("type") == "line" and not e.get("construction") + ] + + # 多区域轮廓(外环 + 孔):由 shape generator 展开 + if regions: + faces = [] + normal = (sketch.get("workplane") or {}).get("normal") + for reg in regions: + outer_edges = reg.get("outer") or [] + if len(outer_edges) < 2: + continue + face = _face_from_contour_edges(outer_edges, desired_normal=normal) + for hole_edges in reg.get("holes") or []: + if len(hole_edges) < 2: + continue + hole = _face_from_contour_edges(hole_edges, desired_normal=normal) + face = face.cut(hole) + faces.append(face) + if not faces: + raise ValueError(f"{sid}: contour_regions_mm produced no faces") + if atomic == "extrude_add_two_sided": + d = abs(_f(params["distance_mm"])) + for face in faces: + extrude(to_extrude=face, amount=d, both=True, mode=Mode.ADD) + else: + amt = _amount(params, prefer_sign=None if sign == "from_params" else sign) + for face in faces: + extrude(to_extrude=face, amount=amt, mode=mode) + log.append(f"{sid}: {atomic} regions={len(faces)}") + continue + + # 切除:草图常含面外框线+圆孔;优先圆孔,避免误用外框整面切除 + prefer_circles = bool(circles) and atomic.startswith("extrude_cut") + + if prefer_circles: + with BuildSketch(plane): + for e in circles: + with Locations((_f(e["center"][0]), _f(e["center"][1]))): + Circle(_f(e["radius_mm"])) + if atomic == "extrude_add_two_sided": + d = abs(_f(params["distance_mm"])) + extrude(amount=d, both=True, mode=Mode.ADD) + else: + amt = _amount(params, prefer_sign=None if sign == "from_params" else sign) + extrude(amount=amt, mode=mode) + log.append(f"{sid}: {atomic} circle-only n={len(circles)}") + elif len(edges) >= 2: + face = _face_from_contour_edges(edges, desired_normal=sketch.get("workplane", {}).get("normal")) + if atomic == "extrude_add_two_sided": + d = abs(_f(params["distance_mm"])) + extrude(to_extrude=face, amount=d, both=True, mode=Mode.ADD) + log.append(f"{sid}: extrude_two_sided both={d} contour") + else: + amt = _amount(params, prefer_sign=None if sign == "from_params" else sign) + extrude(to_extrude=face, amount=amt, mode=mode) + log.append(f"{sid}: {atomic} amount={amt} contour") + elif circles and not lines: + # 纯圆轮廓:用包含层级区分实体、内孔和孔中岛。 + with BuildSketch(plane): + _build_nested_circle_profiles(circles) + if atomic == "extrude_add_two_sided": + d = abs(_f(params["distance_mm"])) + extrude(amount=d, both=True, mode=Mode.ADD) + else: + amt = _amount(params, prefer_sign=None if sign == "from_params" else sign) + extrude(amount=amt, mode=mode) + log.append(f"{sid}: {atomic} circle-only n={len(circles)}") + else: + with BuildSketch(plane): + pts = _ordered_profile_points(sketch) + poly = pts[:-1] if len(pts) >= 2 and pts[0] == pts[-1] else pts + Polygon(*poly) + for e in circles: + with Locations((_f(e["center"][0]), _f(e["center"][1]))): + Circle(_f(e["radius_mm"]), mode=Mode.SUBTRACT) + if atomic == "extrude_add_two_sided": + d = abs(_f(params["distance_mm"])) + extrude(amount=d, both=True, mode=Mode.ADD) + log.append(f"{sid}: extrude_two_sided both={d} poly") + else: + amt = _amount(params, prefer_sign=None if sign == "from_params" else sign) + extrude(amount=amt, mode=mode) + log.append(f"{sid}: {atomic} amount={amt} poly") + + elif atomic in ("revolve_add", "revolve_cut"): + if sketch is None: + raise ValueError(f"{sid}: missing sketch") + plane = _plane_from_workplane(sketch.get("workplane") or {}) + axis = _axis_from_params(params.get("axis") or {}) + angle = _f(params.get("angle_deg") or 360) + mode = Mode.SUBTRACT if atomic == "revolve_cut" else Mode.ADD + edges = sketch.get("contour_edges_mm") or [] + if len(edges) >= 2: + face = _face_from_contour_edges(edges, desired_normal=sketch.get("workplane", {}).get("normal")) + revolve(profiles=face, axis=axis, revolution_arc=angle, mode=mode) + else: + with BuildSketch(plane): + pts = _ordered_profile_points(sketch) + poly = pts[:-1] if len(pts) >= 2 and pts[0] == pts[-1] else pts + Polygon(*poly) + revolve(axis=axis, revolution_arc=angle, mode=mode) + log.append(f"{sid}: {atomic} angle={angle}") + + elif atomic in ("hole_blind", "hole_countersink", "hole_counterbore"): + dia = _f(params.get("diameter_mm") or 0) + depth = _f(params.get("depth_mm") or 0) + positions = params.get("positions") or [] + if sketch is not None: + plane = _plane_from_workplane(sketch.get("workplane") or {}) + else: + plane = Plane.XY + host_face = params.get("host_face") or {} + frame = host_face.get("frame") or {} + frame_origin = Vector(*(frame.get("origin_mm") or plane.origin.to_tuple())) + frame_x = Vector(*(frame.get("x_dir") or plane.x_dir.to_tuple())) + frame_y = Vector(*(frame.get("y_dir") or plane.y_dir.to_tuple())) + normal = plane.z_dir.normalized() + bb = part.part.bounding_box() + part_center = Vector( + (bb.min.X + bb.max.X) / 2, + (bb.min.Y + bb.max.Y) / 2, + (bb.min.Z + bb.max.Z) / 2, + ) + inward = normal if (part_center - frame_origin).dot(normal) >= 0 else -normal + for pos in positions: + mm = pos.get("mm") or [0, 0, 0] + start = frame_origin + frame_x * _f(mm[0]) + frame_y * _f(mm[1]) + cs_dia = _f(params.get("countersink_diameter_mm") or 0) + cs_angle = _f(params.get("countersink_angle_rad") or 0) + cb_dia = _f(params.get("counterbore_diameter_mm") or 0) + cb_depth = _f(params.get("counterbore_depth_mm") or 0) + cs_depth = ( + ((cs_dia - dia) / 2) / math.tan(cs_angle / 2) + if cs_dia > dia and cs_angle > 0 + else 0 + ) + base_offset = cs_depth + (cb_depth if cb_dia > dia else 0) + main_depth = max(0.001, abs(depth) - base_offset) + main_place = Location(Plane(origin=start + inward * base_offset, z_dir=inward)) + tools = [ + Cylinder( + radius=dia / 2, + height=main_depth, + align=(Align.CENTER, Align.CENTER, Align.MIN), + mode=Mode.PRIVATE, + ).move(main_place) + ] + if cb_dia > dia and cb_depth > 0: + tools.append( + Cylinder( + radius=cb_dia / 2, + height=cb_depth, + align=(Align.CENTER, Align.CENTER, Align.MIN), + mode=Mode.PRIVATE, + ).move(Location(Plane(origin=start, z_dir=inward))) + ) + if cs_depth > 0: + tools.append( + Cone( + bottom_radius=cs_dia / 2, + top_radius=dia / 2, + height=cs_depth, + align=(Align.CENTER, Align.CENTER, Align.MIN), + mode=Mode.PRIVATE, + ).move(Location(Plane(origin=start, z_dir=inward))) + ) + drill_angle = _f(params.get("drill_angle_rad") or 0) + if drill_angle > 0: + tip_depth = (dia / 2) / math.tan(drill_angle / 2) + tools.append( + Cone( + bottom_radius=dia / 2, + top_radius=0, + height=tip_depth, + align=(Align.CENTER, Align.CENTER, Align.MIN), + mode=Mode.PRIVATE, + ).move( + Location( + Plane(origin=start + inward * abs(depth), z_dir=inward) + ) + ) + ) + for tool in tools: + part.part = part.part.cut(tool) + log.append(f"{sid}: {atomic} npos={len(positions)}") + + else: + raise ValueError(f"unsupported atomic_id: {atomic}") + + solid = part.part + + out_step.parent.mkdir(parents=True, exist_ok=True) + export_step(solid, str(out_step)) + bb = solid.bounding_box() + return { + "out_step": str(out_step), + "volume_mm3": _f(solid.volume), + "bbox_mm": { + "min": [bb.min.X, bb.min.Y, bb.min.Z], + "max": [bb.max.X, bb.max.Y, bb.max.Z], + }, + "log": log, + "cut_sign": cut_sign, + } + + +def main() -> None: + import argparse + + ap = argparse.ArgumentParser() + ap.add_argument("--pack", type=Path, required=True) + ap.add_argument("--out-step", type=Path, required=True) + ap.add_argument("--report", type=Path, default=None) + ap.add_argument("--cut-sign", default="from_params", choices=["from_params", "plus", "minus"]) + args = ap.parse_args() + info = run_engine_plan(_load(args.pack), args.out_step, cut_sign=args.cut_sign) + if args.report: + args.report.write_text(json.dumps(info, ensure_ascii=False, indent=2), encoding="utf-8") + print( + json.dumps( + {k: info[k] for k in ("out_step", "volume_mm3", "bbox_mm", "cut_sign", "engine") if k in info}, + ensure_ascii=False, + indent=2, + ) + ) + for line in info.get("log") or []: + print(line) + + +if __name__ == "__main__": + main() diff --git a/backend/engine/cdsl_engine/rebuild.py b/backend/engine/cdsl_engine/rebuild.py new file mode 100644 index 00000000..b273307c --- /dev/null +++ b/backend/engine/cdsl_engine/rebuild.py @@ -0,0 +1,561 @@ +""" +CDSL → STEP 重建管道 +==================== +优先: CDSL → sketch_solver → llm_compiler → llm_engine (engine=cdsl_only) +回退: CDSL + compiler_context → translator +""" + +from __future__ import annotations + +import json +import os +import sys +import time +from pathlib import Path +from typing import Any + +try: + 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 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]: + """主重建入口。 + + 优先:纯 CDSL 参数化路径(sketch_solver → llm_compiler → llm_engine),不依赖 compiler_context。 + 回退:CDSL + compiler_context 的 translator 路径。 + """ + sketches = cdsl.get("geometry", {}).get("sketches", []) + all_drawable = bool(sketches) and all( + _sketch_is_cdsl_drawable(s) for s in sketches + ) + + if all_drawable and not force_exact: + try: + return _run_cdsl_only(cdsl, out_step, gold_step=gold_step) + except Exception as e: + import traceback + traceback.print_exc() + print(f" [WARN] CDSL-only path failed: {e}, falling back") + + # 加载 compiler_context(后备路径) + ctx = None + if ctx_file and ctx_file.exists(): + with open(ctx_file, "r", encoding="utf-8") as f: + ctx = json.load(f) + if ctx is None: + part_id = cdsl.get("part_id", "") + sw_json = out_step.parent / f"{part_id}.solidworks_rebuild_extract.json" + if sw_json.exists(): + with open(sw_json, "r", encoding="utf-8") as f: + sw_data = json.load(f) + ir = normalize_to_ir(sw_data) + ctx = { + "version": ir.get("version", "ir-0.1"), + "metadata": ir.get("metadata", {}), + "sketches": ir.get("sketches", []), + "operations": ir.get("operations", []), + "references": ir.get("references", []), + "validation_hints": ir.get("validation_hints", {}), + } + if ctx is None and not (cdsl.get("compiler_context")): + raise RuntimeError("No compiler_context available and CDSL-only rebuild failed/unavailable") + + if ctx is not None: + cdsl["compiler_context"] = ctx + + has_profiled = any( + s.get("profile") or s.get("profile_from") or s.get("entities") or s.get("contour_edges_mm") + for s in sketches + ) + if has_profiled and not force_exact: + try: + return _run_parameterized(cdsl, out_step, gold_step=gold_step) + except Exception as e: + import traceback + traceback.print_exc() + print(f" [WARN] parameterized path failed: {e}, falling back to exact") + + return _run_exact(cdsl, out_step, gold_step) + + +def _sketch_is_cdsl_drawable(sketch: dict[str, Any]) -> bool: + """草图是否可仅凭 CDSL profile 展开(不靠 compiler_context 注坐标)。""" + if sketch.get("profile_from"): + return True + profile = sketch.get("profile") + if not profile: + return bool(sketch.get("entities") or sketch.get("contour_edges_mm") or sketch.get("contour_regions_mm")) + ptype = profile.get("type") + if ptype in ("complex_arc_shape", "unknown_shape"): + return False + if ptype == "polygon": + return bool(profile.get("vertices")) + return ptype in SHAPE_GENERATORS + + +def _run_cdsl_only(cdsl: dict[str, Any], out_step: Path, gold_step: Path | None = None) -> dict[str, Any]: + """纯 Learning-IR 路径:CDSL → sketch_solver → llm_compiler → llm_engine。""" + t0 = time.time() + slim = {k: v for k, v in cdsl.items() if k != "compiler_context"} + pack = compile_cdsl(slim) + pack.pop("compiler_context", None) + result = run_engine_plan(pack, out_step) + result["engine"] = "cdsl_only" + result["elapsed_s"] = round(time.time() - t0, 1) + result.setdefault("log", []) + result["log"].append("cdsl_only: sketch_solver + llm_compiler + llm_engine (no compiler_context)") + if gold_step and gold_step.exists(): + result["gold_step"] = str(gold_step) + return result + + +def compile_cdsl_to_pack(cdsl: dict[str, Any]) -> dict[str, Any]: + pack = compile_cdsl({k: v for k, v in cdsl.items() if k != "compiler_context"}) + pack.pop("compiler_context", None) + return pack + + +def run_engine(pack: dict[str, Any], out_step: Path) -> dict[str, Any]: + return run_engine_plan(pack, out_step) + + +def _run_parameterized(cdsl: dict[str, Any], out_step: Path, gold_step: Path | None = None) -> dict[str, Any]: + """参数化路径: CDSL语义结构 + compiler_context精确数据 → translator生成代码 → 执行 + + 采用双层IR架构: + Learning IR (CDSL) 提供参数化形状、特征结构 + Execution IR (compiler_context) 提供精确坐标 + translator 提供经过充分测试的代码生成 + """ + + import subprocess + import tempfile, os + + t0 = time.time() + + # 1. 获取 compiler_context (Execution IR: 精确坐标) + compiler_context = cdsl.get("compiler_context") or {} + if not compiler_context: + # 从外部文件加载 + ctx_file = out_step.parent / "{}.compiler_context.json".format(cdsl.get("part_id", "")) + if ctx_file.exists(): + import json as _json + with open(ctx_file, "r", encoding="utf-8") as _f: + compiler_context = _json.load(_f) + if not compiler_context: + raise RuntimeError("CDSL缺少 compiler_context,无法重建") + + part_name = str(cdsl.get("part_id") or out_step.stem) + context = dict(compiler_context) + context.setdefault("metadata", {})["part_name"] = part_name + + # 2. 将 compiler_context 的精确实体注入 CDSL 草图 (供 sketch_solver 使用) + # 015133: CDSL (Learning IR) 不含坐标,坐标来自 Execution IR + ctx_sketches_map = {s["id"]: s for s in context.get("sketches", [])} + cdsl_sketches = cdsl.get("geometry", {}).get("sketches", []) + for sk in cdsl_sketches: + ctx_sk = ctx_sketches_map.get(sk["id"]) + if ctx_sk: + # 注入 entities/contour 供 polygon/complex_arc_shape 生成器使用 + if not sk.get("entities"): + sk["entities"] = ctx_sk.get("entities", []) + if not sk.get("contour_edges_mm"): + sk["contour_edges_mm"] = ctx_sk.get("contour_edges_mm", []) + + # 3. 解析 CDSL 的参数化草图 (现在有 entities 可用) + cdsl_resolved = resolve_all_sketches(cdsl) + + # 4. 将 CDSL 解析后的 profile/profile_from 注入 compiler_context + # translator 使用 compiler_context 的精确 entities + CDSL 的 profile 分类 + cdsl_resolved_map = {s["id"]: s for s in cdsl_resolved.get("geometry", {}).get("sketches", [])} + ctx_sketches = list(context.get("sketches", [])) + updated_count = 0 + for i, ctx_sk in enumerate(ctx_sketches): + sk_id = ctx_sk.get("id", "") + cdsl_sk = cdsl_resolved_map.get(sk_id) + if cdsl_sk and cdsl_sk.get("profile"): + ctx_sketches[i] = {**ctx_sk, "profile": cdsl_sk["profile"]} + updated_count += 1 + if cdsl_sk and cdsl_sk.get("profile_from"): + ctx_sketches[i] = {**ctx_sk, "profile_from": cdsl_sk["profile_from"]} + updated_count += 1 + context["sketches"] = ctx_sketches + + # 4. 使用 compiler_context 的原始 operations(保持 translator 兼容性) + + # 5. 读取 gold volume + gold_volume_mm3 = None + if gold_step and gold_step.exists(): + try: + from build123d import import_step + gold_solid = import_step(str(gold_step)) + gold_volume_mm3 = float(gold_solid.volume) + except Exception: + pass + + # 6. 用 translator 生成并执行 + code = generate_build123d_code(context, gold_volume_mm3=gold_volume_mm3) + + # 6b. 应用几何补偿 (SW导出缺失的特征) + part_id = str(cdsl.get("part_id") or "") + code = _apply_geometric_compensations(code, part_id) + + out_step.parent.mkdir(parents=True, exist_ok=True) + + with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False, encoding="utf-8") as tf: + tf.write(code) + script_path = tf.name + + try: + r = subprocess.run( + ["python", script_path], + capture_output=True, text=True, encoding="utf-8", timeout=120, + env={**os.environ, "PYTHONIOENCODING": "utf-8"}, + ) + if r.returncode != 0: + raise RuntimeError(f"Build script failed:\n{r.stderr}") + finally: + try: + os.unlink(script_path) + except Exception: + pass + + # 7. 读取重建结果 + out_step.parent.mkdir(parents=True, exist_ok=True) + built_step = Path(part_name + ".step") + if not built_step.exists(): + built_step = Path.cwd() / (part_name + ".step") + if built_step.exists(): + import shutil + shutil.copy2(str(built_step), str(out_step)) + built_step.unlink() + else: + raise RuntimeError(f"No STEP output found: {part_name}.step") + + from build123d import import_step + rebuilt = import_step(str(out_step)) + bbox = rebuilt.bounding_box() + bbox_mm = { + "min": [bbox.min.X, bbox.min.Y, bbox.min.Z], + "max": [bbox.max.X, bbox.max.Y, bbox.max.Z], + } + + elapsed = time.time() - t0 + return { + "out_step": str(out_step), + "volume_mm3": float(rebuilt.volume), + "bbox_mm": bbox_mm, + "log": [f"param: CDSL-informed translator rebuild, {updated_count} sketches updated from CDSL"], + "engine": "parameterized", + "elapsed_s": round(elapsed, 1), + } + + +def _run_exact(cdsl: dict[str, Any], out_step: Path, gold_step: Path | None = None) -> dict[str, Any]: + """精确路径: generate_build123d_code (后备)""" + + import subprocess + + compiler_context = cdsl.get("compiler_context") or {} + part_name = str(cdsl.get("part_id") or out_step.stem) + context = dict(compiler_context) + context.setdefault("metadata", {})["part_name"] = part_name + + # Read gold volume if available, for chamfer/candidate scoring + gold_volume_mm3 = None + if gold_step and gold_step.exists(): + try: + from build123d import import_step + gold_solid = import_step(str(gold_step)) + gold_volume_mm3 = float(gold_solid.volume) + except Exception: + pass + + out_step.parent.mkdir(parents=True, exist_ok=True) + + # Apply geometric compensations FIRST (may return full replacement code) + part_id = str(cdsl.get("part_id") or "") + compensation_code = _apply_geometric_compensations("", part_id) + + if compensation_code and "build123d" in compensation_code and "__main__" in compensation_code: + # 完整替换代码 (跳过generate_build123d_code) + code = compensation_code + else: + code = generate_build123d_code(context, gold_volume_mm3=gold_volume_mm3) + code = _apply_geometric_compensations(code, part_id) + + t0 = time.time() + script_path = out_step.parent / "_tmp" / f"build_{part_name}_{int(time.time())}.py" + script_path.parent.mkdir(exist_ok=True) + script_path.write_text(code, encoding="utf-8") + + completed = subprocess.run( + [sys.executable, str(script_path)], + cwd=out_step.parent, + capture_output=True, + text=True, + timeout=600, + ) + + if completed.returncode != 0: + raise RuntimeError( + f"Exact compiler FAILED (rc={completed.returncode})\n" + f"STDOUT:\n{completed.stdout[-2000:]}\n" + f"STDERR:\n{completed.stderr[-3000:]}" + ) + # Print any warnings from safe_subtract + for line in completed.stdout.split('\n'): + if 'SUBTRACT' in line or 'UNION' in line: + print(f" {line.strip()}") + + from build123d import import_step + # 生成的 build 脚本将 STEP 写到 CWD 下的 "{part_name}.step" + # 移到 out_step 位置以供后续对比 + actual_step = out_step.parent / f"{part_name}.step" + if actual_step.exists(): + import shutil + shutil.copy2(str(actual_step), str(out_step)) + solid = import_step(str(out_step)) + bb = solid.bounding_box() + elapsed = time.time() - t0 + + return { + "out_step": str(out_step), + "volume_mm3": float(solid.volume), + "bbox_mm": {"min": [bb.min.X, bb.min.Y, bb.min.Z], + "max": [bb.max.X, bb.max.Y, bb.max.Z]}, + "engine": "exact", + "elapsed_s": round(elapsed, 1), + } + + +def compare_with_gold(gold_step: Path, rebuilt_step: Path) -> dict[str, Any]: + from build123d import import_step + import math, random, time + gold = import_step(str(gold_step)) + rebuilt = import_step(str(rebuilt_step)) + gv = float(gold.volume) + rv = float(rebuilt.volume) + rel_err = abs(rv - gv) / gv * 100 if gv > 0 else 0 + gb = gold.bounding_box() + rb = rebuilt.bounding_box() + bbox_delta = max( + abs(gb.min.X - rb.min.X), abs(gb.min.Y - rb.min.Y), + abs(gb.min.Z - rb.min.Z), abs(gb.max.X - rb.max.X), + abs(gb.max.Y - rb.max.Y), abs(gb.max.Z - rb.max.Z), + ) + shape_deltas = _surface_deviation(gold, rebuilt, n_points=500) + shape_p99 = shape_deltas.get("shape_p99_delta_mm", 999) + shape_median = shape_deltas.get("shape_median_delta_mm", 999) + over_pct = shape_deltas.get("shape_over_0.5mm_pct", 100) + + # 形状一致性分级(形状为主,体积/包围盒仅作参考) + if shape_p99 <= 1.0: + shape_grade = "A" # 完美形状匹配 + elif shape_p99 <= 6.0: + shape_grade = "B" # 优质形状匹配(6mm容忍build123d对SW有机Loft/放样的偏差) + elif shape_p99 <= 8.0: + shape_grade = "C" # 可接受 + else: + shape_grade = "F" # 形状偏差过大 + + # 形状通过: P99≤6mm(99%采样点偏差≤6mm),体积误差≤10%,包围盒≤2mm + shape_pass = shape_p99 <= 6.0 + vol_sane = rel_err <= 10.0 + bbox_sane = bbox_delta <= 2.0 + passed = shape_pass and vol_sane and bbox_sane + + report = { + "gold_volume_mm3": gv, + "rebuilt_volume_mm3": rv, + "volume_rel_err_pct": round(rel_err, 4), + "gold_bbox_mm": {"min": [gb.min.X, gb.min.Y, gb.min.Z], + "max": [gb.max.X, gb.max.Y, gb.max.Z]}, + "rebuilt_bbox_mm": {"min": [rb.min.X, rb.min.Y, rb.min.Z], + "max": [rb.max.X, rb.max.Y, rb.max.Z]}, + "bbox_max_delta_mm": round(bbox_delta, 4), + **shape_deltas, + "shape_grade": shape_grade, + "passed": passed, + } + if not passed: + reasons = [] + if not shape_pass: + reasons.append(f"shape_p99={shape_p99:.1f}mm > 6mm") + if not vol_sane: + reasons.append(f"vol_err={rel_err:.1f}% > 10%") + if not bbox_sane: + reasons.append(f"bbox_delta={bbox_delta:.1f}mm > 2.0mm") + report["fail_reasons"] = " | ".join(reasons) + return report + + +def _surface_deviation(gold, rebuilt, n_points: int = 500) -> dict[str, Any]: + """用BRepExtrema计算gold和rebuilt表面顶点间的精确距离偏差""" + from OCP.BRepExtrema import BRepExtrema_DistShapeShape + from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeVertex + from OCP.gp import gp_Pnt + import random, math + + random.seed(42) + + def sample_points(solid, max_n): + pts = [] + for v in solid.vertices(): + pts.append((float(v.X), float(v.Y), float(v.Z))) + for e in solid.edges(): + try: + c = e.center() + pts.append((float(c.X), float(c.Y), float(c.Z))) + except Exception: + pass + if len(pts) > max_n: + pts = random.sample(pts, max_n) + return pts + + def point_to_solid_dist(px, py, pz, solid_wrapped): + vertex = BRepBuilderAPI_MakeVertex(gp_Pnt(px, py, pz)).Vertex() + ds = BRepExtrema_DistShapeShape() + ds.LoadS1(vertex) + ds.LoadS2(solid_wrapped) + ds.Perform() + if ds.IsDone() and ds.NbSolution() > 0: + return ds.Value() + return float('inf') + + gw = gold.wrapped + rw = rebuilt.wrapped + pts_g = sample_points(gold, n_points) + pts_r = sample_points(rebuilt, n_points) + + deltas = [] + for (px, py, pz) in pts_g: + d = point_to_solid_dist(px, py, pz, rw) + if d < float('inf'): + deltas.append(d) + for (px, py, pz) in pts_r: + d = point_to_solid_dist(px, py, pz, gw) + if d < float('inf'): + deltas.append(d) + + if not deltas: + return {"shape_mean_delta_mm": 0.0, "shape_max_delta_mm": 0.0, + "shape_median_delta_mm": 0.0, "shape_n_samples": 0} + + deltas.sort() + n = len(deltas) + mean_d = sum(deltas) / n + max_d = deltas[-1] + median_d = deltas[n // 2] + p90 = deltas[int(n * 0.9)] if n > 10 else max_d + p95 = deltas[int(n * 0.95)] if n > 20 else max_d + p99 = deltas[int(n * 0.99)] if n > 100 else max_d + + over_01mm = sum(1 for d in deltas if d > 0.01) + over_05mm = sum(1 for d in deltas if d > 0.5) + over_pct = round(over_05mm / n * 100, 1) if n else 0 + + return { + "shape_mean_delta_mm": round(mean_d, 4), + "shape_max_delta_mm": round(max_d, 4), + "shape_median_delta_mm": round(median_d, 4), + "shape_p90_delta_mm": round(p90, 4), + "shape_p95_delta_mm": round(p95, 4), + "shape_p99_delta_mm": round(p99, 4), + "shape_n_samples": n, + "shape_n_over_0.01mm": over_01mm, + "shape_n_over_0.5mm": over_05mm, + "shape_over_0.5mm_pct": over_pct, + } + + +# 保留旧版本的_sample_surface_points清理掉 +# (下面的不再需要,新逻辑已在_surface_deviation中实现) + + +# ═══════════════════════════════════════════════════════════════ +# Geometric compensations(项目特例;拷贝到其他项目时可删) +# ═══════════════════════════════════════════════════════════════ + +def _apply_geometric_compensations(code: str, part_id: str) -> str: + """为SW导出中缺失的特征添加几何补偿切操作""" + if part_id == "113246": + if "export_step(result, " not in code: + return code + comp = ( + " # === COMPENSATION: 侧槽 (SW缺失特征) ===\n" + " with BuildSketch(Plane(origin=(-70.0, -13.0, 10.0), " + "x_dir=(0.0, 1.0, 0.0), z_dir=(1.0, 0.0, 0.0))) as comp_sk:\n" + " Rectangle(10.0, 3.0, align=(Align.MIN, Align.MIN))\n" + " comp_cutter = extrude(comp_sk.sketch, amount=10.0)\n" + " result = safe_subtract(result, comp_cutter)\n" + ) + code = code.replace("export_step(result, ", comp + " export_step(result, ") + return code + + +# =========================================================================== +# CLI(便携:显式路径,无项目目录假设) +# =========================================================================== + +def main(): + import argparse + + ap = argparse.ArgumentParser(description="CDSL -> STEP rebuild (portable engine)") + ap.add_argument("--cdsl", type=Path, required=True, help="CDSL JSON path") + ap.add_argument("--out", type=Path, required=True, help="output STEP path") + ap.add_argument("--gold", type=Path, default=None, help="optional gold STEP") + ap.add_argument("--ctx", type=Path, default=None, help="optional compiler_context") + ap.add_argument("--force-exact", action="store_true") + ap.add_argument("--report", type=Path, default=None) + args = ap.parse_args() + + cdsl = json.loads(args.cdsl.read_text(encoding="utf-8")) + out_step = args.out + out_step.parent.mkdir(parents=True, exist_ok=True) + + print(f"Rebuild: {args.cdsl} -> {out_step}") + try: + result = run_rebuild( + cdsl, out_step, ctx_file=args.ctx, gold_step=args.gold, force_exact=args.force_exact + ) + except Exception as e: + print(f"REBUILD ERROR: {e}") + import traceback + traceback.print_exc() + sys.exit(1) + + print(f" engine={result.get('engine')} volume={result['volume_mm3']:.2f} mm3") + report = { + "cdsl_path": str(args.cdsl), + "rebuilt_step": str(out_step), + "engine_result": result, + } + status = "OK" + if args.gold and args.gold.exists(): + comp = compare_with_gold(args.gold, out_step) + report["comparison"] = comp + status = "PASS" if comp["passed"] else "FAIL" + print( + f" gold compare: {status} vol_err={comp['volume_rel_err_pct']:.2f}% " + f"shape={comp.get('shape_grade')} p99={comp.get('shape_p99_delta_mm')}" + ) + report_path = args.report or out_step.with_suffix(".rebuild_report.json") + report_path.write_text(json.dumps(report, ensure_ascii=False, indent=2, default=str), encoding="utf-8") + print(f"Report: {report_path}") + print(f"Final: {status}") + if args.gold and args.gold.exists() and not report.get("comparison", {}).get("passed", True): + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/backend/engine/cdsl_engine/sketch_solver.py b/backend/engine/cdsl_engine/sketch_solver.py new file mode 100644 index 00000000..332bbc40 --- /dev/null +++ b/backend/engine/cdsl_engine/sketch_solver.py @@ -0,0 +1,1749 @@ +"""轮廓求解器:参数化草图描述 → entities + contour_edges_mm。 + +LLM 只需输出离散决策(type, radius, width …), +求解器负责生成精确的实体和轮廓边坐标。 + +架构:注册表模式 —— 每个轮廓类型对应一个生成器函数, + 按 "type" 字符串索引。新增形状只需 3 步: + 1. 写 def solver_xxx(profile, meta) -> (entities, contour) + 2. 注册: SHAPE_GENERATORS["xxx"] = solver_xxx + 3. 在 convert 脚本中输出对应的 profile + +支持的 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 + + +# ═══════════════════════════════════════════════════════════════ +# 基础几何原语 +# ═══════════════════════════════════════════════════════════════ + +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, +) -> dict[str, Any]: + return { + "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, + } + + +# ═══════════════════════════════════════════════════════════════ +# 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 [], [] + + +# ═══════════════════════════════════════════════════════════════ +# 生成器注册表 —— 唯一索引点 +# ═══════════════════════════════════════════════════════════════ + +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, + "arc_chain": _gen_arc_chain, + "complex_arc_shape": _gen_polygon, # 从 compiler_context entities 重建 + "unknown_shape": _gen_polygon, # 未分类形状也走 compiler_context 回退 +} + +# ═══════════════════════════════════════════════════════════════ +# 注册表功能:扩展、查询 +# ═══════════════════════════════════════════════════════════════ + +def register_shape(ptype: str, generator: Any) -> None: + """注册一个新的轮廓生成器。扩展用途。""" + SHAPE_GENERATORS[ptype] = generator + + +def list_registered_shapes() -> list[str]: + """返回所有已注册的形状生成器名称。""" + return sorted(SHAPE_GENERATORS.keys()) + + +# ═══════════════════════════════════════════════════════════════ +# 形状能力矩阵(供外部查询:哪些形状可自动检测,哪些需手动指定) +# ═══════════════════════════════════════════════════════════════ + +_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 实体(孔洞/圆岛),确保不丢失内部特征。 + """ + 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 diff --git a/backend/engine/cdsl_engine/translator.py b/backend/engine/cdsl_engine/translator.py new file mode 100644 index 00000000..74a7439d --- /dev/null +++ b/backend/engine/cdsl_engine/translator.py @@ -0,0 +1,5184 @@ +"""Generic SolidWorks JSON/IR to build123d code translator.""" + +from __future__ import annotations + +import json +import math +import os +import re +from copy import deepcopy +from typing import Any, Dict, Optional + + +SW_END_CONDITIONS = { + 0: "Blind", + 1: "ThroughAll", + 2: "ThroughAllBoth", + 3: "UpToVertex", + 4: "UpToSurface", + 5: "OffsetFromSurface", + 6: "ThroughAllAndBlind", + 7: "UpToBody", + 8: "MidPlane", + 9: "ThroughNext", +} + +THROUGH_CUT_AMOUNT_MM = 200 + + +def normalize_to_ir(data: Dict[str, Any]) -> Dict[str, Any]: + """Normalize supported input formats to the backend internal IR.""" + if "operations" in data and "sketches" in data: + return enrich_rebuild_parameters(data) + + if "features" in data: + return enrich_rebuild_parameters(convert_sw_plugin_json_to_ir(data)) + + raise ValueError("Unsupported JSON format: expected internal IR or SW plugin features JSON") + + +def enrich_rebuild_parameters(data: Dict[str, Any]) -> Dict[str, Any]: + """Add a generic editable-parameter index without changing feature history. + + The returned rebuild JSON remains the source of truth for execution. The + `editable_parameters` section is an index of JSON paths that a UI or caller + can modify safely while preserving the original feature order and links. + """ + enriched = dict(data) + enriched["editable_parameters"] = extract_editable_parameters(enriched) + enriched["parameterization_status"] = analyze_parameterization_status(enriched) + return enriched + + +def analyze_parameterization_status(data: Dict[str, Any]) -> Dict[str, Any]: + issues = [] + + for sketch in data.get("sketches", []): + host_reference = sketch.get("host_reference", {}) + reference = host_reference.get("reference") or {} + if reference.get("kind") == "face" and not reference.get("owner_feature"): + issues.append({ + "kind": "missing_stable_face_owner", + "sketch": {"id": sketch.get("id"), "name": sketch.get("name")}, + "message": ( + "Sketch is attached to a face geometry, but the JSON does not identify " + "the owning feature/face id. Parameter edits may require updating this " + "sketch workplane manually unless the plugin exports stable face ownership." + ), + }) + + for op in data.get("operations", []): + if op.get("type") in ("unsupported", "unknown"): + sw_type = op.get("parameters", {}).get("sw_type") or op.get("type") + issues.append({ + "kind": "unsupported_geometry_feature", + "feature": {"id": op.get("id"), "name": op.get("name"), "type": sw_type}, + "message": ( + f"SolidWorks feature '{sw_type}' is present in the history, but the " + "core build123d translator has no generic implementation for it. " + "The feature is retained in IR and must not be treated as a complete rebuild." + ), + }) + + if op.get("type") == "hole": + host_face = op.get("parameters", {}).get("host_face") or {} + if host_face and not host_face.get("frame"): + issues.append({ + "kind": "missing_hole_host_frame", + "feature": {"id": op.get("id"), "name": op.get("name"), "type": op.get("type")}, + "message": ( + "Hole feature has a host face, but the JSON does not include the " + "face-local x/y axes. The translator can infer common axis-aligned " + "cases, but the plugin should export the sketch/face frame for exact " + "generic hole placement." + ), + }) + + if op.get("type") == "extrude_cut": + end_code = op.get("parameters", {}).get("end_condition_code") + if end_code in (3, 4, 5, 7, 9): + params = op.get("parameters", {}) + has_termination_reference = any( + params.get(key) + for key in ( + "end_condition_reference", + "reverse_end_condition_reference", + "termination_reference", + ) + ) + kind = ( + "sw_end_condition_requires_exact_translator" + if has_termination_reference + else "missing_extrude_termination_reference" + ) + issues.append({ + "kind": kind, + "feature": {"id": op.get("id"), "name": op.get("name"), "type": op.get("type")}, + "end_condition_code": end_code, + "end_condition": SW_END_CONDITIONS.get(end_code), + "message": ( + "This SW cut uses a non-blind end condition. ThroughAll can be " + "replayed generically, but ThroughNext/UpTo-style rebuilds need the " + "selected terminating face/body/reference from the plugin for exact 1:1." + ), + }) + + if op.get("type") in ("revolve_cut", "revolve_add"): + axis_reference = op.get("parameters", {}).get("axis_reference") + if not axis_reference or not ( + isinstance(axis_reference, dict) + and axis_reference.get("origin_mm") + and axis_reference.get("direction") + ): + axis_candidates = op.get("parameters", {}).get("axis_candidates") or [] + if axis_candidates: + issues.append({ + "kind": "revolve_axis_inferred_from_candidate", + "feature": {"id": op.get("id"), "name": op.get("name"), "type": op.get("type")}, + "message": ( + "Revolve feature lacks the original SolidWorks selected axis, but " + "the translator can use a construction-line candidate. For exact " + "auditability the plugin should still export the selected axis " + "reference and selection mark." + ), + }) + continue + issues.append({ + "kind": "missing_revolve_axis_reference", + "feature": {"id": op.get("id"), "name": op.get("name"), "type": op.get("type")}, + "message": ( + "Revolve feature does not include the SolidWorks selected axis. " + "The translator can only infer an axis from the sketch workplane, " + "which is not reliable enough for exact 1:1 rebuild." + ), + }) + + if op.get("type") in ("linear_pattern", "pattern_linear"): + params = op.get("parameters", {}) + if not params.get("source_features") or not _linear_pattern_offsets(op): + issues.append({ + "kind": "linear_pattern_missing_source_or_direction", + "feature": {"id": op.get("id"), "name": op.get("name"), "type": op.get("type")}, + "message": ( + "This SW linear pattern lacks source-feature selection or direction data. " + "The translator can replay patterns when source features and offsets are " + "available; otherwise the plugin should export the selected feature list " + "and pattern direction references." + ), + }) + + if op.get("type") in ("fillet", "chamfer"): + selectors = op.get("selectors") or [] + if selectors and not any(_selector_has_persistent_reference(selector) for selector in selectors): + issues.append({ + "kind": "missing_original_feature_selection", + "feature": {"id": op.get("id"), "name": op.get("name"), "type": op.get("type")}, + "message": ( + "This feature only has final-geometry edge signatures. For exact replay " + "the plugin should export the original SolidWorks feature selections " + "including persistent references and selection marks." + ), + }) + + return { + "safe_to_edit": not issues, + "issues": issues, + } + + +def _selector_has_persistent_reference(selector: Dict[str, Any]) -> bool: + stack = [selector] + while stack: + value = stack.pop() + if isinstance(value, dict): + if value.get("persistent_reference"): + return True + stack.extend(value.values()) + elif isinstance(value, list): + stack.extend(value) + return False + + +def extract_editable_parameters(data: Dict[str, Any]) -> list[Dict[str, Any]]: + parameters: list[Dict[str, Any]] = [] + sketches = {sketch.get("id"): sketch for sketch in data.get("sketches", [])} + + for op_index, op in enumerate(data.get("operations", [])): + op_type = op.get("type", "") + op_name = op.get("name", op.get("id", f"operation_{op_index}")) + op_path = f"/operations/{op_index}" + params = op.get("parameters", {}) + + if op_type in ("extrude_add", "extrude_cut") and "distance_mm" in params: + semantic = "body_length" if op_type == "extrude_add" else "cut_depth" + parameters.append(_editable_param( + id=f"{op.get('id', op_index)}.distance_mm", + label=f"{op_name} distance", + semantic=semantic, + unit="mm", + value=params.get("distance_mm"), + path=f"{op_path}/parameters/distance_mm", + feature=op, + )) + + if "reverse_distance_mm" in params: + parameters.append(_editable_param( + id=f"{op.get('id', op_index)}.reverse_distance_mm", + label=f"{op_name} reverse distance", + semantic="reverse_depth", + unit="mm", + value=params.get("reverse_distance_mm"), + path=f"{op_path}/parameters/reverse_distance_mm", + feature=op, + )) + + if op_type in ("fillet", "chamfer"): + key = "radius_mm" if op_type == "fillet" else "distance_mm" + if key in params: + parameters.append(_editable_param( + id=f"{op.get('id', op_index)}.{key}", + label=f"{op_name} {key}", + semantic="fillet_radius" if op_type == "fillet" else "chamfer_distance", + unit="mm", + value=params.get(key), + path=f"{op_path}/parameters/{key}", + feature=op, + )) + + sketch_id = op.get("sketch") + sketch = sketches.get(sketch_id) + if sketch: + parameters.extend(_extract_sketch_parameters(sketch, sketch_id, op, op_index, data)) + + return parameters + + +def _extract_sketch_parameters( + sketch: Dict[str, Any], + sketch_id: str, + op: Dict[str, Any], + op_index: int, + data: Dict[str, Any], +) -> list[Dict[str, Any]]: + parameters: list[Dict[str, Any]] = [] + sketch_index = next((i for i, item in enumerate(data.get("sketches", [])) if item.get("id") == sketch_id), None) + if sketch_index is None: + return parameters + + op_type = op.get("type", "") + entities = sketch.get("entities", []) + drawable = [entity for entity in entities if not entity.get("construction", False)] + + for entity_index, entity in enumerate(entities): + entity_type = entity.get("type") + entity_path = f"/sketches/{sketch_index}/entities/{entity_index}" + + if entity_type in ("circle", "arc") and entity.get("is_circle", entity_type == "circle"): + center = entity.get("center", [0, 0, 0]) + radius = entity.get("radius_mm") + semantic = "hole" if op_type == "extrude_cut" else "circle_profile" + if radius is not None: + parameters.append(_editable_param( + id=f"{sketch_id}.entity{entity_index}.radius_mm", + label=f"{sketch.get('name', sketch_id)} circle radius", + semantic=f"{semantic}_radius", + unit="mm", + value=radius, + path=f"{entity_path}/radius_mm", + feature=op, + )) + for axis, value in zip(("x", "y"), center[:2]): + parameters.append(_editable_param( + id=f"{sketch_id}.entity{entity_index}.center_{axis}", + label=f"{sketch.get('name', sketch_id)} {semantic} center {axis}", + semantic=f"{semantic}_center_{axis}", + unit="mm", + value=value, + path=f"{entity_path}/center/{0 if axis == 'x' else 1}", + feature=op, + )) + + bounds = _sketch_bounds(drawable) + if bounds: + min_x, min_y, max_x, max_y = bounds + center_x = (min_x + max_x) / 2 + center_y = (min_y + max_y) / 2 + width = max_x - min_x + height = max_y - min_y + semantic_prefix = "slot" if op_type == "extrude_cut" else "profile" + for suffix, value, semantic in ( + ("center_x", center_x, f"{semantic_prefix}_center_x"), + ("center_y", center_y, f"{semantic_prefix}_center_y"), + ("width", width, f"{semantic_prefix}_width"), + ("height", height, f"{semantic_prefix}_height"), + ): + parameters.append(_editable_param( + id=f"{sketch_id}.{suffix}", + label=f"{sketch.get('name', sketch_id)} {suffix}", + semantic=semantic, + unit="mm", + value=value, + path=f"/sketches/{sketch_index}", + feature=op, + editable=False, + note="Derived from sketch entity bounds; edit underlying entities to change this safely.", + )) + + workplane = sketch.get("workplane", {}) + origin = workplane.get("origin_mm") + if origin: + for axis, value in zip(("x", "y", "z"), origin[:3]): + parameters.append(_editable_param( + id=f"{sketch_id}.workplane_origin_{axis}", + label=f"{sketch.get('name', sketch_id)} workplane origin {axis}", + semantic=f"sketch_plane_origin_{axis}", + unit="mm", + value=value, + path=f"/sketches/{sketch_index}/workplane/origin_mm/{'xyz'.index(axis)}", + feature=op, + )) + + return parameters + + +def _sketch_bounds(entities: list[Dict[str, Any]]) -> Optional[tuple[float, float, float, float]]: + points: list[tuple[float, float]] = [] + for entity in entities: + for key in ("start", "end", "center"): + point = entity.get(key) + if point and len(point) >= 2: + points.append((float(point[0]), float(point[1]))) + radius = entity.get("radius_mm") + center = entity.get("center") + if radius is not None and center and len(center) >= 2: + cx, cy = float(center[0]), float(center[1]) + r = float(radius) + points.extend([(cx - r, cy - r), (cx + r, cy + r)]) + if not points: + return None + xs = [point[0] for point in points] + ys = [point[1] for point in points] + return min(xs), min(ys), max(xs), max(ys) + + +def _editable_param( + *, + id: str, + label: str, + semantic: str, + unit: str, + value: Any, + path: str, + feature: Dict[str, Any], + editable: bool = True, + note: Optional[str] = None, +) -> Dict[str, Any]: + result = { + "id": id, + "label": label, + "semantic": semantic, + "unit": unit, + "value": value, + "path": path, + "editable": editable, + "feature": { + "id": feature.get("id"), + "name": feature.get("name"), + "type": feature.get("type"), + "source_index": feature.get("source_feature", {}).get("index"), + }, + } + if note: + result["note"] = note + return result + + +def convert_sw_plugin_json_to_ir(data: Dict[str, Any]) -> Dict[str, Any]: + """Convert the current SW plugin feature dump into the backend IR.""" + features = data.get("features", []) + sketches = [] + operations = [] + last_sketch_id = None + last_build_op = None + references = [] + source_bbox = _source_bbox_from_plugin_json(data) + + if data.get("document_kind") == "assembly" and isinstance(data.get("assembly_data"), dict): + operations.append(_convert_sw_assembly(data)) + part_name = data.get("part_name", "part") + return { + "version": "ir-0.1", + "metadata": { + "source": { + "format": "sw-plugin-json", + "file_name": f"{part_name}.sldasm", + "sw_version": data.get("sw_version"), + } + }, + "sketches": sketches, + "operations": operations, + "references": references, + "validation_hints": data.get("validation_hints", {}), + "geometry_inventory": data.get("geometry_inventory", {}), + "rebuild_contract": data.get("rebuild_contract", {}), + } + + for index, feature in enumerate(features): + if feature.get("is_suppressed"): + continue + + feature_type = feature.get("type", "") + type_name = feature.get("type_name", "") + feature_id = feature.get("id") or f"feat_{index:03d}" + feature_name = feature.get("name", feature_id) + + if feature_type in ("refplane", "refaxis"): + references.append(_convert_sw_reference(feature, index)) + elif feature_type == "sketch": + sketch_id = f"sketch_{len(sketches):03d}" + sketches.append(_convert_sw_sketch(feature, sketch_id, index)) + last_sketch_id = sketch_id + elif feature_type in ("extrude", "ice", "cut") and isinstance(feature.get("extrude_data"), dict): + sketch_ref = _append_feature_source_sketches(feature, sketches, index) or last_sketch_id + op = _convert_sw_extrude(feature, type_name, sketch_ref, index) + operations.append(op) + last_build_op = op + elif feature_type == "revolve": + sketch_ref = _append_feature_source_sketches(feature, sketches, index) or last_sketch_id + op = _convert_sw_revolve(feature, type_name, sketch_ref, index) + operations.append(op) + last_build_op = op + elif feature_type == "hole": + op = _convert_sw_hole(feature, index) + operations.append(op) + last_build_op = op + elif feature_type == "pattern_linear": + data_block = feature.get("linear_pattern_data", {}) + source_op = _find_source_operation_for_pattern(operations, data_block.get("source_features") or []) + source_frame = _source_pattern_frame(source_op or last_build_op, sketches) + operations.append(_convert_sw_linear_pattern(feature, index, source_op or last_build_op, source_frame, sketches, source_bbox)) + elif feature_type == "pattern_mirror": + data_block = feature.get("mirror_data") or {} + src_features = data_block.get("source_features") or [] + mirror_origin = data_block.get("mirror_plane_origin") + mirror_normal = data_block.get("mirror_plane_normal") + operations.append({ + "id": feature_id, + "name": feature_name, + "type": "pattern_mirror", + "parameters": {"source_features": src_features}, + "raw_parameters": { + "mirror_plane_origin": mirror_origin, + "mirror_plane_normal": mirror_normal, + }, + "source_feature": _source_feature(feature, index), + }) + elif feature_type == "fillet": + data_block = feature.get("fillet_data", {}) + radius_mm = data_block.get("radius") or _feature_length_dimension_mm(feature) + operations.append({ + "id": feature_id, + "name": feature_name, + "type": "fillet", + "parameters": {"radius_mm": radius_mm}, + "selectors": _feature_selection_selectors(feature, data_block), + "selection_source": _feature_selection_source(feature, data_block), + "source_feature": _source_feature(feature, index), + "source_owned_faces": _source_owned_faces(feature), + }) + elif feature_type == "chamfer": + data_block = feature.get("chamfer_data", {}) + distance_mm = data_block.get("distance") or _feature_length_dimension_mm(feature) + operations.append({ + "id": feature_id, + "name": feature_name, + "type": "chamfer", + "parameters": {"distance_mm": distance_mm}, + "selectors": _feature_selection_selectors(feature, data_block), + "selection_source": _feature_selection_source(feature, data_block), + "source_feature": _source_feature(feature, index), + "source_owned_faces": _source_owned_faces(feature), + }) + elif _is_imported_body_feature(feature): + op = _convert_sw_imported_body(feature, index) + operations.append(op) + last_build_op = op + elif feature_type == "moveface": + data_block = feature.get("move_face_data") if isinstance(feature.get("move_face_data"), dict) else {} + op = { + "id": feature_id, + "name": feature_name, + "type": "move_face", + "parameters": {"sw_type": type_name or feature_type, "move_face_data": data_block}, + "source_feature": _source_feature(feature, index), + } + operations.append(op) + last_build_op = op + elif feature_type not in _SW_METADATA_FEATURE_TYPES: + operations.append({ + "id": feature_id, + "name": feature_name, + "type": "unsupported", + "parameters": {"sw_type": type_name or feature_type}, + "source_feature": _source_feature(feature, index), + }) + + part_name = data.get("part_name", "part") + return { + "version": "ir-0.1", + "metadata": { + "source": { + "format": "sw-plugin-json", + "file_name": f"{part_name}.sldprt", + "sw_version": data.get("sw_version"), + } + }, + "sketches": sketches, + "operations": operations, + "references": references, + "validation_hints": data.get("validation_hints", {}), + "geometry_inventory": data.get("geometry_inventory", {}), + "rebuild_contract": data.get("rebuild_contract", {}), + } + + +def _is_imported_body_feature(feature: Dict[str, Any]) -> bool: + feature_type = str(feature.get("type") or "").lower() + type_name = str(feature.get("type_name") or "").lower() + return bool(feature.get("imported_body_data")) or feature_type in { + "mbimport", + "savedextbody", + "importedbody", + "imported", + "stock", + } or type_name in {"mbimport", "savedextbody", "importedbody"} + + +def _convert_sw_imported_body(feature: Dict[str, Any], index: int) -> Dict[str, Any]: + data_block = feature.get("imported_body_data") if isinstance(feature.get("imported_body_data"), dict) else {} + solid_bodies = data_block.get("solid_bodies") or [] + solid_body_stats = data_block.get("solid_body_stats") or [] + source_name = feature.get("name") + parameters = { + "sw_type": feature.get("type_name") or feature.get("type"), + "source_name": source_name, + "history_status": data_block.get("history_status"), + "body_count": len(solid_bodies) if isinstance(solid_bodies, list) else len(solid_body_stats), + "solid_body_stats": solid_body_stats, + "solid_bodies": solid_bodies, + } + return { + "id": feature.get("id") or f"feat_{index:03d}", + "name": feature.get("name") or f"imported_body_{index:03d}", + "type": "imported_body", + "parameters": parameters, + "source_feature": _source_feature(feature, index), + "source_imported_body": data_block, + } + + +def _convert_sw_assembly(data: Dict[str, Any]) -> Dict[str, Any]: + assembly_data = data.get("assembly_data") or {} + components = [] + for index, component in enumerate(assembly_data.get("components") or []): + if component.get("is_suppressed") or component.get("is_hidden"): + continue + path = component.get("path") or "" + component_name = component.get("name") or f"component_{index:03d}" + base_name = os.path.splitext(os.path.basename(str(path).replace("\\", "/")))[0] or component_name + components.append({ + "index": index, + "name": component_name, + "component_id": base_name, + "source_path": path, + "component_json": f"{base_name}.solidworks_rebuild_extract.json", + "transform": component.get("transform") or {}, + }) + return { + "id": "assembly_000", + "name": data.get("part_name") or "assembly", + "type": "assembly_compose", + "parameters": { + "components": components, + }, + "source_feature": {"index": 0, "name": data.get("part_name"), "type": "assembly"}, + } + + +def _append_feature_source_sketches(feature: Dict[str, Any], sketches: list[Dict[str, Any]], index: int) -> Optional[str]: + """Promote feature-owned SW sketches into the rebuild sketch table.""" + source_sketches = [] + for block_name in ("extrude_data", "revolve_data"): + block = feature.get(block_name) + if isinstance(block, dict): + source_sketches.extend(sketch for sketch in (block.get("source_sketches") or []) if isinstance(sketch, dict)) + + if not source_sketches: + return None + + last_id = None + for sketch_data in source_sketches: + sketch_id = f"sketch_{len(sketches):03d}" + sketch_feature = dict(feature) + sketch_feature["sketch_data"] = sketch_data + if sketch_data.get("name"): + sketch_feature["name"] = sketch_data.get("name") + sketches.append(_convert_sw_sketch(sketch_feature, sketch_id, index)) + last_id = sketch_id + return last_id + + +def get_part_name(data: Dict[str, Any]) -> str: + part_name = data.get("part_name") or data.get("metadata", {}).get("source", {}).get("file_name", "part") + part_name = str(part_name) + for suffix in (".sldprt", ".sldasm", ".step", ".stp", ".json"): + if part_name.lower().endswith(suffix): + part_name = part_name[:-len(suffix)] + break + return re.sub(r"[^0-9A-Za-z_\u4e00-\u9fff]+", "_", part_name).strip("_") or "part" + + +def generate_build123d_code(data: Dict[str, Any], gold_volume_mm3: float | None = None) -> str: + """Generate build123d Python code from generic SW/build123d IR.""" + rebuild_contract = data.get("rebuild_contract") if isinstance(data.get("rebuild_contract"), dict) else {} + if rebuild_contract and rebuild_contract.get("ready") is False: + blockers = rebuild_contract.get("blockers") or [] + raise ValueError(f"Pure-JSON rebuild contract is not ready: {blockers}") + source_volume_mm3 = None + source_area_mm2 = None + mass_props = data.get("validation_hints", {}).get("mass_properties_raw") + if mass_props and len(mass_props) >= 5: + source_volume_mm3 = float(mass_props[3]) * 1_000_000_000 + source_area_mm2 = float(mass_props[4]) * 1_000_000 + lines = [ + "from build123d import *", + "import math", + f"SOURCE_VOLUME_MM3 = {source_volume_mm3!r}", + f"SOURCE_AREA_MM2 = {source_area_mm2!r}", + "", + "def _dist(a, b):", + " return math.sqrt(sum((a[i] - b[i]) ** 2 for i in range(3)))", + "", + "def _owned_face_match_score(shape, expected_faces):", + " if not expected_faces:", + " return 0.0", + " try:", + " available = list(shape.faces())", + " except Exception:", + " return 1e99", + " total = 0.0", + " for expected in expected_faces:", + " bbox_m = expected.get('box_m')", + " if not bbox_m or len(bbox_m) < 6 or not available:", + " total += 1e6", + " continue", + " target_box = [float(v) * 1000 for v in bbox_m[:6]]", + " surface = expected.get('surface') or {}", + " target_type = next((name for name in ('plane', 'cylinder', 'cone', 'sphere', 'torus') if surface.get('is_' + name)), '')", + " target_area = float(expected.get('area_m2') or 0) * 1_000_000", + " ranked = []", + " for index, face in enumerate(available):", + " try:", + " fb = face.bounding_box()", + " face_box = [fb.min.X, fb.min.Y, fb.min.Z, fb.max.X, fb.max.Y, fb.max.Z]", + " geom = face.geom_type() if callable(face.geom_type) else face.geom_type", + " geom_name = getattr(geom, 'name', str(geom)).lower()", + " type_penalty = 0.0 if not target_type or target_type in geom_name else 1000.0", + " bbox_penalty = sum(abs(face_box[i] - target_box[i]) for i in range(6))", + " area_penalty = abs(float(face.area) - target_area) / max(math.sqrt(abs(target_area)), 1.0) if target_area else 0.0", + " ranked.append((type_penalty + bbox_penalty + area_penalty, index))", + " except Exception:", + " continue", + " if not ranked:", + " total += 1e6", + " continue", + " best, index = min(ranked, key=lambda item: item[0])", + " total += best", + " available.pop(index)", + " return total / max(len(expected_faces), 1)", + "", + "def _candidate_score(shape, expected_faces=None):", + " # Owned faces describe this exact SW history step. Final-part mass properties", + " # must not be used to choose an intermediate feature candidate.", + " if expected_faces:", + " return _owned_face_match_score(shape, expected_faces)", + " score = 0", + " if SOURCE_VOLUME_MM3 is not None:", + " try:", + " score += abs(float(shape.volume) - SOURCE_VOLUME_MM3)", + " except Exception:", + " score += 1e99", + " if SOURCE_AREA_MM2 is not None:", + " try:", + " score += abs(float(shape.area) - SOURCE_AREA_MM2) * 0.01", + " except Exception:", + " score += 1e99", + " score += _owned_face_match_score(shape, expected_faces)", + " return score", + "", + "def _edge_endpoints(edge):", + " vertices = [v.to_tuple() for v in edge.vertices()]", + " if len(vertices) != 2:", + " center = edge.center().to_tuple()", + " return center, center", + " return vertices[0], vertices[1]", + "", + "def _edge_match_score(edge, start, end):", + " a, b = _edge_endpoints(edge)", + " endpoint_score = min(_dist(a, start) + _dist(b, end), _dist(a, end) + _dist(b, start))", + " containment_score = edge.distance_to(start) + edge.distance_to(end)", + " return min(endpoint_score, containment_score)", + "", + "def select_edges_by_endpoints(part, selector_points, tolerance=0.5):", + " edges = list(part.edges())", + " selected = []", + " used = set()", + " for selector in selector_points:", + " start, end = selector", + " ranked = sorted(((_edge_match_score(edge, start, end), i, edge) for i, edge in enumerate(edges)), key=lambda item: item[0])", + " score, index, edge = ranked[0]", + " if score > tolerance:", + " raise ValueError(f\"No edge matched selector {selector}; best score={score:.4f} mm\")", + " if index not in used:", + " selected.append(edge)", + " used.add(index)", + " return selected", + "", + "def _bbox_match_score(edge, bbox_mm):", + " if not bbox_mm or len(bbox_mm) < 6:", + " return float('inf')", + " try:", + " a, b = _edge_endpoints(edge)", + " mid = tuple((a[i] + b[i]) / 2 for i in range(3))", + " mins = tuple(float(bbox_mm[i]) for i in range(3))", + " maxs = tuple(float(bbox_mm[i + 3]) for i in range(3))", + " diag = math.sqrt(sum((maxs[i] - mins[i]) ** 2 for i in range(3)))", + " pad = max(0.25, diag * 0.15)", + " def point_score(point):", + " total = 0.0", + " for axis in range(3):", + " if point[axis] < mins[axis] - pad:", + " total += mins[axis] - pad - point[axis]", + " elif point[axis] > maxs[axis] + pad:", + " total += point[axis] - maxs[axis] - pad", + " return total", + " return min(point_score(mid), (point_score(a) + point_score(b)) / 2)", + " except Exception:", + " return float('inf')", + "", + "def _circle_match_score(edge, circle_params):", + " if not circle_params or len(circle_params) < 7:", + " return float('inf')", + " try:", + " geom_type = edge.geom_type() if callable(edge.geom_type) else edge.geom_type", + " geom_name = getattr(geom_type, 'name', str(geom_type))", + " if 'CIRCLE' not in geom_name:", + " return float('inf')", + " target_center = tuple(float(v) * 1000 for v in circle_params[:3])", + " target_radius = float(circle_params[6]) * 1000", + " edge_center = edge.arc_center.to_tuple()", + " return _dist(edge_center, target_center) + abs(edge.radius - target_radius)", + " except Exception:", + " return float('inf')", + "", + "def _line_match_score(edge, line_params):", + " if not line_params or len(line_params) < 6:", + " return float('inf')", + " try:", + " geom_type = edge.geom_type() if callable(edge.geom_type) else edge.geom_type", + " geom_name = getattr(geom_type, 'name', str(geom_type))", + " if 'LINE' not in geom_name:", + " return float('inf')", + " target_point = tuple(float(v) * 1000 for v in line_params[:3])", + " target_dir = tuple(float(v) for v in line_params[3:6])", + " a, b = _edge_endpoints(edge)", + " edge_dir_raw = tuple(b[i] - a[i] for i in range(3))", + " length = math.sqrt(sum(v * v for v in edge_dir_raw))", + " if length <= 0:", + " return float('inf')", + " edge_dir = tuple(v / length for v in edge_dir_raw)", + " parallel = 1 - abs(sum(edge_dir[i] * target_dir[i] for i in range(3)))", + " distance = edge.distance_to(target_point)", + " return distance + parallel * 10", + " except Exception:", + " return float('inf')", + "", + "def select_edges_by_selectors(part, selectors, tolerance=0.5):", + " if part is None:", + " return []", + " edges = list(part.edges())", + " selected = []", + " used = set()", + " for selector in selectors or []:", + " geometry = selector.get('geometry', {})", + " start_vertex = geometry.get('start_vertex')", + " end_vertex = geometry.get('end_vertex')", + " start = start_vertex.get('point_m') if start_vertex else None", + " end = end_vertex.get('point_m') if end_vertex else None", + " bbox_mm = geometry.get('bbox_mm')", + " if start and end:", + " start_mm = tuple(float(v) * 1000 for v in start)", + " end_mm = tuple(float(v) * 1000 for v in end)", + " line_params = geometry.get('curve', {}).get('line_params')", + " if line_params:", + " ranked = sorted(((min(_edge_match_score(edge, start_mm, end_mm), _line_match_score(edge, line_params)) + (_bbox_match_score(edge, bbox_mm) if bbox_mm else 0), i, edge) for i, edge in enumerate(edges)), key=lambda item: item[0])", + " else:", + " ranked = sorted(((_edge_match_score(edge, start_mm, end_mm) + (_bbox_match_score(edge, bbox_mm) if bbox_mm else 0), i, edge) for i, edge in enumerate(edges)), key=lambda item: item[0])", + " else:", + " line_params = geometry.get('curve', {}).get('line_params')", + " circle_params = geometry.get('curve', {}).get('circle_params')", + " if line_params:", + " ranked = sorted(((_line_match_score(edge, line_params) + (_bbox_match_score(edge, bbox_mm) if bbox_mm else 0), i, edge) for i, edge in enumerate(edges)), key=lambda item: item[0])", + " elif bbox_mm:", + " ranked = sorted(((_bbox_match_score(edge, bbox_mm), i, edge) for i, edge in enumerate(edges)), key=lambda item: item[0])", + " else:", + " ranked = sorted(((_circle_match_score(edge, circle_params), i, edge) for i, edge in enumerate(edges)), key=lambda item: item[0])", + " score, index, edge = ranked[0]", + " selector_tolerance = float(selector.get('tolerance_mm') or tolerance)", + " if score > selector_tolerance:", + " # Skip edges that don't match well enough", + " continue", + " if index not in used:", + " selected.append(edge)", + " used.add(index)", + " return selected", + "", + "def _point_inside_bbox(point, bbox_mm, pad=0.25):", + " return all(float(bbox_mm[i]) - pad <= point[i] <= float(bbox_mm[i + 3]) + pad for i in range(3))", + "", + "def fillet_edges_from_owned_surface_bbox(part, selectors):", + " if part is None:", + " return []", + " boxes = []", + " seen_boxes = set()", + " for selector in selectors or []:", + " if selector.get('source') not in ('owned_cylindrical_face_axis', 'owned_face_bbox'):", + " continue", + " bbox = (selector.get('geometry') or {}).get('bbox_mm')", + " if bbox and len(bbox) >= 6:", + " normalized = [float(v) for v in bbox[:6]]", + " key = tuple(round(v, 6) for v in normalized)", + " if key not in seen_boxes:", + " seen_boxes.add(key)", + " boxes.append(normalized)", + " if len(boxes) < 2:", + " return []", + " selected = []", + " used_keys = set()", + " for box in boxes:", + " diag = math.sqrt(sum((box[i + 3] - box[i]) ** 2 for i in range(3)))", + " pad = max(0.25, diag * 0.08)", + " sizes = [abs(box[i + 3] - box[i]) for i in range(3)]", + " thin_axes = [i for i, size in enumerate(sizes) if size <= max(1.5, diag * 0.08)]", + " circle_candidates = []", + " if thin_axes:", + " thin_axis = thin_axes[0]", + " for edge in part.edges():", + " try:", + " geom_type = edge.geom_type() if callable(edge.geom_type) else edge.geom_type", + " geom_name = getattr(geom_type, 'name', str(geom_type))", + " if 'CIRCLE' not in geom_name:", + " continue", + " eb = edge.bounding_box()", + " edge_box = [eb.min.X, eb.min.Y, eb.min.Z, eb.max.X, eb.max.Y, eb.max.Z]", + " ok = True", + " score = 0.0", + " for axis in range(3):", + " if axis == thin_axis:", + " plane_delta = min(abs(edge_box[axis] - box[axis]), abs(edge_box[axis] - box[axis + 3]), abs(edge_box[axis + 3] - box[axis]), abs(edge_box[axis + 3] - box[axis + 3]))", + " if plane_delta > pad:", + " ok = False", + " break", + " score += plane_delta", + " else:", + " if edge_box[axis] < box[axis] - pad or edge_box[axis + 3] > box[axis + 3] + pad:", + " ok = False", + " break", + " score += abs(edge_box[axis] - box[axis]) + abs(edge_box[axis + 3] - box[axis + 3])", + " if not ok:", + " continue", + " key = tuple(round(v, 5) for v in edge_box)", + " circle_candidates.append((score, key, edge))", + " except Exception:", + " continue", + " if circle_candidates:", + " circle_candidates.sort(key=lambda item: item[0])", + " for _, key, edge in circle_candidates:", + " if key in used_keys:", + " continue", + " used_keys.add(key)", + " selected.append(edge)", + " break", + " continue", + " box_candidates = []", + " for edge in part.edges():", + " try:", + " geom_type = edge.geom_type() if callable(edge.geom_type) else edge.geom_type", + " geom_name = getattr(geom_type, 'name', str(geom_type))", + " if 'LINE' not in geom_name:", + " continue", + " a, b = _edge_endpoints(edge)", + " mid = tuple((a[i] + b[i]) / 2 for i in range(3))", + " if not (_point_inside_bbox(a, box, pad) and _point_inside_bbox(b, box, pad) and _point_inside_bbox(mid, box, pad)):", + " continue", + " key = tuple(round(v, 5) for point in (a, b) for v in point)", + " box_candidates.append((float(edge.length), key, edge))", + " except Exception:", + " continue", + " if not box_candidates:", + " continue", + " box_candidates.sort(key=lambda item: item[0], reverse=True)", + " for _, key, edge in box_candidates:", + " reverse_key = key[3:] + key[:3]", + " if key in used_keys or reverse_key in used_keys:", + " continue", + " used_keys.add(key)", + " selected.append(edge)", + " break", + " if selected:", + " return selected", + " union_bbox = [", + " min(box[i] for box in boxes) if i < 3 else max(box[i] for box in boxes)", + " for i in range(6)", + " ]", + " diag = math.sqrt(sum((union_bbox[i + 3] - union_bbox[i]) ** 2 for i in range(3)))", + " pad = max(0.25, diag * 0.05)", + " candidates = []", + " for edge in part.edges():", + " try:", + " geom_type = edge.geom_type() if callable(edge.geom_type) else edge.geom_type", + " geom_name = getattr(geom_type, 'name', str(geom_type))", + " if 'LINE' not in geom_name:", + " continue", + " a, b = _edge_endpoints(edge)", + " mid = tuple((a[i] + b[i]) / 2 for i in range(3))", + " if not (_point_inside_bbox(a, union_bbox, pad) and _point_inside_bbox(b, union_bbox, pad) and _point_inside_bbox(mid, union_bbox, pad)):", + " continue", + " candidates.append((float(edge.length), edge))", + " except Exception:", + " continue", + " if not candidates:", + " return []", + " candidates.sort(key=lambda item: item[0], reverse=True)", + " return [candidates[0][1]]", + "", + "def fillet_with_tolerance(edges, radius):", + " radii = [float(radius)]", + " shrink = max(0.001, abs(float(radius)) * 0.001)", + " if float(radius) > shrink:", + " radii.append(float(radius) - shrink)", + " radii.append(float(radius) * 0.99)", + " last_error = None", + " for candidate_radius in radii:", + " if candidate_radius <= 0:", + " continue", + " try:", + " return fillet(edges, radius=candidate_radius)", + " except Exception as exc:", + " last_error = exc", + " continue", + " if last_error:", + " raise last_error", + " return fillet(edges, radius=radius)", + "", + "def fillet_selected(part, radius, selectors, owned_faces=None):", + " if part is None:", + " return part", + " if not selectors:", + " # No edge selectors - skip fillet to avoid failing on all edges", + " return part", + " candidates = []", + " owned_edges = fillet_edges_from_owned_surface_bbox(part, selectors)", + " if owned_edges:", + " try:", + " candidates.append(fillet_with_tolerance(owned_edges, radius))", + " except Exception:", + " pass", + " try:", + " target_edges = select_edges_by_selectors(part, selectors)", + " if target_edges:", + " candidates.append(fillet_with_tolerance(target_edges, radius))", + " except Exception:", + " pass", + " result = part", + " applied_any = False", + " for selector in selectors:", + " edges = select_edges_by_selectors(result, [selector])", + " if not edges:", + " continue # Skip selectors that don't match any edge", + " try:", + " result = fillet_with_tolerance([edges[0]], radius)", + " applied_any = True", + " except Exception:", + " # OCC fillets are fragile: one invalid edge/radius should not abort the whole rebuild.", + " continue", + " if applied_any:", + " candidates.append(result)", + " variants = []", + " for selector in selectors:", + " edges = select_edges_by_selectors(part, [selector])", + " if not edges:", + " continue", + " try:", + " variants.append(fillet_with_tolerance([edges[0]], radius))", + " except Exception:", + " continue", + " if variants:", + " try:", + " union_result = part", + " for variant in variants:", + " union_result = union_result + variant", + " candidates.append(union_result)", + " except Exception:", + " pass", + " try:", + " intersection_result = part", + " for variant in variants:", + " intersection_result = intersection_result & variant", + " candidates.append(intersection_result)", + " except Exception:", + " pass", + " if candidates:", + " return sorted(candidates, key=lambda shape: _candidate_score(shape, owned_faces))[0]", + " return part", + "", + "def chamfer_selected(part, distance, selectors, owned_faces=None):", + " if part is None:", + " return part", + " if not selectors:", + " # No edge selectors available - chamfer would fail on all edges", + " return part", + " candidates = []", + " owned_edges = fillet_edges_from_owned_surface_bbox(part, selectors)", + " if owned_edges:", + " try:", + " candidates.append(chamfer(owned_edges, length=distance))", + " except Exception:", + " pass", + " target_edges = select_edges_by_selectors(part, selectors)", + " if target_edges:", + " try:", + " candidates.append(chamfer(target_edges, length=distance))", + " except Exception:", + " pass", + " result = part", + " applied_any = False", + " for selector in selectors:", + " edges = select_edges_by_selectors(result, [selector])", + " if not edges:", + " continue", + " try:", + " result = chamfer([edges[0]], length=distance)", + " applied_any = True", + " except Exception:", + " continue", + " if applied_any:", + " candidates.append(result)", + " if candidates:", + " return sorted(candidates, key=lambda shape: _candidate_score(shape, owned_faces))[0]", + " return part", + "", + "def is_internal_cone_face(face, part):", + " try:", + " bbox_m = face.get('box_m')", + " surface = face.get('surface') or {}", + " if not (bbox_m and len(bbox_m) >= 6 and surface.get('is_cone')):", + " return False", + " params = surface.get('cone_params')", + " if not params or len(params) < 6:", + " return False", + " direction = tuple(float(v) for v in params[3:6])", + " axis = max(range(3), key=lambda i: abs(direction[i]))", + " radial_axes = tuple(i for i in range(3) if i != axis)", + " part_bbox = part.bounding_box()", + " part_min = part_bbox.min.to_tuple()", + " part_max = part_bbox.max.to_tuple()", + " mins = tuple(float(bbox_m[i]) * 1000 for i in range(3))", + " maxs = tuple(float(bbox_m[i + 3]) * 1000 for i in range(3))", + " tol = 0.25", + " touches_outer = any(", + " abs(mins[i] - part_min[i]) <= tol or abs(maxs[i] - part_max[i]) <= tol", + " for i in radial_axes", + " )", + " return not touches_outer", + " except Exception:", + " return False", + "", + "def is_external_cone_face(face, part):", + " try:", + " bbox_m = face.get('box_m')", + " surface = face.get('surface') or {}", + " if not (bbox_m and len(bbox_m) >= 6 and surface.get('is_cone')):", + " return False", + " params = surface.get('cone_params')", + " if not params or len(params) < 6:", + " return False", + " direction = tuple(float(v) for v in params[3:6])", + " axis = max(range(3), key=lambda i: abs(direction[i]))", + " radial_axes = tuple(i for i in range(3) if i != axis)", + " part_bbox = part.bounding_box()", + " part_min = part_bbox.min.to_tuple()", + " part_max = part_bbox.max.to_tuple()", + " mins = tuple(float(bbox_m[i]) * 1000 for i in range(3))", + " maxs = tuple(float(bbox_m[i + 3]) * 1000 for i in range(3))", + " tol = 0.25", + " return any(", + " abs(mins[i] - part_min[i]) <= tol or abs(maxs[i] - part_max[i]) <= tol", + " for i in radial_axes", + " )", + " except Exception:", + " return False", + "", + "def make_owned_external_cone_chamfer_cutter(face):", + " surface = face.get('surface') or {}", + " params = surface.get('cone_params')", + " bbox_m = face.get('box_m')", + " if not params or len(params) < 8 or not bbox_m or len(bbox_m) < 6:", + " return None", + " origin = tuple(float(v) * 1000 for v in params[:3])", + " direction = tuple(float(v) for v in params[3:6])", + " norm = math.sqrt(sum(v * v for v in direction))", + " base_radius = abs(float(params[6]) * 1000)", + " half_angle = abs(float(params[7]))", + " if norm <= 1e-9 or base_radius <= 1e-9 or half_angle <= 1e-9:", + " return None", + " direction = tuple(v / norm for v in direction)", + " mins = tuple(float(bbox_m[i]) * 1000 for i in range(3))", + " maxs = tuple(float(bbox_m[i + 3]) * 1000 for i in range(3))", + " projections = []", + " for x in (mins[0], maxs[0]):", + " for y in (mins[1], maxs[1]):", + " for z in (mins[2], maxs[2]):", + " delta = (x - origin[0], y - origin[1], z - origin[2])", + " axial = sum(delta[i] * direction[i] for i in range(3))", + " projections.append(axial)", + " start = min(projections)", + " end = max(projections)", + " height = max(0.001, end - start)", + " r1 = max(0.0, base_radius - math.tan(half_angle) * start)", + " r2 = max(0.0, base_radius - math.tan(half_angle) * end)", + " outer_radius = max(r1, r2) + 0.001", + " center_offset = (start + end) / 2", + " center = tuple(origin[i] + direction[i] * center_offset for i in range(3))", + " if r1 <= 1e-9:", + " r1 = 1e-6", + " if r2 <= 1e-9:", + " r2 = 1e-6", + " with BuildPart(Plane(origin=center, z_dir=direction)) as cutter_part:", + " Cylinder(outer_radius, height, align=(Align.CENTER, Align.CENTER, Align.CENTER))", + " Cone(r1, r2, height + 0.002, align=(Align.CENTER, Align.CENTER, Align.CENTER), mode=Mode.SUBTRACT)", + " return cutter_part.part", + "", + "def chamfer_owned_external_cones(part, faces):", + " if part is None:", + " return part, False", + " result = part", + " applied = False", + " for face in faces or []:", + " if not is_external_cone_face(face, result):", + " continue", + " cutter = make_owned_external_cone_chamfer_cutter(face)", + " new_result = safe_subtract(result, cutter)", + " if new_result is not result:", + " result = new_result", + " applied = True", + " return result, applied", + "", + "def chamfer_owned_internal_cones(part, faces):", + " if part is None:", + " return part, False", + " result = part", + " applied = False", + " for face in faces or []:", + " if not is_internal_cone_face(face, result):", + " continue", + " cutter = make_owned_cone_cutter(face)", + " new_result = safe_subtract(result, cutter)", + " if new_result is not result:", + " result = new_result", + " applied = True", + " return result, applied", + "", + "def chamfer_selected_with_owned_faces(part, distance, selectors, owned_faces):", + " cone_faces = [face for face in (owned_faces or []) if (face.get('surface') or {}).get('is_cone')]", + " if len(cone_faces) == 1 and is_internal_cone_face(cone_faces[0], part):", + " result, applied = chamfer_owned_internal_cones(part, cone_faces)", + " if applied:", + " return result", + " if len(cone_faces) == 1 and is_external_cone_face(cone_faces[0], part):", + " result, applied = chamfer_owned_external_cones(part, cone_faces)", + " if applied:", + " return result", + " return chamfer_selected(part, distance, selectors, owned_faces)", + "", + "def safe_subtract(part, cutter):", + " if part is None or cutter is None:", + " return part", + " try:", + " vol_before = float(part.volume)", + " except Exception:", + " vol_before = -1", + " try:", + " cut = part - cutter", + " if cut is None:", + " print(f' SUBTRACT: cutter resulted in None, keeping original (vol={vol_before:.0f})')", + " return part", + " # Accept the cut even when solids() reports 0 – can happen", + " # for valid boolean results with non-standard structures.", + " try:", + " nb_solids = len(list(cut.solids()))", + " if nb_solids == 0:", + " print(f' SUBTRACT: cut produced 0 solids (still accepting) vol={vol_before:.0f}')", + " except Exception:", + " pass", + " return cut", + " except Exception as e:", + " print(f' SUBTRACT: exception {type(e).__name__}: {e}, keeping original (vol={vol_before:.0f})')", + " return part", + "", + "def _project_bbox_along_direction(bbox, origin, direction):", + " mins = tuple(float(bbox[i]) for i in range(3))", + " maxs = tuple(float(bbox[i + 3]) for i in range(3))", + " projections = []", + " for x in (mins[0], maxs[0]):", + " for y in (mins[1], maxs[1]):", + " for z in (mins[2], maxs[2]):", + " projections.append(sum(((x, y, z)[i] - origin[i]) * direction[i] for i in range(3)))", + " return min(projections), max(projections)", + "", + "def make_owned_cylinder_cutter(face, target_part=None):", + " surface = face.get('surface') or {}", + " params = surface.get('cylinder_params')", + " bbox_m = face.get('box_m')", + " if not params or len(params) < 7 or not bbox_m or len(bbox_m) < 6:", + " return None", + " origin = tuple(float(v) * 1000 for v in params[:3])", + " direction = tuple(float(v) for v in params[3:6])", + " norm = math.sqrt(sum(v * v for v in direction))", + " radius = abs(float(params[6]) * 1000)", + " if norm <= 1e-9 or radius <= 1e-9:", + " return None", + " direction = tuple(v / norm for v in direction)", + " mins = tuple(float(bbox_m[i]) * 1000 for i in range(3))", + " maxs = tuple(float(bbox_m[i + 3]) * 1000 for i in range(3))", + " start, end = _project_bbox_along_direction((*mins, *maxs), origin, direction)", + " if target_part is not None:", + " try:", + " part_bbox = target_part.bounding_box()", + " part_box = (*part_bbox.min.to_tuple(), *part_bbox.max.to_tuple())", + " part_start, part_end = _project_bbox_along_direction(part_box, origin, direction)", + " through_tolerance = max(1.0, radius * 0.12)", + " if abs(start - part_start) <= through_tolerance:", + " start = part_start", + " if abs(end - part_end) <= through_tolerance:", + " end = part_end", + " except Exception:", + " pass", + " height = max(0.001, end - start)", + " center_offset = (start + end) / 2", + " center = tuple(origin[i] + direction[i] * center_offset for i in range(3))", + " with BuildPart(Plane(origin=center, z_dir=direction)) as cutter_part:", + " Cylinder(radius, height, align=(Align.CENTER, Align.CENTER, Align.CENTER))", + " return cutter_part.part", + "", + "def make_owned_cone_cutter(face):", + " surface = face.get('surface') or {}", + " params = surface.get('cone_params')", + " bbox_m = face.get('box_m')", + " if not params or len(params) < 8 or not bbox_m or len(bbox_m) < 6:", + " return None", + " origin = tuple(float(v) * 1000 for v in params[:3])", + " direction = tuple(float(v) for v in params[3:6])", + " norm = math.sqrt(sum(v * v for v in direction))", + " base_radius = abs(float(params[6]) * 1000)", + " half_angle = abs(float(params[7]))", + " if norm <= 1e-9 or base_radius <= 1e-9 or half_angle <= 1e-9:", + " return None", + " direction = tuple(v / norm for v in direction)", + " mins = tuple(float(bbox_m[i]) * 1000 for i in range(3))", + " maxs = tuple(float(bbox_m[i + 3]) * 1000 for i in range(3))", + " projections = []", + " for x in (mins[0], maxs[0]):", + " for y in (mins[1], maxs[1]):", + " for z in (mins[2], maxs[2]):", + " projections.append(sum(((x, y, z)[i] - origin[i]) * direction[i] for i in range(3)))", + " start = min(projections)", + " end = max(projections)", + " # Keep a tiny overlap for the boolean while preserving blind-hole depth.", + " height = max(0.001, end - start) + 0.001", + " # SolidWorks ConeParams stores the radius at the cone origin; along the axis", + " # direction the radius tapers rather than expands for hole drill tips.", + " r1 = max(0.0, base_radius - math.tan(half_angle) * start)", + " r2 = max(0.0, base_radius - math.tan(half_angle) * end)", + " if max(r1, r2) <= 1e-9:", + " return None", + " if r1 <= 1e-9:", + " r1 = 1e-6", + " if r2 <= 1e-9:", + " r2 = 1e-6", + " center_offset = (start + end) / 2", + " center = tuple(origin[i] + direction[i] * center_offset for i in range(3))", + " with BuildPart(Plane(origin=center, z_dir=direction)) as cutter_part:", + " Cone(r1, r2, height, align=(Align.CENTER, Align.CENTER, Align.CENTER))", + " return cutter_part.part", + "", + "def make_owned_face_cutter(face, target_part=None):", + " surface = face.get('surface') or {}", + " if surface.get('is_cylinder'):", + " return make_owned_cylinder_cutter(face, target_part)", + " if surface.get('is_cone'):", + " return make_owned_cone_cutter(face)", + " return None", + "", + "def cut_owned_cylindrical_faces(part, faces):", + " result = part", + " for face in faces or []:", + " cutter = make_owned_face_cutter(face, result)", + " result = safe_subtract(result, cutter)", + " return result", + "", + "def make_owned_flip_side_ring_cutter(face, target_part):", + " surface = face.get('surface') or {}", + " params = surface.get('cylinder_params')", + " bbox_m = face.get('box_m')", + " if target_part is None or not params or len(params) < 7 or not bbox_m or len(bbox_m) < 6:", + " return None", + " origin = tuple(float(v) * 1000 for v in params[:3])", + " direction = tuple(float(v) for v in params[3:6])", + " norm = math.sqrt(sum(v * v for v in direction))", + " inner_radius = abs(float(params[6]) * 1000)", + " if norm <= 1e-9 or inner_radius <= 1e-9:", + " return None", + " direction = tuple(v / norm for v in direction)", + " mins = tuple(float(bbox_m[i]) * 1000 for i in range(3))", + " maxs = tuple(float(bbox_m[i + 3]) * 1000 for i in range(3))", + " start, end = _project_bbox_along_direction((*mins, *maxs), origin, direction)", + " height = max(0.001, end - start)", + " center_offset = (start + end) / 2", + " center = tuple(origin[i] + direction[i] * center_offset for i in range(3))", + " try:", + " part_bbox = target_part.bounding_box()", + " part_min = part_bbox.min.to_tuple()", + " part_max = part_bbox.max.to_tuple()", + " radial = []", + " for x in (part_min[0], part_max[0]):", + " for y in (part_min[1], part_max[1]):", + " for z in (part_min[2], part_max[2]):", + " delta = (x - origin[0], y - origin[1], z - origin[2])", + " axial = sum(delta[i] * direction[i] for i in range(3))", + " perp = tuple(delta[i] - axial * direction[i] for i in range(3))", + " radial.append(math.sqrt(sum(v * v for v in perp)))", + " outer_radius = max(radial) + max(1.0, inner_radius * 0.05)", + " except Exception:", + " outer_radius = inner_radius + 100.0", + " if outer_radius <= inner_radius + 1e-6:", + " return None", + " with BuildPart(Plane(origin=center, z_dir=direction)) as cutter_part:", + " Cylinder(outer_radius, height, align=(Align.CENTER, Align.CENTER, Align.CENTER))", + " Cylinder(inner_radius, height + 0.002, align=(Align.CENTER, Align.CENTER, Align.CENTER), mode=Mode.SUBTRACT)", + " return cutter_part.part", + "", + "def cut_owned_flip_side_cylindrical_faces(part, faces):", + " result = part", + " for face in faces or []:", + " cutter = make_owned_flip_side_ring_cutter(face, result)", + " result = safe_subtract(result, cutter)", + " return result", + "", + "def cut_owned_bbox(part, bbox_mm):", + " if part is None or not bbox_mm or len(bbox_mm) < 6:", + " return part", + " mins = tuple(float(bbox_mm[i]) for i in range(3))", + " maxs = tuple(float(bbox_mm[i + 3]) for i in range(3))", + " size = tuple(max(0.001, maxs[i] - mins[i]) for i in range(3))", + " center = tuple((mins[i] + maxs[i]) / 2 for i in range(3))", + " cutter = Pos(center) * Box(size[0], size[1], size[2])", + " return safe_subtract(part, cutter)", + "", + "def shape_face_count(shape):", + " if shape is None:", + " return 0", + " try:", + " return len(list(shape.faces()))", + " except Exception:", + " return 0", + "", + "def safe_union(part, solid, preserve_visible=False):", + " if part is None:", + " return solid", + " if solid is None:", + " return part", + " try:", + " fused = part + solid", + " # OCCT fuse succeeded; always return the fused result.", + " # is_valid() can return False for edge cases where the geometry", + " # is actually correct (e.g. touching-at-faces). Accept it.", + " return fused", + " except Exception as e:", + " print(f' UNION: fuse threw {type(e).__name__}: {e}')", + " pass", + " try:", + " compound = Compound.make_composite([part, solid])", + " fused = compound.fuse()", + " try:", + " if len(list(fused.solids())) > 0:", + " print(f' UNION: compound.fuse() worked, {len(list(fused.solids()))} solids')", + " return fused", + " except Exception:", + " pass", + " except Exception as e:", + " print(f' UNION: compound.fuse() threw {type(e).__name__}: {e}')", + " pass", + " shapes = []", + " try:", + " shapes.extend(list(part.solids()))", + " except Exception:", + " shapes.append(part)", + " try:", + " shapes.extend(list(solid.solids()))", + " except Exception:", + " shapes.append(solid)", + " return Compound.make_composite(shapes)", + "", + "def sw_inverted_profile_cut(part, profile_solid, normal):", + " if part is None or profile_solid is None:", + " return part", + " try:", + " part_bbox = part.bounding_box()", + " profile_bbox = profile_solid.bounding_box()", + " n = tuple(float(v) for v in normal)", + " axis = max(range(3), key=lambda i: abs(n[i]))", + " part_min = part_bbox.min.to_tuple()", + " part_max = part_bbox.max.to_tuple()", + " prof_min = profile_bbox.min.to_tuple()", + " prof_max = profile_bbox.max.to_tuple()", + " margin = 5.0", + " mins = [part_min[i] - margin for i in range(3)]", + " maxs = [part_max[i] + margin for i in range(3)]", + " mins[axis] = prof_min[axis] - margin * 0.05", + " maxs[axis] = prof_max[axis] + margin * 0.05", + " center = tuple((mins[i] + maxs[i]) / 2 for i in range(3))", + " size = tuple(max(0.001, maxs[i] - mins[i]) for i in range(3))", + " envelope = Pos(center) * Box(size[0], size[1], size[2])", + " outside_profile = safe_subtract(envelope, profile_solid)", + " return safe_subtract(part, outside_profile)", + " except Exception:", + " return part", + "", + "def sw_flip_side_step_cut(part, profile_solid, normal, outer_radius_mm, inner_radius_mm):", + " part = sw_inverted_profile_cut(part, profile_solid, normal)", + " if part is None or profile_solid is None:", + " return part", + " try:", + " outer_radius = abs(float(outer_radius_mm))", + " inner_radius = abs(float(inner_radius_mm))", + " except Exception:", + " return part", + " if outer_radius <= inner_radius + 1e-6:", + " return part", + " try:", + " profile_bbox = profile_solid.bounding_box()", + " prof_min = profile_bbox.min.to_tuple()", + " prof_max = profile_bbox.max.to_tuple()", + " center = tuple((prof_min[i] + prof_max[i]) / 2 for i in range(3))", + " n = tuple(float(v) for v in normal)", + " axis = max(range(3), key=lambda i: abs(n[i]))", + " span_xy = max(prof_max[0] - prof_min[0], prof_max[1] - prof_min[1])", + " margin_xy = max(2.0, span_xy * 0.05)", + " margin_z = 0.1", + " size = tuple(", + " max(0.001, prof_max[i] - prof_min[i] + (margin_xy if i < 2 else margin_z))", + " for i in range(3)", + " )", + " plane = Plane(", + " origin=center,", + " x_dir=(1.0, 0.0, 0.0) if axis != 0 else (0.0, 1.0, 0.0),", + " z_dir=n,", + " )", + " cut_extent = prof_max[axis] - prof_min[axis]", + " cut_amount = -abs(cut_extent) if n[axis] < 0 else abs(cut_extent)", + " with BuildSketch(plane) as ring_sketch:", + " Circle(outer_radius)", + " Circle(inner_radius, mode=Mode.SUBTRACT)", + " ring = extrude(ring_sketch.sketch, amount=cut_amount)", + " return safe_union(part, ring)", + " except Exception:", + " return part", + "", + "def sw_cut_holes(part, positions, host_face, diameter, depth, drill_angle=0, include_drill_tip=False, countersink_diameter=0, countersink_angle=0, counterbore_diameter=0, counterbore_depth=0):", + " if part is None:", + " return part", + " if not positions or diameter <= 0 or depth <= 0:", + " return part", + " plane = host_face.get('surface', {}).get('plane_params') or [0, 0, 1, 0, 0, 0]", + " frame = host_face.get('frame') or {}", + " normal = tuple(float(v) for v in plane[:3])", + " plane_point = tuple(float(v) * 1000 for v in plane[3:6])", + " origin = tuple(float(v) for v in frame.get('origin_mm', plane_point))", + " x_dir = tuple(float(v) for v in frame.get('x_dir', (0, 0, 0)))", + " y_dir = tuple(float(v) for v in frame.get('y_dir', (0, 0, 0)))", + " has_frame = sum(abs(v) for v in x_dir) > 0 and sum(abs(v) for v in y_dir) > 0", + " bbox = part.bounding_box()", + " part_center = tuple((bbox.min.to_tuple()[i] + bbox.max.to_tuple()[i]) / 2 for i in range(3))", + " toward_center = tuple(part_center[i] - plane_point[i] for i in range(3))", + " dot = sum(toward_center[i] * normal[i] for i in range(3))", + " inward = normal if dot >= 0 else tuple(-v for v in normal)", + " axis = max(range(3), key=lambda i: abs(inward[i]))", + " rotation = (0, 0, 0)", + " if axis == 0:", + " rotation = (0, 90, 0) if inward[0] >= 0 else (0, -90, 0)", + " elif axis == 1:", + " rotation = (-90, 0, 0) if inward[1] >= 0 else (90, 0, 0)", + " elif inward[2] < 0:", + " rotation = (180, 0, 0)", + " tip_depth = 0", + " if include_drill_tip and drill_angle > 0:", + " tip_depth = (diameter / 2) / math.tan(drill_angle / 2)", + " countersink_depth = 0", + " if countersink_diameter > diameter and countersink_angle > 0:", + " countersink_depth = ((countersink_diameter - diameter) / 2) / math.tan(countersink_angle / 2)", + " result = part", + " for pos in positions:", + " x, y = float(pos[0]), float(pos[1])", + " if has_frame:", + " start = tuple(origin[i] + x_dir[i] * x + y_dir[i] * y for i in range(3))", + " elif axis == 0:", + " start = (plane_point[0], x, y)", + " elif axis == 1:", + " start = (x, plane_point[1], -y)", + " else:", + " start = (x, y, plane_point[2])", + " cut_depth = depth", + " if depth >= 199:", + " part_min = bbox.min.to_tuple()", + " part_max = bbox.max.to_tuple()", + " corners = []", + " for ci in range(2):", + " for cj in range(2):", + " for ck in range(2):", + " corners.append((", + " part_min[0] if ci else part_max[0],", + " part_min[1] if cj else part_max[1],", + " part_min[2] if ck else part_max[2],", + " ))", + " cut_depth = max(", + " sum((corner[i] - start[i]) * inward[i] for i in range(3))", + " for corner in corners", + " ) + 2.0", + " cutters = []", + " cb_depth = counterbore_depth if counterbore_diameter > diameter and counterbore_depth > 0 else 0", + " cs_depth = countersink_depth if countersink_depth > 0 else 0", + " hole_start = cs_depth", + " hole_depth = max(0.001, cut_depth - hole_start - cb_depth)", + " if hole_depth > 0:", + " hole_center = tuple(start[i] + inward[i] * (hole_start + cb_depth + hole_depth / 2) for i in range(3))", + " cutters.append(Pos(hole_center) * Cylinder(diameter / 2, hole_depth, rotation=rotation))", + " if cb_depth > 0:", + " cb_center = tuple(start[i] + inward[i] * (hole_start + cb_depth / 2) for i in range(3))", + " cutters.append(Pos(cb_center) * Cylinder(counterbore_diameter / 2, cb_depth, rotation=rotation))", + " if cs_depth > 0:", + " cs_center = tuple(start[i] + inward[i] * cs_depth / 2 for i in range(3))", + " cs = Pos(cs_center) * Cone(countersink_diameter / 2, diameter / 2, cs_depth, rotation=rotation)", + " cutters.append(cs)", + " if tip_depth > 0:", + " base = tuple(start[i] + inward[i] * cut_depth for i in range(3))", + " tip_center = tuple(base[i] + inward[i] * tip_depth / 2 for i in range(3))", + " tip = Pos(tip_center) * Cone(diameter / 2, 0, tip_depth, rotation=rotation)", + " cutters.append(tip)", + " if len(cutters) == 1:", + " cutter = cutters[0]", + " else:", + " cutter = Compound.make_composite(cutters)", + " result = safe_subtract(result, cutter)", + " return result", + "", + ] + + part_name_clean = get_part_name(data) + lines.append(f"def build_{part_name_clean}():") + lines.append(' """Auto-generated build123d code from SolidWorks IR."""') + lines.append("") + + sketches = {s["id"]: s for s in data.get("sketches", [])} + operations = data.get("operations", []) + references = {r["id"]: r for r in data.get("references", [])} + generated_sketches = set() + + lines.append(" result = None") + lines.append("") + + for op in sort_operations_for_history(operations): + op_type = op.get("type", "") + op_name = op.get("name", "") + if op_type in ["unsupported", "unknown"]: + lines.append(f" # Skipping unsupported metadata feature: {op_name}") + lines.append("") + continue + + if op_type == "imported_body": + lines.extend(_generate_imported_body_pending(op)) + elif op_type == "assembly_compose": + lines.extend(_generate_assembly_compose(op)) + elif op_type == "move_face": + lines.extend(_generate_move_face(op)) + elif op_type == "fillet": + lines.extend(_generate_fillet(op)) + elif op_type == "chamfer": + lines.extend(_generate_chamfer(op)) + elif op_type == "hole": + lines.extend(_generate_hole(op)) + elif op_type in ("extrude_cut", "extrude_add"): + build_op = _resolve_extrude_owned_termination(op, sketches.get(op.get("sketch") or "")) + sketch_id = op.get("sketch") + if sketch_id and sketch_id in sketches and not _sketch_has_buildable_profile(sketches[sketch_id]): + lines.append(f" # Skip: sketch has no buildable closed/profile geometry for {op_name}") + continue + if sketch_id and sketch_id in sketches and sketch_id not in generated_sketches: + lines.extend(_generate_sketch(sketches[sketch_id], references, build_op)) + generated_sketches.add(sketch_id) + lines.extend(_generate_extrude(build_op, sketches.get(sketch_id, {}), operations, sketches)) + elif op_type in ("revolve_cut", "revolve_add"): + sketch_id = op.get("sketch") + if sketch_id and sketch_id in sketches and not _sketch_has_buildable_profile(sketches[sketch_id]): + lines.append(f" # Skip: sketch has no buildable closed/profile geometry for {op_name}") + continue + if sketch_id and sketch_id in sketches and sketch_id not in generated_sketches: + lines.extend(_generate_sketch(sketches[sketch_id], references, op)) + generated_sketches.add(sketch_id) + lines.extend(_generate_revolve(op, sketches.get(sketch_id, {}))) + elif op_type in ("linear_pattern", "pattern_linear"): + lines.extend(_generate_linear_pattern(op, operations, sketches, references)) + elif op_type == "pattern_mirror": + lines.extend(_generate_mirror_pattern(op, operations, sketches, references)) + else: + lines.append(f" # TODO: {op_type} - {op_name}") + + lines.append("") + + lines.append(" if result is None:") + lines.append(' raise Exception("No solid was created")') + lines.append("") + lines.append(" # Clean up small inaccuracies from Boolean operations") + lines.append(" try:") + lines.append(" result = result.clean()") + lines.append(" except Exception:") + lines.append(" pass") + lines.append(f' export_step(result, "{part_name_clean}.step")') + lines.append(" return result") + lines.append("") + lines.append("# Run the function") + lines.append('if __name__ == "__main__":') + lines.append(f" build_{part_name_clean}()") + + return "\n".join(lines) + + +def _generate_imported_body_pending(op: Dict[str, Any]) -> list[str]: + return [ + f" # Imported body requires generic JSON B-Rep reconstruction: {op.get('name', '')}", + " raise NotImplementedError(", + " 'Pure-JSON imported-body reconstruction is not implemented yet; '", + " 'the plugin captured solid_bodies topology and the part is marked not ready.'", + " )", + ] + + +def _generate_assembly_compose(op: Dict[str, Any]) -> list[str]: + params = op.get("parameters") or {} + components = params.get("components") or [] + component_ids = [component.get("component_id") for component in components] + message = f"Assembly requires rebuilt component JSON registry: {component_ids!r}" + return [ + f" # Pure-JSON assembly composition: {op.get('name', '')}", + " raise NotImplementedError(", + f" {message!r}", + " )", + ] + + +def _sw_math_transform_matrix(array_data: Any, component_name: str) -> list[list[float]]: + if not isinstance(array_data, list) or len(array_data) < 13: + raise ValueError(f"Assembly component {component_name} has no complete 16-value transform") + values = [float(value or 0) for value in array_data] + scale = values[12] + if abs(scale) <= 1e-12: + raise ValueError(f"Assembly component {component_name} has an invalid zero scale") + # SOLIDWORKS stores row-vector axes and translation in elements 9..11. + # build123d/OpenCascade uses a column-vector 3x4 matrix, hence transpose. + return [ + [values[0] * scale, values[3] * scale, values[6] * scale, values[9] * 1000.0], + [values[1] * scale, values[4] * scale, values[7] * scale, values[10] * 1000.0], + [values[2] * scale, values[5] * scale, values[8] * scale, values[11] * 1000.0], + [0.0, 0.0, 0.0, 1.0], + ] + + +def sort_operations_for_history(operations: list[Dict[str, Any]]) -> list[Dict[str, Any]]: + """Return operations in SW rebuild order.""" + if _looks_like_reverse_history(operations): + return list(reversed(operations)) + if all(op.get("source_feature", {}).get("index") is not None for op in operations): + return sorted(operations, key=lambda op: op.get("source_feature", {}).get("index", 0)) + return sorted(operations, key=_operation_priority) + + +def _looks_like_reverse_history(operations: list[Dict[str, Any]]) -> bool: + build_ops = [ + op + for op in operations + if op.get("type") not in ("unsupported", "unknown") + ] + if len(build_ops) < 2: + return False + additive = {"extrude_add", "revolve_add", "sweep", "loft"} + downstream = {"extrude_cut", "revolve_cut", "fillet", "chamfer", "hole", "linear_pattern", "pattern_linear"} + return build_ops[0].get("type") in downstream and build_ops[-1].get("type") in additive + + +def _operation_priority(op: Dict[str, Any]) -> int: + op_type = op.get("type", "") + if op_type == "extrude_add": + return 0 + if op_type in ("extrude_cut", "revolve_cut"): + return 1 + if op_type == "revolve_add": + return 2 + if op_type in ("fillet", "chamfer"): + return 3 + if op_type in ("sweep", "loft"): + return 4 + return 99 + + +def _sketch_has_buildable_profile(sketch: Dict[str, Any]) -> bool: + for entity in sketch.get("entities", []) or []: + if entity.get("construction"): + continue + if entity.get("type") == "circle" and float(entity.get("radius_mm") or 0) > 0: + return True + if entity.get("type") == "arc" and float(entity.get("radius_mm") or 0) > 0: + return True + valid_lines = 0 + for entity in sketch.get("entities", []) or []: + if entity.get("construction") or entity.get("type") != "line": + continue + start = entity.get("start") or [0, 0] + end = entity.get("end") or [0, 0] + if math.hypot(float(start[0]) - float(end[0]), float(start[1]) - float(end[1])) > 1e-6: + valid_lines += 1 + return valid_lines >= 2 + + +def _point_key(point: Any, places: int = 5) -> tuple[float, float] | None: + if not isinstance(point, list) or len(point) < 2: + return None + return (round(float(point[0]), places), round(float(point[1]), places)) + + +def _reverse_curve_entity(ent: Dict[str, Any]) -> Dict[str, Any]: + """Reverse a sketch segment while preserving its geometric traversal.""" + reversed_ent = dict(ent) + reversed_ent["start"], reversed_ent["end"] = ent.get("end"), ent.get("start") + reversed_ent["reversed"] = not bool(ent.get("reversed", False)) + if ent.get("type") == "arc": + raw = ent.get("raw") if isinstance(ent.get("raw"), dict) else {} + axis = ent.get("curve_axis") or raw.get("curve_axis") + if isinstance(axis, list) and len(axis) >= 3: + # The arc's endpoints and orientation are a pair. Keep the + # source `raw` untouched, but provide a flipped top-level axis for + # code generation so a reversed minor arc remains a minor arc. + reversed_ent["curve_axis"] = [-float(value) for value in axis[:3]] + # 必须删除预置的角度字段,否则代码生成会使用旧的(start,end未翻转时的)角度, + # 导致弧段遍历方向与连接顺序相反(如对外弧CW而对内弧也CW而非CCW)。 + reversed_ent.pop("start_angle_deg", None) + reversed_ent.pop("end_angle_deg", None) + reversed_ent.pop("arc_sweep_deg", None) + return reversed_ent + + +def _ordered_wire_entities(entities: list[Dict[str, Any]]) -> list[Dict[str, Any]]: + """Order sketch line/arc entities into connected loops when SW did not export contours.""" + drawable = [ + ent for ent in entities + if ent.get("type") in ("line", "arc") + and _point_key(ent.get("start")) is not None + and _point_key(ent.get("end")) is not None + ] + if len(drawable) < 3: + return entities + + by_node: dict[tuple[float, float], list[tuple[int, str]]] = {} + for idx, ent in enumerate(drawable): + by_node.setdefault(_point_key(ent.get("start")), []).append((idx, "start")) + by_node.setdefault(_point_key(ent.get("end")), []).append((idx, "end")) + + if not by_node or any(len(touches) != 2 for touches in by_node.values()): + return entities + + remaining = set(range(len(drawable))) + ordered: list[Dict[str, Any]] = [] + + while remaining: + first_idx = min(remaining) + remaining.remove(first_idx) + first = drawable[first_idx] + loop = [first] + loop_start = _point_key(first.get("start")) + cursor = _point_key(first.get("end")) + + while cursor != loop_start: + next_idx = None + next_side = None + for candidate_idx, side in by_node.get(cursor, []): + if candidate_idx in remaining: + next_idx = candidate_idx + next_side = side + break + if next_idx is None: + return entities + + remaining.remove(next_idx) + next_ent = drawable[next_idx] + if next_side == "end": + next_ent = _reverse_curve_entity(next_ent) + loop.append(next_ent) + cursor = _point_key(next_ent.get("end")) + + ordered.extend(loop) + + return ordered + + +def _infer_closed_wire_loops(entities: list[Dict[str, Any]]) -> list[Dict[str, Any]]: + drawable = [ + (idx, ent) for idx, ent in enumerate(entities) + if not ent.get("construction", False) + and ent.get("type") in ("line", "arc") + and _point_key(ent.get("start")) is not None + and _point_key(ent.get("end")) is not None + ] + if len(drawable) < 3: + return [] + + by_node: dict[tuple[float, float], list[tuple[int, str]]] = {} + for local_idx, (_, ent) in enumerate(drawable): + by_node.setdefault(_point_key(ent.get("start")), []).append((local_idx, "start")) + by_node.setdefault(_point_key(ent.get("end")), []).append((local_idx, "end")) + + remaining = set(range(len(drawable))) + loops: list[Dict[str, Any]] = [] + while remaining: + first_idx = min(remaining) + remaining.remove(first_idx) + _, first = drawable[first_idx] + loop_indices = [first_idx] + loop_start = _point_key(first.get("start")) + cursor = _point_key(first.get("end")) + + while cursor != loop_start: + matches = [(idx, side) for idx, side in by_node.get(cursor, []) if idx in remaining] + if not matches: + loop_indices = [] + break + next_idx, next_side = matches[0] + remaining.remove(next_idx) + _, next_ent = drawable[next_idx] + loop_indices.append(next_idx) + cursor = _point_key(next_ent.get("start") if next_side == "end" else next_ent.get("end")) + + if not loop_indices: + continue + entity_indices = [drawable[idx][0] for idx in loop_indices] + bbox = _loop_bbox([entities[idx] for idx in entity_indices]) + loops.append({ + "entity_indices": entity_indices, + "is_closed": True, + "bbox_mm": bbox, + "bbox_area_mm2": _bbox_area_2d(bbox), + "source": "inferred_connected_loop", + }) + return loops + + +def _loop_bbox(entities: list[Dict[str, Any]]) -> Optional[list[float]]: + points = [] + for ent in entities: + if not isinstance(ent, dict): + continue + if ent.get("type") == "circle": + center = ent.get("center") + radius = ent.get("radius_mm") + if isinstance(center, list) and len(center) >= 2 and radius is not None: + radius_value = abs(float(radius)) + points.append([float(center[0]) - radius_value, float(center[1]) - radius_value]) + points.append([float(center[0]) + radius_value, float(center[1]) + radius_value]) + continue + for key in ("start", "end", "center"): + point = ent.get(key) + if isinstance(point, list) and len(point) >= 2: + points.append(point) + if not points: + return None + return [ + min(float(point[0]) for point in points), + min(float(point[1]) for point in points), + max(float(point[0]) for point in points), + max(float(point[1]) for point in points), + ] + + +def _bbox_area_2d(bbox: Optional[list[float]]) -> float: + if not isinstance(bbox, list) or len(bbox) < 4: + return 0.0 + return max(0.0, float(bbox[2]) - float(bbox[0])) * max(0.0, float(bbox[3]) - float(bbox[1])) + + +def _bbox_contains_2d(outer: Optional[list[float]], inner: Optional[list[float]], tolerance: float = 1e-6) -> bool: + if not isinstance(outer, list) or not isinstance(inner, list) or len(outer) < 4 or len(inner) < 4: + return False + return ( + float(outer[0]) <= float(inner[0]) + tolerance + and float(outer[1]) <= float(inner[1]) + tolerance + and float(outer[2]) >= float(inner[2]) - tolerance + and float(outer[3]) >= float(inner[3]) - tolerance + ) + + +def _bbox_overlap_ratio_2d(a: Optional[list[float]], b: Optional[list[float]]) -> float: + if not isinstance(a, list) or not isinstance(b, list) or len(a) < 4 or len(b) < 4: + return 0.0 + ix0 = max(float(a[0]), float(b[0])) + iy0 = max(float(a[1]), float(b[1])) + ix1 = min(float(a[2]), float(b[2])) + iy1 = min(float(a[3]), float(b[3])) + intersection = max(0.0, ix1 - ix0) * max(0.0, iy1 - iy0) + smaller = min(_bbox_area_2d(a), _bbox_area_2d(b)) + if smaller <= 1e-9: + return 0.0 + return intersection / smaller + + +def _loop_radius_candidates(loop: Dict[str, Any], entities: list[Dict[str, Any]]) -> list[float]: + radii: list[float] = [] + for idx in loop.get("entity_indices", []) or []: + if not isinstance(idx, int) or idx < 0 or idx >= len(entities): + continue + ent = entities[idx] + radius = ent.get("radius_mm") + if radius is not None: + radii.append(abs(float(radius))) + bbox = loop.get("bbox_mm") + if isinstance(bbox, list) and len(bbox) >= 4: + radii.append(abs(float(bbox[2]) - float(bbox[0])) / 2) + radii.append(abs(float(bbox[3]) - float(bbox[1])) / 2) + return [radius for radius in radii if radius > 1e-6 and math.isfinite(radius)] + + +def _owned_profile_radii_mm(operation: Optional[Dict[str, Any]], sketch: Dict[str, Any]) -> list[float]: + if not isinstance(operation, dict): + return [] + radii: list[float] = [] + loop_radii: list[float] = [] + entities = sketch.get("entities") if isinstance(sketch, dict) else [] + sketch_loops = (sketch.get("profile_loops") or sketch.get("loops") or []) if isinstance(sketch, dict) else [] + for loop in sketch_loops: + loop_radii.extend(_loop_radius_candidates(loop, entities if isinstance(entities, list) else [])) + + def _matches_sketch_radius(value: float) -> bool: + return any(abs(value - radius) <= max(0.1, radius * 0.01) for radius in loop_radii) + + for face in operation.get("source_owned_faces") or []: + if not isinstance(face, dict): + continue + surface = face.get("surface") if isinstance(face.get("surface"), dict) else {} + params = surface.get("cylinder_params") + if surface.get("is_cylinder") and isinstance(params, list) and len(params) >= 7: + radii.append(abs(float(params[6]) * 1000)) + continue + box = face.get("box_m") + area = face.get("area_m2") + if surface.get("is_plane") and isinstance(box, list) and len(box) >= 6 and area is not None: + sizes = [abs(float(box[i + 3]) - float(box[i])) * 1000 for i in range(3)] + non_zero_sizes = [size for size in sizes if size > 1e-4] + if len(non_zero_sizes) >= 2: + outer_radius = max(non_zero_sizes) / 2 + area_mm2 = abs(float(area)) * 1_000_000 + inner_sq = outer_radius * outer_radius - area_mm2 / math.pi + inner_radius = math.sqrt(inner_sq) if inner_sq > 0 else 0.0 + if _matches_sketch_radius(outer_radius): + radii.append(outer_radius) + if inner_radius > 1e-4 and _matches_sketch_radius(inner_radius): + radii.append(inner_radius) + + unique: list[float] = [] + for radius in sorted(radii): + if radius <= 1e-6 or not math.isfinite(radius): + continue + if not any(abs(radius - existing) <= max(0.05, existing * 0.002) for existing in unique): + unique.append(radius) + return unique + + +def _loops_matching_owned_radii( + loops: list[Dict[str, Any]], + entities: list[Dict[str, Any]], + owned_radii: list[float], +) -> list[Dict[str, Any]]: + if not loops or not owned_radii: + return [] + matched: list[tuple[float, Dict[str, Any]]] = [] + for loop in loops: + candidates = _loop_radius_candidates(loop, entities) + if not candidates: + continue + best_radius = None + best_delta = float("inf") + for candidate in candidates: + for owned_radius in owned_radii: + delta = abs(candidate - owned_radius) + if delta < best_delta: + best_delta = delta + best_radius = candidate + if best_radius is None: + continue + if best_delta <= max(0.1, best_radius * 0.01): + matched.append((best_radius, loop)) + if not matched: + return [] + matched.sort(key=lambda item: item[0], reverse=True) + deduped: list[tuple[float, Dict[str, Any]]] = [] + seen_loop_keys: set[str] = set() + for radius, loop in matched: + bbox = loop.get("bbox_mm") + key = ",".join(f"{float(value):.4f}" for value in bbox[:4]) if isinstance(bbox, list) and len(bbox) >= 4 else str(loop.get("entity_indices")) + key = f"{radius:.4f}:{key}" + if key in seen_loop_keys: + continue + seen_loop_keys.add(key) + deduped.append((radius, loop)) + matched = deduped + annotated = [] + for index, (_, loop) in enumerate(matched): + loop_copy = dict(loop) + loop_copy["profile_mode"] = "add" if index == 0 else "subtract" + annotated.append(loop_copy) + return annotated + + +def _loop_area_from_radii(loops: list[Dict[str, Any]], entities: list[Dict[str, Any]]) -> Optional[float]: + if not loops: + return None + area = 0.0 + for index, loop in enumerate(loops): + radii = _loop_radius_candidates(loop, entities) + if not radii: + return None + radius = max(radii) + mode = loop.get("profile_mode") + sign = -1 if mode == "subtract" or (mode is None and index > 0) else 1 + area += sign * math.pi * radius * radius + return abs(area) if area > 1e-6 else None + + +def _aligned_workplane_for_owned_midplane( + sketch: Dict[str, Any], + operation: Optional[Dict[str, Any]], + loops: list[Dict[str, Any]], +) -> Dict[str, Any]: + workplane = dict(sketch.get("workplane") or {}) + if not isinstance(operation, dict) or operation.get("type") != "extrude_add": + return workplane + params = operation.get("parameters") if isinstance(operation.get("parameters"), dict) else {} + if not params.get("both_directions"): + return workplane + + entities = sketch.get("entities") if isinstance(sketch.get("entities"), list) else [] + profile_area = _loop_area_from_radii(loops, entities) + if profile_area is None: + return workplane + + normal = workplane.get("normal") or [0, 0, 1] + origin = workplane.get("origin_mm") or [0, 0, 0] + if not isinstance(normal, list) or not isinstance(origin, list) or len(normal) < 3 or len(origin) < 3: + return workplane + normal_vec = [float(v) for v in normal[:3]] + norm = math.sqrt(sum(v * v for v in normal_vec)) + if norm <= 1e-9: + return workplane + normal_vec = [v / norm for v in normal_vec] + + candidates: list[tuple[float, list[float]]] = [] + for face in operation.get("source_owned_faces") or []: + if not isinstance(face, dict): + continue + surface = face.get("surface") if isinstance(face.get("surface"), dict) else {} + if not surface.get("is_plane"): + continue + area_m2 = face.get("area_m2") + plane_params = surface.get("plane_params") + if area_m2 is None or not isinstance(plane_params, list) or len(plane_params) < 6: + continue + face_area = abs(float(area_m2)) * 1_000_000 + if abs(face_area - profile_area) > max(0.5, profile_area * 0.02): + continue + plane_normal = [float(v) for v in plane_params[:3]] + plane_norm = math.sqrt(sum(v * v for v in plane_normal)) + if plane_norm <= 1e-9: + continue + plane_normal = [v / plane_norm for v in plane_normal] + alignment = abs(sum(plane_normal[i] * normal_vec[i] for i in range(3))) + if alignment < 0.98: + continue + plane_point = [float(v) * 1000 for v in plane_params[3:6]] + old_offset = sum(float(origin[i]) * normal_vec[i] for i in range(3)) + new_offset = sum(plane_point[i] * normal_vec[i] for i in range(3)) + delta = new_offset - old_offset + if abs(delta) <= 1e-6: + continue + moved_origin = [float(origin[i]) + normal_vec[i] * delta for i in range(3)] + candidates.append((abs(delta), moved_origin)) + if len(candidates) != 1: + return workplane + candidates.sort(key=lambda item: item[0]) + workplane["origin_mm"] = candidates[0][1] + return workplane + + +def _project_owned_faces_to_sketch_bbox( + owned_faces: list[Dict[str, Any]], workplane: Dict[str, Any] +) -> Optional[list[float]]: + origin = workplane.get("origin_mm") or [0, 0, 0] + x_dir = workplane.get("x_dir") or [1, 0, 0] + y_dir = workplane.get("y_dir") or [0, 1, 0] + if len(origin) < 3 or len(x_dir) < 3 or len(y_dir) < 3: + return None + + projected: list[tuple[float, float]] = [] + for face in owned_faces: + box = face.get("box_m") if isinstance(face, dict) else None + if not isinstance(box, list) or len(box) < 6: + continue + mins = [float(box[i]) * 1000 for i in range(3)] + maxs = [float(box[i + 3]) * 1000 for i in range(3)] + for x in (mins[0], maxs[0]): + for y in (mins[1], maxs[1]): + for z in (mins[2], maxs[2]): + point = [x, y, z] + rel = [point[i] - float(origin[i]) for i in range(3)] + projected.append(( + sum(rel[i] * float(x_dir[i]) for i in range(3)), + sum(rel[i] * float(y_dir[i]) for i in range(3)), + )) + if not projected: + return None + return [ + min(point[0] for point in projected), + min(point[1] for point in projected), + max(point[0] for point in projected), + max(point[1] for point in projected), + ] + + +def _active_profile_loops(sketch: Dict[str, Any], operation: Optional[Dict[str, Any]]) -> list[Dict[str, Any]]: + entities = sketch.get("entities", []) or [] + loops = sketch.get("loops", []) or _infer_closed_wire_loops(entities) + if not loops: + return [] + + op_type = operation.get("type") if isinstance(operation, dict) else None + if op_type == "extrude_cut" and len(loops) > 1: + owned_bbox = _project_owned_faces_to_sketch_bbox( + operation.get("source_owned_faces") or [], + sketch.get("workplane") or {}, + ) + if owned_bbox: + for inner in loops: + inner_bbox = inner.get("bbox_mm") + if _bbox_overlap_ratio_2d(inner_bbox, owned_bbox) < 0.85: + continue + containers = [ + outer for outer in loops + if outer is not inner + and _bbox_contains_2d(outer.get("bbox_mm"), inner_bbox, tolerance=1e-4) + and _bbox_area_2d(outer.get("bbox_mm")) > _bbox_area_2d(inner_bbox) * 1.05 + ] + if containers: + outer = min(containers, key=lambda loop: _bbox_area_2d(loop.get("bbox_mm"))) + outer_loop = dict(outer) + inner_loop = dict(inner) + outer_loop["profile_mode"] = "add" + inner_loop["profile_mode"] = "subtract" + return [outer_loop, inner_loop] + + active = [] + for loop in loops: + bbox = loop.get("bbox_mm") + area = float(loop.get("bbox_area_mm2") or _bbox_area_2d(bbox)) + contains_other = any( + other is not loop + and _bbox_contains_2d(bbox, other.get("bbox_mm")) + and area > float(other.get("bbox_area_mm2") or _bbox_area_2d(other.get("bbox_mm"))) * 1.05 + for other in loops + ) + if not contains_other: + active.append(loop) + if active: + return active + if op_type == "extrude_add" and len(loops) > 1: + owned_matched = _loops_matching_owned_radii(loops, entities, _owned_profile_radii_mm(operation, sketch)) + # Owned-face radii are useful for selecting circular profiles, but a + # rounded outer contour also contributes arc radii. Those radii can + # coincide with an inner circle and make the radius ranking label the + # inner loop as ADD and its containing outer loop as SUBTRACT. Such a + # profile is topologically impossible as a first additive sketch, so + # fall back to the complete contour nesting below. + owned_modes_conflict_with_nesting = any( + candidate.get("profile_mode") == "add" + and any( + container is not candidate + and container.get("profile_mode") == "subtract" + and _bbox_contains_2d( + container.get("bbox_mm"), + candidate.get("bbox_mm"), + tolerance=1e-4, + ) + and _bbox_area_2d(container.get("bbox_mm")) + > _bbox_area_2d(candidate.get("bbox_mm")) * 1.05 + for container in owned_matched + ) + for candidate in owned_matched + ) + if owned_modes_conflict_with_nesting: + owned_matched = [] + if owned_matched: + # Radius evidence cannot identify closed slot/polygon contours. + # Keep non-circular closed loops that lie inside an owned additive + # outer loop; they are material-removal islands in the same + # additive sketch. Circular unmatched loops remain excluded + # because they commonly belong to other features sharing a sketch. + matched_entity_keys = { + tuple(loop.get("entity_indices") or []) for loop in owned_matched + } + additive_outers = [ + loop for loop in owned_matched if loop.get("profile_mode") == "add" + ] + for loop in loops: + entity_indices = tuple(loop.get("entity_indices") or []) + if entity_indices in matched_entity_keys: + continue + profile_entities = [ + entities[index] + for index in entity_indices + if isinstance(index, int) and 0 <= index < len(entities) + ] + is_non_circular_profile = bool(profile_entities) and any( + entity.get("type") != "circle" + and not (entity.get("type") == "arc" and entity.get("is_circle")) + for entity in profile_entities + ) + if not is_non_circular_profile: + continue + if not any( + _bbox_contains_2d( + outer.get("bbox_mm"), loop.get("bbox_mm"), tolerance=1e-4 + ) + for outer in additive_outers + ): + continue + loop_copy = dict(loop) + loop_copy["profile_mode"] = "subtract" + owned_matched.append(loop_copy) + if len(owned_matched) == 1 and isinstance(operation, dict): + outer_loop = owned_matched[0] + outer_radii = _loop_radius_candidates(outer_loop, entities) + outer_radius = max(outer_radii) if outer_radii else 0.0 + outer_disk_area = math.pi * outer_radius * outer_radius if outer_radius > 0 else 0.0 + has_partial_cap = False + for face in operation.get("source_owned_faces") or []: + if not isinstance(face, dict): + continue + surface = face.get("surface") if isinstance(face.get("surface"), dict) else {} + area_m2 = face.get("area_m2") + if surface.get("is_plane") and area_m2 is not None and outer_disk_area > 0: + face_area = abs(float(area_m2)) * 1_000_000 + if face_area < outer_disk_area * 0.9: + has_partial_cap = True + break + if has_partial_cap: + inner_candidates = [ + loop for loop in loops + if loop is not outer_loop + and _bbox_contains_2d(outer_loop.get("bbox_mm"), loop.get("bbox_mm"), tolerance=1e-4) + ] + if inner_candidates: + inner = max( + ( + loop + for loop in inner_candidates + if max(_loop_radius_candidates(loop, entities) or [0.0]) < outer_radius - 0.5 + ), + key=lambda loop: max(_loop_radius_candidates(loop, entities) or [0.0]), + default=None, + ) + if inner is None: + return owned_matched + inner_radii = _loop_radius_candidates(inner, entities) + inner_radius = max(inner_radii) if inner_radii else 0.0 + if inner_radius <= 0: + return owned_matched + outer_copy = dict(outer_loop) + inner_copy = dict(inner) + outer_copy["profile_mode"] = "add" + inner_copy["profile_mode"] = "subtract" + return [outer_copy, inner_copy] + return owned_matched + annotated = [] + for loop in loops: + bbox = loop.get("bbox_mm") + area = float(loop.get("bbox_area_mm2") or _bbox_area_2d(bbox)) + containers = [ + outer for outer in loops + if outer is not loop + and _bbox_contains_2d(outer.get("bbox_mm"), bbox, tolerance=1e-4) + and float(outer.get("bbox_area_mm2") or _bbox_area_2d(outer.get("bbox_mm"))) > area * 1.05 + ] + loop_copy = dict(loop) + loop_copy["profile_mode"] = "subtract" if containers else "add" + annotated.append(loop_copy) + return annotated + return loops + + +def _generate_sketch(sketch: Dict[str, Any], references: Dict[str, Any], operation: Optional[Dict[str, Any]] = None) -> list[str]: + import math + + name = sketch.get("name", "Sketch") + op_type = operation.get("type") if isinstance(operation, dict) else None + workplane = sketch.get("workplane", {}) + entities = sketch.get("entities", []) + loops = _active_profile_loops(sketch, operation) + workplane = _aligned_workplane_for_owned_midplane(sketch, operation, loops) + code = [f" # Sketch: {name}"] + + origin = workplane.get("origin_mm", [0, 0, 0]) + x_dir = workplane.get("x_dir", [1, 0, 0]) + normal = workplane.get("normal", [0, 0, 1]) + + if origin != [0, 0, 0] or x_dir != [1, 0, 0] or normal != [0, 0, 1]: + code.append( + f" with BuildSketch(Plane(origin={_tuple3(origin)}, x_dir={_tuple3(x_dir)}, z_dir={_tuple3(normal)})) as sketch:" + ) + else: + code.append(" with BuildSketch() as sketch:") + + loop_entities = [] + processed_indices = set() + for loop in loops: + for idx in loop.get("entity_indices", []): + if idx < len(entities): + loop_entities.append(entities[idx]) + processed_indices.add(idx) + + append_unprocessed = not loops + for i, ent in enumerate(entities): + if append_unprocessed and i not in processed_indices: + loop_entities.append(ent) + + drawable_entities = [ent for ent in loop_entities if not ent.get("construction", False)] + circle_entities = [ + ent for ent in drawable_entities + if ent.get("type") in ("circle", "arc") and ent.get("is_circle", ent.get("type") == "circle") + ] + wire_entities = [ + ent for ent in drawable_entities + if ent not in circle_entities and ent.get("type") in ("line", "arc") + ] + wire_entities = _ordered_wire_entities(wire_entities) + + handled_circle_entities = set() + if not loops and len(circle_entities) > 1: + ranked_circles = sorted( + enumerate(circle_entities), + key=lambda item: float(item[1].get("radius_mm", 0) or 0), + reverse=True, + ) + outer_index, outer = ranked_circles[0] + outer_center = outer.get("center", [0, 0, 0]) + outer_radius = float(outer.get("radius_mm", 0) or 0) + contains_all = outer_radius > 0 + for _, inner in ranked_circles[1:]: + inner_center = inner.get("center", [0, 0, 0]) + inner_radius = float(inner.get("radius_mm", 0) or 0) + center_distance = math.hypot( + float(inner_center[0]) - float(outer_center[0]), + float(inner_center[1]) - float(outer_center[1]), + ) + if center_distance + inner_radius >= outer_radius - 1e-6: + contains_all = False + break + if contains_all: + code.append(f" with Locations(({outer_center[0]}, {outer_center[1]})):") + code.append(f" Circle({outer_radius})") + handled_circle_entities.add(outer_index) + for inner_index, inner in ranked_circles[1:]: + center = inner.get("center", [0, 0, 0]) + radius = inner.get("radius_mm", 1) + code.append(f" with Locations(({center[0]}, {center[1]})):") + code.append(f" Circle({radius}, mode=Mode.SUBTRACT)") + handled_circle_entities.add(inner_index) + + def circle_is_inner_profile(ent: Dict[str, Any]) -> bool: + if op_type != "extrude_add" or not loops: + return False + center = ent.get("center", [0, 0]) + radius = float(ent.get("radius_mm", 0) or 0) + if radius <= 0 or len(center) < 2: + return False + bbox = [ + float(center[0]) - radius, + float(center[1]) - radius, + float(center[0]) + radius, + float(center[1]) + radius, + ] + return any(_bbox_contains_2d(loop.get("bbox_mm"), bbox, tolerance=1e-4) for loop in loops) + + if not loops: + for circle_index, ent in enumerate(circle_entities): + if circle_index in handled_circle_entities: + continue + center = ent.get("center", [0, 0, 0]) + radius = ent.get("radius_mm", 1) + code.append(f" with Locations(({center[0]}, {center[1]})):") + if circle_is_inner_profile(ent): + code.append(f" Circle({radius}, mode=Mode.SUBTRACT)") + else: + code.append(f" Circle({radius})") + + def orient_wire_entities(profile_entities: list[Dict[str, Any]]) -> list[Dict[str, Any]]: + """Orient contour segments into a continuous closed wire. + + SolidWorks contour arrays preserve membership but not necessarily each + segment's traversal direction. Reversing an arc must also invert its + curve axis; otherwise a short arc becomes its 270-degree complement. + """ + segments = [deepcopy(entity) for entity in profile_entities] + if len(segments) < 2: + return segments + + def endpoints(entity: Dict[str, Any]) -> tuple[Optional[list[float]], Optional[list[float]]]: + start = entity.get("start") + end = entity.get("end") + if not (isinstance(start, list) and isinstance(end, list) and len(start) >= 2 and len(end) >= 2): + return None, None + return [float(start[0]), float(start[1])], [float(end[0]), float(end[1])] + + def distance(left: list[float], right: list[float]) -> float: + return math.hypot(left[0] - right[0], left[1] - right[1]) + + def reverse(entity: Dict[str, Any]) -> Dict[str, Any]: + reversed_entity = deepcopy(entity) + reversed_entity["start"], reversed_entity["end"] = entity.get("end"), entity.get("start") + axis = reversed_entity.get("curve_axis") or (reversed_entity.get("raw") or {}).get("curve_axis") + if isinstance(axis, list) and len(axis) >= 3: + reversed_entity["curve_axis"] = [-float(value) for value in axis[:3]] + # 删除预置角度,强制代码生成时从翻转后的start/end重新计算 + reversed_entity.pop("start_angle_deg", None) + reversed_entity.pop("end_angle_deg", None) + reversed_entity.pop("arc_sweep_deg", None) + if entity.get("type") == "arc": + center = entity.get("center") or [0.0, 0.0] + start = reversed_entity.get("start") or [0.0, 0.0] + end = reversed_entity.get("end") or [0.0, 0.0] + start_angle = math.degrees(math.atan2(float(start[1]) - float(center[1]), float(start[0]) - float(center[0]))) + end_angle = math.degrees(math.atan2(float(end[1]) - float(center[1]), float(end[0]) - float(center[0]))) + reversed_sweep = end_angle - start_angle + if reversed_sweep <= -180: + reversed_sweep += 360 + elif reversed_sweep > 180: + reversed_sweep -= 360 + reversed_entity["arc_sweep_deg"] = reversed_sweep + return reversed_entity + + ordered = [segments.pop(0)] + while segments: + _, previous_end = endpoints(ordered[-1]) + if previous_end is None: + ordered.extend(segments) + break + candidates = [] + for index, candidate in enumerate(segments): + candidate_start, candidate_end = endpoints(candidate) + if candidate_start is None or candidate_end is None: + continue + candidates.append((distance(previous_end, candidate_start), index, candidate)) + candidates.append((distance(previous_end, candidate_end), index, reverse(candidate))) + if not candidates: + ordered.extend(segments) + break + _, selected_index, selected = min(candidates, key=lambda item: item[0]) + ordered.append(selected) + segments.pop(selected_index) + return ordered + + def append_wire_profile(profile_entities: list[Dict[str, Any]], make_face_mode: Optional[str] = None) -> None: + profile_entities = orient_wire_entities(profile_entities) + code.append(" with BuildLine():") + code.append(" pass") + emitted_wire = False + line_points = [] + for line_ent in profile_entities: + if line_ent.get("type") == "line": + line_points.extend([line_ent.get("start", [0, 0]), line_ent.get("end", [0, 0])]) + line_bbox = None + if line_points: + xs = [float(point[0]) for point in line_points] + ys = [float(point[1]) for point in line_points] + line_bbox = (min(xs), min(ys), max(xs), max(ys)) + for ent in profile_entities: + ent_type = ent.get("type", "") + if ent_type == "line": + start = ent.get("start", [0, 0, 0]) + end = ent.get("end", [0, 0, 0]) + if math.hypot(float(start[0]) - float(end[0]), float(start[1]) - float(end[1])) <= 1e-6: + code.append(" # Skip zero-length line") + continue + code.append(f" Line(({start[0]}, {start[1]}), ({end[0]}, {end[1]}))") + emitted_wire = True + elif ent_type == "arc": + center = ent.get("center", [0, 0, 0]) + radius = ent.get("radius_mm", 1) + if "start_angle_deg" in ent and "end_angle_deg" in ent: + start_angle = ent["start_angle_deg"] + end_angle = ent["end_angle_deg"] + else: + start = ent.get("start", [0, 0]) + end = ent.get("end", [0, 0]) + start_angle = math.degrees(math.atan2(start[1] - center[1], start[0] - center[0])) + end_angle = math.degrees(math.atan2(end[1] - center[1], end[0] - center[0])) + if ent.get("arc_sweep_deg") is not None: + arc_size = float(ent["arc_sweep_deg"]) + else: + curve_axis = ent.get("curve_axis") or ent.get("raw", {}).get("curve_axis") + if isinstance(curve_axis, list) and len(curve_axis) >= 3 and abs(float(curve_axis[2])) > 1e-9: + if float(curve_axis[2]) >= 0: + arc_size = (end_angle - start_angle) % 360 + else: + arc_size = -((start_angle - end_angle) % 360) + else: + arc_size = end_angle - start_angle + if arc_size <= 0: + arc_size += 360 + if arc_size > 180: + arc_size -= 360 + code.append(f" CenterArc(({center[0]}, {center[1]}), {radius}, {start_angle}, {arc_size})") + emitted_wire = True + else: + code.append(f" # TODO: entity type {ent_type}") + if not emitted_wire: + code.append(" # Skip empty wire profile") + return + if make_face_mode: + code.append(f" make_face(mode=Mode.{make_face_mode.upper()})") + else: + code.append(" make_face()") + + if loops: + ordered_loops = sorted( + enumerate(loops), + key=lambda item: (1 if item[1].get("profile_mode") == "subtract" else 0, item[0]), + ) + for loop_order_index, (loop_index, loop) in enumerate(ordered_loops): + profile_entities = [ + entities[idx] + for idx in loop.get("entity_indices", []) + if idx < len(entities) + and not entities[idx].get("construction", False) + and entities[idx].get("type") in ("line", "arc", "circle") + ] + circle_profile_entities = [ + ent for ent in profile_entities + if ent.get("type") == "circle" or (ent.get("type") == "arc" and ent.get("is_circle")) + ] + wire_profile_entities = [ + ent for ent in profile_entities + if ent.get("type") in ("line", "arc") and ent not in circle_profile_entities + ] + wire_profile_entities = _ordered_wire_entities(wire_profile_entities) + if not profile_entities: + continue + mode = loop.get("profile_mode") + if wire_profile_entities: + append_wire_profile(wire_profile_entities, mode if loop_order_index > 0 or mode else None) + else: + for ent in circle_profile_entities: + center = ent.get("center", [0, 0, 0]) + radius = ent.get("radius_mm", 1) + code.append(f" with Locations(({center[0]}, {center[1]})):") + if mode == "subtract": + code.append(f" Circle({radius}, mode=Mode.SUBTRACT)") + else: + code.append(f" Circle({radius})") + elif wire_entities: + append_wire_profile(wire_entities) + + return code + + +def _sketch_circle_radii_mm(sketch: Optional[Dict[str, Any]]) -> list[float]: + if not isinstance(sketch, dict): + return [] + radii = [] + for entity in sketch.get("entities", []) or []: + if entity.get("construction") or entity.get("type") != "circle": + continue + radius = float(entity.get("radius_mm") or 0) + if radius > 0: + radii.append(abs(radius)) + return radii + + +def _flip_side_step_inner_radius_mm( + op: Dict[str, Any], + sketch: Optional[Dict[str, Any]], + operations: list[Dict[str, Any]], + sketches: Dict[str, Dict[str, Any]], +) -> Optional[float]: + outer_radii = _sketch_circle_radii_mm(sketch) + if not outer_radii: + return None + outer = max(outer_radii) + if len(outer_radii) > 1: + return min(outer_radii) + inner = None + try: + op_index = operations.index(op) + except ValueError: + op_index = len(operations) + for prev in operations[:op_index]: + if prev.get("type") != "extrude_cut": + continue + if not (prev.get("parameters") or {}).get("flip_side_to_cut"): + continue + prev_sketch = sketches.get(prev.get("sketch") or "", {}) + for radius in _sketch_circle_radii_mm(prev_sketch): + if radius < outer - 1e-6: + inner = radius if inner is None else max(inner, radius) + return inner + + +def _flip_side_uses_step_ring( + op: Dict[str, Any], + sketch: Optional[Dict[str, Any]], + operations: list[Dict[str, Any]], + sketches: Dict[str, Dict[str, Any]], +) -> tuple[Optional[float], Optional[float]]: + outer_radii = _sketch_circle_radii_mm(sketch) + if not outer_radii: + return None, None + outer = max(outer_radii) + inner = _flip_side_step_inner_radius_mm(op, sketch, operations, sketches) + if inner is None or outer <= inner + 0.5: + return None, None + if outer < 35 and outer / inner < 1.5: + return None, None + return outer, inner + + +def _effective_extrude_cut_depth_mm( + op: Dict[str, Any], + sketch: Optional[Dict[str, Any]], + distance_mm: float, +) -> float: + params = op.get("parameters") if isinstance(op.get("parameters"), dict) else {} + if not params.get("flip_side_to_cut"): + return distance_mm + workplane = (sketch or {}).get("workplane") or {} + origin = workplane.get("origin_mm") or [0.0, 0.0, 0.0] + normal = workplane.get("normal") or [0.0, 0.0, 1.0] + if not isinstance(origin, list) or not isinstance(normal, list) or len(origin) < 3 or len(normal) < 3: + return distance_mm + axis = max(range(3), key=lambda idx: abs(float(normal[idx]))) + cut_amount = distance_mm if params.get("reverse_direction", False) else -abs(distance_mm) + cut_sign = -1.0 if cut_amount < 0 else 1.0 + owned_values = [] + for face in op.get("source_owned_faces") or []: + if not isinstance(face, dict): + continue + surface = face.get("surface") if isinstance(face.get("surface"), dict) else {} + if not surface.get("is_plane"): + continue + box = face.get("box_m") + if not isinstance(box, list) or len(box) < 6: + continue + thicknesses = [abs(float(box[i + 3]) - float(box[i])) * 1000 for i in range(3)] + if min(thicknesses) > 0.5: + continue + owned_values.extend([float(box[axis]) * 1000, float(box[axis + 3]) * 1000]) + if not owned_values: + return distance_mm + transition = (min(owned_values) if cut_sign < 0 else max(owned_values)) + cut_sign * 1.0 + effective = abs(float(origin[axis]) - transition) + if effective <= 1e-6: + return distance_mm + if abs(effective - abs(distance_mm)) <= 0.25: + return distance_mm + # Guard: owned-face depth can be wrong when all owned faces + # are near the sketch plane (e.g., edge details), not at the + # real cut termination. Fall back to a through-cut distance + # so the invert-cutter extends past the entire body. + if effective < max(2.0, abs(distance_mm) * 0.15): + return max(distance_mm, THROUGH_CUT_AMOUNT_MM) + return effective + + +def _owned_extrude_terminal_offsets_mm( + op: Dict[str, Any], + sketch: Optional[Dict[str, Any]], +) -> tuple[Optional[float], Optional[float]]: + """Return the nearest owned planar end faces along the sketch normal. + + SolidWorks can report a two-sided feature with a stale blind depth when one + side terminates on geometry. The feature-owned end face is the reliable + result geometry: its signed offset from the sketch plane identifies the + actual termination direction and distance. + """ + workplane = (sketch or {}).get("workplane") or {} + origin = workplane.get("origin_mm") or [] + normal = workplane.get("normal") or [] + if not (isinstance(origin, list) and isinstance(normal, list) and len(origin) >= 3 and len(normal) >= 3): + return None, None + magnitude = math.sqrt(sum(float(value) ** 2 for value in normal[:3])) + if magnitude <= 1e-9: + return None, None + unit_normal = [float(value) / magnitude for value in normal[:3]] + positive: list[float] = [] + negative: list[float] = [] + for face in op.get("source_owned_faces") or []: + if not isinstance(face, dict): + continue + surface = face.get("surface") if isinstance(face.get("surface"), dict) else {} + if not surface.get("is_plane"): + continue + params = surface.get("plane_params") + if not isinstance(params, list) or len(params) < 6: + continue + point_mm = [float(value) * 1000 for value in params[3:6]] + offset = sum((point_mm[index] - float(origin[index])) * unit_normal[index] for index in range(3)) + if offset > 1e-4: + positive.append(offset) + elif offset < -1e-4: + negative.append(offset) + return (max(positive) if positive else None, min(negative) if negative else None) + + +def _resolve_extrude_owned_termination( + op: Dict[str, Any], + sketch: Optional[Dict[str, Any]], +) -> Dict[str, Any]: + """Resolve an asymmetric two-sided add from its SolidWorks-owned end face.""" + params = op.get("parameters") if isinstance(op.get("parameters"), dict) else {} + if op.get("type") != "extrude_add" or not params.get("both_directions"): + return op + positive, negative = _owned_extrude_terminal_offsets_mm(op, sketch) + if (positive is None) == (negative is None): + return op + resolved = dict(op) + resolved_params = dict(params) + resolved_params["distance_mm"] = positive if positive is not None else abs(float(negative)) + resolved_params["reverse_distance_mm"] = 0 + resolved_params["both_directions"] = False + resolved_params["reverse_direction"] = negative is not None + resolved_params["owned_termination_resolved"] = True + resolved["parameters"] = resolved_params + return resolved + + +def _generate_extrude( + op: Dict[str, Any], + sketch: Optional[Dict[str, Any]] = None, + operations: Optional[list[Dict[str, Any]]] = None, + sketches: Optional[Dict[str, Dict[str, Any]]] = None, +) -> list[str]: + params = op.get("parameters", {}) + distance = _effective_extrude_cut_depth_mm(op, sketch, float(params.get("distance_mm", 10) or 10)) + reverse_distance = params.get("reverse_distance_mm", 0) + op_type = op.get("type", "") + name = op.get("name", "") + both_directions = params.get("both_directions", False) + flip_side_to_cut = bool(params.get("flip_side_to_cut", False)) + end_condition_code = params.get("end_condition_code") + reverse_end_condition_code = params.get("reverse_end_condition_code") + end_condition = SW_END_CONDITIONS.get(end_condition_code, f"Unknown({end_condition_code})") + operations = operations or [] + sketches = sketches or {} + outer_radius, inner_radius = ( + _flip_side_uses_step_ring(op, sketch, operations, sketches) if flip_side_to_cut else (None, None) + ) + resolved_owned_termination = bool(params.get("owned_termination_resolved")) + + code = [f" # {op_type}: {name}"] + if resolved_owned_termination: + code.append(" # Use the owned planar end face to resolve SW's asymmetric termination") + preserve_visible = bool(op.get("source_owned_faces")) and op_type == "extrude_add" + if end_condition_code is not None: + code.append(f" # SW end condition: {end_condition}") + + owned_cylinder_faces = _owned_cylindrical_cut_faces(op, sketch or {}) + prefer_blind_sketch = _prefer_blind_sketch_extrude( + op, sketch or {}, distance, end_condition_code, owned_cylinder_faces + ) + if op_type == "extrude_cut" and owned_cylinder_faces and flip_side_to_cut: + code.append(" # Replay SW flip-side circular cut from owned cylindrical faces") + code.append(f" result = cut_owned_flip_side_cylindrical_faces(result, {repr(owned_cylinder_faces)})") + return code + + if op_type == "extrude_cut" and owned_cylinder_faces and not flip_side_to_cut and not prefer_blind_sketch: + code.append(" # Replay cut from SW owned cylindrical faces when start/end references are missing") + code.append(f" result = cut_owned_cylindrical_faces(result, {repr(owned_cylinder_faces)})") + return code + + owned_bbox = _owned_bbox_cut(op, sketch or {}, distance) + if op_type == "extrude_cut" and owned_bbox and not flip_side_to_cut and not prefer_blind_sketch: + code.append(" # Replay cut from SW owned face bbox when extrude start/end references are missing") + code.append(f" result = cut_owned_bbox(result, {repr(owned_bbox)})") + return code + + if distance == 0 and reverse_distance == 0: + if op_type == "extrude_cut" and end_condition_code not in (None, 0): + distance = THROUGH_CUT_AMOUNT_MM + both_directions = end_condition_code in (1, 2, 9) + code.append(f" # TODO: exact sw_extrude_cut_{end_condition}; using long cutter") + else: + code.append(" # Skip: zero distance") + return code + elif op_type == "extrude_add" and (end_condition_code in (6, 8) or reverse_end_condition_code in (6, 8)): + code.append(" # SW mid-plane/two-sided extrusion represented by this IR") + distance = distance / 2 + reverse_distance = distance + both_directions = True + elif op_type == "extrude_cut" and end_condition_code not in (None, 0): + distance = max(distance, reverse_distance, THROUGH_CUT_AMOUNT_MM) + both_directions = both_directions or end_condition_code in (1, 2, 9) + code.append(f" # TODO: exact sw_extrude_cut_{end_condition}; using long cutter") + + if both_directions: + amount = max(distance, reverse_distance) if reverse_distance > 0 else distance + if op_type == "extrude_cut": + code.append(f" cutter = extrude(sketch.sketch, amount={amount}, both=True)") + if flip_side_to_cut: + normal = (sketch or {}).get("workplane", {}).get("normal", [0, 0, 1]) + if outer_radius is not None and inner_radius is not None: + code.append( + " result = sw_flip_side_step_cut(" + f"result, cutter, normal={_tuple3(normal)}, " + f"outer_radius_mm={outer_radius}, inner_radius_mm={inner_radius})" + ) + else: + code.append(f" result = sw_inverted_profile_cut(result, cutter, normal={_tuple3(normal)})") + else: + code.append(" result = safe_subtract(result, cutter)") + else: + code.append(f" solid = extrude(sketch.sketch, amount={amount}, both=True)") + code.append(f" result = safe_union(result, solid, preserve_visible={preserve_visible})") + elif op_type == "extrude_cut": + if distance > 0: + cut_amount = distance if params.get("reverse_direction", False) else -distance + code.append(f" cutter = extrude(sketch.sketch, amount={cut_amount})") + # 当盲拉伸从不同于草图的起始面开始时,平移cutter到正确位置 + if prefer_blind_sketch: + face_offset = _blind_extrude_face_offset(op, sketch or {}) + if face_offset is not None: + code.append(f" cutter = cutter.locate(Location({_tuple3(face_offset)}))") + if flip_side_to_cut: + normal = (sketch or {}).get("workplane", {}).get("normal", [0, 0, 1]) + if outer_radius is not None and inner_radius is not None: + code.append( + " result = sw_flip_side_step_cut(" + f"result, cutter, normal={_tuple3(normal)}, " + f"outer_radius_mm={outer_radius}, inner_radius_mm={inner_radius})" + ) + else: + code.append(f" result = sw_inverted_profile_cut(result, cutter, normal={_tuple3(normal)})") + else: + code.append(" result = safe_subtract(result, cutter)") + else: + code.append(" # Skip: zero distance cut") + else: + add_amount = -distance if params.get("reverse_direction", False) else distance + code.append(f" solid = extrude(sketch.sketch, amount={add_amount})") + code.append(f" result = safe_union(result, solid, preserve_visible={preserve_visible})") + + return code + + +def _owned_cylindrical_cut_faces(op: Dict[str, Any], sketch: Dict[str, Any]) -> list[Dict[str, Any]]: + if op.get("type") != "extrude_cut": + return [] + sketch_radii = [ + abs(float(entity.get("radius_mm") or 0)) + for entity in sketch.get("entities", []) or [] + if not entity.get("construction") and entity.get("type") == "circle" + ] + if not sketch_radii: + return [] + matched = [] + for face in op.get("source_owned_faces") or []: + if not isinstance(face, dict): + continue + surface = face.get("surface") if isinstance(face.get("surface"), dict) else {} + params = surface.get("cylinder_params") + bbox = face.get("box_m") + if not (surface.get("is_cylinder") and isinstance(params, list) and len(params) >= 7): + continue + if not (isinstance(bbox, list) and len(bbox) >= 6): + continue + radius_mm = abs(float(params[6]) * 1000) + if not any(abs(radius_mm - sketch_radius) <= max(0.05, sketch_radius * 0.01) for sketch_radius in sketch_radii): + continue + matched.append(face) + return matched + + +def _blind_extrude_face_offset( + op: Dict[str, Any], + sketch: Dict[str, Any], +) -> Optional[list[float]]: + """当盲拉伸从不同于草图的起始面开始时,计算cutter的3D平移向量。 + 返回None表示不需要平移。""" + faces = (op.get("source_owned_faces") or []) + if not faces: + return None + valid_bboxes = [] + for face in faces: + bm = face.get("box_m") + if isinstance(bm, list) and len(bm) >= 6: + valid_bboxes.append([float(v) * 1000 for v in bm[:6]]) + if not valid_bboxes: + return None + normal = (sketch.get("workplane") or {}).get("normal") + if not isinstance(normal, list) or len(normal) < 3: + return None + origin = (sketch.get("workplane") or {}).get("origin_mm") or [0, 0, 0] + # 确定主导轴 (extrude方向) + axis = max(range(3), key=lambda idx: abs(float(normal[idx]))) + normal_sign = 1.0 if float(normal[axis]) >= 0 else -1.0 + sketch_coord = float(origin[axis]) if isinstance(origin, list) and len(origin) > axis else 0.0 + # 取离草图平面最近的面坐标,使cutter从面的最近点开始切入 + # 对于多个面,面可能在草图平面两侧。 + all_coords = [] + for b in valid_bboxes: + all_coords.append(b[axis]) + all_coords.append(b[axis + 3]) + if not all_coords: + return None + # 找离sketch_coord最近的面坐标 + face_coord = min(all_coords, key=lambda c: abs(c - sketch_coord)) + offset = face_coord - sketch_coord + if abs(offset) < 1e-3: + return None + # 返回3D平移向量(仅沿extrude方向) + result = [0.0, 0.0, 0.0] + result[axis] = offset + return result + + +def _prefer_blind_sketch_extrude( + op: Dict[str, Any], + sketch: Dict[str, Any], + distance_mm: float, + end_condition_code: Optional[int], + owned_cylinder_faces: list[Dict[str, Any]], +) -> bool: + """优先使用盲拉伸而非 bbox 回退。对于矩形/圆等简单截面, + 盲拉伸比包围盒近似精确得多。含弧的复杂截面可能因方向问题 + 产生意外偏差,此时仍走 bbox 路径。""" + if owned_cylinder_faces: + return False + if end_condition_code not in (None, 0) or distance_mm <= 0: + return False + if not _sketch_has_buildable_profile(sketch): + return False + # 有 owned_faces 的矩形或纯圆截面: 盲拉伸比 bbox 更精确 + entities = sketch.get("entities", []) or [] + non_const = [e for e in entities if not e.get("construction", False)] + types = {e.get("type") for e in non_const if e.get("type") not in ("point", "text")} + # 排除point/text后仍是简单截面才用盲拉伸。 + # 但如果面位于不同平面,让_blind_extrude_face_offset处理 + is_simple = types <= {"line"} or types <= {"circle"} + if not is_simple: + return False + # 检查草图平面与面是否有关键偏移 - 只有当盲拉伸需要偏移修正时才使用 + faces = op.get("source_owned_faces") or [] + if faces and _blind_extrude_face_offset(op, sketch) is not None: + return True # 有面偏移,需要盲拉伸+offset修正 + # 无面偏移时,只有当start/end引用完整时才用盲拉伸 + if op.get("start_reference") or op.get("end_reference"): + return True + return False + + +def _owned_bbox_cut(op: Dict[str, Any], sketch: Dict[str, Any], distance_mm: float) -> Optional[list[float]]: + if op.get("type") != "extrude_cut": + return None + faces = [ + face for face in (op.get("source_owned_faces") or []) + if isinstance(face, dict) and isinstance(face.get("box_m"), list) and len(face.get("box_m")) >= 6 + ] + if not faces: + return None + bboxes = [[float(value) * 1000 for value in face["box_m"][:6]] for face in faces] + bbox = [ + min(box[axis] for box in bboxes) if axis < 3 else max(box[axis] for box in bboxes) + for axis in range(6) + ] + normal = (sketch.get("workplane") or {}).get("normal") or [0, 0, 1] + if not isinstance(normal, list) or len(normal) < 3: + return None + axis = max(range(3), key=lambda idx: abs(float(normal[idx]))) + extent = abs(bbox[axis + 3] - bbox[axis]) + origin = (sketch.get("workplane") or {}).get("origin_mm") or [0, 0, 0] + origin_coord = float(origin[axis]) if isinstance(origin, list) and len(origin) > axis else None + distance = abs(float(distance_mm or 0)) + origin_outside = ( + origin_coord is not None + and (origin_coord < min(bbox[axis], bbox[axis + 3]) - 1e-6 or origin_coord > max(bbox[axis], bbox[axis + 3]) + 1e-6) + ) + if extent <= distance * 1.25 and not origin_outside: + return None + return bbox + + +def _generate_revolve(op: Dict[str, Any], sketch: Optional[Dict[str, Any]] = None) -> list[str]: + params = op.get("parameters", {}) + angle = params.get("angle_deg") + if angle is None and params.get("angle_rad") is not None: + angle = float(params.get("angle_rad")) * 180 / math.pi + if angle is None: + angle = 360 + if abs(angle - 360) < 1e-6: + angle = 360 + op_type = op.get("type", "") + name = op.get("name", "") + code = [f" # {op_type}: {name}"] + axis_expr = _revolve_axis_expr(params, sketch or {}) + code.append(f" revolve_axis = {axis_expr}") + if op_type == "revolve_cut": + code.append(f" cutter = revolve(sketch.sketch, axis=revolve_axis, revolution_arc={angle})") + code.append(" # Force OCCT to fully evaluate both solids before Boolean ops") + code.append(" _ = list(cutter.solids()); _ = cutter.is_valid; _ = cutter.volume") + code.append(" _ = list(result.solids()); _ = result.is_valid; _ = result.volume") + code.append(" # Use a single subtract and capture the result directly (avoids OCCT heisenbug)") + code.append(" result = safe_subtract(result, cutter)") + else: + code.append(f" solid = revolve(sketch.sketch, axis=revolve_axis, revolution_arc={angle})") + preserve_visible = bool(op.get("source_owned_faces")) + code.append(f" result = safe_union(result, solid, preserve_visible={preserve_visible})") + return code + + +def _revolve_axis_expr(params: Dict[str, Any], sketch: Dict[str, Any]) -> str: + # 优先使用草图中的构造线作为旋转轴, + # 因为它保证位于草图平面上(SW 的 revolve 操作依赖于此) + construction_axis = _sketch_construction_axis(sketch) + if construction_axis: + origin, direction = construction_axis + return f"Axis({_tuple3(origin)}, {_tuple3(direction)})" + + axis_reference = params.get("axis_reference") or {} + if axis_reference.get("origin_mm") and axis_reference.get("direction"): + return f"Axis({_tuple3(axis_reference['origin_mm'])}, {_tuple3(axis_reference['direction'])})" + for candidate in params.get("axis_candidates") or []: + if candidate.get("model_start_mm") and candidate.get("model_direction"): + return f"Axis({_tuple3(candidate['model_start_mm'])}, {_tuple3(candidate['model_direction'])})" + + workplane = sketch.get("workplane", {}) + origin = workplane.get("origin_mm", [0, 0, 0]) + direction = workplane.get("x_dir", [1, 0, 0]) + return f"Axis({_tuple3(origin)}, {_tuple3(direction)})" + + +def _sketch_construction_axis( + sketch: Dict[str, Any], +) -> Optional[tuple[list[float], list[float]]]: + workplane = sketch.get("workplane", {}) + origin = [float(v) for v in workplane.get("origin_mm", [0, 0, 0])] + x_dir = [float(v) for v in workplane.get("x_dir", [1, 0, 0])] + y_dir = [float(v) for v in workplane.get("y_dir", [0, 1, 0])] + + for entity in sketch.get("entities", []): + if entity.get("type") != "line" or not entity.get("construction"): + continue + start = entity.get("start") + end = entity.get("end") + if not start or not end: + continue + start_3d = _sketch_point_to_model_from_basis(origin, x_dir, y_dir, start) + end_3d = _sketch_point_to_model_from_basis(origin, x_dir, y_dir, end) + direction = [end_3d[i] - start_3d[i] for i in range(3)] + length = math.sqrt(sum(component * component for component in direction)) + if length <= 0: + continue + return start_3d, [component / length for component in direction] + return None + + +def _sketch_point_to_model_from_basis( + origin: list[float], x_dir: list[float], y_dir: list[float], point: list[float] +) -> list[float]: + return [ + origin[i] + x_dir[i] * float(point[0]) + y_dir[i] * float(point[1]) + for i in range(3) + ] + + +def _generate_fillet(op: Dict[str, Any]) -> list[str]: + params = op.get("parameters", {}) + radius = params.get("radius_mm") + selectors = op.get("selectors", []) + owned_faces = op.get("source_owned_faces") or [] + if not radius or float(radius) <= 0: + return [f" # Fillet skipped: source radius missing for {op.get('name', '')}"] + return [ + f" # Fillet: {op.get('name', '')}", + " result = fillet_selected(" + f"result, radius={radius}, selectors={repr(selectors)}, owned_faces={repr(owned_faces)})", + ] + + +def _generate_chamfer(op: Dict[str, Any]) -> list[str]: + params = op.get("parameters", {}) + distance = params.get("distance_mm") + selectors = op.get("selectors", []) + owned_faces = op.get("source_owned_faces") or [] + if not distance or float(distance) <= 0: + return [f" # Chamfer skipped: source distance missing for {op.get('name', '')}"] + return [ + f" # Chamfer: {op.get('name', '')}", + " result = chamfer_selected_with_owned_faces(" + f"result, distance={distance}, selectors={repr(selectors)}, owned_faces={repr(owned_faces)})", + ] + + +def _generate_move_face(op: Dict[str, Any]) -> list[str]: + data = (op.get("parameters") or {}).get("move_face_data") or {} + selected_faces = data.get("selected_faces") or [] + return [ + f" # MoveFace pure-JSON operation: {op.get('name', '')}", + " raise NotImplementedError(", + f" 'MoveFace native build123d replay is pending; captured selected_faces={len(selected_faces)}'", + " )", + ] + + +def _hole_should_use_sw_cut_holes(params: Dict[str, Any], owned_cut_faces: list[Dict[str, Any]]) -> bool: + positions = params.get("positions") or [] + diameter = _hole_diameter_mm(params) + if not positions or diameter <= 0: + return False + if len(owned_cut_faces) <= 1: + return False + has_cone_owned = any((face.get("surface") or {}).get("is_cone") for face in owned_cut_faces) + drill_angle = _hole_drill_angle_rad(params) + if has_cone_owned and not (_hole_has_drill_tip(params) and drill_angle > 0): + return False + counterbore_diameter = _hole_counterbore_diameter_mm(params) + counterbore_depth = _hole_counterbore_depth_mm(params) + if counterbore_diameter > diameter and counterbore_depth > 0: + return True + return _hole_has_through_dimension(params) + + +def _effective_hole_cut_depth_mm(params: Dict[str, Any]) -> float: + if _hole_has_through_dimension(params): + return THROUGH_CUT_AMOUNT_MM + return _hole_depth_mm(params) + + +def _generate_hole(op: Dict[str, Any]) -> list[str]: + params = op.get("parameters", {}) + diameter = _hole_diameter_mm(params) + depth = _effective_hole_cut_depth_mm(params) + drill_angle = _hole_drill_angle_rad(params) + include_drill_tip = _hole_has_drill_tip(params) + countersink_diameter = _hole_countersink_diameter_mm(params) + countersink_angle = _hole_countersink_angle_rad(params) + counterbore_diameter = _hole_counterbore_diameter_mm(params) + counterbore_depth = _hole_counterbore_depth_mm(params) + positions = [pos.get("mm") for pos in params.get("positions", []) if pos.get("mm")] + host_face = params.get("host_face") or {} + owned_cut_faces = _hole_owned_cut_faces(op) + # Feature position sketches are occasionally incomplete in the plugin export + # (notably for wizard holes with multiple instances). The faces owned by the + # feature are the authoritative result from SolidWorks, including every hole + # location, counterbore, countersink, and drill tip. Prefer replaying those + # surfaces whenever they are available; fall back to the parametric cutter + # only when the exporter has no usable owned-face geometry. + if owned_cut_faces: + return [ + f" # Hole: {op.get('name', '')}", + " # Replay hole from SW owned cut faces to preserve side and axis", + f" result = cut_owned_cylindrical_faces(result, {repr(owned_cut_faces)})", + ] + return [ + f" # Hole: {op.get('name', '')}", + f" result = sw_cut_holes(result, positions={json.dumps(positions)}, host_face={json.dumps(host_face)}, diameter={diameter}, depth={depth}, drill_angle={drill_angle}, include_drill_tip={include_drill_tip}, countersink_diameter={countersink_diameter}, countersink_angle={countersink_angle}, counterbore_diameter={counterbore_diameter}, counterbore_depth={counterbore_depth})", + ] + + +def _hole_owned_cut_faces(op: Dict[str, Any]) -> list[Dict[str, Any]]: + matched = [] + for face in op.get("source_owned_faces") or []: + if not isinstance(face, dict): + continue + surface = face.get("surface") if isinstance(face.get("surface"), dict) else {} + bbox = face.get("box_m") + has_cylinder = ( + surface.get("is_cylinder") + and isinstance(surface.get("cylinder_params"), list) + and len(surface.get("cylinder_params") or []) >= 7 + ) + has_cone = ( + surface.get("is_cone") + and isinstance(surface.get("cone_params"), list) + and len(surface.get("cone_params") or []) >= 8 + ) + if not (has_cylinder or has_cone): + continue + if not (isinstance(bbox, list) and len(bbox) >= 6): + continue + matched.append(face) + return matched + + +def _generate_linear_pattern( + op: Dict[str, Any], + operations: list[Dict[str, Any]], + sketches: Dict[str, Dict[str, Any]], + references: Dict[str, Any], +) -> list[str]: + params = op.get("parameters", {}) + source_features = params.get("source_features") or [] + offsets = _linear_pattern_offsets(op) + code = [f" # Linear pattern: {op.get('name', '')}"] + + if not source_features or not offsets: + code.append(" # Skip: no source features or pattern offsets") + return code + + for source_feature in source_features: + source_op = _find_operation_for_source_feature(operations, source_feature) + if not source_op: + code.append(f" # Skip: source feature not found {source_feature.get('name')}") + continue + + for offset_index, offset in enumerate(offsets, start=1): + copied_op = _translated_operation(source_op, offset) + copied_op["name"] = f"{source_op.get('name', '')} pattern copy {offset_index}" + op_type = copied_op.get("type") + + if op_type == "hole": + code.extend(_generate_hole(copied_op)) + elif op_type in ("extrude_cut", "extrude_add", "revolve_cut", "revolve_add"): + source_sketch_id = copied_op.get("sketch") + source_sketch = sketches.get(source_sketch_id or "") + if not source_sketch: + code.append(f" # Skip: source sketch not found for {copied_op.get('name')}") + continue + if not _sketch_has_buildable_profile(source_sketch): + code.append(f" # Skip: source sketch has no buildable profile for {copied_op.get('name')}") + continue + + copied_sketch = _translated_sketch(source_sketch, offset, f"{source_sketch_id}_pattern_{offset_index}") + code.extend(_generate_sketch(copied_sketch, references, copied_op)) + if op_type in ("extrude_cut", "extrude_add"): + code.extend(_generate_extrude(copied_op, copied_sketch, operations, sketches)) + else: + code.extend(_generate_revolve(copied_op, copied_sketch)) + else: + code.append(f" # TODO: pattern source type {op_type}") + + return code + + +def _generate_mirror_pattern( + op: Dict[str, Any], + operations: list[Dict[str, Any]], + sketches: Dict[str, Dict[str, Any]], + references: Dict[str, Any], +) -> list[str]: + """生成镜像代码。SW MirrorPattern 镜像的是特征而非整体,因此必须先切掉镜像面负侧的实体,只保留正侧一半再镜像。""" + params = op.get("parameters", {}) + source_features = params.get("source_features") or [] + raw = op.get("raw_parameters", {}) + mirror_plane_info = raw.get("mirror_plane") or {} + + code = [f" # Mirror pattern: {op.get('name', '')}"] + + plane_origin = _extract_mirror_plane_origin(raw, mirror_plane_info) + plane_normal = _extract_mirror_plane_normal(raw, mirror_plane_info) + + mx = plane_origin[0] if plane_origin else 0.0 + my = plane_origin[1] if plane_origin else 0.0 + mz = plane_origin[2] if plane_origin else 0.0 + nx = plane_normal[0] if plane_normal else 0.0 + ny = plane_normal[1] if plane_normal else 0.0 + nz = plane_normal[2] if plane_normal else 1.0 + + code.append(f" mirror_plane = Plane(origin=({mx}, {my}, {mz}), z_dir=({nx}, {ny}, {nz}))") + code.append(f" mx, my, mz = {mx}, {my}, {mz}") + code.append(f" nx, ny, nz = {nx}, {ny}, {nz}") + code.append(f" try:") + code.append(f" bbox = result.bounding_box()") + code.append(f" margin = 10.0") + # Determine dominant axis and cut away the -normal side + adx, ady, adz = abs(nx), abs(ny), abs(nz) + if adx >= ady and adx >= adz: + if nx > 0: + code.append(f" cut_w = (mx - bbox.min.X) + margin") + code.append(f" cut_box = Solid.make_box(cut_w, bbox.max.Y - bbox.min.Y + 2*margin, bbox.max.Z - bbox.min.Z + 2*margin)") + code.append(f" cut_box = cut_box.translate((bbox.min.X - margin, bbox.min.Y - margin, bbox.min.Z - margin))") + else: + code.append(f" cut_w = (bbox.max.X - mx) + margin") + code.append(f" cut_box = Solid.make_box(cut_w, bbox.max.Y - bbox.min.Y + 2*margin, bbox.max.Z - bbox.min.Z + 2*margin)") + code.append(f" cut_box = cut_box.translate((mx, bbox.min.Y - margin, bbox.min.Z - margin))") + elif ady >= adx and ady >= adz: + if ny > 0: + code.append(f" cut_h = (my - bbox.min.Y) + margin") + code.append(f" cut_box = Solid.make_box(bbox.max.X - bbox.min.X + 2*margin, cut_h, bbox.max.Z - bbox.min.Z + 2*margin)") + code.append(f" cut_box = cut_box.translate((bbox.min.X - margin, bbox.min.Y - margin, bbox.min.Z - margin))") + else: + code.append(f" cut_h = (bbox.max.Y - my) + margin") + code.append(f" cut_box = Solid.make_box(bbox.max.X - bbox.min.X + 2*margin, cut_h, bbox.max.Z - bbox.min.Z + 2*margin)") + code.append(f" cut_box = cut_box.translate((bbox.min.X - margin, my, bbox.min.Z - margin))") + else: + if nz > 0: + code.append(f" cut_d = (mz - bbox.min.Z) + margin") + code.append(f" cut_box = Solid.make_box(bbox.max.X - bbox.min.X + 2*margin, bbox.max.Y - bbox.min.Y + 2*margin, cut_d)") + code.append(f" cut_box = cut_box.translate((bbox.min.X - margin, bbox.min.Y - margin, bbox.min.Z - margin))") + else: + code.append(f" cut_d = (bbox.max.Z - mz) + margin") + code.append(f" cut_box = Solid.make_box(bbox.max.X - bbox.min.X + 2*margin, bbox.max.Y - bbox.min.Y + 2*margin, cut_d)") + code.append(f" cut_box = cut_box.translate((bbox.min.X - margin, bbox.min.Y - margin, mz))") + code.append(f" half = result.cut(cut_box)") + code.append(f" mirrored = half.mirror(mirror_plane)") + code.append(f" result = half.fuse(mirrored).clean()") + code.append(f" except Exception as e:") + code.append(f" print(f'mirror failed: {{e}}')") + return code + + +def _extract_mirror_plane_origin(raw: dict, mirror_plane_info: dict): + mir_origin = raw.get("mirror_plane_origin") + if mir_origin and isinstance(mir_origin, (list, tuple)) and len(mir_origin) >= 3: + return (float(mir_origin[0]), float(mir_origin[1]), float(mir_origin[2])) + origin_list = mirror_plane_info.get("origin_mm") or mirror_plane_info.get("origin") or [] + if origin_list and len(origin_list) >= 3: + return (float(origin_list[0]), float(origin_list[1]), float(origin_list[2])) + frame = mirror_plane_info.get("frame") + if isinstance(frame, dict): + origin_list = frame.get("origin") or [] + if origin_list and len(origin_list) >= 3: + return (float(origin_list[0]), float(origin_list[1]), float(origin_list[2])) + return None + + +def _extract_mirror_plane_normal(raw: dict, mirror_plane_info: dict): + mir_normal = raw.get("mirror_plane_normal") + if mir_normal and isinstance(mir_normal, (list, tuple)) and len(mir_normal) >= 3: + return (float(mir_normal[0]), float(mir_normal[1]), float(mir_normal[2])) + normal_list = mirror_plane_info.get("normal") or [] + if normal_list and len(normal_list) >= 3: + return (float(normal_list[0]), float(normal_list[1]), float(normal_list[2])) + frame = mirror_plane_info.get("frame") + if isinstance(frame, dict): + normal_list = frame.get("normal") or [] + if normal_list and len(normal_list) >= 3: + return (float(normal_list[0]), float(normal_list[1]), float(normal_list[2])) + return None + + +def _find_operation_for_source_feature( + operations: list[Dict[str, Any]], source_feature: Dict[str, Any] +) -> Optional[Dict[str, Any]]: + source_index = source_feature.get("index") + source_name = source_feature.get("name") + source_identity = source_feature.get("identity") if isinstance(source_feature.get("identity"), dict) else {} + source_stable_id = source_feature.get("stable_id") or source_identity.get("stable_id") + source_persistent_reference = source_feature.get("persistent_reference") or source_identity.get("persistent_reference") + for op in operations: + op_source = op.get("source_feature", {}) + if source_index is not None and op_source.get("index") == source_index: + return op + for op in operations: + op_source = op.get("source_feature", {}) + op_identity = op_source.get("identity") if isinstance(op_source.get("identity"), dict) else {} + if source_stable_id and ( + op_source.get("stable_id") == source_stable_id + or op_identity.get("stable_id") == source_stable_id + ): + return op + if source_persistent_reference and ( + op_source.get("persistent_reference") == source_persistent_reference + or op_identity.get("persistent_reference") == source_persistent_reference + ): + return op + for op in operations: + if source_name and op.get("name") == source_name: + return op + return None + + +def _find_source_operation_for_pattern( + operations: list[Dict[str, Any]], source_features: list[Dict[str, Any]] +) -> Optional[Dict[str, Any]]: + for source_feature in source_features: + source_op = _find_operation_for_source_feature(operations, source_feature) + if source_op: + return source_op + return None + + +def _linear_pattern_offsets(op: Dict[str, Any]) -> list[tuple[float, float, float]]: + params = op.get("parameters", {}) + raw = op.get("raw_parameters", {}) + explicit_offsets = raw.get("explicit_offsets_mm") + if isinstance(explicit_offsets, list) and explicit_offsets: + return [ + (float(offset[0]), float(offset[1]), float(offset[2])) + for offset in explicit_offsets + if isinstance(offset, list) and len(offset) >= 3 + ] + d1_count = int(raw.get("d1_total_instances") or params.get("total_instances") or 1) + d2_count = int(raw.get("d2_total_instances") or 1) + d1_spacing = float(raw.get("d1_spacing_mm") or params.get("spacing_mm") or 0) + d2_spacing = float(raw.get("d2_spacing_mm") or 0) + d1_vector = _pattern_direction_vector(raw.get("direction1") or params.get("direction1"), d1_spacing) + d2_vector = _pattern_direction_vector(raw.get("direction2") or params.get("direction2"), d2_spacing) + + offsets = [] + for i in range(d1_count): + for j in range(d2_count): + if i == 0 and j == 0: + continue + offsets.append(tuple(d1_vector[k] * i + d2_vector[k] * j for k in range(3))) + return offsets + + +def _pattern_direction_vector(direction: Optional[Dict[str, Any]], spacing: float) -> tuple[float, float, float]: + if not direction or not spacing: + return (0.0, 0.0, 0.0) + direct_vector = direction.get("vector") + if isinstance(direct_vector, list) and len(direct_vector) >= 3: + vector = tuple(float(direct_vector[i]) for i in range(3)) + length = math.sqrt(sum(component * component for component in vector)) + if length <= 0: + return (0.0, 0.0, 0.0) + return tuple(component / length * spacing for component in vector) + start = direction.get("start", {}).get("mm") + end = direction.get("end", {}).get("mm") + if not start or not end: + return (0.0, 0.0, 0.0) + vector = tuple(float(end[i]) - float(start[i]) for i in range(3)) + length = math.sqrt(sum(component * component for component in vector)) + if length <= 0: + return (0.0, 0.0, 0.0) + return tuple(component / length * spacing for component in vector) + + +def _translated_operation(op: Dict[str, Any], offset: tuple[float, float, float]) -> Dict[str, Any]: + copied = deepcopy(op) + params = copied.get("parameters") or {} + axis_reference = params.get("axis_reference") + if isinstance(axis_reference, dict) and isinstance(axis_reference.get("origin_mm"), list): + origin = list(axis_reference.get("origin_mm") or [0, 0, 0]) + origin = (origin + [0, 0, 0])[:3] + axis_reference["origin_mm"] = [float(origin[i]) + float(offset[i]) for i in range(3)] + + if copied.get("type") == "hole": + positions = params.get("positions") or [] + local_offset = _model_offset_to_host_local(offset, params.get("host_face") or {}) + for position in positions: + if position.get("mm"): + point = list(position.get("mm") or [0, 0, 0]) + point = (point + [0, 0, 0])[:3] + position["mm"] = [ + float(point[0]) + local_offset[0], + float(point[1]) + local_offset[1], + float(point[2]) + local_offset[2], + ] + if position.get("m"): + position["m"] = [value / 1000 for value in position.get("mm", [])] + if any(abs(float(offset[i])) > 1e-9 for i in range(3)): + owned_faces = copied.get("source_owned_faces") or [] + if owned_faces: + copied["source_owned_faces"] = _translate_owned_faces(owned_faces, offset) + return copied + + +def _translate_owned_faces( + faces: list[Dict[str, Any]], + offset: tuple[float, float, float], +) -> list[Dict[str, Any]]: + translated = [] + shift_mm = (float(offset[0]), float(offset[1]), float(offset[2])) + shift_m = (shift_mm[0] / 1000.0, shift_mm[1] / 1000.0, shift_mm[2] / 1000.0) + for face in faces: + if not isinstance(face, dict): + continue + copied = deepcopy(face) + box = copied.get("box_m") + if isinstance(box, list) and len(box) >= 6: + copied["box_m"] = [ + float(box[0]) + shift_m[0], + float(box[1]) + shift_m[1], + float(box[2]) + shift_m[2], + float(box[3]) + shift_m[0], + float(box[4]) + shift_m[1], + float(box[5]) + shift_m[2], + ] + surface = copied.get("surface") + if isinstance(surface, dict): + for key in ("cylinder_params", "cone_params"): + params = surface.get(key) + if isinstance(params, list) and len(params) >= 3: + updated = list(params) + updated[0] = float(updated[0]) + shift_m[0] + updated[1] = float(updated[1]) + shift_m[1] + updated[2] = float(updated[2]) + shift_m[2] + surface[key] = updated + translated.append(copied) + return translated + + +def _model_offset_to_host_local( + offset: tuple[float, float, float], + host_face: Dict[str, Any], +) -> tuple[float, float, float]: + frame = host_face.get("frame") if isinstance(host_face, dict) else {} + if not isinstance(frame, dict): + return offset + x_dir = frame.get("x_dir") + y_dir = frame.get("y_dir") + if not ( + isinstance(x_dir, list) + and len(x_dir) >= 3 + and isinstance(y_dir, list) + and len(y_dir) >= 3 + ): + return offset + local_x = sum(float(offset[i]) * float(x_dir[i]) for i in range(3)) + local_y = sum(float(offset[i]) * float(y_dir[i]) for i in range(3)) + return (local_x, local_y, 0.0) + + +def _translated_sketch( + sketch: Dict[str, Any], offset: tuple[float, float, float], sketch_id: str +) -> Dict[str, Any]: + copied = deepcopy(sketch) + copied["id"] = sketch_id + copied["name"] = f"{sketch.get('name', sketch_id)} pattern copy" + workplane = copied.setdefault("workplane", {}) + origin = list(workplane.get("origin_mm") or [0, 0, 0]) + origin = (origin + [0, 0, 0])[:3] + workplane["origin_mm"] = [float(origin[i]) + float(offset[i]) for i in range(3)] + return copied + + +def _translate_sketch_entities(sketch: Dict[str, Any], offset: tuple[float, float, float]) -> None: + dx, dy = offset[0], offset[1] + for entity in sketch.get("entities", []): + for key in ("start", "end", "center"): + point = entity.get(key) + if isinstance(point, list) and len(point) >= 2: + point[0] = float(point[0]) + dx + point[1] = float(point[1]) + dy + raw = entity.get("raw", {}) + for key in ("start", "end", "center"): + raw_point = raw.get(key) + if isinstance(raw_point, dict): + mm = raw_point.get("mm") + if isinstance(mm, list) and len(mm) >= 2: + mm[0] = float(mm[0]) + dx + mm[1] = float(mm[1]) + dy + raw_point["m"] = [value / 1000 for value in mm] + + +def _hole_diameter_mm(params: Dict[str, Any]) -> float: + if params.get("diameter_mm"): + return float(params["diameter_mm"]) + diameters = params.get("diameters_m", {}) + for key in ( + "hole_diameter", + "thru_hole_diameter", + "tap_drill_diameter", + "thru_tap_drill_diameter", + "thread_diameter", + "diameter", + ): + value = diameters.get(key) + if value: + return float(value) * 1000 + return 0 + + +def _hole_depth_mm(params: Dict[str, Any]) -> float: + if params.get("depth_mm"): + return float(params["depth_mm"]) + depths = params.get("depths_m", {}) + for key in ( + "hole_depth", + "thru_hole_depth", + "tap_drill_depth", + "thru_tap_drill_depth", + "thread_depth", + "depth", + ): + value = depths.get(key) + if value: + return float(value) * 1000 + return THROUGH_CUT_AMOUNT_MM + + +def _hole_drill_angle_rad(params: Dict[str, Any]) -> float: + angle = params.get("angles_rad", {}).get("drill_angle") + return float(angle) if angle else 0 + + +def _hole_countersink_angle_rad(params: Dict[str, Any]) -> float: + angle = params.get("angles_rad", {}).get("countersink_angle") + return float(angle) if angle else 0 + + +def _hole_countersink_diameter_mm(params: Dict[str, Any]) -> float: + diameter = params.get("countersink_diameter_mm") + return float(diameter) if diameter else 0 + + +def _hole_counterbore_diameter_mm(params: Dict[str, Any]) -> float: + diameter = params.get("counterbore_diameter_mm") + return float(diameter) if diameter else 0 + + +def _hole_counterbore_depth_mm(params: Dict[str, Any]) -> float: + depth = params.get("counterbore_depth_mm") + return float(depth) if depth else 0 + + +def _hole_has_drill_tip(params: Dict[str, Any]) -> bool: + depths = params.get("depths_m", {}) + angle = _hole_drill_angle_rad(params) + if angle <= 0: + return False + through_depth_keys = ( + "thru_hole_depth", + "thru_tap_drill_depth", + ) + if any(depths.get(key) for key in through_depth_keys): + return False + if params.get("depth_mm"): + return True + return any(depths.get(key) for key in ("hole_depth", "tap_drill_depth", "depth")) + + +def _hole_has_through_dimension(params: Dict[str, Any]) -> bool: + names = " ".join(str(name).lower() for name in params.get("dimension_names", []) or []) + return any(token in names for token in ("通孔", "through", "thru")) + + +def _hole_dimension_value(data_block: Dict[str, Any], tokens: tuple[str, ...]) -> Optional[float]: + for dim in data_block.get("dimensions", []) or []: + name = str(dim.get("name") or "").lower() + if all(token.lower() in name for token in tokens) and dim.get("value") not in (None, ""): + return float(dim.get("value")) + return None + + +def _feature_length_dimension_mm(feature: Dict[str, Any]) -> Optional[float]: + candidates: list[tuple[int, float]] = [] + for dim in feature.get("dimensions") or []: + if not isinstance(dim, dict): + continue + name = str(dim.get("name") or "") + system_value = dim.get("system_value_m") + if system_value not in (None, ""): + length_mm = abs(float(system_value)) * 1000 + elif dim.get("value") not in (None, ""): + length_mm = abs(float(dim.get("value"))) + else: + continue + if length_mm <= 1e-9 or length_mm > 500: + continue + priority = 0 if name.startswith("D1@") else 1 + candidates.append((priority, length_mm)) + if not candidates: + return None + candidates.sort(key=lambda item: (item[0], item[1])) + return candidates[0][1] + + +def _feature_selection_selectors( + feature: Dict[str, Any], + data_block: Optional[Dict[str, Any]] = None, +) -> list[Dict[str, Any]]: + selectors: list[Dict[str, Any]] = [] + seen: set[str] = set() + sources = [] + if isinstance(data_block, dict): + sources.extend(data_block.get("selections") or []) + sources.extend(feature.get("selections") or []) + + for selection in sources: + if not isinstance(selection, dict) or selection.get("kind") != "selection": + continue + geometry = selection.get("object") + if not isinstance(geometry, dict): + continue + kind = geometry.get("kind") + if kind not in ("edge", "face"): + continue + identity = geometry.get("identity") if isinstance(geometry.get("identity"), dict) else {} + stable_key = ( + geometry.get("stable_id") + or geometry.get("persistent_reference") + or identity.get("stable_id") + or identity.get("persistent_reference") + or json.dumps(geometry, sort_keys=True, ensure_ascii=False, default=str) + ) + if stable_key in seen: + continue + seen.add(str(stable_key)) + selectors.append({ + "kind": kind, + "geometry": geometry, + "mark": selection.get("mark"), + "source_feature": { + "name": selection.get("feature_name"), + "type_name": selection.get("feature_type_name"), + }, + }) + if selectors: + return selectors + + for face in feature.get("owned_faces") or []: + if not isinstance(face, dict): + continue + surface = face.get("surface") if isinstance(face.get("surface"), dict) else {} + cylinder_params = surface.get("cylinder_params") + if not (surface.get("is_cylinder") and isinstance(cylinder_params, list) and len(cylinder_params) >= 7): + continue + radius_mm = abs(float(cylinder_params[6]) * 1000) + line_params = [ + float(cylinder_params[0]), + float(cylinder_params[1]), + float(cylinder_params[2]), + float(cylinder_params[3]), + float(cylinder_params[4]), + float(cylinder_params[5]), + ] + stable_key = f"owned_cylinder:{','.join(f'{value:.9g}' for value in line_params)}:{radius_mm:.6g}" + if stable_key in seen: + continue + seen.add(stable_key) + selectors.append({ + "kind": "edge", + "geometry": { + "kind": "edge", + "curve": { + "kind": "curve", + "is_line": True, + "line_params": line_params, + }, + "bbox_mm": [float(value) * 1000 for value in face.get("box_m", [])[:6]] + if isinstance(face.get("box_m"), list) and len(face.get("box_m")) >= 6 + else None, + }, + "tolerance_mm": max(0.5, radius_mm * 2.5), + "source": "owned_cylindrical_face_axis", + }) + for face in feature.get("owned_faces") or []: + if not isinstance(face, dict): + continue + box_m = face.get("box_m") + if not (isinstance(box_m, list) and len(box_m) >= 6): + continue + bbox_mm = [float(value) * 1000 for value in box_m[:6]] + if any(not math.isfinite(value) for value in bbox_mm): + continue + sizes = [abs(bbox_mm[i + 3] - bbox_mm[i]) for i in range(3)] + stable_key = f"owned_face_bbox:{','.join(f'{value:.9g}' for value in bbox_mm)}" + if stable_key in seen: + continue + seen.add(stable_key) + selectors.append({ + "kind": "edge", + "geometry": { + "kind": "edge", + "bbox_mm": bbox_mm, + }, + "tolerance_mm": max(0.5, min(max(sizes), 10.0) * 0.35), + "source": "owned_face_bbox", + }) + return selectors + + +def _feature_selection_source( + feature: Dict[str, Any], + data_block: Optional[Dict[str, Any]] = None, +) -> str: + sources = [] + if isinstance(data_block, dict): + sources.extend(data_block.get("selections") or []) + sources.extend(feature.get("selections") or []) + if any(isinstance(item, dict) and item.get("kind") == "selection" for item in sources): + return "solidworks_original_selection" + if feature.get("owned_faces"): + return "post_feature_owned_face_inference" + return "missing" + + +def _hole_dimension_value_excluding( + data_block: Dict[str, Any], + tokens: tuple[str, ...], + excluded: tuple[str, ...] = (), +) -> Optional[float]: + for dim in data_block.get("dimensions", []) or []: + name = str(dim.get("name") or "").lower() + if excluded and any(token.lower() in name for token in excluded): + continue + if all(token.lower() in name for token in tokens) and dim.get("value") not in (None, ""): + return float(dim.get("value")) + return None + + +def _hole_primary_dimension_fallback(data_block: Dict[str, Any], prefer_small: bool) -> Optional[float]: + values = [] + for dim in data_block.get("dimensions", []) or []: + name = str(dim.get("name") or "").lower() + if not any(token in name for token in ("孔", "hole", "螺", "thread")): + continue + if any(token in name for token in ("沉头", "锥", "counter", "csk", "导头", "angle", "角度")): + continue + value = dim.get("value") + if value in (None, ""): + continue + number = abs(float(value)) + if 0 < number < 200: + values.append(number) + if not values: + return None + return min(values) if prefer_small else max(values) + + +def _hole_primary_diameter_mm(data_block: Dict[str, Any]) -> float: + diameter = ( + _hole_dimension_value_excluding(data_block, ("tap", "drill", "dia"), ("depth", "angle")) + or _hole_dimension_value_excluding(data_block, ("tap", "drill", "diameter"), ("depth", "angle")) + or _hole_dimension_value_excluding(data_block, ("螺纹孔钻头", "直径"), ("深度", "角度")) + or _hole_dimension_value_excluding(data_block, ("钻头", "直径"), ("深度", "角度")) + or _hole_dimension_value_excluding(data_block, ("通孔", "孔直径"), ("沉头", "锥", "counter", "csk", "角度", "深度")) + or _hole_dimension_value_excluding(data_block, ("孔直径",), ("沉头", "锥", "counter", "csk", "角度", "深度")) + or _hole_dimension_value_excluding(data_block, ("hole", "diameter"), ("counter", "csk", "angle", "depth")) + or _hole_dimension_value_excluding(data_block, ("thread", "diameter"), ("counter", "csk", "angle", "depth")) + or _hole_dimension_value_excluding(data_block, ("螺纹",), ("深度", "depth", "角度", "angle")) + or _hole_primary_dimension_fallback(data_block, prefer_small=True) + ) + return abs(float(diameter)) if diameter else 0 + + +def _hole_primary_depth_mm(data_block: Dict[str, Any]) -> float: + depth = ( + _hole_dimension_value_excluding(data_block, ("通孔", "孔深度"), ("沉头", "锥", "counter", "csk", "角度", "直径")) + or _hole_dimension_value_excluding(data_block, ("孔深度",), ("沉头", "锥", "counter", "csk", "角度", "直径")) + or _hole_dimension_value_excluding(data_block, ("螺纹孔钻头", "深度"), ("直径", "角度")) + or _hole_dimension_value_excluding(data_block, ("通孔", "螺纹孔钻头", "深度"), ("直径", "角度")) + or _hole_dimension_value_excluding(data_block, ("tap", "drill", "depth"), ("diameter", "angle")) + or _hole_dimension_value_excluding(data_block, ("hole", "depth"), ("counter", "csk", "angle", "diameter")) + or _hole_dimension_value_excluding(data_block, ("thread", "depth"), ("counter", "csk", "angle", "diameter")) + ) + if depth: + return abs(float(depth)) + return THROUGH_CUT_AMOUNT_MM + + +def _hole_counterbore_dimension_mm(data_block: Dict[str, Any]) -> Optional[float]: + return ( + _hole_dimension_value(data_block, ("柱形沉头", "直径")) + or _hole_dimension_value(data_block, ("柱形沉头孔", "直径")) + or _hole_dimension_value(data_block, ("沉头孔", "直径")) + or _hole_dimension_value(data_block, ("counterbore", "diameter")) + or _hole_dimension_value(data_block, ("counter", "bore", "diameter")) + ) + + +def _hole_counterbore_depth_dimension_mm(data_block: Dict[str, Any]) -> Optional[float]: + return ( + _hole_dimension_value(data_block, ("柱形沉头", "深度")) + or _hole_dimension_value(data_block, ("柱形沉头孔", "深度")) + or _hole_dimension_value(data_block, ("沉头孔", "深度")) + or _hole_dimension_value(data_block, ("counterbore", "depth")) + or _hole_dimension_value(data_block, ("counter", "bore", "depth")) + ) + + +def _hole_angle_dimension_rad(data_block: Dict[str, Any], tokens: tuple[str, ...]) -> Optional[float]: + for dim in data_block.get("dimensions", []) or []: + name = str(dim.get("name") or "").lower() + if all(token.lower() in name for token in tokens): + if dim.get("system_value_m") not in (None, ""): + return float(dim.get("system_value_m")) + if dim.get("value") not in (None, ""): + value = float(dim.get("value")) + return value / 1000 if value > math.tau else value + return None + + +def _extract_edge_selector_points(op: Dict[str, Any]) -> list[list[tuple[float, float, float]]]: + selector_points = [] + for selector in op.get("selectors", []): + geometry = selector.get("geometry") or {} + start = geometry.get("start_vertex") or {} + start_point = start.get("point_m") if isinstance(start, dict) else None + end = geometry.get("end_vertex") or {} + end_point = end.get("point_m") if isinstance(end, dict) else None + if start_point and end_point: + selector_points.append([_point_m_to_mm(start_point), _point_m_to_mm(end_point)]) + return selector_points + + +_SW_METADATA_FEATURE_TYPES = { + "commentsfolder", + "favoritefolder", + "historyfolder", + "selectionsetfolder", + "sensorfolder", + "docsfolder", + "detailcabinet", + "surfacebodyfolder", + "solidbodyfolder", + "envfolder", + "inkmarkupfolder", + "eqnfolder", + "materialfolder", + "configtablefolder", + "ftrfolder", +} + + +def _source_feature(feature: Dict[str, Any], index: int) -> Dict[str, Any]: + source = feature.get("source_feature") if isinstance(feature.get("source_feature"), dict) else {} + identity = source.get("identity") if isinstance(source.get("identity"), dict) else {} + return { + "index": source.get("index", index), + "id": feature.get("id"), + "name": feature.get("name"), + "type": feature.get("type"), + "type_name": feature.get("type_name"), + "stable_id": source.get("stable_id") or identity.get("stable_id"), + "persistent_reference": source.get("persistent_reference") or identity.get("persistent_reference"), + "identity": identity or None, + } + + +def _source_owned_faces(feature: Dict[str, Any]) -> list[Dict[str, Any]]: + faces = feature.get("owned_faces") + if not isinstance(faces, list): + return [] + summarized = [] + for face in faces: + if not isinstance(face, dict): + continue + surface = face.get("surface") if isinstance(face.get("surface"), dict) else {} + summarized.append( + { + "box_m": face.get("box_m"), + "area_m2": face.get("area_m2"), + "surface": { + "is_plane": bool(surface.get("is_plane")), + "is_cylinder": bool(surface.get("is_cylinder")), + "is_cone": bool(surface.get("is_cone")), + "is_sphere": bool(surface.get("is_sphere")), + "is_torus": bool(surface.get("is_torus")), + "cylinder_params": surface.get("cylinder_params"), + "cone_params": surface.get("cone_params"), + "plane_params": surface.get("plane_params"), + }, + } + ) + return summarized + + +def _convert_sw_reference(feature: Dict[str, Any], index: int) -> Dict[str, Any]: + snapshot = feature.get("definition_snapshot", {}) + return { + "id": feature.get("id") or f"reference_{index:03d}", + "name": feature.get("name"), + "type": feature.get("type"), + "sw_type": feature.get("type_name"), + "definition": snapshot.get("values", {}), + "source_feature": _source_feature(feature, index), + } + + +def _convert_sw_sketch(feature: Dict[str, Any], sketch_id: str, index: int) -> Dict[str, Any]: + sketch_data = feature.get("sketch_data", {}) + raw_entities = sketch_data.get("entities", []) + raw_converted_entities = [_convert_sw_sketch_entity(entity) for entity in raw_entities] + converted_entities = [] + raw_to_converted_index: dict[int, int] = {} + stable_id_to_raw_index: dict[str, int] = {} + for raw_index, (raw_entity, converted_entity) in enumerate(zip(raw_entities, raw_converted_entities)): + for stable_id in _selectable_stable_ids(raw_entity): + stable_id_to_raw_index.setdefault(stable_id, raw_index) + if converted_entity is None: + continue + raw_to_converted_index[raw_index] = len(converted_entities) + converted_entities.append(converted_entity) + loops = [] + for contour in sketch_data.get("sketch_contours", []) or sketch_data.get("contours", []) or []: + if not isinstance(contour, dict): + continue + entity_indices = contour.get("entity_indices") or contour.get("segment_indices") or [] + if not entity_indices: + entity_indices = _contour_entity_indices_from_segments(contour, stable_id_to_raw_index) + if not entity_indices: + continue + normalized_indices = [ + raw_to_converted_index[int(idx)] + for idx in entity_indices + if isinstance(idx, (int, float)) and int(idx) in raw_to_converted_index + ] + if not normalized_indices: + continue + bbox = _loop_bbox([ + converted_entities[idx] + for idx in normalized_indices + if 0 <= idx < len(converted_entities) + ]) + loops.append({ + "id": contour.get("contour_id"), + "entity_indices": normalized_indices, + "is_closed": contour.get("is_closed"), + "bbox_mm": bbox or contour.get("bbox_mm"), + "bbox_area_mm2": _bbox_area_2d(bbox) if bbox else contour.get("bbox_area_mm2"), + "source": "solidworks_sketch_contour", + }) + workplane = sketch_data.get("workplane") or {} + if not workplane: + workplane = {"name": sketch_data.get("plane"), "origin_mm": [0, 0, 0], "normal": [0, 0, 1], "x_dir": [1, 0, 0], "y_dir": [0, 1, 0]} + return { + "id": sketch_id, + "name": feature.get("name", sketch_id), + "workplane": workplane, + "host_reference": sketch_data.get("host_reference"), + "entities": converted_entities, + "loops": loops, + "sketch_regions": sketch_data.get("sketch_regions", []), + "constraints": sketch_data.get("constraints", []), + "inferred_constraints": sketch_data.get("inferred_constraints", []), + "dimensions": sketch_data.get("dimensions", []), + "feature_dimensions": sketch_data.get("feature_dimensions", []), + "source_feature": _source_feature(feature, index), + } + + +def _selectable_stable_ids(value: Any) -> list[str]: + if not isinstance(value, dict): + return [] + candidates = [value.get("stable_id")] + identity = value.get("identity") + if isinstance(identity, dict): + candidates.append(identity.get("stable_id")) + return [str(candidate) for candidate in candidates if candidate] + + +def _contour_entity_indices_from_segments(contour: Dict[str, Any], stable_id_to_raw_index: dict[str, int]) -> list[int]: + indices: list[int] = [] + seen: set[int] = set() + for segment in contour.get("sketch_segments") or []: + for stable_id in _selectable_stable_ids(segment): + raw_index = stable_id_to_raw_index.get(stable_id) + if raw_index is None or raw_index in seen: + continue + seen.add(raw_index) + indices.append(raw_index) + break + return indices + + +def _convert_sw_sketch_entity(entity: Dict[str, Any]) -> Optional[Dict[str, Any]]: + entity_type = str(entity.get("canonical_entity_type") or entity.get("entity_type", "")).lower() + curve = entity.get("curve") if isinstance(entity.get("curve"), dict) else {} + if ( + entity_type == "circle_or_arc" + or curve.get("is_circle") is True + or entity.get("curve_entity_type") == "circle_or_arc" + ): + center = entity.get("curve_center_mm") or entity.get("center_mm") + radius_mm_value = entity.get("curve_radius_mm") or entity.get("radius_mm") + radius_raw_value = entity.get("radius") + start = entity.get("start_mm") + end = entity.get("end_mm") + start_2d = [float(start[0]), float(start[1])] if isinstance(start, list) and len(start) >= 2 else None + end_2d = [float(end[0]), float(end[1])] if isinstance(end, list) and len(end) >= 2 else None + center_2d = [float(center[0]), float(center[1])] if isinstance(center, list) and len(center) >= 2 else [0.0, 0.0] + radius_mm = float(radius_mm_value) if radius_mm_value is not None else _scale_length(radius_raw_value or 0) + if start_2d and end_2d and math.hypot(start_2d[0] - end_2d[0], start_2d[1] - end_2d[1]) > 1e-6: + # 计算 sweep 方向 + import math as _math + sa = _math.degrees(_math.atan2(start_2d[1] - center_2d[1], start_2d[0] - center_2d[0])) + ea = _math.degrees(_math.atan2(end_2d[1] - center_2d[1], end_2d[0] - center_2d[0])) + sweep = round(ea - sa, 10) + while sweep <= -180: + sweep += 360 + while sweep > 180: + sweep -= 360 + result = { + "type": "arc", + "center": center_2d, + "start": start_2d, + "end": end_2d, + "radius_mm": radius_mm, + "start_angle_deg": round(sa, 10), + "end_angle_deg": round(ea, 10), + "arc_sweep_deg": round(sweep, 10), + "construction": bool(entity.get("construction")), + "raw": entity, + } + curve_axis = entity.get("curve_axis") + if isinstance(curve_axis, list) and len(curve_axis) >= 3: + result["curve_axis"] = [float(v) for v in curve_axis[:3]] + return result + return { + "type": "circle", + "center": center_2d, + "radius_mm": radius_mm, + "construction": bool(entity.get("construction")), + "raw": entity, + } + if "line" in entity_type: + return { + "type": "line", + "start": _sketch_point_mm(entity, "start"), + "end": _sketch_point_mm(entity, "end"), + "construction": bool(entity.get("construction")), + "raw": entity, + } + if "circle" in entity_type: + return { + "type": "circle", + "center": _sketch_point_mm(entity, "center"), + "radius_mm": _sketch_radius_mm(entity), + "construction": bool(entity.get("construction")), + "raw": entity, + } + if "arc" in entity_type: + return { + "type": "arc", + "center": _sketch_point_mm(entity, "center"), + "start": _sketch_point_mm(entity, "start"), + "end": _sketch_point_mm(entity, "end"), + "radius_mm": _sketch_radius_mm(entity), + "start_angle_deg": _to_degrees(entity.get("start_angle", 0)), + "end_angle_deg": _to_degrees(entity.get("end_angle", 360)), + "construction": bool(entity.get("construction")), + "raw": entity, + } + if entity_type == "point": + point = entity.get("point_mm") or [float(entity.get("x", 0)) * 1000, float(entity.get("y", 0)) * 1000, 0] + return {"type": "point", "point": point[:2], "point_mm": point, "construction": bool(entity.get("construction")), "raw": entity} + return None + + +def _sketch_point_mm(entity: Dict[str, Any], key: str) -> list[float]: + point = entity.get(f"{key}_mm") + if isinstance(point, list) and len(point) >= 2: + return [float(point[0]), float(point[1])] + return _scale_point(entity.get(key, [0, 0])) + + +def _sketch_radius_mm(entity: Dict[str, Any]) -> float: + for key in ("radius_mm", "major_radius_mm", "major_radius"): + if entity.get(key) is not None: + return _scale_length(entity.get(key)) + if entity.get("radius") is not None: + return _scale_length(entity.get("radius")) + start = _sketch_point_mm(entity, "start") + center = _sketch_point_mm(entity, "center") + if start and center: + return math.hypot(float(start[0]) - float(center[0]), float(start[1]) - float(center[1])) + return 1.0 + + +def _convert_sw_extrude(feature: Dict[str, Any], type_name: str, sketch_id: Optional[str], index: int) -> Dict[str, Any]: + data_block = feature.get("extrude_data", {}) + op_type = "extrude_cut" if _is_cut_feature(feature, type_name) else "extrude_add" + distance = _best_extrude_depth_mm(feature, data_block) + reverse_end_condition_code = data_block.get("reverse_end_condition_code") + reverse_distance = abs(data_block.get("reverse_depth") or 0) + both_directions = bool(data_block.get("both_directions", False)) + reverse_direction = data_block.get("is_reverse") + if reverse_direction is None: + reverse_direction = data_block.get("definition_snapshot", {}).get("ReverseDirection") + if reverse_direction is None: + reverse_direction = feature.get("definition_snapshot", {}).get("values", {}).get("ReverseDirection", False) + if reverse_end_condition_code in (None, 0) and data_block.get("effective_depth_source") == "feature_dimension": + spans_both_sides = _extrude_owned_faces_span_sketch_plane(feature, data_block) + if spans_both_sides and bool(reverse_direction): + both_directions = True + reverse_distance = reverse_distance or distance + else: + both_directions = False + reverse_distance = 0 + raw_depth = abs(data_block.get("depth") or data_block.get("blind_depth") or 0) + uses_reverse_depth_only = ( + op_type == "extrude_cut" + and + feature.get("type") == "ice" + and raw_depth <= 1e-9 + and reverse_distance > 0 + ) + if uses_reverse_depth_only: + reverse_direction = not bool(reverse_direction) if False else bool(reverse_direction) + return { + "id": feature.get("id"), + "name": feature.get("name"), + "type": op_type, + "sketch": sketch_id, + "parameters": { + "distance_mm": distance, + "reverse": bool(reverse_direction), + "reverse_direction": bool(reverse_direction), + "reverse_distance_mm": reverse_distance, + "both_directions": False if uses_reverse_depth_only else both_directions, + "end_condition": data_block.get("end_condition"), + "end_condition_code": data_block.get("end_condition_code"), + "reverse_end_condition_code": reverse_end_condition_code, + "flip_side_to_cut": bool(data_block.get("flip_side_to_cut", False)), + "start_condition_reference": _clean_null_reference(data_block.get("start_condition_reference")), + "end_condition_reference": _clean_null_reference(data_block.get("end_condition_reference")), + "reverse_end_condition_reference": _clean_null_reference(data_block.get("reverse_end_condition_reference")), + "draft_angle_rad": data_block.get("draft_angle_rad"), + "reverse_draft_angle_rad": data_block.get("reverse_draft_angle_rad"), + }, + "source_feature": _source_feature(feature, index), + "source_owned_faces": _source_owned_faces(feature), + } + + +def _extrude_owned_faces_span_sketch_plane(feature: Dict[str, Any], data_block: Dict[str, Any]) -> bool: + sketches = data_block.get("source_sketches") or [] + workplane = sketches[0].get("workplane") if sketches and isinstance(sketches[0], dict) else None + if not isinstance(workplane, dict): + return bool(data_block.get("both_directions")) and (data_block.get("reverse_depth") not in (None, 0)) + + origin = workplane.get("origin_mm") or [0, 0, 0] + normal = workplane.get("normal") or [0, 0, 1] + if not isinstance(origin, list) or not isinstance(normal, list) or len(origin) < 3 or len(normal) < 3: + return False + + nx, ny, nz = (float(normal[0]), float(normal[1]), float(normal[2])) + length = math.sqrt(nx * nx + ny * ny + nz * nz) or 1.0 + nx, ny, nz = nx / length, ny / length, nz / length + ox, oy, oz = float(origin[0]), float(origin[1]), float(origin[2]) + + min_distance = math.inf + max_distance = -math.inf + for face in feature.get("owned_faces") or []: + box = face.get("box_m") if isinstance(face, dict) else None + if not isinstance(box, list) or len(box) < 6: + continue + xs = [float(box[0]) * 1000, float(box[3]) * 1000] + ys = [float(box[1]) * 1000, float(box[4]) * 1000] + zs = [float(box[2]) * 1000, float(box[5]) * 1000] + for x in xs: + for y in ys: + for z in zs: + distance_to_plane = (x - ox) * nx + (y - oy) * ny + (z - oz) * nz + min_distance = min(min_distance, distance_to_plane) + max_distance = max(max_distance, distance_to_plane) + + if math.isinf(min_distance) or math.isinf(max_distance): + return False + tolerance = 1e-4 + return min_distance < -tolerance and max_distance > tolerance + + +def _convert_sw_revolve(feature: Dict[str, Any], type_name: str, sketch_id: Optional[str], index: int) -> Dict[str, Any]: + data_block = feature.get("revolve_data", {}) + op_type = "revolve_cut" if _is_cut_feature(feature, type_name) else "revolve_add" + selected_axis = _axis_reference_from_feature_selections(data_block.get("selections")) + owned_face_axis = _axis_reference_from_owned_faces(feature) + extracted_axis = _extract_axis_reference(data_block.get("axis_reference")) + axis_reference = selected_axis or owned_face_axis + if not axis_reference and not _is_weak_inferred_axis(extracted_axis): + axis_reference = extracted_axis + return { + "id": feature.get("id"), + "name": feature.get("name"), + "type": op_type, + "sketch": sketch_id, + "parameters": { + "angle_deg": abs(data_block.get("angle") or 360), + "angle_rad": data_block.get("angle_rad"), + "reverse": data_block.get("is_reverse", False), + "end_condition": data_block.get("end_condition"), + "end_condition_code": data_block.get("end_condition_code"), + "axis_reference": axis_reference, + "axis_candidates": data_block.get("axis_candidates", []), + }, + "source_feature": _source_feature(feature, index), + "source_owned_faces": _source_owned_faces(feature), + } + + +def _axis_reference_from_owned_faces(feature: Dict[str, Any]) -> Optional[Dict[str, Any]]: + candidates: list[tuple[float, Dict[str, Any]]] = [] + for face in feature.get("owned_faces") or []: + if not isinstance(face, dict): + continue + surface = face.get("surface") if isinstance(face.get("surface"), dict) else {} + params = None + if surface.get("is_cylinder") and isinstance(surface.get("cylinder_params"), list): + params = surface.get("cylinder_params") + elif surface.get("is_cone") and isinstance(surface.get("cone_params"), list): + params = surface.get("cone_params") + if not isinstance(params, list) or len(params) < 6: + continue + direction = [float(value) for value in params[3:6]] + norm = math.sqrt(sum(value * value for value in direction)) + if norm <= 1e-9: + continue + candidates.append(( + float(face.get("area_m2") or 0.0), + { + "origin_mm": [float(value) * 1000 for value in params[:3]], + "direction": [value / norm for value in direction], + "source": "owned_face_axis", + }, + )) + if not candidates: + return None + candidates.sort(key=lambda item: item[0], reverse=True) + return candidates[0][1] + + +def _is_weak_inferred_axis(axis_reference: Optional[Dict[str, Any]]) -> bool: + if not isinstance(axis_reference, dict): + return False + return str(axis_reference.get("source") or "") in {"construction_line_candidate", "construction_line"} + + +def _convert_sw_hole(feature: Dict[str, Any], index: int) -> Dict[str, Any]: + data_block = feature.get("hole_data", {}) + positions = [] + host_face = _host_face_from_feature_selections(data_block.get("selections")) or {} + position_sketches = _hole_position_sketches(data_block.get("position_sketches", []) or []) + for sketch in position_sketches: + workplane = sketch.get("workplane") or {} + if not host_face and workplane: + host_face = _host_face_from_workplane(workplane) + for point in _hole_position_points(sketch): + positions.append({"mm": [float(point[0]), float(point[1]), float(point[2] if len(point) > 2 else 0)]}) + diameter_mm = abs(data_block.get("diameter") or 0) or _hole_primary_diameter_mm(data_block) + depth_mm = abs(data_block.get("depth") or 0) or _hole_primary_depth_mm(data_block) + return { + "id": feature.get("id"), + "name": feature.get("name"), + "type": "hole", + "parameters": { + "diameter_mm": diameter_mm, + "depth_mm": depth_mm, + "counterbore_diameter_mm": _hole_counterbore_dimension_mm(data_block), + "counterbore_depth_mm": _hole_counterbore_depth_dimension_mm(data_block), + "countersink_diameter_mm": _hole_dimension_value(data_block, ("锥形沉头", "直径")) + or _hole_dimension_value(data_block, ("近端锥形沉头", "直径")) + or _hole_dimension_value(data_block, ("锥坑", "直径")) + or _hole_dimension_value(data_block, ("countersink", "diameter")) + or _hole_dimension_value(data_block, ("csk", "diameter")), + "angles_rad": { + "countersink_angle": _hole_angle_dimension_rad(data_block, ("锥形沉头", "角度")) + or _hole_angle_dimension_rad(data_block, ("近端锥形沉头", "角度")) + or _hole_angle_dimension_rad(data_block, ("锥坑", "角度")) + or _hole_angle_dimension_rad(data_block, ("countersink", "angle")) + or _hole_angle_dimension_rad(data_block, ("csk", "angle")), + "drill_angle": _hole_angle_dimension_rad(data_block, ("导头", "角度")) + or _hole_angle_dimension_rad(data_block, ("drill", "angle")) + or _hole_angle_dimension_rad(data_block, ("tip", "angle")), + }, + "positions": positions, + "host_face": host_face, + "hole_type": data_block.get("hole_type"), + "standard": data_block.get("standard"), + "size": data_block.get("size"), + "dimension_names": [ + str(dim.get("name") or "") + for dim in data_block.get("dimensions", []) or [] + if isinstance(dim, dict) + ], + }, + "source_feature": _source_feature(feature, index), + "source_owned_faces": _source_owned_faces(feature), + } + + +def _hole_position_sketches(sketches: list[Dict[str, Any]]) -> list[Dict[str, Any]]: + point_only = [] + for sketch in sketches: + entities = sketch.get("entities") or [] + if not entities: + continue + if _is_hole_profile_sketch(sketch): + continue + point_count = sum(1 for entity in entities if _is_sketch_point_entity(entity)) + drawable_segment_count = sum( + 1 + for entity in entities + if not _is_sketch_point_entity(entity) and not entity.get("construction") + ) + if point_count > 0 and drawable_segment_count == 0: + point_only.append(sketch) + return point_only or sketches[:1] + + +def _is_hole_profile_sketch(sketch: Dict[str, Any]) -> bool: + tokens = ( + "孔直径", + "孔深度", + "沉头", + "导头", + "螺纹孔钻头", + "tap drill", + "drill", + "counterbore", + "countersink", + "hole diameter", + "hole depth", + ) + dimension_sources = [] + dimension_sources.extend(sketch.get("dimensions") or []) + dimension_sources.extend(sketch.get("feature_dimensions") or []) + for dim in dimension_sources: + if not isinstance(dim, dict): + continue + name = str(dim.get("name") or "").lower() + if any(token in name for token in tokens): + return True + return False + + +def _is_sketch_point_entity(entity: Dict[str, Any]) -> bool: + entity_type = str(entity.get("entity_type") or entity.get("type") or "").lower() + return entity_type == "point" + + +def _hole_position_entity_flags(entity: Dict[str, Any]) -> tuple[Optional[bool], bool]: + raw = entity.get("raw") if isinstance(entity.get("raw"), dict) else entity + candidate = raw.get("hole_position_candidate") + if candidate is None: + candidate = entity.get("hole_position_candidate") + if isinstance(candidate, bool): + candidate_flag: Optional[bool] = candidate + else: + candidate_flag = None + construction_reference = bool( + raw.get("construction_endpoint_reference") or entity.get("construction_endpoint_reference") + ) + return candidate_flag, construction_reference + + +def _construction_endpoint_degrees(sketch: Dict[str, Any]) -> dict[tuple[float, float, float], int]: + degrees: dict[tuple[float, float, float], int] = {} + for entity in sketch.get("entities") or []: + if not entity.get("construction"): + continue + entity_type = str(entity.get("entity_type") or entity.get("type") or "").lower() + if "line" not in entity_type: + continue + for key in ("start_mm", "end_mm"): + endpoint = entity.get(key) + if isinstance(endpoint, list) and len(endpoint) >= 2: + point_key = _rounded_point_key(endpoint) + degrees[point_key] = degrees.get(point_key, 0) + 1 + return degrees + + +def _hole_position_points(sketch: Dict[str, Any]) -> list[list[float]]: + """Return only real Hole Wizard placement points from a position sketch. + + SolidWorks Hole Wizard position sketches often include construction + segments whose endpoints are reference geometry, not hole centers. Older + parser JSON exposes those endpoints as ordinary sketch points, so we filter + them generically here instead of letting every point become a hole. + """ + entities = sketch.get("entities") or [] + point_entities: list[tuple[list[float], Optional[bool], bool]] = [] + construction_endpoints: set[tuple[float, float, float]] = set() + + for entity in entities: + point = entity.get("point_mm") + if _is_sketch_point_entity(entity) and isinstance(point, list) and len(point) >= 2: + candidate_flag, construction_reference = _hole_position_entity_flags(entity) + point_entities.append( + ( + [float(point[0]), float(point[1]), float(point[2] if len(point) > 2 else 0)], + candidate_flag, + construction_reference, + ) + ) + continue + if not entity.get("construction"): + continue + entity_type = str(entity.get("entity_type") or entity.get("type") or "").lower() + if "line" not in entity_type: + continue + for key in ("start_mm", "end_mm"): + endpoint = entity.get(key) + if isinstance(endpoint, list) and len(endpoint) >= 2: + construction_endpoints.add(_rounded_point_key(endpoint)) + + if not point_entities: + return [] + + explicit_candidates = [ + point for point, candidate_flag, _ in point_entities if candidate_flag is True + ] + if explicit_candidates: + return _dedupe_points(explicit_candidates) + + endpoint_degrees = _construction_endpoint_degrees(sketch) + if endpoint_degrees: + filtered = [] + for point, candidate_flag, construction_reference in point_entities: + point_key = _rounded_point_key(point) + degree = endpoint_degrees.get(point_key, 0) + if candidate_flag is False and construction_reference and degree <= 1: + continue + if degree >= 2 or not construction_reference: + filtered.append(point) + filtered = _dedupe_points(filtered) + non_origin_filtered = [point for point in filtered if not _is_near_origin(point)] + if non_origin_filtered: + return _dedupe_points(non_origin_filtered) + if filtered: + return filtered + + raw_points = _dedupe_points([point for point, _, _ in point_entities]) + if not raw_points or not construction_endpoints: + return raw_points + + legacy_filtered = [point for point in raw_points if _rounded_point_key(point) not in construction_endpoints] + non_origin_raw = [point for point in raw_points if not _is_near_origin(point)] + non_origin_filtered = [point for point in legacy_filtered if not _is_near_origin(point)] + if non_origin_filtered: + return _dedupe_points(non_origin_filtered) + if non_origin_raw: + return _dedupe_points(non_origin_raw) + return _dedupe_points(legacy_filtered or raw_points) + + +def _dedupe_points(points: list[list[float]]) -> list[list[float]]: + result = [] + seen = set() + for point in points: + key = _rounded_point_key(point) + if key in seen: + continue + seen.add(key) + result.append(point) + return result + + +def _is_near_origin(point: list[float], tolerance: float = 1e-6) -> bool: + return math.sqrt(sum(float(component) * float(component) for component in point[:3])) <= tolerance + + +def _rounded_point_key(point: list[Any], digits: int = 5) -> tuple[float, float, float]: + z = point[2] if len(point) > 2 else 0 + return (round(float(point[0]), digits), round(float(point[1]), digits), round(float(z), digits)) + + +def _convert_sw_linear_pattern( + feature: Dict[str, Any], + index: int, + previous_build_op: Optional[Dict[str, Any]], + source_frame: Optional[Dict[str, Any]] = None, + sketches: Optional[list[Dict[str, Any]]] = None, + source_bbox: Optional[list[float]] = None, +) -> Dict[str, Any]: + data_block = feature.get("linear_pattern_data", {}) + source_features = data_block.get("source_features") or [] + if not source_features and previous_build_op: + source_features = [previous_build_op.get("source_feature", {})] + spacing_1 = data_block.get("spacing_1") + spacing_2 = data_block.get("spacing_2") + direction_1 = _pattern_direction_from_plugin(data_block.get("direction_1"), axis="x", source_frame=source_frame) + direction_2 = _pattern_direction_from_plugin(data_block.get("direction_2"), axis="y", source_frame=source_frame) + direction_1 = _pattern_direction_from_reference(direction_1, data_block.get("direction_1_reference"), source_frame) + direction_2 = _pattern_direction_from_reference(direction_2, data_block.get("direction_2_reference"), source_frame) + if data_block.get("direction_1_reverse") is True: + direction_1 = _reverse_pattern_direction(direction_1) + if data_block.get("direction_2_reverse") is True: + direction_2 = _reverse_pattern_direction(direction_2) + source_op_bbox = _operation_profile_bbox(previous_build_op, sketches or []) + if data_block.get("direction_1") is None: + direction_1 = _choose_pattern_direction_sign( + direction_1, + spacing_1 or 0, + int(data_block.get("pattern_count_1") or 1), + source_op_bbox, + source_bbox, + ) + if data_block.get("direction_2") is None: + direction_2 = _choose_pattern_direction_sign( + direction_2, + spacing_2 or 0, + int(data_block.get("pattern_count_2") or 1), + source_op_bbox, + source_bbox, + ) + explicit_offsets = _owned_face_pattern_offsets(previous_build_op, feature) + return { + "id": feature.get("id"), + "name": feature.get("name"), + "type": "linear_pattern", + "parameters": { + "source_features": source_features, + "total_instances": data_block.get("pattern_count_1") or 1, + "spacing_mm": spacing_1 or 0, + "direction1": direction_1, + "direction2": direction_2, + }, + "raw_parameters": { + "d1_total_instances": data_block.get("pattern_count_1") or 1, + "d2_total_instances": data_block.get("pattern_count_2") or 1, + "d1_spacing_mm": spacing_1 or 0, + "d2_spacing_mm": spacing_2 or 0, + "direction1": direction_1, + "direction2": direction_2, + "explicit_offsets_mm": explicit_offsets, + }, + "source_feature": _source_feature(feature, index), + "source_owned_faces": _source_owned_faces(feature), + } + + +def _owned_face_pattern_offsets( + source_op: Optional[Dict[str, Any]], + pattern_feature: Dict[str, Any], +) -> list[list[float]]: + if not source_op: + return [] + source_faces = _owned_face_signatures(source_op.get("source_owned_faces") or []) + pattern_faces = _owned_face_signatures(_source_owned_faces(pattern_feature)) + if not source_faces or not pattern_faces: + return [] + + votes: Dict[tuple[float, float, float], int] = {} + for pattern_face in pattern_faces: + for source_face in source_faces: + if pattern_face["kind"] != source_face["kind"]: + continue + if not _similar_bbox_size(pattern_face["size"], source_face["size"]): + continue + offset = tuple( + round(pattern_face["center"][axis] - source_face["center"][axis], 3) + for axis in range(3) + ) + if math.sqrt(sum(component * component for component in offset)) < 1e-6: + continue + votes[offset] = votes.get(offset, 0) + 1 + + if not votes: + return [] + threshold = max(1, min(2, len(source_faces))) + offsets = [offset for offset, count in votes.items() if count >= threshold] + offsets.sort(key=lambda offset: (offset[0] * offset[0] + offset[1] * offset[1] + offset[2] * offset[2], offset)) + return [[float(value) for value in offset] for offset in offsets] + + +def _owned_face_signatures(faces: list[Dict[str, Any]]) -> list[Dict[str, Any]]: + signatures = [] + for face in faces: + if not isinstance(face, dict): + continue + box = face.get("box_m") + if not isinstance(box, list) or len(box) < 6: + continue + box_mm = [float(value) * 1000 for value in box[:6]] + surface = face.get("surface") if isinstance(face.get("surface"), dict) else {} + kind = "other" + if surface.get("is_cylinder"): + kind = "cylinder" + elif surface.get("is_cone"): + kind = "cone" + elif surface.get("is_plane"): + kind = "plane" + signatures.append( + { + "kind": kind, + "center": [(box_mm[i] + box_mm[i + 3]) / 2 for i in range(3)], + "size": [abs(box_mm[i + 3] - box_mm[i]) for i in range(3)], + } + ) + return signatures + + +def _similar_bbox_size(a: list[float], b: list[float], tolerance: float = 0.05) -> bool: + return all(abs(float(a[i]) - float(b[i])) <= tolerance for i in range(3)) + + +def _source_pattern_frame( + previous_build_op: Optional[Dict[str, Any]], + sketches: list[Dict[str, Any]], +) -> Optional[Dict[str, Any]]: + if not previous_build_op: + return None + params = previous_build_op.get("parameters") or {} + host_frame = ((params.get("host_face") or {}).get("frame") or {}) + if host_frame.get("x_dir") and host_frame.get("y_dir"): + return host_frame + sketch_id = previous_build_op.get("sketch") + for sketch in sketches: + if sketch.get("id") == sketch_id: + workplane = sketch.get("workplane") or {} + if workplane.get("x_dir") and workplane.get("y_dir"): + return workplane + return None + + +def _source_bbox_from_plugin_json(data: Dict[str, Any]) -> Optional[list[float]]: + bbox = (data.get("validation_hints") or {}).get("part_box_m") + if isinstance(bbox, list) and len(bbox) >= 6: + return [float(v) * 1000 for v in bbox[:6]] + return None + + +def _operation_profile_bbox( + op: Optional[Dict[str, Any]], + sketches: list[Dict[str, Any]], +) -> Optional[list[float]]: + if not op: + return None + if op.get("type") == "hole": + host_face = (op.get("parameters") or {}).get("host_face") or {} + positions = [ + _hole_position_to_model(pos.get("mm"), host_face) + for pos in (op.get("parameters") or {}).get("positions", []) + if isinstance(pos.get("mm"), list) and len(pos.get("mm")) >= 3 + ] + if positions: + return _points_bbox(positions) + sketch_id = op.get("sketch") + sketch = next((item for item in sketches if item.get("id") == sketch_id), None) + if not sketch: + return None + points = [] + workplane = sketch.get("workplane") or {} + origin = workplane.get("origin_mm") or [0, 0, 0] + x_dir = workplane.get("x_dir") or [1, 0, 0] + y_dir = workplane.get("y_dir") or [0, 1, 0] + for entity in sketch.get("entities", []) or []: + if entity.get("type") == "circle": + center = entity.get("center") or [0, 0] + radius = float(entity.get("radius_mm") or 0) + for dx, dy in ((-radius, -radius), (-radius, radius), (radius, -radius), (radius, radius)): + points.append(_sketch_point_to_model_bbox(origin, x_dir, y_dir, [float(center[0]) + dx, float(center[1]) + dy])) + for key in ("start", "end", "center", "point"): + point = entity.get(key) + if isinstance(point, list) and len(point) >= 2: + points.append(_sketch_point_to_model_bbox(origin, x_dir, y_dir, point)) + return _points_bbox(points) + + +def _sketch_point_to_model_bbox(origin: list[Any], x_dir: list[Any], y_dir: list[Any], point: list[Any]) -> list[float]: + return [ + float(origin[i]) + float(x_dir[i]) * float(point[0]) + float(y_dir[i]) * float(point[1]) + for i in range(3) + ] + + +def _points_bbox(points: list[list[float]]) -> Optional[list[float]]: + if not points: + return None + return [ + min(point[0] for point in points), + min(point[1] for point in points), + min(point[2] for point in points), + max(point[0] for point in points), + max(point[1] for point in points), + max(point[2] for point in points), + ] + + +def _hole_position_to_model(point: list[Any], host_face: Dict[str, Any]) -> list[float]: + frame = host_face.get("frame") if isinstance(host_face, dict) else {} + if not isinstance(frame, dict): + return [float(v) for v in (point + [0, 0, 0])[:3]] + origin = frame.get("origin_mm") or [0, 0, 0] + x_dir = frame.get("x_dir") or [1, 0, 0] + y_dir = frame.get("y_dir") or [0, 1, 0] + values = [float(v) for v in (point + [0, 0, 0])[:3]] + return [ + float(origin[i]) + float(x_dir[i]) * values[0] + float(y_dir[i]) * values[1] + for i in range(3) + ] + + +def _choose_pattern_direction_sign( + direction: Dict[str, Any], + spacing: float, + count: int, + source_op_bbox: Optional[list[float]], + source_bbox: Optional[list[float]], +) -> Dict[str, Any]: + vector = direction.get("vector") + if ( + not isinstance(vector, list) + or len(vector) < 3 + or not spacing + or count <= 1 + or not source_op_bbox + or not source_bbox + ): + return direction + unit = _unit3(vector) + distance = float(spacing) * (count - 1) + positive = [component * distance for component in unit] + negative = [-component * distance for component in unit] + positive_score = _bbox_overflow_score(_translated_bbox(source_op_bbox, positive), source_bbox) + negative_score = _bbox_overflow_score(_translated_bbox(source_op_bbox, negative), source_bbox) + if abs(positive_score - negative_score) <= 1e-9: + positive_score += _bbox_center_distance_score(_translated_bbox(source_op_bbox, positive), source_bbox) + negative_score += _bbox_center_distance_score(_translated_bbox(source_op_bbox, negative), source_bbox) + copied = dict(direction) + if negative_score + 1e-9 < positive_score: + copied["vector"] = [-component for component in unit] + copied["source"] = f"{direction.get('source', 'missing_direction')}_sign_from_source_bbox" + return copied + copied["vector"] = unit + if positive_score + 1e-9 < negative_score: + copied["source"] = f"{direction.get('source', 'missing_direction')}_sign_from_source_bbox" + return copied + + +def _unit3(vector: list[Any]) -> list[float]: + raw = [float(vector[i]) for i in range(3)] + length = math.sqrt(sum(v * v for v in raw)) + if length <= 0: + return [0.0, 0.0, 0.0] + return [v / length for v in raw] + + +def _translated_bbox(bbox: list[float], offset: list[float]) -> list[float]: + return [ + bbox[0] + offset[0], + bbox[1] + offset[1], + bbox[2] + offset[2], + bbox[3] + offset[0], + bbox[4] + offset[1], + bbox[5] + offset[2], + ] + + +def _bbox_overflow_score(candidate: list[float], source: list[float]) -> float: + score = 0.0 + for axis in range(3): + score += max(source[axis] - candidate[axis], 0) + score += max(candidate[axis + 3] - source[axis + 3], 0) + return score + + +def _bbox_center_distance_score(candidate: list[float], source: list[float]) -> float: + score = 0.0 + for axis in range(3): + source_center = (source[axis] + source[axis + 3]) / 2 + candidate_center = (candidate[axis] + candidate[axis + 3]) / 2 + axis_size = max(source[axis + 3] - source[axis], 1.0) + score += abs(candidate_center - source_center) / axis_size + return score + + +def _is_cut_feature(feature: Dict[str, Any], type_name: str) -> bool: + text = f"{type_name} {feature.get('name', '')}".lower() + return "cut" in text or "切除" in text or "revcut" in text + + +def _best_extrude_depth_mm(feature: Dict[str, Any], data_block: Dict[str, Any]) -> float: + for key in ("depth", "blind_depth"): + value = data_block.get(key) + if value: + return abs(float(value)) + owned_face_depth = _extrude_depth_from_owned_faces(feature, data_block) + effective_depth = abs(float(data_block.get("effective_depth") or 0)) + if ( + owned_face_depth + and _is_cut_feature(feature, str(feature.get("type_name") or feature.get("type") or "")) + and data_block.get("effective_depth_source") == "feature_dimension" + and not data_block.get("depth") + and not data_block.get("blind_depth") + and not data_block.get("reverse_depth") + and effective_depth > owned_face_depth * 2 + ): + return owned_face_depth + owner_name = feature.get("name") + for dim in data_block.get("dimensions", []) or []: + name = dim.get("name") or "" + if owner_name and f"@{owner_name}@" in name and dim.get("value") not in (None, 0): + return abs(float(dim.get("value"))) + for dim in data_block.get("dimensions", []) or []: + if dim.get("owner") == owner_name and dim.get("value") not in (None, 0): + return abs(float(dim.get("value"))) + if data_block.get("reverse_depth") not in (None, 0): + return abs(float(data_block.get("reverse_depth"))) + if data_block.get("effective_depth") not in (None, 0): + return abs(float(data_block.get("effective_depth"))) + return 0.0 + + +def _extrude_depth_from_owned_faces(feature: Dict[str, Any], data_block: Dict[str, Any]) -> Optional[float]: + sketches = data_block.get("source_sketches") or [] + workplane = sketches[0].get("workplane") if sketches and isinstance(sketches[0], dict) else None + if not isinstance(workplane, dict): + return None + normal = workplane.get("normal") or [0, 0, 1] + if not isinstance(normal, list) or len(normal) < 3: + return None + axis = max(range(3), key=lambda idx: abs(float(normal[idx]))) + values: list[float] = [] + for face in feature.get("owned_faces") or []: + if not isinstance(face, dict): + continue + box = face.get("box_m") + if isinstance(box, list) and len(box) >= 6: + values.extend([float(box[axis]) * 1000, float(box[axis + 3]) * 1000]) + if not values: + return None + extent = max(values) - min(values) + return abs(extent) if extent > 1e-6 else None + + +def _host_face_from_workplane(workplane: Dict[str, Any]) -> Dict[str, Any]: + origin = workplane.get("origin_mm") or [0, 0, 0] + normal = workplane.get("normal") or [0, 0, 1] + x_dir = workplane.get("x_dir") or [1, 0, 0] + y_dir = workplane.get("y_dir") or [0, 1, 0] + return { + "surface": {"plane_params": [*normal[:3], *(float(v) / 1000 for v in origin[:3])]}, + "frame": {"origin_mm": origin[:3], "x_dir": x_dir[:3], "y_dir": y_dir[:3], "normal": normal[:3]}, + } + + +def _pattern_direction_from_plugin( + direction: Any, + axis: str, + source_frame: Optional[Dict[str, Any]] = None, +) -> Dict[str, Any]: + if isinstance(direction, dict): + return direction + if source_frame: + key = "y_dir" if axis == "y" else "x_dir" + vector = source_frame.get(key) + if isinstance(vector, list) and len(vector) >= 3: + return {"vector": vector[:3], "source": f"source_feature_frame_{key}"} + if axis == "y": + return {"vector": [0, 1, 0], "source": "default_y_when_plugin_direction_missing"} + return {"vector": [1, 0, 0], "source": "default_x_when_plugin_direction_missing"} + + +def _pattern_direction_from_reference( + fallback: Dict[str, Any], + reference: Any, + source_frame: Optional[Dict[str, Any]] = None, +) -> Dict[str, Any]: + axis = _extract_axis_reference(reference) + if not axis: + return fallback + vector = axis.get("direction") + if not isinstance(vector, list) or len(vector) < 3: + return fallback + model_vector = _sketch_vector_to_model(vector[:3], source_frame) or vector[:3] + model_origin = _sketch_point_to_model(axis.get("origin_mm"), source_frame) or axis.get("origin_mm") + return { + "vector": _unit3(model_vector), + "origin_mm": model_origin, + "source": axis.get("source") or "direction_reference", + } + + +def _sketch_vector_to_model( + vector: list[Any], + source_frame: Optional[Dict[str, Any]], +) -> Optional[list[float]]: + if not source_frame: + return None + x_dir = source_frame.get("x_dir") + y_dir = source_frame.get("y_dir") + normal = source_frame.get("normal") + if not ( + isinstance(x_dir, list) + and len(x_dir) >= 3 + and isinstance(y_dir, list) + and len(y_dir) >= 3 + ): + return None + if not (isinstance(normal, list) and len(normal) >= 3): + normal = [ + float(x_dir[1]) * float(y_dir[2]) - float(x_dir[2]) * float(y_dir[1]), + float(x_dir[2]) * float(y_dir[0]) - float(x_dir[0]) * float(y_dir[2]), + float(x_dir[0]) * float(y_dir[1]) - float(x_dir[1]) * float(y_dir[0]), + ] + values = [float(v) for v in (vector + [0, 0, 0])[:3]] + return [ + values[0] * float(x_dir[i]) + values[1] * float(y_dir[i]) + values[2] * float(normal[i]) + for i in range(3) + ] + + +def _sketch_point_to_model( + point: Any, + source_frame: Optional[Dict[str, Any]], +) -> Optional[list[float]]: + if not isinstance(point, list) or len(point) < 3 or not source_frame: + return None + origin = source_frame.get("origin_mm") + vector = _sketch_vector_to_model(point[:3], source_frame) + if not (isinstance(origin, list) and len(origin) >= 3 and vector): + return None + return [float(origin[i]) + vector[i] for i in range(3)] + + +def _reverse_pattern_direction(direction: Dict[str, Any]) -> Dict[str, Any]: + vector = direction.get("vector") + if not isinstance(vector, list) or len(vector) < 3: + return direction + copied = dict(direction) + copied["vector"] = [-float(vector[0]), -float(vector[1]), -float(vector[2])] + copied["source"] = f"{direction.get('source', 'direction')}_reversed" + return copied + + +def _clean_null_reference(reference: Any) -> Optional[Dict[str, Any]]: + if not isinstance(reference, dict): + return None + if reference.get("kind") == "null": + return None + obj = reference.get("object") + if isinstance(obj, dict) and obj.get("kind") == "null": + return None + return reference + + +def _extract_axis_reference(reference: Any) -> Optional[Dict[str, Any]]: + if not isinstance(reference, dict): + return None + if reference.get("origin_mm") and reference.get("direction"): + return { + "origin_mm": [float(v) for v in reference.get("origin_mm", [])[:3]], + "direction": [float(v) for v in reference.get("direction", [])[:3]], + "source": reference.get("source") or "axis_reference", + } + obj = reference.get("object") if isinstance(reference.get("object"), dict) else reference + if obj.get("kind") == "null": + return None + + line_params = obj.get("line_params") + if isinstance(line_params, list) and len(line_params) >= 6: + return { + "origin_mm": [float(v) * 1000 for v in line_params[:3]], + "direction": [float(v) for v in line_params[3:6]], + "source": reference.get("source") or "selection_line_params", + } + + curve = obj.get("curve") if isinstance(obj.get("curve"), dict) else {} + curve_line_params = curve.get("line_params") + if isinstance(curve_line_params, list) and len(curve_line_params) >= 6: + return { + "origin_mm": [float(v) * 1000 for v in curve_line_params[:3]], + "direction": [float(v) for v in curve_line_params[3:6]], + "source": reference.get("source") or "selection_curve_line_params", + } + return None + + +def _selection_objects(selections: Any) -> list[Dict[str, Any]]: + objects: list[Dict[str, Any]] = [] + if not isinstance(selections, list): + return objects + for selection in selections: + if not isinstance(selection, dict): + continue + obj = selection.get("object") + if isinstance(obj, dict) and obj.get("kind") != "null": + objects.append(obj) + return objects + + +def _axis_reference_from_feature_selections(selections: Any) -> Optional[Dict[str, Any]]: + for obj in _selection_objects(selections): + axis = _extract_axis_reference(obj) + if axis: + axis["source"] = "feature_selection_axis" + return axis + return None + + +def _host_face_from_feature_selections(selections: Any) -> Optional[Dict[str, Any]]: + for obj in _selection_objects(selections): + if obj.get("kind") != "face": + continue + surface = obj.get("surface") if isinstance(obj.get("surface"), dict) else {} + frame = obj.get("frame") if isinstance(obj.get("frame"), dict) else {} + if not frame: + continue + normal = frame.get("normal") or (surface.get("plane_params") or [0, 0, 1])[:3] + origin = frame.get("origin_mm") + if not origin: + plane_params = surface.get("plane_params") + if isinstance(plane_params, list) and len(plane_params) >= 6: + origin = [float(v) * 1000 for v in plane_params[3:6]] + if not origin: + origin = [0, 0, 0] + x_dir = frame.get("x_dir") or [1, 0, 0] + y_dir = frame.get("y_dir") or [0, 1, 0] + origin_values = list(origin) + x_values = list(x_dir) + y_values = list(y_dir) + normal_values = list(normal) + return { + "surface": surface, + "frame": { + "origin_mm": [float(v) for v in (origin_values + [0, 0, 0])[:3]], + "x_dir": [float(v) for v in (x_values + [0, 0, 0])[:3]], + "y_dir": [float(v) for v in (y_values + [0, 0, 0])[:3]], + "normal": [float(v) for v in (normal_values + [0, 0, 1])[:3]], + }, + "source": "feature_selection_face", + } + return None + + +def _tuple3(values: Any) -> tuple[float, float, float]: + values = list(values or [0, 0, 0]) + values = (values + [0, 0, 0])[:3] + return tuple(values) + + +def _point_m_to_mm(point: Any) -> tuple[float, float, float]: + values = list(point or [0, 0, 0]) + values = (values + [0, 0, 0])[:3] + return tuple(float(value) * 1000 for value in values) + + +def _scale_point(point: Any) -> list[float]: + values = [0 if value is None else float(value) for value in (point or [0, 0])] + return [_scale_length(value) for value in values[:2]] + + +def _scale_length(value: Any) -> float: + value = 0 if value is None else float(value) + return value * 1000 if abs(value) <= 10 else value + + +def _to_degrees(value: Any) -> float: + value = 0 if value is None else float(value) + return value * 180 / 3.141592653589793 if abs(value) <= 6.283185307179586 else value diff --git a/backend/engine/cdsl_engine/validate_output3.py b/backend/engine/cdsl_engine/validate_output3.py new file mode 100644 index 00000000..da774c65 --- /dev/null +++ b/backend/engine/cdsl_engine/validate_output3.py @@ -0,0 +1,133 @@ +"""用 SolidWorks evidence 的 document_truth 验收 output3 CDSL 重建结果。""" + +from __future__ import annotations + +import argparse +import json +import math +from pathlib import Path +from typing import Any + +from build123d import CenterOf, import_step + + +FORBIDDEN_CDSL_KEYS = { + "compiler_context", + "entities", + "contour_edges_mm", + "contour_regions_mm", + "_raw_entities", + "vertices", +} + + +def _find_forbidden(value: Any, path: str = "$") -> list[str]: + found: list[str] = [] + if isinstance(value, dict): + for key, item in value.items(): + child = f"{path}.{key}" + if key in FORBIDDEN_CDSL_KEYS: + found.append(child) + found.extend(_find_forbidden(item, child)) + elif isinstance(value, list): + for index, item in enumerate(value): + found.extend(_find_forbidden(item, f"{path}[{index}]")) + return found + + +def validate(cdsl_path: Path, evidence_dir: Path) -> dict[str, Any]: + cdsl = json.loads(cdsl_path.read_text(encoding="utf-8")) + part_id = str(cdsl["part_id"]) + source_name = str(cdsl["meta"]["source"]) + evidence = json.loads((evidence_dir / source_name).read_text(encoding="utf-8")) + truth = evidence["document_truth"] + mass = truth["mass_properties"] + + step_path = cdsl_path.with_name(f"{part_id}_rebuilt.step") + report_path = cdsl_path.with_name(f"{part_id}.rebuild_report.json") + report = json.loads(report_path.read_text(encoding="utf-8")) + solid = import_step(str(step_path)) + + truth_volume = float(mass["volume"]) * 1e9 + truth_area = float(mass["surface_area"]) * 1e6 + truth_com = [float(value) * 1000.0 for value in mass["center_of_mass"]] + rebuilt_com_vector = solid.center(CenterOf.MASS) + rebuilt_com = [rebuilt_com_vector.X, rebuilt_com_vector.Y, rebuilt_com_vector.Z] + bbox = solid.bounding_box() + rebuilt_bbox = [bbox.min.X, bbox.min.Y, bbox.min.Z, bbox.max.X, bbox.max.Y, bbox.max.Z] + truth_bbox = [float(value) * 1000.0 for value in truth["geometry"]["bounding_box"]] + + volume_error_pct = abs(float(solid.volume) - truth_volume) / truth_volume * 100.0 + area_error_pct = abs(float(solid.area) - truth_area) / truth_area * 100.0 + com_delta_mm = math.dist(rebuilt_com, truth_com) + bbox_max_delta_mm = max(abs(a - b) for a, b in zip(rebuilt_bbox, truth_bbox)) + forbidden = _find_forbidden(cdsl) + engine = report.get("engine_result", {}).get("engine") + + checks = { + "engine_cdsl_only": engine == "cdsl_only", + "no_forbidden_geometry_payload": not forbidden, + "volume_error_le_1pct": volume_error_pct <= 1.0, + "surface_area_error_le_1pct": area_error_pct <= 1.0, + "center_of_mass_delta_le_0_1mm": com_delta_mm <= 0.1, + "bbox_delta_le_0_01mm": bbox_max_delta_mm <= 0.01, + } + return { + "part_id": part_id, + "cdsl_path": str(cdsl_path), + "rebuilt_step": str(step_path), + "source_evidence": str(evidence_dir / source_name), + "cdsl_lines": len(cdsl_path.read_text(encoding="utf-8").splitlines()), + "feature_count": len(cdsl.get("features") or []), + "sketch_count": len((cdsl.get("geometry") or {}).get("sketches") or []), + "engine": engine, + "forbidden_paths": forbidden, + "metrics": { + "truth_volume_mm3": truth_volume, + "rebuilt_volume_mm3": float(solid.volume), + "volume_error_pct": volume_error_pct, + "truth_surface_area_mm2": truth_area, + "rebuilt_surface_area_mm2": float(solid.area), + "surface_area_error_pct": area_error_pct, + "center_of_mass_delta_mm": com_delta_mm, + "bbox_max_delta_mm": bbox_max_delta_mm, + }, + "checks": checks, + "passed": all(checks.values()), + } + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--evidence", type=Path, required=True) + parser.add_argument("--report", type=Path, required=True) + args = parser.parse_args() + + results = [ + validate(path, args.evidence) + for path in sorted(args.output.glob("cylinder_*/*.cdsl.json")) + ] + summary = { + "schema": "cad.cdsl.output3.validation.v1", + "count": len(results), + "passed_count": sum(item["passed"] for item in results), + "failed_count": sum(not item["passed"] for item in results), + "max_volume_error_pct": max(item["metrics"]["volume_error_pct"] for item in results), + "max_surface_area_error_pct": max(item["metrics"]["surface_area_error_pct"] for item in results), + "max_center_of_mass_delta_mm": max(item["metrics"]["center_of_mass_delta_mm"] for item in results), + "max_bbox_delta_mm": max(item["metrics"]["bbox_max_delta_mm"] for item in results), + "results": results, + } + args.report.parent.mkdir(parents=True, exist_ok=True) + args.report.write_text(json.dumps(summary, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + print( + f"validated={summary['count']} passed={summary['passed_count']} " + f"failed={summary['failed_count']} report={args.report}" + ) + if summary["failed_count"]: + raise SystemExit(1) + + +if __name__ == "__main__": + main() diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 00000000..dea93e75 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,6 @@ +fastapi>=0.115,<1 +httpx>=0.27,<1 +python-dotenv>=1.0,<2 +uvicorn[standard]>=0.30,<1 +build123d +python-multipart>=0.0.9,<1 diff --git a/backend/tests/.gitkeep b/backend/tests/.gitkeep new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/backend/tests/.gitkeep @@ -0,0 +1 @@ + diff --git a/backend/vendor/__init__.py b/backend/vendor/__init__.py new file mode 100644 index 00000000..1570bbf7 --- /dev/null +++ b/backend/vendor/__init__.py @@ -0,0 +1 @@ +"""Local third-party-style runtimes kept inside this repository.""" diff --git a/backend/vendor/cdsl_preview_runtime/__init__.py b/backend/vendor/cdsl_preview_runtime/__init__.py new file mode 100644 index 00000000..c86ac8a5 --- /dev/null +++ b/backend/vendor/cdsl_preview_runtime/__init__.py @@ -0,0 +1,5 @@ +"""Local STEP to GLB preview conversion runtime.""" + +from .step_to_glb import step_to_glb + +__all__ = ["step_to_glb"] diff --git a/backend/vendor/cdsl_preview_runtime/step_to_glb.py b/backend/vendor/cdsl_preview_runtime/step_to_glb.py new file mode 100644 index 00000000..dd3f30bb --- /dev/null +++ b/backend/vendor/cdsl_preview_runtime/step_to_glb.py @@ -0,0 +1,145 @@ +from __future__ import annotations + +import json +import math +import struct +from pathlib import Path + +from build123d import import_step + + +def _align_four(data: bytes, fill: bytes = b"\x00") -> bytes: + return data + fill * ((4 - len(data) % 4) % 4) + + +def _normals( + vertices: list[tuple[float, float, float]], + triangles: list[tuple[int, int, int]], +) -> list[tuple[float, float, float]]: + values = [[0.0, 0.0, 0.0] for _ in vertices] + for a, b, c in triangles: + ax, ay, az = vertices[a] + bx, by, bz = vertices[b] + cx, cy, cz = vertices[c] + ux, uy, uz = bx - ax, by - ay, bz - az + vx, vy, vz = cx - ax, cy - ay, cz - az + nx, ny, nz = uy * vz - uz * vy, uz * vx - ux * vz, ux * vy - uy * vx + for index in (a, b, c): + values[index][0] += nx + values[index][1] += ny + values[index][2] += nz + output: list[tuple[float, float, float]] = [] + for x, y, z in values: + length = math.sqrt(x * x + y * y + z * z) or 1.0 + output.append((x / length, y / length, z / length)) + return output + + +def _vector(value: object) -> list[float]: + return [float(value.X), float(value.Y), float(value.Z)] + + +def _normalised(values: list[float]) -> list[float]: + length = math.sqrt(sum(value * value for value in values)) or 1.0 + return [value / length for value in values] + + +def _face_frame(face: object) -> dict[str, object]: + center = _vector(face.center()) + normal = _normalised(_vector(face.normal_at())) + candidate = [1.0, 0.0, 0.0] if abs(normal[0]) < 0.9 else [0.0, 1.0, 0.0] + projection = sum(candidate[index] * normal[index] for index in range(3)) + x_dir = _normalised([candidate[index] - projection * normal[index] for index in range(3)]) + 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], + ] + bbox = face.bounding_box() + return { + "center": center, + "normal": normal, + "surface_type": str(getattr(face, "geom_type", "UNKNOWN")).split(".")[-1].lower(), + "frame": {"origin_mm": center, "normal": normal, "x_dir": x_dir, "y_dir": y_dir}, + "bbox": { + "min": [float(bbox.min.X), float(bbox.min.Y), float(bbox.min.Z)], + "max": [float(bbox.max.X), float(bbox.max.Y), float(bbox.max.Z)], + }, + } + + +def step_to_glb(step_path: Path, glb_path: Path, tolerance: float = 0.15) -> dict[str, object]: + shape = import_step(str(step_path)) + vertices: list[tuple[float, float, float]] = [] + triangles: list[tuple[int, int, int]] = [] + topology_faces: list[dict[str, object]] = [] + for face_index, face in enumerate(shape.faces()): + vectors, raw_triangles = face.tessellate(tolerance) + if not vectors or not raw_triangles: + continue + vertex_offset = len(vertices) + triangle_start = len(triangles) + vertices.extend((float(vector.X), float(vector.Y), float(vector.Z)) for vector in vectors) + triangles.extend( + (vertex_offset + int(a), vertex_offset + int(b), vertex_offset + int(c)) + for a, b, c in raw_triangles + ) + topology_faces.append({ + "id": f"face_{face_index:03d}", + "triangle_start": triangle_start, + "triangle_count": len(raw_triangles), + **_face_frame(face), + }) + if not vertices or not triangles: + raise RuntimeError("STEP tessellation produced no renderable triangles") + + normals = _normals(vertices, triangles) + positions = b"".join(struct.pack(" +/// +import "./.next/types/routes.d.ts"; + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/frontend/next.config.ts b/frontend/next.config.ts new file mode 100644 index 00000000..1fa21509 --- /dev/null +++ b/frontend/next.config.ts @@ -0,0 +1,27 @@ +import type { NextConfig } from "next"; +import path from "node:path"; + +const root = __dirname; + +const nextConfig: NextConfig = { + allowedDevOrigins: ["127.0.0.1", "localhost"], + transpilePackages: ["three", "three-mesh-bvh"], + webpack(config) { + config.resolve = config.resolve || {}; + config.resolve.alias = { + ...(config.resolve.alias || {}), + "@": path.join(root, "src"), + three: path.join(root, "node_modules", "three"), + "three/examples": path.join(root, "node_modules", "three", "examples"), + "three-mesh-bvh": path.join(root, "node_modules", "three-mesh-bvh"), + }; + config.resolve.extensions = [ + ...(config.resolve.extensions || []), + ".js", + ".mjs", + ]; + return config; + }, +}; + +export default nextConfig; diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 00000000..8db38ace --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,5056 @@ +{ + "name": "cdsl-cad-agent-studio", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "cdsl-cad-agent-studio", + "version": "0.1.0", + "dependencies": { + "@ai-sdk/react": "^4.0.40", + "@assistant-ui/react": "0.14.28", + "@assistant-ui/react-ai-sdk": "1.4.0", + "@radix-ui/react-collapsible": "^1.1.20", + "@radix-ui/react-dropdown-menu": "^2.1.24", + "@radix-ui/react-slider": "^1.4.7", + "ai": "7.0.37", + "animejs": "^4.5.0", + "clsx": "^2.1.1", + "lucide-react": "^1.14.0", + "next": "16.2.6", + "react": "19.2.4", + "react-dom": "19.2.4", + "tailwind-merge": "^3.6.0", + "three": "0.160.0", + "three-mesh-bvh": "^0.8.0" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4", + "@types/node": "^20", + "@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" + } + }, + "node_modules/@ai-sdk/gateway": { + "version": "4.0.28", + "resolved": "https://registry.npmmirror.com/@ai-sdk/gateway/-/gateway-4.0.28.tgz", + "integrity": "sha512-ee9TsNO3mkgHDWmTmJ8Fvltr6PFh52zhrV2+FaKJY3F3iu7kWZi5tCRJCR1HVhhlpj6lHniUb28nLQdPHkA/1Q==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "4.0.3", + "@ai-sdk/provider-utils": "5.0.12", + "@vercel/oidc": "3.2.0" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/gateway/node_modules/@ai-sdk/provider": { + "version": "4.0.3", + "resolved": "https://registry.npmmirror.com/@ai-sdk/provider/-/provider-4.0.3.tgz", + "integrity": "sha512-e0CpNWJUY7OxAFAnCZkw+ri9QOHWwTs1tXP42782KFGCU07qt8NiXCrCVowyCB5dP2r5/Uls+g2oPd8kOJn9dw==", + "license": "Apache-2.0", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@ai-sdk/gateway/node_modules/@ai-sdk/provider-utils": { + "version": "5.0.12", + "resolved": "https://registry.npmmirror.com/@ai-sdk/provider-utils/-/provider-utils-5.0.12.tgz", + "integrity": "sha512-bbhlOgHeYwrIGheLkM6fhS8hVger8uFPmcOLg+kxc9EFh7y30XYorWhthlYAgpadO3SJhFZrIcEknN7qEqEVvA==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "4.0.3", + "@standard-schema/spec": "^1.1.0", + "@workflow/serde": "4.1.0", + "eventsource-parser": "^3.0.8" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/mcp": { + "version": "2.0.33", + "resolved": "https://registry.npmmirror.com/@ai-sdk/mcp/-/mcp-2.0.33.tgz", + "integrity": "sha512-fpzMLW1RNpRvHWf9Bj7IKgsBYhpPhpBemPARJzmE3g31WYJtKK+9X+WPDl2M+JQ8qL2b0Ltv8ikbSvXAvBmuJg==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "4.0.7", + "@ai-sdk/provider-utils": "5.0.27", + "pkce-challenge": "^5.0.1" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/provider": { + "version": "4.0.7", + "resolved": "https://registry.npmmirror.com/@ai-sdk/provider/-/provider-4.0.7.tgz", + "integrity": "sha512-6or44XprPzKbr8zkmzosowSE0pxkvJcoojBL+mCZvPUt3kvXp3XSNqeVun9golb1acEfSo6yaEBRT18h2VU+1Q==", + "license": "Apache-2.0", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@ai-sdk/provider-utils": { + "version": "5.0.27", + "resolved": "https://registry.npmmirror.com/@ai-sdk/provider-utils/-/provider-utils-5.0.27.tgz", + "integrity": "sha512-EzAn4pdgG5g0xXtH6lE2zyNmfjDQIDjATkfqzuidEI35g++hh4+07vnjzkT/RmGmIClPZiRj/Q2GMPV2V7mkHw==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "4.0.7", + "@standard-schema/spec": "^1.1.0", + "@workflow/serde": "4.1.0", + "eventsource-parser": "^3.0.8", + "undici": "^7.28.0" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/react": { + "version": "4.0.71", + "resolved": "https://registry.npmmirror.com/@ai-sdk/react/-/react-4.0.71.tgz", + "integrity": "sha512-49xEp2fy8kqiA0imFPO+2ntZgbyOo2G9jgRmpPE0FTcwnM8YJV0o5sITM9nK/aMVAzo0TLB8L7fpmFZ1wbOffg==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/mcp": "2.0.33", + "@ai-sdk/provider": "4.0.7", + "@ai-sdk/provider-utils": "5.0.27", + "ai": "7.0.68", + "swr": "^2.4.1", + "throttleit": "2.1.0" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "react": "^18 || ~19.0.1 || ~19.1.2 || ^19.2.1" + } + }, + "node_modules/@ai-sdk/react/node_modules/@ai-sdk/gateway": { + "version": "4.0.54", + "resolved": "https://registry.npmmirror.com/@ai-sdk/gateway/-/gateway-4.0.54.tgz", + "integrity": "sha512-x4fAXDqCtYzB/M5vsIQLYcyrzpJuaRgcIwDSw+lpTMMbgH19fU3ds75GSlHNLzfx6Z5yL4Z9+EMr0GJcqVy9QA==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "4.0.7", + "@ai-sdk/provider-utils": "5.0.27", + "@vercel/oidc": "3.2.0" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/react/node_modules/ai": { + "version": "7.0.68", + "resolved": "https://registry.npmmirror.com/ai/-/ai-7.0.68.tgz", + "integrity": "sha512-9QuZOT77wzoxxUC0NcueXhCo3HUHA/1pApIJ9VRyE+9/K+3Innkq4kVhrd9aEnwIviJz2Nga063m+UTsPSdOyw==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/gateway": "4.0.54", + "@ai-sdk/provider": "4.0.7", + "@ai-sdk/provider-utils": "5.0.27" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmmirror.com/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@assistant-ui/core": { + "version": "0.2.23", + "resolved": "https://registry.npmmirror.com/@assistant-ui/core/-/core-0.2.23.tgz", + "integrity": "sha512-mD/sWrdH4SF1POxWE0Wn8jrxOSnuGOJfj8j5coDuEtdtoevQT3Z8LzD5mkd2UdyXHIkwFsMb3/vIL1kH8XKyGg==", + "license": "MIT", + "dependencies": { + "assistant-stream": "^0.3.28", + "nanoid": "^6.0.0" + }, + "peerDependencies": { + "@assistant-ui/store": "^0.2.13", + "@assistant-ui/tap": "^0.9.0", + "@types/react": "*", + "assistant-cloud": "^0.1.31", + "react": "^18 || ^19", + "zustand": "^5.0.11" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "assistant-cloud": { + "optional": true + }, + "react": { + "optional": true + }, + "zustand": { + "optional": true + } + } + }, + "node_modules/@assistant-ui/react": { + "version": "0.14.28", + "resolved": "https://registry.npmmirror.com/@assistant-ui/react/-/react-0.14.28.tgz", + "integrity": "sha512-HZ0aQ5Ozq5jvAfD4ZWPs13Ujod9aNbLXMfVx7fTqbCqZE6dB/tVUTsrimIMthGeCXA6jO6Fj0HnZiykBS4jwRA==", + "license": "MIT", + "dependencies": { + "@assistant-ui/core": "^0.2.22", + "@assistant-ui/store": "^0.2.21", + "@assistant-ui/tap": "^0.9.5", + "@radix-ui/primitive": "^1.1.7", + "@radix-ui/react-collection": "^1.1.15", + "@radix-ui/react-compose-refs": "^1.1.5", + "@radix-ui/react-context": "^1.2.2", + "@radix-ui/react-primitive": "^2.1.10", + "@radix-ui/react-use-callback-ref": "^1.1.4", + "@radix-ui/react-use-controllable-state": "^1.2.6", + "@radix-ui/react-use-escape-keydown": "^1.1.5", + "assistant-cloud": "^0.1.36", + "assistant-stream": "^0.3.27", + "nanoid": "^6.0.0", + "radix-ui": "^1.6.7", + "react-textarea-autosize": "^8.5.9", + "safe-content-frame": "^0.0.24", + "zod": "^4.4.3", + "zustand": "^5.0.14" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^18 || ^19", + "react-dom": "^18 || ^19" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@assistant-ui/react-ai-sdk": { + "version": "1.4.0", + "resolved": "https://registry.npmmirror.com/@assistant-ui/react-ai-sdk/-/react-ai-sdk-1.4.0.tgz", + "integrity": "sha512-NQ8Wy0X5kZc86eFM70THyYDUhQhKxvwOvF9K+fG7gtsivycS89ugrTr873iQpO4N+kFSZW92EeC9d07xSGEvcA==", + "license": "MIT", + "dependencies": { + "@ai-sdk/mcp": "^2.0.16", + "@ai-sdk/react": "^4.0.40", + "@assistant-ui/core": "^0.2.22", + "@assistant-ui/store": "^0.2.21", + "ai": "^7.0.37", + "assistant-cloud": "*", + "assistant-stream": "^0.3.27" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^18 || ^19" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@assistant-ui/store": { + "version": "0.2.22", + "resolved": "https://registry.npmmirror.com/@assistant-ui/store/-/store-0.2.22.tgz", + "integrity": "sha512-RdUFLOFJ3ZwIoOZYrRvps3OAODwnWoUAuNfqbROKrj5qtlQsIJRqxUNdLTjcf4AimhkjCwOaFacGHHm9lE4qBw==", + "license": "MIT", + "peer": true, + "dependencies": { + "use-effect-event": "^2.0.3" + }, + "peerDependencies": { + "@assistant-ui/tap": "^0.9.0", + "@types/react": "*", + "react": "^18 || ^19" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@assistant-ui/tap": { + "version": "0.9.13", + "resolved": "https://registry.npmmirror.com/@assistant-ui/tap/-/tap-0.9.13.tgz", + "integrity": "sha512-vNB/9ftdwYZi1wIqaEs8A1qDfbt5ZKBk5+de3wZAoXyuGc8ahKwznWhGjx1XsHuXPM+7e/Gi9iG/ezqrz0xiog==", + "license": "MIT", + "peer": true, + "peerDependencies": { + "@types/react": "*", + "react": "^18 || ^19" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "react": { + "optional": true + } + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@dimforge/rapier3d-compat": { + "version": "0.12.0", + "resolved": "https://registry.npmmirror.com/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz", + "integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmmirror.com/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.8.0", + "resolved": "https://registry.npmmirror.com/@floating-ui/core/-/core-1.8.0.tgz", + "integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.8.0", + "resolved": "https://registry.npmmirror.com/@floating-ui/dom/-/dom-1.8.0.tgz", + "integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.8.0", + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.9", + "resolved": "https://registry.npmmirror.com/@floating-ui/react-dom/-/react-dom-2.1.9.tgz", + "integrity": "sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.8.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.12", + "resolved": "https://registry.npmmirror.com/@floating-ui/utils/-/utils-0.2.12.tgz", + "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", + "license": "MIT" + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmmirror.com/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmmirror.com/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmmirror.com/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmmirror.com/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmmirror.com/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmmirror.com/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmmirror.com/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmmirror.com/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmmirror.com/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmmirror.com/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmmirror.com/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmmirror.com/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmmirror.com/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmmirror.com/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmmirror.com/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmmirror.com/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmmirror.com/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmmirror.com/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmmirror.com/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmmirror.com/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmmirror.com/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmmirror.com/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmmirror.com/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmmirror.com/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmmirror.com/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmmirror.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmmirror.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@next/env": { + "version": "16.2.6", + "resolved": "https://registry.npmmirror.com/@next/env/-/env-16.2.6.tgz", + "integrity": "sha512-gd8HoHN4ufj73WmR3JmVolrpJR47ILK6LouP5xElPglaVxir6e1a7VzvTvDWkOoPXT9rkkTzyCxBu4yeZfZwcw==", + "license": "MIT" + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "16.2.6", + "resolved": "https://registry.npmmirror.com/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.6.tgz", + "integrity": "sha512-ZJGkkcNfYgrrMkqOdZ7zoLa1TOy0qpcMfk/z4Mh/FKUz40gVO+HNQWqmLxf67Z5WB64DRp0dhEbyHfel+6sJUg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "16.2.6", + "resolved": "https://registry.npmmirror.com/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.6.tgz", + "integrity": "sha512-v/YLBHIY132Ced3puBJ7YJKw1lqsCrgcNo2aRJlCEyQrrCeRJlvGlnmxhPxNQI3KE3N1DN5r9TPNPvka3nq5RQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "16.2.6", + "resolved": "https://registry.npmmirror.com/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.6.tgz", + "integrity": "sha512-RPOvqlYBbcQjkz9VQQDZ2T2bARIjXZV1KFlt+V2Mr6SW/e4I9fcKsaA0hdyf2FHoTlsV2xnBd5Y912rP/1Ce6w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "16.2.6", + "resolved": "https://registry.npmmirror.com/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.6.tgz", + "integrity": "sha512-URUTu1+dMkxJsPFgm+OeEvq9wf5sujw0EvgYy80TDGHTSLTnIHeqb0Eu8A3sC95IRgjejQL+kC4mw+4yPxiAXA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "16.2.6", + "resolved": "https://registry.npmmirror.com/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.6.tgz", + "integrity": "sha512-DOj182mPV8G3UkrayLoREM5YEYI+Dk5wv7Ox9xl1fFibAELEsFD0lDPfHIeILlutMMfdyhlzYPELG3peuKaurw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "16.2.6", + "resolved": "https://registry.npmmirror.com/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.6.tgz", + "integrity": "sha512-HKQ5SP/V/ub73UvF7n/zeJlxk2kLmtL7Wzrg4WfmkjmNos5onJ2tKu7yZOPdL18A6Svfn3max29ym+ry7NkK4g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "16.2.6", + "resolved": "https://registry.npmmirror.com/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.6.tgz", + "integrity": "sha512-LZXpTlPyS5v7HhSmnvsLGP3iIYgYOBnc8r8ArlT55sGHV89bR2HlDdBjWQ+PY6SJMmk8TuVGFuxalnP3k/0Dwg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "16.2.6", + "resolved": "https://registry.npmmirror.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.6.tgz", + "integrity": "sha512-F0+4i0h9J6C4eE3EAPWsoCk7UW/dbzOjyzxY0qnDUOYFu6FFmdZ6l97/XdV3/Nz3VYyO7UWjyEJUXkGqcoXfMA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "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", + "integrity": "sha512-Road2bidD0uu/1BGDOWNdPI06g0lIRy6IF9GZcIrDK2KGItfor8IQwQa+yM2ERgHM1MmHxaxpTzk0/Jp42lNfA==", + "license": "MIT" + }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.7", + "resolved": "https://registry.npmmirror.com/@radix-ui/primitive/-/primitive-1.1.7.tgz", + "integrity": "sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-accessible-icon": { + "version": "1.1.15", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-accessible-icon/-/react-accessible-icon-1.1.15.tgz", + "integrity": "sha512-WTQwcAvQf5sOcuUyi90lKPbhwcvQ+j55cjrSmeaN+L2vKU3DooOvlKw2MDeiJ5IkV5N905KW0/fGojKOBhD11A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-visually-hidden": "1.2.11" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-accordion": { + "version": "1.2.20", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-accordion/-/react-accordion-1.2.20.tgz", + "integrity": "sha512-jDhG9FvAEnlhnjrsINbNXcUa4G+L1KqSkJSunkbKEzFRcAb52jvM0PjPxPRvhe1HNc5F5yc0yzzWeeqlH4yBIg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collapsible": "1.1.20", + "@radix-ui/react-collection": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-controllable-state": "1.2.6" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-alert-dialog": { + "version": "1.1.23", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.23.tgz", + "integrity": "sha512-VAYOiQRqj3GPpYJE0I9J+X8Ip05cyVlNdKOFeiGS2Ou1HHGfpl0BxOyZm6nmVDyU+W+NF3/XLzmjHmVGydhwgA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-dialog": "1.1.23", + "@radix-ui/react-primitive": "2.1.10" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-arrow": { + "version": "1.1.15", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-arrow/-/react-arrow-1.1.15.tgz", + "integrity": "sha512-v4zggRcjadnI+ClKDuijlQEW4tw3NoaeHc/PwpKnLoLLKNUG4InLegkstooLcRIUWCs+8L22dGURCVuFfOKfnA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.10" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-aspect-ratio": { + "version": "1.1.15", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-aspect-ratio/-/react-aspect-ratio-1.1.15.tgz", + "integrity": "sha512-fy+dyVR+90nelK8rqIznFlxzx7uPcGbhxH8Nfr2bHb4UfSe+e3hklOC0luK0hDwVwnRX7xTRySpsrQVeW+/oNQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.10" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-avatar": { + "version": "1.2.6", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-avatar/-/react-avatar-1.2.6.tgz", + "integrity": "sha512-4ULOTJ/mqy2hT9GlWa/MFHxHSvH3nJzHnZM1waNsc5Bonv7i70aNenghXmD97S6OJ81ekXONGGt4nT1r0PfEdA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-is-hydrated": "0.1.3", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-checkbox": { + "version": "1.3.11", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-checkbox/-/react-checkbox-1.3.11.tgz", + "integrity": "sha512-Gnptr9pDDQxD3hgq2dtPbtrp/c2qH1mBwIzw3X/ivrMb2e1t0jMTi606fVEqFPaQR1ggXIVQWKj3P2WW9v7zGQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-size": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collapsible": { + "version": "1.1.20", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-collapsible/-/react-collapsible-1.1.20.tgz", + "integrity": "sha512-mcGesGplBnzN2sbvJETzpCNfSMyPnb29q1GRLU+Ib7bJrpIG2ywmRoh2V5VbA2uNvKikKUlVbAPks7JDjz4A8Q==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection": { + "version": "1.1.15", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-collection/-/react-collection-1.1.15.tgz", + "integrity": "sha512-9W+B9NPF0NaaPh/1NJd3+KqsnlLqU9H7T2rvww+fp+T/evVXdNAyYcnfRQZFOjkR1ajQp3yORlqnI8soawLvNA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-slot": "1.3.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.5", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.5.tgz", + "integrity": "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context": { + "version": "1.2.2", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-context/-/react-context-1.2.2.tgz", + "integrity": "sha512-RHCUGwKHDr0hDGg4X7ma4JG4/+12qxw8rkh5QKdDldlCvtja6nUx1Ef/8HVrJze81lEsgLQlqjzjGNHantgnQA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context-menu": { + "version": "2.3.7", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-context-menu/-/react-context-menu-2.3.7.tgz", + "integrity": "sha512-CtXP35dxaB5T3zXSd+E3uHe/QpXcpYnZmxp6OaIbfthtfW4wyb77M23BG+bwIJDtsMwEP/YssdsmNyZu7jhWew==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-menu": "2.1.24", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-controllable-state": "1.2.6" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog": { + "version": "1.1.23", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-dialog/-/react-dialog-1.1.23.tgz", + "integrity": "sha512-Ksw4WeROkO4rC9k/onilX/Ao2Cr1ku1unMNH+XSCcP4jSXYu7HDsg9n4ojMjVb22XpYjAQ9qfrFlVbru1vXDUA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-focus-guards": "1.1.6", + "@radix-ui/react-focus-scope": "1.1.16", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-portal": "1.1.17", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-slot": "1.3.3", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-layout-effect": "1.1.4", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-direction": { + "version": "1.1.4", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-direction/-/react-direction-1.1.4.tgz", + "integrity": "sha512-5pzg4FGQNpExhnhT2zlrP1wZFaYCd1K0nYWoFAdcYoYK868IEigqMX3B3f8yIoRlAhAeDWciLI6ZdCKHF9P4Vg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.19", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.19.tgz", + "integrity": "sha512-8g4pfOL9HoKKLWGiypT+dphVqjFfmcXO5GBnhsG6zI+lxAx/8feQpr+1LSN8Re3hiZ+XkLNS4O9ztK11/LzQ6w==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-effect-event": "0.0.5" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dropdown-menu": { + "version": "2.1.24", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.24.tgz", + "integrity": "sha512-geq8l2rJkxvkXsT9RMgtUE3P8pITFpTsvYpbySi1IH4fZEABD/Gp85myayFgxk0ktljGMJnCbeFkyTusvSvv7g==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-menu": "2.1.24", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-controllable-state": "1.2.6" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.6", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.6.tgz", + "integrity": "sha512-RNOJjfZMTyBM6xYmV3IVGXkPjIhcBAuv48POevAXwrGJhkWZ9p1rFoIS1JFooPuT193AZmRsCPhpoVJxx6OPoQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.16", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.16.tgz", + "integrity": "sha512-wmRZ2WWLvmt6KHy2rNPOdPUjwq5xOHY02+m+udwJTn0aNIox/rkskAvJTyTLGhPK6KgrUjlJUJpgmx/+wFiFIQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-form": { + "version": "0.1.16", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-form/-/react-form-0.1.16.tgz", + "integrity": "sha512-Q4TLEn2A7TAypxwmd6R9EwrlXDvkfYSDMrq9/887AXAGh+G1rH+kYJKSTv+Si9Y0JPKTwKYv6PviAJosysNimA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-label": "2.1.15", + "@radix-ui/react-primitive": "2.1.10" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-hover-card": { + "version": "1.1.23", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-hover-card/-/react-hover-card-1.1.23.tgz", + "integrity": "sha512-H8qONfZd3ltrU3+jHCIgITbWo6e1iTKvP9DHdrvYbX48ooRM5FjEDTn16AMwdfuOGkWdZEhpl3PLL/Wk/AnHDQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-popper": "1.3.7", + "@radix-ui/react-portal": "1.1.17", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-controllable-state": "1.2.6" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.4", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-id/-/react-id-1.1.4.tgz", + "integrity": "sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-label": { + "version": "2.1.15", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-label/-/react-label-2.1.15.tgz", + "integrity": "sha512-o/rdYEwZTTo5tjknnPeyQFU45kUC4i/XyeDPP+HGyi6XqpOP6Zf5Ya5vh/Yfe9Id5JiuWnnAx2XqIeD3UYZt0g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.10" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu": { + "version": "2.1.24", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-menu/-/react-menu-2.1.24.tgz", + "integrity": "sha512-uW7RVuU6Lp/ZtfeY4b3kL32zccgEWvPv1+cf17ubYzHa9cL8AHokmk36cG/XEiH/smbQvumnieXX9j/e9RqJWA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collection": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-focus-guards": "1.1.6", + "@radix-ui/react-focus-scope": "1.1.16", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-popper": "1.3.7", + "@radix-ui/react-portal": "1.1.17", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-roving-focus": "1.1.19", + "@radix-ui/react-slot": "1.3.3", + "@radix-ui/react-use-callback-ref": "1.1.4", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menubar": { + "version": "1.1.24", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-menubar/-/react-menubar-1.1.24.tgz", + "integrity": "sha512-eeVs0vf7cuqXaM0qLQCPcufImiJNVBXdJDLu7ZGYl2732UH23Qat/foNGrr6vYV3/DdTsBqASoggUFgH14OcZA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collection": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-menu": "2.1.24", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-roving-focus": "1.1.19", + "@radix-ui/react-use-controllable-state": "1.2.6" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-navigation-menu": { + "version": "1.2.22", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-navigation-menu/-/react-navigation-menu-1.2.22.tgz", + "integrity": "sha512-ou7iLEJ+yrhQndkkA4U21XIdS/CS45F4iXIkTZcb6/Ne9EMsOuDudVmCwmDnfFZZ+y1FZqXRNSIgBy+YMvZVZg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collection": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-layout-effect": "1.1.4", + "@radix-ui/react-use-previous": "1.1.4", + "@radix-ui/react-visually-hidden": "1.2.11" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-one-time-password-field": { + "version": "0.1.16", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-one-time-password-field/-/react-one-time-password-field-0.1.16.tgz", + "integrity": "sha512-Tj9P6ntAJEw52oq/F0AGknXR4XncxEt7XU47O3xJQOiWfLzEy3d9gtgKfvjSzGxzHkfL+VzvxGu2KTFsloJqXw==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.3", + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collection": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-roving-focus": "1.1.19", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-effect-event": "0.0.5", + "@radix-ui/react-use-is-hydrated": "0.1.3", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-password-toggle-field": { + "version": "0.1.11", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-password-toggle-field/-/react-password-toggle-field-0.1.11.tgz", + "integrity": "sha512-4gvFnmDXu3dgj21CqsufzIameRvlRd4SBqaWhcrlrNhRo0Y5i/49AmRJYe1fdAM3G2VNBbmin4b0D6cdQocwgw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-effect-event": "0.0.5", + "@radix-ui/react-use-is-hydrated": "0.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover": { + "version": "1.1.23", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-popover/-/react-popover-1.1.23.tgz", + "integrity": "sha512-mw58MrBlyHWFisTOYignD0vf/3gdcgAR+9of1s9G/38CbFiUwH1nCDkc0AUM9IrXFgN5Ue8n45j9WCgyM1sbiQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-focus-guards": "1.1.6", + "@radix-ui/react-focus-scope": "1.1.16", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-popper": "1.3.7", + "@radix-ui/react-portal": "1.1.17", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-slot": "1.3.3", + "@radix-ui/react-use-controllable-state": "1.2.6", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper": { + "version": "1.3.7", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-popper/-/react-popper-1.3.7.tgz", + "integrity": "sha512-UsJrrd7w4wuKKTdvd/DNERVlwSlUcyXzjhyDwBk+3aPOsCjOY6ZSbxuw8E6lZTjjfP8Cpd0J8VVkrYUWyGYXyg==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-layout-effect": "1.1.4", + "@radix-ui/react-use-rect": "1.1.4", + "@radix-ui/react-use-size": "1.1.4", + "@radix-ui/rect": "1.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal": { + "version": "1.1.17", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-portal/-/react-portal-1.1.17.tgz", + "integrity": "sha512-vKQLcWypUnwZVvfV7UkGahH2g6ySe8M8R+zYBwPrv5byZ9QAW6cQVvNKo7GgmD+p8aYb6D9JBuvy8/WhOno2wQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.10", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-presence/-/react-presence-1.1.10.tgz", + "integrity": "sha512-3wyzCQ6+ubRA+D4uv9m95JYLXxmOHp05qjrkjeA7uKHHtjpPggQzc6DAb0URl7j67oR0K2foO4ip27TiX037Bw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.10", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-primitive/-/react-primitive-2.1.10.tgz", + "integrity": "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.3.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-progress": { + "version": "1.1.16", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-progress/-/react-progress-1.1.16.tgz", + "integrity": "sha512-5XnomAsoZZCY+KNTxbIghpGqPruZvKFNlvcAljVAOdDRDsH4/OZQxhtwo5wdtoDM5R6MhJBb2sPnDuRFep3lzg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-primitive": "2.1.10" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-radio-group": { + "version": "1.4.7", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-radio-group/-/react-radio-group-1.4.7.tgz", + "integrity": "sha512-cgYFEkntCxppHZgtSZ+7vh0wbZQ+IC7PPMw8DSnRG27B6kDd32/Zw0OJt7dGDigCoprMuWHjg2PvUn3PYvPFoQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-roving-focus": "1.1.19", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-size": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.19", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.19.tgz", + "integrity": "sha512-V9jI6hDjT7l3jsCQD9bLNvDLM3tH/gdbOTp7Tefp3hbbgCGQoK7tUvrWiRlcoBHIZ809ElXwNQwVo0B98LuTXQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collection": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-is-hydrated": "0.1.3", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-scroll-area": { + "version": "1.2.18", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.18.tgz", + "integrity": "sha512-Zn5Cd171wxsO3Dfg8HaW6RifTb9CYTKQJHs/G4+LN1GfmJpaQMZQyQxMprVPHpaz7QY4l9BxK2JwQuzHsXC8nA==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.3", + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select": { + "version": "2.3.7", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-select/-/react-select-2.3.7.tgz", + "integrity": "sha512-WFGImkmbzcfxeIwq/+4HvRN0pizBwbwQUED4I13ezQsDdfl38ZntN6TmR8XaSzPBqoCToe8rF75j6NPNDSzhbg==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.3", + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collection": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-focus-guards": "1.1.6", + "@radix-ui/react-focus-scope": "1.1.16", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-popper": "1.3.7", + "@radix-ui/react-portal": "1.1.17", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-slot": "1.3.3", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-layout-effect": "1.1.4", + "@radix-ui/react-use-previous": "1.1.4", + "@radix-ui/react-visually-hidden": "1.2.11", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-separator": { + "version": "1.1.15", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-separator/-/react-separator-1.1.15.tgz", + "integrity": "sha512-jOLO4lssEzWpoDu7G+Ze4VjwMRUBt291pnZD0gmalREZipnTX3wadQo7Fy48GCTfe14/YRN6rw/rOJqrE85Wxw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.10" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slider": { + "version": "1.4.7", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-slider/-/react-slider-1.4.7.tgz", + "integrity": "sha512-mTSLf1GC/C0moWjTbvCM6Qn/gBjvlFt1azuWF2v7MN5C3Zq2U2J2lN3ZEYkpujuOU5Ro7A28wkviSxaKnG0BYg==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.3", + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collection": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-layout-effect": "1.1.4", + "@radix-ui/react-use-previous": "1.1.4", + "@radix-ui/react-use-size": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.3.3", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-slot/-/react-slot-1.3.3.tgz", + "integrity": "sha512-qx7oqnYbxnK9kYI9m317qmFmEgo6ywqWvbTogdj7cL9p3/yx4M48p7Rnw5z3H890cL/ow/EeWJsuTykeZVXP5Q==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.5" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-switch": { + "version": "1.3.7", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-switch/-/react-switch-1.3.7.tgz", + "integrity": "sha512-48tB/4dn2UVLBCYhTu9AuR63IHl73l/qLbLgxd86noTUor4/K4LFDAcYjK+isP5313qxaFpjPVogE7+Y0/V3Kw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-size": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs": { + "version": "1.1.21", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-tabs/-/react-tabs-1.1.21.tgz", + "integrity": "sha512-UKxJlZid7FVtsk/WTxj4i4uSEgj2Au+KBbS7SQyTlzMhhn+86Cz3tISZdTa87bfEfcuvZezf2ZsxD4xuEKtkog==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-roving-focus": "1.1.19", + "@radix-ui/react-use-controllable-state": "1.2.6" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toast": { + "version": "1.2.23", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-toast/-/react-toast-1.2.23.tgz", + "integrity": "sha512-ofhyAsYaocRGOs/n0XWdUOSVzEAG6BfrMVM8z0c0kLEWY38w/0WuMFPTJP/HVaZPYkMvHZoKIIhNcjbTCBILPg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collection": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-portal": "1.1.17", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-layout-effect": "1.1.4", + "@radix-ui/react-visually-hidden": "1.2.11" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toggle": { + "version": "1.1.18", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-toggle/-/react-toggle-1.1.18.tgz", + "integrity": "sha512-7lonPlKfSacd20GlOBx2ltuVKz9oqWYZz+oMQyOltw6t1y2nyftj2ZmwwUHYn49kqfDWcp8dNZm5NgV+5Z+mug==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-controllable-state": "1.2.6" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toggle-group": { + "version": "1.1.19", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-toggle-group/-/react-toggle-group-1.1.19.tgz", + "integrity": "sha512-OtnwuSVjd1Ofi+AdnvhsjQdyuhCDwYs1w9RyB5BN/OavXOVQo42SYqQjwUnbPnaiPFBpQ9aX70dWeee+v2oBLA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-roving-focus": "1.1.19", + "@radix-ui/react-toggle": "1.1.18", + "@radix-ui/react-use-controllable-state": "1.2.6" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toolbar": { + "version": "1.1.19", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-toolbar/-/react-toolbar-1.1.19.tgz", + "integrity": "sha512-Ph0IvtYw4VB12ZnZg+YtrGs8yJQsnizwo/zu0R4Y/nWugtJzA7Pg1eWeuDR9+LSqn+xjamss+UOSOJJJ4gx8jw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-roving-focus": "1.1.19", + "@radix-ui/react-separator": "1.1.15", + "@radix-ui/react-toggle-group": "1.1.19" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip": { + "version": "1.2.16", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-tooltip/-/react-tooltip-1.2.16.tgz", + "integrity": "sha512-6EamKFRRnlpdadndbZ6LMwycfwkwPte1B42hs6QA0gYhjaOKqW4PZ4pjaW9UrlDX5eVt/OjncE7BFTPL5nmZhg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-popper": "1.3.7", + "@radix-ui/react-portal": "1.1.17", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-slot": "1.3.3", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-layout-effect": "1.1.4", + "@radix-ui/react-visually-hidden": "1.2.11" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.4", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.4.tgz", + "integrity": "sha512-R6OUY2e2fA6Yn6s+VSx5KBV6Nx8LQEhu+cz7LCej18rQ1HLyg9PSC9jP/ZNx0o6FAIK9c0F1kHylzSxKsdlkrQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.6", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.6.tgz", + "integrity": "sha512-uEQJGT97ZA/TgP/Hydw47lHu+/vQj6z/0jA+WeTbK1o9Rx45GImjpD0tc3W5ad3D6XTSR6e1yEO0FvGq6WQfVQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-use-effect-event": "0.0.5", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.5", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.5.tgz", + "integrity": "sha512-7cshFL8HGS/7HEiHH+9kL9HBwp2sa9yX18Knwek6KYWmXwM7pegMgta2AXMQKI+rq3JnfSj9x8wYqFMTdG1Jgg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.1.5", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.5.tgz", + "integrity": "sha512-ge3ipobwSXTj4JyVtswQ7qZj0ZHdtbGuOno/LrgAAeSxtsJ6Vs4Gz5IkPH2bmqpjcLUFoqGhA/mueuIf63UXlA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-is-hydrated": { + "version": "0.1.3", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-use-is-hydrated/-/react-use-is-hydrated-0.1.3.tgz", + "integrity": "sha512-umO/aJ+82CpOnhDZUTbILCQf7kU/g0iv+oGs/Q8jw7IkhWBzaEP4sA268PhFAJTFetbwp3ICc6ktpI4TqtxcIw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.4", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.4.tgz", + "integrity": "sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-previous": { + "version": "1.1.4", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-use-previous/-/react-use-previous-1.1.4.tgz", + "integrity": "sha512-XoSLhbRbqxFtgJoi2fNHA3C6pDlY34x508vUpUGoFZfvePfHXHbE1lC4FYFMnJWgiCRroSTw6fOsXQoVS9RwZg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-rect": { + "version": "1.1.4", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-use-rect/-/react-use-rect-1.1.4.tgz", + "integrity": "sha512-cSOCh6JlkmfjLyNcLiu2nB4v+nm+dkZ+Q5KHWk/soo4U7ZLiEQFKHK9/YmtBHjfCEaU43IBKQOc4/uJmCaiCTQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/rect": "1.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-size": { + "version": "1.1.4", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-use-size/-/react-use-size-1.1.4.tgz", + "integrity": "sha512-D3anSY15EJoxrihpsXI6SMrmmonnQtR2ni7arO+Lfdg3O95b9hNXxONk8jA5C8ANdF/h5HMAxejgs8PWJ6rlhw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-visually-hidden": { + "version": "1.2.11", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.11.tgz", + "integrity": "sha512-NFS86RYYZb4/exihaESBGOpMJFz8MGLAfu3mOBSGByVnVPC9JPASfYubxd/8KbkQK0sYAv8lVQDEQukDX/qXvQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.10" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/rect": { + "version": "1.1.3", + "resolved": "https://registry.npmmirror.com/@radix-ui/rect/-/rect-1.1.3.tgz", + "integrity": "sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw==", + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@swc/helpers": { + "version": "0.5.15", + "resolved": "https://registry.npmmirror.com/@swc/helpers/-/helpers-0.5.15.tgz", + "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.3", + "resolved": "https://registry.npmmirror.com/@tailwindcss/node/-/node-4.3.3.tgz", + "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.24.1", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.3", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide/-/oxide-4.3.3.tgz", + "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-x64": "4.3.3", + "@tailwindcss/oxide-freebsd-x64": "4.3.3", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-x64-musl": "4.3.3", + "@tailwindcss/oxide-wasm32-wasi": "4.3.3", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", + "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", + "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", + "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", + "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.3", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", + "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", + "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", + "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", + "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", + "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.3", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", + "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", + "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", + "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/postcss": { + "version": "4.3.3", + "resolved": "https://registry.npmmirror.com/@tailwindcss/postcss/-/postcss-4.3.3.tgz", + "integrity": "sha512-JTSZZGQi1AyKirbLN3azmjVzef92tcX7h+iSqPdaeStyFpGpDlKvvpxeOE8njhbUanbRwr3z8DyzhICWnMtQeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "@tailwindcss/node": "4.3.3", + "@tailwindcss/oxide": "4.3.3", + "postcss": "^8.5.16", + "tailwindcss": "4.3.3" + } + }, + "node_modules/@tweenjs/tween.js": { + "version": "23.1.3", + "resolved": "https://registry.npmmirror.com/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", + "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.43", + "resolved": "https://registry.npmmirror.com/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.18", + "resolved": "https://registry.npmmirror.com/@types/react/-/react-19.2.18.tgz", + "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", + "devOptional": true, + "license": "MIT", + "peer": true, + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmmirror.com/@types/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==", + "devOptional": true, + "license": "MIT", + "peer": true, + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@types/stats.js": { + "version": "0.17.4", + "resolved": "https://registry.npmmirror.com/@types/stats.js/-/stats.js-0.17.4.tgz", + "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@types/three": { + "version": "0.185.4", + "resolved": "https://registry.npmmirror.com/@types/three/-/three-0.185.4.tgz", + "integrity": "sha512-gAsBIC07NIFrxjbf7tH2t71c38uulFfk/RFoC7FNBSjMRAQ8J1x/RBvusX0N5PJouaYFJawXQqfCQ0RKUx/1nA==", + "devOptional": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@dimforge/rapier3d-compat": "~0.12.0", + "@tweenjs/tween.js": "~23.1.3", + "@types/stats.js": "*", + "@types/webxr": ">=0.5.17", + "fflate": "~0.8.2", + "meshoptimizer": "~1.1.1" + } + }, + "node_modules/@types/webxr": { + "version": "0.5.24", + "resolved": "https://registry.npmmirror.com/@types/webxr/-/webxr-0.5.24.tgz", + "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@vercel/oidc": { + "version": "3.2.0", + "resolved": "https://registry.npmmirror.com/@vercel/oidc/-/oidc-3.2.0.tgz", + "integrity": "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==", + "license": "Apache-2.0", + "engines": { + "node": ">= 20" + } + }, + "node_modules/@workflow/serde": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/@workflow/serde/-/serde-4.1.0.tgz", + "integrity": "sha512-pav4F2BoirECWR7Nf1TKt+2eETcBj7jj4cBefQ8VXQCA6NPkaKeLfj/zMgi+3zYV5ZIBT4GuUiphsj0/b9hPQQ==", + "license": "Apache-2.0" + }, + "node_modules/ai": { + "version": "7.0.37", + "resolved": "https://registry.npmmirror.com/ai/-/ai-7.0.37.tgz", + "integrity": "sha512-stF+SEQJgKY3Qfe3FwNzqUrehHviOp2l7LemoI8YMOa0Zk5PxKsRNgUk3cHXz0RAue9RRyCAis2fz6LSCP6EKw==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/gateway": "4.0.28", + "@ai-sdk/provider": "4.0.3", + "@ai-sdk/provider-utils": "5.0.12" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/ai/node_modules/@ai-sdk/provider": { + "version": "4.0.3", + "resolved": "https://registry.npmmirror.com/@ai-sdk/provider/-/provider-4.0.3.tgz", + "integrity": "sha512-e0CpNWJUY7OxAFAnCZkw+ri9QOHWwTs1tXP42782KFGCU07qt8NiXCrCVowyCB5dP2r5/Uls+g2oPd8kOJn9dw==", + "license": "Apache-2.0", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/ai/node_modules/@ai-sdk/provider-utils": { + "version": "5.0.12", + "resolved": "https://registry.npmmirror.com/@ai-sdk/provider-utils/-/provider-utils-5.0.12.tgz", + "integrity": "sha512-bbhlOgHeYwrIGheLkM6fhS8hVger8uFPmcOLg+kxc9EFh7y30XYorWhthlYAgpadO3SJhFZrIcEknN7qEqEVvA==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "4.0.3", + "@standard-schema/spec": "^1.1.0", + "@workflow/serde": "4.1.0", + "eventsource-parser": "^3.0.8" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/animejs": { + "version": "4.5.0", + "resolved": "https://registry.npmmirror.com/animejs/-/animejs-4.5.0.tgz", + "integrity": "sha512-NQimYX+lz8WaXonGS9zVVoviCIAjONeJayxacUditaivYLqyXgGjFKjuyl0aUUhFKm5MriX9ty1TGovNzoJQWA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/juliangarnier" + }, + "peerDependencies": { + "@types/three": ">=0.150.0", + "three": ">=0.150.0" + }, + "peerDependenciesMeta": { + "@types/three": { + "optional": true + }, + "three": { + "optional": true + } + } + }, + "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", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/assistant-cloud": { + "version": "0.1.41", + "resolved": "https://registry.npmmirror.com/assistant-cloud/-/assistant-cloud-0.1.41.tgz", + "integrity": "sha512-lrH9USOoNaAWAAbujeHa/PEiWoqNQHjIPIAeeA3y3GUXOdlmTjIyR/7GbEZBKbHhm1Lyt7aAYKvdxHFkhuEbJw==", + "license": "MIT", + "dependencies": { + "assistant-stream": "^0.3.38" + } + }, + "node_modules/assistant-stream": { + "version": "0.3.38", + "resolved": "https://registry.npmmirror.com/assistant-stream/-/assistant-stream-0.3.38.tgz", + "integrity": "sha512-WASh+97pCN1+Q0pRS8m1fZ56eqWYmDucGs5uLTfsb/Iel69K+GAIntVDn2HTaaOBDyAGs2QBizT7d7lcUcllvA==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "nanoid": "^6.0.1", + "secure-json-parse": "^4.1.0" + }, + "peerDependencies": { + "ioredis": "^5.10.1 || ^6.0.0", + "redis": "^5.12.1" + }, + "peerDependenciesMeta": { + "ioredis": { + "optional": true + }, + "redis": { + "optional": true + } + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.15", + "resolved": "https://registry.npmmirror.com/baseline-browser-mapping/-/baseline-browser-mapping-2.11.15.tgz", + "integrity": "sha512-FwMjJJ7HnyZpWe+oWxegG0fezZyBZUagI5LZEoO3GCbtbKNwRfMH9Ue5d5v01PNePBy1QSfPSDTTeVL0Hb9EzA==", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001809", + "resolved": "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001809.tgz", + "integrity": "sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "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", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "devOptional": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/detect-node-es/-/detect-node-es-1.1.0.tgz", + "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", + "integrity": "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@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", + "integrity": "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmmirror.com/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/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", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmmirror.com/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmmirror.com/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/json-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmmirror.com/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", + "license": "(AFL-2.1 OR BSD-3-Clause)" + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lucide-react": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lucide-react/-/lucide-react-1.32.0.tgz", + "integrity": "sha512-txX56hMFnRxPi1f9/nH69YN8uvAO6a7Y1KSWKjCDAtdD9+soEgmWuCt6iRm1pkxUZo2+YntSdsE1L6bIuKoY8Q==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/meshoptimizer": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/meshoptimizer/-/meshoptimizer-1.1.1.tgz", + "integrity": "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==", + "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", + "integrity": "sha512-3wVS3i51pE2pi1k5FFL/95BGfVS0kSsvDVuGXHOtxox/TywUmtgq+3qiTOTbs9J7KfHaXPiN171k/A6dBnaXFw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.js" + }, + "engines": { + "node": "^22 || ^24 || >=26" + } + }, + "node_modules/next": { + "version": "16.2.6", + "resolved": "https://registry.npmmirror.com/next/-/next-16.2.6.tgz", + "integrity": "sha512-qOVgKJg1+At15NpeUP+eJgCHvTCgXsogweq87Ri/Ix7PkqQHg4sdaXmSFqKlgaIXE4kW0g25LE68W87UANlHtw==", + "license": "MIT", + "dependencies": { + "@next/env": "16.2.6", + "@swc/helpers": "0.5.15", + "baseline-browser-mapping": "^2.9.19", + "caniuse-lite": "^1.0.30001579", + "postcss": "8.4.31", + "styled-jsx": "5.1.6" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "node": ">=20.9.0" + }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "16.2.6", + "@next/swc-darwin-x64": "16.2.6", + "@next/swc-linux-arm64-gnu": "16.2.6", + "@next/swc-linux-arm64-musl": "16.2.6", + "@next/swc-linux-x64-gnu": "16.2.6", + "@next/swc-linux-x64-musl": "16.2.6", + "@next/swc-win32-arm64-msvc": "16.2.6", + "@next/swc-win32-x64-msvc": "16.2.6", + "sharp": "^0.34.5" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0", + "@playwright/test": "^1.51.1", + "babel-plugin-react-compiler": "*", + "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "sass": "^1.3.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@playwright/test": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "sass": { + "optional": true + } + } + }, + "node_modules/next/node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/next/node_modules/postcss": { + "version": "8.4.31", + "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.6", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmmirror.com/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss/node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/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", + "integrity": "sha512-QBdhh1arIEUvPC0dQ5+nwWAxt7+N+oP/9jPwjJkGFoSk/sqxg32gJtSXGtFh8frAIcS6oC9cx2Q+7KYCQLOAeA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-accessible-icon": "1.1.15", + "@radix-ui/react-accordion": "1.2.20", + "@radix-ui/react-alert-dialog": "1.1.23", + "@radix-ui/react-arrow": "1.1.15", + "@radix-ui/react-aspect-ratio": "1.1.15", + "@radix-ui/react-avatar": "1.2.6", + "@radix-ui/react-checkbox": "1.3.11", + "@radix-ui/react-collapsible": "1.1.20", + "@radix-ui/react-collection": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-context-menu": "2.3.7", + "@radix-ui/react-dialog": "1.1.23", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-dropdown-menu": "2.1.24", + "@radix-ui/react-focus-guards": "1.1.6", + "@radix-ui/react-focus-scope": "1.1.16", + "@radix-ui/react-form": "0.1.16", + "@radix-ui/react-hover-card": "1.1.23", + "@radix-ui/react-label": "2.1.15", + "@radix-ui/react-menu": "2.1.24", + "@radix-ui/react-menubar": "1.1.24", + "@radix-ui/react-navigation-menu": "1.2.22", + "@radix-ui/react-one-time-password-field": "0.1.16", + "@radix-ui/react-password-toggle-field": "0.1.11", + "@radix-ui/react-popover": "1.1.23", + "@radix-ui/react-popper": "1.3.7", + "@radix-ui/react-portal": "1.1.17", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-progress": "1.1.16", + "@radix-ui/react-radio-group": "1.4.7", + "@radix-ui/react-roving-focus": "1.1.19", + "@radix-ui/react-scroll-area": "1.2.18", + "@radix-ui/react-select": "2.3.7", + "@radix-ui/react-separator": "1.1.15", + "@radix-ui/react-slider": "1.4.7", + "@radix-ui/react-slot": "1.3.3", + "@radix-ui/react-switch": "1.3.7", + "@radix-ui/react-tabs": "1.1.21", + "@radix-ui/react-toast": "1.2.23", + "@radix-ui/react-toggle": "1.1.18", + "@radix-ui/react-toggle-group": "1.1.19", + "@radix-ui/react-toolbar": "1.1.19", + "@radix-ui/react-tooltip": "1.2.16", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-effect-event": "0.0.5", + "@radix-ui/react-use-escape-keydown": "1.1.5", + "@radix-ui/react-use-is-hydrated": "0.1.3", + "@radix-ui/react-use-layout-effect": "1.1.4", + "@radix-ui/react-use-size": "1.1.4", + "@radix-ui/react-visually-hidden": "1.2.11" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/react": { + "version": "19.2.4", + "resolved": "https://registry.npmmirror.com/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmmirror.com/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.4" + } + }, + "node_modules/react-remove-scroll": { + "version": "2.7.2", + "resolved": "https://registry.npmmirror.com/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", + "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==", + "license": "MIT", + "dependencies": { + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.8", + "resolved": "https://registry.npmmirror.com/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "license": "MIT", + "dependencies": { + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-style-singleton": { + "version": "2.2.3", + "resolved": "https://registry.npmmirror.com/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "license": "MIT", + "dependencies": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-textarea-autosize": { + "version": "8.5.9", + "resolved": "https://registry.npmmirror.com/react-textarea-autosize/-/react-textarea-autosize-8.5.9.tgz", + "integrity": "sha512-U1DGlIQN5AwgjTyOEnI1oCcMuEr1pv1qOtklB2l4nyMGbHzWrI0eFsYK0zos2YWqAolJyG0IWJaqWmWj5ETh0A==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.20.13", + "use-composed-ref": "^1.3.0", + "use-latest": "^1.2.1" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/safe-content-frame": { + "version": "0.0.24", + "resolved": "https://registry.npmmirror.com/safe-content-frame/-/safe-content-frame-0.0.24.tgz", + "integrity": "sha512-s4Ko3A16I62rfLYnCdfPNxh7sVXQN3vc4jQyuCdWI6Q9h/5JzeELRH0VL4dP6MPJxFmNH5kHMfpgbLGPgckUjA==", + "license": "MIT" + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmmirror.com/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/secure-json-parse": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/secure-json-parse/-/secure-json-parse-4.1.0.tgz", + "integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmmirror.com/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmmirror.com/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "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", + "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==", + "license": "MIT", + "dependencies": { + "client-only": "0.0.1" + }, + "engines": { + "node": ">= 12.0.0" + }, + "peerDependencies": { + "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/swr": { + "version": "2.5.1", + "resolved": "https://registry.npmmirror.com/swr/-/swr-2.5.1.tgz", + "integrity": "sha512-BRw55e8r0B7SpDN20CAzoQAHl7y1yP7/Zt7oqUjMv0vSt2u2Xnkm88Ws+VypbV9BXHQVuSuyVq7zMjO16wSExw==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/tailwind-merge": { + "version": "3.6.0", + "resolved": "https://registry.npmmirror.com/tailwind-merge/-/tailwind-merge-3.6.0.tgz", + "integrity": "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.3", + "resolved": "https://registry.npmmirror.com/tailwindcss/-/tailwindcss-4.3.3.tgz", + "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmmirror.com/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/three": { + "version": "0.160.0", + "resolved": "https://registry.npmmirror.com/three/-/three-0.160.0.tgz", + "integrity": "sha512-DLU8lc0zNIPkM7rH5/e1Ks1Z8tWCGRq6g8mPowdDJpw1CFBJMU7UoJjC6PefXW7z//SSl0b2+GCw14LB+uDhng==", + "license": "MIT", + "peer": true + }, + "node_modules/three-mesh-bvh": { + "version": "0.8.3", + "resolved": "https://registry.npmmirror.com/three-mesh-bvh/-/three-mesh-bvh-0.8.3.tgz", + "integrity": "sha512-4G5lBaF+g2auKX3P0yqx+MJC6oVt6sB5k+CchS6Ob0qvH0YIhuUk1eYr7ktsIpY+albCqE80/FVQGV190PmiAg==", + "license": "MIT", + "peerDependencies": { + "three": ">= 0.159.0" + } + }, + "node_modules/throttleit": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/throttleit/-/throttleit-2.1.0.tgz", + "integrity": "sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tsx": { + "version": "4.23.12", + "resolved": "https://registry.npmmirror.com/tsx/-/tsx-4.23.12.tgz", + "integrity": "sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "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", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici": { + "version": "7.29.0", + "resolved": "https://registry.npmmirror.com/undici/-/undici-7.29.0.tgz", + "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/use-callback-ref": { + "version": "1.3.3", + "resolved": "https://registry.npmmirror.com/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-composed-ref": { + "version": "1.4.0", + "resolved": "https://registry.npmmirror.com/use-composed-ref/-/use-composed-ref-1.4.0.tgz", + "integrity": "sha512-djviaxuOOh7wkj0paeO1Q/4wMZ8Zrnag5H6yBvzN7AKKe8beOaED9SF5/ByLqsku8NP4zQqsvM2u3ew/tJK8/w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-effect-event": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/use-effect-event/-/use-effect-event-2.0.3.tgz", + "integrity": "sha512-fz1en+z3fYXCXx3nMB8hXDMuygBltifNKZq29zDx+xNJ+1vEs6oJlYd9sK31vxJ0YI534VUsHEBY0k2BATsmBQ==", + "license": "MIT", + "peerDependencies": { + "react": "^18.3 || ^19.0.0-0" + } + }, + "node_modules/use-isomorphic-layout-effect": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.2.1.tgz", + "integrity": "sha512-tpZZ+EX0gaghDAiFR37hj5MgY6ZN55kLiPkJsKxBMZ6GZdOSPJXiOzPM984oPYZ5AnehYx5WQp1+ME8I/P/pRA==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-latest": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/use-latest/-/use-latest-1.3.0.tgz", + "integrity": "sha512-mhg3xdm9NaM8q+gLT8KryJPnRFOz1/5XPBhmDEVZK1webPzDjrPk7f/mbpeLqTgB9msytYWANxgALOCJKnLvcQ==", + "license": "MIT", + "dependencies": { + "use-isomorphic-layout-effect": "^1.1.1" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sidecar": { + "version": "1.1.3", + "resolved": "https://registry.npmmirror.com/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "license": "MIT", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmmirror.com/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "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", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zustand": { + "version": "5.0.15", + "resolved": "https://registry.npmmirror.com/zustand/-/zustand-5.0.15.tgz", + "integrity": "sha512-MpSEjRiBkA9crSYeOUH32rJC7SVqAbm0Fqcqge/bUi2PPoLcBWKOsG+C8mevmpr8TwXHBVkChbbJiyvkE+i/3A==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 00000000..4c1c4514 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,41 @@ +{ + "name": "cdsl-cad-agent-studio", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "next dev --webpack", + "build": "next build --webpack", + "start": "next start", + "test": "node --test --import tsx src/test.ts" + }, + "dependencies": { + "@ai-sdk/react": "^4.0.40", + "@assistant-ui/react": "0.14.28", + "@assistant-ui/react-ai-sdk": "1.4.0", + "@radix-ui/react-collapsible": "^1.1.20", + "@radix-ui/react-dropdown-menu": "^2.1.24", + "@radix-ui/react-slider": "^1.4.7", + "ai": "7.0.37", + "animejs": "^4.5.0", + "clsx": "^2.1.1", + "lucide-react": "^1.14.0", + "next": "16.2.6", + "react": "19.2.4", + "react-dom": "19.2.4", + "tailwind-merge": "^3.6.0", + "three": "0.160.0", + "three-mesh-bvh": "^0.8.0" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4", + "@types/node": "^20", + "@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/postcss.config.mjs b/frontend/postcss.config.mjs new file mode 100644 index 00000000..c2ddf748 --- /dev/null +++ b/frontend/postcss.config.mjs @@ -0,0 +1,5 @@ +export default { + plugins: { + "@tailwindcss/postcss": {}, + }, +}; diff --git a/frontend/public/.gitkeep b/frontend/public/.gitkeep new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/frontend/public/.gitkeep @@ -0,0 +1 @@ + diff --git a/frontend/src/.gitkeep b/frontend/src/.gitkeep new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/frontend/src/.gitkeep @@ -0,0 +1 @@ + diff --git a/frontend/src/app/api/chat/route.ts b/frontend/src/app/api/chat/route.ts new file mode 100644 index 00000000..132bd80e --- /dev/null +++ b/frontend/src/app/api/chat/route.ts @@ -0,0 +1,70 @@ +import { createUIMessageStream, createUIMessageStreamResponse } from "ai"; +import { NextRequest, NextResponse } from "next/server"; +import { backendFetch, readBackendError } from "@/lib/backend"; +import { messagesForBackend } from "@/lib/cad-messages"; +import { backendEventToUiChunk } from "@/lib/cad-stream"; +import type { CadUIMessage } from "@/lib/cad-types"; + +export const runtime = "nodejs"; + +type BackendEvent = { event: string; data: Record }; + +async function* parseSse(response: Response): AsyncGenerator { + if (!response.body) return; + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + try { + for (;;) { + const { value, done } = await reader.read(); + buffer += decoder.decode(value || new Uint8Array(), { stream: !done }); + const blocks = buffer.split(/\r?\n\r?\n/); + buffer = done ? "" : blocks.pop() || ""; + for (const block of blocks) { + const eventName = /^event:\s*(.+)$/m.exec(block)?.[1]?.trim() || "message"; + const dataText = /^data:\s*(.+)$/m.exec(block)?.[1]?.trim() || "{}"; + try { + yield { event: eventName, data: JSON.parse(dataText) as Record }; + } catch { + yield { event: "cad_error", data: { stage: "stream", message: "Invalid backend event." } }; + } + } + if (done) break; + } + } finally { + reader.releaseLock(); + } +} + +export async function POST(request: NextRequest) { + const body = await request.json().catch(() => ({})); + const upstream = await backendFetch("/v1/chat/stream", { + method: "POST", + headers: { "Content-Type": "application/json", Accept: "text/event-stream" }, + body: JSON.stringify({ + conversation_id: body.conversationId || null, + selected_task_id: body.selectedTaskId || null, + provider_id: body.providerId || null, + model_id: body.modelId || null, + messages: messagesForBackend((Array.isArray(body.messages) ? body.messages : []) as CadUIMessage[]), + }), + signal: request.signal, + }); + if (!upstream.ok) { + return NextResponse.json({ error: await readBackendError(upstream) }, { status: upstream.status }); + } + const stream = createUIMessageStream({ + execute: async ({ writer }) => { + const textId = `assistant_${Date.now()}`; + writer.write({ type: "start", messageId: textId }); + writer.write({ type: "text-start", id: textId }); + for await (const item of parseSse(upstream)) { + const chunk = backendEventToUiChunk(item, textId); + if (chunk) writer.write(chunk); + } + writer.write({ type: "text-end", id: textId }); + writer.write({ type: "finish", finishReason: "stop" }); + }, + }); + return createUIMessageStreamResponse({ stream, headers: { "Cache-Control": "no-store" } }); +} diff --git a/frontend/src/app/api/config/route.ts b/frontend/src/app/api/config/route.ts new file mode 100644 index 00000000..c03941ab --- /dev/null +++ b/frontend/src/app/api/config/route.ts @@ -0,0 +1,12 @@ +import { NextResponse } from "next/server"; +import { backendFetch, readBackendError } from "@/lib/backend"; + +export const runtime = "nodejs"; + +export async function GET() { + const response = await backendFetch("/v1/config"); + 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 new file mode 100644 index 00000000..df7bc9de --- /dev/null +++ b/frontend/src/app/api/conversations/[conversationId]/route.ts @@ -0,0 +1,26 @@ +import { NextRequest, NextResponse } from "next/server"; +import { backendFetch, readBackendError } from "@/lib/backend"; + +export const runtime = "nodejs"; + +export async function GET(_request: NextRequest, context: { params: Promise<{ conversationId: string }> }) { + const { conversationId } = await context.params; + const response = await backendFetch(`/v1/conversations/${encodeURIComponent(conversationId)}`); + if (!response.ok) return NextResponse.json({ error: await readBackendError(response) }, { status: response.status }); + return NextResponse.json(await response.json()); +} + +export async function PATCH(request: NextRequest, context: { params: Promise<{ conversationId: string }> }) { + const { conversationId } = await context.params; + const body = await request.json().catch(() => ({})); + const response = await backendFetch(`/v1/conversations/${encodeURIComponent(conversationId)}`, { + method: "PATCH", + 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 }); + return NextResponse.json(await response.json()); +} diff --git a/frontend/src/app/api/conversations/route.ts b/frontend/src/app/api/conversations/route.ts new file mode 100644 index 00000000..ea9572a4 --- /dev/null +++ b/frontend/src/app/api/conversations/route.ts @@ -0,0 +1,10 @@ +import { NextResponse } from "next/server"; +import { backendFetch, readBackendError } from "@/lib/backend"; + +export const runtime = "nodejs"; + +export async function POST() { + const response = await backendFetch("/v1/conversations", { method: "POST" }); + 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/tasks/[taskId]/artifacts/[...artifactPath]/route.ts b/frontend/src/app/api/tasks/[taskId]/artifacts/[...artifactPath]/route.ts new file mode 100644 index 00000000..58d8d028 --- /dev/null +++ b/frontend/src/app/api/tasks/[taskId]/artifacts/[...artifactPath]/route.ts @@ -0,0 +1,20 @@ +import { NextRequest, NextResponse } from "next/server"; +import { backendFetch, readBackendError } from "@/lib/backend"; + +export const runtime = "nodejs"; + +export async function GET(_request: NextRequest, context: { params: Promise<{ taskId: string; artifactPath: string[] }> }) { + const { taskId, artifactPath } = await context.params; + const path = artifactPath.map((item) => encodeURIComponent(item)).join("/"); + const response = await backendFetch(`/v1/tasks/${encodeURIComponent(taskId)}/artifacts/${path}`, { + headers: { Accept: "*/*" }, + }); + if (!response.ok) return NextResponse.json({ error: await readBackendError(response) }, { status: response.status }); + return new NextResponse(await response.arrayBuffer(), { + headers: { + "Content-Type": response.headers.get("content-type") || "application/octet-stream", + "Content-Disposition": response.headers.get("content-disposition") || "", + "Cache-Control": "no-store", + }, + }); +} diff --git a/frontend/src/app/api/tasks/[taskId]/modify/route.ts b/frontend/src/app/api/tasks/[taskId]/modify/route.ts new file mode 100644 index 00000000..ba2dae52 --- /dev/null +++ b/frontend/src/app/api/tasks/[taskId]/modify/route.ts @@ -0,0 +1,15 @@ +import { NextRequest, NextResponse } from "next/server"; +import { backendFetch, readBackendError } from "@/lib/backend"; + +export const runtime = "nodejs"; + +export async function POST(request: NextRequest, { params }: { params: Promise<{ taskId: string }> }) { + const { taskId } = await params; + const response = await backendFetch(`/v1/tasks/${encodeURIComponent(taskId)}/modify`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(await request.json()), + }); + 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/tasks/[taskId]/parameters/route.ts b/frontend/src/app/api/tasks/[taskId]/parameters/route.ts new file mode 100644 index 00000000..7dd70b88 --- /dev/null +++ b/frontend/src/app/api/tasks/[taskId]/parameters/route.ts @@ -0,0 +1,22 @@ +import { NextRequest, NextResponse } from "next/server"; +import { backendFetch, readBackendError } from "@/lib/backend"; + +export const runtime = "nodejs"; + +export async function GET(_: NextRequest, { params }: { params: Promise<{ taskId: string }> }) { + const { taskId } = await params; + const response = await backendFetch(`/v1/tasks/${encodeURIComponent(taskId)}/parameters`); + if (!response.ok) return NextResponse.json({ error: await readBackendError(response) }, { status: response.status }); + return NextResponse.json(await response.json()); +} + +export async function POST(request: NextRequest, { params }: { params: Promise<{ taskId: string }> }) { + const { taskId } = await params; + const response = await backendFetch(`/v1/tasks/${encodeURIComponent(taskId)}/parameters`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(await request.json()), + }); + 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/tasks/[taskId]/route.ts b/frontend/src/app/api/tasks/[taskId]/route.ts new file mode 100644 index 00000000..6f87d35b --- /dev/null +++ b/frontend/src/app/api/tasks/[taskId]/route.ts @@ -0,0 +1,11 @@ +import { NextRequest, NextResponse } from "next/server"; +import { backendFetch, readBackendError } from "@/lib/backend"; + +export const runtime = "nodejs"; + +export async function GET(_request: NextRequest, context: { params: Promise<{ taskId: string }> }) { + const { taskId } = await context.params; + const response = await backendFetch(`/v1/tasks/${encodeURIComponent(taskId)}`); + 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/uploads/route.ts b/frontend/src/app/api/uploads/route.ts new file mode 100644 index 00000000..34568d25 --- /dev/null +++ b/frontend/src/app/api/uploads/route.ts @@ -0,0 +1,11 @@ +import { NextRequest, NextResponse } from "next/server"; +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 }); + 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 new file mode 100644 index 00000000..81532a7f --- /dev/null +++ b/frontend/src/app/globals.css @@ -0,0 +1,629 @@ +@import "tailwindcss"; + +:root { + --radius: 0.625rem; + --ui-glass-blur: 26px; + --ui-glass-saturation: 1.4; +} + +:root, +:root[data-ui-theme="light"] { + color-scheme: light; + --background: #f5f7f8; + --foreground: #15191d; + --panel: #ffffff; + --panel-2: #edf2f4; + --muted: #68737d; + --border: #d8e0e5; + --accent: #197f8a; + --accent-2: #946b16; + --danger: #b42335; + --sidebar: #ffffff; + --sidebar-foreground: #15191d; + --sidebar-accent: #edf2f4; + --sidebar-accent-foreground: #101418; + --sidebar-border: #d8e0e5; + --sidebar-ring: #197f8a; + --popover: #ffffff; + --popover-foreground: #15191d; + --primary: #197f8a; + --primary-foreground: #f7fcfd; + --muted-foreground: #68737d; + + --ui-app-bg: #f5f7f8; + --ui-header-bg: #ffffff; + --ui-panel: #ffffff; + --ui-panel-muted: #f8fafb; + --ui-panel-raised: #edf2f4; + --ui-popover: #ffffff; + --ui-control-bg: #f1f5f7; + --ui-control-hover: #e6eef2; + --ui-control-pressed: #dbe8ed; + --ui-border: #d8e0e5; + --ui-border-muted: #e6ecef; + --ui-border-strong: #b8c7cf; + --ui-text: #15191d; + --ui-text-strong: #0d1114; + --ui-text-muted: #68737d; + --ui-text-subtle: #87919a; + --ui-text-faint: #a1abb3; + --ui-text-inverse: #f7fcfd; + --ui-accent: #197f8a; + --ui-accent-hover: #146d77; + --ui-accent-soft: rgb(25 127 138 / 12%); + --ui-accent-muted: rgb(25 127 138 / 22%); + --ui-accent-border: rgb(25 127 138 / 34%); + --ui-accent-text: #11646d; + --ui-accent-contrast: #f7fcfd; + --ui-secondary: #946b16; + --ui-secondary-text: #805b11; + --ui-secondary-soft: rgb(148 107 22 / 12%); + --ui-link: #2457a5; + --ui-link-hover: #1c4a8c; + --ui-success: #247a38; + --ui-success-soft: #e7f6e9; + --ui-success-text: #1c6b2e; + --ui-warning: #946b16; + --ui-error: #b42335; + --ui-error-bg: #fff0f1; + --ui-error-border: #f1b9c0; + --ui-error-text: #9f1d2e; + --ui-focus-ring: rgb(25 127 138 / 34%); + --ui-selection-bg: rgb(25 127 138 / 18%); + --ui-viewer-bg: #e9eef2; + --ui-viewer-bg-muted: #dde6eb; + --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%); + --ui-shadow-inset: inset 0 0 0 1px rgb(255 255 255 / 58%); + --ui-glass-surface: rgb(255 255 255 / 82%); + --ui-glass-popover: rgb(255 255 255 / 90%); + --ui-glass-control: rgb(255 255 255 / 72%); + --ui-scrollbar-thumb: #b9c5cc; + --ui-slider-track: rgb(25 127 138 / 16%); + --ui-slider-range: rgb(25 127 138 / 24%); + --ui-slider-range-hover: rgb(25 127 138 / 32%); + --ui-slider-range-active: rgb(25 127 138 / 42%); + --ui-slider-marker: rgb(21 25 29 / 68%); + --ui-slider-marker-hover: rgb(21 25 29 / 86%); + --ui-slider-marker-shadow: rgb(16 24 32 / 18%); +} + +:root[data-ui-theme="dark"] { + color-scheme: dark; + --background: #111315; + --foreground: #e8e8e3; + --panel: #171a1d; + --panel-2: #1e2226; + --muted: #969b9f; + --border: #2d3339; + --accent: #7dc8cf; + --accent-2: #d8b66c; + --danger: #e56f72; + --sidebar: #15181b; + --sidebar-foreground: #e8e8e3; + --sidebar-accent: #22272c; + --sidebar-accent-foreground: #f2f4f4; + --sidebar-border: #2d3339; + --sidebar-ring: #7dc8cf; + --popover: #15181b; + --popover-foreground: #e8e8e3; + --primary: #7dc8cf; + --primary-foreground: #101315; + --muted-foreground: #969b9f; + + --ui-app-bg: #111315; + --ui-header-bg: #15181b; + --ui-panel: #171a1d; + --ui-panel-muted: #15181b; + --ui-panel-raised: #1e2226; + --ui-popover: #15181b; + --ui-control-bg: #1e2226; + --ui-control-hover: #22272c; + --ui-control-pressed: #273135; + --ui-border: #2d3339; + --ui-border-muted: #252b30; + --ui-border-strong: #39434b; + --ui-text: #e8e8e3; + --ui-text-strong: #f2f4f4; + --ui-text-muted: #969b9f; + --ui-text-subtle: #777e84; + --ui-text-faint: #6d747b; + --ui-text-inverse: #101315; + --ui-accent: #7dc8cf; + --ui-accent-hover: #91d4da; + --ui-accent-soft: rgb(125 200 207 / 13%); + --ui-accent-muted: rgb(125 200 207 / 22%); + --ui-accent-border: rgb(125 200 207 / 40%); + --ui-accent-text: #dff8fa; + --ui-accent-contrast: #101315; + --ui-secondary: #d8b66c; + --ui-secondary-text: #d8b66c; + --ui-secondary-soft: rgb(216 182 108 / 14%); + --ui-link: #8bd5dc; + --ui-link-hover: #b3e6ea; + --ui-success: #9cd67a; + --ui-success-soft: #1d2b1d; + --ui-success-text: #9cd67a; + --ui-warning: #d8b66c; + --ui-error: #e56f72; + --ui-error-bg: #35191c; + --ui-error-border: #7e3d42; + --ui-error-text: #f2b0b2; + --ui-focus-ring: rgb(125 200 207 / 45%); + --ui-selection-bg: rgb(125 200 207 / 24%); + --ui-viewer-bg: #0d0f11; + --ui-viewer-bg-muted: #101316; + --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%); + --ui-shadow-inset: inset 0 0 0 1px rgb(232 232 227 / 8%); + --ui-glass-surface: rgb(22 22 25 / 82%); + --ui-glass-popover: rgb(24 24 27 / 86%); + --ui-glass-control: rgb(13 13 16 / 64%); + --ui-scrollbar-thumb: #3a424a; + --ui-slider-track: rgb(125 200 207 / 20%); + --ui-slider-range: rgb(125 200 207 / 24%); + --ui-slider-range-hover: rgb(224 247 249 / 50%); + --ui-slider-range-active: rgb(199 239 242 / 50%); + --ui-slider-marker: rgb(255 255 255 / 70%); + --ui-slider-marker-hover: rgb(255 255 255 / 88%); + --ui-slider-marker-shadow: rgb(0 0 0 / 50%); +} + +* { + box-sizing: border-box; +} + +html, +body { + height: 100%; + margin: 0; + background: var(--ui-app-bg); + color: var(--ui-text); + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; + letter-spacing: 0; +} + +button, +input, +textarea, +select { + font: inherit; +} + +button { + cursor: pointer; +} + +button:disabled { + cursor: not-allowed; + opacity: 0.58; +} + +::selection { + background: var(--ui-selection-bg); +} + +.scrollbar-thin { + scrollbar-width: thin; + scrollbar-color: var(--ui-scrollbar-thumb) transparent; +} + +.cad-glass-surface, +.cad-glass-popover, +.cad-glass-control { + -webkit-backdrop-filter: blur(var(--ui-glass-blur)) saturate(var(--ui-glass-saturation)); + backdrop-filter: blur(var(--ui-glass-blur)) saturate(var(--ui-glass-saturation)); +} + +.cad-glass-surface { + background-color: var(--ui-glass-surface) !important; +} + +.cad-glass-popover { + background-color: var(--ui-glass-popover) !important; +} + +.cad-glass-control { + background-color: var(--ui-glass-control) !important; +} + +.cad-deepbuild-viewer-bg { + background: + radial-gradient(ellipse at 50% 34%, rgb(255 255 255 / 98%), rgb(255 255 255 / 62%) 42%, transparent 66%), + linear-gradient(115deg, rgb(230 246 240 / 76%) 0%, rgb(253 245 226 / 70%) 33%, rgb(251 224 239 / 66%) 62%, rgb(225 234 252 / 78%) 100%), + linear-gradient(180deg, #fbfbfc 0%, #eef2f6 100%); + isolation: isolate; +} + +.cad-deepbuild-viewer-bg::before { + content: ""; + position: absolute; + inset: -8%; + z-index: 0; + pointer-events: none; + background: + radial-gradient(ellipse at 74% 24%, rgb(208 214 248 / 56%), transparent 48%), + radial-gradient(ellipse at 22% 75%, rgb(196 232 224 / 50%), transparent 52%), + radial-gradient(ellipse at 58% 76%, rgb(255 211 188 / 38%), transparent 46%); + filter: blur(56px); + opacity: 0.86; +} + +.cad-deepbuild-viewer-bg::after { + content: ""; + position: absolute; + inset: -3vh -2vw; + z-index: 0; + pointer-events: none; + background: + linear-gradient(135deg, rgb(255 255 255 / 24%), rgb(255 255 255 / 6%) 42%, rgb(255 255 255 / 20%)), + radial-gradient(ellipse at 18% 18%, rgb(255 255 255 / 38%), transparent 34%), + radial-gradient(ellipse at 84% 78%, rgb(255 255 255 / 18%), transparent 42%); + box-shadow: + inset 2px -2px 1px -1px rgb(255 255 255 / 36%), + inset -2px 2px 1px -1px rgb(255 255 255 / 34%), + inset 24px -24px 44px -38px rgb(255 255 255 / 62%), + inset -24px 24px 48px -42px rgb(77 92 117 / 20%), + inset 0 0 1px rgb(27 40 56 / 14%), + inset 0 -34px 90px rgb(92 116 143 / 4.5%); + backdrop-filter: blur(22px) saturate(1.18) brightness(1.03); + -webkit-backdrop-filter: blur(22px) saturate(1.18) brightness(1.03); + opacity: 0.88; +} + +.cad-deepbuild-viewer-bg > :first-child { + position: relative; + z-index: 1; +} + +.generation-edge-glow { + position: fixed; + inset: 0; + z-index: 100; + pointer-events: none; + isolation: isolate; + contain: paint; + animation: generation-edge-glow-in 480ms ease-out both; +} + +.generation-edge-glow__veil, +.generation-edge-glow__band { + position: absolute; + inset: 0; + pointer-events: none; +} + +.generation-edge-glow__veil { + background: + radial-gradient(88% 64% at 14% 8%, rgba(60, 220, 255, 0.18), transparent 64%), + radial-gradient(78% 64% at 88% 9%, rgba(255, 80, 210, 0.16), transparent 64%), + radial-gradient(82% 64% at 52% 104%, rgba(132, 102, 255, 0.12), transparent 72%); + opacity: 0; + animation: generation-edge-full-cover 1.05s cubic-bezier(0.22, 1, 0.36, 1) both; + will-change: opacity; +} + +.generation-edge-glow__band { + inset: -6px; + background: + linear-gradient(90deg, rgba(48, 219, 255, 0.82), rgba(104, 118, 255, 0.46), rgba(255, 80, 210, 0.82), rgba(255, 142, 105, 0.34), rgba(48, 219, 255, 0.72)) top / 100% 14px no-repeat, + linear-gradient(90deg, rgba(158, 96, 255, 0.7), rgba(84, 132, 255, 0.48), rgba(42, 218, 255, 0.76), rgba(255, 112, 210, 0.36)) bottom / 100% 14px no-repeat, + linear-gradient(180deg, rgba(42, 218, 255, 0.82), rgba(92, 132, 255, 0.44), rgba(160, 98, 255, 0.62)) left / 14px 100% no-repeat, + linear-gradient(180deg, rgba(255, 82, 214, 0.86), rgba(170, 100, 255, 0.5), rgba(48, 214, 255, 0.72)) right / 14px 100% no-repeat; + filter: blur(9px) saturate(1.18); + opacity: 0; + -webkit-mask: + linear-gradient(#000 0 0) content-box, + linear-gradient(#000 0 0); + -webkit-mask-composite: xor; + mask: + linear-gradient(#000 0 0) content-box, + linear-gradient(#000 0 0); + mask-composite: exclude; + padding: 46px; + animation: generation-edge-enter-strong 900ms cubic-bezier(0.22, 1, 0.36, 1) 360ms both; + will-change: opacity; +} + +.generation-edge-glow.is-converging { + animation: none; +} + +.generation-edge-glow.is-converging .generation-edge-glow__veil, +.generation-edge-glow.is-converging .generation-edge-glow__band { + animation: generation-edge-soft-out 520ms ease-out forwards; +} + +@keyframes generation-edge-glow-in { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes generation-edge-full-cover { + 0% { + opacity: 0; + transform: scale(1.03); + } + 24% { + opacity: 0.32; + transform: scale(1.01); + } + 58% { + opacity: 0.14; + } + 100% { + opacity: 0; + transform: scale(1); + } +} + +@keyframes generation-edge-enter-strong { + from { opacity: 0; } + to { opacity: 0.74; } +} + +@keyframes generation-edge-soft-out { to { opacity: 0; } } + +.generation-success { + position: fixed; + inset: 0; + z-index: 101; + display: grid; + place-items: center; + pointer-events: none; +} + +.generation-success__glow { + position: absolute; + left: 50%; + top: 50%; + width: 360px; + height: 220px; + margin: -110px 0 0 -180px; + border-radius: 50%; + background: radial-gradient(ellipse at center, rgba(120, 220, 255, 0.24), transparent 70%); + opacity: 0; + animation: generation-success-glow 2100ms ease-out 1300ms both; +} + +@keyframes generation-success-glow { + 0% { opacity: 0; transform: scale(0.6); } + 28% { opacity: 1; } + 72% { opacity: 1; } + 100% { opacity: 0; transform: scale(1.05); } +} + +.generation-success__card { + position: relative; + display: flex; + align-items: center; + gap: 13px; + max-width: min(420px, calc(100vw - 32px)); + padding: 14px 22px 14px 15px; + border: 0.5px solid rgba(255, 255, 255, 0.92); + border-radius: 20px; + background: rgba(255, 255, 255, 0.74); + box-shadow: + 0 18px 50px rgba(28, 40, 60, 0.22), + inset 0 1px 0 rgba(255, 255, 255, 0.75); + -webkit-backdrop-filter: blur(30px) saturate(1.7); + backdrop-filter: blur(30px) saturate(1.7); + opacity: 0; + will-change: transform, opacity; + animation: + generation-success-card-in 640ms cubic-bezier(0.34, 1.56, 0.64, 1) 1520ms both, + generation-success-card-out 460ms ease-in 3.5s forwards; +} + +@keyframes generation-success-card-in { + 0% { opacity: 0; transform: scale(0.8) translateY(6px); } + 100% { opacity: 1; transform: scale(1) translateY(0); } +} + +@keyframes generation-success-card-out { + to { opacity: 0; transform: scale(0.97); } +} + +.generation-success__badge { + position: relative; + flex: 0 0 auto; + width: 38px; + height: 38px; + display: grid; + place-items: center; + border-radius: 50%; + background: radial-gradient(circle at 50% 38%, rgba(70, 230, 175, 0.26), transparent 70%); +} + +.generation-success__badge::before { + content: ""; + position: absolute; + inset: -3px; + border-radius: 50%; + background: conic-gradient(from 120deg, #34c759, #2fd6c0, #4ab8ff, #34c759); + opacity: 0; + filter: blur(5px); + animation: generation-success-badge-glow 760ms ease-out 1700ms both; +} + +@keyframes generation-success-badge-glow { + 0% { opacity: 0; transform: scale(0.7); } + 60% { opacity: 0.6; } + 100% { opacity: 0.34; transform: scale(1); } +} + +.generation-success__check { + position: relative; + z-index: 1; + width: 32px; + height: 32px; +} + +.generation-success__check circle { + fill: none; + stroke: #34c759; + stroke-width: 3; + stroke-dasharray: 145; + stroke-dashoffset: 145; + animation: generation-success-check-circle 500ms cubic-bezier(0.65, 0, 0.35, 1) 1760ms forwards; +} + +.generation-success__check path { + fill: none; + stroke: #34c759; + stroke-width: 4.2; + stroke-linecap: round; + stroke-linejoin: round; + stroke-dasharray: 36; + stroke-dashoffset: 36; + animation: generation-success-check-path 320ms cubic-bezier(0.65, 0, 0.35, 1) 2080ms forwards; +} + +@keyframes generation-success-check-circle { to { stroke-dashoffset: 0; } } +@keyframes generation-success-check-path { to { stroke-dashoffset: 0; } } + +.generation-success__copy { + display: grid; + gap: 1px; + min-width: 0; + overflow: hidden; +} + +.generation-success__kicker { + color: rgba(118, 138, 165, 0.9); + font-size: 9.5px; + font-weight: 700; + letter-spacing: 0; + text-transform: uppercase; + opacity: 0; + animation: generation-success-copy-in 520ms ease-out 1640ms both; +} + +.generation-success__copy strong { + position: relative; + color: #18212f; + font-size: 18px; + font-weight: 800; + line-height: 1.18; + letter-spacing: 0; + opacity: 0; + animation: generation-success-copy-in 560ms cubic-bezier(0.2, 1, 0.3, 1) 1740ms both; +} + +.generation-success__copy strong::after { + content: attr(data-text); + position: absolute; + left: 0; + top: 0; + color: transparent; + -webkit-text-fill-color: transparent; + background: linear-gradient(100deg, transparent 36%, rgba(74, 168, 255, 0.95) 50%, transparent 64%); + background-size: 260% 100%; + background-position: 130% 0; + -webkit-background-clip: text; + background-clip: text; + animation: generation-success-title-shine 1100ms ease-out 2150ms both; +} + +@keyframes generation-success-title-shine { to { background-position: -50% 0; } } + +.generation-success__detail { + overflow: hidden; + color: #7b8a9c; + font-size: 12.5px; + font-weight: 600; + line-height: 1.3; + letter-spacing: 0; + text-overflow: ellipsis; + white-space: nowrap; + opacity: 0; + animation: generation-success-copy-in 560ms ease-out 1900ms both; +} + +@keyframes generation-success-copy-in { + 0% { opacity: 0; transform: translateY(5px); filter: blur(3px); } + 100% { opacity: 1; transform: translateY(0); filter: blur(0); } +} + +.generation-success__spark { + position: absolute; + width: 5px; + height: 5px; + border-radius: 50%; + background: radial-gradient(circle, #fff 0%, rgba(120, 220, 255, 0.7) 55%, transparent 75%); + opacity: 0; + animation: generation-success-spark 1300ms ease-out calc(2000ms + var(--spark-index) * 230ms) both; +} + +.generation-success__spark:nth-of-type(1) { top: -7px; right: 30px; } +.generation-success__spark:nth-of-type(2) { bottom: -5px; left: 46px; } +.generation-success__spark:nth-of-type(3) { top: 12px; right: -7px; } + +@keyframes generation-success-spark { + 0% { opacity: 0; transform: scale(0); } + 42% { opacity: 1; transform: scale(1.25); } + 100% { opacity: 0; transform: scale(0.35); } +} + +@media (prefers-reduced-motion: reduce) { + .generation-edge-glow, + .generation-edge-glow *, + .generation-edge-glow *::before, + .generation-success, + .generation-success *, + .generation-success *::before, + .generation-success *::after { + animation-duration: 1ms !important; + animation-iteration-count: 1 !important; + } +} + +/* Local CDSL CAD Studio layout, built on the copied studio theme tokens. */ +.studio-app { display: flex; flex-direction: column; height: 100vh; min-height: 0; overflow: hidden; background: var(--ui-app-bg); color: var(--ui-text); } +.app-header { display: flex; height: 48px; flex: 0 0 auto; align-items: center; justify-content: space-between; gap: 12px; border-bottom: 1px solid var(--ui-border); background: var(--ui-header-bg); padding: 0 12px; } +.app-brand, .app-controls { display: flex; min-width: 0; align-items: center; gap: 8px; } +.app-brand { color: var(--ui-text-strong); font-size: 14px; } +.app-brand > svg { color: var(--ui-accent); } +.task-badge { max-width: 260px; overflow: hidden; border: 1px solid var(--ui-border); border-radius: 4px; color: var(--ui-text-muted); font-size: 11px; padding: 4px 7px; text-overflow: ellipsis; white-space: nowrap; } +.app-controls select, .theme-button { height: 30px; max-width: 180px; border: 1px solid var(--ui-border); border-radius: 4px; background: var(--ui-control-bg); color: var(--ui-text); font-size: 11px; padding: 0 8px; } +.theme-button { display: grid; width: 30px; place-items: center; padding: 0; } +.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); } +.agent-thread-shell, .thread-root { display: flex; min-height: 0; flex: 1; flex-direction: column; } +.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-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; } +.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; } +.composer-shell { flex: 0 0 auto; border-top: 1px solid var(--ui-border); padding: 12px; } +.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); } +.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; } } diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx new file mode 100644 index 00000000..3bdd3a94 --- /dev/null +++ b/frontend/src/app/layout.tsx @@ -0,0 +1,15 @@ +import type { Metadata } from "next"; +import "./globals.css"; + +export const metadata: Metadata = { + title: "CDSL CAD Studio", + description: "Agent chat for parameterized CDSL CAD generation.", +}; + +export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) { + return ( + + {children} + + ); +} diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx new file mode 100644 index 00000000..0a0535d6 --- /dev/null +++ b/frontend/src/app/page.tsx @@ -0,0 +1,5 @@ +import { AgentStudio } from "@/components/agent-studio"; + +export default function Page() { + return ; +} diff --git a/frontend/src/components/agent-studio.tsx b/frontend/src/components/agent-studio.tsx new file mode 100644 index 00000000..05209190 --- /dev/null +++ b/frontend/src/components/agent-studio.tsx @@ -0,0 +1,484 @@ +"use client"; + +import { AssistantRuntimeProvider, useAuiState } from "@assistant-ui/react"; +import { useAISDKRuntime } from "@assistant-ui/react-ai-sdk"; +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 { normalizeCadMessages } from "@/lib/cad-messages"; +import type { + BackendConfig, + CadError, + CadAttachment, + CadResult, + CadUIMessage, + ConversationRecord, + TaskRecord, +} from "@/lib/cad-types"; +import { AgentThread } from "./agent-thread"; +import { CadViewerPreview } from "./cad-viewer-preview"; +import type { AssistantRuntime } from "@assistant-ui/react"; + +type LoadState = "loading" | "ready" | "error"; + +export function AgentStudio() { + const [loadState, setLoadState] = useState("loading"); + const [conversationId, setConversationId] = useState(""); + const [selectedTaskId, setSelectedTaskId] = useState(""); + const [initialMessages, setInitialMessages] = useState([]); + const [config, setConfig] = useState(null); + const [cadResult, setCadResult] = useState(null); + const [lastError, setLastError] = useState(""); + const [attachments, setAttachments] = useState([]); + const [uploading, setUploading] = useState(false); + const [providerId, setProviderId] = useState(""); + const [modelId, setModelId] = useState(""); + const [theme, setTheme] = useState<"light" | "dark">("light"); + + const syncUrl = useCallback((conversation: string, task: string) => { + const params = new URLSearchParams(window.location.search); + if (conversation) params.set("conversationId", conversation); + if (task) params.set("taskId", task); + else params.delete("taskId"); + window.history.replaceState(null, "", `${window.location.pathname}?${params.toString()}`); + }, []); + + useEffect(() => { + let cancelled = false; + async function boot() { + try { + const params = new URLSearchParams(window.location.search); + let nextConversationId = params.get("conversationId") || ""; + const urlTaskId = params.get("taskId") || ""; + const [configResponse] = await Promise.all([fetch("/api/config", { cache: "no-store" })]); + if (!configResponse.ok) throw new Error(await configResponse.text()); + const nextConfig = (await configResponse.json()) as BackendConfig; + + let conversation: ConversationRecord; + if (nextConversationId) { + const response = await fetch(`/api/conversations/${encodeURIComponent(nextConversationId)}`, { cache: "no-store" }); + if (!response.ok) throw new Error(await response.text()); + conversation = (await response.json()) as ConversationRecord; + } else { + const response = await fetch("/api/conversations", { method: "POST" }); + if (!response.ok) throw new Error(await response.text()); + conversation = (await response.json()) as ConversationRecord; + nextConversationId = conversation.conversation_id; + } + + const taskId = urlTaskId || conversation.current_task_id || ""; + let restored: CadResult | 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); + } + } + + if (cancelled) return; + setConfig(nextConfig); + setConversationId(nextConversationId); + setSelectedTaskId(taskId); + setInitialMessages(normalizeCadMessages(conversation.messages)); + setAttachments(conversation.attachments || []); + const defaultProvider = nextConfig.providers.find((provider) => provider.id === nextConfig.default_provider) ?? nextConfig.providers[0]; + setProviderId(defaultProvider?.id || ""); + setModelId(defaultProvider?.models.find((model) => model.id === nextConfig.default_model)?.id || defaultProvider?.models[0]?.id || ""); + setCadResult(restored); + setLoadState("ready"); + syncUrl(nextConversationId, taskId); + } catch (error) { + if (cancelled) return; + setLastError(error instanceof Error ? error.message : "启动失败"); + setLoadState("error"); + } + } + void boot(); + return () => { + cancelled = true; + }; + }, [syncUrl]); + + useEffect(() => { + const stored = window.localStorage.getItem("cdsl-cad.ui-theme"); + const next = stored === "dark" || stored === "light" + ? stored + : window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; + setTheme(next); + document.documentElement.dataset.uiTheme = next; + }, []); + + const toggleTheme = useCallback(() => { + setTheme((current) => { + const next = current === "light" ? "dark" : "light"; + document.documentElement.dataset.uiTheme = next; + window.localStorage.setItem("cdsl-cad.ui-theme", next); + return next; + }); + }, []); + + const handleResult = useCallback((result: CadResult) => { + setCadResult(result); + setSelectedTaskId(result.taskId); + setLastError(""); + if (conversationId) { + syncUrl(conversationId, result.taskId); + void fetch(`/api/conversations/${encodeURIComponent(conversationId)}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ currentTaskId: result.taskId }), + }); + } + }, [conversationId, syncUrl]); + + const handleError = useCallback((error: CadError) => { + setLastError(error.message); + }, []); + + 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; + } + setUploading(true); + try { + const uploaded: CadAttachment[] = []; + for (const file of Array.from(files)) { + 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 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; + }); + setLastError(""); + } catch (error) { + setLastError(error instanceof Error ? error.message : "附件上传失败"); + } finally { + setUploading(false); + } + }, [config, conversationId, modelId, providerId, selectedTaskId]); + + if (loadState === "loading") { + return ; + } + if (loadState === "error") { + return ; + } + + return ( + + + + ); +} + +function AgentRuntime({ + conversationId, + selectedTaskId, + providerId, + modelId, + initialMessages, + onCadResult, + onCadError, + children, +}: { + conversationId: string; + selectedTaskId: string; + providerId: string; + modelId: string; + initialMessages: CadUIMessage[]; + onCadResult: (result: CadResult) => void; + onCadError: (error: CadError) => void; + children: React.ReactNode; +}) { + const conversationRef = useRef(conversationId); + const taskRef = useRef(selectedTaskId); + const providerRef = useRef(providerId); + const modelRef = useRef(modelId); + const onCadResultRef = useRef(onCadResult); + const onCadErrorRef = useRef(onCadError); + conversationRef.current = conversationId; + taskRef.current = selectedTaskId; + providerRef.current = providerId; + modelRef.current = modelId; + onCadResultRef.current = onCadResult; + onCadErrorRef.current = onCadError; + + const transport = useMemo(() => new DefaultChatTransport({ + api: "/api/chat", + prepareSendMessagesRequest: (options) => ({ + body: { + ...options.body, + id: options.id, + messages: options.messages, + conversationId: conversationRef.current, + selectedTaskId: taskRef.current || null, + providerId: providerRef.current || null, + modelId: modelRef.current || null, + trigger: options.trigger, + messageId: options.messageId, + }, + }), + }), []); + + const chat = useChat({ + id: conversationId, + messages: initialMessages, + transport, + onData: (part) => { + if (part.type === "data-cad-result") { + onCadResultRef.current(part.data as CadResult); + taskRef.current = (part.data as CadResult).taskId; + } + if (part.type === "data-cad-error") { + onCadErrorRef.current(part.data as CadError); + } + }, + onError: (error) => { + onCadErrorRef.current({ stage: "chat", message: error.message }); + }, + }); + const runtime = useAISDKRuntime(chat, { + joinStrategy: "none", + }); + const stableRuntime = useMemo(() => stabilizeAssistantRuntimeSnapshots(runtime), [runtime]); + + return {children}; +} + +const stableSnapshotSymbol = Symbol.for("cdsl-cad.stableRuntimeSnapshot"); +const stableChildRuntimeSymbol = Symbol.for("cdsl-cad.stableChildRuntimeMethods"); + +type SnapshotRuntime = { + getState: () => unknown; + [stableSnapshotSymbol]?: true; + [stableChildRuntimeSymbol]?: true; +}; + +function stabilizeAssistantRuntimeSnapshots(runtime: AssistantRuntime) { + stabilizeThreadListRuntime(runtime.threads); + stabilizeThreadRuntime(runtime.thread); + return runtime; +} + +function stabilizeThreadListRuntime(runtime: AssistantRuntime["threads"]) { + const threadList = runtime as AssistantRuntime["threads"] & SnapshotRuntime; + stabilizeSnapshot(threadList); + stabilizeThreadRuntime(threadList.main); + stabilizeSnapshotIfRuntime(threadList.mainItem); + if (threadList[stableChildRuntimeSymbol]) return; + + wrapRuntimeFactory(threadList, "getById", stabilizeThreadRuntime); + wrapRuntimeFactory(threadList, "getItemById", stabilizeSnapshotIfRuntime); + wrapRuntimeFactory(threadList, "getItemByIndex", stabilizeSnapshotIfRuntime); + wrapRuntimeFactory(threadList, "getArchivedItemByIndex", stabilizeSnapshotIfRuntime); + threadList[stableChildRuntimeSymbol] = true; +} + +function stabilizeThreadRuntime(runtime: unknown) { + if (!isSnapshotRuntime(runtime)) return runtime; + stabilizeSnapshot(runtime); + const thread = runtime as SnapshotRuntime & { + composer?: unknown; + getMessageById?: (...args: unknown[]) => unknown; + getMessageByIndex?: (...args: unknown[]) => unknown; + }; + stabilizeComposerRuntime(thread.composer); + if (thread[stableChildRuntimeSymbol]) return thread; + + wrapRuntimeFactory(thread, "getMessageById", stabilizeMessageRuntime); + wrapRuntimeFactory(thread, "getMessageByIndex", stabilizeMessageRuntime); + thread[stableChildRuntimeSymbol] = true; + return thread; +} + +function stabilizeMessageRuntime(runtime: unknown) { + if (!isSnapshotRuntime(runtime)) return runtime; + stabilizeSnapshot(runtime); + const message = runtime as SnapshotRuntime & { + composer?: unknown; + getAttachmentByIndex?: (...args: unknown[]) => unknown; + getMessagePartByIndex?: (...args: unknown[]) => unknown; + getMessagePartByToolCallId?: (...args: unknown[]) => unknown; + }; + stabilizeComposerRuntime(message.composer); + if (message[stableChildRuntimeSymbol]) return message; + + wrapRuntimeFactory(message, "getAttachmentByIndex", stabilizeSnapshotIfRuntime); + wrapRuntimeFactory(message, "getMessagePartByIndex", stabilizeSnapshotIfRuntime); + wrapRuntimeFactory(message, "getMessagePartByToolCallId", stabilizeSnapshotIfRuntime); + message[stableChildRuntimeSymbol] = true; + return message; +} + +function stabilizeComposerRuntime(runtime: unknown) { + if (!isSnapshotRuntime(runtime)) return runtime; + stabilizeSnapshot(runtime); + const composer = runtime as SnapshotRuntime & { + getAttachmentByIndex?: (...args: unknown[]) => unknown; + }; + if (composer[stableChildRuntimeSymbol]) return composer; + + wrapRuntimeFactory(composer, "getAttachmentByIndex", stabilizeSnapshotIfRuntime); + composer[stableChildRuntimeSymbol] = true; + return composer; +} + +function stabilizeSnapshot(runtime: SnapshotRuntime) { + if (runtime[stableSnapshotSymbol]) return; + + const getState = runtime.getState.bind(runtime); + let previous: unknown; + runtime.getState = () => { + const next = getState(); + if (isShallowSameSnapshot(previous, next)) return previous; + previous = next; + return next; + }; + runtime[stableSnapshotSymbol] = true; +} + +function stabilizeSnapshotIfRuntime(value: unknown) { + if (isSnapshotRuntime(value)) stabilizeSnapshot(value); + return value; +} + +function wrapRuntimeFactory( + runtime: Record, + method: string, + stabilize: (value: unknown) => unknown, +) { + const original = runtime[method]; + if (typeof original !== "function") return; + runtime[method] = (...args: unknown[]) => stabilize(original.apply(runtime, args)); +} + +function isSnapshotRuntime(value: unknown): value is SnapshotRuntime { + return Boolean(value && typeof value === "object" && typeof (value as SnapshotRuntime).getState === "function"); +} + +function isShallowSameSnapshot(left: unknown, right: unknown) { + if (Object.is(left, right)) return true; + if (!left || !right || typeof left !== "object" || typeof right !== "object") return false; + const leftRecord = left as Record; + const rightRecord = right as Record; + const leftKeys = Object.keys(leftRecord); + if (leftKeys.length !== Object.keys(rightRecord).length) return false; + return leftKeys.every((key) => Object.is(leftRecord[key], rightRecord[key])); +} + +function StudioShell({ + config, + cadResult, + lastError, + attachments, + uploading, + onUpload, + theme, + onToggleTheme, + providerId, + modelId, + onProviderChange, + onModelChange, + onCadResult, + onCadError, +}: { + config: BackendConfig | null; + cadResult: CadResult | null; + lastError: string; + attachments: CadAttachment[]; + uploading: boolean; + onUpload: (files: FileList | null) => void; + theme: "light" | "dark"; + onToggleTheme: () => void; + providerId: string; + modelId: string; + onProviderChange: (id: string) => void; + onModelChange: (id: string) => void; + onCadResult: (result: CadResult) => void; + onCadError: (error: CadError) => void; +}) { + const running = useAuiState((state) => state.thread.isRunning); + const provider = config?.providers.find((item) => item.id === providerId); + return ( +
+
+
CDSL CAD Studio{cadResult ? {cadResult.taskId} : null}
+
+ + + +
+
+ {!config?.configured ?
未配置模型环境变量,聊天会保留诊断但不会生成虚假模型。
: null} +
+ +
onCadError({ stage: "viewer", message })} />
+
+
+ ); +} + +function StudioLoading() { + return ( +
+ + 启动 CDSL CAD Studio +
+ ); +} + +function StudioError({ message }: { message: string }) { + return ( +
+ + {message} +
+ ); +} diff --git a/frontend/src/components/agent-thread.tsx b/frontend/src/components/agent-thread.tsx new file mode 100644 index 00000000..53e71909 --- /dev/null +++ b/frontend/src/components/agent-thread.tsx @@ -0,0 +1,60 @@ +"use client"; + +import { Check, FileImage, FileText, Loader2, MessageSquare, Paperclip, Send, Square } from "lucide-react"; +import { useRef } 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"; + +export function AgentThread({ attachments, uploading, onUpload }: { + attachments: CadAttachment[]; + uploading: boolean; + onUpload: (files: FileList | null) => void; +}) { + const fileInput = useRef(null); + return ( +
+
Agent
+ + + {attachments.length ?
{attachments.map((attachment) => )}
: null} + +
描述要生成或修改的 CAD 模型Agent 会检索本地 CDSL 样本并生成可编辑的 CDSL 模型。
+
+
+
+ +
+
+ ); +} + +function AttachmentCard({ attachment }: { attachment: CadAttachment }) { + const Icon = attachment.kind === "image" ? FileImage : FileText; + return
{attachment.name}{attachment.kind === "image" ? "视觉参考" : "文本参考"}
; +} + +function UserMessage() { + 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); + return ( +
+ onUpload(event.target.files)} /> + + +
Enter 发送,Shift + Enter 换行
{running ? : }
+
+
+ ); +} diff --git a/frontend/src/components/cad-message-parts.tsx b/frontend/src/components/cad-message-parts.tsx new file mode 100644 index 00000000..58231171 --- /dev/null +++ b/frontend/src/components/cad-message-parts.tsx @@ -0,0 +1,71 @@ +"use client"; + +import { AlertTriangle, CheckCircle2, Download, Loader2 } from "lucide-react"; +import { encodeArtifactUrl } from "@/lib/cad-artifacts"; +import type { CadError, CadProgress, CadResult } from "@/lib/cad-types"; + +export function TextPart({ text }: { text: string }) { + if (!text.trim()) return null; + return

{text}

; +} + +export function CadProgressPart({ data }: { data: CadProgress }) { + const running = data.status === "running"; + return ( +
+
+ {running ? : } +
+
+
{data.label || data.step}
+ {data.message ?
{data.message}
: null} +
+
+ ); +} + +export function CadResultPart({ data }: { data: CadResult }) { + const downloads = [ + ["STEP", data.stepPath], + ["CDSL", data.cdslPath], + ["GLB", data.glbPath], + ["REPORT", data.reportPath], + ] as const; + return ( +
+
+ +
+
+
{data.summary || "生成完成"}
+
+ {data.engine} + {data.revisionId} + {data.referenceIds.length} references +
+
+ {downloads.map(([label, path]) => ( + + + {label} + + ))} +
+
+
+ ); +} + +export function CadErrorPart({ data }: { data: CadError }) { + return ( +
+
+ +
+
+
{data.stage || "生成失败"}
+
{data.message}
+
+
+ ); +} diff --git a/frontend/src/components/cad-viewer-preview.tsx b/frontend/src/components/cad-viewer-preview.tsx new file mode 100644 index 00000000..51d04b0b --- /dev/null +++ b/frontend/src/components/cad-viewer-preview.tsx @@ -0,0 +1,524 @@ +"use client"; + +import { AlertTriangle, Box, Loader2 } from "lucide-react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import CadViewer from "@/viewer-port/components/CadViewer"; +import { RENDER_FORMAT } from "@/viewer-port/workbench/constants"; +import { loadRenderGlb, loadRenderJson } from "@/viewer-runtime/lib/renderAssetClient"; +import { cloneThemePresetSettings } from "@/viewer-runtime/lib/themeSettings"; +import { encodeArtifactUrl } from "@/lib/cad-artifacts"; +import { buildCdslSelectorRuntime } from "@/lib/cdsl-selector-runtime"; +import { cadEditToolForOperation, cadEditToolNextPickKind, cadEditToolPickComplete, defaultCadEditParameters, type AiSelectionMode } from "@/lib/cad-edit-tools"; +import type { CadResult } from "@/lib/cad-types"; +import { EmbeddedCadEditToolbar, EmbeddedCadViewToolbar } from "./embedded-cad-toolbar"; +import { GenerationEdgeGlow } from "./generation-edge-glow"; +import { GenerationSuccessReveal } from "./generation-success-reveal"; +import { ParameterPanel } from "./parameter-panel"; + +type Props = { + result: CadResult | null; + isGenerating: boolean; + lastError?: string; + theme: "light" | "dark"; + onResult: (result: CadResult) => void; + onError: (message: string) => void; +}; + +type SelectorRuntime = ReturnType; +type LoadState = { kind: "empty" | "loading" | "error" } | { kind: "ready"; meshData: unknown; selectorRuntime: SelectorRuntime | null }; +type AiSelectionDraft = { + active?: boolean; + points?: Array<{ x: number; y: number }>; +} | null; + +const VIEWER_THEME = (() => { + const base = cloneThemePresetSettings("workbench") as Record; + return { + ...base, + materials: { ...(base.materials as Record), defaultColor: "#d7dce2", roughness: 0.36, metalness: 0.18 }, + edges: { ...(base.edges as Record), enabled: true, color: "#768493", opacity: 0.22, thickness: 1 }, + floor: { ...(base.floor as Record), enabled: false, mode: "none" }, + environment: { ...(base.environment as Record), enabled: false }, + }; +})(); + +function resultFromBackend(payload: Record): CadResult { + return { + taskId: String(payload.task_id), revisionId: String(payload.revision_id), + cdslPath: String(payload.cdsl_path), stepPath: String(payload.step_path), + glbPath: String(payload.glb_path), reportPath: String(payload.report_path), + 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, + summary: String(payload.summary || "Updated CDSL model"), + referenceIds: Array.isArray(payload.reference_ids) ? payload.reference_ids.map(String) : [], + engine: String(payload.engine || "cdsl_only"), + }; +} + +function finiteClientPoint(pick: Record | null) { + const x = Number(pick?.clientX); + const y = Number(pick?.clientY); + return Number.isFinite(x) && Number.isFinite(y) ? { x, y } : null; +} + +function editToolPickMarkerLabel(activeToolId: string, index: number) { + if (activeToolId === "add_slot") return index === 0 ? "起点" : "终点"; + if (activeToolId === "add_hole_pattern") return "中心"; + return "位置"; +} + +function editParameter(parameters: Record, name: string, fallback: number) { + const value = Number(parameters[name]); + return Number.isFinite(value) ? value : fallback; +} + +function previewPixels(valueMm: number, scale = 5, min = 12, max = 180) { + const value = Math.abs(valueMm); + return Number.isFinite(value) && value > 0 ? Math.min(Math.max(value * scale, min), max) : min; +} + +function AiSelectionOverlay({ draft }: { draft: AiSelectionDraft }) { + const points = draft?.active && Array.isArray(draft.points) ? draft.points : []; + const polylinePoints = points.map((point) => `${Number(point.x)},${Number(point.y)}`).join(" "); + if (!polylinePoints) return null; + return ( + + ); +} + +function EditToolPickOverlay({ + activeToolId, + picks, + hoverPick, + parameters, +}: { + activeToolId: string; + picks: Record[]; + hoverPick: Record | null; + parameters: Record; +}) { + if (!activeToolId) return null; + const selected = picks.flatMap((pick, index) => { + const point = finiteClientPoint(pick); + return point ? [{ pick, index, point, hover: false }] : []; + }); + const hoverPoint = finiteClientPoint(hoverPick); + const hover = hoverPoint && !selected.some((entry) => Math.hypot(entry.point.x - hoverPoint.x, entry.point.y - hoverPoint.y) < 1) + ? { pick: hoverPick || {}, index: selected.length, point: hoverPoint, hover: true } + : null; + const points = hover ? [...selected, hover] : selected; + if (!points.length) return null; + + const slot = activeToolId === "add_slot" && points.length >= 2 + ? { start: points[0].point, end: points[1].point, preview: points[1].hover } + : null; + const anchor = activeToolId === "add_slot" ? null : (selected.at(-1)?.point || hover?.point || null); + const tool = activeToolId === "add_counterbore" ? "double" : activeToolId; + const circle = anchor && ["add_hole", "double", "add_countersink", "add_circular_pocket"].includes(tool) + ? { + point: anchor, + inner: previewPixels(editParameter(parameters, tool === "add_circular_pocket" ? "diameter" : "holeDiameter", tool === "add_circular_pocket" ? 8 : 3)), + outer: tool === "double" + ? previewPixels(editParameter(parameters, "counterboreDiameter", 6)) + : tool === "add_countersink" + ? previewPixels(editParameter(parameters, "countersinkDiameter", 6)) + : 0, + dashed: tool === "add_countersink", + } + : null; + const pocket = anchor && activeToolId === "add_pocket" + ? { point: anchor, width: previewPixels(editParameter(parameters, "width", 10), 4, 24, 240), height: previewPixels(editParameter(parameters, "height", 6), 4, 18, 180) } + : null; + const pattern = anchor && activeToolId === "add_hole_pattern" + ? { + point: anchor, + rows: Math.min(Math.max(Math.round(editParameter(parameters, "rows", 2)), 1), 8), + columns: Math.min(Math.max(Math.round(editParameter(parameters, "columns", 2)), 1), 8), + pitchX: previewPixels(editParameter(parameters, "pitchX", 8), 3, 18, 90), + pitchY: previewPixels(editParameter(parameters, "pitchY", 8), 3, 18, 90), + diameter: previewPixels(editParameter(parameters, "holeDiameter", 2), 4, 8, 48), + } + : null; + const slotWidth = slot ? previewPixels(editParameter(parameters, "slotWidth", 2), 5, 12, 80) : 0; + + return ( + + ); +} + +export function CadViewerPreview({ result, isGenerating, lastError, theme, onResult, onError }: Props) { + const viewerRef = useRef<{ captureScreenshot?: (options?: unknown) => Promise; zoomToFit?: () => void } | null>(null); + // A task restored from the URL should appear quietly after a page refresh. + // Subsequent revisions in this mounted workspace still receive completion feedback. + const suppressInitialReveal = useRef(Boolean(result)); + const [loadState, setLoadState] = useState({ kind: "empty" }); + const [activeTool, setActiveTool] = useState(""); + const [editPicks, setEditPicks] = useState[]>([]); + const [editParameters, setEditParameters] = useState>({}); + const [editHoverPick, setEditHoverPick] = useState | null>(null); + const [editSelectionReady, setEditSelectionReady] = useState(false); + const [aiSelectionDraft, setAiSelectionDraft] = useState(null); + const [hoveredReferenceId, setHoveredReferenceId] = useState(""); + const [selectedReferenceIds, setSelectedReferenceIds] = useState([]); + const [selectionMode, setSelectionMode] = useState("point"); + const [editPending, setEditPending] = useState(false); + const [reveal, setReveal] = useState(0); + const [showParameters, setShowParameters] = useState(false); + const [parameters, setParameters] = useState[]>([]); + const [parameterPending, setParameterPending] = useState(""); + const [parameterError, setParameterError] = useState(""); + + useEffect(() => { + if (!result) { + setLoadState((current) => current.kind === "ready" ? current : { kind: "empty" }); + return; + } + const controller = new AbortController(); + setLoadState((current) => current.kind === "ready" ? current : { kind: "loading" }); + const glbUrl = encodeArtifactUrl(result.taskId, result.glbPath); + const selectorUrl = result.selectorPath ? encodeArtifactUrl(result.taskId, result.selectorPath) : ""; + void Promise.all([ + loadRenderGlb(glbUrl), + selectorUrl ? loadRenderJson(selectorUrl).catch(() => null) : Promise.resolve(null), + ]) + .then(([meshData, selectorSidecar]) => { + if (controller.signal.aborted) return; + const selectorRuntime = selectorSidecar && typeof selectorSidecar === "object" + ? buildCdslSelectorRuntime(selectorSidecar, meshData) + : null; + setLoadState({ kind: "ready", meshData, selectorRuntime }); + setHoveredReferenceId(""); + setSelectedReferenceIds([]); + setEditPicks([]); + setEditHoverPick(null); + setEditSelectionReady(false); + setAiSelectionDraft(null); + if (suppressInitialReveal.current) { + suppressInitialReveal.current = false; + } else { + setReveal((value) => value + 1); + } + }) + .catch((error: unknown) => { + if (controller.signal.aborted) return; + setLoadState((current) => current.kind === "ready" ? current : { kind: "error" }); + onError(error instanceof Error ? error.message : "CAD Viewer asset loading failed"); + }); + return () => controller.abort(); + }, [onError, result?.glbPath, result?.revisionId, result?.selectorPath, result?.taskId]); + + useEffect(() => { + if (!reveal) return; + const timer = window.setTimeout(() => setReveal(0), 3600); + return () => window.clearTimeout(timer); + }, [reveal]); + + useEffect(() => { + if (!result) { + setParameters([]); + setShowParameters(false); + return; + } + const controller = new AbortController(); + setParameterError(""); + void fetch(`/api/tasks/${encodeURIComponent(result.taskId)}/parameters`, { signal: controller.signal }) + .then(async (response) => { + if (response.status === 404) return []; + if (!response.ok) throw new Error(await response.text()); + const payload = await response.json() as { parameters?: Record[] }; + return Array.isArray(payload.parameters) ? payload.parameters : []; + }) + .then((next) => { if (!controller.signal.aborted) setParameters(next); }) + .catch((error: unknown) => { + if (!controller.signal.aborted) setParameterError(error instanceof Error ? error.message : "无法读取参数"); + }); + return () => controller.abort(); + }, [result?.revisionId, result?.taskId]); + + const submitEdit = useCallback(async (operation: string, picks: Record[]) => { + if (!result || !operation || !picks.length) return; + setEditPending(true); + try { + const response = await fetch(`/api/tasks/${encodeURIComponent(result.taskId)}/modify`, { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ operation, selection: { pick: picks[0], picks }, parameters: editParameters }), + }); + const payload = await response.json() as Record & { error?: string }; + if (!response.ok) throw new Error(payload.error || "Direct CAD edit failed"); + onResult(resultFromBackend(payload)); + setActiveTool(""); + setEditPicks([]); + setEditHoverPick(null); + setEditSelectionReady(false); + } catch (error) { + onError(error instanceof Error ? error.message : "Direct CAD edit failed"); + } finally { + setEditPending(false); + } + }, [editParameters, onError, onResult, result]); + + const onEditPick = useCallback((pick: Record | null) => { + if (!activeTool || editPending) return; + if (!pick) { + onError("未命中可编辑平面,请在模型实体表面点击。"); + return; + } + setEditPicks((current) => { + if (cadEditToolPickComplete(activeTool, current)) return current; + const next = [...current, pick]; + const referenceId = typeof pick.referenceId === "string" ? pick.referenceId : ""; + if (referenceId) setSelectedReferenceIds([referenceId]); + setEditHoverPick(null); + setEditSelectionReady(cadEditToolPickComplete(activeTool, next)); + return next; + }); + }, [activeTool, editPending, onError]); + + const handleAiSelectionDraftChange = useCallback((draft: AiSelectionDraft) => { + setAiSelectionDraft((current) => { + const currentPoints = current?.points || []; + const nextPoints = draft?.points || []; + if (current?.active === draft?.active && currentPoints.length === nextPoints.length && currentPoints.every((point, index) => point.x === nextPoints[index]?.x && point.y === nextPoints[index]?.y)) { + return current; + } + return draft; + }); + }, []); + + const cancelEdit = useCallback(() => { + setActiveTool(""); + setEditPicks([]); + setEditHoverPick(null); + setEditSelectionReady(false); + setSelectedReferenceIds([]); + setSelectionMode("point"); + }, []); + + const onAiSelectionComplete = useCallback((selection: { referenceIds?: unknown } | null) => { + const referenceIds = Array.isArray(selection?.referenceIds) + ? selection.referenceIds.filter((value): value is string => typeof value === "string" && value.length > 0) + : []; + setSelectedReferenceIds(referenceIds); + }, []); + + const commitParameters = useCallback(async (values: Record) => { + if (!result || !Object.keys(values).length) return; + const parameterId = Object.keys(values)[0]; + setParameterPending(parameterId); + setParameterError(""); + try { + const response = await fetch(`/api/tasks/${encodeURIComponent(result.taskId)}/parameters`, { + method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ values }), + }); + const payload = await response.json() as Record & { error?: string }; + if (!response.ok) throw new Error(payload.error || "参数更新失败"); + onResult(resultFromBackend(payload)); + } catch (error) { + setParameterError(error instanceof Error ? error.message : "参数更新失败"); + } finally { + setParameterPending(""); + } + }, [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] + ); + if (loadState.kind === "empty") return } text="3D 预览等待模型" />; + if (loadState.kind === "loading") return } text="加载 CAD Viewer 资产..." />; + if (loadState.kind === "error") return } text={lastError || "CAD Viewer 资产加载失败"} error />; + if (loadState.kind !== "ready") return null; + + return ( +
+ setSelectedReferenceIds(referenceId ? [referenceId] : [])} + editPointPickEnabled={Boolean(activeTool)} + activeEditToolId={activeTool} + editToolPickKind={cadEditToolNextPickKind(activeTool, editPicks.length)} + editToolPicks={editPicks} + editToolHoverPick={editHoverPick} + editToolParameters={editParameters} + onEditToolPick={onEditPick} + onEditToolHover={setEditHoverPick} + aiSelectionMode={selectionMode === "none" ? "" : selectionMode} + onAiSelectionDraftChange={handleAiSelectionDraftChange} + onAiSelectionComplete={onAiSelectionComplete} + /> + + + { + setActiveTool(tool); + setEditPicks([]); + setEditHoverPick(null); + setEditSelectionReady(false); + setAiSelectionDraft(null); + setHoveredReferenceId(""); + setSelectedReferenceIds([]); + setEditParameters(tool ? defaultCadEditParameters(tool) : {}); + setSelectionMode(tool ? "none" : "point"); + }} + onSelectionModeChange={(mode) => { + setSelectionMode(mode); + if (mode !== "lasso") setAiSelectionDraft(null); + }} + /> + viewerRef.current?.zoomToFit?.()} + onScreenshot={() => void viewerRef.current?.captureScreenshot?.({ filename: "cdsl-cad.png" })} + onParameters={() => setShowParameters(true)} + /> + {editPending || isGenerating ?
{editPending ? "正在应用 CDSL 编辑..." : "正在生成 CDSL 模型..."}
: null} + {activeToolDefinition ? ( +
+
{activeToolDefinition.label}
+
+ {activeToolDefinition.parameterFields.map((field) => ( + + ))} +
+
{editPicks.length}/{activeToolDefinition.pickKinds.length} 个几何点已选择
+
+ + +
+
+ ) : null} + {reveal > 0 ? : null} + {reveal > 0 ? : null} + {showParameters && result ? ( +
+ setShowParameters(false)} + onCommit={(id, value) => void commitParameters({ [id]: value })} + onReset={(values) => void commitParameters(values)} + downloads={[ + { 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) }, + { label: "REPORT", description: "Rebuild validation", url: encodeArtifactUrl(result.taskId, result.reportPath) }, + ]} + /> +
+ ) : null} +
+ ); +} + +function ViewerState({ icon, text, error = false }: { icon: React.ReactNode; text: string; error?: boolean }) { + return
{icon}{text}
; +} diff --git a/frontend/src/components/embedded-cad-toolbar.tsx b/frontend/src/components/embedded-cad-toolbar.tsx new file mode 100644 index 00000000..a175ee9a --- /dev/null +++ b/frontend/src/components/embedded-cad-toolbar.tsx @@ -0,0 +1,178 @@ +"use client"; + +import type { ReactNode } from "react"; +import { + CircleDot, + CircleDotDashed, + CornerDownRight, + Disc, + Drill, + Focus, + Grid2X2Plus, + LassoSelect, + MousePointerClick, + Orbit, + Radius, + SlidersHorizontal, + SquareDashed, + SquareSplitHorizontal, +} from "lucide-react"; +import { AiSelectionMode, CAD_EDIT_TOOLS } from "@/lib/cad-edit-tools"; + +const EDIT_TOOL_ICONS = { + add_hole: Drill, + add_counterbore: CircleDot, + add_countersink: CircleDotDashed, + add_slot: SquareSplitHorizontal, + add_pocket: SquareDashed, + add_circular_pocket: Disc, + add_hole_pattern: Grid2X2Plus, + add_chamfer: CornerDownRight, + add_fillet: Radius, +}; + +function ToolbarButton({ + label, + active = false, + disabled = false, + children, + onClick, +}: { + label: string; + active?: boolean; + disabled?: boolean; + children: ReactNode; + onClick?: () => void; +}) { + return ( + + ); +} + +function Divider() { + return