Skip to content

Instantly share code, notes, and snippets.

@jtmcdole
Last active March 26, 2024 20:03
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save jtmcdole/cacf5a46fc6534d94133cc89f0fb31f8 to your computer and use it in GitHub Desktop.
Save jtmcdole/cacf5a46fc6534d94133cc89f0fb31f8 to your computer and use it in GitHub Desktop.
Blender carving a keycap

I wanted to engrave my own legends in blender, with the OpenGorton font. Since I'm 3D printing on an SLA printer, I can go as detailed as I like.

  1. have an object called Cap
  2. have 3 text objects aligned where you want center, lower, and upper legends. apply all formatting.
  3. move this higher than the cap (doesn't matter how high; orthographic projection.
  4. run.

Even with instersect(knife), I was getting inconsistent behavior between runs. I switched to using Shrinkwrap and have been really happy with the results.

capEngraver

Eevee render

Cycles render

# Carve legends into keycaps.
#
# This script uses Intersect (knife) to make accurate cuts. The downside is you need to convert
# a text object into a mesh and extrude by just the right amount. We can do this for caps since
# we know how tall the cap-well is.
#
# Unlike knife-project; this doesn't mess with context, view3d, projections, etc.
#
# TODO: save some custom data to layout the keyboard for renderin.
import bpy
data = bpy.data
# 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 _carveLegend(legend, workCap, body, scale = None):
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()
data.collections['work'].objects.link(workLegend)
if scale is not None:
workLegend.data.size *= scale
workLegend.data.body = body
# Convert the font copied font to a mesh so we can extrude it
workLegend.select_set(True)
bpy.context.view_layer.objects.active = workLegend
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(
TRANSFORM_OT_translate={
"value": (0.0, 0.0, -4),
"constraint_axis": (False, False, True),
"orient_type": 'NORMAL'})
bpy.ops.mesh.select_all(action = 'SELECT')
bpy.ops.mesh.normals_make_consistent()
bpy.ops.object.mode_set(mode = 'OBJECT')
# Join the new mesh with the working cap and then knife-intersect
workCap.select_set(True)
# Join the two working objects' to intersect cut
bpy.context.view_layer.objects.active = workCap
bpy.ops.object.join()
bpy.ops.object.mode_set(mode = 'EDIT')
bpy.ops.mesh.select_mode(type="EDGE")
bpy.ops.mesh.intersect()
workCap = bpy.context.view_layer.objects.active
# The intersect knife operation leaves the new edges slecected.
# Select the region between the loops and extrude down
bpy.ops.mesh.loop_to_region()
bpy.ops.mesh.extrude_context_move(
TRANSFORM_OT_translate={
"value": (0.0, 0.0, -0.4),
"constraint_axis": (False, False, True),
"orient_type": 'NORMAL'})
# Break out the cutter and delete it
bpy.ops.mesh.separate(type='LOOSE')
for gone in bpy.data.collections['work'].objects[1:]:
bpy.data.collections['work'].objects.unlink(gone)
bpy.ops.mesh.select_all(action = 'DESELECT')
bpy.ops.object.mode_set(mode = 'OBJECT')
# Carve a single cap with some text.
def carveCap(legends = {}, name = None):
center_legend = data.objects['Row1_legend_center']
lower_legend = data.objects['Row1_legend_lower']
upper_legend = data.objects['Row1_legend_upper']
ref1u = data.objects['SA Row1 1u']
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'])
data.collections['work'].objects.link(workCap)
if 'center' in legends:
_carveLegend(center_legend, workCap, legends['center'], legends.get('scale'))
if 'lower' in legends:
_carveLegend(lower_legend, workCap, legends['lower'], legends.get('scale'))
if 'upper' in legends:
_carveLegend(upper_legend, workCap, legends['upper'], legends.get('scale'))
data.collections['Done'].objects.link(workCap)
data.collections['work'].objects.unlink(workCap)
workCap.hide_set(True)
_1uKeys = [
['`', '~'],
['1', '!'],
['2', '@'],
['3', '#'],
['4', '$'],
['5', '%'],
['6', '^'],
['7', '&'],
['8', '*'],
['9', '('],
['0', ')'],
['-', '_'],
['=', '+'],
[{'character': 'Home', 'scale': 0.8, 'location': 'upper'}],
]
for key in _1uKeys:
print("working on ", key)
if len(key) == 1:
legends = {}
if isinstance(key[0], dict):
key = key[0]
location = key['location'] if 'location' in key else 'center'
legends.update({location: key['character'], 'scale': key['scale']})
else:
legends.update({'center': key[0]})
carveCap(legends)
elif len(key) == 2:
legends = {}
if isinstance(key[0], dict):
key = key[0]
location = key['location'] if 'location' in key else 'center'
legends.update({
location: key['character'],
'scale': key['scale']})
else:
legends.update({'lower': key[0]})
if isinstance(key[1], dict):
key = key[1]
location = key['location'] if 'location' in key else 'center'
legends.update({
location: key['character'],
'scale': key['scale']})
else:
legends.update({'upper': key[1]})
carveCap(legends)
else:
raise Exception("Well this is awkward; array to big: " + key)
# This is an example of KNIFE PROJECT - This is bad for a few reasons:
# 1. Knife Project has to operate in a View3D, which means lots of context hacking
# 2. Knife Project to work properly needs to be in Ortogrpahic projection.
# 3. Knife Project has a resolution issue. Zoomed out in the 3D view too much? Jaggies.
#
# Please see the other script. I've left this here as an example only :)
#
import bpy
bpy.ops.screen.screen_full_area()
oldType = bpy.context.area.type
oldSmoothView = bpy.context.preferences.view.smooth_view
bpy.context.preferences.view.smooth_view = 0
bpy.context.area.type = 'VIEW_3D'
win = bpy.context.window
scr = win.screen
areas3d = [area for area in scr.areas if area.type == 'VIEW_3D']
region = [region for region in areas3d[0].regions if region.type == 'WINDOW']
oContext = bpy.context.copy()
oContext.update({'window':win,
'screen':scr,
'area' :areas3d[0],
'region':region[0],
'scene' :bpy.context.scene,
})
data = bpy.data
if bpy.ops.view3d.view_axis.poll(oContext):
bpy.ops.view3d.view_axis(oContext, type='TOP')
oContext['area'].spaces.active.region_3d.update()
else:
print('unable to call view3d.view_selected')
def focusText(legend):
legend.select_set(True)
oContext['selected_objects'] = [legend]
if bpy.ops.view3d.view_selected.poll(oContext):
bpy.ops.view3d.view_selected(oContext)
else:
print('unable to call view3d.view_selected')
oContext['area'].spaces.active.region_3d.update()
def _carveLegend(legend, workCap, body, scale = None):
oldSize = legend.data.size
if scale is not None:
legend.data.size = oldSize * scale
legend.data.body = body
focusText(legend)
workCap.select_set(True)
bpy.context.view_layer.objects.active = workCap
oContext['active_object'] = workCap
oContext['edit_object'] = workCap
oContext['selected_objects'] = [legend, workCap]
bpy.ops.object.editmode_toggle(oContext)
bpy.ops.mesh.knife_project(oContext)
bpy.ops.mesh.extrude_region_move(oContext,
TRANSFORM_OT_translate={
"value": (0.0, 0.0, -0.4),
"constraint_axis": (False, False, True),
"orient_type": 'NORMAL'})
legend.data.size = oldSize
legend.select_set(False)
bpy.ops.object.editmode_toggle(oContext)
workCap.select_set(False)
def carveCap(legends = {}, name = None):
center_legend = data.objects['Row1_legend_center']
lower_legend = data.objects['Row1_legend_lower']
upper_legend = data.objects['Row1_legend_upper']
ref1u = data.objects['SA Row1 1u']
center_legend.hide_set(False)
lower_legend.hide_set(False)
upper_legend.hide_set(False)
workCap = ref1u.copy()
workCap.data = ref1u.data.copy()
workCap.hide_render = True
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'])
data.collections['work'].objects.link(workCap)
if 'center' in legends:
_carveLegend(center_legend, workCap, legends['center'], legends.get('scale'))
if 'lower' in legends:
_carveLegend(lower_legend, workCap, legends['lower'], legends.get('scale'))
if 'upper' in legends:
_carveLegend(upper_legend, workCap, legends['upper'], legends.get('scale'))
data.collections['Done'].objects.link(workCap)
data.collections['work'].objects.unlink(workCap)
center_legend.hide_set(True)
lower_legend.hide_set(True)
upper_legend.hide_set(True)
workCap.hide_set(True)
_1uKeys = [
['`', '~'],
['1', '!'],
['2', '@'],
['3', '#'],
['4', '$'],
['5', '%'],
['6', '^'],
['7', '&'],
['8', '*'],
['9', '('],
['0', ')'],
['-', '_'],
['=', '+'],
[{'character': 'Home', 'scale': 0.8, 'location': 'upper'}],
]
for key in _1uKeys:
print("working on ", key)
if len(key) == 1:
legends = {}
if isinstance(key[0], dict):
key = key[0]
location = key['location'] if 'location' in key else 'center'
legends.update({location: key['character'], 'scale': key['scale']})
else:
legends.update({'center': key[0]})
carveCap(legends)
elif len(key) == 2:
legends = {}
if isinstance(key[0], dict):
key = key[0]
location = key['location'] if 'location' in key else 'center'
legends.update({
location: key['character'],
'scale': key['scale']})
else:
legends.update({'lower': key[0]})
if isinstance(key[1], dict):
key = key[1]
location = key['location'] if 'location' in key else 'center'
legends.update({
location: key['character'],
'scale': key['scale']})
else:
legends.update({'upper': key[1]})
carveCap(legends)
else:
raise Exception("Well this is awkward; array to big: " + key)
bpy.context.preferences.view.smooth_view = oldSmoothView
bpy.ops.screen.screen_full_area()
# 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()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment