From 8eda84886d1e122561cce4d2027c7af6044105a0 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Thu, 29 Oct 2026 12:54:13 -0400 Subject: [PATCH 1/3] feat(lights): spotlights, per-light shadows and lamp glows A light effect can now be a spot (aim in the item's frame, full cone in degrees, edge softness), cast shadows, and draw a glow: a soft additive halo at the lamp in its colour that shows more the darker the scene. - Modules: a THREE.SpotLight (or a Blender spot) keeps its aim, angle and penumbra; castShadow / userData.shadows and userData.glow carry over. - author_scene lights take direction or target, angle, softness, shadows and glow; the aim is stored in the host's frame so it turns with it. - The editor and baked viewers share one pooled runtime: 12 point lights, 6 spots and 2 shadow slots (spots ranked before point shadows), the spot and shadow pools mounting the first time a scene needs them; glows are one instanced sprite for every lit lamp. Co-Authored-By: Claude Opus 5.5 --- .../__fixtures__/notation-cases.ts | 2 + .../src/agent-operations/author-scene.test.ts | 45 ++ .../core/src/agent-operations/author-scene.ts | 29 ++ packages/core/src/agent-tools/add-object.ts | 4 +- packages/core/src/agent-tools/author-scene.ts | 45 +- packages/core/src/lib/geometry-script-node.ts | 3 + packages/core/src/schema/geometry-source.ts | 24 + packages/core/src/schema/index.ts | 2 + packages/core/src/schema/nodes/item.ts | 6 +- packages/geometry-script/src/compile.test.ts | 49 +++ packages/geometry-script/src/compile.ts | 50 ++- .../src/components/viewer/glb-interactive.tsx | 230 ++-------- .../viewer/src/store/use-item-light-pool.ts | 20 +- .../item-light/item-light-system.test.ts | 65 ++- .../systems/item-light/item-light-system.tsx | 248 +---------- .../src/systems/item-light/pooled-lights.tsx | 409 ++++++++++++++++++ wiki/architecture/authored-objects.md | 4 +- wiki/blender-geometry-scripts.md | 2 +- 18 files changed, 807 insertions(+), 430 deletions(-) create mode 100644 packages/viewer/src/systems/item-light/pooled-lights.tsx diff --git a/packages/core/src/agent-operations/__fixtures__/notation-cases.ts b/packages/core/src/agent-operations/__fixtures__/notation-cases.ts index 95ad78c120..10e04f98b7 100644 --- a/packages/core/src/agent-operations/__fixtures__/notation-cases.ts +++ b/packages/core/src/agent-operations/__fixtures__/notation-cases.ts @@ -328,6 +328,8 @@ export const NOTATION_CASES: NotationCase[] = [ */ export const NOT_LENGTHS = new Set([ 'author_scene.payload.levels[].lights[].intensity', + 'author_scene.payload.levels[].lights[].softness', + 'author_scene.payload.levels[].lights[].glow.strength', // Coordinates, in level metres. 'add_column.x', 'add_column.y', diff --git a/packages/core/src/agent-operations/author-scene.test.ts b/packages/core/src/agent-operations/author-scene.test.ts index 507924d4ec..46356ab4e7 100644 --- a/packages/core/src/agent-operations/author-scene.test.ts +++ b/packages/core/src/agent-operations/author-scene.test.ts @@ -744,6 +744,51 @@ describe('author_scene', () => { ) }) + test("a spotlight keeps its aim in its fixture's frame, with its shadows and glow", () => { + const payload = authorScenePayload.parse({ + levels: [ + { + name: 'Ground floor', + items: [ + { + id: 'sconce', + name: 'Sconce', + category: 'light', + code: '// sconce', + position: [0, 0, 0], + rotation: 90, + }, + ], + lights: [ + { + position: [1, 2, 0], + target: [2, 2, 0], + fixture: 'sconce', + shadows: true, + glow: { size: 0.2 }, + }, + { position: [0, 2, 0], fixture: 'sconce', angle: 30 }, + ], + }, + ], + }) + const outcome = run(payload, { sconce: bed }, {}) + expect(outcome.result.defects).toEqual([]) + const [aimed, down] = byAuthorId(outcome, 'sconce').asset.interactive.effects + // The target is 1 m along +x from the bulb, as the bulb is from the fixture: both turn alike. + expect(aimed.spot.direction[0]).toBeCloseTo(aimed.offset[0], 4) + expect(aimed.spot.direction[1]).toBeCloseTo(0, 4) + expect(aimed.spot.direction[2]).toBeCloseTo(aimed.offset[2], 4) + expect(Math.abs(aimed.offset[2])).toBeCloseTo(1, 4) + expect(aimed).toMatchObject({ + spot: { angle: 60, softness: 0.4 }, + shadows: true, + glow: { size: 0.2, strength: 1 }, + }) + expect(down.spot).toEqual({ direction: [0, -1, 0], angle: 30, softness: 0.4 }) + expect(down.shadows).toBeUndefined() + }) + test('a ceiling point whose module failed is reported by the light id', () => { const payload = authorScenePayload.parse({ ...bedroom, diff --git a/packages/core/src/agent-operations/author-scene.ts b/packages/core/src/agent-operations/author-scene.ts index 934e119f63..76b83dbff1 100644 --- a/packages/core/src/agent-operations/author-scene.ts +++ b/packages/core/src/agent-operations/author-scene.ts @@ -385,6 +385,32 @@ function toLocal(frame: Frame, world: Vec3): Vec3 { return [c * dx - s * dz, dy, s * dx + c * dz] } +type PayloadLight = AuthorScenePayload['levels'][number]['lights'][number] + +/** + * A payload light's beam in its host's frame (so it turns with the host), or none for a point + * light. A target on the light itself points it down. + */ +function lightSpot(light: PayloadLight, at: Vec3, host: Frame) { + if (!light.direction && !light.target && light.angle === undefined) return undefined + let [x, y, z] = light.target + ? [light.target[0]! - at[0], light.target[1]! - at[1], light.target[2]! - at[2]] + : (light.direction ?? [0, -1, 0]) + let length = Math.hypot(x!, y!, z!) + if (length < 1e-6) [x, y, z, length] = [0, -1, 0, 1] + const [c, s] = [Math.cos(host.yaw), Math.sin(host.yaw)] + const round = (v: number) => Math.round(v * 1e4) / 1e4 + return { + direction: [ + round((c * x! - s * z!) / length), + round(y! / length), + round((s * x! + c * z!) / length), + ] as Vec3, + angle: light.angle ?? 60, + softness: light.softness ?? 0.4, + } +} + /** * The finish a payload value writes. A library or scene ref passes through; a hex colour is kept * as an exact native material, one scene material per distinct colour and surface across the @@ -2226,6 +2252,9 @@ export function authorScene( color: light.color, intensityRange: [0, light.intensity], offset: toLocal(hostFrame!, at), + spot: lightSpot(light, at, hostFrame!), + shadows: light.shadows, + glow: light.glow, }, ] written[hostNode.id] = { diff --git a/packages/core/src/agent-tools/add-object.ts b/packages/core/src/agent-tools/add-object.ts index d6382f7b04..3b4081e835 100644 --- a/packages/core/src/agent-tools/add-object.ts +++ b/packages/core/src/agent-tools/add-object.ts @@ -18,7 +18,7 @@ Conventions (they make the object work in Pascal; follow them): - Metres, Y up, modelled in its installed orientation. Floor and wall mounts put the bottom-centre of the visible bounds at the placement point. Ceiling mounts put the top-centre there: visible geometry extends down in negative local Y, with its top at Y = 0. Wall-side mounts put the bottom-centre of the back face there (back at Z = 0, facing +Z). Normalization translates geometry; it does not flip it. - Paint: name every material slot_ (slot_trim, slot_frame, slot_metal) and reuse one material per finish; a material named "glass" renders as glass. A slot's colour is shown as the library finish it matches (wood, metal, the nearest flat colour); exact: true keeps each colour as written. - Parts: name the few groups a person would point at part: (part:column_left, part:canopy, part:landing), usually 2–24, a group per part, not every mesh. Set userData.type on parts someone would look for: column, beam, slab, roof, railing, panel, trim, step, light. -- Lights: add a THREE.PointLight or SpotLight named light: where the bulb is; it becomes a switchable light. +- Lights: add a THREE.PointLight or SpotLight named light: where the bulb is; it becomes a switchable light. A SpotLight keeps its aim (its target), angle and penumbra (a sconce's cone, a recessed downlight). castShadow = true makes it cast shadows (only the couple nearest the view do at once, so keep it for the lights that shape a room). userData.glow = { size, strength } draws a soft halo around the lamp in its colour (size across in metres, about 0.15 for a sconce to 0.4 for a lantern; strength default 1), strongest at dusk and night. - Motion: put THREE.AnimationClips on the returned group's .animations, as in any three.js project; tracks target . or . and may use any transform property (position, rotation, rotation[y], quaternion, scale); material and visibility tracks do not animate. A clip named open becomes the object's open/close control (close plays a clip named close, or open reversed); a clip named loop runs continuously; every other clip gets its own play toggle labelled with its name (name it for the person: "Twirl", "Music"). Write as many clips as the object needs. - Cuts: a mesh named cutout cuts only the mounted host; cut:wall, cut:ceiling and cut:slab require that host kind (floor items cut their support slab). The hidden cutter must reach the host; walls use its real shape, ceilings and slabs its through-hole footprint. - Sockets: an empty Object3D named anchor: marks where other things attach. @@ -29,7 +29,7 @@ Blender instead (language 'bpy'): when the shape is easier with Blender's modell PARAMS = {"width": {"default": 1.8, "min": 1.2, "max": 2.6, "step": 0.05, "unit": "m", "label": "Width"}} MOUNT = "floor" def build(params): ... # add objects to the empty scene; everything in it is exported, modifiers applied -The same conventions in Blender terms: metres, Z up, the front faces -Y (Blender's Front view); part:, light: (a light object; its custom property intensity sets the brightness, default 2), anchor: (an empty), cutout, cut:wall|ceiling|slab and collider are object names; userData is the object's custom properties (obj["type"] = "panel"); slot_ are material names. A clip is one action named open, close, loop or anything else, assigned to every object that moves in it. Use the data API and bmesh, not operators that need a 3D viewport. A Blender build takes a few seconds. +The same conventions in Blender terms: metres, Z up, the front faces -Y (Blender's Front view); part:, light: (a point or spot light object; its custom properties intensity sets the brightness, default 2, shadows = True casts shadows, glow = {"size": 0.2, "strength": 1} draws the halo), anchor: (an empty), cutout, cut:wall|ceiling|slab and collider are object names; userData is the object's custom properties (obj["type"] = "panel"); slot_ are material names. A clip is one action named open, close, loop or anything else, assigned to every object that moves in it. Use the data API and bmesh, not operators that need a 3D viewport. A Blender build takes a few seconds. Several objects: they do not depend on each other, so several add_object calls can go in one step, or in one run_batch call (a call per object; each is reported on its own with its receipt, and one that fails does not stop the others). diff --git a/packages/core/src/agent-tools/author-scene.ts b/packages/core/src/agent-tools/author-scene.ts index 1ecef79f7f..750bd2b7c5 100644 --- a/packages/core/src/agent-tools/author-scene.ts +++ b/packages/core/src/agent-tools/author-scene.ts @@ -9,7 +9,7 @@ import { unitKind } from './units' const DESCRIPTION = `Turn a whole design you wrote as free three.js into an editable Pascal scene, in one call. You read your own module however you like (its source, running it and reading the objects, both) and hand over a conversion payload: levels, walls (straight, curved or glass) with their openings, rooms (each naming its unit in a building of several dwellings or tenancies: a condo, an apartment building, a hotel; a room naming none is common area), slabs, voids, columns, railings, stairs, every piece of furniture as an authored object with its own module, and lights. Pascal does the rest server-side: compiles and stores each item, places it, derives the rooms and their floors, paints, runs its checks, saves, and answers with a gate report you iterate against. Nothing you hand over is silently dropped: what cannot be placed comes back by name with a reason, and what Pascal corrected (a wall end snapped onto the centerline it meets) is listed. -Coordinates: metres, plan x/z, Y up, each level's floor at y = 0 in its own frame. A wall is its centerline; walls meet where centerlines meet, so end a partition on the centerline of the wall it runs into. An opening's 'along' is measured from the wall's start to the opening's centre; give each opening its type (how it opens) and style (its look), or its own module for a shape they cannot express. An item's position is where its bottom centre stands, except a ceiling item's (mount 'ceiling': a pendant, a ceiling light): it hangs from the ceiling of the room containing its plan position, its top on that ceiling, and its y is not read. A rug, a mat or a runner is an item lying on the room's floor. What stands on a floor stands on its finish: on a room with a floorCode, on the top of its planks or tiles, so its y stays 0. Its module follows the add_object contract (metres, Y up, built in its installed orientation; at most 300k triangles and 32 materials per item; name materials slot_; drawers, doors and lids as animation clips; a THREE light named light: where a bulb is). A hex colour is written as an exact material, one per distinct colour. Pascal builds a wall's cap, the floor plate and the ceiling itself: fold a module's caps, underlays and floor planks into the wall, the room's floor finish and the room (say so in notes), never into slabs or regions: baseboards are the wall's skirting, a plank or tile floor is the room's floorCode (a terrace's decking or paving its slab's surfaceCode), tiles, panelling or boards on a wall are its faceLeft / faceRight (or the room's wallCode), cladding, brick or battens outside its faceOutside; a slab is a terrace, a platform, a balcony; a stair is its level's stairs, which open the floor above; the roof over the top level is roof, Pascal's own segments when its shape is one of theirs, else your geometry as roof items, both on the building's roof level above the top storey. Pascal draws what bold architecture needs natively: a curved wall is one wall with the point its arc passes through, through (never a run of short straight ones), a glass front a curtain wall, a double-height room a void in the floor of the level above it (its ceiling opens with it, a railing along its edges), an archway a passage with an arch shape. Paint a wall by what its faces look onto, colorInside and colorOutside: which face is inside is Pascal's to find. Build the building as it is built: a wall at its real height, nothing lowered or removed for viewing. +Coordinates: metres, plan x/z, Y up, each level's floor at y = 0 in its own frame. A wall is its centerline; walls meet where centerlines meet, so end a partition on the centerline of the wall it runs into. An opening's 'along' is measured from the wall's start to the opening's centre; give each opening its type (how it opens) and style (its look), or its own module for a shape they cannot express. An item's position is where its bottom centre stands, except a ceiling item's (mount 'ceiling': a pendant, a ceiling light): it hangs from the ceiling of the room containing its plan position, its top on that ceiling, and its y is not read. A rug, a mat or a runner is an item lying on the room's floor. What stands on a floor stands on its finish: on a room with a floorCode, on the top of its planks or tiles, so its y stays 0. Its module follows the add_object contract (metres, Y up, built in its installed orientation; at most 300k triangles and 32 materials per item; name materials slot_; drawers, doors and lids as animation clips; a THREE light named light: where a bulb is: a SpotLight for a cone, castShadow for shadows, userData.glow for a halo). A hex colour is written as an exact material, one per distinct colour. Pascal builds a wall's cap, the floor plate and the ceiling itself: fold a module's caps, underlays and floor planks into the wall, the room's floor finish and the room (say so in notes), never into slabs or regions: baseboards are the wall's skirting, a plank or tile floor is the room's floorCode (a terrace's decking or paving its slab's surfaceCode), tiles, panelling or boards on a wall are its faceLeft / faceRight (or the room's wallCode), cladding, brick or battens outside its faceOutside; a slab is a terrace, a platform, a balcony; a stair is its level's stairs, which open the floor above; the roof over the top level is roof, Pascal's own segments when its shape is one of theirs, else your geometry as roof items, both on the building's roof level above the top storey. Pascal draws what bold architecture needs natively: a curved wall is one wall with the point its arc passes through, through (never a run of short straight ones), a glass front a curtain wall, a double-height room a void in the floor of the level above it (its ceiling opens with it, a railing along its edges), an archway a passage with an arch shape. Paint a wall by what its faces look onto, colorInside and colorOutside: which face is inside is Pascal's to find. Build the building as it is built: a wall at its real height, nothing lowered or removed for viewing. Hand over the payload, or your module and its adapter, the program that reads the module and returns the payload: Pascal runs the adapter (one that throws comes back with its error, and nothing is written). A payload too large for one call goes by reference: upload it as a JSON project file (upload_project_file, phase prepare, PUT, complete) and pass its fileId as payloadFile. An item names its piece instead of code when add_object makes it from words; one still being made is written when it lands, with no call from you. Pieces made from words are written many at a time while you go on, so furniture made that way does not lengthen your turn the way pieces you write yourself do. @@ -428,6 +428,42 @@ export const authorSceneLight = z.object({ .describe( 'The id of the item holding this light (a pendant, a lamp). Default: without a fixture, a minimal ceiling point is created on the ceiling of the room containing the light’s plan position; outside any room, it attaches to the nearest item on this level and reports the fallback.', ), + direction: z + .array(z.number()) + .length(3) + .refine((v) => Math.hypot(...v) > 1e-6, 'A direction cannot be [0, 0, 0].') + .optional() + .describe( + 'Makes it a spotlight pointing this way, [x, y, z] (any length): [0, -1, 0] straight down (a recessed downlight), [0, 1, 0] up a wall (a sconce washing upwards).', + ), + target: xyz('a point a spotlight aims at, instead of direction').optional(), + angle: measurement('angle', 'deg', { + min: 1, + max: 179, + description: + "A spotlight's full cone (default 60): about 30 for a narrow accent, 60 for a downlight, 100 for a wide wash. Giving it makes the light a spot pointing down unless direction or target says otherwise.", + }).optional(), + softness: z + .number() + .min(0) + .max(1) + .optional() + .describe("How feathered a spotlight's edge is: 0 hard, 1 fully soft (default 0.4)."), + shadows: z + .boolean() + .optional() + .describe( + 'Casts shadows (default false). Only the couple of shadow-casting lights nearest the view get shadows at once, so keep it for the lights that shape a room.', + ), + glow: z + .object({ + size: length('Across, about 0.15 for a sconce to 0.4 for a lantern.', { positive: true }), + strength: z.number().nonnegative().max(10).default(1).describe('Brightness (default 1).'), + }) + .optional() + .describe( + "A soft halo drawn around the lamp itself in the light's colour, strongest at dusk and night.", + ), }) export const authorSceneVoid = z.object({ @@ -524,7 +560,12 @@ export const authorSceneLevel = z.object({ "Stairs rising from this level to the next one in levels, as Pascal's stair tool builds them: the risers follow the storey height and each stair opens the floor above over its flights. Never a stair as an item.", ), items: z.array(authorSceneItem).optional(), - lights: z.array(authorSceneLight).default([]), + lights: z + .array(authorSceneLight) + .default([]) + .describe( + "This level's lights (default []): each in its fixture, or on a ceiling point Pascal adds.", + ), }) export const authorSceneUnit = z.object({ diff --git a/packages/core/src/lib/geometry-script-node.ts b/packages/core/src/lib/geometry-script-node.ts index 501ae3c9ac..37a187e086 100644 --- a/packages/core/src/lib/geometry-script-node.ts +++ b/packages/core/src/lib/geometry-script-node.ts @@ -201,6 +201,9 @@ export function scriptInteractive( light.position[1] + origin[1], light.position[2] + origin[2], ], + spot: light.spot, + shadows: light.shadows, + glow: light.glow, }) } } diff --git a/packages/core/src/schema/geometry-source.ts b/packages/core/src/schema/geometry-source.ts index 5055961714..583986cfac 100644 --- a/packages/core/src/schema/geometry-source.ts +++ b/packages/core/src/schema/geometry-source.ts @@ -22,6 +22,27 @@ export const isGeometryScriptMimeType = (mimeType: string) => const finite = z.number().finite() const vec3 = z.tuple([finite, finite, finite]) +/** + * A spotlight's beam: where it points (in the object's frame), its full cone in degrees and how + * soft its edge is (0 a hard edge, 1 fully feathered). + */ +export const LightSpot = z.object({ + direction: vec3.refine((v) => Math.hypot(...v) > 1e-6, 'A spot needs a direction'), + angle: z.number().min(1).max(179), + softness: z.number().min(0).max(1), +}) +export type LightSpot = z.infer + +/** + * The soft halo drawn around a lamp itself, in the light's colour: its size across in metres and + * its strength. It shows more the darker the scene is. + */ +export const LightGlow = z.object({ + size: z.number().positive().max(5), + strength: z.number().nonnegative().max(10).default(1), +}) +export type LightGlow = z.infer + export const GeometryScriptParamValue = z.union([finite, z.boolean(), z.string()]) export type GeometryScriptParamValue = z.infer @@ -93,6 +114,9 @@ export const GeometryArtifactManifest = z.object({ color: z.string(), intensity: finite, distance: finite.optional(), + spot: LightSpot.optional(), + shadows: z.boolean().optional(), + glow: LightGlow.optional(), }), ) .default([]), diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts index 7091c47b54..e7f02a21c2 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -54,6 +54,8 @@ export { GeometryScriptParamValue, GeometryScriptSource, isGeometryScriptMimeType, + LightGlow, + LightSpot, } from './geometry-source' export type { MaterialMapProperties, diff --git a/packages/core/src/schema/nodes/item.ts b/packages/core/src/schema/nodes/item.ts index aa0dc1dd35..e0e2cbf7e8 100644 --- a/packages/core/src/schema/nodes/item.ts +++ b/packages/core/src/schema/nodes/item.ts @@ -3,7 +3,7 @@ import { z } from 'zod' import { AssetUrl } from '../asset-url' import { BaseNode, nodeType, objectId } from '../base' import type { CollectionId } from '../collections' -import { GeometryScriptSource } from '../geometry-source' +import { GeometryScriptSource, LightGlow, LightSpot } from '../geometry-source' // --- Control descriptors --- @@ -64,6 +64,10 @@ const lightEffectSchema = z.object({ intensityRange: z.tuple([z.number(), z.number()]), distance: z.number().optional(), offset: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + // A spotlight instead of a point light; its direction turns with the item. + spot: LightSpot.optional(), + shadows: z.boolean().optional(), + glow: LightGlow.optional(), }) const effectSchema = z.discriminatedUnion('kind', [animationEffectSchema, lightEffectSchema]) diff --git a/packages/geometry-script/src/compile.test.ts b/packages/geometry-script/src/compile.test.ts index 046b1faf36..138fc32ba0 100644 --- a/packages/geometry-script/src/compile.test.ts +++ b/packages/geometry-script/src/compile.test.ts @@ -4,6 +4,7 @@ import { CEILING_DRAW_OFFSET, matchScriptSlotsToLibrary, resolveCeilingHeight, + scriptInteractive, scriptSource, } from '@pascal-app/core' import { @@ -137,6 +138,54 @@ export default function build() { }) }) +const SCONCE = (glow: string) => ` +import * as THREE from 'three' +export default function build() { + const g = new THREE.Group() + const body = new THREE.Mesh(new THREE.BoxGeometry(0.12, 0.3, 0.1), new THREE.MeshStandardMaterial()) + body.position.set(2, 1.5, 0) + g.add(body) + const wash = new THREE.SpotLight('#ffd9a0', 3, 4, Math.PI / 6, 0.5) + wash.name = 'light:wash' + wash.position.set(2, 1.6, 0.05) + wash.target.position.set(2, 3, 0.05) + wash.castShadow = true + wash.userData.glow = ${glow} + g.add(wash) + return g +} +` + +describe('lights', () => { + test('a spot keeps its aim, cone, softness, shadows and glow', async () => { + const { manifest } = await compileGeometryScript({ code: SCONCE('{ size: 0.15 }') }) + expect(manifest.lights).toEqual([ + { + id: 'wash', + position: expect.any(Array), + color: '#ffd9a0', + intensity: 3, + distance: 4, + spot: { direction: [0, 1, 0], angle: 60, softness: 0.5 }, + shadows: true, + glow: { size: 0.15, strength: 1 }, + }, + ]) + expect(scriptInteractive(manifest)?.effects[0]).toMatchObject({ + kind: 'light', + spot: { direction: [0, 1, 0], angle: 60 }, + shadows: true, + glow: { size: 0.15 }, + }) + }) + + test('a glow without a size is refused, naming the light', async () => { + await expect(compileGeometryScript({ code: SCONCE('{ strength: 2 }') })).rejects.toThrow( + 'Light "wash": glow must be', + ) + }) +}) + describe('clips', () => { test('a clip is stored ending on its last pose, not wrapped to its first', async () => { const { glb } = await compileGeometryScript({ code: SASH }) diff --git a/packages/geometry-script/src/compile.ts b/packages/geometry-script/src/compile.ts index eec0efa9d0..bc7bcd49b2 100644 --- a/packages/geometry-script/src/compile.ts +++ b/packages/geometry-script/src/compile.ts @@ -7,7 +7,11 @@ import type { GeometryScriptParamValue, } from '@pascal-app/core' import { type Ring, union } from '@pascal-app/core/polygon-boolean' -import { GEOMETRY_MANIFEST_MAX_BYTES, GEOMETRY_SCRIPT_MAX_BYTES } from '@pascal-app/core/schema' +import { + GEOMETRY_MANIFEST_MAX_BYTES, + GEOMETRY_SCRIPT_MAX_BYTES, + LightGlow, +} from '@pascal-app/core/schema' import * as THREE from 'three' import { GLTFExporter } from 'three/examples/jsm/exporters/GLTFExporter.js' import { ConvexGeometry } from 'three/examples/jsm/geometries/ConvexGeometry.js' @@ -308,6 +312,40 @@ function originFor(box: THREE.Box3, mount: GeometryScriptMount): THREE.Vector3 { const round = (v: number) => Math.round(v * 1e6) / 1e6 const vec = (v: THREE.Vector3): [number, number, number] => [round(v.x), round(v.y), round(v.z)] +/** + * A SpotLight's beam in the artifact frame. A target the script never added to the scene is a + * point in the script's own frame, as three.js reads an unparented target in world space. + */ +function readSpot(light: THREE.SpotLight, root: THREE.Object3D, id: string) { + const from = light.getWorldPosition(new THREE.Vector3()) + let to: THREE.Vector3 + if (light.target.parent) to = light.target.getWorldPosition(new THREE.Vector3()) + else { + let frame: THREE.Object3D = light + while (frame.parent && frame.parent !== root) frame = frame.parent + to = frame.parent ? frame.localToWorld(light.target.position.clone()) : light.target.position + } + const direction = to.sub(from) + if (direction.lengthSq() < 1e-12) throw new Error(`Spot light "${id}" targets its own position`) + direction.normalize() + return { + direction: vec(direction), + angle: Math.min(179, Math.max(1, round(THREE.MathUtils.radToDeg(light.angle * 2)))), + softness: Math.min(1, Math.max(0, round(light.penumbra))), + } +} + +/** `userData.glow = { size, strength }` (strength defaults to 1). */ +function readGlow(value: unknown, id: string) { + if (value === undefined) return undefined + const glow = LightGlow.safeParse(value) + if (!glow.success) + throw new Error( + `Light "${id}": glow must be { size: metres across (0–5), strength: 0–10 }, got ${JSON.stringify(value)}`, + ) + return glow.data +} + /** * Reads the naming conventions into the manifest and rewrites the output so * they survive glTF: GLTFLoader strips ':' from node names, so convention ids @@ -379,6 +417,16 @@ function readConventions(root: THREE.Object3D) { color, intensity, distance, + spot: (light as unknown as THREE.SpotLight).isSpotLight + ? readSpot(light as unknown as THREE.SpotLight, root, lightId) + : undefined, + shadows: + typeof userData.shadows === 'boolean' + ? userData.shadows + : light.isLight && light.castShadow + ? true + : undefined, + glow: readGlow(userData.glow, lightId), }) toRemove.push(object) return diff --git a/packages/viewer/src/components/viewer/glb-interactive.tsx b/packages/viewer/src/components/viewer/glb-interactive.tsx index 06669949b9..5379ca3fc3 100644 --- a/packages/viewer/src/components/viewer/glb-interactive.tsx +++ b/packages/viewer/src/components/viewer/glb-interactive.tsx @@ -21,14 +21,14 @@ import { type ProceduralItemNode, } from '@pascal-app/core/procedural-items' import { Html } from '@react-three/drei' -import { createPortal, useFrame, useThree } from '@react-three/fiber' -import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' +import { createPortal, useThree } from '@react-three/fiber' +import { useEffect, useLayoutEffect, useMemo, useState } from 'react' import { type AnimationAction, LoopRepeat, MathUtils, type Object3D, - type PointLight, + Quaternion, Vector3, } from 'three' import { useShallow } from 'zustand/react/shallow' @@ -43,6 +43,7 @@ import useViewer from '../../store/use-viewer' import { ControlWidget } from '../../systems/interactive/control-widget' import { proceduralControlDescriptors } from '../../systems/interactive/procedural-controls' import { type ScriptedClipActions, ScriptedClips } from '../../systems/interactive/scripted-clips' +import { type PooledLight, PooledLights } from '../../systems/item-light/pooled-lights' /** An interactive item recovered from the scene graph so the baked GLB can be * re-lit / re-animated by joining on `pascalId`. The GLB carries the geometry @@ -176,6 +177,7 @@ export function buildGlbLightRegs( const anchor = motion ?? object anchor.updateWorldMatrix(true, false) out.copy(local).applyMatrix4(anchor.matrixWorld) + return true }, getIntensity: () => light.intensity, isOn: () => @@ -197,6 +199,14 @@ export function buildGlbLightRegs( object, color: effect.color, distance: effect.distance ?? 0, + spot: effect.spot, + shadows: effect.shadows, + glow: effect.glow, + peak: effect.intensityRange[1], + getWorldDirection: (out) => { + out.set(...(effect.spot?.direction ?? [0, -1, 0])).normalize() + out.applyQuaternion(object.getWorldQuaternion(_quaternion)) + }, getWorldPosition: (out) => { object.updateWorldMatrix(true, false) if (item.scripted) { @@ -208,10 +218,11 @@ export function buildGlbLightRegs( effect.offset[2] * scale[2], ) .applyMatrix4(object.matrixWorld) - return + return true } object.getWorldPosition(out) out.set(out.x + effect.offset[0], out.y + effect.offset[1], out.z + effect.offset[2]) + return true }, getIntensity: () => { const values = useInteractive.getState().items[item.pascalId]?.controlValues @@ -380,36 +391,21 @@ function GlbProceduralEmission({ item, object }: { item: GlbInteractiveItem; obj // ── Pooled item lights ────────────────────────────────────────────────────── // -// Mirrors the parametric `ItemLightSystem`: a fixed pool of point lights is -// assigned to the nearest/most-visible lit items each tick (camera-proximity -// scored, with hysteresis), snapped to the item's world position + offset, and -// faded in/out on reassignment. Mounting a light per item instead would blow -// the renderer's light budget on a large house. - -const POOL_SIZE = 12 -const REASSIGN_INTERVAL = 0.2 -const HYSTERESIS = 0.15 -const CAM_MOVE_DIST = 0.5 -const CAM_ROT_DOT = 0.995 +// The same pools as the parametric `ItemLightSystem`, fed from the baked +// Object3Ds: mounting a light per item instead would blow the renderer's light +// budget on a large house. -export type GlbLightReg = { - key: string +export type GlbLightReg = PooledLight & { nodeId: AnyNodeId object: Object3D - color: string - distance: number - getWorldPosition: (out: Vector3) => void getIntensity: () => number isOn: () => boolean levelId: string | null } -type SlotRuntime = { key: string | null; pendingKey: string | null; isFadingOut: boolean } - -const _camPos = new Vector3() -const _camFwd = new Vector3() const _dir = new Vector3() const _lightWorld = new Vector3() +const _quaternion = new Quaternion() /** The nearest level-identity ancestor's pascalId, for the level factor. */ function findLevelId(object: Object3D): string | null { @@ -433,6 +429,8 @@ function isRendered(object: Object3D): boolean { function scoreReg( reg: GlbLightReg, + camPos: Vector3, + camFwd: Vector3, selectedLevelId: string | null, levelMode: string, levelIndexById: Map, @@ -441,9 +439,9 @@ function scoreReg( if (selectedLevelId && reg.levelId !== selectedLevelId && levelMode === 'solo') return Number.POSITIVE_INFINITY reg.getWorldPosition(_lightWorld) - _dir.copy(_lightWorld).sub(_camPos).normalize() - const angular = 1 - _camFwd.dot(_dir) - const dist = _camPos.distanceTo(_lightWorld) / 200 + _dir.copy(_lightWorld).sub(camPos).normalize() + const angular = 1 - camFwd.dot(_dir) + const dist = camPos.distanceTo(_lightWorld) / 200 let levelPenalty = 0 if (selectedLevelId) { if (reg.levelId !== selectedLevelId) levelPenalty = levelMode === 'solo' ? 100 : 0.8 @@ -460,170 +458,22 @@ function GlbItemLights({ regs: GlbLightReg[] levelIndexById: Map }) { - const lightRefs = useRef>(Array.from({ length: POOL_SIZE }, () => null)) - const slots = useRef( - Array.from({ length: POOL_SIZE }, () => ({ key: null, pendingKey: null, isFadingOut: false })), - ) - const reassignTimer = useRef(0) - const prevCamPos = useRef(new Vector3()) - const prevCamFwd = useRef(new Vector3(0, 0, -1)) - const regByKey = useMemo(() => new Map(regs.map((r) => [r.key as string, r])), [regs]) - - useFrame(({ camera }, delta) => { - const dt = Math.min(delta, 0.1) - camera.getWorldPosition(_camPos) - camera.getWorldDirection(_camFwd) - - const camMoved = - _camPos.distanceTo(prevCamPos.current) > CAM_MOVE_DIST || - _camFwd.dot(prevCamFwd.current) < CAM_ROT_DOT - reassignTimer.current -= delta - - if (reassignTimer.current <= 0 || camMoved) { - reassignTimer.current = REASSIGN_INTERVAL - prevCamPos.current.copy(_camPos) - prevCamFwd.current.copy(_camFwd) - const viewer = useViewer.getState() - const selectedLevelId = viewer.selection.levelId - const levelMode = viewer.levelMode - - const scored = regs.map((reg) => ({ - key: reg.key as string, - score: scoreReg(reg, selectedLevelId, levelMode, levelIndexById), - })) - scored.sort((a, b) => a.score - b.score) - const scoreByKey = new Map(scored.map((s) => [s.key, s.score] as const)) - const desired = scored - .filter((s) => Number.isFinite(s.score)) - .slice(0, POOL_SIZE) - .map((s) => s.key) - - const currentlyAssigned = new Map() - for (let i = 0; i < POOL_SIZE; i++) { - const s = slots.current[i] - const k = s?.key ?? s?.pendingKey - if (k) currentlyAssigned.set(k, i) - } - - const usedSlots = new Set() - const assignedKeys = new Set() - // Pass 1: keep existing slots whose key is still wanted. - for (const key of desired) { - const existingSlot = currentlyAssigned.get(key) - if (existingSlot !== undefined && !usedSlots.has(existingSlot)) { - usedSlots.add(existingSlot) - assignedKeys.add(key) - } - } - // Pass 2: assign the rest to free slots, evicting only on a clear win. - let freeSlot = 0 - for (const key of desired) { - if (assignedKeys.has(key)) continue - while (freeSlot < POOL_SIZE && usedSlots.has(freeSlot)) freeSlot++ - if (freeSlot >= POOL_SIZE) break - - const freeSlotData = slots.current[freeSlot] - const currentKey = freeSlotData ? (freeSlotData.key ?? freeSlotData.pendingKey) : null - if (currentKey && !desired.includes(currentKey)) { - const currentScore = scoreByKey.get(currentKey) ?? Number.POSITIVE_INFINITY - const newScore = scoreByKey.get(key) ?? 0 - if (currentScore - newScore < HYSTERESIS) { - freeSlot++ - continue - } - } - - usedSlots.add(freeSlot) - assignedKeys.add(key) - const slot = slots.current[freeSlot] - if (slot && slot.key !== key) { - slot.pendingKey = key - slot.isFadingOut = slot.key !== null - if (!slot.isFadingOut) { - slot.key = key - slot.pendingKey = null - const light = lightRefs.current[freeSlot] - const reg = regByKey.get(key) - if (light && reg) { - light.color.set(reg.color) - light.distance = reg.distance - } - } - } - freeSlot++ - } - - // Retire slots whose key is no longer wanted. - for (let i = 0; i < POOL_SIZE; i++) { - if (!usedSlots.has(i)) { - const slot = slots.current[i] - if (slot?.key && !desired.includes(slot.key)) { - slot.pendingKey = null - slot.isFadingOut = true - } - } - } - } - - // Per-frame: fade, snap position, and track intensity from control state. - // The pool lights stay permanently `visible` — only `intensity` is animated - // (an idle light just lerps to 0). Toggling `visible` would change the - // active-light count, which forces the WebGPU renderer to recompile every - // material's lighting node — a hard frame-time spike on every reassignment - // (i.e. on every camera move). Keeping the count fixed avoids that entirely. - for (let i = 0; i < POOL_SIZE; i++) { - const light = lightRefs.current[i] - const slot = slots.current[i] - if (!(light && slot)) continue - - if (slot.isFadingOut) { - light.intensity = MathUtils.lerp(light.intensity, 0, dt * 12) - if (light.intensity < 0.01) { - light.intensity = 0 - slot.isFadingOut = false - slot.key = slot.pendingKey - slot.pendingKey = null - if (slot.key) { - const reg = regByKey.get(slot.key) - if (reg) { - light.color.set(reg.color) - light.distance = reg.distance - } - } - } - continue - } - - if (!slot.key) { - light.intensity = MathUtils.lerp(light.intensity, 0, dt * 12) - continue - } - const reg = regByKey.get(slot.key) - if (!reg) { - slot.key = null - continue - } - - reg.getWorldPosition(_lightWorld) - light.position.copy(_lightWorld) - const targetIntensity = reg.isOn() && isRendered(reg.object) ? reg.getIntensity() : 0 - light.intensity = MathUtils.lerp(light.intensity, targetIntensity, dt * 12) - } - }, 6) - return ( - <> - {Array.from({ length: POOL_SIZE }, (_, i) => ( - { - lightRefs.current[i] = el - }} - /> - ))} - + + getLights={() => regs} + intensity={(reg) => (reg.isOn() && isRendered(reg.object) ? reg.getIntensity() : 0)} + score={(reg, camPos, camFwd) => { + const viewer = useViewer.getState() + return scoreReg( + reg, + camPos, + camFwd, + viewer.selection.levelId, + viewer.levelMode, + levelIndexById, + ) + }} + /> ) } diff --git a/packages/viewer/src/store/use-item-light-pool.ts b/packages/viewer/src/store/use-item-light-pool.ts index b4621e5e4e..af8f4b0d9d 100644 --- a/packages/viewer/src/store/use-item-light-pool.ts +++ b/packages/viewer/src/store/use-item-light-pool.ts @@ -6,19 +6,18 @@ import { sceneRegistry, useInteractive, } from '@pascal-app/core' -import type { Object3D, Vector3 } from 'three' +import { type Object3D, Quaternion } from 'three' import { create } from 'zustand' +import type { PooledLight } from '../systems/item-light/pooled-lights' -export type LightSource = { - key: string +export type LightSource = PooledLight & { nodeId: AnyNodeId - color: string - distance: number - getWorldPosition: (out: Vector3) => boolean getIntensity: () => number isEligible: () => boolean } +const _quaternion = new Quaternion() + export function catalogLightSource( key: string, nodeId: AnyNodeId, @@ -34,6 +33,15 @@ export function catalogLightSource( nodeId, color: effect.color, distance: effect.distance ?? 0, + spot: effect.spot, + shadows: effect.shadows, + glow: effect.glow, + peak: effect.intensityRange[1], + getWorldDirection: (out) => { + const object = sceneRegistry.nodes.get(nodeId) + out.set(...(effect.spot?.direction ?? [0, -1, 0])).normalize() + if (object) out.applyQuaternion(object.getWorldQuaternion(_quaternion)) + }, getWorldPosition: (out) => { const object = sceneRegistry.nodes.get(nodeId) if (!object) return false diff --git a/packages/viewer/src/systems/item-light/item-light-system.test.ts b/packages/viewer/src/systems/item-light/item-light-system.test.ts index e225844e2c..fe12fae647 100644 --- a/packages/viewer/src/systems/item-light/item-light-system.test.ts +++ b/packages/viewer/src/systems/item-light/item-light-system.test.ts @@ -4,10 +4,11 @@ import { sceneRegistry, useScene } from '@pascal-app/core' import { useThree } from '@react-three/fiber' import { act, create } from '@react-three/test-renderer' import { createElement } from 'react' -import { Group, type PointLight } from 'three' +import { Group, type PointLight, type SpotLight, Vector3 } from 'three' import { useItemLightPool } from '../../store/use-item-light-pool' import useViewer from '../../store/use-viewer' import { ItemLightSystem } from './item-light-system' +import { glowDarkness } from './pooled-lights' let previousViewerState = useViewer.getState() @@ -149,3 +150,65 @@ test('a baked owner suppresses the native pool on its canvas', async () => { await renderer.unmount() } }) + +test('a spot with shadows takes a shadow slot, aimed; one without shines from the spot pool; both glow', async () => { + const id = 'item_sconce' as AnyNodeId + const item = new Group() + sceneRegistry.nodes.set(id, item) + useScene.setState({ + nodes: { [id]: { id, type: 'item', visible: true, parentId: null } } as unknown as Record< + AnyNodeId, + AnyNode + >, + }) + const pool = useItemLightPool.getState() + const spot = (key: string, shadows: boolean) => + pool.register({ + key, + nodeId: id, + color: '#ffd9a0', + distance: 4, + spot: { direction: [0, 1, 0], angle: 60, softness: 0.5 }, + shadows, + glow: { size: 0.2, strength: 1 }, + peak: 2, + getWorldPosition: (out) => { + out.set(1, 2, 3) + return true + }, + getWorldDirection: (out) => { + out.set(0, 1, 0) + }, + getIntensity: () => 2, + isEligible: () => true, + }) + spot('wash', true) + spot('accent', false) + const renderer = await create(createElement(ItemLightSystem)) + try { + // The first tick asks for the spot, shadow and glow pools; they mount on the next render. + await act(async () => renderer.advanceFrames(1, 1 / 30)) + await renderer.advanceFrames(20, 1 / 30) + const spots = renderer.scene + .findAllByType('SpotLight') + .map((entry) => entry.instance as SpotLight) + const lit = spots.filter((light) => light.intensity > 0) + expect(lit.map((light) => light.castShadow).sort()).toEqual([false, true]) + for (const light of lit) { + expect(light.angle).toBeCloseTo(Math.PI / 6) + expect(light.penumbra).toBe(0.5) + expect(light.target.position.toArray()).toEqual([1, 3, 3]) + } + const glows = renderer.scene.findAll((entry) => entry.instance.name === 'light-glows') + expect((glows[0]!.instance as unknown as { count: number }).count).toBe(2) + } finally { + await renderer.unmount() + } +}) + +test('a glow shows at night and nearly vanishes under a high sun', () => { + expect(glowDarkness(null, 'dark')).toBe(1) + expect(glowDarkness(null, 'light')).toBeLessThan(0.1) + expect(glowDarkness(new Vector3(0, -1, 0), 'light')).toBe(1) + expect(glowDarkness(new Vector3(1, 1, 0), 'dark')).toBeLessThan(0.1) +}) diff --git a/packages/viewer/src/systems/item-light/item-light-system.tsx b/packages/viewer/src/systems/item-light/item-light-system.tsx index 5e69e0b8fd..6b80c35376 100644 --- a/packages/viewer/src/systems/item-light/item-light-system.tsx +++ b/packages/viewer/src/systems/item-light/item-light-system.tsx @@ -1,36 +1,14 @@ import type { AnyNodeId, LevelNode } from '@pascal-app/core' import { findLevelAncestorId, sceneRegistry, useScene } from '@pascal-app/core' -import { useFrame, useThree } from '@react-three/fiber' -import { useRef } from 'react' -import { MathUtils, type PointLight, Vector3 } from 'three' +import { useThree } from '@react-three/fiber' +import { Vector3 } from 'three' import { SCENE_LAYER } from '../../lib/layers' import { type LightSource, useItemLightPool } from '../../store/use-item-light-pool' import useViewer from '../../store/use-viewer' - -const POOL_SIZE = 12 -// How often (in seconds) to re-evaluate which items have lights assigned (fallback timer) -const REASSIGN_INTERVAL = 0.2 - -// Hysteresis: a currently-assigned slot keeps its key unless an unassigned -// candidate beats it by at least this much (prevents flickering at the boundary) -const HYSTERESIS = 0.15 - -// Camera movement thresholds that trigger an early re-evaluation -const CAM_MOVE_DIST = 0.5 // units -const CAM_ROT_DOT = 0.995 // cos(~5.7°) - -type SlotRuntime = { - // The key currently driving this slot (null = idle) - key: string | null - // A pending reassignment waiting for the fade-out to finish - pendingKey: string | null - isFadingOut: boolean -} +import { PooledLights } from './pooled-lights' // Module-level temp vectors reused every frame (avoids GC pressure) const _dir = new Vector3() -const _camPos = new Vector3() -const _camFwd = new Vector3() const _itemPos = new Vector3() type SceneNodes = ReturnType['nodes'] @@ -47,6 +25,8 @@ function isRendered(nodeId: AnyNodeId): boolean { function scoreRegistration( reg: LightSource, + camPos: Vector3, + camFwd: Vector3, nodes: SceneNodes, selectedLevelId: string | null, levelMode: string, @@ -64,13 +44,13 @@ function scoreRegistration( if (selectedLevelId && itemLevelId !== selectedLevelId && levelMode === 'solo') return Number.POSITIVE_INFINITY - _dir.copy(_itemPos).sub(_camPos).normalize() - const dot = _camFwd.dot(_dir) // 1 = ahead, -1 = behind + _dir.copy(_itemPos).sub(camPos).normalize() + const dot = camFwd.dot(_dir) // 1 = ahead, -1 = behind // Angular component (0 = dead ahead, 2 = directly behind) const angular = 1 - dot // Normalised distance component (assumes scenes < 200 units) - const dist = _camPos.distanceTo(_itemPos) / 200 + const dist = camPos.distanceTo(_itemPos) / 200 // ── Level factor ────────────────────────────────────────────────────────── let levelPenalty = 0 @@ -97,203 +77,23 @@ function scoreRegistration( export function ItemLightSystem() { const scene = useThree((state) => state.scene) const bakedOwner = useItemLightPool((state) => state.bakedCanvases.has(scene)) - const lightRefs = useRef>(Array.from({ length: POOL_SIZE }, () => null)) - const slots = useRef( - Array.from({ length: POOL_SIZE }, () => ({ key: null, pendingKey: null, isFadingOut: false })), - ) - const reassignTimer = useRef(0) - - // Track camera state at last reassignment to detect meaningful movement - const prevReassignCamPos = useRef(new Vector3()) - const prevReassignCamFwd = useRef(new Vector3(0, 0, -1)) - - useFrame(({ camera }, delta) => { - if (bakedOwner) return - const dt = Math.min(delta, 0.1) - const { registrations } = useItemLightPool.getState() - - // ── 1. Throttled priority reassignment ────────────────────────────────── - camera.getWorldPosition(_camPos) - camera.getWorldDirection(_camFwd) - - const camMoved = - _camPos.distanceTo(prevReassignCamPos.current) > CAM_MOVE_DIST || - _camFwd.dot(prevReassignCamFwd.current) < CAM_ROT_DOT - - reassignTimer.current -= delta - const shouldReassign = reassignTimer.current <= 0 || camMoved - - if (shouldReassign) { - reassignTimer.current = REASSIGN_INTERVAL - prevReassignCamPos.current.copy(_camPos) - prevReassignCamFwd.current.copy(_camFwd) - - // Read level/scene state once for the whole tick - const nodes = useScene.getState().nodes - const viewerState = useViewer.getState() - const selectedLevelId = viewerState.selection.levelId - const levelMode = viewerState.levelMode - const hideAbove = viewerState.hideLevelsAboveSelection - - // Score every registration - const scored: Array<{ key: string; score: number }> = [] - for (const [key, reg] of registrations) { - scored.push({ - key, - score: scoreRegistration(reg, nodes, selectedLevelId, levelMode, hideAbove), - }) - } - scored.sort((a, b) => a.score - b.score) - - // Build the desired assignment (top POOL_SIZE keys) - const desired = scored - .filter((s) => Number.isFinite(s.score)) - .slice(0, POOL_SIZE) - .map((s) => s.key) - - // Build a map of currently-assigned keys → slot index for hysteresis - const currentlyAssigned = new Map() - for (let i = 0; i < POOL_SIZE; i++) { - const s = slots.current[i] - if (!s) continue - const k = s.key ?? s.pendingKey - if (k) currentlyAssigned.set(k, i) - } - - // Assign desired keys to slots — prefer keeping existing assignments - const usedSlots = new Set() - const assignedKeys = new Set() - - // Pass 1: keep existing slots where the key is still in desired - for (const key of desired) { - const existingSlot = currentlyAssigned.get(key) - if (existingSlot !== undefined && !usedSlots.has(existingSlot)) { - usedSlots.add(existingSlot) - assignedKeys.add(key) - } - } - - // Pass 2: assign remaining desired keys to free slots - let freeSlot = 0 - for (const key of desired) { - if (assignedKeys.has(key)) continue - while (freeSlot < POOL_SIZE && usedSlots.has(freeSlot)) freeSlot++ - if (freeSlot >= POOL_SIZE) break - - // Hysteresis: only evict the current occupant if the new key scores - // meaningfully better than it - const freeSlotData = slots.current[freeSlot] - const currentKey = freeSlotData ? (freeSlotData.key ?? freeSlotData.pendingKey) : null - if (currentKey && !desired.includes(currentKey)) { - const currentScore = - scored.find((s) => s.key === currentKey)?.score ?? Number.POSITIVE_INFINITY - const newScore = scored.find((s) => s.key === key)?.score ?? 0 - if (currentScore - newScore < HYSTERESIS) { - freeSlot++ - continue - } - } - - usedSlots.add(freeSlot) - assignedKeys.add(key) - - const slot = slots.current[freeSlot] - if (slot && slot.key !== key) { - slot.pendingKey = key - slot.isFadingOut = slot.key !== null - if (!slot.isFadingOut) { - // Slot was idle — skip fade-out, assign immediately - slot.key = key - slot.pendingKey = null - const light = lightRefs.current[freeSlot] - const reg = registrations.get(key) - if (light && reg) { - light.color.set(reg.color) - light.distance = reg.distance - } - } - } - freeSlot++ - } - - // Clear slots whose key is no longer in desired and not pending - for (let i = 0; i < POOL_SIZE; i++) { - if (!usedSlots.has(i)) { - const slot = slots.current[i] - if (slot?.key && !desired.includes(slot.key)) { - slot.pendingKey = null - slot.isFadingOut = true - } - } - } - } - - // ── 2. Per-frame light updates ─────────────────────────────────────────── - for (let i = 0; i < POOL_SIZE; i++) { - const light = lightRefs.current[i] - if (!light) continue - - const slot = slots.current[i] - if (!slot) continue - - // Fade-out phase: lerp intensity → 0, then complete the transition - if (slot.isFadingOut) { - light.intensity = MathUtils.lerp(light.intensity, 0, dt * 12) - if (light.intensity < 0.01) { - light.intensity = 0 - slot.isFadingOut = false - slot.key = slot.pendingKey - slot.pendingKey = null - - if (slot.key) { - const reg = registrations.get(slot.key) - if (reg) { - light.color.set(reg.color) - light.distance = reg.distance - } - } - } - continue - } - - if (!slot.key) { - // Idle slot — keep dark - light.intensity = 0 - continue - } - - const reg = registrations.get(slot.key) - if (!reg) { - slot.key = null - light.intensity = 0 - continue - } - - if (reg.getWorldPosition(_itemPos)) light.position.copy(_itemPos) - const targetIntensity = reg.isEligible() && isRendered(reg.nodeId) ? reg.getIntensity() : 0 - - light.intensity = MathUtils.lerp(light.intensity, targetIntensity, dt * 12) - if (targetIntensity <= 0 && light.intensity < 0.01) { - light.intensity = 0 - } - } - }, 6) - if (bakedOwner) return null - return ( - <> - {Array.from({ length: POOL_SIZE }, (_, i) => ( - { - lightRefs.current[i] = el - }} - /> - ))} - + + getLights={() => useItemLightPool.getState().registrations.values()} + intensity={(reg) => (reg.isEligible() && isRendered(reg.nodeId) ? reg.getIntensity() : 0)} + score={(reg, camPos, camFwd) => { + const viewer = useViewer.getState() + return scoreRegistration( + reg, + camPos, + camFwd, + useScene.getState().nodes, + viewer.selection.levelId, + viewer.levelMode, + viewer.hideLevelsAboveSelection, + ) + }} + /> ) } diff --git a/packages/viewer/src/systems/item-light/pooled-lights.tsx b/packages/viewer/src/systems/item-light/pooled-lights.tsx new file mode 100644 index 0000000000..1bbe349364 --- /dev/null +++ b/packages/viewer/src/systems/item-light/pooled-lights.tsx @@ -0,0 +1,409 @@ +'use client' + +import type { LightGlow, LightSpot } from '@pascal-app/core' +import { useFrame } from '@react-three/fiber' +import { useMemo, useRef, useState } from 'react' +import { float, instancedDynamicBufferAttribute, smoothstep, uv } from 'three/tsl' +import { + AdditiveBlending, + Color, + DynamicDrawUsage, + InstancedBufferAttribute, + MathUtils, + type PointLight, + type SpotLight, + Sprite, + SpriteNodeMaterial, + Vector3, +} from 'three/webgpu' +import { useSceneAtmosphere } from '../../components/viewer/scene-atmosphere' +import useViewer from '../../store/use-viewer' + +/** A light an item holds, as the pools drive it. */ +export type PooledLight = { + key: string + color: string + distance: number + spot?: LightSpot + shadows?: boolean + glow?: LightGlow + /** Its fully-on intensity, so its glow dims with it. */ + peak?: number + /** Where it shines from; false while its object is not mounted. */ + getWorldPosition: (out: Vector3) => boolean + /** Its spot's aim in world space, turned with its item. */ + getWorldDirection?: (out: Vector3) => void +} + +type Props = { + getLights: () => Iterable + /** Lower is more wanted; infinite when it cannot be seen lit (off, hidden). */ + score: (light: T, camPos: Vector3, camFwd: Vector3) => number + /** What it shines at now: 0 while off or not drawn. */ + intensity: (light: T) => number +} + +const POINT_POOL = 12 +const SPOT_POOL = 6 +const SHADOW_POOL = 2 +// A point light's shadow is six renders and a spot's one, so spots win the shadow slots. +const POINT_SHADOW_PENALTY = 0.5 +const GLOW_CAPACITY = 256 +// How bright a glow stays in full daylight, against 1 at night. +const DAYLIGHT_GLOW = 0.08 +// Strength 1 is a lamp's plain halo at night, read against tone-mapped light colours. +const GLOW_GAIN = 3 + +// How often (in seconds) to re-evaluate which lights hold slots (fallback timer) +const REASSIGN_INTERVAL = 0.2 +// A held slot keeps its key unless a candidate beats it by at least this much (no flicker). +const HYSTERESIS = 0.15 +// Camera movement that triggers an early re-evaluation +const CAM_MOVE_DIST = 0.5 +const CAM_ROT_DOT = 0.995 + +type Slot = { key: string | null; pendingKey: string | null; isFadingOut: boolean } +type Candidate = { key: string; score: number } + +const slotsOf = (size: number): Slot[] => + Array.from({ length: size }, () => ({ key: null, pendingKey: null, isFadingOut: false })) + +/** + * Gives the wanted keys (best first) the pool's slots: a key that holds a slot keeps it, and a + * newcomer evicts a key no longer wanted only on a clear win. A slot changing hands fades out + * first; an idle slot takes its key at once. + */ +export function assignSlots(slots: Slot[], wanted: Candidate[], scoreByKey: Map) { + const desired = wanted.map((candidate) => candidate.key) + const held = new Map() + slots.forEach((slot, i) => { + const key = slot.key ?? slot.pendingKey + if (key) held.set(key, i) + }) + const used = new Set() + const assigned = new Set() + for (const key of desired) { + const index = held.get(key) + if (index !== undefined && !used.has(index)) { + used.add(index) + assigned.add(key) + } + } + let free = 0 + for (const key of desired) { + if (assigned.has(key)) continue + while (free < slots.length && used.has(free)) free++ + if (free >= slots.length) break + const slot = slots[free]! + const current = slot.key ?? slot.pendingKey + if (current && !desired.includes(current)) { + const currentScore = scoreByKey.get(current) ?? Number.POSITIVE_INFINITY + if (currentScore - (scoreByKey.get(key) ?? 0) < HYSTERESIS) { + free++ + continue + } + } + used.add(free) + assigned.add(key) + if (slot.key !== key) { + slot.pendingKey = key + slot.isFadingOut = slot.key !== null + if (!slot.isFadingOut) { + slot.key = key + slot.pendingKey = null + } + } + free++ + } + slots.forEach((slot, i) => { + if (!used.has(i) && slot.key && !desired.includes(slot.key)) { + slot.pendingKey = null + slot.isFadingOut = true + } + }) +} + +/** How much a glow shows: 1 at night, nearly nothing in daylight. */ +export function glowDarkness(sunDirection: Vector3 | null, appearance: 'light' | 'dark'): number { + if (!sunDirection) return appearance === 'dark' ? 1 : DAYLIGHT_GLOW + const elevation = sunDirection.y / (sunDirection.length() || 1) + return MathUtils.lerp(1, DAYLIGHT_GLOW, MathUtils.smoothstep(elevation, -0.05, 0.3)) +} + +const _pos = new Vector3() +const _dir = new Vector3() +const _camPos = new Vector3() +const _camFwd = new Vector3() +const _color = new Color() + +function place(light: PointLight | SpotLight, source: PooledLight) { + light.color.set(source.color) + light.distance = source.distance + if (source.getWorldPosition(_pos)) light.position.copy(_pos) + const spot = light as SpotLight + if (!spot.isSpotLight) return + spot.angle = Math.min(Math.PI / 2, MathUtils.degToRad((source.spot?.angle ?? 60) / 2)) + spot.penumbra = source.spot?.softness ?? 0 + if (source.getWorldDirection) source.getWorldDirection(_dir) + else _dir.set(0, -1, 0) + spot.target.position.copy(spot.position).add(_dir) + spot.target.updateMatrixWorld() +} + +const fade = (light: PointLight | SpotLight, to: number, dt: number) => { + light.intensity = MathUtils.lerp(light.intensity, to, dt * 12) + if (to <= 0 && light.intensity < 0.01) light.intensity = 0 +} + +/** + * Drives a pool's slots for a frame. A slot's lights are its point light and, in the shadow + * pool, a spot beside it: the one matching its source shines, the other rests dark with its + * shadow map frozen. + */ +function drivePool( + slots: Slot[], + lights: Array>, + byKey: Map, + intensity: (light: T) => number, + dt: number, +) { + slots.forEach((slot, i) => { + const pair = lights[i] + if (!pair?.every(Boolean)) return + const members = pair as Array + if (slot.isFadingOut) { + for (const light of members) fade(light, 0, dt) + if (members.every((light) => light.intensity === 0)) { + slot.isFadingOut = false + slot.key = slot.pendingKey + slot.pendingKey = null + } + return + } + const source = slot.key ? byKey.get(slot.key) : undefined + if (!source) slot.key = null + const active = source + ? (members.find((light) => Boolean((light as SpotLight).isSpotLight) === !!source.spot) ?? + members[0]!) + : null + for (const light of members) { + if (light !== active || !source) { + fade(light, 0, dt) + if (light.castShadow) light.shadow.autoUpdate = light.intensity > 0 + continue + } + place(light, source) + fade(light, intensity(source), dt) + if (light.castShadow) light.shadow.autoUpdate = true + } + }) +} + +function createGlows() { + const positions = new InstancedBufferAttribute(new Float32Array(GLOW_CAPACITY * 3), 3) + const colors = new InstancedBufferAttribute(new Float32Array(GLOW_CAPACITY * 3), 3) + const sizes = new InstancedBufferAttribute(new Float32Array(GLOW_CAPACITY), 1) + for (const attribute of [positions, colors, sizes]) attribute.setUsage(DynamicDrawUsage) + const material = new SpriteNodeMaterial({ + transparent: true, + depthWrite: false, + blending: AdditiveBlending, + }) + material.positionNode = instancedDynamicBufferAttribute(positions) + material.scaleNode = instancedDynamicBufferAttribute(sizes) + material.colorNode = instancedDynamicBufferAttribute(colors, 'vec3') + // A soft disc: full at the centre, smoothly nothing at the rim. + const falloff = smoothstep(float(1), float(0), uv().sub(0.5).length().mul(2)) + material.opacityNode = falloff.mul(falloff) + material.fog = false + const sprite = new Sprite(material) + sprite.count = 0 + sprite.frustumCulled = false + sprite.raycast = () => {} + sprite.name = 'light-glows' + return { sprite, positions, colors, sizes } +} + +/** + * The lights items hold, through fixed pools: twelve point lights, six spots and two + * shadow-casting slots, each handed each tick to the most-wanted lights (camera-proximity + * scored, with hysteresis) and faded across reassignment. Every pool light stays mounted and + * visible and only its intensity moves: changing the light count recompiles every material. + * The spot and shadow pools mount the first time a light needs them, and stay. Glows are one + * instanced sprite for every glowing lamp, pooled or not. + */ +export function PooledLights({ getLights, score, intensity }: Props) { + const atmosphere = useSceneAtmosphere() + const [needs, setNeeds] = useState({ spots: false, shadows: false, glows: false }) + const needsRef = useRef(needs) + needsRef.current = needs + const pools = useRef({ + point: slotsOf(POINT_POOL), + spot: slotsOf(SPOT_POOL), + shadow: slotsOf(SHADOW_POOL), + }) + const pointRefs = useRef>>( + Array.from({ length: POINT_POOL }, () => [null]), + ) + const spotRefs = useRef>>( + Array.from({ length: SPOT_POOL }, () => [null]), + ) + const shadowRefs = useRef>>( + Array.from({ length: SHADOW_POOL }, () => [null, null]), + ) + const glows = useMemo(createGlows, []) + const reassignTimer = useRef(0) + const prevCamPos = useRef(new Vector3()) + const prevCamFwd = useRef(new Vector3(0, 0, -1)) + + useFrame(({ camera }, delta) => { + const dt = Math.min(delta, 0.1) + const byKey = new Map() + for (const light of getLights()) byKey.set(light.key, light) + + camera.getWorldPosition(_camPos) + camera.getWorldDirection(_camFwd) + const camMoved = + _camPos.distanceTo(prevCamPos.current) > CAM_MOVE_DIST || + _camFwd.dot(prevCamFwd.current) < CAM_ROT_DOT + reassignTimer.current -= delta + if (reassignTimer.current <= 0 || camMoved) { + reassignTimer.current = REASSIGN_INTERVAL + prevCamPos.current.copy(_camPos) + prevCamFwd.current.copy(_camFwd) + + const mounted = needsRef.current + const wants = { ...mounted } + const scored: Array = [] + for (const light of byKey.values()) { + wants.spots ||= Boolean(light.spot) + wants.shadows ||= Boolean(light.shadows) + wants.glows ||= Boolean(light.glow) + const value = score(light, _camPos, _camFwd) + if (Number.isFinite(value)) scored.push({ key: light.key, score: value, light }) + } + if ( + wants.spots !== mounted.spots || + wants.shadows !== mounted.shadows || + wants.glows !== mounted.glows + ) + setNeeds(wants) + scored.sort((a, b) => a.score - b.score) + const scoreByKey = new Map(scored.map((candidate) => [candidate.key, candidate.score])) + + const shadowed = mounted.shadows + ? scored + .filter((candidate) => candidate.light.shadows) + .map((candidate) => ({ + key: candidate.key, + score: candidate.score + (candidate.light.spot ? 0 : POINT_SHADOW_PENALTY), + })) + .sort((a, b) => a.score - b.score) + .slice(0, SHADOW_POOL) + : [] + const inShadowPool = new Set(shadowed.map((candidate) => candidate.key)) + const rest = scored.filter((candidate) => !inShadowPool.has(candidate.key)) + assignSlots(pools.current.shadow, shadowed, scoreByKey) + assignSlots( + pools.current.spot, + mounted.spots ? rest.filter((candidate) => candidate.light.spot).slice(0, SPOT_POOL) : [], + scoreByKey, + ) + // Until the spot pool mounts, a spot shines as a point light. + assignSlots( + pools.current.point, + rest.filter((candidate) => !(mounted.spots && candidate.light.spot)).slice(0, POINT_POOL), + scoreByKey, + ) + } + + drivePool(pools.current.point, pointRefs.current, byKey, intensity, dt) + drivePool(pools.current.spot, spotRefs.current, byKey, intensity, dt) + drivePool(pools.current.shadow, shadowRefs.current, byKey, intensity, dt) + + if (!needsRef.current.glows) return + const darkness = glowDarkness( + atmosphere?.sunDirection ?? null, + useViewer.getState().sceneAppearance.appearance, + ) + let count = 0 + for (const light of byKey.values()) { + if (!light.glow || count >= GLOW_CAPACITY) continue + const now = intensity(light) + if (now <= 0 || !light.getWorldPosition(_pos)) continue + // Drawn its radius nearer the eye, so the fixture around the bulb does not swallow it. + _dir.subVectors(_camPos, _pos) + _pos.addScaledVector( + _dir, + Math.min(light.glow.size / 2, _dir.length() / 2) / (_dir.length() || 1), + ) + const level = light.peak ? Math.min(1, now / light.peak) : 1 + _color.set(light.color).multiplyScalar(GLOW_GAIN * light.glow.strength * level * darkness) + glows.positions.setXYZ(count, _pos.x, _pos.y, _pos.z) + glows.colors.setXYZ(count, _color.r, _color.g, _color.b) + glows.sizes.setX(count, light.glow.size) + count++ + } + glows.sprite.count = count + glows.sprite.visible = count > 0 + glows.positions.needsUpdate = true + glows.colors.needsUpdate = true + glows.sizes.needsUpdate = true + }, 6) + + return ( + <> + {pointRefs.current.map((_, i) => ( + { + pointRefs.current[i]![0] = el + }} + /> + ))} + {needs.spots && + spotRefs.current.map((_, i) => ( + { + spotRefs.current[i]![0] = el + }} + /> + ))} + {needs.shadows && + shadowRefs.current.map((_, i) => ( + + { + shadowRefs.current[i]![0] = el + }} + shadow-bias={-0.0005} + shadow-camera-far={20} + shadow-camera-near={0.05} + shadow-mapSize={[512, 512]} + shadow-normalBias={0.02} + /> + { + shadowRefs.current[i]![1] = el + }} + shadow-bias={-0.0005} + shadow-camera-far={20} + shadow-camera-near={0.05} + shadow-mapSize={[1024, 1024]} + shadow-normalBias={0.02} + /> + + ))} + {needs.glows && } + + ) +} diff --git a/wiki/architecture/authored-objects.md b/wiki/architecture/authored-objects.md index 201127d327..19f987054d 100644 --- a/wiki/architecture/authored-objects.md +++ b/wiki/architecture/authored-objects.md @@ -52,7 +52,7 @@ MOUNT = "floor" def build(params): ... # adds objects to the empty scene it is given ``` -`bpy/runner.py` runs the module in a fresh empty scene, resolves `PARAMS` the way `resolveParams` does, and exports everything in the scene as a GLB (modifiers applied, Y up, no images). `compileBlenderBuild` (`@pascal-app/geometry-script/blender`) loads that GLB and hands it to the same `finishBuild` a three.js build goes through, so the manifest, limits and conventions are one implementation; it re-resolves the params and refuses a run that used other values. It runs no model code, so any host may call it. The conventions in Blender terms: Z up and the front faces −Y (Blender's Front view, which the glTF export turns into +Z); convention names are object names, `userData` is custom properties, `slot_` is a material name; a clip is one action, assigned to every object it moves; a light's brightness is its `intensity` custom property (default 2), since Blender's watts do not map onto Pascal's lights. A material whose shader still has the default base colour takes its viewport `diffuse_color`. +`bpy/runner.py` runs the module in a fresh empty scene, resolves `PARAMS` the way `resolveParams` does, and exports everything in the scene as a GLB (modifiers applied, Y up, no images). `compileBlenderBuild` (`@pascal-app/geometry-script/blender`) loads that GLB and hands it to the same `finishBuild` a three.js build goes through, so the manifest, limits and conventions are one implementation; it re-resolves the params and refuses a run that used other values. It runs no model code, so any host may call it. The conventions in Blender terms: Z up and the front faces −Y (Blender's Front view, which the glTF export turns into +Z); convention names are object names, `userData` is custom properties, `slot_` is a material name; a clip is one action, assigned to every object it moves; a light's brightness is its `intensity` custom property (default 2), since Blender's watts do not map onto Pascal's lights; a Spot light keeps its cone and blend, and the `shadows` and `glow` custom properties carry the rest. A material whose shader still has the default base colour takes its viewport `diffuse_color`. Blender cannot run in a browser, so a bpy module is built by its host: the editor calls the `BlenderScriptBuilder` the host configured (`configureBlenderScriptBuilder`) and refuses bpy without one; a params-only rebuild goes the same way. `runBlenderLocally` (`@pascal-app/geometry-script/blender-local`) runs a local Blender binary with no sandbox, for a developer's own machine. @@ -62,7 +62,7 @@ Blender cannot run in a browser, so a bpy module is built by its host: the edito | --- | --- | | material `slot_` | paint slot (other materials become slots by name) | | object `part:`, `userData.type` | addressable, typed part with bounds (`find_by_type`) | -| a three.js light, or `light:` | switchable light effect | +| a three.js light, or `light:` | switchable light effect; a `SpotLight` keeps its aim, cone and penumbra, `castShadow` (or `userData.shadows`) casts shadows, `userData.glow = { size, strength }` draws a halo at the lamp | | mesh `cutout` / `cut:wall` / `cut:ceiling` / `cut:slab` | a cutter applied only to the object’s bound host | | empty `anchor:` | socket | | mesh `collider` | walkthrough proxy (hidden) | diff --git a/wiki/blender-geometry-scripts.md b/wiki/blender-geometry-scripts.md index 3168d9552f..8ef091c9d7 100644 --- a/wiki/blender-geometry-scripts.md +++ b/wiki/blender-geometry-scripts.md @@ -92,7 +92,7 @@ The result lists its parts (`body`, `door_left`, `door_right`), paint slots (`wo | Metres, Z up, the front facing −Y (Blender's Front view) | Pascal's metres, Y up, front +Z | | object `part:`, custom property `type` | an addressable, typed part (`find_by_type`) | | material `slot_` | a paint slot; while its shader keeps the default base colour, its viewport `diffuse_color` colours it | -| object `light:` holding a light, custom property `intensity` (default 2) | a switchable light; Blender's watts are not read | +| object `light:` holding a point or spot light, custom properties `intensity` (default 2), `shadows`, `glow` (`{"size": 0.2, "strength": 1}`) | a switchable light, a spot keeping its cone and blend; Blender's watts are not read | | empty `anchor:` | a socket | | object `cutout`, `cut:wall`, `cut:ceiling`, `cut:slab` | a cutter for the mounted host | | object `collider` | the walkthrough proxy | From f6fd1c5e49e0d833f46757a015855710edacaa53 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Thu, 29 Oct 2026 13:00:30 -0400 Subject: [PATCH 2/3] fix(lights): baked viewer and rebuilds keep lights hung on authored objects An authored item's controls and effects are its asset.interactive: its module's own, then any light author_scene hung on it. The editor read that, but the baked viewer and the in-scene controls rebuilt them from the module, so hung lights (now spots, shadows and glows too) went dark once published; and rebuilding the object replaced them with the new module's. Both viewers now read the item's own (scriptedInteractive), and a rebuild keeps the hung lights after the new module's (rebuiltInteractive). Co-Authored-By: Claude Opus 5.5 --- .../src/agent-operations/add-object.test.ts | 39 +++++++++++++++++++ .../core/src/agent-operations/add-object.ts | 10 +++-- packages/core/src/index.ts | 2 + packages/core/src/lib/geometry-script-node.ts | 35 +++++++++++++++++ .../components/viewer/glb-interactive.test.ts | 38 ++++++++++++++++++ .../src/components/viewer/glb-interactive.tsx | 5 +-- .../interactive/interactive-system.tsx | 6 +-- 7 files changed, 126 insertions(+), 9 deletions(-) diff --git a/packages/core/src/agent-operations/add-object.test.ts b/packages/core/src/agent-operations/add-object.test.ts index 763b74c6b4..d613e2ea9d 100644 --- a/packages/core/src/agent-operations/add-object.test.ts +++ b/packages/core/src/agent-operations/add-object.test.ts @@ -47,6 +47,45 @@ describe('add_object', () => { } }) +// author_scene hangs lights on an authored fixture after its module's own; a rebuild keeps them. +test("a rebuilt object keeps the lights hung on it after its new module's own", () => { + const level = LevelNode.parse({ id: 'level_0', parentId: 'building_main', level: 0 }) + const solid = compiledSolid([0.3, 0.6, 0.3]) + const bulb = (intensity: number) => ({ + id: 'bulb', + position: [0, 0.5, 0], + color: '#ffcf95', + intensity, + }) + const compiled = (intensity: number) => + ({ ...solid, manifest: { ...solid.manifest, lights: [bulb(intensity)] } }) as typeof solid + const input = (extra: Record, intensity: number) => ({ + ...z.object(addObjectTool.input).parse({ code: 'x', name: 'Lamp', reason: 'r', ...extra }), + compiled: compiled(intensity), + }) + let nodes = { [level.id]: level } as Record + const created = addObject(nodes, input({}, 2), { activeLevelId: level.id }) + nodes = applySceneChanges(nodes, created.changes) + const lamp = nodes[created.result.nodeId] as AnyNode & { asset: any } + const hung = { + kind: 'light', + color: '#ffffff', + intensityRange: [0, 4], + offset: [0, 0.6, 0.1], + spot: { direction: [0, 1, 0], angle: 60, softness: 0.4 }, + } + lamp.asset.interactive.effects.push(hung) + const rebuilt = addObject(nodes, input({ nodeId: lamp.id }, 3), { activeLevelId: level.id }) + const after = applySceneChanges(nodes, rebuilt.changes)[lamp.id] as AnyNode & { asset: any } + expect(after.asset.interactive.effects).toMatchObject([ + { kind: 'light', intensityRange: [0, 3] }, + hung, + ]) + expect(after.asset.interactive.controls).toEqual([ + { kind: 'toggle', label: 'Lights', default: true }, + ]) +}) + // A module's colours are shown as the library finishes they match unless exact keeps them as written. describe('add_object exact', () => { const level = LevelNode.parse({ id: 'level_0', parentId: 'building_main', level: 0 }) diff --git a/packages/core/src/agent-operations/add-object.ts b/packages/core/src/agent-operations/add-object.ts index aa2f53cd68..ece03f470e 100644 --- a/packages/core/src/agent-operations/add-object.ts +++ b/packages/core/src/agent-operations/add-object.ts @@ -6,6 +6,7 @@ import { artifactUrl } from '../lib/artifact-store' import { isScriptedNode, matchScriptSlotsToLibrary, + rebuiltInteractive, requireMeasured, type ScriptedNode, scriptedSize, @@ -80,12 +81,15 @@ const HOSTS: Record = { function scriptAsset( compiled: CompiledGeometryScript, input: AddObjectInput, - previous: ItemNode['asset'] | undefined, + previousNode: ItemNode | undefined, ): ItemNode['asset'] { + const previous = previousNode?.asset const { min, max } = compiled.manifest.bounds const restingHeight = geometryRestingHeight(compiled.manifest) const attachTo = ATTACH[compiled.mount] - const interactive = scriptInteractive(compiled.manifest) + const interactive = previousNode + ? rebuiltInteractive(compiled.manifest, previousNode) + : scriptInteractive(compiled.manifest) return { id: `script_${compiled.sha256.slice(0, 16)}`, category: input.category ?? previous?.category ?? 'object', @@ -352,7 +356,7 @@ export const addObject: AgentOperation = (nodes, input, context) slots: slotsOf( matchScriptSlotsToLibrary(compiled.manifest, previous.slots, previous.source?.manifest), ), - asset: scriptAsset(compiled, input, previous.asset), + asset: scriptAsset(compiled, input, previous), ...(input.reason ? { metadata: { ...previous.metadata, reason: input.reason } } : {}), }) // Children resting on or hanging from the object follow its new geometry. diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 678a19007a..a210c33972 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -255,7 +255,9 @@ export { ceilingSeatY, isScriptedNode, matchScriptSlotsToLibrary, + rebuiltInteractive, type ScriptedNode, + scriptedInteractive, scriptedObjectMeta, scriptedOrigin, scriptedSize, diff --git a/packages/core/src/lib/geometry-script-node.ts b/packages/core/src/lib/geometry-script-node.ts index 37a187e086..8fd97719e9 100644 --- a/packages/core/src/lib/geometry-script-node.ts +++ b/packages/core/src/lib/geometry-script-node.ts @@ -148,6 +148,41 @@ export function requireMeasured( ) } +/** + * A scripted node's controls and effects. An item carries its own: its module's, then any lights + * author_scene hung on it. A window, door or column has only its module's. + */ +export function scriptedInteractive(node: ScriptedNode): ItemNode['asset']['interactive'] { + if (node.type === 'item' && node.asset.interactive) return node.asset.interactive + return scriptInteractive(node.source.manifest, scriptedOrigin(node)) +} + +/** + * The interactive of an item rebuilt from `manifest`: the new module's controls and effects, and + * the lights author_scene hung on the previous build (the effects after its module's own), so a + * rebuild keeps them. + */ +export function rebuiltInteractive( + manifest: CompiledGeometryScript['manifest'], + previous: Pick, +): ItemNode['asset']['interactive'] { + const next = scriptInteractive(manifest) + const own = previous.source + ? (scriptInteractive(previous.source.manifest)?.effects.length ?? 0) + : 0 + const hung = (previous.asset.interactive?.effects ?? []) + .slice(own) + .filter((effect) => effect.kind === 'light') + if (hung.length === 0) return next + const controls = next?.controls ?? [] + return { + controls: controls.some((control) => control.kind === 'toggle') + ? controls + : [...controls, { kind: 'toggle', label: 'Lights', default: true }], + effects: [...(next?.effects ?? []), ...hung], + } +} + /** * Where the script's origin (the bottom centre of what it built) sits in the * node's own frame: a window or door is placed by its centre. diff --git a/packages/viewer/src/components/viewer/glb-interactive.test.ts b/packages/viewer/src/components/viewer/glb-interactive.test.ts index 25560f446e..1aace31f7e 100644 --- a/packages/viewer/src/components/viewer/glb-interactive.test.ts +++ b/packages/viewer/src/components/viewer/glb-interactive.test.ts @@ -161,3 +161,41 @@ test('a scripted window opens and lights from its base, and a music box plays it expect(out.x).toBeCloseTo(0.3) expect(out.z).toBeCloseTo(0.2) }) + +test('an authored object lights what author_scene hung on it, as in the editor', () => { + const lamp = { position: [0, 0.3, 0], color: '#ffcf95', intensity: 2 } + const items = buildGlbInteractiveItems({ + nodes: { + item_sconce: { + type: 'item', + asset: { + name: 'Sconce', + interactive: { + controls: [{ kind: 'toggle', label: 'Lights', default: true }], + effects: [ + { kind: 'light', color: '#ffcf95', intensityRange: [0, 2], offset: [0, 0.3, 0] }, + { + kind: 'light', + color: '#ffffff', + intensityRange: [0, 4], + offset: [0, 0.5, 0.1], + spot: { direction: [0, 1, 0], angle: 60, softness: 0.4 }, + glow: { size: 0.2, strength: 1 }, + }, + ], + }, + }, + source: { + manifest: { + bounds: { min: [-0.1, 0, -0.1], max: [0.1, 0.6, 0.1] }, + lights: [lamp], + animations: [], + }, + }, + }, + }, + } as unknown as SceneGraph) + const regs = buildGlbLightRegs(items, new Map([['item_sconce', new Group()]])) + expect(regs).toHaveLength(2) + expect(regs[1]).toMatchObject({ spot: { angle: 60 }, glow: { size: 0.2 } }) +}) diff --git a/packages/viewer/src/components/viewer/glb-interactive.tsx b/packages/viewer/src/components/viewer/glb-interactive.tsx index 5379ca3fc3..05af985a2c 100644 --- a/packages/viewer/src/components/viewer/glb-interactive.tsx +++ b/packages/viewer/src/components/viewer/glb-interactive.tsx @@ -10,9 +10,8 @@ import { type SceneGraph, type ScriptedNode, type SliderControl, - scriptedOrigin, + scriptedInteractive, scriptedSize, - scriptInteractive, useInteractive, } from '@pascal-app/core' import { @@ -110,7 +109,7 @@ export function buildGlbInteractiveItems( // emitted, as in the editor. if (isScriptedNode(raw as AnyNode)) { const scripted = raw as ScriptedNode - const interactive = scriptInteractive(scripted.source.manifest, scriptedOrigin(scripted)) + const interactive = scriptedInteractive(scripted) if (!interactive) continue const [, height] = scriptedSize(scripted.source.manifest) items.push({ diff --git a/packages/viewer/src/systems/interactive/interactive-system.tsx b/packages/viewer/src/systems/interactive/interactive-system.tsx index 613a71ba67..b17ecda73c 100644 --- a/packages/viewer/src/systems/interactive/interactive-system.tsx +++ b/packages/viewer/src/systems/interactive/interactive-system.tsx @@ -7,9 +7,9 @@ import { pointInPolygon, type ScriptedNode, sceneRegistry, + scriptedInteractive, scriptedOrigin, scriptedSize, - scriptInteractive, useInteractive, useScene, type ZoneNode, @@ -51,7 +51,7 @@ export const InteractiveSystem = () => { Object.values(state.nodes) .filter( (n): n is ItemNode | ProceduralItemNode | ScriptedNode => - (isScriptedNode(n) && scriptInteractive(n.source.manifest) != null) || + (isScriptedNode(n) && scriptedInteractive(n) != null) || (n.type === 'item' && n.asset.interactive != null) || (n.type === 'procedural-item' && (operableParts(n.recipe).length > 0 || n.recipe.parts.some((part) => part.light))), @@ -118,7 +118,7 @@ const ItemControlsOverlay = ({ let descriptors: ControlDescriptor[] = [] let height = 0 const interactive = isScriptedNode(node) - ? scriptInteractive(node.source.manifest, scriptedOrigin(node)) + ? scriptedInteractive(node) : node?.type === 'item' ? node.asset.interactive : undefined From 9c93b9773c2b195930b7d0087a1e182fd36731cd Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Thu, 29 Oct 2026 13:18:21 -0400 Subject: [PATCH 3/3] fix(lights): a saved glow without a strength glows at 1, not black Saved scenes are not parsed on load, so a glow stored as { size } reached the viewer without its schema default and turned its colour NaN: a black square. Co-Authored-By: Claude Opus 5.5 --- .../src/systems/item-light/item-light-system.test.ts | 9 ++++++++- packages/viewer/src/systems/item-light/pooled-lights.tsx | 9 ++++++--- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/packages/viewer/src/systems/item-light/item-light-system.test.ts b/packages/viewer/src/systems/item-light/item-light-system.test.ts index fe12fae647..c7bf0f1fa9 100644 --- a/packages/viewer/src/systems/item-light/item-light-system.test.ts +++ b/packages/viewer/src/systems/item-light/item-light-system.test.ts @@ -184,6 +184,8 @@ test('a spot with shadows takes a shadow slot, aimed; one without shines from th }) spot('wash', true) spot('accent', false) + // As a saved scene stores it: no strength, which is 1. + useItemLightPool.getState().registrations.get('accent')!.glow = { size: 0.2 } const renderer = await create(createElement(ItemLightSystem)) try { // The first tick asks for the spot, shadow and glow pools; they mount on the next render. @@ -200,7 +202,12 @@ test('a spot with shadows takes a shadow slot, aimed; one without shines from th expect(light.target.position.toArray()).toEqual([1, 3, 3]) } const glows = renderer.scene.findAll((entry) => entry.instance.name === 'light-glows') - expect((glows[0]!.instance as unknown as { count: number }).count).toBe(2) + const sprite = glows[0]!.instance as unknown as { + count: number + material: { colorNode: { value: { array: Float32Array } } } + } + expect(sprite.count).toBe(2) + expect([...sprite.material.colorNode.value.array.slice(0, 6)].every(Number.isFinite)).toBe(true) } finally { await renderer.unmount() } diff --git a/packages/viewer/src/systems/item-light/pooled-lights.tsx b/packages/viewer/src/systems/item-light/pooled-lights.tsx index 1bbe349364..cd3a9f10df 100644 --- a/packages/viewer/src/systems/item-light/pooled-lights.tsx +++ b/packages/viewer/src/systems/item-light/pooled-lights.tsx @@ -1,6 +1,6 @@ 'use client' -import type { LightGlow, LightSpot } from '@pascal-app/core' +import type { LightSpot } from '@pascal-app/core' import { useFrame } from '@react-three/fiber' import { useMemo, useRef, useState } from 'react' import { float, instancedDynamicBufferAttribute, smoothstep, uv } from 'three/tsl' @@ -26,7 +26,8 @@ export type PooledLight = { distance: number spot?: LightSpot shadows?: boolean - glow?: LightGlow + // A saved scene is not parsed on load, so a glow may come without its default strength. + glow?: { size: number; strength?: number } /** Its fully-on intensity, so its glow dims with it. */ peak?: number /** Where it shines from; false while its object is not mounted. */ @@ -338,7 +339,9 @@ export function PooledLights({ getLights, score, intensit Math.min(light.glow.size / 2, _dir.length() / 2) / (_dir.length() || 1), ) const level = light.peak ? Math.min(1, now / light.peak) : 1 - _color.set(light.color).multiplyScalar(GLOW_GAIN * light.glow.strength * level * darkness) + _color + .set(light.color) + .multiplyScalar(GLOW_GAIN * (light.glow.strength ?? 1) * level * darkness) glows.positions.setXYZ(count, _pos.x, _pos.y, _pos.z) glows.colors.setXYZ(count, _color.r, _color.g, _color.b) glows.sizes.setX(count, light.glow.size)