lomit commited on
Commit
182efca
·
verified ·
1 Parent(s): bfd7688

Sync from forma-3d-review@b6d4687f5d0f2e5303758c97095ea7e38e740723

Browse files
.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 3d Review Api
3
- emoji: 📉
4
- colorFrom: gray
5
- colorTo: green
6
  sdk: docker
 
7
  pinned: false
8
- license: mit
9
  ---
10
 
11
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
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

  • SHA256: 54d5350d4793d35a53eab8202d3321c79270eac2d39bc90a26ceb466ca01352f
  • Pointer size: 131 Bytes
  • Size of remote file: 167 kB
data/Print2.png ADDED

Git LFS Details

  • SHA256: 8d81182fb66e9983c2fe953e84362218a19a42b81adb360938ba3a14f8dec942
  • Pointer size: 131 Bytes
  • Size of remote file: 214 kB
data/Print3.png ADDED

Git LFS Details

  • SHA256: ef37e8f7270db8e50ae52696acd2d9d1365d79bc7452974193c65eb7e3cb7cca
  • Pointer size: 131 Bytes
  • Size of remote file: 350 kB
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