generate 1.3.0 palette, render new color space figures

This commit is contained in:
2025-11-15 16:57:06 -08:00
parent 1d4d70744e
commit 1edf0dc1c2
29 changed files with 1864 additions and 2756 deletions

View File

@@ -10,6 +10,7 @@ import math
import plotly.io as io
import os
import json
import numpy as np
sys.path.insert(0, os.getcwd())
@@ -1016,6 +1017,440 @@ def main():
return 0
return 1
def plot_sphere_in_oklch(
fig,
center,
radius,
gmap,
filters=(),
resolution=24,
opacity=0.4,
edges=False,
faces=False,
ecolor=None,
fcolor=None,
equal_metric=False # <- new flag, see below
):
space = 'oklch'
target = Color.CS_MAP[space]
flags = {
'is_cyl': target.is_polar(),
'is_labish': isinstance(target, Labish),
'is_lchish': isinstance(target, LChish),
'is_hslish': isinstance(target, HSLish),
'is_hwbish': isinstance(target, HWBish),
'is_hsvish': isinstance(target, HSVish)
}
# Center in OKLab (Euclidean space behind OKLCH)
c_center = Color(center).convert('oklab', in_place=True).normalize(nans=False)
L0, a0, b0 = c_center[0], c_center[1], c_center[2]
# Parametric sphere in (L, a, b)
thetas = np.linspace(0.0, np.pi, resolution)
phis = np.linspace(0.0, 2.0 * np.pi, 2 * resolution, endpoint=False)
TT, PP = np.meshgrid(thetas, phis)
TT_flat = TT.ravel()
PP_flat = PP.ravel()
L_vals = L0 + radius * np.cos(TT_flat)
a_vals = a0 + radius * np.sin(TT_flat) * np.cos(PP_flat)
b_vals = b0 + radius * np.sin(TT_flat) * np.sin(PP_flat)
x = []
y = []
z = []
cmap = []
inside_mask = []
for Lv, av, bv in zip(L_vals, a_vals, b_vals):
c = Color('oklab', [Lv, av, bv]).convert(space, in_place=True).normalize(nans=False)
# Basic L sanity; out-of-range L is definitely not useful
if not (0.0 <= c[0] <= 1.0):
inside_mask.append(False)
x.append(0.0); y.append(0.0); z.append(0.0)
cmap.append('#000000')
continue
store_coords(c, x, y, z, flags)
s = c.convert('srgb')
in_gamut = s.in_gamut()
inside_mask.append(in_gamut)
if in_gamut:
if not s.in_gamut():
s.fit(**gmap)
else:
s.clip()
if filters:
s.filter(filters[0], **filters[1], in_place=True, out_space=s.space()).clip()
cmap.append(s.to_string(hex=True, alpha=False))
else:
cmap.append('#000000')
inside_mask = np.asarray(inside_mask, dtype=bool)
# Triangulate parameter space (theta, phi)
uv = np.column_stack([TT_flat, PP_flat])
tri = Delaunay(uv)
simplices = tri.simplices
# Keep only triangles whose vertices are all in gamut
keep = inside_mask[simplices].all(axis=1)
simplices = simplices[keep]
if simplices.size == 0:
return
# Compact vertices to those actually used in kept simplices
used = np.unique(simplices.ravel())
idx_map = {old: i for i, old in enumerate(used)}
simplices_comp = np.vectorize(idx_map.__getitem__)(simplices)
x2 = [x[i] for i in used]
y2 = [y[i] for i in used]
z2 = [z[i] for i in used]
cmap2 = [cmap[i] for i in used]
class _Tri:
def __init__(self, simplices):
self.simplices = simplices
tri2 = _Tri(simplices_comp)
create3d(
fig,
x2,
y2,
z2,
tri2,
cmap2,
edges=edges,
faces=faces,
ecolor=ecolor,
fcolor=fcolor,
opacity=opacity,
filters=filters
)
if equal_metric:
set_equal_metric_aspect(fig)
def plot_sphere_in_oklch2(
fig,
center, # Color spec, interpreted via Color()
radius, # OKLab radius
gmap,
filters=(),
resolution=24,
opacity=0.2,
edges=False,
faces=False,
ecolor=None,
fcolor=None,
outside_color='#000000'
):
space = 'oklch'
target = Color.CS_MAP[space]
flags = {
'is_cyl': target.is_polar(),
'is_labish': isinstance(target, Labish),
'is_lchish': isinstance(target, LChish),
'is_hslish': isinstance(target, HSLish),
'is_hwbish': isinstance(target, HWBish),
'is_hsvish': isinstance(target, HSVish)
}
# Center in OKLab (Euclidean)
c_center = Color(center).convert('oklab', in_place=True).normalize(nans=False)
L0, a0, b0 = c_center[0], c_center[1], c_center[2]
# Parametric sphere in (L, a, b)
thetas = np.linspace(0.0, np.pi, resolution)
phis = np.linspace(0.0, 2.0 * np.pi, 2 * resolution, endpoint=False)
TT, PP = np.meshgrid(thetas, phis)
TT_flat = TT.ravel()
PP_flat = PP.ravel()
L_vals = L0 + radius * np.cos(TT_flat)
a_vals = a0 + radius * np.sin(TT_flat) * np.cos(PP_flat)
b_vals = b0 + radius * np.sin(TT_flat) * np.sin(PP_flat)
x, y, z, cmap = [], [], [], []
for Lv, av, bv in zip(L_vals, a_vals, b_vals):
# Build OKLab color, convert to OKLCH for consistency with the space
c = Color('oklab', [Lv, av, bv]).convert(space, in_place=True).normalize(nans=False)
# Map to plotted coordinates (LCh → Lab embedding)
store_coords(c, x, y, z, flags)
# Decide vertex color based on sRGB gamut
s = c.convert('srgb')
if s.in_gamut():
if not s.in_gamut():
s.fit(**gmap)
else:
s.clip()
if filters:
s.filter(filters[0], **filters[1], in_place=True, out_space=s.space()).clip()
cmap.append(s.to_string(hex=True, alpha=False))
else:
cmap.append(outside_color)
# Triangulate parameter space (theta, phi).
# NOTE: we DO NOT filter simplices here: full sphere is drawn,
# just colored differently inside vs outside.
uv = np.column_stack([TT_flat, PP_flat])
tri = Delaunay(uv)
create3d(
fig,
x,
y,
z,
tri,
cmap,
edges=edges,
faces=faces,
ecolor=ecolor,
fcolor=fcolor,
opacity=opacity,
filters=filters
)
def plot_sphere_in_oklch3(
fig,
center,
radius,
gmap,
filters=(),
resolution=24,
opacity=0.4,
edges=False,
faces=False,
ecolor=None,
fcolor=None,
draw_boundary=True,
boundary_color='black',
boundary_width=3
):
space = 'oklch'
target = Color.CS_MAP[space]
flags = {
'is_cyl': target.is_polar(),
'is_labish': isinstance(target, Labish),
'is_lchish': isinstance(target, LChish),
'is_hslish': isinstance(target, HSLish),
'is_hwbish': isinstance(target, HWBish),
'is_hsvish': isinstance(target, HSVish)
}
# 1) Center in OKLab (Euclidean)
c_center = Color(center).convert('oklab', in_place=True).normalize(nans=False)
L0, a0, b0 = c_center[0], c_center[1], c_center[2]
# 2) Parametric sphere in (L, a, b)
thetas = np.linspace(0.0, np.pi, resolution)
phis = np.linspace(0.0, 2.0 * np.pi, 2 * resolution, endpoint=False)
TT, PP = np.meshgrid(thetas, phis)
TT_flat = TT.ravel()
PP_flat = PP.ravel()
L_vals = L0 + radius * np.cos(TT_flat)
a_vals = a0 + radius * np.sin(TT_flat) * np.cos(PP_flat)
b_vals = b0 + radius * np.sin(TT_flat) * np.sin(PP_flat)
x = []
y = []
z = []
cmap = []
inside_mask = []
for Lv, av, bv in zip(L_vals, a_vals, b_vals):
# OKLab -> OKLCH (for consistency with space)
c = Color('oklab', [Lv, av, bv]).convert(space, in_place=True).normalize(nans=False)
# Optionally reject totally crazy L, but not strictly required
if not (0.0 <= c[0] <= 1.0):
inside_mask.append(False)
x.append(0.0); y.append(0.0); z.append(0.0)
cmap.append('#000000')
continue
# Map to 3D coords used in gamut plotting
store_coords(c, x, y, z, flags)
# sRGB for display + in-gamut test
s = c.convert('srgb')
in_gamut = s.in_gamut()
inside_mask.append(in_gamut)
if in_gamut:
if not s.in_gamut():
s.fit(**gmap)
else:
s.clip()
if filters:
s.filter(filters[0], **filters[1], in_place=True, out_space=s.space()).clip()
cmap.append(s.to_string(hex=True, alpha=False))
else:
# color won't be used for clipped mesh, but keep list aligned
cmap.append('#000000')
inside_mask = np.asarray(inside_mask, dtype=bool)
# 3) Triangulate parameter space
uv = np.column_stack([TT_flat, PP_flat])
tri_all = Delaunay(uv)
simplices_all = tri_all.simplices
# ----- 3a) Build clipped mesh: only triangles fully inside gamut -----
keep = inside_mask[simplices_all].all(axis=1)
simplices = simplices_all[keep]
if simplices.size == 0:
# sphere completely out of gamut; nothing to draw
return
used = np.unique(simplices.ravel())
idx_map = {old: i for i, old in enumerate(used)}
simplices_comp = np.vectorize(idx_map.__getitem__)(simplices)
x2 = [x[i] for i in used]
y2 = [y[i] for i in used]
z2 = [z[i] for i in used]
cmap2 = [cmap[i] for i in used]
class _Tri:
def __init__(self, simplices):
self.simplices = simplices
tri2 = _Tri(simplices_comp)
# Clipped sphere surface
create3d(
fig,
x2,
y2,
z2,
tri2,
cmap2,
edges=edges,
faces=faces,
ecolor=ecolor,
fcolor=fcolor,
opacity=opacity,
filters=filters
)
# ----- 3b) Boundary curve: edges where inside/outside differ -----
if draw_boundary:
boundary_edges = set()
for vs in simplices_all:
m = inside_mask[vs]
# If all in or all out, no boundary here
if np.all(m) or (not np.any(m)):
continue
# For each triangle edge, if it connects inside<->outside, add it
edge_pairs = ((vs[0], vs[1]), (vs[1], vs[2]), (vs[2], vs[0]))
for a, b in edge_pairs:
if inside_mask[a] != inside_mask[b]:
# use sorted so (a,b) and (b,a) are treated the same
boundary_edges.add(tuple(sorted((a, b))))
if boundary_edges:
xe, ye, ze = [], [], []
for a, b in boundary_edges:
xe.extend([x[a], x[b], None])
ye.extend([y[a], y[b], None])
ze.extend([z[a], z[b], None])
fig.add_trace(
go.Scatter3d(
x=xe,
y=ye,
z=ze,
mode='lines',
line={'color': boundary_color, 'width': boundary_width},
showlegend=False,
name=''
)
)
def set_equal_metric_aspect(fig):
xs = []
ys = []
zs = []
for tr in fig.data:
# Just be defensive in case some traces don't have full 3D coords
tx = getattr(tr, 'x', None)
ty = getattr(tr, 'y', None)
tz = getattr(tr, 'z', None)
if tx is None or ty is None or tz is None:
continue
xs.extend([v for v in tx if v is not None])
ys.extend([v for v in ty if v is not None])
zs.extend([v for v in tz if v is not None])
if not xs or not ys or not zs:
return
xmin, xmax = min(xs), max(xs)
ymin, ymax = min(ys), max(ys)
zmin, zmax = min(zs), max(zs)
dx = xmax - xmin or 1.0
dy = ymax - ymin or 1.0
dz = zmax - zmin or 1.0
m = max(dx, dy, dz)
fig.update_layout(
scene=dict(
aspectmode='manual',
aspectratio=dict(
x=dx / m,
y=dy / m,
z=dz / m
)
)
)
def freeze_scene_axes(fig):
xs, ys, zs = [], [], []
for tr in fig.data:
tx = getattr(tr, 'x', None)
ty = getattr(tr, 'y', None)
tz = getattr(tr, 'z', None)
if tx is None or ty is None or tz is None:
continue
xs.extend([v for v in tx if v is not None])
ys.extend([v for v in ty if v is not None])
zs.extend([v for v in tz if v is not None])
if not xs or not ys or not zs:
return
xmin, xmax = min(xs), max(xs)
ymin, ymax = min(ys), max(ys)
zmin, zmax = min(zs), max(zs)
fig.update_layout(
scene=dict(
xaxis=dict(range=[xmin, xmax], autorange=False),
yaxis=dict(range=[ymin, ymax], autorange=False),
zaxis=dict(range=[zmin, zmax], autorange=False),
)
)
if __name__ == "__main__":
sys.exit(main())