Spaces:
Sleeping
Sleeping
Sync from forma-3d-review@b6d4687f5d0f2e5303758c97095ea7e38e740723
Browse files- .dockerignore +14 -0
- .env.example +7 -0
- .gitattributes +4 -0
- Dockerfile +35 -0
- README.md +10 -6
- config/__init__.py +0 -0
- config/kmvss_rules.yaml +40 -0
- config/settings.py +62 -0
- data/Print1.png +3 -0
- data/Print2.png +3 -0
- data/Print3.png +3 -0
- data/turbine_exterior.names.json +4 -0
- data/turbine_exterior.step +0 -0
- data/turbine_interior.names.json +192 -0
- data/turbine_interior.step +3 -0
- main.py +8 -0
- pyproject.toml +37 -0
- src/__init__.py +0 -0
- src/api/__init__.py +0 -0
- src/api/job_manager.py +274 -0
- src/api/main.py +41 -0
- src/api/mesh_export.py +137 -0
- src/api/router.py +378 -0
- src/api/samples.py +75 -0
- src/api/schemas.py +132 -0
- src/comparison/__init__.py +0 -0
- src/comparison/continuity.py +207 -0
- src/comparison/distance.py +101 -0
- src/comparison/gap_overlap.py +156 -0
- src/compliance/__init__.py +0 -0
- src/compliance/kmvss_checker.py +189 -0
- src/compliance/rule_loader.py +66 -0
- src/geometry/__init__.py +0 -0
- src/geometry/face_extractor.py +107 -0
- src/geometry/measurement.py +130 -0
- src/geometry/splitter.py +84 -0
- src/geometry/tessellator.py +133 -0
- src/loader/__init__.py +0 -0
- src/loader/assembly_tree.py +324 -0
- src/loader/step_loader.py +169 -0
- src/mcp/__init__.py +0 -0
- src/mcp/server.py +30 -0
- src/mcp/tools.py +155 -0
- uv.lock +0 -0
.dockerignore
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.venv/
|
| 2 |
+
.pytest_cache/
|
| 3 |
+
__pycache__/
|
| 4 |
+
*.pyc
|
| 5 |
+
.git
|
| 6 |
+
.github
|
| 7 |
+
jobs/
|
| 8 |
+
tests/
|
| 9 |
+
docs/
|
| 10 |
+
*.md
|
| 11 |
+
!README.md
|
| 12 |
+
.env
|
| 13 |
+
.env.*
|
| 14 |
+
.python-version
|
.env.example
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Comma-separated list of allowed origins. Use "*" only for local dev.
|
| 2 |
+
ALLOWED_ORIGINS=http://localhost:5173
|
| 3 |
+
|
| 4 |
+
# Override the directory that `/api/samples` scans for example STEP files.
|
| 5 |
+
# Defaults to `<repo>/data`. In FastAPI Cloud you can leave this unset if the
|
| 6 |
+
# data folder is included in the deployed source tree.
|
| 7 |
+
# SAMPLES_DIR=/app/data
|
.gitattributes
CHANGED
|
@@ -33,3 +33,7 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
data/Print1.png filter=lfs diff=lfs merge=lfs -text
|
| 37 |
+
data/Print2.png filter=lfs diff=lfs merge=lfs -text
|
| 38 |
+
data/Print3.png filter=lfs diff=lfs merge=lfs -text
|
| 39 |
+
data/turbine_interior.step filter=lfs diff=lfs merge=lfs -text
|
Dockerfile
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
+
# System libs required by cadquery-ocp (OpenCascade) and open3d.
|
| 4 |
+
RUN apt-get update \
|
| 5 |
+
&& apt-get install -y --no-install-recommends \
|
| 6 |
+
libgl1 \
|
| 7 |
+
libglib2.0-0 \
|
| 8 |
+
libxrender1 \
|
| 9 |
+
libxext6 \
|
| 10 |
+
libsm6 \
|
| 11 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 12 |
+
|
| 13 |
+
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
|
| 14 |
+
|
| 15 |
+
ENV UV_LINK_MODE=copy \
|
| 16 |
+
UV_COMPILE_BYTECODE=1 \
|
| 17 |
+
PYTHONUNBUFFERED=1 \
|
| 18 |
+
PYTHONDONTWRITEBYTECODE=1
|
| 19 |
+
|
| 20 |
+
WORKDIR /app
|
| 21 |
+
|
| 22 |
+
# Install dependencies first for layer caching.
|
| 23 |
+
COPY pyproject.toml uv.lock ./
|
| 24 |
+
RUN uv sync --frozen --no-install-project --no-dev
|
| 25 |
+
|
| 26 |
+
# Copy the application source.
|
| 27 |
+
COPY . .
|
| 28 |
+
|
| 29 |
+
# Install the project itself (editable) so backend/data/ is resolvable.
|
| 30 |
+
RUN uv sync --frozen --no-dev
|
| 31 |
+
|
| 32 |
+
ENV PORT=8080
|
| 33 |
+
EXPOSE 8080
|
| 34 |
+
|
| 35 |
+
CMD ["sh", "-c", "uv run uvicorn src.api.main:app --host 0.0.0.0 --port ${PORT}"]
|
README.md
CHANGED
|
@@ -1,11 +1,15 @@
|
|
| 1 |
---
|
| 2 |
-
title: Forma
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
|
|
|
| 7 |
pinned: false
|
| 8 |
-
license: mit
|
| 9 |
---
|
| 10 |
|
| 11 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: Forma 3D Review API
|
| 3 |
+
emoji: 🔧
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: indigo
|
| 6 |
sdk: docker
|
| 7 |
+
app_port: 8080
|
| 8 |
pinned: false
|
|
|
|
| 9 |
---
|
| 10 |
|
| 11 |
+
# Forma 3D Review API
|
| 12 |
+
|
| 13 |
+
Backend for the 3D CAD assembly review tool. Built with FastAPI + cadquery-ocp.
|
| 14 |
+
Source of truth lives at <https://github.com/DShomin/forma-3d-review>; this
|
| 15 |
+
Space is synced from the `backend/` subtree on every push to `main`.
|
config/__init__.py
ADDED
|
File without changes
|
config/kmvss_rules.yaml
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
rules:
|
| 2 |
+
- id: "turbine-nacelle-diameter"
|
| 3 |
+
name: "Nacelle Maximum Diameter"
|
| 4 |
+
description: "Nacelle housing diameter must not exceed specified limit"
|
| 5 |
+
type: bounding_box
|
| 6 |
+
params:
|
| 7 |
+
target_name_pattern: "nacelle|housing|cowl"
|
| 8 |
+
axis: "x"
|
| 9 |
+
max_mm: 5000.0
|
| 10 |
+
severity: error
|
| 11 |
+
|
| 12 |
+
- id: "blade-clearance"
|
| 13 |
+
name: "Blade Tip Clearance"
|
| 14 |
+
description: "Minimum clearance between blade tips and nacelle inner wall"
|
| 15 |
+
type: clearance
|
| 16 |
+
params:
|
| 17 |
+
part_a_pattern: "blade|rotor"
|
| 18 |
+
part_b_pattern: "nacelle|housing|shroud"
|
| 19 |
+
min_clearance_mm: 10.0
|
| 20 |
+
severity: error
|
| 21 |
+
|
| 22 |
+
- id: "hub-height"
|
| 23 |
+
name: "Hub Assembly Height"
|
| 24 |
+
description: "Hub assembly height within specification"
|
| 25 |
+
type: bounding_box
|
| 26 |
+
params:
|
| 27 |
+
target_name_pattern: "hub"
|
| 28 |
+
axis: "z"
|
| 29 |
+
max_mm: 3000.0
|
| 30 |
+
severity: warning
|
| 31 |
+
|
| 32 |
+
- id: "shaft-alignment"
|
| 33 |
+
name: "Main Shaft Concentricity"
|
| 34 |
+
description: "Main shaft center deviation from assembly center"
|
| 35 |
+
type: bounding_box
|
| 36 |
+
params:
|
| 37 |
+
target_name_pattern: "shaft|axle"
|
| 38 |
+
axis: "y"
|
| 39 |
+
max_mm: 2000.0
|
| 40 |
+
severity: warning
|
config/settings.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Configuration settings for the CAD review tool."""
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
|
| 4 |
+
from pydantic import Field, field_validator
|
| 5 |
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
| 6 |
+
|
| 7 |
+
_BACKEND_ROOT = Path(__file__).resolve().parents[1]
|
| 8 |
+
_REPO_ROOT = _BACKEND_ROOT.parent
|
| 9 |
+
_SAMPLES_CANDIDATES = (_BACKEND_ROOT / "data", _REPO_ROOT / "data")
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def _default_samples_dir() -> Path:
|
| 13 |
+
for candidate in _SAMPLES_CANDIDATES:
|
| 14 |
+
if candidate.exists():
|
| 15 |
+
return candidate
|
| 16 |
+
return _SAMPLES_CANDIDATES[0]
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class TessellationSettings(BaseSettings):
|
| 20 |
+
deflection_factor: float = Field(default=0.1)
|
| 21 |
+
min_deflection_mm: float = Field(default=0.001)
|
| 22 |
+
max_deflection_mm: float = Field(default=0.1)
|
| 23 |
+
angular_deflection_rad: float = Field(default=0.5)
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class SamplingSettings(BaseSettings):
|
| 27 |
+
num_samples: int = Field(default=100_000)
|
| 28 |
+
seed: int = Field(default=42)
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
class ToleranceSettings(BaseSettings):
|
| 32 |
+
g0_position_mm: float = Field(default=0.05)
|
| 33 |
+
g1_tangent_deg: float = Field(default=0.3)
|
| 34 |
+
g2_curvature_pct: float = Field(default=3.0)
|
| 35 |
+
gap_tolerance_mm: float = Field(default=0.05)
|
| 36 |
+
overlap_tolerance_mm: float = Field(default=0.05)
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
class SplitterSettings(BaseSettings):
|
| 40 |
+
exterior_patterns: list[str] = Field(
|
| 41 |
+
default=["inlet front", "inlet back"],
|
| 42 |
+
description="Part name patterns for exterior group (case-insensitive substring match)",
|
| 43 |
+
)
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
class Settings(BaseSettings):
|
| 47 |
+
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
| 48 |
+
|
| 49 |
+
tessellation: TessellationSettings = Field(default_factory=TessellationSettings)
|
| 50 |
+
sampling: SamplingSettings = Field(default_factory=SamplingSettings)
|
| 51 |
+
tolerance: ToleranceSettings = Field(default_factory=ToleranceSettings)
|
| 52 |
+
splitter: SplitterSettings = Field(default_factory=SplitterSettings)
|
| 53 |
+
|
| 54 |
+
samples_dir: Path = Field(default_factory=_default_samples_dir)
|
| 55 |
+
allowed_origins: list[str] = Field(default_factory=lambda: ["*"])
|
| 56 |
+
|
| 57 |
+
@field_validator("allowed_origins", mode="before")
|
| 58 |
+
@classmethod
|
| 59 |
+
def _split_origins(cls, v: object) -> object:
|
| 60 |
+
if isinstance(v, str):
|
| 61 |
+
return [s.strip() for s in v.split(",") if s.strip()]
|
| 62 |
+
return v
|
data/Print1.png
ADDED
|
Git LFS Details
|
data/Print2.png
ADDED
|
Git LFS Details
|
data/Print3.png
ADDED
|
Git LFS Details
|
data/turbine_exterior.names.json
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[
|
| 2 |
+
"Inlet Front v9",
|
| 3 |
+
"Inlet Back v3"
|
| 4 |
+
]
|
data/turbine_exterior.step
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
data/turbine_interior.names.json
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[
|
| 2 |
+
"F1 Fan v6",
|
| 3 |
+
"Center Bolt v4",
|
| 4 |
+
"Assembled Shaft v12",
|
| 5 |
+
"skf_bearing_nup_2306_ecml_2 v2",
|
| 6 |
+
"Shaft Front v17",
|
| 7 |
+
"Parafuso sextavado conformado ANSI B18.2.3.2M - M10x1.5 x 16 A\u00e7o grau 2 Liso v1",
|
| 8 |
+
"Shaft Mid v3",
|
| 9 |
+
"Shaft Back v5",
|
| 10 |
+
"skf_bearing_nup_2306_ecml_2 v2",
|
| 11 |
+
"skf_bearing_nup_2306_ecml_2_02",
|
| 12 |
+
"skf_bearing_nup_2306_ecml_2_03",
|
| 13 |
+
"skf_bearing_nup_2306_ecml_2_01",
|
| 14 |
+
"Ring F1 v5",
|
| 15 |
+
"Ring F2 v6",
|
| 16 |
+
"CF1 v6",
|
| 17 |
+
"CR1 v7",
|
| 18 |
+
"Compressor Shell v6",
|
| 19 |
+
"CF2 v5",
|
| 20 |
+
"CR2 v7",
|
| 21 |
+
"CF3 v5",
|
| 22 |
+
"CR3 v2",
|
| 23 |
+
"CF4 v3",
|
| 24 |
+
"CR4 v2",
|
| 25 |
+
"CC Shell v6",
|
| 26 |
+
"CC Fan v3",
|
| 27 |
+
"HP Shell v11",
|
| 28 |
+
"HP Fan v5",
|
| 29 |
+
"LP Fan1 v3",
|
| 30 |
+
"LP Fan2 v2",
|
| 31 |
+
"LP Fan3 v2",
|
| 32 |
+
"Exhaust Fan v4",
|
| 33 |
+
"Exhaust Bolt v4",
|
| 34 |
+
"Exhaust Shell Front v17",
|
| 35 |
+
"Exhaust Shell Back v3",
|
| 36 |
+
"Part_37",
|
| 37 |
+
"Part_38",
|
| 38 |
+
"Part_39",
|
| 39 |
+
"Part_40",
|
| 40 |
+
"Part_41",
|
| 41 |
+
"Part_42",
|
| 42 |
+
"Part_43",
|
| 43 |
+
"Part_44",
|
| 44 |
+
"Part_45",
|
| 45 |
+
"Part_46",
|
| 46 |
+
"Part_47",
|
| 47 |
+
"Part_48",
|
| 48 |
+
"Part_49",
|
| 49 |
+
"Part_50",
|
| 50 |
+
"Part_51",
|
| 51 |
+
"Part_52",
|
| 52 |
+
"Part_53",
|
| 53 |
+
"Part_54",
|
| 54 |
+
"Part_55",
|
| 55 |
+
"Part_56",
|
| 56 |
+
"Part_57",
|
| 57 |
+
"Part_58",
|
| 58 |
+
"Part_59",
|
| 59 |
+
"Part_60",
|
| 60 |
+
"Part_61",
|
| 61 |
+
"Part_62",
|
| 62 |
+
"Part_63",
|
| 63 |
+
"Part_64",
|
| 64 |
+
"Part_65",
|
| 65 |
+
"Part_66",
|
| 66 |
+
"Part_67",
|
| 67 |
+
"Part_68",
|
| 68 |
+
"Part_69",
|
| 69 |
+
"Part_70",
|
| 70 |
+
"Part_71",
|
| 71 |
+
"Part_72",
|
| 72 |
+
"Part_73",
|
| 73 |
+
"Part_74",
|
| 74 |
+
"Part_75",
|
| 75 |
+
"Part_76",
|
| 76 |
+
"Part_77",
|
| 77 |
+
"Part_78",
|
| 78 |
+
"Part_79",
|
| 79 |
+
"Part_80",
|
| 80 |
+
"Part_81",
|
| 81 |
+
"Part_82",
|
| 82 |
+
"Part_83",
|
| 83 |
+
"Part_84",
|
| 84 |
+
"Part_85",
|
| 85 |
+
"Part_86",
|
| 86 |
+
"Part_87",
|
| 87 |
+
"Part_88",
|
| 88 |
+
"Part_89",
|
| 89 |
+
"Part_90",
|
| 90 |
+
"Part_91",
|
| 91 |
+
"Part_92",
|
| 92 |
+
"Part_93",
|
| 93 |
+
"Part_94",
|
| 94 |
+
"Part_95",
|
| 95 |
+
"Part_96",
|
| 96 |
+
"Part_97",
|
| 97 |
+
"Part_98",
|
| 98 |
+
"Part_99",
|
| 99 |
+
"Part_100",
|
| 100 |
+
"Part_101",
|
| 101 |
+
"Part_102",
|
| 102 |
+
"Part_103",
|
| 103 |
+
"Part_104",
|
| 104 |
+
"Part_105",
|
| 105 |
+
"Part_106",
|
| 106 |
+
"Part_107",
|
| 107 |
+
"Part_108",
|
| 108 |
+
"Part_109",
|
| 109 |
+
"Part_110",
|
| 110 |
+
"Part_111",
|
| 111 |
+
"Part_112",
|
| 112 |
+
"Part_113",
|
| 113 |
+
"Part_114",
|
| 114 |
+
"Part_115",
|
| 115 |
+
"Part_116",
|
| 116 |
+
"Part_117",
|
| 117 |
+
"Part_118",
|
| 118 |
+
"Part_119",
|
| 119 |
+
"Part_120",
|
| 120 |
+
"Part_121",
|
| 121 |
+
"Part_122",
|
| 122 |
+
"Part_123",
|
| 123 |
+
"Part_124",
|
| 124 |
+
"Part_125",
|
| 125 |
+
"Part_126",
|
| 126 |
+
"Part_127",
|
| 127 |
+
"Part_128",
|
| 128 |
+
"Part_129",
|
| 129 |
+
"Part_130",
|
| 130 |
+
"Part_131",
|
| 131 |
+
"Part_132",
|
| 132 |
+
"Part_133",
|
| 133 |
+
"Part_134",
|
| 134 |
+
"Part_135",
|
| 135 |
+
"Part_136",
|
| 136 |
+
"Part_137",
|
| 137 |
+
"Part_138",
|
| 138 |
+
"Part_139",
|
| 139 |
+
"Part_140",
|
| 140 |
+
"Part_141",
|
| 141 |
+
"Part_142",
|
| 142 |
+
"Part_143",
|
| 143 |
+
"Part_144",
|
| 144 |
+
"Part_145",
|
| 145 |
+
"Part_146",
|
| 146 |
+
"Part_147",
|
| 147 |
+
"Part_148",
|
| 148 |
+
"Part_149",
|
| 149 |
+
"Part_150",
|
| 150 |
+
"Part_151",
|
| 151 |
+
"Part_152",
|
| 152 |
+
"Part_153",
|
| 153 |
+
"Part_154",
|
| 154 |
+
"Part_155",
|
| 155 |
+
"Part_156",
|
| 156 |
+
"Part_157",
|
| 157 |
+
"Part_158",
|
| 158 |
+
"Part_159",
|
| 159 |
+
"Part_160",
|
| 160 |
+
"Part_161",
|
| 161 |
+
"Part_162",
|
| 162 |
+
"Part_163",
|
| 163 |
+
"Part_164",
|
| 164 |
+
"Part_165",
|
| 165 |
+
"Part_166",
|
| 166 |
+
"Part_167",
|
| 167 |
+
"Part_168",
|
| 168 |
+
"Part_169",
|
| 169 |
+
"Part_170",
|
| 170 |
+
"Part_171",
|
| 171 |
+
"Part_172",
|
| 172 |
+
"Part_173",
|
| 173 |
+
"Part_174",
|
| 174 |
+
"Part_175",
|
| 175 |
+
"Part_176",
|
| 176 |
+
"Part_177",
|
| 177 |
+
"Part_178",
|
| 178 |
+
"Part_179",
|
| 179 |
+
"Part_180",
|
| 180 |
+
"Part_181",
|
| 181 |
+
"Part_182",
|
| 182 |
+
"Part_183",
|
| 183 |
+
"Part_184",
|
| 184 |
+
"Part_185",
|
| 185 |
+
"Part_186",
|
| 186 |
+
"Part_187",
|
| 187 |
+
"Part_188",
|
| 188 |
+
"Part_189",
|
| 189 |
+
"Part_190",
|
| 190 |
+
"Part_191",
|
| 191 |
+
"Part_192"
|
| 192 |
+
]
|
data/turbine_interior.step
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:46f0fc9d1de95c28e756ec57e2b1284bcd892a6605908485e80d2be8245408d6
|
| 3 |
+
size 52856628
|
main.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""FastAPI Cloud entry point.
|
| 2 |
+
|
| 3 |
+
FastAPI Cloud's default `fastapi run` looks for an `app` object in
|
| 4 |
+
`main.py` at the project root. Re-export it from `src.api.main`.
|
| 5 |
+
"""
|
| 6 |
+
from src.api.main import app
|
| 7 |
+
|
| 8 |
+
__all__ = ["app"]
|
pyproject.toml
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[project]
|
| 2 |
+
name = "cad-review"
|
| 3 |
+
version = "0.1.0"
|
| 4 |
+
description = "3D CAD assembly review tool with exterior/interior splitting and compliance checking"
|
| 5 |
+
requires-python = ">=3.11,<3.12"
|
| 6 |
+
dependencies = [
|
| 7 |
+
"cadquery-ocp>=7.7",
|
| 8 |
+
"numpy>=1.26",
|
| 9 |
+
"open3d>=0.18",
|
| 10 |
+
"pydantic-settings>=2.0",
|
| 11 |
+
"fastapi[standard]>=0.115",
|
| 12 |
+
"uvicorn[standard]>=0.30",
|
| 13 |
+
"python-multipart>=0.0.9",
|
| 14 |
+
"pygltflib>=1.16",
|
| 15 |
+
"pyyaml>=6.0",
|
| 16 |
+
"rich>=13.0",
|
| 17 |
+
]
|
| 18 |
+
|
| 19 |
+
[project.scripts]
|
| 20 |
+
cad-review-api = "src.api.main:run_server"
|
| 21 |
+
|
| 22 |
+
[build-system]
|
| 23 |
+
requires = ["hatchling"]
|
| 24 |
+
build-backend = "hatchling.build"
|
| 25 |
+
|
| 26 |
+
[tool.hatch.build.targets.wheel]
|
| 27 |
+
packages = ["src", "config"]
|
| 28 |
+
|
| 29 |
+
[tool.pytest.ini_options]
|
| 30 |
+
testpaths = ["tests"]
|
| 31 |
+
pythonpath = ["."]
|
| 32 |
+
|
| 33 |
+
[dependency-groups]
|
| 34 |
+
dev = [
|
| 35 |
+
"pytest>=9.0.2",
|
| 36 |
+
"httpx>=0.27",
|
| 37 |
+
]
|
src/__init__.py
ADDED
|
File without changes
|
src/api/__init__.py
ADDED
|
File without changes
|
src/api/job_manager.py
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Asynchronous job management for the review pipeline."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import asyncio
|
| 5 |
+
import logging
|
| 6 |
+
import uuid
|
| 7 |
+
from concurrent.futures import ThreadPoolExecutor
|
| 8 |
+
from dataclasses import dataclass, field
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
from typing import Any
|
| 11 |
+
|
| 12 |
+
import numpy as np
|
| 13 |
+
|
| 14 |
+
from config.settings import Settings
|
| 15 |
+
from src.api.mesh_export import mesh_to_glb
|
| 16 |
+
from src.geometry.tessellator import tessellate_faces
|
| 17 |
+
from src.loader.assembly_tree import AssemblyNode, extract_assembly_tree, extract_assembly_from_shape
|
| 18 |
+
from src.loader.step_loader import load_step
|
| 19 |
+
|
| 20 |
+
logger = logging.getLogger(__name__)
|
| 21 |
+
|
| 22 |
+
PIPELINE_STEPS = [
|
| 23 |
+
"loading",
|
| 24 |
+
"assembly_tree",
|
| 25 |
+
"tessellation",
|
| 26 |
+
"classification",
|
| 27 |
+
"mesh_export",
|
| 28 |
+
]
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
@dataclass
|
| 32 |
+
class PartMeshData:
|
| 33 |
+
"""Mesh data for a single part."""
|
| 34 |
+
part_id: str
|
| 35 |
+
vertices: np.ndarray
|
| 36 |
+
triangles: np.ndarray
|
| 37 |
+
glb: bytes
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
@dataclass
|
| 41 |
+
class GroupInfo:
|
| 42 |
+
"""Info about a part group (exterior / interior)."""
|
| 43 |
+
name: str
|
| 44 |
+
part_ids: list[str]
|
| 45 |
+
part_names: list[str]
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
@dataclass
|
| 49 |
+
class JobData:
|
| 50 |
+
"""Stores all data associated with a review job."""
|
| 51 |
+
|
| 52 |
+
job_id: str
|
| 53 |
+
exterior_path: Path
|
| 54 |
+
interior_path: Path
|
| 55 |
+
status: str = "pending" # pending, running, completed, failed
|
| 56 |
+
error: str | None = None
|
| 57 |
+
|
| 58 |
+
# Step tracking
|
| 59 |
+
steps: dict[str, str] = field(default_factory=dict)
|
| 60 |
+
|
| 61 |
+
# Results
|
| 62 |
+
assembly_tree: AssemblyNode | None = None
|
| 63 |
+
part_meshes: dict[str, PartMeshData] = field(default_factory=dict)
|
| 64 |
+
group_meshes: dict[str, PartMeshData] = field(default_factory=dict)
|
| 65 |
+
groups: dict[str, GroupInfo] = field(default_factory=dict)
|
| 66 |
+
exterior_step_info: Any = None
|
| 67 |
+
interior_step_info: Any = None
|
| 68 |
+
|
| 69 |
+
# SSE subscribers
|
| 70 |
+
_listeners: list[asyncio.Queue] = field(default_factory=list)
|
| 71 |
+
_loop: asyncio.AbstractEventLoop | None = None
|
| 72 |
+
|
| 73 |
+
def __post_init__(self) -> None:
|
| 74 |
+
for step in PIPELINE_STEPS:
|
| 75 |
+
self.steps[step] = "pending"
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
class JobManager:
|
| 79 |
+
"""Manages review jobs with thread-based execution and SSE streaming."""
|
| 80 |
+
|
| 81 |
+
def __init__(self, upload_dir: Path | None = None) -> None:
|
| 82 |
+
self._jobs: dict[str, JobData] = {}
|
| 83 |
+
self._executor = ThreadPoolExecutor(max_workers=2)
|
| 84 |
+
self._upload_dir = upload_dir or Path("/tmp/cad-review-uploads")
|
| 85 |
+
self._upload_dir.mkdir(parents=True, exist_ok=True)
|
| 86 |
+
self._settings = Settings()
|
| 87 |
+
|
| 88 |
+
@property
|
| 89 |
+
def upload_dir(self) -> Path:
|
| 90 |
+
return self._upload_dir
|
| 91 |
+
|
| 92 |
+
@property
|
| 93 |
+
def settings(self) -> Settings:
|
| 94 |
+
return self._settings
|
| 95 |
+
|
| 96 |
+
def create_job(self, exterior_path: Path, interior_path: Path) -> JobData:
|
| 97 |
+
"""Create a new review job with exterior and interior STEP files."""
|
| 98 |
+
job_id = uuid.uuid4().hex[:12]
|
| 99 |
+
job = JobData(job_id=job_id, exterior_path=exterior_path, interior_path=interior_path)
|
| 100 |
+
self._jobs[job_id] = job
|
| 101 |
+
return job
|
| 102 |
+
|
| 103 |
+
def get_job(self, job_id: str) -> JobData | None:
|
| 104 |
+
return self._jobs.get(job_id)
|
| 105 |
+
|
| 106 |
+
def subscribe(self, job: JobData) -> asyncio.Queue:
|
| 107 |
+
queue: asyncio.Queue = asyncio.Queue()
|
| 108 |
+
job._listeners.append(queue)
|
| 109 |
+
return queue
|
| 110 |
+
|
| 111 |
+
def unsubscribe(self, job: JobData, queue: asyncio.Queue) -> None:
|
| 112 |
+
if queue in job._listeners:
|
| 113 |
+
job._listeners.remove(queue)
|
| 114 |
+
|
| 115 |
+
def _emit(self, job: JobData, event: dict) -> None:
|
| 116 |
+
loop = job._loop
|
| 117 |
+
if loop is None:
|
| 118 |
+
return
|
| 119 |
+
for q in job._listeners:
|
| 120 |
+
loop.call_soon_threadsafe(q.put_nowait, event)
|
| 121 |
+
|
| 122 |
+
def _emit_step(self, job: JobData, step: str, status: str, pct: int = 0, **kwargs) -> None:
|
| 123 |
+
job.steps[step] = status
|
| 124 |
+
event = {"step": step, "status": status, "progress_pct": pct, **kwargs}
|
| 125 |
+
self._emit(job, event)
|
| 126 |
+
|
| 127 |
+
def start_job(self, job: JobData, loop: asyncio.AbstractEventLoop) -> None:
|
| 128 |
+
"""Start job execution in a background thread."""
|
| 129 |
+
job.status = "running"
|
| 130 |
+
job._loop = loop
|
| 131 |
+
self._executor.submit(self._run_pipeline, job)
|
| 132 |
+
|
| 133 |
+
def _extract_tree(self, step_info, id_prefix: str = "") -> AssemblyNode:
|
| 134 |
+
"""Extract assembly tree from a loaded STEP file, trying XDE first."""
|
| 135 |
+
tree = None
|
| 136 |
+
if step_info.shape_tool is not None:
|
| 137 |
+
try:
|
| 138 |
+
tree = extract_assembly_tree(step_info.shape_tool)
|
| 139 |
+
except Exception as e:
|
| 140 |
+
logger.warning("XDE tree extraction failed: %s, falling back to shape topology", e)
|
| 141 |
+
if tree is None:
|
| 142 |
+
tree = extract_assembly_from_shape(
|
| 143 |
+
step_info.shape, step_info.path.stem,
|
| 144 |
+
reader=step_info.reader, doc=step_info.doc,
|
| 145 |
+
step_path=step_info.path,
|
| 146 |
+
id_prefix=id_prefix,
|
| 147 |
+
)
|
| 148 |
+
return tree
|
| 149 |
+
|
| 150 |
+
def _run_pipeline(self, job: JobData) -> None:
|
| 151 |
+
"""Execute the review pipeline in a worker thread."""
|
| 152 |
+
try:
|
| 153 |
+
settings = self._settings
|
| 154 |
+
|
| 155 |
+
# Step 1: Load both STEP files
|
| 156 |
+
self._emit_step(job, "loading", "running")
|
| 157 |
+
exterior_info = load_step(job.exterior_path)
|
| 158 |
+
job.exterior_step_info = exterior_info
|
| 159 |
+
self._emit_step(job, "loading", "running", 50)
|
| 160 |
+
interior_info = load_step(job.interior_path)
|
| 161 |
+
job.interior_step_info = interior_info
|
| 162 |
+
self._emit_step(job, "loading", "completed", 100)
|
| 163 |
+
|
| 164 |
+
# Step 2: Extract assembly trees and build combined root
|
| 165 |
+
self._emit_step(job, "assembly_tree", "running")
|
| 166 |
+
exterior_tree = self._extract_tree(exterior_info, id_prefix="ext_")
|
| 167 |
+
exterior_tree.name = f"Exterior - {exterior_tree.name}"
|
| 168 |
+
interior_tree = self._extract_tree(interior_info, id_prefix="int_")
|
| 169 |
+
interior_tree.name = f"Interior - {interior_tree.name}"
|
| 170 |
+
|
| 171 |
+
# Tag all leaves with their classification
|
| 172 |
+
for leaf in exterior_tree.iter_leaves():
|
| 173 |
+
leaf.classification = "exterior"
|
| 174 |
+
for leaf in interior_tree.iter_leaves():
|
| 175 |
+
leaf.classification = "interior"
|
| 176 |
+
|
| 177 |
+
# Build combined root
|
| 178 |
+
tree = AssemblyNode(
|
| 179 |
+
id="root",
|
| 180 |
+
name="Combined Assembly",
|
| 181 |
+
is_assembly=True,
|
| 182 |
+
children=[exterior_tree, interior_tree],
|
| 183 |
+
)
|
| 184 |
+
job.assembly_tree = tree
|
| 185 |
+
self._emit_step(job, "assembly_tree", "completed", 100)
|
| 186 |
+
|
| 187 |
+
# Step 3: Tessellate each leaf part
|
| 188 |
+
self._emit_step(job, "tessellation", "running")
|
| 189 |
+
leaves = list(tree.iter_leaves())
|
| 190 |
+
total_leaves = len(leaves)
|
| 191 |
+
|
| 192 |
+
for idx, leaf in enumerate(leaves):
|
| 193 |
+
if leaf.shape is None or leaf.shape.IsNull():
|
| 194 |
+
continue
|
| 195 |
+
try:
|
| 196 |
+
verts, tris = tessellate_faces(
|
| 197 |
+
leaf.shape, settings.tessellation,
|
| 198 |
+
target_tolerance_mm=settings.tolerance.g0_position_mm,
|
| 199 |
+
)
|
| 200 |
+
leaf.num_faces = len(tris)
|
| 201 |
+
pct = int((idx + 1) / total_leaves * 100)
|
| 202 |
+
self._emit_step(job, "tessellation", "running", pct)
|
| 203 |
+
|
| 204 |
+
# Store mesh data
|
| 205 |
+
job.part_meshes[leaf.id] = PartMeshData(
|
| 206 |
+
part_id=leaf.id,
|
| 207 |
+
vertices=verts,
|
| 208 |
+
triangles=tris,
|
| 209 |
+
glb=b"", # Generate later
|
| 210 |
+
)
|
| 211 |
+
except Exception as e:
|
| 212 |
+
logger.warning("Failed to tessellate part '%s': %s", leaf.name, e)
|
| 213 |
+
|
| 214 |
+
self._emit_step(job, "tessellation", "completed", 100)
|
| 215 |
+
|
| 216 |
+
# Step 4: Classification (by file origin - already tagged above)
|
| 217 |
+
self._emit_step(job, "classification", "running")
|
| 218 |
+
for group_name, subtree in [("exterior", exterior_tree), ("interior", interior_tree)]:
|
| 219 |
+
group_leaves = list(subtree.iter_leaves())
|
| 220 |
+
job.groups[group_name] = GroupInfo(
|
| 221 |
+
name=group_name,
|
| 222 |
+
part_ids=[leaf.id for leaf in group_leaves if leaf.shape is not None],
|
| 223 |
+
part_names=[leaf.name for leaf in group_leaves if leaf.shape is not None],
|
| 224 |
+
)
|
| 225 |
+
self._emit_step(job, "classification", "completed", 100)
|
| 226 |
+
|
| 227 |
+
# Step 5: Export meshes to GLB (individual + combined groups)
|
| 228 |
+
self._emit_step(job, "mesh_export", "running")
|
| 229 |
+
for part_id, mesh_data in job.part_meshes.items():
|
| 230 |
+
try:
|
| 231 |
+
mesh_data.glb = mesh_to_glb(mesh_data.vertices, mesh_data.triangles)
|
| 232 |
+
except Exception as e:
|
| 233 |
+
logger.warning("Failed to export GLB for part %s: %s", part_id, e)
|
| 234 |
+
|
| 235 |
+
# Create combined group meshes
|
| 236 |
+
for group_name, group_info in job.groups.items():
|
| 237 |
+
group_verts_list = []
|
| 238 |
+
group_tris_list = []
|
| 239 |
+
vert_offset = 0
|
| 240 |
+
for pid in group_info.part_ids:
|
| 241 |
+
md = job.part_meshes.get(pid)
|
| 242 |
+
if md is None:
|
| 243 |
+
continue
|
| 244 |
+
group_verts_list.append(md.vertices)
|
| 245 |
+
group_tris_list.append(md.triangles + vert_offset)
|
| 246 |
+
vert_offset += len(md.vertices)
|
| 247 |
+
if group_verts_list:
|
| 248 |
+
combined_verts = np.concatenate(group_verts_list, axis=0)
|
| 249 |
+
combined_tris = np.concatenate(group_tris_list, axis=0)
|
| 250 |
+
try:
|
| 251 |
+
combined_glb = mesh_to_glb(combined_verts, combined_tris)
|
| 252 |
+
job.group_meshes[group_name] = PartMeshData(
|
| 253 |
+
part_id=f"{group_name}_combined",
|
| 254 |
+
vertices=combined_verts,
|
| 255 |
+
triangles=combined_tris,
|
| 256 |
+
glb=combined_glb,
|
| 257 |
+
)
|
| 258 |
+
except Exception as e:
|
| 259 |
+
logger.warning("Failed to create combined GLB for %s: %s", group_name, e)
|
| 260 |
+
|
| 261 |
+
self._emit_step(job, "mesh_export", "completed", 100, overall_status="completed")
|
| 262 |
+
|
| 263 |
+
job.status = "completed"
|
| 264 |
+
|
| 265 |
+
except Exception as e:
|
| 266 |
+
logger.exception("Job %s failed", job.job_id)
|
| 267 |
+
job.status = "failed"
|
| 268 |
+
job.error = str(e)
|
| 269 |
+
self._emit(job, {
|
| 270 |
+
"step": "error",
|
| 271 |
+
"status": "failed",
|
| 272 |
+
"error": str(e),
|
| 273 |
+
"overall_status": "failed",
|
| 274 |
+
})
|
src/api/main.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""FastAPI application entry point."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import logging
|
| 5 |
+
import uvicorn
|
| 6 |
+
from fastapi import FastAPI
|
| 7 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 8 |
+
|
| 9 |
+
from config.settings import Settings
|
| 10 |
+
from src.api.router import router
|
| 11 |
+
|
| 12 |
+
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(levelname)s %(message)s")
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def create_app() -> FastAPI:
|
| 16 |
+
app = FastAPI(
|
| 17 |
+
title="CAD Review API",
|
| 18 |
+
description="3D CAD assembly review with exterior/interior splitting and compliance checking",
|
| 19 |
+
version="0.1.0",
|
| 20 |
+
)
|
| 21 |
+
|
| 22 |
+
settings = Settings()
|
| 23 |
+
allow_credentials = settings.allowed_origins != ["*"]
|
| 24 |
+
|
| 25 |
+
app.add_middleware(
|
| 26 |
+
CORSMiddleware,
|
| 27 |
+
allow_origins=settings.allowed_origins,
|
| 28 |
+
allow_credentials=allow_credentials,
|
| 29 |
+
allow_methods=["*"],
|
| 30 |
+
allow_headers=["*"],
|
| 31 |
+
)
|
| 32 |
+
|
| 33 |
+
app.include_router(router)
|
| 34 |
+
return app
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
app = create_app()
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def run_server() -> None:
|
| 41 |
+
uvicorn.run("src.api.main:app", host="0.0.0.0", port=8000, reload=True)
|
src/api/mesh_export.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Export mesh data (vertices + triangles) to glTF binary (.glb) format."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import numpy as np
|
| 6 |
+
import pygltflib
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def _compute_normals(vertices: np.ndarray, triangles: np.ndarray) -> np.ndarray:
|
| 10 |
+
"""Compute smooth per-vertex normals by averaging face normals."""
|
| 11 |
+
normals = np.zeros_like(vertices, dtype=np.float32)
|
| 12 |
+
|
| 13 |
+
v0 = vertices[triangles[:, 0]]
|
| 14 |
+
v1 = vertices[triangles[:, 1]]
|
| 15 |
+
v2 = vertices[triangles[:, 2]]
|
| 16 |
+
|
| 17 |
+
face_normals = np.cross(v1 - v0, v2 - v0)
|
| 18 |
+
|
| 19 |
+
# Accumulate face normals to each vertex
|
| 20 |
+
for i in range(3):
|
| 21 |
+
np.add.at(normals, triangles[:, i], face_normals)
|
| 22 |
+
|
| 23 |
+
# Normalize
|
| 24 |
+
lengths = np.linalg.norm(normals, axis=1, keepdims=True)
|
| 25 |
+
lengths = np.maximum(lengths, 1e-10)
|
| 26 |
+
normals /= lengths
|
| 27 |
+
|
| 28 |
+
return normals.astype(np.float32)
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def mesh_to_glb(vertices: np.ndarray, triangles: np.ndarray) -> bytes:
|
| 32 |
+
"""Convert vertices and triangles to a binary glTF (.glb) buffer.
|
| 33 |
+
|
| 34 |
+
Includes computed vertex normals for proper lighting/shading.
|
| 35 |
+
|
| 36 |
+
Args:
|
| 37 |
+
vertices: Mesh vertices with shape (N, 3), float64.
|
| 38 |
+
triangles: Triangle indices with shape (M, 3), int32.
|
| 39 |
+
|
| 40 |
+
Returns:
|
| 41 |
+
Bytes of the .glb file.
|
| 42 |
+
"""
|
| 43 |
+
vertices_f32 = vertices.astype(np.float32)
|
| 44 |
+
triangles_u32 = triangles.astype(np.uint32)
|
| 45 |
+
normals_f32 = _compute_normals(vertices_f32, triangles_u32)
|
| 46 |
+
|
| 47 |
+
# Byte buffers
|
| 48 |
+
vert_bytes = vertices_f32.tobytes()
|
| 49 |
+
norm_bytes = normals_f32.tobytes()
|
| 50 |
+
tri_bytes = triangles_u32.tobytes()
|
| 51 |
+
|
| 52 |
+
# Align each buffer to 4 bytes
|
| 53 |
+
def pad4(b: bytes) -> bytes:
|
| 54 |
+
pad = (4 - len(b) % 4) % 4
|
| 55 |
+
return b + b"\x00" * pad
|
| 56 |
+
|
| 57 |
+
vert_padded = pad4(vert_bytes)
|
| 58 |
+
norm_padded = pad4(norm_bytes)
|
| 59 |
+
|
| 60 |
+
blob = vert_padded + norm_padded + tri_bytes
|
| 61 |
+
total_bytes = len(blob)
|
| 62 |
+
|
| 63 |
+
norm_offset = len(vert_padded)
|
| 64 |
+
tri_offset = norm_offset + len(norm_padded)
|
| 65 |
+
|
| 66 |
+
v_min = vertices_f32.min(axis=0).tolist()
|
| 67 |
+
v_max = vertices_f32.max(axis=0).tolist()
|
| 68 |
+
|
| 69 |
+
gltf = pygltflib.GLTF2(
|
| 70 |
+
scene=0,
|
| 71 |
+
scenes=[pygltflib.Scene(nodes=[0])],
|
| 72 |
+
nodes=[pygltflib.Node(mesh=0)],
|
| 73 |
+
meshes=[
|
| 74 |
+
pygltflib.Mesh(
|
| 75 |
+
primitives=[
|
| 76 |
+
pygltflib.Primitive(
|
| 77 |
+
attributes=pygltflib.Attributes(POSITION=0, NORMAL=1),
|
| 78 |
+
indices=2,
|
| 79 |
+
)
|
| 80 |
+
]
|
| 81 |
+
)
|
| 82 |
+
],
|
| 83 |
+
accessors=[
|
| 84 |
+
# Accessor 0: vertex positions
|
| 85 |
+
pygltflib.Accessor(
|
| 86 |
+
bufferView=0,
|
| 87 |
+
componentType=pygltflib.FLOAT,
|
| 88 |
+
count=len(vertices_f32),
|
| 89 |
+
type=pygltflib.VEC3,
|
| 90 |
+
max=v_max,
|
| 91 |
+
min=v_min,
|
| 92 |
+
),
|
| 93 |
+
# Accessor 1: vertex normals
|
| 94 |
+
pygltflib.Accessor(
|
| 95 |
+
bufferView=1,
|
| 96 |
+
componentType=pygltflib.FLOAT,
|
| 97 |
+
count=len(normals_f32),
|
| 98 |
+
type=pygltflib.VEC3,
|
| 99 |
+
),
|
| 100 |
+
# Accessor 2: triangle indices
|
| 101 |
+
pygltflib.Accessor(
|
| 102 |
+
bufferView=2,
|
| 103 |
+
componentType=pygltflib.UNSIGNED_INT,
|
| 104 |
+
count=triangles_u32.size,
|
| 105 |
+
type=pygltflib.SCALAR,
|
| 106 |
+
max=[int(triangles_u32.max())],
|
| 107 |
+
min=[int(triangles_u32.min())],
|
| 108 |
+
),
|
| 109 |
+
],
|
| 110 |
+
bufferViews=[
|
| 111 |
+
# BufferView 0: positions
|
| 112 |
+
pygltflib.BufferView(
|
| 113 |
+
buffer=0,
|
| 114 |
+
byteOffset=0,
|
| 115 |
+
byteLength=len(vert_bytes),
|
| 116 |
+
target=pygltflib.ARRAY_BUFFER,
|
| 117 |
+
),
|
| 118 |
+
# BufferView 1: normals
|
| 119 |
+
pygltflib.BufferView(
|
| 120 |
+
buffer=0,
|
| 121 |
+
byteOffset=norm_offset,
|
| 122 |
+
byteLength=len(norm_bytes),
|
| 123 |
+
target=pygltflib.ARRAY_BUFFER,
|
| 124 |
+
),
|
| 125 |
+
# BufferView 2: indices
|
| 126 |
+
pygltflib.BufferView(
|
| 127 |
+
buffer=0,
|
| 128 |
+
byteOffset=tri_offset,
|
| 129 |
+
byteLength=len(tri_bytes),
|
| 130 |
+
target=pygltflib.ELEMENT_ARRAY_BUFFER,
|
| 131 |
+
),
|
| 132 |
+
],
|
| 133 |
+
buffers=[pygltflib.Buffer(byteLength=total_bytes)],
|
| 134 |
+
)
|
| 135 |
+
|
| 136 |
+
gltf.set_binary_blob(blob)
|
| 137 |
+
return b"".join(gltf.save_to_bytes())
|
src/api/router.py
ADDED
|
@@ -0,0 +1,378 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""API endpoint definitions for the CAD review tool."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import asyncio
|
| 5 |
+
import json
|
| 6 |
+
import logging
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
|
| 9 |
+
from fastapi import APIRouter, HTTPException, UploadFile, File
|
| 10 |
+
from fastapi.responses import FileResponse, Response, StreamingResponse
|
| 11 |
+
|
| 12 |
+
from src.api.job_manager import JobManager, PIPELINE_STEPS
|
| 13 |
+
from src.api.samples import discover_samples, get_sample
|
| 14 |
+
from src.api.schemas import (
|
| 15 |
+
AssemblyNodeResponse,
|
| 16 |
+
AssemblyTreeResponse,
|
| 17 |
+
ComplianceResponse,
|
| 18 |
+
ComplianceResultResponse,
|
| 19 |
+
ComplianceRuleResponse,
|
| 20 |
+
DistanceMeasurementResponse,
|
| 21 |
+
DistanceRequest,
|
| 22 |
+
GroupInfoResponse,
|
| 23 |
+
JobStatus,
|
| 24 |
+
ProximityPairResponse,
|
| 25 |
+
ProximityResponse,
|
| 26 |
+
SampleFileResponse,
|
| 27 |
+
SamplePairResponse,
|
| 28 |
+
StepProgress,
|
| 29 |
+
UploadResponse,
|
| 30 |
+
)
|
| 31 |
+
from src.compliance.kmvss_checker import check_compliance
|
| 32 |
+
from src.compliance.rule_loader import load_rules
|
| 33 |
+
from src.geometry.measurement import measure_distance, scan_proximity
|
| 34 |
+
|
| 35 |
+
logger = logging.getLogger(__name__)
|
| 36 |
+
|
| 37 |
+
router = APIRouter(prefix="/api")
|
| 38 |
+
|
| 39 |
+
# Singleton job manager
|
| 40 |
+
_manager: JobManager | None = None
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def get_manager() -> JobManager:
|
| 44 |
+
global _manager
|
| 45 |
+
if _manager is None:
|
| 46 |
+
_manager = JobManager()
|
| 47 |
+
return _manager
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def _node_to_response(node_dict: dict) -> AssemblyNodeResponse:
|
| 51 |
+
"""Convert assembly node dict to response model."""
|
| 52 |
+
children = [_node_to_response(c) for c in node_dict.get("children", [])]
|
| 53 |
+
return AssemblyNodeResponse(
|
| 54 |
+
id=node_dict["id"],
|
| 55 |
+
name=node_dict["name"],
|
| 56 |
+
is_assembly=node_dict["is_assembly"],
|
| 57 |
+
is_leaf=node_dict["is_leaf"],
|
| 58 |
+
classification=node_dict.get("classification", "unknown"),
|
| 59 |
+
num_faces=node_dict.get("num_faces", 0),
|
| 60 |
+
num_solids=node_dict.get("num_solids", 0),
|
| 61 |
+
bounding_box=node_dict.get("bounding_box"),
|
| 62 |
+
children=children,
|
| 63 |
+
)
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
@router.post("/upload", response_model=UploadResponse, status_code=201)
|
| 67 |
+
async def upload_files(
|
| 68 |
+
exterior: UploadFile = File(..., description="Exterior (design) STEP file"),
|
| 69 |
+
interior: UploadFile = File(..., description="Interior (engineering) STEP file"),
|
| 70 |
+
) -> UploadResponse:
|
| 71 |
+
"""Upload exterior and interior STEP files and create a review job."""
|
| 72 |
+
manager = get_manager()
|
| 73 |
+
|
| 74 |
+
for label, f in [("Exterior", exterior), ("Interior", interior)]:
|
| 75 |
+
if f.filename and not f.filename.lower().endswith((".stp", ".step")):
|
| 76 |
+
raise HTTPException(400, f"Invalid {label} file type: {f.filename}. Expected .stp or .step")
|
| 77 |
+
|
| 78 |
+
exterior_path = manager.upload_dir / (exterior.filename or "exterior.step")
|
| 79 |
+
exterior_content = await exterior.read()
|
| 80 |
+
exterior_path.write_bytes(exterior_content)
|
| 81 |
+
|
| 82 |
+
interior_path = manager.upload_dir / (interior.filename or "interior.step")
|
| 83 |
+
interior_content = await interior.read()
|
| 84 |
+
interior_path.write_bytes(interior_content)
|
| 85 |
+
|
| 86 |
+
job = manager.create_job(exterior_path, interior_path)
|
| 87 |
+
|
| 88 |
+
loop = asyncio.get_event_loop()
|
| 89 |
+
manager.start_job(job, loop)
|
| 90 |
+
|
| 91 |
+
return UploadResponse(
|
| 92 |
+
job_id=job.job_id,
|
| 93 |
+
status=job.status,
|
| 94 |
+
exterior_filename=exterior.filename or "exterior.step",
|
| 95 |
+
interior_filename=interior.filename or "interior.step",
|
| 96 |
+
)
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
@router.get("/samples", response_model=list[SamplePairResponse])
|
| 100 |
+
async def list_samples() -> list[SamplePairResponse]:
|
| 101 |
+
"""List available sample STEP pairs discoverable in the samples directory."""
|
| 102 |
+
manager = get_manager()
|
| 103 |
+
pairs = discover_samples(manager.settings.samples_dir)
|
| 104 |
+
return [
|
| 105 |
+
SamplePairResponse(
|
| 106 |
+
id=p.id,
|
| 107 |
+
name=p.name,
|
| 108 |
+
exterior=SampleFileResponse(filename=p.exterior.filename, size=p.exterior.size),
|
| 109 |
+
interior=SampleFileResponse(filename=p.interior.filename, size=p.interior.size),
|
| 110 |
+
)
|
| 111 |
+
for p in pairs
|
| 112 |
+
]
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
@router.get("/samples/{sample_id}/files/{kind}")
|
| 116 |
+
async def download_sample_file(sample_id: str, kind: str) -> FileResponse:
|
| 117 |
+
"""Download a sample STEP file (kind: 'exterior' or 'interior')."""
|
| 118 |
+
if kind not in ("exterior", "interior"):
|
| 119 |
+
raise HTTPException(400, f"Invalid kind: {kind}. Must be 'exterior' or 'interior'.")
|
| 120 |
+
|
| 121 |
+
manager = get_manager()
|
| 122 |
+
pair = get_sample(manager.settings.samples_dir, sample_id)
|
| 123 |
+
if pair is None:
|
| 124 |
+
raise HTTPException(404, f"Sample not found: {sample_id}")
|
| 125 |
+
|
| 126 |
+
file = pair.exterior if kind == "exterior" else pair.interior
|
| 127 |
+
return FileResponse(
|
| 128 |
+
path=file.path,
|
| 129 |
+
filename=file.filename,
|
| 130 |
+
media_type="application/octet-stream",
|
| 131 |
+
)
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
@router.post("/samples/{sample_id}/load", response_model=UploadResponse, status_code=201)
|
| 135 |
+
async def load_sample(sample_id: str) -> UploadResponse:
|
| 136 |
+
"""Start a review job using a server-side sample pair (no upload required)."""
|
| 137 |
+
manager = get_manager()
|
| 138 |
+
pair = get_sample(manager.settings.samples_dir, sample_id)
|
| 139 |
+
if pair is None:
|
| 140 |
+
raise HTTPException(404, f"Sample not found: {sample_id}")
|
| 141 |
+
|
| 142 |
+
job = manager.create_job(pair.exterior.path, pair.interior.path)
|
| 143 |
+
|
| 144 |
+
loop = asyncio.get_event_loop()
|
| 145 |
+
manager.start_job(job, loop)
|
| 146 |
+
|
| 147 |
+
return UploadResponse(
|
| 148 |
+
job_id=job.job_id,
|
| 149 |
+
status=job.status,
|
| 150 |
+
exterior_filename=pair.exterior.filename,
|
| 151 |
+
interior_filename=pair.interior.filename,
|
| 152 |
+
)
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
@router.get("/jobs/{job_id}", response_model=JobStatus)
|
| 156 |
+
async def get_job_status(job_id: str) -> JobStatus:
|
| 157 |
+
"""Get the current status of a job."""
|
| 158 |
+
manager = get_manager()
|
| 159 |
+
job = manager.get_job(job_id)
|
| 160 |
+
if job is None:
|
| 161 |
+
raise HTTPException(404, f"Job not found: {job_id}")
|
| 162 |
+
|
| 163 |
+
steps = [
|
| 164 |
+
StepProgress(step=name, status=job.steps.get(name, "pending"))
|
| 165 |
+
for name in PIPELINE_STEPS
|
| 166 |
+
]
|
| 167 |
+
|
| 168 |
+
return JobStatus(
|
| 169 |
+
job_id=job.job_id,
|
| 170 |
+
status=job.status,
|
| 171 |
+
steps=steps,
|
| 172 |
+
error=job.error,
|
| 173 |
+
)
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
@router.get("/jobs/{job_id}/events")
|
| 177 |
+
async def job_events(job_id: str) -> StreamingResponse:
|
| 178 |
+
"""SSE stream of pipeline progress events."""
|
| 179 |
+
manager = get_manager()
|
| 180 |
+
job = manager.get_job(job_id)
|
| 181 |
+
if job is None:
|
| 182 |
+
raise HTTPException(404, f"Job not found: {job_id}")
|
| 183 |
+
|
| 184 |
+
queue = manager.subscribe(job)
|
| 185 |
+
|
| 186 |
+
async def event_stream():
|
| 187 |
+
try:
|
| 188 |
+
while True:
|
| 189 |
+
event = await asyncio.wait_for(queue.get(), timeout=300)
|
| 190 |
+
yield f"data: {json.dumps(event)}\n\n"
|
| 191 |
+
if event.get("overall_status") in ("completed", "failed"):
|
| 192 |
+
break
|
| 193 |
+
except asyncio.TimeoutError:
|
| 194 |
+
yield f"data: {json.dumps({'step': 'timeout', 'status': 'failed', 'overall_status': 'failed'})}\n\n"
|
| 195 |
+
finally:
|
| 196 |
+
manager.unsubscribe(job, queue)
|
| 197 |
+
|
| 198 |
+
return StreamingResponse(event_stream(), media_type="text/event-stream")
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
@router.get("/jobs/{job_id}/assembly-tree", response_model=AssemblyTreeResponse)
|
| 202 |
+
async def get_assembly_tree(job_id: str) -> AssemblyTreeResponse:
|
| 203 |
+
"""Get the assembly tree for a job."""
|
| 204 |
+
manager = get_manager()
|
| 205 |
+
job = manager.get_job(job_id)
|
| 206 |
+
if job is None:
|
| 207 |
+
raise HTTPException(404, f"Job not found: {job_id}")
|
| 208 |
+
if job.assembly_tree is None:
|
| 209 |
+
raise HTTPException(409, "Assembly tree not available yet")
|
| 210 |
+
|
| 211 |
+
tree_dict = job.assembly_tree.to_dict()
|
| 212 |
+
root = _node_to_response(tree_dict)
|
| 213 |
+
|
| 214 |
+
groups = [
|
| 215 |
+
GroupInfoResponse(
|
| 216 |
+
name=g.name,
|
| 217 |
+
part_count=len(g.part_ids),
|
| 218 |
+
part_ids=g.part_ids,
|
| 219 |
+
part_names=g.part_names,
|
| 220 |
+
)
|
| 221 |
+
for g in job.groups.values()
|
| 222 |
+
]
|
| 223 |
+
|
| 224 |
+
return AssemblyTreeResponse(root=root, groups=groups)
|
| 225 |
+
|
| 226 |
+
|
| 227 |
+
@router.get("/jobs/{job_id}/parts/{part_id}/mesh")
|
| 228 |
+
async def get_part_mesh(job_id: str, part_id: str) -> Response:
|
| 229 |
+
"""Get the GLB mesh for a specific part."""
|
| 230 |
+
manager = get_manager()
|
| 231 |
+
job = manager.get_job(job_id)
|
| 232 |
+
if job is None:
|
| 233 |
+
raise HTTPException(404, f"Job not found: {job_id}")
|
| 234 |
+
|
| 235 |
+
mesh_data = job.part_meshes.get(part_id)
|
| 236 |
+
if mesh_data is None:
|
| 237 |
+
raise HTTPException(404, f"Part mesh not found: {part_id}")
|
| 238 |
+
if not mesh_data.glb:
|
| 239 |
+
raise HTTPException(409, "Mesh not exported yet")
|
| 240 |
+
|
| 241 |
+
return Response(content=mesh_data.glb, media_type="model/gltf-binary")
|
| 242 |
+
|
| 243 |
+
|
| 244 |
+
@router.get("/jobs/{job_id}/groups/{group}/mesh")
|
| 245 |
+
async def get_group_mesh(job_id: str, group: str) -> Response:
|
| 246 |
+
"""Get the combined GLB mesh for a group (exterior or interior)."""
|
| 247 |
+
manager = get_manager()
|
| 248 |
+
job = manager.get_job(job_id)
|
| 249 |
+
if job is None:
|
| 250 |
+
raise HTTPException(404, f"Job not found: {job_id}")
|
| 251 |
+
|
| 252 |
+
if group not in ("exterior", "interior"):
|
| 253 |
+
raise HTTPException(400, f"Invalid group: {group}. Must be 'exterior' or 'interior'.")
|
| 254 |
+
|
| 255 |
+
mesh_data = job.group_meshes.get(group)
|
| 256 |
+
if mesh_data is None:
|
| 257 |
+
raise HTTPException(404, f"Group mesh not found: {group}")
|
| 258 |
+
if not mesh_data.glb:
|
| 259 |
+
raise HTTPException(409, "Group mesh not exported yet")
|
| 260 |
+
|
| 261 |
+
return Response(content=mesh_data.glb, media_type="model/gltf-binary")
|
| 262 |
+
|
| 263 |
+
|
| 264 |
+
@router.post("/jobs/{job_id}/analyze/proximity", response_model=ProximityResponse)
|
| 265 |
+
async def analyze_proximity(job_id: str) -> ProximityResponse:
|
| 266 |
+
"""Run proximity/collision analysis on all parts."""
|
| 267 |
+
manager = get_manager()
|
| 268 |
+
job = manager.get_job(job_id)
|
| 269 |
+
if job is None:
|
| 270 |
+
raise HTTPException(404, f"Job not found: {job_id}")
|
| 271 |
+
if job.assembly_tree is None:
|
| 272 |
+
raise HTTPException(409, "Assembly tree not available yet")
|
| 273 |
+
|
| 274 |
+
parts = []
|
| 275 |
+
for leaf in job.assembly_tree.iter_leaves():
|
| 276 |
+
if leaf.shape is not None and not leaf.shape.IsNull():
|
| 277 |
+
parts.append({"id": leaf.id, "name": leaf.name, "shape": leaf.shape})
|
| 278 |
+
|
| 279 |
+
results = scan_proximity(
|
| 280 |
+
parts,
|
| 281 |
+
collision_threshold_mm=manager.settings.tolerance.gap_tolerance_mm,
|
| 282 |
+
near_threshold_mm=5.0,
|
| 283 |
+
)
|
| 284 |
+
|
| 285 |
+
pairs = [
|
| 286 |
+
ProximityPairResponse(
|
| 287 |
+
part_a_id=r.part_a_id,
|
| 288 |
+
part_a_name=r.part_a_name,
|
| 289 |
+
part_b_id=r.part_b_id,
|
| 290 |
+
part_b_name=r.part_b_name,
|
| 291 |
+
min_distance_mm=r.min_distance_mm,
|
| 292 |
+
point_a=r.point_a,
|
| 293 |
+
point_b=r.point_b,
|
| 294 |
+
status=r.status,
|
| 295 |
+
)
|
| 296 |
+
for r in results
|
| 297 |
+
]
|
| 298 |
+
|
| 299 |
+
return ProximityResponse(
|
| 300 |
+
pairs=pairs,
|
| 301 |
+
collision_count=sum(1 for p in pairs if p.status == "collision"),
|
| 302 |
+
near_count=sum(1 for p in pairs if p.status == "near"),
|
| 303 |
+
)
|
| 304 |
+
|
| 305 |
+
|
| 306 |
+
@router.post("/jobs/{job_id}/analyze/distance", response_model=DistanceMeasurementResponse)
|
| 307 |
+
async def analyze_distance(job_id: str, request: DistanceRequest) -> DistanceMeasurementResponse:
|
| 308 |
+
"""Measure distance between two specific parts."""
|
| 309 |
+
manager = get_manager()
|
| 310 |
+
job = manager.get_job(job_id)
|
| 311 |
+
if job is None:
|
| 312 |
+
raise HTTPException(404, f"Job not found: {job_id}")
|
| 313 |
+
if job.assembly_tree is None:
|
| 314 |
+
raise HTTPException(409, "Assembly tree not available yet")
|
| 315 |
+
|
| 316 |
+
node_a = job.assembly_tree.find_by_id(request.part_a_id)
|
| 317 |
+
node_b = job.assembly_tree.find_by_id(request.part_b_id)
|
| 318 |
+
|
| 319 |
+
if node_a is None or node_a.shape is None:
|
| 320 |
+
raise HTTPException(404, f"Part not found: {request.part_a_id}")
|
| 321 |
+
if node_b is None or node_b.shape is None:
|
| 322 |
+
raise HTTPException(404, f"Part not found: {request.part_b_id}")
|
| 323 |
+
|
| 324 |
+
result = measure_distance(node_a.shape, node_b.shape)
|
| 325 |
+
|
| 326 |
+
return DistanceMeasurementResponse(
|
| 327 |
+
distance_mm=result.distance_mm,
|
| 328 |
+
point_a=result.point_a,
|
| 329 |
+
point_b=result.point_b,
|
| 330 |
+
)
|
| 331 |
+
|
| 332 |
+
|
| 333 |
+
@router.get("/compliance/rules")
|
| 334 |
+
async def get_compliance_rules() -> list[ComplianceRuleResponse]:
|
| 335 |
+
"""Get available compliance rules."""
|
| 336 |
+
rules_path = Path(__file__).parent.parent.parent / "config" / "kmvss_rules.yaml"
|
| 337 |
+
rules = load_rules(rules_path)
|
| 338 |
+
return [
|
| 339 |
+
ComplianceRuleResponse(
|
| 340 |
+
id=r.id, name=r.name, description=r.description,
|
| 341 |
+
type=r.type, severity=r.severity,
|
| 342 |
+
)
|
| 343 |
+
for r in rules
|
| 344 |
+
]
|
| 345 |
+
|
| 346 |
+
|
| 347 |
+
@router.post("/jobs/{job_id}/analyze/compliance", response_model=ComplianceResponse)
|
| 348 |
+
async def analyze_compliance(job_id: str) -> ComplianceResponse:
|
| 349 |
+
"""Run compliance checks on the assembly."""
|
| 350 |
+
manager = get_manager()
|
| 351 |
+
job = manager.get_job(job_id)
|
| 352 |
+
if job is None:
|
| 353 |
+
raise HTTPException(404, f"Job not found: {job_id}")
|
| 354 |
+
if job.assembly_tree is None:
|
| 355 |
+
raise HTTPException(409, "Assembly tree not available yet")
|
| 356 |
+
|
| 357 |
+
rules_path = Path(__file__).parent.parent.parent / "config" / "kmvss_rules.yaml"
|
| 358 |
+
rules = load_rules(rules_path)
|
| 359 |
+
results = check_compliance(rules, job.assembly_tree)
|
| 360 |
+
|
| 361 |
+
return ComplianceResponse(
|
| 362 |
+
results=[
|
| 363 |
+
ComplianceResultResponse(
|
| 364 |
+
rule_id=r.rule_id,
|
| 365 |
+
rule_name=r.rule_name,
|
| 366 |
+
passed=r.passed,
|
| 367 |
+
severity=r.severity,
|
| 368 |
+
measured_value=r.measured_value,
|
| 369 |
+
threshold_value=r.threshold_value,
|
| 370 |
+
unit=r.unit,
|
| 371 |
+
message=r.message,
|
| 372 |
+
affected_parts=r.affected_parts,
|
| 373 |
+
)
|
| 374 |
+
for r in results
|
| 375 |
+
],
|
| 376 |
+
pass_count=sum(1 for r in results if r.passed),
|
| 377 |
+
fail_count=sum(1 for r in results if not r.passed),
|
| 378 |
+
)
|
src/api/samples.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Sample dataset discovery for the public example loader."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
from dataclasses import dataclass
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
|
| 7 |
+
_SUFFIX_EXTERIOR = "_exterior"
|
| 8 |
+
_SUFFIX_INTERIOR = "_interior"
|
| 9 |
+
_ALLOWED_EXT = (".step", ".stp")
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
@dataclass(frozen=True)
|
| 13 |
+
class SampleFile:
|
| 14 |
+
filename: str
|
| 15 |
+
path: Path
|
| 16 |
+
size: int
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
@dataclass(frozen=True)
|
| 20 |
+
class SamplePair:
|
| 21 |
+
id: str
|
| 22 |
+
name: str
|
| 23 |
+
exterior: SampleFile
|
| 24 |
+
interior: SampleFile
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def _pretty_name(stem: str) -> str:
|
| 28 |
+
return stem.replace("_", " ").replace("-", " ").strip().title()
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def _stem_for(path: Path, suffix: str) -> str | None:
|
| 32 |
+
stem = path.stem
|
| 33 |
+
return stem[: -len(suffix)] if stem.endswith(suffix) else None
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def discover_samples(samples_dir: Path) -> list[SamplePair]:
|
| 37 |
+
"""Scan `samples_dir` for `{id}_exterior.(step|stp)` + `{id}_interior.(step|stp)` pairs."""
|
| 38 |
+
if not samples_dir.exists() or not samples_dir.is_dir():
|
| 39 |
+
return []
|
| 40 |
+
|
| 41 |
+
by_id_ext: dict[str, Path] = {}
|
| 42 |
+
by_id_int: dict[str, Path] = {}
|
| 43 |
+
|
| 44 |
+
for file in sorted(samples_dir.iterdir()):
|
| 45 |
+
if not file.is_file() or file.suffix.lower() not in _ALLOWED_EXT:
|
| 46 |
+
continue
|
| 47 |
+
if (sid := _stem_for(file, _SUFFIX_EXTERIOR)) is not None:
|
| 48 |
+
by_id_ext[sid] = file
|
| 49 |
+
elif (sid := _stem_for(file, _SUFFIX_INTERIOR)) is not None:
|
| 50 |
+
by_id_int[sid] = file
|
| 51 |
+
|
| 52 |
+
pairs: list[SamplePair] = []
|
| 53 |
+
for sid in sorted(set(by_id_ext) & set(by_id_int)):
|
| 54 |
+
ext_path = by_id_ext[sid]
|
| 55 |
+
int_path = by_id_int[sid]
|
| 56 |
+
pairs.append(
|
| 57 |
+
SamplePair(
|
| 58 |
+
id=sid,
|
| 59 |
+
name=_pretty_name(sid),
|
| 60 |
+
exterior=SampleFile(
|
| 61 |
+
filename=ext_path.name, path=ext_path, size=ext_path.stat().st_size
|
| 62 |
+
),
|
| 63 |
+
interior=SampleFile(
|
| 64 |
+
filename=int_path.name, path=int_path, size=int_path.stat().st_size
|
| 65 |
+
),
|
| 66 |
+
)
|
| 67 |
+
)
|
| 68 |
+
return pairs
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def get_sample(samples_dir: Path, sample_id: str) -> SamplePair | None:
|
| 72 |
+
for pair in discover_samples(samples_dir):
|
| 73 |
+
if pair.id == sample_id:
|
| 74 |
+
return pair
|
| 75 |
+
return None
|
src/api/schemas.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Pydantic request/response models for the CAD review API."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
from pydantic import BaseModel, Field
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
# --- Request models ---
|
| 8 |
+
|
| 9 |
+
class DistanceRequest(BaseModel):
|
| 10 |
+
"""Request to measure distance between two parts."""
|
| 11 |
+
part_a_id: str
|
| 12 |
+
part_b_id: str
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
# --- Response models ---
|
| 16 |
+
|
| 17 |
+
class UploadResponse(BaseModel):
|
| 18 |
+
job_id: str
|
| 19 |
+
status: str
|
| 20 |
+
exterior_filename: str
|
| 21 |
+
interior_filename: str
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class SampleFileResponse(BaseModel):
|
| 25 |
+
filename: str
|
| 26 |
+
size: int
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
class SamplePairResponse(BaseModel):
|
| 30 |
+
id: str
|
| 31 |
+
name: str
|
| 32 |
+
exterior: SampleFileResponse
|
| 33 |
+
interior: SampleFileResponse
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
class StepProgress(BaseModel):
|
| 37 |
+
step: str
|
| 38 |
+
status: str # pending, running, completed
|
| 39 |
+
progress_pct: int = 0
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
class JobStatus(BaseModel):
|
| 43 |
+
job_id: str
|
| 44 |
+
status: str # pending, running, completed, failed
|
| 45 |
+
steps: list[StepProgress] = []
|
| 46 |
+
error: str | None = None
|
| 47 |
+
|
| 48 |
+
# Rebuild to resolve forward reference
|
| 49 |
+
JobStatus.model_rebuild()
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
class AssemblyNodeResponse(BaseModel):
|
| 53 |
+
id: str
|
| 54 |
+
name: str
|
| 55 |
+
is_assembly: bool
|
| 56 |
+
is_leaf: bool
|
| 57 |
+
classification: str = "unknown"
|
| 58 |
+
num_faces: int = 0
|
| 59 |
+
num_solids: int = 0
|
| 60 |
+
bounding_box: dict | None = None
|
| 61 |
+
children: list[AssemblyNodeResponse] = []
|
| 62 |
+
|
| 63 |
+
AssemblyNodeResponse.model_rebuild()
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
class GroupInfoResponse(BaseModel):
|
| 67 |
+
name: str # "exterior" or "interior"
|
| 68 |
+
part_count: int
|
| 69 |
+
part_ids: list[str]
|
| 70 |
+
part_names: list[str]
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
class AssemblyTreeResponse(BaseModel):
|
| 74 |
+
root: AssemblyNodeResponse
|
| 75 |
+
groups: list[GroupInfoResponse] = []
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
class ProximityPairResponse(BaseModel):
|
| 79 |
+
part_a_id: str
|
| 80 |
+
part_a_name: str
|
| 81 |
+
part_b_id: str
|
| 82 |
+
part_b_name: str
|
| 83 |
+
min_distance_mm: float
|
| 84 |
+
point_a: list[float]
|
| 85 |
+
point_b: list[float]
|
| 86 |
+
status: str # collision, near, ok
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
class ProximityResponse(BaseModel):
|
| 90 |
+
pairs: list[ProximityPairResponse]
|
| 91 |
+
collision_count: int
|
| 92 |
+
near_count: int
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
class DistanceMeasurementResponse(BaseModel):
|
| 96 |
+
distance_mm: float
|
| 97 |
+
point_a: list[float]
|
| 98 |
+
point_b: list[float]
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
class ComplianceResultResponse(BaseModel):
|
| 102 |
+
rule_id: str
|
| 103 |
+
rule_name: str
|
| 104 |
+
passed: bool
|
| 105 |
+
severity: str
|
| 106 |
+
measured_value: float | None = None
|
| 107 |
+
threshold_value: float | None = None
|
| 108 |
+
unit: str = "mm"
|
| 109 |
+
message: str = ""
|
| 110 |
+
affected_parts: list[str] = []
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
class ComplianceRuleResponse(BaseModel):
|
| 114 |
+
id: str
|
| 115 |
+
name: str
|
| 116 |
+
description: str
|
| 117 |
+
type: str
|
| 118 |
+
severity: str
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
class ComplianceResponse(BaseModel):
|
| 122 |
+
results: list[ComplianceResultResponse]
|
| 123 |
+
pass_count: int
|
| 124 |
+
fail_count: int
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
class SSEEvent(BaseModel):
|
| 128 |
+
step: str
|
| 129 |
+
status: str
|
| 130 |
+
progress_pct: int = 0
|
| 131 |
+
overall_status: str | None = None
|
| 132 |
+
error: str | None = None
|
src/comparison/__init__.py
ADDED
|
File without changes
|
src/comparison/continuity.py
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""G0/G1/G2 surface continuity checking at boundary regions."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import logging
|
| 6 |
+
from dataclasses import dataclass, field
|
| 7 |
+
|
| 8 |
+
import numpy as np
|
| 9 |
+
from OCP.BRep import BRep_Tool
|
| 10 |
+
from OCP.BRepAdaptor import BRepAdaptor_Surface
|
| 11 |
+
from OCP.GeomLProp import GeomLProp_SLProps
|
| 12 |
+
from OCP.ShapeAnalysis import ShapeAnalysis_Surface
|
| 13 |
+
from OCP.TopAbs import TopAbs_FACE
|
| 14 |
+
from OCP.TopExp import TopExp_Explorer
|
| 15 |
+
from OCP.TopoDS import TopoDS, TopoDS_Face, TopoDS_Shape
|
| 16 |
+
from OCP.gp import gp_Pnt
|
| 17 |
+
|
| 18 |
+
logger = logging.getLogger(__name__)
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
@dataclass
|
| 22 |
+
class ContinuityResult:
|
| 23 |
+
"""Results of continuity checks."""
|
| 24 |
+
|
| 25 |
+
g0_deviations: list[float] = field(default_factory=list)
|
| 26 |
+
g1_deviations_deg: list[float] = field(default_factory=list)
|
| 27 |
+
g2_deviations_pct: list[float] = field(default_factory=list)
|
| 28 |
+
|
| 29 |
+
g0_max: float = 0.0
|
| 30 |
+
g1_max_deg: float = 0.0
|
| 31 |
+
g2_max_pct: float = 0.0
|
| 32 |
+
|
| 33 |
+
g0_pass: bool = True
|
| 34 |
+
g1_pass: bool = True
|
| 35 |
+
g2_pass: bool = True
|
| 36 |
+
|
| 37 |
+
num_samples_checked: int = 0
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def _get_closest_face_and_uv(
|
| 41 |
+
faces: list[TopoDS_Face],
|
| 42 |
+
point: np.ndarray,
|
| 43 |
+
) -> tuple[TopoDS_Face, float, float, float] | None:
|
| 44 |
+
"""Find the closest face to a 3D point and return UV parameters."""
|
| 45 |
+
best_face = None
|
| 46 |
+
best_dist = float("inf")
|
| 47 |
+
best_u, best_v = 0.0, 0.0
|
| 48 |
+
|
| 49 |
+
gp_point = gp_Pnt(float(point[0]), float(point[1]), float(point[2]))
|
| 50 |
+
|
| 51 |
+
for face in faces:
|
| 52 |
+
try:
|
| 53 |
+
surface = BRep_Tool.Surface_s(face)
|
| 54 |
+
sas = ShapeAnalysis_Surface(surface)
|
| 55 |
+
uv = sas.ValueOfUV(gp_point, 1.0)
|
| 56 |
+
proj_pnt = sas.Value(uv.X(), uv.Y())
|
| 57 |
+
dist = gp_point.Distance(proj_pnt)
|
| 58 |
+
|
| 59 |
+
if dist < best_dist:
|
| 60 |
+
best_dist = dist
|
| 61 |
+
best_face = face
|
| 62 |
+
best_u = uv.X()
|
| 63 |
+
best_v = uv.Y()
|
| 64 |
+
except Exception:
|
| 65 |
+
continue
|
| 66 |
+
|
| 67 |
+
if best_face is None:
|
| 68 |
+
return None
|
| 69 |
+
return best_face, best_u, best_v, best_dist
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def _evaluate_surface_props(
|
| 73 |
+
face: TopoDS_Face,
|
| 74 |
+
u: float,
|
| 75 |
+
v: float,
|
| 76 |
+
) -> tuple[np.ndarray | None, np.ndarray | None, float | None]:
|
| 77 |
+
"""Evaluate surface normal and curvature at (u, v)."""
|
| 78 |
+
try:
|
| 79 |
+
surface = BRep_Tool.Surface_s(face)
|
| 80 |
+
props = GeomLProp_SLProps(surface, u, v, 2, 1e-6)
|
| 81 |
+
|
| 82 |
+
normal = None
|
| 83 |
+
if props.IsNormalDefined():
|
| 84 |
+
n = props.Normal()
|
| 85 |
+
normal = np.array([n.X(), n.Y(), n.Z()])
|
| 86 |
+
|
| 87 |
+
curvature = None
|
| 88 |
+
if props.IsCurvatureDefined():
|
| 89 |
+
curvature = props.MeanCurvature()
|
| 90 |
+
|
| 91 |
+
point = props.Value()
|
| 92 |
+
position = np.array([point.X(), point.Y(), point.Z()])
|
| 93 |
+
|
| 94 |
+
return position, normal, curvature
|
| 95 |
+
except Exception:
|
| 96 |
+
return None, None, None
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def _collect_faces(shape: TopoDS_Shape) -> list[TopoDS_Face]:
|
| 100 |
+
"""Collect all faces from a shape."""
|
| 101 |
+
faces = []
|
| 102 |
+
exp = TopExp_Explorer(shape, TopAbs_FACE)
|
| 103 |
+
while exp.More():
|
| 104 |
+
faces.append(TopoDS.Face_s(exp.Current()))
|
| 105 |
+
exp.Next()
|
| 106 |
+
return faces
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def check_continuity(
|
| 110 |
+
shape_a: TopoDS_Shape,
|
| 111 |
+
shape_b: TopoDS_Shape,
|
| 112 |
+
sample_points: np.ndarray,
|
| 113 |
+
g0_tolerance_mm: float = 0.05,
|
| 114 |
+
g1_tolerance_deg: float = 0.3,
|
| 115 |
+
g2_tolerance_pct: float = 3.0,
|
| 116 |
+
max_check_points: int = 1000,
|
| 117 |
+
) -> ContinuityResult:
|
| 118 |
+
"""Check G0/G1/G2 continuity between two shapes at sample points.
|
| 119 |
+
|
| 120 |
+
Args:
|
| 121 |
+
shape_a: First shape (reference).
|
| 122 |
+
shape_b: Second shape (comparison).
|
| 123 |
+
sample_points: Points at which to evaluate continuity.
|
| 124 |
+
g0_tolerance_mm: G0 positional tolerance.
|
| 125 |
+
g1_tolerance_deg: G1 tangent angle tolerance.
|
| 126 |
+
g2_tolerance_pct: G2 curvature relative tolerance.
|
| 127 |
+
max_check_points: Limit on number of points to check.
|
| 128 |
+
|
| 129 |
+
Returns:
|
| 130 |
+
ContinuityResult with pass/fail per grade.
|
| 131 |
+
"""
|
| 132 |
+
faces_a = _collect_faces(shape_a)
|
| 133 |
+
faces_b = _collect_faces(shape_b)
|
| 134 |
+
|
| 135 |
+
if not faces_a or not faces_b:
|
| 136 |
+
logger.warning("One or both shapes have no faces -- skipping continuity check")
|
| 137 |
+
return ContinuityResult()
|
| 138 |
+
|
| 139 |
+
# Subsample if needed
|
| 140 |
+
check_points = sample_points
|
| 141 |
+
if len(sample_points) > max_check_points:
|
| 142 |
+
rng = np.random.default_rng(42)
|
| 143 |
+
indices = rng.choice(len(sample_points), max_check_points, replace=False)
|
| 144 |
+
check_points = sample_points[indices]
|
| 145 |
+
|
| 146 |
+
result = ContinuityResult()
|
| 147 |
+
|
| 148 |
+
for point in check_points:
|
| 149 |
+
match_a = _get_closest_face_and_uv(faces_a, point)
|
| 150 |
+
match_b = _get_closest_face_and_uv(faces_b, point)
|
| 151 |
+
|
| 152 |
+
if match_a is None or match_b is None:
|
| 153 |
+
continue
|
| 154 |
+
|
| 155 |
+
face_a, u_a, v_a, dist_a = match_a
|
| 156 |
+
face_b, u_b, v_b, dist_b = match_b
|
| 157 |
+
|
| 158 |
+
# Skip if projection is too far (point not on either surface)
|
| 159 |
+
if dist_a > g0_tolerance_mm * 10 or dist_b > g0_tolerance_mm * 10:
|
| 160 |
+
continue
|
| 161 |
+
|
| 162 |
+
pos_a, norm_a, curv_a = _evaluate_surface_props(face_a, u_a, v_a)
|
| 163 |
+
pos_b, norm_b, curv_b = _evaluate_surface_props(face_b, u_b, v_b)
|
| 164 |
+
|
| 165 |
+
if pos_a is None or pos_b is None:
|
| 166 |
+
continue
|
| 167 |
+
|
| 168 |
+
result.num_samples_checked += 1
|
| 169 |
+
|
| 170 |
+
# G0: positional
|
| 171 |
+
g0_dev = float(np.linalg.norm(pos_a - pos_b))
|
| 172 |
+
result.g0_deviations.append(g0_dev)
|
| 173 |
+
|
| 174 |
+
# G1: tangent (normal angle)
|
| 175 |
+
if norm_a is not None and norm_b is not None:
|
| 176 |
+
cos_angle = np.clip(np.dot(norm_a, norm_b), -1.0, 1.0)
|
| 177 |
+
angle_deg = float(np.degrees(np.arccos(abs(cos_angle))))
|
| 178 |
+
result.g1_deviations_deg.append(angle_deg)
|
| 179 |
+
|
| 180 |
+
# G2: curvature
|
| 181 |
+
if curv_a is not None and curv_b is not None:
|
| 182 |
+
denom = max(abs(curv_a), abs(curv_b), 1e-12)
|
| 183 |
+
g2_pct = abs(curv_a - curv_b) / denom * 100.0
|
| 184 |
+
result.g2_deviations_pct.append(float(g2_pct))
|
| 185 |
+
|
| 186 |
+
# Compute max and pass/fail
|
| 187 |
+
if result.g0_deviations:
|
| 188 |
+
result.g0_max = max(result.g0_deviations)
|
| 189 |
+
result.g0_pass = result.g0_max <= g0_tolerance_mm
|
| 190 |
+
|
| 191 |
+
if result.g1_deviations_deg:
|
| 192 |
+
result.g1_max_deg = max(result.g1_deviations_deg)
|
| 193 |
+
result.g1_pass = result.g1_max_deg <= g1_tolerance_deg
|
| 194 |
+
|
| 195 |
+
if result.g2_deviations_pct:
|
| 196 |
+
result.g2_max_pct = max(result.g2_deviations_pct)
|
| 197 |
+
result.g2_pass = result.g2_max_pct <= g2_tolerance_pct
|
| 198 |
+
|
| 199 |
+
logger.info(
|
| 200 |
+
"Continuity check (%d points): G0 max=%.4f mm (%s), "
|
| 201 |
+
"G1 max=%.2f deg (%s), G2 max=%.1f%% (%s)",
|
| 202 |
+
result.num_samples_checked,
|
| 203 |
+
result.g0_max, "PASS" if result.g0_pass else "FAIL",
|
| 204 |
+
result.g1_max_deg, "PASS" if result.g1_pass else "FAIL",
|
| 205 |
+
result.g2_max_pct, "PASS" if result.g2_pass else "FAIL",
|
| 206 |
+
)
|
| 207 |
+
return result
|
src/comparison/distance.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Bidirectional distance computation between point clouds."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import logging
|
| 6 |
+
from dataclasses import dataclass
|
| 7 |
+
|
| 8 |
+
import numpy as np
|
| 9 |
+
import open3d as o3d
|
| 10 |
+
|
| 11 |
+
logger = logging.getLogger(__name__)
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
@dataclass
|
| 15 |
+
class DistanceResult:
|
| 16 |
+
"""Results of bidirectional distance computation."""
|
| 17 |
+
|
| 18 |
+
# A -> B distances
|
| 19 |
+
a_to_b_distances: np.ndarray # per-point distances
|
| 20 |
+
a_to_b_max: float # Hausdorff (one direction)
|
| 21 |
+
a_to_b_rms: float
|
| 22 |
+
a_to_b_mean: float
|
| 23 |
+
|
| 24 |
+
# B -> A distances
|
| 25 |
+
b_to_a_distances: np.ndarray
|
| 26 |
+
b_to_a_max: float
|
| 27 |
+
b_to_a_rms: float
|
| 28 |
+
b_to_a_mean: float
|
| 29 |
+
|
| 30 |
+
# Combined metrics
|
| 31 |
+
hausdorff: float # max(a_to_b_max, b_to_a_max)
|
| 32 |
+
rms: float # RMS of combined
|
| 33 |
+
mean: float
|
| 34 |
+
percentile_95: float
|
| 35 |
+
percentile_99: float
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def _one_directional_distances(
|
| 39 |
+
source: np.ndarray,
|
| 40 |
+
target: np.ndarray,
|
| 41 |
+
) -> np.ndarray:
|
| 42 |
+
"""Compute nearest-neighbor distances from source to target.
|
| 43 |
+
|
| 44 |
+
Uses Open3D KDTree for efficient lookup.
|
| 45 |
+
"""
|
| 46 |
+
target_pcd = o3d.geometry.PointCloud()
|
| 47 |
+
target_pcd.points = o3d.utility.Vector3dVector(target)
|
| 48 |
+
kdtree = o3d.geometry.KDTreeFlann(target_pcd)
|
| 49 |
+
|
| 50 |
+
distances = np.empty(len(source), dtype=np.float64)
|
| 51 |
+
for i, point in enumerate(source):
|
| 52 |
+
_, _, dist_sq = kdtree.search_knn_vector_3d(point, 1)
|
| 53 |
+
distances[i] = np.sqrt(dist_sq[0])
|
| 54 |
+
|
| 55 |
+
return distances
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def compute_bidirectional_distances(
|
| 59 |
+
points_a: np.ndarray,
|
| 60 |
+
points_b: np.ndarray,
|
| 61 |
+
) -> DistanceResult:
|
| 62 |
+
"""Compute bidirectional nearest-neighbor distances.
|
| 63 |
+
|
| 64 |
+
Args:
|
| 65 |
+
points_a: Point cloud A [N, 3].
|
| 66 |
+
points_b: Point cloud B [M, 3].
|
| 67 |
+
|
| 68 |
+
Returns:
|
| 69 |
+
DistanceResult with per-point distances and aggregate metrics.
|
| 70 |
+
"""
|
| 71 |
+
logger.info("Computing A->B distances (%d -> %d points)", len(points_a), len(points_b))
|
| 72 |
+
a_to_b = _one_directional_distances(points_a, points_b)
|
| 73 |
+
|
| 74 |
+
logger.info("Computing B->A distances (%d -> %d points)", len(points_b), len(points_a))
|
| 75 |
+
b_to_a = _one_directional_distances(points_b, points_a)
|
| 76 |
+
|
| 77 |
+
combined = np.concatenate([a_to_b, b_to_a])
|
| 78 |
+
|
| 79 |
+
result = DistanceResult(
|
| 80 |
+
a_to_b_distances=a_to_b,
|
| 81 |
+
a_to_b_max=float(np.max(a_to_b)),
|
| 82 |
+
a_to_b_rms=float(np.sqrt(np.mean(a_to_b**2))),
|
| 83 |
+
a_to_b_mean=float(np.mean(a_to_b)),
|
| 84 |
+
b_to_a_distances=b_to_a,
|
| 85 |
+
b_to_a_max=float(np.max(b_to_a)),
|
| 86 |
+
b_to_a_rms=float(np.sqrt(np.mean(b_to_a**2))),
|
| 87 |
+
b_to_a_mean=float(np.mean(b_to_a)),
|
| 88 |
+
hausdorff=float(max(np.max(a_to_b), np.max(b_to_a))),
|
| 89 |
+
rms=float(np.sqrt(np.mean(combined**2))),
|
| 90 |
+
mean=float(np.mean(combined)),
|
| 91 |
+
percentile_95=float(np.percentile(combined, 95)),
|
| 92 |
+
percentile_99=float(np.percentile(combined, 99)),
|
| 93 |
+
)
|
| 94 |
+
|
| 95 |
+
logger.info(
|
| 96 |
+
"Distance results: Hausdorff=%.6f mm, RMS=%.6f mm, Mean=%.6f mm, "
|
| 97 |
+
"P95=%.6f mm, P99=%.6f mm",
|
| 98 |
+
result.hausdorff, result.rms, result.mean,
|
| 99 |
+
result.percentile_95, result.percentile_99,
|
| 100 |
+
)
|
| 101 |
+
return result
|
src/comparison/gap_overlap.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Gap and overlap detection on boundary edges."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import logging
|
| 6 |
+
from dataclasses import dataclass, field
|
| 7 |
+
|
| 8 |
+
import numpy as np
|
| 9 |
+
import open3d as o3d
|
| 10 |
+
from OCP.BRep import BRep_Tool
|
| 11 |
+
from OCP.BRepAdaptor import BRepAdaptor_Curve
|
| 12 |
+
from OCP.GCPnts import GCPnts_UniformAbscissa
|
| 13 |
+
from OCP.TopAbs import TopAbs_EDGE, TopAbs_FACE
|
| 14 |
+
from OCP.TopExp import TopExp_Explorer
|
| 15 |
+
from OCP.TopoDS import TopoDS, TopoDS_Shape
|
| 16 |
+
|
| 17 |
+
logger = logging.getLogger(__name__)
|
| 18 |
+
|
| 19 |
+
NUM_EDGE_SAMPLES = 50
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
@dataclass
|
| 23 |
+
class GapOverlapResult:
|
| 24 |
+
"""Gap and overlap detection results."""
|
| 25 |
+
|
| 26 |
+
gap_distances: list[float] = field(default_factory=list)
|
| 27 |
+
overlap_distances: list[float] = field(default_factory=list)
|
| 28 |
+
gap_locations: list[np.ndarray] = field(default_factory=list)
|
| 29 |
+
overlap_locations: list[np.ndarray] = field(default_factory=list)
|
| 30 |
+
|
| 31 |
+
@property
|
| 32 |
+
def gap_count(self) -> int:
|
| 33 |
+
return len(self.gap_distances)
|
| 34 |
+
|
| 35 |
+
@property
|
| 36 |
+
def overlap_count(self) -> int:
|
| 37 |
+
return len(self.overlap_distances)
|
| 38 |
+
|
| 39 |
+
@property
|
| 40 |
+
def max_gap(self) -> float:
|
| 41 |
+
return max(self.gap_distances) if self.gap_distances else 0.0
|
| 42 |
+
|
| 43 |
+
@property
|
| 44 |
+
def max_overlap(self) -> float:
|
| 45 |
+
return max(self.overlap_distances) if self.overlap_distances else 0.0
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def _sample_boundary_edges(shape: TopoDS_Shape) -> np.ndarray:
|
| 49 |
+
"""Sample points along boundary edges of a shape.
|
| 50 |
+
|
| 51 |
+
Boundary edges are edges that belong to only one face.
|
| 52 |
+
"""
|
| 53 |
+
# Collect edge-to-face counts
|
| 54 |
+
edge_face_count: dict[int, int] = {}
|
| 55 |
+
edge_map: dict[int, object] = {}
|
| 56 |
+
|
| 57 |
+
face_exp = TopExp_Explorer(shape, TopAbs_FACE)
|
| 58 |
+
while face_exp.More():
|
| 59 |
+
face = TopoDS.Face_s(face_exp.Current())
|
| 60 |
+
edge_exp = TopExp_Explorer(face, TopAbs_EDGE)
|
| 61 |
+
while edge_exp.More():
|
| 62 |
+
edge = TopoDS.Edge_s(edge_exp.Current())
|
| 63 |
+
h = hash(edge)
|
| 64 |
+
edge_face_count[h] = edge_face_count.get(h, 0) + 1
|
| 65 |
+
edge_map[h] = edge
|
| 66 |
+
edge_exp.Next()
|
| 67 |
+
face_exp.Next()
|
| 68 |
+
|
| 69 |
+
# Boundary edges: shared by only one face
|
| 70 |
+
boundary_edges = [edge_map[h] for h, count in edge_face_count.items() if count == 1]
|
| 71 |
+
logger.info("Found %d boundary edges out of %d total", len(boundary_edges), len(edge_map))
|
| 72 |
+
|
| 73 |
+
if not boundary_edges:
|
| 74 |
+
return np.empty((0, 3), dtype=np.float64)
|
| 75 |
+
|
| 76 |
+
points = []
|
| 77 |
+
for edge in boundary_edges:
|
| 78 |
+
try:
|
| 79 |
+
curve = BRepAdaptor_Curve(edge)
|
| 80 |
+
u_first = curve.FirstParameter()
|
| 81 |
+
u_last = curve.LastParameter()
|
| 82 |
+
|
| 83 |
+
distributor = GCPnts_UniformAbscissa(curve, NUM_EDGE_SAMPLES, u_first, u_last)
|
| 84 |
+
if not distributor.IsDone():
|
| 85 |
+
continue
|
| 86 |
+
|
| 87 |
+
for i in range(1, distributor.NbPoints() + 1):
|
| 88 |
+
param = distributor.Parameter(i)
|
| 89 |
+
pnt = curve.Value(param)
|
| 90 |
+
points.append([pnt.X(), pnt.Y(), pnt.Z()])
|
| 91 |
+
except Exception:
|
| 92 |
+
continue
|
| 93 |
+
|
| 94 |
+
if not points:
|
| 95 |
+
return np.empty((0, 3), dtype=np.float64)
|
| 96 |
+
|
| 97 |
+
return np.array(points, dtype=np.float64)
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def detect_gaps_and_overlaps(
|
| 101 |
+
shape_a: TopoDS_Shape,
|
| 102 |
+
shape_b: TopoDS_Shape,
|
| 103 |
+
vertices_b: np.ndarray,
|
| 104 |
+
gap_tolerance_mm: float = 0.05,
|
| 105 |
+
overlap_tolerance_mm: float = 0.05,
|
| 106 |
+
) -> GapOverlapResult:
|
| 107 |
+
"""Detect gaps and overlaps by projecting boundary edge points.
|
| 108 |
+
|
| 109 |
+
Samples boundary edges of shape_a and measures distance to
|
| 110 |
+
the closest point on shape_b's mesh surface.
|
| 111 |
+
|
| 112 |
+
Args:
|
| 113 |
+
shape_a: First shape (will sample boundary edges from).
|
| 114 |
+
shape_b: Second shape (reference).
|
| 115 |
+
vertices_b: Tessellated vertices of shape_b.
|
| 116 |
+
gap_tolerance_mm: Gap detection threshold.
|
| 117 |
+
overlap_tolerance_mm: Overlap detection threshold.
|
| 118 |
+
|
| 119 |
+
Returns:
|
| 120 |
+
GapOverlapResult with detected gaps and overlaps.
|
| 121 |
+
"""
|
| 122 |
+
boundary_points = _sample_boundary_edges(shape_a)
|
| 123 |
+
|
| 124 |
+
if len(boundary_points) == 0:
|
| 125 |
+
logger.info("No boundary edges found -- skipping gap/overlap detection")
|
| 126 |
+
return GapOverlapResult()
|
| 127 |
+
|
| 128 |
+
if len(vertices_b) == 0:
|
| 129 |
+
logger.warning("Target mesh has no vertices -- skipping gap/overlap detection")
|
| 130 |
+
return GapOverlapResult()
|
| 131 |
+
|
| 132 |
+
# Build KDTree on target mesh
|
| 133 |
+
target_pcd = o3d.geometry.PointCloud()
|
| 134 |
+
target_pcd.points = o3d.utility.Vector3dVector(vertices_b)
|
| 135 |
+
kdtree = o3d.geometry.KDTreeFlann(target_pcd)
|
| 136 |
+
|
| 137 |
+
result = GapOverlapResult()
|
| 138 |
+
|
| 139 |
+
for point in boundary_points:
|
| 140 |
+
_, idx, dist_sq = kdtree.search_knn_vector_3d(point, 1)
|
| 141 |
+
dist = np.sqrt(dist_sq[0])
|
| 142 |
+
|
| 143 |
+
if dist > gap_tolerance_mm:
|
| 144 |
+
result.gap_distances.append(float(dist))
|
| 145 |
+
result.gap_locations.append(point)
|
| 146 |
+
elif dist < overlap_tolerance_mm * 0.1:
|
| 147 |
+
# Very close = potential overlap (surface interpenetration)
|
| 148 |
+
result.overlap_distances.append(float(dist))
|
| 149 |
+
result.overlap_locations.append(point)
|
| 150 |
+
|
| 151 |
+
logger.info(
|
| 152 |
+
"Gap/overlap detection: %d gaps (max=%.4f mm), %d overlaps (max=%.4f mm)",
|
| 153 |
+
result.gap_count, result.max_gap,
|
| 154 |
+
result.overlap_count, result.max_overlap,
|
| 155 |
+
)
|
| 156 |
+
return result
|
src/compliance/__init__.py
ADDED
|
File without changes
|
src/compliance/kmvss_checker.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""KMVSS compliance checker - rule engine for geometry validation."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import logging
|
| 5 |
+
import re
|
| 6 |
+
from dataclasses import dataclass, field
|
| 7 |
+
|
| 8 |
+
import numpy as np
|
| 9 |
+
from OCP.Bnd import Bnd_Box
|
| 10 |
+
from OCP.BRepBndLib import BRepBndLib
|
| 11 |
+
from OCP.TopoDS import TopoDS_Shape
|
| 12 |
+
|
| 13 |
+
from src.compliance.rule_loader import ComplianceRule
|
| 14 |
+
from src.geometry.measurement import measure_distance
|
| 15 |
+
from src.loader.assembly_tree import AssemblyNode
|
| 16 |
+
|
| 17 |
+
logger = logging.getLogger(__name__)
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
@dataclass
|
| 21 |
+
class ComplianceResult:
|
| 22 |
+
"""Result of a single compliance check."""
|
| 23 |
+
rule_id: str
|
| 24 |
+
rule_name: str
|
| 25 |
+
passed: bool
|
| 26 |
+
severity: str
|
| 27 |
+
measured_value: float | None = None
|
| 28 |
+
threshold_value: float | None = None
|
| 29 |
+
unit: str = "mm"
|
| 30 |
+
message: str = ""
|
| 31 |
+
affected_parts: list[str] = field(default_factory=list)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def _find_parts_by_pattern(root: AssemblyNode, pattern: str) -> list[AssemblyNode]:
|
| 35 |
+
"""Find leaf parts whose name matches a regex pattern."""
|
| 36 |
+
regex = re.compile(pattern, re.IGNORECASE)
|
| 37 |
+
return [leaf for leaf in root.iter_leaves()
|
| 38 |
+
if leaf.shape is not None and regex.search(leaf.name)]
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def _get_bbox_dimension(shape: TopoDS_Shape, axis: str) -> float:
|
| 42 |
+
"""Get a bounding box dimension along an axis."""
|
| 43 |
+
bbox = Bnd_Box()
|
| 44 |
+
BRepBndLib.Add_s(shape, bbox)
|
| 45 |
+
xmin, ymin, zmin, xmax, ymax, zmax = bbox.Get()
|
| 46 |
+
|
| 47 |
+
axis_map = {
|
| 48 |
+
"x": xmax - xmin,
|
| 49 |
+
"y": ymax - ymin,
|
| 50 |
+
"z": zmax - zmin,
|
| 51 |
+
}
|
| 52 |
+
return axis_map.get(axis.lower(), 0.0)
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def _check_bounding_box(
|
| 56 |
+
rule: ComplianceRule,
|
| 57 |
+
root: AssemblyNode,
|
| 58 |
+
) -> ComplianceResult:
|
| 59 |
+
"""Check bounding box dimension constraint."""
|
| 60 |
+
parts = _find_parts_by_pattern(root, rule.params.target_name_pattern)
|
| 61 |
+
|
| 62 |
+
if not parts:
|
| 63 |
+
return ComplianceResult(
|
| 64 |
+
rule_id=rule.id,
|
| 65 |
+
rule_name=rule.name,
|
| 66 |
+
passed=True,
|
| 67 |
+
severity=rule.severity,
|
| 68 |
+
message=f"No parts matching '{rule.params.target_name_pattern}' found (skipped)",
|
| 69 |
+
)
|
| 70 |
+
|
| 71 |
+
max_dim = 0.0
|
| 72 |
+
for part in parts:
|
| 73 |
+
dim = _get_bbox_dimension(part.shape, rule.params.axis)
|
| 74 |
+
max_dim = max(max_dim, dim)
|
| 75 |
+
|
| 76 |
+
passed = max_dim <= rule.params.max_mm
|
| 77 |
+
|
| 78 |
+
return ComplianceResult(
|
| 79 |
+
rule_id=rule.id,
|
| 80 |
+
rule_name=rule.name,
|
| 81 |
+
passed=passed,
|
| 82 |
+
severity=rule.severity,
|
| 83 |
+
measured_value=max_dim,
|
| 84 |
+
threshold_value=rule.params.max_mm,
|
| 85 |
+
message=f"{rule.params.axis.upper()}-axis dimension: {max_dim:.1f} mm (max: {rule.params.max_mm:.1f} mm)",
|
| 86 |
+
affected_parts=[p.name for p in parts],
|
| 87 |
+
)
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def _check_clearance(
|
| 91 |
+
rule: ComplianceRule,
|
| 92 |
+
root: AssemblyNode,
|
| 93 |
+
) -> ComplianceResult:
|
| 94 |
+
"""Check minimum clearance between two part groups."""
|
| 95 |
+
parts_a = _find_parts_by_pattern(root, rule.params.part_a_pattern)
|
| 96 |
+
parts_b = _find_parts_by_pattern(root, rule.params.part_b_pattern)
|
| 97 |
+
|
| 98 |
+
if not parts_a or not parts_b:
|
| 99 |
+
missing = []
|
| 100 |
+
if not parts_a:
|
| 101 |
+
missing.append(rule.params.part_a_pattern)
|
| 102 |
+
if not parts_b:
|
| 103 |
+
missing.append(rule.params.part_b_pattern)
|
| 104 |
+
return ComplianceResult(
|
| 105 |
+
rule_id=rule.id,
|
| 106 |
+
rule_name=rule.name,
|
| 107 |
+
passed=True,
|
| 108 |
+
severity=rule.severity,
|
| 109 |
+
message=f"Parts not found for pattern(s): {', '.join(missing)} (skipped)",
|
| 110 |
+
)
|
| 111 |
+
|
| 112 |
+
min_clearance = float("inf")
|
| 113 |
+
affected = []
|
| 114 |
+
|
| 115 |
+
for pa in parts_a:
|
| 116 |
+
for pb in parts_b:
|
| 117 |
+
try:
|
| 118 |
+
result = measure_distance(pa.shape, pb.shape)
|
| 119 |
+
if result.distance_mm < min_clearance:
|
| 120 |
+
min_clearance = result.distance_mm
|
| 121 |
+
affected = [pa.name, pb.name]
|
| 122 |
+
except Exception as e:
|
| 123 |
+
logger.warning("Clearance check failed for %s vs %s: %s", pa.name, pb.name, e)
|
| 124 |
+
|
| 125 |
+
if min_clearance == float("inf"):
|
| 126 |
+
return ComplianceResult(
|
| 127 |
+
rule_id=rule.id,
|
| 128 |
+
rule_name=rule.name,
|
| 129 |
+
passed=True,
|
| 130 |
+
severity=rule.severity,
|
| 131 |
+
message="Could not measure clearance (skipped)",
|
| 132 |
+
)
|
| 133 |
+
|
| 134 |
+
passed = min_clearance >= rule.params.min_clearance_mm
|
| 135 |
+
|
| 136 |
+
return ComplianceResult(
|
| 137 |
+
rule_id=rule.id,
|
| 138 |
+
rule_name=rule.name,
|
| 139 |
+
passed=passed,
|
| 140 |
+
severity=rule.severity,
|
| 141 |
+
measured_value=min_clearance,
|
| 142 |
+
threshold_value=rule.params.min_clearance_mm,
|
| 143 |
+
unit="mm",
|
| 144 |
+
message=f"Min clearance: {min_clearance:.2f} mm (required: >= {rule.params.min_clearance_mm:.1f} mm)",
|
| 145 |
+
affected_parts=affected,
|
| 146 |
+
)
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
def check_compliance(
|
| 150 |
+
rules: list[ComplianceRule],
|
| 151 |
+
root: AssemblyNode,
|
| 152 |
+
) -> list[ComplianceResult]:
|
| 153 |
+
"""Run all compliance rules against the assembly.
|
| 154 |
+
|
| 155 |
+
Args:
|
| 156 |
+
rules: List of compliance rules to check.
|
| 157 |
+
root: Assembly tree root node.
|
| 158 |
+
|
| 159 |
+
Returns:
|
| 160 |
+
List of ComplianceResult for each rule.
|
| 161 |
+
"""
|
| 162 |
+
results = []
|
| 163 |
+
|
| 164 |
+
for rule in rules:
|
| 165 |
+
logger.info("Checking rule: %s (%s)", rule.name, rule.type)
|
| 166 |
+
|
| 167 |
+
if rule.type == "bounding_box":
|
| 168 |
+
result = _check_bounding_box(rule, root)
|
| 169 |
+
elif rule.type == "clearance":
|
| 170 |
+
result = _check_clearance(rule, root)
|
| 171 |
+
else:
|
| 172 |
+
result = ComplianceResult(
|
| 173 |
+
rule_id=rule.id,
|
| 174 |
+
rule_name=rule.name,
|
| 175 |
+
passed=True,
|
| 176 |
+
severity=rule.severity,
|
| 177 |
+
message=f"Unknown rule type: {rule.type} (skipped)",
|
| 178 |
+
)
|
| 179 |
+
|
| 180 |
+
results.append(result)
|
| 181 |
+
status = "PASS" if result.passed else "FAIL"
|
| 182 |
+
logger.info(" -> %s: %s", status, result.message)
|
| 183 |
+
|
| 184 |
+
pass_count = sum(1 for r in results if r.passed)
|
| 185 |
+
fail_count = len(results) - pass_count
|
| 186 |
+
logger.info("Compliance: %d passed, %d failed out of %d rules",
|
| 187 |
+
pass_count, fail_count, len(results))
|
| 188 |
+
|
| 189 |
+
return results
|
src/compliance/rule_loader.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Load compliance rules from YAML configuration."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import logging
|
| 5 |
+
from dataclasses import dataclass, field
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
import yaml
|
| 9 |
+
|
| 10 |
+
logger = logging.getLogger(__name__)
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
@dataclass
|
| 14 |
+
class RuleParam:
|
| 15 |
+
"""Parameters for a compliance rule."""
|
| 16 |
+
target_name_pattern: str = ""
|
| 17 |
+
part_a_pattern: str = ""
|
| 18 |
+
part_b_pattern: str = ""
|
| 19 |
+
axis: str = ""
|
| 20 |
+
max_mm: float = 0.0
|
| 21 |
+
min_clearance_mm: float = 0.0
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
@dataclass
|
| 25 |
+
class ComplianceRule:
|
| 26 |
+
"""A single compliance rule definition."""
|
| 27 |
+
id: str
|
| 28 |
+
name: str
|
| 29 |
+
description: str
|
| 30 |
+
type: str # bounding_box, clearance, part_height
|
| 31 |
+
params: RuleParam
|
| 32 |
+
severity: str = "error" # error, warning, info
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def load_rules(yaml_path: str | Path) -> list[ComplianceRule]:
|
| 36 |
+
"""Load compliance rules from a YAML file.
|
| 37 |
+
|
| 38 |
+
Args:
|
| 39 |
+
yaml_path: Path to the YAML rules file.
|
| 40 |
+
|
| 41 |
+
Returns:
|
| 42 |
+
List of ComplianceRule objects.
|
| 43 |
+
"""
|
| 44 |
+
yaml_path = Path(yaml_path)
|
| 45 |
+
if not yaml_path.exists():
|
| 46 |
+
logger.warning("Rules file not found: %s", yaml_path)
|
| 47 |
+
return []
|
| 48 |
+
|
| 49 |
+
with open(yaml_path) as f:
|
| 50 |
+
data = yaml.safe_load(f)
|
| 51 |
+
|
| 52 |
+
rules = []
|
| 53 |
+
for rule_data in data.get("rules", []):
|
| 54 |
+
params = RuleParam(**rule_data.get("params", {}))
|
| 55 |
+
rule = ComplianceRule(
|
| 56 |
+
id=rule_data["id"],
|
| 57 |
+
name=rule_data["name"],
|
| 58 |
+
description=rule_data.get("description", ""),
|
| 59 |
+
type=rule_data["type"],
|
| 60 |
+
params=params,
|
| 61 |
+
severity=rule_data.get("severity", "error"),
|
| 62 |
+
)
|
| 63 |
+
rules.append(rule)
|
| 64 |
+
|
| 65 |
+
logger.info("Loaded %d compliance rules from %s", len(rules), yaml_path)
|
| 66 |
+
return rules
|
src/geometry/__init__.py
ADDED
|
File without changes
|
src/geometry/face_extractor.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""B-Rep face extraction: solid shells vs free surfaces."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import logging
|
| 6 |
+
from dataclasses import dataclass
|
| 7 |
+
|
| 8 |
+
from OCP.BRep import BRep_Tool
|
| 9 |
+
from OCP.TopAbs import TopAbs_FACE, TopAbs_SHELL, TopAbs_SOLID
|
| 10 |
+
from OCP.TopExp import TopExp_Explorer
|
| 11 |
+
from OCP.TopoDS import TopoDS, TopoDS_Face, TopoDS_Shape
|
| 12 |
+
|
| 13 |
+
logger = logging.getLogger(__name__)
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
@dataclass
|
| 17 |
+
class FaceExtractionResult:
|
| 18 |
+
"""Result of face extraction from a shape."""
|
| 19 |
+
|
| 20 |
+
faces: list[TopoDS_Face]
|
| 21 |
+
is_solid: bool
|
| 22 |
+
num_solids: int
|
| 23 |
+
num_shells: int
|
| 24 |
+
num_faces: int
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def _count_topology(shape: TopoDS_Shape, topo_type) -> int:
|
| 28 |
+
"""Count topology entities of a given type."""
|
| 29 |
+
count = 0
|
| 30 |
+
exp = TopExp_Explorer(shape, topo_type)
|
| 31 |
+
while exp.More():
|
| 32 |
+
count += 1
|
| 33 |
+
exp.Next()
|
| 34 |
+
return count
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def _has_surface_geometry(face: TopoDS_Face) -> bool:
|
| 38 |
+
"""Check that a face has valid underlying surface geometry."""
|
| 39 |
+
try:
|
| 40 |
+
surface = BRep_Tool.Surface_s(face)
|
| 41 |
+
return surface is not None
|
| 42 |
+
except Exception:
|
| 43 |
+
return False
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def extract_faces(shape: TopoDS_Shape) -> FaceExtractionResult:
|
| 47 |
+
"""Extract faces from a B-Rep shape.
|
| 48 |
+
|
| 49 |
+
For solids (CATIA): extracts outer shell faces.
|
| 50 |
+
For free surfaces (Alias): collects all faces directly.
|
| 51 |
+
|
| 52 |
+
Args:
|
| 53 |
+
shape: The TopoDS_Shape to extract faces from.
|
| 54 |
+
|
| 55 |
+
Returns:
|
| 56 |
+
FaceExtractionResult with the list of faces and metadata.
|
| 57 |
+
"""
|
| 58 |
+
num_solids = _count_topology(shape, TopAbs_SOLID)
|
| 59 |
+
num_shells = _count_topology(shape, TopAbs_SHELL)
|
| 60 |
+
is_solid = num_solids > 0
|
| 61 |
+
|
| 62 |
+
faces: list[TopoDS_Face] = []
|
| 63 |
+
seen_hashes: set[int] = set()
|
| 64 |
+
|
| 65 |
+
if is_solid:
|
| 66 |
+
logger.info(
|
| 67 |
+
"Solid geometry detected (%d solid(s), %d shell(s)). "
|
| 68 |
+
"Extracting outer shell faces.",
|
| 69 |
+
num_solids, num_shells,
|
| 70 |
+
)
|
| 71 |
+
solid_exp = TopExp_Explorer(shape, TopAbs_SOLID)
|
| 72 |
+
while solid_exp.More():
|
| 73 |
+
shell_exp = TopExp_Explorer(solid_exp.Current(), TopAbs_FACE)
|
| 74 |
+
while shell_exp.More():
|
| 75 |
+
face = TopoDS.Face_s(shell_exp.Current())
|
| 76 |
+
h = hash(face)
|
| 77 |
+
if h not in seen_hashes and _has_surface_geometry(face):
|
| 78 |
+
seen_hashes.add(h)
|
| 79 |
+
faces.append(face)
|
| 80 |
+
shell_exp.Next()
|
| 81 |
+
solid_exp.Next()
|
| 82 |
+
else:
|
| 83 |
+
logger.info(
|
| 84 |
+
"Free-surface geometry detected (%d shell(s)). "
|
| 85 |
+
"Collecting all faces.",
|
| 86 |
+
num_shells,
|
| 87 |
+
)
|
| 88 |
+
face_exp = TopExp_Explorer(shape, TopAbs_FACE)
|
| 89 |
+
while face_exp.More():
|
| 90 |
+
face = TopoDS.Face_s(face_exp.Current())
|
| 91 |
+
h = hash(face)
|
| 92 |
+
if h not in seen_hashes and _has_surface_geometry(face):
|
| 93 |
+
seen_hashes.add(h)
|
| 94 |
+
faces.append(face)
|
| 95 |
+
face_exp.Next()
|
| 96 |
+
|
| 97 |
+
num_faces = len(faces)
|
| 98 |
+
logger.info("Extracted %d unique faces (duplicates removed: %s).",
|
| 99 |
+
num_faces, "solid" if is_solid else "surface")
|
| 100 |
+
|
| 101 |
+
return FaceExtractionResult(
|
| 102 |
+
faces=faces,
|
| 103 |
+
is_solid=is_solid,
|
| 104 |
+
num_solids=num_solids,
|
| 105 |
+
num_shells=num_shells,
|
| 106 |
+
num_faces=num_faces,
|
| 107 |
+
)
|
src/geometry/measurement.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Geometry measurement using BRepExtrema."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import logging
|
| 5 |
+
from dataclasses import dataclass
|
| 6 |
+
|
| 7 |
+
import numpy as np
|
| 8 |
+
from OCP.BRepExtrema import BRepExtrema_DistShapeShape
|
| 9 |
+
from OCP.TopoDS import TopoDS_Shape
|
| 10 |
+
|
| 11 |
+
logger = logging.getLogger(__name__)
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
@dataclass
|
| 15 |
+
class DistanceMeasurement:
|
| 16 |
+
"""Result of distance measurement between two shapes."""
|
| 17 |
+
distance_mm: float
|
| 18 |
+
point_a: list[float] # Closest point on shape A
|
| 19 |
+
point_b: list[float] # Closest point on shape B
|
| 20 |
+
num_solutions: int
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
@dataclass
|
| 24 |
+
class ProximityPair:
|
| 25 |
+
"""A pair of parts with their proximity information."""
|
| 26 |
+
part_a_id: str
|
| 27 |
+
part_a_name: str
|
| 28 |
+
part_b_id: str
|
| 29 |
+
part_b_name: str
|
| 30 |
+
min_distance_mm: float
|
| 31 |
+
point_a: list[float]
|
| 32 |
+
point_b: list[float]
|
| 33 |
+
status: str # "collision", "near", "ok"
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def measure_distance(
|
| 37 |
+
shape_a: TopoDS_Shape,
|
| 38 |
+
shape_b: TopoDS_Shape,
|
| 39 |
+
) -> DistanceMeasurement:
|
| 40 |
+
"""Measure minimum distance between two shapes using BRepExtrema.
|
| 41 |
+
|
| 42 |
+
Args:
|
| 43 |
+
shape_a: First shape.
|
| 44 |
+
shape_b: Second shape.
|
| 45 |
+
|
| 46 |
+
Returns:
|
| 47 |
+
DistanceMeasurement with distance and closest points.
|
| 48 |
+
"""
|
| 49 |
+
dist_calc = BRepExtrema_DistShapeShape(shape_a, shape_b)
|
| 50 |
+
dist_calc.Perform()
|
| 51 |
+
|
| 52 |
+
if not dist_calc.IsDone():
|
| 53 |
+
raise RuntimeError("Distance computation failed")
|
| 54 |
+
|
| 55 |
+
distance = dist_calc.Value()
|
| 56 |
+
n_solutions = dist_calc.NbSolution()
|
| 57 |
+
|
| 58 |
+
if n_solutions > 0:
|
| 59 |
+
pt_a = dist_calc.PointOnShape1(1)
|
| 60 |
+
pt_b = dist_calc.PointOnShape2(1)
|
| 61 |
+
point_a = [pt_a.X(), pt_a.Y(), pt_a.Z()]
|
| 62 |
+
point_b = [pt_b.X(), pt_b.Y(), pt_b.Z()]
|
| 63 |
+
else:
|
| 64 |
+
point_a = [0, 0, 0]
|
| 65 |
+
point_b = [0, 0, 0]
|
| 66 |
+
|
| 67 |
+
logger.info("Distance: %.4f mm (%d solutions)", distance, n_solutions)
|
| 68 |
+
|
| 69 |
+
return DistanceMeasurement(
|
| 70 |
+
distance_mm=distance,
|
| 71 |
+
point_a=point_a,
|
| 72 |
+
point_b=point_b,
|
| 73 |
+
num_solutions=n_solutions,
|
| 74 |
+
)
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def scan_proximity(
|
| 78 |
+
parts: list[dict],
|
| 79 |
+
collision_threshold_mm: float = 0.1,
|
| 80 |
+
near_threshold_mm: float = 5.0,
|
| 81 |
+
max_pairs: int = 100,
|
| 82 |
+
) -> list[ProximityPair]:
|
| 83 |
+
"""Scan all part pairs for proximity/collision.
|
| 84 |
+
|
| 85 |
+
Args:
|
| 86 |
+
parts: List of dicts with 'id', 'name', 'shape' keys.
|
| 87 |
+
collision_threshold_mm: Distance below which parts are considered colliding.
|
| 88 |
+
near_threshold_mm: Distance below which parts are considered near.
|
| 89 |
+
max_pairs: Maximum number of pairs to return.
|
| 90 |
+
|
| 91 |
+
Returns:
|
| 92 |
+
List of ProximityPair sorted by distance.
|
| 93 |
+
"""
|
| 94 |
+
results = []
|
| 95 |
+
n = len(parts)
|
| 96 |
+
|
| 97 |
+
logger.info("Scanning proximity for %d parts (%d pairs)", n, n * (n - 1) // 2)
|
| 98 |
+
|
| 99 |
+
for i in range(n):
|
| 100 |
+
for j in range(i + 1, n):
|
| 101 |
+
try:
|
| 102 |
+
measurement = measure_distance(parts[i]["shape"], parts[j]["shape"])
|
| 103 |
+
|
| 104 |
+
if measurement.distance_mm <= near_threshold_mm:
|
| 105 |
+
if measurement.distance_mm <= collision_threshold_mm:
|
| 106 |
+
status = "collision"
|
| 107 |
+
else:
|
| 108 |
+
status = "near"
|
| 109 |
+
|
| 110 |
+
results.append(ProximityPair(
|
| 111 |
+
part_a_id=parts[i]["id"],
|
| 112 |
+
part_a_name=parts[i]["name"],
|
| 113 |
+
part_b_id=parts[j]["id"],
|
| 114 |
+
part_b_name=parts[j]["name"],
|
| 115 |
+
min_distance_mm=measurement.distance_mm,
|
| 116 |
+
point_a=measurement.point_a,
|
| 117 |
+
point_b=measurement.point_b,
|
| 118 |
+
status=status,
|
| 119 |
+
))
|
| 120 |
+
except Exception as e:
|
| 121 |
+
logger.warning("Failed to measure distance between %s and %s: %s",
|
| 122 |
+
parts[i]["name"], parts[j]["name"], e)
|
| 123 |
+
|
| 124 |
+
results.sort(key=lambda p: p.min_distance_mm)
|
| 125 |
+
|
| 126 |
+
collision_count = sum(1 for r in results if r.status == "collision")
|
| 127 |
+
near_count = sum(1 for r in results if r.status == "near")
|
| 128 |
+
logger.info("Proximity scan: %d collisions, %d near pairs", collision_count, near_count)
|
| 129 |
+
|
| 130 |
+
return results[:max_pairs]
|
src/geometry/splitter.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Exterior/interior part classification for assembly review.
|
| 2 |
+
|
| 3 |
+
Classifies parts based on name-pattern matching against configurable
|
| 4 |
+
exterior patterns. Everything that does not match is classified as interior.
|
| 5 |
+
"""
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
import logging
|
| 9 |
+
from dataclasses import dataclass
|
| 10 |
+
|
| 11 |
+
from config.settings import SplitterSettings
|
| 12 |
+
from src.loader.assembly_tree import AssemblyNode
|
| 13 |
+
|
| 14 |
+
logger = logging.getLogger(__name__)
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
@dataclass
|
| 18 |
+
class ClassificationResult:
|
| 19 |
+
"""Result of exterior/interior classification for a part."""
|
| 20 |
+
node_id: str
|
| 21 |
+
name: str
|
| 22 |
+
classification: str # "exterior" or "interior"
|
| 23 |
+
score: int # 1 = matched exterior pattern, 0 = no match
|
| 24 |
+
reasons: list[str]
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def classify_parts(
|
| 28 |
+
root: AssemblyNode,
|
| 29 |
+
settings: SplitterSettings | None = None,
|
| 30 |
+
) -> list[ClassificationResult]:
|
| 31 |
+
"""Classify all leaf parts as exterior or interior.
|
| 32 |
+
|
| 33 |
+
Uses simple case-insensitive substring matching against the configured
|
| 34 |
+
exterior_patterns. Any part whose name matches at least one pattern
|
| 35 |
+
is classified as "exterior"; everything else is "interior".
|
| 36 |
+
|
| 37 |
+
Args:
|
| 38 |
+
root: Assembly tree root node.
|
| 39 |
+
settings: Splitter settings with exterior_patterns.
|
| 40 |
+
|
| 41 |
+
Returns:
|
| 42 |
+
List of ClassificationResult for each leaf part.
|
| 43 |
+
"""
|
| 44 |
+
if settings is None:
|
| 45 |
+
settings = SplitterSettings()
|
| 46 |
+
|
| 47 |
+
patterns = [p.lower() for p in settings.exterior_patterns]
|
| 48 |
+
|
| 49 |
+
results: list[ClassificationResult] = []
|
| 50 |
+
|
| 51 |
+
for leaf in root.iter_leaves():
|
| 52 |
+
if leaf.shape is None or leaf.shape.IsNull():
|
| 53 |
+
continue
|
| 54 |
+
|
| 55 |
+
name_lower = leaf.name.lower()
|
| 56 |
+
matched = [p for p in patterns if p in name_lower]
|
| 57 |
+
|
| 58 |
+
if matched:
|
| 59 |
+
classification = "exterior"
|
| 60 |
+
score = 1
|
| 61 |
+
reasons = [f"name matches exterior pattern(s): {', '.join(matched)}"]
|
| 62 |
+
else:
|
| 63 |
+
classification = "interior"
|
| 64 |
+
score = 0
|
| 65 |
+
reasons = ["no exterior pattern matched"]
|
| 66 |
+
|
| 67 |
+
leaf.classification = classification
|
| 68 |
+
|
| 69 |
+
result = ClassificationResult(
|
| 70 |
+
node_id=leaf.id,
|
| 71 |
+
name=leaf.name,
|
| 72 |
+
classification=classification,
|
| 73 |
+
score=score,
|
| 74 |
+
reasons=reasons,
|
| 75 |
+
)
|
| 76 |
+
results.append(result)
|
| 77 |
+
|
| 78 |
+
logger.debug("Part '%s': -> %s (%s)", leaf.name, classification, "; ".join(reasons))
|
| 79 |
+
|
| 80 |
+
ext_count = sum(1 for r in results if r.classification == "exterior")
|
| 81 |
+
int_count = sum(1 for r in results if r.classification == "interior")
|
| 82 |
+
logger.info("Classification: %d exterior, %d interior", ext_count, int_count)
|
| 83 |
+
|
| 84 |
+
return results
|
src/geometry/tessellator.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Mesh tessellation and surface point sampling."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import logging
|
| 5 |
+
|
| 6 |
+
import numpy as np
|
| 7 |
+
from OCP.BRep import BRep_Tool
|
| 8 |
+
from OCP.BRepMesh import BRepMesh_IncrementalMesh
|
| 9 |
+
from OCP.TopAbs import TopAbs_FACE
|
| 10 |
+
from OCP.TopExp import TopExp_Explorer
|
| 11 |
+
from OCP.TopLoc import TopLoc_Location
|
| 12 |
+
from OCP.TopoDS import TopoDS, TopoDS_Face, TopoDS_Shape
|
| 13 |
+
|
| 14 |
+
from config.settings import SamplingSettings, TessellationSettings
|
| 15 |
+
|
| 16 |
+
logger = logging.getLogger(__name__)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def tessellate_shape(
|
| 20 |
+
shape: TopoDS_Shape,
|
| 21 |
+
settings: TessellationSettings,
|
| 22 |
+
target_tolerance_mm: float = 0.05,
|
| 23 |
+
) -> None:
|
| 24 |
+
"""Tessellate a shape in-place."""
|
| 25 |
+
deflection = max(
|
| 26 |
+
settings.min_deflection_mm,
|
| 27 |
+
min(settings.max_deflection_mm, target_tolerance_mm * settings.deflection_factor),
|
| 28 |
+
)
|
| 29 |
+
logger.info("Tessellating with deflection=%.4f mm", deflection)
|
| 30 |
+
|
| 31 |
+
mesh = BRepMesh_IncrementalMesh(shape, deflection, False, settings.angular_deflection_rad, True)
|
| 32 |
+
mesh.Perform()
|
| 33 |
+
if not mesh.IsDone():
|
| 34 |
+
raise RuntimeError("Tessellation failed")
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def _extract_face_mesh(face: TopoDS_Face) -> tuple[np.ndarray, np.ndarray] | None:
|
| 38 |
+
"""Extract vertices and triangle indices from a tessellated face."""
|
| 39 |
+
loc = TopLoc_Location()
|
| 40 |
+
triangulation = BRep_Tool.Triangulation_s(face, loc)
|
| 41 |
+
if triangulation is None:
|
| 42 |
+
return None
|
| 43 |
+
|
| 44 |
+
trsf = loc.Transformation()
|
| 45 |
+
n_nodes = triangulation.NbNodes()
|
| 46 |
+
n_tris = triangulation.NbTriangles()
|
| 47 |
+
|
| 48 |
+
if n_nodes == 0 or n_tris == 0:
|
| 49 |
+
return None
|
| 50 |
+
|
| 51 |
+
vertices = np.empty((n_nodes, 3), dtype=np.float64)
|
| 52 |
+
for i in range(1, n_nodes + 1):
|
| 53 |
+
node = triangulation.Node(i)
|
| 54 |
+
node_transformed = node.Transformed(trsf)
|
| 55 |
+
vertices[i - 1] = [node_transformed.X(), node_transformed.Y(), node_transformed.Z()]
|
| 56 |
+
|
| 57 |
+
triangles = np.empty((n_tris, 3), dtype=np.int32)
|
| 58 |
+
for i in range(1, n_tris + 1):
|
| 59 |
+
tri = triangulation.Triangle(i)
|
| 60 |
+
n1, n2, n3 = tri.Get()
|
| 61 |
+
triangles[i - 1] = [n1 - 1, n2 - 1, n3 - 1]
|
| 62 |
+
|
| 63 |
+
return vertices, triangles
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def tessellate_faces(
|
| 67 |
+
shape: TopoDS_Shape,
|
| 68 |
+
settings: TessellationSettings,
|
| 69 |
+
target_tolerance_mm: float = 0.05,
|
| 70 |
+
) -> tuple[np.ndarray, np.ndarray]:
|
| 71 |
+
"""Tessellate all faces and return combined mesh."""
|
| 72 |
+
tessellate_shape(shape, settings, target_tolerance_mm)
|
| 73 |
+
|
| 74 |
+
all_vertices: list[np.ndarray] = []
|
| 75 |
+
all_triangles: list[np.ndarray] = []
|
| 76 |
+
vertex_offset = 0
|
| 77 |
+
|
| 78 |
+
face_exp = TopExp_Explorer(shape, TopAbs_FACE)
|
| 79 |
+
while face_exp.More():
|
| 80 |
+
face = TopoDS.Face_s(face_exp.Current())
|
| 81 |
+
result = _extract_face_mesh(face)
|
| 82 |
+
if result is not None:
|
| 83 |
+
verts, tris = result
|
| 84 |
+
all_vertices.append(verts)
|
| 85 |
+
all_triangles.append(tris + vertex_offset)
|
| 86 |
+
vertex_offset += len(verts)
|
| 87 |
+
face_exp.Next()
|
| 88 |
+
|
| 89 |
+
if not all_vertices:
|
| 90 |
+
raise RuntimeError("No mesh data extracted from tessellation")
|
| 91 |
+
|
| 92 |
+
vertices = np.concatenate(all_vertices, axis=0)
|
| 93 |
+
triangles = np.concatenate(all_triangles, axis=0)
|
| 94 |
+
logger.info("Tessellated mesh: %d vertices, %d triangles", len(vertices), len(triangles))
|
| 95 |
+
return vertices, triangles
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def sample_points_on_mesh(
|
| 99 |
+
vertices: np.ndarray,
|
| 100 |
+
triangles: np.ndarray,
|
| 101 |
+
settings: SamplingSettings,
|
| 102 |
+
) -> np.ndarray:
|
| 103 |
+
"""Area-weighted uniform sampling on a triangle mesh."""
|
| 104 |
+
rng = np.random.default_rng(settings.seed)
|
| 105 |
+
|
| 106 |
+
v0 = vertices[triangles[:, 0]]
|
| 107 |
+
v1 = vertices[triangles[:, 1]]
|
| 108 |
+
v2 = vertices[triangles[:, 2]]
|
| 109 |
+
|
| 110 |
+
cross = np.cross(v1 - v0, v2 - v0)
|
| 111 |
+
areas = 0.5 * np.linalg.norm(cross, axis=1)
|
| 112 |
+
total_area = areas.sum()
|
| 113 |
+
if total_area < 1e-15:
|
| 114 |
+
raise RuntimeError("Degenerate mesh: total area is zero")
|
| 115 |
+
|
| 116 |
+
probabilities = areas / total_area
|
| 117 |
+
tri_indices = rng.choice(len(triangles), size=settings.num_samples, p=probabilities)
|
| 118 |
+
|
| 119 |
+
r1 = rng.random(settings.num_samples)
|
| 120 |
+
r2 = rng.random(settings.num_samples)
|
| 121 |
+
sqrt_r1 = np.sqrt(r1)
|
| 122 |
+
|
| 123 |
+
u = 1.0 - sqrt_r1
|
| 124 |
+
v = sqrt_r1 * (1.0 - r2)
|
| 125 |
+
w = sqrt_r1 * r2
|
| 126 |
+
|
| 127 |
+
sampled_v0 = vertices[triangles[tri_indices, 0]]
|
| 128 |
+
sampled_v1 = vertices[triangles[tri_indices, 1]]
|
| 129 |
+
sampled_v2 = vertices[triangles[tri_indices, 2]]
|
| 130 |
+
|
| 131 |
+
points = u[:, None] * sampled_v0 + v[:, None] * sampled_v1 + w[:, None] * sampled_v2
|
| 132 |
+
logger.info("Sampled %d points (total mesh area: %.4f mm²)", len(points), total_area)
|
| 133 |
+
return points
|
src/loader/__init__.py
ADDED
|
File without changes
|
src/loader/assembly_tree.py
ADDED
|
@@ -0,0 +1,324 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Assembly tree extraction from XDE document or shape topology."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import logging
|
| 5 |
+
from dataclasses import dataclass, field
|
| 6 |
+
|
| 7 |
+
from OCP.Bnd import Bnd_Box
|
| 8 |
+
from OCP.BRepBndLib import BRepBndLib
|
| 9 |
+
from OCP.TDF import TDF_Label, TDF_LabelSequence
|
| 10 |
+
from OCP.TDataStd import TDataStd_Name
|
| 11 |
+
from OCP.TopAbs import TopAbs_COMPOUND, TopAbs_SOLID
|
| 12 |
+
from OCP.TopExp import TopExp_Explorer
|
| 13 |
+
from OCP.TopoDS import TopoDS, TopoDS_Shape
|
| 14 |
+
from OCP.XCAFDoc import XCAFDoc_ShapeTool
|
| 15 |
+
|
| 16 |
+
logger = logging.getLogger(__name__)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
@dataclass
|
| 20 |
+
class AssemblyNode:
|
| 21 |
+
"""Node in the assembly tree."""
|
| 22 |
+
id: str
|
| 23 |
+
name: str
|
| 24 |
+
is_assembly: bool = False
|
| 25 |
+
is_leaf: bool = False
|
| 26 |
+
shape: TopoDS_Shape | None = None
|
| 27 |
+
children: list[AssemblyNode] = field(default_factory=list)
|
| 28 |
+
classification: str = "unknown" # exterior, interior, unknown
|
| 29 |
+
|
| 30 |
+
# Geometry info (populated after tessellation)
|
| 31 |
+
num_faces: int = 0
|
| 32 |
+
num_solids: int = 0
|
| 33 |
+
bounding_box: dict | None = None
|
| 34 |
+
|
| 35 |
+
def to_dict(self) -> dict:
|
| 36 |
+
"""Convert to serializable dictionary."""
|
| 37 |
+
result = {
|
| 38 |
+
"id": self.id,
|
| 39 |
+
"name": self.name,
|
| 40 |
+
"is_assembly": self.is_assembly,
|
| 41 |
+
"is_leaf": self.is_leaf,
|
| 42 |
+
"classification": self.classification,
|
| 43 |
+
"num_faces": self.num_faces,
|
| 44 |
+
"num_solids": self.num_solids,
|
| 45 |
+
}
|
| 46 |
+
if self.bounding_box:
|
| 47 |
+
result["bounding_box"] = self.bounding_box
|
| 48 |
+
if self.children:
|
| 49 |
+
result["children"] = [c.to_dict() for c in self.children]
|
| 50 |
+
return result
|
| 51 |
+
|
| 52 |
+
def iter_leaves(self):
|
| 53 |
+
"""Iterate over all leaf nodes."""
|
| 54 |
+
if self.is_leaf:
|
| 55 |
+
yield self
|
| 56 |
+
for child in self.children:
|
| 57 |
+
yield from child.iter_leaves()
|
| 58 |
+
|
| 59 |
+
def find_by_id(self, node_id: str) -> AssemblyNode | None:
|
| 60 |
+
"""Find a node by ID."""
|
| 61 |
+
if self.id == node_id:
|
| 62 |
+
return self
|
| 63 |
+
for child in self.children:
|
| 64 |
+
found = child.find_by_id(node_id)
|
| 65 |
+
if found:
|
| 66 |
+
return found
|
| 67 |
+
return None
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def _get_label_name(label: TDF_Label) -> str:
|
| 71 |
+
"""Extract name from a TDF_Label."""
|
| 72 |
+
name_attr = TDataStd_Name()
|
| 73 |
+
if label.FindAttribute(TDataStd_Name.GetID_s(), name_attr):
|
| 74 |
+
return name_attr.Get().ToExtString()
|
| 75 |
+
return "unnamed"
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def _count_solids(shape: TopoDS_Shape) -> int:
|
| 79 |
+
"""Count solid entities in a shape."""
|
| 80 |
+
count = 0
|
| 81 |
+
exp = TopExp_Explorer(shape, TopAbs_SOLID)
|
| 82 |
+
while exp.More():
|
| 83 |
+
count += 1
|
| 84 |
+
exp.Next()
|
| 85 |
+
return count
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def _compute_bounding_box(shape: TopoDS_Shape) -> dict | None:
|
| 89 |
+
"""Compute the bounding box of a shape."""
|
| 90 |
+
try:
|
| 91 |
+
bbox = Bnd_Box()
|
| 92 |
+
BRepBndLib.Add_s(shape, bbox)
|
| 93 |
+
if bbox.IsVoid():
|
| 94 |
+
return None
|
| 95 |
+
xmin, ymin, zmin, xmax, ymax, zmax = bbox.Get()
|
| 96 |
+
return {
|
| 97 |
+
"min": [xmin, ymin, zmin],
|
| 98 |
+
"max": [xmax, ymax, zmax],
|
| 99 |
+
"size": [xmax - xmin, ymax - ymin, zmax - zmin],
|
| 100 |
+
"center": [(xmin + xmax) / 2, (ymin + ymax) / 2, (zmin + zmax) / 2],
|
| 101 |
+
}
|
| 102 |
+
except Exception:
|
| 103 |
+
return None
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def _build_tree(
|
| 107 |
+
shape_tool: XCAFDoc_ShapeTool,
|
| 108 |
+
label: TDF_Label,
|
| 109 |
+
parent_id: str = "",
|
| 110 |
+
index: int = 0,
|
| 111 |
+
) -> AssemblyNode:
|
| 112 |
+
"""Recursively build assembly tree from XDE label."""
|
| 113 |
+
node_id = f"{parent_id}_{index}" if parent_id else str(index)
|
| 114 |
+
name = _get_label_name(label)
|
| 115 |
+
|
| 116 |
+
is_assembly = shape_tool.IsAssembly(label)
|
| 117 |
+
shape = shape_tool.GetShape(label)
|
| 118 |
+
|
| 119 |
+
node = AssemblyNode(
|
| 120 |
+
id=node_id,
|
| 121 |
+
name=name,
|
| 122 |
+
is_assembly=is_assembly,
|
| 123 |
+
shape=shape if not shape.IsNull() else None,
|
| 124 |
+
)
|
| 125 |
+
|
| 126 |
+
if is_assembly:
|
| 127 |
+
sub_labels = TDF_LabelSequence()
|
| 128 |
+
shape_tool.GetComponents(label, sub_labels)
|
| 129 |
+
|
| 130 |
+
for i in range(1, sub_labels.Length() + 1):
|
| 131 |
+
sub_label = sub_labels.Value(i)
|
| 132 |
+
ref_label = TDF_Label()
|
| 133 |
+
if shape_tool.GetReferredShape(sub_label, ref_label):
|
| 134 |
+
child = _build_tree(shape_tool, ref_label, node_id, i)
|
| 135 |
+
else:
|
| 136 |
+
child = _build_tree(shape_tool, sub_label, node_id, i)
|
| 137 |
+
node.children.append(child)
|
| 138 |
+
else:
|
| 139 |
+
node.is_leaf = True
|
| 140 |
+
if node.shape is not None and not node.shape.IsNull():
|
| 141 |
+
node.num_solids = _count_solids(node.shape)
|
| 142 |
+
node.bounding_box = _compute_bounding_box(node.shape)
|
| 143 |
+
|
| 144 |
+
return node
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
def extract_assembly_tree(shape_tool: XCAFDoc_ShapeTool) -> AssemblyNode:
|
| 148 |
+
"""Extract the full assembly tree from an XDE ShapeTool.
|
| 149 |
+
|
| 150 |
+
Args:
|
| 151 |
+
shape_tool: XCAFDoc_ShapeTool from the loaded document.
|
| 152 |
+
|
| 153 |
+
Returns:
|
| 154 |
+
Root AssemblyNode with the complete tree.
|
| 155 |
+
"""
|
| 156 |
+
labels = TDF_LabelSequence()
|
| 157 |
+
shape_tool.GetFreeShapes(labels)
|
| 158 |
+
|
| 159 |
+
if labels.Length() == 0:
|
| 160 |
+
raise RuntimeError("No free shapes found in XDE document")
|
| 161 |
+
|
| 162 |
+
if labels.Length() == 1:
|
| 163 |
+
root = _build_tree(shape_tool, labels.Value(1), "", 0)
|
| 164 |
+
else:
|
| 165 |
+
root = AssemblyNode(id="root", name="Assembly", is_assembly=True)
|
| 166 |
+
for i in range(1, labels.Length() + 1):
|
| 167 |
+
child = _build_tree(shape_tool, labels.Value(i), "root", i)
|
| 168 |
+
root.children.append(child)
|
| 169 |
+
|
| 170 |
+
leaf_count = sum(1 for _ in root.iter_leaves())
|
| 171 |
+
logger.info("Assembly tree (XDE): %d leaves, root name='%s'", leaf_count, root.name)
|
| 172 |
+
return root
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
def _extract_product_names(reader) -> list[str]:
|
| 176 |
+
"""Extract product names from STEP model entities.
|
| 177 |
+
|
| 178 |
+
Filters out generic translator names and bare shape type names.
|
| 179 |
+
"""
|
| 180 |
+
try:
|
| 181 |
+
from OCP.StepBasic import StepBasic_Product
|
| 182 |
+
ws = reader.WS()
|
| 183 |
+
model = ws.Model()
|
| 184 |
+
skip = {"Open CASCADE STEP translator", "SOLID", "SHELL", "COMPOUND", "COMPSOLID", "WIRE", "EDGE", "VERTEX", "FACE"}
|
| 185 |
+
names = []
|
| 186 |
+
for i in range(1, model.NbEntities() + 1):
|
| 187 |
+
ent = model.Entity(i)
|
| 188 |
+
if isinstance(ent, StepBasic_Product):
|
| 189 |
+
name = ent.Name().ToCString() if ent.Name() else None
|
| 190 |
+
if name and name not in skip and not any(s in name for s in {"Open CASCADE STEP translator"}):
|
| 191 |
+
names.append(name)
|
| 192 |
+
return names
|
| 193 |
+
except Exception:
|
| 194 |
+
return []
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
def _extract_xde_label_names(doc) -> list[str]:
|
| 198 |
+
"""Extract shape names from XDE document's Shapes sublabel tree."""
|
| 199 |
+
try:
|
| 200 |
+
from OCP.TDF import TDF_ChildIterator
|
| 201 |
+
|
| 202 |
+
names = []
|
| 203 |
+
# First child of Main is the "Shapes" label
|
| 204 |
+
it = TDF_ChildIterator(doc.Main(), False)
|
| 205 |
+
if not it.More():
|
| 206 |
+
return []
|
| 207 |
+
shapes_label = it.Value()
|
| 208 |
+
|
| 209 |
+
# Iterate shape sublabels
|
| 210 |
+
it2 = TDF_ChildIterator(shapes_label, False)
|
| 211 |
+
while it2.More():
|
| 212 |
+
label = it2.Value()
|
| 213 |
+
name_attr = TDataStd_Name()
|
| 214 |
+
if label.FindAttribute(TDataStd_Name.GetID_s(), name_attr):
|
| 215 |
+
raw = name_attr.Get().ToExtString()
|
| 216 |
+
# Skip translator meta-names
|
| 217 |
+
if "Open CASCADE STEP translator" not in raw:
|
| 218 |
+
names.append(raw)
|
| 219 |
+
it2.Next()
|
| 220 |
+
|
| 221 |
+
return names
|
| 222 |
+
except Exception:
|
| 223 |
+
return []
|
| 224 |
+
|
| 225 |
+
|
| 226 |
+
def _load_names_json(step_path) -> list[str]:
|
| 227 |
+
"""Load part names from a .names.json sidecar file."""
|
| 228 |
+
import json
|
| 229 |
+
from pathlib import Path
|
| 230 |
+
|
| 231 |
+
names_path = Path(str(step_path) + ".names.json")
|
| 232 |
+
if not names_path.exists():
|
| 233 |
+
# Also try replacing extension
|
| 234 |
+
names_path = Path(step_path).with_suffix(".names.json")
|
| 235 |
+
if not names_path.exists():
|
| 236 |
+
return []
|
| 237 |
+
|
| 238 |
+
try:
|
| 239 |
+
with open(names_path) as f:
|
| 240 |
+
names = json.load(f)
|
| 241 |
+
if isinstance(names, list):
|
| 242 |
+
return names
|
| 243 |
+
except Exception:
|
| 244 |
+
pass
|
| 245 |
+
return []
|
| 246 |
+
|
| 247 |
+
|
| 248 |
+
def extract_assembly_from_shape(
|
| 249 |
+
shape: TopoDS_Shape,
|
| 250 |
+
name: str = "Assembly",
|
| 251 |
+
reader=None,
|
| 252 |
+
doc=None,
|
| 253 |
+
step_path=None,
|
| 254 |
+
id_prefix: str = "",
|
| 255 |
+
) -> AssemblyNode:
|
| 256 |
+
"""Fallback: extract assembly tree from shape topology.
|
| 257 |
+
|
| 258 |
+
Enumerates solids from the shape compound to create leaf nodes.
|
| 259 |
+
Tries to map names from: XDE labels, or STEP products.
|
| 260 |
+
|
| 261 |
+
Args:
|
| 262 |
+
shape: The TopoDS_Shape to decompose.
|
| 263 |
+
name: Root node name.
|
| 264 |
+
reader: Optional STEPControl_Reader for product name extraction.
|
| 265 |
+
doc: Optional TDocStd_Document for XDE label name extraction.
|
| 266 |
+
step_path: Path to the STEP file.
|
| 267 |
+
id_prefix: Prefix for node IDs to avoid collisions between files.
|
| 268 |
+
|
| 269 |
+
Returns:
|
| 270 |
+
Root AssemblyNode with solids as leaves.
|
| 271 |
+
"""
|
| 272 |
+
part_names: list[str] = []
|
| 273 |
+
|
| 274 |
+
# Source 1: STEP product entities (most reliable for named parts)
|
| 275 |
+
if reader is not None:
|
| 276 |
+
product_names = _extract_product_names(reader)
|
| 277 |
+
if product_names:
|
| 278 |
+
part_names = product_names
|
| 279 |
+
logger.info("Found %d part names from STEP products", len(part_names))
|
| 280 |
+
|
| 281 |
+
# Source 2: XDE document labels (fallback)
|
| 282 |
+
if not part_names and doc is not None:
|
| 283 |
+
xde_names = _extract_xde_label_names(doc)
|
| 284 |
+
# Filter out generic shape type names
|
| 285 |
+
skip = {"SOLID", "SHELL", "COMPOUND", "COMPSOLID", "WIRE", "EDGE", "VERTEX", "FACE"}
|
| 286 |
+
xde_names = [n for n in xde_names if n not in skip]
|
| 287 |
+
if xde_names:
|
| 288 |
+
part_names = xde_names
|
| 289 |
+
logger.info("Found %d part names from XDE labels", len(part_names))
|
| 290 |
+
|
| 291 |
+
root_id = f"{id_prefix}0" if id_prefix else "0"
|
| 292 |
+
root = AssemblyNode(id=root_id, name=name, is_assembly=True, shape=shape)
|
| 293 |
+
|
| 294 |
+
solid_exp = TopExp_Explorer(shape, TopAbs_SOLID)
|
| 295 |
+
idx = 0
|
| 296 |
+
while solid_exp.More():
|
| 297 |
+
solid = TopoDS.Solid_s(solid_exp.Current())
|
| 298 |
+
if idx < len(part_names):
|
| 299 |
+
solid_name = part_names[idx]
|
| 300 |
+
else:
|
| 301 |
+
solid_name = f"Part_{idx + 1}"
|
| 302 |
+
|
| 303 |
+
node = AssemblyNode(
|
| 304 |
+
id=f"{root_id}_{idx + 1}",
|
| 305 |
+
name=solid_name,
|
| 306 |
+
is_leaf=True,
|
| 307 |
+
shape=solid,
|
| 308 |
+
num_solids=1,
|
| 309 |
+
bounding_box=_compute_bounding_box(solid),
|
| 310 |
+
)
|
| 311 |
+
root.children.append(node)
|
| 312 |
+
idx += 1
|
| 313 |
+
solid_exp.Next()
|
| 314 |
+
|
| 315 |
+
leaf_count = len(root.children)
|
| 316 |
+
if leaf_count == 0:
|
| 317 |
+
root.is_leaf = True
|
| 318 |
+
root.is_assembly = False
|
| 319 |
+
root.num_solids = 0
|
| 320 |
+
root.bounding_box = _compute_bounding_box(shape)
|
| 321 |
+
else:
|
| 322 |
+
logger.info("Assembly tree (shape): %d solids from topology", leaf_count)
|
| 323 |
+
|
| 324 |
+
return root
|
src/loader/step_loader.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""STEP file loading with XDE support and Shape Healing."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import logging
|
| 5 |
+
from dataclasses import dataclass, field
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
from OCP.IFSelect import IFSelect_RetDone
|
| 9 |
+
from OCP.STEPCAFControl import STEPCAFControl_Reader
|
| 10 |
+
from OCP.STEPControl import STEPControl_Reader
|
| 11 |
+
from OCP.ShapeFix import ShapeFix_Shape, ShapeFix_ShapeTolerance
|
| 12 |
+
from OCP.TCollection import TCollection_ExtendedString
|
| 13 |
+
from OCP.TDocStd import TDocStd_Document
|
| 14 |
+
from OCP.TopoDS import TopoDS_Shape
|
| 15 |
+
from OCP.XCAFApp import XCAFApp_Application
|
| 16 |
+
from OCP.XCAFDoc import XCAFDoc_ShapeTool
|
| 17 |
+
from OCP.TDF import TDF_LabelSequence
|
| 18 |
+
|
| 19 |
+
logger = logging.getLogger(__name__)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
@dataclass
|
| 23 |
+
class StepFileInfo:
|
| 24 |
+
"""Metadata about a loaded STEP file."""
|
| 25 |
+
path: Path
|
| 26 |
+
shape: TopoDS_Shape
|
| 27 |
+
doc: TDocStd_Document | None = None
|
| 28 |
+
shape_tool: XCAFDoc_ShapeTool | None = None
|
| 29 |
+
reader: STEPControl_Reader | None = None
|
| 30 |
+
protocol: str = ""
|
| 31 |
+
unit: str = "mm"
|
| 32 |
+
num_roots: int = 0
|
| 33 |
+
extra: dict = field(default_factory=dict)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def _heal_shape(shape: TopoDS_Shape, tolerance: float = 0.01) -> TopoDS_Shape:
|
| 37 |
+
"""Apply shape healing to fix geometry issues."""
|
| 38 |
+
fixer = ShapeFix_Shape(shape)
|
| 39 |
+
fixer.SetPrecision(tolerance)
|
| 40 |
+
fixer.SetMaxTolerance(tolerance * 10)
|
| 41 |
+
fixer.Perform()
|
| 42 |
+
|
| 43 |
+
tol_fixer = ShapeFix_ShapeTolerance()
|
| 44 |
+
tol_fixer.SetTolerance(fixer.Shape(), tolerance)
|
| 45 |
+
|
| 46 |
+
logger.info("Shape healing completed")
|
| 47 |
+
return fixer.Shape()
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def _detect_protocol(reader: STEPControl_Reader) -> str:
|
| 51 |
+
"""Detect AP protocol from STEP file."""
|
| 52 |
+
try:
|
| 53 |
+
ws = reader.WS()
|
| 54 |
+
model = ws.Model()
|
| 55 |
+
if model is not None:
|
| 56 |
+
header = str(model.Header()) if hasattr(model, "Header") else ""
|
| 57 |
+
for ap in ("AP214", "AP203", "AP242"):
|
| 58 |
+
if ap.lower() in header.lower() or ap in header:
|
| 59 |
+
return ap
|
| 60 |
+
except Exception:
|
| 61 |
+
pass
|
| 62 |
+
return "unknown"
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def _load_xde(file_path: Path) -> tuple[TDocStd_Document, XCAFDoc_ShapeTool, str]:
|
| 66 |
+
"""Load STEP into XDE document and return (doc, shape_tool, protocol)."""
|
| 67 |
+
app = XCAFApp_Application.GetApplication_s()
|
| 68 |
+
doc = TDocStd_Document(TCollection_ExtendedString("MDTV-XCAF"))
|
| 69 |
+
app.InitDocument(doc)
|
| 70 |
+
|
| 71 |
+
xde_reader = STEPCAFControl_Reader()
|
| 72 |
+
xde_reader.SetNameMode(True)
|
| 73 |
+
xde_reader.SetColorMode(True)
|
| 74 |
+
xde_reader.SetLayerMode(True)
|
| 75 |
+
|
| 76 |
+
status = xde_reader.ReadFile(str(file_path))
|
| 77 |
+
if status != IFSelect_RetDone:
|
| 78 |
+
raise RuntimeError(f"Failed to read STEP file: {file_path} (status={status})")
|
| 79 |
+
|
| 80 |
+
protocol = _detect_protocol(xde_reader.Reader())
|
| 81 |
+
|
| 82 |
+
if not xde_reader.Transfer(doc):
|
| 83 |
+
raise RuntimeError(f"Failed to transfer STEP data: {file_path}")
|
| 84 |
+
|
| 85 |
+
shape_tool = XCAFDoc_ShapeTool.Set_s(doc.Main())
|
| 86 |
+
return doc, shape_tool, protocol
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def _load_basic(file_path: Path) -> tuple[TopoDS_Shape, str, int, STEPControl_Reader]:
|
| 90 |
+
"""Fallback: load with basic STEPControl_Reader."""
|
| 91 |
+
reader = STEPControl_Reader()
|
| 92 |
+
status = reader.ReadFile(str(file_path))
|
| 93 |
+
if status != IFSelect_RetDone:
|
| 94 |
+
raise RuntimeError(f"Failed to read STEP file: {file_path}")
|
| 95 |
+
|
| 96 |
+
protocol = _detect_protocol(reader)
|
| 97 |
+
num_roots = reader.NbRootsForTransfer()
|
| 98 |
+
reader.TransferRoots()
|
| 99 |
+
shape = reader.OneShape()
|
| 100 |
+
return shape, protocol, num_roots, reader
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def load_step(file_path: str | Path, heal: bool = True) -> StepFileInfo:
|
| 104 |
+
"""Load a STEP file, trying XDE reader first then falling back to basic.
|
| 105 |
+
|
| 106 |
+
Args:
|
| 107 |
+
file_path: Path to the .stp/.step file.
|
| 108 |
+
heal: Whether to apply shape healing.
|
| 109 |
+
|
| 110 |
+
Returns:
|
| 111 |
+
StepFileInfo with shape, XDE document (if available), and metadata.
|
| 112 |
+
"""
|
| 113 |
+
file_path = Path(file_path)
|
| 114 |
+
if not file_path.exists():
|
| 115 |
+
raise FileNotFoundError(f"STEP file not found: {file_path}")
|
| 116 |
+
|
| 117 |
+
logger.info("Loading STEP file: %s (%.1f MB)", file_path, file_path.stat().st_size / 1e6)
|
| 118 |
+
|
| 119 |
+
doc = None
|
| 120 |
+
shape_tool = None
|
| 121 |
+
shape = None
|
| 122 |
+
basic_reader = None
|
| 123 |
+
protocol = "unknown"
|
| 124 |
+
num_roots = 0
|
| 125 |
+
|
| 126 |
+
# Try XDE reader for assembly structure
|
| 127 |
+
try:
|
| 128 |
+
doc, shape_tool, protocol = _load_xde(file_path)
|
| 129 |
+
|
| 130 |
+
labels = TDF_LabelSequence()
|
| 131 |
+
shape_tool.GetFreeShapes(labels)
|
| 132 |
+
num_roots = labels.Length()
|
| 133 |
+
logger.info("XDE reader: %d free shape(s), protocol=%s", num_roots, protocol)
|
| 134 |
+
|
| 135 |
+
if num_roots > 0:
|
| 136 |
+
if num_roots == 1:
|
| 137 |
+
shape = shape_tool.GetShape(labels.Value(1))
|
| 138 |
+
else:
|
| 139 |
+
from OCP.BRep import BRep_Builder
|
| 140 |
+
from OCP.TopoDS import TopoDS_Compound
|
| 141 |
+
builder = BRep_Builder()
|
| 142 |
+
compound = TopoDS_Compound()
|
| 143 |
+
builder.MakeCompound(compound)
|
| 144 |
+
for i in range(1, num_roots + 1):
|
| 145 |
+
builder.Add(compound, shape_tool.GetShape(labels.Value(i)))
|
| 146 |
+
shape = compound
|
| 147 |
+
except Exception as e:
|
| 148 |
+
logger.warning("XDE reader failed: %s", e)
|
| 149 |
+
|
| 150 |
+
# Fallback to basic reader if XDE produced no shapes
|
| 151 |
+
if shape is None or shape.IsNull():
|
| 152 |
+
logger.info("Falling back to basic STEPControl_Reader")
|
| 153 |
+
shape, protocol, num_roots, basic_reader = _load_basic(file_path)
|
| 154 |
+
|
| 155 |
+
if shape.IsNull():
|
| 156 |
+
raise RuntimeError(f"Empty shape in STEP file: {file_path}")
|
| 157 |
+
|
| 158 |
+
if heal:
|
| 159 |
+
shape = _heal_shape(shape)
|
| 160 |
+
|
| 161 |
+
return StepFileInfo(
|
| 162 |
+
path=file_path,
|
| 163 |
+
shape=shape,
|
| 164 |
+
doc=doc,
|
| 165 |
+
shape_tool=shape_tool,
|
| 166 |
+
reader=basic_reader,
|
| 167 |
+
protocol=protocol,
|
| 168 |
+
num_roots=num_roots,
|
| 169 |
+
)
|
src/mcp/__init__.py
ADDED
|
File without changes
|
src/mcp/server.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""MCP server for CAD review tools."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import json
|
| 5 |
+
import logging
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
logger = logging.getLogger(__name__)
|
| 9 |
+
|
| 10 |
+
# MCP server setup - this will be a FastMCP-based server
|
| 11 |
+
# that exposes tools for interacting with the CAD review system
|
| 12 |
+
|
| 13 |
+
try:
|
| 14 |
+
from mcp.server.fastmcp import FastMCP
|
| 15 |
+
|
| 16 |
+
mcp = FastMCP("cad-review")
|
| 17 |
+
|
| 18 |
+
from src.mcp.tools import register_tools
|
| 19 |
+
register_tools(mcp)
|
| 20 |
+
|
| 21 |
+
except ImportError:
|
| 22 |
+
logger.info("MCP SDK not available - MCP server disabled")
|
| 23 |
+
mcp = None
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def run_mcp_server():
|
| 27 |
+
"""Run the MCP server."""
|
| 28 |
+
if mcp is None:
|
| 29 |
+
raise RuntimeError("MCP SDK not installed. Install with: pip install mcp")
|
| 30 |
+
mcp.run()
|
src/mcp/tools.py
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""MCP tool definitions for CAD review."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import logging
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
|
| 7 |
+
logger = logging.getLogger(__name__)
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def register_tools(mcp):
|
| 11 |
+
"""Register all MCP tools."""
|
| 12 |
+
|
| 13 |
+
@mcp.tool()
|
| 14 |
+
def get_assembly_tree(job_id: str) -> str:
|
| 15 |
+
"""Get the assembly tree structure for a loaded STEP file.
|
| 16 |
+
|
| 17 |
+
Args:
|
| 18 |
+
job_id: The job ID from uploading a STEP file.
|
| 19 |
+
"""
|
| 20 |
+
from src.api.router import get_manager
|
| 21 |
+
manager = get_manager()
|
| 22 |
+
job = manager.get_job(job_id)
|
| 23 |
+
if job is None:
|
| 24 |
+
return f"Error: Job not found: {job_id}"
|
| 25 |
+
if job.assembly_tree is None:
|
| 26 |
+
return f"Error: Assembly tree not available yet (status: {job.status})"
|
| 27 |
+
|
| 28 |
+
import json
|
| 29 |
+
return json.dumps(job.assembly_tree.to_dict(), indent=2)
|
| 30 |
+
|
| 31 |
+
@mcp.tool()
|
| 32 |
+
def get_part_info(job_id: str, part_id: str) -> str:
|
| 33 |
+
"""Get detailed information about a specific part.
|
| 34 |
+
|
| 35 |
+
Args:
|
| 36 |
+
job_id: The job ID.
|
| 37 |
+
part_id: The part ID from the assembly tree.
|
| 38 |
+
"""
|
| 39 |
+
from src.api.router import get_manager
|
| 40 |
+
manager = get_manager()
|
| 41 |
+
job = manager.get_job(job_id)
|
| 42 |
+
if job is None:
|
| 43 |
+
return f"Error: Job not found: {job_id}"
|
| 44 |
+
if job.assembly_tree is None:
|
| 45 |
+
return "Error: Assembly tree not available yet"
|
| 46 |
+
|
| 47 |
+
node = job.assembly_tree.find_by_id(part_id)
|
| 48 |
+
if node is None:
|
| 49 |
+
return f"Error: Part not found: {part_id}"
|
| 50 |
+
|
| 51 |
+
import json
|
| 52 |
+
info = node.to_dict()
|
| 53 |
+
mesh = job.part_meshes.get(part_id)
|
| 54 |
+
if mesh:
|
| 55 |
+
info["mesh_vertices"] = len(mesh.vertices)
|
| 56 |
+
info["mesh_triangles"] = len(mesh.triangles)
|
| 57 |
+
return json.dumps(info, indent=2)
|
| 58 |
+
|
| 59 |
+
@mcp.tool()
|
| 60 |
+
def measure_part_distance(job_id: str, part_a_id: str, part_b_id: str) -> str:
|
| 61 |
+
"""Measure the minimum distance between two parts.
|
| 62 |
+
|
| 63 |
+
Args:
|
| 64 |
+
job_id: The job ID.
|
| 65 |
+
part_a_id: ID of the first part.
|
| 66 |
+
part_b_id: ID of the second part.
|
| 67 |
+
"""
|
| 68 |
+
from src.api.router import get_manager
|
| 69 |
+
from src.geometry.measurement import measure_distance
|
| 70 |
+
|
| 71 |
+
manager = get_manager()
|
| 72 |
+
job = manager.get_job(job_id)
|
| 73 |
+
if job is None:
|
| 74 |
+
return f"Error: Job not found: {job_id}"
|
| 75 |
+
if job.assembly_tree is None:
|
| 76 |
+
return "Error: Assembly tree not available yet"
|
| 77 |
+
|
| 78 |
+
node_a = job.assembly_tree.find_by_id(part_a_id)
|
| 79 |
+
node_b = job.assembly_tree.find_by_id(part_b_id)
|
| 80 |
+
|
| 81 |
+
if node_a is None or node_a.shape is None:
|
| 82 |
+
return f"Error: Part not found or has no shape: {part_a_id}"
|
| 83 |
+
if node_b is None or node_b.shape is None:
|
| 84 |
+
return f"Error: Part not found or has no shape: {part_b_id}"
|
| 85 |
+
|
| 86 |
+
result = measure_distance(node_a.shape, node_b.shape)
|
| 87 |
+
|
| 88 |
+
import json
|
| 89 |
+
return json.dumps({
|
| 90 |
+
"distance_mm": result.distance_mm,
|
| 91 |
+
"point_a": result.point_a,
|
| 92 |
+
"point_b": result.point_b,
|
| 93 |
+
}, indent=2)
|
| 94 |
+
|
| 95 |
+
@mcp.tool()
|
| 96 |
+
def check_compliance_rules(job_id: str) -> str:
|
| 97 |
+
"""Run compliance checks on the loaded assembly.
|
| 98 |
+
|
| 99 |
+
Args:
|
| 100 |
+
job_id: The job ID.
|
| 101 |
+
"""
|
| 102 |
+
from src.api.router import get_manager
|
| 103 |
+
from src.compliance.kmvss_checker import check_compliance
|
| 104 |
+
from src.compliance.rule_loader import load_rules
|
| 105 |
+
|
| 106 |
+
manager = get_manager()
|
| 107 |
+
job = manager.get_job(job_id)
|
| 108 |
+
if job is None:
|
| 109 |
+
return f"Error: Job not found: {job_id}"
|
| 110 |
+
if job.assembly_tree is None:
|
| 111 |
+
return "Error: Assembly tree not available yet"
|
| 112 |
+
|
| 113 |
+
rules_path = Path(__file__).parent.parent.parent / "config" / "kmvss_rules.yaml"
|
| 114 |
+
rules = load_rules(rules_path)
|
| 115 |
+
results = check_compliance(rules, job.assembly_tree)
|
| 116 |
+
|
| 117 |
+
import json
|
| 118 |
+
return json.dumps([{
|
| 119 |
+
"rule": r.rule_name,
|
| 120 |
+
"passed": r.passed,
|
| 121 |
+
"message": r.message,
|
| 122 |
+
"severity": r.severity,
|
| 123 |
+
} for r in results], indent=2)
|
| 124 |
+
|
| 125 |
+
@mcp.tool()
|
| 126 |
+
def get_collision_report(job_id: str) -> str:
|
| 127 |
+
"""Get a collision/proximity report for all parts.
|
| 128 |
+
|
| 129 |
+
Args:
|
| 130 |
+
job_id: The job ID.
|
| 131 |
+
"""
|
| 132 |
+
from src.api.router import get_manager
|
| 133 |
+
from src.geometry.measurement import scan_proximity
|
| 134 |
+
|
| 135 |
+
manager = get_manager()
|
| 136 |
+
job = manager.get_job(job_id)
|
| 137 |
+
if job is None:
|
| 138 |
+
return f"Error: Job not found: {job_id}"
|
| 139 |
+
if job.assembly_tree is None:
|
| 140 |
+
return "Error: Assembly tree not available yet"
|
| 141 |
+
|
| 142 |
+
parts = []
|
| 143 |
+
for leaf in job.assembly_tree.iter_leaves():
|
| 144 |
+
if leaf.shape is not None and not leaf.shape.IsNull():
|
| 145 |
+
parts.append({"id": leaf.id, "name": leaf.name, "shape": leaf.shape})
|
| 146 |
+
|
| 147 |
+
results = scan_proximity(parts)
|
| 148 |
+
|
| 149 |
+
import json
|
| 150 |
+
return json.dumps([{
|
| 151 |
+
"part_a": r.part_a_name,
|
| 152 |
+
"part_b": r.part_b_name,
|
| 153 |
+
"distance_mm": r.min_distance_mm,
|
| 154 |
+
"status": r.status,
|
| 155 |
+
} for r in results], indent=2)
|
uv.lock
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|