Added backend support for multi-color filaments

This commit is contained in:
Donkie
2024-05-28 18:49:24 +02:00
parent 91f33ae9b2
commit 8c164ac55f
6 changed files with 244 additions and 15 deletions

View File

@@ -0,0 +1,31 @@
"""multi_colors.
Revision ID: 415a8f855e14
Revises: 395d560284b3
Create Date: 2024-05-28 18:46:12.935449
"""
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "415a8f855e14"
down_revision = "395d560284b3"
branch_labels = None
depends_on = None
def upgrade() -> None:
"""Perform the upgrade."""
# ### commands auto generated by Alembic - please adjust! ###
op.add_column("filament", sa.Column("multi_color_hexes", sa.String(length=128), nullable=True))
op.add_column("filament", sa.Column("multi_color_direction", sa.String(length=16), nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
"""Perform the downgrade."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column("filament", "multi_color_direction")
op.drop_column("filament", "multi_color_hexes")
# ### end Alembic commands ###

View File

@@ -7,10 +7,10 @@ from typing import Annotated, Optional
from fastapi import APIRouter, Depends, Query, WebSocket, WebSocketDisconnect from fastapi import APIRouter, Depends, Query, WebSocket, WebSocketDisconnect
from fastapi.encoders import jsonable_encoder from fastapi.encoders import jsonable_encoder
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field, field_validator from pydantic import BaseModel, Field, field_validator, model_validator
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from spoolman.api.v1.models import Filament, FilamentEvent, Message from spoolman.api.v1.models import Filament, FilamentEvent, Message, MultiColorDirection
from spoolman.database import filament from spoolman.database import filament
from spoolman.database.database import get_db_session from spoolman.database.database import get_db_session
from spoolman.database.utils import SortOrder from spoolman.database.utils import SortOrder
@@ -86,9 +86,26 @@ class FilamentParameters(BaseModel):
) )
color_hex: Optional[str] = Field( color_hex: Optional[str] = Field(
None, None,
description="Hexadecimal color code of the filament, e.g. FF0000 for red. Supports alpha channel at the end.", description=(
"Hexadecimal color code of the filament, e.g. FF0000 for red. Supports alpha channel at the end. "
"If it's a multi-color filament, the multi_color_hexes field is used instead."
),
examples=["FF0000"], examples=["FF0000"],
) )
multi_color_hexes: Optional[str] = Field(
None,
description=(
"Hexadecimal color code of the filament, e.g. FF0000 for red. Supports alpha channel at the end. "
"Specifying multiple colors separated by commas. "
"Also set the multi_color_direction field if you specify multiple colors."
),
examples=["FF0000,00FF00,0000FF"],
)
multi_color_direction: Optional[MultiColorDirection] = Field(
None,
description=("Type of multi-color filament. Only set if the color_hex field contains multiple colors. "),
examples=["coaxial", "longitudinal"],
)
external_id: Optional[str] = Field( external_id: Optional[str] = Field(
None, None,
max_length=256, max_length=256,
@@ -104,24 +121,58 @@ class FilamentParameters(BaseModel):
@field_validator("color_hex") @field_validator("color_hex")
@classmethod @classmethod
@classmethod
def color_hex_validator(cls, v: Optional[str]) -> Optional[str]: # noqa: ANN102 def color_hex_validator(cls, v: Optional[str]) -> Optional[str]: # noqa: ANN102
"""Validate the color_hex field.""" """Validate the color_hex field."""
if not v: if not v:
return None return None
if v.startswith("#"):
v = v[1:]
v = v.upper()
for c in v: clr = v.upper()
if clr.startswith("#"):
clr = clr[1:]
for c in clr:
if c not in "0123456789ABCDEF": if c not in "0123456789ABCDEF":
raise ValueError("Invalid character in color code.") raise ValueError("Invalid character in color code.")
if len(v) not in (6, 8): if len(clr) not in (6, 8):
raise ValueError("Color code must be 6 or 8 characters long.") raise ValueError("Color code must be 6 or 8 characters long.")
return v return v
@field_validator("multi_color_hexes")
@classmethod
def multi_color_hexes_validator(cls, v: Optional[str]) -> Optional[str]: # noqa: ANN102
"""Validate the multi_color_hexes field."""
if not v:
return None
for clr_raw in v.split(","):
clr = clr_raw.upper()
if clr.startswith("#"):
clr = clr[1:]
for c in clr:
if c not in "0123456789ABCDEF":
raise ValueError("Invalid character in color code.")
if len(clr) not in (6, 8):
raise ValueError("Color code must be 6 or 8 characters long.")
return v
@model_validator(mode="after") # type: ignore[]
def validate(self) -> "FilamentParameters":
"""Validate the model."""
if self.color_hex and self.multi_color_hexes:
raise ValueError("Cannot specify both color_hex and multi_color_hexes.")
if self.multi_color_hexes and len(self.multi_color_hexes.split(",")) < 2: # noqa: PLR2004
raise ValueError("Must specify at least two colors in multi_color_hexes.")
if self.multi_color_hexes and not self.multi_color_direction:
raise ValueError("Multi-color filament must have multi_color_direction set.")
if not self.multi_color_hexes and self.multi_color_direction:
raise ValueError("Single-color filament must not have multi_color_direction set.")
return self
class FilamentUpdateParameters(FilamentParameters): class FilamentUpdateParameters(FilamentParameters):
density: Optional[float] = Field(None, gt=0, description="The density of this filament in g/cm3.", examples=[1.24]) density: Optional[float] = Field(None, gt=0, description="The density of this filament in g/cm3.", examples=[1.24])
@@ -394,6 +445,8 @@ async def create( # noqa: ANN201
settings_extruder_temp=body.settings_extruder_temp, settings_extruder_temp=body.settings_extruder_temp,
settings_bed_temp=body.settings_bed_temp, settings_bed_temp=body.settings_bed_temp,
color_hex=body.color_hex, color_hex=body.color_hex,
multi_color_hexes=body.multi_color_hexes,
multi_color_direction=body.multi_color_direction,
external_id=body.external_id, external_id=body.external_id,
extra=body.extra, extra=body.extra,
) )

View File

@@ -93,6 +93,13 @@ class Vendor(BaseModel):
) )
class MultiColorDirection(Enum):
"""Enum for multi-color direction."""
COAXIAL = "coaxial"
LONGITUDINAL = "longitudinal"
class Filament(BaseModel): class Filament(BaseModel):
id: int = Field(description="Unique internal ID of this filament type.") id: int = Field(description="Unique internal ID of this filament type.")
registered: SpoolmanDateTime = Field(description="When the filament was registered in the database. UTC Timezone.") registered: SpoolmanDateTime = Field(description="When the filament was registered in the database. UTC Timezone.")
@@ -155,9 +162,27 @@ class Filament(BaseModel):
None, None,
min_length=6, min_length=6,
max_length=8, max_length=8,
description="Hexadecimal color code of the filament, e.g. FF0000 for red. Supports alpha channel at the end.", description=(
"Hexadecimal color code of the filament, e.g. FF0000 for red. Supports alpha channel at the end. "
"If it's a multi-color filament, the multi_color_hexes field is used instead."
),
examples=["FF0000"], examples=["FF0000"],
) )
multi_color_hexes: Optional[str] = Field(
None,
min_length=6,
description=(
"Hexadecimal color code of the filament, e.g. FF0000 for red. Supports alpha channel at the end. "
"Specifying multiple colors separated by commas. "
"Also set the multi_color_direction field if you specify multiple colors."
),
examples=["FF0000,00FF00,0000FF"],
)
multi_color_direction: Optional[MultiColorDirection] = Field(
None,
description=("Type of multi-color filament. Only set if the multi_color_hexes field is set."),
examples=["coaxial", "longitudinal"],
)
external_id: Optional[str] = Field( external_id: Optional[str] = Field(
None, None,
max_length=256, max_length=256,
@@ -192,6 +217,10 @@ class Filament(BaseModel):
settings_extruder_temp=item.settings_extruder_temp, settings_extruder_temp=item.settings_extruder_temp,
settings_bed_temp=item.settings_bed_temp, settings_bed_temp=item.settings_bed_temp,
color_hex=item.color_hex, color_hex=item.color_hex,
multi_color_hexes=item.multi_color_hexes,
multi_color_direction=(
MultiColorDirection(item.multi_color_direction) if item.multi_color_direction is not None else None
),
external_id=item.external_id, external_id=item.external_id,
extra={field.key: field.value for field in item.extra}, extra={field.key: field.value for field in item.extra},
) )

View File

@@ -11,7 +11,7 @@ from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import contains_eager, joinedload from sqlalchemy.orm import contains_eager, joinedload
from spoolman.api.v1.models import EventType, Filament, FilamentEvent from spoolman.api.v1.models import EventType, Filament, FilamentEvent, MultiColorDirection
from spoolman.database import models, vendor from spoolman.database import models, vendor
from spoolman.database.utils import ( from spoolman.database.utils import (
SortOrder, SortOrder,
@@ -42,6 +42,8 @@ async def create(
settings_extruder_temp: Optional[int] = None, settings_extruder_temp: Optional[int] = None,
settings_bed_temp: Optional[int] = None, settings_bed_temp: Optional[int] = None,
color_hex: Optional[str] = None, color_hex: Optional[str] = None,
multi_color_hexes: Optional[str] = None,
multi_color_direction: Optional[MultiColorDirection] = None,
external_id: Optional[str] = None, external_id: Optional[str] = None,
extra: Optional[dict[str, str]] = None, extra: Optional[dict[str, str]] = None,
) -> models.Filament: ) -> models.Filament:
@@ -68,6 +70,8 @@ async def create(
settings_extruder_temp=settings_extruder_temp, settings_extruder_temp=settings_extruder_temp,
settings_bed_temp=settings_bed_temp, settings_bed_temp=settings_bed_temp,
color_hex=color_hex, color_hex=color_hex,
multi_color_hexes=multi_color_hexes,
multi_color_direction=multi_color_direction.value if multi_color_direction is not None else None,
external_id=external_id, external_id=external_id,
extra=[models.FilamentField(key=k, value=v) for k, v in (extra or {}).items()], extra=[models.FilamentField(key=k, value=v) for k, v in (extra or {}).items()],
) )
@@ -231,11 +235,18 @@ async def find_by_color(
found_filaments: list[models.Filament] = [] found_filaments: list[models.Filament] = []
for filament in filaments: for filament in filaments:
if filament.color_hex is None: if filament.color_hex is not None:
colors = [filament.color_hex]
elif filament.multi_color_hexes is not None:
colors = filament.multi_color_hexes.split(",")
else:
continue continue
color_lab = rgb_to_lab(hex_to_rgb(filament.color_hex))
if delta_e(color_query_lab, color_lab) <= similarity_threshold: for color in colors:
found_filaments.append(filament) color_lab = rgb_to_lab(hex_to_rgb(color))
if delta_e(color_query_lab, color_lab) <= similarity_threshold:
found_filaments.append(filament)
break
return found_filaments return found_filaments

View File

@@ -49,6 +49,8 @@ class Filament(Base):
settings_extruder_temp: Mapped[Optional[int]] = mapped_column(comment="Overridden extruder temperature.") settings_extruder_temp: Mapped[Optional[int]] = mapped_column(comment="Overridden extruder temperature.")
settings_bed_temp: Mapped[Optional[int]] = mapped_column(comment="Overridden bed temperature.") settings_bed_temp: Mapped[Optional[int]] = mapped_column(comment="Overridden bed temperature.")
color_hex: Mapped[Optional[str]] = mapped_column(String(8)) color_hex: Mapped[Optional[str]] = mapped_column(String(8))
multi_color_hexes: Mapped[Optional[str]] = mapped_column(String(128))
multi_color_direction: Mapped[Optional[str]] = mapped_column(String(16))
external_id: Mapped[Optional[str]] = mapped_column(String(256)) external_id: Mapped[Optional[str]] = mapped_column(String(256))
extra: Mapped[list["FilamentField"]] = relationship( extra: Mapped[list["FilamentField"]] = relationship(
back_populates="filament", back_populates="filament",

View File

@@ -128,3 +128,106 @@ def test_add_filament_color_hex_alpha():
# Clean up # Clean up
httpx.delete(f"{URL}/api/v1/filament/{filament['id']}").raise_for_status() httpx.delete(f"{URL}/api/v1/filament/{filament['id']}").raise_for_status()
def test_add_filament_multi_color():
"""Test adding a filament with multi color hexes."""
multi_color_hexes = "FF0000,00FF00"
multi_color_direction = "coaxial"
# Execute
result = httpx.post(
f"{URL}/api/v1/filament",
json={
"density": 1.25,
"diameter": 1.75,
"multi_color_hexes": multi_color_hexes,
"multi_color_direction": multi_color_direction,
},
)
result.raise_for_status()
# Verify
filament = result.json()
assert filament["multi_color_hexes"] == multi_color_hexes
assert filament["multi_color_direction"] == multi_color_direction
# Clean up
httpx.delete(f"{URL}/api/v1/filament/{filament['id']}").raise_for_status()
def test_add_filament_multi_color_errors():
"""Test adding a filament with multi color hexes with errors."""
# Bad hex color list
# Execute
result = httpx.post(
f"{URL}/api/v1/filament",
json={
"density": 1.25,
"diameter": 1.75,
"multi_color_hexes": "FF0000,", # Bad 2nd color
"multi_color_direction": "coaxial",
},
)
# Verify
assert result.status_code == 422
# Missing multi_color_hexes but direction is specified
# Execute
result = httpx.post(
f"{URL}/api/v1/filament",
json={
"density": 1.25,
"diameter": 1.75,
"multi_color_direction": "coaxial",
},
)
# Verify
assert result.status_code == 422
# multi_color_hexes is specified but direction is not
# Execute
result = httpx.post(
f"{URL}/api/v1/filament",
json={
"density": 1.25,
"diameter": 1.75,
"multi_color_hexes": "FF0000,00FF00",
},
)
# Verify
assert result.status_code == 422
# multi_color_hexes only has a single color
# Execute
result = httpx.post(
f"{URL}/api/v1/filament",
json={
"density": 1.25,
"diameter": 1.75,
"multi_color_hexes": "FF0000",
"multi_color_direction": "coaxial",
},
)
# Verify
assert result.status_code == 422
# Both multi_color_hexes and color_hex is specified
# Execute
result = httpx.post(
f"{URL}/api/v1/filament",
json={
"density": 1.25,
"diameter": 1.75,
"color_hex": "FF0000",
"multi_color_hexes": "FF0000,00FF00",
"multi_color_direction": "coaxial",
},
)
# Verify
assert result.status_code == 422