medieval-kit/my-registry/models/wooden-fence/model.ts
CRT 50c7fc60b9 Translate the whole repository to English
The kit is going public, so everything a reader sees is now English:
27 model sources, the core vocabulary, meta.ts (titles, descriptions and
every slider label), the viewer UI, all script output, REFERENCE.md and
the READMEs.

Rules applied: only comments, doc comments and human-facing strings were
touched. Identifiers, numbers, logic, registry item ids, part names and
material slot names are unchanged — those are a published contract and
were already English.

Verified rather than assumed: for all 53 changed .ts files the code
skeleton (source with comments stripped and string contents replaced by a
placeholder) is byte-identical before and after. Full gate is green —
typecheck, registry conformance, ~510 model checks, GLB round trip and a
production build.

Also fixed here:
- index.html and viewer.html were missed by the translation pass because
  the file list only covered .ts/.md/.css. Both had lang="tr" and Turkish
  titles.
- The viewer formatted triangle counts with toLocaleString('tr-TR'), which
  prints 15.425 rather than 15,425.
- REFERENCE.md's model table header did not match what
  scripts/catalog-table.ts emits, so the next regeneration would have
  silently changed it. Aligned to the generator.
- Removed scripts/probe.ts and scripts/zdiag.ts; they were throwaway
  diagnostics that got committed by accident.
2026-08-21 14:35:13 +07:00

277 lines
12 KiB
TypeScript

/**
* @medieval-kit/wooden-fence
*
* Mortised riven fence: riven heavy posts, rails passing THROUGH the post.
*
* SECOND attempt, and the reason can be said in one word: JOINERY. The first
* version was four square sticks with two thin battens laid across their
* front; at no point was it visible how two pieces held on to each other, so
* the whole subject of the object was missing. In the render it read not as a
* fence but as "the technical drawing of a fence" — 4.89 × 1.10 × 0.09 m,
* i.e. a depth-to-length ratio of 54:1, cardboard.
*
* In a real riven post-and-rail fence a RECTANGULAR HOLE is cut through the
* post and the rail passes through that hole. The entire model was rebuilt
* around this single fact:
*
* - The post is no longer a single box: two CHEEKS with BRIDGE blocks
* between them. The hole therefore exists geometrically, it is not a
* painted notch. `bakeOcclusion` darkens its mouth on its own too.
* - The rail passes through the hole and PROTRUDES from the far face at the
* two ends of the fence. The tenon tongue is the only horizontal
* protrusion that enters the silhouette, and it answers the question
* "how is this standing up" all by itself.
* - The rail is narrower than the hole: a few millimetres of gap remain on
* each side, so the hole does not close up. That gap is what shows the
* hole as a hole.
*
* And the spacing: the old rail distribution was `0.28 + 0.5·r/(count−1)`,
* i.e. no matter how many rails there were it ALWAYS filled the 0.28–0.78
* band. Filling the top and the bottom was structurally impossible; that is
* why the upper edge of the silhouette was empty.
*/
import { Color } from 'three'
import type { BufferGeometry } from 'three'
import {
chamferedBoxGeometry,
createKitModel,
jitter,
MEDIEVAL_PALETTE,
mergeColoured,
taperedBoxGeometry,
} from '../core/index.ts'
export interface WoodenFenceConfig {
/** Number of sections. Each section is the span between two posts. */
readonly sections: number
/** Length of one section (metres). 2–3 m, because a rail is riven from one log. */
readonly sectionLength: number
readonly height: number
/** Number of horizontal rails. */
readonly railCount: number
/** Curvature and height deviation of the posts. 0 = factory straightness. */
readonly rough: number
/** Whether a brace is placed at one end (0/1). */
readonly brace: number
readonly seed: number
}
export const woodenFenceDefaults: WoodenFenceConfig = {
sections: 2,
sectionLength: 2.4,
height: 1.25,
railCount: 3,
rough: 1,
brace: 1,
seed: 12,
}
export type WoodenFenceParts = 'posts' | 'rails'
export function createModel(overrides: Partial<WoodenFenceConfig> = {}) {
return createKitModel<WoodenFenceConfig, 'oak', WoodenFenceParts>({
id: 'wooden-fence',
// The mottle cell is given by hand: the fence is 4.8 m long, and when the
// automatic derivation takes it from the model's scale a single post falls
// into a single cell and the texture system does nothing. The grain mottle
// of wood is a few centimetres regardless of the object's size.
mottle: { cell: 0.05 },
defaults: woodenFenceDefaults,
slots: ['oak'],
build: ({ config, random }) => {
const tint = new Color()
const shade = (lift = 0): Color => {
tint.copy(MEDIEVAL_PALETTE.oak)
tint.offsetHSL(jitter(random, 0.012), jitter(random, 0.05), lift + jitter(random, 0.06))
return tint
}
/** End grain: riven surface and cut ends. The fence never used this. */
const endGrain = (lift = 0): Color => {
tint.copy(MEDIEVAL_PALETTE.oakEnd)
tint.offsetHSL(jitter(random, 0.01), jitter(random, 0.04), lift + jitter(random, 0.05))
return tint
}
const soil = (): Color => {
tint.copy(MEDIEVAL_PALETTE.oak)
tint.offsetHSL(jitter(random, 0.01), -0.3 + jitter(random, 0.04), -0.19 + jitter(random, 0.04))
return tint
}
const sections = Math.max(1, Math.round(config.sections))
const count = Math.max(1, Math.round(config.railCount))
const total = sections * config.sectionLength
const half = config.height / 2
const rough = Math.max(0, config.rough)
// --- Dimensions, all derived from the height --------------------------
const postW = config.height * 0.12 // post width along the fence
const mortise = config.height * 0.062 // Z opening of the hole
const cheek = config.height * 0.03 // material on each side of the hole
const postD = mortise + cheek * 2 // total depth of the post
const railH = config.height * 0.098 // vertical height of the rail
const railD = config.height * 0.053 // rail depth — NARROWER than the hole
const tenon = config.height * 0.088 // overhang at the ends
// Rail heights. Exponent 1.12: the gaps tighten towards the bottom,
// because the animal trying to get under it is the small one.
const railT = Array.from({ length: count }, (_, r) =>
count === 1 ? 0.55 : 0.19 + 0.71 * Math.pow(r / (count - 1), 1.12))
// --- Posts --------------------------------------------------------------
const postPieces: BufferGeometry[] = []
const slotHalf = railH / 2 + config.height * 0.008
for (let i = 0; i <= sections; i += 1) {
const x = -total / 2 + i * config.sectionLength
const postH = config.height + jitter(random, 0.075 * rough)
const pieces: BufferGeometry[] = []
// Two cheeks: the walls of the hole. Full height, base to top.
for (const side of [-1, 1]) {
pieces.push(chamferedBoxGeometry(
[postW, cheek],
[postW * 0.81, cheek * 0.94],
postH,
cheek * 0.2,
[0, postH / 2, side * (mortise + cheek) / 2],
shade(-0.11),
shade(0.02),
))
}
// Bridges: blocks that fill the space BETWEEN the slots. The hole is
// exactly the gap they leave. Their cross-sections stay INSIDE the
// cheeks (their ±Z faces are buried in the cheek solid), so no pair
// of faces is coplanar.
//
// The bridges do not run all the way to the two ENDS of the post:
// their ends sat on the same plane as the cheek ends and z-fought.
// The insets are not visible — they stay inside the soil mound below
// and inside the cap above.
const inset = cheek * 0.3
const bounds = [0, ...railT.flatMap((t) => [t * postH - slotHalf, t * postH + slotHalf]), postH]
for (let k = 0; k + 1 < bounds.length; k += 2) {
const lo = Math.max(inset, bounds[k]!)
const hi = Math.min(postH - inset, bounds[k + 1]!)
if (hi - lo < 1e-4) continue
const taper = 1 - 0.19 * (lo / postH)
pieces.push(taperedBoxGeometry(
[postW * taper * 0.96, mortise + cheek * 1.1],
[postW * (taper - 0.03) * 0.96, mortise + cheek * 1.1],
hi - lo,
[0, (lo + hi) / 2, 0],
shade(-0.07),
))
}
// Cap: an axe-hewn ridge that sheds water. Its base sits INSIDE the body
// and its section is LARGER than the body's section at that height — the
// same pattern as `toolSocket`; that is why no coplanar face pair forms.
pieces.push(taperedBoxGeometry(
[postW * 0.88, postD * 0.98],
[postW * 0.74, postD * 0.13],
config.height * 0.1,
[0, postH - config.height * 0.016, 0],
endGrain(-0.03),
endGrain(0.07),
))
// Build → ROTATE → translate. The old code passed the centre straight
// into the geometry call, so rotating was impossible; that is why they
// lined up like a grid. Rotations are kept small: 0.045 rad means 7 mm
// of lateral drift along the hole, and the hole slack is 8 mm.
const post = mergeColoured(pieces)
post.rotateY(jitter(random, 0.045 * rough))
post.rotateZ(jitter(random, 0.03 * rough))
post.rotateX(jitter(random, 0.018 * rough))
const sink = config.height * (0.012 + Math.abs(jitter(random, 0.012)))
post.translate(x, -half - sink, 0)
postPieces.push(post)
// Soil mound. DOES NOT ROTATE: the post's lean would lift it off the ground.
postPieces.push(taperedBoxGeometry(
[postW * 2.2, postD * 2],
[postW * 1.25, postD * 1.15],
config.height * 0.11,
[x, -half + config.height * 0.018, 0],
soil(),
))
}
// --- Rails ----------------------------------------------------------------
const railPieces: BufferGeometry[] = []
for (let r = 0; r < count; r += 1) {
const y = -half + railT[r]! * config.height + jitter(random, config.height * 0.005)
for (let i = 0; i < sections; i += 1) {
const xc = -total / 2 + (i + 0.5) * config.sectionLength
// The body enters the holes of the two neighbouring posts and meets
// the body of the adjacent bay end to end in there.
const body = chamferedBoxGeometry(
[config.sectionLength + postW * 0.55, railD],
[config.sectionLength + postW * 0.55, railD * 0.88],
railH,
railH * 0.09,
[xc, y, jitter(random, railD * 0.06)],
shade(0.05),
shade(0.1),
)
railPieces.push(body)
}
// Tenon tongue: ONLY at the two ends. Protruding from the far face, this
// piece is the only horizontal projection entering the silhouette; it
// tells on its own that the rail runs through the post. No overhang at
// the intermediate posts, because there two bodies meet inside the hole.
for (const side of [-1, 1]) {
const px = side * total / 2
railPieces.push(taperedBoxGeometry(
[tenon * 2, railD * 0.9],
[tenon * 1.7, railD * 0.76],
railH * 0.82,
[px + side * tenon * 0.72, y, 0],
shade(0.02),
endGrain(0.05),
))
}
}
// --- Brace -----------------------------------------------------------------
// The model's only off-axis line. It props the end post towards the field.
if (config.brace >= 0.5) {
const rise = config.height * 0.72
const run = config.sectionLength * 0.3
const length = Math.hypot(run, rise)
const atStart = random() < 0.5
const brace = chamferedBoxGeometry(
[postW * 0.72, postD * 0.36],
[postW * 0.56, postD * 0.3],
length,
postW * 0.06,
[0, 0, 0],
shade(-0.05),
endGrain(0.02),
)
// The sign looks INVERTED but this is the correct one: the TOP of the
// brace leans against the post, its FOOT stands on the field. Flipped,
// what came out was a stick with its foot at the base of the post and
// its top in the air — a brace that supports nothing.
const angle = Math.atan2(run, rise)
brace.rotateZ(atStart ? angle : -angle)
brace.translate(
(atStart ? -1 : 1) * (total / 2 - run / 2),
-half + rise / 2 + config.height * 0.02,
// The post's back face is SLOPED (tapered), the brace's is upright —
// they never become coplanar at any height.
-postD * 0.62,
)
postPieces.push(brace)
}
return {
posts: { slot: 'oak', geometry: mergeColoured(postPieces) },
rails: { slot: 'oak', geometry: mergeColoured(railPieces) },
}
},
}, overrides)
}