Initial commit
This commit is contained in:
+41
@@ -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*
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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"
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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},
|
||||
)
|
||||
@@ -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}
|
||||
@@ -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}")
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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))
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
+82
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
+82
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
+79
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
+79
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
+79
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
+83
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
+100
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
+81
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
+82
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
+89
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
+82
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
+79
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
+79
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
+79
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
+82
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
+83
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
+82
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
+81
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
+89
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
+82
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
+100
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
+85
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
+80
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
+80
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
+85
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
+92
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
+80
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
+79
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
+82
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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`.
|
||||
@@ -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"
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<body>(?:[ \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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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()
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
"""Local third-party-style runtimes kept inside this repository."""
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Local STEP to GLB preview conversion runtime."""
|
||||
|
||||
from .step_to_glb import step_to_glb
|
||||
|
||||
__all__ = ["step_to_glb"]
|
||||
+145
@@ -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("<fff", *vertex) for vertex in vertices)
|
||||
normal_bytes = b"".join(struct.pack("<fff", *normal) for normal in normals)
|
||||
indices = b"".join(struct.pack("<III", *triangle) for triangle in triangles)
|
||||
position_offset = 0
|
||||
normal_offset = len(positions)
|
||||
index_offset = normal_offset + len(normal_bytes)
|
||||
binary = _align_four(positions + normal_bytes + indices)
|
||||
minimum = [min(vertex[index] for vertex in vertices) for index in range(3)]
|
||||
maximum = [max(vertex[index] for vertex in vertices) for index in range(3)]
|
||||
|
||||
document = {
|
||||
"asset": {"version": "2.0", "generator": "cdsl-cad-local-preview"},
|
||||
"scene": 0,
|
||||
"scenes": [{"nodes": [0]}],
|
||||
"nodes": [{"mesh": 0, "name": step_path.stem}],
|
||||
"meshes": [{"primitives": [{"attributes": {"POSITION": 0, "NORMAL": 1}, "indices": 2, "material": 0}]}],
|
||||
"materials": [{
|
||||
"name": "CDSL CAD",
|
||||
"pbrMetallicRoughness": {
|
||||
"baseColorFactor": [0.59, 0.67, 0.73, 1.0],
|
||||
"metallicFactor": 0.22,
|
||||
"roughnessFactor": 0.43,
|
||||
},
|
||||
}],
|
||||
"buffers": [{"byteLength": len(binary)}],
|
||||
"bufferViews": [
|
||||
{"buffer": 0, "byteOffset": position_offset, "byteLength": len(positions), "target": 34962},
|
||||
{"buffer": 0, "byteOffset": normal_offset, "byteLength": len(normal_bytes), "target": 34962},
|
||||
{"buffer": 0, "byteOffset": index_offset, "byteLength": len(indices), "target": 34963},
|
||||
],
|
||||
"accessors": [
|
||||
{"bufferView": 0, "componentType": 5126, "count": len(vertices), "type": "VEC3", "min": minimum, "max": maximum},
|
||||
{"bufferView": 1, "componentType": 5126, "count": len(normals), "type": "VEC3"},
|
||||
{"bufferView": 2, "componentType": 5125, "count": len(triangles) * 3, "type": "SCALAR"},
|
||||
],
|
||||
}
|
||||
json_chunk = _align_four(json.dumps(document, separators=(",", ":")).encode("utf-8"), b" ")
|
||||
glb = b"glTF" + struct.pack("<II", 2, 12 + 8 + len(json_chunk) + 8 + len(binary))
|
||||
glb += struct.pack("<I4s", len(json_chunk), b"JSON") + json_chunk
|
||||
glb += struct.pack("<I4s", len(binary), b"BIN\x00") + binary
|
||||
glb_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
glb_path.write_bytes(glb)
|
||||
return {
|
||||
"vertices": len(vertices),
|
||||
"triangles": len(triangles),
|
||||
"bbox_min": minimum,
|
||||
"bbox_max": maximum,
|
||||
"topology_faces": topology_faces,
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
# Documentation
|
||||
|
||||
Architecture decisions, API contracts, engine usage, data formats, and
|
||||
development notes belong here.
|
||||
@@ -0,0 +1,10 @@
|
||||
# Frontend
|
||||
|
||||
The frontend is the agent workspace:
|
||||
|
||||
- Left pane: conversation, prompts, generation progress, and revision history.
|
||||
- Right pane: interactive STEP/mesh preview and model state.
|
||||
- Shared state: current prompt, generated CDSL, build status, preview asset,
|
||||
validation report, and selected model-library references.
|
||||
|
||||
Expected development entrypoint: `npm run dev`.
|
||||
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
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.
|
||||
@@ -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;
|
||||
Generated
+5056
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export default {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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<string, unknown> };
|
||||
|
||||
async function* parseSse(response: Response): AsyncGenerator<BackendEvent> {
|
||||
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<string, unknown> };
|
||||
} 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" } });
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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; } }
|
||||
@@ -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 (
|
||||
<html lang="zh-CN" data-ui-theme="light">
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { AgentStudio } from "@/components/agent-studio";
|
||||
|
||||
export default function Page() {
|
||||
return <AgentStudio />;
|
||||
}
|
||||
@@ -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<LoadState>("loading");
|
||||
const [conversationId, setConversationId] = useState("");
|
||||
const [selectedTaskId, setSelectedTaskId] = useState("");
|
||||
const [initialMessages, setInitialMessages] = useState<CadUIMessage[]>([]);
|
||||
const [config, setConfig] = useState<BackendConfig | null>(null);
|
||||
const [cadResult, setCadResult] = useState<CadResult | null>(null);
|
||||
const [lastError, setLastError] = useState("");
|
||||
const [attachments, setAttachments] = useState<CadAttachment[]>([]);
|
||||
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 <StudioLoading />;
|
||||
}
|
||||
if (loadState === "error") {
|
||||
return <StudioError message={lastError} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<AgentRuntime
|
||||
key={conversationId}
|
||||
conversationId={conversationId}
|
||||
selectedTaskId={selectedTaskId}
|
||||
providerId={providerId}
|
||||
modelId={modelId}
|
||||
initialMessages={initialMessages}
|
||||
onCadResult={handleResult}
|
||||
onCadError={handleError}
|
||||
>
|
||||
<StudioShell
|
||||
config={config}
|
||||
cadResult={cadResult}
|
||||
lastError={lastError}
|
||||
attachments={attachments}
|
||||
uploading={uploading}
|
||||
onUpload={handleUpload}
|
||||
theme={theme}
|
||||
onToggleTheme={toggleTheme}
|
||||
providerId={providerId}
|
||||
modelId={modelId}
|
||||
onProviderChange={setProviderId}
|
||||
onModelChange={setModelId}
|
||||
onCadResult={handleResult}
|
||||
onCadError={handleError}
|
||||
/>
|
||||
</AgentRuntime>
|
||||
);
|
||||
}
|
||||
|
||||
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<CadUIMessage>({
|
||||
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<CadUIMessage>({
|
||||
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<CadUIMessage>(chat, {
|
||||
joinStrategy: "none",
|
||||
});
|
||||
const stableRuntime = useMemo(() => stabilizeAssistantRuntimeSnapshots(runtime), [runtime]);
|
||||
|
||||
return <AssistantRuntimeProvider runtime={stableRuntime}>{children}</AssistantRuntimeProvider>;
|
||||
}
|
||||
|
||||
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<PropertyKey, unknown>,
|
||||
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<string, unknown>;
|
||||
const rightRecord = right as Record<string, unknown>;
|
||||
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 (
|
||||
<main className="studio-app">
|
||||
<header className="app-header">
|
||||
<div className="app-brand"><Box size={16} /><strong>CDSL CAD Studio</strong>{cadResult ? <span className="task-badge">{cadResult.taskId}</span> : null}</div>
|
||||
<div className="app-controls">
|
||||
<select aria-label="模型提供商" value={providerId} onChange={(event) => { const id = event.target.value; onProviderChange(id); onModelChange(config?.providers.find((item) => item.id === id)?.models[0]?.id || ""); }}>
|
||||
{config?.providers.map((item) => <option key={item.id} value={item.id}>{item.label}</option>)}
|
||||
</select>
|
||||
<select aria-label="模型" value={modelId} onChange={(event) => onModelChange(event.target.value)}>
|
||||
{provider?.models.map((model) => <option key={model.id} value={model.id}>{model.id}{model.vision ? " · Vision" : ""}</option>)}
|
||||
</select>
|
||||
<button className="theme-button" type="button" title="切换亮暗主题" onClick={onToggleTheme}>{theme === "light" ? <Moon size={16} /> : <Sun size={16} />}</button>
|
||||
</div>
|
||||
</header>
|
||||
{!config?.configured ? <div className="config-warning"><AlertCircle size={16} /><span>未配置模型环境变量,聊天会保留诊断但不会生成虚假模型。</span></div> : null}
|
||||
<div className="studio-main">
|
||||
<aside className="agent-pane"><AgentThread attachments={attachments} uploading={uploading} onUpload={onUpload} /></aside>
|
||||
<section className="preview-pane"><CadViewerPreview result={cadResult} isGenerating={running} lastError={lastError} theme={theme} onResult={onCadResult} onError={(message) => onCadError({ stage: "viewer", message })} /></section>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function StudioLoading() {
|
||||
return (
|
||||
<main className="boot-screen">
|
||||
<Loader2 className="spin" size={22} />
|
||||
<span>启动 CDSL CAD Studio</span>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function StudioError({ message }: { message: string }) {
|
||||
return (
|
||||
<main className="boot-screen boot-error">
|
||||
<AlertCircle size={22} />
|
||||
<span>{message}</span>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -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<HTMLInputElement>(null);
|
||||
return (
|
||||
<div className="agent-thread-shell">
|
||||
<div className="agent-pane-title"><MessageSquare size={16} /><span>Agent</span></div>
|
||||
<ThreadPrimitive.Root className="thread-root">
|
||||
<ThreadPrimitive.Viewport className="thread-viewport scrollbar-thin" autoScroll>
|
||||
{attachments.length ? <div className="attachment-list">{attachments.map((attachment) => <AttachmentCard key={attachment.id} attachment={attachment} />)}</div> : null}
|
||||
<ThreadPrimitive.Empty>
|
||||
<div className="thread-empty"><strong>描述要生成或修改的 CAD 模型</strong><span>Agent 会检索本地 CDSL 样本并生成可编辑的 CDSL 模型。</span></div>
|
||||
</ThreadPrimitive.Empty>
|
||||
<div className="message-list"><ThreadPrimitive.Messages components={{ UserMessage, AssistantMessage }} /></div>
|
||||
</ThreadPrimitive.Viewport>
|
||||
<Composer fileInput={fileInput} uploading={uploading} onUpload={onUpload} />
|
||||
</ThreadPrimitive.Root>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AttachmentCard({ attachment }: { attachment: CadAttachment }) {
|
||||
const Icon = attachment.kind === "image" ? FileImage : FileText;
|
||||
return <div className="attachment-card"><Icon size={14} /><span>{attachment.name}</span><small>{attachment.kind === "image" ? "视觉参考" : "文本参考"}</small></div>;
|
||||
}
|
||||
|
||||
function UserMessage() {
|
||||
return <MessagePrimitive.Root className="message-row user-row"><div className="message-bubble user-bubble"><MessagePrimitive.Parts components={{ Text: TextPart }} /></div></MessagePrimitive.Root>;
|
||||
}
|
||||
|
||||
function AssistantMessage() {
|
||||
return (
|
||||
<MessagePrimitive.Root className="message-row assistant-row">
|
||||
<div className="message-bubble assistant-bubble"><MessagePrimitive.Parts components={{ Text: TextPart, data: { by_name: { "cad-progress": CadProgressPart, "cad-result": CadResultPart, "cad-error": CadErrorPart } } }} /></div>
|
||||
</MessagePrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
function Composer({ fileInput, uploading, onUpload }: { fileInput: React.RefObject<HTMLInputElement | null>; uploading: boolean; onUpload: (files: FileList | null) => void }) {
|
||||
const running = useAuiState((state) => state.thread.isRunning);
|
||||
return (
|
||||
<div className="composer-shell">
|
||||
<input ref={fileInput} className="hidden" type="file" accept=".png,.jpg,.jpeg,.webp,.txt,.md,.csv,.json" multiple onChange={(event) => onUpload(event.target.files)} />
|
||||
<ComposerPrimitive.Root className="composer-root">
|
||||
<ComposerPrimitive.Input className="composer-input" placeholder="描述要生成或修改的 CAD 模型..." submitMode="enter" rows={4} />
|
||||
<div className="composer-footer"><span><Check size={14} /> Enter 发送,Shift + Enter 换行</span><div>{running ? <ComposerPrimitive.Cancel className="composer-action" title="停止生成"><Square size={15} /></ComposerPrimitive.Cancel> : <button type="button" className="composer-action" title="上传图片或文档" disabled={uploading} onClick={() => fileInput.current?.click()}>{uploading ? <Loader2 className="spin" size={15} /> : <Paperclip size={15} />}</button>}<ComposerPrimitive.Send className="composer-send" title="发送"><Send size={16} /></ComposerPrimitive.Send></div></div>
|
||||
</ComposerPrimitive.Root>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 <p className="message-text">{text}</p>;
|
||||
}
|
||||
|
||||
export function CadProgressPart({ data }: { data: CadProgress }) {
|
||||
const running = data.status === "running";
|
||||
return (
|
||||
<div className="cad-card cad-progress-card">
|
||||
<div className="cad-card-icon" data-status={data.status}>
|
||||
{running ? <Loader2 className="spin" size={16} /> : <CheckCircle2 size={16} />}
|
||||
</div>
|
||||
<div className="cad-card-body">
|
||||
<div className="cad-card-title">{data.label || data.step}</div>
|
||||
{data.message ? <div className="cad-card-copy">{data.message}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CadResultPart({ data }: { data: CadResult }) {
|
||||
const downloads = [
|
||||
["STEP", data.stepPath],
|
||||
["CDSL", data.cdslPath],
|
||||
["GLB", data.glbPath],
|
||||
["REPORT", data.reportPath],
|
||||
] as const;
|
||||
return (
|
||||
<div className="cad-card cad-result-card">
|
||||
<div className="cad-card-icon success">
|
||||
<CheckCircle2 size={16} />
|
||||
</div>
|
||||
<div className="cad-card-body">
|
||||
<div className="cad-card-title">{data.summary || "生成完成"}</div>
|
||||
<div className="cad-result-meta">
|
||||
<span>{data.engine}</span>
|
||||
<span>{data.revisionId}</span>
|
||||
<span>{data.referenceIds.length} references</span>
|
||||
</div>
|
||||
<div className="download-row">
|
||||
{downloads.map(([label, path]) => (
|
||||
<a key={label} className="download-link" href={encodeArtifactUrl(data.taskId, path)} download>
|
||||
<Download size={14} />
|
||||
{label}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CadErrorPart({ data }: { data: CadError }) {
|
||||
return (
|
||||
<div className="cad-card cad-error-card">
|
||||
<div className="cad-card-icon error">
|
||||
<AlertTriangle size={16} />
|
||||
</div>
|
||||
<div className="cad-card-body">
|
||||
<div className="cad-card-title">{data.stage || "生成失败"}</div>
|
||||
<div className="cad-card-copy">{data.message}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<typeof buildCdslSelectorRuntime>;
|
||||
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<string, unknown>;
|
||||
return {
|
||||
...base,
|
||||
materials: { ...(base.materials as Record<string, unknown>), defaultColor: "#d7dce2", roughness: 0.36, metalness: 0.18 },
|
||||
edges: { ...(base.edges as Record<string, unknown>), enabled: true, color: "#768493", opacity: 0.22, thickness: 1 },
|
||||
floor: { ...(base.floor as Record<string, unknown>), enabled: false, mode: "none" },
|
||||
environment: { ...(base.environment as Record<string, unknown>), enabled: false },
|
||||
};
|
||||
})();
|
||||
|
||||
function resultFromBackend(payload: Record<string, unknown>): 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<string, unknown> | 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<string, string | number>, 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 (
|
||||
<div className="pointer-events-none fixed inset-0 z-[26]" aria-hidden="true">
|
||||
<svg className="fixed inset-0 size-full overflow-visible">
|
||||
<polyline
|
||||
points={polylinePoints}
|
||||
fill="none"
|
||||
className="stroke-[var(--ui-accent)] opacity-[0.85]"
|
||||
strokeWidth="2.25"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EditToolPickOverlay({
|
||||
activeToolId,
|
||||
picks,
|
||||
hoverPick,
|
||||
parameters,
|
||||
}: {
|
||||
activeToolId: string;
|
||||
picks: Record<string, unknown>[];
|
||||
hoverPick: Record<string, unknown> | null;
|
||||
parameters: Record<string, string | number>;
|
||||
}) {
|
||||
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 (
|
||||
<div className="pointer-events-none fixed inset-0 z-[25]" aria-hidden="true">
|
||||
{(slot || circle || pocket || pattern) ? (
|
||||
<svg className="fixed inset-0 size-full overflow-visible">
|
||||
{slot ? <>
|
||||
<line x1={slot.start.x} y1={slot.start.y} x2={slot.end.x} y2={slot.end.y} className="stroke-[var(--ui-accent-soft)]" strokeWidth={slotWidth} strokeLinecap="round" />
|
||||
<line x1={slot.start.x} y1={slot.start.y} x2={slot.end.x} y2={slot.end.y} className={slot.preview ? "stroke-[var(--ui-accent)] opacity-65" : "stroke-[var(--ui-accent)] opacity-90"} strokeWidth="2.5" strokeLinecap="round" strokeDasharray={slot.preview ? "7 5" : "none"} />
|
||||
</> : null}
|
||||
{circle ? <>
|
||||
{circle.outer ? <circle cx={circle.point.x} cy={circle.point.y} r={circle.outer / 2} className="fill-[var(--ui-accent-soft)] stroke-[var(--ui-accent)]" strokeWidth="2" strokeDasharray={circle.dashed ? "7 4" : "none"} /> : null}
|
||||
<circle cx={circle.point.x} cy={circle.point.y} r={circle.inner / 2} className="fill-[var(--ui-accent-soft)] stroke-[var(--ui-accent)]" strokeWidth="2" strokeDasharray={circle.outer ? "4 4" : "5 4"} />
|
||||
</> : null}
|
||||
{pocket ? <rect x={pocket.point.x - pocket.width / 2} y={pocket.point.y - pocket.height / 2} width={pocket.width} height={pocket.height} rx="6" className="fill-[var(--ui-accent-soft)] stroke-[var(--ui-accent)]" strokeWidth="2" strokeDasharray="6 4" /> : null}
|
||||
{pattern ? Array.from({ length: pattern.rows * pattern.columns }, (_, index) => {
|
||||
const row = Math.floor(index / pattern.columns);
|
||||
const column = index % pattern.columns;
|
||||
return <circle key={index} cx={pattern.point.x + (column - (pattern.columns - 1) / 2) * pattern.pitchX} cy={pattern.point.y + (row - (pattern.rows - 1) / 2) * pattern.pitchY} r={pattern.diameter / 2} className="fill-[var(--ui-accent-soft)] stroke-[var(--ui-accent)]" strokeWidth="1.5" strokeDasharray="4 3" />;
|
||||
}) : null}
|
||||
</svg>
|
||||
) : null}
|
||||
{points.map(({ index, point, hover: isHover }) => (
|
||||
<div key={`${index}:${point.x}:${point.y}`} className="fixed" style={{ left: `${point.x}px`, top: `${point.y}px`, transform: "translate(-50%, -50%)" }}>
|
||||
<span className={`absolute left-1/2 top-1/2 h-12 w-px -translate-x-1/2 -translate-y-1/2 bg-[var(--ui-accent-muted)] ${isHover ? "opacity-60" : "opacity-100"}`} />
|
||||
<span className={`absolute left-1/2 top-1/2 h-px w-12 -translate-x-1/2 -translate-y-1/2 bg-[var(--ui-accent-muted)] ${isHover ? "opacity-60" : "opacity-100"}`} />
|
||||
<span className={`absolute left-1/2 top-1/2 size-7 -translate-x-1/2 -translate-y-1/2 rounded-full border bg-[var(--ui-accent-soft)] shadow-[0_0_22px_var(--ui-accent-muted)] ${isHover ? "border-[var(--ui-accent-border)] border-dashed opacity-75" : "border-[var(--ui-accent-border)]"}`} />
|
||||
<span className={`relative block rounded-full border border-[var(--ui-text-inverse)] bg-[var(--ui-accent)] shadow-[var(--ui-shadow-soft)] ${isHover ? "size-2 opacity-70" : "size-3"}`} />
|
||||
<span className="absolute left-4 top-3 whitespace-nowrap rounded-full border border-[var(--ui-accent-border)] bg-[var(--ui-glass-popover)] px-2 py-0.5 text-[10px] font-semibold text-[var(--ui-text-strong)] shadow-[var(--ui-shadow-soft)]">{isHover ? "预选" : editToolPickMarkerLabel(activeToolId, index)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CadViewerPreview({ result, isGenerating, lastError, theme, onResult, onError }: Props) {
|
||||
const viewerRef = useRef<{ captureScreenshot?: (options?: unknown) => Promise<void>; 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<LoadState>({ kind: "empty" });
|
||||
const [activeTool, setActiveTool] = useState("");
|
||||
const [editPicks, setEditPicks] = useState<Record<string, unknown>[]>([]);
|
||||
const [editParameters, setEditParameters] = useState<Record<string, string | number>>({});
|
||||
const [editHoverPick, setEditHoverPick] = useState<Record<string, unknown> | null>(null);
|
||||
const [editSelectionReady, setEditSelectionReady] = useState(false);
|
||||
const [aiSelectionDraft, setAiSelectionDraft] = useState<AiSelectionDraft>(null);
|
||||
const [hoveredReferenceId, setHoveredReferenceId] = useState("");
|
||||
const [selectedReferenceIds, setSelectedReferenceIds] = useState<string[]>([]);
|
||||
const [selectionMode, setSelectionMode] = useState<AiSelectionMode>("point");
|
||||
const [editPending, setEditPending] = useState(false);
|
||||
const [reveal, setReveal] = useState(0);
|
||||
const [showParameters, setShowParameters] = useState(false);
|
||||
const [parameters, setParameters] = useState<Record<string, unknown>[]>([]);
|
||||
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<string, unknown>[] };
|
||||
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<string, unknown>[]) => {
|
||||
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<string, unknown> & { 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<string, unknown> | 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<string, number>) => {
|
||||
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<string, unknown> & { 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 <ViewerState icon={<Box size={20} />} text="3D 预览等待模型" />;
|
||||
if (loadState.kind === "loading") return <ViewerState icon={<Loader2 className="spin" size={20} />} text="加载 CAD Viewer 资产..." />;
|
||||
if (loadState.kind === "error") return <ViewerState icon={<AlertTriangle size={20} />} text={lastError || "CAD Viewer 资产加载失败"} error />;
|
||||
if (loadState.kind !== "ready") return null;
|
||||
|
||||
return (
|
||||
<div className={`cad-deepbuild-viewer-bg relative h-full min-h-0 overflow-hidden ${theme === "dark" ? "cad-viewer-dark" : ""}`}>
|
||||
<CadViewer
|
||||
ref={viewerRef}
|
||||
meshData={loadState.meshData}
|
||||
modelKey={`${result?.taskId}:${result?.revisionId}`}
|
||||
renderFormat={RENDER_FORMAT.STEP}
|
||||
showEdges
|
||||
recomputeNormals={false}
|
||||
themeSettings={viewerTheme}
|
||||
theme={theme === "dark" ? { sceneBackground: "#0d0f11" } : { sceneBackground: "#fbfbfc" }}
|
||||
showViewPlane
|
||||
generationIntroKey={`${result?.taskId}:${reveal}`}
|
||||
// The enclosing studio owns completion feedback. Enabling CadViewer's
|
||||
// shader reveal here would play a second, separate animation.
|
||||
generationIntroEnabled={false}
|
||||
hoveredReferenceId={hoveredReferenceId}
|
||||
selectedReferenceIds={selectedReferenceIds}
|
||||
selectorRuntime={loadState.selectorRuntime}
|
||||
pickableFaces={pickableFaces}
|
||||
pickableEdges={[]}
|
||||
onHoverReferenceChange={setHoveredReferenceId}
|
||||
onActivateReference={(referenceId: string) => 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}
|
||||
/>
|
||||
<AiSelectionOverlay draft={aiSelectionDraft} />
|
||||
<EditToolPickOverlay
|
||||
activeToolId={activeTool}
|
||||
picks={editPicks}
|
||||
hoverPick={editHoverPick}
|
||||
parameters={editParameters}
|
||||
/>
|
||||
<EmbeddedCadEditToolbar
|
||||
activeToolId={activeTool}
|
||||
aiSelectionMode={selectionMode}
|
||||
disabled={!result || editPending}
|
||||
unavailableToolIds={["add_chamfer", "add_fillet"]}
|
||||
onSelectTool={(tool) => {
|
||||
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);
|
||||
}}
|
||||
/>
|
||||
<EmbeddedCadViewToolbar
|
||||
disabled={!result}
|
||||
onResetView={() => viewerRef.current?.zoomToFit?.()}
|
||||
onScreenshot={() => void viewerRef.current?.captureScreenshot?.({ filename: "cdsl-cad.png" })}
|
||||
onParameters={() => setShowParameters(true)}
|
||||
/>
|
||||
{editPending || isGenerating ? <div className="viewer-loading"><Loader2 className="spin" size={16} /><span>{editPending ? "正在应用 CDSL 编辑..." : "正在生成 CDSL 模型..."}</span></div> : null}
|
||||
{activeToolDefinition ? (
|
||||
<div className="absolute bottom-3 left-3 z-30 w-[236px] border border-[var(--ui-border)] bg-[var(--ui-glass-popover)] p-3 text-[var(--ui-text-strong)] shadow-[var(--ui-shadow-soft)] backdrop-blur" data-viewer-interaction-overlay="true">
|
||||
<div className="mb-2 text-xs font-semibold">{activeToolDefinition.label}</div>
|
||||
<div className="grid gap-2">
|
||||
{activeToolDefinition.parameterFields.map((field) => (
|
||||
<label className="grid grid-cols-[72px_minmax(0,1fr)] items-center gap-2 text-[11px] text-[var(--ui-text-muted)]" key={field.name}>
|
||||
<span>{field.label}</span>
|
||||
{field.type === "select" ? (
|
||||
<select
|
||||
className="h-7 min-w-0 border border-[var(--ui-border)] bg-[var(--ui-control-bg)] px-2 text-[11px] text-[var(--ui-text-strong)]"
|
||||
value={String(editParameters[field.name] ?? "")}
|
||||
onChange={(event) => setEditParameters((current) => ({ ...current, [field.name]: event.target.value }))}
|
||||
>
|
||||
{(field.options || []).map((option) => <option key={option} value={option}>{option}</option>)}
|
||||
</select>
|
||||
) : (
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
<input
|
||||
className="h-7 min-w-0 flex-1 border border-[var(--ui-border)] bg-[var(--ui-control-bg)] px-2 text-[11px] text-[var(--ui-text-strong)]"
|
||||
type={field.type === "number" ? "number" : "text"}
|
||||
min={field.min}
|
||||
step={field.step}
|
||||
value={String(editParameters[field.name] ?? "")}
|
||||
onChange={(event) => setEditParameters((current) => ({
|
||||
...current,
|
||||
[field.name]: field.type === "number" ? Number(event.target.value) : event.target.value,
|
||||
}))}
|
||||
/>
|
||||
{field.unit ? <span className="w-5 text-[10px]">{field.unit}</span> : null}
|
||||
</div>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-2 text-[10px] text-[var(--ui-text-subtle)]">{editPicks.length}/{activeToolDefinition.pickKinds.length} 个几何点已选择</div>
|
||||
<div className="mt-3 flex justify-end gap-2 border-t border-[var(--ui-border)] pt-2">
|
||||
<button
|
||||
type="button"
|
||||
className="h-7 border border-[var(--ui-border-strong)] px-2 text-[11px] text-[var(--ui-text-muted)] hover:bg-[var(--ui-control-hover)]"
|
||||
onClick={cancelEdit}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!editSelectionReady || editPending}
|
||||
className="h-7 border border-[var(--ui-accent-border)] bg-[var(--ui-accent-soft)] px-2 text-[11px] text-[var(--ui-accent-text)] transition hover:bg-[var(--ui-accent-muted)] disabled:cursor-not-allowed disabled:opacity-45"
|
||||
onClick={() => void submitEdit(activeTool, editPicks)}
|
||||
>
|
||||
应用
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{reveal > 0 ? <GenerationEdgeGlow /> : null}
|
||||
{reveal > 0 ? <GenerationSuccessReveal title="生成成功" detail={result?.summary || "CDSL 模型已完成"} /> : null}
|
||||
{showParameters && result ? (
|
||||
<div className="absolute inset-y-0 right-0 z-40 w-full max-w-[360px] shadow-[var(--ui-shadow-panel)]">
|
||||
<ParameterPanel
|
||||
parameters={parameters}
|
||||
pendingParameter={parameterPending}
|
||||
error={parameterError}
|
||||
onClose={() => 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) },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ViewerState({ icon, text, error = false }: { icon: React.ReactNode; text: string; error?: boolean }) {
|
||||
return <div className={`viewer-state ${error ? "viewer-state-error" : ""}`}>{icon}<span>{text}</span></div>;
|
||||
}
|
||||
@@ -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 (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={label}
|
||||
aria-pressed={active}
|
||||
title={label}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
className={[
|
||||
"grid size-7 place-items-center rounded-md border text-[var(--ui-text-muted)] transition",
|
||||
active
|
||||
? "border-[var(--ui-border-strong)] bg-[var(--ui-control-hover)] text-[var(--ui-text-strong)] shadow-[var(--ui-shadow-inset)]"
|
||||
: "border-transparent hover:border-[var(--ui-border-strong)] hover:bg-[var(--ui-control-hover)] hover:text-[var(--ui-text-strong)]",
|
||||
disabled ? "cursor-not-allowed opacity-40 hover:border-transparent hover:bg-transparent" : "",
|
||||
].join(" ")}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function Divider() {
|
||||
return <span className="mx-0.5 h-5 w-px shrink-0 bg-[var(--ui-border-strong)]" aria-hidden="true" />;
|
||||
}
|
||||
|
||||
export function EmbeddedCadEditToolbar({
|
||||
activeToolId,
|
||||
aiSelectionMode,
|
||||
disabled = false,
|
||||
unavailableToolIds = [],
|
||||
onSelectTool,
|
||||
onSelectionModeChange,
|
||||
}: {
|
||||
activeToolId: string;
|
||||
aiSelectionMode: AiSelectionMode;
|
||||
disabled?: boolean;
|
||||
unavailableToolIds?: string[];
|
||||
onSelectTool: (toolId: string) => void;
|
||||
onSelectionModeChange: (mode: AiSelectionMode) => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-viewer-interaction-overlay="true"
|
||||
className="pointer-events-auto absolute left-3 top-3 z-30 inline-flex h-10 max-w-[calc(100vw-1.5rem)] flex-nowrap items-center gap-1 overflow-x-auto rounded-md border border-[var(--ui-border)] bg-[var(--ui-glass-popover)] p-1.5 text-[var(--ui-text-muted)] shadow-[var(--ui-shadow-soft)] backdrop-blur"
|
||||
role="toolbar"
|
||||
aria-label="CAD 编辑工具"
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<ToolbarButton
|
||||
label="点选几何"
|
||||
active={aiSelectionMode === "point"}
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
onSelectTool("");
|
||||
onSelectionModeChange("point");
|
||||
}}
|
||||
>
|
||||
<MousePointerClick className="size-3.5" strokeWidth={2} aria-hidden="true" />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
label="圈选几何"
|
||||
active={aiSelectionMode === "lasso"}
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
onSelectTool("");
|
||||
onSelectionModeChange("lasso");
|
||||
}}
|
||||
>
|
||||
<LassoSelect className="size-3.5" strokeWidth={2} aria-hidden="true" />
|
||||
</ToolbarButton>
|
||||
|
||||
<Divider />
|
||||
|
||||
{CAD_EDIT_TOOLS.map((tool) => {
|
||||
const Icon = EDIT_TOOL_ICONS[tool.id as keyof typeof EDIT_TOOL_ICONS] || Drill;
|
||||
const active = activeToolId === tool.id;
|
||||
const unavailable = unavailableToolIds.includes(tool.id);
|
||||
return (
|
||||
<ToolbarButton
|
||||
key={tool.id}
|
||||
label={unavailable ? `${tool.label}(当前 engine 尚不支持)` : tool.label}
|
||||
active={active}
|
||||
disabled={disabled || unavailable}
|
||||
onClick={() => {
|
||||
if (active) {
|
||||
onSelectTool("");
|
||||
onSelectionModeChange("point");
|
||||
return;
|
||||
}
|
||||
onSelectTool(tool.id);
|
||||
onSelectionModeChange("none");
|
||||
}}
|
||||
>
|
||||
<Icon className="size-3.5" strokeWidth={2} aria-hidden="true" />
|
||||
</ToolbarButton>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function EmbeddedCadViewToolbar({
|
||||
disabled = false,
|
||||
onResetView,
|
||||
onScreenshot,
|
||||
onParameters,
|
||||
}: {
|
||||
disabled?: boolean;
|
||||
onResetView: () => void;
|
||||
onScreenshot?: () => void;
|
||||
onParameters?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-viewer-interaction-overlay="true"
|
||||
className="pointer-events-auto absolute right-3 top-3 z-30 inline-flex min-h-10 items-center gap-1 rounded-md border border-[var(--ui-border)] bg-[var(--ui-glass-popover)] p-1.5 text-[var(--ui-text-muted)] shadow-[var(--ui-shadow-soft)] backdrop-blur"
|
||||
role="toolbar"
|
||||
aria-label="CAD 预览工具"
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<ToolbarButton label="Orbit" disabled={disabled} onClick={onResetView}>
|
||||
<Orbit className="size-3.5" strokeWidth={2} aria-hidden="true" />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton label="Copy screenshot" disabled={disabled || !onScreenshot} onClick={onScreenshot}>
|
||||
<Focus className="size-3.5" strokeWidth={2} aria-hidden="true" />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton label="Parameters" disabled={disabled || !onParameters} onClick={onParameters}>
|
||||
<SlidersHorizontal className="size-3.5" strokeWidth={2} aria-hidden="true" />
|
||||
</ToolbarButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
export function GenerationEdgeGlow({ exiting = false }: { exiting?: boolean }) {
|
||||
if (typeof document === "undefined") return null;
|
||||
|
||||
return createPortal(
|
||||
<div className={`generation-edge-glow${exiting ? " is-converging" : ""}`} aria-hidden="true">
|
||||
<div className="generation-edge-glow__veil" />
|
||||
<div className="generation-edge-glow__band" />
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
"use client";
|
||||
|
||||
import type { CSSProperties } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
type SparkStyle = CSSProperties & { "--spark-index": number };
|
||||
|
||||
export function GenerationSuccessReveal({
|
||||
title,
|
||||
detail,
|
||||
}: {
|
||||
title: string;
|
||||
detail: string;
|
||||
}) {
|
||||
if (typeof document === "undefined") return null;
|
||||
|
||||
return createPortal(
|
||||
<div className="generation-success" role="status">
|
||||
<span className="generation-success__glow" aria-hidden="true" />
|
||||
<div className="generation-success__card">
|
||||
<span className="generation-success__badge" aria-hidden="true">
|
||||
<svg className="generation-success__check" viewBox="0 0 52 52">
|
||||
<circle cx="26" cy="26" r="23" />
|
||||
<path d="M15 27 l7.5 7.5 L37 18" />
|
||||
</svg>
|
||||
</span>
|
||||
<span className="generation-success__copy">
|
||||
<span className="generation-success__kicker">LINGXIN · 几何内核</span>
|
||||
<strong data-text={title}>{title}</strong>
|
||||
<span className="generation-success__detail">{detail}</span>
|
||||
</span>
|
||||
{[0, 1, 2].map((sparkIndex) => (
|
||||
<i
|
||||
className="generation-success__spark"
|
||||
style={{ "--spark-index": sparkIndex } as SparkStyle}
|
||||
aria-hidden="true"
|
||||
key={sparkIndex}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import * as Collapsible from "@radix-ui/react-collapsible";
|
||||
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
|
||||
import { ChevronDown, ChevronUp, Download, Loader2, RefreshCcw, X } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CadamSlider } from "@/components/ui/cadam-slider";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type EditableParameter = Record<string, unknown>;
|
||||
|
||||
type NormalizedParameter = {
|
||||
id: string;
|
||||
name: string;
|
||||
displayName: string;
|
||||
group: string;
|
||||
groupDisplayName: string;
|
||||
value: number;
|
||||
defaultValue: number;
|
||||
min: number | null;
|
||||
max: number | null;
|
||||
step: number;
|
||||
precision: number;
|
||||
unit: string;
|
||||
editable: boolean;
|
||||
editState: string;
|
||||
};
|
||||
|
||||
type DownloadFormat = {
|
||||
label: string;
|
||||
description: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
function numberValue(value: unknown) {
|
||||
const numeric = typeof value === "number" ? value : Number(value);
|
||||
return Number.isFinite(numeric) ? numeric : null;
|
||||
}
|
||||
|
||||
function parameterToView(parameter: EditableParameter): NormalizedParameter | null {
|
||||
const id = String(parameter.id || parameter.name || "").trim();
|
||||
const name = String(parameter.name || id).trim();
|
||||
const value = numberValue(parameter.value);
|
||||
const declaredRange = Array.isArray(parameter.range) ? parameter.range : [];
|
||||
const min = numberValue(parameter.min ?? parameter.minimum ?? declaredRange[0]);
|
||||
const max = numberValue(parameter.max ?? parameter.maximum ?? declaredRange[1]);
|
||||
if (!name || value === null) return null;
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
displayName: String(parameter.display_name || parameter.displayName || parameter.label || name),
|
||||
group: String(parameter.group || "dimensions"),
|
||||
groupDisplayName: String(parameter.group_display_name || parameter.groupDisplayName || "尺寸"),
|
||||
value,
|
||||
defaultValue: numberValue(parameter.default_value ?? parameter.defaultValue) ?? value,
|
||||
min,
|
||||
max,
|
||||
step: numberValue(parameter.step) ?? 1,
|
||||
precision: Math.max(0, numberValue(parameter.precision) ?? 2),
|
||||
unit: String(parameter.unit || ""),
|
||||
editable: parameter.editable === true || parameter.edit_state === "declared_unvalidated",
|
||||
editState: String(parameter.edit_state || "unvalidated"),
|
||||
};
|
||||
}
|
||||
|
||||
function formatValue(value: number, precision: number) {
|
||||
if (!Number.isFinite(value)) return "";
|
||||
if (Number.isInteger(value) && precision === 0) return String(value);
|
||||
return Number(value.toFixed(precision)).toString();
|
||||
}
|
||||
|
||||
function clampValue(value: number, min: number, max: number) {
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
||||
|
||||
function visualRange(parameter: NormalizedParameter) {
|
||||
if (parameter.min !== null && parameter.max !== null && parameter.max > parameter.min) {
|
||||
return { min: parameter.min, max: parameter.max };
|
||||
}
|
||||
const span = Math.max(Math.abs(parameter.value), 1);
|
||||
return {
|
||||
min: Math.max(0, parameter.value - span),
|
||||
max: parameter.value + span,
|
||||
};
|
||||
}
|
||||
|
||||
function sectionDisplayName(group: { id: string; displayName: string }) {
|
||||
return group.id === "dimensions" || group.displayName === "尺寸"
|
||||
? "Dimensions"
|
||||
: group.displayName;
|
||||
}
|
||||
|
||||
export function ParameterPanel({
|
||||
parameters,
|
||||
downloads = [],
|
||||
pendingParameter,
|
||||
error,
|
||||
onClose,
|
||||
onCommit,
|
||||
onReset,
|
||||
}: {
|
||||
parameters: EditableParameter[];
|
||||
downloads?: DownloadFormat[];
|
||||
pendingParameter?: string;
|
||||
error?: string;
|
||||
onClose: () => void;
|
||||
onCommit: (parameter: string, value: number) => void;
|
||||
onReset: (values: Record<string, number>) => void;
|
||||
}) {
|
||||
const normalizedParameters = useMemo(
|
||||
() => parameters.map(parameterToView).filter((value): value is NormalizedParameter => Boolean(value)),
|
||||
[parameters],
|
||||
);
|
||||
const [drafts, setDrafts] = useState<Record<string, string>>({});
|
||||
const [openGroups, setOpenGroups] = useState<Record<string, boolean>>({});
|
||||
const [selectedDownload, setSelectedDownload] = useState(downloads[0]?.label || "");
|
||||
|
||||
useEffect(() => {
|
||||
setDrafts(Object.fromEntries(
|
||||
normalizedParameters.map((parameter) => [
|
||||
parameter.name,
|
||||
formatValue(parameter.value, parameter.precision),
|
||||
]),
|
||||
));
|
||||
setOpenGroups((current) => {
|
||||
const next = { ...current };
|
||||
for (const parameter of normalizedParameters) {
|
||||
if (next[parameter.group] === undefined) next[parameter.group] = true;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [normalizedParameters]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!downloads.some((download) => download.label === selectedDownload)) {
|
||||
setSelectedDownload(downloads[0]?.label || "");
|
||||
}
|
||||
}, [downloads, selectedDownload]);
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
const groups = new Map<string, { id: string; displayName: string; parameters: NormalizedParameter[] }>();
|
||||
for (const parameter of normalizedParameters) {
|
||||
const group = groups.get(parameter.group) || {
|
||||
id: parameter.group,
|
||||
displayName: parameter.groupDisplayName,
|
||||
parameters: [],
|
||||
};
|
||||
group.parameters.push(parameter);
|
||||
groups.set(parameter.group, group);
|
||||
}
|
||||
return Array.from(groups.values());
|
||||
}, [normalizedParameters]);
|
||||
|
||||
const resetParameters = () => {
|
||||
const values = Object.fromEntries(
|
||||
normalizedParameters
|
||||
.filter((parameter) => parameter.editable && parameter.value !== parameter.defaultValue)
|
||||
.map((parameter) => [parameter.id, parameter.defaultValue]),
|
||||
);
|
||||
if (Object.keys(values).length) {
|
||||
onReset(values);
|
||||
return;
|
||||
}
|
||||
setDrafts(Object.fromEntries(
|
||||
normalizedParameters.map((parameter) => [
|
||||
parameter.name,
|
||||
formatValue(parameter.defaultValue, parameter.precision),
|
||||
]),
|
||||
));
|
||||
};
|
||||
|
||||
const selectedDownloadItem = downloads.find((download) => download.label === selectedDownload) || downloads[0] || null;
|
||||
|
||||
const commitValue = (parameter: NormalizedParameter, rawValue: string | number) => {
|
||||
const numeric = numberValue(rawValue);
|
||||
if (!parameter.editable) {
|
||||
setDrafts((current) => ({
|
||||
...current,
|
||||
[parameter.name]: formatValue(parameter.value, parameter.precision),
|
||||
}));
|
||||
return;
|
||||
}
|
||||
if (numeric === null) {
|
||||
setDrafts((current) => ({
|
||||
...current,
|
||||
[parameter.name]: formatValue(parameter.value, parameter.precision),
|
||||
}));
|
||||
return;
|
||||
}
|
||||
const clamped = parameter.min !== null && parameter.max !== null
|
||||
? clampValue(numeric, parameter.min, parameter.max)
|
||||
: numeric;
|
||||
const formatted = formatValue(clamped, parameter.precision);
|
||||
setDrafts((current) => ({ ...current, [parameter.name]: formatted }));
|
||||
if (Number(formatted) !== parameter.value) {
|
||||
onCommit(parameter.id, Number(formatted));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<aside
|
||||
className="flex h-full min-h-0 w-full flex-col overflow-hidden border-l border-[var(--ui-border)] bg-[var(--ui-panel)] text-[var(--ui-text-strong)] shadow-[var(--ui-shadow-panel)]"
|
||||
data-viewer-interaction-overlay="true"
|
||||
role="dialog"
|
||||
aria-label="可编辑参数"
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="flex h-14 shrink-0 items-center justify-between border-b border-[var(--ui-border-strong)] bg-[var(--ui-panel)] px-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="text-lg font-semibold tracking-tight text-[var(--ui-text-strong)]">Parameters</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
aria-label="恢复全部默认参数"
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
className="size-8 rounded-full text-[var(--ui-text-strong)] hover:bg-[var(--ui-control-hover)]"
|
||||
disabled={Boolean(pendingParameter) || !normalizedParameters.length}
|
||||
onClick={resetParameters}
|
||||
>
|
||||
<RefreshCcw className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
aria-label="隐藏参数面板"
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
className="size-8 rounded-full text-[var(--ui-text-strong)] hover:bg-[var(--ui-control-hover)]"
|
||||
onClick={onClose}
|
||||
>
|
||||
<X className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-auto px-6 py-6 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||
{error ? (
|
||||
<div className="mb-3 rounded border border-[var(--ui-error-border)] bg-[var(--ui-error-bg)] px-3 py-2 text-[11px] leading-4 text-[var(--ui-error-text)]">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
{normalizedParameters.length ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
{grouped.map((group) => (
|
||||
<Collapsible.Root
|
||||
key={group.id}
|
||||
open={openGroups[group.id] ?? true}
|
||||
onOpenChange={(open) => setOpenGroups((current) => ({ ...current, [group.id]: open }))}
|
||||
>
|
||||
<Collapsible.Trigger className="group flex w-full items-center justify-between gap-2 rounded-md py-1 text-left text-xs font-semibold text-[var(--ui-text-strong)] transition-colors focus:outline-none">
|
||||
<span className="flex items-center gap-2">
|
||||
{sectionDisplayName(group)}
|
||||
<span className="text-[10px] text-[var(--ui-text-subtle)]">{group.parameters.length}</span>
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"size-3.5 text-[var(--ui-text-subtle)] transition-all duration-200 group-hover:text-[var(--ui-text-strong)]",
|
||||
openGroups[group.id] !== false && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
</Collapsible.Trigger>
|
||||
<Collapsible.Content className="mt-3 flex flex-col gap-3">
|
||||
{group.parameters.map((parameter) => {
|
||||
const draft = drafts[parameter.name] ?? formatValue(parameter.value, parameter.precision);
|
||||
const numericDraft = numberValue(draft) ?? parameter.value;
|
||||
const disabled = Boolean(pendingParameter) || !parameter.editable;
|
||||
const range = visualRange(parameter);
|
||||
const pending = pendingParameter === parameter.id;
|
||||
return (
|
||||
<div
|
||||
className="grid w-full grid-cols-[80px_minmax(0,1fr)] items-center gap-3"
|
||||
key={parameter.name}
|
||||
>
|
||||
<label
|
||||
className="min-w-0 overflow-hidden text-ellipsis text-xs font-normal leading-4 text-[var(--ui-text-muted)]"
|
||||
htmlFor={`parameter-${parameter.name}`}
|
||||
title={parameter.displayName}
|
||||
>
|
||||
<span className="block truncate">{parameter.displayName}</span>
|
||||
</label>
|
||||
<div className="flex w-full min-w-0 items-center gap-3">
|
||||
<CadamSlider
|
||||
id={`${parameter.name}-slider`}
|
||||
name={parameter.name}
|
||||
min={range.min}
|
||||
max={range.max}
|
||||
step={parameter.step}
|
||||
value={[clampValue(numericDraft, range.min, range.max)]}
|
||||
defaultValue={[clampValue(parameter.defaultValue, range.min, range.max)]}
|
||||
disabled={disabled}
|
||||
visualOnly={false}
|
||||
defaultMarkerStyle="line"
|
||||
onValueChange={([nextValue]) => {
|
||||
setDrafts((current) => ({
|
||||
...current,
|
||||
[parameter.name]: formatValue(nextValue, parameter.precision),
|
||||
}));
|
||||
}}
|
||||
onValueCommit={([nextValue]) => commitValue(parameter, nextValue)}
|
||||
/>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<div className="relative">
|
||||
<Input
|
||||
id={`parameter-${parameter.name}`}
|
||||
autoComplete="off"
|
||||
className="h-6 w-14 rounded-lg border-0 bg-[var(--ui-control-bg)] px-2 pr-2 text-left text-xs text-[var(--ui-text-strong)] transition-colors selection:bg-[var(--ui-selection-bg)] selection:text-[var(--ui-text-inverse)] focus-visible:ring-0 hover:bg-[var(--ui-control-hover)]"
|
||||
disabled={disabled}
|
||||
inputMode="decimal"
|
||||
max={range.max}
|
||||
min={range.min}
|
||||
step={parameter.step}
|
||||
type="number"
|
||||
value={draft}
|
||||
onBlur={() => commitValue(parameter, draft)}
|
||||
onChange={(event) => setDrafts((current) => ({
|
||||
...current,
|
||||
[parameter.name]: event.target.value,
|
||||
}))}
|
||||
onFocus={(event) => event.target.select()}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.currentTarget.blur();
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
setDrafts((current) => ({
|
||||
...current,
|
||||
[parameter.name]: formatValue(parameter.value, parameter.precision),
|
||||
}));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{pending ? (
|
||||
<Loader2 className="absolute right-1.5 top-1.5 size-3 animate-spin text-[var(--ui-accent)]" />
|
||||
) : null}
|
||||
</div>
|
||||
<span className="ml-1 w-6 text-left text-xs text-[var(--ui-text-muted)]">
|
||||
{parameter.unit}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Collapsible.Content>
|
||||
</Collapsible.Root>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded border border-[var(--ui-border-strong)] bg-[var(--ui-panel-raised)] px-3 py-3 text-xs leading-5 text-[var(--ui-text-muted)]">
|
||||
当前模型没有可直接编辑的参数。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-col gap-4 border-t border-[var(--ui-border-strong)] px-6 py-6">
|
||||
<div className="flex">
|
||||
<a
|
||||
aria-disabled={!selectedDownloadItem}
|
||||
className={cn(
|
||||
"inline-flex h-12 flex-1 items-center justify-center rounded-l-lg rounded-r-none bg-[var(--ui-accent)] text-sm font-semibold text-[var(--ui-accent-contrast)] transition-colors hover:bg-[var(--ui-accent-hover)]",
|
||||
!selectedDownloadItem && "pointer-events-none opacity-50",
|
||||
)}
|
||||
href={selectedDownloadItem?.url || "#"}
|
||||
download
|
||||
>
|
||||
<Download className="mr-2 size-4" />
|
||||
{selectedDownloadItem?.label || "STEP"}
|
||||
</a>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger asChild>
|
||||
<Button
|
||||
aria-label="选择下载格式"
|
||||
className="h-12 w-12 rounded-l-none rounded-r-lg border-l border-[var(--ui-border-muted)] bg-[var(--ui-accent)] p-0 text-[var(--ui-accent-contrast)] hover:bg-[var(--ui-accent-hover)]"
|
||||
disabled={!downloads.length}
|
||||
>
|
||||
<ChevronUp className="size-4" />
|
||||
</Button>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
align="end"
|
||||
className="z-50 w-64 rounded-md border border-[var(--ui-border)] bg-[var(--ui-popover)] p-1 shadow-[var(--ui-shadow-popover)]"
|
||||
>
|
||||
{downloads.map((download) => (
|
||||
<DropdownMenu.Item
|
||||
key={download.label}
|
||||
className="flex cursor-pointer items-center rounded px-3 py-2 text-[var(--ui-text-strong)] outline-none hover:bg-[var(--ui-control-hover)]"
|
||||
onSelect={() => setSelectedDownload(download.label)}
|
||||
>
|
||||
<span className="text-sm">.{download.label}</span>
|
||||
<span className="ml-3 text-xs text-[var(--ui-text-muted)]">{download.description}</span>
|
||||
</DropdownMenu.Item>
|
||||
))}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
"use client";
|
||||
|
||||
import type { ButtonHTMLAttributes } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type ButtonVariant = "primary" | "outline" | "ghost";
|
||||
type ButtonSize = "sm" | "icon-sm";
|
||||
|
||||
const variantClassNames: Record<ButtonVariant, string> = {
|
||||
primary: "border-[var(--ui-accent-border)] bg-[var(--ui-accent-soft)] text-[var(--ui-accent-text)] hover:bg-[var(--ui-accent-muted)]",
|
||||
outline: "border-[var(--ui-border-strong)] text-[var(--ui-text-muted)] hover:bg-[var(--ui-control-hover)] hover:text-[var(--ui-text-strong)]",
|
||||
ghost: "border-transparent text-[var(--ui-text-muted)] hover:border-[var(--ui-border-strong)] hover:bg-[var(--ui-control-hover)] hover:text-[var(--ui-text-strong)]",
|
||||
};
|
||||
|
||||
const sizeClassNames: Record<ButtonSize, string> = {
|
||||
sm: "h-8 px-3 text-xs",
|
||||
"icon-sm": "size-6 p-0 text-sm",
|
||||
};
|
||||
|
||||
export function Button({
|
||||
className,
|
||||
variant = "outline",
|
||||
size = "sm",
|
||||
type = "button",
|
||||
...props
|
||||
}: ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type={type}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center rounded-md border font-medium outline-none transition focus-visible:ring-2 focus-visible:ring-[var(--ui-focus-ring)] disabled:pointer-events-none disabled:opacity-50",
|
||||
variantClassNames[variant],
|
||||
sizeClassNames[size],
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user