medieval-kit/my-registry/models/core/occlusion.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

176 lines
6.9 KiB
TypeScript

import type { BufferGeometry } from 'three'
/**
* Ambient occlusion baked into vertex colours.
*
* Why this and not a texture: a bitmap texture would bring three things — UV
* coordinates (our geometry has none), image files the registry would have to
* carry, and a change to the kit's identity. Lowpoly + flat shading + vertex
* colour is a coherent style; a half-hearted texture breaks it.
*
* Instead we derive the darkening from the surface's OWN shape: the more
* neighbouring surfaces surround a point, the less sky it sees. The result is
* darkening in cavities and at contact points — between the boards, under the
* hoop, where the logs touch. The models suddenly look "used" and it costs
* zero memory, zero textures.
*
* Method: a grid is built from the triangle centroids, then for each vertex
* the neighbour density within its own normal hemisphere is measured. No ray
* tracing — at this scale (hundreds of triangles) it would be needlessly
* expensive and the difference is negligible.
*/
export interface OcclusionOptions {
/** Radius searched for neighbours. Derived from the model's size if omitted. */
readonly radius?: number
/** How much the darkest point is darkened. 0 = off. */
readonly strength?: number
/** Neighbour weight where saturation is reached. Raising it softens the darkening. */
readonly saturation?: number
}
interface Sample {
readonly x: number
readonly y: number
readonly z: number
readonly area: number
}
/** Simple spatial grid: for quickly finding the samples within a radius. */
class Grid {
readonly #cells = new Map<string, Sample[]>()
readonly #size: number
constructor(samples: readonly Sample[], cellSize: number) {
this.#size = cellSize
for (const sample of samples) {
const key = this.#key(sample.x, sample.y, sample.z)
const bucket = this.#cells.get(key)
if (bucket) bucket.push(sample)
else this.#cells.set(key, [sample])
}
}
#key(x: number, y: number, z: number): string {
return `${Math.floor(x / this.#size)},${Math.floor(y / this.#size)},${Math.floor(z / this.#size)}`
}
/** The samples of the 27 cells surrounding the given point. */
near(x: number, y: number, z: number): Sample[] {
const cx = Math.floor(x / this.#size)
const cy = Math.floor(y / this.#size)
const cz = Math.floor(z / this.#size)
const found: Sample[] = []
for (let i = -1; i <= 1; i += 1) {
for (let j = -1; j <= 1; j += 1) {
for (let k = -1; k <= 1; k += 1) {
const bucket = this.#cells.get(`${cx + i},${cy + j},${cz + k}`)
if (bucket) found.push(...bucket)
}
}
}
return found
}
}
/**
* Computes the occlusion of the given geometries TOGETHER and writes it into
* their colours.
*
* They all have to be evaluated as one: the place where a board darkens is the
* surface of the neighbouring post, not its own surface. Processing them one
* by one would miss the contact points entirely.
*/
export function bakeOcclusion(
geometries: readonly BufferGeometry[],
options: OcclusionOptions = {},
): void {
const strength = options.strength ?? 0.42
if (strength <= 0 || geometries.length === 0) return
// --- 1. Samples: each triangle's centroid, weighted by its area ---
const samples: Sample[] = []
let minX = Infinity, minY = Infinity, minZ = Infinity
let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity
for (const geometry of geometries) {
const position = geometry.getAttribute('position')
if (!position) continue
const index = geometry.getIndex()
const count = index ? index.count : position.count
for (let i = 0; i < count; i += 3) {
const at = (k: number): [number, number, number] => {
const v = index ? index.getX(i + k) : i + k
return [position.getX(v), position.getY(v), position.getZ(v)]
}
const [ax, ay, az] = at(0)
const [bx, by, bz] = at(1)
const [cx, cy, cz] = at(2)
const ux = bx - ax, uy = by - ay, uz = bz - az
const vx = cx - ax, vy = cy - ay, vz = cz - az
const nx = uy * vz - uz * vy
const ny = uz * vx - ux * vz
const nz = ux * vy - uy * vx
const area = Math.hypot(nx, ny, nz) / 2
if (area <= 0) continue
const x = (ax + bx + cx) / 3
const y = (ay + by + cy) / 3
const z = (az + bz + cz) / 3
samples.push({ x, y, z, area })
minX = Math.min(minX, x); maxX = Math.max(maxX, x)
minY = Math.min(minY, y); maxY = Math.max(maxY, y)
minZ = Math.min(minZ, z); maxZ = Math.max(maxZ, z)
}
}
if (samples.length === 0) return
const extent = Math.max(maxX - minX, maxY - minY, maxZ - minZ, 1e-6)
// The radius is derived from the scale but has an ABSOLUTE ceiling. Without
// the ceiling a 4.89 m fence gets a 0.69 m radius and the definition of
// "neighbour" loses its meaning: everything is everything's neighbour, and
// instead of showing the contact points the occlusion smears a flat
// darkening over the whole model.
//
// The physical meaning says the same thing: what shades a surface is what is
// NEAR it. A post half a metre away is not making the shadow between the
// boards.
const radius = options.radius ?? Math.min(0.16, Math.max(0.015, extent * 0.14))
const grid = new Grid(samples, radius)
// Saturation has to be proportional to the model's scale: as the radius
// grows so does the area it covers, and a fixed threshold would turn small
// models pitch black.
const saturation = options.saturation ?? radius * radius * 1.9
// --- 2. Neighbour density for each vertex ---
for (const geometry of geometries) {
const position = geometry.getAttribute('position')
const colour = geometry.getAttribute('color')
if (!position || !colour) continue
if (!geometry.getAttribute('normal')) geometry.computeVertexNormals()
const normal = geometry.getAttribute('normal')!
for (let i = 0; i < position.count; i += 1) {
const px = position.getX(i), py = position.getY(i), pz = position.getZ(i)
const nx = normal.getX(i), ny = normal.getY(i), nz = normal.getZ(i)
let weight = 0
for (const sample of grid.near(px, py, pz)) {
const dx = sample.x - px, dy = sample.y - py, dz = sample.z - pz
const distance = Math.hypot(dx, dy, dz)
if (distance < 1e-6 || distance > radius) continue
// Only neighbours in the hemisphere the vertex FACES occlude it.
const facing = (dx * nx + dy * ny + dz * nz) / distance
if (facing <= 0) continue
// The effect fades with distance; a soft falloff instead of the
// inverse-square law, because the goal is not physical accuracy but a
// readable cavity shadow.
weight += sample.area * facing * (1 - distance / radius)
}
const occlusion = Math.min(1, weight / saturation)
const factor = 1 - strength * occlusion
colour.setXYZ(i, colour.getX(i) * factor, colour.getY(i) * factor, colour.getZ(i) * factor)
}
colour.needsUpdate = true
}
}