added color mixing for acrylic by RYB model

This commit is contained in:
2026-08-14 16:40:38 +02:00
parent d1d79f3723
commit c8e8122b60
2 changed files with 107 additions and 1 deletions
+31 -1
View File
@@ -1,13 +1,25 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import type { ColorGroup } from '@/types/mosaic'
import { rgbToHex } from '@/utils/colors'
import {
estimateAcrylicMix,
rgbToHex,
type AcrylicBaseColor,
} from '@/utils/colors'
import { useMosaicStore } from '@/stores/mosaic'
const props = defineProps<{ group: ColorGroup; selected: boolean; totalPixels: number }>()
const store = useMosaicStore()
const mergeTarget = ref('')
const otherGroups = computed(() => store.groups.filter((group) => group.id !== props.group.id))
const acrylicMix = computed(() => estimateAcrylicMix(props.group.color))
const acrylicColors: Array<{ id: AcrylicBaseColor; label: string; swatch: string }> = [
{ id: 'white', label: 'Biela', swatch: '#FFFFFF' },
{ id: 'yellow', label: 'Žltá', swatch: '#FACC15' },
{ id: 'red', label: 'Červená', swatch: '#DC2626' },
{ id: 'blue', label: 'Modrá', swatch: '#2563EB' },
{ id: 'black', label: 'Čierna', swatch: '#111827' },
]
function merge(): void {
if (mergeTarget.value) store.mergeGroups(props.group.id, mergeTarget.value)
@@ -28,6 +40,17 @@ function merge(): void {
<div v-if="selected" class="group-details">
<span>{{ totalPixels.toLocaleString('sk-SK') }} pixelov</span>
<label class="color-edit">Farba <input type="color" :value="rgbToHex(group.color)" @change="store.setGroupColor(group.id, ($event.target as HTMLInputElement).value)" /></label>
<section class="acrylic-mix" aria-labelledby="acrylic-mix-heading">
<h3 id="acrylic-mix-heading">Orientačný pomer akrylových farieb</h3>
<ul>
<li v-for="color in acrylicColors" :key="color.id">
<span class="mix-swatch" :style="{ backgroundColor: color.swatch }" />
<span>{{ color.label }}</span>
<strong>{{ acrylicMix[color.id] }} %</strong>
</li>
</ul>
<p>Odhad podľa RGB; reálne pigmenty sa pri miešaní môžu správať odlišne.</p>
</section>
<div v-if="otherGroups.length" class="merge-row">
<select v-model="mergeTarget" aria-label="Cieľová skupina">
<option value="">Zlúčiť do</option>
@@ -53,6 +76,13 @@ function merge(): void {
.group-details { display: grid; gap: 8px; padding: 8px 9px; border-top: 1px solid #e6eaf0; background: #f8fafc; color: #64748b; font-size: .69rem; }
.color-edit { display: flex; align-items: center; justify-content: space-between; }
.color-edit input { width: 48px; height: 26px; padding: 1px; border: 1px solid #cbd5e1; border-radius: 5px; background: white; }
.acrylic-mix { padding: 8px; border: 1px solid #e2e8f0; border-radius: 7px; background: white; }
.acrylic-mix h3 { margin: 0 0 7px; color: #475569; font-size: .69rem; font-weight: 700; }
.acrylic-mix ul { display: grid; gap: 5px; margin: 0; padding: 0; list-style: none; }
.acrylic-mix li { display: grid; grid-template-columns: 15px 1fr auto; align-items: center; gap: 7px; }
.acrylic-mix strong { color: #334155; font-variant-numeric: tabular-nums; }
.mix-swatch { width: 13px; height: 13px; border: 1px solid #94a3b8; border-radius: 3px; }
.acrylic-mix p { margin: 7px 0 0; color: #7c8798; font-size: .62rem; line-height: 1.35; }
.merge-row { display: flex; gap: 6px; }
.merge-row select { min-width: 0; flex: 1; }
</style>
+76
View File
@@ -2,6 +2,31 @@ import type { LabColor, RGBColor } from '@/types/mosaic'
const clampByte = (value: number) => Math.max(0, Math.min(255, Math.round(value)))
export type AcrylicBaseColor = 'white' | 'yellow' | 'red' | 'blue' | 'black'
export type AcrylicMixRatio = Record<AcrylicBaseColor, number>
const acrylicBaseColors: AcrylicBaseColor[] = ['white', 'yellow', 'red', 'blue', 'black']
function roundMixToPercentages(mix: Record<AcrylicBaseColor, number>): AcrylicMixRatio {
const exactPercentages = acrylicBaseColors.map((color) => mix[color] * 100)
const percentages = exactPercentages.map(Math.floor)
let remainder = 100 - percentages.reduce((sum, percentage) => sum + percentage, 0)
const roundingOrder = exactPercentages
.map((percentage, index) => ({ index, fraction: percentage - percentages[index]! }))
.sort((first, second) => second.fraction - first.fraction)
for (const { index } of roundingOrder) {
if (remainder <= 0) break
percentages[index]! += 1
remainder -= 1
}
return Object.fromEntries(
acrylicBaseColors.map((color, index) => [color, percentages[index]]),
) as AcrylicMixRatio
}
export function rgbToHex(color: RGBColor): string {
const channel = (value: number) => clampByte(value).toString(16).padStart(2, '0')
return `#${channel(color.r)}${channel(color.g)}${channel(color.b)}`.toUpperCase()
@@ -61,3 +86,54 @@ export function averageColors(colors: Array<{ color: RGBColor; weight: number }>
b: Math.round(colors.reduce((sum, item) => sum + item.color.b * item.weight, 0) / total),
}
}
/**
* Estimates a practical paint recipe from an RGB color. White and black set the value,
* while the remaining chroma is converted to the traditional red-yellow-blue paint model.
* The result is an orientation aid, not a physical pigment simulation.
*/
export function estimateAcrylicMix(color: RGBColor): AcrylicMixRatio {
const channels = [color.r, color.g, color.b].map((value) =>
(Number.isFinite(value) ? clampByte(value) : 0) / 255,
)
const [sourceRed = 0, sourceGreen = 0, sourceBlue = 0] = channels
const minimum = Math.min(...channels)
const maximum = Math.max(...channels)
const chroma = maximum - minimum
const mix: AcrylicMixRatio = {
white: minimum,
yellow: 0,
red: 0,
blue: 0,
black: 1 - maximum,
}
if (chroma > 0) {
let red = sourceRed - minimum
let green = sourceGreen - minimum
let blue = sourceBlue - minimum
let yellow = Math.min(red, green)
red -= yellow
green -= yellow
// Blue and green overlap in RGB; sharing that overlap yields the expected RYB greens.
if (blue > 0 && green > 0) {
blue /= 2
green /= 2
}
yellow += green
blue += green
const pigmentTotal = red + yellow + blue
if (pigmentTotal > 0) {
mix.red = chroma * (red / pigmentTotal)
mix.yellow = chroma * (yellow / pigmentTotal)
mix.blue = chroma * (blue / pigmentTotal)
}
}
return roundMixToPercentages(mix)
}