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 | ++++ |
| A | LICENSE | | | 23 | +++++++++++++++++++++++ |
| A | Makefile | | | 99 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | src/cube.c | | | 146 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | src/cube.h | | | 82 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | src/debug.h | | | 114 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | src/main.c | | | 58 | ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | src/ncdraw.c | | | 84 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | src/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_