[Python] Problem with merging SVG wall layers when paths are connected in a loop

Discussão em 'Python' iniciado por Stack, Junho 7, 2025.

  1. Stack

    Stack Membro Participativo

    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...

Compartilhe esta Página