|
10 | 10 | """ |
11 | 11 |
|
12 | 12 | import math |
| 13 | +import struct |
13 | 14 | import sys |
14 | 15 | import time |
15 | 16 | from typing import Dict, List, Optional |
16 | 17 |
|
| 18 | + |
| 19 | +# ── Color → texture mapping ────────────────────────────────────────────────── |
| 20 | +# Maps hex RGB colours found on invisible props to the nearest turnt texture. |
| 21 | + |
| 22 | +_COLOR_PALETTE = [ |
| 23 | + # (R, G, B, texture_name) |
| 24 | + (0, 0, 0, "turnt/temp_dark"), |
| 25 | + (255, 255, 255, "turnt/temp_light"), |
| 26 | + (255, 0, 0, "turnt/temp_red"), |
| 27 | + (0, 128, 0, "turnt/temp_green"), |
| 28 | + (0, 0, 255, "turnt/temp_blue"), |
| 29 | + (255, 165, 0, "turnt/temp_orange"), |
| 30 | + (128, 0, 128, "turnt/temp_purple"), |
| 31 | + (255, 255, 0, "turnt/temp_yellow"), |
| 32 | + (0, 255, 255, "turnt/turnt_cyan"), |
| 33 | + (255, 0, 255, "turnt/turnt_magenta"), |
| 34 | + (0, 255, 128, "turnt/turnt_mint"), |
| 35 | + (128, 255, 0, "turnt/turnt_lime"), |
| 36 | + (255, 215, 0, "turnt/turnt_gold"), |
| 37 | + (0, 128, 128, "turnt/turnt_teal"), |
| 38 | + (128, 0, 255, "turnt/turnt_violet"), |
| 39 | + (255, 128, 128, "turnt/turnt_coral"), |
| 40 | + (135, 206, 235, "turnt/turnt_sky"), |
| 41 | +] |
| 42 | + |
| 43 | +# Material name → texture fallback |
| 44 | +_MATERIAL_MAP: Dict[str, str] = { |
| 45 | + "concrete": "turnt/turnt_concrete", |
| 46 | + "asphalt": "turnt/turnt_asphalt", |
| 47 | + "tech": "turnt/turnt_tech", |
| 48 | + "slick": "common/slick", |
| 49 | + "boost": "turnt/turnt_boost", |
| 50 | + "speed": "turnt/turnt_speed", |
| 51 | + "hazard": "turnt/turnt_hazard", |
| 52 | + "checkpoint": "turnt/turnt_checkpoint", |
| 53 | + "platform": "turnt/turnt_platform", |
| 54 | +} |
| 55 | + |
| 56 | + |
| 57 | +def _parse_hex_color(s: str) -> Optional[tuple]: |
| 58 | + """Parse hex color string (AARRGGBB, RRGGBB, #RRGGBB) → (R, G, B) or None.""" |
| 59 | + s = s.strip().lstrip("#") |
| 60 | + if not s: |
| 61 | + return None |
| 62 | + try: |
| 63 | + if len(s) == 8: # AARRGGBB |
| 64 | + r, g, b = int(s[2:4], 16), int(s[4:6], 16), int(s[6:8], 16) |
| 65 | + elif len(s) == 6: # RRGGBB |
| 66 | + r, g, b = int(s[0:2], 16), int(s[2:4], 16), int(s[4:6], 16) |
| 67 | + else: |
| 68 | + return None |
| 69 | + return (r, g, b) |
| 70 | + except ValueError: |
| 71 | + return None |
| 72 | + |
| 73 | + |
| 74 | +def _nearest_texture(r: int, g: int, b: int) -> str: |
| 75 | + """Find the turnt texture with the closest RGB distance.""" |
| 76 | + best_dist = float("inf") |
| 77 | + best_tex = "turnt/temp_dark" |
| 78 | + for pr, pg, pb, tex in _COLOR_PALETTE: |
| 79 | + d = (r - pr) ** 2 + (g - pg) ** 2 + (b - pb) ** 2 |
| 80 | + if d < best_dist: |
| 81 | + best_dist = d |
| 82 | + best_tex = tex |
| 83 | + return best_tex |
| 84 | + |
| 85 | + |
| 86 | +def _resolve_prop_texture(props: dict, fallback: str) -> str: |
| 87 | + """Pick a texture for an invisible prop based on color/material properties.""" |
| 88 | + # Material takes priority (exact name match) |
| 89 | + mat = props.get("material", "").lower() |
| 90 | + if mat: |
| 91 | + for key, tex in _MATERIAL_MAP.items(): |
| 92 | + if key in mat: |
| 93 | + return tex |
| 94 | + |
| 95 | + # Color fallback — parse hex and find nearest palette entry |
| 96 | + color_str = props.get("color", "") |
| 97 | + rgb = _parse_hex_color(color_str) |
| 98 | + if rgb: |
| 99 | + return _nearest_texture(*rgb) |
| 100 | + |
| 101 | + return fallback |
| 102 | + |
17 | 103 | # Allow running this file directly (e.g. for quick testing) |
18 | 104 | try: |
19 | 105 | from .constants import ALL_TEXTURES, NODRAW_TEX |
@@ -440,17 +526,17 @@ def rbe_entities_to_map(entities, sx, sy, sz, opaque_tex="turnt/turnt_concrete") |
440 | 526 | clip_v = props.get("clip", "fullclip").lower() |
441 | 527 |
|
442 | 528 | if no_show: |
443 | | - prop_tex = "common/clip" |
| 529 | + prop_tex = _resolve_prop_texture(props, "common/nodraw") |
444 | 530 | elif slide_v == "1" or slide_v == "2": |
445 | 531 | prop_tex = "common/slick" |
446 | 532 | elif clip_v == "noclip": |
447 | 533 | prop_tex = NODRAW_TEX |
448 | 534 | elif clip_v == "playerclip": |
449 | | - prop_tex = "common/clip" |
| 535 | + prop_tex = _resolve_prop_texture(props, "common/nodraw") |
450 | 536 | elif clip_v == "weapon": |
451 | 537 | prop_tex = "common/weapclip" |
452 | 538 | else: |
453 | | - prop_tex = opaque_tex # fullclip + visible → solid |
| 539 | + prop_tex = _resolve_prop_texture(props, opaque_tex) |
454 | 540 |
|
455 | 541 | # ── Size: base differs per shape ───────────────────────── |
456 | 542 | # All coords in DBT world-units (1 block = 40 X/Z, 20 Y). |
@@ -480,18 +566,24 @@ def rbe_entities_to_map(entities, sx, sy, sz, opaque_tex="turnt/turnt_concrete") |
480 | 566 | zrot = e.get("zrot", 0.0) |
481 | 567 |
|
482 | 568 | def _rot3d(lx, ly, lz): |
483 | | - """Rotate local offset (lx, ly, lz) by xrot/yrot/zrot.""" |
484 | | - # Yaw (around Q-Z axis = DBT Y-up) |
485 | | - cy_, sy_ = math.cos(yrot), math.sin(yrot) |
486 | | - x1 = cy_ * lx + sy_ * ly |
487 | | - y1 = -sy_ * lx + cy_ * ly |
488 | | - z1 = lz |
489 | | - # Pitch (around Q-X axis = DBT X) |
| 569 | + """Rotate local offset (lx, ly, lz) by xrot/yrot/zrot. |
| 570 | +
|
| 571 | + The Y↔Z axis swap (DBT→Q3) has determinant -1, so all |
| 572 | + rotation angles are effectively negated. Using CW |
| 573 | + matrices with the original angles achieves this. |
| 574 | + Order: Pitch → Yaw → Roll. |
| 575 | + """ |
| 576 | + # Pitch — around Q-X, angle = -xrot |
490 | 577 | cx_, sx_ = math.cos(xrot), math.sin(xrot) |
491 | | - x2 = x1 |
492 | | - y2 = cx_ * y1 - sx_ * z1 |
493 | | - z2 = sx_ * y1 + cx_ * z1 |
494 | | - # Roll (around Q-Y axis = DBT Z) |
| 578 | + x1 = lx |
| 579 | + y1 = cx_ * ly + sx_ * lz |
| 580 | + z1 = -sx_ * ly + cx_ * lz |
| 581 | + # Yaw — around Q-Z, angle = -yrot |
| 582 | + cy_, sy_ = math.cos(yrot), math.sin(yrot) |
| 583 | + x2 = cy_ * x1 + sy_ * y1 |
| 584 | + y2 = -sy_ * x1 + cy_ * y1 |
| 585 | + z2 = z1 |
| 586 | + # Roll — around Q-Y, angle = -zrot |
495 | 587 | cz_, sz_ = math.cos(zrot), math.sin(zrot) |
496 | 588 | x3 = cz_ * x2 - sz_ * z2 |
497 | 589 | y3 = y2 |
@@ -571,14 +663,15 @@ def _rotated_box(ox, oy, oz, hx, hy, hz, tex, label): |
571 | 663 |
|
572 | 664 | # ── cylinder ───────────────────────────────────────────── |
573 | 665 | elif prop_shape == "cylinder": |
574 | | - cyl_ang = e.get("yrot", 0.0) |
575 | 666 | outer_r = max(MIN_HALF, abs(e.get("xscale", 1.0)) * sx * 4) |
576 | | - wall_t = sx / 2.0 |
577 | | - inner_r = outer_r - wall_t |
| 667 | + wall_t = max(4.0, sx / 4.0) |
| 668 | + inner_r = max(MIN_HALF, outer_r - wall_t) |
578 | 669 | fz_cyl = max(MIN_HALF, abs(e.get("yscale", 1.0)) * 100 * EFZ) |
579 | 670 | z_bot = qz - fz_cyl / 2 |
580 | 671 | z_top = qz + fz_cyl / 2 |
581 | 672 | arc_step = float(sx) |
| 673 | + # Quarter circle (π/2) starting at entity yaw |
| 674 | + cyl_ang = e.get("yrot", 0.0) |
582 | 675 | cyl_strs = cylinder_brushes( |
583 | 676 | qx, qy, z_bot, z_top, |
584 | 677 | inner_r, outer_r, |
@@ -672,7 +765,7 @@ def _log(msg, level="plain"): |
672 | 765 | CLIP_TEX = { |
673 | 766 | 2: "common/caulk", |
674 | 767 | 4: "common/weapclip", |
675 | | - 5: "common/clip", |
| 768 | + 5: "common/nodraw", |
676 | 769 | } |
677 | 770 | solid_blocks = [b for b in blocks if b["type"] == 1] |
678 | 771 | corner_blocks = [b for b in blocks if b["type"] == 3] |
|
0 commit comments