nx_silhouette_quality.nx source
↩ module page · 382 lines · 14378 B
1// nx_silhouette_quality.nx -- horizon/skyline quality grader.
2//
3// Per the honest audit 2026-05-16: heightmap statistical graders
4// (nx_world_quality_grader) can return S-class for terrain that
5// nonetheless produces a FLAT-LOOKING HORIZON. This primitive
6// grades the SILHOUETTE -- what you see when you look toward the
7// horizon -- on 5 axes designed to catch "high-stats, low-vista"
8// worlds.
9//
10// Method: from a camera viewpoint, sample N azimuth rays (default
11// 64) around the horizon. For each ray, march outward across the
12// heightmap and find the maximum ANGULAR ELEVATION (apparent height
13// above the camera, projected to a small-angle proxy:
14// elev_proxy = (max_h - cam_y) / dist
15// scaled to Q14). This produces a silhouette of N samples.
16//
17// AXES (Q14 [0, Q]; higher = better skyline):
18//
19// 0. ELEVATION_RANGE: max - min angular elevation across silhouette.
20// Worlds with all peaks at the same apparent height = 0.
21// 1. PEAK_COUNT: local-maxima count; target 3-8 distinct peaks per
22// half-horizon. Catches uniform-tooth-saw silhouettes.
23// 2. SMOOTHNESS: penalty for too-jagged AND too-smooth. Mean abs
24// second-difference compared to target band.
25// 3. ASPECT_DIVERSITY: stddev of peak-to-peak widths. Catches
26// repetitive tooth patterns.
27// 4. SKY_BALANCE: fraction of view that is sky (above horizon proxy
28// threshold); target 0.3 - 0.7. Catches "all mountains" and "all
29// flatland".
30//
31// EMITS LAYER_VERDICT (kind = NX_LAYER_KIND_READABILITY,
32// REFINE_HINT = REFINE_MORE_FEATURES).
33//
34// genealogy_id: appleton_1996_landscape_aesthetics +
35// horizon_silhouette_canon
36// lineage_id: nx_silhouette_quality_5axis_v1
37
38// nx_safety_envelope:
39// intended_use: AUTO_APPLIED -- primitive-specific tuning queued
40// sil_target: SIL1
41// evidence: [bulk_applied_2026-05-16, see-file-comment-for-detail]
42// verdict: NOT_YET_EVALUATED
43
44import "nx_syscalls.nx"
45import "nx_tier.nx"
46import "nx_camera_q14.nx"
47import "nx_layer_verdict.nx"
48const NX_MAGIC_36000: i64 = 36000
49
50const NX_SQ_Q: nx_int = 16384
51
52// ===== Axis indices in LAYER_VERDICT ===============================
53const NX_SQ_AXIS_RANGE: nx_int = 0
54const NX_SQ_AXIS_PEAK_COUNT: nx_int = 1
55const NX_SQ_AXIS_SMOOTHNESS: nx_int = 2
56const NX_SQ_AXIS_ASPECT_DIV: nx_int = 3
57const NX_SQ_AXIS_SKY_BALANCE: nx_int = 4
58
59const NX_SQ_AXIS_COUNT: nx_int = 5
60
61// ===== Internal: silhouette computation ============================
62// For each of n_rays azimuths, march along the heightmap up to
63// max_dist (in heightmap cells). Record the max angular elevation
64// proxy = (h - cam_y) * 100 / dist (scaled to fit in Q14 range when
65// dist >> 0).
66//
67// Outputs: silhouette[i] in Q14. Stored in caller-provided buffer.
68//
69// Camera position is (cam_x, cam_y, cam_z) in heightmap cell coords
70// (NOT world Q14). Heightmap is row-major w x h.
71func _sq_build_silhouette(
72 heightmap: *i64, w: nx_int, h: nx_int,
73 cam_x: nx_int, cam_y_q14_m: nx_int, cam_z: nx_int,
74 max_dist: nx_int,
75 silhouette: *i64, n_rays: nx_int
76) {
77 if n_rays <= 0 { return }
78 let q: nx_int = NX_SQ_Q
79 var i: nx_int = 0
80 while i < n_rays {
81 let azimuth_cd: nx_int = (i * NX_MAGIC_36000) / n_rays
82 let cos_a: nx_int = nx_camera_cos_q14_centideg(azimuth_cd)
83 let sin_a: nx_int = nx_camera_sin_q14_centideg(azimuth_cd)
84 var step: nx_int = 1
85 var max_elev: nx_int = 0 - q // very negative initial
86 while step < max_dist {
87 let sx: nx_int = cam_x + (step * cos_a) / q
88 let sz: nx_int = cam_z + (step * sin_a) / q
89 if sx >= 0 {
90 if sx < w {
91 if sz >= 0 {
92 if sz < h {
93 let h_at: nx_int = heightmap[sz * w + sx]
94 // elev_proxy = (h - cam_y) * 100 / dist
95 // h_at already in metres; cam_y_q14_m is Q14 metres.
96 // Convert h_at to Q14 metres (assume input is plain metres):
97 let h_q14_m: nx_int = h_at * q
98 let diff: nx_int = h_q14_m - cam_y_q14_m
99 let elev: nx_int = (diff * 100) / step
100 if elev > max_elev { max_elev = elev }
101 }
102 }
103 }
104 }
105 step = step + 1
106 }
107 silhouette[i] = max_elev
108 i = i + 1
109 }
110}
111
112// ===== Internal: range across silhouette ===========================
113func _sq_range_q14(
114 silhouette: *i64, n_rays: nx_int, max_relief: nx_int
115) -> nx_int {
116 if n_rays <= 0 { return 0 }
117 if max_relief <= 0 { return 0 }
118 var smin: nx_int = silhouette[0]
119 var smax: nx_int = silhouette[0]
120 var i: nx_int = 0
121 while i < n_rays {
122 let v: nx_int = silhouette[i]
123 if v < smin { smin = v }
124 if v > smax { smax = v }
125 i = i + 1
126 }
127 let q: nx_int = NX_SQ_Q
128 let range: nx_int = smax - smin
129 // Target: range >= max_relief * 50 (a 50-m peak at 1-cell distance
130 // gives elev proxy 5000). Normalised against max_relief * 20.
131 var score: nx_int = (range * q) / (max_relief * 20)
132 if score > q { score = q }
133 if score < 0 { score = 0 }
134 return score
135}
136
137// ===== Internal: local-maxima count ================================
138func _sq_peak_count_q14(silhouette: *i64, n_rays: nx_int) -> nx_int {
139 if n_rays < 3 { return 0 }
140 let q: nx_int = NX_SQ_Q
141 var n_peaks: nx_int = 0
142 var i: nx_int = 1
143 while i < n_rays - 1 {
144 let a: nx_int = silhouette[i - 1]
145 let b: nx_int = silhouette[i]
146 let c: nx_int = silhouette[i + 1]
147 if b > a { if b > c { n_peaks = n_peaks + 1 } }
148 i = i + 1
149 }
150 // Target: 3-8 peaks per half-horizon = 6-16 total.
151 var score: nx_int = 0
152 if n_peaks >= 6 {
153 if n_peaks <= 16 { score = q }
154 if n_peaks > 16 { score = q - (n_peaks - 16) * q / 16 }
155 }
156 if n_peaks < 6 { score = (n_peaks * q) / 6 }
157 if score < 0 { score = 0 }
158 if score > q { score = q }
159 return score
160}
161
162// ===== Internal: smoothness (penalty if jagged or smooth) ==========
163func _sq_smoothness_q14(silhouette: *i64, n_rays: nx_int) -> nx_int {
164 if n_rays < 3 { return 0 }
165 let q: nx_int = NX_SQ_Q
166 var sum_2nd_diff: nx_int = 0
167 var count: nx_int = 0
168 var i: nx_int = 1
169 while i < n_rays - 1 {
170 let d2: nx_int = silhouette[i + 1] - 2 * silhouette[i] + silhouette[i - 1]
171 var ad: nx_int = d2
172 if ad < 0 { ad = 0 - ad }
173 sum_2nd_diff = sum_2nd_diff + ad
174 count = count + 1
175 i = i + 1
176 }
177 if count == 0 { return 0 }
178 let mean_2nd: nx_int = sum_2nd_diff / count
179 // Target: mean_2nd ~= 500 (Q14 elev-proxy units). Higher = jagged;
180 // lower = too smooth. Bell curve around 500.
181 let target: nx_int = 500
182 var dev: nx_int = mean_2nd - target
183 if dev < 0 { dev = 0 - dev }
184 let penalty: nx_int = (dev * q) / 1000
185 var score: nx_int = q - penalty
186 if score < 0 { score = 0 }
187 return score
188}
189
190// ===== Internal: peak-to-peak width diversity =====================
191func _sq_aspect_div_q14(silhouette: *i64, n_rays: nx_int) -> nx_int {
192 if n_rays < 6 { return 0 }
193 let q: nx_int = NX_SQ_Q
194 // Find peak indices.
195 let peak_idx: *i64 = (sys_mmap(n_rays * NX_SIZEOF_NX_INT)) as *i64
196 var n_peaks: nx_int = 0
197 var i: nx_int = 1
198 while i < n_rays - 1 {
199 let a: nx_int = silhouette[i - 1]
200 let b: nx_int = silhouette[i]
201 let c: nx_int = silhouette[i + 1]
202 if b > a {
203 if b > c {
204 peak_idx[n_peaks] = i
205 n_peaks = n_peaks + 1
206 }
207 }
208 i = i + 1
209 }
210 if n_peaks < 3 { return q / 4 } // too few peaks for variance
211 // Compute peak-to-peak widths.
212 var sum_w: nx_int = 0
213 var widths: *i64 = (sys_mmap(n_peaks * NX_SIZEOF_NX_INT)) as *i64
214 var j: nx_int = 0
215 while j + 1 < n_peaks {
216 let dw: nx_int = peak_idx[j + 1] - peak_idx[j]
217 widths[j] = dw
218 sum_w = sum_w + dw
219 j = j + 1
220 }
221 let n_w: nx_int = n_peaks - 1
222 if n_w <= 0 { return 0 }
223 let mean_w: nx_int = sum_w / n_w
224 if mean_w <= 0 { return 0 }
225 var abs_dev_sum: nx_int = 0
226 var k: nx_int = 0
227 while k < n_w {
228 var d: nx_int = widths[k] - mean_w
229 if d < 0 { d = 0 - d }
230 abs_dev_sum = abs_dev_sum + d
231 k = k + 1
232 }
233 let mean_abs_dev: nx_int = abs_dev_sum / n_w
234 // Target: mean_abs_dev / mean_w >= 0.3. Higher = more diverse.
235 var ratio: nx_int = (mean_abs_dev * q) / mean_w
236 if ratio > q { ratio = q }
237 // Map [0, 0.3Q] -> [0, q], saturate above.
238 var score: nx_int = (ratio * q) / (q * 3 / 10)
239 if score > q { score = q }
240 if score < 0 { score = 0 }
241 return score
242}
243
244// ===== Internal: sky balance =======================================
245// Sky fraction = count of samples below mean / total. Target 0.3-0.7.
246func _sq_sky_balance_q14(silhouette: *i64, n_rays: nx_int) -> nx_int {
247 if n_rays <= 0 { return 0 }
248 let q: nx_int = NX_SQ_Q
249 var sum: nx_int = 0
250 var i: nx_int = 0
251 while i < n_rays {
252 sum = sum + silhouette[i]
253 i = i + 1
254 }
255 let mean: nx_int = sum / n_rays
256 var n_below: nx_int = 0
257 var j: nx_int = 0
258 while j < n_rays {
259 if silhouette[j] < mean { n_below = n_below + 1 }
260 j = j + 1
261 }
262 let sky_frac_q: nx_int = (n_below * q) / n_rays
263 // Bell-curve target 0.3-0.7. Score = 1 - 2 * |sky_frac - 0.5|.
264 let q_half: nx_int = q / 2
265 var dev: nx_int = sky_frac_q - q_half
266 if dev < 0 { dev = 0 - dev }
267 var score: nx_int = q - 2 * dev
268 if score < 0 { score = 0 }
269 return score
270}
271
272// ===== Public: silhouette grader ===================================
273// Required inputs: heightmap, w, h, max_relief, viewpoint, max_dist.
274// silhouette scratch buffer needs n_rays i64s.
275// out_verdict: 16-i64 LAYER_VERDICT.
276func nx_silhouette_quality_grade(
277 heightmap: *i64, w: nx_int, h: nx_int, max_relief: nx_int,
278 cam_x: nx_int, cam_y_q14_m: nx_int, cam_z: nx_int,
279 max_dist: nx_int,
280 silhouette_scratch: *i64, n_rays: nx_int,
281 out_verdict: *i64
282) {
283 _sq_build_silhouette(heightmap, w, h, cam_x, cam_y_q14_m, cam_z,
284 max_dist, silhouette_scratch, n_rays)
285
286 let range_score: nx_int = _sq_range_q14(silhouette_scratch, n_rays, max_relief)
287 let peak_score: nx_int = _sq_peak_count_q14(silhouette_scratch, n_rays)
288 let smoothness: nx_int = _sq_smoothness_q14(silhouette_scratch, n_rays)
289 let aspect_div: nx_int = _sq_aspect_div_q14(silhouette_scratch, n_rays)
290 let sky_balance: nx_int = _sq_sky_balance_q14(silhouette_scratch, n_rays)
291
292 nx_layer_verdict_init(out_verdict, NX_LAYER_KIND_READABILITY,
293 NX_SQ_AXIS_COUNT, NX_LAYER_REFINE_MORE_FEATURES)
294 out_verdict[NX_LV_OFF_AXIS_0 + NX_SQ_AXIS_RANGE] = range_score
295 out_verdict[NX_LV_OFF_AXIS_0 + NX_SQ_AXIS_PEAK_COUNT] = peak_score
296 out_verdict[NX_LV_OFF_AXIS_0 + NX_SQ_AXIS_SMOOTHNESS] = smoothness
297 out_verdict[NX_LV_OFF_AXIS_0 + NX_SQ_AXIS_ASPECT_DIV] = aspect_div
298 out_verdict[NX_LV_OFF_AXIS_0 + NX_SQ_AXIS_SKY_BALANCE] = sky_balance
299 nx_layer_verdict_finalize(out_verdict)
300}
301
302// ===== Self-test ====================================================
303func main() -> i64 {
304 let q: nx_int = NX_SQ_Q
305 let verdict: *i64 = (sys_mmap(NX_LV_STRIDE * NX_SIZEOF_NX_INT)) as *i64
306
307 // Build a 32x32 mountain heightmap with multiple peaks for the
308 // silhouette to see.
309 let w: nx_int = 32
310 let h: nx_int = 32
311 let n: nx_int = w * h
312 let map: *i64 = (sys_mmap(n * NX_SIZEOF_NX_INT)) as *i64
313 var i: nx_int = 0
314 while i < n {
315 let x: nx_int = i % w
316 let y: nx_int = i / w
317 // Three peak centres: (8,16), (16, 24), (24, 8).
318 var v: nx_int = 0
319 let dx1: nx_int = x - 8
320 let dy1: nx_int = y - 16
321 let d1_sq: nx_int = dx1 * dx1 + dy1 * dy1
322 if d1_sq < 36 { v = v + (36 - d1_sq) * 20 }
323 let dx2: nx_int = x - 16
324 let dy2: nx_int = y - 24
325 let d2_sq: nx_int = dx2 * dx2 + dy2 * dy2
326 if d2_sq < 36 { v = v + (36 - d2_sq) * 15 }
327 let dx3: nx_int = x - 24
328 let dy3: nx_int = y - 8
329 let d3_sq: nx_int = dx3 * dx3 + dy3 * dy3
330 if d3_sq < 36 { v = v + (36 - d3_sq) * 25 }
331 map[i] = v
332 i = i + 1
333 }
334
335 let sil: *i64 = (sys_mmap(64 * NX_SIZEOF_NX_INT)) as *i64
336
337 // T1: Camera at centre (16, 0, 16) looking outward. Silhouette
338 // should have meaningful range from the 3 peak centres.
339 nx_silhouette_quality_grade(map, w, h, 1000,
340 16, 0, 16, 20,
341 sil, 32, verdict)
342 if verdict[NX_LV_OFF_AXIS_0 + NX_SQ_AXIS_RANGE] <= 0 {
343 return __syscall(93, 1, 0, 0, 0, 0, 0)
344 }
345 // Verdict should have a defined grade.
346 if nx_lv_grade_is_valid(verdict[NX_LV_OFF_GRADE]) != 1 {
347 return __syscall(93, 2, 0, 0, 0, 0, 0)
348 }
349
350 // T2: Flat heightmap = silhouette = zero range.
351 let map_flat: *i64 = (sys_mmap(n * NX_SIZEOF_NX_INT)) as *i64
352 var fi: nx_int = 0
353 while fi < n { map_flat[fi] = 100; fi = fi + 1 }
354 nx_silhouette_quality_grade(map_flat, w, h, 1000,
355 16, 0, 16, 20,
356 sil, 32, verdict)
357 if verdict[NX_LV_OFF_AXIS_0 + NX_SQ_AXIS_RANGE] != 0 {
358 return __syscall(93, 10, 0, 0, 0, 0, 0)
359 }
360 // Flat -> peak count = 0 -> score 0.
361 if verdict[NX_LV_OFF_AXIS_0 + NX_SQ_AXIS_PEAK_COUNT] != 0 {
362 return __syscall(93, 11, 0, 0, 0, 0, 0)
363 }
364 // Grade = F (all axes LOSS).
365 if verdict[NX_LV_OFF_GRADE] > NX_LV_GRADE_D {
366 return __syscall(93, 12, 0, 0, 0, 0, 0)
367 }
368
369 // T3: Mountain heightmap should produce non-zero peak count and
370 // range; grade at least D.
371 nx_silhouette_quality_grade(map, w, h, 1000,
372 16, 0, 16, 20,
373 sil, 32, verdict)
374 if verdict[NX_LV_OFF_AXIS_0 + NX_SQ_AXIS_RANGE] <= 0 {
375 return __syscall(93, 20, 0, 0, 0, 0, 0)
376 }
377 if verdict[NX_LV_OFF_GRADE] < NX_LV_GRADE_F {
378 return __syscall(93, 21, 0, 0, 0, 0, 0)
379 }
380
381 return 0
382}