1st implementation.
@@ -0,0 +1,4 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.bak
|
||||
@@ -0,0 +1,492 @@
|
||||
from __future__ import annotations
|
||||
from typing import Optional
|
||||
|
||||
from shapely.geometry import Polygon, LineString, Point
|
||||
from shapely.ops import split, unary_union
|
||||
from shapely.geometry.polygon import orient
|
||||
from shapely import affinity
|
||||
from PIL import Image, ImageDraw, ImageOps
|
||||
import matplotlib.pyplot as plt
|
||||
import math
|
||||
import copy
|
||||
|
||||
|
||||
# -----------------------
|
||||
# Split presets & constants
|
||||
# -----------------------
|
||||
phi = (1.0 + math.sqrt(5.0)) / 2.0
|
||||
SPLIT_MODES = {
|
||||
0: 0.5, # midpoint (default)
|
||||
1: 1.0 / 3.0, # 1/3
|
||||
2: 2.0 / 3.0, # 2/3
|
||||
3: 1.0 - 1.0 / phi,
|
||||
4: 1.0 / phi, # 1/phi
|
||||
}
|
||||
|
||||
|
||||
class CutNode:
|
||||
def __init__(self, vertical: bool, angle: int = 0, split_mode: int = 0):
|
||||
"""
|
||||
vertical: True => vertical cuts; False => horizontal
|
||||
angle: integer degrees (slant)
|
||||
split_mode: index into SPLIT_MODES (0 allowed many cuts, nonzero -> only 1 cut)
|
||||
"""
|
||||
self.vertical: bool = vertical
|
||||
self.angle: int = int(angle)
|
||||
self.split_mode: int = split_mode
|
||||
self.children: list[Optional[CutNode]] = []
|
||||
|
||||
def __deepcopy__(self, memo):
|
||||
node = CutNode(self.vertical, self.angle, self.split_mode)
|
||||
node.children = [
|
||||
copy.deepcopy(c, memo) if c is not None else None
|
||||
for c in self.children
|
||||
]
|
||||
return node
|
||||
|
||||
@property
|
||||
def cuts(self) -> int:
|
||||
return max(0, len(self.children) - 1)
|
||||
|
||||
def add_child(self, child: Optional[CutNode] = None) -> None:
|
||||
# enforce non-midpoint single-cut rule at add time
|
||||
if self.split_mode != 0 and len(self.children) >= 2:
|
||||
raise ValueError("Non-midpoint split modes only support one cut (two children).")
|
||||
self.children.append(child)
|
||||
|
||||
def to_compact(self) -> str:
|
||||
"""Serialize compactly: V15_3(...). Always include split_mode for simplicity."""
|
||||
prefix = "V" if self.vertical else "H"
|
||||
s = f"{prefix}{self.angle}_{self.split_mode}"
|
||||
if self.children:
|
||||
s += "(" + ",".join(c.to_compact() if c else "" for c in self.children) + ")"
|
||||
return s
|
||||
|
||||
@staticmethod
|
||||
def from_compact(data: str) -> Optional[CutNode]:
|
||||
"""Parser. Builds the embedded cut tree."""
|
||||
if not data or not data.strip():
|
||||
return None
|
||||
|
||||
def _split_top_level_commas(s: str) -> list[str]:
|
||||
tokens: list[str] = []
|
||||
buf: list[str] = []
|
||||
depth = 0
|
||||
for ch in s:
|
||||
if ch == "(":
|
||||
depth += 1
|
||||
buf.append(ch)
|
||||
elif ch == ")":
|
||||
depth -= 1
|
||||
buf.append(ch)
|
||||
elif ch == "," and depth == 0:
|
||||
tokens.append("".join(buf))
|
||||
buf = []
|
||||
else:
|
||||
buf.append(ch)
|
||||
tokens.append("".join(buf))
|
||||
return tokens
|
||||
|
||||
def _parse_node_str(s: str) -> Optional[CutNode]:
|
||||
s = s.strip()
|
||||
if not s:
|
||||
return None
|
||||
i = 0
|
||||
n = len(s)
|
||||
if s[i] not in ("V", "H"):
|
||||
raise ValueError(f"Expected 'V' or 'H' at start of node: {s!r}")
|
||||
vertical = s[i] == "V"
|
||||
i += 1
|
||||
|
||||
# integer angle (supports sign)
|
||||
angle_str = ""
|
||||
while i < n and (s[i].isdigit() or s[i] in "-+"):
|
||||
angle_str += s[i]
|
||||
i += 1
|
||||
angle = int(angle_str) if angle_str else 0
|
||||
|
||||
# optional split_mode after '_'
|
||||
split_mode = 0
|
||||
if i < n and s[i] == "_":
|
||||
i += 1
|
||||
mode_str = ""
|
||||
while i < n and s[i].isdigit():
|
||||
mode_str += s[i]
|
||||
i += 1
|
||||
split_mode = int(mode_str) if mode_str else 0
|
||||
|
||||
node = CutNode(vertical, angle, split_mode)
|
||||
|
||||
# children (optional)
|
||||
if i < n and s[i] == "(":
|
||||
# find matching closing paren
|
||||
j = i + 1
|
||||
depth = 0
|
||||
while j < n:
|
||||
if s[j] == "(":
|
||||
depth += 1
|
||||
elif s[j] == ")":
|
||||
if depth == 0:
|
||||
break
|
||||
depth -= 1
|
||||
j += 1
|
||||
if j >= n or s[j] != ")":
|
||||
raise ValueError(f"Unmatched '(' in node string: {s!r}")
|
||||
inner = s[i + 1: j]
|
||||
child_tokens = _split_top_level_commas(inner)
|
||||
for tok in child_tokens:
|
||||
tok = tok.strip()
|
||||
if tok == "":
|
||||
node.add_child(None)
|
||||
else:
|
||||
child = _parse_node_str(tok)
|
||||
node.add_child(child)
|
||||
# i = j + 1
|
||||
return node
|
||||
|
||||
return _parse_node_str(data.strip())
|
||||
|
||||
@staticmethod
|
||||
def _weighted_midpoint_of_lines(intersection_geom) -> Optional[tuple[float, float]]:
|
||||
"""
|
||||
Given the result of polygon.intersection(line) (maybe LineString, MultiLineString, Point, GeometryCollection),
|
||||
compute a robust midpoint: weighted average of segment midpoints by their length.
|
||||
"""
|
||||
if intersection_geom.is_empty:
|
||||
return None
|
||||
|
||||
# single line
|
||||
if intersection_geom.geom_type == "LineString":
|
||||
coords = list(intersection_geom.coords)
|
||||
x0, y0 = coords[0]
|
||||
x1, y1 = coords[-1]
|
||||
return ((x0 + x1) / 2.0, (y0 + y1) / 2.0)
|
||||
|
||||
# point
|
||||
if intersection_geom.geom_type == "Point":
|
||||
return (intersection_geom.x, intersection_geom.y)
|
||||
|
||||
# multi or collection: gather lines and points
|
||||
total_len = 0.0
|
||||
sum_x = 0.0
|
||||
sum_y = 0.0
|
||||
geoms = getattr(intersection_geom, "geoms", []) # Shapely 1.x and 2.x compat
|
||||
for g in geoms:
|
||||
if g.geom_type == "LineString":
|
||||
coords = list(g.coords)
|
||||
x0, y0 = coords[0]
|
||||
x1, y1 = coords[-1]
|
||||
midx, midy = (x0 + x1) / 2.0, (y0 + y1) / 2.0
|
||||
L = g.length
|
||||
sum_x += midx * L
|
||||
sum_y += midy * L
|
||||
total_len += L
|
||||
elif g.geom_type == "Point":
|
||||
sum_x += g.x
|
||||
sum_y += g.y
|
||||
total_len += 1.0
|
||||
if total_len <= 0:
|
||||
return None
|
||||
return (sum_x / total_len, sum_y / total_len)
|
||||
|
||||
@staticmethod
|
||||
def _build_seam_lines_for_panel(
|
||||
panel: Polygon,
|
||||
vertical: bool,
|
||||
angle_deg: int,
|
||||
cuts: int,
|
||||
split_mode: int,
|
||||
) -> list[LineString]:
|
||||
"""
|
||||
Produce a list of seam LineString objects for the given panel.
|
||||
- cuts: suggested number of cuts (children-1). If split_mode != 0 then we only allow 1 cut.
|
||||
- split_mode chooses ratio presets (if 0 we use evenly spaced j/(cuts+1)).
|
||||
Each seam is centered so its midpoint (intersection with panel) lies on the reference point.
|
||||
"""
|
||||
if cuts <= 0 or panel.is_empty:
|
||||
return []
|
||||
|
||||
if split_mode != 0:
|
||||
# enforce single cut for non-midpoint modes
|
||||
cuts = 1
|
||||
|
||||
xmin, ymin, xmax, ymax = panel.bounds
|
||||
width = xmax - xmin
|
||||
height = ymax - ymin
|
||||
pad = max(width, height) * 10.0 + 1.0 # long enough line to cross panel
|
||||
cx, cy = panel.centroid.x, panel.centroid.y
|
||||
|
||||
seam_lines: list[LineString] = []
|
||||
|
||||
for j in range(1, cuts + 1):
|
||||
# compute ratio
|
||||
if split_mode == 0:
|
||||
ratio = j / (cuts + 1)
|
||||
else:
|
||||
ratio = SPLIT_MODES.get(split_mode, 0.5)
|
||||
|
||||
if vertical:
|
||||
c = xmin + (xmax - xmin) * ratio
|
||||
base = LineString([(c, ymin - pad), (c, ymax + pad)])
|
||||
ref_point = Point(c, (ymin + ymax) / 2.0)
|
||||
else:
|
||||
c = ymin + (ymax - ymin) * ratio
|
||||
base = LineString([(xmin - pad, c), (xmax + pad, c)])
|
||||
ref_point = Point((xmin + xmax) / 2.0, c)
|
||||
|
||||
# rotate around centroid
|
||||
line_rot = affinity.rotate(base, angle_deg, origin=(cx, cy))
|
||||
|
||||
# compute intersection with panel and midpoint
|
||||
inter = panel.intersection(line_rot)
|
||||
mid = CutNode._weighted_midpoint_of_lines(inter)
|
||||
if mid is not None:
|
||||
dx = ref_point.x - mid[0]
|
||||
dy = ref_point.y - mid[1]
|
||||
line_centered = affinity.translate(line_rot, xoff=dx, yoff=dy)
|
||||
else:
|
||||
# no intersection found, keep rotated line (it will probably not split)
|
||||
line_centered = line_rot
|
||||
|
||||
# Option: sanity - only keep the line if it intersects the panel
|
||||
if not panel.intersects(line_centered):
|
||||
# skip seam that doesn't intersect (degenerate)
|
||||
continue
|
||||
|
||||
seam_lines.append(LineString(line_centered)) # make a copy
|
||||
|
||||
return seam_lines
|
||||
|
||||
@staticmethod
|
||||
def partition_panel(panel: Polygon, node: CutNode, margin_here: float) -> list[Polygon]:
|
||||
"""
|
||||
Given a panel polygon and a CutNode (for that panel), compute the list of sub-panels
|
||||
after applying node's cuts and carving the margin gaps only on seams.
|
||||
|
||||
Returns list of polygons in ascending order along the cut axis (left->right for vertical,
|
||||
bottom->top for horizontal).
|
||||
"""
|
||||
if panel.is_empty or node is None or len(node.children) == 0:
|
||||
return [panel]
|
||||
|
||||
# number of cuts (children - 1); enforce split_mode rule inside seam builder
|
||||
cuts = node.cuts
|
||||
|
||||
# 1) build seam lines (centered)
|
||||
seam_lines = CutNode._build_seam_lines_for_panel(panel, node.vertical, node.angle, cuts, node.split_mode)
|
||||
|
||||
if not seam_lines:
|
||||
return [panel]
|
||||
|
||||
# 2) unify seams into a single multilinestring for splitting
|
||||
seam_union = unary_union(seam_lines)
|
||||
|
||||
# 3) split panel by seam union
|
||||
split_result = split(panel, seam_union)
|
||||
raw_pieces = [g for g in getattr(split_result, "geoms", [split_result]) if g.geom_type == "Polygon"]
|
||||
|
||||
if not raw_pieces:
|
||||
# fallback
|
||||
return [panel]
|
||||
|
||||
# 4) order raw pieces along the primary axis (so child mapping is deterministic)
|
||||
if node.vertical:
|
||||
raw_pieces.sort(key=lambda p: p.centroid.x)
|
||||
else:
|
||||
raw_pieces.sort(key=lambda p: p.centroid.y)
|
||||
|
||||
# 5) build margin gaps as buffer of seam lines and subtract them from each raw piece
|
||||
if margin_here is not None and margin_here > 0:
|
||||
offsets = margin_here / 2.0
|
||||
# rectangular-ish gaps using square caps (cap_style=2) to keep straight edges
|
||||
gap_polys = [s.buffer(offsets, cap_style=2) for s in seam_lines]
|
||||
gap_union = unary_union(gap_polys)
|
||||
adjusted = []
|
||||
for rp in raw_pieces:
|
||||
diff = rp.difference(gap_union)
|
||||
# difference may produce Polygon or MultiPolygon; keep as-is
|
||||
if diff.is_empty:
|
||||
# If fully removed, keep an empty placeholder (to preserve counts)
|
||||
adjusted.append(Polygon())
|
||||
else:
|
||||
adjusted.append(diff)
|
||||
else:
|
||||
adjusted = raw_pieces
|
||||
|
||||
# 6) result should have len == node.cuts+1 (or close); if not, pad with empties
|
||||
expected = max(1, (node.cuts + 1))
|
||||
while len(adjusted) < expected:
|
||||
adjusted.append(Polygon())
|
||||
|
||||
return adjusted
|
||||
|
||||
@staticmethod
|
||||
def process_tree(node: Optional[CutNode], panel: Polygon, margin: float = 0.0, rtl=False, depth: int = 0) -> list[Polygon]:
|
||||
"""
|
||||
Process the cut tree, starting at `panel`. Uses exact seam-based margin carving.
|
||||
margin is the top-level margin; it is decayed by phi per depth:
|
||||
margin_at_depth = margin / (phi ** depth)
|
||||
|
||||
rtl = True, then panels are sorted from right to left, instead of from left to right
|
||||
"""
|
||||
if panel.is_empty:
|
||||
return []
|
||||
if node is None or len(node.children) == 0:
|
||||
return [panel]
|
||||
|
||||
# compute margin for this depth
|
||||
current_margin = margin / (phi ** depth) if margin > 0 else 0.0
|
||||
|
||||
# partition this panel into pieces and carve gaps (exact)
|
||||
pieces = CutNode.partition_panel(panel, node, current_margin)
|
||||
|
||||
out: list[Polygon] = []
|
||||
# Now map children to pieces in order
|
||||
for child, piece in zip(node.children, pieces):
|
||||
if child is None:
|
||||
# leaf: piece is final (could be Polygon or MultiPolygon)
|
||||
out.append(piece)
|
||||
else:
|
||||
# recursive
|
||||
out.extend(CutNode.process_tree(child, piece, margin=margin, rtl=rtl, depth=depth + 1))
|
||||
|
||||
if depth == 0 and rtl:
|
||||
# flip polygons
|
||||
xmin, ymin, xmax, ymax = panel.bounds
|
||||
cx = (xmin + xmax) / 2 # center x of canvas
|
||||
return [orient(affinity.scale(p, xfact=-1, yfact=1, origin=(cx, 0)), -1.0) for p in out] # bruh
|
||||
|
||||
return out
|
||||
|
||||
|
||||
def layout_to_image(
|
||||
cut_tree: CutNode,
|
||||
rtl: bool = False, # stored image in right-to-left format (x mirrored)
|
||||
canvas_width: int = 210,
|
||||
canvas_height: int = 297,
|
||||
font_size: int = 9,
|
||||
margin: int = 4,
|
||||
index_font_size: int = 10,
|
||||
) -> tuple[Image, str]:
|
||||
"""
|
||||
Save panels as a PNG image, showing both left-to-right and right-to-left indices.
|
||||
The cut tree's compact code is also drawn below and stored in PNG metadata.
|
||||
"""
|
||||
from shapely.geometry import box
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
root_panel = box(0, 0, canvas_width, canvas_height)
|
||||
polygons = CutNode.process_tree(cut_tree, root_panel, margin=0, rtl=rtl)
|
||||
base_im = panels_to_image(polygons, index_font_size, "pink" if rtl else "lightblue")
|
||||
compact_code = cut_tree.to_compact()
|
||||
|
||||
# Extend image at bottom
|
||||
width, height = base_im.size
|
||||
extra_height = font_size + 2 * margin
|
||||
new_im = Image.new("RGBA", (width, height + extra_height), (255, 255, 255, 255))
|
||||
new_im.paste(base_im, (0, 0))
|
||||
|
||||
# Draw compact code text
|
||||
draw = ImageDraw.Draw(new_im)
|
||||
try:
|
||||
font = ImageFont.truetype("DejaVuSansMono.ttf", font_size)
|
||||
except IOError:
|
||||
font = ImageFont.load_default()
|
||||
|
||||
bbox = draw.textbbox((0, 0), compact_code, font=font)
|
||||
text_w = bbox[2] - bbox[0]
|
||||
# text_h = bbox[3] - bbox[1]
|
||||
x_pos = (width - text_w) // 2
|
||||
y_pos = height + margin
|
||||
draw.text((x_pos, y_pos), compact_code, font=font, fill=(0, 0, 0, 255))
|
||||
|
||||
return new_im, compact_code
|
||||
|
||||
|
||||
def panels_to_image(panels: list[Polygon], index_font_size: int = 10,
|
||||
annotate_color: Optional[str] = "lightblue",
|
||||
canvas: Optional[Polygon] = None) -> Image:
|
||||
"""
|
||||
:param annotate_rtl: None->No annotations; False->left to right; True->right to left
|
||||
"""
|
||||
import io
|
||||
fig, ax = plt.subplots()
|
||||
ax.set_aspect("equal")
|
||||
ax.axis("off")
|
||||
ax.yaxis.set_inverted(True)
|
||||
|
||||
if canvas is not None:
|
||||
x, y = canvas.exterior.xy
|
||||
ax.fill(x, y, alpha=1, color="white", edgecolor="black", linewidth=2)
|
||||
|
||||
for poly in panels:
|
||||
x, y = poly.exterior.xy
|
||||
ax.fill(x, y, alpha=0.6, edgecolor="black", linewidth=1)
|
||||
|
||||
if annotate_color is not None:
|
||||
for idx, poly_ltr in enumerate(panels):
|
||||
cx, cy = poly_ltr.centroid.coords[0]
|
||||
|
||||
bg_color = annotate_color
|
||||
|
||||
ax.text(
|
||||
cx, cy, str(idx),
|
||||
ha="center", va="center",
|
||||
fontsize=index_font_size,
|
||||
color="black",
|
||||
bbox=dict(facecolor=bg_color, edgecolor="none", boxstyle="circle,pad=0.2", alpha=0.7)
|
||||
)
|
||||
|
||||
buf = io.BytesIO()
|
||||
plt.savefig(buf, format="png", bbox_inches="tight", pad_inches=0)
|
||||
plt.close(fig)
|
||||
return Image.open(buf).convert("RGBA")
|
||||
|
||||
|
||||
def draw_polygon_contours(
|
||||
polys: list[Polygon],
|
||||
canvas: Polygon,
|
||||
stroke_color: tuple[int, int, int, int] = (0, 0, 0, 255),
|
||||
stroke_width: int = 1,
|
||||
pad: int = 0,
|
||||
upscale: int = 4,
|
||||
) -> Image.Image:
|
||||
"""
|
||||
Draw polygon contours on a transparent RGBA image and return the image.
|
||||
Supports optional bevel smoothing via CurvatureParams.
|
||||
"""
|
||||
xmin, ymin, xmax, ymax = canvas.bounds
|
||||
width = int(math.ceil(xmax - xmin)) + 2 * pad
|
||||
height = int(math.ceil(ymax - ymin)) + 2 * pad
|
||||
|
||||
if width <= 0 or height <= 0:
|
||||
return Image.new("RGBA", (1, 1), (0, 0, 0, 0))
|
||||
|
||||
# internal supersampled image
|
||||
W, H = width * upscale, height * upscale
|
||||
img = Image.new("RGBA", (W, H), (0, 0, 0, 0))
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
def _to_image_coords(x: float, y: float) -> tuple[float, float]:
|
||||
x_img = ((x - xmin) + pad) * upscale
|
||||
y_img = ((ymax - y) + pad) * upscale
|
||||
return (x_img, y_img)
|
||||
|
||||
for poly in polys:
|
||||
if poly is None or not isinstance(poly, Polygon):
|
||||
continue
|
||||
|
||||
# exterior
|
||||
ext = [_to_image_coords(x, y) for x, y in poly.exterior.coords]
|
||||
if len(ext) >= 2:
|
||||
draw.line(ext + [ext[0]], fill=stroke_color, width=max(1, stroke_width * upscale), joint="curve")
|
||||
|
||||
# holes
|
||||
for interior in poly.interiors:
|
||||
coords = [_to_image_coords(x, y) for x, y in interior.coords]
|
||||
if len(coords) >= 2:
|
||||
draw.line(coords + [coords[0]], fill=stroke_color, width=max(1, stroke_width * upscale), joint="curve")
|
||||
|
||||
img = ImageOps.flip(img)
|
||||
return img.resize((width, height), Image.Resampling.LANCZOS)
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2023 bmad4ever
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,3 @@
|
||||
from .nodes import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS
|
||||
|
||||
__all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS']
|
||||
@@ -0,0 +1,33 @@
|
||||
from dataclasses import dataclass
|
||||
from shapely.geometry import Polygon
|
||||
from typing import Tuple
|
||||
|
||||
|
||||
@dataclass
|
||||
class BBoxSnap:
|
||||
xmin: float
|
||||
ymin: float
|
||||
xmax: float
|
||||
ymax: float
|
||||
snap_on_bbox: bool = True
|
||||
|
||||
@classmethod
|
||||
def from_bounds(cls, bounds: Tuple[float, float, float, float], snap_on_bbox: bool = True) -> "BBoxSnap":
|
||||
"""Create from (xmin, ymin, xmax, ymax)."""
|
||||
return cls(*bounds, snap_on_bbox)
|
||||
|
||||
@classmethod
|
||||
def from_polygon(cls, poly: Polygon, snap_on_bbox: bool = True) -> "BBoxSnap":
|
||||
"""Create from a Shapely polygon's bounds."""
|
||||
return cls(*poly.bounds, snap_on_bbox)
|
||||
|
||||
def as_tuple(self) -> tuple[float, float, float, float]:
|
||||
"""Return just the numeric bounds as a tuple."""
|
||||
return (self.xmin, self.ymin, self.xmax, self.ymax)
|
||||
|
||||
|
||||
@dataclass
|
||||
class CurvatureParams:
|
||||
curvature: float = 0.0 # bevel radius
|
||||
iterations: int = 1 # how many times to apply beveling
|
||||
resolution: int = 16 # buffer resolution (segments per quarter circle)
|
||||
@@ -0,0 +1,14 @@
|
||||
[project]
|
||||
name = "comfyui_panels"
|
||||
description = "Comics/Manga like panel layouts."
|
||||
version = "1.0.0"
|
||||
license = { file = "LICENSE" }
|
||||
dependencies = ["shapely==2.1.1", "matplotlib==3.10.6"]
|
||||
|
||||
[project.urls]
|
||||
Repository = "https://github.com/bmad4ever/comfyui-panels"
|
||||
|
||||
[tool.comfy]
|
||||
PublisherId = "bmad4ever"
|
||||
DisplayName = "comfyui-panels"
|
||||
Icon = ""
|
||||
@@ -0,0 +1,2 @@
|
||||
shapely==2.1.1
|
||||
matplotlib==3.10.6
|
||||
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 9.0 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 8.6 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 9.9 KiB |
|
After Width: | Height: | Size: 7.0 KiB |
|
After Width: | Height: | Size: 8.9 KiB |
|
After Width: | Height: | Size: 7.7 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 8.2 KiB |
|
After Width: | Height: | Size: 9.8 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 8.6 KiB |
|
After Width: | Height: | Size: 8.0 KiB |
|
After Width: | Height: | Size: 8.4 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 9.5 KiB |
|
After Width: | Height: | Size: 9.2 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 663 KiB |
|
After Width: | Height: | Size: 101 KiB |
|
After Width: | Height: | Size: 116 KiB |
|
After Width: | Height: | Size: 69 KiB |