mirror of
https://github.com/MartinThoma/LaTeX-examples.git
synced 2025-04-25 06:18:05 +02:00
latest changes from Charles Staats for torus with three paths
This commit is contained in:
parent
c4f9a812f9
commit
8eecb411f0
2 changed files with 541 additions and 1 deletions
|
@ -12,7 +12,7 @@ make:
|
|||
make clean
|
||||
|
||||
clean:
|
||||
rm -rf $(TARGET) *.class *.html *.log *.aux *.data *.gnuplot *.out *.prc *.pre *.asy $(SOURCE)-1_0.pdf $(SOURCE)1+0_0.pdf $(SOURCE)-1.tex
|
||||
rm -rf $(TARGET) *.class *.html *.log *.aux *.data *.gnuplot *.out *.prc *.pre $(SOURCE)-1_0.pdf $(SOURCE)1+0_0.pdf $(SOURCE)-1.tex
|
||||
|
||||
gif:
|
||||
pdfcrop $(SOURCE).pdf
|
||||
|
|
540
asymptote/torus-three-paths/next.asy
Normal file
540
asymptote/torus-three-paths/next.asy
Normal file
|
@ -0,0 +1,540 @@
|
|||
settings.outformat="pdf";
|
||||
|
||||
import graph3;
|
||||
import contour;
|
||||
|
||||
// A bunch of auxiliary functions.
|
||||
|
||||
real fuzz = .001;
|
||||
|
||||
real umin(surface s) { return 0; }
|
||||
real vmin(surface s) { return 0; }
|
||||
pair uvmin(surface s) { return (umin(s), vmin(s)); }
|
||||
real umax(surface s, real fuzz=fuzz) {
|
||||
if (s.ucyclic()) return s.index.length;
|
||||
else return s.index.length - fuzz;
|
||||
}
|
||||
real vmax(surface s, real fuzz=fuzz) {
|
||||
if (s.vcyclic()) return s.index[0].length;
|
||||
return s.index[0].length - fuzz;
|
||||
}
|
||||
pair uvmax(surface s, real fuzz=fuzz) { return (umax(s,fuzz), vmax(s,fuzz)); }
|
||||
|
||||
typedef real function(real, real);
|
||||
|
||||
function normalDot(surface s, triple eyedir(triple)) {
|
||||
real toreturn(real u, real v) {
|
||||
return dot(s.normal(u, v), eyedir(s.point(u,v)));
|
||||
}
|
||||
return toreturn;
|
||||
}
|
||||
|
||||
struct patchWithCoords {
|
||||
patch p;
|
||||
real u;
|
||||
real v;
|
||||
void operator init(patch p, real u, real v) {
|
||||
this.p = p;
|
||||
this.u = u;
|
||||
this.v = v;
|
||||
}
|
||||
void operator init(surface s, real u, real v) {
|
||||
int U=floor(u);
|
||||
int V=floor(v);
|
||||
int index = (s.index.length == 0 ? U+V : s.index[U][V]);
|
||||
|
||||
this.p = s.s[index];
|
||||
this.u = u-U;
|
||||
this.v = v-V;
|
||||
}
|
||||
triple partialu() {
|
||||
return p.partialu(u,v);
|
||||
}
|
||||
triple partialv() {
|
||||
return p.partialv(u,v);
|
||||
}
|
||||
}
|
||||
|
||||
typedef triple paramsurface(pair);
|
||||
|
||||
paramsurface tangentplane(surface s, pair pt) {
|
||||
patchWithCoords thepatch = patchWithCoords(s, pt.x, pt.y);
|
||||
triple partialu = thepatch.partialu();
|
||||
triple partialv = thepatch.partialv();
|
||||
return new triple(pair tangentvector) {
|
||||
return s.point(pt.x, pt.y) + (tangentvector.x * partialu) + (tangentvector.y * partialv);
|
||||
};
|
||||
}
|
||||
|
||||
guide[] normalpathuv(surface s, projection P = currentprojection, int n = ngraph) {
|
||||
triple eyedir(triple a);
|
||||
if (P.infinity) eyedir = new triple(triple) { return P.camera; };
|
||||
else eyedir = new triple(triple pt) { return P.camera - pt; };
|
||||
return contour(normalDot(s, eyedir), uvmin(s), uvmax(s), new real[] {0}, nx=n)[0];
|
||||
}
|
||||
|
||||
path3 onSurface(surface s, path p) {
|
||||
triple f(int t) {
|
||||
pair point = point(p,t);
|
||||
return s.point(point.x, point.y);
|
||||
}
|
||||
|
||||
guide3 toreturn = f(0);
|
||||
paramsurface thetangentplane = tangentplane(s, point(p,0));
|
||||
triple oldcontrol, newcontrol;
|
||||
int size = length(p);
|
||||
for (int i = 1; i <= size; ++i) {
|
||||
oldcontrol = thetangentplane(postcontrol(p,i-1) - point(p,i-1));
|
||||
thetangentplane = tangentplane(s, point(p,i));
|
||||
newcontrol = thetangentplane(precontrol(p, i) - point(p,i));
|
||||
toreturn = toreturn .. controls oldcontrol and newcontrol .. f(i);
|
||||
}
|
||||
|
||||
if (cyclic(p)) toreturn = toreturn & cycle;
|
||||
|
||||
return toreturn;
|
||||
|
||||
}
|
||||
|
||||
/*
|
||||
* This method returns an array of paths that trace out all the
|
||||
* points on s at which s is parallel to eyedir.
|
||||
*/
|
||||
|
||||
path[] paramSilhouetteNoEdges(surface s, projection P = currentprojection, int n = ngraph) {
|
||||
guide[] uvpaths = normalpathuv(s, P, n);
|
||||
//Reduce the number of segments to conserve memory
|
||||
for (int i = 0; i < uvpaths.length; ++i) {
|
||||
real len = length(uvpaths[i]);
|
||||
uvpaths[i] = graph(new pair(real t) {return point(uvpaths[i],t);}, 0, len, n=n);
|
||||
}
|
||||
return uvpaths;
|
||||
}
|
||||
|
||||
private typedef real function2(real, real);
|
||||
private typedef real function3(triple);
|
||||
|
||||
triple[] normalVectors(triple dir, triple surfacen) {
|
||||
dir = unit(dir);
|
||||
surfacen = unit(surfacen);
|
||||
triple v1, v2;
|
||||
int i = 0;
|
||||
do {
|
||||
v1 = unit(cross(dir, (unitrand(), unitrand(), unitrand())));
|
||||
v2 = unit(cross(dir, (unitrand(), unitrand(), unitrand())));
|
||||
++i;
|
||||
} while ((abs(dot(v1,v2)) > Cos(10) || abs(dot(v1,surfacen)) > Cos(5) || abs(dot(v2,surfacen)) > Cos(5)) && i < 1000);
|
||||
if (i >= 1000) {
|
||||
write("problem: Unable to comply.");
|
||||
write(" dir = " + (string)dir);
|
||||
write(" surface normal = " + (string)surfacen);
|
||||
}
|
||||
return new triple[] {v1, v2};
|
||||
}
|
||||
|
||||
function3 planeEqn(triple pt, triple normal) {
|
||||
return new real(triple r) {
|
||||
return dot(normal, r - pt);
|
||||
};
|
||||
}
|
||||
|
||||
function2 pullback(function3 eqn, surface s) {
|
||||
return new real(real u, real v) {
|
||||
return eqn(s.point(u,v));
|
||||
};
|
||||
}
|
||||
|
||||
/*
|
||||
* returns the distinct points in which the surface intersects
|
||||
* the line through the point pt in the direction dir
|
||||
*/
|
||||
|
||||
triple[] intersectionPoints(surface s, pair parampt, triple dir) {
|
||||
triple pt = s.point(parampt.x, parampt.y);
|
||||
triple[] lineNormals = normalVectors(dir, s.normal(parampt.x, parampt.y));
|
||||
path[][] curves;
|
||||
for (triple n : lineNormals) {
|
||||
function3 planeEn = planeEqn(pt, n);
|
||||
function2 pullback = pullback(planeEn, s);
|
||||
guide[] contour = contour(pullback, uvmin(s), uvmax(s), new real[]{0})[0];
|
||||
|
||||
curves.push(contour);
|
||||
}
|
||||
pair[] intersectionPoints;
|
||||
for (path c1 : curves[0])
|
||||
for (path c2 : curves[1])
|
||||
intersectionPoints.append(intersectionpoints(c1, c2));
|
||||
triple[] toreturn;
|
||||
for (pair P : intersectionPoints)
|
||||
toreturn.push(s.point(P.x, P.y));
|
||||
return toreturn;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*
|
||||
* Returns those intersection points for which the vector from pt forms an
|
||||
* acute angle with dir.
|
||||
*/
|
||||
int numPointsInDirection(surface s, pair parampt, triple dir, real fuzz=.05) {
|
||||
triple pt = s.point(parampt.x, parampt.y);
|
||||
dir = unit(dir);
|
||||
triple[] intersections = intersectionPoints(s, parampt, dir);
|
||||
int num = 0;
|
||||
for (triple isection: intersections)
|
||||
if (dot(isection - pt, dir) > fuzz) ++num;
|
||||
return num;
|
||||
}
|
||||
|
||||
bool3 increasing(real t0, real t1) {
|
||||
if (t0 < t1) return true;
|
||||
if (t0 > t1) return false;
|
||||
return default;
|
||||
}
|
||||
|
||||
int[] extremes(real[] f, bool cyclic = f.cyclic) {
|
||||
bool3 lastIncreasing;
|
||||
bool3 nextIncreasing;
|
||||
int max;
|
||||
if (cyclic) {
|
||||
lastIncreasing = increasing(f[-1], f[0]);
|
||||
max = f.length - 1;
|
||||
} else {
|
||||
max = f.length - 2;
|
||||
if (increasing(f[0], f[1])) lastIncreasing = false;
|
||||
else lastIncreasing = true;
|
||||
}
|
||||
int[] toreturn;
|
||||
for (int i = 0; i <= max; ++i) {
|
||||
nextIncreasing = increasing(f[i], f[i+1]);
|
||||
if (lastIncreasing != nextIncreasing) {
|
||||
toreturn.push(i);
|
||||
}
|
||||
lastIncreasing = nextIncreasing;
|
||||
}
|
||||
if (!cyclic) toreturn.push(f.length - 1);
|
||||
toreturn.cyclic = cyclic;
|
||||
return toreturn;
|
||||
}
|
||||
|
||||
int[] extremes(path path, real f(pair) = new real(pair P) {return P.x;})
|
||||
{
|
||||
real[] fvalues = new real[size(path)];
|
||||
for (int i = 0; i < fvalues.length; ++i) {
|
||||
fvalues[i] = f(point(path, i));
|
||||
}
|
||||
fvalues.cyclic = cyclic(path);
|
||||
int[] toreturn = extremes(fvalues);
|
||||
fvalues.delete();
|
||||
return toreturn;
|
||||
}
|
||||
|
||||
path[] splitAtExtremes(path path, real f(pair) = new real(pair P) {return P.x;})
|
||||
{
|
||||
int[] splittingTimes = extremes(path, f);
|
||||
path[] toreturn;
|
||||
if (cyclic(path)) toreturn.push(subpath(path, splittingTimes[-1], splittingTimes[0]));
|
||||
for (int i = 0; i+1 < splittingTimes.length; ++i) {
|
||||
toreturn.push(subpath(path, splittingTimes[i], splittingTimes[i+1]));
|
||||
}
|
||||
return toreturn;
|
||||
}
|
||||
|
||||
path[] splitAtExtremes(path[] paths, real f(pair P) = new real(pair P) {return P.x;})
|
||||
{
|
||||
path[] toreturn;
|
||||
for (path path : paths) {
|
||||
toreturn.append(splitAtExtremes(path, f));
|
||||
}
|
||||
return toreturn;
|
||||
}
|
||||
|
||||
path3 toCamera(triple p, projection P=currentprojection, real fuzz = .01, real upperLimit = 100) {
|
||||
if (!P.infinity) {
|
||||
triple directionToCamera = unit(P.camera - p);
|
||||
triple startingPoint = p + fuzz*directionToCamera;
|
||||
return startingPoint -- P.camera;
|
||||
}
|
||||
else {
|
||||
triple directionToCamera = unit(P.camera);
|
||||
triple startingPoint = p + fuzz*directionToCamera;
|
||||
|
||||
return startingPoint -- (p + upperLimit*directionToCamera);
|
||||
}
|
||||
}
|
||||
|
||||
int numSheetsHiding(surface s, pair parampt, projection P = currentprojection) {
|
||||
triple p = s.point(parampt.x, parampt.y);
|
||||
path3 tocamera = toCamera(p, P);
|
||||
triple pt = beginpoint(tocamera);
|
||||
triple dir = endpoint(tocamera) - pt;
|
||||
return numPointsInDirection(s, parampt, dir);
|
||||
}
|
||||
|
||||
struct coloredPath {
|
||||
path path;
|
||||
pen pen;
|
||||
void operator init(path path, pen p=currentpen) {
|
||||
this.path = path;
|
||||
this.pen = p;
|
||||
}
|
||||
/* draws the path with the pen having the specified weight (using colors)*/
|
||||
void draw(real weight) {
|
||||
draw(path, p=weight*pen + (1-weight)*white);
|
||||
}
|
||||
}
|
||||
coloredPath[][] layeredPaths;
|
||||
// onTop indicates whether the path should be added at the top or bottom of the specified layer
|
||||
void addPath(path path, pen p=currentpen, int layer, bool onTop=true) {
|
||||
coloredPath toAdd = coloredPath(path, p);
|
||||
if (layer >= layeredPaths.length) {
|
||||
layeredPaths[layer] = new coloredPath[] {toAdd};
|
||||
} else if (onTop) {
|
||||
layeredPaths[layer].push(toAdd);
|
||||
} else layeredPaths[layer].insert(0, toAdd);
|
||||
}
|
||||
|
||||
void drawLayeredPaths() {
|
||||
for (int layer = layeredPaths.length - 1; layer >= 0; --layer) {
|
||||
real layerfactor = (1/3)^layer;
|
||||
for (coloredPath toDraw : layeredPaths[layer]) {
|
||||
toDraw.draw(layerfactor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
real[] cutTimes(path tocut, path[] knives) {
|
||||
real[] intersectionTimes = new real[] {0, length(tocut)};
|
||||
for (path knife : knives) {
|
||||
real[][] complexIntersections = intersections(tocut, knife);
|
||||
for (real[] times : complexIntersections) {
|
||||
intersectionTimes.push(times[0]);
|
||||
}
|
||||
}
|
||||
return sort(intersectionTimes);
|
||||
}
|
||||
|
||||
path[] cut(path tocut, path[] knives) {
|
||||
real[] cutTimes = cutTimes(tocut, knives);
|
||||
path[] toreturn;
|
||||
for (int i = 0; i + 1 < cutTimes.length; ++i) {
|
||||
toreturn.push(subpath(tocut,cutTimes[i], cutTimes[i+1]));
|
||||
}
|
||||
return toreturn;
|
||||
}
|
||||
|
||||
real[] condense(real[] values, real fuzz=.001) {
|
||||
values = sort(values);
|
||||
values.push(infinity);
|
||||
real previous = -infinity;
|
||||
real lastMin;
|
||||
real[] toReturn;
|
||||
for (real t : values) {
|
||||
if (t - fuzz > previous) {
|
||||
if (previous > -infinity) toReturn.push((lastMin + previous) / 2);
|
||||
lastMin = t;
|
||||
}
|
||||
previous = t;
|
||||
}
|
||||
return toReturn;
|
||||
}
|
||||
|
||||
/*
|
||||
* A smooth surface parametrized by the domain [0,1] x [0,1]
|
||||
*/
|
||||
struct SmoothSurface {
|
||||
surface s;
|
||||
private real sumax;
|
||||
private real svmax;
|
||||
path[] paramSilhouette;
|
||||
path[] projectedSilhouette;
|
||||
projection theProjection;
|
||||
|
||||
path3 onSurface(path paramPath) {
|
||||
return onSurface(s, scale(sumax,svmax)*paramPath);
|
||||
}
|
||||
|
||||
triple point(real u, real v) { return s.point(sumax*u, svmax*v); }
|
||||
triple point(pair uv) { return point(uv.x, uv.y); }
|
||||
triple normal(real u, real v) { return s.normal(sumax*u, svmax*v); }
|
||||
triple normal(pair uv) { return normal(uv.x, uv.y); }
|
||||
|
||||
void operator init(surface s, projection P=currentprojection) {
|
||||
this.s = s;
|
||||
this.sumax = umax(s);
|
||||
this.svmax = vmax(s);
|
||||
this.theProjection = P;
|
||||
this.paramSilhouette = scale(1/sumax, 1/svmax) * paramSilhouetteNoEdges(s,P);
|
||||
this.projectedSilhouette = sequence(new path(int i) {
|
||||
path3 truePath = onSurface(paramSilhouette[i]);
|
||||
path projectedPath = project(truePath, theProjection, ninterpolate=1);
|
||||
return projectedPath;
|
||||
}, paramSilhouette.length);
|
||||
}
|
||||
|
||||
int numSheetsHiding(pair parampt) {
|
||||
return numSheetsHiding(s, scale(sumax,svmax)*parampt);
|
||||
}
|
||||
|
||||
void drawSilhouette(pen p=currentpen, bool includePathsBehind=false, bool onTop = true) {
|
||||
int[][] extremes;
|
||||
for (path path : projectedSilhouette) {
|
||||
extremes.push(extremes(path));
|
||||
}
|
||||
|
||||
path[] splitSilhouette;
|
||||
path[] paramSplitSilhouette;
|
||||
|
||||
/*
|
||||
* First, split at extremes to ensure that there are no
|
||||
* self-intersections of any one subpath in the projected silhouette.
|
||||
*/
|
||||
|
||||
for (int j = 0; j < paramSilhouette.length; ++j) {
|
||||
path current = projectedSilhouette[j];
|
||||
|
||||
path currentParam = paramSilhouette[j];
|
||||
|
||||
int[] dividers = extremes[j];
|
||||
for (int i = 0; i + 1 < dividers.length; ++i) {
|
||||
int start = dividers[i];
|
||||
int end = dividers[i+1];
|
||||
splitSilhouette.push(subpath(current,start,end));
|
||||
paramSplitSilhouette.push(subpath(currentParam, start, end));
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Now, split at intersections of distinct subpaths.
|
||||
*/
|
||||
|
||||
for (int j = 0; j < splitSilhouette.length; ++j) {
|
||||
path current = splitSilhouette[j];
|
||||
path currentParam = paramSplitSilhouette[j];
|
||||
|
||||
real[] splittingTimes = new real[] {0,length(current)};
|
||||
for (int k = 0; k < splitSilhouette.length; ++k) {
|
||||
if (j == k) continue;
|
||||
real[][] times = intersections(current, splitSilhouette[k]);
|
||||
for (real[] time : times) {
|
||||
real relevantTime = time[0];
|
||||
if (.01 < relevantTime && relevantTime < length(current) - .01) splittingTimes.push(relevantTime);
|
||||
}
|
||||
}
|
||||
splittingTimes = sort(splittingTimes);
|
||||
for (int i = 0; i + 1 < splittingTimes.length; ++i) {
|
||||
real start = splittingTimes[i];
|
||||
real end = splittingTimes[i+1];
|
||||
real mid = start + ((end-start) / (2+0.1*unitrand()));
|
||||
pair theparampoint = point(currentParam, mid);
|
||||
int sheets = numSheetsHiding(theparampoint);
|
||||
|
||||
if (sheets == 0 || includePathsBehind) {
|
||||
path currentSubpath = subpath(current, start, end);
|
||||
addPath(currentSubpath, p=p, onTop=onTop, layer=sheets);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
Splits a parametrized path along the parametrized silhouette,
|
||||
taking [0,1] x [0,1] as the
|
||||
fundamental domain. Could be implemented more efficiently.
|
||||
*/
|
||||
private real[] splitTimes(path thepath) {
|
||||
pair min = min(thepath);
|
||||
pair max = max(thepath);
|
||||
path[] baseknives = paramSilhouette;
|
||||
path[] knives;
|
||||
for (int u = floor(min.x); u < max.x + .001; ++u) {
|
||||
for (int v = floor(min.y); v < max.y + .001; ++v) {
|
||||
knives.append(shift(u,v)*baseknives);
|
||||
}
|
||||
}
|
||||
return cutTimes(thepath, knives);
|
||||
}
|
||||
|
||||
/*
|
||||
Returns the times at which the projection of the given path3 intersects
|
||||
the projection of the surface silhouette. This may miss unstable
|
||||
intersections that can be detected by the previous method.
|
||||
*/
|
||||
private real[] silhouetteCrossingTimes(path3 thepath, real fuzz = .01) {
|
||||
path projectedpath = project(thepath, theProjection, ninterpolate=1);
|
||||
real[] crossingTimes = cutTimes(projectedpath, projectedSilhouette);
|
||||
if (crossingTimes.length == 0) return crossingTimes;
|
||||
real current = 0;
|
||||
real[] toReturn = new real[] {0};
|
||||
for (real prospective : crossingTimes) {
|
||||
if (prospective > current + fuzz
|
||||
&& prospective < length(thepath) - fuzz) {
|
||||
toReturn.push(prospective);
|
||||
current = prospective;
|
||||
}
|
||||
}
|
||||
toReturn.push(length(thepath));
|
||||
return toReturn;
|
||||
}
|
||||
|
||||
void drawSurfacePath(path parampath, pen p=currentpen, bool onTop=true) {
|
||||
path[] toDraw;
|
||||
real[] crossingTimes = splitTimes(parampath);
|
||||
crossingTimes.append(silhouetteCrossingTimes(onSurface(parampath)));
|
||||
crossingTimes = condense(crossingTimes);
|
||||
for (int i = 0; i+1 < crossingTimes.length; ++i) {
|
||||
toDraw.push(subpath(parampath, crossingTimes[i], crossingTimes[i+1]));
|
||||
}
|
||||
for (path thepath : toDraw) {
|
||||
pair midpoint = point(thepath, length(thepath) / (2+.1*unitrand()));
|
||||
int sheets = numSheetsHiding(midpoint);
|
||||
path path3d = project(onSurface(thepath), theProjection, ninterpolate = 1);
|
||||
addPath(path3d, p=p, onTop=onTop, layer=sheets);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
real unit = 4cm;
|
||||
unitsize(unit);
|
||||
triple eye = (10,1,4);
|
||||
//currentprojection=perspective(2*eye);
|
||||
currentprojection=orthographic(eye);
|
||||
|
||||
surface torus = surface(Circle(c=2Y, r=0.6, normal=X, n=32), c=O, axis=Z, n=32);
|
||||
torus.ucyclic(true);
|
||||
torus.vcyclic(true);
|
||||
|
||||
SmoothSurface Torus = SmoothSurface(torus);
|
||||
|
||||
Torus.drawSilhouette(p=black, includePathsBehind=true);
|
||||
|
||||
pair a = (22/40, 3/40);
|
||||
pair b = (5/40, .5);
|
||||
|
||||
path abpathparam(int ucycles, int vcycles) {
|
||||
pair bshift = (ucycles, vcycles);
|
||||
pair f(real t) {
|
||||
return (1-t)*a + t*(b+bshift);
|
||||
}
|
||||
return graph(f, 0, 1);
|
||||
}
|
||||
|
||||
real linewidth = 0.8pt;
|
||||
|
||||
Torus.drawSurfacePath(abpathparam(0,0), p=linewidth + orange);
|
||||
Torus.drawSurfacePath(abpathparam(1,0), p=linewidth + red);
|
||||
Torus.drawSurfacePath(abpathparam(1,-1), p=linewidth + (darkgreen + 0.2blue));
|
||||
|
||||
pen meshpen = gray(0.6);
|
||||
for (real u = 0; u < 1; u += 1/10) {
|
||||
Torus.drawSurfacePath(graph(new pair(real v) {return (u,v);}, 0,1,n=10), p=meshpen, onTop=false);
|
||||
}
|
||||
for (real v = 0; v < 1; v += 1/10) {
|
||||
Torus.drawSurfacePath(graph(new pair(real u) {return (u,v);}, 0, 1, n=10), p=meshpen, onTop=false);
|
||||
}
|
||||
|
||||
drawLayeredPaths();
|
||||
|
||||
dot(project(Torus.point(a.x,a.y)), L="$a$", align=W);
|
||||
dot(project(Torus.point(b.x,b.y)), L="$b$", align=N);
|
Loading…
Add table
Add a link
Reference in a new issue