Some more refactoring, more error correction. When will this end?
This commit is contained in:
parent
93bcd465eb
commit
380b1c9f90
6 changed files with 492 additions and 1167 deletions
71
src/integrators/base.rs
Normal file
71
src/integrators/base.rs
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
use super::state::PathState;
|
||||||
|
use crate::core::light::Light;
|
||||||
|
use shared::core::geometry::Ray;
|
||||||
|
use shared::core::interaction::{Interaction, InteractionTrait};
|
||||||
|
use shared::core::primitive::Primitive;
|
||||||
|
use shared::core::shape::ShapeIntersection;
|
||||||
|
use shared::lights::LightSampler;
|
||||||
|
use shared::spectra::SampledWavelengths;
|
||||||
|
use shared::utils::sampling::power_heuristic;
|
||||||
|
use shared::{Float, SHADOW_EPSILON};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct IntegratorBase {
|
||||||
|
pub aggregate: Arc<Primitive>,
|
||||||
|
pub lights: Vec<Arc<Light>>,
|
||||||
|
pub infinite_lights: Vec<Arc<Light>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IntegratorBase {
|
||||||
|
pub fn new(aggregate: Arc<Primitive>, lights: Vec<Arc<Light>>) -> Self {
|
||||||
|
let infinite_lights = lights
|
||||||
|
.iter()
|
||||||
|
.filter(|light| light.light_type().is_infinite())
|
||||||
|
.cloned()
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Self {
|
||||||
|
aggregate,
|
||||||
|
lights,
|
||||||
|
infinite_lights,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn intersect(&self, ray: &Ray, t_max: Option<Float>) -> Option<ShapeIntersection> {
|
||||||
|
self.aggregate.intersect(ray, t_max)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn intersect_p(&self, ray: &Ray, t_max: Option<Float>) -> bool {
|
||||||
|
self.aggregate.intersect_p(ray, t_max)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn unoccluded(&self, p0: &Interaction, p1: &Interaction) -> bool {
|
||||||
|
!self.intersect_p(&p0.spawn_ray_to_interaction(p1), Some(1. - SHADOW_EPSILON))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add_infinite_light_contribution(
|
||||||
|
&self,
|
||||||
|
state: &mut PathState,
|
||||||
|
ray: &Ray,
|
||||||
|
lambda: &SampledWavelengths,
|
||||||
|
light_sampler: Option<&LightSampler>,
|
||||||
|
use_mis: bool,
|
||||||
|
) {
|
||||||
|
for light in &self.infinite_lights {
|
||||||
|
let le = light.le(ray, lambda);
|
||||||
|
if le.is_black() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if state.depth == 0 || state.specular_bounce || !use_mis {
|
||||||
|
state.l += state.beta * le;
|
||||||
|
} else if let Some(sampler) = light_sampler {
|
||||||
|
let p_l = sampler.pmf_with_context(&state.prev_ctx, light)
|
||||||
|
* light.pdf_li(&state.prev_ctx, ray.d, true);
|
||||||
|
let w_b = power_heuristic(1, state.prev_pdf, 1, p_l);
|
||||||
|
state.l += state.beta * w_b * le;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
42
src/integrators/constants.rs
Normal file
42
src/integrators/constants.rs
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
use shared::core::geometry::Point2f;
|
||||||
|
use shared::Float;
|
||||||
|
|
||||||
|
pub const N_RHO_SAMPLES: usize = 16;
|
||||||
|
|
||||||
|
pub static UC_RHO: [Float; N_RHO_SAMPLES] = [
|
||||||
|
0.75741637,
|
||||||
|
0.37870818,
|
||||||
|
0.7083487,
|
||||||
|
0.18935409,
|
||||||
|
0.9149363,
|
||||||
|
0.35417435,
|
||||||
|
0.5990858,
|
||||||
|
0.09467703,
|
||||||
|
0.8578725,
|
||||||
|
0.45746812,
|
||||||
|
0.686759,
|
||||||
|
0.17708716,
|
||||||
|
0.9674518,
|
||||||
|
0.2995429,
|
||||||
|
0.5083201,
|
||||||
|
0.047338516,
|
||||||
|
];
|
||||||
|
|
||||||
|
pub static U_RHO: [Point2f; N_RHO_SAMPLES] = [
|
||||||
|
Point2f::new(0.855985, 0.570367),
|
||||||
|
Point2f::new(0.381823, 0.851844),
|
||||||
|
Point2f::new(0.285328, 0.764262),
|
||||||
|
Point2f::new(0.733380, 0.114073),
|
||||||
|
Point2f::new(0.542663, 0.344465),
|
||||||
|
Point2f::new(0.127274, 0.414848),
|
||||||
|
Point2f::new(0.964700, 0.947162),
|
||||||
|
Point2f::new(0.594089, 0.643463),
|
||||||
|
Point2f::new(0.095109, 0.170369),
|
||||||
|
Point2f::new(0.825444, 0.263359),
|
||||||
|
Point2f::new(0.429467, 0.454469),
|
||||||
|
Point2f::new(0.244460, 0.816459),
|
||||||
|
Point2f::new(0.756135, 0.731258),
|
||||||
|
Point2f::new(0.516165, 0.152852),
|
||||||
|
Point2f::new(0.180888, 0.214174),
|
||||||
|
Point2f::new(0.898579, 0.503897),
|
||||||
|
];
|
||||||
File diff suppressed because it is too large
Load diff
320
src/integrators/path.rs
Normal file
320
src/integrators/path.rs
Normal file
|
|
@ -0,0 +1,320 @@
|
||||||
|
use super::RayIntegratorTrait;
|
||||||
|
use super::base::IntegratorBase;
|
||||||
|
use super::constants::*;
|
||||||
|
use super::state::PathState;
|
||||||
|
use crate::Arena;
|
||||||
|
use shared::core::bsdf::{BSDF, BSDFSample};
|
||||||
|
use shared::core::bxdf::{BxDFFlags, FArgs, TransportMode};
|
||||||
|
use shared::core::camera::Camera;
|
||||||
|
use shared::core::film::VisibleSurface;
|
||||||
|
use shared::core::geometry::{Point2i, Ray, Vector3f, VectorLike};
|
||||||
|
use shared::core::interaction::{Interaction, InteractionTrait, SurfaceInteraction};
|
||||||
|
use shared::core::light::{Light, LightSampleContext};
|
||||||
|
use shared::core::primitive::Primitive;
|
||||||
|
use shared::core::sampler::{Sampler, SamplerTrait};
|
||||||
|
use shared::lights::sampler::LightSampler;
|
||||||
|
use shared::lights::sampler::LightSamplerTrait;
|
||||||
|
use shared::spectra::{SampledSpectrum, SampledWavelengths};
|
||||||
|
use shared::utils::math::square;
|
||||||
|
use shared::utils::sampling::{
|
||||||
|
power_heuristic, sample_uniform_hemisphere, sample_uniform_sphere, uniform_hemisphere_pdf,
|
||||||
|
uniform_sphere_pdf,
|
||||||
|
};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
pub struct PathConfig {
|
||||||
|
pub max_depth: usize,
|
||||||
|
pub regularize: bool,
|
||||||
|
pub sample_lights: bool,
|
||||||
|
pub sample_bsdf: bool,
|
||||||
|
pub handle_volumes: bool,
|
||||||
|
pub use_mis: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PathConfig {
|
||||||
|
pub const SIMPLE: Self = Self {
|
||||||
|
max_depth: 5,
|
||||||
|
regularize: false,
|
||||||
|
sample_lights: true,
|
||||||
|
sample_bsdf: true,
|
||||||
|
handle_volumes: false,
|
||||||
|
use_mis: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub const FULL: Self = Self {
|
||||||
|
max_depth: 8,
|
||||||
|
regularize: true,
|
||||||
|
sample_lights: true,
|
||||||
|
sample_bsdf: true,
|
||||||
|
handle_volumes: false,
|
||||||
|
use_mis: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub const VOLUMETRIC: Self = Self {
|
||||||
|
max_depth: 8,
|
||||||
|
regularize: true,
|
||||||
|
sample_lights: true,
|
||||||
|
sample_bsdf: true,
|
||||||
|
handle_volumes: true,
|
||||||
|
use_mis: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct PathIntegrator {
|
||||||
|
base: IntegratorBase,
|
||||||
|
camera: Arc<Camera>,
|
||||||
|
light_sampler: LightSampler,
|
||||||
|
config: PathConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe impl Send for PathIntegrator {}
|
||||||
|
unsafe impl Sync for PathIntegrator {}
|
||||||
|
|
||||||
|
impl PathIntegrator {
|
||||||
|
pub fn new(
|
||||||
|
aggregate: Arc<Primitive>,
|
||||||
|
lights: Vec<Arc<Light>>,
|
||||||
|
camera: Arc<Camera>,
|
||||||
|
config: PathConfig,
|
||||||
|
) -> Self {
|
||||||
|
let base = IntegratorBase::new(aggregate, lights.clone());
|
||||||
|
let light_sampler = LightSampler::new(&lights);
|
||||||
|
Self {
|
||||||
|
base,
|
||||||
|
camera,
|
||||||
|
light_sampler,
|
||||||
|
config,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sample_direct(
|
||||||
|
&self,
|
||||||
|
intr: &SurfaceInteraction,
|
||||||
|
bsdf: &BSDF,
|
||||||
|
state: &PathState,
|
||||||
|
lambda: &SampledWavelengths,
|
||||||
|
sampler: &mut Sampler,
|
||||||
|
) -> SampledSpectrum {
|
||||||
|
let ctx = LightSampleContext::from(intr);
|
||||||
|
|
||||||
|
let Some(sampled) = self
|
||||||
|
.light_sampler
|
||||||
|
.sample_with_context(&ctx, sampler.get1d())
|
||||||
|
else {
|
||||||
|
return SampledSpectrum::ZERO;
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(ls) = sampled.light.sample_li(&ctx, sampler.get2d(), lambda, true) else {
|
||||||
|
return SampledSpectrum::ZERO;
|
||||||
|
};
|
||||||
|
|
||||||
|
if ls.l.is_black() || ls.pdf == 0.0 {
|
||||||
|
return SampledSpectrum::ZERO;
|
||||||
|
}
|
||||||
|
|
||||||
|
let wo = intr.wo();
|
||||||
|
let wi = ls.wi;
|
||||||
|
|
||||||
|
let Some(f) = bsdf.f(wo, wi, TransportMode::Radiance) else {
|
||||||
|
return SampledSpectrum::ZERO;
|
||||||
|
};
|
||||||
|
|
||||||
|
let f = f * wi.abs_dot(intr.shading.n.into());
|
||||||
|
if f.is_black() {
|
||||||
|
return SampledSpectrum::ZERO;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !self
|
||||||
|
.base
|
||||||
|
.unoccluded(&Interaction::Surface(intr.clone()), &ls.p_light)
|
||||||
|
{
|
||||||
|
return SampledSpectrum::ZERO;
|
||||||
|
}
|
||||||
|
|
||||||
|
let p_l = sampled.p * ls.pdf;
|
||||||
|
|
||||||
|
if !self.config.use_mis || sampled.light.light_type().is_delta_light() {
|
||||||
|
ls.l * f / p_l
|
||||||
|
} else {
|
||||||
|
let p_b = bsdf.pdf(wo, wi, FArgs::default());
|
||||||
|
let w_l = power_heuristic(1, p_l, 1, p_b);
|
||||||
|
w_l * ls.l * f / p_l
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sample_direction(
|
||||||
|
&self,
|
||||||
|
wo: Vector3f,
|
||||||
|
bsdf: &BSDF,
|
||||||
|
isect: &SurfaceInteraction,
|
||||||
|
sampler: &mut Sampler,
|
||||||
|
) -> Option<BSDFSample> {
|
||||||
|
if self.config.sample_bsdf {
|
||||||
|
bsdf.sample_f(wo, sampler.get1d(), sampler.get2d(), FArgs::default())
|
||||||
|
} else {
|
||||||
|
self.sample_uniform(wo, bsdf, isect, sampler)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sample_uniform(
|
||||||
|
&self,
|
||||||
|
wo: Vector3f,
|
||||||
|
bsdf: &BSDF,
|
||||||
|
isect: &SurfaceInteraction,
|
||||||
|
sampler: &mut Sampler,
|
||||||
|
) -> Option<BSDFSample> {
|
||||||
|
let flags = bsdf.flags();
|
||||||
|
|
||||||
|
let (wi, pdf) = if flags.is_reflective() && flags.is_transmissive() {
|
||||||
|
(sample_uniform_sphere(sampler.get2d()), uniform_sphere_pdf())
|
||||||
|
} else {
|
||||||
|
let mut wi = sample_uniform_hemisphere(sampler.get2d());
|
||||||
|
let same_hemi = wo.dot(isect.n().into()) * wi.dot(isect.n().into()) > 0.0;
|
||||||
|
|
||||||
|
if (flags.is_reflective() && !same_hemi) || (flags.is_transmissive() && same_hemi) {
|
||||||
|
wi = -wi;
|
||||||
|
}
|
||||||
|
(wi, uniform_hemisphere_pdf())
|
||||||
|
};
|
||||||
|
|
||||||
|
let f = bsdf.f(wo, wi, TransportMode::Radiance)?;
|
||||||
|
|
||||||
|
Some(BSDFSample {
|
||||||
|
f,
|
||||||
|
wi,
|
||||||
|
pdf,
|
||||||
|
flags: BxDFFlags::empty(), // or appropriate flags
|
||||||
|
eta: 1.0,
|
||||||
|
pdf_is_proportional: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compute_visible_surface(
|
||||||
|
&self,
|
||||||
|
isect: &SurfaceInteraction,
|
||||||
|
bsdf: &BSDF,
|
||||||
|
lambda: &SampledWavelengths,
|
||||||
|
) -> VisibleSurface {
|
||||||
|
let albedo = bsdf.rho_wo(isect.wo(), &UC_RHO, &U_RHO);
|
||||||
|
VisibleSurface::new(isect, &albedo, lambda)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RayIntegratorTrait for PathIntegrator {
|
||||||
|
fn li(
|
||||||
|
&self,
|
||||||
|
mut ray: Ray,
|
||||||
|
lambda: &SampledWavelengths,
|
||||||
|
sampler: &mut Sampler,
|
||||||
|
want_visible: bool,
|
||||||
|
_arena: &mut Arena,
|
||||||
|
) -> (SampledSpectrum, Option<VisibleSurface>) {
|
||||||
|
let mut state = PathState::new();
|
||||||
|
let mut visible = None;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let Some(mut si) = self.base.intersect(&ray, None) else {
|
||||||
|
self.base.add_infinite_light_contribution(
|
||||||
|
&mut state,
|
||||||
|
&ray,
|
||||||
|
lambda,
|
||||||
|
Some(&self.light_sampler),
|
||||||
|
self.config.use_mis,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
|
||||||
|
let isect = &mut si.intr;
|
||||||
|
|
||||||
|
// Emission from hit surface
|
||||||
|
let le = isect.le(-ray.d, lambda);
|
||||||
|
if !le.is_black() {
|
||||||
|
if state.depth == 0 || state.specular_bounce {
|
||||||
|
state.l += state.beta * le;
|
||||||
|
} else if self.config.use_mis {
|
||||||
|
if let Some(light) = &isect.area_light {
|
||||||
|
let p_l = self.light_sampler.pmf_with_context(&state.prev_ctx, light)
|
||||||
|
* light.pdf_li(&state.prev_ctx, ray.d, true);
|
||||||
|
let w_b = power_heuristic(1, state.prev_pdf, 1, p_l);
|
||||||
|
state.l += state.beta * w_b * le;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get BSDF
|
||||||
|
let Some(mut bsdf) = isect.get_bsdf(&ray, lambda, &self.camera, sampler) else {
|
||||||
|
state.specular_bounce = true;
|
||||||
|
isect.skip_intersection(&mut ray, si.t_hit());
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Visible surface at primary hit
|
||||||
|
if state.depth == 0 && want_visible {
|
||||||
|
visible = Some(self.compute_visible_surface(isect, &bsdf, lambda));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Depth check
|
||||||
|
if state.depth >= self.config.max_depth {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
state.depth += 1;
|
||||||
|
|
||||||
|
// Regularization
|
||||||
|
if self.config.regularize && state.any_non_specular_bounces {
|
||||||
|
bsdf.regularize();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Direct lighting
|
||||||
|
if self.config.sample_lights && bsdf.flags().is_non_specular() {
|
||||||
|
state.l += state.beta * self.sample_direct(isect, &bsdf, &state, lambda, sampler);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sample BSDF for next direction
|
||||||
|
let wo = -ray.d;
|
||||||
|
let Some(bs) = self.sample_direction(wo, &bsdf, isect, sampler) else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
|
||||||
|
state.beta *= bs.f * bs.wi.abs_dot(isect.shading.n.into()) / bs.pdf;
|
||||||
|
state.prev_pdf = if bs.pdf_is_proportional {
|
||||||
|
bsdf.pdf(wo, bs.wi, FArgs::default())
|
||||||
|
} else {
|
||||||
|
bs.pdf
|
||||||
|
};
|
||||||
|
state.specular_bounce = bs.is_specular();
|
||||||
|
state.any_non_specular_bounces |= !bs.is_specular();
|
||||||
|
|
||||||
|
if bs.is_transmissive() {
|
||||||
|
state.eta_scale *= square(bs.eta);
|
||||||
|
}
|
||||||
|
|
||||||
|
state.prev_ctx = LightSampleContext::from(&*isect);
|
||||||
|
ray = isect.spawn_ray_with_differentials(&ray, bs.wi, bs.flags, bs.eta);
|
||||||
|
|
||||||
|
// Russian roulette
|
||||||
|
if state.russian_roulette(sampler, 1) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
(state.l, visible)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn evaluate_pixel_sample(
|
||||||
|
&self,
|
||||||
|
p_pixel: Point2i,
|
||||||
|
sample_ind: usize,
|
||||||
|
sampler: &mut Sampler,
|
||||||
|
arena: &mut Arena,
|
||||||
|
) {
|
||||||
|
crate::integrators::pipeline::evaluate_pixel_sample(
|
||||||
|
self,
|
||||||
|
self.camera.as_ref(),
|
||||||
|
sampler,
|
||||||
|
p_pixel,
|
||||||
|
sample_ind,
|
||||||
|
arena,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,12 +1,19 @@
|
||||||
|
use super::RayIntegratorTrait;
|
||||||
|
use super::base::IntegratorBase;
|
||||||
|
use crate::Arena;
|
||||||
use crate::core::image::{Image, ImageMetadata};
|
use crate::core::image::{Image, ImageMetadata};
|
||||||
use crate::spectra::get_spectra_context;
|
use crate::spectra::get_spectra_context;
|
||||||
use indicatif::{ProgressBar, ProgressStyle};
|
use indicatif::{ProgressBar, ProgressStyle};
|
||||||
|
use shared::Float;
|
||||||
|
use shared::core::camera::Camera;
|
||||||
|
use shared::core::geometry::{Bounds2i, Point2i};
|
||||||
|
use shared::core::options::get_options;
|
||||||
|
use shared::core::sampler::Sampler;
|
||||||
use shared::core::sampler::get_camera_sample;
|
use shared::core::sampler::get_camera_sample;
|
||||||
|
use shared::spectra::SampledSpectrum;
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
struct PbrtProgress {
|
struct PbrtProgress {
|
||||||
bar: ProgressBar,
|
bar: ProgressBar,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
45
src/integrators/state.rs
Normal file
45
src/integrators/state.rs
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
use shared::Float;
|
||||||
|
use shared::core::light::LightSampleContext;
|
||||||
|
use shared::core::sampler::{Sampler, SamplerTrait};
|
||||||
|
use shared::spectra::SampledSpectrum;
|
||||||
|
|
||||||
|
pub struct PathState {
|
||||||
|
pub l: SampledSpectrum,
|
||||||
|
pub beta: SampledSpectrum,
|
||||||
|
pub depth: usize,
|
||||||
|
pub specular_bounce: bool,
|
||||||
|
pub any_non_specular_bounces: bool,
|
||||||
|
pub eta_scale: Float,
|
||||||
|
pub prev_ctx: LightSampleContext,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PathState {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
l: SampledSpectrum::new(0.0),
|
||||||
|
beta: SampledSpectrum::new(1.0),
|
||||||
|
depth: 0,
|
||||||
|
specular_bounce: false,
|
||||||
|
any_non_specular_bounces: false,
|
||||||
|
eta_scale: 1.0,
|
||||||
|
prev_ctx: LightSampleContext::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn terminated(&self) -> bool {
|
||||||
|
self.beta.is_black()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Termination check
|
||||||
|
pub fn russian_roulette(&mut self, sampler: &mut Sampler, min_depth: usize) -> bool {
|
||||||
|
let rr_beta = self.beta * self.eta_scale;
|
||||||
|
if rr_beta.max_component_value() < 1.0 && self.depth > min_depth {
|
||||||
|
let q = (1.0 - rr_beta.max_component_value()).max(0.0);
|
||||||
|
if sampler.get1d() < q {
|
||||||
|
return true; // terminate
|
||||||
|
}
|
||||||
|
self.beta /= 1.0 - q;
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue