Dexter Flex Mask
Obliczymy ostateczną cenę na podstawie wybranego pliku. Sprawdzisz ją i zaakceptujesz przed płatnością.
Zapisane na tym urządzeniu.
Dexter Flex Mask : Made with flexface.mask.generator.py.
- Ocena
- 0 / 5
- Polubienia
- 5
- Pobrania
- 2
- Wydruki
- 0
- Wyświetlenia
- 29
- Oceny
- 0
Aktualizacja u źródła:
Informacje o modelu
- Źródło
- Printables
- Kategoria
- Other Costume Accessories
- Licencja
- Creative Commons — Attribution — Noncommercial — Share Alike
Dexter Flex Mask : Made with flexface.mask.generator.py.
bl_info = {
"name": "FlexFace Mask Generator",
"author": "FlexFace Studio",
"version": (1, 0, 0),
"blender": (4, 0, 0),
"location": "View3D > Sidebar > FlexFace",
"description": "Unrolls 3D head sculpts into flat-printable flexible masks with preserved 3D feature relief.",
"category": "Mesh",
}import bpy
import bmesh
import math
from mathutils import Vector
from bpy.props import (
FloatProperty,
EnumProperty,
BoolProperty,
PointerProperty
)
=========================================================================
HELPER FUNCTIONS & GEOMETRY GENERATORS
=========================================================================
def get_unit_scale(obj):
"""Calculates scaling factor (handling meters vs millimeters)."""
bbox = [Vector(b) for b in obj.bound_box]
h = max(v.z for v in bbox) - min(v.z for v in bbox)
return 0.001 if h < 5.0 else 1.0
def create_pill_prism(name, length, width, depth, location, rot_z):
"""Creates a 2-manifold capsule prism for clean Boolean difference."""
r = width / 2.0
half_l = max(0.0, length - width) / 2.0
segments = 12
pts_2d = []
for i in range(segments + 1):
ang = -math.pi / 2.0 + math.pi * (i / segments)
pts_2d.append(Vector((half_l + r * math.cos(ang), r * math.sin(ang))))
for i in range(segments + 1):
ang = math.pi / 2.0 + math.pi * (i / segments)
pts_2d.append(Vector((-half_l + r * math.cos(ang), r * math.sin(ang))))
bm = bmesh.new()
half_d = depth / 2.0
b_verts = [bm.verts.new(Vector((p.x, p.y, -half_d))) for p in pts_2d]
t_verts = [bm.verts.new(Vector((p.x, p.y, half_d))) for p in pts_2d]
bm.faces.new(reversed(b_verts))
bm.faces.new(t_verts)
n = len(pts_2d)
for i in range(n):
next_i = (i + 1) % n
bm.faces.new([b_verts[i], b_verts[next_i], t_verts[next_i], t_verts[i]])
bmesh.ops.recalc_face_normals(bm, faces=bm.faces)
mesh = bpy.data.meshes.new(f"{name}_Mesh")
bm.to_mesh(mesh)
bm.free()
obj = bpy.data.objects.new(name, mesh)
obj.location = location
obj.rotation_euler.z = rot_z
bpy.context.collection.objects.link(obj)
return obj
def build_guide_mesh(source_obj, props):
"""Generates wireframe visualization showing the capture bounds and strap slots."""
bbox = [Vector(b) for b in source_obj.bound_box]
min_x, max_x = min(v.x for v in bbox), max(v.x for v in bbox)
min_y, max_y = min(v.y for v in bbox), max(v.y for v in bbox)
min_z, max_z = min(v.z for v in bbox), max(v.z for v in bbox)
head_w = max_x - min_x
head_d = max_y - min_y
head_h = max_z - min_z
cx = (min_x + max_x) / 2.0
cz = min_z + (head_h * props.center_z_ratio)
rad_x = (head_w * props.mask_width_ratio) / 2.0
rad_z = (head_h * props.mask_height_ratio) / 2.0
bm = bmesh.new()
segments = 48
ring_verts = []
# Ovoid front projection curve
for i in range(segments):
phi = (2.0 * math.pi * i) / segments
rx = rad_x * (1.0 + props.egg_taper * math.sin(phi))
px = cx + rx * math.cos(phi)
pz = cz + rad_z * math.sin(phi)
# Position slightly forward of bust
py = min_y - (head_d * 0.05) if props.forward_axis == "-Y" else max_y + (head_d * 0.05)
ring_verts.append(bm.verts.new(Vector((px, py, pz))))
for i in range(segments):
bm.edges.new((ring_verts[i], ring_verts[(i + 1) % segments]))
mesh = bpy.data.meshes.new("FlexFace_Guide_Mesh")
bm.to_mesh(mesh)
bm.free()
return mesh
=========================================================================
CORE EXTRACTION PIPELINE
=========================================================================
def execute_mask_generation(context):
props = context.scene.flexface_props
source_obj = context.active_object
if not source_obj or source_obj.type != 'MESH':
raise RuntimeError("Select your head or bust mesh.")
# Remove previous run
if "Face_Unrolled_Mask" in bpy.data.objects:
old = bpy.data.objects["Face_Unrolled_Mask"]
old_mesh = old.data
bpy.data.objects.remove(old, do_unlink=True)
bpy.data.meshes.remove(old_mesh, do_unlink=True)
bpy.ops.object.transform_apply(location=True, rotation=True, scale=True)
# Duplicate
bpy.ops.object.duplicate()
mask_obj = context.active_object
mask_obj.name = "Face_Unrolled_Mask"
bbox = [Vector(b) for b in mask_obj.bound_box]
min_x, max_x = min(v.x for v in bbox), max(v.x for v in bbox)
min_y, max_y = min(v.y for v in bbox), max(v.y for v in bbox)
min_z, max_z = min(v.z for v in bbox), max(v.z for v in bbox)
head_w = max_x - min_x
head_d = max_y - min_y
head_h = max_z - min_z
scale = get_unit_scale(mask_obj)
mask_thickness = props.thickness_mm * scale
slot_w = props.slot_width_mm * scale
slot_h = props.slot_height_mm * scale
wall_margin = props.wall_margin_mm * scale
cx = (min_x + max_x) / 2.0
cz = min_z + (head_h * props.center_z_ratio)
rad_x = (head_w * props.mask_width_ratio) / 2.0
rad_y = (head_h * props.mask_height_ratio) / 2.0
# 1. Isolation
y_cutoff = min_y + (head_d * 0.60) if props.forward_axis == "-Y" else max_y - (head_d * 0.60)
bm = bmesh.new()
bm.from_mesh(mask_obj.data)
bm.faces.ensure_lookup_table()
faces_to_delete = []
for f in bm.faces:
c = f.calc_center_median()
is_front = c.y < y_cutoff if props.forward_axis == "-Y" else c.y > y_cutoff
if not is_front or abs(c.x - cx) > (rad_x * 1.45) or abs(c.z - cz) > (rad_y * 1.35):
faces_to_delete.append(f)
bmesh.ops.delete(bm, geom=faces_to_delete, context='FACES')
verts_to_delete = [v for v in bm.verts if len(v.link_faces) == 0]
bmesh.ops.delete(bm, geom=verts_to_delete, context='VERTS')
# 2. Continuous Linear Regression Baseline (Zero Banding)
axis_y = min_y + (head_d * 0.52)
bm.verts.ensure_lookup_table()
cheek_samples = []
vert_info = []
for v in bm.verts:
dx = v.co.x - cx
dy = max(1e-6, axis_y - v.co.y) if props.forward_axis == "-Y" else max(1e-6, v.co.y - axis_y)
r = math.sqrt(dx * dx + dy * dy)
theta = math.atan2(dx, dy)
rel_y = v.co.z - cz
vert_info.append((dx, dy, r, theta, rel_y))
if abs(dx) > (rad_x * 0.32):
cheek_samples.append((rel_y, r))
if not cheek_samples:
cheek_samples = [(p[4], p[2]) for p in vert_info]
mean_y = sum(p[0] for p in cheek_samples) / len(cheek_samples)
mean_r = sum(p[1] for p in cheek_samples) / len(cheek_samples)
denom = sum((p[0] - mean_y) ** 2 for p in cheek_samples)
slope = sum((p[0] - mean_y) * (p[1] - mean_r) for p in cheek_samples) / max(denom, 1e-6)
intercept = mean_r - slope * mean_y
residuals = sorted([p[1] - (slope * p[0] + intercept) for p in cheek_samples])
r_offset = residuals[int(len(residuals) * 0.15)]
# 3. Isometric Arc-Length Flattening
for idx, v in enumerate(bm.verts):
dx, dy, r, theta, rel_y = vert_info[idx]
r_base_smooth = (slope * rel_y + intercept) + r_offset
v.co.x = r * theta # 1:1 true surface width
v.co.y = rel_y
v.co.z = max(0.0, r - r_base_smooth) # Smooth 3D depth
# 4. Mathematical Ovoid Boundary Cut
bm.faces.ensure_lookup_table()
ovoid_prune = []
for f in bm.faces:
c = f.calc_center_median()
y_clamped = max(-rad_y, min(rad_y, c.y))
rx_local = rad_x * (1.0 + props.egg_taper * (y_clamped / rad_y))
if (c.x / rx_local) ** 2 + (c.y / rad_y) ** 2 > 1.0:
ovoid_prune.append(f)
bmesh.ops.delete(bm, geom=ovoid_prune, context='FACES')
verts_to_delete = [v for v in bm.verts if len(v.link_faces) == 0]
bmesh.ops.delete(bm, geom=verts_to_delete, context='VERTS')
# 5. Adhesion Perimeter Falloff (Z = 0)
for v in bm.verts:
y_clamped = max(-rad_y, min(rad_y, v.co.y))
rx_local = rad_x * (1.0 + props.egg_taper * (y_clamped / rad_y))
d_norm = math.sqrt((v.co.x / rx_local) ** 2 + (v.co.y / rad_y) ** 2)
if d_norm <= 0.72:
w = 1.0
elif d_norm >= 0.96:
w = 0.0
else:
t = (d_norm - 0.72) / (0.96 - 0.72)
w = 1.0 - (t * t * (3.0 - 2.0 * t))
v.co.z *= w
bm.faces.ensure_lookup_table()
if sum(f.normal.z for f in bm.faces) / len(bm.faces) < 0:
bmesh.ops.reverse_faces(bm, faces=bm.faces)
bmesh.ops.recalc_face_normals(bm, faces=bm.faces)
bm.to_mesh(mask_obj.data)
bm.free()
mask_obj.data.update()
context.view_layer.update()
# 6. Solidify
bpy.ops.object.select_all(action='DESELECT')
mask_obj.select_set(True)
context.view_layer.objects.active = mask_obj
solid_mod = mask_obj.modifiers.new(name="Shell_Thickness", type='SOLIDIFY')
solid_mod.thickness = mask_thickness
solid_mod.offset = 1.0
bpy.ops.object.modifier_apply(modifier=solid_mod.name)
# 7. Tangential Slots
slot_angles = [
props.upper_slot_angle,
-props.upper_slot_angle,
props.lower_slot_angle,
-props.lower_slot_angle
]
cutters = []
cutter_depth = head_h * 0.5
for idx, ang_deg in enumerate(slot_angles):
phi = math.radians(ang_deg)
rx_local = rad_x * (1.0 + props.egg_taper * math.cos(phi))
px = rx_local * math.sin(phi)
py = rad_y * math.cos(phi)
dx_dphi = rad_x * (math.cos(phi) + props.egg_taper * math.cos(2.0 * phi))
dy_dphi = -rad_y * math.sin(phi)
t_len = math.sqrt(dx_dphi * dx_dphi + dy_dphi * dy_dphi)
tx, ty = dx_dphi / t_len, dy_dphi / t_len
nx, ny = ty, -tx
if (px * nx + py * ny) > 0:
nx, ny = -nx, -ny
inward_offset = wall_margin + (slot_w / 2.0)
sx = px + nx * inward_offset
sy = py + ny * inward_offset
tangent_angle = math.atan2(ty, tx)
c = create_pill_prism(
f"Pill_Slot_{idx}", slot_h, slot_w, cutter_depth,
Vector((sx, sy, 0.0)), tangent_angle
)
cutters.append(c)
bpy.ops.object.select_all(action='DESELECT')
for c in cutters:
c.select_set(True)
context.view_layer.objects.active = cutters[0]
bpy.ops.object.join()
cutter_tool = context.active_object
bpy.ops.object.select_all(action='DESELECT')
mask_obj.select_set(True)
context.view_layer.objects.active = mask_obj
bool_mod = mask_obj.modifiers.new(name="Strap_Slots", type='BOOLEAN')
bool_mod.object = cutter_tool
bool_mod.operation = 'DIFFERENCE'
try:
bool_mod.solver = 'MANIFOLD'
except TypeError:
bool_mod.solver = 'EXACT'
bpy.ops.object.modifier_apply(modifier=bool_mod.name)
bpy.data.objects.remove(cutter_tool, do_unlink=True)
mask_obj.location.x = -(head_w * 1.4)
=========================================================================
OPERATORS & UI PROPERTIES
=========================================================================
def update_guide_event(self, context):
"""Triggers guide redraw when any slider changes in the UI."""
if context.scene.flexface_props.show_guide:
bpy.ops.flexface.toggle_guide('EXEC_DEFAULT', enable=True)
class FlexFaceProperties(bpy.types.PropertyGroup):
show_guide: BoolProperty(
name="Show Alignment Guide",
description="Displays wireframe framing oval on the bust",
default=False
)
forward_axis: EnumProperty(
name="Forward Axis",
description="Axis pointing straight out from the nose",
items=[
("-Y", "-Y (Standard Front)", "Front view faces -Y"),
("+Y", "+Y", "Faces +Y"),
],
default="-Y",
update=update_guide_event
)
mask_width_ratio: FloatProperty(
name="Width Ratio",
description="Mask width relative to head width",
default=0.68, min=0.45, max=0.95,
update=update_guide_event
)
mask_height_ratio: FloatProperty(
name="Height Ratio",
description="Mask height relative to head height",
default=0.80, min=0.50, max=0.95,
update=update_guide_event
)
center_z_ratio: FloatProperty(
name="Center Z Ratio",
description="Vertical focal position (philtrum / midface)",
default=0.43, min=0.25, max=0.65,
update=update_guide_event
)
egg_taper: FloatProperty(
name="Egg Taper",
description="Widens brow and tapers jawline",
default=0.10, min=0.0, max=0.25,
update=update_guide_event
)
thickness_mm: FloatProperty(
name="Shell Thickness (mm)",
description="Extruded thickness for TPU print",
default=1.4, min=0.8, max=3.0
)
wall_margin_mm: FloatProperty(
name="Wall Margin (mm)",
description="Solid border distance between strap slot and edge",
default=8.5, min=4.0, max=16.0
)
slot_width_mm: FloatProperty(
name="Slot Width (mm)",
description="Strap band thickness clearance",
default=3.2, min=1.5, max=6.0
)
slot_height_mm: FloatProperty(
name="Slot Height (mm)",
description="Strap band width (length of slot)",
default=12.0, min=6.0, max=25.0
)
upper_slot_angle: FloatProperty(
name="Upper Slot Angle",
description="Angle from top for temple straps",
default=46.0, min=25.0, max=65.0
)
lower_slot_angle: FloatProperty(
name="Lower Slot Angle",
description="Angle from top for jawline straps",
default=136.0, min=105.0, max=165.0
)
class FLEXFACE_OT_ToggleGuide(bpy.types.Operator):
"""Toggles or updates the visual alignment guide in the 3D viewport"""
bl_idname = "flexface.toggle_guide"
bl_label = "Update Alignment Guide"
bl_options = {'REGISTER', 'UNDO'}
enable: BoolProperty(default=True)
def execute(self, context):
props = context.scene.flexface_props
# Delete existing guide
if "FlexFace_Guide" in bpy.data.objects:
g = bpy.data.objects["FlexFace_Guide"]
gm = g.data
bpy.data.objects.remove(g, do_unlink=True)
bpy.data.meshes.remove(gm, do_unlink=True)
if not self.enable:
props.show_guide = False
return {'FINISHED'}
active = context.active_object
if not active or active.type != 'MESH':
self.report({'WARNING'}, "Select a head or bust mesh first.")
return {'CANCELLED'}
props.show_guide = True
guide_mesh = build_guide_mesh(active, props)
guide_obj = bpy.data.objects.new("FlexFace_Guide", guide_mesh)
context.collection.objects.link(guide_obj)
guide_obj.display_type = 'WIRE'
guide_obj.show_in_front = True
guide_obj.hide_render = True
return {'FINISHED'}
class FLEXFACE_OT_GenerateMask(bpy.types.Operator):
"""Processes the head mesh and generates the flat flexible mask"""
bl_idname = "flexface.generate_mask"
bl_label = "Generate Flat Mask"
bl_options = {'REGISTER', 'UNDO'}
def execute(self, context):
try:
execute_mask_generation(context)
self.report({'INFO'}, "Flat Flexible Mask generated successfully.")
return {'FINISHED'}
except Exception as e:
self.report({'ERROR'}, str(e))
return {'CANCELLED'}
class FLEXFACE_PT_MainPanel(bpy.types.Panel):
"""Sidebar N-Panel for FlexFace controls"""
bl_label = "FlexFace Mask Generator"
bl_idname = "FLEXFACE_PT_main_panel"
bl_space_type = 'VIEW_3D'
bl_region_type = 'UI'
bl_category = 'FlexFace'
def draw(self, context):
layout = self.layout
props = context.scene.flexface_props
# Alignment Guide Section
box = layout.box()
box.label(text="1. Alignment & Framing", icon='OUTLINER_OB_CAMERA')
if not props.show_guide:
op = box.operator("flexface.toggle_guide", text="Show Alignment Guide", icon='HIDE_OFF')
op.enable = True
else:
op = box.operator("flexface.toggle_guide", text="Hide Alignment Guide", icon='HIDE_ON')
op.enable = False
col = box.column(align=True)
col.prop(props, "forward_axis")
col.prop(props, "center_z_ratio", slider=True)
col.prop(props, "mask_width_ratio", slider=True)
col.prop(props, "mask_height_ratio", slider=True)
col.prop(props, "egg_taper", slider=True)
# Print & Hardware Specs
box2 = layout.box()
box2.label(text="2. Shell & Strap Slots", icon='MOD_SOLIDIFY')
col2 = box2.column(align=True)
col2.prop(props, "thickness_mm")
col2.prop(props, "wall_margin_mm")
col2.prop(props, "slot_height_mm")
col2.prop(props, "slot_width_mm")
row = box2.row(align=True)
row.prop(props, "upper_slot_angle")
row.prop(props, "lower_slot_angle")
# Action Button
layout.separator()
big_btn = layout.operator("flexface.generate_mask", text="Generate Flat Mask", icon='MOD_SMOOTH')
big_btn.scale_y = 1.6
=========================================================================
REGISTRATION
=========================================================================
classes = (
FlexFaceProperties,
FLEXFACE_OT_ToggleGuide,
FLEXFACE_OT_GenerateMask,
FLEXFACE_PT_MainPanel,
)
def register():
for cls in classes:
bpy.utils.register_class(cls)
bpy.types.Scene.flexface_props = PointerProperty(type=FlexFaceProperties)
def unregister():
for cls in reversed(classes):
bpy.utils.unregister_class(cls)
del bpy.types.Scene.flexface_props
if name == "main":
register()