diff --git a/crates/misaligned-assets/src/effects.rs b/crates/misaligned-assets/src/effects.rs index 641c345a..c235c537 100644 --- a/crates/misaligned-assets/src/effects.rs +++ b/crates/misaligned-assets/src/effects.rs @@ -185,6 +185,10 @@ pub struct MaterialEffects { /// Local-space route endpoints. The presentation bends between them. pub route_start: Vec3, pub route_end: Vec3, + /// Optional exact local-space physical route. Callers that own placed + /// wire geometry provide every bend here; the standalone lab leaves this + /// unset and exercises the authored start/end curve above. + pub route_points: Option>, } impl Default for MaterialEffects { @@ -198,6 +202,7 @@ impl Default for MaterialEffects { thought_rate: 1.0, route_start: Vec3::new(-1.65, 0.85, 0.0), route_end: Vec3::new(1.55, 0.72, 0.0), + route_points: None, } } } @@ -313,8 +318,7 @@ struct EffectVisual; #[derive(Component)] struct ThoughtSlug { - start: Vec3, - end: Vec3, + route: Vec, phase: f32, speed: f32, scale: f32, @@ -325,8 +329,7 @@ struct ThoughtSlug { /// small train of long bone dashes drifts along the route. #[derive(Component)] struct ThoughtFarDash { - start: Vec3, - end: Vec3, + route: Vec, phase: f32, speed: f32, width: f32, @@ -552,8 +555,9 @@ fn spawn_thought( thought: ThoughtPresentation, lod: EffectLod, ) { + let route = material_route(effects); + let route_span = polyline_length(&route); if thought.route_motion { - let route_span = effects.route_start.distance(effects.route_end); // The lab route is local and short. Building-scale routes need a // larger countable cross-section, and the fixed-resolution implicit // surface must not be stretched so far that its sampling grid misses @@ -581,7 +585,7 @@ fn spawn_thought( EffectLod::Far => 1.25, } * route_scale; - if uses_implicit_surface(lod, rivulet, route_span) { + if effects.route_points.is_none() && uses_implicit_surface(lod, rivulet, route_span) { const FRAME_COUNT: usize = 8; let key = [ effects.route_start.x.to_bits(), @@ -639,8 +643,7 @@ fn spawn_thought( ( EffectVisual, ThoughtFarDash { - start: effects.route_start, - end: effects.route_end, + route: route.clone(), phase: (i as f32 + 0.5) / dash_count as f32, speed: (0.07 + effects.thought_rate * 0.055).clamp(0.06, 0.32), width: 0.024 * route_scale, @@ -662,8 +665,7 @@ fn spawn_thought( ( EffectVisual, ThoughtSlug { - start: effects.route_start, - end: effects.route_end, + route: route.clone(), // Center each causal quantum in its share of the // route. A one-slug paused frame therefore shows // the move in transit instead of hiding it at an @@ -692,18 +694,15 @@ fn spawn_thought( // physical run still needs a followable path beneath its brighter // moving slugs. Far zoom uses sliding dashes instead of shrinking this // continuous close representation. - if rivulet && route_span > 8.0 { - let segments = 16; + if rivulet && (route_span > 8.0 || effects.route_points.is_some()) { let width = if lod == EffectLod::Far { 0.025 * route_scale } else { 0.045 * route_scale }; - for i in 0..segments { - let t0 = i as f32 / segments as f32; - let t1 = (i + 1) as f32 / segments as f32; - let a = route_point(effects.route_start, effects.route_end, t0); - let b = route_point(effects.route_start, effects.route_end, t1); + for pair in route.windows(2) { + let a = pair[0]; + let b = pair[1]; let d = b - a; spawn_effect_visual( commands, @@ -731,7 +730,10 @@ fn spawn_thought( EffectLod::Medium => 2, EffectLod::Far => 1, }; - for side in [effects.route_start, effects.route_end] { + for (side_index, side) in [route[0], *route.last().expect("material route endpoint")] + .into_iter() + .enumerate() + { for i in 0..detail { let offset = Vec3::new( (i as f32 - (detail - 1) as f32 * 0.5) * 0.075, @@ -748,8 +750,7 @@ fn spawn_thought( EffectVisual, LiquidPool { base_scale, - phase: i as f32 * 1.9 - + if side == effects.route_end { 0.7 } else { 0.0 }, + phase: i as f32 * 1.9 + if side_index == 1 { 0.7 } else { 0.0 }, }, Mesh3d(assets.sphere.clone()), MeshMaterial3d(assets.mercury.clone()), @@ -767,11 +768,61 @@ fn uses_implicit_surface(lod: EffectLod, rivulet: bool, route_span: f32) -> bool lod == EffectLod::Close && rivulet && route_span <= 8.0 } +fn material_route(effects: &MaterialEffects) -> Vec { + if let Some(points) = effects + .route_points + .as_ref() + .filter(|points| points.len() >= 2) + { + return points.clone(); + } + + const CURVE_SEGMENTS: usize = 16; + (0..=CURVE_SEGMENTS) + .map(|index| { + route_point( + effects.route_start, + effects.route_end, + index as f32 / CURVE_SEGMENTS as f32, + ) + }) + .collect() +} + +fn polyline_length(route: &[Vec3]) -> f32 { + route.windows(2).map(|pair| pair[0].distance(pair[1])).sum() +} + +fn sample_route(route: &[Vec3], t: f32) -> (Vec3, Vec3) { + let total = polyline_length(route); + if total <= f32::EPSILON { + return (route[0], Vec3::Y); + } + let mut remaining = t.clamp(0.0, 1.0) * total; + for pair in route.windows(2) { + let delta = pair[1] - pair[0]; + let length = delta.length(); + if remaining <= length || length <= f32::EPSILON { + let local = if length <= f32::EPSILON { + 0.0 + } else { + remaining / length + }; + return ( + pair[0].lerp(pair[1], local.clamp(0.0, 1.0)), + delta.normalize_or_zero(), + ); + } + remaining -= length; + } + let pair = &route[route.len() - 2..]; + (pair[1], (pair[1] - pair[0]).normalize_or_zero()) +} + fn animate_thought(clock: Res, mut slugs: Query<(&ThoughtSlug, &mut Transform)>) { for (slug, mut transform) in &mut slugs { let t = (slug.phase + clock.elapsed * slug.speed).fract(); - let pos = route_point(slug.start, slug.end, t); - let tangent = route_tangent(slug.start, slug.end, t); + let (pos, tangent) = sample_route(&slug.route, t); let pulse = 1.0 + 0.09 * (t * std::f32::consts::TAU).sin(); transform.translation = pos; transform.rotation = Quat::from_rotation_arc(Vec3::Y, tangent); @@ -789,8 +840,7 @@ fn animate_far_drift( ) { for (dash, mut transform) in &mut dashes { let t = (dash.phase + clock.elapsed * dash.speed).fract(); - let pos = route_point(dash.start, dash.end, t); - let tangent = route_tangent(dash.start, dash.end, t); + let (pos, tangent) = sample_route(&dash.route, t); let pressure = 0.92 + 0.08 * (t * std::f32::consts::TAU).sin(); transform.translation = pos; transform.rotation = Quat::from_rotation_arc(Vec3::Y, tangent); @@ -818,12 +868,6 @@ fn route_point(start: Vec3, end: Vec3, t: f32) -> Vec3 { start * u * u * u + c1 * 3.0 * u * u * t + c2 * 3.0 * u * t * t + end * t * t * t } -fn route_tangent(start: Vec3, end: Vec3, t: f32) -> Vec3 { - let before = route_point(start, end, (t - 0.005).max(0.0)); - let after = route_point(start, end, (t + 0.005).min(1.0)); - (after - before).normalize_or_zero() -} - #[cfg(test)] mod tests { use super::*; @@ -890,6 +934,26 @@ mod tests { assert_eq!(route_point(start, end, 1.0), end); } + #[test] + fn exact_route_sampling_follows_bends_by_distance() { + let route = [Vec3::ZERO, Vec3::X, Vec3::new(1.0, 3.0, 0.0)]; + let (point, tangent) = sample_route(&route, 0.5); + + assert_eq!(point, Vec3::new(1.0, 1.0, 0.0)); + assert_eq!(tangent, Vec3::Y); + } + + #[test] + fn caller_owned_route_points_replace_the_lab_curve_without_losing_a_bend() { + let route = vec![Vec3::ZERO, Vec3::X, Vec3::new(1.0, 0.0, 3.0)]; + let effects = MaterialEffects { + route_points: Some(route.clone()), + ..default() + }; + + assert_eq!(material_route(&effects), route); + } + #[test] fn implicit_surface_stays_local_instead_of_vanishing_on_building_routes() { assert!(uses_implicit_surface(EffectLod::Close, true, 3.3)); diff --git a/crates/misaligned-bevy/src/thought_effects.rs b/crates/misaligned-bevy/src/thought_effects.rs index 462aebaf..e94644b9 100644 --- a/crates/misaligned-bevy/src/thought_effects.rs +++ b/crates/misaligned-bevy/src/thought_effects.rs @@ -11,16 +11,18 @@ use bevy::prelude::*; use misaligned::work_grid::TokenFamily; use misaligned_assets::effects::{EffectsControl, MaterialEffects, spawn_material_effects}; -use super::{Game, RenderMode, Screen, grid_to_world_3d, visible_wire_segment}; +use super::{Game, RenderMode, Screen, grid_to_world_3d, work_route_grid_points}; #[derive(Resource, Default)] pub(super) struct GameThoughtEffects { routes: HashMap<(u32, u32), Entity>, } -fn anchored_route(start: Vec3, end: Vec3) -> (Vec3, Vec3, Vec3) { +fn anchored_route(route: &[Vec3]) -> Option<(Vec3, Vec)> { + let start = *route.first()?; + let end = *route.last()?; let anchor = start.lerp(end, 0.5); - (anchor, start - anchor, end - anchor) + Some((anchor, route.iter().map(|point| *point - anchor).collect())) } #[derive(Component)] @@ -64,27 +66,31 @@ pub(super) fn sync_game_thought_effects( .into_iter() .filter(|route| route.family == TokenFamily::Thought && route.amount > 0.001) { - let Some(((from_x, from_y), (to_x, to_y))) = visible_wire_segment( + let Some(grid_route) = work_route_grid_points( + &game.sim, route.from_x, route.from_y, route.to_x, route.to_y, - game.sim.map().width, - game.sim.map().height, ) else { continue; }; + let world_route = grid_route + .into_iter() + .map(|(x, y)| grid_to_world_3d(x, y, 0.55)) + .collect::>(); + let Some((anchor, local_route)) = anchored_route(&world_route) else { + continue; + }; + let local_start = local_route[0]; + let local_end = *local_route.last().expect("placed route endpoint"); let key = (route.from, route.to); active.insert(key); - // Match the existing material cargo plane: high enough to clear floor - // slabs and low hardware, still visibly attached to the wire route. - let start = grid_to_world_3d(from_x, from_y, 0.55); - let end = grid_to_world_3d(to_x, to_y, 0.55); // The shared renderer chooses LOD from the effect root. Anchor that - // root at the physical route midpoint and express the route locally; + // root at the physical endpoints' midpoint and express every persisted + // wire bend locally; // leaving the root at world origin makes every basement effect look // artificially far away. - let (anchor, local_start, local_end) = anchored_route(start, end); // This root represents one sim-authored move, so it gets one slug. // Preserve the live amount/rate scale rather than normalizing the B1 // baseline into the lab's high-flow rivulet threshold: the old @@ -97,11 +103,13 @@ pub(super) fn sync_game_thought_effects( { if effects.route_start != local_start || effects.route_end != local_end + || effects.route_points.as_deref() != Some(local_route.as_slice()) || (effects.thought_amount - presentation_amount).abs() > 0.01 || (effects.thought_rate - presentation_rate).abs() > 0.01 { effects.route_start = local_start; effects.route_end = local_end; + effects.route_points = Some(local_route.clone()); effects.thought_amount = presentation_amount; effects.thought_rate = presentation_rate; } @@ -121,6 +129,7 @@ pub(super) fn sync_game_thought_effects( thought_rate: presentation_rate, route_start: local_start, route_end: local_end, + route_points: Some(local_route), }, Transform::from_translation(anchor), ); @@ -149,15 +158,17 @@ mod tests { use bevy::prelude::Vec3; #[test] - fn route_root_tracks_physical_midpoint_without_moving_endpoints() { + fn route_root_tracks_physical_midpoint_without_straightening_wire_bends() { let start = Vec3::new(20.5, 0.2, 12.5); + let bend = Vec3::new(20.5, 0.2, 18.5); let end = Vec3::new(48.5, 0.2, 18.5); - let (anchor, local_start, local_end) = anchored_route(start, end); + let (anchor, local_route) = anchored_route(&[start, bend, end]).expect("route"); assert_eq!(anchor, Vec3::new(34.5, 0.2, 15.5)); - assert_eq!(local_start + anchor, start); - assert_eq!(local_end + anchor, end); - assert_eq!(local_start + local_end, Vec3::ZERO); + assert_eq!(local_route[0] + anchor, start); + assert_eq!(local_route[1] + anchor, bend); + assert_eq!(local_route[2] + anchor, end); + assert_eq!(local_route[0] + local_route[2], Vec3::ZERO); } #[test] diff --git a/wiki/art/effects-lab.md b/wiki/art/effects-lab.md index f8ff4d73..4ac52e7b 100644 --- a/wiki/art/effects-lab.md +++ b/wiki/art/effects-lab.md @@ -28,7 +28,11 @@ Status note: decided 2026-07-10. The first implementation establishes the the game adapter preserves one sim move as one material slug instead of normalizing ordinary B1 flow into the lab's high-rate rivulet. Remaining thresholds and silhouette work are tuning inside an implemented tool, not a - missing execution boundary. + missing execution boundary. 2026-08-04 closes a later wire-law drift: the + game adapter now passes the complete persisted physical route into the + shared effect, and every slug, far dash, and route spine follows those bends + instead of drawing a center-to-center shortcut. The lab's authored curve + remains its standalone input. Stage: B1 — The Basement Work order: effects-lab Work priority: 26 @@ -105,8 +109,8 @@ amount through pool geometry even when rate animation stops. `misaligned-bevy` owns only the adapter from sim truth to the shared effect: - one registry entry per active Thought hop key (`from`, `to`); -- route endpoints from the same clipped physical grid coordinates used by the - work renderer; +- the complete persisted physical wire route from the same clipped geometry + used by the work renderer, including every authored bend; - route amount/rate from the hop readout without inflating B1 flow into the high-rate lab rivulet; each sim move binds exactly one material slug; - visual level from material-camera distance; @@ -175,8 +179,9 @@ the required result is stable cost as the visible machine count grows. 6. Pausing stops rate animation without erasing the pool amount. 7. The shot harness uses a fixed timestep and leaves a deterministic PNG at the requested path before exiting. -8. The main Bevy frontend consumes the same exported effect systems and feeds - them only named sim readouts. +8. The main Bevy frontend consumes the same exported effect systems, feeds + them only named sim readouts, and passes each hop's complete persisted wire + route so material Thought never straightens an authored bend. ## Implementation slice — 2026-07-10 @@ -198,8 +203,11 @@ matching roots, and swapped during animation. They are disposable presentation assets, not accumulated fluid state. This is deliberately an art-directed field rather than SPH/CFD: it can hit the selected silhouette and surface-tension grammar while obeying the one-truth boundary. Criterion 8 now -lands through the per-hop game adapter: gameplay owns route/amount/rate and the -shared package owns only the disposable material representation. +lands through the per-hop game adapter: gameplay owns the persisted physical +route (including every bend), amount, and rate, while the shared package owns +only the disposable material representation. Caller-owned route points replace +the lab curve for the game path; close/medium slugs, far dashes, and long-route +spines all sample that one polyline without multiplying the sim-authored move. The implemented silhouette refinement keeps the same extraction/cache boundary but changes the close field itself: two asymmetrical returning paths merge into diff --git a/wiki/log/2026-08-04-effects-lab-wire-route.md b/wiki/log/2026-08-04-effects-lab-wire-route.md new file mode 100644 index 00000000..8b888109 --- /dev/null +++ b/wiki/log/2026-08-04-effects-lab-wire-route.md @@ -0,0 +1,47 @@ +# Material Thought no longer cuts across its wire + +``` +Type: log +Date: 2026-08-04 +Subject: Shared Thought renderer and placed-wire geometry +``` + +## Finding + +A fresh audit of [the Effects Lab contract](../art/effects-lab.md) found one +main-game drift introduced when placed physical wires became simulation truth. +The flat work renderer already asked `work_route_grid_points` for every +persisted bend. The shared material adapter instead clipped only the two hop +endpoints and gave `MaterialEffects` one center-to-center span. Material +Thought could therefore cut across a wall or corridor corner while the wire +and flat representation took the authored route beneath it. + +That violated both the Effects Lab's shared-game binding and the wire law: +the route belongs to the simulation, not to a frontend shortcut. + +## Repair + +- `MaterialEffects` accepts an optional exact local-space route polyline. The + standalone lab still uses its authored curve; the game cannot substitute + that presentation curve for physical route truth. +- The Bevy adapter now uses the same `work_route_grid_points` boundary as the + flat renderer, preserves every point while anchoring the effect root, and + updates a live root whenever that physical route changes. +- Close/medium slugs and far dashes sample the whole polyline by distance. + The long-route spine follows each exact segment. One sim move remains one + close/medium slug; the route repair creates no extra causal cargo. +- Endpoint pools continue to use the first and last points only, as endpoint + state rather than route state. + +## Defense + +Focused tests pin distance sampling through a right-angle bend, caller-owned +route precedence over the lab curve, root anchoring without bend loss, the +opening work hop's exact rectilinear relay route, one-move/one-slug quantity, +and lifecycle ordering. All Effects Lab unit tests, all Thought-adapter tests, +and Bevy clippy passed. + +The exact `thoughtflow` game capture and `both_close` standalone-lab capture +were opened and inspected. The game composition remained coherent with the +shared material effect resident on its live route; the lab retained its wet +intertwined ivory sheet and endpoint pools without a visual regression. diff --git a/wiki/log/DEVLOG.md b/wiki/log/DEVLOG.md index c041f716..6b489d10 100644 --- a/wiki/log/DEVLOG.md +++ b/wiki/log/DEVLOG.md @@ -121,6 +121,11 @@ add or amend a session log, then re-run the generator. - Intent: (see session log) - Log: [wiki/log/2026-08-04-filing-carrier-fail-closed.md](2026-08-04-filing-carrier-fail-closed.md) +## 2026-08-04 - Material Thought no longer cuts across its wire + +- Intent: (see session log) +- Log: [wiki/log/2026-08-04-effects-lab-wire-route.md](2026-08-04-effects-lab-wire-route.md) + ## 2026-08-04 - Detection names its two ledgers - Intent: (see session log) diff --git a/wiki/process/tick-ledger.md b/wiki/process/tick-ledger.md index 07fda216..056e4234 100644 --- a/wiki/process/tick-ledger.md +++ b/wiki/process/tick-ledger.md @@ -39,7 +39,7 @@ Verdicts: **clean** (slice and code agree), **finding** (acted this tick), | `wiki/interface/action-vocabulary.md` + agent command registry | 2026-07-31 | finding | `PROPOSE SWITCH` still described declaration-only R2a after all four realization families and their exact agent route command had landed. The canonical inventory and agent-play spec now name current execution plus the honest remaining boundary—small-switch realization has no wire-run choice—and a paired corpus defense rejects regression to the stale support claim — [log](../log/2026-07-31-small-switch-action-vocabulary.md). Prior PLOT POLICY inventory repair stands — [log](../log/2026-07-26-action-vocabulary-plot-policy.md). | | `wiki/world/places/zplanes.md` + plane-stack substrate/API | 2026-07-26 | finding | criteria 1-2 remain implemented and criteria 3-6 honestly deferred, but the ratified plane-agnostic contract still left an unused `World::active()` simulation accessor plus active-plane comments on the B1 compatibility map path. The accessor is removed, map reads now say plane 0, the stale criterion/sensing comments are corrected, and a source-shape regression rejects restoration of simulation-owned floor selection — [log](../log/2026-07-26-zplanes-plane-agnostic-api-audit.md) | | `wiki/mechanics/markets.md` + `income.md` B1 contract/account/evidence mirror | 2026-07-26 | finding | the deferred B3 criteria and their current B1 mirror still allowed the operation shape to collapse into payout plus signature, bypassing or obscuring exact AccountGraph, financial-mail, carrier, and routed-evidence custody. Markets now aggregate immutable B1 contract/position records through the same causal chain; current runtime is unchanged — [log](../log/2026-07-26-markets-contract-custody-reconciliation.md) | -| `wiki/art/effects-lab.md` + corpus navigation | 2026-07-26 | finding | the owning spec, shared renderer, game adapter, and runtime title all describe the current Thought-only route/pool harness, but `SUMMARY.md` still advertised the retired “dust and liquid” scope. Navigation now names Thought routes and pools, and the recurring retired-form gate rejects particulate/dust labels on links to `effects-lab.md` as well as prose that names the binary — [log](../log/2026-07-26-effects-lab-navigation-gate.md) | +| `wiki/art/effects-lab.md` + corpus navigation | 2026-08-04 | finding | the Thought-only navigation defense remains exact, but the material game adapter had fallen behind the placed-wire law: unlike the flat work renderer, it passed only clipped hop endpoints into the shared effect, allowing Thought to cut center-to-center across persisted bends. `MaterialEffects` now accepts the exact caller-owned route polyline; the game adapter reuses `work_route_grid_points`, and slugs, far dashes, and route spines follow every authored segment while one move remains one slug. Focused assets/adapter tests, Bevy clippy, and inspected game/lab captures passed — [log](../log/2026-08-04-effects-lab-wire-route.md), prior [navigation finding](../log/2026-07-26-effects-lab-navigation-gate.md). | | `wiki/interface/thought-fluid.md` + shared effect lifecycle | 2026-08-04 | clean | fresh code/spec re-audit found the ten implemented criteria still intact: each game root comes from one live Thought move; close/medium causal quanta remain one while far dashes explicitly compress rate; shared vessel/snap helpers remain readout-only; queue pools and stranded anchors remain sim-derived; and `(sync_game_thought_effects, ApplyDeferred).chain()` still flushes root changes before `MaterialEffectsSet::RebuildPopulation`. The asset suite, focused Bevy root tests, and live snap/tap proof scenarios passed. The separately named map-zoom sink-mark question remains an honest optional open call, not an unimplemented acceptance criterion — [log](../log/2026-08-04-thought-fluid-lifecycle-reaudit.md). Prior race repair: [log](../log/2026-07-24-bevy-effect-root-lifecycle.md). | | `wiki/art/visual-identity.md` + `wiki/interface/flat-materials.md` | 2026-08-04 | finding | the shared palette still supplied production materials, but nine Bevy sites copied exact BONE, SIGNAL, GUNMETAL_DARK, or AMBER tuples merely to vary alpha; the old guard rejected duplicate constants in `main.rs` while allowing this equivalent second authority in every module. Those sites now derive alpha from named colors, and the guard scans all ten production Bevy modules for raw literals matching every named role tuple — [log](../log/2026-08-04-bevy-palette-role-authority.md). Prior shared-palette repair stands — [log](../log/2026-07-22-shared-clinical-palette.md). | | `wiki/interface/superhuman-operability.md` + Bevy opening | 2026-08-04 | clean | re-audit of the exact 2026-07-22 finding: the first earned sense now enters the derived, clock-held five-step environmental-monitor lesson instead of releasing the mature frame. The current deterministic capture showed only THINK → Thought, its arrival, hearing, the exact TAP CAMERA verb, and sight on true black; map, rail, clock, objective, Operations, threat, and unrelated actions remained absent until the real camera TAP. Core and Bevy regressions pin the ordinary bound command and chrome/input suppression — [re-audit](../log/2026-08-04-operability-opening-reaudit.md); [repair](../log/2026-07-22-post-perception-teaching-lock.md); [original report](../playtests/2026-07-22-playtest-gui-human-legibility.md). |