Plotting Mutually-Occluding (Knit) Curves in 2D

60 views (last 30 days)
DGM on 27 Nov 2022
Edited: DGM on 4 Jan 2023 at 8:39
I was browsing and doing cleanup earlier, and I came across this misplaced question-as-answer on a question about basic plotting of a sine function.
As it's misplaced, I didn't want to answer it in-place, but the more I thought about it, it really did seem like a pretty good question. Considering the upvotes, I'm not the only one. I decided I would repost it as a proper question so that I could take a stab at answering it. It's normally slow on the weekend, so I figured I'd leave this as a bit of a challenge to anyone who wants to play along with what is otherwise a frivolous endeavor.
The requirements are loose.
  • There is no strict definition of the interlocking curves. Any method which strikes at a similar shape is fine.
  • The individual curves should have significant width -- that prevents some minor simplifications.
  • The curves should have different colors for sake of clarity.
  • Result can be done in-figure or as an image.
  • Number of curves and loops should be adjustable.
The way I see it, there are a few inroads to an answer.
  • Using plot3() to actually knit the curves together and then view in 2D
  • Using nonflat patch objects in 3D to do similar
  • Using polyshape or other 2D geometry tools that I'm unfamiliar with
  • Directly compositing an image using masks like some kind of maniac
Using plot3() seems like the simple approach, but linewidth scaling is relative. Using patch() objects is appealing. That would allow the linewidth to be fixed, and it would also allow for the lines to have an additional border color. Off the top of my head, idk a neat way to calculate the vertex coordinates from the coordinates of a centerline curve (minkowski sum?). The idea of strictly operating with plane geometry sounds like the supreme challenge.
I'll add a couple of answers, but I know well enough that if anyone cares to contribute that someone else probably has a better way of approaching the problem.

Answers (2)

DGM on 27 Nov 2022
Edited: DGM on 27 Nov 2022
I'm going to take this cursory shot at doing it with plot3(). My curve construction is pretty naive, but I just found it easier to indulge in basic precalc tedium instead of trying to make a parametric curve that looked similar to the original.
% parameters
aspectratio = 0.78; % loop width/height
rowoffset = 0.33;
strokew = 12; % stroke width
outlinew = 1.5; % relative to strokew
outlinev = 0.6; % relative brightness of outline
rowcolors = [0.07 0.33 0.52; 0.60 0.22 0.24; ...
0.71 0.70 0.63; 0.26 0.47 0.39]; % white adjusted to taste
nloops = 4; % number of loops per row
loopsontop = false; % controls layer order
% apparent stroke width is a function of the view configuration (zoom level,etc)
% appearance and clipping aren't going to be consistent if the figure is adjusted
% %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
% the basic curve construction
% i'm just going to make up something that looks like the picture
y = linspace(0,1,100); % start with quarter-curve
x = (y.^(1/3) - 0.5*y) .* (1 + 0.7*(1 - (y + (1-y).^4)));
x = [x fliplr(1-x)]/2; % half-curve expansion
y = [y y+1]/2;
x = [x fliplr(1-x)]; % single-curve expansion
y = [y fliplr(y)];
x = reshape((x+(0:nloops-1).').',1,[]); % replicate loops
y = repmat(y,[1 nloops]);
x = rescale(x,0,aspectratio*nloops); % scale to fit AR
% create z-contour to allow for mutual occlusion
if loopsontop
z = 0.5^(-4)*(y-0.5).^4;
z = 1 - 0.5^(-4)*(y-0.5).^4;
% plot the thing
hold on
for k = 1:nloops
thisy = y + (k-1)*(1-rowoffset);
thiscolor = rowcolors(nloops-k+1,:);
plot3(x,thisy,z,'color',thiscolor,'linewidth',strokew/outlinew) % the interior stroke
plot3(x,thisy,z-0.1,'color',thiscolor*outlinev,'linewidth',strokew) % the outline stroke
axis equal
If we rotate the view, we can see how this works.
The direction of the weave can be swapped by just complementing the z data:
I was honestly expecting this to be a lot more complicated, but then again, using plot3() probably is the easiest way. Still, the lack of control over the relative width of the strokes is really annoying.

DGM on 27 Nov 2022
Edited: DGM on 4 Jan 2023 at 8:39
I'm going to assume that I'm the only person who would do it by image composition, but I guess that's the niche I've made for myself. I actually did it this way before I even thought of using plot3(). As bad as I thought it would be, it was worse. This answer uses a few tools from MIMT (on the File Exchange), namely the replacepixels() compositor.
The result is an antialiased raster image.
% nominal mask parameters
rowheight = 200; % nominal loop size
loopwidth = 155; % nominal loop size
strokew = 30; % nominal stroke width
% composition parameters
bgcolor = [1 0.9 0.8]*0.15; % pick a bg color
rowcolors = [0.07 0.33 0.52; 0.60 0.22 0.24; ...
0.71 0.70 0.63; 0.26 0.47 0.39]; % white adjusted to taste
nloops = 4; % number of loops per row
loopsontop = false; % controls layer order
% nominal mask parameters are subject to rounding and padding
% maximum strokew is a function of rowheight, loopwidth and quarter-curve function
% there's nothing stopping you from causing clipping if you adjust the mask parameters
% %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
% generate base single-loop mask
basemk = generatebasemk(rowheight,loopwidth,strokew);
% prepare masks and compose image
outpict = composeimg(basemk,nloops,strokew,bgcolor,rowcolors,loopsontop);
% display output
% %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
function outpict = composeimg(basemk,nloops,strokew,bgcolor,rowcolors,loopsontop)
% split single-loop mask into halves
halfh = size(basemk,1)/2;
tophalf = basemk(1:halfh,:);
bothalf = basemk(halfh+1:end,:);
% pad and resplit to create quarter-row masks for compositing
padh = roundeven(halfh + ceil(strokew/2)) - halfh; % pad, ensuring even height
tophalfmid = padarray(tophalf,[padh 0],0,'pre'); % loop tops
bothalfmid = padarray(bothalf,[padh 0],0,'post'); % loop bots
halfh = round(size(tophalfmid,1)/2);
midtu = tophalfmid(1:halfh,:); % lower half of loop tops
midtl = tophalfmid(halfh+1:end,:); % upper half of loop tops
midbu = bothalfmid(1:halfh,:); % lower half of loop bots
midbl = bothalfmid(halfh+1:end,:); % upper half of loop bots
% visualize the intersection of the halfmasks
%comprow = imfuse(tophalfmid,bothalfmid); imshow(iminv(comprow))
% create background templates
BGend = colorpict(imsize(tophalf,2),bgcolor);
BGmid = colorpict(imsize(midtu,2),bgcolor);
% construct the output image chunks
nsubimages = size(rowcolors,1)+1;
C = cell(nsubimages,1);
for k = 1:nsubimages
if k == 1
C{k} = replacepixels(rowcolors(1,:),BGend,tophalf);
elseif k == nsubimages
C{k} = replacepixels(rowcolors(end,:),BGend,bothalf);
if loopsontop
Au = replacepixels(rowcolors(k-1,:),BGmid,midbu);
Au = replacepixels(rowcolors(k,:),Au,midtu);
Al = replacepixels(rowcolors(k,:),BGmid,midtl);
Al = replacepixels(rowcolors(k-1,:),Al,midbl);
C{k} = [Au; Al];
Au = replacepixels(rowcolors(k,:),BGmid,midtu);
Au = replacepixels(rowcolors(k-1,:),Au,midbu);
Al = replacepixels(rowcolors(k-1,:),BGmid,midbl);
Al = replacepixels(rowcolors(k,:),Al,midtl);
C{k} = [Au; Al];
outpict = vertcat(C{:});
outpict = repmat(outpict,[1 nloops]);
function basemask = generatebasemk(rowheight,loopwidth,strokew)
% parameters
kaa = 3; % antialiasing upscale factor (i'm lazy)
% the basic half-loop curve construction
y = linspace(0,1,100); % start with quarter-curve
x = (y.^(1/3) - 0.5*y) .* (1 + 0.7*(1 - (y + (1-y).^4)));
x = [x fliplr(1-x)]; % half-curve expansion
y = [y y+1];
% generate half-loop block mask
rowheight = roundeven(rowheight*kaa,'ceil');
vpad = ceil(kaa*strokew/2);
blocksz = [rowheight+2*vpad round(kaa*loopwidth/2)];
x = rescale(x,1,blocksz(2));
y = rescale(1-y,1+vpad,rowheight+vpad);
% i'm going to be lazy and use roi tools to create the polyline
% it'd be cool to make masks without squatting on a figure
ROI = images.roi.Polyline(gca);
ROI.Position = [x(:) y(:)];
basemask = createMask(ROI);
% assemble the base row mask
basemask = imdilate(basemask,strel('disk',vpad,0)); % dilate
basemask = imresize(double(basemask),1/kaa,'bilinear'); % downscale
basemask = im2uint8([basemask fliplr(basemask)]);
It's a lot more verbose, but at least it provides better control over the stroke width. Since the background color is selectable and replacepixels() supports RGBA workflow, there's no reason that it can't be transparent.
Since transparency is supported, post processing allows the background to be anything. Also, the tuples in rowcolors can be RGBA.
Of course, the size is adjustable and the order can be flipped.
I decided to turn this dumb example into a function and throw it in MIMT. See genknit()

Community Treasure Hunt

Find the treasures in MATLAB Central and discover how the community can help you!

Start Hunting!