Both are now at the kit's standard, so the catalogue table and the contact sheet go with them this time. Five things, each named from looking at a render beside its reference. The fire bed was a smooth disc of the darkest colour in the palette, which reads as a hole cut in the hearth with a few sparks in it. A fire bed is BROKEN -- irregular lumps of charcoal with the glow coming up between them -- and vertex colour cannot fake that, so the lumps are modelled. They are five-sided prisms at eight triangles each, and they are the difference between a forge and a stone box with a black lid. Which ones glow is decided by where they sit: the middle of a working fire is where the air arrives. The bellows' leather was cut NARROWER than the boards it lies between, so it hid in the gap and the whole thing read as a stack of loose planks with daylight through it. A bellows is a bag under pressure and its widest point is the leather, not the wood. The hearth's walls are slabs -- that is the saving that pays for the kerb being real blocks -- and a slab of one colour stays a slab however much it is mottled. Three string courses to a face, twelve boxes in all, say COURSED without laying two hundred stones, and they stand proud because a rib level with its wall is invisible. The shield's paint was pastel at 0.42 saturation against a reference whose red and blue are pigment ground in oil. And its planks are rectangles cut to the chord of a circle, so their ends step in and out around the edge -- unavoidable, since a plank is straight and the outline is not. That is exactly what a rim is for: a wide band of hide folded over the ends. At 0.055 of the radius it was too narrow to cover them and the silhouette came out serrated. Last, the cream cross between the quarters. Without it the quarters meet edge to edge and the eye sees two colours rather than a device. 34 models, 29 022 triangles, all checks pass, 0 floating cases, GLB round-trip clean, Conformant, build clean.
255 lines
10 KiB
TypeScript
255 lines
10 KiB
TypeScript
/**
|
|
* @medieval-kit/round-shield
|
|
*
|
|
* A planked round shield with a rawhide rim and an iron boss.
|
|
*
|
|
* The kit had no arms or armour at all, which for a medieval catalogue is a
|
|
* whole category missing rather than a gap. This is the cheapest way in: a
|
|
* shield is the one piece of war gear that stands on its own in a scene —
|
|
* leaning on a wall, stacked by a door — without needing a person to hold it.
|
|
*
|
|
* Three things make it a shield rather than a disc, and all three are
|
|
* structural rather than decorative:
|
|
*
|
|
* - It is BOARDS, not a plate. A round shield is planks butted edge to edge
|
|
* and held by the rim and the boss; the plank lines are the first thing
|
|
* the eye finds and a smooth face reads as a lid.
|
|
* - The rim binds the ENDS of those planks. Without it the boards would
|
|
* split off one by one at the first blow, and without it in the model the
|
|
* silhouette is a wafer.
|
|
* - The boss is a dome over a HOLE. The hand goes behind it, gripping a bar
|
|
* across the back, so the boss is not ornament — it is the knuckle guard,
|
|
* and it is why the centre of the face is iron.
|
|
*
|
|
* The face is quartered because that is what a painted shield looks like and
|
|
* because it costs nothing: each plank is built in two halves anyway, so the
|
|
* upper and lower halves simply take different colours. `hue` turns both
|
|
* quarters together and keeps them opposite, so the slider always produces a
|
|
* pair that belongs together.
|
|
*/
|
|
import { Color, type BufferGeometry } from 'three'
|
|
|
|
import {
|
|
bandGeometry,
|
|
boxGeometry,
|
|
createKitModel,
|
|
createTinter,
|
|
jitter,
|
|
latheGeometry,
|
|
mergeColoured,
|
|
prismGeometry,
|
|
taperedBoxGeometry,
|
|
type Level,
|
|
} from '../core/index.ts'
|
|
|
|
export interface RoundShieldConfig {
|
|
/** Radius of the shield (metres). */
|
|
readonly radius: number
|
|
/** Planks across the face. */
|
|
readonly planks: number
|
|
/** Thickness of the boards (metres). */
|
|
readonly thickness: number
|
|
/** How far it leans back from upright (radians). */
|
|
readonly lean: number
|
|
/** Paint colour, 0–1 around the wheel. The second quarter sits opposite. */
|
|
readonly hue: number
|
|
/** Rivets around the boss. */
|
|
readonly rivets: number
|
|
readonly seed: number
|
|
}
|
|
|
|
export const roundShieldDefaults: RoundShieldConfig = {
|
|
radius: 0.36,
|
|
planks: 9,
|
|
thickness: 0.016,
|
|
// Barely off vertical. A shield propped against something stands almost
|
|
// upright; at 0.22 it read as lying back against nothing.
|
|
lean: 0.12,
|
|
hue: 0.02,
|
|
rivets: 10,
|
|
seed: 83,
|
|
}
|
|
|
|
export type RoundShieldParts = 'boards' | 'rim' | 'boss'
|
|
|
|
export function createModel(overrides: Partial<RoundShieldConfig> = {}) {
|
|
return createKitModel<RoundShieldConfig, 'oak' | 'leather' | 'iron', RoundShieldParts>({
|
|
id: 'round-shield',
|
|
defaults: roundShieldDefaults,
|
|
slots: ['oak', 'leather', 'iron'],
|
|
|
|
build: ({ config, random }) => {
|
|
const tint = createTinter(random)
|
|
const R = config.radius
|
|
const T = config.thickness
|
|
const planks = Math.max(3, Math.round(config.planks))
|
|
const hue = ((config.hue % 1) + 1) % 1
|
|
|
|
// Saturated and dark. At 0.42 saturation and 0.34 lightness the face
|
|
// came out pastel against a reference whose red and blue are deep enough
|
|
// to read as pigment ground in oil -- which is what they were. Shield
|
|
// paint is not a wash.
|
|
const paint = (h: number, dark: number): Color =>
|
|
new Color().setHSL(
|
|
(h + jitter(random, 0.01) + 1) % 1,
|
|
0.66 + jitter(random, 0.05),
|
|
dark + jitter(random, 0.03),
|
|
)
|
|
|
|
// --- Boards ---------------------------------------------------------
|
|
// Each plank is built in two halves so the face can be quartered without
|
|
// any extra geometry: the split the paint needs is a split the boards
|
|
// already have to make anyway.
|
|
const boards: BufferGeometry[] = []
|
|
const step = (R * 2) / planks
|
|
const upper = paint(hue, 0.26)
|
|
const lower = paint((hue + 0.55) % 1, 0.23)
|
|
|
|
for (let i = 0; i < planks; i += 1) {
|
|
const x = -R + step * (i + 0.5)
|
|
// The plank is a chord of the circle, so its length is set by how far
|
|
// out it sits. Squaring off every plank at full width would give a
|
|
// square with a rim drawn on it.
|
|
const halfSpan = Math.sqrt(Math.max(0, R * R - Math.pow(Math.abs(x) + step * 0.5, 2)))
|
|
if (halfSpan <= step * 0.2) continue
|
|
for (const half of [-1, 1]) {
|
|
// The two halves of one plank are quartered on the DIAGONAL: the
|
|
// left-hand planks take one colour above and the other below, and
|
|
// the right-hand ones the reverse. That is what makes a quartered
|
|
// shield read as quartered rather than as banded.
|
|
const left = x < 0
|
|
const colour = (half < 0) === left ? upper : lower
|
|
boards.push(taperedBoxGeometry(
|
|
[step * 0.97, T],
|
|
[step * 0.94, T * 0.92],
|
|
halfSpan,
|
|
[x, half * halfSpan * 0.5, 0],
|
|
new Color(colour),
|
|
))
|
|
}
|
|
}
|
|
|
|
// --- Rim ------------------------------------------------------------
|
|
// A rawhide binding folded over the edge. It is a band whose thickness
|
|
// reaches INSIDE the boards' own radius, so it grips them rather than
|
|
// sitting against their ends.
|
|
// Thick enough to SWALLOW the plank ends.
|
|
//
|
|
// The boards are rectangles cut to the chord of the circle, so their ends
|
|
// step in and out around the edge -- unavoidable, since a plank is
|
|
// straight and the outline is not. A real rim hides exactly that: it is a
|
|
// wide band of hide folded over the ends and stitched, and it is what
|
|
// turns a stack of boards into a disc. At 0.055 of the radius it was too
|
|
// narrow to cover the steps and the silhouette came out serrated.
|
|
const rim = bandGeometry(
|
|
R * 1.02, 0, T * 3.2, R * 0.105, planks * 3,
|
|
tint('leather', -0.04, 0.9), { inner: true },
|
|
)
|
|
rim.rotateX(Math.PI / 2)
|
|
|
|
// The cross between the quarters, laid ON the face.
|
|
//
|
|
// Two bands of pale paint dividing the four quarters. It is the detail
|
|
// that makes a quartered shield read at a distance -- without it the
|
|
// quarters meet edge to edge and the eye sees two colours rather than a
|
|
// device. Proud of the boards rather than inlaid, because paint is.
|
|
const trim: BufferGeometry[] = []
|
|
const band = R * 0.1
|
|
const cream = new Color().setHSL(0.11, 0.24, 0.74)
|
|
for (const vertical of [false, true]) {
|
|
const arm = taperedBoxGeometry(
|
|
vertical ? [band, T * 0.5] : [R * 1.94, T * 0.5],
|
|
vertical ? [band * 0.97, T * 0.4] : [R * 1.9, T * 0.4],
|
|
vertical ? R * 1.94 : band,
|
|
[0, 0, T * 0.62],
|
|
new Color(cream),
|
|
)
|
|
trim.push(arm)
|
|
}
|
|
|
|
// --- Boss -----------------------------------------------------------
|
|
const bossR = R * 0.26
|
|
const iron: BufferGeometry[] = []
|
|
// The dome. Its back is open, because behind it is the hole the hand
|
|
// goes into, and a cap there would be a face nobody can see.
|
|
iron.push(latheGeometry(
|
|
[
|
|
{ y: 0, radius: bossR * 0.98 },
|
|
{ y: bossR * 0.42, radius: bossR * 0.86 },
|
|
{ y: bossR * 0.78, radius: bossR * 0.54 },
|
|
{ y: bossR * 0.94, radius: bossR * 0.16 },
|
|
] as Level[],
|
|
11, [0, 0, 0], tint('iron', 0.05, 0.7), { capBottom: false },
|
|
// +90, not -90. The dome is turned about Y, and -90 sends its point
|
|
// to -Z -- into the back of the shield, where the only thing visible
|
|
// from the front was the open rim of it. That is why the boss read as a
|
|
// flat black ring.
|
|
).rotateX(Math.PI / 2).translate(0, 0, T * 0.5))
|
|
// The flange it is riveted through.
|
|
iron.push(bandGeometry(
|
|
bossR * 1.34, 0, T * 1.1, bossR * 0.36, 11,
|
|
tint('iron', -0.02, 0.7), { inner: true },
|
|
).rotateX(Math.PI / 2).translate(0, 0, T * 0.55))
|
|
|
|
const rivets = Math.max(0, Math.round(config.rivets))
|
|
for (let i = 0; i < rivets; i += 1) {
|
|
const a = (i / rivets) * Math.PI * 2 + 0.2
|
|
// Flared outwards, like the barrel's: a stud that tapers away from the
|
|
// surface tilts its side normals back towards it, which the radial
|
|
// check reads as reversed winding, and a hammered rivet flares anyway.
|
|
const stud = prismGeometry(
|
|
R * 0.016, R * 0.022, T * 1.4, 4, [0, 0, 0],
|
|
tint('iron', 0.09, 0.6), { capBottom: false },
|
|
)
|
|
stud.rotateX(-Math.PI / 2)
|
|
iron.push(stud.translate(
|
|
Math.sin(a) * bossR * 1.16,
|
|
Math.cos(a) * bossR * 1.16,
|
|
T * 0.8,
|
|
))
|
|
}
|
|
|
|
// --- Back ------------------------------------------------------------
|
|
// The grip bar across the hole, and two battens holding the planks. Cheap
|
|
// and invisible from the front, but a shield seen from behind with
|
|
// nothing to hold is worse than one with no back at all.
|
|
const back: BufferGeometry[] = []
|
|
back.push(boxGeometry(
|
|
[R * 1.2, R * 0.09, T * 1.6],
|
|
[0, 0, -T * 1.1],
|
|
new Color(tint('oak', -0.1)),
|
|
))
|
|
for (const at of [-0.46, 0.46]) {
|
|
back.push(boxGeometry(
|
|
[R * 1.5, R * 0.07, T * 1.2],
|
|
[0, at * R, -T * 0.9],
|
|
new Color(tint('oak', -0.14)),
|
|
))
|
|
}
|
|
|
|
const face = mergeColoured([...boards, ...trim, ...back])
|
|
const boss = mergeColoured(iron)
|
|
|
|
// Leaned back from UPRIGHT, not tipped up from flat.
|
|
//
|
|
// Everything is authored in the XY plane facing +Z, which is already a
|
|
// shield standing on its edge. Rotating by -90 degrees and then adding
|
|
// the lean laid it face-up on the floor with a slight tilt, which is a
|
|
// shield somebody dropped. The lean is the whole rotation.
|
|
for (const g of [face, rim, boss]) g.rotateX(config.lean)
|
|
// Set down by measuring, not by trigonometry: the boss stands proud of
|
|
// the face and the rim wraps past it, so which piece is lowest depends on
|
|
// the lean and is not worth deriving.
|
|
const all = mergeColoured([face, rim, boss])
|
|
all.computeBoundingBox()
|
|
const drop = all.boundingBox?.min.y ?? 0
|
|
for (const g of [face, rim, boss]) g.translate(0, -drop, 0)
|
|
|
|
return {
|
|
boards: { slot: 'oak' as const, geometry: face },
|
|
rim: { slot: 'leather' as const, geometry: rim },
|
|
boss: { slot: 'iron' as const, geometry: boss },
|
|
}
|
|
},
|
|
}, overrides)
|
|
}
|