"""Core CDSL sketch resolver. The runtime accepts only direct geometric descriptions: circles, straight-edge polygons, and closed analytic line/arc/circle/ellipse/B-spline contours. Semantic shapes and historical profile macros belong to the importer compatibility layer and must be lowered before this module is invoked. """ from __future__ import annotations import math from copy import deepcopy from typing import Any, Iterable _Ctx = dict[str, Any] _TOLERANCE_MM = 1e-5 def _circle(center: list[float], radius_mm: float, construction: bool = False) -> _Ctx: return {"type": "circle", "center": [float(center[0]), float(center[1])], "radius_mm": float(radius_mm), "construction": construction} def _line(start: list[float], end: list[float], construction: bool = False) -> _Ctx: return {"type": "line", "start": [float(start[0]), float(start[1])], "end": [float(end[0]), float(end[1])], "construction": construction} def _point(point: list[float]) -> list[float]: return [float(point[0]), float(point[1]), float(point[2]) if len(point) > 2 else 0.0] def _contour_line(start: list[float], end: list[float]) -> _Ctx: return {"type": "line", "start_mm": _point(start), "end_mm": _point(end)} def _contour_arc(start: list[float], end: list[float], center: list[float], radius: float | None, clockwise: bool | None = None) -> _Ctx: edge: _Ctx = {"type": "arc", "start_mm": _point(start), "end_mm": _point(end), "center_mm": _point(center), "radius_mm": float(radius) if radius is not None else None} if clockwise is not None: edge["clockwise"] = bool(clockwise) return edge def _to_3d(workplane: _Ctx, u: float, v: float) -> list[float]: origin = workplane.get("origin_mm") or [0, 0, 0] x_dir = workplane.get("x_dir") or [1, 0, 0] normal = workplane.get("normal") or [0, 0, 1] y_raw = workplane.get("y_dir") y_dir = _default_y_dir(x_dir, normal) if y_raw: magnitude = math.sqrt(sum(component * component for component in y_raw)) if magnitude > 1e-12: y_unit = [component / magnitude for component in y_raw] # 与 PlaneSpec.from_mapping 同策略:只有与 x_dir / normal 正交的 # y_dir 才尊重(SolidWorks 导出的 y_dir==x_dir 占位数据与 X 平行, # 直接使用会让轮廓塌缩成一条线,必须回退到 normal×x_dir)。 if abs(_dot(y_unit, x_dir)) <= 1e-6 and abs(_dot(y_unit, normal)) <= 1e-6: y_dir = y_unit return [origin[0] + u * x_dir[0] + v * y_dir[0], origin[1] + u * x_dir[1] + v * y_dir[1], origin[2] + u * x_dir[2] + v * y_dir[2]] def _to_3d_vector(workplane: _Ctx, u: float, v: float) -> list[float]: origin = _to_3d(workplane, 0.0, 0.0) target = _to_3d(workplane, u, v) return [target[index] - origin[index] for index in range(3)] def _dot(left: Iterable[float], right: Iterable[float]) -> float: return sum(a * b for a, b in zip(left, right)) def _default_y_dir(x_dir: Iterable[float], normal: Iterable[float]) -> list[float]: x, n = list(x_dir), list(normal) return [ n[1] * x[2] - n[2] * x[1], n[2] * x[0] - n[0] * x[2], n[0] * x[1] - n[1] * x[0], ] def _transform_contours(contours: list[_Ctx], workplane: _Ctx) -> list[_Ctx]: transformed: list[_Ctx] = [] normal = workplane.get("normal") or [0, 0, 1] for edge in contours: output = deepcopy(edge) output["start_mm"] = _to_3d(workplane, edge["start_mm"][0], edge["start_mm"][1]) output["end_mm"] = _to_3d(workplane, edge["end_mm"][0], edge["end_mm"][1]) if edge["type"] == "arc": output["center_mm"] = _to_3d(workplane, edge["center_mm"][0], edge["center_mm"][1]) output["normal"] = list(normal) elif edge["type"] == "ellipse": output["center_mm"] = _to_3d(workplane, edge["center_mm"][0], edge["center_mm"][1]) output["major_axis_mm"] = _to_3d_vector(workplane, edge["major_axis_mm"][0], edge["major_axis_mm"][1]) output["normal"] = list(normal) elif edge["type"] == "bspline": output["points_mm"] = [ _to_3d(workplane, point[0], point[1]) for point in edge["points_mm"] ] for key in ("start_tangent_mm", "end_tangent_mm"): if key in edge: tangent = edge[key] output[key] = _to_3d_vector(workplane, tangent[0], tangent[1]) transformed.append(output) return transformed def _gen_circle(profile: _Ctx, _: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: center = profile.get("center") or [0.0, 0.0] radius = float(profile.get("radius_mm") or 0.0) if radius <= 0: raise ValueError("circle radius must be > 0") cx, cy = float(center[0]), float(center[1]) # 直接圆 profile 必须保留为一条完整的圆边。若拆成四条圆弧,后续按边 # 选择的圆角/倒角会把同一拓扑圆误解为四个独立目标。 return [_circle([cx, cy], radius)], [] def _gen_polygon(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: vertices = profile.get("vertices") or [] if len(vertices) < 3: entities = meta.get("_entities") or [] if not entities: raise ValueError("polygon needs at least 3 vertices") return list(entities), [_contour_line(edge["start"], edge["end"]) for edge in entities if edge.get("type") == "line"] points = [(float(vertex[0]), float(vertex[1])) for vertex in vertices] return ( [_line(list(points[index]), list(points[(index + 1) % len(points)])) for index in range(len(points))], [_contour_line([*points[index], 0.0], [*points[(index + 1) % len(points)], 0.0]) for index in range(len(points))], ) def _distance(left: list[float], right: list[float]) -> float: return math.hypot(float(left[0]) - float(right[0]), float(left[1]) - float(right[1])) def _centripetal_parameters(points: list[list[float]], periodic: bool) -> list[float]: pairs = list(zip(points, points[1:])) if periodic: pairs.append((points[-1], points[0])) parameters = [0.0] for start, end in pairs: distance = math.dist(start, end) if distance <= _TOLERANCE_MM: raise ValueError("analytic_contours: centripetal bspline has coincident interpolation points") parameters.append(parameters[-1] + math.sqrt(distance)) return parameters def _reverse(edge: _Ctx) -> _Ctx: output = deepcopy(edge) output["start_mm"], output["end_mm"] = output["end_mm"], output["start_mm"] if output.get("type") == "arc" and "clockwise" in output: output["clockwise"] = not bool(output["clockwise"]) if output.get("type") == "bspline": output["points_mm"] = list(reversed(output["points_mm"])) parameters = output.get("parameters") if parameters is not None: final_parameter = float(parameters[-1]) output["parameters"] = [final_parameter - float(value) for value in reversed(parameters)] start_tangent = output.pop("start_tangent_mm", None) end_tangent = output.pop("end_tangent_mm", None) if end_tangent is not None: output["start_tangent_mm"] = [-float(value) for value in end_tangent] if start_tangent is not None: output["end_tangent_mm"] = [-float(value) for value in start_tangent] return output def _join(edges: list[_Ctx], *, allow_open: bool = False) -> list[_Ctx]: # 将边排序成一条连通链。allow_open=False(默认)要求首尾相接成闭合环; # allow_open=True 时允许链首尾不接(供开放轮廓先拼链、后补闭合边)。 if not edges: return [] remaining = [deepcopy(edge) for edge in edges] ordered = [remaining.pop(0)] while remaining: tail = ordered[-1]["end_mm"] for index, candidate in enumerate(remaining): if _distance(tail, candidate["start_mm"]) <= _TOLERANCE_MM: ordered.append(remaining.pop(index)) break if _distance(tail, candidate["end_mm"]) <= _TOLERANCE_MM: ordered.append(_reverse(remaining.pop(index))) break else: raise ValueError("analytic_contours: segments do not form a connected contour") if not allow_open and _distance(ordered[0]["start_mm"], ordered[-1]["end_mm"]) > _TOLERANCE_MM: raise ValueError("analytic_contours: closed contour endpoints do not meet") return ordered def _close_open_contour(edges: list[_Ctx]) -> tuple[list[_Ctx], bool]: # 开放链补闭合边:若首尾未相接,则沿两点连线补一条直线边形成闭合环。 # 返回 (edges, opened);opened=False 表示首尾已天然相接(无需补边)。 if len(edges) < 2: raise ValueError("analytic_contours: open contour needs at least 2 connected segments") if _distance(edges[0]["start_mm"], edges[-1]["end_mm"]) <= _TOLERANCE_MM: return edges, False closing = _contour_line(edges[-1]["end_mm"][:2], edges[0]["start_mm"][:2]) return [*edges, closing], True def _circle_edges(segment: _Ctx) -> list[_Ctx]: center = segment.get("center") or [0.0, 0.0] radius = float(segment.get("radius_mm") or 0.0) if radius <= 0: raise ValueError("analytic_contours: circle radius_mm must be > 0") cx, cy = float(center[0]), float(center[1]) clockwise = bool(segment.get("clockwise", False)) angles = [0.0, -90.0, -180.0, -270.0, -360.0] if clockwise else [0.0, 90.0, 180.0, 270.0, 360.0] points = [[cx + radius * math.cos(math.radians(angle)), cy + radius * math.sin(math.radians(angle)), 0.0] for angle in angles] return [_contour_arc(points[index], points[index + 1], [cx, cy, 0.0], radius, clockwise) for index in range(4)] def _ellipse_edges(segment: _Ctx) -> list[_Ctx]: center = segment.get("center") or [0.0, 0.0] major_radius = float(segment.get("major_radius_mm") or 0.0) minor_radius = float(segment.get("minor_radius_mm") or 0.0) major_axis = segment.get("major_axis") or [] if major_radius <= 0 or minor_radius <= 0: raise ValueError("analytic_contours: ellipse radii must be > 0") if len(major_axis) < 2: raise ValueError("analytic_contours: ellipse major_axis must have two components") axis_length = math.hypot(float(major_axis[0]), float(major_axis[1])) if axis_length <= _TOLERANCE_MM: raise ValueError("analytic_contours: ellipse major_axis is degenerate") cx, cy = float(center[0]), float(center[1]) ux, uy = float(major_axis[0]) / axis_length, float(major_axis[1]) / axis_length start = [cx + major_radius * ux, cy + major_radius * uy, 0.0] return [{ "type": "ellipse", "start_mm": start, "end_mm": list(start), "center_mm": [cx, cy, 0.0], "major_axis_mm": [ux, uy, 0.0], "major_radius_mm": major_radius, "minor_radius_mm": minor_radius, }] def _segment_edges(segment: _Ctx) -> list[_Ctx]: kind = segment.get("type") if kind == "line": return [_contour_line(segment["start"], segment["end"])] if kind == "arc": return [_contour_arc(segment["start"], segment["end"], segment["center"], segment.get("radius_mm"), segment.get("clockwise"))] if kind == "circle": return _circle_edges(segment) if kind == "ellipse": return _ellipse_edges(segment) if kind == "bspline": points = segment.get("points") or [] if len(points) < 3: raise ValueError("analytic_contours: bspline needs at least 3 interpolation points") converted = [_point(point) for point in points] periodic = bool(segment.get("periodic")) if periodic: if _distance(converted[0], converted[-1]) > _TOLERANCE_MM: raise ValueError("analytic_contours: periodic bspline endpoints do not meet") # The duplicated closing interpolation point describes topology, # not an additional periodic interpolation constraint. OCC's # periodic interpolator receives each unique point exactly once. interpolation_points = converted[:-1] else: interpolation_points = converted parameterization = segment.get("parameterization") if parameterization not in {None, "chord", "centripetal"}: raise ValueError(f"analytic_contours: unsupported bspline parameterization {parameterization!r}") parameters = segment.get("parameters") if parameters is not None: expected_count = len(interpolation_points) + int(periodic) if len(parameters) != expected_count: raise ValueError("analytic_contours: bspline parameter count does not match interpolation points") parameters = [float(value) for value in parameters] if not all(math.isfinite(value) for value in parameters): raise ValueError("analytic_contours: bspline parameters must be finite") if any(right - left <= _TOLERANCE_MM for left, right in zip(parameters, parameters[1:])): raise ValueError("analytic_contours: bspline parameters must be strictly increasing") output: _Ctx = { "type": "bspline", "start_mm": converted[0], "end_mm": converted[-1], "points_mm": interpolation_points, "periodic": periodic, **({"parameters": parameters} if parameters is not None else {}), **({"parameters": _centripetal_parameters(interpolation_points, periodic)} if parameters is None and parameterization == "centripetal" else {}), } start_tangent = segment.get("start_tangent") end_tangent = segment.get("end_tangent") if (start_tangent is None) != (end_tangent is None): raise ValueError("analytic_contours: bspline requires both endpoint tangents") if start_tangent is not None: if periodic: raise ValueError("analytic_contours: periodic bspline does not accept endpoint tangents") output["start_tangent_mm"] = _point(start_tangent) output["end_tangent_mm"] = _point(end_tangent) return [output] raise ValueError(f"analytic_contours: unsupported segment type {kind!r}") def _sample_loop(edges: list[_Ctx]) -> list[tuple[float, float]]: points: list[tuple[float, float]] = [] for edge in edges: start = edge["start_mm"] points.append((float(start[0]), float(start[1]))) if edge.get("type") == "bspline": points.extend((float(point[0]), float(point[1])) for point in edge["points_mm"][1:-1]) continue if edge.get("type") == "ellipse": center, axis = edge["center_mm"], edge["major_axis_mm"] major_radius = float(edge["major_radius_mm"]) minor_radius = float(edge["minor_radius_mm"]) normal = edge.get("normal") or [0.0, 0.0, 1.0] axis_length = math.sqrt(sum(float(value) * float(value) for value in axis)) normal_length = math.sqrt(sum(float(value) * float(value) for value in normal)) if axis_length <= _TOLERANCE_MM or normal_length <= _TOLERANCE_MM: raise ValueError("analytic_contours: ellipse axis is degenerate") x_axis = [float(value) / axis_length for value in axis] z_axis = [float(value) / normal_length for value in normal] y_axis = [z_axis[1] * x_axis[2] - z_axis[2] * x_axis[1], z_axis[2] * x_axis[0] - z_axis[0] * x_axis[2], z_axis[0] * x_axis[1] - z_axis[1] * x_axis[0]] for step in range(1, 8): angle = math.tau * step / 8 points.append(( float(center[0]) + major_radius * math.cos(angle) * x_axis[0] + minor_radius * math.sin(angle) * y_axis[0], float(center[1]) + major_radius * math.cos(angle) * x_axis[1] + minor_radius * math.sin(angle) * y_axis[1], )) continue if edge.get("type") != "arc": continue center, end = edge["center_mm"], edge["end_mm"] start_angle = math.atan2(float(start[1]) - float(center[1]), float(start[0]) - float(center[0])) end_angle = math.atan2(float(end[1]) - float(center[1]), float(end[0]) - float(center[0])) delta = end_angle - start_angle if edge.get("clockwise"): if delta >= 0: delta -= math.tau elif delta <= 0: delta += math.tau radius = float(edge.get("radius_mm") or _distance(start, center)) for fraction in (0.25, 0.5, 0.75): angle = start_angle + delta * fraction points.append((float(center[0]) + radius * math.cos(angle), float(center[1]) + radius * math.sin(angle))) return points def _normalize_quarter_rounding_direction(edges: list[_Ctx]) -> None: """Repair inconsistent direction flags on a conventional rounded box. The rule only applies to the unambiguous case of four equal 90-degree corner arcs. It is geometry normalization, not a semantic shape macro. """ arcs = [edge for edge in edges if edge.get("type") == "arc"] if len(arcs) != 4: return radii = [float(edge.get("radius_mm") or 0.0) for edge in arcs] if min(radii) <= _TOLERANCE_MM or max(radii) - min(radii) > _TOLERANCE_MM: return for edge in arcs: center = edge.get("center_mm") if not isinstance(center, list): return start, end = edge["start_mm"], edge["end_mm"] first = (float(start[0]) - float(center[0]), float(start[1]) - float(center[1])) second = (float(end[0]) - float(center[0]), float(end[1]) - float(center[1])) angle = abs(math.atan2(first[0] * second[1] - first[1] * second[0], first[0] * second[0] + first[1] * second[1])) if abs(angle - math.pi / 2) > 1e-4: return points = [(float(edge["start_mm"][0]), float(edge["start_mm"][1])) for edge in edges] clockwise = sum(points[index][0] * points[(index + 1) % len(points)][1] - points[(index + 1) % len(points)][0] * points[index][1] for index in range(len(points))) < 0.0 for edge in arcs: edge["clockwise"] = clockwise def _area(points: list[tuple[float, float]]) -> float: return abs(sum(points[index][0] * points[(index + 1) % len(points)][1] - points[(index + 1) % len(points)][0] * points[index][1] for index in range(len(points))) / 2.0) if len(points) >= 3 else 0.0 def _contains(point: tuple[float, float], loop: list[tuple[float, float]]) -> bool: inside = False x, y = point previous = loop[-1] for current in loop: if (current[1] > y) != (previous[1] > y): crossing = (previous[0] - current[0]) * (y - current[1]) / (previous[1] - current[1]) + current[0] if x < crossing: inside = not inside previous = current return inside def _gen_analytic_contours(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]: loops: list[_Ctx] = [] entities: list[_Ctx] = [] for index, contour in enumerate(profile.get("contours") or []): # 开放轮廓(closed=false / role=open):先按开放链拼装,再在首尾间补一条 # 闭合边成闭合 region —— V 槽/开放型腔切除的刀具截面本就靠这条"槽口边" # 闭合,故 B-rep 层可完全复用闭合链路。 contour_open = not bool(contour.get("closed", False)) if contour_open and contour.get("role") == "inner": raise ValueError(f"analytic_contours: inner contour {index} cannot be open") raw_edges: list[_Ctx] = [] for segment in contour.get("segments") or []: if segment.get("type") == "line": entities.append(_line(segment["start"], segment["end"])) elif segment.get("type") == "circle": if contour_open: raise ValueError(f"analytic_contours: open contour {index} cannot contain a full circle segment") entities.append(_circle(segment.get("center") or [0.0, 0.0], float(segment.get("radius_mm") or 0.0))) raw_edges.extend(_segment_edges(segment)) if raw_edges: edges = _join(raw_edges, allow_open=contour_open) contour_opened = False if contour_open: if _distance(edges[0]["start_mm"], edges[-1]["end_mm"]) <= _TOLERANCE_MM: raise ValueError(f"analytic_contours: contour {index} is geometrically closed; use closed=true") edges, contour_opened = _close_open_contour(edges) meta["_has_open_contour"] = True _normalize_quarter_rounding_direction(edges) sample = _sample_loop(edges) if _area(sample) <= _TOLERANCE_MM * _TOLERANCE_MM: raise ValueError(f"analytic_contours: contour {index} is degenerate") loops.append({"edges": edges, "points": sample, "area": _area(sample), "open": contour_open and contour_opened}) for segment in profile.get("construction") or []: if segment.get("type") == "line": entities.append(_line(segment["start"], segment["end"], construction=True)) elif segment.get("type") == "circle": entities.append(_circle(segment.get("center") or [0.0, 0.0], float(segment.get("radius_mm") or 0.0), construction=True)) if not loops: return entities, [] for loop in loops: loop["role"] = "inner" if sum(_contains(loop["points"][0], other["points"]) for other in loops if other is not loop) % 2 else "outer" outers = [loop for loop in loops if loop["role"] == "outer"] regions = [{"outer": outer["edges"], "holes": [], "open": bool(outer.get("open"))} for outer in outers] for inner in (loop for loop in loops if loop["role"] == "inner"): containing = [outer for outer in outers if _contains(inner["points"][0], outer["points"])] if not containing: raise ValueError("analytic_contours: inner contour has no containing outer contour") if any(outer.get("open") for outer in containing): raise ValueError("analytic_contours: an open outer contour cannot contain nested holes") selected = min(containing, key=lambda outer: outer["area"]) regions[outers.index(selected)]["holes"].append(inner["edges"]) meta["_regions"] = regions return entities, [] CORE_SHAPE_GENERATORS: dict[str, Any] = {"circle": _gen_circle, "polygon": _gen_polygon, "analytic_contours": _gen_analytic_contours} SHAPE_GENERATORS = CORE_SHAPE_GENERATORS SHAPE_CAPABILITIES: dict[str, _Ctx] = { "circle": {"detectable": True, "arity": "circle", "description": "single circular contour"}, "polygon": {"detectable": True, "arity": "polygon", "description": "closed straight-edge contour"}, "analytic_contours": {"detectable": True, "arity": "analytic", "description": "closed line, arc, circle, ellipse and B-spline contours"}, } def register_shape(_: str, __: Any) -> None: raise RuntimeError("Runtime profile types are fixed; lower custom profiles before CDSL execution") def list_registered_shapes() -> list[str]: return sorted(SHAPE_GENERATORS) def resolve_profile(sketch: _Ctx) -> _Ctx: profile = sketch.get("profile") if not profile: return sketch generator = SHAPE_GENERATORS.get(profile.get("type")) if generator is None: raise ValueError(f"sketch {sketch.get('id')}: unsupported profile type {profile.get('type')!r}") meta: _Ctx = {"id": sketch.get("id"), "_entities": sketch.get("entities"), "_regions": [], "_has_open_contour": False} entities, contour = generator(profile, meta) output = deepcopy(sketch) original_circles = [entity for entity in sketch.get("entities") or [] if entity.get("type") == "circle" and not entity.get("construction")] output["entities"] = list(entities) + (original_circles if contour else []) workplane = sketch.get("workplane") if contour: output["contour_edges_mm"] = _transform_contours(contour, workplane) if workplane else contour if meta["_regions"]: output["contour_regions_mm"] = [ {"outer": _transform_contours(region["outer"], workplane) if workplane else region["outer"], "holes": [_transform_contours(hole, workplane) if workplane else hole for hole in region.get("holes") or []], "open": bool(region.get("open"))} for region in meta["_regions"] ] if meta["_has_open_contour"]: output["_open_contour"] = True return output def _shift_profile(sketch: _Ctx, source: _Ctx) -> _Ctx: output = deepcopy(sketch) output["profile"] = deepcopy(source["profile"]) output.pop("profile_from", None) shift = sketch.get("profile_shift") if shift and len(shift) == 2 and output["profile"].get("type") == "polygon": for vertex in output["profile"]["vertices"]: vertex[0], vertex[1] = round(float(vertex[0]) + float(shift[0]), 6), round(float(vertex[1]) + float(shift[1]), 6) output.pop("profile_shift", None) return output def resolve_all_sketches(cdsl: _Ctx) -> _Ctx: sketches = list((cdsl.get("geometry") or {}).get("sketches") or []) resolved: dict[str, _Ctx] = {} for sketch in sketches: sketch_id = sketch.get("id") if sketch_id is not None and "profile" in sketch: resolved[str(sketch_id)] = resolve_profile(sketch) for sketch in sketches: sketch_id, source_id = sketch.get("id"), sketch.get("profile_from") if sketch_id is not None and source_id: source = resolved.get(str(source_id)) if source is None: raise ValueError(f"sketch {sketch_id}: profile_from={source_id!r} not found or not yet resolved") resolved[str(sketch_id)] = resolve_profile(_shift_profile(sketch, source)) output = deepcopy(cdsl) output.setdefault("geometry", {})["sketches"] = [resolved.get(str(sketch.get("id")), deepcopy(sketch)) for sketch in sketches] return output def resolve_required_sketches(cdsl: _Ctx, sketch_ids: Iterable[str], *, errors: dict[str, str] | None = None) -> _Ctx: sketches = list((cdsl.get("geometry") or {}).get("sketches") or []) by_id = {str(sketch.get("id")): sketch for sketch in sketches if sketch.get("id") is not None} resolved: dict[str, _Ctx] = {} resolving: set[str] = set() def resolve_one(sketch_id: str) -> _Ctx: if sketch_id in resolved: return resolved[sketch_id] sketch = by_id.get(sketch_id) if sketch is None: raise ValueError(f"sketch {sketch_id!r} was not found") if sketch_id in resolving: raise ValueError(f"sketch {sketch_id}: profile_from contains a cycle") resolving.add(sketch_id) try: if "profile" in sketch: output = resolve_profile(sketch) elif sketch.get("profile_from"): output = resolve_profile(_shift_profile(sketch, resolve_one(str(sketch["profile_from"])))) else: output = deepcopy(sketch) resolved[sketch_id] = output return output finally: resolving.discard(sketch_id) for sketch_id in {str(item) for item in sketch_ids}: try: resolve_one(sketch_id) except ValueError as error: if errors is None: raise errors[sketch_id] = str(error) output = deepcopy(cdsl) output.setdefault("geometry", {})["sketches"] = [resolved.get(str(sketch.get("id")), deepcopy(sketch)) for sketch in sketches] return output