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
+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"