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.
277 lines
12 KiB
TypeScript
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)
|
|
}
|