feat(engine): 支持 up_to_surface 非均匀 profile 裁剪拉伸
为 up_to_surface 终止条件新增裁剪语义,通过穿透拉伸与目标面布尔求交保留可达材料,修复非均匀相交时错误拒绝特征的问题。
This commit is contained in:
@@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
import math
|
||||
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
|
||||
|
||||
@@ -151,6 +151,43 @@ class Build123dGeometryAdapter:
|
||||
# 沿给定方向向量拉伸一个面,生成实体。
|
||||
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
|
||||
def body_center(body: Any) -> Vector3:
|
||||
# 取主体包围盒的中心坐标,作为体心的近似。
|
||||
@@ -236,8 +273,9 @@ class Build123dGeometryAdapter:
|
||||
return Solid.revolve(face, angle_deg, Build123dGeometryAdapter.axis(axis))
|
||||
|
||||
@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)
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -44,6 +44,21 @@ class FeatureExecutionError(RuntimeError):
|
||||
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):
|
||||
atomic_id: str
|
||||
|
||||
@@ -62,6 +77,7 @@ class GeometryAdapter(Protocol):
|
||||
def body_geometry(self, body: Any) -> dict[str, Any]: ...
|
||||
def faces_for_sketch(self, sketch: dict[str, Any]) -> list[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 fuse(self, body: Any | None, solid: 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,
|
||||
offset_mm: float | None = None,
|
||||
) -> Vector3:
|
||||
) -> ExtentVector:
|
||||
if session.body is None:
|
||||
raise FeatureExecutionError("missing_extent_body", "Selector-dependent extent requires an existing body", extent=condition)
|
||||
if condition == "through_next":
|
||||
@@ -197,8 +213,20 @@ def _targeted_extent_vector(
|
||||
try:
|
||||
distance = session.adapter.uniform_intersection_distance(target, faces, direction)
|
||||
except ValueError as error:
|
||||
code = "non_uniform_extent_target" if "non-uniform" in str(error) else "extent_target_not_reached"
|
||||
raise FeatureExecutionError(code, str(error), extent=condition) from error
|
||||
message = str(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":
|
||||
offset = abs(float(offset_mm if offset_mm is not None else node.params.get("distance_mm") or 0.0))
|
||||
distance -= offset
|
||||
@@ -208,7 +236,7 @@ def _targeted_extent_vector(
|
||||
"Offset distance reaches or passes the target surface",
|
||||
extent=condition, offset_mm=offset,
|
||||
)
|
||||
return vector_scale(direction, distance)
|
||||
return ExtentVector(vector_scale(direction, distance))
|
||||
|
||||
|
||||
def _side_extent_vectors(
|
||||
@@ -219,7 +247,7 @@ def _side_extent_vectors(
|
||||
*,
|
||||
end_condition: dict[str, Any],
|
||||
distance_mm: float,
|
||||
) -> list[Vector3]:
|
||||
) -> list[ExtentVector]:
|
||||
"""Resolve one directional extent without borrowing the opposite side.
|
||||
|
||||
``extrude_add_two_sided`` calls this once for each independently captured
|
||||
@@ -231,17 +259,22 @@ def _side_extent_vectors(
|
||||
if condition == "blind":
|
||||
if distance <= 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 distance <= 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 session.body is None:
|
||||
if distance <= 0:
|
||||
raise ValueError("through_all on an initial feature has no body and no fallback distance")
|
||||
return [direction * distance]
|
||||
return [vector_scale(direction, max(session.adapter.body_span(session.body, direction), 1.0) + 2.0)]
|
||||
# 注意:Vector3 是 tuple,不能直接做 direction * distance(那是元组
|
||||
# 重复),这里必须用 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"}:
|
||||
return [
|
||||
_targeted_extent_vector(
|
||||
@@ -257,7 +290,7 @@ def _extent_vectors(
|
||||
faces: list[Any],
|
||||
sketch: dict[str, Any],
|
||||
session: ExecutionSession,
|
||||
) -> list[Vector3]:
|
||||
) -> list[ExtentVector]:
|
||||
params = node.params
|
||||
normal = vector_unit(_normal_from_sketch(sketch), field_name="sketch normal")
|
||||
if bool(params.get("reverse")):
|
||||
@@ -285,16 +318,19 @@ def _extent_vectors(
|
||||
# against. The source must provide a usable blind component.
|
||||
if distance <= 0:
|
||||
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
|
||||
if condition == "through_all":
|
||||
return [vector_scale(normal, span)]
|
||||
return [ExtentVector(vector_scale(normal, span))]
|
||||
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
|
||||
# its captured opposite blind direction when available.
|
||||
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(
|
||||
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. 按特征类型生成子实体:
|
||||
if node.atomic_id.startswith("extrude_"):
|
||||
# 拉伸:先按终止条件(盲孔/贯穿/至面/双侧等)求出位移向量,
|
||||
# 再对每个面沿每个向量做拉伸,得到实体列表。
|
||||
vectors = _extent_vectors(node, faces, selected_sketch, session)
|
||||
solids = [session.adapter.extrude(face, vector) for face in faces for vector in vectors]
|
||||
# 再对每个面沿每个向量做拉伸,得到实体列表。up_to_surface 在
|
||||
# profile 与目标面非均匀相交时(extent.trim_to 非空)改用裁剪
|
||||
# 拉伸:穿透后与目标面求交,只保留可达部分(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:
|
||||
# 旋转:解析旋转轴并校验旋转角,然后绕轴旋转每个面得到实体列表。
|
||||
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