Initial commit: business card raytracer with explanation
Preserves Andrew Kensler's original card.cc verbatim and adds: - CMakeLists.txt building both the original and a de-obfuscated variant (card_explained.cc) that produces a visually identical render - A heavily annotated rewrite explaining the vector ops, ray-sphere intersection, soft shadows, depth of field, and reflection recursion - Rendered sample output (docs/aek.png) embedded in the README - CLAUDE.md establishing the "never modify card.cc" rule for future work
This commit is contained in:
commit
f8b7ff475c
7 changed files with 451 additions and 0 deletions
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
build/
|
||||
*.ppm
|
||||
52
CLAUDE.md
Normal file
52
CLAUDE.md
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
# card-raytracer — project notes for Claude
|
||||
|
||||
This repo is a small study of Andrew Kensler's "business card raytracer".
|
||||
The point of the project is **preservation + explanation**, not extension.
|
||||
|
||||
## The sacred rule
|
||||
|
||||
**`card.cc` must never be modified.** It is a historical artifact, ~1337
|
||||
characters of obfuscated C++ that fits on a real business card. Any cleanup,
|
||||
formatting, comment, or "fix" goes into `card_explained.cc` instead.
|
||||
|
||||
If a compiler warning or modern-C++ issue appears against `card.cc`, the
|
||||
answer is always a CMake flag adjustment (e.g. silencing warnings), never
|
||||
an edit to the source.
|
||||
|
||||
## Layout
|
||||
|
||||
| File | Role |
|
||||
|---|---|
|
||||
| `card.cc` | Original, golfed, untouched. Read-only by convention. |
|
||||
| `card_explained.cc` | De-obfuscated rewrite with full names and comments. Same visual output. |
|
||||
| `CMakeLists.txt` | Builds both binaries; provides `image` and `image_explained` render targets. |
|
||||
| `docs/aek.png` | Published render used by the README. Regenerate after meaningful changes. |
|
||||
| `README.md` | Human-facing description with the embedded render. |
|
||||
|
||||
## Build & render
|
||||
|
||||
```sh
|
||||
cmake -S . -B build
|
||||
cmake --build build # builds both binaries
|
||||
cmake --build build --target image # → build/aek.ppm
|
||||
cmake --build build --target image_explained # → build/aek_explained.ppm
|
||||
```
|
||||
|
||||
Convert PPM → PNG (for README, etc.) with `pnmtopng build/aek.ppm > docs/aek.png`.
|
||||
|
||||
## Conventions
|
||||
|
||||
- `card_explained.cc` is built with `-Wall -Wextra` and should stay
|
||||
warning-clean. `card.cc` is built with `-w` because we can't edit it.
|
||||
- C++14, no external dependencies beyond `libm`.
|
||||
- The de-obfuscated version preserves the **same `rand()` call sequence** as
|
||||
the original so the rendered image is visually identical. Don't reorder
|
||||
the `randf()` calls inside `shade()` or the per-sample loop in `main()`
|
||||
without re-checking the output.
|
||||
|
||||
## Remote
|
||||
|
||||
Hosted on Forgejo: `ssh://git@kode.naiv.no:2222/olemd/card-raytracer.git`.
|
||||
Per global instructions, use the `forgejo` skill (invoke via `/forgejo`) and
|
||||
the `fj` CLI for any repository interactions (PRs, issues, releases, etc.).
|
||||
Never use `gh` for this repo.
|
||||
43
CMakeLists.txt
Normal file
43
CMakeLists.txt
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
cmake_minimum_required(VERSION 3.16)
|
||||
project(card_raytracer CXX)
|
||||
|
||||
# The original card.cc is sacred and must NOT be modified. We compile it
|
||||
# verbatim. Because the file is golfed C++ from the early 2010s, we relax
|
||||
# a few things so a modern compiler accepts it untouched:
|
||||
# - C++14 (default constructor v(){} is fine; pow(float,int) overload exists)
|
||||
# - -w silences the unavoidable warnings (implicit float->int conversions
|
||||
# in the final printf, narrowing, unused result of operator new, etc.)
|
||||
# We can't fix them — we'd have to edit card.cc, which we won't.
|
||||
set(CMAKE_CXX_STANDARD 14)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
set(CMAKE_CXX_EXTENSIONS OFF)
|
||||
|
||||
add_executable(card card.cc)
|
||||
add_executable(card_explained card_explained.cc)
|
||||
|
||||
# Per-compiler flags. The original is golfed and triggers warnings we can't
|
||||
# fix without editing it (forbidden). The de-obfuscated version keeps full
|
||||
# warnings on so it stays honest.
|
||||
if(MSVC)
|
||||
target_compile_options(card PRIVATE /w /O2)
|
||||
target_compile_options(card_explained PRIVATE /W4 /O2)
|
||||
else()
|
||||
target_compile_options(card PRIVATE -w -O3)
|
||||
target_compile_options(card_explained PRIVATE -Wall -Wextra -O3)
|
||||
endif()
|
||||
|
||||
# Convenience targets: render an image from each binary.
|
||||
# Run with: cmake --build build --target image
|
||||
add_custom_target(image
|
||||
COMMAND card > aek.ppm
|
||||
DEPENDS card
|
||||
WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}
|
||||
COMMENT "Rendering aek.ppm from the original card (512x512 @ 64 spp)"
|
||||
)
|
||||
|
||||
add_custom_target(image_explained
|
||||
COMMAND card_explained > aek_explained.ppm
|
||||
DEPENDS card_explained
|
||||
WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}
|
||||
COMMENT "Rendering aek_explained.ppm from the de-obfuscated card"
|
||||
)
|
||||
88
README.md
Normal file
88
README.md
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
# card-raytracer
|
||||
|
||||
A study of [Andrew Kensler's "business card raytracer"][aek] — a complete
|
||||
recursive path-tracer with soft shadows, depth of field, anti-aliasing,
|
||||
reflections, a checker floor, and a sky gradient, all crammed into ~1337
|
||||
characters of obfuscated C++ small enough to print on the back of a
|
||||
business card.
|
||||
|
||||
The original code is preserved verbatim as [`card.cc`](card.cc). A
|
||||
de-obfuscated, heavily-annotated rewrite lives in
|
||||
[`card_explained.cc`](card_explained.cc). Both produce the same image:
|
||||
|
||||

|
||||
|
||||
The spheres spell **a · e · k**, Andrew Kensler's initials, encoded as a
|
||||
9-column bitmap in the `G[]` array of the original source.
|
||||
|
||||
## Quick start
|
||||
|
||||
Requires a C++14 compiler, CMake ≥ 3.16, and (optionally) `netpbm` for
|
||||
PPM → PNG conversion.
|
||||
|
||||
```sh
|
||||
cmake -S . -B build
|
||||
cmake --build build
|
||||
|
||||
# Render with the original (PPM, 786 KiB, ~6 s on a modern laptop)
|
||||
./build/card > aek.ppm
|
||||
|
||||
# Or via the convenience target:
|
||||
cmake --build build --target image # → build/aek.ppm
|
||||
cmake --build build --target image_explained # → build/aek_explained.ppm
|
||||
|
||||
# View it (PPM is supported by most image viewers; or convert):
|
||||
pnmtopng aek.ppm > aek.png
|
||||
```
|
||||
|
||||
## What's interesting about it
|
||||
|
||||
The whole renderer is built on **operator overloading**: a single `struct v`
|
||||
provides `+` (add), `*` (scale), `%` (dot product), `^` (cross product), and
|
||||
unary `!` (normalise). Once those are in place, the entire rendering
|
||||
pipeline — sphere intersections, reflection vectors, Phong shading,
|
||||
checker-pattern tile lookup, camera basis construction — collapses into
|
||||
just a handful of expressions.
|
||||
|
||||
Other tricks worth admiring:
|
||||
|
||||
- **Soft shadows for free**: the light position is jittered each call, so
|
||||
averaging 64 samples per pixel produces a real penumbra without any
|
||||
area-light formalism.
|
||||
- **Depth of field for free**: the camera origin is also jittered across
|
||||
a 99-unit "lens" each sample, so points off the focal plane go blurry.
|
||||
- **Anti-aliasing for free**: sub-pixel jitter on the ray direction.
|
||||
|
||||
The same 64-sample loop pays for all three at once.
|
||||
|
||||
A line-by-line walkthrough of the algorithm is the long-form
|
||||
[comments inside `card_explained.cc`](card_explained.cc).
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
.
|
||||
├── card.cc # original, untouched — do not edit
|
||||
├── card_explained.cc # de-obfuscated, documented rewrite
|
||||
├── CMakeLists.txt
|
||||
├── docs/
|
||||
│ └── aek.png # published render
|
||||
├── README.md
|
||||
└── CLAUDE.md # working notes for AI assistance
|
||||
```
|
||||
|
||||
## Credits
|
||||
|
||||
- Original raytracer: [Andrew Kensler][aek-home] ("aek").
|
||||
- Excellent walkthrough that inspired this study:
|
||||
Fabien Sanglard, [_Decyphering The Business Card Raytracer_][aek].
|
||||
|
||||
## Licence
|
||||
|
||||
The original `card.cc` is reproduced as-is from publicly published material
|
||||
for educational purposes; credit and copyright belong to Andrew Kensler.
|
||||
The supporting files in this repository (CMake, README, de-obfuscated
|
||||
rewrite) are placed in the public domain by the repository author.
|
||||
|
||||
[aek]: https://fabiensanglard.net/rayTracing_back_of_business_card/
|
||||
[aek-home]: https://www.cs.utah.edu/~aek/
|
||||
35
card.cc
Normal file
35
card.cc
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
#include <stdlib.h> // card > aek.ppm
|
||||
#include <stdio.h>
|
||||
#include <math.h>
|
||||
typedef int i;typedef float f;struct v{
|
||||
f x,y,z;v operator+(v r){return v(x+r.x
|
||||
,y+r.y,z+r.z);}v operator*(f r){return
|
||||
v(x*r,y*r,z*r);}f operator%(v r){return
|
||||
x*r.x+y*r.y+z*r.z;}v(){}v operator^(v r
|
||||
){return v(y*r.z-z*r.y,z*r.x-x*r.z,x*r.
|
||||
y-y*r.x);}v(f a,f b,f c){x=a;y=b;z=c;}v
|
||||
operator!(){return*this*(1/sqrt(*this%*
|
||||
this));}};i G[]={247570,280596,280600,
|
||||
249748,18578,18577,231184,16,16};f R(){
|
||||
return(f)rand()/RAND_MAX;}i T(v o,v d,f
|
||||
&t,v&n){t=1e9;i m=0;f p=-o.z/d.z;if(.01
|
||||
<p)t=p,n=v(0,0,1),m=1;for(i k=19;k--;)
|
||||
for(i j=9;j--;)if(G[j]&1<<k){v p=o+v(-k
|
||||
,0,-j-4);f b=p%d,c=p%p-1,q=b*b-c;if(q>0
|
||||
){f s=-b-sqrt(q);if(s<t&&s>.01)t=s,n=!(
|
||||
p+d*t),m=2;}}return m;}v S(v o,v d){f t
|
||||
;v n;i m=T(o,d,t,n);if(!m)return v(.7,
|
||||
.6,1)*pow(1-d.z,4);v h=o+d*t,l=!(v(9+R(
|
||||
),9+R(),16)+h*-1),r=d+n*(n%d*-2);f b=l%
|
||||
n;if(b<0||T(h,l,t,n))b=0;f p=pow(l%r*(b
|
||||
>0),99);if(m&1){h=h*.2;return((i)(ceil(
|
||||
h.x)+ceil(h.y))&1?v(3,1,1):v(3,3,3))*(b
|
||||
*.2+.1);}return v(p,p,p)+S(h,r)*.5;}i
|
||||
main(){printf("P6 512 512 255 ");v g=!v
|
||||
(-6,-16,0),a=!(v(0,0,1)^g)*.002,b=!(g^a
|
||||
)*.002,c=(a+b)*-256+g;for(i y=512;y--;)
|
||||
for(i x=512;x--;){v p(13,13,13);for(i r
|
||||
=64;r--;){v t=a*(R()-.5)*99+b*(R()-.5)*
|
||||
99;p=S(v(17,16,8)+t,!(t*-1+(a*(R()+x)+b
|
||||
*(y+R())+c)*16))*3.5+p;}printf("%c%c%c"
|
||||
,(i)p.x,(i)p.y,(i)p.z);}}
|
||||
231
card_explained.cc
Normal file
231
card_explained.cc
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
// card_explained.cc
|
||||
//
|
||||
// A de-obfuscated, heavily-annotated rewrite of Andrew Kensler's "business
|
||||
// card raytracer" (card.cc). The behaviour and visual output match the
|
||||
// original; only the names, formatting and comments differ. The original
|
||||
// file (card.cc) is kept untouched as a historical artifact — this file
|
||||
// exists so a human (or a future you) can read what's going on.
|
||||
//
|
||||
// Build via the project's CMake setup, or standalone:
|
||||
// c++ -O3 -o card_explained card_explained.cc
|
||||
// Render:
|
||||
// ./card_explained > aek.ppm
|
||||
//
|
||||
// Reference: https://fabiensanglard.net/rayTracing_back_of_business_card/
|
||||
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 3D vector — doubles as an RGB colour throughout the renderer.
|
||||
// ---------------------------------------------------------------------------
|
||||
struct Vec3 {
|
||||
float x, y, z;
|
||||
|
||||
// Default-constructed Vec3 is *uninitialised*. Matches the original;
|
||||
// every Vec3 created this way is assigned before it is read.
|
||||
Vec3() {}
|
||||
Vec3(float a, float b, float c) : x(a), y(b), z(c) {}
|
||||
|
||||
Vec3 operator+(const Vec3& r) const { return Vec3(x + r.x, y + r.y, z + r.z); }
|
||||
Vec3 operator*(float s) const { return Vec3(x * s, y * s, z * s); }
|
||||
|
||||
float dot(const Vec3& r) const { return x * r.x + y * r.y + z * r.z; }
|
||||
|
||||
Vec3 cross(const Vec3& r) const {
|
||||
return Vec3(y * r.z - z * r.y,
|
||||
z * r.x - x * r.z,
|
||||
x * r.y - y * r.x);
|
||||
}
|
||||
|
||||
Vec3 normalised() const {
|
||||
return *this * (1.0f / std::sqrt(this->dot(*this)));
|
||||
}
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scene: nine bitmap columns spelling "aek" as unit-radius spheres.
|
||||
//
|
||||
// Each int is one column (j ∈ [0,8]). Each set bit (k ∈ [0,18]) places a
|
||||
// sphere of radius 1 at world position (k, 0, j + 4).
|
||||
// ---------------------------------------------------------------------------
|
||||
static const int kLetterColumns[9] = {
|
||||
247570, 280596, 280600, 249748, 18578, 18577, 231184, 16, 16,
|
||||
};
|
||||
|
||||
// Uniform random float in [0, 1].
|
||||
static float randf() { return static_cast<float>(std::rand()) / RAND_MAX; }
|
||||
|
||||
enum HitKind {
|
||||
HIT_SKY = 0,
|
||||
HIT_FLOOR = 1,
|
||||
HIT_SPHERE = 2,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cast a single ray against the scene.
|
||||
//
|
||||
// origin / direction — the ray (direction assumed normalised)
|
||||
// out_t — written with the distance to the nearest hit
|
||||
// out_normal — written with the surface normal at that hit
|
||||
//
|
||||
// Returns one of HitKind: which surface (if any) was hit.
|
||||
// ---------------------------------------------------------------------------
|
||||
static int trace(Vec3 origin, Vec3 direction, float& out_t, Vec3& out_normal) {
|
||||
out_t = 1e9f;
|
||||
int kind = HIT_SKY;
|
||||
|
||||
// (a) Floor plane at z = 0.
|
||||
//
|
||||
// Solve origin.z + t * direction.z = 0 ⇒ t = -origin.z / direction.z.
|
||||
// The 0.01 guard is the standard "shadow acne" epsilon: it prevents
|
||||
// a ray from re-hitting the surface it just left.
|
||||
float t_floor = -origin.z / direction.z;
|
||||
if (t_floor > 0.01f) {
|
||||
out_t = t_floor;
|
||||
out_normal = Vec3(0, 0, 1);
|
||||
kind = HIT_FLOOR;
|
||||
}
|
||||
|
||||
// (b) Spheres encoded in kLetterColumns.
|
||||
for (int k = 18; k >= 0; --k) {
|
||||
for (int j = 8; j >= 0; --j) {
|
||||
if ((kLetterColumns[j] & (1 << k)) == 0) continue;
|
||||
|
||||
// Vector from sphere centre to ray origin.
|
||||
// centre = (k, 0, j + 4) → p = origin - centre
|
||||
Vec3 p = origin + Vec3(-static_cast<float>(k),
|
||||
0.0f,
|
||||
-static_cast<float>(j + 4));
|
||||
|
||||
// Ray-sphere intersection. Since direction is unit-length the
|
||||
// quadratic's "a" coefficient is 1, so it disappears:
|
||||
// t² + 2 b t + c = 0, with b = p·d, c = p·p - r²
|
||||
float b = p.dot(direction);
|
||||
float c = p.dot(p) - 1.0f; // r² = 1
|
||||
float discriminant = b * b - c;
|
||||
|
||||
if (discriminant > 0.0f) {
|
||||
float t_hit = -b - std::sqrt(discriminant); // nearer root
|
||||
if (t_hit > 0.01f && t_hit < out_t) {
|
||||
out_t = t_hit;
|
||||
out_normal = (p + direction * t_hit).normalised();
|
||||
kind = HIT_SPHERE;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return kind;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shade a ray: trace it, then compute a colour. Recurses on sphere hits
|
||||
// (mirror reflection). Recursion bottoms out naturally when a reflected
|
||||
// ray escapes to the sky.
|
||||
// ---------------------------------------------------------------------------
|
||||
static Vec3 shade(Vec3 origin, Vec3 direction) {
|
||||
float t;
|
||||
Vec3 normal;
|
||||
int kind = trace(origin, direction, t, normal);
|
||||
|
||||
// --- Sky --------------------------------------------------------------
|
||||
// Blue-purple gradient that gets darker looking up, brighter at the
|
||||
// horizon. (1 - direction.z) is small when the ray points upward.
|
||||
if (kind == HIT_SKY) {
|
||||
return Vec3(0.7f, 0.6f, 1.0f) * std::pow(1.0f - direction.z, 4.0f);
|
||||
}
|
||||
|
||||
Vec3 hit_point = origin + direction * t;
|
||||
|
||||
// Light position is jittered each call → averaging many samples gives
|
||||
// soft shadows (penumbrae) without any explicit area-light maths.
|
||||
Vec3 to_light = (Vec3(9.0f + randf(), 9.0f + randf(), 16.0f)
|
||||
+ hit_point * -1.0f).normalised();
|
||||
|
||||
// Reflection direction: R = D - 2 (N·D) N
|
||||
// Written as D + N * (N·D * -2) to match the original.
|
||||
Vec3 reflect_dir = direction + normal * (normal.dot(direction) * -2.0f);
|
||||
|
||||
// Lambertian term, zeroed if the surface faces away from the light or
|
||||
// if a shadow-ray reports occlusion.
|
||||
float diffuse = to_light.dot(normal);
|
||||
{
|
||||
float shadow_t; Vec3 shadow_n;
|
||||
if (diffuse < 0.0f || trace(hit_point, to_light, shadow_t, shadow_n)) {
|
||||
diffuse = 0.0f;
|
||||
}
|
||||
}
|
||||
|
||||
// Phong-style specular. Multiplying by (diffuse > 0) collapses the
|
||||
// highlight to zero in shadow without an extra branch.
|
||||
float specular = std::pow(to_light.dot(reflect_dir) * (diffuse > 0.0f ? 1.0f : 0.0f),
|
||||
99.0f);
|
||||
|
||||
// --- Floor ------------------------------------------------------------
|
||||
if (kind == HIT_FLOOR) {
|
||||
// hit_point * 0.2 makes each tile 5 world-units wide.
|
||||
Vec3 tile_coords = hit_point * 0.2f;
|
||||
bool red_tile = (static_cast<int>(std::ceil(tile_coords.x)
|
||||
+ std::ceil(tile_coords.y)) & 1) != 0;
|
||||
Vec3 base_colour = red_tile ? Vec3(3, 1, 1) : Vec3(3, 3, 3);
|
||||
// 0.2 * diffuse + 0.1 ambient = "always at least dimly lit".
|
||||
return base_colour * (diffuse * 0.2f + 0.1f);
|
||||
}
|
||||
|
||||
// --- Sphere (mirror) --------------------------------------------------
|
||||
// Specular highlight plus half the reflected scene. No explicit recursion
|
||||
// limit: a reflected ray must eventually miss everything and hit the
|
||||
// sky, which terminates immediately.
|
||||
return Vec3(specular, specular, specular) + shade(hit_point, reflect_dir) * 0.5f;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Build the camera, shoot 64 rays per pixel, write a binary PPM to stdout.
|
||||
// ---------------------------------------------------------------------------
|
||||
int main() {
|
||||
std::printf("P6 512 512 255 ");
|
||||
|
||||
// Camera basis (orthonormal). The .002 scale on `right` and `up` is the
|
||||
// pixel size in world units → it determines the field of view.
|
||||
Vec3 forward = Vec3(-6.0f, -16.0f, 0.0f).normalised();
|
||||
Vec3 right = Vec3(0.0f, 0.0f, 1.0f).cross(forward).normalised() * 0.002f;
|
||||
Vec3 up = forward.cross(right).normalised() * 0.002f;
|
||||
|
||||
// Offset from the camera's optical axis to the top-left pixel.
|
||||
Vec3 top_left = (right + up) * -256.0f + forward;
|
||||
|
||||
for (int y = 512; y-- > 0; ) {
|
||||
for (int x = 512; x-- > 0; ) {
|
||||
// Pre-biased accumulator: 13 each channel adds a slight overall
|
||||
// brightness so the truncation to uchar at the end lands in
|
||||
// [0, 255] without an explicit divide-by-samples step.
|
||||
Vec3 pixel(13.0f, 13.0f, 13.0f);
|
||||
|
||||
for (int s = 64; s-- > 0; ) {
|
||||
// Random offset on a 99-unit "lens" → depth of field.
|
||||
Vec3 lens_offset =
|
||||
right * ((randf() - 0.5f) * 99.0f)
|
||||
+ up * ((randf() - 0.5f) * 99.0f);
|
||||
|
||||
// Sub-pixel jitter → anti-aliasing.
|
||||
Vec3 pixel_direction =
|
||||
(lens_offset * -1.0f
|
||||
+ (right * (randf() + x)
|
||||
+ up * (randf() + y)
|
||||
+ top_left) * 16.0f).normalised();
|
||||
|
||||
Vec3 ray_origin = Vec3(17.0f, 16.0f, 8.0f) + lens_offset;
|
||||
pixel = shade(ray_origin, pixel_direction) * 3.5f + pixel;
|
||||
}
|
||||
|
||||
// Write one RGB triple. Values are truncated, not clamped —
|
||||
// the constants above are tuned so this is safe.
|
||||
std::printf("%c%c%c",
|
||||
static_cast<int>(pixel.x),
|
||||
static_cast<int>(pixel.y),
|
||||
static_cast<int>(pixel.z));
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
BIN
docs/aek.png
Normal file
BIN
docs/aek.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 221 KiB |
Loading…
Add table
Add a link
Reference in a new issue