feat: global material override on OutputType for x-ray/clay render modes

- Add `material_override` nullable column on OutputType (DB migration)
- When set, ALL product parts get rendered with this single material
- Override applies after alias resolution in render_order_line task
- Admin UI: dropdown in OutputType table to select a library material
- Display: amber badge showing active override material name

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-14 13:16:00 +01:00
parent b6bac080bb
commit 7c606953ec
6 changed files with 87 additions and 2 deletions
@@ -0,0 +1,30 @@
"""add material_override to output_types
Revision ID: cfcc7ad1e7d5
Revises: 4c15abf3cf40
Create Date: 2026-03-14 12:12:03.706587
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'cfcc7ad1e7d5'
down_revision: Union[str, None] = '4c15abf3cf40'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('output_types', sa.Column('material_override', sa.String(length=200), nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('output_types', 'material_override')
# ### end Alembic commands ###
@@ -198,6 +198,12 @@ def render_order_line_task(self, order_line_id: str):
from app.services.material_service import resolve_material_map
material_map = resolve_material_map(material_map)
# Apply global material override from OutputType (e.g. x-ray mode)
if line.output_type and line.output_type.material_override:
override_mat = line.output_type.material_override
material_map = {k: override_mat for k in material_map}
emit(order_line_id, f"Material override active: all parts → {override_mat}")
if template:
emit(order_line_id, f"Using render template: {template.name} (collection={template.target_collection}, material_replace={template.material_replace_enabled}, lighting_only={template.lighting_only})")
logger.info(f"Render template resolved: '{template.name}' path={template.blend_file_path}, lighting_only={template.lighting_only}")
+2
View File
@@ -44,6 +44,8 @@ class OutputType(Base):
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False
)
material_override: Mapped[str | None] = mapped_column(String(200), nullable=True, default=None)
workflow_definition_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("workflow_definitions.id", ondelete="SET NULL"), nullable=True
)
+3
View File
@@ -17,6 +17,7 @@ class OutputTypeCreate(BaseModel):
transparent_bg: bool = False
pricing_tier_id: int | None = None
cycles_device: str | None = None
material_override: str | None = None
class OutputTypePatch(BaseModel):
@@ -34,6 +35,7 @@ class OutputTypePatch(BaseModel):
pricing_tier_id: int | None = None
cycles_device: str | None = None
workflow_definition_id: uuid.UUID | None = None
material_override: str | None = None
class OutputTypeOut(BaseModel):
@@ -54,6 +56,7 @@ class OutputTypeOut(BaseModel):
price_per_item: float | None = None
workflow_definition_id: uuid.UUID | None = None
workflow_name: str | None = None
material_override: str | None = None
is_active: bool
created_at: datetime
updated_at: datetime
+1
View File
@@ -17,6 +17,7 @@ export interface OutputType {
pricing_tier_name: string | null
price_per_item: number | null
workflow_definition_id: string | null
material_override: string | null
is_active: boolean
created_at: string
updated_at: string
@@ -6,6 +6,8 @@ import {
listOutputTypes, createOutputType, updateOutputType, deleteOutputType,
} from '../../api/outputTypes'
import type { OutputType } from '../../api/outputTypes'
import { listMaterials } from '../../api/materials'
import type { Material } from '../../api/materials'
import { listPricingTiers } from '../../api/pricing'
import type { PricingTier } from '../../api/pricing'
import { getWorkflows } from '../../api/workflows'
@@ -22,7 +24,7 @@ const ALL_CATEGORIES = [
{ key: 'Linear_schiene', label: 'Linear' },
{ key: 'Anschlagplatten', label: 'Anschlag' },
]
const EMPTY_FORM ={ name: '', description: '', renderer: 'threejs', output_format: 'png', sort_order: 0, compatible_categories: [] as string[], render_backend: 'auto', is_animation: false, transparent_bg: false, cycles_device: '' as string, pricing_tier_id: null as number | null, width: '', height: '', engine: '', samples: '', frame_count: '', fps: '', turntable_axis: 'world_z', bg_color: '', noise_threshold: '', denoiser: '', denoising_input_passes: '', denoising_prefilter: '', denoising_quality: '', denoising_use_gpu: '' }
const EMPTY_FORM ={ name: '', description: '', renderer: 'threejs', output_format: 'png', sort_order: 0, compatible_categories: [] as string[], render_backend: 'auto', is_animation: false, transparent_bg: false, cycles_device: '' as string, pricing_tier_id: null as number | null, material_override: '' as string, width: '', height: '', engine: '', samples: '', frame_count: '', fps: '', turntable_axis: 'world_z', bg_color: '', noise_threshold: '', denoiser: '', denoising_input_passes: '', denoising_prefilter: '', denoising_quality: '', denoising_use_gpu: '' }
export default function OutputTypeTable() {
const qc = useQueryClient()
@@ -41,6 +43,12 @@ export default function OutputTypeTable() {
queryFn: listPricingTiers,
})
const { data: allMaterials } = useQuery({
queryKey: ['materials'],
queryFn: listMaterials,
})
const libraryMaterials = (allMaterials ?? []).filter((m: Material) => m.schaeffler_code !== null).sort((a: Material, b: Material) => a.name.localeCompare(b.name))
const { data: workflows } = useQuery({
queryKey: ['workflows'],
queryFn: getWorkflows,
@@ -88,8 +96,9 @@ export default function OutputTypeTable() {
transparent_bg: form.transparent_bg,
cycles_device: form.cycles_device || null,
pricing_tier_id: form.pricing_tier_id,
material_override: form.material_override || null,
render_settings: Object.keys(rs).length > 0 ? rs : {},
})
} as Partial<OutputType>)
},
onSuccess: () => {
toast.success('Output type created')
@@ -229,6 +238,7 @@ export default function OutputTypeTable() {
<th className="px-4 py-2 font-medium text-content-secondary" title="Output resolution in pixels (width × height); leave empty to use global default">Resolution</th>
<th className="px-4 py-2 font-medium text-content-secondary" title="Pricing tier used to calculate the per-item cost for this output type">Pricing</th>
<th className="px-4 py-2 font-medium text-content-secondary" title="Workflow definition assigned to this output type">Workflow</th>
<th className="px-4 py-2 font-medium text-content-secondary" title="Material override — apply a single material to ALL product parts (e.g. x-ray, clay render)">Mat Override</th>
<th className="px-4 py-2 font-medium text-content-secondary" title="Sort order — lower numbers appear first in the wizard output-type picker">Sort</th>
<th className="px-4 py-2 font-medium text-content-secondary" title="Active — inactive types are hidden from the order wizard">Active</th>
<th className="px-4 py-2 font-medium text-content-secondary">Actions</th>
@@ -532,6 +542,18 @@ export default function OutputTypeTable() {
))}
</select>
</td>
<td className="px-4 py-2">
<select
className="input-sm text-xs"
value={editDraft.material_override ?? ot.material_override ?? ''}
onChange={(e) => setEditDraft({ ...editDraft, material_override: e.target.value || null })}
>
<option value=""> None </option>
{libraryMaterials.map((m: Material) => (
<option key={m.id} value={m.name}>{m.name}</option>
))}
</select>
</td>
<td className="px-4 py-2">
<input
type="number"
@@ -719,6 +741,15 @@ export default function OutputTypeTable() {
)
})()}
</td>
<td className="px-4 py-2">
{ot.material_override ? (
<span className="text-xs px-1.5 py-0.5 rounded bg-amber-50 text-amber-700 font-mono truncate block max-w-[140px]" title={ot.material_override}>
{ot.material_override.replace('SCHAEFFLER_', '').replace(/_/g, ' ')}
</span>
) : (
<span className="text-xs text-content-muted"></span>
)}
</td>
<td className="px-4 py-2 text-content-muted">{ot.sort_order}</td>
<td className="px-4 py-2">
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${
@@ -1029,6 +1060,18 @@ export default function OutputTypeTable() {
</select>
</td>
<td className="px-4 py-2 text-content-muted"></td>
<td className="px-4 py-2">
<select
className="input-sm text-xs"
value={form.material_override}
onChange={(e) => setForm({ ...form, material_override: e.target.value })}
>
<option value=""> None </option>
{libraryMaterials.map((m: Material) => (
<option key={m.id} value={m.name}>{m.name}</option>
))}
</select>
</td>
<td className="px-4 py-2">
<input
type="number"