added color mixing for acrylic by RYB model
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user