Not a member of Pastebin yet?
Sign Up,
it unlocks many cool features!
- --[[
- GeoVoxel 3D
- ===========
- One-file CC:Tweaked + Advanced Peripherals Geo Scanner 3D viewer.
- What it does
- ------------
- * Finds a Geo Scanner (geo_scanner / geoScanner).
- * Requests a radius-16 scan: coordinates -16..16 => 33 blocks per axis.
- * Stores every non-air block returned by the scanner in a compact voxel map.
- * Displays the map on a monitor using a perspective voxel ray-caster.
- * Rotate, pitch, zoom, cut the upper Y layers away, rescan, and inspect blocks.
- * Optionally overlays live Player Detector positions as a dot + player name.
- * Works with monitor touch on Advanced Monitors and keyboard controls on the
- computer/turtle running the program.
- Keyboard
- --------
- Arrow Left/Right rotate
- Arrow Up/Down pitch
- + / - zoom in/out
- [ / ] lower/raise Y cut plane
- R rescan
- C reset camera
- Q quit
- Monitor touch
- -------------
- Bottom buttons perform the same camera actions.
- Touch the rendered model to inspect the hit block and relative coordinate.
- Notes
- -----
- * The script deliberately does NOT apply its own spherical filter. It renders
- everything the installed Geo Scanner implementation returns for radius 16.
- * Coordinates are expected to be relative to the scanner. If a scanner version
- returns non-relative x/y/z but provides f/r/u, the script falls back to
- right/up/front coordinates.
- * Lower CUT Y to peel the roof/top layers away and inspect enclosed geometry.
- * AP 0.7 Player Detector reports world x/y/z. Pass the Geo Scanner block's
- world coordinates as 3dscan X Y Z (or set SCANNER_WORLD_ORIGIN) to align
- those positions with the scan model. Newer f/r/u APIs are also supported.
- --]]
- --------------------------------------------------------------------------
- -- CONFIGURATION
- --------------------------------------------------------------------------
- -- Optional command-line scanner origin for Player Detector world-coordinate
- -- APIs: 3dscan <scannerX> <scannerY> <scannerZ>
- local SCRIPT_ARGS = {...}
- local CFG = {
- SCAN_RADIUS = 16, -- radius 16 => -16..16 => 33 cells per axis
- MONITOR_TEXT_SCALE = 0.5,
- -- Camera
- FOV_DEGREES = 60,
- DEFAULT_YAW_DEG = 45,
- DEFAULT_PITCH_DEG = 28,
- DEFAULT_DISTANCE = 58,
- MIN_DISTANCE = 8,
- MAX_DISTANCE = 110,
- YAW_STEP_DEG = 12,
- PITCH_STEP_DEG = 8,
- ZOOM_STEP = 3,
- -- CC terminal characters are taller than they are wide. This compensates
- -- the projection so cubes look closer to cubes on a monitor.
- CHARACTER_ASPECT = 0.66,
- -- Prevent very large monitor walls from doing tens of thousands of raycasts
- -- for every camera movement. Larger displays automatically render in coarse
- -- 2x/3x ray samples and expand each sample over neighbouring cells.
- MAX_RAYS_PER_FRAME = 6500,
- -- Optional exact peripheral names. Leave nil for auto-discovery.
- SCANNER_NAME = nil,
- MONITOR_NAME = nil,
- PLAYER_DETECTOR_NAME = nil,
- -- Live Player Detector overlay. Player locations are refreshed independently
- -- from the Geo Scanner, so moving players do NOT trigger another block scan.
- PLAYER_REFRESH_SECONDS = 0.75,
- PLAYER_NAME_LIMIT = 18,
- -- Advanced Peripherals 0.7 Player Detector returns absolute world x/y/z.
- -- Set this to the GEO SCANNER BLOCK'S world block coordinate so those
- -- coordinates can be converted into this viewer's -16..16 scanner space.
- -- You can either edit this value or run:
- -- 3dscan <scannerX> <scannerY> <scannerZ>
- -- Example: {x = 123, y = 64, z = -456}
- SCANNER_WORLD_ORIGIN = nil,
- -- Newer Player Detector APIs may provide relative f/r/u. If so, the script
- -- can use them without world coordinates when it knows where/facing the
- -- detector is relative to the Geo Scanner. Offset is auto-detected from the
- -- Geo scan when possible. Facing may still need to be supplied.
- -- Valid facing: north, east, south, west.
- PLAYER_DETECTOR_OFFSET = nil, -- e.g. {x = 1, y = 0, z = 0}
- PLAYER_DETECTOR_FACING = nil,
- }
- -- Command-line coordinates override the config origin when supplied.
- if #SCRIPT_ARGS >= 3 then
- local ax, ay, az = tonumber(SCRIPT_ARGS[1]), tonumber(SCRIPT_ARGS[2]), tonumber(SCRIPT_ARGS[3])
- if ax and ay and az then
- CFG.SCANNER_WORLD_ORIGIN = {
- x = math.floor(ax), y = math.floor(ay), z = math.floor(az)
- }
- end
- end
- --------------------------------------------------------------------------
- -- CONSTANTS / BASIC HELPERS
- --------------------------------------------------------------------------
- local R = CFG.SCAN_RADIUS
- local SIDE = R * 2 + 1
- local PLANE = SIDE * SIDE
- local EPS = 1e-7
- local INF = math.huge
- local DEG = math.pi / 180
- local AIR = {
- ["minecraft:air"] = true,
- ["minecraft:cave_air"] = true,
- ["minecraft:void_air"] = true,
- }
- local function clamp(v, lo, hi)
- if v < lo then return lo end
- if v > hi then return hi end
- return v
- end
- local function integer(v)
- return type(v) == "number" and v == v and v % 1 == 0
- end
- local function round(v)
- if v >= 0 then return math.floor(v + 0.5) end
- return math.ceil(v - 0.5)
- end
- local function trunc(text, width)
- text = tostring(text or "")
- if width <= 0 then return "" end
- if #text <= width then return text end
- if width <= 3 then return text:sub(1, width) end
- return text:sub(1, width - 3) .. "..."
- end
- local function padRight(text, width)
- text = trunc(text, width)
- if #text < width then text = text .. string.rep(" ", width - #text) end
- return text
- end
- local function voxelIndex(x, y, z)
- -- Integer key is much cheaper than storing "x:y:z" strings for ~35k cells.
- return (x + R) * PLANE + (y + R) * SIDE + (z + R) + 1
- end
- local function inBounds(x, y, z)
- return x >= -R and x <= R and y >= -R and y <= R and z >= -R and z <= R
- end
- local function vec(x, y, z)
- return {x = x, y = y, z = z}
- end
- local function vadd(a, b)
- return vec(a.x + b.x, a.y + b.y, a.z + b.z)
- end
- local function vsub(a, b)
- return vec(a.x - b.x, a.y - b.y, a.z - b.z)
- end
- local function vmul(a, s)
- return vec(a.x * s, a.y * s, a.z * s)
- end
- local function vdot(a, b)
- return a.x * b.x + a.y * b.y + a.z * b.z
- end
- local function vcross(a, b)
- return vec(
- a.y * b.z - a.z * b.y,
- a.z * b.x - a.x * b.z,
- a.x * b.y - a.y * b.x
- )
- end
- local function vlength(a)
- return math.sqrt(a.x * a.x + a.y * a.y + a.z * a.z)
- end
- local function vnorm(a)
- local len = vlength(a)
- if len < EPS then return vec(0, 0, 0) end
- return vec(a.x / len, a.y / len, a.z / len)
- end
- local function safeYield()
- if sleep then sleep(0) end
- end
- --------------------------------------------------------------------------
- -- COLOUR / BLOCK APPEARANCE
- --------------------------------------------------------------------------
- -- CC's blit encoding. colors.toBlit exists in modern CC:Tweaked, but the
- -- fallback keeps this script portable.
- local BLIT_FALLBACK = {
- [colors.white] = "0",
- [colors.orange] = "1",
- [colors.magenta] = "2",
- [colors.lightBlue] = "3",
- [colors.yellow] = "4",
- [colors.lime] = "5",
- [colors.pink] = "6",
- [colors.gray] = "7",
- [colors.lightGray] = "8",
- [colors.cyan] = "9",
- [colors.purple] = "a",
- [colors.blue] = "b",
- [colors.brown] = "c",
- [colors.green] = "d",
- [colors.red] = "e",
- [colors.black] = "f",
- }
- local function toBlit(colour)
- if colors.toBlit then return colors.toBlit(colour) end
- return BLIT_FALLBACK[colour] or "0"
- end
- local SHADE_FAMILY = {
- [colors.white] = {colors.white, colors.lightGray, colors.gray},
- [colors.orange] = {colors.yellow, colors.orange, colors.brown},
- [colors.magenta] = {colors.pink, colors.magenta, colors.purple},
- [colors.lightBlue] = {colors.white, colors.lightBlue, colors.blue},
- [colors.yellow] = {colors.white, colors.yellow, colors.orange},
- [colors.lime] = {colors.yellow, colors.lime, colors.green},
- [colors.pink] = {colors.white, colors.pink, colors.magenta},
- [colors.gray] = {colors.lightGray, colors.gray, colors.black},
- [colors.lightGray] = {colors.white, colors.lightGray, colors.gray},
- [colors.cyan] = {colors.lightBlue, colors.cyan, colors.blue},
- [colors.purple] = {colors.magenta, colors.purple, colors.gray},
- [colors.blue] = {colors.lightBlue, colors.blue, colors.gray},
- [colors.brown] = {colors.orange, colors.brown, colors.gray},
- [colors.green] = {colors.lime, colors.green, colors.gray},
- [colors.red] = {colors.orange, colors.red, colors.brown},
- [colors.black] = {colors.lightGray, colors.gray, colors.black},
- }
- local UNKNOWN_PALETTE = {
- colors.lightGray, colors.cyan, colors.purple, colors.green,
- colors.brown, colors.orange, colors.blue, colors.magenta,
- }
- local function contains(s, needle)
- return s:find(needle, 1, true) ~= nil
- end
- local function stableUnknownColour(name)
- local hash = 5381
- for i = 1, #name do
- hash = (hash * 33 + name:byte(i)) % 2147483647
- end
- return UNKNOWN_PALETTE[(hash % #UNKNOWN_PALETTE) + 1]
- end
- local function baseColourForBlock(name)
- local n = name:lower()
- -- Fluids / transparent-ish blocks
- if contains(n, "water") then return colors.blue end
- if contains(n, "lava") then return colors.orange end
- if contains(n, "glass") or contains(n, "ice") then return colors.lightBlue end
- -- Valuable / strongly recognisable materials first.
- if contains(n, "diamond") then return colors.cyan end
- if contains(n, "emerald") then return colors.lime end
- if contains(n, "lapis") then return colors.blue end
- if contains(n, "redstone") then return colors.red end
- if contains(n, "gold") then return colors.yellow end
- if contains(n, "copper") then return colors.orange end
- if contains(n, "amethyst") then return colors.purple end
- if contains(n, "quartz") then return colors.white end
- if contains(n, "iron") then return colors.lightGray end
- if contains(n, "coal") then return colors.gray end
- -- Terrain / vegetation.
- if contains(n, "grass") or contains(n, "moss") or contains(n, "leaf")
- or contains(n, "leaves") or contains(n, "vine") or contains(n, "cactus") then
- return colors.green
- end
- if contains(n, "sand") or contains(n, "end_stone") then return colors.yellow end
- if contains(n, "dirt") or contains(n, "mud") or contains(n, "soul_sand")
- or contains(n, "soul_soil") then
- return colors.brown
- end
- -- Wood-like building blocks.
- if contains(n, "log") or contains(n, "wood") or contains(n, "plank")
- or contains(n, "stem") or contains(n, "hyphae") then
- return colors.brown
- end
- -- Nether / dark stone.
- if contains(n, "netherrack") or contains(n, "nether_brick") then return colors.red end
- if contains(n, "deepslate") or contains(n, "blackstone") or contains(n, "basalt")
- or contains(n, "obsidian") then
- return colors.gray
- end
- -- Common stone-like materials.
- if contains(n, "stone") or contains(n, "cobble") or contains(n, "brick")
- or contains(n, "concrete") or contains(n, "terracotta") then
- return colors.lightGray
- end
- return stableUnknownColour(name)
- end
- local LIGHT_DIR = vnorm(vec(-0.45, 0.80, -0.35))
- local function shadedColour(base, normal, distance)
- local family = SHADE_FAMILY[base] or SHADE_FAMILY[colors.lightGray]
- local lambert = math.max(0, vdot(normal, LIGHT_DIR))
- local brightness = 0.35 + lambert * 0.65
- -- Slight atmospheric depth cue.
- local distanceFade = clamp(1.05 - distance / 130, 0.58, 1.0)
- brightness = brightness * distanceFade
- if brightness >= 0.72 then return family[1] end
- if brightness >= 0.43 then return family[2] end
- return family[3]
- end
- --------------------------------------------------------------------------
- -- PERIPHERAL DISCOVERY
- --------------------------------------------------------------------------
- local function peripheralNameOf(wrapped)
- if peripheral.getName then
- local ok, name = pcall(peripheral.getName, wrapped)
- if ok then return name end
- end
- return "unknown"
- end
- local function findScanner()
- local scanner
- if CFG.SCANNER_NAME then
- scanner = peripheral.wrap(CFG.SCANNER_NAME)
- else
- scanner = peripheral.find("geo_scanner")
- if not scanner then scanner = peripheral.find("geoScanner") end
- end
- if not scanner then
- return nil, nil, "No Geo Scanner detected (geo_scanner / geoScanner)."
- end
- local method
- if type(scanner.scan) == "function" then
- method = "scan"
- elseif type(scanner.scanBlocks) == "function" then
- method = "scanBlocks"
- else
- return nil, nil, "Geo Scanner has neither scan(radius) nor scanBlocks(radius)."
- end
- return scanner, method
- end
- local function findPlayerDetector()
- local detector
- if CFG.PLAYER_DETECTOR_NAME then
- detector = peripheral.wrap(CFG.PLAYER_DETECTOR_NAME)
- else
- detector = peripheral.find("player_detector")
- if not detector then detector = peripheral.find("playerDetector") end
- end
- return detector
- end
- local function findMonitor()
- local monitor
- if CFG.MONITOR_NAME then
- monitor = peripheral.wrap(CFG.MONITOR_NAME)
- else
- monitor = peripheral.find("monitor")
- end
- if not monitor then return nil, "No monitor detected." end
- if type(monitor.getSize) ~= "function" or type(monitor.blit) ~= "function" then
- return nil, "Attached monitor does not expose the required terminal methods."
- end
- if type(monitor.setTextScale) == "function" then
- pcall(monitor.setTextScale, CFG.MONITOR_TEXT_SCALE)
- end
- return monitor
- end
- local scanner, scanMethod, scannerErr = findScanner()
- if not scanner then error(scannerErr, 0) end
- local monitor, monitorErr = findMonitor()
- if not monitor then error(monitorErr, 0) end
- local scannerName = peripheralNameOf(scanner)
- local monitorName = peripheralNameOf(monitor)
- local playerDetector = findPlayerDetector()
- local playerDetectorName = playerDetector and peripheralNameOf(playerDetector) or nil
- --------------------------------------------------------------------------
- -- COMPACT VOXEL MODEL
- --------------------------------------------------------------------------
- local model = nil
- local function newModel()
- return {
- cells = {}, -- integer voxel index -> interned name id
- idToName = {}, -- id -> registry name
- nameToId = {}, -- registry name -> id
- idToBaseColour = {}, -- id -> CC colour
- solidCount = 0,
- uniqueBlockTypes = 0,
- returnedRecords = 0,
- invalidRecords = 0,
- outOfRangeRecords = 0,
- airRecords = 0,
- -- Nearest scanned Player Detector block. Useful for newer relative
- -- f/r/u APIs, whose origin is the detector rather than the Geo Scanner.
- playerDetectorBlock = nil,
- minX = R, maxX = -R,
- minY = R, maxY = -R,
- minZ = R, maxZ = -R,
- }
- end
- local function internBlock(m, name)
- local id = m.nameToId[name]
- if id then return id end
- id = #m.idToName + 1
- m.nameToId[name] = id
- m.idToName[id] = name
- m.idToBaseColour[id] = baseColourForBlock(name)
- m.uniqueBlockTypes = id
- return id
- end
- local function chooseRelativeCoordinates(record)
- local x, y, z = record.x, record.y, record.z
- if integer(x) and integer(y) and integer(z)
- and math.abs(x) <= R and math.abs(y) <= R and math.abs(z) <= R then
- return x, y, z
- end
- -- Some Geo Scanner versions expose front/right/up as explicitly relative
- -- coordinates. If x/y/z appear non-relative, use these instead.
- local f, r, u = record.f, record.r, record.u
- if integer(f) and integer(r) and integer(u)
- and math.abs(f) <= R and math.abs(r) <= R and math.abs(u) <= R then
- return r, u, f
- end
- return nil
- end
- local VALID_FACING = {north = true, east = true, south = true, west = true}
- local function scanRecordFacing(record)
- local facing
- if type(record.state) == "table" then facing = record.state.facing end
- if not facing and type(record.properties) == "table" then facing = record.properties.facing end
- if not facing and type(record.blockState) == "table" then facing = record.blockState.facing end
- if type(facing) == "string" then
- facing = facing:lower()
- if VALID_FACING[facing] then return facing end
- end
- return nil
- end
- local function looksLikePlayerDetector(name)
- local n = tostring(name or ""):lower()
- return contains(n, "player_detector") or contains(n, "playerdetector")
- end
- local function buildModel(raw)
- if type(raw) ~= "table" then return nil, "Scanner result is not a table." end
- local m = newModel()
- local work = 0
- for _, record in pairs(raw) do
- m.returnedRecords = m.returnedRecords + 1
- if type(record) ~= "table" or type(record.name) ~= "string" or record.name == "" then
- m.invalidRecords = m.invalidRecords + 1
- else
- local x, y, z = chooseRelativeCoordinates(record)
- if not x then
- m.outOfRangeRecords = m.outOfRangeRecords + 1
- elseif AIR[record.name] then
- m.airRecords = m.airRecords + 1
- else
- local idx = voxelIndex(x, y, z)
- local id = internBlock(m, record.name)
- if looksLikePlayerDetector(record.name) then
- local candidate = {
- x = x, y = y, z = z,
- facing = scanRecordFacing(record),
- distance2 = x * x + y * y + z * z,
- }
- if not m.playerDetectorBlock
- or candidate.distance2 < m.playerDetectorBlock.distance2 then
- m.playerDetectorBlock = candidate
- end
- end
- if not m.cells[idx] then
- m.solidCount = m.solidCount + 1
- end
- m.cells[idx] = id
- if x < m.minX then m.minX = x end
- if x > m.maxX then m.maxX = x end
- if y < m.minY then m.minY = y end
- if y > m.maxY then m.maxY = y end
- if z < m.minZ then m.minZ = z end
- if z > m.maxZ then m.maxZ = z end
- end
- end
- work = work + 1
- if work % 1024 == 0 then safeYield() end
- end
- if m.solidCount == 0 then
- m.minX, m.maxX = 0, 0
- m.minY, m.maxY = 0, 0
- m.minZ, m.maxZ = 0, 0
- end
- return m
- end
- --------------------------------------------------------------------------
- -- CAMERA / VIEW STATE
- --------------------------------------------------------------------------
- local state = {
- yaw = CFG.DEFAULT_YAW_DEG * DEG,
- pitch = CFG.DEFAULT_PITCH_DEG * DEG,
- distance = CFG.DEFAULT_DISTANCE,
- clipY = R,
- target = vec(0, 0, 0),
- selected = nil,
- message = "Ready",
- lastRenderMs = 0,
- lastRayStep = 1,
- players = {},
- playerStatus = playerDetector and "Player Detector ready" or "No Player Detector",
- playerCoordinateMode = nil,
- playerTimer = nil,
- }
- local function resetCamera()
- state.yaw = CFG.DEFAULT_YAW_DEG * DEG
- state.pitch = CFG.DEFAULT_PITCH_DEG * DEG
- state.distance = CFG.DEFAULT_DISTANCE
- state.clipY = R
- state.target = vec(0, 0, 0)
- state.selected = nil
- state.message = "Camera reset"
- end
- local function cameraBasis()
- local cp = math.cos(state.pitch)
- local offset = vec(
- math.sin(state.yaw) * cp * state.distance,
- math.sin(state.pitch) * state.distance,
- math.cos(state.yaw) * cp * state.distance
- )
- local camera = vadd(state.target, offset)
- local forward = vnorm(vsub(state.target, camera))
- local worldUp = vec(0, 1, 0)
- local right = vnorm(vcross(forward, worldUp))
- if vlength(right) < EPS then right = vec(1, 0, 0) end
- local up = vnorm(vcross(right, forward))
- return camera, forward, right, up
- end
- --------------------------------------------------------------------------
- -- VOXEL RAY CASTER
- --------------------------------------------------------------------------
- local function rayBox(origin, direction, minB, maxB)
- local tMin = -INF
- local tMax = INF
- local enterNormal = vec(0, 1, 0)
- local function axis(o, d, mn, mx, nx, ny, nz)
- if math.abs(d) < EPS then
- if o < mn or o > mx then return false end
- return true
- end
- local t1 = (mn - o) / d
- local t2 = (mx - o) / d
- local n1 = vec(nx, ny, nz)
- local n2 = vec(-nx, -ny, -nz)
- if t1 > t2 then
- t1, t2 = t2, t1
- n1, n2 = n2, n1
- end
- if t1 > tMin then
- tMin = t1
- enterNormal = n1
- end
- if t2 < tMax then tMax = t2 end
- if tMin > tMax then return false end
- return true
- end
- -- At the minimum plane the outward normal points negative on that axis.
- if not axis(origin.x, direction.x, minB.x, maxB.x, -1, 0, 0) then return nil end
- if not axis(origin.y, direction.y, minB.y, maxB.y, 0, -1, 0) then return nil end
- if not axis(origin.z, direction.z, minB.z, maxB.z, 0, 0, -1) then return nil end
- if tMax < 0 then return nil end
- if tMin < 0 then
- tMin = 0
- enterNormal = vmul(direction, -1)
- enterNormal = vnorm(enterNormal)
- end
- return tMin, tMax, enterNormal
- end
- local function raycast(origin, direction)
- if not model or model.solidCount == 0 then return nil end
- -- The cut plane removes every voxel above clipY from the raycast volume.
- local minB = vec(-R - 0.5, -R - 0.5, -R - 0.5)
- local maxB = vec( R + 0.5, state.clipY + 0.5, R + 0.5)
- if maxB.y < minB.y then return nil end
- local tEnter, tExit, entryNormal = rayBox(origin, direction, minB, maxB)
- if not tEnter then return nil end
- local startT = tEnter + 1e-6
- local px = origin.x + direction.x * startT
- local py = origin.y + direction.y * startT
- local pz = origin.z + direction.z * startT
- local vx = math.floor(px + 0.5)
- local vy = math.floor(py + 0.5)
- local vz = math.floor(pz + 0.5)
- vx = clamp(vx, -R, R)
- vy = clamp(vy, -R, state.clipY)
- vz = clamp(vz, -R, R)
- local stepX = direction.x >= 0 and 1 or -1
- local stepY = direction.y >= 0 and 1 or -1
- local stepZ = direction.z >= 0 and 1 or -1
- local nextX = vx + (stepX > 0 and 0.5 or -0.5)
- local nextY = vy + (stepY > 0 and 0.5 or -0.5)
- local nextZ = vz + (stepZ > 0 and 0.5 or -0.5)
- local tMaxX = math.abs(direction.x) < EPS and INF or (nextX - origin.x) / direction.x
- local tMaxY = math.abs(direction.y) < EPS and INF or (nextY - origin.y) / direction.y
- local tMaxZ = math.abs(direction.z) < EPS and INF or (nextZ - origin.z) / direction.z
- local tDeltaX = math.abs(direction.x) < EPS and INF or math.abs(1 / direction.x)
- local tDeltaY = math.abs(direction.y) < EPS and INF or math.abs(1 / direction.y)
- local tDeltaZ = math.abs(direction.z) < EPS and INF or math.abs(1 / direction.z)
- local t = tEnter
- local normal = entryNormal
- local maxSteps = SIDE * 3 + 12
- for _ = 1, maxSteps do
- if t > tExit + EPS then break end
- if inBounds(vx, vy, vz) and vy <= state.clipY then
- local id = model.cells[voxelIndex(vx, vy, vz)]
- if id then
- return {
- id = id,
- x = vx, y = vy, z = vz,
- distance = math.max(0, t),
- nx = normal.x, ny = normal.y, nz = normal.z,
- }
- end
- end
- if tMaxX <= tMaxY and tMaxX <= tMaxZ then
- vx = vx + stepX
- t = tMaxX
- tMaxX = tMaxX + tDeltaX
- normal = vec(-stepX, 0, 0)
- elseif tMaxY <= tMaxX and tMaxY <= tMaxZ then
- vy = vy + stepY
- t = tMaxY
- tMaxY = tMaxY + tDeltaY
- normal = vec(0, -stepY, 0)
- else
- vz = vz + stepZ
- t = tMaxZ
- tMaxZ = tMaxZ + tDeltaZ
- normal = vec(0, 0, -stepZ)
- end
- if vx < -R or vx > R or vy < -R or vy > state.clipY or vz < -R or vz > R then
- -- It may have crossed one boundary slightly before tExit due to
- -- floating point, so there is no useful voxel left to visit.
- if t >= tExit - EPS then break end
- end
- end
- return nil
- end
- local function screenRay(screenX, screenY, viewW, viewH, camera, forward, right, up)
- local tanHalf = math.tan((CFG.FOV_DEGREES * DEG) * 0.5)
- local aspect = (viewW / math.max(1, viewH)) * CFG.CHARACTER_ASPECT
- local nx = ((screenX - 0.5) / viewW) * 2 - 1
- local ny = 1 - ((screenY - 0.5) / viewH) * 2
- local horizontal = nx * tanHalf * aspect
- local vertical = ny * tanHalf
- local direction = vnorm(vadd(forward, vadd(vmul(right, horizontal), vmul(up, vertical))))
- return camera, direction
- end
- --------------------------------------------------------------------------
- -- MONITOR DRAWING
- --------------------------------------------------------------------------
- local buttonHitboxes = {}
- local VIEW_TOP = 3
- local lastViewW, lastViewH = 1, 1
- local function monitorSize()
- local w, h = monitor.getSize()
- return math.max(1, w), math.max(1, h)
- end
- local function fillLine(row, text, fg, bg)
- local w = monitorSize()
- text = padRight(text, w)
- local f = string.rep(toBlit(fg or colors.white), w)
- local b = string.rep(toBlit(bg or colors.black), w)
- monitor.setCursorPos(1, row)
- monitor.blit(text, f, b)
- end
- local function splash(title, detail)
- local w, h = monitorSize()
- monitor.setBackgroundColor(colors.black)
- monitor.setTextColor(colors.white)
- monitor.clear()
- local y = math.max(1, math.floor(h / 2) - 1)
- fillLine(y, title or "GeoVoxel 3D", colors.white, colors.black)
- if detail and y + 1 <= h then
- fillLine(y + 1, detail, colors.lightGray, colors.black)
- end
- end
- --------------------------------------------------------------------------
- -- PLAYER DETECTOR / LIVE ENTITY OVERLAY
- --------------------------------------------------------------------------
- local function detectorCall(method, ...)
- if not playerDetector or type(playerDetector[method]) ~= "function" then
- return nil, "unsupported method " .. tostring(method)
- end
- local ok, value, detail = pcall(playerDetector[method], ...)
- if not ok then return nil, tostring(value) end
- if value == nil or value == false then return nil, tostring(detail or "no result") end
- return value
- end
- local function normalisePlayerNames(values)
- local result, seen = {}, {}
- if type(values) ~= "table" then return result end
- for k, v in pairs(values) do
- local name
- if type(v) == "string" then
- name = v
- elseif type(k) == "string" and v == true then
- name = k
- elseif type(v) == "table" then
- name = v.name or v.username
- end
- if type(name) == "string" and name ~= "" and not seen[name] then
- seen[name] = true
- result[#result + 1] = name
- end
- end
- table.sort(result, function(a, b) return a:lower() < b:lower() end)
- return result
- end
- local function detectorPlayerNames()
- -- Prefer a generous local cube so players in other dimensions or very far
- -- away are not queried unnecessarily. If server config rejects this size,
- -- fall back to the online-player list and exact-filter after conversion.
- if playerDetector and type(playerDetector.getPlayersInCubic) == "function" then
- local values = detectorCall("getPlayersInCubic", SIDE + 8, SIDE + 8, SIDE + 8)
- if values then return normalisePlayerNames(values) end
- end
- if playerDetector and type(playerDetector.getOnlinePlayers) == "function" then
- local values, reason = detectorCall("getOnlinePlayers")
- if values then return normalisePlayerNames(values) end
- return nil, reason
- end
- return nil, "Player Detector has no player-list method"
- end
- local function detectorPlayerInfo(name)
- if type(playerDetector.getPlayer) == "function" then
- local value = detectorCall("getPlayer", name)
- if type(value) == "table" then return value end
- end
- if type(playerDetector.getPlayerPos) == "function" then
- local value, reason = detectorCall("getPlayerPos", name)
- if type(value) == "table" then return value end
- return nil, reason
- end
- return nil, "Player Detector has neither getPlayer nor getPlayerPos"
- end
- local function detectorOffset()
- if type(CFG.PLAYER_DETECTOR_OFFSET) == "table" then
- local o = CFG.PLAYER_DETECTOR_OFFSET
- if type(o.x) == "number" and type(o.y) == "number" and type(o.z) == "number" then
- return o
- end
- end
- if model and model.playerDetectorBlock then return model.playerDetectorBlock end
- return nil
- end
- local function detectorFacing()
- local facing = CFG.PLAYER_DETECTOR_FACING
- if type(facing) == "string" then
- facing = facing:lower()
- if VALID_FACING[facing] then return facing end
- end
- if model and model.playerDetectorBlock and VALID_FACING[model.playerDetectorBlock.facing] then
- return model.playerDetectorBlock.facing
- end
- return nil
- end
- local FRONT = {
- north = {x = 0, z = -1}, east = {x = 1, z = 0},
- south = {x = 0, z = 1}, west = {x = -1, z = 0},
- }
- local RIGHT = {
- north = {x = 1, z = 0}, east = {x = 0, z = 1},
- south = {x = -1, z = 0}, west = {x = 0, z = -1},
- }
- local function playerScannerCoordinates(info)
- -- Most AP 0.7 builds expose absolute world x/y/z. The configured scanner
- -- block coordinate is therefore the cleanest and most exact conversion.
- local origin = CFG.SCANNER_WORLD_ORIGIN
- if type(origin) == "table"
- and type(info.x) == "number" and type(info.y) == "number" and type(info.z) == "number"
- and type(origin.x) == "number" and type(origin.y) == "number" and type(origin.z) == "number" then
- -- Voxel 0,0,0 is the scanner block centre. Entity coordinates are
- -- continuous world coordinates, so subtract the scanner centre on all axes.
- return info.x - (origin.x + 0.5),
- info.y - (origin.y + 0.5),
- info.z - (origin.z + 0.5),
- "world"
- end
- -- AP 0.8+ can expose f/r/u relative to the Player Detector. Convert that
- -- basis to the Geo Scanner's world-axis-relative voxel basis.
- if type(info.f) == "number" and type(info.r) == "number" and type(info.u) == "number" then
- local offset, facing = detectorOffset(), detectorFacing()
- if offset and facing then
- local f, r = FRONT[facing], RIGHT[facing]
- return offset.x + f.x * info.f + r.x * info.r,
- offset.y + info.u,
- offset.z + f.z * info.f + r.z * info.r,
- "relative"
- end
- return nil, nil, nil, nil,
- "relative player coordinates available, but detector offset/facing is unknown"
- end
- if type(info.x) == "number" and type(info.y) == "number" and type(info.z) == "number" then
- return nil, nil, nil, nil,
- "set SCANNER_WORLD_ORIGIN or run: 3dscan <scannerX> <scannerY> <scannerZ>"
- end
- return nil, nil, nil, nil, "player position fields unavailable"
- end
- local function insideScannerCube(x, y, z)
- -- A 33-cell voxel span -16..16 has geometric faces at +/-16.5.
- local edge = R + 0.5
- return x >= -edge and x <= edge
- and y >= -edge and y <= edge
- and z >= -edge and z <= edge
- end
- local function refreshPlayers()
- if not playerDetector then
- state.players = {}
- state.playerStatus = "No Player Detector"
- state.playerCoordinateMode = nil
- return false
- end
- local names, listReason = detectorPlayerNames()
- if not names then
- state.players = {}
- state.playerStatus = "PD error: " .. trunc(listReason, 50)
- state.playerCoordinateMode = nil
- return false
- end
- local players, firstReason, mode = {}, nil, nil
- for _, name in ipairs(names) do
- local info, infoReason = detectorPlayerInfo(name)
- if info then
- local x, y, z, coordinateMode, coordinateReason = playerScannerCoordinates(info)
- if x and insideScannerCube(x, y, z) then
- local eyeHeight = tonumber(info.eyeHeight) or 1.62
- players[#players + 1] = {
- name = tostring(info.name or name),
- x = x, y = y, z = z,
- eyeHeight = clamp(eyeHeight, 0.5, 3.5),
- yaw = tonumber(info.yaw), pitch = tonumber(info.pitch),
- }
- mode = mode or coordinateMode
- elseif not x and not firstReason then
- firstReason = coordinateReason or infoReason
- end
- elseif not firstReason then
- firstReason = infoReason
- end
- end
- state.players = players
- state.playerCoordinateMode = mode
- if #players > 0 then
- state.playerStatus = ("Players: %d (%s)"):format(#players, mode or "?")
- elseif firstReason then
- state.playerStatus = "PD: " .. trunc(firstReason, 64)
- else
- state.playerStatus = "Players: 0 in scan cube"
- end
- return true
- end
- local function projectPoint(point, viewW, viewH, camera, forward, right, up)
- local rel = vsub(point, camera)
- local depth = vdot(rel, forward)
- if depth <= 0.05 then return nil end
- local tanHalf = math.tan((CFG.FOV_DEGREES * DEG) * 0.5)
- local aspect = (viewW / math.max(1, viewH)) * CFG.CHARACTER_ASPECT
- local halfX = tanHalf * aspect
- local nx = vdot(rel, right) / (depth * halfX)
- local ny = vdot(rel, up) / (depth * tanHalf)
- if nx < -1.05 or nx > 1.05 or ny < -1.05 or ny > 1.05 then return nil end
- local sx = math.floor(((nx + 1) * 0.5) * viewW + 0.5)
- local sy = math.floor(((1 - ny) * 0.5) * viewH + 0.5)
- if sx < 1 or sx > viewW or sy < 1 or sy > viewH then return nil end
- return sx, sy, depth
- end
- local function clippedWrite(x, y, text, fg, bg, minY, maxY)
- local w = monitorSize()
- text = tostring(text or "")
- if text == "" or y < minY or y > maxY or x > w or x + #text - 1 < 1 then return end
- if x < 1 then
- text = text:sub(2 - x)
- x = 1
- end
- if x + #text - 1 > w then text = text:sub(1, w - x + 1) end
- if text == "" then return end
- monitor.setCursorPos(x, y)
- monitor.setTextColor(fg or colors.white)
- monitor.setBackgroundColor(bg or colors.black)
- monitor.write(text)
- end
- local function drawPlayerOverlay(viewW, viewH, camera, forward, right, up)
- if not state.players or #state.players == 0 then return end
- local projected = {}
- for _, player in ipairs(state.players) do
- -- Put the dot around upper torso/head height while preserving the exact
- -- feet coordinate in state for filtering and future extensions.
- local marker = vec(player.x, player.y + math.min(player.eyeHeight * 0.72, 1.35), player.z)
- local sx, sy, depth = projectPoint(marker, viewW, viewH, camera, forward, right, up)
- if sx then
- projected[#projected + 1] = {player = player, x = sx, y = sy, depth = depth}
- end
- end
- -- Draw far players first, so closer markers/names win overlaps.
- table.sort(projected, function(a, b) return a.depth > b.depth end)
- local occupied = {}
- local minRow, maxRow = VIEW_TOP, VIEW_TOP + viewH - 1
- local function rowFree(row, x1, x2)
- if row < minRow or row > maxRow then return false end
- occupied[row] = occupied[row] or {}
- for x = x1, x2 do
- if occupied[row][x] then return false end
- end
- return true
- end
- local function markRow(row, x1, x2)
- occupied[row] = occupied[row] or {}
- for x = x1, x2 do occupied[row][x] = true end
- end
- for _, p in ipairs(projected) do
- local screenRow = VIEW_TOP + p.y - 1
- clippedWrite(p.x, screenRow, "o", colors.lime, colors.black, minRow, maxRow)
- markRow(screenRow, p.x, p.x)
- local name = trunc(p.player.name, CFG.PLAYER_NAME_LIMIT)
- local nameX = p.x - math.floor(#name / 2)
- nameX = clamp(nameX, 1, math.max(1, viewW - #name + 1))
- local nameX2 = math.min(viewW, nameX + #name - 1)
- -- User requested the name under the dot. If that row collides or falls
- -- off-screen, try one lower, then above as a final fallback.
- local candidates = {screenRow + 1, screenRow + 2, screenRow - 1, screenRow - 2}
- local labelRow
- for _, row in ipairs(candidates) do
- if rowFree(row, nameX, nameX2) then labelRow = row; break end
- end
- if labelRow then
- clippedWrite(nameX, labelRow, name, colors.white, colors.black, minRow, maxRow)
- markRow(labelRow, nameX, nameX2)
- end
- end
- end
- local function armPlayerTimer()
- if state.playerTimer and os.cancelTimer then
- pcall(os.cancelTimer, state.playerTimer)
- state.playerTimer = nil
- end
- if playerDetector and os.startTimer and CFG.PLAYER_REFRESH_SECONDS > 0 then
- state.playerTimer = os.startTimer(CFG.PLAYER_REFRESH_SECONDS)
- end
- end
- local function drawButton(row, x, label, action, bg)
- local w = monitorSize()
- if x > w then return x end
- local text = " " .. label .. " "
- if x + #text - 1 > w then return x end
- monitor.setCursorPos(x, row)
- monitor.setBackgroundColor(bg or colors.gray)
- monitor.setTextColor(colors.white)
- monitor.write(text)
- buttonHitboxes[#buttonHitboxes + 1] = {
- x1 = x, x2 = x + #text - 1,
- y1 = row, y2 = row,
- action = action,
- }
- return x + #text + 1
- end
- local function drawControls(w, h)
- buttonHitboxes = {}
- if h < 2 then return end
- monitor.setBackgroundColor(colors.black)
- monitor.setTextColor(colors.white)
- local row1 = h - 1
- local row2 = h
- fillLine(row1, "", colors.white, colors.black)
- fillLine(row2, "", colors.white, colors.black)
- local x = 1
- x = drawButton(row1, x, "<", "yaw_left", colors.gray)
- x = drawButton(row1, x, ">", "yaw_right", colors.gray)
- x = drawButton(row1, x, "^", "pitch_up", colors.gray)
- x = drawButton(row1, x, "v", "pitch_down", colors.gray)
- x = drawButton(row1, x, "-", "zoom_out", colors.gray)
- x = drawButton(row1, x, "+", "zoom_in", colors.gray)
- x = 1
- x = drawButton(row2, x, "Y-", "clip_down", colors.brown)
- x = drawButton(row2, x, "Y+", "clip_up", colors.brown)
- x = drawButton(row2, x, "C", "reset", colors.blue)
- x = drawButton(row2, x, "SCAN", "scan", colors.green)
- end
- local function hitEqualsSelection(hit)
- local s = state.selected
- return s and hit and s.x == hit.x and s.y == hit.y and s.z == hit.z
- end
- local function pixelForHit(hit)
- if not hit then
- return " ", toBlit(colors.black), toBlit(colors.black)
- end
- local base = model.idToBaseColour[hit.id] or colors.lightGray
- local normal = vec(hit.nx, hit.ny, hit.nz)
- local shade = shadedColour(base, normal, hit.distance)
- local char = " "
- local fg = shade
- if hitEqualsSelection(hit) then
- char = "@"
- fg = colors.white
- elseif hit.x == 0 and hit.y == 0 and hit.z == 0 then
- char = "S"
- fg = colors.white
- end
- return char, toBlit(fg), toBlit(shade)
- end
- local function renderHeader(w)
- local yawDeg = (state.yaw / DEG) % 360
- if yawDeg < 0 then yawDeg = yawDeg + 360 end
- local pitchDeg = state.pitch / DEG
- local line1
- if model then
- line1 = ("GeoVoxel R%d solid=%d types=%d players=%d yaw=%03d pitch=%d zoom=%d cutY=%d"):format(
- R, model.solidCount, model.uniqueBlockTypes, #(state.players or {}),
- round(yawDeg), round(pitchDeg), round(state.distance), state.clipY
- )
- else
- line1 = "GeoVoxel 3D - no model"
- end
- fillLine(1, line1, colors.white, colors.black)
- local line2
- if state.selected and model then
- line2 = ("@ %d,%d,%d %s"):format(
- state.selected.x, state.selected.y, state.selected.z,
- model.idToName[state.selected.id] or "unknown"
- )
- else
- line2 = state.message or ""
- if state.playerStatus and state.playerStatus ~= "" then
- if line2 ~= "" then line2 = line2 .. " | " end
- line2 = line2 .. state.playerStatus
- end
- end
- if state.lastRayStep > 1 then
- line2 = line2 .. (" render=%dx coarse"):format(state.lastRayStep)
- end
- if state.lastRenderMs and state.lastRenderMs > 0 then
- line2 = line2 .. (" %dms"):format(state.lastRenderMs)
- end
- fillLine(2, line2, colors.lightGray, colors.black)
- end
- local function render()
- local w, h = monitorSize()
- local viewBottom = h - 2
- local viewH = viewBottom - VIEW_TOP + 1
- local viewW = w
- monitor.setBackgroundColor(colors.black)
- monitor.setTextColor(colors.white)
- monitor.clear()
- if viewH < 2 or viewW < 4 then
- fillLine(1, "Monitor too small", colors.red, colors.black)
- if h >= 2 then fillLine(2, "Increase monitor size", colors.white, colors.black) end
- return
- end
- lastViewW, lastViewH = viewW, viewH
- local camera, forward, right, up = cameraBasis()
- local area = viewW * viewH
- local rayStep = math.max(1, math.ceil(math.sqrt(area / CFG.MAX_RAYS_PER_FRAME)))
- state.lastRayStep = rayStep
- local startMs = os.epoch and os.epoch("utc") or nil
- -- Each cache row contains one ray result for every rayStep-wide group.
- -- The same ray row is reused across rayStep monitor rows.
- local cachedPixels = nil
- for sy = 1, viewH do
- if not cachedPixels or ((sy - 1) % rayStep == 0) then
- cachedPixels = {}
- local sampleY = math.min(viewH, sy + math.floor((rayStep - 1) / 2))
- local group = 0
- for sx = 1, viewW, rayStep do
- group = group + 1
- local sampleX = math.min(viewW, sx + math.floor((rayStep - 1) / 2))
- local origin, direction = screenRay(
- sampleX, sampleY, viewW, viewH,
- camera, forward, right, up
- )
- local hit = raycast(origin, direction)
- local ch, fg, bg = pixelForHit(hit)
- cachedPixels[group] = {ch = ch, fg = fg, bg = bg}
- end
- end
- local chars, fgs, bgs = {}, {}, {}
- for sx = 1, viewW do
- local group = math.floor((sx - 1) / rayStep) + 1
- local p = cachedPixels[group]
- chars[sx], fgs[sx], bgs[sx] = p.ch, p.fg, p.bg
- end
- monitor.setCursorPos(1, VIEW_TOP + sy - 1)
- monitor.blit(table.concat(chars), table.concat(fgs), table.concat(bgs))
- if sy % 4 == 0 then safeYield() end
- end
- if startMs and os.epoch then
- state.lastRenderMs = math.max(0, os.epoch("utc") - startMs)
- else
- state.lastRenderMs = 0
- end
- -- Player markers intentionally draw over walls/roofs: this is a tracker
- -- overlay, so a player remains visible even when the voxel ray hits a wall.
- drawPlayerOverlay(viewW, viewH, camera, forward, right, up)
- renderHeader(w)
- drawControls(w, h)
- armPlayerTimer()
- monitor.setBackgroundColor(colors.black)
- monitor.setTextColor(colors.white)
- end
- --------------------------------------------------------------------------
- -- SCANNING
- --------------------------------------------------------------------------
- local function scannerCostText()
- if type(scanner.cost) == "function" then
- local ok, value = pcall(scanner.cost, R)
- if ok and type(value) == "number" then return tostring(value) end
- end
- if type(scanner.getScanCost) == "function" then
- local ok, value = pcall(scanner.getScanCost, R)
- if ok and type(value) == "number" then return tostring(value) end
- end
- if type(scanner.getEnergyCost) == "function" then
- local ok, value = pcall(scanner.getEnergyCost, R)
- if ok and type(value) == "number" then return tostring(value) end
- end
- return nil
- end
- local function waitForScannerCooldown()
- local remaining
- if type(scanner.getScanCooldown) == "function" then
- local ok, value = pcall(scanner.getScanCooldown)
- if ok and type(value) == "number" and value > 0 and value <= 60000 then
- remaining = value
- end
- elseif type(scanner.getOperationCooldown) == "function" then
- local ok, value = pcall(scanner.getOperationCooldown, "scan")
- if ok and type(value) == "number" and value > 0 and value <= 60000 then
- remaining = value
- end
- end
- if remaining and remaining > 0 then
- splash("GeoVoxel 3D", ("Scanner cooldown: %.2fs"):format(remaining / 1000))
- sleep(remaining / 1000)
- end
- end
- local function performScan()
- local cost = scannerCostText()
- local detail = ("radius=%d method=%s"):format(R, scanMethod)
- if cost then detail = detail .. " cost=" .. cost end
- splash("Scanning Geo Scanner...", detail)
- print(("[GeoVoxel] Scanning radius %d with %s on %s"):format(R, scanMethod, scannerName))
- waitForScannerCooldown()
- local ok, raw, reason = pcall(scanner[scanMethod], R)
- if not ok then
- return false, "Geo Scanner call crashed: " .. tostring(raw)
- end
- if raw == nil or raw == false then
- return false, "Geo Scanner rejected scan: " .. tostring(reason or "unknown reason")
- end
- if type(raw) ~= "table" then
- return false, "Geo Scanner returned unexpected type: " .. type(raw)
- end
- splash("Building voxel model...", "Normalising block coordinates")
- local candidate, buildReason = buildModel(raw)
- -- Drop the large raw scan table. CC:Tweaked/CraftOS does not expose
- -- Lua's collectgarbage() global, and normal GC will reclaim it automatically.
- raw = nil
- if not candidate then return false, buildReason end
- model = candidate
- state.selected = nil
- state.clipY = R
- state.message = ("Scan OK: %d solid, %d types, %d returned"):format(
- model.solidCount, model.uniqueBlockTypes, model.returnedRecords
- )
- if model.outOfRangeRecords > 0 or model.invalidRecords > 0 then
- state.message = state.message .. (" skipped=%d"):format(
- model.outOfRangeRecords + model.invalidRecords
- )
- end
- print(("[GeoVoxel] Scan complete: %d solid blocks, %d unique block types, %d records")
- :format(model.solidCount, model.uniqueBlockTypes, model.returnedRecords))
- print(("[GeoVoxel] Bounds: x=%d..%d y=%d..%d z=%d..%d")
- :format(model.minX, model.maxX, model.minY, model.maxY, model.minZ, model.maxZ))
- if model.outOfRangeRecords > 0 then
- print(("[GeoVoxel] Warning: %d records had coordinates outside -%d..%d and were skipped.")
- :format(model.outOfRangeRecords, R, R))
- end
- if playerDetector then refreshPlayers() end
- return true
- end
- --------------------------------------------------------------------------
- -- INPUT / ACTIONS
- --------------------------------------------------------------------------
- local running = true
- local function applyAction(action)
- if action == "yaw_left" then
- state.yaw = state.yaw - CFG.YAW_STEP_DEG * DEG
- state.message = "Rotate left"
- elseif action == "yaw_right" then
- state.yaw = state.yaw + CFG.YAW_STEP_DEG * DEG
- state.message = "Rotate right"
- elseif action == "pitch_up" then
- state.pitch = clamp(
- state.pitch + CFG.PITCH_STEP_DEG * DEG,
- -80 * DEG, 80 * DEG
- )
- state.message = "Pitch up"
- elseif action == "pitch_down" then
- state.pitch = clamp(
- state.pitch - CFG.PITCH_STEP_DEG * DEG,
- -80 * DEG, 80 * DEG
- )
- state.message = "Pitch down"
- elseif action == "zoom_in" then
- state.distance = clamp(
- state.distance - CFG.ZOOM_STEP,
- CFG.MIN_DISTANCE, CFG.MAX_DISTANCE
- )
- state.message = "Zoom in"
- elseif action == "zoom_out" then
- state.distance = clamp(
- state.distance + CFG.ZOOM_STEP,
- CFG.MIN_DISTANCE, CFG.MAX_DISTANCE
- )
- state.message = "Zoom out"
- elseif action == "clip_down" then
- state.clipY = clamp(state.clipY - 1, -R, R)
- state.message = "Y cut plane lowered to " .. state.clipY
- elseif action == "clip_up" then
- state.clipY = clamp(state.clipY + 1, -R, R)
- state.message = "Y cut plane raised to " .. state.clipY
- elseif action == "reset" then
- resetCamera()
- elseif action == "scan" then
- local ok, reason = performScan()
- if not ok then
- state.message = "SCAN FAILED: " .. tostring(reason)
- print("[GeoVoxel] " .. state.message)
- end
- elseif action == "quit" then
- running = false
- return
- end
- render()
- end
- local function touchAction(x, y)
- for _, box in ipairs(buttonHitboxes) do
- if x >= box.x1 and x <= box.x2 and y >= box.y1 and y <= box.y2 then
- return box.action
- end
- end
- return nil
- end
- local function inspectAt(screenX, monitorY)
- local sy = monitorY - VIEW_TOP + 1
- if sy < 1 or sy > lastViewH or screenX < 1 or screenX > lastViewW then return false end
- local camera, forward, right, up = cameraBasis()
- local origin, direction = screenRay(
- screenX, sy, lastViewW, lastViewH,
- camera, forward, right, up
- )
- local hit = raycast(origin, direction)
- if hit then
- state.selected = {x = hit.x, y = hit.y, z = hit.z, id = hit.id}
- state.message = ("Selected %d,%d,%d"):format(hit.x, hit.y, hit.z)
- else
- state.selected = nil
- state.message = "No block at touched ray"
- end
- render()
- return true
- end
- local function handleKey(code)
- if code == keys.left then applyAction("yaw_left"); return true end
- if code == keys.right then applyAction("yaw_right"); return true end
- if code == keys.up then applyAction("pitch_up"); return true end
- if code == keys.down then applyAction("pitch_down"); return true end
- return false
- end
- local function handleChar(ch)
- ch = ch:lower()
- if ch == "a" then applyAction("yaw_left")
- elseif ch == "d" then applyAction("yaw_right")
- elseif ch == "w" then applyAction("pitch_up")
- elseif ch == "s" then applyAction("pitch_down")
- elseif ch == "+" or ch == "=" then applyAction("zoom_in")
- elseif ch == "-" or ch == "_" then applyAction("zoom_out")
- elseif ch == "[" then applyAction("clip_down")
- elseif ch == "]" then applyAction("clip_up")
- elseif ch == "r" then applyAction("scan")
- elseif ch == "c" then applyAction("reset")
- elseif ch == "q" then applyAction("quit")
- else return false end
- return true
- end
- --------------------------------------------------------------------------
- -- STARTUP / EVENT LOOP
- --------------------------------------------------------------------------
- term.setBackgroundColor(colors.black)
- term.setTextColor(colors.white)
- term.clear()
- term.setCursorPos(1, 1)
- print("GeoVoxel 3D")
- print("===========")
- print("Scanner: " .. scannerName .. " method=" .. scanMethod)
- print("Monitor: " .. monitorName)
- print("Player Detector: " .. (playerDetectorName or "not detected"))
- print("Radius: " .. R .. " (-" .. R .. ".." .. R .. ", " .. SIDE .. " per axis)")
- if CFG.SCANNER_WORLD_ORIGIN then
- local o = CFG.SCANNER_WORLD_ORIGIN
- print(("Scanner world origin: %s,%s,%s"):format(tostring(o.x), tostring(o.y), tostring(o.z)))
- elseif playerDetector then
- print("Scanner world origin: unset (AP 0.7 player overlay may need: 3dscan X Y Z)")
- end
- print("")
- print("Controls:")
- print(" arrows / WASD rotate + pitch")
- print(" +/- zoom")
- print(" [ ] Y cut plane")
- print(" R rescan")
- print(" C reset camera")
- print(" Q quit")
- print("")
- local ok, startupReason = performScan()
- if not ok then
- state.message = "SCAN FAILED: " .. tostring(startupReason)
- print("[GeoVoxel] " .. state.message)
- splash("SCAN FAILED", tostring(startupReason))
- print("Press R to retry or Q to quit.")
- else
- render()
- end
- while running do
- local event, a, b, c = os.pullEvent()
- if event == "monitor_touch" then
- local touchedMonitor, x, y = a, b, c
- -- If we know the monitor name, ignore touches from other monitors.
- if monitorName == "unknown" or touchedMonitor == monitorName then
- local action = touchAction(x, y)
- if action then
- applyAction(action)
- else
- inspectAt(x, y)
- end
- end
- elseif event == "key" then
- handleKey(a)
- elseif event == "char" then
- handleChar(a)
- elseif event == "timer" then
- if state.playerTimer and a == state.playerTimer then
- state.playerTimer = nil
- refreshPlayers()
- render()
- end
- elseif event == "monitor_resize" then
- -- Re-apply the requested text scale if another process changed it.
- if type(monitor.setTextScale) == "function" then
- pcall(monitor.setTextScale, CFG.MONITOR_TEXT_SCALE)
- end
- render()
- elseif event == "peripheral_detach" then
- if a == scannerName then
- state.message = "Geo Scanner detached"
- render()
- elseif playerDetectorName and a == playerDetectorName then
- playerDetector = nil
- playerDetectorName = nil
- state.players = {}
- state.playerStatus = "Player Detector detached"
- state.playerTimer = nil
- render()
- elseif a == monitorName then
- print("[GeoVoxel] Monitor detached. Exiting.")
- running = false
- end
- elseif event == "peripheral" then
- if not playerDetector then
- local found = findPlayerDetector()
- if found then
- playerDetector = found
- playerDetectorName = peripheralNameOf(found)
- state.playerStatus = "Player Detector attached"
- refreshPlayers()
- render()
- end
- end
- end
- end
- monitor.setBackgroundColor(colors.black)
- monitor.setTextColor(colors.white)
- monitor.clear()
- monitor.setCursorPos(1, 1)
- monitor.write("GeoVoxel 3D stopped")
- term.setBackgroundColor(colors.black)
- term.setTextColor(colors.white)
- term.setCursorPos(1, select(2, term.getSize()))
- print("")
- print("GeoVoxel 3D stopped.")
Advertisement
Add Comment
Please, Sign In to add comment