feat(inventory): multi-colour gradients, transparency, visual effects (#1154)

Spool and color_catalog rows carry extra_colors (comma-separated hex
  stops) and effect_type (14 visual variants: surface effects, sheen,
  structural). The shared FilamentSwatch component renders gradient,
  conic, effect overlay, and alpha-checkerboard consistently across the
  inventory grid, table, group banner, card, ColorSection preview, and
  catalog editor. Catalog hex_color accepts #RRGGBBAA so catalog entries
  can carry transparency too.

  The paste field accepts the exact format 3dfilamentprofiles.com puts on
  its filament details pages, so users can copy a multi-colour combo
  directly. The effect dropdown spans the full filament-variant
  vocabulary -- surface effects (sparkle/wood/marble/glow/matte), sheen
  variants (silk/galaxy/rainbow/metal/translucent), and structural
  variants (gradient/dual-color/tri-color/multicolor). None of these
  fields touch MQTT/firmware -- pure visual hint.

  Spool group-key extended to include extra_colors + effect_type so
  "Group similar" no longer collapses visually distinct spools.

  Migrations: 4 idempotent ALTER TABLE ADD COLUMN (Postgres-safe), plus
  ALTER COLUMN hex_color TYPE VARCHAR(9) on Postgres only (SQLite ignores
  VARCHAR length).

  Tests: 42 new backend (35 unit + 7 integration), 20 new frontend (14
  FilamentSwatch + 3 ColorCatalogSettings + 3 InventoryPageGrouping
  regression). 3522 backend + 1582 frontend tests pass; ruff clean.
  Localised across all 8 UI locales.
This commit is contained in:
maziggy
2026-04-29 09:13:33 +02:00
parent 57af8a1c19
commit a34beaa599
34 changed files with 2054 additions and 491 deletions
+1
View File
File diff suppressed because one or more lines are too long
+1
View File
@@ -217,6 +217,7 @@ Perfect for remote print farms, traveling makers, or accessing your home printer
- **Per-spool cost tracking** — Set cost/kg on each spool; costs are automatically calculated at print completion and aggregated to archives. Print modal shows real-time cost preview. Configurable default cost and currency in Settings.
- **Bulk spool addition** — Add multiple identical spools at once (quantity 1–100) with a single form submission. Quick Add mode for stock spools that only need material, color, and weight.
- Spool catalog, color catalog, PA profile matching, and low-stock alerts
- **Multi-colour gradients, transparency, and visual effects** — Paste a comma-separated hex list (e.g. from 3dfilamentprofiles.com) to render a spool as a gradient or conic colour wheel; transparency shows through a checkerboard so the alpha you set is the alpha you see; pick a visual effect (sparkle, wood, marble, glow, matte) for the swatch overlay. Same fields are editable on the colour catalog so combos can be reused across spools.
### 🔧 Integrations
- [Spoolman](https://github.com/Donkie/Spoolman) filament sync with per-filament usage tracking and fill level display
+38 -3
View File
@@ -4,7 +4,7 @@ import logging
import httpx
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from pydantic import BaseModel, Field, field_validator
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
@@ -30,6 +30,8 @@ from backend.app.schemas.spool import (
SpoolKProfileResponse,
SpoolResponse,
SpoolUpdate,
normalize_effect_type,
normalize_extra_colors,
)
from backend.app.schemas.spool_usage import SpoolUsageHistoryResponse
from backend.app.utils.filament_ids import filament_id_to_setting_id, normalize_slicer_filament
@@ -95,23 +97,52 @@ class ColorEntryResponse(BaseModel):
hex_color: str
material: str | None
is_default: bool
extra_colors: str | None = None
effect_type: str | None = None
class Config:
from_attributes = True
_HEX_COLOR_PATTERN = r"^#[0-9A-Fa-f]{6}([0-9A-Fa-f]{2})?$"
class ColorEntryCreate(BaseModel):
manufacturer: str
color_name: str
hex_color: str
hex_color: str = Field(..., pattern=_HEX_COLOR_PATTERN)
material: str | None = None
extra_colors: str | None = None
effect_type: str | None = None
@field_validator("extra_colors")
@classmethod
def _validate_extra_colors(cls, v: str | None) -> str | None:
return normalize_extra_colors(v)
@field_validator("effect_type")
@classmethod
def _validate_effect_type(cls, v: str | None) -> str | None:
return normalize_effect_type(v)
class ColorEntryUpdate(BaseModel):
manufacturer: str
color_name: str
hex_color: str
hex_color: str = Field(..., pattern=_HEX_COLOR_PATTERN)
material: str | None = None
extra_colors: str | None = None
effect_type: str | None = None
@field_validator("extra_colors")
@classmethod
def _validate_extra_colors(cls, v: str | None) -> str | None:
return normalize_extra_colors(v)
@field_validator("effect_type")
@classmethod
def _validate_effect_type(cls, v: str | None) -> str | None:
return normalize_effect_type(v)
class ColorLookupResult(BaseModel):
@@ -286,6 +317,8 @@ async def add_color_entry(
hex_color=entry.hex_color,
material=entry.material,
is_default=False,
extra_colors=entry.extra_colors,
effect_type=entry.effect_type,
)
db.add(row)
await db.commit()
@@ -309,6 +342,8 @@ async def update_color_entry(
row.color_name = entry.color_name
row.hex_color = entry.hex_color
row.material = entry.material
row.extra_colors = entry.extra_colors
row.effect_type = entry.effect_type
await db.commit()
await db.refresh(row)
return row
+17
View File
@@ -647,6 +647,15 @@ async def run_migrations(conn):
await _safe_execute(conn, "ALTER TABLE projects ADD COLUMN url VARCHAR(2048)")
await _safe_execute(conn, "ALTER TABLE projects ADD COLUMN cover_image_filename VARCHAR(255)")
# Migration: enhanced filament colour handling on color_catalog (#1154).
# Mirrors the Spool columns added below; widens hex_color to VARCHAR(9)
# so catalog entries can store an alpha component (#RRGGBBAA). SQLite
# ignores VARCHAR length, so the widen only matters on PostgreSQL.
await _safe_execute(conn, "ALTER TABLE color_catalog ADD COLUMN extra_colors VARCHAR(255)")
await _safe_execute(conn, "ALTER TABLE color_catalog ADD COLUMN effect_type VARCHAR(20)")
if not is_sqlite():
await _safe_execute(conn, "ALTER TABLE color_catalog ALTER COLUMN hex_color TYPE VARCHAR(9)")
# Migration: Make printer_id nullable in print_queue for unassigned queue items
# SQLite doesn't support ALTER COLUMN, so we need to recreate the table
# PostgreSQL gets the correct schema from create_all(), so skip this
@@ -1266,6 +1275,14 @@ async def run_migrations(conn):
# falls back to the global low_stock_threshold setting.
await _safe_execute(conn, "ALTER TABLE spool ADD COLUMN category VARCHAR(50)")
await _safe_execute(conn, "ALTER TABLE spool ADD COLUMN low_stock_threshold_pct INTEGER")
# Migration: enhanced filament colour handling (#1154). `extra_colors` is
# a comma-separated list of 6- or 8-char hex tokens (no `#`) for multi-
# colour gradients; `effect_type` is one of {sparkle, wood, marble, glow,
# matte} as a visual rendering hint. Both nullable — NULL keeps the
# current single-rgba/no-effect behaviour.
await _safe_execute(conn, "ALTER TABLE spool ADD COLUMN extra_colors VARCHAR(255)")
await _safe_execute(conn, "ALTER TABLE spool ADD COLUMN effect_type VARCHAR(20)")
# Migration: Add cost field to spool_usage_history table
await _safe_execute(conn, "ALTER TABLE spool_usage_history ADD COLUMN cost REAL")
# Migration: Add archive_id field to spool_usage_history table
+4 -1
View File
@@ -14,7 +14,10 @@ class ColorCatalogEntry(Base):
id: Mapped[int] = mapped_column(primary_key=True)
manufacturer: Mapped[str] = mapped_column(String(200))
color_name: Mapped[str] = mapped_column(String(200))
hex_color: Mapped[str] = mapped_column(String(7)) # #RRGGBB
hex_color: Mapped[str] = mapped_column(String(9)) # #RRGGBB or #RRGGBBAA
material: Mapped[str | None] = mapped_column(String(100))
is_default: Mapped[bool] = mapped_column(Boolean, default=False)
# Optional multi-colour stops + visual effect (#1154), mirrors Spool fields.
extra_colors: Mapped[str | None] = mapped_column(String(255))
effect_type: Mapped[str | None] = mapped_column(String(20))
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
+8
View File
@@ -16,6 +16,14 @@ class Spool(Base):
subtype: Mapped[str | None] = mapped_column(String(50)) # Basic, Matte, Silk, etc.
color_name: Mapped[str | None] = mapped_column(String(100)) # "Jade White"
rgba: Mapped[str | None] = mapped_column(String(8)) # RRGGBBAA hex
# Multi-colour gradient stops for filaments with more than one colour
# (e.g. tri-colour, multi-colour). Stored as comma-separated 6- or 8-char
# hex tokens without `#`. Empty/NULL means solid (uses `rgba`). Up to 8
# stops; combination mode is driven by `subtype` (Gradient, Multicolor).
extra_colors: Mapped[str | None] = mapped_column(String(255))
# Visual effect overlay independent of subtype: sparkle, wood, marble,
# glow, matte. Purely a rendering hint — does not affect MQTT/firmware.
effect_type: Mapped[str | None] = mapped_column(String(20))
brand: Mapped[str | None] = mapped_column(String(100)) # "Polymaker"
label_weight: Mapped[int] = mapped_column(Integer, default=1000) # Advertised net weight (g)
core_weight: Mapped[int] = mapped_column(Integer, default=250) # Empty spool weight (g)
+101 -1
View File
@@ -1,6 +1,80 @@
from datetime import datetime
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, field_validator
# Visual variant applied to a spool's swatch — purely cosmetic, does not
# affect MQTT/firmware. Kept independent of `subtype` so users can override
# the rendering hint without touching Bambu's categorical filament label.
# Mirrors the visual variants the spool form's `KNOWN_VARIANTS` exposes so
# the catalog and spool form share one vocabulary; structural variants like
# gradient/dual-color/tri-color/multicolor combine with `extra_colors` for
# rendering, surface effects (sparkle/wood/marble/glow/matte) layer overlays.
ALLOWED_EFFECT_TYPES = frozenset(
{
# Surface effects
"sparkle",
"wood",
"marble",
"glow",
"matte",
# Sheen / finish variants
"silk",
"galaxy",
"rainbow",
"metal",
"translucent",
# Multi-colour structures (drive gradient rendering when paired with extra_colors)
"gradient",
"dual-color",
"tri-color",
"multicolor",
}
)
# Cap how many gradient stops we accept on input so a paste of arbitrary text
# can't blow up the stored value or downstream rendering.
MAX_EXTRA_COLOR_STOPS = 8
def normalize_extra_colors(value: str | None) -> str | None:
"""Parse comma-separated hex tokens into canonical lowercase form.
Accepts 6- or 8-char hex per token, with or without leading `#`. Returns
None for blank input, raises ValueError for malformed tokens or too many
stops. Output is the comma-joined canonical form (no `#`, lowercase).
"""
if value is None:
return None
raw = value.strip()
if not raw:
return None
tokens = [tok.strip().lstrip("#").lower() for tok in raw.split(",") if tok.strip()]
if not tokens:
return None
if len(tokens) > MAX_EXTRA_COLOR_STOPS:
raise ValueError(f"extra_colors accepts at most {MAX_EXTRA_COLOR_STOPS} stops")
for tok in tokens:
if len(tok) not in (6, 8):
raise ValueError(f"extra_colors token '{tok}' must be 6 or 8 hex chars")
try:
int(tok, 16)
except ValueError as exc:
raise ValueError(f"extra_colors token '{tok}' is not valid hex") from exc
return ",".join(tokens)
def normalize_effect_type(value: str | None) -> str | None:
if value is None:
return None
trimmed = value.strip().lower()
if not trimmed:
return None
# Tolerate "Dual Color" / "dual_color" / "dual color" → "dual-color" so
# users pasting from spool-subtype labels don't hit a validation wall.
canonical = trimmed.replace("_", "-").replace(" ", "-")
if canonical not in ALLOWED_EFFECT_TYPES:
raise ValueError(f"effect_type must be one of: {sorted(ALLOWED_EFFECT_TYPES)}")
return canonical
class SpoolBase(BaseModel):
@@ -8,7 +82,20 @@ class SpoolBase(BaseModel):
subtype: str | None = None
color_name: str | None = None
rgba: str | None = Field(None, pattern=r"^[0-9A-Fa-f]{8}$")
extra_colors: str | None = None
effect_type: str | None = None
brand: str | None = None
@field_validator("extra_colors")
@classmethod
def _validate_extra_colors(cls, v: str | None) -> str | None:
return normalize_extra_colors(v)
@field_validator("effect_type")
@classmethod
def _validate_effect_type(cls, v: str | None) -> str | None:
return normalize_effect_type(v)
label_weight: int = 1000
core_weight: int = 250
core_weight_catalog_id: int | None = None
@@ -45,7 +132,20 @@ class SpoolUpdate(BaseModel):
subtype: str | None = None
color_name: str | None = None
rgba: str | None = Field(None, pattern=r"^[0-9A-Fa-f]{8}$")
extra_colors: str | None = None
effect_type: str | None = None
brand: str | None = None
@field_validator("extra_colors")
@classmethod
def _validate_extra_colors(cls, v: str | None) -> str | None:
return normalize_extra_colors(v)
@field_validator("effect_type")
@classmethod
def _validate_effect_type(cls, v: str | None) -> str | None:
return normalize_effect_type(v)
label_weight: int | None = None
core_weight: int | None = None
core_weight_catalog_id: int | None = None
@@ -0,0 +1,159 @@
"""Integration tests for the multi-colour + effect extensions on the colour
catalog routes (#1154).
End-to-end coverage that the new fields on `ColorEntryCreate` / `ColorEntryUpdate`
round-trip through the database, that catalog GET surfaces them in the response,
and that paste-style values from 3dfilamentprofiles.com are normalized.
"""
import pytest
from httpx import AsyncClient
@pytest.mark.asyncio
@pytest.mark.integration
async def test_create_color_entry_with_extras(async_client: AsyncClient):
"""POST /inventory/colors stores extra_colors + effect_type."""
payload = {
"manufacturer": "3dfilamentprofiles",
"color_name": "Aurora Tetracolour",
"hex_color": "#EC984C",
"material": "PLA",
"extra_colors": "EC984C,#6CD4BC,A66EB9,D87694",
"effect_type": "Sparkle",
}
response = await async_client.post("/api/v1/inventory/colors", json=payload)
assert response.status_code == 200, response.text
body = response.json()
# Canonical form: lowercase, no `#`, comma-joined.
assert body["extra_colors"] == "ec984c,6cd4bc,a66eb9,d87694"
assert body["effect_type"] == "sparkle"
assert body["hex_color"] == "#EC984C"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_create_color_entry_accepts_8char_hex(async_client: AsyncClient):
"""Catalog hex_color may include alpha (#RRGGBBAA) post-#1154."""
payload = {
"manufacturer": "Bambu Lab",
"color_name": "Translucent Galaxy",
"hex_color": "#1A2B3C80",
"material": "PETG",
}
response = await async_client.post("/api/v1/inventory/colors", json=payload)
assert response.status_code == 200, response.text
assert response.json()["hex_color"] == "#1A2B3C80"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_update_color_entry_clears_extras(async_client: AsyncClient):
"""PUT with empty extra_colors clears the field (server normalizes "" → null)."""
create = await async_client.post(
"/api/v1/inventory/colors",
json={
"manufacturer": "Test",
"color_name": "Fade",
"hex_color": "#FF0000",
"extra_colors": "FF0000,00FF00",
"effect_type": "wood",
},
)
assert create.status_code == 200
entry_id = create.json()["id"]
update = await async_client.put(
f"/api/v1/inventory/colors/{entry_id}",
json={
"manufacturer": "Test",
"color_name": "Fade",
"hex_color": "#FF0000",
"extra_colors": "",
"effect_type": None,
},
)
assert update.status_code == 200, update.text
body = update.json()
assert body["extra_colors"] is None
assert body["effect_type"] is None
@pytest.mark.asyncio
@pytest.mark.integration
async def test_create_color_entry_rejects_bad_extra_colors(async_client: AsyncClient):
response = await async_client.post(
"/api/v1/inventory/colors",
json={
"manufacturer": "Test",
"color_name": "Bad",
"hex_color": "#FF0000",
"extra_colors": "not-hex,GGHHII",
},
)
assert response.status_code == 422
@pytest.mark.asyncio
@pytest.mark.integration
async def test_create_color_entry_rejects_bad_effect_type(async_client: AsyncClient):
response = await async_client.post(
"/api/v1/inventory/colors",
json={
"manufacturer": "Test",
"color_name": "Bad",
"hex_color": "#FF0000",
"effect_type": "not-a-real-variant",
},
)
assert response.status_code == 422
@pytest.mark.asyncio
@pytest.mark.integration
async def test_get_color_catalog_returns_extras(async_client: AsyncClient):
"""GET /inventory/colors response shape includes the new fields."""
await async_client.post(
"/api/v1/inventory/colors",
json={
"manufacturer": "Test",
"color_name": "Glitter Black",
"hex_color": "#101010",
"extra_colors": "101010,303030",
"effect_type": "sparkle",
},
)
response = await async_client.get("/api/v1/inventory/colors")
assert response.status_code == 200
rows = response.json()
glitter = next((r for r in rows if r["color_name"] == "Glitter Black"), None)
assert glitter is not None
assert glitter["extra_colors"] == "101010,303030"
assert glitter["effect_type"] == "sparkle"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_create_spool_with_color_extras(async_client: AsyncClient):
"""POST /inventory/spools threads the new spool-side fields end-to-end."""
payload = {
"material": "PLA",
"subtype": "Multicolor",
"rgba": "EC984CFF",
"extra_colors": "#EC984C,#6CD4BC,#A66EB9,#D87694",
"effect_type": "matte",
}
response = await async_client.post("/api/v1/inventory/spools", json=payload)
assert response.status_code == 200, response.text
body = response.json()
assert body["extra_colors"] == "ec984c,6cd4bc,a66eb9,d87694"
assert body["effect_type"] == "matte"
# PATCH clears via empty string + null.
patch = await async_client.patch(
f"/api/v1/inventory/spools/{body['id']}",
json={"extra_colors": "", "effect_type": None},
)
assert patch.status_code == 200
assert patch.json()["extra_colors"] is None
assert patch.json()["effect_type"] is None
@@ -0,0 +1,210 @@
"""Schema validation for enhanced filament colour fields (#1154).
Two new fields land on Spool and ColorCatalogEntry:
- `extra_colors`: comma-separated 6- or 8-char hex tokens. Stored canonical
form is lowercase, no `#`, no whitespace. Bounded to MAX_EXTRA_COLOR_STOPS
stops so a paste of arbitrary text can't blow up the column.
- `effect_type`: one of {sparkle, wood, marble, glow, matte}. Independent of
Spool.subtype — purely a rendering hint.
"""
import pytest
from pydantic import ValidationError
from backend.app.schemas.spool import (
ALLOWED_EFFECT_TYPES,
MAX_EXTRA_COLOR_STOPS,
SpoolCreate,
SpoolUpdate,
normalize_effect_type,
normalize_extra_colors,
)
class TestNormalizeExtraColors:
"""The shared helper that both Spool and ColorCatalog schemas delegate to."""
def test_none_passthrough(self):
assert normalize_extra_colors(None) is None
def test_empty_string_returns_none(self):
assert normalize_extra_colors("") is None
assert normalize_extra_colors(" ") is None
def test_strips_hash_prefix(self):
assert normalize_extra_colors("#FF0000,#00FF00") == "ff0000,00ff00"
def test_lowercases(self):
assert normalize_extra_colors("AABBCC") == "aabbcc"
def test_accepts_8char_alpha(self):
assert normalize_extra_colors("AABBCC80,DDEEFF40") == "aabbcc80,ddeeff40"
def test_mixed_6_and_8_char(self):
assert normalize_extra_colors("FF0000,00FF0080") == "ff0000,00ff0080"
def test_handles_whitespace_around_tokens(self):
# 3dfilamentprofiles.com paste sometimes has spaces after commas.
assert normalize_extra_colors("EC984C, #6CD4BC ,A66EB9, D87694") == "ec984c,6cd4bc,a66eb9,d87694"
def test_drops_empty_tokens(self):
# ",,FF0000," is a degenerate paste — keep what's valid.
assert normalize_extra_colors(",,FF0000,") == "ff0000"
def test_rejects_too_many_stops(self):
too_many = ",".join(["FF0000"] * (MAX_EXTRA_COLOR_STOPS + 1))
with pytest.raises(ValueError, match="at most"):
normalize_extra_colors(too_many)
def test_accepts_max_stops(self):
boundary = ",".join(["FF0000"] * MAX_EXTRA_COLOR_STOPS)
out = normalize_extra_colors(boundary)
assert out is not None
assert out.count(",") == MAX_EXTRA_COLOR_STOPS - 1
def test_rejects_non_hex(self):
with pytest.raises(ValueError, match="not valid hex"):
normalize_extra_colors("FF0000,GGHHII")
def test_rejects_wrong_length(self):
with pytest.raises(ValueError, match="6 or 8 hex"):
normalize_extra_colors("FFF")
with pytest.raises(ValueError, match="6 or 8 hex"):
normalize_extra_colors("FFFFF")
with pytest.raises(ValueError, match="6 or 8 hex"):
normalize_extra_colors("FFFFFFFFF")
class TestNormalizeEffectType:
def test_none_passthrough(self):
assert normalize_effect_type(None) is None
def test_empty_string_returns_none(self):
assert normalize_effect_type("") is None
assert normalize_effect_type(" ") is None
def test_lowercases(self):
assert normalize_effect_type("SPARKLE") == "sparkle"
assert normalize_effect_type("Wood") == "wood"
def test_accepts_all_known_types(self):
for effect in ALLOWED_EFFECT_TYPES:
assert normalize_effect_type(effect) == effect
def test_canonicalizes_space_to_dash(self):
# User pastes the spool-subtype label "Dual Color" / "Tri Color".
assert normalize_effect_type("Dual Color") == "dual-color"
assert normalize_effect_type("Tri Color") == "tri-color"
def test_canonicalizes_underscore_to_dash(self):
assert normalize_effect_type("dual_color") == "dual-color"
def test_accepts_structural_variants(self):
# Gradient / Multicolor were added in #1154 follow-up so the catalog
# can express the full spool variant vocabulary.
for variant in ("gradient", "dual-color", "tri-color", "multicolor"):
assert normalize_effect_type(variant) == variant
def test_accepts_sheen_variants(self):
for sheen in ("silk", "galaxy", "rainbow", "metal", "translucent"):
assert normalize_effect_type(sheen) == sheen
def test_rejects_unknown_type(self):
# "neon" isn't in the allowed set — must reject.
with pytest.raises(ValueError, match="effect_type must be one of"):
normalize_effect_type("neon")
class TestSpoolCreateColorExtensions:
def test_accepts_extra_colors_paste(self):
spool = SpoolCreate(material="PLA", extra_colors="EC984C,#6CD4BC,A66EB9,D87694")
assert spool.extra_colors == "ec984c,6cd4bc,a66eb9,d87694"
def test_accepts_effect_type(self):
spool = SpoolCreate(material="PLA", effect_type="sparkle")
assert spool.effect_type == "sparkle"
def test_defaults_to_none(self):
spool = SpoolCreate(material="PLA")
assert spool.extra_colors is None
assert spool.effect_type is None
def test_rejects_bad_extra_colors(self):
with pytest.raises(ValidationError, match="extra_colors"):
SpoolCreate(material="PLA", extra_colors="not-hex")
def test_rejects_bad_effect_type(self):
with pytest.raises(ValidationError, match="effect_type"):
SpoolCreate(material="PLA", effect_type="not-a-real-variant")
class TestSpoolUpdateColorExtensions:
def test_clears_extra_colors_via_empty_string(self):
# Frontend sends "" to clear; normalizer maps that to None.
update = SpoolUpdate(extra_colors="")
assert update.extra_colors is None
def test_clears_effect_type_via_explicit_null(self):
update = SpoolUpdate(effect_type=None)
assert update.effect_type is None
def test_round_trips_canonical_form(self):
update = SpoolUpdate(extra_colors="FF0000,00FF00", effect_type="MATTE")
assert update.extra_colors == "ff0000,00ff00"
assert update.effect_type == "matte"
class TestColorCatalogSchemas:
"""The catalog Create/Update mirror the spool fields."""
def test_create_accepts_8char_hex_color(self):
from backend.app.api.routes.inventory import ColorEntryCreate
entry = ColorEntryCreate(
manufacturer="Bambu Lab",
color_name="Galaxy",
hex_color="#112233AA",
)
assert entry.hex_color == "#112233AA"
def test_create_still_accepts_6char_hex_color(self):
# Backward compat: existing #RRGGBB rows must keep working.
from backend.app.api.routes.inventory import ColorEntryCreate
entry = ColorEntryCreate(
manufacturer="Bambu Lab",
color_name="Jade White",
hex_color="#A1B2C3",
)
assert entry.hex_color == "#A1B2C3"
def test_create_rejects_hex_without_hash(self):
from backend.app.api.routes.inventory import ColorEntryCreate
with pytest.raises(ValidationError, match="hex_color"):
ColorEntryCreate(manufacturer="X", color_name="Y", hex_color="A1B2C3")
def test_create_threads_extra_colors_and_effect(self):
from backend.app.api.routes.inventory import ColorEntryCreate
entry = ColorEntryCreate(
manufacturer="3dfilamentprofiles",
color_name="Aurora",
hex_color="#EC984C",
extra_colors="EC984C,#6CD4BC,A66EB9,D87694",
effect_type="sparkle",
)
assert entry.extra_colors == "ec984c,6cd4bc,a66eb9,d87694"
assert entry.effect_type == "sparkle"
def test_update_validators_match_create(self):
from backend.app.api.routes.inventory import ColorEntryUpdate
with pytest.raises(ValidationError, match="extra_colors"):
ColorEntryUpdate(
manufacturer="X",
color_name="Y",
hex_color="#A1B2C3",
extra_colors="not-hex",
)
@@ -0,0 +1,186 @@
/**
* Tests for the colour catalog admin (ColorCatalogSettings).
*
* Pin the #1154 wiring contract:
* - The Add form sends `extra_colors` + `effect_type` alongside the legacy
* manufacturer / color_name / hex_color / material fields.
* - Inline-edit hydrates the new fields from the existing entry and sends
* them back through `updateColorEntry`.
* - Effect dropdown lists the full unified vocabulary (surface effects
* + sheen variants + structural variants), not just the original 5.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { screen, fireEvent, waitFor } from '@testing-library/react';
import { render } from '../utils';
import { api } from '../../api/client';
import { ColorCatalogSettings } from '../../components/ColorCatalogSettings';
vi.mock('../../api/client', async () => {
// Preserve every other method on `api` (ThemeContext / AuthContext call
// some on mount) and override only the catalog ones the component touches.
const actual: typeof import('../../api/client') = await vi.importActual('../../api/client');
return {
...actual,
api: {
...actual.api,
getColorCatalog: vi.fn(),
addColorEntry: vi.fn(),
updateColorEntry: vi.fn(),
deleteColorEntry: vi.fn(),
bulkDeleteColorEntries: vi.fn(),
resetColorCatalog: vi.fn(),
},
getAuthToken: vi.fn(() => null),
};
});
beforeEach(() => {
vi.clearAllMocks();
});
describe('ColorCatalogSettings — Add form (#1154)', () => {
it('sends extra_colors and effect_type when adding an entry', async () => {
vi.mocked(api.getColorCatalog).mockResolvedValueOnce([]);
vi.mocked(api.addColorEntry).mockResolvedValueOnce({
id: 1,
manufacturer: 'Test',
color_name: 'Aurora',
hex_color: '#EC984C',
material: null,
is_default: false,
extra_colors: 'ec984c,6cd4bc,a66eb9,d87694',
effect_type: 'sparkle',
});
render(<ColorCatalogSettings />);
// Wait for initial load to settle so the Add button is rendered.
await waitFor(() => expect(api.getColorCatalog).toHaveBeenCalled());
await waitFor(() =>
expect(screen.queryByText(/loading/i)).not.toBeInTheDocument(),
);
// Open the Add form via the toolbar's "Add" button — there's only one
// before the form opens, so this is unambiguous.
fireEvent.click(screen.getByRole('button', { name: /Add$/i }));
// The form now renders with manufacturer, color name, hex, material,
// extra_colors, and effect_type inputs.
fireEvent.change(screen.getByPlaceholderText('Manufacturer'), {
target: { value: 'Test' },
});
fireEvent.change(screen.getByPlaceholderText('Color Name'), {
target: { value: 'Aurora' },
});
// Hex has both a <input type="color"> and a <input type="text"
// placeholder="#FFFFFF">. Pick the text variant by placeholder.
fireEvent.change(screen.getByPlaceholderText('#FFFFFF'), {
target: { value: '#EC984C' },
});
fireEvent.change(screen.getByPlaceholderText('EC984C,#6CD4BC,A66EB9,D87694'), {
target: { value: 'EC984C,#6CD4BC,A66EB9,D87694' },
});
// Pick "Sparkle" from the effect-type combobox (the manufacturer filter
// is also a <select>, so disambiguate by looking for the one whose
// options include 'sparkle').
const effectSelectAdd = (
screen.getAllByRole('combobox') as HTMLSelectElement[]
).find((s) => Array.from(s.options).some((o) => o.value === 'sparkle'));
expect(effectSelectAdd).toBeDefined();
fireEvent.change(effectSelectAdd!, { target: { value: 'sparkle' } });
// Submit. The form's submit "Add" button is now the second button with
// that label (toolbar Add still exists), so query inside the form
// container by clicking the last matching button.
const allAddButtons = screen.getAllByRole('button', { name: /Add$/i });
fireEvent.click(allAddButtons[allAddButtons.length - 1]);
await waitFor(() => expect(api.addColorEntry).toHaveBeenCalledTimes(1));
expect(api.addColorEntry).toHaveBeenCalledWith({
manufacturer: 'Test',
color_name: 'Aurora',
hex_color: '#EC984C',
material: null,
extra_colors: 'EC984C,#6CD4BC,A66EB9,D87694',
effect_type: 'sparkle',
});
});
it('lists every variant in the effect dropdown (not just the original 5)', async () => {
vi.mocked(api.getColorCatalog).mockResolvedValueOnce([]);
render(<ColorCatalogSettings />);
await waitFor(() => expect(api.getColorCatalog).toHaveBeenCalled());
await waitFor(() =>
expect(screen.queryByText(/loading/i)).not.toBeInTheDocument(),
);
fireEvent.click(screen.getByRole('button', { name: /Add$/i }));
// Disambiguate from the toolbar's manufacturer-filter <select>.
const effectSelect = (
screen.getAllByRole('combobox') as HTMLSelectElement[]
).find((s) => Array.from(s.options).some((o) => o.value === 'sparkle'));
expect(effectSelect).toBeDefined();
const options = Array.from(effectSelect!.options).map((o) => o.value);
// Surface effects (V1).
expect(options).toContain('sparkle');
expect(options).toContain('wood');
expect(options).toContain('marble');
expect(options).toContain('glow');
expect(options).toContain('matte');
// Structural variants added in #1154 follow-up.
expect(options).toContain('gradient');
expect(options).toContain('dual-color');
expect(options).toContain('tri-color');
expect(options).toContain('multicolor');
// Sheen / finish variants.
expect(options).toContain('silk');
expect(options).toContain('galaxy');
expect(options).toContain('rainbow');
expect(options).toContain('metal');
expect(options).toContain('translucent');
// None / no-effect option.
expect(options).toContain('');
});
});
describe('ColorCatalogSettings — inline edit (#1154)', () => {
it('hydrates extra_colors and effect_type when entering edit mode', async () => {
const seed = {
id: 42,
manufacturer: 'Bambu Lab',
color_name: 'Galaxy',
hex_color: '#1A2B3C',
material: 'PLA',
is_default: true,
extra_colors: 'aabbcc,ddeeff',
effect_type: 'galaxy',
};
vi.mocked(api.getColorCatalog).mockResolvedValueOnce([seed]);
render(<ColorCatalogSettings />);
await waitFor(() => expect(screen.getByText('Galaxy')).toBeInTheDocument());
// Click the Edit button on the seeded row.
// The row's edit button has no accessible label so query by SVG-bearing
// button containing the Pencil icon — there's only one in the rendered tree.
const buttons = screen.getAllByRole('button');
const editButton = buttons.find((b) => b.querySelector('svg.lucide-pencil'));
expect(editButton).toBeDefined();
fireEvent.click(editButton!);
// The extra-colors input should now be populated with the seeded value.
const extraColorsInputs = screen.getAllByPlaceholderText(
'EC984C,#6CD4BC,A66EB9,D87694',
) as HTMLInputElement[];
expect(extraColorsInputs[0].value).toBe('aabbcc,ddeeff');
// The effect dropdown reflects the seeded effect. The manufacturer
// filter is also a <select> at the toolbar level, so query all and
// pick the one whose value matches what we expect — the last one,
// since the filter never has 'galaxy' in its options.
const selects = screen.getAllByRole('combobox') as HTMLSelectElement[];
const effectSelect = selects.find((s) => s.value === 'galaxy');
expect(effectSelect).toBeDefined();
});
});
@@ -0,0 +1,140 @@
/**
* Tests for the FilamentSwatch component (#1154).
*
* Covers the three independent inputs the swatch composes (rgba, extraColors,
* effectType) and the buildFilamentBackground helper used to paint banners.
*/
import { describe, it, expect } from 'vitest';
import { screen } from '@testing-library/react';
import { render } from '../utils';
import { FilamentSwatch } from '../../components/FilamentSwatch';
import { buildFilamentBackground } from '../../components/filamentSwatchHelpers';
describe('FilamentSwatch', () => {
it('renders a solid swatch when only rgba is set', () => {
render(<FilamentSwatch rgba="ff0000ff" />);
const el = screen.getByTestId('filament-swatch');
// Solid swatches are emitted as a 1-stop linear-gradient so the
// checkerboard layer below is still visible through alpha.
const bg = el.getAttribute('style') ?? '';
expect(bg).toMatch(/linear-gradient/);
expect(bg.toLowerCase()).toContain('#ff0000ff');
});
it('falls back to grey when nothing is set', () => {
render(<FilamentSwatch />);
const el = screen.getByTestId('filament-swatch');
expect(el.style.backgroundImage.toLowerCase()).toContain('#808080');
});
it('renders a linear gradient when extraColors has multiple stops', () => {
render(<FilamentSwatch rgba="ff0000ff" extraColors="ec984c,6cd4bc,a66eb9,d87694" />);
const el = screen.getByTestId('filament-swatch');
const bg = el.style.backgroundImage.toLowerCase();
// Linear (not conic) for non-Multicolor subtype.
expect(bg).toMatch(/linear-gradient/);
expect(bg).toContain('#ec984c');
expect(bg).toContain('#6cd4bc');
expect(bg).toContain('#a66eb9');
expect(bg).toContain('#d87694');
});
it('uses conic-gradient for Multicolor subtype', () => {
render(
<FilamentSwatch
rgba="ff0000ff"
extraColors="ec984c,6cd4bc,a66eb9"
subtype="Multicolor"
/>,
);
const el = screen.getByTestId('filament-swatch');
expect(el.style.backgroundImage.toLowerCase()).toMatch(/conic-gradient/);
});
it('also uses conic-gradient when effectType is multicolor (catalog path)', () => {
// Catalog entries don't have a `subtype`, so the multicolor effect_type
// value also has to trigger conic rendering for parity with the spool path.
render(<FilamentSwatch extraColors="ec984c,6cd4bc,a66eb9" effectType="multicolor" />);
const el = screen.getByTestId('filament-swatch');
expect(el.style.backgroundImage.toLowerCase()).toMatch(/conic-gradient/);
});
it('layers an effect overlay on top of the colour layer for sparkle', () => {
render(<FilamentSwatch rgba="ff0000ff" effectType="sparkle" />);
const el = screen.getByTestId('filament-swatch');
// Sparkle overlay is built from radial-gradient layers — confirm at least
// one is in the composed background, ahead of the colour layer.
expect(el.style.backgroundImage).toMatch(/radial-gradient/);
});
it('renders an overlay for silk variant', () => {
// Silk gets a soft sheen overlay (added in #1154 follow-up).
render(<FilamentSwatch rgba="ff0000ff" effectType="silk" />);
const el = screen.getByTestId('filament-swatch');
expect(el.style.backgroundImage).toMatch(/linear-gradient/);
});
it('treats categorical-only variants (gradient/dual-color) as labels without an overlay', () => {
// No extra_colors set → swatch falls back to the solid colour layer; the
// categorical effect value alone does not paint a sheen overlay.
render(<FilamentSwatch rgba="ff0000ff" effectType="gradient" />);
const el = screen.getByTestId('filament-swatch');
// No radial-gradient (sparkle/glow) and no rainbow/sheen overlay either —
// gradient/dual-color/tri-color are pure labels until extra_colors is set.
expect(el.style.backgroundImage).not.toMatch(/radial-gradient/);
});
it('ignores unknown effect types instead of throwing', () => {
render(<FilamentSwatch rgba="ff0000ff" effectType="not-a-real-variant" />);
const el = screen.getByTestId('filament-swatch');
expect(el.style.backgroundImage).not.toMatch(/radial-gradient/);
});
it('renders a checkerboard underneath so alpha is visible', () => {
render(<FilamentSwatch rgba="ff000080" />);
const el = screen.getByTestId('filament-swatch');
// The component always appends a checkerboard layer last so semi-
// transparent rgba values actually look transparent to the user.
expect(el.style.backgroundImage).toMatch(/repeating-conic-gradient/);
});
it('skips invalid hex tokens in extraColors instead of throwing', () => {
render(<FilamentSwatch extraColors="ff0000,not-hex,00ff00" />);
const el = screen.getByTestId('filament-swatch');
const bg = el.style.backgroundImage.toLowerCase();
// The two valid stops survive; the garbage token is dropped.
expect(bg).toContain('#ff0000');
expect(bg).toContain('#00ff00');
expect(bg).not.toContain('not-hex');
});
it('uses extra_colors for the title fallback when provided', () => {
render(<FilamentSwatch extraColors="ff0000,00ff00" />);
const el = screen.getByTestId('filament-swatch');
// Tooltip should show the comma-joined hex stops, not the (unset) rgba.
expect(el.title.toLowerCase()).toContain('#ff0000');
expect(el.title.toLowerCase()).toContain('#00ff00');
});
});
describe('buildFilamentBackground', () => {
it('emits the same layered background string the component renders', () => {
const bg = buildFilamentBackground({
rgba: 'ff0000ff',
extraColors: 'aabbcc,ddeeff',
effectType: 'matte',
});
// Effect overlay → colour layer → checkerboard, in that order.
expect(bg).toMatch(/linear-gradient/);
expect(bg).toMatch(/repeating-conic-gradient/);
expect(bg.toLowerCase()).toContain('#aabbcc');
expect(bg.toLowerCase()).toContain('#ddeeff');
});
it('returns a usable solid background when only rgba is provided', () => {
const bg = buildFilamentBackground({ rgba: '00ff00ff' });
expect(bg.toLowerCase()).toContain('#00ff00ff');
expect(bg).toMatch(/repeating-conic-gradient/);
});
});
@@ -61,6 +61,8 @@ const existingSpool: InventorySpool = {
brand: 'Polymaker',
color_name: 'Red',
rgba: 'FF0000FF',
extra_colors: null,
effect_type: null,
label_weight: 1000,
core_weight: 250,
core_weight_catalog_id: null,
@@ -60,6 +60,8 @@ const existingSpool: InventorySpool = {
brand: 'Polymaker',
color_name: 'Red',
rgba: 'FF0000FF',
extra_colors: null,
effect_type: null,
label_weight: 1000,
core_weight: 250,
core_weight_catalog_id: null,
@@ -11,9 +11,12 @@
import { describe, it, expect } from 'vitest';
import type { InventorySpool, SpoolAssignment } from '../../api/client';
// Replicate the grouping key function from InventoryPage (not exported)
// Replicate the grouping key function from InventoryPage (not exported).
// Must stay in lockstep with InventoryPage.tsx::spoolGroupKey — extra_colors
// and effect_type are part of the key (#1154) so multi-colour / effect
// variants don't get collapsed under "Group similar".
function spoolGroupKey(s: InventorySpool): string {
return `${s.material}|${s.subtype || ''}|${s.brand || ''}|${s.color_name || ''}|${s.rgba || ''}|${s.label_weight}`;
return `${s.material}|${s.subtype || ''}|${s.brand || ''}|${s.color_name || ''}|${s.rgba || ''}|${s.extra_colors || ''}|${s.effect_type || ''}|${s.label_weight}`;
}
type DisplayItem =
@@ -66,6 +69,8 @@ function makeSpool(overrides: Partial<InventorySpool> & { id: number }): Invento
brand: 'Polymaker',
color_name: 'Red',
rgba: 'FF0000FF',
extra_colors: null,
effect_type: null,
label_weight: 1000,
core_weight: 250,
core_weight_catalog_id: null,
@@ -128,6 +133,28 @@ describe('spoolGroupKey', () => {
expect(spoolGroupKey(a)).not.toBe(spoolGroupKey(b));
});
it('generates different key when extra_colors differs (#1154)', () => {
// Two spools that share the base hex but have different gradient stops
// are visually different — they must not collapse under "Group similar".
const a = makeSpool({ id: 1, extra_colors: 'ff0000,00ff00' });
const b = makeSpool({ id: 2, extra_colors: 'ff0000,0000ff' });
expect(spoolGroupKey(a)).not.toBe(spoolGroupKey(b));
});
it('generates different key when effect_type differs (#1154)', () => {
const a = makeSpool({ id: 1, effect_type: 'sparkle' });
const b = makeSpool({ id: 2, effect_type: 'matte' });
expect(spoolGroupKey(a)).not.toBe(spoolGroupKey(b));
});
it('still groups identical multi-colour spools (#1154)', () => {
// Same base + same stops + same effect → same group; the new fields
// join the key but don't break the existing identical-grouping case.
const a = makeSpool({ id: 1, extra_colors: 'ff0000,00ff00', effect_type: 'multicolor' });
const b = makeSpool({ id: 2, extra_colors: 'ff0000,00ff00', effect_type: 'multicolor' });
expect(spoolGroupKey(a)).toBe(spoolGroupKey(b));
});
it('treats null and empty string subtype the same', () => {
const a = makeSpool({ id: 1, subtype: null as unknown as string });
const b = makeSpool({ id: 2, subtype: '' });
+26 -3
View File
@@ -1063,6 +1063,9 @@ export interface ColorCatalogEntry {
hex_color: string;
material: string | null;
is_default: boolean;
// #1154: optional multi-colour gradient stops + visual effect.
extra_colors?: string | null;
effect_type?: string | null;
}
export interface ColorLookupResult {
@@ -2235,6 +2238,10 @@ export interface InventorySpool {
subtype: string | null;
color_name: string | null;
rgba: string | null;
// Multi-colour gradient stops (#1154): comma-separated 6/8-char hex.
extra_colors: string | null;
// Visual effect overlay: sparkle | wood | marble | glow | matte.
effect_type: string | null;
brand: string | null;
label_weight: number;
core_weight: number;
@@ -4346,10 +4353,26 @@ export const api = {
request<ColorCatalogEntry[]>('/inventory/colors'),
getColorNameMap: () =>
request<{ colors: Record<string, string> }>('/inventory/colors/map'),
addColorEntry: (data: { manufacturer: string; color_name: string; hex_color: string; material: string | null }) =>
addColorEntry: (data: {
manufacturer: string;
color_name: string;
hex_color: string;
material: string | null;
extra_colors?: string | null;
effect_type?: string | null;
}) =>
request<ColorCatalogEntry>('/inventory/colors', { method: 'POST', body: JSON.stringify(data) }),
updateColorEntry: (id: number, data: { manufacturer: string; color_name: string; hex_color: string; material: string | null }) =>
request<ColorCatalogEntry>(`/inventory/colors/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
updateColorEntry: (
id: number,
data: {
manufacturer: string;
color_name: string;
hex_color: string;
material: string | null;
extra_colors?: string | null;
effect_type?: string | null;
},
) => request<ColorCatalogEntry>(`/inventory/colors/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
deleteColorEntry: (id: number) =>
request<{ status: string }>(`/inventory/colors/${id}`, { method: 'DELETE' }),
bulkDeleteColorEntries: (ids: number[]) =>
@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { Fragment, useState, useEffect, useCallback, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { Palette, Plus, Trash2, RotateCcw, Loader2, Pencil, Check, X, Search, Download, Upload, Cloud } from 'lucide-react';
import { api, getAuthToken } from '../api/client';
@@ -6,6 +6,8 @@ import type { ColorCatalogEntry } from '../api/client';
import { useToast } from '../contexts/ToastContext';
import { Card, CardHeader, CardContent } from './Card';
import { ConfirmModal } from './ConfirmModal';
import { FilamentSwatch } from './FilamentSwatch';
import { FILAMENT_EFFECT_OPTIONS } from './filamentSwatchHelpers';
export function ColorCatalogSettings() {
const { t } = useTranslation();
@@ -23,6 +25,8 @@ export function ColorCatalogSettings() {
const [formColorName, setFormColorName] = useState('');
const [formHexColor, setFormHexColor] = useState('#FFFFFF');
const [formMaterial, setFormMaterial] = useState('');
const [formExtraColors, setFormExtraColors] = useState('');
const [formEffectType, setFormEffectType] = useState('');
const [saving, setSaving] = useState(false);
// Selection state
@@ -68,6 +72,8 @@ export function ColorCatalogSettings() {
setFormColorName('');
setFormHexColor('#FFFFFF');
setFormMaterial('');
setFormExtraColors('');
setFormEffectType('');
};
const handleAdd = async () => {
@@ -82,6 +88,8 @@ export function ColorCatalogSettings() {
color_name: formColorName.trim(),
hex_color: formHexColor,
material: formMaterial.trim() || null,
extra_colors: formExtraColors.trim() || null,
effect_type: formEffectType.trim() || null,
});
setCatalog(prev => [...prev, entry].sort((a, b) =>
a.manufacturer.localeCompare(b.manufacturer) ||
@@ -104,6 +112,8 @@ export function ColorCatalogSettings() {
setFormColorName(entry.color_name);
setFormHexColor(entry.hex_color);
setFormMaterial(entry.material || '');
setFormExtraColors(entry.extra_colors || '');
setFormEffectType(entry.effect_type || '');
};
const cancelEdit = () => {
@@ -123,6 +133,8 @@ export function ColorCatalogSettings() {
color_name: formColorName.trim(),
hex_color: formHexColor,
material: formMaterial.trim() || null,
extra_colors: formExtraColors.trim() || null,
effect_type: formEffectType.trim() || null,
});
setCatalog(prev =>
prev.map(e => e.id === id ? updated : e).sort((a, b) =>
@@ -254,8 +266,8 @@ export function ColorCatalogSettings() {
};
const handleExport = () => {
const exportData = catalog.map(({ manufacturer, color_name, hex_color, material }) => ({
manufacturer, color_name, hex_color, material,
const exportData = catalog.map(({ manufacturer, color_name, hex_color, material, extra_colors, effect_type }) => ({
manufacturer, color_name, hex_color, material, extra_colors, effect_type,
}));
const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
@@ -276,6 +288,7 @@ export function ColorCatalogSettings() {
const text = await file.text();
const data = JSON.parse(text) as Array<{
manufacturer: string; color_name: string; hex_color: string; material?: string | null;
extra_colors?: string | null; effect_type?: string | null;
}>;
if (!Array.isArray(data)) throw new Error('Invalid format');
@@ -295,6 +308,8 @@ export function ColorCatalogSettings() {
color_name: item.color_name,
hex_color: item.hex_color,
material: item.material || null,
extra_colors: item.extra_colors || null,
effect_type: item.effect_type || null,
});
setCatalog(prev => [...prev, entry].sort((a, b) =>
a.manufacturer.localeCompare(b.manufacturer) ||
@@ -473,6 +488,27 @@ export function ColorCatalogSettings() {
</button>
</div>
</div>
{/* #1154: optional multi-colour stops + visual effect. */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 mt-2">
<input
type="text"
className="px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white text-sm font-mono placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
placeholder={t('inventory.extraColorsPlaceholder')}
value={formExtraColors}
onChange={(e) => setFormExtraColors(e.target.value)}
/>
<select
className="px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:border-bambu-green focus:outline-none"
value={formEffectType}
onChange={(e) => setFormEffectType(e.target.value)}
>
{FILAMENT_EFFECT_OPTIONS.map((opt) => (
<option key={opt.value || 'none'} value={opt.value}>
{t(opt.labelKey)}
</option>
))}
</select>
</div>
</div>
)}
@@ -519,7 +555,8 @@ export function ColorCatalogSettings() {
</tr>
) : (
filteredCatalog.map(entry => (
<tr key={entry.id} className={`border-t border-bambu-dark-tertiary hover:bg-bambu-dark ${selectedIds.has(entry.id) ? 'bg-bambu-dark' : ''}`}>
<Fragment key={entry.id}>
<tr className={`border-t border-bambu-dark-tertiary hover:bg-bambu-dark ${selectedIds.has(entry.id) ? 'bg-bambu-dark' : ''}`}>
{editingId === entry.id ? (
<>
<td className="px-2 py-2">
@@ -596,9 +633,12 @@ export function ColorCatalogSettings() {
/>
</td>
<td className="px-3 py-2">
<div
className="w-8 h-8 rounded border border-bambu-dark-tertiary"
style={{ backgroundColor: entry.hex_color }}
<FilamentSwatch
rgba={entry.hex_color.replace(/^#/, '') + (entry.hex_color.length === 7 ? 'FF' : '')}
extraColors={entry.extra_colors}
effectType={entry.effect_type}
className="w-8 h-8"
shape="square"
title={entry.hex_color}
/>
</td>
@@ -625,6 +665,35 @@ export function ColorCatalogSettings() {
</>
)}
</tr>
{editingId === entry.id && (
<tr className="border-t border-bambu-dark-tertiary/50 bg-bambu-dark">
<td colSpan={2}></td>
<td className="px-3 py-2" colSpan={2}>
<input
type="text"
className="w-full px-2 py-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-sm font-mono focus:border-bambu-green focus:outline-none"
placeholder={t('inventory.extraColorsPlaceholder')}
value={formExtraColors}
onChange={(e) => setFormExtraColors(e.target.value)}
/>
</td>
<td className="px-3 py-2" colSpan={2}>
<select
className="w-full px-2 py-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-sm focus:border-bambu-green focus:outline-none"
value={formEffectType}
onChange={(e) => setFormEffectType(e.target.value)}
>
{FILAMENT_EFFECT_OPTIONS.map((opt) => (
<option key={opt.value || 'none'} value={opt.value}>
{t(opt.labelKey)}
</option>
))}
</select>
</td>
<td></td>
</tr>
)}
</Fragment>
))
)}
</tbody>
@@ -0,0 +1,83 @@
import React, { useMemo } from 'react';
import {
CHECKERBOARD_BG,
EFFECT_OVERLAYS,
buildColorLayer,
parseStops,
type FilamentEffect,
} from './filamentSwatchHelpers';
/** Shared filament-colour swatch. See `filamentSwatchHelpers.ts` for the
* pure helpers + constants this component composes. */
export interface FilamentSwatchProps {
/** RRGGBBAA hex without `#` (Bambu/AMS canonical). Falls back to grey when null. */
rgba?: string | null;
/** Comma-separated 6/8-char hex tokens (no `#`). Empty/undefined → solid. */
extraColors?: string | null;
/** Visual effect overlay. */
effectType?: FilamentEffect | string | null;
/** When `Multicolor`, a conic gradient is used instead of linear. */
subtype?: string | null;
/** Tailwind size token applied to width/height (e.g. `w-5 h-5`). Default: `w-5 h-5`. */
className?: string;
/** Override the rounded shape — defaults to `rounded-full` (circular). */
shape?: 'circle' | 'pill' | 'square';
/** Optional inline style overrides (e.g. height of a card banner). */
style?: React.CSSProperties;
/** Native title attribute for hover tooltip. */
title?: string;
}
export function FilamentSwatch({
rgba,
extraColors,
effectType,
subtype,
className = 'w-5 h-5',
shape = 'circle',
style,
title,
}: FilamentSwatchProps) {
const stops = useMemo(() => parseStops(extraColors), [extraColors]);
const colorLayer = useMemo(
() => buildColorLayer(rgba, stops, subtype, effectType),
[rgba, stops, subtype, effectType],
);
const effectKey =
typeof effectType === 'string' && effectType in EFFECT_OVERLAYS
? (effectType as FilamentEffect)
: null;
const effectLayer = effectKey ? EFFECT_OVERLAYS[effectKey] ?? null : null;
// Layer order (top → bottom): effect overlay → colour layer → checkerboard.
// Set as `background-image` (not the `background` shorthand) so the value
// remains a pure list-of-images that browsers and test runners parse cleanly.
const backgroundImage = [effectLayer, colorLayer, CHECKERBOARD_BG]
.filter((layer): layer is string => Boolean(layer))
.join(', ');
const shapeClass =
shape === 'circle' ? 'rounded-full' : shape === 'pill' ? 'rounded-full' : 'rounded';
// Compute a sensible title fallback — solid hex or gradient summary.
const computedTitle =
title ??
(stops.length > 0
? stops.join(', ')
: rgba
? `#${rgba.substring(0, 6)}`
: undefined);
return (
<span
data-testid="filament-swatch"
className={`${className} ${shapeClass} border border-black/20 inline-block flex-shrink-0`}
style={{ backgroundImage, backgroundSize: 'cover', ...style }}
title={computedTitle}
/>
);
}
export default FilamentSwatch;
@@ -269,6 +269,8 @@ export function SpoolFormModal({
brand: spool.brand || '',
color_name: spool.color_name || '',
rgba: validRgba,
extra_colors: spool.extra_colors || '',
effect_type: spool.effect_type || '',
label_weight: spool.label_weight || 1000,
core_weight: spool.core_weight || 250,
core_weight_catalog_id: spool.core_weight_catalog_id ?? null,
@@ -518,6 +520,8 @@ export function SpoolFormModal({
brand: formData.brand || null,
color_name: formData.color_name || null,
rgba: formData.rgba || null,
extra_colors: formData.extra_colors || null,
effect_type: formData.effect_type || null,
label_weight: formData.label_weight,
core_weight: formData.core_weight,
core_weight_catalog_id: formData.core_weight_catalog_id,
@@ -0,0 +1,175 @@
/* Enhanced filament-colour rendering helpers (#1154).
*
* Pure (non-component) exports that drive `<FilamentSwatch>` and any caller
* that needs the same composed background as a CSS string. Lives in its own
* file so `FilamentSwatch.tsx` can stay component-only and satisfy the
* `react-refresh/only-export-components` ESLint rule.
*
* Inputs the swatch composes:
* 1. `rgba` — RRGGBBAA hex (the Bambu/AMS canonical form)
* 2. `extraColors` — comma-separated 6/8-char hex stops; turns the swatch
* into a gradient. Conic when either `subtype` or
* `effectType` is `multicolor`, otherwise linear.
* 3. `effectType` — visual variant. Some carry a CSS overlay (sparkle,
* wood, marble, glow, matte, silk, galaxy, metal),
* others are categorical labels only.
*
* Alpha < 0xFF on any layer is shown against a checkerboard so the user can
* actually see the transparency they configured.
*/
export type FilamentEffect =
// Surface effects with their own CSS overlay
| 'sparkle'
| 'wood'
| 'marble'
| 'glow'
| 'matte'
// Sheen / finish variants (categorical labels; some carry an overlay)
| 'silk'
| 'galaxy'
| 'rainbow'
| 'metal'
| 'translucent'
// Multi-colour structures (mostly drive the colour-layer choice)
| 'gradient'
| 'dual-color'
| 'tri-color'
| 'multicolor';
/** Public list of all known effect/variant values, in display order. Shared
* by the spool form's ColorSection dropdown and the colour-catalog editor
* so the two stay in lockstep. Each value pairs with an i18n key under
* `inventory.colorEffect.<value>` (kebab → camel: `dualColor`/`triColor`). */
export const FILAMENT_EFFECT_OPTIONS: ReadonlyArray<{
value: '' | FilamentEffect;
labelKey: string;
}> = [
{ value: '', labelKey: 'inventory.colorEffect.none' },
// Surface effects
{ value: 'sparkle', labelKey: 'inventory.colorEffect.sparkle' },
{ value: 'wood', labelKey: 'inventory.colorEffect.wood' },
{ value: 'marble', labelKey: 'inventory.colorEffect.marble' },
{ value: 'glow', labelKey: 'inventory.colorEffect.glow' },
{ value: 'matte', labelKey: 'inventory.colorEffect.matte' },
// Sheen / finish
{ value: 'silk', labelKey: 'inventory.colorEffect.silk' },
{ value: 'galaxy', labelKey: 'inventory.colorEffect.galaxy' },
{ value: 'rainbow', labelKey: 'inventory.colorEffect.rainbow' },
{ value: 'metal', labelKey: 'inventory.colorEffect.metal' },
{ value: 'translucent', labelKey: 'inventory.colorEffect.translucent' },
// Multi-colour structures
{ value: 'gradient', labelKey: 'inventory.colorEffect.gradient' },
{ value: 'dual-color', labelKey: 'inventory.colorEffect.dualColor' },
{ value: 'tri-color', labelKey: 'inventory.colorEffect.triColor' },
{ value: 'multicolor', labelKey: 'inventory.colorEffect.multicolor' },
];
// Checkerboard pattern shown beneath the colour layer so alpha < FF is
// actually visible to the user. Kept as a pure gradient (no position/size)
// so the value parses cleanly inside `background-image:` everywhere.
export const CHECKERBOARD_BG =
'repeating-conic-gradient(#cbcbcb 0% 25%, #f5f5f5 0% 50%)';
/** Optional CSS overlay layer for variants that have a visual treatment.
* Variants without an entry are categorical labels only — they don't paint
* an overlay, just sit in the data. `multicolor` is special: its visual
* effect is to switch the colour layer to a conic-gradient (see
* `buildColorLayer`), not to add an overlay layer. */
export const EFFECT_OVERLAYS: Partial<Record<FilamentEffect, string>> = {
// Sparkle: fine bright dots scattered across the swatch.
sparkle:
'radial-gradient(circle at 30% 20%, rgba(255,255,255,0.85) 0 1px, transparent 1.5px), ' +
'radial-gradient(circle at 70% 60%, rgba(255,255,255,0.7) 0 1px, transparent 1.5px), ' +
'radial-gradient(circle at 45% 75%, rgba(255,255,255,0.6) 0 1px, transparent 1.5px), ' +
'radial-gradient(circle at 80% 30%, rgba(255,255,255,0.5) 0 1px, transparent 1.5px)',
// Wood: subtle horizontal banding to mimic grain.
wood:
'repeating-linear-gradient(90deg, ' +
'rgba(0,0,0,0.18) 0 1px, transparent 1px 6px, ' +
'rgba(0,0,0,0.08) 6px 7px, transparent 7px 12px)',
// Marble: soft diagonal swirls.
marble:
'repeating-linear-gradient(135deg, rgba(255,255,255,0.18) 0 2px, transparent 2px 8px), ' +
'repeating-linear-gradient(45deg, rgba(0,0,0,0.10) 0 1px, transparent 1px 7px)',
// Glow: bright center fade — visual hint for glow-in-the-dark filaments.
glow:
'radial-gradient(circle at 50% 50%, rgba(255,255,255,0.35) 0%, rgba(255,255,255,0) 70%)',
// Matte: very subtle inset shadow to flatten the highlight.
matte:
'linear-gradient(180deg, rgba(0,0,0,0.10) 0%, rgba(0,0,0,0) 50%, rgba(0,0,0,0.10) 100%)',
// Silk / Galaxy: diagonal sheen to suggest the lustrous finish those
// filaments have. Galaxy uses a slightly stronger highlight.
silk:
'linear-gradient(110deg, rgba(255,255,255,0) 30%, rgba(255,255,255,0.30) 50%, rgba(255,255,255,0) 70%)',
galaxy:
'linear-gradient(110deg, rgba(255,255,255,0) 25%, rgba(255,255,255,0.40) 50%, rgba(255,255,255,0) 75%)',
// Metal: brushed-metal look via tight horizontal striations + soft sheen.
metal:
'repeating-linear-gradient(90deg, rgba(255,255,255,0.10) 0 1px, transparent 1px 3px), ' +
'linear-gradient(180deg, rgba(255,255,255,0.18) 0%, rgba(0,0,0,0.18) 100%)',
};
/** Normalize a hex token (with or without `#`, 6 or 8 chars) → CSS hex string. */
export function toCssHex(token: string): string | null {
const t = token.trim().replace(/^#/, '');
if (t.length !== 6 && t.length !== 8) return null;
if (!/^[0-9a-fA-F]+$/.test(t)) return null;
return `#${t}`;
}
/** Parse extra_colors string into an array of CSS hex strings. */
export function parseStops(extra: string | null | undefined): string[] {
if (!extra) return [];
return extra
.split(',')
.map((s) => toCssHex(s))
.filter((s): s is string => Boolean(s));
}
/** Build the colour layer (gradient or solid) given rgba + stops + subtype/effect.
* A conic gradient is used when either subtype OR effect_type is `Multicolor`,
* giving the catalog editor a way to flag a multicolor variant directly. */
export function buildColorLayer(
rgba: string | null | undefined,
stops: string[],
subtype: string | null | undefined,
effectType?: string | null,
): string {
const baseHex = rgba ? toCssHex(rgba) : null;
// No stops → solid colour (or default grey when nothing is set at all).
if (stops.length === 0) {
return `linear-gradient(${baseHex ?? '#808080'}, ${baseHex ?? '#808080'})`;
}
// With stops we ignore the single rgba and gradient across the stops.
const allStops = stops.length === 1 ? [stops[0], stops[0]] : stops;
const isMulticolor =
(subtype ?? '').toLowerCase() === 'multicolor' ||
(effectType ?? '').toLowerCase() === 'multicolor';
if (isMulticolor) {
return `conic-gradient(from 0deg, ${allStops.join(', ')}, ${allStops[0]})`;
}
return `linear-gradient(135deg, ${allStops.join(', ')})`;
}
/** Public helper: produce a CSS background-image value (list of layered
* <image>s) for a filament, for callers that want to paint a banner or
* large area instead of using the swatch element. Pair with
* `background-size: cover` and the swatch logic stays consistent. */
export function buildFilamentBackground(opts: {
rgba?: string | null;
extraColors?: string | null;
effectType?: FilamentEffect | string | null;
subtype?: string | null;
}): string {
const stops = parseStops(opts.extraColors);
const colorLayer = buildColorLayer(opts.rgba, stops, opts.subtype, opts.effectType);
const effectKey =
typeof opts.effectType === 'string' && opts.effectType in EFFECT_OVERLAYS
? (opts.effectType as FilamentEffect)
: null;
const effectLayer = effectKey ? EFFECT_OVERLAYS[effectKey] ?? null : null;
return [effectLayer, colorLayer, CHECKERBOARD_BG]
.filter((layer): layer is string => Boolean(layer))
.join(', ');
}
@@ -1,8 +1,30 @@
import { useState, useMemo } from 'react';
import { Search, Clock, ChevronDown, ChevronUp } from 'lucide-react';
import { Search, Clock, ChevronDown, ChevronUp, Sparkles } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import type { ColorSectionProps, CatalogDisplayColor } from './types';
import { QUICK_COLORS, ALL_COLORS } from './constants';
import { FilamentSwatch } from '../FilamentSwatch';
import { buildFilamentBackground, FILAMENT_EFFECT_OPTIONS } from '../filamentSwatchHelpers';
/** Parse user paste from 3dfilamentprofiles.com etc.: split on commas/whitespace,
* drop the leading `#`, accept 6/8-char hex, lowercase. Returns null when no
* valid stops are found. Mirrors the server-side validator output. */
function normalizeExtraColorsInput(raw: string): { value: string; invalid: string[] } {
const tokens = raw
.split(/[\s,]+/)
.map((t) => t.trim().replace(/^#/, ''))
.filter(Boolean);
const valid: string[] = [];
const invalid: string[] = [];
for (const tok of tokens) {
if ((tok.length === 6 || tok.length === 8) && /^[0-9a-fA-F]+$/.test(tok)) {
valid.push(tok.toLowerCase());
} else {
invalid.push(tok);
}
}
return { value: valid.join(','), invalid };
}
export function ColorSection({
formData,
@@ -143,12 +165,41 @@ export function ColorSection({
return showAllColors ? ALL_COLORS : QUICK_COLORS;
}, [colorSearch, showAllColors]);
// #1154: editable buffer for the multi-colour paste field. We keep the raw
// text the user typed/pasted so they can still see invalid tokens — only
// commit the canonical form to formData on blur or when valid.
const [extraColorsDraft, setExtraColorsDraft] = useState<string>(formData.extra_colors);
const [extraColorsErrors, setExtraColorsErrors] = useState<string[]>([]);
const previewBackground = useMemo(
() =>
buildFilamentBackground({
rgba: formData.rgba,
extraColors: formData.extra_colors,
effectType: formData.effect_type,
subtype: formData.subtype,
}),
[formData.rgba, formData.extra_colors, formData.effect_type, formData.subtype],
);
const commitExtraColors = (text: string) => {
setExtraColorsDraft(text);
if (!text.trim()) {
setExtraColorsErrors([]);
updateField('extra_colors', '');
return;
}
const { value, invalid } = normalizeExtraColorsInput(text);
setExtraColorsErrors(invalid);
updateField('extra_colors', value);
};
return (
<div className="space-y-3">
{/* Color preview banner */}
{/* Color preview banner — shows gradient + effect overlay. */}
<div
className="h-10 rounded-lg border border-bambu-dark-tertiary"
style={{ backgroundColor: `#${currentHex}` }}
style={{ backgroundImage: previewBackground, backgroundSize: 'cover' }}
data-testid="color-preview-banner"
/>
{/* Recently Used Colors */}
@@ -326,6 +377,60 @@ export function ColorSection({
</div>
</div>
</div>
{/* #1154: Multi-colour gradient stops + visual effect. Optional —
empty values keep the spool rendering as a solid swatch. */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 pt-2 border-t border-bambu-dark-tertiary/50">
<div>
<label className="block text-sm font-medium text-bambu-gray mb-1">
{t('inventory.extraColorsLabel')}
</label>
<input
type="text"
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm font-mono placeholder:text-bambu-gray/50 focus:outline-none focus:border-bambu-green"
placeholder={t('inventory.extraColorsPlaceholder')}
value={extraColorsDraft}
onChange={(e) => commitExtraColors(e.target.value)}
data-testid="extra-colors-input"
/>
{extraColorsErrors.length > 0 && (
<p className="text-xs text-red-400 mt-1">
{t('inventory.extraColorsInvalid', { tokens: extraColorsErrors.join(', ') })}
</p>
)}
{!extraColorsErrors.length && (
<p className="text-xs text-bambu-gray/70 mt-1">{t('inventory.extraColorsHint')}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-bambu-gray mb-1 flex items-center gap-1.5">
<Sparkles className="w-3.5 h-3.5" />
{t('inventory.colorEffectLabel')}
</label>
<div className="flex gap-2 items-stretch">
<select
className="flex-1 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:outline-none focus:border-bambu-green"
value={formData.effect_type}
onChange={(e) => updateField('effect_type', e.target.value)}
data-testid="effect-type-select"
>
{FILAMENT_EFFECT_OPTIONS.map((opt) => (
<option key={opt.value || 'none'} value={opt.value}>
{t(opt.labelKey)}
</option>
))}
</select>
<FilamentSwatch
rgba={formData.rgba}
extraColors={formData.extra_colors}
effectType={formData.effect_type}
subtype={formData.subtype}
className="w-10 h-10"
shape="square"
/>
</div>
</div>
</div>
</div>
);
}
@@ -16,6 +16,11 @@ export interface SpoolFormData {
brand: string;
color_name: string;
rgba: string;
// #1154: extra gradient stops + visual effect. Stored as the canonical
// server form ("ec984c,6cd4bc,..." — no `#`, lowercase). Empty string means
// solid (the default).
extra_colors: string;
effect_type: string;
label_weight: number;
core_weight: number;
core_weight_catalog_id: number | null;
@@ -34,6 +39,8 @@ export const defaultFormData: SpoolFormData = {
brand: '',
color_name: '',
rgba: '808080FF',
extra_colors: '',
effect_type: '',
label_weight: 1000,
core_weight: 250,
core_weight_catalog_id: null,
+26
View File
@@ -3423,6 +3423,32 @@ export default {
showAll: 'Alle',
noColorsFound: 'Keine Farben gefunden',
noResults: 'Keine Ergebnisse',
// Multi-Color-Verlauf + visueller Effekt (#1154)
extraColorsLabel: 'Zusätzliche Farben',
extraColorsPlaceholder: 'EC984C,#6CD4BC,A66EB9,D87694',
extraColorsHint: '2 bis 8 Hex-Stops, durch Kommas getrennt. Wird als Verlauf dargestellt.',
extraColorsInvalid: 'Ungültige Hex-Werte ignoriert: {{tokens}}',
colorEffectLabel: 'Effekt',
colorEffect: {
none: 'Keiner',
// Oberflächeneffekte
sparkle: 'Glitzer',
wood: 'Holz',
marble: 'Marmor',
glow: 'Leuchtend',
matte: 'Matt',
// Glanz- / Finish-Varianten
silk: 'Seide',
galaxy: 'Galaxy',
rainbow: 'Regenbogen',
metal: 'Metallic',
translucent: 'Lichtdurchlässig',
// Mehrfarbige Varianten
gradient: 'Verlauf',
dualColor: 'Zweifarbig',
triColor: 'Dreifarbig',
multicolor: 'Mehrfarbig',
},
selectMaterialFirst: 'Bitte zuerst ein Material im Filament-Info Tab auswählen.',
noPrintersConfigured: 'Keine Drucker konfiguriert. Fügen Sie Drucker hinzu.',
matchingFilter: 'Filter',
+26
View File
@@ -3430,6 +3430,32 @@ export default {
showAll: 'Show all',
noColorsFound: 'No colors match your search',
noResults: 'No matches found',
// Multi-colour gradient + visual effect (#1154)
extraColorsLabel: 'Extra colours',
extraColorsPlaceholder: 'EC984C,#6CD4BC,A66EB9,D87694',
extraColorsHint: 'Paste 2 to 8 hex stops, separated by commas. Renders as a gradient.',
extraColorsInvalid: 'Ignored invalid hex: {{tokens}}',
colorEffectLabel: 'Effect',
colorEffect: {
none: 'None',
// Surface effects
sparkle: 'Sparkle',
wood: 'Wood',
marble: 'Marble',
glow: 'Glow',
matte: 'Matte',
// Sheen / finish variants
silk: 'Silk',
galaxy: 'Galaxy',
rainbow: 'Rainbow',
metal: 'Metal',
translucent: 'Translucent',
// Multi-colour structural variants
gradient: 'Gradient',
dualColor: 'Dual Color',
triColor: 'Tri Color',
multicolor: 'Multicolor',
},
// PA Profiles
selectMaterialFirst: 'Please select a material first in the Filament Info tab.',
noPrintersConfigured: 'No printers configured. Add printers to use PA profiles.',
+23
View File
@@ -3347,6 +3347,29 @@ export default {
showAll: 'Toutes',
noColorsFound: 'Aucune couleur correspondante',
noResults: 'Aucun résultat',
// Multi-colour gradient + visual effect (#1154) — English fallback.
extraColorsLabel: 'Extra colours',
extraColorsPlaceholder: 'EC984C,#6CD4BC,A66EB9,D87694',
extraColorsHint: 'Paste 2 to 8 hex stops, separated by commas. Renders as a gradient.',
extraColorsInvalid: 'Ignored invalid hex: {{tokens}}',
colorEffectLabel: 'Effect',
colorEffect: {
none: 'None',
sparkle: 'Sparkle',
wood: 'Wood',
marble: 'Marble',
glow: 'Glow',
matte: 'Matte',
silk: 'Silk',
galaxy: 'Galaxy',
rainbow: 'Rainbow',
metal: 'Metal',
translucent: 'Translucent',
gradient: 'Gradient',
dualColor: 'Dual Color',
triColor: 'Tri Color',
multicolor: 'Multicolor',
},
// PA Profiles
selectMaterialFirst: 'Veuillez choisir un matériau dans l\'onglet Infos Filament.',
noPrintersConfigured: 'Ajoutez une imprimante pour utiliser les profils PA.',
+23
View File
@@ -3346,6 +3346,29 @@ export default {
showAll: 'Mostra tutto',
noColorsFound: 'Nessun colore corrisponde alla ricerca',
noResults: 'Nessun risultato trovato',
// Multi-colour gradient + visual effect (#1154) — English fallback.
extraColorsLabel: 'Extra colours',
extraColorsPlaceholder: 'EC984C,#6CD4BC,A66EB9,D87694',
extraColorsHint: 'Paste 2 to 8 hex stops, separated by commas. Renders as a gradient.',
extraColorsInvalid: 'Ignored invalid hex: {{tokens}}',
colorEffectLabel: 'Effect',
colorEffect: {
none: 'None',
sparkle: 'Sparkle',
wood: 'Wood',
marble: 'Marble',
glow: 'Glow',
matte: 'Matte',
silk: 'Silk',
galaxy: 'Galaxy',
rainbow: 'Rainbow',
metal: 'Metal',
translucent: 'Translucent',
gradient: 'Gradient',
dualColor: 'Dual Color',
triColor: 'Tri Color',
multicolor: 'Multicolor',
},
// PA Profiles
selectMaterialFirst: 'Selezionare prima un materiale nella scheda Info filamento.',
noPrintersConfigured: 'Nessuna stampante configurata. Aggiungi stampanti per usare i profili PA.',
+23
View File
@@ -3385,6 +3385,29 @@ export default {
showAll: 'すべて表示',
noColorsFound: '一致する色がありません',
noResults: '結果なし',
// Multi-colour gradient + visual effect (#1154) — English fallback.
extraColorsLabel: 'Extra colours',
extraColorsPlaceholder: 'EC984C,#6CD4BC,A66EB9,D87694',
extraColorsHint: 'Paste 2 to 8 hex stops, separated by commas. Renders as a gradient.',
extraColorsInvalid: 'Ignored invalid hex: {{tokens}}',
colorEffectLabel: 'Effect',
colorEffect: {
none: 'None',
sparkle: 'Sparkle',
wood: 'Wood',
marble: 'Marble',
glow: 'Glow',
matte: 'Matte',
silk: 'Silk',
galaxy: 'Galaxy',
rainbow: 'Rainbow',
metal: 'Metal',
translucent: 'Translucent',
gradient: 'Gradient',
dualColor: 'Dual Color',
triColor: 'Tri Color',
multicolor: 'Multicolor',
},
// PA Profiles
selectMaterialFirst: 'フィラメント情報タブで素材を選択してください。',
noPrintersConfigured: 'プリンターが設定されていません。プリンターを追加してください。',
+23
View File
@@ -3360,6 +3360,29 @@ export default {
showAll: 'Mostrar tudo',
noColorsFound: 'Nenhuma cor corresponde à sua pesquisa',
noResults: 'Nenhum resultado encontrado',
// Multi-colour gradient + visual effect (#1154) — English fallback.
extraColorsLabel: 'Extra colours',
extraColorsPlaceholder: 'EC984C,#6CD4BC,A66EB9,D87694',
extraColorsHint: 'Paste 2 to 8 hex stops, separated by commas. Renders as a gradient.',
extraColorsInvalid: 'Ignored invalid hex: {{tokens}}',
colorEffectLabel: 'Effect',
colorEffect: {
none: 'None',
sparkle: 'Sparkle',
wood: 'Wood',
marble: 'Marble',
glow: 'Glow',
matte: 'Matte',
silk: 'Silk',
galaxy: 'Galaxy',
rainbow: 'Rainbow',
metal: 'Metal',
translucent: 'Translucent',
gradient: 'Gradient',
dualColor: 'Dual Color',
triColor: 'Tri Color',
multicolor: 'Multicolor',
},
// PA Profiles
selectMaterialFirst: 'Por favor, selecione um material primeiro na aba Informações do Filamento.',
noPrintersConfigured: 'Nenhuma impressora configurada. Adicione impressoras para usar perfis PA.',
+23
View File
@@ -3417,6 +3417,29 @@ export default {
showAll: '显示全部',
noColorsFound: '没有颜色匹配您的搜索',
noResults: '未找到匹配项',
// 多色渐变 + 视觉效果 (#1154)
extraColorsLabel: '附加颜色',
extraColorsPlaceholder: 'EC984C,#6CD4BC,A66EB9,D87694',
extraColorsHint: '粘贴 2 到 8 个十六进制色值,以逗号分隔。将渲染为渐变。',
extraColorsInvalid: '已忽略无效的十六进制值:{{tokens}}',
colorEffectLabel: '效果',
colorEffect: {
none: '无',
sparkle: '闪光',
wood: '木纹',
marble: '大理石',
glow: '夜光',
matte: '哑光',
silk: '丝光',
galaxy: '星空',
rainbow: '彩虹',
metal: '金属',
translucent: '半透明',
gradient: '渐变',
dualColor: '双色',
triColor: '三色',
multicolor: '多色',
},
// PA Profiles
selectMaterialFirst: '请先在耗材信息选项卡中选择材料。',
noPrintersConfigured: '未配置打印机。添加打印机以使用 PA 配置。',
+23
View File
@@ -3417,6 +3417,29 @@ export default {
showAll: '顯示全部',
noColorsFound: '沒有顏色匹配您的搜尋',
noResults: '未找到匹配項',
// 多色漸層 + 視覺效果 (#1154)
extraColorsLabel: '附加顏色',
extraColorsPlaceholder: 'EC984C,#6CD4BC,A66EB9,D87694',
extraColorsHint: '貼上 2 至 8 個十六進位色值,以逗號分隔。將以漸層方式呈現。',
extraColorsInvalid: '已略過無效的十六進位值:{{tokens}}',
colorEffectLabel: '效果',
colorEffect: {
none: '無',
sparkle: '閃光',
wood: '木紋',
marble: '大理石',
glow: '夜光',
matte: '霧面',
silk: '絲光',
galaxy: '星空',
rainbow: '彩虹',
metal: '金屬',
translucent: '半透明',
gradient: '漸層',
dualColor: '雙色',
triColor: '三色',
multicolor: '多色',
},
// PA Profiles
selectMaterialFirst: '請先在耗材資訊分頁中選擇材料。',
noPrintersConfigured: '未設定印表機。新增印表機以使用 PA 設定。',
+26 -14
View File
@@ -10,6 +10,8 @@ import {
import { api, spoolbuddyApi } from '../api/client';
import type { InventorySpool, SpoolAssignment, SpoolCatalogEntry } from '../api/client';
import { Button } from '../components/Button';
import { FilamentSwatch } from '../components/FilamentSwatch';
import { buildFilamentBackground } from '../components/filamentSwatchHelpers';
import { SpoolFormModal } from '../components/SpoolFormModal';
import { ConfirmModal } from '../components/ConfirmModal';
import { ColumnConfigModal, type ColumnConfig } from '../components/ColumnConfigModal';
@@ -30,7 +32,10 @@ type DisplayItem =
| { type: 'group'; key: string; spools: InventorySpool[]; representative: InventorySpool };
function spoolGroupKey(s: InventorySpool): string {
return `${s.material}|${s.subtype || ''}|${s.brand || ''}|${s.color_name || ''}|${s.rgba || ''}|${s.label_weight}`;
// Include extra_colors + effect_type so the "Group similar" toggle does
// not collapse two spools that share the base colour but differ on
// gradient stops or visual effect (#1154).
return `${s.material}|${s.subtype || ''}|${s.brand || ''}|${s.color_name || ''}|${s.rgba || ''}|${s.extra_colors || ''}|${s.effect_type || ''}|${s.label_weight}`;
}
// Column definitions for the inventory table
@@ -180,10 +185,11 @@ const columnCells: Record<string, (ctx: CellCtx) => ReactNode> = {
),
rgba: ({ spool }) => (
<div className="flex items-center justify-center">
<span
className="w-5 h-5 rounded-full border border-black/20 flex-shrink-0"
style={{ backgroundColor: spool.rgba ? `#${spool.rgba.substring(0, 6)}` : '#808080' }}
title={spool.rgba ? `#${spool.rgba.substring(0, 6)}` : undefined}
<FilamentSwatch
rgba={spool.rgba}
extraColors={spool.extra_colors}
effectType={spool.effect_type}
subtype={spool.subtype}
/>
</div>
),
@@ -1289,7 +1295,12 @@ function InventoryPage() {
{pagedItems.map((item) => {
if (item.type === 'group') {
const { key, spools: groupSpools, representative: rep } = item;
const colorStyle = rep.rgba ? `#${rep.rgba.substring(0, 6)}` : '#808080';
const groupBannerImage = buildFilamentBackground({
rgba: rep.rgba,
extraColors: rep.extra_colors,
effectType: rep.effect_type,
subtype: rep.subtype,
});
const isExpanded = expandedGroups.has(key);
return (
<div key={`group-${key}`} className="col-span-full">
@@ -1298,7 +1309,7 @@ function InventoryPage() {
className="bg-bambu-dark-secondary rounded-lg overflow-hidden border border-bambu-green/30 hover:border-bambu-green transition-colors cursor-pointer"
onClick={() => toggleGroupExpand(key)}
>
<div className="h-10 flex items-center px-4 gap-3" style={{ backgroundColor: colorStyle }}>
<div className="h-10 flex items-center px-4 gap-3" style={{ backgroundImage: groupBannerImage, backgroundSize: 'cover' }}>
<span className="bg-white/90 text-gray-800 px-3 py-0.5 rounded-full text-sm font-medium">
{resolveSpoolColorName(rep.color_name, rep.rgba) || '-'}
</span>
@@ -1325,14 +1336,12 @@ function InventoryPage() {
{groupSpools.map((spool) => {
const remaining = Math.max(0, spool.label_weight - spool.weight_used);
const pct = spool.label_weight > 0 ? (remaining / spool.label_weight) * 100 : 0;
const spoolColor = spool.rgba ? `#${spool.rgba.substring(0, 6)}` : '#808080';
return (
<SpoolCard
key={spool.id}
spool={spool}
remaining={remaining}
pct={pct}
colorStyle={spoolColor}
onClick={() => setFormModal({ spool })}
t={t}
/>
@@ -1346,14 +1355,12 @@ function InventoryPage() {
const spool = item.spool;
const remaining = Math.max(0, spool.label_weight - spool.weight_used);
const pct = spool.label_weight > 0 ? (remaining / spool.label_weight) * 100 : 0;
const colorStyle = spool.rgba ? `#${spool.rgba.substring(0, 6)}` : '#808080';
return (
<SpoolCard
key={spool.id}
spool={spool}
remaining={remaining}
pct={pct}
colorStyle={colorStyle}
onClick={() => setFormModal({ spool })}
t={t}
/>
@@ -1662,21 +1669,26 @@ function PaginationBar({
/* Spool card for cards view */
function SpoolCard({
spool, remaining, pct, colorStyle, onClick, t,
spool, remaining, pct, onClick, t,
}: {
spool: InventorySpool;
remaining: number;
pct: number;
colorStyle: string;
onClick: () => void;
t: (key: string, opts?: Record<string, unknown>) => string;
}) {
const bannerImage = buildFilamentBackground({
rgba: spool.rgba,
extraColors: spool.extra_colors,
effectType: spool.effect_type,
subtype: spool.subtype,
});
return (
<div
className={`bg-bambu-dark-secondary rounded-lg overflow-hidden border border-bambu-dark-tertiary hover:border-bambu-green transition-colors cursor-pointer ${spool.archived_at ? 'opacity-50' : ''}`}
onClick={onClick}
>
<div className="h-14 flex items-center justify-center" style={{ backgroundColor: colorStyle }}>
<div className="h-14 flex items-center justify-center" style={{ backgroundImage: bannerImage, backgroundSize: 'cover' }}>
<span className="bg-white/90 text-gray-800 px-3 py-0.5 rounded-full text-sm font-medium">
{resolveSpoolColorName(spool.color_name, spool.rgba) || '-'}
</span>
@@ -287,6 +287,8 @@ export function SpoolBuddyDashboard() {
subtype: null,
color_name: null,
rgba: null,
extra_colors: null,
effect_type: null,
brand: null,
label_weight: 1000,
core_weight: 250,
@@ -637,6 +637,8 @@ function NewSpoolTouchForm({ currencySymbol, onCreated, selectedSpool, t }: {
brand: formData.brand || null,
color_name: formData.color_name || null,
rgba: formData.rgba || null,
extra_colors: formData.extra_colors || null,
effect_type: formData.effect_type || null,
label_weight: formData.label_weight,
core_weight: formData.core_weight,
core_weight_catalog_id: formData.core_weight_catalog_id,
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -26,7 +26,7 @@
<!-- Splash screens for iOS -->
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
<script type="module" crossorigin src="/assets/index-8qP2vyrm.js"></script>
<script type="module" crossorigin src="/assets/index-DE-w2t-x.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-7GmlJb0k.css">
</head>
<body>