辅助资料:正文课程已提供中英双语。本页保存当前源码或示例检查记录;代码标识符与原始结果不作改写。先读概念与推导,再用本页逐项对照。
Supporting material: the lessons now include Chinese and English explanations. This page retains source excerpts or verification records; identifiers and original numerical results remain unchanged. Learn the concepts and derivations first, then compare them with these records.
把公式对照到实际函数
以下内容从当前 Python 文件原样摘录,并保留行号。阅读注释时仍要核对循环的实际顺序:逐点更新未处理的相邻段可能暂时失真;“硬约束”应在完整扫描和最终结果中检查。
这些是课程生成时的源码快照;后续修改实现后需要重新生成。ShapeIK 当前最终恢复使用最短弧,广义 Helix 当前使用一维二分与数值积分。
src/dhik/solvers/fabrik.py · FABRIK._iterate · 第 272 行
272 @staticmethod
273 def _iterate(
274 positions: np.ndarray,
275 base: np.ndarray,
276 target: np.ndarray,
277 bones: np.ndarray,
278 rest_directions: np.ndarray,
279 max_iterations: int,
280 tolerance: float,
281 ) -> tuple:
282 """执行 FABRIK 前后向迭代,返回 ``(实际迭代次数, 是否收敛)``。
283
284 ``positions`` 会被**原地更新**。每一步都严格把每段投影回 ``bones`` 给定的长度,
285 因此任意中间状态都不改变骨长;段方向未定义时用该段的静止骨向兜底。
286 """
287 num_bones = int(bones.shape[0])
288 reached = False
289 iterations = 0
290 for iteration in range(1, max_iterations + 1):
291 iterations = iteration
292 # 前向:末端拉到目标,再从末端往根部投影。
293 positions[num_bones] = target
294 for step in range(num_bones - 1, -1, -1):
295 segment = positions[step] - positions[step + 1]
296 unit = _unit_or_fallback(segment, rest_directions[step])
297 positions[step] = positions[step + 1] + unit * bones[step]
298 # 后向:根部复位,再从根部往末端投影。
299 positions[0] = base
300 for step in range(1, num_bones + 1):
301 segment = positions[step] - positions[step - 1]
302 unit = _unit_or_fallback(segment, rest_directions[step - 1])
303 positions[step] = positions[step - 1] + unit * bones[step - 1]
304 if float(np.linalg.norm(positions[num_bones] - target)) <= tolerance:
305 reached = True
306 break
307 return iterations, reached
src/dhik/solvers/shape.py · ShapeIK.solve · 第 228 行
228 def solve(
229 self,
230 skeleton: Skeleton,
231 target: np.ndarray,
232 pole: Optional[np.ndarray] = None,
233 end_effector: Optional[JointRef] = None,
234 root_transform: Optional[Transform] = None,
235 parameters: Optional[SolverParameters] = None,
236 ) -> SolveResult:
237 """见 :meth:`IKSolver.solve`。
238
239 与其它求解器的差异:除了末端位置,本求解器还决定整条链的**形状**
240 (由 ``family`` 选形状族、``pole`` 选弯曲平面与弯曲侧、``α`` 由端点和
241 骨长自动解出)。不可达 / 退化 / 未收敛都通过 ``status`` 报告。
242 """
243 start = self.stopwatch_start()
244 values = self.resolve_parameters(parameters)
245 index, point, pole_vector = self.validate_common_inputs(skeleton, target, pole, end_effector)
246 try:
247 spec = _SolverSpec.from_parameters(values)
248 except _ParameterError as error:
249 return self.build_failure_result(
250 skeleton,
251 point,
252 SolveStatus.INVALID_INPUT,
253 message=str(error),
254 root_transform=root_transform,
255 end_effector=index,
256 )
257
258 chain = skeleton.ancestors(index)
259 num_bones = len(chain) - 1
260 if num_bones < 1:
261 raise ValueError(
262 "ShapeIK 需要至少 1 段骨的骨链,但末端就是骨架根关节(没有可驱动的骨);"
263 f"请指定 root 之外的末端关节,当前末端为 {skeleton.joint_names[index]!r}。"
264 )
265 bones = np.array([skeleton.bone_length(chain[j]) for j in range(1, len(chain))], dtype=np.float64)
266 total_length = float(bones.sum())
267 radius_min, radius_max = reachable_bounds(skeleton, index)
268
269 root = Transform.identity() if root_transform is None else root_transform
270 base = root.apply(skeleton.rest_position_of(chain[0]))
271 offset = point - base
272 distance = float(np.linalg.norm(offset))
273 scale = max(1.0, total_length)
274 # 弦方向:端点重合时方向未定义,用确定性垂直方向兜底(不产生 NaN)。
275 chord_hat = (
276 offset / distance if distance > _DIRECTION_EPS else orthogonal_unit_vector(np.zeros(3))
277 )
278 bend, bend_note = _bend_direction(chord_hat, pole_vector, 1e-9 * scale)
279 # 形状族在拿到弯曲方向之后才构造:plane_hint 就是"曲线朝哪侧凸出",
280 # 于是换 pole 就换弯曲平面/弯曲侧(形状参数 α 与平面无关,见 shapes.py 约定)。
281 family = spec.build_family(bend)
282 classification = classify_target(skeleton, point, end_effector=index, root_transform=root)
283
284 messages: List[str] = []
285 if bend_note:
286 messages.append(bend_note)
287 if classification is Reachability.NEAR_STRAIGHT:
288 messages.append("目标位于外边界邻域(近完全伸直),按可达处理但形状参数已逼近奇异")
289 elif classification is Reachability.NEAR_FOLDED:
290 messages.append("目标位于内边界邻域(近完全折叠),按可达处理但形状参数已逼近奇异")
291
292 # --- 形状参数求解 + 等弧长采样初始化 --------------------------------- #
293 parameter: Optional[Any] = None
294 initial: Optional[np.ndarray] = None
295 degenerate: Optional[ShapeDegenerateError] = None
296 try:
297 parameter = family.solve_parameter(
298 chord=distance, total_length=total_length, lengths=bones, **spec.control
299 )
300 cumulative = np.concatenate(([0.0], np.cumsum(bones)))
301 initial, polyline = _shape_sampling(family, base, point, parameter, cumulative)
302 except ShapeDegenerateError as error:
303 degenerate = error
304 else:
305 bulge = _max_bulge(initial, base, chord_hat)
306 limit = _MIN_SHAPE_BULGE_RATIO * scale
307 if bulge <= limit:
308 degenerate = ShapeDegenerateError(
309 f"形状参数 {family.parameter_to_metadata(parameter)!r} 解出的曲线与弦的最大垂距 "
310 f"{bulge:.3e} ≤ {limit:.3e}(浮点精度下不可分辨,曲线已退化成直线)",
311 "减小形状控制量(正弦族可减小波数 k)或改用其它形状族",
312 )
313
314 # --- 离散骨长修正(硬约束)+ 关节旋转恢复 ---------------------------- #
315 correction: Optional[CorrectionResult] = None
316 fallback: Optional[str] = None
317 initial_residual: Optional[float] = None
318 if degenerate is None:
319 assert parameter is not None and initial is not None # 非退化分支必然已解出参数与采样
320 initial_residual = _max_segment_residual(initial, bones)
321 projector = _CurveProjector(polyline)
322 correction = correct_to_bone_lengths(
323 points=initial,
324 lengths=bones,
325 A=base,
326 B=point,
327 max_iter=spec.max_iterations,
328 tol=spec.tolerance,
329 shape_projector=projector,
330 attraction=spec.attraction,
331 )
332 points = correction.points
333 iterations = correction.iterations
334 status = SolveStatus.SUCCESS if correction.converged else SolveStatus.MAX_ITERATIONS
335 if not correction.converged:
336 messages.append(
337 f"修正器在 {correction.iterations} 轮内未收敛到容差 {spec.tolerance:.3g}"
338 f"(端点偏移 {correction.endpoint_error:.3e}、段长残差 "
339 f"{correction.max_segment_residual:.3e})"
340 )
341 else:
342 messages.append(f"形状参数退化:{degenerate.reason};建议回退:{degenerate.fallback_hint}")
343 points, iterations, status, fallback, correction, initial_residual = _fallback_configuration(
344 classification=classification,
345 base=base,
346 point=point,
347 chord_hat=chord_hat,
348 bones=bones,
349 bend=bend,
350 spec=spec,
351 )
352 if fallback == "straight":
353 messages.append("已回退为沿弦方向的伸直姿态(骨长仍严格保持)")
354 elif fallback == "folded":
355 messages.append("已回退为折叠极限姿态(最长骨朝目标方向、其余骨折回,末端落在最小可达半径上)")
356 else:
357 messages.append("已放松形状先验为直线并求可行姿态(骨长仍严格保持)")
358
359 local_rotations = self.recover_local_rotations(skeleton, chain, points, root)
360 metadata = _build_metadata(
361 spec=spec,
362 family=family,
363 classification=classification,
364 parameter=parameter,
365 num_bones=num_bones,
366 bones=bones,
367 total_length=total_length,
368 radius_min=radius_min,
369 radius_max=radius_max,
370 distance=distance,
371 bend=bend,
372 pole_vector=pole_vector,
373 points=points,
374 base=base,
375 point=point,
376 chord_hat=chord_hat,
377 correction=correction,
378 initial_residual=initial_residual,
379 degenerate=degenerate,
380 fallback=fallback,
381 )
382 elapsed = self.stopwatch_elapsed(start)
383 return self.build_result(
384 skeleton=skeleton,
385 local_rotations=local_rotations,
386 target=point,
387 status=status,
388 iterations=iterations,
389 elapsed_time=elapsed,
390 root_transform=root,
391 end_effector=index,
392 message=";".join(messages),
393 metadata=metadata,
394 )
src/dhik/shapes.py · ArcShape.solve_parameter · 第 615 行
615 def solve_parameter(self, chord: float, total_length: float, **control: Any) -> float:
616 """解圆心角 ``θ``:见类文档的单调性与二分区间说明。"""
617 distance, total = self._validate_inputs(chord, total_length, dict(control))
618 ratio = total / distance
619 upper = float(np.pi) * (1.0 - 1e-12)
620 if _arc_length_ratio(upper) < ratio:
621 raise ShapeDegenerateError(
622 f"圆心角需要逼近 2π 才能满足 S/d = {ratio:.6g}(超过可表示范围 ~1e12)",
623 "回退为多圈形状或退化为接近闭合的圆环姿态",
624 )
625 half_angle = _bisect_increasing(_arc_length_ratio, 0.0, upper, ratio)
626 return 2.0 * half_angle
src/dhik/shapes.py · CatenaryShape.solve_parameter · 第 713 行
713 def solve_parameter(self, chord: float, total_length: float, **control: Any) -> float:
714 """解 ``u = d/(2a)`` 后返回 ``a = d/(2u)``:见类文档的单调性与二分区间说明。"""
715 distance, total = self._validate_inputs(chord, total_length, dict(control))
716 ratio = total / distance
717 upper = 1.0
718 while _catenary_length_ratio(upper) < ratio:
719 upper *= 2.0
720 if upper > 700.0:
721 raise ShapeDegenerateError(
722 f"悬链线需要 u = d/(2a) > 700 才能满足 S/d = {ratio:.6g}(sinh 溢出)",
723 "回退为沿弦方向的伸直姿态,或改用圆弧/正弦族表达极端弯曲",
724 )
725 u = _bisect_increasing(_catenary_length_ratio, 0.0, upper, ratio)
726 return distance / (2.0 * u)
src/dhik/shapes.py · SinusoidShape._points_at_fractions · 第 874 行
874 def _points_at_fractions(
875 self,
876 A: np.ndarray,
877 B: np.ndarray,
878 parameter: float,
879 fractions: np.ndarray,
880 ) -> np.ndarray:
881 """按弧长比例取点:对 ``s(v) = Q(v)/(kπ)`` 二分反解出 ``v``,再代回 ``p(t)``。
882
883 ``Q(v) = ∫₀^v √(d² + c²cos²u) du`` 用"整周期解析 + 余项复合 Gauss-Legendre"
884 计算(见 :func:`_sinusoid_arc_integral`),避免 ``c ≫ d`` 时 ``|cos|`` 拐点
885 带来的求积误差。
886 """
887 chord_hat, _, distance = self._chord(A, B)
888 amplitude = _as_scalar(parameter, "parameter(正弦幅值 α)")
889 plane = self._plane_direction(A, B)
890 fractions = np.asarray(fractions, dtype=np.float64).reshape(-1)
891 k = self.k
892 c = amplitude * k * np.pi
893 v_max = float(k) * np.pi
894 total = self._arc_length_of(distance, c)
895 if not total > 0.0:
896 raise ShapeDegenerateError(
897 f"正弦曲线弧长非正(S = {total:.6g}),无法按弧长采样",
898 "检查幅值与端点是否合法,或改用其它形状族",
899 )
900
901 def cumulative(v: float) -> float:
902 return _sinusoid_arc_integral(distance, c, v) / v_max
903
904 v = np.empty_like(fractions)
905 for index, fraction in enumerate(fractions):
906 if fraction <= 0.0:
907 v[index] = 0.0
908 elif fraction >= 1.0:
909 v[index] = v_max
910 else:
911 v[index] = _bisect_increasing(cumulative, 0.0, v_max, float(fraction) * total, iterations=64)
912 t = v / v_max
913 offset = (B - A)[None, :] * t[:, None]
914 bulge = (amplitude * np.sin(np.pi * k * t))[:, None] * plane[None, :]
915 return A[None, :] + offset + bulge
src/dhik/shapes.py · correct_to_bone_lengths · 第 1031 行
1031 def correct_to_bone_lengths(
1032 points: np.ndarray,
1033 lengths: np.ndarray,
1034 A: np.ndarray,
1035 B: np.ndarray,
1036 max_iter: int = 200,
1037 tol: float = 1e-12,
1038 shape_projector: Optional[Callable[[np.ndarray], np.ndarray]] = None,
1039 attraction: float = 0.5,
1040 ) -> CorrectionResult:
1041 """把等弧长采样得到的关节点修正到**严格满足离散骨长约束**。
1042
1043 为什么需要它(课程点名的致命陷阱)
1044 ----------------------------------
1045 "曲线弧长不等于相邻关节点间的直线距离":等弧长采样只保证相邻点之间的**弧长**
1046 等于骨长,而约束要求的是**直线距离** ``‖p_{i+1} − p_i‖ = l_i``。曲线上两点间的
1047 弦永远短于它们之间的弧,所以等弧长采样天然偏短(弯得越厉害偏得越多)。
1048 本函数以采样结果为初始化,迭代拉回硬约束。
1049
1050 算法(Gauss-Seidel 交替投影,FABRIK 式)
1051 ----------------------------------------
1052 每一轮做以下三步,返回前**最后一步一定是纯硬约束扫描**(吸引步只在
1053 非最后一轮执行,且每轮都以一次硬投影收尾,因此返回构型永远严格满足骨长):
1054
1055 1. **前向**(保 ``A`` 固定):``p_0 ← A``,逐段把 ``p_{i+1}`` 投到以 ``p_i`` 为心、
1056 半径 ``l_i`` 的球面上——即沿当前方向重新量出 ``l_i``;
1057 2. **形状吸引**(可选,仅当给出 ``shape_projector`` 且 ``attraction > 0``):
1058 每个内点朝曲线最近点混一步 ``p ← (1 − w)·p + w·proj(p)``(形状是**软约束**,
1059 这一步会破坏骨长,由下一步立刻修回);该步只在**形状偏置阶段**执行
1060 (前 ``min(20, max_iter // 2)`` 轮),让软约束决定解落在哪一支,
1061 随后交给纯硬约束扫描收敛;
1062 3. **后向**(保 ``B`` 固定):``p_N ← B``,从末端反扫,同样按 ``l_i`` 重摆每段。
1063
1064 由于每步都把段长写成半径投影,**任意中间构型的段长都精确等于 ``l_i``**
1065 (只受浮点舍入影响),这正是"骨长是硬约束、绝不静默改变骨长"的体现;
1066 收敛判据是端点误差与段长残差都进入 ``tol``。
1067
1068 形状偏置阶段为什么要短(≤ 20 轮)而且必须收尾
1069 ----------------------------------------------
1070 若每轮都吸引,软约束会把构型持续推向曲线;当曲线本身接近直线时,构型会贴到
1071 **共线流形**上——而共线方向正是投影扫描的不动流形(投影只沿线重排,不产生
1072 横向弯曲),此时硬约束的收敛率退化到接近 1:实测端点误差长期停在 ``1e-2``
1073 量级(须几万轮才收敛)。因此偏置只做一个短的前置阶段,之后全是纯硬约束;
1074 这样既保住了形状偏置带来的解分支(形状偏差实测下降 3 成以上),又能把
1075 端点误差压回机器精度。
1076
1077 参数:
1078 points: 形状 ``(N, 3)`` 的初始关节点(等弧长采样结果),``N = len(lengths) + 1``。
1079 lengths: 形状 ``(N−1,)`` 的骨长,必须全为正。
1080 A: 形状 ``(3,)`` 的起点(硬约束,逐轮钉住)。
1081 B: 形状 ``(3,)`` 的终点(硬约束)。
1082 max_iter: 最大扫描轮数(每轮 = 吸引 + 前向 + 后向)。
1083 tol: 收敛容差(端点误差与段长残差同时满足)。
1084 shape_projector: 可选的可调用对象,接收 ``(M, 3)`` 的内点数组并返回其
1085 在曲线上的最近点 ``(M, 3)``;为 ``None`` 时不吸引、偏差字段为 ``None``。
1086 attraction: 吸引步的混合系数 ``w ∈ [0, 1]``(0 等价于不吸引),
1087 只在前 ``min(20, max_iter // 2)`` 轮生效(形状偏置阶段,见下方说明)。
1088
1089 返回:
1090 :class:`CorrectionResult`(含修正前后形状偏差对比)。
1091
1092 异常:
1093 ValueError: 输入形状/取值非法(骨长非正、端点形状错、``attraction`` 越界、
1094 ``shape_projector`` 返回形状不符等)。**不可达(``‖B − A‖`` 小于骨链
1095 最小可达半径)不抛异常**:如实通过 ``converged=False`` 与
1096 ``endpoint_error > 0`` 报告,骨长仍然精确。
1097 """
1098 start = _as_point(A, "A")
1099 end = _as_point(B, "B")
1100 current = np.asarray(points, dtype=np.float64)
1101 if current.ndim != 2 or current.shape[1] != 3:
1102 raise ValueError(f"points 形状必须为 (N, 3),收到 {current.shape}")
1103 if not bool(np.all(np.isfinite(current))):
1104 raise ValueError("points 含 NaN 或无穷值")
1105 bones = np.asarray(lengths, dtype=np.float64).reshape(-1)
1106 if bones.size == 0:
1107 raise ValueError("lengths 不能为空(至少 1 段骨)")
1108 if current.shape[0] != bones.size + 1:
1109 raise ValueError(
1110 f"points 的关节点数应等于骨数 + 1:收到 {current.shape[0]} 个点、{bones.size} 根骨"
1111 )
1112 if not bool(np.all(np.isfinite(bones))) or bool(np.any(bones <= 0.0)):
1113 raise ValueError(f"lengths 必须是有限正数,收到 {bones.tolist()}")
1114 if not np.isfinite(float(bones.sum())):
1115 # 防止骨长量级过大时逐段累加溢出成 Inf(宁可明确报错,也不静默产出 Inf)。
1116 raise ValueError(f"lengths 之和溢出(骨长量级过大),收到 {bones.tolist()}")
1117 rounds = int(max_iter)
1118 if rounds < 1:
1119 raise ValueError(f"max_iter 必须 ≥ 1,收到 {max_iter!r}")
1120 tolerance = _as_scalar(tol, "tol")
1121 if tolerance <= 0.0:
1122 raise ValueError(f"tol 必须为正,收到 {tol!r}")
1123 weight = _as_scalar(attraction, "attraction")
1124 if not 0.0 <= weight <= 1.0:
1125 raise ValueError(f"attraction 必须落在 [0, 1],收到 {attraction!r}")
1126 if shape_projector is not None and not callable(shape_projector):
1127 raise TypeError("shape_projector 必须可调用(接收 (M, 3) 返回 (M, 3))或为 None")
1128
1129 num_bones = int(bones.size)
1130 current = current.copy()
1131 chord = end - start
1132 chord_hat = _unit_or(chord, _deterministic_perpendicular(chord))
1133 deviation_before = (
1134 _shape_deviation(current, shape_projector) if shape_projector is not None else None
1135 )
1136
1137 def forward_sweep() -> None:
1138 """前向:``A`` 固定,逐段把下一点投到以当前点为心、半径 ``l_i`` 的球面上。"""
1139 current[0] = start
1140 direction = chord_hat
1141 for index in range(num_bones):
1142 direction = _unit_or(current[index + 1] - current[index], direction)
1143 current[index + 1] = current[index] + direction * bones[index]
1144
1145 def backward_sweep() -> None:
1146 """后向:``B`` 固定,从末端反扫恢复端点(FABRIK 式)。"""
1147 current[-1] = end
1148 direction = -chord_hat
1149 for index in range(num_bones - 1, -1, -1):
1150 direction = _unit_or(current[index] - current[index + 1], direction)
1151 current[index] = current[index + 1] + direction * bones[index]
1152
1153 def attract() -> None:
1154 """形状吸引步(软约束):内点朝曲线最近点混合 ``weight``。"""
1155 if num_bones < 2:
1156 return
1157 interior = current[1:-1]
1158 projected = _project_points(shape_projector, interior)
1159 current[1:-1] = interior + weight * (projected - interior)
1160
1161 # 形状偏置阶段取前 min(20, max_iter//2) 轮:软约束负责把构型拉进"贴合形状"的
1162 # 那一支,其后(含最后一轮)全是纯硬约束扫描,把端点误差收敛到 0。
1163 # 偏置阶段不能太长:实测把构型拉到近共线流形后,硬约束扫描的收敛率会退化到
1164 # 接近 1(共线方向是投影的不动流形),端点误差会长期停在 1e-2 量级上不去。
1165 bias_rounds = min(20, max(1, rounds // 2))
1166 iterations = 0
1167 converged = False
1168 for round_index in range(1, rounds + 1):
1169 iterations = round_index
1170 # 最后一轮不吸引:返回前必有一次纯硬约束扫描收尾。
1171 if (
1172 shape_projector is not None
1173 and weight > 0.0
1174 and round_index <= bias_rounds
1175 and round_index < rounds
1176 ):
1177 attract()
1178 forward_sweep()
1179 backward_sweep()
1180 endpoint_error = _endpoint_error(current, start, end)
1181 residual = _max_segment_residual(current, bones)
1182 if endpoint_error <= tolerance and residual <= tolerance:
1183 converged = True
1184 break
1185
1186 endpoint_error = _endpoint_error(current, start, end)
1187 residual = _max_segment_residual(current, bones)
1188 deviation_after = (
1189 _shape_deviation(current, shape_projector) if shape_projector is not None else None
1190 )
1191 return CorrectionResult(
1192 points=current,
1193 iterations=iterations,
1194 converged=converged,
1195 endpoint_error=endpoint_error,
1196 max_segment_residual=residual,
1197 shape_deviation_before=deviation_before,
1198 shape_deviation_after=deviation_after,
1199 )
src/dhik/solvers/shape.py · _CurveProjector.__call__ · 第 556 行
556 def __call__(self, points: np.ndarray) -> np.ndarray:
557 query = np.asarray(points, dtype=np.float64)
558 if query.size == 0:
559 return query.copy()
560 starts = self._polyline[:-1]
561 segments = self._polyline[1:] - starts
562 length_squared = np.einsum("ij,ij->i", segments, segments)
563 relative = query[:, None, :] - starts[None, :, :]
564 projection = np.einsum("mij,ij->mi", relative, segments)
565 safe = np.where(length_squared > 0.0, length_squared, 1.0)
566 ratio = np.clip(projection / safe[None, :], 0.0, 1.0)
567 candidates = starts[None, :, :] + ratio[:, :, None] * segments[None, :, :]
568 distances = np.linalg.norm(query[:, None, :] - candidates, axis=2)
569 nearest = np.argmin(distances, axis=1)
570 return candidates[np.arange(query.shape[0]), nearest]
src/dhik/shapes_helix.py · HelixShape.solve_parameter · 第 251 行
251 def solve_parameter(self, chord: float, total_length: float, **control: Any) -> HelixParameter:
252 """由弦长 ``d`` 与目标弧长 ``L`` 解出标准或广义螺旋参数。
253
254 标准版:螺距显式解,半径解析解。广义版:显式解平均螺距,
255 再在非负半径域内二分求平均半径;还须满足 |Δr| <= d、L >= S_min。
256
257 参数:
258 chord: 弦长 ``d = ‖B − A‖``。
259 total_length: 目标弧长 ``L``(取骨链总长)。
260 **control: 认 ``turns``(覆盖实例圈数,须与实例一致)、``sign``、
261 ``lengths``(骨长序列,用于内不可达判定);``taper_radius`` /
262 ``taper_pitch`` 覆盖本次半径和螺距的线性变化量。
263
264 返回:
265 :class:`HelixParameter`。
266
267 异常:
268 ShapeDegenerateError: ``d ≈ 0`` / ``L ≤ d`` / ``L ≈ d`` / ``d < r_min``。
269 ValueError: 圈数非正整数、非有限 taper、控制量与实例不一致等。
270 """
271 distance, total = self._validate_inputs(chord, total_length, dict(control))
272 turns = self._resolve_turns(control)
273 sign = self._resolve_sign(control)
274 dr = _finite(control.get("taper_radius", self.taper_radius), "taper_radius")
275 dp = _finite(control.get("taper_pitch", self.taper_pitch), "taper_pitch")
276 if dr != 0.0 or dp != 0.0:
277 return self._solve_general(distance, total, turns, sign, dr, dp)
278 pbar = sign * distance / float(turns)
279 inner = (total / float(turns)) ** 2 - pbar * pbar
280 if inner <= 0.0:
281 # L > d 已由 _validate_inputs 保证,这里只是数值兜底。
282 raise ShapeDegenerateError(
283 f"标准 helix 在 N = {turns} 下需要 L/N > |p̄| = {abs(pbar):.6g},"
284 f"当前 L/N = {total / float(turns):.6g} 不满足",
285 "增大骨长或减小弦长;或降低圈数 N",
286 )
287 radius = float(np.sqrt(inner) / (2.0 * np.pi))
288 if not np.isfinite(radius):
289 raise ShapeDegenerateError(
290 f"解出的半径非有限(L = {total:.6g}、d = {distance:.6g}、N = {turns})",
291 "检查输入量级,或换用其它形状族",
292 )
293 return HelixParameter(r0=radius, r1=radius, p0=pbar, p1=pbar, turns=float(turns))
src/dhik/shapes_helix.py · HelixShape._solve_general · 第 448 行
448 def _solve_general(self, distance, total, turns, sign, dr, dp):
449 """先解弦长,再在 r_bar >= |dr|/2 的单调区间二分。"""
450 if abs(dr) > distance:
451 raise ShapeDegenerateError("半径变化量超过端点距离,当前螺旋形状无解", "减小 taper_radius")
452 # 避免 d**2 - dr**2 的相减损失。
453 pbar = sign * np.sqrt((distance - abs(dr)) * (distance + abs(dr))) / turns
454 p0, p1 = pbar - 0.5 * dp, pbar + 0.5 * dp
455 low = 0.5 * abs(dr)
456 def length(radius):
457 return _arc_length_closed(radius - 0.5 * dr, radius + 0.5 * dr, p0, p1, turns)
458 minimum = length(low)
459 tolerance = 1e-12 * max(1.0, total)
460 if minimum > total + tolerance:
461 raise ShapeDegenerateError("该锥度的最短曲线仍超过骨链总长", "减小 taper 或增大骨链总长")
462 if abs(minimum - total) <= tolerance:
463 radius = low
464 else:
465 high = max(total, low + total)
466 if length(high) < total:
467 raise ShapeDegenerateError("无法建立有限求根区间", "检查参数量级")
468 for _ in range(60):
469 mid = 0.5 * (low + high)
470 if length(mid) < total:
471 low = mid
472 else:
473 high = mid
474 if high - low <= 1e-13 * max(1.0, total):
475 break
476 radius = 0.5 * (low + high)
477 return HelixParameter(max(0.0, radius - 0.5 * dr), max(0.0, radius + 0.5 * dr),
478 p0, p1, float(turns))
src/dhik/shapes_helix.py · _arc_at · 第 157 行
157 def _arc_at(r0: float, r1: float, p0: float, p1: float, turns: float, u: float) -> float:
158 """累计弧长:标准版解析,广义版复合 Gauss-Legendre 积分。"""
159 if r0 == r1 and p0 == p1:
160 return float(np.hypot(2.0 * np.pi * turns * r0, turns * p0) * u)
161 t = float(u) * _ARC_U
162 dr, dp = r1 - r0, p1 - p0
163 speed = np.sqrt(dr * dr + (2.0 * np.pi * turns * (r0 + dr * t)) ** 2
164 + (turns * (p0 + dp * t)) ** 2)
165 return float(u) * float(np.dot(_ARC_W, speed))
src/dhik/solvers/base.py · IKSolver.recover_local_rotations · 第 386 行
386 @staticmethod
387 def recover_local_rotations(
388 skeleton: Skeleton,
389 chain_indices: Sequence[int],
390 chain_positions: np.ndarray,
391 root_transform: Optional[Transform] = None,
392 ) -> np.ndarray:
393 """由骨链关节的**世界位置**恢复**局部关节旋转向量**(位置 → 旋转)。
394
395 项目要求"求解结果必须恢复为局部关节旋转,再通过 FK 重建骨架",本函数就是
396 这条要求的公共实现:迭代型/形状型求解器算出关节点位置后调用它,解析型
397 求解器推导出关节点位置后同样调用它,从而两类方法口径一致。
398
399 推导(FK 递推 ``p_child = p_parent + R_parent · t_child^rest``):
400
401 对链上相邻的父子关节 ``(i, j)``,有 ``R_i · t_j^rest = p_j − p_i``。
402 已知 ``R_i = R_parent(i) · R(θ_i)``,于是
403
404 .. math::
405
406 R(θ_i) \\, t_j^{rest} = \\big(R_{parent(i)}\\big)^{T} (p_j - p_i)
407
408 右边只依赖已求出的父朝向与两点位置,问题化为"把一个已知方向旋到另一个
409 已知方向",用 :func:`minimal_rotation_vector`(最短弧)解出 ``θ_i``。
410 沿链从根到末端一次扫描即可,**骨长自动保持**(旋转不改变 ``t_j^rest`` 的模长)。
411
412 扭转自由度口径:每个关节只约束"骨轴指向",绕骨轴的自转是自由的;这里统一
413 取**最短弧**(不引入额外轴向扭转),使同一位置序列永远给出同一组关节角。
414 不在链上的关节(含末端关节自身)局部旋转保持为零。
415
416 参数:
417 skeleton: 骨架。
418 chain_indices: 骨链关节下标序列(根 → 末端,拓扑序,必须从骨架根关节开始)。
419 chain_positions: 形状 ``(len(chain_indices), 3)`` 的各关节世界位置。
420 root_transform: 施加在根关节上的世界变换(决定链根的父朝向)。
421
422 返回:
423 形状 ``(N, 3)`` 的局部关节旋转向量。
424
425 异常:
426 ValueError: 下标序列为空、不以骨架根关节开头、长度或位置形状不匹配,
427 或相邻关节点重合(方向未定义)。
428 """
429 indices = [int(i) for i in chain_indices]
430 if not indices:
431 raise ValueError("chain_indices 不能为空")
432 if indices[0] != skeleton.root_index:
433 raise ValueError(
434 "chain_indices 必须从骨架根关节开始(根 → 末端),"
435 f"收到起点下标 {indices[0]},根关节下标为 {skeleton.root_index}"
436 )
437 positions = np.asarray(chain_positions, dtype=np.float64)
438 if positions.shape != (len(indices), 3):
439 raise ValueError(
440 f"chain_positions 形状必须为 ({len(indices)}, 3),收到 {positions.shape}"
441 )
442 if not np.all(np.isfinite(positions)):
443 raise ValueError("chain_positions 包含 NaN 或无穷值")
444
445 root = Transform.identity() if root_transform is None else root_transform
446 local_rotations = np.zeros((skeleton.num_joints, 3), dtype=np.float64)
447 # accumulated = 链根关节**父级**的世界朝向(之后逐关节右乘自身旋转)。
448 accumulated = root.rotation.copy()
449 for offset in range(len(indices) - 1):
450 parent = indices[offset]
451 child = indices[offset + 1]
452 bone_vector = positions[offset + 1] - positions[offset]
453 rest_offset = skeleton.rest_offset_of(child)
454 # accumulated^T · bone_vector 就是 bone_vector 在父关节坐标系下的表达。
455 target_in_parent = accumulated.T @ bone_vector
456 # 先用最短弧给出"把该段骨转过去"的相对旋转,再经 rotation.py 的
457 # "旋转矩阵 → 旋转向量"(Shepperd 分支,θ ∈ [0, π] 规范形式)取回关节角,
458 # 保证扭转自由度口径与 FK/旋转模块完全一致。
459 relative_matrix = rotation_vector_to_matrix(
460 minimal_rotation_vector(rest_offset, target_in_parent)
461 )
462 local_rotations[parent] = matrix_to_rotation_vector(relative_matrix)
463 accumulated = accumulated @ relative_matrix
464 return local_rotations