Compare commits
7 Commits
4b8bd84a84
...
d3153032b1
| Author | SHA1 | Date | |
|---|---|---|---|
| d3153032b1 | |||
| 35071b06ac | |||
| 5f0e7a26dd | |||
| 6fbdb49ce1 | |||
| 23bc5b0bf0 | |||
| 37137ac9ca | |||
| 9353ff675e |
57
rtiow/Cargo.lock
generated
57
rtiow/Cargo.lock
generated
@ -267,7 +267,7 @@ dependencies = [
|
||||
"num-traits",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"toml",
|
||||
"toml 0.4.10",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -1892,6 +1892,15 @@ dependencies = [
|
||||
"version_check 0.1.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nom8"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae01545c9c7fc4486ab7debaf2aad7003ac19431791868fb2e8066df97fad2f8"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-derive"
|
||||
version = "0.2.5"
|
||||
@ -2632,6 +2641,7 @@ dependencies = [
|
||||
"structopt 0.2.18",
|
||||
"strum",
|
||||
"strum_macros",
|
||||
"thiserror",
|
||||
"vec3",
|
||||
]
|
||||
|
||||
@ -2784,6 +2794,15 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_spanned"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0efd8caf556a6cebd3b285caf480045fcc1ac04f6bd786b09a6f11af30c4fcf4"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_urlencoded"
|
||||
version = "0.5.5"
|
||||
@ -3409,6 +3428,40 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml"
|
||||
version = "0.7.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f7afcae9e3f0fe2c370fd4657108972cbb2fa9db1b9f84849cefd80741b01cb6"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_spanned",
|
||||
"toml_datetime",
|
||||
"toml_edit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_datetime"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ab8ed2edee10b50132aed5f331333428b011c99402b5a534154ed15746f9622"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_edit"
|
||||
version = "0.19.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e6a7712b49e1775fb9a7b998de6635b299237f48b404dde71704f2e0e7f37e5"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"nom8",
|
||||
"serde",
|
||||
"serde_spanned",
|
||||
"toml_datetime",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tower-service"
|
||||
version = "0.1.0"
|
||||
@ -3422,11 +3475,13 @@ dependencies = [
|
||||
name = "tracer"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"log 0.4.17",
|
||||
"renderer",
|
||||
"stderrlog",
|
||||
"structopt 0.2.18",
|
||||
"strum",
|
||||
"toml 0.7.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@ -10,3 +10,5 @@ members = [
|
||||
[profile.release]
|
||||
debug = true
|
||||
|
||||
[profile.dev]
|
||||
opt-level = 3
|
||||
|
||||
@ -29,6 +29,7 @@ vec3 = {path = "../vec3"}
|
||||
stl = {path = "../../../stl"}
|
||||
strum = { version = "0.24.1", features = ["derive"] }
|
||||
strum_macros = "0.24.3"
|
||||
thiserror = "1.0.38"
|
||||
#stl = {git = "https://git-private.z.xinu.tv/wathiede/stl"}
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
@ -120,7 +120,7 @@ impl<M> BVHTriangles<M>
|
||||
where
|
||||
M: Material,
|
||||
{
|
||||
pub fn new(stl: &STL, material: M) -> BVHTriangles<M> {
|
||||
pub fn new(stl: &STL, material: M, scale_factor: f32) -> BVHTriangles<M> {
|
||||
let now = std::time::Instant::now();
|
||||
|
||||
assert_eq!(std::mem::size_of::<BVHNode>(), 32);
|
||||
@ -129,9 +129,9 @@ where
|
||||
.triangles
|
||||
.iter()
|
||||
.map(|t| {
|
||||
let v0 = t.verts[0];
|
||||
let v1 = t.verts[1];
|
||||
let v2 = t.verts[2];
|
||||
let v0 = t.verts[0] * scale_factor;
|
||||
let v1 = t.verts[1] * scale_factor;
|
||||
let v2 = t.verts[2] * scale_factor;
|
||||
let centroid = (v0 + v1 + v2) * div3;
|
||||
Triangle {
|
||||
centroid,
|
||||
|
||||
@ -18,6 +18,7 @@ fn random_in_unit_disk() -> Vec3 {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Camera {
|
||||
origin: Vec3,
|
||||
lower_left_corner: Vec3,
|
||||
|
||||
@ -3,8 +3,6 @@ use crate::{
|
||||
hitable::{Hit, HitRecord},
|
||||
material::Lambertian,
|
||||
ray::Ray,
|
||||
texture::{self, ConstantTexture},
|
||||
vec3::Vec3,
|
||||
};
|
||||
|
||||
#[derive(Debug)]
|
||||
|
||||
@ -16,6 +16,7 @@ pub mod material;
|
||||
pub mod moving_sphere;
|
||||
pub mod noise;
|
||||
pub mod output;
|
||||
pub mod parser;
|
||||
pub mod ray;
|
||||
pub mod rect;
|
||||
pub mod renderer;
|
||||
|
||||
192
rtiow/renderer/src/parser.rs
Normal file
192
rtiow/renderer/src/parser.rs
Normal file
@ -0,0 +1,192 @@
|
||||
use crate::{
|
||||
bvh_triangles::BVHTriangles,
|
||||
camera::Camera,
|
||||
cuboid::Cuboid,
|
||||
hitable::Hit,
|
||||
hitable_list::HitableList,
|
||||
material::{Lambertian, Material, Metal},
|
||||
renderer::Scene,
|
||||
sphere::Sphere,
|
||||
texture::{EnvMap, Texture},
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use std::{collections::HashMap, fs::File, io::BufReader, path::PathBuf, sync::Arc};
|
||||
use stl::STL;
|
||||
use thiserror::Error;
|
||||
use vec3::Vec3;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct Config {
|
||||
scene: SceneConfig,
|
||||
camera: CameraConfig,
|
||||
materials: Vec<MaterialConfig>,
|
||||
hitables: Vec<HitableConfig>,
|
||||
envmap: Option<EnvMapConfig>,
|
||||
}
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum ConfigError {
|
||||
#[error("failed to load image")]
|
||||
ImageError(#[from] image::ImageError),
|
||||
#[error("failed to parser STL")]
|
||||
STLError(#[from] stl::ParseError),
|
||||
#[error("I/O error")]
|
||||
IOError(#[from] std::io::Error),
|
||||
#[error("duplication material named '{0}'")]
|
||||
DuplicateMaterial(String),
|
||||
#[error("unkown material named '{0}'")]
|
||||
UnknownMaterial(String),
|
||||
}
|
||||
|
||||
impl TryFrom<Config> for Scene {
|
||||
type Error = ConfigError;
|
||||
|
||||
fn try_from(c: Config) -> Result<Scene, Self::Error> {
|
||||
let mut materials = HashMap::new();
|
||||
for mc in c.materials {
|
||||
let v: Arc<dyn Material> = match mc.material {
|
||||
Materials::Metal { albedo, fuzzy } => Arc::new(Metal::new(albedo, fuzzy)),
|
||||
};
|
||||
if materials.insert(mc.name.clone(), v).is_some() {
|
||||
return Err(ConfigError::DuplicateMaterial(mc.name));
|
||||
}
|
||||
}
|
||||
|
||||
let hitables: Result<Vec<Box<dyn Hit>>, Self::Error> = c
|
||||
.hitables
|
||||
.into_iter()
|
||||
.map(|hc| -> Result<Box<dyn Hit>, Self::Error> {
|
||||
match hc.hitable {
|
||||
Hitables::Sphere { center, radius } => Ok(Box::new(Sphere::new(
|
||||
center,
|
||||
radius,
|
||||
Arc::clone(
|
||||
materials
|
||||
.get(&hc.material_name)
|
||||
.ok_or(ConfigError::UnknownMaterial(hc.material_name))?,
|
||||
),
|
||||
))),
|
||||
Hitables::Cuboid { min, max } => Ok(Box::new(Cuboid::new(
|
||||
min.into(),
|
||||
max.into(),
|
||||
Arc::clone(
|
||||
materials
|
||||
.get(&hc.material_name)
|
||||
.ok_or(ConfigError::UnknownMaterial(hc.material_name))?,
|
||||
),
|
||||
))),
|
||||
Hitables::STL { path, scale } => {
|
||||
let r = BufReader::new(File::open(path)?);
|
||||
let stl = STL::parse(r, false)?;
|
||||
Ok(Box::new(BVHTriangles::new(
|
||||
&stl,
|
||||
Arc::clone(
|
||||
materials
|
||||
.get(&hc.material_name)
|
||||
.ok_or(ConfigError::UnknownMaterial(hc.material_name))?,
|
||||
),
|
||||
scale.unwrap_or(1.),
|
||||
)))
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let hitables = hitables?;
|
||||
|
||||
let world: Box<dyn Hit> = Box::new(HitableList::new(hitables));
|
||||
let mut env_map: Option<EnvMap> = None;
|
||||
|
||||
if let Some(em) = c.envmap {
|
||||
let im = image::open(em.path)?.into_rgb();
|
||||
env_map = Some(EnvMap::new(im));
|
||||
};
|
||||
|
||||
let camera = make_camera(&c.camera, c.scene.width, c.scene.height);
|
||||
|
||||
let scene = Scene {
|
||||
world,
|
||||
camera,
|
||||
env_map,
|
||||
subsamples: c.scene.subsamples.unwrap_or(8),
|
||||
adaptive_subsampling: c.scene.adaptive_subsampling,
|
||||
num_threads: c.scene.num_threads,
|
||||
width: c.scene.width,
|
||||
height: c.scene.height,
|
||||
global_illumination: c.scene.global_illumination.unwrap_or(true),
|
||||
};
|
||||
Ok(scene)
|
||||
}
|
||||
}
|
||||
|
||||
fn make_camera(cfg: &CameraConfig, width: usize, height: usize) -> Camera {
|
||||
Camera::new(
|
||||
cfg.lookfrom.into(),
|
||||
cfg.lookat.into(),
|
||||
Vec3::new(0., 1., 0.),
|
||||
cfg.fov,
|
||||
width as f32 / height as f32,
|
||||
cfg.aperture,
|
||||
cfg.focus_dist,
|
||||
cfg.time_min,
|
||||
cfg.time_max,
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SceneConfig {
|
||||
subsamples: Option<usize>,
|
||||
adaptive_subsampling: Option<f32>,
|
||||
num_threads: Option<usize>,
|
||||
width: usize,
|
||||
height: usize,
|
||||
global_illumination: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(tag = "type")]
|
||||
struct HitableConfig {
|
||||
material_name: String,
|
||||
#[serde(flatten)]
|
||||
hitable: Hitables,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(tag = "type")]
|
||||
enum Hitables {
|
||||
#[serde(rename = "sphere")]
|
||||
Sphere { center: [f32; 3], radius: f32 },
|
||||
#[serde(rename = "cuboid")]
|
||||
Cuboid { min: [f32; 3], max: [f32; 3] },
|
||||
#[serde(rename = "stl")]
|
||||
STL { path: PathBuf, scale: Option<f32> },
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct MaterialConfig {
|
||||
name: String,
|
||||
#[serde(flatten)]
|
||||
material: Materials,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(tag = "type")]
|
||||
enum Materials {
|
||||
#[serde(rename = "metal")]
|
||||
Metal { albedo: [f32; 3], fuzzy: f32 },
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CameraConfig {
|
||||
lookfrom: [f32; 3],
|
||||
lookat: [f32; 3],
|
||||
fov: f32,
|
||||
aperture: f32,
|
||||
focus_dist: f32,
|
||||
time_min: f32,
|
||||
time_max: f32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct EnvMapConfig {
|
||||
path: PathBuf,
|
||||
}
|
||||
@ -1,4 +1,5 @@
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
fmt,
|
||||
ops::{AddAssign, Range},
|
||||
path::{Path, PathBuf},
|
||||
@ -22,7 +23,7 @@ use crate::{
|
||||
camera::Camera,
|
||||
hitable::Hit,
|
||||
human,
|
||||
material::Lambertian,
|
||||
material::{Lambertian, Material},
|
||||
output,
|
||||
ray::Ray,
|
||||
scenes,
|
||||
@ -99,6 +100,9 @@ pub struct Opt {
|
||||
/// Select scene to render.
|
||||
#[structopt(long = "model")]
|
||||
pub model: Option<Model>,
|
||||
/// Toml config describing scene.
|
||||
#[structopt(long = "config")]
|
||||
pub config: Option<PathBuf>,
|
||||
/// Path to store pprof profile data, i.e. /tmp/cpuprofile.pprof
|
||||
#[structopt(long = "pprof", parse(from_os_str))]
|
||||
pub pprof: Option<PathBuf>,
|
||||
@ -126,11 +130,13 @@ pub fn opt_hash(opt: &Opt) -> String {
|
||||
}
|
||||
|
||||
// TODO(wathiede): implement the skips and then the renderer could use json as an input file type.
|
||||
#[derive(Serialize)]
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Scene {
|
||||
#[serde(skip)]
|
||||
pub world: Box<dyn Hit>,
|
||||
//#[serde(skip)]
|
||||
//pub materials: HashMap<String, Box<dyn Material>>,
|
||||
#[serde(skip)]
|
||||
pub camera: Camera,
|
||||
pub subsamples: usize,
|
||||
|
||||
@ -2,7 +2,6 @@ use crate::{
|
||||
aabb::AABB,
|
||||
hitable::{Hit, HitRecord},
|
||||
ray::Ray,
|
||||
vec3::Vec3,
|
||||
};
|
||||
|
||||
#[derive(Debug)]
|
||||
|
||||
@ -52,6 +52,7 @@ pub fn new(opt: &Opt) -> Scene {
|
||||
height: opt.height,
|
||||
global_illumination: true,
|
||||
env_map: Some(EnvMap::new(skybox)),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
use std::{
|
||||
f32::consts::PI,
|
||||
io::{BufReader, Cursor},
|
||||
iter::Inspect,
|
||||
};
|
||||
|
||||
use stl::STL;
|
||||
@ -13,7 +12,7 @@ use crate::{
|
||||
hitable::Hit,
|
||||
hitable_list::HitableList,
|
||||
kdtree::KDTree,
|
||||
material::{Dielectric, Lambertian, Metal},
|
||||
material::{Lambertian, Metal},
|
||||
renderer::{Opt, Scene},
|
||||
rotate::RotateY,
|
||||
scale::Scale,
|
||||
@ -53,7 +52,6 @@ pub fn new(opt: &Opt) -> Scene {
|
||||
|
||||
let stl_cube = STL::parse(
|
||||
BufReader::new(Cursor::new(include_bytes!("../../stls/dragon.stl"))),
|
||||
//BufReader::new(Cursor::new(include_bytes!("../../stls/cube.stl"))),
|
||||
false,
|
||||
)
|
||||
.expect("failed to parse cube");
|
||||
@ -89,7 +87,7 @@ pub fn new(opt: &Opt) -> Scene {
|
||||
// STL Mesh
|
||||
Box::new(crate::debug_hit::DebugHit::new(RotateY::new(
|
||||
Translate::new(
|
||||
Scale::new(BVHTriangles::new(&stl_cube, dragon_material), 250.),
|
||||
BVHTriangles::new(&stl_cube, dragon_material, 250.),
|
||||
[0., -10., 0.],
|
||||
),
|
||||
180.,
|
||||
|
||||
@ -112,5 +112,6 @@ pub fn new(opt: &Opt) -> Scene {
|
||||
height: opt.height,
|
||||
global_illumination: true,
|
||||
env_map: Some(EnvMap::new(skybox)),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
@ -91,7 +91,7 @@ pub fn new(opt: &Opt) -> Scene {
|
||||
)),
|
||||
// STL Mesh
|
||||
Box::new(Translate::new(
|
||||
BVHTriangles::new(&stl_cube, glass),
|
||||
BVHTriangles::new(&stl_cube, glass, 1.),
|
||||
[0., 10., 0.],
|
||||
)),
|
||||
//Box::new(BVHTriangles::new(&stl_cube, box_material.clone())),
|
||||
|
||||
@ -7,8 +7,10 @@ edition = "2021"
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1.0.69"
|
||||
log = "0.4.17"
|
||||
renderer = { path = "../renderer" }
|
||||
stderrlog = "0.4.3"
|
||||
structopt = "0.2.18"
|
||||
strum = "0.24.1"
|
||||
toml = "0.7.2"
|
||||
|
||||
49
rtiow/tracer/configs/test.toml
Normal file
49
rtiow/tracer/configs/test.toml
Normal file
@ -0,0 +1,49 @@
|
||||
[scene]
|
||||
width = 768
|
||||
height = 768
|
||||
#subsamples = 1000
|
||||
|
||||
[camera]
|
||||
lookfrom = [0.0, 30.0, -100.0]
|
||||
lookat = [0.0, 0.0, 0.0]
|
||||
fov = 45
|
||||
aperture = 0.0
|
||||
focus_dist = 10.0
|
||||
time_min = 0.0
|
||||
time_max = 1.0
|
||||
|
||||
[[materials]]
|
||||
name = "green"
|
||||
type = "metal"
|
||||
albedo = [0, 1, 0]
|
||||
fuzzy = 1.5
|
||||
[[materials]]
|
||||
name = "blue"
|
||||
type = "metal"
|
||||
albedo = [0, 0, 1]
|
||||
fuzzy = 1.5
|
||||
[[materials]]
|
||||
name = "red"
|
||||
type = "metal"
|
||||
albedo = [1, 0, 0]
|
||||
fuzzy = 1.5
|
||||
|
||||
|
||||
[[hitables]]
|
||||
type = "cuboid"
|
||||
min = [-10.0, -10.0, -10.0]
|
||||
max = [10.0, 10.0, 10.0]
|
||||
material_name = "green"
|
||||
[[hitables]]
|
||||
type = "sphere"
|
||||
center = [30.0, 0.0, 0.0]
|
||||
radius = 10
|
||||
material_name = "blue"
|
||||
[[hitables]]
|
||||
type = "stl"
|
||||
path = "/net/nasx.h.xinu.tv/x/3dprint/stl/stanford_dragon.stl"
|
||||
scale = 200
|
||||
material_name = "red"
|
||||
|
||||
[envmap]
|
||||
path = "/home/wathiede/src/xinu.tv/raytracers/rtiow/renderer/images/52681723945_e1d94d3df9_6k.jpg"
|
||||
@ -1,12 +1,16 @@
|
||||
#![warn(unused_extern_crates)]
|
||||
use std::{fs, time::Instant};
|
||||
|
||||
use anyhow::Result;
|
||||
#[cfg(feature = "profile")]
|
||||
use cpuprofiler::PROFILER;
|
||||
use log::info;
|
||||
use structopt::StructOpt;
|
||||
|
||||
use renderer::renderer::{render, Model, Opt};
|
||||
use renderer::{
|
||||
parser::Config,
|
||||
renderer::{render, Model, Opt},
|
||||
};
|
||||
use strum::VariantNames;
|
||||
|
||||
#[cfg(not(feature = "profile"))]
|
||||
@ -35,7 +39,7 @@ impl MockProfiler {
|
||||
#[cfg(not(feature = "profile"))]
|
||||
static PROFILER: MockProfiler = MockProfiler {};
|
||||
|
||||
fn main() -> Result<(), std::io::Error> {
|
||||
fn main() -> Result<()> {
|
||||
let start_time = Instant::now();
|
||||
stderrlog::new()
|
||||
.verbosity(3)
|
||||
@ -43,12 +47,28 @@ fn main() -> Result<(), std::io::Error> {
|
||||
.init()
|
||||
.unwrap();
|
||||
let opt = Opt::from_args();
|
||||
if opt.model.is_none() {
|
||||
eprintln!("--model should be one of {:?}", Model::VARIANTS);
|
||||
if opt.model.is_none() && opt.config.is_none() {
|
||||
eprintln!(
|
||||
"--config <path> or --model should be one of {:?}",
|
||||
Model::VARIANTS
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
if opt.model.is_some() && opt.config.is_some() {
|
||||
eprintln!("only specify one of --config or --model");
|
||||
return Ok(());
|
||||
}
|
||||
info!("{:#?}", opt);
|
||||
let scene = opt.model.as_ref().unwrap().scene(&opt);
|
||||
let scene = match (&opt.model, &opt.config) {
|
||||
(Some(model), None) => model.scene(&opt),
|
||||
(None, Some(config)) => {
|
||||
let s = std::fs::read_to_string(config)?;
|
||||
let cfg: Config = toml::from_str(&s)?;
|
||||
println!("{:#?}", cfg);
|
||||
cfg.try_into()?
|
||||
}
|
||||
_ => unreachable!(),
|
||||
};
|
||||
fs::create_dir_all(&opt.output)?;
|
||||
if opt.pprof.is_some() && !cfg!(feature = "profile") {
|
||||
panic!("profiling disabled at compile time, but -pprof specified");
|
||||
@ -68,5 +88,5 @@ fn main() -> Result<(), std::io::Error> {
|
||||
|
||||
let time_diff = Instant::now() - start_time;
|
||||
info!("Total runtime {} seconds", time_diff.as_secs_f32());
|
||||
res
|
||||
Ok(res?)
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user