diff --git a/PlotLine.Tests/Program.cs b/PlotLine.Tests/Program.cs index 42de43a..a746cee 100644 --- a/PlotLine.Tests/Program.cs +++ b/PlotLine.Tests/Program.cs @@ -1190,7 +1190,8 @@ static void Phase21RSceneBrowserAndPanelsAreReplayFriendly() Assert(!view.Contains("data-assets", StringComparison.Ordinal), "Asset visualisation lane should not be rendered on the main stage."); Assert(script.Contains("Assets are recorded in insights/diagnostics but not rendered as stage nodes.", StringComparison.Ordinal), "Asset data should remain available without drawing asset stage nodes."); Assert(script.Contains("width * 0.84 - size / 2", StringComparison.Ordinal), "Location should be a compact right-side anchor once asset nodes are removed."); - Assert(script.Contains("{ x: 0.52, y: 0.66, role: \"context\" }", StringComparison.Ordinal), "Character layout should use more of the stage width."); + Assert(script.Contains("{ x: 0.60, y: 0.66, role: \"context\" }", StringComparison.Ordinal), "Character layout should use more of the stage width."); + Assert(script.Contains("presence-cluster", StringComparison.Ordinal), "Presence should be shown as a single location callout instead of many confusing location lines."); Assert(css.Contains(".story-exp-zone--location .story-exp-zone-label {\n left: 80%;", StringComparison.Ordinal), "Location lane label should follow the compact right-side anchor."); Assert(!css.Contains("grid-template-rows: minmax(218px, 1.35fr) minmax(185px, 1.08fr) auto", StringComparison.Ordinal), "Right rail must not use fixed-height panel tracks."); Assert(!css.Contains("grid-template-rows: auto auto minmax(170px, 1fr) minmax(96px, 0.56fr)", StringComparison.Ordinal), "Left rail must not use fixed-height panel tracks."); diff --git a/PlotLine/Services/IllustrationLibraryServices.cs b/PlotLine/Services/IllustrationLibraryServices.cs index 6d9c8eb..ccb50ae 100644 --- a/PlotLine/Services/IllustrationLibraryServices.cs +++ b/PlotLine/Services/IllustrationLibraryServices.cs @@ -81,7 +81,8 @@ public sealed class IllustrationPromptBuilder : IIllustrationPromptBuilder - subtle depth of field, clean modern rendering, and enough exposure to remain clear inside PlotDirector's dark navy interface - no visible text, captions, logos, watermarks, UI, signatures, or typography - no anime, cartoon, Pixar-like, caricature, celebrity likeness, or photoreal identity claim - - for character images, the specified hair colour, hair length, apparent age, era, location context and skin tone are hard requirements; do not reinterpret or diversify away from them + - for character images, the specified hair colour, hair length, apparent age, era, location context and skin tone are hard requirements; make hair colour plainly visible at thumbnail size and do not reinterpret or diversify away from it + - for character images, vary face shape, nose, eyes, mouth, jaw, expression, hairstyle silhouette, and posture so reusable portraits do not look like siblings unless the specification explicitly asks for family resemblance - no bright flat studio backdrop, no plain colour-block background, no stock-photo lighting, and no cartoon simplicity - avoid private manuscript details, real names, excerpts, or recognisable copyrighted characters - avoid muddy olive and brown grading, heavy sepia tones, underexposure, excessive darkness, low-contrast lighting, flat colour, antique oil-painting texture, distressed canvas texture, rough brushwork, overly sombre default expressions, backgrounds that merge into the subject, and monochrome or near-monochrome results diff --git a/PlotLine/Services/StoryIntelligenceIllustrationCompatibility.cs b/PlotLine/Services/StoryIntelligenceIllustrationCompatibility.cs index 2802145..bd1a2ea 100644 --- a/PlotLine/Services/StoryIntelligenceIllustrationCompatibility.cs +++ b/PlotLine/Services/StoryIntelligenceIllustrationCompatibility.cs @@ -184,6 +184,13 @@ public static class StoryIntelligenceIllustrationCompatibility rejected.Add($"presentation hard mismatch: wanted {evidence.Presentation}, candidate {candidate.Presentation}"); } + if (!string.Equals(evidence.HairColour, HairColours.Unknown, StringComparison.OrdinalIgnoreCase) + && string.Equals(candidate.HairColour, HairColours.Unknown, StringComparison.OrdinalIgnoreCase) + && isNamedCharacter) + { + rejected.Add($"named character with {evidence.HairColour} hair needs hair-specific artwork"); + } + if (!string.Equals(evidence.HairColour, HairColours.Unknown, StringComparison.OrdinalIgnoreCase) && !string.Equals(candidate.HairColour, HairColours.Unknown, StringComparison.OrdinalIgnoreCase) && KnownMismatch(evidence.HairColour, candidate.HairColour)) diff --git a/PlotLine/Services/StoryMemoryServices.cs b/PlotLine/Services/StoryMemoryServices.cs index 73474a3..8c7c705 100644 --- a/PlotLine/Services/StoryMemoryServices.cs +++ b/PlotLine/Services/StoryMemoryServices.cs @@ -996,8 +996,8 @@ public sealed class StoryMemoryService( Code = code, Title = $"{age} {presentation} character", Description = "Reusable character portrait generated from durable Story Memory appearance evidence. Not canonical to a specific manuscript.", - VisualSubject = $"A {agePrompt} {presentation.ToLowerInvariant()} person from {era} {location} with {hairLength.ToLowerInvariant()} {hairColour.ToLowerInvariant()} hair. Skin tone requirement: {skinInstruction}.", - Composition = "single head-and-shoulders portrait with clear facial lighting, neutral story background, and strong thumbnail readability", + VisualSubject = $"A {agePrompt} {presentation.ToLowerInvariant()} person from {era} {location} with unmistakable {hairLength.ToLowerInvariant()} {hairColour.ToLowerInvariant()} hair. Skin tone requirement: {skinInstruction}. Give this person a distinct face shape, nose, eyes, jaw, expression, and hairstyle silhouette so they do not resemble other reusable portraits.", + Composition = "single head-and-shoulders portrait with clear facial lighting, neutral story background, strong thumbnail readability, and visibly individual facial features", Mood = "present, natural, story-aware", ApparentAgeBand = age, Presentation = presentation, diff --git a/PlotLine/wwwroot/css/story-intelligence-experience-prototype.css b/PlotLine/wwwroot/css/story-intelligence-experience-prototype.css index 922a510..00fd0a1 100644 --- a/PlotLine/wwwroot/css/story-intelligence-experience-prototype.css +++ b/PlotLine/wwwroot/css/story-intelligence-experience-prototype.css @@ -698,15 +698,15 @@ input:focus-visible { animation: drawLine 760ms cubic-bezier(.16, .84, .28, 1) forwards; } -.story-exp-connection-group--presence .story-exp-connection, -.story-exp-connection-group--presence-soft .story-exp-connection { - stroke: rgba(125, 193, 255, 0.52); +.story-exp-connection-group--presence-cluster .story-exp-connection { + stroke: rgba(125, 193, 255, 0.62); + stroke-width: 1.5; } -.story-exp-connection-group--presence-soft .story-exp-connection, +.story-exp-connection-group--presence-cluster .story-exp-connection, .story-exp-connection-group--knowledge .story-exp-connection, .story-exp-connection-group--asset-link .story-exp-connection { - stroke-dasharray: 0.16 0.12; + stroke-dasharray: 0.08 0.13; } .story-exp-connection-group--support .story-exp-connection { @@ -714,15 +714,19 @@ input:focus-visible { } .story-exp-connection-group--conflict .story-exp-connection { - stroke: rgba(227, 140, 168, 0.7); + stroke: rgba(227, 140, 168, 0.42); stroke-dasharray: 0.2 0.12; } .story-exp-connection-group--uncertain .story-exp-connection { - stroke: rgba(182, 162, 255, 0.48); + stroke: rgba(182, 162, 255, 0.32); stroke-dasharray: 0.12 0.11; } +.story-exp-connection-group--strong .story-exp-connection { + stroke: rgba(215, 168, 93, 0.34); +} + .story-exp-connection-group--knowledge .story-exp-connection { stroke: rgba(182, 162, 255, 0.44); } diff --git a/PlotLine/wwwroot/js/story-intelligence-experience-prototype.js b/PlotLine/wwwroot/js/story-intelligence-experience-prototype.js index d4c8f8c..82d399c 100644 --- a/PlotLine/wwwroot/js/story-intelligence-experience-prototype.js +++ b/PlotLine/wwwroot/js/story-intelligence-experience-prototype.js @@ -682,7 +682,10 @@ const activeIds = new Set(); const occupiedRects = layoutRects(stageRect); - scene.relationships.forEach((relationship, index) => { + scene.relationships + .filter((relationship) => relationship.weight >= 72) + .slice(0, 2) + .forEach((relationship, index) => { const source = dom.visualStage.querySelector(`[data-character-id="${cssEscape(relationship.sourceId)}"]`); const target = dom.visualStage.querySelector(`[data-character-id="${cssEscape(relationship.targetId)}"]`); if (!source || !target) return; @@ -692,29 +695,22 @@ const obstacles = connectionObstacles(stageRect, source, target); const id = relationship.id || `relationship-${relationship.sourceId}-${relationship.targetId}`; activeIds.add(id); - drawn.push(drawConnection(id, start, end, relationshipTone(relationship), relationship.weight, index < 2 ? inlineRelationshipLabel(relationship) : "", index, obstacles, occupiedRects)); + drawn.push(drawConnection(id, start, end, relationshipTone(relationship), relationship.weight, "", index, obstacles, occupiedRects)); }); - const pov = dom.visualStage.querySelector(`[data-character-id="${cssEscape(scene.povCharacterId)}"]`); const location = dom.visualStage.querySelector("[data-location-id]"); - if (pov && location) { - const id = `presence-${scene.povCharacterId}-${location.dataset.locationId}`; + const presentCharacters = scene.characters + .filter((character) => !character.status || !character.status.toLowerCase().includes("absent")) + .map((character) => dom.visualStage.querySelector(`[data-character-id="${cssEscape(character.id)}"]`)) + .filter(Boolean); + const cluster = characterClusterPoint(presentCharacters, stageRect); + if (cluster && location) { + const id = `presence-cluster-${location.dataset.locationId}`; activeIds.add(id); - drawn.push(drawConnection(id, connectionPoint(pov, stageRect, location), connectionPoint(location, stageRect, pov), "presence", 68, "", drawn.length, connectionObstacles(stageRect, pov, location), occupiedRects)); + const locationPoint = connectionPoint(location, stageRect, { getBoundingClientRect: () => ({ left: stageRect.left + cluster.x, top: stageRect.top + cluster.y, width: 0, height: 0 }) }); + drawn.push(drawConnection(id, cluster, locationPoint, "presence-cluster", 54, `${presentCharacters.length} here`, drawn.length, [], occupiedRects)); } - scene.characters - .filter((character) => character.id !== scene.povCharacterId && character.weight >= 65) - .slice(0, 1) - .forEach((character) => { - const node = dom.visualStage.querySelector(`[data-character-id="${cssEscape(character.id)}"]`); - if (node && location) { - const id = `presence-soft-${character.id}-${location.dataset.locationId}`; - activeIds.add(id); - drawn.push(drawConnection(id, connectionPoint(node, stageRect, location), connectionPoint(location, stageRect, node), "presence-soft", character.weight, "", drawn.length, connectionObstacles(stageRect, node, location), occupiedRects)); - } - }); - removeInactiveSvgConnections(activeIds); } @@ -737,7 +733,7 @@ path.setAttribute("class", "story-exp-connection"); group.append(path); } - const route = routeConnection(start, end, index, obstacles); + const route = routeConnection(start, end, index, obstacles, tone); const d = `M ${start.x} ${start.y} C ${route.startControl.x} ${route.startControl.y}, ${route.endControl.x} ${route.endControl.y}, ${end.x} ${end.y}`; path.setAttribute("d", d); path.style.strokeWidth = String(0.9 + Math.min(weight, 100) / 62); @@ -808,10 +804,10 @@ const others = characters .filter((character) => character.id !== pov?.id) .sort((a, b) => b.weight - a.weight); - const povSize = Math.round(Math.min(188, Math.max(150, width * 0.145))); + const povSize = Math.round(Math.min(188, Math.max(150, width * 0.142))); if (pov) { positions.set(pov.id, { - x: clamp(Math.round(width * 0.25 - povSize / 2), 28, width * 0.52 - povSize), + x: clamp(Math.round(width * 0.28 - povSize / 2), 28, width * 0.58 - povSize), y: clamp(Math.round(height * 0.28 - povSize / 2), 54, height * 0.54 - povSize), size: povSize, scale: 1, @@ -823,13 +819,14 @@ } const slots = [ - { x: 0.48, y: 0.46, role: "partner" }, - { x: 0.12, y: 0.58, role: "support" }, + { x: 0.55, y: 0.46, role: "partner" }, + { x: 0.14, y: 0.58, role: "support" }, { x: 0.12, y: 0.18, role: "support" }, - { x: 0.36, y: 0.72, role: "context" }, - { x: 0.28, y: 0.46, role: "context" }, - { x: 0.52, y: 0.66, role: "context" }, - { x: 0.06, y: 0.38, role: "context" } + { x: 0.42, y: 0.72, role: "context" }, + { x: 0.34, y: 0.46, role: "context" }, + { x: 0.60, y: 0.66, role: "context" }, + { x: 0.06, y: 0.38, role: "context" }, + { x: 0.24, y: 0.76, role: "context" } ]; const placed = [...positions.values()]; others.forEach((character, index) => { @@ -974,14 +971,28 @@ }; } - function routeConnection(start, end, index, obstacles) { + function characterClusterPoint(nodes, parentRect) { + if (!nodes.length) return null; + const rects = nodes + .map((node) => node.querySelector(".story-character__image") || node) + .map((node) => node.getBoundingClientRect()); + const right = Math.max(...rects.map((rect) => rect.right)) - parentRect.left; + const top = Math.min(...rects.map((rect) => rect.top)) - parentRect.top; + const bottom = Math.max(...rects.map((rect) => rect.bottom)) - parentRect.top; + return { + x: right + 18, + y: (top + bottom) / 2 + }; + } + + function routeConnection(start, end, index, obstacles, tone) { const mid = { x: (start.x + end.x) / 2, y: (start.y + end.y) / 2 }; const dx = end.x - start.x; const dy = end.y - start.y; const length = Math.max(1, Math.hypot(dx, dy)); const normal = { x: -dy / length, y: dx / length }; const tangent = { x: dx / length, y: dy / length }; - const base = Math.max(34, Math.min(122, length * 0.2)); + const base = tone === "presence-cluster" ? Math.max(18, Math.min(46, length * 0.08)) : Math.max(22, Math.min(68, length * 0.12)); const direction = index % 2 === 0 ? 1 : -1; const offsets = [direction * base, -direction * base, direction * base * 1.45, -direction * base * 1.45, 0]; let best = offsets[0];