Repository navigation
feat(AFC): add auto-map button to print dialog #2017
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
10 commits
Select commit
Hold shift + click to select a range
d759909
feat(AFC): add auto-map button to print dialog
ghzserg 08b2f62
Merge branch 'develop' into feat/afc-auto-map
ghzserg b17fc38
Merge branch 'develop' into feat/afc-auto-map
pedrolamas 07fc2c2
Merge branch 'fluidd-core:develop' into feat/afc-auto-map
ghzserg 94fe3e6
fix(AFC): auto-map lanes and tools without color, keep current mappin…
ghzserg 3eabdd0
fix(AFC): ignore invalid colors in auto-map
ghzserg 8055c8a
fix(AFC): optimal auto-map assignment, material/weight like the row w…
ghzserg 7ddca2c
refactor(AFC): drop unused save-button-text
pedrolamas 8a11b8d
fix(AFC): disable auto-map without tools, warn about unmapped tools
ghzserg dc1cec4
Merge branch 'develop' into feat/afc-auto-map
ghzserg File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) | ||
| }) | ||
| }) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.