feat(engine): 支持 up_to_surface 非均匀 profile 裁剪拉伸
为 up_to_surface 终止条件新增裁剪语义,通过穿透拉伸与目标面布尔求交保留可达材料,修复非均匀相交时错误拒绝特征的问题。
This commit is contained in:
@@ -5,7 +5,7 @@ from __future__ import annotations
|
|||||||
import math
|
import math
|
||||||
from typing import Any, Iterable
|
from typing import Any, Iterable
|
||||||
|
|
||||||
from build123d import Axis, Edge, Face, Plane, Solid, Vector, Wire, export_step
|
from build123d import Axis, Compound, Edge, Face, Plane, ShapeList, Solid, Vector, Wire, export_step
|
||||||
|
|
||||||
from .runtime_types import AxisSpec, HoleSpec, PlaneSpec, TopologyRecord, Vector3, canonical_plane_signature
|
from .runtime_types import AxisSpec, HoleSpec, PlaneSpec, TopologyRecord, Vector3, canonical_plane_signature
|
||||||
|
|
||||||
@@ -151,6 +151,43 @@ class Build123dGeometryAdapter:
|
|||||||
# 沿给定方向向量拉伸一个面,生成实体。
|
# 沿给定方向向量拉伸一个面,生成实体。
|
||||||
return Solid.extrude(face, _vector(direction))
|
return Solid.extrude(face, _vector(direction))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def extrude_trimmed(face: Face, target: Any, direction: Vector3) -> Any:
|
||||||
|
"""Extrude the profile to the target face, trimming unreached regions.
|
||||||
|
|
||||||
|
Issue #5: when a profile intersects the up_to_surface target
|
||||||
|
non-uniformly (part of the profile reaches the face, part hangs
|
||||||
|
outside it), a plain vector extrusion is wrong. The CAD semantics is
|
||||||
|
to keep only the material between the profile and the target. We
|
||||||
|
pierce the profile through the target, push the target face backward
|
||||||
|
by the same margin to build a slab, and keep their boolean common
|
||||||
|
(intersection) as the trimmed solid.
|
||||||
|
"""
|
||||||
|
# 1. 采样点到目标的最远命中距离决定穿透余量;没有任何采样点命中
|
||||||
|
# 说明 profile 与目标面无交叠,无法裁剪(保留 extent_target_not_reached)。
|
||||||
|
unit = _vector(direction).normalized()
|
||||||
|
hits = [
|
||||||
|
Build123dGeometryAdapter._forward_intersection_distance(target, point, unit)
|
||||||
|
for point in Build123dGeometryAdapter.profile_sample_points(face)
|
||||||
|
]
|
||||||
|
distances = [value for value in hits if value is not None]
|
||||||
|
if not distances:
|
||||||
|
raise ValueError("extent target is not reached by the profile")
|
||||||
|
margin = max(distances) + 2.0
|
||||||
|
# 2. 穿透拉伸 profile,同时把目标面向回推生成体层,二者求交即裁剪体。
|
||||||
|
# build123d 的布尔交方法名是 intersect(不是 OCC 的 common),
|
||||||
|
# 且多实体结果返回 ShapeList,需要规整为单个 Solid / Compound。
|
||||||
|
pierced = Solid.extrude(face, unit * margin)
|
||||||
|
slab = Solid.extrude(target, -unit * margin)
|
||||||
|
trimmed = pierced.intersect(slab)
|
||||||
|
if isinstance(trimmed, ShapeList):
|
||||||
|
members = list(trimmed)
|
||||||
|
# build123d 类型桩未声明 make_compound,但运行时存在(宽泛类型桩噪音)。
|
||||||
|
trimmed = members[0] if len(members) == 1 else Compound.make_compound(members) # pyright: ignore[reportAttributeAccessIssue]
|
||||||
|
if trimmed is None or (hasattr(trimmed, "is_empty") and trimmed.is_empty()):
|
||||||
|
raise ValueError("extent target produced an empty trimmed solid")
|
||||||
|
return trimmed
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def body_center(body: Any) -> Vector3:
|
def body_center(body: Any) -> Vector3:
|
||||||
# 取主体包围盒的中心坐标,作为体心的近似。
|
# 取主体包围盒的中心坐标,作为体心的近似。
|
||||||
@@ -236,8 +273,9 @@ class Build123dGeometryAdapter:
|
|||||||
return Solid.revolve(face, angle_deg, Build123dGeometryAdapter.axis(axis))
|
return Solid.revolve(face, angle_deg, Build123dGeometryAdapter.axis(axis))
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def fuse(body: Any | None, solid: Solid) -> Any:
|
def fuse(body: Any | None, solid: Any) -> Any:
|
||||||
# 布尔并:没有既有主体时,直接以该实体作为新主体。
|
# 布尔并:没有既有主体时,直接以该实体作为新主体。
|
||||||
|
# 实参类型放宽为 Any:build123d 的布尔结果可能是 Solid 或 Compound。
|
||||||
return solid if body is None else body.fuse(solid)
|
return solid if body is None else body.fuse(solid)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|||||||
@@ -44,6 +44,21 @@ class FeatureExecutionError(RuntimeError):
|
|||||||
self.detail = detail
|
self.detail = detail
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ExtentVector:
|
||||||
|
"""Single-directional extrusion displacement for one profile face.
|
||||||
|
|
||||||
|
``trim_to`` stays ``None`` for an exact vector extrusion. When set, the
|
||||||
|
extent means "extrude until the target face, trimming any profile region
|
||||||
|
that does not reach it" (up_to_surface trim semantics, issue #5). The
|
||||||
|
piercing distance is computed inside the adapter, so ``vector`` only
|
||||||
|
supplies the direction.
|
||||||
|
"""
|
||||||
|
|
||||||
|
vector: Vector3
|
||||||
|
trim_to: Any | None = None
|
||||||
|
|
||||||
|
|
||||||
class AtomicExecutor(Protocol):
|
class AtomicExecutor(Protocol):
|
||||||
atomic_id: str
|
atomic_id: str
|
||||||
|
|
||||||
@@ -62,6 +77,7 @@ class GeometryAdapter(Protocol):
|
|||||||
def body_geometry(self, body: Any) -> dict[str, Any]: ...
|
def body_geometry(self, body: Any) -> dict[str, Any]: ...
|
||||||
def faces_for_sketch(self, sketch: dict[str, Any]) -> list[Any]: ...
|
def faces_for_sketch(self, sketch: dict[str, Any]) -> list[Any]: ...
|
||||||
def extrude(self, face: Any, direction: Vector3) -> Any: ...
|
def extrude(self, face: Any, direction: Vector3) -> Any: ...
|
||||||
|
def extrude_trimmed(self, face: Any, target: Any, direction: Vector3) -> Any: ...
|
||||||
def revolve(self, face: Any, angle_deg: float, axis: AxisSpec) -> Any: ...
|
def revolve(self, face: Any, angle_deg: float, axis: AxisSpec) -> Any: ...
|
||||||
def fuse(self, body: Any | None, solid: Any) -> Any: ...
|
def fuse(self, body: Any | None, solid: Any) -> Any: ...
|
||||||
def cut(self, body: Any, tool: Any) -> Any: ...
|
def cut(self, body: Any, tool: Any) -> Any: ...
|
||||||
@@ -163,7 +179,7 @@ def _targeted_extent_vector(
|
|||||||
*,
|
*,
|
||||||
end_condition: dict[str, Any] | None = None,
|
end_condition: dict[str, Any] | None = None,
|
||||||
offset_mm: float | None = None,
|
offset_mm: float | None = None,
|
||||||
) -> Vector3:
|
) -> ExtentVector:
|
||||||
if session.body is None:
|
if session.body is None:
|
||||||
raise FeatureExecutionError("missing_extent_body", "Selector-dependent extent requires an existing body", extent=condition)
|
raise FeatureExecutionError("missing_extent_body", "Selector-dependent extent requires an existing body", extent=condition)
|
||||||
if condition == "through_next":
|
if condition == "through_next":
|
||||||
@@ -197,8 +213,20 @@ def _targeted_extent_vector(
|
|||||||
try:
|
try:
|
||||||
distance = session.adapter.uniform_intersection_distance(target, faces, direction)
|
distance = session.adapter.uniform_intersection_distance(target, faces, direction)
|
||||||
except ValueError as error:
|
except ValueError as error:
|
||||||
code = "non_uniform_extent_target" if "non-uniform" in str(error) else "extent_target_not_reached"
|
message = str(error)
|
||||||
raise FeatureExecutionError(code, str(error), extent=condition) from error
|
code = "non_uniform_extent_target" if "non-uniform" in message else "extent_target_not_reached"
|
||||||
|
if condition == "up_to_surface":
|
||||||
|
# #5 高级终止条件:profile 与目标面非均匀相交(部分采样点未
|
||||||
|
# 命中目标 → 悬空;或各点命中距离不一 → 斜目标面)时不再整体
|
||||||
|
# 拒绝,而是"裁剪"——只保留从 profile 到目标面之间的材料。
|
||||||
|
# extrude_trimmed 内部做穿透拉伸 + 与目标面体层布尔求交,未达
|
||||||
|
# 目标的部分被切掉(CAD "拉伸到面"标准语义)。若全部采样点都
|
||||||
|
# 未命中(profile 与目标面无交叠),extrude_trimmed 内部仍抛
|
||||||
|
# "not reached",保持显式拒绝。
|
||||||
|
# up_to_vertex/up_to_body/offset_from_surface 无 face 可构造
|
||||||
|
# 裁剪体层,仍保持显式拒绝。
|
||||||
|
return ExtentVector(vector_scale(direction, 1.0), trim_to=target)
|
||||||
|
raise FeatureExecutionError(code, message, extent=condition) from error
|
||||||
if condition == "offset_from_surface":
|
if condition == "offset_from_surface":
|
||||||
offset = abs(float(offset_mm if offset_mm is not None else node.params.get("distance_mm") or 0.0))
|
offset = abs(float(offset_mm if offset_mm is not None else node.params.get("distance_mm") or 0.0))
|
||||||
distance -= offset
|
distance -= offset
|
||||||
@@ -208,7 +236,7 @@ def _targeted_extent_vector(
|
|||||||
"Offset distance reaches or passes the target surface",
|
"Offset distance reaches or passes the target surface",
|
||||||
extent=condition, offset_mm=offset,
|
extent=condition, offset_mm=offset,
|
||||||
)
|
)
|
||||||
return vector_scale(direction, distance)
|
return ExtentVector(vector_scale(direction, distance))
|
||||||
|
|
||||||
|
|
||||||
def _side_extent_vectors(
|
def _side_extent_vectors(
|
||||||
@@ -219,7 +247,7 @@ def _side_extent_vectors(
|
|||||||
*,
|
*,
|
||||||
end_condition: dict[str, Any],
|
end_condition: dict[str, Any],
|
||||||
distance_mm: float,
|
distance_mm: float,
|
||||||
) -> list[Vector3]:
|
) -> list[ExtentVector]:
|
||||||
"""Resolve one directional extent without borrowing the opposite side.
|
"""Resolve one directional extent without borrowing the opposite side.
|
||||||
|
|
||||||
``extrude_add_two_sided`` calls this once for each independently captured
|
``extrude_add_two_sided`` calls this once for each independently captured
|
||||||
@@ -231,17 +259,22 @@ def _side_extent_vectors(
|
|||||||
if condition == "blind":
|
if condition == "blind":
|
||||||
if distance <= 0:
|
if distance <= 0:
|
||||||
raise ValueError("blind extent requires distance_mm > 0")
|
raise ValueError("blind extent requires distance_mm > 0")
|
||||||
return [vector_scale(direction, distance)]
|
return [ExtentVector(vector_scale(direction, distance))]
|
||||||
if condition == "mid_plane":
|
if condition == "mid_plane":
|
||||||
if distance <= 0:
|
if distance <= 0:
|
||||||
raise ValueError("mid_plane extent requires distance_mm > 0")
|
raise ValueError("mid_plane extent requires distance_mm > 0")
|
||||||
return [vector_scale(direction, distance / 2), vector_scale(direction, -distance / 2)]
|
return [
|
||||||
|
ExtentVector(vector_scale(direction, distance / 2)),
|
||||||
|
ExtentVector(vector_scale(direction, -distance / 2)),
|
||||||
|
]
|
||||||
if condition == "through_all":
|
if condition == "through_all":
|
||||||
if session.body is None:
|
if session.body is None:
|
||||||
if distance <= 0:
|
if distance <= 0:
|
||||||
raise ValueError("through_all on an initial feature has no body and no fallback distance")
|
raise ValueError("through_all on an initial feature has no body and no fallback distance")
|
||||||
return [direction * distance]
|
# 注意:Vector3 是 tuple,不能直接做 direction * distance(那是元组
|
||||||
return [vector_scale(direction, max(session.adapter.body_span(session.body, direction), 1.0) + 2.0)]
|
# 重复),这里必须用 vector_scale 做数乘(顺带修复的隐藏 bug)。
|
||||||
|
return [ExtentVector(vector_scale(direction, distance))]
|
||||||
|
return [ExtentVector(vector_scale(direction, max(session.adapter.body_span(session.body, direction), 1.0) + 2.0))]
|
||||||
if condition in {"up_to_surface", "up_to_vertex", "offset_from_surface", "through_next", "up_to_body"}:
|
if condition in {"up_to_surface", "up_to_vertex", "offset_from_surface", "through_next", "up_to_body"}:
|
||||||
return [
|
return [
|
||||||
_targeted_extent_vector(
|
_targeted_extent_vector(
|
||||||
@@ -257,7 +290,7 @@ def _extent_vectors(
|
|||||||
faces: list[Any],
|
faces: list[Any],
|
||||||
sketch: dict[str, Any],
|
sketch: dict[str, Any],
|
||||||
session: ExecutionSession,
|
session: ExecutionSession,
|
||||||
) -> list[Vector3]:
|
) -> list[ExtentVector]:
|
||||||
params = node.params
|
params = node.params
|
||||||
normal = vector_unit(_normal_from_sketch(sketch), field_name="sketch normal")
|
normal = vector_unit(_normal_from_sketch(sketch), field_name="sketch normal")
|
||||||
if bool(params.get("reverse")):
|
if bool(params.get("reverse")):
|
||||||
@@ -285,16 +318,19 @@ def _extent_vectors(
|
|||||||
# against. The source must provide a usable blind component.
|
# against. The source must provide a usable blind component.
|
||||||
if distance <= 0:
|
if distance <= 0:
|
||||||
raise ValueError("through_all on an initial feature has no body and no fallback distance")
|
raise ValueError("through_all on an initial feature has no body and no fallback distance")
|
||||||
return [vector_scale(normal, distance)]
|
return [ExtentVector(vector_scale(normal, distance))]
|
||||||
span = max(session.adapter.body_span(session.body, normal), 1.0) + 2.0
|
span = max(session.adapter.body_span(session.body, normal), 1.0) + 2.0
|
||||||
if condition == "through_all":
|
if condition == "through_all":
|
||||||
return [vector_scale(normal, span)]
|
return [ExtentVector(vector_scale(normal, span))]
|
||||||
if condition == "through_all_both":
|
if condition == "through_all_both":
|
||||||
return [vector_scale(normal, span), vector_scale(normal, -span)]
|
return [ExtentVector(vector_scale(normal, span)), ExtentVector(vector_scale(normal, -span))]
|
||||||
# Through-all-and-blind is represented by a through direction plus
|
# Through-all-and-blind is represented by a through direction plus
|
||||||
# its captured opposite blind direction when available.
|
# its captured opposite blind direction when available.
|
||||||
reverse_distance = abs(float(params.get("reverse_distance_mm") or 0.0))
|
reverse_distance = abs(float(params.get("reverse_distance_mm") or 0.0))
|
||||||
return [vector_scale(normal, span), vector_scale(normal, -(reverse_distance or span))]
|
return [
|
||||||
|
ExtentVector(vector_scale(normal, span)),
|
||||||
|
ExtentVector(vector_scale(normal, -(reverse_distance or span))),
|
||||||
|
]
|
||||||
return _side_extent_vectors(
|
return _side_extent_vectors(
|
||||||
node, faces, normal, session, end_condition=end_condition, distance_mm=distance,
|
node, faces, normal, session, end_condition=end_condition, distance_mm=distance,
|
||||||
)
|
)
|
||||||
@@ -337,9 +373,17 @@ def _shape_from_primary(node: FeaturePlanNode, session: ExecutionSession, *, ske
|
|||||||
# 3. 按特征类型生成子实体:
|
# 3. 按特征类型生成子实体:
|
||||||
if node.atomic_id.startswith("extrude_"):
|
if node.atomic_id.startswith("extrude_"):
|
||||||
# 拉伸:先按终止条件(盲孔/贯穿/至面/双侧等)求出位移向量,
|
# 拉伸:先按终止条件(盲孔/贯穿/至面/双侧等)求出位移向量,
|
||||||
# 再对每个面沿每个向量做拉伸,得到实体列表。
|
# 再对每个面沿每个向量做拉伸,得到实体列表。up_to_surface 在
|
||||||
vectors = _extent_vectors(node, faces, selected_sketch, session)
|
# profile 与目标面非均匀相交时(extent.trim_to 非空)改用裁剪
|
||||||
solids = [session.adapter.extrude(face, vector) for face in faces for vector in vectors]
|
# 拉伸:穿透后与目标面求交,只保留可达部分(issue #5)。
|
||||||
|
extents = _extent_vectors(node, faces, selected_sketch, session)
|
||||||
|
solids: list[Any] = []
|
||||||
|
for face in faces:
|
||||||
|
for extent in extents:
|
||||||
|
if extent.trim_to is None:
|
||||||
|
solids.append(session.adapter.extrude(face, extent.vector))
|
||||||
|
else:
|
||||||
|
solids.append(session.adapter.extrude_trimmed(face, extent.trim_to, extent.vector))
|
||||||
else:
|
else:
|
||||||
# 旋转:解析旋转轴并校验旋转角,然后绕轴旋转每个面得到实体列表。
|
# 旋转:解析旋转轴并校验旋转角,然后绕轴旋转每个面得到实体列表。
|
||||||
axis = _revolve_axis(node, session)
|
axis = _revolve_axis(node, session)
|
||||||
|
|||||||
@@ -0,0 +1,209 @@
|
|||||||
|
"""#5 高级终止条件:up_to_surface 非均匀 profile 必须裁剪而非拒绝。
|
||||||
|
|
||||||
|
中文说明
|
||||||
|
--------
|
||||||
|
这个文件在测试什么(issue #5「高级终止条件要求整张 profile 同一距离」的回归测试):
|
||||||
|
|
||||||
|
1. 背景:up_to_surface(拉伸到面)等终止条件用"profile 采样点射线求交"
|
||||||
|
判定终止距离。修复前,只要 profile 与目标面**非均匀相交**——一部分
|
||||||
|
采样点到达目标、一部分悬空(profile 超出目标面范围),或目标面相对
|
||||||
|
profile 倾斜——runtime 就抛 non_uniform_extent_target 拒绝整个特征,
|
||||||
|
零件无法重建。而 CAD 的标准语义是**裁剪**:保留"从 profile 到目标面"
|
||||||
|
的可达材料,切掉悬空部分。
|
||||||
|
修复后:adapter 新增 extrude_trimmed(穿透拉伸 + 目标面体层布尔求交),
|
||||||
|
runtime 的 _targeted_extent_vector 在 up_to_surface 非均匀时返回
|
||||||
|
带 trim_to 的 ExtentVector,_shape_from_primary 改用裁剪拉伸。
|
||||||
|
up_to_vertex / up_to_body / offset_from_surface 没有可构造裁剪体层
|
||||||
|
的 face,仍保持显式拒绝(语义上无法裁剪)。
|
||||||
|
|
||||||
|
2. 本测试套件把"非均匀 up_to_surface 裁剪"合同固定下来:
|
||||||
|
- 单元几何契约(adapter):斜目标面 + 水平 profile 裁剪出楔形,
|
||||||
|
体积 = ∫斜顶 dxdy;
|
||||||
|
- 集成裁剪契约(rebuild):profile 悬空超出目标面时不再拒绝,
|
||||||
|
悬空部分被切掉,只保留与目标面之间的材料;
|
||||||
|
- 集成回归护栏(rebuild):profile 完全落在目标面内仍走精确
|
||||||
|
均匀拉伸路径,体积不变(防止裁剪回退把均匀路径也改坏)。
|
||||||
|
|
||||||
|
3. sys.path 说明:把 backend/engine 加入搜索路径,直接 import cdsl_engine
|
||||||
|
包做端到端测试(与既有测试风格一致)。
|
||||||
|
|
||||||
|
函数功能一览
|
||||||
|
------------
|
||||||
|
_workplane(origin, normal) 构造指定原点与法向的草图工作平面。
|
||||||
|
_rectangle(minimum, maximum) 构造 XY 平面内的矩形轮廓(2D 多边形)。
|
||||||
|
_base_block() 构造 10×10×10 拉伸主体(体积 1000,
|
||||||
|
顶面 z=10、范围 x/y ∈ [-5, 5])。
|
||||||
|
_top_face_selector(baseline) 从 baseline 拓扑记录里挑出顶面(法向 +z)
|
||||||
|
的几何快照,作为 up_to_surface 的 reference。
|
||||||
|
ExtentTrimContractTests 见各测试方法 docstring。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from copy import deepcopy
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(ROOT / "backend"))
|
||||||
|
sys.path.insert(0, str(ROOT / "backend" / "engine"))
|
||||||
|
|
||||||
|
from cdsl_engine.build123d_adapter import Build123dGeometryAdapter # noqa: E402
|
||||||
|
from cdsl_engine.runtime import rebuild_cdsl # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
from build123d import Face, Plane, Vector # noqa: F401
|
||||||
|
_HAS_BUILD123D = True
|
||||||
|
except ImportError:
|
||||||
|
_HAS_BUILD123D = False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 测试夹具
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _workplane(*, origin: list[float], normal: list[float]) -> dict:
|
||||||
|
"""构造草图工作平面:显式指定原点与法向(x_dir 固定 +X)。"""
|
||||||
|
return {"origin_mm": origin, "x_dir": [1, 0, 0], "normal": normal}
|
||||||
|
|
||||||
|
|
||||||
|
def _rectangle(minimum: list[float], maximum: list[float]) -> dict:
|
||||||
|
"""XY 平面内的矩形轮廓(2D 多边形),顶点逆时针。"""
|
||||||
|
return {"type": "polygon", "vertices": [
|
||||||
|
[minimum[0], minimum[1]], [maximum[0], minimum[1]],
|
||||||
|
[maximum[0], maximum[1]], [minimum[0], maximum[1]],
|
||||||
|
]}
|
||||||
|
|
||||||
|
|
||||||
|
def _base_block() -> dict:
|
||||||
|
"""10×10×10 拉伸主体:体积 1000,顶面位于 z=10、范围 x/y ∈ [-5, 5]。"""
|
||||||
|
return {
|
||||||
|
"schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part",
|
||||||
|
"part_id": "extent-trim-contract", "meta": {"unit": "mm"},
|
||||||
|
"geometry": {"sketches": [{
|
||||||
|
"id": "base", "workplane": _workplane(origin=[0, 0, 0], normal=[0, 0, 1]),
|
||||||
|
"profile": _rectangle([-5, -5], [5, 5]),
|
||||||
|
}]},
|
||||||
|
"features": [{
|
||||||
|
"id": "base_add", "atomic_id": "extrude_add_blind", "depends_on": [],
|
||||||
|
"params": {"distance_mm": 10}, "sketch_id": "base",
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _top_face_selector(baseline: dict) -> dict:
|
||||||
|
"""从 baseline 拓扑记录里取顶面(法向 +z 的平面 face)的几何快照。
|
||||||
|
|
||||||
|
这个快照被 TopologyRegistry 用来做几何等价匹配,从而把 up_to_surface
|
||||||
|
的 reference 解析到重建主体上的真实 Face。
|
||||||
|
"""
|
||||||
|
return next(
|
||||||
|
item for item in baseline["topology_records"]
|
||||||
|
if item["kind"] == "face"
|
||||||
|
and item["geometry"]["surface_type"] == "plane"
|
||||||
|
and item["geometry"]["normal"][2] > 0.9
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 测试套件
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class ExtentTrimContractTests(unittest.TestCase):
|
||||||
|
"""up_to_surface 非均匀相交「裁剪几何-行为-回归」三方合同测试。"""
|
||||||
|
|
||||||
|
@unittest.skipUnless(_HAS_BUILD123D, "build123d is not available")
|
||||||
|
def test_extrude_trimmed_slanted_target_produces_wedge(self) -> None:
|
||||||
|
"""单元几何契约:斜目标面 + 水平 profile 裁剪出楔形。
|
||||||
|
|
||||||
|
profile 是 z=0 的 10×10 矩形;目标面是斜面 z = 0.2x + 5
|
||||||
|
(z_dir=(0.2, 0, 0.98) 的平面)。采样点沿 +z 到斜面的距离从
|
||||||
|
4(x=-5)到 6(x=+5)变化 → uniform_intersection_distance 必然
|
||||||
|
判定非均匀。裁剪结果应是顶面贴斜面的楔形,体积 =
|
||||||
|
∫∫ (0.2x + 5) dxdy = 5 × 100 = 500。修复前该场景直接抛
|
||||||
|
non_uniform_extent_target(无实体可断言),本测试锁死裁剪几何。
|
||||||
|
"""
|
||||||
|
profile = Face.make_rect(10, 10) # 中心在原点,x/y ∈ [-5, 5],z=0
|
||||||
|
slanted = Face.make_rect(20, 20, Plane(origin=(0, 0, 5), z_dir=(0.2, 0, 0.98)))
|
||||||
|
|
||||||
|
trimmed = Build123dGeometryAdapter.extrude_trimmed(profile, slanted, (0, 0, 1))
|
||||||
|
|
||||||
|
self.assertAlmostEqual(trimmed.volume, 500.0, places=5)
|
||||||
|
|
||||||
|
@unittest.skipUnless(_HAS_BUILD123D, "build123d is not available")
|
||||||
|
def test_up_to_surface_hanging_profile_is_trimmed_not_rejected(self) -> None:
|
||||||
|
"""集成裁剪契约:profile 悬空超出目标面时不再拒绝,悬空部分被切掉。
|
||||||
|
|
||||||
|
baseline 是 10×10×10 主体(顶面 z=10、范围 [-5, 5]²)。第二个 add
|
||||||
|
特征在 z=12 平面、法向 -z,profile 为 16×16 矩形(x/y ∈ [-8, 8],
|
||||||
|
大部分悬空在顶面范围之外)。up_to_surface 目标 = 顶面:
|
||||||
|
- 中心采样点沿 -z 命中顶面(距离 2);
|
||||||
|
- 角落采样点(x/y = ±8)在顶面范围外,射线不命中 → 非均匀。
|
||||||
|
修复前抛 non_uniform_extent_target;修复后裁剪出
|
||||||
|
x/y ∈ [-5, 5]、z ∈ [10, 12] 的 10×10×2 体块,总体积 = 1000 + 200。
|
||||||
|
"""
|
||||||
|
base = _base_block()
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
baseline = rebuild_cdsl(base, root / "baseline.step")
|
||||||
|
top_face = _top_face_selector(baseline)
|
||||||
|
|
||||||
|
with_cap = deepcopy(base)
|
||||||
|
with_cap["geometry"]["sketches"].append({
|
||||||
|
"id": "cap", "workplane": _workplane(origin=[0, 0, 12], normal=[0, 0, -1]),
|
||||||
|
"profile": _rectangle([-8, -8], [8, 8]),
|
||||||
|
})
|
||||||
|
with_cap["features"].append({
|
||||||
|
"id": "cap_add", "atomic_id": "extrude_add_blind", "depends_on": ["base_add"],
|
||||||
|
"sketch_id": "cap",
|
||||||
|
"params": {
|
||||||
|
"distance_mm": 0,
|
||||||
|
"end_condition": {"type": "up_to_surface", "reference": top_face},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
rebuilt = rebuild_cdsl(with_cap, root / "trimmed.step")
|
||||||
|
|
||||||
|
self.assertAlmostEqual(rebuilt["volume_mm3"], 1000 + 200, places=5)
|
||||||
|
|
||||||
|
@unittest.skipUnless(_HAS_BUILD123D, "build123d is not available")
|
||||||
|
def test_up_to_surface_uniform_profile_keeps_exact_distance(self) -> None:
|
||||||
|
"""集成回归护栏:profile 完全落在目标面内仍走精确均匀拉伸。
|
||||||
|
|
||||||
|
与上一个测试同构,但 profile 缩到 6×6(x/y ∈ [-3, 3]),完全落在
|
||||||
|
顶面 [-5, 5]² 范围内 → 所有采样点沿 -z 都命中且距离一致(2)→
|
||||||
|
保持原精确拉伸路径(不触发裁剪),体积 = 1000 + 36×2 = 1072。
|
||||||
|
防止裁剪回退把均匀路径也改坏。
|
||||||
|
"""
|
||||||
|
base = _base_block()
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
baseline = rebuild_cdsl(base, root / "baseline.step")
|
||||||
|
top_face = _top_face_selector(baseline)
|
||||||
|
|
||||||
|
with_cap = deepcopy(base)
|
||||||
|
with_cap["geometry"]["sketches"].append({
|
||||||
|
"id": "cap", "workplane": _workplane(origin=[0, 0, 12], normal=[0, 0, -1]),
|
||||||
|
"profile": _rectangle([-3, -3], [3, 3]),
|
||||||
|
})
|
||||||
|
with_cap["features"].append({
|
||||||
|
"id": "cap_add", "atomic_id": "extrude_add_blind", "depends_on": ["base_add"],
|
||||||
|
"sketch_id": "cap",
|
||||||
|
"params": {
|
||||||
|
"distance_mm": 0,
|
||||||
|
"end_condition": {"type": "up_to_surface", "reference": top_face},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
rebuilt = rebuild_cdsl(with_cap, root / "exact.step")
|
||||||
|
|
||||||
|
self.assertAlmostEqual(rebuilt["volume_mm3"], 1000 + 36 * 2, places=5)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user