Home

Published

- 17 min read

OpenSCAD configurable calendar 3D model

OpenSCAD configurable calendar 3D model
Liked 18 times

I love creating configurable generic models using OpenSCAD, and this is the most complex one I've created to date—an easy-to-configure calendar using some clever algorithms to render correctly.

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:

A calendar model screenshot for year 2025, showing that the Jan 1st is a Wednesday

According to my computer, Jan 1st, 2025, is indeed a Wednesday.

A screenshot of a 3D model showing that Jan 1st 2056 is a Saturday

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!

© 2024 Dominik Chrástecký. All rights reserved.