EspacioMultiple
one form-idea in its three states. A square with rounded corners (the circle and the square at once), a rectangle, a disk, a half disk and a right triangle are the whole vocabulary, and the form-idea is a large rounded square with a displaced twin under it, a band that crosses it and ends in a half disk, a disk, and triangles cut into the corners. The sketch paints it flat in a silver or a gold scheme, raises the planes into a relief under a light that rakes across the wall, takes the relief apart into a standing piece (the sheet becomes the floor, the band a shelf through the square, the displacement depth) and folds it back, with a new combination each cycle. stage holds one state still, unlit shows that the relief seen straight on is the painting again, and the painting exports as a plan. An original Ollin interpretation, written from the work and from his own account of the method.
- Run
swift run --package-path Examples Example-Recreations-ManuelFelguerez-EspacioMultiple- Source
- Examples/Recreations/ManuelFelguerez/EspacioMultiple/Sketch.swift
// Recreation after Manuel Felguerez - "El espacio multiple" (Museo de Arte
// Moderno, Mexico City, December 1973 to February 1974): a small vocabulary
// of forms cut from the circle, the triangle and the square, carried by
// successive combinations from the painting to the relief to the free-standing
// piece. A homage, not a reproduction, and not affiliated with or endorsed by
// the artist or his estate.
// https://libros.uanl.mx/index.php/u/catalog/book/3
//
// An original Ollin interpretation, written from the work (the 1973 pairs of
// ink drawing and acrylic in the catalog, the paintings of 1979, the reliefs
// and maquettes of 1970 to 1975) and from his own account of the method.
// Nothing was ported. The form-idea below is a reading of his vocabulary,
// not one of his designs.
import Foundation
import Ollin
/// "El espacio multiple" (Manuel Felguerez, 1973): one form-idea, three states.
///
/// His recipe, in his own order. Start from a few simple geometric concepts,
/// the circle, the triangle, the square, and organize them into a form-idea.
/// Draw it in pencil and give it an order. Think of silver and surround it
/// with a few cold colors, or of gold and surround it with warm ones; give the
/// color an order too, and paint the drawing as a design made of planes. Every
/// plane holds infinitely many volumes: choose one and make a relief, and the
/// color takes that dimension with it. Then take the volume out into space,
/// and show that painting, relief and sculpture were never three things.
///
/// So this sketch keeps one vocabulary (a square with rounded corners, which
/// is the circle and the square at once; a rectangle; a disk; a half disk; and
/// a right triangle, which brings the diagonal) and one form-idea built from
/// it: a large rounded square with a displaced twin under it, a band crossing
/// it that ends in a half disk, a disk, and triangles cut into the corners.
/// What changes with `variation`, and from one cycle to the next, is the
/// combination: which plane takes which color of the silver or the gold
/// scheme, where the twin is displaced to, which row the band takes and which
/// way it leaves, which corners the triangles cut, and how tall each plane
/// stands.
///
/// The default run is the cycle. The painting; the planes rising into a
/// relief under a light that rakes across it; the relief coming apart into a
/// standing piece, where the sheet becomes the floor, the square and the disk
/// stand on it, the band becomes a shelf through the square and the
/// displacement becomes depth; and back down. A new combination arrives with
/// each cycle. `stage` holds one state still: `painting` is the flat design
/// (`--export-svg` gives the plan), `relief` the wall seen straight on with
/// the raking light circling, `sculpture` the piece on its floor with the
/// camera walking round. `depth` is how far the relief stands off the wall.
/// `unlit` turns the lights off, which is the demonstration: the relief seen
/// straight on with no light is the painting again.
@main
final class EspacioMultiple: Sketch {
enum Stage: String, CaseIterable, ParamOption { case cycle, painting, relief, sculpture }
@Param(icon: "square.stack.3d.up") var stage = Stage.cycle
@Param(8 ... 60, icon: "clock") var seconds = 24.0
@Param(0.3 ... 2.5, icon: "arrow.up.and.down") var depth = 1.0
@Param(icon: "lightbulb.slash") var unlit = false
/// The sheet is 36 by 30, the size in centimeters of the 1973 acrylics,
/// and every measure below is in centimeters on it.
private let sheet = Vector2(36, 30)
override var canvasSize: CanvasSize { .size(1296, 1080) }
/// One cycle is one lap: the fold ends flat, where the painting began.
override var loopDuration: Double? { max(4.0, seconds) }
// MARK: The vocabulary
private enum Form { case square, rectangle, disk, halfDisk, triangle }
/// One plane of the design: an outline about its own center, and what it
/// becomes in the other two states.
private struct Plane {
var form: Form
/// Sheet centimeters, y down, about `center`.
var outline: [Vector2]
var center: Vector2
var color: Color
var material = Material.matte
/// How much higher its top stands than the plane painted before it.
var step = 0.0
/// The sheet and the field keep their own thickness whatever `depth` is.
var base = false
/// Its own thickness once it stands free.
var thickness = 1.0
/// In the piece: standing on the floor, or lying on it.
var stands = false
/// A lying plane's center height; a standing plane's foot, in z.
var lift = 0.0
var footZ = 0.0
}
private struct Combination {
var planes: [Plane] = []
var wall = Color(white: 0.45)
}
private var combination = Combination()
private var onCycle = -1
// MARK: Drawing
override func draw() {
let period = max(4.0, seconds)
let cycle = stage == .cycle ? Int(floor(time / period)) : 0
if cycle != onCycle {
combination = combine(cycle)
onCycle = cycle
}
let u = (time - Double(cycle) * period) / period
// Where the run is: the heights (rise) and how far the piece has come
// apart (free), each 0 to 1.
var rise = 1.0, free = 0.0
switch stage {
case .painting:
paintFlat()
return
case .relief:
rise = 1; free = 0
case .sculpture:
rise = 1; free = 1
case .cycle:
if u < 0.125 { paintFlat(); return }
rise = u < 0.25 ? ease((u - 0.125) / 0.125) : u < 0.95 ? 1 : 1 - ease((u - 0.95) / 0.05)
free = u < 0.5 ? 0 : u < 0.625 ? ease((u - 0.5) / 0.125) : u < 0.875 ? 1 : 1 - ease((u - 0.875) / 0.075)
}
background(combination.wall)
placeCamera(free: free, period: period)
light(rise: rise, free: free, period: period)
var top = 0.0
for plane in combination.planes {
top += plane.base ? plane.step : plane.step * depth
// The relief: a slab from the wall up to its top. The piece: its own
// thickness, standing at its foot or lying at its height.
let local = Vector2(plane.center.x - sheet.x / 2, sheet.y / 2 - plane.center.y)
let reliefDepth = max(0.02, top * rise)
let reliefAt = Vector3(local.x, local.y, reliefDepth / 2)
let freeDepth = plane.thickness
let freeAt: Vector3
var tilt = 0.0
if plane.stands {
freeAt = Vector3(local.x, 1.2 + halfHeight(of: plane), plane.footZ)
} else {
freeAt = Vector3(local.x, plane.lift, plane.footZ == 0 ? -local.y : plane.footZ)
tilt = -.pi / 2
}
let e = ease(free)
let at = reliefAt + (freeAt - reliefAt) * e
let slab = reliefDepth + (freeDepth - reliefDepth) * e
withState {
translate(at.x, at.y, at.z)
if tilt != 0 { rotateX(tilt * e) }
fill(plane.color)
material(plane.material)
drawExtrude(raised(plane.outline), depth: slab)
}
}
}
/// The design as painted: every plane flat, in paint order.
private func paintFlat() {
background(combination.wall)
noStroke()
let unit = self.unit
let origin = Vector2((width - sheet.x * unit) / 2, (height - sheet.y * unit) / 2)
for plane in combination.planes {
fill(plane.color)
drawPolygon(plane.outline.map { origin + (plane.center + $0) * unit })
}
}
/// Pixels per centimeter, the sheet filling most of the canvas.
private var unit: Double { min(width * 0.86 / sheet.x, height * 0.86 / sheet.y) }
private func placeCamera(free: Double, period: Double) {
// Straight on, the sheet framed as the painting frames it: an
// orthographic wall view for the relief, and in the cycle a long lens
// at a distance, which frames a flat sheet the same way.
let framed = height / unit
// The shadow map is boxed to the eye's distance, so the eye stays as
// near as the sheet's own size allows and the map's texels stay fine.
if stage == .relief {
ortho(eye: Vector3(0, 0, 25), target: .zero, height: framed)
return
}
let distance = 55.0
let straight = Vector3(0, 0, distance)
let narrow = 2 * atan((framed / 2) / distance)
// Walking round the piece.
let around = time * 2 * .pi / period * 0.5
let orbitTarget = Vector3(0, 5.5, 0)
let orbit = orbitTarget + Vector3(cos(around) * 0.95, 0.31, sin(around) * 0.95) * 60
let e = ease(free)
let eye = straight + (orbit - straight) * e
let target = orbitTarget * e
perspective(eye: eye, target: target, fieldOfView: narrow + (0.55 - narrow) * e)
}
private func light(rise: Double, free: Double, period: Double) {
if unlit {
noLights()
return
}
// Flat and full while the planes are still a painting, and a key that
// arrives as they rise: raking across the wall, circling once a cycle,
// then from above once the piece stands free.
let lit = ease(rise)
ambientLight(Color(white: 1 - 0.58 * lit))
let rake = time * 2 * .pi / period
let grazing = 0.5
let acrossTheWall = Vector3(-cos(rake) * cos(grazing), -sin(rake) * cos(grazing), -sin(grazing))
let fromAbove = Vector3(-0.45, -0.8, -0.4).normalized
let e = ease(free)
let direction = (acrossTheWall + (fromAbove - acrossTheWall) * e).normalized
directionalLight(.white, direction: direction, intensity: lit)
directionalLight(Color(white: 0.6), direction: (Vector3(0, -0.3, -0.4) - direction).normalized,
intensity: 0.22 * lit, castsShadow: false)
if lit > 0.02 {
castShadows()
shadowSoftness(0.65)
}
}
// MARK: Combining
/// One combination of the form-idea: the scheme, the colors handed out,
/// the twin's displacement, the band's row and exit, the corners, and the
/// volume each plane chooses.
private func combine(_ index: Int) -> Combination {
randomSeed(variation &* 7919 &+ index &* 104_729)
let silver = random(0, 1) < 0.5
let metal = silver
? Color(red: 0.76, green: 0.77, blue: 0.78)
: Color(red: 0.80, green: 0.64, blue: 0.30)
// Silver among a few cold colors, or gold among warm ones, his own
// rule for the color's order. The darkest is the ground; the rest are
// dealt out without repeats.
var palette = silver
? [Color(red: 0.16, green: 0.30, blue: 0.56), Color(red: 0.36, green: 0.44, blue: 0.58),
Color(red: 0.44, green: 0.36, blue: 0.56), Color(red: 0.55, green: 0.66, blue: 0.72),
Color(red: 0.22, green: 0.40, blue: 0.42), Color(red: 0.60, green: 0.68, blue: 0.62),
Color(red: 0.13, green: 0.16, blue: 0.24)]
: [Color(red: 0.80, green: 0.31, blue: 0.16), Color(red: 0.86, green: 0.16, blue: 0.14),
Color(red: 0.93, green: 0.52, blue: 0.16), Color(red: 0.62, green: 0.45, blue: 0.28),
Color(red: 0.90, green: 0.82, blue: 0.66), Color(red: 0.55, green: 0.12, blue: 0.20),
Color(red: 0.32, green: 0.20, blue: 0.14)]
let ground = palette.removeLast()
let whole = palette
func deal() -> Color {
if palette.isEmpty { palette = whole }
let pick = Int(random(0, Double(palette.count)))
return palette.remove(at: min(pick, palette.count - 1))
}
var c = Combination()
c.wall = silver ? Color(red: 0.40, green: 0.42, blue: 0.45) : Color(red: 0.42, green: 0.38, blue: 0.34)
let paper = silver ? Color(red: 0.86, green: 0.87, blue: 0.88) : Color(red: 0.86, green: 0.83, blue: 0.78)
// The sheet, then the field inside its margin. Both lie down in the
// piece and become its floor.
let margin = 2.5
let field = Rectangle(x: margin, y: margin, width: sheet.x - 2 * margin, height: sheet.y - 2 * margin)
c.planes.append(Plane(form: .rectangle, outline: rectangle(sheet.x, sheet.y), center: sheet / 2,
color: paper, step: 0.6, base: true, thickness: 0.6, lift: 0.3))
c.planes.append(Plane(form: .rectangle, outline: rectangle(field.width, field.height), center: field.center,
color: ground, step: 0.6, base: true, thickness: 0.6, lift: 0.9))
// The square with rounded corners, and its twin displaced along a
// diagonal, painted first so it shows as a shadow of the square.
let side = snap(random(15, 19)), radius = snap(random(2.5, 4))
let square = Vector2(snap(random(field.corner.x + side / 2 + 5, field.corner.x + field.width - side / 2 - 5)),
snap(random(field.corner.y + side / 2 + 1, field.corner.y + field.height - side / 2 - 1)))
let shift = snap(random(1.2, 2.2))
let twin = square + Vector2(random(0, 1) < 0.5 ? shift : -shift, random(0, 1) < 0.5 ? shift : -shift)
let foot = -(sheet.y / 2 - square.y) + side / 2
c.planes.append(Plane(form: .square, outline: roundedSquare(side, radius: radius), center: twin,
color: deal(), step: random(0.7, 1.6), stands: true,
footZ: -(sheet.y / 2 - twin.y) + side / 2))
c.planes.append(Plane(form: .square, outline: roundedSquare(side, radius: radius), center: square,
color: metal, material: .plastic, step: random(0.7, 1.6), stands: true, footZ: foot))
// The band, from the field's edge across the square and out the other
// side, ending in a half disk. In the piece it lies as a shelf through
// the standing square, at the height it crossed.
let bandHeight = snap(random(3, 4.5))
let row = square.y + (random(0, 1) < 0.5 ? -1 : 1) * snap(random(0.3, 0.6) * (side / 2 - radius))
let exitsRight = random(0, 1) < 0.5
let reach = snap(random(2, exitsRight
? field.corner.x + field.width - bandHeight / 2 - (square.x + side / 2)
: (square.x - side / 2) - (field.corner.x + bandHeight / 2)))
let x0 = exitsRight ? field.corner.x : square.x - side / 2 - reach
let x1 = exitsRight ? square.x + side / 2 + reach : field.corner.x + field.width
let bandColor = deal()
let crossing = 1.2 + (square.y + side / 2 - row)
c.planes.append(Plane(form: .rectangle, outline: rectangle(x1 - x0, bandHeight),
center: Vector2((x0 + x1) / 2, row), color: bandColor,
step: random(0.7, 1.6), lift: crossing, footZ: foot))
c.planes.append(Plane(form: .halfDisk, outline: halfDisk(bandHeight / 2, facing: exitsRight ? 1 : -1),
center: Vector2(exitsRight ? x1 : x0, row), color: bandColor,
lift: crossing, footZ: foot))
// Triangles cut into two of the field's corners, lying as ramps in the
// piece.
var corners = [0, 1, 2, 3]
for _ in 0 ..< 2 {
let corner = corners.remove(at: Int(random(0, Double(corners.count))) % corners.count)
let size = snap(random(4, 7))
let (outline, center) = cornerTriangle(size, corner: corner, in: field)
c.planes.append(Plane(form: .triangle, outline: outline, center: center, color: deal(),
step: random(0.7, 1.6), lift: 1.2 + 0.5))
}
// The disk, in a corner of the field, standing on the floor in the piece.
let radiusDisk = snap(random(2, 3))
let corner = corners[Int(random(0, 2)) % 2]
let inset = radiusDisk + 1.5
let disk = Vector2(corner % 2 == 0 ? field.corner.x + inset : field.corner.x + field.width - inset,
corner < 2 ? field.corner.y + inset : field.corner.y + field.height - inset)
c.planes.append(Plane(form: .disk, outline: self.disk(radiusDisk), center: disk, color: deal(),
step: random(0.7, 1.6), stands: true,
footZ: -(sheet.y / 2 - disk.y) + radiusDisk))
// Now and then a small block beside the disk.
if random(0, 1) < 0.5 {
let block = disk + Vector2(corner % 2 == 0 ? radiusDisk + 2.5 : -(radiusDisk + 2.5), 0)
c.planes.append(Plane(form: .rectangle, outline: rectangle(2.5, 2.5), center: block, color: deal(),
step: random(0.7, 1.6), stands: true,
footZ: -(sheet.y / 2 - block.y) + 1.25))
}
return c
}
// MARK: Outlines, about their own center, y down
private func rectangle(_ w: Double, _ h: Double) -> [Vector2] {
[Vector2(-w / 2, -h / 2), Vector2(w / 2, -h / 2), Vector2(w / 2, h / 2), Vector2(-w / 2, h / 2)]
}
private func roundedSquare(_ side: Double, radius: Double) -> [Vector2] {
let inner = side / 2 - radius
var points: [Vector2] = []
for corner in 0 ..< 4 {
let at = Vector2(corner == 1 || corner == 2 ? inner : -inner, corner >= 2 ? inner : -inner)
let start = Double(corner) * .pi / 2 - .pi
for step in 0 ... 10 {
let angle = start + .pi / 2 * Double(step) / 10
points.append(at + Vector2(cos(angle), sin(angle)) * radius)
}
}
return points
}
private func disk(_ radius: Double) -> [Vector2] {
(0 ..< 48).map { Vector2(cos(Double($0) / 48 * 2 * .pi), sin(Double($0) / 48 * 2 * .pi)) * radius }
}
/// A half disk whose flat side sits on its center, bulging toward `facing`.
private func halfDisk(_ radius: Double, facing: Double) -> [Vector2] {
(0 ... 24).map { step in
let angle = -.pi / 2 + .pi * Double(step) / 24
return Vector2(cos(angle) * facing, sin(angle)) * radius
}
}
/// A right triangle in one corner of the field: 0 top-left, 1 top-right,
/// 2 bottom-left, 3 bottom-right.
private func cornerTriangle(_ size: Double, corner: Int, in field: Rectangle) -> ([Vector2], Vector2) {
let sx = corner % 2 == 0 ? 1.0 : -1.0, sy = corner < 2 ? 1.0 : -1.0
let apex = Vector2(corner % 2 == 0 ? field.corner.x : field.corner.x + field.width,
corner < 2 ? field.corner.y : field.corner.y + field.height)
let center = apex + Vector2(sx, sy) * (size / 3)
let points = [apex, apex + Vector2(sx * size, 0), apex + Vector2(0, sy * size)]
return (points.map { $0 - center }, center)
}
/// The same outline in the 3D plane: y up, wound counter-clockwise.
private func raised(_ outline: [Vector2]) -> [Vector2] {
let flipped = outline.map { Vector2($0.x, -$0.y) }
var area = 0.0
for i in flipped.indices {
let a = flipped[i], b = flipped[(i + 1) % flipped.count]
area += a.x * b.y - b.x * a.y
}
return area < 0 ? flipped.reversed() : flipped
}
private func halfHeight(of plane: Plane) -> Double {
let ys = plane.outline.map(\.y)
return ((ys.max() ?? 0) - (ys.min() ?? 0)) / 2
}
private func snap(_ value: Double) -> Double { (value * 2).rounded() / 2 }
private func ease(_ t: Double) -> Double {
let x = min(1, max(0, t))
return x * x * (3 - 2 * x)
}
}