From 9de9f2df7c00261727df41405a98f6fd5b8d828a Mon Sep 17 00:00:00 2001
From: Snowsune
Date: Sun, 10 May 2026 00:56:33 -0400
Subject: [PATCH 01/15] Revamp like ALL of the tanks system into something
per-user!
---
apps/tanks_manager/admin.py | 29 +-
apps/tanks_manager/migrations/0001_initial.py | 40 +-
.../migrations/0002_tanksite_schema.py | 202 ++++++++++
apps/tanks_manager/models.py | 65 +++-
.../static/tanks_manager/css/tank_page.css | 223 +++++++++++
.../templates/tanks_manager/edit.html | 146 +++-----
.../templates/tanks_manager/tank_show.html | 64 ++++
apps/tanks_manager/templatetags/__init__.py | 0
.../templatetags/tanks_manager_tags.py | 24 ++
apps/tanks_manager/urls.py | 5 +-
apps/tanks_manager/views.py | 346 ++++++++++--------
snowsune/settings.py | 3 +-
snowsune/templates/tools.html | 71 +++-
snowsune/views/tools.py | 19 +-
static/css/tools.css | 23 ++
15 files changed, 963 insertions(+), 297 deletions(-)
create mode 100644 apps/tanks_manager/migrations/0002_tanksite_schema.py
create mode 100644 apps/tanks_manager/static/tanks_manager/css/tank_page.css
create mode 100644 apps/tanks_manager/templates/tanks_manager/tank_show.html
create mode 100644 apps/tanks_manager/templatetags/__init__.py
create mode 100644 apps/tanks_manager/templatetags/tanks_manager_tags.py
diff --git a/apps/tanks_manager/admin.py b/apps/tanks_manager/admin.py
index bdb2959..161b4e6 100644
--- a/apps/tanks_manager/admin.py
+++ b/apps/tanks_manager/admin.py
@@ -1,13 +1,28 @@
from django.contrib import admin
-from .models import TankClone, TankLiquid, TankLog, TankSettings
+from .models import TankLiquid, TankLog, TankSite
-@admin.register(TankSettings)
-class TankSettingsAdmin(admin.ModelAdmin):
- list_display = ["id", "tank_top_offset", "tank_bottom_offset"]
+@admin.register(TankSite)
+class TankSiteAdmin(admin.ModelAdmin):
+ list_display = [
+ "slug",
+ "owner",
+ "character_name",
+ "tank_top_offset",
+ "tank_bottom_offset",
+ ]
+ search_fields = ["slug", "owner__username"]
+ raw_id_fields = ["owner"]
-admin.site.register(TankClone)
-admin.site.register(TankLiquid)
-admin.site.register(TankLog)
+@admin.register(TankLiquid)
+class TankLiquidAdmin(admin.ModelAdmin):
+ list_display = ["name", "tank_site", "volume", "sort_order"]
+ list_filter = ["tank_site"]
+
+
+@admin.register(TankLog)
+class TankLogAdmin(admin.ModelAdmin):
+ list_display = ["tank_site", "date", "text"]
+ list_filter = ["tank_site"]
diff --git a/apps/tanks_manager/migrations/0001_initial.py b/apps/tanks_manager/migrations/0001_initial.py
index a92bc6e..bc85f28 100644
--- a/apps/tanks_manager/migrations/0001_initial.py
+++ b/apps/tanks_manager/migrations/0001_initial.py
@@ -11,7 +11,15 @@ class Migration(migrations.Migration):
migrations.CreateModel(
name="TankSettings",
fields=[
- ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
+ (
+ "id",
+ models.BigAutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
("tank_top_offset", models.PositiveIntegerField(default=360)),
("tank_bottom_offset", models.PositiveIntegerField(default=101)),
],
@@ -22,7 +30,15 @@ class Migration(migrations.Migration):
migrations.CreateModel(
name="TankClone",
fields=[
- ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
+ (
+ "id",
+ models.BigAutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
("sort_order", models.PositiveIntegerField(default=0)),
("name", models.CharField(max_length=200)),
("banner", models.CharField(blank=True, max_length=200)),
@@ -52,7 +68,15 @@ class Migration(migrations.Migration):
migrations.CreateModel(
name="TankLiquid",
fields=[
- ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
+ (
+ "id",
+ models.BigAutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
("sort_order", models.PositiveIntegerField(default=0)),
("name", models.CharField(max_length=200)),
("volume", models.PositiveSmallIntegerField(default=0)),
@@ -76,7 +100,15 @@ class Migration(migrations.Migration):
migrations.CreateModel(
name="TankLog",
fields=[
- ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
+ (
+ "id",
+ models.BigAutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
("date", models.PositiveIntegerField(help_text="Unix timestamp")),
("text", models.TextField()),
],
diff --git a/apps/tanks_manager/migrations/0002_tanksite_schema.py b/apps/tanks_manager/migrations/0002_tanksite_schema.py
new file mode 100644
index 0000000..b814f4d
--- /dev/null
+++ b/apps/tanks_manager/migrations/0002_tanksite_schema.py
@@ -0,0 +1,202 @@
+import os
+
+from django.conf import settings
+from django.core.files import File
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+def forwards(apps, schema_editor):
+ TankSite = apps.get_model("tanks_manager", "TankSite")
+ TankSettings = apps.get_model("tanks_manager", "TankSettings")
+ TankLiquid = apps.get_model("tanks_manager", "TankLiquid")
+ TankLog = apps.get_model("tanks_manager", "TankLog")
+ CustomUser = apps.get_model("users", "CustomUser")
+
+ owner = (
+ CustomUser.objects.filter(username="vixi").first()
+ or CustomUser.objects.filter(is_superuser=True).first()
+ or CustomUser.objects.order_by("pk").first()
+ )
+ if not owner:
+ raise RuntimeError(
+ "tanks_manager 0002_tanksite_schema requires at least one users.CustomUser."
+ )
+
+ ts = TankSettings.objects.filter(pk=1).first()
+ top, bot = 360, 101
+ if ts:
+ top, bot = ts.tank_top_offset, ts.tank_bottom_offset
+
+ site = TankSite(
+ owner_id=owner.pk,
+ slug="vixi",
+ tank_top_offset=top,
+ tank_bottom_offset=bot,
+ character_name="",
+ character_url="",
+ )
+ site.save()
+
+ if ts and getattr(ts, "stage_background", None):
+ try:
+ with ts.stage_background.open("rb") as fh:
+ site.stage_background.save(
+ os.path.basename(ts.stage_background.name), File(fh), save=True
+ )
+ except OSError:
+ pass
+ if ts and getattr(ts, "stage_foreground", None):
+ try:
+ with ts.stage_foreground.open("rb") as fh:
+ site.stage_foreground.save(
+ os.path.basename(ts.stage_foreground.name), File(fh), save=True
+ )
+ except OSError:
+ pass
+
+ TankLiquid.objects.all().update(tank_site_id=site.pk)
+ TankLog.objects.all().update(tank_site_id=site.pk)
+
+
+def backwards(apps, schema_editor):
+ pass
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("tanks_manager", "0001_initial"),
+ ("users", "0004_customuser_email_verification_sent_at_and_more"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="TankSite",
+ fields=[
+ (
+ "id",
+ models.BigAutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ (
+ "slug",
+ models.SlugField(
+ db_index=True,
+ help_text="URL segment, e.g. vixi → /tanks/vixi/",
+ max_length=50,
+ unique=True,
+ ),
+ ),
+ ("tank_top_offset", models.PositiveIntegerField(default=360)),
+ ("tank_bottom_offset", models.PositiveIntegerField(default=101)),
+ (
+ "character_name",
+ models.CharField(
+ blank=True,
+ help_text="Shown as the main title on the public tank page when set.",
+ max_length=200,
+ ),
+ ),
+ (
+ "character_url",
+ models.CharField(
+ blank=True,
+ help_text="Optional; makes the title a link (e.g. profile or ref sheet).",
+ max_length=500,
+ ),
+ ),
+ (
+ "stage_background",
+ models.ImageField(
+ blank=True,
+ help_text="Optional; sample Alice tank art is used when empty.",
+ max_length=500,
+ null=True,
+ upload_to="tanks/stage/",
+ ),
+ ),
+ (
+ "stage_foreground",
+ models.ImageField(
+ blank=True,
+ help_text="Optional; sample overlay is used when empty.",
+ max_length=500,
+ null=True,
+ upload_to="tanks/stage/",
+ ),
+ ),
+ (
+ "owner",
+ models.OneToOneField(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="tank_site",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ ],
+ options={
+ "ordering": ["slug"],
+ },
+ ),
+ migrations.AddField(
+ model_name="tankliquid",
+ name="tank_site",
+ field=models.ForeignKey(
+ null=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="liquids",
+ to="tanks_manager.tanksite",
+ ),
+ ),
+ migrations.AddField(
+ model_name="tanklog",
+ name="tank_site",
+ field=models.ForeignKey(
+ null=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="logs",
+ to="tanks_manager.tanksite",
+ ),
+ ),
+ migrations.RunPython(forwards, backwards),
+ migrations.AlterField(
+ model_name="tankliquid",
+ name="tank_site",
+ field=models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="liquids",
+ to="tanks_manager.tanksite",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="tanklog",
+ name="tank_site",
+ field=models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="logs",
+ to="tanks_manager.tanksite",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="tanksite",
+ name="owner",
+ field=models.OneToOneField(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="tank_site",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ migrations.DeleteModel(
+ name="TankClone",
+ ),
+ migrations.DeleteModel(
+ name="TankSettings",
+ ),
+ ]
diff --git a/apps/tanks_manager/models.py b/apps/tanks_manager/models.py
index 32c1637..8fae9a9 100644
--- a/apps/tanks_manager/models.py
+++ b/apps/tanks_manager/models.py
@@ -1,33 +1,61 @@
+from django.conf import settings
from django.db import models
-class TankSettings(models.Model):
+class TankSite(models.Model):
+ """One public tank page per owner; URL is /tanks/<slug>/."""
+
+ owner = models.OneToOneField(
+ settings.AUTH_USER_MODEL,
+ on_delete=models.CASCADE,
+ related_name="tank_site",
+ )
+ slug = models.SlugField(
+ max_length=50,
+ unique=True,
+ db_index=True,
+ help_text="URL segment, e.g. vixi → /tanks/vixi/",
+ )
tank_top_offset = models.PositiveIntegerField(default=360)
tank_bottom_offset = models.PositiveIntegerField(default=101)
-
- class Meta:
- verbose_name_plural = "Tank settings"
-
-
-class TankClone(models.Model):
- sort_order = models.PositiveIntegerField(default=0)
- name = models.CharField(max_length=200)
- banner = models.CharField(max_length=200, blank=True)
- image = models.CharField(
+ character_name = models.CharField(
+ max_length=200,
+ blank=True,
+ help_text="Shown as the main title on the public tank page when set.",
+ )
+ character_url = models.CharField(
max_length=500,
blank=True,
- help_text="Relative path or URL if not using upload",
+ help_text="Optional; makes the title a link (e.g. profile or ref sheet).",
)
- image_file = models.ImageField(
- upload_to="tanks/clones/", blank=True, null=True, max_length=500
+ stage_background = models.ImageField(
+ upload_to="tanks/stage/",
+ blank=True,
+ null=True,
+ max_length=500,
+ help_text="Optional; sample Alice tank art is used when empty.",
+ )
+ stage_foreground = models.ImageField(
+ upload_to="tanks/stage/",
+ blank=True,
+ null=True,
+ max_length=500,
+ help_text="Optional; sample overlay is used when empty.",
)
- url = models.CharField(max_length=500, blank=True)
class Meta:
- ordering = ["sort_order", "id"]
+ ordering = ["slug"]
+
+ def __str__(self):
+ return f"/tanks/{self.slug}/ ({self.owner})"
class TankLiquid(models.Model):
+ tank_site = models.ForeignKey(
+ TankSite,
+ on_delete=models.CASCADE,
+ related_name="liquids",
+ )
sort_order = models.PositiveIntegerField(default=0)
name = models.CharField(max_length=200)
volume = models.PositiveSmallIntegerField(default=0)
@@ -43,6 +71,11 @@ class Meta:
class TankLog(models.Model):
+ tank_site = models.ForeignKey(
+ TankSite,
+ on_delete=models.CASCADE,
+ related_name="logs",
+ )
date = models.PositiveIntegerField(help_text="Unix timestamp")
text = models.TextField()
diff --git a/apps/tanks_manager/static/tanks_manager/css/tank_page.css b/apps/tanks_manager/static/tanks_manager/css/tank_page.css
new file mode 100644
index 0000000..3380060
--- /dev/null
+++ b/apps/tanks_manager/static/tanks_manager/css/tank_page.css
@@ -0,0 +1,223 @@
+/* Public tank page: logbook + stage + liquids */
+:root {
+ color-scheme: dark;
+}
+
+.tank-page {
+ box-sizing: border-box;
+ margin: 0;
+ background: #23232b;
+ font-family: "Inter", Arial, sans-serif;
+ min-height: 100vh;
+ color: #e6e6ff;
+}
+
+.tank-page *,
+.tank-page *::before,
+.tank-page *::after {
+ box-sizing: border-box;
+}
+
+.tank-page-inner {
+ padding: 32px;
+ display: flex;
+ flex-direction: column;
+ gap: 24px;
+}
+
+.tank-page-title {
+ text-align: center;
+ margin: 0;
+}
+
+.tank-page-title h1 {
+ margin: 0;
+ font-size: clamp(1.25rem, 3vw, 1.85rem);
+ font-weight: 700;
+ color: #e6e6ff;
+ line-height: 1.2;
+}
+
+.tank-page-title a {
+ color: inherit;
+ text-decoration: none;
+}
+
+.tank-page-title a:hover {
+ text-decoration: underline;
+ color: #aacfff;
+}
+
+.tank-layout {
+ display: flex;
+ gap: 24px;
+ width: 100%;
+ align-items: flex-start;
+}
+
+.tank-logbook {
+ width: 240px;
+ max-width: 30vw;
+ padding: 16px 12px;
+ background: rgba(30, 30, 40, 0.95);
+ border: 3px solid #000;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ max-height: 850px;
+ overflow-y: auto;
+}
+
+.tank-logbook ul {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.tank-stage {
+ position: relative;
+ flex: 1;
+ max-width: 1200px;
+ margin: 0 auto;
+ aspect-ratio: 1200 / 850;
+ display: flex;
+ justify-content: center;
+ align-items: stretch;
+ overflow: hidden;
+ background: #14141c;
+}
+
+.tank-stage img {
+ position: absolute;
+ inset: 0;
+ margin: auto;
+ height: 100%;
+ pointer-events: none;
+}
+
+.tank-stage .tank-bg {
+ z-index: 1;
+}
+
+.tank-stage .tank-fg {
+ z-index: 3;
+}
+
+.tank-liquid-layer {
+ position: absolute;
+ left: 50%;
+ width: 80%;
+ transform: translateX(-50%);
+ border-radius: 4px;
+ overflow: hidden;
+ z-index: 2;
+}
+
+.tank-liquid-img {
+ position: absolute;
+ top: 0;
+ right: 4%;
+ height: 100%;
+ width: auto;
+ max-width: 85%;
+ object-fit: contain;
+ object-position: top right;
+ pointer-events: none;
+}
+
+.tank-liquid-label {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ z-index: 1;
+ transform: translate(-50%, -50%);
+ padding: clamp(4px, 0.6vw, 8px) clamp(8px, 1vw, 14px);
+ background: rgba(0, 0, 0, 0.45);
+ color: #fff;
+ font-weight: 600;
+ font-size: clamp(10px, 1.4vw, 19px);
+ border-radius: 4px;
+ white-space: nowrap;
+ text-align: center;
+ text-decoration: none;
+}
+
+.tank-liquid-label:hover {
+ text-decoration: underline;
+}
+
+.tank-liquid-label.is-nudged {
+ left: 18%;
+ transform: translate(60%, -50%);
+}
+
+.tank-home-link {
+ width: 220px;
+ display: flex;
+ justify-content: flex-start;
+}
+
+.tank-home-link a {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 8px 16px;
+ border-radius: 8px;
+ background: rgba(0, 0, 0, 0.75);
+ border: 1px solid rgba(255, 255, 255, 0.2);
+ color: #fff;
+ text-decoration: none;
+ font-weight: 600;
+ transition: all 0.2s ease;
+}
+
+.tank-home-link a:hover {
+ background: rgba(255, 255, 255, 0.9);
+ color: #1f1f29;
+}
+
+li.tank-log-new {
+ font-weight: 700;
+ background: linear-gradient(90deg, #fff, #f6f6ff, #ececff, #fff);
+ -webkit-background-clip: text;
+ background-clip: text;
+ -webkit-text-fill-color: transparent;
+ filter: drop-shadow(0 0 3px #fff6);
+}
+
+.tank-footer {
+ font-size: 0.75rem;
+ text-align: center;
+ color: #b3b3c3;
+}
+
+.tank-footer a {
+ color: inherit;
+}
+
+@media (max-width: 900px) {
+ .tank-page-inner {
+ padding: 24px 16px;
+ }
+
+ .tank-layout {
+ flex-direction: column;
+ }
+
+ .tank-logbook,
+ .tank-home-link {
+ width: 100%;
+ max-width: none;
+ }
+
+ .tank-home-link {
+ justify-content: center;
+ }
+
+ .tank-stage {
+ width: 100%;
+ }
+}
diff --git a/apps/tanks_manager/templates/tanks_manager/edit.html b/apps/tanks_manager/templates/tanks_manager/edit.html
index 432f95c..c2d30d9 100644
--- a/apps/tanks_manager/templates/tanks_manager/edit.html
+++ b/apps/tanks_manager/templates/tanks_manager/edit.html
@@ -1,5 +1,6 @@
{% extends "base.html" %}
-{% block title %}Tanks data · Snowsune{% endblock %}
+{% load tanks_manager_tags %}
+{% block title %}Tank editor ({{ tank_site.slug }}) · Snowsune{% endblock %}
{% block extra_css %}
+
{% endblock %}
{% block content %}
Tank editor · /tanks/{{ tank_site.slug }}/
+← All my vore pages
Cumtanks! A lot of people mentioned that they wanted to be able to add their own/upload their own art
so.. tada!
@@ -62,9 +18,6 @@
Tank editor · /tanks/{{ tank_site.slug }}/
And if you have any questions, ask me on discord or email ofc vixi@snowsune.net
-{% if err %}{{ err }}
{% endif %}
-{% if messages %}{% for m in messages %}- {{ m }}
{% endfor %}
{% endif %}
-
+
+Delete page
+Remove this page. I can't really recover it if you do :
+
+
+
-
-
-
diff --git a/templates/includes/django_messages.html b/templates/includes/django_messages.html
new file mode 100644
index 0000000..bd06f1c
--- /dev/null
+++ b/templates/includes/django_messages.html
@@ -0,0 +1,10 @@
+{% if messages %}
+{# Consumed by base.html → notifications.js as toasts; kept off-screen until then #}
+
+ {% for message in messages %}
+
+ {{ message }}
+
+ {% endfor %}
+
+{% endif %}
From e026964d555d029c8b7f05070d90774aa7c4a91f Mon Sep 17 00:00:00 2001
From: Snowsune
Date: Tue, 26 May 2026 15:11:57 -0400
Subject: [PATCH 12/15] Better auto analysis on uploading new tanks image
---
.../migrations/0002_alter_custompage_path.py | 22 +++
apps/tanks_manager/foreground_labels.py | 186 ++++++++++++++++++
.../0004_tanksite_stage_fg_label_profile.py | 20 ++
...5_alter_tanksite_stage_fg_label_profile.py | 22 +++
apps/tanks_manager/models.py | 5 +
.../static/tanks_manager/css/tank_page.css | 5 +-
.../templates/tanks_manager/edit.html | 1 +
.../templates/tanks_manager/tank_show.html | 4 +-
apps/tanks_manager/tests.py | 65 +++++-
apps/tanks_manager/views.py | 49 ++++-
media/tanks/stage/background.png | Bin 0 -> 402046 bytes
media/tanks/stage/foreground.png | Bin 0 -> 122482 bytes
media/tanks/stage/foreground_BcbZ3B2.png | Bin 0 -> 122482 bytes
media/tanks/stage/foreground_QtEX0bB.png | Bin 0 -> 122482 bytes
14 files changed, 368 insertions(+), 11 deletions(-)
create mode 100644 apps/custompages/migrations/0002_alter_custompage_path.py
create mode 100644 apps/tanks_manager/foreground_labels.py
create mode 100644 apps/tanks_manager/migrations/0004_tanksite_stage_fg_label_profile.py
create mode 100644 apps/tanks_manager/migrations/0005_alter_tanksite_stage_fg_label_profile.py
create mode 100644 media/tanks/stage/background.png
create mode 100644 media/tanks/stage/foreground.png
create mode 100644 media/tanks/stage/foreground_BcbZ3B2.png
create mode 100644 media/tanks/stage/foreground_QtEX0bB.png
diff --git a/apps/custompages/migrations/0002_alter_custompage_path.py b/apps/custompages/migrations/0002_alter_custompage_path.py
new file mode 100644
index 0000000..c596d04
--- /dev/null
+++ b/apps/custompages/migrations/0002_alter_custompage_path.py
@@ -0,0 +1,22 @@
+# Generated by Django 5.2.11 on 2026-05-26 19:10
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("custompages", "0001_initial"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="custompage",
+ name="path",
+ field=models.SlugField(
+ help_text="URL path for this page (e.g., 'my-page' will be accessible at /cust/my-page)",
+ max_length=200,
+ unique=True,
+ ),
+ ),
+ ]
diff --git a/apps/tanks_manager/foreground_labels.py b/apps/tanks_manager/foreground_labels.py
new file mode 100644
index 0000000..9f5c587
--- /dev/null
+++ b/apps/tanks_manager/foreground_labels.py
@@ -0,0 +1,186 @@
+"""
+Tool for figuring out the overlay stuff!
+"""
+
+from __future__ import annotations
+
+from typing import BinaryIO, List, Tuple
+
+from PIL import Image, ImageOps
+
+# Image width, height
+STAGE_W = 1200
+STAGE_H = 850
+
+# Pixels with alpha below this count as transparent
+ALPHA_THRESHOLD = 48
+
+# Vertical sampling step (rows)
+ROW_STEP = 4
+
+# Rows qualify as interior if the widest transparent run is at least this wide
+MIN_WIDE_RUN_RATIO = 0.18
+MIN_WIDE_RUN_PX = 120
+
+# Ignore auto-bounds when the overall vertical extent (top→bottom) is too small.
+MIN_TANK_HEIGHT_ROWS = 64
+
+
+def _lanczos():
+ try:
+ return Image.Resampling.LANCZOS
+ except AttributeError:
+ return Image.LANCZOS
+
+
+def _widest_transparent_run_bounds(pixels, y: int, width: int) -> tuple[int, int] | None:
+ runs: list[tuple[int, int]] = []
+ start: int | None = None
+ for x in range(width):
+ pix = pixels[x, y]
+ a = pix[3] if len(pix) >= 4 else 255
+ if a < ALPHA_THRESHOLD:
+ if start is None:
+ start = x
+ elif start is not None:
+ runs.append((start, x))
+ start = None
+ if start is not None:
+ runs.append((start, width))
+ if not runs:
+ return None
+ return max(runs, key=lambda r: r[1] - r[0])
+
+
+def _widest_transparent_run_center_x(pixels, y: int, width: int) -> float | None:
+ """Center x (0..width) of the widest contiguous transparent run on this row."""
+ b = _widest_transparent_run_bounds(pixels, y, width)
+ if b is None:
+ return None
+ lo, hi = b
+ return (lo + hi) / 2.0
+
+
+def _widest_transparent_run_width(pixels, y: int, width: int) -> int:
+ b = _widest_transparent_run_bounds(pixels, y, width)
+ if b is None:
+ return 0
+ return b[1] - b[0]
+
+
+def _fit_stage_foreground_rgba(file_obj: BinaryIO) -> Image.Image:
+ file_obj.seek(0)
+ img = Image.open(file_obj)
+ if getattr(img, "n_frames", 1) > 1:
+ img.seek(0)
+ img = img.convert("RGBA")
+ return ImageOps.fit(img, (STAGE_W, STAGE_H), method=_lanczos())
+
+
+def _row_qualifies_for_tank_band(pixels, y: int) -> bool:
+ need = max(MIN_WIDE_RUN_PX, int(STAGE_W * MIN_WIDE_RUN_RATIO))
+ return _widest_transparent_run_width(pixels, y, STAGE_W) >= need
+
+
+def detect_tank_vertical_offsets_from_pixels(pixels) -> Tuple[int, int] | None:
+ """
+ Infer tank_top_offset and tank_bottom_offset (pixels in STAGE_H space).
+
+ Rows qualify when they have a wide transparent run (same threshold as label holes).
+ Uses the overall vertical extent: topmost qualifying row through bottommost qualifying
+ row, so multiple separate alpha zones still yield one tank span.
+ """
+ ymin: int | None = None
+ ymax: int | None = None
+ for y in range(STAGE_H):
+ if not _row_qualifies_for_tank_band(pixels, y):
+ continue
+ if ymin is None:
+ ymin = ymax = y
+ else:
+ ymin = min(ymin, y)
+ ymax = max(ymax, y)
+
+ if ymin is None or ymax is None:
+ return None
+
+ extent = ymax - ymin + 1
+ if extent < MIN_TANK_HEIGHT_ROWS:
+ return None
+
+ tank_top = ymin
+ tank_bottom = STAGE_H - (ymax + 1)
+ if tank_top + tank_bottom >= STAGE_H:
+ return None
+ return (tank_top, tank_bottom)
+
+
+def _build_label_profile_from_pixels(pixels) -> List[List[float]]:
+ samples: List[List[float]] = []
+ for y in range(0, STAGE_H, ROW_STEP):
+ cx = _widest_transparent_run_center_x(pixels, y, STAGE_W)
+ if cx is None:
+ x_pct = 50.0
+ else:
+ x_pct = (cx / STAGE_W) * 100.0
+ y_pct = (y / STAGE_H) * 100.0 if STAGE_H else 0.0
+ samples.append([round(y_pct, 4), round(x_pct, 4)])
+ return samples
+
+
+def analyze_stage_foreground(
+ file_obj: BinaryIO,
+) -> tuple[List[List[float]], Tuple[int, int] | None]:
+ """
+ Fit image to stage size (object-fit: cover), then compute label profile and optional
+ tank vertical margins from transparency.
+ """
+ fitted = _fit_stage_foreground_rgba(file_obj)
+ pixels = fitted.load()
+ profile = _build_label_profile_from_pixels(pixels)
+ margins = detect_tank_vertical_offsets_from_pixels(pixels)
+ return profile, margins
+
+
+def compute_foreground_label_profile(file_obj: BinaryIO) -> List[List[float]]:
+ """
+ Build [[y_pct_from_top, x_pct_from_left], ...] for the stage coordinate system.
+
+ The image is fitted to STAGE_W×STAGE_H the same way CSS object-fit: cover does
+ (center crop), then each sampled row picks the horizontal center of the widest
+ transparent segment so labels can sit in visible “holes” in the overlay.
+ """
+ profile, _ = analyze_stage_foreground(file_obj)
+ return profile
+
+
+def interpolate_stage_x_pct(samples: List[List[float]], y_pct: float) -> float:
+ """Linear interpolation of stage X% (0=left, 100=right) at vertical position y_pct (from top)."""
+ if not samples:
+ return 50.0
+ if y_pct <= samples[0][0]:
+ return samples[0][1]
+ if y_pct >= samples[-1][0]:
+ return samples[-1][1]
+ for i in range(len(samples) - 1):
+ y0, x0 = samples[i]
+ y1, x1 = samples[i + 1]
+ if y0 <= y_pct <= y1:
+ if y1 == y0:
+ return x0
+ t = (y_pct - y0) / (y1 - y0)
+ return x0 + t * (x1 - x0)
+ return 50.0
+
+
+def band_anchor_stage_x_pct(samples: List[List[float]], y_top_lo: float, y_top_hi: float) -> float:
+ """Average interpolated X at band top, middle, and bottom for stability."""
+ if y_top_hi < y_top_lo:
+ y_top_lo, y_top_hi = y_top_hi, y_top_lo
+ mid = (y_top_lo + y_top_hi) / 2.0
+ xs = [
+ interpolate_stage_x_pct(samples, y_top_lo),
+ interpolate_stage_x_pct(samples, mid),
+ interpolate_stage_x_pct(samples, y_top_hi),
+ ]
+ return sum(xs) / len(xs)
diff --git a/apps/tanks_manager/migrations/0004_tanksite_stage_fg_label_profile.py b/apps/tanks_manager/migrations/0004_tanksite_stage_fg_label_profile.py
new file mode 100644
index 0000000..9043573
--- /dev/null
+++ b/apps/tanks_manager/migrations/0004_tanksite_stage_fg_label_profile.py
@@ -0,0 +1,20 @@
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("tanks_manager", "0003_alter_tanksite_owner_fk"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="tanksite",
+ name="stage_fg_label_profile",
+ field=models.JSONField(
+ blank=True,
+ default=list,
+ help_text="Cached [[y_pct_from_top, x_pct], ...] from transparent scanlines; rebuilt on FG upload.",
+ ),
+ ),
+ ]
diff --git a/apps/tanks_manager/migrations/0005_alter_tanksite_stage_fg_label_profile.py b/apps/tanks_manager/migrations/0005_alter_tanksite_stage_fg_label_profile.py
new file mode 100644
index 0000000..a0aa3f9
--- /dev/null
+++ b/apps/tanks_manager/migrations/0005_alter_tanksite_stage_fg_label_profile.py
@@ -0,0 +1,22 @@
+# Generated by Django 5.2.11 on 2026-05-26 19:10
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("tanks_manager", "0004_tanksite_stage_fg_label_profile"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="tanksite",
+ name="stage_fg_label_profile",
+ field=models.JSONField(
+ blank=True,
+ default=list,
+ help_text="Cached [[y_pct_from_top, x_pct], …] from transparent scanlines; rebuilt on FG upload.",
+ ),
+ ),
+ ]
diff --git a/apps/tanks_manager/models.py b/apps/tanks_manager/models.py
index 0779fa2..dea1427 100644
--- a/apps/tanks_manager/models.py
+++ b/apps/tanks_manager/models.py
@@ -42,6 +42,11 @@ class TankSite(models.Model):
max_length=500,
help_text="Optional; sample overlay is used when empty.",
)
+ stage_fg_label_profile = models.JSONField(
+ default=list,
+ blank=True,
+ help_text="Cached [[y_pct_from_top, x_pct], …] from transparent scanlines; rebuilt on FG upload.",
+ )
class Meta:
ordering = ["slug"]
diff --git a/apps/tanks_manager/static/tanks_manager/css/tank_page.css b/apps/tanks_manager/static/tanks_manager/css/tank_page.css
index 6161f16..345f06d 100644
--- a/apps/tanks_manager/static/tanks_manager/css/tank_page.css
+++ b/apps/tanks_manager/static/tanks_manager/css/tank_page.css
@@ -116,9 +116,8 @@
.tank-liquid-layer {
position: absolute;
- left: 50%;
- width: 80%;
- transform: translateX(-50%);
+ left: 0;
+ width: 100%;
border-radius: 4px;
overflow: hidden;
z-index: 2;
diff --git a/apps/tanks_manager/templates/tanks_manager/edit.html b/apps/tanks_manager/templates/tanks_manager/edit.html
index 538aa8c..ba8b812 100644
--- a/apps/tanks_manager/templates/tanks_manager/edit.html
+++ b/apps/tanks_manager/templates/tanks_manager/edit.html
@@ -26,6 +26,7 @@ Settings
+ Uploading a custom foreground will reset these settings! Tweak if needed afterwards!
{% endfor %}
diff --git a/apps/tanks_manager/tests.py b/apps/tanks_manager/tests.py
index 7ce503c..7ecf2ac 100644
--- a/apps/tanks_manager/tests.py
+++ b/apps/tanks_manager/tests.py
@@ -1,3 +1,66 @@
+from io import BytesIO
+
from django.test import TestCase
+from PIL import Image
+
+from apps.tanks_manager.foreground_labels import (
+ STAGE_H,
+ STAGE_W,
+ analyze_stage_foreground,
+ compute_foreground_label_profile,
+ interpolate_stage_x_pct,
+)
+
+
+class ForegroundLabelProfileTests(TestCase):
+ def test_interpolate_brackets(self):
+ samples = [[0.0, 10.0], [50.0, 60.0], [100.0, 90.0]]
+ self.assertAlmostEqual(interpolate_stage_x_pct(samples, 0), 10.0)
+ self.assertAlmostEqual(interpolate_stage_x_pct(samples, 100), 90.0)
+ self.assertAlmostEqual(interpolate_stage_x_pct(samples, 25), 35.0)
+
+ def test_compute_finds_vertical_transparent_gap(self):
+ img = Image.new("RGBA", (400, 400), (255, 0, 0, 255))
+ for y in range(400):
+ for x in range(160, 240):
+ img.putpixel((x, y), (0, 0, 0, 0))
+ buf = BytesIO()
+ img.save(buf, format="PNG")
+ buf.seek(0)
+ profile = compute_foreground_label_profile(buf)
+ self.assertGreater(len(profile), 8)
+ mid_x = interpolate_stage_x_pct(profile, 50.0)
+ self.assertGreater(mid_x, 35.0)
+ self.assertLess(mid_x, 65.0)
+
+ def test_analyze_detects_tank_vertical_band(self):
+ img = Image.new("RGBA", (STAGE_W, STAGE_H), (10, 10, 10, 255))
+ for y in range(200, 650):
+ for x in range(STAGE_W):
+ img.putpixel((x, y), (0, 0, 0, 0))
+ buf = BytesIO()
+ img.save(buf, format="PNG")
+ buf.seek(0)
+ profile, margins = analyze_stage_foreground(buf)
+ self.assertIsNotNone(margins)
+ top, bot = margins
+ self.assertEqual(top, 200)
+ self.assertEqual(bot, STAGE_H - 650)
+ self.assertGreater(len(profile), 8)
-# Create your tests here.
+ def test_analyze_extent_covers_two_separate_alpha_zones(self):
+ img = Image.new("RGBA", (STAGE_W, STAGE_H), (10, 10, 10, 255))
+ for y in range(100, 151):
+ for x in range(STAGE_W):
+ img.putpixel((x, y), (0, 0, 0, 0))
+ for y in range(500, 601):
+ for x in range(STAGE_W):
+ img.putpixel((x, y), (0, 0, 0, 0))
+ buf = BytesIO()
+ img.save(buf, format="PNG")
+ buf.seek(0)
+ _profile, margins = analyze_stage_foreground(buf)
+ self.assertIsNotNone(margins)
+ top, bot = margins
+ self.assertEqual(top, 100)
+ self.assertEqual(bot, STAGE_H - 601)
diff --git a/apps/tanks_manager/views.py b/apps/tanks_manager/views.py
index 1351485..67cccc9 100644
--- a/apps/tanks_manager/views.py
+++ b/apps/tanks_manager/views.py
@@ -13,6 +13,7 @@
from django.utils.text import slugify
from django.views.decorators.http import require_GET, require_POST
+from .foreground_labels import analyze_stage_foreground, band_anchor_stage_x_pct
from .models import TankLiquid, TankLog, TankSite, tanks_for_user
# Stage layout: offsets are for an 850px-tall design box (see tank_page.css aspect-ratio).
@@ -36,8 +37,9 @@ def _stage_art_urls(site, request):
return bg, fg
-def _liquid_layer_rows(liquids, tank_top, tank_bottom, request):
+def _liquid_layer_rows(liquids, tank_top, tank_bottom, request, fg_label_profile=None):
"""Bottom + height as % of design stage height."""
+ fg_label_profile = fg_label_profile or []
design = _DESIGN_STAGE_HEIGHT
tank_h = max(0, design - tank_top - tank_bottom)
offset_px = 0.0
@@ -63,16 +65,26 @@ def _liquid_layer_rows(liquids, tank_top, tank_bottom, request):
img = request.build_absolute_uri(f"/{raw.lstrip('/')}")
else:
img = ""
+ bottom_pct = (bottom_px / design) * 100
+ height_pct = (h_px / design) * 100
+ label_fg_left_pct = None
+ if fg_label_profile:
+ y_top_lo = 100.0 - bottom_pct - height_pct
+ y_top_hi = 100.0 - bottom_pct
+ sx = band_anchor_stage_x_pct(fg_label_profile, y_top_lo, y_top_hi)
+ label_fg_left_pct = max(0.0, min(100.0, sx))
+
rows.append(
{
- "bottom_pct": (bottom_px / design) * 100,
- "height_pct": (h_px / design) * 100,
+ "bottom_pct": bottom_pct,
+ "height_pct": height_pct,
"color": li.color or "#ffffff",
"image": img,
"name": li.name,
"volume": vol,
"url": li.url.strip(),
"label_nudge": cumulative_vol < 20,
+ "label_fg_left_pct": label_fg_left_pct,
}
)
offset_px += h_px
@@ -225,7 +237,11 @@ def tank_show(request, slug):
"stage_background_url": bg_url,
"stage_foreground_url": fg_url,
"liquid_layers": _liquid_layer_rows(
- liquids, site.tank_top_offset, site.tank_bottom_offset, request
+ liquids,
+ site.tank_top_offset,
+ site.tank_bottom_offset,
+ request,
+ fg_label_profile=site.stage_fg_label_profile or [],
),
"logs": _logs_for_show(logs),
},
@@ -353,6 +369,27 @@ def _can_edit_tank(user, site):
return user.is_staff or (user.is_authenticated and user.pk == site.owner_id)
+def _refresh_stage_foreground_analysis(site):
+ """Rebuild label profile + tank top/bottom offsets from overlay transparency."""
+ if not site.stage_foreground:
+ site.stage_fg_label_profile = []
+ site.save(update_fields=["stage_fg_label_profile"])
+ return
+ try:
+ with site.stage_foreground.open("rb") as fh:
+ profile, margins = analyze_stage_foreground(fh)
+ except Exception:
+ profile, margins = [], None
+ site.stage_fg_label_profile = profile
+ update_fields = ["stage_fg_label_profile"]
+ if margins is not None:
+ top, bot = margins
+ site.tank_top_offset = top
+ site.tank_bottom_offset = bot
+ update_fields.extend(["tank_top_offset", "tank_bottom_offset"])
+ site.save(update_fields=update_fields)
+
+
@login_required
@require_POST
def delete_my_tank(request, slug):
@@ -387,7 +424,8 @@ def edit(request, slug):
if site.stage_foreground:
site.stage_foreground.delete(save=False)
site.stage_foreground = None
- site.save(update_fields=["stage_foreground"])
+ site.stage_fg_label_profile = []
+ site.save(update_fields=["stage_foreground", "stage_fg_label_profile"])
site = get_object_or_404(TankSite, slug=slug)
if f := request.FILES.get("stage_bg"):
if getattr(f, "name", ""):
@@ -395,6 +433,7 @@ def edit(request, slug):
if f := request.FILES.get("stage_fg"):
if getattr(f, "name", ""):
site.stage_foreground.save(f.name, f, save=True)
+ _refresh_stage_foreground_analysis(site)
messages.success(request, "Saved.")
return redirect("tanks_manager:edit", slug=site.slug)
except Exception as e:
diff --git a/media/tanks/stage/background.png b/media/tanks/stage/background.png
new file mode 100644
index 0000000000000000000000000000000000000000..a0827f8c97fbcabdecf13a29dc0630aae9f73170
GIT binary patch
literal 402046
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