OpenSCAD configurable calendar 3D model
OpenSCAD configurable calendar 3D model
OpenSCAD is truly amazing in a way that no other 3D modeling software is, including those with limited scripting abilities.
You can implement standard algorithms from general-purpose languages, like the impressive Zeller's Congruence used to calculate the day of the week for any given date. I utilized this to make the calendar automatically adjust the date offset. Simply change the year number in the configurator, and the model remains accurate:
According to my computer, Jan 1st, 2025, is indeed a Wednesday.
A quick calendar check confirms that Jan 1st, 2056, is a Saturday!
Here’s the OpenSCAD function:
function getFirstDay(year, month, day = 1) = let ( q = day, m = month < 3 ? month + 12 : month, adjusted_year = month < 3 ? year - 1 : year, K = (adjusted_year) % 100, J = floor((adjusted_year) / 100) ) ( let ( h = (q + floor((13 * (m + 1)) / 5) + K + floor(K / 4) + floor(J / 4) + 5 * J) % 7 ) ((h + 5) % 7) + 1 );
I kept the variable names consistent with the Wikipedia page for easier verification.
Additionally, I included a generic leap year check and a function to get the correct number of days in a month:
function daysAmount(month) = month == 2 ? (year % 4 == 0 && (year % 400 == 0 || year % 100 != 0)) ? 29 : 28 : (month % 2 == 0 ? (month >= 8 ? 31 : 30) : (month >= 8 ? 30 : 31));
Working with dates is always a “pleasure,” but doing so in a language with no built-in date support was especially interesting!
This project is highly user-friendly with multiple configurable options, including:
- Selection of months to render, column layout, and layer height adjustments for multi-material printing.
- Custom holiday markings, such as highlighting Saturdays in red and adding holidays through a comma-separated list.
- Full translation support for titles, month names, and day names.
- Configurable holes for magnets and screws to mount on fridges or walls.
Some options leverage libraries like JustinSDK/dotSCAD and davidson16807/relativity.scad lor string manipulation (e.g., replacing %year
in the title with the selected year or splitting holiday dates).
The model is available on Makerworld. If it ever gets taken down (possibly due to my dissatisfaction with the recent Bambu firmware changes), here’s the full source code:
/** * MIT License * * Copyright (c) 2025 Dominik Chrástecký * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal * in the Software without restriction, including without limitation the rights * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the Software is * furnished to do so, subject to the following conditions: * * The above copyright notice and this permission notice shall be included in all * copies or substantial portions of the Software. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ /* [What to render] */ // Whether to render the red parts (holidays, Sundays, Saturdays if enabled) redParts = true; // Whether to render the white parts (background) whiteParts = true; // Whether to render the black parts (dates, text) blackParts = true; // Whether to render the blue parts (background behind month names) blueParts = true; /* [General] */ // The year to generate the calendar for year = 2024; // The start month, useful if you want to print the calendar in multiple parts startMonth = 1; // The end month, useful if you want to print the calendar in multiple parts endMonth = 12; // comma separated holiday dates with day first and month second, for example: 1.1,8.5,5.7,6.7 (means Jan 1st, May 8th, Jul 5th, Jul 6th) holidays = ""; // Whether you want to print using AMS, MMU or a similar system, or a single extruder version multiMaterial = true; // The height of the calendar calendarHeight = 3.2; // a number between 10 and 360, the higher the better quality quality = 60; // [10:360] // whether Saturdays should be rendered in red in addition to Sundays saturdayRedColor = false; // how many months to put on a single row monthsPerRow = 3; /* [Hook and magnet holes] */ // Enable hook holes? hookHole = true; // Enable magnet hole? magnetHole = true; // How much to add to the various sizes, if your printer is not well calibrated, you might need to make the tolerances larger tolerances = 0.2; // The diameter of the lower part of the hook hole hookHoleDiameter = 5.6; // The width of the upper part of the hook hole hookHoleUpperPartWidth = 3; // Whether the magnet is round or square roundMagnet = true; // The diameter of the magnet, ignored if the magnet is not round magnetDiameter = 10; // The width of the magnet, ignored if the magnet is round magnetWidth = 10; // The depth of the magnet, ignored if the magnet is round magnetDepth = 10; // The height of the magnet hole. Please make sure the calendarHeight is larger than the magnet hole, otherwise weird stuff might happen magnetHeight = 2; // When checked, the magnet hole will be hidden inside the calendar and you will have to pause the print to insert the magnet, if unchecked, the magnet hole will be visible on the back hiddenMagnet = true; /* [Text settings] */ // The name of the font to use font = "Liberation Mono:style=Bold"; // The size of the month names monthFontSize = 5.01; // The size of the font for name days dayNameFontSize = 2.51; // The size of the font for calendar title titleFontSize = 10.01; /* [Calendar title] */ // The title of the calendar, %year will be replaced with the current year calendarTitle = "Calendar %year"; // The space around the calendar title, make larger if your magnet is too big to fit titleSpace = 15; /* [Day names] */ // Your language version for Monday monday = "MON"; // Your language version for Tuesday tuesday = "TUE"; // Your language version for Wednesday wednesday = "WED"; // Your language version for Thursday thursday = "THU"; // Your language version for Friday friday = "FRI"; // Your language version for Saturday saturday = "SAT"; // Your language version for Sunday sunday = "SUN"; /* [Month names] */ // Your language version for January january = "JANUARY"; // Your language version for February february = "FEBRUARY"; // Your language version for March march = "MARCH"; // Your language version for April april = "APRIL"; // Your language version for May may = "MAY"; // Your language version for June june = "JUNE"; // Your language version for July july = "JULY"; // Your language version for August august = "AUGUST"; // Your language version for September september = "SEPTEMBER"; // Your language version for October october = "OCTOBER"; // Your language version for November november = "NOVEMBER"; // Your language version for December december = "DECEMBER"; function getFirstDay(year, month, day = 1) = let ( q = day, m = month < 3 ? month + 12 : month, adjusted_year = month < 3 ? year - 1 : year, K = (adjusted_year) % 100, J = floor((adjusted_year) / 100) ) ( let ( h = (q + floor((13 * (m + 1)) / 5) + K + floor(K / 4) + floor(J / 4) + 5 * J) % 7 ) ((h + 5) % 7) + 1 ); // from https://github.com/JustinSDK/dotSCAD/blob/master/src/util/_impl/_split_str_impl.scad function sub_str(t, begin, end) = let( ed = is_undef(end) ? len(t) : end, cum = [ for (i = begin, s = t[i], is_continue = i < ed; is_continue; i = i + 1, is_continue = i < ed, s = is_continue ? str(s, t[i]) : undef) s ] ) cum[len(cum) - 1]; function _split_t_by(idxs, t) = let(leng = len(idxs)) [sub_str(t, 0, idxs[0]), each [for (i = 0; i < leng; i = i + 1) sub_str(t, idxs[i] + 1, idxs[i + 1])]]; function daysAmount(month) = month == 2 ? (year % 4 == 0 && (year % 400 == 0 || year % 100 != 0)) ? 29 : 28 : (month % 2 == 0 ? (month >= 8 ? 31 : 30) : (month >= 8 ? 30 : 31)); function split_str(t, delimiter) = len(search(delimiter, t)) == 0 ? [t] : _split_t_by(search(delimiter, t, 0)[0], t); function contains(value, array) = count_true([for (element = array) element == value]) > 0; function count_true(values) = sum([for (v = values) v ? 1 : 0]); function sum(values) = sum_helper(values, 0); function sum_helper(values, i) = i < len(values) ? values[i] + sum_helper(values, i + 1) : 0; // from https://github.com/davidson16807/relativity.scad/blob/master/strings.scad function replace(string, replaced, replacement, ignore_case=false, regex=false) = _replace(string, replacement, index_of(string, replaced, ignore_case=ignore_case, regex=regex)); function _replace(string, replacement, indices, i=0) = i >= len(indices)? after(string, indices[len(indices)-1].y-1) : i == 0? str( before(string, indices[0].x), replacement, _replace(string, replacement, indices, i+1) ) : str( between(string, indices[i-1].y, indices[i].x), replacement, _replace(string, replacement, indices, i+1) ) ; function after(string, index=0) = string == undef? undef : index == undef? undef : index < 0? string : index >= len(string)-1? "" : join([for (i=[index+1:len(string)-1]) string[i]]) ; function before(string, index=0) = string == undef? undef : index == undef? undef : index > len(string)? string : index <= 0? "" : join([for (i=[0:index-1]) string[i]]) ; function join(strings, delimeter="") = strings == undef? undef : strings == []? "" : _join(strings, len(strings)-1, delimeter); function _join(strings, index, delimeter) = index==0 ? strings[index] : str(_join(strings, index-1, delimeter), delimeter, strings[index]) ; function index_of(string, pattern, ignore_case=false, regex=false) = _index_of(string, regex? _parse_rx(pattern) : pattern, regex=regex, ignore_case=ignore_case); function _index_of(string, pattern, pos=0, regex=false, ignore_case=false) = //[start,end] pos == undef? undef : pos >= len(string)? [] : _index_of_recurse(string, pattern, _index_of_first(string, pattern, pos=pos, regex=regex, ignore_case=ignore_case), pos, regex, ignore_case) ; function _index_of_recurse(string, pattern, index_of_first, pos, regex, ignore_case) = index_of_first == undef? [] : concat( [index_of_first], _coalesce_on( _index_of(string, pattern, pos = index_of_first.y, regex=regex, ignore_case=ignore_case), undef, []) ); function _index_of_first(string, pattern, pos=0, ignore_case=false, regex=false) = pos == undef? undef : pos >= len(string)? undef : _coalesce_on([pos, _match(string, pattern, pos, regex=regex, ignore_case=ignore_case)], [pos, undef], _index_of_first(string, pattern, pos+1, regex=regex, ignore_case=ignore_case)) ; function _coalesce_on(value, error, fallback) = value == error? fallback : value ; function _match(string, pattern, pos, regex=false, ignore_case=false) = regex? _match_parsed_peg(string, undef, pos, peg_op=pattern, ignore_case=ignore_case)[_POS] : starts_with(string, pattern, pos, ignore_case=ignore_case)? pos+len(pattern) : undef ; function starts_with(string, start, pos=0, ignore_case=false, regex=false) = regex? _match_parsed_peg(string, undef, pos, _parse_rx(start), ignore_case=ignore_case) != undef : equals( substring(string, pos, len(start)), start, ignore_case=ignore_case) ; function equals(this, that, ignore_case=false) = ignore_case? lower(this) == lower(that) : this==that ; function substring(string, start, length=undef) = length == undef? between(string, start, len(string)) : between(string, start, length+start) ; function between(string, start, end) = string == undef? undef : start == undef? undef : start > len(string)? undef : start < 0? before(string, end) : end == undef? undef : end < 0? undef : end > len(string)? after(string, start-1) : start > end? undef : start == end ? "" : join([for (i=[start:end-1]) string[i]]) ; module _radiusCorner(depth, radius) { difference(){ translate([radius / 2 + 0.1, radius / 2 + 0.1, 0]){ cube([radius + 0.2, radius + 0.1, depth + 0.2], center=true); } cylinder(h = depth + 0.2, r = radius, center=true); } } module roundedRectangle(width, height, depth, radius, leftTop = true, leftBottom = true, rightTop = true, rightBottom = true) { translate([width / 2, height / 2, depth / 2]) difference() { cube([ width, height, depth, ], center = true); if (rightTop) { translate([width / 2 - radius, height / 2 - radius]) { rotate(0) { _radiusCorner(depth, radius); } } } if (leftTop) { translate([-width / 2 + radius, height / 2 - radius]) { rotate(90) { _radiusCorner(depth, radius); } } } if (leftBottom) { translate([-width / 2 + radius, -height / 2 + radius]) { rotate(180) { _radiusCorner(depth, radius); } } } if (rightBottom) { translate([width / 2 - radius, -height / 2 + radius]) { rotate(270) { _radiusCorner(depth, radius); } } } } } $fn = quality; holidaysArray = split_str(holidays, ","); hasHolidays = !(len(holidaysArray) == 1 && holidaysArray[0] == ""); plateWidth = 80; colorWhite = "#ffffff"; colorBlue = "#2323F7"; colorBlack = "#000000"; colorRed = "#ff0000"; noMmuBlueOffset = 0.4; noMmuBlackOffset = 0.8; noMmuRedOffset = 1.2; noMmuWhiteOffset = 1.6; module monthBg(plateWidth, plateDepth, depth, margin) { height = 0.6; radius = 4; translate([ margin, plateDepth - depth - 5, calendarHeight - height + 0.01 ]) roundedRectangle( plateWidth - margin * 2, depth, height + (multiMaterial ? 0 : noMmuBlueOffset), radius ); } module monthName(month, plateWidth, plateDepth, bgDepth) { height = 0.6; monthNames = [january, february, march, april, may, june, july, august, september, october, november, december]; color(colorWhite) translate([ plateWidth / 2, plateDepth - bgDepth - 3, calendarHeight - height + 0.02 ]) linear_extrude(height + (multiMaterial ? 0 : noMmuWhiteOffset)) text(monthNames[month - 1], size = monthFontSize, font = font, halign = "center"); } module dayName(day, margin, plateWidth, plateDepth) { height = 0.6; days = [monday, tuesday, wednesday, thursday, friday, saturday, sunday]; space = (plateWidth - margin * 2) / 7 + 0.4; translate([ margin + (day - 1) * space, plateDepth - 20, calendarHeight - height + 0.01 ]) linear_extrude(height + (multiMaterial ? 0 : (day == 7 ? noMmuRedOffset : noMmuBlackOffset))) text(days[day - 1], size = dayNameFontSize, font = font); } module dayNumber(day, month, startOffset, plateWidth, plateDepth, margin) { height = 0.6; space = (plateWidth - margin * 2) / 7 + 0.4; index = (startOffset + day) % 7; stringDate = str(day, ".", month); isRed = index == 0 || saturdayRedColor && index == 6 || (hasHolidays && contains(stringDate, holidaysArray)); translate([ margin + ((startOffset + day - 1) % 7) * space, plateDepth - 25 - floor((startOffset + day - 1) / 7) * 5, calendarHeight - height + 0.01 ]) linear_extrude(height + (multiMaterial ? 0 : (isRed ? noMmuRedOffset : noMmuBlackOffset))) text(str(day), size = dayNameFontSize, font = font); } module monthPlate(year, month) { plateDepth = 55; monthBgDepth = 9; margin = 5; if (whiteParts) { difference() { color(colorWhite) cube([plateWidth, plateDepth, calendarHeight]); monthBg(plateWidth, plateDepth, monthBgDepth, margin = margin); for (day = [1:7]) { dayName(day, margin = margin, plateWidth = plateWidth, plateDepth = plateDepth); } for (day = [1:daysAmount(month)]) { startOffset = getFirstDay(year, month) - 1; dayNumber(day, month, startOffset, plateWidth = plateWidth, margin = margin, plateDepth = plateDepth); } } monthName(month, plateWidth, plateDepth, monthBgDepth); } if (blueParts) { difference() { color(colorBlue) monthBg(plateWidth, plateDepth, monthBgDepth, margin = margin); monthName(month, plateWidth, plateDepth, monthBgDepth); } } for (day = [1:7]) { if (((day == 7 || day == 6 && saturdayRedColor) && redParts) || (!(day == 7 || day == 6 && saturdayRedColor) && blackParts)) { color(day == 7 || day == 6 && saturdayRedColor ? colorRed : colorBlack) dayName(day, margin = margin, plateWidth = plateWidth, plateDepth = plateDepth); } } for (day = [1:daysAmount(month)]) { startOffset = getFirstDay(year, month) - 1; index = (startOffset + day) % 7; stringDate = str(day, ".", month); isRed = index == 0 || saturdayRedColor && index == 6 || (hasHolidays && contains(stringDate, holidaysArray)); if ((isRed && redParts) || (!isRed && blackParts)) { color(isRed ? colorRed : colorBlack) dayNumber(day, month, startOffset, plateWidth = plateWidth, margin = margin, plateDepth = plateDepth); } } } module title(bgHeight) { height = 0.6; translate([ (plateWidth * monthsPerRow) / 2, bgHeight / 2, calendarHeight - height + 0.01 ]) linear_extrude(height + (multiMaterial ? 0 : noMmuBlackOffset)) text(replace(calendarTitle, "%year", year), size = titleFontSize, halign = "center", valign = "center"); } module hookHole() { height = calendarHeight + 1; translate([hookHoleDiameter / 2, hookHoleDiameter / 2, -0.01]) { translate([-hookHoleUpperPartWidth / 2, hookHoleDiameter / 5.6, 0]) roundedRectangle(hookHoleUpperPartWidth + tolerances, 6, height, 1.5); cylinder(h = height, d = hookHoleDiameter + tolerances); } } for (month = [startMonth:endMonth]) { translate([ ((month - startMonth) % monthsPerRow) * plateWidth, -(ceil((month - startMonth + 1) / monthsPerRow)) * 55, 0 ]) monthPlate(year, month); } titleHeight = titleSpace; if (whiteParts) { color(colorWhite) difference() { cube([plateWidth * monthsPerRow, titleHeight, calendarHeight]); title(titleHeight); if (hookHole) { margin = 10; translate([margin, 3]) hookHole(); translate([plateWidth * monthsPerRow - margin - hookHoleDiameter, 3]) hookHole(); } if (magnetHole) { translate([0, 0, hiddenMagnet ? 0.4 : 0]) { if (roundMagnet) { translate([ (plateWidth * monthsPerRow) / 2, magnetDiameter / 2 + 1, -0.01 ]) cylinder(h = magnetHeight + tolerances, d = magnetDiameter + tolerances); } else { translate([ (plateWidth * monthsPerRow) / 2 - magnetWidth / 2, magnetDepth / 2, -0.01 ]) cube([magnetWidth + tolerances, magnetDepth + tolerances, magnetHeight + tolerances]); } } } } } if (blackParts) { color(colorBlack) title(titleHeight); }
In a future update, I plan to implement an algorithm to calculate Easter, allowing it to be added to holidays with a single toggle. If you know of any algorithm that could be easily implemented in OpenSCAD, let me know!