diff --git a/python/layout_item.py b/python/layout_item.py new file mode 100644 index 0000000..09800e5 --- /dev/null +++ b/python/layout_item.py @@ -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 diff --git a/python/positions.py b/python/positions.py new file mode 100644 index 0000000..d3f13c1 --- /dev/null +++ b/python/positions.py @@ -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]}") diff --git a/python/render.py b/python/render.py new file mode 100755 index 0000000..17267a1 --- /dev/null +++ b/python/render.py @@ -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)