|
# Carve legends into keycaps. |
|
# |
|
# This script parses the keyboard-layout-editor.com KLE json format to carve and layout keycaps. |
|
# The carving process uses Shrinkwrap to make accurate, consistant cuts. Materials are created |
|
# to give key and legend colors. |
|
|
|
import bpy, json |
|
from mathutils import Vector |
|
|
|
wm = bpy.context.window_manager |
|
data = bpy.data |
|
ops = bpy.ops |
|
context = bpy.context |
|
|
|
def _makeMaterial(obj, color: str): |
|
mat = bpy.data.materials.get(color) |
|
if mat is None: |
|
mat = bpy.data.materials.new(name=color) |
|
obj.data.materials.append(mat) |
|
return mat |
|
|
|
# Take a reference legend, make a copy, update its body and scale, |
|
# and carve that text into a working (assumed in the 'work' collection). |
|
# |
|
# This is a destructive process, so we'll make copies of the legend. |
|
def _carveLegendProject(legend, workCap, body, scale = None, config = {}): |
|
for obj in bpy.context.selected_objects: |
|
obj.select_set(False) |
|
|
|
# Make a copy of the legend to work with destructivly |
|
workLegend = legend.copy() |
|
workLegend.data = legend.data.copy() |
|
workLegend.data.font = bpy.data.fonts['OpenGorton-Regular'] |
|
if scale is not None: |
|
workLegend.data.size *= scale |
|
workLegend.data.space_character = 1.0 |
|
workLegend.data.body = body |
|
data.collections['work'].objects.link(workLegend) |
|
bpy.context.view_layer.update() |
|
|
|
capSafeZone = workCap.dimensions - Vector([6.75, 0.0, 0.0]); |
|
postDim = (0.0, 0.0, 0.0) |
|
preDim = workLegend.dimensions |
|
if workLegend.dimensions.x > capSafeZone.x: |
|
workLegend.data.body = '\n'.join(body.split(' ')) |
|
workLegend.update_tag() |
|
bpy.context.view_layer.update() |
|
postDim = workLegend.dimensions |
|
|
|
if workLegend.dimensions.x > capSafeZone.x: |
|
print('this big boy legend is going to over flow.') |
|
print('workLegendDim:', workLegend.dimensions.x, ' > capSafe:', capSafeZone) |
|
print('pre: ', preDim, ' post: ', postDim) |
|
|
|
# Convert the font copied font to a mesh so we can extrude it |
|
workLegend.select_set(True) |
|
bpy.context.view_layer.objects.active = workLegend |
|
|
|
# Shrinkwrap the label to the key |
|
ops.object.modifier_add(type='SHRINKWRAP') |
|
context.object.modifiers["Shrinkwrap"].offset = 0.02 |
|
context.object.modifiers["Shrinkwrap"].wrap_method = 'PROJECT' |
|
context.object.modifiers["Shrinkwrap"].use_project_z = True |
|
context.object.modifiers["Shrinkwrap"].use_positive_direction = True |
|
context.object.modifiers["Shrinkwrap"].use_negative_direction = True |
|
context.object.modifiers["Shrinkwrap"].target = workCap |
|
bpy.ops.object.convert(target="MESH") |
|
|
|
# Extrude the new mesh down Z-axis, far enough to cut the top |
|
# of the working keycap |
|
bpy.ops.object.mode_set(mode = 'EDIT') |
|
bpy.ops.mesh.select_mode(type="FACE") |
|
bpy.ops.mesh.select_all(action = 'SELECT') |
|
bpy.ops.mesh.extrude_context_move( |
|
MESH_OT_extrude_context = { |
|
'use_normal_flip': True, |
|
}, |
|
TRANSFORM_OT_translate={ |
|
"value": (0.0, 0.0, -0.2), |
|
"constraint_axis": (False, False, True), |
|
"orient_type": 'NORMAL'}) |
|
|
|
workLegend.select_set(False) |
|
bpy.context.view_layer.objects.active = workCap |
|
|
|
ops.object.modifier_add(type='BOOLEAN') |
|
context.object.modifiers["Boolean"].operation = 'DIFFERENCE' |
|
context.object.modifiers["Boolean"].solver = 'FAST' |
|
context.object.modifiers["Boolean"].object = workLegend |
|
|
|
ops.object.modifier_apply(modifier='Boolean') |
|
|
|
workLegend.select_set(False) |
|
bpy.ops.object.mode_set(mode = 'EDIT') |
|
group = workCap.vertex_groups.new(name = 'legend_' + body) |
|
bpy.ops.object.vertex_group_assign() |
|
|
|
bpy.ops.mesh.select_mode(type="FACE") |
|
bpy.ops.mesh.select_all(action = 'SELECT') |
|
ops.mesh.normals_make_consistent(inside=False) |
|
bpy.ops.mesh.select_all(action = 'DESELECT') |
|
bpy.ops.object.mode_set(mode = 'OBJECT') |
|
data.collections['work'].objects.unlink(workLegend) |
|
|
|
def srgb_to_linearrgb(c): |
|
if c < 0: return 0 |
|
elif c < 0.04045: return c/12.92 |
|
else: return ((c+0.055)/1.055)**2.4 |
|
|
|
def hex2rgb(hex: str): |
|
hex = hex.lstrip("#") |
|
r = int(str(hex[0:2]), 16) |
|
g = int(str(hex[2:4]), 16) |
|
b = int(str(hex[4:6]), 16) |
|
return tuple([srgb_to_linearrgb(c/0xff) for c in (r,g,b)] + [1.0]) |
|
|
|
# Carve a single cap with some text. |
|
def carveCap(legends = {}, name = None): |
|
row = 'Spacebar' if legends['row'] == "Spacebar" else 'Row' + legends['row'] |
|
|
|
refKeyName = legends['profile'] + ' ' + row + ' ' + str(legends['width']) + 'u' |
|
if legends['homing'] == True: |
|
refKeyName += ' Homing' |
|
if refKeyName not in data.objects: |
|
print('Missing key: ', refKeyName) |
|
return |
|
ref1u = data.objects[refKeyName] |
|
|
|
workCap = ref1u.copy() |
|
workCap.data = ref1u.data.copy() |
|
workCap.name = (name if name is not None |
|
else legends['center'] if 'center' in legends |
|
else legends['lower'] if 'lower' in legends |
|
else legends['upper'] if 'upper' in legends |
|
else legends['row']) |
|
data.collections['work'].objects.link(workCap) |
|
bpy.context.view_layer.objects.active = workCap |
|
|
|
bpy.ops.object.mode_set(mode = 'EDIT') |
|
mat = _makeMaterial(workCap, color= 'key_' + legends['color_key']) |
|
matIndex = len(workCap.data.materials) - 1 |
|
workCap.active_material_index = matIndex |
|
bpy.ops.object.material_slot_assign() |
|
if mat.node_tree is None: |
|
mat.use_nodes = True |
|
diffuse = mat.node_tree.nodes.new('ShaderNodeBsdfDiffuse') |
|
diffuse.name = 'cap color' |
|
diffuse.inputs["Color"].default_value = hex2rgb(legends['color_key']) |
|
diffuse.location.x = -200 |
|
diffuse.location.y = 133 |
|
|
|
gloss = mat.node_tree.nodes.new('ShaderNodeBsdfGlossy') |
|
gloss.name = 'plastic_gloss' |
|
gloss.inputs["Color"].default_value = hex2rgb('#FFFFFF') |
|
gloss.inputs["Roughness"].default_value = 0.3 |
|
gloss.location.x = -200 |
|
gloss.location.y = 0 |
|
|
|
mix = mat.node_tree.nodes.new('ShaderNodeMixShader') |
|
mix.name = 'make_it_real' |
|
mix.inputs['Fac'].default_value = 0.2 |
|
gloss.location.x = 32 |
|
gloss.location.y = 121 |
|
|
|
|
|
output = mat.node_tree.nodes['Material Output'] |
|
gloss.location.x = 222 |
|
gloss.location.y = 121 |
|
|
|
mat.node_tree.links.new(diffuse.outputs['BSDF'], mix.inputs[1]) |
|
mat.node_tree.links.new(gloss.outputs['BSDF'], mix.inputs[2]) |
|
mat.node_tree.links.new(mix.outputs['Shader'], output.inputs['Surface']) |
|
mat.node_tree.nodes.remove(mat.node_tree.nodes['Principled BSDF']) |
|
|
|
|
|
bpy.ops.mesh.select_mode(type="FACE") |
|
bpy.ops.mesh.select_all(action = 'DESELECT') |
|
bpy.ops.object.mode_set(mode = 'OBJECT') |
|
|
|
scaleMap = { |
|
9: 1.0, |
|
8: 0.9, |
|
7: 0.8, |
|
6: 0.7, |
|
5: 0.6, |
|
4: 0.5, |
|
3: 0.4, |
|
2: 0.3, |
|
1: 0.25, |
|
} |
|
|
|
if 'center' not in legends and 'lower' not in legends and 'upper' not in legends: |
|
print('ignore carving'); |
|
|
|
center_legend = data.objects[row + '_legend_center'] |
|
lower_legend = data.objects[row + '_legend_lower'] |
|
upper_legend = data.objects[row + '_legend_upper'] |
|
upper_legend.data.size = lower_legend.data.size = center_legend.data.size = 8.0 |
|
upper_legend.data.space_word = lower_legend.data.space_word = center_legend.data.space_word = 0.5 |
|
|
|
scale = scaleMap.get(legends['scale_primary'], 1.0) |
|
scale2 = scaleMap.get(legends['scale_primary'] - 2, 1.0) |
|
if 'scale_secondary' in legends: |
|
scale2 = scaleMap.get(legends['scale_secondary'], 1.0) |
|
|
|
if 'center' in legends: |
|
_carveLegendProject(center_legend, workCap, legends['center'], scale, config = legends) |
|
|
|
if 'lower' in legends: |
|
_carveLegendProject(lower_legend, workCap, legends['lower'], scale, config = legends) |
|
|
|
if 'upper' in legends: |
|
_carveLegendProject(upper_legend, workCap, legends['upper'], scale2, config = legends) |
|
|
|
data.collections['Done'].objects.link(workCap) |
|
data.collections['work'].objects.unlink(workCap) |
|
|
|
workCap.select_set(True) |
|
|
|
# |
|
# Make Materials! |
|
# |
|
bpy.ops.object.mode_set(mode = 'EDIT') |
|
bpy.ops.mesh.select_mode(type="FACE") |
|
bpy.ops.mesh.select_all(action = 'DESELECT') |
|
for vGroup in workCap.vertex_groups: |
|
workCap.vertex_groups.active = vGroup |
|
bpy.ops.object.vertex_group_select() |
|
mat = _makeMaterial(workCap, color= 'legend_' + legends['color_legend']) |
|
matIndex = len(workCap.data.materials) - 1 |
|
workCap.active_material_index = matIndex |
|
bpy.ops.object.material_slot_assign() |
|
|
|
if mat.node_tree is None: |
|
mat.use_nodes = True |
|
emission = mat.node_tree.nodes.new('ShaderNodeEmission') |
|
emission.name = 'legend emission' |
|
emission.inputs["Color"].default_value = hex2rgb(legends['color_legend']) |
|
emission.inputs["Strength"].default_value = 50 |
|
emission.location.x = -100 |
|
emission.location.y = 0 |
|
|
|
output = mat.node_tree.nodes['Material Output'] |
|
output.location.x = 100 |
|
output.location.y = 0 |
|
mat.node_tree.links.new(emission.outputs['Emission'], output.inputs['Surface']) |
|
mat.node_tree.nodes.remove(mat.node_tree.nodes['Principled BSDF']) |
|
|
|
bpy.ops.mesh.select_all(action = 'DESELECT') |
|
bpy.ops.object.mode_set(mode = 'OBJECT') |
|
|
|
x = legends['x'] + legends['width'] / 2 |
|
y = legends['y'] + legends['height'] / 2 |
|
x *= 19.05 |
|
y *= 19.05 |
|
bpy.ops.transform.translate(value=(x, -y, 0.0)) |
|
workCap.select_set(False) |
|
|
|
parsed = json.loads(bpy.data.texts['KLE.json'].as_string()) |
|
|
|
mem = { |
|
'x': 0.0, |
|
'y': 0.0, |
|
'profile': 'SA', |
|
'row': '1', |
|
'width': 1, |
|
'height': 1, |
|
'homing': False, |
|
'scale_primary': 5, |
|
} |
|
|
|
key = 0 |
|
skipRows = 0 |
|
|
|
DIR = ['LEFT', 'UP', 'DOWN', 'RIGHT',] |
|
NAV = ['PAGE UP', 'PAGE DOWN', 'END', 'HOME'] |
|
EDIT = ['RETURN', 'BACKSPACE', 'DEL'] |
|
MODS = ['ALT', 'SUPER', 'SHIFT', 'TAB', 'CONTROL', 'CAPS LOCK', 'CAPS', 'LOCK'] |
|
FUN = ['F1', 'F12'] |
|
NUM = ['1', '2'] |
|
OTHER = ['MACRO', 'PRINT', 'INS', '~'] |
|
ONLY_CARVE = DIR + NAV + EDIT + MODS + FUN + NUM + OTHER |
|
#ONLY_CARVE = [] |
|
|
|
work = [] |
|
|
|
def isNotBlank (string): |
|
return bool(string and string.strip()) |
|
|
|
for row in parsed: |
|
if isinstance(row, list) == False: |
|
continue |
|
skipRows -= 1 |
|
if skipRows > 0: |
|
continue |
|
for ele in row: |
|
if isinstance(ele, dict): |
|
if 'p' in ele: |
|
for profile in ele['p'].split(): |
|
if profile == "SA": |
|
mem['profile'] = "SA" |
|
elif profile == "R1": |
|
mem['row'] = '1' |
|
elif profile == "R2": |
|
mem['row'] = '2' |
|
elif profile == "R3": |
|
mem['row'] = '3' |
|
elif profile == "R4": |
|
mem['row'] = '4' |
|
elif profile == "SPACE": |
|
mem['row'] = 'Spacebar' |
|
if 'x' in ele: |
|
mem['x'] += float(ele['x']) |
|
if 'y' in ele: |
|
mem['y'] += float(ele['y']) |
|
if 'w' in ele: |
|
mem['width'] = ele['w'] |
|
if 'h' in ele: |
|
mem['height'] = ele['h'] |
|
if 'n' in ele: |
|
mem['homing'] = True |
|
if 'f' in ele: |
|
mem['scale_primary'] = ele['f'] |
|
if 'f2' in ele: |
|
mem['scale_secondary'] = ele['f2'] |
|
if 'c' in ele: |
|
mem['color_key'] = ele['c'] |
|
if 't' in ele: |
|
mem['color_legend'] = ele['t'] |
|
else: |
|
legends = ele.split('\n') |
|
legend = {} |
|
legend.update(mem) |
|
|
|
shouldCarve = True |
|
if len(ONLY_CARVE) > 0: |
|
if not any(leg in legends for leg in ONLY_CARVE): |
|
shouldCarve = False |
|
|
|
if shouldCarve: |
|
print(' ele:', ele, ' mem:', mem) |
|
if len(legends) == 1: |
|
if isNotBlank(ele): |
|
legend['center'] = ele |
|
work += [legend] |
|
elif len(legends) == 2: |
|
if isNotBlank(legends[0]): |
|
legend['upper'] = legends[0] |
|
if isNotBlank(legends[1]): |
|
legend['lower'] = legends[1] |
|
work += [legend] |
|
elif len(legends) == 5: |
|
if isNotBlank(legends[0]): |
|
legend['center'] = legends[0] |
|
work += [legend] |
|
else: |
|
print('not supported: ', legends) |
|
|
|
mem['x'] += mem['width'] |
|
mem['width'] = 1 |
|
mem['height'] = 1 |
|
mem['homing'] = False |
|
mem['y'] += 1.0 |
|
mem['x'] = 0.0 |
|
|
|
progress = 0 |
|
wm.progress_begin(progress, len(work)) |
|
for legend in work: |
|
print(' work:', legend) |
|
carveCap(legend) |
|
progress += 1 |
|
wm.progress_update(progress) |
|
wm.progress_end() |