Spirograph Guilloche Generator
I’ve been working on a back design for playing cards, and decided to make a tool to draw ornate spiral figures. To make things frustrating challenging, I’m using Reason / React to generate SVG.
I’m still learning how to organize React components in Reason React. I haven’t been able to figure out how to split up the sliders into a subcomponent yet, so the code quality is a bit disappointing.
I noticed some strange things along the way. The <input type="range">
slider has some interesting types, with min
being typed as an int
, and max
being typed as a string
.
I struggled with building an SVG path which wants an array of points as a long string. A method to join an array of strings isn’t present in the standard (non-Javascript) library. Initially, I converted the array(point)
to a list and ran List.fold_left to flatten it down to a string. But in the end, I used Js.Array.joinWith(" ")
which is probably faster.
SpirographGenerator.res
type action =
| SetAngleA(string)
| SetAngleB(string)
| SetAngleC(string)
| SetAngleD(string)
| SetScaleA(string)
| SetScaleB(string)
| SetScaleC(string)
| SetScaleD(string)
| SetOffset(string)
| SetRepeatCount(string)
| SetRepeatOffset(string)
| SetThickness(string)
| SetScale(string)
;
type state = {
angleA: int,
angleB: int,
angleC: int,
angleD: int,
scaleA: int,
scaleB: int,
scaleC: int,
scaleD: int,
offset: int,
repeatCount: int,
repeatOffset: int,
thickness: int,
scale: int,
};
type point = {
x: float,
y: float
};
module SvgPath = {
@react.component
let make = (~state) => {
let moveto = (p) => Js.Float.toString(p.x) ++ "," ++ Js.Float.toString(p.y);
let points = (step) => {
let count = 360 * 8;
let offset = float_of_int(state.offset) /. 500.0 *. float_of_int(step);
let repeatOffset = float_of_int(state.repeatOffset) /. 50.0 *. float_of_int(step);
let angleA = float_of_int(state.angleA);
let angleB = float_of_int(state.angleB);
let angleC = float_of_int(state.angleC);
let angleD = float_of_int(state.angleD);
let scaleA = float_of_int(state.scaleA);
let scaleB = float_of_int(state.scaleB);
let scaleC = float_of_int(state.scaleC);
let scaleD = float_of_int(state.scaleD);
let scale = float_of_int(state.scale) /. 100.0;
let points = [{x: 0.0, y: 0.0}]
let radian_conv = 0.01745329251;
for (i in 0 to count) {
let degrees = float_of_int(i) /. float_of_int(count) *. 360.0;
let x = sin(degrees *. radian_conv *. angleA) *. scaleA;
let y = cos(degrees *. radian_conv *. angleA) *. scaleA;
let xx = x +. sin(degrees *. radian_conv *. angleB +. offset) *. scaleB;
let yy = y +. cos(degrees *. radian_conv *. angleB +. offset) *. scaleB;
let xxx = xx +. sin(degrees *. radian_conv *. angleC) *. (scaleC);
let yyy = yy +. cos(degrees *. radian_conv *. angleC) *. (scaleC);
let xxxx = xxx +. sin(degrees *. radian_conv *. angleD) *. (scaleD +. repeatOffset);
let yyyy = yyy +. cos(degrees *. radian_conv *. angleD) *. (scaleD +. repeatOffset);
points[i] = {
x: xxxx *. scale,
y: yyyy *. scale
}
}
let joined = Array.map(points, (p) => moveto(p))
|> Js.Array.joinWith(" ");
"M" ++ joined ++ "z"
};
let count = state.repeatCount;
let repeatCounter = [];
for (i in 0 to count) {
repeatCounter[i] = i;
};
(React.array(
Array.map(repeatCounter, step => {
let thickness = state.thickness;
let lineThickness = (thickness > 0) ? Js.Float.toString(float_of_int(step) *. float_of_int(state.thickness) /. float_of_int(count)) : "1.0";
<path
transform={"translate(" ++ string_of_int(640/2) ++ " " ++ string_of_int(640/2) ++ ")"}
d=points(step)
stroke="black"
strokeWidth=lineThickness
fill="none"
/>
}
)
))
}
}
module SvgRender = {
@react.component
let make = (~state) => {
<svg width="640" height="640">
<rect
width="640"
height="640"
style=ReactDOM.Style.make(
~fill="#fff",
~border="1px solid #ccc",
~padding="20px", ())
/>
<SvgPath state />
</svg>
}
}
@react.component
let make = () => {
let (state, dispatch) =
React.useReducer(
(state, action) =>
switch (action) {
| SetAngleA(n) => {...state, angleA: int_of_string(n)}
| SetAngleB(n) => {...state, angleB: int_of_string(n)}
| SetAngleC(n) => {...state, angleC: int_of_string(n)}
| SetAngleD(n) => {...state, angleD: int_of_string(n)}
| SetScaleA(n) => {...state, scaleA: int_of_string(n)}
| SetScaleB(n) => {...state, scaleB: int_of_string(n)}
| SetScaleC(n) => {...state, scaleC: int_of_string(n)}
| SetScaleD(n) => {...state, scaleD: int_of_string(n)}
| SetOffset(n) => {...state, offset: int_of_string(n)}
| SetRepeatOffset(n) => {...state, repeatOffset: int_of_string(n)}
| SetRepeatCount(n) => {...state, repeatCount: int_of_string(n)}
| SetThickness(n) => {...state, thickness: int_of_string(n)}
| SetScale(n) => {...state, scale: int_of_string(n)}
},
{
angleA: -8,
angleB: 17,
angleC: 7,
angleD: 0,
scaleA: 82,
scaleB: 41,
scaleC: 144,
scaleD: 0,
offset: 0,
repeatOffset: 45,
repeatCount: 5,
thickness: 1,
scale: 100,
},
);
<div>
<form style=ReactDOM.Style.make(~position="absolute", ~left="20px", ~width="160px", ~margin="10px", ())>
<div>
<label htmlFor="angleA">{React.string("Angle A")}
{React.string(": " ++ string_of_int(state.angleA))}</label><br />
<input
id="angleA"
type_="range"
value=string_of_int(state.angleA)
name="angleA"
min="-72"
max="72"
onChange=(
event => dispatch(SetAngleA(ReactEvent.Form.target(event)["value"]))
)
style=ReactDOM.Style.make(~width="100%", ())
/>
</div>
<div>
<label htmlFor="angleB">{React.string("Angle B")}
{React.string(": " ++ string_of_int(state.angleB))}</label><br />
<input
id="angleB"
type_="range"
value=string_of_int(state.angleB)
name="angleB"
min="-72"
max="72"
onChange=(
event => dispatch(SetAngleB(ReactEvent.Form.target(event)["value"]))
)
style=ReactDOM.Style.make(~width="100%", ())
/>
</div>
<div>
<label htmlFor="angleC">{React.string("Angle C")}
{React.string(": " ++ string_of_int(state.angleC))}</label><br />
<input
id="angleC"
type_="range"
value=string_of_int(state.angleC)
name="angleC"
min="-72"
max="72"
onChange=(
event => dispatch(SetAngleC(ReactEvent.Form.target(event)["value"]))
)
style=ReactDOM.Style.make(~width="100%", ())
/>
</div>
<div>
<label htmlFor="angleD">{React.string("Angle D")}
{React.string(": " ++ string_of_int(state.angleD))}</label><br />
<input
id="angleD"
type_="range"
value=string_of_int(state.angleD)
name="angleD"
min="-72"
max="72"
onChange=(
event => dispatch(SetAngleD(ReactEvent.Form.target(event)["value"]))
)
style=ReactDOM.Style.make(~width="100%", ())
/>
</div>
<div>
<label htmlFor="scaleA">{React.string("Scale A")}
{React.string(": " ++ string_of_int(state.scaleA))}</label><br />
<input
id="scaleA"
type_="range"
value=string_of_int(state.scaleA)
name="scaleA"
min="-360"
max="360"
onChange=(
event => dispatch(SetScaleA(ReactEvent.Form.target(event)["value"]))
)
style=ReactDOM.Style.make(~width="100%", ())
/>
</div>
<div>
<label htmlFor="scaleB">{React.string("Scale B")}
{React.string(": " ++ string_of_int(state.scaleB))}</label><br />
<input
id="scaleB"
type_="range"
value=string_of_int(state.scaleB)
name="scaleB"
min="-360"
max="360"
onChange=(
event => dispatch(SetScaleB(ReactEvent.Form.target(event)["value"]))
)
style=ReactDOM.Style.make(~width="100%", ())
/>
</div>
<div>
<label htmlFor="scaleC">{React.string("Scale C")}
{React.string(": " ++ string_of_int(state.scaleC))}</label><br />
<input
id="scaleC"
type_="range"
value=string_of_int(state.scaleC)
name="scaleC"
min="-360"
max="360"
onChange=(
event => dispatch(SetScaleC(ReactEvent.Form.target(event)["value"]))
)
style=ReactDOM.Style.make(~width="100%", ())
/>
</div>
<div>
<label htmlFor="scaleD">{React.string("Scale D")}
{React.string(": " ++ string_of_int(state.scaleD))}</label><br />
<input
id="scaleD"
type_="range"
value=string_of_int(state.scaleD)
name="scaleD"
min="-360"
max="360"
onChange=(
event => dispatch(SetScaleD(ReactEvent.Form.target(event)["value"]))
)
style=ReactDOM.Style.make(~width="100%", ())
/>
</div>
<div>
<label htmlFor="offset">{React.string("Offset")} {React.string(": " ++ string_of_int(state.offset))}</label><br />
<input
id="offset"
type_="range"
value=string_of_int(state.offset)
name="offset"
min="-360"
max="360"
onChange=(
event => dispatch(SetOffset(ReactEvent.Form.target(event)["value"]))
)
style=ReactDOM.Style.make(~width="100%", ())
/>
</div>
<div>
<label htmlFor="offset">{React.string("Repeat Offset")} {React.string(": " ++ string_of_int(state.repeatOffset))}</label><br />
<input
id="repeatOffset"
type_="range"
value=string_of_int(state.repeatOffset)
name="repeatOffset"
min="-720"
max="720"
onChange=(
event => dispatch(SetRepeatOffset(ReactEvent.Form.target(event)["value"]))
)
style=ReactDOM.Style.make(~width="100%", ())
/>
</div>
<div>
<label htmlFor="offset">{React.string("Repeat Count")} {React.string(": " ++ string_of_int(state.repeatCount))}</label><br />
<input
id="repeatCount"
type_="range"
value=string_of_int(state.repeatCount)
name="repeatCount"
min="0"
max="50"
onChange=(
event => dispatch(SetRepeatCount(ReactEvent.Form.target(event)["value"]))
)
style=ReactDOM.Style.make(~width="100%", ())
/>
</div>
<div>
<label htmlFor="thickness">{React.string("Line Thickness")} {React.string(": " ++ string_of_int(state.thickness))}</label><br />
<input
id="thickness"
type_="range"
value=string_of_int(state.thickness)
name="thickness"
min="0"
max="10"
onChange=(
event => dispatch(SetThickness(ReactEvent.Form.target(event)["value"]))
)
style=ReactDOM.Style.make(~width="100%", ())
/>
</div>
<div>
<label htmlFor="scale">{React.string("Scale")} {React.string(": " ++ string_of_int(state.scale))}</label><br />
<input
id="scale"
type_="range"
value=string_of_int(state.scale)
name="scale"
min="0"
max="400"
onChange=(
event => dispatch(SetScale(ReactEvent.Form.target(event)["value"]))
)
style=ReactDOM.Style.make(~width="100%", ())
/>
</div>
</form>
<SvgRender state />
</div>;
};