Add packed layout Python prototype

This commit is contained in:
ajp_anton
2026-05-13 03:06:09 +00:00
parent 5716224a8c
commit ba3b92d4d0
3 changed files with 1433 additions and 0 deletions
+401
View File
@@ -0,0 +1,401 @@
from dataclasses import dataclass, field
@dataclass(eq=False)
class Box:
rel_x: float
rel_y: float
width: float
height: float
item: "LayoutItem | None" = field(default=None, repr=False)
collision_left: list["Box"] = field(default_factory=list, repr=False)
collision_right: list["Box"] = field(default_factory=list, repr=False)
def get_left(self):
return self.item.x + self.rel_x
def get_right(self):
return self.get_left() + self.width
def get_top(self):
return self.item.y + self.rel_y
def get_bottom(self):
return self.get_top() + self.height
def is_in_collision_left_tree(self, box):
if box in self.collision_left:
return True
return any(collision.is_in_collision_left_tree(box) for collision in self.collision_left)
def __repr__(self):
if len(self.item.boxes) == 1:
return self.item.name
return f"{self.item.name}:{self.item.boxes.index(self)}"
class LayoutItem:
def __init__(
self,
name,
kind,
color,
position,
boxes,
fallback=None,
icon_group=None,
icon_count=0,
icon_width=40,
icon_widths=None,
icon_height=None,
dot_width=40,
allow_zero=False,
allow_dot=False,
direction=None,
spacing=0,
):
self.name = name
self.kind = kind
self.color = color
self.position = position if isinstance(position, str) else position[0]
self.fallback = fallback if isinstance(position, str) else position[1]
self.icon_group = icon_group
self.icon_count = icon_count
self.icon_width = icon_width
self.icon_height = icon_height if icon_height is not None else icon_width
self.icon_widths = self._expanded_icon_widths(icon_widths, icon_count, icon_width)
self.dot_width = dot_width
self.allow_zero = allow_zero
self.allow_dot = allow_dot
self.direction = direction
self.spacing = spacing
self.visible_icons = icon_count
self.visible_icon_widths = self.icon_widths
self.dot = False
self.pool_remaining = icon_count
self.pool_icon_widths = self.icon_widths
self.width_limit = None
self.x = 0
self.y = 0
self.boxes = boxes
for box in self.boxes:
box.item = self
if self.is_icon_item:
self.width_limit = sum(self.icon_widths)
self._apply_icon_width(self.width_limit)
@classmethod
def fixed(cls, name, kind, color, position, width, height, xy=(0, 0), fallback=None):
item = cls(name, kind, color, position, [Box(0, 0, width, height)], fallback=fallback)
item.x, item.y = xy
return item
@classmethod
def multi_box(cls, name, kind, color, position, boxes, xy=(0, 0), fallback=None):
item = cls(name, kind, color, position, boxes, fallback=fallback)
item.x, item.y = xy
return item
@classmethod
def icon_strip(
cls,
name,
kind,
color,
position,
icon_group,
icon_count,
height,
xy=(0, 0),
icon_width=40,
icon_widths=None,
allow_zero=False,
allow_dot=True,
direction=None,
spacing=0,
):
item = cls(
name,
kind,
color,
position,
[Box(0, 0, 0, height)],
icon_group=icon_group,
icon_count=icon_count,
icon_width=icon_width,
icon_widths=icon_widths,
icon_height=height,
dot_width=height * 0.75,
allow_zero=allow_zero,
allow_dot=allow_dot,
direction=direction,
spacing=spacing,
)
item.x, item.y = xy
return item
@property
def is_icon_item(self):
return self.icon_group is not None
def get_width(self):
return self.get_right() - self.get_left()
def get_left(self):
return min(box.get_left() for box in self.boxes)
def get_right(self):
return max(box.get_right() for box in self.boxes)
def get_top(self):
return min(box.get_top() for box in self.boxes)
def get_bottom(self):
return max(box.get_bottom() for box in self.boxes)
def set_left(self, x):
self.x += x - self.get_left()
def set_right(self, x):
self.x += x - self.get_right()
def set_top(self, y):
self.y += y - self.get_top()
def shift_x(self, offset):
self.x += offset
def collision_amount_left(self, items=None):
boxes = self._boxes_for_items(items)
amount = 0
for box in self.boxes:
for collision in box.collision_left:
if boxes is not None and collision not in boxes:
continue
for blocker, blocker_item in self._visible_blockers(collision, "left", boxes):
amount = max(
amount,
blocker + blocker_item.spacing_to(self) - box.get_left(),
)
return amount
def collision_amount_right(self, items=None):
boxes = self._boxes_for_items(items)
amount = 0
for box in self.boxes:
for collision in box.collision_right:
if boxes is not None and collision not in boxes:
continue
for blocker, blocker_item in self._visible_blockers(collision, "right", boxes):
amount = max(
amount,
box.get_right() + self.spacing_to(blocker_item) - blocker,
)
return amount
def spacing_to(self, other):
if self.get_width() <= 0 or other.get_width() <= 0:
return 0
if self.icon_group is not None and self.icon_group == other.icon_group:
return 0
return self.spacing
def get_collisionleft_items(self):
return self._collision_items("left")
def get_collisionright_items(self):
return self._collision_items("right")
def min_width(self):
if not self.is_icon_item:
if self.kind == "Chip":
return 0
return self.get_width()
if self.icon_count <= 0 or self.pool_remaining <= 0:
return 0
if self.allow_zero:
return 0
return self._dot_width(visible_icons=0)
def can_shrink_by(self):
return max(0, self.get_width() - self.min_width())
def truncate_at_least(self, amount):
amount = max(0, min(amount, self.can_shrink_by()))
old_width = self.get_width()
if self.is_icon_item:
self.width_limit = max(0, self.width_limit - amount)
self.recompute_icon_state()
self.width_limit = self.get_width()
else:
self._set_primary_width(old_width - amount)
return old_width - self.get_width()
def set_pool_remaining(self, remaining):
self.pool_remaining = max(0, remaining)
self.recompute_icon_state()
def recompute_icon_state(self):
if not self.is_icon_item:
return
max_width = min(self.width_limit, sum(self.pool_icon_widths))
width, visible, dot = self._best_icon_state(max_width)
self.visible_icons = visible
self.visible_icon_widths = self.pool_icon_widths[:visible]
self.dot = dot
self._apply_icon_width(width)
def set_pool_icons(self, widths):
self.width_limit = min(self.width_limit, self.get_width())
self.pool_icon_widths = list(widths)
self.pool_remaining = len(self.pool_icon_widths)
self.recompute_icon_state()
self.width_limit = self.get_width()
def rendername(self):
text = f"{self.name}"
if self.is_icon_item:
text += f"\n{self.visible_icons}/{self.icon_count} icons" + (" *" if self.dot else "")
text += f"\n{self.get_width()}"
return text
def render_segments(self):
if not self.is_icon_item:
return []
segments = [("icon", width) for width in self.visible_icon_widths]
if self.dot:
dot = ("dot", self._dot_width())
if self._dot_side() == "left":
segments.insert(0, dot)
else:
segments.append(dot)
return segments
def _best_icon_state(self, max_width):
if self.pool_remaining <= 0:
return 0, 0, False
max_icons = len(self.pool_icon_widths)
prefix_widths = [0]
for width in self.pool_icon_widths:
prefix_widths.append(prefix_widths[-1] + width)
best = None
for visible in range(max_icons, -1, -1):
hidden = self.pool_remaining - visible
dot = self.allow_dot and hidden > 0
icon_width = prefix_widths[visible]
if visible == 0 and not dot and not self.allow_zero:
continue
width = None
if visible == 0 and dot and self.allow_zero:
width = self._dot_width()
elif visible > 0 or dot:
width = icon_width + (self._dot_width() if dot else 0)
elif self.allow_zero:
width = 0
if width is not None and width <= max_width:
if best is None or width > best[0]:
best = (width, visible, dot)
if best is not None:
return best
return 0, 0, False
def _dot_width(self, visible_icons=None):
visible_icons = self.visible_icons if visible_icons is None else visible_icons
crop = 0
dot_side = self._dot_side()
if dot_side == "left" and all(not box.collision_left for box in self.boxes):
crop += self.dot_width / 3
if dot_side == "right" and all(not box.collision_right for box in self.boxes):
crop += self.dot_width / 3
if visible_icons == 0:
if dot_side != "left" and all(not box.collision_left for box in self.boxes):
crop += self.dot_width / 3
if dot_side != "right" and all(not box.collision_right for box in self.boxes):
crop += self.dot_width / 3
return max(0, self.dot_width - crop)
def _dot_side(self):
if self.direction in ("left", "right"):
return self.direction
if self.position == "right":
return "left"
return "right"
def _apply_icon_width(self, width):
self._set_primary_width(width)
def _set_primary_width(self, width):
self.boxes[0].width = max(0, width)
@staticmethod
def _expanded_icon_widths(widths, count, fallback):
if count <= 0:
return []
if widths is None:
return [fallback] * count
source = list(widths)
if not source:
source = [fallback]
if len(source) < count:
source.extend([source[-1]] * (count - len(source)))
return source[:count]
def _boxes_for_items(self, items):
if items is None:
return None
return {box for item in items for box in item.boxes}
def _visible_blockers(self, box, side, allowed_boxes=None):
seen = set()
def visit(current):
if current in seen:
return
seen.add(current)
if allowed_boxes is not None and current not in allowed_boxes:
return
if current.item.get_width() > 0:
if side == "left":
yield current.get_right(), current.item
else:
yield current.get_left(), current.item
return
collisions = current.collision_left if side == "left" else current.collision_right
found = False
for collision in collisions:
for blocker in visit(collision):
found = True
yield blocker
if not found:
if side == "left":
yield current.get_right(), current.item
else:
yield current.get_left(), current.item
yield from visit(box)
def _collision_items(self, side):
seen = set()
result = []
def visit(box):
if box in seen:
return
seen.add(box)
if box.item not in result:
result.append(box.item)
collisions = box.collision_left if side == "left" else box.collision_right
for collision in collisions:
visit(collision)
for box in self.boxes:
visit(box)
return result
def __repr__(self):
return self.name
+873
View File
@@ -0,0 +1,873 @@
from dataclasses import dataclass
from layout_item import Box, LayoutItem
SCREEN_WIDTH = 1200
SCREEN_HEIGHT = 160
@dataclass
class Scenario:
cutout: bool = True
camera_x: int = 770
camera_width: int = 100
clock_pos: str = "left"
chip_pos: tuple[str, str] = ("middle", "right")
clock_vertical: int = 20
chip_vertical: int = 90
notifs_count: int = 2
status_count: int = 2
dummy_count: int = 3
notifs_pos: tuple[str | tuple[str, str], ...] = ("left",)
status_pos: tuple[str | tuple[str, str], ...] = ("right",)
dummy_pos: tuple[str | tuple[str, str], ...] = (("middle", "left"),)
notifs_vertical: tuple[int, ...] = (0, 0, 0)
status_vertical: tuple[int, ...] = (0, 100)
dummy_vertical: tuple[int, ...] = (50, 0, 20)
notifs_icons: int = 20
status_icons: int = 20
notifs_icon_width: int = 50
status_icon_widths: tuple[int, ...] = (30, 30, 30, 30)
status_icon_height: int = 30
container_spacing: int = 10
order: tuple[str, ...] = ("Clock", "Chip", "Status", "Notifs", "Dummy")
shrink_order: tuple[str, ...] = ("Notifs", "Status", "Chip")
@dataclass
class PathState:
boxes: list[Box]
gaps: float = 0
def added(self, box):
gap = self.gaps
previous = self.boxes[-1]
if previous.item is not box.item:
actual_gap = box.get_left() - previous.get_right()
required_gap = previous.item.spacing_to(box.item)
gap += max(0, actual_gap - required_gap)
return PathState(self.boxes + [box], gap)
def get_right(self):
return self.boxes[-1].get_right()
def position_containers(scenario=None):
scenario = scenario or Scenario()
# 1. Build the test items from explicit scenario settings.
items = make_test_items(scenario)
regions = make_regions(scenario)
# 2. Assign each item to a cutout-separated region and build collision edges.
place_into_regions(regions, items)
for region in regions:
collision_tree(region["items"])
print_regions(regions)
shrink_priority = [
item
for kind in scenario.shrink_order
for item in items
if item.kind == kind
]
# 3. Build constraints for both regions first. Shared icon pools can span
# regions, so truncation must make decisions with all paths visible at once.
constraints = []
for region in regions:
region_shrink_priority = [item for item in shrink_priority if item in region["items"]]
constraints.extend(collect_constraints(region, region_shrink_priority))
# 4. Run one shared truncation/allocation pass over both regions.
if constraints:
truncate(constraints, shrink_priority, items)
# 5. Restack the final truncated items into their selected positions.
for region in regions:
final_stack(region)
if scenario.cutout:
return items + [make_camera_item(scenario)]
return items
def make_test_items(scenario):
items = []
clock = LayoutItem.multi_box(
"Clock",
"Clock",
(120, 180, 255),
scenario.clock_pos,
[
Box(0, 0, 240, 40),
Box(80, -20, 30, 30),
],
xy=(0, scenario.clock_vertical),
)
clock.spacing = scenario.container_spacing
items.append(clock)
chip = LayoutItem.fixed(
"Chip",
"Chip",
(220, 140, 255),
scenario.chip_pos,
400,
40,
xy=(0, scenario.chip_vertical),
)
chip.spacing = scenario.container_spacing
items.append(chip)
for i in range(scenario.notifs_count):
items.append(LayoutItem.icon_strip(
f"Notifs{i}",
"Notifs",
(120, 255, 160),
repeated(scenario.notifs_pos, i),
"notifications",
scenario.notifs_icons,
scenario.notifs_icon_width,
xy=(0, repeated(scenario.notifs_vertical, i)),
icon_width=scenario.notifs_icon_width,
spacing=scenario.container_spacing,
))
status_widths = repeated_widths(scenario.status_icon_widths, scenario.status_icons)
for i in range(scenario.status_count):
items.append(LayoutItem.icon_strip(
f"Status{i}",
"Status",
(120, 255, 160),
repeated(scenario.status_pos, i),
"status",
scenario.status_icons,
scenario.status_icon_height,
xy=(0, repeated(scenario.status_vertical, i)),
icon_width=scenario.status_icon_height,
icon_widths=status_widths,
spacing=scenario.container_spacing,
))
for i in range(scenario.dummy_count):
dummy = LayoutItem.fixed(
f"Dummy{i}",
"Dummy",
(120, 255, 160),
repeated(scenario.dummy_pos, i),
100,
30,
xy=(0, repeated(scenario.dummy_vertical, i)),
)
dummy.spacing = scenario.container_spacing
items.append(dummy)
items = sort_items_by_kind_order(items, scenario.order)
configure_icon_groups(items)
return items
def repeated(values, index):
if not isinstance(values, tuple) or is_single_position(values):
return values
if len(values) == 0:
return None
return values[min(index, len(values) - 1)]
def is_single_position(value):
return (
isinstance(value, tuple)
and len(value) == 2
and value[0] == "middle"
and value[1] in ("left", "right")
)
def repeated_widths(values, count):
if count <= 0:
return []
widths = list(values)
if not widths:
widths = [40]
if len(widths) < count:
widths.extend([widths[-1]] * (count - len(widths)))
return widths[:count]
def configure_icon_groups(items):
groups = {}
for item in items:
if item.icon_group is not None:
groups.setdefault(item.icon_group, []).append(item)
for group_items in groups.values():
if not group_items:
continue
group_items[0].allow_zero = False
group_items[0].allow_dot = True
for item in group_items[1:]:
item.allow_zero = True
item.allow_dot = False
def make_regions(scenario):
if not scenario.cutout:
return [{"leftwall": 0, "rightwall": SCREEN_WIDTH, "items": []}]
camera_left = scenario.camera_x
camera_right = scenario.camera_x + scenario.camera_width
return [
{"leftwall": 0, "rightwall": camera_left, "items": []},
{"leftwall": camera_right, "rightwall": SCREEN_WIDTH, "items": []},
]
def make_camera_item(scenario):
return LayoutItem.fixed(
"Camera",
"Camera",
(0, 0, 0),
"camera",
scenario.camera_width,
SCREEN_HEIGHT,
xy=(scenario.camera_x, 0),
)
def collect_constraints(region, shrink_priority):
stackleft(region["items"], region["leftwall"])
paths = get_paths(region["items"], region["rightwall"])
if not paths:
return []
wall = region["rightwall"]
print("Does not fit!")
print(f" Wall is at {wall}")
path_constraints = []
best_path_by_constraint_key = {}
for path in paths:
shrinkables = unique_items(
box.item
for box in path.boxes
if box.item in shrink_priority
)
items_on_path = unique_items(box.item for box in path.boxes)
effective_right = path.get_right() - path.gaps
key = (
path.boxes[-1],
tuple(shrinkables),
tuple(items_on_path),
)
if key not in best_path_by_constraint_key:
best_path_by_constraint_key[key] = len(path_constraints)
path_constraints.append({
"path": path,
"end_box": path.boxes[-1],
"shrinkables": shrinkables,
"effective_right": effective_right,
"items_on_path": items_on_path,
})
continue
previous = path_constraints[best_path_by_constraint_key[key]]
if effective_right > previous["effective_right"]:
previous.update({
"path": path,
"effective_right": effective_right,
})
print(" Paths:")
for constraint in path_constraints:
print(f" {describe_path(constraint['path'])}")
# Convert unique overflowing paths into live constraints immediately. Most
# scenarios do not need relaxed walls, so avoid a second pass unless one is
# discovered.
final_constraints = []
constraints_by_end_box = {}
new_wall_by_end_box = {}
for path_constraint in path_constraints:
shrinkables = path_constraint["shrinkables"]
effective_right = path_constraint["effective_right"]
total_capacity = sum(truncation_phase_shrink_capacity(item) for item in shrinkables)
minimum_possible_right = effective_right - total_capacity
if minimum_possible_right > wall:
current = new_wall_by_end_box.get(path_constraint["end_box"], wall)
if minimum_possible_right > current:
new_wall_by_end_box[path_constraint["end_box"]] = minimum_possible_right
constraint = {
"end_box": path_constraint["end_box"],
"shrinkables": shrinkables,
"effective_right": effective_right,
"required": effective_right - wall,
"items_on_path": path_constraint["items_on_path"],
}
if constraint["required"] > 0:
final_constraints.append(constraint)
constraints_by_end_box.setdefault(path_constraint["end_box"], []).append(constraint)
print(" Constraint:")
print(f" end: {path_constraint['end_box']}")
print(f" shrinkables: {shrinkables}")
print(f" effective right: {effective_right}")
print(f" capacity: {total_capacity}")
# Relax only impossible paths. If this map is empty, final_constraints is
# already complete from the single pass above.
for end_box, relaxed_wall in new_wall_by_end_box.items():
for constraint in constraints_by_end_box.get(end_box, []):
constraint["required"] = constraint["effective_right"] - relaxed_wall
final_constraints = [
constraint
for constraint in final_constraints
if constraint["required"] > 0
]
for constraint in final_constraints:
print(" Final constraint:")
print(f" required: {constraint['required']}")
print(f" shrinkables: {constraint['shrinkables']}")
return final_constraints
def truncate(final_constraints, shrink_priority, all_items):
# The scenario order is an absorption order: earlier kinds are more willing
# to absorb shrinkage. Within a kind, lower-numbered copies are more
# important and are therefore processed before later copies.
#
# For each item, ask how much it must shrink if every still-unprocessed,
# more-willing item on the same path shrinks as far as it can. Already
# processed shrink is fixed and counted exactly.
# Icon groups are special: when one item in a shared pool is processed, immediately
# reallocate that pool so sibling containers stop reserving space for icons that
# have already been claimed by earlier containers.
processed = set()
execution_order = truncation_execution_order(shrink_priority)
absorption_order = truncation_absorption_order(shrink_priority)
absorption_index = {item: index for index, item in enumerate(absorption_order)}
kind_absorption_index = kind_order_index(shrink_priority)
icon_groups = grouped_icon_items(all_items)
remaining_icon_widths = {
group: list(group_items[0].icon_widths)
for group, group_items in icon_groups.items()
}
print(" Truncation order:")
print(f" shrink absorption order: {absorption_order}")
print(f" execution least-willing kind first, preserving copy order: {execution_order}")
for index, item in enumerate(execution_order):
print(f" Considering {item}:")
needed_for_item = 0
for constraint in final_constraints:
if constraint["required"] <= 0:
continue
shrinkables = constraint["shrinkables"]
if item not in shrinkables:
continue
assumed_absorbers = [
other
for other in shrinkables
if other not in processed
and other is not item
and can_absorb_for(item, other, kind_absorption_index, absorption_index)
]
assumed_absorber_capacity = sum(
contextual_shrink_capacity(item, other)
for other in assumed_absorbers
)
needed = (
constraint["required"]
- assumed_absorber_capacity
)
needed_for_item = max(needed_for_item, needed)
print(" Live constraint:")
print(f" required: {constraint['required']}")
print(f" shrinkables: {shrinkables}")
print(f" assumed absorbers: {assumed_absorbers}")
print(f" assumed absorber capacity: {assumed_absorber_capacity}")
print(f" needed from this item: {needed}")
amount = min(max(0, needed_for_item), contextual_shrink_capacity(item, item))
old_width = item.get_width()
if item.is_icon_item:
direct_actual = truncate_icon_item_without_dot(item, amount)
else:
direct_actual = item.truncate_at_least(amount)
actual = direct_actual
if direct_actual != 0:
adjust_constraints_for_item_width_delta(final_constraints, item, direct_actual)
if item.is_icon_item:
remaining_icon_widths[item.icon_group] = allocate_current_icon_item(
item,
icon_groups[item.icon_group],
remaining_icon_widths[item.icon_group],
constraints=final_constraints,
)
actual = old_width - item.get_width()
print(" Truncation:")
print(f" item: {item}")
print(f" requested: {amount}")
print(f" actual: {actual}")
print(f" final width: {item.get_width()}")
processed.add(item)
next_item = execution_order[index + 1] if index + 1 < len(execution_order) else None
if item.is_icon_item and (next_item is None or next_item.kind != item.kind):
finish_icon_group(
item.icon_group,
icon_groups[item.icon_group],
remaining_icon_widths[item.icon_group],
constraints=final_constraints,
)
def can_absorb_for(current, other, kind_absorption_index, absorption_index):
current_kind = kind_absorption_index.get(current.kind)
other_kind = kind_absorption_index.get(other.kind)
if current_kind is None or other_kind is None:
return False
if other_kind < current_kind:
return True
if other_kind > current_kind:
return False
return absorption_index[other] < absorption_index[current]
def contextual_shrink_capacity(current, other):
if current.icon_group is not None and current.icon_group == other.icon_group:
return other.get_width()
return other.can_shrink_by()
def truncation_phase_shrink_capacity(item):
if item.icon_group is not None:
return item.get_width()
return item.can_shrink_by()
def truncate_icon_item_without_dot(item, amount):
old_allow_zero = item.allow_zero
old_allow_dot = item.allow_dot
item.allow_zero = True
item.allow_dot = False
try:
return item.truncate_at_least(amount)
finally:
item.allow_zero = old_allow_zero
item.allow_dot = old_allow_dot
def truncation_execution_order(shrink_priority):
by_kind = {}
for item in shrink_priority:
by_kind.setdefault(item.kind, []).append(item)
result = []
for kind in reversed(list(by_kind.keys())):
result.extend(by_kind[kind])
return result
def truncation_absorption_order(shrink_priority):
by_kind = {}
for item in shrink_priority:
by_kind.setdefault(item.kind, []).append(item)
result = []
for items in by_kind.values():
result.extend(reversed(items))
return result
def grouped_icon_items(items):
grouped_items = {}
for item in items:
if item.icon_group is not None:
grouped_items.setdefault(item.icon_group, []).append(item)
for group_items in grouped_items.values():
group_items.sort(key=item_copy_index)
return grouped_items
def allocate_current_icon_item(item, group_items, remaining_widths, constraints):
print(f"Icon distribution while truncating {item}:")
print(f" Group {item.icon_group}:")
print(f" remaining before: {remaining_widths}")
old_widths = {group_item: group_item.get_width() for group_item in group_items}
set_icon_item_pool(item, remaining_widths)
remaining_after_item = remaining_widths[item.visible_icons:]
item_index = group_items.index(item)
for later_item in group_items[item_index + 1:]:
set_icon_item_pool(later_item, remaining_after_item)
for group_item in group_items:
delta = old_widths[group_item] - group_item.get_width()
print(f" Item {group_item}:")
print(f" available before: {group_item.pool_icon_widths}")
print(f" visible widths: {group_item.visible_icon_widths}")
print(f" dot: {group_item.dot}")
print(f" width: {old_widths[group_item]} -> {group_item.get_width()}")
print(f" remaining after item: {remaining_after_item}")
if delta != 0:
adjust_constraints_for_item_width_delta(constraints, group_item, delta)
return remaining_after_item
def finish_icon_group(group, group_items, remaining_widths, constraints):
print(f"Final icon distribution while finishing {group_items[0].kind}:")
print(f" Group {group}:")
print(f" remaining before dot: {remaining_widths}")
old_widths = {item: item.get_width() for item in group_items}
if remaining_widths:
growth_rooms = {
item: expansion_room_for_constraints(item, constraints)
for item in group_items
}
place_final_dot(group_items, growth_rooms)
for item in group_items:
delta = old_widths[item] - item.get_width()
print(f" Item {item}:")
print(f" available before: {item.pool_icon_widths}")
print(f" visible widths: {item.visible_icon_widths}")
print(f" dot: {item.dot}")
print(f" width: {old_widths[item]} -> {item.get_width()}")
print(f" remaining after group: {remaining_widths}")
if delta != 0:
adjust_constraints_for_item_width_delta(constraints, item, delta)
def set_icon_item_pool(item, widths):
old_allow_zero = item.allow_zero
old_allow_dot = item.allow_dot
item.allow_zero = True
item.allow_dot = False
try:
item.width_limit = item.get_width()
item.set_pool_icons(widths)
finally:
item.allow_zero = old_allow_zero
item.allow_dot = old_allow_dot
def place_final_dot(group_items, growth_rooms):
print(" Final dot placement:")
capacities = {
item: item.get_width() + growth_rooms.get(item, 0)
for item in group_items
}
while True:
last_icons = last_item_with_icons(group_items)
if last_icons is None:
last_icons = 0
for item in group_items[last_icons:]:
dot_width = dot_width_for_final_placement(group_items, item)
capacity = capacities[item]
visible_widths = list(item.visible_icon_widths)
print(f" trying {item}: capacity={capacity}, growth={growth_rooms.get(item, 0)}, own_limit={item.width_limit}, icons={visible_widths}, dot={dot_width}")
if sum(visible_widths) + dot_width <= capacity:
item.visible_icon_widths = visible_widths
item.visible_icons = len(visible_widths)
item.dot = True
item._apply_icon_width(sum(visible_widths) + dot_width)
item.width_limit = item.get_width()
print(f" placed dot in {item}")
return True
item = group_items[last_icons]
if not item.visible_icon_widths:
break
removed = item.visible_icon_widths.pop()
item.visible_icons = len(item.visible_icon_widths)
item._apply_icon_width(sum(item.visible_icon_widths))
item.width_limit = item.get_width()
print(f" sacrificed icon width {removed} from {item}")
print(" no item can fit the dot")
return False
def dot_width_for_final_placement(group_items, item):
dot_width = item._dot_width()
dot_side = item._dot_side()
index = group_items.index(item)
if dot_side == "right" and all(other.get_width() <= 0 for other in group_items[index + 1:]):
return min(dot_width, item.dot_width - item.dot_width / 3)
if dot_side == "left" and all(other.get_width() <= 0 for other in group_items[:index]):
return min(dot_width, item.dot_width - item.dot_width / 3)
return dot_width
def last_item_with_icons(group_items):
for index in range(len(group_items) - 1, -1, -1):
if group_items[index].visible_icons > 0:
return index
return None
def expansion_room_for_constraints(item, constraints):
relevant = [
constraint
for constraint in constraints
if item in constraint["items_on_path"]
]
if not relevant:
return float("inf")
return max(0, min(-constraint["required"] for constraint in relevant))
def adjust_constraints_for_item_width_delta(constraints, item, delta):
for constraint in constraints:
if item in constraint["items_on_path"]:
constraint["required"] -= delta
def live_constraints(constraints):
return [constraint for constraint in constraints if constraint["required"] > 0]
def item_copy_index(item):
suffix = ""
for char in reversed(item.name):
if not char.isdigit():
break
suffix = char + suffix
return int(suffix) if suffix else 0
def get_paths(items, wall):
boxes = [box for item in items for box in item.boxes]
paths = []
def can_start(box):
if box.collision_left:
return False
if len(box.item.boxes) > 1 and box.get_left() != box.item.get_left():
return False
return True
def can_end(box):
if box.collision_right:
return False
if box.get_right() <= wall:
return False
if len(box.item.boxes) > 1 and box.get_right() != box.item.get_right():
return False
return True
def can_item_hop(path, box):
return len(box.item.boxes) > 1 and sum(1 for b in path.boxes if b.item is box.item) < 2
def iter_next_boxes(path):
box = path.boxes[-1]
yield from box.collision_right
if can_item_hop(path, box):
for other in box.item.boxes:
if other is not box:
yield other
def dfs(path):
if can_end(path.boxes[-1]):
paths.append(path)
seen = set(path.boxes)
for next_box in iter_next_boxes(path):
if next_box in seen:
continue
dfs(path.added(next_box))
for box in boxes:
if can_start(box):
dfs(PathState([box]))
return paths
def describe_path(path):
return (
" -> ".join(str(box) for box in path.boxes)
+ f" | right={path.get_right()}"
+ f" | collapsible_gap={path.gaps}"
)
def final_stack(region):
stackleft([item for item in region["items"] if item.position == "left"], region["leftwall"])
stackright([item for item in region["items"] if item.position == "right"], region["rightwall"])
items = [item for item in region["items"] if item.position == "middle"]
middle = SCREEN_WIDTH / 2
for item in items:
print(f"Positioning {item} in the middle.")
item.set_left(middle - item.get_width() / 2)
shift_amount = item.collision_amount_left(items)
if shift_amount != 0:
print(f" It collides. Push it to the right by {shift_amount}")
item.shift_x(shift_amount)
collision_tree_items = [collision for collision in item.get_collisionleft_items() if collision in items]
left = min(collision.get_left() for collision in collision_tree_items)
right = item.get_right()
current_mid = (left + right) / 2
print(f" Then, push the whole left collision tree left by {current_mid - middle}")
for collision in collision_tree_items:
collision.shift_x(middle - current_mid)
for item in items:
print(f"Checking {item} for collisions left.")
amount_left = max(item.collision_amount_left(), region["leftwall"] - item.get_left())
if amount_left > 0:
print(f" It collides on the left. Push its right collision tree to the right by {amount_left}")
collision_tree_items = [collision for collision in item.get_collisionright_items() if collision in items]
for collision in collision_tree_items:
print(f" shifting {collision} by {amount_left}")
collision.shift_x(amount_left)
for item in reversed(items):
print(f"Checking {item} for collisions right.")
amount_right = max(item.collision_amount_right(), item.get_right() - region["rightwall"])
if amount_right > 0:
print(f" It collides on the right. Push its left collision tree to the left by {amount_right}")
collision_tree_items = [collision for collision in item.get_collisionleft_items() if collision in items]
for collision in collision_tree_items:
collision.shift_x(-amount_right)
def stackleft(items, against_wall):
for item in items:
item.set_left(against_wall)
item.shift_x(item.collision_amount_left())
def stackright(items, against_wall):
for item in reversed(items):
item.set_right(against_wall)
item.shift_x(-item.collision_amount_right())
def place_into_regions(regions, items):
left_region = 0
right_region = len(regions) - 1
forceleft = False
forceright = False
middle = SCREEN_WIDTH / 2
if middle < regions[left_region]["rightwall"]:
midregion = left_region
elif regions[right_region]["leftwall"] < middle:
midregion = right_region
else:
midregion = None
forceleft = True
forceright = True
for item in items:
if item.position == "middle" and item.fallback == "left":
print(f"Placing {item} in middle/left")
if forceleft:
print(" Force is in effect. Position it in the left region.")
regions[left_region]["items"].insert(0, item)
elif overlaps_cutout(item, middle, regions):
print(" Overlap with cutout. Force.")
regions[left_region]["items"].insert(0, item)
forceleft = True
else:
print(f" Middle in region {midregion}")
regions[midregion]["items"].insert(0, item)
elif item.position == "middle" and item.fallback == "right":
print(f"Placing {item} in middle/right")
if forceright:
print(" Force is in effect. Position it in the right region.")
regions[right_region]["items"].append(item)
elif overlaps_cutout(item, middle, regions):
print(" Overlap with cutout. Force.")
regions[right_region]["items"].append(item)
forceright = True
else:
print(f" Middle in region {midregion}")
regions[midregion]["items"].append(item)
for item in reversed(items):
if item.position == "left":
regions[left_region]["items"].insert(0, item)
elif item.position == "right":
regions[right_region]["items"].append(item)
def overlaps_cutout(item, middle, regions):
if len(regions) < 2:
return False
item_left = middle - item.get_width() / 2
item_right = middle + item.get_width() / 2
return max(item_left, regions[0]["rightwall"]) < min(item_right, regions[1]["leftwall"])
def collision_tree(items):
for item in items:
for box in item.boxes:
box.collision_left.clear()
box.collision_right.clear()
left = []
for item in items:
for candidate in reversed(left):
for box in item.boxes:
for candidate_box in candidate.boxes:
if v_overlap(box, candidate_box):
if box.is_in_collision_left_tree(candidate_box):
continue
box.collision_left.append(candidate_box)
candidate_box.collision_right.append(box)
left.append(item)
def v_overlap(a, b):
return max(a.get_top(), b.get_top()) < min(a.get_bottom(), b.get_bottom())
def sort_items_by_kind_order(items, order):
order_index = {group: i for i, group in enumerate(order)}
return sorted(
sorted(items, key=lambda item: item.name),
key=lambda item: order_index[item.kind],
)
def unique_items(items):
result = []
for item in items:
if item not in result:
result.append(item)
return result
def kind_order_index(items):
result = {}
for item in items:
if item.kind not in result:
result[item.kind] = len(result)
return result
def print_regions(regions):
print("We have regions:")
for region in regions:
print(f"Region: {region['leftwall']} - {region['rightwall']}")
for item in region["items"]:
print(f" Item: {item}")
print(f" Width: {item.get_width()}")
print(f" Height: {item.get_bottom() - item.get_top()}")
print(f" x-pos: {item.get_left()}")
print(f" y-pos: {item.get_top()}")
for box in item.boxes:
print(f" Box: {box}")
print(f" left-collision: {[c.item for c in box.collision_left]}")
print(f" right-collision: {[c.item for c in box.collision_right]}")
+159
View File
@@ -0,0 +1,159 @@
import tkinter as tk
from positions import *
TEXT_SIZE = 14
PADDING = 40
OVERLAP_OUTLINE_WIDTH = 4
SCREEN_WIDTH=1200
SCREEN_HEIGHT=160
VSCALE=2
def rgb_to_hex(rgb):
r, g, b = rgb
return f"#{r:02x}{g:02x}{b:02x}"
def rect_bounds(box):
return box.get_left(), box.get_top(), box.get_right(), box.get_bottom()
def intersection(a, b):
ax1, ay1, ax2, ay2 = rect_bounds(a)
bx1, by1, bx2, by2 = rect_bounds(b)
x1 = max(ax1, bx1)
y1 = max(ay1, by1)
x2 = min(ax2, bx2)
y2 = min(ay2, by2)
if x1 < x2 and y1 < y2:
return x1, y1, x2, y2
return None
def visualize_packing(items):
root = tk.Tk()
root.title("Rectangle Packing Visualizer")
canvas_width = SCREEN_WIDTH + PADDING * 2
canvas_height = SCREEN_HEIGHT*VSCALE + PADDING * 2
canvas = tk.Canvas(
root,
width=canvas_width,
height=canvas_height,
bg="black",
highlightthickness=0
)
canvas.pack()
ox = PADDING
oy = PADDING
# Outer perimeter
canvas.create_rectangle(
ox,
oy,
ox + SCREEN_WIDTH,
oy + SCREEN_HEIGHT*VSCALE,
fill="white",
outline="white"
)
boxes = [box for item in items for box in item.boxes]
# Draw boxes
for box in boxes:
x1 = ox + box.get_left()
y1 = oy + box.get_top() * VSCALE
x2 = ox + box.get_right()
y2 = oy + box.get_bottom() * VSCALE
canvas.create_rectangle(
x1,
y1,
x2,
y2,
fill=rgb_to_hex(box.item.color),
outline="black"
)
# Icon containers draw only vertical separators for simulated icon/dot slots.
if box.item.is_icon_item and box is box.item.boxes[0]:
cursor = box.get_left()
for kind, width in box.item.render_segments()[:-1]:
cursor += width
x = ox + cursor
canvas.create_line(
x,
y1,
x,
y2,
fill="black",
width=2,
)
# Draw overlap regions
for i in range(len(boxes)):
for j in range(i + 1, len(boxes)):
if boxes[i].item is boxes[j].item:
continue
overlap = intersection(boxes[i], boxes[j])
if overlap:
x1, y1, x2, y2 = overlap
y1 *= VSCALE
y2 *= VSCALE
x1 += ox
y1 += oy
x2 += ox
y2 += oy
# Red overlap area
canvas.create_rectangle(
x1,
y1,
x2,
y2,
fill="red",
outline="red"
)
# Thick red circle around overlap
cx = (x1 + x2) / 2
cy = (y1 + y2) / 2
radius = max(x2 - x1, y2 - y1) / 2 + 12
canvas.create_oval(
cx - radius,
cy - radius,
cx + radius,
cy + radius,
outline="red",
width=OVERLAP_OUTLINE_WIDTH
)
# Draw item texts
for item in items:
x1 = ox + item.get_left()
y1 = oy + item.get_top() * VSCALE
x2 = ox + item.get_right()
y2 = oy + item.get_bottom() * VSCALE
canvas.create_text(
(x1 + x2) / 2,
(y1 + y2) / 2,
text=item.rendername(),
font=("Arial", TEXT_SIZE),
fill="black"
)
root.mainloop()
if __name__ == "__main__":
containers = position_containers()
visualize_packing(containers)