cube

A rotating 3D wireframe cube rendered in the terminal
git clone https://github.com/michaelneuper/cube.git
README | Files | Log | Refs | LICENSE

commit bd3414ca130fd1d702a009fd66693ad6e411e584
Author: Michael Neuper <michael@michaelneuper.com>
Date:   Thu,  9 Apr 2026 21:00:43 +0200

hello cube

Diffstat:
A.clang-format | 22++++++++++++++++++++++
A.gitignore | 4++++
ALICENSE | 23+++++++++++++++++++++++
AMakefile | 99+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/cube.c | 146+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/cube.h | 82+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/debug.h | 114+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/main.c | 58++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/ncdraw.c | 84+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/ncdraw.h | 46++++++++++++++++++++++++++++++++++++++++++++++
10 files changed, 678 insertions(+), 0 deletions(-)

diff --git a/.clang-format b/.clang-format @@ -0,0 +1,22 @@ +# -*- mode: yaml -*- +BasedOnStyle: LLVM + +ColumnLimit: 80 +IndentWidth: 4 +IndentCaseLabels: true +IndentPPDirectives: AfterHash + +AllowShortIfStatementsOnASingleLine: Always +AllowShortCaseLabelsOnASingleLine: true + +AlignConsecutiveAssignments: true +AllowShortFunctionsOnASingleLine: false +AlignConsecutiveDeclarations: true +AlignTrailingComments: true +AlignConsecutiveMacros: + Enabled: true + AcrossEmptyLines: true + AcrossComments: true + +InsertNewlineAtEOF: true +KeepEmptyLinesAtEOF: true diff --git a/.gitignore b/.gitignore @@ -0,0 +1,4 @@ +bin/ +build/ + +.DS_Store diff --git a/LICENSE b/LICENSE @@ -0,0 +1,23 @@ +Copyright (c) 2025 Michael Neuper +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this +list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, +this list of conditions and the following disclaimer in the documentation and/or +other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/Makefile b/Makefile @@ -0,0 +1,99 @@ +##### CONFIG ################################################################### + +CC = clang + +# dirs +SRCDIR = src +TESTDIR = test +BINDIR = bin +OBJDIR = build + +APP_SRCS = $(SRCDIR)/cube.c \ + $(SRCDIR)/ncdraw.c + +MAIN_SRC = $(SRCDIR)/main.c + +TEST_SRCS = $(TESTDIR)/test.c \ + $(TESTDIR)/test_cube.c + +APP_OBJS = $(APP_SRCS:$(SRCDIR)/%.c=$(OBJDIR)/%.o) +MAIN_OBJ = $(MAIN_SRC:$(SRCDIR)/%.c=$(OBJDIR)/%.o) +TEST_OBJS = $(TEST_SRCS:$(TESTDIR)/%.c=$(OBJDIR)/%.o) + +# targets +TARGET = cube +TEST_RUNNER = test_runner +TARGET_BIN = $(BINDIR)/$(TARGET) +TEST_BIN = $(BINDIR)/$(TEST_RUNNER) + +##### FLAGS #################################################################### + +CPPFLAGS += -Isrc +CFLAGS += -Wall -Wextra -std=c11 +FSANITIZE = -fsanitize=address,undefined + +# options-only flags +LDFLAGS += +NCURSES = -lncurses +CUNIT = -lcunit +LDLIBS_APP = $(NCURSES) +LDLIBS_TEST = $(NCURSES) $(CUNIT) + +# macOS Homebrew (headers and libs) +UNAME_S := $(shell uname -s) +ifeq ($(UNAME_S),Darwin) + BREW_PREFIX := $(shell brew --prefix 2>/dev/null || echo /opt/homebrew) + CPPFLAGS += -I$(BREW_PREFIX)/include + LDLIBS_TEST += -L$(BREW_PREFIX)/lib +endif + +# build types +ifeq ($(DEBUG_BUILD),1) + CFLAGS += -O1 -g -DDEBUG $(FSANITIZE) + LDFLAGS += $(FSANITIZE) +else + CFLAGS += -O3 +endif + +##### RULES #################################################################### + +.PHONY: all debug run test clean + +all: $(BINDIR) $(TARGET_BIN) + +debug: + @$(MAKE) DEBUG_BUILD=1 all + +run: + @$(MAKE) DEBUG_BUILD=1 all + @./$(TARGET_BIN) + +test: + @$(MAKE) DEBUG_BUILD=1 _test + +_test: $(BINDIR) $(TEST_BIN) + @echo "--- Running Unit Tests (debug build) ---" + @./$(TEST_BIN) + +$(BINDIR): + mkdir -p $(BINDIR) + +# App: main + logic +$(TARGET_BIN): $(MAIN_OBJ) $(APP_OBJS) + $(CC) $^ $(LDFLAGS) -o $@ $(LDLIBS_APP) + +# Tests: logic (no main) + test runner/suites +$(TEST_BIN): $(APP_OBJS) $(TEST_OBJS) + $(CC) $^ $(LDFLAGS) -o $@ $(LDLIBS_TEST) + +# compile into `build/` +$(OBJDIR)/%.o: $(SRCDIR)/%.c + mkdir -p $(dir $@) + $(CC) $(CPPFLAGS) $(CFLAGS) -c $< -o $@ + +$(OBJDIR)/%.o: $(TESTDIR)/%.c + mkdir -p $(dir $@) + $(CC) $(CPPFLAGS) $(CFLAGS) -c $< -o $@ + +clean: + rm -rf $(BINDIR) $(OBJDIR) diff --git a/src/cube.c b/src/cube.c @@ -0,0 +1,146 @@ +/** + * @file cube.c + * @author Michael Neuper (michael@michaelneuper.com) + * + * Implements the cube logic: vertex and edge initialisation, + * perspective projection, rotation, and ncurses rendering. + */ + +#include <math.h> +#include <ncurses.h> +#include <stdlib.h> + +#include "cube.h" +#include "debug.h" +#include "ncdraw.h" + +#define CAM_DISTANCE 50.0 +#define ZOOM 500.0 +#define SCALE_X 2.0 +#define SCALE_Y 1.0 + +#define TRANS_X(x) ((x) + getmaxx(stdscr) / 2.0) +#define TRANS_Y(y) ((y) + getmaxy(stdscr) / 2.0) + +/** + * Represents a projected point in 2D screen-space. + */ +typedef struct { + double x, y; +} Point2D; + +/** Converts a projected 2D point to an ncurses screen pixel. */ +static Pixel point_to_pixel(Point2D p); +/** Applies perspective projection to a 3D point, returning its 2D screen + * position. */ +static Point2D project_point(Point3D p); +/** Rotates a 3D point around the given axis by angle radians. */ +static Point3D rotate_point(Point3D p, Axis3D axis, double angle); + +Cube *create_cube() { + Cube *cube = malloc(sizeof(Cube)); + + if (!cube) return NULL; + + // clang-format off + *cube = (Cube){ + .vertices = { + { -1, -1, -1 }, // 1-------3 + { -1, -1, 1 }, // /| /| + { -1, 1, -1 }, // / | / | + { -1, 1, 1 }, // 5-------7 | + { 1, -1, -1 }, // | 0----|--2 + { 1, -1, 1 }, // | / | / + { 1, 1, -1 }, // |/ |/ + { 1, 1, 1 }, // 4-------6 + }, + .edges = { + { 0, 1 }, { 0, 2 }, { 0, 4 }, + { 1, 3 }, { 1, 5 }, + { 2, 3 }, { 2, 6 }, + { 3, 7 }, // top line + { 4, 5 }, + { 4, 6 }, + { 5, 7 }, + { 6, 7 }, + }, + }; + // clang-format on + + return cube; +} + +void destroy_cube(Cube *cube) { + free(cube); + endwin(); +} + +void rotate_cube(Cube *cube, Axis3D axis, double angle) { + ASSERT_NOT_NULL(cube); + + for (size_t i = 0; i < CUBE_VERTICES; i++) { + cube->vertices[i] = rotate_point(cube->vertices[i], axis, angle); + } +} + +void draw_cube(const Cube *cube) { + ASSERT_NOT_NULL(cube); + + for (size_t i = 0; i < CUBE_EDGES; i++) { + Edge e = cube->edges[i]; + + Point2D p1 = project_point(cube->vertices[e.v1]); + Point2D p2 = project_point(cube->vertices[e.v2]); + + draw_line(point_to_pixel(p1), point_to_pixel(p2)); + } +} + +static Pixel point_to_pixel(Point2D p) { + float x = TRANS_X(SCALE_X * p.x); + float y = TRANS_Y(SCALE_Y * -p.y); + + if (x < 0) x = 0; + if (y < 0) y = 0; + if (x >= getmaxx(stdscr)) x = getmaxx(stdscr) - 1; + if (y >= getmaxy(stdscr)) y = getmaxy(stdscr) - 1; + + return (Pixel){ + .row = y, + .col = x, + }; +} + +static Point2D project_point(Point3D p) { + double depth = p.z + CAM_DISTANCE; + + return (Point2D){ + .x = ZOOM * p.x / depth, + .y = ZOOM * p.y / depth, + }; +} + +static Point3D rotate_point(Point3D p, Axis3D axis, double angle) { + switch (axis) { + case X: + return (Point3D){ + .x = p.x, + .y = p.y * cos(angle) - p.z * sin(angle), + .z = p.y * sin(angle) + p.z * cos(angle), + }; + + case Y: + return (Point3D){ + .x = p.x * cos(angle) + p.z * sin(angle), + .y = p.y, + .z = -p.x * sin(angle) + p.z * cos(angle), + }; + + case Z: + return (Point3D){ + .x = p.x * cos(angle) - p.y * sin(angle), + .y = p.x * sin(angle) + p.y * cos(angle), + .z = p.z, + }; + } +} diff --git a/src/cube.h b/src/cube.h @@ -0,0 +1,82 @@ +/** + * @file cube.h + * @author Michael Neuper (michael@michaelneuper.com) + * + * Public API for the cube: types, constants, and functions for + * creating, rotating, drawing, and destroying a 3D wireframe cube. + */ + +#ifndef CUBE_H_ +#define CUBE_H_ + +#define CUBE_VERTICES 8 +#define CUBE_EDGES 12 + +/** The three cardinal axes used to specify a rotation direction. */ +typedef enum { + X, + Y, + Z, +} Axis3D; + +/** + * Represents a point in 3D-space with double-precision coordinates. + */ +typedef struct { + double x, y, z; +} Point3D; + +/** + * Represents an edge by the indices of the two vertices + * that defines it. + */ +typedef struct { + const int v1, v2; +} Edge; + +/** + * Represents a cube by an array of its vertices + * and an array of its edges. + */ +typedef struct { + Point3D vertices[CUBE_VERTICES]; + Edge edges[CUBE_EDGES]; +} Cube; + +/** + * Allocates and initialises a unit cube centred at the origin. + * + * @return pointer to the new Cube, or NULL on allocation failure + */ +Cube *create_cube(); + +/** + * Frees the cube and tears down the ncurses canvas. + * + * @param[in] cube + * cube to destroy (must not be NULL) + */ +void destroy_cube(Cube *cube); + +/** + * Renders all edges of the cube to stdscr. + * Call refresh() after to make the frame visible. + * + * @param[in] cube + * cube to draw (must not be NULL) + */ +void draw_cube(const Cube *cube); + +/** + * Rotates all vertices of the cube in-place around the given axis. + * + * @param[in,out] cube + * cube to rotate (must not be NULL). + * @param[in] axis + * axis of rotation (X, Y, or Z). + * @param[in] angle + * rotation angle in radians. + */ +void rotate_cube(Cube *cube, Axis3D axis, double angle); + +#endif // CUBE_H_ diff --git a/src/debug.h b/src/debug.h @@ -0,0 +1,114 @@ +/** + * @file debug.h + * @author Michael Neuper (michael@michaelneuper.com) + * + * Debug utilities: assertion macros that print the failing + * expression with operand values and abort. All macros are no-ops + * unless compiled with -DDEBUG. + */ + +#ifndef DEBUG_H_ +#define DEBUG_H_ + +#include <stdio.h> +#include <stdlib.h> + +#define FORMAT_SPECIFIER(x) \ + _Generic((x), \ + char: "%c", \ + signed char: "%hhd", \ + unsigned char: "%hhu", \ + short: "%hd", \ + unsigned short: "%hu", \ + int: "%d", \ + unsigned int: "%u", \ + long: "%ld", \ + unsigned long: "%lu", \ + long long: "%lld", \ + unsigned long long: "%llu", \ + float: "%f", \ + double: "%f", \ + long double: "%Lf", \ + char *: "%s", \ + const char *: "%s", \ + void *: "%p", \ + default: "unknown type (0x%p)") + +#ifdef DEBUG +# define ASSERT(cond, fmt, ...) \ + do { \ + if (!(cond)) { \ + fprintf(stderr, \ + "\e[1m%s:%d: \e[31massertion failed:\e[m\n '%s'.", \ + __FILE__, __LINE__, #cond); \ + fprintf(stderr, fmt, ##__VA_ARGS__); \ + abort(); \ + } \ + } while (0) +#else +# define ASSERT(cond, fmt, ...) ((void)0) +#endif + +#define ASSERT_EQ(a, b) \ + do { \ + const char *fa__ = FORMAT_SPECIFIER(a); \ + const char *fb__ = FORMAT_SPECIFIER(b); \ + char fmt__[64]; \ + snprintf(fmt__, sizeof fmt__, "%%s = %s, %%s = %s\n", fa__, fb__); \ + ASSERT((a) == (b), fmt__, #a, (a), #b, (b)); \ + } while (0) + +#define ASSERT_NE(a, b) \ + do { \ + const char *fa__ = FORMAT_SPECIFIER(a); \ + const char *fb__ = FORMAT_SPECIFIER(b); \ + char fmt__[64]; \ + snprintf(fmt__, sizeof fmt__, "%%s = %s, %%s = %s\n", fa__, fb__); \ + ASSERT((a) != (b), fmt__, #a, (a), #b, (b)); \ + } while (0) + +#define ASSERT_LT(a, b) \ + do { \ + const char *fa__ = FORMAT_SPECIFIER(a); \ + const char *fb__ = FORMAT_SPECIFIER(b); \ + char fmt__[64]; \ + snprintf(fmt__, sizeof fmt__, "%%s = %s, %%s = %s\n", fa__, fb__); \ + ASSERT((a) < (b), fmt__, #a, (a), #b, (b)); \ + } while (0) + +#define ASSERT_LE(a, b) \ + do { \ + const char *fa__ = FORMAT_SPECIFIER(a); \ + const char *fb__ = FORMAT_SPECIFIER(b); \ + char fmt__[64]; \ + snprintf(fmt__, sizeof fmt__, "%%s = %s, %%s = %s\n", fa__, fb__); \ + ASSERT((a) <= (b), fmt__, #a, (a), #b, (b)); \ + } while (0) + +#define ASSERT_NZ(a) \ + do { \ + const char *fa__ = FORMAT_SPECIFIER(a); \ + char fmt__[64]; \ + snprintf(fmt__, sizeof fmt__, "%%s = %s\n", fa__); \ + ASSERT((a) != 0, fmt__, #a, (a)); \ + } while (0) + +#define ASSERT_GZ(a) \ + do { \ + const char *fa__ = FORMAT_SPECIFIER(a); \ + char fmt__[64]; \ + snprintf(fmt__, sizeof fmt__, "%%s = %s\n", fa__); \ + ASSERT((a) > 0, fmt__, #a, (a)); \ + } while (0) + +#define ASSERT_GEZ(a) \ + do { \ + const char *fa__ = FORMAT_SPECIFIER(a); \ + char fmt__[64]; \ + snprintf(fmt__, sizeof fmt__, "%%s = %s\n", fa__); \ + ASSERT((a) >= 0, fmt__, #a, (a)); \ + } while (0) + +#define ASSERT_NOT_NULL(a) ASSERT_NE((a), NULL) + +#endif // DEBUG_H_ diff --git a/src/main.c b/src/main.c @@ -0,0 +1,58 @@ +#include <math.h> +#include <ncurses.h> +#include <time.h> + +#include "cube.h" +#include "debug.h" +#include "ncdraw.h" + +#define KEY_QUIT 'q' +#define FRAME_DELAY_MS 100 +#define INITIAL_ANGLE_X 0.4 +#define INITIAL_ANGLE_Y 0.6 +#define ROTATION_SPEED_X (M_PI / 96) +#define ROTATION_SPEED_Y (M_PI / 64) +#define SLEEP(msecs) \ + do { \ + struct timespec ts; \ + ts.tv_sec = msecs / 1000; \ + ts.tv_nsec = (msecs % 1000) * 1000000; \ + nanosleep(&ts, NULL); \ + } while (0) + +void loop(void); + +int main(void) { + init_canvas(); + loop(); + + return 0; +} + +void loop(void) { + Cube *cube = create_cube(); + ASSERT_NOT_NULL(cube); + + // initial rotation for better 3D view + rotate_cube(cube, X, INITIAL_ANGLE_X); + rotate_cube(cube, Y, INITIAL_ANGLE_Y); + + draw_cube(cube); + refresh(); + + while (TRUE) { + int ch = getch(); + if ((ch) == KEY_QUIT) { + destroy_cube(cube); + return; + } + + clear(); + rotate_cube(cube, Y, ROTATION_SPEED_Y); + rotate_cube(cube, X, ROTATION_SPEED_X); + draw_cube(cube); + refresh(); + + SLEEP(FRAME_DELAY_MS); + } +} diff --git a/src/ncdraw.c b/src/ncdraw.c @@ -0,0 +1,84 @@ +/** + * @file ncdraw.c + * @author Michael Neuper (michael@michaelneuper.com) + * + * Low-level ncurses drawing primitives: canvas initialisation, + * single-pixel rendering, and Bresenham line drawing. + */ + +#include <ctype.h> +#include <ncurses.h> +#include <stdlib.h> + +#include "debug.h" +#include "ncdraw.h" + +void init_canvas(void) { + initscr(); + cbreak(); + noecho(); + curs_set(0); + nodelay(stdscr, TRUE); +} + +void draw_pixel(Pixel p) { + char ch = '*'; + + ASSERT(isprint(ch), "Must be a printable character.\n"); + ASSERT_LE(p.row, getmaxy(stdscr)); + ASSERT_LE(p.col, getmaxx(stdscr)); + + mvprintw(p.row, p.col, "%c", ch); +} + +void draw_line(Pixel p, Pixel q) { + /** step direction */ + int sx = p.col < q.col ? 1 : -1; + int sy = p.row < q.row ? 1 : -1; + + int dx = abs(p.col - q.col); + int dy = abs(p.row - q.row); + + int x = p.col; + int y = p.row; + + if (dx > dy) { + int d = 2 * dy - dx; + draw_pixel(p); + + while (x != q.col) { + x += sx; + + if (d < 0) { + d += 2 * dy; + } else { + y += sy; + d += 2 * (dy - dx); + } + + draw_pixel((Pixel){ + .row = y, + .col = x, + }); + } + } else { + int d = 2 * dx - dy; + draw_pixel(p); + + while (y != q.row) { + y += sy; + + if (d < 0) { + d += 2 * dx; + } else { + x += sx; + d += 2 * (dx - dy); + } + + draw_pixel((Pixel){ + .row = y, + .col = x, + }); + } + } +} diff --git a/src/ncdraw.h b/src/ncdraw.h @@ -0,0 +1,46 @@ +/** + * @file ncdraw.h + * @author Michael Neuper (michael@michaelneuper.com) + * + * Low-level ncurses drawing primitives: canvas initialisation, + * single-pixel rendering, and Bresenham line drawing. + */ + +#ifndef NCDRAW_H_ +#define NCDRAW_H_ + +/** + * Represents a single pixel. + */ +typedef struct { + int row, col; /**< coordinates on the screen */ +} Pixel; + +/** + * Initialises the canvas for drawing. + * Expects <code>endwin()</code> to be called before + * terminating. + */ +void init_canvas(void); + +/** + * Draws a single pixel to <code>stdscr<\code>. + * Expects the pixel to fit within the screen. + * + * @param[in] p + * the point to draw + */ +void draw_pixel(Pixel p); + +/** + * Draws a line between points two points to + * <code>stdscr<\code> using Bresenham’s line algorithm. + * + * @param[in] p + * starting point + * @param[in] q + * ending point + */ +void draw_line(Pixel p, Pixel q); + +#endif // NCDRAW_H_