Skip to content
Merged
85 changes: 84 additions & 1 deletion src/components/widgets/afc/dialogs/AfcPrintStartDialog.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
v-model="open"
:title="$t('app.afc.PrintStartDialog.Title')"
width="600"
:save-button-text="$t('app.general.btn.print')"
@save="handlePrint"
>
<!-- Thumbnail preview -->
Expand Down Expand Up @@ -41,6 +40,35 @@
:class="{ 'border-top': index > 0 }"
/>
</v-card-text>

<template #actions>
Comment thread
pedrolamas marked this conversation as resolved.
Resolved
<app-btn
text
color="primary"
type="button"
:disabled="!currentFile || !usedTools.length"
@click="handleAutoMap"
Comment thread
ghzserg marked this conversation as resolved.
Resolved
>
{{ $t('app.afc.PrintStartDialog.AutoMap') }}
</app-btn>

<v-spacer />

<app-btn
text
color="warning"
type="button"
@click="open = false"
>
{{ $t('app.general.btn.cancel') }}
</app-btn>
<app-btn
color="primary"
type="submit"
>
{{ $t('app.general.btn.print') }}
</app-btn>
</template>
</app-dialog>
</template>

Expand All @@ -52,6 +80,10 @@ import FilesMixin from '@/mixins/files'
import { SocketActions } from '@/api/socketActions'
import getFilePaths from '@/util/get-file-paths'
import type { AppFileWithMeta } from '@/store/files/types'
import { encodeGcodeParamValue } from '@/util/gcode-helpers'
import { autoMap, type AutoMapLane, type AutoMapTool } from '@/util/afc-auto-map'
import { EventBus } from '@/eventBus'

@Component({
components: {
AfcPrintStartDialogTool: () => import('./AfcPrintStartDialogTool.vue')
Expand Down Expand Up @@ -122,6 +154,57 @@ export default class AfcPrintStartDialog extends Mixins(StateMixin, AfcMixin, Fi
}, [])
}

// Map used tools to loaded lanes with the lowest total cost (see util/afc-auto-map)
handleAutoMap () {
const file = this.currentFile
if (!file || !this.usedTools.length) return

const isMapped = (lane: string, tool: number) => {
const map = this.getAfcLaneObject(lane)?.map
return (Array.isArray(map) ? map : [map]).some(t => t?.toLowerCase() === `t${tool}`)
}

const tools: AutoMapTool[] = this.usedTools.map(tool => ({
tool,
color: file.filament_colors?.[tool],
material: file.filament_type?.[tool],
weight: file.filament_weights?.[tool]
}))

const lanes: AutoMapLane[] = this.afcLanes
.map(lane => ({ lane, info: this.getAfcLaneInfo(lane) }))
.filter(({ info }) => info.filamentLoaded)
.map(({ lane, info }) => ({
lane,
color: info.color,
material: info.material,
weight: info.remainingWeight,
current: this.usedTools.reduce<boolean[]>((acc, tool) => { acc[tool] = isMapped(lane, tool); return acc }, [])
}))

if (!lanes.length) {
EventBus.$emit(this.$t('app.afc.PrintStartDialog.AutoMapNoLanes').toString(), { type: 'warning' })
return
}

const mapping = autoMap(tools, lanes)
const unmapped = tools.filter(({ tool }) => !mapping.has(tool)).map(({ tool }) => `T${tool}`)

const gcode = [...mapping]
.filter(([tool, lane]) => !isMapped(lane, tool))
Comment thread
ghzserg marked this conversation as resolved.
Resolved
.map(([tool, lane]) => `SET_MAP LANE=${encodeGcodeParamValue(lane)} MAP=${encodeGcodeParamValue(`T${tool}`)}`)

if (gcode.length) {
this.sendGcode(gcode.join('\n'))
}
Comment thread
ghzserg marked this conversation as resolved.
Resolved

if (unmapped.length) {
EventBus.$emit(this.$t('app.afc.PrintStartDialog.AutoMapUnmapped', { tools: unmapped.join(', ') }).toString(), { type: 'warning' })
} else if (!gcode.length) {
EventBus.$emit(this.$t('app.afc.PrintStartDialog.AutoMapUnchanged').toString(), { type: 'success' })
Comment thread
ghzserg marked this conversation as resolved.
Resolved
}
}

handlePrint () {
if (this.filename) {
SocketActions.printerPrintStart(this.filename)
Expand Down
4 changes: 4 additions & 0 deletions src/locales/en.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@ app:
PrepDetected: "Filament detected but not loaded"
Printing: "Printing"
PrintStartDialog:
AutoMap: "Auto-map"
AutoMapNoLanes: "No lane with filament loaded"
AutoMapUnchanged: "Lanes already match the file"
AutoMapUnmapped: "No loaded lane left for {tools}"
StartJob: "Start Job"
Title: "AFC Lane Assignment"
NoLane: "No Lane"
Expand Down
76 changes: 76 additions & 0 deletions src/util/__tests__/afc-auto-map.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { autoMap, hexToLab, type AutoMapLane, type AutoMapTool } from '../afc-auto-map'

const lane = (name: string, color: string | undefined, material = 'PLA', current: number[] = [], weight = 1000): AutoMapLane => ({
lane: name,
color,
material,
weight,
current: current.reduce<boolean[]>((acc, t) => { acc[t] = true; return acc }, [])
})

const tool = (t: number, color: string | undefined, material: string | undefined = 'PLA', weight = 10): AutoMapTool =>
({ tool: t, color, material, weight })

const result = (map: Map<number, string>) => Object.fromEntries(map)

describe('hexToLab', () => {
it.each(['#FF0000', 'FF0000', 'ff0000'])('accepts %s', hex => {
expect(hexToLab(hex)).toBeDefined()
})

it.each([undefined, '', '#', '#red', 'FF000'])('rejects %s', hex => {
expect(hexToLab(hex)).toBeUndefined()
})
})

describe('autoMap', () => {
it('finds the minimal total assignment, not the greedy one', () => {
// greys: T0 is closest to A, but T1 needs A much more than T0 does
// greedy: T0→A, T1→B; minimal total: T0→B, T1→A
const tools = [tool(0, '#7F7F7F'), tool(1, '#707070')]
const lanes = [lane('A', '#7C7C7C'), lane('B', '#959595')]
expect(result(autoMap(tools, lanes))).toEqual({ 0: 'B', 1: 'A' })
})

it('prefers a material match over a closer color', () => {
const map = autoMap([tool(0, '#FF0000', 'PETG')], [lane('A', '#FF0000', 'PLA'), lane('B', '#00FF00', 'PETG')])
expect(result(map)).toEqual({ 0: 'B' })
})

it('treats unknown material as a mismatch, like the dialog warning', () => {
// the PLA tool must keep the only PLA lane; the untyped tool takes the other one
const map = autoMap(
[tool(0, '#FF0000', 'PLA'), tool(1, '#FF0000', '')],
[lane('A', '#FF0000', 'PLA'), lane('B', '#00FF00', 'PETG')]
)
expect(result(map)).toEqual({ 0: 'A', 1: 'B' })
})

it('prefers a lane with enough filament over a slightly closer color', () => {
const map = autoMap(
[tool(0, '#FF0000', 'PLA', 300)],
[lane('A', '#FF0000', 'PLA', [], 100), lane('B', '#F00000', 'PLA', [], 1000)]
)
expect(result(map)).toEqual({ 0: 'B' })
})

it('keeps the current lane when color data is missing', () => {
const map = autoMap([tool(0, undefined)], [lane('A', '#FF0000'), lane('B', '#00FF00', 'PLA', [0])])
expect(result(map)).toEqual({ 0: 'B' })
})

it('treats invalid lane colors as missing', () => {
const map = autoMap([tool(0, '#000000')], [lane('A', '#red'), lane('B', '#101010')])
expect(result(map)).toEqual({ 0: 'B' })
})

it('leaves extra tools unmapped when there are more tools than lanes', () => {
const map = autoMap([tool(0, '#FF0000'), tool(1, '#00FF00')], [lane('A', '#00FF00')])
expect(result(map)).toEqual({ 1: 'A' })
})

it('returns nothing without tools or lanes', () => {
expect(autoMap([], [lane('A', '#FF0000')]).size).toBe(0)
expect(autoMap([tool(0, '#FF0000')], []).size).toBe(0)
})
})
127 changes: 127 additions & 0 deletions src/util/afc-auto-map.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import { TinyColor } from '@ctrl/tinycolor'

export interface AutoMapTool {
tool: number
color?: string
material?: string
weight?: number
}

export interface AutoMapLane {
lane: string
color?: string
material?: string
weight?: number
current: boolean[] // per tool index: is this tool already mapped to the lane
}

// Cost of a tool on a lane, compared in order:
// material mismatch, not enough filament, color distance (ΔE), not the current lane
type Cost = [number, number, number, number]

// Larger than any CIE Lab ΔE (max ≈ 375), used when a color is missing or invalid
const NO_COLOR_DISTANCE = 1000

// Leaving a tool unmapped is worse than any material mismatch
const SKIP_COST: Cost = [2, 0, 0, 0]

// Exact assignment is tools × 2^lanes; above this, fall back to the greedy pass
const MAX_EXACT_LANES = 12

// sRGB hex -> CIE Lab (D65); undefined for missing or invalid colors
export const hexToLab = (hex?: string): [number, number, number] | undefined => {
if (!hex) return undefined
const color = new TinyColor(hex)
if (!color.isValid) return undefined
const { r, g, b } = color.toRgb()
const lin = (c: number) => (c /= 255) > 0.04045 ? ((c + 0.055) / 1.055) ** 2.4 : c / 12.92
const [lr, lg, lb] = [lin(r), lin(g), lin(b)]
const f = (t: number) => t > 0.008856 ? Math.cbrt(t) : 7.787 * t + 16 / 116
const x = f((lr * 0.4124 + lg * 0.3576 + lb * 0.1805) / 0.95047)
const y = f(lr * 0.2126 + lg * 0.7152 + lb * 0.0722)
const z = f((lr * 0.0193 + lg * 0.1192 + lb * 0.9505) / 1.08883)
return [116 * y - 16, 500 * (x - y), 200 * (y - z)]
}

// Same rule as the dialog's material warning: unknown on either side is a mismatch
const isMaterialMismatch = (file?: string, lane?: string) => {
const fileMaterial = (file || '--').toLowerCase()
const laneMaterial = (lane || '--').toLowerCase()
return fileMaterial === '--' || laneMaterial === '--' || !laneMaterial.includes(fileMaterial)
}

const laneCost = (tool: AutoMapTool, lane: AutoMapLane): Cost => {
const lab1 = hexToLab(tool.color)
const lab2 = hexToLab(lane.color)

return [
isMaterialMismatch(tool.material, lane.material) ? 1 : 0,
Number(tool.weight ?? 0) <= Number(lane.weight ?? 0) ? 0 : 1,
lab1 && lab2 ? Math.hypot(lab1[0] - lab2[0], lab1[1] - lab2[1], lab1[2] - lab2[2]) : NO_COLOR_DISTANCE,
lane.current[tool.tool] ? 0 : 1
]
}

const add = (a: Cost, b: Cost): Cost => [a[0] + b[0], a[1] + b[1], a[2] + b[2], a[3] + b[3]]

const compare = (a: Cost, b: Cost) => a[0] - b[0] || a[1] - b[1] || a[2] - b[2] || a[3] - b[3]

// Greedy fallback: cheapest (tool, lane) pairs first, each tool and lane used once
const greedy = (tools: AutoMapTool[], lanes: AutoMapLane[], costs: Cost[][]) => {
const pairs = tools.flatMap((_, t) => lanes.map((_, l) => ({ t, l })))
.sort((a, b) => compare(costs[a.t][a.l], costs[b.t][b.l]))
const result = new Map<number, string>()
const usedLanes = new Set<number>()

for (const { t, l } of pairs) {
if (result.has(tools[t].tool) || usedLanes.has(l)) continue
result.set(tools[t].tool, lanes[l].lane)
usedLanes.add(l)
}
return result
}

/**
* Maps each tool to a lane so that the summed cost is minimal (each lane used at most once).
* Returns tool -> lane; tools left without a lane (more tools than lanes) are not in the result.
*/
export const autoMap = (tools: AutoMapTool[], lanes: AutoMapLane[]): Map<number, string> => {
if (!tools.length || !lanes.length) return new Map()

const costs = tools.map(tool => lanes.map(lane => laneCost(tool, lane)))
if (lanes.length > MAX_EXACT_LANES) return greedy(tools, lanes, costs)

// best[t][mask]: minimal cost for tools t.. with lanes in mask already taken
const size = 1 << lanes.length
const best: Cost[][] = Array.from({ length: tools.length + 1 }, () => new Array<Cost>(size))
const choice: number[][] = Array.from({ length: tools.length }, () => new Array<number>(size))
best[tools.length].fill([0, 0, 0, 0])

for (let t = tools.length - 1; t >= 0; t--) {
for (let mask = 0; mask < size; mask++) {
let bestCost = add(SKIP_COST, best[t + 1][mask])
let bestLane = -1
for (let l = 0; l < lanes.length; l++) {
if (mask & (1 << l)) continue
const cost = add(costs[t][l], best[t + 1][mask | (1 << l)])
if (compare(cost, bestCost) < 0) {
bestCost = cost
bestLane = l
}
}
best[t][mask] = bestCost
choice[t][mask] = bestLane
}
}

const result = new Map<number, string>()
let mask = 0
for (let t = 0; t < tools.length; t++) {
const l = choice[t][mask]
if (l >= 0) {
result.set(tools[t].tool, lanes[l].lane)
mask |= 1 << l
}
}
return result
}
Loading