263 lines
13 KiB
Python
263 lines
13 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
|
|
|
|
import math
|
|
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:
|
|
reference = _extent_reference(node, end_condition) if condition != "through_next" else None
|
|
source_vertex_point: Vector3 | None = None
|
|
if condition == "up_to_vertex" and isinstance(reference, dict) and reference.get("kind") == "source_vertex":
|
|
raw_point = reference.get("point_mm")
|
|
if not (
|
|
isinstance(raw_point, list)
|
|
and len(raw_point) == 3
|
|
and all(isinstance(value, (int, float)) and math.isfinite(float(value)) for value in raw_point)
|
|
):
|
|
raise FeatureExecutionError(
|
|
"invalid_source_vertex_extent",
|
|
"The source-vertex extent datum must contain one finite 3D point",
|
|
extent=condition,
|
|
)
|
|
source_vertex_point = (float(raw_point[0]), float(raw_point[1]), float(raw_point[2]))
|
|
elif 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
|
|
elif source_vertex_point is not None:
|
|
target = None
|
|
else:
|
|
assert isinstance(reference, dict)
|
|
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 = source_vertex_point or 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)
|
|
if condition == "up_to_surface" and message == "extent target is not reached by every profile ray":
|
|
# Do not reinterpret a partially hit finite face: that path
|
|
# has explicit trimmed-solid semantics below. Only a wholly
|
|
# unreachable planar face may terminate on its supporting
|
|
# plane, and the adapter proves one positive, uniform
|
|
# profile-to-plane distance before returning it.
|
|
if not session.adapter.target_has_forward_intersection(target, faces, direction):
|
|
try:
|
|
distance = session.adapter.uniform_planar_supporting_surface_distance(target, faces, direction)
|
|
except ValueError:
|
|
pass
|
|
else:
|
|
return ExtentVector(vector_scale(direction, distance))
|
|
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,
|
|
)
|