From afd0a4fa1687889e2350b349ffe2d4cc25d10983 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sun, 24 May 2026 18:50:01 +0200 Subject: [PATCH 1/3] feat(room): RoomMessageHandler.applyFloorModelLocally for live floor preview Adds a public method that rebuilds the active room's floor geometry from an in-memory model string + wallHeight without touching the server. Same pipeline as the wire-driven onRoomModelEvent (FloorHeightMapMessageParser -> _planeParser -> wallGeometry), but instead of going through RoomEngine.createRoomInstance (which is a no-op on a room that already exists) it routes the resulting RoomMapData through the room object's ObjectRoomMapUpdateMessage channel - the same mechanism RoomPreviewer.updateRoomPlanes uses for its iso preview. Result: the visualization rebuilds in place and existing furniture/avatars are preserved. Refactor: extracted the parser->planeParser->wallGeometry- >RoomMapData work from onRoomModelEvent into a private _rebuildFloorGeometry(parser) helper so the wire handler and the new public method share an implementation. Intended use: tools that need a live in-room preview of a floor edit before committing it server-side (e.g. the React floor-plan editor's live-preview mode). The wire UpdateFloorPropertiesMessageComposer remains the source of truth - call applyFloorModelLocally only for transient client-side preview. --- packages/room/src/RoomMessageHandler.ts | 76 ++++++++++++++++++++++++- 1 file changed, 74 insertions(+), 2 deletions(-) diff --git a/packages/room/src/RoomMessageHandler.ts b/packages/room/src/RoomMessageHandler.ts index b97c6b6..634d6d6 100644 --- a/packages/room/src/RoomMessageHandler.ts +++ b/packages/room/src/RoomMessageHandler.ts @@ -2,11 +2,17 @@ import { AvatarGuideStatus, IConnection, IMessageEvent, IRoomCreator, IRoomObjec import { AreaHideMessageEvent, ConfInvisStateMessageEvent, DiceValueMessageEvent, FloorHeightMapEvent, FurnitureAliasesComposer, FurnitureAliasesEvent, FurnitureDataEvent, FurnitureFloorAddEvent, FurnitureFloorDataParser, FurnitureFloorEvent, FurnitureFloorRemoveEvent, FurnitureFloorUpdateEvent, FurnitureWallAddEvent, FurnitureWallDataParser, FurnitureWallEvent, FurnitureWallRemoveEvent, FurnitureWallUpdateEvent, GetCommunication, GetRoomEntryDataMessageComposer, GuideSessionEndedMessageEvent, GuideSessionErrorMessageEvent, GuideSessionStartedMessageEvent, IgnoreResultEvent, ItemDataUpdateMessageEvent, ObjectsDataUpdateEvent, ObjectsRollingEvent, OneWayDoorStatusMessageEvent, PetExperienceEvent, PetFigureUpdateEvent, RoomEntryTileMessageEvent, RoomEntryTileMessageParser, RoomHeightMapEvent, RoomHeightMapUpdateEvent, RoomPaintEvent, RoomReadyMessageEvent, RoomUnitChatEvent, RoomUnitChatShoutEvent, RoomUnitChatWhisperEvent, RoomUnitDanceEvent, RoomUnitEffectEvent, RoomUnitEvent, RoomUnitExpressionEvent, RoomUnitHandItemEvent, RoomUnitIdleEvent, RoomUnitInfoEvent, RoomUnitNumberEvent, RoomUnitRemoveEvent, RoomUnitStatusEvent, RoomUnitStatusMessage, RoomUnitTypingEvent, RoomVisualizationSettingsEvent, UserInfoEvent, WiredFurniMovementData, WiredMovementsEvent, WiredUserDirectionUpdateData, WiredUserMovementData, YouArePlayingGameEvent } from '@nitrots/communication'; import { GetRoomSessionManager, GetSessionDataManager } from '@nitrots/session'; import { Vector3d } from '@nitrots/utils'; +import { FloorHeightMapMessageParser } from '@nitrots/communication'; import { GetRoomEngine } from './GetRoomEngine'; import { RoomVariableEnum } from './RoomVariableEnum'; +import { ObjectRoomMapUpdateMessage } from './messages'; import { RoomPlaneParser } from './object/RoomPlaneParser'; import { FurnitureStackingHeightMap, LegacyWallGeometry } from './utils'; +// Local mirror of `RoomEngine.ROOM_OBJECT_ID` to avoid a circular +// import between this handler and the engine that owns it. +const ROOM_OWN_OBJECT_ID = -1; + type AreaHideControllerState = { rootX: number; rootY: number; @@ -232,9 +238,75 @@ export class RoomMessageHandler if(!parser) return; + const roomMap = this._rebuildFloorGeometry(parser); + + if(!roomMap) return; + + // Initial server-driven load: create the instance from + // scratch. RoomEngine.createRoomInstance is a no-op when + // the room already exists, so we never accidentally wipe + // furniture/avatars on a re-enter. + this._roomEngine.createRoomInstance(this._currentRoomId, roomMap); + } + + /** + * Apply a floor model to the ACTIVE room locally, without + * touching the server. Drives the same `wallGeometry` + + * `RoomPlaneParser` pipeline as the wire-driven path, then + * routes the resulting `RoomMapData` through the room object's + * `ObjectRoomMapUpdateMessage` channel — the same mechanism + * `RoomPreviewer.updateRoomPlanes` uses. The visualization + * rebuilds in place, so existing furniture and avatars are + * preserved. + * + * Intended for tools that need a live in-room preview of a + * floor edit before the user commits to a server save (e.g. + * the React floor-plan editor's live-preview mode). The wire + * `UpdateFloorPropertiesMessageComposer` is still the source + * of truth — call this purely for transient client-side + * preview, then send the composer separately when the user + * confirms. + * + * @returns `true` if the floor was rebuilt; `false` if no + * active room is bound, the engine isn't ready, or the + * model string failed to parse. + */ + public applyFloorModelLocally(modelString: string, wallHeight: number, scale: boolean = true): boolean + { + if(!this._roomEngine || this._currentRoomId <= 0 || !modelString) return false; + + const parser = new FloorHeightMapMessageParser(); + + parser.flush(); + + if(!parser.parseModel(modelString, wallHeight, scale)) return false; + + const roomMap = this._rebuildFloorGeometry(parser); + + if(!roomMap) return false; + + const roomObject = this._roomEngine.getRoomObject(this._currentRoomId, ROOM_OWN_OBJECT_ID, RoomObjectCategory.ROOM); + + if(!roomObject) return false; + + roomObject.processUpdateMessage(new ObjectRoomMapUpdateMessage(roomMap)); + + return true; + } + + /** + * Shared body of `onRoomModelEvent` and + * `applyFloorModelLocally`. Feeds the floor heightmap into + * `_planeParser`, refreshes `wallGeometry`, and returns the + * resulting `RoomMapData` (doors included). The caller decides + * whether to seed a fresh room (initial enter) or update an + * existing one (live preview). + */ + private _rebuildFloorGeometry(parser: FloorHeightMapMessageParser) + { const wallGeometry = this._roomEngine.getLegacyWallGeometry(this._currentRoomId); - if(!wallGeometry) return; + if(!wallGeometry) return null; this._planeParser.reset(); @@ -320,7 +392,7 @@ export class RoomMessageHandler dir: doorDirection }); - this._roomEngine.createRoomInstance(this._currentRoomId, roomMap); + return roomMap; } private onRoomHeightMapEvent(event: RoomHeightMapEvent): void From 28a41ba54342305057ca13daeccf9fd4eb2593b0 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sun, 24 May 2026 19:16:40 +0200 Subject: [PATCH 2/3] fix(room): applyFloorModelLocally also rebuilds the furniture stacking map The first cut updated wallGeometry + RoomMapData (so the visualization rebuilt) but NOT the FurnitureStackingHeightMap. The stacking map is what governs whether the room treats a tile as 'a room tile you can stack furni on' vs. 'blocked'. Without rebuilding it, every newly-painted tile in the live preview looks walkable but rejects furniture placement - user reported exactly that. Mirror the structure of onRoomHeightMapEvent: build a fresh FurnitureStackingHeightMap from the parsed floor (height + isRoomTile from FloorHeightMapMessageParser.TILE_BLOCKED), default stackingBlocked=false, then setFurnitureStackingHeightMap + refreshTileObjectMap so the room object map picks up the new tile set. --- packages/room/src/RoomMessageHandler.ts | 42 +++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/packages/room/src/RoomMessageHandler.ts b/packages/room/src/RoomMessageHandler.ts index 634d6d6..9354d0c 100644 --- a/packages/room/src/RoomMessageHandler.ts +++ b/packages/room/src/RoomMessageHandler.ts @@ -291,9 +291,51 @@ export class RoomMessageHandler roomObject.processUpdateMessage(new ObjectRoomMapUpdateMessage(roomMap)); + // Floor visualization is updated above. Without this second + // step the FurnitureStackingHeightMap still reflects the + // pre-edit floor, so the room thinks every newly-painted + // tile is "blocked" and rejects furni placement on it. + // Rebuild it from the same parser so the stacking-map and + // the visual floor agree. + this._rebuildFurnitureStackingMap(parser); + return true; } + private _rebuildFurnitureStackingMap(parser: FloorHeightMapMessageParser): void + { + if(!this._roomEngine) return; + + const width = parser.width; + const height = parser.height; + const heightMap = new FurnitureStackingHeightMap(width, height); + const BLOCKED = FloorHeightMapMessageParser.TILE_BLOCKED; + + let y = 0; + + while(y < height) + { + let x = 0; + + while(x < width) + { + const tileHeight = parser.getHeight(x, y); + const isRoomTile = (tileHeight !== BLOCKED); + + heightMap.setTileHeight(x, y, isRoomTile ? tileHeight : 0); + heightMap.setStackingBlocked(x, y, false); + heightMap.setIsRoomTile(x, y, isRoomTile); + + x++; + } + + y++; + } + + this._roomEngine.setFurnitureStackingHeightMap(this._currentRoomId, heightMap); + this._roomEngine.refreshTileObjectMap(this._currentRoomId, 'RoomMessageHandler.applyFloorModelLocally'); + } + /** * Shared body of `onRoomModelEvent` and * `applyFloorModelLocally`. Feeds the floor heightmap into From 2504aea85f07403708cf443fef2f9a3b909b4133 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sun, 24 May 2026 20:43:50 +0200 Subject: [PATCH 3/3] fix(room): guard RoomPreviewer.updatePreviewModel against null _planeParser After dispose() nulls out the internal _planeParser / _backgroundSprite refs, any further updatePreviewModel call crashed with 'this._planeParser is null'. React 19 StrictMode in dev double-mounts effects (setup, cleanup, setup again), which can briefly leave a consumer holding a stale reference to a disposed previewer between the two setup runs. Bail silently in that window instead of crashing the editor. --- packages/room/src/RoomPreviewer.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/room/src/RoomPreviewer.ts b/packages/room/src/RoomPreviewer.ts index 53b7537..67e9677 100644 --- a/packages/room/src/RoomPreviewer.ts +++ b/packages/room/src/RoomPreviewer.ts @@ -136,6 +136,12 @@ export class RoomPreviewer public updatePreviewModel(model: string, wallHeight: number, scale: boolean = true): void { + // Defensive: dispose() nulls _planeParser, and React 19 + // StrictMode dev double-mount can leave a stale reference + // briefly pointing at a disposed instance. Bail rather + // than crashing with "cannot read property reset of null". + if(!this._planeParser) return; + const parser = new FloorHeightMapMessageParser(); parser.flush();