Files
cdsl-cad/backend/engine/cdsl_engine/extents.py
T
ganjihong 871070c440 refactor(cdsl_engine): extract session/extents/pattern_transform from runtime
Phase 2 of the decoupling refactor (behavior-preserving move):
- runtime_base.py: RuntimeExecutionError, FeatureExecutionError, ExtentVector
- session.py: GeometryAdapter protocol + ExecutionSession
- extents.py: end-condition planning (_extent_vectors family)
- pattern_transform.py: translate/mirror/rotate replay parameter algebra
- runtime.py: keeps executors + registry + entry points; re-exports all
  moved names (incl. test-referenced privates) for import stability

No behavior change; verified against baseline (zero new failures).
2026-09-09 13:13:17 +08:00

232 lines
11 KiB
Python

"""Extrusion/termination-condition planning for the session runtime.
These helpers turn a CDSL feature's end condition (blind, mid-plane,
through-all, up-to-surface, ...) into one or more :class:`ExtentVector`
displacements. They depend on the session only through its adapter and
selector resolution, never on executors.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from .runtime_base import ExtentVector, FeatureExecutionError
from .specs import PlaneSpec, Vector3, vector_dot, vector_scale, vector_subtract, vector_unit
from .topology import FeaturePlanNode
if TYPE_CHECKING: # pragma: no cover - import for type checkers only
from .session import ExecutionSession
def _normal_from_sketch(sketch: dict[str, Any]) -> Vector3:
return PlaneSpec.from_mapping(sketch.get("workplane") or {}).normal
def _extent_reference(node: FeaturePlanNode, condition: dict[str, Any] | None = None) -> dict[str, Any]:
condition = condition or node.params.get("end_condition") or {}
reference = condition.get("reference")
if not isinstance(reference, dict):
raise FeatureExecutionError(
"missing_extent_reference",
"This end condition requires a captured target selector",
extent=condition.get("type"),
)
return reference
def _targeted_extent_vector(
node: FeaturePlanNode,
faces: list[Any],
direction: Vector3,
session: "ExecutionSession",
condition: str,
*,
end_condition: dict[str, Any] | None = None,
offset_mm: float | None = None,
) -> ExtentVector:
if session.body is None:
raise FeatureExecutionError("missing_extent_body", "Selector-dependent extent requires an existing body", extent=condition)
if condition == "through_next":
target = session.body
else:
reference = _extent_reference(node, end_condition)
resolution = session.resolve(reference)
if resolution.status != "resolved" or resolution.record is None:
raise ValueError(resolution.diagnostic.message if resolution.diagnostic else "extent target was not resolved")
expected_kind = {"up_to_vertex": "vertex", "up_to_body": "body"}.get(condition, "face")
if resolution.record.kind != expected_kind:
raise FeatureExecutionError(
"unsupported_extent_target",
"The resolved target kind is incompatible with this end condition",
extent=condition, expected_kind=expected_kind, actual_kind=resolution.record.kind,
)
target = resolution.record.value
if condition == "up_to_vertex":
target_point = session.adapter.vertex_coordinates(target)
projections = [
vector_dot(vector_subtract(target_point, point), direction)
for face in faces
for point in session.adapter.profile_sample_points(face)
]
if not projections or min(projections) <= 1e-6:
raise FeatureExecutionError("extent_target_not_in_direction", "The target vertex is not ahead of the profile", extent=condition)
if max(projections) - min(projections) > 1e-5:
raise FeatureExecutionError("non_uniform_extent_target", "The target vertex does not define one extrusion distance", extent=condition)
distance = sum(projections) / len(projections)
else:
if condition == "up_to_surface" and session.adapter.profile_touches_target(target, faces):
# 草图轮廓本身就在所选终止面上时,selected face 只是拉伸的起始
# 边界。应沿实际拉伸方向穿过当前 body,取下一张完整截获 profile
# 的边界面作为终止面;直接裁剪到 selected face 会生成零厚度工具体。
try:
next_face = session.adapter.next_body_face_after(
session.body, faces, direction, excluded_face=target,
)
except ValueError as error:
raise FeatureExecutionError("extent_target_not_reached", str(error), extent=condition) from error
return ExtentVector(vector_scale(direction, 1.0), trim_to=next_face)
try:
distance = session.adapter.uniform_intersection_distance(target, faces, direction)
except ValueError as error:
message = str(error)
code = "non_uniform_extent_target" if "non-uniform" in message else "extent_target_not_reached"
if condition in {"up_to_surface", "through_next"}:
# #5 高级终止条件:profile 与目标面非均匀相交(部分采样点未
# 命中目标 → 悬空;或各点命中距离不一 → 斜目标面)时不再整体
# 拒绝,而是"裁剪"——只保留从 profile 到目标面之间的材料。
# extrude_trimmed 内部做穿透拉伸 + 与目标面体层布尔求交,未达
# 目标的部分被切掉(CAD "拉伸到面"标准语义)。若全部采样点都
# 未命中(profile 与目标面无交叠),extrude_trimmed 内部仍抛
# "not reached",保持显式拒绝。
# through_next 从当前主体中选取实际命中的下一张面;
# 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
offset = abs(float((end_condition or {}).get("offset_mm") or 0.0))
if condition == "offset_from_surface":
offset = abs(float(offset_mm if offset_mm is not None else offset or node.params.get("distance_mm") or 0.0))
if offset:
distance -= offset
if distance <= 1e-6:
raise FeatureExecutionError(
"invalid_extent_offset",
"Offset distance reaches or passes the target extent",
extent=condition, offset_mm=offset,
)
return ExtentVector(vector_scale(direction, distance))
def _side_extent_vectors(
node: FeaturePlanNode,
faces: list[Any],
direction: Vector3,
session: "ExecutionSession",
*,
end_condition: dict[str, Any],
distance_mm: float,
) -> list[ExtentVector]:
"""Resolve one directional extent without borrowing the opposite side.
``extrude_add_two_sided`` and ``extrude_cut_two_sided`` call this once for each independently captured
termination. The regular one-sided executor also uses it for all simple
termination modes, keeping the geometry adapter interface uniform.
"""
condition = str(end_condition.get("type") or "blind")
distance = abs(float(distance_mm or 0.0))
if condition == "blind":
if distance <= 0:
raise ValueError("blind extent requires distance_mm > 0")
return [ExtentVector(vector_scale(direction, distance))]
if condition == "mid_plane":
if distance <= 0:
raise ValueError("mid_plane extent requires distance_mm > 0")
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")
# 注意: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(
node, faces, direction, session, condition,
end_condition=end_condition, offset_mm=distance,
)
]
raise ValueError(f"unsupported directional extent {condition!r}")
def _extent_vectors(
node: FeaturePlanNode,
faces: list[Any],
sketch: dict[str, Any],
session: "ExecutionSession",
) -> list[ExtentVector]:
return _extent_vectors_from_normal(
node, faces, vector_unit(_normal_from_sketch(sketch), field_name="sketch normal"), session,
)
def _extent_vectors_from_normal(
node: FeaturePlanNode,
faces: list[Any],
profile_normal: Vector3,
session: "ExecutionSession",
) -> list[ExtentVector]:
"""Resolve extents from an explicit profile normal.
A derived profile can be an actual B-rep face rather than a sketch. Its
outward normal is just as authoritative as a sketch workplane normal, so
both profile sources share the same bounded extent semantics.
"""
params = node.params
normal = vector_unit(profile_normal, field_name="profile normal")
if bool(params.get("reverse")):
normal = vector_scale(normal, -1)
end_condition = params.get("end_condition") or {"type": "blind"}
condition = end_condition.get("type", "blind")
distance = abs(float(params.get("distance_mm") or 0.0))
if node.atomic_id in {"extrude_add_two_sided", "extrude_cut_two_sided"} or bool(params.get("two_sided")):
reverse_condition = params.get("reverse_end_condition") or {"type": "blind"}
reverse_distance = abs(float(params.get("reverse_distance_mm") or 0.0))
if reverse_distance <= 0:
raise ValueError("two-sided extrusion requires reverse_distance_mm > 0")
return [
*_side_extent_vectors(
node, faces, normal, session, end_condition=end_condition, distance_mm=distance,
),
*_side_extent_vectors(
node, faces, vector_scale(normal, -1), session,
end_condition=reverse_condition, distance_mm=reverse_distance,
),
]
if condition in {"through_all", "through_all_both", "through_all_and_blind"}:
if session.body is None:
# A first feature with through-all has no body to terminate
# 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 [ExtentVector(vector_scale(normal, distance))]
span = max(session.adapter.body_span(session.body, normal), 1.0) + 2.0
if condition == "through_all":
return [ExtentVector(vector_scale(normal, span))]
if condition == "through_all_both":
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 [
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,
)