feat: initial commit - massive-cloud point cloud processing system
This commit is contained in:
11
scripts/alter-task-analysis-type-enum.sql
Normal file
11
scripts/alter-task-analysis-type-enum.sql
Normal file
@@ -0,0 +1,11 @@
|
||||
-- MySQL:为 task_analysis_results.type 增加 smooth_heatmap(平滑热力异步分析)
|
||||
-- 在部署或本地 drizzle push 未覆盖 enum 时手动执行。
|
||||
|
||||
ALTER TABLE task_analysis_results
|
||||
MODIFY COLUMN type ENUM(
|
||||
'error_analysis',
|
||||
'measurement',
|
||||
'compression',
|
||||
'smooth_heatmap',
|
||||
'assembly_inspection'
|
||||
) NOT NULL;
|
||||
143
scripts/assembly_nearest_gpu.py
Normal file
143
scripts/assembly_nearest_gpu.py
Normal file
@@ -0,0 +1,143 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
服务端 GPU:PyTorch3D point_face_distance(CUDA)计算点到各零件 STL 三角面的最近距离,
|
||||
与 three-mesh-bvh 语义一致(点到三角形)。无 CUDA / 无依赖时退出码 2,由 Node 回退 CPU。
|
||||
|
||||
依赖(需与 CUDA 版本匹配自行安装):
|
||||
pip install torch trimesh numpy
|
||||
pip install "git+https://github.com/facebookresearch/pytorch3d.git@stable"
|
||||
# 或官方 wheel: https://github.com/facebookresearch/pytorch3d/blob/main/INSTALL.md
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--points", required=True, help="raw float32 little-endian, N*3 bytes")
|
||||
ap.add_argument("--manifest-dir", required=True, help="含 manifest.json 与各 .stl")
|
||||
ap.add_argument("--out-dir", required=True, help="写入 assignments.int32 / distances.f32 / result.json")
|
||||
ap.add_argument("--chunk", type=int, default=65536, help="每批点数,显存不足时调小")
|
||||
args = ap.parse_args()
|
||||
|
||||
try:
|
||||
import numpy as np
|
||||
import torch
|
||||
import trimesh
|
||||
from pytorch3d.loss.point_mesh_distance import point_face_distance
|
||||
except ImportError as e: # noqa: PERF203
|
||||
print(json.dumps({"ok": False, "error": f"import: {e}", "fallback": True}), flush=True)
|
||||
sys.exit(2)
|
||||
|
||||
if not torch.cuda.is_available():
|
||||
print(
|
||||
json.dumps({"ok": False, "error": "no CUDA device", "fallback": True}),
|
||||
flush=True,
|
||||
)
|
||||
sys.exit(2)
|
||||
|
||||
device = torch.device("cuda:0")
|
||||
pts_path = args.points
|
||||
man_dir = args.manifest_dir.rstrip(os.sep)
|
||||
out_dir = args.out_dir
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
|
||||
with open(pts_path, "rb") as f:
|
||||
raw = f.read()
|
||||
|
||||
n_float = len(raw) // 4
|
||||
if n_float % 3 != 0:
|
||||
print(json.dumps({"ok": False, "error": "points file size not multiple of 12"}), flush=True)
|
||||
sys.exit(1)
|
||||
pts_np = np.frombuffer(raw, dtype=np.float32).reshape(-1, 3)
|
||||
n_pt = int(pts_np.shape[0])
|
||||
|
||||
man_path = os.path.join(man_dir, "manifest.json")
|
||||
with open(man_path, encoding="utf-8") as f:
|
||||
man = json.load(f)
|
||||
parts = man.get("parts") or []
|
||||
if not parts:
|
||||
print(json.dumps({"ok": False, "error": "empty manifest.parts"}), flush=True)
|
||||
sys.exit(1)
|
||||
|
||||
stl_infos: list[tuple[torch.Tensor, torch.Tensor]] = []
|
||||
for p in parts:
|
||||
rel = p.get("stlRelative") or p.get("stl_relative")
|
||||
if not rel:
|
||||
print(json.dumps({"ok": False, "error": "part missing stlRelative"}), flush=True)
|
||||
sys.exit(1)
|
||||
stl_path = os.path.join(man_dir, rel)
|
||||
if not os.path.isfile(stl_path):
|
||||
print(json.dumps({"ok": False, "error": f"missing {stl_path}"}), flush=True)
|
||||
sys.exit(1)
|
||||
mesh = trimesh.load(stl_path, force="mesh")
|
||||
if hasattr(mesh, "geometry"):
|
||||
mesh = trimesh.util.concatenate(tuple(mesh.geometry.values()))
|
||||
verts = torch.tensor(mesh.vertices, dtype=torch.float32, device=device)
|
||||
faces = torch.tensor(mesh.faces, dtype=torch.int64, device=device)
|
||||
if faces.numel() == 0:
|
||||
print(json.dumps({"ok": False, "error": f"empty mesh {rel}"}), flush=True)
|
||||
sys.exit(1)
|
||||
stl_infos.append((verts, faces))
|
||||
|
||||
n_parts = len(stl_infos)
|
||||
chunk = max(1024, int(args.chunk))
|
||||
|
||||
# 增量 argmin,避免 (N × 零件数) 距离矩阵占满显存
|
||||
best_d = torch.full((n_pt,), float("inf"), dtype=torch.float32, device=device)
|
||||
best_i = torch.zeros((n_pt,), dtype=torch.int64, device=device)
|
||||
p_first = torch.tensor([0], dtype=torch.int64, device=device)
|
||||
t_first = torch.tensor([0], dtype=torch.int64, device=device)
|
||||
|
||||
for pi in range(n_parts):
|
||||
verts, faces = stl_infos[pi]
|
||||
tris = verts[faces] # (F, 3, 3)
|
||||
for s in range(0, n_pt, chunk):
|
||||
end = min(s + chunk, n_pt)
|
||||
block = torch.tensor(pts_np[s:end], dtype=torch.float32, device=device)
|
||||
mp = end - s
|
||||
d_sq = point_face_distance(
|
||||
block,
|
||||
p_first,
|
||||
tris,
|
||||
t_first,
|
||||
mp,
|
||||
5e-3,
|
||||
)
|
||||
d = torch.sqrt(torch.clamp(d_sq, min=0.0))
|
||||
sl = slice(s, end)
|
||||
improve = d < best_d[sl]
|
||||
best_d[sl] = torch.where(improve, d, best_d[sl])
|
||||
best_i[sl] = torch.where(
|
||||
improve,
|
||||
torch.full((mp,), pi, dtype=torch.int64, device=device),
|
||||
best_i[sl],
|
||||
)
|
||||
|
||||
assign = best_i.to(torch.int32).cpu().numpy()
|
||||
min_d = best_d.to(torch.float32).cpu().numpy()
|
||||
|
||||
a_path = os.path.join(out_dir, "assignments.int32")
|
||||
d_path = os.path.join(out_dir, "distances.f32")
|
||||
assign.tofile(a_path)
|
||||
min_d.tofile(d_path)
|
||||
|
||||
meta = {
|
||||
"ok": True,
|
||||
"n": int(n_pt),
|
||||
"parts": n_parts,
|
||||
"assignmentsFile": a_path,
|
||||
"distancesFile": d_path,
|
||||
"device": str(device),
|
||||
}
|
||||
with open(os.path.join(out_dir, "result.json"), "w", encoding="utf-8") as f:
|
||||
json.dump(meta, f)
|
||||
print(json.dumps(meta), flush=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
267
scripts/deviation_heatmap_gpu.py
Normal file
267
scripts/deviation_heatmap_gpu.py
Normal file
@@ -0,0 +1,267 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
GPU 偏差热力:PyTorch CUDA 计算网格顶点到最近点云点的距离并着色。
|
||||
支持千万~亿级点云:点云不全部 load 进显存,分块流式处理。
|
||||
无 CUDA / 无依赖时退出码 2,由 Node 回退 CPU。
|
||||
|
||||
用法:
|
||||
python3 deviation_heatmap_gpu.py \
|
||||
--cad /path/to/model.obj \
|
||||
--points /tmp/points.bin \
|
||||
--out-dir /tmp/dev-heatmap \
|
||||
[--chunk-v 2048] [--chunk-p 100000]
|
||||
|
||||
输入:
|
||||
--cad STL 或 OBJ 文件路径
|
||||
--points 采样点 float32 little-endian,N*3 个 float(x y z 交替)
|
||||
|
||||
输出(写入 --out-dir):
|
||||
colors.bin float32 little-endian,V*3(每顶点 RGB [0,1])
|
||||
result.json { ok, vertexCount, dispMin, dispMax }
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
|
||||
def normalize_like_viewer(pts):
|
||||
"""居中 + 缩放到约 10 单位"""
|
||||
mn = pts.min(dim=0).values
|
||||
mx = pts.max(dim=0).values
|
||||
center = (mn + mx) / 2
|
||||
max_dim = (mx - mn).max().item()
|
||||
scale = 10.0 / max_dim if max_dim > 0 else 1.0
|
||||
return (pts - center) * scale, scale
|
||||
|
||||
|
||||
def sequential_ramp_rgb(t):
|
||||
"""顺序色标 t∈[0,1]:蓝→绿→黄→红"""
|
||||
import torch
|
||||
stops_t = [0.0, 0.33, 0.66, 1.0]
|
||||
stops_rgb = [
|
||||
(0x21 / 255, 0x66 / 255, 0xAC / 255),
|
||||
(0x4D / 255, 0xAF / 255, 0x4A / 255),
|
||||
(0xFE / 255, 0xE0 / 255, 0x8B / 255),
|
||||
(0xD7 / 255, 0x30 / 255, 0x27 / 255),
|
||||
]
|
||||
t = t.clamp(0.0, 1.0)
|
||||
r = torch.zeros_like(t)
|
||||
g = torch.zeros_like(t)
|
||||
b = torch.zeros_like(t)
|
||||
for i in range(len(stops_t) - 1):
|
||||
t0, t1 = stops_t[i], stops_t[i + 1]
|
||||
r0, g0, b0 = stops_rgb[i]
|
||||
r1, g1, b1 = stops_rgb[i + 1]
|
||||
mask = (t >= t0) & (t <= t1) if i < len(stops_t) - 2 else (t >= t0)
|
||||
s = ((t - t0) / (t1 - t0)).clamp(0, 1)
|
||||
r = torch.where(mask, r0 + (r1 - r0) * s, r)
|
||||
g = torch.where(mask, g0 + (g1 - g0) * s, g)
|
||||
b = torch.where(mask, b0 + (b1 - b0) * s, b)
|
||||
return torch.stack([r, g, b], dim=-1)
|
||||
|
||||
|
||||
def auto_tune_chunks(vram_bytes: int, n_verts: int, n_pts: int):
|
||||
"""根据可用显存自动决定分块大小,预留 1GB 给网格/点云/开销"""
|
||||
usable = max(vram_bytes - 1024 * 1024 * 1024, 512 * 1024 * 1024)
|
||||
# cdist 输出 float32: chunk_v * chunk_p * 4 bytes
|
||||
# 目标:每个子矩阵 ≤ usable 的 50%
|
||||
budget = usable * 0.5
|
||||
chunk_v = min(4096, n_verts)
|
||||
chunk_p = int(budget / (chunk_v * 4))
|
||||
chunk_p = max(10000, min(chunk_p, n_pts))
|
||||
return chunk_v, chunk_p
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--cad", required=True, help="STL 或 OBJ 文件")
|
||||
ap.add_argument("--points", required=True, help="float32 little-endian N*3")
|
||||
ap.add_argument("--out-dir", required=True)
|
||||
ap.add_argument("--chunk-v", type=int, default=0, help="顶点分块,0=自动")
|
||||
ap.add_argument("--chunk-p", type=int, default=0, help="点云分块,0=自动")
|
||||
ap.add_argument("--smooth", type=int, default=40,
|
||||
help="拉普拉斯平滑迭代次数,0=不平滑,越大区域越均匀")
|
||||
ap.add_argument("--smooth-alpha", type=float, default=0.7,
|
||||
help="平滑权重 0~1,越大越平滑")
|
||||
args = ap.parse_args()
|
||||
|
||||
try:
|
||||
import numpy as np
|
||||
import torch
|
||||
import trimesh
|
||||
except ImportError as e:
|
||||
print(json.dumps({"ok": False, "error": f"import: {e}", "fallback": True}), flush=True)
|
||||
sys.exit(2)
|
||||
|
||||
if not torch.cuda.is_available():
|
||||
print(json.dumps({"ok": False, "error": "no CUDA device", "fallback": True}), flush=True)
|
||||
sys.exit(2)
|
||||
|
||||
device = torch.device("cuda:0")
|
||||
vram = torch.cuda.get_device_properties(device).total_memory
|
||||
print(f"[DeviationGPU] VRAM={vram / 1e9:.1f}GB device={torch.cuda.get_device_name(device)}", flush=True)
|
||||
|
||||
t0 = time.time()
|
||||
|
||||
# ── 加载 CAD 网格 ──
|
||||
cad_path = args.cad
|
||||
ext = os.path.splitext(cad_path)[1].lower()
|
||||
mesh = trimesh.load(cad_path, force="mesh")
|
||||
if hasattr(mesh, "geometry"):
|
||||
mesh = trimesh.util.concatenate(tuple(mesh.geometry.values()))
|
||||
verts_np = np.array(mesh.vertices, dtype=np.float32)
|
||||
n_verts = verts_np.shape[0]
|
||||
if n_verts == 0:
|
||||
print(json.dumps({"ok": False, "error": "empty mesh"}), flush=True)
|
||||
sys.exit(1)
|
||||
print(f"[DeviationGPU] mesh {ext} verts={n_verts:,}", flush=True)
|
||||
|
||||
# ── 加载点云(mmap,不占全部内存)──
|
||||
pts_path = args.points
|
||||
file_size = os.path.getsize(pts_path)
|
||||
n_floats = file_size // 4
|
||||
if n_floats % 3 != 0:
|
||||
print(json.dumps({"ok": False, "error": "points size not multiple of 12"}), flush=True)
|
||||
sys.exit(1)
|
||||
n_pts = n_floats // 3
|
||||
pts_mmap = np.memmap(pts_path, dtype=np.float32, mode="r", shape=(n_pts, 3))
|
||||
print(f"[DeviationGPU] points={n_pts:,} ({file_size / 1e9:.2f}GB)", flush=True)
|
||||
|
||||
faces_np = np.array(mesh.faces, dtype=np.int64)
|
||||
n_faces = faces_np.shape[0]
|
||||
print(f"[DeviationGPU] faces={n_faces:,} face_verts={n_faces * 3:,} (Three.js layout)", flush=True)
|
||||
|
||||
# ── 归一化 mesh(放 GPU)──
|
||||
verts_t = torch.tensor(verts_np, dtype=torch.float32, device=device)
|
||||
verts_t, _ = normalize_like_viewer(verts_t)
|
||||
|
||||
# ── 点云归一化参数(在 CPU 上扫一遍 mmap 求 bbox)──
|
||||
SCAN_CAP = min(n_pts, 2_000_000)
|
||||
scan_idx = np.linspace(0, n_pts - 1, SCAN_CAP, dtype=int)
|
||||
scan_pts = pts_mmap[scan_idx]
|
||||
pc_min = scan_pts.min(axis=0)
|
||||
pc_max = scan_pts.max(axis=0)
|
||||
pc_center = (pc_min + pc_max) / 2
|
||||
pc_max_dim = float((pc_max - pc_min).max())
|
||||
pc_scale = 10.0 / pc_max_dim if pc_max_dim > 0 else 1.0
|
||||
|
||||
# ── 分块大小 ──
|
||||
chunk_v = args.chunk_v if args.chunk_v > 0 else 0
|
||||
chunk_p = args.chunk_p if args.chunk_p > 0 else 0
|
||||
if chunk_v == 0 or chunk_p == 0:
|
||||
auto_v, auto_p = auto_tune_chunks(vram, n_verts, n_pts)
|
||||
if chunk_v == 0:
|
||||
chunk_v = auto_v
|
||||
if chunk_p == 0:
|
||||
chunk_p = auto_p
|
||||
sub_mem = chunk_v * chunk_p * 4 / 1024 / 1024
|
||||
total_iters = math.ceil(n_verts / chunk_v) * math.ceil(n_pts / chunk_p)
|
||||
print(f"[DeviationGPU] chunk_v={chunk_v:,} chunk_p={chunk_p:,} sub-matrix≈{sub_mem:.0f}MB iters={total_iters:,}", flush=True)
|
||||
|
||||
# ── 双重分块 KNN ──
|
||||
best_dist = torch.full((n_verts,), float("inf"), dtype=torch.float32, device=device)
|
||||
done_iters = 0
|
||||
last_log = time.time()
|
||||
|
||||
for sv in range(0, n_verts, chunk_v):
|
||||
ev = min(sv + chunk_v, n_verts)
|
||||
block_v = verts_t[sv:ev] # (Bv, 3)
|
||||
|
||||
for sp in range(0, n_pts, chunk_p):
|
||||
ep = min(sp + chunk_p, n_pts)
|
||||
# 从 mmap 读一块点云 → GPU,归一化
|
||||
block_p_np = np.array(pts_mmap[sp:ep]) # copy from mmap
|
||||
block_p = torch.tensor(block_p_np, dtype=torch.float32, device=device)
|
||||
block_p = (block_p - torch.tensor(pc_center, dtype=torch.float32, device=device)) * pc_scale
|
||||
|
||||
d = torch.cdist(block_v, block_p) # (Bv, Bp)
|
||||
min_d, _ = d.min(dim=1)
|
||||
best_dist[sv:ev] = torch.minimum(best_dist[sv:ev], min_d)
|
||||
done_iters += 1
|
||||
|
||||
now = time.time()
|
||||
if now - last_log >= 2.0 or sv == 0:
|
||||
pct = done_iters / total_iters * 100
|
||||
elapsed = now - t0
|
||||
eta = elapsed / max(done_iters, 1) * (total_iters - done_iters)
|
||||
print(f"[DeviationGPU] {pct:.0f}% verts={sv:,}/{n_verts:,} elapsed={elapsed:.1f}s ETA={eta:.0f}s", flush=True)
|
||||
last_log = now
|
||||
|
||||
best_dist_cpu = best_dist.cpu()
|
||||
elapsed = time.time() - t0
|
||||
print(f"[DeviationGPU] KNN done in {elapsed:.1f}s", flush=True)
|
||||
|
||||
# ── 拉普拉斯平滑:相邻顶点距离互相平均,消除逐顶点噪声,形成区域级着色 ──
|
||||
smooth_iters = args.smooth
|
||||
smooth_alpha = args.smooth_alpha
|
||||
if smooth_iters > 0:
|
||||
t_smooth = time.time()
|
||||
edges = mesh.edges_unique # (E, 2) 唯一边
|
||||
e0 = edges[:, 0]
|
||||
e1 = edges[:, 1]
|
||||
|
||||
# 每个顶点的邻居数(度)
|
||||
degree = np.zeros(n_verts, dtype=np.float32)
|
||||
np.add.at(degree, e0, 1)
|
||||
np.add.at(degree, e1, 1)
|
||||
degree[degree == 0] = 1.0
|
||||
|
||||
dist = best_dist_cpu.numpy().copy()
|
||||
alpha = smooth_alpha
|
||||
for it in range(smooth_iters):
|
||||
# 对每个顶点,累加所有邻居的距离值
|
||||
neighbor_sum = np.zeros(n_verts, dtype=np.float64)
|
||||
np.add.at(neighbor_sum, e0, dist[e1])
|
||||
np.add.at(neighbor_sum, e1, dist[e0])
|
||||
neighbor_avg = (neighbor_sum / degree).astype(np.float32)
|
||||
dist = alpha * neighbor_avg + (1 - alpha) * dist
|
||||
|
||||
best_dist_cpu = torch.from_numpy(dist)
|
||||
print(f"[DeviationGPU] smoothed {smooth_iters} iters (alpha={smooth_alpha}) in {time.time() - t_smooth:.1f}s", flush=True)
|
||||
|
||||
# ── 色标范围(4%–96% 分位数)──
|
||||
sorted_d, _ = best_dist_cpu.sort()
|
||||
m = sorted_d.numel()
|
||||
lo = sorted_d[max(0, int(m * 0.04))].item()
|
||||
hi = sorted_d[min(m - 1, int(math.ceil(m * 0.96)) - 1)].item()
|
||||
if hi <= lo:
|
||||
hi = lo + 1e-9
|
||||
disp_min = float(lo)
|
||||
disp_max = float(hi)
|
||||
|
||||
# ── 展开为 per-face-vertex(与 Three.js OBJLoader 非索引布局一致)──
|
||||
face_vert_idx = torch.from_numpy(faces_np.flatten().astype(np.int64))
|
||||
best_dist_face = best_dist_cpu[face_vert_idx] # (F*3,)
|
||||
n_face_verts = best_dist_face.numel()
|
||||
print(f"[DeviationGPU] expanded unique→face verts: {n_verts:,} → {n_face_verts:,}", flush=True)
|
||||
|
||||
# ── 着色 ──
|
||||
t_norm = ((best_dist_face - disp_min) / (disp_max - disp_min)).clamp(0, 1)
|
||||
colors = sequential_ramp_rgb(t_norm) # (F*3, 3)
|
||||
|
||||
# ── 写出 ──
|
||||
os.makedirs(args.out_dir, exist_ok=True)
|
||||
colors_np = colors.numpy().astype(np.float32)
|
||||
colors_path = os.path.join(args.out_dir, "colors.bin")
|
||||
colors_np.tofile(colors_path)
|
||||
|
||||
meta = {
|
||||
"ok": True,
|
||||
"vertexCount": int(n_face_verts),
|
||||
"dispMin": disp_min,
|
||||
"dispMax": disp_max,
|
||||
"pointsUsed": int(n_pts),
|
||||
"elapsedSec": round(time.time() - t0, 1),
|
||||
}
|
||||
with open(os.path.join(args.out_dir, "result.json"), "w", encoding="utf-8") as f:
|
||||
json.dump(meta, f)
|
||||
print(json.dumps(meta), flush=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
51
scripts/snapshot-app-dev-image.sh
Executable file
51
scripts/snapshot-app-dev-image.sh
Executable file
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env bash
|
||||
# 将当前运行的 massive-point-cloud-app-dev 容器固化为镜像,避免下次从 Dockerfile 全量重建。
|
||||
#
|
||||
# 限制(必读):
|
||||
# - 绑定挂载 .:/app 下的代码改动在 commit 时不会打进镜像(运行时仍用宿主机目录)。
|
||||
# - app-node-modules 是命名卷,commit 不会包含 node_modules;勿对 app 执行 docker compose down -v,
|
||||
# 否则需重新 pnpm install(卷在则依赖仍在)。
|
||||
# - 在容器内装到 /usr、/usr/local 等的 apt/pip 等会随 commit 保留。
|
||||
#
|
||||
# 用法:
|
||||
# chmod +x scripts/snapshot-app-dev-image.sh
|
||||
# ./scripts/snapshot-app-dev-image.sh # 标签 massive-point-cloud-app-dev:frozen-YYYYMMDD
|
||||
# ./scripts/snapshot-app-dev-image.sh mytag # 标签 massive-point-cloud-app-dev:mytag
|
||||
# ./scripts/snapshot-app-dev-image.sh mytag ./app-dev-snapshot.tar # 同时 docker save 到文件便于备份/迁移
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
CONTAINER_NAME="${CONTAINER_NAME:-massive-point-cloud-app-dev}"
|
||||
IMAGE_BASENAME="${IMAGE_BASENAME:-massive-point-cloud-app-dev}"
|
||||
TAG="${1:-frozen-$(date +%Y%m%d)}"
|
||||
SAVE_PATH="${2:-}"
|
||||
|
||||
if ! docker inspect "$CONTAINER_NAME" &>/dev/null; then
|
||||
echo "错误: 找不到容器 $CONTAINER_NAME ,请先启动: docker compose -f docker-compose.dev.yml up -d app" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
FULL_IMAGE="${IMAGE_BASENAME}:${TAG}"
|
||||
echo "正在提交容器 $CONTAINER_NAME -> $FULL_IMAGE"
|
||||
docker commit "$CONTAINER_NAME" "$FULL_IMAGE"
|
||||
echo "已生成镜像: $FULL_IMAGE"
|
||||
|
||||
if [[ -n "$SAVE_PATH" ]]; then
|
||||
echo "正在导出: docker save -o $SAVE_PATH $FULL_IMAGE"
|
||||
docker save -o "$SAVE_PATH" "$FULL_IMAGE"
|
||||
echo "已保存: $SAVE_PATH"
|
||||
fi
|
||||
|
||||
cat <<EOF
|
||||
|
||||
后续用固化镜像启动(跳过 Dockerfile 构建):
|
||||
|
||||
export MASSIVE_APP_DEV_IMAGE=$FULL_IMAGE
|
||||
docker compose -f docker-compose.dev.yml -f docker-compose.gpu.yml up -d --no-build
|
||||
|
||||
若不用 GPU 覆盖文件,可去掉 -f docker-compose.gpu.yml 。
|
||||
|
||||
勿使用 --build ,否则会按 Dockerfile 重新构建并覆盖本地 tag 的构建结果。
|
||||
如需长期默认使用该镜像,可在项目根目录 .env 中写入一行:
|
||||
MASSIVE_APP_DEV_IMAGE=$FULL_IMAGE
|
||||
EOF
|
||||
94
scripts/step_parse_mvp.py
Normal file
94
scripts/step_parse_mvp.py
Normal file
@@ -0,0 +1,94 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
STEP → 每 solid 一个 STL + manifest.json(需 cadquery:pip install cadquery)
|
||||
用法: python3 step_parse_mvp.py <step_path> <out_dir>
|
||||
stdout 打印一行 JSON: {"ok":true} 或 {"ok":false,"error":"..."}
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def main() -> None:
|
||||
if len(sys.argv) < 3:
|
||||
err = {"ok": False, "error": "usage: step_parse_mvp.py <step_path> <out_dir>"}
|
||||
print(json.dumps(err), flush=True)
|
||||
sys.exit(2)
|
||||
|
||||
step_path = sys.argv[1]
|
||||
out_dir = sys.argv[2]
|
||||
|
||||
try:
|
||||
import cadquery as cq # type: ignore
|
||||
except ImportError as e:
|
||||
# pip3 与 python3 可能不是同一解释器,须用「当前解释器」安装:见 sys.executable
|
||||
exe = sys.executable
|
||||
pip_cmd = (
|
||||
f'"{exe}" -m pip install --break-system-packages '
|
||||
f"-i https://pypi.tuna.tsinghua.edu.cn/simple cadquery"
|
||||
)
|
||||
detail = f"{type(e).__name__}: {e}"
|
||||
err = {
|
||||
"ok": False,
|
||||
"error": (
|
||||
f"无法 import cadquery({detail})。当前 Python: {exe}。"
|
||||
f"请用**同一解释器**安装(勿单独用 pip3,除非确认与 python3 一致): {pip_cmd}"
|
||||
),
|
||||
}
|
||||
print(json.dumps(err, ensure_ascii=False), flush=True)
|
||||
sys.exit(1)
|
||||
|
||||
if not os.path.isfile(step_path):
|
||||
err = {"ok": False, "error": f"文件不存在: {step_path}"}
|
||||
print(json.dumps(err), flush=True)
|
||||
sys.exit(1)
|
||||
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
|
||||
try:
|
||||
wp = cq.importers.importStep(step_path)
|
||||
except Exception as e:
|
||||
err = {"ok": False, "error": f"importStep: {e}"}
|
||||
print(json.dumps(err), flush=True)
|
||||
sys.exit(1)
|
||||
|
||||
solids: list = []
|
||||
try:
|
||||
solids = list(wp.solids().vals())
|
||||
except Exception:
|
||||
try:
|
||||
solids = [wp.val()]
|
||||
except Exception as e:
|
||||
err = {"ok": False, "error": f"无法取得实体: {e}"}
|
||||
print(json.dumps(err), flush=True)
|
||||
sys.exit(1)
|
||||
|
||||
if not solids:
|
||||
err = {"ok": False, "error": "STEP 中未解析出任何 Solid"}
|
||||
print(json.dumps(err), flush=True)
|
||||
sys.exit(1)
|
||||
|
||||
parts: list[dict] = []
|
||||
for i, solid in enumerate(solids):
|
||||
name = f"part_{i:04d}"
|
||||
stl_name = f"{name}.stl"
|
||||
stl_path = os.path.join(out_dir, stl_name)
|
||||
cq.exporters.export(solid, stl_path)
|
||||
parts.append({"id": name, "name": name, "stlRelative": stl_name})
|
||||
|
||||
manifest = {"ok": True, "parts": parts, "sourceStep": os.path.abspath(step_path)}
|
||||
man_path = os.path.join(out_dir, "manifest.json")
|
||||
with open(man_path, "w", encoding="utf-8") as f:
|
||||
json.dump(manifest, f, indent=2, ensure_ascii=False)
|
||||
|
||||
result_line = json.dumps({"ok": True, "parts": parts, "manifestPath": man_path})
|
||||
# Node 端若 stdout 含 OCP 杂讯,仍可从该文件读取结果
|
||||
with open(os.path.join(out_dir, "_parse_result.json"), "w", encoding="utf-8") as f:
|
||||
f.write(result_line)
|
||||
print(result_line, flush=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user