I'm developing a system that can draw 2D walls based on SVG paths (open or closed), automatically generating different layers (for example brick, insulation, block) from a JSON file describing their offset and thickness. The idea is for the program to dynamically draw a complete wall composition, regardless of the shape or number of paths. What works: The layers defined in the JSON are correctly generated around the paths. I've also implemented a system that automatically merges similar layers when they are connected: for example, if pathA and pathB both have a "Bloc" layer with the same properties and are connected, those layers are merged into one. This merging system works well except in some specific cases. More precisely, the problem seems to occur when: The end of pathA is connected to the start of pathB; and the end of pathB is connected to the start of pathA (like a loop) In this case, the layers don’t merge as expected. However, if I insert a pathC between them, for example: The end of pathB is connected to the start of pathC; then the end of pathC is connected to the start of pathA; the merging works perfectly. The hardest part is fixing this issue without breaking the current behavior which already works well in most cases. I'm including two JSON files below: One that works correctly Another that has the issue I'm also providing the relevant functions and the Python file. params7.json (not working case) { "wall": [ { "id": "exterior_wall_1", "path_data": "M 0 132 V 0 H 1107 V 427 H 799", "path_data_debug": true, "layers": [ { "name": "bricks", "offset": 0, "thickness": 9, "fill": "url(#hatch_ansi31)", "stroke": "stroke-medium" }, { "name": "insulation", "offset": 12, "thickness": 12, "fill": "url(#hatch_ansi37)", "stroke": "stroke-medium" }, { "name": "bloc", "offset": 24, "thickness": 14, "fill": "url(#hatch_ansi31)", "stroke": "stroke-ultra" } ] }, { "id": "exterior_wall_2", "path_data": "M 799 427 H 593 V 649 H 0 V 383 H -137 V 132 H 0", "path_data_debug": true, "layers": [ { "name": "pannel", "offset": 6, "thickness": 1, "fill": "none", "stroke": "stroke-medium" }, { "name": "layer1", "offset": 7, "thickness": 2.5, "fill": "none", "stroke": "stroke-medium" }, { "name": "layer2", "offset": 9.5, "thickness": 2.5, "fill": "none", "stroke": "stroke-medium" }, { "name": "insulation", "offset": 12, "thickness": 12, "fill": "url(#hatch_ansi37)", "stroke": "stroke-medium" }, { "name": "bloc", "offset": 24, "thickness": 14, "fill": "url(#hatch_ansi31)", "stroke": "stroke-ultra" } ] } ] } params3.json (working case) { "wall": [ { "id": "exterior_wall_1", "path_data": "M 0 0 H 753 V 135 H 886", "path_data_debug": false, "layers": [ { "name": "bricks", "offset": 0, "thickness": 9, "fill": "url(#hatch_ansi31)", "stroke": "stroke-medium" }, { "name": "insulation", "offset": 12, "thickness": 12, "fill": "url(#hatch_ansi37)", "stroke": "stroke-medium" }, { "name": "bloc", "offset": 24, "thickness": 14, "fill": "url(#hatch_ansi31)", "stroke": "stroke-ultra" } ] }, { "id": "exterior_wall_2", "path_data": "M 0 282 V 0", "path_data_debug": false, "layers": [ { "name": "bricks", "offset": 0, "thickness": 9, "fill": "url(#hatch_ansi31)", "stroke": "stroke-medium" }, { "name": "insulation", "offset": 12, "thickness": 12, "fill": "url(#hatch_ansi37)", "stroke": "stroke-medium" }, { "name": "bloc", "offset": 24, "thickness": 14, "fill": "url(#hatch_ansi31)", "stroke": "stroke-ultra" } ] }, { "id": "exterior_wall_3", "path_data": "M 886 135 H 939 V 323 H 588 V 411 H 0 V 282", "path_data_debug": false, "layers": [ { "name": "pannel", "offset": 6, "thickness": 1, "fill": "none", "stroke": "stroke-medium" }, { "name": "layer1", "offset": 7, "thickness": 2.5, "fill": "none", "stroke": "stroke-medium" }, { "name": "layer2", "offset": 9.5, "thickness": 2.5, "fill": "none", "stroke": "stroke-medium" }, { "name": "insulation", "offset": 12, "thickness": 12, "fill": "url(#hatch_ansi37)", "stroke": "stroke-medium" }, { "name": "bloc", "offset": 24, "thickness": 14, "fill": "url(#hatch_ansi31)", "stroke": "stroke-ultra" } ] } ] } main.py: # === Imports === import json import svgwrite from collections import defaultdict from svgpathtools import Line, parse_path from shapely.geometry import LineString, MultiLineString, Polygon from shapely.ops import linemerge from components.defs import get_svg_defs # === Fonctions Utilitaires Géométriques === def are_points_close(p1, p2, tol=0.1): return abs(p1[0] - p2[0]) < tol and abs(p1[1] - p2[1]) < tol def ensure_consistent_direction(coords, desired='ccw'): if len(coords) < 3: return coords poly = Polygon(coords if are_points_close(coords[0], coords[-1]) else coords + [coords[0]]) is_ccw = poly.exterior.is_ccw return list(reversed(coords)) if (desired == 'ccw' and not is_ccw) or (desired == 'cw' and is_ccw) else coords def is_clockwise(points): return not Polygon(points).exterior.is_ccw def extract_coords(geom): if geom.geom_type == 'LineString': return list(geom.coords) if geom.geom_type == 'MultiLineString': merged = linemerge(geom) if isinstance(merged, LineString): return list(merged.coords) if hasattr(merged, 'geoms'): return list(merged.geoms[0].coords) return [] def svg_path_to_points_clean(path_str, arc_sampling=10): path = parse_path(path_str) points = [] for segment in path: if isinstance(segment, Line): points.append((segment.start.real, segment.start.imag)) else: for i in range(arc_sampling): pt = segment.point(i / arc_sampling) points.append((pt.real, pt.imag)) if path: points.append((path[-1].end.real, path[-1].end.imag)) return points # === Construction et Rendu SVG === def create_svg_document(filename, width, height, min_x, min_y): dwg = svgwrite.Drawing( filename=filename, profile="full", size=(f"{width}px", f"{height}px"), viewBox=f"{min_x} {min_y} {width} {height}" ) transform = f"scale(1,-1) translate(0, {-2 * min_y - height})" group = dwg.g(id="main", transform=transform) dwg.add(group) return dwg, group def split_polygon_into_closed_loops(poly, tolerance=0.1): subpaths, current = [], [] for pt in poly: if not current: current.append(pt) elif are_points_close(pt, current[0], tol=tolerance): current.append(pt) subpaths.append(current) current = [] else: current.append(pt) if current: subpaths.append(current) return subpaths def build_svg_path_from_subpaths(subpaths): return " ".join( f"M {' '.join(f'{round(x,3)},{round(y,3)}' for x, y in sub)} Z" for sub in subpaths ) def render_layer_paths(dwg_group, all_results): for layer, poly in all_results: subpaths = split_polygon_into_closed_loops(poly) path_data = build_svg_path_from_subpaths(subpaths) dwg_group.add(svgwrite.path.Path( d=path_data, fill=layer.get("fill", "none"), stroke="black", id=layer.get("name", "layer"), **{"class": layer.get("stroke", "stroke-medium")} )) def add_debug_paths(dwg_group, config): for wall in config.get("wall", []): if wall.get("path_data_debug", False): dwg_group.add(svgwrite.path.Path( d=wall["path_data"], stroke="red", fill="none", stroke_opacity=0.5, stroke_width=2, **{"class": "debug-path"} )) def inject_defs_into_svg(svg_path, defs_str): with open(svg_path, "r", encoding="utf-8") as f: content = f.read() content = content.replace("<defs />", f"<defs>\n{defs_str.strip()}\n</defs>") with open(svg_path, "w", encoding="utf-8") as f: f.write(content) # === Logique des couches === def layer_key(layer): return ( layer.get("name"), layer.get("offset"), layer.get("thickness"), layer.get("fill"), layer.get("stroke") ) def group_all_segments_by_layer_and_offset(config): layer_segments = defaultdict(list) for wall in config.get("wall", []): path_pts = svg_path_to_points_clean(wall["path_data"]) for layer in wall["layers"]: key = layer_key(layer) layer_segments[key].append(path_pts) return layer_segments def generate_offset_polygon_from_lines(lines, offset, thickness): segments = [LineString(pts) for pts in lines if len(pts) >= 2] merged = linemerge(MultiLineString(segments)) if merged.is_empty: return [] base_lines = list(merged.geoms) if hasattr(merged, "geoms") else [merged] polygons = [] for line in base_lines: coords = ensure_consistent_direction(list(line.coords), desired='ccw') line = LineString(coords) try: outer = line.parallel_offset(offset + thickness, 'left', join_style=2) inner = line.parallel_offset(offset, 'left', join_style=2) except Exception as e: print(f"[⚠️] Offset error: {e}") continue if outer.is_empty or inner.is_empty: continue polygon = extract_coords(inner) + extract_coords(outer)[::-1] polygons.append(polygon) return polygons def generate_all_layer_polygons(config, debug_mode=False): layer_segments = group_all_segments_by_layer_and_offset(config) results = [] for key, lines in layer_segments.items(): layer = { "name": key[0], "offset": key[1], "thickness": key[2], "fill": key[3], "stroke": key[4], } polygons = generate_offset_polygon_from_lines(lines, layer["offset"], layer["thickness"]) if debug_mode: print(f"Layer '{layer['name']}' → {len(polygons)} polygones générés") results.extend((layer, poly) for poly in polygons) return results # === Calcul Bounding Box === def get_all_points(polygons): return [pt for _, poly in polygons for pt in poly] def compute_bounding_box(points, margin=50): xs, ys = zip(*points) return min(xs) - margin, min(ys) - margin, (max(xs) - min(xs)) + 2 * margin, (max(ys) - min(ys)) + 2 * margin # === Entrée Principale === def main(): with open("params7.json", encoding="utf-8") as f: config = json.load(f) all_results = generate_all_layer_polygons(config, debug_mode=False) all_pts = get_all_points(all_results) min_x, min_y, width, height = compute_bounding_box(all_pts) dwg, group = create_svg_document("output.svg", width, height, min_x, min_y) render_layer_paths(group, all_results) add_debug_paths(group, config) dwg.save() inject_defs_into_svg("output.svg", get_svg_defs()) print("✅ SVG généré : output.svg") if __name__ == "__main__": main()``` Continue reading...