"""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, )