Solved 1c sample 1
This commit is contained in:
parent
973e03df4d
commit
a725cc2e39
11 changed files with 388 additions and 175 deletions
45
assignment-1c/examples/soft-shadow-demo.txt
Normal file
45
assignment-1c/examples/soft-shadow-demo.txt
Normal file
|
@ -0,0 +1,45 @@
|
||||||
|
imsize 640 480
|
||||||
|
eye 0 0 15
|
||||||
|
viewdir 0 0 -1
|
||||||
|
hfov 60
|
||||||
|
updir 0 1 0
|
||||||
|
bkgcolor 0.5 0.5 0.5
|
||||||
|
|
||||||
|
depthcueing 0.5 0.5 0.5 1 0.4 60 0
|
||||||
|
|
||||||
|
light 10 10 -10 1 1 1 1
|
||||||
|
|
||||||
|
mtlcolor 0.5 1 0.5 1 1 1 0.2 1 0.1 5
|
||||||
|
sphere 4.5 4.5 -20 4.5
|
||||||
|
sphere -4.5 -4.5 -20 4.5
|
||||||
|
|
||||||
|
mtlcolor 1 0.5 0.5 1 1 1 0.2 0.8 0 5
|
||||||
|
sphere -10 0 -30 4
|
||||||
|
sphere -20 0 -30 4
|
||||||
|
sphere -30 0 -30 4
|
||||||
|
sphere -40 0 -30 4
|
||||||
|
sphere 0 0 -30 4
|
||||||
|
sphere 10 0 -30 4
|
||||||
|
sphere 20 0 -30 4
|
||||||
|
sphere 30 0 -30 4
|
||||||
|
sphere 40 0 -30 4
|
||||||
|
|
||||||
|
sphere -10 -10 -30 4
|
||||||
|
sphere -20 -10 -30 4
|
||||||
|
sphere -30 -10 -30 4
|
||||||
|
sphere -40 -10 -30 4
|
||||||
|
sphere 0 -10 -30 4
|
||||||
|
sphere 10 -10 -30 4
|
||||||
|
sphere 20 -10 -30 4
|
||||||
|
sphere 30 -10 -30 4
|
||||||
|
sphere 40 -10 -30 4
|
||||||
|
|
||||||
|
sphere -10 10 -30 4
|
||||||
|
sphere -20 10 -30 4
|
||||||
|
sphere -30 10 -30 4
|
||||||
|
sphere -40 10 -30 4
|
||||||
|
sphere 0 10 -30 4
|
||||||
|
sphere 10 10 -30 4
|
||||||
|
sphere 20 10 -30 4
|
||||||
|
sphere 30 10 -30 4
|
||||||
|
sphere 40 10 -30 4
|
|
@ -1,5 +1,3 @@
|
||||||
#![doc = include_str!("../README.md")]
|
|
||||||
|
|
||||||
use nalgebra::Vector3;
|
use nalgebra::Vector3;
|
||||||
|
|
||||||
#[macro_use]
|
#[macro_use]
|
||||||
|
|
|
@ -91,7 +91,7 @@ fn main() -> Result<()> {
|
||||||
.iter()
|
.iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
.filter_map(|(i, object)| {
|
.filter_map(|(i, object)| {
|
||||||
match object.kind.intersects_ray_at(&ray) {
|
match object.kind.intersects_ray_at(&scene, &ray) {
|
||||||
Ok(Some(t)) => {
|
Ok(Some(t)) => {
|
||||||
// Return both the t and the sphere, because we want to sort on
|
// Return both the t and the sphere, because we want to sort on
|
||||||
// the t but later retrieve attributes from the sphere
|
// the t but later retrieve attributes from the sphere
|
||||||
|
|
|
@ -7,6 +7,7 @@ use crate::Vector;
|
||||||
use crate::{ray::Ray, Point};
|
use crate::{ray::Ray, Point};
|
||||||
|
|
||||||
use super::illumination::IntersectionContext;
|
use super::illumination::IntersectionContext;
|
||||||
|
use super::Scene;
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct Cylinder {
|
pub struct Cylinder {
|
||||||
|
@ -23,6 +24,7 @@ impl Cylinder {
|
||||||
/// If there is no intersection point, returns None.
|
/// If there is no intersection point, returns None.
|
||||||
pub fn intersects_ray_at(
|
pub fn intersects_ray_at(
|
||||||
&self,
|
&self,
|
||||||
|
_: &Scene,
|
||||||
ray: &Ray,
|
ray: &Ray,
|
||||||
) -> Result<Option<IntersectionContext>> {
|
) -> Result<Option<IntersectionContext>> {
|
||||||
// Determine rotation matrix for turning the cylinder upright along the
|
// Determine rotation matrix for turning the cylinder upright along the
|
||||||
|
@ -180,7 +182,7 @@ impl Cylinder {
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use crate::{ray::Ray, Point, Vector};
|
use crate::{ray::Ray, scene::Scene, Point, Vector};
|
||||||
|
|
||||||
use super::Cylinder;
|
use super::Cylinder;
|
||||||
|
|
||||||
|
@ -197,7 +199,8 @@ mod tests {
|
||||||
let end = Point::new(0.0, 2.0, 2.0);
|
let end = Point::new(0.0, 2.0, 2.0);
|
||||||
let ray = Ray::from_endpoints(eye, end);
|
let ray = Ray::from_endpoints(eye, end);
|
||||||
|
|
||||||
let res = cylinder.intersects_ray_at(&ray);
|
let scene = Scene::default();
|
||||||
panic!("Result: {res:?}");
|
let res = cylinder.intersects_ray_at(&scene, &ray);
|
||||||
|
// panic!("Result: {res:?}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,12 +10,14 @@ use crate::Point;
|
||||||
use super::cylinder::Cylinder;
|
use super::cylinder::Cylinder;
|
||||||
use super::illumination::IntersectionContext;
|
use super::illumination::IntersectionContext;
|
||||||
use super::sphere::Sphere;
|
use super::sphere::Sphere;
|
||||||
|
use super::triangle::FlatTriangle;
|
||||||
use super::Scene;
|
use super::Scene;
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub enum ObjectKind {
|
pub enum ObjectKind {
|
||||||
Sphere(Sphere),
|
Sphere(Sphere),
|
||||||
Cylinder(Cylinder),
|
Cylinder(Cylinder),
|
||||||
|
FlatTriangle(FlatTriangle),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ObjectKind {
|
impl ObjectKind {
|
||||||
|
@ -26,11 +28,15 @@ impl ObjectKind {
|
||||||
/// Shade_Ray.
|
/// Shade_Ray.
|
||||||
pub fn intersects_ray_at(
|
pub fn intersects_ray_at(
|
||||||
&self,
|
&self,
|
||||||
|
scene: &Scene,
|
||||||
ray: &Ray,
|
ray: &Ray,
|
||||||
) -> Result<Option<IntersectionContext>> {
|
) -> Result<Option<IntersectionContext>> {
|
||||||
match self {
|
match self {
|
||||||
ObjectKind::Sphere(sphere) => sphere.intersects_ray_at(ray),
|
ObjectKind::Sphere(sphere) => sphere.intersects_ray_at(scene, ray),
|
||||||
ObjectKind::Cylinder(cylinder) => cylinder.intersects_ray_at(ray),
|
ObjectKind::Cylinder(cylinder) => cylinder.intersects_ray_at(scene, ray),
|
||||||
|
ObjectKind::FlatTriangle(triangle) => {
|
||||||
|
triangle.intersects_ray_at(scene, ray)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -151,7 +151,8 @@ impl Scene {
|
||||||
// This list will be a set of opacities
|
// This list will be a set of opacities
|
||||||
let intersections = other_objects
|
let intersections = other_objects
|
||||||
.filter_map(|(_, object)| {
|
.filter_map(|(_, object)| {
|
||||||
let intersection_context = match object.kind.intersects_ray_at(&ray) {
|
let intersection_context =
|
||||||
|
match object.kind.intersects_ray_at(&self, &ray) {
|
||||||
Ok(v) => v,
|
Ok(v) => v,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
error!("Error while performing shadow casting: {err}");
|
error!("Error while performing shadow casting: {err}");
|
||||||
|
@ -228,7 +229,8 @@ impl Scene {
|
||||||
direction,
|
direction,
|
||||||
};
|
};
|
||||||
|
|
||||||
let intersection_context = match object.kind.intersects_ray_at(&ray) {
|
let intersection_context =
|
||||||
|
match object.kind.intersects_ray_at(&self, &ray) {
|
||||||
Ok(Some(v)) => v,
|
Ok(Some(v)) => v,
|
||||||
Ok(None) => return false,
|
Ok(None) => return false,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
use std::{fs::File, io::Read, path::Path, str::FromStr};
|
use std::{fs::File, io::Read, path::Path};
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use itertools::Itertools;
|
use itertools::Itertools;
|
||||||
use nalgebra::{Vector2, Vector3};
|
use nalgebra::Vector3;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
image::Color,
|
image::Color,
|
||||||
|
@ -10,6 +10,7 @@ use crate::{
|
||||||
cylinder::Cylinder,
|
cylinder::Cylinder,
|
||||||
data::{Attenuation, Light, LightKind, Material, Object},
|
data::{Attenuation, Light, LightKind, Material, Object},
|
||||||
sphere::Sphere,
|
sphere::Sphere,
|
||||||
|
triangle::FlatTriangle,
|
||||||
Scene,
|
Scene,
|
||||||
},
|
},
|
||||||
Point, Vector,
|
Point, Vector,
|
||||||
|
@ -33,27 +34,13 @@ impl Scene {
|
||||||
let mut material_color = None;
|
let mut material_color = None;
|
||||||
|
|
||||||
for line in contents.lines() {
|
for line in contents.lines() {
|
||||||
|
// Split lines into words, and identify the keyword
|
||||||
let mut parts = line.split_whitespace();
|
let mut parts = line.split_whitespace();
|
||||||
let keyword = match parts.next() {
|
let keyword = match parts.next() {
|
||||||
Some(v) => v,
|
Some(v) => v,
|
||||||
None => continue,
|
None => continue,
|
||||||
};
|
};
|
||||||
|
|
||||||
if keyword == "imsize" {
|
|
||||||
let parts = parts
|
|
||||||
.map(|s| s.parse::<usize>().map_err(|e| e.into()))
|
|
||||||
.collect::<Result<Vec<_>>>()?;
|
|
||||||
if let [width, height] = parts[..] {
|
|
||||||
scene.image_width = width;
|
|
||||||
scene.image_height = height;
|
|
||||||
}
|
|
||||||
} else if keyword == "projection" {
|
|
||||||
if let Some("parallel") = parts.next() {
|
|
||||||
scene.parallel_projection = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Do float parsing instead
|
|
||||||
else {
|
|
||||||
/// Shortcut for reading something from the iterator and converting it
|
/// Shortcut for reading something from the iterator and converting it
|
||||||
/// into the appropriate format
|
/// into the appropriate format
|
||||||
macro_rules! r {
|
macro_rules! r {
|
||||||
|
@ -79,6 +66,16 @@ impl Scene {
|
||||||
}
|
}
|
||||||
|
|
||||||
match keyword {
|
match keyword {
|
||||||
|
"imsize" => {
|
||||||
|
scene.image_width = r!(usize);
|
||||||
|
scene.image_height = r!(usize);
|
||||||
|
}
|
||||||
|
"projection" => {
|
||||||
|
if let Some("parallel") = parts.next() {
|
||||||
|
scene.parallel_projection = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
"eye" => scene.eye_pos = r!(Vector3<f64>),
|
"eye" => scene.eye_pos = r!(Vector3<f64>),
|
||||||
"viewdir" => scene.view_dir = r!(Vector3<f64>),
|
"viewdir" => scene.view_dir = r!(Vector3<f64>),
|
||||||
"updir" => scene.up_dir = r!(Vector3<f64>),
|
"updir" => scene.up_dir = r!(Vector3<f64>),
|
||||||
|
@ -196,8 +193,74 @@ impl Scene {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
_ => bail!("Unknown keyword {keyword}"),
|
// Assignment 1C: Triangles and textures
|
||||||
|
|
||||||
|
// v x y z
|
||||||
|
"v" => scene.vertices.push(r!(Vector)),
|
||||||
|
|
||||||
|
// vn nx ny nz
|
||||||
|
"vn" => scene.normals.push(r!(Vector)),
|
||||||
|
|
||||||
|
// f v1 v2 v3
|
||||||
|
// f v1//n1 v2//n2 v3//n3
|
||||||
|
"f" => {
|
||||||
|
use TriangleVertex::*;
|
||||||
|
|
||||||
|
let v1 = r!(TriangleVertex);
|
||||||
|
let v2 = r!(TriangleVertex);
|
||||||
|
let v3 = r!(TriangleVertex);
|
||||||
|
|
||||||
|
match (v1, v2, v3) {
|
||||||
|
(Flat(v1), Flat(v2), Flat(v3)) => {
|
||||||
|
let vertices = Vector3::new(v1, v2, v3);
|
||||||
|
scene.objects.push(Object {
|
||||||
|
kind: ObjectKind::FlatTriangle(FlatTriangle { vertices }),
|
||||||
|
material: mat!(),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
(Smooth(v1, n1), Smooth(v2, n2), Smooth(v3, n3)) => {
|
||||||
|
scene.smooth_triangles.push(((v1, n1), (v2, n2), (v3, n3)))
|
||||||
|
}
|
||||||
|
_ => bail!("Must all be either v_idx or v_idx//n_idx"),
|
||||||
|
}
|
||||||
|
|
||||||
|
enum TriangleVertex {
|
||||||
|
Flat(usize),
|
||||||
|
Smooth(usize, usize),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Construct for TriangleVertex {
|
||||||
|
type Args = ();
|
||||||
|
|
||||||
|
fn construct<'a, I>(it: &mut I, _: Self::Args) -> Result<Self>
|
||||||
|
where
|
||||||
|
I: Iterator<Item = &'a str>,
|
||||||
|
{
|
||||||
|
let s = match it.next() {
|
||||||
|
Some(v) => v,
|
||||||
|
None => bail!("Waiting on another"),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Note: indexed by 1 not 0, so we will just do the subtraction
|
||||||
|
// here to avoid having to deal with it later
|
||||||
|
let parts = s.split("//").collect_vec();
|
||||||
|
ensure!(parts.len() >= 1 && parts.len() <= 2);
|
||||||
|
let v_idx: usize = parts[0].parse()?;
|
||||||
|
Ok(match parts.len() {
|
||||||
|
1 => TriangleVertex::Flat(v_idx - 1),
|
||||||
|
2 => {
|
||||||
|
let n_idx: usize = parts[1].parse()?;
|
||||||
|
TriangleVertex::Smooth(v_idx - 1, n_idx - 1)
|
||||||
|
}
|
||||||
|
_ => bail!("Invalid"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
"texture" => {}
|
||||||
|
|
||||||
|
_ => bail!("Unknown keyword {keyword}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -219,7 +282,7 @@ macro_rules! impl_construct {
|
||||||
impl Construct for $ty {
|
impl Construct for $ty {
|
||||||
type Args = ();
|
type Args = ();
|
||||||
|
|
||||||
fn construct<'a, I>(it: &mut I, args: Self::Args) -> Result<Self>
|
fn construct<'a, I>(it: &mut I, _: Self::Args) -> Result<Self>
|
||||||
where
|
where
|
||||||
I: Iterator<Item = &'a str>,
|
I: Iterator<Item = &'a str>,
|
||||||
{
|
{
|
||||||
|
@ -240,7 +303,7 @@ impl_construct!(usize);
|
||||||
impl Construct for Vector3<f64> {
|
impl Construct for Vector3<f64> {
|
||||||
type Args = ();
|
type Args = ();
|
||||||
|
|
||||||
fn construct<'a, I>(it: &mut I, args: Self::Args) -> Result<Self>
|
fn construct<'a, I>(it: &mut I, _: Self::Args) -> Result<Self>
|
||||||
where
|
where
|
||||||
I: Iterator<Item = &'a str>,
|
I: Iterator<Item = &'a str>,
|
||||||
{
|
{
|
||||||
|
|
|
@ -3,8 +3,7 @@ pub mod data;
|
||||||
pub mod illumination;
|
pub mod illumination;
|
||||||
pub mod input_file;
|
pub mod input_file;
|
||||||
pub mod sphere;
|
pub mod sphere;
|
||||||
|
pub mod triangle;
|
||||||
use nalgebra::{Matrix2x3, Vector3};
|
|
||||||
|
|
||||||
use crate::image::{Color, Image};
|
use crate::image::{Color, Image};
|
||||||
use crate::{Point, Vector};
|
use crate::{Point, Vector};
|
||||||
|
@ -35,8 +34,8 @@ pub struct Scene {
|
||||||
|
|
||||||
pub textures: Vec<Image>,
|
pub textures: Vec<Image>,
|
||||||
pub vertices: Vec<Point>,
|
pub vertices: Vec<Point>,
|
||||||
pub flat_triangles: Vec<Vector3<usize>>,
|
pub flat_triangles: Vec<(usize, usize, usize)>,
|
||||||
|
|
||||||
pub normals: Vec<Vector>,
|
pub normals: Vec<Vector>,
|
||||||
pub smooth_triangles: Vec<Matrix2x3<usize>>,
|
pub smooth_triangles: Vec<((usize, usize), (usize, usize), (usize, usize))>,
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,6 +4,7 @@ use ordered_float::NotNan;
|
||||||
use crate::{ray::Ray, utils::min_f64, Point};
|
use crate::{ray::Ray, utils::min_f64, Point};
|
||||||
|
|
||||||
use super::illumination::IntersectionContext;
|
use super::illumination::IntersectionContext;
|
||||||
|
use super::Scene;
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct Sphere {
|
pub struct Sphere {
|
||||||
|
@ -18,6 +19,7 @@ impl Sphere {
|
||||||
/// If there is no intersection point, returns None.
|
/// If there is no intersection point, returns None.
|
||||||
pub fn intersects_ray_at(
|
pub fn intersects_ray_at(
|
||||||
&self,
|
&self,
|
||||||
|
_: &Scene,
|
||||||
ray: &Ray,
|
ray: &Ray,
|
||||||
) -> Result<Option<IntersectionContext>> {
|
) -> Result<Option<IntersectionContext>> {
|
||||||
let a = ray.direction.norm();
|
let a = ray.direction.norm();
|
||||||
|
|
95
assignment-1c/src/scene/triangle.rs
Normal file
95
assignment-1c/src/scene/triangle.rs
Normal file
|
@ -0,0 +1,95 @@
|
||||||
|
use anyhow::Result;
|
||||||
|
use nalgebra::{Matrix2, Vector2, Vector3};
|
||||||
|
use ordered_float::NotNan;
|
||||||
|
|
||||||
|
use crate::ray::Ray;
|
||||||
|
use crate::utils::{cross, dot};
|
||||||
|
use crate::Point;
|
||||||
|
|
||||||
|
use super::illumination::IntersectionContext;
|
||||||
|
use super::Scene;
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct FlatTriangle {
|
||||||
|
pub vertices: Vector3<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FlatTriangle {
|
||||||
|
pub fn intersects_ray_at(
|
||||||
|
&self,
|
||||||
|
scene: &Scene,
|
||||||
|
ray: &Ray,
|
||||||
|
) -> Result<Option<IntersectionContext>> {
|
||||||
|
let p0 = scene.vertices[self.vertices.x];
|
||||||
|
let p1 = scene.vertices[self.vertices.y];
|
||||||
|
let p2 = scene.vertices[self.vertices.z];
|
||||||
|
|
||||||
|
// Solve for the plane equation coefficients A, B, C, D such that:
|
||||||
|
//
|
||||||
|
// $$
|
||||||
|
// Ax + By + Cz + D = 0
|
||||||
|
// $$
|
||||||
|
let e1 = p1 - p0;
|
||||||
|
let e2 = p2 - p0;
|
||||||
|
let n = cross(e1, e2);
|
||||||
|
let a = n.x;
|
||||||
|
let b = n.y;
|
||||||
|
let c = n.z;
|
||||||
|
|
||||||
|
// Sub in p0 to solve for D
|
||||||
|
let d = -(a * p0.x + b * p0.y + c * p0.z);
|
||||||
|
|
||||||
|
// Find the intersection point
|
||||||
|
let time = {
|
||||||
|
let (x0, y0, z0, xd, yd, zd) =
|
||||||
|
match (ray.origin.as_slice(), ray.direction.as_slice()) {
|
||||||
|
([x0, y0, z0], [xd, yd, zd]) => (x0, y0, z0, xd, yd, zd),
|
||||||
|
_ => unreachable!("lol rip no tuple interface"),
|
||||||
|
};
|
||||||
|
let denom = a * xd + b * yd + c * zd;
|
||||||
|
if denom == 0.0 {
|
||||||
|
// The ray is parallel to the plane, so there is no intersection point.
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
-(a * x0 + b * y0 + c * z0 + d) / denom
|
||||||
|
};
|
||||||
|
|
||||||
|
let time = NotNan::new(time)?;
|
||||||
|
let point = ray.eval(*time);
|
||||||
|
|
||||||
|
// Use barycentric coordinates to determine if the point is inside of the
|
||||||
|
// triangle
|
||||||
|
{
|
||||||
|
// p = p0 + beta * e1 + gamma * e2
|
||||||
|
// Using the whack linear algebra approach derived on slide 57
|
||||||
|
let ep = point - p0;
|
||||||
|
let d = Matrix2::new(dot(e1, e1), dot(e1, e2), dot(e2, e1), dot(e2, e2));
|
||||||
|
let p = Vector2::new(dot(e1, ep), dot(e2, ep));
|
||||||
|
|
||||||
|
let d_inv = match d.try_inverse() {
|
||||||
|
Some(v) => v,
|
||||||
|
// TODO: Whack
|
||||||
|
None => return Ok(None),
|
||||||
|
};
|
||||||
|
|
||||||
|
let sol = d_inv * p;
|
||||||
|
let beta = sol.x;
|
||||||
|
let gamma = sol.y;
|
||||||
|
|
||||||
|
// Slide 46
|
||||||
|
let alpha = 1.0 - beta - gamma;
|
||||||
|
|
||||||
|
// Each of alpha, beta, and gamma must be between 0 and 1
|
||||||
|
if ![alpha, beta, gamma].into_iter().all(|v| 0.0 < v && v < 1.0) {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let normal = n.normalize();
|
||||||
|
|
||||||
|
Ok(Some(IntersectionContext {
|
||||||
|
time,
|
||||||
|
point,
|
||||||
|
normal,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in a new issue