AndyE7

Untitled

Aug 29th, 2026 (edited)
16
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
text 55.72 KB | None | 0 0
  1. --[[
  2. GeoVoxel 3D
  3. ===========
  4. One-file CC:Tweaked + Advanced Peripherals Geo Scanner 3D viewer.
  5.  
  6. What it does
  7. ------------
  8. * Finds a Geo Scanner (geo_scanner / geoScanner).
  9. * Requests a radius-16 scan: coordinates -16..16 => 33 blocks per axis.
  10. * Stores every non-air block returned by the scanner in a compact voxel map.
  11. * Displays the map on a monitor using a perspective voxel ray-caster.
  12. * Rotate, pitch, zoom, cut the upper Y layers away, rescan, and inspect blocks.
  13. * Optionally overlays live Player Detector positions as a dot + player name.
  14. * Works with monitor touch on Advanced Monitors and keyboard controls on the
  15. computer/turtle running the program.
  16.  
  17. Keyboard
  18. --------
  19. Arrow Left/Right rotate
  20. Arrow Up/Down pitch
  21. + / - zoom in/out
  22. [ / ] lower/raise Y cut plane
  23. R rescan
  24. C reset camera
  25. Q quit
  26.  
  27. Monitor touch
  28. -------------
  29. Bottom buttons perform the same camera actions.
  30. Touch the rendered model to inspect the hit block and relative coordinate.
  31.  
  32. Notes
  33. -----
  34. * The script deliberately does NOT apply its own spherical filter. It renders
  35. everything the installed Geo Scanner implementation returns for radius 16.
  36. * Coordinates are expected to be relative to the scanner. If a scanner version
  37. returns non-relative x/y/z but provides f/r/u, the script falls back to
  38. right/up/front coordinates.
  39. * Lower CUT Y to peel the roof/top layers away and inspect enclosed geometry.
  40. * AP 0.7 Player Detector reports world x/y/z. Pass the Geo Scanner block's
  41. world coordinates as 3dscan X Y Z (or set SCANNER_WORLD_ORIGIN) to align
  42. those positions with the scan model. Newer f/r/u APIs are also supported.
  43. --]]
  44.  
  45. --------------------------------------------------------------------------
  46. -- CONFIGURATION
  47. --------------------------------------------------------------------------
  48.  
  49. -- Optional command-line scanner origin for Player Detector world-coordinate
  50. -- APIs: 3dscan <scannerX> <scannerY> <scannerZ>
  51. local SCRIPT_ARGS = {...}
  52.  
  53. local CFG = {
  54. SCAN_RADIUS = 16, -- radius 16 => -16..16 => 33 cells per axis
  55. MONITOR_TEXT_SCALE = 0.5,
  56.  
  57. -- Camera
  58. FOV_DEGREES = 60,
  59. DEFAULT_YAW_DEG = 45,
  60. DEFAULT_PITCH_DEG = 28,
  61. DEFAULT_DISTANCE = 58,
  62. MIN_DISTANCE = 8,
  63. MAX_DISTANCE = 110,
  64. YAW_STEP_DEG = 12,
  65. PITCH_STEP_DEG = 8,
  66. ZOOM_STEP = 3,
  67.  
  68. -- CC terminal characters are taller than they are wide. This compensates
  69. -- the projection so cubes look closer to cubes on a monitor.
  70. CHARACTER_ASPECT = 0.66,
  71.  
  72. -- Prevent very large monitor walls from doing tens of thousands of raycasts
  73. -- for every camera movement. Larger displays automatically render in coarse
  74. -- 2x/3x ray samples and expand each sample over neighbouring cells.
  75. MAX_RAYS_PER_FRAME = 6500,
  76.  
  77. -- Optional exact peripheral names. Leave nil for auto-discovery.
  78. SCANNER_NAME = nil,
  79. MONITOR_NAME = nil,
  80. PLAYER_DETECTOR_NAME = nil,
  81.  
  82. -- Live Player Detector overlay. Player locations are refreshed independently
  83. -- from the Geo Scanner, so moving players do NOT trigger another block scan.
  84. PLAYER_REFRESH_SECONDS = 0.75,
  85. PLAYER_NAME_LIMIT = 18,
  86.  
  87. -- Advanced Peripherals 0.7 Player Detector returns absolute world x/y/z.
  88. -- Set this to the GEO SCANNER BLOCK'S world block coordinate so those
  89. -- coordinates can be converted into this viewer's -16..16 scanner space.
  90. -- You can either edit this value or run:
  91. -- 3dscan <scannerX> <scannerY> <scannerZ>
  92. -- Example: {x = 123, y = 64, z = -456}
  93. SCANNER_WORLD_ORIGIN = nil,
  94.  
  95. -- Newer Player Detector APIs may provide relative f/r/u. If so, the script
  96. -- can use them without world coordinates when it knows where/facing the
  97. -- detector is relative to the Geo Scanner. Offset is auto-detected from the
  98. -- Geo scan when possible. Facing may still need to be supplied.
  99. -- Valid facing: north, east, south, west.
  100. PLAYER_DETECTOR_OFFSET = nil, -- e.g. {x = 1, y = 0, z = 0}
  101. PLAYER_DETECTOR_FACING = nil,
  102. }
  103.  
  104. -- Command-line coordinates override the config origin when supplied.
  105. if #SCRIPT_ARGS >= 3 then
  106. local ax, ay, az = tonumber(SCRIPT_ARGS[1]), tonumber(SCRIPT_ARGS[2]), tonumber(SCRIPT_ARGS[3])
  107. if ax and ay and az then
  108. CFG.SCANNER_WORLD_ORIGIN = {
  109. x = math.floor(ax), y = math.floor(ay), z = math.floor(az)
  110. }
  111. end
  112. end
  113.  
  114. --------------------------------------------------------------------------
  115. -- CONSTANTS / BASIC HELPERS
  116. --------------------------------------------------------------------------
  117.  
  118. local R = CFG.SCAN_RADIUS
  119. local SIDE = R * 2 + 1
  120. local PLANE = SIDE * SIDE
  121. local EPS = 1e-7
  122. local INF = math.huge
  123. local DEG = math.pi / 180
  124.  
  125. local AIR = {
  126. ["minecraft:air"] = true,
  127. ["minecraft:cave_air"] = true,
  128. ["minecraft:void_air"] = true,
  129. }
  130.  
  131. local function clamp(v, lo, hi)
  132. if v < lo then return lo end
  133. if v > hi then return hi end
  134. return v
  135. end
  136.  
  137. local function integer(v)
  138. return type(v) == "number" and v == v and v % 1 == 0
  139. end
  140.  
  141. local function round(v)
  142. if v >= 0 then return math.floor(v + 0.5) end
  143. return math.ceil(v - 0.5)
  144. end
  145.  
  146. local function trunc(text, width)
  147. text = tostring(text or "")
  148. if width <= 0 then return "" end
  149. if #text <= width then return text end
  150. if width <= 3 then return text:sub(1, width) end
  151. return text:sub(1, width - 3) .. "..."
  152. end
  153.  
  154. local function padRight(text, width)
  155. text = trunc(text, width)
  156. if #text < width then text = text .. string.rep(" ", width - #text) end
  157. return text
  158. end
  159.  
  160. local function voxelIndex(x, y, z)
  161. -- Integer key is much cheaper than storing "x:y:z" strings for ~35k cells.
  162. return (x + R) * PLANE + (y + R) * SIDE + (z + R) + 1
  163. end
  164.  
  165. local function inBounds(x, y, z)
  166. return x >= -R and x <= R and y >= -R and y <= R and z >= -R and z <= R
  167. end
  168.  
  169. local function vec(x, y, z)
  170. return {x = x, y = y, z = z}
  171. end
  172.  
  173. local function vadd(a, b)
  174. return vec(a.x + b.x, a.y + b.y, a.z + b.z)
  175. end
  176.  
  177. local function vsub(a, b)
  178. return vec(a.x - b.x, a.y - b.y, a.z - b.z)
  179. end
  180.  
  181. local function vmul(a, s)
  182. return vec(a.x * s, a.y * s, a.z * s)
  183. end
  184.  
  185. local function vdot(a, b)
  186. return a.x * b.x + a.y * b.y + a.z * b.z
  187. end
  188.  
  189. local function vcross(a, b)
  190. return vec(
  191. a.y * b.z - a.z * b.y,
  192. a.z * b.x - a.x * b.z,
  193. a.x * b.y - a.y * b.x
  194. )
  195. end
  196.  
  197. local function vlength(a)
  198. return math.sqrt(a.x * a.x + a.y * a.y + a.z * a.z)
  199. end
  200.  
  201. local function vnorm(a)
  202. local len = vlength(a)
  203. if len < EPS then return vec(0, 0, 0) end
  204. return vec(a.x / len, a.y / len, a.z / len)
  205. end
  206.  
  207. local function safeYield()
  208. if sleep then sleep(0) end
  209. end
  210.  
  211. --------------------------------------------------------------------------
  212. -- COLOUR / BLOCK APPEARANCE
  213. --------------------------------------------------------------------------
  214.  
  215. -- CC's blit encoding. colors.toBlit exists in modern CC:Tweaked, but the
  216. -- fallback keeps this script portable.
  217. local BLIT_FALLBACK = {
  218. [colors.white] = "0",
  219. [colors.orange] = "1",
  220. [colors.magenta] = "2",
  221. [colors.lightBlue] = "3",
  222. [colors.yellow] = "4",
  223. [colors.lime] = "5",
  224. [colors.pink] = "6",
  225. [colors.gray] = "7",
  226. [colors.lightGray] = "8",
  227. [colors.cyan] = "9",
  228. [colors.purple] = "a",
  229. [colors.blue] = "b",
  230. [colors.brown] = "c",
  231. [colors.green] = "d",
  232. [colors.red] = "e",
  233. [colors.black] = "f",
  234. }
  235.  
  236. local function toBlit(colour)
  237. if colors.toBlit then return colors.toBlit(colour) end
  238. return BLIT_FALLBACK[colour] or "0"
  239. end
  240.  
  241. local SHADE_FAMILY = {
  242. [colors.white] = {colors.white, colors.lightGray, colors.gray},
  243. [colors.orange] = {colors.yellow, colors.orange, colors.brown},
  244. [colors.magenta] = {colors.pink, colors.magenta, colors.purple},
  245. [colors.lightBlue] = {colors.white, colors.lightBlue, colors.blue},
  246. [colors.yellow] = {colors.white, colors.yellow, colors.orange},
  247. [colors.lime] = {colors.yellow, colors.lime, colors.green},
  248. [colors.pink] = {colors.white, colors.pink, colors.magenta},
  249. [colors.gray] = {colors.lightGray, colors.gray, colors.black},
  250. [colors.lightGray] = {colors.white, colors.lightGray, colors.gray},
  251. [colors.cyan] = {colors.lightBlue, colors.cyan, colors.blue},
  252. [colors.purple] = {colors.magenta, colors.purple, colors.gray},
  253. [colors.blue] = {colors.lightBlue, colors.blue, colors.gray},
  254. [colors.brown] = {colors.orange, colors.brown, colors.gray},
  255. [colors.green] = {colors.lime, colors.green, colors.gray},
  256. [colors.red] = {colors.orange, colors.red, colors.brown},
  257. [colors.black] = {colors.lightGray, colors.gray, colors.black},
  258. }
  259.  
  260. local UNKNOWN_PALETTE = {
  261. colors.lightGray, colors.cyan, colors.purple, colors.green,
  262. colors.brown, colors.orange, colors.blue, colors.magenta,
  263. }
  264.  
  265. local function contains(s, needle)
  266. return s:find(needle, 1, true) ~= nil
  267. end
  268.  
  269. local function stableUnknownColour(name)
  270. local hash = 5381
  271. for i = 1, #name do
  272. hash = (hash * 33 + name:byte(i)) % 2147483647
  273. end
  274. return UNKNOWN_PALETTE[(hash % #UNKNOWN_PALETTE) + 1]
  275. end
  276.  
  277. local function baseColourForBlock(name)
  278. local n = name:lower()
  279.  
  280. -- Fluids / transparent-ish blocks
  281. if contains(n, "water") then return colors.blue end
  282. if contains(n, "lava") then return colors.orange end
  283. if contains(n, "glass") or contains(n, "ice") then return colors.lightBlue end
  284.  
  285. -- Valuable / strongly recognisable materials first.
  286. if contains(n, "diamond") then return colors.cyan end
  287. if contains(n, "emerald") then return colors.lime end
  288. if contains(n, "lapis") then return colors.blue end
  289. if contains(n, "redstone") then return colors.red end
  290. if contains(n, "gold") then return colors.yellow end
  291. if contains(n, "copper") then return colors.orange end
  292. if contains(n, "amethyst") then return colors.purple end
  293. if contains(n, "quartz") then return colors.white end
  294. if contains(n, "iron") then return colors.lightGray end
  295. if contains(n, "coal") then return colors.gray end
  296.  
  297. -- Terrain / vegetation.
  298. if contains(n, "grass") or contains(n, "moss") or contains(n, "leaf")
  299. or contains(n, "leaves") or contains(n, "vine") or contains(n, "cactus") then
  300. return colors.green
  301. end
  302. if contains(n, "sand") or contains(n, "end_stone") then return colors.yellow end
  303. if contains(n, "dirt") or contains(n, "mud") or contains(n, "soul_sand")
  304. or contains(n, "soul_soil") then
  305. return colors.brown
  306. end
  307.  
  308. -- Wood-like building blocks.
  309. if contains(n, "log") or contains(n, "wood") or contains(n, "plank")
  310. or contains(n, "stem") or contains(n, "hyphae") then
  311. return colors.brown
  312. end
  313.  
  314. -- Nether / dark stone.
  315. if contains(n, "netherrack") or contains(n, "nether_brick") then return colors.red end
  316. if contains(n, "deepslate") or contains(n, "blackstone") or contains(n, "basalt")
  317. or contains(n, "obsidian") then
  318. return colors.gray
  319. end
  320.  
  321. -- Common stone-like materials.
  322. if contains(n, "stone") or contains(n, "cobble") or contains(n, "brick")
  323. or contains(n, "concrete") or contains(n, "terracotta") then
  324. return colors.lightGray
  325. end
  326.  
  327. return stableUnknownColour(name)
  328. end
  329.  
  330. local LIGHT_DIR = vnorm(vec(-0.45, 0.80, -0.35))
  331.  
  332. local function shadedColour(base, normal, distance)
  333. local family = SHADE_FAMILY[base] or SHADE_FAMILY[colors.lightGray]
  334. local lambert = math.max(0, vdot(normal, LIGHT_DIR))
  335. local brightness = 0.35 + lambert * 0.65
  336.  
  337. -- Slight atmospheric depth cue.
  338. local distanceFade = clamp(1.05 - distance / 130, 0.58, 1.0)
  339. brightness = brightness * distanceFade
  340.  
  341. if brightness >= 0.72 then return family[1] end
  342. if brightness >= 0.43 then return family[2] end
  343. return family[3]
  344. end
  345.  
  346. --------------------------------------------------------------------------
  347. -- PERIPHERAL DISCOVERY
  348. --------------------------------------------------------------------------
  349.  
  350. local function peripheralNameOf(wrapped)
  351. if peripheral.getName then
  352. local ok, name = pcall(peripheral.getName, wrapped)
  353. if ok then return name end
  354. end
  355. return "unknown"
  356. end
  357.  
  358. local function findScanner()
  359. local scanner
  360.  
  361. if CFG.SCANNER_NAME then
  362. scanner = peripheral.wrap(CFG.SCANNER_NAME)
  363. else
  364. scanner = peripheral.find("geo_scanner")
  365. if not scanner then scanner = peripheral.find("geoScanner") end
  366. end
  367.  
  368. if not scanner then
  369. return nil, nil, "No Geo Scanner detected (geo_scanner / geoScanner)."
  370. end
  371.  
  372. local method
  373. if type(scanner.scan) == "function" then
  374. method = "scan"
  375. elseif type(scanner.scanBlocks) == "function" then
  376. method = "scanBlocks"
  377. else
  378. return nil, nil, "Geo Scanner has neither scan(radius) nor scanBlocks(radius)."
  379. end
  380.  
  381. return scanner, method
  382. end
  383.  
  384. local function findPlayerDetector()
  385. local detector
  386. if CFG.PLAYER_DETECTOR_NAME then
  387. detector = peripheral.wrap(CFG.PLAYER_DETECTOR_NAME)
  388. else
  389. detector = peripheral.find("player_detector")
  390. if not detector then detector = peripheral.find("playerDetector") end
  391. end
  392. return detector
  393. end
  394.  
  395. local function findMonitor()
  396. local monitor
  397. if CFG.MONITOR_NAME then
  398. monitor = peripheral.wrap(CFG.MONITOR_NAME)
  399. else
  400. monitor = peripheral.find("monitor")
  401. end
  402.  
  403. if not monitor then return nil, "No monitor detected." end
  404. if type(monitor.getSize) ~= "function" or type(monitor.blit) ~= "function" then
  405. return nil, "Attached monitor does not expose the required terminal methods."
  406. end
  407.  
  408. if type(monitor.setTextScale) == "function" then
  409. pcall(monitor.setTextScale, CFG.MONITOR_TEXT_SCALE)
  410. end
  411.  
  412. return monitor
  413. end
  414.  
  415. local scanner, scanMethod, scannerErr = findScanner()
  416. if not scanner then error(scannerErr, 0) end
  417.  
  418. local monitor, monitorErr = findMonitor()
  419. if not monitor then error(monitorErr, 0) end
  420.  
  421. local scannerName = peripheralNameOf(scanner)
  422. local monitorName = peripheralNameOf(monitor)
  423. local playerDetector = findPlayerDetector()
  424. local playerDetectorName = playerDetector and peripheralNameOf(playerDetector) or nil
  425.  
  426. --------------------------------------------------------------------------
  427. -- COMPACT VOXEL MODEL
  428. --------------------------------------------------------------------------
  429.  
  430. local model = nil
  431.  
  432. local function newModel()
  433. return {
  434. cells = {}, -- integer voxel index -> interned name id
  435. idToName = {}, -- id -> registry name
  436. nameToId = {}, -- registry name -> id
  437. idToBaseColour = {}, -- id -> CC colour
  438.  
  439. solidCount = 0,
  440. uniqueBlockTypes = 0,
  441. returnedRecords = 0,
  442. invalidRecords = 0,
  443. outOfRangeRecords = 0,
  444. airRecords = 0,
  445.  
  446. -- Nearest scanned Player Detector block. Useful for newer relative
  447. -- f/r/u APIs, whose origin is the detector rather than the Geo Scanner.
  448. playerDetectorBlock = nil,
  449.  
  450. minX = R, maxX = -R,
  451. minY = R, maxY = -R,
  452. minZ = R, maxZ = -R,
  453. }
  454. end
  455.  
  456. local function internBlock(m, name)
  457. local id = m.nameToId[name]
  458. if id then return id end
  459.  
  460. id = #m.idToName + 1
  461. m.nameToId[name] = id
  462. m.idToName[id] = name
  463. m.idToBaseColour[id] = baseColourForBlock(name)
  464. m.uniqueBlockTypes = id
  465. return id
  466. end
  467.  
  468. local function chooseRelativeCoordinates(record)
  469. local x, y, z = record.x, record.y, record.z
  470.  
  471. if integer(x) and integer(y) and integer(z)
  472. and math.abs(x) <= R and math.abs(y) <= R and math.abs(z) <= R then
  473. return x, y, z
  474. end
  475.  
  476. -- Some Geo Scanner versions expose front/right/up as explicitly relative
  477. -- coordinates. If x/y/z appear non-relative, use these instead.
  478. local f, r, u = record.f, record.r, record.u
  479. if integer(f) and integer(r) and integer(u)
  480. and math.abs(f) <= R and math.abs(r) <= R and math.abs(u) <= R then
  481. return r, u, f
  482. end
  483.  
  484. return nil
  485. end
  486.  
  487. local VALID_FACING = {north = true, east = true, south = true, west = true}
  488.  
  489. local function scanRecordFacing(record)
  490. local facing
  491. if type(record.state) == "table" then facing = record.state.facing end
  492. if not facing and type(record.properties) == "table" then facing = record.properties.facing end
  493. if not facing and type(record.blockState) == "table" then facing = record.blockState.facing end
  494. if type(facing) == "string" then
  495. facing = facing:lower()
  496. if VALID_FACING[facing] then return facing end
  497. end
  498. return nil
  499. end
  500.  
  501. local function looksLikePlayerDetector(name)
  502. local n = tostring(name or ""):lower()
  503. return contains(n, "player_detector") or contains(n, "playerdetector")
  504. end
  505.  
  506. local function buildModel(raw)
  507. if type(raw) ~= "table" then return nil, "Scanner result is not a table." end
  508.  
  509. local m = newModel()
  510. local work = 0
  511.  
  512. for _, record in pairs(raw) do
  513. m.returnedRecords = m.returnedRecords + 1
  514.  
  515. if type(record) ~= "table" or type(record.name) ~= "string" or record.name == "" then
  516. m.invalidRecords = m.invalidRecords + 1
  517. else
  518. local x, y, z = chooseRelativeCoordinates(record)
  519. if not x then
  520. m.outOfRangeRecords = m.outOfRangeRecords + 1
  521. elseif AIR[record.name] then
  522. m.airRecords = m.airRecords + 1
  523. else
  524. local idx = voxelIndex(x, y, z)
  525. local id = internBlock(m, record.name)
  526.  
  527. if looksLikePlayerDetector(record.name) then
  528. local candidate = {
  529. x = x, y = y, z = z,
  530. facing = scanRecordFacing(record),
  531. distance2 = x * x + y * y + z * z,
  532. }
  533. if not m.playerDetectorBlock
  534. or candidate.distance2 < m.playerDetectorBlock.distance2 then
  535. m.playerDetectorBlock = candidate
  536. end
  537. end
  538.  
  539. if not m.cells[idx] then
  540. m.solidCount = m.solidCount + 1
  541. end
  542. m.cells[idx] = id
  543.  
  544. if x < m.minX then m.minX = x end
  545. if x > m.maxX then m.maxX = x end
  546. if y < m.minY then m.minY = y end
  547. if y > m.maxY then m.maxY = y end
  548. if z < m.minZ then m.minZ = z end
  549. if z > m.maxZ then m.maxZ = z end
  550. end
  551. end
  552.  
  553. work = work + 1
  554. if work % 1024 == 0 then safeYield() end
  555. end
  556.  
  557. if m.solidCount == 0 then
  558. m.minX, m.maxX = 0, 0
  559. m.minY, m.maxY = 0, 0
  560. m.minZ, m.maxZ = 0, 0
  561. end
  562.  
  563. return m
  564. end
  565.  
  566. --------------------------------------------------------------------------
  567. -- CAMERA / VIEW STATE
  568. --------------------------------------------------------------------------
  569.  
  570. local state = {
  571. yaw = CFG.DEFAULT_YAW_DEG * DEG,
  572. pitch = CFG.DEFAULT_PITCH_DEG * DEG,
  573. distance = CFG.DEFAULT_DISTANCE,
  574. clipY = R,
  575. target = vec(0, 0, 0),
  576. selected = nil,
  577. message = "Ready",
  578. lastRenderMs = 0,
  579. lastRayStep = 1,
  580.  
  581. players = {},
  582. playerStatus = playerDetector and "Player Detector ready" or "No Player Detector",
  583. playerCoordinateMode = nil,
  584. playerTimer = nil,
  585. }
  586.  
  587. local function resetCamera()
  588. state.yaw = CFG.DEFAULT_YAW_DEG * DEG
  589. state.pitch = CFG.DEFAULT_PITCH_DEG * DEG
  590. state.distance = CFG.DEFAULT_DISTANCE
  591. state.clipY = R
  592. state.target = vec(0, 0, 0)
  593. state.selected = nil
  594. state.message = "Camera reset"
  595. end
  596.  
  597. local function cameraBasis()
  598. local cp = math.cos(state.pitch)
  599. local offset = vec(
  600. math.sin(state.yaw) * cp * state.distance,
  601. math.sin(state.pitch) * state.distance,
  602. math.cos(state.yaw) * cp * state.distance
  603. )
  604.  
  605. local camera = vadd(state.target, offset)
  606. local forward = vnorm(vsub(state.target, camera))
  607. local worldUp = vec(0, 1, 0)
  608. local right = vnorm(vcross(forward, worldUp))
  609.  
  610. if vlength(right) < EPS then right = vec(1, 0, 0) end
  611. local up = vnorm(vcross(right, forward))
  612.  
  613. return camera, forward, right, up
  614. end
  615.  
  616. --------------------------------------------------------------------------
  617. -- VOXEL RAY CASTER
  618. --------------------------------------------------------------------------
  619.  
  620. local function rayBox(origin, direction, minB, maxB)
  621. local tMin = -INF
  622. local tMax = INF
  623. local enterNormal = vec(0, 1, 0)
  624.  
  625. local function axis(o, d, mn, mx, nx, ny, nz)
  626. if math.abs(d) < EPS then
  627. if o < mn or o > mx then return false end
  628. return true
  629. end
  630.  
  631. local t1 = (mn - o) / d
  632. local t2 = (mx - o) / d
  633. local n1 = vec(nx, ny, nz)
  634. local n2 = vec(-nx, -ny, -nz)
  635.  
  636. if t1 > t2 then
  637. t1, t2 = t2, t1
  638. n1, n2 = n2, n1
  639. end
  640.  
  641. if t1 > tMin then
  642. tMin = t1
  643. enterNormal = n1
  644. end
  645. if t2 < tMax then tMax = t2 end
  646. if tMin > tMax then return false end
  647. return true
  648. end
  649.  
  650. -- At the minimum plane the outward normal points negative on that axis.
  651. if not axis(origin.x, direction.x, minB.x, maxB.x, -1, 0, 0) then return nil end
  652. if not axis(origin.y, direction.y, minB.y, maxB.y, 0, -1, 0) then return nil end
  653. if not axis(origin.z, direction.z, minB.z, maxB.z, 0, 0, -1) then return nil end
  654.  
  655. if tMax < 0 then return nil end
  656. if tMin < 0 then
  657. tMin = 0
  658. enterNormal = vmul(direction, -1)
  659. enterNormal = vnorm(enterNormal)
  660. end
  661.  
  662. return tMin, tMax, enterNormal
  663. end
  664.  
  665. local function raycast(origin, direction)
  666. if not model or model.solidCount == 0 then return nil end
  667.  
  668. -- The cut plane removes every voxel above clipY from the raycast volume.
  669. local minB = vec(-R - 0.5, -R - 0.5, -R - 0.5)
  670. local maxB = vec( R + 0.5, state.clipY + 0.5, R + 0.5)
  671. if maxB.y < minB.y then return nil end
  672.  
  673. local tEnter, tExit, entryNormal = rayBox(origin, direction, minB, maxB)
  674. if not tEnter then return nil end
  675.  
  676. local startT = tEnter + 1e-6
  677. local px = origin.x + direction.x * startT
  678. local py = origin.y + direction.y * startT
  679. local pz = origin.z + direction.z * startT
  680.  
  681. local vx = math.floor(px + 0.5)
  682. local vy = math.floor(py + 0.5)
  683. local vz = math.floor(pz + 0.5)
  684.  
  685. vx = clamp(vx, -R, R)
  686. vy = clamp(vy, -R, state.clipY)
  687. vz = clamp(vz, -R, R)
  688.  
  689. local stepX = direction.x >= 0 and 1 or -1
  690. local stepY = direction.y >= 0 and 1 or -1
  691. local stepZ = direction.z >= 0 and 1 or -1
  692.  
  693. local nextX = vx + (stepX > 0 and 0.5 or -0.5)
  694. local nextY = vy + (stepY > 0 and 0.5 or -0.5)
  695. local nextZ = vz + (stepZ > 0 and 0.5 or -0.5)
  696.  
  697. local tMaxX = math.abs(direction.x) < EPS and INF or (nextX - origin.x) / direction.x
  698. local tMaxY = math.abs(direction.y) < EPS and INF or (nextY - origin.y) / direction.y
  699. local tMaxZ = math.abs(direction.z) < EPS and INF or (nextZ - origin.z) / direction.z
  700.  
  701. local tDeltaX = math.abs(direction.x) < EPS and INF or math.abs(1 / direction.x)
  702. local tDeltaY = math.abs(direction.y) < EPS and INF or math.abs(1 / direction.y)
  703. local tDeltaZ = math.abs(direction.z) < EPS and INF or math.abs(1 / direction.z)
  704.  
  705. local t = tEnter
  706. local normal = entryNormal
  707. local maxSteps = SIDE * 3 + 12
  708.  
  709. for _ = 1, maxSteps do
  710. if t > tExit + EPS then break end
  711.  
  712. if inBounds(vx, vy, vz) and vy <= state.clipY then
  713. local id = model.cells[voxelIndex(vx, vy, vz)]
  714. if id then
  715. return {
  716. id = id,
  717. x = vx, y = vy, z = vz,
  718. distance = math.max(0, t),
  719. nx = normal.x, ny = normal.y, nz = normal.z,
  720. }
  721. end
  722. end
  723.  
  724. if tMaxX <= tMaxY and tMaxX <= tMaxZ then
  725. vx = vx + stepX
  726. t = tMaxX
  727. tMaxX = tMaxX + tDeltaX
  728. normal = vec(-stepX, 0, 0)
  729. elseif tMaxY <= tMaxX and tMaxY <= tMaxZ then
  730. vy = vy + stepY
  731. t = tMaxY
  732. tMaxY = tMaxY + tDeltaY
  733. normal = vec(0, -stepY, 0)
  734. else
  735. vz = vz + stepZ
  736. t = tMaxZ
  737. tMaxZ = tMaxZ + tDeltaZ
  738. normal = vec(0, 0, -stepZ)
  739. end
  740.  
  741. if vx < -R or vx > R or vy < -R or vy > state.clipY or vz < -R or vz > R then
  742. -- It may have crossed one boundary slightly before tExit due to
  743. -- floating point, so there is no useful voxel left to visit.
  744. if t >= tExit - EPS then break end
  745. end
  746. end
  747.  
  748. return nil
  749. end
  750.  
  751. local function screenRay(screenX, screenY, viewW, viewH, camera, forward, right, up)
  752. local tanHalf = math.tan((CFG.FOV_DEGREES * DEG) * 0.5)
  753. local aspect = (viewW / math.max(1, viewH)) * CFG.CHARACTER_ASPECT
  754.  
  755. local nx = ((screenX - 0.5) / viewW) * 2 - 1
  756. local ny = 1 - ((screenY - 0.5) / viewH) * 2
  757.  
  758. local horizontal = nx * tanHalf * aspect
  759. local vertical = ny * tanHalf
  760.  
  761. local direction = vnorm(vadd(forward, vadd(vmul(right, horizontal), vmul(up, vertical))))
  762. return camera, direction
  763. end
  764.  
  765. --------------------------------------------------------------------------
  766. -- MONITOR DRAWING
  767. --------------------------------------------------------------------------
  768.  
  769. local buttonHitboxes = {}
  770. local VIEW_TOP = 3
  771. local lastViewW, lastViewH = 1, 1
  772.  
  773. local function monitorSize()
  774. local w, h = monitor.getSize()
  775. return math.max(1, w), math.max(1, h)
  776. end
  777.  
  778. local function fillLine(row, text, fg, bg)
  779. local w = monitorSize()
  780. text = padRight(text, w)
  781. local f = string.rep(toBlit(fg or colors.white), w)
  782. local b = string.rep(toBlit(bg or colors.black), w)
  783. monitor.setCursorPos(1, row)
  784. monitor.blit(text, f, b)
  785. end
  786.  
  787. local function splash(title, detail)
  788. local w, h = monitorSize()
  789. monitor.setBackgroundColor(colors.black)
  790. monitor.setTextColor(colors.white)
  791. monitor.clear()
  792.  
  793. local y = math.max(1, math.floor(h / 2) - 1)
  794. fillLine(y, title or "GeoVoxel 3D", colors.white, colors.black)
  795. if detail and y + 1 <= h then
  796. fillLine(y + 1, detail, colors.lightGray, colors.black)
  797. end
  798. end
  799.  
  800. --------------------------------------------------------------------------
  801. -- PLAYER DETECTOR / LIVE ENTITY OVERLAY
  802. --------------------------------------------------------------------------
  803.  
  804. local function detectorCall(method, ...)
  805. if not playerDetector or type(playerDetector[method]) ~= "function" then
  806. return nil, "unsupported method " .. tostring(method)
  807. end
  808. local ok, value, detail = pcall(playerDetector[method], ...)
  809. if not ok then return nil, tostring(value) end
  810. if value == nil or value == false then return nil, tostring(detail or "no result") end
  811. return value
  812. end
  813.  
  814. local function normalisePlayerNames(values)
  815. local result, seen = {}, {}
  816. if type(values) ~= "table" then return result end
  817. for k, v in pairs(values) do
  818. local name
  819. if type(v) == "string" then
  820. name = v
  821. elseif type(k) == "string" and v == true then
  822. name = k
  823. elseif type(v) == "table" then
  824. name = v.name or v.username
  825. end
  826. if type(name) == "string" and name ~= "" and not seen[name] then
  827. seen[name] = true
  828. result[#result + 1] = name
  829. end
  830. end
  831. table.sort(result, function(a, b) return a:lower() < b:lower() end)
  832. return result
  833. end
  834.  
  835. local function detectorPlayerNames()
  836. -- Prefer a generous local cube so players in other dimensions or very far
  837. -- away are not queried unnecessarily. If server config rejects this size,
  838. -- fall back to the online-player list and exact-filter after conversion.
  839. if playerDetector and type(playerDetector.getPlayersInCubic) == "function" then
  840. local values = detectorCall("getPlayersInCubic", SIDE + 8, SIDE + 8, SIDE + 8)
  841. if values then return normalisePlayerNames(values) end
  842. end
  843. if playerDetector and type(playerDetector.getOnlinePlayers) == "function" then
  844. local values, reason = detectorCall("getOnlinePlayers")
  845. if values then return normalisePlayerNames(values) end
  846. return nil, reason
  847. end
  848. return nil, "Player Detector has no player-list method"
  849. end
  850.  
  851. local function detectorPlayerInfo(name)
  852. if type(playerDetector.getPlayer) == "function" then
  853. local value = detectorCall("getPlayer", name)
  854. if type(value) == "table" then return value end
  855. end
  856. if type(playerDetector.getPlayerPos) == "function" then
  857. local value, reason = detectorCall("getPlayerPos", name)
  858. if type(value) == "table" then return value end
  859. return nil, reason
  860. end
  861. return nil, "Player Detector has neither getPlayer nor getPlayerPos"
  862. end
  863.  
  864. local function detectorOffset()
  865. if type(CFG.PLAYER_DETECTOR_OFFSET) == "table" then
  866. local o = CFG.PLAYER_DETECTOR_OFFSET
  867. if type(o.x) == "number" and type(o.y) == "number" and type(o.z) == "number" then
  868. return o
  869. end
  870. end
  871. if model and model.playerDetectorBlock then return model.playerDetectorBlock end
  872. return nil
  873. end
  874.  
  875. local function detectorFacing()
  876. local facing = CFG.PLAYER_DETECTOR_FACING
  877. if type(facing) == "string" then
  878. facing = facing:lower()
  879. if VALID_FACING[facing] then return facing end
  880. end
  881. if model and model.playerDetectorBlock and VALID_FACING[model.playerDetectorBlock.facing] then
  882. return model.playerDetectorBlock.facing
  883. end
  884. return nil
  885. end
  886.  
  887. local FRONT = {
  888. north = {x = 0, z = -1}, east = {x = 1, z = 0},
  889. south = {x = 0, z = 1}, west = {x = -1, z = 0},
  890. }
  891. local RIGHT = {
  892. north = {x = 1, z = 0}, east = {x = 0, z = 1},
  893. south = {x = -1, z = 0}, west = {x = 0, z = -1},
  894. }
  895.  
  896. local function playerScannerCoordinates(info)
  897. -- Most AP 0.7 builds expose absolute world x/y/z. The configured scanner
  898. -- block coordinate is therefore the cleanest and most exact conversion.
  899. local origin = CFG.SCANNER_WORLD_ORIGIN
  900. if type(origin) == "table"
  901. and type(info.x) == "number" and type(info.y) == "number" and type(info.z) == "number"
  902. and type(origin.x) == "number" and type(origin.y) == "number" and type(origin.z) == "number" then
  903. -- Voxel 0,0,0 is the scanner block centre. Entity coordinates are
  904. -- continuous world coordinates, so subtract the scanner centre on all axes.
  905. return info.x - (origin.x + 0.5),
  906. info.y - (origin.y + 0.5),
  907. info.z - (origin.z + 0.5),
  908. "world"
  909. end
  910.  
  911. -- AP 0.8+ can expose f/r/u relative to the Player Detector. Convert that
  912. -- basis to the Geo Scanner's world-axis-relative voxel basis.
  913. if type(info.f) == "number" and type(info.r) == "number" and type(info.u) == "number" then
  914. local offset, facing = detectorOffset(), detectorFacing()
  915. if offset and facing then
  916. local f, r = FRONT[facing], RIGHT[facing]
  917. return offset.x + f.x * info.f + r.x * info.r,
  918. offset.y + info.u,
  919. offset.z + f.z * info.f + r.z * info.r,
  920. "relative"
  921. end
  922. return nil, nil, nil, nil,
  923. "relative player coordinates available, but detector offset/facing is unknown"
  924. end
  925.  
  926. if type(info.x) == "number" and type(info.y) == "number" and type(info.z) == "number" then
  927. return nil, nil, nil, nil,
  928. "set SCANNER_WORLD_ORIGIN or run: 3dscan <scannerX> <scannerY> <scannerZ>"
  929. end
  930.  
  931. return nil, nil, nil, nil, "player position fields unavailable"
  932. end
  933.  
  934. local function insideScannerCube(x, y, z)
  935. -- A 33-cell voxel span -16..16 has geometric faces at +/-16.5.
  936. local edge = R + 0.5
  937. return x >= -edge and x <= edge
  938. and y >= -edge and y <= edge
  939. and z >= -edge and z <= edge
  940. end
  941.  
  942. local function refreshPlayers()
  943. if not playerDetector then
  944. state.players = {}
  945. state.playerStatus = "No Player Detector"
  946. state.playerCoordinateMode = nil
  947. return false
  948. end
  949.  
  950. local names, listReason = detectorPlayerNames()
  951. if not names then
  952. state.players = {}
  953. state.playerStatus = "PD error: " .. trunc(listReason, 50)
  954. state.playerCoordinateMode = nil
  955. return false
  956. end
  957.  
  958. local players, firstReason, mode = {}, nil, nil
  959. for _, name in ipairs(names) do
  960. local info, infoReason = detectorPlayerInfo(name)
  961. if info then
  962. local x, y, z, coordinateMode, coordinateReason = playerScannerCoordinates(info)
  963. if x and insideScannerCube(x, y, z) then
  964. local eyeHeight = tonumber(info.eyeHeight) or 1.62
  965. players[#players + 1] = {
  966. name = tostring(info.name or name),
  967. x = x, y = y, z = z,
  968. eyeHeight = clamp(eyeHeight, 0.5, 3.5),
  969. yaw = tonumber(info.yaw), pitch = tonumber(info.pitch),
  970. }
  971. mode = mode or coordinateMode
  972. elseif not x and not firstReason then
  973. firstReason = coordinateReason or infoReason
  974. end
  975. elseif not firstReason then
  976. firstReason = infoReason
  977. end
  978. end
  979.  
  980. state.players = players
  981. state.playerCoordinateMode = mode
  982. if #players > 0 then
  983. state.playerStatus = ("Players: %d (%s)"):format(#players, mode or "?")
  984. elseif firstReason then
  985. state.playerStatus = "PD: " .. trunc(firstReason, 64)
  986. else
  987. state.playerStatus = "Players: 0 in scan cube"
  988. end
  989. return true
  990. end
  991.  
  992. local function projectPoint(point, viewW, viewH, camera, forward, right, up)
  993. local rel = vsub(point, camera)
  994. local depth = vdot(rel, forward)
  995. if depth <= 0.05 then return nil end
  996.  
  997. local tanHalf = math.tan((CFG.FOV_DEGREES * DEG) * 0.5)
  998. local aspect = (viewW / math.max(1, viewH)) * CFG.CHARACTER_ASPECT
  999. local halfX = tanHalf * aspect
  1000.  
  1001. local nx = vdot(rel, right) / (depth * halfX)
  1002. local ny = vdot(rel, up) / (depth * tanHalf)
  1003. if nx < -1.05 or nx > 1.05 or ny < -1.05 or ny > 1.05 then return nil end
  1004.  
  1005. local sx = math.floor(((nx + 1) * 0.5) * viewW + 0.5)
  1006. local sy = math.floor(((1 - ny) * 0.5) * viewH + 0.5)
  1007. if sx < 1 or sx > viewW or sy < 1 or sy > viewH then return nil end
  1008. return sx, sy, depth
  1009. end
  1010.  
  1011. local function clippedWrite(x, y, text, fg, bg, minY, maxY)
  1012. local w = monitorSize()
  1013. text = tostring(text or "")
  1014. if text == "" or y < minY or y > maxY or x > w or x + #text - 1 < 1 then return end
  1015. if x < 1 then
  1016. text = text:sub(2 - x)
  1017. x = 1
  1018. end
  1019. if x + #text - 1 > w then text = text:sub(1, w - x + 1) end
  1020. if text == "" then return end
  1021. monitor.setCursorPos(x, y)
  1022. monitor.setTextColor(fg or colors.white)
  1023. monitor.setBackgroundColor(bg or colors.black)
  1024. monitor.write(text)
  1025. end
  1026.  
  1027. local function drawPlayerOverlay(viewW, viewH, camera, forward, right, up)
  1028. if not state.players or #state.players == 0 then return end
  1029.  
  1030. local projected = {}
  1031. for _, player in ipairs(state.players) do
  1032. -- Put the dot around upper torso/head height while preserving the exact
  1033. -- feet coordinate in state for filtering and future extensions.
  1034. local marker = vec(player.x, player.y + math.min(player.eyeHeight * 0.72, 1.35), player.z)
  1035. local sx, sy, depth = projectPoint(marker, viewW, viewH, camera, forward, right, up)
  1036. if sx then
  1037. projected[#projected + 1] = {player = player, x = sx, y = sy, depth = depth}
  1038. end
  1039. end
  1040.  
  1041. -- Draw far players first, so closer markers/names win overlaps.
  1042. table.sort(projected, function(a, b) return a.depth > b.depth end)
  1043. local occupied = {}
  1044. local minRow, maxRow = VIEW_TOP, VIEW_TOP + viewH - 1
  1045.  
  1046. local function rowFree(row, x1, x2)
  1047. if row < minRow or row > maxRow then return false end
  1048. occupied[row] = occupied[row] or {}
  1049. for x = x1, x2 do
  1050. if occupied[row][x] then return false end
  1051. end
  1052. return true
  1053. end
  1054.  
  1055. local function markRow(row, x1, x2)
  1056. occupied[row] = occupied[row] or {}
  1057. for x = x1, x2 do occupied[row][x] = true end
  1058. end
  1059.  
  1060. for _, p in ipairs(projected) do
  1061. local screenRow = VIEW_TOP + p.y - 1
  1062. clippedWrite(p.x, screenRow, "o", colors.lime, colors.black, minRow, maxRow)
  1063. markRow(screenRow, p.x, p.x)
  1064.  
  1065. local name = trunc(p.player.name, CFG.PLAYER_NAME_LIMIT)
  1066. local nameX = p.x - math.floor(#name / 2)
  1067. nameX = clamp(nameX, 1, math.max(1, viewW - #name + 1))
  1068. local nameX2 = math.min(viewW, nameX + #name - 1)
  1069.  
  1070. -- User requested the name under the dot. If that row collides or falls
  1071. -- off-screen, try one lower, then above as a final fallback.
  1072. local candidates = {screenRow + 1, screenRow + 2, screenRow - 1, screenRow - 2}
  1073. local labelRow
  1074. for _, row in ipairs(candidates) do
  1075. if rowFree(row, nameX, nameX2) then labelRow = row; break end
  1076. end
  1077. if labelRow then
  1078. clippedWrite(nameX, labelRow, name, colors.white, colors.black, minRow, maxRow)
  1079. markRow(labelRow, nameX, nameX2)
  1080. end
  1081. end
  1082. end
  1083.  
  1084. local function armPlayerTimer()
  1085. if state.playerTimer and os.cancelTimer then
  1086. pcall(os.cancelTimer, state.playerTimer)
  1087. state.playerTimer = nil
  1088. end
  1089. if playerDetector and os.startTimer and CFG.PLAYER_REFRESH_SECONDS > 0 then
  1090. state.playerTimer = os.startTimer(CFG.PLAYER_REFRESH_SECONDS)
  1091. end
  1092. end
  1093.  
  1094. local function drawButton(row, x, label, action, bg)
  1095. local w = monitorSize()
  1096. if x > w then return x end
  1097.  
  1098. local text = " " .. label .. " "
  1099. if x + #text - 1 > w then return x end
  1100.  
  1101. monitor.setCursorPos(x, row)
  1102. monitor.setBackgroundColor(bg or colors.gray)
  1103. monitor.setTextColor(colors.white)
  1104. monitor.write(text)
  1105.  
  1106. buttonHitboxes[#buttonHitboxes + 1] = {
  1107. x1 = x, x2 = x + #text - 1,
  1108. y1 = row, y2 = row,
  1109. action = action,
  1110. }
  1111.  
  1112. return x + #text + 1
  1113. end
  1114.  
  1115. local function drawControls(w, h)
  1116. buttonHitboxes = {}
  1117. if h < 2 then return end
  1118.  
  1119. monitor.setBackgroundColor(colors.black)
  1120. monitor.setTextColor(colors.white)
  1121.  
  1122. local row1 = h - 1
  1123. local row2 = h
  1124.  
  1125. fillLine(row1, "", colors.white, colors.black)
  1126. fillLine(row2, "", colors.white, colors.black)
  1127.  
  1128. local x = 1
  1129. x = drawButton(row1, x, "<", "yaw_left", colors.gray)
  1130. x = drawButton(row1, x, ">", "yaw_right", colors.gray)
  1131. x = drawButton(row1, x, "^", "pitch_up", colors.gray)
  1132. x = drawButton(row1, x, "v", "pitch_down", colors.gray)
  1133. x = drawButton(row1, x, "-", "zoom_out", colors.gray)
  1134. x = drawButton(row1, x, "+", "zoom_in", colors.gray)
  1135.  
  1136. x = 1
  1137. x = drawButton(row2, x, "Y-", "clip_down", colors.brown)
  1138. x = drawButton(row2, x, "Y+", "clip_up", colors.brown)
  1139. x = drawButton(row2, x, "C", "reset", colors.blue)
  1140. x = drawButton(row2, x, "SCAN", "scan", colors.green)
  1141. end
  1142.  
  1143. local function hitEqualsSelection(hit)
  1144. local s = state.selected
  1145. return s and hit and s.x == hit.x and s.y == hit.y and s.z == hit.z
  1146. end
  1147.  
  1148. local function pixelForHit(hit)
  1149. if not hit then
  1150. return " ", toBlit(colors.black), toBlit(colors.black)
  1151. end
  1152.  
  1153. local base = model.idToBaseColour[hit.id] or colors.lightGray
  1154. local normal = vec(hit.nx, hit.ny, hit.nz)
  1155. local shade = shadedColour(base, normal, hit.distance)
  1156. local char = " "
  1157. local fg = shade
  1158.  
  1159. if hitEqualsSelection(hit) then
  1160. char = "@"
  1161. fg = colors.white
  1162. elseif hit.x == 0 and hit.y == 0 and hit.z == 0 then
  1163. char = "S"
  1164. fg = colors.white
  1165. end
  1166.  
  1167. return char, toBlit(fg), toBlit(shade)
  1168. end
  1169.  
  1170. local function renderHeader(w)
  1171. local yawDeg = (state.yaw / DEG) % 360
  1172. if yawDeg < 0 then yawDeg = yawDeg + 360 end
  1173. local pitchDeg = state.pitch / DEG
  1174.  
  1175. local line1
  1176. if model then
  1177. line1 = ("GeoVoxel R%d solid=%d types=%d players=%d yaw=%03d pitch=%d zoom=%d cutY=%d"):format(
  1178. R, model.solidCount, model.uniqueBlockTypes, #(state.players or {}),
  1179. round(yawDeg), round(pitchDeg), round(state.distance), state.clipY
  1180. )
  1181. else
  1182. line1 = "GeoVoxel 3D - no model"
  1183. end
  1184. fillLine(1, line1, colors.white, colors.black)
  1185.  
  1186. local line2
  1187. if state.selected and model then
  1188. line2 = ("@ %d,%d,%d %s"):format(
  1189. state.selected.x, state.selected.y, state.selected.z,
  1190. model.idToName[state.selected.id] or "unknown"
  1191. )
  1192. else
  1193. line2 = state.message or ""
  1194. if state.playerStatus and state.playerStatus ~= "" then
  1195. if line2 ~= "" then line2 = line2 .. " | " end
  1196. line2 = line2 .. state.playerStatus
  1197. end
  1198. end
  1199.  
  1200. if state.lastRayStep > 1 then
  1201. line2 = line2 .. (" render=%dx coarse"):format(state.lastRayStep)
  1202. end
  1203. if state.lastRenderMs and state.lastRenderMs > 0 then
  1204. line2 = line2 .. (" %dms"):format(state.lastRenderMs)
  1205. end
  1206. fillLine(2, line2, colors.lightGray, colors.black)
  1207. end
  1208.  
  1209. local function render()
  1210. local w, h = monitorSize()
  1211. local viewBottom = h - 2
  1212. local viewH = viewBottom - VIEW_TOP + 1
  1213. local viewW = w
  1214.  
  1215. monitor.setBackgroundColor(colors.black)
  1216. monitor.setTextColor(colors.white)
  1217. monitor.clear()
  1218.  
  1219. if viewH < 2 or viewW < 4 then
  1220. fillLine(1, "Monitor too small", colors.red, colors.black)
  1221. if h >= 2 then fillLine(2, "Increase monitor size", colors.white, colors.black) end
  1222. return
  1223. end
  1224.  
  1225. lastViewW, lastViewH = viewW, viewH
  1226.  
  1227. local camera, forward, right, up = cameraBasis()
  1228. local area = viewW * viewH
  1229. local rayStep = math.max(1, math.ceil(math.sqrt(area / CFG.MAX_RAYS_PER_FRAME)))
  1230. state.lastRayStep = rayStep
  1231.  
  1232. local startMs = os.epoch and os.epoch("utc") or nil
  1233.  
  1234. -- Each cache row contains one ray result for every rayStep-wide group.
  1235. -- The same ray row is reused across rayStep monitor rows.
  1236. local cachedPixels = nil
  1237.  
  1238. for sy = 1, viewH do
  1239. if not cachedPixels or ((sy - 1) % rayStep == 0) then
  1240. cachedPixels = {}
  1241. local sampleY = math.min(viewH, sy + math.floor((rayStep - 1) / 2))
  1242.  
  1243. local group = 0
  1244. for sx = 1, viewW, rayStep do
  1245. group = group + 1
  1246. local sampleX = math.min(viewW, sx + math.floor((rayStep - 1) / 2))
  1247. local origin, direction = screenRay(
  1248. sampleX, sampleY, viewW, viewH,
  1249. camera, forward, right, up
  1250. )
  1251. local hit = raycast(origin, direction)
  1252. local ch, fg, bg = pixelForHit(hit)
  1253. cachedPixels[group] = {ch = ch, fg = fg, bg = bg}
  1254. end
  1255. end
  1256.  
  1257. local chars, fgs, bgs = {}, {}, {}
  1258. for sx = 1, viewW do
  1259. local group = math.floor((sx - 1) / rayStep) + 1
  1260. local p = cachedPixels[group]
  1261. chars[sx], fgs[sx], bgs[sx] = p.ch, p.fg, p.bg
  1262. end
  1263.  
  1264. monitor.setCursorPos(1, VIEW_TOP + sy - 1)
  1265. monitor.blit(table.concat(chars), table.concat(fgs), table.concat(bgs))
  1266.  
  1267. if sy % 4 == 0 then safeYield() end
  1268. end
  1269.  
  1270. if startMs and os.epoch then
  1271. state.lastRenderMs = math.max(0, os.epoch("utc") - startMs)
  1272. else
  1273. state.lastRenderMs = 0
  1274. end
  1275.  
  1276. -- Player markers intentionally draw over walls/roofs: this is a tracker
  1277. -- overlay, so a player remains visible even when the voxel ray hits a wall.
  1278. drawPlayerOverlay(viewW, viewH, camera, forward, right, up)
  1279.  
  1280. renderHeader(w)
  1281. drawControls(w, h)
  1282. armPlayerTimer()
  1283.  
  1284. monitor.setBackgroundColor(colors.black)
  1285. monitor.setTextColor(colors.white)
  1286. end
  1287.  
  1288. --------------------------------------------------------------------------
  1289. -- SCANNING
  1290. --------------------------------------------------------------------------
  1291.  
  1292. local function scannerCostText()
  1293. if type(scanner.cost) == "function" then
  1294. local ok, value = pcall(scanner.cost, R)
  1295. if ok and type(value) == "number" then return tostring(value) end
  1296. end
  1297. if type(scanner.getScanCost) == "function" then
  1298. local ok, value = pcall(scanner.getScanCost, R)
  1299. if ok and type(value) == "number" then return tostring(value) end
  1300. end
  1301. if type(scanner.getEnergyCost) == "function" then
  1302. local ok, value = pcall(scanner.getEnergyCost, R)
  1303. if ok and type(value) == "number" then return tostring(value) end
  1304. end
  1305. return nil
  1306. end
  1307.  
  1308. local function waitForScannerCooldown()
  1309. local remaining
  1310.  
  1311. if type(scanner.getScanCooldown) == "function" then
  1312. local ok, value = pcall(scanner.getScanCooldown)
  1313. if ok and type(value) == "number" and value > 0 and value <= 60000 then
  1314. remaining = value
  1315. end
  1316. elseif type(scanner.getOperationCooldown) == "function" then
  1317. local ok, value = pcall(scanner.getOperationCooldown, "scan")
  1318. if ok and type(value) == "number" and value > 0 and value <= 60000 then
  1319. remaining = value
  1320. end
  1321. end
  1322.  
  1323. if remaining and remaining > 0 then
  1324. splash("GeoVoxel 3D", ("Scanner cooldown: %.2fs"):format(remaining / 1000))
  1325. sleep(remaining / 1000)
  1326. end
  1327. end
  1328.  
  1329. local function performScan()
  1330. local cost = scannerCostText()
  1331. local detail = ("radius=%d method=%s"):format(R, scanMethod)
  1332. if cost then detail = detail .. " cost=" .. cost end
  1333.  
  1334. splash("Scanning Geo Scanner...", detail)
  1335. print(("[GeoVoxel] Scanning radius %d with %s on %s"):format(R, scanMethod, scannerName))
  1336.  
  1337. waitForScannerCooldown()
  1338.  
  1339. local ok, raw, reason = pcall(scanner[scanMethod], R)
  1340. if not ok then
  1341. return false, "Geo Scanner call crashed: " .. tostring(raw)
  1342. end
  1343. if raw == nil or raw == false then
  1344. return false, "Geo Scanner rejected scan: " .. tostring(reason or "unknown reason")
  1345. end
  1346. if type(raw) ~= "table" then
  1347. return false, "Geo Scanner returned unexpected type: " .. type(raw)
  1348. end
  1349.  
  1350. splash("Building voxel model...", "Normalising block coordinates")
  1351. local candidate, buildReason = buildModel(raw)
  1352. -- Drop the large raw scan table. CC:Tweaked/CraftOS does not expose
  1353. -- Lua's collectgarbage() global, and normal GC will reclaim it automatically.
  1354. raw = nil
  1355.  
  1356. if not candidate then return false, buildReason end
  1357.  
  1358. model = candidate
  1359. state.selected = nil
  1360. state.clipY = R
  1361. state.message = ("Scan OK: %d solid, %d types, %d returned"):format(
  1362. model.solidCount, model.uniqueBlockTypes, model.returnedRecords
  1363. )
  1364.  
  1365. if model.outOfRangeRecords > 0 or model.invalidRecords > 0 then
  1366. state.message = state.message .. (" skipped=%d"):format(
  1367. model.outOfRangeRecords + model.invalidRecords
  1368. )
  1369. end
  1370.  
  1371. print(("[GeoVoxel] Scan complete: %d solid blocks, %d unique block types, %d records")
  1372. :format(model.solidCount, model.uniqueBlockTypes, model.returnedRecords))
  1373. print(("[GeoVoxel] Bounds: x=%d..%d y=%d..%d z=%d..%d")
  1374. :format(model.minX, model.maxX, model.minY, model.maxY, model.minZ, model.maxZ))
  1375.  
  1376. if model.outOfRangeRecords > 0 then
  1377. print(("[GeoVoxel] Warning: %d records had coordinates outside -%d..%d and were skipped.")
  1378. :format(model.outOfRangeRecords, R, R))
  1379. end
  1380.  
  1381. if playerDetector then refreshPlayers() end
  1382. return true
  1383. end
  1384.  
  1385. --------------------------------------------------------------------------
  1386. -- INPUT / ACTIONS
  1387. --------------------------------------------------------------------------
  1388.  
  1389. local running = true
  1390.  
  1391. local function applyAction(action)
  1392. if action == "yaw_left" then
  1393. state.yaw = state.yaw - CFG.YAW_STEP_DEG * DEG
  1394. state.message = "Rotate left"
  1395.  
  1396. elseif action == "yaw_right" then
  1397. state.yaw = state.yaw + CFG.YAW_STEP_DEG * DEG
  1398. state.message = "Rotate right"
  1399.  
  1400. elseif action == "pitch_up" then
  1401. state.pitch = clamp(
  1402. state.pitch + CFG.PITCH_STEP_DEG * DEG,
  1403. -80 * DEG, 80 * DEG
  1404. )
  1405. state.message = "Pitch up"
  1406.  
  1407. elseif action == "pitch_down" then
  1408. state.pitch = clamp(
  1409. state.pitch - CFG.PITCH_STEP_DEG * DEG,
  1410. -80 * DEG, 80 * DEG
  1411. )
  1412. state.message = "Pitch down"
  1413.  
  1414. elseif action == "zoom_in" then
  1415. state.distance = clamp(
  1416. state.distance - CFG.ZOOM_STEP,
  1417. CFG.MIN_DISTANCE, CFG.MAX_DISTANCE
  1418. )
  1419. state.message = "Zoom in"
  1420.  
  1421. elseif action == "zoom_out" then
  1422. state.distance = clamp(
  1423. state.distance + CFG.ZOOM_STEP,
  1424. CFG.MIN_DISTANCE, CFG.MAX_DISTANCE
  1425. )
  1426. state.message = "Zoom out"
  1427.  
  1428. elseif action == "clip_down" then
  1429. state.clipY = clamp(state.clipY - 1, -R, R)
  1430. state.message = "Y cut plane lowered to " .. state.clipY
  1431.  
  1432. elseif action == "clip_up" then
  1433. state.clipY = clamp(state.clipY + 1, -R, R)
  1434. state.message = "Y cut plane raised to " .. state.clipY
  1435.  
  1436. elseif action == "reset" then
  1437. resetCamera()
  1438.  
  1439. elseif action == "scan" then
  1440. local ok, reason = performScan()
  1441. if not ok then
  1442. state.message = "SCAN FAILED: " .. tostring(reason)
  1443. print("[GeoVoxel] " .. state.message)
  1444. end
  1445.  
  1446. elseif action == "quit" then
  1447. running = false
  1448. return
  1449. end
  1450.  
  1451. render()
  1452. end
  1453.  
  1454. local function touchAction(x, y)
  1455. for _, box in ipairs(buttonHitboxes) do
  1456. if x >= box.x1 and x <= box.x2 and y >= box.y1 and y <= box.y2 then
  1457. return box.action
  1458. end
  1459. end
  1460. return nil
  1461. end
  1462.  
  1463. local function inspectAt(screenX, monitorY)
  1464. local sy = monitorY - VIEW_TOP + 1
  1465. if sy < 1 or sy > lastViewH or screenX < 1 or screenX > lastViewW then return false end
  1466.  
  1467. local camera, forward, right, up = cameraBasis()
  1468. local origin, direction = screenRay(
  1469. screenX, sy, lastViewW, lastViewH,
  1470. camera, forward, right, up
  1471. )
  1472. local hit = raycast(origin, direction)
  1473.  
  1474. if hit then
  1475. state.selected = {x = hit.x, y = hit.y, z = hit.z, id = hit.id}
  1476. state.message = ("Selected %d,%d,%d"):format(hit.x, hit.y, hit.z)
  1477. else
  1478. state.selected = nil
  1479. state.message = "No block at touched ray"
  1480. end
  1481.  
  1482. render()
  1483. return true
  1484. end
  1485.  
  1486. local function handleKey(code)
  1487. if code == keys.left then applyAction("yaw_left"); return true end
  1488. if code == keys.right then applyAction("yaw_right"); return true end
  1489. if code == keys.up then applyAction("pitch_up"); return true end
  1490. if code == keys.down then applyAction("pitch_down"); return true end
  1491. return false
  1492. end
  1493.  
  1494. local function handleChar(ch)
  1495. ch = ch:lower()
  1496.  
  1497. if ch == "a" then applyAction("yaw_left")
  1498. elseif ch == "d" then applyAction("yaw_right")
  1499. elseif ch == "w" then applyAction("pitch_up")
  1500. elseif ch == "s" then applyAction("pitch_down")
  1501. elseif ch == "+" or ch == "=" then applyAction("zoom_in")
  1502. elseif ch == "-" or ch == "_" then applyAction("zoom_out")
  1503. elseif ch == "[" then applyAction("clip_down")
  1504. elseif ch == "]" then applyAction("clip_up")
  1505. elseif ch == "r" then applyAction("scan")
  1506. elseif ch == "c" then applyAction("reset")
  1507. elseif ch == "q" then applyAction("quit")
  1508. else return false end
  1509.  
  1510. return true
  1511. end
  1512.  
  1513. --------------------------------------------------------------------------
  1514. -- STARTUP / EVENT LOOP
  1515. --------------------------------------------------------------------------
  1516.  
  1517. term.setBackgroundColor(colors.black)
  1518. term.setTextColor(colors.white)
  1519. term.clear()
  1520. term.setCursorPos(1, 1)
  1521.  
  1522. print("GeoVoxel 3D")
  1523. print("===========")
  1524. print("Scanner: " .. scannerName .. " method=" .. scanMethod)
  1525. print("Monitor: " .. monitorName)
  1526. print("Player Detector: " .. (playerDetectorName or "not detected"))
  1527. print("Radius: " .. R .. " (-" .. R .. ".." .. R .. ", " .. SIDE .. " per axis)")
  1528. if CFG.SCANNER_WORLD_ORIGIN then
  1529. local o = CFG.SCANNER_WORLD_ORIGIN
  1530. print(("Scanner world origin: %s,%s,%s"):format(tostring(o.x), tostring(o.y), tostring(o.z)))
  1531. elseif playerDetector then
  1532. print("Scanner world origin: unset (AP 0.7 player overlay may need: 3dscan X Y Z)")
  1533. end
  1534. print("")
  1535. print("Controls:")
  1536. print(" arrows / WASD rotate + pitch")
  1537. print(" +/- zoom")
  1538. print(" [ ] Y cut plane")
  1539. print(" R rescan")
  1540. print(" C reset camera")
  1541. print(" Q quit")
  1542. print("")
  1543.  
  1544. local ok, startupReason = performScan()
  1545. if not ok then
  1546. state.message = "SCAN FAILED: " .. tostring(startupReason)
  1547. print("[GeoVoxel] " .. state.message)
  1548. splash("SCAN FAILED", tostring(startupReason))
  1549. print("Press R to retry or Q to quit.")
  1550. else
  1551. render()
  1552. end
  1553.  
  1554. while running do
  1555. local event, a, b, c = os.pullEvent()
  1556.  
  1557. if event == "monitor_touch" then
  1558. local touchedMonitor, x, y = a, b, c
  1559.  
  1560. -- If we know the monitor name, ignore touches from other monitors.
  1561. if monitorName == "unknown" or touchedMonitor == monitorName then
  1562. local action = touchAction(x, y)
  1563. if action then
  1564. applyAction(action)
  1565. else
  1566. inspectAt(x, y)
  1567. end
  1568. end
  1569.  
  1570. elseif event == "key" then
  1571. handleKey(a)
  1572.  
  1573. elseif event == "char" then
  1574. handleChar(a)
  1575.  
  1576. elseif event == "timer" then
  1577. if state.playerTimer and a == state.playerTimer then
  1578. state.playerTimer = nil
  1579. refreshPlayers()
  1580. render()
  1581. end
  1582.  
  1583. elseif event == "monitor_resize" then
  1584. -- Re-apply the requested text scale if another process changed it.
  1585. if type(monitor.setTextScale) == "function" then
  1586. pcall(monitor.setTextScale, CFG.MONITOR_TEXT_SCALE)
  1587. end
  1588. render()
  1589.  
  1590. elseif event == "peripheral_detach" then
  1591. if a == scannerName then
  1592. state.message = "Geo Scanner detached"
  1593. render()
  1594. elseif playerDetectorName and a == playerDetectorName then
  1595. playerDetector = nil
  1596. playerDetectorName = nil
  1597. state.players = {}
  1598. state.playerStatus = "Player Detector detached"
  1599. state.playerTimer = nil
  1600. render()
  1601. elseif a == monitorName then
  1602. print("[GeoVoxel] Monitor detached. Exiting.")
  1603. running = false
  1604. end
  1605.  
  1606. elseif event == "peripheral" then
  1607. if not playerDetector then
  1608. local found = findPlayerDetector()
  1609. if found then
  1610. playerDetector = found
  1611. playerDetectorName = peripheralNameOf(found)
  1612. state.playerStatus = "Player Detector attached"
  1613. refreshPlayers()
  1614. render()
  1615. end
  1616. end
  1617. end
  1618. end
  1619.  
  1620. monitor.setBackgroundColor(colors.black)
  1621. monitor.setTextColor(colors.white)
  1622. monitor.clear()
  1623. monitor.setCursorPos(1, 1)
  1624. monitor.write("GeoVoxel 3D stopped")
  1625.  
  1626. term.setBackgroundColor(colors.black)
  1627. term.setTextColor(colors.white)
  1628. term.setCursorPos(1, select(2, term.getSize()))
  1629. print("")
  1630. print("GeoVoxel 3D stopped.")
  1631.  
Advertisement
Add Comment
Please, Sign In to add comment