Redo file structure, fix unpinnable doors

- Use global to hold sub-modules
- Move state calculation to state.lua
- Do manual overriding of on_rightclick functions
- Call default right-click impl when holding pin on door
main
copygirl 1 year ago
parent b0fc98b841
commit 04ca1950d7
  1. 8
      hud.lua
  2. 179
      init.lua
  3. 57
      registry.lua
  4. 34
      rotate.lua
  5. 83
      state.lua
  6. 18
      utility.lua

@ -6,9 +6,11 @@ local LABEL_ROTATION_HINT = "rotation hint"
local TEX_ROTATE_FACE = "nc_extended_rotating_hud_rotate_face.png" local TEX_ROTATE_FACE = "nc_extended_rotating_hud_rotate_face.png"
local TEX_ROTATE_EDGE = "nc_extended_rotating_hud_rotate_edge.png" local TEX_ROTATE_EDGE = "nc_extended_rotating_hud_rotate_edge.png"
local hud = {} local hud = {}
local function crosshair_hud_element(texture) function hud.update_player_hud(player, state)
local function crosshair_hud_element(texture)
return { return {
label = LABEL_ROTATION_HINT, label = LABEL_ROTATION_HINT,
hud_elem_type = "image", hud_elem_type = "image",
@ -19,9 +21,8 @@ local function crosshair_hud_element(texture)
scale = { x = 1, y = 1 }, scale = { x = 1, y = 1 },
quick = true quick = true
} }
end end
local function update_player_hud(player, state)
local mode = state and state.mode local mode = state and state.mode
if mode == "face" then if mode == "face" then
local texture = TEX_ROTATE_FACE local texture = TEX_ROTATE_FACE
@ -32,6 +33,5 @@ local function update_player_hud(player, state)
nodecore.hud_set(player, { label = LABEL_ROTATION_HINT, ttl = 0 }) nodecore.hud_set(player, { label = LABEL_ROTATION_HINT, ttl = 0 })
end end
end end
hud.update_player_hud = update_player_hud
return hud return hud

@ -1,104 +1,115 @@
local minetest, vector, nodecore, include local ipairs, ItemStack, minetest, vector, include, nodecore
= minetest, vector, nodecore, include = ipairs, ItemStack, minetest, vector, include, nodecore
local math_round = math.round
rawset(_G, "nc_extended_rotating", {})
local hud = include("hud") nc_extended_rotating.hud = include("hud")
local rotate = include("rotate") nc_extended_rotating.registry = include("registry")
local utility = include("utility") nc_extended_rotating.rotate = include("rotate")
local registry = include("registry") nc_extended_rotating.utility = include("utility") -- Depends on `rotate`.
nc_extended_rotating.state = include("state") -- Depends on `registry`, `rotate` and `utility`.
local update_player_hud = nc_extended_rotating.hud.update_player_hud
local is_rotatable = nc_extended_rotating.registry.is_rotatable
local register_rotatable = nc_extended_rotating.registry.register_rotatable;
local rotate_node = nc_extended_rotating.rotate.rotate_node
local update_rotating_state = nc_extended_rotating.state.update_rotating_state
local get_rotating_state = nc_extended_rotating.state.get_rotating_state
-- TODO: Fix HUD showing rotation hint when we wouldn't / can't rotate.
-- TODO: Add crosshair indicators for rotating around edges. -- TODO: Add crosshair indicators for rotating around edges.
-- TODO: Add some more comments. -- TODO: Add some more comments.
-- TODO: Add particles to preview rotation? -- TODO: Add particles to preview rotation?
-- Distance at which we want to rotate by "pushing" on an edge. nodecore.register_playerstep({
local EDGE_DISTANCE = 2.5 / 16 -- texels (at 16² texture resolution) label = "nc_extended_rotating:update",
action = function(player, data)
local state = update_rotating_state(player, data)
update_player_hud(player, state)
end,
})
-- Register rotatable nodes from NodeCore.
do
local function nodecore_filtered_lookup(eq_func)
local lookup = {}
for i = 0, 23 do
local facedir = nodecore.facedirs[i]
for j = 0, #lookup - 1 do
local other = nodecore.facedirs[lookup[j]]
if eq_func(facedir, other) then lookup[i] = j; break end
end
lookup[i] = lookup[i] or i
end
return lookup
end
local LENS_FILTERED_LOOKUP = nodecore_filtered_lookup(
function(a, b) return vector.equals(a.f, b.f) end)
local PRISM_FILTERED_LOOKUP = nodecore_filtered_lookup(
function(a, b) return vector.equals(a.f, b.r) and vector.equals(a.r, b.f) end)
local PANEL_FILTERED_LOOKUP = nodecore_filtered_lookup(
function(a, b) return vector.equals(a.f, b.r) and vector.equals(a.r, b.f) end)
for _, lens_state in ipairs({ "", "_on", "_glow", "_glow_start" }) do
register_rotatable("nc_optics:lens" .. lens_state, LENS_FILTERED_LOOKUP) end
for _, prism_state in ipairs({ "", "_on", "_gated" }) do
register_rotatable("nc_optics:prism" .. prism_state, PRISM_FILTERED_LOOKUP) end
-- Contains a per-player state that holds information, created by a raycast register_rotatable("nc_doors:panel_plank" , PANEL_FILTERED_LOOKUP)
-- done in `playerstep`, about the node this player might rotate. This is used register_rotatable("nc_doors:panel_cobble", PANEL_FILTERED_LOOKUP)
-- to update the their HUD and to decide what to do when a rightclick occurs. end
local rotating_state = {}
local function handle_rightclick(pos, node, clicker)
local state = get_rotating_state(clicker, pos, node)
if (not state) or (not state.success) then return end
rotate_node(pos, node, state.facedir)
end
-- Replace `on_rightclick` of rotatable nodes from NodeCore.
do
local function replace_optics_on_rightclick(name)
local def = minetest.registered_nodes[name]
def.on_rightclick = handle_rightclick
end
local function handle_rotatable_rightclick(pos, node, clicker) -- Panels can't have their `on_rightclick` overridden completely.
local state = rotating_state[clicker:get_player_name()] -- We need to call the default implementation when holding a "pin".
if not state then return false end -- Not looking at anything rotatable. local function replace_panel_on_rightclick(name, pin)
local def = minetest.registered_nodes[name]
local default_rightclick = def.on_rightclick
def.on_rightclick = function(pos, node, clicker, item_stack, ...)
if nodecore.protection_test(pos, clicker) or ItemStack(item_stack):get_name() == pin
then return default_rightclick(pos, node, clicker, item_stack, ...)
else return handle_rightclick(pos, node, clicker)
end
end
end
-- Make sure we're still the same node that we raycasted in `playerstep`. for _, lens_state in ipairs({ "", "_on", "_glow", "_glow_start" }) do
if not vector.equals(pos, state.pos) then return true end replace_optics_on_rightclick("nc_optics:lens" .. lens_state) end
if node.name ~= state.node.name or node.param2 ~= state.node.param2 then return true end for _, lens_state in ipairs({ "", "_on", "_gated" }) do
-- Only continue if the node can actually be sucessfully rotated. replace_optics_on_rightclick("nc_optics:prism" .. lens_state) end
if not state.success then return true end
rotate.rotate_node(pos, node, state.facedir) replace_panel_on_rightclick("nc_doors:panel_plank" , "nc_woodwork:staff")
return true replace_panel_on_rightclick("nc_doors:panel_cobble", "nc_lode:rod_tempered")
end end
registry.custom_on_rightclick = handle_rotatable_rightclick
-- Override `nc_scaling`'s default empty hand right-click behavior so we can use it -- Override `nc_scaling`'s default empty hand right-click behavior so we can use it
-- to rotate things when holding sneak, which doesn't trigger a node's `on_rightclick`. -- to rotate things when holding sneak, which doesn't trigger a node's `on_rightclick`.
local default_on_place = minetest.registered_items[""].on_place local default_on_place = minetest.registered_items[""].on_place
minetest.registered_items[""].on_place = function(item_stack, placer, pointed_thing, ...) minetest.registered_items[""].on_place = function(item_stack, placer, pointed_thing, ...)
-- Player must sneak in order for this to run. Non-sneak is handled by node's `on_rightclick`. if pointed_thing.type == "node" then
if pointed_thing.type == "node" and minetest.is_player(placer) and placer:get_player_control().sneak then -- Player must sneak in order for this to run.
-- Non-sneak is handled by node's `on_rightclick`.
if minetest.is_player(placer) and placer:get_player_control().sneak then
local pos = pointed_thing.under local pos = pointed_thing.under
local node = minetest.get_node(pos) local node = minetest.get_node(pos)
if handle_rotatable_rightclick(pos, node, placer) then return end if is_rotatable(node) then
handle_rightclick(pos, node, placer)
return -- Skip default behavior.
end end
default_on_place(item_stack, placer, pointed_thing, ...)
end
-- Calculates the state for the specified `player` and player `data`.
-- This state contains information about how the player would rotate a block.
local function calculate_rotating_state(player, data)
local state = {}
local pointed_thing = data.raycast()
if (not pointed_thing) or pointed_thing.type ~= "node" then return nil end
state.pos = pointed_thing.under
state.node = minetest.get_node(state.pos)
if not registry.is_rotatable(state.node) then return nil end
if vector.equals(pointed_thing.above, pointed_thing.under) then return nil end
state.face = pointed_thing.above - pointed_thing.under
-- When player is sneaking, must be empty-handed for rotating to work.
local is_sneaking = player:get_player_control().sneak
if is_sneaking and (not player:get_wielded_item():is_empty()) then return nil end
local degrees = 90
local r = utility.rotation_vector_from_lookat(state.node, pointed_thing, EDGE_DISTANCE)
state.axis = r
local num = math_round(r.x * r.x + r.y * r.y + r.z * r.z) -- Squared length.
if num == 1 then state.mode = "face"
elseif num == 2 then state.mode = "edge"; state.axis = state.face:cross(r)
elseif num == 3 then state.mode = "mirror"; degrees = 120
end end
end
-- Sneaking causes the direction of the rotation to be inverted. -- Call the default function, so we can still get the "scaling" functionality.
if is_sneaking then degrees = -degrees end default_on_place(item_stack, placer, pointed_thing, ...)
state.invert = is_sneaking
state.facedir = rotate.rotate_facedir(state.node.param2, state.axis, -degrees)
state.facedir = registry.fix_rotatable_facedir(state.node, state.facedir)
state.success = state.facedir and state.facedir ~= state.node.param2
return state
end end
nodecore.register_playerstep({
label = "extended rotating update",
action = function(player, data)
local name = player:get_player_name()
rotating_state[name] = calculate_rotating_state(player, data)
hud.update_player_hud(player, rotating_state[name])
end,
})
minetest.register_on_leaveplayer(function(player)
local name = player:get_player_name()
rotating_state[name] = nil
end)

@ -1,73 +1,34 @@
local minetest, nodecore local minetest
= minetest, nodecore = minetest
local registry = {} -- Table of nodes that can be rotated by the mod.
local registered_rotatables = {} local registered_rotatables = {}
-- Registered rotatable nodes will call this function when their `on_rightclick`
-- is triggered instead of their default implementation. Can be overwritten.
registry.custom_on_rightclick = function(pos, node, clicker, item_stack, pointed_thing) end
local function register_rotatable(name, facedir_lookup) local registry = {}
function registry.register_rotatable(name, facedir_lookup)
local def = minetest.registered_nodes[name] local def = minetest.registered_nodes[name]
if not def then error("Unknown node '" .. name .. "'") end if not def then error("Unknown node '" .. name .. "'") end
if def.paramtype2 ~= "facedir" then error("Node '" .. name .. "' must be 'facedir'") end if def.paramtype2 ~= "facedir" then error("Node '" .. name .. "' must be 'facedir'") end
def.on_rightclick = function(...) registry.custom_on_rightclick(...) end
registered_rotatables[name] = { lookup = facedir_lookup } registered_rotatables[name] = { lookup = facedir_lookup }
end end
registry.register_rotatable = register_rotatable
-- Returns whether the specified `node` is rotatable by this mod. -- Returns whether the specified `node` is rotatable by this mod.
local function is_rotatable(node) function registry.is_rotatable(node)
local name = node and node.name or "" return not not registered_rotatables[node.name]
return not not registered_rotatables[name]
end end
registry.is_rotatable = is_rotatable
-- Fixes facedir for the specified `node` when rotated to `facedir`. -- Fixes facedir for the specified `node` when rotated to `facedir`.
-- * Returns `nil` if the node can't rotate this way. -- * Returns `nil` if the node can't rotate this way.
-- * Returns `facedir` when the node can rotate this way. -- * Returns `facedir` when the node can rotate this way.
-- * Returns another facedir when the node should rotate another equivalent way. -- * Returns another facedir when the node should rotate another equivalent way.
local function fix_rotatable_facedir(node, facedir) function registry.fix_rotatable_facedir(node, facedir)
local name = node and node.name or "" local name = node and node.name or ""
local entry = registered_rotatables[name] local entry = registered_rotatables[name]
if not entry then return nil end if not entry then return nil end
if not entry.lookup then return facedir end if not entry.lookup then return facedir end
return entry.lookup[facedir] return entry.lookup[facedir]
end end
registry.fix_rotatable_facedir = fix_rotatable_facedir
--------------------------------------------
-- Register rotatable nodes from NodeCore --
--------------------------------------------
local function nodecore_filtered_lookup(eq_func)
local lookup = {}
for i = 0, 23 do
local facedir = nodecore.facedirs[i]
for j = 0, #lookup - 1 do
local other = nodecore.facedirs[lookup[j]]
if eq_func(facedir, other) then lookup[i] = j; break end
end
lookup[i] = lookup[i] or i
end
return lookup
end
local LENS_FILTERED_LOOKUP = nodecore_filtered_lookup(
function(a, b) return vector.equals(a.f, b.f) end)
local PRISM_FILTERED_LOOKUP = nodecore_filtered_lookup(
function(a, b) return vector.equals(a.f, b.r) and vector.equals(a.r, b.f) end)
local PANEL_FILTERED_LOOKUP = nodecore_filtered_lookup(
function(a, b) return vector.equals(a.f, b.r) and vector.equals(a.r, b.f) end)
for _, lens_state in ipairs({ "", "_on", "_glow", "_glow_start" }) do
register_rotatable("nc_optics:lens" .. lens_state, LENS_FILTERED_LOOKUP) end
for _, prism_state in ipairs({ "", "_on", "_gated" }) do
register_rotatable("nc_optics:prism" .. prism_state, PRISM_FILTERED_LOOKUP) end
register_rotatable("nc_doors:panel_plank" , PANEL_FILTERED_LOOKUP)
register_rotatable("nc_doors:panel_cobble", PANEL_FILTERED_LOOKUP)
return registry return registry

@ -5,8 +5,6 @@ local math_min, math_max, math_floor, math_rad
local minetest, vector local minetest, vector
= minetest, vector = minetest, vector
local rotate = {}
-- Axis lookup table based on how Minetest's `facedir` operates. -- Axis lookup table based on how Minetest's `facedir` operates.
local AXIS_LOOKUP = { local AXIS_LOOKUP = {
vector.new( 0, 1, 0), -- +Y vector.new( 0, 1, 0), -- +Y
@ -50,24 +48,12 @@ for up_index, up in ipairs(AXIS_LOOKUP) do
end end
end end
-- Rotates (or "spins") the specified `node` to face `facedir`,
-- emulating the behavior of NodeCore's spin functionality.
local function rotate_node(pos, node, facedir)
local def = minetest.registered_nodes[node.name]
if (not def) or def.paramtype2 ~= "facedir" then error("Node's paramtype2 must be 'facedir'") end
minetest.swap_node(pos, nodecore.underride({ param2 = facedir }, node))
nodecore.node_sound(pos, "place")
if def.on_spin then def.on_spin(pos, node) end
end
rotate.rotate_node = rotate_node
-- Returns the `up` and `back` vectors that make up the specified `facedir`. -- Returns the `up` and `back` vectors that make up the specified `facedir`.
local function vectors_from_facedir(facedir) local function vectors_from_facedir(facedir)
local up = AXIS_LOOKUP[1 + math_floor(facedir / 4)] local up = AXIS_LOOKUP[1 + math_floor(facedir / 4)]
local back = minetest.facedir_to_dir(facedir) local back = minetest.facedir_to_dir(facedir)
return up, back return up, back
end end
rotate.vectors_from_facedir = vectors_from_facedir
-- Returns a `facedir` constructed from the specified `up` and `back` vectors. -- Returns a `facedir` constructed from the specified `up` and `back` vectors.
local function facedir_from_vectors(up, back) local function facedir_from_vectors(up, back)
@ -75,10 +61,22 @@ local function facedir_from_vectors(up, back)
local back_index = axis_vector_to_index(back) local back_index = axis_vector_to_index(back)
return FACEDIR_LOOKUP[up_index][back_index] return FACEDIR_LOOKUP[up_index][back_index]
end end
rotate.facedir_from_vectors = facedir_from_vectors
local rotate = {}
-- Rotates (or "spins") the specified `node` to face `facedir`,
-- emulating the behavior of NodeCore's spin functionality.
function rotate.rotate_node(pos, node, facedir)
local def = minetest.registered_nodes[node.name]
if (not def) or def.paramtype2 ~= "facedir" then error("Node's paramtype2 must be 'facedir'") end
minetest.swap_node(pos, nodecore.underride({ param2 = facedir }, node))
nodecore.node_sound(pos, "place")
if def.on_spin then def.on_spin(pos, node) end
end
-- Returns the specified `facedir` rotated around `vec` by `degrees` counter-clickwise. -- Returns the specified `facedir` rotated around `vec` by `degrees` counter-clickwise.
local function rotate_facedir(facedir, vec, degrees) function rotate.rotate_facedir(facedir, vec, degrees)
-- NOTE: Removed the axis requirement so we can use this to rotate around corners. -- NOTE: Removed the axis requirement so we can use this to rotate around corners.
-- However, since we `round` the result vectors this might ignore errors. -- However, since we `round` the result vectors this might ignore errors.
-- if degrees % 90 ~= 0 then error("degrees must be divisible by 90") end -- if degrees % 90 ~= 0 then error("degrees must be divisible by 90") end
@ -88,11 +86,10 @@ local function rotate_facedir(facedir, vec, degrees)
back = back:rotate_around_axis(vec, math_rad(degrees)):round() back = back:rotate_around_axis(vec, math_rad(degrees)):round()
return facedir_from_vectors(up, back) return facedir_from_vectors(up, back)
end end
rotate.rotate_facedir = rotate_facedir
-- Rotates the specified default orientation `{ min, max }` box to -- Rotates the specified default orientation `{ min, max }` box to
-- one matching Minetest's rotation algorithm pointing to `facedir`. -- one matching Minetest's rotation algorithm pointing to `facedir`.
local function rotate_box_by_facedir(box, facedir) function rotate.rotate_box_by_facedir(box, facedir)
if facedir == 0 then return end if facedir == 0 then return end
local axis_index = math_floor(facedir / 4) local axis_index = math_floor(facedir / 4)
@ -115,6 +112,5 @@ local function rotate_box_by_facedir(box, facedir)
box.min.y, box.max.y = math_min(box.min.y, box.max.y), math_max(box.min.y, box.max.y) box.min.y, box.max.y = math_min(box.min.y, box.max.y), math_max(box.min.y, box.max.y)
box.min.z, box.max.z = math_min(box.min.z, box.max.z), math_max(box.min.z, box.max.z) box.min.z, box.max.z = math_min(box.min.z, box.max.z), math_max(box.min.z, box.max.z)
end end
rotate.rotate_box_by_facedir = rotate_box_by_facedir
return rotate return rotate

@ -0,0 +1,83 @@
local math_round, minetest, vector
= math.round, minetest, vector
local fix_rotatable_facedir = nc_extended_rotating.registry.fix_rotatable_facedir;
local is_rotatable = nc_extended_rotating.registry.is_rotatable;
local rotate_facedir = nc_extended_rotating.rotate.rotate_facedir;
local rotation_vector_from_lookat = nc_extended_rotating.utility.rotation_vector_from_lookat;
-- Distance at which we want to rotate by "pushing" on an edge.
local EDGE_DISTANCE = 2.5 / 16 -- texels (at 16² texture resolution)
-- Contains a per-player state that holds information, created by a raycast
-- done in `playerstep`, about the node this player might rotate. This is used
-- to update the their HUD and to decide what to do when a rightclick occurs.
local rotating_state = {}
-- Calculates the state for the specified `player` and player `data`.
-- This state contains information about how the player would rotate a block.
local function calculate_rotating_state(player, data)
local state = {}
local pointed_thing = data.raycast()
if (not pointed_thing) or pointed_thing.type ~= "node" then return nil end
state.pos = pointed_thing.under
state.node = minetest.get_node(state.pos)
if not is_rotatable(state.node) then return nil end
if vector.equals(pointed_thing.above, pointed_thing.under) then return nil end
state.face = pointed_thing.above - pointed_thing.under
-- When player is sneaking, must be empty-handed for rotating to work.
local is_sneaking = player:get_player_control().sneak
if is_sneaking and (not player:get_wielded_item():is_empty()) then return nil end
local degrees = 90
local r = rotation_vector_from_lookat(state.node, pointed_thing, EDGE_DISTANCE)
state.axis = r
local num = math_round(r.x * r.x + r.y * r.y + r.z * r.z) -- Squared length.
if num == 1 then state.mode = "face"
elseif num == 2 then state.mode = "edge"; state.axis = state.face:cross(r)
elseif num == 3 then state.mode = "mirror"; degrees = 120
end
-- Sneaking causes the direction of the rotation to be inverted.
if is_sneaking then degrees = -degrees end
state.invert = is_sneaking
state.facedir = rotate_facedir(state.node.param2, state.axis, -degrees)
state.facedir = fix_rotatable_facedir(state.node, state.facedir)
state.success = state.facedir and state.facedir ~= state.node.param2
return state
end
minetest.register_on_leaveplayer(function(player)
local name = player:get_player_name()
rotating_state[name] = nil
end)
local state = {}
function state.update_rotating_state(player, data)
local rot_state = calculate_rotating_state(player, data)
rotating_state[player:get_player_name()] = rot_state
return rot_state
end
function state.get_rotating_state(player, pos, node)
if not minetest.is_player(player) then return nil end
local rot_state = rotating_state[player:get_player_name()]
if not rot_state then return nil end
if not vector.equals(pos, rot_state.pos) then return nil end
if node.name ~= rot_state.node.name or node.param2 ~= rot_state.node.param2 then return nil end
return rot_state
end
return state

@ -1,12 +1,8 @@
local math_abs, math_sign local minetest, vector, math_abs, math_sign
= math.abs, math.sign = minetest, vector, math.abs, math.sign
local minetest, vector, include
= minetest, vector, include
local rotate = include("rotate") local rotate_box_by_facedir = nc_extended_rotating.rotate.rotate_box_by_facedir
local utility = {}
-- Default selection boxes for a node that doesn't have one them explicitly. -- Default selection boxes for a node that doesn't have one them explicitly.
local DEFAULT_SELECTION_BOX = { min = vector.new(-0.5, -0.5, -0.5), local DEFAULT_SELECTION_BOX = { min = vector.new(-0.5, -0.5, -0.5),
@ -25,15 +21,18 @@ local function get_node_active_selection_box(node)
box = { min = vector.new(box[1], box[2], box[3]), box = { min = vector.new(box[1], box[2], box[3]),
max = vector.new(box[4], box[5], box[6]) } max = vector.new(box[4], box[5], box[6]) }
-- Rotate the box to face `facedir`. -- Rotate the box to face `facedir`.
rotate.rotate_box_by_facedir(box, node.param2) rotate_box_by_facedir(box, node.param2)
return box return box
end end
local utility = {}
-- Calculates the rotation vector from where a player is looking at on this node. -- Calculates the rotation vector from where a player is looking at on this node.
-- * If looking near an edge, the vector points in the direction of that edge. -- * If looking near an edge, the vector points in the direction of that edge.
-- * If looking near a corner, the vector similarly points in that direction. -- * If looking near a corner, the vector similarly points in that direction.
-- * Otherwise, vector simply points away from the face. -- * Otherwise, vector simply points away from the face.
local function rotation_vector_from_lookat(node, pointed_thing, edge_distance) function utility.rotation_vector_from_lookat(node, pointed_thing, edge_distance)
local box = get_node_active_selection_box(node) local box = get_node_active_selection_box(node)
local center = (box.min + box.max) / 2 local center = (box.min + box.max) / 2
@ -45,6 +44,5 @@ local function rotation_vector_from_lookat(node, pointed_thing, edge_distance)
return point:combine(bounds, function(p, b) return point:combine(bounds, function(p, b)
return b - math_abs(p) <= edge_distance and math_sign(p) or 0 end) return b - math_abs(p) <= edge_distance and math_sign(p) or 0 end)
end end
utility.rotation_vector_from_lookat = rotation_vector_from_lookat
return utility return utility

Loading…
Cancel
Save