1103 lines
34 KiB
OpenSCAD
1103 lines
34 KiB
OpenSCAD
/*
|
|
===============================================================================
|
|
PARAMETRIC KEYRING GENERATOR v5
|
|
OpenSCAD Nightly / Development Snapshot
|
|
===============================================================================
|
|
|
|
Design goals
|
|
------------
|
|
* Text must NEVER extend outside the usable keyring face.
|
|
- First: textmetrics() fits actual glyph bounds to a conservative safe box.
|
|
- Second: the final text geometry is clipped by an inset copy of the real
|
|
body shape and by a safety zone around an internal ring hole.
|
|
* Ring holes and lugs automatically enforce minimum material thickness.
|
|
* Colored preview for base, text/inlay and accents.
|
|
* Useful output modes for combined STL and separate multi-color parts.
|
|
* No external libraries.
|
|
|
|
Nightly requirement
|
|
-------------------
|
|
This file uses textmetrics(), including object members:
|
|
.position, .size, .ascent, .descent and .advance.
|
|
|
|
All dimensions are millimetres.
|
|
===============================================================================
|
|
*/
|
|
|
|
|
|
/* [Output] */
|
|
|
|
output_mode = "3D"; // [3D,2D]
|
|
|
|
output_part = "All"; // [All,Base Only,Text Only,Accent Only]
|
|
|
|
// Show useful dimensions/warnings in the console
|
|
debug_info = true;
|
|
|
|
|
|
/* [Body] */
|
|
|
|
shape = "Rounded Rectangle"; // [Rounded Rectangle,Capsule,Circle,Oval,Hexagon,Dog Tag,Shield,Heart]
|
|
|
|
body_width = 58; // [20:1:120]
|
|
body_height = 30; // [15:1:80]
|
|
body_thickness = 3.2; // [1:0.1:10]
|
|
corner_radius = 6; // [0:0.5:25]
|
|
|
|
// Curve quality
|
|
quality = 96; // [24:8:240]
|
|
|
|
|
|
/* [Ring attachment] */
|
|
|
|
attachment = "Reinforced Lug"; // [Side Lug,Reinforced Lug,Inside Hole,None]
|
|
|
|
// Requested hole diameter. It may be clamped smaller for an Inside Hole if
|
|
// the chosen body is too small to preserve minimum_wall.
|
|
ring_hole_diameter = 5.2; // [2:0.1:14]
|
|
|
|
// Requested lug outer diameter. Side lugs automatically grow when necessary.
|
|
lug_outer_diameter = 12; // [6:0.5:28]
|
|
|
|
// How much the lug overlaps into the body. 0 = automatic.
|
|
lug_overlap = 0; // [0:0.5:12]
|
|
|
|
// Width of the reinforcing bridge/neck.
|
|
lug_bridge = 5; // [2:0.5:18]
|
|
|
|
// Position mode for side lugs.
|
|
// Preset positions keep the lug attached to the chosen side.
|
|
// Custom XY lets you place the lug centre manually.
|
|
lug_position_mode = "Right"; // [Right,Left,Top,Bottom,Custom XY]
|
|
|
|
// Manual lug centre offsets from the body centre.
|
|
// Used only when lug_position_mode = "Custom XY".
|
|
lug_x = 34; // [-80:0.5:80]
|
|
lug_y = 0; // [-80:0.5:80]
|
|
|
|
// Fine offset added after a preset lug position.
|
|
lug_offset_x = 0; // [-20:0.5:20]
|
|
lug_offset_y = 0; // [-20:0.5:20]
|
|
|
|
// Number of side lugs. Applies only to Side Lug / Reinforced Lug.
|
|
lug_count = 1; // [1,2]
|
|
|
|
// Second lug settings. These are independent of Lug 1.
|
|
lug2_position_mode = "Left"; // [Right,Left,Top,Bottom,Custom XY]
|
|
lug2_hole_diameter = 5.2; // [2:0.1:14]
|
|
lug2_outer_diameter = 12; // [6:0.5:28]
|
|
lug2_overlap = 0; // [0:0.5:12]
|
|
lug2_bridge = 5; // [2:0.5:18]
|
|
lug2_x = -34; // [-80:0.5:80]
|
|
lug2_y = 0; // [-80:0.5:80]
|
|
lug2_offset_x = 0; // [-20:0.5:20]
|
|
lug2_offset_y = 0; // [-20:0.5:20]
|
|
|
|
// Position mode for an internal hole.
|
|
inside_hole_position_mode = "Left"; // [Left,Right,Top,Bottom,Center,Custom XY]
|
|
|
|
// Manual internal-hole centre.
|
|
// Used only when inside_hole_position_mode = "Custom XY".
|
|
inside_hole_custom_x = -20; // [-60:0.5:60]
|
|
inside_hole_custom_y = 0; // [-40:0.5:40]
|
|
|
|
// Fine offset added after preset internal-hole positioning.
|
|
inside_hole_offset_x = 0; // [-20:0.5:20]
|
|
inside_hole_offset_y = 0; // [-20:0.5:20]
|
|
|
|
// Minimum material around every ring hole.
|
|
minimum_wall = 2.2; // [1:0.1:6]
|
|
|
|
|
|
/* [Main text] */
|
|
|
|
text_line_1 = "MAKER";
|
|
text_line_2 = "SPACE";
|
|
|
|
text_mode = "Raised"; // [None,Raised,Engraved,Cut Through,Inlay]
|
|
|
|
font = "Liberation Sans:style=Bold";
|
|
|
|
// Starting size. textmetrics() automatically reduces it.
|
|
font_size = 13; // [3:0.5:40]
|
|
minimum_font_size = 2.5; // [1:0.5:10]
|
|
text_spacing = 1.0; // [0.6:0.05:2]
|
|
|
|
// Guaranteed clearance between text and the actual body edge.
|
|
text_edge_clearance = 2.2; // [0.5:0.1:10]
|
|
|
|
// Additional clearance around an internal keyring hole.
|
|
text_hole_clearance = 2.0; // [0.5:0.1:10]
|
|
|
|
// Gap between line 1 and line 2.
|
|
line_gap = 1.3; // [0:0.1:8]
|
|
|
|
// Fine positioning after automatic layout.
|
|
text_x_adjust = 0; // [-15:0.25:15]
|
|
text_y_adjust = 0; // [-15:0.25:15]
|
|
|
|
text_relief = 0.8; // [0.2:0.1:3]
|
|
engrave_depth = 0.8; // [0.2:0.1:3]
|
|
|
|
// Inlay recess and insert height. The insert is automatically made slightly
|
|
// smaller in XY by inlay_clearance.
|
|
inlay_depth = 0.8; // [0.2:0.1:3]
|
|
inlay_clearance = 0.12; // [0:0.02:0.5]
|
|
|
|
|
|
/* [Symbol / monogram] */
|
|
|
|
symbol_text = "";
|
|
|
|
symbol_mode = "None"; // [None,Raised,Engraved,Cut Through,Inlay]
|
|
|
|
symbol_font = "Liberation Sans:style=Bold";
|
|
symbol_size = 8; // [2:0.5:30]
|
|
|
|
symbol_x = 0; // [-40:0.5:40]
|
|
symbol_y = -9; // [-30:0.5:30]
|
|
|
|
symbol_relief = 0.8; // [0.2:0.1:3]
|
|
symbol_engrave_depth = 0.8; // [0.2:0.1:3]
|
|
symbol_inlay_depth = 0.8; // [0.2:0.1:3]
|
|
|
|
|
|
/* [Border / face effect] */
|
|
|
|
border = true;
|
|
|
|
border_mode = "Raised"; // [Raised,Engraved,Inlay]
|
|
|
|
border_width = 1.1; // [0.4:0.1:5]
|
|
border_inset = 1.5; // [0.5:0.1:10]
|
|
border_relief = 0.55; // [0.2:0.05:3]
|
|
border_engrave_depth = 0.45; // [0.2:0.05:3]
|
|
border_inlay_depth = 0.6; // [0.2:0.05:3]
|
|
|
|
|
|
/* [Colors] */
|
|
|
|
// Preview color mode:
|
|
// Single Color = everything uses base_color
|
|
// Two Colors = base uses base_color, all text/decorations use secondary_color
|
|
// Three Colors = base uses base_color, text uses text_color, decorations use decoration_color
|
|
color_display_mode = "Three Colors"; // [Single Color,Two Colors,Three Colors]
|
|
|
|
// Preset palette. Select Custom to use the custom colors below.
|
|
color_scheme = "Blue White"; // [Blue White,Black Yellow,Red White,Green White,Purple Gold,Orange Black,Custom]
|
|
|
|
// Custom colors are RGBA [R,G,B,A], values 0..1
|
|
custom_base_color = [0.10,0.35,0.85,1];
|
|
custom_secondary_color = [0.95,0.95,0.95,1];
|
|
custom_text_color = [0.95,0.95,0.95,1];
|
|
custom_decoration_color = [1.00,0.55,0.05,1];
|
|
|
|
|
|
/* [Advanced safety] */
|
|
|
|
// Tiny overlap for robust booleans; avoids coincident faces.
|
|
epsilon = 0.03;
|
|
|
|
// If true, text is ALWAYS intersected with the actual safe body mask.
|
|
// Keep this on unless debugging.
|
|
strict_text_clipping = true;
|
|
|
|
// Minimum width/height retained for generated safe regions.
|
|
minimum_safe_size = 1.0;
|
|
|
|
|
|
$fn = quality;
|
|
|
|
|
|
// ============================================================================
|
|
// Utility functions
|
|
// ============================================================================
|
|
|
|
function clamp(v, lo, hi) = min(max(v, lo), hi);
|
|
function nz(v, fallback=1) = abs(v) < 0.000001 ? fallback : v;
|
|
function nonempty(s) = len(s) > 0;
|
|
|
|
function body_diameter_if_circle() = min(body_width, body_height);
|
|
|
|
function nominal_w() =
|
|
shape == "Circle" ? body_diameter_if_circle() : body_width;
|
|
|
|
function nominal_h() =
|
|
shape == "Circle" ? body_diameter_if_circle() : body_height;
|
|
|
|
// Conservative rectangles fully contained well inside each shape.
|
|
// These are intentionally cautious; strict clipping remains the final guard.
|
|
function shape_safe_fx() =
|
|
shape == "Rounded Rectangle" ? 0.92 :
|
|
shape == "Capsule" ? 0.80 :
|
|
shape == "Circle" ? 0.70 :
|
|
shape == "Oval" ? 0.70 :
|
|
shape == "Hexagon" ? 0.72 :
|
|
shape == "Dog Tag" ? 0.70 :
|
|
shape == "Shield" ? 0.58 :
|
|
shape == "Heart" ? 0.52 :
|
|
0.70;
|
|
|
|
function shape_safe_fy() =
|
|
shape == "Rounded Rectangle" ? 0.88 :
|
|
shape == "Capsule" ? 0.68 :
|
|
shape == "Circle" ? 0.70 :
|
|
shape == "Oval" ? 0.70 :
|
|
shape == "Hexagon" ? 0.72 :
|
|
shape == "Dog Tag" ? 0.68 :
|
|
shape == "Shield" ? 0.58 :
|
|
shape == "Heart" ? 0.42 :
|
|
0.65;
|
|
|
|
function safe_rect_w_raw() =
|
|
max(minimum_safe_size, nominal_w()*shape_safe_fx() - 2*text_edge_clearance);
|
|
|
|
function safe_rect_h_raw() =
|
|
max(minimum_safe_size, nominal_h()*shape_safe_fy() - 2*text_edge_clearance);
|
|
|
|
// Hole size for internal holes is limited by the conservative safe rectangle.
|
|
// Side-lug holes do not need this clamp because the lug itself can grow.
|
|
function inside_hole_max_d() =
|
|
max(1,
|
|
min(
|
|
nominal_w()*shape_safe_fx(),
|
|
nominal_h()*shape_safe_fy()
|
|
) - 2*minimum_wall);
|
|
|
|
function effective_hole_d() =
|
|
attachment == "Inside Hole"
|
|
? min(ring_hole_diameter, inside_hole_max_d())
|
|
: ring_hole_diameter;
|
|
|
|
function effective_lug_outer_d() =
|
|
max(lug_outer_diameter, effective_hole_d() + 2*minimum_wall);
|
|
|
|
function effective_lug_overlap() =
|
|
lug_overlap > 0
|
|
? min(lug_overlap, effective_lug_outer_d()*0.48)
|
|
: max(minimum_wall, effective_lug_outer_d()*0.24);
|
|
|
|
function lug_preset_x() =
|
|
lug_position_mode == "Right" ? nominal_w()/2 + effective_lug_outer_d()/2 - effective_lug_overlap() :
|
|
lug_position_mode == "Left" ? -nominal_w()/2 - effective_lug_outer_d()/2 + effective_lug_overlap() :
|
|
0;
|
|
|
|
function lug_preset_y() =
|
|
lug_position_mode == "Top" ? nominal_h()/2 + effective_lug_outer_d()/2 - effective_lug_overlap() :
|
|
lug_position_mode == "Bottom" ? -nominal_h()/2 - effective_lug_outer_d()/2 + effective_lug_overlap() :
|
|
0;
|
|
|
|
function lug_center_x() =
|
|
(lug_position_mode == "Custom XY" ? lug_x : lug_preset_x()) + lug_offset_x;
|
|
|
|
function lug_center_y() =
|
|
(lug_position_mode == "Custom XY" ? lug_y : lug_preset_y()) + lug_offset_y;
|
|
|
|
// Lug 2 uses its own hole diameter, OD, overlap, bridge and position.
|
|
function effective_lug2_hole_d() =
|
|
max(1, lug2_hole_diameter);
|
|
|
|
function effective_lug2_outer_d() =
|
|
max(lug2_outer_diameter, effective_lug2_hole_d() + 2*minimum_wall);
|
|
|
|
function effective_lug2_overlap() =
|
|
lug2_overlap > 0
|
|
? min(lug2_overlap, effective_lug2_outer_d()*0.48)
|
|
: max(minimum_wall, effective_lug2_outer_d()*0.24);
|
|
|
|
function lug2_preset_x() =
|
|
lug2_position_mode == "Right" ? nominal_w()/2 + effective_lug2_outer_d()/2 - effective_lug2_overlap() :
|
|
lug2_position_mode == "Left" ? -nominal_w()/2 - effective_lug2_outer_d()/2 + effective_lug2_overlap() :
|
|
0;
|
|
|
|
function lug2_preset_y() =
|
|
lug2_position_mode == "Top" ? nominal_h()/2 + effective_lug2_outer_d()/2 - effective_lug2_overlap() :
|
|
lug2_position_mode == "Bottom" ? -nominal_h()/2 - effective_lug2_outer_d()/2 + effective_lug2_overlap() :
|
|
0;
|
|
|
|
function lug2_center_x() =
|
|
(lug2_position_mode == "Custom XY" ? lug2_x : lug2_preset_x()) + lug2_offset_x;
|
|
|
|
function lug2_center_y() =
|
|
(lug2_position_mode == "Custom XY" ? lug2_y : lug2_preset_y()) + lug2_offset_y;
|
|
|
|
function inside_hole_x_limit() =
|
|
max(0, nominal_w()*shape_safe_fx()/2 - minimum_wall - inside_hole_radius());
|
|
|
|
function inside_hole_y_limit() =
|
|
max(0, nominal_h()*shape_safe_fy()/2 - minimum_wall - inside_hole_radius());
|
|
|
|
function inside_hole_preset_x() =
|
|
inside_hole_position_mode == "Left" ? -inside_hole_x_limit() :
|
|
inside_hole_position_mode == "Right" ? inside_hole_x_limit() : 0;
|
|
|
|
function inside_hole_preset_y() =
|
|
inside_hole_position_mode == "Top" ? inside_hole_y_limit() :
|
|
inside_hole_position_mode == "Bottom" ? -inside_hole_y_limit() : 0;
|
|
|
|
function inside_hole_x() =
|
|
clamp((inside_hole_position_mode == "Custom XY" ? inside_hole_custom_x : inside_hole_preset_x()) + inside_hole_offset_x, -inside_hole_x_limit(), inside_hole_x_limit());
|
|
|
|
function inside_hole_y() =
|
|
clamp((inside_hole_position_mode == "Custom XY" ? inside_hole_custom_y : inside_hole_preset_y()) + inside_hole_offset_y, -inside_hole_y_limit(), inside_hole_y_limit());
|
|
|
|
function hole_keepout_r() =
|
|
inside_hole_radius() + text_hole_clearance;
|
|
|
|
// Text box accounts for internal hole by reserving everything left of the
|
|
// keepout circle. Fine user X adjustment is then clamped to keep text safe.
|
|
function text_box_left() =
|
|
attachment == "Inside Hole"
|
|
? max(
|
|
-safe_rect_w_raw()/2,
|
|
inside_hole_x() + hole_keepout_r()
|
|
)
|
|
: -safe_rect_w_raw()/2;
|
|
|
|
function text_box_right() = safe_rect_w_raw()/2;
|
|
|
|
function text_box_w() =
|
|
max(minimum_safe_size, text_box_right() - text_box_left());
|
|
|
|
function text_box_h() = safe_rect_h_raw();
|
|
|
|
function text_box_cx() = (text_box_left() + text_box_right())/2;
|
|
|
|
function text_x_adjust_clamped() =
|
|
clamp(
|
|
text_x_adjust,
|
|
-text_box_w()*0.18,
|
|
text_box_w()*0.18
|
|
);
|
|
|
|
function text_y_adjust_clamped() =
|
|
clamp(
|
|
text_y_adjust,
|
|
-text_box_h()*0.18,
|
|
text_box_h()*0.18
|
|
);
|
|
|
|
|
|
// textmetrics() wrapper matching the settings used by text().
|
|
function tm(s, size, fnt, spacing=1) =
|
|
textmetrics(
|
|
text=s,
|
|
size=size,
|
|
font=fnt,
|
|
halign="left",
|
|
valign="baseline",
|
|
spacing=spacing
|
|
);
|
|
|
|
function fit_text_size(s, wanted_size, max_w, max_h, fnt, spacing=1, min_size=1) =
|
|
!nonempty(s) ? wanted_size :
|
|
let(
|
|
m = tm(s, wanted_size, fnt, spacing),
|
|
sx = max_w / nz(m.size.x),
|
|
sy = max_h / nz(m.size.y),
|
|
k = min(1, sx, sy)
|
|
)
|
|
max(min_size, wanted_size*k);
|
|
|
|
// Offset which places actual glyph ink bounds around origin, not merely the
|
|
// font's nominal advance box.
|
|
function ink_center_offset(s, size, fnt, spacing=1) =
|
|
let(m = tm(s, size, fnt, spacing))
|
|
[
|
|
-m.position.x - m.size.x/2,
|
|
-m.position.y - m.size.y/2
|
|
];
|
|
|
|
function line_count() =
|
|
(nonempty(text_line_1) ? 1 : 0)
|
|
+ (nonempty(text_line_2) ? 1 : 0);
|
|
|
|
function per_line_box_h() =
|
|
line_count() <= 1
|
|
? text_box_h()
|
|
: max(minimum_safe_size, (text_box_h()-line_gap)/2);
|
|
|
|
function line1_size() =
|
|
fit_text_size(
|
|
text_line_1, font_size,
|
|
text_box_w(), per_line_box_h(),
|
|
font, text_spacing, minimum_font_size
|
|
);
|
|
|
|
function line2_size() =
|
|
fit_text_size(
|
|
text_line_2, font_size,
|
|
text_box_w(), per_line_box_h(),
|
|
font, text_spacing, minimum_font_size
|
|
);
|
|
|
|
|
|
// ============================================================================
|
|
// Color functions
|
|
// ============================================================================
|
|
|
|
// Palette base color.
|
|
function palette_base_col() =
|
|
color_scheme == "Blue White" ? [0.08,0.32,0.78,1] :
|
|
color_scheme == "Black Yellow" ? [0.04,0.04,0.05,1] :
|
|
color_scheme == "Red White" ? [0.72,0.05,0.08,1] :
|
|
color_scheme == "Green White" ? [0.05,0.48,0.24,1] :
|
|
color_scheme == "Purple Gold" ? [0.32,0.10,0.52,1] :
|
|
color_scheme == "Orange Black" ? [0.95,0.30,0.03,1] :
|
|
custom_base_color;
|
|
|
|
// Palette secondary color, used by Two Colors mode for *everything except base*.
|
|
function palette_secondary_col() =
|
|
color_scheme == "Blue White" ? [0.96,0.97,1.00,1] :
|
|
color_scheme == "Black Yellow" ? [1.00,0.80,0.02,1] :
|
|
color_scheme == "Red White" ? [1.00,0.97,0.96,1] :
|
|
color_scheme == "Green White" ? [0.96,1.00,0.96,1] :
|
|
color_scheme == "Purple Gold" ? [0.95,0.68,0.12,1] :
|
|
color_scheme == "Orange Black" ? [0.03,0.03,0.03,1] :
|
|
custom_secondary_color;
|
|
|
|
// Palette text color, used only by Three Colors mode.
|
|
function palette_text_col() =
|
|
color_scheme == "Blue White" ? [0.96,0.97,1.00,1] :
|
|
color_scheme == "Black Yellow" ? [1.00,0.80,0.02,1] :
|
|
color_scheme == "Red White" ? [1.00,0.97,0.96,1] :
|
|
color_scheme == "Green White" ? [0.96,1.00,0.96,1] :
|
|
color_scheme == "Purple Gold" ? [0.95,0.68,0.12,1] :
|
|
color_scheme == "Orange Black" ? [0.03,0.03,0.03,1] :
|
|
custom_text_color;
|
|
|
|
// Palette decoration/accent color, used only by Three Colors mode.
|
|
function palette_decoration_col() =
|
|
color_scheme == "Blue White" ? [0.15,0.72,1.00,1] :
|
|
color_scheme == "Black Yellow" ? [1.00,0.52,0.00,1] :
|
|
color_scheme == "Red White" ? [1.00,0.58,0.12,1] :
|
|
color_scheme == "Green White" ? [0.20,0.85,0.52,1] :
|
|
color_scheme == "Purple Gold" ? [1.00,0.73,0.18,1] :
|
|
color_scheme == "Orange Black" ? [1.00,0.76,0.20,1] :
|
|
custom_decoration_color;
|
|
|
|
// Final preview colors according to the requested display mode.
|
|
function base_col() =
|
|
palette_base_col();
|
|
|
|
function text_col() =
|
|
color_display_mode == "Single Color" ? palette_base_col() :
|
|
color_display_mode == "Two Colors" ? palette_secondary_col() :
|
|
palette_text_col();
|
|
|
|
function decoration_col() =
|
|
color_display_mode == "Single Color" ? palette_base_col() :
|
|
color_display_mode == "Two Colors" ? palette_secondary_col() :
|
|
palette_decoration_col();
|
|
|
|
|
|
// ============================================================================
|
|
// ============================================================================
|
|
// 2D body geometry
|
|
// ============================================================================
|
|
|
|
module rounded_rectangle_2d(w, h, r) {
|
|
rr = clamp(r, 0, min(w,h)/2);
|
|
if (rr <= epsilon)
|
|
square([w,h], center=true);
|
|
else
|
|
offset(r=rr)
|
|
square(
|
|
[max(epsilon,w-2*rr), max(epsilon,h-2*rr)],
|
|
center=true
|
|
);
|
|
}
|
|
|
|
module capsule_2d(w,h) {
|
|
if (w >= h) {
|
|
r = h/2;
|
|
hull() {
|
|
translate([-w/2+r,0]) circle(r=r);
|
|
translate([ w/2-r,0]) circle(r=r);
|
|
}
|
|
} else {
|
|
r = w/2;
|
|
hull() {
|
|
translate([0,-h/2+r]) circle(r=r);
|
|
translate([0, h/2-r]) circle(r=r);
|
|
}
|
|
}
|
|
}
|
|
|
|
module hexagon_2d(w,h) {
|
|
polygon([
|
|
[-w/2,0],[-w/4,h/2],[w/4,h/2],
|
|
[w/2,0],[w/4,-h/2],[-w/4,-h/2]
|
|
]);
|
|
}
|
|
|
|
module dog_tag_2d(w,h,r) {
|
|
rr = clamp(r,0.8,min(w,h)/5);
|
|
hull() {
|
|
translate([-w/2+rr,-h/2+rr]) circle(r=rr);
|
|
translate([ w/2-rr,-h/2+rr]) circle(r=rr);
|
|
translate([-w/2+rr, h/2-rr*1.7]) circle(r=rr);
|
|
translate([ w/2-rr, h/2-rr*1.7]) circle(r=rr);
|
|
translate([0,h/2-rr*0.30])
|
|
square([max(epsilon,w-4*rr),rr*0.55],center=true);
|
|
}
|
|
}
|
|
|
|
module shield_2d(w,h) {
|
|
polygon([
|
|
[-w/2,h*0.40],
|
|
[-w*0.36,h/2],
|
|
[ w*0.36,h/2],
|
|
[ w/2,h*0.40],
|
|
[ w*0.43,-h*0.08],
|
|
[ w*0.24,-h*0.32],
|
|
[ 0,-h/2],
|
|
[-w*0.24,-h*0.32],
|
|
[-w*0.43,-h*0.08]
|
|
]);
|
|
}
|
|
|
|
module heart_2d(w,h) {
|
|
// Symmetric, solid printable heart centered around the origin.
|
|
scale([w/40,h/38])
|
|
union() {
|
|
hull() {
|
|
translate([-10,7]) circle(r=10);
|
|
translate([0,-15]) circle(r=1.0);
|
|
}
|
|
hull() {
|
|
translate([10,7]) circle(r=10);
|
|
translate([0,-15]) circle(r=1.0);
|
|
}
|
|
}
|
|
}
|
|
|
|
module core_body_2d() {
|
|
w = nominal_w();
|
|
h = nominal_h();
|
|
|
|
if (shape == "Rounded Rectangle")
|
|
rounded_rectangle_2d(w,h,corner_radius);
|
|
else if (shape == "Capsule")
|
|
capsule_2d(w,h);
|
|
else if (shape == "Circle")
|
|
circle(d=body_diameter_if_circle());
|
|
else if (shape == "Oval")
|
|
scale([w/2,h/2]) circle(r=1);
|
|
else if (shape == "Hexagon")
|
|
hexagon_2d(w,h);
|
|
else if (shape == "Dog Tag")
|
|
dog_tag_2d(w,h,corner_radius);
|
|
else if (shape == "Shield")
|
|
shield_2d(w,h);
|
|
else if (shape == "Heart")
|
|
heart_2d(w,h);
|
|
else
|
|
rounded_rectangle_2d(w,h,corner_radius);
|
|
}
|
|
|
|
|
|
// ============================================================================
|
|
// Attachment geometry
|
|
// ============================================================================
|
|
|
|
module one_lug_positive_2d(position_mode, cx, cy, d, overlap, bridge) {
|
|
bridge_d = max(bridge, minimum_wall*2);
|
|
|
|
ax =
|
|
position_mode == "Right" ? nominal_w()/2-overlap :
|
|
position_mode == "Left" ? -nominal_w()/2+overlap :
|
|
clamp(cx, -nominal_w()/2+overlap, nominal_w()/2-overlap);
|
|
|
|
ay =
|
|
position_mode == "Top" ? nominal_h()/2-overlap :
|
|
position_mode == "Bottom" ? -nominal_h()/2+overlap :
|
|
clamp(cy, -nominal_h()/2+overlap, nominal_h()/2-overlap);
|
|
|
|
// Main neck. hull() guarantees a connected printable join.
|
|
hull() {
|
|
translate([ax,ay]) circle(d=bridge_d);
|
|
translate([cx,cy]) circle(d=d);
|
|
}
|
|
|
|
// Reinforced Lug adds two gussets perpendicular to the bridge direction.
|
|
if (attachment == "Reinforced Lug") {
|
|
vx = cx-ax;
|
|
vy = cy-ay;
|
|
vl = max(epsilon, sqrt(vx*vx + vy*vy));
|
|
px = -vy/vl;
|
|
py = vx/vl;
|
|
|
|
for (sgn=[-1,1])
|
|
hull() {
|
|
translate([
|
|
ax + px*sgn*bridge_d*0.36,
|
|
ay + py*sgn*bridge_d*0.36
|
|
])
|
|
circle(d=max(minimum_wall*1.6,bridge_d*0.55));
|
|
|
|
translate([
|
|
cx - vx/vl*d*0.18 + px*sgn*d*0.18,
|
|
cy - vy/vl*d*0.18 + py*sgn*d*0.18
|
|
])
|
|
circle(d=max(minimum_wall*1.6,bridge_d*0.50));
|
|
}
|
|
}
|
|
}
|
|
|
|
module side_lug_1_positive_2d() {
|
|
one_lug_positive_2d(
|
|
lug_position_mode,
|
|
lug_center_x(),
|
|
lug_center_y(),
|
|
effective_lug_outer_d(),
|
|
effective_lug_overlap(),
|
|
lug_bridge
|
|
);
|
|
}
|
|
|
|
module side_lug_2_positive_2d() {
|
|
one_lug_positive_2d(
|
|
lug2_position_mode,
|
|
lug2_center_x(),
|
|
lug2_center_y(),
|
|
effective_lug2_outer_d(),
|
|
effective_lug2_overlap(),
|
|
lug2_bridge
|
|
);
|
|
}
|
|
|
|
module attachment_positive_2d() {
|
|
if (attachment == "Side Lug" || attachment == "Reinforced Lug") {
|
|
side_lug_1_positive_2d();
|
|
|
|
if (lug_count >= 2)
|
|
side_lug_2_positive_2d();
|
|
}
|
|
}
|
|
|
|
module ring_hole_2d(extra=0) {
|
|
if (attachment == "Side Lug" || attachment == "Reinforced Lug") {
|
|
translate([lug_center_x(),lug_center_y()])
|
|
circle(d=max(epsilon,effective_hole_d()+2*extra));
|
|
|
|
if (lug_count >= 2)
|
|
translate([lug2_center_x(),lug2_center_y()])
|
|
circle(d=max(epsilon,effective_lug2_hole_d()+2*extra));
|
|
}
|
|
|
|
else if (attachment == "Inside Hole")
|
|
translate([inside_hole_x(),inside_hole_y()])
|
|
circle(d=max(epsilon,effective_hole_d()+2*extra));
|
|
}
|
|
|
|
module full_profile_2d() {
|
|
difference() {
|
|
union() {
|
|
core_body_2d();
|
|
attachment_positive_2d();
|
|
}
|
|
ring_hole_2d();
|
|
}
|
|
}
|
|
|
|
|
|
// ============================================================================
|
|
// Safe masks
|
|
// ============================================================================
|
|
|
|
module inset_core_mask_2d(clearance) {
|
|
// offset(delta<0) follows the ACTUAL contour, so circles, hearts,
|
|
// shields, etc. receive a true geometry-based inset.
|
|
offset(delta=-max(0,clearance))
|
|
core_body_2d();
|
|
}
|
|
|
|
module text_safe_mask_2d() {
|
|
difference() {
|
|
inset_core_mask_2d(text_edge_clearance);
|
|
|
|
// Reserve a wider safety circle around an internal ring hole.
|
|
if (attachment == "Inside Hole")
|
|
ring_hole_2d(text_hole_clearance);
|
|
}
|
|
}
|
|
|
|
module accent_safe_mask_2d() {
|
|
difference() {
|
|
inset_core_mask_2d(max(0.5,text_edge_clearance*0.5));
|
|
if (attachment == "Inside Hole")
|
|
ring_hole_2d(max(0.5,text_hole_clearance*0.5));
|
|
}
|
|
}
|
|
|
|
|
|
// ============================================================================
|
|
// Text geometry
|
|
// ============================================================================
|
|
|
|
module tight_text_2d(s,size,fnt,spacing=1) {
|
|
if (nonempty(s)) {
|
|
o = ink_center_offset(s,size,fnt,spacing);
|
|
translate(o)
|
|
text(
|
|
text=s,
|
|
size=size,
|
|
font=fnt,
|
|
halign="left",
|
|
valign="baseline",
|
|
spacing=spacing
|
|
);
|
|
}
|
|
}
|
|
|
|
module raw_fitted_text_2d() {
|
|
n = line_count();
|
|
cx = text_box_cx() + text_x_adjust_clamped();
|
|
cy = text_y_adjust_clamped();
|
|
|
|
if (n == 1) {
|
|
s = nonempty(text_line_1) ? text_line_1 : text_line_2;
|
|
fs = nonempty(text_line_1) ? line1_size() : line2_size();
|
|
|
|
translate([cx,cy])
|
|
tight_text_2d(s,fs,font,text_spacing);
|
|
}
|
|
|
|
if (n == 2) {
|
|
fs1 = line1_size();
|
|
fs2 = line2_size();
|
|
|
|
m1 = tm(text_line_1,fs1,font,text_spacing);
|
|
m2 = tm(text_line_2,fs2,font,text_spacing);
|
|
|
|
total_h = m1.size.y + line_gap + m2.size.y;
|
|
|
|
y1 = cy + total_h/2 - m1.size.y/2;
|
|
y2 = cy - total_h/2 + m2.size.y/2;
|
|
|
|
translate([cx,y1])
|
|
tight_text_2d(text_line_1,fs1,font,text_spacing);
|
|
|
|
translate([cx,y2])
|
|
tight_text_2d(text_line_2,fs2,font,text_spacing);
|
|
}
|
|
}
|
|
|
|
module fitted_text_2d() {
|
|
if (strict_text_clipping)
|
|
intersection() {
|
|
raw_fitted_text_2d();
|
|
text_safe_mask_2d();
|
|
}
|
|
else
|
|
raw_fitted_text_2d();
|
|
}
|
|
|
|
module symbol_raw_2d() {
|
|
if (nonempty(symbol_text))
|
|
translate([symbol_x,symbol_y])
|
|
tight_text_2d(symbol_text,symbol_size,symbol_font,1);
|
|
}
|
|
|
|
module symbol_safe_2d() {
|
|
if (strict_text_clipping)
|
|
intersection() {
|
|
symbol_raw_2d();
|
|
text_safe_mask_2d();
|
|
}
|
|
else
|
|
symbol_raw_2d();
|
|
}
|
|
|
|
|
|
// ============================================================================
|
|
// Border geometry
|
|
// ============================================================================
|
|
|
|
module border_ring_2d() {
|
|
// Inset border follows the exact body silhouette.
|
|
difference() {
|
|
offset(delta=-border_inset)
|
|
core_body_2d();
|
|
offset(delta=-(border_inset+border_width))
|
|
core_body_2d();
|
|
}
|
|
}
|
|
|
|
|
|
// ============================================================================
|
|
// Feature classification helpers
|
|
// ============================================================================
|
|
|
|
function text_is_subtractive() =
|
|
text_mode == "Engraved"
|
|
|| text_mode == "Cut Through"
|
|
|| text_mode == "Inlay";
|
|
|
|
function symbol_is_subtractive() =
|
|
symbol_mode == "Engraved"
|
|
|| symbol_mode == "Cut Through"
|
|
|| symbol_mode == "Inlay";
|
|
|
|
function border_is_subtractive() =
|
|
border && (
|
|
border_mode == "Engraved"
|
|
|| border_mode == "Inlay"
|
|
);
|
|
|
|
|
|
// ============================================================================
|
|
// Base part
|
|
// ============================================================================
|
|
|
|
module base_solid_3d() {
|
|
difference() {
|
|
linear_extrude(height=body_thickness)
|
|
full_profile_2d();
|
|
|
|
// Main text cavities/cuts.
|
|
if (text_mode == "Engraved" && line_count()>0)
|
|
translate([0,0,max(0,body_thickness-engrave_depth)])
|
|
linear_extrude(height=min(body_thickness,engrave_depth)+2*epsilon)
|
|
fitted_text_2d();
|
|
|
|
if (text_mode == "Cut Through" && line_count()>0)
|
|
translate([0,0,-epsilon])
|
|
linear_extrude(height=body_thickness+2*epsilon)
|
|
fitted_text_2d();
|
|
|
|
if (text_mode == "Inlay" && line_count()>0)
|
|
translate([0,0,max(0,body_thickness-inlay_depth)])
|
|
linear_extrude(height=min(body_thickness,inlay_depth)+2*epsilon)
|
|
fitted_text_2d();
|
|
|
|
// Symbol cavities/cuts.
|
|
if (symbol_mode == "Engraved" && nonempty(symbol_text))
|
|
translate([0,0,max(0,body_thickness-symbol_engrave_depth)])
|
|
linear_extrude(height=min(body_thickness,symbol_engrave_depth)+2*epsilon)
|
|
symbol_safe_2d();
|
|
|
|
if (symbol_mode == "Cut Through" && nonempty(symbol_text))
|
|
translate([0,0,-epsilon])
|
|
linear_extrude(height=body_thickness+2*epsilon)
|
|
symbol_safe_2d();
|
|
|
|
if (symbol_mode == "Inlay" && nonempty(symbol_text))
|
|
translate([0,0,max(0,body_thickness-symbol_inlay_depth)])
|
|
linear_extrude(height=min(body_thickness,symbol_inlay_depth)+2*epsilon)
|
|
symbol_safe_2d();
|
|
|
|
// Border cavity.
|
|
if (border && border_mode == "Engraved")
|
|
translate([0,0,max(0,body_thickness-border_engrave_depth)])
|
|
linear_extrude(height=min(body_thickness,border_engrave_depth)+2*epsilon)
|
|
border_ring_2d();
|
|
|
|
if (border && border_mode == "Inlay")
|
|
translate([0,0,max(0,body_thickness-border_inlay_depth)])
|
|
linear_extrude(height=min(body_thickness,border_inlay_depth)+2*epsilon)
|
|
border_ring_2d();
|
|
}
|
|
}
|
|
|
|
|
|
// ============================================================================
|
|
// Text / inlay part
|
|
// ============================================================================
|
|
|
|
module text_part_3d() {
|
|
// Raised main text.
|
|
if (text_mode == "Raised" && line_count()>0)
|
|
translate([0,0,body_thickness-epsilon])
|
|
linear_extrude(height=text_relief+epsilon)
|
|
fitted_text_2d();
|
|
|
|
// Inlay text. Negative offset creates assembly clearance.
|
|
if (text_mode == "Inlay" && line_count()>0)
|
|
translate([0,0,body_thickness-inlay_depth])
|
|
linear_extrude(height=inlay_depth)
|
|
offset(delta=-inlay_clearance)
|
|
fitted_text_2d();
|
|
|
|
// Raised symbol shares the text color.
|
|
if (symbol_mode == "Raised" && nonempty(symbol_text))
|
|
translate([0,0,body_thickness-epsilon])
|
|
linear_extrude(height=symbol_relief+epsilon)
|
|
symbol_safe_2d();
|
|
|
|
if (symbol_mode == "Inlay" && nonempty(symbol_text))
|
|
translate([0,0,body_thickness-symbol_inlay_depth])
|
|
linear_extrude(height=symbol_inlay_depth)
|
|
offset(delta=-inlay_clearance)
|
|
symbol_safe_2d();
|
|
}
|
|
|
|
|
|
// ============================================================================
|
|
// Accent part
|
|
// ============================================================================
|
|
|
|
module accent_part_3d() {
|
|
if (border && border_mode == "Raised")
|
|
translate([0,0,body_thickness-epsilon])
|
|
linear_extrude(height=border_relief+epsilon)
|
|
border_ring_2d();
|
|
|
|
if (border && border_mode == "Inlay")
|
|
translate([0,0,body_thickness-border_inlay_depth])
|
|
linear_extrude(height=border_inlay_depth)
|
|
offset(delta=-min(inlay_clearance,border_width*0.25))
|
|
border_ring_2d();
|
|
}
|
|
|
|
|
|
// ============================================================================
|
|
// 2D output
|
|
// ============================================================================
|
|
|
|
module base_part_2d() {
|
|
difference() {
|
|
full_profile_2d();
|
|
|
|
if (text_mode == "Cut Through")
|
|
fitted_text_2d();
|
|
|
|
if (symbol_mode == "Cut Through")
|
|
symbol_safe_2d();
|
|
}
|
|
}
|
|
|
|
module text_part_2d() {
|
|
if (
|
|
text_mode == "Raised"
|
|
|| text_mode == "Engraved"
|
|
|| text_mode == "Inlay"
|
|
)
|
|
fitted_text_2d();
|
|
|
|
if (
|
|
symbol_mode == "Raised"
|
|
|| symbol_mode == "Engraved"
|
|
|| symbol_mode == "Inlay"
|
|
)
|
|
symbol_safe_2d();
|
|
}
|
|
|
|
module accent_part_2d() {
|
|
if (border)
|
|
border_ring_2d();
|
|
}
|
|
|
|
|
|
// ============================================================================
|
|
// Colored assembly / selective export
|
|
// ============================================================================
|
|
|
|
module colored_3d() {
|
|
if (output_part == "All" || output_part == "Base Only")
|
|
color(base_col())
|
|
base_solid_3d();
|
|
|
|
if (output_part == "All" || output_part == "Text Only")
|
|
color(text_col())
|
|
text_part_3d();
|
|
|
|
if (output_part == "All" || output_part == "Accent Only")
|
|
color(decoration_col())
|
|
accent_part_3d();
|
|
}
|
|
|
|
module colored_2d() {
|
|
if (output_part == "All" || output_part == "Base Only")
|
|
color(base_col())
|
|
base_part_2d();
|
|
|
|
if (output_part == "All" || output_part == "Text Only")
|
|
color(text_col())
|
|
text_part_2d();
|
|
|
|
if (output_part == "All" || output_part == "Accent Only")
|
|
color(decoration_col())
|
|
accent_part_2d();
|
|
}
|
|
|
|
|
|
// ============================================================================
|
|
// Diagnostics
|
|
// ============================================================================
|
|
|
|
module diagnostics() {
|
|
if (debug_info) {
|
|
echo("=== KEYRING GENERATOR v5 ===");
|
|
echo("Shape",shape);
|
|
echo("Body", [nominal_w(),nominal_h(),body_thickness]);
|
|
echo("Attachment",attachment);
|
|
echo("Requested ring hole",ring_hole_diameter);
|
|
echo("Effective ring hole",effective_hole_d());
|
|
echo("Lug count",lug_count);
|
|
echo("Lug 1 position mode",lug_position_mode);
|
|
echo("Lug 1 center",[lug_center_x(),lug_center_y()]);
|
|
if (lug_count >= 2) {
|
|
echo("Lug 2 position mode",lug2_position_mode);
|
|
echo("Lug 2 center",[lug2_center_x(),lug2_center_y()]);
|
|
echo("Lug 2 hole diameter",effective_lug2_hole_d());
|
|
echo("Lug 2 outer diameter",effective_lug2_outer_d());
|
|
echo("Lug 2 radial wall",(effective_lug2_outer_d()-effective_lug2_hole_d())/2);
|
|
}
|
|
echo("Inside hole position mode",inside_hole_position_mode);
|
|
|
|
if (attachment == "Side Lug" || attachment == "Reinforced Lug") {
|
|
echo("Requested lug OD",lug_outer_diameter);
|
|
echo("Effective lug OD",effective_lug_outer_d());
|
|
echo(
|
|
"Lug radial wall",
|
|
(effective_lug_outer_d()-effective_hole_d())/2
|
|
);
|
|
}
|
|
|
|
if (attachment == "Inside Hole") {
|
|
echo("Inside hole center",[inside_hole_x(),inside_hole_y()]);
|
|
echo("Inside hole max diameter",inside_hole_max_d());
|
|
|
|
if (ring_hole_diameter > inside_hole_max_d())
|
|
echo(
|
|
"NOTICE: Inside hole was automatically reduced to preserve minimum_wall."
|
|
);
|
|
}
|
|
|
|
echo("Safe text rectangle",[text_box_w(),text_box_h()]);
|
|
echo("Safe text center",[text_box_cx(),0]);
|
|
echo("Text line 1 fitted size",line1_size());
|
|
echo("Text line 2 fitted size",line2_size());
|
|
echo("Strict text clipping",strict_text_clipping);
|
|
echo("Color display mode",color_display_mode);
|
|
|
|
if (text_mode == "Engraved" && engrave_depth >= body_thickness)
|
|
echo(
|
|
"NOTICE: Engraving depth reaches body thickness; consider Cut Through."
|
|
);
|
|
|
|
if (text_mode == "Inlay" && inlay_depth >= body_thickness)
|
|
echo(
|
|
"WARNING: Inlay depth consumes full base thickness."
|
|
);
|
|
|
|
if (border && border_inset+border_width >= min(nominal_w(),nominal_h())/2)
|
|
echo(
|
|
"WARNING: Border inset/width may collapse on this body size."
|
|
);
|
|
}
|
|
}
|
|
|
|
|
|
// ============================================================================
|
|
// Build
|
|
// ============================================================================
|
|
|
|
diagnostics();
|
|
|
|
if (output_mode == "2D")
|
|
colored_2d();
|
|
else
|
|
colored_3d();
|