This commit is contained in:
lenn
2026-06-22 11:18:11 +08:00
parent 011bfe2450
commit b343e74a12
7 changed files with 232 additions and 45 deletions

View File

@@ -17,7 +17,6 @@ const ACTIVE_CELL_MIN_VALUE: f32 = 18.0;
const ACTIVE_CELL_PEAK_RATIO: f32 = 0.14; const ACTIVE_CELL_PEAK_RATIO: f32 = 0.14;
const MIN_ACTIVE_CELLS: usize = 3; const MIN_ACTIVE_CELLS: usize = 3;
const ANCHOR_LERP_ALPHA: f32 = 0.018;
const VECTOR_SMOOTHING_ALPHA: f32 = 0.16; const VECTOR_SMOOTHING_ALPHA: f32 = 0.16;
const REPORT_MAGNITUDE_ENTER: f32 = 0.12; const REPORT_MAGNITUDE_ENTER: f32 = 0.12;
@@ -29,6 +28,12 @@ const REPORT_HOLD_FRAMES: usize = 10;
const ASYMMETRY_WEIGHT: f32 = 1.1; const ASYMMETRY_WEIGHT: f32 = 1.1;
const DRIFT_WEIGHT: f32 = 0.65; const DRIFT_WEIGHT: f32 = 0.65;
const MOTION_WEIGHT: f32 = 0.25; const MOTION_WEIGHT: f32 = 0.25;
const EDGE_ASYMMETRY_DAMPING: f32 = 0.35;
const EDGE_INWARD_ROLLING_BIAS: f32 = 0.55;
const EDGE_START_COP_THRESHOLD: f32 = 0.45;
const EDGE_START_BIAS_WEIGHT: f32 = 1.1;
const ROLLING_FRICTION_ALPHA: f32 = 0.68;
const ROLLING_FRICTION_MIN_MAGNITUDE: f32 = 0.05;
#[derive(Debug, Clone, Copy)] #[derive(Debug, Clone, Copy)]
pub struct PztSpatialAnalysis { pub struct PztSpatialAnalysis {
@@ -50,6 +55,8 @@ pub struct PztProcessor {
anchor_cop_y: Option<f32>, anchor_cop_y: Option<f32>,
last_cop_x: Option<f32>, last_cop_x: Option<f32>,
last_cop_y: Option<f32>, last_cop_y: Option<f32>,
edge_start_bias_x: f32,
edge_start_bias_y: f32,
smoothed_x: f32, smoothed_x: f32,
smoothed_y: f32, smoothed_y: f32,
report_active: bool, report_active: bool,
@@ -84,6 +91,8 @@ impl PztProcessor {
anchor_cop_y: None, anchor_cop_y: None,
last_cop_x: None, last_cop_x: None,
last_cop_y: None, last_cop_y: None,
edge_start_bias_x: 0.0,
edge_start_bias_y: 0.0,
smoothed_x: 0.0, smoothed_x: 0.0,
smoothed_y: 0.0, smoothed_y: 0.0,
report_active: false, report_active: false,
@@ -100,6 +109,8 @@ impl PztProcessor {
self.anchor_cop_y = None; self.anchor_cop_y = None;
self.last_cop_x = None; self.last_cop_x = None;
self.last_cop_y = None; self.last_cop_y = None;
self.edge_start_bias_x = 0.0;
self.edge_start_bias_y = 0.0;
self.smoothed_x = 0.0; self.smoothed_x = 0.0;
self.smoothed_y = 0.0; self.smoothed_y = 0.0;
} }
@@ -273,6 +284,112 @@ impl PztProcessor {
(angle, magnitude) (angle, magnitude)
} }
fn contact_touches_edge(stats: &ContactStats) -> bool {
stats.min_row == 0
|| stats.max_row == SENSOR_ROWS - 1
|| stats.min_col == 0
|| stats.max_col == SENSOR_COLS - 1
}
fn damp_edge_asymmetry(
stats: &ContactStats,
kinematic_x: f32,
kinematic_y: f32,
) -> (f32, f32) {
let mut asymmetry_x = stats.asymmetry_x * ASYMMETRY_WEIGHT;
let mut asymmetry_y = stats.asymmetry_y * ASYMMETRY_WEIGHT;
if stats.min_col == 0 && asymmetry_x < 0.0 {
asymmetry_x = -asymmetry_x * EDGE_INWARD_ROLLING_BIAS;
}
if stats.max_col == SENSOR_COLS - 1 && asymmetry_x > 0.0 {
asymmetry_x = -asymmetry_x * EDGE_INWARD_ROLLING_BIAS;
}
if stats.min_row == 0 && asymmetry_y < 0.0 {
asymmetry_y = -asymmetry_y * EDGE_INWARD_ROLLING_BIAS;
}
if stats.max_row == SENSOR_ROWS - 1 && asymmetry_y > 0.0 {
asymmetry_y = -asymmetry_y * EDGE_INWARD_ROLLING_BIAS;
}
if Self::contact_touches_edge(stats) {
let opposing_dot = asymmetry_x * kinematic_x + asymmetry_y * kinematic_y;
let kinematic_mag = (kinematic_x * kinematic_x + kinematic_y * kinematic_y).sqrt();
if opposing_dot < 0.0 && kinematic_mag >= ROLLING_FRICTION_MIN_MAGNITUDE {
asymmetry_x *= EDGE_ASYMMETRY_DAMPING;
asymmetry_y *= EDGE_ASYMMETRY_DAMPING;
}
}
(asymmetry_x, asymmetry_y)
}
fn edge_start_bias(stats: &ContactStats) -> (f32, f32) {
let center_x = (SENSOR_COLS - 1) as f32 * 0.5;
let center_y = (SENSOR_ROWS - 1) as f32 * 0.5;
let normalized_x = ((stats.cop_x - center_x) / center_x.max(1.0)).clamp(-1.0, 1.0);
let normalized_y = ((stats.cop_y - center_y) / center_y.max(1.0)).clamp(-1.0, 1.0);
let mut bias_x = 0.0;
let mut bias_y = 0.0;
if stats.min_col == 0 || stats.max_col == SENSOR_COLS - 1 {
bias_x = Self::edge_start_axis_bias(normalized_x);
}
if stats.min_row == 0 || stats.max_row == SENSOR_ROWS - 1 {
bias_y = Self::edge_start_axis_bias(normalized_y);
}
(bias_x, bias_y)
}
fn edge_start_axis_bias(normalized_axis: f32) -> f32 {
let distance = normalized_axis.abs();
if distance <= EDGE_START_COP_THRESHOLD {
return 0.0;
}
let strength = ((distance - EDGE_START_COP_THRESHOLD) / (1.0 - EDGE_START_COP_THRESHOLD))
.clamp(0.0, 1.0);
-normalized_axis.signum() * strength * EDGE_START_BIAS_WEIGHT
}
fn apply_rolling_friction(
previous_x: f32,
previous_y: f32,
current_x: f32,
current_y: f32,
) -> (f32, f32) {
let previous_mag = (previous_x * previous_x + previous_y * previous_y).sqrt();
let current_mag = (current_x * current_x + current_y * current_y).sqrt();
if previous_mag < ROLLING_FRICTION_MIN_MAGNITUDE
|| current_mag < ROLLING_FRICTION_MIN_MAGNITUDE
{
return (current_x, current_y);
}
let dot = previous_x * current_x + previous_y * current_y;
if dot >= 0.0 {
return (current_x, current_y);
}
let mixed_x = current_x * (1.0 - ROLLING_FRICTION_ALPHA)
+ previous_x * ROLLING_FRICTION_ALPHA;
let mixed_y = current_y * (1.0 - ROLLING_FRICTION_ALPHA)
+ previous_y * ROLLING_FRICTION_ALPHA;
if mixed_x * previous_x + mixed_y * previous_y >= 0.0 {
return (mixed_x, mixed_y);
}
let keep_mag = previous_mag.min(current_mag) * 0.5;
(
previous_x / previous_mag * keep_mag,
previous_y / previous_mag * keep_mag,
)
}
fn update_contact_state(&mut self, raw_frame: &[f32], frame: &[f32]) -> bool { fn update_contact_state(&mut self, raw_frame: &[f32], frame: &[f32]) -> bool {
if self.contact_active { if self.contact_active {
if Self::is_contact_exit_frame(frame) { if Self::is_contact_exit_frame(frame) {
@@ -376,6 +493,9 @@ impl PztProcessor {
self.anchor_cop_y = Some(stats.cop_y); self.anchor_cop_y = Some(stats.cop_y);
self.last_cop_x = Some(stats.cop_x); self.last_cop_x = Some(stats.cop_x);
self.last_cop_y = Some(stats.cop_y); self.last_cop_y = Some(stats.cop_y);
let (edge_start_bias_x, edge_start_bias_y) = Self::edge_start_bias(&stats);
self.edge_start_bias_x = edge_start_bias_x;
self.edge_start_bias_y = edge_start_bias_y;
return Ok(self.stabilize_report(Self::weak_contact_analysis())); return Ok(self.stabilize_report(Self::weak_contact_analysis()));
}; };
@@ -388,18 +508,25 @@ impl PztProcessor {
let motion_x = stats.cop_x - last_x; let motion_x = stats.cop_x - last_x;
let motion_y = stats.cop_y - last_y; let motion_y = stats.cop_y - last_y;
let combined_x = stats.asymmetry_x * ASYMMETRY_WEIGHT let kinematic_x = drift_x * DRIFT_WEIGHT + motion_x * MOTION_WEIGHT;
+ drift_x * DRIFT_WEIGHT let kinematic_y = drift_y * DRIFT_WEIGHT + motion_y * MOTION_WEIGHT;
+ motion_x * MOTION_WEIGHT; let edge_bias_x = self.edge_start_bias_x;
let combined_y = stats.asymmetry_y * ASYMMETRY_WEIGHT let edge_bias_y = self.edge_start_bias_y;
+ drift_y * DRIFT_WEIGHT let (asymmetry_x, asymmetry_y) =
+ motion_y * MOTION_WEIGHT; Self::damp_edge_asymmetry(&stats, kinematic_x + edge_bias_x, kinematic_y + edge_bias_y);
let combined_x = asymmetry_x + kinematic_x + edge_bias_x;
let combined_y = asymmetry_y + kinematic_y + edge_bias_y;
let (combined_x, combined_y) = Self::apply_rolling_friction(
self.smoothed_x,
self.smoothed_y,
combined_x,
combined_y,
);
self.smoothed_x += (combined_x - self.smoothed_x) * VECTOR_SMOOTHING_ALPHA; self.smoothed_x += (combined_x - self.smoothed_x) * VECTOR_SMOOTHING_ALPHA;
self.smoothed_y += (combined_y - self.smoothed_y) * VECTOR_SMOOTHING_ALPHA; self.smoothed_y += (combined_y - self.smoothed_y) * VECTOR_SMOOTHING_ALPHA;
self.anchor_cop_x = Some(anchor_x + drift_x * ANCHOR_LERP_ALPHA);
self.anchor_cop_y = Some(anchor_y + drift_y * ANCHOR_LERP_ALPHA);
self.last_cop_x = Some(stats.cop_x); self.last_cop_x = Some(stats.cop_x);
self.last_cop_y = Some(stats.cop_y); self.last_cop_y = Some(stats.cop_y);
@@ -446,7 +573,7 @@ impl PztProcessor {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{PztProcessor, SENSOR_COLS, SENSOR_ROWS}; use super::{ContactStats, PztProcessor, SENSOR_COLS, SENSOR_ROWS};
fn index(row: usize, col: usize) -> usize { fn index(row: usize, col: usize) -> usize {
row * SENSOR_COLS + col row * SENSOR_COLS + col
@@ -460,6 +587,23 @@ mod tests {
frame frame
} }
fn stats_touching_bottom_edge() -> ContactStats {
ContactStats {
total: 1000.0,
peak: 300.0,
active_total: 900.0,
active_cells: 6,
min_row: SENSOR_ROWS - 2,
max_row: SENSOR_ROWS - 1,
min_col: 2,
max_col: 4,
cop_x: 3.0,
cop_y: 10.5,
asymmetry_x: 0.0,
asymmetry_y: 1.0,
}
}
#[test] #[test]
fn idle_frame_does_not_report_contact() { fn idle_frame_does_not_report_contact() {
let mut processor = PztProcessor::new(); let mut processor = PztProcessor::new();
@@ -524,4 +668,29 @@ mod tests {
let analysis = processor.get_pzt_analysis(&weak).unwrap(); let analysis = processor.get_pzt_analysis(&weak).unwrap();
assert!(analysis.reportable); assert!(analysis.reportable);
} }
#[test]
fn bottom_edge_outward_gradient_is_turned_inward() {
let stats = stats_touching_bottom_edge();
let (_asymmetry_x, asymmetry_y) = PztProcessor::damp_edge_asymmetry(&stats, 0.0, -0.2);
assert!(asymmetry_y < 0.0);
assert!(asymmetry_y > -1.1);
}
#[test]
fn bottom_edge_start_adds_fixed_upward_bias() {
let stats = stats_touching_bottom_edge();
let (_bias_x, bias_y) = PztProcessor::edge_start_bias(&stats);
assert!(bias_y < 0.0);
}
#[test]
fn rolling_friction_resists_one_frame_reversal() {
let (x, y) = PztProcessor::apply_rolling_friction(0.4, 0.0, -0.6, 0.0);
assert!(x > 0.0);
assert_eq!(y, 0.0);
}
} }

View File

@@ -48,7 +48,7 @@
export let colorMapPreset: PressureColorMapPreset = "emerald"; export let colorMapPreset: PressureColorMapPreset = "emerald";
export let matrixDisplayMode: MatrixDisplayMode = "dots"; export let matrixDisplayMode: MatrixDisplayMode = "dots";
export let stageViewMode: StageViewMode = "webgl"; export let stageViewMode: StageViewMode = "webgl";
export let modelUrl = "/models/je-skin-model.glb"; export let modelUrl = "/models/je-skin-model.gltf";
export let replaySectionLabel = ""; export let replaySectionLabel = "";
export let replayPlayLabel = ""; export let replayPlayLabel = "";
export let replayPauseLabel = ""; export let replayPauseLabel = "";

View File

@@ -19,12 +19,13 @@
const FLOOR_Y = -1.15; const FLOOR_Y = -1.15;
const MODEL_FLOOR_CLEARANCE = 0.035; const MODEL_FLOOR_CLEARANCE = 0.035;
const MODEL_TARGET_HEIGHT = 8.4; const MODEL_TARGET_HEIGHT = 7.4;
const MODEL_MIN_SCALE = 0.02; const MODEL_MIN_SCALE = 0.02;
const MODEL_MAX_SCALE = 80; const MODEL_MAX_SCALE = 80;
const CAMERA_DISTANCE_FACTOR = 1.35; const FIXED_CAMERA_POSITION = new THREE.Vector3(0.8, 3.25, 12.2);
const CAMERA_DISTANCE_MIN = 7.5; const FIXED_CAMERA_TARGET = new THREE.Vector3(0, 2.55, 0);
const CAMERA_DISTANCE_MAX = 24; const MODEL_FRONT_ROTATION_Y = -Math.PI / 2;
const MODEL_UPRIGHT_ROTATION_Z = Math.PI * 1.5;
$: copy = $: copy =
locale === "zh-CN" locale === "zh-CN"
@@ -128,7 +129,41 @@
return Math.min(max, Math.max(min, value)); return Math.min(max, Math.max(min, value));
} }
function materialToUnlit(material: THREE.Material): THREE.Material {
const source = material as THREE.MeshStandardMaterial & THREE.MeshPhysicalMaterial;
const unlit = new THREE.MeshBasicMaterial({
color: source.color?.clone() ?? new THREE.Color(0xffffff),
map: source.map ?? null,
transparent: source.transparent,
opacity: source.opacity,
alphaMap: source.alphaMap ?? null,
side: source.side,
depthWrite: source.depthWrite,
depthTest: source.depthTest,
vertexColors: source.vertexColors,
toneMapped: false
});
return unlit;
}
function applyUnlitMaterials(object: THREE.Object3D): void {
object.traverse((child) => {
const mesh = child as THREE.Mesh;
if (!mesh.isMesh || !mesh.material) {
return;
}
mesh.castShadow = false;
mesh.receiveShadow = false;
mesh.material = Array.isArray(mesh.material)
? mesh.material.map(materialToUnlit)
: materialToUnlit(mesh.material);
});
}
function normalizeObjectToStage(object: THREE.Object3D): THREE.Box3 { function normalizeObjectToStage(object: THREE.Object3D): THREE.Box3 {
object.rotation.set(0, MODEL_FRONT_ROTATION_Y, MODEL_UPRIGHT_ROTATION_Z);
object.updateMatrixWorld(true); object.updateMatrixWorld(true);
let bounds = new THREE.Box3().setFromObject(object); let bounds = new THREE.Box3().setFromObject(object);
const size = bounds.getSize(new THREE.Vector3()); const size = bounds.getSize(new THREE.Vector3());
@@ -149,19 +184,14 @@
} }
function frameObject(object: THREE.Object3D, camera: THREE.PerspectiveCamera, controls: OrbitControls): void { function frameObject(object: THREE.Object3D, camera: THREE.PerspectiveCamera, controls: OrbitControls): void {
const bounds = normalizeObjectToStage(object); normalizeObjectToStage(object);
const size = bounds.getSize(new THREE.Vector3()); camera.position.copy(FIXED_CAMERA_POSITION);
const maxAxis = Math.max(size.x, size.y, size.z, 1); camera.near = 0.05;
const distance = clamp(maxAxis * CAMERA_DISTANCE_FACTOR, CAMERA_DISTANCE_MIN, CAMERA_DISTANCE_MAX); camera.far = 600;
const targetY = FLOOR_Y + Math.max(size.y * 0.46, 1.4);
camera.position.set(distance * 0.48, targetY + distance * 0.24, distance * 0.68);
camera.near = Math.max(distance / 80, 0.01);
camera.far = distance * 24;
camera.updateProjectionMatrix(); camera.updateProjectionMatrix();
controls.target.set(0, targetY, 0); controls.target.copy(FIXED_CAMERA_TARGET);
controls.minDistance = Math.max(distance * 0.32, 2); controls.minDistance = 2.4;
controls.maxDistance = Math.max(distance * 2.5, 12); controls.maxDistance = 32;
controls.update(); controls.update();
} }
@@ -186,14 +216,15 @@
scene.fog = new THREE.FogExp2(0x03070d, 0.028); scene.fog = new THREE.FogExp2(0x03070d, 0.028);
const camera = new THREE.PerspectiveCamera(38, 1, 0.05, 600); const camera = new THREE.PerspectiveCamera(38, 1, 0.05, 600);
camera.position.set(8, 6, 9); camera.position.copy(FIXED_CAMERA_POSITION);
camera.lookAt(FIXED_CAMERA_TARGET);
const controls = new OrbitControls(camera, canvasEl); const controls = new OrbitControls(camera, canvasEl);
controls.enableDamping = true; controls.enableDamping = true;
controls.dampingFactor = 0.08; controls.dampingFactor = 0.08;
controls.minDistance = 2.4; controls.minDistance = 2.4;
controls.maxDistance = 32; controls.maxDistance = 32;
controls.target.set(0, FLOOR_Y + 3.2, 0); controls.target.copy(FIXED_CAMERA_TARGET);
const labGroup = new THREE.Group(); const labGroup = new THREE.Group();
scene.add(labGroup); scene.add(labGroup);
@@ -241,16 +272,8 @@
floor.position.y = FLOOR_Y - 0.018; floor.position.y = FLOOR_Y - 0.018;
labGroup.add(floor); labGroup.add(floor);
const ambient = new THREE.AmbientLight(0x9fb8d0, 0.22);
const keyLight = new THREE.DirectionalLight(0x7be7ff, 1.5);
keyLight.position.set(8, 12, 8);
const rimLight = new THREE.PointLight(0xa6ff7a, 26, 24, 2.1);
rimLight.position.set(-4.5, 4.8, -3.6);
const sideLight = new THREE.PointLight(0x5c8cff, 15, 28, 1.7);
sideLight.position.set(5.8, 3.2, -5.4);
scene.add(ambient, keyLight, rimLight, sideLight);
let activeModel: THREE.Object3D = buildPlaceholderModel(); let activeModel: THREE.Object3D = buildPlaceholderModel();
applyUnlitMaterials(activeModel);
scene.add(activeModel); scene.add(activeModel);
frameObject(activeModel, camera, controls); frameObject(activeModel, camera, controls);
@@ -261,13 +284,7 @@
scene.remove(activeModel); scene.remove(activeModel);
disposeObject3D(activeModel); disposeObject3D(activeModel);
activeModel = gltf.scene; activeModel = gltf.scene;
activeModel.traverse((child) => { applyUnlitMaterials(activeModel);
const mesh = child as THREE.Mesh;
if (mesh.isMesh) {
mesh.castShadow = true;
mesh.receiveShadow = true;
}
});
scene.add(activeModel); scene.add(activeModel);
frameObject(activeModel, camera, controls); frameObject(activeModel, camera, controls);
loadState = "ready"; loadState = "ready";

Binary file not shown.

Binary file not shown.

File diff suppressed because one or more lines are too long